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/builder.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { OBSERVATION_MAILBOX, observationBrief, parseObservationCases, collectObservations } from "./observations.js";
|
|
2
2
|
import { checkCommandFor, effectiveCheckLevel, recordRunCheckLevel } from "./check-levels.js";
|
|
3
|
+
import { queueBatchCheck } from "./batch-checks.js";
|
|
4
|
+
import { batchesFullCheck } from "./batch-policy.js";
|
|
3
5
|
import { skillsContext } from "./project-skills.js";
|
|
4
6
|
import { failedVerificationEvidence, sealVerificationReceipt, verificationEvidence, reuseObservationVerification } from "./verification-evidence.js";
|
|
5
7
|
import { learningContext } from "./project-learning.js";
|
|
@@ -684,7 +686,7 @@ export async function build(store, request) {
|
|
|
684
686
|
// copy links it instead of installing its own; a setup that is about to
|
|
685
687
|
// run never reaches through an old link into a shared copy.
|
|
686
688
|
const depsRoot = sharedDepsRootOf(store);
|
|
687
|
-
const sharing = depsRoot === null ? null : sharingFor({ repo: leased.repo, worktree, setup: setupWanted });
|
|
689
|
+
const sharing = depsRoot === null ? null : sharingFor({ repo: leased.repo, worktree, setup: setupWanted, root: depsRoot });
|
|
688
690
|
if (depsRoot !== null)
|
|
689
691
|
detachShared(worktree, depsRoot);
|
|
690
692
|
if (depsRoot !== null && sharing !== null) {
|
|
@@ -2175,6 +2177,11 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2175
2177
|
const configured = chosenCheck?.command ?? null;
|
|
2176
2178
|
if (repo !== null && chosenCheck !== null)
|
|
2177
2179
|
recordRunCheckLevel(store, { id: runId, taskId: request.taskId, repo }, chosenCheck.level, checkChoice.from, now());
|
|
2180
|
+
// Batch checks (batch-checks.ts): with the project's batching on, a Full check waits to run once with
|
|
2181
|
+
// other results on a temporary batch commit. The build seals no check of its own; the batch's sealed
|
|
2182
|
+
// receipt is this result's check, and Merge waits for it.
|
|
2183
|
+
const batched = repo !== null && chosenCheck !== null && batchesFullCheck(store, repo, chosenCheck.level, configured);
|
|
2184
|
+
const gateCommand = batched ? null : configured;
|
|
2178
2185
|
let verifyCommand;
|
|
2179
2186
|
const checkLog = [];
|
|
2180
2187
|
const checkOutcomes = [];
|
|
@@ -2210,6 +2217,10 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2210
2217
|
else if (configured === null) {
|
|
2211
2218
|
verifyCommand = { configured: false };
|
|
2212
2219
|
}
|
|
2220
|
+
else if (batched && repo !== null) {
|
|
2221
|
+
verifyCommand = { configured: false };
|
|
2222
|
+
queueBatchCheck(store, { runId, taskId: request.taskId, repo, head: sealedHead, base: captured.baseRevision, command: configured }, now());
|
|
2223
|
+
}
|
|
2213
2224
|
else if (!store.proveRunnerCustodyForSpawn(runId, now())) {
|
|
2214
2225
|
verifyCommand = { configured: true, ran: false, attemptFailed: true, failure: "custody-lost" };
|
|
2215
2226
|
recordCheckNote("Verification did not start: this worker no longer owned the build.");
|
|
@@ -2258,7 +2269,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2258
2269
|
const live = linked === null ? null : store.liveWorktreeSetup(repo);
|
|
2259
2270
|
if (linked === null || live === null)
|
|
2260
2271
|
return null;
|
|
2261
|
-
if (sharingFor({ repo, worktree, setup: live })?.key === linked && readyCopy(depsRoot, linked) !== null)
|
|
2272
|
+
if (sharingFor({ repo, worktree, setup: live, root: depsRoot })?.key === linked && readyCopy(depsRoot, linked) !== null)
|
|
2262
2273
|
return null;
|
|
2263
2274
|
if (!store.proveRunnerCustodyForSpawn(runId, now()))
|
|
2264
2275
|
return "custody-lost";
|
|
@@ -2497,7 +2508,7 @@ async function settleProof(captured, statArtifactId, sealedHead) {
|
|
|
2497
2508
|
}
|
|
2498
2509
|
const { verdict, reasons, matrix, machineVerdict } = adjudicate({
|
|
2499
2510
|
directAssessment: true,
|
|
2500
|
-
...(
|
|
2511
|
+
...(gateCommand === null ? {} : { verificationCommand: gateCommand.command }),
|
|
2501
2512
|
proofArtifactPresent,
|
|
2502
2513
|
proofParse,
|
|
2503
2514
|
handoffPresent: handoffArtifact !== null,
|
package/dist/chat-actions.js
CHANGED
|
@@ -5,7 +5,7 @@ import { TEXT_LIMITS } from "./text-limits.js";
|
|
|
5
5
|
import { publicChatText } from "./chat-display.js";
|
|
6
6
|
import { gateWords } from "./approval-policy.js";
|
|
7
7
|
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
-
import { verifiedAuthor } from "./store.js";
|
|
8
|
+
import { mateTurnKeepsProposals, verifiedAuthor } from "./store.js";
|
|
9
9
|
import { isVerifiedApprover, reproveApprover, } from "./principal.js";
|
|
10
10
|
import { changeSkills, importSkill, skillsView, skillsVersion, testSkill, validateSkill, } from "./project-skills.js";
|
|
11
11
|
import { changeKnowledge, knowledgeVersion, knowledgeView, } from "./project-knowledge.js";
|
|
@@ -1025,7 +1025,7 @@ function savedActionContext(store, who, id, state) {
|
|
|
1025
1025
|
session.ceilingDigest !== who.ceilingDigest ||
|
|
1026
1026
|
proposal.ceilingDigest !== who.ceilingDigest ||
|
|
1027
1027
|
(!shared && turn?.session !== session.id) ||
|
|
1028
|
-
turn
|
|
1028
|
+
!mateTurnKeepsProposals(turn))
|
|
1029
1029
|
throw Error("This conversation ended or its project access changed. Ask for a fresh proposal.");
|
|
1030
1030
|
requireActor(store, who, payload.repo, payload.operation);
|
|
1031
1031
|
return payload;
|
package/dist/check-levels-ui.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* one. Every change takes the password and lands in the ledger; approving a
|
|
4
4
|
* command restates exactly what will run, unattended, before the yes. */
|
|
5
5
|
import { CHECK_LEVELS, CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS } from "./check-levels.js";
|
|
6
|
+
import { BATCH_HINT } from "./batch-policy.js";
|
|
6
7
|
const escape = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
7
8
|
export function checkSettingsHtml(view) {
|
|
8
9
|
const hidden = (act) => `<input type="hidden" name="csrf" value="${escape(view.csrf)}"><input type="hidden" name="repo" value="${escape(view.repo)}"><input type="hidden" name="act" value="${act}">`;
|
|
@@ -19,7 +20,7 @@ export function checkSettingsHtml(view) {
|
|
|
19
20
|
(fallsBack ? `<p class="meta">No quick command yet, so builds run the full check.</p>` : "") +
|
|
20
21
|
(view.level !== "full" && view.full !== null ? `<p class="meta">The full check still runs when a pull request opens, and Merge waits for it.</p>` : "");
|
|
21
22
|
if (!view.canChange) {
|
|
22
|
-
return head + `<section class="card check-settings">${current}</section>` + reviewHtml(view, "") +
|
|
23
|
+
return head + `<section class="card check-settings">${current}</section>` + batchHtml(view, "") + reviewHtml(view, "") +
|
|
23
24
|
`<section class="card check-settings"><h2>Quick check</h2>${view.quick === null ? `<p class="meta">None yet.</p>` : command(view.quick)}</section>` +
|
|
24
25
|
`<section class="card check-settings"><h2>Full check</h2>${view.full === null ? `<p class="meta">None yet.</p>` : command(view.full)}</section>`;
|
|
25
26
|
}
|
|
@@ -45,7 +46,25 @@ export function checkSettingsHtml(view) {
|
|
|
45
46
|
`<p class="meta">Quick builds run the full check instead.</p>${password}<button type="submit" class="danger">Remove</button></form></details></section>`;
|
|
46
47
|
const full = `<details class="settings-more"><summary>Full check</summary><section class="card check-settings">` +
|
|
47
48
|
(view.full === null ? `<p class="meta">None yet. Approve one with <span class="mono">toolroll verify set --repo … --command "…"</span>.</p>` : command(view.full)) + `</section></details>`;
|
|
48
|
-
return head + levelForm + reviewHtml(view, password) + quick + full;
|
|
49
|
+
return head + levelForm + batchHtml(view, password) + reviewHtml(view, password) + quick + full;
|
|
50
|
+
}
|
|
51
|
+
/** Batch checks: one state and one sentence; turning it on restates exactly what happens, behind a disclosure with the password. */
|
|
52
|
+
function batchHtml(view, password) {
|
|
53
|
+
const batch = view.batch;
|
|
54
|
+
if (batch === undefined)
|
|
55
|
+
return "";
|
|
56
|
+
const act = batch.on ? "Turn off" : "Turn on";
|
|
57
|
+
const terms = batch.on ? `<p class="meta">Each result runs its own full check again. Results already waiting are checked now.</p>`
|
|
58
|
+
: `<ul class="check-terms"><li>When results finish within 10 minutes of each other, they're merged in a temporary checkout and the full check runs once.</li>` +
|
|
59
|
+
`<li>If it fails, the batch is split until the result that breaks it is found. Results that conflict are checked on their own.</li>` +
|
|
60
|
+
`<li>Nothing is merged into a real branch. Each result still lands on its own.</li></ul>`;
|
|
61
|
+
const change = password === "" ? ""
|
|
62
|
+
: `<details class="settings-more"><summary>${act}</summary><form method="post" action="/settings/checks" class="check-settings">` +
|
|
63
|
+
`<input type="hidden" name="csrf" value="${escape(view.csrf)}"><input type="hidden" name="repo" value="${escape(view.repo)}"><input type="hidden" name="act" value="batch">` +
|
|
64
|
+
`<input type="hidden" name="on" value="${batch.on ? "0" : "1"}">${terms}${password}<button type="submit">${act}</button></form></details>`;
|
|
65
|
+
return `<section class="card check-settings" data-batch-checks="${batch.on ? "on" : "off"}"><h2>Batch checks</h2>` +
|
|
66
|
+
`<p><strong>${batch.on ? "On" : "Off"}</strong> · ${escape(BATCH_HINT)}</p>` +
|
|
67
|
+
(batch.on && view.level !== "full" ? `<p class="meta">Only Full checks are batched.</p>` : "") + `${change}</section>`;
|
|
49
68
|
}
|
|
50
69
|
/** Automatic review: one state, one sentence, and its one change behind a disclosure (the password). */
|
|
51
70
|
function reviewHtml(view, password) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type CheckoutCleanup } from "./storage.js";
|
|
2
2
|
import type { Store, WorktreeRow } from "./store.js";
|
|
3
3
|
import { type WorktreePool } from "./worktree.js";
|
|
4
|
-
/** A release candidate's checkout holds the build a deploy installs:
|
|
4
|
+
/** A release candidate's checkout holds the build a deploy installs: the newest complete one waits at most a week for it. */
|
|
5
5
|
export declare const CANDIDATE_KEEP_MS: number;
|
|
6
6
|
/** The worker's pass removes at most this many; a clean-up by hand has no cap. */
|
|
7
7
|
export declare const AUTO_MAX = 20;
|
|
@@ -43,6 +43,7 @@ type Status = {
|
|
|
43
43
|
export type Deployed = {
|
|
44
44
|
head: string | null;
|
|
45
45
|
run: number | null;
|
|
46
|
+
at?: string | null;
|
|
46
47
|
};
|
|
47
48
|
export declare function deployedRelease(stateDir: string | null): Deployed | null;
|
|
48
49
|
/**
|
package/dist/checkout-cleanup.js
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Always kept: a checkout in use, one whose task isn't finished (on hold, failed, a revision under way), a result
|
|
9
9
|
* still waiting for review, a checkout with changes (files Toolroll wrote itself aside), and one with commits on no
|
|
10
|
-
* branch. A release
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* branch. A release check's checkout (a deploy installs from it) goes once its task is complete, with its dependency
|
|
11
|
+
* install and journey output, except the one deployed now and the project's newest complete one still waiting for its
|
|
12
|
+
* deploy (no deploy since it was complete; at most a week). A superseded or cancelled gate goes at once, whatever its
|
|
13
|
+
* task's state (staged releases keep the rollback copy). A checkout no task names (adopted after a crash) goes only
|
|
13
14
|
* in a clean-up by hand. Every removal is in the ledger.
|
|
14
15
|
*
|
|
15
16
|
* A kept checkout waiting for review or with changes drops its dependencies and build output once its run ends
|
|
@@ -23,7 +24,7 @@ import { COMPLETION_ACTION } from "./result-completion.js";
|
|
|
23
24
|
import { CLEANUP_CHOICES, bytesWords, diskBytes } from "./storage.js";
|
|
24
25
|
import { worktreePath } from "./worktree.js";
|
|
25
26
|
const DAY_MS = 86_400_000;
|
|
26
|
-
/** A release candidate's checkout holds the build a deploy installs:
|
|
27
|
+
/** A release candidate's checkout holds the build a deploy installs: the newest complete one waits at most a week for it. */
|
|
27
28
|
export const CANDIDATE_KEEP_MS = 7 * DAY_MS;
|
|
28
29
|
/** The worker's pass removes at most this many; a clean-up by hand has no cap. */
|
|
29
30
|
export const AUTO_MAX = 20;
|
|
@@ -56,7 +57,7 @@ export function deployedRelease(stateDir) {
|
|
|
56
57
|
continue;
|
|
57
58
|
newest = { at, head: typeof journal["candidate"] === "string" ? journal["candidate"] : null, run: Number.isSafeInteger(journal["builder"]) ? Number(journal["builder"]) : null };
|
|
58
59
|
}
|
|
59
|
-
return newest === null ? null : { head: newest.head, run: newest.run };
|
|
60
|
+
return newest === null ? null : { head: newest.head, run: newest.run, at: newest.at === "" ? null : newest.at };
|
|
60
61
|
}
|
|
61
62
|
/** The folder beside the database (where staged releases live), or null for a database in memory. */
|
|
62
63
|
function stateDirOf(store) {
|
|
@@ -117,6 +118,19 @@ export function taskStatuses(store, now, deployed) {
|
|
|
117
118
|
if (ref !== undefined && (completedRef.get(ref) ?? "") < at)
|
|
118
119
|
completedRef.set(ref, at);
|
|
119
120
|
}
|
|
121
|
+
const completedAtOf = (row) => [completed.get(String(row["id"])), completedRef.get(Number(row["ref"]))].filter((one) => one !== undefined).sort().at(-1) ?? null;
|
|
122
|
+
// Per project, the newest complete gate (by when it was complete): the one a deploy may still be about to install.
|
|
123
|
+
// Once the project deployed after it was complete, it waits for nothing.
|
|
124
|
+
const deployedRepo = deployedRef === undefined ? (live?.head == null ? undefined : gates.find(row => String(row["candidate"]) === live.head)?.["repo"]) : refs.find(row => Number(row["ref"]) === deployedRef)?.["repo"];
|
|
125
|
+
const newestDone = new Map();
|
|
126
|
+
for (const row of gates) {
|
|
127
|
+
if (row["state"] !== "done")
|
|
128
|
+
continue;
|
|
129
|
+
const at = completedAtOf(row) ?? (row["updated_at"] === null ? "" : String(row["updated_at"]));
|
|
130
|
+
const seen = newestDone.get(String(row["repo"]));
|
|
131
|
+
if (seen === undefined || at > seen.at || (at === seen.at && Number(row["ref"]) > seen.ref))
|
|
132
|
+
newestDone.set(String(row["repo"]), { ref: Number(row["ref"]), at });
|
|
133
|
+
}
|
|
120
134
|
const byRef = new Map();
|
|
121
135
|
const byId = new Map();
|
|
122
136
|
const parentOf = new Map();
|
|
@@ -139,11 +153,13 @@ export function taskStatuses(store, now, deployed) {
|
|
|
139
153
|
else if (state === "cancelled")
|
|
140
154
|
status = { keep: null, taskId: id, finishedAt: updated };
|
|
141
155
|
else {
|
|
142
|
-
const completedAt =
|
|
156
|
+
const completedAt = completedAtOf(row);
|
|
143
157
|
if (gate) {
|
|
144
158
|
const since = completedAt ?? updated ?? now.toISOString();
|
|
145
159
|
const until = new Date(Date.parse(since) + CANDIDATE_KEEP_MS).toISOString();
|
|
146
|
-
|
|
160
|
+
const deployedSince = live?.at != null && deployedRepo !== undefined && String(row["repo"]) === String(deployedRepo) && live.at >= since;
|
|
161
|
+
const waiting = newestDone.get(String(row["repo"]))?.ref === ref && !deployedSince && until > now.toISOString();
|
|
162
|
+
status = waiting ? { keep: "release candidate", taskId: id, finishedAt: since, dueAt: until } : { keep: null, taskId: id, finishedAt: since, atOnce: true };
|
|
147
163
|
}
|
|
148
164
|
else
|
|
149
165
|
status = completedAt === null ? { keep: "waiting for review", taskId: id, finishedAt: null } : { keep: null, taskId: id, finishedAt: completedAt };
|
|
@@ -353,7 +369,7 @@ export function whyWords(item) {
|
|
|
353
369
|
case "in use": return "in use";
|
|
354
370
|
case "task not finished": return "its task isn't finished";
|
|
355
371
|
case "waiting for review": return "its result is waiting for review";
|
|
356
|
-
case "release candidate": return item.dueAt === undefined ? "the deployed release candidate" : `newest release candidate, kept until ${item.dueAt.slice(0, 10)}`;
|
|
372
|
+
case "release candidate": return item.dueAt === undefined ? "the deployed release candidate" : `newest release candidate, kept for its deploy until ${item.dueAt.slice(0, 10)}`;
|
|
357
373
|
case "has changes": return "has changes";
|
|
358
374
|
case "has commits": return "has commits on no branch";
|
|
359
375
|
case "unreadable": return "git couldn't read it";
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -125,13 +125,13 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
|
|
|
125
125
|
export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
|
|
126
126
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
127
127
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
128
|
-
error?: never;
|
|
129
128
|
active: boolean;
|
|
130
129
|
phase: string;
|
|
131
130
|
detail: string;
|
|
132
131
|
running: boolean;
|
|
133
132
|
canResume: boolean;
|
|
134
133
|
canCancel: boolean;
|
|
134
|
+
error?: never;
|
|
135
135
|
automaticRecovery?: never;
|
|
136
136
|
recoveryAttempts?: never;
|
|
137
137
|
recoveryRepairs?: never;
|
package/dist/exec.d.ts
CHANGED
|
@@ -56,6 +56,8 @@ export type RunOptions = ProcessTreeObserver & {
|
|
|
56
56
|
* timeoutMs there is deliberately no absolute wall-clock ceiling.
|
|
57
57
|
*/
|
|
58
58
|
idleTimeoutMs?: number;
|
|
59
|
+
/** Ends a process-group run early, exactly as its timeout does: the whole group is killed and the result reads timed out. */
|
|
60
|
+
signal?: AbortSignal;
|
|
59
61
|
maxBuffer?: number;
|
|
60
62
|
/**
|
|
61
63
|
* Extra environment, merged over the process's own. A capability probe is a
|
package/dist/exec.js
CHANGED
|
@@ -526,6 +526,7 @@ export function run(file, args, options = {}) {
|
|
|
526
526
|
return retryTransientSpawn(() => runBufferedGroup(file, args, {
|
|
527
527
|
timeoutMs,
|
|
528
528
|
maxBuffer,
|
|
529
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
529
530
|
...(options.stdin === undefined ? {} : { stdin: options.stdin }),
|
|
530
531
|
...(cwd === undefined ? {} : { cwd }),
|
|
531
532
|
...(childEnv === undefined ? {} : { childEnv }),
|
|
@@ -610,6 +611,15 @@ function runBufferedGroup(file, args, bag) {
|
|
|
610
611
|
timedOut = true;
|
|
611
612
|
killGroup(child);
|
|
612
613
|
}, bag.timeoutMs);
|
|
614
|
+
// The caller's deadline, when it owns one: the same group kill as the timer.
|
|
615
|
+
const aborted = () => {
|
|
616
|
+
timedOut = true;
|
|
617
|
+
killGroup(child);
|
|
618
|
+
};
|
|
619
|
+
if (bag.signal?.aborted === true)
|
|
620
|
+
aborted();
|
|
621
|
+
else
|
|
622
|
+
bag.signal?.addEventListener("abort", aborted, { once: true });
|
|
613
623
|
child.stdout?.setEncoding("utf8");
|
|
614
624
|
child.stdout?.on("data", (chunk) => {
|
|
615
625
|
stdout += chunk;
|
|
@@ -638,6 +648,7 @@ function runBufferedGroup(file, args, bag) {
|
|
|
638
648
|
return;
|
|
639
649
|
settled = true;
|
|
640
650
|
clearTimeout(timer);
|
|
651
|
+
bag.signal?.removeEventListener("abort", aborted);
|
|
641
652
|
liveProviders.delete(child);
|
|
642
653
|
// The OS object is settled BEFORE the answer: a root that exited is
|
|
643
654
|
// not an empty container until the OS says so.
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What the lead can rely on right now, in one read (get_capabilities): the agents a project's route would run, the
|
|
3
|
-
* workers that would run them, the project's tools and skills, the integrations, and the project's checks. Every entry
|
|
4
|
-
* says what it lets the lead do and, when it can't, the one next step and the settings control (show_control) that
|
|
5
|
-
* takes the owner there. Everything here reads what is already recorded — runner readiness reports, sign-in pauses,
|
|
6
|
-
* plan-limit readings, tool tests, skill tests, integration checks — and never probes anything that spends.
|
|
7
|
-
*/
|
|
8
1
|
import type { Store } from "./store.js";
|
|
9
2
|
import type { ChatControl } from "./chat-controls.js";
|
|
10
3
|
import type { ProviderId } from "./provider.js";
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the lead can rely on right now, in one read (get_capabilities): the agents a project's route would run, the
|
|
3
|
+
* workers that would run them, the project's tools and skills, the integrations, and the project's checks. Every entry
|
|
4
|
+
* says what it lets the lead do and, when it can't, the one next step and the settings control (show_control) that
|
|
5
|
+
* takes the owner there. Everything here reads what is already recorded — runner readiness reports, sign-in pauses,
|
|
6
|
+
* plan-limit readings, tool tests, skill tests, integration checks — and never probes anything that spends.
|
|
7
|
+
*/
|
|
8
|
+
import { projectBatchChecks } from "./batch-policy.js";
|
|
1
9
|
import { resolveRouteCandidates } from "./agentconfig.js";
|
|
2
10
|
import { authPauseOf, messageCommand, providerName } from "./provider-auth.js";
|
|
3
11
|
import { readAuthMode, SUBSCRIPTION_CAPABLE } from "./keys.js";
|
|
@@ -136,7 +144,9 @@ function checksOf(store, repo) {
|
|
|
136
144
|
return { ...base, state: "Off", ok: false, next: "Builds are not checked. Turn checks on for the project if results need them.", link: "projects" };
|
|
137
145
|
if (!releaseCheck)
|
|
138
146
|
return { ...base, state: `${CHECK_LEVEL_WORDS[level]} · no release check`, ok: false, next: "No release check is set up, so no result can show a passing check. Set one for the project.", link: "projects" };
|
|
139
|
-
|
|
147
|
+
// Batch checks are changed in Settings → Projects → Checks (or `project checks --batch on|off`), never from chat.
|
|
148
|
+
const batched = level === "full" && projectBatchChecks(store, repo).on ? " · batched with results that finish together" : "";
|
|
149
|
+
return { ...base, state: level === "quick" && !quickCheck ? "Quick · runs the release check" : `${CHECK_LEVEL_WORDS[level]} · release check set${batched}`, ok: true, next: null, link: null };
|
|
140
150
|
}
|
|
141
151
|
function workersOf(store, repos, now) {
|
|
142
152
|
const live = store.handle.prepare(`SELECT ref.external_id AS task, ref.repo AS repo FROM claim JOIN task_ref AS ref ON ref.id = claim.task_ref
|
package/dist/mate-doors.js
CHANGED
|
@@ -15,7 +15,7 @@ import { executeSharedAction, sharedActionPayload, sharedActionAllowsChallenge,
|
|
|
15
15
|
* silent re-read.
|
|
16
16
|
*/
|
|
17
17
|
import { isCheckLevel, setTaskCheckLevel } from "./check-levels.js";
|
|
18
|
-
import { verifiedAuthor } from "./store.js";
|
|
18
|
+
import { mateTurnKeepsProposals, verifiedAuthor } from "./store.js";
|
|
19
19
|
import { isVerifiedApprover, reproveApprover } from "./principal.js";
|
|
20
20
|
import { TeamLeads } from './team-leads.js';
|
|
21
21
|
import { fileTaskProposal } from "./proposal.js";
|
|
@@ -97,7 +97,7 @@ export function confirmMateProposal(store, who, proposalId, now, options) {
|
|
|
97
97
|
return { ok: false, kind: proposal.kind, reason: "ceiling-changed", said: "the admitted projects changed since this was proposed — it cannot be confirmed" };
|
|
98
98
|
}
|
|
99
99
|
const turn = store.getMateTurn(proposal.turn);
|
|
100
|
-
if (turn
|
|
100
|
+
if (!mateTurnKeepsProposals(turn) || proposal.state === "drafting") {
|
|
101
101
|
return { ok: false, kind: proposal.kind, reason: "turn-not-answered", said: "this proposal's turn did not finish — it cannot be confirmed" };
|
|
102
102
|
}
|
|
103
103
|
if (proposal.kind === "cancel") {
|
package/dist/mate.d.ts
CHANGED
|
@@ -93,6 +93,7 @@ export type MateTurnOutcome = {
|
|
|
93
93
|
failed: MateFailure;
|
|
94
94
|
message: string;
|
|
95
95
|
unknownSpend: boolean;
|
|
96
|
+
saved?: boolean;
|
|
96
97
|
};
|
|
97
98
|
/** What happened, what it means, and one next step: plain words a person can act on, never internal terms. */
|
|
98
99
|
export declare const MATE_REFUSAL_COPY: Record<MateRefusal, string>;
|
|
@@ -102,7 +103,7 @@ export declare const MATE_CHANNEL_COPY: Record<MateChannelProblem, string>;
|
|
|
102
103
|
export declare const MATE_FAILURE_COPY: {
|
|
103
104
|
readonly stopped: "This reply was stopped before it finished, from another window or by a newer message. Nothing it proposed was kept. Send your message again if you still need an answer.";
|
|
104
105
|
readonly ended: "Your sign-in or this conversation ended while the lead was answering. Nothing it proposed was kept. Sign in again or start a new chat, then send your message again.";
|
|
105
|
-
readonly tooLong:
|
|
106
|
+
readonly tooLong: string;
|
|
106
107
|
readonly secretRead: "Something the lead read looked like a password or key, so it stopped before sending it anywhere. Nothing was kept. Remove the password or key from that task or note, then ask again.";
|
|
107
108
|
readonly secretReply: "The lead's reply contained something that looked like a password or key, so it was thrown away. Nothing was kept. Ask again, without asking for a password or key.";
|
|
108
109
|
readonly paused: "An earlier reply stopped before its cost was known, so chat is paused. This message wasn't answered. Confirm that earlier reply on the Chat page, then send again.";
|
|
@@ -111,7 +112,7 @@ export declare const MATE_FAILURE_COPY: {
|
|
|
111
112
|
readonly unreadable: "The chat provider sent back an answer that couldn't be read, so it was thrown away. Nothing was kept. Send your message again.";
|
|
112
113
|
readonly signIn: "The chat provider couldn't answer; its sign-in may have expired. Nothing was kept. Sign in to it again on this computer, then send your message again.";
|
|
113
114
|
readonly refused: "The chat provider turned the request down. Nothing was kept or charged. Try again in a minute; if it keeps happening, check the provider in Settings → Lead.";
|
|
114
|
-
readonly tooLongUnknownCost:
|
|
115
|
+
readonly tooLongUnknownCost: string;
|
|
115
116
|
readonly lostUnknownCost: "The chat provider stopped responding partway through, and the cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.";
|
|
116
117
|
readonly unreadableUnknownCost: "The chat provider sent back an answer that couldn't be read, and its cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.";
|
|
117
118
|
readonly empty: "The lead sent back an empty answer. Nothing was kept. Send your message again.";
|
|
@@ -119,6 +120,9 @@ export declare const MATE_FAILURE_COPY: {
|
|
|
119
120
|
};
|
|
120
121
|
/** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry). */
|
|
121
122
|
export declare function mateFailureText(reason: string | null): string;
|
|
123
|
+
/** How long a provider that ignored its abort at the turn's deadline is waited for before the turn ends without it.
|
|
124
|
+
* Its process group was already killed at the deadline; this only bounds the wait for the transport to say so. */
|
|
125
|
+
export declare const MATE_ABORT_GRACE_MS = 5000;
|
|
122
126
|
/** The last messages of the thread as provider-neutral history, newest kept first until the byte cap. */
|
|
123
127
|
export declare function historyFor(store: Store, thread: number, queuedMessageId?: number): MateHistoryMessage[];
|
|
124
128
|
/** "read 3 · proposed 1 · 2 steps" — counts only, never results (ruling 11). */
|
package/dist/mate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { mateToolLabel } from "./mate-progress.js";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
+
import { mateTimeoutNotice } from "./store.js";
|
|
4
5
|
import { RESULT_IMAGES_PER_TURN_CAP } from "./chat-evidence.js";
|
|
5
6
|
import { isVerifiedApprover, reproveApprover } from "./principal.js";
|
|
6
7
|
import { MATE_MAX_CALLS_PER_STEP, MATE_MAX_STEPS, MATE_TOOL_RESULT_CAP_BYTES, TURN_WALL_CLOCK_MS, composeMateRequest, credentialKeyOf, isDirectChatProvider, mateWorstCaseForPrice, performMateRequest, priceForConfig, settleForPrice, subscriptionCredentialKey, } from "./converse.js";
|
|
@@ -56,7 +57,7 @@ export const MATE_CHANNEL_COPY = {
|
|
|
56
57
|
export const MATE_FAILURE_COPY = {
|
|
57
58
|
stopped: "This reply was stopped before it finished, from another window or by a newer message. Nothing it proposed was kept. Send your message again if you still need an answer.",
|
|
58
59
|
ended: "Your sign-in or this conversation ended while the lead was answering. Nothing it proposed was kept. Sign in again or start a new chat, then send your message again.",
|
|
59
|
-
tooLong:
|
|
60
|
+
tooLong: mateTimeoutNotice(false),
|
|
60
61
|
secretRead: "Something the lead read looked like a password or key, so it stopped before sending it anywhere. Nothing was kept. Remove the password or key from that task or note, then ask again.",
|
|
61
62
|
secretReply: "The lead's reply contained something that looked like a password or key, so it was thrown away. Nothing was kept. Ask again, without asking for a password or key.",
|
|
62
63
|
paused: "An earlier reply stopped before its cost was known, so chat is paused. This message wasn't answered. Confirm that earlier reply on the Chat page, then send again.",
|
|
@@ -65,7 +66,7 @@ export const MATE_FAILURE_COPY = {
|
|
|
65
66
|
unreadable: "The chat provider sent back an answer that couldn't be read, so it was thrown away. Nothing was kept. Send your message again.",
|
|
66
67
|
signIn: "The chat provider couldn't answer; its sign-in may have expired. Nothing was kept. Sign in to it again on this computer, then send your message again.",
|
|
67
68
|
refused: "The chat provider turned the request down. Nothing was kept or charged. Try again in a minute; if it keeps happening, check the provider in Settings → Lead.",
|
|
68
|
-
tooLongUnknownCost:
|
|
69
|
+
tooLongUnknownCost: mateTimeoutNotice(false, true),
|
|
69
70
|
lostUnknownCost: "The chat provider stopped responding partway through, and the cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
|
|
70
71
|
unreadableUnknownCost: "The chat provider sent back an answer that couldn't be read, and its cost isn't known, so chat is paused. Nothing it proposed was kept. Confirm the cost on the Chat page to turn chat back on.",
|
|
71
72
|
empty: "The lead sent back an empty answer. Nothing was kept. Send your message again.",
|
|
@@ -74,7 +75,8 @@ export const MATE_FAILURE_COPY = {
|
|
|
74
75
|
/** A failed turn's saved reason as plain words, for a surface that reads the turn back later (a restart, a delivery retry). */
|
|
75
76
|
export function mateFailureText(reason) {
|
|
76
77
|
switch (reason) {
|
|
77
|
-
case "timeout":
|
|
78
|
+
case "timeout":
|
|
79
|
+
case "crashed": return MATE_FAILURE_COPY.tooLong;
|
|
78
80
|
case "malformed-reply": return MATE_FAILURE_COPY.unreadable;
|
|
79
81
|
case "secret-refused": return MATE_FAILURE_COPY.secretReply;
|
|
80
82
|
case "latched": return MATE_FAILURE_COPY.paused;
|
|
@@ -84,6 +86,16 @@ export function mateFailureText(reason) {
|
|
|
84
86
|
default: return "The lead's reply didn't finish. Nothing it proposed was kept. Send your message again.";
|
|
85
87
|
}
|
|
86
88
|
}
|
|
89
|
+
/** How long a provider that ignored its abort at the turn's deadline is waited for before the turn ends without it.
|
|
90
|
+
* Its process group was already killed at the deadline; this only bounds the wait for the transport to say so. */
|
|
91
|
+
export const MATE_ABORT_GRACE_MS = 5_000;
|
|
92
|
+
const LATE = Symbol("late");
|
|
93
|
+
/** The work's own result, or LATE once `ms` passes: the turn's clock, not the work, decides when the person hears back. */
|
|
94
|
+
function settleBy(work, ms) {
|
|
95
|
+
let timer;
|
|
96
|
+
const late = new Promise(resolve => { timer = setTimeout(() => resolve(LATE), Math.max(0, ms)); });
|
|
97
|
+
return Promise.race([work, late]).finally(() => clearTimeout(timer));
|
|
98
|
+
}
|
|
87
99
|
const READ_TOOLS = new Set(["get_brief", "get_project_context", "get_actions", "get_action_status", "get_skills", "get_acceptance_evidence", "recap", "list_repos", "list_tasks", "get_task", "get_result", "get_result_images", "get_controls", "get_agents", "get_project_knowledge", "get_task_conversation", "get_diff", "get_check_log", "get_project_tools", "get_flows", "get_flow_insights", "list_decisions", "get_decision", "queue"]);
|
|
88
100
|
/** The last messages of the thread as provider-neutral history, newest kept first until the byte cap. */
|
|
89
101
|
export function historyFor(store, thread, queuedMessageId) {
|
|
@@ -291,6 +303,20 @@ export async function runMateTurn(input) {
|
|
|
291
303
|
store.dropMateAsk(turnId);
|
|
292
304
|
return { ok: false, turn: turnId, failed, message, unknownSpend };
|
|
293
305
|
};
|
|
306
|
+
/** The turn stopped at its deadline: the notice is saved in the thread with whatever its completed tool calls proposed, in
|
|
307
|
+
* the same write that ends the turn, so a refresh, another device or a restart shows the same outcome. */
|
|
308
|
+
const timedOut = (unknownSpend) => {
|
|
309
|
+
now = clock();
|
|
310
|
+
const message = mateTimeoutNotice(proposals > 0, unknownSpend);
|
|
311
|
+
const saved = store.finalizeMateTurn(turnId, started.generation, { state: "failed", settledMicrousd: settled, unknownSpend, tokensIn, tokensOut, failureReason: "timeout", keepProposals: true, message: { text: message, activity: activitySummary(reads, proposals, steps) } }, now);
|
|
312
|
+
store.dropMateAsk(turnId);
|
|
313
|
+
if (!saved)
|
|
314
|
+
return { ok: false, turn: turnId, failed: "superseded", message: MATE_FAILURE_COPY.stopped, unknownSpend: false };
|
|
315
|
+
return { ok: false, turn: turnId, failed: "timeout", message, unknownSpend, saved: true };
|
|
316
|
+
};
|
|
317
|
+
const deadlineLeft = () => TURN_WALL_CLOCK_MS - (clock().getTime() - turnStartedAt);
|
|
318
|
+
/** The channel's standing, bounded by the turn's deadline: a lookup that never answers cannot hold the turn open. */
|
|
319
|
+
const revalidated = async () => input.revalidate === undefined ? { ok: true } : settleBy(input.revalidate(), deadlineLeft() + MATE_ABORT_GRACE_MS);
|
|
294
320
|
/** The turn's own row, re-read: still running under our generation, or someone ended it under us. */
|
|
295
321
|
const stillOurs = () => {
|
|
296
322
|
const row = store.getMateTurn(turnId);
|
|
@@ -301,6 +327,8 @@ export async function runMateTurn(input) {
|
|
|
301
327
|
const guard = (channel) => {
|
|
302
328
|
if (!stillOurs())
|
|
303
329
|
return { ok: false, turn: turnId, failed: "superseded", message: MATE_FAILURE_COPY.stopped, unknownSpend: false };
|
|
330
|
+
if (channel === LATE)
|
|
331
|
+
return timedOut(false);
|
|
304
332
|
if (!channel.ok)
|
|
305
333
|
return fail("revoked", MATE_CHANNEL_COPY[channel.reason] ?? MATE_CHANNEL_COPY["access-changed"], false);
|
|
306
334
|
const standing = reproveApprover(store, who);
|
|
@@ -328,13 +356,13 @@ export async function runMateTurn(input) {
|
|
|
328
356
|
return claim;
|
|
329
357
|
};
|
|
330
358
|
while (steps < maxSteps) {
|
|
331
|
-
const blocked = guard(
|
|
359
|
+
const blocked = guard(await revalidated());
|
|
332
360
|
if (blocked !== null)
|
|
333
361
|
return blocked;
|
|
334
362
|
now = clock();
|
|
335
363
|
const remainingMs = TURN_WALL_CLOCK_MS - (now.getTime() - turnStartedAt);
|
|
336
364
|
if (remainingMs <= 0)
|
|
337
|
-
return
|
|
365
|
+
return timedOut(false);
|
|
338
366
|
const request = direct ? composeDirect(input.key) : composeSubscription();
|
|
339
367
|
// Tool results join the outbound body: scanned again before every dispatch.
|
|
340
368
|
const outbound = typeof request === "string" ? request : request.body;
|
|
@@ -354,39 +382,34 @@ export async function runMateTurn(input) {
|
|
|
354
382
|
progress({ kind: "step", turn: turnId, step: steps });
|
|
355
383
|
const requestBytes = Buffer.byteLength(outbound, "utf8");
|
|
356
384
|
let result;
|
|
385
|
+
// The turn owns its deadline (lead-stall, release check 2438): at the deadline the request is aborted, which ends a
|
|
386
|
+
// harness's whole process group, and a provider that still has not settled MATE_ABORT_GRACE_MS later is left behind.
|
|
387
|
+
// Whatever it says after that is never read; the turn has already ended with its notice.
|
|
388
|
+
const controller = new AbortController();
|
|
389
|
+
const abortAt = setTimeout(() => controller.abort(), remainingMs);
|
|
390
|
+
const settle = (work) => settleBy(work, remainingMs + MATE_ABORT_GRACE_MS)
|
|
391
|
+
.then(value => value === LATE || controller.signal.aborted ? { ok: false, problem: "timeout" } : value)
|
|
392
|
+
.finally(() => clearTimeout(abortAt));
|
|
357
393
|
if (direct) {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
try {
|
|
361
|
-
result = await performMateRequest(request, directProvider, controller.signal, fetcher);
|
|
362
|
-
}
|
|
363
|
-
catch {
|
|
364
|
-
result = { ok: false, problem: "network" };
|
|
365
|
-
}
|
|
366
|
-
finally {
|
|
367
|
-
clearTimeout(timer);
|
|
368
|
-
}
|
|
394
|
+
result = await settle(performMateRequest(request, directProvider, controller.signal, fetcher)
|
|
395
|
+
.catch(() => ({ ok: false, problem: "network" })));
|
|
369
396
|
}
|
|
370
397
|
else {
|
|
371
398
|
const runner = input.subscriptionRunner ?? performSubscriptionMateRequest;
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
387
|
-
catch {
|
|
388
|
-
result = { ok: false, problem: "provider-error" };
|
|
389
|
-
}
|
|
399
|
+
result = await settle((async () => runner({
|
|
400
|
+
provider: subscriptionProvider,
|
|
401
|
+
model: config.model,
|
|
402
|
+
system: MATE_CONTRACT,
|
|
403
|
+
dataDocument: document,
|
|
404
|
+
history,
|
|
405
|
+
tools: MATE_TOOL_SCHEMAS,
|
|
406
|
+
timeoutMs: remainingMs,
|
|
407
|
+
signal: controller.signal,
|
|
408
|
+
// The reply as it is written; text that looks like a secret is
|
|
409
|
+
// never shown (the finished reply is scanned again before saving).
|
|
410
|
+
...(input.onProgress === undefined ? {} : { onText: (text) => { if (!controller.signal.aborted && scanForSecrets(text).length === 0)
|
|
411
|
+
progress({ kind: "text", turn: turnId, step: steps, text }); } }),
|
|
412
|
+
}))().catch(() => ({ ok: false, problem: "provider-error" })));
|
|
390
413
|
}
|
|
391
414
|
now = clock();
|
|
392
415
|
const finishStep = (outcome) => store.finalizeChatTurn(step.id, stepStarted.generation, outcome, now);
|
|
@@ -400,7 +423,9 @@ export async function runMateTurn(input) {
|
|
|
400
423
|
: result.problem === "malformed-reply"
|
|
401
424
|
? MATE_FAILURE_COPY.unreadable
|
|
402
425
|
: MATE_FAILURE_COPY.signIn;
|
|
403
|
-
|
|
426
|
+
if (result.problem === "timeout")
|
|
427
|
+
return timedOut(false);
|
|
428
|
+
return fail(result.problem === "malformed-reply" ? "malformed-reply" : "provider-error", message, false);
|
|
404
429
|
}
|
|
405
430
|
if (result.problem.startsWith("status-")) {
|
|
406
431
|
// The provider ANSWERED with an error: nothing billed for this step.
|
|
@@ -409,7 +434,7 @@ export async function runMateTurn(input) {
|
|
|
409
434
|
}
|
|
410
435
|
if (result.problem === "timeout") {
|
|
411
436
|
finishStep({ state: "failed", failureReason: "timeout", settledMicrousd: null, unknownSpend: true });
|
|
412
|
-
return
|
|
437
|
+
return timedOut(true);
|
|
413
438
|
}
|
|
414
439
|
if (result.problem === "network") {
|
|
415
440
|
finishStep({ state: "failed", failureReason: "provider-error", settledMicrousd: null, unknownSpend: true });
|
|
@@ -437,7 +462,7 @@ export async function runMateTurn(input) {
|
|
|
437
462
|
// After the wait (finding 5): the row may have been failed under us by
|
|
438
463
|
// a revocation or the sweep — then nothing the model said runs; and the
|
|
439
464
|
// approver must still stand before any tool runs as them.
|
|
440
|
-
const changed = guard(
|
|
465
|
+
const changed = guard(await revalidated());
|
|
441
466
|
if (changed !== null)
|
|
442
467
|
return changed;
|
|
443
468
|
if (answer.calls.length === 0) {
|
|
@@ -470,7 +495,7 @@ export async function runMateTurn(input) {
|
|
|
470
495
|
history.push({ role: "assistant", text: answer.text, calls: answer.calls });
|
|
471
496
|
for (const [index, call] of answer.calls.entries()) {
|
|
472
497
|
if (index > 0) {
|
|
473
|
-
const changed = guard(
|
|
498
|
+
const changed = guard(await revalidated());
|
|
474
499
|
if (changed !== null)
|
|
475
500
|
return changed;
|
|
476
501
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spotlight leaves Toolroll's churn alone: a `.metadata_never_index` file in a folder tells macOS not to index it or
|
|
3
|
+
* what is inside. Toolroll puts one in the folders it fills and empties all day — the checkouts folder and each
|
|
4
|
+
* project's folder in it (never inside a checkout, where a build's `git add -A` would commit it), shared dependency
|
|
5
|
+
* installs, staged runtimes and test temp roots. Measured Oct 4: fseventsd at 15 GB with 107k test temp folders.
|
|
6
|
+
*
|
|
7
|
+
* A marker that can't be written is only noted: it never decides whether anything is removed.
|
|
8
|
+
*/
|
|
9
|
+
import { writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
export const NEVER_INDEX = ".metadata_never_index";
|
|
12
|
+
/** Put the marker in `dir` (an existing folder). False when it couldn't be written. */
|
|
13
|
+
export function markNeverIndex(dir) {
|
|
14
|
+
try {
|
|
15
|
+
writeFileSync(join(dir, NEVER_INDEX), "", { flag: "a" });
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|