ravensight-playtest 0.2.9 → 0.3.0

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.
@@ -12,6 +12,7 @@ import { createModelClient, ModelUnavailableError, ProxyError } from '../run/mod
12
12
  import { jobDir, runDir as runDirFor, JOB_FILES, RUN_FILES } from '../run/paths.js';
13
13
  import { readBridgeConfig } from "../run/drivers/stdio.js";
14
14
  import { WEBDRIVER_NAMES, readDriverConfig } from '../run/drivers/webdriver.js';
15
+ import { automationChecks } from './check.js';
15
16
  import { runPersona } from '../run/personaLoop.js';
16
17
  import { reconcileDroppedFindings } from '../run/dropped.js';
17
18
  import * as stateFile from '../run/state.js';
@@ -106,6 +107,31 @@ async function run(flags, injected, deps, log) {
106
107
  // dedup and success-criteria passes.
107
108
  if (WEBDRIVER_NAMES.includes(driverName)) await readDriverConfig(target);
108
109
  if (driverName === "cli_stdio") await readBridgeConfig(target);
110
+
111
+ // The free capability probe. A native run depends on the developer's own
112
+ // automation server, which no amount of SDK or token setup proves, so the
113
+ // automation rows of `check` run first and a failure stops the run before
114
+ // the estimate, the confirmation and the register: no job, no reservation.
115
+ if (WEBDRIVER_NAMES.includes(driverName) && !options.skipPreflight) {
116
+ // Each row is printed as it completes, and the slow step says so before
117
+ // it starts, so a first session on a fresh simulator does not look hung.
118
+ const preflight = await (injected.preflight || automationChecks)(
119
+ { driverName, target, repoRoot: process.cwd() },
120
+ {
121
+ ...(injected.preflightDeps || {}),
122
+ onProgress: line => log(line),
123
+ onRow: entry => log(`preflight ${entry.status} ${entry.name}: ${entry.detail}`)
124
+ }
125
+ );
126
+ const failed = preflight.rows.filter(entry => entry.status === 'fail');
127
+ if (failed.length > 0) {
128
+ for (const entry of failed) {
129
+ log(`Preflight failed at "${entry.name}": ${entry.detail}${entry.fix ? `. Fix: ${entry.fix}` : ''}`);
130
+ }
131
+ log('No job was registered and nothing was reserved. Run "ravensight-playtest check" for every row, or pass --skip-preflight to register anyway.');
132
+ return EXIT.FAILED;
133
+ }
134
+ }
109
135
  const modules = ['persona_playtest', 'aggregate_report'];
110
136
  const pack = await deps.packs.get({ api, gameId, modules });
111
137
  if (options.controller === 'jev' && !pack.routing?.action_controllers?.includes('jev')) { log('This server does not currently support the requested action controller. No job was registered.'); return EXIT.ENVIRONMENT; }
@@ -561,13 +587,31 @@ export async function playPersonaRun({
561
587
  heartbeat,
562
588
  ledger,
563
589
  recordVideo: Boolean(options.uploadVideo),
590
+ // Written the moment the warning arrives, not after the run: a run that
591
+ // then fails still says in state.json that it was warned.
592
+ onBudgetWarning: async () => {
593
+ try {
594
+ await stateFile.patchRun(repoRoot, journal, runId, { budget_warned: true });
595
+ } catch {
596
+ // The journal write must not kill a paid run.
597
+ }
598
+ },
564
599
  log: message => log(`run ${runId}: ${message}`)
565
600
  });
566
601
  } catch (error) {
567
602
  heartbeat.stop();
568
603
  if (inFlight) inFlight.delete(runId);
569
604
  const failed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
570
- await stateFile.patchRun(repoRoot, journal, runId, { state: failed.state, error: String(error && error.message), quit_reason: failed.quitReason });
605
+ await stateFile.patchRun(repoRoot, journal, runId, {
606
+ state: failed.state,
607
+ error: String(error && error.message),
608
+ quit_reason: failed.quitReason,
609
+ // The failure path records these too. In the Texas run state.json said
610
+ // budget_warned: false for a run whose log had printed the warning,
611
+ // because only the success path wrote it.
612
+ ...(failed.budgetWarned ? { budget_warned: true } : {}),
613
+ report_written: Boolean(failed.report && !failed.report.partial)
614
+ });
571
615
  const delivery = {};
572
616
  const answer = await deliver({ deps, api, gameId, jobId, runId, directory, outcome: failed, options, log, repoRoot, delivery });
573
617
  if (isReportStepBudget(failed)) {
@@ -600,7 +644,8 @@ export async function playPersonaRun({
600
644
  state: 'failed',
601
645
  reason: `ran out of budget while writing the report; the server refused budget_exceeded (${delivery.message || 'no reason given'})`,
602
646
  actions_taken: failed.actionsTaken,
603
- quit_reason: failed.quitReason
647
+ quit_reason: failed.quitReason,
648
+ ...(failed.duration ? { duration: failed.duration } : {})
604
649
  });
605
650
  await stateFile.patchRun(repoRoot, journal, runId, { completed: true, state: 'failed' });
606
651
  log(`run ${runId} (${persona.slug}): completed as failed instead.`);
@@ -676,6 +721,14 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
676
721
  actions_taken: outcome.actionsTaken,
677
722
  quit_reason: outcome.quitReason
678
723
  };
724
+ // The run card's duration. The server lane accepts `duration`; an older
725
+ // server ignores a key it does not know.
726
+ if (outcome.duration) {
727
+ body.duration = {
728
+ wall_seconds: Number.isFinite(Number(outcome.duration.wall_seconds)) ? Number(outcome.duration.wall_seconds) : null,
729
+ model_seconds: Number.isFinite(Number(outcome.duration.model_seconds)) ? Number(outcome.duration.model_seconds) : null
730
+ };
731
+ }
679
732
  if (outcome.report) body.report = outcome.report;
680
733
  // `report_md` is what the server lints for its four required sections. Not
681
734
  // sending it means `report_lint` is silently empty on every run, which reads
@@ -90,43 +90,100 @@ export function issueId(signature) {
90
90
  }
91
91
 
92
92
  /**
93
- * Candidate clusters: same signature, or a shared evidence ref.
93
+ * A title with case, punctuation and spacing taken out, for the exact
94
+ * duplicate check: "Roster never loads!" and "roster never loads" are one
95
+ * finding written twice.
96
+ *
97
+ * @param {string} title
98
+ * @returns {string}
99
+ */
100
+ export function normalisedTitle(title) {
101
+ return String(title || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
102
+ }
103
+
104
+ /**
105
+ * Candidate clusters: same signature, a shared screenshot or file_line ref,
106
+ * the same normalised title, or the same observation of the same run.
107
+ *
108
+ * A cluster is `certain` when its members share one normalised title and one
109
+ * category, and come from one run or share a screenshot or observation of
110
+ * one run: it is one finding written twice, merged before any model is asked
111
+ * anything. Every other cluster, including one joined only by a shared
112
+ * observation, goes to the dedup judge, which may still split it.
94
113
  *
95
114
  * @param {Array<{runId: string, persona: string, findings: Array}>} runs
96
- * @returns {Array<{signature: string, members: Array}>}
115
+ * @returns {Array<{signature: string, members: Array, certain: boolean}>}
97
116
  */
98
117
  export function clusterFindings(runs) {
99
- /** @type {Map<string, Array>} */
100
- const bySignature = new Map();
101
- /** @type {Map<string, string>} */
102
- const byEvidence = new Map();
103
-
118
+ const members = [];
104
119
  for (const run of runs) {
105
120
  for (const finding of run.findings || []) {
106
- const member = {
107
- ref: `${run.runId}:${finding.id}`,
108
- runId: run.runId,
109
- persona: run.persona,
110
- finding
111
- };
112
- let signature = signatureFor(finding);
113
- // A shared evidence ref is strong enough to pull two differently
114
- // worded findings into one candidate cluster.
115
- for (const item of finding.evidence || []) {
116
- if (!item || (item.type !== 'screenshot' && item.type !== 'file_line')) continue;
117
- const key = `${item.type}:${item.ref}`;
118
- const existing = byEvidence.get(key);
119
- if (existing) signature = existing;
120
- else byEvidence.set(key, signature);
121
- }
122
- const bucket = bySignature.get(signature) || [];
123
- bucket.push(member);
124
- bySignature.set(signature, bucket);
121
+ members.push({ ref: `${run.runId}:${finding.id}`, runId: run.runId, persona: run.persona, finding });
125
122
  }
126
123
  }
127
124
 
128
- return [...bySignature.entries()]
129
- .map(([signature, members]) => ({ signature, members }))
125
+ // Union-find over member indexes, joined through any key two members share.
126
+ const parent = members.map((_, index) => index);
127
+ const find = index => (parent[index] === index ? index : (parent[index] = find(parent[index])));
128
+ const union = (a, b) => {
129
+ const left = find(a);
130
+ const right = find(b);
131
+ if (left !== right) parent[Math.max(left, right)] = Math.min(left, right);
132
+ };
133
+ const firstByKey = new Map();
134
+ const join = (key, index) => {
135
+ if (firstByKey.has(key)) union(firstByKey.get(key), index);
136
+ else firstByKey.set(key, index);
137
+ };
138
+ const observationKeys = member => (member.finding.evidence || [])
139
+ .filter(item => item && item.type === 'transcript_step' && /^\s*observation\s*#?\s*\d+\s*$/i.test(String(item.ref || '')))
140
+ .map(item => `obs:${member.runId}:${String(item.ref).replace(/\D+/g, '')}`);
141
+
142
+ members.forEach((member, index) => {
143
+ join(`sig:${signatureFor(member.finding)}`, index);
144
+ const title = normalisedTitle(member.finding.title);
145
+ if (title) join(`title:${title}`, index);
146
+ for (const item of member.finding.evidence || []) {
147
+ if (item && (item.type === 'screenshot' || item.type === 'file_line')) join(`${item.type}:${item.ref}`, index);
148
+ }
149
+ for (const key of observationKeys(member)) join(key, index);
150
+ });
151
+
152
+ const groups = new Map();
153
+ members.forEach((member, index) => {
154
+ const root = find(index);
155
+ if (!groups.has(root)) groups.set(root, []);
156
+ groups.get(root).push(member);
157
+ });
158
+
159
+ // Evidence a certain merge can rest on is scoped to one run: two runs'
160
+ // "screenshots/01-title.png" are different files.
161
+ const runScopedKeys = member => new Set([
162
+ ...observationKeys(member),
163
+ ...(member.finding.evidence || [])
164
+ .filter(item => item && item.type === 'screenshot' && item.ref)
165
+ .map(item => `shot:${member.runId}:${item.ref}`)
166
+ ]);
167
+
168
+ return [...groups.values()]
169
+ .map(group => {
170
+ const titles = new Set(group.map(member => normalisedTitle(member.finding.title)));
171
+ const categories = new Set(group.map(member => member.finding.category || ''));
172
+ const sameRun = new Set(group.map(member => member.runId)).size === 1;
173
+ const keys = group.map(runScopedKeys);
174
+ const sharedEvidence = keys.length > 0 && [...keys[0]].some(key => keys.every(set => set.has(key)));
175
+ return {
176
+ // The first member's signature names the cluster, so a cluster's id is
177
+ // as stable as it was when only signatures merged.
178
+ signature: group.map(member => signatureFor(member.finding)).sort()[0],
179
+ members: group,
180
+ // One finding written twice: the same normalised title AND the same
181
+ // category AND (the same run, or a shared screenshot or observation).
182
+ // Anything less, a shared observation alone included, goes to the
183
+ // dedup judge, which may split it.
184
+ certain: group.length > 1 && titles.size === 1 && !titles.has('') && categories.size === 1 && (sameRun || sharedEvidence)
185
+ };
186
+ })
130
187
  .sort((a, b) => a.signature.localeCompare(b.signature));
131
188
  }
132
189
 
@@ -141,7 +198,8 @@ export function clusterFindings(runs) {
141
198
  */
142
199
  export async function judgeClusters({ client, jobId, runId, clusters, log = () => {} }) {
143
200
  const verdicts = new Map();
144
- const ambiguous = clusters.filter(cluster => cluster.members.length > 1);
201
+ // A certain cluster is one finding written twice; nothing to ask.
202
+ const ambiguous = clusters.filter(cluster => cluster.members.length > 1 && !cluster.certain);
145
203
  if (ambiguous.length === 0 || !runId) return verdicts;
146
204
 
147
205
  const prompt = [
@@ -215,7 +273,7 @@ export function applyQualityGate(runs) {
215
273
  * said they are different
216
274
  */
217
275
  export function toIssues(cluster, verdict) {
218
- const split = cluster.members.length > 1 && verdict && verdict.same === false;
276
+ const split = cluster.members.length > 1 && !cluster.certain && verdict && verdict.same === false;
219
277
  if (split) {
220
278
  return cluster.members.map(member => buildIssue(`${cluster.signature}#${member.ref}`, [member], null));
221
279
  }
@@ -452,6 +510,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
452
510
  ' "tldr":["up to five short bullets: the most important fixes and the strongest positive signal"]}',
453
511
  '',
454
512
  'Never write calendar dates or times in the tldr or anywhere else; the runner stamps generated_at on the report.',
513
+ 'Never write persona counts such as "2/3 personas" in the tldr; the runner adds counts from the run list.',
455
514
  'Rules. A criterion no run touched is "not testable"; never invent evidence for one. An issue is "known" only when the brief already describes it. An issue is excluded by intent only when the persona experience is CONSISTENT with the stated intent; when it CONTRADICTS the intent, leave it in the list, because that is the valuable case.',
456
515
  '',
457
516
  `success criteria:\n${criteria.length > 0 ? criteria.map(row => ` ${row.criterion_id}: ${row.text}`).join('\n') : ' (none stated)'}`,
@@ -564,8 +623,13 @@ export function criteriaFrom(brief) {
564
623
  */
565
624
  export function renderAggregateMarkdown(json) {
566
625
  const lines = [];
626
+ // Counts come from the run list, never from a model and never from how
627
+ // many findings a cluster merged: one persona filing the same issue twice
628
+ // is one persona, so "2/1 personas" cannot happen.
629
+ const personasRun = new Set((json.runs || []).map(run => run.persona).filter(Boolean)).size;
630
+ const affected = issue => Math.min(personasRun, new Set(issue.personas_affected || []).size);
567
631
  lines.push(`# Playtest Report: ${json.game_id}`);
568
- lines.push(`**Job:** ${json.job_id} - **Date:** ${json.generated_at} - **Personas:** ${json.runs.length}`);
632
+ lines.push(`**Job:** ${json.job_id} - **Date:** ${json.generated_at} - **Personas:** ${personasRun}`);
569
633
  if (json.partial) {
570
634
  lines.push('');
571
635
  lines.push('> Partial: at least one run did not finish, so this report does not cover the whole pack.');
@@ -577,7 +641,7 @@ export function renderAggregateMarkdown(json) {
577
641
  lines.push(json.tldr.map(line => `- ${line}`).join('\n'));
578
642
  } else {
579
643
  lines.push(top.length > 0
580
- ? top.map(issue => `- ${issue.label} ${issue.title} (${issue.severity}, ${issue.occurrences} of ${json.runs.length} personas)`).join('\n')
644
+ ? top.map(issue => `- ${issue.label} ${issue.title} (${issue.severity}, ${affected(issue)} of ${personasRun} personas)`).join('\n')
581
645
  : '- No ranked issues: no run produced an evidenced finding.');
582
646
  }
583
647
  lines.push('');
@@ -598,7 +662,7 @@ export function renderAggregateMarkdown(json) {
598
662
  lines.push('## Top issues (ranked)');
599
663
  if (json.issues.length === 0) lines.push('None.');
600
664
  for (const issue of json.issues) {
601
- lines.push(`### ${issue.label}: ${issue.title} \`${issue.severity}\` - ${issue.category} - ${issue.occurrences}/${json.runs.length} personas`);
665
+ lines.push(`### ${issue.label}: ${issue.title} \`${issue.severity}\` - ${issue.category} - ${affected(issue)}/${personasRun} personas`);
602
666
  lines.push(`**Why it matters:** ${issue.description || ''}`);
603
667
  if (issue.repro_steps.length > 0) lines.push(`**Repro:** ${issue.repro_steps.map((step, i) => `${i + 1}. ${step}`).join(' ')}`);
604
668
  lines.push(`**Evidence:** ${issue.evidence.map(item => `${item.type} ${item.ref}`).join(', ') || 'none'}`);
package/src/run/args.js CHANGED
@@ -35,6 +35,8 @@ const BOOLEAN = new Map([
35
35
  ['--headed', 'headed'],
36
36
  ['--headless', 'headless'],
37
37
  ['--verbose', 'verbose'],
38
+ // Native drivers only: register without the free automation probe first.
39
+ ['--skip-preflight', 'skipPreflight'],
38
40
  // Resume only: replay an interrupted run from the start instead of writing a
39
41
  // partial report from the transcript it already has.
40
42
  ['--restart-interrupted', 'restartInterrupted']
@@ -93,6 +93,13 @@ export const DRIVER_METHODS = Object.freeze(['launch', 'observe', 'act', 'screen
93
93
  /** Action kinds a driver may be asked for. A driver may refuse a kind it cannot do. */
94
94
  export const ACTION_KINDS = Object.freeze(['key', 'type', 'click', 'press_action', 'navigate', 'wait']);
95
95
 
96
+ /**
97
+ * What a ref read off a screen that is gone is answered with: a new document
98
+ * since the observation it came from, or an earlier action in the same turn
99
+ * that changed the screen.
100
+ */
101
+ export const STALE_REF_MESSAGE = 'ref from an older screen, observe again';
102
+
96
103
  export class DriverError extends Error {
97
104
  /**
98
105
  * @param {string} message