@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.cjs
CHANGED
|
@@ -48,8 +48,8 @@ __export(reporter_core_exports, {
|
|
|
48
48
|
module.exports = __toCommonJS(reporter_core_exports);
|
|
49
49
|
|
|
50
50
|
// src/reporter-core/session.ts
|
|
51
|
-
var
|
|
52
|
-
var
|
|
51
|
+
var import_node_crypto2 = require("crypto");
|
|
52
|
+
var import_node_fs5 = require("fs");
|
|
53
53
|
var import_node_path3 = require("path");
|
|
54
54
|
var import_node_util2 = require("util");
|
|
55
55
|
|
|
@@ -84,7 +84,17 @@ var import_node_fs2 = require("fs");
|
|
|
84
84
|
var import_node_stream = require("stream");
|
|
85
85
|
var import_node_http = require("http");
|
|
86
86
|
var import_node_https = require("https");
|
|
87
|
-
var
|
|
87
|
+
var BACKOFF_BASE_MS = 300;
|
|
88
|
+
var BACKOFF_CAP_MS = 1e4;
|
|
89
|
+
var DEFAULT_RETRIES = 2;
|
|
90
|
+
var RETRY_AFTER_CAP_MS = 6e4;
|
|
91
|
+
function parseRetryAfter(raw, now = Date.now()) {
|
|
92
|
+
const text = raw?.trim();
|
|
93
|
+
if (text === void 0 || text === "") return null;
|
|
94
|
+
const seconds = /^\d+(\.\d+)?$/.test(text) ? Number(text) : (Date.parse(text) - now) / 1e3;
|
|
95
|
+
if (!Number.isFinite(seconds)) return null;
|
|
96
|
+
return Math.min(Math.max(seconds, 0) * 1e3, RETRY_AFTER_CAP_MS);
|
|
97
|
+
}
|
|
88
98
|
var IDLE_MS = 3e5;
|
|
89
99
|
function nodeTransport(url, init, idleMs = IDLE_MS) {
|
|
90
100
|
return new Promise((resolve2, reject) => {
|
|
@@ -115,7 +125,6 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
|
|
|
115
125
|
req.end(init.body);
|
|
116
126
|
});
|
|
117
127
|
}
|
|
118
|
-
var PUT_DELAYS_MS = [500, 1500, 4e3];
|
|
119
128
|
var withoutLength = (headers) => Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-length"));
|
|
120
129
|
function nodePut(url, headers, source, idleMs) {
|
|
121
130
|
return new Promise((resolve2, reject) => {
|
|
@@ -160,12 +169,6 @@ async function detailOf(res) {
|
|
|
160
169
|
return { detail: "" };
|
|
161
170
|
}
|
|
162
171
|
}
|
|
163
|
-
function retryAfterMs(res) {
|
|
164
|
-
const raw = res.headers.get("retry-after");
|
|
165
|
-
if (raw === null) return null;
|
|
166
|
-
const seconds = Number(raw.trim());
|
|
167
|
-
return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : null;
|
|
168
|
-
}
|
|
169
172
|
function classify(status) {
|
|
170
173
|
if (status === 401 || status === 403) return "auth";
|
|
171
174
|
if (status === 409) return "conflict";
|
|
@@ -176,15 +179,34 @@ function classify(status) {
|
|
|
176
179
|
function createClient(opts) {
|
|
177
180
|
const doFetch = opts.fetchImpl ?? nodeTransport;
|
|
178
181
|
const wait = opts.wait ?? sleep;
|
|
182
|
+
const uploadRetries = opts.uploadRetries === void 0 ? void 0 : Math.max(0, Math.floor(opts.uploadRetries));
|
|
183
|
+
const random = opts.random ?? Math.random;
|
|
184
|
+
const now = opts.now ?? Date.now;
|
|
185
|
+
let pausedUntil = 0;
|
|
186
|
+
const jittered = (ms) => Math.round(ms * (1 + random() * 0.5));
|
|
187
|
+
async function backOff(attempt, said) {
|
|
188
|
+
opts.onRetry?.();
|
|
189
|
+
const ms = jittered(said ?? Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
|
|
190
|
+
const until = now() + ms;
|
|
191
|
+
pausedUntil = Math.max(pausedUntil, until);
|
|
192
|
+
await wait(ms);
|
|
193
|
+
return until;
|
|
194
|
+
}
|
|
195
|
+
async function respectPause(mine) {
|
|
196
|
+
const left = pausedUntil - now();
|
|
197
|
+
if (pausedUntil > mine && left > 0) await wait(left + Math.round(random() * 250));
|
|
198
|
+
}
|
|
179
199
|
const base = opts.apiUrl.replace(/\/+$/, "");
|
|
180
200
|
const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
|
|
181
|
-
async function send(method, path3, payload) {
|
|
201
|
+
async function send(method, path3, payload, retries = DEFAULT_RETRIES) {
|
|
182
202
|
let last = {
|
|
183
203
|
kind: "unavailable",
|
|
184
204
|
status: null,
|
|
185
205
|
detail: "no attempt was made"
|
|
186
206
|
};
|
|
187
|
-
|
|
207
|
+
let mine = 0;
|
|
208
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
209
|
+
await respectPause(mine);
|
|
188
210
|
let res;
|
|
189
211
|
try {
|
|
190
212
|
res = await doFetch(`${base}${path3}`, {
|
|
@@ -197,7 +219,8 @@ function createClient(opts) {
|
|
|
197
219
|
});
|
|
198
220
|
} catch (err) {
|
|
199
221
|
last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
|
|
200
|
-
if (attempt
|
|
222
|
+
if (attempt >= Math.min(retries, DEFAULT_RETRIES)) break;
|
|
223
|
+
mine = await backOff(attempt, null);
|
|
201
224
|
continue;
|
|
202
225
|
}
|
|
203
226
|
if (res.ok) {
|
|
@@ -212,25 +235,28 @@ function createClient(opts) {
|
|
|
212
235
|
const kind = classify(res.status);
|
|
213
236
|
last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
|
|
214
237
|
if (kind !== "unavailable") break;
|
|
215
|
-
|
|
238
|
+
const said429 = parseRetryAfter(res.headers.get("retry-after"), now());
|
|
239
|
+
if (res.status === 429 && said429 === null) {
|
|
216
240
|
last.kind = "invalid";
|
|
217
241
|
last.detail = "quota exceeded (no Retry-After)";
|
|
218
242
|
if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
|
|
219
243
|
break;
|
|
220
244
|
}
|
|
221
|
-
if (attempt
|
|
222
|
-
|
|
223
|
-
}
|
|
245
|
+
if (attempt >= (said429 === null ? Math.min(retries, DEFAULT_RETRIES) : retries)) break;
|
|
246
|
+
mine = await backOff(attempt, said429);
|
|
224
247
|
}
|
|
225
248
|
return { ok: false, ...last };
|
|
226
249
|
}
|
|
227
250
|
return {
|
|
228
|
-
post: (path3, body) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }),
|
|
229
|
-
upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }),
|
|
251
|
+
post: (path3, body, retries) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }, retries),
|
|
252
|
+
upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
|
|
230
253
|
del: (path3) => send("DELETE", path3),
|
|
231
254
|
async put(url, headers, source) {
|
|
255
|
+
const putRetries = uploadRetries ?? 3;
|
|
232
256
|
let last = { ok: false, status: null, code: "network" };
|
|
233
|
-
|
|
257
|
+
let mine = 0;
|
|
258
|
+
for (let attempt = 0; attempt <= putRetries; attempt += 1) {
|
|
259
|
+
await respectPause(mine);
|
|
234
260
|
try {
|
|
235
261
|
let status;
|
|
236
262
|
let retryAfter;
|
|
@@ -245,15 +271,15 @@ function createClient(opts) {
|
|
|
245
271
|
if (status >= 200 && status <= 299) return { ok: true };
|
|
246
272
|
last = { ok: false, status, code: `bucket_${status}` };
|
|
247
273
|
if (status < 500 && status !== 429) return last;
|
|
248
|
-
const
|
|
249
|
-
if (attempt
|
|
250
|
-
|
|
251
|
-
}
|
|
274
|
+
const said = parseRetryAfter(retryAfter, now());
|
|
275
|
+
if (attempt >= (said === null ? Math.min(putRetries, 3) : putRetries)) return last;
|
|
276
|
+
mine = await backOff(attempt, said);
|
|
252
277
|
continue;
|
|
253
278
|
} catch (err) {
|
|
254
279
|
last = { ok: false, status: null, code: codeOfError(err) };
|
|
255
280
|
}
|
|
256
|
-
if (attempt
|
|
281
|
+
if (attempt >= Math.min(putRetries, 3)) return last;
|
|
282
|
+
mine = await backOff(attempt, null);
|
|
257
283
|
}
|
|
258
284
|
return last;
|
|
259
285
|
}
|
|
@@ -263,6 +289,8 @@ function createClient(opts) {
|
|
|
263
289
|
// src/reporter-core/env.ts
|
|
264
290
|
var path = __toESM(require("path"), 1);
|
|
265
291
|
var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
|
|
292
|
+
var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 4 };
|
|
293
|
+
var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
|
|
266
294
|
function value(env, name) {
|
|
267
295
|
const raw = env[name]?.trim();
|
|
268
296
|
return raw === void 0 || raw === "" ? void 0 : raw;
|
|
@@ -277,6 +305,14 @@ function count(env, name) {
|
|
|
277
305
|
const n = Number(raw);
|
|
278
306
|
return Number.isInteger(n) && n > 0 ? n : void 0;
|
|
279
307
|
}
|
|
308
|
+
function bounded(env, name, range, warnings) {
|
|
309
|
+
const raw = value(env, name);
|
|
310
|
+
if (raw === void 0) return void 0;
|
|
311
|
+
const n = Number(raw);
|
|
312
|
+
if (/^\d+$/.test(raw) && n >= range.min && n <= range.max) return n;
|
|
313
|
+
warnings.push(`${name}=${JSON.stringify(raw.slice(0, 40))} is not a whole number from ${range.min} to ${range.max} \u2014 using ${range.default}.`);
|
|
314
|
+
return void 0;
|
|
315
|
+
}
|
|
280
316
|
function list(env, name) {
|
|
281
317
|
const raw = value(env, name);
|
|
282
318
|
if (raw === void 0) return void 0;
|
|
@@ -289,6 +325,9 @@ function readEnv(env = process.env) {
|
|
|
289
325
|
const apiUrl = value(env, "PLUNE_API_URL");
|
|
290
326
|
const fallbackPath = value(env, "PLUNE_FALLBACK");
|
|
291
327
|
const batchSize = count(env, "PLUNE_BATCH_SIZE");
|
|
328
|
+
const warnings = [];
|
|
329
|
+
const uploadConcurrency = bounded(env, "PLUNE_UPLOAD_CONCURRENCY", UPLOAD_CONCURRENCY, warnings);
|
|
330
|
+
const uploadRetries = bounded(env, "PLUNE_UPLOAD_RETRIES", UPLOAD_RETRIES, warnings);
|
|
292
331
|
const title = value(env, "PLUNE_RUN_TITLE");
|
|
293
332
|
const environment = value(env, "PLUNE_ENV");
|
|
294
333
|
const labels = list(env, "PLUNE_LABELS");
|
|
@@ -301,6 +340,8 @@ function readEnv(env = process.env) {
|
|
|
301
340
|
...apiUrl !== void 0 ? { apiUrl } : {},
|
|
302
341
|
...fallbackPath !== void 0 ? { fallbackPath } : {},
|
|
303
342
|
...batchSize !== void 0 ? { batchSize } : {},
|
|
343
|
+
...uploadConcurrency !== void 0 ? { uploadConcurrency } : {},
|
|
344
|
+
...uploadRetries !== void 0 ? { uploadRetries } : {},
|
|
304
345
|
...title !== void 0 ? { title } : {},
|
|
305
346
|
...environment !== void 0 ? { environment } : {},
|
|
306
347
|
...labels !== void 0 ? { labels } : {},
|
|
@@ -313,7 +354,8 @@ function readEnv(env = process.env) {
|
|
|
313
354
|
// reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
|
|
314
355
|
// way this process must not be the one to call it finished.
|
|
315
356
|
keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED"),
|
|
316
|
-
ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0)
|
|
357
|
+
ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
|
|
358
|
+
warnings
|
|
317
359
|
};
|
|
318
360
|
}
|
|
319
361
|
function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cwd = process.cwd()) {
|
|
@@ -477,6 +519,27 @@ function webLink(href) {
|
|
|
477
519
|
return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
|
|
478
520
|
}
|
|
479
521
|
|
|
522
|
+
// src/reporter-core/files-batch.ts
|
|
523
|
+
var import_node_crypto = require("crypto");
|
|
524
|
+
var import_node_fs4 = require("fs");
|
|
525
|
+
var FILES_BATCH_MAX = 100;
|
|
526
|
+
var DIRECT_BATCH_BYTES = 6 * 1024 * 1024;
|
|
527
|
+
var BATCH_PIPELINES = 2;
|
|
528
|
+
async function sha256File(path3) {
|
|
529
|
+
const hash = (0, import_node_crypto.createHash)("sha256");
|
|
530
|
+
for await (const part of (0, import_node_fs4.createReadStream)(path3)) hash.update(part);
|
|
531
|
+
return hash.digest("hex");
|
|
532
|
+
}
|
|
533
|
+
var sha256Bytes = (bytes) => (0, import_node_crypto.createHash)("sha256").update(bytes).digest("hex");
|
|
534
|
+
var BUCKET_CODES = /* @__PURE__ */ new Set([
|
|
535
|
+
"bucket_unreachable",
|
|
536
|
+
"not_connected",
|
|
537
|
+
"platform_not_configured",
|
|
538
|
+
"upload_expired",
|
|
539
|
+
"file_mismatch",
|
|
540
|
+
"file_missing"
|
|
541
|
+
]);
|
|
542
|
+
|
|
480
543
|
// src/reporter-core/fallback.ts
|
|
481
544
|
var fs = __toESM(require("fs"), 1);
|
|
482
545
|
var path2 = __toESM(require("path"), 1);
|
|
@@ -499,7 +562,7 @@ function appendBatch(file, batch) {
|
|
|
499
562
|
var RESOLVE_CHUNK = 500;
|
|
500
563
|
var BATCH_MAX = 500;
|
|
501
564
|
var DISCOVER_MAX = 500;
|
|
502
|
-
var BATCH_DEFAULT =
|
|
565
|
+
var BATCH_DEFAULT = 500;
|
|
503
566
|
var EXPECTED_MAX = 1e4;
|
|
504
567
|
var KEY_MAX = 1024;
|
|
505
568
|
var TITLE_MAX = 300;
|
|
@@ -517,7 +580,6 @@ var TYPE_BY_EXTENSION = {
|
|
|
517
580
|
".webp": "image/webp"
|
|
518
581
|
};
|
|
519
582
|
var METADATA_TYPE2 = "application/plune.metadata+json";
|
|
520
|
-
var UPLOADS_AT_ONCE = 4;
|
|
521
583
|
var NAME_MAX = 200;
|
|
522
584
|
function nameOf(file) {
|
|
523
585
|
const own = file.name.split(/[\\/]/).pop()?.trim() || (file.path === void 0 ? "" : (0, import_node_path3.basename)(file.path));
|
|
@@ -590,7 +652,7 @@ function tally(counts, noun) {
|
|
|
590
652
|
}
|
|
591
653
|
function boundKey(key) {
|
|
592
654
|
if (key.value.length <= KEY_MAX) return key;
|
|
593
|
-
const digest = (0,
|
|
655
|
+
const digest = (0, import_node_crypto2.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
|
|
594
656
|
let head = key.value.slice(0, KEY_MAX - digest.length - 1);
|
|
595
657
|
if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
|
|
596
658
|
return { ...key, value: `${head}#${digest}` };
|
|
@@ -644,13 +706,22 @@ async function startRun(passed, declared) {
|
|
|
644
706
|
`));
|
|
645
707
|
const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
|
|
646
708
|
const externalKey = cfg.externalKey ?? null;
|
|
647
|
-
const sessionMarker = (0,
|
|
709
|
+
const sessionMarker = (0, import_node_crypto2.randomUUID)();
|
|
648
710
|
const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
|
|
649
711
|
const offerDiscovered = cfg.offerDiscovered ?? false;
|
|
650
712
|
const token = cfg.token ?? loadToken() ?? "";
|
|
713
|
+
const uploadsAtOnce = Math.min(Math.max(Math.floor(cfg.uploadConcurrency ?? UPLOAD_CONCURRENCY.default), UPLOAD_CONCURRENCY.min), UPLOAD_CONCURRENCY.max);
|
|
714
|
+
const uploadRetries = Math.min(Math.max(Math.floor(cfg.uploadRetries ?? UPLOAD_RETRIES.default), UPLOAD_RETRIES.min), UPLOAD_RETRIES.max);
|
|
715
|
+
let retried = 0;
|
|
716
|
+
let uploadMs = 0;
|
|
717
|
+
let requests = 0;
|
|
718
|
+
let existed = 0;
|
|
719
|
+
let batchMissing = false;
|
|
651
720
|
const client = createClient({
|
|
652
721
|
apiUrl: resolveApiUrl(cfg.apiUrl),
|
|
653
722
|
token,
|
|
723
|
+
uploadRetries,
|
|
724
|
+
onRetry: () => void (retried += 1),
|
|
654
725
|
...cfg.fetchImpl !== void 0 ? { fetchImpl: cfg.fetchImpl } : {}
|
|
655
726
|
});
|
|
656
727
|
const stats = {
|
|
@@ -701,6 +772,7 @@ async function startRun(passed, declared) {
|
|
|
701
772
|
if (offline) {
|
|
702
773
|
log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
|
|
703
774
|
}
|
|
775
|
+
for (const warning of env.warnings) log(`plune: ${warning}`);
|
|
704
776
|
if (env.ignored.length > 0) {
|
|
705
777
|
log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
|
|
706
778
|
}
|
|
@@ -828,9 +900,10 @@ async function startRun(passed, declared) {
|
|
|
828
900
|
else log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
|
|
829
901
|
}
|
|
830
902
|
function ask(job, size) {
|
|
903
|
+
requests += 1;
|
|
831
904
|
return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
|
|
832
905
|
files: [{ name: job.name, contentType: job.type, size }]
|
|
833
|
-
});
|
|
906
|
+
}, uploadRetries);
|
|
834
907
|
}
|
|
835
908
|
function failed(job, why) {
|
|
836
909
|
countsOf(job.kind).failed += 1;
|
|
@@ -862,13 +935,14 @@ async function startRun(passed, declared) {
|
|
|
862
935
|
if (job.body !== void 0) bytes = job.body;
|
|
863
936
|
else {
|
|
864
937
|
try {
|
|
865
|
-
bytes = await
|
|
938
|
+
bytes = await import_node_fs5.promises.readFile(job.path);
|
|
866
939
|
} catch {
|
|
867
940
|
return skip(kind, job.name, "missing");
|
|
868
941
|
}
|
|
869
942
|
}
|
|
870
943
|
const refusal = kind === "text" ? textRefusal(bytes) : void 0;
|
|
871
944
|
if (refusal !== void 0) return skip(kind, job.name, refusal);
|
|
945
|
+
requests += 1;
|
|
872
946
|
const out = await client.upload(
|
|
873
947
|
`/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
|
|
874
948
|
bytes,
|
|
@@ -891,11 +965,12 @@ async function startRun(passed, declared) {
|
|
|
891
965
|
if (fresh === void 0) return failedToBucket(job, "bad_answer");
|
|
892
966
|
link = fresh;
|
|
893
967
|
}
|
|
894
|
-
|
|
968
|
+
requests += 1;
|
|
969
|
+
const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {}, uploadRetries);
|
|
895
970
|
if (!done2.ok) return failedToBucket(job, codeOf(done2));
|
|
896
971
|
countsOf(job.kind).uploaded += 1;
|
|
897
972
|
}
|
|
898
|
-
let free =
|
|
973
|
+
let free = uploadsAtOnce;
|
|
899
974
|
const waiting = [];
|
|
900
975
|
async function gated(work) {
|
|
901
976
|
if (free === 0) await new Promise((resolve2) => waiting.push(resolve2));
|
|
@@ -915,7 +990,7 @@ async function startRun(passed, declared) {
|
|
|
915
990
|
else if (job.path === void 0) return void skip(kind, job.name, "missing");
|
|
916
991
|
else {
|
|
917
992
|
try {
|
|
918
|
-
size = (await
|
|
993
|
+
size = (await import_node_fs5.promises.stat(job.path)).size;
|
|
919
994
|
} catch {
|
|
920
995
|
return void skip(kind, job.name, "missing");
|
|
921
996
|
}
|
|
@@ -951,6 +1026,245 @@ async function startRun(passed, declared) {
|
|
|
951
1026
|
}
|
|
952
1027
|
return () => uploadToBucket(job, size, link);
|
|
953
1028
|
}
|
|
1029
|
+
async function uploadEach(jobs) {
|
|
1030
|
+
const byResult = /* @__PURE__ */ new Map();
|
|
1031
|
+
for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
|
|
1032
|
+
await Promise.all(
|
|
1033
|
+
[...byResult.values()].map(async (group) => {
|
|
1034
|
+
const work = [];
|
|
1035
|
+
for (const job of group) {
|
|
1036
|
+
const act = await plan(job);
|
|
1037
|
+
if (act !== void 0) work.push(gated(act));
|
|
1038
|
+
}
|
|
1039
|
+
await Promise.all(work);
|
|
1040
|
+
})
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
async function measure(job) {
|
|
1044
|
+
let size;
|
|
1045
|
+
if (job.body !== void 0) size = job.body.length;
|
|
1046
|
+
else if (job.path === void 0) return void skip(job.kind, job.name, "missing");
|
|
1047
|
+
else {
|
|
1048
|
+
try {
|
|
1049
|
+
size = (await import_node_fs5.promises.stat(job.path)).size;
|
|
1050
|
+
} catch {
|
|
1051
|
+
return void skip(job.kind, job.name, "missing");
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
if (size === 0) return void skip(job.kind, job.name, "empty");
|
|
1055
|
+
return size;
|
|
1056
|
+
}
|
|
1057
|
+
const routeMissing = (out) => !out.ok && out.status === 404 && out.detail === "" && out.code === void 0;
|
|
1058
|
+
function takeRoom(job) {
|
|
1059
|
+
if (job.kind === "file") {
|
|
1060
|
+
skip(job.kind, job.name, "noStorage");
|
|
1061
|
+
return false;
|
|
1062
|
+
}
|
|
1063
|
+
const roomKey = `${job.id}/${job.kind}`;
|
|
1064
|
+
const left = rooms.get(roomKey) ?? KINDS[job.kind].room;
|
|
1065
|
+
if (left === 0) {
|
|
1066
|
+
skip(job.kind, job.name, KINDS[job.kind].limit);
|
|
1067
|
+
return false;
|
|
1068
|
+
}
|
|
1069
|
+
rooms.set(roomKey, left - 1);
|
|
1070
|
+
return true;
|
|
1071
|
+
}
|
|
1072
|
+
function refusedItem(job, size, item) {
|
|
1073
|
+
const code = item.code ?? "refused";
|
|
1074
|
+
if (code === "duplicate") existed += 1;
|
|
1075
|
+
else if (code === "no_storage") skip(job.kind, job.name, refusedHere(job, size) ?? "noStorage");
|
|
1076
|
+
else if (code === "too_many_files") skip(job.kind, job.name, KINDS[job.kind].limit);
|
|
1077
|
+
else if (BUCKET_CODES.has(code)) failedToBucket(job, code);
|
|
1078
|
+
else failed(job, code);
|
|
1079
|
+
}
|
|
1080
|
+
const itemPause = (round) => new Promise((r) => setTimeout(r, Math.min(250 * 2 ** round, 4e3)));
|
|
1081
|
+
async function batchOf(jobs) {
|
|
1082
|
+
if (batchMissing) return uploadEach(jobs);
|
|
1083
|
+
const sized = [];
|
|
1084
|
+
for (const job of jobs) {
|
|
1085
|
+
const size = await measure(job);
|
|
1086
|
+
if (size === void 0) continue;
|
|
1087
|
+
try {
|
|
1088
|
+
sized.push({ job, size, sha256: job.body !== void 0 ? sha256Bytes(job.body) : await sha256File(job.path) });
|
|
1089
|
+
} catch {
|
|
1090
|
+
skip(job.kind, job.name, "missing");
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
if (sized.length === 0) return;
|
|
1094
|
+
const announced = await gated(() => {
|
|
1095
|
+
requests += 1;
|
|
1096
|
+
return client.post(
|
|
1097
|
+
"/v1/files/uploads-batch",
|
|
1098
|
+
{ items: sized.map((s) => ({ resultId: s.job.id, name: s.job.name, contentType: s.job.type, size: s.size, sha256: s.sha256 })) },
|
|
1099
|
+
uploadRetries
|
|
1100
|
+
);
|
|
1101
|
+
});
|
|
1102
|
+
if (routeMissing(announced)) {
|
|
1103
|
+
batchMissing = true;
|
|
1104
|
+
return uploadEach(sized.map((s) => s.job));
|
|
1105
|
+
}
|
|
1106
|
+
const verdicts = announced.ok ? announced.body?.items : void 0;
|
|
1107
|
+
if (!announced.ok || !Array.isArray(verdicts) || verdicts.length !== sized.length) {
|
|
1108
|
+
for (const s of sized) {
|
|
1109
|
+
if (announced.ok) failed(s.job, "bad_answer");
|
|
1110
|
+
else if (announced.code !== void 0) failedToBucket(s.job, announced.code);
|
|
1111
|
+
else failed(s.job, announced.detail || announced.kind);
|
|
1112
|
+
}
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
const puts = [];
|
|
1116
|
+
const directs = [];
|
|
1117
|
+
sized.forEach((s, i) => {
|
|
1118
|
+
const v = verdicts[i] ?? {};
|
|
1119
|
+
if (v.status === "exists") existed += 1;
|
|
1120
|
+
else if (v.status === "direct") {
|
|
1121
|
+
if (takeRoom(s.job)) directs.push(s);
|
|
1122
|
+
} else if (v.status === "upload") {
|
|
1123
|
+
const link = linkOf(v);
|
|
1124
|
+
if (link === void 0) return failed(s.job, "bad_answer");
|
|
1125
|
+
if (!bucketSeen) {
|
|
1126
|
+
bucketSeen = true;
|
|
1127
|
+
log(BUCKET_FOUND);
|
|
1128
|
+
}
|
|
1129
|
+
puts.push({ s, link });
|
|
1130
|
+
} else if (v.status === "refused") refusedItem(s.job, s.size, v);
|
|
1131
|
+
else failed(s.job, "bad_answer");
|
|
1132
|
+
});
|
|
1133
|
+
const toComplete = [];
|
|
1134
|
+
await Promise.all(
|
|
1135
|
+
puts.map(
|
|
1136
|
+
({ s, link: first }) => gated(async () => {
|
|
1137
|
+
const source = s.job.body !== void 0 ? { body: s.job.body } : { path: s.job.path, size: s.size };
|
|
1138
|
+
let link = first;
|
|
1139
|
+
for (let renewed = false; ; ) {
|
|
1140
|
+
const put = await client.put(link.url, link.headers, source);
|
|
1141
|
+
if (put.ok) break;
|
|
1142
|
+
if (put.status !== 403 || renewed) return failedToBucket(s.job, put.code);
|
|
1143
|
+
renewed = true;
|
|
1144
|
+
requests += 1;
|
|
1145
|
+
const again = await client.post(
|
|
1146
|
+
"/v1/files/uploads-batch",
|
|
1147
|
+
{ items: [{ resultId: s.job.id, name: s.job.name, contentType: s.job.type, size: s.size, sha256: s.sha256 }] },
|
|
1148
|
+
uploadRetries
|
|
1149
|
+
);
|
|
1150
|
+
const fresh = again.ok ? linkOf(again.body?.items?.[0]) : void 0;
|
|
1151
|
+
if (fresh === void 0) return failedToBucket(s.job, again.ok ? "bad_answer" : codeOf(again));
|
|
1152
|
+
link = fresh;
|
|
1153
|
+
}
|
|
1154
|
+
toComplete.push({ job: s.job, id: link.id });
|
|
1155
|
+
})
|
|
1156
|
+
)
|
|
1157
|
+
);
|
|
1158
|
+
await complete(toComplete);
|
|
1159
|
+
await sendDirect(directs);
|
|
1160
|
+
}
|
|
1161
|
+
async function complete(items) {
|
|
1162
|
+
for (let round = 0; items.length > 0; round += 1) {
|
|
1163
|
+
const out = await gated(() => {
|
|
1164
|
+
requests += 1;
|
|
1165
|
+
return client.post("/v1/files/complete-batch", { ids: items.map((i) => i.id) }, uploadRetries);
|
|
1166
|
+
});
|
|
1167
|
+
if (routeMissing(out)) {
|
|
1168
|
+
await Promise.all(
|
|
1169
|
+
items.map(
|
|
1170
|
+
(i) => gated(async () => {
|
|
1171
|
+
requests += 1;
|
|
1172
|
+
const one = await client.post(`/v1/files/${encodeURIComponent(i.id)}/complete`, {}, uploadRetries);
|
|
1173
|
+
if (one.ok) countsOf(i.job.kind).uploaded += 1;
|
|
1174
|
+
else failedToBucket(i.job, codeOf(one));
|
|
1175
|
+
})
|
|
1176
|
+
)
|
|
1177
|
+
);
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
const verdicts = out.ok ? out.body?.items : void 0;
|
|
1181
|
+
if (!out.ok || !Array.isArray(verdicts) || verdicts.length !== items.length) {
|
|
1182
|
+
for (const i of items) failedToBucket(i.job, out.ok ? "bad_answer" : codeOf(out));
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
const again = [];
|
|
1186
|
+
items.forEach((i, at) => {
|
|
1187
|
+
const v = verdicts[at] ?? {};
|
|
1188
|
+
if (v.status === "ready") countsOf(i.job.kind).uploaded += 1;
|
|
1189
|
+
else if (v.retryable === true && round < Math.min(uploadRetries, 3)) again.push(i);
|
|
1190
|
+
else refusedItem(i.job, 0, v.status === "refused" ? v : { code: "bad_answer" });
|
|
1191
|
+
});
|
|
1192
|
+
items = again;
|
|
1193
|
+
if (items.length > 0) {
|
|
1194
|
+
retried += items.length;
|
|
1195
|
+
await itemPause(round);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
async function sendDirect(items) {
|
|
1200
|
+
const groups = [];
|
|
1201
|
+
let bytes = 0;
|
|
1202
|
+
for (const item of items) {
|
|
1203
|
+
const group = groups[groups.length - 1];
|
|
1204
|
+
if (group === void 0 || group.length >= FILES_BATCH_MAX || bytes + item.size > DIRECT_BATCH_BYTES) {
|
|
1205
|
+
groups.push([item]);
|
|
1206
|
+
bytes = item.size;
|
|
1207
|
+
} else {
|
|
1208
|
+
group.push(item);
|
|
1209
|
+
bytes += item.size;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
for (const group of groups) {
|
|
1213
|
+
const body = [];
|
|
1214
|
+
const sent = [];
|
|
1215
|
+
for (const { job, size } of group) {
|
|
1216
|
+
const { kind } = job;
|
|
1217
|
+
if (size > KINDS[kind].bytes) {
|
|
1218
|
+
skip(kind, job.name, KINDS[kind].size);
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
let data;
|
|
1222
|
+
try {
|
|
1223
|
+
data = job.body ?? await import_node_fs5.promises.readFile(job.path);
|
|
1224
|
+
} catch {
|
|
1225
|
+
skip(kind, job.name, "missing");
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
const refusal = kind === "text" ? textRefusal(data) : void 0;
|
|
1229
|
+
if (refusal !== void 0) {
|
|
1230
|
+
skip(kind, job.name, refusal);
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
body.push({ resultId: job.id, name: job.name, contentType: job.type, data: data.toString("base64") });
|
|
1234
|
+
sent.push({ job, size });
|
|
1235
|
+
}
|
|
1236
|
+
if (body.length === 0) continue;
|
|
1237
|
+
const out = await gated(() => {
|
|
1238
|
+
requests += 1;
|
|
1239
|
+
return client.post("/v1/files/direct-batch", { items: body }, uploadRetries);
|
|
1240
|
+
});
|
|
1241
|
+
if (routeMissing(out)) {
|
|
1242
|
+
batchMissing = true;
|
|
1243
|
+
for (const { job, size } of sent) await gated(() => uploadDirect(job, size));
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
const verdicts = out.ok ? out.body?.items : void 0;
|
|
1247
|
+
if (!out.ok || !Array.isArray(verdicts) || verdicts.length !== sent.length) {
|
|
1248
|
+
for (const { job } of sent) failed(job, out.ok ? "bad_answer" : out.detail || out.kind);
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
sent.forEach(({ job }, i) => {
|
|
1252
|
+
const v = verdicts[i] ?? {};
|
|
1253
|
+
if (v.status === "stored") countsOf(job.kind).uploaded += 1;
|
|
1254
|
+
else if (v.status === "exists") existed += 1;
|
|
1255
|
+
else refusedItem(job, 0, v.status === "refused" ? v : { code: "bad_answer" });
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
async function uploadBatched(jobs) {
|
|
1260
|
+
const batches = chunk(jobs, FILES_BATCH_MAX);
|
|
1261
|
+
let next = 0;
|
|
1262
|
+
await Promise.all(
|
|
1263
|
+
Array.from({ length: Math.min(BATCH_PIPELINES, batches.length) }, async () => {
|
|
1264
|
+
for (let at = next++; at < batches.length; at = next++) await batchOf(batches[at]);
|
|
1265
|
+
})
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
954
1268
|
async function upload(items, filesByKey) {
|
|
955
1269
|
if (filesByKey.size === 0 || !Array.isArray(items)) return;
|
|
956
1270
|
const jobs = [];
|
|
@@ -975,18 +1289,10 @@ async function startRun(passed, declared) {
|
|
|
975
1289
|
}
|
|
976
1290
|
}
|
|
977
1291
|
}
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
await
|
|
981
|
-
|
|
982
|
-
const work = [];
|
|
983
|
-
for (const job of group) {
|
|
984
|
-
const act = await plan(job);
|
|
985
|
-
if (act !== void 0) work.push(gated(act));
|
|
986
|
-
}
|
|
987
|
-
await Promise.all(work);
|
|
988
|
-
})
|
|
989
|
-
);
|
|
1292
|
+
const started = Date.now();
|
|
1293
|
+
if (batchMissing) await uploadEach(jobs);
|
|
1294
|
+
else await uploadBatched(jobs);
|
|
1295
|
+
uploadMs += Date.now() - started;
|
|
990
1296
|
}
|
|
991
1297
|
async function flush() {
|
|
992
1298
|
if (buffer.length === 0) return;
|
|
@@ -1079,6 +1385,13 @@ async function startRun(passed, declared) {
|
|
|
1079
1385
|
}
|
|
1080
1386
|
if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
|
|
1081
1387
|
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
1388
|
+
const files = [stats.screenshots, stats.textFiles, stats.otherFiles];
|
|
1389
|
+
const total = (what) => files.reduce((n, counts) => n + counts[what], 0);
|
|
1390
|
+
if (total("uploaded") + total("skipped") + total("failed") + existed > 0) {
|
|
1391
|
+
log(
|
|
1392
|
+
`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)`
|
|
1393
|
+
);
|
|
1394
|
+
}
|
|
1082
1395
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
1083
1396
|
if (stats.deferred > 0 && process.env["GITHUB_ACTIONS"] === "true") {
|
|
1084
1397
|
log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
|
|
@@ -1135,9 +1448,9 @@ async function startRun(passed, declared) {
|
|
|
1135
1448
|
}
|
|
1136
1449
|
|
|
1137
1450
|
// src/util/hash.ts
|
|
1138
|
-
var
|
|
1451
|
+
var import_node_crypto3 = require("crypto");
|
|
1139
1452
|
function sha256(input) {
|
|
1140
|
-
return (0,
|
|
1453
|
+
return (0, import_node_crypto3.createHash)("sha256").update(input, "utf8").digest("hex");
|
|
1141
1454
|
}
|
|
1142
1455
|
|
|
1143
1456
|
// src/reporter-core/result-key.ts
|
package/dist/reporter-core.d.cts
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`.
|