ravensight-playtest 0.3.1 → 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 +181 -20
- 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 +240 -21
- package/src/run/args.js +18 -1
- package/src/run/drivers/web.js +389 -79
- package/src/run/noise.js +102 -0
- package/src/run/personaLoop.js +308 -24
- package/src/run/report.js +187 -0
- package/src/run/resume.js +5 -0
- package/src/run/tools.js +1 -1
- 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,
|
|
@@ -1260,6 +1328,22 @@ export function capabilityNotesFor(pack, driverName) {
|
|
|
1260
1328
|
return notes;
|
|
1261
1329
|
}
|
|
1262
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
|
+
|
|
1263
1347
|
export function skillFor(pack, moduleName) {
|
|
1264
1348
|
const entry = (pack.skills || []).find(skill => skill.module === moduleName);
|
|
1265
1349
|
if (!entry) throw new Error(`the content pack has no ${moduleName} skill; run a pack sync`);
|
|
@@ -1272,23 +1356,158 @@ export function resolvePersonas(options, config, pack) {
|
|
|
1272
1356
|
return options.personas.split(',').map(slug => slug.trim()).filter(Boolean);
|
|
1273
1357
|
}
|
|
1274
1358
|
if (config && Array.isArray(config.personas) && config.personas.length > 0) return config.personas;
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
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;
|
|
1278
1398
|
}
|
|
1279
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.`;
|
|
1472
|
+
}
|
|
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
|
+
|
|
1280
1480
|
/**
|
|
1281
1481
|
* The action budget is the smallest of the three ceilings that exist: what the
|
|
1282
|
-
* developer asked for, the persona's own patience, and what
|
|
1283
|
-
*
|
|
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}
|
|
1284
1497
|
*/
|
|
1285
|
-
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;
|
|
1286
1505
|
const candidates = [
|
|
1287
|
-
Number.isInteger(options.maxActions) ? options.maxActions : null,
|
|
1288
|
-
|
|
1289
|
-
|
|
1506
|
+
Number.isInteger(options && options.maxActions) ? options.maxActions : null,
|
|
1507
|
+
patience === null ? null : patience * factor,
|
|
1508
|
+
fromServer || (routing === null ? null : routing * factor)
|
|
1290
1509
|
].filter(value => Number.isFinite(value) && value > 0);
|
|
1291
|
-
return candidates.length > 0 ? Math.min(...candidates) :
|
|
1510
|
+
return candidates.length > 0 ? Math.min(...candidates) : DEFAULT_MAX_ACTIONS * factor;
|
|
1292
1511
|
}
|
|
1293
1512
|
|
|
1294
1513
|
export function resolveTarget(options, config, driverName) {
|
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 };
|