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.
@@ -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. Spec 17 defaults to 2. */
48
- export const DEFAULT_PERSONAS = Object.freeze(['curious-kid', 'grumpy-veteran']);
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
- const modules = ['persona_playtest', 'aggregate_report'];
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 personas = personaSlugs.map(slug => {
140
- const found = (pack.personas || []).find(persona => persona.slug === slug);
141
- if (!found) throw new Error(`unknown persona "${slug}"; available: ${(pack.personas || []).map(entry => entry.slug).join(', ')}`);
142
- return found;
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
- const estimate = await api.jobs.estimate(gameId, { modules, personas: personaSlugs });
172
- log(`${personas.length} persona(s) on ${driverName}: ${formatCents(estimate.estimate_cents)}. Balance: ${formatCents(estimate.balance_cents)}.`);
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
- const available = (pack.personas || []).map(persona => persona.slug);
1276
- const defaults = DEFAULT_PERSONAS.filter(slug => available.includes(slug));
1277
- return defaults.length > 0 ? defaults : available.slice(0, 2);
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 routing.json allows
1283
- * per run (`max_actions`, which arrives in the pack's client routing subset).
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
- Number(persona && persona.patience_actions) || null,
1289
- Number(pack && pack.routing && pack.routing.max_actions) || null
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) : 25;
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 };