ravensight-playtest 0.1.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/LICENSE +21 -0
- package/README.md +380 -0
- package/addons/ravensight_driver/driver.gd +836 -0
- package/addons/ravensight_driver/export_plugin.gd +51 -0
- package/addons/ravensight_driver/plugin.cfg +7 -0
- package/addons/ravensight_driver/plugin.gd +36 -0
- package/bin/ravensight-playtest.js +31 -0
- package/package.json +45 -0
- package/src/api/README.md +500 -0
- package/src/api/client.js +340 -0
- package/src/api/errors.js +115 -0
- package/src/api/http.js +194 -0
- package/src/api/index.js +107 -0
- package/src/auth/deviceCode.js +79 -0
- package/src/auth/keychain.js +159 -0
- package/src/auth/session.js +128 -0
- package/src/cli.js +335 -0
- package/src/commands/brief.js +303 -0
- package/src/commands/check.js +318 -0
- package/src/commands/fakeCore.js +379 -0
- package/src/commands/init.js +120 -0
- package/src/commands/login.js +90 -0
- package/src/commands/logout.js +70 -0
- package/src/commands/open.js +125 -0
- package/src/commands/profile.js +262 -0
- package/src/commands/resume.js +156 -0
- package/src/commands/run.js +1015 -0
- package/src/commands/upload.js +137 -0
- package/src/config.js +100 -0
- package/src/dashboard.js +97 -0
- package/src/detect.js +77 -0
- package/src/errors.js +44 -0
- package/src/fsutil.js +77 -0
- package/src/godot.js +85 -0
- package/src/packs/index.js +191 -0
- package/src/paths.js +129 -0
- package/src/run/aggregate.js +658 -0
- package/src/run/args.js +111 -0
- package/src/run/context.js +181 -0
- package/src/run/deps.js +184 -0
- package/src/run/drivers/driver.js +183 -0
- package/src/run/drivers/godot-observation.js +138 -0
- package/src/run/drivers/godot-project.js +475 -0
- package/src/run/drivers/godot-rpc.js +225 -0
- package/src/run/drivers/godot.js +587 -0
- package/src/run/drivers/index.js +52 -0
- package/src/run/drivers/web.js +385 -0
- package/src/run/exit.js +21 -0
- package/src/run/heartbeat.js +131 -0
- package/src/run/index.js +31 -0
- package/src/run/json.js +56 -0
- package/src/run/model.js +384 -0
- package/src/run/paths.js +88 -0
- package/src/run/personaLoop.js +871 -0
- package/src/run/profile.js +214 -0
- package/src/run/regenerate.js +149 -0
- package/src/run/repoTools.js +286 -0
- package/src/run/report.js +222 -0
- package/src/run/resume.js +272 -0
- package/src/run/secretScan.js +171 -0
- package/src/run/state.js +198 -0
- package/src/run/synthetic.js +206 -0
- package/src/run/tools.js +344 -0
- package/src/run/transcript.js +93 -0
- package/src/run/usage.js +115 -0
- package/src/state/index.js +105 -0
- package/src/states.js +104 -0
- package/src/ui/index.js +195 -0
- package/src/upload/allowlist.js +116 -0
- package/src/upload/index.js +467 -0
- package/src/upload/queue.js +114 -0
- package/src/version.js +63 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { mkdir, rename, stat, readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
DriverError,
|
|
5
|
+
OBSERVATION_MAX_CHARS,
|
|
6
|
+
THIN_OBSERVATION_CHARS,
|
|
7
|
+
observationHash,
|
|
8
|
+
screenshotName,
|
|
9
|
+
truncateObservation
|
|
10
|
+
} from './driver.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The `playwright_web` driver: Playwright as a library, not through
|
|
14
|
+
* `@playwright/mcp`.
|
|
15
|
+
*
|
|
16
|
+
* The PoC drove the browser through the Playwright MCP server, which the
|
|
17
|
+
* plan rules out for production (v1-plan W9, "Not reused: ... Playwright
|
|
18
|
+
* MCP is not viable; use the library"), and the model proxy makes that
|
|
19
|
+
* concrete: it refuses a body carrying `mcp_servers` outright. So the tool
|
|
20
|
+
* surface is client tools over this object, and this object is the only
|
|
21
|
+
* thing with a browser handle.
|
|
22
|
+
*
|
|
23
|
+
* Playwright is an optional peer: a `npm i -g ravensight-playtest` should
|
|
24
|
+
* not drag a 300 MB browser download onto a machine that only ever runs
|
|
25
|
+
* `godot_driver`. It is imported lazily and a missing install is an
|
|
26
|
+
* environment error with the two commands that fix it, which is the same
|
|
27
|
+
* thing `check` reports.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Default recording and viewport size. Kept at 720p to match the upload guidance. */
|
|
31
|
+
export const DEFAULT_VIEWPORT = Object.freeze({ width: 1280, height: 720 });
|
|
32
|
+
|
|
33
|
+
/** A `wait` action is clamped to this, so one turn cannot burn the wall clock. */
|
|
34
|
+
export const MAX_WAIT_MS = 10000;
|
|
35
|
+
|
|
36
|
+
/** How long a single Playwright call may take before it is an error, not a hang. */
|
|
37
|
+
export const ACTION_TIMEOUT_MS = 15000;
|
|
38
|
+
|
|
39
|
+
const KNOWN_SELECTOR_ENGINES = /^(role|text|css|xpath|id|data-testid|internal:)=/;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} target
|
|
43
|
+
* @returns {Promise<any>} the playwright module
|
|
44
|
+
*/
|
|
45
|
+
async function loadPlaywright(importer) {
|
|
46
|
+
const load = importer || (specifier => import(specifier));
|
|
47
|
+
try {
|
|
48
|
+
return await load('playwright');
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new DriverError(
|
|
51
|
+
'The playwright_web driver needs Playwright and its Chromium build. Install them with "npm i -D playwright" and "npx playwright install chromium", then run check again.',
|
|
52
|
+
{ fatal: true }
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether the model is allowed to navigate here. The persona may only move
|
|
59
|
+
* around inside the build it was pointed at, plus the rest of loopback when
|
|
60
|
+
* the build ITSELF is on loopback (a local web export is commonly served on
|
|
61
|
+
* one port with an asset or API server on another).
|
|
62
|
+
*
|
|
63
|
+
* Game text is untrusted input: a level name or a leaderboard entry can
|
|
64
|
+
* carry "go to https://.../ and paste your token". Confining `navigate` to
|
|
65
|
+
* the target's own origin means the worst such an instruction can achieve
|
|
66
|
+
* is a page of the developer's own game.
|
|
67
|
+
*
|
|
68
|
+
* The loopback allowance is conditional for a reason. A hosted build has no
|
|
69
|
+
* business steering the browser at the developer's own machine: `localhost`
|
|
70
|
+
* there is whatever else they happen to be running, dev servers and admin
|
|
71
|
+
* panels included, and reaching it is a genuine escalation rather than a
|
|
72
|
+
* convenience.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} candidate
|
|
75
|
+
* @param {string} target
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
export function isNavigationAllowed(candidate, target) {
|
|
79
|
+
let url;
|
|
80
|
+
let base;
|
|
81
|
+
try {
|
|
82
|
+
url = new URL(candidate);
|
|
83
|
+
base = new URL(target);
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
88
|
+
if (url.origin === base.origin) return true;
|
|
89
|
+
return isLoopback(base.hostname) && isLoopback(url.hostname);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]', '::1'];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {string} hostname
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
export function isLoopback(hostname) {
|
|
99
|
+
return LOOPBACK_HOSTS.includes(hostname) || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Turn what the model wrote into a Playwright locator.
|
|
104
|
+
*
|
|
105
|
+
* An aria snapshot prints nodes as `- button "Start"`, so that form is
|
|
106
|
+
* accepted directly and rewritten into the `role=` engine. Anything with a
|
|
107
|
+
* known engine prefix is passed through, a CSS-looking string is treated
|
|
108
|
+
* as CSS, and everything else falls back to visible text, which is what a
|
|
109
|
+
* player would actually be aiming at.
|
|
110
|
+
*
|
|
111
|
+
* @param {any} page
|
|
112
|
+
* @param {string} target
|
|
113
|
+
* @returns {any} a locator
|
|
114
|
+
*/
|
|
115
|
+
export function resolveLocator(page, target) {
|
|
116
|
+
const value = String(target).trim();
|
|
117
|
+
if (KNOWN_SELECTOR_ENGINES.test(value)) return page.locator(value);
|
|
118
|
+
|
|
119
|
+
// The name group cannot contain a quote (`[^"]*`), so there is nothing to
|
|
120
|
+
// escape when it is spliced back into the `role=` engine. A target whose
|
|
121
|
+
// name really does contain a quote falls through to the text search below.
|
|
122
|
+
const roleAndName = value.match(/^([a-z]+)\s+"([^"]*)"$/);
|
|
123
|
+
if (roleAndName) return page.locator(`role=${roleAndName[1]}[name="${roleAndName[2]}"]`);
|
|
124
|
+
|
|
125
|
+
if (/^[#.[]/.test(value) || /^[a-z][a-z0-9-]*(\[|\.|#|:| >)/i.test(value)) return page.locator(value);
|
|
126
|
+
|
|
127
|
+
return page.getByText(value, { exact: false }).first();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {Object} opts
|
|
132
|
+
* @param {(specifier: string) => Promise<any>} [opts.importer] test seam
|
|
133
|
+
* @returns {Object} a driver
|
|
134
|
+
*/
|
|
135
|
+
export function createWebDriver({ importer, headless = true } = {}) {
|
|
136
|
+
let browser = null;
|
|
137
|
+
let context = null;
|
|
138
|
+
let page = null;
|
|
139
|
+
let target = null;
|
|
140
|
+
let runDirectory = null;
|
|
141
|
+
let videoRequested = false;
|
|
142
|
+
let step = 0;
|
|
143
|
+
let shots = 0;
|
|
144
|
+
let lastHash = null;
|
|
145
|
+
const consoleBuffer = [];
|
|
146
|
+
|
|
147
|
+
/** @returns {any} */
|
|
148
|
+
function requirePage() {
|
|
149
|
+
if (!page) throw new DriverError('the browser is not open; launch() first', { fatal: true });
|
|
150
|
+
return page;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function snapshotText() {
|
|
154
|
+
const current = requirePage();
|
|
155
|
+
let text = '';
|
|
156
|
+
const body = current.locator('body');
|
|
157
|
+
if (typeof body.ariaSnapshot === 'function') {
|
|
158
|
+
try {
|
|
159
|
+
text = await body.ariaSnapshot({ timeout: ACTION_TIMEOUT_MS });
|
|
160
|
+
} catch {
|
|
161
|
+
text = '';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (text.trim().length < THIN_OBSERVATION_CHARS) {
|
|
165
|
+
try {
|
|
166
|
+
const inner = await current.evaluate(() => {
|
|
167
|
+
const el = globalThis.document && globalThis.document.body;
|
|
168
|
+
return el ? el.innerText : '';
|
|
169
|
+
});
|
|
170
|
+
if (String(inner).trim().length > text.trim().length) text = String(inner);
|
|
171
|
+
} catch {
|
|
172
|
+
// A page mid-navigation has no body to read. The thin observation
|
|
173
|
+
// is the honest answer, and the screenshot fallback below covers it.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return text;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
name: 'playwright_web',
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @param {Object} opts
|
|
184
|
+
* @param {string} opts.target the build URL
|
|
185
|
+
* @param {string} opts.runDir where video and screenshots land
|
|
186
|
+
* @param {boolean} [opts.recordVideo]
|
|
187
|
+
* @param {{width: number, height: number}} [opts.viewport]
|
|
188
|
+
*/
|
|
189
|
+
async launch({ target: url, runDir, recordVideo = false, viewport = DEFAULT_VIEWPORT }) {
|
|
190
|
+
if (!url) throw new DriverError('playwright_web needs a target URL', { fatal: true });
|
|
191
|
+
target = url;
|
|
192
|
+
runDirectory = runDir;
|
|
193
|
+
videoRequested = Boolean(recordVideo);
|
|
194
|
+
|
|
195
|
+
const playwright = await loadPlaywright(importer);
|
|
196
|
+
await mkdir(runDir, { recursive: true });
|
|
197
|
+
|
|
198
|
+
browser = await playwright.chromium.launch({ headless });
|
|
199
|
+
context = await browser.newContext({
|
|
200
|
+
viewport,
|
|
201
|
+
...(videoRequested ? { recordVideo: { dir: runDir, size: viewport } } : {})
|
|
202
|
+
});
|
|
203
|
+
page = await context.newPage();
|
|
204
|
+
page.on('console', message => {
|
|
205
|
+
if (message.type() === 'error') consoleBuffer.push({ type: 'console', text: message.text() });
|
|
206
|
+
});
|
|
207
|
+
page.on('pageerror', error => consoleBuffer.push({ type: 'pageerror', text: String(error && error.message) }));
|
|
208
|
+
|
|
209
|
+
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
210
|
+
// A 404 is not something to guess around: the build is not there, and
|
|
211
|
+
// guessing URLs is exactly what spec 03 forbids.
|
|
212
|
+
if (response && response.status() >= 400) {
|
|
213
|
+
throw new DriverError(`${url} answered ${response.status()}`, { fatal: true });
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
/** @returns {Promise<import('./driver.js').Observation>} */
|
|
218
|
+
async observe() {
|
|
219
|
+
const current = requirePage();
|
|
220
|
+
const started = Date.now();
|
|
221
|
+
step += 1;
|
|
222
|
+
const text = await snapshotText();
|
|
223
|
+
const observation = {
|
|
224
|
+
step,
|
|
225
|
+
text: truncateObservation(text, OBSERVATION_MAX_CHARS),
|
|
226
|
+
url: current.url(),
|
|
227
|
+
elapsedMs: Date.now() - started
|
|
228
|
+
};
|
|
229
|
+
// Canvas builds (a Godot or Unity web export is one <canvas> and
|
|
230
|
+
// nothing else) have no accessibility tree worth reading, so vision
|
|
231
|
+
// is the only way to play them. Spend an image only then.
|
|
232
|
+
if (String(text).trim().length < THIN_OBSERVATION_CHARS) {
|
|
233
|
+
try {
|
|
234
|
+
const buffer = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
|
|
235
|
+
observation.screenshotBase64 = buffer.toString('base64');
|
|
236
|
+
observation.screenshotMediaType = 'image/png';
|
|
237
|
+
} catch {
|
|
238
|
+
// No image and thin text is still an observation; the persona will
|
|
239
|
+
// read it as a blank screen, which is what it is.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Hash what was REPORTED, not the untruncated snapshot: `act` compares
|
|
243
|
+
// against this, so two screens that differ only past the truncation
|
|
244
|
+
// point would otherwise read as changed to `act` and identical to
|
|
245
|
+
// anything reading the observation.
|
|
246
|
+
lastHash = observationHash(observation);
|
|
247
|
+
return observation;
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @param {import('./driver.js').Action} action
|
|
252
|
+
* @returns {Promise<import('./driver.js').ActResult>}
|
|
253
|
+
*/
|
|
254
|
+
async act(action) {
|
|
255
|
+
const current = requirePage();
|
|
256
|
+
const before = lastHash;
|
|
257
|
+
try {
|
|
258
|
+
switch (action.kind) {
|
|
259
|
+
case 'key':
|
|
260
|
+
await current.keyboard.press(String(action.key), { timeout: ACTION_TIMEOUT_MS });
|
|
261
|
+
break;
|
|
262
|
+
case 'type':
|
|
263
|
+
await current.keyboard.type(String(action.text), { delay: 20 });
|
|
264
|
+
if (action.submit) await current.keyboard.press('Enter');
|
|
265
|
+
break;
|
|
266
|
+
case 'click': {
|
|
267
|
+
if (action.target && typeof action.target === 'object') {
|
|
268
|
+
await current.mouse.click(Number(action.target.x), Number(action.target.y));
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
await resolveLocator(current, action.target).click({ timeout: ACTION_TIMEOUT_MS });
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
case 'navigate': {
|
|
275
|
+
const url = String(action.url);
|
|
276
|
+
if (!isNavigationAllowed(url, target)) {
|
|
277
|
+
return { ok: false, changed: false, error: `navigation to ${url} is outside this build's origin (${new URL(target).origin}) and was refused` };
|
|
278
|
+
}
|
|
279
|
+
await current.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
case 'wait':
|
|
283
|
+
await current.waitForTimeout(Math.min(Math.max(Number(action.ms) || 0, 0), MAX_WAIT_MS));
|
|
284
|
+
break;
|
|
285
|
+
default:
|
|
286
|
+
return { ok: false, changed: false, error: `the playwright_web driver cannot do "${action.kind}"` };
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
return { ok: false, changed: false, error: String(error && error.message ? error.message : error) };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let changed = false;
|
|
293
|
+
try {
|
|
294
|
+
// The same truncation observe() applies, so the two hashes are
|
|
295
|
+
// comparable. Without it a long screen is always "changed".
|
|
296
|
+
changed = observationHash(truncateObservation(await snapshotText(), OBSERVATION_MAX_CHARS)) !== before;
|
|
297
|
+
} catch {
|
|
298
|
+
changed = true;
|
|
299
|
+
}
|
|
300
|
+
return { ok: true, changed };
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* @param {string} name
|
|
305
|
+
* @returns {Promise<{path: string, relativePath: string}>}
|
|
306
|
+
*/
|
|
307
|
+
async screenshot(name) {
|
|
308
|
+
const current = requirePage();
|
|
309
|
+
shots += 1;
|
|
310
|
+
const relativePath = path.join('screenshots', screenshotName(shots, name));
|
|
311
|
+
const absolute = path.join(runDirectory, relativePath);
|
|
312
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
313
|
+
await current.screenshot({ path: absolute, type: 'png', timeout: ACTION_TIMEOUT_MS });
|
|
314
|
+
return { path: absolute, relativePath };
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
/** @returns {Promise<Array<{type: string, text: string}>>} */
|
|
318
|
+
async consoleErrors() {
|
|
319
|
+
return consoleBuffer.splice(0, consoleBuffer.length);
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
/** @returns {Promise<{videoPath: string|null}>} */
|
|
323
|
+
async stop() {
|
|
324
|
+
let videoPath = null;
|
|
325
|
+
const video = videoRequested && page && typeof page.video === 'function' ? page.video() : null;
|
|
326
|
+
try {
|
|
327
|
+
if (context) await context.close();
|
|
328
|
+
} catch {
|
|
329
|
+
// A context that is already gone is not an error worth failing the
|
|
330
|
+
// run over; the report is already written by the time we get here.
|
|
331
|
+
}
|
|
332
|
+
if (video) {
|
|
333
|
+
try {
|
|
334
|
+
videoPath = await renameVideo(await video.path(), runDirectory);
|
|
335
|
+
} catch {
|
|
336
|
+
videoPath = await findVideo(runDirectory);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
if (browser) await browser.close();
|
|
341
|
+
} catch {
|
|
342
|
+
// Same: a closed browser is the desired end state either way.
|
|
343
|
+
}
|
|
344
|
+
page = null;
|
|
345
|
+
context = null;
|
|
346
|
+
browser = null;
|
|
347
|
+
return { videoPath };
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Playwright names its recording with a random id. The upload allowlist and
|
|
354
|
+
* the server's 90 day lifecycle rule are written against `session.webm`, so
|
|
355
|
+
* the file is renamed rather than uploaded under whatever name it got.
|
|
356
|
+
*
|
|
357
|
+
* @param {string} source
|
|
358
|
+
* @param {string} runDir
|
|
359
|
+
* @returns {Promise<string>}
|
|
360
|
+
*/
|
|
361
|
+
async function renameVideo(source, runDir) {
|
|
362
|
+
const target = path.join(runDir, 'session.webm');
|
|
363
|
+
if (!source || path.resolve(source) === path.resolve(target)) return target;
|
|
364
|
+
await rename(source, target);
|
|
365
|
+
return target;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* @param {string} runDir
|
|
370
|
+
* @returns {Promise<string|null>}
|
|
371
|
+
*/
|
|
372
|
+
async function findVideo(runDir) {
|
|
373
|
+
try {
|
|
374
|
+
const entries = await readdir(runDir);
|
|
375
|
+
const webm = entries.find(entry => entry.endsWith('.webm'));
|
|
376
|
+
if (!webm) return null;
|
|
377
|
+
const source = path.join(runDir, webm);
|
|
378
|
+
await stat(source);
|
|
379
|
+
return await renameVideo(source, runDir);
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export default createWebDriver;
|
package/src/run/exit.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process exit codes, from spec 17 section 1.2:
|
|
3
|
+
*
|
|
4
|
+
* 0 success, 2 budget exceeded (partial results uploaded), 3 environment
|
|
5
|
+
* check failed, 4 auth, 5 model API error after retries, 10 canceled.
|
|
6
|
+
*
|
|
7
|
+
* CI treats 2 as success-with-warning behind a flag, so 2 must never be
|
|
8
|
+
* used for anything but "we ran out of money and still shipped what we
|
|
9
|
+
* had".
|
|
10
|
+
*/
|
|
11
|
+
export const EXIT = Object.freeze({
|
|
12
|
+
OK: 0,
|
|
13
|
+
FAILED: 1,
|
|
14
|
+
BUDGET: 2,
|
|
15
|
+
ENVIRONMENT: 3,
|
|
16
|
+
AUTH: 4,
|
|
17
|
+
MODEL: 5,
|
|
18
|
+
CANCELED: 10
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export default EXIT;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The heartbeat, and the only channel through which a run learns it has
|
|
3
|
+
* been cancelled.
|
|
4
|
+
*
|
|
5
|
+
* `PATCH /jobs/:jobId/runs/:runId` with no `state` is a heartbeat, and its
|
|
6
|
+
* answer carries two things the runner needs and cannot get anywhere else:
|
|
7
|
+
*
|
|
8
|
+
* - `cancel_requested`, set by `POST /jobs/:jobId/cancel`. A runner that
|
|
9
|
+
* polls nothing else still stops, because every heartbeat answers with it.
|
|
10
|
+
* (The model proxy reads the same flag and starts refusing calls, so the
|
|
11
|
+
* run would stop either way; the difference is stopping cleanly with a
|
|
12
|
+
* partial report instead of on a 409.)
|
|
13
|
+
* - `run.usage.total_usd`, the authoritative spend. Prices are server side
|
|
14
|
+
* only, so this is where `usage.json`'s dollar figure comes from.
|
|
15
|
+
*
|
|
16
|
+
* The server's housekeeping sweep moves a run to `interrupted` when its
|
|
17
|
+
* `lastHeartbeatAt` is older than 30 minutes, so the interval only has to be
|
|
18
|
+
* comfortably under that; spec 17 asks for 5 seconds or less while online,
|
|
19
|
+
* which is what the default is.
|
|
20
|
+
*
|
|
21
|
+
* A failed beat is not fatal. The network dropping for twenty seconds should
|
|
22
|
+
* not kill a paid run, and the sweep's 30 minute window is the real
|
|
23
|
+
* deadline. Failures are counted and surfaced; the caller decides.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_INTERVAL_MS = 5000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {Object} args
|
|
30
|
+
* @param {Object} args.api the deps api surface
|
|
31
|
+
* @param {string} args.gameId
|
|
32
|
+
* @param {string} args.jobId
|
|
33
|
+
* @param {string} args.runId
|
|
34
|
+
* @param {number} [args.intervalMs]
|
|
35
|
+
* @param {() => void} [args.onCancel] called once, when the server first
|
|
36
|
+
* reports cancel_requested
|
|
37
|
+
* @param {(usage: Object) => void} [args.onUsage]
|
|
38
|
+
* @param {(patch: Object) => void|Promise<void>} [args.onBeat] the progress this
|
|
39
|
+
* beat reported, after the server accepted it
|
|
40
|
+
* @param {(error: Error) => void} [args.onError]
|
|
41
|
+
* @param {Function} [args.setIntervalImpl] test seam
|
|
42
|
+
* @param {Function} [args.clearIntervalImpl] test seam
|
|
43
|
+
*/
|
|
44
|
+
export function createHeartbeat({
|
|
45
|
+
api,
|
|
46
|
+
gameId,
|
|
47
|
+
jobId,
|
|
48
|
+
runId,
|
|
49
|
+
intervalMs = DEFAULT_INTERVAL_MS,
|
|
50
|
+
onCancel,
|
|
51
|
+
onUsage,
|
|
52
|
+
onBeat,
|
|
53
|
+
onError,
|
|
54
|
+
setIntervalImpl = setInterval,
|
|
55
|
+
clearIntervalImpl = clearInterval
|
|
56
|
+
}) {
|
|
57
|
+
let timer = null;
|
|
58
|
+
let canceled = false;
|
|
59
|
+
let failures = 0;
|
|
60
|
+
let inFlight = false;
|
|
61
|
+
// The progress the loop last reported. The interval beat carries it too,
|
|
62
|
+
// rather than resetting the server's and the journal's idea of the
|
|
63
|
+
// checkpoint to zero every five seconds.
|
|
64
|
+
let progress = {};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {{actionsTaken?: number, checkpointStep?: number}} [patch] both
|
|
68
|
+
* ride along on the heartbeat rather than costing a second write.
|
|
69
|
+
* camelCase, because that is the shape cli-core's `api.runs.heartbeat`
|
|
70
|
+
* takes (it translates to the server's snake_case body itself).
|
|
71
|
+
*/
|
|
72
|
+
async function beat(patch = {}) {
|
|
73
|
+
progress = { ...progress, ...patch };
|
|
74
|
+
// A beat that overlaps the previous one is dropped rather than queued:
|
|
75
|
+
// the next tick is five seconds away and a stale beat proves nothing.
|
|
76
|
+
if (inFlight) return null;
|
|
77
|
+
inFlight = true;
|
|
78
|
+
try {
|
|
79
|
+
const answer = await api.runs.heartbeat(gameId, jobId, runId, { heartbeat: true, ...progress });
|
|
80
|
+
failures = 0;
|
|
81
|
+
if (onBeat) await onBeat({ ...progress });
|
|
82
|
+
if (answer && answer.run && answer.run.usage && onUsage) onUsage(answer.run.usage);
|
|
83
|
+
if (answer && answer.cancel_requested && !canceled) {
|
|
84
|
+
canceled = true;
|
|
85
|
+
if (onCancel) onCancel();
|
|
86
|
+
}
|
|
87
|
+
return answer;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
failures += 1;
|
|
90
|
+
if (onError) onError(error);
|
|
91
|
+
return null;
|
|
92
|
+
} finally {
|
|
93
|
+
inFlight = false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
beat,
|
|
99
|
+
|
|
100
|
+
get canceled() {
|
|
101
|
+
return canceled;
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
get failures() {
|
|
105
|
+
return failures;
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
/** The last progress reported, for a caller that needs to journal it. */
|
|
109
|
+
get progress() {
|
|
110
|
+
return { ...progress };
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
start() {
|
|
114
|
+
if (timer) return this;
|
|
115
|
+
timer = setIntervalImpl(() => {
|
|
116
|
+
beat().catch(() => {});
|
|
117
|
+
}, intervalMs);
|
|
118
|
+
// Never hold the process open for a heartbeat.
|
|
119
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
120
|
+
return this;
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
stop() {
|
|
124
|
+
if (timer) clearIntervalImpl(timer);
|
|
125
|
+
timer = null;
|
|
126
|
+
return this;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default createHeartbeat;
|
package/src/run/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runner's entry point, and the only module cli-core's CLI imports from
|
|
3
|
+
* this directory.
|
|
4
|
+
*
|
|
5
|
+
* It lazy-imports exactly three names, so those three are the contract:
|
|
6
|
+
*
|
|
7
|
+
* | Name | Called with | Answers |
|
|
8
|
+
* |---|---|---|
|
|
9
|
+
* | `runCommand(flags)` | commander's flags plus `flags.argv` (the run-specific options, which `src/run/args.js` parses) | an exit code |
|
|
10
|
+
* | `profileCommand(flags)` | the same | an exit code |
|
|
11
|
+
* | `resumeJob({ api, gameId, jobId, job, runs, state, repoRoot, flags })` | cli-core's `resume` hand-off | an exit code |
|
|
12
|
+
*
|
|
13
|
+
* Everything else exported here is for tests and for cli-godot's driver: the
|
|
14
|
+
* driver interface, the model client, and the pieces `resume` shares with
|
|
15
|
+
* `run`. Nothing in cli-core should reach past the three names above.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export { runCommand, playJob, playPersonaRun } from '../commands/run.js';
|
|
19
|
+
export { profileCommand } from '../commands/profile.js';
|
|
20
|
+
export { resumeJob } from './resume.js';
|
|
21
|
+
|
|
22
|
+
export { EXIT } from './exit.js';
|
|
23
|
+
export { assertDriver, DriverError, observationHash, screenshotName } from './drivers/driver.js';
|
|
24
|
+
export { createDriver, DRIVERS } from './drivers/index.js';
|
|
25
|
+
export { createModelClient, callStep, callStepWithRetry, ModelUnavailableError, ProxyError, STEPS } from './model.js';
|
|
26
|
+
export { getDeps, setDeps, resetDeps } from './deps.js';
|
|
27
|
+
export { runPersona } from './personaLoop.js';
|
|
28
|
+
export { runGameProfile } from './profile.js';
|
|
29
|
+
export { buildAggregate } from './aggregate.js';
|
|
30
|
+
export { scanForSecrets, assertNoSecrets } from './secretScan.js';
|
|
31
|
+
export { parseRunArgv, optionsFrom } from './args.js';
|
package/src/run/json.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulling JSON out of a model answer.
|
|
3
|
+
*
|
|
4
|
+
* Two of the four steps this CLI uses have no `write_file` tool in their
|
|
5
|
+
* routing allowlist (`game_profile.synthesis` and `aggregate.synthesis`), so
|
|
6
|
+
* their output arrives as text and may or may not be fenced. Asking for "JSON
|
|
7
|
+
* only" and then failing on a stray sentence would spend a repair turn on
|
|
8
|
+
* punctuation, so the fence and any surrounding prose are tolerated; anything
|
|
9
|
+
* that is still not JSON is a real failure and is reported as one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} text
|
|
14
|
+
* @returns {string} the most likely JSON body
|
|
15
|
+
*/
|
|
16
|
+
function unfence(text) {
|
|
17
|
+
const value = String(text || '');
|
|
18
|
+
const fenced = value.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
19
|
+
return fenced ? fenced[1] : value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} text
|
|
24
|
+
* @returns {Object|null} null when the answer holds no JSON object
|
|
25
|
+
*/
|
|
26
|
+
export function parseJsonObject(text) {
|
|
27
|
+
const body = unfence(text);
|
|
28
|
+
const start = body.indexOf('{');
|
|
29
|
+
const end = body.lastIndexOf('}');
|
|
30
|
+
if (start === -1 || end <= start) return null;
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(body.slice(start, end + 1));
|
|
33
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} text
|
|
41
|
+
* @returns {Array<Object>} empty when the answer holds no JSON array
|
|
42
|
+
*/
|
|
43
|
+
export function parseJsonArray(text) {
|
|
44
|
+
const body = unfence(text);
|
|
45
|
+
const start = body.indexOf('[');
|
|
46
|
+
const end = body.lastIndexOf(']');
|
|
47
|
+
if (start === -1 || end <= start) return [];
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(body.slice(start, end + 1));
|
|
50
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
51
|
+
} catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default { parseJsonObject, parseJsonArray };
|