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,496 @@
|
|
|
1
|
+
// packages/server/src/coordinator/reviewer-visual-evidence.test.ts
|
|
2
|
+
//
|
|
3
|
+
// NOT-384: the UI-affecting classifier (pinned against visual.yml), the pure
|
|
4
|
+
// evidence classifier, worktree staging + clean-check exclusion + cleanup, the
|
|
5
|
+
// resolve orchestration with a faked fetcher, and the merge-gate reader.
|
|
6
|
+
import { test, before, beforeEach } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not384-"));
|
|
14
|
+
const { migrate, getDb } = await import("../db/index.js");
|
|
15
|
+
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
16
|
+
const { createIssue } = await import("../repository/issues.js");
|
|
17
|
+
const { createIssueArtifact } = await import("../repository/artifacts.js");
|
|
18
|
+
const { listArtifactsForIssueByKind } = await import("../repository/artifacts-for-issue.js");
|
|
19
|
+
const { isWorktreeClean, REVIEWER_VISUAL_STAGING_DIR_NAME } = await import("../adapters/git-worktree.js");
|
|
20
|
+
const { VISUAL_WORKFLOW_PATHS, VISUAL_ARTIFACT_NAMES, VISUAL_EVIDENCE_ARTIFACT_KIND, isUiAffectingChange, classifyVisualEvidence, githubVisualEvidenceFetcher, resolveReviewerVisualEvidence, stageVisualArtifacts, cleanupStagedVisualArtifacts, readVisualDiffSummary, reviewerVisualStagingDir, toReviewerVisualEvidenceInput, readVisualEvidenceHold, } = await import("./reviewer-visual-evidence.js");
|
|
21
|
+
before(() => migrate());
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
getDb().exec(`
|
|
24
|
+
DELETE FROM work_items;
|
|
25
|
+
DELETE FROM human_actions;
|
|
26
|
+
DELETE FROM workflow_events;
|
|
27
|
+
DELETE FROM findings;
|
|
28
|
+
DELETE FROM review_publications;
|
|
29
|
+
DELETE FROM worker_sessions;
|
|
30
|
+
DELETE FROM artifacts;
|
|
31
|
+
DELETE FROM usage_events;
|
|
32
|
+
DELETE FROM workflow_instances;
|
|
33
|
+
DELETE FROM issues;
|
|
34
|
+
`);
|
|
35
|
+
});
|
|
36
|
+
function git(cwd, ...args) {
|
|
37
|
+
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
|
38
|
+
}
|
|
39
|
+
function makeIssue() {
|
|
40
|
+
return createIssue({
|
|
41
|
+
title: "UI work",
|
|
42
|
+
description: "d",
|
|
43
|
+
acceptanceCriteria: "It renders",
|
|
44
|
+
repo: "acme/app",
|
|
45
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
46
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
47
|
+
baseBranch: "main",
|
|
48
|
+
maxReviewRounds: 3,
|
|
49
|
+
maxInfraAttempts: 3,
|
|
50
|
+
source: "manual",
|
|
51
|
+
autoMerge: true,
|
|
52
|
+
}).id;
|
|
53
|
+
}
|
|
54
|
+
// The classifier answers true for one representative path per pattern in the
|
|
55
|
+
// shared path set — each entry proven, not just the set as a whole.
|
|
56
|
+
test("NOT-384: classifier returns true for each path in the shared path set", () => {
|
|
57
|
+
const representative = {
|
|
58
|
+
"apps/web/**": "apps/web/src/routes/issues.tsx",
|
|
59
|
+
"packages/shared/**": "packages/shared/src/types.ts",
|
|
60
|
+
"ui-screenshots.json": "ui-screenshots.json",
|
|
61
|
+
"scripts/ci-visual/**": "scripts/ci-visual/capture.mts",
|
|
62
|
+
".github/workflows/visual.yml": ".github/workflows/visual.yml",
|
|
63
|
+
};
|
|
64
|
+
assert.deepEqual(Object.keys(representative).sort(), [...VISUAL_WORKFLOW_PATHS].sort());
|
|
65
|
+
for (const [pattern, file] of Object.entries(representative)) {
|
|
66
|
+
assert.equal(isUiAffectingChange([file]), true, `${pattern} must match ${file}`);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
test("NOT-384: classifier returns false for docs-only and server-only changes", () => {
|
|
70
|
+
assert.equal(isUiAffectingChange(["docs/guide.md", "README.md", "CHANGELOG.md"]), false);
|
|
71
|
+
assert.equal(isUiAffectingChange(["packages/server/src/coordinator/routing.ts", "packages/server/package.json"]), false);
|
|
72
|
+
assert.equal(isUiAffectingChange([]), false);
|
|
73
|
+
assert.equal(isUiAffectingChange(["", " "]), false);
|
|
74
|
+
});
|
|
75
|
+
test("NOT-384: classifier matches directories, ./ prefixes, and mixed lists", () => {
|
|
76
|
+
assert.equal(isUiAffectingChange(["apps/web"]), true);
|
|
77
|
+
assert.equal(isUiAffectingChange(["./packages/shared/src/x.ts"]), true);
|
|
78
|
+
assert.equal(isUiAffectingChange(["docs/a.md", "apps/web/dist/app.js"]), true);
|
|
79
|
+
// Near misses stay non-UI: a differently-named workflow, a nested config.
|
|
80
|
+
assert.equal(isUiAffectingChange([".github/workflows/ci.yml"]), false);
|
|
81
|
+
assert.equal(isUiAffectingChange(["apps/web-ui/x.ts"]), false);
|
|
82
|
+
assert.equal(isUiAffectingChange(["nested/ui-screenshots.json"]), false);
|
|
83
|
+
});
|
|
84
|
+
// The shared path set equals the `paths` list in visual.yml — both trigger
|
|
85
|
+
// blocks, in order. A workflow edit without a classifier edit (or vice versa)
|
|
86
|
+
// fails here instead of silently desyncing what CI captures and what Dealer
|
|
87
|
+
// treats as UI-affecting.
|
|
88
|
+
test("NOT-384: shared path set equals the paths list in visual.yml", () => {
|
|
89
|
+
const testDir = path.dirname(fileURLToPath(import.meta.url));
|
|
90
|
+
const workflowPath = path.resolve(testDir, "..", "..", "..", "..", ".github", "workflows", "visual.yml");
|
|
91
|
+
const lines = fs.readFileSync(workflowPath, "utf8").split("\n");
|
|
92
|
+
const blocks = [];
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
const header = lines[i].match(/^(\s*)paths:\s*$/);
|
|
95
|
+
if (!header)
|
|
96
|
+
continue;
|
|
97
|
+
const indent = header[1].length;
|
|
98
|
+
const block = [];
|
|
99
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
100
|
+
const item = lines[j].match(/^\s+-\s*["']([^"']+)["']\s*(?:#.*)?$/);
|
|
101
|
+
if (item && lines[j].search(/\S/) > indent) {
|
|
102
|
+
block.push(item[1]);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (/^\s*$/.test(lines[j]))
|
|
106
|
+
continue;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
if (block.length > 0)
|
|
110
|
+
blocks.push(block);
|
|
111
|
+
}
|
|
112
|
+
assert.equal(blocks.length, 2, `expected pull_request + push paths blocks, found ${blocks.length}`);
|
|
113
|
+
for (const block of blocks) {
|
|
114
|
+
assert.deepEqual(block, [...VISUAL_WORKFLOW_PATHS]);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
const HEAD = "a".repeat(40);
|
|
118
|
+
const OTHER = "b".repeat(40);
|
|
119
|
+
test("NOT-384: evidence classification — available", () => {
|
|
120
|
+
assert.equal(classifyVisualEvidence({
|
|
121
|
+
uiAffecting: true,
|
|
122
|
+
headSha: HEAD,
|
|
123
|
+
run: { databaseId: 7, headSha: HEAD, conclusion: "success", status: "completed" },
|
|
124
|
+
artifactNames: ["ui-screenshots", "ui-baseline", "ui-diff"],
|
|
125
|
+
}), "available");
|
|
126
|
+
// Extra artifacts and success-adjacent conclusions still count.
|
|
127
|
+
assert.equal(classifyVisualEvidence({
|
|
128
|
+
uiAffecting: true,
|
|
129
|
+
headSha: HEAD,
|
|
130
|
+
run: { databaseId: 7, headSha: HEAD, conclusion: "neutral", status: "completed" },
|
|
131
|
+
artifactNames: ["ui-screenshots", "ui-baseline", "ui-diff", "extra"],
|
|
132
|
+
}), "available");
|
|
133
|
+
});
|
|
134
|
+
test("NOT-384: evidence classification — missing", () => {
|
|
135
|
+
const base = { uiAffecting: true, headSha: HEAD };
|
|
136
|
+
// No run at all (the NOT-380 case: Visual did not even run).
|
|
137
|
+
assert.equal(classifyVisualEvidence({ ...base, run: null, artifactNames: [] }), "missing");
|
|
138
|
+
// A run for a different SHA is never available, however green.
|
|
139
|
+
assert.equal(classifyVisualEvidence({
|
|
140
|
+
...base,
|
|
141
|
+
run: { databaseId: 7, headSha: OTHER, conclusion: "success", status: "completed" },
|
|
142
|
+
artifactNames: ["ui-screenshots", "ui-baseline", "ui-diff"],
|
|
143
|
+
}), "missing");
|
|
144
|
+
// Successful run but an artifact short.
|
|
145
|
+
assert.equal(classifyVisualEvidence({
|
|
146
|
+
...base,
|
|
147
|
+
run: { databaseId: 7, headSha: HEAD, conclusion: "success", status: "completed" },
|
|
148
|
+
artifactNames: ["ui-screenshots", "ui-baseline"],
|
|
149
|
+
}), "missing");
|
|
150
|
+
// Still running: no usable captures yet.
|
|
151
|
+
assert.equal(classifyVisualEvidence({
|
|
152
|
+
...base,
|
|
153
|
+
run: { databaseId: 7, headSha: HEAD, conclusion: null, status: "in_progress" },
|
|
154
|
+
artifactNames: [],
|
|
155
|
+
}), "missing");
|
|
156
|
+
// Completed with an unrecognized conclusion: not proven failed, not usable.
|
|
157
|
+
assert.equal(classifyVisualEvidence({
|
|
158
|
+
...base,
|
|
159
|
+
run: { databaseId: 7, headSha: HEAD, conclusion: "stale", status: "completed" },
|
|
160
|
+
artifactNames: ["ui-screenshots", "ui-baseline", "ui-diff"],
|
|
161
|
+
}), "missing");
|
|
162
|
+
});
|
|
163
|
+
test("NOT-384: evidence classification — failed", () => {
|
|
164
|
+
for (const conclusion of ["failure", "cancelled", "timed_out", "action_required", "startup_failure"]) {
|
|
165
|
+
assert.equal(classifyVisualEvidence({
|
|
166
|
+
uiAffecting: true,
|
|
167
|
+
headSha: HEAD,
|
|
168
|
+
run: { databaseId: 7, headSha: HEAD, conclusion, status: "completed" },
|
|
169
|
+
artifactNames: ["ui-screenshots", "ui-baseline", "ui-diff"],
|
|
170
|
+
}), "failed", `conclusion ${conclusion} must read as failed`);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
test("NOT-384: evidence classification — not_applicable", () => {
|
|
174
|
+
assert.equal(classifyVisualEvidence({ uiAffecting: false, headSha: HEAD, run: null, artifactNames: [] }), "not_applicable");
|
|
175
|
+
// Non-UI heads stay not_applicable even when a failed run exists.
|
|
176
|
+
assert.equal(classifyVisualEvidence({
|
|
177
|
+
uiAffecting: false,
|
|
178
|
+
headSha: HEAD,
|
|
179
|
+
run: { databaseId: 7, headSha: HEAD, conclusion: "failure", status: "completed" },
|
|
180
|
+
artifactNames: [],
|
|
181
|
+
}), "not_applicable");
|
|
182
|
+
});
|
|
183
|
+
test("NOT-384: github fetcher resolves only exact-SHA runs", async () => {
|
|
184
|
+
const fetcher = githubVisualEvidenceFetcher({
|
|
185
|
+
listWorkflowRunsForCommit: async () => [
|
|
186
|
+
{ databaseId: 9, headSha: OTHER, conclusion: "success", status: "completed" },
|
|
187
|
+
{ databaseId: 8, headSha: HEAD, conclusion: "success", status: "completed" },
|
|
188
|
+
],
|
|
189
|
+
downloadRunArtifact: async () => { },
|
|
190
|
+
});
|
|
191
|
+
assert.ok(fetcher);
|
|
192
|
+
const run = await fetcher.findRunForHead({ cwd: "/repo", headSha: HEAD });
|
|
193
|
+
assert.equal(run?.databaseId, 8);
|
|
194
|
+
// Stale-only listing: no run for this head.
|
|
195
|
+
const staleOnly = githubVisualEvidenceFetcher({
|
|
196
|
+
listWorkflowRunsForCommit: async () => [
|
|
197
|
+
{ databaseId: 9, headSha: OTHER, conclusion: "success", status: "completed" },
|
|
198
|
+
],
|
|
199
|
+
downloadRunArtifact: async () => { },
|
|
200
|
+
});
|
|
201
|
+
assert.equal(await staleOnly.findRunForHead({ cwd: "/repo", headSha: HEAD }), null);
|
|
202
|
+
});
|
|
203
|
+
test("NOT-384: github fetcher is null when the adapter predates the lookup methods", () => {
|
|
204
|
+
assert.equal(githubVisualEvidenceFetcher({}), null);
|
|
205
|
+
assert.equal(githubVisualEvidenceFetcher({ listWorkflowRunsForCommit: async () => [] }), null);
|
|
206
|
+
});
|
|
207
|
+
function initRepo() {
|
|
208
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not384-wt-"));
|
|
209
|
+
git(dir, "init", "-q", "-b", "main");
|
|
210
|
+
git(dir, "config", "user.email", "test@example.com");
|
|
211
|
+
git(dir, "config", "user.name", "Test");
|
|
212
|
+
fs.writeFileSync(path.join(dir, "README.md"), "hello\n");
|
|
213
|
+
git(dir, "add", ".");
|
|
214
|
+
git(dir, "commit", "-q", "-m", "init");
|
|
215
|
+
return dir;
|
|
216
|
+
}
|
|
217
|
+
const SUMMARY = "## UI diff (base vs head, report-only)\n\nhead: `aaa`\n\nbase: `bbb`\n";
|
|
218
|
+
function writeFixtureStore(storeDir) {
|
|
219
|
+
for (const name of VISUAL_ARTIFACT_NAMES) {
|
|
220
|
+
const dir = path.join(storeDir, name);
|
|
221
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
222
|
+
fs.writeFileSync(path.join(dir, "issues-home-1440x900.png"), `fake-png-${name}`);
|
|
223
|
+
fs.writeFileSync(path.join(dir, "issues-home-390x800.png"), `fake-png-${name}`);
|
|
224
|
+
}
|
|
225
|
+
fs.writeFileSync(path.join(storeDir, "ui-diff", "SUMMARY.md"), SUMMARY);
|
|
226
|
+
}
|
|
227
|
+
test("NOT-384: staged artifacts land under the reviewer worktree, stay clean, and clean up", async () => {
|
|
228
|
+
const worktree = initRepo();
|
|
229
|
+
try {
|
|
230
|
+
const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not384-store-"));
|
|
231
|
+
writeFixtureStore(storeDir);
|
|
232
|
+
const staged = stageVisualArtifacts({ worktreePath: worktree, storeDir });
|
|
233
|
+
try {
|
|
234
|
+
assert.equal(staged.stagedDir, reviewerVisualStagingDir(worktree));
|
|
235
|
+
assert.ok(staged.stagedDir.startsWith(worktree + path.sep));
|
|
236
|
+
assert.ok(staged.screenshotsDir.startsWith(staged.stagedDir + path.sep));
|
|
237
|
+
assert.ok(staged.baselineDir.startsWith(staged.stagedDir + path.sep));
|
|
238
|
+
assert.ok(staged.diffDir.startsWith(staged.stagedDir + path.sep));
|
|
239
|
+
assert.equal(fs.readFileSync(path.join(staged.screenshotsDir, "issues-home-1440x900.png"), "utf8"), "fake-png-ui-screenshots");
|
|
240
|
+
assert.equal(staged.summaryPath, path.join(staged.diffDir, "SUMMARY.md"));
|
|
241
|
+
// Read-only copy: no write bit on staged files.
|
|
242
|
+
for (const name of VISUAL_ARTIFACT_NAMES) {
|
|
243
|
+
const mode = fs.statSync(path.join(staged.stagedDir, name, "issues-home-1440x900.png")).mode;
|
|
244
|
+
assert.equal(mode & 0o222, 0, `${name} capture must be read-only`);
|
|
245
|
+
}
|
|
246
|
+
// The worktree still reports clean with staged evidence present.
|
|
247
|
+
assert.equal(await isWorktreeClean(worktree), true);
|
|
248
|
+
// Controls: the exclusion is narrow — other untracked files and real
|
|
249
|
+
// modifications still read as dirty.
|
|
250
|
+
fs.writeFileSync(path.join(worktree, "scratch.txt"), "x\n");
|
|
251
|
+
assert.equal(await isWorktreeClean(worktree), false);
|
|
252
|
+
fs.rmSync(path.join(worktree, "scratch.txt"));
|
|
253
|
+
fs.writeFileSync(path.join(worktree, "README.md"), "changed\n");
|
|
254
|
+
assert.equal(await isWorktreeClean(worktree), false);
|
|
255
|
+
git(worktree, "checkout", "--", "README.md");
|
|
256
|
+
assert.equal(await isWorktreeClean(worktree), true);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
cleanupStagedVisualArtifacts(worktree);
|
|
260
|
+
}
|
|
261
|
+
assert.equal(fs.existsSync(path.join(worktree, REVIEWER_VISUAL_STAGING_DIR_NAME)), false);
|
|
262
|
+
assert.equal(await isWorktreeClean(worktree), true);
|
|
263
|
+
// Cleanup is idempotent.
|
|
264
|
+
cleanupStagedVisualArtifacts(worktree);
|
|
265
|
+
}
|
|
266
|
+
finally {
|
|
267
|
+
fs.rmSync(worktree, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
test("NOT-384: diff summary reads bounded, and null when absent", () => {
|
|
271
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not384-summary-"));
|
|
272
|
+
try {
|
|
273
|
+
assert.equal(readVisualDiffSummary(dir), null);
|
|
274
|
+
fs.writeFileSync(path.join(dir, "SUMMARY.md"), SUMMARY);
|
|
275
|
+
assert.equal(readVisualDiffSummary(dir), SUMMARY.trim());
|
|
276
|
+
assert.equal(readVisualDiffSummary(dir, 10)?.length, 10 + "\n… (truncated, full file in the staged ui-diff directory)".length);
|
|
277
|
+
assert.match(readVisualDiffSummary(dir, 10), /truncated/);
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
function fixtureFetcher(opts) {
|
|
284
|
+
const calls = { downloads: [] };
|
|
285
|
+
return {
|
|
286
|
+
calls,
|
|
287
|
+
async findRunForHead() {
|
|
288
|
+
if (opts.failLookup)
|
|
289
|
+
throw new Error("gh exploded");
|
|
290
|
+
return opts.run === undefined
|
|
291
|
+
? { databaseId: 42, headSha: HEAD, conclusion: "success", status: "completed" }
|
|
292
|
+
: opts.run;
|
|
293
|
+
},
|
|
294
|
+
async downloadArtifact({ name, destDir }) {
|
|
295
|
+
calls.downloads.push(name);
|
|
296
|
+
if (opts.failDownload?.includes(name))
|
|
297
|
+
throw new Error(`no such artifact ${name}`);
|
|
298
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
299
|
+
fs.writeFileSync(path.join(destDir, "issues-home-1440x900.png"), `fake-png-${name}`);
|
|
300
|
+
fs.writeFileSync(path.join(destDir, "issues-home-390x800.png"), `fake-png-${name}`);
|
|
301
|
+
if (name === "ui-diff")
|
|
302
|
+
fs.writeFileSync(path.join(destDir, "SUMMARY.md"), SUMMARY);
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
test("NOT-384: resolve records not_applicable for non-UI heads without touching GitHub", async () => {
|
|
307
|
+
const issueId = makeIssue();
|
|
308
|
+
const worktree = initRepo();
|
|
309
|
+
try {
|
|
310
|
+
const fetcher = fixtureFetcher({});
|
|
311
|
+
const resolved = await resolveReviewerVisualEvidence({
|
|
312
|
+
issueId,
|
|
313
|
+
worktreePath: worktree,
|
|
314
|
+
baseSha: "c".repeat(40),
|
|
315
|
+
headSha: HEAD,
|
|
316
|
+
changedFiles: ["packages/server/src/x.ts"],
|
|
317
|
+
github: {},
|
|
318
|
+
fetcher,
|
|
319
|
+
});
|
|
320
|
+
assert.equal(resolved.state, "not_applicable");
|
|
321
|
+
assert.equal(resolved.uiAffecting, false);
|
|
322
|
+
assert.equal(resolved.staged, null);
|
|
323
|
+
assert.deepEqual(fetcher.calls.downloads, []);
|
|
324
|
+
assert.equal(toReviewerVisualEvidenceInput(resolved), undefined);
|
|
325
|
+
const rows = listArtifactsForIssueByKind(issueId, VISUAL_EVIDENCE_ARTIFACT_KIND);
|
|
326
|
+
assert.equal(rows.length, 1);
|
|
327
|
+
assert.equal(JSON.parse(rows[0].contentJson).state, "not_applicable");
|
|
328
|
+
}
|
|
329
|
+
finally {
|
|
330
|
+
fs.rmSync(worktree, { recursive: true, force: true });
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
test("NOT-384: resolve stages available evidence and records the run", async () => {
|
|
334
|
+
const issueId = makeIssue();
|
|
335
|
+
const worktree = initRepo();
|
|
336
|
+
try {
|
|
337
|
+
const resolved = await resolveReviewerVisualEvidence({
|
|
338
|
+
issueId,
|
|
339
|
+
worktreePath: worktree,
|
|
340
|
+
baseSha: "c".repeat(40),
|
|
341
|
+
headSha: HEAD,
|
|
342
|
+
changedFiles: ["apps/web/src/app.tsx"],
|
|
343
|
+
github: {},
|
|
344
|
+
fetcher: fixtureFetcher({}),
|
|
345
|
+
});
|
|
346
|
+
assert.equal(resolved.state, "available");
|
|
347
|
+
assert.equal(resolved.runId, 42);
|
|
348
|
+
assert.deepEqual(resolved.artifactNames, ["ui-screenshots", "ui-baseline", "ui-diff"]);
|
|
349
|
+
assert.ok(resolved.staged);
|
|
350
|
+
assert.ok(resolved.staged.stagedDir.startsWith(worktree + path.sep));
|
|
351
|
+
assert.equal(resolved.summary, SUMMARY.trim());
|
|
352
|
+
assert.equal(resolved.files["ui-diff"].total, 3);
|
|
353
|
+
assert.equal(await isWorktreeClean(worktree), true);
|
|
354
|
+
const input = toReviewerVisualEvidenceInput(resolved);
|
|
355
|
+
assert.equal(input.state, "available");
|
|
356
|
+
assert.equal(input.staged, resolved.staged);
|
|
357
|
+
const rows = listArtifactsForIssueByKind(issueId, VISUAL_EVIDENCE_ARTIFACT_KIND);
|
|
358
|
+
assert.equal(rows.length, 1);
|
|
359
|
+
const content = JSON.parse(rows[0].contentJson);
|
|
360
|
+
assert.equal(content.state, "available");
|
|
361
|
+
assert.equal(content.headSha, HEAD);
|
|
362
|
+
assert.ok(rows[0].blobPath, "Dealer storage path is recorded");
|
|
363
|
+
assert.ok(fs.existsSync(path.join(rows[0].blobPath, "ui-diff", "SUMMARY.md")));
|
|
364
|
+
}
|
|
365
|
+
finally {
|
|
366
|
+
fs.rmSync(worktree, { recursive: true, force: true });
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
test("NOT-384: resolve maps every failure to missing or failed, never throws", async () => {
|
|
370
|
+
const worktree = initRepo();
|
|
371
|
+
try {
|
|
372
|
+
// No run for the head.
|
|
373
|
+
const noRun = await resolveReviewerVisualEvidence({
|
|
374
|
+
issueId: makeIssue(),
|
|
375
|
+
worktreePath: worktree,
|
|
376
|
+
baseSha: "c".repeat(40),
|
|
377
|
+
headSha: HEAD,
|
|
378
|
+
changedFiles: ["apps/web/src/app.tsx"],
|
|
379
|
+
github: {},
|
|
380
|
+
fetcher: fixtureFetcher({ run: null }),
|
|
381
|
+
});
|
|
382
|
+
assert.equal(noRun.state, "missing");
|
|
383
|
+
assert.equal(noRun.staged, null);
|
|
384
|
+
assert.match(noRun.reason, /no Visual workflow run/);
|
|
385
|
+
// Failed run: no downloads attempted, nothing staged.
|
|
386
|
+
const failing = fixtureFetcher({
|
|
387
|
+
run: { databaseId: 43, headSha: HEAD, conclusion: "failure", status: "completed" },
|
|
388
|
+
});
|
|
389
|
+
const failed = await resolveReviewerVisualEvidence({
|
|
390
|
+
issueId: makeIssue(),
|
|
391
|
+
worktreePath: worktree,
|
|
392
|
+
baseSha: "c".repeat(40),
|
|
393
|
+
headSha: HEAD,
|
|
394
|
+
changedFiles: ["apps/web/src/app.tsx"],
|
|
395
|
+
github: {},
|
|
396
|
+
fetcher: failing,
|
|
397
|
+
});
|
|
398
|
+
assert.equal(failed.state, "failed");
|
|
399
|
+
assert.equal(failed.runId, 43);
|
|
400
|
+
assert.deepEqual(failing.calls.downloads, []);
|
|
401
|
+
assert.match(failed.reason, /concluded failure/);
|
|
402
|
+
// Partial artifacts: missing, naming the absent one.
|
|
403
|
+
const partial = await resolveReviewerVisualEvidence({
|
|
404
|
+
issueId: makeIssue(),
|
|
405
|
+
worktreePath: worktree,
|
|
406
|
+
baseSha: "c".repeat(40),
|
|
407
|
+
headSha: HEAD,
|
|
408
|
+
changedFiles: ["apps/web/src/app.tsx"],
|
|
409
|
+
github: {},
|
|
410
|
+
fetcher: fixtureFetcher({ failDownload: ["ui-diff"] }),
|
|
411
|
+
});
|
|
412
|
+
assert.equal(partial.state, "missing");
|
|
413
|
+
assert.match(partial.reason, /ui-diff/);
|
|
414
|
+
// Lookup error and unavailable lookup both degrade to missing.
|
|
415
|
+
const lookupError = await resolveReviewerVisualEvidence({
|
|
416
|
+
issueId: makeIssue(),
|
|
417
|
+
worktreePath: worktree,
|
|
418
|
+
baseSha: "c".repeat(40),
|
|
419
|
+
headSha: HEAD,
|
|
420
|
+
changedFiles: ["apps/web/src/app.tsx"],
|
|
421
|
+
github: {},
|
|
422
|
+
fetcher: fixtureFetcher({ failLookup: true }),
|
|
423
|
+
});
|
|
424
|
+
assert.equal(lookupError.state, "missing");
|
|
425
|
+
assert.match(lookupError.reason, /lookup failed/);
|
|
426
|
+
const unavailable = await resolveReviewerVisualEvidence({
|
|
427
|
+
issueId: makeIssue(),
|
|
428
|
+
worktreePath: worktree,
|
|
429
|
+
baseSha: "c".repeat(40),
|
|
430
|
+
headSha: HEAD,
|
|
431
|
+
changedFiles: ["apps/web/src/app.tsx"],
|
|
432
|
+
github: {},
|
|
433
|
+
});
|
|
434
|
+
assert.equal(unavailable.state, "missing");
|
|
435
|
+
assert.match(unavailable.reason, /unavailable/);
|
|
436
|
+
// Unknown changed files fail closed: UI-affecting + missing.
|
|
437
|
+
const unknown = await resolveReviewerVisualEvidence({
|
|
438
|
+
issueId: makeIssue(),
|
|
439
|
+
worktreePath: worktree,
|
|
440
|
+
baseSha: "c".repeat(40),
|
|
441
|
+
headSha: HEAD,
|
|
442
|
+
changedFiles: null,
|
|
443
|
+
github: {},
|
|
444
|
+
fetcher: fixtureFetcher({}),
|
|
445
|
+
});
|
|
446
|
+
assert.equal(unknown.uiAffecting, true);
|
|
447
|
+
assert.equal(unknown.state, "missing");
|
|
448
|
+
}
|
|
449
|
+
finally {
|
|
450
|
+
fs.rmSync(worktree, { recursive: true, force: true });
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
test("NOT-384: gate reader holds only UI heads with missing/failed evidence at this head", () => {
|
|
454
|
+
const issueId = makeIssue();
|
|
455
|
+
assert.equal(readVisualEvidenceHold(issueId, HEAD), null);
|
|
456
|
+
createIssueArtifact({
|
|
457
|
+
issueId,
|
|
458
|
+
kind: VISUAL_EVIDENCE_ARTIFACT_KIND,
|
|
459
|
+
content: { headSha: HEAD, uiAffecting: true, state: "missing", reason: "no run", runId: null, artifactNames: ["ui-screenshots"] },
|
|
460
|
+
author: "system",
|
|
461
|
+
});
|
|
462
|
+
const hold = readVisualEvidenceHold(issueId, HEAD);
|
|
463
|
+
assert.equal(hold.state, "missing");
|
|
464
|
+
assert.equal(hold.headSha, HEAD);
|
|
465
|
+
assert.deepEqual(hold.missingArtifacts, ["ui-baseline", "ui-diff"]);
|
|
466
|
+
assert.equal(readVisualEvidenceHold(issueId, OTHER), null);
|
|
467
|
+
// A newer available record for a new head releases the old hold.
|
|
468
|
+
createIssueArtifact({
|
|
469
|
+
issueId,
|
|
470
|
+
kind: VISUAL_EVIDENCE_ARTIFACT_KIND,
|
|
471
|
+
content: { headSha: OTHER, uiAffecting: true, state: "available", runId: 1, artifactNames: [...VISUAL_ARTIFACT_NAMES] },
|
|
472
|
+
author: "system",
|
|
473
|
+
});
|
|
474
|
+
assert.equal(readVisualEvidenceHold(issueId, OTHER), null);
|
|
475
|
+
// Failed names every artifact as unusable.
|
|
476
|
+
const failedIssue = makeIssue();
|
|
477
|
+
createIssueArtifact({
|
|
478
|
+
issueId: failedIssue,
|
|
479
|
+
kind: VISUAL_EVIDENCE_ARTIFACT_KIND,
|
|
480
|
+
content: { headSha: HEAD, uiAffecting: true, state: "failed", reason: "boom", runId: 9, artifactNames: [] },
|
|
481
|
+
author: "system",
|
|
482
|
+
});
|
|
483
|
+
assert.deepEqual(readVisualEvidenceHold(failedIssue, HEAD).missingArtifacts, [...VISUAL_ARTIFACT_NAMES]);
|
|
484
|
+
// Unreadable and non-UI records never hold.
|
|
485
|
+
const weirdIssue = makeIssue();
|
|
486
|
+
createIssueArtifact({ issueId: weirdIssue, kind: VISUAL_EVIDENCE_ARTIFACT_KIND, author: "system" });
|
|
487
|
+
assert.equal(readVisualEvidenceHold(weirdIssue, HEAD), null);
|
|
488
|
+
const nonUiIssue = makeIssue();
|
|
489
|
+
createIssueArtifact({
|
|
490
|
+
issueId: nonUiIssue,
|
|
491
|
+
kind: VISUAL_EVIDENCE_ARTIFACT_KIND,
|
|
492
|
+
content: { headSha: HEAD, uiAffecting: false, state: "missing" },
|
|
493
|
+
author: "system",
|
|
494
|
+
});
|
|
495
|
+
assert.equal(readVisualEvidenceHold(nonUiIssue, HEAD), null);
|
|
496
|
+
});
|
|
@@ -12,9 +12,12 @@ import { createHash } from "node:crypto";
|
|
|
12
12
|
import { compileExecutionContract, ExecutionContractError, } from "@agent-dealer/shared";
|
|
13
13
|
import { getDb } from "../db/index.js";
|
|
14
14
|
import { getIssue } from "../repository/issues.js";
|
|
15
|
+
import { findOpenHumanAction } from "../repository/human-actions.js";
|
|
16
|
+
import { listWorkItemsForIssue } from "../repository/work-items.js";
|
|
15
17
|
import { appendWorkflowEvent, getActiveWorkflowInstance } from "../repository/workflow-events.js";
|
|
16
18
|
import { getActiveWorkerSessionForIssue } from "../repository/worker-sessions.js";
|
|
17
19
|
import { getLinearIssue } from "../adapters/linear-inbox.js";
|
|
20
|
+
import { getTaskSnapshot } from "./commands.js";
|
|
18
21
|
import { discardStaged, pruneDisusedBlobs, pruneUncommittedBlobs, SourceAttachmentError, stageSourceAttachments, storeStagedAttachments, } from "./source-attachments.js";
|
|
19
22
|
import { listSourceAttachments, replaceSourceAttachments, } from "../repository/source-attachments.js";
|
|
20
23
|
/** Short digest of task text for the timeline payload — never the full text. */
|
|
@@ -26,18 +29,40 @@ export function digestTaskText(text) {
|
|
|
26
29
|
* workflow and no running worker. Anything else answers 409 and changes
|
|
27
30
|
* nothing, so a reload that races admission fails closed instead of landing
|
|
28
31
|
* on a live snapshot.
|
|
32
|
+
*
|
|
33
|
+
* NOT-422: the one active-workflow exception — a `needs_human` product-scope
|
|
34
|
+
* pause (open `product_scope_decision`, no running session, nothing
|
|
35
|
+
* pending/leased), mirroring `canEditParkedIssue`. The write still touches
|
|
36
|
+
* only title/description/acceptance-criteria plus the attachment rows: the
|
|
37
|
+
* workflow instance, PR/head/branch, worktree, rounds, counters, and the open
|
|
38
|
+
* action itself are preserved, and a product-scope resume re-freezes the
|
|
39
|
+
* amended text into a new task snapshot.
|
|
29
40
|
*/
|
|
30
41
|
function reloadConflict(issue) {
|
|
31
|
-
if (issue.status
|
|
32
|
-
|
|
42
|
+
if (issue.status === "ready") {
|
|
43
|
+
if (getActiveWorkerSessionForIssue(issue.id)) {
|
|
44
|
+
return "Cannot reload the source of an issue with an actively running session";
|
|
45
|
+
}
|
|
46
|
+
if (getActiveWorkflowInstance(issue.id)) {
|
|
47
|
+
return "Cannot reload the source of an issue with an active workflow";
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
33
50
|
}
|
|
34
|
-
if (
|
|
35
|
-
|
|
51
|
+
if (issue.status === "needs_human" &&
|
|
52
|
+
getActiveWorkflowInstance(issue.id) &&
|
|
53
|
+
findOpenHumanAction(issue.id, "product_scope_decision")) {
|
|
54
|
+
if (getActiveWorkerSessionForIssue(issue.id)) {
|
|
55
|
+
return "Cannot reload the source of an issue with an actively running session";
|
|
56
|
+
}
|
|
57
|
+
if (listWorkItemsForIssue(issue.id).some((w) => w.status === "pending" || w.status === "leased")) {
|
|
58
|
+
return "Cannot reload the source of an issue with pending or leased work";
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
36
61
|
}
|
|
37
62
|
if (getActiveWorkflowInstance(issue.id)) {
|
|
38
63
|
return "Cannot reload the source of an issue with an active workflow";
|
|
39
64
|
}
|
|
40
|
-
return
|
|
65
|
+
return `Cannot reload the source of an issue that is ${issue.status}`;
|
|
41
66
|
}
|
|
42
67
|
/**
|
|
43
68
|
* One explicit server operation: fetch the linked Linear ticket through the
|
|
@@ -47,9 +72,10 @@ function reloadConflict(issue) {
|
|
|
47
72
|
*
|
|
48
73
|
* `fetchLinearIssue` is injectable for tests; production passes the shared
|
|
49
74
|
* `getLinearIssue` adapter. The Linear fetch happens outside the write
|
|
50
|
-
* transaction, and the
|
|
51
|
-
* path, once inside the transaction — so a race with admission
|
|
52
|
-
*
|
|
75
|
+
* transaction, and the eligibility guard runs twice — once for the fast
|
|
76
|
+
* path, once inside the transaction — so a race with admission, a resume, a
|
|
77
|
+
* running session, or pending/leased work answers 409 with no partial write
|
|
78
|
+
* and no timeline event.
|
|
53
79
|
*/
|
|
54
80
|
export async function reloadIssueSourceFromLinear(issueId, opts) {
|
|
55
81
|
const issue = getIssue(issueId);
|
|
@@ -161,7 +187,16 @@ export async function reloadIssueSourceFromLinear(issueId, opts) {
|
|
|
161
187
|
throw Object.assign(new Error("Not found"), { code: 404 });
|
|
162
188
|
return next;
|
|
163
189
|
})();
|
|
164
|
-
|
|
190
|
+
// NOT-422: a parked reload swaps the live rows while the frozen snapshot
|
|
191
|
+
// still references the old manifest — until a resume re-freezes, pruning
|
|
192
|
+
// those blobs would strand the next materialization on missing bytes.
|
|
193
|
+
// Retain anything the frozen snapshot still lists (a leftover is
|
|
194
|
+
// retained-history, never a correctness issue); the pre-execution path has
|
|
195
|
+
// no frozen snapshot, so it keeps pruning replaced/removed blobs.
|
|
196
|
+
const liveRecords = getActiveWorkflowInstance(issueId)
|
|
197
|
+
? [...nextAttachmentRecords, ...(getTaskSnapshot(updated).sourceAttachments ?? [])]
|
|
198
|
+
: nextAttachmentRecords;
|
|
199
|
+
pruneDisusedBlobs(prevAttachments, liveRecords);
|
|
165
200
|
return { ok: true, issue: updated };
|
|
166
201
|
}
|
|
167
202
|
catch (err) {
|