@plune-ai/cli 0.21.0 → 0.23.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/README.md +29 -1
- package/dist/cli.cjs +387 -57
- package/dist/reporter-core.cjs +362 -49
- package/dist/reporter-core.d.cts +16 -1
- package/dist/reporter-core.d.ts +16 -1
- package/dist/reporter-core.js +358 -45
- package/package.json +1 -1
package/dist/reporter-core.d.ts
CHANGED
|
@@ -326,6 +326,10 @@ interface ReporterConfig {
|
|
|
326
326
|
labels?: string[];
|
|
327
327
|
/** Results per request. The platform's ceiling is 500. */
|
|
328
328
|
batchSize?: number;
|
|
329
|
+
/** Files in flight at once (`PLUNE_UPLOAD_CONCURRENCY`, 1 to 32, default 4). */
|
|
330
|
+
uploadConcurrency?: number;
|
|
331
|
+
/** Retries of a request the platform or a bucket asked to wait for (`PLUNE_UPLOAD_RETRIES`, 0 to 20, default 6). */
|
|
332
|
+
uploadRetries?: number;
|
|
329
333
|
/** Where unsent batches go. Defaults beside the run file the CLI already writes. */
|
|
330
334
|
fallbackPath?: string;
|
|
331
335
|
/**
|
|
@@ -427,6 +431,8 @@ interface EnvSettings {
|
|
|
427
431
|
keepOpen: boolean;
|
|
428
432
|
/** Variables that were set and have nowhere to land. The caller says so once. */
|
|
429
433
|
ignored: string[];
|
|
434
|
+
/** Variables that were set to something unusable and fell back to their default. The caller says so once. */
|
|
435
|
+
warnings: string[];
|
|
430
436
|
}
|
|
431
437
|
declare function readEnv(env?: NodeJS.ProcessEnv): EnvSettings;
|
|
432
438
|
/**
|
|
@@ -582,9 +588,18 @@ interface ClientOptions {
|
|
|
582
588
|
fetchImpl?: typeof fetch;
|
|
583
589
|
/** Test seam — the same idea as `fetchImpl`, so a retry test costs milliseconds, not seconds. */
|
|
584
590
|
wait?: (ms: number) => Promise<void>;
|
|
591
|
+
/** Retries of a file's requests (default 2 for the platform's, 3 for a bucket's): what `uploadRetries` is. */
|
|
592
|
+
uploadRetries?: number;
|
|
593
|
+
/** Test seam for the jitter: a number in [0, 1). */
|
|
594
|
+
random?: () => number;
|
|
595
|
+
/** Test seam for the clock the shared pause is read against. */
|
|
596
|
+
now?: () => number;
|
|
597
|
+
/** Called once for every retry that is about to wait. */
|
|
598
|
+
onRetry?: () => void;
|
|
585
599
|
}
|
|
586
600
|
interface PlatformClient {
|
|
587
|
-
|
|
601
|
+
/** `retries` after the first try: the default is 2; a file's requests ask for more. */
|
|
602
|
+
post<T>(path: string, body: unknown, retries?: number): Promise<ClientOutcome<T>>;
|
|
588
603
|
/**
|
|
589
604
|
* A file's bytes as the body, under the file's own type (platform ADR 0040) — not JSON. Through the
|
|
590
605
|
* same retries as a POST, so a 429 on the platform's upload limit waits out its `Retry-After`.
|
package/dist/reporter-core.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/reporter-core/session.ts
|
|
2
|
-
import { createHash, randomUUID } from "crypto";
|
|
2
|
+
import { createHash as createHash2, randomUUID } from "crypto";
|
|
3
3
|
import { promises as disk } from "fs";
|
|
4
4
|
import { basename as basename2, extname } from "path";
|
|
5
5
|
import { TextDecoder } from "util";
|
|
@@ -35,7 +35,17 @@ import { createReadStream, openAsBlob } from "fs";
|
|
|
35
35
|
import { pipeline } from "stream";
|
|
36
36
|
import { request as httpRequest } from "http";
|
|
37
37
|
import { request as httpsRequest } from "https";
|
|
38
|
-
var
|
|
38
|
+
var BACKOFF_BASE_MS = 300;
|
|
39
|
+
var BACKOFF_CAP_MS = 1e4;
|
|
40
|
+
var DEFAULT_RETRIES = 2;
|
|
41
|
+
var RETRY_AFTER_CAP_MS = 6e4;
|
|
42
|
+
function parseRetryAfter(raw, now = Date.now()) {
|
|
43
|
+
const text = raw?.trim();
|
|
44
|
+
if (text === void 0 || text === "") return null;
|
|
45
|
+
const seconds = /^\d+(\.\d+)?$/.test(text) ? Number(text) : (Date.parse(text) - now) / 1e3;
|
|
46
|
+
if (!Number.isFinite(seconds)) return null;
|
|
47
|
+
return Math.min(Math.max(seconds, 0) * 1e3, RETRY_AFTER_CAP_MS);
|
|
48
|
+
}
|
|
39
49
|
var IDLE_MS = 3e5;
|
|
40
50
|
function nodeTransport(url, init, idleMs = IDLE_MS) {
|
|
41
51
|
return new Promise((resolve2, reject) => {
|
|
@@ -66,7 +76,6 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
|
|
|
66
76
|
req.end(init.body);
|
|
67
77
|
});
|
|
68
78
|
}
|
|
69
|
-
var PUT_DELAYS_MS = [500, 1500, 4e3];
|
|
70
79
|
var withoutLength = (headers) => Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-length"));
|
|
71
80
|
function nodePut(url, headers, source, idleMs) {
|
|
72
81
|
return new Promise((resolve2, reject) => {
|
|
@@ -111,12 +120,6 @@ async function detailOf(res) {
|
|
|
111
120
|
return { detail: "" };
|
|
112
121
|
}
|
|
113
122
|
}
|
|
114
|
-
function retryAfterMs(res) {
|
|
115
|
-
const raw = res.headers.get("retry-after");
|
|
116
|
-
if (raw === null) return null;
|
|
117
|
-
const seconds = Number(raw.trim());
|
|
118
|
-
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : null;
|
|
119
|
-
}
|
|
120
123
|
function classify(status) {
|
|
121
124
|
if (status === 401 || status === 403) return "auth";
|
|
122
125
|
if (status === 409) return "conflict";
|
|
@@ -127,15 +130,34 @@ function classify(status) {
|
|
|
127
130
|
function createClient(opts) {
|
|
128
131
|
const doFetch = opts.fetchImpl ?? nodeTransport;
|
|
129
132
|
const wait = opts.wait ?? sleep;
|
|
133
|
+
const uploadRetries = opts.uploadRetries === void 0 ? void 0 : Math.max(0, Math.floor(opts.uploadRetries));
|
|
134
|
+
const random = opts.random ?? Math.random;
|
|
135
|
+
const now = opts.now ?? Date.now;
|
|
136
|
+
let pausedUntil = 0;
|
|
137
|
+
const jittered = (ms) => Math.round(ms * (1 + random() * 0.5));
|
|
138
|
+
async function backOff(attempt, said) {
|
|
139
|
+
opts.onRetry?.();
|
|
140
|
+
const ms = jittered(said ?? Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
|
|
141
|
+
const until = now() + ms;
|
|
142
|
+
pausedUntil = Math.max(pausedUntil, until);
|
|
143
|
+
await wait(ms);
|
|
144
|
+
return until;
|
|
145
|
+
}
|
|
146
|
+
async function respectPause(mine) {
|
|
147
|
+
const left = pausedUntil - now();
|
|
148
|
+
if (pausedUntil > mine && left > 0) await wait(left + Math.round(random() * 250));
|
|
149
|
+
}
|
|
130
150
|
const base = opts.apiUrl.replace(/\/+$/, "");
|
|
131
151
|
const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
|
|
132
|
-
async function send(method, path3, payload) {
|
|
152
|
+
async function send(method, path3, payload, retries = DEFAULT_RETRIES) {
|
|
133
153
|
let last = {
|
|
134
154
|
kind: "unavailable",
|
|
135
155
|
status: null,
|
|
136
156
|
detail: "no attempt was made"
|
|
137
157
|
};
|
|
138
|
-
|
|
158
|
+
let mine = 0;
|
|
159
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
160
|
+
await respectPause(mine);
|
|
139
161
|
let res;
|
|
140
162
|
try {
|
|
141
163
|
res = await doFetch(`${base}${path3}`, {
|
|
@@ -148,7 +170,8 @@ function createClient(opts) {
|
|
|
148
170
|
});
|
|
149
171
|
} catch (err) {
|
|
150
172
|
last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
|
|
151
|
-
if (attempt
|
|
173
|
+
if (attempt >= Math.min(retries, DEFAULT_RETRIES)) break;
|
|
174
|
+
mine = await backOff(attempt, null);
|
|
152
175
|
continue;
|
|
153
176
|
}
|
|
154
177
|
if (res.ok) {
|
|
@@ -163,25 +186,28 @@ function createClient(opts) {
|
|
|
163
186
|
const kind = classify(res.status);
|
|
164
187
|
last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
|
|
165
188
|
if (kind !== "unavailable") break;
|
|
166
|
-
|
|
189
|
+
const said429 = parseRetryAfter(res.headers.get("retry-after"), now());
|
|
190
|
+
if (res.status === 429 && said429 === null) {
|
|
167
191
|
last.kind = "invalid";
|
|
168
192
|
last.detail = "quota exceeded (no Retry-After)";
|
|
169
193
|
if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
|
|
170
194
|
break;
|
|
171
195
|
}
|
|
172
|
-
if (attempt
|
|
173
|
-
|
|
174
|
-
}
|
|
196
|
+
if (attempt >= (said429 === null ? Math.min(retries, DEFAULT_RETRIES) : retries)) break;
|
|
197
|
+
mine = await backOff(attempt, said429);
|
|
175
198
|
}
|
|
176
199
|
return { ok: false, ...last };
|
|
177
200
|
}
|
|
178
201
|
return {
|
|
179
|
-
post: (path3, body) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }),
|
|
180
|
-
upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }),
|
|
202
|
+
post: (path3, body, retries) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }, retries),
|
|
203
|
+
upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
|
|
181
204
|
del: (path3) => send("DELETE", path3),
|
|
182
205
|
async put(url, headers, source) {
|
|
206
|
+
const putRetries = uploadRetries ?? 3;
|
|
183
207
|
let last = { ok: false, status: null, code: "network" };
|
|
184
|
-
|
|
208
|
+
let mine = 0;
|
|
209
|
+
for (let attempt = 0; attempt <= putRetries; attempt += 1) {
|
|
210
|
+
await respectPause(mine);
|
|
185
211
|
try {
|
|
186
212
|
let status;
|
|
187
213
|
let retryAfter;
|
|
@@ -196,15 +222,15 @@ function createClient(opts) {
|
|
|
196
222
|
if (status >= 200 && status <= 299) return { ok: true };
|
|
197
223
|
last = { ok: false, status, code: `bucket_${status}` };
|
|
198
224
|
if (status < 500 && status !== 429) return last;
|
|
199
|
-
const
|
|
200
|
-
if (attempt
|
|
201
|
-
|
|
202
|
-
}
|
|
225
|
+
const said = parseRetryAfter(retryAfter, now());
|
|
226
|
+
if (attempt >= (said === null ? Math.min(putRetries, 3) : putRetries)) return last;
|
|
227
|
+
mine = await backOff(attempt, said);
|
|
203
228
|
continue;
|
|
204
229
|
} catch (err) {
|
|
205
230
|
last = { ok: false, status: null, code: codeOfError(err) };
|
|
206
231
|
}
|
|
207
|
-
if (attempt
|
|
232
|
+
if (attempt >= Math.min(putRetries, 3)) return last;
|
|
233
|
+
mine = await backOff(attempt, null);
|
|
208
234
|
}
|
|
209
235
|
return last;
|
|
210
236
|
}
|
|
@@ -214,6 +240,8 @@ function createClient(opts) {
|
|
|
214
240
|
// src/reporter-core/env.ts
|
|
215
241
|
import * as path from "path";
|
|
216
242
|
var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
|
|
243
|
+
var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 4 };
|
|
244
|
+
var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
|
|
217
245
|
function value(env, name) {
|
|
218
246
|
const raw = env[name]?.trim();
|
|
219
247
|
return raw === void 0 || raw === "" ? void 0 : raw;
|
|
@@ -228,6 +256,14 @@ function count(env, name) {
|
|
|
228
256
|
const n = Number(raw);
|
|
229
257
|
return Number.isInteger(n) && n > 0 ? n : void 0;
|
|
230
258
|
}
|
|
259
|
+
function bounded(env, name, range, warnings) {
|
|
260
|
+
const raw = value(env, name);
|
|
261
|
+
if (raw === void 0) return void 0;
|
|
262
|
+
const n = Number(raw);
|
|
263
|
+
if (/^\d+$/.test(raw) && n >= range.min && n <= range.max) return n;
|
|
264
|
+
warnings.push(`${name}=${JSON.stringify(raw.slice(0, 40))} is not a whole number from ${range.min} to ${range.max} \u2014 using ${range.default}.`);
|
|
265
|
+
return void 0;
|
|
266
|
+
}
|
|
231
267
|
function list(env, name) {
|
|
232
268
|
const raw = value(env, name);
|
|
233
269
|
if (raw === void 0) return void 0;
|
|
@@ -240,6 +276,9 @@ function readEnv(env = process.env) {
|
|
|
240
276
|
const apiUrl = value(env, "PLUNE_API_URL");
|
|
241
277
|
const fallbackPath = value(env, "PLUNE_FALLBACK");
|
|
242
278
|
const batchSize = count(env, "PLUNE_BATCH_SIZE");
|
|
279
|
+
const warnings = [];
|
|
280
|
+
const uploadConcurrency = bounded(env, "PLUNE_UPLOAD_CONCURRENCY", UPLOAD_CONCURRENCY, warnings);
|
|
281
|
+
const uploadRetries = bounded(env, "PLUNE_UPLOAD_RETRIES", UPLOAD_RETRIES, warnings);
|
|
243
282
|
const title = value(env, "PLUNE_RUN_TITLE");
|
|
244
283
|
const environment = value(env, "PLUNE_ENV");
|
|
245
284
|
const labels = list(env, "PLUNE_LABELS");
|
|
@@ -252,6 +291,8 @@ function readEnv(env = process.env) {
|
|
|
252
291
|
...apiUrl !== void 0 ? { apiUrl } : {},
|
|
253
292
|
...fallbackPath !== void 0 ? { fallbackPath } : {},
|
|
254
293
|
...batchSize !== void 0 ? { batchSize } : {},
|
|
294
|
+
...uploadConcurrency !== void 0 ? { uploadConcurrency } : {},
|
|
295
|
+
...uploadRetries !== void 0 ? { uploadRetries } : {},
|
|
255
296
|
...title !== void 0 ? { title } : {},
|
|
256
297
|
...environment !== void 0 ? { environment } : {},
|
|
257
298
|
...labels !== void 0 ? { labels } : {},
|
|
@@ -264,7 +305,8 @@ function readEnv(env = process.env) {
|
|
|
264
305
|
// reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
|
|
265
306
|
// way this process must not be the one to call it finished.
|
|
266
307
|
keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED"),
|
|
267
|
-
ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0)
|
|
308
|
+
ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
|
|
309
|
+
warnings
|
|
268
310
|
};
|
|
269
311
|
}
|
|
270
312
|
function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cwd = process.cwd()) {
|
|
@@ -428,6 +470,27 @@ function webLink(href) {
|
|
|
428
470
|
return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
|
|
429
471
|
}
|
|
430
472
|
|
|
473
|
+
// src/reporter-core/files-batch.ts
|
|
474
|
+
import { createHash } from "crypto";
|
|
475
|
+
import { createReadStream as createReadStream2 } from "fs";
|
|
476
|
+
var FILES_BATCH_MAX = 100;
|
|
477
|
+
var DIRECT_BATCH_BYTES = 6 * 1024 * 1024;
|
|
478
|
+
var BATCH_PIPELINES = 2;
|
|
479
|
+
async function sha256File(path3) {
|
|
480
|
+
const hash = createHash("sha256");
|
|
481
|
+
for await (const part of createReadStream2(path3)) hash.update(part);
|
|
482
|
+
return hash.digest("hex");
|
|
483
|
+
}
|
|
484
|
+
var sha256Bytes = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
485
|
+
var BUCKET_CODES = /* @__PURE__ */ new Set([
|
|
486
|
+
"bucket_unreachable",
|
|
487
|
+
"not_connected",
|
|
488
|
+
"platform_not_configured",
|
|
489
|
+
"upload_expired",
|
|
490
|
+
"file_mismatch",
|
|
491
|
+
"file_missing"
|
|
492
|
+
]);
|
|
493
|
+
|
|
431
494
|
// src/reporter-core/fallback.ts
|
|
432
495
|
import * as fs from "fs";
|
|
433
496
|
import * as path2 from "path";
|
|
@@ -450,7 +513,7 @@ function appendBatch(file, batch) {
|
|
|
450
513
|
var RESOLVE_CHUNK = 500;
|
|
451
514
|
var BATCH_MAX = 500;
|
|
452
515
|
var DISCOVER_MAX = 500;
|
|
453
|
-
var BATCH_DEFAULT =
|
|
516
|
+
var BATCH_DEFAULT = 500;
|
|
454
517
|
var EXPECTED_MAX = 1e4;
|
|
455
518
|
var KEY_MAX = 1024;
|
|
456
519
|
var TITLE_MAX = 300;
|
|
@@ -468,7 +531,6 @@ var TYPE_BY_EXTENSION = {
|
|
|
468
531
|
".webp": "image/webp"
|
|
469
532
|
};
|
|
470
533
|
var METADATA_TYPE2 = "application/plune.metadata+json";
|
|
471
|
-
var UPLOADS_AT_ONCE = 4;
|
|
472
534
|
var NAME_MAX = 200;
|
|
473
535
|
function nameOf(file) {
|
|
474
536
|
const own = file.name.split(/[\\/]/).pop()?.trim() || (file.path === void 0 ? "" : basename2(file.path));
|
|
@@ -541,7 +603,7 @@ function tally(counts, noun) {
|
|
|
541
603
|
}
|
|
542
604
|
function boundKey(key) {
|
|
543
605
|
if (key.value.length <= KEY_MAX) return key;
|
|
544
|
-
const digest =
|
|
606
|
+
const digest = createHash2("sha256").update(key.value).digest("hex").slice(0, 16);
|
|
545
607
|
let head = key.value.slice(0, KEY_MAX - digest.length - 1);
|
|
546
608
|
if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
|
|
547
609
|
return { ...key, value: `${head}#${digest}` };
|
|
@@ -599,9 +661,18 @@ async function startRun(passed, declared) {
|
|
|
599
661
|
const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
|
|
600
662
|
const offerDiscovered = cfg.offerDiscovered ?? false;
|
|
601
663
|
const token = cfg.token ?? loadToken() ?? "";
|
|
664
|
+
const uploadsAtOnce = Math.min(Math.max(Math.floor(cfg.uploadConcurrency ?? UPLOAD_CONCURRENCY.default), UPLOAD_CONCURRENCY.min), UPLOAD_CONCURRENCY.max);
|
|
665
|
+
const uploadRetries = Math.min(Math.max(Math.floor(cfg.uploadRetries ?? UPLOAD_RETRIES.default), UPLOAD_RETRIES.min), UPLOAD_RETRIES.max);
|
|
666
|
+
let retried = 0;
|
|
667
|
+
let uploadMs = 0;
|
|
668
|
+
let requests = 0;
|
|
669
|
+
let existed = 0;
|
|
670
|
+
let batchMissing = false;
|
|
602
671
|
const client = createClient({
|
|
603
672
|
apiUrl: resolveApiUrl(cfg.apiUrl),
|
|
604
673
|
token,
|
|
674
|
+
uploadRetries,
|
|
675
|
+
onRetry: () => void (retried += 1),
|
|
605
676
|
...cfg.fetchImpl !== void 0 ? { fetchImpl: cfg.fetchImpl } : {}
|
|
606
677
|
});
|
|
607
678
|
const stats = {
|
|
@@ -652,6 +723,7 @@ async function startRun(passed, declared) {
|
|
|
652
723
|
if (offline) {
|
|
653
724
|
log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
|
|
654
725
|
}
|
|
726
|
+
for (const warning of env.warnings) log(`plune: ${warning}`);
|
|
655
727
|
if (env.ignored.length > 0) {
|
|
656
728
|
log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
|
|
657
729
|
}
|
|
@@ -779,9 +851,10 @@ async function startRun(passed, declared) {
|
|
|
779
851
|
else log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
|
|
780
852
|
}
|
|
781
853
|
function ask(job, size) {
|
|
854
|
+
requests += 1;
|
|
782
855
|
return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
|
|
783
856
|
files: [{ name: job.name, contentType: job.type, size }]
|
|
784
|
-
});
|
|
857
|
+
}, uploadRetries);
|
|
785
858
|
}
|
|
786
859
|
function failed(job, why) {
|
|
787
860
|
countsOf(job.kind).failed += 1;
|
|
@@ -820,6 +893,7 @@ async function startRun(passed, declared) {
|
|
|
820
893
|
}
|
|
821
894
|
const refusal = kind === "text" ? textRefusal(bytes) : void 0;
|
|
822
895
|
if (refusal !== void 0) return skip(kind, job.name, refusal);
|
|
896
|
+
requests += 1;
|
|
823
897
|
const out = await client.upload(
|
|
824
898
|
`/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
|
|
825
899
|
bytes,
|
|
@@ -842,11 +916,12 @@ async function startRun(passed, declared) {
|
|
|
842
916
|
if (fresh === void 0) return failedToBucket(job, "bad_answer");
|
|
843
917
|
link = fresh;
|
|
844
918
|
}
|
|
845
|
-
|
|
919
|
+
requests += 1;
|
|
920
|
+
const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {}, uploadRetries);
|
|
846
921
|
if (!done2.ok) return failedToBucket(job, codeOf(done2));
|
|
847
922
|
countsOf(job.kind).uploaded += 1;
|
|
848
923
|
}
|
|
849
|
-
let free =
|
|
924
|
+
let free = uploadsAtOnce;
|
|
850
925
|
const waiting = [];
|
|
851
926
|
async function gated(work) {
|
|
852
927
|
if (free === 0) await new Promise((resolve2) => waiting.push(resolve2));
|
|
@@ -902,6 +977,245 @@ async function startRun(passed, declared) {
|
|
|
902
977
|
}
|
|
903
978
|
return () => uploadToBucket(job, size, link);
|
|
904
979
|
}
|
|
980
|
+
async function uploadEach(jobs) {
|
|
981
|
+
const byResult = /* @__PURE__ */ new Map();
|
|
982
|
+
for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
|
|
983
|
+
await Promise.all(
|
|
984
|
+
[...byResult.values()].map(async (group) => {
|
|
985
|
+
const work = [];
|
|
986
|
+
for (const job of group) {
|
|
987
|
+
const act = await plan(job);
|
|
988
|
+
if (act !== void 0) work.push(gated(act));
|
|
989
|
+
}
|
|
990
|
+
await Promise.all(work);
|
|
991
|
+
})
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
async function measure(job) {
|
|
995
|
+
let size;
|
|
996
|
+
if (job.body !== void 0) size = job.body.length;
|
|
997
|
+
else if (job.path === void 0) return void skip(job.kind, job.name, "missing");
|
|
998
|
+
else {
|
|
999
|
+
try {
|
|
1000
|
+
size = (await disk.stat(job.path)).size;
|
|
1001
|
+
} catch {
|
|
1002
|
+
return void skip(job.kind, job.name, "missing");
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (size === 0) return void skip(job.kind, job.name, "empty");
|
|
1006
|
+
return size;
|
|
1007
|
+
}
|
|
1008
|
+
const routeMissing = (out) => !out.ok && out.status === 404 && out.detail === "" && out.code === void 0;
|
|
1009
|
+
function takeRoom(job) {
|
|
1010
|
+
if (job.kind === "file") {
|
|
1011
|
+
skip(job.kind, job.name, "noStorage");
|
|
1012
|
+
return false;
|
|
1013
|
+
}
|
|
1014
|
+
const roomKey = `${job.id}/${job.kind}`;
|
|
1015
|
+
const left = rooms.get(roomKey) ?? KINDS[job.kind].room;
|
|
1016
|
+
if (left === 0) {
|
|
1017
|
+
skip(job.kind, job.name, KINDS[job.kind].limit);
|
|
1018
|
+
return false;
|
|
1019
|
+
}
|
|
1020
|
+
rooms.set(roomKey, left - 1);
|
|
1021
|
+
return true;
|
|
1022
|
+
}
|
|
1023
|
+
function refusedItem(job, size, item) {
|
|
1024
|
+
const code = item.code ?? "refused";
|
|
1025
|
+
if (code === "duplicate") existed += 1;
|
|
1026
|
+
else if (code === "no_storage") skip(job.kind, job.name, refusedHere(job, size) ?? "noStorage");
|
|
1027
|
+
else if (code === "too_many_files") skip(job.kind, job.name, KINDS[job.kind].limit);
|
|
1028
|
+
else if (BUCKET_CODES.has(code)) failedToBucket(job, code);
|
|
1029
|
+
else failed(job, code);
|
|
1030
|
+
}
|
|
1031
|
+
const itemPause = (round) => new Promise((r) => setTimeout(r, Math.min(250 * 2 ** round, 4e3)));
|
|
1032
|
+
async function batchOf(jobs) {
|
|
1033
|
+
if (batchMissing) return uploadEach(jobs);
|
|
1034
|
+
const sized = [];
|
|
1035
|
+
for (const job of jobs) {
|
|
1036
|
+
const size = await measure(job);
|
|
1037
|
+
if (size === void 0) continue;
|
|
1038
|
+
try {
|
|
1039
|
+
sized.push({ job, size, sha256: job.body !== void 0 ? sha256Bytes(job.body) : await sha256File(job.path) });
|
|
1040
|
+
} catch {
|
|
1041
|
+
skip(job.kind, job.name, "missing");
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
if (sized.length === 0) return;
|
|
1045
|
+
const announced = await gated(() => {
|
|
1046
|
+
requests += 1;
|
|
1047
|
+
return client.post(
|
|
1048
|
+
"/v1/files/uploads-batch",
|
|
1049
|
+
{ items: sized.map((s) => ({ resultId: s.job.id, name: s.job.name, contentType: s.job.type, size: s.size, sha256: s.sha256 })) },
|
|
1050
|
+
uploadRetries
|
|
1051
|
+
);
|
|
1052
|
+
});
|
|
1053
|
+
if (routeMissing(announced)) {
|
|
1054
|
+
batchMissing = true;
|
|
1055
|
+
return uploadEach(sized.map((s) => s.job));
|
|
1056
|
+
}
|
|
1057
|
+
const verdicts = announced.ok ? announced.body?.items : void 0;
|
|
1058
|
+
if (!announced.ok || !Array.isArray(verdicts) || verdicts.length !== sized.length) {
|
|
1059
|
+
for (const s of sized) {
|
|
1060
|
+
if (announced.ok) failed(s.job, "bad_answer");
|
|
1061
|
+
else if (announced.code !== void 0) failedToBucket(s.job, announced.code);
|
|
1062
|
+
else failed(s.job, announced.detail || announced.kind);
|
|
1063
|
+
}
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
const puts = [];
|
|
1067
|
+
const directs = [];
|
|
1068
|
+
sized.forEach((s, i) => {
|
|
1069
|
+
const v = verdicts[i] ?? {};
|
|
1070
|
+
if (v.status === "exists") existed += 1;
|
|
1071
|
+
else if (v.status === "direct") {
|
|
1072
|
+
if (takeRoom(s.job)) directs.push(s);
|
|
1073
|
+
} else if (v.status === "upload") {
|
|
1074
|
+
const link = linkOf(v);
|
|
1075
|
+
if (link === void 0) return failed(s.job, "bad_answer");
|
|
1076
|
+
if (!bucketSeen) {
|
|
1077
|
+
bucketSeen = true;
|
|
1078
|
+
log(BUCKET_FOUND);
|
|
1079
|
+
}
|
|
1080
|
+
puts.push({ s, link });
|
|
1081
|
+
} else if (v.status === "refused") refusedItem(s.job, s.size, v);
|
|
1082
|
+
else failed(s.job, "bad_answer");
|
|
1083
|
+
});
|
|
1084
|
+
const toComplete = [];
|
|
1085
|
+
await Promise.all(
|
|
1086
|
+
puts.map(
|
|
1087
|
+
({ s, link: first }) => gated(async () => {
|
|
1088
|
+
const source = s.job.body !== void 0 ? { body: s.job.body } : { path: s.job.path, size: s.size };
|
|
1089
|
+
let link = first;
|
|
1090
|
+
for (let renewed = false; ; ) {
|
|
1091
|
+
const put = await client.put(link.url, link.headers, source);
|
|
1092
|
+
if (put.ok) break;
|
|
1093
|
+
if (put.status !== 403 || renewed) return failedToBucket(s.job, put.code);
|
|
1094
|
+
renewed = true;
|
|
1095
|
+
requests += 1;
|
|
1096
|
+
const again = await client.post(
|
|
1097
|
+
"/v1/files/uploads-batch",
|
|
1098
|
+
{ items: [{ resultId: s.job.id, name: s.job.name, contentType: s.job.type, size: s.size, sha256: s.sha256 }] },
|
|
1099
|
+
uploadRetries
|
|
1100
|
+
);
|
|
1101
|
+
const fresh = again.ok ? linkOf(again.body?.items?.[0]) : void 0;
|
|
1102
|
+
if (fresh === void 0) return failedToBucket(s.job, again.ok ? "bad_answer" : codeOf(again));
|
|
1103
|
+
link = fresh;
|
|
1104
|
+
}
|
|
1105
|
+
toComplete.push({ job: s.job, id: link.id });
|
|
1106
|
+
})
|
|
1107
|
+
)
|
|
1108
|
+
);
|
|
1109
|
+
await complete(toComplete);
|
|
1110
|
+
await sendDirect(directs);
|
|
1111
|
+
}
|
|
1112
|
+
async function complete(items) {
|
|
1113
|
+
for (let round = 0; items.length > 0; round += 1) {
|
|
1114
|
+
const out = await gated(() => {
|
|
1115
|
+
requests += 1;
|
|
1116
|
+
return client.post("/v1/files/complete-batch", { ids: items.map((i) => i.id) }, uploadRetries);
|
|
1117
|
+
});
|
|
1118
|
+
if (routeMissing(out)) {
|
|
1119
|
+
await Promise.all(
|
|
1120
|
+
items.map(
|
|
1121
|
+
(i) => gated(async () => {
|
|
1122
|
+
requests += 1;
|
|
1123
|
+
const one = await client.post(`/v1/files/${encodeURIComponent(i.id)}/complete`, {}, uploadRetries);
|
|
1124
|
+
if (one.ok) countsOf(i.job.kind).uploaded += 1;
|
|
1125
|
+
else failedToBucket(i.job, codeOf(one));
|
|
1126
|
+
})
|
|
1127
|
+
)
|
|
1128
|
+
);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
const verdicts = out.ok ? out.body?.items : void 0;
|
|
1132
|
+
if (!out.ok || !Array.isArray(verdicts) || verdicts.length !== items.length) {
|
|
1133
|
+
for (const i of items) failedToBucket(i.job, out.ok ? "bad_answer" : codeOf(out));
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
const again = [];
|
|
1137
|
+
items.forEach((i, at) => {
|
|
1138
|
+
const v = verdicts[at] ?? {};
|
|
1139
|
+
if (v.status === "ready") countsOf(i.job.kind).uploaded += 1;
|
|
1140
|
+
else if (v.retryable === true && round < Math.min(uploadRetries, 3)) again.push(i);
|
|
1141
|
+
else refusedItem(i.job, 0, v.status === "refused" ? v : { code: "bad_answer" });
|
|
1142
|
+
});
|
|
1143
|
+
items = again;
|
|
1144
|
+
if (items.length > 0) {
|
|
1145
|
+
retried += items.length;
|
|
1146
|
+
await itemPause(round);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
async function sendDirect(items) {
|
|
1151
|
+
const groups = [];
|
|
1152
|
+
let bytes = 0;
|
|
1153
|
+
for (const item of items) {
|
|
1154
|
+
const group = groups[groups.length - 1];
|
|
1155
|
+
if (group === void 0 || group.length >= FILES_BATCH_MAX || bytes + item.size > DIRECT_BATCH_BYTES) {
|
|
1156
|
+
groups.push([item]);
|
|
1157
|
+
bytes = item.size;
|
|
1158
|
+
} else {
|
|
1159
|
+
group.push(item);
|
|
1160
|
+
bytes += item.size;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
for (const group of groups) {
|
|
1164
|
+
const body = [];
|
|
1165
|
+
const sent = [];
|
|
1166
|
+
for (const { job, size } of group) {
|
|
1167
|
+
const { kind } = job;
|
|
1168
|
+
if (size > KINDS[kind].bytes) {
|
|
1169
|
+
skip(kind, job.name, KINDS[kind].size);
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
let data;
|
|
1173
|
+
try {
|
|
1174
|
+
data = job.body ?? await disk.readFile(job.path);
|
|
1175
|
+
} catch {
|
|
1176
|
+
skip(kind, job.name, "missing");
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
const refusal = kind === "text" ? textRefusal(data) : void 0;
|
|
1180
|
+
if (refusal !== void 0) {
|
|
1181
|
+
skip(kind, job.name, refusal);
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
body.push({ resultId: job.id, name: job.name, contentType: job.type, data: data.toString("base64") });
|
|
1185
|
+
sent.push({ job, size });
|
|
1186
|
+
}
|
|
1187
|
+
if (body.length === 0) continue;
|
|
1188
|
+
const out = await gated(() => {
|
|
1189
|
+
requests += 1;
|
|
1190
|
+
return client.post("/v1/files/direct-batch", { items: body }, uploadRetries);
|
|
1191
|
+
});
|
|
1192
|
+
if (routeMissing(out)) {
|
|
1193
|
+
batchMissing = true;
|
|
1194
|
+
for (const { job, size } of sent) await gated(() => uploadDirect(job, size));
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
const verdicts = out.ok ? out.body?.items : void 0;
|
|
1198
|
+
if (!out.ok || !Array.isArray(verdicts) || verdicts.length !== sent.length) {
|
|
1199
|
+
for (const { job } of sent) failed(job, out.ok ? "bad_answer" : out.detail || out.kind);
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
sent.forEach(({ job }, i) => {
|
|
1203
|
+
const v = verdicts[i] ?? {};
|
|
1204
|
+
if (v.status === "stored") countsOf(job.kind).uploaded += 1;
|
|
1205
|
+
else if (v.status === "exists") existed += 1;
|
|
1206
|
+
else refusedItem(job, 0, v.status === "refused" ? v : { code: "bad_answer" });
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
async function uploadBatched(jobs) {
|
|
1211
|
+
const batches = chunk(jobs, FILES_BATCH_MAX);
|
|
1212
|
+
let next = 0;
|
|
1213
|
+
await Promise.all(
|
|
1214
|
+
Array.from({ length: Math.min(BATCH_PIPELINES, batches.length) }, async () => {
|
|
1215
|
+
for (let at = next++; at < batches.length; at = next++) await batchOf(batches[at]);
|
|
1216
|
+
})
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
905
1219
|
async function upload(items, filesByKey) {
|
|
906
1220
|
if (filesByKey.size === 0 || !Array.isArray(items)) return;
|
|
907
1221
|
const jobs = [];
|
|
@@ -926,18 +1240,10 @@ async function startRun(passed, declared) {
|
|
|
926
1240
|
}
|
|
927
1241
|
}
|
|
928
1242
|
}
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
await
|
|
932
|
-
|
|
933
|
-
const work = [];
|
|
934
|
-
for (const job of group) {
|
|
935
|
-
const act = await plan(job);
|
|
936
|
-
if (act !== void 0) work.push(gated(act));
|
|
937
|
-
}
|
|
938
|
-
await Promise.all(work);
|
|
939
|
-
})
|
|
940
|
-
);
|
|
1243
|
+
const started = Date.now();
|
|
1244
|
+
if (batchMissing) await uploadEach(jobs);
|
|
1245
|
+
else await uploadBatched(jobs);
|
|
1246
|
+
uploadMs += Date.now() - started;
|
|
941
1247
|
}
|
|
942
1248
|
async function flush() {
|
|
943
1249
|
if (buffer.length === 0) return;
|
|
@@ -1030,6 +1336,13 @@ async function startRun(passed, declared) {
|
|
|
1030
1336
|
}
|
|
1031
1337
|
if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
|
|
1032
1338
|
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
1339
|
+
const files = [stats.screenshots, stats.textFiles, stats.otherFiles];
|
|
1340
|
+
const total = (what) => files.reduce((n, counts) => n + counts[what], 0);
|
|
1341
|
+
if (total("uploaded") + total("skipped") + total("failed") + existed > 0) {
|
|
1342
|
+
log(
|
|
1343
|
+
`plune: files \u2014 ${total("uploaded")} uploaded, ${total("skipped")} skipped, ${existed} already there (skipped), ${retried} retried, ${total("failed")} dropped, in ${(uploadMs / 1e3).toFixed(1)} s (${uploadsAtOnce} at once, ${requests} request(s) to Plune)`
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1033
1346
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
1034
1347
|
if (stats.deferred > 0 && process.env["GITHUB_ACTIONS"] === "true") {
|
|
1035
1348
|
log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
|
|
@@ -1086,9 +1399,9 @@ async function startRun(passed, declared) {
|
|
|
1086
1399
|
}
|
|
1087
1400
|
|
|
1088
1401
|
// src/util/hash.ts
|
|
1089
|
-
import { createHash as
|
|
1402
|
+
import { createHash as createHash3 } from "crypto";
|
|
1090
1403
|
function sha256(input) {
|
|
1091
|
-
return
|
|
1404
|
+
return createHash3("sha256").update(input, "utf8").digest("hex");
|
|
1092
1405
|
}
|
|
1093
1406
|
|
|
1094
1407
|
// src/reporter-core/result-key.ts
|
package/package.json
CHANGED