@plune-ai/cli 0.22.0 → 0.24.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.
@@ -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";
@@ -470,6 +470,27 @@ function webLink(href) {
470
470
  return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
471
471
  }
472
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
+
473
494
  // src/reporter-core/fallback.ts
474
495
  import * as fs from "fs";
475
496
  import * as path2 from "path";
@@ -582,7 +603,7 @@ function tally(counts, noun) {
582
603
  }
583
604
  function boundKey(key) {
584
605
  if (key.value.length <= KEY_MAX) return key;
585
- 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);
586
607
  let head = key.value.slice(0, KEY_MAX - digest.length - 1);
587
608
  if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
588
609
  return { ...key, value: `${head}#${digest}` };
@@ -644,6 +665,9 @@ async function startRun(passed, declared) {
644
665
  const uploadRetries = Math.min(Math.max(Math.floor(cfg.uploadRetries ?? UPLOAD_RETRIES.default), UPLOAD_RETRIES.min), UPLOAD_RETRIES.max);
645
666
  let retried = 0;
646
667
  let uploadMs = 0;
668
+ let requests = 0;
669
+ let existed = 0;
670
+ let batchMissing = false;
647
671
  const client = createClient({
648
672
  apiUrl: resolveApiUrl(cfg.apiUrl),
649
673
  token,
@@ -827,6 +851,7 @@ async function startRun(passed, declared) {
827
851
  else log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
828
852
  }
829
853
  function ask(job, size) {
854
+ requests += 1;
830
855
  return client.post(`/v1/results/${encodeURIComponent(job.id)}/files/uploads`, {
831
856
  files: [{ name: job.name, contentType: job.type, size }]
832
857
  }, uploadRetries);
@@ -868,6 +893,7 @@ async function startRun(passed, declared) {
868
893
  }
869
894
  const refusal = kind === "text" ? textRefusal(bytes) : void 0;
870
895
  if (refusal !== void 0) return skip(kind, job.name, refusal);
896
+ requests += 1;
871
897
  const out = await client.upload(
872
898
  `/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
873
899
  bytes,
@@ -890,6 +916,7 @@ async function startRun(passed, declared) {
890
916
  if (fresh === void 0) return failedToBucket(job, "bad_answer");
891
917
  link = fresh;
892
918
  }
919
+ requests += 1;
893
920
  const done2 = await client.post(`/v1/files/${encodeURIComponent(link.id)}/complete`, {}, uploadRetries);
894
921
  if (!done2.ok) return failedToBucket(job, codeOf(done2));
895
922
  countsOf(job.kind).uploaded += 1;
@@ -950,6 +977,245 @@ async function startRun(passed, declared) {
950
977
  }
951
978
  return () => uploadToBucket(job, size, link);
952
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
+ }
953
1219
  async function upload(items, filesByKey) {
954
1220
  if (filesByKey.size === 0 || !Array.isArray(items)) return;
955
1221
  const jobs = [];
@@ -975,18 +1241,8 @@ async function startRun(passed, declared) {
975
1241
  }
976
1242
  }
977
1243
  const started = Date.now();
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
- );
1244
+ if (batchMissing) await uploadEach(jobs);
1245
+ else await uploadBatched(jobs);
990
1246
  uploadMs += Date.now() - started;
991
1247
  }
992
1248
  async function flush() {
@@ -1082,9 +1338,9 @@ async function startRun(passed, declared) {
1082
1338
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
1083
1339
  const files = [stats.screenshots, stats.textFiles, stats.otherFiles];
1084
1340
  const total = (what) => files.reduce((n, counts) => n + counts[what], 0);
1085
- if (total("uploaded") + total("skipped") + total("failed") > 0) {
1341
+ if (total("uploaded") + total("skipped") + total("failed") + existed > 0) {
1086
1342
  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)`
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)`
1088
1344
  );
1089
1345
  }
1090
1346
  log(`plune: ${parts.join(" \xB7 ")}`);
@@ -1143,9 +1399,9 @@ async function startRun(passed, declared) {
1143
1399
  }
1144
1400
 
1145
1401
  // src/util/hash.ts
1146
- import { createHash as createHash2 } from "crypto";
1402
+ import { createHash as createHash3 } from "crypto";
1147
1403
  function sha256(input) {
1148
- return createHash2("sha256").update(input, "utf8").digest("hex");
1404
+ return createHash3("sha256").update(input, "utf8").digest("hex");
1149
1405
  }
1150
1406
 
1151
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.22.0",
3
+ "version": "0.24.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",