ravensight-playtest 0.3.2 → 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.
@@ -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',
@@ -0,0 +1,176 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { boundedString, bridgeOptions, observation, openBridge } from './protocol.js';
5
+ const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
6
+ const MAX_TRACE_BYTES = 4 * 1024 * 1024;
7
+ const MAX_ACTIONS = 1000000;
8
+ const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
9
+ export function validateConfig(input) {
10
+ if (!input || input.version !== 1 || !boundedString(input.seed, 128)) throw new Error('Simulation config needs version 1 and a seed of 1 to 128 characters.');
11
+ for (const [key, max] of [['runs', 10000], ['max_steps', 10000], ['workers', 16]]) if (!Number.isInteger(input[key]) || input[key] < 1 || input[key] > max) throw new Error(`Invalid simulation ${key}.`);
12
+ if (!Array.isArray(input.strategies) || !input.strategies.length || input.strategies.length > 16) throw new Error('Choose 1 to 16 simulation strategies.');
13
+ const strategies = input.strategies.map(strategy => {
14
+ if (!strategy || typeof strategy.name !== 'string' || !/^[a-z][a-z0-9_-]{0,63}$/.test(strategy.name) || !['random', 'scripted', 'scored'].includes(strategy.policy)) throw new Error('Invalid simulation strategy.');
15
+ if (strategy.policy === 'scripted' && (!Array.isArray(strategy.actions) || !strategy.actions.length || strategy.actions.length > 10000 || strategy.actions.some(id => !boundedString(id, 128)))) throw new Error('Scripted strategy needs 1 to 10000 action IDs.');
16
+ return { name: strategy.name, policy: strategy.policy, ...(strategy.policy === 'scripted' ? { actions: [...strategy.actions] } : {}) };
17
+ });
18
+ if (new Set(strategies.map(s => s.name)).size !== strategies.length) throw new Error('Strategy names must be unique.');
19
+ const config = { version: 1, seed: input.seed, runs: input.runs, max_steps: input.max_steps, workers: input.workers, strategies };
20
+ if (Buffer.byteLength(JSON.stringify(config)) > 2 * 1024 * 1024) throw new Error('Simulation configuration exceeds 2 MiB.');
21
+ return config;
22
+ }
23
+ export function configHash(config) { return hash({ version: 1, seed: config.seed, runs: config.runs, max_steps: config.max_steps, workers: config.workers, strategies: config.strategies.map(({ name, policy }) => ({ name, policy })) }); }
24
+ export function deriveSeed(seed, index) { return hash([seed, index]); }
25
+ function choose(state, strategy, index, seed) {
26
+ if (strategy.policy === 'scripted') {
27
+ const id = strategy.actions[index];
28
+ if (!id) throw new Error('Scripted strategy exhausted before terminal state.');
29
+ if (!state.actions.some(action => action.id === id)) throw new Error('Scripted strategy selected an illegal action.');
30
+ return id;
31
+ }
32
+ if (strategy.policy === 'scored') {
33
+ const choices = state.actions.filter(action => Number.isFinite(action.scores?.[strategy.name]));
34
+ if (!choices.length) throw new Error('Game supplied no scores for this strategy.');
35
+ return choices.sort((a, b) => b.scores[strategy.name] - a.scores[strategy.name] || compareText(a.id, b.id))[0].id;
36
+ }
37
+ const choices = [...state.actions].sort((a, b) => compareText(a.id, b.id));
38
+ return choices[parseInt(hash([seed, index]).slice(0, 12), 16) % choices.length].id;
39
+ }
40
+ function stateRecord(state) { return { done: state.done, state_hash: state.state_hash, ...(state.outcome === undefined ? {} : { outcome: state.outcome }), ...(state.metrics === undefined ? {} : { metrics: state.metrics }) }; }
41
+ async function writeJson(file, value) { await fs.writeFile(file, JSON.stringify(value) + '\n', { flag: 'wx', mode: 0o600 }); }
42
+ export async function probeBridge({ bridge, seed = 'preflight', strategy = 'random', max_steps = 1, signal }) {
43
+ if (!boundedString(seed, 128) || !boundedString(strategy, 80) || !Number.isInteger(max_steps) || max_steps < 1 || max_steps > 10000) throw new Error('Invalid simulation probe.');
44
+ const client = openBridge(bridge, { signal });
45
+ try { return await client.request('simulation.start', { version: 1, seed, strategy, max_steps }); }
46
+ finally { await client.stop(); }
47
+ }
48
+ export async function runBatch({ config: rawConfig, bridge: rawBridge, outputDir, playtest, signal, onProgress }) {
49
+ const config = validateConfig(rawConfig); const bridge = bridgeOptions(rawBridge);
50
+ if (typeof outputDir !== 'string' || !outputDir) throw new Error('Simulation output directory is required.');
51
+ outputDir = path.resolve(outputDir); await fs.mkdir(outputDir, { recursive: true });
52
+ const resultsPath = path.join(outputDir, 'results.jsonl'); const file = await fs.open(resultsPath, 'wx', 0o600);
53
+ const started = Date.now(); const controller = new AbortController(); let stopReason = null;
54
+ const cancel = () => { stopReason ||= 'cancelled'; controller.abort(); };
55
+ signal?.addEventListener('abort', cancel, { once: true }); if (signal?.aborted) cancel();
56
+ const timer = setTimeout(() => { stopReason ||= 'batch_deadline'; controller.abort(); }, bridge.batchTimeoutMs);
57
+ let next = 0, reservedSteps = 0, writeChain = Promise.resolve(), commitTail = Promise.resolve();
58
+ const traces = [];
59
+ const summary = { version: 1, seed: config.seed, config_hash: configHash(config), requested_runs: config.runs, completed_runs: 0, failed_runs: 0, truncated_runs: 0, total_steps: 0, strategies: config.strategies.map(s => ({ name: s.name, runs: 0, completed: 0, failed: 0, truncated: 0, outcomes: {}, metrics: {} })), trace_count: 0, duration_ms: 0 };
60
+ async function execute(index, priorCommit) {
61
+ const strategy = config.strategies[index % config.strategies.length]; const seed = deriveSeed(config.seed, index);
62
+ const row = { version: 1, run_index: index, seed, strategy: strategy.name, status: 'failed', steps: 0 };
63
+ const trace = index < 10 ? { version: 1, run_index: index, seed, strategy: strategy.name, max_steps: config.max_steps, local_config_hash: hash(config), states: [], actions: [] } : null;
64
+ let traceBytes = 1024, client, pendingAction, phase = 'start';
65
+ const record = state => {
66
+ if (!trace) return;
67
+ const item = stateRecord(state); traceBytes += Buffer.byteLength(JSON.stringify(item)) + 1;
68
+ if (traceBytes > MAX_TRACE_BYTES) { trace.truncated = true; return; }
69
+ trace.states.push(item);
70
+ };
71
+ try {
72
+ if (controller.signal.aborted) throw new Error('Simulation stopped before run started.');
73
+ client = openBridge(bridge, { signal: controller.signal, playtest });
74
+ let state = await client.request('simulation.start', { version: 1, seed, strategy: strategy.name, max_steps: config.max_steps }); record(state);
75
+ while (!state.done && row.steps < config.max_steps) {
76
+ if (reservedSteps >= MAX_ACTIONS) { stopReason ||= 'action_budget'; controller.abort(); throw new Error('Batch action budget exhausted.'); }
77
+ phase = 'policy';
78
+ const action = choose(state, strategy, row.steps, seed); reservedSteps++;
79
+ pendingAction = action; phase = 'step';
80
+ state = await client.request('simulation.step', { action }); pendingAction = undefined; row.steps++; summary.total_steps++;
81
+ if (trace && !trace.truncated) { trace.actions.push(action); traceBytes += Buffer.byteLength(JSON.stringify(action)) + 1; }
82
+ record(state);
83
+ }
84
+ row.status = state.done ? 'completed' : 'truncated';
85
+ if (state.done) { row.outcome = state.outcome || 'unspecified'; row.metrics = state.metrics || {}; }
86
+ } catch (error) { row.error = error.message.slice(0, 200); if (trace) { trace.failure_phase = phase; if (pendingAction) trace.failed_action = pendingAction; } }
87
+ finally { if (client) await client.stop(); }
88
+ // Commit in run-index order, retaining at most one result per worker. This
89
+ // makes floating-point aggregate order independent of process scheduling.
90
+ await priorCommit;
91
+ const aggregate = summary.strategies[index % config.strategies.length]; aggregate.runs++;
92
+ summary[`${row.status}_runs`]++; aggregate[row.status]++;
93
+ if (row.status === 'completed') {
94
+ // Bound aggregate cardinality even if a malicious adapter invents a new
95
+ // metric/outcome name in every run. Raw per-run values remain on disk.
96
+ const outcome = Object.hasOwn(aggregate.outcomes, row.outcome) || Object.keys(aggregate.outcomes).length < 127 ? row.outcome : 'other';
97
+ aggregate.outcomes[outcome] = (Object.hasOwn(aggregate.outcomes, outcome) ? aggregate.outcomes[outcome] : 0) + 1;
98
+ for (const [key, value] of Object.entries(row.metrics)) {
99
+ if (!Object.hasOwn(aggregate.metrics, key) && Object.keys(aggregate.metrics).length >= 32) continue;
100
+ if (!Object.hasOwn(aggregate.metrics, key)) aggregate.metrics[key] = { count: 0, min: value, max: value, mean: 0 };
101
+ const metric = aggregate.metrics[key];
102
+ metric.count++; metric.min = Math.min(metric.min, value); metric.max = Math.max(metric.max, value);
103
+ // Weighted arithmetic avoids overflow from subtracting opposite extremes.
104
+ metric.mean = Math.max(metric.min, Math.min(metric.max, metric.mean * ((metric.count - 1) / metric.count) + value / metric.count));
105
+ }
106
+ }
107
+ if (trace) { if (trace.truncated) trace.actions = trace.actions.slice(0, Math.max(0, trace.states.length - 1)); trace.status = row.status; trace.error = row.error; const target = path.join(outputDir, `trace-${String(index).padStart(5, '0')}.json`); await writeJson(target, trace); traces.push(target); }
108
+ writeChain = writeChain.then(() => file.write(JSON.stringify(row) + '\n')); await writeChain;
109
+ if (onProgress) await onProgress({ finished_runs: summary.completed_runs + summary.failed_runs + summary.truncated_runs, requested_runs: config.runs, completed_runs: summary.completed_runs, failed_runs: summary.failed_runs, truncated_runs: summary.truncated_runs, total_steps: summary.total_steps });
110
+ }
111
+ try {
112
+ const workers = Array.from({ length: Math.min(config.workers, config.runs) }, async () => {
113
+ try {
114
+ while (next < config.runs) {
115
+ const index = next++; const prior = commitTail; let release;
116
+ commitTail = new Promise(resolve => { release = resolve; });
117
+ try { await execute(index, prior); } finally { release(); }
118
+ }
119
+ }
120
+ catch (error) { controller.abort(); throw error; }
121
+ });
122
+ const outcomes = await Promise.allSettled(workers);
123
+ const failure = outcomes.find(item => item.status === 'rejected');
124
+ if (failure) throw failure.reason;
125
+ }
126
+ finally { clearTimeout(timer); signal?.removeEventListener('abort', cancel); controller.abort(); try { await writeChain; } finally { await file.close(); } }
127
+ summary.trace_count = traces.length; summary.duration_ms = Math.min(bridge.batchTimeoutMs, Date.now() - started);
128
+ traces.sort();
129
+ const report = { results_path: resultsPath, stop_reason: stopReason, determinism_note: 'Replay verifies recorded observations. Reproducibility requires the game to honor its seed; this runner cannot prove determinism.' };
130
+ await writeJson(path.join(outputDir, 'summary.json'), summary);
131
+ return { summary, traces, report };
132
+ }
133
+ export async function replayTrace({ trace, bridge, signal, outputDir }) {
134
+ if (typeof trace === 'string') { if ((await fs.stat(trace)).size > MAX_TRACE_BYTES + 1024) throw new Error('Trace exceeds replay limit.'); trace = JSON.parse(await fs.readFile(trace, 'utf8')); }
135
+ const failedStart = trace?.status === 'failed' && trace?.failure_phase === 'start' && trace?.states?.length === 0;
136
+ if (!trace || trace.version !== 1 || !boundedString(trace.seed, 128) || !boundedString(trace.strategy, 80) || !Number.isInteger(trace.max_steps) || trace.max_steps < 1 || trace.max_steps > 10000 || !Array.isArray(trace.actions) || trace.actions.length > 10000 || trace.actions.some(action => !boundedString(action, 128)) || !Array.isArray(trace.states) || (!failedStart && trace.states.length !== trace.actions.length + 1) || (failedStart && trace.actions.length) || (trace.failed_action !== undefined && !boundedString(trace.failed_action, 128))) throw new Error('Trace is invalid for replay.');
137
+ if (Buffer.byteLength(JSON.stringify(trace)) > MAX_TRACE_BYTES + 1024) throw new Error('Trace exceeds replay limit.');
138
+ for (const state of trace.states) observation({ ...state, actions: state.done ? [] : [{ id: 'replay-validation' }] });
139
+ const options = bridgeOptions(bridge);
140
+ const deadline = AbortSignal.timeout(options.batchTimeoutMs);
141
+ const replaySignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
142
+ const client = openBridge(options, { signal: replaySignal });
143
+ let result = { matched: true, matched_prefix: false, fully_replayed: false, failure_reproduced: null, first_divergence: null };
144
+ const start = () => client.request('simulation.start', { version: 1, seed: trace.seed, strategy: trace.strategy, max_steps: trace.max_steps });
145
+ async function reproduce(request, step) {
146
+ try {
147
+ const actual = await request(); result.matched = false; result.failure_reproduced = false;
148
+ result.first_divergence = { step, reason: 'Recorded protocol failure did not recur.', actual: stateRecord(actual) };
149
+ } catch (error) {
150
+ result.failure_reproduced = error.message === trace.error;
151
+ result.matched = result.failure_reproduced;
152
+ if (!result.matched) result.first_divergence = { step, expected_error: trace.error, actual_error: error.message };
153
+ }
154
+ }
155
+ try {
156
+ if (failedStart) await reproduce(start, 0);
157
+ else {
158
+ for (let i = 0; i < trace.states.length; i++) {
159
+ let state;
160
+ try { state = i === 0 ? await start() : await client.request('simulation.step', { action: trace.actions[i - 1] }); }
161
+ catch (error) { result.matched = false; result.first_divergence = { step: i, actual_error: error.message }; break; }
162
+ const expected = trace.states[i]; const actual = stateRecord(state);
163
+ const canonical = value => JSON.stringify([value.state_hash, value.done, value.outcome ?? null, Object.entries(value.metrics || {}).sort(([a], [b]) => compareText(a, b))]);
164
+ if (canonical(expected) !== canonical(actual)) { result.matched = false; result.first_divergence = { step: i, expected, actual }; break; }
165
+ if (i < trace.actions.length && !state.actions.some(action => action.id === trace.actions[i])) { result.matched = false; result.first_divergence = { step: i, reason: 'Recorded action is no longer legal.' }; break; }
166
+ }
167
+ if (result.matched) {
168
+ result.matched_prefix = true;
169
+ result.fully_replayed = !trace.truncated && trace.status !== 'failed';
170
+ if (trace.status === 'failed' && trace.failed_action && !trace.truncated) await reproduce(() => client.request('simulation.step', { action: trace.failed_action }), trace.states.length);
171
+ }
172
+ }
173
+ } finally { await client.stop(); }
174
+ if (outputDir) { await fs.mkdir(outputDir, { recursive: true }); const file = path.resolve(outputDir, 'replay.json'); await writeJson(file, result); result.report_path = file; }
175
+ return result;
176
+ }
@@ -0,0 +1,90 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ const forbidden = new Set(['__proto__', 'constructor', 'prototype']);
4
+ export const MAX_RESPONSE = 256 * 1024;
5
+ export function boundedString(value, max) { return typeof value === 'string' && value.length > 0 && value.length <= max && !forbidden.has(value); }
6
+ export function observation(value) {
7
+ if (!value || typeof value !== 'object' || Array.isArray(value) || typeof value.done !== 'boolean' || !boundedString(value.state_hash, 128) || !Array.isArray(value.actions) || value.actions.length > 256) throw new Error('Invalid simulation observation.');
8
+ const numbers = (map, limit, maxAbsolute = Infinity) => {
9
+ if (!map || typeof map !== 'object' || Array.isArray(map) || Object.keys(map).length > limit || Object.entries(map).some(([key, n]) => !boundedString(key, 80) || typeof n !== 'number' || (!Number.isFinite(n) || Math.abs(n) > maxAbsolute))) throw new Error('Invalid simulation numeric map.');
10
+ return { ...map };
11
+ };
12
+ const actions = value.actions.map(action => {
13
+ if (!action || !boundedString(action.id, 128)) throw new Error('Invalid simulation action.');
14
+ return { id: action.id, ...(action.scores === undefined ? {} : { scores: numbers(action.scores, 16) }) };
15
+ });
16
+ if (new Set(actions.map(action => action.id)).size !== actions.length) throw new Error('Duplicate simulation action.');
17
+ if (!value.done && !actions.length) throw new Error('Nonterminal simulation needs legal actions.');
18
+ if (value.outcome !== undefined && !boundedString(value.outcome, 80)) throw new Error('Invalid simulation outcome.');
19
+ return { done: value.done, state_hash: value.state_hash, actions, ...(value.metrics === undefined ? {} : { metrics: numbers(value.metrics, 32, 1e15) }), ...(value.outcome === undefined ? {} : { outcome: value.outcome }) };
20
+ }
21
+ export function bridgeOptions(bridge) {
22
+ // Native Windows has no POSIX process-group kill. Do not buy a batch whose
23
+ // adapter descendants we cannot reliably stop; WSL uses the Linux path.
24
+ if (process.platform === 'win32') throw new Error('Simulation Runner is not supported on native Windows. Run it in WSL, Linux or macOS so cancellation can stop the adapter process group.');
25
+ if (!bridge || !path.isAbsolute(bridge.command || '') || (bridge.args !== undefined && (!Array.isArray(bridge.args) || bridge.args.length > 256 || bridge.args.some(arg => typeof arg !== 'string' || arg.length > 8192))) || (bridge.cwd !== undefined && !path.isAbsolute(bridge.cwd))) throw new Error('Bridge needs an absolute command, string arguments and optional absolute cwd.');
26
+ const timeoutMs = bridge.timeoutMs ?? 30000; const batchTimeoutMs = bridge.batchTimeoutMs ?? 900000;
27
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000 || !Number.isInteger(batchTimeoutMs) || batchTimeoutMs < 1 || batchTimeoutMs > 900000) throw new Error('Invalid simulation deadline.');
28
+ if (bridge.env !== undefined && (!bridge.env || typeof bridge.env !== 'object' || Array.isArray(bridge.env) || Object.entries(bridge.env).some(([k, v]) => !boundedString(k, 128) || typeof v !== 'string' || v.length > 8192))) throw new Error('Invalid bridge environment.');
29
+ return { ...bridge, timeoutMs, batchTimeoutMs };
30
+ }
31
+ export function childEnvironment(extra = {}, playtest = {}) {
32
+ // Local adapters are trusted code, not a security sandbox. Avoid accidentally
33
+ // forwarding account/provider credentials or inherited synthetic identities.
34
+ const env = {};
35
+ for (const [key, value] of Object.entries({ ...process.env, ...extra })) {
36
+ if (/TOKEN|SECRET|PASSWORD|CREDENTIAL|API.?KEY|PRIVATE.?KEY|AUTH|^AWS_|^AZURE_|^GOOGLE_|^ANTHROPIC_|^OPENAI_|^NODE_OPTIONS$|^RAVENSIGHT_.*(?:RUN|JOB)/i.test(key)) continue;
37
+ env[key] = value;
38
+ }
39
+ env.RAVENSIGHT_PLAYTEST_TOKEN = typeof playtest.token === 'string' && playtest.token ? playtest.token : 'invalid-simulation-preflight-token';
40
+ env.RAVENSIGHT_PLAYTEST_RUN_ID = String(playtest.runId || 'local-simulation');
41
+ env.RAVENSIGHT_PLAYTEST_JOB_ID = String(playtest.jobId || 'local-simulation');
42
+ return env;
43
+ }
44
+ export function openBridge(raw, { signal, playtest } = {}) {
45
+ const bridge = bridgeOptions(raw);
46
+ const child = spawn(bridge.command, bridge.args || [], { cwd: bridge.cwd, shell: false, detached: process.platform !== 'win32', env: childEnvironment(bridge.env, playtest), stdio: ['pipe', 'pipe', 'pipe'] });
47
+ let buffer = Buffer.alloc(0), id = 0, pending, failure, closing;
48
+ const kill = sig => { try { if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, sig); else child.kill(sig); } catch {} };
49
+ const fail = message => { failure ||= new Error(message); if (pending) { clearTimeout(pending.timer); pending.reject(failure); pending = null; } };
50
+ const stop = () => {
51
+ if (closing) return closing;
52
+ fail('Simulation bridge stopped.'); signal?.removeEventListener('abort', abort);
53
+ kill('SIGTERM');
54
+ // Always escalate against the process group, including when child.killed is
55
+ // true or the leader exited while a descendant still owns its pipes.
56
+ closing = new Promise(resolve => setTimeout(() => { kill('SIGKILL'); child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); resolve(); }, 100));
57
+ return closing;
58
+ };
59
+ const abort = () => { fail('Simulation cancelled.'); void stop(); };
60
+ signal?.addEventListener('abort', abort, { once: true });
61
+ if (signal?.aborted) abort();
62
+ child.stderr.resume();
63
+ child.on('error', () => { fail('Simulation bridge could not start.'); void stop(); });
64
+ child.on('exit', () => { fail('Simulation bridge exited.'); });
65
+ child.stdin.on('error', () => { fail('Simulation bridge input failed.'); void stop(); });
66
+ child.stdout.on('data', chunk => {
67
+ buffer = Buffer.concat([buffer, chunk]);
68
+ if (buffer.length > MAX_RESPONSE) { fail('Simulation response exceeds 256 KB.'); void stop(); return; }
69
+ let end;
70
+ while ((end = buffer.indexOf(10)) >= 0) {
71
+ const line = buffer.subarray(0, end); buffer = buffer.subarray(end + 1);
72
+ try {
73
+ const message = JSON.parse(line.toString('utf8'), (key, value) => { if (forbidden.has(key)) throw new Error(); return value; });
74
+ if (!pending || message.jsonrpc !== '2.0' || message.id !== pending.id || message.error || !Object.hasOwn(message, 'result')) throw new Error();
75
+ const result = observation(message.result); const entry = pending; pending = null; clearTimeout(entry.timer); entry.resolve(result);
76
+ } catch { fail('Invalid simulation protocol response.'); void stop(); return; }
77
+ }
78
+ });
79
+ return {
80
+ request(method, params) {
81
+ if (failure) return Promise.reject(failure);
82
+ if (pending) return Promise.reject(new Error('Simulation request already pending.'));
83
+ return new Promise((resolve, reject) => {
84
+ const requestId = ++id;
85
+ pending = { id: requestId, resolve, reject, timer: setTimeout(() => { fail('Simulation response timed out.'); void stop(); }, bridge.timeoutMs) };
86
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: requestId, method, params }) + '\n');
87
+ });
88
+ }, stop
89
+ };
90
+ }
@@ -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
+ }