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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/addons/ravensight_driver/driver.gd +836 -0
  4. package/addons/ravensight_driver/export_plugin.gd +51 -0
  5. package/addons/ravensight_driver/plugin.cfg +7 -0
  6. package/addons/ravensight_driver/plugin.gd +36 -0
  7. package/bin/ravensight-playtest.js +31 -0
  8. package/package.json +45 -0
  9. package/src/api/README.md +500 -0
  10. package/src/api/client.js +340 -0
  11. package/src/api/errors.js +115 -0
  12. package/src/api/http.js +194 -0
  13. package/src/api/index.js +107 -0
  14. package/src/auth/deviceCode.js +79 -0
  15. package/src/auth/keychain.js +159 -0
  16. package/src/auth/session.js +128 -0
  17. package/src/cli.js +335 -0
  18. package/src/commands/brief.js +303 -0
  19. package/src/commands/check.js +318 -0
  20. package/src/commands/fakeCore.js +379 -0
  21. package/src/commands/init.js +120 -0
  22. package/src/commands/login.js +90 -0
  23. package/src/commands/logout.js +70 -0
  24. package/src/commands/open.js +125 -0
  25. package/src/commands/profile.js +262 -0
  26. package/src/commands/resume.js +156 -0
  27. package/src/commands/run.js +1015 -0
  28. package/src/commands/upload.js +137 -0
  29. package/src/config.js +100 -0
  30. package/src/dashboard.js +97 -0
  31. package/src/detect.js +77 -0
  32. package/src/errors.js +44 -0
  33. package/src/fsutil.js +77 -0
  34. package/src/godot.js +85 -0
  35. package/src/packs/index.js +191 -0
  36. package/src/paths.js +129 -0
  37. package/src/run/aggregate.js +658 -0
  38. package/src/run/args.js +111 -0
  39. package/src/run/context.js +181 -0
  40. package/src/run/deps.js +184 -0
  41. package/src/run/drivers/driver.js +183 -0
  42. package/src/run/drivers/godot-observation.js +138 -0
  43. package/src/run/drivers/godot-project.js +475 -0
  44. package/src/run/drivers/godot-rpc.js +225 -0
  45. package/src/run/drivers/godot.js +587 -0
  46. package/src/run/drivers/index.js +52 -0
  47. package/src/run/drivers/web.js +385 -0
  48. package/src/run/exit.js +21 -0
  49. package/src/run/heartbeat.js +131 -0
  50. package/src/run/index.js +31 -0
  51. package/src/run/json.js +56 -0
  52. package/src/run/model.js +384 -0
  53. package/src/run/paths.js +88 -0
  54. package/src/run/personaLoop.js +871 -0
  55. package/src/run/profile.js +214 -0
  56. package/src/run/regenerate.js +149 -0
  57. package/src/run/repoTools.js +286 -0
  58. package/src/run/report.js +222 -0
  59. package/src/run/resume.js +272 -0
  60. package/src/run/secretScan.js +171 -0
  61. package/src/run/state.js +198 -0
  62. package/src/run/synthetic.js +206 -0
  63. package/src/run/tools.js +344 -0
  64. package/src/run/transcript.js +93 -0
  65. package/src/run/usage.js +115 -0
  66. package/src/state/index.js +105 -0
  67. package/src/states.js +104 -0
  68. package/src/ui/index.js +195 -0
  69. package/src/upload/allowlist.js +116 -0
  70. package/src/upload/index.js +467 -0
  71. package/src/upload/queue.js +114 -0
  72. 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;