toolroll 0.8.1 → 0.8.2
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 +18 -0
- package/dist/agent-onboard.d.ts +4 -0
- package/dist/agent-onboard.js +20 -5
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +6 -6
- package/dist/checkout-cleanup.d.ts +84 -0
- package/dist/checkout-cleanup.js +245 -0
- package/dist/first-run.d.ts +4 -1
- package/dist/first-run.js +2 -1
- package/dist/mcp.js +26 -0
- package/dist/operate.d.ts +0 -4
- package/dist/operate.js +103 -21
- package/dist/prompt.d.ts +7 -2
- package/dist/prompt.js +36 -7
- package/dist/serve.js +60 -11
- package/dist/storage-ui.d.ts +10 -0
- package/dist/storage-ui.js +67 -0
- package/dist/storage.d.ts +15 -1
- package/dist/storage.js +62 -7
- package/dist/store.d.ts +5 -5
- package/dist/store.js +18 -16
- package/dist/toolroll-update-ui.d.ts +2 -0
- package/dist/toolroll-update-ui.js +1 -1
- package/dist/toolroll-update.d.ts +26 -6
- package/dist/toolroll-update.js +188 -50
- package/dist/worktree.d.ts +24 -10
- package/dist/worktree.js +84 -44
- package/package.json +1 -1
package/dist/worktree.js
CHANGED
|
@@ -25,10 +25,10 @@
|
|
|
25
25
|
import { createHash, randomBytes } from "node:crypto";
|
|
26
26
|
import { existsSync, readFileSync, writeFileSync, rmSync, realpathSync, mkdirSync, readdirSync } from "node:fs";
|
|
27
27
|
import { hostname } from "node:os";
|
|
28
|
-
import {
|
|
28
|
+
import { dirname, join } from "node:path";
|
|
29
29
|
import { run } from "./exec.js";
|
|
30
30
|
import { currentBootId, provenDeadByBootChange } from "./boot-identity.js";
|
|
31
|
-
import { HANDOFF_PREFIX, MAILBOX_SUFFIX, readMailbox } from "./evidence.js";
|
|
31
|
+
import { HANDOFF_PREFIX, MAILBOX_SUFFIX, looksLikeProtocolFile, readMailbox } from "./evidence.js";
|
|
32
32
|
import { parseHandoff } from "./decision.js";
|
|
33
33
|
/**
|
|
34
34
|
* The note a lease leaves in the checkout, naming the process holding it.
|
|
@@ -649,37 +649,40 @@ export class WorktreePool {
|
|
|
649
649
|
});
|
|
650
650
|
}
|
|
651
651
|
/**
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
* everything its HEAD reaches is on a branch, tag or remote, and it isn't the
|
|
656
|
-
* checkout of one of `keep()`'s branches (those unfinished tasks work on or
|
|
657
|
-
* would come back to, read afresh before each removal), by its branch or by
|
|
658
|
-
* the name a lease of that branch would give it under any root. Only the
|
|
659
|
-
* working copy goes: its branch, and so every commit, stays, and a later
|
|
660
|
-
* lease of the same branch makes a new one. At most `max` go per pass.
|
|
652
|
+
* Whether a let-go checkout could go without losing anything: clean (build output that .gitignore hides is not
|
|
653
|
+
* work, and neither are the files Toolroll itself wrote in there), and everything its HEAD reaches on a branch, tag
|
|
654
|
+
* or remote. Reads only.
|
|
661
655
|
*/
|
|
662
|
-
async
|
|
656
|
+
async inspect(path) {
|
|
657
|
+
const dirty = await this.isDirty(path, true);
|
|
658
|
+
if (dirty !== false)
|
|
659
|
+
return dirty === null ? "unreadable" : "has changes";
|
|
660
|
+
// Commits only this checkout's HEAD reaches (a detached HEAD moved on) exist nowhere else.
|
|
661
|
+
const own = await this.runner(GIT, [...READ_ONLY, "rev-list", "-1", "HEAD", "--not", "--branches", "--tags", "--remotes"], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
662
|
+
if (own.code !== 0)
|
|
663
|
+
return "unreadable";
|
|
664
|
+
return own.stdout.trim() === "" ? "clean" : "has commits";
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Keep storage in check: remove the working copies nobody needs any more. Of `rows`, a checkout goes when it is
|
|
668
|
+
* let go, nothing holds it, `wanted` says it may go (asked again right before it goes, so a task coming back or a
|
|
669
|
+
* lease since keeps it) and `inspect` finds it clean. Only the working copy goes: its branch, and so every commit,
|
|
670
|
+
* stays, and a later lease of the same branch makes a new one. At most `max` go per pass.
|
|
671
|
+
*/
|
|
672
|
+
async prune(rows, wanted, max = Infinity) {
|
|
663
673
|
const removed = [];
|
|
664
674
|
const kept = [];
|
|
665
675
|
const idle = (row, releasedAt) => row !== null && row.runner === null && row.releasedAt === releasedAt && !this.inUse(row.path).held;
|
|
666
|
-
|
|
667
|
-
for (const row of this.store.listWorktrees().filter(one => one.repo === repo)) {
|
|
676
|
+
for (const row of rows) {
|
|
668
677
|
if (removed.length >= max)
|
|
669
678
|
break;
|
|
670
|
-
if (row.releasedAt === null || row.runner !== null || busy.has(row.path) ||
|
|
679
|
+
if (row.releasedAt === null || row.runner !== null || busy.has(row.path) || !wanted(row, false))
|
|
671
680
|
continue;
|
|
672
|
-
if (
|
|
681
|
+
if (!existsSync(row.path) || !idle(row, row.releasedAt))
|
|
673
682
|
continue;
|
|
674
|
-
const
|
|
675
|
-
if (
|
|
676
|
-
kept.push({ path: row.path, why:
|
|
677
|
-
continue;
|
|
678
|
-
}
|
|
679
|
-
// Commits only this checkout's HEAD reaches (a detached HEAD moved on) exist nowhere else.
|
|
680
|
-
const own = await this.runner(GIT, [...READ_ONLY, "rev-list", "-1", "HEAD", "--not", "--branches", "--tags", "--remotes"], { cwd: row.path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
681
|
-
if (own.code !== 0 || own.stdout.trim() !== "") {
|
|
682
|
-
kept.push({ path: row.path, why: own.code !== 0 ? "unreadable" : "has commits" });
|
|
683
|
+
const found = await this.inspect(row.path);
|
|
684
|
+
if (found !== "clean") {
|
|
685
|
+
kept.push({ path: row.path, why: found });
|
|
683
686
|
continue;
|
|
684
687
|
}
|
|
685
688
|
if (busy.has(row.path))
|
|
@@ -687,27 +690,14 @@ export class WorktreePool {
|
|
|
687
690
|
busy.set(row.path, "removing");
|
|
688
691
|
try {
|
|
689
692
|
// A lease, or a task coming back, since the listing wins: look again right before removing.
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
// Held while it goes: another process's lease sees this process in it (the busy map is this process's only).
|
|
693
|
-
const note = join(row.path, MARKER);
|
|
694
|
-
try {
|
|
695
|
-
writeFileSync(note, `${process.pid} storage-retention removing ${currentBootId() ?? "unknown"} ${hostname()}\n`, "utf8");
|
|
696
|
-
}
|
|
697
|
-
catch {
|
|
693
|
+
const now = this.store.getWorktree(row.path);
|
|
694
|
+
if (!idle(now, row.releasedAt) || !wanted(now, true))
|
|
698
695
|
continue;
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
if (gone.code !== 0) {
|
|
702
|
-
rmSync(note, { force: true });
|
|
696
|
+
const gone = await this.removeWorkingCopy(row);
|
|
697
|
+
if (!gone) {
|
|
703
698
|
kept.push({ path: row.path, why: "git refused" });
|
|
704
699
|
continue;
|
|
705
700
|
}
|
|
706
|
-
this.store.transact(() => {
|
|
707
|
-
const still = this.store.getWorktree(row.path);
|
|
708
|
-
if (still !== null && still.runner === null && still.releasedAt === row.releasedAt)
|
|
709
|
-
this.store.forgetWorktree(row.path);
|
|
710
|
-
});
|
|
711
701
|
removed.push(row);
|
|
712
702
|
}
|
|
713
703
|
finally {
|
|
@@ -716,6 +706,54 @@ export class WorktreePool {
|
|
|
716
706
|
}
|
|
717
707
|
return { removed, kept };
|
|
718
708
|
}
|
|
709
|
+
/**
|
|
710
|
+
* Throw away a let-go checkout kept for its changes, on purpose: the working copy and what is uncommitted in it
|
|
711
|
+
* go; its branch, and every commit on it, stays. Refused while anything holds it.
|
|
712
|
+
*/
|
|
713
|
+
async discardChanges(path) {
|
|
714
|
+
const row = this.store.getWorktree(path);
|
|
715
|
+
if (row === null)
|
|
716
|
+
return { ok: false, message: `${path} is not a checkout Toolroll made` };
|
|
717
|
+
if (row.releasedAt === null || row.runner !== null || busy.has(path))
|
|
718
|
+
return { ok: false, message: `${path} is in use — it can't be discarded now` };
|
|
719
|
+
const occupied = this.inUse(path);
|
|
720
|
+
if (occupied.held)
|
|
721
|
+
return { ok: false, message: `${path} is being used by process ${occupied.by} — it can't be discarded now` };
|
|
722
|
+
busy.set(path, "removing");
|
|
723
|
+
try {
|
|
724
|
+
if (!existsSync(path)) {
|
|
725
|
+
this.store.transact(() => { const still = this.store.getWorktree(path); if (still !== null && still.runner === null && still.releasedAt === row.releasedAt)
|
|
726
|
+
this.store.forgetWorktree(path); });
|
|
727
|
+
return { ok: true, row };
|
|
728
|
+
}
|
|
729
|
+
return (await this.removeWorkingCopy(row)) ? { ok: true, row } : { ok: false, message: `git would not remove ${path}` };
|
|
730
|
+
}
|
|
731
|
+
finally {
|
|
732
|
+
busy.delete(path);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
/** Remove one idle checkout's working copy (the caller holds it in `busy`) and forget its row. */
|
|
736
|
+
async removeWorkingCopy(row) {
|
|
737
|
+
// Held while it goes: another process's lease sees this process in it (the busy map is this process's only).
|
|
738
|
+
const note = join(row.path, MARKER);
|
|
739
|
+
try {
|
|
740
|
+
writeFileSync(note, `${process.pid} storage-retention removing ${currentBootId() ?? "unknown"} ${hostname()}\n`, "utf8");
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
const gone = await this.git(row.repo, ["worktree", "remove", "--force", row.path]);
|
|
746
|
+
if (gone.code !== 0) {
|
|
747
|
+
rmSync(note, { force: true });
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
this.store.transact(() => {
|
|
751
|
+
const still = this.store.getWorktree(row.path);
|
|
752
|
+
if (still !== null && still.runner === null && still.releasedAt === row.releasedAt)
|
|
753
|
+
this.store.forgetWorktree(row.path);
|
|
754
|
+
});
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
719
757
|
/**
|
|
720
758
|
* Whether anybody's work is in there. null means we could not tell, which is
|
|
721
759
|
* neither clean nor dirty and must not be rounded to either.
|
|
@@ -723,7 +761,7 @@ export class WorktreePool {
|
|
|
723
761
|
* `--untracked-files=all` and no `--ignored`: build output that .gitignore
|
|
724
762
|
* hides is not work, but a file somebody dropped in and never staged is.
|
|
725
763
|
*/
|
|
726
|
-
async isDirty(path) {
|
|
764
|
+
async isDirty(path, ignoreOwnFiles = false) {
|
|
727
765
|
const status = await this.runner(GIT, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all"], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
728
766
|
if (status.code !== 0)
|
|
729
767
|
return null;
|
|
@@ -731,10 +769,12 @@ export class WorktreePool {
|
|
|
731
769
|
// makes every checkout permanently dirty, which would jam the pool shut on
|
|
732
770
|
// the first lease — untracked files counting as dirty is the right rule,
|
|
733
771
|
// and this is the one file it must not apply to.
|
|
772
|
+
// Deciding whether a checkout can go, the files Toolroll wrote at its top (a progress note, a handoff, a
|
|
773
|
+
// proof list) are not a person's work either; any other untracked or changed file still is.
|
|
734
774
|
return status.stdout
|
|
735
775
|
.split("\n")
|
|
736
776
|
.filter(line => line.trim() !== "")
|
|
737
|
-
.some(line => !line.trimEnd().endsWith(MARKER));
|
|
777
|
+
.some(line => !line.trimEnd().endsWith(MARKER) && !(ignoreOwnFiles && line.startsWith("?? ") && looksLikeProtocolFile(line.slice(3).trim())));
|
|
738
778
|
}
|
|
739
779
|
git(cwd, args, prefix = []) {
|
|
740
780
|
return this.runner(GIT, [...prefix, ...args], { cwd, timeoutMs: WORKTREE_TIMEOUT_MS });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
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"
|