@plune-ai/cli 0.24.0 → 0.25.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.
@@ -161,14 +161,25 @@ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
161
161
  async function detailOf(res) {
162
162
  try {
163
163
  const body = await res.json();
164
+ const q = body.quota;
164
165
  return {
166
+ ...typeof body.shardsExpected === "number" && Number.isInteger(body.shardsExpected) ? { shardsExpected: body.shardsExpected } : {},
165
167
  detail: typeof body.error === "string" ? body.error : "",
168
+ ...typeof q === "object" && q !== null && !Array.isArray(q) ? { quota: q } : {},
166
169
  ...typeof body.code === "string" && /^[a-z0-9_]{1,60}$/.test(body.code) ? { code: body.code } : {}
167
170
  };
168
171
  } catch {
169
172
  return { detail: "" };
170
173
  }
171
174
  }
175
+ function describeQuota(q) {
176
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
177
+ const kind = typeof q["kind"] === "string" ? q["kind"].slice(0, 60) : "limit";
178
+ const [used, limit] = [num(q["used"]), num(q["limit"])];
179
+ const parts = [used !== void 0 && limit !== void 0 ? `${kind}: ${used} of ${limit} used` : kind];
180
+ if (typeof q["resetsAt"] === "string" && q["resetsAt"] !== "") parts.push(`resets ${q["resetsAt"].slice(0, 40)}`);
181
+ return parts.join(", ");
182
+ }
172
183
  function classify(status) {
173
184
  if (status === 401 || status === 403) return "auth";
174
185
  if (status === 409) return "conflict";
@@ -233,12 +244,12 @@ function createClient(opts) {
233
244
  }
234
245
  const said = await detailOf(res);
235
246
  const kind = classify(res.status);
236
- last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
247
+ last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {}, ...said.shardsExpected !== void 0 ? { shardsExpected: said.shardsExpected } : {} };
237
248
  if (kind !== "unavailable") break;
238
249
  const said429 = parseRetryAfter(res.headers.get("retry-after"), now());
239
- if (res.status === 429 && said429 === null) {
250
+ if (res.status === 429 && (said.quota !== void 0 || said429 === null)) {
240
251
  last.kind = "invalid";
241
- last.detail = "quota exceeded (no Retry-After)";
252
+ last.detail = `quota exceeded (${said.quota === void 0 ? "no Retry-After" : scrub(describeQuota(said.quota))})`;
242
253
  if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
243
254
  break;
244
255
  }
@@ -251,6 +262,7 @@ function createClient(opts) {
251
262
  post: (path3, body, retries) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }, retries),
252
263
  upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
253
264
  del: (path3) => send("DELETE", path3),
265
+ get: (path3) => send("GET", path3),
254
266
  async put(url, headers, source) {
255
267
  const putRetries = uploadRetries ?? 3;
256
268
  let last = { ok: false, status: null, code: "network" };
@@ -289,7 +301,18 @@ function createClient(opts) {
289
301
  // src/reporter-core/env.ts
290
302
  var path = __toESM(require("path"), 1);
291
303
  var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
292
- var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 4 };
304
+ var SHARDS_MAX = 1e3;
305
+ function shardOf(env) {
306
+ const raw = value(env, "PLUNE_SHARD");
307
+ if (raw === void 0) return {};
308
+ const m = /^(\d{1,4})\/(\d{1,4})$/.exec(raw);
309
+ const [k, n] = m ? [Number(m[1]), Number(m[2])] : [0, 0];
310
+ if (k >= 1 && k <= n && n <= SHARDS_MAX) return { shard: { index: k, total: n } };
311
+ return {
312
+ 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.`
313
+ };
314
+ }
315
+ var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 16 };
293
316
  var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
294
317
  function value(env, name) {
295
318
  const raw = env[name]?.trim();
@@ -333,6 +356,7 @@ function readEnv(env = process.env) {
333
356
  const labels = list(env, "PLUNE_LABELS");
334
357
  const offerDiscovered = flag(env, "PLUNE_CREATE");
335
358
  const full = flag(env, "PLUNE_FULL_RUN");
359
+ const { shard, error: shardError } = shardOf(env);
336
360
  return {
337
361
  config: {
338
362
  ...externalKey !== void 0 ? { externalKey } : {},
@@ -353,7 +377,11 @@ function readEnv(env = process.env) {
353
377
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
354
378
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
355
379
  // way this process must not be the one to call it finished.
356
- keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED"),
380
+ // `PLUNE_SHARD` implies it: the run is joined by the shared key and this process does not close it
381
+ // by itself; `leaveOpen` reports the shard instead.
382
+ keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
383
+ ...shard !== void 0 ? { shard } : {},
384
+ ...shardError !== void 0 ? { shardError } : {},
357
385
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
358
386
  warnings
359
387
  };
@@ -700,6 +728,7 @@ function warnIfDropped(sent, run, log) {
700
728
  }
701
729
  async function startRun(passed, declared) {
702
730
  const env = readEnv();
731
+ if (env.shardError !== void 0) throw new Error(env.shardError);
703
732
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
704
733
  const cfg = { ...env.config, ...stated };
705
734
  const log = cfg.log ?? ((line) => void process.stderr.write(`${line}
@@ -741,6 +770,9 @@ async function startRun(passed, declared) {
741
770
  const resolved = /* @__PURE__ */ new Map();
742
771
  let buffer = [];
743
772
  const discoveries = /* @__PURE__ */ new Map();
773
+ const held = /* @__PURE__ */ new Map();
774
+ let createdWithResult = 0;
775
+ let createdUndelivered = 0;
744
776
  let runId = null;
745
777
  let joined = false;
746
778
  let offline = token === "";
@@ -869,6 +901,7 @@ async function startRun(passed, declared) {
869
901
  specRef: result.specRef,
870
902
  rawStatus: result.rawStatus
871
903
  });
904
+ held.set(result.keys[0].value, result);
872
905
  }
873
906
  async function offer() {
874
907
  if (discoveries.size === 0 || offline || runId === null) return;
@@ -887,7 +920,24 @@ async function startRun(passed, declared) {
887
920
  return;
888
921
  }
889
922
  stats.offered += out.body.results.filter((r) => r?.outcome === "queued").length;
890
- stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
923
+ const made = out.body.results.filter((r) => r?.outcome === "created");
924
+ stats.created += made.length;
925
+ const submissions = [];
926
+ const filesByKey = /* @__PURE__ */ new Map();
927
+ for (const r of made) {
928
+ const pending = typeof r.id === "string" && r.id !== "" ? held.get(r.key?.value) : void 0;
929
+ if (pending === void 0) continue;
930
+ held.delete(r.key.value);
931
+ const { keys: _keys, files, ...rest } = pending;
932
+ submissions.push({ ...rest, testCaseId: r.id });
933
+ if (files !== void 0) filesByKey.set(pending.resultKey, files);
934
+ }
935
+ if (submissions.length > 0) {
936
+ const before = stats.deferred;
937
+ await send(submissions, filesByKey);
938
+ createdWithResult += submissions.length;
939
+ createdUndelivered += stats.deferred - before;
940
+ }
891
941
  }
892
942
  }
893
943
  function skip(kind, name, why) {
@@ -1324,6 +1374,9 @@ async function startRun(passed, declared) {
1324
1374
  progress();
1325
1375
  return;
1326
1376
  }
1377
+ await send(submissions, filesByKey);
1378
+ }
1379
+ async function send(submissions, filesByKey) {
1327
1380
  for (const part of packed(submissions)) {
1328
1381
  if (offline) {
1329
1382
  defer(part);
@@ -1367,7 +1420,10 @@ async function startRun(passed, declared) {
1367
1420
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
1368
1421
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
1369
1422
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
1370
- if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
1423
+ if (stats.created > 0) {
1424
+ const recorded = createdWithResult === stats.created && createdUndelivered === 0;
1425
+ parts.push(`${stats.created} added as cases (trusted source)${recorded ? " and their results recorded" : ""}`);
1426
+ }
1371
1427
  const kinds = [stats.screenshots, stats.textFiles, stats.otherFiles];
1372
1428
  const sum = (what) => kinds.reduce((n, counts) => n + counts[what], 0);
1373
1429
  if (bucketSeen) {
@@ -1397,6 +1453,7 @@ async function startRun(passed, declared) {
1397
1453
  log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
1398
1454
  }
1399
1455
  }
1456
+ const shardLines = [];
1400
1457
  return {
1401
1458
  get runId() {
1402
1459
  return runId;
@@ -1434,15 +1491,43 @@ async function startRun(passed, declared) {
1434
1491
  summarise();
1435
1492
  return frozen;
1436
1493
  },
1437
- async leaveOpen() {
1438
- if (done) return;
1494
+ shardLines,
1495
+ async leaveOpen(opts) {
1496
+ if (done) return void 0;
1439
1497
  done = true;
1440
1498
  await flush();
1441
1499
  await offer();
1442
- if (runId !== null) {
1500
+ let frozen;
1501
+ const after = [];
1502
+ const shard = env.shard;
1503
+ let counted = false;
1504
+ let mismatch;
1505
+ if (runId !== null && shard !== void 0 && !offline) {
1506
+ const out = await client.post(`/v1/runs/${runId}/events`, { event: "finish", shardIndex: shard.index, shardsExpected: shard.total });
1507
+ if (out.ok && typeof out.body?.shardsReported === "number") {
1508
+ counted = true;
1509
+ frozen = out.body.summary ?? void 0;
1510
+ const n = out.body.shardsExpected ?? shard.total;
1511
+ if (out.body.run?.statusEvent === "finished") after.push("plune: run closed");
1512
+ after.push(`plune: shard ${shard.index} of ${shard.total} done \u2014 ${out.body.shardsReported} of ${n} shards reported`);
1513
+ } else if (out.ok || out.kind === "invalid") {
1514
+ log('plune: the platform does not support shard auto-close yet \u2014 the run is left open for "plune run finish".');
1515
+ } else if (out.code === "shards_mismatch") {
1516
+ mismatch = new Error(
1517
+ `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.`
1518
+ );
1519
+ } else {
1520
+ 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}".`);
1521
+ }
1522
+ }
1523
+ if (runId !== null && !counted) {
1443
1524
  log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
1444
1525
  }
1445
1526
  summarise();
1527
+ shardLines.push(...after);
1528
+ if (opts?.holdShardLines !== true) for (const line of after) log(line);
1529
+ if (mismatch !== void 0) throw mismatch;
1530
+ return frozen;
1446
1531
  }
1447
1532
  };
1448
1533
  }
@@ -326,7 +326,7 @@ 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). */
329
+ /** Files in flight at once (`PLUNE_UPLOAD_CONCURRENCY`, 1 to 32, default 16). */
330
330
  uploadConcurrency?: number;
331
331
  /** Retries of a request the platform or a bucket asked to wait for (`PLUNE_UPLOAD_RETRIES`, 0 to 20, default 6). */
332
332
  uploadRetries?: number;
@@ -377,8 +377,15 @@ interface RunSession {
377
377
  * `undefined` when it answered with none: no run, a refused finish, a deployment older than the summary.
378
378
  */
379
379
  finish(reason?: string): Promise<FrozenSummary | undefined>;
380
- /** Send what is left and deliberately leave the run open — the shard case (AC-03). */
381
- leaveOpen(): Promise<void>;
380
+ /**
381
+ * Send what is left and deliberately leave the run open - the shard case (AC-03). With `PLUNE_SHARD=k/N`
382
+ * it reports "shard k of N is done" instead (cli#110) and resolves to the summary when that was the Nth.
383
+ */
384
+ leaveOpen(opts?: {
385
+ holdShardLines?: boolean;
386
+ }): Promise<FrozenSummary | undefined>;
387
+ /** The closing lines of a `PLUNE_SHARD` report (`run closed`, `shard k of N done`). Printed by `leaveOpen` unless it was told to hold them for a caller that has more to say first. */
388
+ readonly shardLines: readonly string[];
382
389
  }
383
390
  declare function startRun(passed: ReporterConfig, declared?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
384
391
 
@@ -424,11 +431,20 @@ declare function resultKey(testId: string, retry: number): string;
424
431
  * put them. `PLUNE_GROUP` stays: a run group is D6's, and a group of one run means nothing.
425
432
  */
426
433
  declare const UNSUPPORTED_VARS: readonly ["PLUNE_GROUP"];
434
+ interface ShardOf {
435
+ /** 1-based. */
436
+ index: number;
437
+ total: number;
438
+ }
427
439
  interface EnvSettings {
428
440
  /** Config fields the environment supplied. Anything passed explicitly outranks these. */
429
441
  config: Partial<ReporterConfig>;
430
442
  /** This process must not close the run — several share it, or the job closes it itself. */
431
443
  keepOpen: boolean;
444
+ /** `PLUNE_SHARD=k/N`: this process is shard k of N, and tells the platform when it is done (cli#110). */
445
+ shard?: ShardOf;
446
+ /** `PLUNE_SHARD` was set to something that is not `k/N`. A configuration error: the caller refuses to start. */
447
+ shardError?: string;
432
448
  /** Variables that were set and have nowhere to land. The caller says so once. */
433
449
  ignored: string[];
434
450
  /** Variables that were set to something unusable and fell back to their default. The caller says so once. */
@@ -562,6 +578,7 @@ type ClientOutcome<T> = {
562
578
  status: number | null;
563
579
  detail: string;
564
580
  code?: string;
581
+ shardsExpected?: number;
565
582
  };
566
583
  /** What a PUT to a bucket carries: a file on disk, streamed at a length known beforehand, or bytes in memory. */
567
584
  type PutSource = {
@@ -612,6 +629,8 @@ interface PlatformClient {
612
629
  * a proxy error page that echoes the request would put a token in a CI log.
613
630
  */
614
631
  del<T>(path: string): Promise<ClientOutcome<T>>;
632
+ /** A GET, through the same retries, classification and token scrubbing. */
633
+ get<T>(path: string): Promise<ClientOutcome<T>>;
615
634
  /**
616
635
  * The bytes to a short-lived link of the customer's bucket (plune-ai/plune#946): exactly the headers the
617
636
  * link came with, no token of ours. Network failures and 5xx wait and try again, up to three times; a 403
@@ -326,7 +326,7 @@ 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). */
329
+ /** Files in flight at once (`PLUNE_UPLOAD_CONCURRENCY`, 1 to 32, default 16). */
330
330
  uploadConcurrency?: number;
331
331
  /** Retries of a request the platform or a bucket asked to wait for (`PLUNE_UPLOAD_RETRIES`, 0 to 20, default 6). */
332
332
  uploadRetries?: number;
@@ -377,8 +377,15 @@ interface RunSession {
377
377
  * `undefined` when it answered with none: no run, a refused finish, a deployment older than the summary.
378
378
  */
379
379
  finish(reason?: string): Promise<FrozenSummary | undefined>;
380
- /** Send what is left and deliberately leave the run open — the shard case (AC-03). */
381
- leaveOpen(): Promise<void>;
380
+ /**
381
+ * Send what is left and deliberately leave the run open - the shard case (AC-03). With `PLUNE_SHARD=k/N`
382
+ * it reports "shard k of N is done" instead (cli#110) and resolves to the summary when that was the Nth.
383
+ */
384
+ leaveOpen(opts?: {
385
+ holdShardLines?: boolean;
386
+ }): Promise<FrozenSummary | undefined>;
387
+ /** The closing lines of a `PLUNE_SHARD` report (`run closed`, `shard k of N done`). Printed by `leaveOpen` unless it was told to hold them for a caller that has more to say first. */
388
+ readonly shardLines: readonly string[];
382
389
  }
383
390
  declare function startRun(passed: ReporterConfig, declared?: readonly (readonly KeyRef[])[]): Promise<RunSession>;
384
391
 
@@ -424,11 +431,20 @@ declare function resultKey(testId: string, retry: number): string;
424
431
  * put them. `PLUNE_GROUP` stays: a run group is D6's, and a group of one run means nothing.
425
432
  */
426
433
  declare const UNSUPPORTED_VARS: readonly ["PLUNE_GROUP"];
434
+ interface ShardOf {
435
+ /** 1-based. */
436
+ index: number;
437
+ total: number;
438
+ }
427
439
  interface EnvSettings {
428
440
  /** Config fields the environment supplied. Anything passed explicitly outranks these. */
429
441
  config: Partial<ReporterConfig>;
430
442
  /** This process must not close the run — several share it, or the job closes it itself. */
431
443
  keepOpen: boolean;
444
+ /** `PLUNE_SHARD=k/N`: this process is shard k of N, and tells the platform when it is done (cli#110). */
445
+ shard?: ShardOf;
446
+ /** `PLUNE_SHARD` was set to something that is not `k/N`. A configuration error: the caller refuses to start. */
447
+ shardError?: string;
432
448
  /** Variables that were set and have nowhere to land. The caller says so once. */
433
449
  ignored: string[];
434
450
  /** Variables that were set to something unusable and fell back to their default. The caller says so once. */
@@ -562,6 +578,7 @@ type ClientOutcome<T> = {
562
578
  status: number | null;
563
579
  detail: string;
564
580
  code?: string;
581
+ shardsExpected?: number;
565
582
  };
566
583
  /** What a PUT to a bucket carries: a file on disk, streamed at a length known beforehand, or bytes in memory. */
567
584
  type PutSource = {
@@ -612,6 +629,8 @@ interface PlatformClient {
612
629
  * a proxy error page that echoes the request would put a token in a CI log.
613
630
  */
614
631
  del<T>(path: string): Promise<ClientOutcome<T>>;
632
+ /** A GET, through the same retries, classification and token scrubbing. */
633
+ get<T>(path: string): Promise<ClientOutcome<T>>;
615
634
  /**
616
635
  * The bytes to a short-lived link of the customer's bucket (plune-ai/plune#946): exactly the headers the
617
636
  * link came with, no token of ours. Network failures and 5xx wait and try again, up to three times; a 403
@@ -112,14 +112,25 @@ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
112
112
  async function detailOf(res) {
113
113
  try {
114
114
  const body = await res.json();
115
+ const q = body.quota;
115
116
  return {
117
+ ...typeof body.shardsExpected === "number" && Number.isInteger(body.shardsExpected) ? { shardsExpected: body.shardsExpected } : {},
116
118
  detail: typeof body.error === "string" ? body.error : "",
119
+ ...typeof q === "object" && q !== null && !Array.isArray(q) ? { quota: q } : {},
117
120
  ...typeof body.code === "string" && /^[a-z0-9_]{1,60}$/.test(body.code) ? { code: body.code } : {}
118
121
  };
119
122
  } catch {
120
123
  return { detail: "" };
121
124
  }
122
125
  }
126
+ function describeQuota(q) {
127
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
128
+ const kind = typeof q["kind"] === "string" ? q["kind"].slice(0, 60) : "limit";
129
+ const [used, limit] = [num(q["used"]), num(q["limit"])];
130
+ const parts = [used !== void 0 && limit !== void 0 ? `${kind}: ${used} of ${limit} used` : kind];
131
+ if (typeof q["resetsAt"] === "string" && q["resetsAt"] !== "") parts.push(`resets ${q["resetsAt"].slice(0, 40)}`);
132
+ return parts.join(", ");
133
+ }
123
134
  function classify(status) {
124
135
  if (status === 401 || status === 403) return "auth";
125
136
  if (status === 409) return "conflict";
@@ -184,12 +195,12 @@ function createClient(opts) {
184
195
  }
185
196
  const said = await detailOf(res);
186
197
  const kind = classify(res.status);
187
- last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {} };
198
+ last = { kind, status: res.status, detail: scrub(said.detail), ...said.code !== void 0 ? { code: said.code } : {}, ...said.shardsExpected !== void 0 ? { shardsExpected: said.shardsExpected } : {} };
188
199
  if (kind !== "unavailable") break;
189
200
  const said429 = parseRetryAfter(res.headers.get("retry-after"), now());
190
- if (res.status === 429 && said429 === null) {
201
+ if (res.status === 429 && (said.quota !== void 0 || said429 === null)) {
191
202
  last.kind = "invalid";
192
- last.detail = "quota exceeded (no Retry-After)";
203
+ last.detail = `quota exceeded (${said.quota === void 0 ? "no Retry-After" : scrub(describeQuota(said.quota))})`;
193
204
  if (said.detail) last.detail += ` \u2014 ${scrub(said.detail)}`;
194
205
  break;
195
206
  }
@@ -202,6 +213,7 @@ function createClient(opts) {
202
213
  post: (path3, body, retries) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }, retries),
203
214
  upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
204
215
  del: (path3) => send("DELETE", path3),
216
+ get: (path3) => send("GET", path3),
205
217
  async put(url, headers, source) {
206
218
  const putRetries = uploadRetries ?? 3;
207
219
  let last = { ok: false, status: null, code: "network" };
@@ -240,7 +252,18 @@ function createClient(opts) {
240
252
  // src/reporter-core/env.ts
241
253
  import * as path from "path";
242
254
  var UNSUPPORTED_VARS = ["PLUNE_GROUP"];
243
- var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 4 };
255
+ var SHARDS_MAX = 1e3;
256
+ function shardOf(env) {
257
+ const raw = value(env, "PLUNE_SHARD");
258
+ if (raw === void 0) return {};
259
+ const m = /^(\d{1,4})\/(\d{1,4})$/.exec(raw);
260
+ const [k, n] = m ? [Number(m[1]), Number(m[2])] : [0, 0];
261
+ if (k >= 1 && k <= n && n <= SHARDS_MAX) return { shard: { index: k, total: n } };
262
+ return {
263
+ 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.`
264
+ };
265
+ }
266
+ var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 16 };
244
267
  var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
245
268
  function value(env, name) {
246
269
  const raw = env[name]?.trim();
@@ -284,6 +307,7 @@ function readEnv(env = process.env) {
284
307
  const labels = list(env, "PLUNE_LABELS");
285
308
  const offerDiscovered = flag(env, "PLUNE_CREATE");
286
309
  const full = flag(env, "PLUNE_FULL_RUN");
310
+ const { shard, error: shardError } = shardOf(env);
287
311
  return {
288
312
  config: {
289
313
  ...externalKey !== void 0 ? { externalKey } : {},
@@ -304,7 +328,11 @@ function readEnv(env = process.env) {
304
328
  // Two names for two situations that end the same way. `SHARED_RUN` says other processes are
305
329
  // reporting into this run; `PROCEED` says the job will close it on its own schedule. Either
306
330
  // way this process must not be the one to call it finished.
307
- keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED"),
331
+ // `PLUNE_SHARD` implies it: the run is joined by the shared key and this process does not close it
332
+ // by itself; `leaveOpen` reports the shard instead.
333
+ keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
334
+ ...shard !== void 0 ? { shard } : {},
335
+ ...shardError !== void 0 ? { shardError } : {},
308
336
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
309
337
  warnings
310
338
  };
@@ -651,6 +679,7 @@ function warnIfDropped(sent, run, log) {
651
679
  }
652
680
  async function startRun(passed, declared) {
653
681
  const env = readEnv();
682
+ if (env.shardError !== void 0) throw new Error(env.shardError);
654
683
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
655
684
  const cfg = { ...env.config, ...stated };
656
685
  const log = cfg.log ?? ((line) => void process.stderr.write(`${line}
@@ -692,6 +721,9 @@ async function startRun(passed, declared) {
692
721
  const resolved = /* @__PURE__ */ new Map();
693
722
  let buffer = [];
694
723
  const discoveries = /* @__PURE__ */ new Map();
724
+ const held = /* @__PURE__ */ new Map();
725
+ let createdWithResult = 0;
726
+ let createdUndelivered = 0;
695
727
  let runId = null;
696
728
  let joined = false;
697
729
  let offline = token === "";
@@ -820,6 +852,7 @@ async function startRun(passed, declared) {
820
852
  specRef: result.specRef,
821
853
  rawStatus: result.rawStatus
822
854
  });
855
+ held.set(result.keys[0].value, result);
823
856
  }
824
857
  async function offer() {
825
858
  if (discoveries.size === 0 || offline || runId === null) return;
@@ -838,7 +871,24 @@ async function startRun(passed, declared) {
838
871
  return;
839
872
  }
840
873
  stats.offered += out.body.results.filter((r) => r?.outcome === "queued").length;
841
- stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
874
+ const made = out.body.results.filter((r) => r?.outcome === "created");
875
+ stats.created += made.length;
876
+ const submissions = [];
877
+ const filesByKey = /* @__PURE__ */ new Map();
878
+ for (const r of made) {
879
+ const pending = typeof r.id === "string" && r.id !== "" ? held.get(r.key?.value) : void 0;
880
+ if (pending === void 0) continue;
881
+ held.delete(r.key.value);
882
+ const { keys: _keys, files, ...rest } = pending;
883
+ submissions.push({ ...rest, testCaseId: r.id });
884
+ if (files !== void 0) filesByKey.set(pending.resultKey, files);
885
+ }
886
+ if (submissions.length > 0) {
887
+ const before = stats.deferred;
888
+ await send(submissions, filesByKey);
889
+ createdWithResult += submissions.length;
890
+ createdUndelivered += stats.deferred - before;
891
+ }
842
892
  }
843
893
  }
844
894
  function skip(kind, name, why) {
@@ -1275,6 +1325,9 @@ async function startRun(passed, declared) {
1275
1325
  progress();
1276
1326
  return;
1277
1327
  }
1328
+ await send(submissions, filesByKey);
1329
+ }
1330
+ async function send(submissions, filesByKey) {
1278
1331
  for (const part of packed(submissions)) {
1279
1332
  if (offline) {
1280
1333
  defer(part);
@@ -1318,7 +1371,10 @@ async function startRun(passed, declared) {
1318
1371
  if (stats.rejected > 0) parts.push(`${stats.rejected} rejected`);
1319
1372
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
1320
1373
  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)`);
1374
+ if (stats.created > 0) {
1375
+ const recorded = createdWithResult === stats.created && createdUndelivered === 0;
1376
+ parts.push(`${stats.created} added as cases (trusted source)${recorded ? " and their results recorded" : ""}`);
1377
+ }
1322
1378
  const kinds = [stats.screenshots, stats.textFiles, stats.otherFiles];
1323
1379
  const sum = (what) => kinds.reduce((n, counts) => n + counts[what], 0);
1324
1380
  if (bucketSeen) {
@@ -1348,6 +1404,7 @@ async function startRun(passed, declared) {
1348
1404
  log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
1349
1405
  }
1350
1406
  }
1407
+ const shardLines = [];
1351
1408
  return {
1352
1409
  get runId() {
1353
1410
  return runId;
@@ -1385,15 +1442,43 @@ async function startRun(passed, declared) {
1385
1442
  summarise();
1386
1443
  return frozen;
1387
1444
  },
1388
- async leaveOpen() {
1389
- if (done) return;
1445
+ shardLines,
1446
+ async leaveOpen(opts) {
1447
+ if (done) return void 0;
1390
1448
  done = true;
1391
1449
  await flush();
1392
1450
  await offer();
1393
- if (runId !== null) {
1451
+ let frozen;
1452
+ const after = [];
1453
+ const shard = env.shard;
1454
+ let counted = false;
1455
+ let mismatch;
1456
+ if (runId !== null && shard !== void 0 && !offline) {
1457
+ const out = await client.post(`/v1/runs/${runId}/events`, { event: "finish", shardIndex: shard.index, shardsExpected: shard.total });
1458
+ if (out.ok && typeof out.body?.shardsReported === "number") {
1459
+ counted = true;
1460
+ frozen = out.body.summary ?? void 0;
1461
+ const n = out.body.shardsExpected ?? shard.total;
1462
+ if (out.body.run?.statusEvent === "finished") after.push("plune: run closed");
1463
+ after.push(`plune: shard ${shard.index} of ${shard.total} done \u2014 ${out.body.shardsReported} of ${n} shards reported`);
1464
+ } else if (out.ok || out.kind === "invalid") {
1465
+ log('plune: the platform does not support shard auto-close yet \u2014 the run is left open for "plune run finish".');
1466
+ } else if (out.code === "shards_mismatch") {
1467
+ mismatch = new Error(
1468
+ `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.`
1469
+ );
1470
+ } else {
1471
+ 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}".`);
1472
+ }
1473
+ }
1474
+ if (runId !== null && !counted) {
1394
1475
  log(`plune: run ${runId} is still open \u2014 close it with "plune run finish ${runId}".`);
1395
1476
  }
1396
1477
  summarise();
1478
+ shardLines.push(...after);
1479
+ if (opts?.holdShardLines !== true) for (const line of after) log(line);
1480
+ if (mismatch !== void 0) throw mismatch;
1481
+ return frozen;
1397
1482
  }
1398
1483
  };
1399
1484
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.24.0",
3
+ "version": "0.25.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",