toolroll 0.9.31 → 0.9.33
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 +30 -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/coding-context.js +2 -2
- package/dist/coding-handoff.js +2 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/exec.d.ts +2 -0
- package/dist/exec.js +11 -0
- package/dist/guides.js +3 -0
- package/dist/knowledge-cli.d.ts +31 -0
- package/dist/knowledge-cli.js +35 -2
- package/dist/knowledge-ui.d.ts +1 -1
- package/dist/knowledge-ui.js +7 -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 +99 -5
- package/dist/orphan-sweep.d.ts +63 -0
- package/dist/orphan-sweep.js +235 -0
- package/dist/project-delete.js +1 -1
- package/dist/project-knowledge.d.ts +28 -3
- package/dist/project-knowledge.js +64 -25
- package/dist/project-learning.d.ts +33 -0
- package/dist/project-learning.js +105 -15
- package/dist/project-skills.js +27 -21
- package/dist/result-follow-ups.d.ts +22 -2
- package/dist/result-follow-ups.js +44 -10
- package/dist/serve.js +33 -11
- 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 +2 -1
- 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/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
|
+
}
|
package/dist/operate.js
CHANGED
|
@@ -3,6 +3,8 @@ import { leadNameOf } from "./lead-identity.js";
|
|
|
3
3
|
import { maybeTriggerRepair } from "./dispose.js";
|
|
4
4
|
import { CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, setTaskCheckLevel, suggestQuickCommand } from "./check-levels.js";
|
|
5
5
|
import { fileAddTestsTask, followUpChecksOf, requestFollowUpChecks, runFollowUpCheck, runWaitingChecks } from "./result-follow-ups.js";
|
|
6
|
+
import { runBatchChecks } from "./batch-checks.js";
|
|
7
|
+
import { BATCH_HINT, BATCH_WINDOW_MS, projectBatchChecks, setProjectBatchChecks } from "./batch-policy.js";
|
|
6
8
|
import { buildReviewPass } from "./build-review.js";
|
|
7
9
|
import { buildReviewLines, buildReviewOf } from "./review-switch.js";
|
|
8
10
|
import { parseProtectedPaths } from "./approval-policy.js";
|
|
@@ -11,6 +13,7 @@ import { evidencePack, exportDay, ledgerExportChunks, standaloneEvidenceHtml } f
|
|
|
11
13
|
import { matchesOutsideCheckpoint } from "./ledger-chain.js";
|
|
12
14
|
import { CLEANUP_CHOICES, bytesWords, cleanupWords, diskBytes, parseCleanup, storageReport } from "./storage.js";
|
|
13
15
|
import { removeStaleTestTemp, tempRoots, testTempFolders } from "./test-temp.js";
|
|
16
|
+
import { dailyStorageSweep, lastSweep, nextSweepAt, saveSweep, storageSweepOff as storageSweepOffNow, sweepDetails, sweepWords as storageSweepWords } from "./storage-sweep.js";
|
|
14
17
|
import { removeShared, sharedDepsRoot, sharedUse, unusedShared } from "./shared-deps.js";
|
|
15
18
|
import { checkoutPlan, cleanCheckouts, discardCheckout, finishedCheckouts, slimKeptCheckouts, whyWords } from "./checkout-cleanup.js";
|
|
16
19
|
import { closeOutCheckouts, closeOutRuns, stoppedWords } from "./run-closeout.js";
|
|
@@ -45,7 +48,7 @@ import { loadSlackCredentials } from "./slack-api.js";
|
|
|
45
48
|
import { followSlack } from "./slack.js";
|
|
46
49
|
import { validateScopeText } from "./task-text.js";
|
|
47
50
|
import { runMemoryCommand } from "./memory-cli.js";
|
|
48
|
-
import { runKnowledgeCommand } from "./knowledge-cli.js";
|
|
51
|
+
import { runKnowledgeApply, runKnowledgeCommand } from "./knowledge-cli.js";
|
|
49
52
|
import { FLOWS_VALUE_FLAGS, runFlowsCommand } from "./flows-cli.js";
|
|
50
53
|
import { runAssignmentCommand } from "./assignment-adapters.js";
|
|
51
54
|
import { applyProjectProfile, runProjectCommand } from "./project-cli.js";
|
|
@@ -996,6 +999,8 @@ async function dispatch(command, positional, flags, context) {
|
|
|
996
999
|
return projectDeleteCommand(positional, flags, context);
|
|
997
1000
|
if (positional[0] === "concurrency")
|
|
998
1001
|
return projectConcurrencyCommand(positional, flags, context);
|
|
1002
|
+
if (positional[0] === "checks")
|
|
1003
|
+
return projectChecksCommand(positional, flags, context);
|
|
999
1004
|
if (positional[0] === "demo")
|
|
1000
1005
|
return projectDemoCommand(positional, flags, context);
|
|
1001
1006
|
return runProjectCommand(positional, flags, context);
|
|
@@ -1004,8 +1009,18 @@ async function dispatch(command, positional, flags, context) {
|
|
|
1004
1009
|
if (positional[0] === "claim" && currentActor()?.lead === true && !flags.has("token-env") && !flags.has("token-file"))
|
|
1005
1010
|
return leadClaimCommand(positional, flags, context);
|
|
1006
1011
|
return runAssignmentCommand(positional, flags, context);
|
|
1007
|
-
case "knowledge":
|
|
1008
|
-
|
|
1012
|
+
case "knowledge": {
|
|
1013
|
+
if (positional[0] !== "apply")
|
|
1014
|
+
return runKnowledgeCommand(positional, flags, { ...context, now: context.clock() });
|
|
1015
|
+
if (positional.length !== 1)
|
|
1016
|
+
return fail(context.write, context.json, "knowledge apply", "usage", "knowledge apply takes no query.", EXIT.usage);
|
|
1017
|
+
// A person's password, never the lead's token: the lead may not carry knowledge forward.
|
|
1018
|
+
const acting = currentActor()?.lead === true ? null : await askCredentials(flags, context);
|
|
1019
|
+
const repo = text(flags, "repo");
|
|
1020
|
+
const lead = acting !== null && parseLeadToken(acting.token) !== null;
|
|
1021
|
+
const verified = acting === null || lead ? null : authenticateApprover(context.store, acting.name, acting.token, repo === undefined ? null : resolve(repo));
|
|
1022
|
+
return runKnowledgeApply(flags, { ...context, now: context.clock(), actor: verified !== null && verified.ok ? acting.name : null });
|
|
1023
|
+
}
|
|
1009
1024
|
case "models": {
|
|
1010
1025
|
const acting = await askCredentials(flags, context);
|
|
1011
1026
|
const verified = acting === null ? null : authenticateApprover(context.store, acting.name, acting.token);
|
|
@@ -4541,7 +4556,18 @@ async function reconcileCommand(flags, context) {
|
|
|
4541
4556
|
retention = null; /* the next pass tries again */
|
|
4542
4557
|
}
|
|
4543
4558
|
const retained = retention === null ? [] : retention.counts.filter(one => one.count > 0);
|
|
4559
|
+
// The daily storage sweep (storage-sweep.ts): stale test temp folders, orphans Toolroll left running, finished
|
|
4560
|
+
// checkouts, extra staged runtimes and dependency installs unused for a week; saved for Settings → Storage.
|
|
4561
|
+
let storageSwept = null;
|
|
4562
|
+
try {
|
|
4563
|
+
storageSwept = await dailyStorageSweep(store, { databaseFile: context.databaseFile, pool: worktrees, tempRoots: context.tempRoots ?? tempRoots() }, clock);
|
|
4564
|
+
}
|
|
4565
|
+
catch {
|
|
4566
|
+
storageSwept = null; /* the next pass tries again */
|
|
4567
|
+
}
|
|
4568
|
+
const swept = storageSwept === null ? [] : storageSwept.parts.filter(one => one.count > 0);
|
|
4544
4569
|
const nothing = retained.length === 0 &&
|
|
4570
|
+
swept.length === 0 &&
|
|
4545
4571
|
recovered.length === 0 &&
|
|
4546
4572
|
reaped.length === 0 &&
|
|
4547
4573
|
adoption.adopted.length === 0 &&
|
|
@@ -4562,6 +4588,7 @@ async function reconcileCommand(flags, context) {
|
|
|
4562
4588
|
checkoutsSlimmed: slimmed.slimmed.map(one => one.path),
|
|
4563
4589
|
liveViewsSwept: liveSwept.removed.length,
|
|
4564
4590
|
retention: retention === null ? null : { counts: retention.counts, freedBytes: retention.freed },
|
|
4591
|
+
storageSweep: storageSwept,
|
|
4565
4592
|
}, () => nothing
|
|
4566
4593
|
? ["Nothing to reconcile. Everything is where it should be."]
|
|
4567
4594
|
: [
|
|
@@ -4575,6 +4602,7 @@ async function reconcileCommand(flags, context) {
|
|
|
4575
4602
|
...(slimmed.slimmed.length === 0 ? [] : [`Dropped dependencies and build output from ${slimmed.slimmed.length} kept checkout(s), about ${bytesWords(slimmed.slimmed.reduce((sum, one) => sum + one.bytes, 0))}.`]),
|
|
4576
4603
|
...(liveSwept.removed.length === 0 ? [] : [`Cleared ${liveSwept.removed.length} finished live view(s).`]),
|
|
4577
4604
|
...(retention === null || retained.length === 0 ? [] : [`Retention removed ${sweepWords(retention.counts, retention.freed)}.`]),
|
|
4605
|
+
...(storageSwept === null || swept.length === 0 ? [] : [`Storage sweep: ${storageSweepWords(storageSwept.parts)}`]),
|
|
4578
4606
|
]);
|
|
4579
4607
|
}
|
|
4580
4608
|
// ---- gaps -----------------------------------------------------------------
|
|
@@ -7658,11 +7686,15 @@ async function runWatchLoop(args) {
|
|
|
7658
7686
|
run: async () => {
|
|
7659
7687
|
if (store.isDemo())
|
|
7660
7688
|
return;
|
|
7689
|
+
// Batch checks first: a cohort that is ready runs one check for all its results.
|
|
7690
|
+
const batched = await runBatchChecks(store, context.evidenceRoot, { now: context.clock, shouldStop: stopping });
|
|
7691
|
+
if (batched > 0)
|
|
7692
|
+
progress(`watch: settled ${batched} batch-checked result(s)`);
|
|
7661
7693
|
const ran = await runWaitingChecks(store, context.evidenceRoot, { now: context.clock, shouldStop: stopping });
|
|
7662
7694
|
if (ran > 0)
|
|
7663
7695
|
progress(`watch: ran ${ran} follow-up check(s)`);
|
|
7664
7696
|
// A finished check may send a build's card down its failure path: now, not on the next beat.
|
|
7665
|
-
if (ran > 0)
|
|
7697
|
+
if (ran + batched > 0)
|
|
7666
7698
|
moveCards(store, repo, context.clock(), context.evidenceRoot);
|
|
7667
7699
|
},
|
|
7668
7700
|
});
|
|
@@ -12231,6 +12263,45 @@ async function projectConcurrencyCommand(positional, flags, context) {
|
|
|
12231
12263
|
...(changed.before > changed.after ? ["Builds already running carry on; the new number applies to the next one."] : []),
|
|
12232
12264
|
]);
|
|
12233
12265
|
}
|
|
12266
|
+
/** `project checks [--batch on|off] --repo <p>`: whether the project's results share one full check when they
|
|
12267
|
+
* finish close together (batch-checks.ts). Off by default; changing it is an approver's act, in the ledger. */
|
|
12268
|
+
async function projectChecksCommand(positional, flags, context) {
|
|
12269
|
+
const command = "project checks";
|
|
12270
|
+
const allowed = new Set(["repo", "batch", "as", "token", "token-file", "token-env", "db", "json"]);
|
|
12271
|
+
for (const name of flags.keys())
|
|
12272
|
+
if (!allowed.has(name))
|
|
12273
|
+
return fail(context.write, context.json, command, "usage", `--${name} is not a project checks option.`, EXIT.usage);
|
|
12274
|
+
const repoFlag = text(flags, "repo");
|
|
12275
|
+
const wanted = flags.get("batch");
|
|
12276
|
+
if (positional.length > 1 || repoFlag === undefined || (wanted !== undefined && wanted !== "on" && wanted !== "off")) {
|
|
12277
|
+
return fail(context.write, context.json, command, "usage", "Use project checks [--batch on|off] --repo <project path>.", EXIT.usage);
|
|
12278
|
+
}
|
|
12279
|
+
const registered = await loadRepos(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
|
|
12280
|
+
const known = [...new Set([...context.store.knownRepos(), ...context.store.listProjects().map(one => one.path), ...("error" in registered ? [] : registered.repos)])];
|
|
12281
|
+
const repo = known.find(one => one === repoFlag || one === resolve(repoFlag) || one === canonicalProject(repoFlag));
|
|
12282
|
+
if (repo === undefined)
|
|
12283
|
+
return fail(context.write, context.json, command, "not-found", "That isn't a project Toolroll knows.", EXIT.refused);
|
|
12284
|
+
const level = projectCheckLevel(context.store, repo).level;
|
|
12285
|
+
const words = (on) => [
|
|
12286
|
+
`${repo}: batch checks are ${on ? "on" : "off"}. ${on ? BATCH_HINT : "Each result runs its own full check."}`,
|
|
12287
|
+
...(on && level !== "full" ? [`Checks are ${CHECK_LEVEL_WORDS[level]} for this project, so nothing is batched until they are Full.`] : []),
|
|
12288
|
+
];
|
|
12289
|
+
if (wanted === undefined) {
|
|
12290
|
+
const current = projectBatchChecks(context.store, repo);
|
|
12291
|
+
return succeed(context.write, context.json, command, { repo, batch: current.on, windowMinutes: current.windowMs / 60_000, level, setBy: current.setBy }, () => words(current.on));
|
|
12292
|
+
}
|
|
12293
|
+
const acting = await askCredentials(flags, context);
|
|
12294
|
+
const verified = acting === null ? null : authenticateApprover(context.store, acting.name, acting.token, repo);
|
|
12295
|
+
if (acting === null || verified === null || !verified.ok || !context.store.accountCanAccess(acting.name, repo)) {
|
|
12296
|
+
return fail(context.write, context.json, command, "refused", "An approver for this project changes batch checks: pass --as and --token (or use the remembered login).", EXIT.refused);
|
|
12297
|
+
}
|
|
12298
|
+
const on = wanted === "on";
|
|
12299
|
+
const changed = setProjectBatchChecks(context.store, repo, on, acting.name, context.clock());
|
|
12300
|
+
return succeed(context.write, context.json, command, { repo, batch: on, before: changed.before, changed: changed.changed, windowMinutes: BATCH_WINDOW_MS / 60_000, level }, () => [
|
|
12301
|
+
...words(on),
|
|
12302
|
+
...(changed.changed && !on ? ["Results already waiting are checked now."] : []),
|
|
12303
|
+
]);
|
|
12304
|
+
}
|
|
12234
12305
|
/** `project demo [<url>|off] --repo <p>` (review 827): the project's own demo or dev server, which its scouts may
|
|
12235
12306
|
* screenshot. Changing it is an approver's act. */
|
|
12236
12307
|
async function projectDemoCommand(positional, flags, context) {
|
|
@@ -12625,17 +12696,33 @@ async function storageCommand(positional, flags, context) {
|
|
|
12625
12696
|
return copies.map(one => ({ key: one.key, repo: one.repo, path: one.dir, bytes: sizes.get(one.dir) ?? 0, checkouts: one.checkouts.length, usedBy: one.checkouts, retired: one.retired, node: one.node, createdAt: one.createdAt, usedAt: one.usedAt }));
|
|
12626
12697
|
};
|
|
12627
12698
|
const sharedLine = (one) => ` ${bytesWords(one.bytes).padStart(8)} ${projectName(one.repo)} ${one.key.slice(0, 8)}: ${one.checkouts === 1 ? "1 checkout uses it" : `${one.checkouts} checkouts use it`}${one.retired ? " (changed after install; never linked again)" : ""}`;
|
|
12699
|
+
// What the latest sweep (automatic, or a clean-up by hand) did and when (storage-sweep.ts).
|
|
12700
|
+
const sweepFacts = () => {
|
|
12701
|
+
const last = lastSweep(store);
|
|
12702
|
+
const automatic = last?.source === "automatic" ? last : lastSweep(store, "automatic");
|
|
12703
|
+
return { last, lastAutomatic: automatic, next: nextSweepAt(store) };
|
|
12704
|
+
};
|
|
12705
|
+
const sweepLines = (facts) => {
|
|
12706
|
+
const when = (at) => new Date(at).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
12707
|
+
const one = facts.last;
|
|
12708
|
+
const head = one === null ? (facts.next === null && storageSweepOffNow() ? "Automatic sweep: off." : "Automatic sweep: not run yet; it runs once a day.")
|
|
12709
|
+
: `Last swept ${when(one.at)}${one.source === "manual" ? ` by ${one.actor}` : " automatically"}: ${storageSweepWords(one.parts)}`;
|
|
12710
|
+
const auto = one !== null && one.source === "manual" && facts.lastAutomatic !== null ? [`Last automatic sweep ${when(facts.lastAutomatic.at)}: ${storageSweepWords(facts.lastAutomatic.parts)}`] : [];
|
|
12711
|
+
return [head, ...auto, ...(one === null ? [] : sweepDetails(one.parts).slice(0, 8).map(line => ` ${line}`))];
|
|
12712
|
+
};
|
|
12628
12713
|
if (action === undefined) {
|
|
12629
12714
|
const plan = await checkoutPlan(store, pool, context.clock(), { manual: true });
|
|
12630
12715
|
const report = storageReport(store, context.databaseFile, plan.go.length === 0 ? "nothing to clean up" : `about ${bytesWords(plan.freeBytes)} to clean up`);
|
|
12631
12716
|
const { staleBytes: _, ...temp } = leftovers(false);
|
|
12632
12717
|
const shared = sharedFacts(sharedUse(depsRoot, checkoutRows()));
|
|
12633
|
-
|
|
12718
|
+
const sweep = sweepFacts();
|
|
12719
|
+
return succeed(context.write, context.json, "storage", { ...report, checkouts: checkoutSummary(plan), testTemp: temp, shared, sweep }, () => [
|
|
12634
12720
|
`${report.folder}: ${bytesWords(report.total)}`,
|
|
12635
12721
|
...report.lines.map(one => ` ${one.what.padEnd(19)} ${bytesWords(one.bytes).padStart(8)}${one.count === undefined ? "" : ` (${one.count})`}${one.note === undefined ? "" : ` ${one.note}`}`),
|
|
12636
12722
|
...(shared.length === 0 ? [] : ["Shared dependencies:", ...shared.map(sharedLine)]),
|
|
12637
12723
|
...summary(plan),
|
|
12638
12724
|
leftoverLine({ ...temp, staleBytes: 0 }),
|
|
12725
|
+
...sweepLines(sweep),
|
|
12639
12726
|
]);
|
|
12640
12727
|
}
|
|
12641
12728
|
if (action === "cleanup") {
|
|
@@ -12692,6 +12779,13 @@ async function storageCommand(positional, flags, context) {
|
|
|
12692
12779
|
for (const debris of unused.debris)
|
|
12693
12780
|
removeShared(debris);
|
|
12694
12781
|
const sharedFreed = sharedRemoved.reduce((sum, one) => sum + one.bytes, 0);
|
|
12782
|
+
// Saved like an automatic sweep, so Storage shows what this clean-up did and when.
|
|
12783
|
+
const parts = [
|
|
12784
|
+
{ 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) },
|
|
12785
|
+
{ kind: "test temp", count: swept.removed.length, bytes: temp.staleBytes, failed: swept.failed.length, items: swept.removed.slice(0, 20) },
|
|
12786
|
+
{ kind: "dependencies", count: sharedRemoved.length, bytes: sharedFreed, failed: unused.copies.length - sharedRemoved.length, items: sharedRemoved.slice(0, 20).map(one => one.path) },
|
|
12787
|
+
];
|
|
12788
|
+
saveSweep(store, { at: context.clock().toISOString(), source: "manual", actor, parts });
|
|
12695
12789
|
return succeed(context.write, context.json, command, { removed: done.removed, kept: done.kept, freedBytes: done.freed, testTempRemoved: swept.removed.length, testTempFailed: swept.failed, sharedRemoved, sharedFreedBytes: sharedFreed }, () => [
|
|
12696
12790
|
done.removed.length === 0 ? (swept.removed.length === 0 && sharedRemoved.length === 0 ? "Nothing to clean up." : "No checkouts to clean up.") : `Removed ${done.removed.length} checkout${done.removed.length === 1 ? "" : "s"}, about ${bytesWords(done.freed)}; their branches stay.`,
|
|
12697
12791
|
...done.removed.map(itemLine),
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type ProcessRow, type StopOptions } from "./run-closeout.js";
|
|
2
|
+
import type { Store } from "./store.js";
|
|
3
|
+
import { type TempOwner } from "./test-temp.js";
|
|
4
|
+
import type { WorktreePool } from "./worktree.js";
|
|
5
|
+
/** Where a process runs from: its working folder and executable, when the OS says. */
|
|
6
|
+
export type Located = {
|
|
7
|
+
cwd?: string;
|
|
8
|
+
exe?: string;
|
|
9
|
+
};
|
|
10
|
+
/** Working folder and executable of each pid: /proc on Linux, lsof on macOS. Throws when the OS can't be asked. */
|
|
11
|
+
export declare function locateProcesses(pids: readonly number[]): Map<number, Located>;
|
|
12
|
+
/** A folder an orphan may be stopped from: a let-go checkout (only what was born before it was let go) or a temp root. */
|
|
13
|
+
export type SweepRoot = {
|
|
14
|
+
path: string;
|
|
15
|
+
kind: "checkout" | "test temp";
|
|
16
|
+
bornBefore?: number;
|
|
17
|
+
repo?: string | null;
|
|
18
|
+
taskId?: string | null;
|
|
19
|
+
};
|
|
20
|
+
/** The root `located` runs from inside of, if any (its working folder first, then its executable). */
|
|
21
|
+
export declare function rootOf(located: Located | undefined, roots: readonly SweepRoot[]): SweepRoot | null;
|
|
22
|
+
export type OrphanTarget = {
|
|
23
|
+
row: ProcessRow;
|
|
24
|
+
root: SweepRoot;
|
|
25
|
+
orphan: number;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Which processes go: each orphan (parent 1, no terminal, not protected) running from inside a root — for a checkout,
|
|
29
|
+
* born before it was let go — and every process descending from one that goes (none at a terminal or protected).
|
|
30
|
+
*/
|
|
31
|
+
export declare function orphanTargets(census: readonly ProcessRow[], located: ReadonlyMap<number, Located>, roots: readonly SweepRoot[], guard: {
|
|
32
|
+
pids: ReadonlySet<number>;
|
|
33
|
+
groups: ReadonlySet<number>;
|
|
34
|
+
}): OrphanTarget[];
|
|
35
|
+
/** Whether a temp root's owner (pid and start) is still the running process. */
|
|
36
|
+
export declare function ownerRunning(census: readonly ProcessRow[]): (owner: TempOwner) => boolean;
|
|
37
|
+
/**
|
|
38
|
+
* The roots orphans may be stopped from now: every checkout let go with no live run on its task and not held, and
|
|
39
|
+
* every test temp root whose owner is gone or that nothing touched for a day.
|
|
40
|
+
*/
|
|
41
|
+
export declare function sweepRoots(store: Store, pool: WorktreePool | null, census: readonly ProcessRow[], tempRoots: readonly string[], now: Date): SweepRoot[];
|
|
42
|
+
export type OrphanStop = {
|
|
43
|
+
pid: number;
|
|
44
|
+
name: string;
|
|
45
|
+
where: string;
|
|
46
|
+
kind: SweepRoot["kind"];
|
|
47
|
+
ranMs: number;
|
|
48
|
+
killed: boolean;
|
|
49
|
+
};
|
|
50
|
+
export type OrphanSweep = {
|
|
51
|
+
stopped: OrphanStop[];
|
|
52
|
+
failed: string[];
|
|
53
|
+
};
|
|
54
|
+
export type OrphanOptions = StopOptions & {
|
|
55
|
+
actor?: string;
|
|
56
|
+
pool?: WorktreePool | null;
|
|
57
|
+
tempRoots?: readonly string[];
|
|
58
|
+
locate?: (pids: readonly number[]) => Map<number, Located>;
|
|
59
|
+
self?: number;
|
|
60
|
+
};
|
|
61
|
+
/** Stop the orphans Toolroll left (see the top of this file), each in the ledger. */
|
|
62
|
+
export declare function sweepOrphans(store: Store, now: () => Date, options?: OrphanOptions): Promise<OrphanSweep>;
|
|
63
|
+
export declare function durationWords(ms: number): string;
|