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/run/fixLoop.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseJsonObject } from './json.js';
|
|
2
2
|
import { callStepWithRetry, ProxyError, STEPS } from './model.js';
|
|
3
|
+
import { acceptanceBlock } from './acceptance.js';
|
|
3
4
|
import { createRepoTools, WRITE_TOOLS } from './repoTools.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -51,6 +52,12 @@ export const PLAN_MAX_TURNS = 6;
|
|
|
51
52
|
*/
|
|
52
53
|
export const NUDGE_EVERY = 8;
|
|
53
54
|
|
|
55
|
+
/** What `fix.apply` is told first. */
|
|
56
|
+
export const FIX_APPLY_INSTRUCTIONS = Object.freeze([
|
|
57
|
+
'Make the change now. Read before you edit. Change an existing file with `edit_file` (old_text copied verbatim, new_text in its place); use `write_file` only for a new file, because it replaces the whole file and a large file will not fit in one answer.',
|
|
58
|
+
'Change as little as possible, keep the style of the code around it, and stop as soon as the fix is in. When you are done, say in one line what you changed and call no more tools.'
|
|
59
|
+
]);
|
|
60
|
+
|
|
54
61
|
/** One breakpoint, frozen so no caller can hand a mutated one to the next turn. */
|
|
55
62
|
const CACHE_BREAKPOINT = Object.freeze({ type: 'ephemeral' });
|
|
56
63
|
|
|
@@ -146,7 +153,7 @@ export function resolveFixMaxActions(options = {}, pack = {}) {
|
|
|
146
153
|
* @param {Object} args
|
|
147
154
|
* @returns {Array<Object>}
|
|
148
155
|
*/
|
|
149
|
-
export function fixSystem({ skill, jobId, branch, baseCommit, engine = null }) {
|
|
156
|
+
export function fixSystem({ skill, jobId, branch, baseCommit, engine = null, runCheck = false }) {
|
|
150
157
|
return [
|
|
151
158
|
{ type: 'text', text: skill },
|
|
152
159
|
{
|
|
@@ -156,7 +163,9 @@ export function fixSystem({ skill, jobId, branch, baseCommit, engine = null }) {
|
|
|
156
163
|
'the paths you read and write are relative to the repository root; never absolute',
|
|
157
164
|
`engine recorded on this game: ${engine || 'unknown'}`,
|
|
158
165
|
`branch: ${branch} (created for this attempt, from ${baseCommit || 'unknown'})`,
|
|
159
|
-
|
|
166
|
+
runCheck
|
|
167
|
+
? 'you cannot run commands, reach the network, or commit anything, except the named checks the run_check tool offers. The developer reviews the branch.'
|
|
168
|
+
: 'you cannot run commands, reach the network, or commit anything. The developer reviews the branch and opens the pull request themselves.'
|
|
160
169
|
].join('\n'),
|
|
161
170
|
cache_control: { type: 'ephemeral' }
|
|
162
171
|
}
|
|
@@ -244,7 +253,8 @@ export async function planFix({
|
|
|
244
253
|
content,
|
|
245
254
|
repo,
|
|
246
255
|
ledger = null,
|
|
247
|
-
log = () => {}
|
|
256
|
+
log = () => {},
|
|
257
|
+
step = STEPS.fixPlan
|
|
248
258
|
}) {
|
|
249
259
|
const messages = [{ role: 'user', content }];
|
|
250
260
|
let turns = 0;
|
|
@@ -260,7 +270,7 @@ export async function planFix({
|
|
|
260
270
|
client,
|
|
261
271
|
jobId,
|
|
262
272
|
runId,
|
|
263
|
-
step
|
|
273
|
+
step,
|
|
264
274
|
system,
|
|
265
275
|
messages: withRollingCacheBreakpoint(messages),
|
|
266
276
|
tools: repo ? repo.tools : undefined,
|
|
@@ -321,7 +331,9 @@ export function normalisePlan(parsed) {
|
|
|
321
331
|
.map(entry => entry.trim()),
|
|
322
332
|
out_of_scope: (Array.isArray(parsed.out_of_scope) ? parsed.out_of_scope : [])
|
|
323
333
|
.map(entry => (typeof entry === 'string' ? entry : JSON.stringify(entry)))
|
|
324
|
-
.filter(Boolean)
|
|
334
|
+
.filter(Boolean),
|
|
335
|
+
// Only a feature plan answers this. A fix plan reads exactly as it did.
|
|
336
|
+
...(Array.isArray(parsed.placeholders) ? { placeholders: parsed.placeholders.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim().slice(0, 200)) } : {})
|
|
325
337
|
};
|
|
326
338
|
}
|
|
327
339
|
|
|
@@ -346,21 +358,34 @@ export async function applyFix({
|
|
|
346
358
|
maxActions,
|
|
347
359
|
codeBrief = null,
|
|
348
360
|
graphContext = null,
|
|
361
|
+
acceptance = null,
|
|
362
|
+
repair = null,
|
|
349
363
|
repo = createRepoTools({ root: repoRoot, tools: WRITE_TOOLS }),
|
|
350
364
|
ledger = null,
|
|
351
365
|
log = () => {},
|
|
352
366
|
onProgress = () => {},
|
|
353
|
-
shouldStop = () => false
|
|
367
|
+
shouldStop = () => false,
|
|
368
|
+
step = STEPS.fixApply,
|
|
369
|
+
// What this apply is told to do and what it is held to. The feature module
|
|
370
|
+
// has its own words and its own frozen spec; everything else about the loop,
|
|
371
|
+
// its guards and its budget stop, is shared.
|
|
372
|
+
instructions = FIX_APPLY_INSTRUCTIONS,
|
|
373
|
+
contract = null
|
|
354
374
|
}) {
|
|
355
375
|
const messages = [{
|
|
356
376
|
role: 'user',
|
|
357
377
|
content: [{
|
|
358
378
|
type: 'text',
|
|
359
379
|
text: [
|
|
360
|
-
|
|
361
|
-
|
|
380
|
+
...(repair ? repairLines(repair) : []),
|
|
381
|
+
...instructions,
|
|
362
382
|
'',
|
|
363
383
|
`<plan>\n${JSON.stringify(plan || EMPTY_PLAN, null, 2)}\n</plan>`,
|
|
384
|
+
...(acceptance ? [acceptanceBlock(acceptance)] : []),
|
|
385
|
+
...(contract ? [contract] : []),
|
|
386
|
+
...(repo.tools.some(tool => tool.name === 'run_check')
|
|
387
|
+
? ['Before you finish, call run_check with godot_import to prove the project still imports. A change that fails it cannot be uploaded.']
|
|
388
|
+
: []),
|
|
364
389
|
...bundleLines(codeBrief, graphContext)
|
|
365
390
|
].join('\n')
|
|
366
391
|
}]
|
|
@@ -384,7 +409,7 @@ export async function applyFix({
|
|
|
384
409
|
client,
|
|
385
410
|
jobId,
|
|
386
411
|
runId,
|
|
387
|
-
step
|
|
412
|
+
step,
|
|
388
413
|
system,
|
|
389
414
|
messages: withRollingCacheBreakpoint(messages),
|
|
390
415
|
tools: repo.tools,
|
|
@@ -453,6 +478,47 @@ export async function applyFix({
|
|
|
453
478
|
return { turns, written: repo.written, stop };
|
|
454
479
|
}
|
|
455
480
|
|
|
481
|
+
/**
|
|
482
|
+
* The opening of a repair turn set: the change already on disk did not pass,
|
|
483
|
+
* and this is why. The acceptance contract is restated by the caller,
|
|
484
|
+
* unchanged; nothing here redefines success.
|
|
485
|
+
*
|
|
486
|
+
* @param {{attempt: number, of: number, reason: string, detail: string}} repair
|
|
487
|
+
* @returns {Array<string>}
|
|
488
|
+
*/
|
|
489
|
+
export function repairLines(repair) {
|
|
490
|
+
return [
|
|
491
|
+
`Repair ${repair.attempt} of ${repair.of}. Your earlier change is on disk on this branch, and it did not pass: ${repair.reason}.`,
|
|
492
|
+
`<failure>\n${String(repair.detail || '').slice(0, 6000)}\n</failure>`,
|
|
493
|
+
'Read the files you changed, correct the change so it passes, and keep it as small as before. Do not undo the fix to make a check pass.',
|
|
494
|
+
''
|
|
495
|
+
];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The verify result as the summary model reads it. A bare `reproduced: 0.34`
|
|
500
|
+
* was once written up as "reproduced the finding in 34% of actions", so the
|
|
501
|
+
* number never travels without what it is, what it is not and how it reads.
|
|
502
|
+
*
|
|
503
|
+
* @param {Object|null} verify
|
|
504
|
+
* @param {{fixedBelow: number, reproducedAbove: number}} thresholds
|
|
505
|
+
* @returns {string}
|
|
506
|
+
*/
|
|
507
|
+
export function verifyForSummary(verify, { fixedBelow, reproducedAbove }) {
|
|
508
|
+
if (!verify) return 'The fix was not verified by replaying the finding, so say that and claim no result.';
|
|
509
|
+
if (verify.failed === 'game_boot_failed') return 'The verify run could not test the fix because the game did not boot. Say the fix is not verified and claim no result.';
|
|
510
|
+
if (verify.failed) return `The verify run did not finish${verify.detail ? ` (${verify.detail})` : ''}, so it says nothing about the fix. Say the fix is not verified and claim no result.`;
|
|
511
|
+
const score = verify.reproduced;
|
|
512
|
+
if (typeof score !== 'number' || !Number.isFinite(score)) return 'The verify run played, but no verdict was reached. Say the result is unknown and claim no result.';
|
|
513
|
+
const reads = score < fixedBelow ? 'fixed' : (score > reproducedAbove ? 'still reproduced' : 'unknown');
|
|
514
|
+
return [
|
|
515
|
+
`One verify run replayed the finding, and that run was judged and given a score of ${score}.`,
|
|
516
|
+
`The score is a judged probability, from 0 to 1, that the finding is still present: below ${fixedBelow} reads as fixed, above ${reproducedAbove} reads as still reproduced, and anything between reads as unknown.`,
|
|
517
|
+
`This score reads as ${reads}.`,
|
|
518
|
+
'It is one opinion about one replay. It is not a percentage or a share of actions, attempts, sessions or players, and nothing was measured a number of times. Do not write it as a rate.'
|
|
519
|
+
].join(' ');
|
|
520
|
+
}
|
|
521
|
+
|
|
456
522
|
/**
|
|
457
523
|
* The pull request text, written from the diff that exists rather than from
|
|
458
524
|
* the plan that was made.
|
|
@@ -474,6 +540,7 @@ export async function summariseFix({
|
|
|
474
540
|
plan,
|
|
475
541
|
files,
|
|
476
542
|
verify,
|
|
543
|
+
thresholds,
|
|
477
544
|
branch,
|
|
478
545
|
fallback,
|
|
479
546
|
ledger = null,
|
|
@@ -487,7 +554,7 @@ export async function summariseFix({
|
|
|
487
554
|
`<finding>\n${JSON.stringify(finding, null, 2)}\n</finding>`,
|
|
488
555
|
`<plan>\n${JSON.stringify(plan || EMPTY_PLAN, null, 2)}\n</plan>`,
|
|
489
556
|
`<diff_stat>\n${files.map(file => `${file.path}: +${file.additions} -${file.deletions}`).join('\n') || 'nothing'}\n</diff_stat>`,
|
|
490
|
-
`<verify>\n${verify
|
|
557
|
+
`<verify>\n${verifyForSummary(verify, thresholds)}\n</verify>`,
|
|
491
558
|
`<branch>${branch}</branch>`,
|
|
492
559
|
'',
|
|
493
560
|
'Answer with this JSON object and nothing else:',
|
|
@@ -511,7 +578,10 @@ export async function summariseFix({
|
|
|
511
578
|
log('There was no budget left to write the pull request text, so it was written locally instead.');
|
|
512
579
|
return { ...fallback, source: 'local' };
|
|
513
580
|
}
|
|
514
|
-
|
|
581
|
+
// The change exists, the checks and the verify have run. Prose is the one thing
|
|
582
|
+
// left, and losing it must not lose the changeset or fail a run that worked.
|
|
583
|
+
log(`The pull request text could not be written by the model (${String(error && error.message)}), so it was written locally instead.`);
|
|
584
|
+
return { ...fallback, source: 'local' };
|
|
515
585
|
}
|
|
516
586
|
if (ledger) ledger.record({ model: answer.model, usage: answer.usage });
|
|
517
587
|
|
|
@@ -535,4 +605,4 @@ export function clamp(value, limit) {
|
|
|
535
605
|
return text.length <= limit ? text : text.slice(0, limit);
|
|
536
606
|
}
|
|
537
607
|
|
|
538
|
-
export default { planFix, applyFix, summariseFix, resolveFixMaxActions, fixSystem, planContent, normalisePlan, withRollingCacheBreakpoint };
|
|
608
|
+
export default { planFix, applyFix, summariseFix, verifyForSummary, resolveFixMaxActions, fixSystem, planContent, normalisePlan, withRollingCacheBreakpoint };
|
package/src/run/git.js
CHANGED
|
@@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
3
3
|
import { readFile, stat } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
-
import { skippedDirIn } from './repoTools.js';
|
|
6
|
+
import { isRunOutputName, skippedDirIn } from './repoTools.js';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* The only module in this CLI that runs git, and the only one that ever will.
|
|
@@ -19,7 +19,9 @@ import { skippedDirIn } from './repoTools.js';
|
|
|
19
19
|
* - **It never rewrites history.** No `reset`, no `rebase`, no `checkout --`
|
|
20
20
|
* of a path. The only mutation is creating and checking out one new branch,
|
|
21
21
|
* and that is refused if the branch already exists, so an earlier attempt's
|
|
22
|
-
* work is never landed on.
|
|
22
|
+
* work is never landed on. Undoing it, when the server refused the job before
|
|
23
|
+
* anything happened on it, goes back to where it started and deletes the branch
|
|
24
|
+
* with `-d`, which git refuses if the branch holds anything.
|
|
23
25
|
* - **It refuses a dirty tree** unless the developer says otherwise, because
|
|
24
26
|
* the changeset is computed as a diff against the recorded HEAD and a tree
|
|
25
27
|
* that was already dirty makes that diff a mixture of two people's work.
|
|
@@ -35,7 +37,7 @@ import { skippedDirIn } from './repoTools.js';
|
|
|
35
37
|
const run = promisify(execFile);
|
|
36
38
|
|
|
37
39
|
/** Branch names git itself refuses, checked before git is asked. */
|
|
38
|
-
const BAD_BRANCH = /(
|
|
40
|
+
const BAD_BRANCH = /(^-|^\+|^@$|^\.|\.\.|[~^:?*[\\ ]|@\{|\.lock$|\/$|^\/|\/\/)/;
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* Where a fix lands by default: one branch per attempt, named after the
|
|
@@ -136,14 +138,22 @@ export function createGit({ root, exec = run }) {
|
|
|
136
138
|
* @returns {Promise<Array<string>>}
|
|
137
139
|
*/
|
|
138
140
|
async dirtyPaths(limit = Number.POSITIVE_INFINITY) {
|
|
139
|
-
|
|
141
|
+
// `-z` because git quotes a name with non-ASCII characters otherwise, and a
|
|
142
|
+
// quoted name is a path that does not exist. `-- .` because a repository's
|
|
143
|
+
// other directories are not what this command changes.
|
|
144
|
+
const answer = await git(['status', '--porcelain', '-z', '--untracked-files=normal', '--', '.']);
|
|
140
145
|
if (!answer.ok) return [];
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
.
|
|
146
|
-
.
|
|
146
|
+
const fields = answer.stdout.split('\0');
|
|
147
|
+
const out = [];
|
|
148
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
149
|
+
const field = fields[i];
|
|
150
|
+
if (field.length < 4) continue;
|
|
151
|
+
// A rename is `R new`, then the old name in a field of its own.
|
|
152
|
+
if (/[RC]/.test(field.slice(0, 2))) i += 1;
|
|
153
|
+
const entry = field.slice(3);
|
|
154
|
+
if (!skippedDirIn(entry)) out.push(entry);
|
|
155
|
+
}
|
|
156
|
+
return out.slice(0, limit);
|
|
147
157
|
},
|
|
148
158
|
|
|
149
159
|
/** Whether the tree has uncommitted work worth refusing over. */
|
|
@@ -192,12 +202,15 @@ export function createGit({ root, exec = run }) {
|
|
|
192
202
|
*/
|
|
193
203
|
async diffStat() {
|
|
194
204
|
const out = [];
|
|
195
|
-
|
|
205
|
+
// `-z` as above. `--relative` because numstat otherwise answers from the top
|
|
206
|
+
// of the repository, and the rest of this CLI, untracked files included,
|
|
207
|
+
// works from `root`, which may be one game inside a monorepo.
|
|
208
|
+
const tracked = await git(['diff', '--numstat', '-z', '--relative', '--no-renames', '--no-ext-diff']);
|
|
196
209
|
if (tracked.ok) {
|
|
197
|
-
for (const line of tracked.stdout.split('\
|
|
210
|
+
for (const line of tracked.stdout.split('\0')) {
|
|
198
211
|
if (!line.trim()) continue;
|
|
199
212
|
const [additions, deletions, ...rest] = line.split('\t');
|
|
200
|
-
const file = rest.join('\t')
|
|
213
|
+
const file = rest.join('\t');
|
|
201
214
|
if (!file || skippedDirIn(file)) continue;
|
|
202
215
|
out.push({
|
|
203
216
|
path: file,
|
|
@@ -208,11 +221,12 @@ export function createGit({ root, exec = run }) {
|
|
|
208
221
|
});
|
|
209
222
|
}
|
|
210
223
|
}
|
|
211
|
-
const untracked = await git(['ls-files', '--others', '--exclude-standard']);
|
|
224
|
+
const untracked = await git(['ls-files', '-z', '--others', '--exclude-standard']);
|
|
212
225
|
if (untracked.ok) {
|
|
213
|
-
for (const
|
|
214
|
-
|
|
215
|
-
|
|
226
|
+
for (const file of untracked.stdout.split('\0')) {
|
|
227
|
+
// Untracked at the root under one of these names: an older CLI's
|
|
228
|
+
// leftovers, never the customer's source.
|
|
229
|
+
if (!file || skippedDirIn(file) || isRunOutputName(file)) continue;
|
|
216
230
|
out.push({ path: file, additions: await countLines(path.join(root, file)), deletions: 0 });
|
|
217
231
|
}
|
|
218
232
|
}
|
|
@@ -226,6 +240,61 @@ export function createGit({ root, exec = run }) {
|
|
|
226
240
|
};
|
|
227
241
|
}
|
|
228
242
|
|
|
243
|
+
/**
|
|
244
|
+
* Which of these paths git ignores. A tracked file is never among them, whatever
|
|
245
|
+
* the rules say, because git still shows its changes. Outside a repository, or
|
|
246
|
+
* with no git at all, nothing is ignored: there are no rules to apply.
|
|
247
|
+
*
|
|
248
|
+
* @param {string} root
|
|
249
|
+
* @param {Array<string>} paths repository relative
|
|
250
|
+
* @returns {Promise<Set<string>>}
|
|
251
|
+
*/
|
|
252
|
+
export function ignoredPaths(root, paths) {
|
|
253
|
+
return new Promise((resolve) => {
|
|
254
|
+
if (paths.length === 0) {
|
|
255
|
+
resolve(new Set());
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const child = execFile('git', ['-C', root, 'check-ignore', '--stdin', '-z'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, (error, stdout) => {
|
|
259
|
+
// Exit 1 is "none of them", and 128 is "not a repository".
|
|
260
|
+
resolve(new Set(error ? [] : String(stdout).split('\0').filter(Boolean)));
|
|
261
|
+
});
|
|
262
|
+
child.stdin.on('error', () => {});
|
|
263
|
+
child.stdin.end(`${paths.join('\0')}\0`);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Every ignored file under the root. Asked this way round for a whole tree, because
|
|
269
|
+
* `check-ignore` refuses the entire list over one path inside a submodule.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} root
|
|
272
|
+
* @returns {Promise<Set<string>>}
|
|
273
|
+
*/
|
|
274
|
+
export async function allIgnoredFiles(root) {
|
|
275
|
+
try {
|
|
276
|
+
const { stdout } = await run('git', ['-C', root, 'ls-files', '-z', '--others', '--ignored', '--exclude-standard'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
277
|
+
return new Set(stdout.split('\0').filter(Boolean));
|
|
278
|
+
} catch {
|
|
279
|
+
return new Set();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Where this repository's hooks live, `core.hooksPath` included, as an absolute path.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} root
|
|
287
|
+
* @returns {Promise<string|null>}
|
|
288
|
+
*/
|
|
289
|
+
export async function hooksDirectory(root) {
|
|
290
|
+
try {
|
|
291
|
+
const { stdout } = await run('git', ['-C', root, 'rev-parse', '--git-path', 'hooks'], { encoding: 'utf8' });
|
|
292
|
+
return path.resolve(root, stdout.trim());
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
229
298
|
/**
|
|
230
299
|
* The checks `fix` runs before it edits anything, in the order that answers
|
|
231
300
|
* the developer soonest.
|
|
@@ -247,7 +316,7 @@ export function createGit({ root, exec = run }) {
|
|
|
247
316
|
* @param {boolean} [options.create] create and check out the branch now
|
|
248
317
|
* @returns {Promise<{ok: true, head: string, branch: string, from: string|null, dirty: boolean, created: boolean}|{ok: false, reason: string}>}
|
|
249
318
|
*/
|
|
250
|
-
export async function preflight({ git, branch, allowDirty = false, create = true }) {
|
|
319
|
+
export async function preflight({ git, branch, allowDirty = false, create = true, reuseBranch = false }) {
|
|
251
320
|
if (!(await git.available())) {
|
|
252
321
|
return { ok: false, reason: 'This command needs git on the PATH, and there is none here.' };
|
|
253
322
|
}
|
|
@@ -256,6 +325,10 @@ export async function preflight({ git, branch, allowDirty = false, create = true
|
|
|
256
325
|
}
|
|
257
326
|
const named = branchNameRefusal(branch);
|
|
258
327
|
if (named) return { ok: false, reason: named };
|
|
328
|
+
// Git has the last word on what a branch can be called, and asking it now means
|
|
329
|
+
// the refusal comes before a price rather than after one.
|
|
330
|
+
const usable = await git.git(['check-ref-format', '--branch', branch]);
|
|
331
|
+
if (!usable.ok && !usable.missing) return { ok: false, reason: `"${printable(branch)}" is not a usable branch name.` };
|
|
259
332
|
|
|
260
333
|
const dirty = await git.isDirty();
|
|
261
334
|
if (!allowDirty && dirty) {
|
|
@@ -267,7 +340,8 @@ export async function preflight({ git, branch, allowDirty = false, create = true
|
|
|
267
340
|
if (!head) {
|
|
268
341
|
return { ok: false, reason: 'This repository has no commits yet, so there is nothing to branch from.' };
|
|
269
342
|
}
|
|
270
|
-
|
|
343
|
+
// Only a rerun after an unknown register keeps its branch (`run/pendingRegister.js`).
|
|
344
|
+
if (!reuseBranch && await git.branchExists(branch)) {
|
|
271
345
|
return { ok: false, reason: `The branch ${branch} already exists. Pass --branch <name> to use another one.` };
|
|
272
346
|
}
|
|
273
347
|
|
|
@@ -303,6 +377,17 @@ export async function startBranch({ git, branch }) {
|
|
|
303
377
|
return { ok: true, branch };
|
|
304
378
|
}
|
|
305
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Undo `startBranch`, when the job it was for never existed.
|
|
382
|
+
*
|
|
383
|
+
* @param {{git: Object, branch: string, from: string|null, head: string}} options
|
|
384
|
+
* @returns {Promise<void>}
|
|
385
|
+
*/
|
|
386
|
+
export async function abandonBranch({ git, branch, from, head }) {
|
|
387
|
+
const back = await git.git(from ? ['switch', from] : ['switch', '--detach', head]);
|
|
388
|
+
if (back.ok) await git.git(['branch', '-d', branch]);
|
|
389
|
+
}
|
|
390
|
+
|
|
306
391
|
/**
|
|
307
392
|
* Lines in a file the fix created. Read rather than counted with git, because
|
|
308
393
|
* git does not have a number for a file it has never seen.
|
|
@@ -324,8 +409,13 @@ async function countLines(absolute) {
|
|
|
324
409
|
}
|
|
325
410
|
}
|
|
326
411
|
|
|
412
|
+
function printable(text) {
|
|
413
|
+
/* eslint-disable-next-line no-control-regex */
|
|
414
|
+
return String(text).replace(/[\u0000-\u001f\u007f-\u009f]/g, '?');
|
|
415
|
+
}
|
|
416
|
+
|
|
327
417
|
function firstLine(text) {
|
|
328
418
|
return String(text || '').split('\n').map(line => line.trim()).find(Boolean) || 'git gave no reason';
|
|
329
419
|
}
|
|
330
420
|
|
|
331
|
-
export default { createGit, preflight, startBranch, fixBranchName, branchNameRefusal };
|
|
421
|
+
export default { abandonBranch, allIgnoredFiles, createGit, hooksDirectory, ignoredPaths, preflight, startBranch, fixBranchName, branchNameRefusal };
|
package/src/run/json.js
CHANGED
|
@@ -19,21 +19,77 @@ function unfence(text) {
|
|
|
19
19
|
return fenced ? fenced[1] : value;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** The most `{` positions tried per candidate text, so a pathological answer stays cheap. */
|
|
23
|
+
const MAX_OBJECT_STARTS = 200;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every balanced `{...}` span in a text, skipping braces inside strings.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} body
|
|
29
|
+
* @returns {Array<string>}
|
|
30
|
+
*/
|
|
31
|
+
function objectSpans(body) {
|
|
32
|
+
const spans = [];
|
|
33
|
+
let starts = 0;
|
|
34
|
+
for (let start = body.indexOf('{'); start !== -1 && starts < MAX_OBJECT_STARTS; start = body.indexOf('{', start + 1)) {
|
|
35
|
+
starts += 1;
|
|
36
|
+
let depth = 0;
|
|
37
|
+
let inString = false;
|
|
38
|
+
let escaped = false;
|
|
39
|
+
for (let i = start; i < body.length; i += 1) {
|
|
40
|
+
const char = body[i];
|
|
41
|
+
if (inString) {
|
|
42
|
+
if (escaped) escaped = false;
|
|
43
|
+
else if (char === '\\') escaped = true;
|
|
44
|
+
else if (char === '"') inString = false;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (char === '"') inString = true;
|
|
48
|
+
else if (char === '{') depth += 1;
|
|
49
|
+
else if (char === '}') {
|
|
50
|
+
depth -= 1;
|
|
51
|
+
if (depth === 0) {
|
|
52
|
+
spans.push(body.slice(start, i + 1));
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return spans;
|
|
59
|
+
}
|
|
60
|
+
|
|
22
61
|
/**
|
|
62
|
+
* The JSON object in a model answer: fenced or not, with prose before or
|
|
63
|
+
* after it, and with braces in that prose (a GDScript `{}` quoted in a
|
|
64
|
+
* sentence, a fence of another language first). Of every object that parses,
|
|
65
|
+
* the longest wins, since the report is the big one and a quoted example is
|
|
66
|
+
* small.
|
|
67
|
+
*
|
|
23
68
|
* @param {string} text
|
|
24
69
|
* @returns {Object|null} null when the answer holds no JSON object
|
|
25
70
|
*/
|
|
26
71
|
export function parseJsonObject(text) {
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
72
|
+
const value = String(text || '');
|
|
73
|
+
const candidates = [];
|
|
74
|
+
for (const match of value.matchAll(/```[A-Za-z0-9_-]*[ \t]*\n?([\s\S]*?)```/g)) candidates.push(match[1]);
|
|
75
|
+
candidates.push(value);
|
|
76
|
+
let best = null;
|
|
77
|
+
let bestLength = -1;
|
|
78
|
+
for (const candidate of candidates) {
|
|
79
|
+
for (const span of objectSpans(candidate)) {
|
|
80
|
+
if (span.length <= bestLength) continue;
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(span);
|
|
83
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
84
|
+
best = parsed;
|
|
85
|
+
bestLength = span.length;
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
// Not this span.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
36
91
|
}
|
|
92
|
+
return best;
|
|
37
93
|
}
|
|
38
94
|
|
|
39
95
|
/**
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { jobsDir } from './paths.js';
|
|
5
|
+
import * as stateFile from './state.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What became of a pull request this CLI opened with `--open-pr`, reported on the
|
|
9
|
+
* next run. Feedback for the job's `labs_outcome`, read by no money path.
|
|
10
|
+
*
|
|
11
|
+
* Best effort and never blocking: it starts in the background, every failure is
|
|
12
|
+
* swallowed, it has a hard time limit, and a report the server did not take is
|
|
13
|
+
* tried again on the run after. It runs `gh` only for a job whose local journal
|
|
14
|
+
* says this machine opened a pull request, so a job without one costs a directory
|
|
15
|
+
* read. A revert found this way is one made through a pull request: a direct push
|
|
16
|
+
* of a revert commit reads as merged.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const run = promisify(execFile);
|
|
20
|
+
|
|
21
|
+
/** A check of one pull request at most this long, and all of them together. */
|
|
22
|
+
const GH_TIMEOUT_MS = 5000;
|
|
23
|
+
const OVERALL_TIMEOUT_MS = 8000;
|
|
24
|
+
/** How many pull requests one run looks at, newest first. */
|
|
25
|
+
export const MAX_CHECKS_PER_RUN = 5;
|
|
26
|
+
/** A merged pull request can still be reverted, so it is followed this long. */
|
|
27
|
+
export const FOLLOW_DAYS = 90;
|
|
28
|
+
const FINAL = new Set(['reverted', 'abandoned']);
|
|
29
|
+
const PR_URL = /^https:\/\/[^/\s]+\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)$/;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {{state: string}} pr what `gh pr view` shows
|
|
33
|
+
* @param {boolean} reverted
|
|
34
|
+
* @returns {'open'|'merged'|'reverted'|'abandoned'|null}
|
|
35
|
+
*/
|
|
36
|
+
export function outcomeOf(pr, reverted) {
|
|
37
|
+
if (pr.state === 'OPEN') return 'open';
|
|
38
|
+
if (pr.state === 'MERGED') return reverted ? 'reverted' : 'merged';
|
|
39
|
+
if (pr.state === 'CLOSED') return 'abandoned';
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function gh(exec, args, signal) {
|
|
44
|
+
const { stdout } = await exec('gh', args, { timeout: GH_TIMEOUT_MS, maxBuffer: 1024 * 1024, signal });
|
|
45
|
+
return JSON.parse(String(stdout));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A merged pull request whose title or body says it reverts this one. */
|
|
49
|
+
async function wasReverted(exec, pr, [, owner, name, number], signal) {
|
|
50
|
+
const repo = `${owner}/${name}`;
|
|
51
|
+
const merged = await gh(exec, ['pr', 'list', '--repo', repo, '--state', 'merged', '--search', 'Revert in:title,body', '--json', 'title,body', '--limit', '30'], signal);
|
|
52
|
+
const byBody = new RegExp(`\\bReverts\\s+(${repo.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})?#${number}\\b`, 'i');
|
|
53
|
+
return merged.some(entry => entry.title === `Revert "${pr.title}"` || byBody.test(String(entry.body || '')));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Tell the server this job opened a pull request and remember the URL locally. The
|
|
58
|
+
* URL stays on this machine: the server stores the outcome only.
|
|
59
|
+
*/
|
|
60
|
+
export async function recordPrOpened({ api, gameId, repoRoot, journal, url, now = () => new Date() }) {
|
|
61
|
+
if (!url) return;
|
|
62
|
+
try {
|
|
63
|
+
journal.pull_request = { url, opened_at: now().toISOString(), reported: null };
|
|
64
|
+
await stateFile.save(repoRoot, journal);
|
|
65
|
+
await api.jobs.labsOutcomePr(gameId, journal.job_id, { pr: 'open' });
|
|
66
|
+
journal.pull_request.reported = 'open';
|
|
67
|
+
await stateFile.save(repoRoot, journal);
|
|
68
|
+
} catch {
|
|
69
|
+
// The next run says it again.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function reportOne({ api, gameId, repoRoot, journal, exec, signal }) {
|
|
74
|
+
const record = journal.pull_request;
|
|
75
|
+
const parts = PR_URL.exec(record.url);
|
|
76
|
+
const pr = await gh(exec, ['pr', 'view', record.url, '--json', 'state,title,number'], signal);
|
|
77
|
+
const reverted = pr.state === 'MERGED' ? await wasReverted(exec, pr, parts, signal) : false;
|
|
78
|
+
const outcome = outcomeOf(pr, reverted);
|
|
79
|
+
if (!outcome) return;
|
|
80
|
+
if (!record.reported) {
|
|
81
|
+
await api.jobs.labsOutcomePr(gameId, journal.job_id, { pr: 'open' });
|
|
82
|
+
record.reported = 'open';
|
|
83
|
+
await stateFile.save(repoRoot, journal);
|
|
84
|
+
}
|
|
85
|
+
if (outcome === record.reported) return;
|
|
86
|
+
await api.jobs.labsOutcomePr(gameId, journal.job_id, { pr: outcome });
|
|
87
|
+
record.reported = outcome;
|
|
88
|
+
if (FINAL.has(outcome)) record.done = true;
|
|
89
|
+
await stateFile.save(repoRoot, journal);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function followable(repoRoot, now) {
|
|
93
|
+
const cutoff = now().getTime() - FOLLOW_DAYS * 24 * 60 * 60 * 1000;
|
|
94
|
+
const found = [];
|
|
95
|
+
for (const jobId of await readdir(jobsDir(repoRoot))) {
|
|
96
|
+
const journal = await stateFile.load(repoRoot, jobId).catch(() => null);
|
|
97
|
+
const record = journal && journal.pull_request;
|
|
98
|
+
if (!record || record.done || !PR_URL.test(String(record.url))) continue;
|
|
99
|
+
const opened = Date.parse(record.opened_at);
|
|
100
|
+
if (!Number.isFinite(opened) || opened < cutoff) continue;
|
|
101
|
+
found.push({ journal, opened });
|
|
102
|
+
}
|
|
103
|
+
return found.sort((a, b) => b.opened - a.opened).slice(0, MAX_CHECKS_PER_RUN);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Look at every pull request this repository's jobs opened, and report what changed.
|
|
108
|
+
* Never rejects and never takes longer than its limit.
|
|
109
|
+
*
|
|
110
|
+
* @returns {Promise<void>}
|
|
111
|
+
*/
|
|
112
|
+
export async function reportPrOutcomes({ api, gameId, repoRoot, exec = run, now = () => new Date(), timeoutMs = OVERALL_TIMEOUT_MS }) {
|
|
113
|
+
const controller = new AbortController();
|
|
114
|
+
let timer;
|
|
115
|
+
const expired = new Promise(resolve => {
|
|
116
|
+
timer = setTimeout(() => {
|
|
117
|
+
controller.abort();
|
|
118
|
+
resolve();
|
|
119
|
+
}, timeoutMs);
|
|
120
|
+
});
|
|
121
|
+
const work = (async () => {
|
|
122
|
+
const entries = await followable(repoRoot, now);
|
|
123
|
+
for (const { journal } of entries) {
|
|
124
|
+
if (controller.signal.aborted) return;
|
|
125
|
+
try {
|
|
126
|
+
await reportOne({ api, gameId, repoRoot, journal, exec, signal: controller.signal });
|
|
127
|
+
} catch {
|
|
128
|
+
// No gh, not signed in, no network, a server without the route: next run.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
})().catch(() => {});
|
|
132
|
+
await Promise.race([work, expired]);
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export default { outcomeOf, recordPrOpened, reportPrOutcomes, MAX_CHECKS_PER_RUN, FOLLOW_DAYS };
|