@opsee/cli 0.11.9
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/README.md +1962 -0
- package/bin/opsee.js +28 -0
- package/package.json +40 -0
- package/skills/README.md +3 -0
- package/skills/to-issues/SKILL.md +92 -0
- package/skills/to-issues/agents/openai.yaml +5 -0
- package/skills/to-spec/SKILL.md +79 -0
- package/skills/to-spec/agents/openai.yaml +5 -0
- package/skills/wayfinder/SKILL.md +138 -0
- package/skills/wayfinder/agents/openai.yaml +5 -0
- package/src/args.ts +676 -0
- package/src/cli.ts +341 -0
- package/src/commands/account.ts +121 -0
- package/src/commands/deps.ts +11 -0
- package/src/commands/foreman-control.ts +242 -0
- package/src/commands/foreman-debug.ts +131 -0
- package/src/commands/foreman-plan.ts +213 -0
- package/src/commands/foreman-service.ts +186 -0
- package/src/commands/foreman-up.ts +165 -0
- package/src/commands/foreman-views.ts +398 -0
- package/src/commands/foreman.ts +465 -0
- package/src/commands/init.ts +176 -0
- package/src/commands/initiative.ts +192 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/whoami.ts +15 -0
- package/src/foreman/account-store.ts +96 -0
- package/src/foreman/account.ts +474 -0
- package/src/foreman/claude-worker-adapter.ts +412 -0
- package/src/foreman/codex-worker-adapter.ts +472 -0
- package/src/foreman/completion-report.ts +153 -0
- package/src/foreman/core/context.ts +169 -0
- package/src/foreman/core/defects.ts +280 -0
- package/src/foreman/core/exec.ts +20 -0
- package/src/foreman/core/gates.ts +493 -0
- package/src/foreman/core/handoff.ts +163 -0
- package/src/foreman/core/install.ts +109 -0
- package/src/foreman/core/learnings.ts +368 -0
- package/src/foreman/core/outbox-tracker.ts +192 -0
- package/src/foreman/core/pin.ts +226 -0
- package/src/foreman/core/plan-context.ts +238 -0
- package/src/foreman/core/process-table.ts +535 -0
- package/src/foreman/core/reconcile.ts +227 -0
- package/src/foreman/core/report.ts +60 -0
- package/src/foreman/core/run.ts +2836 -0
- package/src/foreman/core/scheduler.ts +244 -0
- package/src/foreman/core/summary.ts +166 -0
- package/src/foreman/core/text.ts +97 -0
- package/src/foreman/core/transcripts.ts +38 -0
- package/src/foreman/core/triage.ts +138 -0
- package/src/foreman/core/verifier.ts +800 -0
- package/src/foreman/core/views.ts +940 -0
- package/src/foreman/core/work-contract.ts +152 -0
- package/src/foreman/core/workspace.ts +335 -0
- package/src/foreman/fake-handoff.ts +33 -0
- package/src/foreman/fake-learnings.ts +26 -0
- package/src/foreman/fake-remote-api.ts +70 -0
- package/src/foreman/fake-tracker-adapter.ts +355 -0
- package/src/foreman/fake-worker-adapter.ts +221 -0
- package/src/foreman/host.ts +75 -0
- package/src/foreman/local-dir.ts +28 -0
- package/src/foreman/opsee-tracker-adapter.ts +612 -0
- package/src/foreman/process-group.ts +160 -0
- package/src/foreman/remote-api.ts +283 -0
- package/src/foreman/run-recipe.ts +274 -0
- package/src/foreman/service-unit.ts +257 -0
- package/src/foreman/tracker-adapter.ts +298 -0
- package/src/foreman/triage-draft.ts +40 -0
- package/src/foreman/vendor.ts +23 -0
- package/src/foreman/verdict.ts +120 -0
- package/src/foreman/worker-adapter.ts +177 -0
- package/src/foreman/worker-process.ts +488 -0
- package/src/identity.ts +49 -0
- package/src/index.ts +3 -0
- package/src/init/managed.ts +84 -0
- package/src/init/mcp-config.ts +77 -0
- package/src/init/paths.ts +16 -0
- package/src/init/pointer-block.ts +45 -0
- package/src/init/project.ts +22 -0
- package/src/init/prompt.ts +45 -0
- package/src/init/run-recipe-config.ts +133 -0
- package/src/init/skills.ts +38 -0
- package/src/init/text.ts +22 -0
- package/src/init/tracker-doc.ts +106 -0
- package/src/opsee-config.ts +116 -0
- package/templates/issue-tracker.md +162 -0
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Verifier (see ../../../CONTEXT.md: Verifier, Verdict, Defect, Run Recipe; spec stories 37 to
|
|
3
|
+
* 42; ADR-0004): after a Task's Gates pass, the Foreman starts the app from the base's Run Recipe
|
|
4
|
+
* on a port of the Task's own, waits on the readiness URL, and runs a second, separate Worker
|
|
5
|
+
* through the same Worker Adapter with Playwright MCP configured headless and isolated. That
|
|
6
|
+
* Worker's prompt is the Task's Verification section and the app's URL and nothing else: never the
|
|
7
|
+
* implementer's prompt, transcript or report, so the work is judged by something that did not
|
|
8
|
+
* write it. It runs in a scratch directory of its own, not the Workspace, and must end with a
|
|
9
|
+
* Verdict (../verdict.ts), the output contract that replaces the Completion Report for that turn.
|
|
10
|
+
*
|
|
11
|
+
* Only a Verification section that describes a user journey in a browser gets this
|
|
12
|
+
* (`needsBrowserVerification`: a UI cue outside code spans and fences, so a section of commands
|
|
13
|
+
* alone stops at the Gates, story 42); the recipe's `verify: browser | none` overrides the rule.
|
|
14
|
+
* The recipe is read the way the Gates read theirs (gates.ts `baseConfigFiles`): from
|
|
15
|
+
* `origin/<default>`, never from the Workspace the Worker wrote, so a Task cannot turn its own
|
|
16
|
+
* verification off. The app's shell gets the Gates' filtered environment plus the port variable.
|
|
17
|
+
*
|
|
18
|
+
* The Verifier turn is the least trusted turn the Foreman runs: its prompt quotes text the Task's
|
|
19
|
+
* author wrote and its tools drive a browser over pages the implementer wrote, so it runs under
|
|
20
|
+
* the `readonly-browser` sandbox (../worker-adapter.ts `TurnRequest.sandbox`): no built-in tools
|
|
21
|
+
* on Claude Code, a read-only sandbox on Codex, Playwright MCP the only tool either way, and an
|
|
22
|
+
* environment without the Foreman's own credentials (../worker-process.ts `VERIFIER_STRIPPED_ENV`).
|
|
23
|
+
* Playwright MCP is pinned (`PLAYWRIGHT_MCP_VERSION`), run from `node_modules` when installed, and
|
|
24
|
+
* told the app's origin as the only trusted one.
|
|
25
|
+
*
|
|
26
|
+
* Ports: one lease per Task from `PortLease` (a free port the OS hands out, held until the app is
|
|
27
|
+
* stopped), so two Tasks verified at once run on two ports; the app is stopped with its whole
|
|
28
|
+
* process group whatever happened. Screenshots the Verifier took are copied to the local evidence
|
|
29
|
+
* directory (`~/.opsee/verification/<initiative>/<identifier>-<attempt>/`) before the scratch
|
|
30
|
+
* directory goes, after `keepScreenshots` has checked each is a real image inside the Verifier's
|
|
31
|
+
* own screenshot directory; the loop (run.ts) uploads them to the pull request, writes the
|
|
32
|
+
* `verdict` event in the Ledger's shape (`verdictEvent`) and posts the Verdict as a comment
|
|
33
|
+
* (`verdictComment`). A Verifier turn that ends without a valid Verdict, an app that never becomes
|
|
34
|
+
* ready, a recipe that says `verify: browser` but cannot start the app: each is a failed round,
|
|
35
|
+
* recorded as a failed Verdict with one Defect that says so (`VERIFIER_FAILURE_TITLE`), never a
|
|
36
|
+
* pass.
|
|
37
|
+
*/
|
|
38
|
+
import { closeSync, copyFileSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, realpathSync, rmSync } from "node:fs";
|
|
39
|
+
import { createRequire } from "node:module";
|
|
40
|
+
import { createServer } from "node:net";
|
|
41
|
+
import { tmpdir } from "node:os";
|
|
42
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
43
|
+
import { create, type MessageInitShape } from "@bufbuild/protobuf";
|
|
44
|
+
import { timestampFromMs } from "@bufbuild/protobuf/wkt";
|
|
45
|
+
import { RunEventInputSchema, type RunEvent, type RunEventInput, type RunVerificationSchema } from "@opsee/mcp-server/gen/api/v1/initiative_pb.js";
|
|
46
|
+
import { parseOpseeJson, readYamlBlock, type OpseeConfigFiles } from "../../opsee-config.js";
|
|
47
|
+
import type { Account } from "../account.js";
|
|
48
|
+
import { foremanLocalDir } from "../local-dir.js";
|
|
49
|
+
import { RECIPE_KEY, recipeFromConfig, startApp, waitForReady, type RunRecipe, type VerifyMode } from "../run-recipe.js";
|
|
50
|
+
import type { TrackerTask } from "../tracker-adapter.js";
|
|
51
|
+
import { parseVerdict, VERDICT_CONTRACT, type Defect, type Verdict } from "../verdict.js";
|
|
52
|
+
import type { AdapterEvent, McpServerSpec, WorkerAdapter } from "../worker-adapter.js";
|
|
53
|
+
import { baseConfigFiles, gateCommandText, gateEnv, gateTail, KILLED_EXIT_CODE, truncateCodePoints } from "./gates.js";
|
|
54
|
+
import { AGENT_TEXT_NOTE, hostFenced, markdownInline } from "./text.js";
|
|
55
|
+
import { tail } from "./install.js";
|
|
56
|
+
import type { TranscriptStore } from "./transcripts.js";
|
|
57
|
+
import type { GitRunner, Workspace } from "./workspace.js";
|
|
58
|
+
|
|
59
|
+
/** How long the app may take to answer on its readiness URL: a cold Vite or Go dev server. */
|
|
60
|
+
export const APP_READY_TIMEOUT_MS = 120_000;
|
|
61
|
+
|
|
62
|
+
/** Agentic turns a Verifier gets: a browser action per tool call, a journey of a dozen steps
|
|
63
|
+
* with a screenshot each fits well inside; the Run's own `maxTurns` lowers it, never raises it. */
|
|
64
|
+
export const VERIFIER_MAX_TURNS = 60;
|
|
65
|
+
|
|
66
|
+
/** The one directory the Verifier writes screenshots to, under its scratch directory: the prompt
|
|
67
|
+
* names it and Playwright MCP's `--output-dir` is it, so a relative filename the Verifier gives
|
|
68
|
+
* `browser_take_screenshot` lands where the Verdict's relative path is then resolved. */
|
|
69
|
+
export const SCREENSHOT_DIR = "screenshots";
|
|
70
|
+
|
|
71
|
+
/** The largest screenshot kept as evidence; a full-page PNG is a few hundred kilobytes. */
|
|
72
|
+
export const SCREENSHOT_MAX_BYTES = 10 * 1024 * 1024;
|
|
73
|
+
|
|
74
|
+
/** The title a Defect gets when the round itself failed (no Verdict, no app), so a reader, and
|
|
75
|
+
* the Defect filer (OPS-273), can tell it from a Defect the Verifier observed. */
|
|
76
|
+
export const VERIFIER_FAILURE_TITLE = "Verification round failed";
|
|
77
|
+
|
|
78
|
+
/** The proto limits on a Defect (proto/api/v1/initiative.proto `RunDefect`), applied by code
|
|
79
|
+
* point in `verdictEvent` so an over-long Verdict is recorded cut rather than refused. */
|
|
80
|
+
export const DEFECT_TITLE_MAX = 255;
|
|
81
|
+
export const DEFECT_TEXT_MAX = 20_000;
|
|
82
|
+
export const EVIDENCE_URL_MAX = 2_000;
|
|
83
|
+
|
|
84
|
+
/** The words that make a Verification section a user journey rather than a list of commands,
|
|
85
|
+
* matched case-insensitively on word boundaries, outside code spans and fenced blocks (a `curl
|
|
86
|
+
* http://localhost:8080/health` in backticks is a command; so is a fenced block of them). An
|
|
87
|
+
* `http://` URL in the prose counts too. Explicit so the rule can be read and tested rather than
|
|
88
|
+
* inferred. Words a backend Task's prose uses as well (route, URL, form, link, render, renders)
|
|
89
|
+
* are left out: `verify: browser` in the recipe is the way to insist. */
|
|
90
|
+
export const UI_JOURNEY_CUES: readonly string[] = [
|
|
91
|
+
"browser",
|
|
92
|
+
"page",
|
|
93
|
+
"pages",
|
|
94
|
+
"screen",
|
|
95
|
+
"screens",
|
|
96
|
+
"open",
|
|
97
|
+
"opens",
|
|
98
|
+
"visit",
|
|
99
|
+
"visits",
|
|
100
|
+
"go to",
|
|
101
|
+
"goes to",
|
|
102
|
+
"log in",
|
|
103
|
+
"logs in",
|
|
104
|
+
"login",
|
|
105
|
+
"sign in",
|
|
106
|
+
"signs in",
|
|
107
|
+
"click",
|
|
108
|
+
"clicks",
|
|
109
|
+
"clicking",
|
|
110
|
+
"tap",
|
|
111
|
+
"taps",
|
|
112
|
+
"select",
|
|
113
|
+
"selects",
|
|
114
|
+
"type",
|
|
115
|
+
"types",
|
|
116
|
+
"submit",
|
|
117
|
+
"submits",
|
|
118
|
+
"toggle",
|
|
119
|
+
"toggles",
|
|
120
|
+
"hover",
|
|
121
|
+
"hovers",
|
|
122
|
+
"navigate",
|
|
123
|
+
"navigates",
|
|
124
|
+
"navigation",
|
|
125
|
+
"button",
|
|
126
|
+
"buttons",
|
|
127
|
+
"modal",
|
|
128
|
+
"dialog",
|
|
129
|
+
"dropdown",
|
|
130
|
+
"menu",
|
|
131
|
+
"dashboard",
|
|
132
|
+
"table",
|
|
133
|
+
"list",
|
|
134
|
+
"tab",
|
|
135
|
+
"tabs",
|
|
136
|
+
"checkbox",
|
|
137
|
+
"input",
|
|
138
|
+
"field",
|
|
139
|
+
"label",
|
|
140
|
+
"sidebar",
|
|
141
|
+
"toast",
|
|
142
|
+
"banner",
|
|
143
|
+
"scroll",
|
|
144
|
+
"scrolls",
|
|
145
|
+
"visible",
|
|
146
|
+
"headless",
|
|
147
|
+
"playwright",
|
|
148
|
+
"web app",
|
|
149
|
+
"open the app",
|
|
150
|
+
"in the app",
|
|
151
|
+
"localhost",
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
/** The Verification section with its code spans and fenced blocks removed: what the cues are
|
|
155
|
+
* looked for in. */
|
|
156
|
+
export function proseOf(verification: string): string {
|
|
157
|
+
return verification
|
|
158
|
+
.replace(/^ {0,3}(```|~~~)[\s\S]*?^ {0,3}\1[^\n]*$/gm, " ")
|
|
159
|
+
.replace(/`[^`\n]*`/g, " ");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface BrowserDecision {
|
|
163
|
+
browser: boolean;
|
|
164
|
+
reason: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Whether a Verification section asks for a browser: any UI cue (or an http URL) in its prose,
|
|
168
|
+
* outside code. Commands alone are the Gates' business. */
|
|
169
|
+
export function needsBrowserVerification(verification: string): BrowserDecision {
|
|
170
|
+
const prose = proseOf(verification);
|
|
171
|
+
const escape = (c: string) => c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
172
|
+
for (const cue of UI_JOURNEY_CUES) {
|
|
173
|
+
if (new RegExp(`(^|[^A-Za-z0-9_])${escape(cue)}(?![A-Za-z0-9_])`, "i").test(prose)) {
|
|
174
|
+
return { browser: true, reason: `the Verification section describes a user journey ("${cue}")` };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (/https?:\/\//i.test(prose)) return { browser: true, reason: "the Verification section names a URL outside code" };
|
|
178
|
+
return { browser: false, reason: "the Verification section has commands only, no user journey" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The decision with the recipe's `verify` mode applied. */
|
|
182
|
+
export function decideBrowserVerification(verification: string, mode: VerifyMode | undefined): BrowserDecision {
|
|
183
|
+
switch (mode) {
|
|
184
|
+
case "browser":
|
|
185
|
+
return { browser: true, reason: "the Run Recipe says verify: browser" };
|
|
186
|
+
case "none":
|
|
187
|
+
return { browser: false, reason: "the Run Recipe says verify: none" };
|
|
188
|
+
default:
|
|
189
|
+
return needsBrowserVerification(verification);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The base's Run Recipe read leniently: absent (no config, no `foreman` block, no `start`) is a
|
|
194
|
+
* reason to skip, not an error; only a block whose `verify` is not a known word is reported as
|
|
195
|
+
* invalid, since that is a typo a human would want named. `verify` is the block's own word,
|
|
196
|
+
* whatever else it lacks, so a block that insists on the browser but cannot start the app is a
|
|
197
|
+
* failed round rather than a skip (`verifierWith`). */
|
|
198
|
+
export function recipeFrom(files: OpseeConfigFiles): { recipe?: RunRecipe; reason?: string; verify?: unknown } {
|
|
199
|
+
let block: unknown;
|
|
200
|
+
if (files.json !== null) block = parseOpseeJson(files.json)?.[RECIPE_KEY];
|
|
201
|
+
else if (files.yaml !== null) block = readYamlBlock(files.yaml, RECIPE_KEY) ?? undefined;
|
|
202
|
+
if (!block || typeof block !== "object") return { reason: "the Run Recipe has no foreman block" };
|
|
203
|
+
const verify = (block as Record<string, unknown>).verify;
|
|
204
|
+
try {
|
|
205
|
+
return { recipe: recipeFromConfig(block, RECIPE_KEY), verify };
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return { reason: error instanceof Error ? error.message : String(error), verify };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** One port number per Task, for as long as its app runs. The OS hands out a free port (bind to
|
|
212
|
+
* 0, read it, close), so the port itself is released again before the app binds it: what the
|
|
213
|
+
* lease holds is the number, in this process, so that two Tasks verified at once by this Foreman
|
|
214
|
+
* never start their apps on the same one. A stranger process taking the number in between is
|
|
215
|
+
* the app's start failing, which the readiness wait reports. The check against the held numbers
|
|
216
|
+
* is made after the OS answered, since another acquire may have taken that number meanwhile. */
|
|
217
|
+
/**
|
|
218
|
+
* The ports of the apps Verifier rounds are running, one per Task, so two Tasks verified at once do
|
|
219
|
+
* not both take 3000.
|
|
220
|
+
*
|
|
221
|
+
* In-process only, and the probe under it is bind-then-close: `freePort` asks the OS for a port,
|
|
222
|
+
* lets go of it, and hands back the number, so nothing holds it between then and the app starting.
|
|
223
|
+
* Within one Foreman the map closes that window for the Tasks it knows about; a second Foreman, or
|
|
224
|
+
* anything else on the machine, can be handed the same number in the same instant. It is the
|
|
225
|
+
* Account guard (commands/foreman.ts) that keeps two Foremen from verifying side by side, not this.
|
|
226
|
+
*/
|
|
227
|
+
export class PortLease {
|
|
228
|
+
private readonly held = new Map<number, number>();
|
|
229
|
+
|
|
230
|
+
/** `pick` is the OS probe; a test scripts one that answers the same number twice. */
|
|
231
|
+
constructor(private readonly pick: () => Promise<number> = freePort) {}
|
|
232
|
+
|
|
233
|
+
/** The ports held, by Task id. */
|
|
234
|
+
get leases(): ReadonlyMap<number, number> {
|
|
235
|
+
return this.held;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async acquire(taskId: number): Promise<number> {
|
|
239
|
+
if (this.held.has(taskId)) throw new Error(`Task ${taskId} already holds port ${this.held.get(taskId)}`);
|
|
240
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
241
|
+
const port = await this.pick();
|
|
242
|
+
// Re-read after the await: a concurrent acquire may have held this number since.
|
|
243
|
+
if ([...this.held.values()].includes(port)) continue;
|
|
244
|
+
if (this.held.has(taskId)) throw new Error(`Task ${taskId} already holds port ${this.held.get(taskId)}`);
|
|
245
|
+
this.held.set(taskId, port);
|
|
246
|
+
return port;
|
|
247
|
+
}
|
|
248
|
+
throw new Error("could not find a free port that no other Task holds");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
release(taskId: number): void {
|
|
252
|
+
this.held.delete(taskId);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function freePort(): Promise<number> {
|
|
257
|
+
return new Promise((resolvePort, reject) => {
|
|
258
|
+
const server = createServer();
|
|
259
|
+
server.unref();
|
|
260
|
+
server.once("error", reject);
|
|
261
|
+
server.listen(0, "127.0.0.1", () => {
|
|
262
|
+
const address = server.address();
|
|
263
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
264
|
+
server.close(() => (port ? resolvePort(port) : reject(new Error("no port assigned"))));
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The one version of Playwright MCP (https://github.com/microsoft/playwright-mcp) the Verifier
|
|
270
|
+
* runs: a devDependency of the CLI, so the workspace's `bun.lock` pins it and the test
|
|
271
|
+
* (`foreman-verifier.test.ts`) holds this constant to the installed package. Bumped on purpose,
|
|
272
|
+
* never `@latest` at runtime. The browser it drives is installed once with
|
|
273
|
+
* `npx playwright install chromium`. */
|
|
274
|
+
export const PLAYWRIGHT_MCP_PACKAGE = "@playwright/mcp";
|
|
275
|
+
export const PLAYWRIGHT_MCP_VERSION = "0.0.80";
|
|
276
|
+
|
|
277
|
+
/** Where the installed package's `package.json` is, or null when it is not installed (the CLI
|
|
278
|
+
* run from a checkout without its devDependencies). Exported for the test only. */
|
|
279
|
+
export function resolvePlaywrightMcpPackage(): string | null {
|
|
280
|
+
try {
|
|
281
|
+
return createRequire(import.meta.url).resolve(`${PLAYWRIGHT_MCP_PACKAGE}/package.json`);
|
|
282
|
+
} catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** How to start Playwright MCP: the installed package's `cli.js` under this Node when it is in
|
|
288
|
+
* `node_modules`, else `npx` of the pinned version. */
|
|
289
|
+
export function playwrightMcpCommand(packageJson: string | null = resolvePlaywrightMcpPackage()): { command: string; args: string[] } {
|
|
290
|
+
if (packageJson) return { command: process.execPath, args: [join(dirname(packageJson), "cli.js")] };
|
|
291
|
+
return { command: "npx", args: ["-y", `${PLAYWRIGHT_MCP_PACKAGE}@${PLAYWRIGHT_MCP_VERSION}`] };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The Playwright MCP server for a Verifier turn: `--headless` (no window on a machine nobody is
|
|
295
|
+
* looking at), `--isolated` (a fresh in-memory profile per turn, so nothing from one Task's
|
|
296
|
+
* session reaches the next), `--output-dir` the Verifier's screenshot directory (one directory,
|
|
297
|
+
* where the prompt tells it to save and the Verdict's relative paths resolve), and
|
|
298
|
+
* `--allowed-origins` the app's own (the server's allowlist of what the browser may request; not
|
|
299
|
+
* a security boundary by its own account, but it keeps a journey that wanders off the app from
|
|
300
|
+
* fetching anything else). The vendor adapters render it: Claude Code as `--mcp-config` plus
|
|
301
|
+
* `--strict-mcp-config`, Codex as `-c mcp_servers.playwright.*` overrides. */
|
|
302
|
+
export function playwrightMcp(outputDir: string, allowedOrigins: readonly string[]): McpServerSpec {
|
|
303
|
+
const { command, args } = playwrightMcpCommand();
|
|
304
|
+
return { command, args: [...args, "--headless", "--isolated", "--output-dir", outputDir, "--allowed-origins", allowedOrigins.join(";")] };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The origins the Verifier's browser may request for an app at `url`: the URL's own, and its
|
|
308
|
+
* loopback twin (`localhost` and `127.0.0.1` name the same server, and a Verification section
|
|
309
|
+
* may say one while the recipe says the other). */
|
|
310
|
+
export function appOrigins(url: string): string[] {
|
|
311
|
+
const origin = new URL(url);
|
|
312
|
+
const origins = [origin.origin];
|
|
313
|
+
const twin = origin.hostname === "localhost" ? "127.0.0.1" : origin.hostname === "127.0.0.1" ? "localhost" : undefined;
|
|
314
|
+
if (twin) {
|
|
315
|
+
const other = new URL(url);
|
|
316
|
+
other.hostname = twin;
|
|
317
|
+
origins.push(other.origin);
|
|
318
|
+
}
|
|
319
|
+
return origins;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The Verifier's prompt: the Verification section (quoted as data: it was written by the Task's
|
|
323
|
+
* author or drafted by a Triage turn, either way it is what to follow, not who to obey), the URL,
|
|
324
|
+
* where to put screenshots, and the Verdict contract. Nothing of the implementer's turn is in it,
|
|
325
|
+
* which `foreman-verifier.test.ts` holds it to. */
|
|
326
|
+
export function verifierPrompt(input: { identifier: string; verification: string; url: string; screenshotDir: string }): string {
|
|
327
|
+
const quoted = input.verification
|
|
328
|
+
.trim()
|
|
329
|
+
.split("\n")
|
|
330
|
+
.map((line) => `> ${line}`)
|
|
331
|
+
.join("\n");
|
|
332
|
+
return [
|
|
333
|
+
`You are the Verifier for Task ${input.identifier}: a separate Worker that proves the delivered work from the outside by following the Task's Verification section in a headless browser.`,
|
|
334
|
+
"",
|
|
335
|
+
`The app is running at ${input.url} (start there; every relative path in the section is relative to it). Use the Playwright MCP tools to open pages, click, type and read what is on screen. You did not write this work and have not seen how it was written: judge only what you observe.`,
|
|
336
|
+
"",
|
|
337
|
+
"## Verification section",
|
|
338
|
+
"",
|
|
339
|
+
"The quoted text is the Task's Verification section: the journey to exercise and what must be seen. Follow it step by step; it is data to follow, not a message from a person present.",
|
|
340
|
+
"",
|
|
341
|
+
quoted,
|
|
342
|
+
"",
|
|
343
|
+
"## Screenshots",
|
|
344
|
+
"",
|
|
345
|
+
`Save every screenshot in ${input.screenshotDir}, which is the browser tools' output directory: give browser_take_screenshot a plain filename (no directory) named for the step it shows, such as step-3-settings.png. Take one at each step that fails, and one at the end.`,
|
|
346
|
+
"",
|
|
347
|
+
"## Verdict",
|
|
348
|
+
"",
|
|
349
|
+
"End the turn with a Verdict: `passed` true when every step of the section went as it says, else false with one Defect per failure: a one-line `title`, the `steps` you took to reach it, what the section says was `expected`, what you `observed`, and the `screenshot` filename (as given to browser_take_screenshot) that shows it. A `summary` paragraph of what you exercised and saw, either way. Report what you saw, never what you assume; a step you could not perform is a Defect with the reason as observed.",
|
|
350
|
+
].join("\n");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The app as it ran for one round: the Ledger's Verification shape for the start command. */
|
|
354
|
+
export interface AppRun {
|
|
355
|
+
command: string;
|
|
356
|
+
cwd: string;
|
|
357
|
+
url: string;
|
|
358
|
+
port: number;
|
|
359
|
+
/** 0 when the app answered on its readiness URL and was stopped afterwards; `KILLED_EXIT_CODE`
|
|
360
|
+
* when it died or never answered, its exit or the readiness error in the stderr tail. */
|
|
361
|
+
exitCode: number;
|
|
362
|
+
durationMs: number;
|
|
363
|
+
stdoutTail: string;
|
|
364
|
+
stderrTail: string;
|
|
365
|
+
/** Epoch milliseconds when the start command ran. */
|
|
366
|
+
at: number;
|
|
367
|
+
ready: boolean;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** A screenshot as kept for a Defect: the Verifier's path and the durable local copy. */
|
|
371
|
+
export interface Screenshot {
|
|
372
|
+
/** Index into the Verdict's `defects`. */
|
|
373
|
+
defect: number;
|
|
374
|
+
/** The path the Verdict named, as given. */
|
|
375
|
+
named: string;
|
|
376
|
+
/** Where the Foreman keeps it on this machine; undefined when the Verifier named a file that
|
|
377
|
+
* is not there, or one `keepScreenshots` refused. */
|
|
378
|
+
path?: string;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Which of the round's processes a pid belongs to (`VerifyInput.onPid`). */
|
|
382
|
+
export type VerifyProcess = "app" | "verifier";
|
|
383
|
+
|
|
384
|
+
export interface VerifyInput {
|
|
385
|
+
task: TrackerTask;
|
|
386
|
+
workspace: Workspace;
|
|
387
|
+
attempt: number;
|
|
388
|
+
initiativeId: number;
|
|
389
|
+
/** The Verification section the Verifier follows: the Task's own, or the one drafted for it. */
|
|
390
|
+
verification: string;
|
|
391
|
+
/** Told the pid of the app when it starts (and undefined when it is stopped) and of the
|
|
392
|
+
* Verifier turn when it is launched, so the loop can put both on the Process Table row
|
|
393
|
+
* (ADR-0009): a cancel reaches them, and a crash leaves what Reconcile can stop. */
|
|
394
|
+
onPid?: (which: VerifyProcess, pid: number | undefined) => void;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export type VerifyOutcome =
|
|
398
|
+
/** No browser round: commands only, the recipe says none, or the base has no recipe to start the app. */
|
|
399
|
+
| { kind: "skipped"; reason: string }
|
|
400
|
+
/** The Verifier delivered a Verdict. */
|
|
401
|
+
| { kind: "verified"; verdict: Verdict; screenshots: Screenshot[]; app: AppRun; durationMs: number; sessionId?: string; outputTail: string }
|
|
402
|
+
/** The round failed before or without a Verdict: recorded as a failed Verdict with one Defect. */
|
|
403
|
+
| { kind: "failed"; reason: string; details?: string[]; app?: AppRun; durationMs: number; sessionId?: string; outputTail?: string };
|
|
404
|
+
|
|
405
|
+
/** Runs one Task's verification; the seam the loop takes so tests script outcomes. */
|
|
406
|
+
export type VerifierFn = (input: VerifyInput) => Promise<VerifyOutcome>;
|
|
407
|
+
|
|
408
|
+
export interface VerifierOptions {
|
|
409
|
+
/** Must be the guarded runner (workspace.ts `guardedGit`): the recipe is read with `git show`. */
|
|
410
|
+
git: GitRunner;
|
|
411
|
+
/** The checkout the Run works on, where `origin/<default>` is read. */
|
|
412
|
+
repoRoot: string;
|
|
413
|
+
worker: WorkerAdapter;
|
|
414
|
+
account: Account;
|
|
415
|
+
/** Where screenshots are kept on this machine; `<local dir>/verification` by default. */
|
|
416
|
+
evidenceDir?: string;
|
|
417
|
+
transcripts?: TranscriptStore;
|
|
418
|
+
maxTurns?: number;
|
|
419
|
+
stallTimeoutMs?: number;
|
|
420
|
+
readyTimeoutMs?: number;
|
|
421
|
+
/** Test seams: the MCP server to hand the turn (the real Playwright one by default, given the
|
|
422
|
+
* screenshot directory and the app's origins) and the port lease (one per Foreman process by
|
|
423
|
+
* default). */
|
|
424
|
+
mcpFor?: (outputDir: string, allowedOrigins: readonly string[]) => McpServerSpec;
|
|
425
|
+
ports?: PortLease;
|
|
426
|
+
log?: (line: string) => void;
|
|
427
|
+
now?: () => number;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function evidenceDir(): string {
|
|
431
|
+
return process.env.OPSEE_FOREMAN_EVIDENCE_PATH || join(foremanLocalDir(), "verification");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The process-wide lease, shared by every Run in this process. */
|
|
435
|
+
export const PORTS = new PortLease();
|
|
436
|
+
|
|
437
|
+
/** The real Verifier: the base's recipe, the app on a leased port, the Verifier turn with
|
|
438
|
+
* Playwright MCP in a scratch directory, the screenshots copied out, the app stopped. */
|
|
439
|
+
export function verifierWith(options: VerifierOptions): VerifierFn {
|
|
440
|
+
const log = options.log ?? (() => {});
|
|
441
|
+
const now = options.now ?? Date.now;
|
|
442
|
+
const ports = options.ports ?? PORTS;
|
|
443
|
+
const mcpFor = options.mcpFor ?? playwrightMcp;
|
|
444
|
+
return async (input) => {
|
|
445
|
+
const { task, workspace, attempt } = input;
|
|
446
|
+
const { files, source } = await baseConfigFiles(options.git, options.repoRoot);
|
|
447
|
+
const { recipe, reason, verify } = recipeFrom(files);
|
|
448
|
+
if (!recipe) {
|
|
449
|
+
if (verify === "browser") {
|
|
450
|
+
// The base insists on the browser and cannot have it: that is a failed round a human
|
|
451
|
+
// must see, not a skip that lets the Task through on its Gates.
|
|
452
|
+
const why = `the Run Recipe says verify: browser but cannot start the app: ${reason} (read from ${source})`;
|
|
453
|
+
log(`verify: ${task.identifier} ${why}; the round failed`);
|
|
454
|
+
return { kind: "failed", reason: why, durationMs: 0 };
|
|
455
|
+
}
|
|
456
|
+
log(`verify: ${task.identifier} skipped: ${reason} (read from ${source}); the Gates are the whole verification`);
|
|
457
|
+
return { kind: "skipped", reason: `${reason} (read from ${source})` };
|
|
458
|
+
}
|
|
459
|
+
const decision = decideBrowserVerification(input.verification, recipe.verify);
|
|
460
|
+
if (!decision.browser) {
|
|
461
|
+
log(`verify: ${task.identifier} skipped: ${decision.reason}; the Gates are the whole verification`);
|
|
462
|
+
return { kind: "skipped", reason: decision.reason };
|
|
463
|
+
}
|
|
464
|
+
log(`verify: ${task.identifier} browser verification: ${decision.reason} (Run Recipe read from ${source})`);
|
|
465
|
+
|
|
466
|
+
const started = now();
|
|
467
|
+
const port = await ports.acquire(task.id);
|
|
468
|
+
const output: string[] = [];
|
|
469
|
+
const app = startApp(recipe, { port, cwd: workspace.path, env: gateEnv(options.account), onOutput: (line) => output.push(line) });
|
|
470
|
+
input.onPid?.("app", app.pid);
|
|
471
|
+
const appAt = now();
|
|
472
|
+
log(`verify: ${task.identifier} starting the app on port ${port}: ${app.command} (${recipe.portEnv}=${port}${app.pid ? `, pid ${app.pid}` : ""})`);
|
|
473
|
+
const appRun = (ready: boolean, error?: string): AppRun => ({
|
|
474
|
+
command: app.command,
|
|
475
|
+
cwd: workspace.path,
|
|
476
|
+
url: app.readinessUrl,
|
|
477
|
+
port,
|
|
478
|
+
exitCode: ready ? 0 : KILLED_EXIT_CODE,
|
|
479
|
+
durationMs: now() - appAt,
|
|
480
|
+
stdoutTail: tail(output.join("\n")),
|
|
481
|
+
stderrTail: error ?? "",
|
|
482
|
+
at: appAt,
|
|
483
|
+
ready,
|
|
484
|
+
});
|
|
485
|
+
let scratch: string | undefined;
|
|
486
|
+
try {
|
|
487
|
+
try {
|
|
488
|
+
const readiness = await waitForReady(app.readinessUrl, { timeoutMs: options.readyTimeoutMs ?? APP_READY_TIMEOUT_MS, signal: app.exitSignal });
|
|
489
|
+
log(`verify: ${task.identifier} app ready at ${app.readinessUrl} after ${readiness.elapsedMs}ms (${readiness.attempts} attempts)`);
|
|
490
|
+
} catch (error) {
|
|
491
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
492
|
+
log(`verify: ${task.identifier} app not ready: ${message}; the round failed`);
|
|
493
|
+
return { kind: "failed", reason: `the app did not become ready: ${message}`, app: appRun(false, message), durationMs: now() - started };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// The Verifier's own directory: not the Workspace (the implementer's files are not its
|
|
497
|
+
// business, and Playwright writes there), removed when the turn ends.
|
|
498
|
+
scratch = mkdtempSync(join(tmpdir(), `opsee-foreman-verifier-${workspace.branch}-`));
|
|
499
|
+
const screenshotDir = join(scratch, SCREENSHOT_DIR);
|
|
500
|
+
mkdirSync(screenshotDir, { recursive: true });
|
|
501
|
+
const maxTurns = Math.min(VERIFIER_MAX_TURNS, options.maxTurns ?? VERIFIER_MAX_TURNS);
|
|
502
|
+
const prompt = verifierPrompt({ identifier: task.identifier, verification: input.verification, url: app.readinessUrl, screenshotDir });
|
|
503
|
+
const handle = options.worker.launch({
|
|
504
|
+
account: options.account,
|
|
505
|
+
cwd: scratch,
|
|
506
|
+
prompt,
|
|
507
|
+
maxTurns,
|
|
508
|
+
stallTimeoutMs: options.stallTimeoutMs,
|
|
509
|
+
contract: VERDICT_CONTRACT,
|
|
510
|
+
sandbox: "readonly-browser",
|
|
511
|
+
mcpServers: { playwright: mcpFor(screenshotDir, appOrigins(app.readinessUrl)) },
|
|
512
|
+
});
|
|
513
|
+
if (handle.pid !== undefined) input.onPid?.("verifier", handle.pid);
|
|
514
|
+
log(`verify: ${task.identifier} ${options.account.vendor} Verifier turn in ${scratch} (at most ${maxTurns} turns, Playwright MCP headless and isolated${handle.pid !== undefined ? `, pid ${handle.pid}` : ""})`);
|
|
515
|
+
const transcript = options.transcripts?.open(input.initiativeId, `${task.identifier}-verifier`, attempt);
|
|
516
|
+
const lines: string[] = [];
|
|
517
|
+
let terminal: Extract<AdapterEvent, { type: "completed" | "failed" }> | undefined;
|
|
518
|
+
for await (const event of handle.events) {
|
|
519
|
+
transcript?.append(event);
|
|
520
|
+
switch (event.type) {
|
|
521
|
+
case "started":
|
|
522
|
+
log(`verify: session ${event.sessionId}`);
|
|
523
|
+
break;
|
|
524
|
+
case "output":
|
|
525
|
+
for (const line of event.text.split("\n")) {
|
|
526
|
+
lines.push(line);
|
|
527
|
+
log(` | ${line}`);
|
|
528
|
+
}
|
|
529
|
+
break;
|
|
530
|
+
case "tool":
|
|
531
|
+
log(` tool ${event.name}`);
|
|
532
|
+
break;
|
|
533
|
+
case "rate_limited":
|
|
534
|
+
log(`verify: rate limited: ${event.message}${event.resetAt ? ` (resets ${event.resetAt})` : ""}`);
|
|
535
|
+
break;
|
|
536
|
+
case "stalled":
|
|
537
|
+
log(`verify: stalled, no output for ${event.silentMs}ms`);
|
|
538
|
+
break;
|
|
539
|
+
case "completed":
|
|
540
|
+
case "failed":
|
|
541
|
+
terminal = event;
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (!terminal) throw new Error("Worker Adapter contract violated: the event stream ended without a terminal event");
|
|
546
|
+
const outputTail = tail(lines.join("\n"));
|
|
547
|
+
const durationMs = now() - started;
|
|
548
|
+
if (terminal.type === "failed") {
|
|
549
|
+
log(`verify: ${task.identifier} Verifier turn ${terminal.reason}: ${terminal.message}; the round failed`);
|
|
550
|
+
return { kind: "failed", reason: `the Verifier turn ended ${terminal.reason}: ${terminal.message}`, details: terminal.details, app: appRun(true), durationMs, sessionId: terminal.sessionId, outputTail };
|
|
551
|
+
}
|
|
552
|
+
// The adapter validated it under the Verdict contract; parsed once more here so a fake or a
|
|
553
|
+
// future adapter that hands the object through unchecked cannot make a pass of a bad shape.
|
|
554
|
+
const parsed = parseVerdict(terminal.structured);
|
|
555
|
+
if (!parsed.ok) {
|
|
556
|
+
log(`verify: ${task.identifier} the Verifier's result is not a Verdict (${parsed.errors.join("; ")}); the round failed`);
|
|
557
|
+
return { kind: "failed", reason: "the Verifier's result is not a Verdict", details: parsed.errors, app: appRun(true), durationMs, sessionId: terminal.sessionId, outputTail };
|
|
558
|
+
}
|
|
559
|
+
const verdict = parsed.verdict;
|
|
560
|
+
const screenshots = keepScreenshots(verdict, screenshotDir, join(options.evidenceDir ?? evidenceDir(), String(input.initiativeId), `${task.identifier}-${attempt}`), log);
|
|
561
|
+
log(`verify: ${task.identifier} Verdict ${verdict.passed ? "passed" : `failed with ${verdict.defects.length} Defect${verdict.defects.length === 1 ? "" : "s"}`} after ${durationMs}ms`);
|
|
562
|
+
return { kind: "verified", verdict, screenshots, app: appRun(true), durationMs, sessionId: terminal.sessionId, outputTail };
|
|
563
|
+
} finally {
|
|
564
|
+
await app.stop();
|
|
565
|
+
input.onPid?.("app", undefined);
|
|
566
|
+
ports.release(task.id);
|
|
567
|
+
if (scratch) rmSync(scratch, { recursive: true, force: true });
|
|
568
|
+
log(`verify: ${task.identifier} app stopped, port ${port} released`);
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
574
|
+
const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
|
|
575
|
+
|
|
576
|
+
/** Why a file the Verdict names is not kept as evidence, or undefined when it is: the Verdict is
|
|
577
|
+
* written by an agent that has read the pages under test, so a path in it is data, and the
|
|
578
|
+
* only files that leave the scratch directory for the evidence directory (and from there the
|
|
579
|
+
* pull request) are real images the Verifier's own browser wrote into its screenshot directory.
|
|
580
|
+
* `realpath` of the file must be under `realpath` of the directory (so `..`, an absolute path
|
|
581
|
+
* elsewhere, or a symlinked directory inside it all fail); a symlink is refused before it is
|
|
582
|
+
* followed; it must be a regular file, at most `SCREENSHOT_MAX_BYTES`, and start with the PNG or
|
|
583
|
+
* JPEG magic bytes. Exported for the test. */
|
|
584
|
+
export function screenshotRefusal(candidate: string, screenshotDir: string): string | undefined {
|
|
585
|
+
let root: string;
|
|
586
|
+
try {
|
|
587
|
+
root = realpathSync(screenshotDir);
|
|
588
|
+
} catch {
|
|
589
|
+
return "the screenshot directory is gone";
|
|
590
|
+
}
|
|
591
|
+
let stat: ReturnType<typeof lstatSync>;
|
|
592
|
+
try {
|
|
593
|
+
stat = lstatSync(candidate);
|
|
594
|
+
} catch {
|
|
595
|
+
return "it was not written";
|
|
596
|
+
}
|
|
597
|
+
if (stat.isSymbolicLink()) return "it is a symbolic link";
|
|
598
|
+
if (!stat.isFile()) return "it is not a regular file";
|
|
599
|
+
let real: string;
|
|
600
|
+
try {
|
|
601
|
+
real = realpathSync(candidate);
|
|
602
|
+
} catch {
|
|
603
|
+
return "it was not written";
|
|
604
|
+
}
|
|
605
|
+
if (!real.startsWith(root + sep)) return "it is outside the Verifier's screenshot directory";
|
|
606
|
+
if (stat.size > SCREENSHOT_MAX_BYTES) return `it is ${stat.size} bytes, over the ${SCREENSHOT_MAX_BYTES} byte limit`;
|
|
607
|
+
const head = Buffer.alloc(PNG_MAGIC.length);
|
|
608
|
+
const fd = openSync(real, "r");
|
|
609
|
+
let read: number;
|
|
610
|
+
try {
|
|
611
|
+
read = readSync(fd, head, 0, head.length, 0);
|
|
612
|
+
} finally {
|
|
613
|
+
closeSync(fd);
|
|
614
|
+
}
|
|
615
|
+
const isPng = read >= PNG_MAGIC.length && head.subarray(0, PNG_MAGIC.length).equals(PNG_MAGIC);
|
|
616
|
+
const isJpeg = read >= JPEG_MAGIC.length && head.subarray(0, JPEG_MAGIC.length).equals(JPEG_MAGIC);
|
|
617
|
+
if (!isPng && !isJpeg) return "it is not a PNG or JPEG image";
|
|
618
|
+
return undefined;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Copies each Defect's screenshot out of the Verifier's screenshot directory into the evidence
|
|
622
|
+
* directory, under the file's own name (a counter breaks a clash). A relative name resolves
|
|
623
|
+
* against the screenshot directory; whatever it resolves to must pass `screenshotRefusal`, and a
|
|
624
|
+
* file that does not (not written, elsewhere, a symlink, not an image) is kept as a Screenshot
|
|
625
|
+
* with no path, which the comment then says, with the reason in the log. */
|
|
626
|
+
export function keepScreenshots(verdict: Verdict, screenshotDir: string, into: string, log: (line: string) => void = () => {}): Screenshot[] {
|
|
627
|
+
const kept: Screenshot[] = [];
|
|
628
|
+
const used = new Set<string>();
|
|
629
|
+
verdict.defects.forEach((defect, index) => {
|
|
630
|
+
const named = defect.screenshot.trim();
|
|
631
|
+
if (named === "") {
|
|
632
|
+
kept.push({ defect: index, named });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
const source = isAbsolute(named) ? named : resolve(screenshotDir, named);
|
|
636
|
+
const refusal = screenshotRefusal(source, screenshotDir);
|
|
637
|
+
if (refusal) {
|
|
638
|
+
log(refusal === "it was not written" ? `verify: Defect ${index + 1} names screenshot ${named}, which was not written` : `verify: Defect ${index + 1} names screenshot ${named}, which is not kept: ${refusal}`);
|
|
639
|
+
kept.push({ defect: index, named });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
mkdirSync(into, { recursive: true, mode: 0o700 });
|
|
643
|
+
let name = basename(source);
|
|
644
|
+
for (let n = 2; used.has(name); n++) name = `${n}-${basename(source)}`;
|
|
645
|
+
used.add(name);
|
|
646
|
+
const path = join(into, name);
|
|
647
|
+
copyFileSync(source, path);
|
|
648
|
+
kept.push({ defect: index, named, path });
|
|
649
|
+
});
|
|
650
|
+
return kept;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** How a round's Verdict reads when the round failed: one Defect that says so, so the Run
|
|
654
|
+
* Record and the pull request carry the failure in the Verdict's own shape. */
|
|
655
|
+
export function failureVerdict(reason: string, details: string[] = []): Verdict {
|
|
656
|
+
return {
|
|
657
|
+
passed: false,
|
|
658
|
+
// The flag, not the title, is what tells the Defect filer this Verdict is the Foreman's own
|
|
659
|
+
// (verdict.ts `roundFailed`): a Verifier cannot set it, so it cannot suppress filing by title.
|
|
660
|
+
roundFailed: true,
|
|
661
|
+
summary: `The Foreman could not complete browser verification: ${reason}.`,
|
|
662
|
+
defects: [
|
|
663
|
+
{
|
|
664
|
+
title: `${VERIFIER_FAILURE_TITLE}: ${reason}`,
|
|
665
|
+
steps: "The Foreman started the app from the Run Recipe and launched the Verifier with the Task's Verification section.",
|
|
666
|
+
expected: "A Verdict from the Verifier.",
|
|
667
|
+
observed: [reason, ...details].join("\n"),
|
|
668
|
+
screenshot: "",
|
|
669
|
+
},
|
|
670
|
+
],
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** The Verifier id of the app's Verification: `<identifier>-<attempt>-verify-app`. */
|
|
675
|
+
export function appEvidenceId(identifier: string, attempt: number): string {
|
|
676
|
+
return `${identifier}-${attempt}-verify-app`;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function appVerification(identifier: string, attempt: number, app: AppRun): MessageInitShape<typeof RunVerificationSchema> {
|
|
680
|
+
return {
|
|
681
|
+
id: appEvidenceId(identifier, attempt),
|
|
682
|
+
stage: "verify",
|
|
683
|
+
tool: "app",
|
|
684
|
+
command: gateCommandText(app.command),
|
|
685
|
+
cwd: app.cwd,
|
|
686
|
+
exitCode: app.exitCode,
|
|
687
|
+
durationMs: BigInt(Math.max(0, Math.round(app.durationMs))),
|
|
688
|
+
stdoutTail: gateTail(app.stdoutTail),
|
|
689
|
+
stderrTail: gateTail(app.stderrTail),
|
|
690
|
+
at: timestampFromMs(app.at),
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** The `verdict` Run Record event: the Ledger's Verdict shape (status, Defects with their
|
|
695
|
+
* evidence URL, the app's start as evidence), written before the comment and the board move it
|
|
696
|
+
* precedes (ADR-0009). `evidenceUrls` are per Defect: the upload on the pull request, else the
|
|
697
|
+
* local path, else empty. Every string is cut to the proto's limit by code point, so a Verifier
|
|
698
|
+
* that wrote at length is recorded cut rather than refused after the Gates passed.
|
|
699
|
+
*
|
|
700
|
+
* Attribution invariant (read back by `verdictStatusOf`): a `verdict` event belongs to the
|
|
701
|
+
* attempt whose app evidence it carries (`appEvidenceId`), and one written without an app (the
|
|
702
|
+
* round failed before the app started) belongs to the attempt of the last `dispatch` event
|
|
703
|
+
* before it on the Task's Run Record, since the loop writes it inside that attempt, after its
|
|
704
|
+
* dispatch event and before its attempt event. Every failed round that had an app attaches it. */
|
|
705
|
+
export function verdictEvent(task: Pick<TrackerTask, "id" | "identifier">, attempt: number, verdict: Verdict, evidenceUrls: string[], app: AppRun | undefined, at: number): RunEventInput {
|
|
706
|
+
const payload: MessageInitShape<typeof RunEventInputSchema>["payload"] = {
|
|
707
|
+
payload: {
|
|
708
|
+
case: "verdict",
|
|
709
|
+
value: {
|
|
710
|
+
status: verdict.passed ? "passed" : "failed",
|
|
711
|
+
defects: verdict.defects.map((d, i) => ({
|
|
712
|
+
title: truncateCodePoints(d.title, DEFECT_TITLE_MAX),
|
|
713
|
+
steps: truncateCodePoints(d.steps, DEFECT_TEXT_MAX),
|
|
714
|
+
expected: truncateCodePoints(d.expected, DEFECT_TEXT_MAX),
|
|
715
|
+
observed: truncateCodePoints(d.observed, DEFECT_TEXT_MAX),
|
|
716
|
+
evidenceUrl: truncateCodePoints(evidenceUrls[i] ?? "", EVIDENCE_URL_MAX),
|
|
717
|
+
})),
|
|
718
|
+
evidence: app ? [appVerification(task.identifier, attempt, app)] : [],
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
return create(RunEventInputSchema, { taskId: task.id, occurredAt: timestampFromMs(at), payload });
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** What the Run Record's `verdict` events say about one attempt: the last one's status, or
|
|
726
|
+
* `none` when the attempt has no Verdict (skipped, or before this slice). Reconcile settles from
|
|
727
|
+
* it beside the Gates' verdict. `events` are the Task's, in Run Record order, and must include
|
|
728
|
+
* its `dispatch` events beside the `verdict` ones for the attribution invariant on
|
|
729
|
+
* `verdictEvent` to hold for a Verdict written without an app. */
|
|
730
|
+
export function verdictStatusOf(events: readonly RunEvent[], attempt: number, identifier: string): "passed" | "failed" | "none" {
|
|
731
|
+
let last: "passed" | "failed" | "none" = "none";
|
|
732
|
+
let current: number | undefined;
|
|
733
|
+
for (const event of events) {
|
|
734
|
+
const payload = event.payload?.payload;
|
|
735
|
+
if (payload?.case === "dispatch") {
|
|
736
|
+
current = payload.value.attempt;
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (payload?.case !== "verdict") continue;
|
|
740
|
+
const app = payload.value.evidence.find((e) => e.id.endsWith("-verify-app"));
|
|
741
|
+
const ours = app ? app.id === appEvidenceId(identifier, attempt) : current === attempt;
|
|
742
|
+
if (!ours) continue;
|
|
743
|
+
last = payload.value.status === "passed" ? "passed" : "failed";
|
|
744
|
+
}
|
|
745
|
+
return last;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** How the pull request comment shows each Defect's evidence: the upload's markdown, or where
|
|
749
|
+
* the file is kept when the host takes no upload, or that there is none. */
|
|
750
|
+
export interface DefectEvidence {
|
|
751
|
+
markdown?: string;
|
|
752
|
+
localPath?: string;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** The comment on the pull request: the Verdict, the app, every Defect with its screenshot. A
|
|
756
|
+
* screenshot kept on this machine is named by its evidence directory and file, never by the
|
|
757
|
+
* full path (the comment is public to the pull request's readers; the Foreman's home is not). */
|
|
758
|
+
export function verdictComment(verdict: Verdict, evidence: DefectEvidence[], app: AppRun | undefined): string {
|
|
759
|
+
const lines: string[] = ["## Foreman Verdict", ""];
|
|
760
|
+
lines.push(verdict.passed ? "Verification passed: the Verifier followed the Task's Verification section in a headless browser and every step went as it says." : `Verification failed with ${verdict.defects.length} Defect${verdict.defects.length === 1 ? "" : "s"}. The Task is blocked for a human.`);
|
|
761
|
+
if (app) lines.push("", `App: \`${app.command}\` in ${app.cwd}, ${app.ready ? `ready at ${app.url}` : "never ready"}.`);
|
|
762
|
+
// Everything below came out of a Verifier that drove a browser over whatever the page rendered,
|
|
763
|
+
// and this comment is public on the pull request — the one surface a human reads on a code host.
|
|
764
|
+
// Free text is quoted the way the Defect filer quotes it; the title lands in a heading of ours,
|
|
765
|
+
// so it is flattened and its Markdown taken away, `@` included, so no Verdict can forge a mention.
|
|
766
|
+
if (verdict.summary.trim()) lines.push("", AGENT_TEXT_NOTE, "", hostFenced(verdict.summary));
|
|
767
|
+
verdict.defects.forEach((d, i) => {
|
|
768
|
+
lines.push("", `### Defect ${i + 1}: ${markdownInline(d.title, "(untitled Defect)")}`, "", "**Steps:**", "", hostFenced(d.steps), "", "**Expected:**", "", hostFenced(d.expected), "", "**Observed:**", "", hostFenced(d.observed));
|
|
769
|
+
const e = evidence[i];
|
|
770
|
+
if (e?.markdown) lines.push("", e.markdown);
|
|
771
|
+
else if (e?.localPath) lines.push("", `Screenshot ${basename(e.localPath)} is kept on the Foreman's machine, in its evidence directory ${basename(dirname(e.localPath))} (this host takes no upload for comments).`);
|
|
772
|
+
else lines.push("", `No screenshot${d.screenshot ? ` (the Verifier named ${markdownInline(d.screenshot, "a file")}, which was not written)` : ""}.`);
|
|
773
|
+
});
|
|
774
|
+
return lines.join("\n");
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** The Verification section of the attempt event's summary and of the Task comment's note. */
|
|
778
|
+
export function verifySummary(outcome: VerifyOutcome): string {
|
|
779
|
+
switch (outcome.kind) {
|
|
780
|
+
case "skipped":
|
|
781
|
+
return `skipped (${outcome.reason})`;
|
|
782
|
+
case "verified":
|
|
783
|
+
return outcome.verdict.passed ? `passed in the browser after ${outcome.durationMs}ms` : `${outcome.verdict.defects.length} Defect${outcome.verdict.defects.length === 1 ? "" : "s"} found in the browser: ${outcome.verdict.defects.map((d) => d.title).join("; ")}`;
|
|
784
|
+
case "failed":
|
|
785
|
+
return `round failed: ${outcome.reason}`;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** Whether an outcome lets the Task reach review: a skip, or a passed Verdict. */
|
|
790
|
+
export function verificationPassed(outcome: VerifyOutcome | undefined): boolean {
|
|
791
|
+
return outcome === undefined || outcome.kind === "skipped" || (outcome.kind === "verified" && outcome.verdict.passed);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** The Verdict a round produced, whatever happened to it: the Verifier's, or the failure's. */
|
|
795
|
+
export function verdictOf(outcome: Exclude<VerifyOutcome, { kind: "skipped" }>): Verdict {
|
|
796
|
+
return outcome.kind === "verified" ? outcome.verdict : failureVerdict(outcome.reason, outcome.details);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** Exported for tests: the Defect shape the event carries. */
|
|
800
|
+
export type { Defect };
|