ravensight-playtest 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +319 -14
  2. package/addons/ravensight_driver/driver.gd +406 -8
  3. package/addons/ravensight_simulate/simulate.gd +360 -0
  4. package/docs/godot-driver.md +70 -8
  5. package/package.json +4 -3
  6. package/src/api/README.md +4 -1
  7. package/src/api/client.js +120 -3
  8. package/src/api/errors.js +17 -1
  9. package/src/api/http.js +43 -17
  10. package/src/assets/formats.js +267 -0
  11. package/src/assets/index.js +260 -0
  12. package/src/assets/integration.js +429 -0
  13. package/src/assets/inventory.js +337 -0
  14. package/src/assets/measure.js +392 -0
  15. package/src/cli.js +233 -0
  16. package/src/commands/assets.js +62 -0
  17. package/src/commands/check.js +4 -1
  18. package/src/commands/create.js +734 -0
  19. package/src/commands/fakeCore.js +16 -1
  20. package/src/commands/feature.js +845 -0
  21. package/src/commands/fix.js +647 -70
  22. package/src/commands/init.js +19 -3
  23. package/src/commands/lists.js +206 -0
  24. package/src/commands/profile.js +25 -6
  25. package/src/commands/punch.js +116 -0
  26. package/src/commands/replay.js +204 -0
  27. package/src/commands/resume.js +7 -0
  28. package/src/commands/run.js +200 -24
  29. package/src/commands/tape.js +74 -0
  30. package/src/commands/template.js +70 -0
  31. package/src/commands/validate.js +414 -0
  32. package/src/commands/validateHosted.js +292 -0
  33. package/src/create/scaffold.js +192 -0
  34. package/src/run/acceptance.js +212 -0
  35. package/src/run/args.js +18 -1
  36. package/src/run/checks.js +705 -0
  37. package/src/run/dimension.js +95 -0
  38. package/src/run/drivers/godot-observation.js +59 -11
  39. package/src/run/drivers/godot-project.js +201 -31
  40. package/src/run/drivers/godot.js +532 -45
  41. package/src/run/drivers/web.js +18 -0
  42. package/src/run/evidence.js +170 -0
  43. package/src/run/featureLoop.js +288 -0
  44. package/src/run/fixLoop.js +82 -12
  45. package/src/run/git.js +110 -20
  46. package/src/run/json.js +65 -9
  47. package/src/run/labsOutcome.js +136 -0
  48. package/src/run/liveWork.js +103 -0
  49. package/src/run/model.js +105 -5
  50. package/src/run/openPr.js +107 -0
  51. package/src/run/paths.js +4 -1
  52. package/src/run/pendingRegister.js +81 -0
  53. package/src/run/personaLoop.js +111 -4
  54. package/src/run/profile.js +68 -7
  55. package/src/run/replay.js +236 -0
  56. package/src/run/repoTools.js +227 -27
  57. package/src/run/report.js +7 -1
  58. package/src/run/resume.js +19 -1
  59. package/src/run/secretScan.js +35 -0
  60. package/src/run/settle.js +41 -0
  61. package/src/run/tools.js +33 -4
  62. package/src/run/trace.js +124 -0
  63. package/src/simulation/godotPack.js +112 -0
  64. package/src/ui/index.js +61 -9
  65. package/src/upload/allowlist.js +6 -2
  66. package/src/upload/index.js +5 -4
  67. package/templates/godot-2d/README.md +36 -0
  68. package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
  69. package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
  70. package/templates/godot-2d/export_presets.cfg +27 -0
  71. package/templates/godot-2d/gitignore +10 -0
  72. package/templates/godot-2d/project.godot +58 -0
  73. package/templates/godot-2d/scenes/main.tscn +67 -0
  74. package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
  75. package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
  76. package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
  77. package/templates/godot-2d/scripts/main.gd +32 -0
  78. package/templates/godot-2d/scripts/player.gd +10 -0
  79. package/templates/godot-2d/scripts/state_hook.gd +16 -0
  80. package/templates/godot-2d/template.json +14 -0
  81. package/templates/godot-2d/tools/inject_release_key.sh +19 -0
  82. package/templates/godot-2d/tools/selftest.gd +70 -0
  83. package/templates/godot-3d/README.md +49 -0
  84. package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
  85. package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
  86. package/templates/godot-3d/export_presets.cfg +27 -0
  87. package/templates/godot-3d/gitignore +10 -0
  88. package/templates/godot-3d/project.godot +56 -0
  89. package/templates/godot-3d/scenes/main.tscn +154 -0
  90. package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
  91. package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
  92. package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
  93. package/templates/godot-3d/scripts/beacon.gd +20 -0
  94. package/templates/godot-3d/scripts/main.gd +49 -0
  95. package/templates/godot-3d/scripts/player.gd +19 -0
  96. package/templates/godot-3d/scripts/state_hook.gd +25 -0
  97. package/templates/godot-3d/template.json +16 -0
  98. package/templates/godot-3d/tools/inject_release_key.sh +19 -0
  99. package/templates/godot-3d/tools/selftest.gd +77 -0
@@ -0,0 +1,103 @@
1
+ import { rmSync } from 'node:fs';
2
+
3
+ /**
4
+ * What this process has running or lying around right now: Godot processes and the
5
+ * temp copies they run on. Each is removed by its owner in the ordinary course of
6
+ * things. This list is for the rest: a second Ctrl-C, a SIGTERM, a `process.exit`.
7
+ * Death by a signal runs no `finally` and no exit handler, and a check's process group
8
+ * does not hear the terminal's Ctrl-C at all, so without this a killed CLI leaves a
9
+ * Godot running and a copy of the project in the temp directory.
10
+ */
11
+ const live = new Set();
12
+ const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'];
13
+ let installed = false;
14
+
15
+ function onExit() {
16
+ finishAll();
17
+ }
18
+
19
+ let claimed = false;
20
+
21
+ /**
22
+ * A handler has taken this signal, even if it has already stepped aside. A listener
23
+ * that removes itself during the signal is still the one that answered it, and the
24
+ * count below cannot see that.
25
+ */
26
+ export function claimSignal() {
27
+ claimed = true;
28
+ setImmediate(() => { claimed = false; });
29
+ }
30
+
31
+ /** A signal nobody else is handling: tidy up, then die of it as the shell expects. */
32
+ function onSignal(signal) {
33
+ if (claimed || process.listenerCount(signal) > 1) return;
34
+ dieOf(signal);
35
+ }
36
+
37
+ /**
38
+ * @param {string} signal
39
+ * @param {Object} [target] test seam
40
+ */
41
+ export function dieOf(signal, target = process) {
42
+ finishAll();
43
+ for (const name of SIGNALS) process.removeListener(name, onSignal);
44
+ target.kill(target.pid, signal);
45
+ }
46
+
47
+ function install() {
48
+ if (installed) return;
49
+ installed = true;
50
+ process.on('exit', onExit);
51
+ for (const name of SIGNALS) process.on(name, onSignal);
52
+ }
53
+
54
+ function uninstall() {
55
+ if (!installed || live.size > 0) return;
56
+ installed = false;
57
+ process.removeListener('exit', onExit);
58
+ for (const name of SIGNALS) process.removeListener(name, onSignal);
59
+ }
60
+
61
+ /**
62
+ * @param {{stop?: () => void, kill?: () => void, root?: string|null|(() => Array<string>)}} entry `stop` also runs on
63
+ * a first Ctrl-C and `kill` only when the process is going away; `root` is what to remove then
64
+ * @returns {() => void} call when the work is over
65
+ */
66
+ export function track(entry) {
67
+ live.add(entry);
68
+ install();
69
+ return () => {
70
+ live.delete(entry);
71
+ uninstall();
72
+ };
73
+ }
74
+
75
+ /** Stop every process, leaving their temp copies for their owners to remove. */
76
+ export function stopAll() {
77
+ for (const entry of live) {
78
+ try {
79
+ entry.stop?.();
80
+ } catch {
81
+ // Already gone.
82
+ }
83
+ }
84
+ }
85
+
86
+ /** Stop every process and remove every temp copy, synchronously. */
87
+ export function finishAll() {
88
+ stopAll();
89
+ for (const entry of live) {
90
+ try {
91
+ entry.kill?.();
92
+ const roots = typeof entry.root === 'function' ? entry.root() : [entry.root];
93
+ for (const root of roots) {
94
+ if (root) rmSync(root, { recursive: true, force: true });
95
+ }
96
+ } catch {
97
+ // Nothing more can be done from here.
98
+ }
99
+ }
100
+ live.clear();
101
+ }
102
+
103
+ export default { track, stopAll, finishAll, dieOf, claimSignal };
package/src/run/model.js CHANGED
@@ -54,7 +54,15 @@ export const STEPS = Object.freeze({
54
54
  // allowlist includes `write_file`.
55
55
  fixPlan: 'fix.plan',
56
56
  fixApply: 'fix.apply',
57
- fixSummarize: 'fix.summarize'
57
+ fixSummarize: 'fix.summarize',
58
+ // The `feature` module's three (Labs beta), spendable only from a run whose
59
+ // module is `feature`. `feature.apply` has the same tools as `fix.apply`.
60
+ featurePlan: 'feature.plan',
61
+ featureApply: 'feature.apply',
62
+ featureSummarize: 'feature.summarize',
63
+ // The `prototype` module's one (Labs, Experimental): the game design. Its only
64
+ // tools answer the design or decline the brief, so it cannot touch anything.
65
+ prototypeDesign: 'prototype.design'
58
66
  });
59
67
 
60
68
  /** Default retry budget for the answers that are worth retrying. */
@@ -63,6 +71,9 @@ export const MAX_ATTEMPTS = 4;
63
71
  /** Codes the proxy raises that a retry can actually clear. */
64
72
  const RETRYABLE_CODES = new Set(['concurrency_limited', 'velocity_limited', 'rate_limited', 'upstream_error']);
65
73
 
74
+ /** What the SDK calls an answer that never came back. */
75
+ const CONNECTION_ERRORS = new Set(['APIConnectionError', 'APIConnectionTimeoutError']);
76
+
66
77
  export class ProxyError extends Error {
67
78
  /**
68
79
  * @param {{status: number, code: string, message: string, retryAfter?: number|null, figures?: Object}} init
@@ -92,7 +103,11 @@ export class ProxyError extends Error {
92
103
  }
93
104
 
94
105
  get isRetryable() {
95
- if (this.status === 502) return true;
106
+ if (this.status === 502 || this.status === 503 || this.status === 504) return true;
107
+ // No status at all: the answer never arrived. A lost call costs the run's model
108
+ // budget at most, never a charge, and one dropped connection should not end a
109
+ // job the developer paid for. Any other statusless error is a defect here.
110
+ if (this.status === 0) return CONNECTION_ERRORS.has(this.code);
96
111
  return this.status === 429 && RETRYABLE_CODES.has(this.code);
97
112
  }
98
113
  }
@@ -330,6 +345,11 @@ export async function callStep({
330
345
  toolUses.push({ id: block.id, name: block.name, input: null, malformed: true, raw: json });
331
346
  continue;
332
347
  }
348
+ // The same for JSON that parses into something no tool takes.
349
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
350
+ toolUses.push({ id: block.id, name: block.name, input: null, malformed: true, raw: json });
351
+ continue;
352
+ }
333
353
  }
334
354
  toolUses.push({ id: block.id, name: block.name, input, malformed: false });
335
355
  }
@@ -337,6 +357,68 @@ export async function callStep({
337
357
  return { text, toolUses, stopReason, usage, model, tier, budget };
338
358
  }
339
359
 
360
+ /**
361
+ * THE HOURLY SPEND HOLD. The proxy refuses a call once a studio has spent its
362
+ * hourly model limit, and that refusal costs nothing. A large feature's budget is
363
+ * bigger than one hour's limit, so this is an ordinary thing to meet on a job the
364
+ * customer paid for, and failing the job over it would throw away work that one
365
+ * wait would save. So the call waits, says how long, and carries on, and the
366
+ * attempt count does not move.
367
+ *
368
+ * Bounded: at most `MAX_SPEND_HOLDS` hours in one call, because a studio whose own
369
+ * parallel work keeps refilling the window should hear about it rather than wait
370
+ * forever. Stoppable: `shouldStop` is checked every `SPEND_HOLD_POLL_MS`, which is
371
+ * also how often the call is re-asked, so a cap that clears early resumes early.
372
+ * The limit itself is the server's and nothing here can change it. The job's
373
+ * heartbeat keeps running during a hold, so a paused run is not read as a dead one.
374
+ */
375
+ export const MAX_SPEND_HOLDS = 4;
376
+ export const SPEND_HOLD_POLL_MS = 60000;
377
+
378
+ const spendHold = { log: message => process.stderr.write(`${message}\n`), shouldStop: () => false, pollMs: null };
379
+
380
+ /**
381
+ * Who hears about a hold, and what stops one. Set by the command that owns the
382
+ * interrupt; a call with neither still pauses and prints to stderr.
383
+ *
384
+ * @param {{log?: (message: string) => void, shouldStop?: () => boolean, pollMs?: number|null}} [options]
385
+ */
386
+ export function configureSpendHold({ log, shouldStop, pollMs } = {}) {
387
+ if (log) spendHold.log = log;
388
+ if (shouldStop) spendHold.shouldStop = shouldStop;
389
+ if (pollMs !== undefined) spendHold.pollMs = pollMs;
390
+ }
391
+
392
+ /** The poll interval: this module's, or the local harness's by environment. */
393
+ function holdPollMs() {
394
+ if (spendHold.pollMs) return spendHold.pollMs;
395
+ const fromEnv = Number(process.env.RAVENSIGHT_SPEND_HOLD_POLL_MS);
396
+ return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : SPEND_HOLD_POLL_MS;
397
+ }
398
+
399
+ /** Plain words for how long a hold is. */
400
+ export function holdWords(seconds) {
401
+ if (seconds < 60) return `${Math.max(1, Math.round(seconds))} seconds`;
402
+ const minutes = Math.ceil(seconds / 60);
403
+ return minutes === 1 ? 'about a minute' : `about ${minutes} minutes`;
404
+ }
405
+
406
+ /**
407
+ * Wait out one hold, re-asking each poll. Answers false when told to stop.
408
+ *
409
+ * @param {ProxyError} error
410
+ * @param {Function} [sleep]
411
+ * @returns {Promise<boolean>}
412
+ */
413
+ async function holdFor(error, sleep) {
414
+ const seconds = Number(error.figures.hold_seconds);
415
+ const limit = Number(error.figures.limit_usd);
416
+ const at = typeof error.figures.resets_at === 'string' ? new Date(error.figures.resets_at).toISOString().slice(11, 16) : null;
417
+ spendHold.log(`Paused: this studio reached its hourly model spend limit${Number.isFinite(limit) ? ` of $${limit}` : ''}. Waiting ${holdWords(seconds)}${at ? `, until ${at} UTC` : ''}, then carrying on. Nothing is lost and nothing is charged for the wait. Ctrl-C stops the job and gives back what did not happen.`);
418
+ await wait(Math.min(seconds * 1000, holdPollMs()), sleep);
419
+ return !spendHold.shouldStop();
420
+ }
421
+
340
422
  /**
341
423
  * @param {number} ms
342
424
  * @param {Function} [sleep]
@@ -350,8 +432,8 @@ function wait(ms, sleep) {
350
432
  * `callStep` with the retries that are worth taking, and none of the ones
351
433
  * that are not.
352
434
  *
353
- * Retried: 502 `upstream_error`, and a 429 whose code says the wait is the
354
- * fix (`concurrency_limited`, `velocity_limited`, `rate_limited`). The
435
+ * Retried: a 502, 503 or 504, a connection that dropped or timed out, and a 429
436
+ * whose code says the wait is the fix (`concurrency_limited`, `velocity_limited`, `rate_limited`). The
355
437
  * proxy's own `retry_after` is honoured when it sends one, since it knows
356
438
  * how long its hourly window or its upstream asked for.
357
439
  *
@@ -369,6 +451,9 @@ function wait(ms, sleep) {
369
451
  export async function callStepWithRetry(args) {
370
452
  const { maxAttempts = MAX_ATTEMPTS, onRetry, sleep, ...rest } = args;
371
453
  let last = null;
454
+ let polls = 0;
455
+ let holdUntil = 0;
456
+ let holds = 0;
372
457
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
373
458
  try {
374
459
  return await callStep(rest);
@@ -376,6 +461,21 @@ export async function callStepWithRetry(args) {
376
461
  if (error instanceof ProxyError && !error.isRetryable) throw error;
377
462
  if (error && (error.name === 'AbortError' || error.name === 'APIUserAbortError')) throw error;
378
463
  last = error;
464
+ // The spend hold is not an attempt. See above.
465
+ if (error instanceof ProxyError && error.code === 'velocity_limited' && Number(error.figures.hold_seconds) > 0) {
466
+ const now = Date.now();
467
+ if (now >= holdUntil) {
468
+ holds += 1;
469
+ holdUntil = now + Number(error.figures.hold_seconds) * 1000;
470
+ if (holds <= MAX_SPEND_HOLDS && await holdFor(error, sleep)) { attempt -= 1; continue; }
471
+ break;
472
+ }
473
+ polls += 1;
474
+ await wait(Math.min(Math.max(holdUntil - now, 1), holdPollMs()), sleep);
475
+ if (spendHold.shouldStop()) break;
476
+ attempt -= 1;
477
+ continue;
478
+ }
379
479
  if (attempt === maxAttempts) break;
380
480
  const suggested = error instanceof ProxyError && error.retryAfter ? error.retryAfter * 1000 : null;
381
481
  const backoff = suggested ?? Math.min(1000 * 2 ** (attempt - 1), 30000);
@@ -384,7 +484,7 @@ export async function callStepWithRetry(args) {
384
484
  }
385
485
  }
386
486
  const reason = last instanceof ProxyError ? `${last.status} ${last.code}: ${last.message}` : String(last && last.message);
387
- throw new ModelUnavailableError(`The model proxy did not answer after ${maxAttempts} attempts (${reason}).`, last);
487
+ throw new ModelUnavailableError(`The model proxy did not answer after ${maxAttempts} attempts (${reason}${polls ? `, after waiting out the hourly limit` : ''}).`, last);
388
488
  }
389
489
 
390
490
  export default { createModelClient, callStep, callStepWithRetry, STEPS, ProxyError, ModelUnavailableError };
@@ -0,0 +1,107 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ /**
5
+ * `fix --open-pr`: commit the fix's own files, push the branch and open a pull
6
+ * request with the evidence report as its body.
7
+ *
8
+ * This is the one place this CLI pushes anything, and it is fenced:
9
+ *
10
+ * - only with `--open-pr`, only after a fix the verify run scored as fixed,
11
+ * and only after the developer answers yes at a prompt (`--yes` does not
12
+ * count: it agrees to a price, not to publishing code);
13
+ * - `gh auth status` has to pass before anything is committed;
14
+ * - only the files the fix changed are committed, by path;
15
+ * - the push is a plain `git push -u origin <branch>`, never forced, and
16
+ * only the branch the fix created, which must be the one checked out.
17
+ *
18
+ * Every command goes through `execFile`, never a shell.
19
+ */
20
+
21
+ const run = promisify(execFile);
22
+
23
+ /**
24
+ * Single quote a value for a command a person pastes into a shell. The title is
25
+ * the model's, so nothing inside it may expand or run.
26
+ *
27
+ * @param {*} value
28
+ * @returns {string}
29
+ */
30
+ export function shellQuote(value) {
31
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
32
+ }
33
+
34
+ /**
35
+ * A pull request title is the model's text. One line, no control characters: a
36
+ * newline would turn the rest into the commit's body and its trailers.
37
+ *
38
+ * @param {*} value
39
+ * @returns {string}
40
+ */
41
+ export function plainTitle(value) {
42
+ /* eslint-disable-next-line no-control-regex */
43
+ return String(value).replace(/[\u0000-\u001f\u007f-\u009f]+/g, match => (/[\n\r\t]/.test(match) ? ' ' : '')).replace(/ +/g, ' ').trim();
44
+ }
45
+
46
+ /**
47
+ * @param {Object} args
48
+ * @param {string} args.repoRoot
49
+ * @param {string} args.branch
50
+ * @param {Array<string>} args.paths the files the fix changed, repository relative
51
+ * @param {string} args.title
52
+ * @param {string} args.bodyFile the evidence report
53
+ * @param {(question: string, options: Object) => Promise<boolean>} args.confirm
54
+ * @param {Function} [args.exec] test seam, shaped like promisified execFile
55
+ * @returns {Promise<{ok: boolean, url?: string, reason?: string}>}
56
+ */
57
+ export async function openPullRequest({ repoRoot, branch, paths, title: rawTitle, bodyFile, confirm, exec = run, log = () => {} }) {
58
+ const call = async (command, args) => {
59
+ try {
60
+ // Literal, so a file named `*` or `:(top)` commits that file. As a pathspec the
61
+ // first of those swept every other change in the tree into the pull request.
62
+ const { stdout } = await exec(command, args, { cwd: repoRoot, maxBuffer: 1024 * 1024, env: { ...process.env, GIT_LITERAL_PATHSPECS: '1' } });
63
+ return { ok: true, stdout: String(stdout || '') };
64
+ } catch (error) {
65
+ return { ok: false, stdout: String((error && error.stdout) || ''), stderr: String((error && error.stderr) || (error && error.message) || '') };
66
+ }
67
+ };
68
+ const refuse = reason => {
69
+ log(`${reason} Nothing was pushed.`);
70
+ return { ok: false, reason };
71
+ };
72
+
73
+ if (!Array.isArray(paths) || paths.length === 0) return refuse('There are no changed files to commit.');
74
+ const title = plainTitle(rawTitle);
75
+ const current = await call('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
76
+ if (!current.ok || current.stdout.trim() !== branch) return refuse(`The checked out branch is not ${branch}, so --open-pr will not push it.`);
77
+
78
+ const auth = await call('gh', ['auth', 'status']);
79
+ if (!auth.ok) return refuse('gh is not installed or not signed in ("gh auth status" failed). Run "gh auth login" first.');
80
+
81
+ if (typeof confirm !== 'function') return refuse('--open-pr asks before it pushes, and there is no terminal to ask in.');
82
+ const question = [
83
+ `Commit ${paths.length} file(s) on ${branch}, push it to origin and open a pull request?`,
84
+ ` title: ${title}`,
85
+ ...paths.map(file => ` ${plainTitle(file)}`)
86
+ ].join('\n');
87
+ // `yes: false` on purpose: --yes agreed to the price, not to publishing.
88
+ const go = await confirm(question, { yes: false });
89
+ if (!go) return refuse('Not confirmed.');
90
+
91
+ const added = await call('git', ['add', '--', ...paths]);
92
+ if (!added.ok) return refuse(`git add failed: ${added.stderr.trim()}`);
93
+ const committed = await call('git', ['commit', '-m', title, '--only', '--', ...paths]);
94
+ if (!committed.ok) return refuse(`git commit failed: ${committed.stderr.trim()}`);
95
+ const pushed = await call('git', ['push', '-u', 'origin', branch]);
96
+ if (!pushed.ok) return refuse(`git push failed: ${pushed.stderr.trim()} The commit is on ${branch} locally.`);
97
+ const created = await call('gh', ['pr', 'create', '--head', branch, '--title', title, '--body-file', bodyFile]);
98
+ if (!created.ok) {
99
+ log(`gh pr create failed: ${created.stderr.trim()} The branch ${branch} is pushed; open the pull request by hand.`);
100
+ return { ok: false, reason: 'gh pr create failed' };
101
+ }
102
+ const url = created.stdout.trim().split('\n').filter(Boolean).at(-1) || '';
103
+ log(`Pushed ${branch} and opened ${url || 'the pull request'}.`);
104
+ return { ok: true, url };
105
+ }
106
+
107
+ export default { openPullRequest };
package/src/run/paths.js CHANGED
@@ -66,7 +66,10 @@ export const RUN_FILES = Object.freeze({
66
66
  reportJson: 'report.json',
67
67
  usageJson: 'usage.json',
68
68
  transcript: 'transcript.jsonl',
69
- video: 'session.webm'
69
+ video: 'session.webm',
70
+ // Local only (not on the upload allowlist): the screenshot evidence the
71
+ // runner attached to findings, so the aggregate does not cluster on it.
72
+ runnerEvidence: 'runner-evidence.json'
70
73
  });
71
74
 
72
75
  export const JOB_FILES = Object.freeze({
@@ -0,0 +1,81 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * A register whose answer never arrived, kept until a later run gets one.
7
+ *
8
+ * Fix and feature run on a branch and throw their key away when a register fails, which
9
+ * is right for a refusal and wrong for no answer: the debit may have landed, and the only
10
+ * way back to that job is the same key. So the key is on disk before the request goes,
11
+ * and the same command finds it again and replays one job instead of buying a second.
12
+ */
13
+ const directory = repoRoot => path.join(repoRoot, '.ravensight', 'pending-register');
14
+ const fileFor = (repoRoot, { kind, gameId, target }) =>
15
+ path.join(directory(repoRoot), `${createHash('sha256').update(JSON.stringify([kind, gameId, target])).digest('hex').slice(0, 32)}.json`);
16
+
17
+ /**
18
+ * Whether a failed register leaves the money unknown. A 4xx is the server saying no,
19
+ * except `charge_in_progress`, which means it cannot tell yet.
20
+ * @param {unknown} error
21
+ * @returns {boolean}
22
+ */
23
+ export function outcomeUnknown(error) {
24
+ if (!error || !Number.isInteger(error.status)) return false;
25
+ return error.code === 'network_error' || error.code === 'charge_in_progress' || error.status === 0 || error.status >= 500;
26
+ }
27
+
28
+ /**
29
+ * The line that tells the developer what they are left with.
30
+ * @param {string} what - "this fix"
31
+ * @param {string} branch
32
+ * @returns {string}
33
+ */
34
+ export function unknownOutcomeMessage(what, branch) {
35
+ return `The server gave no clear answer, so ${what} may have been charged. The branch ${branch} and this request's key are kept. Run the same command again: it asks about this same request, so it answers with the job if there is one and does not charge twice.`;
36
+ }
37
+
38
+ /**
39
+ * @param {string} repoRoot
40
+ * @param {{kind: string, gameId: string, target: string}} scope
41
+ * @returns {Promise<{idempotencyKey: string, branch: string, from: string|null, head: string}|null>}
42
+ */
43
+ export async function load(repoRoot, scope) {
44
+ try {
45
+ const record = JSON.parse(await readFile(fileFor(repoRoot, scope), 'utf8'));
46
+ return typeof record.idempotencyKey === 'string' && typeof record.branch === 'string' ? record : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * @param {string} repoRoot
54
+ * @param {{kind: string, gameId: string, target: string}} scope
55
+ * @param {{idempotencyKey: string, branch: string, from: string|null, head: string}} record
56
+ * @returns {Promise<void>}
57
+ */
58
+ export async function save(repoRoot, scope, record) {
59
+ await mkdir(directory(repoRoot), { recursive: true });
60
+ await writeFile(fileFor(repoRoot, scope), JSON.stringify(record), { mode: 0o600 });
61
+ }
62
+
63
+ /**
64
+ * @param {string} repoRoot
65
+ * @param {{kind: string, gameId: string, target: string}} scope
66
+ * @returns {Promise<void>}
67
+ */
68
+ export async function clear(repoRoot, scope) {
69
+ await rm(fileFor(repoRoot, scope), { force: true });
70
+ }
71
+
72
+ /**
73
+ * Get onto the kept branch for a rerun: it may have been switched away from or deleted.
74
+ * @param {{git: Object, branch: string}} options
75
+ * @returns {Promise<{ok: true}|{ok: false, reason: string}>}
76
+ */
77
+ export async function returnToBranch({ git, branch }) {
78
+ if ((await git.currentBranch()) === branch) return { ok: true };
79
+ const back = await git.git(['switch', ...(await git.branchExists(branch) ? [] : ['-c']), branch]);
80
+ return back.ok ? { ok: true } : { ok: false, reason: `The branch ${branch} from the earlier attempt could not be checked out. Nothing more was charged.` };
81
+ }
@@ -217,6 +217,7 @@ export async function runPersona(args) {
217
217
  // game", and a launch that hangs or dies should leave the server holding
218
218
  // that rather than still holding `queued`.
219
219
  await advance('launching');
220
+ if ('traceSecrets' in driver) driver.traceSecrets = secretsOf(localBrief);
220
221
  await driver.launch({ target, runDir: runDirectory, recordVideo, viewport, signal: args.signal });
221
222
  await advance('playing');
222
223
 
@@ -239,6 +240,7 @@ export async function runPersona(args) {
239
240
 
240
241
  const endedAt = new Date().toISOString();
241
242
  await driverStop(driver, outcome, log, play.canceled ? 'canceled' : 'completed');
243
+ if (typeof driver.playMetrics === 'function') outcome.playMetrics = driver.playMetrics();
242
244
 
243
245
  if (args.signal?.aborted || heartbeat?.canceled) play.canceled = true;
244
246
  if (play.canceled) {
@@ -413,7 +415,7 @@ async function playLoop({
413
415
  // blocker.
414
416
  let awaitingAfter = [];
415
417
  const observed = observation => {
416
- const entry = { index: observations.length + 1, step: observation.step, url: observation.url || null, title: observationTitle(observation) };
418
+ const entry = observationEntry(observation, observations.length + 1);
417
419
  observations.push(entry);
418
420
  for (const note of awaitingAfter) note.after = { url: entry.url, title: entry.title };
419
421
  awaitingAfter = [];
@@ -543,6 +545,14 @@ async function playLoop({
543
545
  for (const toolUse of answer.toolUses) {
544
546
  if (argsSignal?.aborted || heartbeat?.canceled) { canceled = true; quitReason = 'error'; quitDetail = 'the job was cancelled'; break; }
545
547
  if (selection && toolUse.id !== selection.toolId) { results.push({ type: 'tool_result', tool_use_id: toolUse.id, content: 'Alternative not selected. No action executed.' }); continue; }
548
+ // One turn can send many actions, and the budget is a promise to the
549
+ // developer about all of them, so it is checked per action. Every tool_use
550
+ // still needs its tool_result, or the next message would be refused.
551
+ if (ACTION_TOOLS.includes(toolUse.name) && actions >= maxActions) {
552
+ results.push({ type: 'tool_result', tool_use_id: toolUse.id, content: `Not played: the action budget of ${maxActions} has run out.` });
553
+ transcript.write({ kind: 'action_not_played', tool: toolUse.name, reason: 'action_budget' });
554
+ continue;
555
+ }
546
556
  const notesBefore = notes.length;
547
557
  const executed = await executeTool({
548
558
  driver,
@@ -1012,6 +1022,15 @@ async function reportPhase({
1012
1022
  markdown = stampGeneratedAt(markdown, generatedAt, facts);
1013
1023
  // The same slots in the prose the dashboard shows from report.json.
1014
1024
  report = fillReportPlaceholders(report, { generatedAt, ...facts });
1025
+ // A finding the server would drop for want of an uploaded file it can
1026
+ // open gets the screenshot taken where it happened.
1027
+ const withShots = attachScreenshotEvidence(report, { screenshots, notes });
1028
+ if (withShots.attached.length > 0) {
1029
+ report = withShots.report;
1030
+ transcript.write({ kind: 'evidence_attached', findings: withShots.attached });
1031
+ await writeFile(path.join(runDirectory, RUN_FILES.runnerEvidence), `${JSON.stringify({ attached: withShots.attached }, null, 2)}\n`, 'utf8');
1032
+ log(`attached a screenshot as evidence to ${withShots.attached.length} finding(s) that cited none: ${withShots.attached.map(entry => `${entry.id} (${entry.ref})`).join(', ')}`);
1033
+ }
1015
1034
  // Whatever the model wrote, the password does not leave in a report.
1016
1035
  markdown = scrubSecrets(markdown, secrets);
1017
1036
  report = scrubSecrets(report, secrets);
@@ -1466,9 +1485,16 @@ export function redactToolInput(toolUse, secrets = []) {
1466
1485
  return scrubSecrets(rest, secrets);
1467
1486
  }
1468
1487
 
1469
- async function safeScreenshot(driver, name, screenshots, transcript, observation = null) {
1488
+ export async function safeScreenshot(driver, name, screenshots, transcript, observation = null) {
1470
1489
  try {
1471
1490
  const shot = await driver.screenshot(name);
1491
+ // A driver that cannot screenshot here (headless Godot answers
1492
+ // `{ path: null, skipped }`) saved nothing. Listing it anyway is how a
1493
+ // report came to cite "screenshots/undefined" and "- undefined".
1494
+ if (!shot || !shot.relativePath) {
1495
+ transcript.write({ kind: 'screenshot_skipped', name, reason: (shot && shot.skipped) || 'no file' });
1496
+ return null;
1497
+ }
1472
1498
  screenshots.push({ ...shot, reason: name, ...(Number.isInteger(observation) ? { observation } : {}), order: nextOrder() });
1473
1499
  transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: name });
1474
1500
  return shot;
@@ -1769,14 +1795,95 @@ export function screenshotLine(shot) {
1769
1795
  /** The most observations listed in the report prompt; the newest are kept. */
1770
1796
  export const MAX_OBSERVATIONS_IN_REPORT = 200;
1771
1797
 
1772
- function observationLines(observations) {
1798
+ /** The most characters of one observation's game state kept for the report. */
1799
+ export const MAX_STATE_IN_REPORT_CHARS = 600;
1800
+
1801
+ /** The most characters of game state in the whole report prompt; newest first. */
1802
+ export const REPORT_STATE_BUDGET_CHARS = 24000;
1803
+
1804
+ /**
1805
+ * What the report step keeps of one observation: its index, step, URL,
1806
+ * title and, when the game exposes `_ravensight_state()`, that state. On
1807
+ * godot_driver the title is always the scene path, so without the state the
1808
+ * report writer saw sixty identical lines and concluded the game showed no
1809
+ * ember, room or deaths.
1810
+ *
1811
+ * @param {Object} observation
1812
+ * @param {number} index
1813
+ * @returns {Object}
1814
+ */
1815
+ export function observationEntry(observation, index) {
1816
+ const entry = { index, step: observation.step, url: observation.url || null, title: observationTitle(observation) };
1817
+ const state = typeof observation.gameStateText === 'string' ? observation.gameStateText.split('\n').map(line => line.trim()).filter(Boolean).join(' ; ') : '';
1818
+ if (state) entry.state = state.length > MAX_STATE_IN_REPORT_CHARS ? `${state.slice(0, MAX_STATE_IN_REPORT_CHARS)}... [cut]` : state;
1819
+ return entry;
1820
+ }
1821
+
1822
+ export function observationLines(observations) {
1773
1823
  if (!observations || observations.length === 0) return '(none)';
1774
1824
  const shown = observations.slice(-MAX_OBSERVATIONS_IN_REPORT);
1775
- const lines = shown.map(entry => `observation ${entry.index}: step ${entry.step}${entry.url ? ` ${entry.url}` : ''}${entry.title ? ` "${entry.title}"` : ''}${entry.driver_error ? ` [driver error: ${entry.driver_error}]` : ''}`);
1825
+ // Game state for the newest observations first, until the budget is spent.
1826
+ let budget = REPORT_STATE_BUDGET_CHARS;
1827
+ let withState = 0;
1828
+ const states = new Map();
1829
+ for (let i = shown.length - 1; i >= 0; i -= 1) {
1830
+ const state = shown[i].state;
1831
+ if (!state) continue;
1832
+ if (state.length > budget) break;
1833
+ budget -= state.length;
1834
+ states.set(shown[i], state);
1835
+ withState += 1;
1836
+ }
1837
+ const lines = shown.map(entry => {
1838
+ const head = `observation ${entry.index}: step ${entry.step}${entry.url ? ` ${entry.url}` : ''}${entry.title ? ` "${entry.title}"` : ''}${entry.driver_error ? ` [driver error: ${entry.driver_error}]` : ''}`;
1839
+ return states.has(entry) ? `${head}\n game state: ${states.get(entry)}` : head;
1840
+ });
1841
+ const statefulCount = shown.filter(entry => entry.state).length;
1842
+ if (withState < statefulCount) lines.unshift(`(game state is shown for the newest ${withState} of ${statefulCount} observations that have one)`);
1776
1843
  if (shown.length < observations.length) lines.unshift(`(the first ${observations.length - shown.length} are left out)`);
1777
1844
  return lines.join('\n');
1778
1845
  }
1779
1846
 
1847
+ /**
1848
+ * Give every finding that cites no screenshot this run has the screenshot
1849
+ * nearest after the latest observation it cites (a `note N` counts as that
1850
+ * note's observation), else the last screenshot. The server keeps a finding
1851
+ * only when one of its evidence refs names a file the run uploaded, and the
1852
+ * transcript is uploaded only on `--upload-transcript`, so a finding that
1853
+ * cited only `observation N` was dropped however real it was. Screenshots are
1854
+ * uploaded by default, and this one was taken at that point of the run.
1855
+ *
1856
+ * @param {Object} report
1857
+ * @param {{screenshots: Array<Object>, notes?: Array<Object>}} context
1858
+ * @returns {{report: Object, attached: Array<{id: string, ref: string}>}}
1859
+ */
1860
+ export function attachScreenshotEvidence(report, { screenshots = [], notes = [] } = {}) {
1861
+ const usable = (screenshots || []).filter(shot => shot && typeof shot.relativePath === 'string' && shot.relativePath);
1862
+ if (!report || !Array.isArray(report.findings) || usable.length === 0) return { report, attached: [] };
1863
+ const known = new Set(usable.map(shot => shot.relativePath));
1864
+ const attached = [];
1865
+ const findings = report.findings.map(finding => {
1866
+ const evidence = Array.isArray(finding && finding.evidence) ? finding.evidence : [];
1867
+ if (evidence.some(item => item && item.type === 'screenshot' && known.has(item.ref))) return finding;
1868
+ let latest = null;
1869
+ for (const item of evidence) {
1870
+ if (!item || item.type !== 'transcript_step') continue;
1871
+ const ref = String(item.ref || '');
1872
+ const observation = ref.match(/^observation\s+(\d+)$/i);
1873
+ const note = ref.match(/^note\s+(\d+)$/i);
1874
+ const at = observation ? Number(observation[1]) : note && notes[Number(note[1]) - 1] ? Number(notes[Number(note[1]) - 1].observation) : null;
1875
+ if (Number.isFinite(at) && (latest === null || at > latest)) latest = at;
1876
+ }
1877
+ const after = latest === null ? [] : usable.filter(shot => Number.isFinite(shot.observation) && shot.observation >= latest);
1878
+ const shot = after.length > 0
1879
+ ? after.reduce((best, candidate) => (candidate.observation < best.observation || (candidate.observation === best.observation && (candidate.order ?? 0) < (best.order ?? 0)) ? candidate : best))
1880
+ : usable[usable.length - 1];
1881
+ attached.push({ id: finding.id, ref: shot.relativePath });
1882
+ return { ...finding, evidence: [...evidence, { type: 'screenshot', ref: shot.relativePath }] };
1883
+ });
1884
+ return { report: { ...report, findings }, attached };
1885
+ }
1886
+
1780
1887
  /**
1781
1888
  * A short title for an observation: the page title when the driver has one,
1782
1889
  * otherwise the first line of what was observed.