ravensight-playtest 0.2.7 → 0.2.9
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 +53 -1
- package/package.json +1 -1
- package/src/cli.js +1 -0
- package/src/commands/run.js +100 -9
- package/src/commands/upload.js +10 -0
- package/src/run/aggregate.js +8 -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 +322 -19
- package/src/run/drivers/webdriver.js +1 -0
- package/src/run/dropped.js +294 -0
- package/src/run/heartbeat.js +8 -1
- package/src/run/personaLoop.js +113 -34
- package/src/run/placeholders.js +186 -0
- package/src/run/report.js +93 -2
- package/src/run/resume.js +25 -1
- package/src/run/state.js +4 -1
- package/src/run/synthetic.js +2 -0
- package/src/run/tools.js +10 -4
- 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
|
|
|
@@ -253,10 +254,56 @@ How the report step reads what happened:
|
|
|
253
254
|
- Every note carries `after`, the URL and title of the screen that followed
|
|
254
255
|
it. A blocker or major finding has to cite the observation where the failure
|
|
255
256
|
is visible and must not be contradicted by that next screen or by a later
|
|
256
|
-
persona note.
|
|
257
|
+
persona note. It also has to cite the screenshot taken at or after that
|
|
258
|
+
observation (the prompt says which observation each screenshot followed),
|
|
259
|
+
and that screenshot must not show what the finding says is missing.
|
|
260
|
+
- Every evidence entry is `{"type": ..., "ref": ...}`. A screenshot path the
|
|
261
|
+
model put under `path` or `file` is moved to `ref` before the report is
|
|
262
|
+
checked; the file still has to exist in the run directory. If the server
|
|
263
|
+
still refuses `report.json` at `complete`, the CLI prints every validation
|
|
264
|
+
message it sent, the run is not counted as succeeded or in the aggregate,
|
|
265
|
+
and the job exits 1.
|
|
257
266
|
- The model is told never to write calendar dates. `report.md` carries the
|
|
258
267
|
CLI's own `Generated at` timestamp under its title, and the aggregate report's
|
|
259
268
|
header uses its `generated_at`.
|
|
269
|
+
- A template slot the model copied instead of filling (`**Date:** <run date>`,
|
|
270
|
+
`<driver>`, `<N actions>`) is filled from the CLI's own facts, or "not
|
|
271
|
+
recorded" when it has none, in `report.md`, the prose of `report.json` and
|
|
272
|
+
the aggregate. Other angle brackets are left as written: keys such as
|
|
273
|
+
`<Enter>`, elements such as `<form>`, types such as `Array<string>`, links,
|
|
274
|
+
code and quoted game text.
|
|
275
|
+
- The server keeps a finding only when one of its evidence references points
|
|
276
|
+
at a file the run uploaded. After delivery the CLI marks the findings it
|
|
277
|
+
dropped (by the server's `dropped_finding_ids`, or by applying the same rule
|
|
278
|
+
to the files it uploaded when its count matches): `dropped: true` in
|
|
279
|
+
`report.json`, out of the summary in `report.md` and into a section headed
|
|
280
|
+
"Dropped by the server (no usable evidence)", and out of the aggregate and
|
|
281
|
+
the synthetic telemetry, so the local report counts what the dashboard
|
|
282
|
+
counts. When it cannot tell which, `report.md` says how many and that the
|
|
283
|
+
dashboard is the record. The rewritten files are journalled as local edits,
|
|
284
|
+
and `upload <job>` never re-sends a completed run's report files.
|
|
285
|
+
|
|
286
|
+
### What personas do well
|
|
287
|
+
|
|
288
|
+
A persona plays like a first time player at human speed: menus, onboarding,
|
|
289
|
+
the first few minutes, unclear text, screens that do not respond, and whether
|
|
290
|
+
it would come back. On the web, when a persona replaces a field's value (the
|
|
291
|
+
`type` tool with `replace`), the driver clears it with `fill`, the same way on
|
|
292
|
+
macOS, Windows and Linux; ordinary typing is keystrokes. An editing shortcut
|
|
293
|
+
such as select all uses the platform's own modifier (Command on a Mac, Control
|
|
294
|
+
elsewhere, or `--key-modifier`).
|
|
295
|
+
|
|
296
|
+
On a long web page the persona's text view of the screen is capped at 4,000
|
|
297
|
+
characters. Over the cap, buttons, links and fields are kept first, a long run
|
|
298
|
+
of look-alike items (a list of 249 cards) is shown as its first two and "and
|
|
299
|
+
247 more like it", and what changed since the last look comes next, so a form
|
|
300
|
+
after a long list still reaches the persona.
|
|
301
|
+
|
|
302
|
+
Each persona starts at the game's URL as a fresh player with no account, so a
|
|
303
|
+
sign-up form with validation can use up a whole unit before the game itself is
|
|
304
|
+
reached. Point `--build-url` at a guest or demo path, or say where to start in
|
|
305
|
+
the brief's `how_to_start` note (for example "choose Play as guest"), when the
|
|
306
|
+
sign-up is not what you want tested.
|
|
260
307
|
|
|
261
308
|
### fix
|
|
262
309
|
|
|
@@ -407,6 +454,10 @@ lost to it. The CLI lints locally first and feeds any miss back into one repair
|
|
|
407
454
|
turn, which costs a model turn; a report written with the four headings from the
|
|
408
455
|
start avoids that.
|
|
409
456
|
|
|
457
|
+
The repository's tests check rendered reports against `test/fixtures/server/`, a
|
|
458
|
+
byte copy of the server's report schema from game_tracker commit 864027e,
|
|
459
|
+
which has to be refreshed by hand whenever the server's schema changes.
|
|
460
|
+
|
|
410
461
|
## Exit codes
|
|
411
462
|
|
|
412
463
|
| Code | Meaning |
|
|
@@ -429,6 +480,7 @@ CI can treat 2 as a success with a warning.
|
|
|
429
480
|
| `RAVENSIGHT_API_URL` | The API base URL. Defaults to `https://api.ravensight.io`. |
|
|
430
481
|
| `RAVENSIGHT_DASHBOARD_URL` | Where the dashboard is, if it is not derivable from the API URL. |
|
|
431
482
|
| `RAVENSIGHT_GAME_ID` | The game, when there is no linked repo. |
|
|
483
|
+
| `RAVENSIGHT_KEY_MODIFIER` | `meta` or `control`: the browser driver's modifier for editing shortcuts, as `--key-modifier`. The flag wins when both are set. |
|
|
432
484
|
| `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. |
|
|
433
485
|
| `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. |
|
|
434
486
|
| `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/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) {
|
|
@@ -442,7 +445,10 @@ export async function playJob({
|
|
|
442
445
|
const reportBudgetRefused = results.some(result => result.reportStepBudget === 'refused');
|
|
443
446
|
const reportBudgetDelivered = results.some(result => result.reportStepBudget === 'delivered' || result.reportStepBudget === 'dry_upload');
|
|
444
447
|
const canceled = results.some(result => result.state === 'canceled');
|
|
445
|
-
|
|
448
|
+
// A run whose report the server refused played, but the server never
|
|
449
|
+
// completed it: it is not a success, and CI should hear about it.
|
|
450
|
+
const anySucceeded = results.some(result => result.state === 'succeeded' && !result.reportRefused);
|
|
451
|
+
const reportRefused = results.some(result => result.reportRefused);
|
|
446
452
|
|
|
447
453
|
if (finish) {
|
|
448
454
|
const finished = await api.jobs.finish(gameId, jobId, {
|
|
@@ -456,7 +462,7 @@ export async function playJob({
|
|
|
456
462
|
log(`Reports: ${directory}`);
|
|
457
463
|
|
|
458
464
|
if (canceled) return EXIT.CANCELED;
|
|
459
|
-
if (reportBudgetRefused) return EXIT.FAILED;
|
|
465
|
+
if (reportBudgetRefused || reportRefused) return EXIT.FAILED;
|
|
460
466
|
if (playBudgetHit) return EXIT.BUDGET;
|
|
461
467
|
return anySucceeded || reportBudgetDelivered ? EXIT.OK : EXIT.FAILED;
|
|
462
468
|
}
|
|
@@ -497,7 +503,7 @@ export async function playPersonaRun({
|
|
|
497
503
|
// Headless is right for a browser and wrong for Godot: a headless Godot
|
|
498
504
|
// routes no GUI input, so it is a smoke-test mode rather than a way to play.
|
|
499
505
|
const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
|
|
500
|
-
const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId }));
|
|
506
|
+
const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId, keyModifier: options.keyModifier }));
|
|
501
507
|
// The ledger is created here rather than inside the loop so the heartbeat
|
|
502
508
|
// can feed it the server's own figure for what this run has spent. Prices
|
|
503
509
|
// are server side only, so that answer is the only real one.
|
|
@@ -563,7 +569,7 @@ export async function playPersonaRun({
|
|
|
563
569
|
const failed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
|
|
564
570
|
await stateFile.patchRun(repoRoot, journal, runId, { state: failed.state, error: String(error && error.message), quit_reason: failed.quitReason });
|
|
565
571
|
const delivery = {};
|
|
566
|
-
const answer = await deliver({ deps, api, gameId, jobId, runId, directory, outcome: failed, options, log, delivery });
|
|
572
|
+
const answer = await deliver({ deps, api, gameId, jobId, runId, directory, outcome: failed, options, log, repoRoot, delivery });
|
|
567
573
|
if (isReportStepBudget(failed)) {
|
|
568
574
|
// The money ran out while the report was being written. The partial
|
|
569
575
|
// report went up and the run is completed as budget_exceeded from
|
|
@@ -617,7 +623,15 @@ export async function playPersonaRun({
|
|
|
617
623
|
budget_warned: outcome.budgetWarned
|
|
618
624
|
});
|
|
619
625
|
|
|
620
|
-
|
|
626
|
+
const delivered = {};
|
|
627
|
+
await deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot, delivery: delivered });
|
|
628
|
+
|
|
629
|
+
if (delivered.reportInvalid) {
|
|
630
|
+
// No synthetic telemetry for findings the server does not hold, and no
|
|
631
|
+
// "succeeded" line for a run the server never completed.
|
|
632
|
+
log(`run ${runId} (${persona.slug}) played ${outcome.actionsTaken} actions (${outcome.quitReason}), but the server refused its report, so the run is not complete.`);
|
|
633
|
+
return { runId, persona: persona.slug, state: outcome.state, quitReason: outcome.quitReason, reportRefused: true };
|
|
634
|
+
}
|
|
621
635
|
|
|
622
636
|
if (outcome.report) {
|
|
623
637
|
const emitted = await emitRunEvents({
|
|
@@ -710,9 +724,10 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
710
724
|
const { report: ignored, report_md: alsoIgnored, ...withoutReport } = body;
|
|
711
725
|
answer = await api.runs.complete(gameId, jobId, runId, withoutReport);
|
|
712
726
|
}
|
|
713
|
-
if (journal) await stateFile.patchRun(repoRoot, journal, runId, { uploaded: true, completed: true });
|
|
727
|
+
if (journal) await stateFile.patchRun(repoRoot, journal, runId, { uploaded: true, completed: true, report_refused: false });
|
|
714
728
|
if (answer && answer.dropped_findings > 0) {
|
|
715
729
|
log(`run ${runId}: the server dropped ${answer.dropped_findings} finding(s) with no usable evidence.`);
|
|
730
|
+
await markDropped({ directory, jobId, runId, repoRoot, outcome, droppedCount: answer.dropped_findings, droppedIds: answer.dropped_finding_ids, options, log });
|
|
716
731
|
}
|
|
717
732
|
if (answer && Array.isArray(answer.report_lint) && answer.report_lint.length > 0) {
|
|
718
733
|
log(`run ${runId}: report lint: ${answer.report_lint.join('; ')}`);
|
|
@@ -724,11 +739,58 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
724
739
|
delivery.status = error && error.status;
|
|
725
740
|
delivery.code = error && error.code;
|
|
726
741
|
}
|
|
742
|
+
if (error && error.status === 422 && error.code === 'invalid_report') {
|
|
743
|
+
// Every validation message first, one per line, before anything says
|
|
744
|
+
// the run did not complete: the summary line alone ("does not match
|
|
745
|
+
// the playtest report schema") is not something anyone can act on.
|
|
746
|
+
const details = refusalDetails(error);
|
|
747
|
+
log(`run ${runId}: the server refused report.json (${details.length} problem(s)):\n${details.length > 0 ? details.map(line => ` - ${line}`).join('\n') : ' - (the server named no problem)'}`);
|
|
748
|
+
if (delivery) delivery.reportInvalid = true;
|
|
749
|
+
if (journal) await stateFile.patchRun(repoRoot, journal, runId, { report_refused: true });
|
|
750
|
+
log(`run ${runId}: not completed, so the server keeps no findings from it; whether its unit is refunded is decided when the job settles. report.json and the screenshots stay in ${directory}.`);
|
|
751
|
+
return null;
|
|
752
|
+
}
|
|
727
753
|
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.`);
|
|
728
754
|
return null;
|
|
729
755
|
}
|
|
730
756
|
}
|
|
731
757
|
|
|
758
|
+
/**
|
|
759
|
+
* The validation messages a 422 carried, as `pointer: message` lines. Read
|
|
760
|
+
* from `details` (the API client's error) or `body.details` (the envelope).
|
|
761
|
+
*
|
|
762
|
+
* @param {any} error
|
|
763
|
+
* @returns {Array<string>}
|
|
764
|
+
*/
|
|
765
|
+
export function refusalDetails(error) {
|
|
766
|
+
const list = Array.isArray(error && error.details) && error.details.length > 0
|
|
767
|
+
? error.details
|
|
768
|
+
: (error && error.body && Array.isArray(error.body.details) ? error.body.details : []);
|
|
769
|
+
return list.map(detail => `${(detail && detail.instancePath) || '/'}: ${(detail && detail.message) || 'is invalid'}`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Bring the local report into line with what the server kept, so report.md,
|
|
774
|
+
* report.json, the aggregate and the telemetry count what the dashboard
|
|
775
|
+
* counts. A failure here never fails the delivery: the run is already
|
|
776
|
+
* complete on the server.
|
|
777
|
+
*/
|
|
778
|
+
async function markDropped({ directory, jobId, runId, repoRoot, outcome, droppedCount, droppedIds, options, log }) {
|
|
779
|
+
try {
|
|
780
|
+
const result = await reconcileDroppedFindings({ directory, droppedCount, droppedIds, options, journal: { jobId, runId, repoRoot } });
|
|
781
|
+
if (!result) return;
|
|
782
|
+
if (outcome) {
|
|
783
|
+
outcome.report = result.report;
|
|
784
|
+
outcome.reportMarkdown = result.markdown;
|
|
785
|
+
}
|
|
786
|
+
log(result.identified
|
|
787
|
+
? `run ${runId}: marked ${result.dropped.join(', ')} as dropped in report.md and report.json, under "Dropped by the server".`
|
|
788
|
+
: `run ${runId}: could not tell which finding(s) the server dropped, so report.md says so and the dashboard is the record.`);
|
|
789
|
+
} catch (error) {
|
|
790
|
+
log(`run ${runId}: could not mark the dropped finding(s) in the local report (${String(error && error.message)}).`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
732
794
|
/**
|
|
733
795
|
* Whether a run ran out of money in the report step: it is budget_exceeded
|
|
734
796
|
* and the server had already accepted `reporting` (or `uploading`) for it.
|
|
@@ -817,7 +879,7 @@ async function aggregatePhase({
|
|
|
817
879
|
|
|
818
880
|
// The aggregate reads reports off disk, so a resumed job whose earlier runs
|
|
819
881
|
// were played by a previous process is covered too.
|
|
820
|
-
const reports = await loadRunReports(repoRoot, jobId, personaRuns);
|
|
882
|
+
const reports = await loadRunReports(repoRoot, jobId, personaRuns, journal);
|
|
821
883
|
let aggregate;
|
|
822
884
|
try {
|
|
823
885
|
aggregate = await buildAggregate({
|
|
@@ -1040,9 +1102,12 @@ export async function resolveSetup(deps, options, log) {
|
|
|
1040
1102
|
*
|
|
1041
1103
|
* @returns {Object}
|
|
1042
1104
|
*/
|
|
1043
|
-
export function driverOptions({ headless, driverName, target, directory, entry, jobId }) {
|
|
1105
|
+
export function driverOptions({ headless, driverName, target, directory, entry, jobId, keyModifier }) {
|
|
1106
|
+
// Only the browser driver sends key chords with a modifier.
|
|
1107
|
+
const modifier = driverName === 'playwright_web' ? resolveKeyModifier(keyModifier).modifier : null;
|
|
1044
1108
|
return {
|
|
1045
1109
|
headless,
|
|
1110
|
+
...(modifier ? { modifier } : {}),
|
|
1046
1111
|
playtest: {
|
|
1047
1112
|
token: entry.playtest_token || null,
|
|
1048
1113
|
runId: entry.run_id,
|
|
@@ -1054,14 +1119,40 @@ export function driverOptions({ headless, driverName, target, directory, entry,
|
|
|
1054
1119
|
};
|
|
1055
1120
|
}
|
|
1056
1121
|
|
|
1122
|
+
/**
|
|
1123
|
+
* The modifier the web driver uses for editing shortcuts, from
|
|
1124
|
+
* `--key-modifier meta|control`, else `RAVENSIGHT_KEY_MODIFIER`, else the
|
|
1125
|
+
* platform's own (Command on macOS, Control elsewhere, decided in the driver).
|
|
1126
|
+
* For a remote or containerised browser whose platform is not this machine's.
|
|
1127
|
+
*
|
|
1128
|
+
* @param {string} [value] the flag's value
|
|
1129
|
+
* @param {Object} [env]
|
|
1130
|
+
* @returns {{modifier: 'Meta'|'Control'|null, error?: string}}
|
|
1131
|
+
*/
|
|
1132
|
+
export function resolveKeyModifier(value, env = process.env) {
|
|
1133
|
+
const raw = value !== undefined && value !== null && value !== '' ? value : env.RAVENSIGHT_KEY_MODIFIER;
|
|
1134
|
+
if (raw === undefined || raw === null || raw === '') return { modifier: null };
|
|
1135
|
+
const key = String(raw).trim().toLowerCase();
|
|
1136
|
+
if (['meta', 'cmd', 'command'].includes(key)) return { modifier: 'Meta' };
|
|
1137
|
+
if (['control', 'ctrl'].includes(key)) return { modifier: 'Control' };
|
|
1138
|
+
return { modifier: null, error: `Choose --key-modifier meta or --key-modifier control (or set RAVENSIGHT_KEY_MODIFIER), not "${raw}".` };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1057
1141
|
/**
|
|
1058
1142
|
* The persona reports on disk, for the aggregate. A partial report (written
|
|
1059
1143
|
* from the notes when the money ran out) is loaded as a `budget_exceeded`
|
|
1060
1144
|
* run: its findings count, and the aggregate says the job is partial.
|
|
1061
1145
|
*/
|
|
1062
|
-
export async function loadRunReports(repoRoot, jobId, personaRuns) {
|
|
1146
|
+
export async function loadRunReports(repoRoot, jobId, personaRuns, journal = null) {
|
|
1063
1147
|
const out = [];
|
|
1064
1148
|
for (const entry of personaRuns) {
|
|
1149
|
+
// A report the server refused is on disk but not in the record: the
|
|
1150
|
+
// summary must not count findings the dashboard does not have.
|
|
1151
|
+
const recorded = journal && journal.runs ? journal.runs[entry.run_id] : null;
|
|
1152
|
+
if (recorded && recorded.report_refused === true) {
|
|
1153
|
+
out.push({ runId: entry.run_id, persona: entry.persona, state: 'failed', report: null, reportRefused: true });
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1065
1156
|
const file = path.join(runDirFor(repoRoot, jobId, entry.run_id), RUN_FILES.reportJson);
|
|
1066
1157
|
try {
|
|
1067
1158
|
const report = JSON.parse(await readFile(file, 'utf8'));
|
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
|
|
|
@@ -624,7 +628,9 @@ export function renderAggregateMarkdown(json) {
|
|
|
624
628
|
? `Observations dropped for lack of evidence:\n${json.observations.map(o => `- ${o.persona}: ${o.text}`).join('\n')}`
|
|
625
629
|
: 'No dropped observations.');
|
|
626
630
|
lines.push('');
|
|
627
|
-
|
|
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 });
|
|
628
634
|
}
|
|
629
635
|
|
|
630
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) }
|