@plainconceptsplatform/workflows 1.1.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog-installation.js +4 -0
- package/loops/actions/capture-evidence/action.yml +11 -2
- package/loops/actions/capture-evidence/capture.mjs +200 -14
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +1 -1
- package/loops/docs/diagrams.md +6 -3
- package/loops/workflows/evidence.yml +153 -44
- package/loops/workflows/work-router.yml +2 -2
- package/package.json +1 -1
|
@@ -326,7 +326,11 @@ async function preCommitHookUpdate(repositoryPath) {
|
|
|
326
326
|
if (!await exists(hookPath)) {
|
|
327
327
|
return { target, content: managedLines };
|
|
328
328
|
}
|
|
329
|
+
// A Windows clone with core.autocrlf checks the hook out with CRLF, which the LF patterns below
|
|
330
|
+
// never match: every update from Windows appended one more block (one consumer had ten). The
|
|
331
|
+
// hook is written back with LF, as every managed file is.
|
|
329
332
|
const content = (await readFile(hookPath, "utf8"))
|
|
333
|
+
.replace(/\r\n/g, "\n")
|
|
330
334
|
.replace("pnpm exec if git diff --cached --name-only -- .github | grep -q .; then", "if git diff --cached --name-only -- .github | grep -q .; then");
|
|
331
335
|
// Replace whatever managed block is there, never append beside it. The previous version
|
|
332
336
|
// recognised only two exact shapes -- the current block and the pre-`if` legacy lines -- and
|
|
@@ -3,7 +3,8 @@ name: Capture evidence
|
|
|
3
3
|
description: >
|
|
4
4
|
Reads a pull request's verification plans (written by the harness skill pc-repo-verify) and,
|
|
5
5
|
in capture mode, replays them in headless Chromium against the running app, writing one JPEG
|
|
6
|
-
per waypoint
|
|
6
|
+
per waypoint under files/<plan directory>/evidence/, laid out as the repository is, plus
|
|
7
|
+
manifest.json and comment.md. Inspect mode only reports whether any plan is
|
|
7
8
|
replayable, at which address, and with which start command. No model, no network beyond
|
|
8
9
|
installing its two pinned packages.
|
|
9
10
|
inputs:
|
|
@@ -20,6 +21,13 @@ inputs:
|
|
|
20
21
|
description: Where capture and unreached write the screenshots, manifest.json and comment.md.
|
|
21
22
|
required: false
|
|
22
23
|
default: ''
|
|
24
|
+
sign-in:
|
|
25
|
+
description: >
|
|
26
|
+
The repository's own sign-in steps for plans that start signed in (`login:`), relative to the
|
|
27
|
+
workspace, which is the default branch's checkout. The repository owns the file; without it
|
|
28
|
+
such a plan replays signed out and its comment says why.
|
|
29
|
+
required: false
|
|
30
|
+
default: '.github/evidence/sign-in.yml'
|
|
23
31
|
playwright-version:
|
|
24
32
|
description: playwright-core version. Must match the mcr.microsoft.com/playwright image tag the capture runs in.
|
|
25
33
|
required: false
|
|
@@ -74,6 +82,7 @@ runs:
|
|
|
74
82
|
ROOT: ${{ inputs.root }}
|
|
75
83
|
PLANS: ${{ inputs.plans }}
|
|
76
84
|
OUT_DIR: ${{ inputs.out-dir }}
|
|
85
|
+
EVIDENCE_SIGN_IN: ${{ inputs.sign-in }}
|
|
77
86
|
PLAYWRIGHT_VERSION: ${{ inputs.playwright-version }}
|
|
78
87
|
# Chromium's own stderr. A browser that dies on launch says why only here.
|
|
79
88
|
DEBUG: pw:browser
|
|
@@ -101,7 +110,7 @@ runs:
|
|
|
101
110
|
if command -v docker > /dev/null 2>&1; then
|
|
102
111
|
# The host network, so the browser reaches the app on localhost where it started;
|
|
103
112
|
# the runner's own user, so what it writes the publish job can read.
|
|
104
|
-
docker run --rm --network host --ipc host --user "$(id -u):$(id -g)" -e HOME=/tmp -e DEBUG -e EVIDENCE_SHA -e EVIDENCE_RUN_URL -e EVIDENCE_STEP_TIMEOUT_MS -e EVIDENCE_NAVIGATION_TIMEOUT_MS -v "$runner:$runner:ro" -v "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE:ro" -v "$OUT_DIR:$OUT_DIR" -w "$GITHUB_WORKSPACE" "mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble" node "$runner/capture.mjs" capture "$ROOT" "$OUT_DIR" "${plans[@]}"
|
|
113
|
+
docker run --rm --network host --ipc host --user "$(id -u):$(id -g)" -e HOME=/tmp -e DEBUG -e EVIDENCE_SHA -e EVIDENCE_RUN_URL -e EVIDENCE_SIGN_IN -e EVIDENCE_STEP_TIMEOUT_MS -e EVIDENCE_NAVIGATION_TIMEOUT_MS -v "$runner:$runner:ro" -v "$GITHUB_WORKSPACE:$GITHUB_WORKSPACE:ro" -v "$OUT_DIR:$OUT_DIR" -w "$GITHUB_WORKSPACE" "mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble" node "$runner/capture.mjs" capture "$ROOT" "$OUT_DIR" "${plans[@]}"
|
|
105
114
|
else
|
|
106
115
|
node "$runner/capture.mjs" capture "$ROOT" "$OUT_DIR" "${plans[@]}"
|
|
107
116
|
fi
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
// prints {"runnable","url","start","plans"} as JSON, so the workflow knows whether to start
|
|
9
9
|
// the app at all and which address to wait for
|
|
10
10
|
// node capture.mjs capture <repo-root> <out-dir> <plan.md>...
|
|
11
|
-
// writes
|
|
11
|
+
// writes one screenshot per waypoint under <out-dir>/files/, laid out as the repository is:
|
|
12
|
+
// <plan's directory>/evidence/NN-<capture>.jpg beside a manifest.json for that plan, so the
|
|
13
|
+
// publish job copies files/ over the default branch as is. Writes the run's manifest.json and
|
|
14
|
+
// comment.md into <out-dir>
|
|
12
15
|
// node capture.mjs unreached <repo-root> <out-dir> <plan.md>...
|
|
13
16
|
// the app never answered: writes manifest.json and comment.md saying so, and nothing else
|
|
14
17
|
//
|
|
@@ -16,7 +19,7 @@
|
|
|
16
19
|
// imports are bare: ESM resolves them from the file's own directory, never from NODE_PATH.
|
|
17
20
|
|
|
18
21
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
19
|
-
import { basename, dirname, join } from "node:path";
|
|
22
|
+
import { basename, dirname, join, posix } from "node:path";
|
|
20
23
|
import { pathToFileURL } from "node:url";
|
|
21
24
|
|
|
22
25
|
const DEFAULT_URL = "http://localhost:3000";
|
|
@@ -27,6 +30,17 @@ const NAVIGATION_TIMEOUT_MS = Number(process.env.EVIDENCE_NAVIGATION_TIMEOUT_MS
|
|
|
27
30
|
// is the commit that job pushes, so the comment is written here and the publish job fills it in.
|
|
28
31
|
export const IMAGE_BASE = "__EVIDENCE_BASE__";
|
|
29
32
|
export const MARKER = "<!-- agentic-evidence -->";
|
|
33
|
+
// Opens the machine-readable copy of the manifest at the end of the comment, which is what other
|
|
34
|
+
// tools read (Project Health shows the screenshots with each issue) instead of parsing markdown.
|
|
35
|
+
export const EMBED_MARKER = "agentic-evidence-manifest";
|
|
36
|
+
// The folder beside each verification plan that keeps its screenshots.
|
|
37
|
+
export const EVIDENCE_DIR = "evidence";
|
|
38
|
+
// Stays well under GitHub's 65,536 character comment limit with the markdown above it.
|
|
39
|
+
const EMBED_LIMIT = 30000;
|
|
40
|
+
// The repository's own steps for signing in locally, read from the default branch so a replay of
|
|
41
|
+
// an older pull request finds them too. The repository owns the file: an update never writes it.
|
|
42
|
+
export const SIGN_IN_FILE = ".github/evidence/sign-in.yml";
|
|
43
|
+
const ACCOUNT_PLACEHOLDER = "{account}";
|
|
30
44
|
|
|
31
45
|
const ACTIONS = new Set(["open", "click", "dblclick", "fill", "press", "select", "check", "uncheck", "hover", "wait"]);
|
|
32
46
|
const LOCATOR_KEYS = ["role", "text", "label", "placeholder", "testid", "alt", "title", "css"];
|
|
@@ -44,6 +58,17 @@ export function slug(value) {
|
|
|
44
58
|
return text.slice(0, 60) || "waypoint";
|
|
45
59
|
}
|
|
46
60
|
|
|
61
|
+
// The directory a plan lives in, which is where its screenshots are kept. The path comes from the
|
|
62
|
+
// pull request's file list, so it is repository-relative; anything that could climb out of the
|
|
63
|
+
// repository is refused rather than written.
|
|
64
|
+
export function planDir(planPath) {
|
|
65
|
+
const dir = posix.dirname(String(planPath).replace(/\\/g, "/"));
|
|
66
|
+
if (dir === "." || dir.startsWith("/") || dir.split("/").includes("..")) {
|
|
67
|
+
throw new Error(`unsafe plan path ${planPath}`);
|
|
68
|
+
}
|
|
69
|
+
return dir;
|
|
70
|
+
}
|
|
71
|
+
|
|
47
72
|
// The change a plan belongs to, from its directory. Archived changes carry a date prefix.
|
|
48
73
|
export function changeName(planPath) {
|
|
49
74
|
return basename(dirname(planPath)).replace(/^\d{4}-\d{2}-\d{2}-/, "") || "change";
|
|
@@ -108,15 +133,83 @@ export function normalizePlan(doc) {
|
|
|
108
133
|
return { n: index + 1, capture, expect: String(raw.expect ?? "").trim(), steps, ignored };
|
|
109
134
|
});
|
|
110
135
|
|
|
136
|
+
const login = parseLogin(doc.login ?? environment.login, environment);
|
|
111
137
|
return {
|
|
112
138
|
status: "runnable",
|
|
113
139
|
url: String(doc.url ?? environment.url ?? DEFAULT_URL).trim() || DEFAULT_URL,
|
|
114
140
|
start: String(doc.start ?? environment.start ?? "").trim(),
|
|
115
141
|
viewport: parseViewport(doc.viewport ?? environment.viewport),
|
|
142
|
+
...(login ? { login } : {}),
|
|
116
143
|
waypoints,
|
|
117
144
|
};
|
|
118
145
|
}
|
|
119
146
|
|
|
147
|
+
// A plan that starts signed in says so with `login`, rather than signing in in its first
|
|
148
|
+
// waypoint. The plans written before that waypoint signed in name a mechanism (`login: mock-sso`)
|
|
149
|
+
// and say whom in prose ("Sign in as Bob Builder"), so the prose travels along as a hint. A plan
|
|
150
|
+
// can also name the account (`login: { account: Bob Builder }`) or spell out its own steps.
|
|
151
|
+
export function parseLogin(value, environment = {}) {
|
|
152
|
+
if (value === undefined || value === null || value === false) return undefined;
|
|
153
|
+
if (Array.isArray(value)) return { steps: value };
|
|
154
|
+
if (isObject(value) && Array.isArray(value.steps)) return { steps: value.steps };
|
|
155
|
+
if (isObject(value) && value.account !== undefined) return { account: String(value.account) };
|
|
156
|
+
const hint = Object.values(environment).filter((text) => typeof text === "string").join("\n");
|
|
157
|
+
return { hint };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The account a plan's prose asks for: one of the repository's accounts right after "sign in as",
|
|
161
|
+
// the earliest when the prose names several. Only an account the repository lists can match.
|
|
162
|
+
export function accountNamed(text, accounts) {
|
|
163
|
+
let best;
|
|
164
|
+
for (const account of accounts) {
|
|
165
|
+
const escaped = account.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
166
|
+
const at = String(text ?? "").search(new RegExp(`sign(?:ed)?[\\s-]*in\\s+as\\s+(?:the\\s+)?${escaped}\\b`, "i"));
|
|
167
|
+
if (at >= 0 && (best === undefined || at < best.at)) best = { account, at };
|
|
168
|
+
}
|
|
169
|
+
return best?.account;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Reads the repository's sign-in: the steps, in the journey's own step syntax, with `{account}`
|
|
173
|
+
// where the account goes, and the accounts a plan can ask for, the first being the default.
|
|
174
|
+
export function parseSignIn(text, parseYaml) {
|
|
175
|
+
const doc = parseYaml(String(text));
|
|
176
|
+
if (!isObject(doc) || !Array.isArray(doc.steps) || doc.steps.length === 0) {
|
|
177
|
+
throw new Error("the sign-in needs a steps list");
|
|
178
|
+
}
|
|
179
|
+
return { accounts: asArray(doc.accounts).map((account) => String(account)), steps: doc.steps };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const fillAccount = (value, account) =>
|
|
183
|
+
typeof value === "string"
|
|
184
|
+
? value.split(ACCOUNT_PLACEHOLDER).join(account)
|
|
185
|
+
: Array.isArray(value)
|
|
186
|
+
? value.map((item) => fillAccount(item, account))
|
|
187
|
+
: isObject(value)
|
|
188
|
+
? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, fillAccount(item, account)]))
|
|
189
|
+
: value;
|
|
190
|
+
|
|
191
|
+
// A first waypoint that clicks one of the repository's accounts signs the plan in itself, as some
|
|
192
|
+
// plans that also say `login:` do. Signing in before it would leave that click nothing to find.
|
|
193
|
+
function signsItselfIn(plan, accounts) {
|
|
194
|
+
const names = new Set(accounts.map((account) => account.toLowerCase()));
|
|
195
|
+
return (plan.waypoints[0]?.steps ?? []).some((step) =>
|
|
196
|
+
step.action === "click" && [step.locator?.text, step.locator?.name].some((name) => name !== undefined && names.has(name.toLowerCase())),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The steps that sign a plan in before its first waypoint, or why there are none to run.
|
|
201
|
+
export function signInFor(plan, signIn) {
|
|
202
|
+
const login = plan.login;
|
|
203
|
+
if (!login) return undefined;
|
|
204
|
+
const parsed = (raw) => raw.map(parseStep).filter((step) => step.ignored === undefined);
|
|
205
|
+
if (login.steps) return { steps: parsed(login.steps) };
|
|
206
|
+
if (!signIn) return { steps: [], error: `the plan starts signed in, but the repository has no ${SIGN_IN_FILE}` };
|
|
207
|
+
if (signIn.error) return { steps: [], error: signIn.error };
|
|
208
|
+
if (signsItselfIn(plan, signIn.accounts)) return undefined;
|
|
209
|
+
const account = login.account ?? accountNamed(login.hint, signIn.accounts) ?? signIn.accounts[0] ?? "";
|
|
210
|
+
return { ...(account ? { account } : {}), steps: parsed(fillAccount(signIn.steps, account)) };
|
|
211
|
+
}
|
|
212
|
+
|
|
120
213
|
function locatorOf(value) {
|
|
121
214
|
if (typeof value === "string") return { text: value };
|
|
122
215
|
if (!isObject(value)) return undefined;
|
|
@@ -422,14 +515,30 @@ async function runStep(page, step, baseUrl) {
|
|
|
422
515
|
}
|
|
423
516
|
}
|
|
424
517
|
|
|
425
|
-
async function replay(browser, plan, outDir,
|
|
518
|
+
async function replay(browser, plan, outDir, dir) {
|
|
426
519
|
const context = await browser.newContext({ viewport: plan.viewport, ignoreHTTPSErrors: true });
|
|
427
520
|
const page = await context.newPage();
|
|
428
521
|
page.setDefaultTimeout(STEP_TIMEOUT_MS);
|
|
429
522
|
page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
430
523
|
|
|
431
524
|
const results = [];
|
|
525
|
+
let signedIn;
|
|
432
526
|
try {
|
|
527
|
+
if (plan.signIn) {
|
|
528
|
+
// Signing in is not a waypoint: nothing is captured, and a failure is recorded while the
|
|
529
|
+
// journey still runs, so its screenshots show where a missing sign-in left it.
|
|
530
|
+
const errors = plan.signIn.error ? [plan.signIn.error] : [];
|
|
531
|
+
for (const step of plan.signIn.steps) {
|
|
532
|
+
try {
|
|
533
|
+
await runStep(page, step, plan.url);
|
|
534
|
+
} catch (error) {
|
|
535
|
+
errors.push(`${describeStep(step)}: ${firstLine(error)}`);
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
signedIn = { ...(plan.signIn.account ? { account: plan.signIn.account } : {}), reached: errors.length === 0, errors };
|
|
540
|
+
console.log(`${dir} sign-in${plan.signIn.account ? ` as ${plan.signIn.account}` : ""}: ${errors.length === 0 ? "ok" : errors.join("; ")}`);
|
|
541
|
+
}
|
|
433
542
|
for (const waypoint of plan.waypoints) {
|
|
434
543
|
const errors = [];
|
|
435
544
|
for (const step of waypoint.steps) {
|
|
@@ -442,10 +551,11 @@ async function replay(browser, plan, outDir, prefix) {
|
|
|
442
551
|
break;
|
|
443
552
|
}
|
|
444
553
|
}
|
|
445
|
-
|
|
554
|
+
// Repository-relative, so the comment, the manifest and the commit all name the same path.
|
|
555
|
+
const file = `${dir}/${EVIDENCE_DIR}/${String(waypoint.n).padStart(2, "0")}-${waypoint.capture}.jpg`;
|
|
446
556
|
let captured = false;
|
|
447
557
|
try {
|
|
448
|
-
await page.screenshot({ path: join(outDir, file), type: "jpeg", quality: 70, fullPage: true });
|
|
558
|
+
await page.screenshot({ path: join(outDir, "files", file), type: "jpeg", quality: 70, fullPage: true });
|
|
449
559
|
captured = true;
|
|
450
560
|
} catch (error) {
|
|
451
561
|
errors.push(`screenshot: ${firstLine(error)}`);
|
|
@@ -459,23 +569,70 @@ async function replay(browser, plan, outDir, prefix) {
|
|
|
459
569
|
errors,
|
|
460
570
|
ignored: waypoint.ignored,
|
|
461
571
|
});
|
|
462
|
-
console.log(`${
|
|
572
|
+
console.log(`${dir} ${waypoint.n} ${waypoint.capture}: ${errors.length === 0 ? "ok" : errors.join("; ")}`);
|
|
463
573
|
}
|
|
464
574
|
} finally {
|
|
465
575
|
await context.close();
|
|
466
576
|
}
|
|
467
|
-
return results;
|
|
577
|
+
return { waypoints: results, signedIn };
|
|
468
578
|
}
|
|
469
579
|
|
|
470
580
|
// ── Report ──────────────────────────────────────────────────────────────────────────────────
|
|
471
581
|
|
|
472
582
|
const escapeMarkdown = (text) => String(text).replace(/([\\`*_[\]<>|])/g, "\\$1");
|
|
473
583
|
|
|
584
|
+
// The manifest other tools read, as an HTML comment so it never renders. JSON can carry `--` only
|
|
585
|
+
// inside strings, where `\u002d` means the same character, so escaping it keeps the comment closed.
|
|
586
|
+
export function embedManifest(manifest) {
|
|
587
|
+
const compact = (keepExpect) => ({
|
|
588
|
+
version: 2,
|
|
589
|
+
base: IMAGE_BASE,
|
|
590
|
+
sha: manifest.sha ?? "",
|
|
591
|
+
runUrl: manifest.runUrl ?? "",
|
|
592
|
+
...(manifest.app ? { app: manifest.app } : {}),
|
|
593
|
+
plans: (manifest.plans ?? []).map((plan) => ({
|
|
594
|
+
path: plan.path,
|
|
595
|
+
change: plan.change,
|
|
596
|
+
status: plan.status,
|
|
597
|
+
...(plan.reason ? { reason: plan.reason } : {}),
|
|
598
|
+
...(plan.waypoints
|
|
599
|
+
? {
|
|
600
|
+
waypoints: plan.waypoints.map((w) => ({
|
|
601
|
+
n: w.n,
|
|
602
|
+
capture: w.capture,
|
|
603
|
+
...(keepExpect && w.expect ? { expect: w.expect } : {}),
|
|
604
|
+
reached: w.reached,
|
|
605
|
+
file: w.file,
|
|
606
|
+
})),
|
|
607
|
+
}
|
|
608
|
+
: {}),
|
|
609
|
+
})),
|
|
610
|
+
});
|
|
611
|
+
let json = JSON.stringify(compact(true));
|
|
612
|
+
if (json.length > EMBED_LIMIT) json = JSON.stringify(compact(false));
|
|
613
|
+
if (json.length > EMBED_LIMIT) return "";
|
|
614
|
+
return `<!-- ${EMBED_MARKER}\n${json.replace(/-(?=-)/g, "\\u002d")}\n-->`;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Reads the embedded manifest back out of a comment body, or null when there is none.
|
|
618
|
+
export function readEmbeddedManifest(body) {
|
|
619
|
+
const match = String(body ?? "").match(new RegExp(`<!-- ${EMBED_MARKER}\\n([\\s\\S]*?)\\n-->`));
|
|
620
|
+
if (!match) return null;
|
|
621
|
+
try {
|
|
622
|
+
return JSON.parse(match[1]);
|
|
623
|
+
} catch {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
474
628
|
export function renderComment(manifest) {
|
|
475
629
|
const lines = [MARKER, "## Visual evidence", ""];
|
|
476
630
|
const short = manifest.sha ? manifest.sha.slice(0, 7) : "";
|
|
477
631
|
const run = manifest.runUrl ? ` ([run](${manifest.runUrl}))` : "";
|
|
478
632
|
lines.push(`The verification plan replayed against \`${short}\`, the head of this pull request${run}.`, "");
|
|
633
|
+
if ((manifest.plans ?? []).some((plan) => plan.status === "captured")) {
|
|
634
|
+
lines.push(`The screenshots are kept beside each plan, in its \`${EVIDENCE_DIR}/\` folder on the default branch.`, "");
|
|
635
|
+
}
|
|
479
636
|
|
|
480
637
|
if (manifest.app && manifest.app.started === false) {
|
|
481
638
|
lines.push(`Nothing was captured: the app did not answer at ${manifest.app.url} within ${manifest.app.waitedSeconds} seconds of \`${manifest.app.start}\`. The run log has the app's output.`, "");
|
|
@@ -491,6 +648,10 @@ export function renderComment(manifest) {
|
|
|
491
648
|
lines.push(`Not replayed: ${escapeMarkdown(plan.reason || plan.status)}.`, "");
|
|
492
649
|
continue;
|
|
493
650
|
}
|
|
651
|
+
if (plan.signIn) {
|
|
652
|
+
const as = plan.signIn.account ? ` as ${escapeMarkdown(plan.signIn.account)}` : "";
|
|
653
|
+
lines.push(plan.signIn.reached ? `Signed in${as} first.` : `> Could not sign in${as} first: ${escapeMarkdown(plan.signIn.errors.join("; "))}`, "");
|
|
654
|
+
}
|
|
494
655
|
const missed = plan.waypoints.filter((waypoint) => !waypoint.reached).length;
|
|
495
656
|
const summary = `${plan.waypoints.length} waypoint${plan.waypoints.length === 1 ? "" : "s"}${missed > 0 ? `, ${missed} did not reach their state` : ""}`;
|
|
496
657
|
lines.push("<details>", `<summary>${summary}</summary>`, "");
|
|
@@ -503,6 +664,8 @@ export function renderComment(manifest) {
|
|
|
503
664
|
}
|
|
504
665
|
lines.push("</details>", "");
|
|
505
666
|
}
|
|
667
|
+
const embedded = embedManifest(manifest);
|
|
668
|
+
if (embedded) lines.push(embedded, "");
|
|
506
669
|
return `${lines.join("\n").trimEnd()}\n`;
|
|
507
670
|
}
|
|
508
671
|
|
|
@@ -535,26 +698,43 @@ async function inspect(root, paths) {
|
|
|
535
698
|
};
|
|
536
699
|
}
|
|
537
700
|
|
|
701
|
+
// The repository's sign-in, or null when it has none. The path is relative to the working
|
|
702
|
+
// directory, which is the default branch's checkout, not the pull request head.
|
|
703
|
+
async function loadSignIn(path, parseYaml) {
|
|
704
|
+
let text;
|
|
705
|
+
try {
|
|
706
|
+
text = await readFile(path, "utf8");
|
|
707
|
+
} catch (error) {
|
|
708
|
+
if (error?.code === "ENOENT") return null;
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
711
|
+
return parseSignIn(text, parseYaml);
|
|
712
|
+
}
|
|
713
|
+
|
|
538
714
|
async function capture(root, outDir, paths) {
|
|
539
715
|
const { parse } = await import("yaml");
|
|
540
716
|
const { chromium } = await import("playwright-core");
|
|
541
717
|
await mkdir(outDir, { recursive: true });
|
|
542
718
|
const plans = await loadPlans(root, paths, parse);
|
|
543
719
|
const runnable = plans.filter((plan) => plan.status === "runnable");
|
|
544
|
-
const manifest = { version:
|
|
720
|
+
const manifest = { version: 2, plans: [] };
|
|
721
|
+
let signIn = null;
|
|
722
|
+
if (runnable.some((plan) => plan.login)) {
|
|
723
|
+
const path = process.env.EVIDENCE_SIGN_IN || SIGN_IN_FILE;
|
|
724
|
+
signIn = await loadSignIn(path, parse).catch((error) => ({ error: `${path}: ${firstLine(error)}` }));
|
|
725
|
+
}
|
|
545
726
|
|
|
546
727
|
const browser = runnable.length > 0 ? await chromium.launch({ headless: true }) : undefined;
|
|
547
728
|
try {
|
|
548
|
-
let index = 0;
|
|
549
729
|
for (const plan of plans) {
|
|
550
730
|
if (plan.status !== "runnable") {
|
|
551
731
|
manifest.plans.push({ path: plan.path, change: plan.change, status: plan.status, reason: plan.reason ?? "" });
|
|
552
732
|
continue;
|
|
553
733
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
const waypoints = await replay(browser, plan, outDir,
|
|
557
|
-
manifest.plans.push({ path: plan.path, change: plan.change, status: "captured", url: plan.url, waypoints });
|
|
734
|
+
const dir = planDir(plan.path);
|
|
735
|
+
await mkdir(join(outDir, "files", dir, EVIDENCE_DIR), { recursive: true });
|
|
736
|
+
const { waypoints, signedIn } = await replay(browser, { ...plan, signIn: signInFor(plan, signIn) }, outDir, dir);
|
|
737
|
+
manifest.plans.push({ path: plan.path, change: plan.change, status: "captured", url: plan.url, ...(signedIn ? { signIn: signedIn } : {}), waypoints });
|
|
558
738
|
}
|
|
559
739
|
} finally {
|
|
560
740
|
await browser?.close();
|
|
@@ -573,6 +753,12 @@ async function main(argv) {
|
|
|
573
753
|
const manifest = await capture(root, outDir, paths);
|
|
574
754
|
manifest.sha = process.env.EVIDENCE_SHA ?? "";
|
|
575
755
|
manifest.runUrl = process.env.EVIDENCE_RUN_URL ?? "";
|
|
756
|
+
// Each plan keeps its own record beside its screenshots, so the folder explains itself on the
|
|
757
|
+
// default branch without the pull request comment.
|
|
758
|
+
for (const plan of manifest.plans.filter((p) => p.status === "captured")) {
|
|
759
|
+
const record = { version: 2, sha: manifest.sha, runUrl: manifest.runUrl, plan };
|
|
760
|
+
await writeFile(join(outDir, "files", planDir(plan.path), EVIDENCE_DIR, "manifest.json"), `${JSON.stringify(record, null, 2)}\n`);
|
|
761
|
+
}
|
|
576
762
|
await writeFile(join(outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
577
763
|
await writeFile(join(outDir, "comment.md"), renderComment(manifest));
|
|
578
764
|
const shots = manifest.plans.flatMap((plan) => plan.waypoints ?? []).filter((waypoint) => waypoint.file).length;
|
|
@@ -586,7 +772,7 @@ async function main(argv) {
|
|
|
586
772
|
await mkdir(outDir, { recursive: true });
|
|
587
773
|
const plans = await loadPlans(root, paths, parse);
|
|
588
774
|
const manifest = {
|
|
589
|
-
version:
|
|
775
|
+
version: 2,
|
|
590
776
|
sha: process.env.EVIDENCE_SHA ?? "",
|
|
591
777
|
runUrl: process.env.EVIDENCE_RUN_URL ?? "",
|
|
592
778
|
app: {
|
|
@@ -1723,7 +1723,7 @@ else
|
|
|
1723
1723
|
echo "FAIL: evidence.yml's publish job holds a write token and must not check out or run repository code" >&2
|
|
1724
1724
|
fi
|
|
1725
1725
|
ev_absent 'secrets\.' "$EVIDENCE_YML" 'evidence.yml must need no secret; GITHUB_TOKEN is enough'
|
|
1726
|
-
ev_absent 'gh (issue|pr) comment.*--attach' "$EVIDENCE_YML" 'gh --attach refuses installation tokens;
|
|
1726
|
+
ev_absent 'gh (issue|pr) comment.*--attach' "$EVIDENCE_YML" 'gh --attach refuses installation tokens; commit the screenshots beside their plans'
|
|
1727
1727
|
fi
|
|
1728
1728
|
|
|
1729
1729
|
# The retired worker stays retired: nothing dispatches it and nothing waits for it.
|
package/loops/docs/diagrams.md
CHANGED
|
@@ -626,7 +626,10 @@ earlier versions of them passed against a deliberately broken guard and were rew
|
|
|
626
626
|
|
|
627
627
|
Archives what a merged pull request looks like. Not an agent: the plan was written by the agent that
|
|
628
628
|
implemented the change (`pc-repo-verify`), and this replays it. The router calls it when a pull
|
|
629
|
-
request merges, and `operation=evidence` runs it by hand. Nothing waits on it
|
|
629
|
+
request merges, and `operation=evidence` runs it by hand. Nothing waits on it. The screenshots are
|
|
630
|
+
committed beside each plan, in an `evidence/` folder on the default branch, so the record stays with
|
|
631
|
+
the change it proves; the comment embeds a manifest that Project Health reads. A pull request that
|
|
632
|
+
is not merged has no plan on the default branch, so its screenshots stay in the run's artifact.
|
|
630
633
|
The capture job runs the pull request's code with a read-only token; only the publish job writes,
|
|
631
634
|
and it runs no repository code.
|
|
632
635
|
|
|
@@ -645,8 +648,8 @@ flowchart TD
|
|
|
645
648
|
evUp -.->|"no, after the timeout"| evUnreached
|
|
646
649
|
evReplay["replay each waypoint<br/>in headless Chromium,<br/>one JPEG per waypoint"] --> evPublish
|
|
647
650
|
evUnreached["record why nothing<br/>was captured"] --> evPublish
|
|
648
|
-
evPublish["publish (GITHUB_TOKEN)<br/>commit
|
|
649
|
-
evDone(("Archived<br/>
|
|
651
|
+
evPublish["publish (GITHUB_TOKEN)<br/>commit beside each plan,<br/>evidence/ on the default branch,<br/>upsert one comment"] --> evDone
|
|
652
|
+
evDone(("Archived<br/>beside the plan"))
|
|
650
653
|
evIdle(("Nothing to capture"))
|
|
651
654
|
|
|
652
655
|
classDef start fill:#ffffff,stroke:#172033,stroke-width:2px,color:#172033
|
|
@@ -3,15 +3,17 @@ name: "Agentics: Evidence"
|
|
|
3
3
|
|
|
4
4
|
# Archives what a merged pull request looks like. The agent that implemented the change wrote a
|
|
5
5
|
# verification plan with the harness skill pc-repo-verify; this replays that plan against the
|
|
6
|
-
# app, with no model, and keeps one screenshot per waypoint
|
|
7
|
-
# it when a pull request merges, and operation=evidence runs it by hand for any pull
|
|
8
|
-
# Nothing waits on it
|
|
6
|
+
# app, with no model, and keeps one screenshot per waypoint beside the plan it came from. The
|
|
7
|
+
# router calls it when a pull request merges, and operation=evidence runs it by hand for any pull
|
|
8
|
+
# request. Nothing waits on it: it is the record, not a gate. Project Health reads the manifest
|
|
9
|
+
# embedded in the comment to show the screenshots with each issue.
|
|
9
10
|
#
|
|
10
11
|
# Two jobs, because one runs the pull request's code and the other writes. `capture` checks out
|
|
11
12
|
# the head, starts the app and drives Chromium with a read-only token and no secret. `publish`
|
|
12
|
-
# checks nothing out: it takes the images as an artifact, commits them
|
|
13
|
-
# branch and writes one comment. Both use GITHUB_TOKEN,
|
|
14
|
-
#
|
|
13
|
+
# checks nothing out: it takes the images as an artifact, commits them into an `evidence/` folder
|
|
14
|
+
# beside each plan on the default branch and writes one comment. Both use GITHUB_TOKEN, and nothing
|
|
15
|
+
# either job writes raises another workflow event. The one thing a repository may add is its own
|
|
16
|
+
# .github/evidence/sign-in.yml, for plans that start signed in rather than signing in themselves.
|
|
15
17
|
#
|
|
16
18
|
# This replaced agent-visual-verify, which never produced a screenshot: it ran only on pull
|
|
17
19
|
# requests nobody would review, raced the merge it was dispatched before, re-planned with a model
|
|
@@ -35,8 +37,10 @@ env:
|
|
|
35
37
|
EVIDENCE_START_TIMEOUT_SECONDS: "900"
|
|
36
38
|
# The SDK when a repository has .NET projects but no global.json to say which.
|
|
37
39
|
EVIDENCE_DOTNET_VERSION: "10.0.x"
|
|
38
|
-
EVIDENCE_BRANCH: evidence
|
|
39
40
|
EVIDENCE_MARKER: "<!-- agentic-evidence -->"
|
|
41
|
+
# How long the run keeps the screenshots it could not archive: a pull request that is not merged
|
|
42
|
+
# has no plan on the default branch to keep them beside.
|
|
43
|
+
EVIDENCE_ARTIFACT_DAYS: "14"
|
|
40
44
|
# The capture runs in mcr.microsoft.com/playwright at this tag, pre-pulled by the runner image
|
|
41
45
|
# (runners/versions.env), so the two move together.
|
|
42
46
|
PLAYWRIGHT_VERSION: "1.62.1"
|
|
@@ -66,8 +70,11 @@ jobs:
|
|
|
66
70
|
const pull_number = Number(process.env.PR_NUMBER);
|
|
67
71
|
const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number });
|
|
68
72
|
const files = await github.paginate(github.rest.pulls.listFiles, { ...context.repo, pull_number, per_page: 100 });
|
|
73
|
+
// A plan that was only moved (the archive step, in a later pull request) is skipped: its
|
|
74
|
+
// evidence/ folder moves with it, so replaying it would only repeat the record.
|
|
69
75
|
const plans = files
|
|
70
76
|
.filter((file) => file.status !== 'removed' && /(^|\/)verification-plan\.md$/.test(file.filename))
|
|
77
|
+
.filter((file) => !(file.status === 'renamed' && file.changes === 0))
|
|
71
78
|
.map((file) => file.filename);
|
|
72
79
|
core.setOutput('sha', pr.head.sha);
|
|
73
80
|
core.setOutput('plans', plans.join('\n'));
|
|
@@ -185,6 +192,25 @@ jobs:
|
|
|
185
192
|
echo "::group::Last lines of the app's output"
|
|
186
193
|
tail -n 80 "$log" || true
|
|
187
194
|
echo "::endgroup::"
|
|
195
|
+
# An orchestrator (Aspire, compose) keeps each service's output to itself, so the
|
|
196
|
+
# start command's own log rarely says which service never came up. Show them.
|
|
197
|
+
echo "::group::Listening ports"
|
|
198
|
+
(ss -ltnp 2>/dev/null || netstat -ltnp 2>/dev/null) | head -n 40 || true
|
|
199
|
+
echo "::endgroup::"
|
|
200
|
+
echo "::group::Containers"
|
|
201
|
+
docker ps -a --format '{{.Names}} {{.Status}} {{.Image}}' 2>/dev/null || true
|
|
202
|
+
echo "::endgroup::"
|
|
203
|
+
for id in $(docker ps -aq 2>/dev/null); do
|
|
204
|
+
echo "::group::Container $(docker inspect -f '{{.Name}}' "$id" 2>/dev/null)"
|
|
205
|
+
docker logs --tail 60 "$id" 2>&1 || true
|
|
206
|
+
echo "::endgroup::"
|
|
207
|
+
done
|
|
208
|
+
for f in "$HOME"/.aspire/logs/*.log; do
|
|
209
|
+
[ -f "$f" ] || continue
|
|
210
|
+
echo "::group::$f"
|
|
211
|
+
tail -n 200 "$f" || true
|
|
212
|
+
echo "::endgroup::"
|
|
213
|
+
done
|
|
188
214
|
fi
|
|
189
215
|
|
|
190
216
|
- name: Replay the plans
|
|
@@ -240,7 +266,7 @@ jobs:
|
|
|
240
266
|
with:
|
|
241
267
|
name: evidence-pr-${{ inputs.pr-number }}
|
|
242
268
|
path: ${{ runner.temp }}/evidence
|
|
243
|
-
retention-days:
|
|
269
|
+
retention-days: ${{ env.EVIDENCE_ARTIFACT_DAYS }}
|
|
244
270
|
if-no-files-found: error
|
|
245
271
|
|
|
246
272
|
publish:
|
|
@@ -259,72 +285,113 @@ jobs:
|
|
|
259
285
|
name: evidence-pr-${{ inputs.pr-number }}
|
|
260
286
|
path: ${{ runner.temp }}/evidence
|
|
261
287
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
288
|
+
- name: Read the pull request
|
|
289
|
+
id: pr
|
|
290
|
+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
291
|
+
env:
|
|
292
|
+
PR_NUMBER: ${{ inputs.pr-number }}
|
|
293
|
+
with:
|
|
294
|
+
script: |
|
|
295
|
+
const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: Number(process.env.PR_NUMBER) });
|
|
296
|
+
core.setOutput('merged', pr.merged ? 'true' : 'false');
|
|
297
|
+
core.setOutput('branch', pr.base.ref);
|
|
298
|
+
|
|
299
|
+
# Beside each plan on the default branch, where the plan itself is archived: the record stays
|
|
300
|
+
# with the change it proves, and moves with it if the change folder ever moves. Only a merged
|
|
301
|
+
# pull request's plans are there to keep it beside. A sparse, blob-less fetch reads only the
|
|
302
|
+
# plan folders, and [skip ci] plus GITHUB_TOKEN keep the commit from starting any workflow.
|
|
303
|
+
- name: Commit the screenshots beside their plans
|
|
304
|
+
id: archive
|
|
305
|
+
if: steps.pr.outputs.merged == 'true'
|
|
268
306
|
env:
|
|
269
307
|
TOKEN: ${{ github.token }}
|
|
270
308
|
REPO: ${{ github.repository }}
|
|
271
309
|
SERVER: ${{ github.server_url }}
|
|
272
|
-
BRANCH: ${{
|
|
310
|
+
BRANCH: ${{ steps.pr.outputs.branch }}
|
|
273
311
|
PR: ${{ inputs.pr-number }}
|
|
274
312
|
run: |
|
|
275
313
|
set -euo pipefail
|
|
276
|
-
src="$RUNNER_TEMP/evidence"
|
|
277
|
-
if !
|
|
314
|
+
src="$RUNNER_TEMP/evidence/files"
|
|
315
|
+
if [ ! -d "$src" ] || [ -z "$(find "$src" -name '*.jpg' -print -quit)" ]; then
|
|
278
316
|
echo "No screenshots to keep; the comment records why."
|
|
279
317
|
echo "base=" >> "$GITHUB_OUTPUT"
|
|
280
318
|
exit 0
|
|
281
319
|
fi
|
|
282
320
|
|
|
283
|
-
|
|
321
|
+
# Each folder holding screenshots is <plan directory>/evidence.
|
|
322
|
+
mapfile -t dirs < <(cd "$src" && find . -type d -name evidence | sed 's|^\./||; s|/evidence$||' | sort)
|
|
323
|
+
|
|
324
|
+
work="$RUNNER_TEMP/evidence-commit"
|
|
284
325
|
rm -rf "$work"
|
|
285
|
-
|
|
326
|
+
auth="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$TOKEN" | base64 -w0)"
|
|
327
|
+
# Blob-less and sparse: only the plan folders are downloaded, however large the repository.
|
|
328
|
+
git -c "http.${SERVER}/.extraheader=${auth}" clone -q --filter=blob:none --no-checkout --depth=1 \
|
|
329
|
+
--branch "$BRANCH" "${SERVER}/${REPO}.git" "$work"
|
|
286
330
|
cd "$work"
|
|
287
|
-
git
|
|
331
|
+
git config "http.${SERVER}/.extraheader" "$auth"
|
|
288
332
|
git config user.name "github-actions[bot]"
|
|
289
333
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
290
|
-
|
|
291
|
-
|
|
334
|
+
patterns=()
|
|
335
|
+
for dir in "${dirs[@]}"; do patterns+=("/$dir/"); done
|
|
336
|
+
git sparse-checkout set --no-cone "${patterns[@]}"
|
|
292
337
|
|
|
293
338
|
pushed=false
|
|
339
|
+
missing=()
|
|
294
340
|
for attempt in 1 2 3 4 5; do
|
|
295
|
-
if
|
|
296
|
-
git
|
|
297
|
-
git checkout -q -f -B "$BRANCH" FETCH_HEAD
|
|
341
|
+
if [ "$attempt" -eq 1 ]; then
|
|
342
|
+
git checkout -q "$BRANCH"
|
|
298
343
|
else
|
|
299
|
-
git
|
|
300
|
-
git
|
|
301
|
-
printf '%s\n' "# Evidence" "" \
|
|
302
|
-
"Screenshots the evidence workflow replayed from each merged pull request's verification plan, one folder per pull request." \
|
|
303
|
-
"Written by a workflow, never by hand. Nothing here is built, deployed or merged." > README.md
|
|
304
|
-
git add README.md
|
|
344
|
+
git fetch -q --depth=1 origin "$BRANCH"
|
|
345
|
+
git reset -q --hard FETCH_HEAD
|
|
305
346
|
fi
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
347
|
+
missing=()
|
|
348
|
+
for dir in "${dirs[@]}"; do
|
|
349
|
+
# A plan moved or removed since the merge has no folder to keep the record in.
|
|
350
|
+
if [ ! -f "$dir/verification-plan.md" ]; then
|
|
351
|
+
missing+=("$dir")
|
|
352
|
+
continue
|
|
353
|
+
fi
|
|
354
|
+
rm -rf "$dir/evidence"
|
|
355
|
+
mkdir -p "$dir/evidence"
|
|
356
|
+
cp -R "$src/$dir/evidence/." "$dir/evidence/"
|
|
357
|
+
git add -A "$dir/evidence"
|
|
358
|
+
done
|
|
311
359
|
if git diff --cached --quiet; then
|
|
312
360
|
pushed=true
|
|
313
361
|
break
|
|
314
362
|
fi
|
|
315
|
-
git commit -q -m "evidence:
|
|
363
|
+
git commit -q -m "evidence: screenshots for #$PR [skip ci]"
|
|
316
364
|
if git push -q origin "HEAD:refs/heads/$BRANCH"; then
|
|
317
365
|
pushed=true
|
|
318
366
|
break
|
|
319
367
|
fi
|
|
320
|
-
echo "
|
|
368
|
+
echo "$BRANCH moved before the push (attempt $attempt); retrying on its new tip."
|
|
321
369
|
sleep $((attempt * 3))
|
|
322
370
|
done
|
|
323
|
-
[ "$pushed"
|
|
371
|
+
if [ "$pushed" != "true" ]; then
|
|
372
|
+
# A protected default branch refuses the push. The comment says so, and the run's
|
|
373
|
+
# artifact keeps the screenshots, so the merge that already happened stays green.
|
|
374
|
+
echo "::warning::could not push the screenshots to $BRANCH after 5 attempts"
|
|
375
|
+
echo "base=" >> "$GITHUB_OUTPUT"
|
|
376
|
+
echo "reason=the push to $BRANCH was refused" >> "$GITHUB_OUTPUT"
|
|
377
|
+
exit 0
|
|
378
|
+
fi
|
|
379
|
+
|
|
380
|
+
if [ "${#missing[@]}" -eq "${#dirs[@]}" ]; then
|
|
381
|
+
echo "None of the plans is on $BRANCH any more; nothing was kept."
|
|
382
|
+
echo "base=" >> "$GITHUB_OUTPUT"
|
|
383
|
+
echo "reason=none of the plans is on $BRANCH any more" >> "$GITHUB_OUTPUT"
|
|
384
|
+
exit 0
|
|
385
|
+
fi
|
|
324
386
|
|
|
325
387
|
sha="$(git rev-parse HEAD)"
|
|
326
|
-
echo "base=${SERVER}/${REPO}/raw/${sha}
|
|
327
|
-
|
|
388
|
+
echo "base=${SERVER}/${REPO}/raw/${sha}" >> "$GITHUB_OUTPUT"
|
|
389
|
+
{
|
|
390
|
+
echo "missing<<EOF"
|
|
391
|
+
printf '%s\n' "${missing[@]}"
|
|
392
|
+
echo "EOF"
|
|
393
|
+
} >> "$GITHUB_OUTPUT"
|
|
394
|
+
echo "Screenshots kept at ${SERVER}/${REPO}/tree/${sha} beside $(( ${#dirs[@]} - ${#missing[@]} )) plan(s)"
|
|
328
395
|
|
|
329
396
|
# GITHUB_TOKEN on purpose: its comment raises no workflow event, so the router does not wake
|
|
330
397
|
# up to read its own record. One comment per pull request, rewritten on a re-run.
|
|
@@ -333,14 +400,56 @@ jobs:
|
|
|
333
400
|
env:
|
|
334
401
|
PR_NUMBER: ${{ inputs.pr-number }}
|
|
335
402
|
MARKER: ${{ env.EVIDENCE_MARKER }}
|
|
336
|
-
|
|
403
|
+
MERGED: ${{ steps.pr.outputs.merged }}
|
|
404
|
+
IMAGE_BASE: ${{ steps.archive.outputs.base }}
|
|
405
|
+
MISSING: ${{ steps.archive.outputs.missing }}
|
|
406
|
+
REASON: ${{ steps.archive.outputs.reason }}
|
|
407
|
+
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
408
|
+
ARTIFACT_DAYS: ${{ env.EVIDENCE_ARTIFACT_DAYS }}
|
|
337
409
|
with:
|
|
338
410
|
script: |
|
|
339
411
|
const fs = require('node:fs');
|
|
340
412
|
const path = require('node:path');
|
|
341
413
|
const issue_number = Number(process.env.PR_NUMBER);
|
|
342
|
-
const
|
|
343
|
-
|
|
414
|
+
const evidence = path.join(process.env.RUNNER_TEMP, 'evidence');
|
|
415
|
+
let source = fs.readFileSync(path.join(evidence, 'comment.md'), 'utf8');
|
|
416
|
+
const base = process.env.IMAGE_BASE ?? '';
|
|
417
|
+
const missing = (process.env.MISSING ?? '').split('\n').map((line) => line.trim()).filter(Boolean);
|
|
418
|
+
const kept = (file) => Boolean(base) && !missing.some((dir) => file.startsWith(`${dir}/evidence/`));
|
|
419
|
+
|
|
420
|
+
// An image that was not archived would render broken: its line goes, and a note says
|
|
421
|
+
// the run's artifact has it instead.
|
|
422
|
+
source = source.replace(/^!\[[^\]]*\]\(__EVIDENCE_BASE__\/([^)]*)\)\n?/gm, (line, file) => (kept(file) ? line : ''));
|
|
423
|
+
const shots = fs.readdirSync(evidence, { recursive: true }).some((name) => String(name).endsWith('.jpg'));
|
|
424
|
+
if (!base) source = source.replace(/^The screenshots are kept beside each plan.*\n\n?/m, '');
|
|
425
|
+
if (shots && (!base || missing.length > 0)) {
|
|
426
|
+
const why = process.env.MERGED !== 'true'
|
|
427
|
+
? 'the pull request is not merged, so its plans are not on the default branch'
|
|
428
|
+
: !base
|
|
429
|
+
? (process.env.REASON || 'the screenshots could not be committed')
|
|
430
|
+
: `${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} no longer on the default branch`;
|
|
431
|
+
source = source.replace('## Visual evidence\n', `## Visual evidence\n\n> Not archived: ${why}. The screenshots are in [the run](${process.env.RUN_URL})'s artifact for ${process.env.ARTIFACT_DAYS} days.\n`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// The embedded manifest is what other tools read, so it says exactly what was kept: the
|
|
435
|
+
// base where the files are, and no file for a screenshot that was not archived. It sits
|
|
436
|
+
// in an HTML comment, which a `--` would close; `\u002d` is the same to a JSON reader.
|
|
437
|
+
source = source.replace(/<!-- agentic-evidence-manifest\n([\s\S]*?)\n-->/, (block, json) => {
|
|
438
|
+
let data;
|
|
439
|
+
try {
|
|
440
|
+
data = JSON.parse(json);
|
|
441
|
+
} catch {
|
|
442
|
+
return block;
|
|
443
|
+
}
|
|
444
|
+
data.base = base || null;
|
|
445
|
+
for (const plan of data.plans ?? []) {
|
|
446
|
+
for (const waypoint of plan.waypoints ?? []) {
|
|
447
|
+
if (waypoint.file && !kept(waypoint.file)) waypoint.file = null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return `<!-- agentic-evidence-manifest\n${JSON.stringify(data).replace(/-(?=-)/g, '\\u002d')}\n-->`;
|
|
451
|
+
});
|
|
452
|
+
const body = source.split('__EVIDENCE_BASE__').join(base);
|
|
344
453
|
const comments = await github.paginate(github.rest.issues.listComments, { ...context.repo, issue_number, per_page: 100 });
|
|
345
454
|
const existing = comments.find((comment) => comment.user?.type === 'Bot' && (comment.body ?? '').includes(process.env.MARKER));
|
|
346
455
|
if (existing) {
|
|
@@ -630,8 +630,8 @@ jobs:
|
|
|
630
630
|
AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
|
|
631
631
|
|
|
632
632
|
# Screenshots of a merged pull request, replayed from the verification plan its implementation
|
|
633
|
-
# wrote and archived
|
|
634
|
-
# grant is exactly what the publish job writes:
|
|
633
|
+
# wrote and archived beside that plan. Plain YAML with no model, so no secret is passed and the
|
|
634
|
+
# grant is exactly what the publish job writes: one commit of evidence/ folders and one comment. Its capture
|
|
635
635
|
# job runs the pull request's code with read-only access; only publish holds these writes, and it
|
|
636
636
|
# runs no repository code.
|
|
637
637
|
call-evidence:
|