toolroll 0.9.93 → 0.9.94
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 +68 -0
- package/dist/activity-line.d.ts +7 -4
- package/dist/activity-line.js +9 -7
- package/dist/agent-fence.d.ts +4 -0
- package/dist/agent-fence.js +4 -1
- package/dist/approval-authority.d.ts +7 -1
- package/dist/approval-authority.js +11 -2
- package/dist/approval-password.d.ts +31 -0
- package/dist/approval-password.js +26 -0
- package/dist/assignment-ui.js +1 -1
- package/dist/assignment.js +5 -0
- package/dist/boot-identity.d.ts +2 -1
- package/dist/boot-identity.js +6 -4
- package/dist/bounded-spawn.d.ts +57 -0
- package/dist/bounded-spawn.js +220 -0
- package/dist/browser/terminal.js +42 -42
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +32 -32
- package/dist/browser-shell.d.ts +1 -1
- package/dist/browser-shell.js +2 -2
- package/dist/browser-workspace.d.ts +45 -2
- package/dist/browser-workspace.js +3 -3
- package/dist/builder-restart.d.ts +21 -0
- package/dist/builder-restart.js +50 -0
- package/dist/builder.d.ts +4 -0
- package/dist/builder.js +53 -24
- package/dist/chat-acceptance.js +1 -1
- package/dist/chat-actions.d.ts +12 -2
- package/dist/chat-actions.js +25 -2
- package/dist/chat-channel.js +11 -0
- package/dist/chat-decide.d.ts +7 -2
- package/dist/chat-decide.js +37 -9
- package/dist/chat-quiet.js +3 -3
- package/dist/chat-voice.d.ts +1 -1
- package/dist/chat-voice.js +2 -2
- package/dist/check-facts.d.ts +3 -1
- package/dist/check-facts.js +57 -11
- package/dist/check-failure.d.ts +22 -3
- package/dist/check-failure.js +57 -13
- package/dist/check-levels-ui.d.ts +4 -0
- package/dist/check-levels-ui.js +10 -1
- package/dist/check-load.d.ts +85 -0
- package/dist/check-load.js +147 -0
- package/dist/check-only-standing.d.ts +36 -0
- package/dist/check-only-standing.js +39 -0
- package/dist/check-only.d.ts +15 -0
- package/dist/check-only.js +99 -0
- package/dist/check-report.d.ts +2 -0
- package/dist/check-report.js +1 -1
- package/dist/check-stamp.d.ts +46 -0
- package/dist/check-stamp.js +93 -0
- package/dist/checkout-cleanup.d.ts +36 -5
- package/dist/checkout-cleanup.js +166 -14
- package/dist/cli-help.d.ts +4 -1
- package/dist/cli-help.js +47 -30
- package/dist/cli.js +11 -1
- package/dist/code-extract.d.ts +44 -0
- package/dist/code-extract.js +88 -0
- package/dist/code-map-acts.d.ts +24 -0
- package/dist/code-map-acts.js +53 -0
- package/dist/code-map-admission.d.ts +52 -0
- package/dist/code-map-admission.js +295 -0
- package/dist/code-map-build.d.ts +63 -0
- package/dist/code-map-build.js +415 -0
- package/dist/code-map-cli.d.ts +153 -0
- package/dist/code-map-cli.js +103 -0
- package/dist/code-map-main.d.ts +1 -0
- package/dist/code-map-main.js +48 -0
- package/dist/code-map-read.d.ts +106 -0
- package/dist/code-map-read.js +171 -0
- package/dist/code-map-report.d.ts +37 -0
- package/dist/code-map-report.js +95 -0
- package/dist/code-map-shape.d.ts +172 -0
- package/dist/code-map-shape.js +327 -0
- package/dist/code-map-store.d.ts +136 -0
- package/dist/code-map-store.js +233 -0
- package/dist/coding-provider.d.ts +2 -0
- package/dist/coding-provider.js +3 -2
- package/dist/coding-update.js +3 -2
- package/dist/coding-workspace.js +4 -4
- package/dist/console-chrome.js +2 -2
- package/dist/containment.d.ts +5 -0
- package/dist/containment.js +14 -1
- package/dist/contracts/chat-actions.d.ts +9 -0
- package/dist/contracts/chat-actions.js +2 -0
- package/dist/contracts/cli.d.ts +52 -0
- package/dist/contracts/cli.js +4 -0
- package/dist/contracts/console-api.d.ts +10 -3
- package/dist/contracts/console-api.js +10 -3
- package/dist/contracts/gateway-tools.d.ts +17 -0
- package/dist/contracts/gateway-tools.js +3 -1
- package/dist/contracts/lead-tools.d.ts +61 -0
- package/dist/contracts/lead-tools.js +10 -1
- package/dist/contracts/project-knowledge.d.ts +38 -5
- package/dist/contracts/project-knowledge.js +11 -5
- package/dist/contracts/project-memory.d.ts +3 -1
- package/dist/contracts/project-memory.js +3 -2
- package/dist/contracts/storage.d.ts +14 -0
- package/dist/contracts/storage.js +3 -1
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/contracts/youiyoux-request.d.ts +1 -1
- package/dist/contracts/youiyoux-request.js +1 -1
- package/dist/desktop-access.js +6 -2
- package/dist/dispatch.js +18 -15
- package/dist/dispose.js +8 -2
- package/dist/easy-start.js +2 -3
- package/dist/evals.js +2 -1
- package/dist/fast-start.d.ts +136 -0
- package/dist/fast-start.js +431 -0
- package/dist/fast-terms.d.ts +9 -0
- package/dist/fast-terms.js +23 -0
- package/dist/fenced-check-coverage.d.ts +62 -0
- package/dist/fenced-check-coverage.js +54 -0
- package/dist/first-run.js +1 -1
- package/dist/flaky-ci.d.ts +31 -0
- package/dist/flaky-ci.js +149 -0
- package/dist/flaky-cli.d.ts +60 -0
- package/dist/flaky-cli.js +60 -0
- package/dist/flaky.d.ts +75 -9
- package/dist/flaky.js +255 -0
- package/dist/flow-card-task.js +3 -0
- package/dist/flow-triggers.d.ts +1 -1
- package/dist/flow-triggers.js +1 -1
- package/dist/github-import.d.ts +62 -0
- package/dist/github-import.js +146 -0
- package/dist/guides.js +15 -10
- package/dist/invoke.d.ts +5 -1
- package/dist/invoke.js +49 -14
- package/dist/job-object-helper.ps1 +10 -3
- package/dist/keymap.d.ts +41 -0
- package/dist/keymap.js +51 -0
- package/dist/knowledge-cli.js +1 -1
- package/dist/knowledge-ui.d.ts +21 -4
- package/dist/knowledge-ui.js +75 -7
- package/dist/laptop-background.d.ts +16 -0
- package/dist/laptop-background.js +108 -2
- package/dist/lead-context.js +1 -1
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +2 -2
- package/dist/lead-progress.js +3 -0
- package/dist/lead-status.d.ts +2 -0
- package/dist/lead-status.js +6 -2
- package/dist/lead-tools.js +51 -2
- package/dist/lead.js +1 -1
- package/dist/lesson-notes.d.ts +81 -0
- package/dist/lesson-notes.js +336 -0
- package/dist/limits-ui.js +1 -1
- package/dist/local-owner.d.ts +1 -1
- package/dist/local-owner.js +11 -0
- package/dist/machine-check.d.ts +16 -0
- package/dist/machine-check.js +26 -0
- package/dist/machine-identity.d.ts +75 -0
- package/dist/machine-identity.js +170 -0
- package/dist/mcp-core.js +11 -1
- package/dist/memory-cli.d.ts +300 -1
- package/dist/memory-cli.js +62 -9
- package/dist/memory-pass.d.ts +16 -0
- package/dist/memory-pass.js +67 -3
- package/dist/memory-tidy.d.ts +181 -0
- package/dist/memory-tidy.js +523 -0
- package/dist/memory-use.d.ts +35 -0
- package/dist/memory-use.js +109 -0
- package/dist/merge-checklist.d.ts +55 -0
- package/dist/merge-checklist.js +182 -0
- package/dist/needs-you.d.ts +27 -8
- package/dist/needs-you.js +96 -39
- package/dist/nightly-github.d.ts +2 -0
- package/dist/nightly-github.js +4 -4
- package/dist/operate-remote-arguments.js +6 -1
- package/dist/operate-remote.js +3 -1
- package/dist/operate.d.ts +6 -1
- package/dist/operate.js +402 -52
- package/dist/phase-routing.js +2 -2
- package/dist/plain-schema.d.ts +23 -0
- package/dist/plain-schema.js +94 -0
- package/dist/plain-words.d.ts +20 -0
- package/dist/plain-words.js +66 -0
- package/dist/planner.js +2 -1
- package/dist/process-liveness.d.ts +2 -0
- package/dist/process-liveness.js +61 -17
- package/dist/project-delete-ui.d.ts +7 -2
- package/dist/project-delete-ui.js +12 -1
- package/dist/project-delete.js +8 -1
- package/dist/project-knowledge.d.ts +18 -11
- package/dist/project-knowledge.js +39 -9
- package/dist/project-learning.d.ts +2 -1
- package/dist/project-learning.js +15 -45
- package/dist/project-memory.d.ts +5 -0
- package/dist/project-memory.js +22 -2
- package/dist/proof.js +30 -5
- package/dist/provider-auth.js +1 -1
- package/dist/publish.js +9 -1
- package/dist/pull-request-flow.js +5 -0
- package/dist/push.d.ts +18 -2
- package/dist/push.js +40 -16
- package/dist/release-adapters.d.ts +9 -0
- package/dist/release-adapters.js +76 -0
- package/dist/release-auto.js +13 -13
- package/dist/release-card.d.ts +1 -2
- package/dist/release-card.js +11 -0
- package/dist/release-carry.d.ts +7 -2
- package/dist/release-carry.js +39 -9
- package/dist/release-cli.d.ts +4 -0
- package/dist/release-cli.js +29 -22
- package/dist/release-coverage.js +1 -1
- package/dist/release-next.d.ts +48 -0
- package/dist/release-next.js +161 -0
- package/dist/release.d.ts +20 -1
- package/dist/release.js +76 -8
- package/dist/remote-destination.d.ts +36 -0
- package/dist/remote-destination.js +217 -0
- package/dist/remote-main.d.ts +48 -0
- package/dist/remote-main.js +84 -5
- package/dist/repository-context.d.ts +3 -2
- package/dist/repository-context.js +112 -70
- package/dist/restart-certification.d.ts +1 -0
- package/dist/restart-certification.js +8 -6
- package/dist/result-acts.d.ts +27 -19
- package/dist/result-acts.js +36 -25
- package/dist/result-follow-ups.d.ts +2 -2
- package/dist/result-follow-ups.js +18 -4
- package/dist/result-review.d.ts +1 -1
- package/dist/route-change-check.d.ts +4 -0
- package/dist/route-change-check.js +13 -0
- package/dist/route-change.d.ts +22 -0
- package/dist/route-change.js +89 -0
- package/dist/route-hooks.js +3 -2
- package/dist/route-results.d.ts +6 -1
- package/dist/route-results.js +14 -5
- package/dist/run-closeout.d.ts +1 -1
- package/dist/run-closeout.js +7 -5
- package/dist/runner-process.js +3 -2
- package/dist/runner.js +2 -1
- package/dist/scope.d.ts +6 -1
- package/dist/scope.js +22 -9
- package/dist/serve.d.ts +1 -1
- package/dist/serve.js +24 -18
- package/dist/server/chat.js +14 -12
- package/dist/server/chrome.d.ts +1 -1
- package/dist/server/chrome.js +26 -35
- package/dist/server/console.css +23 -0
- package/dist/server/guards.js +3 -0
- package/dist/server/home.d.ts +6 -0
- package/dist/server/home.js +21 -13
- package/dist/server/http.js +1 -1
- package/dist/server/kit.js +1 -1
- package/dist/server/merge-ready-view.d.ts +10 -0
- package/dist/server/merge-ready-view.js +30 -0
- package/dist/server/pages-assets.d.ts +1 -0
- package/dist/server/pages-assets.js +8 -5
- package/dist/server/pages.js +103 -82
- package/dist/server/render-chat.js +2 -2
- package/dist/server/render-code-map.d.ts +30 -0
- package/dist/server/render-code-map.js +137 -0
- package/dist/server/render-flaky.d.ts +20 -0
- package/dist/server/render-flaky.js +59 -0
- package/dist/server/render-history.js +1 -1
- package/dist/server/render-new-task.d.ts +26 -1
- package/dist/server/render-new-task.js +30 -5
- package/dist/server/render-pages.d.ts +1 -175
- package/dist/server/render-pages.js +38 -313
- package/dist/server/render-projects.d.ts +2 -1
- package/dist/server/render-projects.js +9 -7
- package/dist/server/render-release.d.ts +2 -0
- package/dist/server/render-release.js +3 -3
- package/dist/server/render-results.d.ts +2 -0
- package/dist/server/render-results.js +67 -67
- package/dist/server/render-tasks.d.ts +13 -2
- package/dist/server/render-tasks.js +153 -136
- package/dist/server/retired-routes.d.ts +17 -0
- package/dist/server/retired-routes.js +53 -0
- package/dist/server/route-table.d.ts +3 -0
- package/dist/server/route-table.js +45 -12
- package/dist/server/settings.js +129 -22
- package/dist/server/task-views.js +61 -26
- package/dist/server/tasks.d.ts +2 -6
- package/dist/server/tasks.js +174 -321
- package/dist/server/welcome.js +1 -1
- package/dist/settings-groups-ui.js +1 -1
- package/dist/setup-guide.d.ts +2 -2
- package/dist/stop-proof.js +6 -4
- package/dist/storage-sweep-process.js +1 -1
- package/dist/storage-sweep.d.ts +5 -3
- package/dist/storage-sweep.js +30 -9
- package/dist/storage-ui.js +28 -17
- package/dist/storage.d.ts +27 -2
- package/dist/storage.js +39 -2
- package/dist/store.d.ts +80 -5
- package/dist/store.js +296 -87
- package/dist/surface.js +45 -4
- package/dist/sweep-quarantine.d.ts +1 -0
- package/dist/sweep-quarantine.js +6 -4
- package/dist/tamper-scan.d.ts +4 -0
- package/dist/tamper-scan.js +90 -0
- package/dist/task-aliases.d.ts +5 -0
- package/dist/task-aliases.js +9 -0
- package/dist/task-control.js +4 -1
- package/dist/task-outcome-cli.d.ts +2 -0
- package/dist/task-outcome-cli.js +17 -17
- package/dist/task-sizing.d.ts +5 -0
- package/dist/task-sizing.js +2 -0
- package/dist/task-source.d.ts +7 -0
- package/dist/task-source.js +14 -0
- package/dist/task-status.d.ts +9 -0
- package/dist/task-status.js +40 -49
- package/dist/teardown-git.d.ts +16 -0
- package/dist/teardown-git.js +63 -5
- package/dist/telegram-progress.js +5 -3
- package/dist/telegram-status.js +6 -4
- package/dist/test-infra.d.ts +8 -0
- package/dist/test-infra.js +18 -0
- package/dist/tidy.js +1 -1
- package/dist/toolroll-update.js +17 -5
- package/dist/tui-contract.d.ts +24 -0
- package/dist/tui-source.js +67 -8
- package/dist/tui.js +51 -51
- package/dist/vanished-retry.d.ts +30 -0
- package/dist/vanished-retry.js +174 -0
- package/dist/work-index.d.ts +2 -0
- package/dist/work-index.js +76 -62
- package/dist/work-summary.js +2 -0
- package/dist/workspace-ui.d.ts +4 -2
- package/dist/workspace-ui.js +28 -22
- package/dist/worktree.d.ts +12 -3
- package/dist/worktree.js +68 -13
- package/package.json +1 -1
- package/dist/server/render-next.d.ts +0 -63
- package/dist/server/render-next.js +0 -109
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,73 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.9.94 — 2026-11-07
|
|
6
|
+
|
|
7
|
+
- **Small tasks start in one step.** For small work you start yourself, New
|
|
8
|
+
task shows the exact terms and **Build** is your approval. If the change
|
|
9
|
+
grows past small, Toolroll stops and asks you to approve it to keep it.
|
|
10
|
+
In the terminal: `toolroll task new "<what should change>" --repo <project>`.
|
|
11
|
+
- **Start a task from a GitHub issue or pull request.** Paste the link in New
|
|
12
|
+
task, or use `toolroll task new --from <url>`. Toolroll quotes the text as
|
|
13
|
+
written outside Toolroll, and you read it before filing it.
|
|
14
|
+
- **Needs you now holds only what needs you.** Things that are simply waiting
|
|
15
|
+
say what they wait for, and the task page, chat, the terminal and pings all
|
|
16
|
+
say the same sentence. Pings are one line with one button ("#41 is ready to
|
|
17
|
+
review").
|
|
18
|
+
- **Fewer pages and fewer words.** Home has Needs you, Running and Waiting;
|
|
19
|
+
Tasks is the one list. Old links still land in the right place. A result has
|
|
20
|
+
Accept, Send back and Retry; a plan has Approve, Request changes and Reject.
|
|
21
|
+
One set of keyboard shortcuts works in the console and the terminal.
|
|
22
|
+
- **"Can I merge?"** One checklist on each task: plan, build, checks,
|
|
23
|
+
requirements, pull request, review comments and release check.
|
|
24
|
+
- **A code map for each project.** `/projects/<name>/map` and `toolroll map`
|
|
25
|
+
show the areas, files and exports of your main branch, generated by Toolroll
|
|
26
|
+
itself with no agent or model. Builds and the lead can read it, and the page
|
|
27
|
+
reports how builds with the map compare to builds without it. Turn it off
|
|
28
|
+
per project in its settings.
|
|
29
|
+
- **Flaky tests**: each project lists tests with mixed outcomes under recorded
|
|
30
|
+
conditions, from local checks and GitHub CI re-runs, with history and one
|
|
31
|
+
action, "Make a task to fix it". `toolroll flaky list|show`. Read only:
|
|
32
|
+
nothing is rerun and no check changes.
|
|
33
|
+
- **Lesson notes**: when a revision passes a test that failed, Toolroll
|
|
34
|
+
proposes a note with what was recorded. You put it in your own words and
|
|
35
|
+
accept it as a decision, then see how often that test fails afterwards.
|
|
36
|
+
- **Last included in a run** on lessons, references and decisions, and
|
|
37
|
+
decisions now reach runs in projects with no other knowledge.
|
|
38
|
+
- **Clean up** in Settings → Knowledge proposes retiring, merging or settling
|
|
39
|
+
memory entries. Nothing changes until you accept, and restore and undo merge
|
|
40
|
+
are there when you change your mind. `toolroll memory tidy|restore|unmerge`.
|
|
41
|
+
- **Toolroll cleans up task folders nobody is using.** A task idle for 14
|
|
42
|
+
days now loses its working folder even if its result is still waiting for
|
|
43
|
+
review or the task never finished, as long as nothing would be lost: no
|
|
44
|
+
uncommitted changes, every commit on its branch, nothing running in it.
|
|
45
|
+
Reviewing, Accept, Send back, Run checks, Retry and Resume all keep working;
|
|
46
|
+
the next run makes the folder again from its branch. Choose 7, 14 or 30
|
|
47
|
+
days or never in Settings → Storage, or with
|
|
48
|
+
`toolroll storage cleanup idle <days>`. `toolroll storage` shows what the
|
|
49
|
+
next sweep would free.
|
|
50
|
+
- **Folders with uncommitted changes are listed, never removed.** Settings →
|
|
51
|
+
Storage and `toolroll storage` now show them so you can decide. Storage
|
|
52
|
+
calls these "task folders" now.
|
|
53
|
+
- **Builds that vanish recover on their own, within limits.** A lost build
|
|
54
|
+
counts once and retries up to three times. Past that, or with no budget left,
|
|
55
|
+
the task stops and says why.
|
|
56
|
+
- **A missing check verdict gets one automatic recheck** of that exact commit,
|
|
57
|
+
with no agent, instead of waiting for you.
|
|
58
|
+
- **The builder comes back after a crash.** Toolroll restarts it only if you
|
|
59
|
+
left it on and it can prove the old process is gone. If you turned it off, it
|
|
60
|
+
stays off.
|
|
61
|
+
- **A changed route is one Approve.** When a task's model route changed since
|
|
62
|
+
you approved, you see one line saying what changed and approve once.
|
|
63
|
+
- **One release command.** `toolroll release <branch>` works out the next step,
|
|
64
|
+
picks up what a stopped deploy left, and updates a branch that is behind main.
|
|
65
|
+
- **Toolroll knows which computer it is on, even if the network renames it.**
|
|
66
|
+
A release no longer waits forever on "the computer that ran build #N".
|
|
67
|
+
- **Checks say how they ran.** Each check launch records its sandbox, and the
|
|
68
|
+
result page and release card show it. A check with no record reads unknown and
|
|
69
|
+
is never carried forward as sandboxed.
|
|
70
|
+
|
|
3
71
|
## 0.9.93 — 2026-11-06
|
|
4
72
|
|
|
5
73
|
- **Approve a release with one tap.** When a release is ready for your yes,
|
package/dist/activity-line.d.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* 40 s ago". Pure and shared: the server sends the facts, and each page words
|
|
4
4
|
* them against its own clock, so the age keeps counting between reads.
|
|
5
5
|
*
|
|
6
|
-
* After five minutes with no word the line
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* After five minutes with no word the line says when it last heard, in the
|
|
7
|
+
* same quiet secondary text (a quiet agent isn't something a person must do;
|
|
8
|
+
* amber is for what needs you). A worker whose heartbeat stopped says that
|
|
9
|
+
* instead, and that one is marked: the builder going away needs a person.
|
|
9
10
|
*/
|
|
10
11
|
/** The facts behind the line: our own words for the last thing done (never a tool's arguments), when, and whether
|
|
11
12
|
* the worker running it still answers. */
|
|
@@ -19,8 +20,10 @@ export declare const QUIET_AFTER_MS: number;
|
|
|
19
20
|
export declare const ACTIVITY_WORDS: Readonly<Record<string, string>>;
|
|
20
21
|
/** "just now", "40 s ago", "3 min ago", "2 h ago". */
|
|
21
22
|
export declare function agoWords(ms: number): string;
|
|
22
|
-
/** The line itself
|
|
23
|
+
/** The line itself. `quiet` (marked): the worker is offline, which needs a person. `stale`: silent five minutes or
|
|
24
|
+
* more, said in the same neutral words as any other update. */
|
|
23
25
|
export declare function activityLine(activity: RunActivity, now: number): {
|
|
24
26
|
text: string;
|
|
25
27
|
quiet: boolean;
|
|
28
|
+
stale: boolean;
|
|
26
29
|
};
|
package/dist/activity-line.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
* 40 s ago". Pure and shared: the server sends the facts, and each page words
|
|
4
4
|
* them against its own clock, so the age keeps counting between reads.
|
|
5
5
|
*
|
|
6
|
-
* After five minutes with no word the line
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* After five minutes with no word the line says when it last heard, in the
|
|
7
|
+
* same quiet secondary text (a quiet agent isn't something a person must do;
|
|
8
|
+
* amber is for what needs you). A worker whose heartbeat stopped says that
|
|
9
|
+
* instead, and that one is marked: the builder going away needs a person.
|
|
9
10
|
*/
|
|
10
11
|
export const QUIET_AFTER_MS = 5 * 60_000;
|
|
11
12
|
/** The words for each fixed activity kind (live.ts's vocabulary) and for a progress checkpoint. */
|
|
@@ -35,13 +36,14 @@ export function agoWords(ms) {
|
|
|
35
36
|
return `${minutes} min ago`;
|
|
36
37
|
return `${Math.floor(minutes / 60)} h ago`;
|
|
37
38
|
}
|
|
38
|
-
/** The line itself
|
|
39
|
+
/** The line itself. `quiet` (marked): the worker is offline, which needs a person. `stale`: silent five minutes or
|
|
40
|
+
* more, said in the same neutral words as any other update. */
|
|
39
41
|
export function activityLine(activity, now) {
|
|
40
42
|
const at = Date.parse(activity.at);
|
|
41
43
|
const since = Number.isNaN(at) ? 0 : Math.max(0, now - at);
|
|
42
44
|
if (activity.worker === "offline")
|
|
43
|
-
return { text: `Worker offline · last word ${agoWords(since)}`, quiet: true };
|
|
45
|
+
return { text: `Worker offline · last word ${agoWords(since)}`, quiet: true, stale: false };
|
|
44
46
|
if (since >= QUIET_AFTER_MS)
|
|
45
|
-
return { text: `
|
|
46
|
-
return { text: `${activity.what} · ${agoWords(since)}`, quiet: false };
|
|
47
|
+
return { text: `Last update ${agoWords(since)}`, quiet: false, stale: true };
|
|
48
|
+
return { text: `${activity.what} · ${agoWords(since)}`, quiet: false, stale: false };
|
|
47
49
|
}
|
package/dist/agent-fence.d.ts
CHANGED
|
@@ -90,6 +90,8 @@ export type FenceGrants = {
|
|
|
90
90
|
pool?: CheckoutPool;
|
|
91
91
|
network?: NetworkFence;
|
|
92
92
|
writes?: WriteAllowlist;
|
|
93
|
+
/** Which `readable` entries are one exact file: read with no listing of its folder, so a sibling stays denied. */
|
|
94
|
+
exactFiles?: string[];
|
|
93
95
|
/** Policy v1: the home configuration names denied by name, present or not (TaskBoundary.homeNames). */
|
|
94
96
|
homeNames?: string[];
|
|
95
97
|
/** macOS: the project's sealed mach services beyond the runtime's baseline (its `allowMachLookup`). */
|
|
@@ -359,6 +361,8 @@ export type FenceKind = "build" | "command" | "check" | "flow" | "tool-server";
|
|
|
359
361
|
* with no checkout), so no caller forgets one; every path comes from Toolroll's own records, never from the checkout.
|
|
360
362
|
*/
|
|
361
363
|
export type FenceContext = {
|
|
364
|
+
/** One exact file read back inside the fence (a build's code map), or null. */
|
|
365
|
+
exactFile?: string | null;
|
|
362
366
|
databaseFile: string | null;
|
|
363
367
|
worktree: string | null;
|
|
364
368
|
boundary: TaskBoundary | null;
|
package/dist/agent-fence.js
CHANGED
|
@@ -1006,7 +1006,7 @@ export function srtTermsOf(fence, grants = NO_GRANTS) {
|
|
|
1006
1006
|
const denyRead = new Set(fence.map(real));
|
|
1007
1007
|
// A readable grant's own folder is read too: bundlers (esbuild) find node_modules by listing it. The runtime can't
|
|
1008
1008
|
// grant a listing without the folder's files, so the shared copy's folder is read whole (its ready note among them).
|
|
1009
|
-
const allowRead = new Set(grants.readable.flatMap(path => [real(path), real(dirname(path))]));
|
|
1009
|
+
const allowRead = new Set(grants.readable.flatMap(path => grants.exactFiles?.includes(path) === true ? [real(path)] : [real(path), real(dirname(path))]));
|
|
1010
1010
|
const denyWrite = new Set([...fence, ...grants.readOnly].map(real));
|
|
1011
1011
|
for (const path of grants.writeDenies ?? [])
|
|
1012
1012
|
denyWrite.add(path);
|
|
@@ -1180,9 +1180,12 @@ export function fenceFor(kind, ctx) {
|
|
|
1180
1180
|
// launches: rewriting the code that fences a build would switch the fence off.
|
|
1181
1181
|
const made = { ...found, readOnly: [...new Set([...found.readOnly, ...toolrollInstall(ctx.worktree, ctx.home ?? homedir(), ctx.binding?.main ?? null)])].sort() };
|
|
1182
1182
|
const written = ctx.writes ?? null;
|
|
1183
|
+
const exact = ctx.exactFile ?? null;
|
|
1183
1184
|
const grants = {
|
|
1184
1185
|
...made,
|
|
1185
1186
|
...(written === null ? {} : { writes: written.writes, readOnly: [...new Set([...made.readOnly, ...written.readOnly])].sort() }),
|
|
1187
|
+
// One exact file, granted as the shared dependencies are, and never written (Claude's Edit rules read `readOnly`).
|
|
1188
|
+
...(exact === null ? {} : { readable: [...made.readable, exact], exactFiles: [exact], readOnly: [...new Set([...made.readOnly, ...(written?.readOnly ?? []), exact])].sort() }),
|
|
1186
1189
|
...(ctx.network === undefined ? {} : { network: ctx.network }),
|
|
1187
1190
|
...(ctx.machLookup === undefined || ctx.machLookup.length === 0 ? {} : { machLookup: [...ctx.machLookup] }),
|
|
1188
1191
|
// A project's checks may drive a browser (Playwright's Chromium), and so may a flow's script zone whose browser use
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/** SQL over a `task_scope` alias and a repo expression: whether that row's approval authority stands at `now` (an SQL
|
|
2
|
-
* expression, used exactly
|
|
2
|
+
* expression, used exactly four times: the mode and grant clauses, the release's expiry and a fast start's window). It never asks whether the row is approved.
|
|
3
3
|
* COALESCE: a NULL basis would make the OR chain NULL, and NOT NULL reads as "not dead"; NULL stands on nothing. */
|
|
4
4
|
export declare function authorityStandsSql(scope: string, repo: string, now: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* SQL, conjunctive with every alternative above (plain-toolroll.md P2.4): narrow only. A fast start's seal dispatches only
|
|
7
|
+
* inside its start window, and only as what it claims to be: a person's own ceremony yes to these exact bytes, proved at
|
|
8
|
+
* the seal's instant. Losing the marker leaves an ordinary person approval; nothing here grants anything.
|
|
9
|
+
*/
|
|
10
|
+
export declare function fastStartStandsSql(scope: string, now: string): string;
|
|
5
11
|
/**
|
|
6
12
|
* SQL, conjunctive with every alternative above (results routing slice 3): a task whose sealed route tries a cheaper
|
|
7
13
|
* model (its build leg's selection is a trial) stands only on a PERSON's own yes to these exact bytes — the ceremony
|
|
@@ -18,7 +18,7 @@ import { autoSealSql } from "./release-authority.js";
|
|
|
18
18
|
/** The proofs a person gives (src/person-proof.ts), as an SQL list. */
|
|
19
19
|
const PERSON_PROOFS = "('password-now', 'sso-now', 'fresh-password', 'fresh-sso', 'session', 'cli-login', 'chat', 'passkey', 'local-owner')";
|
|
20
20
|
/** SQL over a `task_scope` alias and a repo expression: whether that row's approval authority stands at `now` (an SQL
|
|
21
|
-
* expression, used exactly
|
|
21
|
+
* expression, used exactly four times: the mode and grant clauses, the release's expiry and a fast start's window). It never asks whether the row is approved.
|
|
22
22
|
* COALESCE: a NULL basis would make the OR chain NULL, and NOT NULL reads as "not dead"; NULL stands on nothing. */
|
|
23
23
|
export function authorityStandsSql(scope, repo, now) {
|
|
24
24
|
return `COALESCE((((COALESCE(${scope}.approval_basis, '') IN ('password', 'chat') AND NOT ${machineSealSql(scope)}
|
|
@@ -31,9 +31,18 @@ export function authorityStandsSql(scope, repo, now) {
|
|
|
31
31
|
OR (${scope}.approval_basis = 'grant' AND ${grantStandsSql(`${scope}.mode_digest`, now, repo)})
|
|
32
32
|
OR (${scope}.approval_basis = 'schedule' AND ${approverStandsSql(scope, repo)})
|
|
33
33
|
OR (${scope}.approval_basis IN ('auto-check', 'auto-green') AND ${autoSealSql(scope, repo)} AND ${approverStandsSql(scope, repo)}))
|
|
34
|
-
AND ${releaseStandsSql(scope, now)} AND ${localSealStandsSql(scope, repo)}
|
|
34
|
+
AND ${releaseStandsSql(scope, now)} AND ${localSealStandsSql(scope, repo)} AND ${fastStartStandsSql(scope, now)}
|
|
35
35
|
AND (instr(COALESCE(${scope}.approved_route_json, ''), '"trial"') = 0 OR ${trialSealStandsSql(scope)})), 0)`;
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* SQL, conjunctive with every alternative above (plain-toolroll.md P2.4): narrow only. A fast start's seal dispatches only
|
|
39
|
+
* inside its start window, and only as what it claims to be: a person's own ceremony yes to these exact bytes, proved at
|
|
40
|
+
* the seal's instant. Losing the marker leaves an ordinary person approval; nothing here grants anything.
|
|
41
|
+
*/
|
|
42
|
+
export function fastStartStandsSql(scope, now) {
|
|
43
|
+
return `NOT EXISTS (SELECT 1 FROM task_ref fsr WHERE fsr.backend = 'built-in' AND fsr.external_id = ${scope}.task_id AND fsr.fast_start_until IS NOT NULL
|
|
44
|
+
AND (fsr.fast_start_until <= ${now} OR COALESCE(${scope}.approval_basis, '') <> 'password' OR ${machineSealSql(scope)} OR NOT ${personSealSql(scope)}))`;
|
|
45
|
+
}
|
|
37
46
|
/**
|
|
38
47
|
* SQL, conjunctive with every alternative above (results routing slice 3): a task whose sealed route tries a cheaper
|
|
39
48
|
* model (its build leg's selection is a trial) stands only on a PERSON's own yes to these exact bytes — the ceremony
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Scope } from "./scope.js";
|
|
2
|
+
import type { Store } from "./store.js";
|
|
3
|
+
export type ApprovalPasswordField = {
|
|
4
|
+
field: "none";
|
|
5
|
+
why: "passwordless" | "local-owner";
|
|
6
|
+
} | {
|
|
7
|
+
field: "remembered";
|
|
8
|
+
since: string;
|
|
9
|
+
} | {
|
|
10
|
+
field: "required";
|
|
11
|
+
} | {
|
|
12
|
+
field: "local-refused";
|
|
13
|
+
message: string;
|
|
14
|
+
href: string;
|
|
15
|
+
action: string;
|
|
16
|
+
};
|
|
17
|
+
export type ApprovalPasswordFacts = {
|
|
18
|
+
/** The approver. */
|
|
19
|
+
name: string;
|
|
20
|
+
taskId: string;
|
|
21
|
+
scope: Scope;
|
|
22
|
+
/** Passwordless is on for this browser session (html.ts passwordlessRendering). */
|
|
23
|
+
passwordless: boolean;
|
|
24
|
+
/** The request is this laptop's local owner (easy-start C5). */
|
|
25
|
+
localOwner: boolean;
|
|
26
|
+
/** When a password was last typed on this session, if it may still stand (scope.ts freshnessFor), else null. */
|
|
27
|
+
freshAt: number | null;
|
|
28
|
+
/** Where a refused local owner goes back to. */
|
|
29
|
+
returnTo: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function approvalPasswordField(store: Store, facts: ApprovalPasswordFacts): ApprovalPasswordField;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** When an approval form asks for a password (docs/plans/plain-toolroll.md P1.3): one function every approve form reads,
|
|
2
|
+
* so the task page, the focused chat and the shared-action review never disagree. It only decides what a form shows;
|
|
3
|
+
* approve() proves the person, the freshness, the local owner and the digest again inside its own transaction, with
|
|
4
|
+
* the same two rules this reads (freshlyApprovable, localApprovalEligible).
|
|
5
|
+
*
|
|
6
|
+
* - `none`: Passwordless is on for this session, or this computer's local owner may approve this exact work.
|
|
7
|
+
* - `remembered`: a password typed on this session in the last fifteen minutes may stand for these terms; the field
|
|
8
|
+
* shows, and may be left empty.
|
|
9
|
+
* - `required`: a password typed now: protected or escalated work, terms an agent wrote, or nothing remembered.
|
|
10
|
+
* - `local-refused`: a local owner who can't approve this here, with where to go instead. */
|
|
11
|
+
import { freshlyApprovable, localApprovalEligible } from "./scope.js";
|
|
12
|
+
import { localOwnerRefusal } from "./local-owner.js";
|
|
13
|
+
export function approvalPasswordField(store, facts) {
|
|
14
|
+
if (facts.localOwner) {
|
|
15
|
+
if (localApprovalEligible(store, facts.name, facts.taskId).ok)
|
|
16
|
+
return { field: "none", why: "local-owner" };
|
|
17
|
+
return { field: "local-refused", ...localOwnerRefusal(store.accountOf(facts.name)?.passwordSet === true, facts.returnTo) };
|
|
18
|
+
}
|
|
19
|
+
if (facts.passwordless)
|
|
20
|
+
return { field: "none", why: "passwordless" };
|
|
21
|
+
if (facts.freshAt === null)
|
|
22
|
+
return { field: "required" };
|
|
23
|
+
const gate = store.approvalGate(facts.taskId, facts.name, "person");
|
|
24
|
+
return freshlyApprovable(store, facts.taskId, facts.scope, gate.verdict === "seal" && !gate.protectedWork)
|
|
25
|
+
? { field: "remembered", since: new Date(facts.freshAt).toISOString() } : { field: "required" };
|
|
26
|
+
}
|
package/dist/assignment-ui.js
CHANGED
|
@@ -63,7 +63,7 @@ export function assignmentAttemptsOf(assignment) {
|
|
|
63
63
|
export function assignmentAttemptsHtml(assignment) {
|
|
64
64
|
if (assignment.attempts.length === 0)
|
|
65
65
|
return html ``;
|
|
66
|
-
return html `<details class="assignment-attempts"><summary>Attempts <span class="meta">${assignment.attempts.length}</span></summary><ol>${assignmentAttemptsOf(assignment).map(attempt => html `<li data-attempt-task="${attempt.taskId}" data-history-version="${attempt.taskId}"><a href="${attempt.href}">${attempt.label}</a>${attempt.runId === null ? '' : html ` <a href="/r/${attempt.runId}">
|
|
66
|
+
return html `<details class="assignment-attempts"><summary>Attempts <span class="meta">${assignment.attempts.length}</span></summary><ol>${assignmentAttemptsOf(assignment).map(attempt => html `<li data-attempt-task="${attempt.taskId}" data-history-version="${attempt.taskId}"><a href="${attempt.href}">${attempt.label}</a>${attempt.runId === null ? '' : html ` <a href="/r/${attempt.runId}">Build #${attempt.runId}</a>`}${attempt.detail === null ? '' : html `<p class="meta">${attempt.detail}</p>`}</li>`)}</ol>${assignment.owner === null ? '' : html `<p class="meta">Lead: ${assignment.owner.label}${assignment.owner.active ? '' : ' · access ended'}</p>`}<p class="work-meta work-id">Task <span class="mono">${assignment.rootId}</span></p></details>`;
|
|
67
67
|
}
|
|
68
68
|
/** Only recognized storage limits and explicitly identified older records are
|
|
69
69
|
* secondary. Unknown findings and damaged current material stay in view. */
|
package/dist/assignment.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** One assignment follows the existing task family. It owns no execution,
|
|
2
2
|
* approval or proof state. Lead ownership and exact receipt acknowledgments
|
|
3
3
|
* are append-only actions; handoffs use the existing durable outbox. */
|
|
4
|
+
import { fastCompletionProblem } from "./fast-start.js";
|
|
4
5
|
import { createHash } from "node:crypto";
|
|
5
6
|
import { currentActor, withActor } from "./actor.js";
|
|
6
7
|
import { replacedWords } from "./task-status.js";
|
|
@@ -496,6 +497,10 @@ function acknowledgeCurrent(store, current, receiptDigest, actor, now, root, acc
|
|
|
496
497
|
// token, a remote API run) that may complete ordinary work but is never the second person on protected work.
|
|
497
498
|
const person = actor.startsWith("operator:") && !machineActing() ? actor.slice("operator:".length) : null;
|
|
498
499
|
const marked = actor.startsWith("operator:") ? (person === null ? COMPLETION_BY_MACHINE : COMPLETION_BY_PERSON) : null;
|
|
500
|
+
// A fast start's Accept backstop (fast-start.ts): a person only, and only a change that stayed small.
|
|
501
|
+
const grown = fastCompletionProblem(store, receipt.taskId, receipt.runId, person);
|
|
502
|
+
if (grown !== null)
|
|
503
|
+
return { ok: false, reason: "not-ready", message: grown };
|
|
499
504
|
if (current.state !== "complete") {
|
|
500
505
|
// The one readiness guard: a failed or missing required check, an unresolved requirement or HIGH finding refuses.
|
|
501
506
|
const blockers = current.readiness?.blockers ?? [];
|
package/dist/boot-identity.d.ts
CHANGED
|
@@ -48,10 +48,11 @@ export declare function injectBootIdentity(identity: BootIdentity | null): void;
|
|
|
48
48
|
export type BootWitness = {
|
|
49
49
|
host: string;
|
|
50
50
|
bootId: string | null;
|
|
51
|
+
machineId?: string | null;
|
|
51
52
|
};
|
|
52
53
|
/**
|
|
53
54
|
* THE ONE RULE (plan §login/reboot recovery): a witnessed process is
|
|
54
|
-
* proven dead by boot change only when the witness names THIS
|
|
55
|
+
* proven dead by boot change only when the witness names THIS machine (isThisMachine), the
|
|
55
56
|
* witness carries a well-formed boot id, this host's current boot id is
|
|
56
57
|
* well-formed and known, and the two differ. Same boot, a legacy witness
|
|
57
58
|
* without an id, a foreign host, an unknown or malformed current id — each
|
package/dist/boot-identity.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { execFileSync } from "node:child_process";
|
|
23
23
|
import { readFileSync } from "node:fs";
|
|
24
|
-
import {
|
|
24
|
+
import { isThisMachine } from "./machine-identity.js";
|
|
25
25
|
/** RFC 4122 shape, any case; the kernel tokens are exactly this. */
|
|
26
26
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
27
27
|
/** Normalize a candidate token: exactly one UUID, lower-cased; anything else is malformed. */
|
|
@@ -79,15 +79,17 @@ export function injectBootIdentity(identity) {
|
|
|
79
79
|
}
|
|
80
80
|
/**
|
|
81
81
|
* THE ONE RULE (plan §login/reboot recovery): a witnessed process is
|
|
82
|
-
* proven dead by boot change only when the witness names THIS
|
|
82
|
+
* proven dead by boot change only when the witness names THIS machine (isThisMachine), the
|
|
83
83
|
* witness carries a well-formed boot id, this host's current boot id is
|
|
84
84
|
* well-formed and known, and the two differ. Same boot, a legacy witness
|
|
85
85
|
* without an id, a foreign host, an unknown or malformed current id — each
|
|
86
86
|
* answers false, and the caller keeps its PID probe or its refusal.
|
|
87
87
|
*/
|
|
88
88
|
export function provenDeadByBootChange(witness, current = { bootId: currentBootId() }) {
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
// A caller naming the host compares exactly (restart certification, tests); otherwise THIS machine is decided by
|
|
90
|
+
// its stable id, or for a witness without one by this machine's own names (machine-identity.ts), never by the
|
|
91
|
+
// network hostname alone: a boot id from another machine always differs from ours.
|
|
92
|
+
if (current.host !== undefined ? witness.host !== current.host : !isThisMachine(witness))
|
|
91
93
|
return false;
|
|
92
94
|
if (witness.bootId === null || current.bootId === null)
|
|
93
95
|
return false;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type BoundedStart = {
|
|
2
|
+
pid: number;
|
|
3
|
+
pgid: number;
|
|
4
|
+
startedAt: string | null;
|
|
5
|
+
};
|
|
6
|
+
export type BoundedOptions = {
|
|
7
|
+
cwd: string;
|
|
8
|
+
/** The child's whole environment. */
|
|
9
|
+
env: Record<string, string>;
|
|
10
|
+
label: string;
|
|
11
|
+
stdin?: Buffer;
|
|
12
|
+
timeoutMs: number;
|
|
13
|
+
/** Past this the tree is killed (`killed: "output"`). */
|
|
14
|
+
maxStdout: number;
|
|
15
|
+
/** Kept to this many bytes; more is dropped. */
|
|
16
|
+
maxStderr: number;
|
|
17
|
+
memoryBytes?: number;
|
|
18
|
+
diskBytes?: number;
|
|
19
|
+
/** The largest file any process may write (RLIMIT_FSIZE). */
|
|
20
|
+
fileBytes?: number;
|
|
21
|
+
/** The folder whose total `diskBytes` bounds. */
|
|
22
|
+
folder?: string;
|
|
23
|
+
onStart?: (started: BoundedStart) => void;
|
|
24
|
+
sampleMs?: number;
|
|
25
|
+
};
|
|
26
|
+
export type BoundedResult = {
|
|
27
|
+
code: number | null;
|
|
28
|
+
signal: NodeJS.Signals | null;
|
|
29
|
+
stdout: Buffer;
|
|
30
|
+
stderr: string;
|
|
31
|
+
killed: "time" | "memory" | "disk" | "output" | null;
|
|
32
|
+
enforced: "system" | "checked";
|
|
33
|
+
peakBytes: number;
|
|
34
|
+
};
|
|
35
|
+
export type BoundedRunner = (file: string, args: readonly string[], options: BoundedOptions) => Promise<BoundedResult>;
|
|
36
|
+
/** Tests: forget the probed machine. */
|
|
37
|
+
export declare function resetBoundedContainmentForTests(): void;
|
|
38
|
+
/** A process's own start time, as `ps` reports it: what tells a live process from a reused pid. */
|
|
39
|
+
export declare function processStartedAt(pid: number): string | null;
|
|
40
|
+
/** The folder's total size, without following a link. */
|
|
41
|
+
export declare function folderBytes(folder: string): number;
|
|
42
|
+
export declare const boundedRun: BoundedRunner;
|
|
43
|
+
export type SpawnRecord = {
|
|
44
|
+
pid: number | null;
|
|
45
|
+
pgid: number | null;
|
|
46
|
+
startedAt: string | null;
|
|
47
|
+
folder: string | null;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* What a crashed build left: its group is killed only if the leader is still the process recorded (the pid and its start
|
|
51
|
+
* time, so a reused pid isn't killed), and its folder goes if it sits under `root`. On Linux the cgroup janitor has
|
|
52
|
+
* done this already.
|
|
53
|
+
*/
|
|
54
|
+
export declare function cleanupLeftover(record: SpawnRecord, root: string): {
|
|
55
|
+
killed: boolean;
|
|
56
|
+
removed: boolean;
|
|
57
|
+
};
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One bounded child process tree, for work on a repository's untrusted bytes (docs/plans/code-map.md §1). What is
|
|
3
|
+
* guaranteed depends on the platform, and the result says which:
|
|
4
|
+
*
|
|
5
|
+
* Linux with a delegated cgroup, Windows the OS object (containment.ts, policy `preferred`) holds the memory limit,
|
|
6
|
+
* and dies with the worker. `enforced: "system"`.
|
|
7
|
+
* macOS, Linux without a cgroup no hard memory or descendant bound exists. Memory and disk are sampled every
|
|
8
|
+
* 250 ms; a burst between samples can pass the limit before the kill, and a
|
|
9
|
+
* helper that left the process group can survive it. `enforced: "checked"`.
|
|
10
|
+
*
|
|
11
|
+
* `RLIMIT_FSIZE` applies to every POSIX spawn. The whole group is killed with SIGKILL on any limit, and again when the
|
|
12
|
+
* root exits, so nothing the root started outlives it.
|
|
13
|
+
*/
|
|
14
|
+
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
15
|
+
import { lstatSync, readdirSync, rmSync } from "node:fs";
|
|
16
|
+
import { join, relative, isAbsolute } from "node:path";
|
|
17
|
+
import { createContainer, effectiveContainment, probeContainmentCapability } from "./containment.js";
|
|
18
|
+
const SAMPLE_MS = 250;
|
|
19
|
+
const PIDS = 64;
|
|
20
|
+
/** Applies the file-size limit (bash counts it in 1024-byte blocks, POSIX shells in 512), then becomes the target with
|
|
21
|
+
* argv untouched. */
|
|
22
|
+
const PRELUDE = 'if [ "$1" != none ]; then if [ -n "$BASH_VERSION" ]; then b=$(($1/1024)); else b=$(($1/512)); fi; ulimit -f "$b" 2>/dev/null || exit 126; fi\nshift\nexec "$@"';
|
|
23
|
+
let containment = null;
|
|
24
|
+
const preferred = () => containment ??= effectiveContainment("preferred", probeContainmentCapability());
|
|
25
|
+
/** Tests: forget the probed machine. */
|
|
26
|
+
export function resetBoundedContainmentForTests() { containment = null; }
|
|
27
|
+
/** A process's own start time, as `ps` reports it: what tells a live process from a reused pid. */
|
|
28
|
+
export function processStartedAt(pid) {
|
|
29
|
+
if (process.platform === "win32")
|
|
30
|
+
return null;
|
|
31
|
+
try {
|
|
32
|
+
return execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function processGroupOf(pid) {
|
|
39
|
+
if (process.platform === "win32")
|
|
40
|
+
return null;
|
|
41
|
+
try {
|
|
42
|
+
const group = Number(execFileSync("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"] }).trim());
|
|
43
|
+
return Number.isInteger(group) && group > 1 ? group : null;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function groupRss(pgid) {
|
|
50
|
+
return new Promise(done => {
|
|
51
|
+
execFile("ps", ["-A", "-o", "pgid=,rss="], { encoding: "utf8", timeout: 2_000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => {
|
|
52
|
+
if (error !== null) {
|
|
53
|
+
done(null);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
let kilobytes = 0;
|
|
57
|
+
for (const line of stdout.split("\n")) {
|
|
58
|
+
const [group, rss] = line.trim().split(/\s+/);
|
|
59
|
+
if (Number(group) === pgid)
|
|
60
|
+
kilobytes += Number(rss) || 0;
|
|
61
|
+
}
|
|
62
|
+
done(kilobytes * 1024);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** The folder's total size, without following a link. */
|
|
67
|
+
export function folderBytes(folder) {
|
|
68
|
+
let total = 0;
|
|
69
|
+
const walk = (dir) => {
|
|
70
|
+
let names;
|
|
71
|
+
try {
|
|
72
|
+
names = readdirSync(dir);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const name of names) {
|
|
78
|
+
const path = join(dir, name);
|
|
79
|
+
try {
|
|
80
|
+
const held = lstatSync(path);
|
|
81
|
+
if (held.isDirectory())
|
|
82
|
+
walk(path);
|
|
83
|
+
else
|
|
84
|
+
total += held.size;
|
|
85
|
+
}
|
|
86
|
+
catch { /* a temporary pack gone between the list and the look */ }
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
walk(folder);
|
|
90
|
+
return total;
|
|
91
|
+
}
|
|
92
|
+
const killGroup = (pgid) => { try {
|
|
93
|
+
process.kill(-pgid, "SIGKILL");
|
|
94
|
+
}
|
|
95
|
+
catch { /* gone */ } };
|
|
96
|
+
export const boundedRun = async (file, args, options) => {
|
|
97
|
+
const windows = process.platform === "win32";
|
|
98
|
+
const effective = preferred();
|
|
99
|
+
const made = createContainer(effective, options.label);
|
|
100
|
+
const container = "container" in made ? made.container : null;
|
|
101
|
+
let enforced = "checked";
|
|
102
|
+
if (container !== null && options.memoryBytes !== undefined && container.limit?.({ memoryBytes: options.memoryBytes, pids: PIDS }) === true)
|
|
103
|
+
enforced = "system";
|
|
104
|
+
const target = windows ? { file, args: [...args] } : { file: "/bin/sh", args: ["-c", PRELUDE, "so-bounded", options.fileBytes === undefined ? "none" : String(options.fileBytes), file, ...args] };
|
|
105
|
+
const launch = container?.launch(target.file, target.args) ?? null;
|
|
106
|
+
const child = spawn(launch?.file ?? target.file, launch?.args ?? target.args, {
|
|
107
|
+
cwd: options.cwd, env: options.env, shell: false, windowsHide: true, detached: !windows,
|
|
108
|
+
stdio: ["pipe", "pipe", "pipe", ...(launch?.extraStdio ?? [])],
|
|
109
|
+
});
|
|
110
|
+
const stdout = [];
|
|
111
|
+
let stdoutBytes = 0, stderr = "", killed = null, peakBytes = 0, sampling = false;
|
|
112
|
+
const pid = child.pid;
|
|
113
|
+
const kill = (why) => {
|
|
114
|
+
killed ??= why;
|
|
115
|
+
if (pid !== undefined && !windows)
|
|
116
|
+
killGroup(pid);
|
|
117
|
+
if (container !== null)
|
|
118
|
+
void container.kill();
|
|
119
|
+
else if (windows) {
|
|
120
|
+
try {
|
|
121
|
+
child.kill("SIGKILL");
|
|
122
|
+
}
|
|
123
|
+
catch { /* gone */ }
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
if (pid !== undefined) {
|
|
127
|
+
try {
|
|
128
|
+
options.onStart?.({ pid, pgid: pid, startedAt: processStartedAt(pid) });
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
kill("time");
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
if (launch !== null) {
|
|
135
|
+
const attached = await launch.attach(child);
|
|
136
|
+
if (!attached.ok)
|
|
137
|
+
kill("time");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
child.stdout?.on("data", (chunk) => {
|
|
141
|
+
stdoutBytes += chunk.length;
|
|
142
|
+
if (stdoutBytes > options.maxStdout) {
|
|
143
|
+
kill("output");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
stdout.push(chunk);
|
|
147
|
+
});
|
|
148
|
+
child.stderr?.on("data", (chunk) => { if (stderr.length < options.maxStderr)
|
|
149
|
+
stderr += chunk.toString("utf8").slice(0, options.maxStderr - stderr.length); });
|
|
150
|
+
child.stdin?.on("error", () => { });
|
|
151
|
+
child.stdin?.end(options.stdin);
|
|
152
|
+
const timer = setTimeout(() => kill("time"), options.timeoutMs);
|
|
153
|
+
const sampler = setInterval(() => {
|
|
154
|
+
if (sampling || pid === undefined)
|
|
155
|
+
return;
|
|
156
|
+
sampling = true;
|
|
157
|
+
void (async () => {
|
|
158
|
+
try {
|
|
159
|
+
if (options.memoryBytes !== undefined && enforced === "checked" && !windows) {
|
|
160
|
+
const rss = await groupRss(pid);
|
|
161
|
+
if (rss !== null) {
|
|
162
|
+
peakBytes = Math.max(peakBytes, rss);
|
|
163
|
+
if (rss > options.memoryBytes)
|
|
164
|
+
kill("memory");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (options.diskBytes !== undefined && options.folder !== undefined && folderBytes(options.folder) > options.diskBytes)
|
|
168
|
+
kill("disk");
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
sampling = false;
|
|
172
|
+
}
|
|
173
|
+
})();
|
|
174
|
+
}, options.sampleMs ?? SAMPLE_MS);
|
|
175
|
+
const finished = await new Promise(done => {
|
|
176
|
+
let settled = false;
|
|
177
|
+
const settle = (code, signal) => { if (!settled) {
|
|
178
|
+
settled = true;
|
|
179
|
+
done({ code, signal });
|
|
180
|
+
} };
|
|
181
|
+
child.once("error", () => settle(127, null));
|
|
182
|
+
child.once("close", (code, signal) => settle(code, signal));
|
|
183
|
+
// A helper that left the group can hold the pipes open past the root's exit: the root's exit is the answer.
|
|
184
|
+
child.once("exit", (code, signal) => {
|
|
185
|
+
if (pid !== undefined && !windows)
|
|
186
|
+
killGroup(pid);
|
|
187
|
+
setTimeout(() => { child.stdout?.destroy(); child.stderr?.destroy(); settle(code, signal); }, 2_000).unref?.();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
clearInterval(sampler);
|
|
192
|
+
if (container !== null) {
|
|
193
|
+
await container.kill(2_000);
|
|
194
|
+
container.release();
|
|
195
|
+
}
|
|
196
|
+
return { code: finished.code, signal: finished.signal, stdout: Buffer.concat(stdout), stderr, killed, enforced, peakBytes };
|
|
197
|
+
};
|
|
198
|
+
/**
|
|
199
|
+
* What a crashed build left: its group is killed only if the leader is still the process recorded (the pid and its start
|
|
200
|
+
* time, so a reused pid isn't killed), and its folder goes if it sits under `root`. On Linux the cgroup janitor has
|
|
201
|
+
* done this already.
|
|
202
|
+
*/
|
|
203
|
+
export function cleanupLeftover(record, root) {
|
|
204
|
+
let killed = false, removed = false;
|
|
205
|
+
if (record.pid !== null && record.pgid !== null && record.startedAt !== null && processStartedAt(record.pid) === record.startedAt && processGroupOf(record.pid) === record.pgid) {
|
|
206
|
+
killGroup(record.pgid);
|
|
207
|
+
killed = true;
|
|
208
|
+
}
|
|
209
|
+
if (record.folder !== null) {
|
|
210
|
+
const inner = relative(root, record.folder);
|
|
211
|
+
if (inner !== "" && !inner.startsWith("..") && !isAbsolute(inner)) {
|
|
212
|
+
try {
|
|
213
|
+
rmSync(record.folder, { recursive: true, force: true });
|
|
214
|
+
removed = true;
|
|
215
|
+
}
|
|
216
|
+
catch { /* the next takeover tries again */ }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { killed, removed };
|
|
220
|
+
}
|