toolroll 0.9.61 → 0.9.62
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 +17 -0
- package/dist/cli.js +28 -11
- package/dist/landed-cleanup.d.ts +23 -10
- package/dist/landed-cleanup.js +188 -81
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +3 -3
- package/dist/operate.js +67 -16
- 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/remote-main.d.ts +57 -0
- package/dist/remote-main.js +179 -0
- package/dist/server/render-tasks.d.ts +3 -0
- package/dist/server/render-tasks.js +3 -0
- package/dist/server/settings.js +3 -2
- package/dist/server/task-views.js +2 -0
- package/dist/storage-sweep-process.d.ts +7 -0
- package/dist/storage-sweep-process.js +20 -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 +39 -3
- package/dist/store.js +96 -5
- package/dist/surface.js +4 -2
- package/dist/task-base.d.ts +35 -0
- package/dist/task-base.js +51 -0
- package/dist/toolroll-update.js +3 -2
- package/dist/worktree.d.ts +54 -2
- package/dist/worktree.js +108 -13
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -355,6 +355,22 @@ CREATE TABLE IF NOT EXISTS run_activity (
|
|
|
355
355
|
at TEXT NOT NULL
|
|
356
356
|
);
|
|
357
357
|
`;
|
|
358
|
+
/** A task branch moved to current main before an attempt (fresh base; v122, a new table: additive only): the branch
|
|
359
|
+
* had no commits of its own and no changes, so it started over from `to_rev`. `after_run` is the newest run id when it
|
|
360
|
+
* moved: the branch's whole-task diff runs from the first builder base recorded after it (firstBuilderBase), never from
|
|
361
|
+
* the older main it left. */
|
|
362
|
+
export const BRANCH_BASE_MOVE_SCHEMA = `
|
|
363
|
+
CREATE TABLE IF NOT EXISTS branch_base_move (
|
|
364
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
365
|
+
task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,
|
|
366
|
+
branch TEXT NOT NULL,
|
|
367
|
+
from_rev TEXT NOT NULL,
|
|
368
|
+
to_rev TEXT NOT NULL,
|
|
369
|
+
after_run INTEGER NOT NULL,
|
|
370
|
+
moved_at TEXT NOT NULL
|
|
371
|
+
);
|
|
372
|
+
CREATE INDEX IF NOT EXISTS branch_base_move_branch ON branch_base_move (task_ref, branch, id);
|
|
373
|
+
`;
|
|
358
374
|
/** Pausing a running Claude build (live steering, v121: additive only). A person's Pause asks the build to
|
|
359
375
|
* stop its current turn and keep its session: one row per request, bound to the exact run it was asked of, under the
|
|
360
376
|
* request id the asker chose (a repeated request is the same request). `note` is the steering note a Pause and steer
|
|
@@ -605,7 +621,10 @@ const REGISTERED_AT_FILING = "again, as it was filed";
|
|
|
605
621
|
// v121 adds, and changes no saved row: the Agents board (run.attempt_key; agent_turn, agent_now, agent_question,
|
|
606
622
|
// agent_seen, agent_view), live steering (a note's live identity and sends, a run's stdin session; run_pause and
|
|
607
623
|
// run_resume) and landed work's teardown journal (landed_cleanup, landed_cleanup_item).
|
|
608
|
-
|
|
624
|
+
// v122 adds, and changes no saved row: the remote URL an owner's project add recorded (project.remote_url), the only
|
|
625
|
+
// remote whose main automatic completion trusts (remote-main.ts; saved projects read none until it is recorded), and
|
|
626
|
+
// fresh base's record of a task branch moved to current main before an attempt (branch_base_move).
|
|
627
|
+
export const SCHEMA_VERSION = 122;
|
|
609
628
|
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
610
629
|
export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
|
|
611
630
|
/** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
|
|
@@ -730,9 +749,11 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
|
|
|
730
749
|
* (planning_generation on task_ref, run and plan_revision, defaulting to 0, and decision.superseded_reason, null);
|
|
731
750
|
* the rehearsal checks every saved row reads the declared default (HISTORY_RULES' added columns). v120 only adds
|
|
732
751
|
* three nullable task_steer columns (the running build's steering folder), checked the same way. v121 only adds
|
|
733
|
-
* new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way.
|
|
752
|
+
* new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way. v122 only adds a new
|
|
753
|
+
* table (branch_base_move) and the nullable column in V122_ADDED_COLUMNS (a project's recorded remote), checked the
|
|
754
|
+
* same way.
|
|
734
755
|
*/
|
|
735
|
-
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121]);
|
|
756
|
+
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122]);
|
|
736
757
|
/** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
|
|
737
758
|
export const V119_ADDED_COLUMNS = Object.freeze({
|
|
738
759
|
task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
|
|
@@ -778,6 +799,11 @@ export const V121_ADDED_COLUMNS = Object.freeze({
|
|
|
778
799
|
task_steer: LIVE_STEER_COLUMNS.task_steer,
|
|
779
800
|
run: { ...LIVE_STEER_COLUMNS.run, attempt_key: { definition: "TEXT", saved: "NULL" } },
|
|
780
801
|
});
|
|
802
|
+
/** v122: the column it adds, with the value every row saved before it reads: the remote URL an owner's project add
|
|
803
|
+
* recorded (projects added before it read none, until `repos add <project> --remote <url>` records one). */
|
|
804
|
+
export const V122_ADDED_COLUMNS = Object.freeze({
|
|
805
|
+
project: { remote_url: { definition: "TEXT", saved: "NULL" } },
|
|
806
|
+
});
|
|
781
807
|
/** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
|
|
782
808
|
export function updateSafeSchema(version) {
|
|
783
809
|
if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
|
|
@@ -3508,6 +3534,8 @@ function initializeStore(db, file) {
|
|
|
3508
3534
|
db.exec(CHAT_APPROVAL_SCHEMA);
|
|
3509
3535
|
// What a running agent did last (no version bump: additive only; absent means no word yet).
|
|
3510
3536
|
db.exec(RUN_ACTIVITY_SCHEMA);
|
|
3537
|
+
// v122: a task branch moved to current main before an attempt (fresh base): a new table, nothing carried over.
|
|
3538
|
+
db.exec(BRANCH_BASE_MOVE_SCHEMA);
|
|
3511
3539
|
// v121: the Agents board: each run's attempt key (V121_ADDED_COLUMNS, below), its turn history and current snapshot,
|
|
3512
3540
|
// the agent's open questions, who has seen a finished run and who is watching one now. Older runs read null keys.
|
|
3513
3541
|
db.exec(AGENT_BOARD_SCHEMA);
|
|
@@ -3540,6 +3568,12 @@ function initializeStore(db, file) {
|
|
|
3540
3568
|
db.exec(STORAGE_SWEEP_SCHEMA);
|
|
3541
3569
|
// v121: landed work's teardown journal (landed-cleanup.ts): new tables.
|
|
3542
3570
|
db.exec(LANDED_CLEANUP_SCHEMA);
|
|
3571
|
+
// v122: the remote URL an owner's project add recorded (V122_ADDED_COLUMNS; additive only, saved projects read none):
|
|
3572
|
+
// automatic completion trusts only that remote's main (remote-main.ts), never the shared repository's config.
|
|
3573
|
+
for (const [table, columns] of Object.entries(V122_ADDED_COLUMNS)) {
|
|
3574
|
+
for (const [column, { definition }] of Object.entries(columns))
|
|
3575
|
+
addColumn(db, table, column, definition);
|
|
3576
|
+
}
|
|
3543
3577
|
db.exec(BACKUP_SCHEMA);
|
|
3544
3578
|
// Sprint 8: the organisation policy (one row, or none: nothing restricted).
|
|
3545
3579
|
db.exec(POLICY_SCHEMA);
|
|
@@ -3712,6 +3746,8 @@ function createBaseline(db, file) {
|
|
|
3712
3746
|
}
|
|
3713
3747
|
db.exec(BASELINE_SCHEMA);
|
|
3714
3748
|
db.prepare("INSERT INTO schema_version (version) VALUES (?)").run(BASELINE_SCHEMA_VERSION);
|
|
3749
|
+
const installedAt = new Date().toISOString();
|
|
3750
|
+
db.prepare("INSERT INTO installation_fact (key, value, created_at) VALUES ('installed-at', ?, ?)").run(installedAt, installedAt);
|
|
3715
3751
|
db.exec("COMMIT");
|
|
3716
3752
|
}
|
|
3717
3753
|
catch (error) {
|
|
@@ -12140,13 +12176,46 @@ export class Store {
|
|
|
12140
12176
|
* legacy first-attempt behavior, unchanged byte-for-byte.
|
|
12141
12177
|
*/
|
|
12142
12178
|
firstBuilderBase(taskRef, branch) {
|
|
12179
|
+
// A branch moved to current main (fresh base) had no commits of its own:
|
|
12180
|
+
// its origin is the first base recorded after the move, not the older main.
|
|
12143
12181
|
const row = this.db
|
|
12144
12182
|
.prepare(`SELECT base_revision FROM run
|
|
12145
12183
|
WHERE task_ref = ? AND branch = ? AND role = 'builder' AND base_revision IS NOT NULL
|
|
12184
|
+
AND id > COALESCE((SELECT after_run FROM branch_base_move WHERE task_ref = ? AND branch = ? ORDER BY id DESC LIMIT 1), 0)
|
|
12146
12185
|
ORDER BY id ASC LIMIT 1`)
|
|
12147
|
-
.get(taskRef, branch);
|
|
12186
|
+
.get(taskRef, branch, taskRef, branch);
|
|
12187
|
+
return row === undefined ? null : row.base_revision;
|
|
12188
|
+
}
|
|
12189
|
+
/** Where a task branch's attempts start now: a builder branch's pinned base, or the newest base any other attempt on
|
|
12190
|
+
* it recorded (a planner never commits). Null before any attempt on the branch recorded one, or after a move to
|
|
12191
|
+
* current main until the next attempt records its base. */
|
|
12192
|
+
recordedBranchBase(taskRef, branch) {
|
|
12193
|
+
const pinned = this.firstBuilderBase(taskRef, branch);
|
|
12194
|
+
if (pinned !== null)
|
|
12195
|
+
return pinned;
|
|
12196
|
+
const row = this.db
|
|
12197
|
+
.prepare(`SELECT base_revision FROM run
|
|
12198
|
+
WHERE task_ref = ? AND branch = ? AND role <> 'builder' AND base_revision IS NOT NULL
|
|
12199
|
+
AND id > COALESCE((SELECT after_run FROM branch_base_move WHERE task_ref = ? AND branch = ? ORDER BY id DESC LIMIT 1), 0)
|
|
12200
|
+
ORDER BY id DESC LIMIT 1`)
|
|
12201
|
+
.get(taskRef, branch, taskRef, branch);
|
|
12148
12202
|
return row === undefined ? null : row.base_revision;
|
|
12149
12203
|
}
|
|
12204
|
+
/** Record a task branch's move to current main, before git moves it: a move that then fails leaves the next
|
|
12205
|
+
* attempt's own base (still the old one) as the branch's origin, which is exactly right. */
|
|
12206
|
+
recordBranchBaseMove(taskRef, branch, from, to, now) {
|
|
12207
|
+
this.db
|
|
12208
|
+
.prepare(`INSERT INTO branch_base_move (task_ref, branch, from_rev, to_rev, after_run, moved_at)
|
|
12209
|
+
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(id), 0) FROM run), ?)`)
|
|
12210
|
+
.run(taskRef, branch, from, to, now.toISOString());
|
|
12211
|
+
}
|
|
12212
|
+
/** The newest move of a task branch to current main, or null. */
|
|
12213
|
+
latestBranchBaseMove(taskRef, branch) {
|
|
12214
|
+
const row = this.db
|
|
12215
|
+
.prepare("SELECT from_rev, to_rev, after_run, moved_at FROM branch_base_move WHERE task_ref = ? AND branch = ? ORDER BY id DESC LIMIT 1")
|
|
12216
|
+
.get(taskRef, branch);
|
|
12217
|
+
return row === undefined ? null : { from: String(row["from_rev"]), to: String(row["to_rev"]), afterRun: Number(row["after_run"]), movedAt: String(row["moved_at"]) };
|
|
12218
|
+
}
|
|
12150
12219
|
finishRun(id, result) {
|
|
12151
12220
|
this.transact(() => {
|
|
12152
12221
|
// Read before the write (Telegram task updates): only the OPEN → closed
|
|
@@ -15594,17 +15663,38 @@ export class Store {
|
|
|
15594
15663
|
// ---- projects ------------------------------------------------------------
|
|
15595
15664
|
/** Remember a project was opened. Upsert keeps added_at; recency always moves.
|
|
15596
15665
|
* A project added for the first time starts on Quick checks. */
|
|
15597
|
-
upsertProject(path, name, now) {
|
|
15666
|
+
upsertProject(path, name, now, admission) {
|
|
15598
15667
|
this.transact(() => {
|
|
15599
15668
|
const added = this.db.prepare("SELECT 1 FROM project WHERE path = ?").get(path) === undefined;
|
|
15600
15669
|
this.db
|
|
15601
15670
|
.prepare(`INSERT INTO project (path, name, added_at, last_opened_at) VALUES (?, ?, ?, ?)
|
|
15602
15671
|
ON CONFLICT (path) DO UPDATE SET name = excluded.name, last_opened_at = excluded.last_opened_at`)
|
|
15603
15672
|
.run(path, name, now.toISOString(), now.toISOString());
|
|
15673
|
+
// Admission owns remote changes; ordinary openings preserve the saved value.
|
|
15674
|
+
if (admission !== undefined)
|
|
15675
|
+
this.db.prepare("UPDATE project SET remote_url = ? WHERE path = ?").run(admission.remoteUrl, path);
|
|
15604
15676
|
if (added)
|
|
15605
15677
|
stampNewProjectLevel(this, path, now);
|
|
15606
15678
|
});
|
|
15607
15679
|
}
|
|
15680
|
+
/** Includes removed/legacy projects, so deleting a row never makes build-writable config authoritative again. */
|
|
15681
|
+
projectAdmissionKnown(path) {
|
|
15682
|
+
return this.db.prepare(`SELECT 1 FROM project WHERE path = ?
|
|
15683
|
+
UNION ALL SELECT 1 FROM action_ledger WHERE repo = ?
|
|
15684
|
+
UNION ALL SELECT 1 FROM task_ref WHERE repo = ?
|
|
15685
|
+
UNION ALL SELECT 1 FROM worktree WHERE repo = ? LIMIT 1`).get(path, path, path, path) !== undefined;
|
|
15686
|
+
}
|
|
15687
|
+
/** Full structured URL audit, without the generic ledger's display-text truncation. */
|
|
15688
|
+
recordProjectRemote(actor, repo, previous, remoteUrl, now) {
|
|
15689
|
+
this.db.prepare(`INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source,detail)
|
|
15690
|
+
VALUES (?,?,?,NULL,NULL,'project remote recorded','recorded','access',?)`)
|
|
15691
|
+
.run(now.toISOString(), actor, repo, JSON.stringify({ previousRemote: previous, remoteUrl }));
|
|
15692
|
+
}
|
|
15693
|
+
/** The remote URL captured on admission or explicitly supplied by the owner; null when none was (or the project was removed). */
|
|
15694
|
+
projectRemote(path) {
|
|
15695
|
+
const url = this.db.prepare("SELECT remote_url FROM project WHERE path = ?").get(path)?.["remote_url"];
|
|
15696
|
+
return typeof url === "string" && url !== "" ? url : null;
|
|
15697
|
+
}
|
|
15608
15698
|
/** A project removed from the lists (project-admission.ts): only its row goes; its tasks, results and settings stay. */
|
|
15609
15699
|
forgetProject(path) {
|
|
15610
15700
|
this.db.prepare("DELETE FROM project WHERE path = ?").run(path);
|
|
@@ -15619,6 +15709,7 @@ export class Store {
|
|
|
15619
15709
|
name: String(row["name"]),
|
|
15620
15710
|
addedAt: String(row["added_at"]),
|
|
15621
15711
|
lastOpenedAt: String(row["last_opened_at"]),
|
|
15712
|
+
remoteUrl: typeof row["remote_url"] === "string" ? row["remote_url"] : null,
|
|
15622
15713
|
}));
|
|
15623
15714
|
}
|
|
15624
15715
|
/**
|
package/dist/surface.js
CHANGED
|
@@ -67,9 +67,11 @@ const GUIDE_ROWS = [
|
|
|
67
67
|
// D5: the central team service (connect, the central lead verbs, conversation …) and native coding sessions are
|
|
68
68
|
// deprecated: they still run this release, with a warning, but are no longer declared here.
|
|
69
69
|
operator("chat", "talk to your lead: one conversation across every project; --say sends one message; --follow enables crew updates and --no-follow pauses them; proposals keep their approval requirements"),
|
|
70
|
-
|
|
70
|
+
// Unkeyed: --remote with a different URL replaces the project's trusted remote (recorded in the action ledger).
|
|
71
|
+
{ invocation: "repos add", synopsis: "add a project: a Git repository, in the console at once; the lead adds with its token; --remote records or replaces its trusted remote", audience: "agent", agentMayInvoke: true, mutation: "unkeyed",
|
|
71
72
|
positionals: [{ name: "path", required: false, meaning: "the repository (default: the one you are in)" }],
|
|
72
|
-
flags: [jsonFlag, { name: "token", takesValue: true, meaning: "the lead token (or TOOLROLL_LEAD_TOKEN)" }
|
|
73
|
+
flags: [jsonFlag, { name: "token", takesValue: true, meaning: "the lead token (or TOOLROLL_LEAD_TOKEN)" },
|
|
74
|
+
{ name: "remote", takesValue: true, meaning: "an HTTPS or SSH URL without credentials whose main may complete landed work; replaces a different recorded one (owner or an instance operator's lead only)" }],
|
|
73
75
|
notableReasons: ["not-git", "unavailable", "refused", "locked"] },
|
|
74
76
|
{ invocation: "repos remove", synopsis: "remove a project from the console and the builder; its tasks and results stay saved", audience: "agent", agentMayInvoke: true, mutation: "identity-idempotent",
|
|
75
77
|
positionals: [{ name: "path", required: true, meaning: "the repository" }],
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a task's work starts, against current main (fresh base): one view for
|
|
3
|
+
* the task page and `task show`. The base is saved run lineage — a builder
|
|
4
|
+
* branch's pinned origin, or the newest plan's base — and the behind count is
|
|
5
|
+
* read from Git now. Reads only.
|
|
6
|
+
*/
|
|
7
|
+
import type { Store } from "./store.js";
|
|
8
|
+
export type TaskBaseView = {
|
|
9
|
+
/** The base commit, in full. */
|
|
10
|
+
base: string;
|
|
11
|
+
branch: string;
|
|
12
|
+
/** The project's main: its checked-out branch, as a dispatch pass resolves it. Null when Git could not say. */
|
|
13
|
+
main: string | null;
|
|
14
|
+
/** Commits on main the base does not have; null when Git could not say. */
|
|
15
|
+
behind: number | null;
|
|
16
|
+
/** One short line: `Base 9d087efe · Behind main by 2 commits`. */
|
|
17
|
+
words: string;
|
|
18
|
+
};
|
|
19
|
+
/** A synchronous, read-only Git call in `cwd`: its trimmed output, or null on any failure. */
|
|
20
|
+
export type GitRead = (cwd: string, args: readonly string[]) => string | null;
|
|
21
|
+
export declare const readGit: GitRead;
|
|
22
|
+
/** The base line on its own, for surfaces that label it already: `9d087efe · Behind main by 2 commits`. */
|
|
23
|
+
export declare function baseStandingWords(view: Pick<TaskBaseView, "base" | "main" | "behind">): string;
|
|
24
|
+
/**
|
|
25
|
+
* The lease's fresh-base request for one attempt on `branch`, or nothing when the task's start is pinned elsewhere: a
|
|
26
|
+
* revision keeps the head of the attempt it revises, and a prepared candidate or coding review keeps the base its
|
|
27
|
+
* candidate was proved against.
|
|
28
|
+
*/
|
|
29
|
+
export declare function freshBaseFor(store: Store, taskId: string, branch: string, main: string): {
|
|
30
|
+
freshBase: {
|
|
31
|
+
main: string;
|
|
32
|
+
recordedBase: string | null;
|
|
33
|
+
};
|
|
34
|
+
} | Record<string, never>;
|
|
35
|
+
export declare function taskBaseOf(store: Store, taskId: string, git?: GitRead): TaskBaseView | null;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a task's work starts, against current main (fresh base): one view for
|
|
3
|
+
* the task page and `task show`. The base is saved run lineage — a builder
|
|
4
|
+
* branch's pinned origin, or the newest plan's base — and the behind count is
|
|
5
|
+
* read from Git now. Reads only.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
export const readGit = (cwd, args) => {
|
|
9
|
+
try {
|
|
10
|
+
return execFileSync("git", ["--no-optional-locks", ...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, maxBuffer: 65_536 }).trim();
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const commits = (count) => `${count} commit${count === 1 ? "" : "s"}`;
|
|
17
|
+
/** The base line on its own, for surfaces that label it already: `9d087efe · Behind main by 2 commits`. */
|
|
18
|
+
export function baseStandingWords(view) {
|
|
19
|
+
const short = view.base.slice(0, 8);
|
|
20
|
+
if (view.main === null || view.behind === null)
|
|
21
|
+
return short;
|
|
22
|
+
return view.behind === 0 ? `${short} · Up to date with ${view.main}` : `${short} · Behind ${view.main} by ${commits(view.behind)}`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The lease's fresh-base request for one attempt on `branch`, or nothing when the task's start is pinned elsewhere: a
|
|
26
|
+
* revision keeps the head of the attempt it revises, and a prepared candidate or coding review keeps the base its
|
|
27
|
+
* candidate was proved against.
|
|
28
|
+
*/
|
|
29
|
+
export function freshBaseFor(store, taskId, branch, main) {
|
|
30
|
+
const ref = store.lookupRef(taskId);
|
|
31
|
+
if (ref === null || ref.revisionOf !== null || store.getScope(taskId)?.candidate != null || store.filedViaOf(taskId)?.startsWith("coding:") === true)
|
|
32
|
+
return {};
|
|
33
|
+
return { freshBase: { main, recordedBase: store.recordedBranchBase(ref.id, branch) } };
|
|
34
|
+
}
|
|
35
|
+
export function taskBaseOf(store, taskId, git = readGit) {
|
|
36
|
+
const ref = store.lookupRef(taskId);
|
|
37
|
+
if (ref === null || ref.repo === null)
|
|
38
|
+
return null;
|
|
39
|
+
const latest = store.runsFor(ref.id).find(one => one.role !== "reviewer" && one.branch !== null && one.baseRevision !== null);
|
|
40
|
+
if (latest === undefined || latest.branch === null || latest.baseRevision === null)
|
|
41
|
+
return null;
|
|
42
|
+
const branch = latest.branch;
|
|
43
|
+
// A move to current main newer than the latest attempt is where the next one starts.
|
|
44
|
+
const move = store.latestBranchBaseMove(ref.id, branch);
|
|
45
|
+
const base = move !== null && move.afterRun >= latest.id ? move.to : store.recordedBranchBase(ref.id, branch) ?? latest.baseRevision;
|
|
46
|
+
const main = git(ref.repo, ["symbolic-ref", "--short", "-q", "HEAD"]) || null;
|
|
47
|
+
const counted = main === null ? null : git(ref.repo, ["rev-list", "--count", `${base}..refs/heads/${main}`]);
|
|
48
|
+
const behind = counted === null || !/^\d+$/.test(counted) ? null : Number(counted);
|
|
49
|
+
const view = { base, branch, main: behind === null ? null : main, behind };
|
|
50
|
+
return { ...view, words: `Base ${baseStandingWords(view)}` };
|
|
51
|
+
}
|
package/dist/toolroll-update.js
CHANGED
|
@@ -35,7 +35,7 @@ import { processMayBeAlive } from "./process-liveness.js";
|
|
|
35
35
|
import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner, removeCodingUpdateGate } from "./coding-update.js";
|
|
36
36
|
import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
|
|
37
37
|
import { NAME } from "./names.js";
|
|
38
|
-
import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS } from "./store.js";
|
|
38
|
+
import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS } from "./store.js";
|
|
39
39
|
import { isNewer, REGISTRY } from "./releases.js";
|
|
40
40
|
import { markNeverIndex } from "./never-index.js";
|
|
41
41
|
import { readRuntimeUpdateJournal, RUNTIME_PHASES, RUNTIME_UPDATE_STEPS, stagedStartedAt, updaterStartingOf } from "./contracts/update-journal.js";
|
|
@@ -399,7 +399,8 @@ const HISTORY_RULES = {
|
|
|
399
399
|
// v120: a steering note's mailing and acknowledgement, and a run's steering folder, are new columns; every row
|
|
400
400
|
// saved before them reads none.
|
|
401
401
|
// v121: live steering's columns and a run's attempt key read none, and no sends, the same way.
|
|
402
|
-
|
|
402
|
+
// v122: a project's recorded remote reads none the same way.
|
|
403
|
+
...addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS),
|
|
403
404
|
};
|
|
404
405
|
/** The tables a migration may remove whole (v115's removed features). */
|
|
405
406
|
export const RETIRED_TABLES = Object.freeze(Object.keys(HISTORY_RULES).filter(name => HISTORY_RULES[name].retired));
|
package/dist/worktree.d.ts
CHANGED
|
@@ -52,6 +52,32 @@ export type LeaseRequest = {
|
|
|
52
52
|
* stands, as its kept checkout would have been reused. Without it, `base` always starts a new branch (and an
|
|
53
53
|
* existing one refuses). */
|
|
54
54
|
reuseBranch?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Start from current main (fresh base): a checkout still at its recorded base — no commits of its own — and with no
|
|
57
|
+
* changes moves, with its branch, to `main` before the attempt. Anything else is never moved; the result says how
|
|
58
|
+
* far behind main it is. Needs `taskRef`. `recordedBase` null means no attempt on the branch recorded one yet, and
|
|
59
|
+
* then only a HEAD that main already contains counts as having no commits of its own.
|
|
60
|
+
*/
|
|
61
|
+
freshBase?: {
|
|
62
|
+
main: string;
|
|
63
|
+
recordedBase: string | null;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
/** Where a checkout stands against current main once its lease is granted (fresh base). */
|
|
67
|
+
export type BaseStanding = {
|
|
68
|
+
/** HEAD as the attempt starts. */
|
|
69
|
+
head: string;
|
|
70
|
+
/** Current main's commit. */
|
|
71
|
+
main: string;
|
|
72
|
+
/** Commits on main that HEAD does not have. */
|
|
73
|
+
behind: number;
|
|
74
|
+
/** Set when this lease moved the checkout to main: where it was, and how far behind that was. */
|
|
75
|
+
moved: {
|
|
76
|
+
from: string;
|
|
77
|
+
behind: number;
|
|
78
|
+
} | null;
|
|
79
|
+
/** Why a checkout behind main stayed where it is. */
|
|
80
|
+
kept: "has commits" | "has changes" | "not on its branch" | null;
|
|
55
81
|
};
|
|
56
82
|
export type LeaseResult = {
|
|
57
83
|
ok: true;
|
|
@@ -60,6 +86,8 @@ export type LeaseResult = {
|
|
|
60
86
|
reclaimed?: string;
|
|
61
87
|
resumedFromRun?: number;
|
|
62
88
|
recoveryKind?: "completed" | "partial";
|
|
89
|
+
/** With `freshBase`: where the checkout stands against main (null when main could not be read). */
|
|
90
|
+
base?: BaseStanding | null;
|
|
63
91
|
} | {
|
|
64
92
|
ok: false;
|
|
65
93
|
reason: LeaseFailure;
|
|
@@ -76,6 +104,13 @@ export type KeptWhy = "has changes" | "has commits" | "unreadable" | "git refuse
|
|
|
76
104
|
* something is in there.
|
|
77
105
|
*/
|
|
78
106
|
export declare const MARKER = ".standing-orders-lease";
|
|
107
|
+
/**
|
|
108
|
+
* The reflog message of a branch Toolroll created, naming it: its creation entry is the proof, later, that the branch
|
|
109
|
+
* is Toolroll's and how old it is. A branch copied or renamed from it names the old branch, so it proves nothing.
|
|
110
|
+
*/
|
|
111
|
+
export declare const branchCreatedMessage: (branch: string) => string;
|
|
112
|
+
/** Scouts' disposable branches: never landed work. */
|
|
113
|
+
export declare const SCOUT_PREFIX = "toolroll-scout/";
|
|
79
114
|
/** What a kept checkout drops once its run ends (only where .gitignore hides it): dependencies and build output. */
|
|
80
115
|
export declare const SLIM_NAMES: readonly string[];
|
|
81
116
|
/** Creating a worktree copies a tree; it is local work but not instant. */
|
|
@@ -157,6 +192,13 @@ export declare class WorktreePool {
|
|
|
157
192
|
recordProviderOccupancy(path: string, runner: string, providerPid: number, leaseEpoch?: string | null): void;
|
|
158
193
|
lease(request: LeaseRequest): Promise<LeaseResult>;
|
|
159
194
|
private leaseNow;
|
|
195
|
+
/**
|
|
196
|
+
* Move an untouched checkout to current main, or say how far behind it is. Untouched means all three: on its own
|
|
197
|
+
* branch, HEAD still the recorded base (or, with none recorded, a commit main already has), and nothing changed or
|
|
198
|
+
* untracked. The move is `git reset --keep`, which refuses rather than overwrite anything; HEAD and the tree are read
|
|
199
|
+
* again right before and after it, and any surprise fails the lease with the checkout left for a person.
|
|
200
|
+
*/
|
|
201
|
+
private freshen;
|
|
160
202
|
/**
|
|
161
203
|
* A crash can strand either a finished handoff or a partial source draft.
|
|
162
204
|
* The expired lease can no longer authorize a commit, so this never ingests
|
|
@@ -245,7 +287,8 @@ export declare class WorktreePool {
|
|
|
245
287
|
/**
|
|
246
288
|
* Keep storage in check: remove the working copies nobody needs any more. Of `rows`, a checkout goes when it is
|
|
247
289
|
* let go, nothing holds it, `wanted` says it may go (asked again right before it goes, so a task coming back or a
|
|
248
|
-
* lease since keeps it) and `inspect` finds it clean
|
|
290
|
+
* lease since keeps it) and `inspect` finds it clean, then clean still right before it goes (git refuses an edit
|
|
291
|
+
* made after that). Only the working copy goes: its branch, and so every commit,
|
|
249
292
|
* stays, and a later lease of the same branch makes a new one. At most `max` go per pass.
|
|
250
293
|
*/
|
|
251
294
|
prune(rows: readonly WorktreeRow[], wanted: (row: WorktreeRow, fresh: boolean) => boolean | Promise<boolean>, max?: number): Promise<{
|
|
@@ -280,8 +323,17 @@ export declare class WorktreePool {
|
|
|
280
323
|
ok: false;
|
|
281
324
|
message: string;
|
|
282
325
|
}>;
|
|
283
|
-
/**
|
|
326
|
+
/** Throw away one idle checkout's working copy, changes and all (the caller holds it in `busy`), and forget its row.
|
|
327
|
+
* Only `discardChanges`, where losing them is what a person asked for. */
|
|
284
328
|
private removeWorkingCopy;
|
|
329
|
+
/**
|
|
330
|
+
* Remove one idle checkout's working copy only while it is still clean (the caller holds it in `busy`, and its
|
|
331
|
+
* reservation outside the checkout holds it for other processes): inspected once more right before it goes, then
|
|
332
|
+
* removed without --force, so git itself refuses an edit made since. Only Toolroll's own files go first, because
|
|
333
|
+
* git's check would count them.
|
|
334
|
+
*/
|
|
335
|
+
private removeCleanCopy;
|
|
336
|
+
private forgetRemoved;
|
|
285
337
|
/**
|
|
286
338
|
* Whether anybody's work is in there. null means we could not tell, which is
|
|
287
339
|
* neither clean nor dirty and must not be rounded to either.
|
package/dist/worktree.js
CHANGED
|
@@ -40,6 +40,13 @@ import { markNeverIndex } from "./never-index.js";
|
|
|
40
40
|
* something is in there.
|
|
41
41
|
*/
|
|
42
42
|
export const MARKER = ".standing-orders-lease";
|
|
43
|
+
/**
|
|
44
|
+
* The reflog message of a branch Toolroll created, naming it: its creation entry is the proof, later, that the branch
|
|
45
|
+
* is Toolroll's and how old it is. A branch copied or renamed from it names the old branch, so it proves nothing.
|
|
46
|
+
*/
|
|
47
|
+
export const branchCreatedMessage = (branch) => `Toolroll created branch ${branch}`;
|
|
48
|
+
/** Scouts' disposable branches: never landed work. */
|
|
49
|
+
export const SCOUT_PREFIX = "toolroll-scout/";
|
|
43
50
|
/** Checkouts this process is leasing or removing right now: a lease and storage retention never overlap on one. */
|
|
44
51
|
const busy = new Map();
|
|
45
52
|
// Outside the checkout so removal cannot erase the reservation before git and the row update finish.
|
|
@@ -407,17 +414,34 @@ export class WorktreePool {
|
|
|
407
414
|
catch { /* git reports it */ }
|
|
408
415
|
markNeverIndex(this.options.root);
|
|
409
416
|
markNeverIndex(dirname(path));
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
+
// A new branch is created first, on its own, so its reflog's first entry says Toolroll made it and when.
|
|
418
|
+
let made = null;
|
|
419
|
+
if (fromBase) {
|
|
420
|
+
const base = await this.git(request.repo, ["rev-parse", "--verify", "--quiet", `${request.base}^{commit}`], READ_ONLY);
|
|
421
|
+
const oid = base.stdout.trim();
|
|
422
|
+
if (base.code !== 0 || !/^[0-9a-f]{40,64}$/.test(oid))
|
|
423
|
+
return { ok: false, reason: "git", message: `git couldn't find ${request.base}` };
|
|
424
|
+
const branch = await this.git(request.repo, ["update-ref", "--create-reflog", "-m", branchCreatedMessage(request.branch), `refs/heads/${request.branch}`, oid, "0".repeat(oid.length)]);
|
|
425
|
+
if (branch.code !== 0)
|
|
426
|
+
return { ok: false, reason: "git", message: firstLine(branch.stderr) };
|
|
427
|
+
made = oid;
|
|
428
|
+
}
|
|
429
|
+
const add = await this.git(request.repo, ["worktree", "add", path, request.branch]);
|
|
417
430
|
if (add.code !== 0) {
|
|
431
|
+
if (made !== null)
|
|
432
|
+
await this.git(request.repo, ["update-ref", "-d", `refs/heads/${request.branch}`, made]);
|
|
418
433
|
return { ok: false, reason: "git", message: firstLine(add.stderr) };
|
|
419
434
|
}
|
|
420
435
|
}
|
|
436
|
+
// Fresh base, inside the same reservation as everything above: nothing else leases, prunes or slims this
|
|
437
|
+
// checkout between the inspection and the move.
|
|
438
|
+
let base;
|
|
439
|
+
if (request.freshBase !== undefined && request.taskRef !== undefined) {
|
|
440
|
+
const fresh = await this.freshen(path, request.branch, request.taskRef, request.freshBase, request.now);
|
|
441
|
+
if (!fresh.ok)
|
|
442
|
+
return { ok: false, reason: "git", message: fresh.message };
|
|
443
|
+
base = fresh.standing;
|
|
444
|
+
}
|
|
421
445
|
const row = {
|
|
422
446
|
path,
|
|
423
447
|
repo: request.repo,
|
|
@@ -453,7 +477,49 @@ export class WorktreePool {
|
|
|
453
477
|
...(reclaimed === undefined ? {} : { reclaimed }),
|
|
454
478
|
...(resumedFromRun === undefined ? {} : { resumedFromRun }),
|
|
455
479
|
...(recoveryKind === undefined ? {} : { recoveryKind }),
|
|
480
|
+
...(base === undefined ? {} : { base }),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Move an untouched checkout to current main, or say how far behind it is. Untouched means all three: on its own
|
|
485
|
+
* branch, HEAD still the recorded base (or, with none recorded, a commit main already has), and nothing changed or
|
|
486
|
+
* untracked. The move is `git reset --keep`, which refuses rather than overwrite anything; HEAD and the tree are read
|
|
487
|
+
* again right before and after it, and any surprise fails the lease with the checkout left for a person.
|
|
488
|
+
*/
|
|
489
|
+
async freshen(path, branch, taskRef, fresh, now) {
|
|
490
|
+
const read = async (args) => {
|
|
491
|
+
const out = await this.runner(GIT, [...READ_ONLY, ...args], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
492
|
+
return out.code === 0 ? out.stdout.trim() : null;
|
|
456
493
|
};
|
|
494
|
+
const main = await read(["rev-parse", "--verify", "--quiet", `${fresh.main}^{commit}`]);
|
|
495
|
+
const head = await read(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
|
496
|
+
if (main === null || head === null)
|
|
497
|
+
return { ok: true, standing: null };
|
|
498
|
+
const count = await read(["rev-list", "--count", `${head}..${main}`]);
|
|
499
|
+
const behind = count === null ? NaN : Number(count);
|
|
500
|
+
if (!Number.isSafeInteger(behind))
|
|
501
|
+
return { ok: true, standing: null };
|
|
502
|
+
const stay = (kept) => ({ ok: true, standing: { head, main, behind, moved: null, kept } });
|
|
503
|
+
if (behind === 0)
|
|
504
|
+
return stay(null);
|
|
505
|
+
if (await read(["symbolic-ref", "-q", "HEAD"]) !== `refs/heads/${branch}`)
|
|
506
|
+
return stay("not on its branch");
|
|
507
|
+
if (fresh.recordedBase !== null && fresh.recordedBase !== head)
|
|
508
|
+
return stay("has commits");
|
|
509
|
+
const contained = await this.runner(GIT, [...READ_ONLY, "merge-base", "--is-ancestor", head, main], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
510
|
+
if (contained.code !== 0)
|
|
511
|
+
return stay("has commits");
|
|
512
|
+
if (await this.isDirty(path) !== false)
|
|
513
|
+
return stay("has changes");
|
|
514
|
+
// Recorded before git moves it: a move that then fails leaves the next attempt's own base as the branch's origin.
|
|
515
|
+
this.store.recordBranchBaseMove(taskRef, branch, head, main, now);
|
|
516
|
+
const moved = await this.runner(GIT, ["reset", "--keep", main], { cwd: path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
517
|
+
const after = await read(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]);
|
|
518
|
+
const branchNow = await read(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}^{commit}`]);
|
|
519
|
+
if (moved.code !== 0 || after !== main || branchNow !== main || await this.isDirty(path) !== false) {
|
|
520
|
+
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` };
|
|
521
|
+
}
|
|
522
|
+
return { ok: true, standing: { head: main, main, behind: 0, moved: { from: head, behind }, kept: null } };
|
|
457
523
|
}
|
|
458
524
|
/**
|
|
459
525
|
* A crash can strand either a finished handoff or a partial source draft.
|
|
@@ -739,7 +805,8 @@ export class WorktreePool {
|
|
|
739
805
|
/**
|
|
740
806
|
* Keep storage in check: remove the working copies nobody needs any more. Of `rows`, a checkout goes when it is
|
|
741
807
|
* 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
|
|
808
|
+
* lease since keeps it) and `inspect` finds it clean, then clean still right before it goes (git refuses an edit
|
|
809
|
+
* made after that). Only the working copy goes: its branch, and so every commit,
|
|
743
810
|
* stays, and a later lease of the same branch makes a new one. At most `max` go per pass.
|
|
744
811
|
*/
|
|
745
812
|
async prune(rows, wanted, max = Infinity) {
|
|
@@ -776,9 +843,9 @@ export class WorktreePool {
|
|
|
776
843
|
const allowed = wanted(now, true);
|
|
777
844
|
if (!(typeof allowed === "boolean" ? allowed : await allowed))
|
|
778
845
|
continue;
|
|
779
|
-
const gone = await this.
|
|
780
|
-
if (
|
|
781
|
-
kept.push({ path: row.path, why:
|
|
846
|
+
const gone = await this.removeCleanCopy(row);
|
|
847
|
+
if (gone !== "removed") {
|
|
848
|
+
kept.push({ path: row.path, why: gone });
|
|
782
849
|
continue;
|
|
783
850
|
}
|
|
784
851
|
removed.push(row);
|
|
@@ -871,7 +938,8 @@ export class WorktreePool {
|
|
|
871
938
|
busy.delete(path);
|
|
872
939
|
}
|
|
873
940
|
}
|
|
874
|
-
/**
|
|
941
|
+
/** Throw away one idle checkout's working copy, changes and all (the caller holds it in `busy`), and forget its row.
|
|
942
|
+
* Only `discardChanges`, where losing them is what a person asked for. */
|
|
875
943
|
async removeWorkingCopy(row) {
|
|
876
944
|
// Held while it goes: another process's lease sees this process in it (the busy map is this process's only).
|
|
877
945
|
const note = join(row.path, MARKER);
|
|
@@ -886,12 +954,39 @@ export class WorktreePool {
|
|
|
886
954
|
rmSync(note, { force: true });
|
|
887
955
|
return false;
|
|
888
956
|
}
|
|
957
|
+
this.forgetRemoved(row);
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Remove one idle checkout's working copy only while it is still clean (the caller holds it in `busy`, and its
|
|
962
|
+
* reservation outside the checkout holds it for other processes): inspected once more right before it goes, then
|
|
963
|
+
* removed without --force, so git itself refuses an edit made since. Only Toolroll's own files go first, because
|
|
964
|
+
* git's check would count them.
|
|
965
|
+
*/
|
|
966
|
+
async removeCleanCopy(row) {
|
|
967
|
+
const found = await this.inspect(row.path);
|
|
968
|
+
if (found !== "clean")
|
|
969
|
+
return found;
|
|
970
|
+
const status = await this.runner(GIT, [...READ_ONLY, "status", "--porcelain", "--untracked-files=all"], { cwd: row.path, timeoutMs: WORKTREE_TIMEOUT_MS });
|
|
971
|
+
if (status.code !== 0)
|
|
972
|
+
return "unreadable";
|
|
973
|
+
for (const line of status.stdout.split("\n")) {
|
|
974
|
+
const name = line.startsWith("?? ") ? line.slice(3).trim() : "";
|
|
975
|
+
if (name === MARKER || (name !== "" && looksLikeProtocolFile(name)))
|
|
976
|
+
rmSync(join(row.path, name), { recursive: true, force: true });
|
|
977
|
+
}
|
|
978
|
+
const gone = await this.git(row.repo, ["worktree", "remove", row.path]);
|
|
979
|
+
if (gone.code !== 0)
|
|
980
|
+
return (await this.isDirty(row.path)) === false ? "git refused" : "has changes";
|
|
981
|
+
this.forgetRemoved(row);
|
|
982
|
+
return "removed";
|
|
983
|
+
}
|
|
984
|
+
forgetRemoved(row) {
|
|
889
985
|
this.store.transact(() => {
|
|
890
986
|
const still = this.store.getWorktree(row.path);
|
|
891
987
|
if (still !== null && still.runner === null && still.releasedAt === row.releasedAt)
|
|
892
988
|
this.store.forgetWorktree(row.path);
|
|
893
989
|
});
|
|
894
|
-
return true;
|
|
895
990
|
}
|
|
896
991
|
/**
|
|
897
992
|
* Whether anybody's work is in there. null means we could not tell, which is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolroll",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.62",
|
|
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"
|