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,467 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join, sep } from 'node:path';
3
+ import { ApiError, isRetryable } from '../api/errors.js';
4
+ import { backoffDelay } from '../api/http.js';
5
+ import { sha256File } from '../fsutil.js';
6
+ import { CliError } from '../errors.js';
7
+ import {
8
+ MAX_FILES_PER_RUN,
9
+ MAX_RUN_BYTES,
10
+ MAX_VIDEO_BYTES,
11
+ VIDEO_PATHS,
12
+ contentTypeFor,
13
+ skipReason
14
+ } from './allowlist.js';
15
+ import { STATUS, record, uploadedKeys, keyFor } from './queue.js';
16
+
17
+ const PUT_RETRIES = 4;
18
+
19
+ /**
20
+ * Walk a directory into run relative, forward slashed paths. Symlinks are not
21
+ * followed: a run directory is written by this CLI, and a symlink in it is
22
+ * either a mistake or a way to get a file from outside the run into the bucket.
23
+ * @param {string} root
24
+ * @param {string} [prefix]
25
+ * @returns {Promise<string[]>}
26
+ */
27
+ export async function walkRunDir(root, prefix = '') {
28
+ let entries;
29
+ try {
30
+ entries = await readdir(join(root, prefix), { withFileTypes: true });
31
+ } catch (error) {
32
+ if (error.code === 'ENOENT') return [];
33
+ throw error;
34
+ }
35
+ const found = [];
36
+ for (const entry of entries) {
37
+ const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
38
+ if (entry.isDirectory()) {
39
+ found.push(...await walkRunDir(root, relPath));
40
+ } else if (entry.isFile()) {
41
+ found.push(relPath);
42
+ }
43
+ }
44
+ return found.sort();
45
+ }
46
+
47
+ /**
48
+ * Turn a directory into the `files` array the presign route wants, plus the
49
+ * list of everything deliberately left out and why.
50
+ *
51
+ * Reports come first in the returned order. That matters when a run is near the
52
+ * per run byte ceiling: the server plans uploads in the order it is given, so
53
+ * sending the report before the video is what makes a refusal cost the video
54
+ * rather than the findings.
55
+ *
56
+ * `allowedPaths`, when given, narrows the allowlist to exactly those run
57
+ * relative names. The job level upload uses it (see JOB_UPLOAD_PATHS) so that a
58
+ * file sitting in the job directory for some other reason is not swept into the
59
+ * bucket under a job key.
60
+ *
61
+ * @param {string} runDir
62
+ * @param {{video?: boolean, transcript?: boolean, allowedPaths?: string[]}} [opts]
63
+ * @returns {Promise<{files: Array, skipped: Array, bytes: number}>}
64
+ */
65
+ export async function planRunDir(runDir, opts = {}) {
66
+ const relPaths = await walkRunDir(runDir);
67
+ const files = [];
68
+ const skipped = [];
69
+ const narrowed = Array.isArray(opts.allowedPaths) ? new Set(opts.allowedPaths) : null;
70
+
71
+ for (const relPath of relPaths) {
72
+ const contentType = contentTypeFor(relPath, opts);
73
+ if (!contentType) {
74
+ skipped.push({ path: relPath, reason: skipReason(relPath, opts) });
75
+ continue;
76
+ }
77
+ if (narrowed && !narrowed.has(relPath)) {
78
+ skipped.push({ path: relPath, reason: 'not a job level artifact' });
79
+ continue;
80
+ }
81
+ const info = await stat(join(runDir, relPath.split('/').join(sep)));
82
+ if (VIDEO_PATHS.includes(relPath) && info.size > MAX_VIDEO_BYTES) {
83
+ skipped.push({ path: relPath, reason: `video is larger than ${MAX_VIDEO_BYTES} bytes` });
84
+ continue;
85
+ }
86
+ files.push({
87
+ path: relPath,
88
+ size: info.size,
89
+ sha256: await sha256File(join(runDir, relPath.split('/').join(sep))),
90
+ content_type: contentType
91
+ });
92
+ }
93
+
94
+ const priority = path => {
95
+ if (path === 'report.json' || path === 'report.md') return 0;
96
+ if (path === 'usage.json') return 1;
97
+ if (path.startsWith('screenshots/')) return 2;
98
+ return 3;
99
+ };
100
+ files.sort((a, b) => priority(a.path) - priority(b.path) || a.path.localeCompare(b.path));
101
+
102
+ const bytes = files.reduce((sum, file) => sum + file.size, 0);
103
+ if (files.length > MAX_FILES_PER_RUN) {
104
+ throw new CliError(
105
+ `That run has ${files.length} uploadable files, over the limit of ${MAX_FILES_PER_RUN}.`,
106
+ 1,
107
+ { hint: 'Drop some screenshots, or raise the screenshot budget in the run config.' }
108
+ );
109
+ }
110
+ if (bytes > MAX_RUN_BYTES) {
111
+ throw new CliError(
112
+ `That run is ${bytes} bytes, over the per run limit of ${MAX_RUN_BYTES}.`,
113
+ 1,
114
+ { hint: 'Turn off the video upload, or re-encode it smaller.' }
115
+ );
116
+ }
117
+ return { files, skipped, bytes };
118
+ }
119
+
120
+ /**
121
+ * The verbatim `x-amz-tagging` value for a presigned entry.
122
+ *
123
+ * The presign response is the authority: it carries back exactly what was
124
+ * signed, so the CLI never computes the tag itself. Two shapes are accepted
125
+ * because the two server tasks that meet here spell it differently: W8's
126
+ * `presignPut` returns the encoded string, and W4b's upload route currently
127
+ * passes the tag object through. Either is normalized to the string S3 wants;
128
+ * a mismatch would be a SignatureDoesNotMatch rather than an untagged object,
129
+ * so guessing is not an option and accepting both is.
130
+ *
131
+ * @param {unknown} tagging
132
+ * @returns {string|null}
133
+ */
134
+ export function taggingHeader(tagging) {
135
+ if (tagging === null || tagging === undefined || tagging === '') return null;
136
+ if (typeof tagging === 'string') return tagging;
137
+ if (typeof tagging === 'object' && !Array.isArray(tagging)) {
138
+ const params = new URLSearchParams();
139
+ for (const [key, value] of Object.entries(tagging)) params.set(key, String(value));
140
+ const encoded = params.toString();
141
+ return encoded || null;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * PUT one file at a presigned URL.
148
+ *
149
+ * The headers are exactly the ones the URL was signed with: the content type,
150
+ * `x-amz-tagging` when the presign said so, and `x-amz-checksum-sha256` with
151
+ * the base64 digest the presign echoed back. The checksum header is what
152
+ * makes S3 hash the body it receives and refuse one that differs, and what
153
+ * lets the server's `complete` read the checksum back; a presign that names
154
+ * none is an older server, and the header is simply not sent. An extra
155
+ * signed header that is missing, or a value that differs by one character,
156
+ * is a SignatureDoesNotMatch from S3 and not a retryable failure, so nothing
157
+ * is added here on a guess.
158
+ *
159
+ * @param {Object} options
160
+ * @param {{path: string, put_url: string, tagging?: unknown, checksum_sha256?: string}} options.entry
161
+ * @param {string} options.contentType
162
+ * @param {Buffer} options.body
163
+ * @param {typeof fetch} [options.fetchImpl]
164
+ * @param {number} [options.retries]
165
+ * @param {Function} [options.sleep]
166
+ * @param {() => number} [options.random]
167
+ * @returns {Promise<void>}
168
+ */
169
+ export async function putFile(options) {
170
+ const {
171
+ entry,
172
+ contentType,
173
+ body,
174
+ fetchImpl = globalThis.fetch,
175
+ retries = PUT_RETRIES,
176
+ sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
177
+ random = Math.random
178
+ } = options;
179
+
180
+ const headers = { 'content-type': contentType };
181
+ const tagging = taggingHeader(entry.tagging);
182
+ if (tagging) headers['x-amz-tagging'] = tagging;
183
+ if (typeof entry.checksum_sha256 === 'string' && entry.checksum_sha256.length > 0) {
184
+ headers['x-amz-checksum-sha256'] = entry.checksum_sha256;
185
+ }
186
+
187
+ let lastError = null;
188
+ for (let attempt = 1; attempt <= retries + 1; attempt += 1) {
189
+ let response = null;
190
+ try {
191
+ response = await fetchImpl(entry.put_url, { method: 'PUT', headers, body });
192
+ } catch (cause) {
193
+ lastError = new ApiError({
194
+ status: 0,
195
+ code: 'network_error',
196
+ message: `PUT ${entry.path} failed: ${cause.message}`,
197
+ method: 'PUT',
198
+ path: entry.path,
199
+ cause
200
+ });
201
+ }
202
+
203
+ if (response && response.ok) return;
204
+ if (response) {
205
+ const text = await response.text().catch(() => '');
206
+ lastError = new ApiError({
207
+ status: response.status,
208
+ code: response.status === 403 ? 'presign_expired' : `s3_${response.status}`,
209
+ message: `PUT ${entry.path} answered ${response.status}: ${text.slice(0, 300)}`,
210
+ method: 'PUT',
211
+ path: entry.path
212
+ });
213
+ // A 403 is the expired or otherwise invalid URL, which a retry against
214
+ // the same URL can never fix. The caller re-presigns and calls again.
215
+ if (response.status === 403) throw lastError;
216
+ }
217
+
218
+ if (attempt > retries || !isRetryable(lastError)) throw lastError;
219
+ await sleep(backoffDelay(attempt, lastError.retryAfter, random));
220
+ }
221
+ throw lastError;
222
+ }
223
+
224
+ /**
225
+ * Ask the right presign route for a run or a job.
226
+ *
227
+ * `include_video` is ONE flag on the server and it gates BOTH opt in artifacts:
228
+ * `limits.contentTypeFor` skips every `optIn` entry when it is false, and the
229
+ * transcript is an `optIn` entry. So a transcript only upload sent with
230
+ * `include_video: false` is refused, and the refusal is a 400 on the whole
231
+ * batch rather than on the one path. Until the server grows a second flag, the
232
+ * honest reading of the one it has is "include the opt in artifacts", and that
233
+ * is what is sent.
234
+ */
235
+ function presignWith({ api, gameId, jobId, runId, includeVideo, includeTranscript }) {
236
+ const includeOptIn = Boolean(includeVideo) || Boolean(includeTranscript);
237
+ return files => (runId
238
+ ? api.runs.uploads(gameId, jobId, runId, { files, includeVideo: includeOptIn })
239
+ : api.jobs.uploads(gameId, jobId, { files }));
240
+ }
241
+
242
+ /**
243
+ * Upload one run directory.
244
+ *
245
+ * Presign every file in one request, then PUT each. A 403 on a PUT means the
246
+ * URL expired (they last 15 minutes, and a big video upload can outlive one):
247
+ * that file is presigned again, once, and retried. Everything else retries with
248
+ * backoff inside `putFile`.
249
+ *
250
+ * Every attempt is journalled to the job's `upload-queue.jsonl`, and a file
251
+ * already recorded as uploaded at the same sha256 is skipped rather than sent
252
+ * twice. That is what makes this safe to call again after a crash, which is
253
+ * what `resume` and `upload <job>` do.
254
+ *
255
+ * @param {string} runDir
256
+ * @param {Object} options
257
+ * @param {Object} options.api
258
+ * @param {string} options.gameId
259
+ * @param {string} options.jobId
260
+ * @param {string} [options.runId] - omit for a job level upload
261
+ * @param {boolean} [options.includeVideo]
262
+ * @param {boolean} [options.includeTranscript]
263
+ * @param {boolean} [options.dryRun]
264
+ * @param {boolean} [options.force] - re-send files the journal says are done
265
+ * @param {string[]} [options.only] - restrict to these run relative paths
266
+ * @param {string[]} [options.allowedPaths] - narrow the allowlist to these names
267
+ * @param {(info: Object) => void} [options.onFile]
268
+ * @param {{repoRoot?: string}} [options.repoRoot]
269
+ * @param {typeof fetch} [options.fetch]
270
+ * @param {Function} [options.sleep]
271
+ * @returns {Promise<{uploaded: Array, skipped: Array, bytes: number, dryRun: boolean}>}
272
+ */
273
+ export async function uploadRunDir(runDir, options) {
274
+ const {
275
+ api, gameId, jobId, runId = null,
276
+ includeVideo = false, includeTranscript = false,
277
+ dryRun = false, force = false, only = null, allowedPaths = null,
278
+ onFile, repoRoot, fetch: fetchImpl, sleep
279
+ } = options;
280
+
281
+ const journalOptions = { repoRoot };
282
+ const plan = await planRunDir(runDir, {
283
+ video: includeVideo,
284
+ transcript: includeTranscript,
285
+ allowedPaths
286
+ });
287
+ let files = plan.files;
288
+ const skipped = [...plan.skipped];
289
+
290
+ if (Array.isArray(only)) {
291
+ const wanted = new Set(only);
292
+ for (const file of files) {
293
+ if (!wanted.has(file.path)) skipped.push({ path: file.path, reason: 'not in the requested set' });
294
+ }
295
+ files = files.filter(file => wanted.has(file.path));
296
+ }
297
+
298
+ if (!force) {
299
+ const done = await uploadedKeys(jobId, journalOptions);
300
+ const remaining = [];
301
+ for (const file of files) {
302
+ if (done.has(keyFor(runId, file.path, file.sha256))) {
303
+ skipped.push({ path: file.path, reason: 'already uploaded' });
304
+ } else {
305
+ remaining.push(file);
306
+ }
307
+ }
308
+ files = remaining;
309
+ }
310
+
311
+ if (dryRun) {
312
+ for (const file of files) {
313
+ if (onFile) onFile({ ...file, status: 'dry-run' });
314
+ }
315
+ return { uploaded: files, skipped, bytes: files.reduce((sum, f) => sum + f.size, 0), dryRun: true };
316
+ }
317
+
318
+ if (files.length === 0) {
319
+ return { uploaded: [], skipped, bytes: 0, dryRun: false };
320
+ }
321
+
322
+ const presign = presignWith({ api, gameId, jobId, runId, includeVideo, includeTranscript });
323
+ const request = files.map(({ path, size, sha256, content_type: contentType }) => ({
324
+ path, size, sha256, content_type: contentType
325
+ }));
326
+
327
+ // The presign comes first, and the journal records `queued` only once it
328
+ // succeeds. Journalling first would leave a queue full of entries for files
329
+ // the server refused to sign at all, and `upload <job>` would then report
330
+ // them as owed forever, retrying a request that cannot start working. A
331
+ // refusal is recorded as `failed` with the server's own message, because
332
+ // "that path is not one a playtest run may upload" is the whole diagnosis.
333
+ let answer;
334
+ try {
335
+ answer = await presign(request);
336
+ } catch (error) {
337
+ const detail = error instanceof ApiError
338
+ ? [error.message, error.formatDetails()].filter(Boolean).join('\n')
339
+ : String(error && error.message);
340
+ for (const file of files) {
341
+ await record(jobId, {
342
+ kind: runId ? 'run' : 'job',
343
+ run_id: runId,
344
+ path: file.path,
345
+ sha256: file.sha256,
346
+ size: file.size,
347
+ status: STATUS.FAILED,
348
+ detail
349
+ }, journalOptions);
350
+ }
351
+ throw error;
352
+ }
353
+
354
+ for (const file of files) {
355
+ await record(jobId, {
356
+ kind: runId ? 'run' : 'job',
357
+ run_id: runId,
358
+ path: file.path,
359
+ sha256: file.sha256,
360
+ size: file.size,
361
+ status: STATUS.QUEUED
362
+ }, journalOptions);
363
+ }
364
+
365
+ const byPath = new Map((answer.urls || []).map(entry => [entry.path, entry]));
366
+ const uploaded = [];
367
+
368
+ for (const file of files) {
369
+ const entry = byPath.get(file.path);
370
+ if (!entry) {
371
+ throw new CliError(`The server presigned nothing for ${file.path}.`);
372
+ }
373
+ const body = await readFile(join(runDir, file.path.split('/').join(sep)));
374
+
375
+ try {
376
+ await putFile({ entry, contentType: file.content_type, body, fetchImpl, sleep });
377
+ } catch (error) {
378
+ if (error instanceof ApiError && error.code === 'presign_expired') {
379
+ // One retry with a fresh URL. A second 403 is not an expiry, it is a
380
+ // policy or a clock problem, and hiding that behind a loop would turn a
381
+ // configuration error into a hang.
382
+ const refreshed = await presign([{
383
+ path: file.path, size: file.size, sha256: file.sha256, content_type: file.content_type
384
+ }]);
385
+ const fresh = (refreshed.urls || []).find(url => url.path === file.path);
386
+ if (!fresh) throw error;
387
+ await putFile({ entry: fresh, contentType: file.content_type, body, fetchImpl, sleep });
388
+ } else {
389
+ await record(jobId, {
390
+ kind: runId ? 'run' : 'job',
391
+ run_id: runId,
392
+ path: file.path,
393
+ sha256: file.sha256,
394
+ size: file.size,
395
+ status: STATUS.FAILED,
396
+ detail: error.message
397
+ }, journalOptions);
398
+ throw error;
399
+ }
400
+ }
401
+
402
+ await record(jobId, {
403
+ kind: runId ? 'run' : 'job',
404
+ run_id: runId,
405
+ path: file.path,
406
+ sha256: file.sha256,
407
+ size: file.size,
408
+ status: STATUS.UPLOADED
409
+ }, journalOptions);
410
+ uploaded.push(file);
411
+ if (onFile) onFile({ ...file, status: 'uploaded' });
412
+ }
413
+
414
+ return { uploaded, skipped, bytes: uploaded.reduce((sum, f) => sum + f.size, 0), dryRun: false };
415
+ }
416
+
417
+ /**
418
+ * The paths a 422 `upload_unverified` named. The server writes them as
419
+ * `instancePath: '/manifest/<run relative path>'`, so this is the one place
420
+ * that spelling is parsed.
421
+ * @param {ApiError} error
422
+ * @returns {string[]}
423
+ */
424
+ export function unverifiedPaths(error) {
425
+ return (error.details || [])
426
+ .map(detail => String(detail.instancePath || ''))
427
+ .filter(pointer => pointer.startsWith('/manifest/'))
428
+ .map(pointer => pointer.slice('/manifest/'.length));
429
+ }
430
+
431
+ /**
432
+ * Upload a run directory and complete the run.
433
+ *
434
+ * A 422 `upload_unverified` is the one refusal worth repairing automatically:
435
+ * it means an object in the bucket does not match what was declared, the run is
436
+ * deliberately left non terminal so a corrected upload is possible, and the
437
+ * correction is simply to send those exact files again. So the named paths are
438
+ * re-uploaded (forced, ignoring the journal, since the journal is what was
439
+ * wrong) and `complete` is called once more. A second failure throws.
440
+ *
441
+ * 422 `invalid_report` and 422 `secrets_detected` are NOT repaired. A report the
442
+ * schema refuses needs regenerating, and a secret needs rotating; retrying
443
+ * either would just fail again more slowly and, in the secret case, would be
444
+ * the CLI trying to talk the server into storing a live key.
445
+ *
446
+ * @param {string} runDir
447
+ * @param {Object} options - as uploadRunDir, plus `body` for the complete call
448
+ * @returns {Promise<Object>} the `complete` response
449
+ */
450
+ export async function finalizeRun(runDir, options) {
451
+ const { api, gameId, jobId, runId, body = {} } = options;
452
+ if (!runId) throw new TypeError('finalizeRun needs a runId');
453
+
454
+ await uploadRunDir(runDir, options);
455
+
456
+ try {
457
+ return await api.runs.complete(gameId, jobId, runId, body);
458
+ } catch (error) {
459
+ if (!(error instanceof ApiError) || error.status !== 422 || error.code !== 'upload_unverified') {
460
+ throw error;
461
+ }
462
+ const paths = unverifiedPaths(error);
463
+ if (paths.length === 0) throw error;
464
+ await uploadRunDir(runDir, { ...options, only: paths, force: true });
465
+ return api.runs.complete(gameId, jobId, runId, body);
466
+ }
467
+ }
@@ -0,0 +1,114 @@
1
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { paths } from '../paths.js';
4
+
5
+ /**
6
+ * `<repo>/.ravensight/jobs/<jobId>/upload-queue.jsonl`: an append only journal
7
+ * of every upload this CLI has attempted for a job.
8
+ *
9
+ * Append only, one JSON object per line, rather than a rewritten state file,
10
+ * for two reasons. An append is a single write that either lands or does not,
11
+ * so a kill mid upload cannot corrupt the history of the uploads that already
12
+ * finished. And the file doubles as a log: when a pilot user says "it uploaded
13
+ * nothing", this is the artifact that says what was tried and what the answer
14
+ * was.
15
+ *
16
+ * Each line is `{ at, kind, run_id, path, sha256, size, status, detail }`.
17
+ * `status` is one of `queued`, `uploaded`, `failed`, `skipped`.
18
+ *
19
+ * Idempotency is by sha256: a path recorded `uploaded` with the same hash is
20
+ * not sent again, which is what makes `resume` and the manual `upload <job>`
21
+ * safe to run repeatedly.
22
+ */
23
+
24
+ export const STATUS = Object.freeze({
25
+ QUEUED: 'queued',
26
+ UPLOADED: 'uploaded',
27
+ FAILED: 'failed',
28
+ SKIPPED: 'skipped'
29
+ });
30
+
31
+ /**
32
+ * Append one entry.
33
+ * @param {string} jobId
34
+ * @param {Object} entry
35
+ * @param {{repoRoot?: string}} [options]
36
+ */
37
+ export async function record(jobId, entry, options = {}) {
38
+ const file = paths.queueFile(jobId, options);
39
+ await mkdir(dirname(file), { recursive: true });
40
+ await appendFile(file, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`);
41
+ }
42
+
43
+ /**
44
+ * Read the journal. A line that does not parse is skipped rather than fatal: a
45
+ * torn final line from a kill is exactly the case this file exists to survive,
46
+ * and refusing to read the other 200 good lines because of it would be
47
+ * backwards.
48
+ * @param {string} jobId
49
+ * @param {{repoRoot?: string}} [options]
50
+ * @returns {Promise<Object[]>}
51
+ */
52
+ export async function readQueue(jobId, options = {}) {
53
+ let text;
54
+ try {
55
+ text = await readFile(paths.queueFile(jobId, options), 'utf8');
56
+ } catch (error) {
57
+ if (error.code === 'ENOENT') return [];
58
+ throw error;
59
+ }
60
+ const entries = [];
61
+ for (const line of text.split('\n')) {
62
+ if (!line.trim()) continue;
63
+ try {
64
+ entries.push(JSON.parse(line));
65
+ } catch {
66
+ // torn or hand edited line
67
+ }
68
+ }
69
+ return entries;
70
+ }
71
+
72
+ /**
73
+ * The set of `<runId>|<path>|<sha256>` triples already recorded as uploaded.
74
+ * The sha256 is part of the key on purpose: a corrected file at the same path
75
+ * has a different hash and must be sent again.
76
+ * @param {string} jobId
77
+ * @param {{repoRoot?: string}} [options]
78
+ * @returns {Promise<Set<string>>}
79
+ */
80
+ export async function uploadedKeys(jobId, options = {}) {
81
+ const done = new Set();
82
+ for (const entry of await readQueue(jobId, options)) {
83
+ if (entry.status === STATUS.UPLOADED) {
84
+ done.add(keyFor(entry.run_id || null, entry.path, entry.sha256));
85
+ }
86
+ }
87
+ return done;
88
+ }
89
+
90
+ export function keyFor(runId, path, sha256) {
91
+ return `${runId || ''}|${path}|${sha256}`;
92
+ }
93
+
94
+ /**
95
+ * Everything still owed: a path whose last entry is `queued` or `failed` and
96
+ * which was never recorded `uploaded` at that hash. What `upload <job>` drains.
97
+ * @param {string} jobId
98
+ * @param {{repoRoot?: string}} [options]
99
+ * @returns {Promise<Object[]>}
100
+ */
101
+ export async function pending(jobId, options = {}) {
102
+ const entries = await readQueue(jobId, options);
103
+ const done = new Set();
104
+ const latest = new Map();
105
+ for (const entry of entries) {
106
+ if (!entry.path) continue;
107
+ const key = keyFor(entry.run_id || null, entry.path, entry.sha256);
108
+ if (entry.status === STATUS.UPLOADED) done.add(key);
109
+ latest.set(key, entry);
110
+ }
111
+ return [...latest.entries()]
112
+ .filter(([key, entry]) => !done.has(key) && entry.status !== STATUS.SKIPPED)
113
+ .map(([, entry]) => entry);
114
+ }
package/src/version.js ADDED
@@ -0,0 +1,63 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { dirname, join } from 'node:path';
4
+
5
+ const here = dirname(fileURLToPath(import.meta.url));
6
+
7
+ /**
8
+ * This package's version, read from package.json rather than duplicated as a
9
+ * literal. It is sent as `cli_version` on every job register, and the server
10
+ * refuses a job from a CLI older than its `min_supported`, so a stale copy of
11
+ * this string would be a refusal nobody could explain.
12
+ */
13
+ export const CLI_VERSION = JSON.parse(
14
+ readFileSync(join(here, '..', 'package.json'), 'utf8')
15
+ ).version;
16
+
17
+ /** The `client` name sent with a device code request, shown on the approval page. */
18
+ export const CLIENT_NAME = 'ravensight-playtest';
19
+
20
+ /**
21
+ * The User-Agent a request carries. Takes the version rather than reading the
22
+ * constant, so a client built with an explicit `cliVersion` says so on the wire
23
+ * as well as in the `cli_version` field: the two disagreeing is exactly the kind
24
+ * of thing this header exists to let the server notice.
25
+ * @param {string} [version]
26
+ * @returns {string}
27
+ */
28
+ export function userAgentFor(version = CLI_VERSION) {
29
+ return `${CLIENT_NAME}/${version} node/${process.versions.node}`;
30
+ }
31
+
32
+ /** The User-Agent this build sends by default. */
33
+ export const USER_AGENT = userAgentFor();
34
+
35
+ /**
36
+ * Compare two dotted version strings. Returns a negative number when `a` is
37
+ * older than `b`, 0 when they are equal, positive when `a` is newer. Only the
38
+ * numeric prefix of each segment is compared, so `1.2.0-beta.3` sorts as
39
+ * `1.2.0`: a prerelease of a supported version is treated as that version,
40
+ * which is the forgiving direction for a version gate.
41
+ * @param {string} a
42
+ * @param {string} b
43
+ * @returns {number}
44
+ */
45
+ export function compareVersions(a, b) {
46
+ // The prerelease suffix is cut before splitting, not after: splitting first
47
+ // turns `1.2.0-beta.3` into four segments and the stray `3` would make it
48
+ // sort as NEWER than `1.2.0`, which is the wrong direction for a gate that
49
+ // decides whether this CLI is too old to run.
50
+ const parse = value => String(value || '0')
51
+ .split('-')[0]
52
+ .split('+')[0]
53
+ .split('.')
54
+ .map(part => parseInt(part, 10) || 0);
55
+ const left = parse(a);
56
+ const right = parse(b);
57
+ const length = Math.max(left.length, right.length);
58
+ for (let i = 0; i < length; i += 1) {
59
+ const diff = (left[i] || 0) - (right[i] || 0);
60
+ if (diff !== 0) return diff < 0 ? -1 : 1;
61
+ }
62
+ return 0;
63
+ }