ravensight-playtest 0.2.8 → 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 +19 -1
- package/package.json +1 -1
- package/src/commands/run.js +49 -6
- package/src/run/drivers/web.js +206 -16
- package/src/run/heartbeat.js +8 -1
- package/src/run/personaLoop.js +80 -26
- package/src/run/report.js +93 -2
- package/src/run/resume.js +9 -0
- package/src/run/state.js +4 -1
package/README.md
CHANGED
|
@@ -254,7 +254,15 @@ How the report step reads what happened:
|
|
|
254
254
|
- Every note carries `after`, the URL and title of the screen that followed
|
|
255
255
|
it. A blocker or major finding has to cite the observation where the failure
|
|
256
256
|
is visible and must not be contradicted by that next screen or by a later
|
|
257
|
-
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.
|
|
258
266
|
- The model is told never to write calendar dates. `report.md` carries the
|
|
259
267
|
CLI's own `Generated at` timestamp under its title, and the aggregate report's
|
|
260
268
|
header uses its `generated_at`.
|
|
@@ -285,6 +293,12 @@ macOS, Windows and Linux; ordinary typing is keystrokes. An editing shortcut
|
|
|
285
293
|
such as select all uses the platform's own modifier (Command on a Mac, Control
|
|
286
294
|
elsewhere, or `--key-modifier`).
|
|
287
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
|
+
|
|
288
302
|
Each persona starts at the game's URL as a fresh player with no account, so a
|
|
289
303
|
sign-up form with validation can use up a whole unit before the game itself is
|
|
290
304
|
reached. Point `--build-url` at a guest or demo path, or say where to start in
|
|
@@ -440,6 +454,10 @@ lost to it. The CLI lints locally first and feeds any miss back into one repair
|
|
|
440
454
|
turn, which costs a model turn; a report written with the four headings from the
|
|
441
455
|
start avoids that.
|
|
442
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
|
+
|
|
443
461
|
## Exit codes
|
|
444
462
|
|
|
445
463
|
| Code | Meaning |
|
package/package.json
CHANGED
package/src/commands/run.js
CHANGED
|
@@ -445,7 +445,10 @@ export async function playJob({
|
|
|
445
445
|
const reportBudgetRefused = results.some(result => result.reportStepBudget === 'refused');
|
|
446
446
|
const reportBudgetDelivered = results.some(result => result.reportStepBudget === 'delivered' || result.reportStepBudget === 'dry_upload');
|
|
447
447
|
const canceled = results.some(result => result.state === 'canceled');
|
|
448
|
-
|
|
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);
|
|
449
452
|
|
|
450
453
|
if (finish) {
|
|
451
454
|
const finished = await api.jobs.finish(gameId, jobId, {
|
|
@@ -459,7 +462,7 @@ export async function playJob({
|
|
|
459
462
|
log(`Reports: ${directory}`);
|
|
460
463
|
|
|
461
464
|
if (canceled) return EXIT.CANCELED;
|
|
462
|
-
if (reportBudgetRefused) return EXIT.FAILED;
|
|
465
|
+
if (reportBudgetRefused || reportRefused) return EXIT.FAILED;
|
|
463
466
|
if (playBudgetHit) return EXIT.BUDGET;
|
|
464
467
|
return anySucceeded || reportBudgetDelivered ? EXIT.OK : EXIT.FAILED;
|
|
465
468
|
}
|
|
@@ -620,7 +623,15 @@ export async function playPersonaRun({
|
|
|
620
623
|
budget_warned: outcome.budgetWarned
|
|
621
624
|
});
|
|
622
625
|
|
|
623
|
-
|
|
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
|
+
}
|
|
624
635
|
|
|
625
636
|
if (outcome.report) {
|
|
626
637
|
const emitted = await emitRunEvents({
|
|
@@ -713,7 +724,7 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
713
724
|
const { report: ignored, report_md: alsoIgnored, ...withoutReport } = body;
|
|
714
725
|
answer = await api.runs.complete(gameId, jobId, runId, withoutReport);
|
|
715
726
|
}
|
|
716
|
-
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 });
|
|
717
728
|
if (answer && answer.dropped_findings > 0) {
|
|
718
729
|
log(`run ${runId}: the server dropped ${answer.dropped_findings} finding(s) with no usable evidence.`);
|
|
719
730
|
await markDropped({ directory, jobId, runId, repoRoot, outcome, droppedCount: answer.dropped_findings, droppedIds: answer.dropped_finding_ids, options, log });
|
|
@@ -728,11 +739,36 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
728
739
|
delivery.status = error && error.status;
|
|
729
740
|
delivery.code = error && error.code;
|
|
730
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
|
+
}
|
|
731
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.`);
|
|
732
754
|
return null;
|
|
733
755
|
}
|
|
734
756
|
}
|
|
735
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
|
+
|
|
736
772
|
/**
|
|
737
773
|
* Bring the local report into line with what the server kept, so report.md,
|
|
738
774
|
* report.json, the aggregate and the telemetry count what the dashboard
|
|
@@ -843,7 +879,7 @@ async function aggregatePhase({
|
|
|
843
879
|
|
|
844
880
|
// The aggregate reads reports off disk, so a resumed job whose earlier runs
|
|
845
881
|
// were played by a previous process is covered too.
|
|
846
|
-
const reports = await loadRunReports(repoRoot, jobId, personaRuns);
|
|
882
|
+
const reports = await loadRunReports(repoRoot, jobId, personaRuns, journal);
|
|
847
883
|
let aggregate;
|
|
848
884
|
try {
|
|
849
885
|
aggregate = await buildAggregate({
|
|
@@ -1107,9 +1143,16 @@ export function resolveKeyModifier(value, env = process.env) {
|
|
|
1107
1143
|
* from the notes when the money ran out) is loaded as a `budget_exceeded`
|
|
1108
1144
|
* run: its findings count, and the aggregate says the job is partial.
|
|
1109
1145
|
*/
|
|
1110
|
-
export async function loadRunReports(repoRoot, jobId, personaRuns) {
|
|
1146
|
+
export async function loadRunReports(repoRoot, jobId, personaRuns, journal = null) {
|
|
1111
1147
|
const out = [];
|
|
1112
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
|
+
}
|
|
1113
1156
|
const file = path.join(runDirFor(repoRoot, jobId, entry.run_id), RUN_FILES.reportJson);
|
|
1114
1157
|
try {
|
|
1115
1158
|
const report = JSON.parse(await readFile(file, 'utf8'));
|
package/src/run/drivers/web.js
CHANGED
|
@@ -77,9 +77,22 @@ const INTERACTIVE_ROLES = new Set([
|
|
|
77
77
|
* @returns {string}
|
|
78
78
|
*/
|
|
79
79
|
export function compactAriaSnapshot(text) {
|
|
80
|
+
const nodes = compactAriaNodes(text);
|
|
81
|
+
return nodes === null ? String(text == null ? '' : text) : nodes.map(node => node.line).join('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The kept nodes of an aria snapshot with the depth each one had, or null for
|
|
86
|
+
* text that is not an aria snapshot. The depth is not shown to the model; it
|
|
87
|
+
* is what tells repeated siblings apart from a panel that follows them.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} text
|
|
90
|
+
* @returns {Array<{depth: number, line: string, body: string, role: string}>|null}
|
|
91
|
+
*/
|
|
92
|
+
export function compactAriaNodes(text) {
|
|
80
93
|
const value = String(text == null ? '' : text);
|
|
81
94
|
const lines = value.split('\n');
|
|
82
|
-
if (!lines.some(line => /^\s*- /.test(line))) return
|
|
95
|
+
if (!lines.some(line => /^\s*- /.test(line))) return null;
|
|
83
96
|
const kept = [];
|
|
84
97
|
for (const line of lines) {
|
|
85
98
|
const trimmed = line.trim();
|
|
@@ -89,9 +102,163 @@ export function compactAriaSnapshot(text) {
|
|
|
89
102
|
const role = (body.match(/^([a-z]+)/) || [])[1] || '';
|
|
90
103
|
const named = /^[a-z]+ "[^"]*"/.test(body);
|
|
91
104
|
const inlineText = /^[a-z]+(?: "[^"]*")?(?: \[[^\]]*\])*: \S/.test(body);
|
|
92
|
-
if (INTERACTIVE_ROLES.has(role) || named || inlineText)
|
|
105
|
+
if (INTERACTIVE_ROLES.has(role) || named || inlineText) {
|
|
106
|
+
kept.push({ depth: line.length - line.trimStart().length, line: `- ${body}`, body, role });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return kept;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Roles a player types into or sets, kept first when an observation has to be cut. */
|
|
113
|
+
const INPUT_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'slider', 'spinbutton', 'switch', 'listbox']);
|
|
114
|
+
|
|
115
|
+
/** The share of interactive nodes, counted from the end of the page, kept ahead of the middle when a page is cut. */
|
|
116
|
+
export const TAIL_FRACTION = 0.1;
|
|
117
|
+
|
|
118
|
+
/** The tail window is never smaller than this many interactive nodes. */
|
|
119
|
+
const TAIL_MIN = 3;
|
|
120
|
+
|
|
121
|
+
/** A run of this many look-alike siblings or more is collapsed when the page is over the cap. */
|
|
122
|
+
export const COLLAPSE_MIN_RUN = 4;
|
|
123
|
+
|
|
124
|
+
/** How many of a collapsed run are still shown, so the model sees what the items look like. */
|
|
125
|
+
export const COLLAPSE_KEEP = 2;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The shape of one node, without the words that make it unique: its role,
|
|
129
|
+
* its state attributes, whether it has a name, whether the name carries
|
|
130
|
+
* digits and how many separators. Two school cards share a shape; the card
|
|
131
|
+
* list and a "TAKE THE JOB" button after it do not.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} body
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
function nodeShape(body) {
|
|
137
|
+
const role = (body.match(/^([a-z]+)/) || [])[1] || '';
|
|
138
|
+
const name = (body.match(/^[a-z]+ "([^"]*)"/) || [])[1];
|
|
139
|
+
const attributes = [...body.matchAll(/\[([a-z-]+)(?:=[^\]]*)?\]/g)].map(match => match[1]).filter(key => key !== 'ref').join(',');
|
|
140
|
+
const nameShape = name === undefined ? '' : `n${/\d/.test(name) ? 'd' : ''}${(name.match(/[·•|/]/g) || []).length}`;
|
|
141
|
+
const inline = /: \S/.test(body.replace(/^[a-z]+(?: "[^"]*")?/, '')) ? 't' : '';
|
|
142
|
+
return `${role}|${nameShape}|${attributes}|${inline}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Collapse runs of look-alike siblings (a node and its kept descendants,
|
|
147
|
+
* compared by shape) into the first {@link COLLAPSE_KEEP} plus one line that
|
|
148
|
+
* says how many more there are.
|
|
149
|
+
*
|
|
150
|
+
* @param {Array<{depth: number, line: string, body: string, role: string}>} nodes
|
|
151
|
+
* @returns {{entries: Array<{line: string, role: string, marker?: boolean}>, collapsed: number}}
|
|
152
|
+
*/
|
|
153
|
+
export function collapseRepeatedSiblings(nodes) {
|
|
154
|
+
const unitEnd = start => {
|
|
155
|
+
let end = start + 1;
|
|
156
|
+
while (end < nodes.length && nodes[end].depth > nodes[start].depth) end += 1;
|
|
157
|
+
return end;
|
|
158
|
+
};
|
|
159
|
+
const signature = (start, end) => nodes.slice(start, end).map(node => `${node.depth - nodes[start].depth}:${nodeShape(node.body)}`).join(';');
|
|
160
|
+
const entries = [];
|
|
161
|
+
let collapsed = 0;
|
|
162
|
+
let i = 0;
|
|
163
|
+
while (i < nodes.length) {
|
|
164
|
+
const units = [[i, unitEnd(i)]];
|
|
165
|
+
const sig = signature(i, units[0][1]);
|
|
166
|
+
let next = units[0][1];
|
|
167
|
+
while (next < nodes.length && nodes[next].depth === nodes[i].depth) {
|
|
168
|
+
const end = unitEnd(next);
|
|
169
|
+
if (signature(next, end) !== sig) break;
|
|
170
|
+
units.push([next, end]);
|
|
171
|
+
next = end;
|
|
172
|
+
}
|
|
173
|
+
if (units.length >= COLLAPSE_MIN_RUN) {
|
|
174
|
+
for (const [start, end] of units.slice(0, COLLAPSE_KEEP)) {
|
|
175
|
+
for (const node of nodes.slice(start, end)) entries.push({ line: node.line, role: node.role });
|
|
176
|
+
}
|
|
177
|
+
const more = units.length - COLLAPSE_KEEP;
|
|
178
|
+
collapsed += more;
|
|
179
|
+
entries.push({ line: `- ... and ${more} more like it (${nodes[i].role || 'items'}; target one by its name)`, role: '', marker: true });
|
|
180
|
+
i = next;
|
|
181
|
+
} else {
|
|
182
|
+
entries.push({ line: nodes[i].line, role: nodes[i].role });
|
|
183
|
+
i += 1;
|
|
184
|
+
}
|
|
93
185
|
}
|
|
94
|
-
return
|
|
186
|
+
return { entries, collapsed };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Fit a compacted aria snapshot under the web ceiling without losing the
|
|
191
|
+
* part of the page the persona has to act on.
|
|
192
|
+
*
|
|
193
|
+
* Under the cap nothing changes. Over it, in this order: interactive nodes
|
|
194
|
+
* are what is kept (the last tenth of them on the page first, since that is
|
|
195
|
+
* where a form after a long list sits, then inputs, then buttons and links,
|
|
196
|
+
* then any role with a name); runs of look-alike siblings collapse to their
|
|
197
|
+
* first two and a count; nodes that are new since the previous observation
|
|
198
|
+
* come next; then
|
|
199
|
+
* the rest, and only what still does not fit is trimmed. Kept lines stay in
|
|
200
|
+
* page order, and a first line says how many items were collapsed and how
|
|
201
|
+
* many lines were left out, so the model knows the screen was cut.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} text the raw aria snapshot
|
|
204
|
+
* @param {{max?: number, previous?: Set<string>|null}} [options]
|
|
205
|
+
* @returns {{text: string, lines: Array<string>}|null} null when the text is not an aria snapshot
|
|
206
|
+
*/
|
|
207
|
+
export function fitAriaSnapshot(text, { max = WEB_OBSERVATION_MAX_CHARS, previous = null } = {}) {
|
|
208
|
+
const nodes = compactAriaNodes(text);
|
|
209
|
+
if (nodes === null) return null;
|
|
210
|
+
const lines = nodes.map(node => node.line);
|
|
211
|
+
const full = lines.join('\n');
|
|
212
|
+
if (full.length <= max) return { text: full, lines };
|
|
213
|
+
|
|
214
|
+
const { entries, collapsed } = collapseRepeatedSiblings(nodes);
|
|
215
|
+
const header = (left, out) => {
|
|
216
|
+
const parts = [];
|
|
217
|
+
if (left > 0) parts.push(`${left} similar items collapsed`);
|
|
218
|
+
if (out > 0) parts.push(`${out} lines left out to fit`);
|
|
219
|
+
return parts.length > 0 ? `[${parts.join('; ')}]` : '';
|
|
220
|
+
};
|
|
221
|
+
const collapsedText = entries.map(entry => entry.line).join('\n');
|
|
222
|
+
const withHeader = `${header(collapsed, 0)}\n${collapsedText}`;
|
|
223
|
+
if (withHeader.length <= max) return { text: withHeader, lines };
|
|
224
|
+
|
|
225
|
+
const rank = entry => {
|
|
226
|
+
if (entry.marker) return 0;
|
|
227
|
+
if (INPUT_ROLES.has(entry.role)) return 1;
|
|
228
|
+
if (entry.role === 'button' || entry.role === 'link') return 2;
|
|
229
|
+
if (INTERACTIVE_ROLES.has(entry.role) || /^- [a-z]+ "/.test(entry.line)) return 3;
|
|
230
|
+
return 5;
|
|
231
|
+
};
|
|
232
|
+
const isNew = entry => Boolean(previous) && !entry.marker && !previous.has(entry.line);
|
|
233
|
+
// The last interactive nodes on the page are usually its form or its
|
|
234
|
+
// confirm button (a panel after a list). When the list could not be
|
|
235
|
+
// collapsed they would otherwise lose to the list on page order, so the
|
|
236
|
+
// tail window, the final tenth of interactive nodes, ranks above the middle.
|
|
237
|
+
const interactive = entries.map((entry, index) => ({ entry, index })).filter(({ entry }) => !entry.marker && rank(entry) < 5);
|
|
238
|
+
const tailSize = Math.max(TAIL_MIN, Math.ceil(interactive.length * TAIL_FRACTION));
|
|
239
|
+
const tail = new Set(interactive.slice(-tailSize).map(({ index }) => index));
|
|
240
|
+
const tierOf = (entry, index) => {
|
|
241
|
+
if (entry.marker) return 0;
|
|
242
|
+
if (tail.has(index)) return 1 + (isNew(entry) ? 0 : 0.5);
|
|
243
|
+
if (rank(entry) < 5) return 2 + rank(entry) * 2 + (isNew(entry) ? 0 : 1);
|
|
244
|
+
return isNew(entry) ? 20 : 21;
|
|
245
|
+
};
|
|
246
|
+
const order = entries
|
|
247
|
+
.map((entry, index) => ({ entry, index, tier: tierOf(entry, index) }))
|
|
248
|
+
.sort((a, b) => a.tier - b.tier || a.index - b.index);
|
|
249
|
+
// Room for the header line, whose figures are not known until the end.
|
|
250
|
+
const budget = max - 80;
|
|
251
|
+
const chosen = new Set();
|
|
252
|
+
let used = 0;
|
|
253
|
+
for (const candidate of order) {
|
|
254
|
+
const cost = candidate.entry.line.length + 1;
|
|
255
|
+
if (used + cost > budget) continue;
|
|
256
|
+
chosen.add(candidate.index);
|
|
257
|
+
used += cost;
|
|
258
|
+
}
|
|
259
|
+
const kept = entries.filter((entry, index) => chosen.has(index)).map(entry => entry.line);
|
|
260
|
+
const out = entries.length - kept.length;
|
|
261
|
+
return { text: truncateObservation(`${header(collapsed, out)}\n${kept.join('\n')}`, max), lines };
|
|
95
262
|
}
|
|
96
263
|
|
|
97
264
|
/**
|
|
@@ -323,6 +490,8 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
323
490
|
let shots = 0;
|
|
324
491
|
let lastHash = null;
|
|
325
492
|
let lastObservedHash = null;
|
|
493
|
+
/** The compacted lines of the last observation, so a cut can prefer what is new. */
|
|
494
|
+
let lastObservedLines = null;
|
|
326
495
|
const consoleBuffer = [];
|
|
327
496
|
|
|
328
497
|
/** @returns {any} */
|
|
@@ -367,13 +536,33 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
367
536
|
}
|
|
368
537
|
|
|
369
538
|
/**
|
|
370
|
-
* What the model is shown for a raw snapshot
|
|
371
|
-
*
|
|
539
|
+
* What the model is shown for a raw snapshot, and the lines it was built
|
|
540
|
+
* from. A page over the web ceiling is fitted rather than cut at the
|
|
541
|
+
* character limit (see fitAriaSnapshot), so a panel after a long list
|
|
542
|
+
* still reaches the model.
|
|
372
543
|
* @param {{text: string, aria: boolean}} snap
|
|
373
|
-
* @returns {string}
|
|
544
|
+
* @returns {{text: string, lines: Array<string>|null}}
|
|
374
545
|
*/
|
|
375
546
|
function shownText(snap) {
|
|
376
|
-
|
|
547
|
+
if (snap.aria) {
|
|
548
|
+
const fitted = fitAriaSnapshot(snap.text, { max: WEB_OBSERVATION_MAX_CHARS, previous: lastObservedLines ? new Set(lastObservedLines) : null });
|
|
549
|
+
if (fitted) return fitted;
|
|
550
|
+
}
|
|
551
|
+
return { text: truncateObservation(snap.text, WEB_OBSERVATION_MAX_CHARS), lines: null };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* What `changed` and `unchanged` compare. For an aria snapshot that is the
|
|
556
|
+
* whole compacted tree, not the fitted text: the fit depends on the
|
|
557
|
+
* previous observation, so hashing it would read the same screen as
|
|
558
|
+
* changed. For the innerText fallback it is the truncated text, so a
|
|
559
|
+
* difference the model never saw is not a change.
|
|
560
|
+
* @param {{text: string, aria: boolean}} snap
|
|
561
|
+
* @param {{text: string, lines: Array<string>|null}} shown
|
|
562
|
+
* @returns {string}
|
|
563
|
+
*/
|
|
564
|
+
function hashText(snap, shown) {
|
|
565
|
+
return shown.lines ? shown.lines.join('\n') : shown.text;
|
|
377
566
|
}
|
|
378
567
|
|
|
379
568
|
/**
|
|
@@ -468,9 +657,10 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
468
657
|
step += 1;
|
|
469
658
|
const snap = await snapshotText();
|
|
470
659
|
const text = snap.text;
|
|
660
|
+
const shown = shownText(snap);
|
|
471
661
|
const observation = {
|
|
472
662
|
step,
|
|
473
|
-
text:
|
|
663
|
+
text: shown.text,
|
|
474
664
|
url: current.url(),
|
|
475
665
|
elapsedMs: Date.now() - started
|
|
476
666
|
};
|
|
@@ -497,11 +687,11 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
497
687
|
// read it as a blank screen, which is what it is.
|
|
498
688
|
}
|
|
499
689
|
}
|
|
500
|
-
// Hash what
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
690
|
+
// Hash what hashText says, which `act` hashes the same way: the whole
|
|
691
|
+
// compacted tree for an aria snapshot (a change anywhere in it is shown,
|
|
692
|
+
// because a fit keeps new nodes first), the truncated text otherwise.
|
|
693
|
+
lastHash = observationHash({ text: hashText(snap, shown), screenshotBase64: observation.screenshotBase64 });
|
|
694
|
+
lastObservedLines = shown.lines;
|
|
505
695
|
// The same screen twice is sent as a marker rather than in full
|
|
506
696
|
// (./tools.js), which is most of what a stuck persona would otherwise
|
|
507
697
|
// pay for.
|
|
@@ -554,10 +744,10 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
|
|
|
554
744
|
|
|
555
745
|
let changed = false;
|
|
556
746
|
try {
|
|
557
|
-
// The same
|
|
558
|
-
//
|
|
747
|
+
// The same basis observe() hashes, so the two are comparable.
|
|
748
|
+
// Without it a long screen is always "changed".
|
|
559
749
|
const snap = await snapshotText();
|
|
560
|
-
const after = { text: shownText(snap) };
|
|
750
|
+
const after = { text: hashText(snap, shownText(snap)) };
|
|
561
751
|
if (String(snap.text).trim().length < THIN_OBSERVATION_CHARS) {
|
|
562
752
|
const frame = await current.screenshot({ type: 'png', timeout: ACTION_TIMEOUT_MS });
|
|
563
753
|
after.screenshotBase64 = frame.toString('base64');
|
package/src/run/heartbeat.js
CHANGED
|
@@ -58,6 +58,11 @@ export function createHeartbeat({
|
|
|
58
58
|
let canceled = false;
|
|
59
59
|
let failures = 0;
|
|
60
60
|
let inFlight = false;
|
|
61
|
+
// Set by stop(). A beat already on the wire when the run ends must not
|
|
62
|
+
// write its progress after the run's final state: in 0.2.8 one landed
|
|
63
|
+
// after the outcome and put a finished run back to `playing` in the
|
|
64
|
+
// journal.
|
|
65
|
+
let stopped = false;
|
|
61
66
|
// The progress the loop last reported. The interval beat carries it too,
|
|
62
67
|
// rather than resetting the server's and the journal's idea of the
|
|
63
68
|
// checkpoint to zero every five seconds.
|
|
@@ -78,7 +83,7 @@ export function createHeartbeat({
|
|
|
78
83
|
try {
|
|
79
84
|
const answer = await api.runs.heartbeat(gameId, jobId, runId, { heartbeat: true, ...progress });
|
|
80
85
|
failures = 0;
|
|
81
|
-
if (onBeat) await onBeat({ ...progress });
|
|
86
|
+
if (onBeat && !stopped) await onBeat({ ...progress });
|
|
82
87
|
if (answer && answer.run && answer.run.usage && onUsage) onUsage(answer.run.usage);
|
|
83
88
|
if (answer && answer.cancel_requested && !canceled) {
|
|
84
89
|
canceled = true;
|
|
@@ -111,6 +116,7 @@ export function createHeartbeat({
|
|
|
111
116
|
},
|
|
112
117
|
|
|
113
118
|
start() {
|
|
119
|
+
stopped = false;
|
|
114
120
|
if (timer) return this;
|
|
115
121
|
timer = setIntervalImpl(() => {
|
|
116
122
|
beat().catch(() => {});
|
|
@@ -121,6 +127,7 @@ export function createHeartbeat({
|
|
|
121
127
|
},
|
|
122
128
|
|
|
123
129
|
stop() {
|
|
130
|
+
stopped = true;
|
|
124
131
|
if (timer) clearIntervalImpl(timer);
|
|
125
132
|
timer = null;
|
|
126
133
|
return this;
|
package/src/run/personaLoop.js
CHANGED
|
@@ -2,11 +2,10 @@ import { selectAction, JEV_CONTROLLER_PROMPT } from './actionSelector.js';
|
|
|
2
2
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
|
|
5
|
-
import { observationHash } from './drivers/driver.js';
|
|
6
5
|
import { withRollingCacheBreakpoint } from './fixLoop.js';
|
|
7
6
|
import { callStepWithRetry, ProxyError, STEPS } from './model.js';
|
|
8
7
|
import { RUN_FILES } from './paths.js';
|
|
9
|
-
import { checkEvidence, createReportValidator, lintMarkdownSections, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
|
|
8
|
+
import { checkEvidence, createReportValidator, lintMarkdownSections, normaliseEvidence, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
|
|
10
9
|
import { assertNoSecrets } from './secretScan.js';
|
|
11
10
|
import { ACTION_TOOLS, PLAY_TOOLS, SWIPE_TOOL, REPORT_TOOLS, executeTool } from './tools.js';
|
|
12
11
|
import { createTranscript } from './transcript.js';
|
|
@@ -388,7 +387,7 @@ async function playLoop({
|
|
|
388
387
|
latestObservation = first.text;
|
|
389
388
|
visualObservation = Boolean(first.screenshotBase64);
|
|
390
389
|
transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
|
|
391
|
-
const landing = await safeScreenshot(driver, 'landing', screenshots, transcript);
|
|
390
|
+
const landing = await safeScreenshot(driver, 'landing', screenshots, transcript, observations.length);
|
|
392
391
|
messages.push({
|
|
393
392
|
role: 'user',
|
|
394
393
|
content: [
|
|
@@ -503,7 +502,7 @@ async function playLoop({
|
|
|
503
502
|
transcript.write({ kind: 'note', tool: toolUse.name, note });
|
|
504
503
|
},
|
|
505
504
|
onScreenshot: shot => {
|
|
506
|
-
screenshots.push({ ...shot, order: nextOrder() });
|
|
505
|
+
screenshots.push({ ...shot, observation: observations.length, order: nextOrder() });
|
|
507
506
|
transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: shot.reason });
|
|
508
507
|
}
|
|
509
508
|
});
|
|
@@ -552,7 +551,7 @@ async function playLoop({
|
|
|
552
551
|
// with the persona's view of the screen in front of it. A 4xx on a
|
|
553
552
|
// resource is a `network` note, which the report step reads as context
|
|
554
553
|
// rather than as a bug (a missing favicon is not a broken game).
|
|
555
|
-
const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript);
|
|
554
|
+
const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript, observations.length);
|
|
556
555
|
for (const entry of consoleErrors) {
|
|
557
556
|
const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
|
|
558
557
|
const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true, ...(shot ? { screenshot: shot.relativePath } : {}) };
|
|
@@ -592,7 +591,7 @@ async function playLoop({
|
|
|
592
591
|
quitDetail = `the action budget of ${maxActions} ran out`;
|
|
593
592
|
}
|
|
594
593
|
|
|
595
|
-
const final = await safeScreenshot(driver, 'final', screenshots, transcript);
|
|
594
|
+
const final = await safeScreenshot(driver, 'final', screenshots, transcript, observations.length);
|
|
596
595
|
return {
|
|
597
596
|
actionsTaken: actions,
|
|
598
597
|
turns,
|
|
@@ -651,12 +650,15 @@ async function reportPhase({
|
|
|
651
650
|
observationLines(observations),
|
|
652
651
|
'',
|
|
653
652
|
'Each note carries "after": the URL and title of the observation that followed it. A blocker or major finding must cite the observation index where the failure is visible, as evidence {"type": "transcript_step", "ref": "observation N"}, and must not be contradicted by its note\'s "after" state or by a later persona note: if the next screen or a later note shows the game working, it is not a blocker or a major. Cite a note as {"type": "transcript_step", "ref": "note N"}.',
|
|
653
|
+
'A blocker or major finding must also cite the screenshot taken at or after that observation (the list below says which observation each one followed), and that screenshot must not contradict it: if it shows the thing the finding says is missing or broken, such as the field or the button, it is not a blocker or a major. A text observation can be cut short on a long page; the screenshot is what the player saw.',
|
|
654
|
+
'',
|
|
655
|
+
'Every evidence entry is an object with exactly two keys, "type" and "ref", and nothing else. A screenshot is {"type": "screenshot", "ref": "screenshots/04-example.png"}, never "path" or "file".',
|
|
654
656
|
'',
|
|
655
657
|
'Never write calendar dates or times in the prose of report.md or report.json. The runner stamps when the report was generated, so leave the template\'s Date out rather than writing a placeholder for it.',
|
|
656
658
|
'Replace every <...> slot in the template with real content. Never copy a <...> placeholder into the report.',
|
|
657
659
|
'',
|
|
658
660
|
'Screenshots available as evidence (use these exact paths in evidence refs):',
|
|
659
|
-
screenshots.length > 0 ? screenshots.map(
|
|
661
|
+
screenshots.length > 0 ? screenshots.map(screenshotLine).join('\n') : '(none)',
|
|
660
662
|
'',
|
|
661
663
|
`report.md must contain these section headings, spelled exactly: ${REQUIRED_MARKDOWN_SECTIONS.map(section => `"${section}"`).join(', ')}.`,
|
|
662
664
|
'Write report.md first, then report.json. Leave usage as zeros; the runner fills it in.'
|
|
@@ -667,12 +669,20 @@ async function reportPhase({
|
|
|
667
669
|
let report = null;
|
|
668
670
|
let markdown = null;
|
|
669
671
|
let lint = [];
|
|
670
|
-
|
|
671
|
-
//
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
672
|
+
// The newest report.json the model wrote, across turns: a repair turn that
|
|
673
|
+
// rewrites only report.md keeps the report.json it already wrote.
|
|
674
|
+
let jsonText = null;
|
|
675
|
+
let repaired = false;
|
|
676
|
+
let continued = false;
|
|
677
|
+
|
|
678
|
+
// One repair at most when the schema or the evidence refs refuse what was
|
|
679
|
+
// written: each turn is a metered question, and the dogfood gate asks for
|
|
680
|
+
// "first or the repair attempt", not an open ended loop. A turn that wrote
|
|
681
|
+
// report.md and stopped before report.json is not a refused report, so it
|
|
682
|
+
// gets one plain "now write report.json" that does not spend the repair.
|
|
683
|
+
// In 0.2.8 it did, and the report.json that came back had its five
|
|
684
|
+
// screenshot refs under "path" with no turn left to fix them.
|
|
685
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
676
686
|
const answer = await callStepWithRetry({
|
|
677
687
|
client,
|
|
678
688
|
jobId,
|
|
@@ -695,12 +705,13 @@ async function reportPhase({
|
|
|
695
705
|
files.set(name, String(toolUse.input.content || ''));
|
|
696
706
|
}
|
|
697
707
|
if (files.has(RUN_FILES.reportMd)) markdown = files.get(RUN_FILES.reportMd);
|
|
708
|
+
if (files.has(RUN_FILES.reportJson)) jsonText = files.get(RUN_FILES.reportJson);
|
|
698
709
|
|
|
699
710
|
let parsed = null;
|
|
700
711
|
let parseError = null;
|
|
701
|
-
if (
|
|
712
|
+
if (jsonText !== null) {
|
|
702
713
|
try {
|
|
703
|
-
parsed = JSON.parse(
|
|
714
|
+
parsed = JSON.parse(jsonText);
|
|
704
715
|
} catch (error) {
|
|
705
716
|
parseError = String(error && error.message);
|
|
706
717
|
}
|
|
@@ -720,7 +731,13 @@ async function reportPhase({
|
|
|
720
731
|
quitReason: play.quitReason,
|
|
721
732
|
quitDetail: play.quitDetail
|
|
722
733
|
};
|
|
723
|
-
|
|
734
|
+
// An evidence path under the wrong key is moved to `ref` before anything
|
|
735
|
+
// judges the report; see normaliseEvidence.
|
|
736
|
+
const normalised = parsed ? normaliseEvidence(parsed, runDirectory) : null;
|
|
737
|
+
if (normalised && normalised.changed > 0) {
|
|
738
|
+
transcript.write({ kind: 'report_evidence_normalised', attempt, entries: normalised.changed });
|
|
739
|
+
}
|
|
740
|
+
const candidate = normalised ? capNetworkOnlyFindings(stampReport(normalised.report, facts), { notes, screenshots }) : null;
|
|
724
741
|
const validation = candidate ? validator.validate(candidate) : { ok: false, details: [{ instancePath: '/', message: parseError || 'report.json was not written' }] };
|
|
725
742
|
const evidenceProblems = candidate ? await checkEvidence(candidate, runDirectory) : [];
|
|
726
743
|
// The server lints the same four sections at `complete` and records the
|
|
@@ -742,24 +759,39 @@ async function reportPhase({
|
|
|
742
759
|
...evidenceProblems,
|
|
743
760
|
...missingSections.map(section => `report.md has no "${section}" section`)
|
|
744
761
|
];
|
|
745
|
-
|
|
762
|
+
|
|
763
|
+
// Every tool_use the next request echoes back needs its tool_result, or
|
|
764
|
+
// the API refuses the request. A write cut off at the token limit is
|
|
765
|
+
// malformed: it was not saved, and it is answered so.
|
|
766
|
+
const toolResults = answer.toolUses.map(toolUse => (toolUse.malformed
|
|
767
|
+
? { type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: [{ type: 'text', text: CUT_OFF_WRITE }] }
|
|
768
|
+
: { type: 'tool_result', tool_use_id: toolUse.id, content: [{ type: 'text', text: 'saved' }] }));
|
|
769
|
+
|
|
770
|
+
if (jsonText === null && files.has(RUN_FILES.reportMd) && !continued) {
|
|
771
|
+
continued = true;
|
|
772
|
+
messages.push({ role: 'assistant', content: assistantContent(answer) });
|
|
773
|
+
messages.push({ role: 'user', content: toolResults.concat([{ type: 'text', text: CONTINUE_WITH_JSON }]) });
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (repaired) {
|
|
746
778
|
if (!candidate) throw new Error(`the report step did not produce a valid report.json: ${lint.join('; ')}`);
|
|
747
|
-
//
|
|
779
|
+
// Still imperfect after the repair: keep it, record the lint, and let
|
|
748
780
|
// the server have the final word. It validates the same schema and
|
|
749
781
|
// drops unevidenced findings itself, so this is a report with known
|
|
750
|
-
// problems rather than no report at all.
|
|
782
|
+
// problems rather than no report at all. Every problem on its own line,
|
|
783
|
+
// before anything is uploaded, so a refusal at complete is never the
|
|
784
|
+
// first the developer hears of it.
|
|
751
785
|
report = candidate;
|
|
752
|
-
log(`report.json still has problems after one repair
|
|
786
|
+
log(`report.json still has problems after one repair (${lint.length}); the server will refuse a report that fails its schema:\n${lint.map(line => ` - ${line}`).join('\n')}`);
|
|
753
787
|
break;
|
|
754
788
|
}
|
|
755
789
|
|
|
790
|
+
repaired = true;
|
|
756
791
|
messages.push({ role: 'assistant', content: assistantContent(answer) });
|
|
757
792
|
messages.push({
|
|
758
793
|
role: 'user',
|
|
759
|
-
content:
|
|
760
|
-
.filter(toolUse => !toolUse.malformed)
|
|
761
|
-
.map(toolUse => ({ type: 'tool_result', tool_use_id: toolUse.id, content: [{ type: 'text', text: 'saved' }] }))
|
|
762
|
-
.concat([{ type: 'text', text: repairInstruction(validation.details, evidenceProblems, missingSections) }])
|
|
794
|
+
content: toolResults.concat([{ type: 'text', text: repairInstruction(validation.details, evidenceProblems, missingSections) }])
|
|
763
795
|
});
|
|
764
796
|
}
|
|
765
797
|
|
|
@@ -901,10 +933,10 @@ function redactToolInput(toolUse) {
|
|
|
901
933
|
return rest;
|
|
902
934
|
}
|
|
903
935
|
|
|
904
|
-
async function safeScreenshot(driver, name, screenshots, transcript) {
|
|
936
|
+
async function safeScreenshot(driver, name, screenshots, transcript, observation = null) {
|
|
905
937
|
try {
|
|
906
938
|
const shot = await driver.screenshot(name);
|
|
907
|
-
screenshots.push({ ...shot, reason: name, order: nextOrder() });
|
|
939
|
+
screenshots.push({ ...shot, reason: name, ...(Number.isInteger(observation) ? { observation } : {}), order: nextOrder() });
|
|
908
940
|
transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: name });
|
|
909
941
|
return shot;
|
|
910
942
|
} catch (error) {
|
|
@@ -1122,6 +1154,28 @@ export function noteLine(note, i) {
|
|
|
1122
1154
|
return `${i + 1}. [${note.kind}${note.severity ? `/${note.severity}` : ''}] ${note.text}${tags.length > 0 ? ` (${tags.join(', ')})` : ''}${after}`;
|
|
1123
1155
|
}
|
|
1124
1156
|
|
|
1157
|
+
/** The tool_result for a write_file call that was cut off before its input was complete. */
|
|
1158
|
+
export const CUT_OFF_WRITE = 'That write_file call was cut off before it finished, so nothing was saved. Write that file again, shorter if it has to be.';
|
|
1159
|
+
|
|
1160
|
+
/** The one follow-up a report turn gets for writing report.md and stopping. */
|
|
1161
|
+
export const CONTINUE_WITH_JSON = 'report.md is saved. Now write report.json with write_file. Every evidence entry is exactly {"type": ..., "ref": ...}; a screenshot is {"type": "screenshot", "ref": "screenshots/04-example.png"}.';
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* A screenshot as one line of the report prompt: its path, the observation
|
|
1165
|
+
* it followed (what a blocker or major has to be checked against) and why
|
|
1166
|
+
* it was taken.
|
|
1167
|
+
*
|
|
1168
|
+
* @param {Object} shot
|
|
1169
|
+
* @returns {string}
|
|
1170
|
+
*/
|
|
1171
|
+
export function screenshotLine(shot) {
|
|
1172
|
+
const tags = [
|
|
1173
|
+
Number.isInteger(shot.observation) && shot.observation > 0 ? `after observation ${shot.observation}` : null,
|
|
1174
|
+
shot.reason ? String(shot.reason) : null
|
|
1175
|
+
].filter(Boolean);
|
|
1176
|
+
return `- ${shot.relativePath}${tags.length > 0 ? ` (${tags.join('; ')})` : ''}`;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1125
1179
|
/** The most observations listed in the report prompt; the newest are kept. */
|
|
1126
1180
|
export const MAX_OBSERVATIONS_IN_REPORT = 200;
|
|
1127
1181
|
|
package/src/run/report.js
CHANGED
|
@@ -127,6 +127,86 @@ export function stampReport(report, facts) {
|
|
|
127
127
|
return stamped;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/** Keys a model has been seen to put an evidence path under instead of `ref`. */
|
|
131
|
+
const REF_ALIASES = ['path', 'file', 'screenshot', 'src', 'href', 'location', 'value'];
|
|
132
|
+
|
|
133
|
+
/** The four evidence types the schema allows. */
|
|
134
|
+
const EVIDENCE_TYPES = new Set(['screenshot', 'video_ts', 'transcript_step', 'file_line']);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* One evidence entry in the schema's shape, `{type, ref}` and nothing else,
|
|
138
|
+
* when the model's own words say unambiguously what it meant.
|
|
139
|
+
*
|
|
140
|
+
* The 0.2.8 report that failed upload wrote
|
|
141
|
+
* `{"type": "screenshot", "path": "screenshots/04-04-school-selected.png"}`
|
|
142
|
+
* five times: a real file, under the wrong key. The schema refuses the extra
|
|
143
|
+
* key and the missing `ref`, so the whole report was refused and the run
|
|
144
|
+
* failed over a key name. This moves the value, never invents one: a path
|
|
145
|
+
* still has to name a file in the run directory (`checkEvidence`), and the
|
|
146
|
+
* server still checks it against what was uploaded.
|
|
147
|
+
*
|
|
148
|
+
* Also: a bare string becomes a screenshot or a transcript step by its look;
|
|
149
|
+
* a missing type on a `screenshots/...` ref becomes `screenshot`; a ref
|
|
150
|
+
* written `./screenshots/x.png` or as an absolute path inside the run
|
|
151
|
+
* directory becomes the run relative path the server's manifest uses.
|
|
152
|
+
*
|
|
153
|
+
* @param {any} item
|
|
154
|
+
* @param {string|null} runDirectory
|
|
155
|
+
* @returns {any} the normalised entry, or the input unchanged when it is not an object it can read
|
|
156
|
+
*/
|
|
157
|
+
export function normaliseEvidenceItem(item, runDirectory = null) {
|
|
158
|
+
if (typeof item === 'string') {
|
|
159
|
+
const text = item.trim();
|
|
160
|
+
if (/^(?:\.\/)?screenshots\//.test(text)) return normaliseEvidenceItem({ type: 'screenshot', ref: text }, runDirectory);
|
|
161
|
+
if (/^(?:observation|note|step|action)\s*#?\s*\d+$/i.test(text)) return { type: 'transcript_step', ref: text };
|
|
162
|
+
return item;
|
|
163
|
+
}
|
|
164
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
|
|
165
|
+
let ref = typeof item.ref === 'string' && item.ref.trim() ? item.ref : null;
|
|
166
|
+
if (ref === null) {
|
|
167
|
+
const alias = REF_ALIASES.find(key => typeof item[key] === 'string' && item[key].trim());
|
|
168
|
+
if (alias) ref = item[alias];
|
|
169
|
+
}
|
|
170
|
+
let type = typeof item.type === 'string' ? item.type : null;
|
|
171
|
+
if (!type && ref && /^(?:\.\/)?screenshots\//.test(ref.trim())) type = 'screenshot';
|
|
172
|
+
if (type === null || ref === null || !EVIDENCE_TYPES.has(type)) return item;
|
|
173
|
+
ref = ref.trim();
|
|
174
|
+
if (type === 'screenshot') ref = runRelative(ref, runDirectory);
|
|
175
|
+
return { type, ref };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function runRelative(ref, runDirectory) {
|
|
179
|
+
let value = ref.replace(/\\/g, '/');
|
|
180
|
+
if (runDirectory && path.isAbsolute(ref)) {
|
|
181
|
+
const relative = path.relative(path.resolve(runDirectory), path.resolve(ref));
|
|
182
|
+
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) value = relative.split(path.sep).join('/');
|
|
183
|
+
}
|
|
184
|
+
while (value.startsWith('./')) value = value.slice(2);
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Every finding's evidence through {@link normaliseEvidenceItem}.
|
|
190
|
+
*
|
|
191
|
+
* @param {Object} report
|
|
192
|
+
* @param {string|null} [runDirectory]
|
|
193
|
+
* @returns {{report: Object, changed: number}} a new report, and how many entries were rewritten
|
|
194
|
+
*/
|
|
195
|
+
export function normaliseEvidence(report, runDirectory = null) {
|
|
196
|
+
if (!report || typeof report !== 'object' || !Array.isArray(report.findings)) return { report, changed: 0 };
|
|
197
|
+
let changed = 0;
|
|
198
|
+
const findings = report.findings.map(finding => {
|
|
199
|
+
if (!finding || typeof finding !== 'object' || !Array.isArray(finding.evidence)) return finding;
|
|
200
|
+
const evidence = finding.evidence.map(item => {
|
|
201
|
+
const out = normaliseEvidenceItem(item, runDirectory);
|
|
202
|
+
if (JSON.stringify(out) !== JSON.stringify(item)) changed += 1;
|
|
203
|
+
return out;
|
|
204
|
+
});
|
|
205
|
+
return { ...finding, evidence };
|
|
206
|
+
});
|
|
207
|
+
return { report: { ...report, findings }, changed };
|
|
208
|
+
}
|
|
209
|
+
|
|
130
210
|
/**
|
|
131
211
|
* Check every `screenshot` evidence ref against the files actually on disk.
|
|
132
212
|
*
|
|
@@ -151,8 +231,17 @@ export async function checkEvidence(report, runDirectory) {
|
|
|
151
231
|
continue;
|
|
152
232
|
}
|
|
153
233
|
for (const item of evidence) {
|
|
154
|
-
if (!item || item
|
|
155
|
-
|
|
234
|
+
if (!item || typeof item !== 'object') {
|
|
235
|
+
problems.push(`${finding.id}: an evidence entry is not an object; write {"type": ..., "ref": ...}`);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (typeof item.ref !== 'string' || !item.ref.trim()) {
|
|
239
|
+
const keys = Object.keys(item).filter(key => key !== 'type');
|
|
240
|
+
problems.push(`${finding.id}: a ${item.type || 'typeless'} evidence entry has no "ref"${keys.length > 0 ? ` (it has ${keys.map(key => `"${key}"`).join(', ')})` : ''}; every entry is exactly {"type": ..., "ref": ...}`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (item.type !== 'screenshot') continue;
|
|
244
|
+
const ref = String(item.ref);
|
|
156
245
|
const candidate = path.resolve(runDirectory, ref);
|
|
157
246
|
// Refuse a ref that climbs out of the run directory before touching
|
|
158
247
|
// the filesystem with it.
|
|
@@ -214,6 +303,8 @@ export default {
|
|
|
214
303
|
createReportValidator,
|
|
215
304
|
stampReport,
|
|
216
305
|
checkEvidence,
|
|
306
|
+
normaliseEvidence,
|
|
307
|
+
normaliseEvidenceItem,
|
|
217
308
|
writeReportFiles,
|
|
218
309
|
repairInstruction,
|
|
219
310
|
lintMarkdownSections,
|
package/src/run/resume.js
CHANGED
|
@@ -62,6 +62,15 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
62
62
|
return EXIT.ENVIRONMENT;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// A run whose report the server refused is done here, whatever the server
|
|
66
|
+
// says its state is: it is never finalised from its transcript (which would
|
|
67
|
+
// rewrite the kept report.json and report.md) and never replayed.
|
|
68
|
+
for (const [runId, entry] of Object.entries(journal.runs || {})) {
|
|
69
|
+
if (entry && entry.report_refused === true) {
|
|
70
|
+
log(`run ${runId}: the server refused its report, so it is not replayed or rebuilt. Its report.json, report.md and screenshots are in ${runDirFor(repoRoot, jobId, runId)}.`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
65
74
|
// Reconcile the journal with what the server actually thinks: a run the sweep
|
|
66
75
|
// marked interrupted, or one that finished after the journal was last
|
|
67
76
|
// written, is the server's word against a stale file, and the server wins.
|
package/src/run/state.js
CHANGED
|
@@ -192,7 +192,10 @@ export async function patchRun(repoRoot, state, runId, patch) {
|
|
|
192
192
|
* @returns {boolean}
|
|
193
193
|
*/
|
|
194
194
|
export function isRunDone(entry) {
|
|
195
|
-
|
|
195
|
+
// A run whose report the server refused was played to the end: replaying
|
|
196
|
+
// it would spend a unit again, and rebuilding its report from the
|
|
197
|
+
// transcript would overwrite the one the developer is meant to read.
|
|
198
|
+
return Boolean(entry) && (entry.completed === true || entry.report_refused === true || RUN_DONE_STATES.includes(entry.state));
|
|
196
199
|
}
|
|
197
200
|
|
|
198
201
|
export default { SCHEMA_VERSION, newState, newRunEntry, save, load, patchRun, isRunDone, stateFile };
|