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.
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/addons/ravensight_driver/driver.gd +836 -0
- package/addons/ravensight_driver/export_plugin.gd +51 -0
- package/addons/ravensight_driver/plugin.cfg +7 -0
- package/addons/ravensight_driver/plugin.gd +36 -0
- package/bin/ravensight-playtest.js +31 -0
- package/package.json +45 -0
- package/src/api/README.md +500 -0
- package/src/api/client.js +340 -0
- package/src/api/errors.js +115 -0
- package/src/api/http.js +194 -0
- package/src/api/index.js +107 -0
- package/src/auth/deviceCode.js +79 -0
- package/src/auth/keychain.js +159 -0
- package/src/auth/session.js +128 -0
- package/src/cli.js +335 -0
- package/src/commands/brief.js +303 -0
- package/src/commands/check.js +318 -0
- package/src/commands/fakeCore.js +379 -0
- package/src/commands/init.js +120 -0
- package/src/commands/login.js +90 -0
- package/src/commands/logout.js +70 -0
- package/src/commands/open.js +125 -0
- package/src/commands/profile.js +262 -0
- package/src/commands/resume.js +156 -0
- package/src/commands/run.js +1015 -0
- package/src/commands/upload.js +137 -0
- package/src/config.js +100 -0
- package/src/dashboard.js +97 -0
- package/src/detect.js +77 -0
- package/src/errors.js +44 -0
- package/src/fsutil.js +77 -0
- package/src/godot.js +85 -0
- package/src/packs/index.js +191 -0
- package/src/paths.js +129 -0
- package/src/run/aggregate.js +658 -0
- package/src/run/args.js +111 -0
- package/src/run/context.js +181 -0
- package/src/run/deps.js +184 -0
- package/src/run/drivers/driver.js +183 -0
- package/src/run/drivers/godot-observation.js +138 -0
- package/src/run/drivers/godot-project.js +475 -0
- package/src/run/drivers/godot-rpc.js +225 -0
- package/src/run/drivers/godot.js +587 -0
- package/src/run/drivers/index.js +52 -0
- package/src/run/drivers/web.js +385 -0
- package/src/run/exit.js +21 -0
- package/src/run/heartbeat.js +131 -0
- package/src/run/index.js +31 -0
- package/src/run/json.js +56 -0
- package/src/run/model.js +384 -0
- package/src/run/paths.js +88 -0
- package/src/run/personaLoop.js +871 -0
- package/src/run/profile.js +214 -0
- package/src/run/regenerate.js +149 -0
- package/src/run/repoTools.js +286 -0
- package/src/run/report.js +222 -0
- package/src/run/resume.js +272 -0
- package/src/run/secretScan.js +171 -0
- package/src/run/state.js +198 -0
- package/src/run/synthetic.js +206 -0
- package/src/run/tools.js +344 -0
- package/src/run/transcript.js +93 -0
- package/src/run/usage.js +115 -0
- package/src/state/index.js +105 -0
- package/src/states.js +104 -0
- package/src/ui/index.js +195 -0
- package/src/upload/allowlist.js +116 -0
- package/src/upload/index.js +467 -0
- package/src/upload/queue.js +114 -0
- 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
|
+
}
|