ravensight-playtest 0.3.1 → 0.4.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 +200 -20
- package/docs/NATIVE_RUNNER.md +2 -2
- package/docs/SIMULATION.md +91 -0
- package/examples/simulation/economy.mjs +22 -0
- package/package.json +7 -3
- package/src/api/client.js +9 -3
- package/src/briefLocal.js +126 -0
- package/src/cli.js +32 -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/commands/simulate.js +298 -0
- 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/simulation/index.js +176 -0
- package/src/simulation/protocol.js +90 -0
- package/src/state/index.js +4 -1
- package/src/ui/index.js +15 -1
package/src/run/report.js
CHANGED
|
@@ -207,6 +207,191 @@ export function normaliseEvidence(report, runDirectory = null) {
|
|
|
207
207
|
return { report: { ...report, findings }, changed };
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/** Category words a model writes that are not in the schema, mapped to one that is, when the schema has it. */
|
|
211
|
+
export const CATEGORY_SYNONYMS = Object.freeze({
|
|
212
|
+
ui: 'ux', usability: 'ux', onboarding: 'ux', clarity: 'ux', visual: 'ux', visuals: 'ux', feedback: 'ux', navigation: 'ux', polish: 'ux', design: 'ux',
|
|
213
|
+
crash: 'bug', error: 'bug', defect: 'bug', broken: 'bug', functional: 'bug', functionality: 'bug', network: 'bug',
|
|
214
|
+
perf: 'performance', lag: 'performance', fps: 'performance', loading: 'performance',
|
|
215
|
+
a11y: 'accessibility',
|
|
216
|
+
level: 'level_design', map: 'level_design', 'level design': 'level_design', 'level-design': 'level_design',
|
|
217
|
+
text: 'content', story: 'content', narrative: 'content', writing: 'content', copy: 'content', audio: 'content', art: 'content',
|
|
218
|
+
economy: 'balance', difficulty: 'balance', progression: 'balance'
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
/** The severity an out of range one becomes. */
|
|
222
|
+
export const NORMALISED_SEVERITY = 'minor';
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The category an unknown one becomes: `other` when the schema has it, else a
|
|
226
|
+
* catch-all the schema names, else `ux`. The server's schema today has no
|
|
227
|
+
* catch-all (bug, ux, balance, content, performance, accessibility,
|
|
228
|
+
* level_design), so an unknown word that no synonym places becomes `ux`, the
|
|
229
|
+
* category most unplaced findings turn out to be.
|
|
230
|
+
*
|
|
231
|
+
* @param {Array<string>} categories the schema's enum
|
|
232
|
+
* @returns {string}
|
|
233
|
+
*/
|
|
234
|
+
export function catchAllCategory(categories) {
|
|
235
|
+
for (const candidate of ['other', 'misc', 'general', 'uncategorized']) if (categories.includes(candidate)) return candidate;
|
|
236
|
+
return categories.includes('ux') ? 'ux' : categories[0];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The enum lists and defaults of the report schema's root and of one finding,
|
|
241
|
+
* read from the schema itself so a pack update moves them.
|
|
242
|
+
*
|
|
243
|
+
* @param {Object} schema the playtest-report schema
|
|
244
|
+
* @returns {{severities: Array<string>, categories: Array<string>, root: Object, finding: Object}}
|
|
245
|
+
*/
|
|
246
|
+
export function enumFields(schema) {
|
|
247
|
+
const props = (schema && schema.properties) || {};
|
|
248
|
+
const item = (props.findings && props.findings.items && props.findings.items.properties) || {};
|
|
249
|
+
const enumsOf = properties => Object.fromEntries(Object.entries(properties)
|
|
250
|
+
.filter(([, spec]) => spec && Array.isArray(spec.enum))
|
|
251
|
+
.map(([key, spec]) => [key, { values: spec.enum, ...(spec.default !== undefined ? { default: spec.default } : {}) }]));
|
|
252
|
+
return {
|
|
253
|
+
severities: (item.severity && item.severity.enum) || [],
|
|
254
|
+
categories: (item.category && item.category.enum) || [],
|
|
255
|
+
root: enumsOf(props),
|
|
256
|
+
finding: enumsOf(item)
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Bring known out of range values back into the schema before upload,
|
|
262
|
+
* instead of losing the run to them. The server's schema stays strict; this
|
|
263
|
+
* only rewrites what the model's own words say plainly:
|
|
264
|
+
*
|
|
265
|
+
* - a value that differs from an allowed one only in case or spacing takes
|
|
266
|
+
* the allowed spelling (`Major` is `major`);
|
|
267
|
+
* - a severity that is a category word (`"severity": "ux"`) moves to
|
|
268
|
+
* `category` when the category is missing or not allowed, and the severity
|
|
269
|
+
* becomes `minor`; a severity that is anything else unknown becomes `minor`;
|
|
270
|
+
* - a category that is a severity word, with the severity out of range,
|
|
271
|
+
* swaps back; any other unknown category becomes a synonym the schema has,
|
|
272
|
+
* else `other` (or the catch-all, see {@link catchAllCategory});
|
|
273
|
+
* - any other enum field, at the root or on a finding, with a value the
|
|
274
|
+
* schema does not allow takes the schema's `default` when it has one.
|
|
275
|
+
*
|
|
276
|
+
* Every change is returned, and recorded on the finding as a runner note at
|
|
277
|
+
* the end of its description. The Texas round lost a run to `"severity": "ux"`
|
|
278
|
+
* when the model's budget ran out before its repair turn.
|
|
279
|
+
*
|
|
280
|
+
* @param {Object} report
|
|
281
|
+
* @param {Object} schema the playtest-report schema
|
|
282
|
+
* @returns {{report: Object, changes: Array<{id: string|null, field: string, from: any, to: any, note: string}>}}
|
|
283
|
+
*/
|
|
284
|
+
export function normaliseEnums(report, schema) {
|
|
285
|
+
if (!report || typeof report !== 'object' || !schema) return { report, changes: [] };
|
|
286
|
+
const fields = enumFields(schema);
|
|
287
|
+
const changes = [];
|
|
288
|
+
const spelled = (value, allowed) => {
|
|
289
|
+
if (typeof value !== 'string') return null;
|
|
290
|
+
const key = value.trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
291
|
+
return allowed.find(entry => String(entry).toLowerCase() === key) ?? null;
|
|
292
|
+
};
|
|
293
|
+
const fixEnum = (target, key, spec, id) => {
|
|
294
|
+
if (!(key in target) || spec.values.includes(target[key])) return;
|
|
295
|
+
const respelled = spelled(target[key], spec.values);
|
|
296
|
+
const to = respelled !== null ? respelled : spec.default;
|
|
297
|
+
if (to === undefined) return;
|
|
298
|
+
changes.push({ id, field: key, from: target[key], to, note: `${key} ${JSON.stringify(target[key])} is not allowed, so it became ${JSON.stringify(to)}` });
|
|
299
|
+
target[key] = to;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const root = { ...report };
|
|
303
|
+
for (const [key, spec] of Object.entries(fields.root)) fixEnum(root, key, spec, null);
|
|
304
|
+
|
|
305
|
+
const { severities, categories } = fields;
|
|
306
|
+
if (Array.isArray(root.findings)) {
|
|
307
|
+
root.findings = root.findings.map(original => {
|
|
308
|
+
if (!original || typeof original !== 'object' || Array.isArray(original)) return original;
|
|
309
|
+
const finding = { ...original };
|
|
310
|
+
const id = typeof finding.id === 'string' ? finding.id : null;
|
|
311
|
+
const notes = [];
|
|
312
|
+
const record = (field, from, to, note) => {
|
|
313
|
+
changes.push({ id, field, from, to, note });
|
|
314
|
+
notes.push(note);
|
|
315
|
+
};
|
|
316
|
+
if (severities.length > 0 && categories.length > 0) {
|
|
317
|
+
const severityOk = severities.includes(finding.severity);
|
|
318
|
+
const categoryOk = categories.includes(finding.category);
|
|
319
|
+
const severityRespelled = severityOk ? finding.severity : spelled(finding.severity, severities);
|
|
320
|
+
const categoryRespelled = categoryOk ? finding.category : spelled(finding.category, categories);
|
|
321
|
+
const severityAsCategory = severityRespelled === null ? spelled(finding.severity, categories) : null;
|
|
322
|
+
const categoryAsSeverity = categoryRespelled === null ? spelled(finding.category, severities) : null;
|
|
323
|
+
if (severityRespelled !== null && !severityOk) record('severity', finding.severity, severityRespelled, `severity ${JSON.stringify(finding.severity)} became ${JSON.stringify(severityRespelled)}`);
|
|
324
|
+
if (categoryRespelled !== null && !categoryOk) record('category', finding.category, categoryRespelled, `category ${JSON.stringify(finding.category)} became ${JSON.stringify(categoryRespelled)}`);
|
|
325
|
+
let severity = severityRespelled;
|
|
326
|
+
let category = categoryRespelled;
|
|
327
|
+
if (severity === null && category === null && severityAsCategory !== null && categoryAsSeverity !== null) {
|
|
328
|
+
// The two were written in each other's slot.
|
|
329
|
+
record('severity', finding.severity, categoryAsSeverity, `severity ${JSON.stringify(finding.severity)} and category ${JSON.stringify(finding.category)} were swapped, so they were put back`);
|
|
330
|
+
record('category', finding.category, severityAsCategory, `category ${JSON.stringify(finding.category)} is a severity word, so it moved to severity`);
|
|
331
|
+
severity = categoryAsSeverity;
|
|
332
|
+
category = severityAsCategory;
|
|
333
|
+
}
|
|
334
|
+
if (severity === null && severityAsCategory !== null && category === null) {
|
|
335
|
+
record('category', finding.category === undefined ? null : finding.category, severityAsCategory, `severity ${JSON.stringify(finding.severity)} is a category, so it moved to category`);
|
|
336
|
+
category = severityAsCategory;
|
|
337
|
+
}
|
|
338
|
+
if (severity === null) {
|
|
339
|
+
record('severity', finding.severity === undefined ? null : finding.severity, NORMALISED_SEVERITY, `severity ${JSON.stringify(finding.severity === undefined ? null : finding.severity)} is not a severity, so it became ${NORMALISED_SEVERITY}`);
|
|
340
|
+
severity = NORMALISED_SEVERITY;
|
|
341
|
+
}
|
|
342
|
+
if (category === null) {
|
|
343
|
+
const word = typeof finding.category === 'string' ? finding.category.trim().toLowerCase() : '';
|
|
344
|
+
const synonym = CATEGORY_SYNONYMS[word] && categories.includes(CATEGORY_SYNONYMS[word]) ? CATEGORY_SYNONYMS[word] : null;
|
|
345
|
+
const to = synonym || catchAllCategory(categories);
|
|
346
|
+
record('category', finding.category === undefined ? null : finding.category, to, `category ${JSON.stringify(finding.category === undefined ? null : finding.category)} is not a category, so it became ${to}`);
|
|
347
|
+
category = to;
|
|
348
|
+
}
|
|
349
|
+
finding.severity = severity;
|
|
350
|
+
finding.category = category;
|
|
351
|
+
}
|
|
352
|
+
for (const [key, spec] of Object.entries(fields.finding)) {
|
|
353
|
+
if (key === 'severity' || key === 'category') continue;
|
|
354
|
+
const before = changes.length;
|
|
355
|
+
fixEnum(finding, key, spec, id);
|
|
356
|
+
if (changes.length > before) notes.push(changes[changes.length - 1].note);
|
|
357
|
+
}
|
|
358
|
+
if (notes.length > 0) {
|
|
359
|
+
const line = `Runner note: ${notes.join('; ')}.`;
|
|
360
|
+
const description = typeof finding.description === 'string' ? finding.description : '';
|
|
361
|
+
finding.description = description ? `${description}\n\n${line}` : line;
|
|
362
|
+
}
|
|
363
|
+
return finding;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return { report: changes.length > 0 ? root : report, changes };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** How report.md lists what the runner normalised. */
|
|
370
|
+
export const NORMALISED_HEADING = '## Normalised by the runner (values the report schema does not allow)';
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* report.md agreeing with report.json about normalised values: each change is
|
|
374
|
+
* listed under {@link NORMALISED_HEADING}, replacing a list written before.
|
|
375
|
+
*
|
|
376
|
+
* @param {string} markdown
|
|
377
|
+
* @param {Array<{id: string|null, note: string}>} changes
|
|
378
|
+
* @returns {string}
|
|
379
|
+
*/
|
|
380
|
+
export function appendNormalisedMarkdown(markdown, changes) {
|
|
381
|
+
if (!Array.isArray(changes) || changes.length === 0) return markdown;
|
|
382
|
+
let body = String(markdown || '');
|
|
383
|
+
const at = body.indexOf(NORMALISED_HEADING);
|
|
384
|
+
if (at !== -1) {
|
|
385
|
+
// A list written before is replaced: from its heading to the next
|
|
386
|
+
// second level heading, or the end.
|
|
387
|
+
const next = body.indexOf('\n## ', at + NORMALISED_HEADING.length);
|
|
388
|
+
body = body.slice(0, at) + (next === -1 ? '' : body.slice(next + 1));
|
|
389
|
+
}
|
|
390
|
+
body = body.replace(/\s+$/, '');
|
|
391
|
+
const lines = changes.map(change => `- ${change.id ? `${change.id}: ` : ''}${change.note}.`);
|
|
392
|
+
return `${body}\n\n${NORMALISED_HEADING}\n${lines.join('\n')}\n`;
|
|
393
|
+
}
|
|
394
|
+
|
|
210
395
|
/**
|
|
211
396
|
* Check every `screenshot` evidence ref against the files actually on disk.
|
|
212
397
|
*
|
|
@@ -301,6 +486,8 @@ export function repairInstruction(details, evidenceProblems, missingSections = [
|
|
|
301
486
|
|
|
302
487
|
export default {
|
|
303
488
|
createReportValidator,
|
|
489
|
+
normaliseEnums,
|
|
490
|
+
appendNormalisedMarkdown,
|
|
304
491
|
stampReport,
|
|
305
492
|
checkEvidence,
|
|
306
493
|
normaliseEvidence,
|
package/src/run/resume.js
CHANGED
|
@@ -149,6 +149,8 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
149
149
|
build: { kind: driverName === 'playwright_web' ? 'url' : 'godot_project', ref: target },
|
|
150
150
|
repo: { commit: '', dirty: false },
|
|
151
151
|
max_actions: (journal.options && journal.options.maxActions) ?? null,
|
|
152
|
+
// The same body as the first register, so a long job replays as long.
|
|
153
|
+
...(journal.options?.runLength === 'long' ? { run_length: 'long' } : {}),
|
|
152
154
|
pack_versions: { pack: pack.pack_version }
|
|
153
155
|
}, { idempotencyKey: journal.idempotency_key });
|
|
154
156
|
|
|
@@ -165,6 +167,9 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
|
|
|
165
167
|
...options,
|
|
166
168
|
controller: journal.options?.controller || options.controller || 'standard',
|
|
167
169
|
maxActions: (journal.options && journal.options.maxActions) ?? options.maxActions,
|
|
170
|
+
// The journal's answer, never the flag: a job is long or standard for good.
|
|
171
|
+
runLength: journal.options?.runLength === 'long' ? 'long' : 'standard',
|
|
172
|
+
...(Number.isInteger(journal.options?.longRunFactor) ? { longRunFactor: journal.options.longRunFactor } : {}),
|
|
168
173
|
concurrency: options.concurrency ?? (journal.options && journal.options.concurrency),
|
|
169
174
|
uploadVideo: options.uploadVideo ?? (journal.options && journal.options.uploadVideo),
|
|
170
175
|
uploadTranscript: options.uploadTranscript ?? (journal.options && journal.options.uploadTranscript)
|
package/src/run/tools.js
CHANGED
|
@@ -83,7 +83,7 @@ export const PLAY_TOOLS = Object.freeze([
|
|
|
83
83
|
additionalProperties: false,
|
|
84
84
|
properties: {
|
|
85
85
|
ref: { type: 'string', description: 'playwright_web only, the preferred target: the ref shown on the element, such as e12.' },
|
|
86
|
-
target: { type: 'string', description: 'web: button "Start" or Start Game. Godot: a node path such as /root/Main/UI/StartButton.' },
|
|
86
|
+
target: { type: 'string', description: 'web: button "Start" or Start Game; a whole line copied from the screen also works, and is clicked by its ref, else its @(x,y). Text that is not on the screen is refused at once. Godot: a node path such as /root/Main/UI/StartButton.' },
|
|
87
87
|
x: { type: 'number' },
|
|
88
88
|
y: { type: 'number' },
|
|
89
89
|
thought_in_character: thought,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a plain `run` asks for, in one place, so `brief`, `check` and `run`
|
|
3
|
+
* quote the same job. Kept free of runner imports: `check` and `brief` load
|
|
4
|
+
* this without pulling in the drivers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Personas used when the developer names none. One since 0.3.2: a first run
|
|
9
|
+
* should cost one persona run, and a second persona is one `--personas` away.
|
|
10
|
+
* Spec 17 defaulted to two, which made the default run cost twice what most
|
|
11
|
+
* customers expected to pay for a first look.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_PERSONAS = Object.freeze(['curious-kid']);
|
|
14
|
+
|
|
15
|
+
/** The modules `run` registers. `aggregate_report` is free and buys the aggregate's steps. */
|
|
16
|
+
export const RUN_MODULES = Object.freeze(['persona_playtest', 'aggregate_report']);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Actions a sign up form alone took in customer runs (an email, a password, a
|
|
20
|
+
* confirm, a name, the submit, and the clicks between them), which is why a
|
|
21
|
+
* brief's test account matters on a short action budget.
|
|
22
|
+
*/
|
|
23
|
+
export const SIGN_UP_ACTIONS = 8;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The default persona set as far as this pack has them: the defaults it
|
|
27
|
+
* carries, else its first persona.
|
|
28
|
+
*
|
|
29
|
+
* @param {Array<{slug: string}>} [available]
|
|
30
|
+
* @returns {Array<string>}
|
|
31
|
+
*/
|
|
32
|
+
export function defaultPersonasFor(available = null) {
|
|
33
|
+
if (!Array.isArray(available)) return [...DEFAULT_PERSONAS];
|
|
34
|
+
const slugs = available.map(persona => persona && persona.slug).filter(Boolean);
|
|
35
|
+
const defaults = DEFAULT_PERSONAS.filter(slug => slugs.includes(slug));
|
|
36
|
+
return defaults.length > 0 ? defaults : slugs.slice(0, 1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** "1 persona", "3 personas". */
|
|
40
|
+
export function personaCount(count) {
|
|
41
|
+
return `${count} persona${count === 1 ? '' : 's'}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* An estimate's breakdown in words, every unit named with its own price:
|
|
46
|
+
* "profile job $4.00, a persona run $1.00 each (x2)".
|
|
47
|
+
*
|
|
48
|
+
* @param {Array<{unit: string, count: number, unit_cents: number}>} breakdown
|
|
49
|
+
* @param {(cents: number) => string} money
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
export function describeBreakdown(breakdown, money) {
|
|
53
|
+
const parts = [];
|
|
54
|
+
for (const line of Array.isArray(breakdown) ? breakdown : []) {
|
|
55
|
+
if (!line || !(Number(line.count) > 0)) continue;
|
|
56
|
+
const price = money(line.unit_cents);
|
|
57
|
+
const times = Number(line.count) > 1 ? ` (x${line.count})` : '';
|
|
58
|
+
if (line.unit === 'playtest_run') parts.push(`a persona run ${price} each${times}`);
|
|
59
|
+
else if (line.unit === 'playtest_run_long') parts.push(`a long persona run ${price} each${times}`);
|
|
60
|
+
else if (line.unit === 'playtest_profile') parts.push(`profile job ${price}${times}`);
|
|
61
|
+
else if (line.unit === 'playtest_fix') parts.push(`a fix attempt ${price} each${times}`);
|
|
62
|
+
else parts.push(`${line.unit} ${price}${times}`);
|
|
63
|
+
}
|
|
64
|
+
return parts.join(', ');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The persona slugs in a list that the pack does not have.
|
|
69
|
+
*
|
|
70
|
+
* @param {Array<string>} slugs
|
|
71
|
+
* @param {Array<{slug: string}>} available
|
|
72
|
+
* @returns {Array<string>}
|
|
73
|
+
*/
|
|
74
|
+
export function unknownPersonas(slugs, available) {
|
|
75
|
+
const known = new Set((available || []).map(persona => persona && persona.slug));
|
|
76
|
+
return (slugs || []).filter(slug => !known.has(slug));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* "unknown persona "x"; the valid slugs are a, b and c. "ravensight-playtest personas" lists them."
|
|
81
|
+
*
|
|
82
|
+
* @param {Array<string>} unknown
|
|
83
|
+
* @param {Array<{slug: string}>} available
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
export function unknownPersonaMessage(unknown, available) {
|
|
87
|
+
const slugs = (available || []).map(persona => persona.slug);
|
|
88
|
+
const listed = slugs.length > 1 ? `${slugs.slice(0, -1).join(', ')} and ${slugs[slugs.length - 1]}` : slugs.join('');
|
|
89
|
+
return `unknown persona${unknown.length === 1 ? '' : 's'} ${unknown.map(slug => JSON.stringify(slug)).join(', ')}; the valid slugs are ${listed || '(none in this pack)'}. "ravensight-playtest personas" lists them.`;
|
|
90
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { boundedString, bridgeOptions, observation, openBridge } from './protocol.js';
|
|
5
|
+
const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
6
|
+
const MAX_TRACE_BYTES = 4 * 1024 * 1024;
|
|
7
|
+
const MAX_ACTIONS = 1000000;
|
|
8
|
+
const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
9
|
+
export function validateConfig(input) {
|
|
10
|
+
if (!input || input.version !== 1 || !boundedString(input.seed, 128)) throw new Error('Simulation config needs version 1 and a seed of 1 to 128 characters.');
|
|
11
|
+
for (const [key, max] of [['runs', 10000], ['max_steps', 10000], ['workers', 16]]) if (!Number.isInteger(input[key]) || input[key] < 1 || input[key] > max) throw new Error(`Invalid simulation ${key}.`);
|
|
12
|
+
if (!Array.isArray(input.strategies) || !input.strategies.length || input.strategies.length > 16) throw new Error('Choose 1 to 16 simulation strategies.');
|
|
13
|
+
const strategies = input.strategies.map(strategy => {
|
|
14
|
+
if (!strategy || typeof strategy.name !== 'string' || !/^[a-z][a-z0-9_-]{0,63}$/.test(strategy.name) || !['random', 'scripted', 'scored'].includes(strategy.policy)) throw new Error('Invalid simulation strategy.');
|
|
15
|
+
if (strategy.policy === 'scripted' && (!Array.isArray(strategy.actions) || !strategy.actions.length || strategy.actions.length > 10000 || strategy.actions.some(id => !boundedString(id, 128)))) throw new Error('Scripted strategy needs 1 to 10000 action IDs.');
|
|
16
|
+
return { name: strategy.name, policy: strategy.policy, ...(strategy.policy === 'scripted' ? { actions: [...strategy.actions] } : {}) };
|
|
17
|
+
});
|
|
18
|
+
if (new Set(strategies.map(s => s.name)).size !== strategies.length) throw new Error('Strategy names must be unique.');
|
|
19
|
+
const config = { version: 1, seed: input.seed, runs: input.runs, max_steps: input.max_steps, workers: input.workers, strategies };
|
|
20
|
+
if (Buffer.byteLength(JSON.stringify(config)) > 2 * 1024 * 1024) throw new Error('Simulation configuration exceeds 2 MiB.');
|
|
21
|
+
return config;
|
|
22
|
+
}
|
|
23
|
+
export function configHash(config) { return hash({ version: 1, seed: config.seed, runs: config.runs, max_steps: config.max_steps, workers: config.workers, strategies: config.strategies.map(({ name, policy }) => ({ name, policy })) }); }
|
|
24
|
+
export function deriveSeed(seed, index) { return hash([seed, index]); }
|
|
25
|
+
function choose(state, strategy, index, seed) {
|
|
26
|
+
if (strategy.policy === 'scripted') {
|
|
27
|
+
const id = strategy.actions[index];
|
|
28
|
+
if (!id) throw new Error('Scripted strategy exhausted before terminal state.');
|
|
29
|
+
if (!state.actions.some(action => action.id === id)) throw new Error('Scripted strategy selected an illegal action.');
|
|
30
|
+
return id;
|
|
31
|
+
}
|
|
32
|
+
if (strategy.policy === 'scored') {
|
|
33
|
+
const choices = state.actions.filter(action => Number.isFinite(action.scores?.[strategy.name]));
|
|
34
|
+
if (!choices.length) throw new Error('Game supplied no scores for this strategy.');
|
|
35
|
+
return choices.sort((a, b) => b.scores[strategy.name] - a.scores[strategy.name] || compareText(a.id, b.id))[0].id;
|
|
36
|
+
}
|
|
37
|
+
const choices = [...state.actions].sort((a, b) => compareText(a.id, b.id));
|
|
38
|
+
return choices[parseInt(hash([seed, index]).slice(0, 12), 16) % choices.length].id;
|
|
39
|
+
}
|
|
40
|
+
function stateRecord(state) { return { done: state.done, state_hash: state.state_hash, ...(state.outcome === undefined ? {} : { outcome: state.outcome }), ...(state.metrics === undefined ? {} : { metrics: state.metrics }) }; }
|
|
41
|
+
async function writeJson(file, value) { await fs.writeFile(file, JSON.stringify(value) + '\n', { flag: 'wx', mode: 0o600 }); }
|
|
42
|
+
export async function probeBridge({ bridge, seed = 'preflight', strategy = 'random', max_steps = 1, signal }) {
|
|
43
|
+
if (!boundedString(seed, 128) || !boundedString(strategy, 80) || !Number.isInteger(max_steps) || max_steps < 1 || max_steps > 10000) throw new Error('Invalid simulation probe.');
|
|
44
|
+
const client = openBridge(bridge, { signal });
|
|
45
|
+
try { return await client.request('simulation.start', { version: 1, seed, strategy, max_steps }); }
|
|
46
|
+
finally { await client.stop(); }
|
|
47
|
+
}
|
|
48
|
+
export async function runBatch({ config: rawConfig, bridge: rawBridge, outputDir, playtest, signal, onProgress }) {
|
|
49
|
+
const config = validateConfig(rawConfig); const bridge = bridgeOptions(rawBridge);
|
|
50
|
+
if (typeof outputDir !== 'string' || !outputDir) throw new Error('Simulation output directory is required.');
|
|
51
|
+
outputDir = path.resolve(outputDir); await fs.mkdir(outputDir, { recursive: true });
|
|
52
|
+
const resultsPath = path.join(outputDir, 'results.jsonl'); const file = await fs.open(resultsPath, 'wx', 0o600);
|
|
53
|
+
const started = Date.now(); const controller = new AbortController(); let stopReason = null;
|
|
54
|
+
const cancel = () => { stopReason ||= 'cancelled'; controller.abort(); };
|
|
55
|
+
signal?.addEventListener('abort', cancel, { once: true }); if (signal?.aborted) cancel();
|
|
56
|
+
const timer = setTimeout(() => { stopReason ||= 'batch_deadline'; controller.abort(); }, bridge.batchTimeoutMs);
|
|
57
|
+
let next = 0, reservedSteps = 0, writeChain = Promise.resolve(), commitTail = Promise.resolve();
|
|
58
|
+
const traces = [];
|
|
59
|
+
const summary = { version: 1, seed: config.seed, config_hash: configHash(config), requested_runs: config.runs, completed_runs: 0, failed_runs: 0, truncated_runs: 0, total_steps: 0, strategies: config.strategies.map(s => ({ name: s.name, runs: 0, completed: 0, failed: 0, truncated: 0, outcomes: {}, metrics: {} })), trace_count: 0, duration_ms: 0 };
|
|
60
|
+
async function execute(index, priorCommit) {
|
|
61
|
+
const strategy = config.strategies[index % config.strategies.length]; const seed = deriveSeed(config.seed, index);
|
|
62
|
+
const row = { version: 1, run_index: index, seed, strategy: strategy.name, status: 'failed', steps: 0 };
|
|
63
|
+
const trace = index < 10 ? { version: 1, run_index: index, seed, strategy: strategy.name, max_steps: config.max_steps, local_config_hash: hash(config), states: [], actions: [] } : null;
|
|
64
|
+
let traceBytes = 1024, client, pendingAction, phase = 'start';
|
|
65
|
+
const record = state => {
|
|
66
|
+
if (!trace) return;
|
|
67
|
+
const item = stateRecord(state); traceBytes += Buffer.byteLength(JSON.stringify(item)) + 1;
|
|
68
|
+
if (traceBytes > MAX_TRACE_BYTES) { trace.truncated = true; return; }
|
|
69
|
+
trace.states.push(item);
|
|
70
|
+
};
|
|
71
|
+
try {
|
|
72
|
+
if (controller.signal.aborted) throw new Error('Simulation stopped before run started.');
|
|
73
|
+
client = openBridge(bridge, { signal: controller.signal, playtest });
|
|
74
|
+
let state = await client.request('simulation.start', { version: 1, seed, strategy: strategy.name, max_steps: config.max_steps }); record(state);
|
|
75
|
+
while (!state.done && row.steps < config.max_steps) {
|
|
76
|
+
if (reservedSteps >= MAX_ACTIONS) { stopReason ||= 'action_budget'; controller.abort(); throw new Error('Batch action budget exhausted.'); }
|
|
77
|
+
phase = 'policy';
|
|
78
|
+
const action = choose(state, strategy, row.steps, seed); reservedSteps++;
|
|
79
|
+
pendingAction = action; phase = 'step';
|
|
80
|
+
state = await client.request('simulation.step', { action }); pendingAction = undefined; row.steps++; summary.total_steps++;
|
|
81
|
+
if (trace && !trace.truncated) { trace.actions.push(action); traceBytes += Buffer.byteLength(JSON.stringify(action)) + 1; }
|
|
82
|
+
record(state);
|
|
83
|
+
}
|
|
84
|
+
row.status = state.done ? 'completed' : 'truncated';
|
|
85
|
+
if (state.done) { row.outcome = state.outcome || 'unspecified'; row.metrics = state.metrics || {}; }
|
|
86
|
+
} catch (error) { row.error = error.message.slice(0, 200); if (trace) { trace.failure_phase = phase; if (pendingAction) trace.failed_action = pendingAction; } }
|
|
87
|
+
finally { if (client) await client.stop(); }
|
|
88
|
+
// Commit in run-index order, retaining at most one result per worker. This
|
|
89
|
+
// makes floating-point aggregate order independent of process scheduling.
|
|
90
|
+
await priorCommit;
|
|
91
|
+
const aggregate = summary.strategies[index % config.strategies.length]; aggregate.runs++;
|
|
92
|
+
summary[`${row.status}_runs`]++; aggregate[row.status]++;
|
|
93
|
+
if (row.status === 'completed') {
|
|
94
|
+
// Bound aggregate cardinality even if a malicious adapter invents a new
|
|
95
|
+
// metric/outcome name in every run. Raw per-run values remain on disk.
|
|
96
|
+
const outcome = Object.hasOwn(aggregate.outcomes, row.outcome) || Object.keys(aggregate.outcomes).length < 127 ? row.outcome : 'other';
|
|
97
|
+
aggregate.outcomes[outcome] = (Object.hasOwn(aggregate.outcomes, outcome) ? aggregate.outcomes[outcome] : 0) + 1;
|
|
98
|
+
for (const [key, value] of Object.entries(row.metrics)) {
|
|
99
|
+
if (!Object.hasOwn(aggregate.metrics, key) && Object.keys(aggregate.metrics).length >= 32) continue;
|
|
100
|
+
if (!Object.hasOwn(aggregate.metrics, key)) aggregate.metrics[key] = { count: 0, min: value, max: value, mean: 0 };
|
|
101
|
+
const metric = aggregate.metrics[key];
|
|
102
|
+
metric.count++; metric.min = Math.min(metric.min, value); metric.max = Math.max(metric.max, value);
|
|
103
|
+
// Weighted arithmetic avoids overflow from subtracting opposite extremes.
|
|
104
|
+
metric.mean = Math.max(metric.min, Math.min(metric.max, metric.mean * ((metric.count - 1) / metric.count) + value / metric.count));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (trace) { if (trace.truncated) trace.actions = trace.actions.slice(0, Math.max(0, trace.states.length - 1)); trace.status = row.status; trace.error = row.error; const target = path.join(outputDir, `trace-${String(index).padStart(5, '0')}.json`); await writeJson(target, trace); traces.push(target); }
|
|
108
|
+
writeChain = writeChain.then(() => file.write(JSON.stringify(row) + '\n')); await writeChain;
|
|
109
|
+
if (onProgress) await onProgress({ finished_runs: summary.completed_runs + summary.failed_runs + summary.truncated_runs, requested_runs: config.runs, completed_runs: summary.completed_runs, failed_runs: summary.failed_runs, truncated_runs: summary.truncated_runs, total_steps: summary.total_steps });
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const workers = Array.from({ length: Math.min(config.workers, config.runs) }, async () => {
|
|
113
|
+
try {
|
|
114
|
+
while (next < config.runs) {
|
|
115
|
+
const index = next++; const prior = commitTail; let release;
|
|
116
|
+
commitTail = new Promise(resolve => { release = resolve; });
|
|
117
|
+
try { await execute(index, prior); } finally { release(); }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (error) { controller.abort(); throw error; }
|
|
121
|
+
});
|
|
122
|
+
const outcomes = await Promise.allSettled(workers);
|
|
123
|
+
const failure = outcomes.find(item => item.status === 'rejected');
|
|
124
|
+
if (failure) throw failure.reason;
|
|
125
|
+
}
|
|
126
|
+
finally { clearTimeout(timer); signal?.removeEventListener('abort', cancel); controller.abort(); try { await writeChain; } finally { await file.close(); } }
|
|
127
|
+
summary.trace_count = traces.length; summary.duration_ms = Math.min(bridge.batchTimeoutMs, Date.now() - started);
|
|
128
|
+
traces.sort();
|
|
129
|
+
const report = { results_path: resultsPath, stop_reason: stopReason, determinism_note: 'Replay verifies recorded observations. Reproducibility requires the game to honor its seed; this runner cannot prove determinism.' };
|
|
130
|
+
await writeJson(path.join(outputDir, 'summary.json'), summary);
|
|
131
|
+
return { summary, traces, report };
|
|
132
|
+
}
|
|
133
|
+
export async function replayTrace({ trace, bridge, signal, outputDir }) {
|
|
134
|
+
if (typeof trace === 'string') { if ((await fs.stat(trace)).size > MAX_TRACE_BYTES + 1024) throw new Error('Trace exceeds replay limit.'); trace = JSON.parse(await fs.readFile(trace, 'utf8')); }
|
|
135
|
+
const failedStart = trace?.status === 'failed' && trace?.failure_phase === 'start' && trace?.states?.length === 0;
|
|
136
|
+
if (!trace || trace.version !== 1 || !boundedString(trace.seed, 128) || !boundedString(trace.strategy, 80) || !Number.isInteger(trace.max_steps) || trace.max_steps < 1 || trace.max_steps > 10000 || !Array.isArray(trace.actions) || trace.actions.length > 10000 || trace.actions.some(action => !boundedString(action, 128)) || !Array.isArray(trace.states) || (!failedStart && trace.states.length !== trace.actions.length + 1) || (failedStart && trace.actions.length) || (trace.failed_action !== undefined && !boundedString(trace.failed_action, 128))) throw new Error('Trace is invalid for replay.');
|
|
137
|
+
if (Buffer.byteLength(JSON.stringify(trace)) > MAX_TRACE_BYTES + 1024) throw new Error('Trace exceeds replay limit.');
|
|
138
|
+
for (const state of trace.states) observation({ ...state, actions: state.done ? [] : [{ id: 'replay-validation' }] });
|
|
139
|
+
const options = bridgeOptions(bridge);
|
|
140
|
+
const deadline = AbortSignal.timeout(options.batchTimeoutMs);
|
|
141
|
+
const replaySignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
142
|
+
const client = openBridge(options, { signal: replaySignal });
|
|
143
|
+
let result = { matched: true, matched_prefix: false, fully_replayed: false, failure_reproduced: null, first_divergence: null };
|
|
144
|
+
const start = () => client.request('simulation.start', { version: 1, seed: trace.seed, strategy: trace.strategy, max_steps: trace.max_steps });
|
|
145
|
+
async function reproduce(request, step) {
|
|
146
|
+
try {
|
|
147
|
+
const actual = await request(); result.matched = false; result.failure_reproduced = false;
|
|
148
|
+
result.first_divergence = { step, reason: 'Recorded protocol failure did not recur.', actual: stateRecord(actual) };
|
|
149
|
+
} catch (error) {
|
|
150
|
+
result.failure_reproduced = error.message === trace.error;
|
|
151
|
+
result.matched = result.failure_reproduced;
|
|
152
|
+
if (!result.matched) result.first_divergence = { step, expected_error: trace.error, actual_error: error.message };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
if (failedStart) await reproduce(start, 0);
|
|
157
|
+
else {
|
|
158
|
+
for (let i = 0; i < trace.states.length; i++) {
|
|
159
|
+
let state;
|
|
160
|
+
try { state = i === 0 ? await start() : await client.request('simulation.step', { action: trace.actions[i - 1] }); }
|
|
161
|
+
catch (error) { result.matched = false; result.first_divergence = { step: i, actual_error: error.message }; break; }
|
|
162
|
+
const expected = trace.states[i]; const actual = stateRecord(state);
|
|
163
|
+
const canonical = value => JSON.stringify([value.state_hash, value.done, value.outcome ?? null, Object.entries(value.metrics || {}).sort(([a], [b]) => compareText(a, b))]);
|
|
164
|
+
if (canonical(expected) !== canonical(actual)) { result.matched = false; result.first_divergence = { step: i, expected, actual }; break; }
|
|
165
|
+
if (i < trace.actions.length && !state.actions.some(action => action.id === trace.actions[i])) { result.matched = false; result.first_divergence = { step: i, reason: 'Recorded action is no longer legal.' }; break; }
|
|
166
|
+
}
|
|
167
|
+
if (result.matched) {
|
|
168
|
+
result.matched_prefix = true;
|
|
169
|
+
result.fully_replayed = !trace.truncated && trace.status !== 'failed';
|
|
170
|
+
if (trace.status === 'failed' && trace.failed_action && !trace.truncated) await reproduce(() => client.request('simulation.step', { action: trace.failed_action }), trace.states.length);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} finally { await client.stop(); }
|
|
174
|
+
if (outputDir) { await fs.mkdir(outputDir, { recursive: true }); const file = path.resolve(outputDir, 'replay.json'); await writeJson(file, result); result.report_path = file; }
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const forbidden = new Set(['__proto__', 'constructor', 'prototype']);
|
|
4
|
+
export const MAX_RESPONSE = 256 * 1024;
|
|
5
|
+
export function boundedString(value, max) { return typeof value === 'string' && value.length > 0 && value.length <= max && !forbidden.has(value); }
|
|
6
|
+
export function observation(value) {
|
|
7
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || typeof value.done !== 'boolean' || !boundedString(value.state_hash, 128) || !Array.isArray(value.actions) || value.actions.length > 256) throw new Error('Invalid simulation observation.');
|
|
8
|
+
const numbers = (map, limit, maxAbsolute = Infinity) => {
|
|
9
|
+
if (!map || typeof map !== 'object' || Array.isArray(map) || Object.keys(map).length > limit || Object.entries(map).some(([key, n]) => !boundedString(key, 80) || typeof n !== 'number' || (!Number.isFinite(n) || Math.abs(n) > maxAbsolute))) throw new Error('Invalid simulation numeric map.');
|
|
10
|
+
return { ...map };
|
|
11
|
+
};
|
|
12
|
+
const actions = value.actions.map(action => {
|
|
13
|
+
if (!action || !boundedString(action.id, 128)) throw new Error('Invalid simulation action.');
|
|
14
|
+
return { id: action.id, ...(action.scores === undefined ? {} : { scores: numbers(action.scores, 16) }) };
|
|
15
|
+
});
|
|
16
|
+
if (new Set(actions.map(action => action.id)).size !== actions.length) throw new Error('Duplicate simulation action.');
|
|
17
|
+
if (!value.done && !actions.length) throw new Error('Nonterminal simulation needs legal actions.');
|
|
18
|
+
if (value.outcome !== undefined && !boundedString(value.outcome, 80)) throw new Error('Invalid simulation outcome.');
|
|
19
|
+
return { done: value.done, state_hash: value.state_hash, actions, ...(value.metrics === undefined ? {} : { metrics: numbers(value.metrics, 32, 1e15) }), ...(value.outcome === undefined ? {} : { outcome: value.outcome }) };
|
|
20
|
+
}
|
|
21
|
+
export function bridgeOptions(bridge) {
|
|
22
|
+
// Native Windows has no POSIX process-group kill. Do not buy a batch whose
|
|
23
|
+
// adapter descendants we cannot reliably stop; WSL uses the Linux path.
|
|
24
|
+
if (process.platform === 'win32') throw new Error('Simulation Runner is not supported on native Windows. Run it in WSL, Linux or macOS so cancellation can stop the adapter process group.');
|
|
25
|
+
if (!bridge || !path.isAbsolute(bridge.command || '') || (bridge.args !== undefined && (!Array.isArray(bridge.args) || bridge.args.length > 256 || bridge.args.some(arg => typeof arg !== 'string' || arg.length > 8192))) || (bridge.cwd !== undefined && !path.isAbsolute(bridge.cwd))) throw new Error('Bridge needs an absolute command, string arguments and optional absolute cwd.');
|
|
26
|
+
const timeoutMs = bridge.timeoutMs ?? 30000; const batchTimeoutMs = bridge.batchTimeoutMs ?? 900000;
|
|
27
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30000 || !Number.isInteger(batchTimeoutMs) || batchTimeoutMs < 1 || batchTimeoutMs > 900000) throw new Error('Invalid simulation deadline.');
|
|
28
|
+
if (bridge.env !== undefined && (!bridge.env || typeof bridge.env !== 'object' || Array.isArray(bridge.env) || Object.entries(bridge.env).some(([k, v]) => !boundedString(k, 128) || typeof v !== 'string' || v.length > 8192))) throw new Error('Invalid bridge environment.');
|
|
29
|
+
return { ...bridge, timeoutMs, batchTimeoutMs };
|
|
30
|
+
}
|
|
31
|
+
export function childEnvironment(extra = {}, playtest = {}) {
|
|
32
|
+
// Local adapters are trusted code, not a security sandbox. Avoid accidentally
|
|
33
|
+
// forwarding account/provider credentials or inherited synthetic identities.
|
|
34
|
+
const env = {};
|
|
35
|
+
for (const [key, value] of Object.entries({ ...process.env, ...extra })) {
|
|
36
|
+
if (/TOKEN|SECRET|PASSWORD|CREDENTIAL|API.?KEY|PRIVATE.?KEY|AUTH|^AWS_|^AZURE_|^GOOGLE_|^ANTHROPIC_|^OPENAI_|^NODE_OPTIONS$|^RAVENSIGHT_.*(?:RUN|JOB)/i.test(key)) continue;
|
|
37
|
+
env[key] = value;
|
|
38
|
+
}
|
|
39
|
+
env.RAVENSIGHT_PLAYTEST_TOKEN = typeof playtest.token === 'string' && playtest.token ? playtest.token : 'invalid-simulation-preflight-token';
|
|
40
|
+
env.RAVENSIGHT_PLAYTEST_RUN_ID = String(playtest.runId || 'local-simulation');
|
|
41
|
+
env.RAVENSIGHT_PLAYTEST_JOB_ID = String(playtest.jobId || 'local-simulation');
|
|
42
|
+
return env;
|
|
43
|
+
}
|
|
44
|
+
export function openBridge(raw, { signal, playtest } = {}) {
|
|
45
|
+
const bridge = bridgeOptions(raw);
|
|
46
|
+
const child = spawn(bridge.command, bridge.args || [], { cwd: bridge.cwd, shell: false, detached: process.platform !== 'win32', env: childEnvironment(bridge.env, playtest), stdio: ['pipe', 'pipe', 'pipe'] });
|
|
47
|
+
let buffer = Buffer.alloc(0), id = 0, pending, failure, closing;
|
|
48
|
+
const kill = sig => { try { if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, sig); else child.kill(sig); } catch {} };
|
|
49
|
+
const fail = message => { failure ||= new Error(message); if (pending) { clearTimeout(pending.timer); pending.reject(failure); pending = null; } };
|
|
50
|
+
const stop = () => {
|
|
51
|
+
if (closing) return closing;
|
|
52
|
+
fail('Simulation bridge stopped.'); signal?.removeEventListener('abort', abort);
|
|
53
|
+
kill('SIGTERM');
|
|
54
|
+
// Always escalate against the process group, including when child.killed is
|
|
55
|
+
// true or the leader exited while a descendant still owns its pipes.
|
|
56
|
+
closing = new Promise(resolve => setTimeout(() => { kill('SIGKILL'); child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); resolve(); }, 100));
|
|
57
|
+
return closing;
|
|
58
|
+
};
|
|
59
|
+
const abort = () => { fail('Simulation cancelled.'); void stop(); };
|
|
60
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
61
|
+
if (signal?.aborted) abort();
|
|
62
|
+
child.stderr.resume();
|
|
63
|
+
child.on('error', () => { fail('Simulation bridge could not start.'); void stop(); });
|
|
64
|
+
child.on('exit', () => { fail('Simulation bridge exited.'); });
|
|
65
|
+
child.stdin.on('error', () => { fail('Simulation bridge input failed.'); void stop(); });
|
|
66
|
+
child.stdout.on('data', chunk => {
|
|
67
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
68
|
+
if (buffer.length > MAX_RESPONSE) { fail('Simulation response exceeds 256 KB.'); void stop(); return; }
|
|
69
|
+
let end;
|
|
70
|
+
while ((end = buffer.indexOf(10)) >= 0) {
|
|
71
|
+
const line = buffer.subarray(0, end); buffer = buffer.subarray(end + 1);
|
|
72
|
+
try {
|
|
73
|
+
const message = JSON.parse(line.toString('utf8'), (key, value) => { if (forbidden.has(key)) throw new Error(); return value; });
|
|
74
|
+
if (!pending || message.jsonrpc !== '2.0' || message.id !== pending.id || message.error || !Object.hasOwn(message, 'result')) throw new Error();
|
|
75
|
+
const result = observation(message.result); const entry = pending; pending = null; clearTimeout(entry.timer); entry.resolve(result);
|
|
76
|
+
} catch { fail('Invalid simulation protocol response.'); void stop(); return; }
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
request(method, params) {
|
|
81
|
+
if (failure) return Promise.reject(failure);
|
|
82
|
+
if (pending) return Promise.reject(new Error('Simulation request already pending.'));
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const requestId = ++id;
|
|
85
|
+
pending = { id: requestId, resolve, reject, timer: setTimeout(() => { fail('Simulation response timed out.'); void stop(); }, bridge.timeoutMs) };
|
|
86
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: requestId, method, params }) + '\n');
|
|
87
|
+
});
|
|
88
|
+
}, stop
|
|
89
|
+
};
|
|
90
|
+
}
|
package/src/state/index.js
CHANGED
|
@@ -100,6 +100,9 @@ export async function listStates(options = {}) {
|
|
|
100
100
|
state
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
|
-
|
|
103
|
+
// Two journals written in the same millisecond tie on updated_at; the job
|
|
104
|
+
// id breaks the tie (descending, like the time), so the order never
|
|
105
|
+
// depends on how the filesystem lists the directory.
|
|
106
|
+
found.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)) || String(b.job_id).localeCompare(String(a.job_id)));
|
|
104
107
|
return found;
|
|
105
108
|
}
|
package/src/ui/index.js
CHANGED
|
@@ -184,6 +184,19 @@ export async function choose(question, labels, options = {}) {
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Bold on a terminal that shows it, plain everywhere else (a pipe, CI, a
|
|
189
|
+
* log file, NO_COLOR), so a saved log never carries escape codes.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} text
|
|
192
|
+
* @param {{isTTY?: boolean}} [target] the stream it will be written to
|
|
193
|
+
* @returns {string}
|
|
194
|
+
*/
|
|
195
|
+
export function bold(text, target = stream) {
|
|
196
|
+
if (process.env.NO_COLOR || !(target && target.isTTY)) return String(text);
|
|
197
|
+
return `\u001b[1m${text}\u001b[22m`;
|
|
198
|
+
}
|
|
199
|
+
|
|
187
200
|
/** Cents as dollars, the one place that formatting exists. */
|
|
188
201
|
export function money(cents) {
|
|
189
202
|
if (cents === null || cents === undefined) return 'unknown';
|
|
@@ -219,5 +232,6 @@ export const ui = {
|
|
|
219
232
|
confirm,
|
|
220
233
|
choose,
|
|
221
234
|
money,
|
|
222
|
-
bytes
|
|
235
|
+
bytes,
|
|
236
|
+
bold
|
|
223
237
|
};
|