toolroll 0.9.30 → 0.9.32

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 (61) hide show
  1. package/CHANGELOG.md +35 -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/desktop-update.d.ts +1 -1
  16. package/dist/exec.d.ts +2 -0
  17. package/dist/exec.js +11 -0
  18. package/dist/flow-gallery-ui.d.ts +14 -9
  19. package/dist/flow-gallery-ui.js +71 -29
  20. package/dist/flow-gallery.d.ts +26 -2
  21. package/dist/flow-gallery.js +182 -7
  22. package/dist/lead-capabilities.d.ts +0 -7
  23. package/dist/lead-capabilities.js +11 -1
  24. package/dist/mate-doors.js +2 -2
  25. package/dist/mate.d.ts +6 -2
  26. package/dist/mate.js +63 -38
  27. package/dist/mcp-connect.d.ts +3 -1
  28. package/dist/mcp-connect.js +11 -6
  29. package/dist/never-index.d.ts +3 -0
  30. package/dist/never-index.js +21 -0
  31. package/dist/operate.js +86 -2
  32. package/dist/orphan-sweep.d.ts +63 -0
  33. package/dist/orphan-sweep.js +235 -0
  34. package/dist/result-follow-ups.d.ts +22 -2
  35. package/dist/result-follow-ups.js +44 -10
  36. package/dist/serve.js +52 -19
  37. package/dist/shared-deps.d.ts +18 -2
  38. package/dist/shared-deps.js +129 -15
  39. package/dist/staged-runtimes.d.ts +42 -0
  40. package/dist/staged-runtimes.js +191 -0
  41. package/dist/storage-sweep.d.ts +50 -0
  42. package/dist/storage-sweep.js +203 -0
  43. package/dist/storage-ui.d.ts +5 -0
  44. package/dist/storage-ui.js +30 -4
  45. package/dist/storage.d.ts +2 -0
  46. package/dist/storage.js +15 -3
  47. package/dist/store.d.ts +17 -2
  48. package/dist/store.js +37 -5
  49. package/dist/subscription-chat.d.ts +3 -1
  50. package/dist/subscription-chat.js +1 -0
  51. package/dist/surface.js +1 -0
  52. package/dist/task-status.d.ts +13 -0
  53. package/dist/task-status.js +37 -3
  54. package/dist/team-leads.js +6 -2
  55. package/dist/team-runtime.js +2 -1
  56. package/dist/test-temp.d.ts +20 -2
  57. package/dist/test-temp.js +53 -13
  58. package/dist/toolroll-update.js +2 -0
  59. package/dist/workspace-ui.js +3 -1
  60. package/dist/worktree.js +9 -0
  61. package/package.json +1 -1
@@ -47,28 +47,34 @@ export function followUpChecksOf(store, runId, now = new Date(), root) {
47
47
  const body = JSON.parse(read.content.toString("utf8"));
48
48
  if (typeof body.request !== "number")
49
49
  continue;
50
+ const head = typeof body.head === "string" ? body.head : null;
50
51
  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
+ log: typeof body.log?.artifactId === "number" ? body.log.artifactId : null, head,
53
+ tested: typeof body.tested === "string" ? body.tested : head, batch: batchFactsOf(body.batch, runId, head) });
52
54
  }
53
55
  catch { /* an unreadable receipt upgrades nothing */ }
54
56
  }
55
57
  }
56
58
  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(" · ");
59
+ const [head = "", why = "person", digest = "", recordedBase = ""] = (request.detail ?? "").split(" · ");
58
60
  const started = rows.find(row => row.action === CHECKS_STARTED && row.outcome === String(request.id)) ?? null;
59
61
  const finished = rows.find(row => row.action === CHECKS_FINISHED && row.outcome.startsWith(`${request.id}:`)) ?? null;
60
62
  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 };
63
+ why: why === "pull-request" ? "pull-request" : why === "batch" ? "batch" : "person", exitCode: null, logArtifactId: null, note: null,
64
+ ...(why === "batch" ? { base: /^[a-f0-9]{40}$/.test(recordedBase) ? recordedBase : null } : {}) };
62
65
  if (finished !== null) {
63
66
  const status = finished.outcome.slice(String(request.id).length + 1);
64
67
  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)) {
68
+ // With the evidence at hand, only a sealed receipt can say passed: for this result's own commit, and,
69
+ // for a batch, a batch that names this result.
70
+ if (status === "passed" && root !== undefined && (sealed === null || !sealed.ran || sealed.exitCode !== 0 || (sealed.head !== null && sealed.head !== head)
71
+ || (base.why === "batch" && sealed.batch === null))) {
67
72
  return { ...base, state: "not-run", note: "The saved check result could not be verified." };
68
73
  }
69
74
  const exit = /exit (\d+)/.exec(finished.detail ?? "");
70
75
  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 };
76
+ exitCode: exit === null ? sealed?.exitCode ?? null : Number(exit[1]), logArtifactId: sealed?.log ?? null, note: status === "not-run" ? finished.detail : null,
77
+ ...(sealed === null ? {} : { tested: sealed.ran ? sealed.tested : null, batch: sealed.batch }) };
72
78
  }
73
79
  if (started !== null) {
74
80
  const limit = Number(/limit (\d+)/.exec(started.detail ?? "")?.[1] ?? 3_600_000);
@@ -79,6 +85,26 @@ export function followUpChecksOf(store, runId, now = new Date(), root) {
79
85
  return { ...base, state: "waiting" };
80
86
  });
81
87
  }
88
+ /** A receipt's batch facts, only when they are whole and name this result at its own commit. */
89
+ function batchFactsOf(value, runId, head) {
90
+ if (value === null || typeof value !== "object")
91
+ return null;
92
+ const raw = value;
93
+ const modes = ["together", "split", "conflict", "alone"];
94
+ const mode = modes.find(one => one === raw.mode);
95
+ if (mode === undefined || typeof raw.tested !== "string" || !/^[a-f0-9]{40}$/.test(raw.tested) || !Array.isArray(raw.members))
96
+ return null;
97
+ const members = raw.members.flatMap(one => {
98
+ const m = one;
99
+ return typeof m?.task === "string" && typeof m.run === "number" && typeof m.head === "string" ? [{ task: m.task, run: m.run, head: m.head }] : [];
100
+ });
101
+ if (members.length !== raw.members.length || !members.some(one => one.run === runId && one.head === head))
102
+ return null;
103
+ // Checked on its own: the commit tested is the result's own.
104
+ if (mode !== "together" && (members.length !== 1 || raw.tested !== head))
105
+ return null;
106
+ return { mode, tested: raw.tested, base: typeof raw.base === "string" ? raw.base : null, members };
107
+ }
82
108
  /** Whether a result can have checks run on it, and with which command. */
83
109
  function checkTarget(store, runId, level) {
84
110
  const run = store.getRun(runId);
@@ -110,8 +136,11 @@ export function requestFollowUpChecks(store, input, now) {
110
136
  return { ok: true, request, level: target.level, existing: false };
111
137
  });
112
138
  }
113
- /** Requests nobody has started yet, oldest first. */
139
+ /** Requests nobody has started yet, oldest first. Batch requests wait for the batch pass (batch-checks.ts). */
114
140
  export function waitingCheckRequests(store, now) {
141
+ return waitingRequestsOfAnyKind(store, now).filter(one => one.why !== "batch");
142
+ }
143
+ export function waitingRequestsOfAnyKind(store, now) {
115
144
  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
145
  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
146
  .all(CHECKS_REQUESTED, CHECKS_STARTED).map(row => Number(row["run"]));
@@ -204,7 +233,7 @@ export async function runFollowUpCheck(store, root, request, deps = {}) {
204
233
  const text = log.length === 0 ? "No command started." : log.join("\n\n");
205
234
  const logId = storeEvidence(store, root, runId, "structured-output", `follow-up-check-${request}.log`, Buffer.from(text, "utf8"), FOLLOW_UP_LOG, at, { captureStatus: "ok" });
206
235
  const logArtifact = store.getArtifact(logId);
207
- const receipt = JSON.stringify({ version: 1, request, run: runId, head: target.head, level: target.level,
236
+ const receipt = JSON.stringify({ version: 1, request, run: runId, head: target.head, tested: target.head, level: target.level,
208
237
  command: { repo: target.command.repo, command: target.command.command, digest: target.command.digest, approvedBy: target.command.approvedBy },
209
238
  result, log: { artifactId: logArtifact.id, sha256: logArtifact.sha256, bytesStored: logArtifact.bytesStored, truncated: logArtifact.truncated } }, null, 1);
210
239
  storeEvidence(store, root, runId, "structured-output", `follow-up-check-${request}.json`, Buffer.from(receipt, "utf8"), FOLLOW_UP_RECEIPT, at, { captureStatus: "ok" });
@@ -262,9 +291,14 @@ export function fullCheckGate(store, runId, now) {
262
291
  const level = runCheckLevel(store, runId);
263
292
  const repo = repoOfRun(store, runId);
264
293
  const run = store.getRun(runId);
265
- if (level === null || level === "full" || repo === null || run === null || store.liveVerifyCommand(repo) === null)
294
+ if (level === null || repo === null || run === null)
295
+ return { state: "clear" };
296
+ const fulls = followUpChecksOf(store, runId, now).filter(one => one.level === "full" && one.head === run.headRevision);
297
+ // A Full build that waited for a batch check has had no check of its own: Merge waits for the batch, like Quick does.
298
+ const batched = level === "full" && fulls.some(one => one.why === "batch");
299
+ if ((level === "full" && !batched) || (!batched && store.liveVerifyCommand(repo) === null))
266
300
  return { state: "clear" };
267
- const last = followUpChecksOf(store, runId, now).filter(one => one.level === "full" && one.head === run.headRevision).at(-1);
301
+ const last = fulls.at(-1);
268
302
  if (last === undefined)
269
303
  return { state: "not-requested", message: "The full check hasn't run on this commit yet. Merge once it passes, or merge anyway." };
270
304
  if (last.state === "passed")
package/dist/serve.js CHANGED
@@ -3,6 +3,7 @@ import { aboutYouOf, checkAboutYou, saveAboutYou, ABOUT_YOU_LINE_MAX, ABOUT_YOU_
3
3
  import { withActor } from "./actor.js";
4
4
  import { repositoryContext, repositoryContextRead } from './repository-context.js';
5
5
  import { ADD_TESTS_ACTION, followUpChecksOf, requestFollowUpChecks, fileAddTestsTask, resultCheckLevel } from './result-follow-ups.js';
6
+ import { projectBatchChecks, setProjectBatchChecks } from './batch-policy.js';
6
7
  import { CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, suggestQuickCommand } from './check-levels.js';
7
8
  import { repositoryContextHtml } from './repository-context-ui.js';
8
9
  import { browserAssetsAvailable, browserWorkspaceDocument, serveBrowserAsset } from './browser-shell.js';
@@ -34,7 +35,7 @@ import { CONNECT_CALLBACK, connectionsOf, finishConnect, localConnectOf, oneClic
34
35
  import { KITS_CSS, kitPageHtml, kitsGalleryHtml } from "./kits-ui.js";
35
36
  import { STARTERS_CSS, startersHtml } from "./flow-starters-ui.js";
36
37
  import { GALLERY_CSS, galleryHtml, galleryUseHtml } from "./flow-gallery-ui.js";
37
- import { galleryDefaults, galleryDiagram, galleryTemplateOf, previewGallery, useGalleryTemplate } from "./flow-gallery.js";
38
+ import { galleryDefaults, galleryDiagram, galleryTemplateOf, galleryToolsOf, previewGallery, useGalleryTemplate } from "./flow-gallery.js";
38
39
  import { starterForWork, starterOf, startersFor, switchOnStarter } from "./flow-starters.js";
39
40
  import { createTeammateFrom, labelOf, nameOf, saveSoul, setTeammateState, teammateSettings, sendTeammateSummaries } from "./teammate-admin.js";
40
41
  import { editMemory, forgetMemory, tellTeammate } from "./teammate-memory.js";
@@ -235,6 +236,7 @@ import { prometheusMetrics } from "./metrics.js";
235
236
  import { SPEND_CSS, spendCsv, spendHtml } from "./spend-ui.js";
236
237
  import { RETENTION_CSS, retentionHtml } from "./retention-ui.js";
237
238
  import { STORAGE_CSS, storageHtml } from "./storage-ui.js";
239
+ import { lastSweep, saveSweep, storageSweepOff } from "./storage-sweep.js";
238
240
  import { bytesWords, parseCleanup } from "./storage.js";
239
241
  import { checkoutPlan, cleanCheckouts, discardCheckout, previewDigest } from "./checkout-cleanup.js";
240
242
  import { UPDATES_CSS, newerThan, updateStepsHtml, updatesHtml, updatesScript } from "./toolroll-update-ui.js";
@@ -1116,7 +1118,10 @@ export function createDecisionServer(options) {
1116
1118
  return done("/settings/tools", "problem", "That sign-in expired. Connect again.");
1117
1119
  const service = oneClickOf(visit.service);
1118
1120
  const kit = visit.kit === null ? null : kitOf(visit.kit);
1119
- const back = kit === null ? `/settings/tools?repo=${encodeURIComponent(visit.repo)}` : `/kits/${kit.id}?repo=${encodeURIComponent(visit.repo)}`;
1121
+ // Started from a gallery template's page: back to it, for the same project.
1122
+ const template = visit.template === null ? null : galleryTemplateOf(visit.template);
1123
+ const back = template !== null ? `/flows/new/${template.id}?repo=${encodeURIComponent(visit.repo)}`
1124
+ : kit === null ? `/settings/tools?repo=${encodeURIComponent(visit.repo)}` : `/kits/${kit.id}?repo=${encodeURIComponent(visit.repo)}`;
1120
1125
  const to = projectName(visit.repo);
1121
1126
  if (url.searchParams.has("error"))
1122
1127
  return done(back, "problem", url.searchParams.get("error") === "access_denied" ? `${service.label} wasn't connected to ${to}: access was declined.` : `${service.label} wasn't connected to ${to}.`);
@@ -1748,7 +1753,7 @@ export function createDecisionServer(options) {
1748
1753
  }
1749
1754
  // ---- reads ---------------------------------------------------------------
1750
1755
  /** A gallery template's page (Flows → New → Use this): its questions, the preview of these answers, and Create. */
1751
- function sendGalleryUse(response, who, template, projects, repo, given, name, notice) {
1756
+ function sendGalleryUse(response, who, template, projects, repo, given, name, notice, said = null) {
1752
1757
  const answers = given ?? galleryDefaults(store, template, repo);
1753
1758
  let preview = null, problem = notice;
1754
1759
  try {
@@ -1757,8 +1762,9 @@ export function createDecisionServer(options) {
1757
1762
  catch (error) {
1758
1763
  problem = error instanceof Error ? error.message : "That can't be made here.";
1759
1764
  }
1765
+ const diagram = preview?.built.definition ?? galleryDiagram(template);
1760
1766
  const html = galleryUseHtml({ template, projects: projects.map(path => ({ path, name: projectName(path) })), repo, answers, name, preview, problem,
1761
- csrf: who.via === "cookie" ? who.session.csrf : "", diagram: preview?.built.definition ?? galleryDiagram(template) });
1767
+ csrf: who.via === "cookie" ? who.session.csrf : "", diagram, tools: preview?.tools ?? galleryToolsOf(template, diagram, connectionsOf(store, repo)), said });
1762
1768
  return sendScreen(response, problem !== null && preview === null && notice !== null ? 400 : 200, screen(template.name, `<p><a href="/flows/new">New flow</a></p><h1>${escape(template.name)}</h1>${html}`, { chrome: chromeFor(repo, "flows") }));
1763
1769
  }
1764
1770
  async function handleGet(url, who, request, response) {
@@ -3188,8 +3194,10 @@ export function createDecisionServer(options) {
3188
3194
  if (url.pathname === "/flows/new") {
3189
3195
  const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
3190
3196
  const canUse = who.via === "cookie" && who.role === "approver" && projects.length > 0;
3191
- const repo = url.searchParams.get("repo");
3192
- return sendScreen(response, 200, screen("New flow", `<p><a href="/flows">Flows</a></p><h1>New flow</h1>${galleryHtml({ repo: repo !== null && projects.includes(repo) ? repo : null, canUse })}`, { chrome: chromeFor(project, "flows") }));
3197
+ // The chosen project, else the one being looked at: its tools are what each card says is connected.
3198
+ const asked = url.searchParams.get("repo");
3199
+ const repo = asked !== null && projects.includes(asked) ? asked : project !== null && projects.includes(project) ? project : null;
3200
+ return sendScreen(response, 200, screen("New flow", `<p><a href="/flows">Flows</a></p><h1>New flow</h1>${galleryHtml({ repo, canUse, connections: repo === null ? null : connectionsOf(store, repo) })}`, { chrome: chromeFor(repo ?? project, "flows") }));
3193
3201
  }
3194
3202
  const galleryRead = /^\/flows\/new\/([a-z-]{1,40})$/.exec(url.pathname);
3195
3203
  if (galleryRead !== null) {
@@ -3203,7 +3211,9 @@ export function createDecisionServer(options) {
3203
3211
  return refuse(response, who, 409, "Add a project first.", "/projects");
3204
3212
  const asked = url.searchParams.get("repo");
3205
3213
  const repo = asked !== null && projects.includes(asked) ? asked : project !== null && projects.includes(project) ? project : projects[0];
3206
- return sendGalleryUse(response, who, template, projects, repo, null, template.name, null);
3214
+ // Back from a Connect started on this page: how it went.
3215
+ const problem = url.searchParams.get("problem"), said = url.searchParams.get("said");
3216
+ return sendGalleryUse(response, who, template, projects, repo, null, template.name, problem === null ? null : problem.slice(0, 400), said === null ? null : said.slice(0, 400));
3207
3217
  }
3208
3218
  if (url.pathname === "/flows") {
3209
3219
  const projects = [...new Set([...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()])].filter(visible);
@@ -3969,7 +3979,7 @@ export function createDecisionServer(options) {
3969
3979
  said: url.searchParams.get("said"), problem: url.searchParams.get("problem")
3970
3980
  }) +
3971
3981
  // Every template, beside the starters: Use this opens its page for this project.
3972
- `<h2 class="gallery-heading">Templates</h2>${galleryHtml({ repo: chosen, canUse: who.via === "cookie" && who.role === "approver" && !store.isDemo() })}`;
3982
+ `<h2 class="gallery-heading">Templates</h2>${galleryHtml({ repo: chosen, canUse: who.via === "cookie" && who.role === "approver" && !store.isDemo(), connections: connectionsOf(store, chosen) })}`;
3973
3983
  return sendScreen(response, 200, screen("Flows", `<p><a href="/settings">Settings</a></p><h1>Flows</h1>${content}`, { chrome: chromeFor(chosen || project, "settings") }));
3974
3984
  }
3975
3985
  // Settings → Integrations: which integrations work. The list is the last checks; a render never waits on one.
@@ -4020,7 +4030,8 @@ export function createDecisionServer(options) {
4020
4030
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
4021
4031
  return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
4022
4032
  const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
4023
- return sendScreen(response, 200, screen("Storage", `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${storageHtml({ plan, csrf: who.session.csrf }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
4033
+ const sweep = { last: lastSweep(store), off: storageSweepOff() };
4034
+ return sendScreen(response, 200, screen("Storage", `<p><a href="/settings">Settings</a></p><h1>Storage</h1>${storageHtml({ plan, csrf: who.session.csrf, sweep }, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
4024
4035
  }
4025
4036
  // Settings → Projects → Pull requests: one project's setup. Off, the checks run on open, so the page says
4026
4037
  // exactly what turning on would do, or what to fix first.
@@ -4045,7 +4056,7 @@ export function createDecisionServer(options) {
4045
4056
  const quick = liveQuickCommand(store, repo);
4046
4057
  const html = checkSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange: who.role === "approver" && !store.isDemo(),
4047
4058
  level: projectCheckLevel(store, repo).level, full: store.liveVerifyCommand(repo), quick, suggestion: quick === null ? suggestQuickCommand(repo) : null,
4048
- review: (({ on, source }) => ({ on, source }))(store.reviewSwitch(repo, clock())),
4059
+ review: (({ on, source }) => ({ on, source }))(store.reviewSwitch(repo, clock())), batch: { on: projectBatchChecks(store, repo).on },
4049
4060
  said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
4050
4061
  return sendScreen(response, 200, screen("Checks", `<p><a href="/projects">Projects</a></p><h1>Checks</h1>${html}`, { chrome: chromeFor(project, "projects") }));
4051
4062
  }
@@ -7533,6 +7544,7 @@ export function createDecisionServer(options) {
7533
7544
  if (body.get("preview") !== previewDigest(plan))
7534
7545
  return back("problem", "What a clean-up would remove changed since you looked. Check the list again.");
7535
7546
  const done = await cleanCheckouts(store, pool, clock, { manual: true, actor: who.name, only: new Set(plan.go.map(one => one.path)) });
7547
+ saveSweep(store, { at: clock().toISOString(), source: "manual", actor: who.name, parts: [{ kind: "checkouts", count: done.removed.length, bytes: done.freed, failed: done.kept.filter(one => one.why === "git refused").length, items: done.removed.slice(0, 20).map(one => one.path) }] });
7536
7548
  return back("said", done.removed.length === 0 ? "Nothing was removed: those checkouts are no longer ready to go." : `Removed ${done.removed.length} checkout${done.removed.length === 1 ? "" : "s"}, about ${bytesWords(done.freed)}. Their branches stay.`);
7537
7549
  }
7538
7550
  const done = await discardCheckout(store, pool, body.get("path") ?? "", now, who.name);
@@ -7594,6 +7606,12 @@ export function createDecisionServer(options) {
7594
7606
  store.setReviewSwitch(repo, on, who.name, now);
7595
7607
  return back("said", on ? "Automatic review is on." : "Automatic review is off.");
7596
7608
  }
7609
+ // Batch checks: off by default; the ledger keeps before → after.
7610
+ if (act === "batch") {
7611
+ const on = body.get("on") === "1";
7612
+ const changed = setProjectBatchChecks(store, repo, on, who.name, now);
7613
+ return back("said", !changed.changed ? "Saved." : on ? "Batch checks are on." : "Batch checks are off.");
7614
+ }
7597
7615
  if (act === "level") {
7598
7616
  const level = body.get("level");
7599
7617
  if (!isCheckLevel(level))
@@ -7737,10 +7755,14 @@ export function createDecisionServer(options) {
7737
7755
  const local = service === null && also === null ? localConnectOf(body.get("service") ?? "") : null;
7738
7756
  const chosen = service ?? local;
7739
7757
  const target = also === null ? repo : split > 0 ? also.slice(split + 1) : "";
7740
- const kit = kitOf(body.get("kit") ?? "")?.id ?? null;
7758
+ // From a gallery template that uses this tool: it comes back to that template's page, for this project.
7759
+ const gallery = also === null && chosen !== null ? galleryTemplateOf(body.get("template") ?? "") : null;
7760
+ const template = gallery !== null && (gallery.tools ?? []).includes(chosen.id) ? gallery.id : null;
7761
+ const kit = template === null ? kitOf(body.get("kit") ?? "")?.id ?? null : null;
7741
7762
  // A Connect that fails comes back to its own button, where the reason is said (no #fragment: one stops the alert's
7742
7763
  // autofocus); an "Also connect to" refusal goes back to the Connect card.
7743
- const back = (words, page = repo) => redirect(response, `/settings/tools?repo=${encodeURIComponent(page)}${kit === null ? "" : `&kit=${kit}`}${chosen === null || also !== null ? "" : `&connect=${chosen.id}`}&problem=${encodeURIComponent(words)}${also === null ? "" : "#connect"}`);
7764
+ const back = (words, page = repo) => template !== null ? redirect(response, `/flows/new/${template}?repo=${encodeURIComponent(page)}&problem=${encodeURIComponent(words)}`)
7765
+ : redirect(response, `/settings/tools?repo=${encodeURIComponent(page)}${kit === null ? "" : `&kit=${kit}`}${chosen === null || also !== null ? "" : `&connect=${chosen.id}`}&problem=${encodeURIComponent(words)}${also === null ? "" : "#connect"}`);
7744
7766
  // The page posts the project it showed; a post from a page showing another project is stale.
7745
7767
  const shown = body.get("shown") ?? "";
7746
7768
  if (shown !== repo)
@@ -7769,8 +7791,10 @@ export function createDecisionServer(options) {
7769
7791
  return back(added.message);
7770
7792
  }
7771
7793
  const tested = await testToolOf(store, repo, local.id, clock(), { home: toolHome, omitEnv: ALL_CREDENTIAL_ENV });
7794
+ const connected = `${local.label} is connected: ${tested?.tools.length ?? 0} action${tested?.tools.length === 1 ? "" : "s"}.`;
7772
7795
  if (tested?.ok)
7773
- return redirect(response, `/settings/tools?repo=${encodeURIComponent(repo)}${kit === null ? "" : `&kit=${kit}`}&said=${encodeURIComponent(`${local.label} is connected: ${tested.tools.length} action${tested.tools.length === 1 ? "" : "s"}.`)}#tool-${local.id}`);
7796
+ return redirect(response, template !== null ? `/flows/new/${template}?repo=${encodeURIComponent(repo)}&said=${encodeURIComponent(connected)}`
7797
+ : `/settings/tools?repo=${encodeURIComponent(repo)}${kit === null ? "" : `&kit=${kit}`}&said=${encodeURIComponent(connected)}#tool-${local.id}`);
7774
7798
  if (had === undefined)
7775
7799
  removeToolFrom(store, repo, local.id, who.name, clock(), toolHome);
7776
7800
  return back(tested?.problem ?? `${local.label} didn't answer.`);
@@ -7780,7 +7804,7 @@ export function createDecisionServer(options) {
7780
7804
  const origin = consoleOrigin(request.headers.host);
7781
7805
  if (origin === null)
7782
7806
  return back("Connect tools from this computer (localhost) or from your https address.");
7783
- const started = await startConnect({ service: service.id, repo: target, by: who.name, origin, kit: also === null ? kit : null }, options.connectFetch ?? fetch);
7807
+ const started = await startConnect({ service: service.id, repo: target, by: who.name, origin, kit: also === null ? kit : null, template }, options.connectFetch ?? fetch);
7784
7808
  if (!started.ok)
7785
7809
  return back(started.said);
7786
7810
  for (const [key, visit] of connectVisits)
@@ -9658,7 +9682,8 @@ export function createDecisionServer(options) {
9658
9682
  const onProgress = beginLiveTurn(opened.thread.id);
9659
9683
  void runMateTurn({ store, who: principal, session: mateSession, thread: opened.thread, config: enabled.config, key: enabled.key, message, onProgress, channel: "console", ...(requestId === null ? {} : { requestId }), ...(focusTask === null && chatProject !== null ? { context: `Current project: ${projectName(chatProject)} (${chatProject}). Keep this conversation about that project unless the operator explicitly asks to broaden it; use it as the repo for project tools.` } : {}), ...(focusTask === null ? {} : { context: `Current task: ${focusTask.id}. Read it with get_task before answering or proposing changes. Read its currentExecution next and bind new actions to that exact execution. Never replace the target of a prior proposal with a newer revision. Keep this turn about that task unless the operator explicitly asks to broaden it.${resultContext}${modeContext}` }), fetcher: chatFetcher, ...(options.subscriptionChatRunner === undefined ? {} : { subscriptionRunner: options.subscriptionChatRunner }), clock, evidenceRoot })
9660
9684
  .then(outcome => {
9661
- if (!outcome.ok)
9685
+ // A turn that saved its outcome in the thread (one stopped at its deadline) is said there, once.
9686
+ if (!outcome.ok && !("saved" in outcome && outcome.saved === true))
9662
9687
  noteMate(who.session.csrf, "turn" in outcome ? outcome.turn : null, outcome.message);
9663
9688
  endLiveTurn(opened.thread.id, outcome.ok);
9664
9689
  })
@@ -18268,7 +18293,8 @@ function mateBrowserMessages(rows, csrf, back, target = { task: null, project: n
18268
18293
  html: message.role === 'operator' ? `<p>${escape(message.text)}</p>` : renderChatText(message.text, askedBy.get(message.id)) + asked(message.turn),
18269
18294
  activity: message.activity, createdAt: message.createdAt,
18270
18295
  ...(() => {
18271
- const parts = message.turn === null ? [] : rows.proposals.filter(one => one.turn === message.turn)
18296
+ // Under the lead's message only, as the server-rendered thread does: the person's message shares the turn.
18297
+ const parts = message.turn === null || message.role === 'operator' ? [] : rows.proposals.filter(one => one.turn === message.turn)
18272
18298
  .map(one => mateProposalCardParts(one, csrf, rows.pending !== null, rows.decisions.get(typeof one.payload['decision'] === 'number' ? one.payload['decision'] : -1) ?? null, back));
18273
18299
  return { cardsHtml: parts.map(one => one.html).join(''), cards: parts.map(one => one.card) };
18274
18300
  })(),
@@ -23010,9 +23036,16 @@ function followUpsFor(store, evidenceRoot, run, now) {
23010
23036
  const FOLLOW_UP_WORDS = { waiting: "waiting for a worker", running: "running", passed: "passed", failed: "failed", "not-run": "didn't run" };
23011
23037
  /** Run checks and Add tests, under the result's Checks: what ran since, then the two acts. */
23012
23038
  function followUpsHtml(followUps, runId, o) {
23013
- const rows = followUps.checks.map(one => `<li data-follow-up-check="${one.state}">${escape(CHECK_LEVEL_WORDS[one.level])} checks ${escape(FOLLOW_UP_WORDS[one.state])}` +
23014
- `${one.exitCode === null || one.state === "passed" ? "" : ` (exit ${one.exitCode})`} on <span class="mono">${escape(one.head.slice(0, 7))}</span>` +
23015
- `<span class="meta"> · ${one.why === "pull-request" ? "for the pull request" : escape(one.actor)}${one.note === null ? "" : ` · ${escape(one.note)}`}</span>` +
23039
+ // A batch check names every result it ran with and the exact commit it tested (batch-checks.ts).
23040
+ const batchHow = (one) => {
23041
+ const peers = one.batch?.members.filter(member => member.run !== runId).map(member => member.task) ?? [];
23042
+ return one.batch?.mode === "together" && peers.length > 0 ? ` together with ${peers.map(escape).join(", ")}`
23043
+ : one.batch?.mode === "split" ? " on its own after its batch failed" : one.batch?.mode === "conflict" ? " on its own (it conflicted with another result)" : "";
23044
+ };
23045
+ const rows = followUps.checks.map(one => `<li data-follow-up-check="${one.state}"${one.why === "batch" ? ` data-batch="${escape(one.batch?.mode ?? "waiting")}"` : ""}>${escape(CHECK_LEVEL_WORDS[one.level])} checks ` +
23046
+ `${escape(one.why === "batch" && one.state === "waiting" ? "waiting to check with other results" : FOLLOW_UP_WORDS[one.state])}${batchHow(one)}` +
23047
+ `${one.exitCode === null || one.state === "passed" ? "" : ` (exit ${one.exitCode})`} on <span class="mono">${escape((one.tested ?? one.head).slice(0, 7))}</span>` +
23048
+ `<span class="meta"> · ${one.why === "pull-request" ? "for the pull request" : one.why === "batch" ? "batch check" : escape(one.actor)}${one.note === null ? "" : ` · ${escape(one.note)}`}</span>` +
23016
23049
  `${one.logArtifactId === null ? "" : ` <a href="/r/${runId}/evidence/${one.logArtifactId}">Log</a>`}</li>`).join("");
23017
23050
  const hidden = `<input type="hidden" name="csrf" value="${escape(o.csrf)}"><input type="hidden" name="return" value="${escape(o.returnTo)}">`;
23018
23051
  const canCheck = followUps.quick || followUps.full;
@@ -1,12 +1,14 @@
1
1
  export declare const DEPS_FOLDER = "deps";
2
2
  /** A copy linked or made this recently is never removed by a clean-up: a lease may be about to link it. */
3
3
  export declare const SHARED_GRACE_MS: number;
4
+ /** The daily storage sweep removes a copy no checkout uses and none has linked for this long. */
5
+ export declare const SHARED_UNUSED_MS: number;
4
6
  export type SharedCopy = {
5
7
  key: string;
6
8
  /** The copy's folder, holding node_modules and ready.json. */
7
9
  dir: string;
8
10
  repo: string;
9
- /** sha256 of the lockfile and package.json it was installed from. */
11
+ /** sha256 of the lockfile and package.json, excluding the project's own version (raw bytes in old copies). */
10
12
  lock: string;
11
13
  node: string;
12
14
  createdAt: string;
@@ -19,7 +21,8 @@ export declare function sharedDepsRoot(databaseFile: string): string;
19
21
  /** A setup whose whole effect is node_modules: `npm ci` or `npm install`, with plain flags only. */
20
22
  export declare function installsOnly(command: string): boolean;
21
23
  /**
22
- * The checkout's dependency identity: its npm lockfile and package.json, hashed together. null when there is no
24
+ * The checkout's dependency identity: its npm lockfile and package.json, hashed together without the root
25
+ * package's own version, as in release-check.mjs versionOnly(). null when either JSON file is invalid, there is no
23
26
  * lockfile, or the lockfile links local folders (workspaces, `file:`), whose links would point into the shared
24
27
  * folder instead of the checkout.
25
28
  */
@@ -35,6 +38,7 @@ export declare function sharingFor(args: {
35
38
  command: string;
36
39
  digest: string;
37
40
  };
41
+ root?: string;
38
42
  }): {
39
43
  key: string;
40
44
  lock: string;
@@ -92,3 +96,15 @@ export declare function unusedShared(root: string, checkouts: readonly {
92
96
  };
93
97
  /** Remove one copy or debris folder (read-only on disk, so made writable first). */
94
98
  export declare function removeShared(path: string): boolean;
99
+ /**
100
+ * The daily sweep: remove each copy no checkout uses and none has linked for `unusedMs` (a week), and old debris.
101
+ * Right before a copy goes, who uses it and when it was last linked are read again; a copy a checkout picked up in
102
+ * between stays.
103
+ */
104
+ export declare function pruneUnusedShared(root: string, checkouts: () => readonly {
105
+ path: string;
106
+ repo: string;
107
+ }[], now: Date, unusedMs?: number): {
108
+ removed: SharedUse[];
109
+ failed: string[];
110
+ };
@@ -14,12 +14,13 @@
14
14
  * The link is read-only to the build. A checkout whose package.json or lockfile no longer matches the copy it links
15
15
  * gets its own install (the link goes, the approved setup runs there) and the shared copy is never touched; a copy
16
16
  * whose installed tree changed anyway is retired, never linked again. `toolroll storage clean` removes a copy no
17
- * checkout links or matches.
17
+ * checkout links or matches; the plane's daily storage sweep removes one no checkout has used for a week.
18
18
  */
19
19
  import { createHash, randomBytes } from "node:crypto";
20
20
  import { spawnSync } from "node:child_process";
21
21
  import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
22
22
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
23
+ import { markNeverIndex } from "./never-index.js";
23
24
  export const DEPS_FOLDER = "deps";
24
25
  const READY = "ready.json";
25
26
  const RETIRED = "retired";
@@ -28,6 +29,8 @@ const LINK_NOTE = ".toolroll-shared";
28
29
  const NODE_MODULES = "node_modules";
29
30
  /** A copy linked or made this recently is never removed by a clean-up: a lease may be about to link it. */
30
31
  export const SHARED_GRACE_MS = 60 * 60_000;
32
+ /** The daily storage sweep removes a copy no checkout uses and none has linked for this long. */
33
+ export const SHARED_UNUSED_MS = 7 * 24 * 60 * 60_000;
31
34
  /** A half-made copy (a crash mid-promotion) is debris after this long. */
32
35
  const PARTIAL_STALE_MS = 24 * 60 * 60_000;
33
36
  export function sharedDepsRoot(databaseFile) {
@@ -38,15 +41,20 @@ export function installsOnly(command) {
38
41
  return /^\s*npm\s+(?:ci|install|i)(?:\s+--?[A-Za-z][\w-]*(?:=[\w.@/:+-]+)?)*\s*$/.test(command);
39
42
  }
40
43
  /**
41
- * The checkout's dependency identity: its npm lockfile and package.json, hashed together. null when there is no
44
+ * The checkout's dependency identity: its npm lockfile and package.json, hashed together without the root
45
+ * package's own version, as in release-check.mjs versionOnly(). null when either JSON file is invalid, there is no
42
46
  * lockfile, or the lockfile links local folders (workspaces, `file:`), whose links would point into the shared
43
47
  * folder instead of the checkout.
44
48
  */
45
49
  export function lockDigest(checkout) {
50
+ return dependencyInputs(checkout)?.digest ?? null;
51
+ }
52
+ const digestPair = (lock, manifest) => createHash("sha256").update(lock).update("\u0000").update(manifest).digest("hex");
53
+ function dependencyInputs(checkout) {
46
54
  let lock = null;
47
55
  for (const name of ["npm-shrinkwrap.json", "package-lock.json"]) {
48
56
  try {
49
- lock = readFileSync(join(checkout, name));
57
+ lock = readFileSync(join(checkout, name), "utf8");
50
58
  break;
51
59
  }
52
60
  catch {
@@ -55,16 +63,75 @@ export function lockDigest(checkout) {
55
63
  }
56
64
  if (lock === null)
57
65
  return null;
58
- let manifest;
59
66
  try {
60
- manifest = readFileSync(join(checkout, "package.json"));
67
+ const manifest = readFileSync(join(checkout, "package.json"), "utf8");
68
+ const normalized = (text, isLock) => {
69
+ const json = JSON.parse(text);
70
+ if (json === null || typeof json !== "object" || Array.isArray(json))
71
+ throw Error("not an object");
72
+ delete json.version;
73
+ if (isLock && json.packages?.[""] !== undefined)
74
+ delete json.packages[""].version;
75
+ return JSON.stringify(json);
76
+ };
77
+ if (/"link"\s*:\s*true/.test(lock))
78
+ return null;
79
+ return { lock, manifest, digest: digestPair(normalized(lock, true), normalized(manifest, false)) };
61
80
  }
62
81
  catch {
63
82
  return null;
64
83
  }
65
- if (/"link"\s*:\s*true/.test(lock.toString("utf8")))
66
- return null;
67
- return createHash("sha256").update(lock).update("\u0000").update(manifest).digest("hex");
84
+ }
85
+ /** Replace only root version values, preserving every other byte for comparison with a pre-normalization hash.
86
+ * The caller already parsed the JSON. Token offsets let compact and indented files keep their original layout. */
87
+ function withRootVersion(text, version, isLock) {
88
+ const tokens = [...text.matchAll(/"(?:[^"\\]|\\.)*"|[{}[\]:,]|[^\s{}[\]:,]+/g)];
89
+ let cursor = 0;
90
+ const edits = [];
91
+ const value = (path) => {
92
+ const start = tokens[cursor++];
93
+ if (start[0] === "{") {
94
+ while (tokens[cursor][0] !== "}") {
95
+ const key = JSON.parse(tokens[cursor++][0]);
96
+ cursor++; // colon
97
+ value([...path, key]);
98
+ if (tokens[cursor][0] === ",")
99
+ cursor++;
100
+ }
101
+ cursor++;
102
+ }
103
+ else if (start[0] === "[") {
104
+ let index = 0;
105
+ while (tokens[cursor][0] !== "]") {
106
+ value([...path, String(index++)]);
107
+ if (tokens[cursor][0] === ",")
108
+ cursor++;
109
+ }
110
+ cursor++;
111
+ }
112
+ if ((path.length === 1 && path[0] === "version") ||
113
+ (isLock && path.length === 3 && path[0] === "packages" && path[1] === "" && path[2] === "version")) {
114
+ const end = tokens[cursor - 1];
115
+ edits.push({ start: start.index, end: end.index + end[0].length });
116
+ }
117
+ };
118
+ value([]);
119
+ for (const edit of edits.sort((a, b) => b.start - a.start))
120
+ text = text.slice(0, edit.start) + JSON.stringify(version) + text.slice(edit.end);
121
+ return text;
122
+ }
123
+ /** Old copies did not save the manifests. npm's hidden lock records their root version; reconstruct only those
124
+ * version fields and require the exact old raw digest. Never infer equality from the installed packages alone. */
125
+ function matchesInputs(dir, ready, inputs) {
126
+ if (ready.lock === inputs.digest || ready.lock === digestPair(inputs.lock, inputs.manifest))
127
+ return true;
128
+ try {
129
+ const version = JSON.parse(readFileSync(join(dir, NODE_MODULES, ".package-lock.json"), "utf8")).version;
130
+ return typeof version === "string" && ready.lock === digestPair(withRootVersion(inputs.lock, version, true), withRootVersion(inputs.manifest, version, false));
131
+ }
132
+ catch {
133
+ return false;
134
+ }
68
135
  }
69
136
  const nodeSeen = new Map();
70
137
  /** The node a setup on this PATH would install with: version, platform and architecture (native modules differ). */
@@ -85,13 +152,32 @@ export function depsKey(repo, setupDigest, lock, node) {
85
152
  export function sharingFor(args) {
86
153
  if (!installsOnly(args.setup.command))
87
154
  return null;
88
- const lock = lockDigest(args.worktree);
89
- if (lock === null)
155
+ const inputs = dependencyInputs(args.worktree);
156
+ if (inputs === null)
90
157
  return null;
158
+ const lock = inputs.digest;
91
159
  const node = nodeIdentity();
92
160
  if (node === null)
93
161
  return null;
94
- return { key: depsKey(args.repo, args.setup.digest, lock, node), lock, node };
162
+ const key = depsKey(args.repo, args.setup.digest, lock, node);
163
+ if (args.root !== undefined) {
164
+ let names = [];
165
+ try {
166
+ names = readdirSync(args.root).filter(name => /^[0-9a-f]{24}$/.test(name));
167
+ }
168
+ catch { /* no copies yet */ }
169
+ // Keep a currently linked copy first. Reusing its old directory preserves every existing checkout's links.
170
+ for (const name of new Set([linkedKey(args.worktree, args.root), key, ...names])) {
171
+ if (name === null)
172
+ continue;
173
+ const dir = join(args.root, name);
174
+ const ready = readReady(dir);
175
+ if (ready?.repo === args.repo && ready.setup === args.setup.digest && ready.node === node &&
176
+ readyCopy(args.root, name) !== null && matchesInputs(dir, ready, inputs))
177
+ return { key: name, lock, node };
178
+ }
179
+ }
180
+ return { key, lock, node };
95
181
  }
96
182
  function readReady(dir) {
97
183
  try {
@@ -179,6 +265,7 @@ export function linkInto(worktree, shared) {
179
265
  symlinkSync(join(shared, entry.name), join(at, entry.name), entry.isDirectory() ? (process.platform === "win32" ? "junction" : "dir") : "file");
180
266
  }
181
267
  writeFileSync(join(at, LINK_NOTE), `${shared}\n`, "utf8");
268
+ markNeverIndex(at);
182
269
  touch(dirname(shared));
183
270
  return "link";
184
271
  }
@@ -234,6 +321,8 @@ export function promoteInstall(args) {
234
321
  const partial = join(args.root, `${args.key}.partial-${process.pid}-${randomBytes(4).toString("hex")}`);
235
322
  try {
236
323
  mkdirSync(partial, { recursive: true });
324
+ // Spotlight leaves shared installs alone (never-index.ts).
325
+ markNeverIndex(args.root);
237
326
  renameSync(own, join(partial, NODE_MODULES));
238
327
  }
239
328
  catch {
@@ -317,12 +406,14 @@ export function sharedUse(root, checkouts) {
317
406
  }
318
407
  if (!existsSync(checkout.path))
319
408
  continue;
320
- const lock = lockDigest(checkout.path);
321
- if (lock === null)
409
+ const inputs = dependencyInputs(checkout.path);
410
+ if (inputs === null)
322
411
  continue;
323
- for (const copy of copies)
324
- if (!copy.retired && copy.repo === checkout.repo && copy.lock === lock)
412
+ for (const copy of copies) {
413
+ const ready = readReady(copy.dir);
414
+ if (!copy.retired && copy.repo === checkout.repo && ready !== null && matchesInputs(copy.dir, ready, inputs))
325
415
  users.get(copy.key).add(checkout.path);
416
+ }
326
417
  }
327
418
  return copies.map(one => ({ ...one, checkouts: [...users.get(one.key)].sort() }));
328
419
  }
@@ -363,3 +454,26 @@ export function removeShared(path) {
363
454
  return false;
364
455
  }
365
456
  }
457
+ /**
458
+ * The daily sweep: remove each copy no checkout uses and none has linked for `unusedMs` (a week), and old debris.
459
+ * Right before a copy goes, who uses it and when it was last linked are read again; a copy a checkout picked up in
460
+ * between stays.
461
+ */
462
+ export function pruneUnusedShared(root, checkouts, now, unusedMs = SHARED_UNUSED_MS) {
463
+ const stale = (one) => one.checkouts.length === 0 && now.getTime() - Date.parse(one.usedAt) > unusedMs;
464
+ const removed = [];
465
+ const failed = [];
466
+ for (const one of sharedUse(root, checkouts()).filter(stale)) {
467
+ const fresh = sharedUse(root, checkouts()).find(other => other.key === one.key);
468
+ if (fresh === undefined || !stale(fresh))
469
+ continue;
470
+ if (removeShared(fresh.dir))
471
+ removed.push(fresh);
472
+ else
473
+ failed.push(fresh.dir);
474
+ }
475
+ for (const debris of sharedDebris(root, now))
476
+ if (!removeShared(debris))
477
+ failed.push(debris);
478
+ return { removed, failed };
479
+ }