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.
@@ -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.type !== 'screenshot') continue;
155
- const ref = String(item.ref || '');
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
- return Boolean(entry) && (entry.completed === true || RUN_DONE_STATES.includes(entry.state));
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 };
@@ -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
- return { kind: 'type', text: String(input.text || ''), submit: Boolean(input.submit) };
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':
@@ -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 = [];
@@ -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 === STATUS.UPLOADED) {
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 === STATUS.UPLOADED) done.add(key);
116
+ if (DONE.has(entry.status)) done.add(key);
109
117
  latest.set(key, entry);
110
118
  }
111
119
  return [...latest.entries()]