toolroll 0.8.0 → 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 +40 -0
- package/README.md +5 -5
- package/dist/agent-onboard.d.ts +45 -18
- package/dist/agent-onboard.js +146 -57
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +14 -13
- package/dist/checkout-cleanup.d.ts +84 -0
- package/dist/checkout-cleanup.js +245 -0
- package/dist/daemon.d.ts +6 -2
- package/dist/daemon.js +6 -5
- package/dist/first-run.d.ts +8 -2
- package/dist/first-run.js +12 -8
- package/dist/install-method.d.ts +1 -1
- package/dist/install-method.js +4 -0
- package/dist/mcp.js +26 -0
- package/dist/operate.d.ts +2 -4
- package/dist/operate.js +167 -30
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +41 -0
- package/dist/serve.js +103 -28
- 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 +6 -6
- package/dist/store.js +23 -20
- package/dist/toolroll-update-cli.js +7 -3
- package/dist/toolroll-update-ui.d.ts +2 -0
- package/dist/toolroll-update-ui.js +7 -6
- package/dist/toolroll-update.d.ts +69 -15
- package/dist/toolroll-update.js +572 -110
- package/dist/worktree.d.ts +24 -10
- package/dist/worktree.js +84 -44
- package/package.json +1 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type CheckoutCleanup } from "./storage.js";
|
|
2
|
+
import type { Store } from "./store.js";
|
|
3
|
+
import { type WorktreePool } from "./worktree.js";
|
|
4
|
+
/** A release candidate's checkout holds the build a deploy installs: kept a week after its task was done. */
|
|
5
|
+
export declare const CANDIDATE_KEEP_MS: number;
|
|
6
|
+
/** The worker's pass removes at most this many; a clean-up by hand has no cap. */
|
|
7
|
+
export declare const AUTO_MAX = 20;
|
|
8
|
+
export type KeepWhy = "in use" | "task not finished" | "waiting for review" | "release candidate" | "has changes" | "has commits" | "unreadable" | "git refused" | "not due yet" | "cleanup is off" | "no task";
|
|
9
|
+
export type CheckoutItem = {
|
|
10
|
+
path: string;
|
|
11
|
+
repo: string;
|
|
12
|
+
branch: string;
|
|
13
|
+
taskId: string | null;
|
|
14
|
+
bytes: number;
|
|
15
|
+
releasedAt: string | null;
|
|
16
|
+
/** null: it goes. */
|
|
17
|
+
why: KeepWhy | null;
|
|
18
|
+
/** With "not due yet" or "release candidate": when it may go. */
|
|
19
|
+
dueAt?: string;
|
|
20
|
+
};
|
|
21
|
+
export type CheckoutPlan = {
|
|
22
|
+
at: string;
|
|
23
|
+
cleanup: CheckoutCleanup;
|
|
24
|
+
manual: boolean;
|
|
25
|
+
go: CheckoutItem[];
|
|
26
|
+
stay: CheckoutItem[];
|
|
27
|
+
/** Every checkout on disk. */
|
|
28
|
+
count: number;
|
|
29
|
+
totalBytes: number;
|
|
30
|
+
freeBytes: number;
|
|
31
|
+
waitingReview: number;
|
|
32
|
+
withChanges: number;
|
|
33
|
+
};
|
|
34
|
+
type Status = {
|
|
35
|
+
keep: KeepWhy | null;
|
|
36
|
+
taskId: string;
|
|
37
|
+
finishedAt: string | null;
|
|
38
|
+
dueAt?: string;
|
|
39
|
+
};
|
|
40
|
+
/** Where every task stands for cleanup, by task_ref id and by each branch it or its runs used. */
|
|
41
|
+
export declare function taskStatuses(store: Store, now: Date): {
|
|
42
|
+
byRef: Map<number, Status>;
|
|
43
|
+
byBranch: Map<string, Status>;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* What a clean-up would remove now, how much space it would free, and what stays and why. `manual` is a clean-up by
|
|
47
|
+
* hand (it doesn't wait for the setting); otherwise it is the worker's pass under the setting. Reads only.
|
|
48
|
+
*/
|
|
49
|
+
export declare function checkoutPlan(store: Store, pool: WorktreePool, now: Date, options: {
|
|
50
|
+
manual: boolean;
|
|
51
|
+
repo?: string;
|
|
52
|
+
}): Promise<CheckoutPlan>;
|
|
53
|
+
/** Names exactly what a preview would remove, so a confirmation removes that and nothing else. */
|
|
54
|
+
export declare function previewDigest(plan: Pick<CheckoutPlan, "go">): string;
|
|
55
|
+
export type CleanResult = {
|
|
56
|
+
removed: CheckoutItem[];
|
|
57
|
+
kept: {
|
|
58
|
+
path: string;
|
|
59
|
+
why: KeepWhy;
|
|
60
|
+
}[];
|
|
61
|
+
freed: number;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Remove what may go: the worker's pass (`manual` false: under the setting, at most AUTO_MAX, one repository) or a
|
|
65
|
+
* clean-up by hand (no cap, every repository). Each checkout is asked again right before it goes; each removal is in
|
|
66
|
+
* the ledger.
|
|
67
|
+
*/
|
|
68
|
+
export declare function cleanCheckouts(store: Store, pool: WorktreePool, now: () => Date, options: {
|
|
69
|
+
manual: boolean;
|
|
70
|
+
actor: string;
|
|
71
|
+
repo?: string;
|
|
72
|
+
only?: ReadonlySet<string>;
|
|
73
|
+
}): Promise<CleanResult>;
|
|
74
|
+
/** Throw away a checkout kept for its changes, on purpose; its branch stays. In the ledger. */
|
|
75
|
+
export declare function discardCheckout(store: Store, pool: WorktreePool, path: string, now: Date, actor: string): Promise<{
|
|
76
|
+
ok: true;
|
|
77
|
+
bytes: number;
|
|
78
|
+
} | {
|
|
79
|
+
ok: false;
|
|
80
|
+
message: string;
|
|
81
|
+
}>;
|
|
82
|
+
/** "Waiting for review", "Has changes" … as a sentence fragment for a person. */
|
|
83
|
+
export declare function whyWords(item: Pick<CheckoutItem, "why" | "dueAt">): string;
|
|
84
|
+
export {};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkout cleanup (Settings → Storage, `toolroll storage clean`): which task checkouts can go, and removing them.
|
|
3
|
+
*
|
|
4
|
+
* A finished task's clean checkout goes as the setting says: when its task is complete or cancelled (the default),
|
|
5
|
+
* 2 days or a week after that, or never (only a clean-up by hand). A clean-up by hand doesn't wait for the setting.
|
|
6
|
+
* Only the working copy goes: its branch, and so every commit, stays, and a later lease of the branch makes a new one.
|
|
7
|
+
*
|
|
8
|
+
* Always kept: a checkout in use, one whose task isn't finished (on hold, failed, a revision under way), a result
|
|
9
|
+
* still waiting for review, a release candidate for a week after it was done (a deploy installs from it), a checkout
|
|
10
|
+
* with changes (files Toolroll wrote itself aside), and one with commits on no branch. A checkout no task names
|
|
11
|
+
* (adopted after a crash) goes only in a clean-up by hand. Every removal is in the ledger.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { basename } from "node:path";
|
|
16
|
+
import { taskBranches } from "./names.js";
|
|
17
|
+
import { COMPLETION_ACTION } from "./result-completion.js";
|
|
18
|
+
import { CLEANUP_CHOICES, bytesWords, diskBytes } from "./storage.js";
|
|
19
|
+
import { worktreePath } from "./worktree.js";
|
|
20
|
+
const DAY_MS = 86_400_000;
|
|
21
|
+
/** A release candidate's checkout holds the build a deploy installs: kept a week after its task was done. */
|
|
22
|
+
export const CANDIDATE_KEEP_MS = 7 * DAY_MS;
|
|
23
|
+
/** The worker's pass removes at most this many; a clean-up by hand has no cap. */
|
|
24
|
+
export const AUTO_MAX = 20;
|
|
25
|
+
/** Git inspections at once. */
|
|
26
|
+
const INSPECT_AT_ONCE = 8;
|
|
27
|
+
const STRENGTH = { "in use": 5, "task not finished": 4, "waiting for review": 3, "release candidate": 2 };
|
|
28
|
+
const strength = (one) => one === undefined ? -1 : one.keep === null ? 0 : STRENGTH[one.keep] ?? 1;
|
|
29
|
+
const stronger = (a, b) => strength(b) > strength(a) || (strength(b) === 0 && strength(a) === 0 && (b.finishedAt ?? "") > (a.finishedAt ?? "")) ? b : a;
|
|
30
|
+
/** Where every task stands for cleanup, by task_ref id and by each branch it or its runs used. */
|
|
31
|
+
export function taskStatuses(store, now) {
|
|
32
|
+
const db = store.handle;
|
|
33
|
+
const refs = db.prepare(`SELECT r.id AS ref, r.external_id AS id, r.revision_of AS revision_of, t.state AS state, t.updated_at AS updated_at,
|
|
34
|
+
EXISTS (SELECT 1 FROM hold h WHERE h.task_ref = r.id) AS held,
|
|
35
|
+
EXISTS (SELECT 1 FROM run live WHERE live.task_ref = r.id AND live.finished_at IS NULL) AS live,
|
|
36
|
+
EXISTS (SELECT 1 FROM task_scope s WHERE s.task_id = r.external_id AND s.candidate IS NOT NULL) AS candidate
|
|
37
|
+
FROM task_ref r LEFT JOIN task t ON t.id = r.external_id AND r.backend = 'built-in'`).all();
|
|
38
|
+
const runs = db.prepare("SELECT id, task_ref, branch FROM run").all();
|
|
39
|
+
const refOfRun = new Map(runs.map(row => [Number(row["id"]), Number(row["task_ref"])]));
|
|
40
|
+
// The completion names its family's root; its run names the result it completes.
|
|
41
|
+
const completed = new Map();
|
|
42
|
+
const completedRef = new Map();
|
|
43
|
+
for (const row of db.prepare("SELECT task_id, run_id, at FROM action_ledger WHERE action = ?").all(COMPLETION_ACTION)) {
|
|
44
|
+
const at = String(row["at"]);
|
|
45
|
+
if (row["task_id"] !== null) {
|
|
46
|
+
const id = String(row["task_id"]);
|
|
47
|
+
if ((completed.get(id) ?? "") < at)
|
|
48
|
+
completed.set(id, at);
|
|
49
|
+
}
|
|
50
|
+
const ref = row["run_id"] === null ? undefined : refOfRun.get(Number(row["run_id"]));
|
|
51
|
+
if (ref !== undefined && (completedRef.get(ref) ?? "") < at)
|
|
52
|
+
completedRef.set(ref, at);
|
|
53
|
+
}
|
|
54
|
+
const byRef = new Map();
|
|
55
|
+
const byId = new Map();
|
|
56
|
+
const parentOf = new Map();
|
|
57
|
+
for (const row of refs) {
|
|
58
|
+
const ref = Number(row["ref"]);
|
|
59
|
+
const id = String(row["id"]);
|
|
60
|
+
const state = row["state"] === null ? null : String(row["state"]);
|
|
61
|
+
const updated = row["updated_at"] === null ? null : String(row["updated_at"]);
|
|
62
|
+
let status;
|
|
63
|
+
if (Number(row["live"]) === 1)
|
|
64
|
+
status = { keep: "in use", taskId: id, finishedAt: null };
|
|
65
|
+
else if (state === null || Number(row["held"]) === 1 || (state !== "done" && state !== "cancelled"))
|
|
66
|
+
status = { keep: "task not finished", taskId: id, finishedAt: null };
|
|
67
|
+
else if (state === "cancelled")
|
|
68
|
+
status = { keep: null, taskId: id, finishedAt: updated };
|
|
69
|
+
else {
|
|
70
|
+
const completedAt = [completed.get(id), completedRef.get(ref)].filter((one) => one !== undefined).sort().at(-1) ?? null;
|
|
71
|
+
if (Number(row["candidate"]) === 1) {
|
|
72
|
+
const since = completedAt ?? updated ?? now.toISOString();
|
|
73
|
+
const until = new Date(Date.parse(since) + CANDIDATE_KEEP_MS).toISOString();
|
|
74
|
+
status = until > now.toISOString() ? { keep: "release candidate", taskId: id, finishedAt: since, dueAt: until } : { keep: null, taskId: id, finishedAt: since };
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
status = completedAt === null ? { keep: "waiting for review", taskId: id, finishedAt: null } : { keep: null, taskId: id, finishedAt: completedAt };
|
|
78
|
+
}
|
|
79
|
+
byRef.set(ref, status);
|
|
80
|
+
byId.set(id, stronger(byId.get(id), status));
|
|
81
|
+
if (row["revision_of"] !== null)
|
|
82
|
+
parentOf.set(id, String(row["revision_of"]));
|
|
83
|
+
}
|
|
84
|
+
// A revision under way keeps what its ancestors worked on: it builds on their branch.
|
|
85
|
+
for (const [id, status] of byId) {
|
|
86
|
+
if (status.keep === null || status.keep === "release candidate")
|
|
87
|
+
continue;
|
|
88
|
+
const seen = new Set([id]);
|
|
89
|
+
for (let parent = parentOf.get(id); parent !== undefined && !seen.has(parent); parent = parentOf.get(parent)) {
|
|
90
|
+
seen.add(parent);
|
|
91
|
+
const lifted = { ...status, taskId: parent };
|
|
92
|
+
byId.set(parent, stronger(byId.get(parent), lifted));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const row of refs)
|
|
96
|
+
byRef.set(Number(row["ref"]), stronger(byRef.get(Number(row["ref"])), { ...byId.get(String(row["id"])), taskId: String(row["id"]) }));
|
|
97
|
+
const byBranch = new Map();
|
|
98
|
+
const note = (branch, status) => byBranch.set(branch, stronger(byBranch.get(branch), status));
|
|
99
|
+
for (const row of refs)
|
|
100
|
+
for (const branch of taskBranches(String(row["id"])))
|
|
101
|
+
note(branch, byRef.get(Number(row["ref"])));
|
|
102
|
+
for (const row of runs) {
|
|
103
|
+
if (row["branch"] === null)
|
|
104
|
+
continue;
|
|
105
|
+
const status = byRef.get(Number(row["task_ref"]));
|
|
106
|
+
if (status !== undefined)
|
|
107
|
+
note(String(row["branch"]), status);
|
|
108
|
+
}
|
|
109
|
+
return { byRef, byBranch };
|
|
110
|
+
}
|
|
111
|
+
/** Where one checkout's task stands: by its task, its branch, or (branch unknown) the name a lease of a branch would give it. */
|
|
112
|
+
function statusOf(row, statuses) {
|
|
113
|
+
let found = stronger(row.taskRef === null ? undefined : statuses.byRef.get(row.taskRef), statuses.byBranch.get(row.branch));
|
|
114
|
+
if (found === undefined) {
|
|
115
|
+
const name = basename(row.path);
|
|
116
|
+
for (const [branch, status] of statuses.byBranch)
|
|
117
|
+
if (basename(worktreePath("/", row.repo, branch)) === name)
|
|
118
|
+
found = stronger(found, status);
|
|
119
|
+
}
|
|
120
|
+
return found;
|
|
121
|
+
}
|
|
122
|
+
/** Why a checkout stays before anyone looks at its files, or null: it may go (as far as its task and the setting say). */
|
|
123
|
+
function keptFor(pool, row, statuses, now, cleanup) {
|
|
124
|
+
const status = statusOf(row, statuses);
|
|
125
|
+
const taskId = status?.taskId ?? null;
|
|
126
|
+
if (row.releasedAt === null || row.runner !== null || pool.inUse(row.path).held)
|
|
127
|
+
return { why: "in use", taskId };
|
|
128
|
+
if (status !== undefined && status.keep !== null)
|
|
129
|
+
return { why: status.keep, taskId, ...(status.dueAt === undefined ? {} : { dueAt: status.dueAt }) };
|
|
130
|
+
if (cleanup === "manual")
|
|
131
|
+
return { why: null, taskId };
|
|
132
|
+
// A checkout no task names (adopted after a crash, say) has no task to finish: only a clean-up by hand takes it.
|
|
133
|
+
if (status === undefined)
|
|
134
|
+
return { why: "no task", taskId };
|
|
135
|
+
if (cleanup === "finished")
|
|
136
|
+
return { why: null, taskId };
|
|
137
|
+
if (cleanup === "never")
|
|
138
|
+
return { why: "cleanup is off", taskId };
|
|
139
|
+
const days = CLEANUP_CHOICES.find(one => one.value === cleanup).days;
|
|
140
|
+
const since = [status.finishedAt, row.releasedAt].filter((one) => one !== null).sort().at(-1);
|
|
141
|
+
const dueAt = new Date(Date.parse(since) + days * DAY_MS).toISOString();
|
|
142
|
+
return dueAt > now.toISOString() ? { why: "not due yet", taskId, dueAt } : { why: null, taskId };
|
|
143
|
+
}
|
|
144
|
+
async function eachAtMost(items, limit, work) {
|
|
145
|
+
const out = new Array(items.length);
|
|
146
|
+
let next = 0;
|
|
147
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
148
|
+
while (next < items.length) {
|
|
149
|
+
const at = next++;
|
|
150
|
+
out[at] = await work(items[at]);
|
|
151
|
+
}
|
|
152
|
+
}));
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* What a clean-up would remove now, how much space it would free, and what stays and why. `manual` is a clean-up by
|
|
157
|
+
* hand (it doesn't wait for the setting); otherwise it is the worker's pass under the setting. Reads only.
|
|
158
|
+
*/
|
|
159
|
+
export async function checkoutPlan(store, pool, now, options) {
|
|
160
|
+
const cleanup = store.checkoutCleanup();
|
|
161
|
+
const rows = store.listWorktrees().filter(row => existsSync(row.path) && (options.repo === undefined || row.repo === options.repo));
|
|
162
|
+
const sizes = diskBytes(rows.map(row => row.path));
|
|
163
|
+
const statuses = taskStatuses(store, now);
|
|
164
|
+
const items = await eachAtMost(rows, INSPECT_AT_ONCE, async (row) => {
|
|
165
|
+
const kept = keptFor(pool, row, statuses, now, options.manual ? "manual" : cleanup);
|
|
166
|
+
let why = kept.why;
|
|
167
|
+
if (why === null) {
|
|
168
|
+
const found = await pool.inspect(row.path);
|
|
169
|
+
why = found === "clean" ? null : found;
|
|
170
|
+
}
|
|
171
|
+
return { path: row.path, repo: row.repo, branch: row.branch, taskId: kept.taskId, bytes: sizes.get(row.path) ?? 0, releasedAt: row.releasedAt, why, ...(kept.dueAt === undefined ? {} : { dueAt: kept.dueAt }) };
|
|
172
|
+
});
|
|
173
|
+
const go = items.filter(one => one.why === null).sort((a, b) => b.bytes - a.bytes);
|
|
174
|
+
const stay = items.filter(one => one.why !== null).sort((a, b) => (a.why ?? "").localeCompare(b.why ?? "") || b.bytes - a.bytes);
|
|
175
|
+
return {
|
|
176
|
+
at: now.toISOString(), cleanup, manual: options.manual, go, stay,
|
|
177
|
+
count: items.length, totalBytes: items.reduce((sum, one) => sum + one.bytes, 0), freeBytes: go.reduce((sum, one) => sum + one.bytes, 0),
|
|
178
|
+
waitingReview: stay.filter(one => one.why === "waiting for review").length, withChanges: stay.filter(one => one.why === "has changes").length,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** Names exactly what a preview would remove, so a confirmation removes that and nothing else. */
|
|
182
|
+
export function previewDigest(plan) {
|
|
183
|
+
return createHash("sha256").update(plan.go.map(one => one.path).sort().join("\u0000"), "utf8").digest("hex").slice(0, 32);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Remove what may go: the worker's pass (`manual` false: under the setting, at most AUTO_MAX, one repository) or a
|
|
187
|
+
* clean-up by hand (no cap, every repository). Each checkout is asked again right before it goes; each removal is in
|
|
188
|
+
* the ledger.
|
|
189
|
+
*/
|
|
190
|
+
export async function cleanCheckouts(store, pool, now, options) {
|
|
191
|
+
const cleanup = store.checkoutCleanup();
|
|
192
|
+
if (!options.manual && cleanup === "never")
|
|
193
|
+
return { removed: [], kept: [], freed: 0 };
|
|
194
|
+
// `only`: the checkouts a person saw in the preview; nothing they didn't see goes.
|
|
195
|
+
const rows = store.listWorktrees().filter(row => existsSync(row.path) && (options.repo === undefined || row.repo === options.repo) && (options.only === undefined || options.only.has(row.path)));
|
|
196
|
+
let statuses = taskStatuses(store, now());
|
|
197
|
+
const sizes = new Map();
|
|
198
|
+
const wanted = (row, fresh) => {
|
|
199
|
+
// Right before a checkout goes, where its task stands is read afresh, and its size taken for the ledger.
|
|
200
|
+
if (fresh)
|
|
201
|
+
statuses = taskStatuses(store, now());
|
|
202
|
+
const go = keptFor(pool, row, statuses, now(), options.manual ? "manual" : cleanup).why === null;
|
|
203
|
+
if (go && fresh)
|
|
204
|
+
sizes.set(row.path, diskBytes([row.path]).get(row.path) ?? 0);
|
|
205
|
+
return go;
|
|
206
|
+
};
|
|
207
|
+
const pruned = await pool.prune(rows, wanted, options.manual ? Infinity : AUTO_MAX);
|
|
208
|
+
const removed = [];
|
|
209
|
+
for (const row of pruned.removed) {
|
|
210
|
+
const taskId = row.taskRef === null ? null : store.externalIdFor(row.taskRef);
|
|
211
|
+
const bytes = sizes.get(row.path) ?? 0;
|
|
212
|
+
removed.push({ path: row.path, repo: row.repo, branch: row.branch, taskId, bytes, releasedAt: row.releasedAt, why: null });
|
|
213
|
+
store.recordAction({ at: now().toISOString(), actor: options.actor, repo: row.repo, taskId, runId: null, action: "checkout removed", outcome: "removed",
|
|
214
|
+
source: options.manual ? "request" : "work", detail: `${basename(row.path)} (released ${row.releasedAt?.slice(0, 10) ?? "?"}, about ${bytesWords(bytes)}); branch ${row.branch} kept` });
|
|
215
|
+
}
|
|
216
|
+
return { removed, kept: pruned.kept, freed: removed.reduce((sum, one) => sum + one.bytes, 0) };
|
|
217
|
+
}
|
|
218
|
+
/** Throw away a checkout kept for its changes, on purpose; its branch stays. In the ledger. */
|
|
219
|
+
export async function discardCheckout(store, pool, path, now, actor) {
|
|
220
|
+
const bytes = existsSync(path) ? diskBytes([path]).get(path) ?? 0 : 0;
|
|
221
|
+
const done = await pool.discardChanges(path);
|
|
222
|
+
if (!done.ok)
|
|
223
|
+
return done;
|
|
224
|
+
const taskId = done.row.taskRef === null ? null : store.externalIdFor(done.row.taskRef);
|
|
225
|
+
store.recordAction({ at: now.toISOString(), actor, repo: done.row.repo, taskId, runId: null, action: "checkout discarded", outcome: "removed", source: "request",
|
|
226
|
+
detail: `${basename(path)} and its uncommitted changes (about ${bytesWords(bytes)}); branch ${done.row.branch} kept` });
|
|
227
|
+
return { ok: true, bytes };
|
|
228
|
+
}
|
|
229
|
+
/** "Waiting for review", "Has changes" … as a sentence fragment for a person. */
|
|
230
|
+
export function whyWords(item) {
|
|
231
|
+
switch (item.why) {
|
|
232
|
+
case null: return "goes";
|
|
233
|
+
case "in use": return "in use";
|
|
234
|
+
case "task not finished": return "its task isn't finished";
|
|
235
|
+
case "waiting for review": return "its result is waiting for review";
|
|
236
|
+
case "release candidate": return `release candidate, kept until ${item.dueAt?.slice(0, 10) ?? "a week after"}`;
|
|
237
|
+
case "has changes": return "has changes";
|
|
238
|
+
case "has commits": return "has commits on no branch";
|
|
239
|
+
case "unreadable": return "git couldn't read it";
|
|
240
|
+
case "git refused": return "git wouldn't remove it";
|
|
241
|
+
case "not due yet": return `due ${item.dueAt?.slice(0, 10) ?? "later"}`;
|
|
242
|
+
case "cleanup is off": return "automatic cleanup is off";
|
|
243
|
+
case "no task": return "no task names it; only a clean-up by hand removes it";
|
|
244
|
+
}
|
|
245
|
+
}
|
package/dist/daemon.d.ts
CHANGED
|
@@ -148,8 +148,12 @@ export declare function installLaunchdService(definition: ServiceDefinition, run
|
|
|
148
148
|
/** Replace a file whole: a flushed temporary beside it, renamed over it, and the directory flushed, so a crash
|
|
149
149
|
* leaves the old text or the new, never a torn one. */
|
|
150
150
|
export declare function writeFileDurably(file: string, content: string | Uint8Array, mode?: number): void;
|
|
151
|
-
/** Explicit stop: unload AND disable, so nothing relaunches it until a person installs again.
|
|
152
|
-
|
|
151
|
+
/** Explicit stop: unload AND disable, so nothing relaunches it until a person installs again. `waitMs` is how long
|
|
152
|
+
* the job may take to leave launchd (5 s by default; a watch's `toolroll up` can take 45 s to exit). */
|
|
153
|
+
export declare function stopLaunchdService(definition: ServiceDefinition, run: SupervisorRunner, wait?: {
|
|
154
|
+
waitMs?: number;
|
|
155
|
+
sleep?: (ms: number) => Promise<void>;
|
|
156
|
+
}): Promise<{
|
|
153
157
|
ok: true;
|
|
154
158
|
wasLoaded: boolean;
|
|
155
159
|
} | {
|
package/dist/daemon.js
CHANGED
|
@@ -52,12 +52,12 @@ export function definitionDigest(unitContent) {
|
|
|
52
52
|
* appears to succeed, then the pending bootout removes the new job too. Wait
|
|
53
53
|
* for the observable supervisor boundary before loading the replacement.
|
|
54
54
|
*/
|
|
55
|
-
async function waitForLaunchdBootout(run, service, attempts = 50) {
|
|
55
|
+
async function waitForLaunchdBootout(run, service, attempts = 50, sleep = ms => new Promise(resolve => setTimeout(resolve, ms))) {
|
|
56
56
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
57
57
|
const status = await run("launchctl", ["print", service]);
|
|
58
58
|
if (status.code !== 0)
|
|
59
59
|
return true;
|
|
60
|
-
await
|
|
60
|
+
await sleep(100);
|
|
61
61
|
}
|
|
62
62
|
return false;
|
|
63
63
|
}
|
|
@@ -406,8 +406,9 @@ export function writeFileDurably(file, content, mode = 0o600) {
|
|
|
406
406
|
}
|
|
407
407
|
}
|
|
408
408
|
}
|
|
409
|
-
/** Explicit stop: unload AND disable, so nothing relaunches it until a person installs again.
|
|
410
|
-
|
|
409
|
+
/** Explicit stop: unload AND disable, so nothing relaunches it until a person installs again. `waitMs` is how long
|
|
410
|
+
* the job may take to leave launchd (5 s by default; a watch's `toolroll up` can take 45 s to exit). */
|
|
411
|
+
export async function stopLaunchdService(definition, run, wait = {}) {
|
|
411
412
|
const service = `gui/${launchdUid()}/${definition.label}`;
|
|
412
413
|
const disabled = await run("launchctl", ["disable", service]);
|
|
413
414
|
if (disabled.code !== 0)
|
|
@@ -415,7 +416,7 @@ export async function stopLaunchdService(definition, run) {
|
|
|
415
416
|
const modern = await run("launchctl", ["bootout", service]);
|
|
416
417
|
if (modern.code !== 0)
|
|
417
418
|
await run("launchctl", ["unload", definition.unitPath]);
|
|
418
|
-
if (!(await waitForLaunchdBootout(run, service)))
|
|
419
|
+
if (!(await waitForLaunchdBootout(run, service, Math.ceil((wait.waitMs ?? 5000) / 100), wait.sleep)))
|
|
419
420
|
return { ok: false, message: `launchctl did not stop ${definition.label}; the service is still loaded` };
|
|
420
421
|
return { ok: true, wasLoaded: modern.code === 0 };
|
|
421
422
|
}
|
package/dist/first-run.d.ts
CHANGED
|
@@ -9,14 +9,17 @@ export type FirstRunAction = {
|
|
|
9
9
|
kind: "command";
|
|
10
10
|
command: string;
|
|
11
11
|
};
|
|
12
|
+
/** `checking`: the answer is not in yet, so the step is shown neither done nor to do. */
|
|
12
13
|
export type FirstRunStep = {
|
|
13
14
|
key: "agent" | "project" | "task";
|
|
14
15
|
title: string;
|
|
15
16
|
done: boolean;
|
|
16
17
|
action: FirstRunAction | null;
|
|
18
|
+
checking?: true;
|
|
17
19
|
};
|
|
20
|
+
/** agentSignedIn is null until this machine's first sign-in check has answered. */
|
|
18
21
|
export type FirstRunFacts = {
|
|
19
|
-
agentSignedIn: boolean;
|
|
22
|
+
agentSignedIn: boolean | null;
|
|
20
23
|
projects: number;
|
|
21
24
|
hasTask: boolean;
|
|
22
25
|
firstResultAt: string | null;
|
|
@@ -46,8 +49,11 @@ export declare function firstTaskSuggestions(found: {
|
|
|
46
49
|
issues: readonly OpenIssue[];
|
|
47
50
|
todos: readonly TodoComment[];
|
|
48
51
|
}): FirstTaskSuggestion[];
|
|
49
|
-
/** `git grep`
|
|
52
|
+
/** `git grep -z -n` records ("file\0line\0text\n") to TODO or FIXME notes, one per file. NUL-separated so a
|
|
53
|
+
* file name with a colon or a newline in it is still one file name. */
|
|
50
54
|
export declare function parseTodoLines(stdout: string, limit?: number): TodoComment[];
|
|
55
|
+
/** The TODO search: no colour and no column whatever the person's git config says, NUL after each file name. */
|
|
56
|
+
export declare const TODO_GREP_ARGS: readonly string[];
|
|
51
57
|
/** Read the project's own sources for suggestions: its open issues through the `gh` login, then its TODO comments. Failures fall through quietly. */
|
|
52
58
|
export declare function findFirstTasks(repo: string, runner?: Runner): Promise<FirstTaskSuggestion[]>;
|
|
53
59
|
/** "First result in 7 min": how long after the installation began its first Ready result arrived. */
|
package/dist/first-run.js
CHANGED
|
@@ -15,7 +15,8 @@ export function firstRunSteps(facts) {
|
|
|
15
15
|
return null;
|
|
16
16
|
const project = facts.projects > 0;
|
|
17
17
|
return [
|
|
18
|
-
{ key: "agent", title: "Agent signed in", done:
|
|
18
|
+
facts.agentSignedIn === null ? { key: "agent", title: "Agent signed in", done: false, action: null, checking: true }
|
|
19
|
+
: { key: "agent", title: "Agent signed in", done: facts.agentSignedIn, action: facts.agentSignedIn ? null : { kind: "command", command: SIGN_IN_COMMAND } },
|
|
19
20
|
{ key: "project", title: "Project added", done: project, action: project ? null : { kind: "link", label: "Add a project", href: "/projects" } },
|
|
20
21
|
{ key: "task", title: "Your first task", done: facts.hasTask, action: facts.hasTask ? null : { kind: "link", label: "New task", href: "/tasks/new" } },
|
|
21
22
|
];
|
|
@@ -42,24 +43,27 @@ export function firstTaskSuggestions(found) {
|
|
|
42
43
|
return [...fromIssues, ...fromTodos, ...GENERIC_FIRST_TASKS].slice(0, 3);
|
|
43
44
|
}
|
|
44
45
|
const SKIPPED = /(^|\/)(node_modules|dist|build|vendor|third_party|\.git)\//;
|
|
45
|
-
/** `git grep`
|
|
46
|
+
/** `git grep -z -n` records ("file\0line\0text\n") to TODO or FIXME notes, one per file. NUL-separated so a
|
|
47
|
+
* file name with a colon or a newline in it is still one file name. */
|
|
46
48
|
export function parseTodoLines(stdout, limit = 3) {
|
|
47
49
|
const found = [];
|
|
48
50
|
const seen = new Set();
|
|
49
|
-
for (const
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
51
|
+
for (const match of stdout.matchAll(/([^\0]*)\0(\d+)\0([^\n]*)(?:\n|$)/g)) {
|
|
52
|
+
const file = match[1];
|
|
53
|
+
if (file === "" || SKIPPED.test(file) || seen.has(file))
|
|
52
54
|
continue;
|
|
53
55
|
const note = /\b(?:TODO|FIXME)\b(?:\([^)]*\))?[:\s-]*(.*)$/.exec(match[3]);
|
|
54
56
|
if (note === null)
|
|
55
57
|
continue;
|
|
56
|
-
seen.add(
|
|
57
|
-
found.push({ file
|
|
58
|
+
seen.add(file);
|
|
59
|
+
found.push({ file, line: Number(match[2]), text: clip(note[1].replace(/\s*(\*\/|-->)\s*$/, "").trim(), 120) });
|
|
58
60
|
if (found.length === limit)
|
|
59
61
|
break;
|
|
60
62
|
}
|
|
61
63
|
return found;
|
|
62
64
|
}
|
|
65
|
+
/** The TODO search: no colour and no column whatever the person's git config says, NUL after each file name. */
|
|
66
|
+
export const TODO_GREP_ARGS = ["-c", "grep.column=false", "grep", "--no-color", "-z", "-n", "-I", "-w", "--max-count=1", "-E", "TODO|FIXME"];
|
|
63
67
|
/** Read the project's own sources for suggestions: its open issues through the `gh` login, then its TODO comments. Failures fall through quietly. */
|
|
64
68
|
export async function findFirstTasks(repo, runner = run) {
|
|
65
69
|
let issues = [];
|
|
@@ -74,7 +78,7 @@ export async function findFirstTasks(repo, runner = run) {
|
|
|
74
78
|
let todos = [];
|
|
75
79
|
if (issues.length < 3) {
|
|
76
80
|
try {
|
|
77
|
-
const result = await runner("git", [
|
|
81
|
+
const result = await runner("git", [...TODO_GREP_ARGS], { cwd: repo, timeoutMs: 5_000 });
|
|
78
82
|
if (result.code === 0 || result.stdout !== "")
|
|
79
83
|
todos = parseTodoLines(result.stdout);
|
|
80
84
|
}
|
package/dist/install-method.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* the one command that updates it that way. Only the path is read: nothing is
|
|
4
4
|
* run and nothing leaves the computer.
|
|
5
5
|
*/
|
|
6
|
-
export type InstallKind = "npm" | "pnpm" | "bun" | "yarn" | "homebrew" | "npx" | "source" | "managed";
|
|
6
|
+
export type InstallKind = "npm" | "pnpm" | "bun" | "yarn" | "homebrew" | "npx" | "source" | "managed" | "desktop";
|
|
7
7
|
export type InstallMethod = {
|
|
8
8
|
kind: InstallKind;
|
|
9
9
|
updateCommand: string;
|
package/dist/install-method.js
CHANGED
|
@@ -15,6 +15,8 @@ export const UPDATE_COMMANDS = {
|
|
|
15
15
|
source: "git pull && npm install && npm run build",
|
|
16
16
|
// A runtime `toolroll update` installed (staged-upgrades/release-* or rollback-*): it updates itself.
|
|
17
17
|
managed: "toolroll update",
|
|
18
|
+
// The Toolroll app carries its own runtime and updates as a whole app, never through a package manager.
|
|
19
|
+
desktop: "Update from the Toolroll app",
|
|
18
20
|
};
|
|
19
21
|
/** The install method of the bin at `bin` (the running one by default). Unknown shapes read as npm, the common case. */
|
|
20
22
|
export function installMethod(bin = process.argv[1], probe = {}) {
|
|
@@ -36,6 +38,8 @@ function installKind(bin, probe) {
|
|
|
36
38
|
// by deploying from source, never from npm.
|
|
37
39
|
if (path.includes("/staged-upgrades/browser-"))
|
|
38
40
|
return "source";
|
|
41
|
+
if (/\.app\/Contents\/Resources\//.test(path))
|
|
42
|
+
return "desktop";
|
|
39
43
|
if (/\/staged-upgrades\/(release|rollback)-/.test(path))
|
|
40
44
|
return "managed";
|
|
41
45
|
if (/\/Cellar\/toolroll\//i.test(path))
|
package/dist/mcp.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TASK_SCOPE_TEXT_SCHEMA } from "./task-text.js";
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
+
import { statSync } from 'node:fs';
|
|
3
4
|
import { repositoryContextRead } from './repository-context.js';
|
|
4
5
|
import { ASSIGNMENT_TOOLS, assignmentForCoordinator } from "./assignment-adapters.js";
|
|
5
6
|
/**
|
|
@@ -367,12 +368,30 @@ function proposeTools() {
|
|
|
367
368
|
make("answer", "Propose an answer to an open decision you read with get_decision, with a rationale. The approver confirms where every consequence and the builder's recommendation are shown; an irreversible option needs their explicit confirmation.", { decision: { type: "integer", minimum: 1 }, option: { type: "string", minLength: 1, maxLength: 64 }, rationale: { type: "string", maxLength: 400 } }, ["decision", "option", "rationale"]),
|
|
368
369
|
];
|
|
369
370
|
}
|
|
371
|
+
function fileIdentity(path) {
|
|
372
|
+
if (path === null)
|
|
373
|
+
return null;
|
|
374
|
+
try {
|
|
375
|
+
const at = statSync(path);
|
|
376
|
+
return { path, dev: at.dev, ino: at.ino };
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/** Still the same file at the same path: not moved aside, deleted, or replaced by a rename. */
|
|
383
|
+
function sameFile(was) {
|
|
384
|
+
const now = fileIdentity(was.path);
|
|
385
|
+
return now !== null && now.dev === was.dev && now.ino === was.ino;
|
|
386
|
+
}
|
|
370
387
|
/** Serve until EOF. The store is already open through the non-migrating
|
|
371
388
|
* door; the token was startup-verified by the caller (and dies here again
|
|
372
389
|
* if it does not hold). */
|
|
373
390
|
export function serveMcp(store, token, io, clock = () => new Date(), enrolled = null, evidenceRoot) {
|
|
374
391
|
/** Per connection: which decisions this credential read in full (v3). */
|
|
375
392
|
const readDecisions = new Set();
|
|
393
|
+
/** The database file this server opened, by identity (null in memory). */
|
|
394
|
+
const database = fileIdentity(store.databaseFile());
|
|
376
395
|
const auth = authenticateCoordinator(store, token);
|
|
377
396
|
if (!auth.ok) {
|
|
378
397
|
return {
|
|
@@ -414,6 +433,13 @@ export function serveMcp(store, token, io, clock = () => new Date(), enrolled =
|
|
|
414
433
|
tools: TOOLS.map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })),
|
|
415
434
|
});
|
|
416
435
|
const callTool = (id, era, params) => {
|
|
436
|
+
// A restore or rollback puts a different file at the database's path: this server's connection still reads and
|
|
437
|
+
// writes the one it replaced, so it answers nothing more and exits (the agent starts it again on the new one).
|
|
438
|
+
if (database !== null && !sameFile(database)) {
|
|
439
|
+
error(id, -32000, "the database was replaced underneath this server (a Toolroll restore or rollback) — restart it");
|
|
440
|
+
io.exit(0);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
417
443
|
// ONE snapshot per call (review finding 3): the version check, the
|
|
418
444
|
// credential re-read, and every data read share a transaction, so a
|
|
419
445
|
// concurrent migration cannot slip between them; filing's own
|
package/dist/operate.d.ts
CHANGED
|
@@ -22,10 +22,6 @@ export declare const EXIT: {
|
|
|
22
22
|
readonly usage: 2;
|
|
23
23
|
readonly refused: 3;
|
|
24
24
|
};
|
|
25
|
-
/** Storage retention: how long a finished task's clean checkout is kept after it was let go, and a result marked
|
|
26
|
-
* complete's or a release candidate's after it was (its checkout holds the build a deploy installs). */
|
|
27
|
-
export declare const CHECKOUT_KEEP_MS: number;
|
|
28
|
-
export declare const RESULT_KEEP_MS: number;
|
|
29
25
|
export type OperateOptions = {
|
|
30
26
|
/** Native-shell proof key, passed in memory rather than command-line arguments. */
|
|
31
27
|
desktopIdentity?: string;
|
|
@@ -72,6 +68,7 @@ export type OperateOptions = {
|
|
|
72
68
|
/** Injected by tests: whether `up` has a person at a terminal, and how it opens a browser. */
|
|
73
69
|
upSeams?: {
|
|
74
70
|
terminal?: boolean;
|
|
71
|
+
env?: Record<string, string | undefined>;
|
|
75
72
|
openBrowser?: (url: string) => void;
|
|
76
73
|
};
|
|
77
74
|
};
|
|
@@ -115,6 +112,7 @@ export declare function runOperate(command: string, argv: readonly string[], wri
|
|
|
115
112
|
* finding 27): exclusive, 0600, fsynced — file and directory both — so the
|
|
116
113
|
* row only ever follows a durable secret. Throws on any failure. */
|
|
117
114
|
export declare function writeLoginFileDurably(path: string, name: string, password: string): void;
|
|
115
|
+
export declare function readUpConsoleUrl(dir: string): string | null;
|
|
118
116
|
/**
|
|
119
117
|
* The people allowed to agree to a scope.
|
|
120
118
|
*
|