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.
- package/README.md +77 -2
- package/docs/SIMULATION.md +91 -0
- package/docs/godot-driver.md +310 -0
- package/examples/simulation/economy.mjs +22 -0
- package/package.json +7 -2
- package/src/api/client.js +2 -2
- package/src/cli.js +21 -1
- package/src/commands/run.js +42 -13
- package/src/commands/simulate.js +298 -0
- 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/simulation/index.js +176 -0
- package/src/simulation/protocol.js +90 -0
- package/src/upload/index.js +4 -2
- package/src/upload/recording.js +56 -0
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',
|
|
@@ -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
|
+
}
|
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
|
+
}
|