toolroll 0.9.75 → 0.9.77
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 +241 -0
- package/README.md +11 -4
- package/THIRD_PARTY_NOTICES.md +5 -5
- package/dist/agent-fence.js +36 -8
- package/dist/approval-authority.js +6 -2
- package/dist/approval-rules-ui.d.ts +11 -0
- package/dist/approval-rules-ui.js +13 -1
- package/dist/assignment.js +3 -0
- package/dist/batch-checks.js +3 -1
- package/dist/browser/THIRD_PARTY_NOTICES.txt +5 -5
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +31 -31
- package/dist/builder.d.ts +14 -0
- package/dist/builder.js +141 -42
- package/dist/chat-decide.js +4 -0
- package/dist/check-failure.d.ts +92 -0
- package/dist/check-failure.js +267 -0
- package/dist/check-progress.d.ts +28 -1
- package/dist/check-progress.js +105 -6
- package/dist/check-report.d.ts +52 -6
- package/dist/check-report.js +193 -8
- package/dist/checkout-cleanup.js +2 -2
- package/dist/cli.js +8 -5
- package/dist/coding-handoff.d.ts +1 -0
- package/dist/coding-handoff.js +20 -11
- package/dist/coding-workspace.js +7 -5
- package/dist/commit-extract.d.ts +6 -0
- package/dist/commit-extract.js +63 -8
- package/dist/console-chrome.d.ts +41 -0
- package/dist/console-chrome.js +166 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/release-check.d.ts +12 -0
- package/dist/contracts/release-check.js +17 -0
- package/dist/contracts/verification-receipt.d.ts +20 -2
- package/dist/contracts/verification-receipt.js +15 -3
- package/dist/contracts/verification-view.d.ts +8 -0
- package/dist/controller-supervisor.d.ts +8 -0
- package/dist/controller-supervisor.js +33 -1
- package/dist/demo.js +3 -2
- package/dist/deploy-gate.d.ts +3 -0
- package/dist/deploy-gate.js +8 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discover.js +6 -1
- package/dist/dispose.d.ts +3 -0
- package/dist/dispose.js +6 -3
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +10 -2
- package/dist/envelope.js +2 -1
- package/dist/evidence.js +4 -1
- package/dist/exec.d.ts +7 -0
- package/dist/exec.js +7 -1
- package/dist/first-run.js +3 -1
- package/dist/git-floor.d.ts +16 -0
- package/dist/git-floor.js +36 -0
- package/dist/git-incident.d.ts +49 -0
- package/dist/git-incident.js +69 -0
- package/dist/git-views.d.ts +20 -0
- package/dist/git-views.js +120 -0
- package/dist/grant.js +7 -1
- package/dist/guides.js +4 -3
- package/dist/operate.d.ts +13 -0
- package/dist/operate.js +779 -319
- package/dist/planner.js +5 -1
- package/dist/prepared-evidence.d.ts +2 -1
- package/dist/prepared-evidence.js +8 -3
- package/dist/project-knowledge.js +6 -2
- package/dist/project-learning.js +23 -3
- package/dist/proof.js +6 -0
- package/dist/pull-request-flow.js +4 -0
- package/dist/read-executor-worker.js +8 -4
- package/dist/read-executor.d.ts +18 -1
- package/dist/read-executor.js +8 -0
- package/dist/release-adapters.d.ts +71 -2
- package/dist/release-adapters.js +471 -12
- package/dist/release-authority.d.ts +46 -0
- package/dist/release-authority.js +68 -0
- package/dist/release-auto.d.ts +184 -0
- package/dist/release-auto.js +612 -0
- package/dist/release-card.d.ts +15 -3
- package/dist/release-card.js +25 -9
- package/dist/release-ci-hold.d.ts +41 -0
- package/dist/release-ci-hold.js +53 -0
- package/dist/release-cli.js +77 -14
- package/dist/release-coverage.js +5 -2
- package/dist/release-recheck.d.ts +1 -1
- package/dist/release-recheck.js +6 -1
- package/dist/release.d.ts +207 -6
- package/dist/release.js +495 -20
- package/dist/repository-context.js +4 -6
- package/dist/result-follow-ups.js +3 -1
- package/dist/review-context.js +8 -6
- package/dist/runner.js +2 -1
- package/dist/sandbox-runtime.d.ts +8 -0
- package/dist/sandbox-runtime.js +14 -2
- package/dist/scope.d.ts +31 -2
- package/dist/scope.js +51 -4
- package/dist/scout.js +5 -1
- package/dist/secret-mint.d.ts +1 -0
- package/dist/secret-mint.js +14 -0
- package/dist/serve.js +101 -51
- package/dist/server/chat.js +5 -5
- package/dist/server/render-tasks.d.ts +12 -0
- package/dist/server/render-tasks.js +25 -14
- package/dist/server/request-context.d.ts +6 -0
- package/dist/server/route-table.js +3 -0
- package/dist/server/runtime.d.ts +2 -0
- package/dist/server/settings.js +29 -2
- package/dist/server/task-views.js +15 -2
- package/dist/setup-guide.d.ts +2 -2
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +28 -1
- package/dist/store.d.ts +11 -1
- package/dist/store.js +44 -5
- package/dist/task-base.d.ts +4 -0
- package/dist/task-base.js +22 -2
- package/dist/teardown-git.d.ts +90 -13
- package/dist/teardown-git.js +1027 -129
- package/dist/test-infra.d.ts +26 -0
- package/dist/test-infra.js +321 -0
- package/dist/up-processes.d.ts +137 -0
- package/dist/up-processes.js +165 -0
- package/dist/up-role-child.d.ts +1 -0
- package/dist/up-role-child.js +77 -0
- package/dist/verification-evidence.d.ts +1 -0
- package/dist/verification-evidence.js +13 -1
- package/dist/worktree.d.ts +4 -0
- package/dist/worktree.js +13 -7
- package/package.json +2 -1
package/dist/dispose.js
CHANGED
|
@@ -455,7 +455,10 @@ export function regateTask(store, taskId, now, approval) {
|
|
|
455
455
|
if (scope === null)
|
|
456
456
|
return { ok: false, reason: "no-attempt", message: `${taskId} has no scope` };
|
|
457
457
|
const head = last.headRevision;
|
|
458
|
-
const actor = approval.kind === "operator" ? approval.name : `mode ${approval.name}`;
|
|
458
|
+
const actor = approval.kind === "operator" ? approval.name : approval.kind === "release" ? approval.name : `mode ${approval.name}`;
|
|
459
|
+
if (approval.kind === "release" && ((scope.candidate ?? null) === null || (scope.releaseTerms ?? null) === null)) {
|
|
460
|
+
return { ok: false, reason: "not-approved", message: `${taskId} isn't a release candidate; only a release reruns under its own approval` };
|
|
461
|
+
}
|
|
459
462
|
// A prepared candidate (a release gate, the nightly check) already names the approved commit, and the builder loads
|
|
460
463
|
// exactly its tree on every attempt. The rerun keeps that scope and its approval as they are: nothing is proposed or
|
|
461
464
|
// sealed again, so a regate can never swap the approved commit, its release terms or the person's yes for others
|
|
@@ -481,7 +484,7 @@ export function regateTask(store, taskId, now, approval) {
|
|
|
481
484
|
return { ok: false, reason: why[queued.reason] ?? "not-rejected", message: `${taskId} could not be queued again: ${queued.reason}` };
|
|
482
485
|
}
|
|
483
486
|
}
|
|
484
|
-
store.addRunNote(last.id, "Toolroll", `The check will run again on the approved commit ${candidate.slice(0, 7)}, under the same approval, as a new attempt, ${approval.kind === "operator" ? `on ${approval.name}'s say-so` : "automatically once, because the failure was outside this change"}.`, now);
|
|
487
|
+
store.addRunNote(last.id, "Toolroll", `The check will run again on the approved commit ${candidate.slice(0, 7)}, under the same approval, as a new attempt, ${approval.kind === "operator" ? `on ${approval.name}'s say-so` : approval.kind === "release" ? "because Toolroll confirmed the failure was its own infrastructure, not the code" : "automatically once, because the failure was outside this change"}.`, now);
|
|
485
488
|
return { ok: true, run: last.id, head: candidate };
|
|
486
489
|
});
|
|
487
490
|
}
|
|
@@ -494,7 +497,7 @@ export function regateTask(store, taskId, now, approval) {
|
|
|
494
497
|
let sealed = false;
|
|
495
498
|
if (approval.kind === "operator")
|
|
496
499
|
sealed = approve(store, taskId, approval.name, now, proposed.digest, approval.token).ok;
|
|
497
|
-
else
|
|
500
|
+
else if (approval.kind === "mode")
|
|
498
501
|
sealed = store.sealScopeApproval(taskId, actor, now, {}, { kind: "mode", modeDigest: approval.modeDigest });
|
|
499
502
|
if (!sealed)
|
|
500
503
|
return { ok: false, reason: "not-approved", message: `${taskId}'s rerun scope could not be approved` };
|
package/dist/doctor.d.ts
CHANGED
|
@@ -24,6 +24,11 @@ export type DoctorReport = {
|
|
|
24
24
|
version: string;
|
|
25
25
|
problem: string | null;
|
|
26
26
|
};
|
|
27
|
+
/** The installed git against Toolroll's floor (git-floor.ts): null when it is new enough (or unreadable). */
|
|
28
|
+
git?: {
|
|
29
|
+
floor: string;
|
|
30
|
+
problem: string | null;
|
|
31
|
+
};
|
|
27
32
|
};
|
|
28
33
|
export type DoctorDeps = {
|
|
29
34
|
platform?: NodeJS.Platform;
|
|
@@ -35,6 +40,8 @@ export type DoctorDeps = {
|
|
|
35
40
|
procRoot?: string;
|
|
36
41
|
/** The doctor's own environment: the terminal a `toolroll up` would start from. */
|
|
37
42
|
env?: NodeJS.ProcessEnv;
|
|
43
|
+
/** The installed git's floor problem (a test's stand-in for git-floor.ts installedGitFloorProblem). */
|
|
44
|
+
gitFloorProblem?: string | null;
|
|
38
45
|
};
|
|
39
46
|
/** The names among `pairs` that hold a credential: a provider key, a credential-looking name, or a key-shaped value.
|
|
40
47
|
* Values are only tested here and never leave. */
|
package/dist/doctor.js
CHANGED
|
@@ -21,6 +21,7 @@ import { join } from "node:path";
|
|
|
21
21
|
import { daemonStatus, installedServiceDefinition, planDaemon } from "./daemon.js";
|
|
22
22
|
import { scanForSecrets } from "./evidence.js";
|
|
23
23
|
import { envelopeJson } from "./envelope.js";
|
|
24
|
+
import { GIT_FLOOR_WORDS, installedGitFloorProblem } from "./git-floor.js";
|
|
24
25
|
import { run as runCommand } from "./exec.js";
|
|
25
26
|
import { OWN_KEY_ENV } from "./keys.js";
|
|
26
27
|
import { ALL_CREDENTIAL_ENV } from "./provider.js";
|
|
@@ -123,7 +124,8 @@ function reinstallCommand(args) {
|
|
|
123
124
|
const flag = equal < 0 ? arg : arg.slice(0, equal);
|
|
124
125
|
const value = equal < 0 ? args[index + 1] : arg.slice(equal + 1);
|
|
125
126
|
const known = freeform.has(flag) || numeric.has(flag) || Object.hasOwn(enums, flag);
|
|
126
|
-
|
|
127
|
+
// A credential's value is the next argument even when it starts with "-" (random base64url can).
|
|
128
|
+
if (equal < 0 && value !== undefined && (CREDENTIAL_FLAG.test(flag) || (known && !value.startsWith("-"))))
|
|
127
129
|
index += 1;
|
|
128
130
|
if (flag === "--token") {
|
|
129
131
|
kept.push("--token-file", "<set-again>");
|
|
@@ -386,7 +388,9 @@ export async function auditServiceEnvironment(deps = {}) {
|
|
|
386
388
|
/** The report as the owner reads it: one state, then each finding with its steps; detail after. */
|
|
387
389
|
export function doctorLines(report) {
|
|
388
390
|
const lines = [];
|
|
389
|
-
if (report.
|
|
391
|
+
if (report.git?.problem != null)
|
|
392
|
+
lines.push(`Git: ${report.git.problem}`, "");
|
|
393
|
+
if (report.findings.length === 0 && report.unreadable.length === 0)
|
|
390
394
|
lines.push("No secrets found in Toolroll's service. A build can't read a key from it.");
|
|
391
395
|
else if (report.findings.length > 0)
|
|
392
396
|
lines.push(`Toolroll's service holds ${report.findings.length === 1 ? "a secret" : "secrets"} a build could read. Nothing was changed; follow these steps.`);
|
|
@@ -422,6 +426,10 @@ export async function runDoctorCommand(argv, write, deps = {}) {
|
|
|
422
426
|
}
|
|
423
427
|
const report = await auditServiceEnvironment(deps);
|
|
424
428
|
report.sandbox = { engine: SANDBOX_RUNTIME.name, version: SANDBOX_RUNTIME.version, problem: sandboxRuntimeProblem() };
|
|
429
|
+
// A git older than the floor: named here too, and the console and worker won't start on it.
|
|
430
|
+
report.git = { floor: GIT_FLOOR_WORDS, problem: deps.gitFloorProblem === undefined ? installedGitFloorProblem() : deps.gitFloorProblem };
|
|
431
|
+
if (report.git.problem !== null)
|
|
432
|
+
report.ok = false;
|
|
425
433
|
if (json)
|
|
426
434
|
write(envelopeJson({ command: "doctor", ...report }));
|
|
427
435
|
else
|
package/dist/envelope.js
CHANGED
|
@@ -74,7 +74,8 @@ export const DOCUMENTED_REASONS = [
|
|
|
74
74
|
"drift", // the branch, pull request or result moved from the gated commit
|
|
75
75
|
"check-failed", // the release check failed; it stays visible and nothing reruns it
|
|
76
76
|
"ci-failed", // a required pull-request check failed or is missing — nothing merged
|
|
77
|
-
"
|
|
77
|
+
"git-too-old", // the installed git is older than Toolroll's floor (git-floor.ts) — serve, watch and up don't start
|
|
78
|
+
"base-moved", // the release branch lacks main's head — nothing filed (gate) or merged
|
|
78
79
|
"tree-mismatch", // main's merge commit has a different tree than the gated commit — nothing tagged
|
|
79
80
|
"conflict", // a task or tag exists for a different commit
|
|
80
81
|
"mixed-runtime", // the console, worker and CLI names don't run one build
|
package/dist/evidence.js
CHANGED
|
@@ -23,6 +23,7 @@ import { parseReport, REPORT_LIMITS } from "./scout-report.js";
|
|
|
23
23
|
import { parseProof, PROOF_LIMITS } from "./proof.js";
|
|
24
24
|
import { HANDOFF_VERSION, readHandoffArtifact } from "./contracts/handoff.js";
|
|
25
25
|
import { encodeBaseTreeSnapshot, parseBaseTreeSnapshot } from "./peek.js";
|
|
26
|
+
import { noLazyFetch } from "./teardown-git.js";
|
|
26
27
|
/** The park mailbox pattern. Each run's actual name carries a nonce; see `mailboxName`. */
|
|
27
28
|
export const MAILBOX_PREFIX = "STANDING-ORDERS-PARK-";
|
|
28
29
|
/** The terminal handoff: how every non-parking attempt says how it ended. */
|
|
@@ -929,7 +930,9 @@ export async function captureBaseTree(store, git, repoRoot, repo, base, root, ru
|
|
|
929
930
|
}
|
|
930
931
|
}
|
|
931
932
|
async function captureBaseTreeInner(store, git, repoRoot, repo, base, root, runId, now) {
|
|
932
|
-
const args = ["
|
|
933
|
+
const args = [...noLazyFetch(), "-c", "protocol.allow=never", "--no-replace-objects", "--no-optional-locks", "ls-tree", "-r", "-l", "-z", base];
|
|
934
|
+
// `git` is the builder's hardened checkout git (a frozen view): on a git without --no-lazy-fetch it refuses a partial
|
|
935
|
+
// clone from the same config it runs with (teardown-git.ts lazyFetchSetting).
|
|
933
936
|
const listed = await git("git", args, { cwd: repoRoot, envAllowlist: ["PATH", "HOME"] });
|
|
934
937
|
const command = `git ls-tree -r -l -z ${base} (exit ${listed.code})`;
|
|
935
938
|
const refuse = () => {
|
package/dist/exec.d.ts
CHANGED
|
@@ -28,6 +28,13 @@ export type ExecResult = {
|
|
|
28
28
|
* returned — a transport exit alone never proves that.
|
|
29
29
|
*/
|
|
30
30
|
containment?: ContainmentOutcome;
|
|
31
|
+
/** The signal that ended the process, when one did (its `code` then reads 1, as it always has): kept apart so a
|
|
32
|
+
* killed command is never mistaken for one that exited 1 on its own (release check 3261). Only the buffered
|
|
33
|
+
* process-group transport says. */
|
|
34
|
+
signal?: NodeJS.Signals | null;
|
|
35
|
+
/** This process (the plane) ended it with its hard stop (terminateLiveProviders: a worker shutting down), not
|
|
36
|
+
* anything the command or another process did. */
|
|
37
|
+
stoppedByPlane?: boolean;
|
|
31
38
|
};
|
|
32
39
|
export type ContainmentOutcome = {
|
|
33
40
|
refused: string;
|
package/dist/exec.js
CHANGED
|
@@ -21,6 +21,8 @@ import { primaryResultEvent, topLevelEvent } from "./stream-scope.js";
|
|
|
21
21
|
export const CONTAINMENT_REFUSED_CODE = 126;
|
|
22
22
|
/** Live provider children, for the deterministic stop. Registered only when `processGroup` was set. */
|
|
23
23
|
const liveProviders = new Set();
|
|
24
|
+
/** Children this process's own hard stop ended: their results say so (`stoppedByPlane`). */
|
|
25
|
+
const hardStopped = new WeakSet();
|
|
24
26
|
/** Live children by owner tag (v52): the exact-attempt stop's handle. A
|
|
25
27
|
* child is registered here beside liveProviders when its options named an
|
|
26
28
|
* owner, and dropped the moment it closes. */
|
|
@@ -370,6 +372,7 @@ function killGroup(child) {
|
|
|
370
372
|
export function terminateLiveProviders() {
|
|
371
373
|
let terminated = 0;
|
|
372
374
|
for (const child of liveProviders) {
|
|
375
|
+
hardStopped.add(child);
|
|
373
376
|
killGroup(child);
|
|
374
377
|
terminated += 1;
|
|
375
378
|
}
|
|
@@ -677,6 +680,7 @@ function runBufferedGroup(file, args, bag) {
|
|
|
677
680
|
});
|
|
678
681
|
let settled = false;
|
|
679
682
|
let transient = false;
|
|
683
|
+
let endSignal = null;
|
|
680
684
|
const finish = (code) => {
|
|
681
685
|
if (settled)
|
|
682
686
|
return;
|
|
@@ -697,6 +701,8 @@ function runBufferedGroup(file, args, bag) {
|
|
|
697
701
|
timedOut: timedOut && !overflowed,
|
|
698
702
|
notFound,
|
|
699
703
|
...containmentOf(container, empty, attachFailure),
|
|
704
|
+
...(endSignal === null ? {} : { signal: endSignal }),
|
|
705
|
+
...(hardStopped.has(child) ? { stoppedByPlane: true } : {}),
|
|
700
706
|
},
|
|
701
707
|
transient,
|
|
702
708
|
});
|
|
@@ -709,7 +715,7 @@ function runBufferedGroup(file, args, bag) {
|
|
|
709
715
|
stderr = String(error);
|
|
710
716
|
finish(null);
|
|
711
717
|
});
|
|
712
|
-
child.on("close", code => finish(code));
|
|
718
|
+
child.on("close", (code, signal) => { endSignal = code === null ? signal ?? null : null; finish(code); });
|
|
713
719
|
if (bag.stdin !== undefined && child.stdin !== null) {
|
|
714
720
|
child.stdin.on("error", () => {
|
|
715
721
|
inputFailed = true;
|
package/dist/first-run.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* the composer: nothing is filed until the person sends it and confirms.
|
|
7
7
|
*/
|
|
8
8
|
import { run } from "./exec.js";
|
|
9
|
+
import { hardenedGit } from "./teardown-git.js";
|
|
9
10
|
import { githubIssues } from "./issues.js";
|
|
10
11
|
export const SIGN_IN_COMMAND = "claude auth login";
|
|
11
12
|
export const SANDBOX_COMMAND = "npx toolroll demo";
|
|
@@ -95,7 +96,8 @@ export async function findFirstTasks(repo, runner = run) {
|
|
|
95
96
|
let todos = [];
|
|
96
97
|
if (issues.length < 3) {
|
|
97
98
|
try {
|
|
98
|
-
|
|
99
|
+
// The project's repository is shared with builds that can write its config: the hardened read (teardown-git.ts).
|
|
100
|
+
const result = await (runner === run ? hardenedGit(run, "read") : runner)("git", [...TODO_GREP_ARGS], { cwd: repo, timeoutMs: 5_000 });
|
|
99
101
|
if (result.code === 0 || result.stdout !== "")
|
|
100
102
|
todos = parseTodoLines(result.stdout);
|
|
101
103
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const GIT_FLOOR: {
|
|
2
|
+
readonly major: 2;
|
|
3
|
+
readonly minor: 41;
|
|
4
|
+
};
|
|
5
|
+
export declare const GIT_FLOOR_WORDS: string;
|
|
6
|
+
/** The version `git --version` names ("git version 2.50.1 (Apple Git-155)" → 2.50.1), or null when it names none. */
|
|
7
|
+
export declare function parseGitVersion(text: string): {
|
|
8
|
+
major: number;
|
|
9
|
+
minor: number;
|
|
10
|
+
words: string;
|
|
11
|
+
} | null;
|
|
12
|
+
/** Why `version` (`git --version`'s answer) is below the floor, in one sentence, or null when it isn't (or can't be read). */
|
|
13
|
+
export declare function gitFloorWords(versionText: string): string | null;
|
|
14
|
+
/** The installed git's floor problem, asked once per process with no system or global config; null when git is new
|
|
15
|
+
* enough, or when no version could be read (a missing git is reported where it is used). */
|
|
16
|
+
export declare function installedGitFloorProblem(): string | null;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The oldest git Toolroll runs on (README, Requirements): the global `git --attr-source` option every hardened read
|
|
3
|
+
* takes arrived in git 2.41 (others in use are older: `worktree list -z` 2.36, GIT_CONFIG_COUNT and `--path-format`
|
|
4
|
+
* 2.31). On git older than 2.45, which lacks `--no-lazy-fetch`, partial clones are refused for protected reads
|
|
5
|
+
* (teardown-git.ts lazyFetchSupported), every protected read runs through a frozen view and refuses every transport,
|
|
6
|
+
* and nothing leans on GIT_NO_LAZY_FETCH, which 2.41.0 ignores. The CI git lanes test this floor's oldest release
|
|
7
|
+
* exactly, 2.41.0, and 2.47 (.github/workflows/ci.yml, scripts/ci-git.sh).
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
export const GIT_FLOOR = { major: 2, minor: 41 };
|
|
11
|
+
export const GIT_FLOOR_WORDS = `${GIT_FLOOR.major}.${GIT_FLOOR.minor}`;
|
|
12
|
+
/** The version `git --version` names ("git version 2.50.1 (Apple Git-155)" → 2.50.1), or null when it names none. */
|
|
13
|
+
export function parseGitVersion(text) {
|
|
14
|
+
const found = /^git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(text.trim());
|
|
15
|
+
return found === null ? null : { major: Number(found[1]), minor: Number(found[2]), words: `${found[1]}.${found[2]}${found[3] === undefined ? "" : `.${found[3]}`}` };
|
|
16
|
+
}
|
|
17
|
+
/** Why `version` (`git --version`'s answer) is below the floor, in one sentence, or null when it isn't (or can't be read). */
|
|
18
|
+
export function gitFloorWords(versionText) {
|
|
19
|
+
const version = parseGitVersion(versionText);
|
|
20
|
+
if (version === null)
|
|
21
|
+
return null;
|
|
22
|
+
if (version.major > GIT_FLOOR.major || (version.major === GIT_FLOOR.major && version.minor >= GIT_FLOOR.minor))
|
|
23
|
+
return null;
|
|
24
|
+
return `Toolroll needs git ${GIT_FLOOR_WORDS} or newer, and this machine's git is ${version.words}. Install a newer git (and put it first on PATH), then start Toolroll again.`;
|
|
25
|
+
}
|
|
26
|
+
let probed;
|
|
27
|
+
/** The installed git's floor problem, asked once per process with no system or global config; null when git is new
|
|
28
|
+
* enough, or when no version could be read (a missing git is reported where it is used). */
|
|
29
|
+
export function installedGitFloorProblem() {
|
|
30
|
+
if (probed === undefined) {
|
|
31
|
+
const answer = spawnSync("git", ["--version"], { cwd: "/", encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"],
|
|
32
|
+
env: { PATH: process.env["PATH"] ?? "", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", HOME: "/nonexistent" } });
|
|
33
|
+
probed = answer.status === 0 ? gitFloorWords(answer.stdout ?? "") : null;
|
|
34
|
+
}
|
|
35
|
+
return probed;
|
|
36
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type GitIncident = {
|
|
2
|
+
at: string;
|
|
3
|
+
checkout: string;
|
|
4
|
+
repository: string;
|
|
5
|
+
/** Folders of the repository found swapped, and where each now leads (null: its pointers changed instead). */
|
|
6
|
+
swapped: {
|
|
7
|
+
path: string;
|
|
8
|
+
leadsTo: string | null;
|
|
9
|
+
}[];
|
|
10
|
+
};
|
|
11
|
+
type Listener = (incident: GitIncident) => void;
|
|
12
|
+
/** Hear about every git incident (returns the unsubscribe). With none registered, incidents go to stderr. */
|
|
13
|
+
export declare function onGitIncident(listener: Listener): () => void;
|
|
14
|
+
/** Record and tell one incident. */
|
|
15
|
+
export declare function gitIncident(input: {
|
|
16
|
+
checkout: string;
|
|
17
|
+
repository: string;
|
|
18
|
+
swapped: GitIncident["swapped"];
|
|
19
|
+
record: string;
|
|
20
|
+
}): GitIncident;
|
|
21
|
+
/** What noticeGitIncidents needs of the store: its durable outbox, and its own project records. */
|
|
22
|
+
export type IncidentOutbox = {
|
|
23
|
+
enqueueNotification(notification: {
|
|
24
|
+
dedupeKey: string;
|
|
25
|
+
kind: string;
|
|
26
|
+
subject: string;
|
|
27
|
+
body: string;
|
|
28
|
+
pushClass?: "attention";
|
|
29
|
+
source?: {
|
|
30
|
+
project: string;
|
|
31
|
+
} | {
|
|
32
|
+
installation: true;
|
|
33
|
+
};
|
|
34
|
+
}, now: Date): boolean;
|
|
35
|
+
listProjects(): {
|
|
36
|
+
path: string;
|
|
37
|
+
name: string;
|
|
38
|
+
}[];
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Every git incident from now on raises an attention notification in `outbox` (the console and the background worker
|
|
42
|
+
* register their store as they start; once per store): "Git incident in <project>: a folder of its repository changed
|
|
43
|
+
* while Toolroll's git ran". Nothing the agent chose reaches the text (paths in an agent-written repository can carry
|
|
44
|
+
* newlines and words of its own): the project is named only by the plane's own record of it (matched by real path,
|
|
45
|
+
* else "a project"), and the person is pointed to the incident record file, where the paths are. Returns the
|
|
46
|
+
* unsubscribe.
|
|
47
|
+
*/
|
|
48
|
+
export declare function noticeGitIncidents(outbox: IncidentOutbox, now?: () => Date, record?: string): () => void;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A git incident: the plane's own git wrote through a view of a repository (teardown-git.ts gitView) while a folder of
|
|
3
|
+
* that repository the view links to was swapped for a link elsewhere, or its git pointers changed (the residual the
|
|
4
|
+
* view can't prevent: git takes paths, and a build that outlived its turn can still rename a folder of the shared
|
|
5
|
+
* `.git` mid-command). Seen after git ran, it is:
|
|
6
|
+
*
|
|
7
|
+
* - recorded, one JSON line in `~/.toolroll/git-incidents.jsonl` (beside the fenced views folder, git-views.ts);
|
|
8
|
+
* - told to every listener (the console's, when it registers one; otherwise the plane's stderr);
|
|
9
|
+
* - and the command is reported refused, so no caller takes its result as done.
|
|
10
|
+
*
|
|
11
|
+
* Nothing is rolled back automatically: where a swapped folder leads is the agent's choice, and the plane never runs
|
|
12
|
+
* git there on its say (a confused deputy); what git wrote is for a person, named in the record.
|
|
13
|
+
*/
|
|
14
|
+
import { appendFileSync, realpathSync } from "node:fs";
|
|
15
|
+
import { basename, dirname } from "node:path";
|
|
16
|
+
const listeners = new Set();
|
|
17
|
+
/** Hear about every git incident (returns the unsubscribe). With none registered, incidents go to stderr. */
|
|
18
|
+
export function onGitIncident(listener) {
|
|
19
|
+
listeners.add(listener);
|
|
20
|
+
return () => listeners.delete(listener);
|
|
21
|
+
}
|
|
22
|
+
/** Record and tell one incident. */
|
|
23
|
+
export function gitIncident(input) {
|
|
24
|
+
const incident = { at: new Date().toISOString(), checkout: input.checkout, repository: input.repository, swapped: input.swapped };
|
|
25
|
+
try {
|
|
26
|
+
appendFileSync(input.record, `${JSON.stringify(incident)}\n`, { mode: 0o600 });
|
|
27
|
+
}
|
|
28
|
+
catch { /* still told */ }
|
|
29
|
+
if (listeners.size === 0)
|
|
30
|
+
process.stderr.write(`Toolroll git incident: ${JSON.stringify(incident)}\n`);
|
|
31
|
+
for (const listener of listeners) {
|
|
32
|
+
try {
|
|
33
|
+
listener(incident);
|
|
34
|
+
}
|
|
35
|
+
catch { /* every listener hears it */ }
|
|
36
|
+
}
|
|
37
|
+
return incident;
|
|
38
|
+
}
|
|
39
|
+
const noticed = new WeakSet();
|
|
40
|
+
/**
|
|
41
|
+
* Every git incident from now on raises an attention notification in `outbox` (the console and the background worker
|
|
42
|
+
* register their store as they start; once per store): "Git incident in <project>: a folder of its repository changed
|
|
43
|
+
* while Toolroll's git ran". Nothing the agent chose reaches the text (paths in an agent-written repository can carry
|
|
44
|
+
* newlines and words of its own): the project is named only by the plane's own record of it (matched by real path,
|
|
45
|
+
* else "a project"), and the person is pointed to the incident record file, where the paths are. Returns the
|
|
46
|
+
* unsubscribe.
|
|
47
|
+
*/
|
|
48
|
+
export function noticeGitIncidents(outbox, now = () => new Date(), record = "~/.toolroll/git-incidents.jsonl") {
|
|
49
|
+
if (noticed.has(outbox))
|
|
50
|
+
return () => { };
|
|
51
|
+
noticed.add(outbox);
|
|
52
|
+
const stop = onGitIncident(incident => {
|
|
53
|
+
const main = basename(incident.repository) === ".git" ? dirname(incident.repository) : incident.repository;
|
|
54
|
+
const real = (path) => { try {
|
|
55
|
+
return realpathSync(path);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
} };
|
|
60
|
+
const known = outbox.listProjects().find(project => real(project.path) !== null && real(project.path) === real(main)) ?? null;
|
|
61
|
+
outbox.enqueueNotification({
|
|
62
|
+
dedupeKey: `git-incident:${incident.at}:${known?.path ?? "unrecorded"}`, kind: "git-incident", pushClass: "attention",
|
|
63
|
+
source: known === null ? { installation: true } : { project: known.path },
|
|
64
|
+
subject: `Git incident in ${known === null ? "a project" : known.name}: a folder of its repository changed while Toolroll's git ran`,
|
|
65
|
+
body: `Toolroll stopped trusting that git command and changed nothing more. Nothing was rolled back: check the repository by hand. The checkout and the folders that changed are in ${record}.`,
|
|
66
|
+
}, now());
|
|
67
|
+
});
|
|
68
|
+
return () => { stop(); noticed.delete(outbox); };
|
|
69
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The views folder under `home`. */
|
|
2
|
+
export declare function gitViewsRoot(home: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* The views folder under `home`, made if it isn't there (owner-only), and proven to be this user's own real folder all
|
|
5
|
+
* the way from `home`: neither it nor `~/.toolroll` may be a link or anyone else's. Throws otherwise.
|
|
6
|
+
*/
|
|
7
|
+
export declare function ensureGitViewsRoot(home: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* One plane git command at a time on a checkout (Codex review of a0fa135d): each command's private GIT_DIR has its own
|
|
10
|
+
* HEAD, so two at once would each take their own HEAD lock and the later carry-back would undo the earlier. The lock
|
|
11
|
+
* is a file the plane makes exclusively in the fenced views folder, named by the checkout's real path, holding this
|
|
12
|
+
* process's id; one whose process is gone is taken over. Waits up to `timeoutMs`; returns the release.
|
|
13
|
+
*/
|
|
14
|
+
export declare function lockCheckout(home: string, checkout: string, timeoutMs?: number): Promise<() => void>;
|
|
15
|
+
/**
|
|
16
|
+
* Run `work` holding the checkout's lock (lockCheckout). Re-entrant along one call chain: a hardened git nested in
|
|
17
|
+
* another on the same checkout (teardown's git run through the builder's hardened runner, a view within a view) runs
|
|
18
|
+
* under the lock its caller already holds instead of waiting on itself.
|
|
19
|
+
*/
|
|
20
|
+
export declare function withCheckoutLock<T>(home: string, checkout: string, work: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the plane makes its private views of a repository for git (teardown-git.ts gitView): a folder of its own under
|
|
3
|
+
* the home folder's `~/.toolroll`, which every fence denies to builds, read and write (agent-fence.ts agentFence names
|
|
4
|
+
* this folder by name, made first so no build can be the one to create it). A view in the system temp folder could be
|
|
5
|
+
* rewritten by a build that writes anywhere and outlived its turn (Codex review of e48e9d8d).
|
|
6
|
+
*/
|
|
7
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { closeSync, constants as fsConstants, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
/** The views folder under `home`. */
|
|
12
|
+
export function gitViewsRoot(home) {
|
|
13
|
+
return join(home, ".toolroll", "git-views");
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The views folder under `home`, made if it isn't there (owner-only), and proven to be this user's own real folder all
|
|
17
|
+
* the way from `home`: neither it nor `~/.toolroll` may be a link or anyone else's. Throws otherwise.
|
|
18
|
+
*/
|
|
19
|
+
export function ensureGitViewsRoot(home) {
|
|
20
|
+
const uid = process.getuid?.();
|
|
21
|
+
const base = join(home, ".toolroll");
|
|
22
|
+
const root = gitViewsRoot(home);
|
|
23
|
+
for (const dir of [base, root]) {
|
|
24
|
+
try {
|
|
25
|
+
mkdirSync(dir, { mode: 0o700 });
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error.code !== "EEXIST")
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
const held = lstatSync(dir);
|
|
32
|
+
if (!held.isDirectory() || held.isSymbolicLink() || (uid !== undefined && held.uid !== uid))
|
|
33
|
+
throw new Error(`${dir} isn't a folder of this user's own`);
|
|
34
|
+
}
|
|
35
|
+
return root;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* One plane git command at a time on a checkout (Codex review of a0fa135d): each command's private GIT_DIR has its own
|
|
39
|
+
* HEAD, so two at once would each take their own HEAD lock and the later carry-back would undo the earlier. The lock
|
|
40
|
+
* is a file the plane makes exclusively in the fenced views folder, named by the checkout's real path, holding this
|
|
41
|
+
* process's id; one whose process is gone is taken over. Waits up to `timeoutMs`; returns the release.
|
|
42
|
+
*/
|
|
43
|
+
export async function lockCheckout(home, checkout, timeoutMs = 10 * 60_000) {
|
|
44
|
+
const folder = join(ensureGitViewsRoot(home), "locks");
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(folder, { mode: 0o700 });
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code !== "EEXIST")
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
const held = lstatSync(folder);
|
|
53
|
+
if (!held.isDirectory() || held.isSymbolicLink())
|
|
54
|
+
throw new Error(`${folder} isn't a folder of this user's own`);
|
|
55
|
+
const path = join(folder, `${createHash("sha256").update(checkout).digest("hex")}.lock`);
|
|
56
|
+
const deadline = Date.now() + timeoutMs;
|
|
57
|
+
for (;;) {
|
|
58
|
+
try {
|
|
59
|
+
const fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
|
|
60
|
+
try {
|
|
61
|
+
writeSync(fd, `${process.pid}\n`);
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
closeSync(fd);
|
|
65
|
+
}
|
|
66
|
+
let released = false;
|
|
67
|
+
return () => { if (!released) {
|
|
68
|
+
released = true;
|
|
69
|
+
rmSync(path, { force: true });
|
|
70
|
+
} };
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error.code !== "EEXIST")
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
// Held: by a live process, wait; by one that is gone, take it over.
|
|
77
|
+
let owner = NaN;
|
|
78
|
+
try {
|
|
79
|
+
owner = Number(readFileSync(path, "utf8").trim());
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
owner = NaN;
|
|
83
|
+
}
|
|
84
|
+
if (Number.isSafeInteger(owner) && owner > 0 && owner !== process.pid && !alive(owner)) {
|
|
85
|
+
rmSync(path, { force: true });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (Date.now() > deadline)
|
|
89
|
+
throw new Error("another of Toolroll's git commands on this checkout is still running");
|
|
90
|
+
await new Promise(done => setTimeout(done, 25));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function alive(pid) {
|
|
94
|
+
try {
|
|
95
|
+
process.kill(pid, 0);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
return error.code === "EPERM";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** The checkouts the current async call chain already holds (lockCheckout through withCheckoutLock). */
|
|
103
|
+
const heldHere = new AsyncLocalStorage();
|
|
104
|
+
/**
|
|
105
|
+
* Run `work` holding the checkout's lock (lockCheckout). Re-entrant along one call chain: a hardened git nested in
|
|
106
|
+
* another on the same checkout (teardown's git run through the builder's hardened runner, a view within a view) runs
|
|
107
|
+
* under the lock its caller already holds instead of waiting on itself.
|
|
108
|
+
*/
|
|
109
|
+
export async function withCheckoutLock(home, checkout, work) {
|
|
110
|
+
const held = heldHere.getStore() ?? new Set();
|
|
111
|
+
if (held.has(checkout))
|
|
112
|
+
return work();
|
|
113
|
+
const release = await lockCheckout(home, checkout);
|
|
114
|
+
try {
|
|
115
|
+
return await heldHere.run(new Set([...held, checkout]), work);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
release();
|
|
119
|
+
}
|
|
120
|
+
}
|
package/dist/grant.js
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* the same reason an unknown lease is: silence is not consent.
|
|
31
31
|
*/
|
|
32
32
|
import { run } from "./exec.js";
|
|
33
|
+
import { lazyFetchSafeGit, noLazyFetch } from "./teardown-git.js";
|
|
33
34
|
export const MUTATION_CLASSES = [
|
|
34
35
|
"create",
|
|
35
36
|
"transition",
|
|
@@ -175,10 +176,15 @@ async function observedByGit(repo, paths, runner) {
|
|
|
175
176
|
const local = paths.filter(path => !isRemoteRef(path));
|
|
176
177
|
if (local.length === 0)
|
|
177
178
|
return false;
|
|
179
|
+
// On a git without --no-lazy-fetch, through a frozen read view (teardown-git.ts lazyFetchSafeGit): a partial clone is
|
|
180
|
+
// refused there, and a refusal reads as "not ignored", so observed.
|
|
181
|
+
const git = lazyFetchSafeGit(runner);
|
|
178
182
|
for (const path of local) {
|
|
179
183
|
const asked = [path, `${normalise(path)}/`];
|
|
180
184
|
const ignored = await Promise.all(asked.map(async (candidate) => {
|
|
181
|
-
|
|
185
|
+
// check-ignore reads the index, so a planted fsmonitor would run: off (and hooks, lazy fetch) on the command line,
|
|
186
|
+
// which wins over any config the repository's builds can write.
|
|
187
|
+
const result = await git("git", ["--no-optional-locks", ...noLazyFetch(), "-c", "protocol.allow=never", "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null", "check-ignore", "-q", candidate], {
|
|
182
188
|
cwd: repo,
|
|
183
189
|
});
|
|
184
190
|
return result.code === 0;
|
package/dist/guides.js
CHANGED
|
@@ -510,9 +510,10 @@ instead of making them twice.
|
|
|
510
510
|
rerunning the failed job on GitHub, run the same command again.
|
|
511
511
|
- \`publish-failed\` / \`homebrew-failed\`: fix the failed workflow before
|
|
512
512
|
resuming. A failed tag check cannot finish publication or update Homebrew.
|
|
513
|
-
- \`base-moved\`:
|
|
514
|
-
merge wouldn't be the
|
|
515
|
-
|
|
513
|
+
- \`base-moved\`: the branch is behind main (it doesn't contain main's
|
|
514
|
+
head), so the merge wouldn't be the tree CI tested. Checked at the gate
|
|
515
|
+
(nothing filed) and again right before merging (nothing merged): update
|
|
516
|
+
the branch, push, and release it with \`--new\`.
|
|
516
517
|
- \`tree-mismatch\`: main changed what was checked. Nothing was tagged;
|
|
517
518
|
the deployed build stays.
|
|
518
519
|
- \`timeout\`: rerun to keep waiting; \`--limit <step>=<minutes>\`
|
package/dist/operate.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { StepIo } from "./flow-steps.js";
|
|
|
6
6
|
import type { FetchLike } from "./flow-share.js";
|
|
7
7
|
import { type Store } from "./store.js";
|
|
8
8
|
import { type DispatchAdapter } from "./sync.js";
|
|
9
|
+
import { type RolePort, type UpRoleStart } from "./up-processes.js";
|
|
9
10
|
import { type TelegramTransport } from "./telegram.js";
|
|
10
11
|
import { type LeadCliSeams } from "./lead-cli.js";
|
|
11
12
|
import { type Principal, type RemoteSource } from "./operate-remote.js";
|
|
@@ -89,6 +90,8 @@ export type OperateOptions = {
|
|
|
89
90
|
};
|
|
90
91
|
/** Injected by tests: the classifier that sizes filed tasks (none by default under test). */
|
|
91
92
|
filingSizer?: Sizer;
|
|
93
|
+
/** Set only by up-role-child.ts: this process is one role of a `toolroll up` (the console or the worker). */
|
|
94
|
+
upRole?: UpRoleHost;
|
|
92
95
|
};
|
|
93
96
|
export type OnboardSeams = Partial<Pick<OnboardIo, "home" | "env" | "cwd" | "interactive" | "confirm" | "findRepo" | "checkConnection" | "pullRequests">>;
|
|
94
97
|
export declare const OPERATE_HELP: string;
|
|
@@ -117,6 +120,8 @@ export declare const KEYS_ACTIONS: readonly ["status", "set", "clear", "verify",
|
|
|
117
120
|
* tests): every value-taking flag any verb reads, and every boolean.
|
|
118
121
|
* A --flag in neither set is a typo, refused by name.
|
|
119
122
|
*/
|
|
123
|
+
/** Flags whose value is a secret someone types or pastes: taken as the next argument even when it starts with "--". */
|
|
124
|
+
export declare const SECRET_VALUE_FLAGS: ReadonlySet<string>;
|
|
120
125
|
export declare const OPERATE_VALUE_FLAGS: ReadonlySet<string>;
|
|
121
126
|
export declare const OPERATE_BOOLEAN_FLAGS: ReadonlySet<string>;
|
|
122
127
|
export declare function parseOperateArgs(argv: readonly string[], ownValues?: ReadonlySet<string>): Args | {
|
|
@@ -168,3 +173,11 @@ export declare function readUpConsoleUrl(dir: string): string | null;
|
|
|
168
173
|
export declare const PROPOSALS_ACTIONS: readonly ["list", "confirm", "dismiss"];
|
|
169
174
|
/** The value flags a verb reads under a global switch's name, for parseOperateArgs. */
|
|
170
175
|
export declare function ownValueFlags(command: string, argv: readonly string[]): ReadonlySet<string> | undefined;
|
|
176
|
+
/** What up-role-child.ts hands runOperate: the role's start, and its channel to the `up` that started it. */
|
|
177
|
+
export type UpRoleHost = {
|
|
178
|
+
start: UpRoleStart;
|
|
179
|
+
port: RolePort;
|
|
180
|
+
};
|
|
181
|
+
/** A role child's whole run (up-role-child.ts): the ordinary command prelude (containment, secrets, limits), then the
|
|
182
|
+
* role itself. Only a process started by `up` gets here; no command line selects a role. */
|
|
183
|
+
export declare function runUpRole(start: UpRoleStart, port: RolePort): Promise<number>;
|