toolroll 0.9.31 → 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 +21 -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/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/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 +26 -7
- 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
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';
|
|
@@ -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";
|
|
@@ -4028,7 +4030,8 @@ export function createDecisionServer(options) {
|
|
|
4028
4030
|
if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
|
|
4029
4031
|
return refuse(response, who, 403, "An instance operator looks after storage.", "/settings");
|
|
4030
4032
|
const plan = await checkoutPlan(store, storagePool(databaseFile), now, { manual: true });
|
|
4031
|
-
|
|
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") }));
|
|
4032
4035
|
}
|
|
4033
4036
|
// Settings → Projects → Pull requests: one project's setup. Off, the checks run on open, so the page says
|
|
4034
4037
|
// exactly what turning on would do, or what to fix first.
|
|
@@ -4053,7 +4056,7 @@ export function createDecisionServer(options) {
|
|
|
4053
4056
|
const quick = liveQuickCommand(store, repo);
|
|
4054
4057
|
const html = checkSettingsHtml({ repo, name: projectName(repo), csrf: who.session.csrf, canChange: who.role === "approver" && !store.isDemo(),
|
|
4055
4058
|
level: projectCheckLevel(store, repo).level, full: store.liveVerifyCommand(repo), quick, suggestion: quick === null ? suggestQuickCommand(repo) : null,
|
|
4056
|
-
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 },
|
|
4057
4060
|
said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
|
|
4058
4061
|
return sendScreen(response, 200, screen("Checks", `<p><a href="/projects">Projects</a></p><h1>Checks</h1>${html}`, { chrome: chromeFor(project, "projects") }));
|
|
4059
4062
|
}
|
|
@@ -7541,6 +7544,7 @@ export function createDecisionServer(options) {
|
|
|
7541
7544
|
if (body.get("preview") !== previewDigest(plan))
|
|
7542
7545
|
return back("problem", "What a clean-up would remove changed since you looked. Check the list again.");
|
|
7543
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) }] });
|
|
7544
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.`);
|
|
7545
7549
|
}
|
|
7546
7550
|
const done = await discardCheckout(store, pool, body.get("path") ?? "", now, who.name);
|
|
@@ -7602,6 +7606,12 @@ export function createDecisionServer(options) {
|
|
|
7602
7606
|
store.setReviewSwitch(repo, on, who.name, now);
|
|
7603
7607
|
return back("said", on ? "Automatic review is on." : "Automatic review is off.");
|
|
7604
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
|
+
}
|
|
7605
7615
|
if (act === "level") {
|
|
7606
7616
|
const level = body.get("level");
|
|
7607
7617
|
if (!isCheckLevel(level))
|
|
@@ -9672,7 +9682,8 @@ export function createDecisionServer(options) {
|
|
|
9672
9682
|
const onProgress = beginLiveTurn(opened.thread.id);
|
|
9673
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 })
|
|
9674
9684
|
.then(outcome => {
|
|
9675
|
-
|
|
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))
|
|
9676
9687
|
noteMate(who.session.csrf, "turn" in outcome ? outcome.turn : null, outcome.message);
|
|
9677
9688
|
endLiveTurn(opened.thread.id, outcome.ok);
|
|
9678
9689
|
})
|
|
@@ -18282,7 +18293,8 @@ function mateBrowserMessages(rows, csrf, back, target = { task: null, project: n
|
|
|
18282
18293
|
html: message.role === 'operator' ? `<p>${escape(message.text)}</p>` : renderChatText(message.text, askedBy.get(message.id)) + asked(message.turn),
|
|
18283
18294
|
activity: message.activity, createdAt: message.createdAt,
|
|
18284
18295
|
...(() => {
|
|
18285
|
-
|
|
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)
|
|
18286
18298
|
.map(one => mateProposalCardParts(one, csrf, rows.pending !== null, rows.decisions.get(typeof one.payload['decision'] === 'number' ? one.payload['decision'] : -1) ?? null, back));
|
|
18287
18299
|
return { cardsHtml: parts.map(one => one.html).join(''), cards: parts.map(one => one.card) };
|
|
18288
18300
|
})(),
|
|
@@ -23024,9 +23036,16 @@ function followUpsFor(store, evidenceRoot, run, now) {
|
|
|
23024
23036
|
const FOLLOW_UP_WORDS = { waiting: "waiting for a worker", running: "running", passed: "passed", failed: "failed", "not-run": "didn't run" };
|
|
23025
23037
|
/** Run checks and Add tests, under the result's Checks: what ran since, then the two acts. */
|
|
23026
23038
|
function followUpsHtml(followUps, runId, o) {
|
|
23027
|
-
|
|
23028
|
-
|
|
23029
|
-
|
|
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>` +
|
|
23030
23049
|
`${one.logArtifactId === null ? "" : ` <a href="/r/${runId}/evidence/${one.logArtifactId}">Log</a>`}</li>`).join("");
|
|
23031
23050
|
const hidden = `<input type="hidden" name="csrf" value="${escape(o.csrf)}"><input type="hidden" name="return" value="${escape(o.returnTo)}">`;
|
|
23032
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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** A deploy or update that never finished keeps its stage this long (it may be resumed). */
|
|
2
|
+
export declare const UNFINISHED_KEEP_MS: number;
|
|
3
|
+
export declare const STAGED_FOLDER = "staged-upgrades";
|
|
4
|
+
export type Stage = {
|
|
5
|
+
path: string;
|
|
6
|
+
name: string;
|
|
7
|
+
at: number;
|
|
8
|
+
finished: boolean;
|
|
9
|
+
prior: string | null;
|
|
10
|
+
keptAside: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type StageKeep = "current" | "previous" | "newest" | "under way" | "kept-aside database";
|
|
13
|
+
export type StagePlan = {
|
|
14
|
+
keep: {
|
|
15
|
+
path: string;
|
|
16
|
+
why: StageKeep;
|
|
17
|
+
}[];
|
|
18
|
+
go: Stage[];
|
|
19
|
+
refused: string | null;
|
|
20
|
+
};
|
|
21
|
+
/** The `browser-*` and `release-*` stages: real folders directly in staged-upgrades, newest first. */
|
|
22
|
+
export declare function stagedRuntimes(stateDir: string): Stage[];
|
|
23
|
+
/**
|
|
24
|
+
* Paths a runtime may be running from: the launchd services' definitions, every `toolroll` (or older
|
|
25
|
+
* `standing-orders`) command on PATH or under nvm, this process, and `extra`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function runtimesInUse(extra?: readonly string[], home?: string, path?: string): string[];
|
|
28
|
+
/** What stays and what goes now, and why. */
|
|
29
|
+
export declare function stagePlan(stateDir: string, inUse: readonly string[], now: Date): StagePlan;
|
|
30
|
+
export type StagePrune = {
|
|
31
|
+
removed: string[];
|
|
32
|
+
failed: string[];
|
|
33
|
+
refused: string | null;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Remove the stages that go. Right before each goes, the plan is made again (what's in use now) and the stage must
|
|
37
|
+
* still be a real folder directly in staged-upgrades.
|
|
38
|
+
*/
|
|
39
|
+
export declare function pruneStagedRuntimes(stateDir: string, now: Date, options?: {
|
|
40
|
+
inUse?: () => string[];
|
|
41
|
+
remove?: (path: string) => void;
|
|
42
|
+
}): StagePrune;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staged runtimes (`staged-upgrades/`): each deploy (scripts/deploy-browser.mjs, `browser-*`) and each `toolroll
|
|
3
|
+
* update` (`release-*`) stages a whole runtime, a few hundred MB with its database backup. Oct 4: 9 of them, 2.2 GB.
|
|
4
|
+
*
|
|
5
|
+
* Kept: the current runtime (one the service, a `toolroll` command or this process runs from), the one before it
|
|
6
|
+
* (its journal's way back), the newest stage, a deploy or update still under way (no finished journal) for a week,
|
|
7
|
+
* and a stage holding a database a failed update kept aside. Everything else of those two kinds goes — after a deploy
|
|
8
|
+
* and in the plane's daily storage sweep. `rollback-*` records and anything else in the folder are never touched.
|
|
9
|
+
* With no current runtime found, nothing goes: which one is safe to remove is then unknown.
|
|
10
|
+
*/
|
|
11
|
+
import { lstatSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
14
|
+
const DAY_MS = 86_400_000;
|
|
15
|
+
/** A deploy or update that never finished keeps its stage this long (it may be resumed). */
|
|
16
|
+
export const UNFINISHED_KEEP_MS = 7 * DAY_MS;
|
|
17
|
+
export const STAGED_FOLDER = "staged-upgrades";
|
|
18
|
+
/** The canonical path, even of something that doesn't exist (yet): its nearest existing folder, resolved, plus the rest. */
|
|
19
|
+
function real(path) {
|
|
20
|
+
let at = resolve(path);
|
|
21
|
+
const rest = [];
|
|
22
|
+
for (let hops = 0; hops < 256; hops++) {
|
|
23
|
+
try {
|
|
24
|
+
return join(realpathSync(at), ...rest.reverse());
|
|
25
|
+
}
|
|
26
|
+
catch { /* not there */ }
|
|
27
|
+
const up = dirname(at);
|
|
28
|
+
if (up === at)
|
|
29
|
+
break;
|
|
30
|
+
rest.push(basename(at));
|
|
31
|
+
at = up;
|
|
32
|
+
}
|
|
33
|
+
return resolve(path);
|
|
34
|
+
}
|
|
35
|
+
const readJson = (file) => {
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
38
|
+
return parsed !== null && typeof parsed === "object" ? parsed : null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/** The `browser-*` and `release-*` stages: real folders directly in staged-upgrades, newest first. */
|
|
45
|
+
export function stagedRuntimes(stateDir) {
|
|
46
|
+
const root = real(join(stateDir, STAGED_FOLDER));
|
|
47
|
+
let names = [];
|
|
48
|
+
try {
|
|
49
|
+
names = readdirSync(root);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const stages = [];
|
|
55
|
+
for (const name of names) {
|
|
56
|
+
if (!/^(browser|release)-/.test(name))
|
|
57
|
+
continue;
|
|
58
|
+
const path = join(root, name);
|
|
59
|
+
try {
|
|
60
|
+
if (!lstatSync(path).isDirectory())
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const deploy = name.startsWith("browser-") ? readJson(join(path, "deployment.json")) : null;
|
|
67
|
+
const update = name.startsWith("release-") ? readJson(join(path, "update.json")) : null;
|
|
68
|
+
const journal = deploy ?? update;
|
|
69
|
+
const stamp = String(journal?.["deployedAt"] ?? journal?.["startedAt"] ?? journal?.["createdAt"] ?? "");
|
|
70
|
+
let at = Date.parse(stamp);
|
|
71
|
+
if (!Number.isFinite(at)) {
|
|
72
|
+
try {
|
|
73
|
+
at = statSync(path).mtimeMs;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
at = 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const finished = deploy !== null ? deploy["phase"] === "deployed" : update !== null ? typeof update["finishedAt"] === "string" : false;
|
|
80
|
+
const from = update?.["from"];
|
|
81
|
+
const prior = typeof deploy?.["priorRuntime"] === "string" ? deploy["priorRuntime"] : typeof from?.dist === "string" ? from.dist : null;
|
|
82
|
+
let keptAside = false;
|
|
83
|
+
try {
|
|
84
|
+
keptAside = readdirSync(path).some(one => /^orders\.(?:kept|unreadable)\./.test(one));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
keptAside = true;
|
|
88
|
+
}
|
|
89
|
+
stages.push({ path, name, at, finished, prior, keptAside });
|
|
90
|
+
}
|
|
91
|
+
return stages.sort((a, b) => b.at - a.at);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Paths a runtime may be running from: the launchd services' definitions, every `toolroll` (or older
|
|
95
|
+
* `standing-orders`) command on PATH or under nvm, this process, and `extra`.
|
|
96
|
+
*/
|
|
97
|
+
export function runtimesInUse(extra = [], home = homedir(), path = process.env["PATH"] ?? "") {
|
|
98
|
+
const found = [...extra];
|
|
99
|
+
const agents = join(home, "Library", "LaunchAgents");
|
|
100
|
+
try {
|
|
101
|
+
for (const name of readdirSync(agents).filter(one => one.startsWith("com.toolroll.") || one.startsWith("com.standing-orders."))) {
|
|
102
|
+
for (const match of readFileSync(join(agents, name), "utf8").matchAll(/<string>([^<]*staged-upgrades[^<]*)<\/string>/g))
|
|
103
|
+
found.push(match[1]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch { /* no launch agents here */ }
|
|
107
|
+
const commands = path.split(":").filter(Boolean).flatMap(dir => ["toolroll", "standing-orders"].map(bin => join(dir, bin)));
|
|
108
|
+
try {
|
|
109
|
+
for (const version of readdirSync(join(home, ".nvm", "versions", "node")))
|
|
110
|
+
for (const bin of ["toolroll", "standing-orders"])
|
|
111
|
+
commands.push(join(home, ".nvm", "versions", "node", version, "bin", bin));
|
|
112
|
+
}
|
|
113
|
+
catch { /* no nvm */ }
|
|
114
|
+
for (const command of commands) {
|
|
115
|
+
try {
|
|
116
|
+
lstatSync(command);
|
|
117
|
+
found.push(command);
|
|
118
|
+
}
|
|
119
|
+
catch { /* not there */ }
|
|
120
|
+
}
|
|
121
|
+
if (process.argv[1] !== undefined)
|
|
122
|
+
found.push(process.argv[1]);
|
|
123
|
+
return found;
|
|
124
|
+
}
|
|
125
|
+
/** The stage `path` lies in, or null. */
|
|
126
|
+
function stageOf(stages, path) {
|
|
127
|
+
if (path === null || path === undefined || path === "")
|
|
128
|
+
return null;
|
|
129
|
+
for (const at of new Set([resolve(path), real(path)])) {
|
|
130
|
+
for (const stage of stages)
|
|
131
|
+
for (const base of new Set([stage.path, real(stage.path)])) {
|
|
132
|
+
if (at === base || at.startsWith(base + sep))
|
|
133
|
+
return stage;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
/** What stays and what goes now, and why. */
|
|
139
|
+
export function stagePlan(stateDir, inUse, now) {
|
|
140
|
+
const stages = stagedRuntimes(stateDir);
|
|
141
|
+
const keep = new Map();
|
|
142
|
+
const current = inUse.map(path => stageOf(stages, path)).filter((one) => one !== null);
|
|
143
|
+
if (stages.length > 0 && current.length === 0)
|
|
144
|
+
return { keep: [], go: [], refused: "no staged runtime is in use, so which one runs now is unknown" };
|
|
145
|
+
for (const one of current)
|
|
146
|
+
keep.set(one.path, "current");
|
|
147
|
+
for (const one of current) {
|
|
148
|
+
const prior = stageOf(stages, one.prior) ?? stages.find(other => other.finished && other.path !== one.path && other.at < one.at) ?? null;
|
|
149
|
+
if (prior !== null && !keep.has(prior.path))
|
|
150
|
+
keep.set(prior.path, "previous");
|
|
151
|
+
}
|
|
152
|
+
if (stages[0] !== undefined && !keep.has(stages[0].path))
|
|
153
|
+
keep.set(stages[0].path, "newest");
|
|
154
|
+
for (const one of stages) {
|
|
155
|
+
if (keep.has(one.path))
|
|
156
|
+
continue;
|
|
157
|
+
if (one.keptAside)
|
|
158
|
+
keep.set(one.path, "kept-aside database");
|
|
159
|
+
else if (!one.finished && now.getTime() - one.at < UNFINISHED_KEEP_MS)
|
|
160
|
+
keep.set(one.path, "under way");
|
|
161
|
+
}
|
|
162
|
+
return { keep: [...keep].map(([path, why]) => ({ path, why })), go: stages.filter(one => !keep.has(one.path)), refused: null };
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Remove the stages that go. Right before each goes, the plan is made again (what's in use now) and the stage must
|
|
166
|
+
* still be a real folder directly in staged-upgrades.
|
|
167
|
+
*/
|
|
168
|
+
export function pruneStagedRuntimes(stateDir, now, options = {}) {
|
|
169
|
+
const inUse = options.inUse ?? (() => runtimesInUse());
|
|
170
|
+
const first = stagePlan(stateDir, inUse(), now);
|
|
171
|
+
if (first.refused !== null)
|
|
172
|
+
return { removed: [], failed: [], refused: first.refused };
|
|
173
|
+
const root = real(join(stateDir, STAGED_FOLDER));
|
|
174
|
+
const removed = [];
|
|
175
|
+
const failed = [];
|
|
176
|
+
for (const one of first.go) {
|
|
177
|
+
const again = stagePlan(stateDir, inUse(), now);
|
|
178
|
+
if (again.refused !== null || !again.go.some(other => other.path === one.path))
|
|
179
|
+
continue;
|
|
180
|
+
try {
|
|
181
|
+
if (lstatSync(one.path).isSymbolicLink() || dirname(one.path) !== root)
|
|
182
|
+
continue;
|
|
183
|
+
(options.remove ?? (path => rmSync(path, { recursive: true, force: true, maxRetries: 3 })))(one.path);
|
|
184
|
+
removed.push(one.path);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
failed.push(one.path);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { removed, failed, refused: null };
|
|
191
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type Located } from "./orphan-sweep.js";
|
|
2
|
+
import { type ProcessRow } from "./run-closeout.js";
|
|
3
|
+
import type { Store } from "./store.js";
|
|
4
|
+
import type { WorktreePool } from "./worktree.js";
|
|
5
|
+
export declare const SWEEP_EVERY_MS = 86400000;
|
|
6
|
+
/** A sweep that failed somewhere, or left work for later, runs again this soon. */
|
|
7
|
+
export declare const RETRY_MS: number;
|
|
8
|
+
export type SweepKind = "test temp" | "orphans" | "checkouts" | "runtimes" | "dependencies";
|
|
9
|
+
export type SweepPart = {
|
|
10
|
+
kind: SweepKind;
|
|
11
|
+
count: number;
|
|
12
|
+
bytes: number;
|
|
13
|
+
failed: number;
|
|
14
|
+
more?: boolean;
|
|
15
|
+
problem?: string;
|
|
16
|
+
items: string[];
|
|
17
|
+
};
|
|
18
|
+
export type SweepRecord = {
|
|
19
|
+
at: string;
|
|
20
|
+
source: "automatic" | "manual";
|
|
21
|
+
actor: string;
|
|
22
|
+
parts: SweepPart[];
|
|
23
|
+
};
|
|
24
|
+
export type SweepDeps = {
|
|
25
|
+
databaseFile: string;
|
|
26
|
+
pool: WorktreePool | null;
|
|
27
|
+
tempRoots: readonly string[];
|
|
28
|
+
actor?: string;
|
|
29
|
+
census?: () => ProcessRow[];
|
|
30
|
+
locate?: (pids: readonly number[]) => Map<number, Located>;
|
|
31
|
+
inUse?: () => string[];
|
|
32
|
+
graceMs?: number;
|
|
33
|
+
};
|
|
34
|
+
export declare function storageSweepOff(env?: NodeJS.ProcessEnv): boolean;
|
|
35
|
+
export declare function storageSweepDue(store: Store, now: Date): boolean;
|
|
36
|
+
/** The daily sweep, when it's due and on: one worker takes it. Null when it wasn't this pass's to run. */
|
|
37
|
+
export declare function dailyStorageSweep(store: Store, deps: SweepDeps, now: () => Date): Promise<SweepRecord | null>;
|
|
38
|
+
/** One sweep, now, every part; saved and in the ledger. Each part's failure is its own: the others still run. */
|
|
39
|
+
export declare function sweepStorage(store: Store, deps: SweepDeps, now: () => Date): Promise<SweepRecord>;
|
|
40
|
+
/** Save what a sweep or a clean-up by hand did (the latest few are kept), with one ledger entry saying it. */
|
|
41
|
+
export declare function saveSweep(store: Store, record: SweepRecord): SweepRecord;
|
|
42
|
+
/** The latest sweep (of `source`, or either), or null when there has been none. */
|
|
43
|
+
export declare function lastSweep(store: Store, source?: SweepRecord["source"]): SweepRecord | null;
|
|
44
|
+
/** When the next automatic sweep is due, or null when it is off. */
|
|
45
|
+
export declare function nextSweepAt(store: Store): string | null;
|
|
46
|
+
export declare function partWords(one: Pick<SweepPart, "kind" | "count">): string;
|
|
47
|
+
/** "Removed 120 test temp folders and 2 finished checkouts, about 1.2 GB. Stopped 1 leftover process. 3 couldn't be removed." */
|
|
48
|
+
export declare function sweepWords(parts: readonly SweepPart[]): string;
|
|
49
|
+
/** The details a disclosure shows: each part's paths, problems and what's left for later, one line each. */
|
|
50
|
+
export declare function sweepDetails(parts: readonly SweepPart[]): string[];
|