ravensight-playtest 0.6.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,11 @@ import { resolveGameId } from '../config.js';
3
3
  import { withAuthHandling } from '../auth/session.js';
4
4
  import { CliError, ExitCode } from '../errors.js';
5
5
  import { ui } from '../ui/index.js';
6
- import { quoteHostedRun, runHostedQuote, hostedQuoteLines } from '../hosted/recordedRun.js';
6
+ import { quoteHostedRun, runHostedQuote, hostedQuoteLines, prepareHostedRun, admitHostedOffer, confirmQuestion,
7
+ hostedConsentLines, fundingShortfall, readHostedRun, cancelHostedRun } from '../hosted/recordedRun.js';
8
+ import { hostedPlaytestStatus, modeOutcomes, modeLine, TERMINAL_PHASES } from '../hosted/status.js';
9
+ import { jobLink } from '../dashboard.js';
10
+ import { DEFAULT_PERSONAS } from '../runDefaults.js';
7
11
 
8
12
  export async function hostedQuote(flags = {}, deps = {}) {
9
13
  const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
@@ -11,7 +15,7 @@ export async function hostedQuote(flags = {}, deps = {}) {
11
15
  const result = await quoteHostedRun({ api: context.api, apiUrl: context.api.apiUrl,
12
16
  gameId: resolveGameId(flags, context.config), buildId: flags.build, profileVersion: flags.profile,
13
17
  persona: flags.persona, standardProvider: flags.standardProvider, includeVideo: Boolean(flags.video),
14
- includeTranscript: Boolean(flags.transcript), output: flags.output });
18
+ output: flags.output });
15
19
  if (flags.json) ui.json(result);
16
20
  else {
17
21
  hostedQuoteLines(result).forEach(line => ui.info(line));
@@ -37,3 +41,140 @@ export async function hostedRun(filename, flags = {}, deps = {}) {
37
41
  return result.state === 'declined' ? ExitCode.CANCELED : ExitCode.OK;
38
42
  });
39
43
  }
44
+
45
+ const POLL_MS = 15000;
46
+ const dollars = cents => `$${(cents / 100).toFixed(2)}`;
47
+ const sleepFor = ms => new Promise(resolve => { const timer = setTimeout(resolve, ms); if (timer.unref) timer.unref(); });
48
+
49
+ /** One status object for --json and for the printed lines. */
50
+ function statusView(gameId, apiUrl, { admission, job, runs }, now = Date.now()) {
51
+ const status = hostedPlaytestStatus(admission, job, runs, now);
52
+ return {
53
+ admissionId: admission.admissionId,
54
+ jobId: admission.jobId || null,
55
+ gameId,
56
+ buildId: admission.buildId || null,
57
+ phase: status.phase,
58
+ label: status.label,
59
+ detail: status.detail,
60
+ terminal: status.terminal,
61
+ settled: status.settled,
62
+ cancelRequested: status.cancelRequested,
63
+ failure: status.failure || null,
64
+ elapsedSeconds: status.elapsedSeconds,
65
+ totalPriceCents: Number.isSafeInteger(admission.totalPriceCents) ? admission.totalPriceCents : null,
66
+ modes: modeOutcomes(runs, Array.isArray(admission.controllers) ? admission.controllers : ['standard', 'jev']),
67
+ summaryReport: job?.summary_report ?? null,
68
+ reportUrl: admission.jobId ? jobLink(gameId, admission.jobId, apiUrl) : null
69
+ };
70
+ }
71
+
72
+ function printOutcome(view) {
73
+ ui.info(`${view.label}. ${view.detail}`);
74
+ if (view.failure) ui.info(`Reason: ${view.failure}`);
75
+ if (view.cancelRequested && view.phase !== 'canceled') ui.info('Cancellation was requested.');
76
+ if (TERMINAL_PHASES.includes(view.phase) || view.modes.some(row => row.state !== 'not_started')) view.modes.forEach(row => ui.info(modeLine(row)));
77
+ if (TERMINAL_PHASES.includes(view.phase) && !view.settled) ui.info('Refunds and cleanup are still settling.');
78
+ if (view.reportUrl) ui.info(`Report: ${view.reportUrl}`);
79
+ }
80
+
81
+ const exitFor = view => view.phase === 'canceled' ? ExitCode.CANCELED : view.phase === 'failed' ? ExitCode.FAILURE : ExitCode.OK;
82
+
83
+ /** Poll until the phase is terminal, printing each phase change once. */
84
+ async function waitForRun({ api, gameId, admissionId, apiUrl, sleep, now, onPhase }) {
85
+ let last = null;
86
+ for (;;) {
87
+ const view = statusView(gameId, apiUrl, await readHostedRun({ api, gameId, admissionId }), now());
88
+ if (view.phase !== last) { last = view.phase; onPhase(view); }
89
+ if (TERMINAL_PHASES.includes(view.phase)) return view;
90
+ await sleep();
91
+ }
92
+ }
93
+
94
+ const phaseLine = flags => row => { if (!flags.json) ui.info(`${row.label}: ${row.detail}`); };
95
+
96
+ /**
97
+ * `hosted run` with no saved quote: the newest eligible build (or --build), a
98
+ * fresh quote, the price and consent printed from it, one confirmation, one
99
+ * admission. The admission keeps the saved-quote path's journal and never retries.
100
+ */
101
+ export async function hostedRunDirect(flags = {}, deps = {}) {
102
+ if (flags.json && !flags.yes && !deps.confirm) throw new CliError('A hosted run charges prepaid funds. Use --yes with --json. Nothing was charged.');
103
+ const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
104
+ const now = deps.now || Date.now, sleep = deps.sleep || (() => sleepFor(POLL_MS));
105
+ return withAuthHandling(context, async () => {
106
+ const api = context.api, apiUrl = api.apiUrl, gameId = resolveGameId(flags, context.config);
107
+ const { offer, build, funding } = await prepareHostedRun({ api, apiUrl, gameId, buildId: flags.build,
108
+ persona: flags.persona || DEFAULT_PERSONAS[0], includeVideo: Boolean(flags.video), now });
109
+ if (!flags.json) {
110
+ ui.info(`Build ${build.buildId}${build.entrypoint ? ` (${build.entrypoint})` : ''}${flags.build ? '' : ', the newest ready build'}.`);
111
+ hostedQuoteLines(offer).forEach(line => ui.info(line));
112
+ hostedConsentLines(offer).forEach(line => ui.info(line));
113
+ }
114
+ const short = fundingShortfall(offer.quote, funding);
115
+ if (short) {
116
+ const topUp = short.requiredTopUpCents ? ` Add at least ${dollars(short.requiredTopUpCents)} of prepaid funds, then run this again.` : ' Add prepaid funds, then run this again.';
117
+ throw new CliError(`The balance does not meet this hosted run's funding requirement. Nothing was charged.${topUp}`, ExitCode.ENVIRONMENT);
118
+ }
119
+ const confirm = deps.confirm || (question => ui.confirm(question, { yes: Boolean(flags.yes) }));
120
+ if (!await confirm(confirmQuestion(offer))) {
121
+ if (flags.json) ui.json({ state: 'declined', gameId, buildId: build.buildId, walletMutationRequested: false });
122
+ else ui.info('Declined. Nothing was charged.');
123
+ return ExitCode.CANCELED;
124
+ }
125
+ const admitted = await admitHostedOffer({ offer, api, apiUrl, repoRoot: context.repoRoot, now });
126
+ const reportUrl = jobLink(gameId, admitted.jobId, apiUrl);
127
+ const result = { state: admitted.state, admissionId: admitted.admissionId, jobId: admitted.jobId, gameId, buildId: build.buildId,
128
+ persona: offer.request.personas[0], totalPriceCents: admitted.totalPriceCents, dueNowCents: offer.quote.dueNowCents,
129
+ journal: admitted.journal, reportUrl };
130
+ if (!flags.json) {
131
+ ui.ok(`Paid ${dollars(offer.quote.dueNowCents)}. Hosted run ${admitted.admissionId}, job ${admitted.jobId}.`);
132
+ ui.info(`Check on it with: ravensight-playtest hosted status ${admitted.admissionId}`);
133
+ }
134
+ if (!flags.wait) {
135
+ if (flags.json) ui.json(result);
136
+ else ui.info(`Report: ${reportUrl}`);
137
+ return ExitCode.OK;
138
+ }
139
+ const view = await waitForRun({ api, gameId, admissionId: admitted.admissionId, apiUrl, sleep, now, onPhase: phaseLine(flags) });
140
+ if (flags.json) ui.json({ ...result, status: view });
141
+ else printOutcome(view);
142
+ return exitFor(view);
143
+ });
144
+ }
145
+
146
+ export async function hostedStatus(admissionId, flags = {}, deps = {}) {
147
+ const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
148
+ const now = deps.now || Date.now, sleep = deps.sleep || (() => sleepFor(POLL_MS));
149
+ return withAuthHandling(context, async () => {
150
+ const api = context.api, apiUrl = api.apiUrl, gameId = resolveGameId(flags, context.config);
151
+ const view = flags.wait
152
+ ? await waitForRun({ api, gameId, admissionId, apiUrl, sleep, now, onPhase: phaseLine(flags) })
153
+ : statusView(gameId, apiUrl, await readHostedRun({ api, gameId, admissionId }), now());
154
+ if (flags.json) ui.json(view); else printOutcome(view);
155
+ return flags.wait ? exitFor(view) : ExitCode.OK;
156
+ });
157
+ }
158
+
159
+ export async function hostedCancel(admissionId, flags = {}, deps = {}) {
160
+ if (flags.json && !flags.yes && !deps.confirm) throw new CliError('Use --yes with --json to cancel a hosted run. Nothing was canceled.');
161
+ const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
162
+ const now = deps.now || Date.now;
163
+ return withAuthHandling(context, async () => {
164
+ const api = context.api, apiUrl = api.apiUrl, gameId = resolveGameId(flags, context.config);
165
+ const confirm = deps.confirm || (question => ui.confirm(question, { yes: Boolean(flags.yes) }));
166
+ if (!await confirm(`Cancel hosted run ${admissionId}? Work already completed follows the accepted policy; unstarted work is refunded during settlement.`)) {
167
+ if (flags.json) ui.json({ state: 'kept', admissionId });
168
+ else ui.info('Nothing was canceled.');
169
+ return ExitCode.CANCELED;
170
+ }
171
+ await cancelHostedRun({ api, gameId, admissionId });
172
+ const view = statusView(gameId, apiUrl, await readHostedRun({ api, gameId, admissionId }), now());
173
+ if (flags.json) ui.json(view);
174
+ else {
175
+ ui.ok(view.phase === 'canceled' ? `Hosted run ${admissionId} is canceled.` : `Cancellation requested for hosted run ${admissionId}.`);
176
+ printOutcome(view);
177
+ }
178
+ return ExitCode.OK;
179
+ });
180
+ }
@@ -0,0 +1,206 @@
1
+ import { createContext } from '../api/index.js';
2
+ import { ApiError } from '../api/errors.js';
3
+ import { resolveGameId } from '../config.js';
4
+ import { withAuthHandling } from '../auth/session.js';
5
+ import { CliError, ExitCode } from '../errors.js';
6
+ import { ui } from '../ui/index.js';
7
+
8
+ /**
9
+ * What `punch` and `tape` share: the game, the client, the refusals in plain
10
+ * words, and how an item is printed.
11
+ *
12
+ * The punch list holds what has to be finished before handover, closed one at
13
+ * a time. Blue tape holds small first-impression annoyances, captured fast and
14
+ * promoted to the punch list when they matter. Both live on the server under
15
+ * the game's Labs lists, so the dashboard and this CLI read the same items.
16
+ */
17
+
18
+ export const LIST_NAMES = Object.freeze({ punch: 'punch list', tape: 'blue tape list' });
19
+ export const STATUSES = Object.freeze(['open', 'done', 'dropped', 'all']);
20
+ export const ITEM_ID = /^li_[A-Za-z0-9_-]+$/;
21
+ export const LIMIT = 500;
22
+
23
+ /**
24
+ * Run `fn` with the client and the game this repo is linked to (or `--game`).
25
+ * @param {Object} flags
26
+ * @param {Object} deps test seam: context
27
+ * @param {(setup: {api: Object, gameId: string}) => Promise<number>} fn
28
+ * @returns {Promise<number>}
29
+ */
30
+ export async function withList(flags, deps, fn) {
31
+ const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
32
+ return withAuthHandling(context, async () => {
33
+ const gameId = resolveGameId(flags, context.config);
34
+ return fn({ api: context.api, gameId });
35
+ });
36
+ }
37
+
38
+ /**
39
+ * A refusal from the lists routes as a sentence a person can act on. Anything
40
+ * this does not recognise is handed back unchanged for `report` to print.
41
+ *
42
+ * @param {unknown} error
43
+ * @param {{list: string, itemId?: string|null}} where
44
+ * @returns {unknown}
45
+ */
46
+ export function listError(error, { list, itemId = null }) {
47
+ if (!(error instanceof ApiError) && !(error && typeof error.status === 'number' && typeof error.code === 'string')) return error;
48
+ const name = LIST_NAMES[list] || 'list';
49
+ if (error.code === 'labs_not_enabled') {
50
+ return new CliError(
51
+ `The ${name} is part of Ravensight Labs (beta). Labs is not on for this game.`,
52
+ ExitCode.ENVIRONMENT,
53
+ { hint: 'Turn Labs on from the Labs page in the dashboard. Nothing was changed.' }
54
+ );
55
+ }
56
+ if (error.code === 'item_closed') {
57
+ return new CliError(`${itemId || 'That item'} is closed. Reopen it first, then promote it.`, ExitCode.FAILURE);
58
+ }
59
+ if (error.code === 'quota_exceeded') {
60
+ return new CliError('This account has no AI units left. Add funds, then try again. Nothing was charged.', ExitCode.FAILURE);
61
+ }
62
+ if (error.code === 'labs_list_attempts') {
63
+ return new CliError('Too many drafts came back empty this hour. Try again later. Nothing was charged.', ExitCode.FAILURE);
64
+ }
65
+ if (error.code === 'ai_unavailable') {
66
+ return new CliError('The AI service is not available right now. Try again later. Nothing was charged.', ExitCode.FAILURE);
67
+ }
68
+ if (error.code === 'list_full') {
69
+ return new CliError(
70
+ `The ${name} already has ${LIMIT} open items. Close or drop some, then add this one.`,
71
+ ExitCode.FAILURE
72
+ );
73
+ }
74
+ if (error.status === 404) {
75
+ if (itemId) {
76
+ return new CliError(
77
+ `No item ${itemId} is on this game's ${name}.`,
78
+ ExitCode.FAILURE,
79
+ { hint: `Run "ravensight-playtest ${list} list --status all" to see the item ids.` }
80
+ );
81
+ }
82
+ return new CliError(
83
+ `This game has no ${name} that this token can see.`,
84
+ ExitCode.FAILURE,
85
+ { hint: 'Run "ravensight-playtest check" to confirm the game is linked. An older server has no lists yet.' }
86
+ );
87
+ }
88
+ return error;
89
+ }
90
+
91
+ /**
92
+ * Call the server and turn its refusals into sentences.
93
+ * @template T
94
+ * @param {() => Promise<T>} call
95
+ * @param {{list: string, itemId?: string|null}} where
96
+ * @returns {Promise<T>}
97
+ */
98
+ export async function guarded(call, where) {
99
+ try {
100
+ return await call();
101
+ } catch (error) {
102
+ throw listError(error, where);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Refuse an id that cannot be an item before asking the server about it.
108
+ * @param {string} itemId
109
+ */
110
+ export function requireItemId(itemId) {
111
+ const value = String(itemId || '').trim();
112
+ if (!ITEM_ID.test(value)) {
113
+ throw new CliError(`"${value}" is not an item id. Item ids start with li_.`, ExitCode.FAILURE, {
114
+ hint: 'Run "ravensight-playtest punch list" or "ravensight-playtest tape list" to see them.'
115
+ });
116
+ }
117
+ return value;
118
+ }
119
+
120
+ /**
121
+ * Refuse empty text here rather than spend a round trip on it.
122
+ * @param {string} text
123
+ */
124
+ export function requireText(text) {
125
+ const value = String(text || '').trim();
126
+ if (!value) throw new CliError('Say what the item is, in quotes.', ExitCode.FAILURE);
127
+ return value;
128
+ }
129
+
130
+ /** @param {string|undefined} status */
131
+ export function requireStatus(status) {
132
+ if (status === undefined || status === null) return 'open';
133
+ if (!STATUSES.includes(status)) {
134
+ throw new CliError(`--status takes ${STATUSES.join(', ')}.`, ExitCode.FAILURE);
135
+ }
136
+ return status;
137
+ }
138
+
139
+ /** Where an item came from, in a few words. */
140
+ export function sourceWords(source) {
141
+ if (!source || !source.kind || source.kind === 'manual') return 'manual';
142
+ const kind = String(source.kind).replace(/_/g, ' ');
143
+ return source.ref ? `${kind} ${source.ref}` : kind;
144
+ }
145
+
146
+ /** The plain-language hints under an item. A hint, never a block. */
147
+ export function printHints(item) {
148
+ const issues = item && item.plain_check && Array.isArray(item.plain_check.issues) ? item.plain_check.issues : [];
149
+ for (const issue of issues) {
150
+ if (issue && issue.message) ui.info(` hint: ${issue.message}`);
151
+ }
152
+ return issues.length;
153
+ }
154
+
155
+ /**
156
+ * Print a list answer as a table, or say it is empty.
157
+ * @param {Object} answer `{ items, counts }`
158
+ * @param {string} list
159
+ * @param {string} status
160
+ */
161
+ export function printList(answer, list, status) {
162
+ const items = answer && Array.isArray(answer.items) ? answer.items : [];
163
+ const name = LIST_NAMES[list];
164
+ if (items.length === 0) {
165
+ ui.info(status === 'all' ? `The ${name} is empty.` : `The ${name} has no ${status} items.`);
166
+ } else {
167
+ ui.table(items.map(item => ({
168
+ item_id: item.item_id,
169
+ status: item.status,
170
+ text: item.text,
171
+ source: sourceWords(item.source) + (item.promoted_to ? `, now ${item.promoted_to}` : '') + (item.fix_job_id ? `, fix ${item.fix_job_id}` : '')
172
+ })), [
173
+ { key: 'item_id', label: 'ITEM' },
174
+ { key: 'status', label: 'STATUS' },
175
+ { key: 'text', label: 'TEXT' },
176
+ { key: 'source', label: 'SOURCE' }
177
+ ]);
178
+ }
179
+ const counts = answer && answer.counts;
180
+ if (counts) {
181
+ ui.blank();
182
+ ui.info(`${counts.open ?? 0} open, ${counts.done ?? 0} done, ${counts.dropped ?? 0} dropped.`);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Find one item by id, for `fix li_...`. The punch list first, because that is
188
+ * where a finding to fix usually sits, then blue tape on a 404.
189
+ *
190
+ * @param {Object} api
191
+ * @param {string} gameId
192
+ * @param {string} itemId
193
+ * @returns {Promise<{item: Object, list: string}|null>}
194
+ */
195
+ export async function findListItem(api, gameId, itemId) {
196
+ for (const list of ['punch', 'tape']) {
197
+ try {
198
+ const answer = await api.labs.lists.get(gameId, list, itemId);
199
+ if (answer && answer.item) return { item: answer.item, list };
200
+ } catch (error) {
201
+ if (error && error.status === 404) continue;
202
+ throw listError(error, { list, itemId });
203
+ }
204
+ }
205
+ return null;
206
+ }
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { mkdir } from 'node:fs/promises';
3
3
  import { optionsFrom, unknownOptionNotes } from '../run/args.js';
4
4
  import { settleFailureLine } from '../run/settle.js';
5
+ import { failureReason } from '../run/failureReason.js';
5
6
  import { getDeps } from '../run/deps.js';
6
7
  import EXIT from '../run/exit.js';
7
8
  import { createHeartbeat } from '../run/heartbeat.js';
@@ -234,7 +235,7 @@ async function safeFail({ api, gameId, jobId, runId, error, log }) {
234
235
  try {
235
236
  await api.runs.complete(gameId, jobId, runId, {
236
237
  state: 'failed',
237
- reason: String(error && error.message).slice(0, 400),
238
+ reason: failureReason(error, { fallback: 'the profile stopped on an error' }),
238
239
  quit_reason: 'error'
239
240
  });
240
241
  } catch (completeError) {
@@ -0,0 +1,116 @@
1
+ import { ui } from '../ui/index.js';
2
+ import { ExitCode } from '../errors.js';
3
+ import { guarded, printHints, printList, requireItemId, requireStatus, requireText, sourceWords, withList } from './lists.js';
4
+
5
+ /**
6
+ * `ravensight-playtest punch`: the punch list (Labs beta).
7
+ *
8
+ * What has to be finished before handover, closed one item at a time. Reading,
9
+ * adding and closing are free. `draft` asks for proposals from the game's
10
+ * findings and specs, which is one AI unit, and saves nothing unless
11
+ * `--accept` is passed. Nothing here closes an item on its own: `fix li_...`
12
+ * records the fix job on the item and leaves closing it to a person.
13
+ */
14
+
15
+ const LIST = 'punch';
16
+
17
+ /** `punch list [--status open|done|dropped|all]` */
18
+ export async function punchList(flags = {}, deps = {}) {
19
+ const status = requireStatus(flags.status);
20
+ return withList(flags, deps, async ({ api, gameId }) => {
21
+ const answer = await guarded(() => api.labs.lists.list(gameId, LIST, { status }), { list: LIST });
22
+ if (flags.json) {
23
+ ui.json(answer);
24
+ return ExitCode.OK;
25
+ }
26
+ printList(answer, LIST, status);
27
+ return ExitCode.OK;
28
+ });
29
+ }
30
+
31
+ /** `punch add "<text>"` */
32
+ export async function punchAdd(text, flags = {}, deps = {}) {
33
+ const value = requireText(text);
34
+ return withList(flags, deps, async ({ api, gameId }) => {
35
+ const answer = await guarded(() => api.labs.lists.add(gameId, LIST, { text: value }), { list: LIST });
36
+ if (flags.json) {
37
+ ui.json(answer);
38
+ return ExitCode.OK;
39
+ }
40
+ printAdded(answer);
41
+ return ExitCode.OK;
42
+ });
43
+ }
44
+
45
+ /** `punch done <li_id>` and `punch drop <li_id>` */
46
+ export async function punchClose(itemId, status, flags = {}, deps = {}) {
47
+ const id = requireItemId(itemId);
48
+ return withList(flags, deps, async ({ api, gameId }) => {
49
+ const answer = await guarded(() => api.labs.lists.update(gameId, LIST, id, { status }), { list: LIST, itemId: id });
50
+ if (flags.json) {
51
+ ui.json(answer);
52
+ return ExitCode.OK;
53
+ }
54
+ const text = answer && answer.item && answer.item.text ? `: ${answer.item.text}` : '';
55
+ ui.ok(`${status === 'done' ? 'Done' : 'Dropped'} ${id}${text}`);
56
+ return ExitCode.OK;
57
+ });
58
+ }
59
+
60
+ /** `punch draft [--accept]` */
61
+ export async function punchDraft(flags = {}, deps = {}) {
62
+ return withList(flags, deps, async ({ api, gameId }) => {
63
+ if (!flags.json) {
64
+ ui.info('Drafting a punch list is one AI unit. It is not charged when there is nothing to propose.');
65
+ ui.info(flags.accept ? 'Every proposal will be added to the punch list.' : 'Nothing is saved. Pass --accept to add the proposals.');
66
+ }
67
+ const confirm = deps.confirm || ui.confirm;
68
+ if (!flags.yes && !(await confirm('Draft the punch list?', { yes: flags.yes }))) {
69
+ ui.info('Nothing was charged. Pass --yes to draft without a terminal.');
70
+ return ExitCode.CANCELED;
71
+ }
72
+ const answer = await guarded(() => api.labs.lists.draft(gameId), { list: LIST });
73
+ const proposals = answer && Array.isArray(answer.proposals) ? answer.proposals : [];
74
+ const added = [];
75
+ if (flags.accept) {
76
+ try {
77
+ for (const proposal of proposals) {
78
+ const body = proposal.source ? { text: proposal.text, source: proposal.source } : { text: proposal.text };
79
+ added.push(await guarded(() => api.labs.lists.add(gameId, LIST, body), { list: LIST }));
80
+ }
81
+ } catch (error) {
82
+ // Say what was saved before the refusal, so nobody adds it twice.
83
+ if (!flags.json) for (const saved of added) printAdded(saved);
84
+ throw error;
85
+ }
86
+ }
87
+ if (flags.json) {
88
+ ui.json({ ...answer, ...(flags.accept ? { added } : {}) });
89
+ return ExitCode.OK;
90
+ }
91
+ if (proposals.length === 0) {
92
+ ui.info('No proposals, so nothing was charged.');
93
+ return ExitCode.OK;
94
+ }
95
+ const units = Number(answer.units) || 0;
96
+ ui.info(`${proposals.length} proposal${proposals.length === 1 ? '' : 's'}, ${units} AI unit${units === 1 ? '' : 's'} charged:`);
97
+ if (flags.accept) {
98
+ for (const saved of added) printAdded(saved);
99
+ return ExitCode.OK;
100
+ }
101
+ proposals.forEach((proposal, index) => {
102
+ ui.info(` ${index + 1}. ${proposal.text} (${sourceWords(proposal.source)})`);
103
+ });
104
+ ui.blank();
105
+ ui.info('Nothing was saved. Add the ones you want with "ravensight-playtest punch add".');
106
+ return ExitCode.OK;
107
+ });
108
+ }
109
+
110
+ /** One added item, with its plain-language hints under it. */
111
+ export function printAdded(answer) {
112
+ const item = answer && answer.item ? answer.item : {};
113
+ if (answer && answer.existing) ui.info(`Already on the list: ${item.item_id}, ${item.text}`);
114
+ else ui.ok(`Added ${item.item_id}: ${item.text}`);
115
+ if (printHints(item) > 0) ui.info(' These hints follow the rules of Simplified Technical English. They never block an item.');
116
+ }
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { buildAggregate, writeAggregate } from '../run/aggregate.js';
6
6
  import { reportPrOutcomes } from '../run/labsOutcome.js';
7
7
  import { finishJob } from '../run/settle.js';
8
+ import { driverStartReason, failureReason } from '../run/failureReason.js';
8
9
  import { optionsFrom, unknownOptionNotes } from '../run/args.js';
9
10
  import { controllerPlan, quoteMatchesPlan, runsMatchPlan } from '../run/controllers.js';
10
11
  import { getDeps } from '../run/deps.js';
@@ -19,8 +20,10 @@ import { configureSpendHold, createModelClient, ModelUnavailableError, ProxyErro
19
20
  import { jobDir, runDir as runDirFor, JOB_FILES, RUN_FILES } from '../run/paths.js';
20
21
  import { readBridgeConfig } from "../run/drivers/stdio.js";
21
22
  import { WEBDRIVER_NAMES, readDriverConfig } from '../run/drivers/webdriver.js';
22
- import { automationChecks } from './check.js';
23
+ import { BROWSER_FIX, automationChecks } from './check.js';
23
24
  import { runPersona } from '../run/personaLoop.js';
25
+ import { runModelBudgetUsd } from '../run/budgetReserve.js';
26
+ import { missingScopeMessage, missingScopes } from '../auth/scopes.js';
24
27
  import { tracesWanted } from '../run/acceptance.js';
25
28
  import { cleanCommit } from '../run/replay.js';
26
29
  import { loadLocalBrief, localBriefProblems } from '../briefLocal.js';
@@ -102,6 +105,11 @@ async function run(flags, injected, deps, log) {
102
105
  const setup = await resolveSetup(deps, options, log);
103
106
  if (setup.code !== undefined) return setup.code;
104
107
  const { context, gameId, repoRoot, apiUrl, cliVersion, api } = setup;
108
+ const scopeRefusal = await tokenScopeRefusal(api);
109
+ if (scopeRefusal !== null) {
110
+ log(scopeRefusal.message);
111
+ return scopeRefusal.code;
112
+ }
105
113
 
106
114
  let controllers;
107
115
  try { controllers = controllerPlan(options); } catch (error) { log(error.message); return EXIT.ENVIRONMENT; }
@@ -678,7 +686,25 @@ export async function playPersonaRun({
678
686
  const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
679
687
  // A validate run plays its own side's checkout and its pair's seed.
680
688
  const runTarget = entry.target || target;
681
- const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target: runTarget, directory, entry, jobId, keyModifier: options.keyModifier, godotPath: options.godotPath, realtime: options.realtime }));
689
+ let driver;
690
+ try {
691
+ driver = await makeDriver(driverName, driverOptions({ headless, driverName, target: runTarget, directory, entry, jobId, keyModifier: options.keyModifier, godotPath: options.godotPath, realtime: options.realtime }));
692
+ } catch (error) {
693
+ // A driver that cannot even be built (the browser package missing, say) is
694
+ // what a new machine hits first. The run is completed as failed now, with
695
+ // what to do, rather than left queued for the server's sweep.
696
+ if (inFlight) inFlight.delete(runId);
697
+ const reason = driverStartReason(driverName, error, { browserFix: BROWSER_FIX });
698
+ log(`run ${runId} (${persona.slug}) failed: ${reason}`);
699
+ await stateFile.patchRun(repoRoot, journal, runId, { state: 'failed', error: reason, quit_reason: 'error', report_written: false }).catch(() => {});
700
+ try {
701
+ await api.runs.complete(gameId, jobId, runId, { state: 'failed', reason, actions_taken: 0, quit_reason: 'error' });
702
+ await stateFile.patchRun(repoRoot, journal, runId, { completed: true }).catch(() => {});
703
+ } catch (completeError) {
704
+ log(`run ${runId} (${persona.slug}): could not be completed as failed (${String(completeError && completeError.message)}). "ravensight-playtest resume" can retry it.`);
705
+ }
706
+ return { runId, persona: persona.slug, state: 'failed', quitReason: 'error', error };
707
+ }
682
708
  // The ledger is created here rather than inside the loop so the heartbeat
683
709
  // can feed it the server's own figure for what this run has spent. Prices
684
710
  // are server side only, so that answer is the only real one.
@@ -719,6 +745,7 @@ export async function playPersonaRun({
719
745
  signal: options.signal,
720
746
  client,
721
747
  api,
748
+ modelBudgetUsd: runModelBudgetUsd(entry),
722
749
  gameId,
723
750
  jobId,
724
751
  runId,
@@ -757,7 +784,18 @@ export async function playPersonaRun({
757
784
  return { runId, persona: persona.slug, state: 'interrupted', error };
758
785
  }
759
786
  if (inFlight) inFlight.delete(runId);
760
- const failed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
787
+ const crashed = error.outcome || { state: 'failed', quitReason: 'error', actionsTaken: 0, report: null, reportLint: [], reachedState: 'playing' };
788
+ // The server stores this as the run's reason and the dashboard shows it. A
789
+ // crash outside the persona loop's own handler carries no detail, so the
790
+ // caught error's message stands in, redacted and cut to 400 characters.
791
+ // A run the server holds at `launching` never got its game or browser up,
792
+ // so the reason also says what to do about it.
793
+ const failed = {
794
+ ...crashed,
795
+ quitDetail: crashed.reachedState === 'launching'
796
+ ? driverStartReason(driverName, error, { detail: crashed.quitDetail, browserFix: BROWSER_FIX })
797
+ : failureReason(error, { detail: crashed.quitDetail })
798
+ };
761
799
  await stateFile.patchRun(repoRoot, journal, runId, {
762
800
  state: failed.state,
763
801
  error: String(error && error.message),
@@ -894,6 +932,22 @@ export function durationBody(duration) {
894
932
  return { wall_seconds: value('wall_seconds'), model_seconds: value('model_seconds') };
895
933
  }
896
934
 
935
+ /**
936
+ * The reason `complete` sends. The server stores it as the run's
937
+ * `stateReason` and the dashboard shows it as why the run ended, so a run that
938
+ * succeeded sends none: in 0.7.1 a succeeded run sent its quit detail, which
939
+ * was the persona's whole closing message, report text included. Any other
940
+ * state sends its detail, redacted and cut like every other reason.
941
+ *
942
+ * @param {Object} outcome
943
+ * @returns {string}
944
+ */
945
+ export function completionReason(outcome) {
946
+ if (!outcome || outcome.state === 'succeeded') return '';
947
+ const detail = String(outcome.quitDetail || '').trim();
948
+ return detail ? failureReason(null, { detail }) : '';
949
+ }
950
+
897
951
  /**
898
952
  * Upload the run directory and complete the run, through cli-core's
899
953
  * `finalizeRun` so the 422 `upload_unverified` repair pass lives in one place.
@@ -913,7 +967,7 @@ export function durationBody(duration) {
913
967
  export async function deliver({ deps, api, gameId, jobId, runId, directory, outcome, options, log, journal, repoRoot, delivery = null, extraBody = null }) {
914
968
  const body = {
915
969
  state: outcome.state,
916
- reason: outcome.quitDetail || '',
970
+ reason: completionReason(outcome),
917
971
  actions_taken: outcome.actionsTaken,
918
972
  quit_reason: outcome.quitReason,
919
973
  ...(extraBody || {})
@@ -1352,6 +1406,30 @@ export async function godotRefusal({ driverName, godotPath, projectDir, injected
1352
1406
  *
1353
1407
  * @returns {Promise<Object>}
1354
1408
  */
1409
+ /**
1410
+ * A token that cannot finish a run is refused before anything is priced,
1411
+ * registered or charged (../auth/scopes.js). Null when the token has what a
1412
+ * run needs, or when its scopes cannot be read: an older server that lists
1413
+ * none, or a network failure, leaves the server's own checks in charge.
1414
+ *
1415
+ * @param {Object} api
1416
+ * @returns {Promise<{code: number, message: string}|null>}
1417
+ */
1418
+ export async function tokenScopeRefusal(api) {
1419
+ if (!api || !api.cli || typeof api.cli.whoami !== 'function') return null;
1420
+ let whoami;
1421
+ try {
1422
+ whoami = await api.cli.whoami();
1423
+ } catch (error) {
1424
+ if (error && (error.status === 401 || error.status === 403)) {
1425
+ return { code: EXIT.AUTH, message: `The server refused this login (${String(error.message || error.status)}). Log in again with "ravensight-playtest login". Nothing was registered or charged.` };
1426
+ }
1427
+ return null;
1428
+ }
1429
+ const missing = missingScopes(whoami && whoami.scopes);
1430
+ return missing && missing.length > 0 ? { code: EXIT.AUTH, message: missingScopeMessage(missing) } : null;
1431
+ }
1432
+
1355
1433
  export async function resolveSetup(deps, options, log) {
1356
1434
  let context;
1357
1435
  try {
@@ -1462,7 +1540,10 @@ export async function loadRunReports(repoRoot, jobId, personaRuns, journal = nul
1462
1540
  const file = path.join(runDirFor(repoRoot, jobId, entry.run_id), RUN_FILES.reportJson);
1463
1541
  try {
1464
1542
  const report = withoutRunnerEvidence(JSON.parse(await readFile(file, 'utf8')), await readRunnerEvidence(path.dirname(file)));
1465
- out.push({ runId: entry.run_id, persona: entry.persona, ...(entry.controller ? { controller: entry.controller } : {}), state: recorded?.state || entry.state || (report && report.partial === true ? 'budget_exceeded' : 'succeeded'), report });
1543
+ // report.md, for the aggregate's "How the personas felt": each persona's
1544
+ // in-character section in its own words.
1545
+ const markdown = await readFile(path.join(path.dirname(file), RUN_FILES.reportMd), 'utf8').catch(() => null);
1546
+ out.push({ runId: entry.run_id, persona: entry.persona, ...(entry.controller ? { controller: entry.controller } : {}), state: recorded?.state || entry.state || (report && report.partial === true ? 'budget_exceeded' : 'succeeded'), report, markdown });
1466
1547
  } catch {
1467
1548
  out.push({ runId: entry.run_id, persona: entry.persona, ...(entry.controller ? { controller: entry.controller } : {}), state: 'failed', report: null });
1468
1549
  }