ravensight-playtest 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/addons/ravensight_driver/driver.gd +836 -0
  4. package/addons/ravensight_driver/export_plugin.gd +51 -0
  5. package/addons/ravensight_driver/plugin.cfg +7 -0
  6. package/addons/ravensight_driver/plugin.gd +36 -0
  7. package/bin/ravensight-playtest.js +31 -0
  8. package/package.json +45 -0
  9. package/src/api/README.md +500 -0
  10. package/src/api/client.js +340 -0
  11. package/src/api/errors.js +115 -0
  12. package/src/api/http.js +194 -0
  13. package/src/api/index.js +107 -0
  14. package/src/auth/deviceCode.js +79 -0
  15. package/src/auth/keychain.js +159 -0
  16. package/src/auth/session.js +128 -0
  17. package/src/cli.js +335 -0
  18. package/src/commands/brief.js +303 -0
  19. package/src/commands/check.js +318 -0
  20. package/src/commands/fakeCore.js +379 -0
  21. package/src/commands/init.js +120 -0
  22. package/src/commands/login.js +90 -0
  23. package/src/commands/logout.js +70 -0
  24. package/src/commands/open.js +125 -0
  25. package/src/commands/profile.js +262 -0
  26. package/src/commands/resume.js +156 -0
  27. package/src/commands/run.js +1015 -0
  28. package/src/commands/upload.js +137 -0
  29. package/src/config.js +100 -0
  30. package/src/dashboard.js +97 -0
  31. package/src/detect.js +77 -0
  32. package/src/errors.js +44 -0
  33. package/src/fsutil.js +77 -0
  34. package/src/godot.js +85 -0
  35. package/src/packs/index.js +191 -0
  36. package/src/paths.js +129 -0
  37. package/src/run/aggregate.js +658 -0
  38. package/src/run/args.js +111 -0
  39. package/src/run/context.js +181 -0
  40. package/src/run/deps.js +184 -0
  41. package/src/run/drivers/driver.js +183 -0
  42. package/src/run/drivers/godot-observation.js +138 -0
  43. package/src/run/drivers/godot-project.js +475 -0
  44. package/src/run/drivers/godot-rpc.js +225 -0
  45. package/src/run/drivers/godot.js +587 -0
  46. package/src/run/drivers/index.js +52 -0
  47. package/src/run/drivers/web.js +385 -0
  48. package/src/run/exit.js +21 -0
  49. package/src/run/heartbeat.js +131 -0
  50. package/src/run/index.js +31 -0
  51. package/src/run/json.js +56 -0
  52. package/src/run/model.js +384 -0
  53. package/src/run/paths.js +88 -0
  54. package/src/run/personaLoop.js +871 -0
  55. package/src/run/profile.js +214 -0
  56. package/src/run/regenerate.js +149 -0
  57. package/src/run/repoTools.js +286 -0
  58. package/src/run/report.js +222 -0
  59. package/src/run/resume.js +272 -0
  60. package/src/run/secretScan.js +171 -0
  61. package/src/run/state.js +198 -0
  62. package/src/run/synthetic.js +206 -0
  63. package/src/run/tools.js +344 -0
  64. package/src/run/transcript.js +93 -0
  65. package/src/run/usage.js +115 -0
  66. package/src/state/index.js +105 -0
  67. package/src/states.js +104 -0
  68. package/src/ui/index.js +195 -0
  69. package/src/upload/allowlist.js +116 -0
  70. package/src/upload/index.js +467 -0
  71. package/src/upload/queue.js +114 -0
  72. package/src/version.js +63 -0
@@ -0,0 +1,344 @@
1
+ import { OBSERVATION_MAX_CHARS, truncateObservation } from './drivers/driver.js';
2
+
3
+ /**
4
+ * The client tool set for `persona_playtest.act`.
5
+ *
6
+ * **The names are not ours to choose.** `routing.json`'s
7
+ * `persona_playtest.act` lists `allowed_client_tools: ["snapshot", "click",
8
+ * "type", "key", "navigate", "wait", "screenshot"]`, and the proxy answers
9
+ * 400 `tool_not_allowed` for anything else. Two consequences worth knowing
10
+ * before editing this file:
11
+ *
12
+ * - There is no `note` tool and no `observe`/`act` pair, so the log note
13
+ * rides along as an optional field on whichever tool the persona is
14
+ * already calling. Same information, same transcript line, no tool the
15
+ * server would refuse.
16
+ * - There is no `quit` tool either. A persona that wants to stop simply
17
+ * stops calling tools; the loop reads a turn with no tool call as
18
+ * `persona_quit` and keeps the model's own words as the quit detail.
19
+ *
20
+ * Spec 03's accounting is kept: one action is one input to the game, so
21
+ * `click`, `type`, `key` and `navigate` count against `max_actions` while
22
+ * `snapshot`, `screenshot` and `wait` do not.
23
+ */
24
+
25
+ /** Note kinds, from spec 03's `log_note`. */
26
+ export const NOTE_KINDS = Object.freeze(['bug', 'confusing', 'good', 'suggestion', 'reaction']);
27
+
28
+ /** Severities, from the report schema's finding severity enum. */
29
+ export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor', 'cosmetic', 'praise']);
30
+
31
+ /** Tools that spend an action. */
32
+ export const ACTION_TOOLS = Object.freeze(['click', 'type', 'key', 'navigate']);
33
+
34
+ const thought = {
35
+ type: 'string',
36
+ description: 'One to three sentences in the persona voice, first person. Never break character here.'
37
+ };
38
+
39
+ const note = {
40
+ type: 'object',
41
+ description: 'Optional: something worth recording at this step. Engineer voice, not persona voice. Use it for anything surprising, frustrating, delightful, broken or confusing.',
42
+ additionalProperties: false,
43
+ required: ['kind', 'text'],
44
+ properties: {
45
+ kind: { type: 'string', enum: [...NOTE_KINDS] },
46
+ text: { type: 'string' },
47
+ severity: { type: 'string', enum: [...SEVERITIES] }
48
+ }
49
+ };
50
+
51
+ /** The tool definitions sent with every `persona_playtest.act` call. */
52
+ export const PLAY_TOOLS = Object.freeze([
53
+ {
54
+ name: 'snapshot',
55
+ description: 'Look at the current screen. Does not spend an action. Use it after anything that might have changed the game state.',
56
+ input_schema: {
57
+ type: 'object',
58
+ additionalProperties: false,
59
+ properties: { thought_in_character: thought, note }
60
+ }
61
+ },
62
+ {
63
+ name: 'click',
64
+ description: 'Click one thing. Spends an action. Either name the target or give x and y pixel coordinates. What a named target means depends on the driver: on playwright_web it is an accessibility node such as button "Start", a visible text fragment, or a CSS selector; on godot_driver it is a node path from the scene tree you were shown.',
65
+ input_schema: {
66
+ type: 'object',
67
+ additionalProperties: false,
68
+ properties: {
69
+ target: { type: 'string', description: 'web: button "Start", Start Game, or #start-button. Godot: a node path such as /root/Main/UI/StartButton.' },
70
+ x: { type: 'number' },
71
+ y: { type: 'number' },
72
+ thought_in_character: thought,
73
+ note
74
+ }
75
+ }
76
+ },
77
+ {
78
+ name: 'type',
79
+ description: 'Type text. Spends an action. Set submit to press Enter afterwards.',
80
+ input_schema: {
81
+ type: 'object',
82
+ additionalProperties: false,
83
+ required: ['text'],
84
+ properties: {
85
+ text: { type: 'string' },
86
+ submit: { type: 'boolean' },
87
+ thought_in_character: thought,
88
+ note
89
+ }
90
+ }
91
+ },
92
+ {
93
+ name: 'key',
94
+ description: 'Press one key. Spends an action. Names are the usual ones: Enter, Space, ArrowUp, Escape, a, 1.',
95
+ input_schema: {
96
+ type: 'object',
97
+ additionalProperties: false,
98
+ required: ['key'],
99
+ properties: {
100
+ key: { type: 'string' },
101
+ thought_in_character: thought,
102
+ note
103
+ }
104
+ }
105
+ },
106
+ {
107
+ name: 'navigate',
108
+ description: 'Go to a URL inside this build. Spends an action. Anything outside the build origin is refused, so do not guess URLs.',
109
+ input_schema: {
110
+ type: 'object',
111
+ additionalProperties: false,
112
+ required: ['url'],
113
+ properties: {
114
+ url: { type: 'string' },
115
+ thought_in_character: thought,
116
+ note
117
+ }
118
+ }
119
+ },
120
+ {
121
+ name: 'wait',
122
+ description: 'Wait for the game to catch up. Does not spend an action. Capped, so ask for what you need rather than a long sleep.',
123
+ input_schema: {
124
+ type: 'object',
125
+ additionalProperties: false,
126
+ required: ['ms'],
127
+ properties: {
128
+ ms: { type: 'integer', minimum: 0 },
129
+ thought_in_character: thought,
130
+ note
131
+ }
132
+ }
133
+ },
134
+ {
135
+ name: 'screenshot',
136
+ description: 'Save a screenshot, for evidence. Does not spend an action. Only at key moments: a screen or mode seen for the first time, a bug, a strong reaction, a crash, the final state. Never on a fixed interval and never the same state twice.',
137
+ input_schema: {
138
+ type: 'object',
139
+ additionalProperties: false,
140
+ required: ['name', 'reason'],
141
+ properties: {
142
+ name: { type: 'string', description: 'a short slug, such as title-screen or level-2-softlock' },
143
+ reason: { type: 'string' },
144
+ thought_in_character: thought,
145
+ note
146
+ }
147
+ }
148
+ }
149
+ ]);
150
+
151
+ /** The single tool for `persona_playtest.report`. */
152
+ export const REPORT_TOOLS = Object.freeze([
153
+ {
154
+ name: 'write_file',
155
+ description: 'Write one of the two report files. path must be exactly report.md or report.json.',
156
+ input_schema: {
157
+ type: 'object',
158
+ additionalProperties: false,
159
+ required: ['path', 'content'],
160
+ properties: {
161
+ path: { type: 'string', enum: ['report.md', 'report.json'] },
162
+ content: { type: 'string' }
163
+ }
164
+ }
165
+ }
166
+ ]);
167
+
168
+ /**
169
+ * @param {Object} toolUse a tool_use block from ./model.js
170
+ * @returns {{kind: string}|null} the driver action, or null for the tools
171
+ * that do not touch the game
172
+ */
173
+ export function actionFor(toolUse) {
174
+ const input = toolUse.input || {};
175
+ switch (toolUse.name) {
176
+ case 'click':
177
+ return Number.isFinite(Number(input.x)) && Number.isFinite(Number(input.y))
178
+ ? { kind: 'click', target: { x: Number(input.x), y: Number(input.y) } }
179
+ : { kind: 'click', target: String(input.target || '') };
180
+ case 'type':
181
+ return { kind: 'type', text: String(input.text || ''), submit: Boolean(input.submit) };
182
+ case 'key':
183
+ return { kind: 'key', key: String(input.key || '') };
184
+ case 'navigate':
185
+ return { kind: 'navigate', url: String(input.url || '') };
186
+ case 'wait':
187
+ return { kind: 'wait', ms: Number(input.ms) || 0 };
188
+ default:
189
+ return null;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Execute one tool call against the driver.
195
+ *
196
+ * The result is a `tool_result` content block plus the bookkeeping the loop
197
+ * needs: whether an action was spent, whether the screen moved, and the
198
+ * observation to keep in context.
199
+ *
200
+ * @param {Object} args
201
+ * @param {Object} args.driver
202
+ * @param {Object} args.toolUse
203
+ * @param {(note: Object) => void} [args.onNote]
204
+ * @param {(shot: Object) => void} [args.onScreenshot]
205
+ * @returns {Promise<{result: Object, observation: Object|null, spentAction: boolean, changed: boolean, error: string|null}>}
206
+ */
207
+ export async function executeTool({ driver, toolUse, onNote, onScreenshot }) {
208
+ const input = toolUse.input || {};
209
+ if (toolUse.malformed) {
210
+ return {
211
+ result: toolResult(toolUse.id, 'That tool call did not parse as JSON. Send it again, smaller.', true),
212
+ observation: null,
213
+ spentAction: false,
214
+ changed: false,
215
+ error: 'malformed_tool_input'
216
+ };
217
+ }
218
+
219
+ if (input.note && onNote) onNote({ ...input.note, tool: toolUse.name });
220
+
221
+ if (toolUse.name === 'screenshot') {
222
+ try {
223
+ const shot = await driver.screenshot(String(input.name || 'shot'));
224
+ if (onScreenshot) onScreenshot({ ...shot, reason: String(input.reason || '') });
225
+ return {
226
+ result: toolResult(toolUse.id, `Saved ${shot.relativePath}.`),
227
+ observation: null,
228
+ spentAction: false,
229
+ changed: false,
230
+ error: null
231
+ };
232
+ } catch (error) {
233
+ return {
234
+ result: toolResult(toolUse.id, `The screenshot failed: ${message(error)}`, true),
235
+ observation: null,
236
+ spentAction: false,
237
+ changed: false,
238
+ error: message(error)
239
+ };
240
+ }
241
+ }
242
+
243
+ if (toolUse.name === 'snapshot') {
244
+ const observation = await driver.observe();
245
+ return {
246
+ result: observationResult(toolUse.id, observation),
247
+ observation,
248
+ spentAction: false,
249
+ changed: false,
250
+ error: null
251
+ };
252
+ }
253
+
254
+ const action = actionFor(toolUse);
255
+ if (!action) {
256
+ return {
257
+ result: toolResult(toolUse.id, `There is no tool called ${toolUse.name}.`, true),
258
+ observation: null,
259
+ spentAction: false,
260
+ changed: false,
261
+ error: 'unknown_tool'
262
+ };
263
+ }
264
+
265
+ const outcome = await driver.act(action);
266
+ const observation = await driver.observe();
267
+ const spentAction = ACTION_TOOLS.includes(toolUse.name) && outcome.ok;
268
+ const prefix = outcome.ok
269
+ ? (outcome.changed ? '' : 'That did not change the screen.\n')
270
+ : `That did not work: ${outcome.error}\n`;
271
+ return {
272
+ result: observationResult(toolUse.id, observation, prefix, !outcome.ok),
273
+ observation,
274
+ spentAction,
275
+ changed: Boolean(outcome.changed),
276
+ error: outcome.ok ? null : String(outcome.error || 'act failed')
277
+ };
278
+ }
279
+
280
+ /**
281
+ * A tool_result whose content is the new observation, with the screenshot
282
+ * attached as an image when the driver decided the text was too thin to
283
+ * play from. Images are pruned from history by the caller: the act step
284
+ * accepts a very small number of image blocks per request.
285
+ *
286
+ * @param {string} id
287
+ * @param {Object} observation
288
+ * @param {string} [prefix]
289
+ * @param {boolean} [isError]
290
+ */
291
+ export function observationResult(id, observation, prefix = '', isError = false) {
292
+ const content = [
293
+ {
294
+ type: 'text',
295
+ text: `${prefix}step ${observation.step}${observation.url ? ` (${observation.url})` : ''}${citationLine(observation)}\n${truncateObservation(observation.text, OBSERVATION_MAX_CHARS)}`
296
+ }
297
+ ];
298
+ if (observation.screenshotBase64) {
299
+ content.push({
300
+ type: 'image',
301
+ source: {
302
+ type: 'base64',
303
+ media_type: observation.screenshotMediaType || 'image/png',
304
+ data: observation.screenshotBase64
305
+ }
306
+ });
307
+ }
308
+ return { type: 'tool_result', tool_use_id: id, content, ...(isError ? { is_error: true } : {}) };
309
+ }
310
+
311
+ /**
312
+ * @param {string} id
313
+ * @param {string} text
314
+ * @param {boolean} [isError]
315
+ */
316
+ export function toolResult(id, text, isError = false) {
317
+ return { type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text }], ...(isError ? { is_error: true } : {}) };
318
+ }
319
+
320
+ /**
321
+ * What the persona can cite as `file_line` evidence.
322
+ *
323
+ * The Godot driver reports the current scene and the scenes and scripts
324
+ * behind it, which is the only way a finding from a running game can name a
325
+ * `res://` path. The dogfood gate asks for exactly that ("at least one
326
+ * finding cites a res:// scene or script path"), so the paths have to reach
327
+ * the model, not just the transcript.
328
+ *
329
+ * @param {Object} observation
330
+ * @returns {string}
331
+ */
332
+ export function citationLine(observation) {
333
+ const parts = [];
334
+ if (observation.scene) parts.push(`scene ${observation.scene}`);
335
+ if (Array.isArray(observation.scenes) && observation.scenes.length > 0) parts.push(`scenes ${observation.scenes.slice(0, 8).join(', ')}`);
336
+ if (Array.isArray(observation.scripts) && observation.scripts.length > 0) parts.push(`scripts ${observation.scripts.slice(0, 8).join(', ')}`);
337
+ return parts.length > 0 ? `\n[${parts.join(' | ')}] (cite these as file_line evidence)` : '';
338
+ }
339
+
340
+ function message(error) {
341
+ return String(error && error.message ? error.message : error);
342
+ }
343
+
344
+ export default { PLAY_TOOLS, REPORT_TOOLS, executeTool, actionFor, toolResult, observationResult };
@@ -0,0 +1,93 @@
1
+ import { createWriteStream } from 'node:fs';
2
+ import { mkdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { RUN_FILES } from './paths.js';
5
+ import { scanForSecrets } from './secretScan.js';
6
+
7
+ /**
8
+ * `transcript.jsonl`, one JSON object per line.
9
+ *
10
+ * Two things depend on the exact contents:
11
+ *
12
+ * 1. `regenerate` rebuilds `report.md` and `report.json` from the transcript
13
+ * plus the screenshots, with the game not running (spec 03). So every
14
+ * input the report step reads has to be in here: the header line, each
15
+ * observation, each action, each note, each screenshot name.
16
+ * 2. Evidence refs are written as `step N`, where N is the step field on
17
+ * these lines. A transcript that renumbers on resume would invalidate
18
+ * every evidence ref in the report, so a resumed run starts a new
19
+ * transcript in a new run directory rather than appending.
20
+ *
21
+ * The file is opt-in for upload (spec 17 section 1.7) and the server expires
22
+ * it at 90 days, so it is written in full locally either way.
23
+ *
24
+ * Every line is scanned before it is written, because the transcript is the
25
+ * one artifact whose content the CLI does not author: it carries whatever the
26
+ * game printed and whatever the model quoted back. A leaked key would
27
+ * otherwise sit in a file the developer may then opt into uploading, and the
28
+ * server's own scan only ever sees the report. A hit is refused and replaced
29
+ * by a line naming the shapes, never the value, and the run continues: losing
30
+ * one transcript line is better than losing the playthrough, and the
31
+ * `secret_withheld` line is what tells anyone reading it that something was
32
+ * dropped.
33
+ */
34
+ export function createTranscript(runDirectory) {
35
+ const target = path.join(runDirectory, RUN_FILES.transcript);
36
+ /** @type {import('node:fs').WriteStream|null} */
37
+ let stream = null;
38
+ let lines = 0;
39
+ let withheld = 0;
40
+
41
+ return {
42
+ path: target,
43
+
44
+ async open() {
45
+ await mkdir(runDirectory, { recursive: true });
46
+ stream = createWriteStream(target, { flags: 'a' });
47
+ return this;
48
+ },
49
+
50
+ /**
51
+ * @param {Object} line
52
+ */
53
+ write(line) {
54
+ if (!stream) throw new Error('transcript is not open');
55
+ const body = JSON.stringify({ t: Date.now(), ...line });
56
+ const secrets = scanForSecrets(body);
57
+ lines += 1;
58
+ if (secrets.length > 0) {
59
+ withheld += 1;
60
+ // The shapes, never the value: this file is the last place a found
61
+ // secret should be written out.
62
+ stream.write(`${JSON.stringify({
63
+ t: Date.now(),
64
+ kind: 'secret_withheld',
65
+ of: line.kind || 'unknown',
66
+ shapes: secrets.map(secret => `${secret.type} (${secret.count})`)
67
+ })}\n`);
68
+ return;
69
+ }
70
+ stream.write(`${body}\n`);
71
+ },
72
+
73
+ get lineCount() {
74
+ return lines;
75
+ },
76
+
77
+ /** How many lines were refused for looking secret shaped. */
78
+ get withheldCount() {
79
+ return withheld;
80
+ },
81
+
82
+ async close() {
83
+ if (!stream) return;
84
+ const closing = stream;
85
+ stream = null;
86
+ await new Promise((resolve, reject) => {
87
+ closing.end(error => (error ? reject(error) : resolve()));
88
+ });
89
+ }
90
+ };
91
+ }
92
+
93
+ export default createTranscript;
@@ -0,0 +1,115 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { RUN_FILES } from './paths.js';
4
+ import { assertNoSecrets } from './secretScan.js';
5
+
6
+ /**
7
+ * Token accounting for one run, and `usage.json`.
8
+ *
9
+ * Tokens are counted locally, off the stream: `message_start` and
10
+ * `message_delta` carry them, and the proxy re-emits both verbatim.
11
+ *
12
+ * **Dollars are not counted locally, and cannot be.** Per-token prices live
13
+ * in `routing.json`, which is server-only by design (CLAUDE.md: no model id
14
+ * or price anywhere in the API's `src/**`, and the client subset
15
+ * `clientRouting()` deliberately carries neither). So `total_usd` is taken
16
+ * from the server, which prices every call as it makes it: the run's
17
+ * `usage.total_usd` comes back on every heartbeat answer
18
+ * (`PATCH /jobs/:jobId/runs/:runId` -> `{ run: { usage: { total_usd, calls,
19
+ * wall_seconds } } }`). A locally invented figure would be a second,
20
+ * disagreeing number for the one thing the customer is paying, which is
21
+ * worse than no number.
22
+ *
23
+ * `by_model[].usd` therefore stays 0: the server reports the run total, not
24
+ * a per-model split, and splitting it here would be arithmetic we made up.
25
+ * The model ids themselves are real, read off `message_start.message.model`.
26
+ */
27
+ export function createUsageLedger() {
28
+ /** @type {Map<string, {model: string, input_tokens: number, cache_read_tokens: number, output_tokens: number, usd: number}>} */
29
+ const byModel = new Map();
30
+ const started = Date.now();
31
+ let calls = 0;
32
+ let serverTotalUsd = null;
33
+ let serverCalls = null;
34
+
35
+ return {
36
+ /**
37
+ * @param {{model: string|null, usage: Object}} turn
38
+ */
39
+ record({ model, usage }) {
40
+ calls += 1;
41
+ const key = model || 'unknown';
42
+ const entry = byModel.get(key) || { model: key, input_tokens: 0, cache_read_tokens: 0, output_tokens: 0, usd: 0 };
43
+ // The report schema has no cache-creation field, so creation tokens
44
+ // fold into input_tokens (they are billed as input) and only cache
45
+ // reads are broken out. The PoC did the same.
46
+ entry.input_tokens += Number(usage.input_tokens || 0) + Number(usage.cache_creation_input_tokens || 0);
47
+ entry.cache_read_tokens += Number(usage.cache_read_input_tokens || 0);
48
+ entry.output_tokens += Number(usage.output_tokens || 0);
49
+ byModel.set(key, entry);
50
+ },
51
+
52
+ /**
53
+ * The authoritative spend, from a heartbeat or run read.
54
+ *
55
+ * @param {{total_usd?: number, calls?: number}|null|undefined} runUsage
56
+ */
57
+ recordServerUsage(runUsage) {
58
+ if (!runUsage) return;
59
+ if (Number.isFinite(Number(runUsage.total_usd))) serverTotalUsd = Number(runUsage.total_usd);
60
+ if (Number.isFinite(Number(runUsage.calls))) serverCalls = Number(runUsage.calls);
61
+ },
62
+
63
+ get modelCalls() {
64
+ return calls;
65
+ },
66
+
67
+ /**
68
+ * The `usage` object as the report schema wants it.
69
+ *
70
+ * @returns {{by_model: Array, total_usd: number, wall_seconds: number}}
71
+ */
72
+ toReportUsage() {
73
+ return {
74
+ by_model: [...byModel.values()],
75
+ total_usd: serverTotalUsd === null ? 0 : Number(serverTotalUsd.toFixed(6)),
76
+ wall_seconds: Number(((Date.now() - started) / 1000).toFixed(3))
77
+ };
78
+ },
79
+
80
+ /**
81
+ * `usage.json`, which is not schema-constrained and so can say where
82
+ * each number came from.
83
+ *
84
+ * @returns {Object}
85
+ */
86
+ toUsageFile() {
87
+ const report = this.toReportUsage();
88
+ return {
89
+ ...report,
90
+ local_model_calls: calls,
91
+ server_model_calls: serverCalls,
92
+ usd_source: serverTotalUsd === null ? 'unavailable' : 'server',
93
+ note: 'Token counts are read off the model stream. total_usd is the server figure, because per-token prices are server side only.'
94
+ };
95
+ }
96
+ };
97
+ }
98
+
99
+ /**
100
+ * @param {string} runDirectory
101
+ * @param {ReturnType<typeof createUsageLedger>} ledger
102
+ * @returns {Promise<string>} the path written
103
+ */
104
+ export async function writeUsageFile(runDirectory, ledger) {
105
+ const target = path.join(runDirectory, RUN_FILES.usageJson);
106
+ const body = `${JSON.stringify(ledger.toUsageFile(), null, 2)}\n`;
107
+ // Token counts and model ids should never look secret shaped, and that is
108
+ // the point of checking: this file is uploaded by default, so anything
109
+ // unexpected in it is refused rather than shipped.
110
+ assertNoSecrets('usage.json', body);
111
+ await writeFile(target, body, 'utf8');
112
+ return target;
113
+ }
114
+
115
+ export default { createUsageLedger, writeUsageFile };
@@ -0,0 +1,105 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { paths } from '../paths.js';
3
+ import { readJson, writeJson } from '../fsutil.js';
4
+ import { CliError } from '../errors.js';
5
+
6
+ export const STATE_VERSION = 1;
7
+
8
+ /**
9
+ * The resumable job journal, one file per job, at
10
+ * `<repo>/.ravensight/jobs/<jobId>/state.json`.
11
+ *
12
+ * Written atomically (temp file plus rename, see fsutil), because the whole
13
+ * point of the file is to survive the process being killed, and a half written
14
+ * journal is worse than none: `resume` would read it, believe a run finished
15
+ * that did not, and skip it.
16
+ *
17
+ * The contents beyond `job_id` and `game_id` belong to the runner. cli-core
18
+ * reads those two, plus `runs` when it is present, and treats everything else
19
+ * as opaque so a runner change never needs a change here.
20
+ */
21
+
22
+ /** The skeleton a runner starts from. */
23
+ export function newState({ jobId, gameId, ...rest }) {
24
+ return {
25
+ state_version: STATE_VERSION,
26
+ job_id: jobId,
27
+ game_id: gameId,
28
+ created_at: new Date().toISOString(),
29
+ updated_at: new Date().toISOString(),
30
+ runs: {},
31
+ ...rest
32
+ };
33
+ }
34
+
35
+ export const stateFileFor = (jobId, options = {}) => paths.stateFile(jobId, options);
36
+
37
+ /**
38
+ * Read a job's journal, or null when there is none.
39
+ * @param {string} jobId
40
+ * @param {{repoRoot?: string}} [options]
41
+ * @returns {Promise<Object|null>}
42
+ */
43
+ export async function readState(jobId, options = {}) {
44
+ return readJson(paths.stateFile(jobId, options), null);
45
+ }
46
+
47
+ /**
48
+ * Write a job's journal. `updated_at` is stamped here rather than by every
49
+ * caller, so a journal always says when it was last touched even if the writer
50
+ * forgot.
51
+ * @param {string} jobId
52
+ * @param {Object} state
53
+ * @param {{repoRoot?: string}} [options]
54
+ */
55
+ export async function writeState(jobId, state, options = {}) {
56
+ const next = { ...state, state_version: STATE_VERSION, updated_at: new Date().toISOString() };
57
+ await writeJson(paths.stateFile(jobId, options), next);
58
+ return next;
59
+ }
60
+
61
+ /**
62
+ * Read, transform, write. Not a lock: one job is driven by one process, and two
63
+ * processes on one job is a mistake no amount of locking here would make safe.
64
+ * @param {string} jobId
65
+ * @param {(state: Object|null) => Object} mutate
66
+ * @param {{repoRoot?: string}} [options]
67
+ */
68
+ export async function updateState(jobId, mutate, options = {}) {
69
+ const current = await readState(jobId, options);
70
+ const next = mutate(current);
71
+ if (!next || typeof next !== 'object') {
72
+ throw new CliError('updateState needs an object back from its mutator.');
73
+ }
74
+ return writeState(jobId, next, options);
75
+ }
76
+
77
+ /**
78
+ * Every job this repo has a journal for, newest first.
79
+ * @param {{repoRoot?: string}} [options]
80
+ * @returns {Promise<Array<{job_id: string, game_id: string, updated_at: string, state: Object}>>}
81
+ */
82
+ export async function listStates(options = {}) {
83
+ let entries;
84
+ try {
85
+ entries = await readdir(paths.jobsDir(options), { withFileTypes: true });
86
+ } catch (error) {
87
+ if (error.code === 'ENOENT') return [];
88
+ throw error;
89
+ }
90
+
91
+ const found = [];
92
+ for (const entry of entries) {
93
+ if (!entry.isDirectory()) continue;
94
+ const state = await readState(entry.name, options);
95
+ if (!state) continue;
96
+ found.push({
97
+ job_id: state.job_id || entry.name,
98
+ game_id: state.game_id || null,
99
+ updated_at: state.updated_at || null,
100
+ state
101
+ });
102
+ }
103
+ found.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
104
+ return found;
105
+ }