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.
- package/CHANGELOG.md +35 -0
- package/dist/assignment.d.ts +3 -0
- package/dist/assignment.js +8 -2
- package/dist/batch-checks.d.ts +15 -0
- package/dist/batch-checks.js +244 -0
- package/dist/batch-policy.d.ts +23 -0
- package/dist/batch-policy.js +25 -0
- package/dist/browser/workspace.js +13 -13
- package/dist/builder.js +14 -3
- package/dist/chat-actions.js +2 -2
- package/dist/check-levels-ui.d.ts +4 -0
- package/dist/check-levels-ui.js +21 -2
- package/dist/checkout-cleanup.d.ts +2 -1
- package/dist/checkout-cleanup.js +24 -8
- package/dist/desktop-update.d.ts +1 -1
- package/dist/exec.d.ts +2 -0
- package/dist/exec.js +11 -0
- package/dist/flow-gallery-ui.d.ts +14 -9
- package/dist/flow-gallery-ui.js +71 -29
- package/dist/flow-gallery.d.ts +26 -2
- package/dist/flow-gallery.js +182 -7
- package/dist/lead-capabilities.d.ts +0 -7
- package/dist/lead-capabilities.js +11 -1
- package/dist/mate-doors.js +2 -2
- package/dist/mate.d.ts +6 -2
- package/dist/mate.js +63 -38
- package/dist/mcp-connect.d.ts +3 -1
- package/dist/mcp-connect.js +11 -6
- package/dist/never-index.d.ts +3 -0
- package/dist/never-index.js +21 -0
- package/dist/operate.js +86 -2
- package/dist/orphan-sweep.d.ts +63 -0
- package/dist/orphan-sweep.js +235 -0
- package/dist/result-follow-ups.d.ts +22 -2
- package/dist/result-follow-ups.js +44 -10
- package/dist/serve.js +52 -19
- package/dist/shared-deps.d.ts +18 -2
- package/dist/shared-deps.js +129 -15
- package/dist/staged-runtimes.d.ts +42 -0
- package/dist/staged-runtimes.js +191 -0
- package/dist/storage-sweep.d.ts +50 -0
- package/dist/storage-sweep.js +203 -0
- package/dist/storage-ui.d.ts +5 -0
- package/dist/storage-ui.js +30 -4
- package/dist/storage.d.ts +2 -0
- package/dist/storage.js +15 -3
- package/dist/store.d.ts +17 -2
- package/dist/store.js +37 -5
- package/dist/subscription-chat.d.ts +3 -1
- package/dist/subscription-chat.js +1 -0
- package/dist/surface.js +1 -0
- package/dist/task-status.d.ts +13 -0
- package/dist/task-status.js +37 -3
- package/dist/team-leads.js +6 -2
- package/dist/team-runtime.js +2 -1
- package/dist/test-temp.d.ts +20 -2
- package/dist/test-temp.js +53 -13
- package/dist/toolroll-update.js +2 -0
- package/dist/workspace-ui.js +3 -1
- package/dist/worktree.js +9 -0
- 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
|
-
|
|
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 ||
|
|
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 =
|
|
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
|
-
|
|
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?.
|
|
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
|
-
|
|
3192
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, `/
|
|
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, `/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23014
|
-
|
|
23015
|
-
|
|
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;
|
package/dist/shared-deps.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
};
|
package/dist/shared-deps.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
89
|
-
if (
|
|
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
|
-
|
|
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
|
|
321
|
-
if (
|
|
409
|
+
const inputs = dependencyInputs(checkout.path);
|
|
410
|
+
if (inputs === null)
|
|
322
411
|
continue;
|
|
323
|
-
for (const copy of copies)
|
|
324
|
-
|
|
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
|
+
}
|