ravensight-playtest 0.1.0 → 0.2.3

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.
@@ -0,0 +1,1059 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { appendFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { optionsFrom } from '../run/args.js';
5
+ import { getDeps } from '../run/deps.js';
6
+ import { createDriver } from '../run/drivers/index.js';
7
+ import EXIT from '../run/exit.js';
8
+ import { applyFix, fixSystem, MAX_PLAN_IMAGES, planContent, planFix, resolveFixMaxActions, summariseFix } from '../run/fixLoop.js';
9
+ import { createGit, fixBranchName, preflight, startBranch } from '../run/git.js';
10
+ import { createHeartbeat } from '../run/heartbeat.js';
11
+ import { createModelClient } from '../run/model.js';
12
+ import { jobDir, runDir as runDirFor, RUN_FILES, screenshotsDir } from '../run/paths.js';
13
+ import { runPersona } from '../run/personaLoop.js';
14
+ import { createRepoTools, PROFILE_TOOLS } from '../run/repoTools.js';
15
+ import { assertNoSecrets } from '../run/secretScan.js';
16
+ import * as stateFile from '../run/state.js';
17
+ import { createUsageLedger, writeUsageFile } from '../run/usage.js';
18
+ import { createInterruptHandler, deliver, exitCodeFor, explainRegisterRefusal, formatCents, resolveSetup, resolveTarget, skillFor } from './run.js';
19
+
20
+ /**
21
+ * `ravensight-playtest fix <target>`: take one finding, change the code on a
22
+ * new branch, replay the finding to see whether the change worked, and hand
23
+ * the developer a pull request to open.
24
+ *
25
+ * Three rules shape everything else in this file:
26
+ *
27
+ * 1. **It edits, it never publishes.** All git usage lives in
28
+ * `src/run/git.js`, which has no commit, no push and no way to reach a
29
+ * forge. The command ends by printing the pull request command; running it
30
+ * is the developer's decision, made with the diff in front of them.
31
+ * 2. **It buys the answer "no" as cheaply as possible.** The triage call comes
32
+ * before the first edit, because a finding that is design intent should
33
+ * cost a refunded unit rather than a branch full of changes nobody wanted.
34
+ * A fix that changes nothing is refunded the same way.
35
+ * 3. **A verify run that cannot see the change is worse than none.** The
36
+ * verify plays the build the developer points at, so a build served from
37
+ * somewhere else would answer about yesterday's code. That is refused
38
+ * rather than reported, because a false "fixed" is the one answer this
39
+ * command must never give.
40
+ */
41
+
42
+ /** The job a fix registers. The verify run comes back with it. */
43
+ export const FIX_MODULES = Object.freeze(['fix']);
44
+
45
+ /**
46
+ * What the content pack is asked for: the fix skill and the persona skill,
47
+ * because the verify run is an ordinary persona run.
48
+ */
49
+ export const PACK_MODULES = Object.freeze(['fix', 'persona_playtest']);
50
+
51
+ /** The changeset lands beside `report.md` in the fix run directory. */
52
+ export const CHANGESET_FILE = 'changeset.json';
53
+
54
+ /** At or above this confidence, `design_intent` stops the fix before it edits. */
55
+ export const DESIGN_INTENT_CONFIDENCE = 0.7;
56
+
57
+ /** The verify run's own action ceiling: it reproduces one finding, nothing more. */
58
+ export const VERIFY_MAX_ACTIONS = 20;
59
+
60
+ /** How the judged `reproduced` score reads in words. */
61
+ export const FIXED_BELOW = 0.35;
62
+ export const REPRODUCED_ABOVE = 0.65;
63
+
64
+ /** A changeset carries at most this many files. */
65
+ export const MAX_CHANGESET_FILES = 20;
66
+
67
+ /**
68
+ * @param {string} target `<jobId>/<label>` or a finding id
69
+ * @param {Object} flags what commander hands over
70
+ * @param {Object} [injected] test seam
71
+ * @returns {Promise<number>} process exit code
72
+ */
73
+ export async function fixCommand(target, flags = {}, injected = {}) {
74
+ const deps = injected.deps || (await getDeps());
75
+ const log = injected.log || (deps.ui && deps.ui.info ? message => deps.ui.info(message) : message => console.log(message));
76
+ try {
77
+ return await fix(target, flags, injected, deps, log);
78
+ } catch (error) {
79
+ const code = exitCodeFor(error);
80
+ if (code === EXIT.FAILED) throw error;
81
+ log(String(error && error.message ? error.message : error));
82
+ return code;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * `<jobId>/<label>` or a bare finding id.
88
+ *
89
+ * The last slash wins, because a job id has no slash in it and a label never
90
+ * does either, while an id from somewhere else might.
91
+ *
92
+ * @param {string} target
93
+ * @returns {{jobId: string|null, label: string|null, findingId: string|null}|null}
94
+ */
95
+ export function parseTarget(target) {
96
+ const value = String(target || '').trim();
97
+ if (!value) return null;
98
+ const slash = value.lastIndexOf('/');
99
+ if (slash > 0 && slash < value.length - 1) {
100
+ return { jobId: value.slice(0, slash), label: value.slice(slash + 1), findingId: null };
101
+ }
102
+ if (slash !== -1) return null;
103
+ return { jobId: null, label: null, findingId: value };
104
+ }
105
+
106
+ /**
107
+ * The verify run plays a URL, and a URL this machine does not serve cannot
108
+ * show an edit that only exists in this working tree. A hosted build would
109
+ * answer about the last upload instead, and answer it confidently.
110
+ *
111
+ * @param {string} driverName
112
+ * @param {string|null} target
113
+ * @returns {string|null} the refusal, or null
114
+ */
115
+ export function hostedTargetRefusal(driverName, target) {
116
+ if (driverName !== 'playwright_web' || !target) return null;
117
+ let host;
118
+ try {
119
+ host = new URL(String(target)).hostname.toLowerCase();
120
+ } catch {
121
+ return `${target} is not a URL the verify run can play. Pass --build-url with a local address, or --no-verify.`;
122
+ }
123
+ // Node keeps the brackets on an IPv6 hostname, so `http://[::1]:8080/`
124
+ // answers `[::1]` and never the bare form.
125
+ const local = host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'
126
+ || host === '0.0.0.0' || host.endsWith('.localhost');
127
+ if (local) return null;
128
+ return `${target} is not served from this machine, so a verify run there cannot see a change that only exists in this working tree. Serve the build locally and pass --build-url, or pass --no-verify to fix without verifying.`;
129
+ }
130
+
131
+ /**
132
+ * The verdict in the words a developer reads.
133
+ *
134
+ * @param {{reproduced: number|null}|null} verify
135
+ * @returns {string}
136
+ */
137
+ export function verifyWords(verify) {
138
+ if (!verify) return 'not verified';
139
+ const score = verify.reproduced;
140
+ if (typeof score !== 'number' || !Number.isFinite(score)) return 'unknown, the verify run reached no verdict';
141
+ if (score < FIXED_BELOW) return `fixed (reproduced ${score})`;
142
+ if (score > REPRODUCED_ABOVE) return `still reproduced (${score})`;
143
+ return `unknown (reproduced ${score})`;
144
+ }
145
+
146
+ /**
147
+ * @param {string} target
148
+ * @param {Object} flags
149
+ * @param {Object} injected
150
+ * @param {Object} deps
151
+ * @param {(message: string) => void} log
152
+ * @returns {Promise<number>}
153
+ */
154
+ async function fix(target, flags, injected, deps, log) {
155
+ const { options, unknown } = optionsFrom(flags);
156
+ if (unknown.length > 0) {
157
+ log(`I do not know these options: ${unknown.join(', ')}. Run "ravensight-playtest fix --help".`);
158
+ return EXIT.ENVIRONMENT;
159
+ }
160
+ const parsed = parseTarget(target);
161
+ if (!parsed) {
162
+ log('Name the finding to fix: either <jobId>/<label>, like pt_job_1/F002, or a finding id.');
163
+ return EXIT.ENVIRONMENT;
164
+ }
165
+
166
+ const setup = await resolveSetup(deps, options, log);
167
+ if (setup.code !== undefined) return setup.code;
168
+ const { context, api, gameId, apiUrl, repoRoot, cliVersion } = setup;
169
+
170
+ const wantsVerify = options.verify !== false;
171
+ const driverName = options.driver || (context.config && context.config.driver) || 'playwright_web';
172
+ const buildTarget = resolveTarget(options, context.config, driverName);
173
+
174
+ // Said before anything is charged, because it is the difference between a
175
+ // verify that means something and one that does not.
176
+ if (wantsVerify) {
177
+ if (!buildTarget) {
178
+ log('The verify run needs a build to play: pass --build-url for a web build or --godot-project for a Godot project, or pass --no-verify.');
179
+ return EXIT.ENVIRONMENT;
180
+ }
181
+ const refusal = hostedTargetRefusal(driverName, buildTarget);
182
+ if (refusal) {
183
+ log(refusal);
184
+ return EXIT.ENVIRONMENT;
185
+ }
186
+ if (driverName === 'playwright_web') {
187
+ log(`The verify run plays ${buildTarget}, which has to serve this working tree. Rebuild and serve the branch before the verify, or its answer is about the old build.`);
188
+ }
189
+ }
190
+
191
+ const pack = await deps.packs.get({ api, gameId, modules: PACK_MODULES });
192
+ const fixSkill = (pack.skills || []).find(entry => entry.module === 'fix');
193
+ if (!fixSkill) {
194
+ log('The content pack has no fix skill. Run "ravensight-playtest check" and try again.');
195
+ return EXIT.ENVIRONMENT;
196
+ }
197
+ // A fix job registers a persona run, so the persona skill has to be there
198
+ // too. Finding that out now beats finding it out after the charge.
199
+ if (wantsVerify && !(pack.skills || []).some(entry => entry.module === 'persona_playtest')) {
200
+ log('The content pack has no persona-playtest skill, so the verify run could not be played. Run "ravensight-playtest check" and try again.');
201
+ return EXIT.ENVIRONMENT;
202
+ }
203
+ const brief = await deps.brief.get({ api, gameId }).catch(() => null);
204
+
205
+ const listed = await resolveFinding(api, gameId, parsed);
206
+ if (!listed) {
207
+ log(parsed.findingId
208
+ ? `No finding ${parsed.findingId} is visible to this token. Run "ravensight-playtest open" to see the findings this game has.`
209
+ : `Job ${parsed.jobId} has no finding labelled ${parsed.label}.`);
210
+ return EXIT.ENVIRONMENT;
211
+ }
212
+ const sourceJobId = listed.job_id || parsed.jobId || null;
213
+ const sourceRunId = listed.run_id || null;
214
+ const evidence = await gatherEvidence({ api, gameId, repoRoot, sourceJobId, sourceRunId, injected, log });
215
+ // The findings list is a summary; the run's own report.json is where the
216
+ // repro steps and the persona's words are written in full.
217
+ const finding = enrichFinding(listed, evidence.reportJson);
218
+
219
+ const branch = options.branch || fixBranchName(finding.label || finding.finding_id);
220
+ const git = injected.git || createGit({ root: repoRoot });
221
+ // Every refusal now, the one mutation after the price is agreed: a developer
222
+ // who says no to the charge should not be left on a new branch.
223
+ const checked = await preflight({ git, branch, allowDirty: Boolean(options.allowDirty), create: false });
224
+ if (!checked.ok) {
225
+ log(checked.reason);
226
+ return EXIT.ENVIRONMENT;
227
+ }
228
+
229
+ // A fix job asks for exactly one persona, the one who filed the finding: the
230
+ // server creates the verify run from it, and both zero and two are a 400.
231
+ const personaSlug = finding.persona || ((pack.personas || [])[0] || {}).slug || null;
232
+ if (!personaSlug) {
233
+ log('That finding names no persona and the content pack has none, so there is nobody to verify the fix with.');
234
+ return EXIT.ENVIRONMENT;
235
+ }
236
+ const estimate = await api.jobs.estimate(gameId, { modules: [...FIX_MODULES], personas: [personaSlug] });
237
+ const priceCents = Number.isFinite(Number(estimate.estimate_cents)) ? Number(estimate.estimate_cents) : Number(estimate.price_cents);
238
+ if (!Number.isFinite(priceCents)) {
239
+ // Sending `confirm_price_cents: undefined` would be refused as
240
+ // `price_changed`, which reads as a race rather than as the truth: this
241
+ // server did not quote a price at all.
242
+ log('The server quoted no price for this fix, so nothing was charged. Run "ravensight-playtest check" and try again.');
243
+ return EXIT.ENVIRONMENT;
244
+ }
245
+ const offer = `${formatCents(priceCents)} fix attempt (includes one verify run). Balance: ${formatCents(estimate.balance_cents)}.`;
246
+ log(offer);
247
+ if (estimate.ok === false) {
248
+ log(`That is more than the balance covers. Top up at ${estimate.topup_url || 'ravensight.io'} and run this again.`);
249
+ return EXIT.ENVIRONMENT;
250
+ }
251
+ if (estimate.wall_warning) log(estimate.wall_warning);
252
+ const confirm = injected.confirm || (deps.ui && deps.ui.confirm);
253
+ if (!options.yes && confirm) {
254
+ const go = await confirm(offer, { yes: options.yes });
255
+ if (!go) {
256
+ log('Nothing was charged and nothing was changed.');
257
+ return EXIT.CANCELED;
258
+ }
259
+ }
260
+
261
+ const started = await startBranch({ git, branch });
262
+ if (!started.ok) {
263
+ log(started.reason);
264
+ return EXIT.ENVIRONMENT;
265
+ }
266
+ log(`branch ${branch}, from ${checked.head.slice(0, 8)}${checked.from ? ` on ${checked.from}` : ''}.`);
267
+
268
+ const idempotencyKey = options.idempotencyKey || randomUUID();
269
+ let registered;
270
+ try {
271
+ registered = await api.jobs.register(gameId, {
272
+ modules: [...FIX_MODULES],
273
+ personas: [personaSlug],
274
+ finding_id: finding.finding_id,
275
+ driver: driverName,
276
+ cli_version: cliVersion,
277
+ confirm_price_cents: priceCents,
278
+ brief_version: brief ? brief.version ?? null : null,
279
+ build: buildTarget
280
+ ? { kind: driverName === 'playwright_web' ? 'url' : 'godot_project', ref: buildTarget }
281
+ : { kind: 'repo', ref: repoRoot },
282
+ repo: { commit: checked.head, dirty: Boolean(checked.dirty), branch },
283
+ max_actions: Number.isInteger(options.maxActions) ? options.maxActions : null,
284
+ pack_versions: { pack: pack.pack_version }
285
+ }, { idempotencyKey });
286
+ } catch (error) {
287
+ const handled = explainRegisterRefusal(error, log);
288
+ if (handled !== null) return handled;
289
+ throw error;
290
+ }
291
+
292
+ const jobId = registered.job_id;
293
+ // Runs are told apart by module and nothing else: the fix run carries
294
+ // `persona: null`, and only the verify run carries the slug.
295
+ const fixRun = (registered.runs || []).find(entry => entry.module === 'fix');
296
+ const verifyRun = (registered.runs || []).find(entry => entry.module === 'persona_playtest') || null;
297
+
298
+ await mkdir(jobDir(repoRoot, jobId), { recursive: true });
299
+ const journal = stateFile.newState({
300
+ jobId,
301
+ gameId,
302
+ idempotencyKey,
303
+ cliVersion,
304
+ packVersion: pack.pack_version,
305
+ briefVersion: brief ? brief.version ?? null : null,
306
+ modules: [...FIX_MODULES],
307
+ // The driver and the target belong to the verify run, which is the only
308
+ // run in this job that plays anything.
309
+ driver: driverName,
310
+ target: buildTarget,
311
+ priceCents: registered.price && registered.price.price_cents,
312
+ options: {
313
+ branch,
314
+ baseCommit: checked.head,
315
+ findingId: finding.finding_id,
316
+ verify: wantsVerify,
317
+ allowDirty: Boolean(options.allowDirty),
318
+ maxActions: options.maxActions ?? null
319
+ }
320
+ });
321
+ for (const entry of registered.runs || []) {
322
+ journal.runs[entry.run_id] = stateFile.newRunEntry({ runId: entry.run_id, module: entry.module, persona: entry.persona });
323
+ }
324
+ await stateFile.save(repoRoot, journal);
325
+ // The journal and the job id are written before anything can go wrong with
326
+ // the runs, because the charge has already happened: a developer who cannot
327
+ // name the job they paid for cannot ask anybody about it either.
328
+ log(`job ${jobId}: ${formatCents(registered.price && registered.price.price_cents)} charged. ${jobDir(repoRoot, jobId)}`);
329
+
330
+ if (!fixRun) {
331
+ log('The server registered the job without a fix run, so there is nothing to spend a model turn from.');
332
+ await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'the job has no fix run', log });
333
+ // Settled rather than abandoned: the job is charged and unplayable, so the
334
+ // refund is owed now and not in half an hour when the sweep notices.
335
+ await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the job has no fix run', log });
336
+ return EXIT.FAILED;
337
+ }
338
+
339
+ const directory = runDirFor(repoRoot, jobId, fixRun.run_id);
340
+ await mkdir(directory, { recursive: true });
341
+
342
+ const createClient = injected.createClient || createModelClient;
343
+ const client = createClient({ apiUrl, token: injected.modelToken || api.token });
344
+ const ledger = createUsageLedger();
345
+ const heartbeat = createHeartbeat({
346
+ api,
347
+ gameId,
348
+ jobId,
349
+ runId: fixRun.run_id,
350
+ onCancel: () => log('the job was cancelled; stopping.'),
351
+ onUsage: usage => ledger.recordServerUsage(usage),
352
+ onError: () => {}
353
+ });
354
+ const inFlight = new Map();
355
+ const interrupt = createInterruptHandler({
356
+ api,
357
+ gameId,
358
+ jobId,
359
+ repoRoot,
360
+ journal,
361
+ inFlight,
362
+ log,
363
+ // Not "resume carries on": `resume` replays persona runs, and replaying
364
+ // this job's verify persona against a half-applied edit would answer a
365
+ // question nobody asked. A fix is abandoned and refunded instead.
366
+ message: 'finishing the turn in flight, then stopping. A fix cannot be resumed, so this one is being given back.',
367
+ injected
368
+ });
369
+
370
+ try {
371
+ await api.runs.transition(gameId, jobId, fixRun.run_id, 'launching', { reason: '' });
372
+ await api.runs.transition(gameId, jobId, fixRun.run_id, 'playing', { reason: '' });
373
+ heartbeat.start();
374
+ inFlight.set(fixRun.run_id, { state: 'playing' });
375
+
376
+ // 1. Is this a bug at all? The cheapest possible "no".
377
+ const verdict = await api.jobs.fixTriage(gameId, jobId, { finding_id: finding.finding_id });
378
+ if (verdict && verdict.verdict === null) {
379
+ // The triage service is off, so nothing was judged. Proceeding is right
380
+ // (the finding was filed by a persona that was told the design intent),
381
+ // but the developer should know the check did not happen.
382
+ log(`Triage could not judge this finding (${verdict.source || 'unavailable'}), so the fix proceeds without it. Read the diff against your own design intent.`);
383
+ }
384
+ if (isDesignIntent(verdict)) {
385
+ heartbeat.stop();
386
+ inFlight.delete(fixRun.run_id);
387
+ log(`${finding.label || finding.finding_id} reads as design intent, not a defect (confidence ${verdict.confidence}, from ${verdict.source || 'the brief'}), so nothing was edited.`);
388
+ await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'intended' }, log });
389
+ await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'the finding is design intent', log });
390
+ await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the finding is design intent', log });
391
+ log('The fix unit was refunded. Mark the finding as intended in the dashboard so no later job spends a turn on it.');
392
+ return EXIT.OK;
393
+ }
394
+
395
+ // 2. Plan, then apply.
396
+ const images = await saveEvidenceImages(directory, evidence.images);
397
+ const system = fixSystem({
398
+ skill: fixSkill.content,
399
+ jobId,
400
+ branch,
401
+ baseCommit: checked.head,
402
+ engine: pack.engine || null
403
+ });
404
+ const codeBrief = pack.code_brief || null;
405
+ // `graph_context` only exists in a bundle asked for with this job's id: it
406
+ // is assembled from the job's own findings, so the pre-register fetch (which
407
+ // bought the skills and the price) has it as null. A refetch that fails is
408
+ // not fatal, it just means planning without the level graph.
409
+ const graphContext = await jobGraphContext({ deps, api, gameId, jobId, log });
410
+ const planRepo = createRepoTools({ root: repoRoot, tools: PROFILE_TOOLS });
411
+ const { plan } = await planFix({
412
+ client,
413
+ jobId,
414
+ runId: fixRun.run_id,
415
+ system,
416
+ repo: planRepo,
417
+ content: planContent({
418
+ finding: findingForPrompt(finding),
419
+ reproSteps: reproStepsOf(finding),
420
+ personaQuote: finding.persona_quote || null,
421
+ images,
422
+ intentNotes: intentNotesFor(pack, brief),
423
+ reportMarkdown: evidence.reportMarkdown,
424
+ codeBrief,
425
+ graphContext
426
+ }),
427
+ ledger,
428
+ log
429
+ });
430
+ if (plan.hypothesis) log(`hypothesis: ${plan.hypothesis}`);
431
+
432
+ const maxActions = resolveFixMaxActions(options, pack);
433
+ const applied = await applyFix({
434
+ client,
435
+ jobId,
436
+ runId: fixRun.run_id,
437
+ repoRoot,
438
+ system,
439
+ plan,
440
+ maxActions,
441
+ codeBrief,
442
+ graphContext,
443
+ ledger,
444
+ log,
445
+ onProgress: progress => {
446
+ if (options.verbose) {
447
+ log(` ${progress.tool} ${progress.target || ''} (${progress.resultChars} chars${progress.isError ? ', error' : ''}${progress.repeat ? ', repeat' : ''})`);
448
+ }
449
+ // A local transcript of the apply loop: which tools, on what, how much
450
+ // came back. Never uploaded; it is how a fix that wrote nothing gets
451
+ // explained afterwards, which the first production attempt could not be.
452
+ appendFile(path.join(directory, 'transcript.jsonl'), `${JSON.stringify({ at: new Date().toISOString(), ...progress })}\n`).catch(() => {});
453
+ },
454
+ shouldStop: () => interrupt.signalled || heartbeat.canceled
455
+ });
456
+
457
+ // A Ctrl-C, or the job cancelled from the dashboard. Either way this run is
458
+ // over, and it is abandoned rather than left `interrupted`: `resume` only
459
+ // knows how to replay persona runs, so a fix run parked in a resumable
460
+ // state is a promise nothing can keep. The edits stay on the branch, which
461
+ // is where the developer can read them, and the unit goes back.
462
+ if (interrupt.signalled || heartbeat.canceled || applied.stop === 'interrupted') {
463
+ heartbeat.stop();
464
+ inFlight.delete(fixRun.run_id);
465
+ const why = interrupt.signalled ? 'interrupted by the developer' : 'cancelled';
466
+ await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'error' }, log });
467
+ await skipRun({ api, gameId, jobId, run: verifyRun, reason: `the fix was ${why}`, log });
468
+ try {
469
+ await stateFile.patchRun(repoRoot, journal, fixRun.run_id, { state: 'failed', quit_reason: 'error' });
470
+ } catch {
471
+ // The journal is a convenience here; the server's own run state is the
472
+ // record, and it has just been told.
473
+ }
474
+ await settle({ api, gameId, jobId, repoRoot, journal, reason: `the fix was ${why}`, log });
475
+ log(`The fix was ${why} and cannot be resumed, so the unit was given back. The branch ${branch} holds whatever was written, and nothing was committed or pushed.`);
476
+ return EXIT.CANCELED;
477
+ }
478
+
479
+ // 3. Did anything change? If not, say so and give the money back.
480
+ const changed = await git.diffStat();
481
+ if (changed.length === 0) {
482
+ heartbeat.stop();
483
+ inFlight.delete(fixRun.run_id);
484
+ log(`Nothing was changed, so there is nothing to review. The branch ${branch} is empty; delete it or stay on it.`);
485
+ await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'no_change' }, log });
486
+ await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'nothing was changed', log });
487
+ await settle({ api, gameId, jobId, repoRoot, journal, reason: 'nothing was changed', log });
488
+ log('The fix unit was refunded.');
489
+ return EXIT.OK;
490
+ }
491
+
492
+ const files = changed.slice(0, MAX_CHANGESET_FILES);
493
+ if (changed.length > files.length) {
494
+ log(`${changed.length} files changed and a changeset carries at most ${MAX_CHANGESET_FILES}, so the changeset lists the first ${MAX_CHANGESET_FILES}. The branch has all of them.`);
495
+ }
496
+
497
+ // 4. Verify, by replaying the finding against the build.
498
+ let verified = null;
499
+ if (wantsVerify && verifyRun) {
500
+ verified = await runVerify({
501
+ deps,
502
+ api,
503
+ client,
504
+ gameId,
505
+ jobId,
506
+ repoRoot,
507
+ entry: verifyRun,
508
+ pack,
509
+ finding,
510
+ driverName,
511
+ target: buildTarget,
512
+ options,
513
+ journal,
514
+ maxActions: Math.min(VERIFY_MAX_ACTIONS, maxActions),
515
+ makeDriver: injected.driverFactory || createDriver,
516
+ log
517
+ });
518
+ } else {
519
+ await skipRun({ api, gameId, jobId, run: verifyRun, reason: wantsVerify ? 'no verify run was registered' : 'skipped with --no-verify', log });
520
+ }
521
+
522
+ // 5. The pull request text, from the diff that exists.
523
+ const fallback = localSummary({ finding, plan, files, verify: verified, branch, applied });
524
+ // Both budget stops mean the apply loop left off early rather than because
525
+ // it was finished, so both carry the caveat. Only the 402 skips the
526
+ // summarise call, because only the 402 means there is nothing left to
527
+ // spend on it.
528
+ const budgetTouched = applied.stop === 'budget' || applied.stop === 'budget_warning';
529
+ const summary = applied.stop === 'budget'
530
+ ? { ...fallback, source: 'local' }
531
+ : await summariseFix({
532
+ client,
533
+ jobId,
534
+ runId: fixRun.run_id,
535
+ system,
536
+ finding: findingForPrompt(finding),
537
+ plan,
538
+ files,
539
+ verify: verified,
540
+ branch,
541
+ fallback,
542
+ ledger,
543
+ log
544
+ });
545
+
546
+ const changeset = {
547
+ schema_version: '1.0',
548
+ finding_id: finding.finding_id,
549
+ branch,
550
+ base_commit: checked.head,
551
+ files,
552
+ verify: verified ? { run_id: verified.run_id, reproduced: verified.reproduced } : null,
553
+ pr_title: summary.prTitle,
554
+ pr_body: summary.prBody
555
+ };
556
+
557
+ await heartbeat.beat();
558
+ heartbeat.stop();
559
+ inFlight.delete(fixRun.run_id);
560
+ const reportPath = await writeChangeset(directory, changeset);
561
+ await writeUsageFile(directory, ledger);
562
+
563
+ await api.runs.transition(gameId, jobId, fixRun.run_id, 'reporting', { reason: '' });
564
+ await api.runs.transition(gameId, jobId, fixRun.run_id, 'uploading', { reason: '' });
565
+ try {
566
+ await deps.finalizeRun(directory, {
567
+ api,
568
+ gameId,
569
+ jobId,
570
+ runId: fixRun.run_id,
571
+ // Exactly the three files this run produced. Screenshots copied from
572
+ // the run that found the bug are evidence for the model, not something
573
+ // to upload a second time.
574
+ allowedPaths: [CHANGESET_FILE, RUN_FILES.reportMd, RUN_FILES.usageJson],
575
+ body: { state: 'succeeded', changeset }
576
+ });
577
+ await stateFile.patchRun(repoRoot, journal, fixRun.run_id, { state: 'succeeded', uploaded: true, completed: true, report_written: true });
578
+ } catch (error) {
579
+ log(`The changeset could not be uploaded (${String(error && error.message)}). It is in ${directory} and "ravensight-playtest upload ${jobId}" can retry it.`);
580
+ // 422 `invalid_changeset` carries per-field pointers, and they are the
581
+ // whole diagnosis: "pr_title too long" is actionable, "422" is not.
582
+ for (const detail of changesetDetails(error)) log(` ${detail}`);
583
+ }
584
+ await settle({ api, gameId, jobId, repoRoot, journal, reason: '', log });
585
+
586
+ report({ changeset, directory, reportPath, verified, options, log, injected, deps, budget: budgetTouched });
587
+ // Exit 2 is "the money ran out", which a warning is not: a warning means
588
+ // the run wrapped up early with budget still in hand.
589
+ return applied.stop === 'budget' ? EXIT.BUDGET : EXIT.OK;
590
+ } catch (error) {
591
+ heartbeat.stop();
592
+ const code = exitCodeFor(error);
593
+ log(`fix failed: ${String(error && error.message)}`);
594
+ log(`The branch ${branch} still holds whatever was written. Nothing was committed or pushed.`);
595
+ await failFixRun({ api, gameId, jobId, runId: fixRun.run_id, log });
596
+ await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'the fix failed', log });
597
+ await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the fix failed', log });
598
+ return code;
599
+ } finally {
600
+ interrupt.release();
601
+ }
602
+ }
603
+
604
+ /**
605
+ * The finding, by label inside one job or by id across the game.
606
+ *
607
+ * @param {Object} api
608
+ * @param {string} gameId
609
+ * @param {{jobId: string|null, label: string|null, findingId: string|null}} target
610
+ * @returns {Promise<Object|null>}
611
+ */
612
+ export async function resolveFinding(api, gameId, target) {
613
+ const answer = await api.jobs.findings(gameId, target.jobId ? { job: target.jobId } : {});
614
+ const list = (answer && answer.findings) || [];
615
+ if (target.findingId) return list.find(entry => entry.finding_id === target.findingId) || null;
616
+ const wanted = String(target.label).toLowerCase();
617
+ return list.find(entry => String(entry.label || '').toLowerCase() === wanted) || null;
618
+ }
619
+
620
+ /**
621
+ * Fill in what the findings list left out from the run's own report.
622
+ *
623
+ * `GET /findings` answers a list for a dashboard, so it may carry a title and
624
+ * a severity and nothing else. The report the persona wrote is where the repro
625
+ * steps, the quote and the cited evidence live, and those are most of what the
626
+ * plan step has to work from. The listed record still wins on every field it
627
+ * does have: it is the one the server considers current.
628
+ *
629
+ * @param {Object} listed
630
+ * @param {Object|null} reportJson
631
+ * @returns {Object}
632
+ */
633
+ export function enrichFinding(listed, reportJson) {
634
+ const findings = (reportJson && Array.isArray(reportJson.findings) ? reportJson.findings : []);
635
+ const match = findings.find(entry => entry
636
+ && (entry.finding_id === listed.finding_id
637
+ || (entry.label && listed.label && String(entry.label).toLowerCase() === String(listed.label).toLowerCase())));
638
+ if (!match) return listed;
639
+ const merged = { ...match };
640
+ for (const [key, value] of Object.entries(listed)) {
641
+ if (value !== null && value !== undefined && !(Array.isArray(value) && value.length === 0)) merged[key] = value;
642
+ }
643
+ return merged;
644
+ }
645
+
646
+ /**
647
+ * @param {Object|null} verdict
648
+ * @returns {boolean}
649
+ */
650
+ export function isDesignIntent(verdict) {
651
+ if (!verdict || verdict.verdict !== 'design_intent') return false;
652
+ const confidence = Number(verdict.confidence);
653
+ return Number.isFinite(confidence) && confidence >= DESIGN_INTENT_CONFIDENCE;
654
+ }
655
+
656
+ /**
657
+ * The report and the screenshots behind the finding.
658
+ *
659
+ * Screenshots come from the local run directory when this machine is the one
660
+ * that played the job, which is the usual case and costs nothing. Otherwise
661
+ * they come from the presigned artifact URLs, which is what makes `fix` usable
662
+ * from a different machine than the run. Either way at most two are taken,
663
+ * because that is the plan step's image ceiling.
664
+ *
665
+ * @returns {Promise<{reportMarkdown: string|null, reportJson: Object|null, images: Array}>}
666
+ */
667
+ export async function gatherEvidence({ api, gameId, repoRoot, sourceJobId, sourceRunId, injected = {}, log = () => {} }) {
668
+ const out = { reportMarkdown: null, reportJson: null, images: [] };
669
+ if (!sourceJobId || !sourceRunId) return out;
670
+
671
+ try {
672
+ out.reportMarkdown = String(await api.runs.report(gameId, sourceJobId, sourceRunId, 'md'));
673
+ } catch {
674
+ log('The run report for that finding could not be read, so the fix works from the finding alone.');
675
+ }
676
+ try {
677
+ const body = await api.runs.report(gameId, sourceJobId, sourceRunId, 'json');
678
+ out.reportJson = typeof body === 'string' ? JSON.parse(body) : body;
679
+ } catch {
680
+ // The markdown is the part the plan step reads; the JSON is a bonus.
681
+ }
682
+
683
+ const local = screenshotsDir(runDirFor(repoRoot, sourceJobId, sourceRunId));
684
+ try {
685
+ const names = (await readdir(local)).filter(isImageName).sort().slice(0, MAX_PLAN_IMAGES);
686
+ for (const name of names) {
687
+ out.images.push({ name, mediaType: mediaTypeFor(name), base64: (await readFile(path.join(local, name))).toString('base64') });
688
+ }
689
+ } catch {
690
+ // No local run directory: this machine did not play that job.
691
+ }
692
+ if (out.images.length > 0) return out;
693
+
694
+ const fetchImpl = injected.fetch || globalThis.fetch;
695
+ if (!fetchImpl) return out;
696
+ try {
697
+ const answer = await api.jobs.artifacts(gameId, sourceJobId);
698
+ const wanted = (answer && answer.artifacts ? answer.artifacts : [])
699
+ // The server answers every artifact of the job, so the run has to be
700
+ // matched, and the content type is what says an artifact is an image: a
701
+ // report is not, whatever its path looks like.
702
+ .filter(entry => entry && entry.url && entry.run_id === sourceRunId)
703
+ .filter(entry => String(entry.content_type || '').startsWith('image/') || isImageName(String(entry.path || '')))
704
+ .slice(0, MAX_PLAN_IMAGES);
705
+ for (const entry of wanted) {
706
+ const response = await fetchImpl(entry.url);
707
+ if (!response || !response.ok) continue;
708
+ const body = Buffer.from(await response.arrayBuffer());
709
+ out.images.push({ name: path.basename(String(entry.path)), mediaType: mediaTypeFor(String(entry.path)), base64: body.toString('base64') });
710
+ }
711
+ } catch {
712
+ log('The screenshots behind that finding could not be fetched, so the fix works from the text alone.');
713
+ }
714
+ return out;
715
+ }
716
+
717
+ /**
718
+ * Put the evidence images in the fix run directory, so what the model was
719
+ * shown is on disk next to what it produced.
720
+ *
721
+ * @param {string} directory the fix run directory
722
+ * @param {Array<Object>} images
723
+ * @returns {Promise<Array<Object>>} the images, unchanged
724
+ */
725
+ export async function saveEvidenceImages(directory, images) {
726
+ if (images.length === 0) return images;
727
+ const target = screenshotsDir(directory);
728
+ await mkdir(target, { recursive: true });
729
+ for (const image of images) {
730
+ await writeFile(path.join(target, image.name), Buffer.from(image.base64, 'base64'));
731
+ }
732
+ return images;
733
+ }
734
+
735
+ /**
736
+ * Replay the finding on the build, as an ordinary persona run whose brief
737
+ * starts with the one thing it is being asked to do.
738
+ *
739
+ * Unlike `run`, this emits no synthetic telemetry: a verify run is one
740
+ * question asked of a build that only exists on this machine, and putting its
741
+ * session into the game's event stream would count a private replay as
742
+ * playtest traffic.
743
+ *
744
+ * @returns {Promise<{run_id: string, reproduced: number|null, source: string|null}|null>}
745
+ */
746
+ export async function runVerify({
747
+ deps,
748
+ api,
749
+ client,
750
+ gameId,
751
+ jobId,
752
+ repoRoot,
753
+ entry,
754
+ pack,
755
+ finding,
756
+ driverName,
757
+ target,
758
+ options,
759
+ journal,
760
+ maxActions,
761
+ makeDriver,
762
+ log
763
+ }) {
764
+ const directory = runDirFor(repoRoot, jobId, entry.run_id);
765
+ await mkdir(directory, { recursive: true });
766
+ const persona = (pack.personas || []).find(candidate => candidate.slug === entry.persona) || (pack.personas || [])[0];
767
+ if (!persona) {
768
+ log('The content pack has no persona to verify with, so the fix is reported unverified.');
769
+ return null;
770
+ }
771
+ const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
772
+ const driver = await makeDriver(driverName, { headless });
773
+ const ledger = createUsageLedger();
774
+ const heartbeat = createHeartbeat({
775
+ api,
776
+ gameId,
777
+ jobId,
778
+ runId: entry.run_id,
779
+ onUsage: usage => ledger.recordServerUsage(usage),
780
+ onError: () => {}
781
+ });
782
+ heartbeat.start();
783
+
784
+ let outcome;
785
+ try {
786
+ outcome = await runPersona({
787
+ client,
788
+ api,
789
+ gameId,
790
+ jobId,
791
+ runId: entry.run_id,
792
+ persona,
793
+ module: 'persona_playtest',
794
+ driver,
795
+ driverName,
796
+ target,
797
+ runDirectory: directory,
798
+ skill: skillFor(pack, 'persona_playtest'),
799
+ brief: verifyBrief(pack.brief || null, finding),
800
+ intentNotes: pack.intent_notes || [],
801
+ schemas: pack.schemas || [],
802
+ maxActions,
803
+ heartbeat,
804
+ ledger,
805
+ log: message => log(`verify: ${message}`)
806
+ });
807
+ } catch (error) {
808
+ heartbeat.stop();
809
+ const failed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
810
+ await stateFile.patchRun(repoRoot, journal, entry.run_id, { state: failed.state, error: String(error && error.message), quit_reason: failed.quitReason });
811
+ await deliver({ deps, api, gameId, jobId, runId: entry.run_id, directory, outcome: failed, options, log, journal, repoRoot });
812
+ log(`the verify run failed (${String(error && error.message)}), so the fix is reported unverified.`);
813
+ return null;
814
+ }
815
+ heartbeat.stop();
816
+
817
+ await stateFile.patchRun(repoRoot, journal, entry.run_id, {
818
+ state: outcome.state,
819
+ actions_taken: outcome.actionsTaken,
820
+ quit_reason: outcome.quitReason,
821
+ report_written: Boolean(outcome.report),
822
+ budget_warned: outcome.budgetWarned
823
+ });
824
+ await deliver({ deps, api, gameId, jobId, runId: entry.run_id, directory, outcome, options, log, journal, repoRoot });
825
+
826
+ // The verdict is the server's: it reads the verify run's own report against
827
+ // the finding, which is judgement the CLI has no business making locally.
828
+ let run = null;
829
+ try {
830
+ const answer = await api.runs.get(gameId, jobId, entry.run_id);
831
+ run = (answer && (answer.run || answer)) || null;
832
+ } catch (error) {
833
+ log(`the verify verdict could not be read (${String(error && error.message)}).`);
834
+ }
835
+ const verdict = (run && run.verify_verdict) || null;
836
+ const reproduced = verdict && Number.isFinite(Number(verdict.reproduced)) ? Number(verdict.reproduced) : null;
837
+ return { run_id: entry.run_id, reproduced, source: (verdict && verdict.source) || null };
838
+ }
839
+
840
+ /**
841
+ * The verify run's brief: what it is being asked to reproduce, then the brief
842
+ * a persona usually plays against. The task comes first because it is the one
843
+ * thing this run exists for.
844
+ *
845
+ * @param {Object|null} base
846
+ * @param {Object} finding
847
+ * @returns {Object}
848
+ */
849
+ export function verifyBrief(base, finding) {
850
+ const fields = (base && base.fields) || {};
851
+ return {
852
+ ...(base || {}),
853
+ fields: {
854
+ verify_task: `Reproduce ${finding.label || finding.finding_id}: ${finding.title || 'the finding'}`,
855
+ verify_repro_steps: reproStepsOf(finding),
856
+ verify_instruction: 'Try these steps first and say plainly whether the problem still happens. Then play as you normally would.',
857
+ ...fields
858
+ }
859
+ };
860
+ }
861
+
862
+ /**
863
+ * What the pull request says when the model could not say it: no budget left,
864
+ * or an answer with no JSON in it. It is deliberately plain, and it says which
865
+ * parts are unknown rather than guessing at them.
866
+ *
867
+ * @returns {{prTitle: string, prBody: string}}
868
+ */
869
+ export function localSummary({ finding, plan, files, verify, branch, applied = {} }) {
870
+ const label = finding.label || finding.finding_id;
871
+ const body = [
872
+ `# Fix ${label}: ${finding.title || 'a playtest finding'}`,
873
+ '',
874
+ '## What was wrong',
875
+ finding.title ? `${finding.title}${finding.severity ? ` (${finding.severity})` : ''}` : 'See the finding in the dashboard.',
876
+ plan.hypothesis ? `\n${plan.hypothesis}` : '',
877
+ '',
878
+ '## What changed',
879
+ ...files.map(file => `- \`${file.path}\`: +${file.additions} -${file.deletions}`),
880
+ '',
881
+ '## Verify',
882
+ verifyWords(verify),
883
+ '',
884
+ '## What the reviewer should check',
885
+ 'This summary was written locally rather than by the model, so read the diff before trusting the description above.',
886
+ applied.stop === 'budget' || applied.stop === 'budget_warning'
887
+ ? 'The fix stopped early because its model budget was spent or nearly spent, so it may be incomplete.'
888
+ : '',
889
+ '',
890
+ `Branch: ${branch}`,
891
+ ''
892
+ ].filter(line => line !== '').join('\n');
893
+ return { prTitle: `Fix ${label}: ${(finding.title || 'a playtest finding').slice(0, 100)}`, prBody: body };
894
+ }
895
+
896
+ /**
897
+ * Write `changeset.json` and `report.md`, scanning both first.
898
+ *
899
+ * The scan is not a formality: the pull request body is written by a model
900
+ * that has just been reading source files, and a key quoted into it would be
901
+ * uploaded and then pasted into a pull request.
902
+ *
903
+ * @param {string} directory
904
+ * @param {Object} changeset
905
+ * @returns {Promise<string>} the path to report.md
906
+ */
907
+ export async function writeChangeset(directory, changeset) {
908
+ const json = `${JSON.stringify(changeset, null, 2)}\n`;
909
+ const markdown = `${String(changeset.pr_body || '').trimEnd()}\n`;
910
+ assertNoSecrets(CHANGESET_FILE, json);
911
+ assertNoSecrets(RUN_FILES.reportMd, markdown);
912
+ await writeFile(path.join(directory, CHANGESET_FILE), json, 'utf8');
913
+ const reportPath = path.join(directory, RUN_FILES.reportMd);
914
+ await writeFile(reportPath, markdown, 'utf8');
915
+ return reportPath;
916
+ }
917
+
918
+ /** What the developer reads at the end, or the changeset when `--json`. */
919
+ function report({ changeset, reportPath, verified, options, log, injected, deps, budget }) {
920
+ if (options.json) {
921
+ const emit = injected.emitJson || (deps.ui && deps.ui.json ? value => deps.ui.json(value) : value => console.log(JSON.stringify(value, null, 2)));
922
+ emit(changeset);
923
+ return;
924
+ }
925
+ log(`branch ${changeset.branch}`);
926
+ for (const file of changeset.files) log(` ${file.path} +${file.additions} -${file.deletions}`);
927
+ log(`verify: ${verifyWords(verified)}`);
928
+ if (budget) log('The fix stopped early because its model budget was spent or nearly spent, so read the diff carefully: it may be half a change.');
929
+ log('Nothing was committed and nothing was pushed. Read the diff, commit it, then:');
930
+ log(` gh pr create --title "${String(changeset.pr_title).replace(/"/g, '\\"')}" --body-file ${reportPath}`);
931
+ }
932
+
933
+ /**
934
+ * Complete the fix run, tolerating a refusal. The money is settled by
935
+ * `finish` either way, and a refused complete must not turn a fix that worked
936
+ * into a crash.
937
+ */
938
+ async function completeFixRun({ api, gameId, jobId, runId, body, log }) {
939
+ try {
940
+ await api.runs.complete(gameId, jobId, runId, body);
941
+ } catch (error) {
942
+ log(`the fix run could not be completed (${String(error && error.message)}); the hourly sweep will settle it.`);
943
+ }
944
+ }
945
+
946
+ async function failFixRun({ api, gameId, jobId, runId, log }) {
947
+ await completeFixRun({ api, gameId, jobId, runId, body: { state: 'failed', reason: 'error' }, log });
948
+ }
949
+
950
+ /**
951
+ * A run this job paid for and never played. Saying so is what gets it
952
+ * refunded now rather than by the stall sweep half an hour later.
953
+ */
954
+ async function skipRun({ api, gameId, jobId, run, reason, log }) {
955
+ if (!run) return;
956
+ try {
957
+ await api.runs.transition(gameId, jobId, run.run_id, 'skipped', { reason });
958
+ } catch (error) {
959
+ log(`the verify run could not be marked skipped (${String(error && error.message)}).`);
960
+ }
961
+ }
962
+
963
+ /** `finish` settles the job once and issues at most one refund. */
964
+ async function settle({ api, gameId, jobId, repoRoot, journal, reason, log }) {
965
+ try {
966
+ const finished = await api.jobs.finish(gameId, jobId, { reason });
967
+ log(`job ${jobId} ${finished.job ? finished.job.state : 'finished'}: refunded ${formatCents(finished.refunded_cents)}.`);
968
+ } catch (error) {
969
+ log(`the job could not be settled now; the hourly sweep will settle it: ${String(error && error.message)}`);
970
+ }
971
+ journal.finished = true;
972
+ try {
973
+ await stateFile.save(repoRoot, journal);
974
+ } catch {
975
+ // The journal is a convenience at this point: the server's ledger is the
976
+ // record of what was charged and what came back.
977
+ }
978
+ }
979
+
980
+ /** The finding as the prompt sees it: no ids nobody can act on, no noise. */
981
+ export function findingForPrompt(finding) {
982
+ return {
983
+ finding_id: finding.finding_id,
984
+ label: finding.label || null,
985
+ title: finding.title || null,
986
+ severity: finding.severity || null,
987
+ kind: finding.kind || null,
988
+ description: finding.description || null,
989
+ expected: finding.expected || null,
990
+ actual: finding.actual || null,
991
+ evidence: finding.evidence || null
992
+ };
993
+ }
994
+
995
+ /**
996
+ * @param {Object} finding
997
+ * @returns {Array<string>}
998
+ */
999
+ export function reproStepsOf(finding) {
1000
+ const steps = finding.repro_steps ?? finding.repro ?? null;
1001
+ if (Array.isArray(steps)) return steps.map(step => (typeof step === 'string' ? step : String(step && step.text ? step.text : JSON.stringify(step)))).filter(Boolean);
1002
+ if (typeof steps === 'string' && steps.trim()) return steps.split('\n').map(line => line.trim()).filter(Boolean);
1003
+ return [];
1004
+ }
1005
+
1006
+ /**
1007
+ * Design intent comes from the pack and from the brief, and both are binding:
1008
+ * a fix that "fixes" something the developer meant is worse than no fix.
1009
+ *
1010
+ * @param {Object} pack
1011
+ * @param {Object|null} brief
1012
+ * @returns {Array<string>}
1013
+ */
1014
+ export function intentNotesFor(pack, brief) {
1015
+ const fromBrief = ((brief && brief.fields && brief.fields.design_intent_notes) || []).map(note => (typeof note === 'string' ? note : note && note.text)).filter(Boolean);
1016
+ const fromPack = (pack.intent_notes || []).map(note => (typeof note === 'string' ? note : note && note.text)).filter(Boolean);
1017
+ return [...fromBrief, ...fromPack];
1018
+ }
1019
+
1020
+ /**
1021
+ * The level graph for this job, or null.
1022
+ *
1023
+ * @returns {Promise<Object|null>}
1024
+ */
1025
+ export async function jobGraphContext({ deps, api, gameId, jobId, log = () => {} }) {
1026
+ try {
1027
+ const bundle = await deps.packs.get({ api, gameId, modules: PACK_MODULES, jobId });
1028
+ return (bundle && bundle.graph_context) || null;
1029
+ } catch (error) {
1030
+ log(`The level graph for this job could not be read (${String(error && error.message)}), so the plan is made without it.`);
1031
+ return null;
1032
+ }
1033
+ }
1034
+
1035
+ /**
1036
+ * The server's per-field reasons for refusing a changeset, as lines.
1037
+ *
1038
+ * @param {any} error
1039
+ * @returns {Array<string>}
1040
+ */
1041
+ export function changesetDetails(error) {
1042
+ if (!error || error.code !== 'invalid_changeset') return [];
1043
+ const details = (error.body && error.body.details) || error.details || [];
1044
+ return (Array.isArray(details) ? details : [])
1045
+ .map(detail => [detail.instancePath, detail.code, detail.message].filter(Boolean).join(': '))
1046
+ .filter(Boolean);
1047
+ }
1048
+
1049
+ function isImageName(name) {
1050
+ return /\.(png|jpe?g|webp)$/i.test(String(name));
1051
+ }
1052
+
1053
+ function mediaTypeFor(name) {
1054
+ if (/\.jpe?g$/i.test(name)) return 'image/jpeg';
1055
+ if (/\.webp$/i.test(name)) return 'image/webp';
1056
+ return 'image/png';
1057
+ }
1058
+
1059
+ export default fixCommand;