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.
- package/README.md +61 -8
- package/package.json +3 -3
- package/src/auth/scopes.js +36 -0
- package/src/cli.js +42 -9
- package/src/commands/brief.js +1 -1
- package/src/commands/build.js +23 -1
- 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 +144 -2
- package/src/commands/profile.js +2 -1
- package/src/commands/run.js +86 -5
- package/src/hosted/buildStatus.js +35 -0
- package/src/hosted/buildUpload.js +24 -2
- package/src/hosted/recordedRun.js +135 -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/context.js +122 -3
- package/src/run/drivers/web.js +342 -126
- package/src/run/failureReason.js +77 -0
- package/src/run/gameRefusal.js +57 -0
- package/src/run/model.js +18 -1
- package/src/run/openPr.js +8 -1
- package/src/run/personaLoop.js +333 -76
- package/src/run/regenerate.js +4 -1
- package/src/run/report.js +54 -0
- package/src/run/tools.js +31 -3
- package/src/run/usage.js +54 -0
- package/src/ui/index.js +21 -6
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.
|
|
@@ -27,14 +66,20 @@ The hosted build command source adds a local check before a paid upload:
|
|
|
27
66
|
|
|
28
67
|
```sh
|
|
29
68
|
ravensight-playtest build preflight ./game.zip --format web --entrypoint index.html
|
|
30
|
-
ravensight-playtest build upload ./game.zip --game GAME --
|
|
69
|
+
ravensight-playtest build upload ./game.zip --game GAME --entrypoint index.html --dry-run
|
|
70
|
+
ravensight-playtest build status --game GAME
|
|
31
71
|
```
|
|
32
72
|
|
|
33
73
|
`preflight` seeks through bounded ZIP metadata; it does not load a multi-GB
|
|
34
74
|
archive into memory. A saved server profile can be supplied with `--capacity`
|
|
35
|
-
for its declared limits. Upload
|
|
36
|
-
|
|
37
|
-
|
|
75
|
+
for its declared limits. Upload uses the server's build profile when it
|
|
76
|
+
advertises exactly one; when it advertises several, name one with `--profile`
|
|
77
|
+
and the error lists them. It checks metadata and a streamed checksum, then
|
|
78
|
+
asks for the separate $0.49 validation hold before requesting it. Without a
|
|
79
|
+
terminal to ask in, pass `--yes`: a piped answer is never taken as consent.
|
|
80
|
+
`build status` shows each build's state and, for a build the server's check
|
|
81
|
+
refused, the reason in one sentence and whether its validation hold is still
|
|
82
|
+
to be returned after cleanup. The server’s isolated validator remains
|
|
38
83
|
authoritative. A passing local check does not establish engine, GPU or license
|
|
39
84
|
compatibility.
|
|
40
85
|
|
|
@@ -1070,8 +1115,16 @@ at the current $1 unit price total $2; the displayed server quote is authoritati
|
|
|
1070
1115
|
No comparison model call or extra recording fee is added. Use `--upload-video`
|
|
1071
1116
|
instead of `--record-video` only when you want the videos uploaded for private
|
|
1072
1117
|
dashboard playback. Recording is silent and captures only the selected game,
|
|
1073
|
-
not your desktop.
|
|
1074
|
-
|
|
1118
|
+
not your desktop.
|
|
1119
|
+
|
|
1120
|
+
A web persona stays on the build, recording or not. A clicked link, a form, a
|
|
1121
|
+
script or a server redirect to another site, or to another port on your
|
|
1122
|
+
machine, is stopped (a redirect is undone), and a new window is closed. The
|
|
1123
|
+
persona is told "Refused: that link leaves the build under test (host). Stay
|
|
1124
|
+
on this page and use its controls." and the step counts as one that changed
|
|
1125
|
+
nothing. Each attempt is a network note in the report. `localhost`,
|
|
1126
|
+
`127.0.0.1` and `[::1]` on the build's own port are the build. Assets and API
|
|
1127
|
+
calls the page loads from elsewhere are not affected.
|
|
1075
1128
|
|
|
1076
1129
|
Web recording produces WebM. Graphical Godot recording needs an existing FFmpeg
|
|
1077
1130
|
installation and produces MP4; a headless Godot run or missing encoder is reported
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ravensight-playtest",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
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",
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
"./api": "./src/api/index.js"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
|
-
"test": "node --test --test-reporter=spec 'src/**/!(godot-real|web.e2e|scaffold.godot|template3d.godot).test.js'",
|
|
49
|
+
"test": "node --test --test-reporter=spec 'src/**/!(godot-real|web.e2e|web.containment.e2e|scaffold.godot|template3d.godot).test.js'",
|
|
50
50
|
"test:godot": "node --test --test-reporter=spec src/run/drivers/godot-real.test.js src/create/scaffold.godot.test.js src/create/template3d.godot.test.js",
|
|
51
|
-
"test:e2e": "node --test --test-reporter=spec src/run/drivers/web.e2e.test.js",
|
|
51
|
+
"test:e2e": "node --test --test-reporter=spec src/run/drivers/web.e2e.test.js src/run/drivers/web.containment.e2e.test.js",
|
|
52
52
|
"lint": "node scripts/no-dashes.mjs"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
@@ -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>')
|
|
@@ -186,13 +211,21 @@ export function buildProgram() {
|
|
|
186
211
|
const { buildPreflight } = await import('./commands/build.js');
|
|
187
212
|
return buildPreflight(archive, flagsFrom(command.optsWithGlobals(), { entrypoint: options.entrypoint, format: options.format, capacity: options.capacity }));
|
|
188
213
|
}));
|
|
214
|
+
build.command('status [build-id]')
|
|
215
|
+
.description('show each private hosted build, why a refused one was refused, and whether its hold is still to be returned')
|
|
216
|
+
.option('--game <id>', 'game id, overriding this repo’s configuration')
|
|
217
|
+
.action(capture(slot, async (buildId, options, command) => {
|
|
218
|
+
const { buildStatus } = await import('./commands/build.js');
|
|
219
|
+
return buildStatus(buildId, flagsFrom(command.optsWithGlobals(), { game: options.game }));
|
|
220
|
+
}));
|
|
189
221
|
build.command('upload <archive>')
|
|
190
222
|
.description('preflight and stream one private build upload after validation-hold consent')
|
|
191
|
-
.
|
|
223
|
+
.option('--profile <version>', 'server build profile to upload under; optional when the server advertises exactly one, otherwise the error lists the choices')
|
|
192
224
|
.requiredOption('--entrypoint <path>', 'entry file inside the ZIP')
|
|
193
225
|
.option('--game <id>', 'game id, overriding this repo’s configuration')
|
|
194
226
|
.option('--journal <file>', 'preserve this original upload intent at the specified path')
|
|
195
227
|
.option('--dry-run', 'inspect and hash the archive without requesting a reservation')
|
|
228
|
+
.addHelpText('after', '\nThe $0.49 validation hold is asked for at a terminal. Without one, pass --yes\nto accept it; piped input is never taken as consent. See the result with: ravensight-playtest build status')
|
|
196
229
|
.action(capture(slot, async (archive, options, command) => {
|
|
197
230
|
const { buildUpload } = await import('./commands/build.js');
|
|
198
231
|
return buildUpload(archive, flagsFrom(command.optsWithGlobals(), { entrypoint: options.entrypoint, profile: options.profile, game: options.game, journal: options.journal, dryRun: options.dryRun }));
|
package/src/commands/brief.js
CHANGED
|
@@ -273,7 +273,7 @@ async function editBrief(context, flags, deps) {
|
|
|
273
273
|
parentVersion,
|
|
274
274
|
onStale: async version => {
|
|
275
275
|
ui.warn(`The brief moved to version ${version} while you were editing.`);
|
|
276
|
-
return ui.confirm('Save on top of that version?', { yes: flags.yes });
|
|
276
|
+
return ui.confirm('Save on top of that version?', { yes: flags.yes, noTerminalMessage: 'No terminal to confirm in: pass --yes to save on top of the newer version.' });
|
|
277
277
|
}
|
|
278
278
|
});
|
|
279
279
|
|
package/src/commands/build.js
CHANGED
|
@@ -4,6 +4,7 @@ import { resolveGameId } from '../config.js';
|
|
|
4
4
|
import { withAuthHandling } from '../auth/session.js';
|
|
5
5
|
import { preflightBuildArchive, limitsFromProfile } from '../hosted/archivePreflight.js';
|
|
6
6
|
import { uploadHostedBuild } from '../hosted/buildUpload.js';
|
|
7
|
+
import { buildStatusLines, readHostedBuilds } from '../hosted/buildStatus.js';
|
|
7
8
|
import { CliError, ExitCode } from '../errors.js';
|
|
8
9
|
import { ui } from '../ui/index.js';
|
|
9
10
|
|
|
@@ -61,6 +62,7 @@ export async function buildUpload(filename, flags = {}, deps = {}) {
|
|
|
61
62
|
onPrepared: prepared => {
|
|
62
63
|
progress.stop();
|
|
63
64
|
if (flags.json) return;
|
|
65
|
+
ui.info(`Build profile: ${prepared.profileVersion}${flags.profile ? '' : ', the only one the server advertises'}.`);
|
|
64
66
|
ui.info(`ZIP metadata checked: ${prepared.entries} entries; ${ui.bytes(prepared.archiveBytes)} archive, ${ui.bytes(prepared.expandedBytes)} expanded.`);
|
|
65
67
|
const limit = prepared.limits;
|
|
66
68
|
ui.info(`Selected limits: archive ${ui.bytes(limit.archiveBytes)}, expanded ${ui.bytes(limit.expandedBytes)}, file ${ui.bytes(limit.fileBytes)}, ${limit.files} entries, depth ${limit.depth}, ratio ${limit.ratio}:1.`);
|
|
@@ -73,9 +75,29 @@ export async function buildUpload(filename, flags = {}, deps = {}) {
|
|
|
73
75
|
});
|
|
74
76
|
progress.stop();
|
|
75
77
|
if (flags.json) ui.json(result);
|
|
76
|
-
else if (result.state === 'awaiting_validation')
|
|
78
|
+
else if (result.state === 'awaiting_validation') {
|
|
79
|
+
ui.ok(`Uploaded ${result.buildId}; awaiting server validation. Journal: ${result.journal}`);
|
|
80
|
+
ui.info(`See the result, and the reason if it is not accepted: ravensight-playtest build status ${result.buildId}`);
|
|
81
|
+
}
|
|
77
82
|
else ui.info(result.state === 'declined' ? 'Upload declined; no reservation requested.' : 'Preflight finished; no reservation requested.');
|
|
78
83
|
return result.state === 'declined' ? ExitCode.CANCELED : ExitCode.OK;
|
|
79
84
|
} finally { progress.stop(); }
|
|
80
85
|
});
|
|
81
86
|
}
|
|
87
|
+
|
|
88
|
+
// State of the game's private hosted builds, with the reason a refused build
|
|
89
|
+
// was refused and whether its validation hold is still to be returned.
|
|
90
|
+
export async function buildStatus(buildId, flags = {}, deps = {}) {
|
|
91
|
+
const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
|
|
92
|
+
return withAuthHandling(context, async () => {
|
|
93
|
+
const builds = await readHostedBuilds({ api: context.api, gameId: resolveGameId(flags, context.config), buildId });
|
|
94
|
+
if (flags.json) { ui.json({ builds }); return ExitCode.OK; }
|
|
95
|
+
if (!builds.length) {
|
|
96
|
+
if (buildId) throw new CliError(`No hosted build ${buildId} for this game. A refused build is listed until its cleanup finishes.`);
|
|
97
|
+
ui.info('No private hosted builds for this game.');
|
|
98
|
+
return ExitCode.OK;
|
|
99
|
+
}
|
|
100
|
+
for (const build of builds) buildStatusLines(build).forEach(text => ui.info(text));
|
|
101
|
+
return ExitCode.OK;
|
|
102
|
+
});
|
|
103
|
+
}
|
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,141 @@ 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
|
+
noTerminalMessage: 'No terminal to confirm in: pass --yes to cancel this run.' }));
|
|
167
|
+
if (!await confirm(`Cancel hosted run ${admissionId}? Work already completed follows the accepted policy; unstarted work is refunded during settlement.`)) {
|
|
168
|
+
if (flags.json) ui.json({ state: 'kept', admissionId });
|
|
169
|
+
else ui.info('Nothing was canceled.');
|
|
170
|
+
return ExitCode.CANCELED;
|
|
171
|
+
}
|
|
172
|
+
await cancelHostedRun({ api, gameId, admissionId });
|
|
173
|
+
const view = statusView(gameId, apiUrl, await readHostedRun({ api, gameId, admissionId }), now());
|
|
174
|
+
if (flags.json) ui.json(view);
|
|
175
|
+
else {
|
|
176
|
+
ui.ok(view.phase === 'canceled' ? `Hosted run ${admissionId} is canceled.` : `Cancellation requested for hosted run ${admissionId}.`);
|
|
177
|
+
printOutcome(view);
|
|
178
|
+
}
|
|
179
|
+
return ExitCode.OK;
|
|
180
|
+
});
|
|
181
|
+
}
|
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) {
|