ravensight-playtest 0.7.0 → 0.7.5
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 +61 -8
- package/package.json +3 -3
- package/src/auth/scopes.js +36 -0
- package/src/cli.js +42 -9
- package/src/commands/brief.js +1 -1
- package/src/commands/build.js +23 -1
- package/src/commands/check.js +5 -1
- package/src/commands/fakeCore.js +5 -1
- package/src/commands/fix.js +2 -0
- package/src/commands/hosted.js +144 -2
- package/src/commands/profile.js +2 -1
- package/src/commands/run.js +86 -5
- package/src/hosted/buildStatus.js +35 -0
- package/src/hosted/buildUpload.js +24 -2
- package/src/hosted/recordedRun.js +135 -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/context.js +122 -3
- package/src/run/drivers/web.js +342 -126
- package/src/run/failureReason.js +77 -0
- package/src/run/gameRefusal.js +57 -0
- package/src/run/model.js +18 -1
- package/src/run/openPr.js +8 -1
- package/src/run/personaLoop.js +333 -76
- package/src/run/regenerate.js +4 -1
- package/src/run/report.js +54 -0
- package/src/run/tools.js +31 -3
- package/src/run/usage.js +54 -0
- package/src/ui/index.js +21 -6
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
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { printable } from '../ui/index.js';
|
|
2
|
+
|
|
3
|
+
const base = game => `/api/v1/games/${encodeURIComponent(game)}/playtest/hosted`;
|
|
4
|
+
|
|
5
|
+
const LABELS = {
|
|
6
|
+
uploading: 'uploading',
|
|
7
|
+
awaiting_validation: 'waiting for its archive check',
|
|
8
|
+
ready: 'ready',
|
|
9
|
+
graphical_static_validated: 'archive check passed',
|
|
10
|
+
rejected: 'not accepted',
|
|
11
|
+
deleting: 'being removed'
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Server text is one fixed sentence; still print it as one plain line.
|
|
15
|
+
const line = text => printable(String(text || '')).replace(/\s+/g, ' ').trim().slice(0, 300);
|
|
16
|
+
|
|
17
|
+
/** The plain lines that describe one build: its state, why it was refused, and the hold. */
|
|
18
|
+
export function buildStatusLines(build) {
|
|
19
|
+
const state = build?.state === 'deleting' && build.rejection ? 'not accepted, being removed' : (LABELS[build?.state] || line(build?.state) || 'unknown');
|
|
20
|
+
const lines = [`${line(build?.buildId)}${build?.entrypoint ? ` (${line(build.entrypoint)})` : ''}: ${state}.`];
|
|
21
|
+
if (build?.rejection?.message) lines.push(` Why: ${line(build.rejection.message)}`);
|
|
22
|
+
else if (build?.state === 'awaiting_validation') lines.push(' Validation is required before a run. Check again in a minute.');
|
|
23
|
+
if (build?.holdReturn?.message) lines.push(` ${line(build.holdReturn.message)}`);
|
|
24
|
+
if (build?.safetyHold) lines.push(' A run of this build was stopped by the safety check, so it cannot be quoted until that hold is cleared.');
|
|
25
|
+
return lines;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The game's private hosted builds, newest first, optionally one of them. */
|
|
29
|
+
export async function readHostedBuilds({ api, gameId, buildId }) {
|
|
30
|
+
const answer = await api.request('GET', `${base(gameId)}/builds`, { idempotent: true });
|
|
31
|
+
const builds = (Array.isArray(answer.body?.builds) ? answer.body.builds : [])
|
|
32
|
+
.filter(build => build && typeof build.buildId === 'string')
|
|
33
|
+
.sort((a, b) => (Date.parse(b.createdAt) || 0) - (Date.parse(a.createdAt) || 0));
|
|
34
|
+
return buildId ? builds.filter(build => build.buildId === buildId) : builds;
|
|
35
|
+
}
|
|
@@ -42,6 +42,28 @@ export async function putArchive(archive, transfer, { fetchImpl = globalThis.fet
|
|
|
42
42
|
} finally { stream.destroy(); }
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The profile to upload under: the one named, or the only one advertised. With
|
|
47
|
+
* several and none named, the error lists them so the person can pick one.
|
|
48
|
+
*/
|
|
49
|
+
export function chooseProfile(profiles, requested) {
|
|
50
|
+
const advertised = profiles.filter(profile => typeof profile?.profileVersion === 'string' && profile.profileVersion);
|
|
51
|
+
const names = [...new Set(advertised.map(profile => profile.profileVersion))];
|
|
52
|
+
const list = names.map(name => {
|
|
53
|
+
const format = advertised.find(profile => profile.profileVersion === name).format;
|
|
54
|
+
return format ? `${name} (${format})` : name;
|
|
55
|
+
}).join(', ');
|
|
56
|
+
if (!names.length) fail('No build profile is advertised for this game right now. Nothing was charged.');
|
|
57
|
+
if (!requested) {
|
|
58
|
+
if (names.length === 1) return names[0];
|
|
59
|
+
fail(`Several build profiles are available. Choose one with --profile: ${list}. Nothing was charged.`);
|
|
60
|
+
}
|
|
61
|
+
if (advertised.filter(profile => profile.profileVersion === requested).length !== 1) {
|
|
62
|
+
fail(`"${requested}" is not an advertised build profile. Choose one with --profile: ${list}. Nothing was charged.`);
|
|
63
|
+
}
|
|
64
|
+
return requested;
|
|
65
|
+
}
|
|
66
|
+
|
|
45
67
|
async function journalOnce(filename, value) {
|
|
46
68
|
await mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
|
|
47
69
|
let handle;
|
|
@@ -58,13 +80,13 @@ async function journalOnce(filename, value) {
|
|
|
58
80
|
// the only wallet-mutating POST. No model or gameplay API is called here.
|
|
59
81
|
export async function uploadHostedBuild({ filename, gameId, profileVersion, entrypoint, repoRoot, api,
|
|
60
82
|
apiUrl, journalPath, dryRun = false, confirm, onPrepared = () => {}, onProgress = () => {}, fetchImpl, signal } = {}) {
|
|
61
|
-
if (!gameId ||
|
|
83
|
+
if (!gameId || typeof confirm !== 'function') fail('Choose a game and upload confirmation.');
|
|
62
84
|
onProgress({ phase: 'capabilities' });
|
|
63
85
|
const caps = body(await api.request('GET', `${base(gameId)}/capabilities`, { idempotent: true }));
|
|
64
86
|
const profiles = caps?.recorded?.profiles;
|
|
65
87
|
if (!caps?.recorded?.available || !Array.isArray(profiles)) fail('Recorded game uploads are not available for this game.');
|
|
88
|
+
profileVersion = chooseProfile(profiles, profileVersion);
|
|
66
89
|
const matching = profiles.filter(profile => profile.profileVersion === profileVersion);
|
|
67
|
-
if (matching.length !== 1) fail('Choose exactly one currently advertised ready profile.');
|
|
68
90
|
const profile = matching[0], limits = profile.capacity ? limitsFromProfile(profile) : buildLimits(caps.limits);
|
|
69
91
|
const offer = profile.reservationOffer;
|
|
70
92
|
if (!offer || offer.amountCents !== 49 || offer.currency !== 'USD' || offer.purpose !== 'validation_hold'
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { mkdir, open, access, rename } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { CliError } from '../errors.js';
|
|
4
|
+
import { CliError, ExitCode } from '../errors.js';
|
|
5
5
|
import { ApiError } from '../api/errors.js';
|
|
6
6
|
import { stableStringify } from '../fsutil.js';
|
|
7
|
+
import { printable } from '../ui/index.js';
|
|
7
8
|
|
|
8
9
|
const MAX_QUOTE_BYTES = 1024 * 1024;
|
|
9
10
|
const CONSENT_KEYS = ['quoteId', 'planHash', 'profileVersion', 'controllers', 'includeVideo', 'includeTranscript',
|
|
@@ -140,7 +141,7 @@ export function hostedQuoteLines(offer) {
|
|
|
140
141
|
...q.localBreakdown.map(row => `${row.count} ${row.unit} at ${money(row.unitCents)} each: ${money(row.count * row.unitCents)}.`),
|
|
141
142
|
...q.computeBreakdown.map(row => `${row.controller} hosted compute: ${money(row.priceCents)}.`),
|
|
142
143
|
`Total ${money(q.totalPriceCents)}; already reserved ${money(q.alreadyReservedCents)}; due now ${money(q.dueNowCents)} from prepaid funds. No top-up is performed.`,
|
|
143
|
-
`Video: ${r.includeVideo ? 'included' : 'off'}
|
|
144
|
+
`Video: ${r.includeVideo ? 'included' : 'off'}. Hosted runs save no transcript.`,
|
|
144
145
|
`Customer failure: ${q.customerFailure}; cancellation after start: ${q.cancelAfterStart}. Price ${q.priceVersion}; refund policy ${q.policyVersion}.`,
|
|
145
146
|
`Quote expires ${new Date(q.expiresAt).toISOString()}.`];
|
|
146
147
|
}
|
|
@@ -157,8 +158,42 @@ export async function runHostedQuote({ filename, api, apiUrl, repoRoot, confirm,
|
|
|
157
158
|
if (profile.driver !== offer.request.driver || funding?.eligible === false || offer.quote.funding?.eligible === false) fail('The server no longer advertises this profile or sufficient prepaid funding. Nothing was charged.');
|
|
158
159
|
await onPrepared(offer);
|
|
159
160
|
if (typeof confirm !== 'function') fail('The full hosted quote must be confirmed before admission.');
|
|
160
|
-
if (!await confirm(
|
|
161
|
+
if (!await confirm(confirmQuestion(offer))) return { state: 'declined', walletMutationRequested: false };
|
|
162
|
+
return admitHostedOffer({ offer, api, apiUrl, repoRoot, now });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const confirmQuestion = offer => `Pay ${money(offer.quote.dueNowCents)} now (${money(offer.quote.totalPriceCents)} total) for the quoted Standard then Jev runs and the evidence options shown?`;
|
|
166
|
+
|
|
167
|
+
// Refusals the server makes before any charge: the quote or consent was not
|
|
168
|
+
// accepted, funding or capacity said no, or the quote is gone. The original
|
|
169
|
+
// intent is kept on disk all the same; only the message differs.
|
|
170
|
+
const DEFINITE_REFUSALS = new Set(['hosted_funding_required', 'quote_not_accepted', 'not_found', 'recorded_unavailable',
|
|
171
|
+
'recorded_setup_required', 'hosted_global_capacity', 'too_many_active_hosted_runs', 'hosted_daily_capacity',
|
|
172
|
+
'hosted_build_too_large', 'hosted_build_safety_hold']);
|
|
173
|
+
|
|
174
|
+
export function hostedRefusalMessage(error) {
|
|
175
|
+
const code = error?.code;
|
|
176
|
+
if (code === 'hosted_funding_required') {
|
|
177
|
+
const f = error.body?.funding;
|
|
178
|
+
const topUp = Number.isSafeInteger(f?.requiredTopUpCents) && f.requiredTopUpCents > 0 ? ` Add at least ${money(f.requiredTopUpCents)} of prepaid funds, then request a new quote.` : ' Add prepaid funds, then request a new quote.';
|
|
179
|
+
return `The server refused the run for funding. Nothing was charged.${topUp}`;
|
|
180
|
+
}
|
|
181
|
+
if (code === 'quote_not_accepted') return 'The server did not accept this consent for the quote. Nothing was charged; request a new quote.';
|
|
182
|
+
if (['hosted_global_capacity', 'too_many_active_hosted_runs'].includes(code)) return 'Hosted capacity is busy. Nothing was charged. Wait for an existing run to finish, then retry. Adding funds does not increase capacity.';
|
|
183
|
+
if (code === 'hosted_daily_capacity') return 'Today\'s hosted run allowance is used up. Nothing was charged. Try again after the daily limit resets.';
|
|
184
|
+
if (code === 'recorded_setup_required') return `${error.message || 'Complete the playtest brief and choose an available persona.'} Nothing was charged.`;
|
|
185
|
+
if (code === 'not_found') return 'The quote is no longer available. Nothing was charged; request a new quote.';
|
|
186
|
+
if (code === 'hosted_build_too_large') return 'This build is too large to play on a hosted machine: hosted playtests play builds up to 300 MiB unpacked. Make the build smaller and upload it again. Nothing was charged.';
|
|
187
|
+
if (code === 'hosted_build_safety_hold') return 'This build was stopped for safety during a hosted playtest and cannot run again until an admin reviews and clears it in the dashboard. Nothing was charged.';
|
|
188
|
+
return 'Hosted playtests are not available right now. Nothing was charged.';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A saved durable intent is never retried. The journal is fsynced before the
|
|
192
|
+
// only wallet-mutating POST and the server receives a stable key for the plan.
|
|
193
|
+
export async function admitHostedOffer({ offer, api, apiUrl, repoRoot, now = Date.now }) {
|
|
161
194
|
validateHostedOffer(offer, apiUrl, now());
|
|
195
|
+
const identity = digest({ apiUrl: offer.apiUrl, gameId: offer.gameId, planHash: offer.quote.planHash });
|
|
196
|
+
const journal = path.join(repoRoot, '.ravensight', 'hosted-runs', `${identity}.json`);
|
|
162
197
|
const state = { schema: 'ravensight-hosted-admission-journal/v1', apiUrl: offer.apiUrl, gameId: offer.gameId,
|
|
163
198
|
quoteId: offer.quote.quoteId, planHash: offer.quote.planHash, consent: offer.consent,
|
|
164
199
|
idempotencyKey: `hosted-${offer.quote.planHash}`, phase: 'admission_unknown', createdAt: new Date(now()).toISOString() };
|
|
@@ -186,6 +221,103 @@ export async function runHostedQuote({ filename, api, apiUrl, repoRoot, confirm,
|
|
|
186
221
|
return { state: 'charged_blocked', ...ack, journal, replayAuthorized: false, gameplayProven: false };
|
|
187
222
|
} catch (error) {
|
|
188
223
|
if (error instanceof ApiError && error.status === 401) throw error;
|
|
224
|
+
if (error instanceof ApiError && DEFINITE_REFUSALS.has(error.code) && error.status >= 400 && error.status < 600) {
|
|
225
|
+
throw new CliError(hostedRefusalMessage(error), ExitCode.ENVIRONMENT);
|
|
226
|
+
}
|
|
189
227
|
fail(`The original admission outcome needs reconciliation. Preserve ${journal}; no retry or replacement admission was requested.`);
|
|
190
228
|
}
|
|
191
229
|
}
|
|
230
|
+
|
|
231
|
+
const ELIGIBLE_BUILD_STATE = 'graphical_static_validated';
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The builds a recorded hosted run can use: static-checked, unexpired and on a
|
|
235
|
+
* profile the server currently advertises as ready. Newest first, the same
|
|
236
|
+
* filter the dashboard's recorded comparison form applies.
|
|
237
|
+
*/
|
|
238
|
+
export function eligibleHostedBuilds(builds, profiles, now = Date.now()) {
|
|
239
|
+
const rows = Array.isArray(builds) ? builds : [];
|
|
240
|
+
return rows.filter(build => build && build.kind === 'recorded_graphical' && build.state === ELIGIBLE_BUILD_STATE
|
|
241
|
+
&& id(build.buildId) && !(Date.parse(build.expiresAt) <= now)
|
|
242
|
+
&& profiles.some(p => p.profileVersion === build.profileVersion && p.format === build.format))
|
|
243
|
+
.sort((a, b) => (Date.parse(b.createdAt) || 0) - (Date.parse(a.createdAt) || 0));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Quote a recorded Standard then Jev run without a saved quote file: pick the
|
|
248
|
+
* build (the newest eligible one unless named), ask for a quote, check it, and
|
|
249
|
+
* hand back the same offer shape `hosted quote` saves. Charges nothing.
|
|
250
|
+
*/
|
|
251
|
+
export async function prepareHostedRun({ api, apiUrl, gameId, buildId, persona, includeVideo = false, includeTranscript = false, standardProvider, now = Date.now }) {
|
|
252
|
+
if (!id(gameId)) fail('Choose a game. Nothing was charged.');
|
|
253
|
+
const answer = await api.request('GET', `${base(gameId)}/run-capabilities`, { idempotent: true });
|
|
254
|
+
const recorded = answer.body?.recorded;
|
|
255
|
+
if (!recorded?.available || recorded.intakeEnabled !== true || !Array.isArray(recorded.profiles)) {
|
|
256
|
+
fail('Hosted playtests are not available for this game right now. Nothing was charged.');
|
|
257
|
+
}
|
|
258
|
+
const listed = await api.request('GET', `${base(gameId)}/builds`, { idempotent: true });
|
|
259
|
+
const eligible = eligibleHostedBuilds(listed.body?.builds, recorded.profiles, now());
|
|
260
|
+
let build;
|
|
261
|
+
if (buildId) {
|
|
262
|
+
build = eligible.find(row => row.buildId === buildId);
|
|
263
|
+
const refused = (Array.isArray(listed.body?.builds) ? listed.body.builds : []).find(row => row?.buildId === buildId && row.rejection?.message);
|
|
264
|
+
if (!build && refused) fail(`Build ${buildId} was not accepted: ${printable(String(refused.rejection.message)).replace(/\s+/g, ' ').slice(0, 300)} Nothing was charged.`);
|
|
265
|
+
if (!build) fail(`Build ${buildId} is not ready for a hosted run: it has to pass its static archive check on a ready profile and not have expired. Nothing was charged.`);
|
|
266
|
+
} else {
|
|
267
|
+
build = eligible[0];
|
|
268
|
+
if (!build) fail('No build is ready for a hosted run. Upload one with "build upload" and wait for its static archive check. Nothing was charged.');
|
|
269
|
+
}
|
|
270
|
+
const { profile, funding } = await available(api, gameId, build.profileVersion, standardProvider);
|
|
271
|
+
const request = requestFor({ buildId: build.buildId, profileVersion: build.profileVersion, persona, standardProvider, includeVideo, includeTranscript }, profile);
|
|
272
|
+
const response = await api.request('POST', `${base(gameId)}/quotes`, { body: request, idempotent: false });
|
|
273
|
+
if (response.status !== 200) fail('The server did not return a hosted quote. Nothing was charged.');
|
|
274
|
+
const quote = response.body, consent = hostedConsent(quote, request, now());
|
|
275
|
+
const value = { apiUrl: origin(apiUrl), gameId, request, quote, consent };
|
|
276
|
+
const offer = { schema: 'ravensight-hosted-recorded-quote/v1', ...value, integrity: digest(value) };
|
|
277
|
+
return { offer, build, funding: quote.funding || funding || null };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** True when the server says the balance does not meet this run's funding requirement. */
|
|
281
|
+
export function fundingShortfall(quote, funding) {
|
|
282
|
+
const f = quote?.funding || funding;
|
|
283
|
+
if (f && f.eligible === false) return { requiredTopUpCents: Number.isSafeInteger(f.requiredTopUpCents) ? f.requiredTopUpCents : null };
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Plain statements a person accepts with the price. Each is something the server enforces or the quote states. */
|
|
288
|
+
export function hostedConsentLines(offer) {
|
|
289
|
+
const q = offer.quote;
|
|
290
|
+
return [
|
|
291
|
+
'One persona plays the same build twice, once with Standard and once with Jev, on isolated hosted machines. These are AI playtests, not human feedback.',
|
|
292
|
+
'The game runs without network access. Shared capacity, daily limits and the quoted runtime bounds still apply; funding does not guarantee an immediate start.',
|
|
293
|
+
`If the build fails, hosted compute is ${q.customerFailure === 'refund' ? 'refunded' : 'kept'}. Canceling after a confirmed start ${q.cancelAfterStart === 'refund' ? 'refunds' : 'keeps'} that run's compute charge. Unstarted work is refunded during settlement.`,
|
|
294
|
+
'The build\'s validation hold is separate and is returned after safe cleanup. It does not reduce this price.',
|
|
295
|
+
...(Number.isSafeInteger(q.minimumBalanceCents) ? [`Balance needed to start: ${money(q.minimumBalanceCents)}. Only the total above is charged.`] : [])
|
|
296
|
+
];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const admissionPattern = /^ha_[a-f0-9-]{36}$/;
|
|
300
|
+
|
|
301
|
+
/** Admission, job and runs for one hosted run. A job or runs not readable yet reads as absent. */
|
|
302
|
+
export async function readHostedRun({ api, gameId, admissionId }) {
|
|
303
|
+
if (!admissionPattern.test(admissionId || '')) fail('Name a hosted run id (ha_...), as "hosted run" printed it.');
|
|
304
|
+
const admission = (await api.request('GET', `${base(gameId)}/runs/${encodeURIComponent(admissionId)}`, { idempotent: true })).body;
|
|
305
|
+
if (!admission || admission.admissionId !== admissionId) fail('The server answered for a different hosted run.');
|
|
306
|
+
let job = null, runs = [];
|
|
307
|
+
if (admission.jobId) {
|
|
308
|
+
const jobPath = `/api/v1/games/${encodeURIComponent(gameId)}/playtest/jobs/${encodeURIComponent(admission.jobId)}`;
|
|
309
|
+
const soft = async promise => { try { return (await promise).body; } catch (error) { if (error instanceof ApiError && error.status === 404) return null; throw error; } };
|
|
310
|
+
const [jobBody, runsBody] = await Promise.all([
|
|
311
|
+
soft(api.request('GET', jobPath, { idempotent: true })),
|
|
312
|
+
soft(api.request('GET', `${jobPath}/runs`, { idempotent: true }))
|
|
313
|
+
]);
|
|
314
|
+
job = jobBody?.job || null;
|
|
315
|
+
runs = Array.isArray(runsBody?.runs) ? runsBody.runs : [];
|
|
316
|
+
}
|
|
317
|
+
return { admission, job, runs };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export async function cancelHostedRun({ api, gameId, admissionId }) {
|
|
321
|
+
if (!admissionPattern.test(admissionId || '')) fail('Name a hosted run id (ha_...), as "hosted run" printed it.');
|
|
322
|
+
return (await api.request('POST', `${base(gameId)}/runs/${encodeURIComponent(admissionId)}/cancel`, { body: {}, idempotent: false })).body;
|
|
323
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Customer-facing progress for a hosted admission plus its ordinary Playtest job.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors dashboard/src/lib/hostedPlaytestStatus.js in the platform repo
|
|
4
|
+
// (game_tracker), so the CLI and the dashboard use the same phases and the same
|
|
5
|
+
// words. Change both together. The admission owns the provider and payment
|
|
6
|
+
// lifecycle; the job and its runs own gameplay, report capture and aggregation.
|
|
7
|
+
|
|
8
|
+
const FAILED = new Set(['failed', 'budget_exceeded', 'interrupted', 'stalled', 'refused']);
|
|
9
|
+
const CANCELED = new Set(['canceled']);
|
|
10
|
+
const boundedReason = value => String(value || '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
|
11
|
+
|
|
12
|
+
export const HOSTED_PHASES = Object.freeze(['queued', 'provisioning', 'running', 'capturing', 'analyzing', 'report-ready', 'failed', 'canceled']);
|
|
13
|
+
export const TERMINAL_PHASES = Object.freeze(['report-ready', 'failed', 'canceled']);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} admission GET /hosted/runs/:admissionId
|
|
17
|
+
* @param {object|null} [job] GET /jobs/:jobId `job`, or null when not readable yet
|
|
18
|
+
* @param {Array<object>} [runs] GET /jobs/:jobId/runs `runs`
|
|
19
|
+
* @param {number} [now]
|
|
20
|
+
*/
|
|
21
|
+
export function hostedPlaytestStatus(admission, job = null, runs = [], now = Date.now()) {
|
|
22
|
+
if (!admission) return null;
|
|
23
|
+
const runRows = Array.isArray(runs) ? runs : [];
|
|
24
|
+
const jobState = job?.state || '';
|
|
25
|
+
const admissionState = admission.executionState || admission.state || '';
|
|
26
|
+
const cancelRequested = admission.cancelRequested === true || job?.cancel_requested === true;
|
|
27
|
+
const aggregate = runRows.find(row => row.module === 'aggregate_report');
|
|
28
|
+
const plays = runRows.filter(row => row.module !== 'aggregate_report');
|
|
29
|
+
|
|
30
|
+
let phase = 'queued';
|
|
31
|
+
let label = 'Queued';
|
|
32
|
+
let detail = 'Waiting for hosted capacity.';
|
|
33
|
+
|
|
34
|
+
if (CANCELED.has(jobState) || CANCELED.has(admissionState)) {
|
|
35
|
+
phase = 'canceled'; label = 'Canceled'; detail = 'The hosted run was canceled.';
|
|
36
|
+
} else if (FAILED.has(jobState) || FAILED.has(admissionState) || job?.summary_report === 'failed') {
|
|
37
|
+
phase = 'failed'; label = 'Failed'; detail = 'The hosted run stopped before a report was ready.';
|
|
38
|
+
} else if (job?.summary_report === 'ready' || admissionState === 'recorded_closed') {
|
|
39
|
+
phase = 'report-ready'; label = 'Report ready'; detail = 'The hosted run and report are complete.';
|
|
40
|
+
} else if (['aggregating', 'uploading'].includes(jobState) || ['reporting', 'uploading'].includes(aggregate?.state)) {
|
|
41
|
+
phase = 'analyzing'; label = 'Analyzing'; detail = 'Combining the completed playtests into the comparison report.';
|
|
42
|
+
} else if (plays.some(row => ['reporting', 'uploading'].includes(row.state))) {
|
|
43
|
+
phase = 'capturing'; label = 'Capturing results'; detail = 'Saving the playtest report, evidence and requested media.';
|
|
44
|
+
} else if (plays.some(row => row.state === 'playing') || jobState === 'running') {
|
|
45
|
+
phase = 'running'; label = 'Running'; detail = 'The hosted game session is in progress.';
|
|
46
|
+
} else if (plays.some(row => row.state === 'launching') || jobState === 'registered' || admissionState === 'dispatched') {
|
|
47
|
+
phase = 'provisioning'; label = 'Provisioning'; detail = 'Preparing isolated hosted compute for this run.';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const startedAt = Date.parse(job?.created_at || admission.createdAt || '');
|
|
51
|
+
const finishedAt = Date.parse(job?.finished_at || '');
|
|
52
|
+
const elapsedSeconds = Number.isFinite(startedAt)
|
|
53
|
+
? Math.max(0, Math.floor(((Number.isFinite(finishedAt) ? finishedAt : Number(now)) - startedAt) / 1000))
|
|
54
|
+
: null;
|
|
55
|
+
const failure = phase === 'failed'
|
|
56
|
+
? boundedReason(job?.state_reason || admission.failureReason || admission.stopReason)
|
|
57
|
+
: '';
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
phase,
|
|
61
|
+
label,
|
|
62
|
+
detail,
|
|
63
|
+
elapsedSeconds,
|
|
64
|
+
failure,
|
|
65
|
+
cancelRequested,
|
|
66
|
+
settled: admission.settled === true,
|
|
67
|
+
// A terminal job can still be settling provider cleanup and refunds. The
|
|
68
|
+
// admission is the authoritative signal that a new paid run is safe.
|
|
69
|
+
terminal: admission.settled === true && TERMINAL_PHASES.includes(phase)
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const MODE_NAMES = Object.freeze({ standard: 'Standard', jev: 'Jev' });
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One row per hosted mode (Standard, Jev), from the job's persona runs. A mode
|
|
77
|
+
* whose run the server has not created yet reads as not started.
|
|
78
|
+
* @param {Array<object>} runs
|
|
79
|
+
* @param {Array<string>} [controllers]
|
|
80
|
+
*/
|
|
81
|
+
export function modeOutcomes(runs = [], controllers = ['standard', 'jev']) {
|
|
82
|
+
const plays = (Array.isArray(runs) ? runs : []).filter(row => row.module !== 'aggregate_report');
|
|
83
|
+
return controllers.map(controller => {
|
|
84
|
+
const run = plays.find(row => row.controller === controller) || null;
|
|
85
|
+
const report = run?.report || null;
|
|
86
|
+
return {
|
|
87
|
+
mode: controller,
|
|
88
|
+
name: MODE_NAMES[controller] || controller,
|
|
89
|
+
runId: run?.run_id || null,
|
|
90
|
+
state: run?.state || 'not_started',
|
|
91
|
+
reason: run?.state_reason ? boundedReason(run.state_reason) : null,
|
|
92
|
+
reportValid: report ? report.valid === true : false,
|
|
93
|
+
findings: report && Number.isSafeInteger(report.findings_count) ? report.findings_count : null,
|
|
94
|
+
wouldContinue: report && typeof report.would_continue === 'boolean' ? report.would_continue : null,
|
|
95
|
+
recording: run?.recording?.status || null
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** "Standard: completed, 4 findings, video saved." */
|
|
101
|
+
export function modeLine(row) {
|
|
102
|
+
const parts = [row.state.replace(/_/g, ' ')];
|
|
103
|
+
if (row.findings !== null) parts.push(`${row.findings} finding${row.findings === 1 ? '' : 's'}`);
|
|
104
|
+
if (row.wouldContinue !== null) parts.push(row.wouldContinue ? 'would keep playing' : 'would stop playing');
|
|
105
|
+
if (row.recording) parts.push(`video ${row.recording}`);
|
|
106
|
+
if (row.reason) parts.push(row.reason);
|
|
107
|
+
return `${row.name}: ${parts.join(', ')}.`;
|
|
108
|
+
}
|
package/src/run/aggregate.js
CHANGED
|
@@ -232,11 +232,12 @@ export async function judgeClusters({ client, jobId, runId, clusters, ledger = n
|
|
|
232
232
|
}
|
|
233
233
|
} catch (error) {
|
|
234
234
|
// A dedup that could not be judged is not worth failing a job over: the
|
|
235
|
-
// local clustering is the fallback and it is deterministic.
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
235
|
+
// local clustering is the fallback and it is deterministic. That includes
|
|
236
|
+
// a budget refusal: the aggregate is the job's report and always comes
|
|
237
|
+
// back, and the exit code is decided by the persona runs, not by this
|
|
238
|
+
// pass. Only a closed job stops it.
|
|
239
|
+
if (error instanceof ProxyError && error.isClosed) throw error;
|
|
240
|
+
log(`dedup pass skipped${error instanceof ProxyError && error.isBudget ? ' (the aggregate run is out of model budget; the local clustering stands)' : ''}: ${String(error && error.message)}`);
|
|
240
241
|
}
|
|
241
242
|
return verdicts;
|
|
242
243
|
}
|
|
@@ -393,6 +394,7 @@ export async function buildAggregate({
|
|
|
393
394
|
persona: run.persona,
|
|
394
395
|
state: run.state,
|
|
395
396
|
report: run.report,
|
|
397
|
+
markdown: typeof run.markdown === 'string' ? run.markdown : null,
|
|
396
398
|
// A finding the server dropped for want of evidence is marked on disk
|
|
397
399
|
// after delivery (./dropped.js) and kept out of the aggregate, so the
|
|
398
400
|
// job report counts what the dashboard counts.
|
|
@@ -456,6 +458,7 @@ export async function buildAggregate({
|
|
|
456
458
|
dropped_for: 'no evidence'
|
|
457
459
|
})),
|
|
458
460
|
capability_notes: capabilityNotes.length > 0 ? capabilityNotes : ['No capability limits noted for this job.'],
|
|
461
|
+
persona_voices: inputs.map(run => personaVoice(run)),
|
|
459
462
|
usage: ledger ? aggregateUsage(ledger) : { usd: 0, by_model: {} }
|
|
460
463
|
};
|
|
461
464
|
|
|
@@ -540,11 +543,11 @@ export async function judgeAggregate({ client, jobId, runId, ranked, inputs, bri
|
|
|
540
543
|
if (ledger) ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
|
|
541
544
|
return parseJsonObject(answer.text);
|
|
542
545
|
} catch (error) {
|
|
543
|
-
// A
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
if (error instanceof ProxyError &&
|
|
547
|
-
log(`aggregate synthesis skipped: ${String(error && error.message)}`);
|
|
546
|
+
// A cancel refusal reaches the caller. Anything else, a budget refusal
|
|
547
|
+
// included, leaves the locally ranked aggregate standing, which is still
|
|
548
|
+
// the report a developer can read: the aggregate always comes back.
|
|
549
|
+
if (error instanceof ProxyError && error.isClosed) throw error;
|
|
550
|
+
log(`aggregate synthesis skipped${error instanceof ProxyError && error.isBudget ? ' (the aggregate run is out of model budget; the local ranking stands)' : ''}: ${String(error && error.message)}`);
|
|
548
551
|
return null;
|
|
549
552
|
}
|
|
550
553
|
}
|
|
@@ -665,6 +668,14 @@ export function renderAggregateMarkdown(json) {
|
|
|
665
668
|
lines.push(`| ${run.persona} | ${run.actions_taken} | ${run.quit_reason} | ${sentiment.fun ?? '-'} | ${sentiment.clarity ?? '-'} | ${sentiment.frustration ?? '-'} | ${run.would_continue === null || run.would_continue === undefined ? '-' : run.would_continue ? 'yes' : 'no'} |`);
|
|
666
669
|
}
|
|
667
670
|
lines.push('');
|
|
671
|
+
lines.push('## How the personas felt');
|
|
672
|
+
const voices = Array.isArray(json.persona_voices) ? json.persona_voices : [];
|
|
673
|
+
if (voices.length === 0) lines.push('No persona report was written, so there is nothing in their own words.');
|
|
674
|
+
for (const voice of voices) {
|
|
675
|
+
lines.push(`### ${voice.persona}${voice.written_from_log ? ' (written from the run log)' : ''}`);
|
|
676
|
+
lines.push(voice.text ? voice.text.split('\n').map(line => `> ${line}`).join('\n') : '> This run\'s report has no in-character section.');
|
|
677
|
+
lines.push('');
|
|
678
|
+
}
|
|
668
679
|
lines.push('## Top issues (ranked)');
|
|
669
680
|
if (json.issues.length === 0) lines.push('None.');
|
|
670
681
|
for (const issue of json.issues) {
|
|
@@ -703,6 +714,54 @@ export function renderAggregateMarkdown(json) {
|
|
|
703
714
|
return fillPlaceholders(lines.join('\n'), { generatedAt: json.generated_at, game: json.game_id });
|
|
704
715
|
}
|
|
705
716
|
|
|
717
|
+
/** The most characters of one persona's in-character section the aggregate quotes. */
|
|
718
|
+
export const MAX_VOICE_CHARS = 1200;
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* One persona's in-character section, from its report.md: the section under
|
|
722
|
+
* "In-character experience", the one a report written from the run log
|
|
723
|
+
* carries too, cut to MAX_VOICE_CHARS at a paragraph or sentence where it can.
|
|
724
|
+
*
|
|
725
|
+
* @param {{runId: string, persona: string, markdown: string|null}} run
|
|
726
|
+
* @returns {{run_id: string, persona: string, written_from_log: boolean, text: string|null}}
|
|
727
|
+
*/
|
|
728
|
+
export function personaVoice(run) {
|
|
729
|
+
const section = inCharacterSection(run && run.markdown);
|
|
730
|
+
return {
|
|
731
|
+
run_id: run.runId,
|
|
732
|
+
persona: run.persona,
|
|
733
|
+
written_from_log: Boolean(section && section.fromLog),
|
|
734
|
+
text: section ? clipVoice(section.text) : null
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* The body of report.md's in-character section, or null when it has none.
|
|
740
|
+
*
|
|
741
|
+
* @param {string|null} markdown
|
|
742
|
+
* @returns {{text: string, fromLog: boolean}|null}
|
|
743
|
+
*/
|
|
744
|
+
export function inCharacterSection(markdown) {
|
|
745
|
+
if (typeof markdown !== 'string' || !markdown) return null;
|
|
746
|
+
const lines = markdown.split('\n');
|
|
747
|
+
const start = lines.findIndex(line => /^##\s+In-character experience\b/i.test(line.trim()));
|
|
748
|
+
if (start === -1) return null;
|
|
749
|
+
const rest = lines.slice(start + 1);
|
|
750
|
+
const end = rest.findIndex(line => /^#{1,2}\s/.test(line));
|
|
751
|
+
const text = (end === -1 ? rest : rest.slice(0, end)).join('\n').trim();
|
|
752
|
+
if (!text) return null;
|
|
753
|
+
return { text, fromLog: /written from the run log/i.test(lines[start]) };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function clipVoice(text) {
|
|
757
|
+
if (text.length <= MAX_VOICE_CHARS) return text;
|
|
758
|
+
const cut = text.slice(0, MAX_VOICE_CHARS);
|
|
759
|
+
const paragraph = cut.lastIndexOf('\n\n');
|
|
760
|
+
const sentence = cut.lastIndexOf('. ');
|
|
761
|
+
const at = paragraph > MAX_VOICE_CHARS / 2 ? paragraph : sentence > MAX_VOICE_CHARS / 2 ? sentence + 1 : cut.length;
|
|
762
|
+
return `${cut.slice(0, at).trim()} [...]`;
|
|
763
|
+
}
|
|
764
|
+
|
|
706
765
|
/**
|
|
707
766
|
* Write both aggregate files into the job directory.
|
|
708
767
|
*
|