@yagni-app/code-staging 1.2.9-staging.2082.1 → 1.2.9-staging.2089.1
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/dist/extension/pipeline/baseTree.d.ts +68 -0
- package/dist/extension/pipeline/baseTree.js +522 -0
- package/dist/extension/pipeline/cloud.d.ts +13 -0
- package/dist/extension/pipeline/cloud.js +24 -0
- package/dist/extension/pipeline/headlessGo.d.ts +5 -0
- package/dist/extension/pipeline/headlessGo.js +143 -43
- package/dist/extension/pipeline/verify.js +4 -3
- package/package.json +2 -2
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The attempt's base commit's files, for what a resumed session measures
|
|
3
|
+
* before it builds.
|
|
4
|
+
*
|
|
5
|
+
* A fresh build measures the gate's checks on its clean tree, which is the
|
|
6
|
+
* attempt's base (YAG-1003, `measureOnBase`), so a failure the base already
|
|
7
|
+
* has is a note. A resumed session (a continuation, a fix round) starts on the
|
|
8
|
+
* branch past the earlier sessions' commits, so it used to measure nothing,
|
|
9
|
+
* and every failure the base already had was its own to fix: COREX-1291's
|
|
10
|
+
* sessions each took main's lint errors as findings, ended `round_cap`, and
|
|
11
|
+
* were resumed again (production, 2026-10-26).
|
|
12
|
+
*
|
|
13
|
+
* {@link measureOnBaseTree} puts the base commit's files in the checkout for
|
|
14
|
+
* one measurement and then puts the session's tree back exactly: HEAD, its
|
|
15
|
+
* branch, the index, every file git sees (`git stash --include-untracked`
|
|
16
|
+
* holds the uncommitted state meanwhile), the untracked empty folders and the
|
|
17
|
+
* uncommitted files' modes, which git keeps no record of. Ignored files stay
|
|
18
|
+
* where they are, so the clone's installed dependencies serve both trees; an
|
|
19
|
+
* attempt whose commits changed what is installed is therefore never
|
|
20
|
+
* measured, since the base's sources on the change's dependencies would
|
|
21
|
+
* excuse what the dependency change broke. It measures only when it can put
|
|
22
|
+
* the tree back: anything it cannot be sure of is a skip that leaves the tree
|
|
23
|
+
* as it was. A tree it cannot put back is a {@link BaseTreeRestoreError},
|
|
24
|
+
* never a quiet build on the wrong tree. Hooks are off for every call: a
|
|
25
|
+
* repository's post-checkout hook must not run.
|
|
26
|
+
*/
|
|
27
|
+
/** One git call's outcome; a spawn error or a timeout is code -1. */
|
|
28
|
+
export interface BaseTreeGitResult {
|
|
29
|
+
code: number;
|
|
30
|
+
stdout: string;
|
|
31
|
+
stderr: string;
|
|
32
|
+
}
|
|
33
|
+
/** The git seam (default: real git, hooks off); injected in tests. */
|
|
34
|
+
export type BaseTreeGit = (args: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<BaseTreeGitResult>;
|
|
35
|
+
export declare const defaultBaseTreeGit: BaseTreeGit;
|
|
36
|
+
/** A commit id the session may put in a git command line, or undefined. */
|
|
37
|
+
export declare function baseCommitOf(value: string | undefined): string | undefined;
|
|
38
|
+
/** The tree put back after a measurement it could not be sure of. */
|
|
39
|
+
export declare class BaseTreeRestoreError extends Error {
|
|
40
|
+
/** The session's HEAD, its branch (null when detached) and the stash holding its uncommitted state. */
|
|
41
|
+
readonly head: string;
|
|
42
|
+
readonly branch: string | null;
|
|
43
|
+
readonly stash: string | null;
|
|
44
|
+
constructor(message: string, state: {
|
|
45
|
+
head: string;
|
|
46
|
+
branch: string | null;
|
|
47
|
+
stash: string | null;
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
export type BaseTreeOutcome<T> = {
|
|
51
|
+
kind: "measured";
|
|
52
|
+
value: T;
|
|
53
|
+
} | {
|
|
54
|
+
kind: "skipped";
|
|
55
|
+
reason: string;
|
|
56
|
+
};
|
|
57
|
+
/** The paths the session's HEAD changed since `base`, relative to `cwd`; null when git cannot say. */
|
|
58
|
+
export declare function pathsCommittedSince(cwd: string, base: string, git?: BaseTreeGit): Promise<string[] | null>;
|
|
59
|
+
/**
|
|
60
|
+
* Run `measure` on the files of the commit `base`, then put the tree back as
|
|
61
|
+
* it was. `skipped` (with why) when it could not start, the tree as it was;
|
|
62
|
+
* throws {@link BaseTreeRestoreError} when the tree could not be put back
|
|
63
|
+
* exactly, and rethrows what `measure` threw once the tree is back.
|
|
64
|
+
*/
|
|
65
|
+
export declare function measureOnBaseTree<T>(cwd: string, base: string, measure: () => Promise<T>, opts?: {
|
|
66
|
+
git?: BaseTreeGit;
|
|
67
|
+
}): Promise<BaseTreeOutcome<T>>;
|
|
68
|
+
//# sourceMappingURL=baseTree.d.ts.map
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The attempt's base commit's files, for what a resumed session measures
|
|
3
|
+
* before it builds.
|
|
4
|
+
*
|
|
5
|
+
* A fresh build measures the gate's checks on its clean tree, which is the
|
|
6
|
+
* attempt's base (YAG-1003, `measureOnBase`), so a failure the base already
|
|
7
|
+
* has is a note. A resumed session (a continuation, a fix round) starts on the
|
|
8
|
+
* branch past the earlier sessions' commits, so it used to measure nothing,
|
|
9
|
+
* and every failure the base already had was its own to fix: COREX-1291's
|
|
10
|
+
* sessions each took main's lint errors as findings, ended `round_cap`, and
|
|
11
|
+
* were resumed again (production, 2026-10-26).
|
|
12
|
+
*
|
|
13
|
+
* {@link measureOnBaseTree} puts the base commit's files in the checkout for
|
|
14
|
+
* one measurement and then puts the session's tree back exactly: HEAD, its
|
|
15
|
+
* branch, the index, every file git sees (`git stash --include-untracked`
|
|
16
|
+
* holds the uncommitted state meanwhile), the untracked empty folders and the
|
|
17
|
+
* uncommitted files' modes, which git keeps no record of. Ignored files stay
|
|
18
|
+
* where they are, so the clone's installed dependencies serve both trees; an
|
|
19
|
+
* attempt whose commits changed what is installed is therefore never
|
|
20
|
+
* measured, since the base's sources on the change's dependencies would
|
|
21
|
+
* excuse what the dependency change broke. It measures only when it can put
|
|
22
|
+
* the tree back: anything it cannot be sure of is a skip that leaves the tree
|
|
23
|
+
* as it was. A tree it cannot put back is a {@link BaseTreeRestoreError},
|
|
24
|
+
* never a quiet build on the wrong tree. Hooks are off for every call: a
|
|
25
|
+
* repository's post-checkout hook must not run.
|
|
26
|
+
*/
|
|
27
|
+
import { execFile } from "node:child_process";
|
|
28
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, } from "node:fs";
|
|
29
|
+
import { tmpdir } from "node:os";
|
|
30
|
+
import { basename, join, resolve as resolvePath } from "node:path";
|
|
31
|
+
import { trackChild } from "./childRegistry.js";
|
|
32
|
+
const GIT_ARGS = [
|
|
33
|
+
"-c",
|
|
34
|
+
"core.hooksPath=/dev/null",
|
|
35
|
+
"-c",
|
|
36
|
+
"user.name=YAGNI",
|
|
37
|
+
"-c",
|
|
38
|
+
"user.email=worker@yagni.local",
|
|
39
|
+
];
|
|
40
|
+
/** One git call's cap: a stash or a checkout of a large tree takes a while. */
|
|
41
|
+
const GIT_TIMEOUT_MS = 5 * 60_000;
|
|
42
|
+
export const defaultBaseTreeGit = (args, cwd, env) => new Promise((resolve) => {
|
|
43
|
+
const child = execFile("git", [...GIT_ARGS, ...args], {
|
|
44
|
+
cwd,
|
|
45
|
+
env: { ...process.env, ...env, GIT_TERMINAL_PROMPT: "0" },
|
|
46
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
47
|
+
timeout: GIT_TIMEOUT_MS,
|
|
48
|
+
}, (err, stdout, stderr) => {
|
|
49
|
+
const code = err?.code;
|
|
50
|
+
resolve({
|
|
51
|
+
code: err ? (typeof code === "number" ? code : -1) : 0,
|
|
52
|
+
stdout: String(stdout ?? ""),
|
|
53
|
+
stderr: String(stderr ?? err?.message ?? ""),
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
trackChild(child);
|
|
57
|
+
});
|
|
58
|
+
const COMMIT_SHA = /^[0-9a-f]{40}$/;
|
|
59
|
+
/** A commit id the session may put in a git command line, or undefined. */
|
|
60
|
+
export function baseCommitOf(value) {
|
|
61
|
+
return value && COMMIT_SHA.test(value) ? value : undefined;
|
|
62
|
+
}
|
|
63
|
+
/** The tree put back after a measurement it could not be sure of. */
|
|
64
|
+
export class BaseTreeRestoreError extends Error {
|
|
65
|
+
/** The session's HEAD, its branch (null when detached) and the stash holding its uncommitted state. */
|
|
66
|
+
head;
|
|
67
|
+
branch;
|
|
68
|
+
stash;
|
|
69
|
+
constructor(message, state) {
|
|
70
|
+
super(message);
|
|
71
|
+
this.name = "BaseTreeRestoreError";
|
|
72
|
+
this.head = state.head;
|
|
73
|
+
this.branch = state.branch;
|
|
74
|
+
this.stash = state.stash;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const errorText = (err) => (err instanceof Error ? err.message : String(err));
|
|
78
|
+
const firstLine = (text) => text.trim().split("\n")[0]?.trim() ?? "";
|
|
79
|
+
function why(result) {
|
|
80
|
+
return firstLine(result.stderr) || firstLine(result.stdout) || `exit ${result.code}`;
|
|
81
|
+
}
|
|
82
|
+
const skipped = (reason) => ({
|
|
83
|
+
kind: "skipped",
|
|
84
|
+
reason,
|
|
85
|
+
});
|
|
86
|
+
/** The paths the session's HEAD changed since `base`, relative to `cwd`; null when git cannot say. */
|
|
87
|
+
export async function pathsCommittedSince(cwd, base, git = defaultBaseTreeGit) {
|
|
88
|
+
const diff = await git(["diff", "--name-only", "-z", "--no-renames", "--relative", base, "HEAD"], cwd);
|
|
89
|
+
return diff.code === 0 ? diff.stdout.split("\0").filter(Boolean) : null;
|
|
90
|
+
}
|
|
91
|
+
/** Files that say which dependencies or which toolchain a checkout installs. */
|
|
92
|
+
const DEPENDENCY_FILES = new Set([
|
|
93
|
+
"pnpm-lock.yaml",
|
|
94
|
+
"pnpm-workspace.yaml",
|
|
95
|
+
"yarn.lock",
|
|
96
|
+
".yarnrc.yml",
|
|
97
|
+
"package-lock.json",
|
|
98
|
+
"npm-shrinkwrap.json",
|
|
99
|
+
"bun.lock",
|
|
100
|
+
"bun.lockb",
|
|
101
|
+
".npmrc",
|
|
102
|
+
"composer.json",
|
|
103
|
+
"composer.lock",
|
|
104
|
+
"Gemfile",
|
|
105
|
+
"Gemfile.lock",
|
|
106
|
+
"go.mod",
|
|
107
|
+
"go.sum",
|
|
108
|
+
"go.work",
|
|
109
|
+
"go.work.sum",
|
|
110
|
+
"Cargo.toml",
|
|
111
|
+
"Cargo.lock",
|
|
112
|
+
"pyproject.toml",
|
|
113
|
+
"poetry.lock",
|
|
114
|
+
"uv.lock",
|
|
115
|
+
"Pipfile",
|
|
116
|
+
"Pipfile.lock",
|
|
117
|
+
"setup.py",
|
|
118
|
+
"setup.cfg",
|
|
119
|
+
"pom.xml",
|
|
120
|
+
"build.gradle",
|
|
121
|
+
"build.gradle.kts",
|
|
122
|
+
"gradle.lockfile",
|
|
123
|
+
"global.json",
|
|
124
|
+
"packages.lock.json",
|
|
125
|
+
"Directory.Packages.props",
|
|
126
|
+
".nvmrc",
|
|
127
|
+
".node-version",
|
|
128
|
+
".tool-versions",
|
|
129
|
+
".python-version",
|
|
130
|
+
".ruby-version",
|
|
131
|
+
".java-version",
|
|
132
|
+
"rust-toolchain",
|
|
133
|
+
"rust-toolchain.toml",
|
|
134
|
+
"mise.toml",
|
|
135
|
+
".mise.toml",
|
|
136
|
+
]);
|
|
137
|
+
const REQUIREMENTS_FILE = /^requirements.*\.(?:txt|in)$/;
|
|
138
|
+
/** A package.json's keys that change what an install puts in `node_modules`. */
|
|
139
|
+
const PACKAGE_INSTALL_KEYS = [
|
|
140
|
+
"dependencies",
|
|
141
|
+
"devDependencies",
|
|
142
|
+
"peerDependencies",
|
|
143
|
+
"optionalDependencies",
|
|
144
|
+
"bundleDependencies",
|
|
145
|
+
"bundledDependencies",
|
|
146
|
+
"overrides",
|
|
147
|
+
"resolutions",
|
|
148
|
+
"pnpm",
|
|
149
|
+
"packageManager",
|
|
150
|
+
"engines",
|
|
151
|
+
"workspaces",
|
|
152
|
+
];
|
|
153
|
+
/** A JSON value as text with every object's keys sorted, so reordered keys compare equal. Pure. */
|
|
154
|
+
function canonicalJson(value) {
|
|
155
|
+
if (Array.isArray(value))
|
|
156
|
+
return `[${value.map(canonicalJson).join(",")}]`;
|
|
157
|
+
if (value && typeof value === "object") {
|
|
158
|
+
const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
159
|
+
return `{${entries.map(([key, inner]) => `${JSON.stringify(key)}:${canonicalJson(inner)}`).join(",")}}`;
|
|
160
|
+
}
|
|
161
|
+
return JSON.stringify(value) ?? "undefined";
|
|
162
|
+
}
|
|
163
|
+
/** Whether a package.json says to install something else at HEAD than at `base`; a side git cannot read differs. */
|
|
164
|
+
async function packageInstallDiffers(root, base, path, git) {
|
|
165
|
+
const read = async (rev) => {
|
|
166
|
+
const shown = await git(["show", `${rev}:${path}`], root);
|
|
167
|
+
if (shown.code !== 0)
|
|
168
|
+
return null;
|
|
169
|
+
try {
|
|
170
|
+
const value = JSON.parse(shown.stdout);
|
|
171
|
+
return value && typeof value === "object" ? value : null;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
const before = await read(base);
|
|
178
|
+
const after = await read("HEAD");
|
|
179
|
+
if (!before || !after)
|
|
180
|
+
return true;
|
|
181
|
+
return PACKAGE_INSTALL_KEYS.some((key) => canonicalJson(before[key]) !== canonicalJson(after[key]));
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Why the clone's installed dependencies are not the base's, or null when
|
|
185
|
+
* they are: the attempt's commits changed a lockfile, a manifest or a
|
|
186
|
+
* toolchain pin. Uncommitted changes (an install's lockfile churn) are not
|
|
187
|
+
* the attempt's and do not count.
|
|
188
|
+
*/
|
|
189
|
+
async function dependencyChange(root, base, git) {
|
|
190
|
+
const diff = await git(["diff", "--name-only", "-z", "--no-renames", base, "HEAD"], root);
|
|
191
|
+
if (diff.code !== 0)
|
|
192
|
+
return `git could not list what the attempt changed: ${why(diff)}`;
|
|
193
|
+
for (const path of diff.stdout.split("\0").filter(Boolean)) {
|
|
194
|
+
const name = basename(path);
|
|
195
|
+
const installs = DEPENDENCY_FILES.has(name) ||
|
|
196
|
+
REQUIREMENTS_FILE.test(name) ||
|
|
197
|
+
(name === "package.json" && (await packageInstallDiffers(root, base, path, git)));
|
|
198
|
+
if (installs) {
|
|
199
|
+
return `the attempt changed what is installed since the base (${path}), so the installed dependencies are not the base's`;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
async function fingerprint(top, git) {
|
|
205
|
+
const must = async (args, env) => {
|
|
206
|
+
const result = await git(args, top, env);
|
|
207
|
+
if (result.code !== 0)
|
|
208
|
+
throw new Error(`git ${args[0]} failed: ${why(result)}`);
|
|
209
|
+
return result.stdout.trim();
|
|
210
|
+
};
|
|
211
|
+
const head = await must(["rev-parse", "--verify", "HEAD"]);
|
|
212
|
+
const symbolic = await git(["symbolic-ref", "-q", "HEAD"], top);
|
|
213
|
+
const branch = symbolic.code === 0 ? symbolic.stdout.trim() : "";
|
|
214
|
+
// Fails on an index with unresolved conflicts.
|
|
215
|
+
const index = await must(["write-tree"]);
|
|
216
|
+
// Every file `.gitignore` leaves in, through a throwaway index seeded from
|
|
217
|
+
// the real one so `add -A` re-reads only what changed (treeSnapshot.ts).
|
|
218
|
+
const scratch = mkdtempSync(join(tmpdir(), "yagni-base-tree-"));
|
|
219
|
+
try {
|
|
220
|
+
const scratchIndex = join(scratch, "index");
|
|
221
|
+
const env = { GIT_INDEX_FILE: scratchIndex };
|
|
222
|
+
const realIndex = resolvePath(top, await must(["rev-parse", "--git-path", "index"]));
|
|
223
|
+
if (existsSync(realIndex))
|
|
224
|
+
copyFileSync(realIndex, scratchIndex);
|
|
225
|
+
else
|
|
226
|
+
await must(["read-tree", "HEAD"], env);
|
|
227
|
+
await must(["add", "-A"], env);
|
|
228
|
+
const files = await must(["write-tree"], env);
|
|
229
|
+
return { head, branch, index, files };
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/** The most untracked folders read for empty ones; past it the tree is not touched. */
|
|
236
|
+
const MAX_UNTRACKED_DIRS = 2_000;
|
|
237
|
+
/** Folders the empty-folder walk never enters: another repository, or installed dependencies. */
|
|
238
|
+
const UNWALKED_DIRS = new Set([".git", "node_modules"]);
|
|
239
|
+
/** The untracked folders with nothing in them, which `stash` and `clean` remove. */
|
|
240
|
+
async function untrackedEmptyDirs(root, git) {
|
|
241
|
+
const listed = await git(["ls-files", "--others", "--directory", "--exclude-standard", "-z"], root);
|
|
242
|
+
if (listed.code !== 0)
|
|
243
|
+
throw new Error(`git ls-files failed: ${why(listed)}`);
|
|
244
|
+
const empty = [];
|
|
245
|
+
let read = 0;
|
|
246
|
+
const walk = (dir) => {
|
|
247
|
+
read += 1;
|
|
248
|
+
if (read > MAX_UNTRACKED_DIRS)
|
|
249
|
+
throw new Error("too many untracked folders to put back");
|
|
250
|
+
const entries = readdirSync(join(root, dir), { withFileTypes: true });
|
|
251
|
+
if (entries.length === 0)
|
|
252
|
+
empty.push(dir);
|
|
253
|
+
for (const entry of entries) {
|
|
254
|
+
if (entry.isDirectory() && !UNWALKED_DIRS.has(entry.name))
|
|
255
|
+
walk(join(dir, entry.name));
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
for (const entry of listed.stdout.split("\0")) {
|
|
259
|
+
if (entry.endsWith("/"))
|
|
260
|
+
walk(entry.slice(0, -1));
|
|
261
|
+
}
|
|
262
|
+
return empty;
|
|
263
|
+
}
|
|
264
|
+
/** Each uncommitted file's permission bits, by repository-relative path. */
|
|
265
|
+
async function uncommittedModes(root, git) {
|
|
266
|
+
const status = await git(["status", "--porcelain", "-z", "--untracked-files=all"], root);
|
|
267
|
+
if (status.code !== 0)
|
|
268
|
+
throw new Error(`git status failed: ${why(status)}`);
|
|
269
|
+
const modes = new Map();
|
|
270
|
+
const fields = status.stdout.split("\0");
|
|
271
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
272
|
+
const field = fields[i] ?? "";
|
|
273
|
+
if (field.length < 4)
|
|
274
|
+
continue;
|
|
275
|
+
// A rename's or a copy's source path is the next field.
|
|
276
|
+
if (/[RC]/.test(field.slice(0, 2)))
|
|
277
|
+
i += 1;
|
|
278
|
+
const path = field.slice(3);
|
|
279
|
+
try {
|
|
280
|
+
const stat = lstatSync(join(root, path));
|
|
281
|
+
if (stat.isFile())
|
|
282
|
+
modes.set(path, stat.mode & 0o7777);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// Deleted in the tree: nothing to give a mode back to.
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return modes;
|
|
289
|
+
}
|
|
290
|
+
async function readTree(root, git) {
|
|
291
|
+
return {
|
|
292
|
+
fingerprint: await fingerprint(root, git),
|
|
293
|
+
emptyDirs: await untrackedEmptyDirs(root, git),
|
|
294
|
+
modes: await uncommittedModes(root, git),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function differences(before, after) {
|
|
298
|
+
return [
|
|
299
|
+
...(after.head !== before.head ? [`HEAD is ${after.head}, not ${before.head}`] : []),
|
|
300
|
+
...(after.branch !== before.branch
|
|
301
|
+
? [`HEAD is on ${after.branch || "no branch"}, not ${before.branch || "no branch"}`]
|
|
302
|
+
: []),
|
|
303
|
+
...(after.index !== before.index ? ["the index differs"] : []),
|
|
304
|
+
...(after.files !== before.files ? ["the files differ"] : []),
|
|
305
|
+
];
|
|
306
|
+
}
|
|
307
|
+
const IN_PROGRESS = [
|
|
308
|
+
"MERGE_HEAD",
|
|
309
|
+
"CHERRY_PICK_HEAD",
|
|
310
|
+
"REVERT_HEAD",
|
|
311
|
+
"rebase-merge",
|
|
312
|
+
"rebase-apply",
|
|
313
|
+
];
|
|
314
|
+
async function operationInProgress(top, git) {
|
|
315
|
+
for (const name of IN_PROGRESS) {
|
|
316
|
+
const path = await git(["rev-parse", "--git-path", name], top);
|
|
317
|
+
if (path.code !== 0)
|
|
318
|
+
return `git could not say whether a ${name} is in progress`;
|
|
319
|
+
if (existsSync(resolvePath(top, path.stdout.trim())))
|
|
320
|
+
return `a ${name} is in progress`;
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
async function refOf(top, ref, git) {
|
|
325
|
+
const result = await git(["rev-parse", "-q", "--verify", ref], top);
|
|
326
|
+
return result.code === 0 ? result.stdout.trim() : null;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Run `measure` on the files of the commit `base`, then put the tree back as
|
|
330
|
+
* it was. `skipped` (with why) when it could not start, the tree as it was;
|
|
331
|
+
* throws {@link BaseTreeRestoreError} when the tree could not be put back
|
|
332
|
+
* exactly, and rethrows what `measure` threw once the tree is back.
|
|
333
|
+
*/
|
|
334
|
+
export async function measureOnBaseTree(cwd, base, measure, opts = {}) {
|
|
335
|
+
const git = opts.git ?? defaultBaseTreeGit;
|
|
336
|
+
if (!baseCommitOf(base))
|
|
337
|
+
return skipped("the base is not a commit id");
|
|
338
|
+
// Nothing is touched until the tree is read: any trouble here is a skip.
|
|
339
|
+
let root;
|
|
340
|
+
let before;
|
|
341
|
+
try {
|
|
342
|
+
const top = await git(["rev-parse", "--show-toplevel"], cwd);
|
|
343
|
+
if (top.code !== 0)
|
|
344
|
+
return skipped(`not a git checkout: ${why(top)}`);
|
|
345
|
+
root = top.stdout.trim();
|
|
346
|
+
if ((await git(["cat-file", "-e", `${base}^{commit}`], root)).code !== 0) {
|
|
347
|
+
return skipped("the base commit is not in this clone");
|
|
348
|
+
}
|
|
349
|
+
const busy = await operationInProgress(root, git);
|
|
350
|
+
if (busy)
|
|
351
|
+
return skipped(busy);
|
|
352
|
+
const installs = await dependencyChange(root, base, git);
|
|
353
|
+
if (installs)
|
|
354
|
+
return skipped(installs);
|
|
355
|
+
before = await readTree(root, git);
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
return skipped(`the tree could not be read: ${errorText(err)}`);
|
|
359
|
+
}
|
|
360
|
+
const aside = await checkOutBase(root, base, git);
|
|
361
|
+
let ran;
|
|
362
|
+
if (aside.skip !== null) {
|
|
363
|
+
ran = { outcome: skipped(aside.skip) };
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
try {
|
|
367
|
+
ran = { outcome: { kind: "measured", value: await measure() } };
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
ran = { error };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const state = {
|
|
374
|
+
head: before.fingerprint.head,
|
|
375
|
+
branch: before.fingerprint.branch.replace(/^refs\/heads\//, "") || null,
|
|
376
|
+
stash: aside.stash,
|
|
377
|
+
};
|
|
378
|
+
try {
|
|
379
|
+
await putBack(root, before, state, aside.setAside, git);
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
if (err instanceof BaseTreeRestoreError)
|
|
383
|
+
throw err;
|
|
384
|
+
throw new BaseTreeRestoreError(`the session's tree could not be put back after the base measurement: ${errorText(err)}`, state);
|
|
385
|
+
}
|
|
386
|
+
if ("error" in ran)
|
|
387
|
+
throw ran.error;
|
|
388
|
+
return ran.outcome;
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Set the session's tree aside and put the base's files in it. `setAside`
|
|
392
|
+
* once everything git sees outside HEAD is in the stash: only then may
|
|
393
|
+
* putting the tree back force a checkout and clean what is left. `skip` says
|
|
394
|
+
* why the base is not checked out; the caller puts the tree back either way.
|
|
395
|
+
*/
|
|
396
|
+
async function checkOutBase(root, base, git) {
|
|
397
|
+
let stash = null;
|
|
398
|
+
let setAside = false;
|
|
399
|
+
const done = (skip) => ({ stash, setAside, skip });
|
|
400
|
+
try {
|
|
401
|
+
const stashBefore = await refOf(root, "refs/stash", git);
|
|
402
|
+
const pushed = await git([
|
|
403
|
+
"stash",
|
|
404
|
+
"push",
|
|
405
|
+
"--include-untracked",
|
|
406
|
+
"--quiet",
|
|
407
|
+
"-m",
|
|
408
|
+
"yagni: the tree, set aside to measure the base",
|
|
409
|
+
], root);
|
|
410
|
+
const stashAfter = await refOf(root, "refs/stash", git);
|
|
411
|
+
if (stashAfter && stashAfter !== stashBefore)
|
|
412
|
+
stash = stashAfter;
|
|
413
|
+
if (pushed.code !== 0)
|
|
414
|
+
return done(`the tree could not be set aside: ${why(pushed)}`);
|
|
415
|
+
const left = await git(["status", "--porcelain", "--untracked-files=all"], root);
|
|
416
|
+
if (left.code !== 0 || left.stdout.trim())
|
|
417
|
+
return done("the tree was not clean once set aside");
|
|
418
|
+
setAside = true;
|
|
419
|
+
// A file only the base tracks, sitting here ignored (a build output the
|
|
420
|
+
// change stopped tracking), would be overwritten and then deleted; one
|
|
421
|
+
// in the way of its folder makes the checkout refuse.
|
|
422
|
+
const baseOnly = await git(["diff", "--name-only", "-z", "--no-renames", "--diff-filter=D", base, "HEAD"], root);
|
|
423
|
+
if (baseOnly.code !== 0)
|
|
424
|
+
return done(`git could not compare the base: ${why(baseOnly)}`);
|
|
425
|
+
const inTheWay = baseOnly.stdout
|
|
426
|
+
.split("\0")
|
|
427
|
+
.filter(Boolean)
|
|
428
|
+
.find((path) => {
|
|
429
|
+
try {
|
|
430
|
+
lstatSync(join(root, path));
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
if (inTheWay)
|
|
438
|
+
return done(`an ignored file is where the base has a tracked one: ${inTheWay}`);
|
|
439
|
+
const onBase = await git(["checkout", "--quiet", "--no-overwrite-ignore", "--detach", base, "--"], root);
|
|
440
|
+
if (onBase.code !== 0)
|
|
441
|
+
return done(`the base could not be checked out: ${why(onBase)}`);
|
|
442
|
+
return done(null);
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
return done(`the base could not be checked out: ${errorText(err)}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Put the tree back to `before`, or throw {@link BaseTreeRestoreError} saying
|
|
450
|
+
* what is wrong. Only a tree set aside whole is forced back to its branch and
|
|
451
|
+
* cleaned of what the measurement left; otherwise the stash, if the tree
|
|
452
|
+
* changed, goes back on the tree as it is. A `stash apply` that exits non-zero
|
|
453
|
+
* on a tree that is back all the same is not a failure.
|
|
454
|
+
*/
|
|
455
|
+
async function putBack(root, before, state, setAside, git) {
|
|
456
|
+
const { stash } = state;
|
|
457
|
+
const recover = stash ? ` Its uncommitted state is in the stash ${stash}.` : "";
|
|
458
|
+
const fail = (what) => {
|
|
459
|
+
throw new BaseTreeRestoreError(`the session's tree could not be put back after the base measurement: ${what}.${recover}`, state);
|
|
460
|
+
};
|
|
461
|
+
const read = async () => {
|
|
462
|
+
try {
|
|
463
|
+
return await fingerprint(root, git);
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
return fail(`the tree could not be read again: ${errorText(err)}`);
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
let applyFailed = null;
|
|
470
|
+
const apply = async () => {
|
|
471
|
+
if (!stash)
|
|
472
|
+
return;
|
|
473
|
+
const applied = await git(["stash", "apply", "--index", "--quiet", stash], root);
|
|
474
|
+
if (applied.code !== 0)
|
|
475
|
+
applyFailed = `git stash apply failed: ${why(applied)}`;
|
|
476
|
+
};
|
|
477
|
+
if (setAside) {
|
|
478
|
+
const back = await git(state.branch
|
|
479
|
+
? ["checkout", "--quiet", "--force", state.branch, "--"]
|
|
480
|
+
: ["checkout", "--quiet", "--force", "--detach", state.head, "--"], root);
|
|
481
|
+
if (back.code !== 0)
|
|
482
|
+
fail(`git checkout ${state.branch ?? state.head} failed: ${why(back)}`);
|
|
483
|
+
const cleaned = await git(["clean", "-fdq"], root);
|
|
484
|
+
if (cleaned.code !== 0)
|
|
485
|
+
fail(`git clean failed: ${why(cleaned)}`);
|
|
486
|
+
await apply();
|
|
487
|
+
}
|
|
488
|
+
else if (differences(before.fingerprint, await read()).length > 0) {
|
|
489
|
+
// Setting it aside stopped partway, after it took something.
|
|
490
|
+
await apply();
|
|
491
|
+
}
|
|
492
|
+
const problems = restoreUnrecorded(root, before);
|
|
493
|
+
const off = differences(before.fingerprint, await read());
|
|
494
|
+
if (off.length > 0 || problems.length > 0) {
|
|
495
|
+
fail([...(applyFailed ? [applyFailed] : []), ...off, ...problems].join("; "));
|
|
496
|
+
}
|
|
497
|
+
if (stash && (await refOf(root, "refs/stash", git)) === stash) {
|
|
498
|
+
await git(["stash", "drop", "--quiet"], root);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/** Put back what git keeps no record of: the untracked empty folders and the uncommitted files' modes. What could not be. */
|
|
502
|
+
function restoreUnrecorded(root, before) {
|
|
503
|
+
const problems = [];
|
|
504
|
+
for (const dir of before.emptyDirs) {
|
|
505
|
+
try {
|
|
506
|
+
mkdirSync(join(root, dir), { recursive: true });
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
problems.push(`the empty folder ${dir} could not be made again: ${errorText(err)}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
for (const [path, mode] of before.modes) {
|
|
513
|
+
try {
|
|
514
|
+
chmodSync(join(root, path), mode);
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
problems.push(`${path} could not be given its mode back: ${errorText(err)}`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return problems;
|
|
521
|
+
}
|
|
522
|
+
//# sourceMappingURL=baseTree.js.map
|
|
@@ -152,6 +152,19 @@ export declare function ticketOfBranch(branch: string | null): string | undefine
|
|
|
152
152
|
export declare function attemptCommitted(cwd: string, exec: WorktreeExec, attemptId: string): Promise<boolean | null>;
|
|
153
153
|
/** Read the execution's work off git. Never throws: a read that fails is a note. */
|
|
154
154
|
export declare function gitFacts(cwd: string, exec: WorktreeExec, baseSha: string): Promise<GitFacts>;
|
|
155
|
+
/**
|
|
156
|
+
* `result.json` for a session that stopped before it built anything, its
|
|
157
|
+
* tree not put back after the base measurement (baseTree.ts): a failed stop
|
|
158
|
+
* with why, on the head it started on, with nothing committed. Pure.
|
|
159
|
+
*/
|
|
160
|
+
export declare function unbuiltCloudResult(input: {
|
|
161
|
+
identity: CloudIdentity;
|
|
162
|
+
stage: CloudStage;
|
|
163
|
+
head: string;
|
|
164
|
+
branch: string | null;
|
|
165
|
+
roundsDone: number;
|
|
166
|
+
note: string;
|
|
167
|
+
}): AttemptResult;
|
|
155
168
|
/**
|
|
156
169
|
* The checks that gave no verdict because the sandbox could not run them,
|
|
157
170
|
* each with why, as one sentence for the person who reads the result: no
|
|
@@ -236,6 +236,30 @@ export async function gitFacts(cwd, exec, baseSha) {
|
|
|
236
236
|
notes,
|
|
237
237
|
};
|
|
238
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* `result.json` for a session that stopped before it built anything, its
|
|
241
|
+
* tree not put back after the base measurement (baseTree.ts): a failed stop
|
|
242
|
+
* with why, on the head it started on, with nothing committed. Pure.
|
|
243
|
+
*/
|
|
244
|
+
export function unbuiltCloudResult(input) {
|
|
245
|
+
return {
|
|
246
|
+
version: 1,
|
|
247
|
+
...input.identity,
|
|
248
|
+
stage: input.stage,
|
|
249
|
+
headSha: input.head,
|
|
250
|
+
branch: input.branch,
|
|
251
|
+
baseSha: input.head,
|
|
252
|
+
stopReason: "failed",
|
|
253
|
+
rounds: input.roundsDone,
|
|
254
|
+
checks: [],
|
|
255
|
+
findingsOpen: [],
|
|
256
|
+
filesChanged: [],
|
|
257
|
+
commits: [],
|
|
258
|
+
notes: [input.note],
|
|
259
|
+
blockingKey: null,
|
|
260
|
+
usage: { tokens: 0, costMillicents: 0, byStage: [] },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
239
263
|
/** Checks the cannot-run note names before "and N more". */
|
|
240
264
|
const MAX_NAMED_CANNOT_RUN = 5;
|
|
241
265
|
/**
|
|
@@ -70,6 +70,7 @@
|
|
|
70
70
|
* cannot finish, rather than being killed partway through one (see budget.ts). Such a run gets the sandbox lane's
|
|
71
71
|
* ceilings (`SANDBOX_RUN_BUDGET`), not interactive /go's.
|
|
72
72
|
*/
|
|
73
|
+
import { measureOnBaseTree } from "./baseTree.js";
|
|
73
74
|
import { snapshotWorkspace } from "./workspace.js";
|
|
74
75
|
import type { WorktreeExec } from "./worktree.js";
|
|
75
76
|
import { type TreeSnapshotDeps } from "./treeSnapshot.js";
|
|
@@ -183,6 +184,10 @@ export interface HeadlessGoDeps {
|
|
|
183
184
|
makeRunVerify?: typeof makeRunVerify;
|
|
184
185
|
/** The approved checks' run on the unchanged code (`measureOnBase`). Defaults to the real one; injected in tests. */
|
|
185
186
|
measureChecksOnBase?: ReturnType<typeof makeMeasureChecksOnBase>;
|
|
187
|
+
/** A resumed session's switch to the attempt's base commit and back (baseTree.ts). Defaults to real git; injected in tests. */
|
|
188
|
+
measureOnBaseTree?: typeof measureOnBaseTree;
|
|
189
|
+
/** The paths the session's HEAD changed since the attempt's base, null when git cannot say. Defaults to real git; injected in tests. */
|
|
190
|
+
pathsCommittedSince?: (cwd: string, base: string) => Promise<string[] | null>;
|
|
186
191
|
/** The verify gate's clean-tree baseline run (YAG-1003). Defaults to the real one; injected in tests. */
|
|
187
192
|
takeVerifyBaseline?: (cwd: string, dirs: string[], signal?: AbortSignal, budgetMs?: number) => Promise<VerifyBaselineResult>;
|
|
188
193
|
/** stdout sink; one call per NDJSON line, newline added by the caller. */
|
|
@@ -72,7 +72,8 @@
|
|
|
72
72
|
*/
|
|
73
73
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
74
74
|
import { dirname, join, relative } from "node:path";
|
|
75
|
-
import {
|
|
75
|
+
import { baseCommitOf, BaseTreeRestoreError, measureOnBaseTree, pathsCommittedSince, } from "./baseTree.js";
|
|
76
|
+
import { CLOUD_SESSION_ENV, completeCloudSession, prepareCloudLane, readCloudEnv, RESULT_FILE_NAME, unbuiltCloudResult, } from "./cloud.js";
|
|
76
77
|
import { scanChange } from "./changeScan.js";
|
|
77
78
|
import { continuationResumePlan, parseContinuation, } from "./continuation.js";
|
|
78
79
|
import { finishExec } from "./finish.js";
|
|
@@ -450,10 +451,9 @@ export async function runHeadlessGo(argv, deps = {}) {
|
|
|
450
451
|
// The gate's own checks, once on the clean tree before the build (YAG-1003),
|
|
451
452
|
// in the packages the approved plan names: what already failed there is a
|
|
452
453
|
// note, never a finding. Only a fresh build's tree is the clean base; a
|
|
453
|
-
// continuation or a fix round already carries a change, so
|
|
454
|
-
//
|
|
455
|
-
// the
|
|
456
|
-
// on `checks.json`, whose base standing the host took on the real base.
|
|
454
|
+
// continuation or a fix round already carries a change, so taking them on
|
|
455
|
+
// its own tree would excuse the attempt's own breakage. It takes them on
|
|
456
|
+
// the attempt's base commit instead (baseTree.ts), when its host names one.
|
|
457
457
|
let baseline;
|
|
458
458
|
const logPipeline = (event, data) => {
|
|
459
459
|
deps.logger?.(event, data);
|
|
@@ -467,6 +467,41 @@ export async function runHeadlessGo(argv, deps = {}) {
|
|
|
467
467
|
? (event) => logPipeline("verify_check", event)
|
|
468
468
|
: undefined;
|
|
469
469
|
const heard = onCheck ? { onCheck } : {};
|
|
470
|
+
/**
|
|
471
|
+
* A resumed session whose tree could not be put back after its base
|
|
472
|
+
* measurement stops before it builds. Its tree is not the one it started
|
|
473
|
+
* on, so it commits nothing; a cloud session reports a failed stop on the
|
|
474
|
+
* head it started on, so its host pushes nothing either.
|
|
475
|
+
*/
|
|
476
|
+
const stopUnbuilt = (err, base) => {
|
|
477
|
+
const message = `The session stops before it builds: ${err.message}`;
|
|
478
|
+
logPipeline("verify_base_tree", { base, error: err.message });
|
|
479
|
+
emit({ type: "pipeline_error", message });
|
|
480
|
+
writeErr(message);
|
|
481
|
+
if (cloud) {
|
|
482
|
+
const attempt = unbuiltCloudResult({
|
|
483
|
+
identity: cloud.identity,
|
|
484
|
+
stage: cloud.stage,
|
|
485
|
+
head: err.head,
|
|
486
|
+
branch: err.branch,
|
|
487
|
+
roundsDone: continuation?.reviewRoundsDone ?? 0,
|
|
488
|
+
note: message,
|
|
489
|
+
});
|
|
490
|
+
const path = join(cloud.outDir, RESULT_FILE_NAME);
|
|
491
|
+
try {
|
|
492
|
+
(deps.writeFile ?? writeFileSync)(path, `${JSON.stringify(attempt, null, 2)}\n`);
|
|
493
|
+
}
|
|
494
|
+
catch (writeError) {
|
|
495
|
+
// The stream's result then says why the file is missing, as a written one would.
|
|
496
|
+
const note = `${RESULT_FILE_NAME} could not be written: ${writeError instanceof Error ? writeError.message : String(writeError)}`;
|
|
497
|
+
attempt.notes.push(note);
|
|
498
|
+
logPipeline("result_write_failed", { path, error: note });
|
|
499
|
+
writeErr(note);
|
|
500
|
+
}
|
|
501
|
+
emit({ type: "pipeline_result", ...attempt });
|
|
502
|
+
}
|
|
503
|
+
return { exitCode: HEADLESS_GO_EXIT.unverified, error: message };
|
|
504
|
+
};
|
|
470
505
|
// A measurement before the build (the baseline, the plan's checks on the
|
|
471
506
|
// base) gets at most a sixth of the run's time left when it starts, and
|
|
472
507
|
// none below a minute: the build itself must still fit. No deadline, no cap.
|
|
@@ -479,46 +514,48 @@ export async function runHeadlessGo(argv, deps = {}) {
|
|
|
479
514
|
tooLittle: shareMs !== undefined && shareMs < MIN_BASELINE_BUDGET_MS,
|
|
480
515
|
};
|
|
481
516
|
};
|
|
482
|
-
|
|
483
|
-
|
|
517
|
+
/** The gate's detected checks on the tree as it is, in `dirs`; never throws. */
|
|
518
|
+
const takeBaseline = async (dirs) => {
|
|
484
519
|
const share = preBuildShare();
|
|
485
|
-
if (
|
|
520
|
+
if (share.tooLittle) {
|
|
486
521
|
logPipeline("verify_baseline", {
|
|
487
522
|
skipped: "too little of the run's time left",
|
|
488
523
|
deadlineLeftMs: share.leftMs,
|
|
489
524
|
baselineBudgetMs: share.shareMs,
|
|
490
525
|
});
|
|
526
|
+
return;
|
|
491
527
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
writeErr(`The verify baseline could not run (every failure counts): ${message}`);
|
|
507
|
-
}
|
|
528
|
+
try {
|
|
529
|
+
const taken = await (deps.takeVerifyBaseline ?? makeTakeVerifyBaseline(heard))(cwd, dirs, deps.signal, share.shareMs);
|
|
530
|
+
logPipeline("verify_baseline", {
|
|
531
|
+
ran: taken.ran,
|
|
532
|
+
failures: taken.failures,
|
|
533
|
+
notes: taken.notes,
|
|
534
|
+
});
|
|
535
|
+
if (taken.baseline.size > 0)
|
|
536
|
+
baseline = taken.baseline;
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
540
|
+
logPipeline("verify_baseline", { error: message });
|
|
541
|
+
writeErr(`The verify baseline could not run (every failure counts): ${message}`);
|
|
508
542
|
}
|
|
543
|
+
};
|
|
544
|
+
const resumed = Boolean(continuation || fixRound);
|
|
545
|
+
if (mission?.plan && !resumed && !deps.signal?.aborted) {
|
|
546
|
+
const dirs = baselineDirs(cwd, planPaths(mission.plan), readFile);
|
|
547
|
+
if (dirs.length > 0)
|
|
548
|
+
await takeBaseline(dirs);
|
|
509
549
|
}
|
|
510
550
|
// The approved checks the host has not seen pass on the unchanged code
|
|
511
551
|
// (`measureOnBase`: the plan's, and CI's when the repository's record has
|
|
512
552
|
// none of its own), each run once there before the build, on the same
|
|
513
553
|
// fresh tree the baseline above reads: a check that already failed there is
|
|
514
|
-
// not this change's to fix.
|
|
515
|
-
// so it measures nothing; its host hands it what an earlier session found.
|
|
554
|
+
// not this change's to fix.
|
|
516
555
|
let checksOnBase;
|
|
517
556
|
const measureFile = gateOpts?.repoChecks?.() ?? null;
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
!fixRound &&
|
|
521
|
-
!deps.signal?.aborted) {
|
|
557
|
+
/** The approved checks `file` marks to measure, on the tree as it is; never throws. */
|
|
558
|
+
const measureChecks = async (file) => {
|
|
522
559
|
const share = preBuildShare();
|
|
523
560
|
if (share.tooLittle) {
|
|
524
561
|
logPipeline("verify_checks_on_base", {
|
|
@@ -526,24 +563,87 @@ export async function runHeadlessGo(argv, deps = {}) {
|
|
|
526
563
|
leftMs: share.leftMs,
|
|
527
564
|
shareMs: share.shareMs,
|
|
528
565
|
});
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
const measured = await (deps.measureChecksOnBase ?? makeMeasureChecksOnBase(heard))(cwd, file, deps.signal, share.shareMs);
|
|
570
|
+
logPipeline("verify_checks_on_base", {
|
|
571
|
+
ran: measured.ran,
|
|
572
|
+
passed: [...measured.passed],
|
|
573
|
+
failed: [...measured.failed.keys()],
|
|
574
|
+
notes: measured.notes,
|
|
575
|
+
treeUndone: measured.treeUndone,
|
|
576
|
+
});
|
|
577
|
+
if (measured.passed.size > 0 || measured.failed.size > 0)
|
|
578
|
+
checksOnBase = measured;
|
|
579
|
+
}
|
|
580
|
+
catch (err) {
|
|
581
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
582
|
+
logPipeline("verify_checks_on_base", { error: message });
|
|
583
|
+
writeErr(`The approved checks could not run on the unchanged code (each keeps its checks.json standing): ${message}`);
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
if (measureFile?.checks.some((check) => check.measureOnBase) &&
|
|
587
|
+
!resumed &&
|
|
588
|
+
!deps.signal?.aborted) {
|
|
589
|
+
await measureChecks(measureFile);
|
|
590
|
+
}
|
|
591
|
+
// A one-attempt lane continuation or fix round starts past the attempt's
|
|
592
|
+
// earlier commits, so it measures both on the attempt's base commit
|
|
593
|
+
// (`continue.json` names it, YAG-1042), with the tree put back after (YAG-1056).
|
|
594
|
+
// Before, its host had nothing to hand it for the gate's own checks and only
|
|
595
|
+
// a stale record for a plan check, so every failure main already had was its
|
|
596
|
+
// to fix, and COREX-1291's sessions ended `round_cap` on main's lint errors
|
|
597
|
+
// one after another (production, 2026-10-26). It measures every approved
|
|
598
|
+
// check it can run there, not only those marked: the record's green is what
|
|
599
|
+
// went stale. Its baseline covers the plan's packages and those the attempt
|
|
600
|
+
// changed. A base it cannot measure on leaves the run as it was; a tree it
|
|
601
|
+
// cannot put back stops the session before it builds.
|
|
602
|
+
if (cloud && resumed && !deps.signal?.aborted) {
|
|
603
|
+
const base = baseCommitOf(continuation?.baseSha) ?? baseCommitOf(fixRound?.baseRef);
|
|
604
|
+
const share = preBuildShare();
|
|
605
|
+
if (!base) {
|
|
606
|
+
logPipeline("verify_base_tree", { skipped: "the host named no base commit" });
|
|
607
|
+
}
|
|
608
|
+
else if (share.tooLittle) {
|
|
609
|
+
logPipeline("verify_base_tree", {
|
|
610
|
+
base,
|
|
611
|
+
skipped: "too little of the run's time left",
|
|
612
|
+
leftMs: share.leftMs,
|
|
613
|
+
shareMs: share.shareMs,
|
|
614
|
+
});
|
|
529
615
|
}
|
|
530
616
|
else {
|
|
617
|
+
const onBaseFile = measureFile && {
|
|
618
|
+
...measureFile,
|
|
619
|
+
checks: measureFile.checks.map((check) => check.status === "cannot_run" ? check : { ...check, measureOnBase: true }),
|
|
620
|
+
};
|
|
621
|
+
const checksToMeasure = Boolean(onBaseFile?.checks.some((check) => check.status !== "cannot_run"));
|
|
531
622
|
try {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
623
|
+
// A list git cannot read narrows the baseline to the plan's packages: said.
|
|
624
|
+
const changed = await (deps.pathsCommittedSince ?? pathsCommittedSince)(cwd, base);
|
|
625
|
+
const unread = changed === null ? { changedPathsUnread: true } : {};
|
|
626
|
+
const dirs = baselineDirs(cwd, [...planPaths(mission?.plan ?? ""), ...(changed ?? [])], readFile);
|
|
627
|
+
const outcome = dirs.length === 0 && !checksToMeasure
|
|
628
|
+
? { kind: "skipped", reason: "nothing to measure" }
|
|
629
|
+
: await (deps.measureOnBaseTree ?? measureOnBaseTree)(cwd, base, async () => {
|
|
630
|
+
if (dirs.length > 0)
|
|
631
|
+
await takeBaseline(dirs);
|
|
632
|
+
if (onBaseFile && checksToMeasure)
|
|
633
|
+
await measureChecks(onBaseFile);
|
|
634
|
+
});
|
|
635
|
+
logPipeline("verify_base_tree", outcome.kind === "measured"
|
|
636
|
+
? { base, measured: true, ...unread }
|
|
637
|
+
: { base, skipped: outcome.reason, ...unread });
|
|
542
638
|
}
|
|
543
639
|
catch (err) {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
640
|
+
// Any other error came once the tree was back: only the measurement failed.
|
|
641
|
+
if (err instanceof BaseTreeRestoreError)
|
|
642
|
+
return stopUnbuilt(err, base);
|
|
643
|
+
logPipeline("verify_base_tree", {
|
|
644
|
+
base,
|
|
645
|
+
error: err instanceof Error ? err.message : String(err),
|
|
646
|
+
});
|
|
547
647
|
}
|
|
548
648
|
}
|
|
549
649
|
}
|
|
@@ -1734,9 +1734,10 @@ export function makeRunVerify(opts = {}) {
|
|
|
1734
1734
|
* failure with its output. A check that passed on the unchanged code
|
|
1735
1735
|
* (`passedOnBase`, measured in this environment) and now says it lacks a
|
|
1736
1736
|
* credential failed: the change made it need one, and that is the change's
|
|
1737
|
-
* (`needs` says what). Only
|
|
1738
|
-
* resumed session
|
|
1739
|
-
* what passed, so there the same check
|
|
1737
|
+
* (`needs` says what). Only a session that measured knows it passed: a
|
|
1738
|
+
* resumed session that could not measure on the attempt's base gets what
|
|
1739
|
+
* failed there from its host, never what passed, so there the same check
|
|
1740
|
+
* is could not run, failing open.
|
|
1740
1741
|
* The stream hears the command start, and its end here unless it failed:
|
|
1741
1742
|
* a failure's caller tells its end once it decided whose the failure is.
|
|
1742
1743
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.2.9-staging.
|
|
3
|
+
"version": "1.2.9-staging.2089.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -58,5 +58,5 @@
|
|
|
58
58
|
"turndown": "^7.2.4",
|
|
59
59
|
"typebox": "^1.3.15"
|
|
60
60
|
},
|
|
61
|
-
"yagniSourceSha": "
|
|
61
|
+
"yagniSourceSha": "0a52413870fd8ac25bc138527a51d189b5ee5322"
|
|
62
62
|
}
|