ravensight-playtest 0.3.0 → 0.3.2
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 +207 -24
- package/docs/NATIVE_RUNNER.md +2 -2
- package/package.json +2 -2
- package/src/api/client.js +9 -3
- package/src/briefLocal.js +126 -0
- package/src/cli.js +14 -3
- package/src/commands/brief.js +48 -9
- package/src/commands/check.js +187 -38
- package/src/commands/fix.js +12 -1
- package/src/commands/init.js +13 -6
- package/src/commands/personas.js +50 -0
- package/src/commands/run.js +264 -31
- package/src/run/aggregate.js +6 -3
- package/src/run/args.js +18 -1
- package/src/run/drivers/web.js +634 -83
- package/src/run/noise.js +102 -0
- package/src/run/personaLoop.js +658 -26
- package/src/run/report.js +187 -0
- package/src/run/resume.js +5 -0
- package/src/run/tools.js +20 -4
- package/src/runDefaults.js +90 -0
- package/src/state/index.js +4 -1
- package/src/ui/index.js +15 -1
package/src/commands/run.js
CHANGED
|
@@ -14,6 +14,9 @@ import { readBridgeConfig } from "../run/drivers/stdio.js";
|
|
|
14
14
|
import { WEBDRIVER_NAMES, readDriverConfig } from '../run/drivers/webdriver.js';
|
|
15
15
|
import { automationChecks } from './check.js';
|
|
16
16
|
import { runPersona } from '../run/personaLoop.js';
|
|
17
|
+
import { loadLocalBrief, localBriefProblems } from '../briefLocal.js';
|
|
18
|
+
import { DEFAULT_PERSONAS as RUN_DEFAULT_PERSONAS, RUN_MODULES, SIGN_UP_ACTIONS, defaultPersonasFor, describeBreakdown, personaCount, unknownPersonaMessage, unknownPersonas } from '../runDefaults.js';
|
|
19
|
+
import { bold } from '../ui/index.js';
|
|
17
20
|
import { reconcileDroppedFindings } from '../run/dropped.js';
|
|
18
21
|
import * as stateFile from '../run/state.js';
|
|
19
22
|
import { emitRunEvents } from '../run/synthetic.js';
|
|
@@ -44,8 +47,8 @@ import { emitRunEvents } from '../run/synthetic.js';
|
|
|
44
47
|
* nothing to point at, 4 auth, 5 the proxy never answered, 10 cancelled.
|
|
45
48
|
*/
|
|
46
49
|
|
|
47
|
-
/** Personas used when the developer names none
|
|
48
|
-
export const DEFAULT_PERSONAS =
|
|
50
|
+
/** Personas used when the developer names none: one since 0.3.2 (see ../runDefaults.js). */
|
|
51
|
+
export const DEFAULT_PERSONAS = RUN_DEFAULT_PERSONAS;
|
|
49
52
|
|
|
50
53
|
/** Parallel runs. Each Chromium is 0.5 to 1.5 GB, so 2 is the honest default. */
|
|
51
54
|
export const DEFAULT_CONCURRENCY = 2;
|
|
@@ -132,15 +135,26 @@ async function run(flags, injected, deps, log) {
|
|
|
132
135
|
return EXIT.FAILED;
|
|
133
136
|
}
|
|
134
137
|
}
|
|
135
|
-
|
|
138
|
+
// The build's own health, before anything is priced or charged: the
|
|
139
|
+
// Snowball round paid for a run against an API that was down.
|
|
140
|
+
if (!options.skipPreflight) {
|
|
141
|
+
const health = await healthPreflight({ config: context.config, driverName, target, fetchImpl: injected.fetch, log });
|
|
142
|
+
if (health && !health.ok) {
|
|
143
|
+
log(`Preflight failed: ${health.detail}.`);
|
|
144
|
+
log('No job was registered and nothing was reserved. Start the build or its API and run this again, or pass --skip-preflight to register anyway.');
|
|
145
|
+
return EXIT.FAILED;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const modules = [...RUN_MODULES];
|
|
136
149
|
const pack = await deps.packs.get({ api, gameId, modules });
|
|
137
150
|
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; }
|
|
138
151
|
const personaSlugs = resolvePersonas(options, context.config, pack);
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return
|
|
143
|
-
}
|
|
152
|
+
const unknownSlugs = unknownPersonas(personaSlugs, pack.personas || []);
|
|
153
|
+
if (unknownSlugs.length > 0) {
|
|
154
|
+
log(`${unknownPersonaMessage(unknownSlugs, pack.personas || [])} Nothing was registered or charged.`);
|
|
155
|
+
return EXIT.ENVIRONMENT;
|
|
156
|
+
}
|
|
157
|
+
const personas = personaSlugs.map(slug => (pack.personas || []).find(persona => persona.slug === slug));
|
|
144
158
|
|
|
145
159
|
// The same ceiling the server's `personaListRefusal` enforces, checked here
|
|
146
160
|
// first so a list past it never reaches the network: no estimate, no brief
|
|
@@ -168,8 +182,43 @@ async function run(flags, injected, deps, log) {
|
|
|
168
182
|
return EXIT.ENVIRONMENT;
|
|
169
183
|
}
|
|
170
184
|
|
|
171
|
-
|
|
172
|
-
|
|
185
|
+
let localBrief = {};
|
|
186
|
+
try {
|
|
187
|
+
localBrief = await loadLocalBrief({ repoRoot });
|
|
188
|
+
} catch (error) {
|
|
189
|
+
log(`${String(error && error.message)} The run goes ahead without expected_noise and test_account.`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const runLength = options.runLength === 'long' ? 'long' : 'standard';
|
|
193
|
+
let estimate;
|
|
194
|
+
try {
|
|
195
|
+
estimate = await api.jobs.estimate(gameId, { modules, personas: personaSlugs, runLength });
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (error && error.code === 'invalid_estimate') {
|
|
198
|
+
log(`The server refused to price this job: ${String(error.message || 'invalid estimate')}${runLength === 'long' ? ' This server may not price long runs yet; run without --long.' : ''} Nothing was registered or charged.`);
|
|
199
|
+
return EXIT.ENVIRONMENT;
|
|
200
|
+
}
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
// A long run is its own unit on the server. An older server that ignores
|
|
204
|
+
// run_length prices a standard run, and confirming that would buy a run
|
|
205
|
+
// the CLI then plays as long.
|
|
206
|
+
if (runLength === 'long' && !(Array.isArray(estimate.breakdown) && estimate.breakdown.some(line => line && line.unit === 'playtest_run_long'))) {
|
|
207
|
+
log('This server does not price long runs; nothing was registered or charged. Run without --long.');
|
|
208
|
+
return EXIT.ENVIRONMENT;
|
|
209
|
+
}
|
|
210
|
+
if (runLength === 'long') {
|
|
211
|
+
const factor = longRunFactor({ estimate, pack });
|
|
212
|
+
options.longRunFactor = factor.factor;
|
|
213
|
+
if (factor.note) log(factor.note);
|
|
214
|
+
}
|
|
215
|
+
// Said before any charge, even with --yes, in bold: how many personas and
|
|
216
|
+
// what they cost in total, each unit with its own price.
|
|
217
|
+
const units = describeBreakdown(estimate.breakdown, formatCents);
|
|
218
|
+
log(bold(`This run: ${personaCount(personas.length)} (${personaSlugs.join(', ')}) on ${driverName}, ${formatCents(estimate.estimate_cents)} in total${units ? `: ${units}` : ''}.`));
|
|
219
|
+
log(`Balance: ${formatCents(estimate.balance_cents)}.`);
|
|
220
|
+
if (runLength === 'long') log(longRunLine(options.longRunFactor));
|
|
221
|
+
for (const line of actionBudgetLines({ options, personas, pack, localBrief })) log(line);
|
|
173
222
|
if (estimate.ok === false) {
|
|
174
223
|
log(`That is more than the balance covers. Top up at ${estimate.topup_url || 'ravensight.io'} and run this again.`);
|
|
175
224
|
// Nothing is wrong with the request; the account needs money. Spec 17's
|
|
@@ -203,6 +252,7 @@ async function run(flags, injected, deps, log) {
|
|
|
203
252
|
build: { kind: driverName === 'playwright_web' ? 'url' : (WEBDRIVER_NAMES.includes(driverName) || driverName === 'cli_stdio') ? 'driver_config' : 'godot_project', ref: target },
|
|
204
253
|
repo: { commit: (context.config && context.config.commit) || '', dirty: Boolean(context.config && context.config.dirty) },
|
|
205
254
|
max_actions: Number.isInteger(options.maxActions) ? options.maxActions : null,
|
|
255
|
+
...(runLength === 'long' ? { run_length: 'long' } : {}),
|
|
206
256
|
pack_versions: { pack: pack.pack_version }
|
|
207
257
|
}, { idempotencyKey });
|
|
208
258
|
} catch (error) {
|
|
@@ -227,6 +277,8 @@ async function run(flags, injected, deps, log) {
|
|
|
227
277
|
priceCents: registered.price && registered.price.price_cents,
|
|
228
278
|
options: {
|
|
229
279
|
maxActions: options.maxActions ?? null,
|
|
280
|
+
runLength,
|
|
281
|
+
...(runLength === 'long' ? { longRunFactor: options.longRunFactor } : {}),
|
|
230
282
|
controller: options.controller || 'standard',
|
|
231
283
|
concurrency: options.concurrency ?? DEFAULT_CONCURRENCY,
|
|
232
284
|
uploadVideo: Boolean(options.uploadVideo),
|
|
@@ -257,6 +309,7 @@ async function run(flags, injected, deps, log) {
|
|
|
257
309
|
journal,
|
|
258
310
|
config: context.config,
|
|
259
311
|
brief,
|
|
312
|
+
localBrief,
|
|
260
313
|
injected,
|
|
261
314
|
log
|
|
262
315
|
});
|
|
@@ -371,6 +424,7 @@ export async function playJob({
|
|
|
371
424
|
journal,
|
|
372
425
|
config,
|
|
373
426
|
brief = null,
|
|
427
|
+
localBrief: givenLocalBrief = null,
|
|
374
428
|
injected = {},
|
|
375
429
|
log = () => {},
|
|
376
430
|
finish = true
|
|
@@ -382,6 +436,17 @@ export async function playJob({
|
|
|
382
436
|
|
|
383
437
|
const directory = jobDir(repoRoot, jobId);
|
|
384
438
|
const personaRuns = runs.filter(entry => entry.module === 'persona_playtest');
|
|
439
|
+
// expected_noise and test_account, which never go to the server's brief.
|
|
440
|
+
let localBrief = givenLocalBrief || injected.localBrief || null;
|
|
441
|
+
if (!localBrief) {
|
|
442
|
+
try {
|
|
443
|
+
localBrief = await loadLocalBrief({ repoRoot });
|
|
444
|
+
} catch (error) {
|
|
445
|
+
log(`${String(error && error.message)} The run goes ahead without expected_noise and test_account.`);
|
|
446
|
+
localBrief = {};
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
for (const problem of localBriefProblems(localBrief)) log(`.ravensight/brief.local.json: ${problem}; that field is ignored.`);
|
|
385
450
|
const aggregateRun = runs.find(entry => entry.module === 'aggregate_report') || null;
|
|
386
451
|
|
|
387
452
|
const results = [];
|
|
@@ -423,6 +488,7 @@ export async function playJob({
|
|
|
423
488
|
inFlight,
|
|
424
489
|
makeDriver,
|
|
425
490
|
brief,
|
|
491
|
+
localBrief,
|
|
426
492
|
log
|
|
427
493
|
}));
|
|
428
494
|
}
|
|
@@ -517,6 +583,7 @@ export async function playPersonaRun({
|
|
|
517
583
|
inFlight = null,
|
|
518
584
|
makeDriver,
|
|
519
585
|
brief = null,
|
|
586
|
+
localBrief = {},
|
|
520
587
|
log
|
|
521
588
|
}) {
|
|
522
589
|
const runId = entry.run_id;
|
|
@@ -525,7 +592,7 @@ export async function playPersonaRun({
|
|
|
525
592
|
if (inFlight) inFlight.set(runId, { state: 'queued' });
|
|
526
593
|
|
|
527
594
|
const skill = skillFor(pack, 'persona_playtest');
|
|
528
|
-
const maxActions = resolveMaxActions(options, persona, pack);
|
|
595
|
+
const maxActions = resolveMaxActions(options, persona, pack, entry);
|
|
529
596
|
// Headless is right for a browser and wrong for Godot: a headless Godot
|
|
530
597
|
// routes no GUI input, so it is a smoke-test mode rather than a way to play.
|
|
531
598
|
const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
|
|
@@ -581,6 +648,7 @@ export async function playPersonaRun({
|
|
|
581
648
|
// The brief this command fetched and checked is complete, not the
|
|
582
649
|
// pack's copy, which a pack bundle does not always carry.
|
|
583
650
|
brief: brief || pack.brief || null,
|
|
651
|
+
localBrief: usableLocalBrief(localBrief),
|
|
584
652
|
intentNotes: pack.intent_notes || [],
|
|
585
653
|
schemas: pack.schemas || [],
|
|
586
654
|
maxActions,
|
|
@@ -698,6 +766,18 @@ export async function playPersonaRun({
|
|
|
698
766
|
return { runId, persona: persona.slug, state: outcome.state, quitReason: outcome.quitReason };
|
|
699
767
|
}
|
|
700
768
|
|
|
769
|
+
/**
|
|
770
|
+
* A run's duration as `complete` sends it: `{ wall_seconds, model_seconds }`,
|
|
771
|
+
* each a number or null.
|
|
772
|
+
*
|
|
773
|
+
* @param {{wall_seconds?: number, model_seconds?: number}} duration
|
|
774
|
+
* @returns {{wall_seconds: number|null, model_seconds: number|null}}
|
|
775
|
+
*/
|
|
776
|
+
export function durationBody(duration) {
|
|
777
|
+
const value = key => (duration && Number.isFinite(Number(duration[key])) && duration[key] !== null ? Number(duration[key]) : null);
|
|
778
|
+
return { wall_seconds: value('wall_seconds'), model_seconds: value('model_seconds') };
|
|
779
|
+
}
|
|
780
|
+
|
|
701
781
|
/**
|
|
702
782
|
* Upload the run directory and complete the run, through cli-core's
|
|
703
783
|
* `finalizeRun` so the 422 `upload_unverified` repair pass lives in one place.
|
|
@@ -723,12 +803,7 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
723
803
|
};
|
|
724
804
|
// The run card's duration. The server lane accepts `duration`; an older
|
|
725
805
|
// 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
|
-
}
|
|
806
|
+
if (outcome.duration) body.duration = durationBody(outcome.duration);
|
|
732
807
|
if (outcome.report) body.report = outcome.report;
|
|
733
808
|
// `report_md` is what the server lints for its four required sections. Not
|
|
734
809
|
// sending it means `report_lint` is silently empty on every run, which reads
|
|
@@ -910,6 +985,8 @@ async function aggregatePhase({
|
|
|
910
985
|
log
|
|
911
986
|
}) {
|
|
912
987
|
const runId = aggregateRun ? aggregateRun.run_id : null;
|
|
988
|
+
// Created here, so its wall clock is measured from the start of the
|
|
989
|
+
// aggregate phase: what the summary step's card shows as its duration.
|
|
913
990
|
const ledger = createUsageLedger();
|
|
914
991
|
const heartbeat = runId
|
|
915
992
|
? createHeartbeat({ api, gameId, jobId, runId, onUsage: usage => ledger.recordServerUsage(usage), onError: () => {} })
|
|
@@ -966,7 +1043,7 @@ async function aggregatePhase({
|
|
|
966
1043
|
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.');
|
|
967
1044
|
}
|
|
968
1045
|
|
|
969
|
-
const published = await publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log });
|
|
1046
|
+
const published = await publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log, duration: durationBody(ledger.duration()) });
|
|
970
1047
|
if (published === 'finished') reached = 'succeeded';
|
|
971
1048
|
else if (published) await advance('uploading');
|
|
972
1049
|
|
|
@@ -976,7 +1053,8 @@ async function aggregatePhase({
|
|
|
976
1053
|
state: reached === 'uploading' ? 'succeeded' : 'failed',
|
|
977
1054
|
actions_taken: 0,
|
|
978
1055
|
quit_reason: reached === 'uploading' ? 'goal_reached' : 'error',
|
|
979
|
-
reason: reached === 'uploading' ? '' : 'the aggregate was not published'
|
|
1056
|
+
reason: reached === 'uploading' ? '' : 'the aggregate was not published',
|
|
1057
|
+
duration: durationBody(ledger.duration())
|
|
980
1058
|
});
|
|
981
1059
|
} catch (error) {
|
|
982
1060
|
log(`aggregate run ${runId}: complete was refused (${String(error && error.message)}).`);
|
|
@@ -996,7 +1074,7 @@ async function aggregatePhase({
|
|
|
996
1074
|
* sent, 'published' when the server has it, 'finished' when it also closed
|
|
997
1075
|
* the aggregate run
|
|
998
1076
|
*/
|
|
999
|
-
async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log }) {
|
|
1077
|
+
async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory, options, log, duration = null }) {
|
|
1000
1078
|
if (options.dryUpload) {
|
|
1001
1079
|
log('The aggregate was not published: --dry-upload.');
|
|
1002
1080
|
return false;
|
|
@@ -1023,7 +1101,11 @@ async function publishAggregate({ deps, api, gameId, jobId, repoRoot, directory,
|
|
|
1023
1101
|
md_size: markdown.size,
|
|
1024
1102
|
md_sha256: markdown.sha256,
|
|
1025
1103
|
json_size: json.size,
|
|
1026
|
-
json_sha256: json.sha256
|
|
1104
|
+
json_sha256: json.sha256,
|
|
1105
|
+
// The server closes the aggregate run on this call, so the duration
|
|
1106
|
+
// rides here as well as on the run's own complete; an older server
|
|
1107
|
+
// ignores a key it does not know.
|
|
1108
|
+
...(duration ? { duration } : {})
|
|
1027
1109
|
});
|
|
1028
1110
|
// The server finishes the aggregate run itself when it accepts the
|
|
1029
1111
|
// report, and says so in `run`. A caller that then tries to move the
|
|
@@ -1246,6 +1328,22 @@ export function capabilityNotesFor(pack, driverName) {
|
|
|
1246
1328
|
return notes;
|
|
1247
1329
|
}
|
|
1248
1330
|
|
|
1331
|
+
/**
|
|
1332
|
+
* The local brief with any field that failed its check left out, so a
|
|
1333
|
+
* malformed test account never reaches the prompt half filled.
|
|
1334
|
+
*
|
|
1335
|
+
* @param {Object} localBrief
|
|
1336
|
+
* @returns {Object}
|
|
1337
|
+
*/
|
|
1338
|
+
export function usableLocalBrief(localBrief) {
|
|
1339
|
+
const out = {};
|
|
1340
|
+
for (const key of ['expected_noise', 'test_account']) {
|
|
1341
|
+
if (!localBrief || localBrief[key] === undefined) continue;
|
|
1342
|
+
if (localBriefProblems({ [key]: localBrief[key] }).length === 0) out[key] = localBrief[key];
|
|
1343
|
+
}
|
|
1344
|
+
return out;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1249
1347
|
export function skillFor(pack, moduleName) {
|
|
1250
1348
|
const entry = (pack.skills || []).find(skill => skill.module === moduleName);
|
|
1251
1349
|
if (!entry) throw new Error(`the content pack has no ${moduleName} skill; run a pack sync`);
|
|
@@ -1258,23 +1356,158 @@ export function resolvePersonas(options, config, pack) {
|
|
|
1258
1356
|
return options.personas.split(',').map(slug => slug.trim()).filter(Boolean);
|
|
1259
1357
|
}
|
|
1260
1358
|
if (config && Array.isArray(config.personas) && config.personas.length > 0) return config.personas;
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1359
|
+
return defaultPersonasFor(pack.personas || []);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* What one persona run buys, said before the charge: the action budget each
|
|
1364
|
+
* persona gets (its patience in the pack, lowered by --max-actions or the
|
|
1365
|
+
* server's ceiling), and that a sign up form alone can take eight of them.
|
|
1366
|
+
*
|
|
1367
|
+
* @returns {Array<string>}
|
|
1368
|
+
*/
|
|
1369
|
+
export function actionBudgetLines({ options = {}, personas = [], pack = {}, localBrief = {} }) {
|
|
1370
|
+
if (personas.length === 0) return [];
|
|
1371
|
+
const budgets = personas.map(persona => ({ slug: persona.slug, actions: resolveMaxActions(options, persona, pack) }));
|
|
1372
|
+
const same = budgets.every(entry => entry.actions === budgets[0].actions);
|
|
1373
|
+
const typical = Math.min(...budgets.map(entry => entry.actions));
|
|
1374
|
+
const lines = [
|
|
1375
|
+
same
|
|
1376
|
+
? `Action budget: ${budgets[0].actions} actions per persona.`
|
|
1377
|
+
: `Action budget: ${budgets.map(entry => `${entry.slug} ${entry.actions}`).join(', ')} actions.`,
|
|
1378
|
+
`A persona typically reaches ${typical} actions; sign-up alone can take ${SIGN_UP_ACTIONS}.${localBrief && localBrief.test_account ? ' The brief\'s test account lets it sign in instead.' : ' A test_account in the brief lets it start past sign-up.'}`
|
|
1379
|
+
];
|
|
1380
|
+
return lines;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/** How long the health check waits for an answer. */
|
|
1384
|
+
export const HEALTH_TIMEOUT_MS = 10000;
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* Whether a status is what the health check expects: the configured
|
|
1388
|
+
* `expect_status` (a number or a list of numbers), else any 2xx or 3xx.
|
|
1389
|
+
*
|
|
1390
|
+
* @param {number} status
|
|
1391
|
+
* @param {number|Array<number>|undefined} expected
|
|
1392
|
+
* @returns {boolean}
|
|
1393
|
+
*/
|
|
1394
|
+
export function statusExpected(status, expected) {
|
|
1395
|
+
if (Array.isArray(expected) && expected.length > 0) return expected.map(Number).includes(status);
|
|
1396
|
+
if (Number.isInteger(Number(expected)) && expected !== null && expected !== undefined && expected !== '') return status === Number(expected);
|
|
1397
|
+
return status >= 200 && status < 400;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* The free health check `run` makes before it prices or registers anything.
|
|
1402
|
+
* `.ravensight/config.json` may name one: `preflight: { url, expect_status }`
|
|
1403
|
+
* (a relative url is resolved against the build url). With none, a web
|
|
1404
|
+
* build's own url has to answer 2xx or 3xx. Other drivers with no configured
|
|
1405
|
+
* url are not checked. Redirects are not followed: a 3xx is an answer.
|
|
1406
|
+
*
|
|
1407
|
+
* @returns {Promise<{ok: boolean, url: string, status: number|null, detail: string}|null>} null when nothing was checked
|
|
1408
|
+
*/
|
|
1409
|
+
export async function healthPreflight({ config = null, driverName, target, fetchImpl = null, timeoutMs = HEALTH_TIMEOUT_MS, log = () => {} }) {
|
|
1410
|
+
const configured = config && config.preflight && typeof config.preflight === 'object' ? config.preflight : null;
|
|
1411
|
+
let url = configured && typeof configured.url === 'string' && configured.url.trim() ? configured.url.trim() : null;
|
|
1412
|
+
const expected = configured ? configured.expect_status : undefined;
|
|
1413
|
+
if (url && !/^https?:\/\//i.test(url)) {
|
|
1414
|
+
if (driverName !== 'playwright_web' || !target) return { ok: false, url, status: null, detail: `preflight.url ${url} is not an http or https URL` };
|
|
1415
|
+
url = new URL(url, target).toString();
|
|
1416
|
+
}
|
|
1417
|
+
if (!url) {
|
|
1418
|
+
if (driverName !== 'playwright_web' || !/^https?:\/\//i.test(String(target || ''))) return null;
|
|
1419
|
+
url = String(target);
|
|
1420
|
+
}
|
|
1421
|
+
const doFetch = fetchImpl || globalThis.fetch;
|
|
1422
|
+
const wanted = Array.isArray(expected) && expected.length > 0 ? expected.join(' or ') : (expected !== undefined && expected !== null && expected !== '' ? String(expected) : '2xx or 3xx');
|
|
1423
|
+
log(`preflight: GET ${url}, expecting ${wanted}`);
|
|
1424
|
+
try {
|
|
1425
|
+
const response = await doFetch(url, { method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
|
|
1426
|
+
const status = Number(response.status);
|
|
1427
|
+
const ok = statusExpected(status, expected);
|
|
1428
|
+
return { ok, url, status, detail: ok ? `${url} answered ${status}` : `GET ${url} answered ${status}, expected ${wanted}` };
|
|
1429
|
+
} catch (error) {
|
|
1430
|
+
const reason = error && error.name === 'TimeoutError' ? `no answer in ${Math.round(timeoutMs / 1000)} seconds` : String((error && error.cause && error.cause.code) || (error && error.message) || error);
|
|
1431
|
+
return { ok: false, url, status: null, detail: `GET ${url} failed: ${reason}` };
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/** The factor a long run's limits use when the server names none. */
|
|
1436
|
+
export const FALLBACK_LONG_RUN_FACTOR = 2;
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* How many times a standard run's limits a long run gets, from the server:
|
|
1440
|
+
* the estimate's `long_run_factor`, a run's `limits.long_run_factor`, or the
|
|
1441
|
+
* pack's client routing `long_run_factor`. Only when none of them says does it
|
|
1442
|
+
* fall back to 2, with a note saying so.
|
|
1443
|
+
*
|
|
1444
|
+
* @returns {{factor: number, source: string, note: string|null}}
|
|
1445
|
+
*/
|
|
1446
|
+
export function longRunFactor({ estimate = null, run = null, pack = null } = {}) {
|
|
1447
|
+
const sources = [
|
|
1448
|
+
['the estimate', estimate && estimate.long_run_factor],
|
|
1449
|
+
['the run limits', run && run.limits && run.limits.long_run_factor],
|
|
1450
|
+
['the content pack', pack && pack.routing && pack.routing.long_run_factor]
|
|
1451
|
+
];
|
|
1452
|
+
for (const [source, value] of sources) {
|
|
1453
|
+
const factor = Number(value);
|
|
1454
|
+
if (Number.isInteger(factor) && factor >= 1) return { factor, source, note: null };
|
|
1455
|
+
}
|
|
1456
|
+
return { factor: FALLBACK_LONG_RUN_FACTOR, source: 'fallback', note: `The server did not say how much longer a long run is, so the local action budget assumes ${FALLBACK_LONG_RUN_FACTOR}x; the server's own limits still apply.` };
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/** "twice", "3 times": a factor as the run line says it. */
|
|
1460
|
+
function times(factor) {
|
|
1461
|
+
return factor === 2 ? 'twice' : `${factor} times`;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* Said under the price when `--long` was passed; the price itself comes from the estimate.
|
|
1466
|
+
*
|
|
1467
|
+
* @param {number} [factor]
|
|
1468
|
+
* @returns {string}
|
|
1469
|
+
*/
|
|
1470
|
+
export function longRunLine(factor = FALLBACK_LONG_RUN_FACTOR) {
|
|
1471
|
+
return `Long run: each persona gets ${times(factor)} the actions, model budget and wall time of a standard run, and is priced as a long run in the total above.`;
|
|
1264
1472
|
}
|
|
1265
1473
|
|
|
1474
|
+
/** The line for the default factor, kept for callers that print it as is. */
|
|
1475
|
+
export const LONG_RUN_LINE = longRunLine();
|
|
1476
|
+
|
|
1477
|
+
/** A standard run's local action budget when nothing else sets one. */
|
|
1478
|
+
export const DEFAULT_MAX_ACTIONS = 25;
|
|
1479
|
+
|
|
1266
1480
|
/**
|
|
1267
1481
|
* The action budget is the smallest of the three ceilings that exist: what the
|
|
1268
|
-
* developer asked for, the persona's own patience, and what
|
|
1269
|
-
*
|
|
1482
|
+
* developer asked for, the persona's own patience, and what the run is allowed
|
|
1483
|
+
* (`max_actions`, which arrives in the pack's client routing subset).
|
|
1484
|
+
*
|
|
1485
|
+
* A long run (`options.runLength === 'long'`) uses the run's own action limit
|
|
1486
|
+
* when the server's register answer carries one on the run (`max_actions`, or
|
|
1487
|
+
* `limits.max_actions`), and otherwise the server's long run factor (see
|
|
1488
|
+
* {@link longRunFactor}) times the routing ceiling. The persona's patience is
|
|
1489
|
+
* multiplied too, or a long run would stop where a standard one does.
|
|
1490
|
+
* `--max-actions` is the developer's own ceiling and is never doubled.
|
|
1491
|
+
*
|
|
1492
|
+
* @param {Object} options
|
|
1493
|
+
* @param {Object} persona
|
|
1494
|
+
* @param {Object} pack
|
|
1495
|
+
* @param {Object|null} [run] the run as register answered it
|
|
1496
|
+
* @returns {number}
|
|
1270
1497
|
*/
|
|
1271
|
-
export function resolveMaxActions(options, persona, pack) {
|
|
1498
|
+
export function resolveMaxActions(options, persona, pack, run = null) {
|
|
1499
|
+
const factor = options && options.runLength === 'long'
|
|
1500
|
+
? (Number.isInteger(options.longRunFactor) && options.longRunFactor >= 1 ? options.longRunFactor : longRunFactor({ run, pack }).factor)
|
|
1501
|
+
: 1;
|
|
1502
|
+
const fromServer = Number(run && ((run.limits && run.limits.max_actions) ?? run.max_actions)) || null;
|
|
1503
|
+
const routing = Number(pack && pack.routing && pack.routing.max_actions) || null;
|
|
1504
|
+
const patience = Number(persona && persona.patience_actions) || null;
|
|
1272
1505
|
const candidates = [
|
|
1273
|
-
Number.isInteger(options.maxActions) ? options.maxActions : null,
|
|
1274
|
-
|
|
1275
|
-
|
|
1506
|
+
Number.isInteger(options && options.maxActions) ? options.maxActions : null,
|
|
1507
|
+
patience === null ? null : patience * factor,
|
|
1508
|
+
fromServer || (routing === null ? null : routing * factor)
|
|
1276
1509
|
].filter(value => Number.isFinite(value) && value > 0);
|
|
1277
|
-
return candidates.length > 0 ? Math.min(...candidates) :
|
|
1510
|
+
return candidates.length > 0 ? Math.min(...candidates) : DEFAULT_MAX_ACTIONS * factor;
|
|
1278
1511
|
}
|
|
1279
1512
|
|
|
1280
1513
|
export function resolveTarget(options, config, driverName) {
|
package/src/run/aggregate.js
CHANGED
|
@@ -196,7 +196,7 @@ export function clusterFindings(runs) {
|
|
|
196
196
|
*
|
|
197
197
|
* @returns {Promise<Map<string, {same: boolean, title: string|null}>>}
|
|
198
198
|
*/
|
|
199
|
-
export async function judgeClusters({ client, jobId, runId, clusters, log = () => {} }) {
|
|
199
|
+
export async function judgeClusters({ client, jobId, runId, clusters, ledger = null, log = () => {} }) {
|
|
200
200
|
const verdicts = new Map();
|
|
201
201
|
// A certain cluster is one finding written twice; nothing to ask.
|
|
202
202
|
const ambiguous = clusters.filter(cluster => cluster.members.length > 1 && !cluster.certain);
|
|
@@ -214,6 +214,7 @@ export async function judgeClusters({ client, jobId, runId, clusters, log = () =
|
|
|
214
214
|
].join('\n');
|
|
215
215
|
|
|
216
216
|
try {
|
|
217
|
+
const askedAt = Date.now();
|
|
217
218
|
const answer = await callStepWithRetry({
|
|
218
219
|
client,
|
|
219
220
|
jobId,
|
|
@@ -222,6 +223,7 @@ export async function judgeClusters({ client, jobId, runId, clusters, log = () =
|
|
|
222
223
|
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
223
224
|
maxTokens: 512
|
|
224
225
|
});
|
|
226
|
+
if (ledger) ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
|
|
225
227
|
for (const entry of parseJsonArray(answer.text)) {
|
|
226
228
|
const cluster = ambiguous[Number(entry.cluster)];
|
|
227
229
|
if (!cluster) continue;
|
|
@@ -400,7 +402,7 @@ export async function buildAggregate({
|
|
|
400
402
|
const { kept, observations } = applyQualityGate(inputs);
|
|
401
403
|
const clusters = clusterFindings(kept);
|
|
402
404
|
const verdicts = aggregateRunId
|
|
403
|
-
? await judgeClusters({ client, jobId, runId: aggregateRunId, clusters, log })
|
|
405
|
+
? await judgeClusters({ client, jobId, runId: aggregateRunId, clusters, ledger, log })
|
|
404
406
|
: new Map();
|
|
405
407
|
|
|
406
408
|
const issues = clusters.flatMap(cluster => toIssues(cluster, verdicts.get(cluster.signature)));
|
|
@@ -523,6 +525,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
|
|
|
523
525
|
].join('\n');
|
|
524
526
|
|
|
525
527
|
try {
|
|
528
|
+
const askedAt = Date.now();
|
|
526
529
|
const answer = await callStepWithRetry({
|
|
527
530
|
client,
|
|
528
531
|
jobId,
|
|
@@ -531,7 +534,7 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
|
|
|
531
534
|
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
|
532
535
|
maxTokens: 8192
|
|
533
536
|
});
|
|
534
|
-
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 });
|
|
535
538
|
return parseJsonObject(answer.text);
|
|
536
539
|
} catch (error) {
|
|
537
540
|
// A budget or cancel refusal decides the caller's exit code and has to
|
package/src/run/args.js
CHANGED
|
@@ -35,6 +35,9 @@ const BOOLEAN = new Map([
|
|
|
35
35
|
['--headed', 'headed'],
|
|
36
36
|
['--headless', 'headless'],
|
|
37
37
|
['--verbose', 'verbose'],
|
|
38
|
+
// A long persona run: twice the actions, model budget and wall time of a
|
|
39
|
+
// standard run, priced by the server as its own unit.
|
|
40
|
+
['--long', 'long'],
|
|
38
41
|
// Native drivers only: register without the free automation probe first.
|
|
39
42
|
['--skip-preflight', 'skipPreflight'],
|
|
40
43
|
// Resume only: replay an interrupted run from the start instead of writing a
|
|
@@ -106,6 +109,7 @@ export function optionsFrom(flags = {}) {
|
|
|
106
109
|
return {
|
|
107
110
|
options: {
|
|
108
111
|
...options,
|
|
112
|
+
runLength: runLengthFor(options),
|
|
109
113
|
...stripUndefined({
|
|
110
114
|
repo: flags.repoRoot || flags.repo,
|
|
111
115
|
apiUrl: flags.apiUrl,
|
|
@@ -128,8 +132,21 @@ export function optionsFrom(flags = {}) {
|
|
|
128
132
|
};
|
|
129
133
|
}
|
|
130
134
|
|
|
135
|
+
/** The two run lengths the server prices: `standard`, and `long` at twice a standard run. */
|
|
136
|
+
export const RUN_LENGTHS = Object.freeze(['standard', 'long']);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* `long` when `--long` was passed, otherwise `standard`.
|
|
140
|
+
*
|
|
141
|
+
* @param {{long?: boolean}} options
|
|
142
|
+
* @returns {'standard'|'long'}
|
|
143
|
+
*/
|
|
144
|
+
export function runLengthFor(options = {}) {
|
|
145
|
+
return options.long === true ? 'long' : 'standard';
|
|
146
|
+
}
|
|
147
|
+
|
|
131
148
|
function stripUndefined(value) {
|
|
132
149
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
133
150
|
}
|
|
134
151
|
|
|
135
|
-
export default { parseRunArgv, optionsFrom, unknownOptionNotes };
|
|
152
|
+
export default { parseRunArgv, optionsFrom, unknownOptionNotes, runLengthFor };
|