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.
@@ -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
+ }
@@ -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: runDir, size: viewport } } : {})
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
- /** @returns {Promise<{videoPath: string|null}>} */
1602
- async stop() {
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;
@@ -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 (controller === 'jev') system.push({ type: 'text', text: JEV_CONTROLLER_PROMPT });
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 = controller === 'jev' ? availableTools.map(tool => ({
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 (controller === 'jev') {
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
- if (!replayed.replayed) {
158
- // A register that was NOT a replay means the key did not match a stored
159
- // job, which means money just moved. Say so rather than letting it look
160
- // like a free resume.
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',
@@ -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, body);
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, body);
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
+ }