ravensight-playtest 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +319 -14
- package/addons/ravensight_driver/driver.gd +406 -8
- package/addons/ravensight_simulate/simulate.gd +360 -0
- package/docs/godot-driver.md +70 -8
- package/package.json +4 -3
- package/src/api/README.md +4 -1
- package/src/api/client.js +120 -3
- package/src/api/errors.js +17 -1
- package/src/api/http.js +43 -17
- package/src/assets/formats.js +267 -0
- package/src/assets/index.js +260 -0
- package/src/assets/integration.js +429 -0
- package/src/assets/inventory.js +337 -0
- package/src/assets/measure.js +392 -0
- package/src/cli.js +233 -0
- package/src/commands/assets.js +62 -0
- package/src/commands/check.js +4 -1
- package/src/commands/create.js +734 -0
- package/src/commands/fakeCore.js +16 -1
- package/src/commands/feature.js +845 -0
- package/src/commands/fix.js +647 -70
- package/src/commands/init.js +19 -3
- package/src/commands/lists.js +206 -0
- package/src/commands/profile.js +25 -6
- package/src/commands/punch.js +116 -0
- package/src/commands/replay.js +204 -0
- package/src/commands/resume.js +7 -0
- package/src/commands/run.js +200 -24
- package/src/commands/tape.js +74 -0
- package/src/commands/template.js +70 -0
- package/src/commands/validate.js +414 -0
- package/src/commands/validateHosted.js +292 -0
- package/src/create/scaffold.js +192 -0
- package/src/run/acceptance.js +212 -0
- package/src/run/args.js +18 -1
- package/src/run/checks.js +705 -0
- package/src/run/dimension.js +95 -0
- package/src/run/drivers/godot-observation.js +59 -11
- package/src/run/drivers/godot-project.js +201 -31
- package/src/run/drivers/godot.js +532 -45
- package/src/run/drivers/web.js +18 -0
- package/src/run/evidence.js +170 -0
- package/src/run/featureLoop.js +288 -0
- package/src/run/fixLoop.js +82 -12
- package/src/run/git.js +110 -20
- package/src/run/json.js +65 -9
- package/src/run/labsOutcome.js +136 -0
- package/src/run/liveWork.js +103 -0
- package/src/run/model.js +105 -5
- package/src/run/openPr.js +107 -0
- package/src/run/paths.js +4 -1
- package/src/run/pendingRegister.js +81 -0
- package/src/run/personaLoop.js +111 -4
- package/src/run/profile.js +68 -7
- package/src/run/replay.js +236 -0
- package/src/run/repoTools.js +227 -27
- package/src/run/report.js +7 -1
- package/src/run/resume.js +19 -1
- package/src/run/secretScan.js +35 -0
- package/src/run/settle.js +41 -0
- package/src/run/tools.js +33 -4
- package/src/run/trace.js +124 -0
- package/src/simulation/godotPack.js +112 -0
- package/src/ui/index.js +61 -9
- package/src/upload/allowlist.js +6 -2
- package/src/upload/index.js +5 -4
- package/templates/godot-2d/README.md +36 -0
- package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-2d/export_presets.cfg +27 -0
- package/templates/godot-2d/gitignore +10 -0
- package/templates/godot-2d/project.godot +58 -0
- package/templates/godot-2d/scenes/main.tscn +67 -0
- package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-2d/scripts/main.gd +32 -0
- package/templates/godot-2d/scripts/player.gd +10 -0
- package/templates/godot-2d/scripts/state_hook.gd +16 -0
- package/templates/godot-2d/template.json +14 -0
- package/templates/godot-2d/tools/inject_release_key.sh +19 -0
- package/templates/godot-2d/tools/selftest.gd +70 -0
- package/templates/godot-3d/README.md +49 -0
- package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
- package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
- package/templates/godot-3d/export_presets.cfg +27 -0
- package/templates/godot-3d/gitignore +10 -0
- package/templates/godot-3d/project.godot +56 -0
- package/templates/godot-3d/scenes/main.tscn +154 -0
- package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
- package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
- package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
- package/templates/godot-3d/scripts/beacon.gd +20 -0
- package/templates/godot-3d/scripts/main.gd +49 -0
- package/templates/godot-3d/scripts/player.gd +19 -0
- package/templates/godot-3d/scripts/state_hook.gd +25 -0
- package/templates/godot-3d/template.json +16 -0
- package/templates/godot-3d/tools/inject_release_key.sh +19 -0
- package/templates/godot-3d/tools/selftest.gd +77 -0
|
@@ -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
|
|
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
|
+
}
|
package/src/run/personaLoop.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|