stuntdouble 0.1.2 → 0.2.0
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 +37 -9
- package/dist/{chunk-VWTYG7G4.js → chunk-BFI6P5DO.js} +65 -25
- package/dist/chunk-BFI6P5DO.js.map +1 -0
- package/dist/chunk-DFXASIGV.js +431 -0
- package/dist/chunk-DFXASIGV.js.map +1 -0
- package/dist/{chunk-JAB46QV7.js → chunk-F3R3QL7V.js} +64 -8
- package/dist/chunk-F3R3QL7V.js.map +1 -0
- package/dist/{mcp-CYPMT37O.js → chunk-FMZTT2MC.js} +72 -1059
- package/dist/chunk-FMZTT2MC.js.map +1 -0
- package/dist/chunk-MSV5OLSY.js +117 -0
- package/dist/chunk-MSV5OLSY.js.map +1 -0
- package/dist/chunk-P3CI4FTE.js +469 -0
- package/dist/chunk-P3CI4FTE.js.map +1 -0
- package/dist/chunk-QDA5FTUH.js +79 -0
- package/dist/chunk-QDA5FTUH.js.map +1 -0
- package/dist/chunk-XHSDRB3X.js +996 -0
- package/dist/chunk-XHSDRB3X.js.map +1 -0
- package/dist/cli.js +180 -68
- package/dist/cli.js.map +1 -1
- package/dist/cloud-DLLNKYND.js +34 -0
- package/dist/credentials-QHESDY4K.js +27 -0
- package/dist/credentials-QHESDY4K.js.map +1 -0
- package/dist/doctor-ZAYOOCPT.js +13 -0
- package/dist/doctor-ZAYOOCPT.js.map +1 -0
- package/dist/files-UBPF7PKW.js +45 -0
- package/dist/files-UBPF7PKW.js.map +1 -0
- package/dist/mcp-HFIAQ6HP.js +133 -0
- package/dist/mcp-HFIAQ6HP.js.map +1 -0
- package/dist/open-browser-CTOGH5HQ.js +17 -0
- package/dist/open-browser-CTOGH5HQ.js.map +1 -0
- package/dist/run-MPIAR3LQ.js +20 -0
- package/dist/run-MPIAR3LQ.js.map +1 -0
- package/dist/runner-E45EJ76B.js +17 -0
- package/dist/runner-E45EJ76B.js.map +1 -0
- package/dist/studio-FBC66BER.js +360 -0
- package/dist/studio-FBC66BER.js.map +1 -0
- package/dist/studio.js +1621 -0
- package/dist/ui-server-C7CFJWRD.js +12 -0
- package/dist/ui-server-C7CFJWRD.js.map +1 -0
- package/dist/ui.html +210 -103
- package/package.json +2 -2
- package/dist/chunk-JAB46QV7.js.map +0 -1
- package/dist/chunk-VWTYG7G4.js.map +0 -1
- package/dist/mcp-CYPMT37O.js.map +0 -1
- package/dist/ui-server-AYIR7DTN.js +0 -10
- /package/dist/{ui-server-AYIR7DTN.js.map → cloud-DLLNKYND.js.map} +0 -0
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
import {
|
|
2
|
+
startUi
|
|
3
|
+
} from "./chunk-BFI6P5DO.js";
|
|
4
|
+
import {
|
|
5
|
+
DirectBrowserTools,
|
|
6
|
+
INDEX_SESSION_SAFETY,
|
|
7
|
+
hostMatchesDomain,
|
|
8
|
+
isPrivateHost,
|
|
9
|
+
redactInput
|
|
10
|
+
} from "./chunk-FMZTT2MC.js";
|
|
11
|
+
import {
|
|
12
|
+
ActorSchema,
|
|
13
|
+
ChecklistSchema,
|
|
14
|
+
ConfigSchema,
|
|
15
|
+
appendJsonl,
|
|
16
|
+
describeRunScore,
|
|
17
|
+
effectiveStatus,
|
|
18
|
+
isLive,
|
|
19
|
+
newChecklistScoring,
|
|
20
|
+
readJson,
|
|
21
|
+
readRuns,
|
|
22
|
+
resolveHaltedChecks,
|
|
23
|
+
resolveSkippedChecks,
|
|
24
|
+
runningMarker,
|
|
25
|
+
scoreChecklistRun,
|
|
26
|
+
sdDir,
|
|
27
|
+
tallyRun,
|
|
28
|
+
writeFileAtomic,
|
|
29
|
+
writeJsonAtomic
|
|
30
|
+
} from "./chunk-F3R3QL7V.js";
|
|
31
|
+
|
|
32
|
+
// src/run.ts
|
|
33
|
+
import { createHash } from "crypto";
|
|
34
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, stat } from "fs/promises";
|
|
35
|
+
import { homedir, tmpdir } from "os";
|
|
36
|
+
import { join } from "path";
|
|
37
|
+
|
|
38
|
+
// package.json
|
|
39
|
+
var package_default = {
|
|
40
|
+
name: "stuntdouble",
|
|
41
|
+
version: "0.2.0",
|
|
42
|
+
description: "Stunt Double Local: run Stunt Double checklists in a local Chrome from your own coding agent session, with the runs kept as files and a local viewer",
|
|
43
|
+
keywords: [
|
|
44
|
+
"stunt-double",
|
|
45
|
+
"testing",
|
|
46
|
+
"browser",
|
|
47
|
+
"checklist",
|
|
48
|
+
"mcp",
|
|
49
|
+
"playwright",
|
|
50
|
+
"agents"
|
|
51
|
+
],
|
|
52
|
+
license: "Apache-2.0",
|
|
53
|
+
author: "Turnout Labs Ltd",
|
|
54
|
+
homepage: "https://github.com/stunt-double/stuntkit/tree/main/packages/local#readme",
|
|
55
|
+
bugs: {
|
|
56
|
+
url: "https://github.com/stunt-double/stuntkit/issues"
|
|
57
|
+
},
|
|
58
|
+
repository: {
|
|
59
|
+
type: "git",
|
|
60
|
+
url: "git+https://github.com/stunt-double/stuntkit.git",
|
|
61
|
+
directory: "packages/local"
|
|
62
|
+
},
|
|
63
|
+
type: "module",
|
|
64
|
+
bin: {
|
|
65
|
+
stuntdouble: "dist/cli.js"
|
|
66
|
+
},
|
|
67
|
+
files: [
|
|
68
|
+
"dist",
|
|
69
|
+
"README.md",
|
|
70
|
+
"LICENSE"
|
|
71
|
+
],
|
|
72
|
+
scripts: {
|
|
73
|
+
build: "tsup",
|
|
74
|
+
"type-check": "tsc -p tsconfig.json",
|
|
75
|
+
test: 'node --conditions=@stunt-double/source --test "test/**/*.test.ts"',
|
|
76
|
+
prepack: "pnpm run build"
|
|
77
|
+
},
|
|
78
|
+
dependencies: {
|
|
79
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
80
|
+
"playwright-core": "^1.64.1",
|
|
81
|
+
zod: "^4.6.7"
|
|
82
|
+
},
|
|
83
|
+
devDependencies: {
|
|
84
|
+
"@stuntkit/browser-toolset": "workspace:*"
|
|
85
|
+
},
|
|
86
|
+
engines: {
|
|
87
|
+
node: ">=22.18"
|
|
88
|
+
},
|
|
89
|
+
publishConfig: {
|
|
90
|
+
access: "public",
|
|
91
|
+
registry: "https://registry.npmjs.org",
|
|
92
|
+
provenance: true
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// src/brief.ts
|
|
97
|
+
var CONDITION_LABELS = {
|
|
98
|
+
always: "always",
|
|
99
|
+
if_previous_passed: "only if the check before it passed",
|
|
100
|
+
if_previous_failed: "only if the check before it failed"
|
|
101
|
+
};
|
|
102
|
+
function checkLabel(check) {
|
|
103
|
+
const kind = check.type === "informational" ? "informational, record what you observe" : check.type === "weighted" ? `weighted x${check.weight ?? 1}${(check.weight ?? 1) < 0 ? ", a penalty" : ""}` : "pass/fail";
|
|
104
|
+
const parts = [kind, CONDITION_LABELS[check.run_condition]];
|
|
105
|
+
if (check.on_failure === "stop") parts.push("the run stops if it fails");
|
|
106
|
+
return `(${parts.join(", ")})`;
|
|
107
|
+
}
|
|
108
|
+
function checkLine(check) {
|
|
109
|
+
return `- [${check.key}] ${checkLabel(check)} ${check.description}`;
|
|
110
|
+
}
|
|
111
|
+
function buildBrief(input) {
|
|
112
|
+
const { actor, checklist, url, checks, resumed } = input;
|
|
113
|
+
const persona = [
|
|
114
|
+
`You are ${actor.name}${actor.description ? `: ${actor.description.replace(/[.\s]+$/, "")}` : ""}.`,
|
|
115
|
+
actor.system_prompt ?? ""
|
|
116
|
+
].filter(Boolean).join("\n");
|
|
117
|
+
const knowledge = actor.knowledge.length ? [
|
|
118
|
+
"What you know:",
|
|
119
|
+
...actor.knowledge.map((k) => `### ${k.title} (${k.kind})
|
|
120
|
+
${k.content}`)
|
|
121
|
+
].join("\n") : "";
|
|
122
|
+
return [
|
|
123
|
+
persona,
|
|
124
|
+
knowledge,
|
|
125
|
+
`Your task: ${checklist.instructions}`,
|
|
126
|
+
`Start at ${url}${resumed ? " (this run is resuming: earlier verdicts stand, judge only the checks below)" : ""}.`,
|
|
127
|
+
`Checks to judge:
|
|
128
|
+
${checks.map(checkLine).join("\n")}`,
|
|
129
|
+
[
|
|
130
|
+
"How to work:",
|
|
131
|
+
"- Stay in persona: act and judge as this person would, not as a tester.",
|
|
132
|
+
"- Read the page with read_page and find. Ask for a screenshot only when how something looks is the question.",
|
|
133
|
+
'- get_page_text and read_page "all" return up to 20,000 characters: keep them for long text such as a policy.',
|
|
134
|
+
"- Judge each check with evaluate_check as soon as you have observed it, while the evidence is on screen.",
|
|
135
|
+
"- A passed verdict needs a locator: text visible on the page, or a CSS selector, that shows the evidence. A failure may describe something missing.",
|
|
136
|
+
"- Cite only what you saw in this run. Never pass a check on what a page probably does.",
|
|
137
|
+
'- When something stops you (a sign-in wall, a broken page, a CAPTCHA), judge the checks you cannot reach as "blocked" and say what stopped you.',
|
|
138
|
+
"- Always end with finish_run and a one or two sentence summary, even when blocked or stopped."
|
|
139
|
+
].join("\n")
|
|
140
|
+
].filter(Boolean).join("\n\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/driver.ts
|
|
144
|
+
import { chromium } from "playwright-core";
|
|
145
|
+
var pageIds = /* @__PURE__ */ new WeakMap();
|
|
146
|
+
var pageSeq = 0;
|
|
147
|
+
function idOf(page) {
|
|
148
|
+
let id = pageIds.get(page);
|
|
149
|
+
if (!id) {
|
|
150
|
+
pageSeq += 1;
|
|
151
|
+
id = `pw-${pageSeq}`;
|
|
152
|
+
pageIds.set(page, id);
|
|
153
|
+
}
|
|
154
|
+
return id;
|
|
155
|
+
}
|
|
156
|
+
var PlaywrightPage = class {
|
|
157
|
+
id;
|
|
158
|
+
raw;
|
|
159
|
+
constructor(page) {
|
|
160
|
+
this.raw = page;
|
|
161
|
+
this.id = idOf(page);
|
|
162
|
+
}
|
|
163
|
+
goto(url, options = {}) {
|
|
164
|
+
return this.raw.goto(url, {
|
|
165
|
+
...options.waitUntil ? { waitUntil: options.waitUntil } : {},
|
|
166
|
+
...options.timeoutMs ? { timeout: options.timeoutMs } : {}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
goBack() {
|
|
170
|
+
return this.raw.goBack();
|
|
171
|
+
}
|
|
172
|
+
goForward() {
|
|
173
|
+
return this.raw.goForward();
|
|
174
|
+
}
|
|
175
|
+
screenshot(options = {}) {
|
|
176
|
+
return this.raw.screenshot(options);
|
|
177
|
+
}
|
|
178
|
+
evaluate(expression, arg) {
|
|
179
|
+
if (typeof expression === "string") return this.raw.evaluate(expression);
|
|
180
|
+
return this.raw.evaluate(expression, arg);
|
|
181
|
+
}
|
|
182
|
+
url() {
|
|
183
|
+
return this.raw.url();
|
|
184
|
+
}
|
|
185
|
+
title() {
|
|
186
|
+
return this.raw.title();
|
|
187
|
+
}
|
|
188
|
+
setViewportSize(width, height) {
|
|
189
|
+
return this.raw.setViewportSize({ width, height });
|
|
190
|
+
}
|
|
191
|
+
waitForTimeout(ms) {
|
|
192
|
+
return this.raw.waitForTimeout(ms);
|
|
193
|
+
}
|
|
194
|
+
waitForSelector(selector, options = {}) {
|
|
195
|
+
return this.raw.waitForSelector(selector, options);
|
|
196
|
+
}
|
|
197
|
+
waitForLoadState(state, timeoutMs) {
|
|
198
|
+
return this.raw.waitForLoadState(state, timeoutMs ? { timeout: timeoutMs } : void 0);
|
|
199
|
+
}
|
|
200
|
+
locator(selector) {
|
|
201
|
+
return this.raw.locator(selector).first();
|
|
202
|
+
}
|
|
203
|
+
click(x, y, options = {}) {
|
|
204
|
+
return this.raw.mouse.click(x, y, options);
|
|
205
|
+
}
|
|
206
|
+
hover(x, y) {
|
|
207
|
+
return this.raw.mouse.move(x, y);
|
|
208
|
+
}
|
|
209
|
+
async scroll(x, y, deltaX, deltaY) {
|
|
210
|
+
await this.raw.mouse.move(x, y);
|
|
211
|
+
await this.raw.mouse.wheel(deltaX, deltaY);
|
|
212
|
+
}
|
|
213
|
+
async dragAndDrop(fromX, fromY, toX, toY) {
|
|
214
|
+
await this.raw.mouse.move(fromX, fromY);
|
|
215
|
+
await this.raw.mouse.down();
|
|
216
|
+
await this.raw.mouse.move(toX, toY, { steps: 10 });
|
|
217
|
+
await this.raw.mouse.up();
|
|
218
|
+
}
|
|
219
|
+
type(text) {
|
|
220
|
+
return this.raw.keyboard.type(text);
|
|
221
|
+
}
|
|
222
|
+
keyPress(key) {
|
|
223
|
+
return this.raw.keyboard.press(key);
|
|
224
|
+
}
|
|
225
|
+
close() {
|
|
226
|
+
return this.raw.close();
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
var PlaywrightContext = class {
|
|
230
|
+
context;
|
|
231
|
+
active;
|
|
232
|
+
policy = {};
|
|
233
|
+
routed = false;
|
|
234
|
+
constructor(context) {
|
|
235
|
+
this.context = context;
|
|
236
|
+
context.on("page", (page) => {
|
|
237
|
+
this.active = page;
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
activePage() {
|
|
241
|
+
const open = this.context.pages();
|
|
242
|
+
if (!this.active || this.active.isClosed()) this.active = open[open.length - 1];
|
|
243
|
+
return this.active ? new PlaywrightPage(this.active) : void 0;
|
|
244
|
+
}
|
|
245
|
+
pages() {
|
|
246
|
+
return this.context.pages().map((p) => new PlaywrightPage(p));
|
|
247
|
+
}
|
|
248
|
+
async newPage(url) {
|
|
249
|
+
const page = await this.context.newPage();
|
|
250
|
+
this.active = page;
|
|
251
|
+
if (url) await page.goto(url);
|
|
252
|
+
return new PlaywrightPage(page);
|
|
253
|
+
}
|
|
254
|
+
async setActivePage(page) {
|
|
255
|
+
const raw = this.context.pages().find((p) => idOf(p) === page.id);
|
|
256
|
+
if (!raw) throw new Error(`No open tab with id ${page.id}`);
|
|
257
|
+
this.active = raw;
|
|
258
|
+
await raw.bringToFront();
|
|
259
|
+
}
|
|
260
|
+
clearCookies() {
|
|
261
|
+
return this.context.clearCookies();
|
|
262
|
+
}
|
|
263
|
+
async addInitScript(source) {
|
|
264
|
+
await this.context.addInitScript(source);
|
|
265
|
+
}
|
|
266
|
+
/** Abort requests to blocked hosts, or to anything outside the allow list. */
|
|
267
|
+
async setDomainPolicy(policy) {
|
|
268
|
+
this.policy = policy;
|
|
269
|
+
if (this.routed) return;
|
|
270
|
+
this.routed = true;
|
|
271
|
+
await this.context.route("**/*", (route) => {
|
|
272
|
+
let host = "";
|
|
273
|
+
try {
|
|
274
|
+
host = new URL(route.request().url()).hostname;
|
|
275
|
+
} catch {
|
|
276
|
+
return route.continue();
|
|
277
|
+
}
|
|
278
|
+
const { allowedDomains, blockedDomains } = this.policy;
|
|
279
|
+
const blocked = (blockedDomains ?? []).some((d) => hostMatchesDomain(host, d)) || allowedDomains !== void 0 && allowedDomains.length > 0 && !allowedDomains.some((d) => hostMatchesDomain(host, d));
|
|
280
|
+
return blocked ? route.abort("blockedbyclient") : route.continue();
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
var PlaywrightDriver = class _PlaywrightDriver {
|
|
285
|
+
context;
|
|
286
|
+
browserContext;
|
|
287
|
+
refusal = null;
|
|
288
|
+
constructor(context) {
|
|
289
|
+
this.browserContext = context;
|
|
290
|
+
this.context = new PlaywrightContext(context);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The installed Google Chrome, or Playwright's own Chromium when Chrome is
|
|
294
|
+
* missing and `npx playwright install chromium` has been run.
|
|
295
|
+
*/
|
|
296
|
+
static async launch(options) {
|
|
297
|
+
const settings = {
|
|
298
|
+
headless: options.headless,
|
|
299
|
+
viewport: options.viewport,
|
|
300
|
+
hasTouch: options.touch,
|
|
301
|
+
...options.locale ? { locale: options.locale } : {}
|
|
302
|
+
};
|
|
303
|
+
let context;
|
|
304
|
+
try {
|
|
305
|
+
context = await chromium.launchPersistentContext(options.profileDir, {
|
|
306
|
+
...settings,
|
|
307
|
+
channel: "chrome"
|
|
308
|
+
});
|
|
309
|
+
} catch (chromeError) {
|
|
310
|
+
if (/ProcessSingleton|SingletonLock|already in use/i.test(chromeError.message)) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
"Another run is already using this actor's kept login (persist_login). Finish that run first, or try again when it ends.",
|
|
313
|
+
{ cause: chromeError }
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
context = await chromium.launchPersistentContext(options.profileDir, settings);
|
|
318
|
+
} catch {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`Could not start Chrome (${chromeError.message.split("\n")[0]}). Install Google Chrome, or run \`npx playwright install chromium\`.`
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const driver = new _PlaywrightDriver(context);
|
|
325
|
+
const refuse = (request) => {
|
|
326
|
+
const reason = request.isNavigationRequest() ? options.refuseNavigation(request.url()) : null;
|
|
327
|
+
if (reason && !request.frame().parentFrame()) driver.refusal = reason;
|
|
328
|
+
return reason;
|
|
329
|
+
};
|
|
330
|
+
await context.route(
|
|
331
|
+
"**/*",
|
|
332
|
+
(route) => refuse(route.request()) ? route.abort("blockedbyclient") : route.fallback()
|
|
333
|
+
);
|
|
334
|
+
context.on("request", (request) => {
|
|
335
|
+
if (request.redirectedFrom() && refuse(request)) {
|
|
336
|
+
request.frame().goto("about:blank").catch(() => {
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
if (context.pages().length === 0) await driver.context.newPage();
|
|
341
|
+
return driver;
|
|
342
|
+
}
|
|
343
|
+
takeRefusal() {
|
|
344
|
+
const refusal = this.refusal;
|
|
345
|
+
this.refusal = null;
|
|
346
|
+
return refusal;
|
|
347
|
+
}
|
|
348
|
+
/** Instruction-style actions need a model of their own. Nothing here calls them. */
|
|
349
|
+
async act() {
|
|
350
|
+
throw new Error("act is not supported by this driver: use the browser toolset instead");
|
|
351
|
+
}
|
|
352
|
+
async extract() {
|
|
353
|
+
throw new Error("extract is not supported by this driver: use the browser toolset instead");
|
|
354
|
+
}
|
|
355
|
+
close() {
|
|
356
|
+
return this.browserContext.close();
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// src/evidence.ts
|
|
361
|
+
var MAX_LOCATOR_TEXT = 120;
|
|
362
|
+
var MAX_LOCATOR_SELECTOR = 400;
|
|
363
|
+
var SETTLE_MS = 350;
|
|
364
|
+
var FIND_TIMEOUT_MS = 5e3;
|
|
365
|
+
function cleanLocator(locator) {
|
|
366
|
+
const clean = (value) => value?.replace(/\s+/g, " ").trim() || null;
|
|
367
|
+
const selector = clean(locator?.selector);
|
|
368
|
+
return {
|
|
369
|
+
selector: selector && selector.length <= MAX_LOCATOR_SELECTOR ? selector : null,
|
|
370
|
+
text: clean(locator?.text)?.slice(0, MAX_LOCATOR_TEXT) ?? null
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
async function focusEvidence(page, query, timeoutMs = FIND_TIMEOUT_MS) {
|
|
374
|
+
if (!query.selector && !query.text) return null;
|
|
375
|
+
let timer;
|
|
376
|
+
const via = await Promise.race([
|
|
377
|
+
page.evaluate(findAndScroll, query),
|
|
378
|
+
new Promise((ok) => timer = setTimeout(() => ok(null), timeoutMs))
|
|
379
|
+
]).catch(() => null).finally(() => clearTimeout(timer));
|
|
380
|
+
if (via) await page.waitForTimeout(SETTLE_MS).catch(() => {
|
|
381
|
+
});
|
|
382
|
+
return via;
|
|
383
|
+
}
|
|
384
|
+
function findAndScroll(query) {
|
|
385
|
+
const centre = { block: "center", inline: "center" };
|
|
386
|
+
const shown = (el) => {
|
|
387
|
+
const rect = el.getBoundingClientRect();
|
|
388
|
+
return rect.width > 0 && rect.height > 0 && el.checkVisibility({ visibilityProperty: true, opacityProperty: true });
|
|
389
|
+
};
|
|
390
|
+
const norm = (s) => (s ?? "").replace(/\s+/g, " ").toLowerCase();
|
|
391
|
+
const wanted = query.text ? norm(query.text) : null;
|
|
392
|
+
const labelled = (el, fragment) => {
|
|
393
|
+
const labels = ["placeholder", "aria-label", "alt", "title"].map((a) => el.getAttribute(a));
|
|
394
|
+
if ("value" in el && typeof el.value === "string") labels.push(el.value);
|
|
395
|
+
return labels.some((l) => norm(l).includes(fragment));
|
|
396
|
+
};
|
|
397
|
+
const rendered = (el) => norm(el instanceof HTMLElement ? el.innerText : "");
|
|
398
|
+
if (query.selector) {
|
|
399
|
+
let matches = [];
|
|
400
|
+
try {
|
|
401
|
+
matches = Array.from(document.querySelectorAll(query.selector));
|
|
402
|
+
} catch {
|
|
403
|
+
}
|
|
404
|
+
const el = matches.find(
|
|
405
|
+
(m) => m !== document.documentElement && m !== document.body && m.tagName !== "MAIN" && shown(m) && (!wanted || labelled(m, wanted) || rendered(m).includes(wanted))
|
|
406
|
+
);
|
|
407
|
+
if (el) {
|
|
408
|
+
el.scrollIntoView(centre);
|
|
409
|
+
return "selector";
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (!wanted) return null;
|
|
413
|
+
const elements = [];
|
|
414
|
+
const collect = (root) => {
|
|
415
|
+
for (const el of Array.from(root.querySelectorAll("*"))) {
|
|
416
|
+
elements.push(el);
|
|
417
|
+
if (el.shadowRoot) collect(el.shadowRoot);
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
collect(document.body);
|
|
421
|
+
let best = null;
|
|
422
|
+
let bestLength = Number.POSITIVE_INFINITY;
|
|
423
|
+
for (const el of elements) {
|
|
424
|
+
if (["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE"].includes(el.tagName)) continue;
|
|
425
|
+
let length = 0;
|
|
426
|
+
if (!labelled(el, wanted)) {
|
|
427
|
+
const text = rendered(el);
|
|
428
|
+
length = text.includes(wanted) ? text.length : Number.POSITIVE_INFINITY;
|
|
429
|
+
}
|
|
430
|
+
if (length >= bestLength || !shown(el)) continue;
|
|
431
|
+
best = el;
|
|
432
|
+
bestLength = length;
|
|
433
|
+
}
|
|
434
|
+
if (!best) return null;
|
|
435
|
+
best.scrollIntoView(centre);
|
|
436
|
+
return "text";
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/run.ts
|
|
440
|
+
var PACKAGE = `${package_default.name}@${package_default.version}`;
|
|
441
|
+
var STOPPED = "This run was stopped from the viewer. Do not use the browser again: call finish_run now with a summary of what you found.";
|
|
442
|
+
var NOT_JUDGED = "Not judged before the run finished.";
|
|
443
|
+
var PRIVATE_REFUSED = "Local and private addresses other than the app under test are not reachable.";
|
|
444
|
+
var SETUP = "Run /stuntdouble setup to create one.";
|
|
445
|
+
var LOG_TEXT_CHARS = 500;
|
|
446
|
+
var PRESETS = {
|
|
447
|
+
desktop: { width: 1280, height: 720, touch: false },
|
|
448
|
+
tablet: { width: 1024, height: 1366, touch: true },
|
|
449
|
+
mobile: { width: 390, height: 844, touch: true }
|
|
450
|
+
};
|
|
451
|
+
function rebase(url, localBase) {
|
|
452
|
+
if (!localBase) return url;
|
|
453
|
+
const target = new URL(url);
|
|
454
|
+
return new URL(target.pathname + target.search + target.hash, new URL(localBase).origin).href;
|
|
455
|
+
}
|
|
456
|
+
function stamp(date = /* @__PURE__ */ new Date()) {
|
|
457
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
458
|
+
return `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}-${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`;
|
|
459
|
+
}
|
|
460
|
+
var now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
461
|
+
function ago(iso) {
|
|
462
|
+
if (!iso) return "not started";
|
|
463
|
+
const minutes = Math.round((Date.now() - Date.parse(iso)) / 6e4);
|
|
464
|
+
if (minutes < 1) return "just now";
|
|
465
|
+
if (minutes < 60) return `${minutes} min ago`;
|
|
466
|
+
const hours = Math.round(minutes / 60);
|
|
467
|
+
return hours < 48 ? `${hours} h ago` : `${Math.round(hours / 24)} days ago`;
|
|
468
|
+
}
|
|
469
|
+
var LocalSession = class {
|
|
470
|
+
root;
|
|
471
|
+
launch;
|
|
472
|
+
active = null;
|
|
473
|
+
ui = null;
|
|
474
|
+
/** The run this session last finished, so a second finish_run is answered, not refused. */
|
|
475
|
+
finished = null;
|
|
476
|
+
/** Extra `runner` fields for a new run, such as the model a standalone run uses. */
|
|
477
|
+
runnerFields;
|
|
478
|
+
/** A viewer started by someone else (the Studio): its links are used, and it is not closed here. */
|
|
479
|
+
sharedUi;
|
|
480
|
+
constructor(options) {
|
|
481
|
+
this.root = sdDir(options.project);
|
|
482
|
+
this.launch = options.launch ?? PlaywrightDriver.launch;
|
|
483
|
+
this.runnerFields = options.runner ?? {};
|
|
484
|
+
this.ui = options.ui ?? null;
|
|
485
|
+
this.sharedUi = Boolean(options.ui);
|
|
486
|
+
}
|
|
487
|
+
/** The viewer's address, starting it on first use. */
|
|
488
|
+
async openUi(path = "/") {
|
|
489
|
+
this.ui ??= await startUi({ root: this.root });
|
|
490
|
+
await writeJsonAtomic(join(this.root, ".live", "server.json"), {
|
|
491
|
+
port: this.ui.port,
|
|
492
|
+
token: this.ui.token,
|
|
493
|
+
pid: process.pid
|
|
494
|
+
});
|
|
495
|
+
return this.ui.link(path);
|
|
496
|
+
}
|
|
497
|
+
/** The viewer's port while it is running in this process. */
|
|
498
|
+
get viewerPort() {
|
|
499
|
+
return this.ui?.port ?? null;
|
|
500
|
+
}
|
|
501
|
+
/** open_ui: the link, and a line on each of the latest runs. */
|
|
502
|
+
async overview() {
|
|
503
|
+
const link = await this.openUi();
|
|
504
|
+
const runs = (await readRuns(this.root)).slice(0, 5);
|
|
505
|
+
if (runs.length === 0) return `${link}
|
|
506
|
+
|
|
507
|
+
No runs yet.`;
|
|
508
|
+
const lines = runs.map((e) => {
|
|
509
|
+
if (!("run" in e)) return `- ${e.id}: unreadable (${e.error.split("\n")[0]})`;
|
|
510
|
+
const { run } = e;
|
|
511
|
+
const status = effectiveStatus(this.root, run);
|
|
512
|
+
const t = tallyRun(run);
|
|
513
|
+
const outcome = run.score ? `${status}, ${run.score.outcome}` : status;
|
|
514
|
+
return `- ${run.checklist}: ${outcome}, ${t.passed}/${t.total} checks passed, started ${ago(run.started_at)} (${run.id})`;
|
|
515
|
+
});
|
|
516
|
+
return [link, `Latest runs, newest first:
|
|
517
|
+
${lines.join("\n")}`].join("\n\n");
|
|
518
|
+
}
|
|
519
|
+
async startRun(input) {
|
|
520
|
+
if (this.active) return this.resumeActive(input);
|
|
521
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(input.checklist)) {
|
|
522
|
+
throw new Error(`"${input.checklist}" is not a checklist slug (checklists/<slug>.json).`);
|
|
523
|
+
}
|
|
524
|
+
const config = await readJson(
|
|
525
|
+
join(this.root, "stuntdouble.json"),
|
|
526
|
+
ConfigSchema,
|
|
527
|
+
".stuntdouble/stuntdouble.json"
|
|
528
|
+
).catch((err) => {
|
|
529
|
+
throw /file not found$/.test(err.message) ? new Error(`${err.message}. ${SETUP}`) : err;
|
|
530
|
+
});
|
|
531
|
+
let checklist = await readJson(
|
|
532
|
+
join(this.root, "checklists", `${input.checklist}.json`),
|
|
533
|
+
ChecklistSchema,
|
|
534
|
+
`.stuntdouble/checklists/${input.checklist}.json`
|
|
535
|
+
).catch(async (err) => {
|
|
536
|
+
if (!/file not found$/.test(err.message)) throw err;
|
|
537
|
+
const slugs = (await readdir(join(this.root, "checklists")).catch(() => [])).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -".json".length));
|
|
538
|
+
throw new Error(
|
|
539
|
+
`${err.message}. ${slugs.length ? `The checklists here are: ${slugs.join(", ")}.` : SETUP}`
|
|
540
|
+
);
|
|
541
|
+
});
|
|
542
|
+
const actor = await readJson(
|
|
543
|
+
join(this.root, "actors", `${checklist.actor}.json`),
|
|
544
|
+
ActorSchema,
|
|
545
|
+
`.stuntdouble/actors/${checklist.actor}.json`
|
|
546
|
+
);
|
|
547
|
+
await this.markInterrupted();
|
|
548
|
+
let run;
|
|
549
|
+
let dir;
|
|
550
|
+
if (input.resume) {
|
|
551
|
+
const entry = (await readRuns(this.root)).find(
|
|
552
|
+
(e) => "run" in e && e.run.checklist === input.checklist && e.run.runner.kind === "local" && (e.run.status === "interrupted" || e.run.status === "stopped")
|
|
553
|
+
);
|
|
554
|
+
if (!entry || !("run" in entry)) {
|
|
555
|
+
throw new Error(`No interrupted or stopped run of ${input.checklist} to resume.`);
|
|
556
|
+
}
|
|
557
|
+
dir = join(this.root, "runs", entry.id);
|
|
558
|
+
checklist = ChecklistSchema.parse(entry.run.checklist_snapshot);
|
|
559
|
+
await this.clearStop(entry.id);
|
|
560
|
+
run = {
|
|
561
|
+
...entry.run,
|
|
562
|
+
results: entry.run.results.filter((r) => r.evidence !== NOT_JUDGED),
|
|
563
|
+
status: "running",
|
|
564
|
+
pid: process.pid,
|
|
565
|
+
finished_at: null,
|
|
566
|
+
score: null,
|
|
567
|
+
summary: null
|
|
568
|
+
};
|
|
569
|
+
} else {
|
|
570
|
+
dir = "";
|
|
571
|
+
run = {
|
|
572
|
+
format: "stuntdouble.run/1",
|
|
573
|
+
id: "",
|
|
574
|
+
runner: { ...this.runnerFields, kind: "local", package: PACKAGE, judged: false },
|
|
575
|
+
status: "running",
|
|
576
|
+
pid: process.pid,
|
|
577
|
+
url: rebase(checklist.url, config.local_base_url),
|
|
578
|
+
started_at: now(),
|
|
579
|
+
finished_at: null,
|
|
580
|
+
checklist: input.checklist,
|
|
581
|
+
actor: checklist.actor,
|
|
582
|
+
checklist_snapshot: { ...checklist, cloud: void 0 },
|
|
583
|
+
results: [],
|
|
584
|
+
score: null,
|
|
585
|
+
summary: null,
|
|
586
|
+
cloud: null
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const trusted = [run.url, checklist.url, config.local_base_url].filter((u) => Boolean(u)).map((u) => new URL(u));
|
|
590
|
+
const trustedHosts = [...new Set(trusted.map((u) => u.hostname))];
|
|
591
|
+
const trustedOrigins = new Set(trusted.map((u) => u.origin));
|
|
592
|
+
const refuseNavigation = (href) => {
|
|
593
|
+
const url = URL.parse(href);
|
|
594
|
+
return url && isPrivateHost(url.hostname) && !trustedOrigins.has(url.origin) ? PRIVATE_REFUSED : null;
|
|
595
|
+
};
|
|
596
|
+
const preset = PRESETS[checklist.browser_config?.preset ?? "desktop"];
|
|
597
|
+
const toolOptions = {
|
|
598
|
+
viewport: checklist.browser_config?.viewport ?? preset,
|
|
599
|
+
allowPrivateHosts: true,
|
|
600
|
+
checkNavigation: refuseNavigation
|
|
601
|
+
};
|
|
602
|
+
const tempProfile = actor.persist_login ? null : await mkdtemp(join(tmpdir(), "stuntdouble-profile-"));
|
|
603
|
+
const project = createHash("sha256").update(this.root).digest("hex").slice(0, 12);
|
|
604
|
+
const profileDir = tempProfile ?? join(homedir(), ".stuntdouble", "profiles", project, checklist.actor);
|
|
605
|
+
await mkdir(profileDir, { recursive: true });
|
|
606
|
+
let driver;
|
|
607
|
+
try {
|
|
608
|
+
driver = await this.launch({
|
|
609
|
+
profileDir,
|
|
610
|
+
headless: config.headless,
|
|
611
|
+
viewport: toolOptions.viewport,
|
|
612
|
+
touch: checklist.browser_config?.touch ?? preset.touch,
|
|
613
|
+
locale: checklist.browser_config?.locale,
|
|
614
|
+
refuseNavigation
|
|
615
|
+
});
|
|
616
|
+
} catch (err) {
|
|
617
|
+
if (tempProfile) await rm(tempProfile, { recursive: true, force: true });
|
|
618
|
+
throw err;
|
|
619
|
+
}
|
|
620
|
+
if (!input.resume) {
|
|
621
|
+
await mkdir(join(this.root, "runs"), { recursive: true });
|
|
622
|
+
for (let n = 1; !dir; n++) {
|
|
623
|
+
const id = `${stamp()}-${input.checklist}${n > 1 ? `-${n}` : ""}`;
|
|
624
|
+
const made = await mkdir(join(this.root, "runs", id)).then(
|
|
625
|
+
() => true,
|
|
626
|
+
(err) => {
|
|
627
|
+
if (err.code === "EEXIST") return false;
|
|
628
|
+
throw err;
|
|
629
|
+
}
|
|
630
|
+
);
|
|
631
|
+
if (made) [run.id, dir] = [id, join(this.root, "runs", id)];
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const gitignore = join(this.root, ".gitignore");
|
|
635
|
+
if (!await stat(gitignore).catch(() => null)) await writeFileAtomic(gitignore, ".live/\n");
|
|
636
|
+
this.active = {
|
|
637
|
+
dir,
|
|
638
|
+
run,
|
|
639
|
+
checklist,
|
|
640
|
+
actor,
|
|
641
|
+
driver,
|
|
642
|
+
trusted: new DirectBrowserTools(driver, toolOptions),
|
|
643
|
+
guarded: config.safety === "third_party" ? new DirectBrowserTools(driver, { ...toolOptions, safety: INDEX_SESSION_SAFETY }) : null,
|
|
644
|
+
trustedHosts,
|
|
645
|
+
tempProfile,
|
|
646
|
+
everyScreenshot: config.model_screenshots === "every_action",
|
|
647
|
+
stopped: false
|
|
648
|
+
};
|
|
649
|
+
this.finished = null;
|
|
650
|
+
await this.save();
|
|
651
|
+
await this.markRunning();
|
|
652
|
+
await this.log("start_run", input, input.resume ? "Resumed" : "Started");
|
|
653
|
+
const opened = await this.browserTool("navigate", { url: run.url });
|
|
654
|
+
const pending = this.pending();
|
|
655
|
+
const viewer = await this.openUi(`/runs/${run.id}`);
|
|
656
|
+
return [
|
|
657
|
+
`Run ${run.id} ${input.resume ? "resumed" : "started"}. Watch it or stop it at ${viewer}`,
|
|
658
|
+
unweightedNote(checklist),
|
|
659
|
+
buildBrief({
|
|
660
|
+
actor,
|
|
661
|
+
checklist,
|
|
662
|
+
url: run.url,
|
|
663
|
+
checks: pending,
|
|
664
|
+
resumed: Boolean(input.resume)
|
|
665
|
+
}),
|
|
666
|
+
// Not the navigate result: it carries the page title, and this reply is
|
|
667
|
+
// read by the main session, which never reads page content.
|
|
668
|
+
opened.isError ? `The start page did not load: ${opened.text}
|
|
669
|
+
Try navigate again, or judge the checks you cannot reach as "blocked".` : `The browser is open at ${run.url}: read the page with read_page before acting.`,
|
|
670
|
+
this.nextStep()
|
|
671
|
+
].filter(Boolean).join("\n\n");
|
|
672
|
+
}
|
|
673
|
+
/** One of the browser toolset's direct tools, on the active run's browser. */
|
|
674
|
+
async browserTool(name, input) {
|
|
675
|
+
const active = this.requireActive();
|
|
676
|
+
if (await this.stopRequested(active)) return { text: STOPPED, isError: true };
|
|
677
|
+
const page = await active.driver.context.activePage();
|
|
678
|
+
const host = page ? URL.parse(await page.url())?.hostname ?? "" : "";
|
|
679
|
+
const tools = active.guarded && !isTrustedHost(host, active.trustedHosts) ? active.guarded : active.trusted;
|
|
680
|
+
const result = await tools.run(name, input);
|
|
681
|
+
const refusal = active.driver.takeRefusal();
|
|
682
|
+
if (refusal) {
|
|
683
|
+
const text = `Error: Navigation refused. ${refusal}`;
|
|
684
|
+
await this.log(name, redactInput(name, input), text);
|
|
685
|
+
return { text, isError: true };
|
|
686
|
+
}
|
|
687
|
+
if (result.screenshotBase64)
|
|
688
|
+
await this.saveFrame(Buffer.from(result.screenshotBase64, "base64"));
|
|
689
|
+
await this.log(name, redactInput(name, input), result.text);
|
|
690
|
+
const image = result.screenshotBase64 && (name === "screenshot" || active.everyScreenshot) ? result.screenshotBase64 : void 0;
|
|
691
|
+
return { text: result.text, ...image ? { image } : {}, isError: Boolean(result.error) };
|
|
692
|
+
}
|
|
693
|
+
async evaluateCheck(input) {
|
|
694
|
+
if (!this.active && this.finished) return this.alreadyFinished();
|
|
695
|
+
const active = this.requireActive();
|
|
696
|
+
if (await this.stopRequested(active)) throw new Error(STOPPED);
|
|
697
|
+
const { run, checklist } = active;
|
|
698
|
+
const check = checklist.checks.find((c) => c.key === input.check);
|
|
699
|
+
if (!check) {
|
|
700
|
+
throw new Error(
|
|
701
|
+
`No check "${input.check}" in this run. The keys are: ${checklist.checks.map((c) => c.key).join(", ")}.`
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
const earlier = run.results.find((r) => r.check === check.key);
|
|
705
|
+
if (earlier) {
|
|
706
|
+
throw new Error(
|
|
707
|
+
`[${check.key}] already has a verdict (${earlier.status}). Each check gets one.`
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
if (check.type === "informational" && (input.status === "passed" || input.status === "failed")) {
|
|
711
|
+
throw new Error(
|
|
712
|
+
`[${check.key}] is informational: record what you observed with status "informational".`
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
if (check.type !== "informational" && input.status === "informational") {
|
|
716
|
+
throw new Error(
|
|
717
|
+
`[${check.key}] is scored: judge it "passed" or "failed", or "blocked" if you could not reach it.`
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
const page = await active.driver.context.activePage();
|
|
721
|
+
const query = cleanLocator(input.locator);
|
|
722
|
+
const found = page ? await focusEvidence(page, query) : null;
|
|
723
|
+
if (input.status === "passed" && !found) {
|
|
724
|
+
throw new Error(
|
|
725
|
+
query.text || query.selector ? `Not recorded: the locator ${JSON.stringify(query.text ?? query.selector)} is not visible on the current page. Go back to where you saw the evidence, or cite text that is on screen now.` : 'Not recorded: a passed verdict needs a locator, {"text": "..."} visible on the page or {"selector": "..."}.'
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
let screenshot;
|
|
729
|
+
if (page) {
|
|
730
|
+
try {
|
|
731
|
+
const png = await page.screenshot({ type: "png", timeout: 1e4 });
|
|
732
|
+
screenshot = `shots/${check.key}.png`;
|
|
733
|
+
await writeFileAtomic(join(active.dir, screenshot), png);
|
|
734
|
+
await this.saveFrame(png);
|
|
735
|
+
} catch {
|
|
736
|
+
screenshot = void 0;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
run.results.push({
|
|
740
|
+
check: check.key,
|
|
741
|
+
status: input.status === "blocked" ? "error" : input.status,
|
|
742
|
+
evidence: input.evidence,
|
|
743
|
+
...query.text || query.selector ? {
|
|
744
|
+
locator: {
|
|
745
|
+
...query.text ? { text: query.text } : {},
|
|
746
|
+
...query.selector ? { selector: query.selector } : {}
|
|
747
|
+
}
|
|
748
|
+
} : {},
|
|
749
|
+
...screenshot ? { screenshot } : {},
|
|
750
|
+
at: now()
|
|
751
|
+
});
|
|
752
|
+
const statuses = new Map(run.results.map((r) => [r.check, r.status]));
|
|
753
|
+
const halted = resolveHaltedChecks({
|
|
754
|
+
checks: checklist.checks.map((c) => ({
|
|
755
|
+
id: c.key,
|
|
756
|
+
description: c.description,
|
|
757
|
+
onFailure: c.on_failure
|
|
758
|
+
})),
|
|
759
|
+
statuses
|
|
760
|
+
});
|
|
761
|
+
const skipped = halted.length ? halted : this.conditionSkips(statuses);
|
|
762
|
+
for (const s of skipped) {
|
|
763
|
+
run.results.push({ check: s.checkId, status: "skipped", evidence: s.reason, at: now() });
|
|
764
|
+
}
|
|
765
|
+
await this.save();
|
|
766
|
+
const recorded = `Recorded [${check.key}] as ${input.status}.`;
|
|
767
|
+
await this.log("evaluate_check", input, recorded);
|
|
768
|
+
const notes = skipped.map((s) => `[${s.checkId}] ${s.reason}`);
|
|
769
|
+
if (halted.length) notes.push("The run has stopped.");
|
|
770
|
+
return [recorded, ...notes, this.nextStep()].join("\n");
|
|
771
|
+
}
|
|
772
|
+
async finishRun(input) {
|
|
773
|
+
if (!this.active && this.finished) return this.alreadyFinished();
|
|
774
|
+
const active = this.requireActive();
|
|
775
|
+
const stopped = await this.stopRequested(active);
|
|
776
|
+
const { run, checklist } = active;
|
|
777
|
+
for (const s of this.conditionSkips(new Map(run.results.map((r) => [r.check, r.status])))) {
|
|
778
|
+
run.results.push({ check: s.checkId, status: "skipped", evidence: s.reason, at: now() });
|
|
779
|
+
}
|
|
780
|
+
for (const check of this.pending()) {
|
|
781
|
+
run.results.push({
|
|
782
|
+
check: check.key,
|
|
783
|
+
status: "error",
|
|
784
|
+
evidence: NOT_JUDGED,
|
|
785
|
+
at: now()
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
const score = scoreLocalRun(checklist, run.results);
|
|
789
|
+
run.score = {
|
|
790
|
+
score: score.score,
|
|
791
|
+
unit: score.scoreUnit,
|
|
792
|
+
outcome: score.outcome,
|
|
793
|
+
pass_threshold: score.passThreshold,
|
|
794
|
+
grade: score.grade
|
|
795
|
+
};
|
|
796
|
+
run.summary = input.summary;
|
|
797
|
+
run.status = stopped ? "stopped" : "completed";
|
|
798
|
+
run.finished_at = now();
|
|
799
|
+
await this.save();
|
|
800
|
+
await this.log("finish_run", input, `Finished: ${score.outcome}`);
|
|
801
|
+
const viewer = await this.openUi(`/runs/${run.id}`);
|
|
802
|
+
await this.endBrowser();
|
|
803
|
+
const line = (r) => `- [${r.check}] ${r.evidence}`;
|
|
804
|
+
const failed = run.results.filter((r) => r.status === "failed");
|
|
805
|
+
const blocked = run.results.filter((r) => r.status === "error");
|
|
806
|
+
const counts = describeRunScore(score, {
|
|
807
|
+
passedCount: run.results.filter((r) => r.status === "passed").length,
|
|
808
|
+
totalCount: checklist.checks.length
|
|
809
|
+
});
|
|
810
|
+
const t = tallyRun(run);
|
|
811
|
+
const tally = score.weighted ? ` ${t.passed}/${t.total} checks passed.` : "";
|
|
812
|
+
this.finished = { id: run.id, report: viewer };
|
|
813
|
+
return [
|
|
814
|
+
`Run ${run.id} ${run.status}: ${score.outcome}. ${counts}.${tally} ${score.weighted ? "" : `${unweightedNote(checklist, true)} `}Self-judged: the actor recorded its own verdicts.`,
|
|
815
|
+
failed.length ? `Failed:
|
|
816
|
+
${failed.map(line).join("\n")}` : "",
|
|
817
|
+
blocked.length ? `Not judged:
|
|
818
|
+
${blocked.map(line).join("\n")}` : "",
|
|
819
|
+
`Report: ${viewer}`
|
|
820
|
+
].filter(Boolean).join("\n\n");
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* start_run while this process still has a run open, as when the person
|
|
824
|
+
* interrupts the actor: resume carries on with it in the same browser.
|
|
825
|
+
*/
|
|
826
|
+
async resumeActive(input) {
|
|
827
|
+
const active = this.requireActive();
|
|
828
|
+
const { run } = active;
|
|
829
|
+
if (!input.resume || run.checklist !== input.checklist) {
|
|
830
|
+
throw new Error(
|
|
831
|
+
`Run ${run.id} of ${run.checklist} is still going: call start_run with checklist "${run.checklist}" and resume to carry on with it, or finish_run to end it.`
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
active.stopped = false;
|
|
835
|
+
run.status = "running";
|
|
836
|
+
await this.clearStop(run.id);
|
|
837
|
+
await this.save();
|
|
838
|
+
await this.markRunning();
|
|
839
|
+
await this.log("start_run", input, "Resumed");
|
|
840
|
+
const viewer = await this.openUi(`/runs/${run.id}`);
|
|
841
|
+
return [
|
|
842
|
+
`Run ${run.id} resumed. Watch it or stop it at ${viewer}`,
|
|
843
|
+
buildBrief({
|
|
844
|
+
actor: active.actor,
|
|
845
|
+
checklist: active.checklist,
|
|
846
|
+
url: run.url,
|
|
847
|
+
checks: this.pending(),
|
|
848
|
+
resumed: true
|
|
849
|
+
}),
|
|
850
|
+
"The browser is still open where the run left off: read the page before acting.",
|
|
851
|
+
this.nextStep()
|
|
852
|
+
].join("\n\n");
|
|
853
|
+
}
|
|
854
|
+
/** On shutdown: an unfinished run is marked interrupted, so it can be resumed. */
|
|
855
|
+
async close() {
|
|
856
|
+
if (this.active) {
|
|
857
|
+
this.active.run.status = this.active.stopped ? "stopped" : "interrupted";
|
|
858
|
+
await this.save().catch(() => {
|
|
859
|
+
});
|
|
860
|
+
await this.endBrowser();
|
|
861
|
+
}
|
|
862
|
+
if (!this.sharedUi) await this.ui?.close();
|
|
863
|
+
this.ui = null;
|
|
864
|
+
}
|
|
865
|
+
alreadyFinished() {
|
|
866
|
+
const { id, report } = this.finished;
|
|
867
|
+
return `Run ${id} has already finished: its report is at ${report}. Start another with start_run.`;
|
|
868
|
+
}
|
|
869
|
+
requireActive() {
|
|
870
|
+
if (!this.active) throw new Error("No run is going: call start_run first.");
|
|
871
|
+
return this.active;
|
|
872
|
+
}
|
|
873
|
+
/** Checks with no result yet, in run order. */
|
|
874
|
+
pending() {
|
|
875
|
+
const active = this.requireActive();
|
|
876
|
+
const judged = new Set(active.run.results.map((r) => r.check));
|
|
877
|
+
return active.checklist.checks.filter((c) => !judged.has(c.key));
|
|
878
|
+
}
|
|
879
|
+
conditionSkips(statuses) {
|
|
880
|
+
return resolveSkippedChecks({
|
|
881
|
+
checks: this.requireActive().checklist.checks.map((c) => ({
|
|
882
|
+
id: c.key,
|
|
883
|
+
description: c.description,
|
|
884
|
+
runCondition: c.run_condition
|
|
885
|
+
})),
|
|
886
|
+
statuses
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
nextStep() {
|
|
890
|
+
const pending = this.pending();
|
|
891
|
+
if (pending.length === 0)
|
|
892
|
+
return "Every check has a verdict: call finish_run with a short summary.";
|
|
893
|
+
const next = pending[0];
|
|
894
|
+
return `Next: [${next.key}] ${checkLabel(next)} ${next.description} (${pending.length} left).`;
|
|
895
|
+
}
|
|
896
|
+
/** The viewer's Stop writes the run id to `.live/stop`; the run notices on its next call. */
|
|
897
|
+
async stopRequested(active) {
|
|
898
|
+
if (active.stopped) return true;
|
|
899
|
+
const flag = await readFile(join(this.root, ".live", "stop"), "utf8").catch(() => "");
|
|
900
|
+
if (flag.trim() !== active.run.id) return false;
|
|
901
|
+
active.stopped = true;
|
|
902
|
+
active.run.status = "stopped";
|
|
903
|
+
await this.save();
|
|
904
|
+
await this.log("stop", {}, "Stopped from the viewer");
|
|
905
|
+
return true;
|
|
906
|
+
}
|
|
907
|
+
/** Runs left `running` by a process that is gone, or by this one before a restart. */
|
|
908
|
+
async markInterrupted() {
|
|
909
|
+
for (const entry of await readRuns(this.root)) {
|
|
910
|
+
if (!("run" in entry) || entry.run.status !== "running") continue;
|
|
911
|
+
if (entry.run.pid !== process.pid && isLive(this.root, entry.run)) continue;
|
|
912
|
+
await writeJsonAtomic(join(this.root, "runs", entry.id, "run.json"), {
|
|
913
|
+
...entry.run,
|
|
914
|
+
status: "interrupted"
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
async endBrowser() {
|
|
919
|
+
const active = this.active;
|
|
920
|
+
if (!active) return;
|
|
921
|
+
this.active = null;
|
|
922
|
+
await active.driver.close().catch(() => {
|
|
923
|
+
});
|
|
924
|
+
if (active.tempProfile) await rm(active.tempProfile, { recursive: true, force: true });
|
|
925
|
+
await this.clearStop(active.run.id);
|
|
926
|
+
await rm(runningMarker(this.root, active.run.id), { force: true });
|
|
927
|
+
}
|
|
928
|
+
async markRunning() {
|
|
929
|
+
const { run } = this.requireActive();
|
|
930
|
+
await writeFileAtomic(runningMarker(this.root, run.id), String(run.pid));
|
|
931
|
+
}
|
|
932
|
+
async clearStop(id) {
|
|
933
|
+
const stopFile = join(this.root, ".live", "stop");
|
|
934
|
+
const flag = await readFile(stopFile, "utf8").catch(() => "");
|
|
935
|
+
if (flag.trim() === id) await rm(stopFile, { force: true });
|
|
936
|
+
}
|
|
937
|
+
async save() {
|
|
938
|
+
const active = this.requireActive();
|
|
939
|
+
await writeJsonAtomic(join(active.dir, "run.json"), active.run);
|
|
940
|
+
}
|
|
941
|
+
async log(tool, input, text) {
|
|
942
|
+
const active = this.requireActive();
|
|
943
|
+
await appendJsonl(join(active.dir, "activity.jsonl"), {
|
|
944
|
+
at: now(),
|
|
945
|
+
tool,
|
|
946
|
+
input,
|
|
947
|
+
text: text.length > LOG_TEXT_CHARS ? `${text.slice(0, LOG_TEXT_CHARS)}...` : text
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
/** The viewer's live frame, best effort. ponytail: one per project, so two sessions running at once share it. */
|
|
951
|
+
async saveFrame(png) {
|
|
952
|
+
await writeFileAtomic(join(this.root, ".live", "latest.png"), png).catch(() => {
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
function isTrustedHost(host, trustedHosts) {
|
|
957
|
+
return host !== "" && trustedHosts.some((t) => hostMatchesDomain(host, t));
|
|
958
|
+
}
|
|
959
|
+
function unweightedNote(checklist, atFinish = false) {
|
|
960
|
+
if (checklist.checks.some((c) => c.type === "weighted")) return null;
|
|
961
|
+
const threshold = checklist.pass_threshold == null ? "" : ` Its pass_threshold (${checklist.pass_threshold}) applies to weighted checks only.`;
|
|
962
|
+
if (!atFinish && !threshold) return null;
|
|
963
|
+
return `No score: this checklist has no weighted checks, so it passes only when every pass/fail check passes.${threshold}`;
|
|
964
|
+
}
|
|
965
|
+
function scoreLocalRun(checklist, results) {
|
|
966
|
+
const scoring = newChecklistScoring({
|
|
967
|
+
scoreType: checklist.score_type,
|
|
968
|
+
scoreUnit: checklist.score_unit,
|
|
969
|
+
passThreshold: checklist.pass_threshold,
|
|
970
|
+
scoreBands: checklist.score_bands,
|
|
971
|
+
checks: checklist.checks.map((c) => ({ checkType: c.type, weight: c.weight }))
|
|
972
|
+
});
|
|
973
|
+
return scoreChecklistRun({
|
|
974
|
+
checks: checklist.checks.map((c) => ({
|
|
975
|
+
id: c.key,
|
|
976
|
+
checkType: c.type,
|
|
977
|
+
weight: c.weight ?? 1,
|
|
978
|
+
onFailure: c.on_failure
|
|
979
|
+
})),
|
|
980
|
+
results: results.map((r) => ({ checkId: r.check, status: r.status })),
|
|
981
|
+
passThreshold: scoring.pass_threshold,
|
|
982
|
+
scoreBands: scoring.score_bands,
|
|
983
|
+
scoreType: scoring.score_type,
|
|
984
|
+
scoreUnit: scoring.score_unit
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
export {
|
|
989
|
+
PACKAGE,
|
|
990
|
+
rebase,
|
|
991
|
+
LocalSession,
|
|
992
|
+
isTrustedHost,
|
|
993
|
+
unweightedNote,
|
|
994
|
+
scoreLocalRun
|
|
995
|
+
};
|
|
996
|
+
//# sourceMappingURL=chunk-XHSDRB3X.js.map
|