toolroll 0.9.61 → 0.9.63
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 +46 -0
- package/README.md +51 -3
- package/dist/agent-fence.d.ts +155 -10
- package/dist/agent-fence.js +473 -38
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +21 -21
- package/dist/browser-workspace.d.ts +28 -0
- package/dist/chat-actions.js +2 -2
- package/dist/check-levels.js +3 -0
- package/dist/claim.js +1 -1
- package/dist/cli-help.d.ts +1 -1
- package/dist/cli-help.js +1 -0
- package/dist/cli.js +31 -11
- package/dist/contracts/cli.d.ts +8 -0
- package/dist/contracts/cli.js +3 -1
- package/dist/contracts/console-api.d.ts +19 -1
- package/dist/contracts/console-api.js +2 -1
- package/dist/contracts/integration-metadata.d.ts +2 -1
- package/dist/contracts/integration-metadata.js +2 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.js +7 -4
- package/dist/doctor.d.ts +53 -0
- package/dist/doctor.js +427 -0
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +2 -2
- package/dist/exec.d.ts +3 -0
- package/dist/exec.js +5 -4
- package/dist/flow-code.d.ts +2 -2
- package/dist/flow-code.js +4 -15
- package/dist/flow-engine.js +11 -6
- package/dist/flow-triggers.d.ts +8 -4
- package/dist/flow-triggers.js +45 -25
- package/dist/flow-unattended-words.d.ts +28 -0
- package/dist/flow-unattended-words.js +50 -0
- package/dist/flow-unattended.d.ts +227 -0
- package/dist/flow-unattended.js +776 -0
- package/dist/flows-cli.d.ts +28 -1
- package/dist/flows-cli.js +55 -10
- package/dist/flows-ui.d.ts +2 -1
- package/dist/flows-ui.js +4 -1
- package/dist/google-mail.d.ts +1 -0
- package/dist/google-mail.js +61 -18
- package/dist/integrations-ui.d.ts +2 -1
- package/dist/integrations-ui.js +6 -3
- package/dist/integrations.d.ts +17 -3
- package/dist/integrations.js +38 -18
- package/dist/invoke.js +300 -168
- package/dist/landed-cleanup.d.ts +26 -12
- package/dist/landed-cleanup.js +199 -85
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +3 -3
- package/dist/lead-tools.js +11 -3
- package/dist/mcp-connect.d.ts +8 -7
- package/dist/mcp-connect.js +49 -51
- package/dist/oauth-run-token.d.ts +51 -0
- package/dist/oauth-run-token.js +127 -0
- package/dist/operate.js +74 -17
- package/dist/plan-auto.d.ts +11 -0
- package/dist/plan-auto.js +62 -11
- package/dist/process-recovery-eligibility.d.ts +1 -1
- package/dist/project-access.d.ts +3 -0
- package/dist/project-access.js +3 -0
- package/dist/project-admission.d.ts +10 -6
- package/dist/project-admission.js +33 -11
- package/dist/project-delete-ui.d.ts +1 -0
- package/dist/project-delete-ui.js +4 -1
- package/dist/project-delete.d.ts +3 -1
- package/dist/project-delete.js +10 -4
- package/dist/project-file.d.ts +3 -0
- package/dist/project-file.js +16 -0
- package/dist/project-tools.d.ts +99 -25
- package/dist/project-tools.js +394 -98
- package/dist/provider-home.d.ts +22 -0
- package/dist/provider-home.js +99 -0
- package/dist/provider.d.ts +17 -3
- package/dist/provider.js +34 -10
- package/dist/remote-main.d.ts +61 -0
- package/dist/remote-main.js +179 -0
- package/dist/scope.d.ts +1 -1
- package/dist/serve.js +11 -3
- package/dist/server/flows.js +27 -5
- package/dist/server/render-tasks.d.ts +3 -0
- package/dist/server/render-tasks.js +3 -0
- package/dist/server/route-table.js +3 -0
- package/dist/server/runtime.d.ts +1 -1
- package/dist/server/settings.js +14 -5
- package/dist/server/task-views.js +2 -0
- package/dist/storage-sweep-child.js +3 -1
- package/dist/storage-sweep-process.d.ts +14 -0
- package/dist/storage-sweep-process.js +37 -3
- package/dist/storage-sweep-schedule.d.ts +30 -0
- package/dist/storage-sweep-schedule.js +164 -0
- package/dist/storage-sweep.d.ts +6 -5
- package/dist/storage-sweep.js +10 -70
- package/dist/storage-ui.d.ts +2 -0
- package/dist/storage-ui.js +7 -4
- package/dist/store.d.ts +75 -7
- package/dist/store.js +241 -17
- package/dist/subagent-tools.d.ts +1 -1
- package/dist/surface.js +10 -6
- package/dist/task-base.d.ts +35 -0
- package/dist/task-base.js +51 -0
- package/dist/teardown-git.d.ts +14 -0
- package/dist/teardown-git.js +119 -0
- package/dist/tool-broker.d.ts +37 -0
- package/dist/tool-broker.js +369 -0
- package/dist/tool-launcher.js +5 -4
- package/dist/tool-secret-store.d.ts +170 -0
- package/dist/tool-secret-store.js +834 -0
- package/dist/toolroll-update.js +4 -2
- package/dist/tools-ui.d.ts +7 -1
- package/dist/tools-ui.js +17 -8
- package/dist/work-index.js +5 -3
- package/dist/worktree.d.ts +64 -6
- package/dist/worktree.js +171 -35
- package/package.json +3 -1
- package/scripts/provider-canary.mjs +210 -31
package/dist/worktree.js
CHANGED
|
@@ -23,15 +23,16 @@
|
|
|
23
23
|
* over `.git` internals corrupts both.
|
|
24
24
|
*/
|
|
25
25
|
import { createHash, randomBytes } from "node:crypto";
|
|
26
|
-
import { existsSync, readFileSync, writeFileSync, rmSync, realpathSync, mkdirSync, readdirSync, renameSync } from "node:fs";
|
|
26
|
+
import { existsSync, lstatSync, readFileSync, writeFileSync, rmSync, realpathSync, mkdirSync, readdirSync, renameSync } from "node:fs";
|
|
27
27
|
import { hostname } from "node:os";
|
|
28
|
-
import { dirname, join } from "node:path";
|
|
28
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
29
29
|
import { run } from "./exec.js";
|
|
30
30
|
import { currentBootId, provenDeadByBootChange } from "./boot-identity.js";
|
|
31
31
|
import { HANDOFF_PREFIX, MAILBOX_SUFFIX, looksLikeProtocolFile, readMailbox } from "./evidence.js";
|
|
32
32
|
import { parseHandoff } from "./decision.js";
|
|
33
33
|
import { diskBytes } from "./storage.js";
|
|
34
34
|
import { markNeverIndex } from "./never-index.js";
|
|
35
|
+
import { teardownGit } from "./teardown-git.js";
|
|
35
36
|
/**
|
|
36
37
|
* The note a lease leaves in the checkout, naming the process holding it.
|
|
37
38
|
*
|
|
@@ -40,6 +41,13 @@ import { markNeverIndex } from "./never-index.js";
|
|
|
40
41
|
* something is in there.
|
|
41
42
|
*/
|
|
42
43
|
export const MARKER = ".standing-orders-lease";
|
|
44
|
+
/**
|
|
45
|
+
* The reflog message of a branch Toolroll created, naming it: its creation entry is the proof, later, that the branch
|
|
46
|
+
* is Toolroll's and how old it is. A branch copied or renamed from it names the old branch, so it proves nothing.
|
|
47
|
+
*/
|
|
48
|
+
export const branchCreatedMessage = (branch) => `Toolroll created branch ${branch}`;
|
|
49
|
+
/** Scouts' disposable branches: never landed work. */
|
|
50
|
+
export const SCOUT_PREFIX = "toolroll-scout/";
|
|
43
51
|
/** Checkouts this process is leasing or removing right now: a lease and storage retention never overlap on one. */
|
|
44
52
|
const busy = new Map();
|
|
45
53
|
// Outside the checkout so removal cannot erase the reservation before git and the row update finish.
|
|
@@ -122,6 +130,31 @@ export function worktreePath(root, repo, branch) {
|
|
|
122
130
|
.slice(0, 8);
|
|
123
131
|
return join(root, safeSegment(basenameOf(repo)), `${safeSegment(branch)}-${digest}`);
|
|
124
132
|
}
|
|
133
|
+
/** The pool root a checkout at `path` of `repo` was made under (worktreePath's layout), or null for one it didn't make. */
|
|
134
|
+
export function poolRootOf(path, repo) {
|
|
135
|
+
const project = dirname(path);
|
|
136
|
+
return basename(project) === safeSegment(basenameOf(repo)) && /-[0-9a-f]{8}$/.test(basename(path)) ? dirname(project) : null;
|
|
137
|
+
}
|
|
138
|
+
/** Inspect names, not their destinations, before the plane writes into a pool. */
|
|
139
|
+
function safePoolPath(root, target) {
|
|
140
|
+
const base = resolve(root);
|
|
141
|
+
const rel = relative(base, resolve(target));
|
|
142
|
+
if (rel.startsWith("..") || rel.startsWith(sep))
|
|
143
|
+
return false;
|
|
144
|
+
let path = base;
|
|
145
|
+
for (const component of ["", ...rel.split(sep)]) {
|
|
146
|
+
path = join(path, component);
|
|
147
|
+
try {
|
|
148
|
+
if (!lstatSync(path).isDirectory())
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (error.code !== "ENOENT")
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
125
158
|
function normalisePath(path) {
|
|
126
159
|
return path.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
127
160
|
}
|
|
@@ -203,20 +236,21 @@ export function worktreeProcessOccupancy(path) {
|
|
|
203
236
|
* reset or clean a tree saves this first.
|
|
204
237
|
*/
|
|
205
238
|
export async function saveWorkPatch(runner, path, file, header) {
|
|
206
|
-
const
|
|
239
|
+
const git = teardownGit(runner);
|
|
240
|
+
const status = await git(path, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all", "--ignore-submodules=all"]);
|
|
207
241
|
if (status.code !== 0)
|
|
208
242
|
return { ok: false, message: `${path} could not be inspected before its work was saved` };
|
|
209
243
|
const untracked = status.stdout
|
|
210
244
|
.split("\n")
|
|
211
245
|
.filter(line => line.startsWith("?? ") && !line.trimEnd().endsWith(MARKER))
|
|
212
246
|
.map(line => line.slice(3).trim());
|
|
213
|
-
const tracked = await
|
|
247
|
+
const tracked = await git(path, [...READ_ONLY, "diff", "--binary", "--ignore-submodules=all", "HEAD"]);
|
|
214
248
|
if (tracked.code !== 0)
|
|
215
249
|
return { ok: false, message: `${path}: git diff failed while saving its work (${firstLine(tracked.stderr)})` };
|
|
216
250
|
const parts = [tracked.stdout];
|
|
217
251
|
for (const one of untracked) {
|
|
218
252
|
// --no-index exits 1 when the sides differ; that is the expected answer.
|
|
219
|
-
const diff = await
|
|
253
|
+
const diff = await git(path, [...READ_ONLY, "diff", "--binary", "--no-index", "--ignore-submodules=all", "--", "/dev/null", one]);
|
|
220
254
|
if (diff.code > 1)
|
|
221
255
|
return { ok: false, message: `${path}: ${one} could not be captured (${firstLine(diff.stderr)})` };
|
|
222
256
|
parts.push(diff.stdout);
|
|
@@ -297,6 +331,8 @@ export class WorktreePool {
|
|
|
297
331
|
}
|
|
298
332
|
async lease(request) {
|
|
299
333
|
const target = worktreePath(this.options.root, request.repo, request.branch);
|
|
334
|
+
if (!safePoolPath(this.options.root, target))
|
|
335
|
+
return { ok: false, reason: "git", message: "The checkout pool contains a symlink or an unreadable folder." };
|
|
300
336
|
if (busy.has(target))
|
|
301
337
|
return { ok: false, reason: "in-use", message: `${target} is busy; try again in a moment` };
|
|
302
338
|
const release = reserveCheckout(this.store, target);
|
|
@@ -324,6 +360,9 @@ export class WorktreePool {
|
|
|
324
360
|
};
|
|
325
361
|
}
|
|
326
362
|
const path = worktreePath(this.options.root, request.repo, request.branch);
|
|
363
|
+
const unsafe = () => ({ ok: false, reason: "git", message: "The checkout pool contains a symlink or an unreadable folder." });
|
|
364
|
+
if (!safePoolPath(this.options.root, path))
|
|
365
|
+
return unsafe();
|
|
327
366
|
const existing = this.store.getWorktree(path);
|
|
328
367
|
// The machine outranks the database here. A row can say "released" while a
|
|
329
368
|
// process that never got to write its own ending is still working in the
|
|
@@ -401,23 +440,46 @@ export class WorktreePool {
|
|
|
401
440
|
const fromBase = request.base !== undefined && !branchExists;
|
|
402
441
|
// Spotlight leaves the checkouts alone: the marker sits in the pool and the project's folder, never inside a
|
|
403
442
|
// checkout, where a build's `git add -A` would commit it (never-index.ts).
|
|
443
|
+
if (!safePoolPath(this.options.root, path))
|
|
444
|
+
return unsafe();
|
|
404
445
|
try {
|
|
405
446
|
mkdirSync(dirname(path), { recursive: true });
|
|
406
447
|
}
|
|
407
|
-
catch {
|
|
448
|
+
catch {
|
|
449
|
+
return unsafe();
|
|
450
|
+
}
|
|
408
451
|
markNeverIndex(this.options.root);
|
|
409
452
|
markNeverIndex(dirname(path));
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
453
|
+
// A new branch is created first, on its own, so its reflog's first entry says Toolroll made it and when.
|
|
454
|
+
let made = null;
|
|
455
|
+
if (fromBase) {
|
|
456
|
+
const base = await this.git(request.repo, ["rev-parse", "--verify", "--quiet", `${request.base}^{commit}`], READ_ONLY);
|
|
457
|
+
const oid = base.stdout.trim();
|
|
458
|
+
if (base.code !== 0 || !/^[0-9a-f]{40,64}$/.test(oid))
|
|
459
|
+
return { ok: false, reason: "git", message: `git couldn't find ${request.base}` };
|
|
460
|
+
const branch = await this.git(request.repo, ["update-ref", "--create-reflog", "-m", branchCreatedMessage(request.branch), `refs/heads/${request.branch}`, oid, "0".repeat(oid.length)]);
|
|
461
|
+
if (branch.code !== 0)
|
|
462
|
+
return { ok: false, reason: "git", message: firstLine(branch.stderr) };
|
|
463
|
+
made = oid;
|
|
464
|
+
}
|
|
465
|
+
if (!safePoolPath(this.options.root, path))
|
|
466
|
+
return unsafe();
|
|
467
|
+
const add = await this.git(request.repo, ["worktree", "add", path, request.branch]);
|
|
417
468
|
if (add.code !== 0) {
|
|
469
|
+
if (made !== null)
|
|
470
|
+
await this.teardown(request.repo, ["update-ref", "-d", `refs/heads/${request.branch}`, made]);
|
|
418
471
|
return { ok: false, reason: "git", message: firstLine(add.stderr) };
|
|
419
472
|
}
|
|
420
473
|
}
|
|
474
|
+
// Fresh base, inside the same reservation as everything above: nothing else leases, prunes or slims this
|
|
475
|
+
// checkout between the inspection and the move.
|
|
476
|
+
let base;
|
|
477
|
+
if (request.freshBase !== undefined && request.taskRef !== undefined) {
|
|
478
|
+
const fresh = await this.freshen(path, request.branch, request.taskRef, request.freshBase, request.now);
|
|
479
|
+
if (!fresh.ok)
|
|
480
|
+
return { ok: false, reason: "git", message: fresh.message };
|
|
481
|
+
base = fresh.standing;
|
|
482
|
+
}
|
|
421
483
|
const row = {
|
|
422
484
|
path,
|
|
423
485
|
repo: request.repo,
|
|
@@ -453,8 +515,50 @@ export class WorktreePool {
|
|
|
453
515
|
...(reclaimed === undefined ? {} : { reclaimed }),
|
|
454
516
|
...(resumedFromRun === undefined ? {} : { resumedFromRun }),
|
|
455
517
|
...(recoveryKind === undefined ? {} : { recoveryKind }),
|
|
518
|
+
...(base === undefined ? {} : { base }),
|
|
456
519
|
};
|
|
457
520
|
}
|
|
521
|
+
/**
|
|
522
|
+
* Move an untouched checkout to current main, or say how far behind it is. Untouched means all three: on its own
|
|
523
|
+
* branch, HEAD still the recorded base (or, with none recorded, a commit main already has), and nothing changed or
|
|
524
|
+
* untracked. The move is `git reset --keep`, which refuses rather than overwrite anything; HEAD and the tree are read
|
|
525
|
+
* again right before and after it, and any surprise fails the lease with the checkout left for a person.
|
|
526
|
+
*/
|
|
527
|
+
async freshen(path, branch, taskRef, fresh, now) {
|
|
528
|
+
const read = async (args) => {
|
|
529
|
+
const out = await this.runner(GIT, [...READ_ONLY, ...args], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
530
|
+
return out.code === 0 ? out.stdout.trim() : null;
|
|
531
|
+
};
|
|
532
|
+
const main = await read(["rev-parse", "--verify", "--quiet", `${fresh.main}^{commit}`]);
|
|
533
|
+
const head = await read(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
|
534
|
+
if (main === null || head === null)
|
|
535
|
+
return { ok: true, standing: null };
|
|
536
|
+
const count = await read(["rev-list", "--count", `${head}..${main}`]);
|
|
537
|
+
const behind = count === null ? NaN : Number(count);
|
|
538
|
+
if (!Number.isSafeInteger(behind))
|
|
539
|
+
return { ok: true, standing: null };
|
|
540
|
+
const stay = (kept) => ({ ok: true, standing: { head, main, behind, moved: null, kept } });
|
|
541
|
+
if (behind === 0)
|
|
542
|
+
return stay(null);
|
|
543
|
+
if (await read(["symbolic-ref", "-q", "HEAD"]) !== `refs/heads/${branch}`)
|
|
544
|
+
return stay("not on its branch");
|
|
545
|
+
if (fresh.recordedBase !== null && fresh.recordedBase !== head)
|
|
546
|
+
return stay("has commits");
|
|
547
|
+
const contained = await this.runner(GIT, [...READ_ONLY, "merge-base", "--is-ancestor", head, main], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
548
|
+
if (contained.code !== 0)
|
|
549
|
+
return stay("has commits");
|
|
550
|
+
if (await this.isDirty(path) !== false)
|
|
551
|
+
return stay("has changes");
|
|
552
|
+
// Recorded before git moves it: a move that then fails leaves the next attempt's own base as the branch's origin.
|
|
553
|
+
this.store.recordBranchBaseMove(taskRef, branch, head, main, now);
|
|
554
|
+
const moved = await this.runner(GIT, ["reset", "--keep", main], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
555
|
+
const after = await read(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
|
556
|
+
const branchNow = await read(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}^{commit}`]);
|
|
557
|
+
if (moved.code !== 0 || after !== main || branchNow !== main || await this.isDirty(path) !== false) {
|
|
558
|
+
return { ok: false, message: `${path} could not be moved to current main (${firstLine(moved.stderr) || "it changed during the move"}) — leaving it for a person` };
|
|
559
|
+
}
|
|
560
|
+
return { ok: true, standing: { head: main, main, behind: 0, moved: { from: head, behind }, kept: null } };
|
|
561
|
+
}
|
|
458
562
|
/**
|
|
459
563
|
* A crash can strand either a finished handoff or a partial source draft.
|
|
460
564
|
* The expired lease can no longer authorize a commit, so this never ingests
|
|
@@ -526,10 +630,10 @@ export class WorktreePool {
|
|
|
526
630
|
}
|
|
527
631
|
/** Back to HEAD, untracked gone, our lease note kept; proven clean after. */
|
|
528
632
|
async resetTree(path) {
|
|
529
|
-
const checkout = await this.
|
|
633
|
+
const checkout = await this.teardown(path, ["checkout", "--", "."]);
|
|
530
634
|
if (checkout.code !== 0)
|
|
531
635
|
return { ok: false, message: `${path}: git checkout failed while resetting (${firstLine(checkout.stderr)})` };
|
|
532
|
-
const clean = await this.
|
|
636
|
+
const clean = await this.teardown(path, ["clean", "-fd", "-e", MARKER]);
|
|
533
637
|
if (clean.code !== 0)
|
|
534
638
|
return { ok: false, message: `${path}: git clean failed while resetting (${firstLine(clean.stderr)})` };
|
|
535
639
|
const dirty = await this.isDirty(path);
|
|
@@ -617,17 +721,17 @@ export class WorktreePool {
|
|
|
617
721
|
const occupied = this.inUse(path);
|
|
618
722
|
if (occupied.held)
|
|
619
723
|
return { ok: false, message: `${path} is being used by process ${occupied.by} — leaving it alone` };
|
|
620
|
-
const removed = await this.
|
|
724
|
+
const removed = await this.teardown(existing.repo, ["worktree", "remove", "--force", path]);
|
|
621
725
|
if (removed.code !== 0)
|
|
622
726
|
return { ok: false, message: `git worktree remove: ${removed.stderr.trim() || `exit ${removed.code}`}` };
|
|
623
727
|
// The branch is disposable too; a failure here is bookkeeping, not custody.
|
|
624
|
-
await this.
|
|
728
|
+
await this.teardown(existing.repo, ["branch", "-D", existing.branch]);
|
|
625
729
|
this.store.forgetWorktree(path);
|
|
626
730
|
void now;
|
|
627
731
|
return { ok: true };
|
|
628
732
|
}
|
|
629
733
|
async orphans(repo) {
|
|
630
|
-
const listed = await this.
|
|
734
|
+
const listed = await this.teardown(repo, ["worktree", "list", "--porcelain"]);
|
|
631
735
|
// A listing that failed is not a listing that came back empty. Treating
|
|
632
736
|
// it as empty would report every stored row as missing — and anything
|
|
633
737
|
// acting on that answer would erase real bookkeeping over a git hiccup.
|
|
@@ -724,14 +828,14 @@ export class WorktreePool {
|
|
|
724
828
|
/**
|
|
725
829
|
* Whether a let-go checkout could go without losing anything: clean (build output that .gitignore hides is not
|
|
726
830
|
* work, and neither are the files Toolroll itself wrote in there), and everything its HEAD reaches on a branch, tag
|
|
727
|
-
* or remote. Reads only.
|
|
831
|
+
* or remote. Reads only, through `git` when given (teardown's hardened git).
|
|
728
832
|
*/
|
|
729
|
-
async inspect(path) {
|
|
730
|
-
const dirty = await this.isDirty(path, true);
|
|
833
|
+
async inspect(path, git = this.teardown) {
|
|
834
|
+
const dirty = await this.isDirty(path, true, git);
|
|
731
835
|
if (dirty !== false)
|
|
732
836
|
return dirty === null ? "unreadable" : "has changes";
|
|
733
837
|
// Commits only this checkout's HEAD reaches (a detached HEAD moved on) exist nowhere else.
|
|
734
|
-
const own = await
|
|
838
|
+
const own = await git(path, [...READ_ONLY, "rev-list", "-1", "HEAD", "--not", "--branches", "--tags", "--remotes"]);
|
|
735
839
|
if (own.code !== 0)
|
|
736
840
|
return "unreadable";
|
|
737
841
|
return own.stdout.trim() === "" ? "clean" : "has commits";
|
|
@@ -739,10 +843,12 @@ export class WorktreePool {
|
|
|
739
843
|
/**
|
|
740
844
|
* Keep storage in check: remove the working copies nobody needs any more. Of `rows`, a checkout goes when it is
|
|
741
845
|
* let go, nothing holds it, `wanted` says it may go (asked again right before it goes, so a task coming back or a
|
|
742
|
-
* lease since keeps it) and `inspect` finds it clean
|
|
743
|
-
*
|
|
846
|
+
* lease since keeps it) and `inspect` finds it clean, then clean still right before it goes (git refuses an edit
|
|
847
|
+
* made after that). Only the working copy goes: its branch, and so every commit,
|
|
848
|
+
* stays, and a later lease of the same branch makes a new one. At most `max` go per pass. Every git command goes
|
|
849
|
+
* through `git` when given: landed cleanup passes its hardened teardown git (teardown-git.ts).
|
|
744
850
|
*/
|
|
745
|
-
async prune(rows, wanted, max = Infinity) {
|
|
851
|
+
async prune(rows, wanted, max = Infinity, git = this.teardown) {
|
|
746
852
|
const removed = [];
|
|
747
853
|
const kept = [];
|
|
748
854
|
const idle = (row, releasedAt) => row !== null && row.runner === null && row.releasedAt === releasedAt && !this.inUse(row.path).held;
|
|
@@ -756,7 +862,7 @@ export class WorktreePool {
|
|
|
756
862
|
continue;
|
|
757
863
|
if (!existsSync(row.path) || !idle(row, row.releasedAt))
|
|
758
864
|
continue;
|
|
759
|
-
const found = await this.inspect(row.path);
|
|
865
|
+
const found = await this.inspect(row.path, git);
|
|
760
866
|
if (found !== "clean") {
|
|
761
867
|
kept.push({ path: row.path, why: found });
|
|
762
868
|
continue;
|
|
@@ -776,9 +882,9 @@ export class WorktreePool {
|
|
|
776
882
|
const allowed = wanted(now, true);
|
|
777
883
|
if (!(typeof allowed === "boolean" ? allowed : await allowed))
|
|
778
884
|
continue;
|
|
779
|
-
const gone = await this.
|
|
780
|
-
if (
|
|
781
|
-
kept.push({ path: row.path, why:
|
|
885
|
+
const gone = await this.removeCleanCopy(row, git);
|
|
886
|
+
if (gone !== "removed") {
|
|
887
|
+
kept.push({ path: row.path, why: gone });
|
|
782
888
|
continue;
|
|
783
889
|
}
|
|
784
890
|
removed.push(row);
|
|
@@ -800,7 +906,7 @@ export class WorktreePool {
|
|
|
800
906
|
const idle = (now) => now !== null && now.runner === null && now.releasedAt !== null && now.releasedAt === row.releasedAt && !this.inUse(row.path).held;
|
|
801
907
|
if (busy.has(row.path) || !existsSync(row.path) || !idle(this.store.getWorktree(row.path)))
|
|
802
908
|
return { ok: false, message: `${row.path} is in use` };
|
|
803
|
-
const listed = await this.
|
|
909
|
+
const listed = await this.teardown(row.path, [...READ_ONLY, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"]);
|
|
804
910
|
if (listed.code !== 0)
|
|
805
911
|
return { ok: false, message: `git couldn't list ${row.path}` };
|
|
806
912
|
const drop = listed.stdout.split("\0").filter(one => one.endsWith("/")).map(one => one.slice(0, -1))
|
|
@@ -871,7 +977,8 @@ export class WorktreePool {
|
|
|
871
977
|
busy.delete(path);
|
|
872
978
|
}
|
|
873
979
|
}
|
|
874
|
-
/**
|
|
980
|
+
/** Throw away one idle checkout's working copy, changes and all (the caller holds it in `busy`), and forget its row.
|
|
981
|
+
* Only `discardChanges`, where losing them is what a person asked for. */
|
|
875
982
|
async removeWorkingCopy(row) {
|
|
876
983
|
// Held while it goes: another process's lease sees this process in it (the busy map is this process's only).
|
|
877
984
|
const note = join(row.path, MARKER);
|
|
@@ -881,17 +988,44 @@ export class WorktreePool {
|
|
|
881
988
|
catch {
|
|
882
989
|
return false;
|
|
883
990
|
}
|
|
884
|
-
const gone = await this.
|
|
991
|
+
const gone = await this.teardown(row.repo, ["worktree", "remove", "--force", row.path]);
|
|
885
992
|
if (gone.code !== 0) {
|
|
886
993
|
rmSync(note, { force: true });
|
|
887
994
|
return false;
|
|
888
995
|
}
|
|
996
|
+
this.forgetRemoved(row);
|
|
997
|
+
return true;
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Remove one idle checkout's working copy only while it is still clean (the caller holds it in `busy`, and its
|
|
1001
|
+
* reservation outside the checkout holds it for other processes): inspected once more right before it goes, then
|
|
1002
|
+
* removed without --force, so git itself refuses an edit made since. Only Toolroll's own files go first, because
|
|
1003
|
+
* git's check would count them.
|
|
1004
|
+
*/
|
|
1005
|
+
async removeCleanCopy(row, git) {
|
|
1006
|
+
const found = await this.inspect(row.path, git);
|
|
1007
|
+
if (found !== "clean")
|
|
1008
|
+
return found;
|
|
1009
|
+
const status = await git(row.path, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all", "--ignore-submodules=all"]);
|
|
1010
|
+
if (status.code !== 0)
|
|
1011
|
+
return "unreadable";
|
|
1012
|
+
for (const line of status.stdout.split("\n")) {
|
|
1013
|
+
const name = line.startsWith("?? ") ? line.slice(3).trim() : "";
|
|
1014
|
+
if (name === MARKER || (name !== "" && looksLikeProtocolFile(name)))
|
|
1015
|
+
rmSync(join(row.path, name), { recursive: true, force: true });
|
|
1016
|
+
}
|
|
1017
|
+
const gone = await git(row.repo, ["worktree", "remove", row.path]);
|
|
1018
|
+
if (gone.code !== 0)
|
|
1019
|
+
return (await this.isDirty(row.path, false, git)) === false ? "git refused" : "has changes";
|
|
1020
|
+
this.forgetRemoved(row);
|
|
1021
|
+
return "removed";
|
|
1022
|
+
}
|
|
1023
|
+
forgetRemoved(row) {
|
|
889
1024
|
this.store.transact(() => {
|
|
890
1025
|
const still = this.store.getWorktree(row.path);
|
|
891
1026
|
if (still !== null && still.runner === null && still.releasedAt === row.releasedAt)
|
|
892
1027
|
this.store.forgetWorktree(row.path);
|
|
893
1028
|
});
|
|
894
|
-
return true;
|
|
895
1029
|
}
|
|
896
1030
|
/**
|
|
897
1031
|
* Whether anybody's work is in there. null means we could not tell, which is
|
|
@@ -900,8 +1034,8 @@ export class WorktreePool {
|
|
|
900
1034
|
* `--untracked-files=all` and no `--ignored`: build output that .gitignore
|
|
901
1035
|
* hides is not work, but a file somebody dropped in and never staged is.
|
|
902
1036
|
*/
|
|
903
|
-
async isDirty(path, ignoreOwnFiles = false) {
|
|
904
|
-
const status = await
|
|
1037
|
+
async isDirty(path, ignoreOwnFiles = false, git = this.teardown) {
|
|
1038
|
+
const status = await git(path, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all", "--ignore-submodules=all"]);
|
|
905
1039
|
if (status.code !== 0)
|
|
906
1040
|
return null;
|
|
907
1041
|
// Our own lease note is not the operator's work. Without this the marker
|
|
@@ -915,6 +1049,8 @@ export class WorktreePool {
|
|
|
915
1049
|
.filter(line => line.trim() !== "")
|
|
916
1050
|
.some(line => !line.trimEnd().endsWith(MARKER) && !(ignoreOwnFiles && line.startsWith("?? ") && looksLikeProtocolFile(line.slice(3).trim())));
|
|
917
1051
|
}
|
|
1052
|
+
/** All disposal and inspection paths share the hardened teardown boundary. */
|
|
1053
|
+
teardown = (cwd, args) => teardownGit(this.runner)(cwd, args);
|
|
918
1054
|
git(cwd, args, prefix = []) {
|
|
919
1055
|
return this.runner(GIT, [...prefix, ...args], { cwd, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
920
1056
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.63",
|
|
4
4
|
"description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
|
|
5
5
|
"directories": {
|
|
6
6
|
"doc": "docs"
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "tsc -p tsconfig.build.json && node scripts/browser-build.mjs && node scripts/postbuild.mjs",
|
|
10
10
|
"test": "vitest run",
|
|
11
|
+
"test:attacks": "vitest run --config test/attacks/vitest.config.ts",
|
|
12
|
+
"check:quick": "node scripts/quick-check.mjs",
|
|
11
13
|
"dev": "tsx src/cli.ts",
|
|
12
14
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.browser.json",
|
|
13
15
|
"certify:provider": "npm run build && node scripts/provider-canary.mjs",
|