toolroll 0.9.8 → 0.9.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/dist/assignment-brief.d.ts +2 -0
- package/dist/assignment-brief.js +2 -1
- package/dist/assignment-presentation.js +3 -1
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment-ui.js +5 -0
- package/dist/assignment.d.ts +9 -0
- package/dist/assignment.js +42 -8
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +18 -18
- package/dist/browser-crew.js +4 -0
- package/dist/browser-workspace.d.ts +40 -0
- package/dist/builder.d.ts +2 -0
- package/dist/builder.js +114 -8
- package/dist/chat-controls.d.ts +1 -1
- package/dist/chat-controls.js +1 -1
- package/dist/chat-delivery-state.d.ts +5 -0
- package/dist/chat-delivery.js +45 -8
- package/dist/chat-quiet.d.ts +10 -4
- package/dist/chat-quiet.js +87 -22
- package/dist/chat-voice.d.ts +68 -0
- package/dist/chat-voice.js +175 -0
- package/dist/check-progress.js +4 -3
- package/dist/checkout-cleanup.d.ts +40 -2
- package/dist/checkout-cleanup.js +129 -9
- package/dist/desktop-update.d.ts +52 -0
- package/dist/desktop-update.js +147 -5
- package/dist/discord-chat.js +1 -0
- package/dist/evidence.d.ts +6 -0
- package/dist/evidence.js +8 -0
- package/dist/first-run.d.ts +1 -1
- package/dist/first-run.js +7 -5
- package/dist/lead-context.d.ts +1 -1
- package/dist/lead-context.js +4 -2
- package/dist/lead-status.d.ts +10 -1
- package/dist/lead-status.js +21 -24
- package/dist/needs-you.d.ts +55 -0
- package/dist/needs-you.js +83 -0
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +122 -15
- package/dist/plane-review.js +4 -0
- package/dist/process-recovery-eligibility.d.ts +1 -0
- package/dist/project-delete.js +1 -0
- package/dist/project-learning.d.ts +3 -3
- package/dist/project-learning.js +28 -17
- package/dist/proof.d.ts +3 -1
- package/dist/proof.js +4 -2
- package/dist/releases.d.ts +9 -0
- package/dist/releases.js +30 -0
- package/dist/result-completion.d.ts +4 -0
- package/dist/result-completion.js +11 -0
- package/dist/review-context.d.ts +3 -0
- package/dist/review-context.js +45 -5
- package/dist/run-closeout.d.ts +89 -0
- package/dist/run-closeout.js +302 -0
- package/dist/serve.d.ts +15 -1
- package/dist/serve.js +129 -14
- package/dist/setup-guide.d.ts +2 -2
- package/dist/shared-deps.d.ts +94 -0
- package/dist/shared-deps.js +365 -0
- package/dist/slack-chat.js +2 -0
- package/dist/storage.js +2 -0
- package/dist/store.d.ts +60 -0
- package/dist/store.js +207 -30
- package/dist/task-status.d.ts +18 -4
- package/dist/task-status.js +35 -18
- package/dist/teams-chat.js +1 -1
- package/dist/telegram-progress.d.ts +6 -1
- package/dist/telegram-progress.js +13 -5
- package/dist/telegram-status.d.ts +2 -1
- package/dist/telegram-status.js +6 -5
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +93 -43
- package/dist/test-temp.d.ts +19 -0
- package/dist/test-temp.js +98 -0
- package/dist/toolroll-update-ui.js +7 -2
- package/dist/toolroll-update.d.ts +21 -1
- package/dist/toolroll-update.js +135 -5
- package/dist/work-index.d.ts +11 -0
- package/dist/work-index.js +103 -16
- package/dist/work-summary.d.ts +3 -1
- package/dist/workspace-ui.js +1 -1
- package/dist/worktree.d.ts +16 -0
- package/dist/worktree.js +47 -0
- package/package.json +1 -1
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared dependencies: one installed node_modules per (project, lockfile, setup, node) under the state folder
|
|
3
|
+
* (`deps/<key>`), linked into each new checkout instead of a fresh `npm ci` there. Measured Oct 2: a checkout of
|
|
4
|
+
* Toolroll is 20,500 files and 18,066 of them were its own node_modules; forty checkouts created and deleted ~800k
|
|
5
|
+
* files, most of the churn behind a bloated fseventsd, and each install cost time.
|
|
6
|
+
*
|
|
7
|
+
* The first checkout with a new key runs the approved setup itself; its node_modules then moves (one rename, same
|
|
8
|
+
* volume) into `deps/<key>`, is made read-only, and is linked back. Later checkouts link it: symlinks, else an
|
|
9
|
+
* APFS/reflink clone, else the plain setup as before. A linked node_modules is a real folder holding one symlink per
|
|
10
|
+
* top-level entry (a few hundred files, not tens of thousands), so caches tools write there stay the checkout's own.
|
|
11
|
+
* Only a setup that installs and does nothing else is shared (`npm ci`, `npm install`): skipping any other command
|
|
12
|
+
* would skip its other effects.
|
|
13
|
+
*
|
|
14
|
+
* The link is read-only to the build. A checkout whose package.json or lockfile no longer matches the copy it links
|
|
15
|
+
* gets its own install (the link goes, the approved setup runs there) and the shared copy is never touched; a copy
|
|
16
|
+
* whose installed tree changed anyway is retired, never linked again. `toolroll storage clean` removes a copy no
|
|
17
|
+
* checkout links or matches.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
23
|
+
export const DEPS_FOLDER = "deps";
|
|
24
|
+
const READY = "ready.json";
|
|
25
|
+
const RETIRED = "retired";
|
|
26
|
+
/** In a linked node_modules: the shared copy it links. */
|
|
27
|
+
const LINK_NOTE = ".toolroll-shared";
|
|
28
|
+
const NODE_MODULES = "node_modules";
|
|
29
|
+
/** A copy linked or made this recently is never removed by a clean-up: a lease may be about to link it. */
|
|
30
|
+
export const SHARED_GRACE_MS = 60 * 60_000;
|
|
31
|
+
/** A half-made copy (a crash mid-promotion) is debris after this long. */
|
|
32
|
+
const PARTIAL_STALE_MS = 24 * 60 * 60_000;
|
|
33
|
+
export function sharedDepsRoot(databaseFile) {
|
|
34
|
+
return join(dirname(databaseFile), DEPS_FOLDER);
|
|
35
|
+
}
|
|
36
|
+
/** A setup whose whole effect is node_modules: `npm ci` or `npm install`, with plain flags only. */
|
|
37
|
+
export function installsOnly(command) {
|
|
38
|
+
return /^\s*npm\s+(?:ci|install|i)(?:\s+--?[A-Za-z][\w-]*(?:=[\w.@/:+-]+)?)*\s*$/.test(command);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The checkout's dependency identity: its npm lockfile and package.json, hashed together. null when there is no
|
|
42
|
+
* lockfile, or the lockfile links local folders (workspaces, `file:`), whose links would point into the shared
|
|
43
|
+
* folder instead of the checkout.
|
|
44
|
+
*/
|
|
45
|
+
export function lockDigest(checkout) {
|
|
46
|
+
let lock = null;
|
|
47
|
+
for (const name of ["npm-shrinkwrap.json", "package-lock.json"]) {
|
|
48
|
+
try {
|
|
49
|
+
lock = readFileSync(join(checkout, name));
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
lock = null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (lock === null)
|
|
57
|
+
return null;
|
|
58
|
+
let manifest;
|
|
59
|
+
try {
|
|
60
|
+
manifest = readFileSync(join(checkout, "package.json"));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (/"link"\s*:\s*true/.test(lock.toString("utf8")))
|
|
66
|
+
return null;
|
|
67
|
+
return createHash("sha256").update(lock).update("\u0000").update(manifest).digest("hex");
|
|
68
|
+
}
|
|
69
|
+
const nodeSeen = new Map();
|
|
70
|
+
/** The node a setup on this PATH would install with: version, platform and architecture (native modules differ). */
|
|
71
|
+
export function nodeIdentity(path = process.env["PATH"] ?? "") {
|
|
72
|
+
if (nodeSeen.has(path))
|
|
73
|
+
return nodeSeen.get(path);
|
|
74
|
+
const found = spawnSync("node", ["-p", "process.version + ' ' + process.platform + ' ' + process.arch"], {
|
|
75
|
+
encoding: "utf8", timeout: 15_000, env: { PATH: path, HOME: process.env["HOME"] ?? "" }, shell: process.platform === "win32",
|
|
76
|
+
});
|
|
77
|
+
const identity = found.status === 0 && /^v\d+\.\d+\.\d+ \S+ \S+$/.test(found.stdout.trim()) ? found.stdout.trim() : null;
|
|
78
|
+
nodeSeen.set(path, identity);
|
|
79
|
+
return identity;
|
|
80
|
+
}
|
|
81
|
+
export function depsKey(repo, setupDigest, lock, node) {
|
|
82
|
+
return createHash("sha256").update([repo, setupDigest, lock, node].join("\u0000"), "utf8").digest("hex").slice(0, 24);
|
|
83
|
+
}
|
|
84
|
+
/** What a checkout would share: its key and the facts behind it, or null when it can't share. */
|
|
85
|
+
export function sharingFor(args) {
|
|
86
|
+
if (!installsOnly(args.setup.command))
|
|
87
|
+
return null;
|
|
88
|
+
const lock = lockDigest(args.worktree);
|
|
89
|
+
if (lock === null)
|
|
90
|
+
return null;
|
|
91
|
+
const node = nodeIdentity();
|
|
92
|
+
if (node === null)
|
|
93
|
+
return null;
|
|
94
|
+
return { key: depsKey(args.repo, args.setup.digest, lock, node), lock, node };
|
|
95
|
+
}
|
|
96
|
+
function readReady(dir) {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(readFileSync(join(dir, READY), "utf8"));
|
|
99
|
+
return parsed.version === 1 && typeof parsed.key === "string" && typeof parsed.lock === "string" ? parsed : null;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** npm's record of what it installed: changes when anything installs or removes a package in the tree. */
|
|
106
|
+
function treeDigest(nodeModules) {
|
|
107
|
+
try {
|
|
108
|
+
return createHash("sha256").update(readFileSync(join(nodeModules, ".package-lock.json"))).digest("hex");
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** The shared node_modules for a key, when one is ready and unchanged since it was made. A changed one is retired. */
|
|
115
|
+
export function readyCopy(root, key) {
|
|
116
|
+
const dir = join(root, key);
|
|
117
|
+
const ready = readReady(dir);
|
|
118
|
+
if (ready === null || ready.key !== key || existsSync(join(dir, RETIRED)) || !existsSync(join(dir, NODE_MODULES)))
|
|
119
|
+
return null;
|
|
120
|
+
if (ready.tree !== treeDigest(join(dir, NODE_MODULES))) {
|
|
121
|
+
retire(dir);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return join(dir, NODE_MODULES);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Take a changed copy out of use. It stays where it is, so a checkout still linking it keeps working; no new
|
|
128
|
+
* checkout links it, and a clean-up removes it once nothing links it.
|
|
129
|
+
*/
|
|
130
|
+
function retire(dir) {
|
|
131
|
+
try {
|
|
132
|
+
writeFileSync(join(dir, RETIRED), `${new Date().toISOString()}\n`, "utf8");
|
|
133
|
+
}
|
|
134
|
+
catch { /* the next look retires it */ }
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The shared copy a checkout's node_modules links, or null (none, or its own install). A linked node_modules is a
|
|
138
|
+
* small real folder: one link per top-level entry of the shared copy and a note naming it. Tools that write a cache
|
|
139
|
+
* there (`node_modules/.vite`, `.cache`) write into the checkout's own folder, never the shared copy.
|
|
140
|
+
*/
|
|
141
|
+
export function linkedKey(worktree, root) {
|
|
142
|
+
let target;
|
|
143
|
+
try {
|
|
144
|
+
target = resolve(readFileSync(join(worktree, NODE_MODULES, LINK_NOTE), "utf8").trim());
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const inside = relative(resolve(root), target);
|
|
150
|
+
if (inside === "" || inside.startsWith("..") || isAbsolute(inside))
|
|
151
|
+
return null;
|
|
152
|
+
const [key, folder, ...rest] = inside.split(/[\\/]/);
|
|
153
|
+
return key !== undefined && folder === NODE_MODULES && rest.length === 0 ? key : null;
|
|
154
|
+
}
|
|
155
|
+
/** Remove a checkout's linked node_modules (its links and caches, never what they point to). True when there was one. */
|
|
156
|
+
export function detachShared(worktree, root) {
|
|
157
|
+
if (linkedKey(worktree, root) === null)
|
|
158
|
+
return false;
|
|
159
|
+
// rm removes a link itself; it never follows one into the shared copy.
|
|
160
|
+
rmSync(join(worktree, NODE_MODULES), { recursive: true, force: true });
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
function writable(path, on) {
|
|
164
|
+
if (process.platform === "win32")
|
|
165
|
+
return;
|
|
166
|
+
spawnSync("chmod", ["-R", on ? "u+w" : "a-w", path], { timeout: 120_000 });
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Put a shared copy into a checkout that has no node_modules: links (a junction per folder on Windows), else a
|
|
170
|
+
* copy-on-write clone the build owns. null: neither worked, and the caller runs the plain setup.
|
|
171
|
+
*/
|
|
172
|
+
export function linkInto(worktree, shared) {
|
|
173
|
+
const at = join(worktree, NODE_MODULES);
|
|
174
|
+
if (existsSync(at) || isLink(at))
|
|
175
|
+
return null;
|
|
176
|
+
try {
|
|
177
|
+
mkdirSync(at);
|
|
178
|
+
for (const entry of readdirSync(shared, { withFileTypes: true })) {
|
|
179
|
+
symlinkSync(join(shared, entry.name), join(at, entry.name), entry.isDirectory() ? (process.platform === "win32" ? "junction" : "dir") : "file");
|
|
180
|
+
}
|
|
181
|
+
writeFileSync(join(at, LINK_NOTE), `${shared}\n`, "utf8");
|
|
182
|
+
touch(dirname(shared));
|
|
183
|
+
return "link";
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
rmSync(at, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
const cloned = process.platform === "darwin" ? spawnSync("cp", ["-c", "-R", shared, at], { timeout: 600_000 })
|
|
189
|
+
: process.platform === "linux" ? spawnSync("cp", ["--reflink=always", "-R", shared, at], { timeout: 600_000 })
|
|
190
|
+
: null;
|
|
191
|
+
if (cloned !== null && cloned.status === 0) {
|
|
192
|
+
writable(at, true);
|
|
193
|
+
touch(dirname(shared));
|
|
194
|
+
return "clone";
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
writable(at, true);
|
|
198
|
+
rmSync(at, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
catch { /* a partial clone stays for a person */ }
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
function isLink(path) {
|
|
204
|
+
try {
|
|
205
|
+
return lstatSync(path).isSymbolicLink();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function touch(dir) {
|
|
212
|
+
const now = new Date();
|
|
213
|
+
try {
|
|
214
|
+
utimesSync(join(dir, READY), now, now);
|
|
215
|
+
}
|
|
216
|
+
catch { /* the age only guards a clean-up */ }
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* After the approved setup succeeded in a checkout with no copy for its key: move its node_modules into
|
|
220
|
+
* `deps/<key>` (one rename), make it read-only and link it back. Returns how the checkout now has it, or null when it
|
|
221
|
+
* kept its own install (a different volume, a setup that rewrote the lockfile, a race lost cleanly).
|
|
222
|
+
*/
|
|
223
|
+
export function promoteInstall(args) {
|
|
224
|
+
const own = join(args.worktree, NODE_MODULES);
|
|
225
|
+
try {
|
|
226
|
+
if (!lstatSync(own).isDirectory())
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
if (lockDigest(args.worktree) !== args.lock)
|
|
233
|
+
return null;
|
|
234
|
+
const partial = join(args.root, `${args.key}.partial-${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
235
|
+
try {
|
|
236
|
+
mkdirSync(partial, { recursive: true });
|
|
237
|
+
renameSync(own, join(partial, NODE_MODULES));
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
rmSync(partial, { recursive: true, force: true });
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
const ready = { version: 1, repo: args.repo, key: args.key, lock: args.lock, node: args.node, setup: args.setupDigest, tree: treeDigest(join(partial, NODE_MODULES)), createdAt: args.now.toISOString() };
|
|
244
|
+
writeFileSync(join(partial, READY), `${JSON.stringify(ready)}\n`, "utf8");
|
|
245
|
+
writable(join(partial, NODE_MODULES), false);
|
|
246
|
+
let shared = null;
|
|
247
|
+
try {
|
|
248
|
+
renameSync(partial, join(args.root, args.key));
|
|
249
|
+
shared = join(args.root, args.key, NODE_MODULES);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Another checkout made this copy first: use theirs.
|
|
253
|
+
shared = readyCopy(args.root, args.key);
|
|
254
|
+
}
|
|
255
|
+
const linked = shared === null ? null : linkInto(args.worktree, shared);
|
|
256
|
+
if (linked === null && !existsSync(partial) && shared === join(args.root, args.key, NODE_MODULES) && !existsSync(own)) {
|
|
257
|
+
// Ours became the shared copy but couldn't be linked: it goes back to being this checkout's own install.
|
|
258
|
+
writable(shared, true);
|
|
259
|
+
try {
|
|
260
|
+
renameSync(shared, own);
|
|
261
|
+
rmSync(join(args.root, args.key), { recursive: true, force: true });
|
|
262
|
+
}
|
|
263
|
+
catch { /* left for a clean-up */ }
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
if (existsSync(partial)) {
|
|
267
|
+
// Lost the race: ours goes back if nothing else could be linked, else it's removed.
|
|
268
|
+
if (linked === null && !existsSync(own)) {
|
|
269
|
+
writable(join(partial, NODE_MODULES), true);
|
|
270
|
+
try {
|
|
271
|
+
renameSync(join(partial, NODE_MODULES), own);
|
|
272
|
+
}
|
|
273
|
+
catch { /* left for a clean-up */ }
|
|
274
|
+
}
|
|
275
|
+
removeTree(partial);
|
|
276
|
+
}
|
|
277
|
+
return linked;
|
|
278
|
+
}
|
|
279
|
+
function removeTree(path) {
|
|
280
|
+
writable(path, true);
|
|
281
|
+
rmSync(path, { recursive: true, force: true });
|
|
282
|
+
}
|
|
283
|
+
/** Every shared copy on disk, newest first. */
|
|
284
|
+
export function sharedCopies(root) {
|
|
285
|
+
let names = [];
|
|
286
|
+
try {
|
|
287
|
+
names = readdirSync(root);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
names = [];
|
|
291
|
+
}
|
|
292
|
+
const copies = [];
|
|
293
|
+
for (const name of names) {
|
|
294
|
+
if (!/^[0-9a-f]{24}$/.test(name))
|
|
295
|
+
continue;
|
|
296
|
+
const ready = readReady(join(root, name));
|
|
297
|
+
if (ready === null)
|
|
298
|
+
continue;
|
|
299
|
+
let usedAt = ready.createdAt;
|
|
300
|
+
try {
|
|
301
|
+
usedAt = statSync(join(root, name, READY)).mtime.toISOString();
|
|
302
|
+
}
|
|
303
|
+
catch { /* createdAt stands */ }
|
|
304
|
+
copies.push({ key: name, dir: join(root, name), repo: ready.repo, lock: ready.lock, node: ready.node, createdAt: ready.createdAt, usedAt, retired: existsSync(join(root, name, RETIRED)) });
|
|
305
|
+
}
|
|
306
|
+
return copies.sort((a, b) => b.usedAt.localeCompare(a.usedAt));
|
|
307
|
+
}
|
|
308
|
+
/** Which checkouts use each copy: those linking it, and those whose lockfile it was installed from. */
|
|
309
|
+
export function sharedUse(root, checkouts) {
|
|
310
|
+
const copies = sharedCopies(root);
|
|
311
|
+
const users = new Map(copies.map(one => [one.key, new Set()]));
|
|
312
|
+
for (const checkout of checkouts) {
|
|
313
|
+
const linked = linkedKey(checkout.path, root);
|
|
314
|
+
if (linked !== null) {
|
|
315
|
+
users.get(linked)?.add(checkout.path);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (!existsSync(checkout.path))
|
|
319
|
+
continue;
|
|
320
|
+
const lock = lockDigest(checkout.path);
|
|
321
|
+
if (lock === null)
|
|
322
|
+
continue;
|
|
323
|
+
for (const copy of copies)
|
|
324
|
+
if (!copy.retired && copy.repo === checkout.repo && copy.lock === lock)
|
|
325
|
+
users.get(copy.key).add(checkout.path);
|
|
326
|
+
}
|
|
327
|
+
return copies.map(one => ({ ...one, checkouts: [...users.get(one.key)].sort() }));
|
|
328
|
+
}
|
|
329
|
+
/** Half-made copies under deps/ (a crash mid-promotion), old enough that nothing is still making them. */
|
|
330
|
+
export function sharedDebris(root, now) {
|
|
331
|
+
let names = [];
|
|
332
|
+
try {
|
|
333
|
+
names = readdirSync(root);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return [];
|
|
337
|
+
}
|
|
338
|
+
return names.filter(name => {
|
|
339
|
+
if (!/\.partial-/.test(name))
|
|
340
|
+
return false;
|
|
341
|
+
try {
|
|
342
|
+
return now.getTime() - statSync(join(root, name)).mtime.getTime() > PARTIAL_STALE_MS;
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
}).map(name => join(root, name));
|
|
348
|
+
}
|
|
349
|
+
/** What `storage clean` removes: copies no checkout uses and none linked within the grace period, plus debris. */
|
|
350
|
+
export function unusedShared(root, checkouts, now) {
|
|
351
|
+
const copies = sharedUse(root, checkouts).filter(one => one.checkouts.length === 0 && now.getTime() - Date.parse(one.usedAt) > SHARED_GRACE_MS);
|
|
352
|
+
return { copies, debris: sharedDebris(root, now) };
|
|
353
|
+
}
|
|
354
|
+
/** Remove one copy or debris folder (read-only on disk, so made writable first). */
|
|
355
|
+
export function removeShared(path) {
|
|
356
|
+
if (basename(dirname(path)) !== DEPS_FOLDER)
|
|
357
|
+
return false;
|
|
358
|
+
try {
|
|
359
|
+
removeTree(path);
|
|
360
|
+
return !existsSync(path);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
}
|
package/dist/slack-chat.js
CHANGED
|
@@ -360,6 +360,8 @@ export async function deliverSlackPart(options) {
|
|
|
360
360
|
}))
|
|
361
361
|
: []),
|
|
362
362
|
...linkButton(options.origin(), content.link),
|
|
363
|
+
// [Look first] beside [Merge] or [Mark complete]: each button's own action id.
|
|
364
|
+
...(content.also ?? []).flatMap((one, index) => linkButton(options.origin(), one).map(button => ({ ...button, action_id: `toolroll_link_${index + 2}` }))),
|
|
363
365
|
];
|
|
364
366
|
const target = content.edit ?? row.message;
|
|
365
367
|
const args = {
|
package/dist/storage.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { spawnSync } from "node:child_process";
|
|
9
9
|
import { lstatSync, readdirSync } from "node:fs";
|
|
10
10
|
import { basename, dirname, join } from "node:path";
|
|
11
|
+
import { DEPS_FOLDER, sharedCopies } from "./shared-deps.js";
|
|
11
12
|
/** Settings → Storage: when a finished task's clean checkout is removed. One row, or none (the default). */
|
|
12
13
|
export const CHECKOUT_CLEANUP_SCHEMA = `
|
|
13
14
|
CREATE TABLE IF NOT EXISTS checkout_cleanup_setting (
|
|
@@ -123,6 +124,7 @@ export function storageReport(store, databaseFile, checkoutNote) {
|
|
|
123
124
|
const lines = [
|
|
124
125
|
{ what: "Database", bytes: take(name => name.startsWith(base)) },
|
|
125
126
|
{ what: "Build checkouts", bytes: take(name => name === "worktrees"), count: checkouts.length, ...(checkoutNote === undefined ? {} : { note: checkoutNote }) },
|
|
127
|
+
{ what: "Shared dependencies", bytes: take(name => name === DEPS_FOLDER), count: sharedCopies(join(folder, DEPS_FOLDER)).length, note: "one install per lockfile, linked into checkouts" },
|
|
126
128
|
{ what: "Staged releases", bytes: take(name => name === "staged-upgrades"), count: staged, note: "a deploy keeps the release it installed, the one before and the newest few" },
|
|
127
129
|
{ what: "Evidence", bytes: take(name => name === "evidence"), note: "the audit record of every run; kept" },
|
|
128
130
|
{ what: "Backups", bytes: take(name => name === "backups") },
|
package/dist/store.d.ts
CHANGED
|
@@ -21,7 +21,16 @@ import { type RetentionKind, type RetentionPeriods } from "./retention.js";
|
|
|
21
21
|
import { type CheckoutCleanup } from "./storage.js";
|
|
22
22
|
import { type LedgerChainReport } from "./ledger-chain.js";
|
|
23
23
|
import { type ApprovalGate, type ApprovalRules, type ApproverKind, type Filer } from "./approval-policy.js";
|
|
24
|
+
/** What stops a finished run reading stopped (Store.stopQuiescenceFact). */
|
|
25
|
+
export type StopFactKind = "open" | "alive" | "elsewhere" | "unprovable" | "unknown";
|
|
24
26
|
export type TaskAct = "filed" | "approved" | "cancelled" | "completed" | "asked";
|
|
27
|
+
/** Store.stopQuiescenceFact, read safely: a deploy proves completion with this code over the INSTALLED runtime's store,
|
|
28
|
+
* which may predate the method. There the older stopQuiescenceProblem decides, and its words give the kind. */
|
|
29
|
+
export declare function stopFactOf(store: Pick<Store, "stopQuiescenceProblem"> & Partial<Pick<Store, "stopQuiescenceFact">>, runId: number): {
|
|
30
|
+
run: number;
|
|
31
|
+
kind: StopFactKind;
|
|
32
|
+
problem: string;
|
|
33
|
+
} | null;
|
|
25
34
|
export type TaskActRow = {
|
|
26
35
|
act: TaskAct;
|
|
27
36
|
account: string;
|
|
@@ -981,6 +990,18 @@ export type ChatCard = {
|
|
|
981
990
|
digest: string | null;
|
|
982
991
|
tasks: number[];
|
|
983
992
|
};
|
|
993
|
+
export type ChatBatch = {
|
|
994
|
+
id: number;
|
|
995
|
+
destination: string;
|
|
996
|
+
openedAt: string;
|
|
997
|
+
message: string | null;
|
|
998
|
+
digest: string | null;
|
|
999
|
+
items: {
|
|
1000
|
+
taskRef: number;
|
|
1001
|
+
run: number | null;
|
|
1002
|
+
notification: number | null;
|
|
1003
|
+
}[];
|
|
1004
|
+
};
|
|
984
1005
|
/**
|
|
985
1006
|
* What one recovery walk over a gone runner's work settled (P0.1a): the run
|
|
986
1007
|
* ids it finished as failed/interrupted and the built-in task ids it returned
|
|
@@ -2711,6 +2732,8 @@ export declare class Store {
|
|
|
2711
2732
|
clearWorktreeSetup(repo: string, by: string, now: Date): boolean;
|
|
2712
2733
|
/** The cache stamp: this checkout completed this setup. Success-only by contract. */
|
|
2713
2734
|
stampWorktreeSetup(path: string, digest: string): void;
|
|
2735
|
+
/** The checkout no longer holds what its setup made (its dependencies were dropped): the next lease runs setup again. */
|
|
2736
|
+
forgetWorktreeSetup(path: string): void;
|
|
2714
2737
|
/**
|
|
2715
2738
|
* Approve one repo's verification command, revoking any predecessor in
|
|
2716
2739
|
* the same transaction — exactly one live command per repo, enforced by
|
|
@@ -3712,6 +3735,10 @@ export declare class Store {
|
|
|
3712
3735
|
lastBuilderRun(taskRef: number): Run | null;
|
|
3713
3736
|
/** How many finished builder attempts of this task ended at `head`. */
|
|
3714
3737
|
builderAttemptsAt(taskRef: number, head: string): number;
|
|
3738
|
+
/** Why a finished result can't be built again on the same filing: it was
|
|
3739
|
+
* verified, attested or accepted, or its branch was published (revised
|
|
3740
|
+
* through its pull request). Null when `requeueTask` may run it again. */
|
|
3741
|
+
finalResultReason(runId: number): "accepted-result" | "published" | null;
|
|
3715
3742
|
requeueTask(taskId: string, by: string, now: Date): {
|
|
3716
3743
|
ok: true;
|
|
3717
3744
|
resolvedIncidents: number;
|
|
@@ -4594,6 +4621,7 @@ export declare class Store {
|
|
|
4594
4621
|
enqueueEpisode(prefix: string, notification: Omit<NotificationInput, "dedupeKey">, suffix: string, now: Date): boolean;
|
|
4595
4622
|
/** Close every open episode under a prefix — the fault is provably gone. */
|
|
4596
4623
|
resolveEpisodes(prefix: string, now: Date): number;
|
|
4624
|
+
notificationById(id: number): Notification | null;
|
|
4597
4625
|
notificationsAfter(id: number, limit?: number): Notification[];
|
|
4598
4626
|
listNotifications(only?: "pending" | "all"): Notification[];
|
|
4599
4627
|
recordDelivery(id: number, outcome: {
|
|
@@ -7078,6 +7106,21 @@ export declare class Store {
|
|
|
7078
7106
|
/** Every owned run must have ended, and every retained local process
|
|
7079
7107
|
* witness must be absent. Read-only probes never authorize PID kills. */
|
|
7080
7108
|
stopQuiescenceProblem(runId: number): string | null;
|
|
7109
|
+
/** The same answer as stopQuiescenceProblem, with which run it names and
|
|
7110
|
+
* what kind of answer it is (needs-you.ts reads it): `alive` while
|
|
7111
|
+
* something of the run may still be running, `elsewhere` when only
|
|
7112
|
+
* another computer can tell, `unprovable` when nothing is known to run
|
|
7113
|
+
* but its exit was never recorded, `unknown` when this computer can't
|
|
7114
|
+
* check at all (both are what `run settle` resolves; only the first may
|
|
7115
|
+
* be called not running). */
|
|
7116
|
+
stopQuiescenceFact(runId: number): {
|
|
7117
|
+
run: number;
|
|
7118
|
+
kind: StopFactKind;
|
|
7119
|
+
problem: string;
|
|
7120
|
+
} | null;
|
|
7121
|
+
/** `run settle` recorded an approver's word that a run whose workspace
|
|
7122
|
+
* note couldn't be read has stopped. */
|
|
7123
|
+
private workspaceConfirmedByApprover;
|
|
7081
7124
|
reserveRunProcess(runId: number, now: Date, group?: boolean): number;
|
|
7082
7125
|
/** Save positive OS exit evidence while it is available, before a numeric
|
|
7083
7126
|
* PID can be reused. Completion, missing ancestry and age prove nothing.
|
|
@@ -7088,6 +7131,12 @@ export declare class Store {
|
|
|
7088
7131
|
pid: number;
|
|
7089
7132
|
group: boolean;
|
|
7090
7133
|
}): number;
|
|
7134
|
+
/** The reconcile road for a finished run whose process outlived its
|
|
7135
|
+
* finish: once each process is provably gone, its exit is recorded, so
|
|
7136
|
+
* the task stops waiting on a person by itself. A run that can't be proven
|
|
7137
|
+
* yet stays open and is passed over, never holding newer runs behind it:
|
|
7138
|
+
* every finished run is visited, a batch at a time. Never signals. */
|
|
7139
|
+
recordFinishedRunExits(now: Date, batch?: number): number;
|
|
7091
7140
|
finishUnspawnedProcess(witness: number, now: Date): void;
|
|
7092
7141
|
/** The reconcile road for a pid-less witness a crash or a thrown spawn
|
|
7093
7142
|
* left behind: once its run has finished, no live child is tracked for it
|
|
@@ -7441,6 +7490,12 @@ export declare class Store {
|
|
|
7441
7490
|
* and the evening digest keep every fact either way.
|
|
7442
7491
|
*/
|
|
7443
7492
|
pingAllowed(row: Pick<Notification, "id" | "kind" | "recipient" | "project" | "taskRef">, account: string): boolean;
|
|
7493
|
+
/** Who last completed or cancelled this task, when that is still its latest act: null otherwise. A revision's
|
|
7494
|
+
* completion is recorded on its root, so an act on a task it revises counts too, but only one made after this
|
|
7495
|
+
* revision was filed: settling older work never silences a newer revision, whose failures still reach people. */
|
|
7496
|
+
settledBy(taskRef: number): string | null;
|
|
7497
|
+
/** A release check, a replaced task, or one a revision superseded: its facts stay in the console. */
|
|
7498
|
+
neverPings(taskRef: number): boolean;
|
|
7444
7499
|
/** The lead's work, which messages nobody: unless it is a security alert or a failure with nothing left for the lead. */
|
|
7445
7500
|
leadQuiet(row: Pick<Notification, "id" | "kind" | "taskRef">, actor?: Actor | null): boolean;
|
|
7446
7501
|
/** A failure with nothing left for the lead to do: its attempts are spent, or the task has failed for good. */
|
|
@@ -7456,6 +7511,11 @@ export declare class Store {
|
|
|
7456
7511
|
} | null;
|
|
7457
7512
|
/** Task-bound facts recorded since an instant, newest last (bounded): what an evening digest reads. */
|
|
7458
7513
|
taskFactsSince(since: string, limit?: number): Notification[];
|
|
7514
|
+
/** The one message this update joins: the destination's batch opened within `windowMs`, or a new one. A task
|
|
7515
|
+
* has one line in it, saying its newest update (`notification`; null for a finished result). */
|
|
7516
|
+
chatBatchFor(destination: string, taskRef: number, run: number | null, now: Date, windowMs: number, notification?: number | null): ChatBatch;
|
|
7517
|
+
chatBatch(id: number): ChatBatch | null;
|
|
7518
|
+
setChatBatchMessage(id: number, message: string, digest: string): void;
|
|
7459
7519
|
/** The message now showing this card, and what it shows. */
|
|
7460
7520
|
setChatCardMessage(id: number, message: string, digest: string): void;
|
|
7461
7521
|
markTelegramDigestSent(now: Date): void;
|