agent-dealer 1.3.0 → 1.3.2
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/bundle/server/dist/adapters/git-worktree.js +38 -1
- package/bundle/server/dist/adapters/github.js +50 -0
- package/bundle/server/dist/adapters/github.test.js +89 -1
- package/bundle/server/dist/coordinator/admission.test.js +115 -0
- package/bundle/server/dist/coordinator/auto-merge.js +46 -9
- package/bundle/server/dist/coordinator/commands.js +89 -9
- package/bundle/server/dist/coordinator/commands.test.js +228 -2
- package/bundle/server/dist/coordinator/developer-effect.js +36 -0
- package/bundle/server/dist/coordinator/operator-verification.integration.test.js +102 -1
- package/bundle/server/dist/coordinator/product-scope-contract.test.js +249 -0
- package/bundle/server/dist/coordinator/prompts.js +57 -2
- package/bundle/server/dist/coordinator/prompts.test.js +236 -2
- package/bundle/server/dist/coordinator/reviewer-browser-probe.js +1083 -0
- package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +780 -0
- package/bundle/server/dist/coordinator/reviewer-effect.js +30 -1
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +54 -0
- package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +594 -0
- package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +496 -0
- package/bundle/server/dist/coordinator/source-reload.js +44 -9
- package/bundle/server/dist/coordinator/visual-qa-collection.test.js +619 -0
- package/bundle/server/dist/coordinator/visual-qa.js +630 -0
- package/bundle/server/dist/coordinator/visual-qa.test.js +493 -0
- package/bundle/server/dist/coordinator/worker-loop.js +1 -1
- package/bundle/server/dist/paths.js +24 -0
- package/bundle/server/dist/repository/admission-settings.js +5 -5
- package/bundle/server/dist/repository/admission-settings.test.js +71 -15
- package/bundle/server/dist/repository/issues.js +8 -1
- package/bundle/server/dist/repository/issues.test.js +34 -0
- package/bundle/server/dist/routes/issues-execution-contract.test.js +293 -0
- package/bundle/server/dist/routes/issues-source-attachments.test.js +277 -0
- package/bundle/server/dist/routes/issues.js +23 -8
- package/bundle/server/dist/routes/issues.test.js +80 -0
- package/bundle/server/dist/routes/queue.js +1 -1
- package/bundle/server/dist/routes/queue.test.js +55 -20
- package/bundle/server/dist/runners/process-registry.js +1 -1
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-DQ5ivcyg.js +64 -0
- package/bundle/server/static-ui/index.html +1 -1
- package/bundle/shared/dist/queue-entries.d.ts +5 -4
- package/bundle/shared/dist/queue-entries.js +5 -4
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/templates/prod.env.example +5 -2
- package/bundle/server/static-ui/assets/index-Bm74Vq_X.js +0 -64
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
// packages/server/src/coordinator/reviewer-visual-evidence.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-384: SHA-bound CI visual artifacts for reviewers.
|
|
4
|
+
//
|
|
5
|
+
// NOT-380's split is a trusted job capturing screenshots and the read-only
|
|
6
|
+
// reviewer judging them. The trusted job is the CI `Visual` workflow (NOT-383),
|
|
7
|
+
// which uploads `ui-screenshots` (head captures), `ui-baseline` (base captures),
|
|
8
|
+
// and `ui-diff` (per-route/viewport pixel diffs plus a SUMMARY.md naming head
|
|
9
|
+
// and base SHAs) at the required 1440x900 and 390x800 viewports. This module is
|
|
10
|
+
// the reviewer-side consumer Dealer was missing:
|
|
11
|
+
//
|
|
12
|
+
// 1. `isUiAffectingChange` — a pure classifier over the head's changed files,
|
|
13
|
+
// driven by the same path set as the `paths` filter in
|
|
14
|
+
// `.github/workflows/visual.yml` (`VISUAL_WORKFLOW_PATHS`, defined once
|
|
15
|
+
// here; a unit test pins it against the workflow file).
|
|
16
|
+
// 2. `classifyVisualEvidence` — a pure evidence classifier over a faked or
|
|
17
|
+
// real workflow run plus the downloaded artifact names. A run for any
|
|
18
|
+
// other SHA never counts.
|
|
19
|
+
// 3. `resolveReviewerVisualEvidence` — the async orchestration the reviewer
|
|
20
|
+
// effect runs after CI is green: look up the Visual run for exactly the
|
|
21
|
+
// head SHA through the `GithubAdapter`, download the three artifacts into
|
|
22
|
+
// Dealer's issue-artifact storage, stage a read-only copy inside the
|
|
23
|
+
// reviewer's worktree (outside tracked paths, ignored by `isWorktreeClean`),
|
|
24
|
+
// and record one `visual_evidence` artifact row. It never throws: every
|
|
25
|
+
// failure mode resolves to `missing` with a reason, and a missing or
|
|
26
|
+
// failed capture never counts as a visual pass.
|
|
27
|
+
// 4. `readVisualEvidenceHold` — the merge-gate reader (auto-merge.ts): a
|
|
28
|
+
// UI-affecting head whose recorded evidence is `missing` or `failed` holds
|
|
29
|
+
// for `operator_verification` instead of merging or queuing a repair round.
|
|
30
|
+
//
|
|
31
|
+
// Reviewers gain no browser, shell, or write grant — they judge staged PNGs
|
|
32
|
+
// and the SUMMARY.md with the file tools they already have.
|
|
33
|
+
import fs from "node:fs";
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import { REVIEWER_VISUAL_STAGING_DIR_NAME } from "../adapters/git-worktree.js";
|
|
36
|
+
import { getVisualEvidenceDir } from "../paths.js";
|
|
37
|
+
import { createIssueArtifact, latestIssueArtifact } from "../repository/artifacts.js";
|
|
38
|
+
/**
|
|
39
|
+
* The path set that makes a head UI-affecting — the same list as the `paths`
|
|
40
|
+
* filter in `.github/workflows/visual.yml` (both `pull_request` and `push`
|
|
41
|
+
* blocks). Defined once here; `isUiAffectingChange` and the pinning unit test
|
|
42
|
+
* both reference this constant, so a workflow edit without a classifier edit
|
|
43
|
+
* (or vice versa) fails loudly.
|
|
44
|
+
*/
|
|
45
|
+
export const VISUAL_WORKFLOW_PATHS = [
|
|
46
|
+
"apps/web/**",
|
|
47
|
+
"packages/shared/**",
|
|
48
|
+
"ui-screenshots.json",
|
|
49
|
+
"scripts/ci-visual/**",
|
|
50
|
+
".github/workflows/visual.yml",
|
|
51
|
+
];
|
|
52
|
+
/** The workflow file name as `gh run list --workflow` accepts it. */
|
|
53
|
+
export const VISUAL_WORKFLOW_FILE = "visual.yml";
|
|
54
|
+
/** The three artifacts NOT-383's `Visual` job uploads for every UI run. */
|
|
55
|
+
export const VISUAL_ARTIFACT_NAMES = ["ui-screenshots", "ui-baseline", "ui-diff"];
|
|
56
|
+
/** Issue-artifact kind holding one recorded `VisualEvidenceRecord` per review. */
|
|
57
|
+
export const VISUAL_EVIDENCE_ARTIFACT_KIND = "visual_evidence";
|
|
58
|
+
/** Max SUMMARY.md characters embedded in the reviewer prompt. */
|
|
59
|
+
export const VISUAL_SUMMARY_MAX_CHARS = 4000;
|
|
60
|
+
/** Max staged file names listed per artifact in the reviewer prompt. */
|
|
61
|
+
export const VISUAL_FILE_LISTING_MAX_PER_ARTIFACT = 20;
|
|
62
|
+
function normalizeChangedPath(file) {
|
|
63
|
+
let out = file.trim();
|
|
64
|
+
while (out.startsWith("./"))
|
|
65
|
+
out = out.slice(2);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/** GitHub `paths`-filter semantics for this module's small pattern set:
|
|
69
|
+
* `dir/**` matches the dir itself and everything under it; anything else is
|
|
70
|
+
* an exact repo-relative path. */
|
|
71
|
+
function visualPathMatches(pattern, file) {
|
|
72
|
+
if (pattern.endsWith("/**")) {
|
|
73
|
+
const dir = pattern.slice(0, -3);
|
|
74
|
+
return file === dir || file.startsWith(`${dir}/`);
|
|
75
|
+
}
|
|
76
|
+
return file === pattern;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* True when any changed file falls under `VISUAL_WORKFLOW_PATHS` — i.e. the
|
|
80
|
+
* head is one the CI `Visual` workflow would capture. Pure.
|
|
81
|
+
*/
|
|
82
|
+
export function isUiAffectingChange(changedFiles) {
|
|
83
|
+
for (const raw of changedFiles) {
|
|
84
|
+
const file = normalizeChangedPath(raw);
|
|
85
|
+
if (!file)
|
|
86
|
+
continue;
|
|
87
|
+
for (const pattern of VISUAL_WORKFLOW_PATHS) {
|
|
88
|
+
if (visualPathMatches(pattern, file))
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
/** Conclusions where the run completed and its captures are trustworthy. */
|
|
95
|
+
const SUCCESS_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
|
|
96
|
+
/** Conclusions where the run reached a verdict and its captures are unusable. */
|
|
97
|
+
const FAILED_CONCLUSIONS = new Set([
|
|
98
|
+
"failure",
|
|
99
|
+
"cancelled",
|
|
100
|
+
"timed_out",
|
|
101
|
+
"action_required",
|
|
102
|
+
"startup_failure",
|
|
103
|
+
]);
|
|
104
|
+
/**
|
|
105
|
+
* Classifies CI visual evidence for exactly `headSha`. Pure — the run and the
|
|
106
|
+
* artifact names are inputs, so tests fake them.
|
|
107
|
+
*
|
|
108
|
+
* - `not_applicable`: the head is not UI-affecting (nothing to capture).
|
|
109
|
+
* - `missing`: no run for this head, the run targets another SHA, the run has
|
|
110
|
+
* not completed, or a completed-successful run lacks any of the three
|
|
111
|
+
* artifacts. A stale-SHA run is never evidence, however green.
|
|
112
|
+
* - `failed`: the run for this head completed with a failure conclusion.
|
|
113
|
+
* - `available`: a completed-successful run for this head with all three
|
|
114
|
+
* artifacts present.
|
|
115
|
+
*/
|
|
116
|
+
export function classifyVisualEvidence(opts) {
|
|
117
|
+
if (!opts.uiAffecting)
|
|
118
|
+
return "not_applicable";
|
|
119
|
+
const run = opts.run;
|
|
120
|
+
if (!run)
|
|
121
|
+
return "missing";
|
|
122
|
+
if (run.headSha !== opts.headSha)
|
|
123
|
+
return "missing";
|
|
124
|
+
const status = (run.status ?? "").toLowerCase();
|
|
125
|
+
if (status !== "completed")
|
|
126
|
+
return "missing";
|
|
127
|
+
const conclusion = (run.conclusion ?? "").toLowerCase();
|
|
128
|
+
if (FAILED_CONCLUSIONS.has(conclusion))
|
|
129
|
+
return "failed";
|
|
130
|
+
if (!SUCCESS_CONCLUSIONS.has(conclusion))
|
|
131
|
+
return "missing";
|
|
132
|
+
const present = new Set(opts.artifactNames);
|
|
133
|
+
return VISUAL_ARTIFACT_NAMES.every((name) => present.has(name)) ? "available" : "missing";
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Wraps the coordinator's existing GitHub access (`gh` via `GithubAdapter`).
|
|
137
|
+
* Null when the adapter predates the NOT-384 methods — the caller records
|
|
138
|
+
* `missing` (lookup unavailable), never "no run".
|
|
139
|
+
*/
|
|
140
|
+
export function githubVisualEvidenceFetcher(github) {
|
|
141
|
+
const list = github.listWorkflowRunsForCommit?.bind(github);
|
|
142
|
+
const download = github.downloadRunArtifact?.bind(github);
|
|
143
|
+
if (!list || !download)
|
|
144
|
+
return null;
|
|
145
|
+
return {
|
|
146
|
+
async findRunForHead({ cwd, headSha }) {
|
|
147
|
+
const runs = await list({ cwd, workflow: VISUAL_WORKFLOW_FILE, commitSha: headSha });
|
|
148
|
+
// `--commit` already filters server-side; re-verify client-side so a run
|
|
149
|
+
// for any other SHA can never slip through.
|
|
150
|
+
return runs.find((run) => run.headSha === headSha) ?? null;
|
|
151
|
+
},
|
|
152
|
+
async downloadArtifact({ cwd, runId, name, destDir }) {
|
|
153
|
+
await download({ cwd, runId, name, destDir });
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/** Dealer-owned bytes for one head's downloaded artifacts. */
|
|
158
|
+
export function visualEvidenceStoreDir(issueId, headSha) {
|
|
159
|
+
for (const part of [issueId, headSha]) {
|
|
160
|
+
if (!part || part.includes("/") || part.includes("\\") || part === "." || part === "..") {
|
|
161
|
+
throw new Error(`refusing to store visual evidence under unsafe path part: ${JSON.stringify(part)}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const dir = path.join(getVisualEvidenceDir(), issueId, headSha);
|
|
165
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
166
|
+
return dir;
|
|
167
|
+
}
|
|
168
|
+
/** The staging directory inside the reviewer worktree (see
|
|
169
|
+
* `REVIEWER_VISUAL_STAGING_DIR_NAME`). */
|
|
170
|
+
export function reviewerVisualStagingDir(worktreePath) {
|
|
171
|
+
return path.join(worktreePath, REVIEWER_VISUAL_STAGING_DIR_NAME);
|
|
172
|
+
}
|
|
173
|
+
/** True when `dir` holds at least one regular file anywhere under it. Bounded
|
|
174
|
+
* so a pathological tree cannot hang the check. */
|
|
175
|
+
function dirHasFiles(dir, budget = 5000) {
|
|
176
|
+
let remaining = budget;
|
|
177
|
+
const stack = [dir];
|
|
178
|
+
while (stack.length > 0) {
|
|
179
|
+
if (remaining-- <= 0)
|
|
180
|
+
return true;
|
|
181
|
+
const current = stack.pop();
|
|
182
|
+
let entries;
|
|
183
|
+
try {
|
|
184
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
for (const entry of entries) {
|
|
190
|
+
if (entry.isSymbolicLink())
|
|
191
|
+
continue;
|
|
192
|
+
if (entry.isFile())
|
|
193
|
+
return true;
|
|
194
|
+
if (entry.isDirectory())
|
|
195
|
+
stack.push(path.join(current, entry.name));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Canonical downloaded layout: `gh run download --name` extracts directly
|
|
202
|
+
* into `destDir`, but if a nested `<destDir>/<name>/` directory holds files
|
|
203
|
+
* instead (older `gh` layouts nest single artifacts), move them up so staging
|
|
204
|
+
* and SUMMARY.md lookup see one shape.
|
|
205
|
+
*/
|
|
206
|
+
function normalizeDownloadedLayout(destDir, name) {
|
|
207
|
+
const nested = path.join(destDir, name);
|
|
208
|
+
let nestedStat;
|
|
209
|
+
try {
|
|
210
|
+
nestedStat = fs.statSync(nested);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (!nestedStat.isDirectory())
|
|
216
|
+
return;
|
|
217
|
+
let entries;
|
|
218
|
+
try {
|
|
219
|
+
entries = fs.readdirSync(nested, { withFileTypes: true });
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
for (const entry of entries) {
|
|
225
|
+
if (entry.isSymbolicLink())
|
|
226
|
+
continue;
|
|
227
|
+
const target = path.join(destDir, entry.name);
|
|
228
|
+
if (fs.existsSync(target))
|
|
229
|
+
continue;
|
|
230
|
+
fs.renameSync(path.join(nested, entry.name), target);
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
fs.rmdirSync(nested);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// a leftover nested dir is harmless — the files already moved up
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/** Sorted top-level file names under `dir` (bounded; "" when unreadable). */
|
|
240
|
+
function listTopLevelFiles(dir, limit) {
|
|
241
|
+
let entries;
|
|
242
|
+
try {
|
|
243
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return { files: [], total: 0 };
|
|
247
|
+
}
|
|
248
|
+
const files = entries
|
|
249
|
+
.filter((entry) => !entry.isSymbolicLink() && entry.isFile())
|
|
250
|
+
.map((entry) => entry.name)
|
|
251
|
+
.sort();
|
|
252
|
+
return { files: files.slice(0, limit), total: files.length };
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Copies one downloaded artifact into the staging tree: directories 0755,
|
|
256
|
+
* files 0444 (a read-only copy — the reviewer must judge it, never edit it).
|
|
257
|
+
* Symlinks are never followed or recreated. Every destination is built from
|
|
258
|
+
* `readdir` entry names, so nothing can escape `destRoot`.
|
|
259
|
+
*/
|
|
260
|
+
function copyTreeReadOnly(srcRoot, destRoot) {
|
|
261
|
+
fs.mkdirSync(destRoot, { recursive: true, mode: 0o755 });
|
|
262
|
+
const stack = [{ src: srcRoot, dest: destRoot }];
|
|
263
|
+
while (stack.length > 0) {
|
|
264
|
+
const { src, dest } = stack.pop();
|
|
265
|
+
let entries;
|
|
266
|
+
try {
|
|
267
|
+
entries = fs.readdirSync(src, { withFileTypes: true });
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
for (const entry of entries) {
|
|
273
|
+
if (entry.isSymbolicLink())
|
|
274
|
+
continue;
|
|
275
|
+
const srcPath = path.join(src, entry.name);
|
|
276
|
+
const destPath = path.join(dest, entry.name);
|
|
277
|
+
if (entry.isDirectory()) {
|
|
278
|
+
fs.mkdirSync(destPath, { recursive: true, mode: 0o755 });
|
|
279
|
+
stack.push({ src: srcPath, dest: destPath });
|
|
280
|
+
}
|
|
281
|
+
else if (entry.isFile()) {
|
|
282
|
+
fs.copyFileSync(srcPath, destPath);
|
|
283
|
+
fs.chmodSync(destPath, 0o444);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Stages the three downloaded artifacts as a read-only copy under
|
|
290
|
+
* `<worktree>/.agent-dealer-visual/` (outside tracked paths, ignored by
|
|
291
|
+
* `isWorktreeClean`). Idempotent — an existing staging dir is replaced.
|
|
292
|
+
*/
|
|
293
|
+
export function stageVisualArtifacts(opts) {
|
|
294
|
+
const stagedDir = reviewerVisualStagingDir(opts.worktreePath);
|
|
295
|
+
fs.rmSync(stagedDir, { recursive: true, force: true });
|
|
296
|
+
fs.mkdirSync(stagedDir, { recursive: true, mode: 0o755 });
|
|
297
|
+
const screenshotsDir = path.join(stagedDir, "ui-screenshots");
|
|
298
|
+
const baselineDir = path.join(stagedDir, "ui-baseline");
|
|
299
|
+
const diffDir = path.join(stagedDir, "ui-diff");
|
|
300
|
+
copyTreeReadOnly(path.join(opts.storeDir, "ui-screenshots"), screenshotsDir);
|
|
301
|
+
copyTreeReadOnly(path.join(opts.storeDir, "ui-baseline"), baselineDir);
|
|
302
|
+
copyTreeReadOnly(path.join(opts.storeDir, "ui-diff"), diffDir);
|
|
303
|
+
const summaryPath = path.join(diffDir, "SUMMARY.md");
|
|
304
|
+
return {
|
|
305
|
+
stagedDir,
|
|
306
|
+
screenshotsDir,
|
|
307
|
+
baselineDir,
|
|
308
|
+
diffDir,
|
|
309
|
+
summaryPath: fs.existsSync(summaryPath) ? summaryPath : null,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/** Removes the staging directory (best-effort; absent is fine). Called before
|
|
313
|
+
* worktree removal so no residue survives the attempt even if that fails. */
|
|
314
|
+
export function cleanupStagedVisualArtifacts(worktreePath) {
|
|
315
|
+
fs.rmSync(reviewerVisualStagingDir(worktreePath), { recursive: true, force: true });
|
|
316
|
+
}
|
|
317
|
+
/** Bounded `SUMMARY.md` text, or null when the file is absent/unreadable. */
|
|
318
|
+
export function readVisualDiffSummary(stagedDiffDir, maxChars = VISUAL_SUMMARY_MAX_CHARS) {
|
|
319
|
+
let text;
|
|
320
|
+
try {
|
|
321
|
+
text = fs.readFileSync(path.join(stagedDiffDir, "SUMMARY.md"), "utf8");
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
const trimmed = text.trim();
|
|
327
|
+
if (!trimmed)
|
|
328
|
+
return null;
|
|
329
|
+
if (trimmed.length <= maxChars)
|
|
330
|
+
return trimmed;
|
|
331
|
+
return `${trimmed.slice(0, maxChars).trimEnd()}\n… (truncated, full file in the staged ui-diff directory)`;
|
|
332
|
+
}
|
|
333
|
+
function truncateReason(text, maxChars = 300) {
|
|
334
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
335
|
+
return oneLine.length > maxChars ? `${oneLine.slice(0, maxChars).trimEnd()}…` : oneLine;
|
|
336
|
+
}
|
|
337
|
+
function noEvidence(uiAffecting, base, reason) {
|
|
338
|
+
return {
|
|
339
|
+
...base,
|
|
340
|
+
uiAffecting,
|
|
341
|
+
state: "missing",
|
|
342
|
+
runId: null,
|
|
343
|
+
artifactNames: [],
|
|
344
|
+
stagedDir: null,
|
|
345
|
+
summaryChars: null,
|
|
346
|
+
reason,
|
|
347
|
+
staged: null,
|
|
348
|
+
summary: null,
|
|
349
|
+
files: null,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Resolves CI visual evidence for one reviewer attempt (runs after CI is
|
|
354
|
+
* green): classify the head, look up the Visual run for exactly the head SHA,
|
|
355
|
+
* download the artifacts into Dealer storage, stage them read-only in the
|
|
356
|
+
* worktree when usable, and record one `visual_evidence` artifact row.
|
|
357
|
+
*
|
|
358
|
+
* Never throws and never returns usable evidence it does not have: unknown
|
|
359
|
+
* changed files fail closed to UI-affecting + `missing`, and every lookup,
|
|
360
|
+
* download, stage, or record failure degrades to `missing` with a reason.
|
|
361
|
+
*/
|
|
362
|
+
export async function resolveReviewerVisualEvidence(opts) {
|
|
363
|
+
const base = { headSha: opts.headSha, baseSha: opts.baseSha };
|
|
364
|
+
const record = (content, blobPath) => {
|
|
365
|
+
try {
|
|
366
|
+
createIssueArtifact({
|
|
367
|
+
issueId: opts.issueId,
|
|
368
|
+
workerSessionId: opts.workerSessionId ?? null,
|
|
369
|
+
kind: VISUAL_EVIDENCE_ARTIFACT_KIND,
|
|
370
|
+
content,
|
|
371
|
+
blobPath,
|
|
372
|
+
author: "system",
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
console.error("[coordinator] visual evidence record failed", { issueId: opts.issueId, err });
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
try {
|
|
380
|
+
const uiAffecting = opts.changedFiles === null ? true : isUiAffectingChange(opts.changedFiles);
|
|
381
|
+
if (!uiAffecting) {
|
|
382
|
+
const resolved = {
|
|
383
|
+
...base,
|
|
384
|
+
uiAffecting,
|
|
385
|
+
state: "not_applicable",
|
|
386
|
+
runId: null,
|
|
387
|
+
artifactNames: [],
|
|
388
|
+
stagedDir: null,
|
|
389
|
+
summaryChars: null,
|
|
390
|
+
reason: "head touches none of the Visual workflow paths",
|
|
391
|
+
staged: null,
|
|
392
|
+
summary: null,
|
|
393
|
+
files: null,
|
|
394
|
+
};
|
|
395
|
+
record(resolved, null);
|
|
396
|
+
return resolved;
|
|
397
|
+
}
|
|
398
|
+
if (opts.changedFiles === null) {
|
|
399
|
+
const resolved = noEvidence(true, base, "changed-file list unavailable — treating the head as UI-affecting without evidence");
|
|
400
|
+
record(resolved, null);
|
|
401
|
+
return resolved;
|
|
402
|
+
}
|
|
403
|
+
const fetcher = opts.fetcher ?? githubVisualEvidenceFetcher(opts.github);
|
|
404
|
+
if (!fetcher) {
|
|
405
|
+
const resolved = noEvidence(true, base, "Visual run lookup is unavailable through the configured GitHub access");
|
|
406
|
+
record(resolved, null);
|
|
407
|
+
return resolved;
|
|
408
|
+
}
|
|
409
|
+
let run;
|
|
410
|
+
try {
|
|
411
|
+
run = await fetcher.findRunForHead({ cwd: opts.worktreePath, headSha: opts.headSha });
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
const resolved = noEvidence(true, base, `Visual run lookup failed: ${truncateReason(err instanceof Error ? err.message : String(err))}`);
|
|
415
|
+
record(resolved, null);
|
|
416
|
+
return resolved;
|
|
417
|
+
}
|
|
418
|
+
if (!run || run.headSha !== opts.headSha) {
|
|
419
|
+
const resolved = noEvidence(true, base, run
|
|
420
|
+
? `newest Visual run targets ${run.headSha.slice(0, 8)} instead of this head — a run for another SHA never counts`
|
|
421
|
+
: `no Visual workflow run found for head ${opts.headSha.slice(0, 8)} (the workflow may not have run for this push)`);
|
|
422
|
+
resolved.runId = run?.databaseId ?? null;
|
|
423
|
+
record(resolved, null);
|
|
424
|
+
return resolved;
|
|
425
|
+
}
|
|
426
|
+
const status = (run.status ?? "").toLowerCase();
|
|
427
|
+
const conclusion = (run.conclusion ?? "").toLowerCase();
|
|
428
|
+
if (status !== "completed" || !SUCCESS_CONCLUSIONS.has(conclusion)) {
|
|
429
|
+
const state = status === "completed" && FAILED_CONCLUSIONS.has(conclusion) ? "failed" : "missing";
|
|
430
|
+
const reason = state === "failed"
|
|
431
|
+
? `Visual run ${run.databaseId} for this head concluded ${conclusion || "without a conclusion"} — its captures are unusable`
|
|
432
|
+
: status !== "completed"
|
|
433
|
+
? `Visual run ${run.databaseId} for this head has not completed (status ${status || "unknown"})`
|
|
434
|
+
: `Visual run ${run.databaseId} for this head concluded ${conclusion || "without a conclusion"} — not a usable success`;
|
|
435
|
+
const resolved = {
|
|
436
|
+
...base,
|
|
437
|
+
uiAffecting: true,
|
|
438
|
+
state,
|
|
439
|
+
runId: run.databaseId,
|
|
440
|
+
artifactNames: [],
|
|
441
|
+
stagedDir: null,
|
|
442
|
+
summaryChars: null,
|
|
443
|
+
reason,
|
|
444
|
+
staged: null,
|
|
445
|
+
summary: null,
|
|
446
|
+
files: null,
|
|
447
|
+
};
|
|
448
|
+
record(resolved, null);
|
|
449
|
+
return resolved;
|
|
450
|
+
}
|
|
451
|
+
// Completed + successful: the downloads themselves are the artifact
|
|
452
|
+
// presence check (a missing artifact fails its download).
|
|
453
|
+
const storeDir = visualEvidenceStoreDir(opts.issueId, opts.headSha);
|
|
454
|
+
const downloaded = [];
|
|
455
|
+
for (const name of VISUAL_ARTIFACT_NAMES) {
|
|
456
|
+
const destDir = path.join(storeDir, name);
|
|
457
|
+
try {
|
|
458
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
459
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
460
|
+
await fetcher.downloadArtifact({ cwd: opts.worktreePath, runId: run.databaseId, name, destDir });
|
|
461
|
+
normalizeDownloadedLayout(destDir, name);
|
|
462
|
+
if (dirHasFiles(destDir))
|
|
463
|
+
downloaded.push(name);
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
// per-artifact failure only marks that artifact absent
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const state = classifyVisualEvidence({
|
|
470
|
+
uiAffecting: true,
|
|
471
|
+
headSha: opts.headSha,
|
|
472
|
+
run,
|
|
473
|
+
artifactNames: downloaded,
|
|
474
|
+
});
|
|
475
|
+
if (state !== "available") {
|
|
476
|
+
const absent = VISUAL_ARTIFACT_NAMES.filter((name) => !downloaded.includes(name));
|
|
477
|
+
const resolved = {
|
|
478
|
+
...base,
|
|
479
|
+
uiAffecting: true,
|
|
480
|
+
state,
|
|
481
|
+
runId: run.databaseId,
|
|
482
|
+
artifactNames: downloaded,
|
|
483
|
+
stagedDir: null,
|
|
484
|
+
summaryChars: null,
|
|
485
|
+
reason: `Visual run ${run.databaseId} for this head is missing usable artifacts: ${absent.join(", ")}`,
|
|
486
|
+
staged: null,
|
|
487
|
+
summary: null,
|
|
488
|
+
files: null,
|
|
489
|
+
};
|
|
490
|
+
record(resolved, storeDir);
|
|
491
|
+
return resolved;
|
|
492
|
+
}
|
|
493
|
+
let staged;
|
|
494
|
+
try {
|
|
495
|
+
staged = stageVisualArtifacts({ worktreePath: opts.worktreePath, storeDir });
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
const resolved = {
|
|
499
|
+
...base,
|
|
500
|
+
uiAffecting: true,
|
|
501
|
+
state: "missing",
|
|
502
|
+
runId: run.databaseId,
|
|
503
|
+
artifactNames: downloaded,
|
|
504
|
+
stagedDir: null,
|
|
505
|
+
summaryChars: null,
|
|
506
|
+
reason: `Visual run ${run.databaseId} artifacts downloaded but could not be staged for the reviewer: ${truncateReason(err instanceof Error ? err.message : String(err))}`,
|
|
507
|
+
staged: null,
|
|
508
|
+
summary: null,
|
|
509
|
+
files: null,
|
|
510
|
+
};
|
|
511
|
+
record(resolved, storeDir);
|
|
512
|
+
return resolved;
|
|
513
|
+
}
|
|
514
|
+
const summary = readVisualDiffSummary(staged.diffDir);
|
|
515
|
+
const files = {
|
|
516
|
+
"ui-screenshots": listTopLevelFiles(staged.screenshotsDir, VISUAL_FILE_LISTING_MAX_PER_ARTIFACT),
|
|
517
|
+
"ui-baseline": listTopLevelFiles(staged.baselineDir, VISUAL_FILE_LISTING_MAX_PER_ARTIFACT),
|
|
518
|
+
"ui-diff": listTopLevelFiles(staged.diffDir, VISUAL_FILE_LISTING_MAX_PER_ARTIFACT),
|
|
519
|
+
};
|
|
520
|
+
const resolved = {
|
|
521
|
+
...base,
|
|
522
|
+
uiAffecting: true,
|
|
523
|
+
state: "available",
|
|
524
|
+
runId: run.databaseId,
|
|
525
|
+
artifactNames: downloaded,
|
|
526
|
+
stagedDir: staged.stagedDir,
|
|
527
|
+
summaryChars: summary?.length ?? null,
|
|
528
|
+
reason: null,
|
|
529
|
+
staged,
|
|
530
|
+
summary,
|
|
531
|
+
files,
|
|
532
|
+
};
|
|
533
|
+
record(resolved, storeDir);
|
|
534
|
+
return resolved;
|
|
535
|
+
}
|
|
536
|
+
catch (err) {
|
|
537
|
+
// Truly unexpected (store dir, record shape) — still `missing`, never a pass.
|
|
538
|
+
const resolved = noEvidence(true, base, `visual evidence resolution failed: ${truncateReason(err instanceof Error ? err.message : String(err))}`);
|
|
539
|
+
record(resolved, null);
|
|
540
|
+
return resolved;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
export function toReviewerVisualEvidenceInput(resolved) {
|
|
544
|
+
if (resolved.state === "not_applicable")
|
|
545
|
+
return undefined;
|
|
546
|
+
return {
|
|
547
|
+
state: resolved.state,
|
|
548
|
+
headSha: resolved.headSha,
|
|
549
|
+
baseSha: resolved.baseSha,
|
|
550
|
+
runId: resolved.runId,
|
|
551
|
+
staged: resolved.staged,
|
|
552
|
+
summary: resolved.summary,
|
|
553
|
+
files: resolved.files,
|
|
554
|
+
reason: resolved.reason,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Reads the latest recorded evidence for `headSha`: non-null exactly when the
|
|
559
|
+
* head is UI-affecting and its recorded state is `missing` or `failed`. A
|
|
560
|
+
* record for any other head, an unreadable record, or no record at all means
|
|
561
|
+
* no hold (reviewer sessions always record before a verdict can approve, so a
|
|
562
|
+
* missing record means no review ran — not missing evidence).
|
|
563
|
+
*/
|
|
564
|
+
export function readVisualEvidenceHold(issueId, headSha) {
|
|
565
|
+
if (!headSha)
|
|
566
|
+
return null;
|
|
567
|
+
const artifact = latestIssueArtifact(issueId, VISUAL_EVIDENCE_ARTIFACT_KIND);
|
|
568
|
+
if (!artifact?.contentJson)
|
|
569
|
+
return null;
|
|
570
|
+
let stored;
|
|
571
|
+
try {
|
|
572
|
+
stored = JSON.parse(artifact.contentJson);
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
if (stored.headSha !== headSha)
|
|
578
|
+
return null;
|
|
579
|
+
if (stored.uiAffecting !== true)
|
|
580
|
+
return null;
|
|
581
|
+
if (stored.state !== "missing" && stored.state !== "failed")
|
|
582
|
+
return null;
|
|
583
|
+
const present = new Set(Array.isArray(stored.artifactNames) ? stored.artifactNames.filter((n) => typeof n === "string") : []);
|
|
584
|
+
const missingArtifacts = stored.state === "failed"
|
|
585
|
+
? [...VISUAL_ARTIFACT_NAMES]
|
|
586
|
+
: VISUAL_ARTIFACT_NAMES.filter((name) => !present.has(name));
|
|
587
|
+
return {
|
|
588
|
+
state: stored.state,
|
|
589
|
+
headSha,
|
|
590
|
+
reason: typeof stored.reason === "string" && stored.reason ? stored.reason : "no usable CI visual captures",
|
|
591
|
+
runId: typeof stored.runId === "number" ? stored.runId : null,
|
|
592
|
+
missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : [...VISUAL_ARTIFACT_NAMES],
|
|
593
|
+
};
|
|
594
|
+
}
|