ravensight-playtest 0.2.6 → 0.2.8
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 +65 -1
- package/package.json +1 -1
- package/src/cli.js +1 -0
- package/src/commands/fakeCore.js +36 -7
- package/src/commands/run.js +149 -6
- package/src/commands/upload.js +10 -0
- package/src/run/aggregate.js +9 -2
- package/src/run/args.js +2 -1
- package/src/run/drivers/godot.js +5 -0
- package/src/run/drivers/stdio.js +1 -0
- package/src/run/drivers/web.js +126 -3
- package/src/run/drivers/webdriver.js +1 -0
- package/src/run/dropped.js +294 -0
- package/src/run/personaLoop.js +218 -12
- package/src/run/placeholders.js +186 -0
- package/src/run/resume.js +16 -1
- package/src/run/synthetic.js +14 -1
- package/src/run/tools.js +10 -4
- package/src/states.js +4 -2
- package/src/upload/index.js +9 -1
- package/src/upload/queue.js +13 -5
package/README.md
CHANGED
|
@@ -206,6 +206,7 @@ splitting the job in two is what gets everything played.
|
|
|
206
206
|
| `--upload-video` | Upload `session.webm` as well as the reports. |
|
|
207
207
|
| `--upload-transcript` | Upload `transcript.jsonl` as well as the reports. |
|
|
208
208
|
| `--dry-upload` | List every file and byte that would be sent, and send nothing. |
|
|
209
|
+
| `--key-modifier <meta\|control>` | The modifier the browser driver uses for editing shortcuts such as select all. Defaults to this machine's own (Command on macOS, Control elsewhere); set it when the browser's platform is not this machine's. `RAVENSIGHT_KEY_MODIFIER` does the same. |
|
|
209
210
|
| `--headed`, `--headless` | Show or hide the browser. |
|
|
210
211
|
| `--verbose` | Print each turn. |
|
|
211
212
|
|
|
@@ -223,11 +224,73 @@ from the page: the browser driver sets `window.__RAVENSIGHT_PLAYTEST__` and
|
|
|
223
224
|
`localStorage['ravensight.playtest']` before the game's scripts run. It is never
|
|
224
225
|
sent as a request header, which would reach your game's own API too.
|
|
225
226
|
|
|
227
|
+
With no ingest key configured, a Godot run says "no ingest key configured, so
|
|
228
|
+
no synthetic telemetry was sent". A web run says "synthetic telemetry comes from
|
|
229
|
+
the game's own SDK on the page, sent with the run's playtest token" instead,
|
|
230
|
+
because the page's SDK already sends the game's events marked synthetic.
|
|
231
|
+
|
|
226
232
|
When a run reaches its model budget before the report step, nothing more is
|
|
227
233
|
asked of the model. The persona's notes are written to a partial `report.json`
|
|
228
234
|
(`partial: true`, one unreviewed finding per note with the nearest screenshot as
|
|
229
235
|
evidence) and uploaded, so the job's aggregate still counts them.
|
|
230
236
|
|
|
237
|
+
When the budget runs out during the report step itself, the same partial report
|
|
238
|
+
is uploaded and the run is completed as `budget_exceeded`. The job does not exit 2 for it, and the CLI
|
|
239
|
+
prints "Run ran out of budget; the partial report was uploaded and the refund
|
|
240
|
+
decision is the server's". When the server's answer says a refund is
|
|
241
|
+
expected, the line ends "a refund of $X.XX is expected when the job settles"
|
|
242
|
+
instead, and "the run was refunded" once the job has settled and the run was
|
|
243
|
+
refunded. If the server refused the partial report, it says so and where the
|
|
244
|
+
notes are instead. An older API that refuses that completion prints its own
|
|
245
|
+
message, the run is completed as `failed`, and the job exits 1.
|
|
246
|
+
|
|
247
|
+
How the report step reads what happened:
|
|
248
|
+
|
|
249
|
+
- Console errors are recorded as automatic notes. A 4xx on a request (`Failed
|
|
250
|
+
to load resource`, `HTTP 404`, `status: 401`, `401 Unauthorized` and similar)
|
|
251
|
+
is a network note: context, not a bug. A finding whose only evidence is
|
|
252
|
+
automatic network notes is capped at `minor` by the CLI, and its description
|
|
253
|
+
says so.
|
|
254
|
+
- Every note carries `after`, the URL and title of the screen that followed
|
|
255
|
+
it. A blocker or major finding has to cite the observation where the failure
|
|
256
|
+
is visible and must not be contradicted by that next screen or by a later
|
|
257
|
+
persona note.
|
|
258
|
+
- The model is told never to write calendar dates. `report.md` carries the
|
|
259
|
+
CLI's own `Generated at` timestamp under its title, and the aggregate report's
|
|
260
|
+
header uses its `generated_at`.
|
|
261
|
+
- A template slot the model copied instead of filling (`**Date:** <run date>`,
|
|
262
|
+
`<driver>`, `<N actions>`) is filled from the CLI's own facts, or "not
|
|
263
|
+
recorded" when it has none, in `report.md`, the prose of `report.json` and
|
|
264
|
+
the aggregate. Other angle brackets are left as written: keys such as
|
|
265
|
+
`<Enter>`, elements such as `<form>`, types such as `Array<string>`, links,
|
|
266
|
+
code and quoted game text.
|
|
267
|
+
- The server keeps a finding only when one of its evidence references points
|
|
268
|
+
at a file the run uploaded. After delivery the CLI marks the findings it
|
|
269
|
+
dropped (by the server's `dropped_finding_ids`, or by applying the same rule
|
|
270
|
+
to the files it uploaded when its count matches): `dropped: true` in
|
|
271
|
+
`report.json`, out of the summary in `report.md` and into a section headed
|
|
272
|
+
"Dropped by the server (no usable evidence)", and out of the aggregate and
|
|
273
|
+
the synthetic telemetry, so the local report counts what the dashboard
|
|
274
|
+
counts. When it cannot tell which, `report.md` says how many and that the
|
|
275
|
+
dashboard is the record. The rewritten files are journalled as local edits,
|
|
276
|
+
and `upload <job>` never re-sends a completed run's report files.
|
|
277
|
+
|
|
278
|
+
### What personas do well
|
|
279
|
+
|
|
280
|
+
A persona plays like a first time player at human speed: menus, onboarding,
|
|
281
|
+
the first few minutes, unclear text, screens that do not respond, and whether
|
|
282
|
+
it would come back. On the web, when a persona replaces a field's value (the
|
|
283
|
+
`type` tool with `replace`), the driver clears it with `fill`, the same way on
|
|
284
|
+
macOS, Windows and Linux; ordinary typing is keystrokes. An editing shortcut
|
|
285
|
+
such as select all uses the platform's own modifier (Command on a Mac, Control
|
|
286
|
+
elsewhere, or `--key-modifier`).
|
|
287
|
+
|
|
288
|
+
Each persona starts at the game's URL as a fresh player with no account, so a
|
|
289
|
+
sign-up form with validation can use up a whole unit before the game itself is
|
|
290
|
+
reached. Point `--build-url` at a guest or demo path, or say where to start in
|
|
291
|
+
the brief's `how_to_start` note (for example "choose Play as guest"), when the
|
|
292
|
+
sign-up is not what you want tested.
|
|
293
|
+
|
|
231
294
|
### fix
|
|
232
295
|
|
|
233
296
|
Takes one finding, attempts the change on a branch of its own, replays the
|
|
@@ -383,7 +446,7 @@ start avoids that.
|
|
|
383
446
|
|---|---|
|
|
384
447
|
| 0 | Success |
|
|
385
448
|
| 1 | Failure |
|
|
386
|
-
| 2 | Budget exceeded. Partial results were written and uploaded. |
|
|
449
|
+
| 2 | Budget exceeded during play. Partial results were written and uploaded. A run that ran out during the report step and was completed does not make the job exit 2. |
|
|
387
450
|
| 3 | Environment check failed |
|
|
388
451
|
| 4 | Authentication failed |
|
|
389
452
|
| 5 | Model API error after retries |
|
|
@@ -399,6 +462,7 @@ CI can treat 2 as a success with a warning.
|
|
|
399
462
|
| `RAVENSIGHT_API_URL` | The API base URL. Defaults to `https://api.ravensight.io`. |
|
|
400
463
|
| `RAVENSIGHT_DASHBOARD_URL` | Where the dashboard is, if it is not derivable from the API URL. |
|
|
401
464
|
| `RAVENSIGHT_GAME_ID` | The game, when there is no linked repo. |
|
|
465
|
+
| `RAVENSIGHT_KEY_MODIFIER` | `meta` or `control`: the browser driver's modifier for editing shortcuts, as `--key-modifier`. The flag wins when both are set. |
|
|
402
466
|
| `RAVENSIGHT_INGEST_KEY` | The game's ingest key, so a web run can emit its synthetic session into your event stream. `ingest_key` in `.ravensight/config.json` does the same. Without either the run still reports; it just sends no telemetry. |
|
|
403
467
|
| `RAVENSIGHT_GODOT` | Path to the Godot binary. Tried first, then `$GODOT`, then `$GODOT_PATH`, then the PATH, then the platform's usual install locations. `--godot-path` beats all of them. |
|
|
404
468
|
| `RAVENSIGHT_FFMPEG` | Path to ffmpeg. |
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -63,6 +63,7 @@ Run options (parsed by the runner, see src/run/args.js):
|
|
|
63
63
|
--upload-video upload session.webm as well as the reports
|
|
64
64
|
--upload-transcript upload transcript.jsonl as well as the reports
|
|
65
65
|
--dry-upload list every file and byte that would be sent, send nothing
|
|
66
|
+
--key-modifier <name> meta or control: the modifier for editing shortcuts in the browser, default the platform's own
|
|
66
67
|
--headed show the browser
|
|
67
68
|
--headless hide it
|
|
68
69
|
--verbose print each turn
|
package/src/commands/fakeCore.js
CHANGED
|
@@ -18,13 +18,17 @@ const SKILL = ['---', 'name: persona-playtest', 'prompt_version: persona-playtes
|
|
|
18
18
|
const PROFILE_SKILL = ['---', 'name: game-profile', 'prompt_version: game-profile@0.1.0', '---', '# Game Profile'].join('\n');
|
|
19
19
|
const FIX_SKILL = ['---', 'name: fix', 'prompt_version: fix@0.1.0', '---', '# Fix'].join('\n');
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* The server's table, verbatim, including the change that lets a run out of
|
|
23
|
+
* money in its report step end as budget_exceeded from `reporting` or
|
|
24
|
+
* `uploading`.
|
|
25
|
+
*/
|
|
22
26
|
export const RUN_TRANSITIONS = Object.freeze({
|
|
23
27
|
queued: ['launching', 'canceled', 'skipped', 'failed', 'interrupted'],
|
|
24
28
|
launching: ['playing', 'failed', 'canceled', 'interrupted'],
|
|
25
29
|
playing: ['reporting', 'failed', 'canceled', 'budget_exceeded', 'interrupted'],
|
|
26
|
-
reporting: ['uploading', 'failed', 'interrupted'],
|
|
27
|
-
uploading: ['succeeded', 'failed', 'interrupted'],
|
|
30
|
+
reporting: ['uploading', 'failed', 'budget_exceeded', 'interrupted'],
|
|
31
|
+
uploading: ['succeeded', 'failed', 'budget_exceeded', 'interrupted'],
|
|
28
32
|
interrupted: ['launching', 'playing', 'reporting', 'uploading', 'failed', 'canceled'],
|
|
29
33
|
succeeded: [],
|
|
30
34
|
failed: [],
|
|
@@ -33,6 +37,13 @@ export const RUN_TRANSITIONS = Object.freeze({
|
|
|
33
37
|
skipped: []
|
|
34
38
|
});
|
|
35
39
|
|
|
40
|
+
/** An API from before that change, which refuses budget_exceeded after `playing`. */
|
|
41
|
+
export const OLDER_RUN_TRANSITIONS = Object.freeze({
|
|
42
|
+
...RUN_TRANSITIONS,
|
|
43
|
+
reporting: ['uploading', 'failed', 'interrupted'],
|
|
44
|
+
uploading: ['succeeded', 'failed', 'interrupted']
|
|
45
|
+
});
|
|
46
|
+
|
|
36
47
|
export const RUN_TERMINAL_STATES = Object.freeze(['succeeded', 'failed', 'canceled', 'budget_exceeded', 'skipped']);
|
|
37
48
|
|
|
38
49
|
/** Shaped like cli-core's `ApiError`: `status`, `code`, `body`. */
|
|
@@ -234,7 +245,10 @@ export function fakeCore({
|
|
|
234
245
|
findings = [fakeFinding()],
|
|
235
246
|
triage = { verdict: 'bug', confidence: 0.9, source: 'brief' },
|
|
236
247
|
graphContext = { levels: [{ name: 'title', exits: ['level_1'] }] },
|
|
237
|
-
verifyVerdict = { reproduced: 0.1, source: 'judge' }
|
|
248
|
+
verifyVerdict = { reproduced: 0.1, source: 'judge' },
|
|
249
|
+
transitions = RUN_TRANSITIONS,
|
|
250
|
+
completeExtras = {},
|
|
251
|
+
runRefund = {}
|
|
238
252
|
} = {}) {
|
|
239
253
|
const calls = [];
|
|
240
254
|
const record = (name, payload) => calls.push({ name, ...payload });
|
|
@@ -250,7 +264,7 @@ export function fakeCore({
|
|
|
250
264
|
|
|
251
265
|
const move = (runId, to) => {
|
|
252
266
|
const from = runStates.get(runId) || 'queued';
|
|
253
|
-
if (!(
|
|
267
|
+
if (!(transitions[from] || []).includes(to)) {
|
|
254
268
|
throw new FakeApiError({
|
|
255
269
|
status: 409,
|
|
256
270
|
code: 'invalid_transition',
|
|
@@ -347,8 +361,23 @@ export function fakeCore({
|
|
|
347
361
|
if (RUN_TERMINAL_STATES.includes(from)) {
|
|
348
362
|
throw new FakeApiError({ status: 409, code: 'run_already_complete', message: 'That run has already finished.', body: { state: from } });
|
|
349
363
|
}
|
|
350
|
-
|
|
351
|
-
|
|
364
|
+
// The server's complete reads the table too, after advancing a
|
|
365
|
+
// `reporting` run that is succeeding to `uploading` itself.
|
|
366
|
+
const state = body.state || 'succeeded';
|
|
367
|
+
const effective = from === 'reporting' && state === 'succeeded' ? 'uploading' : from;
|
|
368
|
+
if (!(transitions[effective] || []).includes(state)) {
|
|
369
|
+
throw new FakeApiError({ status: 409, code: 'invalid_transition', message: `A run cannot go from "${effective}" to "${state}".`, body: { from: effective, to: state } });
|
|
370
|
+
}
|
|
371
|
+
runStates.set(runId, state);
|
|
372
|
+
// The server's refund fields ride on `run`. Before the job settles
|
|
373
|
+
// `refunded` is false and `refund_expected` says whether settling
|
|
374
|
+
// would give the run's unit back.
|
|
375
|
+
return {
|
|
376
|
+
run: { run_id: runId, state, refunded: false, refunded_cents: 0, refund_expected: false, refund_expected_cents: 0, ...runRefund },
|
|
377
|
+
dropped_findings: 0,
|
|
378
|
+
report_lint: [],
|
|
379
|
+
...completeExtras
|
|
380
|
+
};
|
|
352
381
|
},
|
|
353
382
|
async list(gameId, jobId) {
|
|
354
383
|
record('list', { jobId });
|
package/src/commands/run.js
CHANGED
|
@@ -13,6 +13,7 @@ import { jobDir, runDir as runDirFor, JOB_FILES, RUN_FILES } from '../run/paths.
|
|
|
13
13
|
import { readBridgeConfig } from "../run/drivers/stdio.js";
|
|
14
14
|
import { WEBDRIVER_NAMES, readDriverConfig } from '../run/drivers/webdriver.js';
|
|
15
15
|
import { runPersona } from '../run/personaLoop.js';
|
|
16
|
+
import { reconcileDroppedFindings } from '../run/dropped.js';
|
|
16
17
|
import * as stateFile from '../run/state.js';
|
|
17
18
|
import { emitRunEvents } from '../run/synthetic.js';
|
|
18
19
|
|
|
@@ -90,6 +91,8 @@ async function run(flags, injected, deps, log) {
|
|
|
90
91
|
const { context, gameId, repoRoot, apiUrl, cliVersion, api } = setup;
|
|
91
92
|
|
|
92
93
|
if (options.controller && !['standard', 'jev'].includes(options.controller)) { log('Choose --controller standard or --controller jev.'); return EXIT.ENVIRONMENT; }
|
|
94
|
+
const keyModifier = resolveKeyModifier(options.keyModifier);
|
|
95
|
+
if (keyModifier.error) { log(keyModifier.error); return EXIT.ENVIRONMENT; }
|
|
93
96
|
const driverName = options.driver || (context.config && context.config.driver) || 'playwright_web';
|
|
94
97
|
const target = resolveTarget(options, context.config, driverName);
|
|
95
98
|
if (!target) {
|
|
@@ -433,7 +436,14 @@ export async function playJob({
|
|
|
433
436
|
log
|
|
434
437
|
});
|
|
435
438
|
|
|
439
|
+
// A run that ran out of money in the report step, after its partial report
|
|
440
|
+
// was uploaded and the run completed, delivered what it bought: it does not
|
|
441
|
+
// make the job exit 2. One the server refused to complete exits 1, never 2,
|
|
442
|
+
// because 2 promises the partial results were shipped.
|
|
443
|
+
const playBudgetHit = results.some(result => result.state === 'budget_exceeded' && !result.reportStepBudget);
|
|
436
444
|
const budgetHit = results.some(result => result.state === 'budget_exceeded');
|
|
445
|
+
const reportBudgetRefused = results.some(result => result.reportStepBudget === 'refused');
|
|
446
|
+
const reportBudgetDelivered = results.some(result => result.reportStepBudget === 'delivered' || result.reportStepBudget === 'dry_upload');
|
|
437
447
|
const canceled = results.some(result => result.state === 'canceled');
|
|
438
448
|
const anySucceeded = results.some(result => result.state === 'succeeded');
|
|
439
449
|
|
|
@@ -449,8 +459,9 @@ export async function playJob({
|
|
|
449
459
|
log(`Reports: ${directory}`);
|
|
450
460
|
|
|
451
461
|
if (canceled) return EXIT.CANCELED;
|
|
452
|
-
if (
|
|
453
|
-
|
|
462
|
+
if (reportBudgetRefused) return EXIT.FAILED;
|
|
463
|
+
if (playBudgetHit) return EXIT.BUDGET;
|
|
464
|
+
return anySucceeded || reportBudgetDelivered ? EXIT.OK : EXIT.FAILED;
|
|
454
465
|
}
|
|
455
466
|
|
|
456
467
|
/**
|
|
@@ -489,7 +500,7 @@ export async function playPersonaRun({
|
|
|
489
500
|
// Headless is right for a browser and wrong for Godot: a headless Godot
|
|
490
501
|
// routes no GUI input, so it is a smoke-test mode rather than a way to play.
|
|
491
502
|
const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
|
|
492
|
-
const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId }));
|
|
503
|
+
const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId, keyModifier: options.keyModifier }));
|
|
493
504
|
// The ledger is created here rather than inside the loop so the heartbeat
|
|
494
505
|
// can feed it the server's own figure for what this run has spent. Prices
|
|
495
506
|
// are server side only, so that answer is the only real one.
|
|
@@ -554,7 +565,47 @@ export async function playPersonaRun({
|
|
|
554
565
|
if (inFlight) inFlight.delete(runId);
|
|
555
566
|
const failed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
|
|
556
567
|
await stateFile.patchRun(repoRoot, journal, runId, { state: failed.state, error: String(error && error.message), quit_reason: failed.quitReason });
|
|
557
|
-
|
|
568
|
+
const delivery = {};
|
|
569
|
+
const answer = await deliver({ deps, api, gameId, jobId, runId, directory, outcome: failed, options, log, repoRoot, delivery });
|
|
570
|
+
if (isReportStepBudget(failed)) {
|
|
571
|
+
// The money ran out while the report was being written. The partial
|
|
572
|
+
// report went up and the run is completed as budget_exceeded from
|
|
573
|
+
// `uploading`; what happens to the charge is the server's decision.
|
|
574
|
+
if (answer) {
|
|
575
|
+
await stateFile.patchRun(repoRoot, journal, runId, { uploaded: true, completed: true });
|
|
576
|
+
// "Uploaded" only when it was: the server can refuse the partial
|
|
577
|
+
// report (and the run is completed without it), and the report may
|
|
578
|
+
// never have been written.
|
|
579
|
+
const message = delivery.reportRefused || !failed.report
|
|
580
|
+
? `Run ran out of budget; the server refused the partial report; the persona's notes are in ${directory}`
|
|
581
|
+
: reportBudgetMessage(answer);
|
|
582
|
+
log(`run ${runId} (${persona.slug}): ${message}`);
|
|
583
|
+
return { runId, persona: persona.slug, state: failed.state, quitReason: failed.quitReason, reportStepBudget: 'delivered' };
|
|
584
|
+
}
|
|
585
|
+
if (options.dryUpload) {
|
|
586
|
+
// Nothing was sent, so the server still holds the run where it was.
|
|
587
|
+
// Not a refusal, so the job does not exit 1 for it.
|
|
588
|
+
log(`run ${runId} (${persona.slug}) ran out of budget while writing its report: dry upload, run not completed on the server.`);
|
|
589
|
+
return { runId, persona: persona.slug, state: failed.state, quitReason: failed.quitReason, reportStepBudget: 'dry_upload' };
|
|
590
|
+
}
|
|
591
|
+
log(`run ${runId} (${persona.slug}) ran out of budget while writing its report, and the server would not complete it: ${delivery.message || 'no reason given'}`);
|
|
592
|
+
// An older API refuses budget_exceeded after `playing`. The CLI moved
|
|
593
|
+
// the run to `uploading` itself, so it does not leave it there: `failed`
|
|
594
|
+
// is allowed from `uploading` on every table.
|
|
595
|
+
try {
|
|
596
|
+
await api.runs.complete(gameId, jobId, runId, {
|
|
597
|
+
state: 'failed',
|
|
598
|
+
reason: `ran out of budget while writing the report; the server refused budget_exceeded (${delivery.message || 'no reason given'})`,
|
|
599
|
+
actions_taken: failed.actionsTaken,
|
|
600
|
+
quit_reason: failed.quitReason
|
|
601
|
+
});
|
|
602
|
+
await stateFile.patchRun(repoRoot, journal, runId, { completed: true, state: 'failed' });
|
|
603
|
+
log(`run ${runId} (${persona.slug}): completed as failed instead.`);
|
|
604
|
+
} catch (fallbackError) {
|
|
605
|
+
log(`run ${runId} (${persona.slug}): could not be completed as failed either (${String(fallbackError && fallbackError.message)}). "ravensight-playtest resume" can retry it.`);
|
|
606
|
+
}
|
|
607
|
+
return { runId, persona: persona.slug, state: failed.state, quitReason: failed.quitReason, reportStepBudget: 'refused', error };
|
|
608
|
+
}
|
|
558
609
|
log(`run ${runId} (${persona.slug}) failed: ${String(error && error.message)}`);
|
|
559
610
|
return { runId, persona: persona.slug, state: failed.state, quitReason: failed.quitReason, error };
|
|
560
611
|
}
|
|
@@ -576,6 +627,9 @@ export async function playPersonaRun({
|
|
|
576
627
|
apiUrl,
|
|
577
628
|
ingestKey: (config && config.ingest_key) || process.env.RAVENSIGHT_INGEST_KEY || null,
|
|
578
629
|
playtestToken: entry.playtest_token,
|
|
630
|
+
// The web driver injects the playtest token into the page (see
|
|
631
|
+
// driverOptions), so the game's own SDK sends with it.
|
|
632
|
+
pageSdk: driverName === 'playwright_web' && Boolean(entry.playtest_token),
|
|
579
633
|
context: { jobId, runId, persona: persona.slug, gameVersion: (config && config.game_version) || 'unknown' },
|
|
580
634
|
report: outcome.report,
|
|
581
635
|
driver: driverName,
|
|
@@ -604,7 +658,7 @@ export async function playPersonaRun({
|
|
|
604
658
|
* uploaded and completed in exactly this order, including the `uploading`
|
|
605
659
|
* transition the server's table allows from `reporting` and nowhere else.
|
|
606
660
|
*/
|
|
607
|
-
export async function deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot }) {
|
|
661
|
+
export async function deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot, delivery = null }) {
|
|
608
662
|
const body = {
|
|
609
663
|
state: outcome.state,
|
|
610
664
|
reason: outcome.quitDetail || '',
|
|
@@ -655,23 +709,90 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
655
709
|
// report.json either way.
|
|
656
710
|
if (!(body.report && body.report.partial && error && error.status === 422 && error.code === 'invalid_report')) throw error;
|
|
657
711
|
log(`run ${runId}: the server refused the partial report (${String(error.message)}), so the run is completed without it. The notes are in ${directory}.`);
|
|
712
|
+
if (delivery) delivery.reportRefused = true;
|
|
658
713
|
const { report: ignored, report_md: alsoIgnored, ...withoutReport } = body;
|
|
659
714
|
answer = await api.runs.complete(gameId, jobId, runId, withoutReport);
|
|
660
715
|
}
|
|
661
716
|
if (journal) await stateFile.patchRun(repoRoot, journal, runId, { uploaded: true, completed: true });
|
|
662
717
|
if (answer && answer.dropped_findings > 0) {
|
|
663
718
|
log(`run ${runId}: the server dropped ${answer.dropped_findings} finding(s) with no usable evidence.`);
|
|
719
|
+
await markDropped({ directory, jobId, runId, repoRoot, outcome, droppedCount: answer.dropped_findings, droppedIds: answer.dropped_finding_ids, options, log });
|
|
664
720
|
}
|
|
665
721
|
if (answer && Array.isArray(answer.report_lint) && answer.report_lint.length > 0) {
|
|
666
722
|
log(`run ${runId}: report lint: ${answer.report_lint.join('; ')}`);
|
|
667
723
|
}
|
|
668
724
|
return answer;
|
|
669
725
|
} catch (error) {
|
|
726
|
+
if (delivery) {
|
|
727
|
+
delivery.message = String(error && error.message);
|
|
728
|
+
delivery.status = error && error.status;
|
|
729
|
+
delivery.code = error && error.code;
|
|
730
|
+
}
|
|
670
731
|
log(`run ${runId}: upload or complete was refused (${String(error && error.message)}). The artifacts are in the run directory and "ravensight-playtest resume" can retry it.`);
|
|
671
732
|
return null;
|
|
672
733
|
}
|
|
673
734
|
}
|
|
674
735
|
|
|
736
|
+
/**
|
|
737
|
+
* Bring the local report into line with what the server kept, so report.md,
|
|
738
|
+
* report.json, the aggregate and the telemetry count what the dashboard
|
|
739
|
+
* counts. A failure here never fails the delivery: the run is already
|
|
740
|
+
* complete on the server.
|
|
741
|
+
*/
|
|
742
|
+
async function markDropped({ directory, jobId, runId, repoRoot, outcome, droppedCount, droppedIds, options, log }) {
|
|
743
|
+
try {
|
|
744
|
+
const result = await reconcileDroppedFindings({ directory, droppedCount, droppedIds, options, journal: { jobId, runId, repoRoot } });
|
|
745
|
+
if (!result) return;
|
|
746
|
+
if (outcome) {
|
|
747
|
+
outcome.report = result.report;
|
|
748
|
+
outcome.reportMarkdown = result.markdown;
|
|
749
|
+
}
|
|
750
|
+
log(result.identified
|
|
751
|
+
? `run ${runId}: marked ${result.dropped.join(', ')} as dropped in report.md and report.json, under "Dropped by the server".`
|
|
752
|
+
: `run ${runId}: could not tell which finding(s) the server dropped, so report.md says so and the dashboard is the record.`);
|
|
753
|
+
} catch (error) {
|
|
754
|
+
log(`run ${runId}: could not mark the dropped finding(s) in the local report (${String(error && error.message)}).`);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Whether a run ran out of money in the report step: it is budget_exceeded
|
|
760
|
+
* and the server had already accepted `reporting` (or `uploading`) for it.
|
|
761
|
+
*
|
|
762
|
+
* @param {Object} outcome
|
|
763
|
+
* @returns {boolean}
|
|
764
|
+
*/
|
|
765
|
+
export function isReportStepBudget(outcome) {
|
|
766
|
+
return Boolean(outcome) && outcome.state === 'budget_exceeded' && ['reporting', 'uploading'].includes(outcome.reachedState);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* What the developer is told when a report-step budget run was completed.
|
|
771
|
+
* The refund is the server's decision; the CLI only repeats what the
|
|
772
|
+
* complete answer says about it. Before the job settles the answer carries
|
|
773
|
+
* `refund_expected` and `refund_expected_cents`; `refunded` is true only once
|
|
774
|
+
* settlement gave the unit back. Each may be on the answer or on its `run`.
|
|
775
|
+
*
|
|
776
|
+
* @param {Object} answer the complete response
|
|
777
|
+
* @returns {string}
|
|
778
|
+
*/
|
|
779
|
+
export function reportBudgetMessage(answer) {
|
|
780
|
+
const read = name => {
|
|
781
|
+
if (!answer) return undefined;
|
|
782
|
+
if (answer[name] !== undefined) return answer[name];
|
|
783
|
+
return answer.run ? answer.run[name] : undefined;
|
|
784
|
+
};
|
|
785
|
+
const prefix = 'Run ran out of budget; the partial report was uploaded and';
|
|
786
|
+
if (read('refunded') === true) return `${prefix} the run was refunded`;
|
|
787
|
+
if (read('refund_expected') === true) {
|
|
788
|
+
const cents = read('refund_expected_cents');
|
|
789
|
+
return Number.isFinite(Number(cents)) && cents !== null && Number(cents) > 0
|
|
790
|
+
? `${prefix} a refund of ${formatCents(cents)} is expected when the job settles`
|
|
791
|
+
: `${prefix} a refund is expected when the job settles`;
|
|
792
|
+
}
|
|
793
|
+
return `${prefix} the refund decision is the server's`;
|
|
794
|
+
}
|
|
795
|
+
|
|
675
796
|
/**
|
|
676
797
|
* Build the aggregate, and drive the `aggregate_report` run's own state machine
|
|
677
798
|
* while doing it.
|
|
@@ -945,9 +1066,12 @@ export async function resolveSetup(deps, options, log) {
|
|
|
945
1066
|
*
|
|
946
1067
|
* @returns {Object}
|
|
947
1068
|
*/
|
|
948
|
-
export function driverOptions({ headless, driverName, target, directory, entry, jobId }) {
|
|
1069
|
+
export function driverOptions({ headless, driverName, target, directory, entry, jobId, keyModifier }) {
|
|
1070
|
+
// Only the browser driver sends key chords with a modifier.
|
|
1071
|
+
const modifier = driverName === 'playwright_web' ? resolveKeyModifier(keyModifier).modifier : null;
|
|
949
1072
|
return {
|
|
950
1073
|
headless,
|
|
1074
|
+
...(modifier ? { modifier } : {}),
|
|
951
1075
|
playtest: {
|
|
952
1076
|
token: entry.playtest_token || null,
|
|
953
1077
|
runId: entry.run_id,
|
|
@@ -959,6 +1083,25 @@ export function driverOptions({ headless, driverName, target, directory, entry,
|
|
|
959
1083
|
};
|
|
960
1084
|
}
|
|
961
1085
|
|
|
1086
|
+
/**
|
|
1087
|
+
* The modifier the web driver uses for editing shortcuts, from
|
|
1088
|
+
* `--key-modifier meta|control`, else `RAVENSIGHT_KEY_MODIFIER`, else the
|
|
1089
|
+
* platform's own (Command on macOS, Control elsewhere, decided in the driver).
|
|
1090
|
+
* For a remote or containerised browser whose platform is not this machine's.
|
|
1091
|
+
*
|
|
1092
|
+
* @param {string} [value] the flag's value
|
|
1093
|
+
* @param {Object} [env]
|
|
1094
|
+
* @returns {{modifier: 'Meta'|'Control'|null, error?: string}}
|
|
1095
|
+
*/
|
|
1096
|
+
export function resolveKeyModifier(value, env = process.env) {
|
|
1097
|
+
const raw = value !== undefined && value !== null && value !== '' ? value : env.RAVENSIGHT_KEY_MODIFIER;
|
|
1098
|
+
if (raw === undefined || raw === null || raw === '') return { modifier: null };
|
|
1099
|
+
const key = String(raw).trim().toLowerCase();
|
|
1100
|
+
if (['meta', 'cmd', 'command'].includes(key)) return { modifier: 'Meta' };
|
|
1101
|
+
if (['control', 'ctrl'].includes(key)) return { modifier: 'Control' };
|
|
1102
|
+
return { modifier: null, error: `Choose --key-modifier meta or --key-modifier control (or set RAVENSIGHT_KEY_MODIFIER), not "${raw}".` };
|
|
1103
|
+
}
|
|
1104
|
+
|
|
962
1105
|
/**
|
|
963
1106
|
* The persona reports on disk, for the aggregate. A partial report (written
|
|
964
1107
|
* from the notes when the money ran out) is loaded as a `budget_exceeded`
|
package/src/commands/upload.js
CHANGED
|
@@ -8,6 +8,9 @@ import { pending } from '../upload/queue.js';
|
|
|
8
8
|
import { uploadRunDir } from '../upload/index.js';
|
|
9
9
|
import { JOB_UPLOAD_PATHS } from '../upload/allowlist.js';
|
|
10
10
|
import { ui } from '../ui/index.js';
|
|
11
|
+
|
|
12
|
+
/** Report files never re-sent for a run the journal marks completed. */
|
|
13
|
+
const COMPLETED_RUN_REPORTS = Object.freeze(['report.md', 'report.json']);
|
|
11
14
|
import { CliError, ExitCode } from '../errors.js';
|
|
12
15
|
|
|
13
16
|
/**
|
|
@@ -73,11 +76,18 @@ async function drainJob(context, jobId, flags) {
|
|
|
73
76
|
const results = [];
|
|
74
77
|
|
|
75
78
|
for (const runId of runIds) {
|
|
79
|
+
// A completed run's report went to the server with `complete`, which
|
|
80
|
+
// validated it; the copy on disk may since have been marked locally
|
|
81
|
+
// (dropped findings), and sending it again would replace the validated
|
|
82
|
+
// artifact, or be refused once the job is closed.
|
|
83
|
+
const completed = Boolean(state && state.runs && state.runs[runId] && state.runs[runId].completed);
|
|
76
84
|
const result = await uploadRunDir(paths.runDir(jobId, runId, { repoRoot }), {
|
|
77
85
|
api: context.api,
|
|
78
86
|
gameId,
|
|
79
87
|
jobId,
|
|
80
88
|
runId,
|
|
89
|
+
exclude: completed ? COMPLETED_RUN_REPORTS : null,
|
|
90
|
+
excludeReason: 'the run is complete; its report was delivered with complete',
|
|
81
91
|
includeVideo,
|
|
82
92
|
includeTranscript,
|
|
83
93
|
dryRun: Boolean(flags.dryRun),
|
package/src/run/aggregate.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { fillPlaceholders } from './placeholders.js';
|
|
2
3
|
import { writeFile } from 'node:fs/promises';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { parseJsonArray, parseJsonObject } from './json.js';
|
|
@@ -331,7 +332,10 @@ export async function buildAggregate({
|
|
|
331
332
|
persona: run.persona,
|
|
332
333
|
state: run.state,
|
|
333
334
|
report: run.report,
|
|
334
|
-
|
|
335
|
+
// A finding the server dropped for want of evidence is marked on disk
|
|
336
|
+
// after delivery (./dropped.js) and kept out of the aggregate, so the
|
|
337
|
+
// job report counts what the dashboard counts.
|
|
338
|
+
findings: (run.report.findings || []).filter(finding => !(finding && finding.dropped === true))
|
|
335
339
|
}));
|
|
336
340
|
const personasRun = new Set(inputs.map(run => run.persona).filter(Boolean)).size;
|
|
337
341
|
|
|
@@ -447,6 +451,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
|
|
|
447
451
|
' "known":["iss_..."], "excluded_by_intent":[{"issue_id":"iss_...","intent":"<the stated intent it matches>"}],',
|
|
448
452
|
' "tldr":["up to five short bullets: the most important fixes and the strongest positive signal"]}',
|
|
449
453
|
'',
|
|
454
|
+
'Never write calendar dates or times in the tldr or anywhere else; the runner stamps generated_at on the report.',
|
|
450
455
|
'Rules. A criterion no run touched is "not testable"; never invent evidence for one. An issue is "known" only when the brief already describes it. An issue is excluded by intent only when the persona experience is CONSISTENT with the stated intent; when it CONTRADICTS the intent, leave it in the list, because that is the valuable case.',
|
|
451
456
|
'',
|
|
452
457
|
`success criteria:\n${criteria.length > 0 ? criteria.map(row => ` ${row.criterion_id}: ${row.text}`).join('\n') : ' (none stated)'}`,
|
|
@@ -623,7 +628,9 @@ export function renderAggregateMarkdown(json) {
|
|
|
623
628
|
? `Observations dropped for lack of evidence:\n${json.observations.map(o => `- ${o.persona}: ${o.text}`).join('\n')}`
|
|
624
629
|
: 'No dropped observations.');
|
|
625
630
|
lines.push('');
|
|
626
|
-
|
|
631
|
+
// The synthesis writes the TL;DR, and a model can copy a template slot into
|
|
632
|
+
// it as easily as into a persona report.
|
|
633
|
+
return fillPlaceholders(lines.join('\n'), { generatedAt: json.generated_at, game: json.game_id });
|
|
627
634
|
}
|
|
628
635
|
|
|
629
636
|
/**
|
package/src/run/args.js
CHANGED
|
@@ -22,7 +22,8 @@ const VALUED = new Map([
|
|
|
22
22
|
['--cli', 'cli'],
|
|
23
23
|
['--max-actions', 'maxActions'],
|
|
24
24
|
['--concurrency', 'concurrency'],
|
|
25
|
-
['--quality', 'quality']
|
|
25
|
+
['--quality', 'quality'],
|
|
26
|
+
['--key-modifier', 'keyModifier']
|
|
26
27
|
]);
|
|
27
28
|
|
|
28
29
|
/** Options that are just present or absent. */
|
package/src/run/drivers/godot.js
CHANGED
|
@@ -360,6 +360,11 @@ export class GodotDriver {
|
|
|
360
360
|
if (!action || typeof action.kind !== 'string') {
|
|
361
361
|
return { ok: false, error: 'action needs a kind', changed: false };
|
|
362
362
|
}
|
|
363
|
+
// Typing more characters is not replacing a value, and claiming it was
|
|
364
|
+
// would join the old text to the new one without saying so.
|
|
365
|
+
if (action.kind === 'type' && (action.replace || action.target)) {
|
|
366
|
+
return { ok: false, error: 'replace (empty a field, then type) and a typing target are only available on playwright_web; this driver can only type more text into what has focus', changed: false };
|
|
367
|
+
}
|
|
363
368
|
try {
|
|
364
369
|
await this._perform(action);
|
|
365
370
|
} catch (error) {
|
package/src/run/drivers/stdio.js
CHANGED
|
@@ -79,6 +79,7 @@ export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000
|
|
|
79
79
|
observe,
|
|
80
80
|
async act(action) {
|
|
81
81
|
if (!['type', 'key', 'click', 'wait'].includes(action.kind)) return { ok: false, changed: false, error: 'Unsupported bridge action.' };
|
|
82
|
+
if (action.kind === 'type' && (action.replace || action.target)) return { ok: false, changed: false, error: 'replace (empty a field, then type) and a typing target are only available on playwright_web; this driver can only type more text into what has focus' };
|
|
82
83
|
const before = lastHash;
|
|
83
84
|
const safe = action.kind === 'type' ? { kind: 'type', text: String(action.text || '').slice(0, 2000), submit: Boolean(action.submit) }
|
|
84
85
|
: action.kind === 'key' ? { kind: 'key', key: String(action.key || '').slice(0, 64) }
|