@webpieces/pr-gate 0.4.692 → 0.4.693
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 +2 -2
- package/src/scripts/commands/build-command.d.ts +3 -3
- package/src/scripts/commands/build-command.js +4 -4
- package/src/scripts/commands/build-command.js.map +1 -1
- package/src/scripts/commands/finish-upsert-pr-command.d.ts +12 -3
- package/src/scripts/commands/finish-upsert-pr-command.js +25 -10
- package/src/scripts/commands/finish-upsert-pr-command.js.map +1 -1
- package/src/scripts/commands/review-upsert-pr-command.d.ts +15 -1
- package/src/scripts/commands/review-upsert-pr-command.js +25 -4
- package/src/scripts/commands/review-upsert-pr-command.js.map +1 -1
- package/src/scripts/workflow/build-affected.d.ts +11 -30
- package/src/scripts/workflow/build-affected.js +25 -61
- package/src/scripts/workflow/build-affected.js.map +1 -1
- package/src/scripts/workflow/build-gate-log.d.ts +14 -29
- package/src/scripts/workflow/build-gate-log.js +34 -91
- package/src/scripts/workflow/build-gate-log.js.map +1 -1
- package/src/scripts/workflow/gate-log-file.d.ts +55 -0
- package/src/scripts/workflow/gate-log-file.js +136 -0
- package/src/scripts/workflow/gate-log-file.js.map +1 -0
- package/src/scripts/workflow/stage-output-log.d.ts +40 -0
- package/src/scripts/workflow/stage-output-log.js +137 -0
- package/src/scripts/workflow/stage-output-log.js.map +1 -0
|
@@ -4,9 +4,9 @@ exports.BuildGateLog = exports.BuildLogHeartbeat = exports.BUILD_LOG_NAME = expo
|
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const child_process_1 = require("child_process");
|
|
6
6
|
const fs = tslib_1.__importStar(require("fs"));
|
|
7
|
-
const path = tslib_1.__importStar(require("path"));
|
|
8
|
-
const rules_config_1 = require("@webpieces/rules-config");
|
|
9
7
|
const inversify_1 = require("inversify");
|
|
8
|
+
const gate_log_file_1 = require("./gate-log-file");
|
|
9
|
+
const stage_output_log_1 = require("./stage-output-log");
|
|
10
10
|
// ─── Why ───────────────────────────────────────────────────────────────────────────────────────────────
|
|
11
11
|
// The build gate already builds everything. When the output only ever went to the CONSOLE, an agent that
|
|
12
12
|
// wanted a different slice of it re-ran the WHOLE BUILD to get it: one measured session spent 23.9 minutes
|
|
@@ -28,6 +28,10 @@ const inversify_1 = require("inversify");
|
|
|
28
28
|
// `spawnSync` blocks the event loop for the length of the build, so NOTHING can print while it runs — and
|
|
29
29
|
// a silent terminal for 3–7 minutes is indistinguishable from a hang. The heartbeat is the reason this is
|
|
30
30
|
// async, and it is why `run` returns a Promise and `BuildAffected.runBuildGate` is async with it.
|
|
31
|
+
//
|
|
32
|
+
// It is also the reason the heartbeat goes through `StageOutputLog.say` rather than `process.stdout` —
|
|
33
|
+
// stage ② and stage ③ capture their own console output to a file, and a heartbeat captured into a file is
|
|
34
|
+
// a heartbeat nobody can see. The 600-second watchdog that kills a silent command does not read files.
|
|
31
35
|
/** How often the heartbeat reports the log's size. Hardcoded: a knob here would be a knob the PR gate's
|
|
32
36
|
* own build never receives, and the two must stay the same command. */
|
|
33
37
|
exports.HEARTBEAT_MS = 10_000;
|
|
@@ -44,7 +48,6 @@ exports.FINISH_STAGE = 'finish';
|
|
|
44
48
|
exports.BUILD_STAGE = 'build';
|
|
45
49
|
/** The one fixed log name — see BuildGateLog.fileNameFor for why only `wp-build` gets one. */
|
|
46
50
|
exports.BUILD_LOG_NAME = 'build.log';
|
|
47
|
-
const BACKUP_SUFFIX = '.bak';
|
|
48
51
|
/**
|
|
49
52
|
* The heartbeat's state: the line count reported on the PREVIOUS tick, so a tick that has not moved can
|
|
50
53
|
* say so. Stateful per RUN, which is why it is constructed per run rather than injected.
|
|
@@ -54,49 +57,39 @@ const BACKUP_SUFFIX = '.bak';
|
|
|
54
57
|
* BUILD from a stalled REPORTER.
|
|
55
58
|
*/
|
|
56
59
|
class BuildLogHeartbeat {
|
|
60
|
+
files;
|
|
57
61
|
logPath;
|
|
58
62
|
displayPath;
|
|
59
63
|
previous = null;
|
|
60
|
-
constructor(logPath, displayPath) {
|
|
64
|
+
constructor(files, logPath, displayPath) {
|
|
65
|
+
this.files = files;
|
|
61
66
|
this.logPath = logPath;
|
|
62
67
|
this.displayPath = displayPath;
|
|
63
68
|
}
|
|
64
69
|
/** One heartbeat line — `<path> size <n> lines`, plus ` still` when <n> has not moved. */
|
|
65
70
|
tick() {
|
|
66
|
-
const count = this.lineCount();
|
|
71
|
+
const count = this.files.lineCount(this.logPath);
|
|
67
72
|
const still = this.previous !== null && count === this.previous ? ' still' : '';
|
|
68
73
|
this.previous = count;
|
|
69
74
|
return `${this.displayPath} size ${count} lines${still}`;
|
|
70
75
|
}
|
|
71
|
-
// Lines currently in the log. A log that does not exist yet is zero lines, not an error: the build may
|
|
72
|
-
// simply not have written its first byte, and a heartbeat may never be the reason a build stops.
|
|
73
|
-
lineCount() {
|
|
74
|
-
if (!fs.existsSync(this.logPath))
|
|
75
|
-
return 0;
|
|
76
|
-
const body = fs.readFileSync(this.logPath, 'utf8');
|
|
77
|
-
if (body === '')
|
|
78
|
-
return 0;
|
|
79
|
-
return body.split('\n').length - (body.endsWith('\n') ? 1 : 0);
|
|
80
|
-
}
|
|
81
76
|
}
|
|
82
77
|
exports.BuildLogHeartbeat = BuildLogHeartbeat;
|
|
83
78
|
/**
|
|
84
79
|
* Captures the build gate's full output to a file, reports progress while it runs, and renders the
|
|
85
80
|
* pointer the caller is handed instead of a rebuild instruction.
|
|
86
81
|
*
|
|
82
|
+
* WHERE the file goes, how the previous run is kept, and what the pointer reads like are `GateLogFile`'s
|
|
83
|
+
* — one mechanism, shared with the stage console log, so there is a single answer to "where is it?".
|
|
84
|
+
*
|
|
87
85
|
* ─── Two naming schemes, one rule each ─────────────────────────────────────────────────────────────────
|
|
88
86
|
* • `wp-build` (BUILD_STAGE) writes ONE fixed path, `.webpieces/build.log`. It is fixed because a HUMAN
|
|
89
87
|
* OR AN AGENT TYPES IT — `grep -n error .webpieces/build.log` has to be writable from memory, and a
|
|
90
|
-
* name carrying a branch and a sha is not. History comes from the rotation
|
|
88
|
+
* name carrying a branch and a sha is not. History comes from the rotation instead.
|
|
91
89
|
* • stage ② and stage ③ write `logs/build-gate-<stage>-<branch>-<shortSha>.log`, because those two
|
|
92
90
|
* gates CAN run against one commit and a failure message from one must not be pointing at a file the
|
|
93
91
|
* other overwrote. Nobody types those names; the failure message prints them.
|
|
94
92
|
*
|
|
95
|
-
* ─── Rotation, everywhere ──────────────────────────────────────────────────────────────────────────────
|
|
96
|
-
* Every run moves an existing log to `<log>.bak` before writing, so the last TWO runs are always on disk.
|
|
97
|
-
* One rule for every stage: no branch, and the previous run of a re-run at the same commit survives
|
|
98
|
-
* instead of being truncated away.
|
|
99
|
-
*
|
|
100
93
|
* ─── Concurrency ───────────────────────────────────────────────────────────────────────────────────────
|
|
101
94
|
* The DIRECTORY is `dotWebpieces.local()`-scoped — `<primary>/.webpieces/worktrees/<git worktree name>/`
|
|
102
95
|
* in a linked worktree — so "N agents in N worktrees" is safe by construction rather than by naming. The
|
|
@@ -104,13 +97,17 @@ exports.BuildLogHeartbeat = BuildLogHeartbeat;
|
|
|
104
97
|
* driving one git index.
|
|
105
98
|
*/
|
|
106
99
|
let BuildGateLog = class BuildGateLog {
|
|
100
|
+
files;
|
|
101
|
+
stageConsole;
|
|
102
|
+
constructor(files, stageConsole) {
|
|
103
|
+
this.files = files;
|
|
104
|
+
this.stageConsole = stageConsole;
|
|
105
|
+
}
|
|
107
106
|
/** Absolute path of the log for `stage` at the current HEAD, creating its directory. */
|
|
108
107
|
pathFor(repoRoot, stage) {
|
|
109
|
-
|
|
110
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
111
|
-
return file;
|
|
108
|
+
return this.resolvePath(repoRoot, stage);
|
|
112
109
|
}
|
|
113
|
-
/** The same path
|
|
110
|
+
/** The same path, and '' when no log has been WRITTEN there yet. Used by finish's skip path. */
|
|
114
111
|
existingLogFor(repoRoot, stage) {
|
|
115
112
|
const file = this.resolvePath(repoRoot, stage);
|
|
116
113
|
return fs.existsSync(file) ? file : '';
|
|
@@ -123,31 +120,16 @@ let BuildGateLog = class BuildGateLog {
|
|
|
123
120
|
const sha = this.slug(this.git(repoRoot, ['rev-parse', '--short', 'HEAD']));
|
|
124
121
|
return `build-gate-${this.slug(stage)}-${branch === '' ? 'nobranch' : branch}-${sha === '' ? 'nosha' : sha}.log`;
|
|
125
122
|
}
|
|
126
|
-
/** Where the PREVIOUS run of `logPath` is kept — always `<logPath>.bak`. */
|
|
127
|
-
backupPathFor(logPath) {
|
|
128
|
-
return `${logPath}${BACKUP_SUFFIX}`;
|
|
129
|
-
}
|
|
130
|
-
/**
|
|
131
|
-
* Move an existing log aside to `<log>.bak`, overwriting any previous backup. A missing log is the
|
|
132
|
-
* normal first-run state and is not an error.
|
|
133
|
-
*/
|
|
134
|
-
rotate(logPath) {
|
|
135
|
-
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
136
|
-
if (!fs.existsSync(logPath))
|
|
137
|
-
return;
|
|
138
|
-
fs.rmSync(this.backupPathFor(logPath), { force: true });
|
|
139
|
-
fs.renameSync(logPath, this.backupPathFor(logPath));
|
|
140
|
-
}
|
|
141
123
|
/**
|
|
142
124
|
* Run `buildCommand` with its stdout AND stderr redirected in full to `logPath`, printing a heartbeat
|
|
143
|
-
*
|
|
144
|
-
*
|
|
125
|
+
* every HEARTBEAT_MS so the caller can see it is alive. Returns the BUILD's exit code. Nothing is
|
|
126
|
+
* truncated and nothing is streamed.
|
|
145
127
|
*/
|
|
146
128
|
async run(repoRoot, buildCommand, logPath) {
|
|
147
|
-
this.rotate(logPath);
|
|
129
|
+
this.files.rotate(logPath);
|
|
148
130
|
const fd = fs.openSync(logPath, 'w');
|
|
149
|
-
const heartbeat = new BuildLogHeartbeat(logPath, this.displayPath(repoRoot, logPath));
|
|
150
|
-
const timer = setInterval(() => {
|
|
131
|
+
const heartbeat = new BuildLogHeartbeat(this.files, logPath, this.files.displayPath(repoRoot, logPath));
|
|
132
|
+
const timer = setInterval(() => { this.stageConsole.say(`${heartbeat.tick()}\n`); }, exports.HEARTBEAT_MS);
|
|
151
133
|
// webpieces-disable no-unmanaged-exceptions -- chokepoint: the timer and the fd MUST be released
|
|
152
134
|
// whatever the child does, and the exit code is returned rather than thrown so runBuildGate owns
|
|
153
135
|
// the one CliExitError.
|
|
@@ -164,7 +146,7 @@ let BuildGateLog = class BuildGateLog {
|
|
|
164
146
|
* The success summary: the caller is told WHERE the full output is, not handed the output.
|
|
165
147
|
*/
|
|
166
148
|
successMessage(logPath) {
|
|
167
|
-
return `\nBuild success\n${this.
|
|
149
|
+
return `\nBuild success\n${this.files.pointer(logPath)}`;
|
|
168
150
|
}
|
|
169
151
|
/**
|
|
170
152
|
* The ENTIRE message the caller gets on a failed build. It names the log, echoes the last
|
|
@@ -176,46 +158,11 @@ let BuildGateLog = class BuildGateLog {
|
|
|
176
158
|
* agent guessing or rebuilding — so it is told to surface the contradiction to the human and stop.
|
|
177
159
|
*/
|
|
178
160
|
failureMessage(buildCommand, logPath) {
|
|
179
|
-
return `\nBuild Failed: ${buildCommand}\n${this.
|
|
180
|
-
`Last ${exports.FAILURE_TAIL_LINES} lines of that log:\n${this.tail(logPath)}\n` +
|
|
161
|
+
return `\nBuild Failed: ${buildCommand}\n${this.files.pointer(logPath)}\n` +
|
|
162
|
+
`Last ${exports.FAILURE_TAIL_LINES} lines of that log:\n${this.files.tail(logPath, exports.FAILURE_TAIL_LINES)}\n` +
|
|
181
163
|
`Read that FILE for the failures. Do NOT re-run the build to see them.\n` +
|
|
182
164
|
`If you do not see failures in that log, report that to the user and stop.\n`;
|
|
183
165
|
}
|
|
184
|
-
// The two lines that name the log, identical on success and failure so there is one thing to recognise.
|
|
185
|
-
// The backup line says what is TRUE RIGHT NOW: on the very first build in a tree there is no `.bak`
|
|
186
|
-
// yet, and pointing a reader at a file that does not exist is the small lie that costs a wasted `cat`.
|
|
187
|
-
logPointer(logPath) {
|
|
188
|
-
const name = path.basename(logPath);
|
|
189
|
-
const backedUp = fs.existsSync(this.backupPathFor(logPath))
|
|
190
|
-
? `(${name} is backed up to ${name}${BACKUP_SUFFIX} every run so you have the last 2 builds of logs)`
|
|
191
|
-
: `(the previous ${name} is kept as ${name}${BACKUP_SUFFIX} on every run — this is the first, so there is none yet)`;
|
|
192
|
-
return `FullLog : ${logPath}\n${backedUp}\n`;
|
|
193
|
-
}
|
|
194
|
-
/**
|
|
195
|
-
* The log's last FAILURE_TAIL_LINES lines, or a plain statement of why there are none.
|
|
196
|
-
*
|
|
197
|
-
* A read that fails is REPORTED, never allowed to throw: this renders the message for a build that has
|
|
198
|
-
* ALREADY failed, so an I/O error escaping here would replace the real failure with the renderer's own
|
|
199
|
-
* — the caller would lose the build error and be handed a filesystem error instead. The full log is
|
|
200
|
-
* still named on the line above, so nothing is hidden by degrading to one line.
|
|
201
|
-
*/
|
|
202
|
-
tail(logPath) {
|
|
203
|
-
if (!fs.existsSync(logPath))
|
|
204
|
-
return ` (no log file at ${logPath})\n`;
|
|
205
|
-
// webpieces-disable no-unmanaged-exceptions -- chokepoint: see above, the failure renderer may not
|
|
206
|
-
// replace the build's failure with its own.
|
|
207
|
-
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
208
|
-
try {
|
|
209
|
-
const lines = fs.readFileSync(logPath, 'utf8').split('\n').filter((l) => l !== '');
|
|
210
|
-
if (lines.length === 0)
|
|
211
|
-
return ' (the log is empty)\n';
|
|
212
|
-
return lines.slice(-exports.FAILURE_TAIL_LINES).map((l) => ` ${l}\n`).join('');
|
|
213
|
-
}
|
|
214
|
-
catch (err) {
|
|
215
|
-
const error = (0, rules_config_1.toError)(err);
|
|
216
|
-
return ` (could not read ${logPath}: ${error.message})\n`;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
166
|
// Resolve, and wait for, the child's exit code. A spawn that never starts (a shell that is missing, a
|
|
220
167
|
// cwd that vanished) fails CLOSED to 1 — calling a build that never ran green is the one outcome that
|
|
221
168
|
// must be impossible — and the reason is APPENDED TO THE LOG, so the failure message's pointer still
|
|
@@ -229,17 +176,11 @@ let BuildGateLog = class BuildGateLog {
|
|
|
229
176
|
child.on('close', (code) => { resolve(code ?? 1); });
|
|
230
177
|
});
|
|
231
178
|
}
|
|
232
|
-
// The path as the heartbeat shows it: relative to the repo when it sits inside it (a linked worktree's
|
|
233
|
-
// state lives under the PRIMARY clone, so it often does not), absolute otherwise.
|
|
234
|
-
displayPath(repoRoot, logPath) {
|
|
235
|
-
const relative = path.relative(repoRoot, logPath);
|
|
236
|
-
return relative === '' || relative.startsWith('..') || path.isAbsolute(relative) ? logPath : relative;
|
|
237
|
-
}
|
|
238
179
|
resolvePath(repoRoot, stage) {
|
|
239
180
|
const name = this.fileNameFor(repoRoot, stage);
|
|
240
181
|
// `wp-build`'s log sits at the ROOT of the state dir, not under `logs/`, because it is the one log
|
|
241
182
|
// path a person types from memory. Everything else keeps the per-commit names in `logs/`.
|
|
242
|
-
return stage === exports.BUILD_STAGE ?
|
|
183
|
+
return stage === exports.BUILD_STAGE ? this.files.localPath(repoRoot, name) : this.files.logsPath(repoRoot, name);
|
|
243
184
|
}
|
|
244
185
|
// Anything that is not a filename-safe character becomes '-', so `dean/feat` cannot create directories.
|
|
245
186
|
slug(value) {
|
|
@@ -256,6 +197,8 @@ let BuildGateLog = class BuildGateLog {
|
|
|
256
197
|
};
|
|
257
198
|
exports.BuildGateLog = BuildGateLog;
|
|
258
199
|
exports.BuildGateLog = BuildGateLog = tslib_1.__decorate([
|
|
259
|
-
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
|
|
200
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton),
|
|
201
|
+
tslib_1.__metadata("design:paramtypes", [gate_log_file_1.GateLogFile,
|
|
202
|
+
stage_output_log_1.StageOutputLog])
|
|
260
203
|
], BuildGateLog);
|
|
261
204
|
//# sourceMappingURL=build-gate-log.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-gate-log.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/build-gate-log.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn, spawnSync, ChildProcess } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { dotWebpieces, toError } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\n// ─── Why ───────────────────────────────────────────────────────────────────────────────────────────────\n// The build gate already builds everything. When the output only ever went to the CONSOLE, an agent that\n// wanted a different slice of it re-ran the WHOLE BUILD to get it: one measured session spent 23.9 minutes\n// across nine `nx affected` runs, five of them with NO code change in between — `| tail -50`, then\n// `> /tmp/file`, then `| grep`, then `| sed -n '1100,1230p'`. ~19 minutes spent re-reading a log.\n//\n// So the build's output is not streamed; it is REDIRECTED, in full, to a file whose path the caller is\n// handed on completion. Reading a different slice is then a `grep` of a FILE, not a second build.\n//\n// ─── Why a redirect and not `tee` ──────────────────────────────────────────────────────────────────────\n// An earlier cut of this used `cmd 2>&1 | tee log`, to keep the terminal byte-identical. That is what\n// makes the transcript expensive in the first place — an AI caller carries every line of it in context.\n// The redirect keeps the console to a handful of lines (a heartbeat, then a pointer at the file), which is\n// the entire productivity claim. Losing `tee` also deletes the `$?`-into-a-side-file dance it needed: in a\n// pipeline the shell reports TEE's status, which is 0 whether the build passed or failed, so the status\n// had to be smuggled out through a side file. With no pipe, the child's own exit code IS the answer.\n//\n// ─── Why async (`spawn`, not `spawnSync`) ──────────────────────────────────────────────────────────────\n// `spawnSync` blocks the event loop for the length of the build, so NOTHING can print while it runs — and\n// a silent terminal for 3–7 minutes is indistinguishable from a hang. The heartbeat is the reason this is\n// async, and it is why `run` returns a Promise and `BuildAffected.runBuildGate` is async with it.\n\n/** How often the heartbeat reports the log's size. Hardcoded: a knob here would be a knob the PR gate's\n * own build never receives, and the two must stay the same command. */\nexport const HEARTBEAT_MS = 10_000;\n\n/** How many trailing log lines the failure message echoes, so the immediate cause is visible without a\n * second command. Small on purpose — the FULL log is one grep away and the message must not become the\n * transcript it exists to replace. */\nexport const FAILURE_TAIL_LINES = 20;\n\n/**\n * Which stage's gate is being captured. The value decides the log FILENAME.\n */\nexport const REVIEW_STAGE = 'review';\nexport const FINISH_STAGE = 'finish';\n// `wp-build`, which is not a stage of the PR flow but runs the SAME gate (BuildAffected.runBuildGate).\nexport const BUILD_STAGE = 'build';\n\n/** The one fixed log name — see BuildGateLog.fileNameFor for why only `wp-build` gets one. */\nexport const BUILD_LOG_NAME = 'build.log';\nconst BACKUP_SUFFIX = '.bak';\n\n/**\n * The heartbeat's state: the line count reported on the PREVIOUS tick, so a tick that has not moved can\n * say so. Stateful per RUN, which is why it is constructed per run rather than injected.\n *\n * `still` is the load-bearing word. A build that is linking, or waiting on a cold nx cache, produces no\n * output for minutes; without `still` the caller sees the same number twice and cannot tell a stalled\n * BUILD from a stalled REPORTER.\n */\nexport class BuildLogHeartbeat {\n private previous: number | null = null;\n\n constructor(private readonly logPath: string, private readonly displayPath: string) {}\n\n /** One heartbeat line — `<path> size <n> lines`, plus ` still` when <n> has not moved. */\n tick(): string {\n const count = this.lineCount();\n const still = this.previous !== null && count === this.previous ? ' still' : '';\n this.previous = count;\n return `${this.displayPath} size ${count} lines${still}`;\n }\n\n // Lines currently in the log. A log that does not exist yet is zero lines, not an error: the build may\n // simply not have written its first byte, and a heartbeat may never be the reason a build stops.\n private lineCount(): number {\n if (!fs.existsSync(this.logPath)) return 0;\n const body = fs.readFileSync(this.logPath, 'utf8');\n if (body === '') return 0;\n return body.split('\\n').length - (body.endsWith('\\n') ? 1 : 0);\n }\n}\n\n/**\n * Captures the build gate's full output to a file, reports progress while it runs, and renders the\n * pointer the caller is handed instead of a rebuild instruction.\n *\n * ─── Two naming schemes, one rule each ─────────────────────────────────────────────────────────────────\n * • `wp-build` (BUILD_STAGE) writes ONE fixed path, `.webpieces/build.log`. It is fixed because a HUMAN\n * OR AN AGENT TYPES IT — `grep -n error .webpieces/build.log` has to be writable from memory, and a\n * name carrying a branch and a sha is not. History comes from the rotation below instead.\n * • stage ② and stage ③ write `logs/build-gate-<stage>-<branch>-<shortSha>.log`, because those two\n * gates CAN run against one commit and a failure message from one must not be pointing at a file the\n * other overwrote. Nobody types those names; the failure message prints them.\n *\n * ─── Rotation, everywhere ──────────────────────────────────────────────────────────────────────────────\n * Every run moves an existing log to `<log>.bak` before writing, so the last TWO runs are always on disk.\n * One rule for every stage: no branch, and the previous run of a re-run at the same commit survives\n * instead of being truncated away.\n *\n * ─── Concurrency ───────────────────────────────────────────────────────────────────────────────────────\n * The DIRECTORY is `dotWebpieces.local()`-scoped — `<primary>/.webpieces/worktrees/<git worktree name>/`\n * in a linked worktree — so \"N agents in N worktrees\" is safe by construction rather than by naming. The\n * residual case is two builds in the SAME worktree at once, which is already unsupported: both would be\n * driving one git index.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class BuildGateLog {\n /** Absolute path of the log for `stage` at the current HEAD, creating its directory. */\n pathFor(repoRoot: string, stage: string): string {\n const file = this.resolvePath(repoRoot, stage);\n fs.mkdirSync(path.dirname(file), { recursive: true });\n return file;\n }\n\n /** The same path WITHOUT creating anything, and '' when no such log exists. Used by finish's skip path. */\n existingLogFor(repoRoot: string, stage: string): string {\n const file = this.resolvePath(repoRoot, stage);\n return fs.existsSync(file) ? file : '';\n }\n\n /** `build.log` for `wp-build`; `build-gate-<stage>-<branch>-<shortSha>.log` for the PR-flow stages. */\n fileNameFor(repoRoot: string, stage: string): string {\n if (stage === BUILD_STAGE) return BUILD_LOG_NAME;\n const branch = this.slug(this.git(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']));\n const sha = this.slug(this.git(repoRoot, ['rev-parse', '--short', 'HEAD']));\n return `build-gate-${this.slug(stage)}-${branch === '' ? 'nobranch' : branch}-${sha === '' ? 'nosha' : sha}.log`;\n }\n\n /** Where the PREVIOUS run of `logPath` is kept — always `<logPath>.bak`. */\n backupPathFor(logPath: string): string {\n return `${logPath}${BACKUP_SUFFIX}`;\n }\n\n /**\n * Move an existing log aside to `<log>.bak`, overwriting any previous backup. A missing log is the\n * normal first-run state and is not an error.\n */\n rotate(logPath: string): void {\n fs.mkdirSync(path.dirname(logPath), { recursive: true });\n if (!fs.existsSync(logPath)) return;\n fs.rmSync(this.backupPathFor(logPath), { force: true });\n fs.renameSync(logPath, this.backupPathFor(logPath));\n }\n\n /**\n * Run `buildCommand` with its stdout AND stderr redirected in full to `logPath`, printing a heartbeat\n * to the console every HEARTBEAT_MS so the caller can see it is alive. Returns the BUILD's exit code.\n * Nothing is truncated and nothing is streamed.\n */\n async run(repoRoot: string, buildCommand: string, logPath: string): Promise<number> {\n this.rotate(logPath);\n const fd = fs.openSync(logPath, 'w');\n const heartbeat = new BuildLogHeartbeat(logPath, this.displayPath(repoRoot, logPath));\n const timer = setInterval((): void => { process.stdout.write(`${heartbeat.tick()}\\n`); }, HEARTBEAT_MS);\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: the timer and the fd MUST be released\n // whatever the child does, and the exit code is returned rather than thrown so runBuildGate owns\n // the one CliExitError.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return await this.awaitExit(spawn(buildCommand, { cwd: repoRoot, shell: true, stdio: ['ignore', fd, fd] }), fd);\n } finally {\n clearInterval(timer);\n fs.closeSync(fd);\n }\n }\n\n /**\n * The success summary: the caller is told WHERE the full output is, not handed the output.\n */\n successMessage(logPath: string): string {\n return `\\nBuild success\\n${this.logPointer(logPath)}`;\n }\n\n /**\n * The ENTIRE message the caller gets on a failed build. It names the log, echoes the last\n * FAILURE_TAIL_LINES lines so the immediate cause needs no second command, and forbids the rebuild\n * this whole file exists to prevent.\n *\n * The last sentence is not filler. If the log holds no visible failure then something upstream is wrong\n * (a runner that died without printing, a truncated redirect), and the worst possible response is an\n * agent guessing or rebuilding — so it is told to surface the contradiction to the human and stop.\n */\n failureMessage(buildCommand: string, logPath: string): string {\n return `\\nBuild Failed: ${buildCommand}\\n${this.logPointer(logPath)}\\n` +\n `Last ${FAILURE_TAIL_LINES} lines of that log:\\n${this.tail(logPath)}\\n` +\n `Read that FILE for the failures. Do NOT re-run the build to see them.\\n` +\n `If you do not see failures in that log, report that to the user and stop.\\n`;\n }\n\n // The two lines that name the log, identical on success and failure so there is one thing to recognise.\n // The backup line says what is TRUE RIGHT NOW: on the very first build in a tree there is no `.bak`\n // yet, and pointing a reader at a file that does not exist is the small lie that costs a wasted `cat`.\n private logPointer(logPath: string): string {\n const name = path.basename(logPath);\n const backedUp = fs.existsSync(this.backupPathFor(logPath))\n ? `(${name} is backed up to ${name}${BACKUP_SUFFIX} every run so you have the last 2 builds of logs)`\n : `(the previous ${name} is kept as ${name}${BACKUP_SUFFIX} on every run — this is the first, so there is none yet)`;\n return `FullLog : ${logPath}\\n${backedUp}\\n`;\n }\n\n /**\n * The log's last FAILURE_TAIL_LINES lines, or a plain statement of why there are none.\n *\n * A read that fails is REPORTED, never allowed to throw: this renders the message for a build that has\n * ALREADY failed, so an I/O error escaping here would replace the real failure with the renderer's own\n * — the caller would lose the build error and be handed a filesystem error instead. The full log is\n * still named on the line above, so nothing is hidden by degrading to one line.\n */\n private tail(logPath: string): string {\n if (!fs.existsSync(logPath)) return ` (no log file at ${logPath})\\n`;\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: see above, the failure renderer may not\n // replace the build's failure with its own.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const lines = fs.readFileSync(logPath, 'utf8').split('\\n').filter((l: string): boolean => l !== '');\n if (lines.length === 0) return ' (the log is empty)\\n';\n return lines.slice(-FAILURE_TAIL_LINES).map((l: string): string => ` ${l}\\n`).join('');\n } catch (err: unknown) {\n const error = toError(err);\n return ` (could not read ${logPath}: ${error.message})\\n`;\n }\n }\n\n // Resolve, and wait for, the child's exit code. A spawn that never starts (a shell that is missing, a\n // cwd that vanished) fails CLOSED to 1 — calling a build that never ran green is the one outcome that\n // must be impossible — and the reason is APPENDED TO THE LOG, so the failure message's pointer still\n // leads to it rather than to an empty file.\n private awaitExit(child: ChildProcess, fd: number): Promise<number> {\n return new Promise<number>((resolve: (code: number) => void): void => {\n child.on('error', (err: Error): void => {\n fs.writeSync(fd, `\\nThe build command could not be started: ${err.message}\\n`);\n resolve(1);\n });\n child.on('close', (code: number | null): void => { resolve(code ?? 1); });\n });\n }\n\n // The path as the heartbeat shows it: relative to the repo when it sits inside it (a linked worktree's\n // state lives under the PRIMARY clone, so it often does not), absolute otherwise.\n private displayPath(repoRoot: string, logPath: string): string {\n const relative = path.relative(repoRoot, logPath);\n return relative === '' || relative.startsWith('..') || path.isAbsolute(relative) ? logPath : relative;\n }\n\n private resolvePath(repoRoot: string, stage: string): string {\n const name = this.fileNameFor(repoRoot, stage);\n // `wp-build`'s log sits at the ROOT of the state dir, not under `logs/`, because it is the one log\n // path a person types from memory. Everything else keeps the per-commit names in `logs/`.\n return stage === BUILD_STAGE ? dotWebpieces.localFile(repoRoot, name) : dotWebpieces.logsFile(repoRoot, name);\n }\n\n // Anything that is not a filename-safe character becomes '-', so `dean/feat` cannot create directories.\n private slug(value: string): string {\n return value.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');\n }\n\n // One local git read. Fails SOFT to '' — a missing branch/sha degrades the FILENAME, and degrading a\n // filename may never be the reason a build gate does not run.\n private git(repoRoot: string, args: string[]): string {\n const result = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8' });\n if (result.status !== 0) return '';\n return (result.stdout ?? '').trim();\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"build-gate-log.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/build-gate-log.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn, spawnSync, ChildProcess } from 'child_process';\nimport * as fs from 'fs';\nimport { injectable, bindingScopeValues } from 'inversify';\n\nimport { GateLogFile } from './gate-log-file';\nimport { StageOutputLog } from './stage-output-log';\n\n// ─── Why ───────────────────────────────────────────────────────────────────────────────────────────────\n// The build gate already builds everything. When the output only ever went to the CONSOLE, an agent that\n// wanted a different slice of it re-ran the WHOLE BUILD to get it: one measured session spent 23.9 minutes\n// across nine `nx affected` runs, five of them with NO code change in between — `| tail -50`, then\n// `> /tmp/file`, then `| grep`, then `| sed -n '1100,1230p'`. ~19 minutes spent re-reading a log.\n//\n// So the build's output is not streamed; it is REDIRECTED, in full, to a file whose path the caller is\n// handed on completion. Reading a different slice is then a `grep` of a FILE, not a second build.\n//\n// ─── Why a redirect and not `tee` ──────────────────────────────────────────────────────────────────────\n// An earlier cut of this used `cmd 2>&1 | tee log`, to keep the terminal byte-identical. That is what\n// makes the transcript expensive in the first place — an AI caller carries every line of it in context.\n// The redirect keeps the console to a handful of lines (a heartbeat, then a pointer at the file), which is\n// the entire productivity claim. Losing `tee` also deletes the `$?`-into-a-side-file dance it needed: in a\n// pipeline the shell reports TEE's status, which is 0 whether the build passed or failed, so the status\n// had to be smuggled out through a side file. With no pipe, the child's own exit code IS the answer.\n//\n// ─── Why async (`spawn`, not `spawnSync`) ──────────────────────────────────────────────────────────────\n// `spawnSync` blocks the event loop for the length of the build, so NOTHING can print while it runs — and\n// a silent terminal for 3–7 minutes is indistinguishable from a hang. The heartbeat is the reason this is\n// async, and it is why `run` returns a Promise and `BuildAffected.runBuildGate` is async with it.\n//\n// It is also the reason the heartbeat goes through `StageOutputLog.say` rather than `process.stdout` —\n// stage ② and stage ③ capture their own console output to a file, and a heartbeat captured into a file is\n// a heartbeat nobody can see. The 600-second watchdog that kills a silent command does not read files.\n\n/** How often the heartbeat reports the log's size. Hardcoded: a knob here would be a knob the PR gate's\n * own build never receives, and the two must stay the same command. */\nexport const HEARTBEAT_MS = 10_000;\n\n/** How many trailing log lines the failure message echoes, so the immediate cause is visible without a\n * second command. Small on purpose — the FULL log is one grep away and the message must not become the\n * transcript it exists to replace. */\nexport const FAILURE_TAIL_LINES = 20;\n\n/**\n * Which stage's gate is being captured. The value decides the log FILENAME.\n */\nexport const REVIEW_STAGE = 'review';\nexport const FINISH_STAGE = 'finish';\n// `wp-build`, which is not a stage of the PR flow but runs the SAME gate (BuildAffected.runBuildGate).\nexport const BUILD_STAGE = 'build';\n\n/** The one fixed log name — see BuildGateLog.fileNameFor for why only `wp-build` gets one. */\nexport const BUILD_LOG_NAME = 'build.log';\n\n/**\n * The heartbeat's state: the line count reported on the PREVIOUS tick, so a tick that has not moved can\n * say so. Stateful per RUN, which is why it is constructed per run rather than injected.\n *\n * `still` is the load-bearing word. A build that is linking, or waiting on a cold nx cache, produces no\n * output for minutes; without `still` the caller sees the same number twice and cannot tell a stalled\n * BUILD from a stalled REPORTER.\n */\nexport class BuildLogHeartbeat {\n private previous: number | null = null;\n\n constructor(\n private readonly files: GateLogFile,\n private readonly logPath: string,\n private readonly displayPath: string,\n ) {}\n\n /** One heartbeat line — `<path> size <n> lines`, plus ` still` when <n> has not moved. */\n tick(): string {\n const count = this.files.lineCount(this.logPath);\n const still = this.previous !== null && count === this.previous ? ' still' : '';\n this.previous = count;\n return `${this.displayPath} size ${count} lines${still}`;\n }\n}\n\n/**\n * Captures the build gate's full output to a file, reports progress while it runs, and renders the\n * pointer the caller is handed instead of a rebuild instruction.\n *\n * WHERE the file goes, how the previous run is kept, and what the pointer reads like are `GateLogFile`'s\n * — one mechanism, shared with the stage console log, so there is a single answer to \"where is it?\".\n *\n * ─── Two naming schemes, one rule each ─────────────────────────────────────────────────────────────────\n * • `wp-build` (BUILD_STAGE) writes ONE fixed path, `.webpieces/build.log`. It is fixed because a HUMAN\n * OR AN AGENT TYPES IT — `grep -n error .webpieces/build.log` has to be writable from memory, and a\n * name carrying a branch and a sha is not. History comes from the rotation instead.\n * • stage ② and stage ③ write `logs/build-gate-<stage>-<branch>-<shortSha>.log`, because those two\n * gates CAN run against one commit and a failure message from one must not be pointing at a file the\n * other overwrote. Nobody types those names; the failure message prints them.\n *\n * ─── Concurrency ───────────────────────────────────────────────────────────────────────────────────────\n * The DIRECTORY is `dotWebpieces.local()`-scoped — `<primary>/.webpieces/worktrees/<git worktree name>/`\n * in a linked worktree — so \"N agents in N worktrees\" is safe by construction rather than by naming. The\n * residual case is two builds in the SAME worktree at once, which is already unsupported: both would be\n * driving one git index.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class BuildGateLog {\n constructor(\n private readonly files: GateLogFile,\n private readonly stageConsole: StageOutputLog,\n ) {}\n\n /** Absolute path of the log for `stage` at the current HEAD, creating its directory. */\n pathFor(repoRoot: string, stage: string): string {\n return this.resolvePath(repoRoot, stage);\n }\n\n /** The same path, and '' when no log has been WRITTEN there yet. Used by finish's skip path. */\n existingLogFor(repoRoot: string, stage: string): string {\n const file = this.resolvePath(repoRoot, stage);\n return fs.existsSync(file) ? file : '';\n }\n\n /** `build.log` for `wp-build`; `build-gate-<stage>-<branch>-<shortSha>.log` for the PR-flow stages. */\n fileNameFor(repoRoot: string, stage: string): string {\n if (stage === BUILD_STAGE) return BUILD_LOG_NAME;\n const branch = this.slug(this.git(repoRoot, ['rev-parse', '--abbrev-ref', 'HEAD']));\n const sha = this.slug(this.git(repoRoot, ['rev-parse', '--short', 'HEAD']));\n return `build-gate-${this.slug(stage)}-${branch === '' ? 'nobranch' : branch}-${sha === '' ? 'nosha' : sha}.log`;\n }\n\n /**\n * Run `buildCommand` with its stdout AND stderr redirected in full to `logPath`, printing a heartbeat\n * every HEARTBEAT_MS so the caller can see it is alive. Returns the BUILD's exit code. Nothing is\n * truncated and nothing is streamed.\n */\n async run(repoRoot: string, buildCommand: string, logPath: string): Promise<number> {\n this.files.rotate(logPath);\n const fd = fs.openSync(logPath, 'w');\n const heartbeat = new BuildLogHeartbeat(this.files, logPath, this.files.displayPath(repoRoot, logPath));\n const timer = setInterval((): void => { this.stageConsole.say(`${heartbeat.tick()}\\n`); }, HEARTBEAT_MS);\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: the timer and the fd MUST be released\n // whatever the child does, and the exit code is returned rather than thrown so runBuildGate owns\n // the one CliExitError.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return await this.awaitExit(spawn(buildCommand, { cwd: repoRoot, shell: true, stdio: ['ignore', fd, fd] }), fd);\n } finally {\n clearInterval(timer);\n fs.closeSync(fd);\n }\n }\n\n /**\n * The success summary: the caller is told WHERE the full output is, not handed the output.\n */\n successMessage(logPath: string): string {\n return `\\nBuild success\\n${this.files.pointer(logPath)}`;\n }\n\n /**\n * The ENTIRE message the caller gets on a failed build. It names the log, echoes the last\n * FAILURE_TAIL_LINES lines so the immediate cause needs no second command, and forbids the rebuild\n * this whole file exists to prevent.\n *\n * The last sentence is not filler. If the log holds no visible failure then something upstream is wrong\n * (a runner that died without printing, a truncated redirect), and the worst possible response is an\n * agent guessing or rebuilding — so it is told to surface the contradiction to the human and stop.\n */\n failureMessage(buildCommand: string, logPath: string): string {\n return `\\nBuild Failed: ${buildCommand}\\n${this.files.pointer(logPath)}\\n` +\n `Last ${FAILURE_TAIL_LINES} lines of that log:\\n${this.files.tail(logPath, FAILURE_TAIL_LINES)}\\n` +\n `Read that FILE for the failures. Do NOT re-run the build to see them.\\n` +\n `If you do not see failures in that log, report that to the user and stop.\\n`;\n }\n\n // Resolve, and wait for, the child's exit code. A spawn that never starts (a shell that is missing, a\n // cwd that vanished) fails CLOSED to 1 — calling a build that never ran green is the one outcome that\n // must be impossible — and the reason is APPENDED TO THE LOG, so the failure message's pointer still\n // leads to it rather than to an empty file.\n private awaitExit(child: ChildProcess, fd: number): Promise<number> {\n return new Promise<number>((resolve: (code: number) => void): void => {\n child.on('error', (err: Error): void => {\n fs.writeSync(fd, `\\nThe build command could not be started: ${err.message}\\n`);\n resolve(1);\n });\n child.on('close', (code: number | null): void => { resolve(code ?? 1); });\n });\n }\n\n private resolvePath(repoRoot: string, stage: string): string {\n const name = this.fileNameFor(repoRoot, stage);\n // `wp-build`'s log sits at the ROOT of the state dir, not under `logs/`, because it is the one log\n // path a person types from memory. Everything else keeps the per-commit names in `logs/`.\n return stage === BUILD_STAGE ? this.files.localPath(repoRoot, name) : this.files.logsPath(repoRoot, name);\n }\n\n // Anything that is not a filename-safe character becomes '-', so `dean/feat` cannot create directories.\n private slug(value: string): string {\n return value.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');\n }\n\n // One local git read. Fails SOFT to '' — a missing branch/sha degrades the FILENAME, and degrading a\n // filename may never be the reason a build gate does not run.\n private git(repoRoot: string, args: string[]): string {\n const result = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8' });\n if (result.status !== 0) return '';\n return (result.stdout ?? '').trim();\n }\n}\n"]}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file mechanics shared by every captured log in the PR gate: WHERE it goes, keeping the previous
|
|
3
|
+
* run, the pointer the caller prints instead of the output, and the failure tail.
|
|
4
|
+
*
|
|
5
|
+
* It knows nothing about builds, stages or console interception — its callers own all of that. Singleton
|
|
6
|
+
* because it is pure behaviour over paths; it holds no state.
|
|
7
|
+
*/
|
|
8
|
+
export declare class GateLogFile {
|
|
9
|
+
/**
|
|
10
|
+
* `<local()>/logs/<name>` — the ONE log directory, per worktree namespace. Creates the directory.
|
|
11
|
+
* Use for anything a MESSAGE points at; nobody types these names.
|
|
12
|
+
*/
|
|
13
|
+
logsPath(repoRoot: string, name: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* `<local()>/<name>` — the ROOT of the state dir, for the one log a PERSON types from memory
|
|
16
|
+
* (`.webpieces/build.log`). Creates the directory. Reach for `logsPath` unless the path itself has
|
|
17
|
+
* to be memorable.
|
|
18
|
+
*/
|
|
19
|
+
localPath(repoRoot: string, name: string): string;
|
|
20
|
+
/** Where the PREVIOUS run of `logPath` is kept — always `<logPath>.bak`. */
|
|
21
|
+
backupPathFor(logPath: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Move an existing log aside to `<log>.bak`, overwriting any previous backup, so the last TWO runs
|
|
24
|
+
* are always on disk. A missing log is the normal first-run state and is not an error.
|
|
25
|
+
*
|
|
26
|
+
* ONE generation, everywhere: a re-run at the same commit does not truncate away the run before it,
|
|
27
|
+
* and a reader never has to work out which of N dated files is the one they want.
|
|
28
|
+
*/
|
|
29
|
+
rotate(logPath: string): void;
|
|
30
|
+
/**
|
|
31
|
+
* The two lines that name a log — identical on success and on failure, so there is exactly one shape
|
|
32
|
+
* to recognise.
|
|
33
|
+
*
|
|
34
|
+
* The backup line states what is TRUE RIGHT NOW: on the very first run in a tree there is no `.bak`
|
|
35
|
+
* yet, and pointing a reader at a file that does not exist is the small lie that costs a wasted `cat`.
|
|
36
|
+
*/
|
|
37
|
+
pointer(logPath: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* The path as a progress line should show it: relative to the repo when it sits inside it (a linked
|
|
40
|
+
* worktree's state lives under the PRIMARY clone, so it usually does not), absolute otherwise.
|
|
41
|
+
*/
|
|
42
|
+
displayPath(repoRoot: string, logPath: string): string;
|
|
43
|
+
/**
|
|
44
|
+
* The log's last `lines` lines, indented, or a plain statement of why there are none.
|
|
45
|
+
*
|
|
46
|
+
* A read that fails is REPORTED, never allowed to throw: this renders a message for something that
|
|
47
|
+
* has ALREADY failed, so an I/O error escaping here would replace the real failure with the
|
|
48
|
+
* renderer's own — the caller would lose the actual error and be handed a filesystem error instead.
|
|
49
|
+
* The full log is still named on the line above, so nothing is hidden by degrading to one line.
|
|
50
|
+
*/
|
|
51
|
+
tail(logPath: string, lines: number): string;
|
|
52
|
+
/** How many lines the log holds right now. A log that does not exist yet is zero lines, not an error. */
|
|
53
|
+
lineCount(logPath: string): number;
|
|
54
|
+
private ensureDir;
|
|
55
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GateLogFile = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const rules_config_1 = require("@webpieces/rules-config");
|
|
8
|
+
const inversify_1 = require("inversify");
|
|
9
|
+
// ─── Why this class exists ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
// TWO things in this package write a big blob of output to a file and hand the caller a one-line pointer
|
|
11
|
+
// instead of the blob: the BUILD (BuildGateLog) and the STAGE CONSOLE (StageOutputLog). They are different
|
|
12
|
+
// subjects — one spawns a child and heartbeats, the other intercepts this process's own stdout — but the
|
|
13
|
+
// FILE half is identical: where the file lands, how the previous run is kept, and what the pointer at it
|
|
14
|
+
// reads like.
|
|
15
|
+
//
|
|
16
|
+
// That half lives here, once. Cloning it would have given the two logs two answers to "where is it?" and
|
|
17
|
+
// two rotation policies, and the whole value of the pointer is that a reader recognises it instantly and
|
|
18
|
+
// can `grep` it without first working out which subsystem wrote it.
|
|
19
|
+
//
|
|
20
|
+
// ─── WHERE the files land, and why that shape ──────────────────────────────────────────────────────────
|
|
21
|
+
// Through `dotWebpieces` — never `path.join(repoRoot, '.webpieces')`. In a LINKED WORKTREE `local()`
|
|
22
|
+
// resolves to `<primary>/.webpieces/worktrees/<git worktree name>/`, so:
|
|
23
|
+
// • N agents in N worktrees never write one another's log, by construction rather than by naming;
|
|
24
|
+
// • the history OUTLIVES the worktree — `wp-cleanup` reaps a tree's directory and the logs it wrote are
|
|
25
|
+
// still there under the primary clone, which is exactly when you want them;
|
|
26
|
+
// • it self-limits, because `.webpieces/worktrees/**` is already swept once a namespace goes stale.
|
|
27
|
+
// Nothing here needs a retention policy of its own.
|
|
28
|
+
/** The suffix of the ONE previous generation kept beside a log. */
|
|
29
|
+
const BACKUP_SUFFIX = '.bak';
|
|
30
|
+
/**
|
|
31
|
+
* The file mechanics shared by every captured log in the PR gate: WHERE it goes, keeping the previous
|
|
32
|
+
* run, the pointer the caller prints instead of the output, and the failure tail.
|
|
33
|
+
*
|
|
34
|
+
* It knows nothing about builds, stages or console interception — its callers own all of that. Singleton
|
|
35
|
+
* because it is pure behaviour over paths; it holds no state.
|
|
36
|
+
*/
|
|
37
|
+
let GateLogFile = class GateLogFile {
|
|
38
|
+
/**
|
|
39
|
+
* `<local()>/logs/<name>` — the ONE log directory, per worktree namespace. Creates the directory.
|
|
40
|
+
* Use for anything a MESSAGE points at; nobody types these names.
|
|
41
|
+
*/
|
|
42
|
+
logsPath(repoRoot, name) {
|
|
43
|
+
return this.ensureDir(rules_config_1.dotWebpieces.logsFile(repoRoot, name));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* `<local()>/<name>` — the ROOT of the state dir, for the one log a PERSON types from memory
|
|
47
|
+
* (`.webpieces/build.log`). Creates the directory. Reach for `logsPath` unless the path itself has
|
|
48
|
+
* to be memorable.
|
|
49
|
+
*/
|
|
50
|
+
localPath(repoRoot, name) {
|
|
51
|
+
return this.ensureDir(rules_config_1.dotWebpieces.localFile(repoRoot, name));
|
|
52
|
+
}
|
|
53
|
+
/** Where the PREVIOUS run of `logPath` is kept — always `<logPath>.bak`. */
|
|
54
|
+
backupPathFor(logPath) {
|
|
55
|
+
return `${logPath}${BACKUP_SUFFIX}`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Move an existing log aside to `<log>.bak`, overwriting any previous backup, so the last TWO runs
|
|
59
|
+
* are always on disk. A missing log is the normal first-run state and is not an error.
|
|
60
|
+
*
|
|
61
|
+
* ONE generation, everywhere: a re-run at the same commit does not truncate away the run before it,
|
|
62
|
+
* and a reader never has to work out which of N dated files is the one they want.
|
|
63
|
+
*/
|
|
64
|
+
rotate(logPath) {
|
|
65
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
66
|
+
if (!fs.existsSync(logPath))
|
|
67
|
+
return;
|
|
68
|
+
fs.rmSync(this.backupPathFor(logPath), { force: true });
|
|
69
|
+
fs.renameSync(logPath, this.backupPathFor(logPath));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The two lines that name a log — identical on success and on failure, so there is exactly one shape
|
|
73
|
+
* to recognise.
|
|
74
|
+
*
|
|
75
|
+
* The backup line states what is TRUE RIGHT NOW: on the very first run in a tree there is no `.bak`
|
|
76
|
+
* yet, and pointing a reader at a file that does not exist is the small lie that costs a wasted `cat`.
|
|
77
|
+
*/
|
|
78
|
+
pointer(logPath) {
|
|
79
|
+
const name = path.basename(logPath);
|
|
80
|
+
const backedUp = fs.existsSync(this.backupPathFor(logPath))
|
|
81
|
+
? `(${name} is backed up to ${name}${BACKUP_SUFFIX} every run so you have the last 2 runs of logs)`
|
|
82
|
+
: `(the previous ${name} is kept as ${name}${BACKUP_SUFFIX} on every run — this is the first, so there is none yet)`;
|
|
83
|
+
return `FullLog : ${logPath}\n${backedUp}\n`;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The path as a progress line should show it: relative to the repo when it sits inside it (a linked
|
|
87
|
+
* worktree's state lives under the PRIMARY clone, so it usually does not), absolute otherwise.
|
|
88
|
+
*/
|
|
89
|
+
displayPath(repoRoot, logPath) {
|
|
90
|
+
const relative = path.relative(repoRoot, logPath);
|
|
91
|
+
return relative === '' || relative.startsWith('..') || path.isAbsolute(relative) ? logPath : relative;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The log's last `lines` lines, indented, or a plain statement of why there are none.
|
|
95
|
+
*
|
|
96
|
+
* A read that fails is REPORTED, never allowed to throw: this renders a message for something that
|
|
97
|
+
* has ALREADY failed, so an I/O error escaping here would replace the real failure with the
|
|
98
|
+
* renderer's own — the caller would lose the actual error and be handed a filesystem error instead.
|
|
99
|
+
* The full log is still named on the line above, so nothing is hidden by degrading to one line.
|
|
100
|
+
*/
|
|
101
|
+
tail(logPath, lines) {
|
|
102
|
+
if (!fs.existsSync(logPath))
|
|
103
|
+
return ` (no log file at ${logPath})\n`;
|
|
104
|
+
// webpieces-disable no-unmanaged-exceptions -- chokepoint: see above, the failure renderer may not
|
|
105
|
+
// replace the caller's failure with its own.
|
|
106
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
107
|
+
try {
|
|
108
|
+
const all = fs.readFileSync(logPath, 'utf8').split('\n').filter((l) => l !== '');
|
|
109
|
+
if (all.length === 0)
|
|
110
|
+
return ' (the log is empty)\n';
|
|
111
|
+
return all.slice(-lines).map((l) => ` ${l}\n`).join('');
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
const error = (0, rules_config_1.toError)(err);
|
|
115
|
+
return ` (could not read ${logPath}: ${error.message})\n`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** How many lines the log holds right now. A log that does not exist yet is zero lines, not an error. */
|
|
119
|
+
lineCount(logPath) {
|
|
120
|
+
if (!fs.existsSync(logPath))
|
|
121
|
+
return 0;
|
|
122
|
+
const body = fs.readFileSync(logPath, 'utf8');
|
|
123
|
+
if (body === '')
|
|
124
|
+
return 0;
|
|
125
|
+
return body.split('\n').length - (body.endsWith('\n') ? 1 : 0);
|
|
126
|
+
}
|
|
127
|
+
ensureDir(file) {
|
|
128
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
129
|
+
return file;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
exports.GateLogFile = GateLogFile;
|
|
133
|
+
exports.GateLogFile = GateLogFile = tslib_1.__decorate([
|
|
134
|
+
(0, inversify_1.injectable)(inversify_1.bindingScopeValues.Singleton)
|
|
135
|
+
], GateLogFile);
|
|
136
|
+
//# sourceMappingURL=gate-log-file.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gate-log-file.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/pr-gate/src/scripts/workflow/gate-log-file.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'fs';\nimport * as path from 'path';\nimport { dotWebpieces, toError } from '@webpieces/rules-config';\nimport { injectable, bindingScopeValues } from 'inversify';\n\n// ─── Why this class exists ─────────────────────────────────────────────────────────────────────────────\n// TWO things in this package write a big blob of output to a file and hand the caller a one-line pointer\n// instead of the blob: the BUILD (BuildGateLog) and the STAGE CONSOLE (StageOutputLog). They are different\n// subjects — one spawns a child and heartbeats, the other intercepts this process's own stdout — but the\n// FILE half is identical: where the file lands, how the previous run is kept, and what the pointer at it\n// reads like.\n//\n// That half lives here, once. Cloning it would have given the two logs two answers to \"where is it?\" and\n// two rotation policies, and the whole value of the pointer is that a reader recognises it instantly and\n// can `grep` it without first working out which subsystem wrote it.\n//\n// ─── WHERE the files land, and why that shape ──────────────────────────────────────────────────────────\n// Through `dotWebpieces` — never `path.join(repoRoot, '.webpieces')`. In a LINKED WORKTREE `local()`\n// resolves to `<primary>/.webpieces/worktrees/<git worktree name>/`, so:\n// • N agents in N worktrees never write one another's log, by construction rather than by naming;\n// • the history OUTLIVES the worktree — `wp-cleanup` reaps a tree's directory and the logs it wrote are\n// still there under the primary clone, which is exactly when you want them;\n// • it self-limits, because `.webpieces/worktrees/**` is already swept once a namespace goes stale.\n// Nothing here needs a retention policy of its own.\n\n/** The suffix of the ONE previous generation kept beside a log. */\nconst BACKUP_SUFFIX = '.bak';\n\n/**\n * The file mechanics shared by every captured log in the PR gate: WHERE it goes, keeping the previous\n * run, the pointer the caller prints instead of the output, and the failure tail.\n *\n * It knows nothing about builds, stages or console interception — its callers own all of that. Singleton\n * because it is pure behaviour over paths; it holds no state.\n */\n@injectable(bindingScopeValues.Singleton)\nexport class GateLogFile {\n /**\n * `<local()>/logs/<name>` — the ONE log directory, per worktree namespace. Creates the directory.\n * Use for anything a MESSAGE points at; nobody types these names.\n */\n logsPath(repoRoot: string, name: string): string {\n return this.ensureDir(dotWebpieces.logsFile(repoRoot, name));\n }\n\n /**\n * `<local()>/<name>` — the ROOT of the state dir, for the one log a PERSON types from memory\n * (`.webpieces/build.log`). Creates the directory. Reach for `logsPath` unless the path itself has\n * to be memorable.\n */\n localPath(repoRoot: string, name: string): string {\n return this.ensureDir(dotWebpieces.localFile(repoRoot, name));\n }\n\n /** Where the PREVIOUS run of `logPath` is kept — always `<logPath>.bak`. */\n backupPathFor(logPath: string): string {\n return `${logPath}${BACKUP_SUFFIX}`;\n }\n\n /**\n * Move an existing log aside to `<log>.bak`, overwriting any previous backup, so the last TWO runs\n * are always on disk. A missing log is the normal first-run state and is not an error.\n *\n * ONE generation, everywhere: a re-run at the same commit does not truncate away the run before it,\n * and a reader never has to work out which of N dated files is the one they want.\n */\n rotate(logPath: string): void {\n fs.mkdirSync(path.dirname(logPath), { recursive: true });\n if (!fs.existsSync(logPath)) return;\n fs.rmSync(this.backupPathFor(logPath), { force: true });\n fs.renameSync(logPath, this.backupPathFor(logPath));\n }\n\n /**\n * The two lines that name a log — identical on success and on failure, so there is exactly one shape\n * to recognise.\n *\n * The backup line states what is TRUE RIGHT NOW: on the very first run in a tree there is no `.bak`\n * yet, and pointing a reader at a file that does not exist is the small lie that costs a wasted `cat`.\n */\n pointer(logPath: string): string {\n const name = path.basename(logPath);\n const backedUp = fs.existsSync(this.backupPathFor(logPath))\n ? `(${name} is backed up to ${name}${BACKUP_SUFFIX} every run so you have the last 2 runs of logs)`\n : `(the previous ${name} is kept as ${name}${BACKUP_SUFFIX} on every run — this is the first, so there is none yet)`;\n return `FullLog : ${logPath}\\n${backedUp}\\n`;\n }\n\n /**\n * The path as a progress line should show it: relative to the repo when it sits inside it (a linked\n * worktree's state lives under the PRIMARY clone, so it usually does not), absolute otherwise.\n */\n displayPath(repoRoot: string, logPath: string): string {\n const relative = path.relative(repoRoot, logPath);\n return relative === '' || relative.startsWith('..') || path.isAbsolute(relative) ? logPath : relative;\n }\n\n /**\n * The log's last `lines` lines, indented, or a plain statement of why there are none.\n *\n * A read that fails is REPORTED, never allowed to throw: this renders a message for something that\n * has ALREADY failed, so an I/O error escaping here would replace the real failure with the\n * renderer's own — the caller would lose the actual error and be handed a filesystem error instead.\n * The full log is still named on the line above, so nothing is hidden by degrading to one line.\n */\n tail(logPath: string, lines: number): string {\n if (!fs.existsSync(logPath)) return ` (no log file at ${logPath})\\n`;\n // webpieces-disable no-unmanaged-exceptions -- chokepoint: see above, the failure renderer may not\n // replace the caller's failure with its own.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const all = fs.readFileSync(logPath, 'utf8').split('\\n').filter((l: string): boolean => l !== '');\n if (all.length === 0) return ' (the log is empty)\\n';\n return all.slice(-lines).map((l: string): string => ` ${l}\\n`).join('');\n } catch (err: unknown) {\n const error = toError(err);\n return ` (could not read ${logPath}: ${error.message})\\n`;\n }\n }\n\n /** How many lines the log holds right now. A log that does not exist yet is zero lines, not an error. */\n lineCount(logPath: string): number {\n if (!fs.existsSync(logPath)) return 0;\n const body = fs.readFileSync(logPath, 'utf8');\n if (body === '') return 0;\n return body.split('\\n').length - (body.endsWith('\\n') ? 1 : 0);\n }\n\n private ensureDir(file: string): string {\n fs.mkdirSync(path.dirname(file), { recursive: true });\n return file;\n }\n}\n"]}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { GateLogFile } from './gate-log-file';
|
|
2
|
+
/** Stage ②'s console log. Named for the command, because that is what a reader is looking for. */
|
|
3
|
+
export declare const REVIEW_CONSOLE_LOG = "wp-review-upsert-pr.log";
|
|
4
|
+
/** Stage ③'s console log. */
|
|
5
|
+
export declare const FINISH_CONSOLE_LOG = "wp-finish-upsert-pr.log";
|
|
6
|
+
/**
|
|
7
|
+
* Captures a stage's verbose console output to a file, keeps the handful of lines the caller must act on
|
|
8
|
+
* immediately on the terminal, and hands back the one-line pointer at the file.
|
|
9
|
+
*
|
|
10
|
+
* Singleton, and stateful FOR THE DURATION OF ONE `withCapture` call: a bin runs exactly one stage.
|
|
11
|
+
* Re-entering it is a programming error, not a supported mode, and it throws rather than silently
|
|
12
|
+
* capturing into the inner file and restoring the outer one.
|
|
13
|
+
*/
|
|
14
|
+
export declare class StageOutputLog {
|
|
15
|
+
private readonly files;
|
|
16
|
+
constructor(files: GateLogFile);
|
|
17
|
+
private logPath;
|
|
18
|
+
private fd;
|
|
19
|
+
private original;
|
|
20
|
+
/**
|
|
21
|
+
* Run `body` with this process's stdout captured to `<logs>/<fileName>`, then restore stdout and
|
|
22
|
+
* print the pointer at the file.
|
|
23
|
+
*
|
|
24
|
+
* The pointer is printed in a `finally`, so it is there on the FAILURE path too — which is the path
|
|
25
|
+
* where a reader most needs it, and the one where an uncaptured stage would have scrolled the cause
|
|
26
|
+
* off the top of the terminal.
|
|
27
|
+
*/
|
|
28
|
+
withCapture<T>(repoRoot: string, fileName: string, body: () => Promise<T>): Promise<T>;
|
|
29
|
+
/**
|
|
30
|
+
* Print `text` to the TERMINAL as well as the log — for the few things a caller must act on before it
|
|
31
|
+
* can do anything else: the build heartbeat (a silent command is a killed command), the build result,
|
|
32
|
+
* the review instructions, the PR link, and anything naming a file to read.
|
|
33
|
+
*
|
|
34
|
+
* Outside a capture this is an ordinary write, so a class that calls it is not coupled to the stage.
|
|
35
|
+
*/
|
|
36
|
+
say(text: string): void;
|
|
37
|
+
private intercept;
|
|
38
|
+
private release;
|
|
39
|
+
private appendToLog;
|
|
40
|
+
}
|