ravensight-playtest 0.2.9 → 0.3.1

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.`);
@@ -653,6 +698,18 @@ export async function playPersonaRun({
653
698
  return { runId, persona: persona.slug, state: outcome.state, quitReason: outcome.quitReason };
654
699
  }
655
700
 
701
+ /**
702
+ * A run's duration as `complete` sends it: `{ wall_seconds, model_seconds }`,
703
+ * each a number or null.
704
+ *
705
+ * @param {{wall_seconds?: number, model_seconds?: number}} duration
706
+ * @returns {{wall_seconds: number|null, model_seconds: number|null}}
707
+ */
708
+ export function durationBody(duration) {
709
+ const value = key => (duration && Number.isFinite(Number(duration[key])) && duration[key] !== null ? Number(duration[key]) : null);
710
+ return { wall_seconds: value('wall_seconds'), model_seconds: value('model_seconds') };
711
+ }
712
+
656
713
  /**
657
714
  * Upload the run directory and complete the run, through cli-core's
658
715
  * `finalizeRun` so the 422 `upload_unverified` repair pass lives in one place.
@@ -676,6 +733,9 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
676
733
  actions_taken: outcome.actionsTaken,
677
734
  quit_reason: outcome.quitReason
678
735
  };
736
+ // The run card's duration. The server lane accepts `duration`; an older
737
+ // server ignores a key it does not know.
738
+ if (outcome.duration) body.duration = durationBody(outcome.duration);
679
739
  if (outcome.report) body.report = outcome.report;
680
740
  // `report_md` is what the server lints for its four required sections. Not
681
741
  // sending it means `report_lint` is silently empty on every run, which reads
@@ -857,6 +917,8 @@ async function aggregatePhase({
857
917
  log
858
918
  }) {
859
919
  const runId = aggregateRun ? aggregateRun.run_id : null;
920
+ // Created here, so its wall clock is measured from the start of the
921
+ // aggregate phase: what the summary step's card shows as its duration.
860
922
  const ledger = createUsageLedger();
861
923
  const heartbeat = runId
862
924
  ? createHeartbeat({ api, gameId, jobId, runId, onUsage: usage => ledger.recordServerUsage(usage), onError: () => {} })
@@ -913,7 +975,7 @@ async function aggregatePhase({
913
975
  log('The aggregate was built locally: this job has no aggregate_report run to spend a model turn from, so the dedup and judgement passes were skipped.');
914
976
  }
915
977
 
916
- const published = await publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log });
978
+ const published = await publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log, duration: durationBody(ledger.duration()) });
917
979
  if (published === 'finished') reached = 'succeeded';
918
980
  else if (published) await advance('uploading');
919
981
 
@@ -923,7 +985,8 @@ async function aggregatePhase({
923
985
  state: reached === 'uploading' ? 'succeeded' : 'failed',
924
986
  actions_taken: 0,
925
987
  quit_reason: reached === 'uploading' ? 'goal_reached' : 'error',
926
- reason: reached === 'uploading' ? '' : 'the aggregate was not published'
988
+ reason: reached === 'uploading' ? '' : 'the aggregate was not published',
989
+ duration: durationBody(ledger.duration())
927
990
  });
928
991
  } catch (error) {
929
992
  log(`aggregate run ${runId}: complete was refused (${String(error && error.message)}).`);
@@ -943,7 +1006,7 @@ async function aggregatePhase({
943
1006
  * sent, 'published' when the server has it, 'finished' when it also closed
944
1007
  * the aggregate run
945
1008
  */
946
- async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log }) {
1009
+ async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log, duration = null }) {
947
1010
  if (options.dryUpload) {
948
1011
  log('The aggregate was not published: --dry-upload.');
949
1012
  return false;
@@ -970,7 +1033,11 @@ async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory,
970
1033
  md_size: markdown.size,
971
1034
  md_sha256: markdown.sha256,
972
1035
  json_size: json.size,
973
- json_sha256: json.sha256
1036
+ json_sha256: json.sha256,
1037
+ // The server closes the aggregate run on this call, so the duration
1038
+ // rides here as well as on the run's own complete; an older server
1039
+ // ignores a key it does not know.
1040
+ ...(duration ? { duration } : {})
974
1041
  });
975
1042
  // The server finishes the aggregate run itself when it accepts the
976
1043
  // report, and says so in `run`. A caller that then tries to move the
@@ -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
 
@@ -139,9 +196,10 @@ export function clusterFindings(runs) {
139
196
  *
140
197
  * @returns {Promise<Map<string, {same: boolean, title: string|null}>>}
141
198
  */
142
- export async function judgeClusters({ client, jobId, runId, clusters, log = () => {} }) {
199
+ export async function judgeClusters({ client, jobId, runId, clusters, ledger = null, 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 = [
@@ -156,6 +214,7 @@ export async function judgeClusters({ client, jobId, runId, clusters, log = () =
156
214
  ].join('\n');
157
215
 
158
216
  try {
217
+ const askedAt = Date.now();
159
218
  const answer = await callStepWithRetry({
160
219
  client,
161
220
  jobId,
@@ -164,6 +223,7 @@ export async function judgeClusters({ client, jobId, runId, clusters, log = () =
164
223
  messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
165
224
  maxTokens: 512
166
225
  });
226
+ if (ledger) ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
167
227
  for (const entry of parseJsonArray(answer.text)) {
168
228
  const cluster = ambiguous[Number(entry.cluster)];
169
229
  if (!cluster) continue;
@@ -215,7 +275,7 @@ export function applyQualityGate(runs) {
215
275
  * said they are different
216
276
  */
217
277
  export function toIssues(cluster, verdict) {
218
- const split = cluster.members.length > 1 && verdict && verdict.same === false;
278
+ const split = cluster.members.length > 1 && !cluster.certain && verdict && verdict.same === false;
219
279
  if (split) {
220
280
  return cluster.members.map(member => buildIssue(`${cluster.signature}#${member.ref}`, [member], null));
221
281
  }
@@ -342,7 +402,7 @@ export async function buildAggregate({
342
402
  const { kept, observations } = applyQualityGate(inputs);
343
403
  const clusters = clusterFindings(kept);
344
404
  const verdicts = aggregateRunId
345
- ? await judgeClusters({ client, jobId, runId: aggregateRunId, clusters, log })
405
+ ? await judgeClusters({ client, jobId, runId: aggregateRunId, clusters, ledger, log })
346
406
  : new Map();
347
407
 
348
408
  const issues = clusters.flatMap(cluster => toIssues(cluster, verdicts.get(cluster.signature)));
@@ -452,6 +512,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
452
512
  ' "tldr":["up to five short bullets: the most important fixes and the strongest positive signal"]}',
453
513
  '',
454
514
  'Never write calendar dates or times in the tldr or anywhere else; the runner stamps generated_at on the report.',
515
+ 'Never write persona counts such as "2/3 personas" in the tldr; the runner adds counts from the run list.',
455
516
  '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
517
  '',
457
518
  `success criteria:\n${criteria.length > 0 ? criteria.map(row => ` ${row.criterion_id}: ${row.text}`).join('\n') : ' (none stated)'}`,
@@ -464,6 +525,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
464
525
  ].join('\n');
465
526
 
466
527
  try {
528
+ const askedAt = Date.now();
467
529
  const answer = await callStepWithRetry({
468
530
  client,
469
531
  jobId,
@@ -472,7 +534,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
472
534
  messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
473
535
  maxTokens: 8192
474
536
  });
475
- if (ledger) ledger.record({ model: answer.model, usage: answer.usage });
537
+ if (ledger) ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
476
538
  return parseJsonObject(answer.text);
477
539
  } catch (error) {
478
540
  // A budget or cancel refusal decides the caller's exit code and has to
@@ -564,8 +626,13 @@ export function criteriaFrom(brief) {
564
626
  */
565
627
  export function renderAggregateMarkdown(json) {
566
628
  const lines = [];
629
+ // Counts come from the run list, never from a model and never from how
630
+ // many findings a cluster merged: one persona filing the same issue twice
631
+ // is one persona, so "2/1 personas" cannot happen.
632
+ const personasRun = new Set((json.runs || []).map(run => run.persona).filter(Boolean)).size;
633
+ const affected = issue => Math.min(personasRun, new Set(issue.personas_affected || []).size);
567
634
  lines.push(`# Playtest Report: ${json.game_id}`);
568
- lines.push(`**Job:** ${json.job_id} - **Date:** ${json.generated_at} - **Personas:** ${json.runs.length}`);
635
+ lines.push(`**Job:** ${json.job_id} - **Date:** ${json.generated_at} - **Personas:** ${personasRun}`);
569
636
  if (json.partial) {
570
637
  lines.push('');
571
638
  lines.push('> Partial: at least one run did not finish, so this report does not cover the whole pack.');
@@ -577,7 +644,7 @@ export function renderAggregateMarkdown(json) {
577
644
  lines.push(json.tldr.map(line => `- ${line}`).join('\n'));
578
645
  } else {
579
646
  lines.push(top.length > 0
580
- ? top.map(issue => `- ${issue.label} ${issue.title} (${issue.severity}, ${issue.occurrences} of ${json.runs.length} personas)`).join('\n')
647
+ ? top.map(issue => `- ${issue.label} ${issue.title} (${issue.severity}, ${affected(issue)} of ${personasRun} personas)`).join('\n')
581
648
  : '- No ranked issues: no run produced an evidenced finding.');
582
649
  }
583
650
  lines.push('');
@@ -598,7 +665,7 @@ export function renderAggregateMarkdown(json) {
598
665
  lines.push('## Top issues (ranked)');
599
666
  if (json.issues.length === 0) lines.push('None.');
600
667
  for (const issue of json.issues) {
601
- lines.push(`### ${issue.label}: ${issue.title} \`${issue.severity}\` - ${issue.category} - ${issue.occurrences}/${json.runs.length} personas`);
668
+ lines.push(`### ${issue.label}: ${issue.title} \`${issue.severity}\` - ${issue.category} - ${affected(issue)}/${personasRun} personas`);
602
669
  lines.push(`**Why it matters:** ${issue.description || ''}`);
603
670
  if (issue.repro_steps.length > 0) lines.push(`**Repro:** ${issue.repro_steps.map((step, i) => `${i + 1}. ${step}`).join(' ')}`);
604
671
  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