@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.
@@ -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`.
@@ -1,5 +1,5 @@
1
1
  // src/reporter-core/session.ts
2
- import { createHash, randomUUID } from "crypto";
2
+ import { createHash as createHash2, randomUUID } from "crypto";
3
3
  import { promises as disk } from "fs";
4
4
  import { basename as basename2, extname } from "path";
5
5
  import { TextDecoder } from "util";
@@ -35,7 +35,17 @@ import { createReadStream, openAsBlob } from "fs";
35
35
  import { pipeline } from "stream";
36
36
  import { request as httpRequest } from "http";
37
37
  import { request as httpsRequest } from "https";
38
- var 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()) {
@@ -428,6 +470,27 @@ function webLink(href) {
428
470
  return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
429
471
  }
430
472
 
473
+ // src/reporter-core/files-batch.ts
474
+ import { createHash } from "crypto";
475
+ import { createReadStream as createReadStream2 } from "fs";
476
+ var FILES_BATCH_MAX = 100;
477
+ var DIRECT_BATCH_BYTES = 6 * 1024 * 1024;
478
+ var BATCH_PIPELINES = 2;
479
+ async function sha256File(path3) {
480
+ const hash = createHash("sha256");
481
+ for await (const part of createReadStream2(path3)) hash.update(part);
482
+ return hash.digest("hex");
483
+ }
484
+ var sha256Bytes = (bytes) => createHash("sha256").update(bytes).digest("hex");
485
+ var BUCKET_CODES = /* @__PURE__ */ new Set([
486
+ "bucket_unreachable",
487
+ "not_connected",
488
+ "platform_not_configured",
489
+ "upload_expired",
490
+ "file_mismatch",
491
+ "file_missing"
492
+ ]);
493
+
431
494
  // src/reporter-core/fallback.ts
432
495
  import * as fs from "fs";
433
496
  import * as path2 from "path";
@@ -450,7 +513,7 @@ function appendBatch(file, batch) {
450
513
  var RESOLVE_CHUNK = 500;
451
514
  var BATCH_MAX = 500;
452
515
  var DISCOVER_MAX = 500;
453
- var BATCH_DEFAULT = 100;
516
+ var BATCH_DEFAULT = 500;
454
517
  var EXPECTED_MAX = 1e4;
455
518
  var KEY_MAX = 1024;
456
519
  var TITLE_MAX = 300;
@@ -468,7 +531,6 @@ var TYPE_BY_EXTENSION = {
468
531
  ".webp": "image/webp"
469
532
  };
470
533
  var METADATA_TYPE2 = "application/plune.metadata+json";
471
- var UPLOADS_AT_ONCE = 4;
472
534
  var NAME_MAX = 200;
473
535
  function nameOf(file) {
474
536
  const own = file.name.split(/[\\/]/).pop()?.trim() || (file.path === void 0 ? "" : basename2(file.path));
@@ -541,7 +603,7 @@ function tally(counts, noun) {
541
603
  }
542
604
  function boundKey(key) {
543
605
  if (key.value.length <= KEY_MAX) return key;
544
- const digest = createHash("sha256").update(key.value).digest("hex").slice(0, 16);
606
+ const digest = createHash2("sha256").update(key.value).digest("hex").slice(0, 16);
545
607
  let head = key.value.slice(0, KEY_MAX - digest.length - 1);
546
608
  if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
547
609
  return { ...key, value: `${head}#${digest}` };
@@ -599,9 +661,18 @@ async function startRun(passed, declared) {
599
661
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
600
662
  const offerDiscovered = cfg.offerDiscovered ?? false;
601
663
  const token = cfg.token ?? loadToken() ?? "";
664
+ const uploadsAtOnce = Math.min(Math.max(Math.floor(cfg.uploadConcurrency ?? UPLOAD_CONCURRENCY.default), UPLOAD_CONCURRENCY.min), UPLOAD_CONCURRENCY.max);
665
+ const uploadRetries = Math.min(Math.max(Math.floor(cfg.uploadRetries ?? UPLOAD_RETRIES.default), UPLOAD_RETRIES.min), UPLOAD_RETRIES.max);
666
+ let retried = 0;
667
+ let uploadMs = 0;
668
+ let requests = 0;
669
+ let existed = 0;
670
+ let batchMissing = false;
602
671
  const client = createClient({
603
672
  apiUrl: resolveApiUrl(cfg.apiUrl),
604
673
  token,
674
+ uploadRetries,
675
+ onRetry: () => void (retried += 1),
605
676
  ...cfg.fetchImpl !== void 0 ? { fetchImpl: cfg.fetchImpl } : {}
606
677
  });
607
678
  const stats = {
@@ -652,6 +723,7 @@ async function startRun(passed, declared) {
652
723
  if (offline) {
653
724
  log('plune: no API token \u2014 run "plune login" first. Results will be written to the fallback file.');
654
725
  }
726
+ for (const warning of env.warnings) log(`plune: ${warning}`);
655
727
  if (env.ignored.length > 0) {
656
728
  log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
657
729
  }
@@ -779,9 +851,10 @@ async function startRun(passed, declared) {
779
851
  else log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
780
852
  }
781
853
  function ask(job, size) {
854
+ requests += 1;
782
855
  return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
783
856
  files: [{ name: job.name, contentType: job.type, size }]
784
- });
857
+ }, uploadRetries);
785
858
  }
786
859
  function failed(job, why) {
787
860
  countsOf(job.kind).failed += 1;
@@ -820,6 +893,7 @@ async function startRun(passed, declared) {
820
893
  }
821
894
  const refusal = kind === "text" ? textRefusal(bytes) : void 0;
822
895
  if (refusal !== void 0) return skip(kind, job.name, refusal);
896
+ requests += 1;
823
897
  const out = await client.upload(
824
898
  `/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
825
899
  bytes,
@@ -842,11 +916,12 @@ async function startRun(passed, declared) {
842
916
  if (fresh === void 0) return failedToBucket(job, "bad_answer");
843
917
  link = fresh;
844
918
  }
845
- const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {});
919
+ requests += 1;
920
+ const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {}, uploadRetries);
846
921
  if (!done2.ok) return failedToBucket(job, codeOf(done2));
847
922
  countsOf(job.kind).uploaded += 1;
848
923
  }
849
- let free = UPLOADS_AT_ONCE;
924
+ let free = uploadsAtOnce;
850
925
  const waiting = [];
851
926
  async function gated(work) {
852
927
  if (free === 0) await new Promise((resolve2) => waiting.push(resolve2));
@@ -902,6 +977,245 @@ async function startRun(passed, declared) {
902
977
  }
903
978
  return () => uploadToBucket(job, size, link);
904
979
  }
980
+ async function uploadEach(jobs) {
981
+ const byResult = /* @__PURE__ */ new Map();
982
+ for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
983
+ await Promise.all(
984
+ [...byResult.values()].map(async (group) => {
985
+ const work = [];
986
+ for (const job of group) {
987
+ const act = await plan(job);
988
+ if (act !== void 0) work.push(gated(act));
989
+ }
990
+ await Promise.all(work);
991
+ })
992
+ );
993
+ }
994
+ async function measure(job) {
995
+ let size;
996
+ if (job.body !== void 0) size = job.body.length;
997
+ else if (job.path === void 0) return void skip(job.kind, job.name, "missing");
998
+ else {
999
+ try {
1000
+ size = (await disk.stat(job.path)).size;
1001
+ } catch {
1002
+ return void skip(job.kind, job.name, "missing");
1003
+ }
1004
+ }
1005
+ if (size === 0) return void skip(job.kind, job.name, "empty");
1006
+ return size;
1007
+ }
1008
+ const routeMissing = (out) => !out.ok && out.status === 404 && out.detail === "" && out.code === void 0;
1009
+ function takeRoom(job) {
1010
+ if (job.kind === "file") {
1011
+ skip(job.kind, job.name, "noStorage");
1012
+ return false;
1013
+ }
1014
+ const roomKey = `${job.id}/${job.kind}`;
1015
+ const left = rooms.get(roomKey) ?? KINDS[job.kind].room;
1016
+ if (left === 0) {
1017
+ skip(job.kind, job.name, KINDS[job.kind].limit);
1018
+ return false;
1019
+ }
1020
+ rooms.set(roomKey, left - 1);
1021
+ return true;
1022
+ }
1023
+ function refusedItem(job, size, item) {
1024
+ const code = item.code ?? "refused";
1025
+ if (code === "duplicate") existed += 1;
1026
+ else if (code === "no_storage") skip(job.kind, job.name, refusedHere(job, size) ?? "noStorage");
1027
+ else if (code === "too_many_files") skip(job.kind, job.name, KINDS[job.kind].limit);
1028
+ else if (BUCKET_CODES.has(code)) failedToBucket(job, code);
1029
+ else failed(job, code);
1030
+ }
1031
+ const itemPause = (round) => new Promise((r) => setTimeout(r, Math.min(250 * 2 ** round, 4e3)));
1032
+ async function batchOf(jobs) {
1033
+ if (batchMissing) return uploadEach(jobs);
1034
+ const sized = [];
1035
+ for (const job of jobs) {
1036
+ const size = await measure(job);
1037
+ if (size === void 0) continue;
1038
+ try {
1039
+ sized.push({ job, size, sha256: job.body !== void 0 ? sha256Bytes(job.body) : await sha256File(job.path) });
1040
+ } catch {
1041
+ skip(job.kind, job.name, "missing");
1042
+ }
1043
+ }
1044
+ if (sized.length === 0) return;
1045
+ const announced = await gated(() => {
1046
+ requests += 1;
1047
+ return client.post(
1048
+ "/v1/files/uploads-batch",
1049
+ { items: sized.map((s) => ({ resultId: s.job.id, name: s.job.name, contentType: s.job.type, size: s.size, sha256: s.sha256 })) },
1050
+ uploadRetries
1051
+ );
1052
+ });
1053
+ if (routeMissing(announced)) {
1054
+ batchMissing = true;
1055
+ return uploadEach(sized.map((s) => s.job));
1056
+ }
1057
+ const verdicts = announced.ok ? announced.body?.items : void 0;
1058
+ if (!announced.ok || !Array.isArray(verdicts) || verdicts.length !== sized.length) {
1059
+ for (const s of sized) {
1060
+ if (announced.ok) failed(s.job, "bad_answer");
1061
+ else if (announced.code !== void 0) failedToBucket(s.job, announced.code);
1062
+ else failed(s.job, announced.detail || announced.kind);
1063
+ }
1064
+ return;
1065
+ }
1066
+ const puts = [];
1067
+ const directs = [];
1068
+ sized.forEach((s, i) => {
1069
+ const v = verdicts[i] ?? {};
1070
+ if (v.status === "exists") existed += 1;
1071
+ else if (v.status === "direct") {
1072
+ if (takeRoom(s.job)) directs.push(s);
1073
+ } else if (v.status === "upload") {
1074
+ const link = linkOf(v);
1075
+ if (link === void 0) return failed(s.job, "bad_answer");
1076
+ if (!bucketSeen) {
1077
+ bucketSeen = true;
1078
+ log(BUCKET_FOUND);
1079
+ }
1080
+ puts.push({ s, link });
1081
+ } else if (v.status === "refused") refusedItem(s.job, s.size, v);
1082
+ else failed(s.job, "bad_answer");
1083
+ });
1084
+ const toComplete = [];
1085
+ await Promise.all(
1086
+ puts.map(
1087
+ ({ s, link: first }) => gated(async () => {
1088
+ const source = s.job.body !== void 0 ? { body: s.job.body } : { path: s.job.path, size: s.size };
1089
+ let link = first;
1090
+ for (let renewed = false; ; ) {
1091
+ const put = await client.put(link.url, link.headers, source);
1092
+ if (put.ok) break;
1093
+ if (put.status !== 403 || renewed) return failedToBucket(s.job, put.code);
1094
+ renewed = true;
1095
+ requests += 1;
1096
+ const again = await client.post(
1097
+ "/v1/files/uploads-batch",
1098
+ { items: [{ resultId: s.job.id, name: s.job.name, contentType: s.job.type, size: s.size, sha256: s.sha256 }] },
1099
+ uploadRetries
1100
+ );
1101
+ const fresh = again.ok ? linkOf(again.body?.items?.[0]) : void 0;
1102
+ if (fresh === void 0) return failedToBucket(s.job, again.ok ? "bad_answer" : codeOf(again));
1103
+ link = fresh;
1104
+ }
1105
+ toComplete.push({ job: s.job, id: link.id });
1106
+ })
1107
+ )
1108
+ );
1109
+ await complete(toComplete);
1110
+ await sendDirect(directs);
1111
+ }
1112
+ async function complete(items) {
1113
+ for (let round = 0; items.length > 0; round += 1) {
1114
+ const out = await gated(() => {
1115
+ requests += 1;
1116
+ return client.post("/v1/files/complete-batch", { ids: items.map((i) => i.id) }, uploadRetries);
1117
+ });
1118
+ if (routeMissing(out)) {
1119
+ await Promise.all(
1120
+ items.map(
1121
+ (i) => gated(async () => {
1122
+ requests += 1;
1123
+ const one = await client.post(`/v1/files/${encodeURIComponent(i.id)}/complete`, {}, uploadRetries);
1124
+ if (one.ok) countsOf(i.job.kind).uploaded += 1;
1125
+ else failedToBucket(i.job, codeOf(one));
1126
+ })
1127
+ )
1128
+ );
1129
+ return;
1130
+ }
1131
+ const verdicts = out.ok ? out.body?.items : void 0;
1132
+ if (!out.ok || !Array.isArray(verdicts) || verdicts.length !== items.length) {
1133
+ for (const i of items) failedToBucket(i.job, out.ok ? "bad_answer" : codeOf(out));
1134
+ return;
1135
+ }
1136
+ const again = [];
1137
+ items.forEach((i, at) => {
1138
+ const v = verdicts[at] ?? {};
1139
+ if (v.status === "ready") countsOf(i.job.kind).uploaded += 1;
1140
+ else if (v.retryable === true && round < Math.min(uploadRetries, 3)) again.push(i);
1141
+ else refusedItem(i.job, 0, v.status === "refused" ? v : { code: "bad_answer" });
1142
+ });
1143
+ items = again;
1144
+ if (items.length > 0) {
1145
+ retried += items.length;
1146
+ await itemPause(round);
1147
+ }
1148
+ }
1149
+ }
1150
+ async function sendDirect(items) {
1151
+ const groups = [];
1152
+ let bytes = 0;
1153
+ for (const item of items) {
1154
+ const group = groups[groups.length - 1];
1155
+ if (group === void 0 || group.length >= FILES_BATCH_MAX || bytes + item.size > DIRECT_BATCH_BYTES) {
1156
+ groups.push([item]);
1157
+ bytes = item.size;
1158
+ } else {
1159
+ group.push(item);
1160
+ bytes += item.size;
1161
+ }
1162
+ }
1163
+ for (const group of groups) {
1164
+ const body = [];
1165
+ const sent = [];
1166
+ for (const { job, size } of group) {
1167
+ const { kind } = job;
1168
+ if (size > KINDS[kind].bytes) {
1169
+ skip(kind, job.name, KINDS[kind].size);
1170
+ continue;
1171
+ }
1172
+ let data;
1173
+ try {
1174
+ data = job.body ?? await disk.readFile(job.path);
1175
+ } catch {
1176
+ skip(kind, job.name, "missing");
1177
+ continue;
1178
+ }
1179
+ const refusal = kind === "text" ? textRefusal(data) : void 0;
1180
+ if (refusal !== void 0) {
1181
+ skip(kind, job.name, refusal);
1182
+ continue;
1183
+ }
1184
+ body.push({ resultId: job.id, name: job.name, contentType: job.type, data: data.toString("base64") });
1185
+ sent.push({ job, size });
1186
+ }
1187
+ if (body.length === 0) continue;
1188
+ const out = await gated(() => {
1189
+ requests += 1;
1190
+ return client.post("/v1/files/direct-batch", { items: body }, uploadRetries);
1191
+ });
1192
+ if (routeMissing(out)) {
1193
+ batchMissing = true;
1194
+ for (const { job, size } of sent) await gated(() => uploadDirect(job, size));
1195
+ continue;
1196
+ }
1197
+ const verdicts = out.ok ? out.body?.items : void 0;
1198
+ if (!out.ok || !Array.isArray(verdicts) || verdicts.length !== sent.length) {
1199
+ for (const { job } of sent) failed(job, out.ok ? "bad_answer" : out.detail || out.kind);
1200
+ continue;
1201
+ }
1202
+ sent.forEach(({ job }, i) => {
1203
+ const v = verdicts[i] ?? {};
1204
+ if (v.status === "stored") countsOf(job.kind).uploaded += 1;
1205
+ else if (v.status === "exists") existed += 1;
1206
+ else refusedItem(job, 0, v.status === "refused" ? v : { code: "bad_answer" });
1207
+ });
1208
+ }
1209
+ }
1210
+ async function uploadBatched(jobs) {
1211
+ const batches = chunk(jobs, FILES_BATCH_MAX);
1212
+ let next = 0;
1213
+ await Promise.all(
1214
+ Array.from({ length: Math.min(BATCH_PIPELINES, batches.length) }, async () => {
1215
+ for (let at = next++; at < batches.length; at = next++) await batchOf(batches[at]);
1216
+ })
1217
+ );
1218
+ }
905
1219
  async function upload(items, filesByKey) {
906
1220
  if (filesByKey.size === 0 || !Array.isArray(items)) return;
907
1221
  const jobs = [];
@@ -926,18 +1240,10 @@ async function startRun(passed, declared) {
926
1240
  }
927
1241
  }
928
1242
  }
929
- const byResult = /* @__PURE__ */ new Map();
930
- for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
931
- await Promise.all(
932
- [...byResult.values()].map(async (group) => {
933
- const work = [];
934
- for (const job of group) {
935
- const act = await plan(job);
936
- if (act !== void 0) work.push(gated(act));
937
- }
938
- await Promise.all(work);
939
- })
940
- );
1243
+ const started = Date.now();
1244
+ if (batchMissing) await uploadEach(jobs);
1245
+ else await uploadBatched(jobs);
1246
+ uploadMs += Date.now() - started;
941
1247
  }
942
1248
  async function flush() {
943
1249
  if (buffer.length === 0) return;
@@ -1030,6 +1336,13 @@ async function startRun(passed, declared) {
1030
1336
  }
1031
1337
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
1032
1338
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
1339
+ const files = [stats.screenshots, stats.textFiles, stats.otherFiles];
1340
+ const total = (what) => files.reduce((n, counts) => n + counts[what], 0);
1341
+ if (total("uploaded") + total("skipped") + total("failed") + existed > 0) {
1342
+ log(
1343
+ `plune: files \u2014 ${total("uploaded")} uploaded, ${total("skipped")} skipped, ${existed} already there (skipped), ${retried} retried, ${total("failed")} dropped, in ${(uploadMs / 1e3).toFixed(1)} s (${uploadsAtOnce} at once, ${requests} request(s) to Plune)`
1344
+ );
1345
+ }
1033
1346
  log(`plune: ${parts.join(" \xB7 ")}`);
1034
1347
  if (stats.deferred > 0 && process.env["GITHUB_ACTIONS"] === "true") {
1035
1348
  log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
@@ -1086,9 +1399,9 @@ async function startRun(passed, declared) {
1086
1399
  }
1087
1400
 
1088
1401
  // src/util/hash.ts
1089
- import { createHash as createHash2 } from "crypto";
1402
+ import { createHash as createHash3 } from "crypto";
1090
1403
  function sha256(input) {
1091
- return createHash2("sha256").update(input, "utf8").digest("hex");
1404
+ return createHash3("sha256").update(input, "utf8").digest("hex");
1092
1405
  }
1093
1406
 
1094
1407
  // src/reporter-core/result-key.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.21.0",
3
+ "version": "0.23.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",