ravensight-playtest 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/addons/ravensight_driver/driver.gd +836 -0
- package/addons/ravensight_driver/export_plugin.gd +51 -0
- package/addons/ravensight_driver/plugin.cfg +7 -0
- package/addons/ravensight_driver/plugin.gd +36 -0
- package/bin/ravensight-playtest.js +31 -0
- package/package.json +45 -0
- package/src/api/README.md +500 -0
- package/src/api/client.js +340 -0
- package/src/api/errors.js +115 -0
- package/src/api/http.js +194 -0
- package/src/api/index.js +107 -0
- package/src/auth/deviceCode.js +79 -0
- package/src/auth/keychain.js +159 -0
- package/src/auth/session.js +128 -0
- package/src/cli.js +335 -0
- package/src/commands/brief.js +303 -0
- package/src/commands/check.js +318 -0
- package/src/commands/fakeCore.js +379 -0
- package/src/commands/init.js +120 -0
- package/src/commands/login.js +90 -0
- package/src/commands/logout.js +70 -0
- package/src/commands/open.js +125 -0
- package/src/commands/profile.js +262 -0
- package/src/commands/resume.js +156 -0
- package/src/commands/run.js +1015 -0
- package/src/commands/upload.js +137 -0
- package/src/config.js +100 -0
- package/src/dashboard.js +97 -0
- package/src/detect.js +77 -0
- package/src/errors.js +44 -0
- package/src/fsutil.js +77 -0
- package/src/godot.js +85 -0
- package/src/packs/index.js +191 -0
- package/src/paths.js +129 -0
- package/src/run/aggregate.js +658 -0
- package/src/run/args.js +111 -0
- package/src/run/context.js +181 -0
- package/src/run/deps.js +184 -0
- package/src/run/drivers/driver.js +183 -0
- package/src/run/drivers/godot-observation.js +138 -0
- package/src/run/drivers/godot-project.js +475 -0
- package/src/run/drivers/godot-rpc.js +225 -0
- package/src/run/drivers/godot.js +587 -0
- package/src/run/drivers/index.js +52 -0
- package/src/run/drivers/web.js +385 -0
- package/src/run/exit.js +21 -0
- package/src/run/heartbeat.js +131 -0
- package/src/run/index.js +31 -0
- package/src/run/json.js +56 -0
- package/src/run/model.js +384 -0
- package/src/run/paths.js +88 -0
- package/src/run/personaLoop.js +871 -0
- package/src/run/profile.js +214 -0
- package/src/run/regenerate.js +149 -0
- package/src/run/repoTools.js +286 -0
- package/src/run/report.js +222 -0
- package/src/run/resume.js +272 -0
- package/src/run/secretScan.js +171 -0
- package/src/run/state.js +198 -0
- package/src/run/synthetic.js +206 -0
- package/src/run/tools.js +344 -0
- package/src/run/transcript.js +93 -0
- package/src/run/usage.js +115 -0
- package/src/state/index.js +105 -0
- package/src/states.js +104 -0
- package/src/ui/index.js +195 -0
- package/src/upload/allowlist.js +116 -0
- package/src/upload/index.js +467 -0
- package/src/upload/queue.js +114 -0
- package/src/version.js +63 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { appendFile, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createContext } from '../api/index.js';
|
|
4
|
+
import { defaultConfig, loadConfig, saveConfig } from '../config.js';
|
|
5
|
+
import { withAuthHandling } from '../auth/session.js';
|
|
6
|
+
import { paths } from '../paths.js';
|
|
7
|
+
import { ui } from '../ui/index.js';
|
|
8
|
+
import { CliError, ExitCode } from '../errors.js';
|
|
9
|
+
|
|
10
|
+
const GITIGNORE_LINE = '.ravensight/jobs/';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Add `.ravensight/jobs/` to the repo's .gitignore if it has one and does not
|
|
14
|
+
* already ignore it.
|
|
15
|
+
*
|
|
16
|
+
* Job directories hold screenshots and a video, which nobody wants in their
|
|
17
|
+
* history. Only appended when a .gitignore already exists: creating one in
|
|
18
|
+
* somebody else's repo is a bigger decision than this command is making.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} repoRoot
|
|
21
|
+
* @returns {Promise<'added'|'present'|'absent'>}
|
|
22
|
+
*/
|
|
23
|
+
export async function ensureGitignore(repoRoot) {
|
|
24
|
+
const file = join(repoRoot, '.gitignore');
|
|
25
|
+
let text;
|
|
26
|
+
try {
|
|
27
|
+
text = await readFile(file, 'utf8');
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error.code === 'ENOENT') return 'absent';
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
if (text.split('\n').some(line => line.trim() === GITIGNORE_LINE)) return 'present';
|
|
33
|
+
await appendFile(file, `${text.endsWith('\n') ? '' : '\n'}${GITIGNORE_LINE}\n`);
|
|
34
|
+
return 'added';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `ravensight-playtest init --game <gameId>`
|
|
39
|
+
*
|
|
40
|
+
* Link this repo to a Ravensight game.
|
|
41
|
+
*
|
|
42
|
+
* The game is checked against `whoami` before anything is written, because the
|
|
43
|
+
* alternative is a config that looks right and fails on the first job with a
|
|
44
|
+
* 404 that reads like the game does not exist. The token's own game list is the
|
|
45
|
+
* authority: an unapproved game is invisible to it, by design.
|
|
46
|
+
*
|
|
47
|
+
* @param {{game?: string, apiUrl?: string, repoRoot?: string, force?: boolean, json?: boolean}} flags
|
|
48
|
+
* @param {Object} [deps]
|
|
49
|
+
* @returns {Promise<number>}
|
|
50
|
+
*/
|
|
51
|
+
export async function init(flags = {}, deps = {}) {
|
|
52
|
+
const repoRoot = flags.repoRoot || process.cwd();
|
|
53
|
+
const context = deps.context || await createContext({
|
|
54
|
+
apiUrl: flags.apiUrl,
|
|
55
|
+
repoRoot,
|
|
56
|
+
requireAuth: true
|
|
57
|
+
});
|
|
58
|
+
// Every API call in this command goes through the one 401 handler, so a
|
|
59
|
+
// refused credential is cleared and explained here the same way it is
|
|
60
|
+
// everywhere else.
|
|
61
|
+
return withAuthHandling(context, () => linkRepo(context, flags, repoRoot));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function linkRepo(context, flags, repoRoot) {
|
|
65
|
+
const whoami = await context.api.cli.whoami();
|
|
66
|
+
|
|
67
|
+
const games = whoami.games || [];
|
|
68
|
+
let gameId = flags.game;
|
|
69
|
+
|
|
70
|
+
if (!gameId) {
|
|
71
|
+
if (games.length === 1) {
|
|
72
|
+
gameId = games[0].gameId;
|
|
73
|
+
ui.info(`Only one game is visible to this token, so using ${gameId}.`);
|
|
74
|
+
} else {
|
|
75
|
+
ui.info('Pass --game with one of these:');
|
|
76
|
+
ui.table(games, [
|
|
77
|
+
{ key: 'gameId', label: 'GAME ID' },
|
|
78
|
+
{ key: 'name', label: 'NAME' }
|
|
79
|
+
]);
|
|
80
|
+
throw new CliError('No game id given.', 1, { hint: 'ravensight-playtest init --game <gameId>' });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!games.some(game => game.gameId === gameId)) {
|
|
85
|
+
throw new CliError(
|
|
86
|
+
`${gameId} is not a game this token can see.`,
|
|
87
|
+
1,
|
|
88
|
+
{ hint: 'Approve it during login, or run login again and pick it.' }
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const existing = await loadConfig({ repoRoot });
|
|
93
|
+
if (existing && existing.game_id && existing.game_id !== gameId && !flags.force) {
|
|
94
|
+
throw new CliError(
|
|
95
|
+
`This repo is already linked to ${existing.game_id}.`,
|
|
96
|
+
1,
|
|
97
|
+
{ hint: 'Pass --force to relink it.' }
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const config = await saveConfig(
|
|
102
|
+
{ ...(existing || {}), ...defaultConfig(gameId), api_url: context.apiUrl },
|
|
103
|
+
{ repoRoot }
|
|
104
|
+
);
|
|
105
|
+
const gitignore = await ensureGitignore(repoRoot);
|
|
106
|
+
|
|
107
|
+
if (flags.json) {
|
|
108
|
+
ui.json({ ok: true, config_file: paths.configFile({ repoRoot }), config, gitignore });
|
|
109
|
+
return ExitCode.OK;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const name = (games.find(game => game.gameId === gameId) || {}).name || gameId;
|
|
113
|
+
ui.ok(`Linked this repo to ${name} (${gameId}).`);
|
|
114
|
+
ui.info(`Wrote ${paths.configFile({ repoRoot })}`);
|
|
115
|
+
if (gitignore === 'added') ui.info(`Added ${GITIGNORE_LINE} to .gitignore`);
|
|
116
|
+
if (gitignore === 'absent') ui.warn(`No .gitignore here. Add ${GITIGNORE_LINE} to whatever ignores build output.`);
|
|
117
|
+
ui.blank();
|
|
118
|
+
ui.info('Next: ravensight-playtest brief');
|
|
119
|
+
return ExitCode.OK;
|
|
120
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createClient } from '../api/client.js';
|
|
2
|
+
import { resolveApiUrl, apiHost } from '../paths.js';
|
|
3
|
+
import { setStoredToken, backendName } from '../auth/keychain.js';
|
|
4
|
+
import { pollForToken } from '../auth/deviceCode.js';
|
|
5
|
+
import { CLI_VERSION, CLIENT_NAME } from '../version.js';
|
|
6
|
+
import { ui } from '../ui/index.js';
|
|
7
|
+
import { openInBrowser } from '../dashboard.js';
|
|
8
|
+
import { ExitCode } from '../errors.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `ravensight-playtest login`
|
|
12
|
+
*
|
|
13
|
+
* The device code flow, whole: start one, show the user code, wait for the
|
|
14
|
+
* approval, store the token.
|
|
15
|
+
*
|
|
16
|
+
* No Anthropic key is involved at any point, and none is ever stored: model
|
|
17
|
+
* calls go through the Ravensight proxy under this same token, so the developer
|
|
18
|
+
* machine holds one credential and it is revocable from the dashboard.
|
|
19
|
+
*
|
|
20
|
+
* @param {{apiUrl?: string, json?: boolean, noBrowser?: boolean}} [flags]
|
|
21
|
+
* @param {Object} [deps] - test seams
|
|
22
|
+
* @returns {Promise<number>} exit code
|
|
23
|
+
*/
|
|
24
|
+
export async function login(flags = {}, deps = {}) {
|
|
25
|
+
const apiUrl = flags.apiUrl || resolveApiUrl();
|
|
26
|
+
const host = apiHost(apiUrl);
|
|
27
|
+
const api = deps.api || createClient({ apiUrl, ...(deps.clientOptions || {}) });
|
|
28
|
+
|
|
29
|
+
const started = await api.cli.deviceCode({ client: CLIENT_NAME, clientVersion: CLI_VERSION });
|
|
30
|
+
|
|
31
|
+
if (flags.json) {
|
|
32
|
+
ui.json({
|
|
33
|
+
user_code: started.user_code,
|
|
34
|
+
verification_uri: started.verification_uri,
|
|
35
|
+
expires_in: started.expires_in
|
|
36
|
+
});
|
|
37
|
+
} else {
|
|
38
|
+
ui.blank();
|
|
39
|
+
ui.info(`Open ${started.verification_uri}`);
|
|
40
|
+
ui.info(`and enter the code: ${started.user_code}`);
|
|
41
|
+
ui.blank();
|
|
42
|
+
if (!flags.noBrowser) await openInBrowser(started.verification_uri, deps);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const spin = flags.json ? null : ui.spinner('Waiting for approval');
|
|
46
|
+
let minted;
|
|
47
|
+
try {
|
|
48
|
+
minted = await pollForToken({
|
|
49
|
+
api,
|
|
50
|
+
deviceCode: started.device_code,
|
|
51
|
+
intervalSeconds: started.interval,
|
|
52
|
+
expiresInSeconds: started.expires_in,
|
|
53
|
+
sleep: deps.sleep,
|
|
54
|
+
now: deps.now
|
|
55
|
+
});
|
|
56
|
+
} finally {
|
|
57
|
+
if (spin) spin.stop();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const { backend } = await setStoredToken(host, minted.token);
|
|
61
|
+
|
|
62
|
+
if (flags.json) {
|
|
63
|
+
ui.json({ ok: true, host, backend, scopes: minted.scopes, games: minted.games });
|
|
64
|
+
return ExitCode.OK;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
ui.ok(`Logged in to ${host}.`);
|
|
68
|
+
ui.info(`Token stored in the ${backend === 'keychain' ? 'OS keychain' : 'credentials file'}.`);
|
|
69
|
+
if (backend === 'file') {
|
|
70
|
+
ui.warn('No OS keychain was available, so the token is in a 0600 file under your home directory.');
|
|
71
|
+
}
|
|
72
|
+
if (minted.games && minted.games.length > 0) {
|
|
73
|
+
ui.blank();
|
|
74
|
+
ui.info('Games this token can see:');
|
|
75
|
+
ui.table(minted.games, [
|
|
76
|
+
{ key: 'gameId', label: 'GAME ID' },
|
|
77
|
+
{ key: 'name', label: 'NAME' }
|
|
78
|
+
]);
|
|
79
|
+
} else {
|
|
80
|
+
ui.info('This token is scoped to every game you can see.');
|
|
81
|
+
}
|
|
82
|
+
ui.blank();
|
|
83
|
+
ui.info('Next: ravensight-playtest init --game <gameId>');
|
|
84
|
+
return ExitCode.OK;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What `check` reports about the credential store, without doing a login. */
|
|
88
|
+
export async function credentialBackend() {
|
|
89
|
+
return backendName();
|
|
90
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createClient } from '../api/client.js';
|
|
2
|
+
import { resolveApiUrl } from '../paths.js';
|
|
3
|
+
import { resolveToken } from '../auth/session.js';
|
|
4
|
+
import { deleteStoredToken } from '../auth/keychain.js';
|
|
5
|
+
import { ApiError } from '../api/errors.js';
|
|
6
|
+
import { ui } from '../ui/index.js';
|
|
7
|
+
import { ExitCode } from '../errors.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `ravensight-playtest logout`
|
|
11
|
+
*
|
|
12
|
+
* Revoke the token server side, then forget it locally, in that order. A local
|
|
13
|
+
* delete that ran first would leave a live credential on the server with
|
|
14
|
+
* nothing left to revoke it with.
|
|
15
|
+
*
|
|
16
|
+
* A 401 on the revoke is a success, not a failure: the token is already gone.
|
|
17
|
+
* Any other refusal still clears the local copy and says so, because the
|
|
18
|
+
* alternative is a developer stuck holding a credential they asked to be rid of.
|
|
19
|
+
*
|
|
20
|
+
* A token that came from `RAVENSIGHT_TOKEN` is not revoked: an env var is the
|
|
21
|
+
* caller's to manage, and revoking a shared CI secret because someone ran
|
|
22
|
+
* logout locally would break every other job using it.
|
|
23
|
+
*
|
|
24
|
+
* @param {{apiUrl?: string, json?: boolean}} [flags]
|
|
25
|
+
* @param {Object} [deps]
|
|
26
|
+
* @returns {Promise<number>}
|
|
27
|
+
*/
|
|
28
|
+
export async function logout(flags = {}, deps = {}) {
|
|
29
|
+
const apiUrl = flags.apiUrl || resolveApiUrl();
|
|
30
|
+
const { token, source, host } = await resolveToken({ apiUrl });
|
|
31
|
+
|
|
32
|
+
if (!token) {
|
|
33
|
+
if (flags.json) ui.json({ ok: true, host, revoked: false, cleared: false });
|
|
34
|
+
else ui.info(`Not logged in to ${host}.`);
|
|
35
|
+
return ExitCode.OK;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (source === 'env') {
|
|
39
|
+
if (flags.json) ui.json({ ok: true, host, revoked: false, cleared: false, source });
|
|
40
|
+
else {
|
|
41
|
+
ui.warn('That credential comes from RAVENSIGHT_TOKEN, so logout leaves it alone.');
|
|
42
|
+
ui.info('Unset the variable, or revoke the token from the dashboard settings page.');
|
|
43
|
+
}
|
|
44
|
+
return ExitCode.OK;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const api = deps.api || createClient({ apiUrl, token });
|
|
48
|
+
let revoked = false;
|
|
49
|
+
let detail = null;
|
|
50
|
+
try {
|
|
51
|
+
await api.cli.revoke();
|
|
52
|
+
revoked = true;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error instanceof ApiError && error.status === 401) revoked = true;
|
|
55
|
+
else detail = error instanceof ApiError ? error.message : String(error && error.message);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const cleared = await deleteStoredToken(host);
|
|
59
|
+
|
|
60
|
+
if (flags.json) {
|
|
61
|
+
ui.json({ ok: true, host, revoked, cleared, detail });
|
|
62
|
+
return ExitCode.OK;
|
|
63
|
+
}
|
|
64
|
+
if (revoked) ui.ok(`Logged out of ${host}.`);
|
|
65
|
+
else {
|
|
66
|
+
ui.warn(`Cleared the local token, but the server did not confirm the revoke: ${detail}`);
|
|
67
|
+
ui.info('Revoke it from the dashboard settings page to be sure.');
|
|
68
|
+
}
|
|
69
|
+
return ExitCode.OK;
|
|
70
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { createContext } from '../api/index.js';
|
|
2
|
+
import { resolveGameId } from '../config.js';
|
|
3
|
+
import { withAuthHandling } from '../auth/session.js';
|
|
4
|
+
import { readState } from '../state/index.js';
|
|
5
|
+
import { gameLink, jobLink, reviewLink, openInBrowser } from '../dashboard.js';
|
|
6
|
+
import { ui } from '../ui/index.js';
|
|
7
|
+
import { ExitCode } from '../errors.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the game id for a link command: the flag, the job's own journal (so
|
|
11
|
+
* `open <jobId>` works from any directory that ran it), then the repo config.
|
|
12
|
+
* @param {string|undefined} jobId
|
|
13
|
+
* @param {Object} flags
|
|
14
|
+
* @param {Object} context
|
|
15
|
+
* @returns {Promise<string>}
|
|
16
|
+
*/
|
|
17
|
+
async function gameFor(jobId, flags, context) {
|
|
18
|
+
if (flags.game) return flags.game;
|
|
19
|
+
if (jobId) {
|
|
20
|
+
const state = await readState(jobId, { repoRoot: context.repoRoot });
|
|
21
|
+
if (state && state.game_id) return state.game_id;
|
|
22
|
+
}
|
|
23
|
+
return resolveGameId(flags, context.config);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `ravensight-playtest open [jobId]`
|
|
28
|
+
*
|
|
29
|
+
* The dashboard page for a job, or for the game's playtest surface.
|
|
30
|
+
*
|
|
31
|
+
* The URL is always printed as well as opened. A headless box cannot open
|
|
32
|
+
* anything and a printed link costs nothing, so there is no case where this
|
|
33
|
+
* command leaves someone with no way to get there.
|
|
34
|
+
*
|
|
35
|
+
* No credential is needed: this builds a URL, it does not read anything.
|
|
36
|
+
*
|
|
37
|
+
* @param {string|undefined} jobId
|
|
38
|
+
* @param {Object} [flags]
|
|
39
|
+
* @param {Object} [deps]
|
|
40
|
+
* @returns {Promise<number>}
|
|
41
|
+
*/
|
|
42
|
+
export async function open(jobId, flags = {}, deps = {}) {
|
|
43
|
+
const context = deps.context || await createContext({
|
|
44
|
+
apiUrl: flags.apiUrl,
|
|
45
|
+
repoRoot: flags.repoRoot
|
|
46
|
+
});
|
|
47
|
+
const gameId = await gameFor(jobId, flags, context);
|
|
48
|
+
const link = jobId ? jobLink(gameId, jobId, context.apiUrl) : gameLink(gameId, context.apiUrl);
|
|
49
|
+
|
|
50
|
+
if (flags.json) {
|
|
51
|
+
ui.json({ url: link });
|
|
52
|
+
return ExitCode.OK;
|
|
53
|
+
}
|
|
54
|
+
ui.info(link);
|
|
55
|
+
await openInBrowser(link, deps);
|
|
56
|
+
return ExitCode.OK;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `ravensight-playtest review <jobId>`
|
|
61
|
+
*
|
|
62
|
+
* Open the post job review form, and say what is waiting to be reviewed.
|
|
63
|
+
*
|
|
64
|
+
* The review is not a formality: the marks it collects are what
|
|
65
|
+
* `finding precision` is measured from, and a job's `review_pending` flag stays
|
|
66
|
+
* true until this reviewer files one. So this command reads the job first and
|
|
67
|
+
* prints the finding counts, which is the context somebody needs before they
|
|
68
|
+
* start marking.
|
|
69
|
+
*
|
|
70
|
+
* @param {string|undefined} jobId
|
|
71
|
+
* @param {Object} [flags]
|
|
72
|
+
* @param {Object} [deps]
|
|
73
|
+
* @returns {Promise<number>}
|
|
74
|
+
*/
|
|
75
|
+
export async function review(jobId, flags = {}, deps = {}) {
|
|
76
|
+
const context = deps.context || await createContext({
|
|
77
|
+
apiUrl: flags.apiUrl,
|
|
78
|
+
repoRoot: flags.repoRoot,
|
|
79
|
+
requireAuth: true
|
|
80
|
+
});
|
|
81
|
+
return withAuthHandling(context, () => reviewWith(context, jobId, flags, deps));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function reviewWith(context, jobId, flags, deps) {
|
|
85
|
+
const gameId = await gameFor(jobId, flags, context);
|
|
86
|
+
|
|
87
|
+
if (!jobId) {
|
|
88
|
+
const summary = await context.api.jobs.summary(gameId);
|
|
89
|
+
const waiting = summary.review_pending || {};
|
|
90
|
+
if (flags.json) {
|
|
91
|
+
ui.json({ summary });
|
|
92
|
+
return ExitCode.OK;
|
|
93
|
+
}
|
|
94
|
+
if (waiting.job_id) {
|
|
95
|
+
ui.info(`${waiting.count} jobs are waiting for your review, starting with ${waiting.job_id}.`);
|
|
96
|
+
ui.info(reviewLink(gameId, waiting.job_id, context.apiUrl));
|
|
97
|
+
} else {
|
|
98
|
+
ui.info('Nothing is waiting for your review.');
|
|
99
|
+
}
|
|
100
|
+
return ExitCode.OK;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const { job } = await context.api.jobs.get(gameId, jobId);
|
|
104
|
+
const link = reviewLink(gameId, jobId, context.apiUrl);
|
|
105
|
+
|
|
106
|
+
if (flags.json) {
|
|
107
|
+
ui.json({ job, url: link });
|
|
108
|
+
return ExitCode.OK;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
ui.info(`Job ${jobId} is ${job.state}, with ${job.findings_count} findings.`);
|
|
112
|
+
if (job.severity_counts) {
|
|
113
|
+
ui.table([job.severity_counts], [
|
|
114
|
+
{ key: 'blocker', label: 'BLOCKER', align: 'right' },
|
|
115
|
+
{ key: 'major', label: 'MAJOR', align: 'right' },
|
|
116
|
+
{ key: 'minor', label: 'MINOR', align: 'right' },
|
|
117
|
+
{ key: 'cosmetic', label: 'COSMETIC', align: 'right' },
|
|
118
|
+
{ key: 'praise', label: 'PRAISE', align: 'right' }
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
ui.blank();
|
|
122
|
+
ui.info(link);
|
|
123
|
+
await openInBrowser(link, deps);
|
|
124
|
+
return ExitCode.OK;
|
|
125
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir } from 'node:fs/promises';
|
|
3
|
+
import { optionsFrom } from '../run/args.js';
|
|
4
|
+
import { getDeps } from '../run/deps.js';
|
|
5
|
+
import EXIT from '../run/exit.js';
|
|
6
|
+
import { createHeartbeat } from '../run/heartbeat.js';
|
|
7
|
+
import { createModelClient, ModelUnavailableError, ProxyError } from '../run/model.js';
|
|
8
|
+
import { jobDir } from '../run/paths.js';
|
|
9
|
+
import { runGameProfile, writeCapabilityReport } from '../run/profile.js';
|
|
10
|
+
import * as stateFile from '../run/state.js';
|
|
11
|
+
import { createUsageLedger, writeUsageFile } from '../run/usage.js';
|
|
12
|
+
import { formatCents, resolveSetup } from './run.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `ravensight-playtest profile`: read the repository, answer what this product
|
|
16
|
+
* can and cannot do for this game, and post the capability report.
|
|
17
|
+
*
|
|
18
|
+
* A profile job is `modules: ['game_profile']` and nothing else, which matters
|
|
19
|
+
* twice: it is the one job shape that does not need a complete expectations
|
|
20
|
+
* brief (profiling is what produces the facts a brief gets written against),
|
|
21
|
+
* and it buys exactly one run, whose module is `game_profile`, which is the run
|
|
22
|
+
* the `game_profile.*` steps are allowed to spend from.
|
|
23
|
+
*
|
|
24
|
+
* The source never leaves the machine. The capability report carries
|
|
25
|
+
* repo-relative paths, the commit and the dirty flag, and nothing else from the
|
|
26
|
+
* repository unless the developer opts in per upload category.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {Object} flags what cli-core's commander program hands over
|
|
31
|
+
* @param {Object} [injected] test seam
|
|
32
|
+
* @returns {Promise<number>} process exit code
|
|
33
|
+
*/
|
|
34
|
+
export async function profileCommand(flags = {}, injected = {}) {
|
|
35
|
+
const deps = injected.deps || (await getDeps());
|
|
36
|
+
const log = injected.log || (deps.ui && deps.ui.info ? message => deps.ui.info(message) : message => console.log(message));
|
|
37
|
+
try {
|
|
38
|
+
return await profile(flags, injected, deps, log);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const code = exitCodeFor(error);
|
|
41
|
+
if (code === EXIT.FAILED) throw error;
|
|
42
|
+
log(String(error && error.message ? error.message : error));
|
|
43
|
+
return code;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {Object} flags
|
|
49
|
+
* @param {Object} injected
|
|
50
|
+
* @param {Object} deps
|
|
51
|
+
* @param {(message: string) => void} log
|
|
52
|
+
* @returns {Promise<number>}
|
|
53
|
+
*/
|
|
54
|
+
async function profile(flags, injected, deps, log) {
|
|
55
|
+
const { options, unknown } = optionsFrom(flags);
|
|
56
|
+
if (unknown.length > 0) {
|
|
57
|
+
log(`I do not know these options: ${unknown.join(', ')}.`);
|
|
58
|
+
return EXIT.ENVIRONMENT;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const setup = await resolveSetup(deps, options, log);
|
|
62
|
+
if (setup.code !== undefined) return setup.code;
|
|
63
|
+
const { context, api, gameId, apiUrl, repoRoot, cliVersion } = setup;
|
|
64
|
+
|
|
65
|
+
const modules = ['game_profile'];
|
|
66
|
+
const pack = await deps.packs.get({ api, gameId, modules });
|
|
67
|
+
// A profile job is the one shape that does not need a brief, so a missing one
|
|
68
|
+
// is not an error here. It is still read when there is one: a brief that
|
|
69
|
+
// already states the audience and the core loop is context the profile should
|
|
70
|
+
// not have to guess at.
|
|
71
|
+
const brief = await deps.brief.get({ api, gameId }).catch(() => null);
|
|
72
|
+
const skill = (pack.skills || []).find(entry => entry.module === 'game_profile');
|
|
73
|
+
if (!skill) {
|
|
74
|
+
log('The content pack has no game-profile skill. Run "ravensight-playtest check" and try again.');
|
|
75
|
+
return EXIT.ENVIRONMENT;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const estimate = await api.jobs.estimate(gameId, { modules, personas: [] });
|
|
79
|
+
log(`Profiling this repository: ${formatCents(estimate.estimate_cents)}. Balance: ${formatCents(estimate.balance_cents)}.`);
|
|
80
|
+
if (estimate.ok === false) {
|
|
81
|
+
log(`That is more than the balance covers. Top up at ${estimate.topup_url || 'ravensight.io'} and try again.`);
|
|
82
|
+
return EXIT.ENVIRONMENT;
|
|
83
|
+
}
|
|
84
|
+
// A profile job carries no personas, so this never fires today, but the
|
|
85
|
+
// estimate is answered by the same route as `run` and should read the same
|
|
86
|
+
// way if that ever changes.
|
|
87
|
+
if (estimate.wall_warning) log(estimate.wall_warning);
|
|
88
|
+
const confirm = injected.confirm || (deps.ui && deps.ui.confirm);
|
|
89
|
+
if (!options.yes && confirm) {
|
|
90
|
+
const go = await confirm(`Profile this repository for ${formatCents(estimate.estimate_cents)}?`, { yes: options.yes });
|
|
91
|
+
if (!go) {
|
|
92
|
+
log('Nothing was charged.');
|
|
93
|
+
return EXIT.CANCELED;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const idempotencyKey = options.idempotencyKey || randomUUID();
|
|
98
|
+
const registered = await api.jobs.register(gameId, {
|
|
99
|
+
modules,
|
|
100
|
+
personas: [],
|
|
101
|
+
driver: options.driver || (context.config && context.config.driver) || 'playwright_web',
|
|
102
|
+
cli_version: cliVersion,
|
|
103
|
+
confirm_price_cents: estimate.estimate_cents,
|
|
104
|
+
build: { kind: 'repo', ref: repoRoot },
|
|
105
|
+
repo: { commit: (context.config && context.config.commit) || '', dirty: Boolean(context.config && context.config.dirty) },
|
|
106
|
+
pack_versions: { pack: pack.pack_version }
|
|
107
|
+
}, { idempotencyKey });
|
|
108
|
+
|
|
109
|
+
const jobId = registered.job_id;
|
|
110
|
+
const profileRun = (registered.runs || []).find(entry => entry.module === 'game_profile');
|
|
111
|
+
if (!profileRun) {
|
|
112
|
+
log('The server registered the job without a game_profile run, so there is nothing to spend a model turn from.');
|
|
113
|
+
return EXIT.FAILED;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const directory = jobDir(repoRoot, jobId);
|
|
117
|
+
await mkdir(directory, { recursive: true });
|
|
118
|
+
const journal = stateFile.newState({
|
|
119
|
+
jobId,
|
|
120
|
+
gameId,
|
|
121
|
+
idempotencyKey,
|
|
122
|
+
cliVersion,
|
|
123
|
+
packVersion: pack.pack_version,
|
|
124
|
+
modules,
|
|
125
|
+
driver: 'none',
|
|
126
|
+
target: repoRoot,
|
|
127
|
+
priceCents: registered.price && registered.price.price_cents
|
|
128
|
+
});
|
|
129
|
+
journal.runs[profileRun.run_id] = stateFile.newRunEntry({ runId: profileRun.run_id, module: 'game_profile', persona: null });
|
|
130
|
+
await stateFile.save(repoRoot, journal);
|
|
131
|
+
|
|
132
|
+
const createClient = injected.createClient || createModelClient;
|
|
133
|
+
const client = createClient({ apiUrl, token: injected.modelToken || api.token });
|
|
134
|
+
const ledger = createUsageLedger();
|
|
135
|
+
const heartbeat = createHeartbeat({
|
|
136
|
+
api,
|
|
137
|
+
gameId,
|
|
138
|
+
jobId,
|
|
139
|
+
runId: profileRun.run_id,
|
|
140
|
+
onCancel: () => log('the job was cancelled; stopping.'),
|
|
141
|
+
onUsage: usage => ledger.recordServerUsage(usage),
|
|
142
|
+
onError: () => {}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
await api.runs.transition(gameId, jobId, profileRun.run_id, 'launching', { reason: '' });
|
|
147
|
+
await api.runs.transition(gameId, jobId, profileRun.run_id, 'playing', { reason: '' });
|
|
148
|
+
heartbeat.start();
|
|
149
|
+
|
|
150
|
+
const { report, turns } = await runGameProfile({
|
|
151
|
+
client,
|
|
152
|
+
jobId,
|
|
153
|
+
runId: profileRun.run_id,
|
|
154
|
+
repoRoot,
|
|
155
|
+
skill: skill.content,
|
|
156
|
+
schemas: pack.schemas || [],
|
|
157
|
+
codeBrief: pack.code_brief || null,
|
|
158
|
+
brief,
|
|
159
|
+
engine: pack.engine || null,
|
|
160
|
+
commit: (context.config && context.config.commit) || null,
|
|
161
|
+
dirty: Boolean(context.config && context.config.dirty),
|
|
162
|
+
ledger,
|
|
163
|
+
log,
|
|
164
|
+
onProgress: progress => {
|
|
165
|
+
if (options.verbose) log(` ${progress.tool} ${progress.target || ''}`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// One last beat before the ledger is written: it is what carries the
|
|
170
|
+
// authoritative spend back, and prices are server side only.
|
|
171
|
+
await heartbeat.beat();
|
|
172
|
+
heartbeat.stop();
|
|
173
|
+
await api.runs.transition(gameId, jobId, profileRun.run_id, 'reporting', { reason: '' });
|
|
174
|
+
const target = await writeCapabilityReport(directory, report);
|
|
175
|
+
await writeUsageFile(directory, ledger);
|
|
176
|
+
|
|
177
|
+
const accepted = await api.jobs.capabilityReport(gameId, jobId, { report });
|
|
178
|
+
// `uploading` first. The server's table only allows `succeeded` from
|
|
179
|
+
// `uploading`, so completing straight out of `reporting` is a 409
|
|
180
|
+
// `invalid_transition`: the capability report has just been sent, which is
|
|
181
|
+
// this module's upload, so the state is true as well as required.
|
|
182
|
+
await api.runs.transition(gameId, jobId, profileRun.run_id, 'uploading', { reason: '' });
|
|
183
|
+
await api.runs.complete(gameId, jobId, profileRun.run_id, {
|
|
184
|
+
state: 'succeeded',
|
|
185
|
+
actions_taken: turns,
|
|
186
|
+
quit_reason: 'goal_reached'
|
|
187
|
+
});
|
|
188
|
+
await api.jobs.finish(gameId, jobId, {});
|
|
189
|
+
journal.finished = true;
|
|
190
|
+
journal.runs[profileRun.run_id].completed = true;
|
|
191
|
+
journal.runs[profileRun.run_id].state = 'succeeded';
|
|
192
|
+
await stateFile.save(repoRoot, journal);
|
|
193
|
+
|
|
194
|
+
log(`capability report: ${target}`);
|
|
195
|
+
summarise(report, log);
|
|
196
|
+
if (accepted && accepted.capability_report && accepted.capability_report.accepted === false) {
|
|
197
|
+
log('The server stored the report but has not accepted it yet. Confirm or correct it in the dashboard before running personas.');
|
|
198
|
+
}
|
|
199
|
+
return EXIT.OK;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
heartbeat.stop();
|
|
202
|
+
const code = exitCodeFor(error);
|
|
203
|
+
await safeFail({ api, gameId, jobId, runId: profileRun.run_id, error, log });
|
|
204
|
+
log(`profile failed: ${String(error && error.message)}`);
|
|
205
|
+
return code;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Mark the run failed and settle the job, so a profile that dies does not leave
|
|
211
|
+
* a charged job open and does not wait for the 24 hour stall sweep to give the
|
|
212
|
+
* money back.
|
|
213
|
+
*/
|
|
214
|
+
async function safeFail({ api, gameId, jobId, runId, error, log }) {
|
|
215
|
+
try {
|
|
216
|
+
await api.runs.complete(gameId, jobId, runId, {
|
|
217
|
+
state: 'failed',
|
|
218
|
+
reason: String(error && error.message).slice(0, 400),
|
|
219
|
+
quit_reason: 'error'
|
|
220
|
+
});
|
|
221
|
+
} catch (completeError) {
|
|
222
|
+
log(`the run could not be marked failed: ${String(completeError && completeError.message)}`);
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
await api.jobs.finish(gameId, jobId, { reason: 'profile failed' });
|
|
226
|
+
} catch (finishError) {
|
|
227
|
+
log(`the job could not be settled now; the hourly sweep will settle it: ${String(finishError && finishError.message)}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The part a developer reads: what is eligible, what is missing, and what the
|
|
233
|
+
* profile wants from them.
|
|
234
|
+
*/
|
|
235
|
+
function summarise(report, log) {
|
|
236
|
+
const eligible = (report.eligibility || []).filter(row => row.eligible);
|
|
237
|
+
const blocked = (report.eligibility || []).filter(row => !row.eligible);
|
|
238
|
+
log(`engine: ${report.game && report.game.engine ? report.game.engine : 'unknown'}`);
|
|
239
|
+
log(`eligible: ${eligible.length > 0 ? eligible.map(row => `${row.module}/${row.driver}`).join(', ') : 'nothing yet'}`);
|
|
240
|
+
for (const row of blocked) log(` not eligible: ${row.module}/${row.driver}: ${row.reason || 'no reason given'}`);
|
|
241
|
+
for (const ask of report.asks_for_customer || []) log(` asks: ${typeof ask === 'string' ? ask : ask.question || JSON.stringify(ask)}`);
|
|
242
|
+
for (const warning of report.warnings || []) log(` warning: ${typeof warning === 'string' ? warning : warning.message || JSON.stringify(warning)}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {any} error
|
|
247
|
+
* @returns {number}
|
|
248
|
+
*/
|
|
249
|
+
export function exitCodeFor(error) {
|
|
250
|
+
if (error instanceof ModelUnavailableError) return EXIT.MODEL;
|
|
251
|
+
if (error instanceof ProxyError) {
|
|
252
|
+
if (error.isBudget) return EXIT.BUDGET;
|
|
253
|
+
if (error.isClosed) return EXIT.CANCELED;
|
|
254
|
+
if (error.isAuth) return EXIT.AUTH;
|
|
255
|
+
// The kill switch, or a server with no platform key: a model-access
|
|
256
|
+
// failure, which is what 5 means.
|
|
257
|
+
if (error.status === 503) return EXIT.MODEL;
|
|
258
|
+
}
|
|
259
|
+
return EXIT.FAILED;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export default profileCommand;
|