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/check-report.js
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* After the check, the report is the last `TOOLROLL_CHECK_REPORT <json>` line of its output that carries that nonce
|
|
10
10
|
* (a line a test prints, or one a leftover process writes, has no nonce and is ignored). It is read strictly and sealed
|
|
11
11
|
* as `check-report.json`, beside `check-inventory.json` (what was sealed, and the groups and versions the check
|
|
12
|
-
* declared). A
|
|
12
|
+
* declared). A Full release check qualifies only with all of it (fullCheckProblem): a valid report carrying this run's
|
|
13
|
+
* nonce, a complete inventory, a terminal result for every part, file, journey group and upgrade version it owed, and a
|
|
14
|
+
* successful exit. Anything less — no report, a malformed or cut-short one, one whose exit disagrees, a part that never
|
|
15
|
+
* finished, a file with no result — is an error, "no result", and never a pass. A failed check's report only has to be
|
|
16
|
+
* valid: what it failed on is its result.
|
|
13
17
|
*
|
|
14
18
|
* Coverage trusts test code (the trust decision in the plan). Nothing here decides who may release or deploy.
|
|
15
19
|
*/
|
|
@@ -19,6 +23,7 @@ import { join } from "node:path";
|
|
|
19
23
|
import { readVerifiedCommit, verifiedTreeEntries } from "./commit-extract.js";
|
|
20
24
|
import { CHECK_PARTS, CHECK_REPORT_MAX_BYTES, CHECK_REPORT_PREFIX, readCheckReport, } from "./contracts/release-check.js";
|
|
21
25
|
import { readVerifiedArtifact, storeEvidence } from "./evidence.js";
|
|
26
|
+
import { releaseBaselineRecord } from "./release-card.js";
|
|
22
27
|
import { checkAttemptOf, recordCheckAttempt } from "./store.js";
|
|
23
28
|
/** The sealed artifacts' capture labels: what a reader looks for, by exact name. */
|
|
24
29
|
export const CHECK_REPORT_CAPTURE = "release check report v1";
|
|
@@ -91,17 +96,82 @@ export function treeFactsOf(gitDir, sha) {
|
|
|
91
96
|
export function checkContextOf(facts, command, access) {
|
|
92
97
|
return { version: 1, node: process.version, platform: process.platform, arch: process.arch, lockfile: facts.lockfile, harness: facts.harnessDigest, grant: { id: command.id, digest: command.digest }, access };
|
|
93
98
|
}
|
|
94
|
-
/**
|
|
99
|
+
/** Every part a check can have. */
|
|
95
100
|
export const FULL_PARTS = CHECK_PARTS;
|
|
101
|
+
/** Changes no test reads: the plane's copy of scripts/release-check.mjs NOTHING (check-report.test.ts holds them equal). */
|
|
102
|
+
export const NOTHING_PATHS = Object.freeze([/\.md$/, /^docs\//, /^evidence\//, /^design\//, /^output\//, /^LICENSE$/, /\.png$/]);
|
|
103
|
+
/** Model-facing code, whose change runs the real-model journeys: the plane's copy of scripts/release-check.mjs MODEL. */
|
|
104
|
+
export const MODEL_PATHS = Object.freeze([
|
|
105
|
+
/^src\/(lead|converse|subscription-chat|chat-[a-z-]+|lead-[a-z-]+|memory-pass)\.ts$/,
|
|
106
|
+
/^src\/(planner|planner-[a-z-]+|plan|builder|build-review|reviewer|scout|scout-[a-z-]+|decision|evidence)\.ts$/,
|
|
107
|
+
/^src\/(subagents|subagent-work|task-sizing|flow-sort|flow-draft)\.ts$/,
|
|
108
|
+
/^src\/(provider|provider-[a-z-]+|invoke|exec|attest|coding-provider|assignment-adapters|subscription-[a-z-]+)\.ts$/,
|
|
109
|
+
]);
|
|
110
|
+
/** Whether a changed path can affect an upgrade: the plane's copy of scripts/release-check.mjs needsUpgrade, without its
|
|
111
|
+
* version-only exception (the plane counts a version bump as a change, so it owes more, never less). */
|
|
112
|
+
export function upgradeRelevant(file) {
|
|
113
|
+
if (/^(docs|evidence|design|output)\//.test(file) || /\.md$/.test(file) || file === "LICENSE")
|
|
114
|
+
return false;
|
|
115
|
+
if (/\.test\.[cm]?[jt]sx?$/.test(file) || /^test\//.test(file))
|
|
116
|
+
return false;
|
|
117
|
+
if (/^src\/(browser|design)\//.test(file) || /\.css$/.test(file))
|
|
118
|
+
return false;
|
|
119
|
+
if (/^scripts\/(app-e2e|flows-e2e|e2e-parallel)\.mjs$/.test(file))
|
|
120
|
+
return false;
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
/** The paths that differ between two verified trees: added, removed, or with another mode or blob. */
|
|
124
|
+
export function changedPaths(facts, base) {
|
|
125
|
+
const before = new Map(base.entries.map(one => [one.path, `${one.mode} ${one.id}`]));
|
|
126
|
+
const after = new Map(facts.entries.map(one => [one.path, `${one.mode} ${one.id}`]));
|
|
127
|
+
return [...new Set([...before.keys(), ...after.keys()])].filter(path => before.get(path) !== after.get(path)).sort();
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The parts a Full check owes, decided by the plane before it runs (eval-harness review 1, finding 2), never by the
|
|
131
|
+
* check's own report: the typecheck and the build always; the unit suite when anything a test reads changed since
|
|
132
|
+
* `base` (the plane's own record of the last release, both trees verified here) and the tree has unit files; the
|
|
133
|
+
* scripted journeys likewise when the tree has the journey scripts; the real-model journeys when model-facing code
|
|
134
|
+
* changed; the upgrade path when a change can affect an upgrade and the tree has it. With no base every one the tree
|
|
135
|
+
* can run is owed. The check is told these on its handshake and runs at least them.
|
|
136
|
+
*/
|
|
137
|
+
export function fullObligationsOf(facts, base) {
|
|
138
|
+
// What the tree can run is read from the candidate AND the last release (security scan): a candidate that deletes a
|
|
139
|
+
// journey script, the upgrade path or its unit files doesn't take away what was owed at the base — the part stays
|
|
140
|
+
// owed, and a check that can't run it has no result. Any entry counts, a symlink included, never only a regular file.
|
|
141
|
+
const trees = [facts, ...(base === null ? [] : [base])];
|
|
142
|
+
const has = (path) => trees.some(tree => tree.entries.some(one => one.path === path));
|
|
143
|
+
const units = trees.some(tree => tree.units.length > 0 || tree.entries.some(one => isUnitFile(one.path)));
|
|
144
|
+
const journeys = has("scripts/flows-e2e.mjs") || has("scripts/app-e2e.mjs");
|
|
145
|
+
const changed = base === null ? null : changedPaths(facts, base);
|
|
146
|
+
const code = changed?.filter(path => !NOTHING_PATHS.some(re => re.test(path))) ?? null;
|
|
147
|
+
const checks = code === null || code.length > 0;
|
|
148
|
+
const real = code === null || code.some(path => !/\.test\.[cm]?[jt]s$/.test(path) && MODEL_PATHS.some(re => re.test(path)));
|
|
149
|
+
const upgrade = has("scripts/upgrade-path.mjs") && (changed === null || changed.some(upgradeRelevant));
|
|
150
|
+
return [
|
|
151
|
+
"typecheck", "build",
|
|
152
|
+
...(checks && units ? ["unit"] : []),
|
|
153
|
+
...(checks && journeys ? ["flows", "app"] : []),
|
|
154
|
+
...(real && journeys ? ["flows-real", "app-real"] : []),
|
|
155
|
+
...(upgrade ? ["upgrade"] : []),
|
|
156
|
+
];
|
|
157
|
+
}
|
|
96
158
|
/** What a check must account for, sealed before it runs: every unit file of the tree, whatever runs (a targeted
|
|
97
|
-
* check accounts for the rest through its base), and the parts it
|
|
98
|
-
|
|
99
|
-
|
|
159
|
+
* check accounts for the rest through its base), and the parts it owes (a full check's from fullObligationsOf), and
|
|
160
|
+
* whether CI may stand in for its unit suite. */
|
|
161
|
+
export function inventoryOf(facts, plan, full = { owed: FULL_PARTS, release: true }) {
|
|
162
|
+
if (plan !== null)
|
|
163
|
+
return { version: 1, units: facts.units, parts: ["typecheck", "build", ...plan.parts.filter(one => one !== "typecheck" && one !== "build")] };
|
|
164
|
+
return { version: 1, units: facts.units, parts: [...full.owed], ciSubstitute: !full.release, release: full.release };
|
|
165
|
+
}
|
|
166
|
+
/** A Full check's preparation refused (eval-harness review 2, finding 4): the plane recorded a release whose commit it
|
|
167
|
+
* can't verify now, so the check's obligations can't be decided. The check isn't run (builder.ts: `baseline-unverifiable`). */
|
|
168
|
+
export class CheckBaselineUnverifiable extends Error {
|
|
100
169
|
}
|
|
101
170
|
/**
|
|
102
171
|
* Seal a check's attempt row before its command is spawned: intended mode, nonce hash, inventory and context (and for a
|
|
103
172
|
* targeted check its plan and obligation snapshot, sealed as artifacts first). Null when the plane can't read the
|
|
104
|
-
* commit, which leaves the check running exactly as it always has, with no handshake.
|
|
173
|
+
* commit, which leaves the check running exactly as it always has, with no handshake. Throws CheckBaselineUnverifiable,
|
|
174
|
+
* having sealed nothing, when a recorded last release can't be verified.
|
|
105
175
|
*/
|
|
106
176
|
export function prepareCheckAttempt(store, root, input, now) {
|
|
107
177
|
if (checkAttemptOf(store, input.runId) !== null)
|
|
@@ -113,7 +183,22 @@ export function prepareCheckAttempt(store, root, input, now) {
|
|
|
113
183
|
if (!read.ok)
|
|
114
184
|
return null;
|
|
115
185
|
const plan = input.plan ?? null, snapshot = input.snapshot ?? null;
|
|
116
|
-
|
|
186
|
+
// A full check owes what the plane decides here, from the last release it recorded (never a tag the checkout holds).
|
|
187
|
+
// No release recorded owes everything the candidate can run; a recorded one that can't be verified refuses the
|
|
188
|
+
// preparation (eval-harness review 2, finding 4): read as "no release", it would drop what the last release could run.
|
|
189
|
+
const baseline = plan === null ? releaseBaselineRecord(store, input.repo) : { recorded: false };
|
|
190
|
+
let baseFacts = null;
|
|
191
|
+
if (baseline.recorded) {
|
|
192
|
+
const baseRead = baseline.sha === null ? null : treeFactsOf(gitDir, baseline.sha);
|
|
193
|
+
if (baseRead === null || !baseRead.ok) {
|
|
194
|
+
throw new CheckBaselineUnverifiable(`The last release Toolroll recorded (${baseline.sha === null ? "its merge commit isn't a full id" : baseline.sha.slice(0, 7)}) couldn't be verified${baseRead !== null && !baseRead.ok ? ` (${baseRead.problem})` : ""}, so what this check owes is unknown; it wasn't run.`);
|
|
195
|
+
}
|
|
196
|
+
baseFacts = baseRead.facts;
|
|
197
|
+
}
|
|
198
|
+
const owed = fullObligationsOf(read.facts, baseFacts);
|
|
199
|
+
const release = input.release !== false;
|
|
200
|
+
const ciSubstitute = !release;
|
|
201
|
+
const inventory = inventoryOf(read.facts, plan, { owed, release });
|
|
117
202
|
const context = checkContextOf(read.facts, input.command, input.access);
|
|
118
203
|
const nonce = randomBytes(32).toString("hex");
|
|
119
204
|
const mode = plan === null ? "full" : "targeted";
|
|
@@ -128,7 +213,7 @@ export function prepareCheckAttempt(store, root, input, now) {
|
|
|
128
213
|
inventoryDigest: payloadDigest(inventory), contextDigest: payloadDigest(context), nonceHash: sha256(nonce),
|
|
129
214
|
}, now);
|
|
130
215
|
// The command's own limit rides along, so the check ends itself (logs and report printed) before the plane stops it.
|
|
131
|
-
const stdin = JSON.stringify({ nonce, mode, plan, watch: [...(input.watch ?? [])], limitMs: input.command.timeoutMs });
|
|
216
|
+
const stdin = JSON.stringify({ nonce, mode, plan, watch: [...(input.watch ?? [])], limitMs: input.command.timeoutMs, ...(plan === null ? { owed, ciSubstitute } : {}) });
|
|
132
217
|
return { runId: input.runId, nonce, stdin, mode, inventory, context, plan, snapshot, watch: [...(input.watch ?? [])] };
|
|
133
218
|
}
|
|
134
219
|
/** Seal one JSON payload as a structured-output artifact under its capture label. */
|
|
@@ -203,3 +288,103 @@ export function readSealedJson(store, root, runId, capture) {
|
|
|
203
288
|
return null;
|
|
204
289
|
}
|
|
205
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Why a Full release check has no result it can count — or null when it qualifies as one. The plane's own facts are the
|
|
293
|
+
* exit it saw (`exitCode`, null when the check didn't exit on its own: timed out or stopped) and the inventory it sealed
|
|
294
|
+
* before the check ran; the report must agree with both. A failed check (a nonzero exit) needs only a valid report: what
|
|
295
|
+
* it failed on is its result. A passing exit needs every obligation the plane sealed (the inventory's parts, never the
|
|
296
|
+
* report's own "selected") to have an outcome, and every part the report ran too: every part once, each run to its
|
|
297
|
+
* end; the unit suite finished with a result for every sealed unit file (CI stands in only when the sealed plan allowed
|
|
298
|
+
* it); every journey run finished, its groups listed before they ran, each passed group with a journey result; and the
|
|
299
|
+
* upgrade path finished, its versions listed, every one run. An explicitly empty inventory (no groups of that kind) is
|
|
300
|
+
* an outcome; a missing one is not. Missing, malformed, truncated or skipped-required results never read as a pass.
|
|
301
|
+
*/
|
|
302
|
+
export function fullCheckProblem(input) {
|
|
303
|
+
const { report, inventory, exitCode } = input;
|
|
304
|
+
if (exitCode === null)
|
|
305
|
+
return "the check didn't end on its own (it timed out or was stopped), so it has no result";
|
|
306
|
+
if (report === null)
|
|
307
|
+
return "the check gave no valid report for this run (none, a malformed or cut-short one, or one without this run's nonce)";
|
|
308
|
+
if (report.mode !== "full")
|
|
309
|
+
return "the check reported a targeted run, not the full one it was asked for";
|
|
310
|
+
if (report.exitCode !== exitCode)
|
|
311
|
+
return `the check's report says exit ${report.exitCode}, but it exited ${exitCode}`;
|
|
312
|
+
if (inventory === null)
|
|
313
|
+
return "the check's sealed inventory can't be read";
|
|
314
|
+
// Sealed before it ran as a release's check, or it isn't one this can count (security scan: the decision is recorded
|
|
315
|
+
// with the attempt and a mismatch is no result).
|
|
316
|
+
if (inventory.sealed.release !== true)
|
|
317
|
+
return "the check wasn't sealed as a release's check before it ran";
|
|
318
|
+
if (exitCode !== 0)
|
|
319
|
+
return null;
|
|
320
|
+
const parts = new Map(report.parts.map(one => [one.part, one]));
|
|
321
|
+
if (parts.size !== report.parts.length || CHECK_PARTS.some(part => !parts.has(part)))
|
|
322
|
+
return "the report doesn't account for every part of the check exactly once";
|
|
323
|
+
// The obligations are the plane's, sealed before the run: the typecheck and build always, and every part it owed.
|
|
324
|
+
const owed = new Set(["typecheck", "build", ...inventory.sealed.parts]);
|
|
325
|
+
for (const part of CHECK_PARTS)
|
|
326
|
+
if (owed.has(part) && parts.get(part).selected !== true)
|
|
327
|
+
return `the report says the ${part} wasn't selected, but the check owed it`;
|
|
328
|
+
for (const part of report.parts) {
|
|
329
|
+
if (!part.selected)
|
|
330
|
+
continue;
|
|
331
|
+
if (!part.ran || part.code === null)
|
|
332
|
+
return `${part.part} has no result: it was selected and never ran`;
|
|
333
|
+
if (part.timedOut || part.stopped === true || part.signal != null)
|
|
334
|
+
return `${part.part} has no result: it was stopped before it finished`;
|
|
335
|
+
if (part.code !== 0)
|
|
336
|
+
return `${part.part} exited ${part.code}, yet the check exited 0`;
|
|
337
|
+
}
|
|
338
|
+
if (parts.get("unit").selected) {
|
|
339
|
+
const units = report.units;
|
|
340
|
+
if (units === null)
|
|
341
|
+
return "the unit suite reported nothing";
|
|
342
|
+
if (!units.finished)
|
|
343
|
+
return "the unit suite didn't reach its end";
|
|
344
|
+
if (units.runErrors > 0)
|
|
345
|
+
return `the unit suite had ${units.runErrors} errors no test owned`;
|
|
346
|
+
if (units.files.some(one => one.state === "failed"))
|
|
347
|
+
return "a unit file failed, yet the check exited 0";
|
|
348
|
+
// A release's check runs its own units, whatever was sealed (security scan): CI never stands in here.
|
|
349
|
+
if (units.reusedFromCI)
|
|
350
|
+
return "CI's run stood in for the unit suite, which a release's check never allows";
|
|
351
|
+
if (!units.reusedFromCI) {
|
|
352
|
+
const reported = new Set(units.files.map(one => one.file));
|
|
353
|
+
const missing = inventory.sealed.units.filter(file => !reported.has(file));
|
|
354
|
+
if (missing.length > 0)
|
|
355
|
+
return `${missing.length} unit file${missing.length === 1 ? " has" : "s have"} no result (${missing.slice(0, 3).join(", ")}${missing.length > 3 ? ", …" : ""})`;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
for (const label of ["flows", "app", "flows-real", "app-real"]) {
|
|
359
|
+
if (!parts.get(label).selected)
|
|
360
|
+
continue;
|
|
361
|
+
const script = label.replace(/-real$/, ""), kind = label.endsWith("-real") ? "real" : "scripted";
|
|
362
|
+
const runs = report.journeys.filter(one => one.script === script && one.kind === kind);
|
|
363
|
+
if (runs.length === 0)
|
|
364
|
+
return `the ${label} journeys reported nothing`;
|
|
365
|
+
if (runs.length > 1)
|
|
366
|
+
return `the ${label} journeys reported more than one run`;
|
|
367
|
+
const run = runs[0];
|
|
368
|
+
if (!run.finished)
|
|
369
|
+
return `the ${label} journeys didn't reach their end`;
|
|
370
|
+
if (run.inventoried !== true)
|
|
371
|
+
return `the ${label} journeys never listed their groups, so what they owed can't be told`;
|
|
372
|
+
const open = run.groups.filter(group => group.state !== "passed");
|
|
373
|
+
if (open.length > 0)
|
|
374
|
+
return `the ${label} journey group ${open[0].group} ${open[0].state === "not-run" ? "never ran" : "failed"}, yet the check exited 0`;
|
|
375
|
+
const unread = run.groups.find(group => group.unattributed || !group.journeys.some(one => one.state === "passed"));
|
|
376
|
+
if (unread !== undefined)
|
|
377
|
+
return `the ${label} journey group ${unread.group} reported no journey's result`;
|
|
378
|
+
}
|
|
379
|
+
if (parts.get("upgrade").selected) {
|
|
380
|
+
const upgrades = report.upgrades;
|
|
381
|
+
if (upgrades === null || !upgrades.finished)
|
|
382
|
+
return "the upgrade path didn't reach its end";
|
|
383
|
+
if (upgrades.inventoried !== true)
|
|
384
|
+
return "the upgrade path never listed its versions, so what it owed can't be told";
|
|
385
|
+
const open = upgrades.versions.filter(one => one.state !== "passed");
|
|
386
|
+
if (open.length > 0)
|
|
387
|
+
return `the upgrade from ${open[0].version} ${open[0].state === "not-run" ? "never ran" : "failed"}, yet the check exited 0`;
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
}
|
package/dist/checkout-cleanup.js
CHANGED
|
@@ -21,7 +21,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
|
21
21
|
import { basename, dirname, join } from "node:path";
|
|
22
22
|
import { taskBranches } from "./names.js";
|
|
23
23
|
import { COMPLETION_ACTION } from "./result-completion.js";
|
|
24
|
-
import { CLEANUP_CHOICES, bytesWords, diskBytes } from "./storage.js";
|
|
24
|
+
import { CLEANUP_CHOICES, bytesWords, diskBytes, diskBytesAsync } from "./storage.js";
|
|
25
25
|
import { worktreePath } from "./worktree.js";
|
|
26
26
|
const DAY_MS = 86_400_000;
|
|
27
27
|
/** A release candidate's checkout holds the build a deploy installs: the newest complete one waits at most a week for it. */
|
|
@@ -264,7 +264,7 @@ async function eachAtMost(items, limit, work) {
|
|
|
264
264
|
export async function checkoutPlan(store, pool, now, options) {
|
|
265
265
|
const cleanup = store.checkoutCleanup();
|
|
266
266
|
const rows = store.listWorktrees().filter(row => existsSync(row.path) && (options.repo === undefined || row.repo === options.repo));
|
|
267
|
-
const sizes =
|
|
267
|
+
const sizes = await diskBytesAsync(rows.map(row => row.path));
|
|
268
268
|
const statuses = taskStatuses(store, now);
|
|
269
269
|
const items = await eachAtMost(rows, INSPECT_AT_ONCE, async (row) => {
|
|
270
270
|
const kept = keptFor(pool, row, statuses, now, options.manual ? "manual" : cleanup);
|
package/dist/cli.js
CHANGED
|
@@ -514,11 +514,13 @@ function renderMissingJson(roots, missingRoots, now) {
|
|
|
514
514
|
async function runReposCommand(argv, write, onboard) {
|
|
515
515
|
// `repos` honors the machine contract like everything else (audit TG-3):
|
|
516
516
|
// --json answers with one envelope, whatever the outcome.
|
|
517
|
+
// The lead adds with its token (--token <t>, --token=<t> or TOOLROLL_LEAD_TOKEN); the owner needs none. A token is
|
|
518
|
+
// random base64url and may start with "--": it is the next argument, whatever it looks like.
|
|
519
|
+
const tokenAt = argv.findIndex(argument => argument === "--token" || argument.startsWith("--token="));
|
|
520
|
+
const tokenInline = tokenAt !== -1 && argv[tokenAt] !== "--token";
|
|
521
|
+
const tokenFlag = tokenAt === -1 ? undefined : tokenInline ? argv[tokenAt].slice("--token=".length) : argv[tokenAt + 1];
|
|
517
522
|
const json = argv.includes("--json");
|
|
518
|
-
|
|
519
|
-
const tokenAt = argv.indexOf("--token");
|
|
520
|
-
const tokenFlag = tokenAt === -1 ? undefined : argv[tokenAt + 1];
|
|
521
|
-
const bare = argv.filter((argument, index) => argument !== "--json" && (tokenAt === -1 || (index !== tokenAt && index !== tokenAt + 1)));
|
|
523
|
+
const bare = argv.filter((argument, index) => argument !== "--json" && (tokenAt === -1 || (index !== tokenAt && (tokenInline || index !== tokenAt + 1))));
|
|
522
524
|
const remoteAt = bare.indexOf("--remote");
|
|
523
525
|
const remoteUrl = remoteAt === -1 ? undefined : bare[remoteAt + 1];
|
|
524
526
|
const [action, ...paths] = bare.filter((_, i) => remoteAt === -1 || (i !== remoteAt && i !== remoteAt + 1));
|
|
@@ -529,7 +531,8 @@ async function runReposCommand(argv, write, onboard) {
|
|
|
529
531
|
}
|
|
530
532
|
const databaseFile = databasePath(process.env, homedir());
|
|
531
533
|
const file = registryBeside(databaseFile);
|
|
532
|
-
|
|
534
|
+
// A dash-led token is the token, unless it is exactly one of this command's flags (never swallowed).
|
|
535
|
+
if (tokenAt !== -1 && (tokenFlag === undefined || tokenFlag === "" || (!tokenInline && ["--json", "--remote", "--token"].includes(tokenFlag)))) {
|
|
533
536
|
const message = "--token needs the lead token";
|
|
534
537
|
write(json ? envelopeJson({ ok: false, command: `repos ${action ?? ""}`.trim(), reason: "usage", message }) : message);
|
|
535
538
|
return USAGE_EXIT;
|
package/dist/coding-handoff.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ type Handoff = {
|
|
|
17
17
|
scopeDigest: string;
|
|
18
18
|
receipt: CodingHandoffReceipt;
|
|
19
19
|
};
|
|
20
|
+
export declare function branchHead(repo: string, branch: string): string | null;
|
|
20
21
|
export declare function codingHandoffPreview(store: Store, input: {
|
|
21
22
|
sessionId: string;
|
|
22
23
|
actor: string;
|
package/dist/coding-handoff.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/** A committed native session enters the existing review flow, never another executor. */
|
|
2
|
-
import { checkoutGitSync } from './teardown-git.js';
|
|
3
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { checkoutGitSync, noLazyFetch, viewedGitSync } from './teardown-git.js';
|
|
4
3
|
import { lstatSync, realpathSync } from 'node:fs';
|
|
5
4
|
import { sqliteRuntime } from './sqlite-runtime.js';
|
|
6
5
|
import { codingCatalogPath } from './coding-update.js';
|
|
@@ -57,21 +56,28 @@ function catalog(store, readOnly) {
|
|
|
57
56
|
// checkout git, which the code page's GET preview also uses (docs/plans/get-side-effects.md).
|
|
58
57
|
function git(repo, args) {
|
|
59
58
|
try {
|
|
60
|
-
return checkoutGitSync(repo, ['--no-replace-objects',
|
|
59
|
+
return checkoutGitSync(repo, ['--no-replace-objects', ...noLazyFetch(), ...args]).trimEnd();
|
|
61
60
|
}
|
|
62
61
|
catch {
|
|
63
62
|
throw Error('The committed coding result could not be verified in Git. Check the project and try again.');
|
|
64
63
|
}
|
|
65
64
|
}
|
|
66
|
-
|
|
65
|
+
// The project's repository is shared with every build (its config, includes): read through the frozen, bounded view
|
|
66
|
+
// (eval-harness review 2, finding 6), and never inside a database transaction.
|
|
67
|
+
export function branchHead(repo, branch) {
|
|
68
|
+
let ran;
|
|
67
69
|
try {
|
|
68
|
-
|
|
70
|
+
ran = viewedGitSync(repo, ['--no-replace-objects', ...noLazyFetch(), 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], { timeoutMs: 30_000 });
|
|
69
71
|
}
|
|
70
|
-
catch
|
|
71
|
-
if (error.status === 1)
|
|
72
|
-
return null;
|
|
72
|
+
catch {
|
|
73
73
|
throw Error('The review branch could not be inspected.');
|
|
74
74
|
}
|
|
75
|
+
if (ran.status === 1)
|
|
76
|
+
return null;
|
|
77
|
+
const head = ran.stdout.trim();
|
|
78
|
+
if (ran.status !== 0 || !/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(head))
|
|
79
|
+
throw Error('The review branch could not be inspected.');
|
|
80
|
+
return head;
|
|
75
81
|
}
|
|
76
82
|
function actorGeneration(store, actor, repo) {
|
|
77
83
|
const account = store.accountOf(actor);
|
|
@@ -200,6 +206,8 @@ export function createCodingHandoff(store, input) {
|
|
|
200
206
|
const receipt = makeReceipt({ ...shown, version: 1, identity: learningIdentity(input.repo), actor: input.actor,
|
|
201
207
|
generation: actorGeneration(store, input.actor, input.repo), title: input.title.trim(), goal: input.goal.trim(), acceptance: parsed.criteria, outOfScope: EXCLUSIONS });
|
|
202
208
|
const payload = JSON.stringify(receipt), sha = learningSha(payload);
|
|
209
|
+
// Git is read before the reservation's transaction, never inside it (review 2, finding 6).
|
|
210
|
+
const before = branchHead(input.repo, receipt.branch);
|
|
203
211
|
db.exec('BEGIN IMMEDIATE');
|
|
204
212
|
let reserved = false;
|
|
205
213
|
try {
|
|
@@ -210,7 +218,7 @@ export function createCodingHandoff(store, input) {
|
|
|
210
218
|
reserved = true;
|
|
211
219
|
}
|
|
212
220
|
else {
|
|
213
|
-
if (store.lookupRef(receipt.taskId) ||
|
|
221
|
+
if (store.lookupRef(receipt.taskId) || before !== null)
|
|
214
222
|
throw Error('The review task or branch already exists. It has been preserved.');
|
|
215
223
|
db.prepare('INSERT INTO coding_handoff VALUES(?,?,?,?)').run(receipt.id, payload, sha, new Date().toISOString());
|
|
216
224
|
}
|
|
@@ -239,11 +247,12 @@ export function createCodingHandoff(store, input) {
|
|
|
239
247
|
// No checked-out/leased branch is adopted, even if its HEAD happens to match.
|
|
240
248
|
if (git(input.repo, ['worktree', 'list', '--porcelain']).split('\n').includes(`branch refs/heads/${receipt.branch}`))
|
|
241
249
|
throw Error('The review branch is already checked out. Its work has been preserved.');
|
|
250
|
+
// The branch, read last before filing and outside the transaction: a database transaction never holds a git ref still.
|
|
251
|
+
if (branchHead(input.repo, receipt.branch) !== receipt.base)
|
|
252
|
+
throw Error('The review branch moved before filing. Its work has been preserved.');
|
|
242
253
|
return store.transact(() => {
|
|
243
254
|
if (actorGeneration(store, input.actor, input.repo) !== receipt.generation)
|
|
244
255
|
throw Error('Your account changed. Sign in again.');
|
|
245
|
-
if (branchHead(input.repo, receipt.branch) !== receipt.base)
|
|
246
|
-
throw Error('The review branch moved before filing. Its work has been preserved.');
|
|
247
256
|
const made = fileTaskProposal(store, { id: receipt.taskId, title: receipt.title, repo: receipt.repo, goal: receipt.goal,
|
|
248
257
|
outOfScope: receipt.outOfScope, acceptance: receipt.acceptance, filedVia: PREFIX + receipt.id, filedBy: { name: input.actor, kind: "person" }, planning: 'skip', admittedRepos: [receipt.repo] }, new Date());
|
|
249
258
|
if (!made.ok)
|
package/dist/coding-workspace.js
CHANGED
|
@@ -400,8 +400,10 @@ export class CodingWorkspace {
|
|
|
400
400
|
}, 50);
|
|
401
401
|
}
|
|
402
402
|
}
|
|
403
|
-
async git(repo, args) {
|
|
404
|
-
|
|
403
|
+
async git(repo, args, project = repo) {
|
|
404
|
+
// The project's repository and the session's checkout are agent-written: the hardened git, against a frozen view of
|
|
405
|
+
// the repository's config and info (teardown-git.ts checkoutGit).
|
|
406
|
+
const result = await checkoutGit(run, 'build', { repo: project })('git', ['--no-optional-locks', ...args], { cwd: repo, timeoutMs: 60_000, maxBuffer: 1_048_576 });
|
|
405
407
|
if (result.code !== 0)
|
|
406
408
|
throw Error('Git could not complete this workspace operation. Check the repository and try again.');
|
|
407
409
|
return result.stdout;
|
|
@@ -520,13 +522,13 @@ export class CodingWorkspace {
|
|
|
520
522
|
}
|
|
521
523
|
async verifyWorktree(session) {
|
|
522
524
|
const [branch, common, original] = await Promise.all([
|
|
523
|
-
this.git(session.worktree, ['symbolic-ref', '--short', 'HEAD']),
|
|
524
|
-
this.git(session.worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir']),
|
|
525
|
+
this.git(session.worktree, ['symbolic-ref', '--short', 'HEAD'], session.repo),
|
|
526
|
+
this.git(session.worktree, ['rev-parse', '--path-format=absolute', '--git-common-dir'], session.repo),
|
|
525
527
|
this.git(session.repo, ['rev-parse', '--path-format=absolute', '--git-common-dir']),
|
|
526
528
|
]);
|
|
527
529
|
if (branch.trim() !== session.branch || realpathSync(common.trim()) !== realpathSync(original.trim()))
|
|
528
530
|
throw Error('This checkout no longer matches the saved project and branch. Restore that checkout before resuming.');
|
|
529
|
-
await this.git(session.worktree, ['merge-base', '--is-ancestor', session.base, 'HEAD']);
|
|
531
|
+
await this.git(session.worktree, ['merge-base', '--is-ancestor', session.base, 'HEAD'], session.repo);
|
|
530
532
|
}
|
|
531
533
|
priorProcessesGone() {
|
|
532
534
|
const row = this.db.prepare('SELECT payload FROM coding_custody WHERE singleton=1').get();
|
package/dist/commit-extract.d.ts
CHANGED
|
@@ -25,6 +25,12 @@ type TreeEntry = {
|
|
|
25
25
|
};
|
|
26
26
|
/** A tree object's entries, parsed from its verified bytes: `<mode> <name>\0<raw id>` each. Null when malformed. */
|
|
27
27
|
export declare function parseTree(bytes: Buffer, idBytes: number): TreeEntry[] | null;
|
|
28
|
+
/**
|
|
29
|
+
* Why a commit's file paths can't all be written as separate regular files, or null: a `.git` part, a path twice, two
|
|
30
|
+
* paths that are one on a case-insensitive or Unicode-normalizing file system (macOS), or a path that is both a file
|
|
31
|
+
* and a folder. scripts/deploy-git.mjs writtenPathsProblem keeps the same rules (held equal by test).
|
|
32
|
+
*/
|
|
33
|
+
export declare function writtenPathsProblem(paths: readonly string[]): string | null;
|
|
28
34
|
/** Write commit `sha` of the repository whose Git directory is `gitDir` (a realpath Toolroll took from its own
|
|
29
35
|
* records) into `dest`, an empty folder this process made. Every object on the way — the commit, each tree, each
|
|
30
36
|
* blob — is hashed here and must be the id that names it: Git's own answer about an id is never taken on trust, so a
|
package/dist/commit-extract.js
CHANGED
|
@@ -10,12 +10,10 @@
|
|
|
10
10
|
* files are written (mode 644, or 755 when the tree says executable), each at a normalized path inside the folder;
|
|
11
11
|
* symlinks, submodules and any other entry refuse the whole extraction.
|
|
12
12
|
*/
|
|
13
|
-
import { spawnSync } from "node:child_process";
|
|
14
13
|
import { createHash } from "node:crypto";
|
|
15
14
|
import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
|
16
|
-
import { join, sep } from "node:path";
|
|
17
|
-
import {
|
|
18
|
-
import { commandSetting } from "./teardown-git.js";
|
|
15
|
+
import { basename, dirname, join, sep } from "node:path";
|
|
16
|
+
import { commandSetting, viewedGitSync } from "./teardown-git.js";
|
|
19
17
|
const MAX_BYTES = 1024 * 1024 * 1024;
|
|
20
18
|
export const EXTRACTION_LIMITS = Object.freeze({ bytes: MAX_BYTES, files: 50_000, entries: 200_000, depth: 64 });
|
|
21
19
|
/** A tree path that stays where it says: relative, no empty, `.` or `..` part, no backslash or control character. */
|
|
@@ -39,16 +37,45 @@ export function parseTree(bytes, idBytes) {
|
|
|
39
37
|
}
|
|
40
38
|
return entries;
|
|
41
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Why a commit's file paths can't all be written as separate regular files, or null: a `.git` part, a path twice, two
|
|
42
|
+
* paths that are one on a case-insensitive or Unicode-normalizing file system (macOS), or a path that is both a file
|
|
43
|
+
* and a folder. scripts/deploy-git.mjs writtenPathsProblem keeps the same rules (held equal by test).
|
|
44
|
+
*/
|
|
45
|
+
export function writtenPathsProblem(paths) {
|
|
46
|
+
const folded = new Map();
|
|
47
|
+
for (const path of paths) {
|
|
48
|
+
if (!normalizedTreePath(path))
|
|
49
|
+
return `${JSON.stringify(path)} isn't a plain relative path`;
|
|
50
|
+
if (path.split("/").some(part => part.toLowerCase() === ".git"))
|
|
51
|
+
return `${path} names a .git folder`;
|
|
52
|
+
const key = path.normalize("NFC").toLowerCase();
|
|
53
|
+
const known = folded.get(key);
|
|
54
|
+
if (known !== undefined)
|
|
55
|
+
return known === path ? `${path} is listed twice` : `${path} and ${known} are the same file on this computer`;
|
|
56
|
+
folded.set(key, path);
|
|
57
|
+
}
|
|
58
|
+
for (const key of folded.keys()) {
|
|
59
|
+
const parts = key.split("/");
|
|
60
|
+
for (let at = 1; at < parts.length; at++) {
|
|
61
|
+
const folder = parts.slice(0, at).join("/");
|
|
62
|
+
if (folded.has(folder))
|
|
63
|
+
return `${folded.get(folder)} is both a file and a folder`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
42
68
|
/** Write commit `sha` of the repository whose Git directory is `gitDir` (a realpath Toolroll took from its own
|
|
43
69
|
* records) into `dest`, an empty folder this process made. Every object on the way — the commit, each tree, each
|
|
44
70
|
* blob — is hashed here and must be the id that names it: Git's own answer about an id is never taken on trust, so a
|
|
45
71
|
* corrupted or replaced object in a store a build could write to refuses the extraction. */
|
|
46
72
|
export function extractCommit(gitDir, sha, dest, limits = EXTRACTION_LIMITS) {
|
|
47
73
|
const refuse = (message) => ({ ok: false, message });
|
|
48
|
-
const
|
|
49
|
-
const git = (args, input) => spawnSync("git", ["--git-dir", gitDir, "--no-replace-objects", ...HARDENED_GIT_ARGS, ...args], { env, input, maxBuffer: MAX_BYTES, timeout: 300_000 });
|
|
74
|
+
const git = protectedGit(gitDir, {}, []);
|
|
50
75
|
// The repository's own config, read without includes, must name no command and include nothing.
|
|
51
76
|
const config = git(["config", "--list", "--no-includes", "--show-scope", "-z"]);
|
|
77
|
+
if (config.refused !== null)
|
|
78
|
+
return refuse(`The project's Git directory can't be read safely: ${config.refused}.`);
|
|
52
79
|
if (config.status !== 0)
|
|
53
80
|
return refuse("The project's Git config couldn't be read.");
|
|
54
81
|
const listing = config.stdout.toString("utf8");
|
|
@@ -137,6 +164,9 @@ export function extractCommit(gitDir, sha, dest, limits = EXTRACTION_LIMITS) {
|
|
|
137
164
|
}
|
|
138
165
|
if (files.length === 0)
|
|
139
166
|
return refuse(`The commit ${sha.slice(0, 7)} holds no files.`);
|
|
167
|
+
const clash = writtenPathsProblem(files.map(one => one.path));
|
|
168
|
+
if (clash !== null)
|
|
169
|
+
return refuse(`The commit's files can't be written: ${clash}.`);
|
|
140
170
|
const blobs = read([...new Set(files.map(one => one.id))]);
|
|
141
171
|
if (typeof blobs === "string")
|
|
142
172
|
return refuse(`The approved commit couldn't be verified: ${blobs}.`);
|
|
@@ -166,12 +196,35 @@ export function extractCommit(gitDir, sha, dest, limits = EXTRACTION_LIMITS) {
|
|
|
166
196
|
}
|
|
167
197
|
return { ok: true, files: files.length };
|
|
168
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* How the protected readers run git on the project's Git directory (eval-harness review 1, finding 5): never a fetch.
|
|
201
|
+
* Every command, on every git version, runs through a frozen view of the repository (teardown-git.ts viewedGitSync,
|
|
202
|
+
* findings 5 and 7): its config read once into a folder of the plane's own and inspected there — an include, a key that
|
|
203
|
+
* names a command, every `protocol.*` key and (on a git without `--no-lazy-fetch`) a partial clone refused — which is
|
|
204
|
+
* the config git then runs with, bounded in time. Every run also denies every transport (`protocol.allow=never`) and,
|
|
205
|
+
* on git 2.45+, takes `--no-lazy-fetch`; nothing leans on GIT_NO_LAZY_FETCH, which 2.41.0 ignores.
|
|
206
|
+
*/
|
|
207
|
+
function protectedGit(gitDir, runner, extra) {
|
|
208
|
+
const base = ["--no-replace-objects", "-c", "protocol.allow=never", ...extra];
|
|
209
|
+
// The view is of a checkout: the project's own `.git` folder, as Toolroll records it.
|
|
210
|
+
const checkout = basename(gitDir) === ".git" ? dirname(gitDir) : null;
|
|
211
|
+
return (args, input) => {
|
|
212
|
+
if (checkout === null)
|
|
213
|
+
return { status: null, stdout: Buffer.alloc(0), refused: "it isn't a checkout's .git folder, which this git can only read through a view" };
|
|
214
|
+
try {
|
|
215
|
+
const ran = viewedGitSync(checkout, [...base, ...args], { buffer: true, timeoutMs: 300_000, maxBuffer: MAX_BYTES, ...(input === undefined ? {} : { input }), ...(runner.git === undefined ? {} : { git: runner.git }) });
|
|
216
|
+
return { status: ran.status, stdout: ran.stdout, refused: null };
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
return { status: null, stdout: Buffer.alloc(0), refused: error instanceof Error ? error.message : "it couldn't be read" };
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
169
223
|
/** A reader over the project's object store that hashes every object it hands back, or why it can't be one. */
|
|
170
224
|
function verifiedReader(gitDir, runner = {}) {
|
|
171
225
|
const refuse = (message) => ({ ok: false, message });
|
|
172
|
-
const env = { ...(runner.env ?? checkoutGitEnv()), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_ATTR_NOSYSTEM: "1" };
|
|
173
226
|
// The commit-graph file is a cache a build can write: parents come from the objects themselves, never from it.
|
|
174
|
-
const git = (
|
|
227
|
+
const git = protectedGit(gitDir, runner, ["-c", "core.commitGraph=false"]);
|
|
175
228
|
// History a build could have rewritten around the objects: a shallow boundary, grafts, or replacement refs. Each is
|
|
176
229
|
// refused outright, so no reader here ever has to decide whether to believe one.
|
|
177
230
|
if (existsSync(join(gitDir, "shallow")))
|
|
@@ -179,6 +232,8 @@ function verifiedReader(gitDir, runner = {}) {
|
|
|
179
232
|
if (existsSync(join(gitDir, "info", "grafts")))
|
|
180
233
|
return refuse("The project's Git directory has grafts, which rewrite history; they are refused.");
|
|
181
234
|
const replaced = git(["for-each-ref", "--format=%(refname)", "refs/replace/"]);
|
|
235
|
+
if (replaced.refused !== null)
|
|
236
|
+
return refuse(`The project's Git directory can't be read safely: ${replaced.refused}.`);
|
|
182
237
|
if (replaced.status !== 0)
|
|
183
238
|
return refuse("The project's refs couldn't be read.");
|
|
184
239
|
if (replaced.stdout.toString("utf8").trim() !== "")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type BrowserProjectState } from "./browser-workspace.js";
|
|
2
|
+
import type { Store } from "./store.js";
|
|
3
|
+
import { type WorkIndexOptions } from "./work-index.js";
|
|
4
|
+
import type { WorkSummaryAccess } from "./work-summary.js";
|
|
5
|
+
import type { WorkspaceRevision } from "./workspace-revision.js";
|
|
6
|
+
export type SidebarProject = {
|
|
7
|
+
path: string;
|
|
8
|
+
name: string;
|
|
9
|
+
};
|
|
10
|
+
/** The sidebar's projects with their states (agent-state.ts) for one reader: the page's first read and the summary
|
|
11
|
+
* room's snapshots are this one function, so the two never disagree. Never marks anything seen. Work with no project
|
|
12
|
+
* yet is its own entry when this reader may see it and something counts there. */
|
|
13
|
+
export declare function projectSummaryOf(store: Store, now: Date, access: WorkSummaryAccess, actor: string, projects: readonly SidebarProject[], evidenceRoot: string | undefined): BrowserProjectState[];
|
|
14
|
+
/** One access, canonically and in full (every field it has, so a field added later keys too): the same reader and
|
|
15
|
+
* scope always spell the same key, whatever order its projects came in. */
|
|
16
|
+
export declare function accessKey(access: WorkSummaryAccess): string;
|
|
17
|
+
/**
|
|
18
|
+
* Values read from the database, each kept while the workspace revision (workspace-revision.ts: every meaningful
|
|
19
|
+
* commit, from any process, moves it) is the one it was read at, and never past the revision's own clock boundary
|
|
20
|
+
* (a lease or liveness window ending) or `maxAgeMs`, whichever is first — time alone (an agent going quiet) shows
|
|
21
|
+
* within that bound. A value is stored under the revision read before it was computed, so a commit that lands while it
|
|
22
|
+
* is computed only makes it miss sooner. Callers must not change a value they were handed.
|
|
23
|
+
*/
|
|
24
|
+
export declare class RevisionCache {
|
|
25
|
+
#private;
|
|
26
|
+
constructor(revision: WorkspaceRevision, clock: () => Date, options?: {
|
|
27
|
+
maxEntries?: number;
|
|
28
|
+
maxAgeMs?: number;
|
|
29
|
+
});
|
|
30
|
+
/** The value for `key` at the current revision, or undefined. */
|
|
31
|
+
peek<T>(key: string): T | undefined;
|
|
32
|
+
/** The cached value, or `compute()` now (stored for the next reader). Errors are never cached. */
|
|
33
|
+
read<T>(key: string, compute: () => T): T;
|
|
34
|
+
/** The cached value, or one read asynchronously (a read worker) and stored. A failure answers undefined and leaves
|
|
35
|
+
* the key empty, so the request thread computes it as before. */
|
|
36
|
+
fill<T>(key: string, compute: () => Promise<T>): Promise<T | undefined>;
|
|
37
|
+
}
|
|
38
|
+
/** The keys the console's chrome reads are kept under (serve.ts and its read-worker prefetch spell them the same). */
|
|
39
|
+
export declare const countsKey: (access: WorkSummaryAccess) => string;
|
|
40
|
+
export declare const summaryKey: (actor: string, access: WorkSummaryAccess, projects: readonly SidebarProject[]) => string;
|
|
41
|
+
export declare const indexKey: (access: WorkSummaryAccess, options: WorkIndexOptions) => string;
|