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
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template placeholders a model copied into a report instead of filling.
|
|
3
|
+
*
|
|
4
|
+
* The persona skill's report template reads `**Date:** <date> ...`, and the
|
|
5
|
+
* report prompt says never to write a calendar date, so a model that obeys
|
|
6
|
+
* both writes the placeholder itself (`<run date>`) and the developer reads
|
|
7
|
+
* it as the report's date.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately narrow, because angle brackets in a report are usually real:
|
|
10
|
+
* a key (`<Enter>`, `<Ctrl+A>`), an element (`<form>`, `<title>`), a type
|
|
11
|
+
* (`Array<string>`), a link, or the game's own text. So only two things are
|
|
12
|
+
* treated as a slot:
|
|
13
|
+
*
|
|
14
|
+
* 1. A slot the runner knows (date, driver, target, persona, actions, quit
|
|
15
|
+
* reason, game, version), filled from its own facts, or "not recorded"
|
|
16
|
+
* when it has no value for it.
|
|
17
|
+
* 2. Lowercase words in brackets on a `**Field:**` line, the shape of the
|
|
18
|
+
* template's header, which become "not recorded".
|
|
19
|
+
*
|
|
20
|
+
* Never touched: a `<` straight after a word character, a key name, anything
|
|
21
|
+
* quoted, HTML, autolinks, code spans and fenced blocks.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** What a slot with no value becomes. */
|
|
25
|
+
export const NOT_RECORDED = 'not recorded';
|
|
26
|
+
|
|
27
|
+
const HTML_TAGS = new Set([
|
|
28
|
+
'a', 'abbr', 'article', 'aside', 'b', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'code', 'dd', 'del',
|
|
29
|
+
'details', 'dialog', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5',
|
|
30
|
+
'h6', 'head', 'header', 'hr', 'html', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'label', 'li', 'link', 'main', 'mark',
|
|
31
|
+
'meta', 'nav', 'ol', 'option', 'p', 'picture', 'pre', 'q', 's', 'samp', 'script', 'section', 'select', 'small',
|
|
32
|
+
'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea',
|
|
33
|
+
'tfoot', 'th', 'thead', 'title', 'tr', 'u', 'ul', 'video'
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const KEY_NAMES = new Set([
|
|
37
|
+
'enter', 'return', 'tab', 'space', 'spacebar', 'esc', 'escape', 'backspace', 'delete', 'del', 'insert', 'home', 'end',
|
|
38
|
+
'pageup', 'pagedown', 'up', 'down', 'left', 'right', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'shift',
|
|
39
|
+
'ctrl', 'control', 'alt', 'option', 'cmd', 'command', 'meta', 'super', 'win', 'fn', 'capslock'
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/** `<...>` with no nested bracket and no line break, short enough to be a template slot. */
|
|
43
|
+
const ANGLE = /<([^<>\n]{1,80})>/g;
|
|
44
|
+
|
|
45
|
+
/** A line in the template's header shape: `**Date:** ...`. */
|
|
46
|
+
const FIELD_LINE = /\*\*[^*\n]+:\*\*/;
|
|
47
|
+
|
|
48
|
+
const QUOTES = { '"': '"', "'": "'", '“': '”', '‘': '’', '`': '`' };
|
|
49
|
+
|
|
50
|
+
function normalise(inner) {
|
|
51
|
+
return String(inner).trim().toLowerCase().replace(/[_-]+/g, ' ').replace(/\s+/g, ' ');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether the bracket is a slot the runner knows, and its value if it has one.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} inner
|
|
58
|
+
* @param {Object} facts
|
|
59
|
+
* @returns {{known: boolean, value: string|null}}
|
|
60
|
+
*/
|
|
61
|
+
function knownSlot(inner, facts) {
|
|
62
|
+
const key = normalise(inner);
|
|
63
|
+
const date = facts.generatedAt ? String(facts.generatedAt).slice(0, 10) : null;
|
|
64
|
+
const actions = facts.actionsTaken === null || facts.actionsTaken === undefined || !Number.isFinite(Number(facts.actionsTaken))
|
|
65
|
+
? null
|
|
66
|
+
: Number(facts.actionsTaken);
|
|
67
|
+
const slot = value => ({ known: true, value: value === null || value === undefined || value === '' ? null : String(value) });
|
|
68
|
+
if (/^(run |report |session )?(date|timestamp|date and time)$/.test(key) || key === 'generated at') return slot(date);
|
|
69
|
+
if (/^driver( name)?$/.test(key)) return slot(facts.driver);
|
|
70
|
+
if (/^(target|target url|build url|game url)$/.test(key)) return slot(facts.target);
|
|
71
|
+
if (/^persona( name| slug)?$/.test(key)) return slot(facts.persona);
|
|
72
|
+
if (/^(n|number of) actions$/.test(key)) return slot(actions === null ? null : `${actions} actions`);
|
|
73
|
+
if (key === 'actions taken') return slot(actions);
|
|
74
|
+
if (/^(quit|stop) reason$/.test(key)) return slot(facts.quitReason);
|
|
75
|
+
if (key === 'game' || key === 'game name') return slot(facts.game);
|
|
76
|
+
if (key === 'version' || key === 'game version') return slot(facts.version);
|
|
77
|
+
return { known: false, value: null };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The replacement for one bracket match, or null to leave it as written.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} text the prose segment the match is in
|
|
84
|
+
* @param {string} whole the match, brackets included
|
|
85
|
+
* @param {string} inner
|
|
86
|
+
* @param {number} offset
|
|
87
|
+
* @param {string} line the whole line, for the field check
|
|
88
|
+
* @param {Object} facts
|
|
89
|
+
* @returns {string|null}
|
|
90
|
+
*/
|
|
91
|
+
function replacementFor(text, whole, inner, offset, line, facts) {
|
|
92
|
+
const before = text[offset - 1];
|
|
93
|
+
const after = text[offset + whole.length];
|
|
94
|
+
// `Array<string>`, `Map<K, V>`: a type, not a slot.
|
|
95
|
+
if (before !== undefined && /\w/.test(before)) return null;
|
|
96
|
+
// The game's own words, quoted as evidence.
|
|
97
|
+
if (before !== undefined && QUOTES[before] !== undefined && QUOTES[before] === after) return null;
|
|
98
|
+
const value = String(inner).trim();
|
|
99
|
+
if (!/^[A-Za-z]/.test(value)) return null;
|
|
100
|
+
// A key or a chord: <Enter>, <Ctrl+A>, <Shift + Tab>.
|
|
101
|
+
if (value.includes('+') || KEY_NAMES.has(value.toLowerCase())) return null;
|
|
102
|
+
const known = knownSlot(value, facts);
|
|
103
|
+
if (known.known) return known.value || NOT_RECORDED;
|
|
104
|
+
if (FIELD_LINE.test(line) && /^[a-z]+(?: [a-z]+)*$/.test(value) && !HTML_TAGS.has(value)) return NOT_RECORDED;
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Walk the prose of a markdown document (not fenced blocks, not code spans),
|
|
110
|
+
* handing each segment and its whole line to `fn`.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} markdown
|
|
113
|
+
* @param {(text: string, line: string) => string} fn
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
function mapProse(markdown, fn) {
|
|
117
|
+
let fenced = false;
|
|
118
|
+
return String(markdown == null ? '' : markdown).split('\n').map(line => {
|
|
119
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
120
|
+
fenced = !fenced;
|
|
121
|
+
return line;
|
|
122
|
+
}
|
|
123
|
+
if (fenced) return line;
|
|
124
|
+
return line.split(/(`[^`]*`)/).map(part => (part.length > 1 && part.startsWith('`') && part.endsWith('`') ? part : fn(part, line))).join('');
|
|
125
|
+
}).join('\n');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Fill the slots the runner knows, and the header fields it does not, from
|
|
130
|
+
* its own facts. Everything else in angle brackets is left as written.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} markdown
|
|
133
|
+
* @param {{generatedAt?: string, driver?: string, target?: string, persona?: string,
|
|
134
|
+
* actionsTaken?: number, quitReason?: string, game?: string, version?: string}} [facts]
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
export function fillPlaceholders(markdown, facts = {}) {
|
|
138
|
+
return mapProse(markdown, (text, line) => text.replace(ANGLE, (whole, inner, offset) => {
|
|
139
|
+
const replacement = replacementFor(text, whole, inner, offset, line, facts);
|
|
140
|
+
return replacement === null ? whole : replacement;
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Every slot `fillPlaceholders` would still replace, for tests and a last check.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} markdown
|
|
148
|
+
* @returns {Array<string>}
|
|
149
|
+
*/
|
|
150
|
+
export function findPlaceholders(markdown) {
|
|
151
|
+
const found = [];
|
|
152
|
+
mapProse(markdown, (text, line) => {
|
|
153
|
+
for (const match of text.matchAll(ANGLE)) {
|
|
154
|
+
if (replacementFor(text, match[0], match[1], match.index, line, {}) !== null) found.push(match[0]);
|
|
155
|
+
}
|
|
156
|
+
return text;
|
|
157
|
+
});
|
|
158
|
+
return found;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The same filling over the prose fields of report.json, which the dashboard
|
|
163
|
+
* shows: titles, descriptions, repro steps and quotes.
|
|
164
|
+
*
|
|
165
|
+
* @param {Object} report
|
|
166
|
+
* @param {Object} [facts]
|
|
167
|
+
* @returns {Object}
|
|
168
|
+
*/
|
|
169
|
+
export function fillReportPlaceholders(report, facts = {}) {
|
|
170
|
+
if (!report || !Array.isArray(report.findings)) return report;
|
|
171
|
+
const fill = value => (typeof value === 'string' ? fillPlaceholders(value, facts) : value);
|
|
172
|
+
return {
|
|
173
|
+
...report,
|
|
174
|
+
findings: report.findings.map(finding => {
|
|
175
|
+
if (!finding || typeof finding !== 'object') return finding;
|
|
176
|
+
const out = { ...finding };
|
|
177
|
+
for (const field of ['title', 'description', 'persona_quote']) {
|
|
178
|
+
if (field in out) out[field] = fill(out[field]);
|
|
179
|
+
}
|
|
180
|
+
if (Array.isArray(out.repro_steps)) out.repro_steps = out.repro_steps.map(fill);
|
|
181
|
+
return out;
|
|
182
|
+
})
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export default { fillPlaceholders, findPlaceholders, fillReportPlaceholders, NOT_RECORDED };
|
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
|
@@ -3,6 +3,7 @@ import { getDeps } from './deps.js';
|
|
|
3
3
|
import EXIT from './exit.js';
|
|
4
4
|
import { runDir as runDirFor } from './paths.js';
|
|
5
5
|
import { buildPartialReport, readTranscript, shouldFinalize } from './regenerate.js';
|
|
6
|
+
import { reconcileDroppedFindings } from './dropped.js';
|
|
6
7
|
import { createReportValidator, writeReportFiles } from './report.js';
|
|
7
8
|
import * as stateFile from './state.js';
|
|
8
9
|
import { playJob, resolvePersonas } from '../commands/run.js';
|
|
@@ -61,6 +62,15 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
61
62
|
return EXIT.ENVIRONMENT;
|
|
62
63
|
}
|
|
63
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
|
+
|
|
64
74
|
// Reconcile the journal with what the server actually thinks: a run the sweep
|
|
65
75
|
// marked interrupted, or one that finished after the journal was last
|
|
66
76
|
// written, is the server's word against a stale file, and the server wins.
|
|
@@ -221,7 +231,7 @@ async function finalizeFromTranscript({ deps, api, gameId, jobId, repoRoot, row,
|
|
|
221
231
|
// `interrupted` can go to `reporting`, which is where this run actually is:
|
|
222
232
|
// it has a report now. Then `uploading`, then the delivery.
|
|
223
233
|
await api.runs.transition(gameId, jobId, row.run_id, 'reporting', { reason: 'rebuilt from transcript' });
|
|
224
|
-
await deps.finalizeRun(directory, {
|
|
234
|
+
const answer = await deps.finalizeRun(directory, {
|
|
225
235
|
api,
|
|
226
236
|
gameId,
|
|
227
237
|
jobId,
|
|
@@ -237,6 +247,20 @@ async function finalizeFromTranscript({ deps, api, gameId, jobId, repoRoot, row,
|
|
|
237
247
|
report_md: markdown
|
|
238
248
|
}
|
|
239
249
|
});
|
|
250
|
+
if (answer && answer.dropped_findings > 0) {
|
|
251
|
+
log(`run ${row.run_id}: the server dropped ${answer.dropped_findings} finding(s) with no usable evidence.`);
|
|
252
|
+
try {
|
|
253
|
+
await reconcileDroppedFindings({
|
|
254
|
+
directory,
|
|
255
|
+
droppedCount: answer.dropped_findings,
|
|
256
|
+
droppedIds: answer.dropped_finding_ids,
|
|
257
|
+
options,
|
|
258
|
+
journal: { jobId, runId: row.run_id, repoRoot }
|
|
259
|
+
});
|
|
260
|
+
} catch (markError) {
|
|
261
|
+
log(`run ${row.run_id}: could not mark the dropped finding(s) in the local report (${String(markError && markError.message)}).`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
240
264
|
} catch (error) {
|
|
241
265
|
log(`run ${row.run_id}: could not be finalised (${String(error && error.message)}); it will be replayed.`);
|
|
242
266
|
return false;
|
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 };
|
package/src/run/synthetic.js
CHANGED
|
@@ -193,6 +193,8 @@ export async function emitRunEvents({ apiUrl, ingestKey, playtestToken, pageSdk
|
|
|
193
193
|
|
|
194
194
|
session.track('playtest_run_started', { driver });
|
|
195
195
|
for (const finding of Array.isArray(report && report.findings) ? report.findings : []) {
|
|
196
|
+
// The server kept no record of a dropped finding, so neither does the game's telemetry.
|
|
197
|
+
if (finding && finding.dropped === true) continue;
|
|
196
198
|
session.track('playtest_finding', {
|
|
197
199
|
finding_id: finding.id,
|
|
198
200
|
severity: finding.severity,
|
package/src/run/tools.js
CHANGED
|
@@ -86,7 +86,7 @@ export const PLAY_TOOLS = Object.freeze([
|
|
|
86
86
|
},
|
|
87
87
|
{
|
|
88
88
|
name: 'type',
|
|
89
|
-
description: 'Type text. Spends an action. Set submit to press Enter afterwards.',
|
|
89
|
+
description: 'Type text. Spends an action. Set submit to press Enter afterwards. To correct or clear a text field, set replace: the field is emptied first and holds only this text afterwards (an empty text clears it). Do that rather than a select-all key chord, which differs between platforms.',
|
|
90
90
|
input_schema: {
|
|
91
91
|
type: 'object',
|
|
92
92
|
additionalProperties: false,
|
|
@@ -94,6 +94,8 @@ export const PLAY_TOOLS = Object.freeze([
|
|
|
94
94
|
properties: {
|
|
95
95
|
text: { type: 'string' },
|
|
96
96
|
submit: { type: 'boolean' },
|
|
97
|
+
replace: { type: 'boolean', description: 'Empty the field first, so it holds only this text. playwright_web only; other drivers refuse it.' },
|
|
98
|
+
target: { type: 'string', description: 'playwright_web only, other drivers refuse it: the field to type into, named like a click target, such as textbox "Email". Without it, the focused field.' },
|
|
97
99
|
thought_in_character: thought,
|
|
98
100
|
note
|
|
99
101
|
}
|
|
@@ -101,7 +103,7 @@ export const PLAY_TOOLS = Object.freeze([
|
|
|
101
103
|
},
|
|
102
104
|
{
|
|
103
105
|
name: 'key',
|
|
104
|
-
description: 'Press one key. Spends an action. Names are the usual ones: Enter, Space, ArrowUp, Escape, a, 1.',
|
|
106
|
+
description: 'Press one key. Spends an action. Names are the usual ones: Enter, Space, ArrowUp, Escape, a, 1. Hold a modifier with +, such as Shift+Tab; editing shortcuts such as Control+A are sent with this platform\'s own modifier. To clear a text field, use type with replace instead.',
|
|
105
107
|
input_schema: {
|
|
106
108
|
type: 'object',
|
|
107
109
|
additionalProperties: false,
|
|
@@ -187,8 +189,12 @@ export function actionFor(toolUse) {
|
|
|
187
189
|
return Number.isFinite(Number(input.x)) && Number.isFinite(Number(input.y))
|
|
188
190
|
? { kind: 'click', target: { x: Number(input.x), y: Number(input.y) } }
|
|
189
191
|
: { kind: 'click', target: String(input.target || '') };
|
|
190
|
-
case 'type':
|
|
191
|
-
|
|
192
|
+
case 'type': {
|
|
193
|
+
const action = { kind: 'type', text: String(input.text || ''), submit: Boolean(input.submit) };
|
|
194
|
+
if (input.replace === true) action.replace = true;
|
|
195
|
+
if (typeof input.target === 'string' && input.target.trim()) action.target = input.target;
|
|
196
|
+
return action;
|
|
197
|
+
}
|
|
192
198
|
case 'key':
|
|
193
199
|
return { kind: 'key', key: String(input.key || '') };
|
|
194
200
|
case 'navigate':
|
package/src/upload/index.js
CHANGED
|
@@ -274,7 +274,7 @@ export async function uploadRunDir(runDir, options) {
|
|
|
274
274
|
const {
|
|
275
275
|
api, gameId, jobId, runId = null,
|
|
276
276
|
includeVideo = false, includeTranscript = false,
|
|
277
|
-
dryRun = false, force = false, only = null, allowedPaths = null,
|
|
277
|
+
dryRun = false, force = false, only = null, allowedPaths = null, exclude = null, excludeReason = 'excluded',
|
|
278
278
|
onFile, repoRoot, fetch: fetchImpl, sleep
|
|
279
279
|
} = options;
|
|
280
280
|
|
|
@@ -295,6 +295,14 @@ export async function uploadRunDir(runDir, options) {
|
|
|
295
295
|
files = files.filter(file => wanted.has(file.path));
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
if (Array.isArray(exclude) && exclude.length > 0) {
|
|
299
|
+
const unwanted = new Set(exclude);
|
|
300
|
+
for (const file of files) {
|
|
301
|
+
if (unwanted.has(file.path)) skipped.push({ path: file.path, reason: excludeReason });
|
|
302
|
+
}
|
|
303
|
+
files = files.filter(file => !unwanted.has(file.path));
|
|
304
|
+
}
|
|
305
|
+
|
|
298
306
|
if (!force) {
|
|
299
307
|
const done = await uploadedKeys(jobId, journalOptions);
|
|
300
308
|
const remaining = [];
|
package/src/upload/queue.js
CHANGED
|
@@ -14,7 +14,10 @@ import { paths } from '../paths.js';
|
|
|
14
14
|
* was.
|
|
15
15
|
*
|
|
16
16
|
* Each line is `{ at, kind, run_id, path, sha256, size, status, detail }`.
|
|
17
|
-
* `status` is one of `queued`, `uploaded`, `failed`, `skipped`.
|
|
17
|
+
* `status` is one of `queued`, `uploaded`, `failed`, `skipped`, `local_edit`.
|
|
18
|
+
* A `local_edit` is a file the CLI changed on disk after it was delivered
|
|
19
|
+
* (dropped findings marked in a run's report): the delivered copy is the
|
|
20
|
+
* record, so the new hash counts as done and is never sent again.
|
|
18
21
|
*
|
|
19
22
|
* Idempotency is by sha256: a path recorded `uploaded` with the same hash is
|
|
20
23
|
* not sent again, which is what makes `resume` and the manual `upload <job>`
|
|
@@ -25,9 +28,13 @@ export const STATUS = Object.freeze({
|
|
|
25
28
|
QUEUED: 'queued',
|
|
26
29
|
UPLOADED: 'uploaded',
|
|
27
30
|
FAILED: 'failed',
|
|
28
|
-
SKIPPED: 'skipped'
|
|
31
|
+
SKIPPED: 'skipped',
|
|
32
|
+
LOCAL_EDIT: 'local_edit'
|
|
29
33
|
});
|
|
30
34
|
|
|
35
|
+
/** Statuses that mean "nothing left to send at this hash". */
|
|
36
|
+
const DONE = new Set([STATUS.UPLOADED, STATUS.LOCAL_EDIT]);
|
|
37
|
+
|
|
31
38
|
/**
|
|
32
39
|
* Append one entry.
|
|
33
40
|
* @param {string} jobId
|
|
@@ -70,7 +77,8 @@ export async function readQueue(jobId, options = {}) {
|
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
/**
|
|
73
|
-
* The set of `<runId>|<path>|<sha256>` triples already recorded as uploaded
|
|
80
|
+
* The set of `<runId>|<path>|<sha256>` triples already recorded as uploaded,
|
|
81
|
+
* or as a local edit of a file that was.
|
|
74
82
|
* The sha256 is part of the key on purpose: a corrected file at the same path
|
|
75
83
|
* has a different hash and must be sent again.
|
|
76
84
|
* @param {string} jobId
|
|
@@ -80,7 +88,7 @@ export async function readQueue(jobId, options = {}) {
|
|
|
80
88
|
export async function uploadedKeys(jobId, options = {}) {
|
|
81
89
|
const done = new Set();
|
|
82
90
|
for (const entry of await readQueue(jobId, options)) {
|
|
83
|
-
if (entry.status
|
|
91
|
+
if (DONE.has(entry.status)) {
|
|
84
92
|
done.add(keyFor(entry.run_id || null, entry.path, entry.sha256));
|
|
85
93
|
}
|
|
86
94
|
}
|
|
@@ -105,7 +113,7 @@ export async function pending(jobId, options = {}) {
|
|
|
105
113
|
for (const entry of entries) {
|
|
106
114
|
if (!entry.path) continue;
|
|
107
115
|
const key = keyFor(entry.run_id || null, entry.path, entry.sha256);
|
|
108
|
-
if (entry.status
|
|
116
|
+
if (DONE.has(entry.status)) done.add(key);
|
|
109
117
|
latest.set(key, entry);
|
|
110
118
|
}
|
|
111
119
|
return [...latest.entries()]
|