ravensight-playtest 0.5.2 → 0.6.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 +249 -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 +92 -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 +165 -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 +578 -68
- package/src/commands/init.js +19 -3
- package/src/commands/profile.js +25 -6
- package/src/commands/replay.js +204 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/run.js +200 -24
- 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,18 +8,30 @@ 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';
|
|
20
32
|
|
|
33
|
+
const execFileAsync = promisify(execFile);
|
|
34
|
+
|
|
21
35
|
/**
|
|
22
36
|
* `ravensight-playtest fix <target>`: take one finding, change the code on a
|
|
23
37
|
* new branch, replay the finding to see whether the change worked, and hand
|
|
@@ -25,10 +39,12 @@ import { loadLocalBrief } from '../briefLocal.js';
|
|
|
25
39
|
*
|
|
26
40
|
* Three rules shape everything else in this file:
|
|
27
41
|
*
|
|
28
|
-
* 1. **It edits, it never publishes.**
|
|
42
|
+
* 1. **It edits, it never publishes on its own.** Git usage lives in
|
|
29
43
|
* `src/run/git.js`, which has no commit, no push and no way to reach a
|
|
30
44
|
* 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.
|
|
45
|
+
* is the developer's decision, made with the diff in front of them. The
|
|
46
|
+
* one exception is Fix v2's `--open-pr` (`src/run/openPr.js`), which still
|
|
47
|
+
* asks at its own prompt after a verified fix and never forces a push.
|
|
32
48
|
* 2. **It buys the answer "no" as cheaply as possible.** The triage call comes
|
|
33
49
|
* before the first edit, because a finding that is design intent should
|
|
34
50
|
* cost a refunded unit rather than a branch full of changes nobody wanted.
|
|
@@ -65,6 +81,18 @@ export const REPRODUCED_ABOVE = 0.65;
|
|
|
65
81
|
/** A changeset carries at most this many files. */
|
|
66
82
|
export const MAX_CHANGESET_FILES = 20;
|
|
67
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Fix v2: at most this many repair turn sets after the first apply, each
|
|
86
|
+
* started by a failed check or a verify that still reproduces the finding.
|
|
87
|
+
*/
|
|
88
|
+
export const MAX_REPAIRS = 2;
|
|
89
|
+
|
|
90
|
+
/** A repair turn set's own action ceiling, inside the same run budget. */
|
|
91
|
+
export const REPAIR_MAX_ACTIONS = 20;
|
|
92
|
+
|
|
93
|
+
/** The checks run after every apply turn set, in this order. */
|
|
94
|
+
export const GATE_CHECKS = Object.freeze(['godot_import', 'godot_script_check', 'project_tests']);
|
|
95
|
+
|
|
68
96
|
/**
|
|
69
97
|
* @param {string} target `<jobId>/<label>` or a finding id
|
|
70
98
|
* @param {Object} flags what commander hands over
|
|
@@ -96,6 +124,9 @@ export async function fixCommand(target, flags = {}, injected = {}) {
|
|
|
96
124
|
export function parseTarget(target) {
|
|
97
125
|
const value = String(target || '').trim();
|
|
98
126
|
if (!value) return null;
|
|
127
|
+
// A finding's id is `<runId>/<label>`, so a run id on the left means the whole
|
|
128
|
+
// value names the finding, not a label inside a job.
|
|
129
|
+
if (value.startsWith('pt_run_')) return { jobId: null, label: null, findingId: value };
|
|
99
130
|
const slash = value.lastIndexOf('/');
|
|
100
131
|
if (slash > 0 && slash < value.length - 1) {
|
|
101
132
|
return { jobId: value.slice(0, slash), label: value.slice(slash + 1), findingId: null };
|
|
@@ -129,6 +160,24 @@ export function hostedTargetRefusal(driverName, target) {
|
|
|
129
160
|
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
161
|
}
|
|
131
162
|
|
|
163
|
+
/**
|
|
164
|
+
* What became of a fix's unit when its verify could not boot the game, from the
|
|
165
|
+
* server's own answers: the unit comes back up to a limit per account.
|
|
166
|
+
*
|
|
167
|
+
* @param {{finished: Object|null, run: Object|null, priceCents: number}} input
|
|
168
|
+
* @returns {string}
|
|
169
|
+
*/
|
|
170
|
+
export function bootRefundLine({ finished, run, priceCents }) {
|
|
171
|
+
const start = 'The game did not boot for the verify run';
|
|
172
|
+
if (run && run.changeset && run.changeset.boot_refund_limited) {
|
|
173
|
+
return `${start}, and this account has used its refunds for that in the last 30 days, so the ${formatCents(priceCents)} is kept.`;
|
|
174
|
+
}
|
|
175
|
+
const cents = finished ? Number(finished.refunded_cents) || 0 : 0;
|
|
176
|
+
if (cents > 0) return `${start}, so the ${formatCents(cents)} comes back.`;
|
|
177
|
+
if (!finished) return `${start}. The job page says what came back once it settles.`;
|
|
178
|
+
return `${start}, and nothing came back of the ${formatCents(priceCents)} charged.`;
|
|
179
|
+
}
|
|
180
|
+
|
|
132
181
|
/**
|
|
133
182
|
* The verdict in the words a developer reads.
|
|
134
183
|
*
|
|
@@ -137,6 +186,10 @@ export function hostedTargetRefusal(driverName, target) {
|
|
|
137
186
|
*/
|
|
138
187
|
export function verifyWords(verify) {
|
|
139
188
|
if (!verify) return 'not verified';
|
|
189
|
+
// A verify run whose game never booted played a black screen: whatever the
|
|
190
|
+
// replay's report says, it says nothing about the fix.
|
|
191
|
+
if (verify.failed === 'game_boot_failed') return `not verified, the game did not boot (${verify.detail || 'see godot.log in the verify run directory'})`;
|
|
192
|
+
if (verify.failed) return `not verified, the verify run failed (${verify.detail || verify.failed})`;
|
|
140
193
|
const score = verify.reproduced;
|
|
141
194
|
if (typeof score !== 'number' || !Number.isFinite(score)) return 'unknown, the verify run reached no verdict';
|
|
142
195
|
if (score < FIXED_BELOW) return `fixed (reproduced ${score})`;
|
|
@@ -171,7 +224,7 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
171
224
|
|
|
172
225
|
const wantsVerify = options.verify !== false;
|
|
173
226
|
const driverName = options.driver || (context.config && context.config.driver) || 'playwright_web';
|
|
174
|
-
const buildTarget = resolveTarget(options, context.config, driverName);
|
|
227
|
+
const buildTarget = resolveTarget(options, context.config, driverName, repoRoot);
|
|
175
228
|
|
|
176
229
|
// Said before anything is charged, because it is the difference between a
|
|
177
230
|
// verify that means something and one that does not.
|
|
@@ -185,6 +238,11 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
185
238
|
log(refusal);
|
|
186
239
|
return EXIT.ENVIRONMENT;
|
|
187
240
|
}
|
|
241
|
+
const noGodot = await godotRefusal({ driverName, godotPath: options.godotPath, projectDir: buildTarget, injected });
|
|
242
|
+
if (noGodot) {
|
|
243
|
+
log(noGodot);
|
|
244
|
+
return EXIT.ENVIRONMENT;
|
|
245
|
+
}
|
|
188
246
|
if (driverName === 'playwright_web') {
|
|
189
247
|
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
248
|
}
|
|
@@ -218,16 +276,29 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
218
276
|
// repro steps and the persona's words are written in full.
|
|
219
277
|
const finding = enrichFinding(listed, evidence.reportJson);
|
|
220
278
|
|
|
221
|
-
const
|
|
279
|
+
const pendingScope = { kind: 'fix', gameId, target: finding.finding_id };
|
|
280
|
+
const pendingRequest = options.idempotencyKey ? null : await pendingRegister.load(repoRoot, pendingScope);
|
|
281
|
+
const branch = pendingRequest ? pendingRequest.branch : options.branch || fixBranchName(finding.label || finding.finding_id);
|
|
222
282
|
const git = injected.git || createGit({ root: repoRoot });
|
|
223
283
|
// Every refusal now, the one mutation after the price is agreed: a developer
|
|
224
284
|
// 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 });
|
|
285
|
+
const checked = await preflight({ git, branch, allowDirty: Boolean(options.allowDirty), create: false, reuseBranch: Boolean(pendingRequest) });
|
|
286
|
+
if (pendingRequest) {
|
|
287
|
+
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.`);
|
|
288
|
+
checked.from = pendingRequest.from;
|
|
289
|
+
}
|
|
226
290
|
if (!checked.ok) {
|
|
227
291
|
log(checked.reason);
|
|
228
292
|
return EXIT.ENVIRONMENT;
|
|
229
293
|
}
|
|
230
294
|
|
|
295
|
+
// --open-pr commits what the fix changed, and a dirty tree makes that a mixture
|
|
296
|
+
// of two people's work. Refused now, because nothing later can tell them apart.
|
|
297
|
+
if (options.openPr && checked.dirty) {
|
|
298
|
+
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.');
|
|
299
|
+
return EXIT.ENVIRONMENT;
|
|
300
|
+
}
|
|
301
|
+
|
|
231
302
|
// A fix job asks for exactly one persona, the one who filed the finding: the
|
|
232
303
|
// server creates the verify run from it, and both zero and two are a 400.
|
|
233
304
|
const personaSlug = finding.persona || ((pack.personas || [])[0] || {}).slug || null;
|
|
@@ -235,7 +306,33 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
235
306
|
log('That finding names no persona and the content pack has none, so there is nobody to verify the fix with.');
|
|
236
307
|
return EXIT.ENVIRONMENT;
|
|
237
308
|
}
|
|
238
|
-
|
|
309
|
+
// Fix v2 (Labs beta) only when the server says it is live for this game.
|
|
310
|
+
// Anything else, including a server that has never heard of Labs, is the standard fix.
|
|
311
|
+
const labs = !options.noLabs && (await fixV2Live(api, gameId, {
|
|
312
|
+
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.')
|
|
313
|
+
}));
|
|
314
|
+
const acceptance = labs ? buildAcceptance(finding, { persona: personaSlug, reproSteps: reproStepsOf(finding) }) : null;
|
|
315
|
+
if (acceptance) {
|
|
316
|
+
log('Fix v2 (Labs beta) is on for this game: named checks, up to two repairs, and an evidence report.');
|
|
317
|
+
for (const line of acceptanceLines(acceptance)) log(line);
|
|
318
|
+
}
|
|
319
|
+
// Tests are the developer's code run with the model's edits in place, so
|
|
320
|
+
// they run only with --allow-tests or a yes at a terminal, asked now,
|
|
321
|
+
// before anything is charged.
|
|
322
|
+
let allowTests = Boolean(options.allowTests);
|
|
323
|
+
if (labs && !allowTests) {
|
|
324
|
+
const projectDir = await checkProjectDir({ driverName, buildTarget, repoRoot });
|
|
325
|
+
const runner = projectDir ? await detectTestRunner(projectDir) : null;
|
|
326
|
+
const askTests = injected.confirmTests || (process.stdin.isTTY && deps.ui && deps.ui.confirm);
|
|
327
|
+
if (runner && askTests) {
|
|
328
|
+
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 }));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (options.openPr && !labs) {
|
|
332
|
+
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.');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const estimate = await api.jobs.estimate(gameId, { modules: [...FIX_MODULES], personas: [personaSlug], ...(acceptance ? { acceptance: true } : {}) });
|
|
239
336
|
const priceCents = Number.isFinite(Number(estimate.estimate_cents)) ? Number(estimate.estimate_cents) : Number(estimate.price_cents);
|
|
240
337
|
if (!Number.isFinite(priceCents)) {
|
|
241
338
|
// Sending `confirm_price_cents: undefined` would be refused as
|
|
@@ -244,8 +341,15 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
244
341
|
log('The server quoted no price for this fix, so nothing was charged. Run "ravensight-playtest check" and try again.');
|
|
245
342
|
return EXIT.ENVIRONMENT;
|
|
246
343
|
}
|
|
247
|
-
const offer = `${formatCents(priceCents)} fix attempt (includes one verify run). Balance: ${formatCents(estimate.balance_cents)}
|
|
344
|
+
const offer = `${formatCents(priceCents)} fix attempt (includes one verify run). Balance: ${formatCents(estimate.balance_cents)}.`
|
|
345
|
+
+ (labs ? ' The checks and the verify run execute the edited project on this machine with a reduced environment.' : '');
|
|
248
346
|
log(offer);
|
|
347
|
+
const floorLine = startBalanceLine(estimate);
|
|
348
|
+
if (floorLine) log(floorLine);
|
|
349
|
+
if (estimate.ok === false && floorLine) {
|
|
350
|
+
log(`${startBalanceShort(estimate)} An ordinary fix, with --no-labs, starts on its price alone.`);
|
|
351
|
+
return EXIT.ENVIRONMENT;
|
|
352
|
+
}
|
|
249
353
|
if (estimate.ok === false) {
|
|
250
354
|
log(`That is more than the balance covers. Top up at ${estimate.topup_url || 'ravensight.io'} and run this again.`);
|
|
251
355
|
return EXIT.ENVIRONMENT;
|
|
@@ -260,42 +364,86 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
260
364
|
}
|
|
261
365
|
}
|
|
262
366
|
|
|
263
|
-
const started = await startBranch({ git, branch });
|
|
367
|
+
const started = pendingRequest ? await pendingRegister.returnToBranch({ git, branch }) : await startBranch({ git, branch });
|
|
264
368
|
if (!started.ok) {
|
|
265
369
|
log(started.reason);
|
|
266
370
|
return EXIT.ENVIRONMENT;
|
|
267
371
|
}
|
|
268
372
|
log(`branch ${branch}, from ${checked.head.slice(0, 8)}${checked.from ? ` on ${checked.from}` : ''}.`);
|
|
269
373
|
|
|
270
|
-
|
|
374
|
+
// Every way out before a job exists has to leave the developer where they were,
|
|
375
|
+
// unless nobody knows whether a job exists. Then the key and the branch are the way back.
|
|
376
|
+
const noJob = async () => {
|
|
377
|
+
await pendingRegister.clear(repoRoot, pendingScope);
|
|
378
|
+
await abandonBranch({ git, branch, from: checked.from, head: checked.head });
|
|
379
|
+
};
|
|
380
|
+
const remember = key => pendingRegister.save(repoRoot, pendingScope, { idempotencyKey: key, branch, from: checked.from, head: checked.head });
|
|
381
|
+
let idempotencyKey = options.idempotencyKey || (pendingRequest && pendingRequest.idempotencyKey) || randomUUID();
|
|
382
|
+
const registerBody = {
|
|
383
|
+
modules: [...FIX_MODULES],
|
|
384
|
+
personas: [personaSlug],
|
|
385
|
+
finding_id: finding.finding_id,
|
|
386
|
+
driver: driverName,
|
|
387
|
+
cli_version: cliVersion,
|
|
388
|
+
confirm_price_cents: priceCents,
|
|
389
|
+
brief_version: brief ? brief.version ?? null : null,
|
|
390
|
+
build: buildTarget
|
|
391
|
+
? { kind: driverName === 'playwright_web' ? 'url' : 'godot_project', ref: buildTarget }
|
|
392
|
+
: { kind: 'repo', ref: repoRoot },
|
|
393
|
+
repo: { commit: checked.head, dirty: Boolean(checked.dirty), branch },
|
|
394
|
+
max_actions: Number.isInteger(options.maxActions) ? options.maxActions : null,
|
|
395
|
+
pack_versions: { pack: pack.pack_version },
|
|
396
|
+
...(acceptance ? { acceptance } : {})
|
|
397
|
+
};
|
|
398
|
+
// A failed register, whichever attempt: `undefined` means it was an exception to rethrow.
|
|
399
|
+
const registerFailed = async error => {
|
|
400
|
+
if (pendingRegister.outcomeUnknown(error)) {
|
|
401
|
+
log(pendingRegister.unknownOutcomeMessage('this fix', branch));
|
|
402
|
+
return EXIT.ENVIRONMENT;
|
|
403
|
+
}
|
|
404
|
+
await noJob();
|
|
405
|
+
const handled = explainRegisterRefusal(error, log);
|
|
406
|
+
return handled !== null ? handled : undefined;
|
|
407
|
+
};
|
|
271
408
|
let registered;
|
|
272
409
|
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 });
|
|
410
|
+
await remember(idempotencyKey);
|
|
411
|
+
registered = await api.jobs.register(gameId, registerBody, { idempotencyKey });
|
|
288
412
|
} catch (error) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
413
|
+
if (acceptance && error && ['labs_not_enabled', 'funding_required'].includes(error.code)) {
|
|
414
|
+
// Labs went off, or the funding floor stopped being met, between the status read
|
|
415
|
+
// and the register. Nothing was charged by a refusal, so register the ordinary
|
|
416
|
+
// fix instead, on a fresh key because the body differs.
|
|
417
|
+
log(`The server refused Fix v2 for this game (${error.code}), so this is an ordinary fix.`);
|
|
418
|
+
delete registerBody.acceptance;
|
|
419
|
+
idempotencyKey = randomUUID();
|
|
420
|
+
try {
|
|
421
|
+
await remember(idempotencyKey);
|
|
422
|
+
registered = await api.jobs.register(gameId, registerBody, { idempotencyKey });
|
|
423
|
+
} catch (retryError) {
|
|
424
|
+
const exit = await registerFailed(retryError);
|
|
425
|
+
if (exit !== undefined) return exit;
|
|
426
|
+
throw retryError;
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
const exit = await registerFailed(error);
|
|
430
|
+
if (exit !== undefined) return exit;
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
292
433
|
}
|
|
434
|
+
await pendingRegister.clear(repoRoot, pendingScope);
|
|
293
435
|
|
|
294
436
|
const jobId = registered.job_id;
|
|
295
437
|
// Runs are told apart by module and nothing else: the fix run carries
|
|
296
438
|
// `persona: null`, and only the verify run carries the slug.
|
|
297
439
|
const fixRun = (registered.runs || []).find(entry => entry.module === 'fix');
|
|
298
|
-
const
|
|
440
|
+
const verifyRuns = (registered.runs || []).filter(entry => entry.module === 'persona_playtest');
|
|
441
|
+
const verifyRun = verifyRuns[0] || null;
|
|
442
|
+
// The contract the server froze. None means the server kept none, and the
|
|
443
|
+
// fix runs as the standard fix does: no checks, no repairs, no evidence report.
|
|
444
|
+
const frozen = acceptance ? frozenAcceptance(registered) : null;
|
|
445
|
+
if (acceptance && !frozen) log('The server did not keep the acceptance contract, so this fix runs without checks or repairs.');
|
|
446
|
+
const v2 = Boolean(frozen);
|
|
299
447
|
|
|
300
448
|
await mkdir(jobDir(repoRoot, jobId), { recursive: true });
|
|
301
449
|
const journal = stateFile.newState({
|
|
@@ -317,7 +465,8 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
317
465
|
findingId: finding.finding_id,
|
|
318
466
|
verify: wantsVerify,
|
|
319
467
|
allowDirty: Boolean(options.allowDirty),
|
|
320
|
-
maxActions: options.maxActions ?? null
|
|
468
|
+
maxActions: options.maxActions ?? null,
|
|
469
|
+
...(v2 ? { labs: 'fix_v2' } : {})
|
|
321
470
|
}
|
|
322
471
|
});
|
|
323
472
|
for (const entry of registered.runs || []) {
|
|
@@ -331,7 +480,7 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
331
480
|
|
|
332
481
|
if (!fixRun) {
|
|
333
482
|
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:
|
|
483
|
+
for (const entry of v2 ? verifyRuns : [verifyRun]) await skipRun({ api, gameId, jobId, run: entry, reason: 'the job has no fix run', log });
|
|
335
484
|
// Settled rather than abandoned: the job is charged and unplayable, so the
|
|
336
485
|
// refund is owed now and not in half an hour when the sweep notices.
|
|
337
486
|
await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the job has no fix run', log });
|
|
@@ -354,6 +503,7 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
354
503
|
onError: () => {}
|
|
355
504
|
});
|
|
356
505
|
const inFlight = new Map();
|
|
506
|
+
const abort = new AbortController();
|
|
357
507
|
const interrupt = createInterruptHandler({
|
|
358
508
|
api,
|
|
359
509
|
gameId,
|
|
@@ -366,7 +516,9 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
366
516
|
// this job's verify persona against a half-applied edit would answer a
|
|
367
517
|
// question nobody asked. A fix is abandoned and refunded instead.
|
|
368
518
|
message: 'finishing the turn in flight, then stopping. A fix cannot be resumed, so this one is being given back.',
|
|
369
|
-
injected
|
|
519
|
+
injected,
|
|
520
|
+
// The verify run listens to this: every turn it plays after a Ctrl-C is model spend.
|
|
521
|
+
onInterrupt: () => abort.abort()
|
|
370
522
|
});
|
|
371
523
|
|
|
372
524
|
try {
|
|
@@ -387,10 +539,11 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
387
539
|
heartbeat.stop();
|
|
388
540
|
inFlight.delete(fixRun.run_id);
|
|
389
541
|
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.`);
|
|
542
|
+
for (const entry of v2 ? verifyRuns : [verifyRun]) await skipRun({ api, gameId, jobId, run: entry, reason: 'the finding is design intent', log });
|
|
390
543
|
await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'intended' }, log });
|
|
391
|
-
await
|
|
392
|
-
|
|
393
|
-
log('
|
|
544
|
+
const intended = await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the finding is design intent', log });
|
|
545
|
+
if (Number(intended?.refunded_cents) > 0) log('The fix unit was refunded.');
|
|
546
|
+
log('Mark the finding as intended in the dashboard so no later job spends a turn on it.');
|
|
394
547
|
return EXIT.OK;
|
|
395
548
|
}
|
|
396
549
|
|
|
@@ -401,7 +554,8 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
401
554
|
jobId,
|
|
402
555
|
branch,
|
|
403
556
|
baseCommit: checked.head,
|
|
404
|
-
engine: pack.engine || null
|
|
557
|
+
engine: pack.engine || null,
|
|
558
|
+
...(v2 ? { runCheck: true } : {})
|
|
405
559
|
});
|
|
406
560
|
const codeBrief = pack.code_brief || null;
|
|
407
561
|
// `graph_context` only exists in a bundle asked for with this job's id: it
|
|
@@ -432,14 +586,26 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
432
586
|
if (plan.hypothesis) log(`hypothesis: ${plan.hypothesis}`);
|
|
433
587
|
|
|
434
588
|
const maxActions = resolveFixMaxActions(options, pack);
|
|
435
|
-
|
|
589
|
+
// Fix v2: one repository tool surface for the first apply and every
|
|
590
|
+
// repair, so the per-run write cap counts all of them, plus run_check.
|
|
591
|
+
const checks = v2
|
|
592
|
+
? createCheckRunner({
|
|
593
|
+
projectDir: await checkProjectDir({ driverName, buildTarget, repoRoot }),
|
|
594
|
+
godotPath: options.godotPath || null,
|
|
595
|
+
allowTests,
|
|
596
|
+
allowToolScripts: Boolean(options.allowToolScripts),
|
|
597
|
+
changedFiles: async () => projectRelative(await git.diffStat(), repoRoot, await checkProjectDir({ driverName, buildTarget, repoRoot })),
|
|
598
|
+
...(injected.checkRunner ? injected.checkRunner : {})
|
|
599
|
+
})
|
|
600
|
+
: null;
|
|
601
|
+
const applyRepo = v2 ? createRepoTools({ root: repoRoot, tools: WRITE_TOOLS }) : undefined;
|
|
602
|
+
const applyArgs = {
|
|
436
603
|
client,
|
|
437
604
|
jobId,
|
|
438
605
|
runId: fixRun.run_id,
|
|
439
606
|
repoRoot,
|
|
440
607
|
system,
|
|
441
608
|
plan,
|
|
442
|
-
maxActions,
|
|
443
609
|
codeBrief,
|
|
444
610
|
graphContext,
|
|
445
611
|
ledger,
|
|
@@ -454,6 +620,11 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
454
620
|
appendFile(path.join(directory, 'transcript.jsonl'), `${JSON.stringify({ at: new Date().toISOString(), ...progress })}\n`).catch(() => {});
|
|
455
621
|
},
|
|
456
622
|
shouldStop: () => interrupt.signalled || heartbeat.canceled
|
|
623
|
+
};
|
|
624
|
+
let applied = await applyFix({
|
|
625
|
+
...applyArgs,
|
|
626
|
+
maxActions,
|
|
627
|
+
...(v2 ? { acceptance: frozen, repo: withRunCheck(applyRepo, checks) } : {})
|
|
457
628
|
});
|
|
458
629
|
|
|
459
630
|
// A Ctrl-C, or the job cancelled from the dashboard. Either way this run is
|
|
@@ -465,16 +636,16 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
465
636
|
heartbeat.stop();
|
|
466
637
|
inFlight.delete(fixRun.run_id);
|
|
467
638
|
const why = interrupt.signalled ? 'interrupted by the developer' : 'cancelled';
|
|
639
|
+
for (const entry of v2 ? verifyRuns : [verifyRun]) await skipRun({ api, gameId, jobId, run: entry, reason: `the fix was ${why}`, log });
|
|
468
640
|
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
641
|
try {
|
|
471
642
|
await stateFile.patchRun(repoRoot, journal, fixRun.run_id, { state: 'failed', quit_reason: 'error' });
|
|
472
643
|
} catch {
|
|
473
644
|
// The journal is a convenience here; the server's own run state is the
|
|
474
645
|
// record, and it has just been told.
|
|
475
646
|
}
|
|
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.`);
|
|
647
|
+
const stopped = await settle({ api, gameId, jobId, repoRoot, journal, reason: `the fix was ${why}`, log });
|
|
648
|
+
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
649
|
return EXIT.CANCELED;
|
|
479
650
|
}
|
|
480
651
|
|
|
@@ -484,10 +655,10 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
484
655
|
heartbeat.stop();
|
|
485
656
|
inFlight.delete(fixRun.run_id);
|
|
486
657
|
log(`Nothing was changed, so there is nothing to review. The branch ${branch} is empty; delete it or stay on it.`);
|
|
658
|
+
for (const entry of v2 ? verifyRuns : [verifyRun]) await skipRun({ api, gameId, jobId, run: entry, reason: 'nothing was changed', log });
|
|
487
659
|
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.');
|
|
660
|
+
const unchanged = await settle({ api, gameId, jobId, repoRoot, journal, reason: 'nothing was changed', log });
|
|
661
|
+
if (Number(unchanged?.refunded_cents) > 0) log('The fix unit was refunded.');
|
|
491
662
|
return EXIT.OK;
|
|
492
663
|
}
|
|
493
664
|
|
|
@@ -498,7 +669,69 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
498
669
|
|
|
499
670
|
// 4. Verify, by replaying the finding against the build.
|
|
500
671
|
let verified = null;
|
|
501
|
-
|
|
672
|
+
const verifies = [];
|
|
673
|
+
let repairs = 0;
|
|
674
|
+
// A script that does not parse or a project that does not import is not a
|
|
675
|
+
// change anyone can review, so it blocks the upload and the unit comes back
|
|
676
|
+
// (playtest_fix_check_failed), as it does for a feature. A failing project
|
|
677
|
+
// test ships, and the report says so first.
|
|
678
|
+
let checksBlock = false;
|
|
679
|
+
let usedVerifyRuns = 0;
|
|
680
|
+
if (v2) {
|
|
681
|
+
// Fix v2: checks after every apply turn set, verify once they pass, and
|
|
682
|
+
// at most MAX_REPAIRS repair turn sets started by a failed check or a
|
|
683
|
+
// verify that still reproduces. The contract never changes.
|
|
684
|
+
const verifyArgs = {
|
|
685
|
+
deps, api, client, gameId, jobId, repoRoot, pack, brief, finding, driverName,
|
|
686
|
+
target: buildTarget, options, journal,
|
|
687
|
+
maxActions: Math.min(VERIFY_MAX_ACTIONS, maxActions),
|
|
688
|
+
makeDriver: injected.driverFactory || createDriver,
|
|
689
|
+
signal: abort.signal,
|
|
690
|
+
acceptance: frozen,
|
|
691
|
+
sourceRunId,
|
|
692
|
+
...(injected.replayer ? { replayer: injected.replayer } : {}),
|
|
693
|
+
log
|
|
694
|
+
};
|
|
695
|
+
for (;;) {
|
|
696
|
+
const gate = await runGateChecks(checks, log);
|
|
697
|
+
checksBlock = gate.importFailed || gate.failed.some(result => result.name === 'godot_script_check');
|
|
698
|
+
const canRepair = repairs < MAX_REPAIRS && applied.stop !== 'budget' && !interrupt.signalled && !heartbeat.canceled;
|
|
699
|
+
let repairReason = null;
|
|
700
|
+
let repairDetail = '';
|
|
701
|
+
if (gate.failed.length > 0) {
|
|
702
|
+
repairReason = gate.failed.some(result => result.status === 'check_refused')
|
|
703
|
+
? 'the checks were refused because of what the change edited'
|
|
704
|
+
: `the ${gate.failed.map(result => result.name).join(' and ')} check${gate.failed.length > 1 ? 's' : ''} failed`;
|
|
705
|
+
repairDetail = gate.failed.map(checkResultText).join('\n\n');
|
|
706
|
+
} else if (wantsVerify && usedVerifyRuns < verifyRuns.length) {
|
|
707
|
+
verified = await runVerify({ ...verifyArgs, entry: verifyRuns[usedVerifyRuns] });
|
|
708
|
+
usedVerifyRuns += 1;
|
|
709
|
+
verifies.push(verified);
|
|
710
|
+
const score = verified && !verified.failed ? verified.reproduced : null;
|
|
711
|
+
// A repair after verify needs a fresh verify run to judge it; the
|
|
712
|
+
// server registers spares for Fix v2, and without one there is
|
|
713
|
+
// nothing to re-verify with, so no repair is spent.
|
|
714
|
+
if (typeof score === 'number' && score > REPRODUCED_ABOVE && usedVerifyRuns < verifyRuns.length) {
|
|
715
|
+
repairReason = `the verify run still reproduced the finding (${score})`;
|
|
716
|
+
repairDetail = `verify run ${verified.run_id}: ${verifyWords(verified)}. The acceptance contract is unchanged: ${frozen.actual} should become: ${frozen.expected}`;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (!repairReason || !canRepair) break;
|
|
720
|
+
repairs += 1;
|
|
721
|
+
log(`repair ${repairs} of ${MAX_REPAIRS}: ${repairReason}.`);
|
|
722
|
+
applied = await applyFix({
|
|
723
|
+
...applyArgs,
|
|
724
|
+
maxActions: Math.min(REPAIR_MAX_ACTIONS, maxActions),
|
|
725
|
+
acceptance: frozen,
|
|
726
|
+
repair: { attempt: repairs, of: MAX_REPAIRS, reason: repairReason, detail: repairDetail },
|
|
727
|
+
repo: withRunCheck(applyRepo, checks)
|
|
728
|
+
});
|
|
729
|
+
if (interrupt.signalled || heartbeat.canceled) break;
|
|
730
|
+
}
|
|
731
|
+
for (const spare of verifyRuns.slice(usedVerifyRuns)) {
|
|
732
|
+
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 });
|
|
733
|
+
}
|
|
734
|
+
} else if (wantsVerify && verifyRun) {
|
|
502
735
|
verified = await runVerify({
|
|
503
736
|
deps,
|
|
504
737
|
api,
|
|
@@ -516,14 +749,61 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
516
749
|
journal,
|
|
517
750
|
maxActions: Math.min(VERIFY_MAX_ACTIONS, maxActions),
|
|
518
751
|
makeDriver: injected.driverFactory || createDriver,
|
|
752
|
+
signal: abort.signal,
|
|
519
753
|
log
|
|
520
754
|
});
|
|
521
755
|
} else {
|
|
522
756
|
await skipRun({ api, gameId, jobId, run: verifyRun, reason: wantsVerify ? 'no verify run was registered' : 'skipped with --no-verify', log });
|
|
523
757
|
}
|
|
758
|
+
if (interrupt.signalled || heartbeat.canceled) {
|
|
759
|
+
heartbeat.stop();
|
|
760
|
+
inFlight.delete(fixRun.run_id);
|
|
761
|
+
await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'error' }, log });
|
|
762
|
+
await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the fix was interrupted', log });
|
|
763
|
+
log(`The fix was interrupted and cannot be resumed, so the unit was given back. The branch ${branch} holds whatever was written.`);
|
|
764
|
+
return EXIT.CANCELED;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// A repair may have touched more files than the first apply did.
|
|
768
|
+
const finalChanged = v2 ? await git.diffStat() : changed;
|
|
769
|
+
const finalFiles = v2 ? finalChanged.slice(0, MAX_CHANGESET_FILES) : files;
|
|
770
|
+
|
|
771
|
+
if (v2 && checksBlock) {
|
|
772
|
+
// A fix the project cannot import or parse is not uploaded: there is no
|
|
773
|
+
// review it deserves. The evidence stays local for the developer.
|
|
774
|
+
heartbeat.stop();
|
|
775
|
+
inFlight.delete(fixRun.run_id);
|
|
776
|
+
const body = evidenceReport({
|
|
777
|
+
prBody: `# Fix ${finding.label || finding.finding_id}: not uploaded\n\nThis change does not pass its checks.`,
|
|
778
|
+
summarySource: 'local',
|
|
779
|
+
acceptance: frozen,
|
|
780
|
+
finding,
|
|
781
|
+
sourceRunId,
|
|
782
|
+
plan,
|
|
783
|
+
files: finalFiles,
|
|
784
|
+
checks: checks.results,
|
|
785
|
+
verifies,
|
|
786
|
+
repairs,
|
|
787
|
+
maxRepairs: MAX_REPAIRS,
|
|
788
|
+
wantsVerify,
|
|
789
|
+
thresholds: { fixedBelow: FIXED_BELOW, reproducedAbove: REPRODUCED_ABOVE }
|
|
790
|
+
});
|
|
791
|
+
await writeFile(path.join(directory, RUN_FILES.reportMd), body, 'utf8').catch(() => {});
|
|
792
|
+
const stillFailing = checksStillFailing(checks.results);
|
|
793
|
+
const refusedCheck = checks.results.some(result => stillFailing.includes(result.name) && result.status === 'check_refused');
|
|
794
|
+
const blockedBy = refusedCheck
|
|
795
|
+
? 'The checks were refused for this change'
|
|
796
|
+
: (stillFailing.includes('godot_import') ? 'The project does not import with this change' : 'This change does not pass its script check');
|
|
797
|
+
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.`);
|
|
798
|
+
// `check_failed` is the server's own word for this, so the ledger and the
|
|
799
|
+
// dashboard say the fix failed its checks rather than that a run crashed.
|
|
800
|
+
await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'check_failed' }, log });
|
|
801
|
+
await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the fix did not pass its checks', log });
|
|
802
|
+
return EXIT.FAILED;
|
|
803
|
+
}
|
|
524
804
|
|
|
525
805
|
// 5. The pull request text, from the diff that exists.
|
|
526
|
-
const fallback = localSummary({ finding, plan, files, verify: verified, branch, applied });
|
|
806
|
+
const fallback = localSummary({ finding, plan, files: finalFiles, verify: verified, branch, applied });
|
|
527
807
|
// Both budget stops mean the apply loop left off early rather than because
|
|
528
808
|
// it was finished, so both carry the caveat. Only the 402 skips the
|
|
529
809
|
// summarise call, because only the 402 means there is nothing left to
|
|
@@ -538,24 +818,48 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
538
818
|
system,
|
|
539
819
|
finding: findingForPrompt(finding),
|
|
540
820
|
plan,
|
|
541
|
-
files,
|
|
821
|
+
files: finalFiles,
|
|
542
822
|
verify: verified,
|
|
823
|
+
thresholds: { fixedBelow: FIXED_BELOW, reproducedAbove: REPRODUCED_ABOVE },
|
|
543
824
|
branch,
|
|
544
825
|
fallback,
|
|
545
826
|
ledger,
|
|
546
827
|
log
|
|
547
828
|
});
|
|
548
829
|
|
|
549
|
-
|
|
830
|
+
// Fix v2: the pull request body is the evidence report, which keeps what
|
|
831
|
+
// was measured apart from what a model wrote.
|
|
832
|
+
const prBody = v2
|
|
833
|
+
? withEvidenceIntact(description => evidenceReport({
|
|
834
|
+
prBody: description,
|
|
835
|
+
summarySource: summary.source,
|
|
836
|
+
acceptance: frozen,
|
|
837
|
+
finding,
|
|
838
|
+
sourceRunId,
|
|
839
|
+
plan,
|
|
840
|
+
files: finalFiles,
|
|
841
|
+
checks: checks.results,
|
|
842
|
+
verifies,
|
|
843
|
+
repairs,
|
|
844
|
+
maxRepairs: MAX_REPAIRS,
|
|
845
|
+
wantsVerify,
|
|
846
|
+
budgetTouched,
|
|
847
|
+
thresholds: { fixedBelow: FIXED_BELOW, reproducedAbove: REPRODUCED_ABOVE }
|
|
848
|
+
}), summary.prBody)
|
|
849
|
+
: summary.prBody;
|
|
850
|
+
|
|
851
|
+
const changeset = withoutQuotedSecrets({
|
|
550
852
|
schema_version: '1.0',
|
|
551
853
|
finding_id: finding.finding_id,
|
|
552
854
|
branch,
|
|
553
855
|
base_commit: checked.head,
|
|
554
|
-
files,
|
|
555
|
-
verify
|
|
856
|
+
files: finalFiles,
|
|
857
|
+
// A verify that never played the fix (its game did not boot, or the run
|
|
858
|
+
// died) is no verify at all: the changeset says unverified.
|
|
859
|
+
verify: verified && !verified.failed ? { run_id: verified.run_id, reproduced: verified.reproduced } : null,
|
|
556
860
|
pr_title: summary.prTitle,
|
|
557
|
-
pr_body:
|
|
558
|
-
};
|
|
861
|
+
pr_body: prBody
|
|
862
|
+
}, log);
|
|
559
863
|
|
|
560
864
|
await heartbeat.beat();
|
|
561
865
|
heartbeat.stop();
|
|
@@ -584,19 +888,34 @@ async function fix(target, flags, injected, deps, log) {
|
|
|
584
888
|
// whole diagnosis: "pr_title too long" is actionable, "422" is not.
|
|
585
889
|
for (const detail of changesetDetails(error)) log(` ${detail}`);
|
|
586
890
|
}
|
|
587
|
-
await settle({ api, gameId, jobId, repoRoot, journal, reason: '', log });
|
|
588
|
-
|
|
589
|
-
|
|
891
|
+
const finished = await settle({ api, gameId, jobId, repoRoot, journal, reason: '', log });
|
|
892
|
+
|
|
893
|
+
if (verified && verified.failed === 'game_boot_failed') {
|
|
894
|
+
// The server refunds a fix whose verify run could not boot
|
|
895
|
+
// (playtest_fix_unverified_boot) only up to the account's limit, so this says
|
|
896
|
+
// what its answer says and never assumes.
|
|
897
|
+
const answer = await api.runs.get(gameId, jobId, fixRun.run_id).catch(() => null);
|
|
898
|
+
log(bootRefundLine({ finished, run: (answer && (answer.run || answer)) || null, priceCents: registered.price && registered.price.price_cents }));
|
|
899
|
+
}
|
|
900
|
+
let opened = null;
|
|
901
|
+
if (v2 && options.openPr) {
|
|
902
|
+
opened = await maybeOpenPr({ repoRoot, branch, changed: finalChanged, written: applyRepo.written, changeset, reportPath, verified, injected, deps, log });
|
|
903
|
+
if (opened.ok) await recordPrOpened({ api, gameId, repoRoot, journal, url: opened.url });
|
|
904
|
+
}
|
|
905
|
+
const stillFailing = v2 ? checksStillFailing(checks.results) : [];
|
|
906
|
+
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.`);
|
|
907
|
+
if (!opened || !opened.ok) report({ changeset, directory, reportPath, verified, options, log, injected, deps, budget: budgetTouched });
|
|
590
908
|
// Exit 2 is "the money ran out", which a warning is not: a warning means
|
|
591
909
|
// the run wrapped up early with budget still in hand.
|
|
592
|
-
|
|
910
|
+
if (applied.stop === 'budget') return EXIT.BUDGET;
|
|
911
|
+
return opened && !opened.ok ? EXIT.ENVIRONMENT : EXIT.OK;
|
|
593
912
|
} catch (error) {
|
|
594
913
|
heartbeat.stop();
|
|
595
914
|
const code = exitCodeFor(error);
|
|
596
915
|
log(`fix failed: ${String(error && error.message)}`);
|
|
597
916
|
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
917
|
await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'the fix failed', log });
|
|
918
|
+
await failFixRun({ api, gameId, jobId, runId: fixRun.run_id, log });
|
|
600
919
|
await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the fix failed', log });
|
|
601
920
|
return code;
|
|
602
921
|
} finally {
|
|
@@ -763,17 +1082,34 @@ export async function runVerify({
|
|
|
763
1082
|
journal,
|
|
764
1083
|
maxActions,
|
|
765
1084
|
makeDriver,
|
|
1085
|
+
acceptance = null,
|
|
1086
|
+
sourceRunId = null,
|
|
1087
|
+
replayer = replayTrace,
|
|
1088
|
+
signal,
|
|
766
1089
|
log
|
|
767
1090
|
}) {
|
|
768
1091
|
const directory = runDirFor(repoRoot, jobId, entry.run_id);
|
|
769
1092
|
await mkdir(directory, { recursive: true });
|
|
1093
|
+
|
|
1094
|
+
// FIX V2 REPLAYS THE SOURCE RUN FIRST. It costs no model call, and it can say
|
|
1095
|
+
// one thing for certain: if the game answers identically at every recorded step,
|
|
1096
|
+
// the fix changed nothing on the path that found the problem. It cannot say the
|
|
1097
|
+
// problem is gone, because after a divergence the recorded inputs no longer mean
|
|
1098
|
+
// anything, so a divergence goes to the persona like a run with no trace at all.
|
|
1099
|
+
let divergence = null;
|
|
1100
|
+
if (acceptance && driverName === 'godot_driver') {
|
|
1101
|
+
const replayed = await replaySourceTrace({ api, gameId, jobId, repoRoot, finding, target, godotPath: options.godotPath, directory, sourceRunId, entry, log, replayer });
|
|
1102
|
+
if (replayed.unchanged) return closeVerifyOnUnchanged({ deps, api, gameId, jobId, repoRoot, entry, journal, directory, log, report: replayed.report });
|
|
1103
|
+
divergence = replayed.divergence;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
770
1106
|
const persona = (pack.personas || []).find(candidate => candidate.slug === entry.persona) || (pack.personas || [])[0];
|
|
771
1107
|
if (!persona) {
|
|
772
1108
|
log('The content pack has no persona to verify with, so the fix is reported unverified.');
|
|
773
1109
|
return null;
|
|
774
1110
|
}
|
|
775
1111
|
const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
|
|
776
|
-
const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId }));
|
|
1112
|
+
const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId, godotPath: options.godotPath, realtime: options.realtime }));
|
|
777
1113
|
const ledger = createUsageLedger();
|
|
778
1114
|
const heartbeat = createHeartbeat({
|
|
779
1115
|
api,
|
|
@@ -810,13 +1146,17 @@ export async function runVerify({
|
|
|
810
1146
|
runDirectory: directory,
|
|
811
1147
|
skill: skillFor(pack, 'persona_playtest'),
|
|
812
1148
|
// The brief fetched above, as `run` does: the pack does not always carry one.
|
|
813
|
-
|
|
1149
|
+
// Fix v2 builds the verify brief from the frozen contract alone.
|
|
1150
|
+
brief: withDivergence(acceptance
|
|
1151
|
+
? verifyBriefFromAcceptance(brief || pack.brief || null, acceptance)
|
|
1152
|
+
: verifyBrief(brief || pack.brief || null, finding), divergence),
|
|
814
1153
|
localBrief,
|
|
815
1154
|
intentNotes: pack.intent_notes || [],
|
|
816
1155
|
schemas: pack.schemas || [],
|
|
817
1156
|
maxActions,
|
|
818
1157
|
heartbeat,
|
|
819
1158
|
ledger,
|
|
1159
|
+
signal,
|
|
820
1160
|
log: message => log(`verify: ${message}`)
|
|
821
1161
|
});
|
|
822
1162
|
} catch (error) {
|
|
@@ -825,7 +1165,8 @@ export async function runVerify({
|
|
|
825
1165
|
await stateFile.patchRun(repoRoot, journal, entry.run_id, { state: failed.state, error: String(error && error.message), quit_reason: failed.quitReason });
|
|
826
1166
|
await deliver({ deps, api, gameId, jobId, runId: entry.run_id, directory, outcome: failed, options, log, journal, repoRoot });
|
|
827
1167
|
log(`the verify run failed (${String(error && error.message)}), so the fix is reported unverified.`);
|
|
828
|
-
|
|
1168
|
+
const booted = !(error && (error.code === 'game_boot_failed' || /^game_boot_failed: /.test(String(error.message))));
|
|
1169
|
+
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
1170
|
}
|
|
830
1171
|
heartbeat.stop();
|
|
831
1172
|
|
|
@@ -848,10 +1189,100 @@ export async function runVerify({
|
|
|
848
1189
|
log(`the verify verdict could not be read (${String(error && error.message)}).`);
|
|
849
1190
|
}
|
|
850
1191
|
const verdict = (run && run.verify_verdict) || null;
|
|
851
|
-
|
|
1192
|
+
// Strictly a number. `Number(null)` is 0, and the server answers
|
|
1193
|
+
// `{ reproduced: null, source: 'unavailable' }` when nobody could judge, so a
|
|
1194
|
+
// coercion here reads a judge being down as "fixed (reproduced 0)".
|
|
1195
|
+
const reproduced = verdict && typeof verdict.reproduced === 'number' && Number.isFinite(verdict.reproduced) ? verdict.reproduced : null;
|
|
852
1196
|
return { run_id: entry.run_id, reproduced, source: (verdict && verdict.source) || null };
|
|
853
1197
|
}
|
|
854
1198
|
|
|
1199
|
+
/**
|
|
1200
|
+
* Replay the source run's trace against the fix, on this working tree. Never
|
|
1201
|
+
* fails the verify: anything short of a full match, including no trace at all,
|
|
1202
|
+
* answers "not unchanged" and the persona decides. The outcome is recorded on the
|
|
1203
|
+
* job, free, when the server takes it.
|
|
1204
|
+
*
|
|
1205
|
+
* @returns {Promise<{unchanged: boolean, divergence: Object|null}>}
|
|
1206
|
+
*/
|
|
1207
|
+
export async function replaySourceTrace({ api, gameId, jobId, repoRoot, finding, target, godotPath, directory, sourceRunId, entry, log, replayer = replayTrace }) {
|
|
1208
|
+
const runId = sourceRunId || finding.run_id || null;
|
|
1209
|
+
if (!runId) return { unchanged: false, divergence: null };
|
|
1210
|
+
const trace = await loadTrace({ repoRoot, runId, jobId: finding.job_id || null, api, gameId });
|
|
1211
|
+
if (!trace) return { unchanged: false, divergence: null };
|
|
1212
|
+
let result;
|
|
1213
|
+
try {
|
|
1214
|
+
result = await replayer({ text: trace.text, projectDir: target, godotPath, runDir: path.join(directory, 'trace-replay') });
|
|
1215
|
+
} catch (error) {
|
|
1216
|
+
log(`verify: the source run's trace could not be replayed (${String(error && error.message)}). A persona replays the finding instead.`);
|
|
1217
|
+
return { unchanged: false, divergence: null };
|
|
1218
|
+
}
|
|
1219
|
+
log(`verify: ${replaySentence(result)}`);
|
|
1220
|
+
const sha = await headSha(repoRoot);
|
|
1221
|
+
const report = sha ? replayReport(result, { ref: 'fix-branch', sha }) : null;
|
|
1222
|
+
if (report && entry) {
|
|
1223
|
+
try {
|
|
1224
|
+
await api.runs.recordReplay(gameId, jobId, entry.run_id, report);
|
|
1225
|
+
} catch {
|
|
1226
|
+
// A record is for reading later. The verify does not depend on it.
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
// The server decides a verify from this whole report, so a replay that cannot
|
|
1230
|
+
// name its commit cannot close one: a persona replays the finding instead.
|
|
1231
|
+
// The same test as the server's `replayProvesIdentical`: a replay that compared
|
|
1232
|
+
// nothing, or ran no ticks, matched only because it did nothing.
|
|
1233
|
+
const proven = result.matched === true && result.fully_replayed === true && !result.stopped && !result.first_divergence && result.steps_compared > 0 && result.ticks > 0;
|
|
1234
|
+
return { unchanged: Boolean(report) && proven, divergence: result.first_divergence || null, report };
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
async function headSha(repoRoot) {
|
|
1238
|
+
try {
|
|
1239
|
+
const { stdout } = await execFileAsync('git', ['-C', repoRoot, 'rev-parse', 'HEAD']);
|
|
1240
|
+
return stdout.trim();
|
|
1241
|
+
} catch {
|
|
1242
|
+
return null;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* The trace matched everywhere, so the verify run is finished without a model.
|
|
1248
|
+
* The verdict comes back from the server's own record of this complete, the same
|
|
1249
|
+
* as a persona verify's, and it says the finding is still there.
|
|
1250
|
+
*/
|
|
1251
|
+
export async function closeVerifyOnUnchanged({ deps, api, gameId, jobId, repoRoot, entry, journal, directory, log, report }) {
|
|
1252
|
+
log('verify: every recorded step answered the same, so this fix changed nothing on the path that found the problem. No model was called.');
|
|
1253
|
+
for (const state of ['launching', 'playing', 'reporting', 'uploading']) {
|
|
1254
|
+
await api.runs.transition(gameId, jobId, entry.run_id, state, { reason: '' });
|
|
1255
|
+
}
|
|
1256
|
+
await deps.finalizeRun(directory, {
|
|
1257
|
+
api,
|
|
1258
|
+
gameId,
|
|
1259
|
+
jobId,
|
|
1260
|
+
runId: entry.run_id,
|
|
1261
|
+
body: { state: 'succeeded', actions_taken: 0, quit_reason: 'goal_reached', trace_replay: report }
|
|
1262
|
+
});
|
|
1263
|
+
await stateFile.patchRun(repoRoot, journal, entry.run_id, { state: 'succeeded', completed: true, uploaded: true });
|
|
1264
|
+
const answer = await api.runs.get(gameId, jobId, entry.run_id);
|
|
1265
|
+
const run = (answer && (answer.run || answer)) || null;
|
|
1266
|
+
const verdict = (run && run.verify_verdict) || null;
|
|
1267
|
+
// The same reading as a persona verify's: `Number(null)` is 0, which would read a
|
|
1268
|
+
// judge that could not answer as "fixed (reproduced 0)".
|
|
1269
|
+
const reproduced = verdict && typeof verdict.reproduced === 'number' && Number.isFinite(verdict.reproduced) ? verdict.reproduced : null;
|
|
1270
|
+
return { run_id: entry.run_id, reproduced, source: (verdict && verdict.source) || 'trace_replay' };
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/** Tell the verify persona where the fix first moved the game, as a starting point. */
|
|
1274
|
+
export function withDivergence(brief, divergence) {
|
|
1275
|
+
if (!divergence || !brief) return brief;
|
|
1276
|
+
const diff = Object.entries(divergence.game_state_diff || {}).slice(0, 5).map(([key, pair]) => `${key}: ${JSON.stringify(pair.expected)} became ${JSON.stringify(pair.actual)}`);
|
|
1277
|
+
return {
|
|
1278
|
+
...brief,
|
|
1279
|
+
fields: {
|
|
1280
|
+
...(brief.fields || {}),
|
|
1281
|
+
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.`
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
|
|
855
1286
|
/**
|
|
856
1287
|
* The verify run's brief: what it is being asked to reproduce, then the brief
|
|
857
1288
|
* a persona usually plays against. The task comes first because it is the one
|
|
@@ -942,11 +1373,13 @@ function report({ changeset, reportPath, verified, options, log, injected, deps,
|
|
|
942
1373
|
log(`verify: ${verifyWords(verified)}`);
|
|
943
1374
|
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
1375
|
log('Nothing was committed and nothing was pushed. Read the diff, commit it, then:');
|
|
945
|
-
log(` gh pr create --title
|
|
1376
|
+
log(` gh pr create --title ${shellQuote(changeset.pr_title)} --body-file ${shellQuote(reportPath)}`);
|
|
946
1377
|
}
|
|
947
1378
|
|
|
948
1379
|
/**
|
|
949
|
-
* Complete the fix run, tolerating a refusal.
|
|
1380
|
+
* Complete the fix run, tolerating a refusal. Every caller completes or skips
|
|
1381
|
+
* the verify run(s) first: the server reads the verify run's record when the
|
|
1382
|
+
* fix run completes (a verify that could not boot refunds the unit). The money is settled by
|
|
950
1383
|
* `finish` either way, and a refused complete must not turn a fix that worked
|
|
951
1384
|
* into a crash.
|
|
952
1385
|
*/
|
|
@@ -977,11 +1410,13 @@ async function skipRun({ api, gameId, jobId, run, reason, log }) {
|
|
|
977
1410
|
|
|
978
1411
|
/** `finish` settles the job once and issues at most one refund. */
|
|
979
1412
|
async function settle({ api, gameId, jobId, repoRoot, journal, reason, log }) {
|
|
1413
|
+
let finished = null;
|
|
980
1414
|
try {
|
|
981
|
-
|
|
1415
|
+
finished = await api.jobs.finish(gameId, jobId, { reason });
|
|
982
1416
|
log(`job ${jobId} ${finished.job ? finished.job.state : 'finished'}: refunded ${formatCents(finished.refunded_cents)}.`);
|
|
1417
|
+
if (finished.refund_limited_note) log(finished.refund_limited_note);
|
|
983
1418
|
} catch (error) {
|
|
984
|
-
log(
|
|
1419
|
+
log(settleFailureLine(jobId, error));
|
|
985
1420
|
}
|
|
986
1421
|
journal.finished = true;
|
|
987
1422
|
try {
|
|
@@ -990,6 +1425,7 @@ async function settle({ api, gameId, jobId, repoRoot, journal, reason, log }) {
|
|
|
990
1425
|
// The journal is a convenience at this point: the server's ledger is the
|
|
991
1426
|
// record of what was charged and what came back.
|
|
992
1427
|
}
|
|
1428
|
+
return finished;
|
|
993
1429
|
}
|
|
994
1430
|
|
|
995
1431
|
/** The finding as the prompt sees it: no ids nobody can act on, no noise. */
|
|
@@ -1061,6 +1497,80 @@ export function changesetDetails(error) {
|
|
|
1061
1497
|
.filter(Boolean);
|
|
1062
1498
|
}
|
|
1063
1499
|
|
|
1500
|
+
/**
|
|
1501
|
+
* The Godot project the checks copy: the verify target for the Godot driver,
|
|
1502
|
+
* else the repository when it is a Godot project, else none.
|
|
1503
|
+
*
|
|
1504
|
+
* @returns {Promise<string|null>}
|
|
1505
|
+
*/
|
|
1506
|
+
export async function checkProjectDir({ driverName, buildTarget, repoRoot }) {
|
|
1507
|
+
if (driverName === 'godot_driver' && buildTarget) return buildTarget;
|
|
1508
|
+
return (await isGodotProject(repoRoot)) ? repoRoot : null;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* Repository relative paths as paths inside the Godot project, dropping any
|
|
1513
|
+
* outside it.
|
|
1514
|
+
*/
|
|
1515
|
+
export function projectRelative(changed, repoRoot, projectDir) {
|
|
1516
|
+
if (!projectDir) return [];
|
|
1517
|
+
return changed
|
|
1518
|
+
.map(file => path.relative(projectDir, path.join(repoRoot, file.path)))
|
|
1519
|
+
.filter(file => file && !file.startsWith('..') && !path.isAbsolute(file));
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Every gate check, in order. `godot_import` failing is the one that blocks an
|
|
1524
|
+
* upload; the others start a repair and, if they still fail, are listed as
|
|
1525
|
+
* remaining risks.
|
|
1526
|
+
*
|
|
1527
|
+
* @returns {Promise<{failed: Array<Object>, importFailed: boolean}>}
|
|
1528
|
+
*/
|
|
1529
|
+
export async function runGateChecks(checks, log = () => {}) {
|
|
1530
|
+
const failed = [];
|
|
1531
|
+
let importFailed = false;
|
|
1532
|
+
for (const name of GATE_CHECKS) {
|
|
1533
|
+
// A refusal names the edits, not the check, so the rest would only repeat it.
|
|
1534
|
+
if (failed.some(result => result.status === 'check_refused')) break;
|
|
1535
|
+
// After a failed import the tests cannot mean anything, but the script
|
|
1536
|
+
// check still can: the import names the scripts that could not load
|
|
1537
|
+
// because of a broken one, the script check names the broken line.
|
|
1538
|
+
if (importFailed && name === 'project_tests') continue;
|
|
1539
|
+
const result = await checks.run(name);
|
|
1540
|
+
log(`check ${name}: ${result.status} (${result.detail})`);
|
|
1541
|
+
if (result.status === 'failed' || result.status === 'check_refused') {
|
|
1542
|
+
failed.push(result);
|
|
1543
|
+
// A refused check blocks the upload exactly as a failed import does:
|
|
1544
|
+
// nothing proved the change imports.
|
|
1545
|
+
if (name === 'godot_import' || result.status === 'check_refused') importFailed = true;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return { failed, importFailed };
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/**
|
|
1552
|
+
* `--open-pr` after the fix is uploaded: only for a fix the verify scored as
|
|
1553
|
+
* fixed, and only with the developer's own yes.
|
|
1554
|
+
*/
|
|
1555
|
+
export async function maybeOpenPr({ repoRoot, branch, changed, written = null, changeset, reportPath, verified, injected, deps, log }) {
|
|
1556
|
+
if (!verifyWords(verified).startsWith('fixed')) {
|
|
1557
|
+
log(`--open-pr opens a pull request only for a verified fix, and this one reads ${verifyWords(verified)}. Nothing was pushed.`);
|
|
1558
|
+
return { ok: false, reason: 'not_verified' };
|
|
1559
|
+
}
|
|
1560
|
+
const opener = injected.openPullRequest || openPullRequest;
|
|
1561
|
+
return opener({
|
|
1562
|
+
repoRoot,
|
|
1563
|
+
branch,
|
|
1564
|
+
// What the fix wrote, not whatever else is in the tree by now: a run can leave
|
|
1565
|
+
// files behind that are not the change.
|
|
1566
|
+
paths: changed.map(file => file.path).filter(file => !written || written.includes(file)),
|
|
1567
|
+
title: changeset.pr_title,
|
|
1568
|
+
bodyFile: reportPath,
|
|
1569
|
+
confirm: injected.confirmPr || injected.confirm || (deps.ui && deps.ui.confirm),
|
|
1570
|
+
log
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1064
1574
|
function isImageName(name) {
|
|
1065
1575
|
return /\.(png|jpe?g|webp)$/i.test(String(name));
|
|
1066
1576
|
}
|