ravensight-playtest 0.7.0 → 0.7.5

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.
@@ -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
  }
@@ -0,0 +1,35 @@
1
+ import { printable } from '../ui/index.js';
2
+
3
+ const base = game => `/api/v1/games/${encodeURIComponent(game)}/playtest/hosted`;
4
+
5
+ const LABELS = {
6
+ uploading: 'uploading',
7
+ awaiting_validation: 'waiting for its archive check',
8
+ ready: 'ready',
9
+ graphical_static_validated: 'archive check passed',
10
+ rejected: 'not accepted',
11
+ deleting: 'being removed'
12
+ };
13
+
14
+ // Server text is one fixed sentence; still print it as one plain line.
15
+ const line = text => printable(String(text || '')).replace(/\s+/g, ' ').trim().slice(0, 300);
16
+
17
+ /** The plain lines that describe one build: its state, why it was refused, and the hold. */
18
+ export function buildStatusLines(build) {
19
+ const state = build?.state === 'deleting' && build.rejection ? 'not accepted, being removed' : (LABELS[build?.state] || line(build?.state) || 'unknown');
20
+ const lines = [`${line(build?.buildId)}${build?.entrypoint ? ` (${line(build.entrypoint)})` : ''}: ${state}.`];
21
+ if (build?.rejection?.message) lines.push(` Why: ${line(build.rejection.message)}`);
22
+ else if (build?.state === 'awaiting_validation') lines.push(' Validation is required before a run. Check again in a minute.');
23
+ if (build?.holdReturn?.message) lines.push(` ${line(build.holdReturn.message)}`);
24
+ if (build?.safetyHold) lines.push(' A run of this build was stopped by the safety check, so it cannot be quoted until that hold is cleared.');
25
+ return lines;
26
+ }
27
+
28
+ /** The game's private hosted builds, newest first, optionally one of them. */
29
+ export async function readHostedBuilds({ api, gameId, buildId }) {
30
+ const answer = await api.request('GET', `${base(gameId)}/builds`, { idempotent: true });
31
+ const builds = (Array.isArray(answer.body?.builds) ? answer.body.builds : [])
32
+ .filter(build => build && typeof build.buildId === 'string')
33
+ .sort((a, b) => (Date.parse(b.createdAt) || 0) - (Date.parse(a.createdAt) || 0));
34
+ return buildId ? builds.filter(build => build.buildId === buildId) : builds;
35
+ }
@@ -42,6 +42,28 @@ export async function putArchive(archive, transfer, { fetchImpl = globalThis.fet
42
42
  } finally { stream.destroy(); }
43
43
  }
44
44
 
45
+ /**
46
+ * The profile to upload under: the one named, or the only one advertised. With
47
+ * several and none named, the error lists them so the person can pick one.
48
+ */
49
+ export function chooseProfile(profiles, requested) {
50
+ const advertised = profiles.filter(profile => typeof profile?.profileVersion === 'string' && profile.profileVersion);
51
+ const names = [...new Set(advertised.map(profile => profile.profileVersion))];
52
+ const list = names.map(name => {
53
+ const format = advertised.find(profile => profile.profileVersion === name).format;
54
+ return format ? `${name} (${format})` : name;
55
+ }).join(', ');
56
+ if (!names.length) fail('No build profile is advertised for this game right now. Nothing was charged.');
57
+ if (!requested) {
58
+ if (names.length === 1) return names[0];
59
+ fail(`Several build profiles are available. Choose one with --profile: ${list}. Nothing was charged.`);
60
+ }
61
+ if (advertised.filter(profile => profile.profileVersion === requested).length !== 1) {
62
+ fail(`"${requested}" is not an advertised build profile. Choose one with --profile: ${list}. Nothing was charged.`);
63
+ }
64
+ return requested;
65
+ }
66
+
45
67
  async function journalOnce(filename, value) {
46
68
  await mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
47
69
  let handle;
@@ -58,13 +80,13 @@ async function journalOnce(filename, value) {
58
80
  // the only wallet-mutating POST. No model or gameplay API is called here.
59
81
  export async function uploadHostedBuild({ filename, gameId, profileVersion, entrypoint, repoRoot, api,
60
82
  apiUrl, journalPath, dryRun = false, confirm, onPrepared = () => {}, onProgress = () => {}, fetchImpl, signal } = {}) {
61
- if (!gameId || !profileVersion || typeof confirm !== 'function') fail('Choose a game, ready profile and upload confirmation.');
83
+ if (!gameId || typeof confirm !== 'function') fail('Choose a game and upload confirmation.');
62
84
  onProgress({ phase: 'capabilities' });
63
85
  const caps = body(await api.request('GET', `${base(gameId)}/capabilities`, { idempotent: true }));
64
86
  const profiles = caps?.recorded?.profiles;
65
87
  if (!caps?.recorded?.available || !Array.isArray(profiles)) fail('Recorded game uploads are not available for this game.');
88
+ profileVersion = chooseProfile(profiles, profileVersion);
66
89
  const matching = profiles.filter(profile => profile.profileVersion === profileVersion);
67
- if (matching.length !== 1) fail('Choose exactly one currently advertised ready profile.');
68
90
  const profile = matching[0], limits = profile.capacity ? limitsFromProfile(profile) : buildLimits(caps.limits);
69
91
  const offer = profile.reservationOffer;
70
92
  if (!offer || offer.amountCents !== 49 || offer.currency !== 'USD' || offer.purpose !== 'validation_hold'
@@ -1,9 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { mkdir, open, access, rename } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { CliError } from '../errors.js';
4
+ import { CliError, ExitCode } from '../errors.js';
5
5
  import { ApiError } from '../api/errors.js';
6
6
  import { stableStringify } from '../fsutil.js';
7
+ import { printable } from '../ui/index.js';
7
8
 
8
9
  const MAX_QUOTE_BYTES = 1024 * 1024;
9
10
  const CONSENT_KEYS = ['quoteId', 'planHash', 'profileVersion', 'controllers', 'includeVideo', 'includeTranscript',
@@ -140,7 +141,7 @@ export function hostedQuoteLines(offer) {
140
141
  ...q.localBreakdown.map(row => `${row.count} ${row.unit} at ${money(row.unitCents)} each: ${money(row.count * row.unitCents)}.`),
141
142
  ...q.computeBreakdown.map(row => `${row.controller} hosted compute: ${money(row.priceCents)}.`),
142
143
  `Total ${money(q.totalPriceCents)}; already reserved ${money(q.alreadyReservedCents)}; due now ${money(q.dueNowCents)} from prepaid funds. No top-up is performed.`,
143
- `Video: ${r.includeVideo ? 'included' : 'off'}; transcript: ${r.includeTranscript ? 'included' : 'off'}.`,
144
+ `Video: ${r.includeVideo ? 'included' : 'off'}. Hosted runs save no transcript.`,
144
145
  `Customer failure: ${q.customerFailure}; cancellation after start: ${q.cancelAfterStart}. Price ${q.priceVersion}; refund policy ${q.policyVersion}.`,
145
146
  `Quote expires ${new Date(q.expiresAt).toISOString()}.`];
146
147
  }
@@ -157,8 +158,42 @@ export async function runHostedQuote({ filename, api, apiUrl, repoRoot, confirm,
157
158
  if (profile.driver !== offer.request.driver || funding?.eligible === false || offer.quote.funding?.eligible === false) fail('The server no longer advertises this profile or sufficient prepaid funding. Nothing was charged.');
158
159
  await onPrepared(offer);
159
160
  if (typeof confirm !== 'function') fail('The full hosted quote must be confirmed before admission.');
160
- if (!await confirm(`Pay ${money(offer.quote.dueNowCents)} now (${money(offer.quote.totalPriceCents)} total) for the quoted Standard then Jev runs and the evidence options shown?`)) return { state: 'declined', walletMutationRequested: false };
161
+ if (!await confirm(confirmQuestion(offer))) return { state: 'declined', walletMutationRequested: false };
162
+ return admitHostedOffer({ offer, api, apiUrl, repoRoot, now });
163
+ }
164
+
165
+ export const confirmQuestion = offer => `Pay ${money(offer.quote.dueNowCents)} now (${money(offer.quote.totalPriceCents)} total) for the quoted Standard then Jev runs and the evidence options shown?`;
166
+
167
+ // Refusals the server makes before any charge: the quote or consent was not
168
+ // accepted, funding or capacity said no, or the quote is gone. The original
169
+ // intent is kept on disk all the same; only the message differs.
170
+ const DEFINITE_REFUSALS = new Set(['hosted_funding_required', 'quote_not_accepted', 'not_found', 'recorded_unavailable',
171
+ 'recorded_setup_required', 'hosted_global_capacity', 'too_many_active_hosted_runs', 'hosted_daily_capacity',
172
+ 'hosted_build_too_large', 'hosted_build_safety_hold']);
173
+
174
+ export function hostedRefusalMessage(error) {
175
+ const code = error?.code;
176
+ if (code === 'hosted_funding_required') {
177
+ const f = error.body?.funding;
178
+ const topUp = Number.isSafeInteger(f?.requiredTopUpCents) && f.requiredTopUpCents > 0 ? ` Add at least ${money(f.requiredTopUpCents)} of prepaid funds, then request a new quote.` : ' Add prepaid funds, then request a new quote.';
179
+ return `The server refused the run for funding. Nothing was charged.${topUp}`;
180
+ }
181
+ if (code === 'quote_not_accepted') return 'The server did not accept this consent for the quote. Nothing was charged; request a new quote.';
182
+ if (['hosted_global_capacity', 'too_many_active_hosted_runs'].includes(code)) return 'Hosted capacity is busy. Nothing was charged. Wait for an existing run to finish, then retry. Adding funds does not increase capacity.';
183
+ if (code === 'hosted_daily_capacity') return 'Today\'s hosted run allowance is used up. Nothing was charged. Try again after the daily limit resets.';
184
+ if (code === 'recorded_setup_required') return `${error.message || 'Complete the playtest brief and choose an available persona.'} Nothing was charged.`;
185
+ if (code === 'not_found') return 'The quote is no longer available. Nothing was charged; request a new quote.';
186
+ if (code === 'hosted_build_too_large') return 'This build is too large to play on a hosted machine: hosted playtests play builds up to 300 MiB unpacked. Make the build smaller and upload it again. Nothing was charged.';
187
+ if (code === 'hosted_build_safety_hold') return 'This build was stopped for safety during a hosted playtest and cannot run again until an admin reviews and clears it in the dashboard. Nothing was charged.';
188
+ return 'Hosted playtests are not available right now. Nothing was charged.';
189
+ }
190
+
191
+ // A saved durable intent is never retried. The journal is fsynced before the
192
+ // only wallet-mutating POST and the server receives a stable key for the plan.
193
+ export async function admitHostedOffer({ offer, api, apiUrl, repoRoot, now = Date.now }) {
161
194
  validateHostedOffer(offer, apiUrl, now());
195
+ const identity = digest({ apiUrl: offer.apiUrl, gameId: offer.gameId, planHash: offer.quote.planHash });
196
+ const journal = path.join(repoRoot, '.ravensight', 'hosted-runs', `${identity}.json`);
162
197
  const state = { schema: 'ravensight-hosted-admission-journal/v1', apiUrl: offer.apiUrl, gameId: offer.gameId,
163
198
  quoteId: offer.quote.quoteId, planHash: offer.quote.planHash, consent: offer.consent,
164
199
  idempotencyKey: `hosted-${offer.quote.planHash}`, phase: 'admission_unknown', createdAt: new Date(now()).toISOString() };
@@ -186,6 +221,103 @@ export async function runHostedQuote({ filename, api, apiUrl, repoRoot, confirm,
186
221
  return { state: 'charged_blocked', ...ack, journal, replayAuthorized: false, gameplayProven: false };
187
222
  } catch (error) {
188
223
  if (error instanceof ApiError && error.status === 401) throw error;
224
+ if (error instanceof ApiError && DEFINITE_REFUSALS.has(error.code) && error.status >= 400 && error.status < 600) {
225
+ throw new CliError(hostedRefusalMessage(error), ExitCode.ENVIRONMENT);
226
+ }
189
227
  fail(`The original admission outcome needs reconciliation. Preserve ${journal}; no retry or replacement admission was requested.`);
190
228
  }
191
229
  }
230
+
231
+ const ELIGIBLE_BUILD_STATE = 'graphical_static_validated';
232
+
233
+ /**
234
+ * The builds a recorded hosted run can use: static-checked, unexpired and on a
235
+ * profile the server currently advertises as ready. Newest first, the same
236
+ * filter the dashboard's recorded comparison form applies.
237
+ */
238
+ export function eligibleHostedBuilds(builds, profiles, now = Date.now()) {
239
+ const rows = Array.isArray(builds) ? builds : [];
240
+ return rows.filter(build => build && build.kind === 'recorded_graphical' && build.state === ELIGIBLE_BUILD_STATE
241
+ && id(build.buildId) && !(Date.parse(build.expiresAt) <= now)
242
+ && profiles.some(p => p.profileVersion === build.profileVersion && p.format === build.format))
243
+ .sort((a, b) => (Date.parse(b.createdAt) || 0) - (Date.parse(a.createdAt) || 0));
244
+ }
245
+
246
+ /**
247
+ * Quote a recorded Standard then Jev run without a saved quote file: pick the
248
+ * build (the newest eligible one unless named), ask for a quote, check it, and
249
+ * hand back the same offer shape `hosted quote` saves. Charges nothing.
250
+ */
251
+ export async function prepareHostedRun({ api, apiUrl, gameId, buildId, persona, includeVideo = false, includeTranscript = false, standardProvider, now = Date.now }) {
252
+ if (!id(gameId)) fail('Choose a game. Nothing was charged.');
253
+ const answer = await api.request('GET', `${base(gameId)}/run-capabilities`, { idempotent: true });
254
+ const recorded = answer.body?.recorded;
255
+ if (!recorded?.available || recorded.intakeEnabled !== true || !Array.isArray(recorded.profiles)) {
256
+ fail('Hosted playtests are not available for this game right now. Nothing was charged.');
257
+ }
258
+ const listed = await api.request('GET', `${base(gameId)}/builds`, { idempotent: true });
259
+ const eligible = eligibleHostedBuilds(listed.body?.builds, recorded.profiles, now());
260
+ let build;
261
+ if (buildId) {
262
+ build = eligible.find(row => row.buildId === buildId);
263
+ const refused = (Array.isArray(listed.body?.builds) ? listed.body.builds : []).find(row => row?.buildId === buildId && row.rejection?.message);
264
+ if (!build && refused) fail(`Build ${buildId} was not accepted: ${printable(String(refused.rejection.message)).replace(/\s+/g, ' ').slice(0, 300)} Nothing was charged.`);
265
+ if (!build) fail(`Build ${buildId} is not ready for a hosted run: it has to pass its static archive check on a ready profile and not have expired. Nothing was charged.`);
266
+ } else {
267
+ build = eligible[0];
268
+ if (!build) fail('No build is ready for a hosted run. Upload one with "build upload" and wait for its static archive check. Nothing was charged.');
269
+ }
270
+ const { profile, funding } = await available(api, gameId, build.profileVersion, standardProvider);
271
+ const request = requestFor({ buildId: build.buildId, profileVersion: build.profileVersion, persona, standardProvider, includeVideo, includeTranscript }, profile);
272
+ const response = await api.request('POST', `${base(gameId)}/quotes`, { body: request, idempotent: false });
273
+ if (response.status !== 200) fail('The server did not return a hosted quote. Nothing was charged.');
274
+ const quote = response.body, consent = hostedConsent(quote, request, now());
275
+ const value = { apiUrl: origin(apiUrl), gameId, request, quote, consent };
276
+ const offer = { schema: 'ravensight-hosted-recorded-quote/v1', ...value, integrity: digest(value) };
277
+ return { offer, build, funding: quote.funding || funding || null };
278
+ }
279
+
280
+ /** True when the server says the balance does not meet this run's funding requirement. */
281
+ export function fundingShortfall(quote, funding) {
282
+ const f = quote?.funding || funding;
283
+ if (f && f.eligible === false) return { requiredTopUpCents: Number.isSafeInteger(f.requiredTopUpCents) ? f.requiredTopUpCents : null };
284
+ return null;
285
+ }
286
+
287
+ /** Plain statements a person accepts with the price. Each is something the server enforces or the quote states. */
288
+ export function hostedConsentLines(offer) {
289
+ const q = offer.quote;
290
+ return [
291
+ 'One persona plays the same build twice, once with Standard and once with Jev, on isolated hosted machines. These are AI playtests, not human feedback.',
292
+ 'The game runs without network access. Shared capacity, daily limits and the quoted runtime bounds still apply; funding does not guarantee an immediate start.',
293
+ `If the build fails, hosted compute is ${q.customerFailure === 'refund' ? 'refunded' : 'kept'}. Canceling after a confirmed start ${q.cancelAfterStart === 'refund' ? 'refunds' : 'keeps'} that run's compute charge. Unstarted work is refunded during settlement.`,
294
+ 'The build\'s validation hold is separate and is returned after safe cleanup. It does not reduce this price.',
295
+ ...(Number.isSafeInteger(q.minimumBalanceCents) ? [`Balance needed to start: ${money(q.minimumBalanceCents)}. Only the total above is charged.`] : [])
296
+ ];
297
+ }
298
+
299
+ const admissionPattern = /^ha_[a-f0-9-]{36}$/;
300
+
301
+ /** Admission, job and runs for one hosted run. A job or runs not readable yet reads as absent. */
302
+ export async function readHostedRun({ api, gameId, admissionId }) {
303
+ if (!admissionPattern.test(admissionId || '')) fail('Name a hosted run id (ha_...), as "hosted run" printed it.');
304
+ const admission = (await api.request('GET', `${base(gameId)}/runs/${encodeURIComponent(admissionId)}`, { idempotent: true })).body;
305
+ if (!admission || admission.admissionId !== admissionId) fail('The server answered for a different hosted run.');
306
+ let job = null, runs = [];
307
+ if (admission.jobId) {
308
+ const jobPath = `/api/v1/games/${encodeURIComponent(gameId)}/playtest/jobs/${encodeURIComponent(admission.jobId)}`;
309
+ const soft = async promise => { try { return (await promise).body; } catch (error) { if (error instanceof ApiError && error.status === 404) return null; throw error; } };
310
+ const [jobBody, runsBody] = await Promise.all([
311
+ soft(api.request('GET', jobPath, { idempotent: true })),
312
+ soft(api.request('GET', `${jobPath}/runs`, { idempotent: true }))
313
+ ]);
314
+ job = jobBody?.job || null;
315
+ runs = Array.isArray(runsBody?.runs) ? runsBody.runs : [];
316
+ }
317
+ return { admission, job, runs };
318
+ }
319
+
320
+ export async function cancelHostedRun({ api, gameId, admissionId }) {
321
+ if (!admissionPattern.test(admissionId || '')) fail('Name a hosted run id (ha_...), as "hosted run" printed it.');
322
+ return (await api.request('POST', `${base(gameId)}/runs/${encodeURIComponent(admissionId)}/cancel`, { body: {}, idempotent: false })).body;
323
+ }
@@ -0,0 +1,108 @@
1
+ // Customer-facing progress for a hosted admission plus its ordinary Playtest job.
2
+ //
3
+ // Mirrors dashboard/src/lib/hostedPlaytestStatus.js in the platform repo
4
+ // (game_tracker), so the CLI and the dashboard use the same phases and the same
5
+ // words. Change both together. The admission owns the provider and payment
6
+ // lifecycle; the job and its runs own gameplay, report capture and aggregation.
7
+
8
+ const FAILED = new Set(['failed', 'budget_exceeded', 'interrupted', 'stalled', 'refused']);
9
+ const CANCELED = new Set(['canceled']);
10
+ const boundedReason = value => String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
11
+
12
+ export const HOSTED_PHASES = Object.freeze(['queued', 'provisioning', 'running', 'capturing', 'analyzing', 'report-ready', 'failed', 'canceled']);
13
+ export const TERMINAL_PHASES = Object.freeze(['report-ready', 'failed', 'canceled']);
14
+
15
+ /**
16
+ * @param {object} admission GET /hosted/runs/:admissionId
17
+ * @param {object|null} [job] GET /jobs/:jobId `job`, or null when not readable yet
18
+ * @param {Array<object>} [runs] GET /jobs/:jobId/runs `runs`
19
+ * @param {number} [now]
20
+ */
21
+ export function hostedPlaytestStatus(admission, job = null, runs = [], now = Date.now()) {
22
+ if (!admission) return null;
23
+ const runRows = Array.isArray(runs) ? runs : [];
24
+ const jobState = job?.state || '';
25
+ const admissionState = admission.executionState || admission.state || '';
26
+ const cancelRequested = admission.cancelRequested === true || job?.cancel_requested === true;
27
+ const aggregate = runRows.find(row => row.module === 'aggregate_report');
28
+ const plays = runRows.filter(row => row.module !== 'aggregate_report');
29
+
30
+ let phase = 'queued';
31
+ let label = 'Queued';
32
+ let detail = 'Waiting for hosted capacity.';
33
+
34
+ if (CANCELED.has(jobState) || CANCELED.has(admissionState)) {
35
+ phase = 'canceled'; label = 'Canceled'; detail = 'The hosted run was canceled.';
36
+ } else if (FAILED.has(jobState) || FAILED.has(admissionState) || job?.summary_report === 'failed') {
37
+ phase = 'failed'; label = 'Failed'; detail = 'The hosted run stopped before a report was ready.';
38
+ } else if (job?.summary_report === 'ready' || admissionState === 'recorded_closed') {
39
+ phase = 'report-ready'; label = 'Report ready'; detail = 'The hosted run and report are complete.';
40
+ } else if (['aggregating', 'uploading'].includes(jobState) || ['reporting', 'uploading'].includes(aggregate?.state)) {
41
+ phase = 'analyzing'; label = 'Analyzing'; detail = 'Combining the completed playtests into the comparison report.';
42
+ } else if (plays.some(row => ['reporting', 'uploading'].includes(row.state))) {
43
+ phase = 'capturing'; label = 'Capturing results'; detail = 'Saving the playtest report, evidence and requested media.';
44
+ } else if (plays.some(row => row.state === 'playing') || jobState === 'running') {
45
+ phase = 'running'; label = 'Running'; detail = 'The hosted game session is in progress.';
46
+ } else if (plays.some(row => row.state === 'launching') || jobState === 'registered' || admissionState === 'dispatched') {
47
+ phase = 'provisioning'; label = 'Provisioning'; detail = 'Preparing isolated hosted compute for this run.';
48
+ }
49
+
50
+ const startedAt = Date.parse(job?.created_at || admission.createdAt || '');
51
+ const finishedAt = Date.parse(job?.finished_at || '');
52
+ const elapsedSeconds = Number.isFinite(startedAt)
53
+ ? Math.max(0, Math.floor(((Number.isFinite(finishedAt) ? finishedAt : Number(now)) - startedAt) / 1000))
54
+ : null;
55
+ const failure = phase === 'failed'
56
+ ? boundedReason(job?.state_reason || admission.failureReason || admission.stopReason)
57
+ : '';
58
+
59
+ return {
60
+ phase,
61
+ label,
62
+ detail,
63
+ elapsedSeconds,
64
+ failure,
65
+ cancelRequested,
66
+ settled: admission.settled === true,
67
+ // A terminal job can still be settling provider cleanup and refunds. The
68
+ // admission is the authoritative signal that a new paid run is safe.
69
+ terminal: admission.settled === true && TERMINAL_PHASES.includes(phase)
70
+ };
71
+ }
72
+
73
+ const MODE_NAMES = Object.freeze({ standard: 'Standard', jev: 'Jev' });
74
+
75
+ /**
76
+ * One row per hosted mode (Standard, Jev), from the job's persona runs. A mode
77
+ * whose run the server has not created yet reads as not started.
78
+ * @param {Array<object>} runs
79
+ * @param {Array<string>} [controllers]
80
+ */
81
+ export function modeOutcomes(runs = [], controllers = ['standard', 'jev']) {
82
+ const plays = (Array.isArray(runs) ? runs : []).filter(row => row.module !== 'aggregate_report');
83
+ return controllers.map(controller => {
84
+ const run = plays.find(row => row.controller === controller) || null;
85
+ const report = run?.report || null;
86
+ return {
87
+ mode: controller,
88
+ name: MODE_NAMES[controller] || controller,
89
+ runId: run?.run_id || null,
90
+ state: run?.state || 'not_started',
91
+ reason: run?.state_reason ? boundedReason(run.state_reason) : null,
92
+ reportValid: report ? report.valid === true : false,
93
+ findings: report && Number.isSafeInteger(report.findings_count) ? report.findings_count : null,
94
+ wouldContinue: report && typeof report.would_continue === 'boolean' ? report.would_continue : null,
95
+ recording: run?.recording?.status || null
96
+ };
97
+ });
98
+ }
99
+
100
+ /** "Standard: completed, 4 findings, video saved." */
101
+ export function modeLine(row) {
102
+ const parts = [row.state.replace(/_/g, ' ')];
103
+ if (row.findings !== null) parts.push(`${row.findings} finding${row.findings === 1 ? '' : 's'}`);
104
+ if (row.wouldContinue !== null) parts.push(row.wouldContinue ? 'would keep playing' : 'would stop playing');
105
+ if (row.recording) parts.push(`video ${row.recording}`);
106
+ if (row.reason) parts.push(row.reason);
107
+ return `${row.name}: ${parts.join(', ')}.`;
108
+ }
@@ -232,11 +232,12 @@ export async function judgeClusters({ client, jobId, runId, clusters, ledger = n
232
232
  }
233
233
  } catch (error) {
234
234
  // A dedup that could not be judged is not worth failing a job over: the
235
- // local clustering is the fallback and it is deterministic. A budget
236
- // refusal in particular has to reach the caller though, because it
237
- // decides the exit code.
238
- if (error instanceof ProxyError && (error.isBudget || error.isClosed)) throw error;
239
- log(`dedup pass skipped: ${String(error && error.message)}`);
235
+ // local clustering is the fallback and it is deterministic. That includes
236
+ // a budget refusal: the aggregate is the job's report and always comes
237
+ // back, and the exit code is decided by the persona runs, not by this
238
+ // pass. Only a closed job stops it.
239
+ if (error instanceof ProxyError && error.isClosed) throw error;
240
+ log(`dedup pass skipped${error instanceof ProxyError && error.isBudget ? ' (the aggregate run is out of model budget; the local clustering stands)' : ''}: ${String(error && error.message)}`);
240
241
  }
241
242
  return verdicts;
242
243
  }
@@ -393,6 +394,7 @@ export async function buildAggregate({
393
394
  persona: run.persona,
394
395
  state: run.state,
395
396
  report: run.report,
397
+ markdown: typeof run.markdown === 'string' ? run.markdown : null,
396
398
  // A finding the server dropped for want of evidence is marked on disk
397
399
  // after delivery (./dropped.js) and kept out of the aggregate, so the
398
400
  // job report counts what the dashboard counts.
@@ -456,6 +458,7 @@ export async function buildAggregate({
456
458
  dropped_for: 'no evidence'
457
459
  })),
458
460
  capability_notes: capabilityNotes.length > 0 ? capabilityNotes : ['No capability limits noted for this job.'],
461
+ persona_voices: inputs.map(run => personaVoice(run)),
459
462
  usage: ledger ? aggregateUsage(ledger) : { usd: 0, by_model: {} }
460
463
  };
461
464
 
@@ -540,11 +543,11 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
540
543
  if (ledger) ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
541
544
  return parseJsonObject(answer.text);
542
545
  } catch (error) {
543
- // A budget or cancel refusal decides the caller's exit code and has to
544
- // reach it. Anything else leaves the locally ranked aggregate standing,
545
- // which is still the report a developer can read.
546
- if (error instanceof ProxyError && (error.isBudget || error.isClosed)) throw error;
547
- log(`aggregate synthesis skipped: ${String(error && error.message)}`);
546
+ // A cancel refusal reaches the caller. Anything else, a budget refusal
547
+ // included, leaves the locally ranked aggregate standing, which is still
548
+ // the report a developer can read: the aggregate always comes back.
549
+ if (error instanceof ProxyError && error.isClosed) throw error;
550
+ log(`aggregate synthesis skipped${error instanceof ProxyError && error.isBudget ? ' (the aggregate run is out of model budget; the local ranking stands)' : ''}: ${String(error && error.message)}`);
548
551
  return null;
549
552
  }
550
553
  }
@@ -665,6 +668,14 @@ export function renderAggregateMarkdown(json) {
665
668
  lines.push(`| ${run.persona} | ${run.actions_taken} | ${run.quit_reason} | ${sentiment.fun ?? '-'} | ${sentiment.clarity ?? '-'} | ${sentiment.frustration ?? '-'} | ${run.would_continue === null || run.would_continue === undefined ? '-' : run.would_continue ? 'yes' : 'no'} |`);
666
669
  }
667
670
  lines.push('');
671
+ lines.push('## How the personas felt');
672
+ const voices = Array.isArray(json.persona_voices) ? json.persona_voices : [];
673
+ if (voices.length === 0) lines.push('No persona report was written, so there is nothing in their own words.');
674
+ for (const voice of voices) {
675
+ lines.push(`### ${voice.persona}${voice.written_from_log ? ' (written from the run log)' : ''}`);
676
+ lines.push(voice.text ? voice.text.split('\n').map(line => `> ${line}`).join('\n') : '> This run\'s report has no in-character section.');
677
+ lines.push('');
678
+ }
668
679
  lines.push('## Top issues (ranked)');
669
680
  if (json.issues.length === 0) lines.push('None.');
670
681
  for (const issue of json.issues) {
@@ -703,6 +714,54 @@ export function renderAggregateMarkdown(json) {
703
714
  return fillPlaceholders(lines.join('\n'), { generatedAt: json.generated_at, game: json.game_id });
704
715
  }
705
716
 
717
+ /** The most characters of one persona's in-character section the aggregate quotes. */
718
+ export const MAX_VOICE_CHARS = 1200;
719
+
720
+ /**
721
+ * One persona's in-character section, from its report.md: the section under
722
+ * "In-character experience", the one a report written from the run log
723
+ * carries too, cut to MAX_VOICE_CHARS at a paragraph or sentence where it can.
724
+ *
725
+ * @param {{runId: string, persona: string, markdown: string|null}} run
726
+ * @returns {{run_id: string, persona: string, written_from_log: boolean, text: string|null}}
727
+ */
728
+ export function personaVoice(run) {
729
+ const section = inCharacterSection(run && run.markdown);
730
+ return {
731
+ run_id: run.runId,
732
+ persona: run.persona,
733
+ written_from_log: Boolean(section && section.fromLog),
734
+ text: section ? clipVoice(section.text) : null
735
+ };
736
+ }
737
+
738
+ /**
739
+ * The body of report.md's in-character section, or null when it has none.
740
+ *
741
+ * @param {string|null} markdown
742
+ * @returns {{text: string, fromLog: boolean}|null}
743
+ */
744
+ export function inCharacterSection(markdown) {
745
+ if (typeof markdown !== 'string' || !markdown) return null;
746
+ const lines = markdown.split('\n');
747
+ const start = lines.findIndex(line => /^##\s+In-character experience\b/i.test(line.trim()));
748
+ if (start === -1) return null;
749
+ const rest = lines.slice(start + 1);
750
+ const end = rest.findIndex(line => /^#{1,2}\s/.test(line));
751
+ const text = (end === -1 ? rest : rest.slice(0, end)).join('\n').trim();
752
+ if (!text) return null;
753
+ return { text, fromLog: /written from the run log/i.test(lines[start]) };
754
+ }
755
+
756
+ function clipVoice(text) {
757
+ if (text.length <= MAX_VOICE_CHARS) return text;
758
+ const cut = text.slice(0, MAX_VOICE_CHARS);
759
+ const paragraph = cut.lastIndexOf('\n\n');
760
+ const sentence = cut.lastIndexOf('. ');
761
+ const at = paragraph > MAX_VOICE_CHARS / 2 ? paragraph : sentence > MAX_VOICE_CHARS / 2 ? sentence + 1 : cut.length;
762
+ return `${cut.slice(0, at).trim()} [...]`;
763
+ }
764
+
706
765
  /**
707
766
  * Write both aggregate files into the job directory.
708
767
  *