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.
- package/README.md +79 -8
- package/docs/ADAPTERS.md +2 -0
- package/docs/NATIVE_RUNNER.md +243 -0
- package/package.json +2 -1
- package/src/cli.js +6 -0
- package/src/commands/check.js +330 -106
- package/src/commands/run.js +55 -2
- package/src/run/aggregate.js +97 -33
- package/src/run/args.js +2 -0
- package/src/run/drivers/driver.js +7 -0
- package/src/run/drivers/web.js +376 -41
- package/src/run/drivers/webdriver-probe.js +353 -0
- package/src/run/dropped.js +7 -7
- package/src/run/personaLoop.js +247 -37
- package/src/run/regenerate.js +24 -4
- package/src/run/resume.js +7 -1
- package/src/run/tools.js +31 -10
- package/src/run/usage.js +19 -1
package/src/commands/run.js
CHANGED
|
@@ -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, {
|
|
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
|
package/src/run/aggregate.js
CHANGED
|
@@ -90,43 +90,100 @@ export function issueId(signature) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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:** ${
|
|
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
|
|
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
|
|
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
|