ravensight-playtest 0.4.0 → 0.5.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 +60 -4
- package/docs/SIMULATION.md +1 -1
- package/docs/godot-driver.md +310 -0
- package/package.json +2 -1
- package/src/api/client.js +2 -2
- package/src/cli.js +3 -1
- package/src/commands/run.js +42 -13
- package/src/run/actionSelector.js +3 -2
- package/src/run/aggregate.js +4 -1
- package/src/run/args.js +2 -0
- package/src/run/comparison.js +32 -0
- package/src/run/controllers.js +17 -0
- package/src/run/drivers/driver.js +5 -5
- package/src/run/drivers/godot.js +131 -39
- package/src/run/drivers/recording.js +148 -0
- package/src/run/drivers/web.js +128 -66
- package/src/run/personaLoop.js +17 -10
- package/src/run/resume.js +16 -5
- package/src/upload/index.js +4 -2
- package/src/upload/recording.js +56 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Local recording only. This module never uploads, reads credentials, or captures
|
|
2
|
+
// a display/audio device. Final files use the existing opt-in upload allowlist.
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { detectFfmpeg } from '../../detect.js';
|
|
8
|
+
import { MAX_VIDEO_BYTES } from '../../upload/allowlist.js';
|
|
9
|
+
|
|
10
|
+
const execute = promisify(execFile);
|
|
11
|
+
export const RECORDING_LIMITS = Object.freeze({
|
|
12
|
+
durationMs: 15 * 60 * 1000,
|
|
13
|
+
videoBytes: MAX_VIDEO_BYTES,
|
|
14
|
+
frameBytes: 256 * 1024 * 1024,
|
|
15
|
+
singleFrameBytes: 4 * 1024 * 1024,
|
|
16
|
+
width: 1280,
|
|
17
|
+
height: 720,
|
|
18
|
+
fps: 10,
|
|
19
|
+
finalizeMs: 60000,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Test seams may lower limits, never raise production ceilings.
|
|
23
|
+
export function recordingLimits(overrides = {}) {
|
|
24
|
+
const out = { ...RECORDING_LIMITS };
|
|
25
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
26
|
+
if (!(key in out) || !Number.isSafeInteger(value) || value < 1 || value > out[key]) {
|
|
27
|
+
throw new Error(`invalid recording limit: ${key}`);
|
|
28
|
+
}
|
|
29
|
+
out[key] = value;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function recordingViewport(viewport, limits = RECORDING_LIMITS) {
|
|
35
|
+
const width = Number(viewport?.width ?? viewport?.w ?? limits.width);
|
|
36
|
+
const height = Number(viewport?.height ?? viewport?.h ?? limits.height);
|
|
37
|
+
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1) {
|
|
38
|
+
throw new Error('recording viewport must have positive integer dimensions');
|
|
39
|
+
}
|
|
40
|
+
const scale = Math.min(1, limits.width / width, limits.height / height);
|
|
41
|
+
return { width: Math.max(1, Math.floor(width * scale)), height: Math.max(1, Math.floor(height * scale)) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function recordingDirectory(runDir) {
|
|
45
|
+
await fs.mkdir(runDir, { recursive: true });
|
|
46
|
+
// Reusing an old playable recording after a failed capture is worse than
|
|
47
|
+
// reporting no recording. Do not silently replace it either.
|
|
48
|
+
for (const name of ['session.webm', 'session.mp4', 'recording.json']) {
|
|
49
|
+
if (await fs.lstat(path.join(runDir, name)).then(() => true, e => { if (e.code === 'ENOENT') return false; throw e; })) {
|
|
50
|
+
throw new Error(`recording output already exists: ${name}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const directory = await fs.mkdtemp(path.join(runDir, '.recording-'));
|
|
54
|
+
await fs.chmod(directory, 0o700);
|
|
55
|
+
return directory;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function recordingMetadata({ driver, startedAt, endedAt, reason = 'stopped', ...details }) {
|
|
59
|
+
return {
|
|
60
|
+
schemaVersion: 1, driver, status: 'failed', audio: false,
|
|
61
|
+
startedAt: new Date(startedAt).toISOString(), endedAt: new Date(endedAt).toISOString(),
|
|
62
|
+
durationMs: Math.max(0, endedAt - startedAt), durationSource: 'capture-clock',
|
|
63
|
+
relativePath: null, mimeType: null, bytes: 0, reason, stopReason: reason, ...details,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function saveRecording(runDir, metadata) {
|
|
68
|
+
const pending = path.join(runDir, '.recording.json.tmp');
|
|
69
|
+
await fs.writeFile(pending, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
70
|
+
await fs.rename(pending, path.join(runDir, 'recording.json'));
|
|
71
|
+
return metadata;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function videoInfo(file, { limits = RECORDING_LIMITS } = {}) {
|
|
75
|
+
const stat = await fs.lstat(file);
|
|
76
|
+
if (!stat.isFile() || stat.size < 16 || stat.size > limits.videoBytes) throw new Error('invalid_video_size');
|
|
77
|
+
const handle = await fs.open(file, 'r');
|
|
78
|
+
const head = Buffer.alloc(16);
|
|
79
|
+
try { await handle.read(head, 0, head.length, 0); } finally { await handle.close(); }
|
|
80
|
+
const webm = head.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]));
|
|
81
|
+
const mp4 = head.toString('ascii', 4, 8) === 'ftyp';
|
|
82
|
+
if (!webm && !mp4) throw new Error('invalid_video_container');
|
|
83
|
+
return { bytes: stat.size, mimeType: webm ? 'video/webm' : 'video/mp4' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ffprobe is optional for Playwright, whose successful close finalizes WebM.
|
|
87
|
+
// When installed, verify the actual media timeline and absence of audio. A
|
|
88
|
+
// present tool rejecting the media is a failure, never a fallback success.
|
|
89
|
+
export async function probeVideo(file, { limits = RECORDING_LIMITS, run = execute } = {}) {
|
|
90
|
+
const info = await videoInfo(file, { limits });
|
|
91
|
+
const binary = process.env.RAVENSIGHT_FFPROBE || (process.env.RAVENSIGHT_FFMPEG
|
|
92
|
+
? path.join(path.dirname(process.env.RAVENSIGHT_FFMPEG), process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe') : 'ffprobe');
|
|
93
|
+
let stdout;
|
|
94
|
+
try {
|
|
95
|
+
({ stdout } = await run(binary, ['-v', 'error', '-show_entries', 'stream=codec_type,width,height:format=duration', '-of', 'json', file],
|
|
96
|
+
{ timeout: 10000, killSignal: 'SIGKILL', maxBuffer: 64 * 1024, windowsHide: true }));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error.code === 'ENOENT') return info;
|
|
99
|
+
throw new Error('invalid_video_media');
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const media = JSON.parse(stdout);
|
|
103
|
+
const video = media.streams.filter(stream => stream.codec_type === 'video');
|
|
104
|
+
const durationMs = Math.round(Number(media.format.duration) * 1000);
|
|
105
|
+
if (video.length !== 1 || media.streams.some(stream => stream.codec_type === 'audio')
|
|
106
|
+
|| !Number.isSafeInteger(durationMs) || durationMs <= 0 || durationMs > limits.durationMs
|
|
107
|
+
|| video[0].width > limits.width || video[0].height > limits.height) throw new Error();
|
|
108
|
+
return { ...info, durationMs, durationSource: 'media', width: video[0].width, height: video[0].height };
|
|
109
|
+
} catch { throw new Error('invalid_video_media'); }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Encode only numbered PNGs we wrote, with their actual capture timestamps. */
|
|
113
|
+
export async function encodeFrames({ directory, frames, endedAt, limits = RECORDING_LIMITS, encoder, run = execute, inspect = probeVideo }) {
|
|
114
|
+
if (!frames.length) throw new Error('no_frames');
|
|
115
|
+
const detected = encoder ?? await detectFfmpeg();
|
|
116
|
+
if (!detected.path) throw new Error('encoder_unavailable');
|
|
117
|
+
const first = frames[0].at;
|
|
118
|
+
const durationMs = Math.min(limits.durationMs, Math.max(1000 / limits.fps, endedAt - first));
|
|
119
|
+
const lines = ['ffconcat version 1.0'];
|
|
120
|
+
for (let i = 0; i < frames.length; i++) {
|
|
121
|
+
const frame = frames[i];
|
|
122
|
+
if (!/^\d{6}\.png$/.test(frame.name) || !Number.isFinite(frame.at) || (i && frame.at < frames[i - 1].at)) {
|
|
123
|
+
throw new Error('invalid_frame_timeline');
|
|
124
|
+
}
|
|
125
|
+
const end = Math.min(first + durationMs, frames[i + 1]?.at ?? first + durationMs);
|
|
126
|
+
lines.push(`file '${frame.name}'`, `duration ${Math.max(0.001, (end - frame.at) / 1000).toFixed(6)}`);
|
|
127
|
+
}
|
|
128
|
+
lines.push(`file '${frames.at(-1).name}'`);
|
|
129
|
+
const list = path.join(directory, 'frames.ffconcat');
|
|
130
|
+
await fs.writeFile(list, `${lines.join('\n')}\n`, { mode: 0o600, flag: 'wx' });
|
|
131
|
+
const output = path.join(directory, 'encoded.mp4');
|
|
132
|
+
try {
|
|
133
|
+
await run(detected.path, [
|
|
134
|
+
'-nostdin', '-hide_banner', '-loglevel', 'error', '-n',
|
|
135
|
+
'-protocol_whitelist', 'file', '-f', 'concat', '-safe', '1', '-i', list,
|
|
136
|
+
'-an', '-vf', `scale=${limits.width}:${limits.height}:force_original_aspect_ratio=decrease,pad=ceil(iw/2)*2:ceil(ih/2)*2,setsar=1`,
|
|
137
|
+
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-pix_fmt', 'yuv420p',
|
|
138
|
+
'-r', '30', '-t', (durationMs / 1000).toFixed(3), '-fs', String(limits.videoBytes - 4096),
|
|
139
|
+
'-movflags', '+faststart', output,
|
|
140
|
+
], { cwd: directory, timeout: limits.finalizeMs, killSignal: 'SIGKILL', maxBuffer: 64 * 1024, windowsHide: true });
|
|
141
|
+
const info = await inspect(output, { limits });
|
|
142
|
+
return { file: output, durationMs, ...info, truncated: info.durationMs != null && info.durationMs + 150 < durationMs };
|
|
143
|
+
} catch (error) {
|
|
144
|
+
await fs.rm(output, { force: true });
|
|
145
|
+
// Never include process output, a game path, or inherited environment in a report.
|
|
146
|
+
throw new Error(error.killed ? 'encoding_timeout' : 'encoding_failed');
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/run/drivers/web.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { mkdir, rename, stat, readdir } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, rename, stat, readdir, rm, chmod } from 'node:fs/promises';
|
|
2
|
+
import { recordingLimits, recordingViewport, recordingDirectory, recordingMetadata, saveRecording, probeVideo } from './recording.js';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import {
|
|
4
5
|
DriverError,
|
|
@@ -1121,7 +1122,7 @@ export const CHECK_PLAYTEST_IDENTITY = Object.freeze({ token: 'check', runId: nu
|
|
|
1121
1122
|
* the one the platform implies
|
|
1122
1123
|
* @returns {Object} a driver
|
|
1123
1124
|
*/
|
|
1124
|
-
export function createWebDriver({ importer, headless = true, playtest = null, platform = process.platform, modifier = null } = {}) {
|
|
1125
|
+
export function createWebDriver({ importer, headless = true, playtest = null, platform = process.platform, modifier = null, recording = {}, now = () => Date.now(), inspectVideo = probeVideo } = {}) {
|
|
1125
1126
|
const editModifier = modifier === 'Meta' || modifier === 'Control' ? modifier : platformModifier(platform);
|
|
1126
1127
|
let browser = null;
|
|
1127
1128
|
let context = null;
|
|
@@ -1129,6 +1130,16 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1129
1130
|
let target = null;
|
|
1130
1131
|
let runDirectory = null;
|
|
1131
1132
|
let videoRequested = false;
|
|
1133
|
+
const limits = recordingLimits(recording);
|
|
1134
|
+
let captureDirectory = null;
|
|
1135
|
+
let captureStartedAt = null;
|
|
1136
|
+
let stopPromise = null;
|
|
1137
|
+
let captureTimer = null;
|
|
1138
|
+
let captureDeadline = null;
|
|
1139
|
+
let runSignal = null;
|
|
1140
|
+
let onAbort = null;
|
|
1141
|
+
let inspecting = false;
|
|
1142
|
+
let terminalReason = null;
|
|
1132
1143
|
let step = 0;
|
|
1133
1144
|
let shots = 0;
|
|
1134
1145
|
let lastHash = null;
|
|
@@ -1344,6 +1355,77 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1344
1355
|
return observationHash(after);
|
|
1345
1356
|
}
|
|
1346
1357
|
|
|
1358
|
+
function stop({ reason = 'stopped' } = {}) {
|
|
1359
|
+
if (stopPromise) return stopPromise;
|
|
1360
|
+
terminalReason ??= reason;
|
|
1361
|
+
stopPromise = Promise.resolve().then(finalize);
|
|
1362
|
+
return stopPromise;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
async function finalize() {
|
|
1366
|
+
clearInterval(captureTimer);
|
|
1367
|
+
clearTimeout(captureDeadline);
|
|
1368
|
+
runSignal?.removeEventListener('abort', onAbort);
|
|
1369
|
+
const endedAt = now();
|
|
1370
|
+
let videoPath = null;
|
|
1371
|
+
let promotedPath = null;
|
|
1372
|
+
let finalized = true;
|
|
1373
|
+
// Only the explicitly selected page's handle may become session.webm.
|
|
1374
|
+
const video = videoRequested && page && typeof page.video === 'function' ? page.video() : null;
|
|
1375
|
+
try { if (context) await withTimeout(context.close(), 15000); } catch { finalized = false; }
|
|
1376
|
+
let metadata = null;
|
|
1377
|
+
if (videoRequested && captureStartedAt != null) {
|
|
1378
|
+
metadata = recordingMetadata({ driver: 'playwright_web', startedAt: captureStartedAt, endedAt,
|
|
1379
|
+
reason: terminalReason, timeOrigin: 'page-creation-request' });
|
|
1380
|
+
if (video) {
|
|
1381
|
+
try {
|
|
1382
|
+
let source;
|
|
1383
|
+
if (typeof video.saveAs === 'function') {
|
|
1384
|
+
source = path.join(captureDirectory, 'selected-final.webm');
|
|
1385
|
+
await withTimeout(video.saveAs(source), 15000);
|
|
1386
|
+
} else source = await withTimeout(video.path(), 15000);
|
|
1387
|
+
// No arbitrary run-directory scan or old-file fallback on a crash.
|
|
1388
|
+
if (path.dirname(path.resolve(source)) !== path.resolve(captureDirectory)) throw new Error('foreign_video_path');
|
|
1389
|
+
const info = await inspectVideo(source, { limits });
|
|
1390
|
+
if (info.mimeType !== 'video/webm') throw new Error('invalid_video_container');
|
|
1391
|
+
const destination = path.join(runDirectory, 'session.webm');
|
|
1392
|
+
await rename(source, destination);
|
|
1393
|
+
promotedPath = destination;
|
|
1394
|
+
await chmod(destination, 0o600);
|
|
1395
|
+
videoPath = destination;
|
|
1396
|
+
metadata = { ...metadata, ...info, relativePath: 'session.webm',
|
|
1397
|
+
status: finalized && ['stopped', 'completed'].includes(terminalReason) ? 'complete' : 'partial' };
|
|
1398
|
+
} catch (error) {
|
|
1399
|
+
if (promotedPath) await rm(promotedPath, { force: true });
|
|
1400
|
+
videoPath = null;
|
|
1401
|
+
metadata.reason = 'video_finalization_failed';
|
|
1402
|
+
metadata.failure = ['invalid_video_size', 'invalid_video_container', 'invalid_video_media', 'foreign_video_path'].includes(error.message) ? error.message : 'recorder_unavailable';
|
|
1403
|
+
}
|
|
1404
|
+
} else metadata.reason = 'video_unavailable';
|
|
1405
|
+
}
|
|
1406
|
+
try { if (browser) await withTimeout(browser.close(), 15000); } catch { /* already closed or unavailable */ }
|
|
1407
|
+
if (captureDirectory) await rm(captureDirectory, { recursive: true, force: true });
|
|
1408
|
+
if (metadata) await saveRecording(runDirectory, metadata);
|
|
1409
|
+
page = null;
|
|
1410
|
+
context = null;
|
|
1411
|
+
browser = null;
|
|
1412
|
+
return { videoPath, ...(metadata ? { recording: metadata } : {}) };
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
async function inspectCapture() {
|
|
1416
|
+
if (inspecting || stopPromise || !captureDirectory) return;
|
|
1417
|
+
inspecting = true;
|
|
1418
|
+
try {
|
|
1419
|
+
let bytes = 0;
|
|
1420
|
+
for (const name of await readdir(captureDirectory)) {
|
|
1421
|
+
bytes += (await stat(path.join(captureDirectory, name))).size;
|
|
1422
|
+
}
|
|
1423
|
+
// Leave room for WebM's final index; oversize output is never published.
|
|
1424
|
+
if (bytes >= Math.max(1, limits.videoBytes - 8 * 1024 * 1024)) await stop({ reason: 'size_limit' });
|
|
1425
|
+
} catch { await stop({ reason: 'capture_failed' }); }
|
|
1426
|
+
finally { inspecting = false; }
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1347
1429
|
return {
|
|
1348
1430
|
name: 'playwright_web',
|
|
1349
1431
|
supportsRefs: true,
|
|
@@ -1357,20 +1439,26 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1357
1439
|
* @param {boolean} [opts.recordVideo]
|
|
1358
1440
|
* @param {{width: number, height: number}} [opts.viewport]
|
|
1359
1441
|
*/
|
|
1360
|
-
async launch({ target: url, runDir, recordVideo = false, viewport = DEFAULT_VIEWPORT }) {
|
|
1442
|
+
async launch({ target: url, runDir, recordVideo = false, viewport = DEFAULT_VIEWPORT, signal }) {
|
|
1361
1443
|
if (!url) throw new DriverError('playwright_web needs a target URL', { fatal: true });
|
|
1444
|
+
if (signal?.aborted) throw new DriverError('run canceled before browser launch');
|
|
1362
1445
|
target = url;
|
|
1363
1446
|
viewportSize = viewport || DEFAULT_VIEWPORT;
|
|
1364
1447
|
runDirectory = runDir;
|
|
1365
1448
|
videoRequested = Boolean(recordVideo);
|
|
1449
|
+
if (videoRequested) {
|
|
1450
|
+
viewport = recordingViewport(viewport, limits);
|
|
1451
|
+
viewportSize = viewport;
|
|
1452
|
+
captureDirectory = await recordingDirectory(runDir);
|
|
1453
|
+
}
|
|
1366
1454
|
|
|
1455
|
+
try {
|
|
1367
1456
|
const playwright = await loadPlaywright(importer);
|
|
1368
1457
|
await mkdir(runDir, { recursive: true });
|
|
1369
|
-
|
|
1370
|
-
browser = await playwright.chromium.launch({ headless });
|
|
1458
|
+
browser = await playwright.chromium.launch({ headless, ...(videoRequested ? { args: ['--mute-audio'] } : {}) });
|
|
1371
1459
|
context = await browser.newContext({
|
|
1372
1460
|
viewport,
|
|
1373
|
-
...(videoRequested ? { recordVideo: { dir:
|
|
1461
|
+
...(videoRequested ? { recordVideo: { dir: captureDirectory, size: viewport }, permissions: [] } : {})
|
|
1374
1462
|
});
|
|
1375
1463
|
// The playtest identity goes in as an init script, not as
|
|
1376
1464
|
// `extraHTTPHeaders`: a context header rides on every request the page
|
|
@@ -1387,7 +1475,34 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1387
1475
|
// One token per document: set again by every new document, kept by
|
|
1388
1476
|
// pushState and hash route changes, which do not run init scripts.
|
|
1389
1477
|
if (typeof context.addInitScript === 'function') await context.addInitScript(markDocument);
|
|
1478
|
+
if (videoRequested && typeof context.route === 'function') {
|
|
1479
|
+
const origin = new URL(url).origin;
|
|
1480
|
+
await context.route('**/*', route => {
|
|
1481
|
+
const request = route.request();
|
|
1482
|
+
if (request.isNavigationRequest() && !request.frame().parentFrame() && new URL(request.url()).origin !== origin) {
|
|
1483
|
+
return route.abort('blockedbyclient');
|
|
1484
|
+
}
|
|
1485
|
+
return route.continue();
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
captureStartedAt = videoRequested ? now() : null;
|
|
1390
1489
|
page = await context.newPage();
|
|
1490
|
+
if (videoRequested) {
|
|
1491
|
+
// Context video may briefly allocate a popup file; close it immediately
|
|
1492
|
+
// and discard all non-selected files during finalization.
|
|
1493
|
+
page.on('popup', popup => { void popup.close().catch(() => {}); });
|
|
1494
|
+
page.on('crash', () => { void stop({ reason: 'game_crashed' }).catch(() => {}); });
|
|
1495
|
+
page.on('close', () => { if (!stopPromise) void stop({ reason: 'page_closed' }).catch(() => {}); });
|
|
1496
|
+
captureTimer = setInterval(() => { void inspectCapture(); }, 250);
|
|
1497
|
+
captureTimer.unref?.();
|
|
1498
|
+
captureDeadline = setTimeout(() => { void stop({ reason: 'duration_limit' }).catch(() => {}); }, limits.durationMs);
|
|
1499
|
+
captureDeadline.unref?.();
|
|
1500
|
+
}
|
|
1501
|
+
runSignal = signal;
|
|
1502
|
+
onAbort = () => { void stop({ reason: 'canceled' }).catch(() => {}); };
|
|
1503
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
1504
|
+
if (signal?.aborted) { await stop({ reason: 'canceled' }); throw new DriverError('run canceled during browser launch'); }
|
|
1505
|
+
|
|
1391
1506
|
page.on('console', message => {
|
|
1392
1507
|
if (message.type() !== 'error') return;
|
|
1393
1508
|
let location = null;
|
|
@@ -1426,6 +1541,11 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1426
1541
|
if (response && response.status() >= 400) {
|
|
1427
1542
|
throw new DriverError(`${url} answered ${response.status()}`, { fatal: true });
|
|
1428
1543
|
}
|
|
1544
|
+
} catch (error) {
|
|
1545
|
+
await stop({ reason: 'launch_failed' }).catch(() => {});
|
|
1546
|
+
if (captureDirectory) await rm(captureDirectory, { recursive: true, force: true }).catch(() => {});
|
|
1547
|
+
throw error;
|
|
1548
|
+
}
|
|
1429
1549
|
},
|
|
1430
1550
|
|
|
1431
1551
|
/** @returns {Promise<import('./driver.js').Observation>} */
|
|
@@ -1598,33 +1718,8 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
1598
1718
|
return attachNetworkFailures(consoleBuffer.splice(0, consoleBuffer.length), networkFailures);
|
|
1599
1719
|
},
|
|
1600
1720
|
|
|
1601
|
-
/**
|
|
1602
|
-
|
|
1603
|
-
let videoPath = null;
|
|
1604
|
-
const video = videoRequested && page && typeof page.video === 'function' ? page.video() : null;
|
|
1605
|
-
try {
|
|
1606
|
-
if (context) await context.close();
|
|
1607
|
-
} catch {
|
|
1608
|
-
// A context that is already gone is not an error worth failing the
|
|
1609
|
-
// run over; the report is already written by the time we get here.
|
|
1610
|
-
}
|
|
1611
|
-
if (video) {
|
|
1612
|
-
try {
|
|
1613
|
-
videoPath = await renameVideo(await video.path(), runDirectory);
|
|
1614
|
-
} catch {
|
|
1615
|
-
videoPath = await findVideo(runDirectory);
|
|
1616
|
-
}
|
|
1617
|
-
}
|
|
1618
|
-
try {
|
|
1619
|
-
if (browser) await browser.close();
|
|
1620
|
-
} catch {
|
|
1621
|
-
// Same: a closed browser is the desired end state either way.
|
|
1622
|
-
}
|
|
1623
|
-
page = null;
|
|
1624
|
-
context = null;
|
|
1625
|
-
browser = null;
|
|
1626
|
-
return { videoPath };
|
|
1627
|
-
}
|
|
1721
|
+
/** Idempotent: cancellation, crashes and finally share one finalization. */
|
|
1722
|
+
stop
|
|
1628
1723
|
};
|
|
1629
1724
|
}
|
|
1630
1725
|
|
|
@@ -1694,37 +1789,4 @@ export function attachNetworkFailures(entries, failures) {
|
|
|
1694
1789
|
});
|
|
1695
1790
|
}
|
|
1696
1791
|
|
|
1697
|
-
/**
|
|
1698
|
-
* Playwright names its recording with a random id. The upload allowlist and
|
|
1699
|
-
* the server's 90 day lifecycle rule are written against `session.webm`, so
|
|
1700
|
-
* the file is renamed rather than uploaded under whatever name it got.
|
|
1701
|
-
*
|
|
1702
|
-
* @param {string} source
|
|
1703
|
-
* @param {string} runDir
|
|
1704
|
-
* @returns {Promise<string>}
|
|
1705
|
-
*/
|
|
1706
|
-
async function renameVideo(source, runDir) {
|
|
1707
|
-
const target = path.join(runDir, 'session.webm');
|
|
1708
|
-
if (!source || path.resolve(source) === path.resolve(target)) return target;
|
|
1709
|
-
await rename(source, target);
|
|
1710
|
-
return target;
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
/**
|
|
1714
|
-
* @param {string} runDir
|
|
1715
|
-
* @returns {Promise<string|null>}
|
|
1716
|
-
*/
|
|
1717
|
-
async function findVideo(runDir) {
|
|
1718
|
-
try {
|
|
1719
|
-
const entries = await readdir(runDir);
|
|
1720
|
-
const webm = entries.find(entry => entry.endsWith('.webm'));
|
|
1721
|
-
if (!webm) return null;
|
|
1722
|
-
const source = path.join(runDir, webm);
|
|
1723
|
-
await stat(source);
|
|
1724
|
-
return await renameVideo(source, runDir);
|
|
1725
|
-
} catch {
|
|
1726
|
-
return null;
|
|
1727
|
-
}
|
|
1728
|
-
}
|
|
1729
|
-
|
|
1730
1792
|
export default createWebDriver;
|
package/src/run/personaLoop.js
CHANGED
|
@@ -211,7 +211,7 @@ export async function runPersona(args) {
|
|
|
211
211
|
// game", and a launch that hangs or dies should leave the server holding
|
|
212
212
|
// that rather than still holding `queued`.
|
|
213
213
|
await advance('launching');
|
|
214
|
-
await driver.launch({ target, runDir: runDirectory, recordVideo, viewport });
|
|
214
|
+
await driver.launch({ target, runDir: runDirectory, recordVideo, viewport, signal: args.signal });
|
|
215
215
|
await advance('playing');
|
|
216
216
|
|
|
217
217
|
const play = await playLoop({
|
|
@@ -231,8 +231,9 @@ export async function runPersona(args) {
|
|
|
231
231
|
outcome = { ...outcome, ...play };
|
|
232
232
|
|
|
233
233
|
const endedAt = new Date().toISOString();
|
|
234
|
-
await driverStop(driver, outcome, log);
|
|
234
|
+
await driverStop(driver, outcome, log, play.canceled ? 'canceled' : 'completed');
|
|
235
235
|
|
|
236
|
+
if (args.signal?.aborted || heartbeat?.canceled) play.canceled = true;
|
|
236
237
|
if (play.canceled) {
|
|
237
238
|
outcome.state = 'canceled';
|
|
238
239
|
outcome.quitReason = play.quitReason || 'error';
|
|
@@ -258,6 +259,7 @@ export async function runPersona(args) {
|
|
|
258
259
|
}
|
|
259
260
|
|
|
260
261
|
await advance('reporting');
|
|
262
|
+
if (args.signal?.aborted || heartbeat?.canceled) throw new Error('The playthrough was interrupted before reporting.');
|
|
261
263
|
onProgress({ phase: 'reporting', persona: persona.slug });
|
|
262
264
|
|
|
263
265
|
const written = await reportPhase({
|
|
@@ -293,7 +295,7 @@ export async function runPersona(args) {
|
|
|
293
295
|
outcome.quitDetail = written.report.quit_detail || '';
|
|
294
296
|
return finish(outcome, ledger, runDirectory, transcript, heartbeat);
|
|
295
297
|
} catch (error) {
|
|
296
|
-
await driverStop(driver, outcome, log);
|
|
298
|
+
await driverStop(driver, outcome, log, args.signal?.aborted ? 'canceled' : 'failed');
|
|
297
299
|
if (error instanceof ProxyError && error.isBudget) {
|
|
298
300
|
outcome.state = 'budget_exceeded';
|
|
299
301
|
outcome.quitReason = 'cost_cap';
|
|
@@ -327,6 +329,7 @@ export async function runPersona(args) {
|
|
|
327
329
|
async function playLoop({
|
|
328
330
|
api,
|
|
329
331
|
controller = 'standard',
|
|
332
|
+
signal: argsSignal,
|
|
330
333
|
client,
|
|
331
334
|
jobId,
|
|
332
335
|
runId,
|
|
@@ -355,11 +358,11 @@ async function playLoop({
|
|
|
355
358
|
const identity = runIdentity({ persona, runId });
|
|
356
359
|
const secrets = secretsOf(localBrief);
|
|
357
360
|
const noisePatterns = (localBrief && Array.isArray(localBrief.expected_noise)) ? localBrief.expected_noise : [];
|
|
358
|
-
if (
|
|
361
|
+
if (['jev', 'rune'].includes(controller)) system.push({ type: 'text', text: JEV_CONTROLLER_PROMPT });
|
|
359
362
|
const availableTools = driver.supportedActions
|
|
360
363
|
? [...PLAY_TOOLS, ...(driver.supportedActions.includes('swipe') ? [SWIPE_TOOL] : [])].filter(tool => !ACTION_TOOLS.includes(tool.name) || driver.supportedActions.includes(tool.name))
|
|
361
364
|
: PLAY_TOOLS;
|
|
362
|
-
const playTools =
|
|
365
|
+
const playTools = ['jev', 'rune'].includes(controller) ? availableTools.map(tool => ({
|
|
363
366
|
...tool,
|
|
364
367
|
input_schema: {
|
|
365
368
|
...tool.input_schema,
|
|
@@ -426,7 +429,7 @@ async function playLoop({
|
|
|
426
429
|
});
|
|
427
430
|
|
|
428
431
|
while (turns < maxTurns && actions < maxActions) {
|
|
429
|
-
if (heartbeat && heartbeat.canceled) {
|
|
432
|
+
if (argsSignal?.aborted || (heartbeat && heartbeat.canceled)) {
|
|
430
433
|
canceled = true;
|
|
431
434
|
quitReason = 'error';
|
|
432
435
|
quitDetail = 'the job was cancelled';
|
|
@@ -485,6 +488,7 @@ async function playLoop({
|
|
|
485
488
|
if (typeof onBudgetWarning === 'function') await onBudgetWarning();
|
|
486
489
|
}
|
|
487
490
|
|
|
491
|
+
if (argsSignal?.aborted || heartbeat?.canceled) { canceled = true; quitReason = 'error'; quitDetail = 'the job was cancelled'; break; }
|
|
488
492
|
if (answer.toolUses.length === 0) {
|
|
489
493
|
// No tool call is how a persona stops: there is no quit tool in the
|
|
490
494
|
// step's allowlist, and adding one would be refused by the proxy.
|
|
@@ -495,14 +499,15 @@ async function playLoop({
|
|
|
495
499
|
}
|
|
496
500
|
|
|
497
501
|
let selection = null;
|
|
498
|
-
if (
|
|
502
|
+
if (['jev', 'rune'].includes(controller)) {
|
|
499
503
|
try {
|
|
500
|
-
selection = await selectAction({ api, jobId, runId, persona, observation: `${visualObservation ? '' : latestObservation}\nScreen interpretation: ${answer.toolUses.map(tool => tool.input?.visible_state || '').filter(Boolean).join('\n') || answer.text}`, toolUses: answer.toolUses, recentActions, intentNotes });
|
|
504
|
+
selection = await selectAction({ api, jobId, runId, persona, controller, observation: `${visualObservation ? '' : latestObservation}\nScreen interpretation: ${answer.toolUses.map(tool => tool.input?.visible_state || '').filter(Boolean).join('\n') || answer.text}`, toolUses: answer.toolUses, recentActions, intentNotes });
|
|
501
505
|
} catch (error) {
|
|
502
506
|
if (error instanceof ProxyError && error.isBudget) { budgetExceeded = true; quitReason = 'cost_cap'; quitDetail = error.message; break; }
|
|
503
507
|
if (error instanceof ProxyError && error.isClosed) { canceled = true; quitDetail = error.message; break; }
|
|
504
508
|
throw error;
|
|
505
509
|
}
|
|
510
|
+
if (argsSignal?.aborted || heartbeat?.canceled) { canceled = true; quitReason = 'error'; quitDetail = 'the job was cancelled'; break; }
|
|
506
511
|
transcript.write({ kind: 'action_selection', source: selection.source || 'jev', status: selection.status, confidence: selection.confidence, selected_tool_id: selection.toolId, reason: selection.reason });
|
|
507
512
|
if (selection.status !== 'selected') { quitReason = 'persona_quit'; quitDetail = 'Ravensight Playtester stopped for review: ' + selection.reason; break; }
|
|
508
513
|
if (selection.budget === 'warn' && !budgetWarned) {
|
|
@@ -518,6 +523,7 @@ async function playLoop({
|
|
|
518
523
|
// its actions changes the screen, a later ref may name something else.
|
|
519
524
|
let screenChangedThisTurn = false;
|
|
520
525
|
for (const toolUse of answer.toolUses) {
|
|
526
|
+
if (argsSignal?.aborted || heartbeat?.canceled) { canceled = true; quitReason = 'error'; quitDetail = 'the job was cancelled'; break; }
|
|
521
527
|
if (selection && toolUse.id !== selection.toolId) { results.push({ type: 'tool_result', tool_use_id: toolUse.id, content: 'Alternative not selected. No action executed.' }); continue; }
|
|
522
528
|
const notesBefore = notes.length;
|
|
523
529
|
const executed = await executeTool({
|
|
@@ -1437,9 +1443,10 @@ async function safeScreenshot(driver, name, screenshots, transcript, observation
|
|
|
1437
1443
|
}
|
|
1438
1444
|
}
|
|
1439
1445
|
|
|
1440
|
-
async function driverStop(driver, outcome, log) {
|
|
1446
|
+
async function driverStop(driver, outcome, log, reason) {
|
|
1441
1447
|
try {
|
|
1442
|
-
const stopped = await driver.stop();
|
|
1448
|
+
const stopped = await driver.stop({ reason });
|
|
1449
|
+
if (stopped?.recording) outcome.recording = stopped.recording;
|
|
1443
1450
|
if (stopped && stopped.videoPath) outcome.videoPath = stopped.videoPath;
|
|
1444
1451
|
// The Godot driver captures frames rather than video, and stitching them
|
|
1445
1452
|
// needs ffmpeg, which is optional. No video is a normal outcome: say
|
package/src/run/resume.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { optionsFrom } from './args.js';
|
|
2
2
|
import { getDeps } from './deps.js';
|
|
3
3
|
import EXIT from './exit.js';
|
|
4
|
+
import { runsMatchPlan } from './controllers.js';
|
|
4
5
|
import { runDir as runDirFor } from './paths.js';
|
|
5
6
|
import { buildPartialReport, readTranscript, shouldFinalize } from './regenerate.js';
|
|
6
7
|
import { reconcileDroppedFindings } from './dropped.js';
|
|
@@ -57,6 +58,13 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
57
58
|
log(`There is no journal for ${jobId} in this repository, so there is nothing to resume from. "ravensight-playtest upload ${jobId}" can still drain any pending uploads.`);
|
|
58
59
|
return EXIT.ENVIRONMENT;
|
|
59
60
|
}
|
|
61
|
+
const controllers = job.controllers?.length ? job.controllers : null;
|
|
62
|
+
if (journal.job_id !== jobId || journal.game_id !== gameId ||
|
|
63
|
+
JSON.stringify(journal.options?.controllers || null) !== JSON.stringify(controllers) ||
|
|
64
|
+
!runsMatchPlan(runs || [], controllers, [...new Set((runs || []).filter(row => row.module === 'persona_playtest').map(row => row.persona))])) {
|
|
65
|
+
log('The saved job/controller identity does not match the server. Nothing was replayed or charged; reconcile the saved job first.');
|
|
66
|
+
return EXIT.ENVIRONMENT;
|
|
67
|
+
}
|
|
60
68
|
if (!journal.idempotency_key) {
|
|
61
69
|
log('That journal has no idempotency key, so the per-run tokens cannot be minted again safely. Start a new job instead.');
|
|
62
70
|
return EXIT.ENVIRONMENT;
|
|
@@ -142,7 +150,9 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
142
150
|
// nothing is charged. The tokens that come back are new ones.
|
|
143
151
|
const replayed = await api.jobs.register(gameId, {
|
|
144
152
|
modules,
|
|
153
|
+
resume_job_id: jobId,
|
|
145
154
|
personas: slugs,
|
|
155
|
+
...(journal.options?.controllers ? { controllers: journal.options.controllers } : {}),
|
|
146
156
|
driver: driverName,
|
|
147
157
|
cli_version: deps.cliVersion || api.cliVersion,
|
|
148
158
|
confirm_price_cents: Number.isInteger(journal.price_cents) ? journal.price_cents : 0,
|
|
@@ -154,11 +164,10 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
154
164
|
pack_versions: { pack: pack.pack_version }
|
|
155
165
|
}, { idempotencyKey: journal.idempotency_key });
|
|
156
166
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
log('That idempotency key did not match a stored job, so a new job was registered and charged.');
|
|
167
|
+
const identity = rows => JSON.stringify(rows.map(row => [row.run_id, row.module, row.persona || null, row.controller || null]).sort((a, b) => a[0].localeCompare(b[0])));
|
|
168
|
+
if (replayed.replayed !== true || replayed.job_id !== jobId || identity(replayed.runs || []) !== identity(runs || [])) {
|
|
169
|
+
log('The server did not replay the exact saved job and controller runs. No playthrough was started. Reconcile this response before retrying.');
|
|
170
|
+
return EXIT.FAILED;
|
|
162
171
|
}
|
|
163
172
|
|
|
164
173
|
log(`resuming ${jobId}: ${outstanding.length} run(s) to play again of ${(runs || []).length}.`);
|
|
@@ -166,6 +175,8 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
166
175
|
const resumeOptions = {
|
|
167
176
|
...options,
|
|
168
177
|
controller: journal.options?.controller || options.controller || 'standard',
|
|
178
|
+
controllers,
|
|
179
|
+
recordVideo: options.recordVideo ?? journal.options?.recordVideo,
|
|
169
180
|
maxActions: (journal.options && journal.options.maxActions) ?? options.maxActions,
|
|
170
181
|
// The journal's answer, never the flag: a job is long or standard for good.
|
|
171
182
|
runLength: journal.options?.runLength === 'long' ? 'long' : 'standard',
|
package/src/upload/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recordingCompletionBody } from './recording.js';
|
|
1
2
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
3
|
import { join, sep } from 'node:path';
|
|
3
4
|
import { ApiError, isRetryable } from '../api/errors.js';
|
|
@@ -459,10 +460,11 @@ export async function finalizeRun(runDir, options) {
|
|
|
459
460
|
const { api, gameId, jobId, runId, body = {} } = options;
|
|
460
461
|
if (!runId) throw new TypeError('finalizeRun needs a runId');
|
|
461
462
|
|
|
463
|
+
const completionBody = await recordingCompletionBody(runDir, body, options.includeVideo);
|
|
462
464
|
await uploadRunDir(runDir, options);
|
|
463
465
|
|
|
464
466
|
try {
|
|
465
|
-
return await api.runs.complete(gameId, jobId, runId,
|
|
467
|
+
return await api.runs.complete(gameId, jobId, runId, completionBody);
|
|
466
468
|
} catch (error) {
|
|
467
469
|
if (!(error instanceof ApiError) || error.status !== 422 || error.code !== 'upload_unverified') {
|
|
468
470
|
throw error;
|
|
@@ -470,6 +472,6 @@ export async function finalizeRun(runDir, options) {
|
|
|
470
472
|
const paths = unverifiedPaths(error);
|
|
471
473
|
if (paths.length === 0) throw error;
|
|
472
474
|
await uploadRunDir(runDir, { ...options, only: paths, force: true });
|
|
473
|
-
return api.runs.complete(gameId, jobId, runId,
|
|
475
|
+
return api.runs.complete(gameId, jobId, runId, completionBody);
|
|
474
476
|
}
|
|
475
477
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Deliberately independent of the full local driver receipt. Never send paths,
|
|
6
|
+
// timestamps, targets, driver errors or recording.json as an artifact.
|
|
7
|
+
const STATUSES = ['complete', 'partial', 'unsupported', 'failed'];
|
|
8
|
+
const STOP_REASONS = [
|
|
9
|
+
'stopped', 'completed', 'canceled', 'failed', 'game_exited', 'game_crashed',
|
|
10
|
+
'page_closed', 'duration_limit', 'size_limit', 'frame_size_limit',
|
|
11
|
+
'frame_bytes_limit', 'capture_failed', 'launch_failed', 'headless_no_viewport',
|
|
12
|
+
'run_directory_required', 'encoder_unavailable', 'no_frames',
|
|
13
|
+
'encoding_timeout', 'encoding_failed', 'video_finalization_failed',
|
|
14
|
+
'video_unavailable', 'unknown'
|
|
15
|
+
];
|
|
16
|
+
const MAX_METADATA_BYTES = 8192;
|
|
17
|
+
const MAX_DURATION_MS = 900000;
|
|
18
|
+
|
|
19
|
+
export function recordingForCompletion(metadata) {
|
|
20
|
+
if (!metadata || typeof metadata !== 'object' || !STATUSES.includes(metadata.status)) return null;
|
|
21
|
+
return {
|
|
22
|
+
status: metadata.status,
|
|
23
|
+
duration_ms: typeof metadata.durationMs === 'number' && Number.isFinite(metadata.durationMs)
|
|
24
|
+
&& metadata.durationMs >= 0 && metadata.durationMs <= MAX_DURATION_MS ? metadata.durationMs : null,
|
|
25
|
+
stop_reason: STOP_REASONS.includes(metadata.reason) ? metadata.reason : 'unknown'
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function readRecording(runDir) {
|
|
30
|
+
let file;
|
|
31
|
+
try {
|
|
32
|
+
file = await open(join(runDir, 'recording.json'), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
33
|
+
const info = await file.stat();
|
|
34
|
+
if (!info.isFile() || info.size > MAX_METADATA_BYTES) return null;
|
|
35
|
+
// One bounded read also protects against a file growing after stat().
|
|
36
|
+
const buffer = Buffer.alloc(MAX_METADATA_BYTES + 1);
|
|
37
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
38
|
+
if (bytesRead > MAX_METADATA_BYTES) return null;
|
|
39
|
+
return recordingForCompletion(JSON.parse(buffer.subarray(0, bytesRead).toString('utf8')));
|
|
40
|
+
} catch {
|
|
41
|
+
// Missing or malformed local status must not lose the run's existing report.
|
|
42
|
+
return null;
|
|
43
|
+
} finally {
|
|
44
|
+
await file?.close().catch(() => {});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function recordingCompletionBody(runDir, body, includeVideo) {
|
|
49
|
+
const { recording: supplied, include_video: ignored, ...result } = body;
|
|
50
|
+
if (includeVideo !== true) return result;
|
|
51
|
+
const recording = supplied ? recordingForCompletion({
|
|
52
|
+
status: supplied.status, durationMs: supplied.duration_ms, reason: supplied.stop_reason
|
|
53
|
+
}) : await readRecording(runDir);
|
|
54
|
+
if (recording) Object.assign(result, { include_video: true, recording });
|
|
55
|
+
return result;
|
|
56
|
+
}
|