ravensight-playtest 0.7.0 → 0.7.2

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 CHANGED
@@ -1,9 +1,47 @@
1
1
  # ravensight-playtest
2
2
 
3
- Hosted comparisons use a separate command from the local `run`:
3
+ Hosted playtests run your uploaded build on our machines instead of yours. One
4
+ persona plays it twice, once with Standard and once with Jev, and the dashboard
5
+ gets a report for each and a comparison. They use a separate command from the
6
+ local `run`:
4
7
 
5
8
  ```sh
6
- ravensight-playtest hosted quote --game GAME --build BUILD --profile PROFILE --persona curious-kid --video --transcript --output hosted-quote.json
9
+ ravensight-playtest hosted run --wait
10
+ ravensight-playtest hosted status ha_...
11
+ ravensight-playtest hosted cancel ha_...
12
+ ```
13
+
14
+ `hosted run` uses the game this directory is linked to (or `--game`), picks the
15
+ newest build that passed its static archive check on a ready profile (or the one
16
+ named by `--build`), asks the server for a quote and prints it: each unit and
17
+ its price, the hosted compute for each mode, the total and what is due now from
18
+ prepaid funds, the expiry, and the statements you accept with it (the refund
19
+ rules for a failed build and for cancellation, no network access for the game,
20
+ the separate validation hold). It asks before it charges. `--yes` answers for
21
+ you, a run with no terminal and no `--yes` is a no, and `--json` needs `--yes`.
22
+ A balance that does not meet the quote's funding requirement stops before the
23
+ question, and nothing is charged.
24
+
25
+ | Flag | Effect |
26
+ |---|---|
27
+ | `--build <id>` | Use this build instead of the newest ready one. |
28
+ | `--persona <slug>` | The persona for both runs. Default `curious-kid`. |
29
+ | `--video`, `--transcript` | Save video or transcripts of both sessions in their reports. Priced in the quote. |
30
+ | `--wait` | Stay until the run finishes, printing each phase change, then each mode's outcome. |
31
+ | `--game <id>` | Override the linked game. |
32
+
33
+ The phases are the dashboard's: queued, provisioning, running, capturing,
34
+ analyzing, then report ready, failed or canceled. `hosted status <id>` prints
35
+ the current one (`--wait` to follow it) with each mode's state and findings and
36
+ the dashboard link to the report. With `--wait`, a failed run exits 1 and a
37
+ canceled one exits 10. `hosted cancel <id>` asks first, then requests
38
+ cancellation: work already done follows the accepted policy and unstarted work
39
+ is refunded during settlement.
40
+
41
+ To review a quote before paying, save it and confirm it later:
42
+
43
+ ```sh
44
+ ravensight-playtest hosted quote --game GAME --build BUILD --profile PROFILE --persona curious-kid --video --output hosted-quote.json
7
45
  ravensight-playtest hosted run hosted-quote.json
8
46
  ```
9
47
 
@@ -11,6 +49,7 @@ The quote names the full Standard then Jev price, prepaid amount, evidence
11
49
  options, expiry and refund rules. It charges nothing. `hosted run` confirms
12
50
  that saved quote before one admission request. Its durable journal blocks an
13
51
  automatic second POST after an uncertain outcome; preserve it for reconciliation.
52
+ Both forms of `hosted run` keep that journal under `.ravensight/hosted-runs/`.
14
53
  A charged-blocked acknowledgement does not prove gameplay or a report. This
15
54
  command does not top up funds, change local run/report files or ask for model
16
55
  proxy permission on the CLI credential.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Local AI playtests and seeded game simulations with reports in Ravensight.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ravensight.io/docs/#playtest",
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The scopes a CLI token carries, checked before anything is charged.
3
+ *
4
+ * A token's scopes are fixed when it is approved. A persona run needs
5
+ * `playtest:model` to play and `playtest:upload` to hand in its report, and
6
+ * the server only checks each one when the run reaches it: in October a token
7
+ * without `playtest:upload` registered a paid job, played it, and was refused
8
+ * at the upload, so the job came back with no report. `whoami` answers with
9
+ * the token's scopes, so `run` asks first and refuses locally.
10
+ */
11
+
12
+ /** What a persona run (`run`, and `fix`'s verify replay) needs. */
13
+ export const RUN_SCOPES = Object.freeze(['playtest:write', 'playtest:model', 'playtest:upload']);
14
+
15
+ /**
16
+ * The required scopes a token lacks, or null when its scopes are not known
17
+ * (an older server's `whoami` does not list them).
18
+ *
19
+ * @param {unknown} scopes the `scopes` field of a whoami answer
20
+ * @param {ReadonlyArray<string>} [required]
21
+ * @returns {Array<string>|null}
22
+ */
23
+ export function missingScopes(scopes, required = RUN_SCOPES) {
24
+ if (!Array.isArray(scopes)) return null;
25
+ return required.filter(scope => !scopes.includes(scope));
26
+ }
27
+
28
+ /**
29
+ * The refusal for a token that lacks what a run needs.
30
+ *
31
+ * @param {Array<string>} missing
32
+ * @returns {string}
33
+ */
34
+ export function missingScopeMessage(missing) {
35
+ return `This login cannot run a playtest: its token does not have ${missing.join(', ')}. Log in again with "ravensight-playtest login" and approve the full set of playtest permissions. Nothing was registered or charged.`;
36
+ }
package/src/cli.js CHANGED
@@ -153,7 +153,7 @@ function withoutFirst(values, value) {
153
153
  export function buildProgram() {
154
154
  const program = new Command();
155
155
  const slot = { code: ExitCode.OK };
156
- const hosted = common(program.command('hosted').description('quote and admit a server-hosted Standard/Jev comparison'));
156
+ const hosted = common(program.command('hosted').description('run a server-hosted Standard and Jev playtest: quote, run, status and cancel'));
157
157
  hosted.command('quote')
158
158
  .description('save a full hosted quote without charging or admitting a run')
159
159
  .requiredOption('--build <id>', 'an existing validated hosted build')
@@ -163,18 +163,43 @@ export function buildProgram() {
163
163
  .option('--game <id>', 'game id, overriding this repo’s configuration')
164
164
  .addOption(new Option('--standard-provider <name>', 'explicit Standard provider; unavailable choices are refused').choices(['anthropic', 'openai']))
165
165
  .option('--video', 'include hosted gameplay video in this quote')
166
- .option('--transcript', 'include the hosted transcript in this quote')
167
166
  .action(capture(slot, async (options, command) => {
168
167
  const { hostedQuote } = await import('./commands/hosted.js');
169
168
  return hostedQuote(flagsFrom(command.optsWithGlobals(), { build: options.build, profile: options.profile,
170
169
  persona: options.persona, output: options.output, game: options.game, standardProvider: options.standardProvider,
171
- video: options.video, transcript: options.transcript }));
170
+ video: options.video }));
172
171
  }));
173
- hosted.command('run <quote-file>')
174
- .description('confirm the full saved quote and request one hosted admission; never retry an unknown outcome')
175
- .action(capture(slot, async (filename, _options, command) => {
176
- const { hostedRun } = await import('./commands/hosted.js');
177
- return hostedRun(filename, flagsFrom(command.optsWithGlobals()));
172
+ common(hosted.command('run [quote-file]'))
173
+ .description('quote the newest ready build (or --build), confirm the price, and start one hosted Standard and Jev run; with a saved quote file, confirm that quote instead. Never retries an unknown outcome')
174
+ .option('--build <id>', 'a build that passed its static archive check (default: the newest one)')
175
+ .option('--persona <slug>', 'the persona that plays both runs (default: curious-kid)')
176
+ .option('--game <id>', 'game id, overriding this repo’s configuration')
177
+ .option('--video', 'save video of both game sessions in their reports')
178
+ .option('--wait', 'stay until the report is ready, printing each phase change')
179
+ .action(capture(slot, async (filename, options, command) => {
180
+ const { hostedRun, hostedRunDirect } = await import('./commands/hosted.js');
181
+ if (filename) {
182
+ const extra = ['build', 'persona', 'game', 'video', 'wait'].filter(name => options[name] !== undefined);
183
+ if (extra.length) throw new CliError(`A saved quote already fixes its build, persona and options. Drop --${extra.join(', --')}, or run "hosted run" without a quote file.`);
184
+ return hostedRun(filename, flagsFrom(command.optsWithGlobals()));
185
+ }
186
+ return hostedRunDirect(flagsFrom(command.optsWithGlobals(), { build: options.build, persona: options.persona, game: options.game,
187
+ video: options.video, wait: options.wait }));
188
+ }));
189
+ common(hosted.command('status <run-id>'))
190
+ .description('show the phase of one hosted run (queued, provisioning, running, capturing, analyzing, report ready, failed or canceled), each mode and the report link')
191
+ .option('--game <id>', 'game id, overriding this repo’s configuration')
192
+ .option('--wait', 'stay until the run finishes, printing each phase change')
193
+ .action(capture(slot, async (runId, options, command) => {
194
+ const { hostedStatus } = await import('./commands/hosted.js');
195
+ return hostedStatus(runId, flagsFrom(command.optsWithGlobals(), { game: options.game, wait: options.wait }));
196
+ }));
197
+ common(hosted.command('cancel <run-id>'))
198
+ .description('request cancellation of one hosted run; unstarted work is refunded during settlement')
199
+ .option('--game <id>', 'game id, overriding this repo’s configuration')
200
+ .action(capture(slot, async (runId, options, command) => {
201
+ const { hostedCancel } = await import('./commands/hosted.js');
202
+ return hostedCancel(runId, flagsFrom(command.optsWithGlobals(), { game: options.game }));
178
203
  }));
179
204
  const build = common(program.command('build').description('check or upload a self-contained hosted game archive'));
180
205
  build.command('preflight <archive>')
@@ -3,6 +3,7 @@ import { ApiError } from '../api/errors.js';
3
3
  import { resolveApiUrl, paths } from '../paths.js';
4
4
  import { resolveToken, looksLikeIngestKey, looksLikeMcpToken } from '../auth/session.js';
5
5
  import { backendName } from '../auth/keychain.js';
6
+ import { missingScopes } from '../auth/scopes.js';
6
7
  import { loadConfig } from '../config.js';
7
8
  import { packs } from '../packs/index.js';
8
9
  import { detectFfmpeg, freeBytes, recommendConcurrency } from '../detect.js';
@@ -421,7 +422,10 @@ export async function runChecks(flags = {}, deps = {}) {
421
422
  facts.login.valid = true;
422
423
  facts.login.games = games.map(game => ({ gameId: game.gameId, name: game.name || null }));
423
424
  const who = whoami.user.email || whoami.user.name || whoami.user.id;
424
- rows.push(row(
425
+ const missing = missingScopes(whoami.scopes);
426
+ if (missing && missing.length > 0) {
427
+ rows.push(row('login', 'fail', `${who}, valid, but the token does not have ${missing.join(', ')}, so a playtest run would be refused`, 'ravensight-playtest login'));
428
+ } else rows.push(row(
425
429
  'login',
426
430
  games.length > 0 ? 'pass' : 'warn',
427
431
  games.length > 0
@@ -363,7 +363,11 @@ export function fakeCore({
363
363
  runs: {
364
364
  async heartbeat(gameId, jobId, runId, options) {
365
365
  record('heartbeat', { runId, options });
366
- return { run: { usage: { total_usd: 0.11 } }, cancel_requested: false };
366
+ // calls: 0, so the fake spend never prices a unit: $0.11 over a few
367
+ // ten-token scripted turns would make the report reserve
368
+ // (../run/budgetReserve.js) stop play after one turn in tests about
369
+ // other things. The reserve has its own tests.
370
+ return { run: { usage: { total_usd: 0.11, calls: 0 } }, cancel_requested: false };
367
371
  },
368
372
  async transition(gameId, jobId, runId, state, options) {
369
373
  record('transition', { runId, state, options });
@@ -13,6 +13,7 @@ import { createHeartbeat } from '../run/heartbeat.js';
13
13
  import { createModelClient } from '../run/model.js';
14
14
  import { jobDir, runDir as runDirFor, RUN_FILES, screenshotsDir } from '../run/paths.js';
15
15
  import { runPersona } from '../run/personaLoop.js';
16
+ import { runModelBudgetUsd } from '../run/budgetReserve.js';
16
17
  import { replayTrace } from '../run/replay.js';
17
18
  import { loadTrace, replayReport, replaySentence } from './replay.js';
18
19
  import { createRepoTools, PROFILE_TOOLS, WRITE_TOOLS } from '../run/repoTools.js';
@@ -1202,6 +1203,7 @@ export async function runVerify({
1202
1203
  outcome = await runPersona({
1203
1204
  client,
1204
1205
  api,
1206
+ modelBudgetUsd: runModelBudgetUsd(entry),
1205
1207
  gameId,
1206
1208
  jobId,
1207
1209
  runId: entry.run_id,
@@ -3,7 +3,11 @@ import { resolveGameId } from '../config.js';
3
3
  import { withAuthHandling } from '../auth/session.js';
4
4
  import { CliError, ExitCode } from '../errors.js';
5
5
  import { ui } from '../ui/index.js';
6
- import { quoteHostedRun, runHostedQuote, hostedQuoteLines } from '../hosted/recordedRun.js';
6
+ import { quoteHostedRun, runHostedQuote, hostedQuoteLines, prepareHostedRun, admitHostedOffer, confirmQuestion,
7
+ hostedConsentLines, fundingShortfall, readHostedRun, cancelHostedRun } from '../hosted/recordedRun.js';
8
+ import { hostedPlaytestStatus, modeOutcomes, modeLine, TERMINAL_PHASES } from '../hosted/status.js';
9
+ import { jobLink } from '../dashboard.js';
10
+ import { DEFAULT_PERSONAS } from '../runDefaults.js';
7
11
 
8
12
  export async function hostedQuote(flags = {}, deps = {}) {
9
13
  const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
@@ -11,7 +15,7 @@ export async function hostedQuote(flags = {}, deps = {}) {
11
15
  const result = await quoteHostedRun({ api: context.api, apiUrl: context.api.apiUrl,
12
16
  gameId: resolveGameId(flags, context.config), buildId: flags.build, profileVersion: flags.profile,
13
17
  persona: flags.persona, standardProvider: flags.standardProvider, includeVideo: Boolean(flags.video),
14
- includeTranscript: Boolean(flags.transcript), output: flags.output });
18
+ output: flags.output });
15
19
  if (flags.json) ui.json(result);
16
20
  else {
17
21
  hostedQuoteLines(result).forEach(line => ui.info(line));
@@ -37,3 +41,140 @@ export async function hostedRun(filename, flags = {}, deps = {}) {
37
41
  return result.state === 'declined' ? ExitCode.CANCELED : ExitCode.OK;
38
42
  });
39
43
  }
44
+
45
+ const POLL_MS = 15000;
46
+ const dollars = cents => `$${(cents / 100).toFixed(2)}`;
47
+ const sleepFor = ms => new Promise(resolve => { const timer = setTimeout(resolve, ms); if (timer.unref) timer.unref(); });
48
+
49
+ /** One status object for --json and for the printed lines. */
50
+ function statusView(gameId, apiUrl, { admission, job, runs }, now = Date.now()) {
51
+ const status = hostedPlaytestStatus(admission, job, runs, now);
52
+ return {
53
+ admissionId: admission.admissionId,
54
+ jobId: admission.jobId || null,
55
+ gameId,
56
+ buildId: admission.buildId || null,
57
+ phase: status.phase,
58
+ label: status.label,
59
+ detail: status.detail,
60
+ terminal: status.terminal,
61
+ settled: status.settled,
62
+ cancelRequested: status.cancelRequested,
63
+ failure: status.failure || null,
64
+ elapsedSeconds: status.elapsedSeconds,
65
+ totalPriceCents: Number.isSafeInteger(admission.totalPriceCents) ? admission.totalPriceCents : null,
66
+ modes: modeOutcomes(runs, Array.isArray(admission.controllers) ? admission.controllers : ['standard', 'jev']),
67
+ summaryReport: job?.summary_report ?? null,
68
+ reportUrl: admission.jobId ? jobLink(gameId, admission.jobId, apiUrl) : null
69
+ };
70
+ }
71
+
72
+ function printOutcome(view) {
73
+ ui.info(`${view.label}. ${view.detail}`);
74
+ if (view.failure) ui.info(`Reason: ${view.failure}`);
75
+ if (view.cancelRequested && view.phase !== 'canceled') ui.info('Cancellation was requested.');
76
+ if (TERMINAL_PHASES.includes(view.phase) || view.modes.some(row => row.state !== 'not_started')) view.modes.forEach(row => ui.info(modeLine(row)));
77
+ if (TERMINAL_PHASES.includes(view.phase) && !view.settled) ui.info('Refunds and cleanup are still settling.');
78
+ if (view.reportUrl) ui.info(`Report: ${view.reportUrl}`);
79
+ }
80
+
81
+ const exitFor = view => view.phase === 'canceled' ? ExitCode.CANCELED : view.phase === 'failed' ? ExitCode.FAILURE : ExitCode.OK;
82
+
83
+ /** Poll until the phase is terminal, printing each phase change once. */
84
+ async function waitForRun({ api, gameId, admissionId, apiUrl, sleep, now, onPhase }) {
85
+ let last = null;
86
+ for (;;) {
87
+ const view = statusView(gameId, apiUrl, await readHostedRun({ api, gameId, admissionId }), now());
88
+ if (view.phase !== last) { last = view.phase; onPhase(view); }
89
+ if (TERMINAL_PHASES.includes(view.phase)) return view;
90
+ await sleep();
91
+ }
92
+ }
93
+
94
+ const phaseLine = flags => row => { if (!flags.json) ui.info(`${row.label}: ${row.detail}`); };
95
+
96
+ /**
97
+ * `hosted run` with no saved quote: the newest eligible build (or --build), a
98
+ * fresh quote, the price and consent printed from it, one confirmation, one
99
+ * admission. The admission keeps the saved-quote path's journal and never retries.
100
+ */
101
+ export async function hostedRunDirect(flags = {}, deps = {}) {
102
+ if (flags.json && !flags.yes && !deps.confirm) throw new CliError('A hosted run charges prepaid funds. Use --yes with --json. Nothing was charged.');
103
+ const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
104
+ const now = deps.now || Date.now, sleep = deps.sleep || (() => sleepFor(POLL_MS));
105
+ return withAuthHandling(context, async () => {
106
+ const api = context.api, apiUrl = api.apiUrl, gameId = resolveGameId(flags, context.config);
107
+ const { offer, build, funding } = await prepareHostedRun({ api, apiUrl, gameId, buildId: flags.build,
108
+ persona: flags.persona || DEFAULT_PERSONAS[0], includeVideo: Boolean(flags.video), now });
109
+ if (!flags.json) {
110
+ ui.info(`Build ${build.buildId}${build.entrypoint ? ` (${build.entrypoint})` : ''}${flags.build ? '' : ', the newest ready build'}.`);
111
+ hostedQuoteLines(offer).forEach(line => ui.info(line));
112
+ hostedConsentLines(offer).forEach(line => ui.info(line));
113
+ }
114
+ const short = fundingShortfall(offer.quote, funding);
115
+ if (short) {
116
+ const topUp = short.requiredTopUpCents ? ` Add at least ${dollars(short.requiredTopUpCents)} of prepaid funds, then run this again.` : ' Add prepaid funds, then run this again.';
117
+ throw new CliError(`The balance does not meet this hosted run's funding requirement. Nothing was charged.${topUp}`, ExitCode.ENVIRONMENT);
118
+ }
119
+ const confirm = deps.confirm || (question => ui.confirm(question, { yes: Boolean(flags.yes) }));
120
+ if (!await confirm(confirmQuestion(offer))) {
121
+ if (flags.json) ui.json({ state: 'declined', gameId, buildId: build.buildId, walletMutationRequested: false });
122
+ else ui.info('Declined. Nothing was charged.');
123
+ return ExitCode.CANCELED;
124
+ }
125
+ const admitted = await admitHostedOffer({ offer, api, apiUrl, repoRoot: context.repoRoot, now });
126
+ const reportUrl = jobLink(gameId, admitted.jobId, apiUrl);
127
+ const result = { state: admitted.state, admissionId: admitted.admissionId, jobId: admitted.jobId, gameId, buildId: build.buildId,
128
+ persona: offer.request.personas[0], totalPriceCents: admitted.totalPriceCents, dueNowCents: offer.quote.dueNowCents,
129
+ journal: admitted.journal, reportUrl };
130
+ if (!flags.json) {
131
+ ui.ok(`Paid ${dollars(offer.quote.dueNowCents)}. Hosted run ${admitted.admissionId}, job ${admitted.jobId}.`);
132
+ ui.info(`Check on it with: ravensight-playtest hosted status ${admitted.admissionId}`);
133
+ }
134
+ if (!flags.wait) {
135
+ if (flags.json) ui.json(result);
136
+ else ui.info(`Report: ${reportUrl}`);
137
+ return ExitCode.OK;
138
+ }
139
+ const view = await waitForRun({ api, gameId, admissionId: admitted.admissionId, apiUrl, sleep, now, onPhase: phaseLine(flags) });
140
+ if (flags.json) ui.json({ ...result, status: view });
141
+ else printOutcome(view);
142
+ return exitFor(view);
143
+ });
144
+ }
145
+
146
+ export async function hostedStatus(admissionId, flags = {}, deps = {}) {
147
+ const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
148
+ const now = deps.now || Date.now, sleep = deps.sleep || (() => sleepFor(POLL_MS));
149
+ return withAuthHandling(context, async () => {
150
+ const api = context.api, apiUrl = api.apiUrl, gameId = resolveGameId(flags, context.config);
151
+ const view = flags.wait
152
+ ? await waitForRun({ api, gameId, admissionId, apiUrl, sleep, now, onPhase: phaseLine(flags) })
153
+ : statusView(gameId, apiUrl, await readHostedRun({ api, gameId, admissionId }), now());
154
+ if (flags.json) ui.json(view); else printOutcome(view);
155
+ return flags.wait ? exitFor(view) : ExitCode.OK;
156
+ });
157
+ }
158
+
159
+ export async function hostedCancel(admissionId, flags = {}, deps = {}) {
160
+ if (flags.json && !flags.yes && !deps.confirm) throw new CliError('Use --yes with --json to cancel a hosted run. Nothing was canceled.');
161
+ const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
162
+ const now = deps.now || Date.now;
163
+ return withAuthHandling(context, async () => {
164
+ const api = context.api, apiUrl = api.apiUrl, gameId = resolveGameId(flags, context.config);
165
+ const confirm = deps.confirm || (question => ui.confirm(question, { yes: Boolean(flags.yes) }));
166
+ if (!await confirm(`Cancel hosted run ${admissionId}? Work already completed follows the accepted policy; unstarted work is refunded during settlement.`)) {
167
+ if (flags.json) ui.json({ state: 'kept', admissionId });
168
+ else ui.info('Nothing was canceled.');
169
+ return ExitCode.CANCELED;
170
+ }
171
+ await cancelHostedRun({ api, gameId, admissionId });
172
+ const view = statusView(gameId, apiUrl, await readHostedRun({ api, gameId, admissionId }), now());
173
+ if (flags.json) ui.json(view);
174
+ else {
175
+ ui.ok(view.phase === 'canceled' ? `Hosted run ${admissionId} is canceled.` : `Cancellation requested for hosted run ${admissionId}.`);
176
+ printOutcome(view);
177
+ }
178
+ return ExitCode.OK;
179
+ });
180
+ }
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { mkdir } from 'node:fs/promises';
3
3
  import { optionsFrom, unknownOptionNotes } from '../run/args.js';
4
4
  import { settleFailureLine } from '../run/settle.js';
5
+ import { failureReason } from '../run/failureReason.js';
5
6
  import { getDeps } from '../run/deps.js';
6
7
  import EXIT from '../run/exit.js';
7
8
  import { createHeartbeat } from '../run/heartbeat.js';
@@ -234,7 +235,7 @@ async function safeFail({ api, gameId, jobId, runId, error, log }) {
234
235
  try {
235
236
  await api.runs.complete(gameId, jobId, runId, {
236
237
  state: 'failed',
237
- reason: String(error && error.message).slice(0, 400),
238
+ reason: failureReason(error, { fallback: 'the profile stopped on an error' }),
238
239
  quit_reason: 'error'
239
240
  });
240
241
  } catch (completeError) {
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { buildAggregate, writeAggregate } from '../run/aggregate.js';
6
6
  import { reportPrOutcomes } from '../run/labsOutcome.js';
7
7
  import { finishJob } from '../run/settle.js';
8
+ import { driverStartReason, failureReason } from '../run/failureReason.js';
8
9
  import { optionsFrom, unknownOptionNotes } from '../run/args.js';
9
10
  import { controllerPlan, quoteMatchesPlan, runsMatchPlan } from '../run/controllers.js';
10
11
  import { getDeps } from '../run/deps.js';
@@ -19,8 +20,10 @@ import { configureSpendHold, createModelClient, ModelUnavailableError, ProxyErro
19
20
  import { jobDir, runDir as runDirFor, JOB_FILES, RUN_FILES } from '../run/paths.js';
20
21
  import { readBridgeConfig } from "../run/drivers/stdio.js";
21
22
  import { WEBDRIVER_NAMES, readDriverConfig } from '../run/drivers/webdriver.js';
22
- import { automationChecks } from './check.js';
23
+ import { BROWSER_FIX, automationChecks } from './check.js';
23
24
  import { runPersona } from '../run/personaLoop.js';
25
+ import { runModelBudgetUsd } from '../run/budgetReserve.js';
26
+ import { missingScopeMessage, missingScopes } from '../auth/scopes.js';
24
27
  import { tracesWanted } from '../run/acceptance.js';
25
28
  import { cleanCommit } from '../run/replay.js';
26
29
  import { loadLocalBrief, localBriefProblems } from '../briefLocal.js';
@@ -102,6 +105,11 @@ async function run(flags, injected, deps, log) {
102
105
  const setup = await resolveSetup(deps, options, log);
103
106
  if (setup.code !== undefined) return setup.code;
104
107
  const { context, gameId, repoRoot, apiUrl, cliVersion, api } = setup;
108
+ const scopeRefusal = await tokenScopeRefusal(api);
109
+ if (scopeRefusal !== null) {
110
+ log(scopeRefusal.message);
111
+ return scopeRefusal.code;
112
+ }
105
113
 
106
114
  let controllers;
107
115
  try { controllers = controllerPlan(options); } catch (error) { log(error.message); return EXIT.ENVIRONMENT; }
@@ -678,7 +686,25 @@ export async function playPersonaRun({
678
686
  const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
679
687
  // A validate run plays its own side's checkout and its pair's seed.
680
688
  const runTarget = entry.target || target;
681
- const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target: runTarget, directory, entry, jobId, keyModifier: options.keyModifier, godotPath: options.godotPath, realtime: options.realtime }));
689
+ let driver;
690
+ try {
691
+ driver = await makeDriver(driverName, driverOptions({ headless, driverName, target: runTarget, directory, entry, jobId, keyModifier: options.keyModifier, godotPath: options.godotPath, realtime: options.realtime }));
692
+ } catch (error) {
693
+ // A driver that cannot even be built (the browser package missing, say) is
694
+ // what a new machine hits first. The run is completed as failed now, with
695
+ // what to do, rather than left queued for the server's sweep.
696
+ if (inFlight) inFlight.delete(runId);
697
+ const reason = driverStartReason(driverName, error, { browserFix: BROWSER_FIX });
698
+ log(`run ${runId} (${persona.slug}) failed: ${reason}`);
699
+ await stateFile.patchRun(repoRoot, journal, runId, { state: 'failed', error: reason, quit_reason: 'error', report_written: false }).catch(() => {});
700
+ try {
701
+ await api.runs.complete(gameId, jobId, runId, { state: 'failed', reason, actions_taken: 0, quit_reason: 'error' });
702
+ await stateFile.patchRun(repoRoot, journal, runId, { completed: true }).catch(() => {});
703
+ } catch (completeError) {
704
+ log(`run ${runId} (${persona.slug}): could not be completed as failed (${String(completeError && completeError.message)}). "ravensight-playtest resume" can retry it.`);
705
+ }
706
+ return { runId, persona: persona.slug, state: 'failed', quitReason: 'error', error };
707
+ }
682
708
  // The ledger is created here rather than inside the loop so the heartbeat
683
709
  // can feed it the server's own figure for what this run has spent. Prices
684
710
  // are server side only, so that answer is the only real one.
@@ -719,6 +745,7 @@ export async function playPersonaRun({
719
745
  signal: options.signal,
720
746
  client,
721
747
  api,
748
+ modelBudgetUsd: runModelBudgetUsd(entry),
722
749
  gameId,
723
750
  jobId,
724
751
  runId,
@@ -757,7 +784,18 @@ export async function playPersonaRun({
757
784
  return { runId, persona: persona.slug, state: 'interrupted', error };
758
785
  }
759
786
  if (inFlight) inFlight.delete(runId);
760
- const failed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
787
+ const crashed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
788
+ // The server stores this as the run's reason and the dashboard shows it. A
789
+ // crash outside the persona loop's own handler carries no detail, so the
790
+ // caught error's message stands in, redacted and cut to 400 characters.
791
+ // A run the server holds at `launching` never got its game or browser up,
792
+ // so the reason also says what to do about it.
793
+ const failed = {
794
+ ...crashed,
795
+ quitDetail: crashed.reachedState === 'launching'
796
+ ? driverStartReason(driverName, error, { detail: crashed.quitDetail, browserFix: BROWSER_FIX })
797
+ : failureReason(error, { detail: crashed.quitDetail })
798
+ };
761
799
  await stateFile.patchRun(repoRoot, journal, runId, {
762
800
  state: failed.state,
763
801
  error: String(error && error.message),
@@ -894,6 +932,22 @@ export function durationBody(duration) {
894
932
  return { wall_seconds: value('wall_seconds'), model_seconds: value('model_seconds') };
895
933
  }
896
934
 
935
+ /**
936
+ * The reason `complete` sends. The server stores it as the run's
937
+ * `stateReason` and the dashboard shows it as why the run ended, so a run that
938
+ * succeeded sends none: in 0.7.1 a succeeded run sent its quit detail, which
939
+ * was the persona's whole closing message, report text included. Any other
940
+ * state sends its detail, redacted and cut like every other reason.
941
+ *
942
+ * @param {Object} outcome
943
+ * @returns {string}
944
+ */
945
+ export function completionReason(outcome) {
946
+ if (!outcome || outcome.state === 'succeeded') return '';
947
+ const detail = String(outcome.quitDetail || '').trim();
948
+ return detail ? failureReason(null, { detail }) : '';
949
+ }
950
+
897
951
  /**
898
952
  * Upload the run directory and complete the run, through cli-core's
899
953
  * `finalizeRun` so the 422 `upload_unverified` repair pass lives in one place.
@@ -913,7 +967,7 @@ export function durationBody(duration) {
913
967
  export async function deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot, delivery = null, extraBody = null }) {
914
968
  const body = {
915
969
  state: outcome.state,
916
- reason: outcome.quitDetail || '',
970
+ reason: completionReason(outcome),
917
971
  actions_taken: outcome.actionsTaken,
918
972
  quit_reason: outcome.quitReason,
919
973
  ...(extraBody || {})
@@ -1352,6 +1406,30 @@ export async function godotRefusal({ driverName, godotPath, projectDir, injected
1352
1406
  *
1353
1407
  * @returns {Promise<Object>}
1354
1408
  */
1409
+ /**
1410
+ * A token that cannot finish a run is refused before anything is priced,
1411
+ * registered or charged (../auth/scopes.js). Null when the token has what a
1412
+ * run needs, or when its scopes cannot be read: an older server that lists
1413
+ * none, or a network failure, leaves the server's own checks in charge.
1414
+ *
1415
+ * @param {Object} api
1416
+ * @returns {Promise<{code: number, message: string}|null>}
1417
+ */
1418
+ export async function tokenScopeRefusal(api) {
1419
+ if (!api || !api.cli || typeof api.cli.whoami !== 'function') return null;
1420
+ let whoami;
1421
+ try {
1422
+ whoami = await api.cli.whoami();
1423
+ } catch (error) {
1424
+ if (error && (error.status === 401 || error.status === 403)) {
1425
+ return { code: EXIT.AUTH, message: `The server refused this login (${String(error.message || error.status)}). Log in again with "ravensight-playtest login". Nothing was registered or charged.` };
1426
+ }
1427
+ return null;
1428
+ }
1429
+ const missing = missingScopes(whoami && whoami.scopes);
1430
+ return missing && missing.length > 0 ? { code: EXIT.AUTH, message: missingScopeMessage(missing) } : null;
1431
+ }
1432
+
1355
1433
  export async function resolveSetup(deps, options, log) {
1356
1434
  let context;
1357
1435
  try {
@@ -1462,7 +1540,10 @@ export async function loadRunReports(repoRoot, jobId, personaRuns, journal = nul
1462
1540
  const file = path.join(runDirFor(repoRoot, jobId, entry.run_id), RUN_FILES.reportJson);
1463
1541
  try {
1464
1542
  const report = withoutRunnerEvidence(JSON.parse(await readFile(file, 'utf8')), await readRunnerEvidence(path.dirname(file)));
1465
- out.push({ runId: entry.run_id, persona: entry.persona, ...(entry.controller ? { controller: entry.controller } : {}), state: recorded?.state || entry.state || (report && report.partial === true ? 'budget_exceeded' : 'succeeded'), report });
1543
+ // report.md, for the aggregate's "How the personas felt": each persona's
1544
+ // in-character section in its own words.
1545
+ const markdown = await readFile(path.join(path.dirname(file), RUN_FILES.reportMd), 'utf8').catch(() => null);
1546
+ out.push({ runId: entry.run_id, persona: entry.persona, ...(entry.controller ? { controller: entry.controller } : {}), state: recorded?.state || entry.state || (report && report.partial === true ? 'budget_exceeded' : 'succeeded'), report, markdown });
1466
1547
  } catch {
1467
1548
  out.push({ runId: entry.run_id, persona: entry.persona, ...(entry.controller ? { controller: entry.controller } : {}), state: 'failed', report: null });
1468
1549
  }