ravensight-playtest 0.3.1 → 0.4.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,298 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { createContext, resolveGameId, CLI_VERSION, ui } from '../api/index.js';
5
+ import { createHeartbeat } from '../run/heartbeat.js';
6
+ import { readBridgeConfig } from '../run/drivers/stdio.js';
7
+ import { runBatch, replayTrace, validateConfig, probeBridge } from '../simulation/index.js';
8
+ import { jobLink } from '../dashboard.js';
9
+ import { CliError } from '../errors.js';
10
+ import EXIT from '../run/exit.js';
11
+
12
+ const MODULES = ['balance_sim', 'aggregate_report'];
13
+ const TERMINAL = new Set(['succeeded', 'failed', 'canceled', 'budget_exceeded', 'skipped', 'stalled']);
14
+ const money = cents => `$${(cents / 100).toFixed(2)}`;
15
+ const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
16
+ async function loadBridge(file) {
17
+ const bridge = await readBridgeConfig(file);
18
+ return { ...bridge, cwd: bridge.cwd || path.dirname(file) };
19
+ }
20
+
21
+ export function publicConfig(config) {
22
+ return { version: config.version, seed: config.seed, runs: config.runs, max_steps: config.max_steps,
23
+ workers: config.workers, strategies: config.strategies.map(({ name, policy }) => ({ name, policy })) };
24
+ }
25
+
26
+ async function jsonFile(file, maxBytes = 64000) {
27
+ if ((await stat(file)).size > maxBytes) throw new CliError(`File exceeds ${maxBytes} bytes: ${file}`);
28
+ return JSON.parse(await readFile(file, 'utf8'));
29
+ }
30
+
31
+ async function save(file, value) {
32
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
33
+ const temporary = `${file}.${randomUUID()}.tmp`;
34
+ try {
35
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
36
+ await rename(temporary, file);
37
+ } finally { await unlink(temporary).catch(() => {}); }
38
+ }
39
+
40
+ // Exclusive per-journal execution. A dead process's lock is recoverable; a live
41
+ // process cannot have its paid run restarted underneath it by another shell.
42
+ async function acquire(file) {
43
+ const lock = `${file}.lock`;
44
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
45
+ for (let attempt = 0; attempt < 2; attempt++) {
46
+ try {
47
+ const handle = await open(lock, 'wx', 0o600);
48
+ await handle.writeFile(String(process.pid));
49
+ await handle.close();
50
+ return () => unlink(lock).catch(() => {});
51
+ } catch (error) {
52
+ if (error.code !== 'EEXIST') throw error;
53
+ const pid = Number(await readFile(lock, 'utf8'));
54
+ if (!Number.isInteger(pid) || pid < 1) throw new CliError(`Unreadable simulation lock: ${lock}`);
55
+ try { process.kill(pid, 0); }
56
+ catch (probe) {
57
+ if (probe.code === 'ESRCH' && attempt === 0) { await unlink(lock); continue; }
58
+ }
59
+ throw new CliError('This simulation journal is already in use. Wait for the other runner to stop.');
60
+ }
61
+ }
62
+ }
63
+
64
+ function assertQuote(quote) {
65
+ if (quote?.execution_mode !== 'direct_command_batch' || !quote.simulation_limits ||
66
+ !Number.isSafeInteger(quote.estimate_cents) || quote.estimate_cents <= 0 ||
67
+ quote.breakdown?.length !== 1 || quote.breakdown[0].unit !== 'playtest_run' ||
68
+ quote.breakdown[0].count !== 1 || quote.breakdown[0].unit_cents !== quote.estimate_cents) {
69
+ throw new CliError('This server did not quote a paid Simulation Runner batch. Nothing was registered or charged. Update the server and CLI together.');
70
+ }
71
+ }
72
+
73
+ /** One confirmed purchase, a local batch, then bounded evidence to the same job. */
74
+ export async function simulateCommand(flags = {}, injected = {}) {
75
+ const output = injected.ui || ui;
76
+ const repoRoot = path.resolve(flags.repoRoot || process.cwd());
77
+ let journal, file;
78
+ if (flags.resume) {
79
+ if (flags.configFile || flags.driverConfig) throw new CliError('Resume uses the original config and bridge. Do not combine --resume with --config or --driver-config.');
80
+ file = path.resolve(repoRoot, flags.resume);
81
+ journal = await jsonFile(file, 2 * 1024 * 1024);
82
+ if (journal.version !== 1 || journal.kind !== 'simulation' || !journal.idempotency_key ||
83
+ !journal.game_id || !journal.api_url || !journal.config || !journal.registration) {
84
+ throw new CliError('This is not a Simulation Runner journal.');
85
+ }
86
+ if (flags.game && flags.game !== journal.game_id) throw new CliError('Resume must use the original game.');
87
+ if (flags.apiUrl && flags.apiUrl.replace(/\/+$/, '') !== journal.api_url) throw new CliError('Resume must use the original API host.');
88
+ } else {
89
+ if (!flags.configFile) throw new CliError('Supply --config simulation.json, or --resume <state.json>.');
90
+ const configFile = path.resolve(repoRoot, flags.configFile);
91
+ const { driver_config: configDriver, ...raw } = await jsonFile(configFile);
92
+ const config = validateConfig(raw);
93
+ const driverFile = flags.driverConfig ? path.resolve(repoRoot, flags.driverConfig)
94
+ : typeof configDriver === 'string' ? path.resolve(path.dirname(configFile), configDriver) : null;
95
+ if (!driverFile) throw new CliError('Supply --driver-config bridge.json or driver_config in the simulation config.');
96
+ const bridge = await loadBridge(driverFile);
97
+ journal = { version: 1, kind: 'simulation', idempotency_key: randomUUID(), config,
98
+ bridge_file: driverFile, bridge_hash: hash(bridge), created_at: new Date().toISOString(), finished: false };
99
+ file = path.join(repoRoot, '.ravensight', 'simulations', journal.idempotency_key, 'state.json');
100
+ }
101
+ validateConfig(journal.config);
102
+ const context = await (injected.createContext || createContext)({ apiUrl: journal.api_url || flags.apiUrl, repoRoot, requireAuth: true });
103
+ if (journal.api_url && context.apiUrl.replace(/\/+$/, '') !== journal.api_url) throw new CliError('The API host changed. Resume was refused.');
104
+ const api = context.api;
105
+ const gameId = journal.game_id || resolveGameId(flags, context.config);
106
+ const release = await acquire(file);
107
+ const controller = new AbortController();
108
+ const target = injected.process || process;
109
+ const stop = () => controller.abort(new Error('Simulation canceled.'));
110
+ target.once('SIGINT', stop);
111
+ target.once('SIGTERM', stop);
112
+ let heartbeat;
113
+ const persist = () => save(file, journal);
114
+ const settledExit = state => journal.canceled || state === 'canceled' ? EXIT.CANCELED
115
+ : state !== 'succeeded' || (journal.summary && (!journal.summary.completed_runs || journal.summary.failed_runs || journal.summary.truncated_runs)) ? EXIT.FAILED : EXIT.OK;
116
+ const recover = () => output.error(`Recover this same purchase with: ravensight-playtest simulate --resume ${JSON.stringify(file)}`);
117
+ try {
118
+ if (journal.finished) {
119
+ if (flags.json) output.json({ job_id: journal.job_id, finished: true, summary: journal.summary });
120
+ else output.info(`This batch is already finished: ${jobLink(gameId, journal.job_id, context.apiUrl)}`);
121
+ return journal.exit_code ?? EXIT.OK;
122
+ }
123
+ if (journal.job_id) {
124
+ const { job } = await api.jobs.get(gameId, journal.job_id);
125
+ if (TERMINAL.has(job.state)) {
126
+ journal.finished = true;
127
+ journal.exit_code = settledExit(job.state);
128
+ await persist();
129
+ if (flags.json) output.json({ job_id: journal.job_id, finished: true, state: job.state });
130
+ else output.info(`The server already settled this batch as ${job.state}. No simulation was restarted.`);
131
+ return journal.exit_code;
132
+ }
133
+ // A completed run can await job settlement after an interrupted finish.
134
+ // Neither adapter files nor local execution are needed to settle it.
135
+ if (!journal.summary && journal.run_id) {
136
+ const { run: existing } = await api.runs.get(gameId, journal.job_id, journal.run_id);
137
+ if (TERMINAL.has(existing.state)) {
138
+ await api.jobs.finish(gameId, journal.job_id, {});
139
+ journal.finished = true; journal.exit_code = settledExit(existing.state); await persist();
140
+ if (flags.json) output.json({ job_id: journal.job_id, finished: true, state: existing.state });
141
+ else output.info(`This run already finished as ${existing.state}; its job is now settled.`);
142
+ return journal.exit_code;
143
+ }
144
+ }
145
+ if (job.cancel_requested) controller.abort(new Error('Simulation canceled on the server.'));
146
+ }
147
+ let bridge;
148
+ if (!journal.summary) {
149
+ bridge = await loadBridge(journal.bridge_file);
150
+ if (hash(bridge) !== journal.bridge_hash) throw new CliError('The bridge configuration changed. Restore it before resuming this purchase.');
151
+ if (!controller.signal.aborted) await (injected.probeBridge || probeBridge)({ bridge, seed: journal.config.seed,
152
+ strategy: journal.config.strategies[0].name, max_steps: journal.config.max_steps, signal: controller.signal });
153
+ }
154
+ if (!journal.registration) {
155
+ const simulation = publicConfig(journal.config);
156
+ const quote = await api.jobs.estimate(gameId, { modules: MODULES, personas: [], simulation });
157
+ assertQuote(quote);
158
+ output.info(`One simulation batch: ${journal.config.runs} games across ${journal.config.strategies.length} strategies, ${money(quote.estimate_cents)} total from prepaid balance.`);
159
+ output.info('Game execution and replay traces stay local. Only the bounded summary is uploaded. This is a mechanics test, not a test of the player interface.');
160
+ if (quote.ok === false) throw new CliError(`Insufficient prepaid balance (${money(quote.balance_cents)}). Nothing was charged.`, EXIT.ENVIRONMENT);
161
+ if (!flags.yes && !await (injected.confirm || output.confirm)(`Start this batch for ${money(quote.estimate_cents)}?`)) return EXIT.CANCELED;
162
+ if (controller.signal.aborted) return EXIT.CANCELED;
163
+ journal.api_url = context.apiUrl.replace(/\/+$/, '');
164
+ journal.game_id = gameId;
165
+ journal.limits = quote.simulation_limits;
166
+ journal.registration = { modules: MODULES, personas: [], driver: 'cli_stdio', simulation,
167
+ cli_version: CLI_VERSION, confirm_price_cents: quote.estimate_cents,
168
+ build: { kind: 'simulation', ref: hash(simulation) } };
169
+ // Durable BEFORE the money request. A lost register response replays the
170
+ // same idempotency key, even when no job_id ever reached this machine.
171
+ await persist();
172
+ output.info(`Recovery journal: ${file}`);
173
+ }
174
+
175
+ let registered;
176
+ try { registered = await api.jobs.register(gameId, journal.registration, { idempotencyKey: journal.idempotency_key }); }
177
+ catch (error) { recover(); throw error; }
178
+ journal.job_id = registered.job_id;
179
+ const run = registered.runs?.find(entry => entry.module === 'balance_sim');
180
+ if (!run || !run.playtest_token) {
181
+ await api.jobs.finish(gameId, journal.job_id, { state: 'failed', reason: 'Simulation run or synthetic token missing.' });
182
+ throw new CliError('The server did not create an executable simulation run. The job was stopped.');
183
+ }
184
+ journal.run_id = run.run_id;
185
+ await persist();
186
+ let current = flags.resume ? (await api.runs.get(gameId, journal.job_id, run.run_id)).run : run;
187
+ if (TERMINAL.has(current.state)) {
188
+ await api.jobs.finish(gameId, journal.job_id, {});
189
+ journal.finished = true;
190
+ journal.exit_code = settledExit(current.state);
191
+ await persist();
192
+ if (flags.json) output.json({ job_id: journal.job_id, finished: true, state: current.state });
193
+ else output.info(`This run already finished as ${current.state}; its job is now settled.`);
194
+ return journal.exit_code;
195
+ }
196
+ const transition = async state => {
197
+ if (current.state !== state) {
198
+ await api.runs.transition(gameId, journal.job_id, run.run_id, state);
199
+ current = { ...current, state };
200
+ }
201
+ };
202
+ heartbeat = (injected.createHeartbeat || createHeartbeat)({ api, gameId, jobId: journal.job_id, runId: run.run_id,
203
+ onCancel: stop, onError: error => {
204
+ if ([401, 403, 404, 409].includes(error.status)) controller.abort(new Error('The server closed this run.'));
205
+ } });
206
+ heartbeat.start();
207
+ await heartbeat.beat();
208
+ if (!journal.summary) {
209
+ if (['reporting', 'uploading'].includes(current.state)) throw new CliError('The local simulation summary is missing. Restore the journal before finishing this paid run.');
210
+ if (['queued', undefined].includes(current.state)) await transition('launching');
211
+ await transition('playing');
212
+ try {
213
+ // A crash can leave an incomplete results.jsonl. Preserve it and use a
214
+ // new directory for the deterministic restart of the same paid run.
215
+ journal.attempt_directory = path.join(path.dirname(file), `attempt-${randomUUID()}`);
216
+ await persist();
217
+ const result = await (injected.runBatch || runBatch)({ config: journal.config,
218
+ bridge: { ...bridge, batchTimeoutMs: Math.min(900000, (run.limits?.wall_seconds || run.budget?.wall_seconds_max || journal.limits?.wall_seconds_max || 900) * 1000) },
219
+ outputDir: journal.attempt_directory,
220
+ playtest: { token: run.playtest_token, runId: run.run_id, jobId: journal.job_id }, signal: controller.signal,
221
+ onProgress: progress => {
222
+ if (progress.finished_runs % 100 === 0 || progress.finished_runs === progress.requested_runs) {
223
+ output.info(`${progress.finished_runs}/${progress.requested_runs} games processed; ${progress.completed_runs} completed, ${progress.failed_runs} failed, ${progress.truncated_runs} reached a limit.`);
224
+ void heartbeat.beat({ actionsTaken: progress.total_steps, checkpointStep: progress.finished_runs });
225
+ }
226
+ } });
227
+ journal.summary = result.summary;
228
+ journal.traces = result.traces;
229
+ journal.local_report = result.report;
230
+ journal.canceled = controller.signal.aborted;
231
+ await persist();
232
+ } catch (error) {
233
+ // No usable local result exists. Complete and settle through the same
234
+ // server refund path; neither a second charge nor a local refund.
235
+ try {
236
+ await api.runs.complete(gameId, journal.job_id, run.run_id, { state: 'failed', quit_reason: 'error', reason: 'Local simulation could not produce a summary.' });
237
+ await api.jobs.finish(gameId, journal.job_id, { reason: 'Local simulation failed.' });
238
+ journal.finished = true; journal.exit_code = EXIT.FAILED; await persist();
239
+ } catch { recover(); }
240
+ throw error;
241
+ }
242
+ }
243
+ const useful = journal.summary.completed_runs > 0;
244
+ try {
245
+ if (useful) {
246
+ if (current.state !== 'uploading') await transition('reporting');
247
+ await transition('uploading');
248
+ }
249
+ await api.runs.complete(gameId, journal.job_id, run.run_id, {
250
+ state: useful ? 'succeeded' : 'failed', simulation: journal.summary,
251
+ actions_taken: journal.summary.total_steps, quit_reason: useful ? 'goal_reached' : 'error'
252
+ });
253
+ await api.jobs.finish(gameId, journal.job_id, {});
254
+ } catch (error) {
255
+ // Keep valid results for retry. A dropped complete/finish response is
256
+ // ambiguous, so do not overwrite success with failed or buy another job.
257
+ recover();
258
+ throw error;
259
+ }
260
+ journal.finished = true;
261
+ journal.exit_code = journal.canceled ? EXIT.CANCELED :
262
+ (!useful || journal.summary.failed_runs || journal.summary.truncated_runs) ? EXIT.FAILED : EXIT.OK;
263
+ await persist();
264
+ if (flags.json) output.json({ job_id: journal.job_id, summary: journal.summary, traces: journal.traces, journal: file });
265
+ else {
266
+ output.info(`Simulation report: ${jobLink(gameId, journal.job_id, context.apiUrl)}`);
267
+ output.info(`Local summary and replay traces: ${path.dirname(file)}`);
268
+ if (!useful) output.info('No game reached a terminal result. The server settles this batch without keeping its Playtest unit.');
269
+ }
270
+ return journal.exit_code;
271
+ } finally {
272
+ heartbeat?.stop();
273
+ target.removeListener('SIGINT', stop);
274
+ target.removeListener('SIGTERM', stop);
275
+ await release();
276
+ }
277
+ }
278
+
279
+ export async function replayCommand(trace, flags = {}, injected = {}) {
280
+ const output = injected.ui || ui;
281
+ const repo = path.resolve(flags.repoRoot || process.cwd());
282
+ const bridge = await loadBridge(path.resolve(repo, flags.driverConfig));
283
+ const controller = new AbortController();
284
+ const stop = () => controller.abort(new Error('Replay canceled.'));
285
+ const target = injected.process || process;
286
+ target.once('SIGINT', stop); target.once('SIGTERM', stop);
287
+ try {
288
+ const result = await (injected.replayTrace || replayTrace)({ trace: path.resolve(repo, trace), bridge, signal: controller.signal });
289
+ if (flags.json) output.json(result);
290
+ else output.info(result.matched
291
+ ? result.fully_replayed === false
292
+ ? result.failure_reproduced ? 'Replay matched the recorded prefix and reproduced the recorded failure.'
293
+ : 'Replay matched the recorded prefix only. The trace does not establish a complete playthrough.'
294
+ : 'Replay matched every recorded state, outcome and metric.'
295
+ : `Replay diverged: ${JSON.stringify(result.first_divergence)}. Check the game seed, build and external dependencies.`);
296
+ return result.matched ? EXIT.OK : EXIT.FAILED;
297
+ } finally { target.removeListener('SIGINT', stop); target.removeListener('SIGTERM', stop); }
298
+ }
package/src/run/args.js CHANGED
@@ -35,6 +35,9 @@ const BOOLEAN = new Map([
35
35
  ['--headed', 'headed'],
36
36
  ['--headless', 'headless'],
37
37
  ['--verbose', 'verbose'],
38
+ // A long persona run: twice the actions, model budget and wall time of a
39
+ // standard run, priced by the server as its own unit.
40
+ ['--long', 'long'],
38
41
  // Native drivers only: register without the free automation probe first.
39
42
  ['--skip-preflight', 'skipPreflight'],
40
43
  // Resume only: replay an interrupted run from the start instead of writing a
@@ -106,6 +109,7 @@ export function optionsFrom(flags = {}) {
106
109
  return {
107
110
  options: {
108
111
  ...options,
112
+ runLength: runLengthFor(options),
109
113
  ...stripUndefined({
110
114
  repo: flags.repoRoot || flags.repo,
111
115
  apiUrl: flags.apiUrl,
@@ -128,8 +132,21 @@ export function optionsFrom(flags = {}) {
128
132
  };
129
133
  }
130
134
 
135
+ /** The two run lengths the server prices: `standard`, and `long` at twice a standard run. */
136
+ export const RUN_LENGTHS = Object.freeze(['standard', 'long']);
137
+
138
+ /**
139
+ * `long` when `--long` was passed, otherwise `standard`.
140
+ *
141
+ * @param {{long?: boolean}} options
142
+ * @returns {'standard'|'long'}
143
+ */
144
+ export function runLengthFor(options = {}) {
145
+ return options.long === true ? 'long' : 'standard';
146
+ }
147
+
131
148
  function stripUndefined(value) {
132
149
  return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
133
150
  }
134
151
 
135
- export default { parseRunArgv, optionsFrom, unknownOptionNotes };
152
+ export default { parseRunArgv, optionsFrom, unknownOptionNotes, runLengthFor };