@plune-ai/cli 0.17.0 → 0.18.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.
@@ -49,7 +49,7 @@ module.exports = __toCommonJS(reporter_core_exports);
49
49
 
50
50
  // src/reporter-core/session.ts
51
51
  var import_node_crypto = require("crypto");
52
- var import_node_fs3 = require("fs");
52
+ var import_node_fs4 = require("fs");
53
53
  var import_node_path3 = require("path");
54
54
  var import_node_util2 = require("util");
55
55
 
@@ -80,6 +80,8 @@ function loadToken() {
80
80
  }
81
81
 
82
82
  // src/reporter-core/client.ts
83
+ var import_node_fs2 = require("fs");
84
+ var import_node_stream = require("stream");
83
85
  var import_node_http = require("http");
84
86
  var import_node_https = require("https");
85
87
  var RETRY_DELAYS_MS = [300, 900];
@@ -113,13 +115,49 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
113
115
  req.end(init.body);
114
116
  });
115
117
  }
118
+ var PUT_DELAYS_MS = [500, 1500, 4e3];
119
+ var withoutLength = (headers) => Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-length"));
120
+ function nodePut(url, headers, source, idleMs) {
121
+ return new Promise((resolve2, reject) => {
122
+ const target = new URL(url);
123
+ const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
124
+ const length = "body" in source ? source.body.length : source.size;
125
+ let answered;
126
+ const settle = (err) => answered !== void 0 ? resolve2(answered) : reject(err);
127
+ const req = send(
128
+ target,
129
+ { method: "PUT", headers: { ...withoutLength(headers), "content-length": String(length) } },
130
+ (res) => {
131
+ const retry = res.headers["retry-after"];
132
+ answered = { status: res.statusCode ?? 0, retryAfter: Array.isArray(retry) ? retry[0] ?? null : retry ?? null };
133
+ res.on("error", settle);
134
+ res.on("close", () => {
135
+ resolve2(answered);
136
+ req.destroy();
137
+ });
138
+ res.resume();
139
+ }
140
+ );
141
+ req.setTimeout(idleMs, () => req.destroy(Object.assign(new Error("idle"), { code: "ETIMEDOUT" })));
142
+ req.on("error", settle);
143
+ if ("body" in source) req.end(source.body);
144
+ else (0, import_node_stream.pipeline)((0, import_node_fs2.createReadStream)(source.path), req, (err) => err ? settle(err) : void 0);
145
+ });
146
+ }
147
+ var codeOfError = (err) => {
148
+ const code = err?.code;
149
+ return typeof code === "string" && /^[A-Z0-9_]{2,40}$/.test(code) ? code : "network";
150
+ };
116
151
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
117
152
  async function detailOf(res) {
118
153
  try {
119
154
  const body = await res.json();
120
- return typeof body.error === "string" ? body.error : "";
155
+ return {
156
+ detail: typeof body.error === "string" ? body.error : "",
157
+ ...typeof body.code === "string" && /^[a-z0-9_]{1,60}$/.test(body.code) ? { code: body.code } : {}
158
+ };
121
159
  } catch {
122
- return "";
160
+ return { detail: "" };
123
161
  }
124
162
  }
125
163
  function retryAfterMs(res) {
@@ -170,10 +208,16 @@ function createClient(opts) {
170
208
  return { ok: false, kind: "other", status: res.status, detail: "the answer was not JSON" };
171
209
  }
172
210
  }
173
- const detail = scrub(await detailOf(res));
211
+ const said = await detailOf(res);
174
212
  const kind = classify(res.status);
175
- last = { kind, status: res.status, detail };
213
+ last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
176
214
  if (kind !== "unavailable") break;
215
+ if (res.status === 429 && retryAfterMs(res) === null) {
216
+ last.kind = "invalid";
217
+ last.detail = "quota exceeded (no Retry-After)";
218
+ if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
219
+ break;
220
+ }
177
221
  if (attempt < RETRY_DELAYS_MS.length) {
178
222
  await wait(retryAfterMs(res) ?? RETRY_DELAYS_MS[attempt]);
179
223
  }
@@ -183,7 +227,36 @@ function createClient(opts) {
183
227
  return {
184
228
  post: (path3, body) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }),
185
229
  upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }),
186
- del: (path3) => send("DELETE", path3)
230
+ del: (path3) => send("DELETE", path3),
231
+ async put(url, headers, source) {
232
+ let last = { ok: false, status: null, code: "network" };
233
+ for (let attempt = 0; attempt <= PUT_DELAYS_MS.length; attempt += 1) {
234
+ try {
235
+ let status;
236
+ let retryAfter;
237
+ if (opts.fetchImpl === void 0) ({ status, retryAfter } = await nodePut(url, headers, source, IDLE_MS));
238
+ else {
239
+ const body = "body" in source ? source.body : await (0, import_node_fs2.openAsBlob)(source.path);
240
+ const res = await opts.fetchImpl(url, { method: "PUT", headers: withoutLength(headers), body });
241
+ await res.arrayBuffer().catch(() => void 0);
242
+ status = res.status;
243
+ retryAfter = res.headers.get("retry-after");
244
+ }
245
+ if (status >= 200 && status <= 299) return { ok: true };
246
+ last = { ok: false, status, code: `bucket_${status}` };
247
+ 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
+ }
252
+ continue;
253
+ } catch (err) {
254
+ last = { ok: false, status: null, code: codeOfError(err) };
255
+ }
256
+ if (attempt < PUT_DELAYS_MS.length) await wait(PUT_DELAYS_MS[attempt]);
257
+ }
258
+ return last;
259
+ }
187
260
  };
188
261
  }
189
262
 
@@ -273,7 +346,7 @@ function ciCommit(reported, env = process.env) {
273
346
  }
274
347
 
275
348
  // src/reporter-core/failure-detail.ts
276
- var import_node_fs2 = require("fs");
349
+ var import_node_fs3 = require("fs");
277
350
  var import_node_os2 = require("os");
278
351
  var import_node_path2 = require("path");
279
352
  var import_node_util = require("util");
@@ -296,7 +369,7 @@ function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
296
369
  }
297
370
  function repoRootOf(dir, stopAt) {
298
371
  for (let here = (0, import_node_path2.resolve)(dir); ; here = (0, import_node_path2.dirname)(here)) {
299
- if ((0, import_node_fs2.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
372
+ if ((0, import_node_fs3.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
300
373
  if (here === stopAt || (0, import_node_path2.dirname)(here) === here) return void 0;
301
374
  }
302
375
  }
@@ -304,6 +377,7 @@ function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
304
377
  const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
305
378
  return cut(machineless(text, attempt.repoRoot, home));
306
379
  }
380
+ var printable = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
307
381
  var mediaTypeOf = (contentType) => contentType?.split(";")[0]?.trim().toLowerCase() || void 0;
308
382
  var TEXT_TYPES = /* @__PURE__ */ new Set(["application/json", "text/plain"]);
309
383
  var isTextType = (contentType) => TEXT_TYPES.has(mediaTypeOf(contentType) ?? "");
@@ -442,6 +516,7 @@ var TYPE_BY_EXTENSION = {
442
516
  ".jpeg": "image/jpeg",
443
517
  ".webp": "image/webp"
444
518
  };
519
+ var METADATA_TYPE2 = "application/plune.metadata+json";
445
520
  var UPLOADS_AT_ONCE = 4;
446
521
  var NAME_MAX = 200;
447
522
  function nameOf(file) {
@@ -467,15 +542,26 @@ var SKIPPED = {
467
542
  textSize: "Plune takes a text file of at most 512 KiB",
468
543
  empty: "the file is empty",
469
544
  binary: "the file holds a NUL byte, and Plune takes UTF-8 text without one",
470
- encoding: "Plune takes UTF-8 text only"
545
+ encoding: "Plune takes UTF-8 text only",
546
+ noStorage: "no storage bucket is connected to this project"
471
547
  };
548
+ var BUCKET_FOUND = "plune: storage bucket found for this project \u2014 screenshots, text, trace and video go straight to it over short-lived links.";
472
549
  var KINDS = {
473
550
  screenshot: { label: "screenshot", bytes: SCREENSHOT_BYTES, room: SCREENSHOTS_PER_RESULT, limit: "limit", size: "size" },
474
- text: { label: "text file", bytes: TEXT_BYTES, room: TEXTS_PER_RESULT, limit: "textLimit", size: "textSize" }
551
+ text: { label: "text file", bytes: TEXT_BYTES, room: TEXTS_PER_RESULT, limit: "textLimit", size: "textSize" },
552
+ // Only the label is used: a file of this kind has no home in the database, so no ceiling of its own is known here.
553
+ file: { label: "file", bytes: 0, room: 0, limit: "limit", size: "size" }
475
554
  };
476
555
  function kindOf(type) {
477
556
  if (type.startsWith("image/")) return "screenshot";
478
- return TEXT_TYPES.has(type) ? "text" : void 0;
557
+ return TEXT_TYPES.has(type) ? "text" : "file";
558
+ }
559
+ var codeOf = (out) => out.code ?? (out.status === null ? "network" : `http_${out.status}`);
560
+ function linkOf(item) {
561
+ const link = item;
562
+ if (typeof link?.id !== "string" || typeof link.url !== "string") return void 0;
563
+ const headers = typeof link.headers === "object" && link.headers !== null ? link.headers : {};
564
+ return { id: link.id, url: link.url, headers };
479
565
  }
480
566
  function textTypeOf(contentType, type) {
481
567
  const charset = /;\s*charset\s*=\s*"?([^\s";]+)"?/i.exec(contentType)?.[1];
@@ -578,7 +664,8 @@ async function startRun(passed, declared) {
578
664
  unoffered: 0,
579
665
  deferred: 0,
580
666
  screenshots: { uploaded: 0, skipped: 0, failed: 0 },
581
- textFiles: { uploaded: 0, skipped: 0, failed: 0 }
667
+ textFiles: { uploaded: 0, skipped: 0, failed: 0 },
668
+ otherFiles: { uploaded: 0, skipped: 0, failed: 0 }
582
669
  };
583
670
  const resolved = /* @__PURE__ */ new Map();
584
671
  let buffer = [];
@@ -589,7 +676,13 @@ async function startRun(passed, declared) {
589
676
  let done = false;
590
677
  const shortened = /* @__PURE__ */ new Set();
591
678
  const skipsSaid = /* @__PURE__ */ new Set();
592
- const countsOf = (kind) => kind === "text" ? stats.textFiles : stats.screenshots;
679
+ const countsOf = (kind) => kind === "text" ? stats.textFiles : kind === "file" ? stats.otherFiles : stats.screenshots;
680
+ let bucketSeen = false;
681
+ let noStorage = 0;
682
+ let uploadsMissing = false;
683
+ const failedSaid = /* @__PURE__ */ new Set();
684
+ const codesSaid = /* @__PURE__ */ new Set();
685
+ const rooms = /* @__PURE__ */ new Map();
593
686
  function bound(keys) {
594
687
  return keys.map((key) => {
595
688
  const fit = boundKey(key);
@@ -597,7 +690,7 @@ async function startRun(passed, declared) {
597
690
  shortened.add(key.value);
598
691
  if (shortened.size === 1) {
599
692
  log(
600
- `plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 "${key.value.slice(0, 80)}\u2026". Give the test a shorter name; until then this is its identity.`
693
+ `plune: a test name longer than ${KEY_MAX} characters was shortened to fit its key \u2014 ${JSON.stringify(printable(key.value.slice(0, 80)))}\u2026 Give the test a shorter name; until then this is its identity.`
601
694
  );
602
695
  }
603
696
  }
@@ -727,20 +820,49 @@ async function startRun(passed, declared) {
727
820
  }
728
821
  function skip(kind, name, why) {
729
822
  countsOf(kind).skipped += 1;
730
- if (skipsSaid.has(`${kind}/${why}`)) return;
731
- skipsSaid.add(`${kind}/${why}`);
732
- log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
823
+ if (why === "noStorage") noStorage += 1;
824
+ const said = why === "noStorage" ? why : `${kind}/${why}`;
825
+ if (skipsSaid.has(said)) return;
826
+ skipsSaid.add(said);
827
+ if (why === "noStorage") log(`plune: "${name}" not stored \u2014 ${SKIPPED[why]}.`);
828
+ else log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
733
829
  }
734
- async function uploadOne(job) {
830
+ function ask(job, size) {
831
+ return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
832
+ files: [{ name: job.name, contentType: job.type, size }]
833
+ });
834
+ }
835
+ function failed(job, why) {
836
+ countsOf(job.kind).failed += 1;
837
+ if (!failedSaid.has(job.kind)) {
838
+ failedSaid.add(job.kind);
839
+ log(`plune: could not upload a ${KINDS[job.kind].label} (${why}) \u2014 its result is reported without it.`);
840
+ }
841
+ }
842
+ function failedToBucket(job, code) {
843
+ countsOf(job.kind).failed += 1;
844
+ if (codesSaid.has(code)) return;
845
+ codesSaid.add(code);
846
+ log(`plune: "${job.name}" not uploaded to your bucket (${code}) \u2014 its result is reported without it.`);
847
+ }
848
+ function refusedHere(job, size) {
849
+ if (job.kind === "screenshot") {
850
+ if (!SCREENSHOT_TYPES.has(job.type)) return "type";
851
+ if (size > SCREENSHOT_BYTES) return "size";
852
+ }
853
+ if (job.kind === "text" && size > TEXT_BYTES) return "textSize";
854
+ return void 0;
855
+ }
856
+ async function uploadDirect(job, size) {
735
857
  const { kind } = job;
736
- const { label, bytes: ceiling, size } = KINDS[kind];
858
+ if (kind === "file") return skip(kind, job.name, "noStorage");
859
+ const { bytes: ceiling, size: tooBig } = KINDS[kind];
860
+ if (size > ceiling) return skip(kind, job.name, tooBig);
737
861
  let bytes;
738
862
  if (job.body !== void 0) bytes = job.body;
739
- else if (job.path === void 0) return skip(kind, job.name, "missing");
740
863
  else {
741
864
  try {
742
- if ((await import_node_fs3.promises.stat(job.path)).size > ceiling) return skip(kind, job.name, size);
743
- bytes = await import_node_fs3.promises.readFile(job.path);
865
+ bytes = await import_node_fs4.promises.readFile(job.path);
744
866
  } catch {
745
867
  return skip(kind, job.name, "missing");
746
868
  }
@@ -752,32 +874,96 @@ async function startRun(passed, declared) {
752
874
  bytes,
753
875
  job.type
754
876
  );
755
- const counts = countsOf(kind);
756
- if (out.ok) {
757
- counts.uploaded += 1;
758
- return;
877
+ if (out.ok) countsOf(kind).uploaded += 1;
878
+ else failed(job, out.detail || out.kind);
879
+ }
880
+ async function uploadToBucket(job, size, first) {
881
+ const source = job.body !== void 0 ? { body: job.body } : { path: job.path, size };
882
+ let link = first;
883
+ for (let renewed = false; ; ) {
884
+ const put = await client.put(link.url, link.headers, source);
885
+ if (put.ok) break;
886
+ if (put.status !== 403 || renewed) return failedToBucket(job, put.code);
887
+ renewed = true;
888
+ const again = await ask(job, size);
889
+ if (!again.ok) return failedToBucket(job, codeOf(again));
890
+ const fresh = linkOf(again.body?.files?.[0]);
891
+ if (fresh === void 0) return failedToBucket(job, "bad_answer");
892
+ link = fresh;
893
+ }
894
+ const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {});
895
+ if (!done2.ok) return failedToBucket(job, codeOf(done2));
896
+ countsOf(job.kind).uploaded += 1;
897
+ }
898
+ let free = UPLOADS_AT_ONCE;
899
+ const waiting = [];
900
+ async function gated(work) {
901
+ if (free === 0) await new Promise((resolve2) => waiting.push(resolve2));
902
+ else free -= 1;
903
+ try {
904
+ return await work();
905
+ } finally {
906
+ const next = waiting.shift();
907
+ if (next !== void 0) next();
908
+ else free += 1;
909
+ }
910
+ }
911
+ async function plan(job) {
912
+ const { kind } = job;
913
+ let size;
914
+ if (job.body !== void 0) size = job.body.length;
915
+ else if (job.path === void 0) return void skip(kind, job.name, "missing");
916
+ else {
917
+ try {
918
+ size = (await import_node_fs4.promises.stat(job.path)).size;
919
+ } catch {
920
+ return void skip(kind, job.name, "missing");
921
+ }
759
922
  }
760
- counts.failed += 1;
761
- if (counts.failed === 1) {
762
- log(`plune: could not upload a ${label} (${out.detail || out.kind}) \u2014 its result is reported without it.`);
923
+ if (size === 0) return void skip(kind, job.name, "empty");
924
+ const direct = () => {
925
+ if (kind === "file") return void skip(kind, job.name, "noStorage");
926
+ const roomKey = `${job.id}/${kind}`;
927
+ const left = rooms.get(roomKey) ?? KINDS[kind].room;
928
+ if (left === 0) return void skip(kind, job.name, KINDS[kind].limit);
929
+ rooms.set(roomKey, left - 1);
930
+ return () => uploadDirect(job, size);
931
+ };
932
+ if (uploadsMissing) return direct();
933
+ const out = await gated(() => ask(job, size));
934
+ if (!out.ok) {
935
+ if (out.status === 404 && out.detail === "" && out.code === void 0) {
936
+ uploadsMissing = true;
937
+ return direct();
938
+ }
939
+ if (out.code !== void 0) failedToBucket(job, out.code);
940
+ else failed(job, out.detail || out.kind);
941
+ return void 0;
763
942
  }
943
+ const item = out.body?.files?.[0];
944
+ if (item?.skipped === "no_storage") return void skip(kind, job.name, refusedHere(job, size) ?? "noStorage");
945
+ if (item?.direct === true) return direct();
946
+ const link = linkOf(item);
947
+ if (link === void 0) return void failed(job, "bad_answer");
948
+ if (!bucketSeen) {
949
+ bucketSeen = true;
950
+ log(BUCKET_FOUND);
951
+ }
952
+ return () => uploadToBucket(job, size, link);
764
953
  }
765
954
  async function upload(items, filesByKey) {
766
955
  if (filesByKey.size === 0 || !Array.isArray(items)) return;
767
956
  const jobs = [];
768
957
  for (const item of items) {
769
958
  if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
770
- const room = { screenshot: KINDS.screenshot.room, text: KINDS.text.room };
771
959
  for (const file of filesByKey.get(item.resultKey) ?? []) {
772
960
  const type = mediaTypeOf(file.contentType) ?? (file.path === void 0 ? void 0 : TYPE_BY_EXTENSION[(0, import_node_path3.extname)(file.path).toLowerCase()]);
773
- const kind = type === void 0 ? void 0 : kindOf(type);
774
- if (type === void 0 || kind === void 0 || kind === "screenshot" && file.path === void 0) continue;
961
+ if (type === void 0 || type === METADATA_TYPE2) continue;
962
+ const kind = kindOf(type);
963
+ if (kind !== "text" && file.path === void 0) continue;
775
964
  const name = nameOf(file);
776
965
  if (file.name.startsWith("_")) skip(kind, name, "hidden");
777
- else if (kind === "screenshot" && !SCREENSHOT_TYPES.has(type)) skip(kind, name, "type");
778
- else if (room[kind] === 0) skip(kind, name, KINDS[kind].limit);
779
966
  else {
780
- room[kind] -= 1;
781
967
  jobs.push({
782
968
  id: item.id,
783
969
  kind,
@@ -789,10 +975,16 @@ async function startRun(passed, declared) {
789
975
  }
790
976
  }
791
977
  }
792
- let next = 0;
978
+ const byResult = /* @__PURE__ */ new Map();
979
+ for (const job of jobs) byResult.set(job.id, [...byResult.get(job.id) ?? [], job]);
793
980
  await Promise.all(
794
- Array.from({ length: Math.min(UPLOADS_AT_ONCE, jobs.length) }, async () => {
795
- while (next < jobs.length) await uploadOne(jobs[next++]);
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);
796
988
  })
797
989
  );
798
990
  }
@@ -870,8 +1062,20 @@ async function startRun(passed, declared) {
870
1062
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
871
1063
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
872
1064
  if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
873
- for (const said of [tally(stats.screenshots, "screenshot"), tally(stats.textFiles, "text file")]) {
874
- if (said !== void 0) parts.push(said);
1065
+ const kinds = [stats.screenshots, stats.textFiles, stats.otherFiles];
1066
+ const sum = (what) => kinds.reduce((n, counts) => n + counts[what], 0);
1067
+ if (bucketSeen) {
1068
+ const other = sum("skipped") - noStorage;
1069
+ parts.push(
1070
+ `${sum("uploaded")} file(s) uploaded to your bucket` + (noStorage > 0 ? `, ${noStorage} skipped (no bucket connected)` : "") + (other > 0 ? `, ${other} skipped` : "") + (sum("failed") > 0 ? `, ${sum("failed")} failed to upload` : "")
1071
+ );
1072
+ } else {
1073
+ for (const said of [tally(stats.screenshots, "screenshot"), tally(stats.textFiles, "text file")]) {
1074
+ if (said !== void 0) parts.push(said);
1075
+ }
1076
+ const other = stats.otherFiles;
1077
+ if (other.skipped > 0) parts.push(`${other.skipped} file(s) not stored (no bucket connected)`);
1078
+ if (other.failed > 0) parts.push(`${other.failed} file(s) failed to upload`);
875
1079
  }
876
1080
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
877
1081
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
@@ -287,6 +287,15 @@ interface RunStats extends SubmitCounts {
287
287
  skipped: number;
288
288
  failed: number;
289
289
  };
290
+ /**
291
+ * Every other file (a trace, a video, a page): only a project's bucket takes them (plune-ai/plune#946), so
292
+ * without one they are counted as skipped. With one, the summary adds all three kinds up.
293
+ */
294
+ otherFiles: {
295
+ uploaded: number;
296
+ skipped: number;
297
+ failed: number;
298
+ };
290
299
  }
291
300
  /** Everything the core needs to talk to a deployment. */
292
301
  interface ReporterConfig {
@@ -532,6 +541,26 @@ type ClientOutcome<T> = {
532
541
  kind: ClientFailureKind;
533
542
  status: number | null;
534
543
  detail: string;
544
+ code?: string;
545
+ };
546
+ /** What a PUT to a bucket carries: a file on disk, streamed at a length known beforehand, or bytes in memory. */
547
+ type PutSource = {
548
+ path: string;
549
+ size: number;
550
+ } | {
551
+ body: Buffer;
552
+ };
553
+ /**
554
+ * How a PUT ended. Never a URL, never a message: the link is a write key to someone's bucket, and the
555
+ * errors of the transports (`getaddrinfo ENOTFOUND <host>`, `no answer from <origin>`) name the host. All
556
+ * that survives is a status and a short code.
557
+ */
558
+ type PutOutcome = {
559
+ ok: true;
560
+ } | {
561
+ ok: false;
562
+ status: number | null;
563
+ code: string;
535
564
  };
536
565
  interface ClientOptions {
537
566
  apiUrl: string;
@@ -554,6 +583,12 @@ interface PlatformClient {
554
583
  * a proxy error page that echoes the request would put a token in a CI log.
555
584
  */
556
585
  del<T>(path: string): Promise<ClientOutcome<T>>;
586
+ /**
587
+ * The bytes to a short-lived link of the customer's bucket (plune-ai/plune#946): exactly the headers the
588
+ * link came with, no token of ours. Network failures and 5xx wait and try again, up to three times; a 403
589
+ * (the link has expired) comes back at once, for the caller to ask for a new one.
590
+ */
591
+ put(url: string, headers: Record<string, string>, source: PutSource): Promise<PutOutcome>;
557
592
  }
558
593
  declare function createClient(opts: ClientOptions): PlatformClient;
559
594
 
@@ -287,6 +287,15 @@ interface RunStats extends SubmitCounts {
287
287
  skipped: number;
288
288
  failed: number;
289
289
  };
290
+ /**
291
+ * Every other file (a trace, a video, a page): only a project's bucket takes them (plune-ai/plune#946), so
292
+ * without one they are counted as skipped. With one, the summary adds all three kinds up.
293
+ */
294
+ otherFiles: {
295
+ uploaded: number;
296
+ skipped: number;
297
+ failed: number;
298
+ };
290
299
  }
291
300
  /** Everything the core needs to talk to a deployment. */
292
301
  interface ReporterConfig {
@@ -532,6 +541,26 @@ type ClientOutcome<T> = {
532
541
  kind: ClientFailureKind;
533
542
  status: number | null;
534
543
  detail: string;
544
+ code?: string;
545
+ };
546
+ /** What a PUT to a bucket carries: a file on disk, streamed at a length known beforehand, or bytes in memory. */
547
+ type PutSource = {
548
+ path: string;
549
+ size: number;
550
+ } | {
551
+ body: Buffer;
552
+ };
553
+ /**
554
+ * How a PUT ended. Never a URL, never a message: the link is a write key to someone's bucket, and the
555
+ * errors of the transports (`getaddrinfo ENOTFOUND <host>`, `no answer from <origin>`) name the host. All
556
+ * that survives is a status and a short code.
557
+ */
558
+ type PutOutcome = {
559
+ ok: true;
560
+ } | {
561
+ ok: false;
562
+ status: number | null;
563
+ code: string;
535
564
  };
536
565
  interface ClientOptions {
537
566
  apiUrl: string;
@@ -554,6 +583,12 @@ interface PlatformClient {
554
583
  * a proxy error page that echoes the request would put a token in a CI log.
555
584
  */
556
585
  del<T>(path: string): Promise<ClientOutcome<T>>;
586
+ /**
587
+ * The bytes to a short-lived link of the customer's bucket (plune-ai/plune#946): exactly the headers the
588
+ * link came with, no token of ours. Network failures and 5xx wait and try again, up to three times; a 403
589
+ * (the link has expired) comes back at once, for the caller to ask for a new one.
590
+ */
591
+ put(url: string, headers: Record<string, string>, source: PutSource): Promise<PutOutcome>;
557
592
  }
558
593
  declare function createClient(opts: ClientOptions): PlatformClient;
559
594