ravensight-playtest 0.2.8 → 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 +98 -9
- 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 +104 -8
- 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 +559 -34
- package/src/run/drivers/webdriver-probe.js +353 -0
- package/src/run/dropped.js +7 -7
- package/src/run/heartbeat.js +8 -1
- package/src/run/personaLoop.js +326 -62
- package/src/run/regenerate.js +24 -4
- package/src/run/report.js +93 -2
- package/src/run/resume.js +16 -1
- package/src/run/state.js +4 -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; }
|
|
@@ -445,7 +471,10 @@ export async function playJob({
|
|
|
445
471
|
const reportBudgetRefused = results.some(result => result.reportStepBudget === 'refused');
|
|
446
472
|
const reportBudgetDelivered = results.some(result => result.reportStepBudget === 'delivered' || result.reportStepBudget === 'dry_upload');
|
|
447
473
|
const canceled = results.some(result => result.state === 'canceled');
|
|
448
|
-
|
|
474
|
+
// A run whose report the server refused played, but the server never
|
|
475
|
+
// completed it: it is not a success, and CI should hear about it.
|
|
476
|
+
const anySucceeded = results.some(result => result.state === 'succeeded' && !result.reportRefused);
|
|
477
|
+
const reportRefused = results.some(result => result.reportRefused);
|
|
449
478
|
|
|
450
479
|
if (finish) {
|
|
451
480
|
const finished = await api.jobs.finish(gameId, jobId, {
|
|
@@ -459,7 +488,7 @@ export async function playJob({
|
|
|
459
488
|
log(`Reports: ${directory}`);
|
|
460
489
|
|
|
461
490
|
if (canceled) return EXIT.CANCELED;
|
|
462
|
-
if (reportBudgetRefused) return EXIT.FAILED;
|
|
491
|
+
if (reportBudgetRefused || reportRefused) return EXIT.FAILED;
|
|
463
492
|
if (playBudgetHit) return EXIT.BUDGET;
|
|
464
493
|
return anySucceeded || reportBudgetDelivered ? EXIT.OK : EXIT.FAILED;
|
|
465
494
|
}
|
|
@@ -558,13 +587,31 @@ export async function playPersonaRun({
|
|
|
558
587
|
heartbeat,
|
|
559
588
|
ledger,
|
|
560
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
|
+
},
|
|
561
599
|
log: message => log(`run ${runId}: ${message}`)
|
|
562
600
|
});
|
|
563
601
|
} catch (error) {
|
|
564
602
|
heartbeat.stop();
|
|
565
603
|
if (inFlight) inFlight.delete(runId);
|
|
566
604
|
const failed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
|
|
567
|
-
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
|
+
});
|
|
568
615
|
const delivery = {};
|
|
569
616
|
const answer = await deliver({ deps, api, gameId, jobId, runId, directory, outcome: failed, options, log, repoRoot, delivery });
|
|
570
617
|
if (isReportStepBudget(failed)) {
|
|
@@ -597,7 +644,8 @@ export async function playPersonaRun({
|
|
|
597
644
|
state: 'failed',
|
|
598
645
|
reason: `ran out of budget while writing the report; the server refused budget_exceeded (${delivery.message || 'no reason given'})`,
|
|
599
646
|
actions_taken: failed.actionsTaken,
|
|
600
|
-
quit_reason: failed.quitReason
|
|
647
|
+
quit_reason: failed.quitReason,
|
|
648
|
+
...(failed.duration ? { duration: failed.duration } : {})
|
|
601
649
|
});
|
|
602
650
|
await stateFile.patchRun(repoRoot, journal, runId, { completed: true, state: 'failed' });
|
|
603
651
|
log(`run ${runId} (${persona.slug}): completed as failed instead.`);
|
|
@@ -620,7 +668,15 @@ export async function playPersonaRun({
|
|
|
620
668
|
budget_warned: outcome.budgetWarned
|
|
621
669
|
});
|
|
622
670
|
|
|
623
|
-
|
|
671
|
+
const delivered = {};
|
|
672
|
+
await deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot, delivery: delivered });
|
|
673
|
+
|
|
674
|
+
if (delivered.reportInvalid) {
|
|
675
|
+
// No synthetic telemetry for findings the server does not hold, and no
|
|
676
|
+
// "succeeded" line for a run the server never completed.
|
|
677
|
+
log(`run ${runId} (${persona.slug}) played ${outcome.actionsTaken} actions (${outcome.quitReason}), but the server refused its report, so the run is not complete.`);
|
|
678
|
+
return { runId, persona: persona.slug, state: outcome.state, quitReason: outcome.quitReason, reportRefused: true };
|
|
679
|
+
}
|
|
624
680
|
|
|
625
681
|
if (outcome.report) {
|
|
626
682
|
const emitted = await emitRunEvents({
|
|
@@ -665,6 +721,14 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
665
721
|
actions_taken: outcome.actionsTaken,
|
|
666
722
|
quit_reason: outcome.quitReason
|
|
667
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
|
+
}
|
|
668
732
|
if (outcome.report) body.report = outcome.report;
|
|
669
733
|
// `report_md` is what the server lints for its four required sections. Not
|
|
670
734
|
// sending it means `report_lint` is silently empty on every run, which reads
|
|
@@ -713,7 +777,7 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
713
777
|
const { report: ignored, report_md: alsoIgnored, ...withoutReport } = body;
|
|
714
778
|
answer = await api.runs.complete(gameId, jobId, runId, withoutReport);
|
|
715
779
|
}
|
|
716
|
-
if (journal) await stateFile.patchRun(repoRoot, journal, runId, { uploaded: true, completed: true });
|
|
780
|
+
if (journal) await stateFile.patchRun(repoRoot, journal, runId, { uploaded: true, completed: true, report_refused: false });
|
|
717
781
|
if (answer && answer.dropped_findings > 0) {
|
|
718
782
|
log(`run ${runId}: the server dropped ${answer.dropped_findings} finding(s) with no usable evidence.`);
|
|
719
783
|
await markDropped({ directory, jobId, runId, repoRoot, outcome, droppedCount: answer.dropped_findings, droppedIds: answer.dropped_finding_ids, options, log });
|
|
@@ -728,11 +792,36 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
728
792
|
delivery.status = error && error.status;
|
|
729
793
|
delivery.code = error && error.code;
|
|
730
794
|
}
|
|
795
|
+
if (error && error.status === 422 && error.code === 'invalid_report') {
|
|
796
|
+
// Every validation message first, one per line, before anything says
|
|
797
|
+
// the run did not complete: the summary line alone ("does not match
|
|
798
|
+
// the playtest report schema") is not something anyone can act on.
|
|
799
|
+
const details = refusalDetails(error);
|
|
800
|
+
log(`run ${runId}: the server refused report.json (${details.length} problem(s)):\n${details.length > 0 ? details.map(line => ` - ${line}`).join('\n') : ' - (the server named no problem)'}`);
|
|
801
|
+
if (delivery) delivery.reportInvalid = true;
|
|
802
|
+
if (journal) await stateFile.patchRun(repoRoot, journal, runId, { report_refused: true });
|
|
803
|
+
log(`run ${runId}: not completed, so the server keeps no findings from it; whether its unit is refunded is decided when the job settles. report.json and the screenshots stay in ${directory}.`);
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
731
806
|
log(`run ${runId}: upload or complete was refused (${String(error && error.message)}). The artifacts are in the run directory and "ravensight-playtest resume" can retry it.`);
|
|
732
807
|
return null;
|
|
733
808
|
}
|
|
734
809
|
}
|
|
735
810
|
|
|
811
|
+
/**
|
|
812
|
+
* The validation messages a 422 carried, as `pointer: message` lines. Read
|
|
813
|
+
* from `details` (the API client's error) or `body.details` (the envelope).
|
|
814
|
+
*
|
|
815
|
+
* @param {any} error
|
|
816
|
+
* @returns {Array<string>}
|
|
817
|
+
*/
|
|
818
|
+
export function refusalDetails(error) {
|
|
819
|
+
const list = Array.isArray(error && error.details) && error.details.length > 0
|
|
820
|
+
? error.details
|
|
821
|
+
: (error && error.body && Array.isArray(error.body.details) ? error.body.details : []);
|
|
822
|
+
return list.map(detail => `${(detail && detail.instancePath) || '/'}: ${(detail && detail.message) || 'is invalid'}`);
|
|
823
|
+
}
|
|
824
|
+
|
|
736
825
|
/**
|
|
737
826
|
* Bring the local report into line with what the server kept, so report.md,
|
|
738
827
|
* report.json, the aggregate and the telemetry count what the dashboard
|
|
@@ -843,7 +932,7 @@ async function aggregatePhase({
|
|
|
843
932
|
|
|
844
933
|
// The aggregate reads reports off disk, so a resumed job whose earlier runs
|
|
845
934
|
// were played by a previous process is covered too.
|
|
846
|
-
const reports = await loadRunReports(repoRoot, jobId, personaRuns);
|
|
935
|
+
const reports = await loadRunReports(repoRoot, jobId, personaRuns, journal);
|
|
847
936
|
let aggregate;
|
|
848
937
|
try {
|
|
849
938
|
aggregate = await buildAggregate({
|
|
@@ -1107,9 +1196,16 @@ export function resolveKeyModifier(value, env = process.env) {
|
|
|
1107
1196
|
* from the notes when the money ran out) is loaded as a `budget_exceeded`
|
|
1108
1197
|
* run: its findings count, and the aggregate says the job is partial.
|
|
1109
1198
|
*/
|
|
1110
|
-
export async function loadRunReports(repoRoot, jobId, personaRuns) {
|
|
1199
|
+
export async function loadRunReports(repoRoot, jobId, personaRuns, journal = null) {
|
|
1111
1200
|
const out = [];
|
|
1112
1201
|
for (const entry of personaRuns) {
|
|
1202
|
+
// A report the server refused is on disk but not in the record: the
|
|
1203
|
+
// summary must not count findings the dashboard does not have.
|
|
1204
|
+
const recorded = journal && journal.runs ? journal.runs[entry.run_id] : null;
|
|
1205
|
+
if (recorded && recorded.report_refused === true) {
|
|
1206
|
+
out.push({ runId: entry.run_id, persona: entry.persona, state: 'failed', report: null, reportRefused: true });
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1113
1209
|
const file = path.join(runDirFor(repoRoot, jobId, entry.run_id), RUN_FILES.reportJson);
|
|
1114
1210
|
try {
|
|
1115
1211
|
const report = JSON.parse(await readFile(file, 'utf8'));
|
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
|