toolroll 0.9.59 → 0.9.61
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 +26 -0
- package/README.md +8 -6
- package/dist/agent-board.d.ts +208 -0
- package/dist/agent-board.js +454 -0
- package/dist/agent-fence.d.ts +19 -0
- package/dist/agent-fence.js +100 -8
- package/dist/agent-pulse.d.ts +43 -0
- package/dist/agent-pulse.js +214 -0
- package/dist/agent-turns.d.ts +52 -0
- package/dist/agent-turns.js +137 -0
- package/dist/agents-board.d.ts +101 -0
- package/dist/agents-board.js +225 -0
- package/dist/assignment-adapters.d.ts +2 -1
- package/dist/assignment-adapters.js +1 -1
- package/dist/assignment-delivery.d.ts +16 -0
- package/dist/assignment-delivery.js +54 -28
- package/dist/assignment-status.d.ts +14 -5
- package/dist/assignment-status.js +70 -39
- package/dist/assignment.d.ts +10 -2
- package/dist/assignment.js +21 -14
- package/dist/bin.js +11 -2
- package/dist/brief-fence.d.ts +9 -0
- package/dist/brief-fence.js +22 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +29 -25
- package/dist/browser-crew.js +2 -0
- package/dist/browser-workspace.d.ts +79 -1
- package/dist/browser-workspace.js +1 -0
- package/dist/builder.d.ts +8 -0
- package/dist/builder.js +178 -29
- package/dist/chat-channel.js +10 -1
- package/dist/chat-core.d.ts +6 -0
- package/dist/chat-core.js +8 -0
- package/dist/chat-delivery.js +22 -0
- package/dist/chat-messages.js +8 -1
- package/dist/chat-quiet.js +1 -0
- package/dist/chat-voice.d.ts +2 -0
- package/dist/chat-voice.js +2 -0
- package/dist/checkout-cleanup.d.ts +3 -0
- package/dist/checkout-cleanup.js +11 -2
- package/dist/cli-fast.d.ts +8 -0
- package/dist/cli-fast.js +18 -0
- package/dist/cli-help.d.ts +5 -0
- package/dist/cli-help.js +91 -0
- package/dist/cli.d.ts +6 -5
- package/dist/cli.js +17 -96
- package/dist/contracts/console-api.d.ts +3 -1
- package/dist/contracts/console-api.js +5 -1
- package/dist/contracts/lead-tools.d.ts +16 -2
- package/dist/contracts/lead-tools.js +5 -1
- package/dist/design/tokens.css +234 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord.js +2 -1
- package/dist/evidence-pack.js +1 -1
- package/dist/evidence.d.ts +28 -0
- package/dist/evidence.js +87 -17
- package/dist/exec.d.ts +32 -0
- package/dist/exec.js +229 -10
- package/dist/flow-actions.js +2 -1
- package/dist/guides.js +14 -11
- package/dist/invoke.js +110 -18
- package/dist/landed-cleanup.d.ts +157 -0
- package/dist/landed-cleanup.js +707 -0
- package/dist/lead-cli.js +22 -19
- package/dist/lead-contract.js +1 -1
- package/dist/lead-doors.js +49 -2
- package/dist/lead-follow.d.ts +2 -0
- package/dist/lead-follow.js +138 -88
- package/dist/lead-tools.js +48 -8
- package/dist/live-input.d.ts +68 -0
- package/dist/live-input.js +254 -0
- package/dist/live.d.ts +1 -1
- package/dist/live.js +4 -2
- package/dist/mailbox.js +3 -2
- package/dist/maintenance.d.ts +2 -0
- package/dist/maintenance.js +5 -2
- package/dist/mcp-core.js +1 -1
- package/dist/operate-remote-arguments.js +5 -1
- package/dist/operate-remote.js +1 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +201 -19
- package/dist/orphan-sweep.d.ts +2 -0
- package/dist/orphan-sweep.js +5 -3
- package/dist/project-delete-ui.d.ts +11 -0
- package/dist/project-delete-ui.js +29 -2
- package/dist/project-tools.d.ts +7 -11
- package/dist/project-tools.js +13 -37
- package/dist/provider.d.ts +73 -8
- package/dist/provider.js +87 -17
- package/dist/result-completion.d.ts +7 -0
- package/dist/result-completion.js +9 -0
- package/dist/runner.d.ts +4 -2
- package/dist/runner.js +3 -3
- package/dist/serve.js +22 -3
- package/dist/server/chrome.d.ts +6 -3
- package/dist/server/chrome.js +8 -4
- package/dist/server/console.css +9 -64
- package/dist/server/live.d.ts +3 -0
- package/dist/server/live.js +35 -2
- package/dist/server/pages.js +59 -35
- package/dist/server/render-chat.js +14 -2
- package/dist/server/render-tasks.d.ts +14 -2
- package/dist/server/render-tasks.js +38 -20
- package/dist/server/route-table.js +4 -1
- package/dist/server/settings.js +2 -1
- package/dist/server/task-views.js +10 -0
- package/dist/server/tasks.js +63 -0
- package/dist/shared-deps.d.ts +9 -4
- package/dist/shared-deps.js +89 -13
- package/dist/slack-socket.d.ts +12 -0
- package/dist/slack-socket.js +29 -0
- package/dist/slack.d.ts +0 -7
- package/dist/slack.js +2 -24
- package/dist/staged-runtimes.d.ts +16 -4
- package/dist/staged-runtimes.js +160 -38
- package/dist/steer-live.d.ts +19 -0
- package/dist/steer-live.js +93 -0
- package/dist/steer-mailbox.d.ts +39 -0
- package/dist/steer-mailbox.js +207 -0
- package/dist/steer-status.d.ts +25 -0
- package/dist/steer-status.js +44 -0
- package/dist/storage-sweep-child.d.ts +1 -0
- package/dist/storage-sweep-child.js +76 -0
- package/dist/storage-sweep-process.d.ts +41 -0
- package/dist/storage-sweep-process.js +187 -0
- package/dist/storage-sweep.d.ts +30 -6
- package/dist/storage-sweep.js +214 -28
- package/dist/storage-ui.d.ts +2 -0
- package/dist/storage-ui.js +16 -4
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +35 -0
- package/dist/store.d.ts +202 -2
- package/dist/store.js +440 -13
- package/dist/surface.js +5 -3
- package/dist/sweep-gate.d.ts +10 -0
- package/dist/sweep-gate.js +18 -0
- package/dist/sweep-quarantine.d.ts +28 -0
- package/dist/sweep-quarantine.js +153 -0
- package/dist/task-status.d.ts +2 -0
- package/dist/task-status.js +5 -1
- package/dist/team-updates.js +3 -3
- package/dist/test-temp.d.ts +6 -3
- package/dist/test-temp.js +15 -9
- package/dist/toolroll-update.js +15 -2
- package/dist/work-index.d.ts +1 -0
- package/dist/work-index.js +32 -12
- package/dist/work-summary.d.ts +2 -2
- package/dist/workspace-revision.js +3 -1
- package/dist/worktree.d.ts +3 -1
- package/dist/worktree.js +93 -10
- package/package.json +21 -21
- package/scripts/provider-canary.mjs +235 -147
package/dist/server/tasks.js
CHANGED
|
@@ -46,6 +46,7 @@ import { runCostWords } from "../summary.js";
|
|
|
46
46
|
import { requestTaskStop, resumeTaskStop } from "../task-control.js";
|
|
47
47
|
import { templateByName } from "../templates.js";
|
|
48
48
|
import { TEXT_LIMITS } from "../text-limits.js";
|
|
49
|
+
import { agentQuestion, answerAgentQuestion, markAgentSeen } from "../agent-board.js";
|
|
49
50
|
import { WorkIndexCursorError, workIndexPage } from "../work-index.js";
|
|
50
51
|
import { buildProgressOf, parseWorkView } from "../workspace-ui.js";
|
|
51
52
|
import { requestContext } from "./request-context.js";
|
|
@@ -458,6 +459,9 @@ export function createTasksHandlers(runtime) {
|
|
|
458
459
|
return sendScreen(response, 409, screen("Result changed", html `<h1>Result changed</h1><p>This acceptance link no longer matches the current result. Review the current task before accepting.</p><p class="refusal-back"><a class="button-link" href="/review">Review results</a></p>`, { chrome: chromeFor(project, "runs") }));
|
|
459
460
|
}
|
|
460
461
|
const selected = selectedRow === null ? null : reviewCockpitViewOf(selectedRow, who, now);
|
|
462
|
+
// Opening a run's own result page is viewing it: it leaves this person's unread Done on the Agents board.
|
|
463
|
+
if (wantedId !== null && selected?.run != null)
|
|
464
|
+
markAgentSeen(store, who.name, selected.run.id, now);
|
|
461
465
|
// A build that delivered nothing reads Failed here, and so does a failed task's delivered result: what went wrong
|
|
462
466
|
// is the card and Retry the ink act. That result may still be accepted, but only in outline, as Accept anyway, with
|
|
463
467
|
// a reason.
|
|
@@ -1621,8 +1625,31 @@ export function createTasksHandlers(runtime) {
|
|
|
1621
1625
|
: "no such task";
|
|
1622
1626
|
return taskScreen(response, who, taskId, said, 400);
|
|
1623
1627
|
}
|
|
1628
|
+
// From the Agents board, back to the same agent (allow-listed: a run of this task, never a URL from the form).
|
|
1629
|
+
const run = Number(body.get("run") ?? "");
|
|
1630
|
+
if (body.get("return") === "agents" && Number.isSafeInteger(run) && store.getRun(run)?.taskRef === ref.id)
|
|
1631
|
+
return redirect(response, `/agents?run=${run}&said=steered`);
|
|
1624
1632
|
return redirect(response, taskHref(taskId));
|
|
1625
1633
|
}
|
|
1634
|
+
case "reply": {
|
|
1635
|
+
const body = readForm(posted, CONSOLE_FORMS.taskReply);
|
|
1636
|
+
// A reply is steering, so it is a browser session's act too.
|
|
1637
|
+
if (who.via !== "cookie")
|
|
1638
|
+
return refuse(response, who, 403, "replying to an agent is a browser session's act");
|
|
1639
|
+
// The same gates as a pause: the route's act scope already refuses a viewer; these say why, and the demo answers nothing.
|
|
1640
|
+
if (who.role !== "approver")
|
|
1641
|
+
return taskScreen(response, who, taskId, "your login can watch — replying to an agent is an approver's act", 403);
|
|
1642
|
+
if (store.isDemo())
|
|
1643
|
+
return taskScreen(response, who, taskId, "the demo authorizes nothing", 403);
|
|
1644
|
+
const id = Number(body.get("question") ?? "");
|
|
1645
|
+
const asked = Number.isSafeInteger(id) && id > 0 ? agentQuestion(store, id) : null;
|
|
1646
|
+
if (asked === null || asked.taskRef !== ref.id)
|
|
1647
|
+
return taskScreen(response, who, taskId, "that question is not on this task", 404);
|
|
1648
|
+
const answered = answerAgentQuestion(store, { question: id, answer: body.get("answer") ?? "", author: who.name, at: now,
|
|
1649
|
+
file: (task, note) => store.fileSteerNote(task, verifiedAuthor(who.name), note, now) });
|
|
1650
|
+
const said = answered.ok ? "replied" : answered.reason === "invalid-answer" || answered.reason === "not-saved" ? "reply-invalid" : answered.reason === "closed" ? "reply-closed" : "reply-stale";
|
|
1651
|
+
return redirect(response, `/agents?run=${asked.run}&said=${said}`);
|
|
1652
|
+
}
|
|
1626
1653
|
case "hold": {
|
|
1627
1654
|
const body = readForm(posted, CONSOLE_FORMS.taskHold);
|
|
1628
1655
|
const reason = (body.get("reason") ?? "").trim() || "held from the console";
|
|
@@ -2306,6 +2333,40 @@ export function createTasksHandlers(runtime) {
|
|
|
2306
2333
|
const back = body.get("return") ?? "";
|
|
2307
2334
|
return redirect(response, back === "chat" ? `${taskChatHref(taskId)}#task-control` : `${taskHref(taskId)}#task-control`);
|
|
2308
2335
|
}
|
|
2336
|
+
case "pause": {
|
|
2337
|
+
const body = readForm(posted, CONSOLE_FORMS.taskPause);
|
|
2338
|
+
// Pause, and Pause and steer (live steering): a browser session's act, an approver's act, naming the ONE run
|
|
2339
|
+
// the page showed under the page's own request id. It stops the running Claude build's turn and keeps the
|
|
2340
|
+
// build; a note that rides along is filed in the same transaction and sent right after. mode=resume continues a
|
|
2341
|
+
// paused build with no note. Never a stop.
|
|
2342
|
+
if (who.via !== "cookie")
|
|
2343
|
+
return refuse(response, who, 403, "pausing a build is a browser session's act");
|
|
2344
|
+
if (who.role !== "approver")
|
|
2345
|
+
return taskScreen(response, who, taskId, "your login can watch — pausing a build is an approver's act", 403);
|
|
2346
|
+
if (store.isDemo())
|
|
2347
|
+
return taskScreen(response, who, taskId, "the demo authorizes nothing", 403);
|
|
2348
|
+
const named = (body.get("run") ?? "").trim();
|
|
2349
|
+
if (!/^[0-9]{1,15}$/.test(named))
|
|
2350
|
+
return taskScreen(response, who, taskId, "which build? a pause names one exact run", 400);
|
|
2351
|
+
const note = (body.get("note") ?? "").trim();
|
|
2352
|
+
// Resume: the paused build goes on where it stopped; no note is filed.
|
|
2353
|
+
const asked = body.get("mode") === "resume"
|
|
2354
|
+
? store.requestResume({ taskId, runId: Number(named), by: verifiedAuthor(who.name), via: "web", requestId: body.get("request") ?? "" }, now)
|
|
2355
|
+
: store.requestPause({ taskId, runId: Number(named), by: verifiedAuthor(who.name), via: "web", requestId: body.get("request") ?? "", ...(note === "" ? {} : { note }) }, now);
|
|
2356
|
+
if (!asked.ok) {
|
|
2357
|
+
const words = {
|
|
2358
|
+
stale: `build #${named} has stopped taking notes — reload to see the current attempt`,
|
|
2359
|
+
"not-paused": `build #${named} isn't paused any more — reload to see where it stands`,
|
|
2360
|
+
"invalid-request": "that form was incomplete — reload and try again",
|
|
2361
|
+
duplicate: "that form was already used for another build — reload and try again",
|
|
2362
|
+
"task-finished": "this task is finished",
|
|
2363
|
+
"unknown-task": "no such task",
|
|
2364
|
+
};
|
|
2365
|
+
return taskScreen(response, who, taskId, words[asked.reason] ?? asked.detail, asked.reason === "invalid-note" ? 400 : 409);
|
|
2366
|
+
}
|
|
2367
|
+
const back = body.get("return") ?? "";
|
|
2368
|
+
return redirect(response, back === "chat" ? `${taskChatHref(taskId)}#task-control` : `${taskHref(taskId)}#task-control`);
|
|
2369
|
+
}
|
|
2309
2370
|
case "resume-arm": {
|
|
2310
2371
|
const body = readForm(posted, CONSOLE_FORMS.taskResume);
|
|
2311
2372
|
return armTaskResume(response, who, taskId, (body.get("run") ?? "").trim(), body.get("return") === "chat" ? "chat" : "task", now);
|
|
@@ -2715,6 +2776,7 @@ export function createTasksHandlers(runtime) {
|
|
|
2715
2776
|
"task.act.next": taskAct("next"),
|
|
2716
2777
|
"task.act.reopen": taskAct("reopen"),
|
|
2717
2778
|
"task.act.steer": taskAct("steer"),
|
|
2779
|
+
"task.act.reply": taskAct("reply"),
|
|
2718
2780
|
"task.act.accept-proof": taskAct("accept-proof"),
|
|
2719
2781
|
"task.act.accept-revision": taskAct("accept-revision"),
|
|
2720
2782
|
"task.act.reject-revision": taskAct("reject-revision"),
|
|
@@ -2724,6 +2786,7 @@ export function createTasksHandlers(runtime) {
|
|
|
2724
2786
|
"task.act.merge": taskMerge,
|
|
2725
2787
|
"task.act.confirm-stopped": taskConfirmStopped,
|
|
2726
2788
|
"task.act.stop": taskAct("stop"),
|
|
2789
|
+
"task.act.pause": taskAct("pause"),
|
|
2727
2790
|
"task.act.resume-arm": taskAct("resume-arm"),
|
|
2728
2791
|
"task.act.resume": taskAct("resume"),
|
|
2729
2792
|
"task.act.block": taskAct("block"),
|
package/dist/shared-deps.d.ts
CHANGED
|
@@ -98,13 +98,18 @@ export declare function unusedShared(root: string, checkouts: readonly {
|
|
|
98
98
|
export declare function removeShared(path: string): boolean;
|
|
99
99
|
/**
|
|
100
100
|
* The daily sweep: remove each copy no checkout uses and none has linked for `unusedMs` (a week), and old debris.
|
|
101
|
-
* Right before a copy goes, who uses it
|
|
102
|
-
* between stays.
|
|
101
|
+
* Right before a copy goes, who uses it, when it was last linked and whether live work uses it (`spare`,
|
|
102
|
+
* storage-sweep.ts) are read again; a copy a checkout picked up in between stays. Removal never holds the event loop.
|
|
103
103
|
*/
|
|
104
104
|
export declare function pruneUnusedShared(root: string, checkouts: () => readonly {
|
|
105
105
|
path: string;
|
|
106
106
|
repo: string;
|
|
107
|
-
}[], now: Date, unusedMs?: number
|
|
107
|
+
}[], now: Date, unusedMs?: number, options?: {
|
|
108
|
+
spare?: (path: string) => boolean;
|
|
109
|
+
/** Deterministic race/failure seams; never supplied by the worker. */
|
|
110
|
+
quarantined?: (original: string, trash: string) => void | Promise<void>;
|
|
111
|
+
remove?: (path: string) => Promise<void>;
|
|
112
|
+
}): Promise<{
|
|
108
113
|
removed: SharedUse[];
|
|
109
114
|
failed: string[];
|
|
110
|
-
}
|
|
115
|
+
}>;
|
package/dist/shared-deps.js
CHANGED
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
* checkout links or matches; the plane's daily storage sweep removes one no checkout has used for a week.
|
|
18
18
|
*/
|
|
19
19
|
import { createHash, randomBytes } from "node:crypto";
|
|
20
|
-
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
21
|
+
import { rm } from "node:fs/promises";
|
|
21
22
|
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
22
23
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
23
24
|
import { markNeverIndex } from "./never-index.js";
|
|
25
|
+
import { QUARANTINE_NOTE, quarantine, quarantineEntries, claimQuarantine, releaseQuarantine, restoreQuarantine, deleteQuarantine } from "./sweep-quarantine.js";
|
|
24
26
|
export const DEPS_FOLDER = "deps";
|
|
25
27
|
const READY = "ready.json";
|
|
26
28
|
const RETIRED = "retired";
|
|
@@ -199,7 +201,11 @@ function treeDigest(nodeModules) {
|
|
|
199
201
|
}
|
|
200
202
|
/** The shared node_modules for a key, when one is ready and unchanged since it was made. A changed one is retired. */
|
|
201
203
|
export function readyCopy(root, key) {
|
|
204
|
+
if (!/^[0-9a-f]{24}$/.test(key))
|
|
205
|
+
return null;
|
|
202
206
|
const dir = join(root, key);
|
|
207
|
+
if (existsSync(join(dir, QUARANTINE_NOTE)))
|
|
208
|
+
return null;
|
|
203
209
|
const ready = readReady(dir);
|
|
204
210
|
if (ready === null || ready.key !== key || existsSync(join(dir, RETIRED)) || !existsSync(join(dir, NODE_MODULES)))
|
|
205
211
|
return null;
|
|
@@ -259,12 +265,20 @@ export function linkInto(worktree, shared) {
|
|
|
259
265
|
const at = join(worktree, NODE_MODULES);
|
|
260
266
|
if (existsSync(at) || isLink(at))
|
|
261
267
|
return null;
|
|
268
|
+
const root = dirname(dirname(shared)), key = basename(dirname(shared));
|
|
269
|
+
if (readyCopy(root, key) !== shared)
|
|
270
|
+
return null;
|
|
262
271
|
try {
|
|
263
272
|
mkdirSync(at);
|
|
273
|
+
// Publish intent before reading the copy. A sweep that renames it mid-link sees this user and restores it;
|
|
274
|
+
// if the sweep won before the intent, the final ready check fails and no broken link is handed to a run.
|
|
275
|
+
writeFileSync(join(at, LINK_NOTE), `${shared}\n`, "utf8");
|
|
276
|
+
touch(dirname(shared));
|
|
264
277
|
for (const entry of readdirSync(shared, { withFileTypes: true })) {
|
|
265
278
|
symlinkSync(join(shared, entry.name), join(at, entry.name), entry.isDirectory() ? (process.platform === "win32" ? "junction" : "dir") : "file");
|
|
266
279
|
}
|
|
267
|
-
|
|
280
|
+
if (readyCopy(root, key) !== shared)
|
|
281
|
+
throw new Error("Shared copy is no longer available");
|
|
268
282
|
markNeverIndex(at);
|
|
269
283
|
touch(dirname(shared));
|
|
270
284
|
return "link";
|
|
@@ -272,6 +286,8 @@ export function linkInto(worktree, shared) {
|
|
|
272
286
|
catch {
|
|
273
287
|
rmSync(at, { recursive: true, force: true });
|
|
274
288
|
}
|
|
289
|
+
if (readyCopy(root, key) !== shared)
|
|
290
|
+
return null;
|
|
275
291
|
const cloned = process.platform === "darwin" ? spawnSync("cp", ["-c", "-R", shared, at], { timeout: 600_000 })
|
|
276
292
|
: process.platform === "linux" ? spawnSync("cp", ["--reflink=always", "-R", shared, at], { timeout: 600_000 })
|
|
277
293
|
: null;
|
|
@@ -396,7 +412,9 @@ export function sharedCopies(root) {
|
|
|
396
412
|
}
|
|
397
413
|
/** Which checkouts use each copy: those linking it, and those whose lockfile it was installed from. */
|
|
398
414
|
export function sharedUse(root, checkouts) {
|
|
399
|
-
|
|
415
|
+
return useCopies(root, sharedCopies(root), checkouts);
|
|
416
|
+
}
|
|
417
|
+
function useCopies(root, copies, checkouts) {
|
|
400
418
|
const users = new Map(copies.map(one => [one.key, new Set()]));
|
|
401
419
|
for (const checkout of checkouts) {
|
|
402
420
|
const linked = linkedKey(checkout.path, root);
|
|
@@ -411,7 +429,8 @@ export function sharedUse(root, checkouts) {
|
|
|
411
429
|
continue;
|
|
412
430
|
for (const copy of copies) {
|
|
413
431
|
const ready = readReady(copy.dir);
|
|
414
|
-
if (!copy.retired && copy.repo === checkout.repo && ready !== null
|
|
432
|
+
if (!copy.retired && copy.repo === checkout.repo && (ready !== null ? matchesInputs(copy.dir, ready, inputs) :
|
|
433
|
+
[inputs.digest, digestPair(inputs.lock, inputs.manifest)].includes(copy.lock)))
|
|
415
434
|
users.get(copy.key).add(checkout.path);
|
|
416
435
|
}
|
|
417
436
|
}
|
|
@@ -456,24 +475,81 @@ export function removeShared(path) {
|
|
|
456
475
|
}
|
|
457
476
|
/**
|
|
458
477
|
* The daily sweep: remove each copy no checkout uses and none has linked for `unusedMs` (a week), and old debris.
|
|
459
|
-
* Right before a copy goes, who uses it
|
|
460
|
-
* between stays.
|
|
478
|
+
* Right before a copy goes, who uses it, when it was last linked and whether live work uses it (`spare`,
|
|
479
|
+
* storage-sweep.ts) are read again; a copy a checkout picked up in between stays. Removal never holds the event loop.
|
|
461
480
|
*/
|
|
462
|
-
export function pruneUnusedShared(root, checkouts, now, unusedMs = SHARED_UNUSED_MS) {
|
|
481
|
+
export async function pruneUnusedShared(root, checkouts, now, unusedMs = SHARED_UNUSED_MS, options = {}) {
|
|
463
482
|
const stale = (one) => one.checkouts.length === 0 && now.getTime() - Date.parse(one.usedAt) > unusedMs;
|
|
464
483
|
const removed = [];
|
|
465
484
|
const failed = [];
|
|
485
|
+
const finish = async (trash) => {
|
|
486
|
+
try {
|
|
487
|
+
await options.quarantined?.(trash.original, trash.path);
|
|
488
|
+
let used = options.spare?.(trash.original) === true || options.spare?.(trash.path) === true;
|
|
489
|
+
const copy = trash.journal.data;
|
|
490
|
+
if (copy !== null) {
|
|
491
|
+
let usedAt = copy.usedAt;
|
|
492
|
+
try {
|
|
493
|
+
usedAt = statSync(join(trash.path, READY)).mtime.toISOString();
|
|
494
|
+
}
|
|
495
|
+
catch { /* partially deleted trash */ }
|
|
496
|
+
const fresh = useCopies(root, [{ ...copy, dir: trash.path, usedAt }], checkouts())[0];
|
|
497
|
+
used ||= !stale(fresh);
|
|
498
|
+
}
|
|
499
|
+
if (used) {
|
|
500
|
+
if (!restoreQuarantine(trash))
|
|
501
|
+
failed.push(trash.original);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
await deleteQuarantine(trash, options.remove ?? removeSharedLater);
|
|
505
|
+
if (copy !== null)
|
|
506
|
+
removed.push(copy);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
failed.push(trash.original);
|
|
510
|
+
}
|
|
511
|
+
finally {
|
|
512
|
+
releaseQuarantine(trash);
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
for (const entry of quarantineEntries(root)) {
|
|
516
|
+
const claimed = claimQuarantine(entry);
|
|
517
|
+
if (claimed !== null)
|
|
518
|
+
await finish(claimed);
|
|
519
|
+
}
|
|
466
520
|
for (const one of sharedUse(root, checkouts()).filter(stale)) {
|
|
521
|
+
await nextTurn();
|
|
467
522
|
const fresh = sharedUse(root, checkouts()).find(other => other.key === one.key);
|
|
468
|
-
if (fresh === undefined || !stale(fresh))
|
|
523
|
+
if (fresh === undefined || !stale(fresh) || options.spare?.(fresh.dir) === true)
|
|
469
524
|
continue;
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
525
|
+
try {
|
|
526
|
+
const trash = quarantine(fresh.dir, fresh);
|
|
527
|
+
if (trash !== null)
|
|
528
|
+
await finish(trash);
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
473
531
|
failed.push(fresh.dir);
|
|
532
|
+
}
|
|
474
533
|
}
|
|
475
|
-
for (const debris of sharedDebris(root, now))
|
|
476
|
-
|
|
534
|
+
for (const debris of sharedDebris(root, now)) {
|
|
535
|
+
await nextTurn();
|
|
536
|
+
if (options.spare?.(debris) === true)
|
|
537
|
+
continue;
|
|
538
|
+
try {
|
|
539
|
+
const trash = quarantine(debris, null);
|
|
540
|
+
if (trash !== null)
|
|
541
|
+
await finish(trash);
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
477
544
|
failed.push(debris);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
478
547
|
return { removed, failed };
|
|
479
548
|
}
|
|
549
|
+
const nextTurn = () => new Promise(resolve => setImmediate(resolve));
|
|
550
|
+
/** removeShared without holding the event loop: chmod and rm run off it. */
|
|
551
|
+
async function removeSharedLater(path) {
|
|
552
|
+
if (process.platform !== "win32")
|
|
553
|
+
await new Promise(resolve => execFile("chmod", ["-R", "u+w", path], { timeout: 120_000 }, () => resolve()));
|
|
554
|
+
await rm(path, { recursive: true, force: true });
|
|
555
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Socket Mode client, apart from the follower so the Slack SDK loads only
|
|
3
|
+
* once Slack is set up (slack.ts imports this when it finds credentials).
|
|
4
|
+
*/
|
|
5
|
+
import { SocketModeClient } from "@slack/socket-mode";
|
|
6
|
+
export { LogLevel } from "@slack/socket-mode";
|
|
7
|
+
/** The bot and socket tokens must belong to the same app, including after reconnect. */
|
|
8
|
+
export declare class ToolrollSlackSocket extends SocketModeClient {
|
|
9
|
+
expectedApp: string;
|
|
10
|
+
wrongApp: () => void;
|
|
11
|
+
protected onWebSocketMessage(data: string | ArrayBuffer, isBinary: boolean): Promise<void>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Socket Mode client, apart from the follower so the Slack SDK loads only
|
|
3
|
+
* once Slack is set up (slack.ts imports this when it finds credentials).
|
|
4
|
+
*/
|
|
5
|
+
import { SocketModeClient } from "@slack/socket-mode";
|
|
6
|
+
export { LogLevel } from "@slack/socket-mode";
|
|
7
|
+
/** The bot and socket tokens must belong to the same app, including after reconnect. */
|
|
8
|
+
export class ToolrollSlackSocket extends SocketModeClient {
|
|
9
|
+
expectedApp = "";
|
|
10
|
+
wrongApp = () => { };
|
|
11
|
+
async onWebSocketMessage(data, isBinary) {
|
|
12
|
+
let packet;
|
|
13
|
+
try {
|
|
14
|
+
packet = JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (packet.type === "hello") {
|
|
20
|
+
const info = packet.connection_info;
|
|
21
|
+
if (info?.app_id !== this.expectedApp) {
|
|
22
|
+
this.wrongApp();
|
|
23
|
+
await this.disconnect();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return super.onWebSocketMessage(data, isBinary);
|
|
28
|
+
}
|
|
29
|
+
}
|
package/dist/slack.d.ts
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import { SocketModeClient } from "@slack/socket-mode";
|
|
2
1
|
import { type SlackChatOptions } from "./slack-chat.js";
|
|
3
|
-
/** The bot and socket tokens must belong to the same app, including after reconnect. */
|
|
4
|
-
export declare class ToolrollSlackSocket extends SocketModeClient {
|
|
5
|
-
expectedApp: string;
|
|
6
|
-
wrongApp: () => void;
|
|
7
|
-
protected onWebSocketMessage(data: string | ArrayBuffer, isBinary: boolean): Promise<void>;
|
|
8
|
-
}
|
|
9
2
|
export declare function followSlack(options: Omit<SlackChatOptions, "identity" | "api" | "owner" | "current"> & {
|
|
10
3
|
dir: string;
|
|
11
4
|
signal: AbortSignal;
|
package/dist/slack.js
CHANGED
|
@@ -1,33 +1,9 @@
|
|
|
1
1
|
/** One leased Socket Mode connection, hosted by the ordinary worker. */
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
|
-
import { SocketModeClient, LogLevel } from "@slack/socket-mode";
|
|
5
4
|
import { loadSlackCredentials, slackApi } from "./slack-api.js";
|
|
6
5
|
import { ChatState } from "./chat-delivery-state.js";
|
|
7
6
|
import { receiveSlack, processSlackEvent, deliverSlackPart, planSlackNotifications, planSlackRooms, } from "./slack-chat.js";
|
|
8
|
-
/** The bot and socket tokens must belong to the same app, including after reconnect. */
|
|
9
|
-
export class ToolrollSlackSocket extends SocketModeClient {
|
|
10
|
-
expectedApp = "";
|
|
11
|
-
wrongApp = () => { };
|
|
12
|
-
async onWebSocketMessage(data, isBinary) {
|
|
13
|
-
let packet;
|
|
14
|
-
try {
|
|
15
|
-
packet = JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data));
|
|
16
|
-
}
|
|
17
|
-
catch {
|
|
18
|
-
return;
|
|
19
|
-
}
|
|
20
|
-
if (packet.type === "hello") {
|
|
21
|
-
const info = packet.connection_info;
|
|
22
|
-
if (info?.app_id !== this.expectedApp) {
|
|
23
|
-
this.wrongApp();
|
|
24
|
-
await this.disconnect();
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return super.onWebSocketMessage(data, isBinary);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
7
|
export async function followSlack(options) {
|
|
32
8
|
const owner = randomBytes(16).toString("hex"), state = new ChatState(options.store, "slack");
|
|
33
9
|
const pause = async (ms) => {
|
|
@@ -56,6 +32,8 @@ export async function followSlack(options) {
|
|
|
56
32
|
state.owns(credentials.installation, owner));
|
|
57
33
|
};
|
|
58
34
|
const problem = (text) => state.setProblem(credentials.installation, owner, text);
|
|
35
|
+
// The SDK loads once Slack is set up, not with every command that could host the follower.
|
|
36
|
+
const { ToolrollSlackSocket, LogLevel } = await import("./slack-socket.js");
|
|
59
37
|
// SDK log output can include envelopes/tokens. Only fixed status phrases are saved.
|
|
60
38
|
const client = new ToolrollSlackSocket({
|
|
61
39
|
appToken: credentials.appToken,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ProcessRow } from "./run-closeout.js";
|
|
1
2
|
/** A deploy or update that never finished keeps its stage this long (it may be resumed). */
|
|
2
3
|
export declare const UNFINISHED_KEEP_MS: number;
|
|
3
4
|
export declare const STAGED_FOLDER = "staged-upgrades";
|
|
@@ -22,9 +23,17 @@ export type StagePlan = {
|
|
|
22
23
|
export declare function stagedRuntimes(stateDir: string): Stage[];
|
|
23
24
|
/**
|
|
24
25
|
* Paths a runtime may be running from: the launchd services' definitions, every `toolroll` (or older
|
|
25
|
-
* `standing-orders`) command on PATH or under nvm
|
|
26
|
+
* `standing-orders`) command on PATH, in the usual link folders or under nvm (a link counts by its target), this
|
|
27
|
+
* process, and `extra`.
|
|
26
28
|
*/
|
|
27
|
-
export declare function runtimesInUse(extra?: readonly string[], home?: string, path?: string): string[];
|
|
29
|
+
export declare function runtimesInUse(extra?: readonly string[], home?: string, path?: string, stateDir?: string): string[];
|
|
30
|
+
/** Retain only recognized Toolroll entry scripts in candidate stages, never complete argv. ps is asked only
|
|
31
|
+
* for Node/Toolroll processes; /proc supplies NUL-separated argv on Linux. Fail closed with a fixed message,
|
|
32
|
+
* since an execFileSync exception can otherwise contain private argument text in stdout/stderr. */
|
|
33
|
+
export declare function runtimeScriptPaths(stateDir: string, onlyPids?: readonly number[], observations?: {
|
|
34
|
+
census?: () => ProcessRow[];
|
|
35
|
+
argv?: (pids: readonly number[]) => string[];
|
|
36
|
+
}): string[];
|
|
28
37
|
/** What stays and what goes now, and why. */
|
|
29
38
|
export declare function stagePlan(stateDir: string, inUse: readonly string[], now: Date): StagePlan;
|
|
30
39
|
export type StagePrune = {
|
|
@@ -33,10 +42,13 @@ export type StagePrune = {
|
|
|
33
42
|
refused: string | null;
|
|
34
43
|
};
|
|
35
44
|
/**
|
|
36
|
-
* Remove the stages that go. Right before each goes, the plan is made again (what's in use now)
|
|
37
|
-
* still be a real folder directly in staged-upgrades.
|
|
45
|
+
* Remove the stages that go. Right before each goes, the plan is made again (what's in use now), nothing live may use
|
|
46
|
+
* it (`spare`, storage-sweep.ts) and the stage must still be a real folder directly in staged-upgrades.
|
|
38
47
|
*/
|
|
39
48
|
export declare function pruneStagedRuntimes(stateDir: string, now: Date, options?: {
|
|
40
49
|
inUse?: () => string[];
|
|
41
50
|
remove?: (path: string) => void;
|
|
51
|
+
spare?: (path: string) => boolean;
|
|
52
|
+
/** Deterministic race seam, not supplied by production callers. */
|
|
53
|
+
quarantined?: (original: string, trash: string) => void;
|
|
42
54
|
}): StagePrune;
|