ravensight-playtest 0.5.2 → 0.7.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/README.md +319 -14
- package/addons/ravensight_driver/driver.gd +406 -8
- package/addons/ravensight_simulate/simulate.gd +360 -0
- package/docs/godot-driver.md +70 -8
- package/package.json +4 -3
- package/src/api/README.md +4 -1
- package/src/api/client.js +120 -3
- package/src/api/errors.js +17 -1
- package/src/api/http.js +43 -17
- package/src/assets/formats.js +267 -0
- package/src/assets/index.js +260 -0
- package/src/assets/integration.js +429 -0
- package/src/assets/inventory.js +337 -0
- package/src/assets/measure.js +392 -0
- package/src/cli.js +233 -0
- package/src/commands/assets.js +62 -0
- package/src/commands/check.js +4 -1
- package/src/commands/create.js +734 -0
- package/src/commands/fakeCore.js +16 -1
- package/src/commands/feature.js +845 -0
- package/src/commands/fix.js +647 -70
- package/src/commands/init.js +19 -3
- package/src/commands/lists.js +206 -0
- package/src/commands/profile.js +25 -6
- package/src/commands/punch.js +116 -0
- package/src/commands/replay.js +204 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/run.js +200 -24
- package/src/commands/tape.js +74 -0
- package/src/commands/template.js +70 -0
- package/src/commands/validate.js +414 -0
- package/src/commands/validateHosted.js +292 -0
- package/src/create/scaffold.js +192 -0
- package/src/run/acceptance.js +212 -0
- package/src/run/args.js +18 -1
- package/src/run/checks.js +705 -0
- package/src/run/dimension.js +95 -0
- package/src/run/drivers/godot-observation.js +59 -11
- package/src/run/drivers/godot-project.js +201 -31
- package/src/run/drivers/godot.js +532 -45
- package/src/run/drivers/web.js +18 -0
- package/src/run/evidence.js +170 -0
- package/src/run/featureLoop.js +288 -0
- package/src/run/fixLoop.js +82 -12
- package/src/run/git.js +110 -20
- package/src/run/json.js +65 -9
- package/src/run/labsOutcome.js +136 -0
- package/src/run/liveWork.js +103 -0
- package/src/run/model.js +105 -5
- package/src/run/openPr.js +107 -0
- package/src/run/paths.js +4 -1
- package/src/run/pendingRegister.js +81 -0
- package/src/run/personaLoop.js +111 -4
- package/src/run/profile.js +68 -7
- package/src/run/replay.js +236 -0
- package/src/run/repoTools.js +227 -27
- package/src/run/report.js +7 -1
- package/src/run/resume.js +19 -1
- package/src/run/secretScan.js +35 -0
- package/src/run/settle.js +41 -0
- package/src/run/tools.js +33 -4
- package/src/run/trace.js +124 -0
- package/src/simulation/godotPack.js +112 -0
- package/src/ui/index.js +61 -9
- package/src/upload/allowlist.js +6 -2
- package/src/upload/index.js +5 -4
- package/templates/godot-2d/README.md +36 -0
- package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-2d/export_presets.cfg +27 -0
- package/templates/godot-2d/gitignore +10 -0
- package/templates/godot-2d/project.godot +58 -0
- package/templates/godot-2d/scenes/main.tscn +67 -0
- package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-2d/scripts/main.gd +32 -0
- package/templates/godot-2d/scripts/player.gd +10 -0
- package/templates/godot-2d/scripts/state_hook.gd +16 -0
- package/templates/godot-2d/template.json +14 -0
- package/templates/godot-2d/tools/inject_release_key.sh +19 -0
- package/templates/godot-2d/tools/selftest.gd +70 -0
- package/templates/godot-3d/README.md +49 -0
- package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-3d/export_presets.cfg +27 -0
- package/templates/godot-3d/gitignore +10 -0
- package/templates/godot-3d/project.godot +56 -0
- package/templates/godot-3d/scenes/main.tscn +154 -0
- package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-3d/scripts/beacon.gd +20 -0
- package/templates/godot-3d/scripts/main.gd +49 -0
- package/templates/godot-3d/scripts/player.gd +19 -0
- package/templates/godot-3d/scripts/state_hook.gd +25 -0
- package/templates/godot-3d/template.json +16 -0
- package/templates/godot-3d/tools/inject_release_key.sh +19 -0
- package/templates/godot-3d/tools/selftest.gd +77 -0
package/src/commands/fix.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
2
4
|
import { appendFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
5
|
import path from 'node:path';
|
|
4
6
|
import { optionsFrom, unknownOptionNotes } from '../run/args.js';
|
|
@@ -6,17 +8,31 @@ import { getDeps } from '../run/deps.js';
|
|
|
6
8
|
import { createDriver } from '../run/drivers/index.js';
|
|
7
9
|
import EXIT from '../run/exit.js';
|
|
8
10
|
import { applyFix, fixSystem, MAX_PLAN_IMAGES, planContent, planFix, resolveFixMaxActions, summariseFix } from '../run/fixLoop.js';
|
|
9
|
-
import { createGit, fixBranchName, preflight, startBranch } from '../run/git.js';
|
|
11
|
+
import { abandonBranch, createGit, fixBranchName, preflight, startBranch } from '../run/git.js';
|
|
10
12
|
import { createHeartbeat } from '../run/heartbeat.js';
|
|
11
13
|
import { createModelClient } from '../run/model.js';
|
|
12
14
|
import { jobDir, runDir as runDirFor, RUN_FILES, screenshotsDir } from '../run/paths.js';
|
|
13
15
|
import { runPersona } from '../run/personaLoop.js';
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
+
import { replayTrace } from '../run/replay.js';
|
|
17
|
+
import { loadTrace, replayReport, replaySentence } from './replay.js';
|
|
18
|
+
import { createRepoTools, PROFILE_TOOLS, WRITE_TOOLS } from '../run/repoTools.js';
|
|
19
|
+
import { recordPrOpened } from '../run/labsOutcome.js';
|
|
20
|
+
import { settleFailureLine } from '../run/settle.js';
|
|
21
|
+
import { acceptanceLines, buildAcceptance, fixV2Live, frozenAcceptance, verifyBriefFromAcceptance } from '../run/acceptance.js';
|
|
22
|
+
import { checkResultText, createCheckRunner, detectTestRunner, withRunCheck } from '../run/checks.js';
|
|
23
|
+
import { isGodotProject } from '../run/drivers/godot-project.js';
|
|
24
|
+
import { checksStillFailing, evidenceReport, withEvidenceIntact } from '../run/evidence.js';
|
|
25
|
+
import { openPullRequest, shellQuote } from '../run/openPr.js';
|
|
26
|
+
import { assertNoSecrets, withoutQuotedSecrets } from '../run/secretScan.js';
|
|
27
|
+
import * as pendingRegister from '../run/pendingRegister.js';
|
|
16
28
|
import * as stateFile from '../run/state.js';
|
|
17
29
|
import { createUsageLedger, writeUsageFile } from '../run/usage.js';
|
|
18
|
-
import { createInterruptHandler, deliver, driverOptions, exitCodeFor, explainRegisterRefusal, formatCents, resolveSetup, resolveTarget, skillFor, usableLocalBrief } from './run.js';
|
|
30
|
+
import { createInterruptHandler, deliver, driverOptions, exitCodeFor, explainRegisterRefusal, formatCents, godotRefusal, resolveSetup, resolveTarget, skillFor, usableLocalBrief, startBalanceLine, startBalanceShort } from './run.js';
|
|
19
31
|
import { loadLocalBrief } from '../briefLocal.js';
|
|
32
|
+
import { findListItem } from './lists.js';
|
|
33
|
+
import { jobLink } from '../dashboard.js';
|
|
34
|
+
|
|
35
|
+
const execFileAsync = promisify(execFile);
|
|
20
36
|
|
|
21
37
|
/**
|
|
22
38
|
* `ravensight-playtest fix <target>`: take one finding, change the code on a
|
|
@@ -25,10 +41,12 @@ import { loadLocalBrief } from '../briefLocal.js';
|
|
|
25
41
|
*
|
|
26
42
|
* Three rules shape everything else in this file:
|
|
27
43
|
*
|
|
28
|
-
* 1. **It edits, it never publishes.**
|
|
44
|
+
* 1. **It edits, it never publishes on its own.** Git usage lives in
|
|
29
45
|
* `src/run/git.js`, which has no commit, no push and no way to reach a
|
|
30
46
|
* forge. The command ends by printing the pull request command; running it
|
|
31
|
-
* is the developer's decision, made with the diff in front of them.
|
|
47
|
+
* is the developer's decision, made with the diff in front of them. The
|
|
48
|
+
* one exception is Fix v2's `--open-pr` (`src/run/openPr.js`), which still
|
|
49
|
+
* asks at its own prompt after a verified fix and never forces a push.
|
|
32
50
|
* 2. **It buys the answer "no" as cheaply as possible.** The triage call comes
|
|
33
51
|
* before the first edit, because a finding that is design intent should
|
|
34
52
|
* cost a refunded unit rather than a branch full of changes nobody wanted.
|
|
@@ -65,6 +83,18 @@ export const REPRODUCED_ABOVE = 0.65;
|
|
|
65
83
|
/** A changeset carries at most this many files. */
|
|
66
84
|
export const MAX_CHANGESET_FILES = 20;
|
|
67
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Fix v2: at most this many repair turn sets after the first apply, each
|
|
88
|
+
* started by a failed check or a verify that still reproduces the finding.
|
|
89
|
+
*/
|
|
90
|
+
export const MAX_REPAIRS = 2;
|
|
91
|
+
|
|
92
|
+
/** A repair turn set's own action ceiling, inside the same run budget. */
|
|
93
|
+
export const REPAIR_MAX_ACTIONS = 20;
|
|
94
|
+
|
|
95
|
+
/** The checks run after every apply turn set, in this order. */
|
|
96
|
+
export const GATE_CHECKS = Object.freeze(['godot_import', 'godot_script_check', 'project_tests']);
|
|
97
|
+
|
|
68
98
|
/**
|
|
69
99
|
* @param {string} target `<jobId>/<label>` or a finding id
|
|
70
100
|
* @param {Object} flags what commander hands over
|
|
@@ -96,6 +126,9 @@ export async function fixCommand(target, flags = {}, injected = {}) {
|
|
|
96
126
|
export function parseTarget(target) {
|
|
97
127
|
const value = String(target || '').trim();
|
|
98
128
|
if (!value) return null;
|
|
129
|
+
// A finding's id is `<runId>/<label>`, so a run id on the left means the whole
|
|
130
|
+
// value names the finding, not a label inside a job.
|
|
131
|
+
if (value.startsWith('pt_run_')) return { jobId: null, label: null, findingId: value };
|
|
99
132
|
const slash = value.lastIndexOf('/');
|
|
100
133
|
if (slash > 0 && slash < value.length - 1) {
|
|
101
134
|
return { jobId: value.slice(0, slash), label: value.slice(slash + 1), findingId: null };
|
|
@@ -129,6 +162,24 @@ export function hostedTargetRefusal(driverName, target) {
|
|
|
129
162
|
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.`;
|
|
130
163
|
}
|
|
131
164
|
|
|
165
|
+
/**
|
|
166
|
+
* What became of a fix's unit when its verify could not boot the game, from the
|
|
167
|
+
* server's own answers: the unit comes back up to a limit per account.
|
|
168
|
+
*
|
|
169
|
+
* @param {{finished: Object|null, run: Object|null, priceCents: number}} input
|
|
170
|
+
* @returns {string}
|
|
171
|
+
*/
|
|
172
|
+
export function bootRefundLine({ finished, run, priceCents }) {
|
|
173
|
+
const start = 'The game did not boot for the verify run';
|
|
174
|
+
if (run && run.changeset && run.changeset.boot_refund_limited) {
|
|
175
|
+
return `${start}, and this account has used its refunds for that in the last 30 days, so the ${formatCents(priceCents)} is kept.`;
|
|
176
|
+
}
|
|
177
|
+
const cents = finished ? Number(finished.refunded_cents) || 0 : 0;
|
|
178
|
+
if (cents > 0) return `${start}, so the ${formatCents(cents)} comes back.`;
|
|
179
|
+
if (!finished) return `${start}. The job page says what came back once it settles.`;
|
|
180
|
+
return `${start}, and nothing came back of the ${formatCents(priceCents)} charged.`;
|
|
181
|
+
}
|
|
182
|
+
|
|
132
183
|
/**
|
|
133
184
|
* The verdict in the words a developer reads.
|
|
134
185
|
*
|
|
@@ -137,6 +188,10 @@ export function hostedTargetRefusal(driverName, target) {
|
|
|
137
188
|
*/
|
|
138
189
|
export function verifyWords(verify) {
|
|
139
190
|
if (!verify) return 'not verified';
|
|
191
|
+
// A verify run whose game never booted played a black screen: whatever the
|
|
192
|
+
// replay's report says, it says nothing about the fix.
|
|
193
|
+
if (verify.failed === 'game_boot_failed') return `not verified, the game did not boot (${verify.detail || 'see godot.log in the verify run directory'})`;
|
|
194
|
+
if (verify.failed) return `not verified, the verify run failed (${verify.detail || verify.failed})`;
|
|
140
195
|
const score = verify.reproduced;
|
|
141
196
|
if (typeof score !== 'number' || !Number.isFinite(score)) return 'unknown, the verify run reached no verdict';
|
|
142
197
|
if (score < FIXED_BELOW) return `fixed (reproduced ${score})`;
|
|
@@ -159,9 +214,9 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
159
214
|
for (const line of unknownOptionNotes(unknown)) log(line);
|
|
160
215
|
return EXIT.ENVIRONMENT;
|
|
161
216
|
}
|
|
162
|
-
|
|
217
|
+
let parsed = parseTarget(target);
|
|
163
218
|
if (!parsed) {
|
|
164
|
-
log('Name the finding to fix: either <jobId>/<label>, like pt_job_1/F002, or a
|
|
219
|
+
log('Name the finding to fix: either <jobId>/<label>, like pt_job_1/F002, a finding id, or a punch list item, li_...');
|
|
165
220
|
return EXIT.ENVIRONMENT;
|
|
166
221
|
}
|
|
167
222
|
|
|
@@ -169,9 +224,23 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
169
224
|
if (setup.code !== undefined) return setup.code;
|
|
170
225
|
const { context, api, gameId, apiUrl, repoRoot, cliVersion } = setup;
|
|
171
226
|
|
|
227
|
+
// A punch list item that came from a finding is fixed as that finding. The
|
|
228
|
+
// item only lends its finding's id, and later records the job; it is never closed here.
|
|
229
|
+
let listItem = null;
|
|
230
|
+
if (isListItemTarget(target)) {
|
|
231
|
+
const resolved = await resolveListItemTarget(api, gameId, String(target).trim());
|
|
232
|
+
if (resolved.refusal) {
|
|
233
|
+
log(resolved.refusal);
|
|
234
|
+
return EXIT.ENVIRONMENT;
|
|
235
|
+
}
|
|
236
|
+
listItem = resolved;
|
|
237
|
+
parsed = parseTarget(resolved.item.source.ref);
|
|
238
|
+
log(`${resolved.item.item_id} came from ${resolved.item.source.kind === 'fresh_eyes' ? 'fresh-eyes finding' : 'finding'} ${resolved.item.source.ref}, so this fixes that finding.`);
|
|
239
|
+
}
|
|
240
|
+
|
|
172
241
|
const wantsVerify = options.verify !== false;
|
|
173
242
|
const driverName = options.driver || (context.config && context.config.driver) || 'playwright_web';
|
|
174
|
-
const buildTarget = resolveTarget(options, context.config, driverName);
|
|
243
|
+
const buildTarget = resolveTarget(options, context.config, driverName, repoRoot);
|
|
175
244
|
|
|
176
245
|
// Said before anything is charged, because it is the difference between a
|
|
177
246
|
// verify that means something and one that does not.
|
|
@@ -185,6 +254,11 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
185
254
|
log(refusal);
|
|
186
255
|
return EXIT.ENVIRONMENT;
|
|
187
256
|
}
|
|
257
|
+
const noGodot = await godotRefusal({ driverName, godotPath: options.godotPath, projectDir: buildTarget, injected });
|
|
258
|
+
if (noGodot) {
|
|
259
|
+
log(noGodot);
|
|
260
|
+
return EXIT.ENVIRONMENT;
|
|
261
|
+
}
|
|
188
262
|
if (driverName === 'playwright_web') {
|
|
189
263
|
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.`);
|
|
190
264
|
}
|
|
@@ -218,16 +292,29 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
218
292
|
// repro steps and the persona's words are written in full.
|
|
219
293
|
const finding = enrichFinding(listed, evidence.reportJson);
|
|
220
294
|
|
|
221
|
-
const
|
|
295
|
+
const pendingScope = { kind: 'fix', gameId, target: finding.finding_id };
|
|
296
|
+
const pendingRequest = options.idempotencyKey ? null : await pendingRegister.load(repoRoot, pendingScope);
|
|
297
|
+
const branch = pendingRequest ? pendingRequest.branch : options.branch || fixBranchName(finding.label || finding.finding_id);
|
|
222
298
|
const git = injected.git || createGit({ root: repoRoot });
|
|
223
299
|
// Every refusal now, the one mutation after the price is agreed: a developer
|
|
224
300
|
// who says no to the charge should not be left on a new branch.
|
|
225
|
-
const checked = await preflight({ git, branch, allowDirty: Boolean(options.allowDirty), create: false });
|
|
301
|
+
const checked = await preflight({ git, branch, allowDirty: Boolean(options.allowDirty), create: false, reuseBranch: Boolean(pendingRequest) });
|
|
302
|
+
if (pendingRequest) {
|
|
303
|
+
log(`The last attempt at this fix got no clear answer, so this run asks about that same request on ${branch}. If it was charged, this answers with that job.`);
|
|
304
|
+
checked.from = pendingRequest.from;
|
|
305
|
+
}
|
|
226
306
|
if (!checked.ok) {
|
|
227
307
|
log(checked.reason);
|
|
228
308
|
return EXIT.ENVIRONMENT;
|
|
229
309
|
}
|
|
230
310
|
|
|
311
|
+
// --open-pr commits what the fix changed, and a dirty tree makes that a mixture
|
|
312
|
+
// of two people's work. Refused now, because nothing later can tell them apart.
|
|
313
|
+
if (options.openPr && checked.dirty) {
|
|
314
|
+
log('--open-pr publishes a commit, and this working tree has uncommitted changes that would be mixed into it. Commit or stash them, or leave out --open-pr. Nothing was charged.');
|
|
315
|
+
return EXIT.ENVIRONMENT;
|
|
316
|
+
}
|
|
317
|
+
|
|
231
318
|
// A fix job asks for exactly one persona, the one who filed the finding: the
|
|
232
319
|
// server creates the verify run from it, and both zero and two are a 400.
|
|
233
320
|
const personaSlug = finding.persona || ((pack.personas || [])[0] || {}).slug || null;
|
|
@@ -235,7 +322,33 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
235
322
|
log('That finding names no persona and the content pack has none, so there is nobody to verify the fix with.');
|
|
236
323
|
return EXIT.ENVIRONMENT;
|
|
237
324
|
}
|
|
238
|
-
|
|
325
|
+
// Fix v2 (Labs beta) only when the server says it is live for this game.
|
|
326
|
+
// Anything else, including a server that has never heard of Labs, is the standard fix.
|
|
327
|
+
const labs = !options.noLabs && (await fixV2Live(api, gameId, {
|
|
328
|
+
onUnfunded: () => log('Fix v2 (Labs beta) needs an account that has added at least $10.00 in top-ups, so this is an ordinary fix.')
|
|
329
|
+
}));
|
|
330
|
+
const acceptance = labs ? buildAcceptance(finding, { persona: personaSlug, reproSteps: reproStepsOf(finding) }) : null;
|
|
331
|
+
if (acceptance) {
|
|
332
|
+
log('Fix v2 (Labs beta) is on for this game: named checks, up to two repairs, and an evidence report.');
|
|
333
|
+
for (const line of acceptanceLines(acceptance)) log(line);
|
|
334
|
+
}
|
|
335
|
+
// Tests are the developer's code run with the model's edits in place, so
|
|
336
|
+
// they run only with --allow-tests or a yes at a terminal, asked now,
|
|
337
|
+
// before anything is charged.
|
|
338
|
+
let allowTests = Boolean(options.allowTests);
|
|
339
|
+
if (labs && !allowTests) {
|
|
340
|
+
const projectDir = await checkProjectDir({ driverName, buildTarget, repoRoot });
|
|
341
|
+
const runner = projectDir ? await detectTestRunner(projectDir) : null;
|
|
342
|
+
const askTests = injected.confirmTests || (process.stdin.isTTY && deps.ui && deps.ui.confirm);
|
|
343
|
+
if (runner && askTests) {
|
|
344
|
+
allowTests = Boolean(await askTests(`This project ships ${runner === 'gut' ? 'GUT' : 'gdUnit4'} tests. Let the fix run them, with its edits in place, on this machine?`, { yes: false }));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (options.openPr && !labs) {
|
|
348
|
+
log('--open-pr is part of Fix v2 (Labs beta), which is not on for this game, so it is ignored and the pull request command is printed instead.');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const estimate = await api.jobs.estimate(gameId, { modules: [...FIX_MODULES], personas: [personaSlug], ...(acceptance ? { acceptance: true } : {}) });
|
|
239
352
|
const priceCents = Number.isFinite(Number(estimate.estimate_cents)) ? Number(estimate.estimate_cents) : Number(estimate.price_cents);
|
|
240
353
|
if (!Number.isFinite(priceCents)) {
|
|
241
354
|
// Sending `confirm_price_cents: undefined` would be refused as
|
|
@@ -244,8 +357,15 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
244
357
|
log('The server quoted no price for this fix, so nothing was charged. Run "ravensight-playtest check" and try again.');
|
|
245
358
|
return EXIT.ENVIRONMENT;
|
|
246
359
|
}
|
|
247
|
-
const offer = `${formatCents(priceCents)} fix attempt (includes one verify run). Balance: ${formatCents(estimate.balance_cents)}
|
|
360
|
+
const offer = `${formatCents(priceCents)} fix attempt (includes one verify run). Balance: ${formatCents(estimate.balance_cents)}.`
|
|
361
|
+
+ (labs ? ' The checks and the verify run execute the edited project on this machine with a reduced environment.' : '');
|
|
248
362
|
log(offer);
|
|
363
|
+
const floorLine = startBalanceLine(estimate);
|
|
364
|
+
if (floorLine) log(floorLine);
|
|
365
|
+
if (estimate.ok === false && floorLine) {
|
|
366
|
+
log(`${startBalanceShort(estimate)} An ordinary fix, with --no-labs, starts on its price alone.`);
|
|
367
|
+
return EXIT.ENVIRONMENT;
|
|
368
|
+
}
|
|
249
369
|
if (estimate.ok === false) {
|
|
250
370
|
log(`That is more than the balance covers. Top up at ${estimate.topup_url || 'ravensight.io'} and run this again.`);
|
|
251
371
|
return EXIT.ENVIRONMENT;
|
|
@@ -260,42 +380,86 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
260
380
|
}
|
|
261
381
|
}
|
|
262
382
|
|
|
263
|
-
const started = await startBranch({ git, branch });
|
|
383
|
+
const started = pendingRequest ? await pendingRegister.returnToBranch({ git, branch }) : await startBranch({ git, branch });
|
|
264
384
|
if (!started.ok) {
|
|
265
385
|
log(started.reason);
|
|
266
386
|
return EXIT.ENVIRONMENT;
|
|
267
387
|
}
|
|
268
388
|
log(`branch ${branch}, from ${checked.head.slice(0, 8)}${checked.from ? ` on ${checked.from}` : ''}.`);
|
|
269
389
|
|
|
270
|
-
|
|
390
|
+
// Every way out before a job exists has to leave the developer where they were,
|
|
391
|
+
// unless nobody knows whether a job exists. Then the key and the branch are the way back.
|
|
392
|
+
const noJob = async () => {
|
|
393
|
+
await pendingRegister.clear(repoRoot, pendingScope);
|
|
394
|
+
await abandonBranch({ git, branch, from: checked.from, head: checked.head });
|
|
395
|
+
};
|
|
396
|
+
const remember = key => pendingRegister.save(repoRoot, pendingScope, { idempotencyKey: key, branch, from: checked.from, head: checked.head });
|
|
397
|
+
let idempotencyKey = options.idempotencyKey || (pendingRequest && pendingRequest.idempotencyKey) || randomUUID();
|
|
398
|
+
const registerBody = {
|
|
399
|
+
modules: [...FIX_MODULES],
|
|
400
|
+
personas: [personaSlug],
|
|
401
|
+
finding_id: finding.finding_id,
|
|
402
|
+
driver: driverName,
|
|
403
|
+
cli_version: cliVersion,
|
|
404
|
+
confirm_price_cents: priceCents,
|
|
405
|
+
brief_version: brief ? brief.version ?? null : null,
|
|
406
|
+
build: buildTarget
|
|
407
|
+
? { kind: driverName === 'playwright_web' ? 'url' : 'godot_project', ref: buildTarget }
|
|
408
|
+
: { kind: 'repo', ref: repoRoot },
|
|
409
|
+
repo: { commit: checked.head, dirty: Boolean(checked.dirty), branch },
|
|
410
|
+
max_actions: Number.isInteger(options.maxActions) ? options.maxActions : null,
|
|
411
|
+
pack_versions: { pack: pack.pack_version },
|
|
412
|
+
...(acceptance ? { acceptance } : {})
|
|
413
|
+
};
|
|
414
|
+
// A failed register, whichever attempt: `undefined` means it was an exception to rethrow.
|
|
415
|
+
const registerFailed = async error => {
|
|
416
|
+
if (pendingRegister.outcomeUnknown(error)) {
|
|
417
|
+
log(pendingRegister.unknownOutcomeMessage('this fix', branch));
|
|
418
|
+
return EXIT.ENVIRONMENT;
|
|
419
|
+
}
|
|
420
|
+
await noJob();
|
|
421
|
+
const handled = explainRegisterRefusal(error, log);
|
|
422
|
+
return handled !== null ? handled : undefined;
|
|
423
|
+
};
|
|
271
424
|
let registered;
|
|
272
425
|
try {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
personas: [personaSlug],
|
|
276
|
-
finding_id: finding.finding_id,
|
|
277
|
-
driver: driverName,
|
|
278
|
-
cli_version: cliVersion,
|
|
279
|
-
confirm_price_cents: priceCents,
|
|
280
|
-
brief_version: brief ? brief.version ?? null : null,
|
|
281
|
-
build: buildTarget
|
|
282
|
-
? { kind: driverName === 'playwright_web' ? 'url' : 'godot_project', ref: buildTarget }
|
|
283
|
-
: { kind: 'repo', ref: repoRoot },
|
|
284
|
-
repo: { commit: checked.head, dirty: Boolean(checked.dirty), branch },
|
|
285
|
-
max_actions: Number.isInteger(options.maxActions) ? options.maxActions : null,
|
|
286
|
-
pack_versions: { pack: pack.pack_version }
|
|
287
|
-
}, { idempotencyKey });
|
|
426
|
+
await remember(idempotencyKey);
|
|
427
|
+
registered = await api.jobs.register(gameId, registerBody, { idempotencyKey });
|
|
288
428
|
} catch (error) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
429
|
+
if (acceptance && error && ['labs_not_enabled', 'funding_required'].includes(error.code)) {
|
|
430
|
+
// Labs went off, or the funding floor stopped being met, between the status read
|
|
431
|
+
// and the register. Nothing was charged by a refusal, so register the ordinary
|
|
432
|
+
// fix instead, on a fresh key because the body differs.
|
|
433
|
+
log(`The server refused Fix v2 for this game (${error.code}), so this is an ordinary fix.`);
|
|
434
|
+
delete registerBody.acceptance;
|
|
435
|
+
idempotencyKey = randomUUID();
|
|
436
|
+
try {
|
|
437
|
+
await remember(idempotencyKey);
|
|
438
|
+
registered = await api.jobs.register(gameId, registerBody, { idempotencyKey });
|
|
439
|
+
} catch (retryError) {
|
|
440
|
+
const exit = await registerFailed(retryError);
|
|
441
|
+
if (exit !== undefined) return exit;
|
|
442
|
+
throw retryError;
|
|
443
|
+
}
|
|
444
|
+
} else {
|
|
445
|
+
const exit = await registerFailed(error);
|
|
446
|
+
if (exit !== undefined) return exit;
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
292
449
|
}
|
|
450
|
+
await pendingRegister.clear(repoRoot, pendingScope);
|
|
293
451
|
|
|
294
452
|
const jobId = registered.job_id;
|
|
295
453
|
// Runs are told apart by module and nothing else: the fix run carries
|
|
296
454
|
// `persona: null`, and only the verify run carries the slug.
|
|
297
455
|
const fixRun = (registered.runs || []).find(entry => entry.module === 'fix');
|
|
298
|
-
const
|
|
456
|
+
const verifyRuns = (registered.runs || []).filter(entry => entry.module === 'persona_playtest');
|
|
457
|
+
const verifyRun = verifyRuns[0] || null;
|
|
458
|
+
// The contract the server froze. None means the server kept none, and the
|
|
459
|
+
// fix runs as the standard fix does: no checks, no repairs, no evidence report.
|
|
460
|
+
const frozen = acceptance ? frozenAcceptance(registered) : null;
|
|
461
|
+
if (acceptance && !frozen) log('The server did not keep the acceptance contract, so this fix runs without checks or repairs.');
|
|
462
|
+
const v2 = Boolean(frozen);
|
|
299
463
|
|
|
300
464
|
await mkdir(jobDir(repoRoot, jobId), { recursive: true });
|
|
301
465
|
const journal = stateFile.newState({
|
|
@@ -317,7 +481,8 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
317
481
|
findingId: finding.finding_id,
|
|
318
482
|
verify: wantsVerify,
|
|
319
483
|
allowDirty: Boolean(options.allowDirty),
|
|
320
|
-
maxActions: options.maxActions ?? null
|
|
484
|
+
maxActions: options.maxActions ?? null,
|
|
485
|
+
...(v2 ? { labs: 'fix_v2' } : {})
|
|
321
486
|
}
|
|
322
487
|
});
|
|
323
488
|
for (const entry of registered.runs || []) {
|
|
@@ -328,10 +493,11 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
328
493
|
// the runs, because the charge has already happened: a developer who cannot
|
|
329
494
|
// name the job they paid for cannot ask anybody about it either.
|
|
330
495
|
log(`job ${jobId}: ${formatCents(registered.price && registered.price.price_cents)} charged. ${jobDir(repoRoot, jobId)}`);
|
|
496
|
+
if (listItem) await recordFixJob({ api, gameId, listItem, jobId, link: (registered.links && registered.links.dashboard) || jobLink(gameId, jobId, apiUrl), log });
|
|
331
497
|
|
|
332
498
|
if (!fixRun) {
|
|
333
499
|
log('The server registered the job without a fix run, so there is nothing to spend a model turn from.');
|
|
334
|
-
await skipRun({ api, gameId, jobId, run:
|
|
500
|
+
for (const entry of v2 ? verifyRuns : [verifyRun]) await skipRun({ api, gameId, jobId, run: entry, reason: 'the job has no fix run', log });
|
|
335
501
|
// Settled rather than abandoned: the job is charged and unplayable, so the
|
|
336
502
|
// refund is owed now and not in half an hour when the sweep notices.
|
|
337
503
|
await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the job has no fix run', log });
|
|
@@ -354,6 +520,7 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
354
520
|
onError: () => {}
|
|
355
521
|
});
|
|
356
522
|
const inFlight = new Map();
|
|
523
|
+
const abort = new AbortController();
|
|
357
524
|
const interrupt = createInterruptHandler({
|
|
358
525
|
api,
|
|
359
526
|
gameId,
|
|
@@ -366,7 +533,9 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
366
533
|
// this job's verify persona against a half-applied edit would answer a
|
|
367
534
|
// question nobody asked. A fix is abandoned and refunded instead.
|
|
368
535
|
message: 'finishing the turn in flight, then stopping. A fix cannot be resumed, so this one is being given back.',
|
|
369
|
-
injected
|
|
536
|
+
injected,
|
|
537
|
+
// The verify run listens to this: every turn it plays after a Ctrl-C is model spend.
|
|
538
|
+
onInterrupt: () => abort.abort()
|
|
370
539
|
});
|
|
371
540
|
|
|
372
541
|
try {
|
|
@@ -387,10 +556,11 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
387
556
|
heartbeat.stop();
|
|
388
557
|
inFlight.delete(fixRun.run_id);
|
|
389
558
|
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.`);
|
|
559
|
+
for (const entry of v2 ? verifyRuns : [verifyRun]) await skipRun({ api, gameId, jobId, run: entry, reason: 'the finding is design intent', log });
|
|
390
560
|
await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'intended' }, log });
|
|
391
|
-
await
|
|
392
|
-
|
|
393
|
-
log('
|
|
561
|
+
const intended = await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the finding is design intent', log });
|
|
562
|
+
if (Number(intended?.refunded_cents) > 0) log('The fix unit was refunded.');
|
|
563
|
+
log('Mark the finding as intended in the dashboard so no later job spends a turn on it.');
|
|
394
564
|
return EXIT.OK;
|
|
395
565
|
}
|
|
396
566
|
|
|
@@ -401,7 +571,8 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
401
571
|
jobId,
|
|
402
572
|
branch,
|
|
403
573
|
baseCommit: checked.head,
|
|
404
|
-
engine: pack.engine || null
|
|
574
|
+
engine: pack.engine || null,
|
|
575
|
+
...(v2 ? { runCheck: true } : {})
|
|
405
576
|
});
|
|
406
577
|
const codeBrief = pack.code_brief || null;
|
|
407
578
|
// `graph_context` only exists in a bundle asked for with this job's id: it
|
|
@@ -432,14 +603,26 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
432
603
|
if (plan.hypothesis) log(`hypothesis: ${plan.hypothesis}`);
|
|
433
604
|
|
|
434
605
|
const maxActions = resolveFixMaxActions(options, pack);
|
|
435
|
-
|
|
606
|
+
// Fix v2: one repository tool surface for the first apply and every
|
|
607
|
+
// repair, so the per-run write cap counts all of them, plus run_check.
|
|
608
|
+
const checks = v2
|
|
609
|
+
? createCheckRunner({
|
|
610
|
+
projectDir: await checkProjectDir({ driverName, buildTarget, repoRoot }),
|
|
611
|
+
godotPath: options.godotPath || null,
|
|
612
|
+
allowTests,
|
|
613
|
+
allowToolScripts: Boolean(options.allowToolScripts),
|
|
614
|
+
changedFiles: async () => projectRelative(await git.diffStat(), repoRoot, await checkProjectDir({ driverName, buildTarget, repoRoot })),
|
|
615
|
+
...(injected.checkRunner ? injected.checkRunner : {})
|
|
616
|
+
})
|
|
617
|
+
: null;
|
|
618
|
+
const applyRepo = v2 ? createRepoTools({ root: repoRoot, tools: WRITE_TOOLS }) : undefined;
|
|
619
|
+
const applyArgs = {
|
|
436
620
|
client,
|
|
437
621
|
jobId,
|
|
438
622
|
runId: fixRun.run_id,
|
|
439
623
|
repoRoot,
|
|
440
624
|
system,
|
|
441
625
|
plan,
|
|
442
|
-
maxActions,
|
|
443
626
|
codeBrief,
|
|
444
627
|
graphContext,
|
|
445
628
|
ledger,
|
|
@@ -454,6 +637,11 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
454
637
|
appendFile(path.join(directory, 'transcript.jsonl'), `${JSON.stringify({ at: new Date().toISOString(), ...progress })}\n`).catch(() => {});
|
|
455
638
|
},
|
|
456
639
|
shouldStop: () => interrupt.signalled || heartbeat.canceled
|
|
640
|
+
};
|
|
641
|
+
let applied = await applyFix({
|
|
642
|
+
...applyArgs,
|
|
643
|
+
maxActions,
|
|
644
|
+
...(v2 ? { acceptance: frozen, repo: withRunCheck(applyRepo, checks) } : {})
|
|
457
645
|
});
|
|
458
646
|
|
|
459
647
|
// A Ctrl-C, or the job cancelled from the dashboard. Either way this run is
|
|
@@ -465,16 +653,16 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
465
653
|
heartbeat.stop();
|
|
466
654
|
inFlight.delete(fixRun.run_id);
|
|
467
655
|
const why = interrupt.signalled ? 'interrupted by the developer' : 'cancelled';
|
|
656
|
+
for (const entry of v2 ? verifyRuns : [verifyRun]) await skipRun({ api, gameId, jobId, run: entry, reason: `the fix was ${why}`, log });
|
|
468
657
|
await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'error' }, log });
|
|
469
|
-
await skipRun({ api, gameId, jobId, run: verifyRun, reason: `the fix was ${why}`, log });
|
|
470
658
|
try {
|
|
471
659
|
await stateFile.patchRun(repoRoot, journal, fixRun.run_id, { state: 'failed', quit_reason: 'error' });
|
|
472
660
|
} catch {
|
|
473
661
|
// The journal is a convenience here; the server's own run state is the
|
|
474
662
|
// record, and it has just been told.
|
|
475
663
|
}
|
|
476
|
-
await settle({ api, gameId, jobId, repoRoot, journal, reason: `the fix was ${why}`, log });
|
|
477
|
-
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.`);
|
|
664
|
+
const stopped = await settle({ api, gameId, jobId, repoRoot, journal, reason: `the fix was ${why}`, log });
|
|
665
|
+
log(`The fix was ${why} and cannot be resumed${Number(stopped?.refunded_cents) > 0 ? ', so the unit was given back' : ''}. The branch ${branch} holds whatever was written, and nothing was committed or pushed.`);
|
|
478
666
|
return EXIT.CANCELED;
|
|
479
667
|
}
|
|
480
668
|
|
|
@@ -484,10 +672,10 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
484
672
|
heartbeat.stop();
|
|
485
673
|
inFlight.delete(fixRun.run_id);
|
|
486
674
|
log(`Nothing was changed, so there is nothing to review. The branch ${branch} is empty; delete it or stay on it.`);
|
|
675
|
+
for (const entry of v2 ? verifyRuns : [verifyRun]) await skipRun({ api, gameId, jobId, run: entry, reason: 'nothing was changed', log });
|
|
487
676
|
await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'no_change' }, log });
|
|
488
|
-
await
|
|
489
|
-
|
|
490
|
-
log('The fix unit was refunded.');
|
|
677
|
+
const unchanged = await settle({ api, gameId, jobId, repoRoot, journal, reason: 'nothing was changed', log });
|
|
678
|
+
if (Number(unchanged?.refunded_cents) > 0) log('The fix unit was refunded.');
|
|
491
679
|
return EXIT.OK;
|
|
492
680
|
}
|
|
493
681
|
|
|
@@ -498,7 +686,69 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
498
686
|
|
|
499
687
|
// 4. Verify, by replaying the finding against the build.
|
|
500
688
|
let verified = null;
|
|
501
|
-
|
|
689
|
+
const verifies = [];
|
|
690
|
+
let repairs = 0;
|
|
691
|
+
// A script that does not parse or a project that does not import is not a
|
|
692
|
+
// change anyone can review, so it blocks the upload and the unit comes back
|
|
693
|
+
// (playtest_fix_check_failed), as it does for a feature. A failing project
|
|
694
|
+
// test ships, and the report says so first.
|
|
695
|
+
let checksBlock = false;
|
|
696
|
+
let usedVerifyRuns = 0;
|
|
697
|
+
if (v2) {
|
|
698
|
+
// Fix v2: checks after every apply turn set, verify once they pass, and
|
|
699
|
+
// at most MAX_REPAIRS repair turn sets started by a failed check or a
|
|
700
|
+
// verify that still reproduces. The contract never changes.
|
|
701
|
+
const verifyArgs = {
|
|
702
|
+
deps, api, client, gameId, jobId, repoRoot, pack, brief, finding, driverName,
|
|
703
|
+
target: buildTarget, options, journal,
|
|
704
|
+
maxActions: Math.min(VERIFY_MAX_ACTIONS, maxActions),
|
|
705
|
+
makeDriver: injected.driverFactory || createDriver,
|
|
706
|
+
signal: abort.signal,
|
|
707
|
+
acceptance: frozen,
|
|
708
|
+
sourceRunId,
|
|
709
|
+
...(injected.replayer ? { replayer: injected.replayer } : {}),
|
|
710
|
+
log
|
|
711
|
+
};
|
|
712
|
+
for (;;) {
|
|
713
|
+
const gate = await runGateChecks(checks, log);
|
|
714
|
+
checksBlock = gate.importFailed || gate.failed.some(result => result.name === 'godot_script_check');
|
|
715
|
+
const canRepair = repairs < MAX_REPAIRS && applied.stop !== 'budget' && !interrupt.signalled && !heartbeat.canceled;
|
|
716
|
+
let repairReason = null;
|
|
717
|
+
let repairDetail = '';
|
|
718
|
+
if (gate.failed.length > 0) {
|
|
719
|
+
repairReason = gate.failed.some(result => result.status === 'check_refused')
|
|
720
|
+
? 'the checks were refused because of what the change edited'
|
|
721
|
+
: `the ${gate.failed.map(result => result.name).join(' and ')} check${gate.failed.length > 1 ? 's' : ''} failed`;
|
|
722
|
+
repairDetail = gate.failed.map(checkResultText).join('\n\n');
|
|
723
|
+
} else if (wantsVerify && usedVerifyRuns < verifyRuns.length) {
|
|
724
|
+
verified = await runVerify({ ...verifyArgs, entry: verifyRuns[usedVerifyRuns] });
|
|
725
|
+
usedVerifyRuns += 1;
|
|
726
|
+
verifies.push(verified);
|
|
727
|
+
const score = verified && !verified.failed ? verified.reproduced : null;
|
|
728
|
+
// A repair after verify needs a fresh verify run to judge it; the
|
|
729
|
+
// server registers spares for Fix v2, and without one there is
|
|
730
|
+
// nothing to re-verify with, so no repair is spent.
|
|
731
|
+
if (typeof score === 'number' && score > REPRODUCED_ABOVE && usedVerifyRuns < verifyRuns.length) {
|
|
732
|
+
repairReason = `the verify run still reproduced the finding (${score})`;
|
|
733
|
+
repairDetail = `verify run ${verified.run_id}: ${verifyWords(verified)}. The acceptance contract is unchanged: ${frozen.actual} should become: ${frozen.expected}`;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (!repairReason || !canRepair) break;
|
|
737
|
+
repairs += 1;
|
|
738
|
+
log(`repair ${repairs} of ${MAX_REPAIRS}: ${repairReason}.`);
|
|
739
|
+
applied = await applyFix({
|
|
740
|
+
...applyArgs,
|
|
741
|
+
maxActions: Math.min(REPAIR_MAX_ACTIONS, maxActions),
|
|
742
|
+
acceptance: frozen,
|
|
743
|
+
repair: { attempt: repairs, of: MAX_REPAIRS, reason: repairReason, detail: repairDetail },
|
|
744
|
+
repo: withRunCheck(applyRepo, checks)
|
|
745
|
+
});
|
|
746
|
+
if (interrupt.signalled || heartbeat.canceled) break;
|
|
747
|
+
}
|
|
748
|
+
for (const spare of verifyRuns.slice(usedVerifyRuns)) {
|
|
749
|
+
await skipRun({ api, gameId, jobId, run: spare, reason: checksBlock ? 'the fix did not pass its checks' : (wantsVerify ? 'no repair needed it' : 'skipped with --no-verify'), log });
|
|
750
|
+
}
|
|
751
|
+
} else if (wantsVerify && verifyRun) {
|
|
502
752
|
verified = await runVerify({
|
|
503
753
|
deps,
|
|
504
754
|
api,
|
|
@@ -516,14 +766,61 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
516
766
|
journal,
|
|
517
767
|
maxActions: Math.min(VERIFY_MAX_ACTIONS, maxActions),
|
|
518
768
|
makeDriver: injected.driverFactory || createDriver,
|
|
769
|
+
signal: abort.signal,
|
|
519
770
|
log
|
|
520
771
|
});
|
|
521
772
|
} else {
|
|
522
773
|
await skipRun({ api, gameId, jobId, run: verifyRun, reason: wantsVerify ? 'no verify run was registered' : 'skipped with --no-verify', log });
|
|
523
774
|
}
|
|
775
|
+
if (interrupt.signalled || heartbeat.canceled) {
|
|
776
|
+
heartbeat.stop();
|
|
777
|
+
inFlight.delete(fixRun.run_id);
|
|
778
|
+
await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'error' }, log });
|
|
779
|
+
await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the fix was interrupted', log });
|
|
780
|
+
log(`The fix was interrupted and cannot be resumed, so the unit was given back. The branch ${branch} holds whatever was written.`);
|
|
781
|
+
return EXIT.CANCELED;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// A repair may have touched more files than the first apply did.
|
|
785
|
+
const finalChanged = v2 ? await git.diffStat() : changed;
|
|
786
|
+
const finalFiles = v2 ? finalChanged.slice(0, MAX_CHANGESET_FILES) : files;
|
|
787
|
+
|
|
788
|
+
if (v2 && checksBlock) {
|
|
789
|
+
// A fix the project cannot import or parse is not uploaded: there is no
|
|
790
|
+
// review it deserves. The evidence stays local for the developer.
|
|
791
|
+
heartbeat.stop();
|
|
792
|
+
inFlight.delete(fixRun.run_id);
|
|
793
|
+
const body = evidenceReport({
|
|
794
|
+
prBody: `# Fix ${finding.label || finding.finding_id}: not uploaded\n\nThis change does not pass its checks.`,
|
|
795
|
+
summarySource: 'local',
|
|
796
|
+
acceptance: frozen,
|
|
797
|
+
finding,
|
|
798
|
+
sourceRunId,
|
|
799
|
+
plan,
|
|
800
|
+
files: finalFiles,
|
|
801
|
+
checks: checks.results,
|
|
802
|
+
verifies,
|
|
803
|
+
repairs,
|
|
804
|
+
maxRepairs: MAX_REPAIRS,
|
|
805
|
+
wantsVerify,
|
|
806
|
+
thresholds: { fixedBelow: FIXED_BELOW, reproducedAbove: REPRODUCED_ABOVE }
|
|
807
|
+
});
|
|
808
|
+
await writeFile(path.join(directory, RUN_FILES.reportMd), body, 'utf8').catch(() => {});
|
|
809
|
+
const stillFailing = checksStillFailing(checks.results);
|
|
810
|
+
const refusedCheck = checks.results.some(result => stillFailing.includes(result.name) && result.status === 'check_refused');
|
|
811
|
+
const blockedBy = refusedCheck
|
|
812
|
+
? 'The checks were refused for this change'
|
|
813
|
+
: (stillFailing.includes('godot_import') ? 'The project does not import with this change' : 'This change does not pass its script check');
|
|
814
|
+
log(`${blockedBy} after ${repairs} repair(s), so the changeset was not uploaded. The evidence is in ${path.join(directory, RUN_FILES.reportMd)} and the branch ${branch} holds the edit.`);
|
|
815
|
+
// `check_failed` is the server's own word for this, so the ledger and the
|
|
816
|
+
// dashboard say the fix failed its checks rather than that a run crashed.
|
|
817
|
+
await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'check_failed' }, log });
|
|
818
|
+
await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the fix did not pass its checks', log });
|
|
819
|
+
return EXIT.FAILED;
|
|
820
|
+
}
|
|
524
821
|
|
|
525
822
|
// 5. The pull request text, from the diff that exists.
|
|
526
|
-
const fallback = localSummary({ finding, plan, files, verify: verified, branch, applied });
|
|
823
|
+
const fallback = localSummary({ finding, plan, files: finalFiles, verify: verified, branch, applied });
|
|
527
824
|
// Both budget stops mean the apply loop left off early rather than because
|
|
528
825
|
// it was finished, so both carry the caveat. Only the 402 skips the
|
|
529
826
|
// summarise call, because only the 402 means there is nothing left to
|
|
@@ -538,24 +835,48 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
538
835
|
system,
|
|
539
836
|
finding: findingForPrompt(finding),
|
|
540
837
|
plan,
|
|
541
|
-
files,
|
|
838
|
+
files: finalFiles,
|
|
542
839
|
verify: verified,
|
|
840
|
+
thresholds: { fixedBelow: FIXED_BELOW, reproducedAbove: REPRODUCED_ABOVE },
|
|
543
841
|
branch,
|
|
544
842
|
fallback,
|
|
545
843
|
ledger,
|
|
546
844
|
log
|
|
547
845
|
});
|
|
548
846
|
|
|
549
|
-
|
|
847
|
+
// Fix v2: the pull request body is the evidence report, which keeps what
|
|
848
|
+
// was measured apart from what a model wrote.
|
|
849
|
+
const prBody = v2
|
|
850
|
+
? withEvidenceIntact(description => evidenceReport({
|
|
851
|
+
prBody: description,
|
|
852
|
+
summarySource: summary.source,
|
|
853
|
+
acceptance: frozen,
|
|
854
|
+
finding,
|
|
855
|
+
sourceRunId,
|
|
856
|
+
plan,
|
|
857
|
+
files: finalFiles,
|
|
858
|
+
checks: checks.results,
|
|
859
|
+
verifies,
|
|
860
|
+
repairs,
|
|
861
|
+
maxRepairs: MAX_REPAIRS,
|
|
862
|
+
wantsVerify,
|
|
863
|
+
budgetTouched,
|
|
864
|
+
thresholds: { fixedBelow: FIXED_BELOW, reproducedAbove: REPRODUCED_ABOVE }
|
|
865
|
+
}), summary.prBody)
|
|
866
|
+
: summary.prBody;
|
|
867
|
+
|
|
868
|
+
const changeset = withoutQuotedSecrets({
|
|
550
869
|
schema_version: '1.0',
|
|
551
870
|
finding_id: finding.finding_id,
|
|
552
871
|
branch,
|
|
553
872
|
base_commit: checked.head,
|
|
554
|
-
files,
|
|
555
|
-
verify
|
|
873
|
+
files: finalFiles,
|
|
874
|
+
// A verify that never played the fix (its game did not boot, or the run
|
|
875
|
+
// died) is no verify at all: the changeset says unverified.
|
|
876
|
+
verify: verified && !verified.failed ? { run_id: verified.run_id, reproduced: verified.reproduced } : null,
|
|
556
877
|
pr_title: summary.prTitle,
|
|
557
|
-
pr_body:
|
|
558
|
-
};
|
|
878
|
+
pr_body: prBody
|
|
879
|
+
}, log);
|
|
559
880
|
|
|
560
881
|
await heartbeat.beat();
|
|
561
882
|
heartbeat.stop();
|
|
@@ -584,19 +905,34 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
584
905
|
// whole diagnosis: "pr_title too long" is actionable, "422" is not.
|
|
585
906
|
for (const detail of changesetDetails(error)) log(` ${detail}`);
|
|
586
907
|
}
|
|
587
|
-
await settle({ api, gameId, jobId, repoRoot, journal, reason: '', log });
|
|
588
|
-
|
|
589
|
-
|
|
908
|
+
const finished = await settle({ api, gameId, jobId, repoRoot, journal, reason: '', log });
|
|
909
|
+
|
|
910
|
+
if (verified && verified.failed === 'game_boot_failed') {
|
|
911
|
+
// The server refunds a fix whose verify run could not boot
|
|
912
|
+
// (playtest_fix_unverified_boot) only up to the account's limit, so this says
|
|
913
|
+
// what its answer says and never assumes.
|
|
914
|
+
const answer = await api.runs.get(gameId, jobId, fixRun.run_id).catch(() => null);
|
|
915
|
+
log(bootRefundLine({ finished, run: (answer && (answer.run || answer)) || null, priceCents: registered.price && registered.price.price_cents }));
|
|
916
|
+
}
|
|
917
|
+
let opened = null;
|
|
918
|
+
if (v2 && options.openPr) {
|
|
919
|
+
opened = await maybeOpenPr({ repoRoot, branch, changed: finalChanged, written: applyRepo.written, changeset, reportPath, verified, injected, deps, log });
|
|
920
|
+
if (opened.ok) await recordPrOpened({ api, gameId, repoRoot, journal, url: opened.url });
|
|
921
|
+
}
|
|
922
|
+
const stillFailing = v2 ? checksStillFailing(checks.results) : [];
|
|
923
|
+
if (stillFailing.length > 0) log(`checks failed on this change: ${stillFailing.join(', ')}. The verify run did not play it, and the unit is kept because a change was delivered. Read the evidence report before you use it.`);
|
|
924
|
+
if (!opened || !opened.ok) report({ changeset, directory, reportPath, verified, options, log, injected, deps, budget: budgetTouched });
|
|
590
925
|
// Exit 2 is "the money ran out", which a warning is not: a warning means
|
|
591
926
|
// the run wrapped up early with budget still in hand.
|
|
592
|
-
|
|
927
|
+
if (applied.stop === 'budget') return EXIT.BUDGET;
|
|
928
|
+
return opened && !opened.ok ? EXIT.ENVIRONMENT : EXIT.OK;
|
|
593
929
|
} catch (error) {
|
|
594
930
|
heartbeat.stop();
|
|
595
931
|
const code = exitCodeFor(error);
|
|
596
932
|
log(`fix failed: ${String(error && error.message)}`);
|
|
597
933
|
log(`The branch ${branch} still holds whatever was written. Nothing was committed or pushed.`);
|
|
598
|
-
await failFixRun({ api, gameId, jobId, runId: fixRun.run_id, log });
|
|
599
934
|
await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'the fix failed', log });
|
|
935
|
+
await failFixRun({ api, gameId, jobId, runId: fixRun.run_id, log });
|
|
600
936
|
await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the fix failed', log });
|
|
601
937
|
return code;
|
|
602
938
|
} finally {
|
|
@@ -612,6 +948,56 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
612
948
|
* @param {{jobId: string|null, label: string|null, findingId: string|null}} target
|
|
613
949
|
* @returns {Promise<Object|null>}
|
|
614
950
|
*/
|
|
951
|
+
/** The list item sources whose ref is a finding a fix can replay. */
|
|
952
|
+
export const FIXABLE_SOURCES = Object.freeze(['finding', 'fresh_eyes']);
|
|
953
|
+
|
|
954
|
+
/** Whether a fix target names a Labs list item rather than a finding. */
|
|
955
|
+
export function isListItemTarget(target) {
|
|
956
|
+
return String(target || '').trim().startsWith('li_');
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Find a list item and check it came from a finding. Only a finding has the
|
|
961
|
+
* persona, the repro steps and the replay a fix needs; anything else is new
|
|
962
|
+
* work, which is what `feature` builds.
|
|
963
|
+
*
|
|
964
|
+
* @param {Object} api
|
|
965
|
+
* @param {string} gameId
|
|
966
|
+
* @param {string} itemId
|
|
967
|
+
* @returns {Promise<{item: Object, list: string}|{refusal: string}>}
|
|
968
|
+
*/
|
|
969
|
+
export async function resolveListItemTarget(api, gameId, itemId) {
|
|
970
|
+
if (!api.labs || !api.labs.lists) {
|
|
971
|
+
return { refusal: 'This server has no punch list. Fix the finding by its id instead.' };
|
|
972
|
+
}
|
|
973
|
+
const found = await findListItem(api, gameId, itemId);
|
|
974
|
+
if (!found) {
|
|
975
|
+
return { refusal: `No item ${itemId} is on this game's punch list or blue tape. Run "ravensight-playtest punch list --status all" to see the ids.` };
|
|
976
|
+
}
|
|
977
|
+
const source = found.item.source || {};
|
|
978
|
+
// A fresh-eyes item's ref is a finding's targetId too.
|
|
979
|
+
if (!FIXABLE_SOURCES.includes(source.kind) || !source.ref) {
|
|
980
|
+
return {
|
|
981
|
+
refusal: `${itemId} did not come from a finding, so fix has nothing to replay. Use "ravensight-playtest feature" for that item. Nothing was charged.`
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
return found;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Record the fix job on the list item. Best effort: the job is already paid
|
|
989
|
+
* for, and a list that cannot be written must not stop the fix.
|
|
990
|
+
*/
|
|
991
|
+
async function recordFixJob({ api, gameId, listItem, jobId, link, log }) {
|
|
992
|
+
const itemId = listItem.item.item_id;
|
|
993
|
+
try {
|
|
994
|
+
await api.labs.lists.update(gameId, listItem.list, itemId, { fix_job_id: jobId });
|
|
995
|
+
log(`Recorded job ${jobId} on ${itemId}: ${link}. The item stays open until you close it.`);
|
|
996
|
+
} catch (error) {
|
|
997
|
+
log(`warning: could not record job ${jobId} on ${itemId} (${error && error.message ? error.message : error}). The fix carries on.`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
615
1001
|
export async function resolveFinding(api, gameId, target) {
|
|
616
1002
|
const answer = await api.jobs.findings(gameId, target.jobId ? { job: target.jobId } : {});
|
|
617
1003
|
const list = (answer && answer.findings) || [];
|
|
@@ -763,17 +1149,34 @@ export async function runVerify({
|
|
|
763
1149
|
journal,
|
|
764
1150
|
maxActions,
|
|
765
1151
|
makeDriver,
|
|
1152
|
+
acceptance = null,
|
|
1153
|
+
sourceRunId = null,
|
|
1154
|
+
replayer = replayTrace,
|
|
1155
|
+
signal,
|
|
766
1156
|
log
|
|
767
1157
|
}) {
|
|
768
1158
|
const directory = runDirFor(repoRoot, jobId, entry.run_id);
|
|
769
1159
|
await mkdir(directory, { recursive: true });
|
|
1160
|
+
|
|
1161
|
+
// FIX V2 REPLAYS THE SOURCE RUN FIRST. It costs no model call, and it can say
|
|
1162
|
+
// one thing for certain: if the game answers identically at every recorded step,
|
|
1163
|
+
// the fix changed nothing on the path that found the problem. It cannot say the
|
|
1164
|
+
// problem is gone, because after a divergence the recorded inputs no longer mean
|
|
1165
|
+
// anything, so a divergence goes to the persona like a run with no trace at all.
|
|
1166
|
+
let divergence = null;
|
|
1167
|
+
if (acceptance && driverName === 'godot_driver') {
|
|
1168
|
+
const replayed = await replaySourceTrace({ api, gameId, jobId, repoRoot, finding, target, godotPath: options.godotPath, directory, sourceRunId, entry, log, replayer });
|
|
1169
|
+
if (replayed.unchanged) return closeVerifyOnUnchanged({ deps, api, gameId, jobId, repoRoot, entry, journal, directory, log, report: replayed.report });
|
|
1170
|
+
divergence = replayed.divergence;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
770
1173
|
const persona = (pack.personas || []).find(candidate => candidate.slug === entry.persona) || (pack.personas || [])[0];
|
|
771
1174
|
if (!persona) {
|
|
772
1175
|
log('The content pack has no persona to verify with, so the fix is reported unverified.');
|
|
773
1176
|
return null;
|
|
774
1177
|
}
|
|
775
1178
|
const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
|
|
776
|
-
const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId }));
|
|
1179
|
+
const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId, godotPath: options.godotPath, realtime: options.realtime }));
|
|
777
1180
|
const ledger = createUsageLedger();
|
|
778
1181
|
const heartbeat = createHeartbeat({
|
|
779
1182
|
api,
|
|
@@ -810,13 +1213,17 @@ export async function runVerify({
|
|
|
810
1213
|
runDirectory: directory,
|
|
811
1214
|
skill: skillFor(pack, 'persona_playtest'),
|
|
812
1215
|
// The brief fetched above, as `run` does: the pack does not always carry one.
|
|
813
|
-
|
|
1216
|
+
// Fix v2 builds the verify brief from the frozen contract alone.
|
|
1217
|
+
brief: withDivergence(acceptance
|
|
1218
|
+
? verifyBriefFromAcceptance(brief || pack.brief || null, acceptance)
|
|
1219
|
+
: verifyBrief(brief || pack.brief || null, finding), divergence),
|
|
814
1220
|
localBrief,
|
|
815
1221
|
intentNotes: pack.intent_notes || [],
|
|
816
1222
|
schemas: pack.schemas || [],
|
|
817
1223
|
maxActions,
|
|
818
1224
|
heartbeat,
|
|
819
1225
|
ledger,
|
|
1226
|
+
signal,
|
|
820
1227
|
log: message => log(`verify: ${message}`)
|
|
821
1228
|
});
|
|
822
1229
|
} catch (error) {
|
|
@@ -825,7 +1232,8 @@ export async function runVerify({
|
|
|
825
1232
|
await stateFile.patchRun(repoRoot, journal, entry.run_id, { state: failed.state, error: String(error && error.message), quit_reason: failed.quitReason });
|
|
826
1233
|
await deliver({ deps, api, gameId, jobId, runId: entry.run_id, directory, outcome: failed, options, log, journal, repoRoot });
|
|
827
1234
|
log(`the verify run failed (${String(error && error.message)}), so the fix is reported unverified.`);
|
|
828
|
-
|
|
1235
|
+
const booted = !(error && (error.code === 'game_boot_failed' || /^game_boot_failed: /.test(String(error.message))));
|
|
1236
|
+
return { run_id: entry.run_id, reproduced: null, source: null, failed: booted ? 'verify_failed' : 'game_boot_failed', detail: String(error && error.message).replace(/^game_boot_failed: /, '').slice(0, 200) };
|
|
829
1237
|
}
|
|
830
1238
|
heartbeat.stop();
|
|
831
1239
|
|
|
@@ -848,10 +1256,100 @@ export async function runVerify({
|
|
|
848
1256
|
log(`the verify verdict could not be read (${String(error && error.message)}).`);
|
|
849
1257
|
}
|
|
850
1258
|
const verdict = (run && run.verify_verdict) || null;
|
|
851
|
-
|
|
1259
|
+
// Strictly a number. `Number(null)` is 0, and the server answers
|
|
1260
|
+
// `{ reproduced: null, source: 'unavailable' }` when nobody could judge, so a
|
|
1261
|
+
// coercion here reads a judge being down as "fixed (reproduced 0)".
|
|
1262
|
+
const reproduced = verdict && typeof verdict.reproduced === 'number' && Number.isFinite(verdict.reproduced) ? verdict.reproduced : null;
|
|
852
1263
|
return { run_id: entry.run_id, reproduced, source: (verdict && verdict.source) || null };
|
|
853
1264
|
}
|
|
854
1265
|
|
|
1266
|
+
/**
|
|
1267
|
+
* Replay the source run's trace against the fix, on this working tree. Never
|
|
1268
|
+
* fails the verify: anything short of a full match, including no trace at all,
|
|
1269
|
+
* answers "not unchanged" and the persona decides. The outcome is recorded on the
|
|
1270
|
+
* job, free, when the server takes it.
|
|
1271
|
+
*
|
|
1272
|
+
* @returns {Promise<{unchanged: boolean, divergence: Object|null}>}
|
|
1273
|
+
*/
|
|
1274
|
+
export async function replaySourceTrace({ api, gameId, jobId, repoRoot, finding, target, godotPath, directory, sourceRunId, entry, log, replayer = replayTrace }) {
|
|
1275
|
+
const runId = sourceRunId || finding.run_id || null;
|
|
1276
|
+
if (!runId) return { unchanged: false, divergence: null };
|
|
1277
|
+
const trace = await loadTrace({ repoRoot, runId, jobId: finding.job_id || null, api, gameId });
|
|
1278
|
+
if (!trace) return { unchanged: false, divergence: null };
|
|
1279
|
+
let result;
|
|
1280
|
+
try {
|
|
1281
|
+
result = await replayer({ text: trace.text, projectDir: target, godotPath, runDir: path.join(directory, 'trace-replay') });
|
|
1282
|
+
} catch (error) {
|
|
1283
|
+
log(`verify: the source run's trace could not be replayed (${String(error && error.message)}). A persona replays the finding instead.`);
|
|
1284
|
+
return { unchanged: false, divergence: null };
|
|
1285
|
+
}
|
|
1286
|
+
log(`verify: ${replaySentence(result)}`);
|
|
1287
|
+
const sha = await headSha(repoRoot);
|
|
1288
|
+
const report = sha ? replayReport(result, { ref: 'fix-branch', sha }) : null;
|
|
1289
|
+
if (report && entry) {
|
|
1290
|
+
try {
|
|
1291
|
+
await api.runs.recordReplay(gameId, jobId, entry.run_id, report);
|
|
1292
|
+
} catch {
|
|
1293
|
+
// A record is for reading later. The verify does not depend on it.
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
// The server decides a verify from this whole report, so a replay that cannot
|
|
1297
|
+
// name its commit cannot close one: a persona replays the finding instead.
|
|
1298
|
+
// The same test as the server's `replayProvesIdentical`: a replay that compared
|
|
1299
|
+
// nothing, or ran no ticks, matched only because it did nothing.
|
|
1300
|
+
const proven = result.matched === true && result.fully_replayed === true && !result.stopped && !result.first_divergence && result.steps_compared > 0 && result.ticks > 0;
|
|
1301
|
+
return { unchanged: Boolean(report) && proven, divergence: result.first_divergence || null, report };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
async function headSha(repoRoot) {
|
|
1305
|
+
try {
|
|
1306
|
+
const { stdout } = await execFileAsync('git', ['-C', repoRoot, 'rev-parse', 'HEAD']);
|
|
1307
|
+
return stdout.trim();
|
|
1308
|
+
} catch {
|
|
1309
|
+
return null;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* The trace matched everywhere, so the verify run is finished without a model.
|
|
1315
|
+
* The verdict comes back from the server's own record of this complete, the same
|
|
1316
|
+
* as a persona verify's, and it says the finding is still there.
|
|
1317
|
+
*/
|
|
1318
|
+
export async function closeVerifyOnUnchanged({ deps, api, gameId, jobId, repoRoot, entry, journal, directory, log, report }) {
|
|
1319
|
+
log('verify: every recorded step answered the same, so this fix changed nothing on the path that found the problem. No model was called.');
|
|
1320
|
+
for (const state of ['launching', 'playing', 'reporting', 'uploading']) {
|
|
1321
|
+
await api.runs.transition(gameId, jobId, entry.run_id, state, { reason: '' });
|
|
1322
|
+
}
|
|
1323
|
+
await deps.finalizeRun(directory, {
|
|
1324
|
+
api,
|
|
1325
|
+
gameId,
|
|
1326
|
+
jobId,
|
|
1327
|
+
runId: entry.run_id,
|
|
1328
|
+
body: { state: 'succeeded', actions_taken: 0, quit_reason: 'goal_reached', trace_replay: report }
|
|
1329
|
+
});
|
|
1330
|
+
await stateFile.patchRun(repoRoot, journal, entry.run_id, { state: 'succeeded', completed: true, uploaded: true });
|
|
1331
|
+
const answer = await api.runs.get(gameId, jobId, entry.run_id);
|
|
1332
|
+
const run = (answer && (answer.run || answer)) || null;
|
|
1333
|
+
const verdict = (run && run.verify_verdict) || null;
|
|
1334
|
+
// The same reading as a persona verify's: `Number(null)` is 0, which would read a
|
|
1335
|
+
// judge that could not answer as "fixed (reproduced 0)".
|
|
1336
|
+
const reproduced = verdict && typeof verdict.reproduced === 'number' && Number.isFinite(verdict.reproduced) ? verdict.reproduced : null;
|
|
1337
|
+
return { run_id: entry.run_id, reproduced, source: (verdict && verdict.source) || 'trace_replay' };
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/** Tell the verify persona where the fix first moved the game, as a starting point. */
|
|
1341
|
+
export function withDivergence(brief, divergence) {
|
|
1342
|
+
if (!divergence || !brief) return brief;
|
|
1343
|
+
const diff = Object.entries(divergence.game_state_diff || {}).slice(0, 5).map(([key, pair]) => `${key}: ${JSON.stringify(pair.expected)} became ${JSON.stringify(pair.actual)}`);
|
|
1344
|
+
return {
|
|
1345
|
+
...brief,
|
|
1346
|
+
fields: {
|
|
1347
|
+
...(brief.fields || {}),
|
|
1348
|
+
verify_trace_note: `Replaying the original run against this fix first differed at tick ${divergence.frame ?? 'unknown'}${diff.length ? ` (${diff.join('; ')})` : ''}. That shows the fix changed behaviour there, not that the problem is gone. Check for yourself.`
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
|
|
855
1353
|
/**
|
|
856
1354
|
* The verify run's brief: what it is being asked to reproduce, then the brief
|
|
857
1355
|
* a persona usually plays against. The task comes first because it is the one
|
|
@@ -942,11 +1440,13 @@ function report({ changeset, reportPath, verified, options, log, injected, deps,
|
|
|
942
1440
|
log(`verify: ${verifyWords(verified)}`);
|
|
943
1441
|
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.');
|
|
944
1442
|
log('Nothing was committed and nothing was pushed. Read the diff, commit it, then:');
|
|
945
|
-
log(` gh pr create --title
|
|
1443
|
+
log(` gh pr create --title ${shellQuote(changeset.pr_title)} --body-file ${shellQuote(reportPath)}`);
|
|
946
1444
|
}
|
|
947
1445
|
|
|
948
1446
|
/**
|
|
949
|
-
* Complete the fix run, tolerating a refusal.
|
|
1447
|
+
* Complete the fix run, tolerating a refusal. Every caller completes or skips
|
|
1448
|
+
* the verify run(s) first: the server reads the verify run's record when the
|
|
1449
|
+
* fix run completes (a verify that could not boot refunds the unit). The money is settled by
|
|
950
1450
|
* `finish` either way, and a refused complete must not turn a fix that worked
|
|
951
1451
|
* into a crash.
|
|
952
1452
|
*/
|
|
@@ -977,11 +1477,13 @@ async function skipRun({ api, gameId, jobId, run, reason, log }) {
|
|
|
977
1477
|
|
|
978
1478
|
/** `finish` settles the job once and issues at most one refund. */
|
|
979
1479
|
async function settle({ api, gameId, jobId, repoRoot, journal, reason, log }) {
|
|
1480
|
+
let finished = null;
|
|
980
1481
|
try {
|
|
981
|
-
|
|
1482
|
+
finished = await api.jobs.finish(gameId, jobId, { reason });
|
|
982
1483
|
log(`job ${jobId} ${finished.job ? finished.job.state : 'finished'}: refunded ${formatCents(finished.refunded_cents)}.`);
|
|
1484
|
+
if (finished.refund_limited_note) log(finished.refund_limited_note);
|
|
983
1485
|
} catch (error) {
|
|
984
|
-
log(
|
|
1486
|
+
log(settleFailureLine(jobId, error));
|
|
985
1487
|
}
|
|
986
1488
|
journal.finished = true;
|
|
987
1489
|
try {
|
|
@@ -990,6 +1492,7 @@ async function settle({ api, gameId, jobId, repoRoot, journal, reason, log }) {
|
|
|
990
1492
|
// The journal is a convenience at this point: the server's ledger is the
|
|
991
1493
|
// record of what was charged and what came back.
|
|
992
1494
|
}
|
|
1495
|
+
return finished;
|
|
993
1496
|
}
|
|
994
1497
|
|
|
995
1498
|
/** The finding as the prompt sees it: no ids nobody can act on, no noise. */
|
|
@@ -1061,6 +1564,80 @@ export function changesetDetails(error) {
|
|
|
1061
1564
|
.filter(Boolean);
|
|
1062
1565
|
}
|
|
1063
1566
|
|
|
1567
|
+
/**
|
|
1568
|
+
* The Godot project the checks copy: the verify target for the Godot driver,
|
|
1569
|
+
* else the repository when it is a Godot project, else none.
|
|
1570
|
+
*
|
|
1571
|
+
* @returns {Promise<string|null>}
|
|
1572
|
+
*/
|
|
1573
|
+
export async function checkProjectDir({ driverName, buildTarget, repoRoot }) {
|
|
1574
|
+
if (driverName === 'godot_driver' && buildTarget) return buildTarget;
|
|
1575
|
+
return (await isGodotProject(repoRoot)) ? repoRoot : null;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* Repository relative paths as paths inside the Godot project, dropping any
|
|
1580
|
+
* outside it.
|
|
1581
|
+
*/
|
|
1582
|
+
export function projectRelative(changed, repoRoot, projectDir) {
|
|
1583
|
+
if (!projectDir) return [];
|
|
1584
|
+
return changed
|
|
1585
|
+
.map(file => path.relative(projectDir, path.join(repoRoot, file.path)))
|
|
1586
|
+
.filter(file => file && !file.startsWith('..') && !path.isAbsolute(file));
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* Every gate check, in order. `godot_import` failing is the one that blocks an
|
|
1591
|
+
* upload; the others start a repair and, if they still fail, are listed as
|
|
1592
|
+
* remaining risks.
|
|
1593
|
+
*
|
|
1594
|
+
* @returns {Promise<{failed: Array<Object>, importFailed: boolean}>}
|
|
1595
|
+
*/
|
|
1596
|
+
export async function runGateChecks(checks, log = () => {}) {
|
|
1597
|
+
const failed = [];
|
|
1598
|
+
let importFailed = false;
|
|
1599
|
+
for (const name of GATE_CHECKS) {
|
|
1600
|
+
// A refusal names the edits, not the check, so the rest would only repeat it.
|
|
1601
|
+
if (failed.some(result => result.status === 'check_refused')) break;
|
|
1602
|
+
// After a failed import the tests cannot mean anything, but the script
|
|
1603
|
+
// check still can: the import names the scripts that could not load
|
|
1604
|
+
// because of a broken one, the script check names the broken line.
|
|
1605
|
+
if (importFailed && name === 'project_tests') continue;
|
|
1606
|
+
const result = await checks.run(name);
|
|
1607
|
+
log(`check ${name}: ${result.status} (${result.detail})`);
|
|
1608
|
+
if (result.status === 'failed' || result.status === 'check_refused') {
|
|
1609
|
+
failed.push(result);
|
|
1610
|
+
// A refused check blocks the upload exactly as a failed import does:
|
|
1611
|
+
// nothing proved the change imports.
|
|
1612
|
+
if (name === 'godot_import' || result.status === 'check_refused') importFailed = true;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
return { failed, importFailed };
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* `--open-pr` after the fix is uploaded: only for a fix the verify scored as
|
|
1620
|
+
* fixed, and only with the developer's own yes.
|
|
1621
|
+
*/
|
|
1622
|
+
export async function maybeOpenPr({ repoRoot, branch, changed, written = null, changeset, reportPath, verified, injected, deps, log }) {
|
|
1623
|
+
if (!verifyWords(verified).startsWith('fixed')) {
|
|
1624
|
+
log(`--open-pr opens a pull request only for a verified fix, and this one reads ${verifyWords(verified)}. Nothing was pushed.`);
|
|
1625
|
+
return { ok: false, reason: 'not_verified' };
|
|
1626
|
+
}
|
|
1627
|
+
const opener = injected.openPullRequest || openPullRequest;
|
|
1628
|
+
return opener({
|
|
1629
|
+
repoRoot,
|
|
1630
|
+
branch,
|
|
1631
|
+
// What the fix wrote, not whatever else is in the tree by now: a run can leave
|
|
1632
|
+
// files behind that are not the change.
|
|
1633
|
+
paths: changed.map(file => file.path).filter(file => !written || written.includes(file)),
|
|
1634
|
+
title: changeset.pr_title,
|
|
1635
|
+
bodyFile: reportPath,
|
|
1636
|
+
confirm: injected.confirmPr || injected.confirm || (deps.ui && deps.ui.confirm),
|
|
1637
|
+
log
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1064
1641
|
function isImageName(name) {
|
|
1065
1642
|
return /\.(png|jpe?g|webp)$/i.test(String(name));
|
|
1066
1643
|
}
|