@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/dist/cli.cjs CHANGED
@@ -2304,6 +2304,13 @@ var init_credentials = __esm({
2304
2304
  });
2305
2305
 
2306
2306
  // src/reporter-core/client.ts
2307
+ function parseRetryAfter(raw, now = Date.now()) {
2308
+ const text2 = raw?.trim();
2309
+ if (text2 === void 0 || text2 === "") return null;
2310
+ const seconds = /^\d+(\.\d+)?$/.test(text2) ? Number(text2) : (Date.parse(text2) - now) / 1e3;
2311
+ if (!Number.isFinite(seconds)) return null;
2312
+ return Math.min(Math.max(seconds, 0) * 1e3, RETRY_AFTER_CAP_MS);
2313
+ }
2307
2314
  function nodeTransport(url, init, idleMs = IDLE_MS) {
2308
2315
  return new Promise((resolve9, reject) => {
2309
2316
  const target = new URL(url);
@@ -2371,12 +2378,6 @@ async function detailOf(res) {
2371
2378
  return { detail: "" };
2372
2379
  }
2373
2380
  }
2374
- function retryAfterMs2(res) {
2375
- const raw = res.headers.get("retry-after");
2376
- if (raw === null) return null;
2377
- const seconds = Number(raw.trim());
2378
- return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : null;
2379
- }
2380
2381
  function classify(status) {
2381
2382
  if (status === 401 || status === 403) return "auth";
2382
2383
  if (status === 409) return "conflict";
@@ -2387,15 +2388,34 @@ function classify(status) {
2387
2388
  function createClient(opts) {
2388
2389
  const doFetch = opts.fetchImpl ?? nodeTransport;
2389
2390
  const wait = opts.wait ?? sleep;
2391
+ const uploadRetries = opts.uploadRetries === void 0 ? void 0 : Math.max(0, Math.floor(opts.uploadRetries));
2392
+ const random = opts.random ?? Math.random;
2393
+ const now = opts.now ?? Date.now;
2394
+ let pausedUntil = 0;
2395
+ const jittered = (ms) => Math.round(ms * (1 + random() * 0.5));
2396
+ async function backOff(attempt, said) {
2397
+ opts.onRetry?.();
2398
+ const ms = jittered(said ?? Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
2399
+ const until = now() + ms;
2400
+ pausedUntil = Math.max(pausedUntil, until);
2401
+ await wait(ms);
2402
+ return until;
2403
+ }
2404
+ async function respectPause(mine) {
2405
+ const left = pausedUntil - now();
2406
+ if (pausedUntil > mine && left > 0) await wait(left + Math.round(random() * 250));
2407
+ }
2390
2408
  const base = opts.apiUrl.replace(/\/+$/, "");
2391
2409
  const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
2392
- async function send(method, path17, payload) {
2410
+ async function send(method, path17, payload, retries = DEFAULT_RETRIES) {
2393
2411
  let last = {
2394
2412
  kind: "unavailable",
2395
2413
  status: null,
2396
2414
  detail: "no attempt was made"
2397
2415
  };
2398
- for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
2416
+ let mine = 0;
2417
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
2418
+ await respectPause(mine);
2399
2419
  let res;
2400
2420
  try {
2401
2421
  res = await doFetch(`${base}${path17}`, {
@@ -2408,7 +2428,8 @@ function createClient(opts) {
2408
2428
  });
2409
2429
  } catch (err) {
2410
2430
  last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
2411
- if (attempt < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[attempt]);
2431
+ if (attempt >= Math.min(retries, DEFAULT_RETRIES)) break;
2432
+ mine = await backOff(attempt, null);
2412
2433
  continue;
2413
2434
  }
2414
2435
  if (res.ok) {
@@ -2423,25 +2444,28 @@ function createClient(opts) {
2423
2444
  const kind = classify(res.status);
2424
2445
  last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
2425
2446
  if (kind !== "unavailable") break;
2426
- if (res.status === 429 && retryAfterMs2(res) === null) {
2447
+ const said429 = parseRetryAfter(res.headers.get("retry-after"), now());
2448
+ if (res.status === 429 && said429 === null) {
2427
2449
  last.kind = "invalid";
2428
2450
  last.detail = "quota exceeded (no Retry-After)";
2429
2451
  if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
2430
2452
  break;
2431
2453
  }
2432
- if (attempt < RETRY_DELAYS_MS.length) {
2433
- await wait(retryAfterMs2(res) ?? RETRY_DELAYS_MS[attempt]);
2434
- }
2454
+ if (attempt >= (said429 === null ? Math.min(retries, DEFAULT_RETRIES) : retries)) break;
2455
+ mine = await backOff(attempt, said429);
2435
2456
  }
2436
2457
  return { ok: false, ...last };
2437
2458
  }
2438
2459
  return {
2439
- post: (path17, body) => send("POST", path17, { body: JSON.stringify(body), type: "application/json" }),
2440
- upload: (path17, bytes, contentType) => send("POST", path17, { body: bytes, type: contentType }),
2460
+ post: (path17, body, retries) => send("POST", path17, { body: JSON.stringify(body), type: "application/json" }, retries),
2461
+ upload: (path17, bytes, contentType) => send("POST", path17, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
2441
2462
  del: (path17) => send("DELETE", path17),
2442
2463
  async put(url, headers, source) {
2464
+ const putRetries = uploadRetries ?? 3;
2443
2465
  let last = { ok: false, status: null, code: "network" };
2444
- for (let attempt = 0; attempt <= PUT_DELAYS_MS.length; attempt += 1) {
2466
+ let mine = 0;
2467
+ for (let attempt = 0; attempt <= putRetries; attempt += 1) {
2468
+ await respectPause(mine);
2445
2469
  try {
2446
2470
  let status;
2447
2471
  let retryAfter;
@@ -2456,21 +2480,21 @@ function createClient(opts) {
2456
2480
  if (status >= 200 && status <= 299) return { ok: true };
2457
2481
  last = { ok: false, status, code: `bucket_${status}` };
2458
2482
  if (status < 500 && status !== 429) return last;
2459
- const seconds = retryAfter === null ? NaN : Number(retryAfter.trim());
2460
- if (attempt < PUT_DELAYS_MS.length) {
2461
- await wait(Number.isFinite(seconds) && seconds >= 0 ? Math.min(seconds, 60) * 1e3 : PUT_DELAYS_MS[attempt]);
2462
- }
2483
+ const said = parseRetryAfter(retryAfter, now());
2484
+ if (attempt >= (said === null ? Math.min(putRetries, 3) : putRetries)) return last;
2485
+ mine = await backOff(attempt, said);
2463
2486
  continue;
2464
2487
  } catch (err) {
2465
2488
  last = { ok: false, status: null, code: codeOfError(err) };
2466
2489
  }
2467
- if (attempt < PUT_DELAYS_MS.length) await wait(PUT_DELAYS_MS[attempt]);
2490
+ if (attempt >= Math.min(putRetries, 3)) return last;
2491
+ mine = await backOff(attempt, null);
2468
2492
  }
2469
2493
  return last;
2470
2494
  }
2471
2495
  };
2472
2496
  }
2473
- var import_node_fs2, import_node_stream, import_node_http, import_node_https, RETRY_DELAYS_MS, IDLE_MS, PUT_DELAYS_MS, withoutLength, codeOfError, sleep;
2497
+ var import_node_fs2, import_node_stream, import_node_http, import_node_https, BACKOFF_BASE_MS, BACKOFF_CAP_MS, DEFAULT_RETRIES, RETRY_AFTER_CAP_MS, IDLE_MS, withoutLength, codeOfError, sleep;
2474
2498
  var init_client = __esm({
2475
2499
  "src/reporter-core/client.ts"() {
2476
2500
  "use strict";
@@ -2479,9 +2503,11 @@ var init_client = __esm({
2479
2503
  import_node_stream = require("stream");
2480
2504
  import_node_http = require("http");
2481
2505
  import_node_https = require("https");
2482
- RETRY_DELAYS_MS = [300, 900];
2506
+ BACKOFF_BASE_MS = 300;
2507
+ BACKOFF_CAP_MS = 1e4;
2508
+ DEFAULT_RETRIES = 2;
2509
+ RETRY_AFTER_CAP_MS = 6e4;
2483
2510
  IDLE_MS = 3e5;
2484
- PUT_DELAYS_MS = [500, 1500, 4e3];
2485
2511
  withoutLength = (headers) => Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-length"));
2486
2512
  codeOfError = (err) => {
2487
2513
  const code = err?.code;
@@ -2506,6 +2532,14 @@ function count(env, name) {
2506
2532
  const n = Number(raw);
2507
2533
  return Number.isInteger(n) && n > 0 ? n : void 0;
2508
2534
  }
2535
+ function bounded(env, name, range, warnings) {
2536
+ const raw = value(env, name);
2537
+ if (raw === void 0) return void 0;
2538
+ const n = Number(raw);
2539
+ if (/^\d+$/.test(raw) && n >= range.min && n <= range.max) return n;
2540
+ warnings.push(`${name}=${JSON.stringify(raw.slice(0, 40))} is not a whole number from ${range.min} to ${range.max} \u2014 using ${range.default}.`);
2541
+ return void 0;
2542
+ }
2509
2543
  function list(env, name) {
2510
2544
  const raw = value(env, name);
2511
2545
  if (raw === void 0) return void 0;
@@ -2518,6 +2552,9 @@ function readEnv(env = process.env) {
2518
2552
  const apiUrl = value(env, "PLUNE_API_URL");
2519
2553
  const fallbackPath = value(env, "PLUNE_FALLBACK");
2520
2554
  const batchSize = count(env, "PLUNE_BATCH_SIZE");
2555
+ const warnings = [];
2556
+ const uploadConcurrency = bounded(env, "PLUNE_UPLOAD_CONCURRENCY", UPLOAD_CONCURRENCY, warnings);
2557
+ const uploadRetries = bounded(env, "PLUNE_UPLOAD_RETRIES", UPLOAD_RETRIES, warnings);
2521
2558
  const title = value(env, "PLUNE_RUN_TITLE");
2522
2559
  const environment = value(env, "PLUNE_ENV");
2523
2560
  const labels = list(env, "PLUNE_LABELS");
@@ -2530,6 +2567,8 @@ function readEnv(env = process.env) {
2530
2567
  ...apiUrl !== void 0 ? { apiUrl } : {},
2531
2568
  ...fallbackPath !== void 0 ? { fallbackPath } : {},
2532
2569
  ...batchSize !== void 0 ? { batchSize } : {},
2570
+ ...uploadConcurrency !== void 0 ? { uploadConcurrency } : {},
2571
+ ...uploadRetries !== void 0 ? { uploadRetries } : {},
2533
2572
  ...title !== void 0 ? { title } : {},
2534
2573
  ...environment !== void 0 ? { environment } : {},
2535
2574
  ...labels !== void 0 ? { labels } : {},
@@ -2542,7 +2581,8 @@ function readEnv(env = process.env) {
2542
2581
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
2543
2582
  // way this process must not be the one to call it finished.
2544
2583
  keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED"),
2545
- ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0)
2584
+ ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
2585
+ warnings
2546
2586
  };
2547
2587
  }
2548
2588
  function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cwd = process.cwd()) {
@@ -2568,13 +2608,15 @@ function ciCommit(reported, env = process.env) {
2568
2608
  ...branch !== void 0 ? { branch } : {}
2569
2609
  };
2570
2610
  }
2571
- var path6, UNSUPPORTED_VARS, PROVIDERS;
2611
+ var path6, UNSUPPORTED_VARS, UPLOAD_CONCURRENCY, UPLOAD_RETRIES, PROVIDERS;
2572
2612
  var init_env2 = __esm({
2573
2613
  "src/reporter-core/env.ts"() {
2574
2614
  "use strict";
2575
2615
  init_cjs_shims();
2576
2616
  path6 = __toESM(require("path"), 1);
2577
2617
  UNSUPPORTED_VARS = ["PLUNE_GROUP"];
2618
+ UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 4 };
2619
+ UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
2578
2620
  PROVIDERS = [
2579
2621
  { is: "GITHUB_ACTIONS", sha: "GITHUB_SHA", branch: ["GITHUB_HEAD_REF", "GITHUB_REF_NAME"] },
2580
2622
  { is: "GITLAB_CI", sha: "CI_COMMIT_SHA", branch: ["CI_COMMIT_REF_NAME"] },
@@ -2747,6 +2789,34 @@ var init_failure_detail = __esm({
2747
2789
  }
2748
2790
  });
2749
2791
 
2792
+ // src/reporter-core/files-batch.ts
2793
+ async function sha256File(path17) {
2794
+ const hash = (0, import_node_crypto2.createHash)("sha256");
2795
+ for await (const part of (0, import_node_fs4.createReadStream)(path17)) hash.update(part);
2796
+ return hash.digest("hex");
2797
+ }
2798
+ var import_node_crypto2, import_node_fs4, FILES_BATCH_MAX, DIRECT_BATCH_BYTES, BATCH_PIPELINES, sha256Bytes, BUCKET_CODES;
2799
+ var init_files_batch = __esm({
2800
+ "src/reporter-core/files-batch.ts"() {
2801
+ "use strict";
2802
+ init_cjs_shims();
2803
+ import_node_crypto2 = require("crypto");
2804
+ import_node_fs4 = require("fs");
2805
+ FILES_BATCH_MAX = 100;
2806
+ DIRECT_BATCH_BYTES = 6 * 1024 * 1024;
2807
+ BATCH_PIPELINES = 2;
2808
+ sha256Bytes = (bytes) => (0, import_node_crypto2.createHash)("sha256").update(bytes).digest("hex");
2809
+ BUCKET_CODES = /* @__PURE__ */ new Set([
2810
+ "bucket_unreachable",
2811
+ "not_connected",
2812
+ "platform_not_configured",
2813
+ "upload_expired",
2814
+ "file_mismatch",
2815
+ "file_missing"
2816
+ ]);
2817
+ }
2818
+ });
2819
+
2750
2820
  // src/reporter-core/session.ts
2751
2821
  function nameOf(file) {
2752
2822
  const own = file.name.split(/[\\/]/).pop()?.trim() || (file.path === void 0 ? "" : (0, import_node_path3.basename)(file.path));
@@ -2798,7 +2868,7 @@ function tally(counts, noun) {
2798
2868
  }
2799
2869
  function boundKey(key) {
2800
2870
  if (key.value.length <= KEY_MAX) return key;
2801
- const digest = (0, import_node_crypto2.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
2871
+ const digest = (0, import_node_crypto3.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
2802
2872
  let head = key.value.slice(0, KEY_MAX - digest.length - 1);
2803
2873
  if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
2804
2874
  return { ...key, value: `${head}#${digest}` };
@@ -2850,13 +2920,22 @@ async function startRun(passed, declared2) {
2850
2920
  `));
2851
2921
  const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
2852
2922
  const externalKey = cfg.externalKey ?? null;
2853
- const sessionMarker = (0, import_node_crypto2.randomUUID)();
2923
+ const sessionMarker = (0, import_node_crypto3.randomUUID)();
2854
2924
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
2855
2925
  const offerDiscovered = cfg.offerDiscovered ?? false;
2856
2926
  const token = cfg.token ?? loadToken() ?? "";
2927
+ const uploadsAtOnce = Math.min(Math.max(Math.floor(cfg.uploadConcurrency ?? UPLOAD_CONCURRENCY.default), UPLOAD_CONCURRENCY.min), UPLOAD_CONCURRENCY.max);
2928
+ const uploadRetries = Math.min(Math.max(Math.floor(cfg.uploadRetries ?? UPLOAD_RETRIES.default), UPLOAD_RETRIES.min), UPLOAD_RETRIES.max);
2929
+ let retried = 0;
2930
+ let uploadMs = 0;
2931
+ let requests = 0;
2932
+ let existed = 0;
2933
+ let batchMissing = false;
2857
2934
  const client = createClient({
2858
2935
  apiUrl: resolveApiUrl(cfg.apiUrl),
2859
2936
  token,
2937
+ uploadRetries,
2938
+ onRetry: () => void (retried += 1),
2860
2939
  ...cfg.fetchImpl !== void 0 ? { fetchImpl: cfg.fetchImpl } : {}
2861
2940
  });
2862
2941
  const stats = {
@@ -2907,6 +2986,7 @@ async function startRun(passed, declared2) {
2907
2986
  if (offline) {
2908
2987
  log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
2909
2988
  }
2989
+ for (const warning of env.warnings) log(`plune: ${warning}`);
2910
2990
  if (env.ignored.length > 0) {
2911
2991
  log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
2912
2992
  }
@@ -3034,9 +3114,10 @@ async function startRun(passed, declared2) {
3034
3114
  else log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
3035
3115
  }
3036
3116
  function ask(job, size) {
3117
+ requests += 1;
3037
3118
  return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
3038
3119
  files: [{ name: job.name, contentType: job.type, size }]
3039
- });
3120
+ }, uploadRetries);
3040
3121
  }
3041
3122
  function failed(job, why) {
3042
3123
  countsOf(job.kind).failed += 1;
@@ -3068,13 +3149,14 @@ async function startRun(passed, declared2) {
3068
3149
  if (job.body !== void 0) bytes = job.body;
3069
3150
  else {
3070
3151
  try {
3071
- bytes = await import_node_fs4.promises.readFile(job.path);
3152
+ bytes = await import_node_fs5.promises.readFile(job.path);
3072
3153
  } catch {
3073
3154
  return skip(kind, job.name, "missing");
3074
3155
  }
3075
3156
  }
3076
3157
  const refusal = kind === "text" ? textRefusal(bytes) : void 0;
3077
3158
  if (refusal !== void 0) return skip(kind, job.name, refusal);
3159
+ requests += 1;
3078
3160
  const out = await client.upload(
3079
3161
  `/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
3080
3162
  bytes,
@@ -3097,11 +3179,12 @@ async function startRun(passed, declared2) {
3097
3179
  if (fresh === void 0) return failedToBucket(job, "bad_answer");
3098
3180
  link = fresh;
3099
3181
  }
3100
- const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {});
3182
+ requests += 1;
3183
+ const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {}, uploadRetries);
3101
3184
  if (!done2.ok) return failedToBucket(job, codeOf2(done2));
3102
3185
  countsOf(job.kind).uploaded += 1;
3103
3186
  }
3104
- let free = UPLOADS_AT_ONCE;
3187
+ let free = uploadsAtOnce;
3105
3188
  const waiting = [];
3106
3189
  async function gated(work) {
3107
3190
  if (free === 0) await new Promise((resolve9) => waiting.push(resolve9));
@@ -3121,7 +3204,7 @@ async function startRun(passed, declared2) {
3121
3204
  else if (job.path === void 0) return void skip(kind, job.name, "missing");
3122
3205
  else {
3123
3206
  try {
3124
- size = (await import_node_fs4.promises.stat(job.path)).size;
3207
+ size = (await import_node_fs5.promises.stat(job.path)).size;
3125
3208
  } catch {
3126
3209
  return void skip(kind, job.name, "missing");
3127
3210
  }
@@ -3157,6 +3240,245 @@ async function startRun(passed, declared2) {
3157
3240
  }
3158
3241
  return () => uploadToBucket(job, size, link);
3159
3242
  }
3243
+ async function uploadEach(jobs) {
3244
+ const byResult = /* @__PURE__ */ new Map();
3245
+ for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
3246
+ await Promise.all(
3247
+ [...byResult.values()].map(async (group) => {
3248
+ const work = [];
3249
+ for (const job of group) {
3250
+ const act = await plan(job);
3251
+ if (act !== void 0) work.push(gated(act));
3252
+ }
3253
+ await Promise.all(work);
3254
+ })
3255
+ );
3256
+ }
3257
+ async function measure(job) {
3258
+ let size;
3259
+ if (job.body !== void 0) size = job.body.length;
3260
+ else if (job.path === void 0) return void skip(job.kind, job.name, "missing");
3261
+ else {
3262
+ try {
3263
+ size = (await import_node_fs5.promises.stat(job.path)).size;
3264
+ } catch {
3265
+ return void skip(job.kind, job.name, "missing");
3266
+ }
3267
+ }
3268
+ if (size === 0) return void skip(job.kind, job.name, "empty");
3269
+ return size;
3270
+ }
3271
+ const routeMissing = (out) => !out.ok && out.status === 404 && out.detail === "" && out.code === void 0;
3272
+ function takeRoom(job) {
3273
+ if (job.kind === "file") {
3274
+ skip(job.kind, job.name, "noStorage");
3275
+ return false;
3276
+ }
3277
+ const roomKey = `${job.id}/${job.kind}`;
3278
+ const left = rooms.get(roomKey) ?? KINDS[job.kind].room;
3279
+ if (left === 0) {
3280
+ skip(job.kind, job.name, KINDS[job.kind].limit);
3281
+ return false;
3282
+ }
3283
+ rooms.set(roomKey, left - 1);
3284
+ return true;
3285
+ }
3286
+ function refusedItem(job, size, item) {
3287
+ const code = item.code ?? "refused";
3288
+ if (code === "duplicate") existed += 1;
3289
+ else if (code === "no_storage") skip(job.kind, job.name, refusedHere(job, size) ?? "noStorage");
3290
+ else if (code === "too_many_files") skip(job.kind, job.name, KINDS[job.kind].limit);
3291
+ else if (BUCKET_CODES.has(code)) failedToBucket(job, code);
3292
+ else failed(job, code);
3293
+ }
3294
+ const itemPause = (round) => new Promise((r) => setTimeout(r, Math.min(250 * 2 ** round, 4e3)));
3295
+ async function batchOf(jobs) {
3296
+ if (batchMissing) return uploadEach(jobs);
3297
+ const sized = [];
3298
+ for (const job of jobs) {
3299
+ const size = await measure(job);
3300
+ if (size === void 0) continue;
3301
+ try {
3302
+ sized.push({ job, size, sha256: job.body !== void 0 ? sha256Bytes(job.body) : await sha256File(job.path) });
3303
+ } catch {
3304
+ skip(job.kind, job.name, "missing");
3305
+ }
3306
+ }
3307
+ if (sized.length === 0) return;
3308
+ const announced = await gated(() => {
3309
+ requests += 1;
3310
+ return client.post(
3311
+ "/v1/files/uploads-batch",
3312
+ { items: sized.map((s) => ({ resultId: s.job.id, name: s.job.name, contentType: s.job.type, size: s.size, sha256: s.sha256 })) },
3313
+ uploadRetries
3314
+ );
3315
+ });
3316
+ if (routeMissing(announced)) {
3317
+ batchMissing = true;
3318
+ return uploadEach(sized.map((s) => s.job));
3319
+ }
3320
+ const verdicts = announced.ok ? announced.body?.items : void 0;
3321
+ if (!announced.ok || !Array.isArray(verdicts) || verdicts.length !== sized.length) {
3322
+ for (const s of sized) {
3323
+ if (announced.ok) failed(s.job, "bad_answer");
3324
+ else if (announced.code !== void 0) failedToBucket(s.job, announced.code);
3325
+ else failed(s.job, announced.detail || announced.kind);
3326
+ }
3327
+ return;
3328
+ }
3329
+ const puts = [];
3330
+ const directs = [];
3331
+ sized.forEach((s, i) => {
3332
+ const v = verdicts[i] ?? {};
3333
+ if (v.status === "exists") existed += 1;
3334
+ else if (v.status === "direct") {
3335
+ if (takeRoom(s.job)) directs.push(s);
3336
+ } else if (v.status === "upload") {
3337
+ const link = linkOf(v);
3338
+ if (link === void 0) return failed(s.job, "bad_answer");
3339
+ if (!bucketSeen) {
3340
+ bucketSeen = true;
3341
+ log(BUCKET_FOUND);
3342
+ }
3343
+ puts.push({ s, link });
3344
+ } else if (v.status === "refused") refusedItem(s.job, s.size, v);
3345
+ else failed(s.job, "bad_answer");
3346
+ });
3347
+ const toComplete = [];
3348
+ await Promise.all(
3349
+ puts.map(
3350
+ ({ s, link: first }) => gated(async () => {
3351
+ const source = s.job.body !== void 0 ? { body: s.job.body } : { path: s.job.path, size: s.size };
3352
+ let link = first;
3353
+ for (let renewed = false; ; ) {
3354
+ const put = await client.put(link.url, link.headers, source);
3355
+ if (put.ok) break;
3356
+ if (put.status !== 403 || renewed) return failedToBucket(s.job, put.code);
3357
+ renewed = true;
3358
+ requests += 1;
3359
+ const again = await client.post(
3360
+ "/v1/files/uploads-batch",
3361
+ { items: [{ resultId: s.job.id, name: s.job.name, contentType: s.job.type, size: s.size, sha256: s.sha256 }] },
3362
+ uploadRetries
3363
+ );
3364
+ const fresh = again.ok ? linkOf(again.body?.items?.[0]) : void 0;
3365
+ if (fresh === void 0) return failedToBucket(s.job, again.ok ? "bad_answer" : codeOf2(again));
3366
+ link = fresh;
3367
+ }
3368
+ toComplete.push({ job: s.job, id: link.id });
3369
+ })
3370
+ )
3371
+ );
3372
+ await complete(toComplete);
3373
+ await sendDirect(directs);
3374
+ }
3375
+ async function complete(items) {
3376
+ for (let round = 0; items.length > 0; round += 1) {
3377
+ const out = await gated(() => {
3378
+ requests += 1;
3379
+ return client.post("/v1/files/complete-batch", { ids: items.map((i) => i.id) }, uploadRetries);
3380
+ });
3381
+ if (routeMissing(out)) {
3382
+ await Promise.all(
3383
+ items.map(
3384
+ (i) => gated(async () => {
3385
+ requests += 1;
3386
+ const one = await client.post(`/v1/files/${encodeURIComponent(i.id)}/complete`, {}, uploadRetries);
3387
+ if (one.ok) countsOf(i.job.kind).uploaded += 1;
3388
+ else failedToBucket(i.job, codeOf2(one));
3389
+ })
3390
+ )
3391
+ );
3392
+ return;
3393
+ }
3394
+ const verdicts = out.ok ? out.body?.items : void 0;
3395
+ if (!out.ok || !Array.isArray(verdicts) || verdicts.length !== items.length) {
3396
+ for (const i of items) failedToBucket(i.job, out.ok ? "bad_answer" : codeOf2(out));
3397
+ return;
3398
+ }
3399
+ const again = [];
3400
+ items.forEach((i, at) => {
3401
+ const v = verdicts[at] ?? {};
3402
+ if (v.status === "ready") countsOf(i.job.kind).uploaded += 1;
3403
+ else if (v.retryable === true && round < Math.min(uploadRetries, 3)) again.push(i);
3404
+ else refusedItem(i.job, 0, v.status === "refused" ? v : { code: "bad_answer" });
3405
+ });
3406
+ items = again;
3407
+ if (items.length > 0) {
3408
+ retried += items.length;
3409
+ await itemPause(round);
3410
+ }
3411
+ }
3412
+ }
3413
+ async function sendDirect(items) {
3414
+ const groups = [];
3415
+ let bytes = 0;
3416
+ for (const item of items) {
3417
+ const group = groups[groups.length - 1];
3418
+ if (group === void 0 || group.length >= FILES_BATCH_MAX || bytes + item.size > DIRECT_BATCH_BYTES) {
3419
+ groups.push([item]);
3420
+ bytes = item.size;
3421
+ } else {
3422
+ group.push(item);
3423
+ bytes += item.size;
3424
+ }
3425
+ }
3426
+ for (const group of groups) {
3427
+ const body = [];
3428
+ const sent = [];
3429
+ for (const { job, size } of group) {
3430
+ const { kind } = job;
3431
+ if (size > KINDS[kind].bytes) {
3432
+ skip(kind, job.name, KINDS[kind].size);
3433
+ continue;
3434
+ }
3435
+ let data;
3436
+ try {
3437
+ data = job.body ?? await import_node_fs5.promises.readFile(job.path);
3438
+ } catch {
3439
+ skip(kind, job.name, "missing");
3440
+ continue;
3441
+ }
3442
+ const refusal = kind === "text" ? textRefusal(data) : void 0;
3443
+ if (refusal !== void 0) {
3444
+ skip(kind, job.name, refusal);
3445
+ continue;
3446
+ }
3447
+ body.push({ resultId: job.id, name: job.name, contentType: job.type, data: data.toString("base64") });
3448
+ sent.push({ job, size });
3449
+ }
3450
+ if (body.length === 0) continue;
3451
+ const out = await gated(() => {
3452
+ requests += 1;
3453
+ return client.post("/v1/files/direct-batch", { items: body }, uploadRetries);
3454
+ });
3455
+ if (routeMissing(out)) {
3456
+ batchMissing = true;
3457
+ for (const { job, size } of sent) await gated(() => uploadDirect(job, size));
3458
+ continue;
3459
+ }
3460
+ const verdicts = out.ok ? out.body?.items : void 0;
3461
+ if (!out.ok || !Array.isArray(verdicts) || verdicts.length !== sent.length) {
3462
+ for (const { job } of sent) failed(job, out.ok ? "bad_answer" : out.detail || out.kind);
3463
+ continue;
3464
+ }
3465
+ sent.forEach(({ job }, i) => {
3466
+ const v = verdicts[i] ?? {};
3467
+ if (v.status === "stored") countsOf(job.kind).uploaded += 1;
3468
+ else if (v.status === "exists") existed += 1;
3469
+ else refusedItem(job, 0, v.status === "refused" ? v : { code: "bad_answer" });
3470
+ });
3471
+ }
3472
+ }
3473
+ async function uploadBatched(jobs) {
3474
+ const batches = chunk(jobs, FILES_BATCH_MAX);
3475
+ let next = 0;
3476
+ await Promise.all(
3477
+ Array.from({ length: Math.min(BATCH_PIPELINES, batches.length) }, async () => {
3478
+ for (let at = next++; at < batches.length; at = next++) await batchOf(batches[at]);
3479
+ })
3480
+ );
3481
+ }
3160
3482
  async function upload(items, filesByKey) {
3161
3483
  if (filesByKey.size === 0 || !Array.isArray(items)) return;
3162
3484
  const jobs = [];
@@ -3181,18 +3503,10 @@ async function startRun(passed, declared2) {
3181
3503
  }
3182
3504
  }
3183
3505
  }
3184
- const byResult = /* @__PURE__ */ new Map();
3185
- for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
3186
- await Promise.all(
3187
- [...byResult.values()].map(async (group) => {
3188
- const work = [];
3189
- for (const job of group) {
3190
- const act = await plan(job);
3191
- if (act !== void 0) work.push(gated(act));
3192
- }
3193
- await Promise.all(work);
3194
- })
3195
- );
3506
+ const started = Date.now();
3507
+ if (batchMissing) await uploadEach(jobs);
3508
+ else await uploadBatched(jobs);
3509
+ uploadMs += Date.now() - started;
3196
3510
  }
3197
3511
  async function flush() {
3198
3512
  if (buffer.length === 0) return;
@@ -3285,6 +3599,13 @@ async function startRun(passed, declared2) {
3285
3599
  }
3286
3600
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
3287
3601
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
3602
+ const files = [stats.screenshots, stats.textFiles, stats.otherFiles];
3603
+ const total = (what) => files.reduce((n, counts) => n + counts[what], 0);
3604
+ if (total("uploaded") + total("skipped") + total("failed") + existed > 0) {
3605
+ log(
3606
+ `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)`
3607
+ );
3608
+ }
3288
3609
  log(`plune: ${parts.join(" \xB7 ")}`);
3289
3610
  if (stats.deferred > 0 && process.env["GITHUB_ACTIONS"] === "true") {
3290
3611
  log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
@@ -3339,13 +3660,13 @@ async function startRun(passed, declared2) {
3339
3660
  }
3340
3661
  };
3341
3662
  }
3342
- var import_node_crypto2, import_node_fs4, import_node_path3, import_node_util2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TEXT_BYTES, TEXTS_PER_RESULT, UTF8, TYPE_BY_EXTENSION, METADATA_TYPE2, UPLOADS_AT_ONCE, NAME_MAX, SKIPPED, BUCKET_FOUND, KINDS, codeOf2, BATCH_BYTES, ENVELOPE_BYTES;
3663
+ var import_node_crypto3, import_node_fs5, import_node_path3, import_node_util2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TEXT_BYTES, TEXTS_PER_RESULT, UTF8, TYPE_BY_EXTENSION, METADATA_TYPE2, NAME_MAX, SKIPPED, BUCKET_FOUND, KINDS, codeOf2, BATCH_BYTES, ENVELOPE_BYTES;
3343
3664
  var init_session = __esm({
3344
3665
  "src/reporter-core/session.ts"() {
3345
3666
  "use strict";
3346
3667
  init_cjs_shims();
3347
- import_node_crypto2 = require("crypto");
3348
- import_node_fs4 = require("fs");
3668
+ import_node_crypto3 = require("crypto");
3669
+ import_node_fs5 = require("fs");
3349
3670
  import_node_path3 = require("path");
3350
3671
  import_node_util2 = require("util");
3351
3672
  init_api_url();
@@ -3353,11 +3674,12 @@ var init_session = __esm({
3353
3674
  init_client();
3354
3675
  init_env2();
3355
3676
  init_failure_detail();
3677
+ init_files_batch();
3356
3678
  init_fallback();
3357
3679
  RESOLVE_CHUNK = 500;
3358
3680
  BATCH_MAX = 500;
3359
3681
  DISCOVER_MAX = 500;
3360
- BATCH_DEFAULT = 100;
3682
+ BATCH_DEFAULT = 500;
3361
3683
  EXPECTED_MAX = 1e4;
3362
3684
  KEY_MAX = 1024;
3363
3685
  TITLE_MAX = 300;
@@ -3375,7 +3697,6 @@ var init_session = __esm({
3375
3697
  ".webp": "image/webp"
3376
3698
  };
3377
3699
  METADATA_TYPE2 = "application/plune.metadata+json";
3378
- UPLOADS_AT_ONCE = 4;
3379
3700
  NAME_MAX = 200;
3380
3701
  SKIPPED = {
3381
3702
  hidden: "its name starts with an underscore, which hides an attachment in Playwright's own reports too",
@@ -4821,7 +5142,7 @@ function readPlaywrightJson(source, file) {
4821
5142
  const { config, suites } = parsed;
4822
5143
  const rootDir = typeof config?.rootDir === "string" ? config.rootDir : void 0;
4823
5144
  const buildHref = config?.metadata?.ci?.buildHref;
4824
- const repoRoot = rootDir !== void 0 && (0, import_node_fs5.existsSync)(rootDir) ? repoRootOf(rootDir) : void 0;
5145
+ const repoRoot = rootDir !== void 0 && (0, import_node_fs6.existsSync)(rootDir) ? repoRootOf(rootDir) : void 0;
4825
5146
  const ctx = {
4826
5147
  ...rootDir !== void 0 ? { rootDir } : {},
4827
5148
  ...repoRoot !== void 0 ? { repoRoot } : {},
@@ -4840,12 +5161,12 @@ function readPlaywrightJson(source, file) {
4840
5161
  function looksLikePlaywrightJson(source) {
4841
5162
  return /^\s*\{/.test(source) && source.includes('"suites"');
4842
5163
  }
4843
- var import_node_fs5, SOURCE3, TOKEN3, NOT_FAILED, JsonReportError;
5164
+ var import_node_fs6, SOURCE3, TOKEN3, NOT_FAILED, JsonReportError;
4844
5165
  var init_playwright_json = __esm({
4845
5166
  "src/importers/playwright-json.ts"() {
4846
5167
  "use strict";
4847
5168
  init_cjs_shims();
4848
- import_node_fs5 = require("fs");
5169
+ import_node_fs6 = require("fs");
4849
5170
  init_failure_detail();
4850
5171
  init_result_key();
4851
5172
  SOURCE3 = "playwright";
@@ -4958,6 +5279,7 @@ async function handleRunImport(options) {
4958
5279
  ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
4959
5280
  ...options.key !== void 0 ? { externalKey: options.key } : {},
4960
5281
  ...options.create === true ? { offerDiscovered: true } : {},
5282
+ ...options.uploadConcurrency !== void 0 ? { uploadConcurrency: options.uploadConcurrency } : {},
4961
5283
  kind: "automated",
4962
5284
  // The CI run the report names, never this machine's: whoever imports may be another job (#790).
4963
5285
  // The commit and branch likewise, where the report names a commit; a report that names none —
@@ -5872,11 +6194,11 @@ module.exports = __toCommonJS(cli_exports);
5872
6194
  init_cjs_shims();
5873
6195
  init_api_url();
5874
6196
  var import_commander = require("commander");
5875
- var import_node_fs6 = require("fs");
6197
+ var import_node_fs7 = require("fs");
5876
6198
  var import_node_path4 = require("path");
5877
6199
  var import_node_url = require("url");
5878
6200
  var __dirname = (0, import_node_path4.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
5879
- var pkg = JSON.parse((0, import_node_fs6.readFileSync)((0, import_node_path4.join)(__dirname, "..", "package.json"), "utf-8"));
6201
+ var pkg = JSON.parse((0, import_node_fs7.readFileSync)((0, import_node_path4.join)(__dirname, "..", "package.json"), "utf-8"));
5880
6202
  function toFormat(s) {
5881
6203
  return s === "json" || s === "markdown" ? s : "console";
5882
6204
  }
@@ -5885,7 +6207,7 @@ function shouldColor(outputPath) {
5885
6207
  }
5886
6208
  function writeReport(text2, outputPath) {
5887
6209
  if (outputPath !== void 0) {
5888
- (0, import_node_fs6.writeFileSync)(outputPath, text2);
6210
+ (0, import_node_fs7.writeFileSync)(outputPath, text2);
5889
6211
  } else {
5890
6212
  process.stdout.write(text2 + "\n");
5891
6213
  }
@@ -6110,6 +6432,13 @@ function createProgram() {
6110
6432
  "--fail-on-missing-attachments",
6111
6433
  "Exit 1 when a file the report names ([[ATTACHMENT|path]] in JUnit, a source in allure-results) is not there; the results are sent either way",
6112
6434
  false
6435
+ ).option(
6436
+ "--upload-concurrency <n>",
6437
+ "File uploads in flight at once, 1 to 32 (default 4; PLUNE_UPLOAD_CONCURRENCY sets it too). Files are announced and confirmed up to 100 to a request; a 429 is retried PLUNE_UPLOAD_RETRIES times (default 6)",
6438
+ (v) => {
6439
+ if (!/^\d+$/.test(v) || Number(v) < 1 || Number(v) > 32) throw new import_commander.InvalidArgumentError("a whole number from 1 to 32.");
6440
+ return Number(v);
6441
+ }
6113
6442
  ).option("--fail-on-failures", FAIL_ON_FAILURES, false).action(
6114
6443
  async (file, options, command) => {
6115
6444
  const [
@@ -6135,6 +6464,7 @@ function createProgram() {
6135
6464
  ...format !== void 0 ? { format } : {},
6136
6465
  ...options.key !== void 0 ? { key: options.key } : {},
6137
6466
  ...options.create ? { create: true } : {},
6467
+ ...options.uploadConcurrency !== void 0 ? { uploadConcurrency: options.uploadConcurrency } : {},
6138
6468
  ...options.failOnFailures ? { failOnFailures: true } : {}
6139
6469
  });
6140
6470
  if (options.failOnMissingAttachments && imported.missingAttachments.length > 0) process.exitCode = 1;
@@ -6376,7 +6706,7 @@ function isMainModule() {
6376
6706
  const entry = process.argv[1];
6377
6707
  if (entry === void 0) return false;
6378
6708
  try {
6379
- return (0, import_node_fs6.realpathSync)(entry) === (0, import_node_fs6.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
6709
+ return (0, import_node_fs7.realpathSync)(entry) === (0, import_node_fs7.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
6380
6710
  } catch {
6381
6711
  return false;
6382
6712
  }