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,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
|
+
}
|