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,222 @@
1
+ import { access, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import Ajv2020 from 'ajv/dist/2020.js';
4
+ import addFormats from 'ajv-formats';
5
+ import { RUN_FILES } from './paths.js';
6
+ import { assertNoSecrets } from './secretScan.js';
7
+
8
+ /**
9
+ * `report.json` and `report.md`: validated here before they are written,
10
+ * because the server validates them again at
11
+ * `POST /jobs/:jobId/runs/:runId/complete` and answers 422 `invalid_report`
12
+ * with the same ajv details. Catching it locally turns a wasted round trip
13
+ * into one cheap repair turn, which is what the dogfood gate asks for
14
+ * ("every report.json validates at complete on the first or the repair
15
+ * attempt").
16
+ *
17
+ * The schema is the one from the content pack, not a copy: `packs.get()`
18
+ * ships `schemas[]` with the server's own
19
+ * `playtest-report.schema.json` in it, so there is one definition of the
20
+ * shape and the CLI cannot drift from it between releases.
21
+ *
22
+ * Four fields are overwritten rather than trusted: `run_id`, `job_id`,
23
+ * `actions_taken` and `usage`. The model does not get to decide what run it
24
+ * was, how many actions it spent or what it cost. Everything else, including
25
+ * the narrative and the findings, is the model's.
26
+ */
27
+
28
+ /** Written into the report so a finding can be attributed to a prompt revision. */
29
+ export const REPORT_SCHEMA_VERSION = '1.0';
30
+
31
+ /**
32
+ * The four sections `report.md` has to carry.
33
+ *
34
+ * This list is the server's, verbatim from `reportValidator.js`'s
35
+ * `REQUIRED_MARKDOWN_SECTIONS`, and the server's check is a plain substring
36
+ * test. Missing one is a lint finding rather than a validation failure there,
37
+ * which is exactly why it is worth checking locally: the developer never sees
38
+ * `report_lint` on a run that already finished, whereas a repair turn here
39
+ * costs one metered question and fixes it.
40
+ */
41
+ export const REQUIRED_MARKDOWN_SECTIONS = Object.freeze([
42
+ 'What worked',
43
+ 'Top issues',
44
+ "What this run didn't cover",
45
+ 'Suggested next step'
46
+ ]);
47
+
48
+ /**
49
+ * Which required sections this markdown is missing, by the same substring test
50
+ * the server uses.
51
+ *
52
+ * @param {string} markdown
53
+ * @returns {Array<string>}
54
+ */
55
+ export function lintMarkdownSections(markdown) {
56
+ const text = typeof markdown === 'string' ? markdown : '';
57
+ return REQUIRED_MARKDOWN_SECTIONS.filter(section => !text.includes(section));
58
+ }
59
+
60
+ export class ReportInvalidError extends Error {
61
+ /**
62
+ * @param {Array<{instancePath: string, message: string}>} details
63
+ */
64
+ constructor(details) {
65
+ super(`report.json does not match the playtest report schema (${details.length} problem(s)).`);
66
+ this.name = 'ReportInvalidError';
67
+ this.details = details;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * @param {Array<{name: string, content: Object}>} schemas the pack's schemas
73
+ * @returns {{validate: (report: any) => {ok: boolean, details: Array}}}
74
+ */
75
+ export function createReportValidator(schemas) {
76
+ const entry = (schemas || []).find(schema => schema.name === 'playtest-report');
77
+ if (!entry) throw new Error('the content pack has no playtest-report schema; run a pack sync');
78
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
79
+ addFormats(ajv);
80
+ const compiled = ajv.compile(entry.content);
81
+ return {
82
+ /**
83
+ * @param {any} report
84
+ */
85
+ validate(report) {
86
+ const ok = compiled(report);
87
+ const details = (compiled.errors || []).map(error => ({
88
+ // ajv always sets one of the two: every error but "required" carries
89
+ // a non-empty instancePath, a "required" error carries
90
+ // missingProperty, and an "additionalProperties" error at the root
91
+ // carries additionalProperty (which used to print as "/undefined").
92
+ instancePath: error.instancePath || `/${(error.params && (error.params.missingProperty || error.params.additionalProperty)) || ''}`,
93
+ message: error.message
94
+ }));
95
+ return { ok: Boolean(ok), details };
96
+ }
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Stamp the fields the runner owns onto whatever the model wrote.
102
+ *
103
+ * @param {Object} report parsed report.json
104
+ * @param {Object} facts
105
+ * @returns {Object} a new report object
106
+ */
107
+ export function stampReport(report, facts) {
108
+ const stamped = { ...(report && typeof report === 'object' ? report : {}) };
109
+ stamped.schema_version = REPORT_SCHEMA_VERSION;
110
+ stamped.run_id = facts.runId;
111
+ stamped.job_id = facts.jobId;
112
+ stamped.persona = facts.persona;
113
+ stamped.driver = facts.driver;
114
+ stamped.target = facts.target;
115
+ stamped.started_at = facts.startedAt;
116
+ stamped.ended_at = facts.endedAt;
117
+ stamped.actions_taken = facts.actionsTaken;
118
+ stamped.usage = facts.usage;
119
+ if (facts.promptVersion && !stamped.prompt_version) stamped.prompt_version = facts.promptVersion;
120
+ // A run the proxy cut off for money did not quit for the reason the model
121
+ // thinks it did. `cost_cap` is in the schema's quit_reason enum precisely
122
+ // for this.
123
+ if (facts.quitReason) stamped.quit_reason = facts.quitReason;
124
+ // Why it stopped, in the runner's words rather than the model's guess: the
125
+ // action budget ran out, the screen stopped changing, the proxy cut it off.
126
+ if (facts.quitDetail) stamped.quit_detail = facts.quitDetail;
127
+ return stamped;
128
+ }
129
+
130
+ /**
131
+ * Check every `screenshot` evidence ref against the files actually on disk.
132
+ *
133
+ * The server does this against the upload manifest and quarantines what does
134
+ * not match, so a finding citing a screenshot that was never taken is a 422
135
+ * waiting to happen. Reported rather than rewritten: an invented evidence
136
+ * ref is the model's mistake to fix in its repair turn, and silently
137
+ * deleting it would leave a finding with no evidence, which the server then
138
+ * drops anyway.
139
+ *
140
+ * @param {Object} report
141
+ * @param {string} runDirectory
142
+ * @returns {Promise<Array<string>>} human readable problems, empty when clean
143
+ */
144
+ export async function checkEvidence(report, runDirectory) {
145
+ const problems = [];
146
+ const findings = Array.isArray(report && report.findings) ? report.findings : [];
147
+ for (const finding of findings) {
148
+ const evidence = Array.isArray(finding && finding.evidence) ? finding.evidence : [];
149
+ if (evidence.length === 0) {
150
+ problems.push(`${finding && finding.id}: no evidence, so the server will drop this finding`);
151
+ continue;
152
+ }
153
+ for (const item of evidence) {
154
+ if (!item || item.type !== 'screenshot') continue;
155
+ const ref = String(item.ref || '');
156
+ const candidate = path.resolve(runDirectory, ref);
157
+ // Refuse a ref that climbs out of the run directory before touching
158
+ // the filesystem with it.
159
+ if (!candidate.startsWith(path.resolve(runDirectory) + path.sep)) {
160
+ problems.push(`${finding.id}: evidence ref ${ref} points outside the run directory`);
161
+ continue;
162
+ }
163
+ try {
164
+ await access(candidate);
165
+ } catch {
166
+ problems.push(`${finding.id}: evidence ref ${ref} is not a file in this run directory`);
167
+ }
168
+ }
169
+ }
170
+ return problems;
171
+ }
172
+
173
+ /**
174
+ * Write both report files, refusing rather than redacting if either one
175
+ * carries something secret shaped.
176
+ *
177
+ * @param {Object} args
178
+ * @param {string} args.runDirectory
179
+ * @param {string} args.markdown
180
+ * @param {Object} args.report
181
+ * @returns {Promise<{markdownPath: string, reportPath: string}>}
182
+ */
183
+ export async function writeReportFiles({ runDirectory, markdown, report }) {
184
+ const json = `${JSON.stringify(report, null, 2)}\n`;
185
+ assertNoSecrets('report.md', markdown);
186
+ assertNoSecrets('report.json', json);
187
+ const markdownPath = path.join(runDirectory, RUN_FILES.reportMd);
188
+ const reportPath = path.join(runDirectory, RUN_FILES.reportJson);
189
+ await writeFile(markdownPath, markdown, 'utf8');
190
+ await writeFile(reportPath, json, 'utf8');
191
+ return { markdownPath, reportPath };
192
+ }
193
+
194
+ /**
195
+ * The repair prompt: exactly what was wrong, nothing else. One turn, one
196
+ * chance, because each turn is a metered question.
197
+ *
198
+ * @param {Array<{instancePath: string, message: string}>} details
199
+ * @param {Array<string>} evidenceProblems
200
+ * @returns {string}
201
+ */
202
+ export function repairInstruction(details, evidenceProblems, missingSections = []) {
203
+ const lines = ['The report you wrote was refused. Fix exactly these problems and call write_file again for the file that changed.'];
204
+ for (const detail of details) lines.push(`report.json ${detail.instancePath || '/'}: ${detail.message}`);
205
+ for (const problem of evidenceProblems) lines.push(`evidence: ${problem}`);
206
+ for (const section of missingSections) {
207
+ lines.push(`report.md is missing the required section heading "${section}". Add it with real content under it.`);
208
+ }
209
+ lines.push('Change nothing else. Do not add findings, and do not remove a finding you can still evidence.');
210
+ return lines.join('\n');
211
+ }
212
+
213
+ export default {
214
+ createReportValidator,
215
+ stampReport,
216
+ checkEvidence,
217
+ writeReportFiles,
218
+ repairInstruction,
219
+ lintMarkdownSections,
220
+ REQUIRED_MARKDOWN_SECTIONS,
221
+ ReportInvalidError
222
+ };
@@ -0,0 +1,272 @@
1
+ import { optionsFrom } from './args.js';
2
+ import { getDeps } from './deps.js';
3
+ import EXIT from './exit.js';
4
+ import { runDir as runDirFor } from './paths.js';
5
+ import { buildPartialReport, readTranscript, shouldFinalize } from './regenerate.js';
6
+ import { createReportValidator, writeReportFiles } from './report.js';
7
+ import * as stateFile from './state.js';
8
+ import { playJob, resolvePersonas } from '../commands/run.js';
9
+
10
+ /**
11
+ * `resumeJob`, the hand-off cli-core's `resume` command makes once it has
12
+ * decided there is something to carry on with.
13
+ *
14
+ * Three things make a resume safe, and all three are about not charging twice:
15
+ *
16
+ * 1. **The register is replayed, not remade.** `POST /jobs` with the
17
+ * `idempotency_key` out of `state.json` answers the stored job and freshly
18
+ * minted per-run tokens, without pricing or charging anything. That is also
19
+ * the only way to get tokens back: they are never written to disk (see
20
+ * ./state.js), because a JWT under the developer's repo is a secret on disk.
21
+ * 2. **A finished run keeps its result.** The journal, not the model, decides
22
+ * what has already been played, and a run marked `completed` is skipped.
23
+ * 3. **An unfinished persona run starts over, unless it got far enough to be
24
+ * worth finalising.** Live game state cannot be restored, so half a
25
+ * playthrough is normally worth less than a clean one (spec 17 section 1.8).
26
+ * But a run that recorded a real session and then lost its heartbeat has
27
+ * observations, notes and screenshots on disk, and replaying it spends the
28
+ * action budget again for a different playthrough. Past
29
+ * `PARTIAL_CHECKPOINT_FLOOR` checkpoints the run is finalised from its own
30
+ * transcript instead, with no model turn spent;
31
+ * `--restart-interrupted` forces the replay.
32
+ *
33
+ * Spend carries forward because the server is the one counting it: the run
34
+ * budgets and the job ceiling are what they were, and the proxy enforces them
35
+ * against usage already recorded.
36
+ *
37
+ * @param {Object} args cli-core's hand-off
38
+ * @param {Object} args.api the API client
39
+ * @param {string} args.gameId
40
+ * @param {string} args.jobId
41
+ * @param {Object} args.job the job row
42
+ * @param {Array<Object>} args.runs the server's run rows
43
+ * @param {Object|null} args.state the journal cli-core read
44
+ * @param {string} args.repoRoot
45
+ * @param {Object} args.flags
46
+ * @param {Object} [injected] test seam
47
+ * @returns {Promise<number>} an exit code
48
+ */
49
+ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot, flags = {} }, injected = {}) {
50
+ const deps = injected.deps || (await getDeps());
51
+ const log = injected.log || (deps.ui && deps.ui.info ? message => deps.ui.info(message) : message => console.log(message));
52
+ const { options } = optionsFrom(flags);
53
+
54
+ const journal = state || (await stateFile.load(repoRoot, jobId));
55
+ if (!journal) {
56
+ log(`There is no journal for ${jobId} in this repository, so there is nothing to resume from. "ravensight-playtest upload ${jobId}" can still drain any pending uploads.`);
57
+ return EXIT.ENVIRONMENT;
58
+ }
59
+ if (!journal.idempotency_key) {
60
+ log('That journal has no idempotency key, so the per-run tokens cannot be minted again safely. Start a new job instead.');
61
+ return EXIT.ENVIRONMENT;
62
+ }
63
+
64
+ // Reconcile the journal with what the server actually thinks: a run the sweep
65
+ // marked interrupted, or one that finished after the journal was last
66
+ // written, is the server's word against a stale file, and the server wins.
67
+ for (const row of runs || []) {
68
+ if (!journal.runs[row.run_id]) {
69
+ journal.runs[row.run_id] = stateFile.newRunEntry({ runId: row.run_id, module: row.module, persona: row.persona });
70
+ }
71
+ const entry = journal.runs[row.run_id];
72
+ entry.state = row.state;
73
+ if (stateFile.RUN_DONE_STATES.includes(row.state)) entry.completed = true;
74
+ if (Number.isFinite(Number(row.actions_taken))) entry.actions_taken = Number(row.actions_taken);
75
+ }
76
+ await stateFile.save(repoRoot, journal);
77
+
78
+ if ((runs || []).every(row => stateFile.isRunDone(journal.runs[row.run_id]))) {
79
+ log('Every run of that job has finished. Nothing to replay.');
80
+ return EXIT.OK;
81
+ }
82
+
83
+ const driverName = journal.driver || options.driver || 'playwright_web';
84
+ const target = journal.target || options.buildUrl || options.godotProject;
85
+ if (!target) {
86
+ log('The journal does not say what this job was pointed at. Pass --build-url or --godot-project.');
87
+ return EXIT.ENVIRONMENT;
88
+ }
89
+
90
+ const modules = journal.modules && journal.modules.length > 0 ? journal.modules : ['persona_playtest'];
91
+ const pack = await deps.packs.get({ api, gameId, modules });
92
+ // The brief the job was run against. A resumed run has to be told the same
93
+ // thing the first attempt was, or it reports against different rules.
94
+ const brief = await deps.brief.get({ api, gameId });
95
+
96
+ // Runs that got far enough to be worth finalising are dealt with first: each
97
+ // costs a `complete` and no model turn, and doing them before the replay pass
98
+ // means `outstanding` below already reflects them.
99
+ const finalized = [];
100
+ for (const row of (runs || []).filter(entry => entry.module === 'persona_playtest')) {
101
+ const entry = journal.runs[row.run_id];
102
+ if (stateFile.isRunDone(entry) || !shouldFinalize(entry, options)) continue;
103
+ if (await finalizeFromTranscript({ deps, api, gameId, jobId, repoRoot, row, entry, journal, options, pack, log })) {
104
+ finalized.push(row.run_id);
105
+ }
106
+ }
107
+
108
+ const outstanding = (runs || []).filter(row => !stateFile.isRunDone(journal.runs[row.run_id]));
109
+ if (outstanding.length === 0) {
110
+ log(`${finalized.length} interrupted run(s) were finalised from their own transcripts. Nothing left to replay.`);
111
+ if (!isTerminalJob(job)) {
112
+ const settled = await api.jobs.finish(gameId, jobId, { reason: 'resumed and finalised' });
113
+ journal.finished = true;
114
+ await stateFile.save(repoRoot, journal);
115
+ log(`job ${jobId} ${settled.job ? settled.job.state : 'finished'}: refunded ${formatCents(settled.refunded_cents)}.`);
116
+ }
117
+ return EXIT.OK;
118
+ }
119
+
120
+ const personaSlugs = [...new Set((runs || []).filter(row => row.persona).map(row => row.persona))];
121
+ const slugs = personaSlugs.length > 0 ? personaSlugs : resolvePersonas(options, null, pack);
122
+ const personas = slugs
123
+ .map(slug => (pack.personas || []).find(persona => persona.slug === slug))
124
+ .filter(Boolean);
125
+ if (personas.length === 0) {
126
+ log('None of that job\'s personas are in the current content pack. Run a pack sync and try again.');
127
+ return EXIT.ENVIRONMENT;
128
+ }
129
+
130
+ // The replay. Same key, same body: the server matches on `{gameId,
131
+ // idempotencyKey}` and answers the stored job, so nothing is priced and
132
+ // nothing is charged. The tokens that come back are new ones.
133
+ const replayed = await api.jobs.register(gameId, {
134
+ modules,
135
+ personas: slugs,
136
+ driver: driverName,
137
+ cli_version: deps.cliVersion || api.cliVersion,
138
+ confirm_price_cents: Number.isInteger(journal.price_cents) ? journal.price_cents : 0,
139
+ build: { kind: driverName === 'playwright_web' ? 'url' : 'godot_project', ref: target },
140
+ repo: { commit: '', dirty: false },
141
+ max_actions: (journal.options && journal.options.maxActions) ?? null,
142
+ pack_versions: { pack: pack.pack_version }
143
+ }, { idempotencyKey: journal.idempotency_key });
144
+
145
+ if (!replayed.replayed) {
146
+ // A register that was NOT a replay means the key did not match a stored
147
+ // job, which means money just moved. Say so rather than letting it look
148
+ // like a free resume.
149
+ log('That idempotency key did not match a stored job, so a new job was registered and charged.');
150
+ }
151
+
152
+ log(`resuming ${jobId}: ${outstanding.length} run(s) to play again of ${(runs || []).length}.`);
153
+
154
+ const resumeOptions = {
155
+ ...options,
156
+ maxActions: (journal.options && journal.options.maxActions) ?? options.maxActions,
157
+ concurrency: options.concurrency ?? (journal.options && journal.options.concurrency),
158
+ uploadVideo: options.uploadVideo ?? (journal.options && journal.options.uploadVideo),
159
+ uploadTranscript: options.uploadTranscript ?? (journal.options && journal.options.uploadTranscript)
160
+ };
161
+
162
+ return playJob({
163
+ deps,
164
+ api,
165
+ apiUrl: api.apiUrl,
166
+ gameId,
167
+ jobId,
168
+ repoRoot,
169
+ runs: replayed.runs || [],
170
+ pack,
171
+ personas,
172
+ driverName,
173
+ target,
174
+ options: resumeOptions,
175
+ journal,
176
+ config: null,
177
+ brief,
178
+ injected,
179
+ log,
180
+ // A job that is already terminal must not be settled twice: `finish` is
181
+ // the one writer of a terminal state and the one issuer of a refund.
182
+ finish: !isTerminalJob(job)
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Finalise one interrupted run from its transcript: no game, no model turn,
188
+ * just what it already wrote down.
189
+ *
190
+ * @returns {Promise<boolean>} whether the server took it
191
+ */
192
+ async function finalizeFromTranscript({ deps, api, gameId, jobId, repoRoot, row, entry, journal, options, pack, log }) {
193
+ const directory = runDirFor(repoRoot, jobId, row.run_id);
194
+ const transcript = await readTranscript(directory);
195
+ if (transcript.observations === 0) return false;
196
+
197
+ const { report, markdown } = buildPartialReport({
198
+ runId: row.run_id,
199
+ jobId,
200
+ persona: row.persona || entry.persona || 'unknown',
201
+ driver: journal.driver || 'playwright_web',
202
+ target: journal.target || '',
203
+ transcript,
204
+ usage: { by_model: [], total_usd: 0 }
205
+ });
206
+
207
+ // Validated against the same pack schema a live report is, because the server
208
+ // validates it again and a partial report that 422s is worse than none.
209
+ if (pack && Array.isArray(pack.schemas)) {
210
+ const validation = createReportValidator(pack.schemas).validate(report);
211
+ if (!validation.ok) {
212
+ log(`run ${row.run_id}: the rebuilt report does not validate (${validation.details.map(detail => `${detail.instancePath}: ${detail.message}`).join('; ')}); replaying it instead.`);
213
+ return false;
214
+ }
215
+ }
216
+
217
+ await writeReportFiles({ runDirectory: directory, markdown, report });
218
+
219
+ try {
220
+ // `interrupted` can go to `reporting`, which is where this run actually is:
221
+ // it has a report now. Then `uploading`, then the delivery.
222
+ await api.runs.transition(gameId, jobId, row.run_id, 'reporting', { reason: 'rebuilt from transcript' });
223
+ await deps.finalizeRun(directory, {
224
+ api,
225
+ gameId,
226
+ jobId,
227
+ runId: row.run_id,
228
+ includeVideo: Boolean(options.uploadVideo),
229
+ includeTranscript: Boolean(options.uploadTranscript),
230
+ body: {
231
+ state: 'failed',
232
+ reason: 'interrupted; finalised from its transcript',
233
+ actions_taken: report.actions_taken,
234
+ quit_reason: 'error',
235
+ report,
236
+ report_md: markdown
237
+ }
238
+ });
239
+ } catch (error) {
240
+ log(`run ${row.run_id}: could not be finalised (${String(error && error.message)}); it will be replayed.`);
241
+ return false;
242
+ }
243
+
244
+ await stateFile.patchRun(repoRoot, journal, row.run_id, {
245
+ state: 'failed',
246
+ completed: true,
247
+ uploaded: true,
248
+ report_written: true,
249
+ quit_reason: 'error',
250
+ actions_taken: report.actions_taken,
251
+ finalized_from_transcript: true
252
+ });
253
+ log(`run ${row.run_id}: finalised from its transcript at ${report.actions_taken} action(s), no replay and no model turn spent.`);
254
+ return true;
255
+ }
256
+
257
+ function formatCents(cents) {
258
+ if (!Number.isFinite(Number(cents))) return 'unknown';
259
+ return `$${(Number(cents) / 100).toFixed(2)}`;
260
+ }
261
+
262
+ const TERMINAL_JOB_STATES = ['succeeded', 'failed', 'canceled', 'budget_exceeded', 'stalled'];
263
+
264
+ /**
265
+ * @param {Object|null|undefined} job
266
+ * @returns {boolean}
267
+ */
268
+ export function isTerminalJob(job) {
269
+ return Boolean(job) && TERMINAL_JOB_STATES.includes(job.state);
270
+ }
271
+
272
+ export default resumeJob;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Local secret scan, run before this CLI writes a report or hands a
3
+ * directory to the upload queue.
4
+ *
5
+ * This is a port of the Ravensight API's `src/playtest/secretScan.js`, and
6
+ * it is a port on purpose: the server refuses `POST .../complete` with 422
7
+ * `secrets_detected` using those patterns, so a local scan built on
8
+ * different patterns would either pass something the server then refuses
9
+ * (a wasted run) or refuse something the server would have taken. Keep the
10
+ * two lists in step.
11
+ *
12
+ * Two rules carried over verbatim:
13
+ *
14
+ * 1. Nothing is ever redacted in place. A hit means the artifact is
15
+ * refused, because the secret has to be rotated and the only way the
16
+ * developer learns that is by being told which shape was found.
17
+ * 2. The matched text never appears in the return value, a log line or an
18
+ * error message. A function whose whole job is finding secrets is the
19
+ * last place that should leak one, so callers only ever see
20
+ * `{ type, count }`.
21
+ *
22
+ * Every quantifier below is bounded. The JWT shape is deliberately not a
23
+ * backtracking regex: `JWT_CANDIDATE` grabs one bounded run of
24
+ * JWT-charset-or-dot characters in a single linear pass, and
25
+ * `candidateHasJwtShape` then checks segment lengths with plain string
26
+ * operations. See the server file's header for the measurements behind
27
+ * that.
28
+ */
29
+
30
+ /** Hard cap. Refused outright rather than scanned. */
31
+ export const MAX_SCAN_BYTES = 25 * 1024 * 1024;
32
+
33
+ /** How much new text each pass looks at. */
34
+ export const SLICE_SIZE = 65536;
35
+
36
+ /**
37
+ * How far each window reaches past its own core, so a match that starts in
38
+ * the core is always fully visible and a word-boundary check at the core's
39
+ * first character sees the real character before it. Must exceed the
40
+ * longest possible single match (the JWT candidate cap, 1600).
41
+ */
42
+ export const SLICE_OVERLAP = 2048;
43
+
44
+ const JWT_SEGMENT_MIN = 10;
45
+ const JWT_SEGMENT_MAX = 512;
46
+ const JWT_CANDIDATE = /[A-Za-z0-9_.-]{22,1600}/g;
47
+
48
+ /**
49
+ * Whether a candidate run holds three consecutive dot-separated segments
50
+ * each of JWT segment length. Every window of three is checked, not just
51
+ * the first, because the candidate run can pick up a leading `v1.` style
52
+ * token that pushes the real JWT past position three.
53
+ *
54
+ * @param {string} candidate
55
+ * @returns {boolean}
56
+ */
57
+ export function candidateHasJwtShape(candidate) {
58
+ const parts = candidate.split('.');
59
+ for (let i = 0; i + 2 < parts.length; i += 1) {
60
+ const ok = [parts[i], parts[i + 1], parts[i + 2]].every(
61
+ part => part.length >= JWT_SEGMENT_MIN && part.length <= JWT_SEGMENT_MAX
62
+ );
63
+ if (ok) return true;
64
+ }
65
+ return false;
66
+ }
67
+
68
+ /**
69
+ * The patterns, one per shape. A string can trip more than one (a
70
+ * `TOKEN=gt_live_...` line trips both `key_value_secret` and
71
+ * `ravensight_token`) and that is intended: two reasons to refuse, not a
72
+ * competition over which pattern owns the match.
73
+ */
74
+ export const PATTERNS = [
75
+ { type: 'anthropic_api_key', regex: /\bsk-ant-[A-Za-z0-9_-]{10,256}\b/g },
76
+ { type: 'aws_access_key', regex: /\bAKIA[0-9A-Z]{16}\b/g },
77
+ { type: 'github_token', regex: /\b(?:ghp_[A-Za-z0-9]{20,255}|github_pat_[A-Za-z0-9_]{20,255})\b/g },
78
+ { type: 'stripe_live_key', regex: /\b(?:sk|rk)_live_[A-Za-z0-9]{10,256}\b/g },
79
+ { type: 'private_key_block', regex: /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----/g },
80
+ { type: 'ravensight_token', regex: /\bgt_(?:live|mcp|cli)_[A-Za-z0-9]{10,256}\b/g },
81
+ { type: 'jwt', regex: JWT_CANDIDATE, accept: candidateHasJwtShape },
82
+ { type: 'key_value_secret', regex: /\b(?:KEY|SECRET|TOKEN|PASSWORD)\s{0,20}=\s{0,20}\S{12,512}/gi }
83
+ ];
84
+
85
+ export class ScanInputTooLargeError extends Error {
86
+ constructor(byteLength) {
87
+ super(`secretScan: input is ${byteLength} bytes, over the ${MAX_SCAN_BYTES} byte cap`);
88
+ this.name = 'ScanInputTooLargeError';
89
+ }
90
+ }
91
+
92
+ export class SecretsFoundError extends Error {
93
+ /**
94
+ * @param {string} what human name of the thing that was refused
95
+ * @param {Array<{type: string, count: number}>} secrets
96
+ */
97
+ constructor(what, secrets) {
98
+ const shapes = secrets.map(s => `${s.type} (${s.count})`).join(', ');
99
+ super(`${what} contains something shaped like a secret: ${shapes}. Rotate it, take it out and try again.`);
100
+ this.name = 'SecretsFoundError';
101
+ this.secrets = secrets;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Count matches of one pattern whose start falls inside this slice's own
107
+ * region, which is what makes slicing exactly-once rather than either
108
+ * double counting a match seen in two windows or missing one at a boundary.
109
+ *
110
+ * @param {{regex: RegExp, accept?: (text: string) => boolean}} pattern
111
+ * @param {string} window
112
+ * @param {number} windowOffset
113
+ * @param {number} coreStart
114
+ * @param {number} coreEnd
115
+ * @returns {number}
116
+ */
117
+ function countCoreMatches({ regex, accept }, window, windowOffset, coreStart, coreEnd) {
118
+ regex.lastIndex = 0;
119
+ let count = 0;
120
+ let match = regex.exec(window);
121
+ while (match !== null) {
122
+ if (!accept || accept(match[0])) {
123
+ const absoluteStart = windowOffset + match.index;
124
+ if (absoluteStart >= coreStart && absoluteStart < coreEnd) count += 1;
125
+ }
126
+ // Never true for the patterns above, but it keeps the loop provably
127
+ // finite if a future pattern can match zero characters.
128
+ if (match[0].length === 0) regex.lastIndex += 1;
129
+ match = regex.exec(window);
130
+ }
131
+ return count;
132
+ }
133
+
134
+ /**
135
+ * @param {string} text
136
+ * @returns {Array<{type: string, count: number}>} only the shapes that
137
+ * matched, never the matched text
138
+ * @throws {ScanInputTooLargeError}
139
+ */
140
+ export function scanForSecrets(text) {
141
+ if (typeof text !== 'string' || text.length === 0) return [];
142
+
143
+ const byteLength = Buffer.byteLength(text, 'utf8');
144
+ if (byteLength > MAX_SCAN_BYTES) throw new ScanInputTooLargeError(byteLength);
145
+
146
+ const counts = new Map();
147
+ for (let coreStart = 0; coreStart < text.length; coreStart += SLICE_SIZE) {
148
+ const coreEnd = Math.min(coreStart + SLICE_SIZE, text.length);
149
+ const windowStart = Math.max(0, coreStart - SLICE_OVERLAP);
150
+ const windowEnd = Math.min(coreEnd + SLICE_OVERLAP, text.length);
151
+ const window = text.slice(windowStart, windowEnd);
152
+ for (const pattern of PATTERNS) {
153
+ const matched = countCoreMatches(pattern, window, windowStart, coreStart, coreEnd);
154
+ if (matched > 0) counts.set(pattern.type, (counts.get(pattern.type) || 0) + matched);
155
+ }
156
+ }
157
+ return [...counts.entries()].map(([type, count]) => ({ type, count }));
158
+ }
159
+
160
+ /**
161
+ * Refuse rather than write. Every file this CLI produces from model output
162
+ * goes through here first.
163
+ *
164
+ * @param {string} what human name used in the error
165
+ * @param {string} text
166
+ * @throws {SecretsFoundError}
167
+ */
168
+ export function assertNoSecrets(what, text) {
169
+ const secrets = scanForSecrets(text);
170
+ if (secrets.length > 0) throw new SecretsFoundError(what, secrets);
171
+ }