ravensight-playtest 0.1.0 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -3
- package/package.json +12 -1
- package/src/api/client.js +25 -2
- package/src/cli.js +40 -1
- package/src/commands/fakeCore.js +68 -3
- package/src/commands/fix.js +1059 -0
- package/src/commands/run.js +12 -3
- package/src/dashboard.js +15 -5
- package/src/packs/index.js +17 -2
- package/src/run/args.js +6 -1
- package/src/run/fixLoop.js +489 -0
- package/src/run/git.js +331 -0
- package/src/run/index.js +7 -3
- package/src/run/model.js +7 -1
- package/src/run/repoTools.js +373 -12
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { appendFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { optionsFrom } from '../run/args.js';
|
|
5
|
+
import { getDeps } from '../run/deps.js';
|
|
6
|
+
import { createDriver } from '../run/drivers/index.js';
|
|
7
|
+
import EXIT from '../run/exit.js';
|
|
8
|
+
import { applyFix, fixSystem, MAX_PLAN_IMAGES, planContent, planFix, resolveFixMaxActions, summariseFix } from '../run/fixLoop.js';
|
|
9
|
+
import { createGit, fixBranchName, preflight, startBranch } from '../run/git.js';
|
|
10
|
+
import { createHeartbeat } from '../run/heartbeat.js';
|
|
11
|
+
import { createModelClient } from '../run/model.js';
|
|
12
|
+
import { jobDir, runDir as runDirFor, RUN_FILES, screenshotsDir } from '../run/paths.js';
|
|
13
|
+
import { runPersona } from '../run/personaLoop.js';
|
|
14
|
+
import { createRepoTools, PROFILE_TOOLS } from '../run/repoTools.js';
|
|
15
|
+
import { assertNoSecrets } from '../run/secretScan.js';
|
|
16
|
+
import * as stateFile from '../run/state.js';
|
|
17
|
+
import { createUsageLedger, writeUsageFile } from '../run/usage.js';
|
|
18
|
+
import { createInterruptHandler, deliver, exitCodeFor, explainRegisterRefusal, formatCents, resolveSetup, resolveTarget, skillFor } from './run.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `ravensight-playtest fix <target>`: take one finding, change the code on a
|
|
22
|
+
* new branch, replay the finding to see whether the change worked, and hand
|
|
23
|
+
* the developer a pull request to open.
|
|
24
|
+
*
|
|
25
|
+
* Three rules shape everything else in this file:
|
|
26
|
+
*
|
|
27
|
+
* 1. **It edits, it never publishes.** All git usage lives in
|
|
28
|
+
* `src/run/git.js`, which has no commit, no push and no way to reach a
|
|
29
|
+
* forge. The command ends by printing the pull request command; running it
|
|
30
|
+
* is the developer's decision, made with the diff in front of them.
|
|
31
|
+
* 2. **It buys the answer "no" as cheaply as possible.** The triage call comes
|
|
32
|
+
* before the first edit, because a finding that is design intent should
|
|
33
|
+
* cost a refunded unit rather than a branch full of changes nobody wanted.
|
|
34
|
+
* A fix that changes nothing is refunded the same way.
|
|
35
|
+
* 3. **A verify run that cannot see the change is worse than none.** The
|
|
36
|
+
* verify plays the build the developer points at, so a build served from
|
|
37
|
+
* somewhere else would answer about yesterday's code. That is refused
|
|
38
|
+
* rather than reported, because a false "fixed" is the one answer this
|
|
39
|
+
* command must never give.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** The job a fix registers. The verify run comes back with it. */
|
|
43
|
+
export const FIX_MODULES = Object.freeze(['fix']);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What the content pack is asked for: the fix skill and the persona skill,
|
|
47
|
+
* because the verify run is an ordinary persona run.
|
|
48
|
+
*/
|
|
49
|
+
export const PACK_MODULES = Object.freeze(['fix', 'persona_playtest']);
|
|
50
|
+
|
|
51
|
+
/** The changeset lands beside `report.md` in the fix run directory. */
|
|
52
|
+
export const CHANGESET_FILE = 'changeset.json';
|
|
53
|
+
|
|
54
|
+
/** At or above this confidence, `design_intent` stops the fix before it edits. */
|
|
55
|
+
export const DESIGN_INTENT_CONFIDENCE = 0.7;
|
|
56
|
+
|
|
57
|
+
/** The verify run's own action ceiling: it reproduces one finding, nothing more. */
|
|
58
|
+
export const VERIFY_MAX_ACTIONS = 20;
|
|
59
|
+
|
|
60
|
+
/** How the judged `reproduced` score reads in words. */
|
|
61
|
+
export const FIXED_BELOW = 0.35;
|
|
62
|
+
export const REPRODUCED_ABOVE = 0.65;
|
|
63
|
+
|
|
64
|
+
/** A changeset carries at most this many files. */
|
|
65
|
+
export const MAX_CHANGESET_FILES = 20;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {string} target `<jobId>/<label>` or a finding id
|
|
69
|
+
* @param {Object} flags what commander hands over
|
|
70
|
+
* @param {Object} [injected] test seam
|
|
71
|
+
* @returns {Promise<number>} process exit code
|
|
72
|
+
*/
|
|
73
|
+
export async function fixCommand(target, flags = {}, injected = {}) {
|
|
74
|
+
const deps = injected.deps || (await getDeps());
|
|
75
|
+
const log = injected.log || (deps.ui && deps.ui.info ? message => deps.ui.info(message) : message => console.log(message));
|
|
76
|
+
try {
|
|
77
|
+
return await fix(target, flags, injected, deps, log);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
const code = exitCodeFor(error);
|
|
80
|
+
if (code === EXIT.FAILED) throw error;
|
|
81
|
+
log(String(error && error.message ? error.message : error));
|
|
82
|
+
return code;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `<jobId>/<label>` or a bare finding id.
|
|
88
|
+
*
|
|
89
|
+
* The last slash wins, because a job id has no slash in it and a label never
|
|
90
|
+
* does either, while an id from somewhere else might.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} target
|
|
93
|
+
* @returns {{jobId: string|null, label: string|null, findingId: string|null}|null}
|
|
94
|
+
*/
|
|
95
|
+
export function parseTarget(target) {
|
|
96
|
+
const value = String(target || '').trim();
|
|
97
|
+
if (!value) return null;
|
|
98
|
+
const slash = value.lastIndexOf('/');
|
|
99
|
+
if (slash > 0 && slash < value.length - 1) {
|
|
100
|
+
return { jobId: value.slice(0, slash), label: value.slice(slash + 1), findingId: null };
|
|
101
|
+
}
|
|
102
|
+
if (slash !== -1) return null;
|
|
103
|
+
return { jobId: null, label: null, findingId: value };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The verify run plays a URL, and a URL this machine does not serve cannot
|
|
108
|
+
* show an edit that only exists in this working tree. A hosted build would
|
|
109
|
+
* answer about the last upload instead, and answer it confidently.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} driverName
|
|
112
|
+
* @param {string|null} target
|
|
113
|
+
* @returns {string|null} the refusal, or null
|
|
114
|
+
*/
|
|
115
|
+
export function hostedTargetRefusal(driverName, target) {
|
|
116
|
+
if (driverName !== 'playwright_web' || !target) return null;
|
|
117
|
+
let host;
|
|
118
|
+
try {
|
|
119
|
+
host = new URL(String(target)).hostname.toLowerCase();
|
|
120
|
+
} catch {
|
|
121
|
+
return `${target} is not a URL the verify run can play. Pass --build-url with a local address, or --no-verify.`;
|
|
122
|
+
}
|
|
123
|
+
// Node keeps the brackets on an IPv6 hostname, so `http://[::1]:8080/`
|
|
124
|
+
// answers `[::1]` and never the bare form.
|
|
125
|
+
const local = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'
|
|
126
|
+
|| host === '0.0.0.0' || host.endsWith('.localhost');
|
|
127
|
+
if (local) return null;
|
|
128
|
+
return `${target} is not served from this machine, so a verify run there cannot see a change that only exists in this working tree. Serve the build locally and pass --build-url, or pass --no-verify to fix without verifying.`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The verdict in the words a developer reads.
|
|
133
|
+
*
|
|
134
|
+
* @param {{reproduced: number|null}|null} verify
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
export function verifyWords(verify) {
|
|
138
|
+
if (!verify) return 'not verified';
|
|
139
|
+
const score = verify.reproduced;
|
|
140
|
+
if (typeof score !== 'number' || !Number.isFinite(score)) return 'unknown, the verify run reached no verdict';
|
|
141
|
+
if (score < FIXED_BELOW) return `fixed (reproduced ${score})`;
|
|
142
|
+
if (score > REPRODUCED_ABOVE) return `still reproduced (${score})`;
|
|
143
|
+
return `unknown (reproduced ${score})`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {string} target
|
|
148
|
+
* @param {Object} flags
|
|
149
|
+
* @param {Object} injected
|
|
150
|
+
* @param {Object} deps
|
|
151
|
+
* @param {(message: string) => void} log
|
|
152
|
+
* @returns {Promise<number>}
|
|
153
|
+
*/
|
|
154
|
+
async function fix(target, flags, injected, deps, log) {
|
|
155
|
+
const { options, unknown } = optionsFrom(flags);
|
|
156
|
+
if (unknown.length > 0) {
|
|
157
|
+
log(`I do not know these options: ${unknown.join(', ')}. Run "ravensight-playtest fix --help".`);
|
|
158
|
+
return EXIT.ENVIRONMENT;
|
|
159
|
+
}
|
|
160
|
+
const parsed = parseTarget(target);
|
|
161
|
+
if (!parsed) {
|
|
162
|
+
log('Name the finding to fix: either <jobId>/<label>, like pt_job_1/F002, or a finding id.');
|
|
163
|
+
return EXIT.ENVIRONMENT;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const setup = await resolveSetup(deps, options, log);
|
|
167
|
+
if (setup.code !== undefined) return setup.code;
|
|
168
|
+
const { context, api, gameId, apiUrl, repoRoot, cliVersion } = setup;
|
|
169
|
+
|
|
170
|
+
const wantsVerify = options.verify !== false;
|
|
171
|
+
const driverName = options.driver || (context.config && context.config.driver) || 'playwright_web';
|
|
172
|
+
const buildTarget = resolveTarget(options, context.config, driverName);
|
|
173
|
+
|
|
174
|
+
// Said before anything is charged, because it is the difference between a
|
|
175
|
+
// verify that means something and one that does not.
|
|
176
|
+
if (wantsVerify) {
|
|
177
|
+
if (!buildTarget) {
|
|
178
|
+
log('The verify run needs a build to play: pass --build-url for a web build or --godot-project for a Godot project, or pass --no-verify.');
|
|
179
|
+
return EXIT.ENVIRONMENT;
|
|
180
|
+
}
|
|
181
|
+
const refusal = hostedTargetRefusal(driverName, buildTarget);
|
|
182
|
+
if (refusal) {
|
|
183
|
+
log(refusal);
|
|
184
|
+
return EXIT.ENVIRONMENT;
|
|
185
|
+
}
|
|
186
|
+
if (driverName === 'playwright_web') {
|
|
187
|
+
log(`The verify run plays ${buildTarget}, which has to serve this working tree. Rebuild and serve the branch before the verify, or its answer is about the old build.`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const pack = await deps.packs.get({ api, gameId, modules: PACK_MODULES });
|
|
192
|
+
const fixSkill = (pack.skills || []).find(entry => entry.module === 'fix');
|
|
193
|
+
if (!fixSkill) {
|
|
194
|
+
log('The content pack has no fix skill. Run "ravensight-playtest check" and try again.');
|
|
195
|
+
return EXIT.ENVIRONMENT;
|
|
196
|
+
}
|
|
197
|
+
// A fix job registers a persona run, so the persona skill has to be there
|
|
198
|
+
// too. Finding that out now beats finding it out after the charge.
|
|
199
|
+
if (wantsVerify && !(pack.skills || []).some(entry => entry.module === 'persona_playtest')) {
|
|
200
|
+
log('The content pack has no persona-playtest skill, so the verify run could not be played. Run "ravensight-playtest check" and try again.');
|
|
201
|
+
return EXIT.ENVIRONMENT;
|
|
202
|
+
}
|
|
203
|
+
const brief = await deps.brief.get({ api, gameId }).catch(() => null);
|
|
204
|
+
|
|
205
|
+
const listed = await resolveFinding(api, gameId, parsed);
|
|
206
|
+
if (!listed) {
|
|
207
|
+
log(parsed.findingId
|
|
208
|
+
? `No finding ${parsed.findingId} is visible to this token. Run "ravensight-playtest open" to see the findings this game has.`
|
|
209
|
+
: `Job ${parsed.jobId} has no finding labelled ${parsed.label}.`);
|
|
210
|
+
return EXIT.ENVIRONMENT;
|
|
211
|
+
}
|
|
212
|
+
const sourceJobId = listed.job_id || parsed.jobId || null;
|
|
213
|
+
const sourceRunId = listed.run_id || null;
|
|
214
|
+
const evidence = await gatherEvidence({ api, gameId, repoRoot, sourceJobId, sourceRunId, injected, log });
|
|
215
|
+
// The findings list is a summary; the run's own report.json is where the
|
|
216
|
+
// repro steps and the persona's words are written in full.
|
|
217
|
+
const finding = enrichFinding(listed, evidence.reportJson);
|
|
218
|
+
|
|
219
|
+
const branch = options.branch || fixBranchName(finding.label || finding.finding_id);
|
|
220
|
+
const git = injected.git || createGit({ root: repoRoot });
|
|
221
|
+
// Every refusal now, the one mutation after the price is agreed: a developer
|
|
222
|
+
// who says no to the charge should not be left on a new branch.
|
|
223
|
+
const checked = await preflight({ git, branch, allowDirty: Boolean(options.allowDirty), create: false });
|
|
224
|
+
if (!checked.ok) {
|
|
225
|
+
log(checked.reason);
|
|
226
|
+
return EXIT.ENVIRONMENT;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// A fix job asks for exactly one persona, the one who filed the finding: the
|
|
230
|
+
// server creates the verify run from it, and both zero and two are a 400.
|
|
231
|
+
const personaSlug = finding.persona || ((pack.personas || [])[0] || {}).slug || null;
|
|
232
|
+
if (!personaSlug) {
|
|
233
|
+
log('That finding names no persona and the content pack has none, so there is nobody to verify the fix with.');
|
|
234
|
+
return EXIT.ENVIRONMENT;
|
|
235
|
+
}
|
|
236
|
+
const estimate = await api.jobs.estimate(gameId, { modules: [...FIX_MODULES], personas: [personaSlug] });
|
|
237
|
+
const priceCents = Number.isFinite(Number(estimate.estimate_cents)) ? Number(estimate.estimate_cents) : Number(estimate.price_cents);
|
|
238
|
+
if (!Number.isFinite(priceCents)) {
|
|
239
|
+
// Sending `confirm_price_cents: undefined` would be refused as
|
|
240
|
+
// `price_changed`, which reads as a race rather than as the truth: this
|
|
241
|
+
// server did not quote a price at all.
|
|
242
|
+
log('The server quoted no price for this fix, so nothing was charged. Run "ravensight-playtest check" and try again.');
|
|
243
|
+
return EXIT.ENVIRONMENT;
|
|
244
|
+
}
|
|
245
|
+
const offer = `${formatCents(priceCents)} fix attempt (includes one verify run). Balance: ${formatCents(estimate.balance_cents)}.`;
|
|
246
|
+
log(offer);
|
|
247
|
+
if (estimate.ok === false) {
|
|
248
|
+
log(`That is more than the balance covers. Top up at ${estimate.topup_url || 'ravensight.io'} and run this again.`);
|
|
249
|
+
return EXIT.ENVIRONMENT;
|
|
250
|
+
}
|
|
251
|
+
if (estimate.wall_warning) log(estimate.wall_warning);
|
|
252
|
+
const confirm = injected.confirm || (deps.ui && deps.ui.confirm);
|
|
253
|
+
if (!options.yes && confirm) {
|
|
254
|
+
const go = await confirm(offer, { yes: options.yes });
|
|
255
|
+
if (!go) {
|
|
256
|
+
log('Nothing was charged and nothing was changed.');
|
|
257
|
+
return EXIT.CANCELED;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const started = await startBranch({ git, branch });
|
|
262
|
+
if (!started.ok) {
|
|
263
|
+
log(started.reason);
|
|
264
|
+
return EXIT.ENVIRONMENT;
|
|
265
|
+
}
|
|
266
|
+
log(`branch ${branch}, from ${checked.head.slice(0, 8)}${checked.from ? ` on ${checked.from}` : ''}.`);
|
|
267
|
+
|
|
268
|
+
const idempotencyKey = options.idempotencyKey || randomUUID();
|
|
269
|
+
let registered;
|
|
270
|
+
try {
|
|
271
|
+
registered = await api.jobs.register(gameId, {
|
|
272
|
+
modules: [...FIX_MODULES],
|
|
273
|
+
personas: [personaSlug],
|
|
274
|
+
finding_id: finding.finding_id,
|
|
275
|
+
driver: driverName,
|
|
276
|
+
cli_version: cliVersion,
|
|
277
|
+
confirm_price_cents: priceCents,
|
|
278
|
+
brief_version: brief ? brief.version ?? null : null,
|
|
279
|
+
build: buildTarget
|
|
280
|
+
? { kind: driverName === 'playwright_web' ? 'url' : 'godot_project', ref: buildTarget }
|
|
281
|
+
: { kind: 'repo', ref: repoRoot },
|
|
282
|
+
repo: { commit: checked.head, dirty: Boolean(checked.dirty), branch },
|
|
283
|
+
max_actions: Number.isInteger(options.maxActions) ? options.maxActions : null,
|
|
284
|
+
pack_versions: { pack: pack.pack_version }
|
|
285
|
+
}, { idempotencyKey });
|
|
286
|
+
} catch (error) {
|
|
287
|
+
const handled = explainRegisterRefusal(error, log);
|
|
288
|
+
if (handled !== null) return handled;
|
|
289
|
+
throw error;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const jobId = registered.job_id;
|
|
293
|
+
// Runs are told apart by module and nothing else: the fix run carries
|
|
294
|
+
// `persona: null`, and only the verify run carries the slug.
|
|
295
|
+
const fixRun = (registered.runs || []).find(entry => entry.module === 'fix');
|
|
296
|
+
const verifyRun = (registered.runs || []).find(entry => entry.module === 'persona_playtest') || null;
|
|
297
|
+
|
|
298
|
+
await mkdir(jobDir(repoRoot, jobId), { recursive: true });
|
|
299
|
+
const journal = stateFile.newState({
|
|
300
|
+
jobId,
|
|
301
|
+
gameId,
|
|
302
|
+
idempotencyKey,
|
|
303
|
+
cliVersion,
|
|
304
|
+
packVersion: pack.pack_version,
|
|
305
|
+
briefVersion: brief ? brief.version ?? null : null,
|
|
306
|
+
modules: [...FIX_MODULES],
|
|
307
|
+
// The driver and the target belong to the verify run, which is the only
|
|
308
|
+
// run in this job that plays anything.
|
|
309
|
+
driver: driverName,
|
|
310
|
+
target: buildTarget,
|
|
311
|
+
priceCents: registered.price && registered.price.price_cents,
|
|
312
|
+
options: {
|
|
313
|
+
branch,
|
|
314
|
+
baseCommit: checked.head,
|
|
315
|
+
findingId: finding.finding_id,
|
|
316
|
+
verify: wantsVerify,
|
|
317
|
+
allowDirty: Boolean(options.allowDirty),
|
|
318
|
+
maxActions: options.maxActions ?? null
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
for (const entry of registered.runs || []) {
|
|
322
|
+
journal.runs[entry.run_id] = stateFile.newRunEntry({ runId: entry.run_id, module: entry.module, persona: entry.persona });
|
|
323
|
+
}
|
|
324
|
+
await stateFile.save(repoRoot, journal);
|
|
325
|
+
// The journal and the job id are written before anything can go wrong with
|
|
326
|
+
// the runs, because the charge has already happened: a developer who cannot
|
|
327
|
+
// name the job they paid for cannot ask anybody about it either.
|
|
328
|
+
log(`job ${jobId}: ${formatCents(registered.price && registered.price.price_cents)} charged. ${jobDir(repoRoot, jobId)}`);
|
|
329
|
+
|
|
330
|
+
if (!fixRun) {
|
|
331
|
+
log('The server registered the job without a fix run, so there is nothing to spend a model turn from.');
|
|
332
|
+
await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'the job has no fix run', log });
|
|
333
|
+
// Settled rather than abandoned: the job is charged and unplayable, so the
|
|
334
|
+
// refund is owed now and not in half an hour when the sweep notices.
|
|
335
|
+
await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the job has no fix run', log });
|
|
336
|
+
return EXIT.FAILED;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const directory = runDirFor(repoRoot, jobId, fixRun.run_id);
|
|
340
|
+
await mkdir(directory, { recursive: true });
|
|
341
|
+
|
|
342
|
+
const createClient = injected.createClient || createModelClient;
|
|
343
|
+
const client = createClient({ apiUrl, token: injected.modelToken || api.token });
|
|
344
|
+
const ledger = createUsageLedger();
|
|
345
|
+
const heartbeat = createHeartbeat({
|
|
346
|
+
api,
|
|
347
|
+
gameId,
|
|
348
|
+
jobId,
|
|
349
|
+
runId: fixRun.run_id,
|
|
350
|
+
onCancel: () => log('the job was cancelled; stopping.'),
|
|
351
|
+
onUsage: usage => ledger.recordServerUsage(usage),
|
|
352
|
+
onError: () => {}
|
|
353
|
+
});
|
|
354
|
+
const inFlight = new Map();
|
|
355
|
+
const interrupt = createInterruptHandler({
|
|
356
|
+
api,
|
|
357
|
+
gameId,
|
|
358
|
+
jobId,
|
|
359
|
+
repoRoot,
|
|
360
|
+
journal,
|
|
361
|
+
inFlight,
|
|
362
|
+
log,
|
|
363
|
+
// Not "resume carries on": `resume` replays persona runs, and replaying
|
|
364
|
+
// this job's verify persona against a half-applied edit would answer a
|
|
365
|
+
// question nobody asked. A fix is abandoned and refunded instead.
|
|
366
|
+
message: 'finishing the turn in flight, then stopping. A fix cannot be resumed, so this one is being given back.',
|
|
367
|
+
injected
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
await api.runs.transition(gameId, jobId, fixRun.run_id, 'launching', { reason: '' });
|
|
372
|
+
await api.runs.transition(gameId, jobId, fixRun.run_id, 'playing', { reason: '' });
|
|
373
|
+
heartbeat.start();
|
|
374
|
+
inFlight.set(fixRun.run_id, { state: 'playing' });
|
|
375
|
+
|
|
376
|
+
// 1. Is this a bug at all? The cheapest possible "no".
|
|
377
|
+
const verdict = await api.jobs.fixTriage(gameId, jobId, { finding_id: finding.finding_id });
|
|
378
|
+
if (verdict && verdict.verdict === null) {
|
|
379
|
+
// The triage service is off, so nothing was judged. Proceeding is right
|
|
380
|
+
// (the finding was filed by a persona that was told the design intent),
|
|
381
|
+
// but the developer should know the check did not happen.
|
|
382
|
+
log(`Triage could not judge this finding (${verdict.source || 'unavailable'}), so the fix proceeds without it. Read the diff against your own design intent.`);
|
|
383
|
+
}
|
|
384
|
+
if (isDesignIntent(verdict)) {
|
|
385
|
+
heartbeat.stop();
|
|
386
|
+
inFlight.delete(fixRun.run_id);
|
|
387
|
+
log(`${finding.label || finding.finding_id} reads as design intent, not a defect (confidence ${verdict.confidence}, from ${verdict.source || 'the brief'}), so nothing was edited.`);
|
|
388
|
+
await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'intended' }, log });
|
|
389
|
+
await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'the finding is design intent', log });
|
|
390
|
+
await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the finding is design intent', log });
|
|
391
|
+
log('The fix unit was refunded. Mark the finding as intended in the dashboard so no later job spends a turn on it.');
|
|
392
|
+
return EXIT.OK;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 2. Plan, then apply.
|
|
396
|
+
const images = await saveEvidenceImages(directory, evidence.images);
|
|
397
|
+
const system = fixSystem({
|
|
398
|
+
skill: fixSkill.content,
|
|
399
|
+
jobId,
|
|
400
|
+
branch,
|
|
401
|
+
baseCommit: checked.head,
|
|
402
|
+
engine: pack.engine || null
|
|
403
|
+
});
|
|
404
|
+
const codeBrief = pack.code_brief || null;
|
|
405
|
+
// `graph_context` only exists in a bundle asked for with this job's id: it
|
|
406
|
+
// is assembled from the job's own findings, so the pre-register fetch (which
|
|
407
|
+
// bought the skills and the price) has it as null. A refetch that fails is
|
|
408
|
+
// not fatal, it just means planning without the level graph.
|
|
409
|
+
const graphContext = await jobGraphContext({ deps, api, gameId, jobId, log });
|
|
410
|
+
const planRepo = createRepoTools({ root: repoRoot, tools: PROFILE_TOOLS });
|
|
411
|
+
const { plan } = await planFix({
|
|
412
|
+
client,
|
|
413
|
+
jobId,
|
|
414
|
+
runId: fixRun.run_id,
|
|
415
|
+
system,
|
|
416
|
+
repo: planRepo,
|
|
417
|
+
content: planContent({
|
|
418
|
+
finding: findingForPrompt(finding),
|
|
419
|
+
reproSteps: reproStepsOf(finding),
|
|
420
|
+
personaQuote: finding.persona_quote || null,
|
|
421
|
+
images,
|
|
422
|
+
intentNotes: intentNotesFor(pack, brief),
|
|
423
|
+
reportMarkdown: evidence.reportMarkdown,
|
|
424
|
+
codeBrief,
|
|
425
|
+
graphContext
|
|
426
|
+
}),
|
|
427
|
+
ledger,
|
|
428
|
+
log
|
|
429
|
+
});
|
|
430
|
+
if (plan.hypothesis) log(`hypothesis: ${plan.hypothesis}`);
|
|
431
|
+
|
|
432
|
+
const maxActions = resolveFixMaxActions(options, pack);
|
|
433
|
+
const applied = await applyFix({
|
|
434
|
+
client,
|
|
435
|
+
jobId,
|
|
436
|
+
runId: fixRun.run_id,
|
|
437
|
+
repoRoot,
|
|
438
|
+
system,
|
|
439
|
+
plan,
|
|
440
|
+
maxActions,
|
|
441
|
+
codeBrief,
|
|
442
|
+
graphContext,
|
|
443
|
+
ledger,
|
|
444
|
+
log,
|
|
445
|
+
onProgress: progress => {
|
|
446
|
+
if (options.verbose) {
|
|
447
|
+
log(` ${progress.tool} ${progress.target || ''} (${progress.resultChars} chars${progress.isError ? ', error' : ''}${progress.repeat ? ', repeat' : ''})`);
|
|
448
|
+
}
|
|
449
|
+
// A local transcript of the apply loop: which tools, on what, how much
|
|
450
|
+
// came back. Never uploaded; it is how a fix that wrote nothing gets
|
|
451
|
+
// explained afterwards, which the first production attempt could not be.
|
|
452
|
+
appendFile(path.join(directory, 'transcript.jsonl'), `${JSON.stringify({ at: new Date().toISOString(), ...progress })}\n`).catch(() => {});
|
|
453
|
+
},
|
|
454
|
+
shouldStop: () => interrupt.signalled || heartbeat.canceled
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// A Ctrl-C, or the job cancelled from the dashboard. Either way this run is
|
|
458
|
+
// over, and it is abandoned rather than left `interrupted`: `resume` only
|
|
459
|
+
// knows how to replay persona runs, so a fix run parked in a resumable
|
|
460
|
+
// state is a promise nothing can keep. The edits stay on the branch, which
|
|
461
|
+
// is where the developer can read them, and the unit goes back.
|
|
462
|
+
if (interrupt.signalled || heartbeat.canceled || applied.stop === 'interrupted') {
|
|
463
|
+
heartbeat.stop();
|
|
464
|
+
inFlight.delete(fixRun.run_id);
|
|
465
|
+
const why = interrupt.signalled ? 'interrupted by the developer' : 'cancelled';
|
|
466
|
+
await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'error' }, log });
|
|
467
|
+
await skipRun({ api, gameId, jobId, run: verifyRun, reason: `the fix was ${why}`, log });
|
|
468
|
+
try {
|
|
469
|
+
await stateFile.patchRun(repoRoot, journal, fixRun.run_id, { state: 'failed', quit_reason: 'error' });
|
|
470
|
+
} catch {
|
|
471
|
+
// The journal is a convenience here; the server's own run state is the
|
|
472
|
+
// record, and it has just been told.
|
|
473
|
+
}
|
|
474
|
+
await settle({ api, gameId, jobId, repoRoot, journal, reason: `the fix was ${why}`, log });
|
|
475
|
+
log(`The fix was ${why} and cannot be resumed, so the unit was given back. The branch ${branch} holds whatever was written, and nothing was committed or pushed.`);
|
|
476
|
+
return EXIT.CANCELED;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// 3. Did anything change? If not, say so and give the money back.
|
|
480
|
+
const changed = await git.diffStat();
|
|
481
|
+
if (changed.length === 0) {
|
|
482
|
+
heartbeat.stop();
|
|
483
|
+
inFlight.delete(fixRun.run_id);
|
|
484
|
+
log(`Nothing was changed, so there is nothing to review. The branch ${branch} is empty; delete it or stay on it.`);
|
|
485
|
+
await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'no_change' }, log });
|
|
486
|
+
await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'nothing was changed', log });
|
|
487
|
+
await settle({ api, gameId, jobId, repoRoot, journal, reason: 'nothing was changed', log });
|
|
488
|
+
log('The fix unit was refunded.');
|
|
489
|
+
return EXIT.OK;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const files = changed.slice(0, MAX_CHANGESET_FILES);
|
|
493
|
+
if (changed.length > files.length) {
|
|
494
|
+
log(`${changed.length} files changed and a changeset carries at most ${MAX_CHANGESET_FILES}, so the changeset lists the first ${MAX_CHANGESET_FILES}. The branch has all of them.`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// 4. Verify, by replaying the finding against the build.
|
|
498
|
+
let verified = null;
|
|
499
|
+
if (wantsVerify && verifyRun) {
|
|
500
|
+
verified = await runVerify({
|
|
501
|
+
deps,
|
|
502
|
+
api,
|
|
503
|
+
client,
|
|
504
|
+
gameId,
|
|
505
|
+
jobId,
|
|
506
|
+
repoRoot,
|
|
507
|
+
entry: verifyRun,
|
|
508
|
+
pack,
|
|
509
|
+
finding,
|
|
510
|
+
driverName,
|
|
511
|
+
target: buildTarget,
|
|
512
|
+
options,
|
|
513
|
+
journal,
|
|
514
|
+
maxActions: Math.min(VERIFY_MAX_ACTIONS, maxActions),
|
|
515
|
+
makeDriver: injected.driverFactory || createDriver,
|
|
516
|
+
log
|
|
517
|
+
});
|
|
518
|
+
} else {
|
|
519
|
+
await skipRun({ api, gameId, jobId, run: verifyRun, reason: wantsVerify ? 'no verify run was registered' : 'skipped with --no-verify', log });
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// 5. The pull request text, from the diff that exists.
|
|
523
|
+
const fallback = localSummary({ finding, plan, files, verify: verified, branch, applied });
|
|
524
|
+
// Both budget stops mean the apply loop left off early rather than because
|
|
525
|
+
// it was finished, so both carry the caveat. Only the 402 skips the
|
|
526
|
+
// summarise call, because only the 402 means there is nothing left to
|
|
527
|
+
// spend on it.
|
|
528
|
+
const budgetTouched = applied.stop === 'budget' || applied.stop === 'budget_warning';
|
|
529
|
+
const summary = applied.stop === 'budget'
|
|
530
|
+
? { ...fallback, source: 'local' }
|
|
531
|
+
: await summariseFix({
|
|
532
|
+
client,
|
|
533
|
+
jobId,
|
|
534
|
+
runId: fixRun.run_id,
|
|
535
|
+
system,
|
|
536
|
+
finding: findingForPrompt(finding),
|
|
537
|
+
plan,
|
|
538
|
+
files,
|
|
539
|
+
verify: verified,
|
|
540
|
+
branch,
|
|
541
|
+
fallback,
|
|
542
|
+
ledger,
|
|
543
|
+
log
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
const changeset = {
|
|
547
|
+
schema_version: '1.0',
|
|
548
|
+
finding_id: finding.finding_id,
|
|
549
|
+
branch,
|
|
550
|
+
base_commit: checked.head,
|
|
551
|
+
files,
|
|
552
|
+
verify: verified ? { run_id: verified.run_id, reproduced: verified.reproduced } : null,
|
|
553
|
+
pr_title: summary.prTitle,
|
|
554
|
+
pr_body: summary.prBody
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
await heartbeat.beat();
|
|
558
|
+
heartbeat.stop();
|
|
559
|
+
inFlight.delete(fixRun.run_id);
|
|
560
|
+
const reportPath = await writeChangeset(directory, changeset);
|
|
561
|
+
await writeUsageFile(directory, ledger);
|
|
562
|
+
|
|
563
|
+
await api.runs.transition(gameId, jobId, fixRun.run_id, 'reporting', { reason: '' });
|
|
564
|
+
await api.runs.transition(gameId, jobId, fixRun.run_id, 'uploading', { reason: '' });
|
|
565
|
+
try {
|
|
566
|
+
await deps.finalizeRun(directory, {
|
|
567
|
+
api,
|
|
568
|
+
gameId,
|
|
569
|
+
jobId,
|
|
570
|
+
runId: fixRun.run_id,
|
|
571
|
+
// Exactly the three files this run produced. Screenshots copied from
|
|
572
|
+
// the run that found the bug are evidence for the model, not something
|
|
573
|
+
// to upload a second time.
|
|
574
|
+
allowedPaths: [CHANGESET_FILE, RUN_FILES.reportMd, RUN_FILES.usageJson],
|
|
575
|
+
body: { state: 'succeeded', changeset }
|
|
576
|
+
});
|
|
577
|
+
await stateFile.patchRun(repoRoot, journal, fixRun.run_id, { state: 'succeeded', uploaded: true, completed: true, report_written: true });
|
|
578
|
+
} catch (error) {
|
|
579
|
+
log(`The changeset could not be uploaded (${String(error && error.message)}). It is in ${directory} and "ravensight-playtest upload ${jobId}" can retry it.`);
|
|
580
|
+
// 422 `invalid_changeset` carries per-field pointers, and they are the
|
|
581
|
+
// whole diagnosis: "pr_title too long" is actionable, "422" is not.
|
|
582
|
+
for (const detail of changesetDetails(error)) log(` ${detail}`);
|
|
583
|
+
}
|
|
584
|
+
await settle({ api, gameId, jobId, repoRoot, journal, reason: '', log });
|
|
585
|
+
|
|
586
|
+
report({ changeset, directory, reportPath, verified, options, log, injected, deps, budget: budgetTouched });
|
|
587
|
+
// Exit 2 is "the money ran out", which a warning is not: a warning means
|
|
588
|
+
// the run wrapped up early with budget still in hand.
|
|
589
|
+
return applied.stop === 'budget' ? EXIT.BUDGET : EXIT.OK;
|
|
590
|
+
} catch (error) {
|
|
591
|
+
heartbeat.stop();
|
|
592
|
+
const code = exitCodeFor(error);
|
|
593
|
+
log(`fix failed: ${String(error && error.message)}`);
|
|
594
|
+
log(`The branch ${branch} still holds whatever was written. Nothing was committed or pushed.`);
|
|
595
|
+
await failFixRun({ api, gameId, jobId, runId: fixRun.run_id, log });
|
|
596
|
+
await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'the fix failed', log });
|
|
597
|
+
await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the fix failed', log });
|
|
598
|
+
return code;
|
|
599
|
+
} finally {
|
|
600
|
+
interrupt.release();
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The finding, by label inside one job or by id across the game.
|
|
606
|
+
*
|
|
607
|
+
* @param {Object} api
|
|
608
|
+
* @param {string} gameId
|
|
609
|
+
* @param {{jobId: string|null, label: string|null, findingId: string|null}} target
|
|
610
|
+
* @returns {Promise<Object|null>}
|
|
611
|
+
*/
|
|
612
|
+
export async function resolveFinding(api, gameId, target) {
|
|
613
|
+
const answer = await api.jobs.findings(gameId, target.jobId ? { job: target.jobId } : {});
|
|
614
|
+
const list = (answer && answer.findings) || [];
|
|
615
|
+
if (target.findingId) return list.find(entry => entry.finding_id === target.findingId) || null;
|
|
616
|
+
const wanted = String(target.label).toLowerCase();
|
|
617
|
+
return list.find(entry => String(entry.label || '').toLowerCase() === wanted) || null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Fill in what the findings list left out from the run's own report.
|
|
622
|
+
*
|
|
623
|
+
* `GET /findings` answers a list for a dashboard, so it may carry a title and
|
|
624
|
+
* a severity and nothing else. The report the persona wrote is where the repro
|
|
625
|
+
* steps, the quote and the cited evidence live, and those are most of what the
|
|
626
|
+
* plan step has to work from. The listed record still wins on every field it
|
|
627
|
+
* does have: it is the one the server considers current.
|
|
628
|
+
*
|
|
629
|
+
* @param {Object} listed
|
|
630
|
+
* @param {Object|null} reportJson
|
|
631
|
+
* @returns {Object}
|
|
632
|
+
*/
|
|
633
|
+
export function enrichFinding(listed, reportJson) {
|
|
634
|
+
const findings = (reportJson && Array.isArray(reportJson.findings) ? reportJson.findings : []);
|
|
635
|
+
const match = findings.find(entry => entry
|
|
636
|
+
&& (entry.finding_id === listed.finding_id
|
|
637
|
+
|| (entry.label && listed.label && String(entry.label).toLowerCase() === String(listed.label).toLowerCase())));
|
|
638
|
+
if (!match) return listed;
|
|
639
|
+
const merged = { ...match };
|
|
640
|
+
for (const [key, value] of Object.entries(listed)) {
|
|
641
|
+
if (value !== null && value !== undefined && !(Array.isArray(value) && value.length === 0)) merged[key] = value;
|
|
642
|
+
}
|
|
643
|
+
return merged;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* @param {Object|null} verdict
|
|
648
|
+
* @returns {boolean}
|
|
649
|
+
*/
|
|
650
|
+
export function isDesignIntent(verdict) {
|
|
651
|
+
if (!verdict || verdict.verdict !== 'design_intent') return false;
|
|
652
|
+
const confidence = Number(verdict.confidence);
|
|
653
|
+
return Number.isFinite(confidence) && confidence >= DESIGN_INTENT_CONFIDENCE;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* The report and the screenshots behind the finding.
|
|
658
|
+
*
|
|
659
|
+
* Screenshots come from the local run directory when this machine is the one
|
|
660
|
+
* that played the job, which is the usual case and costs nothing. Otherwise
|
|
661
|
+
* they come from the presigned artifact URLs, which is what makes `fix` usable
|
|
662
|
+
* from a different machine than the run. Either way at most two are taken,
|
|
663
|
+
* because that is the plan step's image ceiling.
|
|
664
|
+
*
|
|
665
|
+
* @returns {Promise<{reportMarkdown: string|null, reportJson: Object|null, images: Array}>}
|
|
666
|
+
*/
|
|
667
|
+
export async function gatherEvidence({ api, gameId, repoRoot, sourceJobId, sourceRunId, injected = {}, log = () => {} }) {
|
|
668
|
+
const out = { reportMarkdown: null, reportJson: null, images: [] };
|
|
669
|
+
if (!sourceJobId || !sourceRunId) return out;
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
out.reportMarkdown = String(await api.runs.report(gameId, sourceJobId, sourceRunId, 'md'));
|
|
673
|
+
} catch {
|
|
674
|
+
log('The run report for that finding could not be read, so the fix works from the finding alone.');
|
|
675
|
+
}
|
|
676
|
+
try {
|
|
677
|
+
const body = await api.runs.report(gameId, sourceJobId, sourceRunId, 'json');
|
|
678
|
+
out.reportJson = typeof body === 'string' ? JSON.parse(body) : body;
|
|
679
|
+
} catch {
|
|
680
|
+
// The markdown is the part the plan step reads; the JSON is a bonus.
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const local = screenshotsDir(runDirFor(repoRoot, sourceJobId, sourceRunId));
|
|
684
|
+
try {
|
|
685
|
+
const names = (await readdir(local)).filter(isImageName).sort().slice(0, MAX_PLAN_IMAGES);
|
|
686
|
+
for (const name of names) {
|
|
687
|
+
out.images.push({ name, mediaType: mediaTypeFor(name), base64: (await readFile(path.join(local, name))).toString('base64') });
|
|
688
|
+
}
|
|
689
|
+
} catch {
|
|
690
|
+
// No local run directory: this machine did not play that job.
|
|
691
|
+
}
|
|
692
|
+
if (out.images.length > 0) return out;
|
|
693
|
+
|
|
694
|
+
const fetchImpl = injected.fetch || globalThis.fetch;
|
|
695
|
+
if (!fetchImpl) return out;
|
|
696
|
+
try {
|
|
697
|
+
const answer = await api.jobs.artifacts(gameId, sourceJobId);
|
|
698
|
+
const wanted = (answer && answer.artifacts ? answer.artifacts : [])
|
|
699
|
+
// The server answers every artifact of the job, so the run has to be
|
|
700
|
+
// matched, and the content type is what says an artifact is an image: a
|
|
701
|
+
// report is not, whatever its path looks like.
|
|
702
|
+
.filter(entry => entry && entry.url && entry.run_id === sourceRunId)
|
|
703
|
+
.filter(entry => String(entry.content_type || '').startsWith('image/') || isImageName(String(entry.path || '')))
|
|
704
|
+
.slice(0, MAX_PLAN_IMAGES);
|
|
705
|
+
for (const entry of wanted) {
|
|
706
|
+
const response = await fetchImpl(entry.url);
|
|
707
|
+
if (!response || !response.ok) continue;
|
|
708
|
+
const body = Buffer.from(await response.arrayBuffer());
|
|
709
|
+
out.images.push({ name: path.basename(String(entry.path)), mediaType: mediaTypeFor(String(entry.path)), base64: body.toString('base64') });
|
|
710
|
+
}
|
|
711
|
+
} catch {
|
|
712
|
+
log('The screenshots behind that finding could not be fetched, so the fix works from the text alone.');
|
|
713
|
+
}
|
|
714
|
+
return out;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Put the evidence images in the fix run directory, so what the model was
|
|
719
|
+
* shown is on disk next to what it produced.
|
|
720
|
+
*
|
|
721
|
+
* @param {string} directory the fix run directory
|
|
722
|
+
* @param {Array<Object>} images
|
|
723
|
+
* @returns {Promise<Array<Object>>} the images, unchanged
|
|
724
|
+
*/
|
|
725
|
+
export async function saveEvidenceImages(directory, images) {
|
|
726
|
+
if (images.length === 0) return images;
|
|
727
|
+
const target = screenshotsDir(directory);
|
|
728
|
+
await mkdir(target, { recursive: true });
|
|
729
|
+
for (const image of images) {
|
|
730
|
+
await writeFile(path.join(target, image.name), Buffer.from(image.base64, 'base64'));
|
|
731
|
+
}
|
|
732
|
+
return images;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Replay the finding on the build, as an ordinary persona run whose brief
|
|
737
|
+
* starts with the one thing it is being asked to do.
|
|
738
|
+
*
|
|
739
|
+
* Unlike `run`, this emits no synthetic telemetry: a verify run is one
|
|
740
|
+
* question asked of a build that only exists on this machine, and putting its
|
|
741
|
+
* session into the game's event stream would count a private replay as
|
|
742
|
+
* playtest traffic.
|
|
743
|
+
*
|
|
744
|
+
* @returns {Promise<{run_id: string, reproduced: number|null, source: string|null}|null>}
|
|
745
|
+
*/
|
|
746
|
+
export async function runVerify({
|
|
747
|
+
deps,
|
|
748
|
+
api,
|
|
749
|
+
client,
|
|
750
|
+
gameId,
|
|
751
|
+
jobId,
|
|
752
|
+
repoRoot,
|
|
753
|
+
entry,
|
|
754
|
+
pack,
|
|
755
|
+
finding,
|
|
756
|
+
driverName,
|
|
757
|
+
target,
|
|
758
|
+
options,
|
|
759
|
+
journal,
|
|
760
|
+
maxActions,
|
|
761
|
+
makeDriver,
|
|
762
|
+
log
|
|
763
|
+
}) {
|
|
764
|
+
const directory = runDirFor(repoRoot, jobId, entry.run_id);
|
|
765
|
+
await mkdir(directory, { recursive: true });
|
|
766
|
+
const persona = (pack.personas || []).find(candidate => candidate.slug === entry.persona) || (pack.personas || [])[0];
|
|
767
|
+
if (!persona) {
|
|
768
|
+
log('The content pack has no persona to verify with, so the fix is reported unverified.');
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
|
|
772
|
+
const driver = await makeDriver(driverName, { headless });
|
|
773
|
+
const ledger = createUsageLedger();
|
|
774
|
+
const heartbeat = createHeartbeat({
|
|
775
|
+
api,
|
|
776
|
+
gameId,
|
|
777
|
+
jobId,
|
|
778
|
+
runId: entry.run_id,
|
|
779
|
+
onUsage: usage => ledger.recordServerUsage(usage),
|
|
780
|
+
onError: () => {}
|
|
781
|
+
});
|
|
782
|
+
heartbeat.start();
|
|
783
|
+
|
|
784
|
+
let outcome;
|
|
785
|
+
try {
|
|
786
|
+
outcome = await runPersona({
|
|
787
|
+
client,
|
|
788
|
+
api,
|
|
789
|
+
gameId,
|
|
790
|
+
jobId,
|
|
791
|
+
runId: entry.run_id,
|
|
792
|
+
persona,
|
|
793
|
+
module: 'persona_playtest',
|
|
794
|
+
driver,
|
|
795
|
+
driverName,
|
|
796
|
+
target,
|
|
797
|
+
runDirectory: directory,
|
|
798
|
+
skill: skillFor(pack, 'persona_playtest'),
|
|
799
|
+
brief: verifyBrief(pack.brief || null, finding),
|
|
800
|
+
intentNotes: pack.intent_notes || [],
|
|
801
|
+
schemas: pack.schemas || [],
|
|
802
|
+
maxActions,
|
|
803
|
+
heartbeat,
|
|
804
|
+
ledger,
|
|
805
|
+
log: message => log(`verify: ${message}`)
|
|
806
|
+
});
|
|
807
|
+
} catch (error) {
|
|
808
|
+
heartbeat.stop();
|
|
809
|
+
const failed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
|
|
810
|
+
await stateFile.patchRun(repoRoot, journal, entry.run_id, { state: failed.state, error: String(error && error.message), quit_reason: failed.quitReason });
|
|
811
|
+
await deliver({ deps, api, gameId, jobId, runId: entry.run_id, directory, outcome: failed, options, log, journal, repoRoot });
|
|
812
|
+
log(`the verify run failed (${String(error && error.message)}), so the fix is reported unverified.`);
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
heartbeat.stop();
|
|
816
|
+
|
|
817
|
+
await stateFile.patchRun(repoRoot, journal, entry.run_id, {
|
|
818
|
+
state: outcome.state,
|
|
819
|
+
actions_taken: outcome.actionsTaken,
|
|
820
|
+
quit_reason: outcome.quitReason,
|
|
821
|
+
report_written: Boolean(outcome.report),
|
|
822
|
+
budget_warned: outcome.budgetWarned
|
|
823
|
+
});
|
|
824
|
+
await deliver({ deps, api, gameId, jobId, runId: entry.run_id, directory, outcome, options, log, journal, repoRoot });
|
|
825
|
+
|
|
826
|
+
// The verdict is the server's: it reads the verify run's own report against
|
|
827
|
+
// the finding, which is judgement the CLI has no business making locally.
|
|
828
|
+
let run = null;
|
|
829
|
+
try {
|
|
830
|
+
const answer = await api.runs.get(gameId, jobId, entry.run_id);
|
|
831
|
+
run = (answer && (answer.run || answer)) || null;
|
|
832
|
+
} catch (error) {
|
|
833
|
+
log(`the verify verdict could not be read (${String(error && error.message)}).`);
|
|
834
|
+
}
|
|
835
|
+
const verdict = (run && run.verify_verdict) || null;
|
|
836
|
+
const reproduced = verdict && Number.isFinite(Number(verdict.reproduced)) ? Number(verdict.reproduced) : null;
|
|
837
|
+
return { run_id: entry.run_id, reproduced, source: (verdict && verdict.source) || null };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* The verify run's brief: what it is being asked to reproduce, then the brief
|
|
842
|
+
* a persona usually plays against. The task comes first because it is the one
|
|
843
|
+
* thing this run exists for.
|
|
844
|
+
*
|
|
845
|
+
* @param {Object|null} base
|
|
846
|
+
* @param {Object} finding
|
|
847
|
+
* @returns {Object}
|
|
848
|
+
*/
|
|
849
|
+
export function verifyBrief(base, finding) {
|
|
850
|
+
const fields = (base && base.fields) || {};
|
|
851
|
+
return {
|
|
852
|
+
...(base || {}),
|
|
853
|
+
fields: {
|
|
854
|
+
verify_task: `Reproduce ${finding.label || finding.finding_id}: ${finding.title || 'the finding'}`,
|
|
855
|
+
verify_repro_steps: reproStepsOf(finding),
|
|
856
|
+
verify_instruction: 'Try these steps first and say plainly whether the problem still happens. Then play as you normally would.',
|
|
857
|
+
...fields
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* What the pull request says when the model could not say it: no budget left,
|
|
864
|
+
* or an answer with no JSON in it. It is deliberately plain, and it says which
|
|
865
|
+
* parts are unknown rather than guessing at them.
|
|
866
|
+
*
|
|
867
|
+
* @returns {{prTitle: string, prBody: string}}
|
|
868
|
+
*/
|
|
869
|
+
export function localSummary({ finding, plan, files, verify, branch, applied = {} }) {
|
|
870
|
+
const label = finding.label || finding.finding_id;
|
|
871
|
+
const body = [
|
|
872
|
+
`# Fix ${label}: ${finding.title || 'a playtest finding'}`,
|
|
873
|
+
'',
|
|
874
|
+
'## What was wrong',
|
|
875
|
+
finding.title ? `${finding.title}${finding.severity ? ` (${finding.severity})` : ''}` : 'See the finding in the dashboard.',
|
|
876
|
+
plan.hypothesis ? `\n${plan.hypothesis}` : '',
|
|
877
|
+
'',
|
|
878
|
+
'## What changed',
|
|
879
|
+
...files.map(file => `- \`${file.path}\`: +${file.additions} -${file.deletions}`),
|
|
880
|
+
'',
|
|
881
|
+
'## Verify',
|
|
882
|
+
verifyWords(verify),
|
|
883
|
+
'',
|
|
884
|
+
'## What the reviewer should check',
|
|
885
|
+
'This summary was written locally rather than by the model, so read the diff before trusting the description above.',
|
|
886
|
+
applied.stop === 'budget' || applied.stop === 'budget_warning'
|
|
887
|
+
? 'The fix stopped early because its model budget was spent or nearly spent, so it may be incomplete.'
|
|
888
|
+
: '',
|
|
889
|
+
'',
|
|
890
|
+
`Branch: ${branch}`,
|
|
891
|
+
''
|
|
892
|
+
].filter(line => line !== '').join('\n');
|
|
893
|
+
return { prTitle: `Fix ${label}: ${(finding.title || 'a playtest finding').slice(0, 100)}`, prBody: body };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Write `changeset.json` and `report.md`, scanning both first.
|
|
898
|
+
*
|
|
899
|
+
* The scan is not a formality: the pull request body is written by a model
|
|
900
|
+
* that has just been reading source files, and a key quoted into it would be
|
|
901
|
+
* uploaded and then pasted into a pull request.
|
|
902
|
+
*
|
|
903
|
+
* @param {string} directory
|
|
904
|
+
* @param {Object} changeset
|
|
905
|
+
* @returns {Promise<string>} the path to report.md
|
|
906
|
+
*/
|
|
907
|
+
export async function writeChangeset(directory, changeset) {
|
|
908
|
+
const json = `${JSON.stringify(changeset, null, 2)}\n`;
|
|
909
|
+
const markdown = `${String(changeset.pr_body || '').trimEnd()}\n`;
|
|
910
|
+
assertNoSecrets(CHANGESET_FILE, json);
|
|
911
|
+
assertNoSecrets(RUN_FILES.reportMd, markdown);
|
|
912
|
+
await writeFile(path.join(directory, CHANGESET_FILE), json, 'utf8');
|
|
913
|
+
const reportPath = path.join(directory, RUN_FILES.reportMd);
|
|
914
|
+
await writeFile(reportPath, markdown, 'utf8');
|
|
915
|
+
return reportPath;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** What the developer reads at the end, or the changeset when `--json`. */
|
|
919
|
+
function report({ changeset, reportPath, verified, options, log, injected, deps, budget }) {
|
|
920
|
+
if (options.json) {
|
|
921
|
+
const emit = injected.emitJson || (deps.ui && deps.ui.json ? value => deps.ui.json(value) : value => console.log(JSON.stringify(value, null, 2)));
|
|
922
|
+
emit(changeset);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
log(`branch ${changeset.branch}`);
|
|
926
|
+
for (const file of changeset.files) log(` ${file.path} +${file.additions} -${file.deletions}`);
|
|
927
|
+
log(`verify: ${verifyWords(verified)}`);
|
|
928
|
+
if (budget) log('The fix stopped early because its model budget was spent or nearly spent, so read the diff carefully: it may be half a change.');
|
|
929
|
+
log('Nothing was committed and nothing was pushed. Read the diff, commit it, then:');
|
|
930
|
+
log(` gh pr create --title "${String(changeset.pr_title).replace(/"/g, '\\"')}" --body-file ${reportPath}`);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Complete the fix run, tolerating a refusal. The money is settled by
|
|
935
|
+
* `finish` either way, and a refused complete must not turn a fix that worked
|
|
936
|
+
* into a crash.
|
|
937
|
+
*/
|
|
938
|
+
async function completeFixRun({ api, gameId, jobId, runId, body, log }) {
|
|
939
|
+
try {
|
|
940
|
+
await api.runs.complete(gameId, jobId, runId, body);
|
|
941
|
+
} catch (error) {
|
|
942
|
+
log(`the fix run could not be completed (${String(error && error.message)}); the hourly sweep will settle it.`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async function failFixRun({ api, gameId, jobId, runId, log }) {
|
|
947
|
+
await completeFixRun({ api, gameId, jobId, runId, body: { state: 'failed', reason: 'error' }, log });
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* A run this job paid for and never played. Saying so is what gets it
|
|
952
|
+
* refunded now rather than by the stall sweep half an hour later.
|
|
953
|
+
*/
|
|
954
|
+
async function skipRun({ api, gameId, jobId, run, reason, log }) {
|
|
955
|
+
if (!run) return;
|
|
956
|
+
try {
|
|
957
|
+
await api.runs.transition(gameId, jobId, run.run_id, 'skipped', { reason });
|
|
958
|
+
} catch (error) {
|
|
959
|
+
log(`the verify run could not be marked skipped (${String(error && error.message)}).`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** `finish` settles the job once and issues at most one refund. */
|
|
964
|
+
async function settle({ api, gameId, jobId, repoRoot, journal, reason, log }) {
|
|
965
|
+
try {
|
|
966
|
+
const finished = await api.jobs.finish(gameId, jobId, { reason });
|
|
967
|
+
log(`job ${jobId} ${finished.job ? finished.job.state : 'finished'}: refunded ${formatCents(finished.refunded_cents)}.`);
|
|
968
|
+
} catch (error) {
|
|
969
|
+
log(`the job could not be settled now; the hourly sweep will settle it: ${String(error && error.message)}`);
|
|
970
|
+
}
|
|
971
|
+
journal.finished = true;
|
|
972
|
+
try {
|
|
973
|
+
await stateFile.save(repoRoot, journal);
|
|
974
|
+
} catch {
|
|
975
|
+
// The journal is a convenience at this point: the server's ledger is the
|
|
976
|
+
// record of what was charged and what came back.
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** The finding as the prompt sees it: no ids nobody can act on, no noise. */
|
|
981
|
+
export function findingForPrompt(finding) {
|
|
982
|
+
return {
|
|
983
|
+
finding_id: finding.finding_id,
|
|
984
|
+
label: finding.label || null,
|
|
985
|
+
title: finding.title || null,
|
|
986
|
+
severity: finding.severity || null,
|
|
987
|
+
kind: finding.kind || null,
|
|
988
|
+
description: finding.description || null,
|
|
989
|
+
expected: finding.expected || null,
|
|
990
|
+
actual: finding.actual || null,
|
|
991
|
+
evidence: finding.evidence || null
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* @param {Object} finding
|
|
997
|
+
* @returns {Array<string>}
|
|
998
|
+
*/
|
|
999
|
+
export function reproStepsOf(finding) {
|
|
1000
|
+
const steps = finding.repro_steps ?? finding.repro ?? null;
|
|
1001
|
+
if (Array.isArray(steps)) return steps.map(step => (typeof step === 'string' ? step : String(step && step.text ? step.text : JSON.stringify(step)))).filter(Boolean);
|
|
1002
|
+
if (typeof steps === 'string' && steps.trim()) return steps.split('\n').map(line => line.trim()).filter(Boolean);
|
|
1003
|
+
return [];
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Design intent comes from the pack and from the brief, and both are binding:
|
|
1008
|
+
* a fix that "fixes" something the developer meant is worse than no fix.
|
|
1009
|
+
*
|
|
1010
|
+
* @param {Object} pack
|
|
1011
|
+
* @param {Object|null} brief
|
|
1012
|
+
* @returns {Array<string>}
|
|
1013
|
+
*/
|
|
1014
|
+
export function intentNotesFor(pack, brief) {
|
|
1015
|
+
const fromBrief = ((brief && brief.fields && brief.fields.design_intent_notes) || []).map(note => (typeof note === 'string' ? note : note && note.text)).filter(Boolean);
|
|
1016
|
+
const fromPack = (pack.intent_notes || []).map(note => (typeof note === 'string' ? note : note && note.text)).filter(Boolean);
|
|
1017
|
+
return [...fromBrief, ...fromPack];
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* The level graph for this job, or null.
|
|
1022
|
+
*
|
|
1023
|
+
* @returns {Promise<Object|null>}
|
|
1024
|
+
*/
|
|
1025
|
+
export async function jobGraphContext({ deps, api, gameId, jobId, log = () => {} }) {
|
|
1026
|
+
try {
|
|
1027
|
+
const bundle = await deps.packs.get({ api, gameId, modules: PACK_MODULES, jobId });
|
|
1028
|
+
return (bundle && bundle.graph_context) || null;
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
log(`The level graph for this job could not be read (${String(error && error.message)}), so the plan is made without it.`);
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* The server's per-field reasons for refusing a changeset, as lines.
|
|
1037
|
+
*
|
|
1038
|
+
* @param {any} error
|
|
1039
|
+
* @returns {Array<string>}
|
|
1040
|
+
*/
|
|
1041
|
+
export function changesetDetails(error) {
|
|
1042
|
+
if (!error || error.code !== 'invalid_changeset') return [];
|
|
1043
|
+
const details = (error.body && error.body.details) || error.details || [];
|
|
1044
|
+
return (Array.isArray(details) ? details : [])
|
|
1045
|
+
.map(detail => [detail.instancePath, detail.code, detail.message].filter(Boolean).join(': '))
|
|
1046
|
+
.filter(Boolean);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function isImageName(name) {
|
|
1050
|
+
return /\.(png|jpe?g|webp)$/i.test(String(name));
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function mediaTypeFor(name) {
|
|
1054
|
+
if (/\.jpe?g$/i.test(name)) return 'image/jpeg';
|
|
1055
|
+
if (/\.webp$/i.test(name)) return 'image/webp';
|
|
1056
|
+
return 'image/png';
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
export default fixCommand;
|