ravensight-playtest 0.1.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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/addons/ravensight_driver/driver.gd +836 -0
  4. package/addons/ravensight_driver/export_plugin.gd +51 -0
  5. package/addons/ravensight_driver/plugin.cfg +7 -0
  6. package/addons/ravensight_driver/plugin.gd +36 -0
  7. package/bin/ravensight-playtest.js +31 -0
  8. package/package.json +45 -0
  9. package/src/api/README.md +500 -0
  10. package/src/api/client.js +340 -0
  11. package/src/api/errors.js +115 -0
  12. package/src/api/http.js +194 -0
  13. package/src/api/index.js +107 -0
  14. package/src/auth/deviceCode.js +79 -0
  15. package/src/auth/keychain.js +159 -0
  16. package/src/auth/session.js +128 -0
  17. package/src/cli.js +335 -0
  18. package/src/commands/brief.js +303 -0
  19. package/src/commands/check.js +318 -0
  20. package/src/commands/fakeCore.js +379 -0
  21. package/src/commands/init.js +120 -0
  22. package/src/commands/login.js +90 -0
  23. package/src/commands/logout.js +70 -0
  24. package/src/commands/open.js +125 -0
  25. package/src/commands/profile.js +262 -0
  26. package/src/commands/resume.js +156 -0
  27. package/src/commands/run.js +1015 -0
  28. package/src/commands/upload.js +137 -0
  29. package/src/config.js +100 -0
  30. package/src/dashboard.js +97 -0
  31. package/src/detect.js +77 -0
  32. package/src/errors.js +44 -0
  33. package/src/fsutil.js +77 -0
  34. package/src/godot.js +85 -0
  35. package/src/packs/index.js +191 -0
  36. package/src/paths.js +129 -0
  37. package/src/run/aggregate.js +658 -0
  38. package/src/run/args.js +111 -0
  39. package/src/run/context.js +181 -0
  40. package/src/run/deps.js +184 -0
  41. package/src/run/drivers/driver.js +183 -0
  42. package/src/run/drivers/godot-observation.js +138 -0
  43. package/src/run/drivers/godot-project.js +475 -0
  44. package/src/run/drivers/godot-rpc.js +225 -0
  45. package/src/run/drivers/godot.js +587 -0
  46. package/src/run/drivers/index.js +52 -0
  47. package/src/run/drivers/web.js +385 -0
  48. package/src/run/exit.js +21 -0
  49. package/src/run/heartbeat.js +131 -0
  50. package/src/run/index.js +31 -0
  51. package/src/run/json.js +56 -0
  52. package/src/run/model.js +384 -0
  53. package/src/run/paths.js +88 -0
  54. package/src/run/personaLoop.js +871 -0
  55. package/src/run/profile.js +214 -0
  56. package/src/run/regenerate.js +149 -0
  57. package/src/run/repoTools.js +286 -0
  58. package/src/run/report.js +222 -0
  59. package/src/run/resume.js +272 -0
  60. package/src/run/secretScan.js +171 -0
  61. package/src/run/state.js +198 -0
  62. package/src/run/synthetic.js +206 -0
  63. package/src/run/tools.js +344 -0
  64. package/src/run/transcript.js +93 -0
  65. package/src/run/usage.js +115 -0
  66. package/src/state/index.js +105 -0
  67. package/src/states.js +104 -0
  68. package/src/ui/index.js +195 -0
  69. package/src/upload/allowlist.js +116 -0
  70. package/src/upload/index.js +467 -0
  71. package/src/upload/queue.js +114 -0
  72. package/src/version.js +63 -0
@@ -0,0 +1,1015 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { buildAggregate, writeAggregate } from '../run/aggregate.js';
5
+ import { optionsFrom } from '../run/args.js';
6
+ import { getDeps } from '../run/deps.js';
7
+ import { createDriver } from '../run/drivers/index.js';
8
+ import EXIT from '../run/exit.js';
9
+ import { createHeartbeat } from '../run/heartbeat.js';
10
+ import { createUsageLedger } from '../run/usage.js';
11
+ import { createModelClient, ModelUnavailableError, ProxyError } from '../run/model.js';
12
+ import { jobDir, runDir as runDirFor, JOB_FILES, RUN_FILES } from '../run/paths.js';
13
+ import { runPersona } from '../run/personaLoop.js';
14
+ import * as stateFile from '../run/state.js';
15
+ import { emitRunEvents } from '../run/synthetic.js';
16
+
17
+ /**
18
+ * `ravensight-playtest run`: register a job, play it, upload it, finish it.
19
+ *
20
+ * The order is load-bearing, and most of it is about money:
21
+ *
22
+ * 1. Estimate and confirm BEFORE anything is created. `POST /estimate` answers
23
+ * the price and the balance together, so "you are short" is one answer
24
+ * rather than a charge that fails.
25
+ * 2. `POST /jobs` with an `Idempotency-Key`. This is the one request on the
26
+ * surface that moves money before it answers, so the key is required, and it
27
+ * is written into `state.json` before the runs start: `resume` replays the
28
+ * same key, which is why the server hands back the stored job and freshly
29
+ * minted tokens rather than charging again.
30
+ * 3. Play. Each run gets its own heartbeat and its own budget.
31
+ * 4. Upload, then `complete`, through cli-core's `finalizeRun`. Uploads first,
32
+ * because `complete` verifies the manifest before it reads the report: a
33
+ * report citing a screenshot that was never uploaded is a 422, by design,
34
+ * and `finalizeRun` owns the one repair pass for it.
35
+ * 5. Aggregate, then `finish`. `finish` settles the job once and issues at most
36
+ * one refund, so it is called exactly once, at the end, including after a
37
+ * failure.
38
+ *
39
+ * Exit codes are spec 17's: 0, 2 out of budget with partial results uploaded, 3
40
+ * nothing to point at, 4 auth, 5 the proxy never answered, 10 cancelled.
41
+ */
42
+
43
+ /** Personas used when the developer names none. Spec 17 defaults to 2. */
44
+ export const DEFAULT_PERSONAS = Object.freeze(['curious-kid', 'grumpy-veteran']);
45
+
46
+ /** Parallel runs. Each Chromium is 0.5 to 1.5 GB, so 2 is the honest default. */
47
+ export const DEFAULT_CONCURRENCY = 2;
48
+
49
+ /**
50
+ * @param {Object} flags what cli-core's commander program hands over
51
+ * @param {Object} [injected] test seam: deps, createClient, driverFactory, log, confirm
52
+ * @returns {Promise<number>} process exit code
53
+ */
54
+ export async function runCommand(flags = {}, injected = {}) {
55
+ const deps = injected.deps || (await getDeps());
56
+ const log = injected.log || logger(deps);
57
+ try {
58
+ return await run(flags, injected, deps, log);
59
+ } catch (error) {
60
+ // Without this a 503 `proxy_disabled` and a ModelUnavailableError both
61
+ // surface as exit 1, which tells CI nothing. `exitCodeFor` is the one
62
+ // place that mapping lives.
63
+ const code = exitCodeFor(error);
64
+ if (code === EXIT.FAILED) throw error;
65
+ log(String(error && error.message ? error.message : error));
66
+ return code;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * @param {Object} flags
72
+ * @param {Object} injected
73
+ * @param {Object} deps
74
+ * @param {(message: string) => void} log
75
+ * @returns {Promise<number>}
76
+ */
77
+ async function run(flags, injected, deps, log) {
78
+ const { options, unknown } = optionsFrom(flags);
79
+ if (unknown.length > 0) {
80
+ log(`I do not know these options: ${unknown.join(', ')}. Run "ravensight-playtest run --help".`);
81
+ return EXIT.ENVIRONMENT;
82
+ }
83
+ for (const line of unusedFlagWarnings(options)) log(line);
84
+
85
+ const setup = await resolveSetup(deps, options, log);
86
+ if (setup.code !== undefined) return setup.code;
87
+ const { context, gameId, repoRoot, apiUrl, cliVersion, api } = setup;
88
+
89
+ const driverName = options.driver || (context.config && context.config.driver) || 'playwright_web';
90
+ const target = resolveTarget(options, context.config, driverName);
91
+ if (!target) {
92
+ log('Point the run at a build: --build-url for a web build, or --godot-project for a Godot project.');
93
+ return EXIT.ENVIRONMENT;
94
+ }
95
+
96
+ // `aggregate_report` is asked for explicitly: it is what buys the run the
97
+ // `aggregate.*` steps are spendable from, and it is free (the price list has
98
+ // no unit for it), so asking costs nothing and not asking costs the judged
99
+ // dedup and success-criteria passes.
100
+ const modules = ['persona_playtest', 'aggregate_report'];
101
+ const pack = await deps.packs.get({ api, gameId, modules });
102
+ const personaSlugs = resolvePersonas(options, context.config, pack);
103
+ const personas = personaSlugs.map(slug => {
104
+ const found = (pack.personas || []).find(persona => persona.slug === slug);
105
+ if (!found) throw new Error(`unknown persona "${slug}"; available: ${(pack.personas || []).map(entry => entry.slug).join(', ')}`);
106
+ return found;
107
+ });
108
+
109
+ // The same ceiling the server's `personaListRefusal` enforces, checked here
110
+ // first so a list past it never reaches the network: no estimate, no brief
111
+ // fetch, nothing charged. `pack.routing` is the clientRouting subset, and an
112
+ // older cached pack without `max_personas` skips the check rather than
113
+ // refusing on a field that does not exist yet.
114
+ const personaRefusal = personaCountRefusal(personaSlugs.length, pack.routing && pack.routing.max_personas);
115
+ if (personaRefusal) {
116
+ log(personaRefusal);
117
+ return EXIT.ENVIRONMENT;
118
+ }
119
+
120
+ // The brief is what a persona is told to respect: scope, known issues, design
121
+ // intent, the bug definition, the success criteria. Without it every persona
122
+ // reports findings nobody asked for and re-reports issues the developer
123
+ // already knows about, so a job that plays the game needs one. The server
124
+ // agrees and refuses `POST /jobs` with 409 `brief_required`.
125
+ const brief = await deps.brief.get({ api, gameId });
126
+ if (!brief) {
127
+ log('This game has no expectations brief yet, and a persona run needs one: without it the personas do not know what the game is for. Run "ravensight-playtest brief" first.');
128
+ return EXIT.ENVIRONMENT;
129
+ }
130
+ if (brief.status !== 'complete') {
131
+ log(`The expectations brief for this game is ${brief.status}, not complete. Finish it with "ravensight-playtest brief" and run this again.`);
132
+ return EXIT.ENVIRONMENT;
133
+ }
134
+
135
+ const estimate = await api.jobs.estimate(gameId, { modules, personas: personaSlugs });
136
+ log(`${personas.length} persona(s) on ${driverName}: ${formatCents(estimate.estimate_cents)}. Balance: ${formatCents(estimate.balance_cents)}.`);
137
+ if (estimate.ok === false) {
138
+ log(`That is more than the balance covers. Top up at ${estimate.topup_url || 'ravensight.io'} and run this again.`);
139
+ // Nothing is wrong with the request; the account needs money. Spec 17's
140
+ // table has no code for that, and 3 (environment) is the one that means
141
+ // "fix something outside this command and try again".
142
+ return EXIT.ENVIRONMENT;
143
+ }
144
+ // Alongside the price, never instead of it: the job is allowed either way,
145
+ // and the developer decides with the wall clock in front of them, printed
146
+ // above the confirmation rather than after it is too late to matter.
147
+ if (estimate.wall_warning) log(estimate.wall_warning);
148
+ const confirm = injected.confirm || (deps.ui && deps.ui.confirm);
149
+ if (!options.yes && confirm) {
150
+ const go = await confirm(`Run ${personas.length} persona(s) for ${formatCents(estimate.estimate_cents)}?`, { yes: options.yes });
151
+ if (!go) {
152
+ log('Nothing was charged.');
153
+ return EXIT.CANCELED;
154
+ }
155
+ }
156
+
157
+ const idempotencyKey = options.idempotencyKey || randomUUID();
158
+ let registered;
159
+ try {
160
+ registered = await api.jobs.register(gameId, {
161
+ modules,
162
+ personas: personaSlugs,
163
+ driver: driverName,
164
+ cli_version: cliVersion,
165
+ confirm_price_cents: estimate.estimate_cents,
166
+ brief_version: brief.version ?? null,
167
+ build: { kind: driverName === 'playwright_web' ? 'url' : 'godot_project', ref: target },
168
+ repo: { commit: (context.config && context.config.commit) || '', dirty: Boolean(context.config && context.config.dirty) },
169
+ max_actions: Number.isInteger(options.maxActions) ? options.maxActions : null,
170
+ pack_versions: { pack: pack.pack_version }
171
+ }, { idempotencyKey });
172
+ } catch (error) {
173
+ const handled = explainRegisterRefusal(error, log);
174
+ if (handled !== null) return handled;
175
+ throw error;
176
+ }
177
+
178
+ const jobId = registered.job_id;
179
+ const directory = jobDir(repoRoot, jobId);
180
+ await mkdir(directory, { recursive: true });
181
+
182
+ const journal = (await stateFile.load(repoRoot, jobId)) || stateFile.newState({
183
+ jobId,
184
+ gameId,
185
+ idempotencyKey,
186
+ cliVersion,
187
+ packVersion: pack.pack_version,
188
+ modules,
189
+ driver: driverName,
190
+ target,
191
+ priceCents: registered.price && registered.price.price_cents,
192
+ options: {
193
+ maxActions: options.maxActions ?? null,
194
+ concurrency: options.concurrency ?? DEFAULT_CONCURRENCY,
195
+ uploadVideo: Boolean(options.uploadVideo),
196
+ uploadTranscript: Boolean(options.uploadTranscript)
197
+ }
198
+ });
199
+ for (const entry of registered.runs || []) {
200
+ if (!journal.runs[entry.run_id]) {
201
+ journal.runs[entry.run_id] = stateFile.newRunEntry({ runId: entry.run_id, module: entry.module, persona: entry.persona });
202
+ }
203
+ }
204
+ await stateFile.save(repoRoot, journal);
205
+ log(`job ${jobId}${registered.replayed ? ' (replayed)' : ''}: ${formatCents(registered.price && registered.price.price_cents)} charged. ${directory}`);
206
+
207
+ return playJob({
208
+ deps,
209
+ api,
210
+ apiUrl,
211
+ gameId,
212
+ jobId,
213
+ repoRoot,
214
+ runs: registered.runs || [],
215
+ pack,
216
+ personas,
217
+ driverName,
218
+ target,
219
+ options,
220
+ journal,
221
+ config: context.config,
222
+ brief,
223
+ injected,
224
+ log
225
+ });
226
+ }
227
+
228
+ /**
229
+ * The register refusals that mean something specific to a developer, turned
230
+ * into a sentence and an exit code. Anything else is rethrown: an error this
231
+ * function does not recognise should surface as itself.
232
+ *
233
+ * @param {any} error
234
+ * @param {(message: string) => void} log
235
+ * @returns {number|null} an exit code, or null when the error is not one of these
236
+ */
237
+ export function explainRegisterRefusal(error, log) {
238
+ const code = error && error.code;
239
+ const body = (error && error.body) || {};
240
+ if (code === 'brief_required') {
241
+ log('The server refused this job because the game has no complete expectations brief. Run "ravensight-playtest brief" and try again.');
242
+ return EXIT.ENVIRONMENT;
243
+ }
244
+ if (code === 'insufficient_balance') {
245
+ log(`That is more than the balance covers: ${formatCents(body.shortfall_cents)} short. Top up at ${body.topup_url || 'ravensight.io'} and run this again.`);
246
+ return EXIT.ENVIRONMENT;
247
+ }
248
+ if (code === 'price_changed') {
249
+ log(`The price changed while you were deciding: it is now ${formatCents(body.estimate_cents)}. Nothing was charged. Run this again to confirm the new price.`);
250
+ return EXIT.FAILED;
251
+ }
252
+ if (code === 'cli_outdated') {
253
+ log(`This CLI is too old for that server: it needs ${body.min_version || 'a newer version'}. Upgrade with "npm i -g ravensight-playtest".`);
254
+ return EXIT.ENVIRONMENT;
255
+ }
256
+ if (code === 'too_many_active_jobs') {
257
+ log(`You already have ${body.max_active_jobs || 'the maximum number of'} jobs running on this game. Let one finish, or cancel it in the dashboard.`);
258
+ return EXIT.FAILED;
259
+ }
260
+ if (code === 'charge_in_progress') {
261
+ log('A charge for this job is still in flight, so the server cannot say yet whether it went through. Wait a moment and run the same command again: the idempotency key makes that safe.');
262
+ return EXIT.FAILED;
263
+ }
264
+ return null;
265
+ }
266
+
267
+ /**
268
+ * The client-side half of the server's persona ceiling.
269
+ *
270
+ * The server refuses a job over `max_personas` with `error: 'too_many_personas'`
271
+ * and this exact sentence (`personaListRefusal` in `src/routes/v1/playtest/jobs.js`).
272
+ * Checking it here too means the developer reads the same words before a single
273
+ * request goes out, rather than after paying for a round trip to hear it. There
274
+ * is still no product limit under that ceiling: a large job is allowed and
275
+ * simply may not finish inside its wall clock, which is what `wall_warning`
276
+ * says instead.
277
+ *
278
+ * @param {number} count
279
+ * @param {number|null|undefined} maxPersonas the pack's `routing.max_personas`
280
+ * @returns {string|null}
281
+ */
282
+ export function personaCountRefusal(count, maxPersonas) {
283
+ if (!Number.isInteger(maxPersonas) || count <= maxPersonas) return null;
284
+ return `A job may ask for at most ${maxPersonas} personas, and this one asks for ${count}.`;
285
+ }
286
+
287
+ /**
288
+ * Flags this release parses and then does not act on.
289
+ *
290
+ * Saying so is the point: a developer who passes `--budget-usd 1` and is not
291
+ * told it did nothing will believe they capped the spend.
292
+ *
293
+ * @param {Object} options
294
+ * @returns {Array<string>}
295
+ */
296
+ export function unusedFlagWarnings(options) {
297
+ const warnings = [];
298
+ if (options.budgetUsd !== undefined) {
299
+ warnings.push('--budget-usd is not applied by this release: the model budget is the server\'s, derived from what the job was charged, and it is enforced per call at the proxy.');
300
+ }
301
+ if (options.quality !== undefined) {
302
+ warnings.push('--quality is not applied by this release: the model tier is the server\'s decision, and the CLI is not allowed to name one.');
303
+ }
304
+ if (options.offlineUpload !== undefined) {
305
+ warnings.push('--offline-upload is not applied by this release: uploads already queue and can be drained later with "ravensight-playtest upload <job>".');
306
+ }
307
+ if (options.buildDir !== undefined) {
308
+ warnings.push('--build-dir is not applied by this release: serve the build and pass --build-url instead.');
309
+ }
310
+ return warnings;
311
+ }
312
+
313
+ /**
314
+ * Play every unfinished run of a registered job, aggregate, finish.
315
+ *
316
+ * Shared with `resume`, which arrives at exactly this point by a different
317
+ * route: it replays the register instead of making one.
318
+ *
319
+ * @returns {Promise<number>} an exit code
320
+ */
321
+ export async function playJob({
322
+ deps,
323
+ api,
324
+ apiUrl,
325
+ gameId,
326
+ jobId,
327
+ repoRoot,
328
+ runs,
329
+ pack,
330
+ personas,
331
+ driverName,
332
+ target,
333
+ options,
334
+ journal,
335
+ config,
336
+ brief = null,
337
+ injected = {},
338
+ log = () => {},
339
+ finish = true
340
+ }) {
341
+ const createClient = injected.createClient || createModelClient;
342
+ const makeDriver = injected.driverFactory || createDriver;
343
+ const token = injected.modelToken || api.token;
344
+ const client = createClient({ apiUrl, token });
345
+
346
+ const directory = jobDir(repoRoot, jobId);
347
+ const personaRuns = runs.filter(entry => entry.module === 'persona_playtest');
348
+ const aggregateRun = runs.find(entry => entry.module === 'aggregate_report') || null;
349
+
350
+ const results = [];
351
+ const queue = personaRuns.filter(entry => !stateFile.isRunDone(journal.runs[entry.run_id]));
352
+ const concurrency = Math.max(1, Math.min(Number(options.concurrency) || DEFAULT_CONCURRENCY, queue.length || 1));
353
+
354
+ /**
355
+ * Runs the process is in the middle of. A Ctrl-C has to move these to
356
+ * `interrupted` rather than leave them `playing`: the server's sweep would
357
+ * get there eventually, thirty minutes later, and until then the dashboard
358
+ * shows a run that is still going and `resume` has nothing to work from.
359
+ * `interrupted` is the one non-terminal state the transition table can come
360
+ * back out of, which is exactly what it is for.
361
+ */
362
+ const inFlight = new Map();
363
+ const interrupt = createInterruptHandler({ api, gameId, jobId, repoRoot, journal, inFlight, log, injected });
364
+
365
+ try {
366
+ await Promise.all(Array.from({ length: concurrency }, async () => {
367
+ while (queue.length > 0 && !interrupt.signalled) {
368
+ const entry = queue.shift();
369
+ const persona = personas.find(candidate => candidate.slug === entry.persona) || personas[0];
370
+ results.push(await playPersonaRun({
371
+ deps,
372
+ api,
373
+ apiUrl,
374
+ client,
375
+ config,
376
+ gameId,
377
+ jobId,
378
+ repoRoot,
379
+ entry,
380
+ persona,
381
+ pack,
382
+ driverName,
383
+ target,
384
+ options,
385
+ journal,
386
+ inFlight,
387
+ makeDriver,
388
+ log
389
+ }));
390
+ }
391
+ }));
392
+ } finally {
393
+ interrupt.release();
394
+ }
395
+
396
+ if (interrupt.signalled) {
397
+ await interrupt.settle();
398
+ log(`job ${jobId} was interrupted. Everything played so far is in ${directory}; "ravensight-playtest resume ${jobId}" carries on.`);
399
+ return EXIT.CANCELED;
400
+ }
401
+
402
+ for (const entry of personaRuns) {
403
+ const done = journal.runs[entry.run_id];
404
+ if (done && done.completed && !results.some(result => result.runId === entry.run_id)) {
405
+ results.push({ runId: entry.run_id, persona: entry.persona, state: done.state, quitReason: done.quit_reason, skipped: true });
406
+ }
407
+ }
408
+
409
+ await aggregatePhase({
410
+ deps,
411
+ api,
412
+ client,
413
+ gameId,
414
+ jobId,
415
+ repoRoot,
416
+ directory,
417
+ aggregateRun,
418
+ personaRuns,
419
+ pack,
420
+ brief,
421
+ driverName,
422
+ options,
423
+ journal,
424
+ log
425
+ });
426
+
427
+ const budgetHit = results.some(result => result.state === 'budget_exceeded');
428
+ const canceled = results.some(result => result.state === 'canceled');
429
+ const anySucceeded = results.some(result => result.state === 'succeeded');
430
+
431
+ if (finish) {
432
+ const finished = await api.jobs.finish(gameId, jobId, {
433
+ state: budgetHit && !anySucceeded ? 'budget_exceeded' : undefined,
434
+ reason: canceled ? 'cancelled by the developer' : ''
435
+ });
436
+ journal.finished = true;
437
+ await stateFile.save(repoRoot, journal);
438
+ log(`job ${jobId} ${finished.job ? finished.job.state : 'finished'}: refunded ${formatCents(finished.refunded_cents)}.`);
439
+ }
440
+ log(`Reports: ${directory}`);
441
+
442
+ if (canceled) return EXIT.CANCELED;
443
+ if (budgetHit) return EXIT.BUDGET;
444
+ return anySucceeded ? EXIT.OK : EXIT.FAILED;
445
+ }
446
+
447
+ /**
448
+ * One persona, start to finish: play, upload, complete.
449
+ *
450
+ * @returns {Promise<{runId: string, persona: string, state: string, quitReason: string}>}
451
+ */
452
+ export async function playPersonaRun({
453
+ deps,
454
+ api,
455
+ apiUrl,
456
+ client,
457
+ config,
458
+ gameId,
459
+ jobId,
460
+ repoRoot,
461
+ entry,
462
+ persona,
463
+ pack,
464
+ driverName,
465
+ target,
466
+ options,
467
+ journal,
468
+ inFlight = null,
469
+ makeDriver,
470
+ log
471
+ }) {
472
+ const runId = entry.run_id;
473
+ const directory = runDirFor(repoRoot, jobId, runId);
474
+ await mkdir(directory, { recursive: true });
475
+ if (inFlight) inFlight.set(runId, { state: 'queued' });
476
+
477
+ const skill = skillFor(pack, 'persona_playtest');
478
+ const maxActions = resolveMaxActions(options, persona, pack);
479
+ // Headless is right for a browser and wrong for Godot: a headless Godot
480
+ // routes no GUI input, so it is a smoke-test mode rather than a way to play.
481
+ const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
482
+ const driver = await makeDriver(driverName, { headless });
483
+ // The ledger is created here rather than inside the loop so the heartbeat
484
+ // can feed it the server's own figure for what this run has spent. Prices
485
+ // are server side only, so that answer is the only real one.
486
+ const ledger = createUsageLedger();
487
+ const heartbeat = createHeartbeat({
488
+ api,
489
+ gameId,
490
+ jobId,
491
+ runId,
492
+ onCancel: () => log(`run ${runId}: the job was cancelled; stopping after this turn.`),
493
+ onUsage: usage => ledger.recordServerUsage(usage),
494
+ // Every beat writes the checkpoint into the journal. That is what lets a
495
+ // resume tell a run that got three actions in from one that got thirty:
496
+ // without it the journal only ever says 0 until the run ends, and a
497
+ // resume has no way to prefer finalising over replaying.
498
+ onBeat: async beat => {
499
+ if (!beat) return;
500
+ try {
501
+ await stateFile.patchRun(repoRoot, journal, runId, {
502
+ state: 'playing',
503
+ checkpoint_step: Number(beat.checkpointStep) || 0,
504
+ actions_taken: Number(beat.actionsTaken) || 0
505
+ });
506
+ } catch {
507
+ // A journal write that fails must not kill a paid run; the next beat
508
+ // tries again five seconds later.
509
+ }
510
+ },
511
+ onError: () => {}
512
+ });
513
+ heartbeat.start();
514
+
515
+ let outcome;
516
+ try {
517
+ if (inFlight) inFlight.set(runId, { state: 'launching' });
518
+ outcome = await runPersona({
519
+ client,
520
+ api,
521
+ gameId,
522
+ jobId,
523
+ runId,
524
+ persona,
525
+ driver,
526
+ driverName,
527
+ target,
528
+ runDirectory: directory,
529
+ skill,
530
+ brief: pack.brief || null,
531
+ intentNotes: pack.intent_notes || [],
532
+ schemas: pack.schemas || [],
533
+ maxActions,
534
+ heartbeat,
535
+ ledger,
536
+ recordVideo: Boolean(options.uploadVideo),
537
+ log: message => log(`run ${runId}: ${message}`)
538
+ });
539
+ } catch (error) {
540
+ heartbeat.stop();
541
+ if (inFlight) inFlight.delete(runId);
542
+ const failed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
543
+ await stateFile.patchRun(repoRoot, journal, runId, { state: failed.state, error: String(error && error.message), quit_reason: failed.quitReason });
544
+ await deliver({ deps, api, gameId, jobId, runId, directory, outcome: failed, options, log });
545
+ log(`run ${runId} (${persona.slug}) failed: ${String(error && error.message)}`);
546
+ return { runId, persona: persona.slug, state: failed.state, quitReason: failed.quitReason, error };
547
+ }
548
+ heartbeat.stop();
549
+ if (inFlight) inFlight.delete(runId);
550
+
551
+ await stateFile.patchRun(repoRoot, journal, runId, {
552
+ state: outcome.state,
553
+ actions_taken: outcome.actionsTaken,
554
+ quit_reason: outcome.quitReason,
555
+ report_written: Boolean(outcome.report),
556
+ budget_warned: outcome.budgetWarned
557
+ });
558
+
559
+ await deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot });
560
+
561
+ if (outcome.report) {
562
+ const emitted = await emitRunEvents({
563
+ apiUrl,
564
+ ingestKey: (config && config.ingest_key) || process.env.RAVENSIGHT_INGEST_KEY || null,
565
+ playtestToken: entry.playtest_token,
566
+ context: { jobId, runId, persona: persona.slug, gameVersion: (config && config.game_version) || 'unknown' },
567
+ report: outcome.report,
568
+ driver: driverName,
569
+ log: message => log(`run ${runId}: ${message}`)
570
+ });
571
+ if (emitted.reason) log(`run ${runId}: ${emitted.reason}`);
572
+ }
573
+
574
+ log(`run ${runId} (${persona.slug}) ${outcome.state}: ${outcome.actionsTaken} actions, ${outcome.quitReason}.`);
575
+ return { runId, persona: persona.slug, state: outcome.state, quitReason: outcome.quitReason };
576
+ }
577
+
578
+ /**
579
+ * Upload the run directory and complete the run, through cli-core's
580
+ * `finalizeRun` so the 422 `upload_unverified` repair pass lives in one place.
581
+ *
582
+ * A budget-capped or cancelled run sends no `report`: the route accepts that,
583
+ * and it is the honest answer. Inventing a sentiment score nobody gave so a
584
+ * placeholder report would validate would put a fabricated verdict in front of
585
+ * the developer.
586
+ *
587
+ * A refused delivery does not take the job down. `finish` still settles it, the
588
+ * artifacts are on disk, and `resume` can retry.
589
+ */
590
+ async function deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot }) {
591
+ const body = {
592
+ state: outcome.state,
593
+ reason: outcome.quitDetail || '',
594
+ actions_taken: outcome.actionsTaken,
595
+ quit_reason: outcome.quitReason
596
+ };
597
+ if (outcome.report) body.report = outcome.report;
598
+ // `report_md` is what the server lints for its four required sections. Not
599
+ // sending it means `report_lint` is silently empty on every run, which reads
600
+ // as "the markdown is fine" rather than "nobody looked".
601
+ if (outcome.reportMarkdown) body.report_md = outcome.reportMarkdown;
602
+
603
+ if (options.dryUpload) {
604
+ const listing = await listUploadable(directory, options);
605
+ log(`run ${runId}: --dry-upload, nothing was sent. Would upload:\n${listing.map(item => ` ${item.path} (${item.size} bytes)`).join('\n')}`);
606
+ return null;
607
+ }
608
+
609
+ // The server's table allows `uploading` only from `reporting`, so asking for
610
+ // it from `playing` (a run that died before it wrote anything) is a 409 with
611
+ // a confusing message. The run says where it got to; believe it.
612
+ if (outcome.reachedState === 'reporting') {
613
+ try {
614
+ await api.runs.transition(gameId, jobId, runId, 'uploading', { reason: '' });
615
+ } catch (error) {
616
+ // A refused transition is not worth abandoning the artifacts over: the
617
+ // upload and the complete below are what actually matter.
618
+ log(`run ${runId}: could not move to uploading (${String(error && error.message)}).`);
619
+ }
620
+ }
621
+
622
+ try {
623
+ const answer = await deps.finalizeRun(directory, {
624
+ api,
625
+ gameId,
626
+ jobId,
627
+ runId,
628
+ includeVideo: Boolean(options.uploadVideo),
629
+ includeTranscript: Boolean(options.uploadTranscript),
630
+ body
631
+ });
632
+ if (journal) await stateFile.patchRun(repoRoot, journal, runId, { uploaded: true, completed: true });
633
+ if (answer && answer.dropped_findings > 0) {
634
+ log(`run ${runId}: the server dropped ${answer.dropped_findings} finding(s) with no usable evidence.`);
635
+ }
636
+ if (answer && Array.isArray(answer.report_lint) && answer.report_lint.length > 0) {
637
+ log(`run ${runId}: report lint: ${answer.report_lint.join('; ')}`);
638
+ }
639
+ return answer;
640
+ } catch (error) {
641
+ log(`run ${runId}: upload or complete was refused (${String(error && error.message)}). The artifacts are in the run directory and "ravensight-playtest resume" can retry it.`);
642
+ return null;
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Build the aggregate, and drive the `aggregate_report` run's own state machine
648
+ * while doing it.
649
+ *
650
+ * That run is what makes the judged passes possible: `aggregate.*` steps are
651
+ * only spendable from a run whose module is `aggregate_report`, so it is
652
+ * launched, played, and completed like any other run rather than treated as
653
+ * bookkeeping. Without one (a job registered before the module existed) the
654
+ * aggregate is still built, locally, and says so.
655
+ */
656
+ async function aggregatePhase({
657
+ deps,
658
+ api,
659
+ client,
660
+ gameId,
661
+ jobId,
662
+ repoRoot,
663
+ directory,
664
+ aggregateRun,
665
+ personaRuns,
666
+ pack,
667
+ brief,
668
+ driverName,
669
+ options,
670
+ journal,
671
+ log
672
+ }) {
673
+ const runId = aggregateRun ? aggregateRun.run_id : null;
674
+ const ledger = createUsageLedger();
675
+ const heartbeat = runId
676
+ ? createHeartbeat({ api, gameId, jobId, runId, onUsage: usage => ledger.recordServerUsage(usage), onError: () => {} })
677
+ : null;
678
+
679
+ let reached = 'queued';
680
+ const advance = async state => {
681
+ if (!runId) return;
682
+ try {
683
+ await api.runs.transition(gameId, jobId, runId, state, { reason: '' });
684
+ reached = state;
685
+ } catch (error) {
686
+ log(`aggregate run ${runId}: could not move to ${state} (${String(error && error.message)}).`);
687
+ }
688
+ };
689
+
690
+ await advance('launching');
691
+ await advance('playing');
692
+ if (heartbeat) heartbeat.start();
693
+
694
+ // The aggregate reads reports off disk, so a resumed job whose earlier runs
695
+ // were played by a previous process is covered too.
696
+ const reports = await loadRunReports(repoRoot, jobId, personaRuns);
697
+ let aggregate;
698
+ try {
699
+ aggregate = await buildAggregate({
700
+ client,
701
+ jobId,
702
+ gameId,
703
+ aggregateRunId: runId,
704
+ runs: reports,
705
+ brief,
706
+ capabilityNotes: capabilityNotesFor(pack, driverName),
707
+ intentNotes: pack.intent_notes || [],
708
+ ledger,
709
+ log
710
+ });
711
+ } finally {
712
+ if (heartbeat) {
713
+ await heartbeat.beat();
714
+ heartbeat.stop();
715
+ }
716
+ }
717
+
718
+ await advance('reporting');
719
+ await writeAggregate(directory, aggregate);
720
+ journal.aggregate = {
721
+ state: 'written',
722
+ generated_from: aggregate.json.provenance.generated_from,
723
+ run_id: runId
724
+ };
725
+ await stateFile.save(repoRoot, journal);
726
+ if (!runId) {
727
+ log('The aggregate was built locally: this job has no aggregate_report run to spend a model turn from, so the dedup and judgement passes were skipped.');
728
+ }
729
+
730
+ const published = await publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log });
731
+ if (published === 'finished') reached = 'succeeded';
732
+ else if (published) await advance('uploading');
733
+
734
+ if (runId && reached !== 'succeeded') {
735
+ try {
736
+ await api.runs.complete(gameId, jobId, runId, {
737
+ state: reached === 'uploading' ? 'succeeded' : 'failed',
738
+ actions_taken: 0,
739
+ quit_reason: reached === 'uploading' ? 'goal_reached' : 'error',
740
+ reason: reached === 'uploading' ? '' : 'the aggregate was not published'
741
+ });
742
+ } catch (error) {
743
+ log(`aggregate run ${runId}: complete was refused (${String(error && error.message)}).`);
744
+ }
745
+ }
746
+ return aggregate;
747
+ }
748
+
749
+ /**
750
+ * Publish the aggregate and tell the server it is there.
751
+ *
752
+ * `POST /jobs/:jobId/aggregate/complete` verifies the markdown and the JSON
753
+ * against the sizes and sha256s it is given, so all four come from the bytes
754
+ * that were uploaded.
755
+ *
756
+ * @returns {Promise<false|'published'|'finished'>} false when nothing was
757
+ * sent, 'published' when the server has it, 'finished' when it also closed
758
+ * the aggregate run
759
+ */
760
+ async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log }) {
761
+ if (options.dryUpload) {
762
+ log('The aggregate was not published: --dry-upload.');
763
+ return false;
764
+ }
765
+ if (!deps.publishJobFiles) {
766
+ log('The aggregate stayed local: this build has no job-level upload.');
767
+ return false;
768
+ }
769
+ try {
770
+ const digests = await deps.publishJobFiles({
771
+ api,
772
+ gameId,
773
+ jobId,
774
+ dir: directory,
775
+ files: [JOB_FILES.aggregateMd, JOB_FILES.aggregateJson],
776
+ repoRoot
777
+ });
778
+ const markdown = digests[JOB_FILES.aggregateMd];
779
+ const json = digests[JOB_FILES.aggregateJson];
780
+ // Both halves, not just the markdown: the server verifies each one it
781
+ // will later serve, and a missing pair reads as "nothing declared" and
782
+ // refuses the whole aggregate (found in the first production run).
783
+ const answer = await api.jobs.aggregateComplete(gameId, jobId, {
784
+ md_size: markdown.size,
785
+ md_sha256: markdown.sha256,
786
+ json_size: json.size,
787
+ json_sha256: json.sha256
788
+ });
789
+ // The server finishes the aggregate run itself when it accepts the
790
+ // report, and says so in `run`. A caller that then tries to move the
791
+ // run to `uploading` and complete it gets two refusals for a run that
792
+ // is already done.
793
+ const run = answer && answer.run;
794
+ return run && run.state === 'succeeded' ? 'finished' : 'published';
795
+ } catch (error) {
796
+ log(`The aggregate could not be published (${String(error && error.message)}). It is on disk in ${directory}.`);
797
+ return false;
798
+ }
799
+ }
800
+
801
+ /**
802
+ * The Ctrl-C path.
803
+ *
804
+ * One handler for the whole job rather than one per run, registered once and
805
+ * removed in a `finally`, because a handler left behind by a command that has
806
+ * returned is a handler that fires for the next one. A second signal is left to
807
+ * the default behaviour: someone pressing Ctrl-C twice means it now, and
808
+ * holding the process open to be tidy would be the wrong answer.
809
+ *
810
+ * @returns {{signalled: boolean, settle: () => Promise<void>, release: () => void}}
811
+ */
812
+ export function createInterruptHandler({ api, gameId, jobId, repoRoot, journal, inFlight, log, injected = {} }) {
813
+ const signals = injected.signals || ['SIGINT', 'SIGTERM'];
814
+ const target = injected.process || process;
815
+ const state = { signalled: false, signal: null };
816
+
817
+ const onSignal = signal => {
818
+ if (state.signalled) return;
819
+ state.signalled = true;
820
+ state.signal = signal;
821
+ log(`\n${signal}: finishing the turn in flight, then stopping. Nothing is lost; "ravensight-playtest resume ${jobId}" carries on.`);
822
+ // Hand the signal back to the default behaviour, so a second press kills
823
+ // the process rather than being swallowed by a handler that is now done.
824
+ for (const name of signals) target.removeListener(name, onSignal);
825
+ };
826
+
827
+ for (const name of signals) target.on(name, onSignal);
828
+
829
+ return {
830
+ get signalled() {
831
+ return state.signalled;
832
+ },
833
+
834
+ /**
835
+ * Move every run that was mid-flight to `interrupted` and flush the
836
+ * journal, so the server and the file on disk agree with each other.
837
+ */
838
+ async settle() {
839
+ for (const [runId] of inFlight) {
840
+ try {
841
+ await api.runs.transition(gameId, jobId, runId, 'interrupted', { reason: `interrupted by ${state.signal || 'a signal'}` });
842
+ } catch (error) {
843
+ log(`run ${runId}: could not be marked interrupted (${String(error && error.message)}). The server's sweep will do it.`);
844
+ }
845
+ try {
846
+ await stateFile.patchRun(repoRoot, journal, runId, { state: 'interrupted' });
847
+ } catch {
848
+ // The journal write is best effort at this point; the server's own
849
+ // run state is what `resume` reconciles against anyway.
850
+ }
851
+ }
852
+ inFlight.clear();
853
+ await stateFile.save(repoRoot, journal);
854
+ },
855
+
856
+ release() {
857
+ for (const name of signals) target.removeListener(name, onSignal);
858
+ }
859
+ };
860
+ }
861
+
862
+ /**
863
+ * Resolve the context, the game and the credential, or answer with an exit
864
+ * code. Shared with `profile`.
865
+ *
866
+ * @returns {Promise<Object>}
867
+ */
868
+ export async function resolveSetup(deps, options, log) {
869
+ let context;
870
+ try {
871
+ context = await deps.createContext({ apiUrl: options.apiUrl, repoRoot: options.repo, requireAuth: true });
872
+ } catch (error) {
873
+ log(`You are not signed in. Run "ravensight-playtest login" first. (${String(error && error.message)})`);
874
+ return { code: EXIT.AUTH };
875
+ }
876
+ if (!context.token && !options.token) {
877
+ log('You are not signed in. Run "ravensight-playtest login" first.');
878
+ return { code: EXIT.AUTH };
879
+ }
880
+
881
+ let gameId;
882
+ try {
883
+ gameId = deps.resolveGameId
884
+ ? deps.resolveGameId(options, context.config)
885
+ : (options.game || (context.config && context.config.game_id));
886
+ } catch (error) {
887
+ log(String(error && error.message));
888
+ return { code: EXIT.ENVIRONMENT };
889
+ }
890
+ if (!gameId) {
891
+ log('This repository is not linked to a game. Run "ravensight-playtest init --game <gameId>" first.');
892
+ return { code: EXIT.ENVIRONMENT };
893
+ }
894
+
895
+ return {
896
+ context,
897
+ api: context.api,
898
+ gameId,
899
+ apiUrl: context.apiUrl,
900
+ repoRoot: path.resolve(context.repoRoot || options.repo || process.cwd()),
901
+ cliVersion: deps.cliVersion || context.api.cliVersion || '0.0.0'
902
+ };
903
+ }
904
+
905
+ async function loadRunReports(repoRoot, jobId, personaRuns) {
906
+ const out = [];
907
+ for (const entry of personaRuns) {
908
+ const file = path.join(runDirFor(repoRoot, jobId, entry.run_id), RUN_FILES.reportJson);
909
+ try {
910
+ out.push({ runId: entry.run_id, persona: entry.persona, state: 'succeeded', report: JSON.parse(await readFile(file, 'utf8')) });
911
+ } catch {
912
+ out.push({ runId: entry.run_id, persona: entry.persona, state: 'failed', report: null });
913
+ }
914
+ }
915
+ return out;
916
+ }
917
+
918
+ async function listUploadable(directory, options) {
919
+ const names = [RUN_FILES.reportMd, RUN_FILES.reportJson, RUN_FILES.usageJson];
920
+ if (options.uploadTranscript) names.push(RUN_FILES.transcript);
921
+ if (options.uploadVideo) names.push(RUN_FILES.video);
922
+ const out = [];
923
+ for (const name of names) {
924
+ try {
925
+ const info = await stat(path.join(directory, name));
926
+ out.push({ path: name, size: info.size });
927
+ } catch {
928
+ // Not every run produces every file; a missing one simply is not
929
+ // uploaded, and saying so is the point of --dry-upload.
930
+ }
931
+ }
932
+ return out;
933
+ }
934
+
935
+ /**
936
+ * What this job could NOT test, which is what stops a reader over-trusting it.
937
+ */
938
+ export function capabilityNotesFor(pack, driverName) {
939
+ const notes = [];
940
+ if (driverName === 'playwright_web') {
941
+ notes.push('Played through a web build with keyboard and mouse at human speed. Reflex, aim and physics feel were not tested.');
942
+ }
943
+ if (!pack.code_brief) notes.push('No codebase model was available, so no finding cites a source path.');
944
+ return notes;
945
+ }
946
+
947
+ export function skillFor(pack, moduleName) {
948
+ const entry = (pack.skills || []).find(skill => skill.module === moduleName);
949
+ if (!entry) throw new Error(`the content pack has no ${moduleName} skill; run a pack sync`);
950
+ return entry.content;
951
+ }
952
+
953
+ export function resolvePersonas(options, config, pack) {
954
+ if (Array.isArray(options.personas) && options.personas.length > 0) return options.personas;
955
+ if (typeof options.personas === 'string' && options.personas.trim()) {
956
+ return options.personas.split(',').map(slug => slug.trim()).filter(Boolean);
957
+ }
958
+ if (config && Array.isArray(config.personas) && config.personas.length > 0) return config.personas;
959
+ const available = (pack.personas || []).map(persona => persona.slug);
960
+ const defaults = DEFAULT_PERSONAS.filter(slug => available.includes(slug));
961
+ return defaults.length > 0 ? defaults : available.slice(0, 2);
962
+ }
963
+
964
+ /**
965
+ * The action budget is the smallest of the three ceilings that exist: what the
966
+ * developer asked for, the persona's own patience, and what routing.json allows
967
+ * per run (`max_actions`, which arrives in the pack's client routing subset).
968
+ */
969
+ export function resolveMaxActions(options, persona, pack) {
970
+ const candidates = [
971
+ Number.isInteger(options.maxActions) ? options.maxActions : null,
972
+ Number(persona && persona.patience_actions) || null,
973
+ Number(pack && pack.routing && pack.routing.max_actions) || null
974
+ ].filter(value => Number.isFinite(value) && value > 0);
975
+ return candidates.length > 0 ? Math.min(...candidates) : 25;
976
+ }
977
+
978
+ export function resolveTarget(options, config, driverName) {
979
+ const build = (config && config.build) || {};
980
+ if (driverName === 'playwright_web') return options.buildUrl || (build.kind === 'url' ? build.ref : null) || null;
981
+ if (driverName === 'godot_driver') return options.godotProject || (build.kind === 'godot_project' ? build.ref : null) || null;
982
+ return options.cli || null;
983
+ }
984
+
985
+ function logger(deps) {
986
+ if (deps && deps.ui && typeof deps.ui.info === 'function') return message => deps.ui.info(message);
987
+ return message => console.log(message);
988
+ }
989
+
990
+ export function formatCents(cents) {
991
+ if (!Number.isFinite(Number(cents))) return 'unknown';
992
+ return `$${(Number(cents) / 100).toFixed(2)}`;
993
+ }
994
+
995
+ /**
996
+ * Map a thrown error onto an exit code, for whoever wires this into commander.
997
+ *
998
+ * @param {any} error
999
+ * @returns {number}
1000
+ */
1001
+ export function exitCodeFor(error) {
1002
+ if (error instanceof ModelUnavailableError) return EXIT.MODEL;
1003
+ if (error instanceof ProxyError) {
1004
+ if (error.isBudget) return EXIT.BUDGET;
1005
+ if (error.isClosed) return EXIT.CANCELED;
1006
+ if (error.isAuth) return EXIT.AUTH;
1007
+ // The kill switch, or a server with no platform key. Nothing the developer
1008
+ // did wrong and nothing a retry fixes, but it IS a model-access failure,
1009
+ // which is what 5 means.
1010
+ if (error.status === 503) return EXIT.MODEL;
1011
+ }
1012
+ return EXIT.FAILED;
1013
+ }
1014
+
1015
+ export default runCommand;