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.
- package/README.md +111 -2
- package/package.json +1 -1
- package/src/api/client.js +28 -0
- package/src/auth/scopes.js +36 -0
- package/src/cli.js +101 -8
- package/src/commands/check.js +5 -1
- package/src/commands/fakeCore.js +5 -1
- package/src/commands/fix.js +71 -2
- package/src/commands/hosted.js +143 -2
- package/src/commands/lists.js +206 -0
- package/src/commands/profile.js +2 -1
- package/src/commands/punch.js +116 -0
- package/src/commands/run.js +86 -5
- package/src/commands/tape.js +74 -0
- package/src/hosted/recordedRun.js +132 -3
- package/src/hosted/status.js +108 -0
- package/src/run/aggregate.js +69 -10
- package/src/run/budgetReserve.js +102 -0
- package/src/run/drivers/web.js +26 -3
- package/src/run/failureReason.js +77 -0
- package/src/run/gameRefusal.js +57 -0
- package/src/run/personaLoop.js +255 -59
- package/src/run/regenerate.js +4 -1
- package/src/run/report.js +54 -0
- package/src/run/usage.js +54 -0
package/src/commands/hosted.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/profile.js
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/src/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|