@plune-ai/cli 0.20.0 → 0.22.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.
@@ -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()) {
@@ -499,7 +541,7 @@ function appendBatch(file, batch) {
499
541
  var RESOLVE_CHUNK = 500;
500
542
  var BATCH_MAX = 500;
501
543
  var DISCOVER_MAX = 500;
502
- var BATCH_DEFAULT = 100;
544
+ var BATCH_DEFAULT = 500;
503
545
  var EXPECTED_MAX = 1e4;
504
546
  var KEY_MAX = 1024;
505
547
  var TITLE_MAX = 300;
@@ -517,7 +559,6 @@ var TYPE_BY_EXTENSION = {
517
559
  ".webp": "image/webp"
518
560
  };
519
561
  var METADATA_TYPE2 = "application/plune.metadata+json";
520
- var UPLOADS_AT_ONCE = 4;
521
562
  var NAME_MAX = 200;
522
563
  function nameOf(file) {
523
564
  const own = file.name.split(/[\\/]/).pop()?.trim() || (file.path === void 0 ? "" : (0, import_node_path3.basename)(file.path));
@@ -648,9 +689,15 @@ async function startRun(passed, declared) {
648
689
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
649
690
  const offerDiscovered = cfg.offerDiscovered ?? false;
650
691
  const token = cfg.token ?? loadToken() ?? "";
692
+ const uploadsAtOnce = Math.min(Math.max(Math.floor(cfg.uploadConcurrency ?? UPLOAD_CONCURRENCY.default), UPLOAD_CONCURRENCY.min), UPLOAD_CONCURRENCY.max);
693
+ const uploadRetries = Math.min(Math.max(Math.floor(cfg.uploadRetries ?? UPLOAD_RETRIES.default), UPLOAD_RETRIES.min), UPLOAD_RETRIES.max);
694
+ let retried = 0;
695
+ let uploadMs = 0;
651
696
  const client = createClient({
652
697
  apiUrl: resolveApiUrl(cfg.apiUrl),
653
698
  token,
699
+ uploadRetries,
700
+ onRetry: () => void (retried += 1),
654
701
  ...cfg.fetchImpl !== void 0 ? { fetchImpl: cfg.fetchImpl } : {}
655
702
  });
656
703
  const stats = {
@@ -701,6 +748,7 @@ async function startRun(passed, declared) {
701
748
  if (offline) {
702
749
  log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
703
750
  }
751
+ for (const warning of env.warnings) log(`plune: ${warning}`);
704
752
  if (env.ignored.length > 0) {
705
753
  log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
706
754
  }
@@ -830,7 +878,7 @@ async function startRun(passed, declared) {
830
878
  function ask(job, size) {
831
879
  return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
832
880
  files: [{ name: job.name, contentType: job.type, size }]
833
- });
881
+ }, uploadRetries);
834
882
  }
835
883
  function failed(job, why) {
836
884
  countsOf(job.kind).failed += 1;
@@ -891,11 +939,11 @@ async function startRun(passed, declared) {
891
939
  if (fresh === void 0) return failedToBucket(job, "bad_answer");
892
940
  link = fresh;
893
941
  }
894
- const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {});
942
+ const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {}, uploadRetries);
895
943
  if (!done2.ok) return failedToBucket(job, codeOf(done2));
896
944
  countsOf(job.kind).uploaded += 1;
897
945
  }
898
- let free = UPLOADS_AT_ONCE;
946
+ let free = uploadsAtOnce;
899
947
  const waiting = [];
900
948
  async function gated(work) {
901
949
  if (free === 0) await new Promise((resolve2) => waiting.push(resolve2));
@@ -975,6 +1023,7 @@ async function startRun(passed, declared) {
975
1023
  }
976
1024
  }
977
1025
  }
1026
+ const started = Date.now();
978
1027
  const byResult = /* @__PURE__ */ new Map();
979
1028
  for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
980
1029
  await Promise.all(
@@ -987,6 +1036,7 @@ async function startRun(passed, declared) {
987
1036
  await Promise.all(work);
988
1037
  })
989
1038
  );
1039
+ uploadMs += Date.now() - started;
990
1040
  }
991
1041
  async function flush() {
992
1042
  if (buffer.length === 0) return;
@@ -1079,6 +1129,13 @@ async function startRun(passed, declared) {
1079
1129
  }
1080
1130
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
1081
1131
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
1132
+ const files = [stats.screenshots, stats.textFiles, stats.otherFiles];
1133
+ const total = (what) => files.reduce((n, counts) => n + counts[what], 0);
1134
+ if (total("uploaded") + total("skipped") + total("failed") > 0) {
1135
+ log(
1136
+ `plune: files \u2014 ${total("uploaded")} uploaded, ${total("skipped")} skipped, ${retried} retried, ${total("failed")} dropped, in ${(uploadMs / 1e3).toFixed(1)} s (${uploadsAtOnce} at once)`
1137
+ );
1138
+ }
1082
1139
  log(`plune: ${parts.join(" \xB7 ")}`);
1083
1140
  if (stats.deferred > 0 && process.env["GITHUB_ACTIONS"] === "true") {
1084
1141
  log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
@@ -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`.
@@ -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`.
@@ -35,7 +35,17 @@ import { createReadStream, openAsBlob } from "fs";
35
35
  import { pipeline } from "stream";
36
36
  import { request as httpRequest } from "http";
37
37
  import { request as httpsRequest } from "https";
38
- var RETRY_DELAYS_MS = [300, 900];
38
+ var BACKOFF_BASE_MS = 300;
39
+ var BACKOFF_CAP_MS = 1e4;
40
+ var DEFAULT_RETRIES = 2;
41
+ var RETRY_AFTER_CAP_MS = 6e4;
42
+ function parseRetryAfter(raw, now = Date.now()) {
43
+ const text = raw?.trim();
44
+ if (text === void 0 || text === "") return null;
45
+ const seconds = /^\d+(\.\d+)?$/.test(text) ? Number(text) : (Date.parse(text) - now) / 1e3;
46
+ if (!Number.isFinite(seconds)) return null;
47
+ return Math.min(Math.max(seconds, 0) * 1e3, RETRY_AFTER_CAP_MS);
48
+ }
39
49
  var IDLE_MS = 3e5;
40
50
  function nodeTransport(url, init, idleMs = IDLE_MS) {
41
51
  return new Promise((resolve2, reject) => {
@@ -66,7 +76,6 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
66
76
  req.end(init.body);
67
77
  });
68
78
  }
69
- var PUT_DELAYS_MS = [500, 1500, 4e3];
70
79
  var withoutLength = (headers) => Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-length"));
71
80
  function nodePut(url, headers, source, idleMs) {
72
81
  return new Promise((resolve2, reject) => {
@@ -111,12 +120,6 @@ async function detailOf(res) {
111
120
  return { detail: "" };
112
121
  }
113
122
  }
114
- function retryAfterMs(res) {
115
- const raw = res.headers.get("retry-after");
116
- if (raw === null) return null;
117
- const seconds = Number(raw.trim());
118
- return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1e3 : null;
119
- }
120
123
  function classify(status) {
121
124
  if (status === 401 || status === 403) return "auth";
122
125
  if (status === 409) return "conflict";
@@ -127,15 +130,34 @@ function classify(status) {
127
130
  function createClient(opts) {
128
131
  const doFetch = opts.fetchImpl ?? nodeTransport;
129
132
  const wait = opts.wait ?? sleep;
133
+ const uploadRetries = opts.uploadRetries === void 0 ? void 0 : Math.max(0, Math.floor(opts.uploadRetries));
134
+ const random = opts.random ?? Math.random;
135
+ const now = opts.now ?? Date.now;
136
+ let pausedUntil = 0;
137
+ const jittered = (ms) => Math.round(ms * (1 + random() * 0.5));
138
+ async function backOff(attempt, said) {
139
+ opts.onRetry?.();
140
+ const ms = jittered(said ?? Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_CAP_MS));
141
+ const until = now() + ms;
142
+ pausedUntil = Math.max(pausedUntil, until);
143
+ await wait(ms);
144
+ return until;
145
+ }
146
+ async function respectPause(mine) {
147
+ const left = pausedUntil - now();
148
+ if (pausedUntil > mine && left > 0) await wait(left + Math.round(random() * 250));
149
+ }
130
150
  const base = opts.apiUrl.replace(/\/+$/, "");
131
151
  const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
132
- async function send(method, path3, payload) {
152
+ async function send(method, path3, payload, retries = DEFAULT_RETRIES) {
133
153
  let last = {
134
154
  kind: "unavailable",
135
155
  status: null,
136
156
  detail: "no attempt was made"
137
157
  };
138
- for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
158
+ let mine = 0;
159
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
160
+ await respectPause(mine);
139
161
  let res;
140
162
  try {
141
163
  res = await doFetch(`${base}${path3}`, {
@@ -148,7 +170,8 @@ function createClient(opts) {
148
170
  });
149
171
  } catch (err) {
150
172
  last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
151
- if (attempt < RETRY_DELAYS_MS.length) await wait(RETRY_DELAYS_MS[attempt]);
173
+ if (attempt >= Math.min(retries, DEFAULT_RETRIES)) break;
174
+ mine = await backOff(attempt, null);
152
175
  continue;
153
176
  }
154
177
  if (res.ok) {
@@ -163,25 +186,28 @@ function createClient(opts) {
163
186
  const kind = classify(res.status);
164
187
  last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
165
188
  if (kind !== "unavailable") break;
166
- if (res.status === 429 && retryAfterMs(res) === null) {
189
+ const said429 = parseRetryAfter(res.headers.get("retry-after"), now());
190
+ if (res.status === 429 && said429 === null) {
167
191
  last.kind = "invalid";
168
192
  last.detail = "quota exceeded (no Retry-After)";
169
193
  if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
170
194
  break;
171
195
  }
172
- if (attempt < RETRY_DELAYS_MS.length) {
173
- await wait(retryAfterMs(res) ?? RETRY_DELAYS_MS[attempt]);
174
- }
196
+ if (attempt >= (said429 === null ? Math.min(retries, DEFAULT_RETRIES) : retries)) break;
197
+ mine = await backOff(attempt, said429);
175
198
  }
176
199
  return { ok: false, ...last };
177
200
  }
178
201
  return {
179
- post: (path3, body) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }),
180
- upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }),
202
+ post: (path3, body, retries) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }, retries),
203
+ upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
181
204
  del: (path3) => send("DELETE", path3),
182
205
  async put(url, headers, source) {
206
+ const putRetries = uploadRetries ?? 3;
183
207
  let last = { ok: false, status: null, code: "network" };
184
- for (let attempt = 0; attempt <= PUT_DELAYS_MS.length; attempt += 1) {
208
+ let mine = 0;
209
+ for (let attempt = 0; attempt <= putRetries; attempt += 1) {
210
+ await respectPause(mine);
185
211
  try {
186
212
  let status;
187
213
  let retryAfter;
@@ -196,15 +222,15 @@ function createClient(opts) {
196
222
  if (status >= 200 && status <= 299) return { ok: true };
197
223
  last = { ok: false, status, code: `bucket_${status}` };
198
224
  if (status < 500 && status !== 429) return last;
199
- const seconds = retryAfter === null ? NaN : Number(retryAfter.trim());
200
- if (attempt < PUT_DELAYS_MS.length) {
201
- await wait(Number.isFinite(seconds) && seconds >= 0 ? Math.min(seconds, 60) * 1e3 : PUT_DELAYS_MS[attempt]);
202
- }
225
+ const said = parseRetryAfter(retryAfter, now());
226
+ if (attempt >= (said === null ? Math.min(putRetries, 3) : putRetries)) return last;
227
+ mine = await backOff(attempt, said);
203
228
  continue;
204
229
  } catch (err) {
205
230
  last = { ok: false, status: null, code: codeOfError(err) };
206
231
  }
207
- if (attempt < PUT_DELAYS_MS.length) await wait(PUT_DELAYS_MS[attempt]);
232
+ if (attempt >= Math.min(putRetries, 3)) return last;
233
+ mine = await backOff(attempt, null);
208
234
  }
209
235
  return last;
210
236
  }
@@ -214,6 +240,8 @@ function createClient(opts) {
214
240
  // src/reporter-core/env.ts
215
241
  import * as path from "path";
216
242
  var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
243
+ var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 4 };
244
+ var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
217
245
  function value(env, name) {
218
246
  const raw = env[name]?.trim();
219
247
  return raw === void 0 || raw === "" ? void 0 : raw;
@@ -228,6 +256,14 @@ function count(env, name) {
228
256
  const n = Number(raw);
229
257
  return Number.isInteger(n) && n > 0 ? n : void 0;
230
258
  }
259
+ function bounded(env, name, range, warnings) {
260
+ const raw = value(env, name);
261
+ if (raw === void 0) return void 0;
262
+ const n = Number(raw);
263
+ if (/^\d+$/.test(raw) && n >= range.min && n <= range.max) return n;
264
+ warnings.push(`${name}=${JSON.stringify(raw.slice(0, 40))} is not a whole number from ${range.min} to ${range.max} \u2014 using ${range.default}.`);
265
+ return void 0;
266
+ }
231
267
  function list(env, name) {
232
268
  const raw = value(env, name);
233
269
  if (raw === void 0) return void 0;
@@ -240,6 +276,9 @@ function readEnv(env = process.env) {
240
276
  const apiUrl = value(env, "PLUNE_API_URL");
241
277
  const fallbackPath = value(env, "PLUNE_FALLBACK");
242
278
  const batchSize = count(env, "PLUNE_BATCH_SIZE");
279
+ const warnings = [];
280
+ const uploadConcurrency = bounded(env, "PLUNE_UPLOAD_CONCURRENCY", UPLOAD_CONCURRENCY, warnings);
281
+ const uploadRetries = bounded(env, "PLUNE_UPLOAD_RETRIES", UPLOAD_RETRIES, warnings);
243
282
  const title = value(env, "PLUNE_RUN_TITLE");
244
283
  const environment = value(env, "PLUNE_ENV");
245
284
  const labels = list(env, "PLUNE_LABELS");
@@ -252,6 +291,8 @@ function readEnv(env = process.env) {
252
291
  ...apiUrl !== void 0 ? { apiUrl } : {},
253
292
  ...fallbackPath !== void 0 ? { fallbackPath } : {},
254
293
  ...batchSize !== void 0 ? { batchSize } : {},
294
+ ...uploadConcurrency !== void 0 ? { uploadConcurrency } : {},
295
+ ...uploadRetries !== void 0 ? { uploadRetries } : {},
255
296
  ...title !== void 0 ? { title } : {},
256
297
  ...environment !== void 0 ? { environment } : {},
257
298
  ...labels !== void 0 ? { labels } : {},
@@ -264,7 +305,8 @@ function readEnv(env = process.env) {
264
305
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
265
306
  // way this process must not be the one to call it finished.
266
307
  keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED"),
267
- ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0)
308
+ ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
309
+ warnings
268
310
  };
269
311
  }
270
312
  function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cwd = process.cwd()) {
@@ -450,7 +492,7 @@ function appendBatch(file, batch) {
450
492
  var RESOLVE_CHUNK = 500;
451
493
  var BATCH_MAX = 500;
452
494
  var DISCOVER_MAX = 500;
453
- var BATCH_DEFAULT = 100;
495
+ var BATCH_DEFAULT = 500;
454
496
  var EXPECTED_MAX = 1e4;
455
497
  var KEY_MAX = 1024;
456
498
  var TITLE_MAX = 300;
@@ -468,7 +510,6 @@ var TYPE_BY_EXTENSION = {
468
510
  ".webp": "image/webp"
469
511
  };
470
512
  var METADATA_TYPE2 = "application/plune.metadata+json";
471
- var UPLOADS_AT_ONCE = 4;
472
513
  var NAME_MAX = 200;
473
514
  function nameOf(file) {
474
515
  const own = file.name.split(/[\\/]/).pop()?.trim() || (file.path === void 0 ? "" : basename2(file.path));
@@ -599,9 +640,15 @@ async function startRun(passed, declared) {
599
640
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
600
641
  const offerDiscovered = cfg.offerDiscovered ?? false;
601
642
  const token = cfg.token ?? loadToken() ?? "";
643
+ const uploadsAtOnce = Math.min(Math.max(Math.floor(cfg.uploadConcurrency ?? UPLOAD_CONCURRENCY.default), UPLOAD_CONCURRENCY.min), UPLOAD_CONCURRENCY.max);
644
+ const uploadRetries = Math.min(Math.max(Math.floor(cfg.uploadRetries ?? UPLOAD_RETRIES.default), UPLOAD_RETRIES.min), UPLOAD_RETRIES.max);
645
+ let retried = 0;
646
+ let uploadMs = 0;
602
647
  const client = createClient({
603
648
  apiUrl: resolveApiUrl(cfg.apiUrl),
604
649
  token,
650
+ uploadRetries,
651
+ onRetry: () => void (retried += 1),
605
652
  ...cfg.fetchImpl !== void 0 ? { fetchImpl: cfg.fetchImpl } : {}
606
653
  });
607
654
  const stats = {
@@ -652,6 +699,7 @@ async function startRun(passed, declared) {
652
699
  if (offline) {
653
700
  log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
654
701
  }
702
+ for (const warning of env.warnings) log(`plune: ${warning}`);
655
703
  if (env.ignored.length > 0) {
656
704
  log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
657
705
  }
@@ -781,7 +829,7 @@ async function startRun(passed, declared) {
781
829
  function ask(job, size) {
782
830
  return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
783
831
  files: [{ name: job.name, contentType: job.type, size }]
784
- });
832
+ }, uploadRetries);
785
833
  }
786
834
  function failed(job, why) {
787
835
  countsOf(job.kind).failed += 1;
@@ -842,11 +890,11 @@ async function startRun(passed, declared) {
842
890
  if (fresh === void 0) return failedToBucket(job, "bad_answer");
843
891
  link = fresh;
844
892
  }
845
- const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {});
893
+ const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {}, uploadRetries);
846
894
  if (!done2.ok) return failedToBucket(job, codeOf(done2));
847
895
  countsOf(job.kind).uploaded += 1;
848
896
  }
849
- let free = UPLOADS_AT_ONCE;
897
+ let free = uploadsAtOnce;
850
898
  const waiting = [];
851
899
  async function gated(work) {
852
900
  if (free === 0) await new Promise((resolve2) => waiting.push(resolve2));
@@ -926,6 +974,7 @@ async function startRun(passed, declared) {
926
974
  }
927
975
  }
928
976
  }
977
+ const started = Date.now();
929
978
  const byResult = /* @__PURE__ */ new Map();
930
979
  for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
931
980
  await Promise.all(
@@ -938,6 +987,7 @@ async function startRun(passed, declared) {
938
987
  await Promise.all(work);
939
988
  })
940
989
  );
990
+ uploadMs += Date.now() - started;
941
991
  }
942
992
  async function flush() {
943
993
  if (buffer.length === 0) return;
@@ -1030,6 +1080,13 @@ async function startRun(passed, declared) {
1030
1080
  }
1031
1081
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
1032
1082
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
1083
+ const files = [stats.screenshots, stats.textFiles, stats.otherFiles];
1084
+ const total = (what) => files.reduce((n, counts) => n + counts[what], 0);
1085
+ if (total("uploaded") + total("skipped") + total("failed") > 0) {
1086
+ log(
1087
+ `plune: files \u2014 ${total("uploaded")} uploaded, ${total("skipped")} skipped, ${retried} retried, ${total("failed")} dropped, in ${(uploadMs / 1e3).toFixed(1)} s (${uploadsAtOnce} at once)`
1088
+ );
1089
+ }
1033
1090
  log(`plune: ${parts.join(" \xB7 ")}`);
1034
1091
  if (stats.deferred > 0 && process.env["GITHUB_ACTIONS"] === "true") {
1035
1092
  log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "AI-powered assertion testing for LLM apps — write assertions, run them against any provider, get a pass/fail report (local, CI, or diff).",
5
5
  "keywords": [
6
6
  "ai",