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 +41 -2
- package/package.json +1 -1
- package/src/auth/scopes.js +36 -0
- package/src/cli.js +33 -8
- package/src/commands/check.js +5 -1
- package/src/commands/fakeCore.js +5 -1
- package/src/commands/fix.js +2 -0
- package/src/commands/hosted.js +143 -2
- package/src/commands/profile.js +2 -1
- package/src/commands/run.js +86 -5
- package/src/hosted/recordedRun.js +132 -3
- package/src/hosted/status.js +108 -0
- package/src/run/aggregate.js +69 -10
- package/src/run/budgetReserve.js +102 -0
- package/src/run/drivers/web.js +26 -3
- package/src/run/failureReason.js +77 -0
- package/src/run/gameRefusal.js +57 -0
- package/src/run/personaLoop.js +255 -59
- package/src/run/regenerate.js +4 -1
- package/src/run/report.js +54 -0
- package/src/run/usage.js +54 -0
package/README.md
CHANGED
|
@@ -1,9 +1,47 @@
|
|
|
1
1
|
# ravensight-playtest
|
|
2
2
|
|
|
3
|
-
Hosted
|
|
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
|
|
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
|
@@ -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('
|
|
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
|
|
170
|
+
video: options.video }));
|
|
172
171
|
}));
|
|
173
|
-
hosted.command('run
|
|
174
|
-
.description('
|
|
175
|
-
.
|
|
176
|
-
|
|
177
|
-
|
|
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>')
|
package/src/commands/check.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/commands/fakeCore.js
CHANGED
|
@@ -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
|
-
|
|
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 });
|
package/src/commands/fix.js
CHANGED
|
@@ -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,
|
package/src/commands/hosted.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/profile.js
CHANGED
|
@@ -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:
|
|
238
|
+
reason: failureReason(error, { fallback: 'the profile stopped on an error' }),
|
|
238
239
|
quit_reason: 'error'
|
|
239
240
|
});
|
|
240
241
|
} catch (completeError) {
|
package/src/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|