faberun 0.30.0 → 0.32.0
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/package.json +1 -1
- package/src/campaign/campaign-brief.mjs +17 -6
- package/src/campaign/chain.mjs +124 -8
- package/src/campaign/ledger.mjs +19 -7
- package/src/campaign/metrics-command.mjs +68 -9
- package/src/campaign/metrics.mjs +75 -45
- package/src/campaign/record.mjs +8 -1
- package/src/campaign/reserve.mjs +340 -0
- package/src/campaign/spend.mjs +229 -0
- package/src/campaign/unpark.mjs +54 -1
- package/src/campaign/watch.mjs +314 -37
- package/src/cli/brand.mjs +2 -1
- package/src/cli.mjs +52 -2
- package/src/contract/judge-envelope.mjs +17 -2
- package/src/contract/scope-findings.mjs +3 -1
- package/src/contract/task-packet.mjs +40 -5
- package/src/contract/verification.mjs +67 -1
- package/src/engine/aux-admission.mjs +124 -0
- package/src/engine/capacity.mjs +78 -0
- package/src/engine/dispatch.mjs +116 -23
- package/src/engine/failure-cause.mjs +59 -0
- package/src/engine/lifecycle.mjs +3 -2
- package/src/engine/phase-session.mjs +56 -10
- package/src/engine/prompts.mjs +57 -2
- package/src/engine/reserve-admission.mjs +99 -0
- package/src/engine/resume.mjs +3 -1
- package/src/engine/review.mjs +21 -0
- package/src/engine/run-command.mjs +166 -11
- package/src/engine/scheduler.mjs +92 -88
- package/src/engine/settle.mjs +15 -8
- package/src/engine/settlement-overlap.mjs +143 -0
- package/src/engine/verify.mjs +131 -7
- package/src/harnesses/session-metrics.mjs +112 -20
- package/src/notify/index.mjs +86 -10
- package/src/plan/freeze.mjs +7 -2
- package/src/plan/human-step.mjs +36 -0
- package/src/plan/pipeline.mjs +53 -22
- package/src/plan/preflight.mjs +81 -5
- package/src/plan/progress.mjs +101 -1
- package/src/plan/repo-facts.mjs +403 -49
- package/src/plan/resolve.mjs +7 -0
- package/src/plan/revise-context.mjs +108 -0
- package/src/plan/rounds.mjs +180 -45
- package/src/plan/sizing.mjs +33 -1
- package/src/plan/template.mjs +134 -22
- package/src/repo/integrate.mjs +47 -5
- package/src/report/campaign-brief-estimate.mjs +47 -1
- package/src/report/campaign-brief.mjs +31 -2
- package/src/report/locale.mjs +20 -0
- package/src/report/message.mjs +13 -1
- package/src/report/metrics-report.mjs +38 -0
- package/src/report/progress.mjs +194 -10
- package/src/report/render.mjs +1 -1
- package/src/report/report-html.mjs +235 -0
- package/src/report/report-video.mjs +323 -0
- package/src/run/usage-windows.mjs +99 -4
- package/src/run/usage.mjs +69 -7
- package/src/web/api.mjs +179 -24
- package/src/web/app.mjs +27 -2
- package/src/web/server.mjs +32 -0
package/src/plan/repo-facts.mjs
CHANGED
|
@@ -14,15 +14,34 @@
|
|
|
14
14
|
* exists — and its output rides along as `requirementMeasurements`: the draft
|
|
15
15
|
* reasons from a fact the planner measured, not one it inferred from prose.
|
|
16
16
|
*
|
|
17
|
+
* A collection may be handed the facts a previous attempt recorded
|
|
18
|
+
* (`previousFacts`): each candidate timing is then reused, not re-paid,
|
|
19
|
+
* exactly while every input it was taken under still matches — the tree, the
|
|
20
|
+
* command line, the manifest and lockfile bytes, the machine — and is re-timed
|
|
21
|
+
* the moment any of them changes. A run that reached its timeout never enters
|
|
22
|
+
* that economy: it is recorded ineligible, and an ineligible measurement is
|
|
23
|
+
* never lent on — a later collection times the command fresh rather than
|
|
24
|
+
* promote the kill to a duration.
|
|
25
|
+
*
|
|
17
26
|
* Sorting and the absence of any clock in the output itself (only inside an
|
|
18
27
|
* injected measurer's own numbers) is what makes two calls at the same HEAD
|
|
19
28
|
* byte-identical.
|
|
29
|
+
*
|
|
30
|
+
* The `paths` array is a byte-limited cut, never the tree. A path a
|
|
31
|
+
* requirement's proof or measure names, a test file, and a module the tree
|
|
32
|
+
* references are always kept; the ceiling bounds only what nothing asks for.
|
|
33
|
+
* What the cut discarded is reported per source kind in `pathOmission`, so a
|
|
34
|
+
* reader can tell a dropped manifest from a dropped historical log instead of
|
|
35
|
+
* reading one `truncated` flag, and the complete index every path was listed
|
|
36
|
+
* from is written beside the artefact (`writePathIndex`) for a discovery node
|
|
37
|
+
* explicitly authorised to enumerate the tree.
|
|
20
38
|
*/
|
|
21
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
39
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
40
|
import { join } from "node:path";
|
|
23
41
|
import { timeVerificationCommands } from "../host/preflight.mjs";
|
|
24
42
|
import { directReferenceGraph } from "../repo/scope-closure.mjs";
|
|
25
43
|
import { boundedGitSync, gitHead } from "../repo/worktree.mjs";
|
|
44
|
+
import { contentDigest } from "./freeze.mjs";
|
|
26
45
|
import { runShellCapture } from "./proof-run.mjs";
|
|
27
46
|
|
|
28
47
|
/** @typedef {import("./spec.mjs").SpecRequirement} SpecRequirement */
|
|
@@ -31,11 +50,32 @@ import { runShellCapture } from "./proof-run.mjs";
|
|
|
31
50
|
/**
|
|
32
51
|
* A verification command repo facts measured: a timed Node candidate, the
|
|
33
52
|
* manifest file it was read from, and its eligibility. It keeps the shape
|
|
34
|
-
* `freeze.mjs`'s `MeasuredFacts` reads (`argv` plus
|
|
35
|
-
*
|
|
36
|
-
*
|
|
53
|
+
* `freeze.mjs`'s `MeasuredFacts` reads (`argv` plus `measuredMs`), so a
|
|
54
|
+
* repository's facts stay assignable there while carrying the manifest the
|
|
55
|
+
* planner needs. A run completed under the ceiling persists its duration; a
|
|
56
|
+
* run killed at the timeout is not a measured duration, so it persists
|
|
57
|
+
* `measuredMs` null with `eligible` false — nothing may plan from it, and no
|
|
58
|
+
* record may lend it.
|
|
59
|
+
*
|
|
60
|
+
* @typedef {{argv: string[], manifest: string, measuredMs: number|null, eligible: boolean}} VerificationCandidate
|
|
61
|
+
*/
|
|
62
|
+
/**
|
|
63
|
+
* The machine a timing was taken on. Two collections that differ in any of
|
|
64
|
+
* these measured different machines, whatever else matched.
|
|
37
65
|
*
|
|
38
|
-
* @typedef {{
|
|
66
|
+
* @typedef {{platform: string, arch: string, node: string}} MeasurementEnvironment
|
|
67
|
+
*/
|
|
68
|
+
/**
|
|
69
|
+
* The dependency and environment inputs the candidate timings of one
|
|
70
|
+
* collection were taken under: the bytes of the manifest the commands and
|
|
71
|
+
* their dependencies are declared in, the bytes of the lockfile that resolved
|
|
72
|
+
* those dependencies, and the machine they ran on. Recorded beside the
|
|
73
|
+
* timings they qualify, so a later collection can lend from the record only
|
|
74
|
+
* while every one of them still matches. Absent on a record collected before
|
|
75
|
+
* the field existed — which is one of the reasons `reusableTimings` refuses a
|
|
76
|
+
* record it cannot verify.
|
|
77
|
+
*
|
|
78
|
+
* @typedef {{manifestDigest: string|null, lockfileDigest: string|null, environment: MeasurementEnvironment}} MeasuredUnder
|
|
39
79
|
*/
|
|
40
80
|
/**
|
|
41
81
|
* A verification command repo facts detected in a non-Node manifest it never
|
|
@@ -48,13 +88,63 @@ import { runShellCapture } from "./proof-run.mjs";
|
|
|
48
88
|
* @typedef {{argv: string[], manifest: string, measuredMs: number|null, eligible: boolean}} DetectedVerificationCandidate
|
|
49
89
|
*/
|
|
50
90
|
/** @typedef {{path: string, covers: string[]}} TestFileEntry */
|
|
51
|
-
/**
|
|
91
|
+
/**
|
|
92
|
+
* The source kind a discarded path is reported under. `other` is reserved for
|
|
93
|
+
* a path this module cannot name — no manifest basename, no document, log or
|
|
94
|
+
* source suffix — so a known category is never pooled into it.
|
|
95
|
+
*
|
|
96
|
+
* @typedef {"document"|"archived-log"|"manifest"|"code"|"other"} PathKind
|
|
97
|
+
*/
|
|
98
|
+
/** @typedef {{paths: number, bytes: number}} PathKindOmission */
|
|
99
|
+
/** @typedef {{paths: number, bytes: number, byKind: Record<PathKind, PathKindOmission>}} PathOmission */
|
|
100
|
+
/** @typedef {{formatVersion: number, gitHead: string|null, measuredUnder?: MeasuredUnder, paths: string[], pathOmission: PathOmission, scripts: Record<string, string>, verificationCandidates: VerificationCandidate[], detectedVerificationCandidates?: DetectedVerificationCandidate[], testFiles: TestFileEntry[], requirementMeasurements: RequirementMeasurement[]}} RepoFacts */
|
|
52
101
|
|
|
53
102
|
const FORMAT_VERSION = 1;
|
|
54
|
-
|
|
103
|
+
/**
|
|
104
|
+
* The byte ceiling on the artefact's `paths` array. Measured 2026-09-21
|
|
105
|
+
* (AGENTS.md, Faberun protocol): a node whose serialized packet passed 64 KiB
|
|
106
|
+
* was killed by the guard that serializes it, and this inventory rides in that
|
|
107
|
+
* same packet — so the cut is held under 48 KiB and the rest of the artefact
|
|
108
|
+
* has the remainder. A count cap (the 2000 paths this replaces) says nothing
|
|
109
|
+
* about size: the same count is a different artefact in every repository.
|
|
110
|
+
*/
|
|
111
|
+
export const DEFAULT_MAX_PATH_BYTES = 48 * 1024;
|
|
55
112
|
const ELIGIBLE_MS_CEILING = 600_000;
|
|
56
113
|
const CANDIDATE_TIMEOUT_SEC = ELIGIBLE_MS_CEILING / 1_000;
|
|
57
114
|
|
|
115
|
+
/** Every kind a discarded path can be reported under, so the omission report
|
|
116
|
+
* carries a full row per kind even when it discarded nothing there. */
|
|
117
|
+
export const PATH_KINDS = Object.freeze(/** @type {PathKind[]} */ (["document", "archived-log", "manifest", "code", "other"]));
|
|
118
|
+
|
|
119
|
+
/** The files commands are declared in: this module reads its own candidates
|
|
120
|
+
* out of the first two, and the rest are the other ecosystems' manifests. */
|
|
121
|
+
export const MANIFEST_BASENAMES = Object.freeze([
|
|
122
|
+
"package.json", "package-lock.json", "tsconfig.json",
|
|
123
|
+
"pyproject.toml", "pytest.ini", "go.mod", "go.sum",
|
|
124
|
+
"Cargo.toml", "Cargo.lock", "build.zig", "build.zig.zon",
|
|
125
|
+
"Makefile", "makefile", "GNUmakefile",
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
/** Every TOML file is a manifest, declared name or not (`netlify.toml`). */
|
|
129
|
+
export const MANIFEST_SUFFIX = ".toml";
|
|
130
|
+
|
|
131
|
+
/** Prose a human wrote for a human: a document is never a build input. */
|
|
132
|
+
export const DOCUMENT_SUFFIX = ".md";
|
|
133
|
+
|
|
134
|
+
/** Directories holding historical record: ledgers and superseded runs. */
|
|
135
|
+
export const ARCHIVED_LOG_PREFIXES = Object.freeze([".runs/", "docs/history/"]);
|
|
136
|
+
|
|
137
|
+
/** A machine-written ledger, wherever it lives. */
|
|
138
|
+
export const ARCHIVED_LOG_SUFFIX = ".jsonl";
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The extensions of the ecosystems this module already reasons about (Node,
|
|
142
|
+
* Python, Go, Rust, Zig, shell). A path whose extension is not here is not
|
|
143
|
+
* promoted to `code`: calling a PNG source would be the same lie in the other
|
|
144
|
+
* direction as calling it `other`.
|
|
145
|
+
*/
|
|
146
|
+
const CODE_SUFFIXES = Object.freeze([".mjs", ".cjs", ".js", ".jsx", ".ts", ".tsx", ".py", ".go", ".rs", ".zig", ".sh", ".mk", ".c", ".h"]);
|
|
147
|
+
|
|
58
148
|
/**
|
|
59
149
|
* Every path git tracks at HEAD, sorted. The bounded spawn is the same
|
|
60
150
|
* pattern `src/repo/source-identity.mjs` uses for its own git reads: a
|
|
@@ -70,12 +160,145 @@ function listTrackedPaths(cwd) {
|
|
|
70
160
|
return String(result.stdout).split("\n").filter(Boolean).sort();
|
|
71
161
|
}
|
|
72
162
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
163
|
+
/**
|
|
164
|
+
* The bytes one path costs the artefact: its own UTF-8 length. That is what
|
|
165
|
+
* the ceiling counts and what the omission report sums, so the number a reader
|
|
166
|
+
* sees for a discarded kind is the number that kind was worth to the budget.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} path
|
|
169
|
+
* @returns {number}
|
|
170
|
+
*/
|
|
171
|
+
export function pathBytes(path) {
|
|
172
|
+
return Buffer.byteLength(path, "utf8");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Which source kind one path belongs to. Total by construction — every path
|
|
177
|
+
* lands in exactly one of `PATH_KINDS` — which is what lets the per-kind
|
|
178
|
+
* counts of a cut sum to the discarded total with no path uncounted.
|
|
179
|
+
*
|
|
180
|
+
* Order matters: a `docs/history/*.md` is record, not live prose, and a
|
|
181
|
+
* `package.json` is a manifest, not a document that happens to be JSON.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} path
|
|
184
|
+
* @returns {PathKind}
|
|
185
|
+
*/
|
|
186
|
+
export function pathKindOf(path) {
|
|
187
|
+
const basename = path.slice(path.lastIndexOf("/") + 1);
|
|
188
|
+
if (MANIFEST_BASENAMES.includes(basename) || basename.endsWith(MANIFEST_SUFFIX)) return "manifest";
|
|
189
|
+
if (ARCHIVED_LOG_PREFIXES.some((prefix) => path.startsWith(prefix)) || basename.endsWith(ARCHIVED_LOG_SUFFIX)) return "archived-log";
|
|
190
|
+
if (basename.endsWith(DOCUMENT_SUFFIX)) return "document";
|
|
191
|
+
if (CODE_SUFFIXES.some((suffix) => basename.endsWith(suffix))) return "code";
|
|
192
|
+
return "other";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The tracked paths the cut may not discard, whatever the byte ceiling: every
|
|
197
|
+
* path a requirement's own proof or measure names, every test file, and every
|
|
198
|
+
* module the tree references. A path a requirement names is the one piece of
|
|
199
|
+
* the tree the plan is already contracted to reason about; dropping it to save
|
|
200
|
+
* bytes would make the artefact cheap and the plan blind. References matter
|
|
201
|
+
* for the same reason: a module nothing names is a leaf, a module something
|
|
202
|
+
* reads is where a change lands.
|
|
203
|
+
*
|
|
204
|
+
* @param {string[]} allPaths every tracked path
|
|
205
|
+
* @param {SpecRequirement[]} requirements
|
|
206
|
+
* @param {Map<string, string[]>} references repo-relative references per file
|
|
207
|
+
* @returns {Set<string>}
|
|
208
|
+
*/
|
|
209
|
+
export function requiredPaths(allPaths, requirements, references) {
|
|
210
|
+
const tracked = new Set(allPaths);
|
|
211
|
+
/** @type {Set<string>} */
|
|
212
|
+
const required = new Set();
|
|
213
|
+
for (const requirement of requirements) {
|
|
214
|
+
for (const proof of [requirement.proof, requirement.measure]) {
|
|
215
|
+
if (proof?.kind !== "path" || typeof proof.ref !== "string") continue;
|
|
216
|
+
if (tracked.has(proof.ref)) required.add(proof.ref);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const path of allPaths) {
|
|
220
|
+
if (path.startsWith("test/") && path.endsWith(".test.mjs")) required.add(path);
|
|
221
|
+
}
|
|
222
|
+
for (const referenced of references.values()) {
|
|
223
|
+
for (const path of referenced) if (tracked.has(path)) required.add(path);
|
|
224
|
+
}
|
|
225
|
+
return required;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The byte-limited cut over the tracked paths. Every required path is kept
|
|
230
|
+
* whatever the ceiling; the rest are kept in sorted order while the path bytes
|
|
231
|
+
* fit. Each discarded path is counted once under the one kind `pathKindOf`
|
|
232
|
+
* gives it, so the per-kind counts and bytes sum to the omitted total by
|
|
233
|
+
* construction — no path counted twice, none uncounted, and a known category
|
|
234
|
+
* never reported as a generic `other`.
|
|
235
|
+
*
|
|
236
|
+
* The ceiling bounds only what nothing requires, so the kept list can exceed
|
|
237
|
+
* `maxPathBytes` on a repository whose requirements name more than the budget
|
|
238
|
+
* holds. That is the intended direction: an artefact cheap and blind is the
|
|
239
|
+
* outcome this rule exists to prevent, and `omitted` says by how much the rest
|
|
240
|
+
* lost.
|
|
241
|
+
*
|
|
242
|
+
* @param {string[]} allPaths every tracked path, sorted
|
|
243
|
+
* @param {Set<string>} required the paths `requiredPaths` selected
|
|
244
|
+
* @param {number} maxPathBytes
|
|
245
|
+
* @returns {{paths: string[], omitted: PathOmission}}
|
|
246
|
+
*/
|
|
247
|
+
export function cutPaths(allPaths, required, maxPathBytes) {
|
|
248
|
+
const byKind = /** @type {Record<PathKind, PathKindOmission>} */ (Object.fromEntries(PATH_KINDS.map((kind) => [kind, { paths: 0, bytes: 0 }])));
|
|
249
|
+
/** @type {string[]} */
|
|
250
|
+
const kept = [];
|
|
251
|
+
let optionalBytes = 0;
|
|
252
|
+
for (const path of allPaths) {
|
|
253
|
+
if (required.has(path)) {
|
|
254
|
+
kept.push(path);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const bytes = pathBytes(path);
|
|
258
|
+
if (optionalBytes + bytes > maxPathBytes) {
|
|
259
|
+
const kind = pathKindOf(path);
|
|
260
|
+
byKind[kind].paths += 1;
|
|
261
|
+
byKind[kind].bytes += bytes;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
optionalBytes += bytes;
|
|
265
|
+
kept.push(path);
|
|
266
|
+
}
|
|
267
|
+
const kinds = PATH_KINDS.map((kind) => byKind[kind]);
|
|
268
|
+
return {
|
|
269
|
+
paths: [...kept].sort(),
|
|
270
|
+
omitted: {
|
|
271
|
+
paths: kinds.reduce((sum, entry) => sum + entry.paths, 0),
|
|
272
|
+
bytes: kinds.reduce((sum, entry) => sum + entry.bytes, 0),
|
|
273
|
+
byKind,
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The lockfile the Node candidates' dependencies resolve through. */
|
|
279
|
+
const LOCKFILE = "package-lock.json";
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The Node inputs one collection measures under: the declared scripts, and
|
|
283
|
+
* the digests of the two files that qualify them — the manifest the commands
|
|
284
|
+
* and their dependencies are declared in, and the lockfile that resolved
|
|
285
|
+
* those dependencies. Either digest is null when its file is absent; the
|
|
286
|
+
* manifest's absence is also what makes the candidate list empty.
|
|
287
|
+
*
|
|
288
|
+
* @param {string} cwd
|
|
289
|
+
* @returns {{scripts: Record<string, string>, manifestDigest: string|null, lockfileDigest: string|null}}
|
|
290
|
+
*/
|
|
291
|
+
function readNodeInputs(cwd) {
|
|
292
|
+
const manifestPath = join(cwd, PACKAGE_MANIFEST);
|
|
293
|
+
if (!existsSync(manifestPath)) return { scripts: {}, manifestDigest: null, lockfileDigest: null };
|
|
294
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
295
|
+
const scripts = parsed.scripts && typeof parsed.scripts === "object" ? parsed.scripts : {};
|
|
296
|
+
const lockPath = join(cwd, LOCKFILE);
|
|
297
|
+
return {
|
|
298
|
+
scripts,
|
|
299
|
+
manifestDigest: contentDigest(readFileSync(manifestPath, "utf8")),
|
|
300
|
+
lockfileDigest: existsSync(lockPath) ? contentDigest(readFileSync(lockPath, "utf8")) : null,
|
|
301
|
+
};
|
|
79
302
|
}
|
|
80
303
|
|
|
81
304
|
/**
|
|
@@ -201,39 +424,54 @@ function manifestCandidateCommands(cwd) {
|
|
|
201
424
|
/**
|
|
202
425
|
* How a caller watches this stage work. `commands` arrives once, naming every
|
|
203
426
|
* command about to be timed; `measured` arrives once per command as it
|
|
204
|
-
* finishes
|
|
205
|
-
*
|
|
427
|
+
* finishes; `reused` arrives once per command answered from a previous
|
|
428
|
+
* record, when that record is consulted — before any timing begins — so every
|
|
429
|
+
* command the `commands` list announced is accounted for exactly once,
|
|
430
|
+
* whether or not its duration is paid for again. No event reaches the
|
|
431
|
+
* returned facts: two calls at the same HEAD stay byte-identical because no
|
|
432
|
+
* event is written into them.
|
|
206
433
|
*
|
|
207
434
|
* Measured 2026-09-25/26 (AP3): this stage took five to eight minutes and said
|
|
208
435
|
* nothing while it did, so `faberun plan` was indistinguishable from a hang
|
|
209
436
|
* and the operator checked the process by hand on six relaunches.
|
|
210
437
|
*
|
|
211
|
-
* @typedef {(event: {kind: "commands", commands: string[][]} | {kind: "measured", argv: string[], measuredMs: number, index: number, total: number}) => void} RepoFactsProgress
|
|
438
|
+
* @typedef {(event: {kind: "commands", commands: string[][]} | {kind: "measured", argv: string[], measuredMs: number|null, index: number, total: number} | {kind: "reused", argv: string[], measuredMs: number, index: number, total: number}) => void} RepoFactsProgress
|
|
212
439
|
*/
|
|
213
440
|
|
|
214
441
|
/**
|
|
215
|
-
* Time
|
|
216
|
-
* real `spawnSync` and `Date.now` by
|
|
217
|
-
*
|
|
218
|
-
* That function calls `now()`
|
|
219
|
-
* then stop); wrapping it to
|
|
220
|
-
*
|
|
442
|
+
* Time the candidates no recorded measurement may answer through
|
|
443
|
+
* `timeVerificationCommands`'s own probe — real `spawnSync` and `Date.now` by
|
|
444
|
+
* default, or the caller's fake — instead of re-implementing the spawn,
|
|
445
|
+
* ceiling and ENOENT handling it already owns. That function calls `now()`
|
|
446
|
+
* exactly twice per command, in order (start, then stop); wrapping it to
|
|
447
|
+
* record every mark it produces is how the real elapsed ms is recovered
|
|
448
|
+
* without parsing its human-readable report.
|
|
449
|
+
*
|
|
450
|
+
* `timed` carries each command with its position in the full candidate list,
|
|
451
|
+
* so the progress events keep numbering against every command the stage
|
|
452
|
+
* accounts for, the reused ones included. Eligibility is strict below the
|
|
453
|
+
* ceiling: a command still running when the timeout fires is killed at it,
|
|
454
|
+
* so a span at or past the ceiling is the kill, not a duration — recorded as
|
|
455
|
+
* `measuredMs` null with `eligible` false, it may not be planned from and may
|
|
456
|
+
* not be lent to a later collection, which times it fresh instead of
|
|
457
|
+
* promoting the kill to data.
|
|
221
458
|
*
|
|
222
459
|
* @param {string} cwd
|
|
223
|
-
* @param {{argv: string[]}[]}
|
|
460
|
+
* @param {{argv: string[], index: number}[]} timed
|
|
461
|
+
* @param {number} total
|
|
224
462
|
* @param {MeasureProbes} probes
|
|
225
463
|
* @param {RepoFactsProgress} [onProgress]
|
|
226
|
-
* @returns {{argv: string[], measuredMs: number, eligible: boolean}[]}
|
|
464
|
+
* @returns {{argv: string[], index: number, measuredMs: number|null, eligible: boolean}[]}
|
|
227
465
|
*/
|
|
228
|
-
function measureCandidates(cwd,
|
|
229
|
-
if (
|
|
466
|
+
function measureCandidates(cwd, timed, total, probes, onProgress) {
|
|
467
|
+
if (timed.length === 0) return [];
|
|
230
468
|
const now = probes.now ?? (() => Date.now());
|
|
231
469
|
/** @type {number[]} */
|
|
232
470
|
const marks = [];
|
|
233
471
|
const contract = /** @type {import("../contract/index.mjs").ValidatedContract} */ (/** @type {any} */ ({
|
|
234
472
|
cwd,
|
|
235
|
-
nodes:
|
|
236
|
-
id: `repo-facts-${
|
|
473
|
+
nodes: timed.map((command, position) => ({
|
|
474
|
+
id: `repo-facts-${position}`,
|
|
237
475
|
taskPacket: { verification: [{ argv: command.argv, timeoutSec: CANDIDATE_TIMEOUT_SEC }] },
|
|
238
476
|
})),
|
|
239
477
|
}));
|
|
@@ -248,14 +486,17 @@ function measureCandidates(cwd, commands, probes, onProgress) {
|
|
|
248
486
|
// holds the terminal for minutes.
|
|
249
487
|
const stopped = marks.length;
|
|
250
488
|
if (stopped % 2 === 0 && onProgress) {
|
|
251
|
-
|
|
489
|
+
const elapsedMs = mark - marks[stopped - 2];
|
|
490
|
+
const finished = timed[stopped / 2 - 1];
|
|
491
|
+
onProgress({ kind: "measured", argv: finished.argv, measuredMs: elapsedMs >= ELIGIBLE_MS_CEILING ? null : elapsedMs, index: finished.index + 1, total });
|
|
252
492
|
}
|
|
253
493
|
return mark;
|
|
254
494
|
},
|
|
255
495
|
});
|
|
256
|
-
return
|
|
257
|
-
const
|
|
258
|
-
|
|
496
|
+
return timed.map((command, position) => {
|
|
497
|
+
const elapsedMs = marks[position * 2 + 1] - marks[position * 2];
|
|
498
|
+
const eligible = elapsedMs < ELIGIBLE_MS_CEILING;
|
|
499
|
+
return { argv: command.argv, index: command.index, measuredMs: eligible ? elapsedMs : null, eligible };
|
|
259
500
|
});
|
|
260
501
|
}
|
|
261
502
|
|
|
@@ -296,36 +537,149 @@ export function measureRequirements(cwd, requirements, probes = {}) {
|
|
|
296
537
|
}
|
|
297
538
|
|
|
298
539
|
|
|
540
|
+
/**
|
|
541
|
+
* The timings a previous collection may lend this one, keyed by the exact
|
|
542
|
+
* command line, or null when it may lend nothing. Every input the recorded
|
|
543
|
+
* durations were taken under has to still hold — the same format version,
|
|
544
|
+
* the same tree (`gitHead`), the same manifest and lockfile bytes and the
|
|
545
|
+
* same machine (`measuredUnder`) — and per candidate the same command line,
|
|
546
|
+
* recorded eligible with a numeric duration. A record failing any of these
|
|
547
|
+
* is invalidated wholesale: nothing from it is reused, and every command it
|
|
548
|
+
* cannot answer is timed fresh. A timed-out entry never qualifies even on an
|
|
549
|
+
* otherwise valid record, because it carries no duration to lend.
|
|
550
|
+
*
|
|
551
|
+
* @param {RepoFacts|undefined} previous
|
|
552
|
+
* @param {string|null} head
|
|
553
|
+
* @param {MeasuredUnder} under
|
|
554
|
+
* @returns {Map<string, number>|null}
|
|
555
|
+
*/
|
|
556
|
+
function reusableTimings(previous, head, under) {
|
|
557
|
+
if (!previous || previous.formatVersion !== FORMAT_VERSION) return null;
|
|
558
|
+
if (previous.gitHead !== head) return null;
|
|
559
|
+
const prior = previous.measuredUnder;
|
|
560
|
+
if (!prior
|
|
561
|
+
|| prior.manifestDigest !== under.manifestDigest
|
|
562
|
+
|| prior.lockfileDigest !== under.lockfileDigest
|
|
563
|
+
|| prior.environment?.platform !== under.environment.platform
|
|
564
|
+
|| prior.environment?.arch !== under.environment.arch
|
|
565
|
+
|| prior.environment?.node !== under.environment.node) return null;
|
|
566
|
+
/** @type {Map<string, number>} */
|
|
567
|
+
const timings = new Map();
|
|
568
|
+
for (const candidate of previous.verificationCandidates) {
|
|
569
|
+
if (candidate.eligible !== true || typeof candidate.measuredMs !== "number") continue;
|
|
570
|
+
timings.set(JSON.stringify(candidate.argv), candidate.measuredMs);
|
|
571
|
+
}
|
|
572
|
+
return timings;
|
|
573
|
+
}
|
|
574
|
+
|
|
299
575
|
/**
|
|
300
576
|
* @param {string} cwd
|
|
301
|
-
* @param {{measure?: MeasureProbes,
|
|
577
|
+
* @param {{measure?: MeasureProbes, maxPathBytes?: number, requirements?: SpecRequirement[], previousFacts?: RepoFacts, onProgress?: RepoFactsProgress}} [options]
|
|
578
|
+
* `previousFacts` is what a previous attempt's collection recorded — its
|
|
579
|
+
* persisted `repo-facts.json` read back — and it is an offer, never an
|
|
580
|
+
* instruction: any input a recorded timing cannot prove unchanged sends
|
|
581
|
+
* that command back through the measurer.
|
|
302
582
|
* @returns {RepoFacts}
|
|
303
583
|
*/
|
|
304
584
|
export function collectRepoFacts(cwd, options = {}) {
|
|
305
|
-
const
|
|
585
|
+
const maxPathBytes = options.maxPathBytes ?? DEFAULT_MAX_PATH_BYTES;
|
|
306
586
|
const allPaths = listTrackedPaths(cwd);
|
|
307
587
|
const pathSet = new Set(allPaths);
|
|
308
|
-
|
|
588
|
+
// One graph for both readers: the cut needs it to know what the tree
|
|
589
|
+
// references, `testFileEntries` to know what each test file covers.
|
|
590
|
+
const references = directReferenceGraph(cwd);
|
|
591
|
+
const head = gitHead(cwd);
|
|
592
|
+
const { scripts, manifestDigest, lockfileDigest } = readNodeInputs(cwd);
|
|
593
|
+
// The inputs every timing below is taken under, recorded beside them so a
|
|
594
|
+
// later collection can check them before lending from the record.
|
|
595
|
+
const measuredUnder = {
|
|
596
|
+
manifestDigest,
|
|
597
|
+
lockfileDigest,
|
|
598
|
+
environment: { platform: process.platform, arch: process.arch, node: process.version },
|
|
599
|
+
};
|
|
309
600
|
const candidates = nodeCandidateCommands(scripts, allPaths);
|
|
310
601
|
options.onProgress?.({ kind: "commands", commands: candidates.map((command) => command.argv) });
|
|
311
|
-
const
|
|
602
|
+
const reusable = reusableTimings(options.previousFacts, head, measuredUnder);
|
|
603
|
+
/** @type {VerificationCandidate[]} */
|
|
604
|
+
const measured = new Array(candidates.length);
|
|
605
|
+
/** @type {{argv: string[], index: number}[]} */
|
|
606
|
+
const timed = [];
|
|
607
|
+
candidates.forEach((candidate, index) => {
|
|
608
|
+
const recorded = reusable?.get(JSON.stringify(candidate.argv));
|
|
609
|
+
if (typeof recorded === "number") {
|
|
610
|
+
measured[index] = { argv: candidate.argv, manifest: PACKAGE_MANIFEST, measuredMs: recorded, eligible: true };
|
|
611
|
+
options.onProgress?.({ kind: "reused", argv: candidate.argv, measuredMs: recorded, index: index + 1, total: candidates.length });
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
timed.push({ argv: candidate.argv, index });
|
|
615
|
+
});
|
|
616
|
+
for (const result of measureCandidates(cwd, timed, candidates.length, options.measure ?? {}, options.onProgress)) {
|
|
617
|
+
measured[result.index] = { argv: result.argv, manifest: PACKAGE_MANIFEST, measuredMs: result.measuredMs, eligible: result.eligible };
|
|
618
|
+
}
|
|
312
619
|
const detected = manifestCandidateCommands(cwd);
|
|
313
|
-
const
|
|
620
|
+
const requirements = options.requirements ?? [];
|
|
621
|
+
const cut = cutPaths(allPaths, requiredPaths(allPaths, requirements, references), maxPathBytes);
|
|
314
622
|
return {
|
|
315
623
|
formatVersion: FORMAT_VERSION,
|
|
316
|
-
gitHead:
|
|
317
|
-
|
|
318
|
-
|
|
624
|
+
gitHead: head,
|
|
625
|
+
measuredUnder,
|
|
626
|
+
paths: cut.paths,
|
|
627
|
+
pathOmission: cut.omitted,
|
|
319
628
|
scripts,
|
|
320
|
-
// The measured Node candidates first,
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
629
|
+
// The measured Node candidates first, each naming package.json as the
|
|
630
|
+
// manifest it came from; then the manifest-only candidates in a fixed
|
|
631
|
+
// ecosystem order, each naming the manifest it was read from. A measured
|
|
632
|
+
// candidate carries a duration only while it is eligible: one killed at
|
|
633
|
+
// the timeout persists measuredMs null with eligible false — the kill is
|
|
634
|
+
// the evidence it never completed — and no reader, reuse included, may
|
|
635
|
+
// treat anything about it as how long the command takes.
|
|
636
|
+
verificationCandidates: measured,
|
|
327
637
|
detectedVerificationCandidates: detected.map((candidate) => ({ ...candidate, measuredMs: null, eligible: true })),
|
|
328
|
-
testFiles: testFileEntries(allPaths, pathSet,
|
|
329
|
-
requirementMeasurements: measureRequirements(cwd,
|
|
638
|
+
testFiles: testFileEntries(allPaths, pathSet, references),
|
|
639
|
+
requirementMeasurements: measureRequirements(cwd, requirements, options.measure ?? {}),
|
|
330
640
|
};
|
|
331
641
|
}
|
|
642
|
+
|
|
643
|
+
/** The file name the cut artefact is persisted under, both beside the staged
|
|
644
|
+
* planning inputs and durably beside a frozen plan. */
|
|
645
|
+
export const REPO_FACTS_FILE = "repo-facts.json";
|
|
646
|
+
/** The file name the complete path index is staged under, beside that artefact. */
|
|
647
|
+
export const PATH_INDEX_FILE = "repo-paths.txt";
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Write the complete tracked-path index — every path `git ls-files` reports,
|
|
651
|
+
* one per line — beside the cut artefact. The cut is what a planning stage
|
|
652
|
+
* reads; this file is what an authorised discovery packet reads when it must
|
|
653
|
+
* reach a path the cut discarded, which is why it is a separate file and never
|
|
654
|
+
* a field of the artefact itself: the artefact stays byte-bounded, and the
|
|
655
|
+
* whole tree stays reachable.
|
|
656
|
+
*
|
|
657
|
+
* @param {string} cwd
|
|
658
|
+
* @param {string} indexPath
|
|
659
|
+
* @returns {string[]} the indexed paths, in the order written
|
|
660
|
+
*/
|
|
661
|
+
export function writePathIndex(cwd, indexPath) {
|
|
662
|
+
const paths = listTrackedPaths(cwd);
|
|
663
|
+
writeFileSync(indexPath, paths.map((path) => `${path}\n`).join(""));
|
|
664
|
+
return paths;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Persist the two files a later reader needs: the changed artefact — repo
|
|
669
|
+
* facts carrying the byte cut and its per-kind omission report — and the
|
|
670
|
+
* complete path index beside it. `pipeline.mjs` calls this into the scratch
|
|
671
|
+
* relay the draft and review stages read; `resolve.mjs` calls it into the
|
|
672
|
+
* plans directory, so a contested plan resumed without redrafting still leaves
|
|
673
|
+
* the facts it froze with on disk.
|
|
674
|
+
*
|
|
675
|
+
* @param {string} outDir
|
|
676
|
+
* @param {RepoFacts} facts
|
|
677
|
+
* @param {string} cwd the checkout the index is listed from
|
|
678
|
+
* @returns {string} the artefact path
|
|
679
|
+
*/
|
|
680
|
+
export function persistRepoFacts(outDir, facts, cwd) {
|
|
681
|
+
const factsPath = join(outDir, REPO_FACTS_FILE);
|
|
682
|
+
writeFileSync(factsPath, `${JSON.stringify(facts, null, 2)}\n`);
|
|
683
|
+
writePathIndex(cwd, join(outDir, PATH_INDEX_FILE));
|
|
684
|
+
return factsPath;
|
|
685
|
+
}
|
package/src/plan/resolve.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { highestOf, modelOf } from "./pipeline-shape.mjs";
|
|
|
28
28
|
import { reviewerProvenanceOf } from "./reviewer.mjs";
|
|
29
29
|
import { PLANNER_SESSION_ID, assembleFrozenPlan, carriesSuites, effectiveVerificationSuites, frozenContractRawOf, invalidPlanFinding, writeFrozenPlan } from "./pipeline.mjs";
|
|
30
30
|
import { contestPlan } from "./contest.mjs";
|
|
31
|
+
import { persistRepoFacts } from "./repo-facts.mjs";
|
|
31
32
|
import { parseSpec } from "./spec.mjs";
|
|
32
33
|
import { RISK_TIERS } from "./template.mjs";
|
|
33
34
|
|
|
@@ -131,6 +132,12 @@ export async function resolvePlanningPipeline({ plansDir, cwd, answers }) {
|
|
|
131
132
|
]);
|
|
132
133
|
}
|
|
133
134
|
logStage("resolve", { round, answered: criticalFindings.length });
|
|
135
|
+
// R4: the facts a --resolve freeze carries are the same shaped artefact a
|
|
136
|
+
// fresh `plan` stages — the byte cut plus its per-kind omission report — and
|
|
137
|
+
// they are written here, with the complete path index beside them, so a plan
|
|
138
|
+
// that reached freeze without redrafting still leaves on disk the inventory
|
|
139
|
+
// and the whole tree a later discovery packet reads.
|
|
140
|
+
persistRepoFacts(plansDir, repoFacts, cwd);
|
|
134
141
|
|
|
135
142
|
// The plan resumed here may carry suites of its own (RM-107), so the
|
|
136
143
|
// warning is read off the same effective set the freeze below will carry.
|