@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.
@@ -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 import_node_crypto = require("crypto");
52
- var import_node_fs4 = require("fs");
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 RETRY_DELAYS_MS = [300, 900];
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
- for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
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 < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[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
- if (res.status === 429 && retryAfterMs(res) === null) {
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 < RETRY_DELAYS_MS.length) {
222
- await wait(retryAfterMs(res) ?? RETRY_DELAYS_MS[attempt]);
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
- for (let attempt = 0; attempt <= PUT_DELAYS_MS.length; attempt += 1) {
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 seconds = retryAfter === null ? NaN : Number(retryAfter.trim());
249
- if (attempt < PUT_DELAYS_MS.length) {
250
- await wait(Number.isFinite(seconds) && seconds >= 0 ? Math.min(seconds, 60) * 1e3 : PUT_DELAYS_MS[attempt]);
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 < PUT_DELAYS_MS.length) await wait(PUT_DELAYS_MS[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 = 100;
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, import_node_crypto.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
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, import_node_crypto.randomUUID)();
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 import_node_fs4.promises.readFile(job.path);
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
- const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {});
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 = UPLOADS_AT_ONCE;
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 import_node_fs4.promises.stat(job.path)).size;
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 byResult = /* @__PURE__ */ new Map();
979
- for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
980
- await Promise.all(
981
- [...byResult.values()].map(async (group) => {
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 import_node_crypto2 = require("crypto");
1451
+ var import_node_crypto3 = require("crypto");
1139
1452
  function sha256(input) {
1140
- return (0, import_node_crypto2.createHash)("sha256").update(input, "utf8").digest("hex");
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
@@ -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
- post<T>(path: string, body: unknown): Promise<ClientOutcome<T>>;
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`.