toolroll 0.9.5 → 0.9.7

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 (92) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/actor.d.ts +21 -0
  3. package/dist/actor.js +48 -0
  4. package/dist/assignment-adapters.d.ts +1 -0
  5. package/dist/assignment-presentation.d.ts +13 -2
  6. package/dist/assignment-presentation.js +22 -9
  7. package/dist/assignment-ui.d.ts +10 -15
  8. package/dist/assignment-ui.js +19 -18
  9. package/dist/assignment.d.ts +9 -0
  10. package/dist/assignment.js +62 -13
  11. package/dist/browser/workspace.css +2 -2
  12. package/dist/browser/workspace.js +29 -24
  13. package/dist/browser-workspace.d.ts +120 -12
  14. package/dist/builder.d.ts +1 -0
  15. package/dist/builder.js +33 -13
  16. package/dist/chat-acceptance.js +2 -2
  17. package/dist/chat-actions.js +2 -1
  18. package/dist/chat-delivery.js +2 -0
  19. package/dist/chat-quiet.js +30 -19
  20. package/dist/chat-task-actions.d.ts +13 -0
  21. package/dist/chat-task-actions.js +19 -1
  22. package/dist/check-levels-ui.d.ts +21 -0
  23. package/dist/check-levels-ui.js +70 -0
  24. package/dist/check-levels.d.ts +67 -0
  25. package/dist/check-levels.js +171 -0
  26. package/dist/claim.d.ts +22 -0
  27. package/dist/claim.js +43 -0
  28. package/dist/cli.d.ts +1 -1
  29. package/dist/cli.js +4 -0
  30. package/dist/demo-chat.d.ts +0 -5
  31. package/dist/demo-chat.js +14 -3
  32. package/dist/dispatch.js +7 -2
  33. package/dist/dispose.d.ts +8 -0
  34. package/dist/dispose.js +7 -1
  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/lead-context.js +6 -2
  54. package/dist/lead-status.d.ts +7 -0
  55. package/dist/lead-status.js +11 -2
  56. package/dist/mate-doors.d.ts +0 -15
  57. package/dist/mate-doors.js +5 -1
  58. package/dist/mate-tools.js +9 -4
  59. package/dist/models-ui.js +1 -1
  60. package/dist/operate.d.ts +4 -1
  61. package/dist/operate.js +601 -85
  62. package/dist/project-concurrency.d.ts +62 -0
  63. package/dist/project-concurrency.js +116 -0
  64. package/dist/project-delete-ui.d.ts +7 -0
  65. package/dist/project-delete-ui.js +19 -2
  66. package/dist/project-delete.js +4 -0
  67. package/dist/pull-request-flow.d.ts +9 -0
  68. package/dist/pull-request-flow.js +21 -3
  69. package/dist/result-follow-ups.d.ts +101 -0
  70. package/dist/result-follow-ups.js +333 -0
  71. package/dist/scope.js +11 -0
  72. package/dist/serve.d.ts +211 -2
  73. package/dist/serve.js +1070 -204
  74. package/dist/store.d.ts +76 -3
  75. package/dist/store.js +311 -22
  76. package/dist/surface.js +31 -8
  77. package/dist/tailnet.d.ts +11 -0
  78. package/dist/tailnet.js +47 -0
  79. package/dist/task-outcome-cli.js +2 -2
  80. package/dist/task-status.d.ts +186 -0
  81. package/dist/task-status.js +335 -0
  82. package/dist/team-cli.js +3 -0
  83. package/dist/telegram-progress.js +62 -100
  84. package/dist/telegram-status.js +21 -8
  85. package/dist/verification-evidence.js +21 -1
  86. package/dist/webhooks.js +5 -0
  87. package/dist/work-index.js +60 -11
  88. package/dist/work-summary.d.ts +0 -4
  89. package/dist/work-summary.js +2 -0
  90. package/dist/workspace-ui.d.ts +10 -0
  91. package/dist/workspace-ui.js +33 -2
  92. 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
+ }
package/dist/scope.js CHANGED
@@ -2,6 +2,7 @@ import { passwordGuardOf } from "./sign-in-guard.js";
2
2
  import { validateScopeText } from "./task-text.js";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
4
  import { projectAuthority } from "./project-access.js";
5
+ import { claimActor, currentActor, parseLeadToken } from "./actor.js";
5
6
  /**
6
7
  * What a task is allowed to become, agreed before anything builds it.
7
8
  *
@@ -791,6 +792,14 @@ password) {
791
792
  export function authenticateAccount(store, by, secret) {
792
793
  if (store.listApprovers().length === 0)
793
794
  return { ok: false, reason: "no-approvers" };
795
+ // A lead token signs in as its owner, only inside a command the lead runs with it (never a console sign-in).
796
+ const acting = currentActor();
797
+ if (acting?.lead === true && parseLeadToken(secret) !== null) {
798
+ const lead = store.leadFor(secret), account = store.accountOf(by);
799
+ if (lead === null || lead.owner !== by || acting.account !== by || account === null)
800
+ return { ok: false, reason: "unknown" };
801
+ return { ok: true, role: account.role, generation: account.generation };
802
+ }
794
803
  // v99: wrong passwords in a row lock the name for a while, whatever road they came by.
795
804
  const guard = passwordGuardOf(store), now = Date.now();
796
805
  if (guard.lockedFor(by, now) > 0)
@@ -803,6 +812,8 @@ export function authenticateAccount(store, by, secret) {
803
812
  guard.succeeded(by);
804
813
  if (account.revokedAt !== null)
805
814
  return { ok: false, reason: "revoked" };
815
+ // Inside a command, the person who signed in is who acted (their own acts never ping them).
816
+ claimActor(by);
806
817
  return { ok: true, role: account.role, generation: account.generation };
807
818
  }
808
819
  /**
package/dist/serve.d.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import { type BrowserWorkspace, type BrowserSignIn, type BrowserUpdateNotice } from './browser-workspace.js';
1
2
  import { CodingWorkspace } from './coding-workspace.js';
3
+ import { type FetchLike } from "./flow-share.js";
2
4
  import { type MailSender } from "./flow-actions.js";
3
5
  import { type TriggerIo } from "./flow-triggers.js";
4
6
  import { type AssignmentSnapshot } from './assignment.js';
@@ -6,14 +8,17 @@ import type { PublishExec } from './publish.js';
6
8
  import { type VersionRunner } from "./model-catalog.js";
7
9
  import { type Server } from "node:http";
8
10
  import { type ProofVerdict, type CriterionMatrixRow } from "./proof.js";
9
- import { type Run, type Store } from "./store.js";
11
+ import { type Decision, type Run, type Store, type TaskState } from "./store.js";
10
12
  import { previewGithubRepo, cloneGithubRepo, listGithubRepos } from "./onboard.js";
11
13
  import { run as execRun } from "./exec.js";
14
+ import { type FirstRunStep } from "./first-run.js";
15
+ import { type Gap } from "./gaps.js";
12
16
  import { type AgentChoice } from "./agentconfig.js";
13
17
  import { type RouteProjection, type RouteOverride, type RouteStamp, type RiskLevel } from "./phase-routing.js";
14
18
  import { type Phase } from "./provider.js";
15
19
  import { type IntegrationIo } from "./integrations.js";
16
20
  import { launchRuntimeUpdate } from "./toolroll-update.js";
21
+ import type { RepairChainRow } from "./store.js";
17
22
  import type { RevisionLineage } from "./store.js";
18
23
  import type { SubscriptionMateRunner } from "./subscription-chat.js";
19
24
  import { type InstallMethod } from "./install-method.js";
@@ -39,8 +44,22 @@ export type ServeOptions = {
39
44
  registryPath?: string;
40
45
  /** This console fronts an `up` process: onboarding copy says how to watch. */
41
46
  upConsole?: boolean;
47
+ /**
48
+ * The lead on by default (onboarding): with no lead set up, the agent CLI
49
+ * signed in on this computer runs it, once. `up` and `serve` turn this on;
50
+ * tests opt in.
51
+ */
52
+ leadByDefault?: boolean;
53
+ /** Test seam: the operating system the sign-in command is written for. */
54
+ platform?: NodeJS.Platform;
42
55
  /** Extra Host values this server answers as (a Tailscale name, a LAN ip:port). */
43
56
  allowedHosts?: readonly string[];
57
+ /**
58
+ * This computer's names on its tailnet (onboarding): read at start and every
59
+ * few minutes; the console answers to each on its own port without an
60
+ * --allow-host, and the phone card names the address. Absent = none.
61
+ */
62
+ tailnetNames?: () => Promise<readonly string[]>;
44
63
  /**
45
64
  * The first-account road (setup review): while NO approver exists, the
46
65
  * login page offers "create the first account", gated by this code —
@@ -107,6 +126,8 @@ export type ServeOptions = {
107
126
  toolHome?: string;
108
127
  /** v87: sends Send email steps' mail and the settings test (tests inject one). */
109
128
  mailSender?: MailSender;
129
+ /** Injected by tests: how Flows → Import fetches a flow file's address. */
130
+ flowFetch?: FetchLike;
110
131
  /** v89: Google's token endpoint (tests inject a scripted one). */
111
132
  googleFetch?: typeof fetch;
112
133
  /** Tests: every request a one-click connection makes (discovery, registration, tokens). */
@@ -164,7 +185,14 @@ export type ServeOptions = {
164
185
  /** Injected by tests: the git and gh calls pull-request setup and Merge make. */
165
186
  publishExec?: PublishExec;
166
187
  };
167
- export declare function createDecisionServer(options: ServeOptions): Server;
188
+ /** Where `up`'s one-time sign-in link points, and how long it works. */
189
+ export declare const SIGN_IN_LINK_PATH = "/login/once/";
190
+ export declare const SIGN_IN_LINK_MS: number;
191
+ /** The console's server, and the one-time sign-in link `up` opens: a path on this server, or null for no such approver. */
192
+ export type DecisionServer = Server & {
193
+ mintSignInLink(account: string): string | null;
194
+ };
195
+ export declare function createDecisionServer(options: ServeOptions): DecisionServer;
168
196
  /**
169
197
  * A vscode://file href, or null (arc 6, finding 2): the link exists only
170
198
  * when every part is provably tame — an absolute, control-free worktree;
@@ -211,6 +239,79 @@ export type RouteView = {
211
239
  };
212
240
  /** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
213
241
  export declare const PAGE_CSS: string;
242
+ /** Everything the sidebar needs to draw itself for one request. */
243
+ type Chrome = {
244
+ projectScoped?: boolean;
245
+ active: "code" | "inbox" | "board" | "queue" | "fleet" | "workbench" | "work" | "done" | "activity" | "review" | "system" | "tasks" | "runs" | "caps" | "routines" | "recipes" | "projects" | "flows" | "settings" | "chat" | "people" | "ledger" | "spend" | "mode" | "menu" | "none";
246
+ project: string | null;
247
+ /** The surface's scope for the scope bar — which rows this screen can
248
+ * show. Derived from the ROUTE, not the session: portfolio and fleet are
249
+ * all-project even while a project is open; the board says which of its
250
+ * two modes it is in. Absent = the session default (open project, else
251
+ * all projects). Display only — switching stays POST + CSRF. */
252
+ scope?: "all" | "project" | "board-all";
253
+ /** The saturated inbox count — never a sum of unbounded list reads. */
254
+ inboxCount: number;
255
+ inboxSaturated: boolean;
256
+ settings: boolean;
257
+ /** This database is a demo sandbox: banner every page, spend fenced. */
258
+ demo?: boolean;
259
+ /** The active operating mode's banner (M1): rides every page scoped to
260
+ * a repo with a live mode — a signed posture is never invisible. */
261
+ modeBanner?: {
262
+ words: string;
263
+ name: string;
264
+ };
265
+ /** Providers whose sign-in stopped working: every page says so, once. */
266
+ signIn?: BrowserSignIn[];
267
+ /** A newer Toolroll: a quiet notice for an operator until they dismiss this version. */
268
+ update?: BrowserUpdateNotice;
269
+ /** The chat tab renders only where chat could ever be allowed. */
270
+ chat?: boolean;
271
+ code?: boolean;
272
+ /** A rendered list pane makes the page master-detail. */
273
+ listPane?: string;
274
+ /** A compact pulse for the currently open workspace. Null in the
275
+ * cross-workspace view; absent only when the store could not answer. */
276
+ projectPeek?: ProjectPeek | null;
277
+ /** The switcher (board pass): every enrolled project inside the ceiling,
278
+ * most recently opened first — the one-tap switch on any screen. */
279
+ projects?: {
280
+ path: string;
281
+ name: string;
282
+ }[];
283
+ /** The session's csrf token, for the switcher's forms; "" when the
284
+ * request has no cookie session (bearer), which renders the switcher
285
+ * inert. */
286
+ csrf?: string;
287
+ /** Where a switch made on this screen returns to. */
288
+ returnTo?: string;
289
+ };
290
+ /**
291
+ * A page described, not yet rendered (arc 4): every chromed HTML route
292
+ * returns one of these and ONE helper (sendScreen, inside the server)
293
+ * owns the nonce, the palette index, the shortcuts overlay, script
294
+ * composition, and the CSP. Renderers stopped calling shell() themselves
295
+ * so those five things cannot drift apart per route.
296
+ */
297
+ type Screen = {
298
+ title: string;
299
+ body: string;
300
+ chrome?: Chrome;
301
+ /** The page's own executable behavior (a region poller, the push
302
+ * enrollment script). fetches: true when it calls fetch — connect-src
303
+ * is granted only then. */
304
+ functional?: {
305
+ script: string;
306
+ fetches?: boolean;
307
+ };
308
+ refreshSeconds?: number;
309
+ /** Render sensitive even when no password field is visible — one-time
310
+ * secrets and judgment calls the classifier cannot see. */
311
+ forceSensitive?: boolean;
312
+ /** Structured conversation; complex guarded forms stay native islands. */
313
+ workspace?: Partial<Pick<BrowserWorkspace, 'conversation' | 'team' | 'focus' | 'result' | 'catchUpHtml' | 'controlsHtml' | 'notices' | 'pageHtml' | 'view' | 'firstRun' | 'phone' | 'home'>>;
314
+ };
214
315
  /**
215
316
  * The sensitivity classifier (arc 4, findings 3/17): a body showing a
216
317
  * password input renders WITHOUT the chrome additions (palette, overlay,
@@ -232,6 +333,105 @@ export declare function ssoStepUps(html: string, sso: {
232
333
  label: string;
233
334
  fresh: boolean;
234
335
  }, returnTo: string): string;
336
+ /**
337
+ * The page for an address the console doesn't answer to (onboarding): what was opened, where it answers, and the exact
338
+ * command that admits this address. It stands alone (styles inline, no script, no font), because every other asset
339
+ * would be refused at this address too; and it holds nothing a stranger's page doesn't already know.
340
+ */
341
+ export declare function wrongHostPage(facts: {
342
+ opened: string | null;
343
+ served: string;
344
+ command: string | null;
345
+ }): string;
346
+ /**
347
+ * The inbox (v3): only things stalling progress without a person, one card
348
+ * per underlying stall, each with its verb inline or one step away. No
349
+ * auto-refresh — approval links lead to the step-up screen, and a page
350
+ * that might hold typed input never re-renders itself.
351
+ */
352
+ export type InboxTab = "needs-you" | "ready" | "running" | "all";
353
+ export declare function parseInboxTab(raw: string | null): InboxTab | undefined;
354
+ /** What each tab holds, as a short fingerprint: a tab is unread when its
355
+ * fingerprint differs from the one this browser saw there last. */
356
+ export declare function inboxFingerprints(data: Pick<Parameters<typeof inboxPage>[1], "decisions" | "approvals" | "requeueables" | "cancelledBlockers" | "gaps" | "needsVerification" | "ready" | "running">): Record<InboxTab, string>;
357
+ declare function inboxPage(chrome: Chrome, data: {
358
+ csrf: string;
359
+ revision: number;
360
+ /** No project open in scoped mode: every admitted project at once,
361
+ * chips on every row, links only — acting means opening the project. */
362
+ rollup: boolean;
363
+ /** A project is OPEN: only then do decisions answer on the card. The
364
+ * legacy unscoped projectless inbox stays links-only too (commit-1
365
+ * review, finding 4) — the partial belongs to a chosen project. */
366
+ interactive: boolean;
367
+ decisions: (Decision & {
368
+ taskId: string;
369
+ repo?: string | null;
370
+ })[];
371
+ approvals: {
372
+ taskId: string;
373
+ title: string;
374
+ goal: string;
375
+ proposedAt: string;
376
+ repo?: string | null; /** v48 authority repair: the closed consent door's title, or null when a yes could bind. */
377
+ closed?: string | null;
378
+ }[];
379
+ requeueables: {
380
+ taskId: string;
381
+ title: string;
382
+ state: TaskState;
383
+ strikes: number;
384
+ incidentCount: number;
385
+ repo?: string | null;
386
+ }[];
387
+ cancelledBlockers: {
388
+ blockerId: string;
389
+ dependentCount: number;
390
+ exampleDependent: string;
391
+ repo?: string | null;
392
+ blockerRepo?: string | null;
393
+ }[];
394
+ gaps: Gap[];
395
+ /** A completed task whose proof is short or refuted and not yet accepted
396
+ * (Priority 2) — reads "needs verification" here too, never silently
397
+ * "done" just because the inbox does not otherwise look at finished work. */
398
+ needsVerification: {
399
+ taskId: string;
400
+ title: string;
401
+ verdict: "short" | "refuted";
402
+ repo?: string | null;
403
+ matrix?: CriterionMatrixRow[];
404
+ repairChain?: RepairChainRow | null;
405
+ }[];
406
+ /** The first-run steps; null once the installation has had its first Ready result. */
407
+ wizard: FirstRunStep[] | null;
408
+ /** Whether any worker is answering right now — said at the top when none is. */
409
+ worker: {
410
+ answering: number;
411
+ registered: number;
412
+ lastHeard: string | null;
413
+ };
414
+ now: Date;
415
+ /** Console v2: the tab shown, results ready to review, work running now,
416
+ * and the tabs holding something this browser hasn't seen (dots on a phone). */
417
+ tab?: InboxTab;
418
+ ready?: {
419
+ taskId: string;
420
+ title: string;
421
+ detail: string;
422
+ repo: string | null;
423
+ }[];
424
+ running?: {
425
+ taskId: string;
426
+ title: string;
427
+ detail: string;
428
+ repo: string | null;
429
+ }[];
430
+ unread?: readonly InboxTab[];
431
+ }): Screen;
432
+ /** The task composer's modes (console v2), as the words the lead reads for that one turn. */
433
+ export type TaskComposerMode = "build" | "plan" | "answer";
434
+ export declare const TASK_COMPOSER_MODES: Record<TaskComposerMode, string>;
235
435
  /**
236
436
  * The inline-answer enhancement (portfolio arc §2): submits a reversible
237
437
  * option's form as the urlencoded POST the forms-only gate expects, follows
@@ -242,6 +442,12 @@ export declare function ssoStepUps(html: string, sso: {
242
442
  * Nothing else on the page is touched: typed input elsewhere survives.
243
443
  */
244
444
  export declare function decisionAnswerScript(): string;
445
+ type ProjectPeek = {
446
+ waiting: number;
447
+ queued: number;
448
+ running: number;
449
+ doneRecently: number;
450
+ };
245
451
  /**
246
452
  * Creating work is the product's first verb, so it gets a whole calm page:
247
453
  * a title, the goal that becomes the scope draft, and the project it lands
@@ -257,6 +463,8 @@ export declare function decisionAnswerScript(): string;
257
463
  */
258
464
  /** The person's pinned theme from their own cookie; null follows the device. */
259
465
  export declare function pinnedTheme(cookieHeader: string | undefined): "light" | "dark" | null;
466
+ /** A request path fit for a log: the one-time sign-in link's code is left out. */
467
+ export declare function redactedPath(path: string): string;
260
468
  /** The /queue page's one nonce'd script: delegated pointer-event drag (it
261
469
  * survives every fragment swap — finding 17) plus a poller that re-checks
262
470
  * focus, dirty inputs, and an in-flight drag AT SWAP TIME, never only
@@ -339,3 +547,4 @@ export declare function plainConclusionOf(conclusion: string): string;
339
547
  * stop marker, so an open tab quits refetching a dead build (round-4
340
548
  * finding 15). */
341
549
  export declare function runFactsFragment(run: Run, taskId: string, live: boolean, route?: RouteStamp | null): string;
550
+ export {};