@plune-ai/cli 0.24.0 → 0.26.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.
@@ -33,8 +33,8 @@ function loadToken() {
33
33
  // src/reporter-core/client.ts
34
34
  import { createReadStream, openAsBlob } from "fs";
35
35
  import { pipeline } from "stream";
36
- import { request as httpRequest } from "http";
37
- import { request as httpsRequest } from "https";
36
+ import { Agent as HttpAgent, request as httpRequest } from "http";
37
+ import { Agent as HttpsAgent, request as httpsRequest } from "https";
38
38
  var BACKOFF_BASE_MS = 300;
39
39
  var BACKOFF_CAP_MS = 1e4;
40
40
  var DEFAULT_RETRIES = 2;
@@ -46,12 +46,14 @@ function parseRetryAfter(raw, now = Date.now()) {
46
46
  if (!Number.isFinite(seconds)) return null;
47
47
  return Math.min(Math.max(seconds, 0) * 1e3, RETRY_AFTER_CAP_MS);
48
48
  }
49
+ var POOLS = { "http:": new HttpAgent({ keepAlive: true }), "https:": new HttpsAgent({ keepAlive: true }) };
49
50
  var IDLE_MS = 3e5;
50
51
  function nodeTransport(url, init, idleMs = IDLE_MS) {
51
52
  return new Promise((resolve2, reject) => {
52
53
  const target = new URL(url);
53
54
  const send = target.protocol === "https:" ? httpsRequest : httpRequest;
54
- const req = send(target, { method: init.method, headers: init.headers }, (res) => {
55
+ const agent = POOLS[target.protocol === "https:" ? "https:" : "http:"];
56
+ const req = send(target, { method: init.method, headers: init.headers, agent }, (res) => {
55
57
  const chunks = [];
56
58
  res.on("data", (chunk2) => chunks.push(chunk2));
57
59
  res.on("error", reject);
@@ -112,14 +114,25 @@ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
112
114
  async function detailOf(res) {
113
115
  try {
114
116
  const body = await res.json();
117
+ const q = body.quota;
115
118
  return {
119
+ ...typeof body.shardsExpected === "number" && Number.isInteger(body.shardsExpected) ? { shardsExpected: body.shardsExpected } : {},
116
120
  detail: typeof body.error === "string" ? body.error : "",
121
+ ...typeof q === "object" && q !== null && !Array.isArray(q) ? { quota: q } : {},
117
122
  ...typeof body.code === "string" && /^[a-z0-9_]{1,60}$/.test(body.code) ? { code: body.code } : {}
118
123
  };
119
124
  } catch {
120
125
  return { detail: "" };
121
126
  }
122
127
  }
128
+ function describeQuota(q) {
129
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
130
+ const kind = typeof q["kind"] === "string" ? q["kind"].slice(0, 60) : "limit";
131
+ const [used, limit] = [num(q["used"]), num(q["limit"])];
132
+ const parts = [used !== void 0 && limit !== void 0 ? `${kind}: ${used} of ${limit} used` : kind];
133
+ if (typeof q["resetsAt"] === "string" && q["resetsAt"] !== "") parts.push(`resets ${q["resetsAt"].slice(0, 40)}`);
134
+ return parts.join(", ");
135
+ }
123
136
  function classify(status) {
124
137
  if (status === 401 || status === 403) return "auth";
125
138
  if (status === 409) return "conflict";
@@ -184,12 +197,12 @@ function createClient(opts) {
184
197
  }
185
198
  const said = await detailOf(res);
186
199
  const kind = classify(res.status);
187
- last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
200
+ last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {}, ...said.shardsExpected !== void 0 ? { shardsExpected: said.shardsExpected } : {} };
188
201
  if (kind !== "unavailable") break;
189
202
  const said429 = parseRetryAfter(res.headers.get("retry-after"), now());
190
- if (res.status === 429 && said429 === null) {
203
+ if (res.status === 429 && (said.quota !== void 0 || said429 === null)) {
191
204
  last.kind = "invalid";
192
- last.detail = "quota exceeded (no Retry-After)";
205
+ last.detail = `quota exceeded (${said.quota === void 0 ? "no Retry-After" : scrub(describeQuota(said.quota))})`;
193
206
  if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
194
207
  break;
195
208
  }
@@ -202,6 +215,7 @@ function createClient(opts) {
202
215
  post: (path3, body, retries) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }, retries),
203
216
  upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
204
217
  del: (path3) => send("DELETE", path3),
218
+ get: (path3) => send("GET", path3),
205
219
  async put(url, headers, source) {
206
220
  const putRetries = uploadRetries ?? 3;
207
221
  let last = { ok: false, status: null, code: "network" };
@@ -240,7 +254,18 @@ function createClient(opts) {
240
254
  // src/reporter-core/env.ts
241
255
  import * as path from "path";
242
256
  var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
243
- var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 4 };
257
+ var SHARDS_MAX = 1e3;
258
+ function shardOf(env) {
259
+ const raw = value(env, "PLUNE_SHARD");
260
+ if (raw === void 0) return {};
261
+ const m = /^(\d{1,4})\/(\d{1,4})$/.exec(raw);
262
+ const [k, n] = m ? [Number(m[1]), Number(m[2])] : [0, 0];
263
+ if (k >= 1 && k <= n && n <= SHARDS_MAX) return { shard: { index: k, total: n } };
264
+ return {
265
+ error: `PLUNE_SHARD=${JSON.stringify(raw.slice(0, 40))} is not "k/N" with 1 <= k <= N <= ${SHARDS_MAX} (for example 2/4) - nothing was sent.`
266
+ };
267
+ }
268
+ var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 16 };
244
269
  var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
245
270
  function value(env, name) {
246
271
  const raw = env[name]?.trim();
@@ -284,6 +309,7 @@ function readEnv(env = process.env) {
284
309
  const labels = list(env, "PLUNE_LABELS");
285
310
  const offerDiscovered = flag(env, "PLUNE_CREATE");
286
311
  const full = flag(env, "PLUNE_FULL_RUN");
312
+ const { shard, error: shardError } = shardOf(env);
287
313
  return {
288
314
  config: {
289
315
  ...externalKey !== void 0 ? { externalKey } : {},
@@ -304,7 +330,11 @@ function readEnv(env = process.env) {
304
330
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
305
331
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
306
332
  // way this process must not be the one to call it finished.
307
- keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED"),
333
+ // `PLUNE_SHARD` implies it: the run is joined by the shared key and this process does not close it
334
+ // by itself; `leaveOpen` reports the shard instead.
335
+ keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
336
+ ...shard !== void 0 ? { shard } : {},
337
+ ...shardError !== void 0 ? { shardError } : {},
308
338
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
309
339
  warnings
310
340
  };
@@ -514,6 +544,7 @@ var RESOLVE_CHUNK = 500;
514
544
  var BATCH_MAX = 500;
515
545
  var DISCOVER_MAX = 500;
516
546
  var BATCH_DEFAULT = 500;
547
+ var SEND_CONCURRENCY = 4;
517
548
  var EXPECTED_MAX = 1e4;
518
549
  var KEY_MAX = 1024;
519
550
  var TITLE_MAX = 300;
@@ -651,6 +682,7 @@ function warnIfDropped(sent, run, log) {
651
682
  }
652
683
  async function startRun(passed, declared) {
653
684
  const env = readEnv();
685
+ if (env.shardError !== void 0) throw new Error(env.shardError);
654
686
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
655
687
  const cfg = { ...env.config, ...stated };
656
688
  const log = cfg.log ?? ((line) => void process.stderr.write(`${line}
@@ -692,6 +724,9 @@ async function startRun(passed, declared) {
692
724
  const resolved = /* @__PURE__ */ new Map();
693
725
  let buffer = [];
694
726
  const discoveries = /* @__PURE__ */ new Map();
727
+ const held = /* @__PURE__ */ new Map();
728
+ let createdWithResult = 0;
729
+ let createdUndelivered = 0;
695
730
  let runId = null;
696
731
  let joined = false;
697
732
  let offline = token === "";
@@ -820,6 +855,7 @@ async function startRun(passed, declared) {
820
855
  specRef: result.specRef,
821
856
  rawStatus: result.rawStatus
822
857
  });
858
+ held.set(result.keys[0].value, result);
823
859
  }
824
860
  async function offer() {
825
861
  if (discoveries.size === 0 || offline || runId === null) return;
@@ -838,7 +874,24 @@ async function startRun(passed, declared) {
838
874
  return;
839
875
  }
840
876
  stats.offered += out.body.results.filter((r) => r?.outcome === "queued").length;
841
- stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
877
+ const made = out.body.results.filter((r) => r?.outcome === "created");
878
+ stats.created += made.length;
879
+ const submissions = [];
880
+ const filesByKey = /* @__PURE__ */ new Map();
881
+ for (const r of made) {
882
+ const pending = typeof r.id === "string" && r.id !== "" ? held.get(r.key?.value) : void 0;
883
+ if (pending === void 0) continue;
884
+ held.delete(r.key.value);
885
+ const { keys: _keys, files, ...rest } = pending;
886
+ submissions.push({ ...rest, testCaseId: r.id });
887
+ if (files !== void 0) filesByKey.set(pending.resultKey, files);
888
+ }
889
+ if (submissions.length > 0) {
890
+ const before = stats.deferred;
891
+ await send(submissions, filesByKey);
892
+ createdWithResult += submissions.length;
893
+ createdUndelivered += stats.deferred - before;
894
+ }
842
895
  }
843
896
  }
844
897
  function skip(kind, name, why) {
@@ -1245,7 +1298,29 @@ async function startRun(passed, declared) {
1245
1298
  else await uploadBatched(jobs);
1246
1299
  uploadMs += Date.now() - started;
1247
1300
  }
1301
+ const sending = /* @__PURE__ */ new Set();
1302
+ let sendError;
1303
+ let answered = false;
1304
+ async function dispatch(work) {
1305
+ while (sending.size >= (answered ? SEND_CONCURRENCY : 1)) await Promise.race(sending);
1306
+ const task = work().catch((err) => {
1307
+ sendError ??= err;
1308
+ }).finally(() => sending.delete(task));
1309
+ sending.add(task);
1310
+ }
1311
+ async function settle() {
1312
+ while (sending.size > 0) await Promise.all([...sending]);
1313
+ if (sendError !== void 0) {
1314
+ const err = sendError;
1315
+ sendError = void 0;
1316
+ throw err;
1317
+ }
1318
+ }
1248
1319
  async function flush() {
1320
+ await flushBuffer();
1321
+ await settle();
1322
+ }
1323
+ async function flushBuffer() {
1249
1324
  if (buffer.length === 0) return;
1250
1325
  const pending = buffer;
1251
1326
  buffer = [];
@@ -1253,6 +1328,9 @@ async function startRun(passed, declared) {
1253
1328
  defer(pending);
1254
1329
  return;
1255
1330
  }
1331
+ await dispatch(() => deliver(pending));
1332
+ }
1333
+ async function deliver(pending) {
1256
1334
  await resolveKeys(pending.flatMap((p) => p.keys));
1257
1335
  const submissions = [];
1258
1336
  const unanswered = [];
@@ -1275,6 +1353,9 @@ async function startRun(passed, declared) {
1275
1353
  progress();
1276
1354
  return;
1277
1355
  }
1356
+ await send(submissions, filesByKey);
1357
+ }
1358
+ async function send(submissions, filesByKey) {
1278
1359
  for (const part of packed(submissions)) {
1279
1360
  if (offline) {
1280
1361
  defer(part);
@@ -1290,6 +1371,7 @@ async function startRun(passed, declared) {
1290
1371
  for (const k of told) stats[k] += counts[k];
1291
1372
  progress();
1292
1373
  await upload(out.ok ? out.body?.items : void 0, filesByKey);
1374
+ answered = true;
1293
1375
  continue;
1294
1376
  }
1295
1377
  if (out.ok) {
@@ -1318,7 +1400,10 @@ async function startRun(passed, declared) {
1318
1400
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
1319
1401
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
1320
1402
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
1321
- if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
1403
+ if (stats.created > 0) {
1404
+ const recorded = createdWithResult === stats.created && createdUndelivered === 0;
1405
+ parts.push(`${stats.created} added as cases (trusted source)${recorded ? " and their results recorded" : ""}`);
1406
+ }
1322
1407
  const kinds = [stats.screenshots, stats.textFiles, stats.otherFiles];
1323
1408
  const sum = (what) => kinds.reduce((n, counts) => n + counts[what], 0);
1324
1409
  if (bucketSeen) {
@@ -1348,6 +1433,7 @@ async function startRun(passed, declared) {
1348
1433
  log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
1349
1434
  }
1350
1435
  }
1436
+ const shardLines = [];
1351
1437
  return {
1352
1438
  get runId() {
1353
1439
  return runId;
@@ -1360,7 +1446,7 @@ async function startRun(passed, declared) {
1360
1446
  },
1361
1447
  async add(result) {
1362
1448
  buffer.push({ ...result, keys: bound(result.keys) });
1363
- if (buffer.length >= batchSize) await flush();
1449
+ if (buffer.length >= batchSize) await flushBuffer();
1364
1450
  },
1365
1451
  flush,
1366
1452
  async finish(reason) {
@@ -1385,15 +1471,43 @@ async function startRun(passed, declared) {
1385
1471
  summarise();
1386
1472
  return frozen;
1387
1473
  },
1388
- async leaveOpen() {
1389
- if (done) return;
1474
+ shardLines,
1475
+ async leaveOpen(opts) {
1476
+ if (done) return void 0;
1390
1477
  done = true;
1391
1478
  await flush();
1392
1479
  await offer();
1393
- if (runId !== null) {
1480
+ let frozen;
1481
+ const after = [];
1482
+ const shard = env.shard;
1483
+ let counted = false;
1484
+ let mismatch;
1485
+ if (runId !== null && shard !== void 0 && !offline) {
1486
+ const out = await client.post(`/v1/runs/${runId}/events`, { event: "finish", shardIndex: shard.index, shardsExpected: shard.total });
1487
+ if (out.ok && typeof out.body?.shardsReported === "number") {
1488
+ counted = true;
1489
+ frozen = out.body.summary ?? void 0;
1490
+ const n = out.body.shardsExpected ?? shard.total;
1491
+ if (out.body.run?.statusEvent === "finished") after.push("plune: run closed");
1492
+ after.push(`plune: shard ${shard.index} of ${shard.total} done \u2014 ${out.body.shardsReported} of ${n} shards reported`);
1493
+ } else if (out.ok || out.kind === "invalid") {
1494
+ log('plune: the platform does not support shard auto-close yet \u2014 the run is left open for "plune run finish".');
1495
+ } else if (out.code === "shards_mismatch") {
1496
+ mismatch = new Error(
1497
+ `PLUNE_SHARD=${shard.index}/${shard.total} says ${shard.total} shards, but run ${runId} already expects ${out.shardsExpected ?? "a different number of"} \u2014 every shard of one run must use the same N. The results are uploaded; the run is left open.`
1498
+ );
1499
+ } else {
1500
+ log(`plune: could not report shard ${shard.index} of ${shard.total} to run ${runId} (${out.detail || out.kind}) \u2014 the results are uploaded; close the run with "plune run finish ${runId}".`);
1501
+ }
1502
+ }
1503
+ if (runId !== null && !counted) {
1394
1504
  log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
1395
1505
  }
1396
1506
  summarise();
1507
+ shardLines.push(...after);
1508
+ if (opts?.holdShardLines !== true) for (const line of after) log(line);
1509
+ if (mismatch !== void 0) throw mismatch;
1510
+ return frozen;
1397
1511
  }
1398
1512
  };
1399
1513
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.24.0",
3
+ "version": "0.26.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",