@plune-ai/cli 0.25.0 → 0.27.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.
package/README.md CHANGED
@@ -165,9 +165,20 @@ plune requirements import prd.md --sections Requirements --unit bullets --key-pr
165
165
 
166
166
  `--sections` takes the heading or its number; `--unit bullets|subheadings` (default: sub-headings when the section has any); `--key-prefix` is text a bullet starts with that carries its key (`REQ-12: The system shall ...`; no pattern is accepted); `--priority-from subheading` makes the bullets under a sub-heading take its text as their priority.
167
167
 
168
- **3. Read the plan, then write it.** Without `--yes` nothing is written. The report is the summary line (`create 3 · update 0 · unchanged 12 · skip 1`), the rows grouped by action with their line in the file, key, title and reason (a skipped row says why), the priority and category values seen and what they map to, and what was **not imported** (other tables, other sections, frontmatter keys). It ends with `dry run — nothing was written; add --yes to apply`. Add `--yes` to apply the same plan in one transaction. A row with a key matches the requirement of that key; one without matches by exact title; running the same file again changes nothing (every row is `unchanged`). A skipped row does not fail the command.
168
+ **2c. Several tables in one run.** A PRD keeps its functional and non-functional requirements in different tables. Repeat `--table` and put each table's columns inside it, after the number and a colon:
169
169
 
170
- `--json` prints the platform's answer instead. The project is the only one of the account, or `--project <id>` / `PLUNE_PROJECT` (a token only works in the project it was minted in). Exit `0` on success, `2` for a flag combination that cannot work (`--table` with `--sections`, `--title-column` with `--text-column`), an unreadable or over-limit file, or a rejected token, `1` when the platform refuses (400, 403, 409 a key taken meanwhile, 413) or does not have the routes yet.
170
+ ```bash
171
+ plune requirements import prd.md \
172
+ --table "1:key=ID,text=Requirement,priority=Priority,fixed-category=functional" \
173
+ --table "2:key=ID,text=Requirement,fixed-category=non-functional" \
174
+ --map-priority "Must=Must-have"
175
+ ```
176
+
177
+ Inside a `--table` the names are `key`, `title` or `text` (one of the two), `description`, `priority`, `category`, `type`, `status`, `criteria`, plus `fixed-category=functional|non-functional|other` and `fixed-type=user_story|feature|epic` — one meaning for the whole table, used where a row has none of its own. Names are separated by commas, so a header that contains a comma cannot be named in this form. `--map-priority`, `--type` and `--map-category` are shared by all tables. A table number may be given once. The single-table form (`--table 0 --key-column ...`) keeps working, but the two forms are not mixed: `--key-column` and the other column flags are refused together with `--table "<n>:..."`. The plan shows `table #N "heading"` in front of each row.
178
+
179
+ **3. Read the plan, then write it.** Without `--yes` nothing is written. The report is the summary line (`create 3 · update 0 · unchanged 12 · skip 1`), the rows grouped by action with their line in the file, key, title and reason (a skipped row says why), the priority and category values seen and what they map to, and what was **not imported** (other tables, other sections, frontmatter keys). It ends with `dry run — nothing was written; add --yes to apply`. Add `--yes` to apply the same plan in one transaction. A row with a key matches the requirement of that key; one without matches by exact title; running the same file again changes nothing (every row is `unchanged`). A skipped row does not fail the command. Rows that are only placeholders (`[Behavior]`) are skipped with the reason `template placeholder`; a key that appears twice in the file is skipped with `first on line N, again on line M`. When every row is a placeholder the plan says `this file is an empty template` (`--json` has `"emptyTemplate": true`) and the exit code is `0`, like any plan whose rows are skipped: nothing is written, and nothing is an error.
180
+
181
+ `--json` prints the platform's answer instead. The project is the only one of the account, or `--project <id>` / `PLUNE_PROJECT` (a token only works in the project it was minted in). Exit `0` on success, `2` for a flag combination that cannot work (`--table` with `--sections`, `--title-column` with `--text-column`, a `--table "<n>:..."` spec with the single-table column flags), an unreadable or over-limit file, or a rejected token, `1` when the platform refuses (400, 403, 409 a key taken meanwhile, 413) or does not have the routes yet.
171
182
 
172
183
  ### A release gate: `--fail-on-failures`
173
184
 
@@ -381,6 +392,17 @@ The shard that is the Nth also prints `plune: run closed` just above that line,
381
392
  and leaves the run open, as `PLUNE_SHARED_RUN` does: close it with `plune run finish`.
382
393
  - With `--fail-on-failures`, the shard that closes the run gates on its summary; the others have no verdict yet.
383
394
 
395
+ ### A time limit for the import
396
+
397
+ The whole `plune run import` stops after 600 seconds by default, so a platform that stops answering cannot hold a CI job until its own timeout kills it. Raise it for a very large report, or turn it off with `0`:
398
+
399
+ ```bash
400
+ PLUNE_IMPORT_TIMEOUT=1200 plune run import ./playwright.json # seconds, 0 to 86400; 0 is no limit
401
+ plune run import ./playwright.json --timeout 1200 # the same, as a flag (it wins)
402
+ ```
403
+
404
+ At the limit the command sends `terminate` for the run it opened (not for a run other jobs share), says in one line that the platform may have received part of the results, and exits 1. Import the report again afterwards.
405
+
384
406
  ### Many screenshots: upload concurrency and retries
385
407
 
386
408
  Files go to Plune up to 100 to a request: `uploads-batch` announces them (with the SHA-256 of each, read as a stream,
package/dist/cli.cjs CHANGED
@@ -2315,7 +2315,8 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
2315
2315
  return new Promise((resolve10, reject) => {
2316
2316
  const target = new URL(url);
2317
2317
  const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
2318
- const req = send(target, { method: init.method, headers: init.headers }, (res) => {
2318
+ const agent = POOLS[target.protocol === "https:" ? "https:" : "http:"];
2319
+ const req = send(target, { method: init.method, headers: init.headers, agent }, (res) => {
2319
2320
  const chunks = [];
2320
2321
  res.on("data", (chunk2) => chunks.push(chunk2));
2321
2322
  res.on("error", reject);
@@ -2506,7 +2507,7 @@ function createClient(opts) {
2506
2507
  }
2507
2508
  };
2508
2509
  }
2509
- var import_node_fs2, import_node_stream, import_node_http, import_node_https, BACKOFF_BASE_MS, BACKOFF_CAP_MS, DEFAULT_RETRIES, RETRY_AFTER_CAP_MS, IDLE_MS, withoutLength, codeOfError, sleep;
2510
+ var import_node_fs2, import_node_stream, import_node_http, import_node_https, BACKOFF_BASE_MS, BACKOFF_CAP_MS, DEFAULT_RETRIES, RETRY_AFTER_CAP_MS, POOLS, IDLE_MS, withoutLength, codeOfError, sleep;
2510
2511
  var init_client = __esm({
2511
2512
  "src/reporter-core/client.ts"() {
2512
2513
  "use strict";
@@ -2519,6 +2520,7 @@ var init_client = __esm({
2519
2520
  BACKOFF_CAP_MS = 1e4;
2520
2521
  DEFAULT_RETRIES = 2;
2521
2522
  RETRY_AFTER_CAP_MS = 6e4;
2523
+ POOLS = { "http:": new import_node_http.Agent({ keepAlive: true }), "https:": new import_node_https.Agent({ keepAlive: true }) };
2522
2524
  IDLE_MS = 3e5;
2523
2525
  withoutLength = (headers) => Object.fromEntries(Object.entries(headers).filter(([name]) => name.toLowerCase() !== "content-length"));
2524
2526
  codeOfError = (err) => {
@@ -2577,6 +2579,7 @@ function readEnv(env = process.env) {
2577
2579
  const warnings = [];
2578
2580
  const uploadConcurrency = bounded(env, "PLUNE_UPLOAD_CONCURRENCY", UPLOAD_CONCURRENCY, warnings);
2579
2581
  const uploadRetries = bounded(env, "PLUNE_UPLOAD_RETRIES", UPLOAD_RETRIES, warnings);
2582
+ const importTimeout = bounded(env, "PLUNE_IMPORT_TIMEOUT", IMPORT_TIMEOUT, warnings);
2580
2583
  const title = value(env, "PLUNE_RUN_TITLE");
2581
2584
  const environment = value(env, "PLUNE_ENV");
2582
2585
  const labels = list(env, "PLUNE_LABELS");
@@ -2608,6 +2611,7 @@ function readEnv(env = process.env) {
2608
2611
  keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
2609
2612
  ...shard !== void 0 ? { shard } : {},
2610
2613
  ...shardError !== void 0 ? { shardError } : {},
2614
+ ...importTimeout !== void 0 ? { importTimeout } : {},
2611
2615
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
2612
2616
  warnings
2613
2617
  };
@@ -2635,7 +2639,7 @@ function ciCommit(reported, env = process.env) {
2635
2639
  ...branch !== void 0 ? { branch } : {}
2636
2640
  };
2637
2641
  }
2638
- var path6, UNSUPPORTED_VARS, SHARDS_MAX, UPLOAD_CONCURRENCY, UPLOAD_RETRIES, PROVIDERS;
2642
+ var path6, UNSUPPORTED_VARS, SHARDS_MAX, UPLOAD_CONCURRENCY, UPLOAD_RETRIES, IMPORT_TIMEOUT, PROVIDERS;
2639
2643
  var init_env2 = __esm({
2640
2644
  "src/reporter-core/env.ts"() {
2641
2645
  "use strict";
@@ -2645,6 +2649,7 @@ var init_env2 = __esm({
2645
2649
  SHARDS_MAX = 1e3;
2646
2650
  UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 16 };
2647
2651
  UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
2652
+ IMPORT_TIMEOUT = { min: 0, max: 86400, default: 600 };
2648
2653
  PROVIDERS = [
2649
2654
  { is: "GITHUB_ACTIONS", sha: "GITHUB_SHA", branch: ["GITHUB_HEAD_REF", "GITHUB_REF_NAME"] },
2650
2655
  { is: "GITLAB_CI", sha: "CI_COMMIT_SHA", branch: ["CI_COMMIT_REF_NAME"] },
@@ -3558,7 +3563,29 @@ async function startRun(passed, declared2) {
3558
3563
  else await uploadBatched(jobs);
3559
3564
  uploadMs += Date.now() - started;
3560
3565
  }
3566
+ const sending = /* @__PURE__ */ new Set();
3567
+ let sendError;
3568
+ let answered = false;
3569
+ async function dispatch(work) {
3570
+ while (sending.size >= (answered ? SEND_CONCURRENCY : 1)) await Promise.race(sending);
3571
+ const task = work().catch((err) => {
3572
+ sendError ??= err;
3573
+ }).finally(() => sending.delete(task));
3574
+ sending.add(task);
3575
+ }
3576
+ async function settle() {
3577
+ while (sending.size > 0) await Promise.all([...sending]);
3578
+ if (sendError !== void 0) {
3579
+ const err = sendError;
3580
+ sendError = void 0;
3581
+ throw err;
3582
+ }
3583
+ }
3561
3584
  async function flush() {
3585
+ await flushBuffer();
3586
+ await settle();
3587
+ }
3588
+ async function flushBuffer() {
3562
3589
  if (buffer.length === 0) return;
3563
3590
  const pending = buffer;
3564
3591
  buffer = [];
@@ -3566,6 +3593,9 @@ async function startRun(passed, declared2) {
3566
3593
  defer(pending);
3567
3594
  return;
3568
3595
  }
3596
+ await dispatch(() => deliver(pending));
3597
+ }
3598
+ async function deliver(pending) {
3569
3599
  await resolveKeys(pending.flatMap((p) => p.keys));
3570
3600
  const submissions = [];
3571
3601
  const unanswered = [];
@@ -3606,6 +3636,7 @@ async function startRun(passed, declared2) {
3606
3636
  for (const k of told) stats[k] += counts[k];
3607
3637
  progress();
3608
3638
  await upload(out.ok ? out.body?.items : void 0, filesByKey);
3639
+ answered = true;
3609
3640
  continue;
3610
3641
  }
3611
3642
  if (out.ok) {
@@ -3680,7 +3711,7 @@ async function startRun(passed, declared2) {
3680
3711
  },
3681
3712
  async add(result) {
3682
3713
  buffer.push({ ...result, keys: bound(result.keys) });
3683
- if (buffer.length >= batchSize) await flush();
3714
+ if (buffer.length >= batchSize) await flushBuffer();
3684
3715
  },
3685
3716
  flush,
3686
3717
  async finish(reason) {
@@ -3745,7 +3776,7 @@ async function startRun(passed, declared2) {
3745
3776
  }
3746
3777
  };
3747
3778
  }
3748
- var import_node_crypto3, import_node_fs5, import_node_path3, import_node_util2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TEXT_BYTES, TEXTS_PER_RESULT, UTF8, TYPE_BY_EXTENSION, METADATA_TYPE2, NAME_MAX, SKIPPED, BUCKET_FOUND, KINDS, codeOf2, BATCH_BYTES, ENVELOPE_BYTES;
3779
+ var import_node_crypto3, import_node_fs5, import_node_path3, import_node_util2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, SEND_CONCURRENCY, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TEXT_BYTES, TEXTS_PER_RESULT, UTF8, TYPE_BY_EXTENSION, METADATA_TYPE2, NAME_MAX, SKIPPED, BUCKET_FOUND, KINDS, codeOf2, BATCH_BYTES, ENVELOPE_BYTES;
3749
3780
  var init_session = __esm({
3750
3781
  "src/reporter-core/session.ts"() {
3751
3782
  "use strict";
@@ -3765,6 +3796,7 @@ var init_session = __esm({
3765
3796
  BATCH_MAX = 500;
3766
3797
  DISCOVER_MAX = 500;
3767
3798
  BATCH_DEFAULT = 500;
3799
+ SEND_CONCURRENCY = 4;
3768
3800
  EXPECTED_MAX = 1e4;
3769
3801
  KEY_MAX = 1024;
3770
3802
  TITLE_MAX = 300;
@@ -5336,8 +5368,24 @@ var init_importers = __esm({
5336
5368
  // src/cli/commands/run-import.ts
5337
5369
  var run_import_exports = {};
5338
5370
  __export(run_import_exports, {
5371
+ ImportTimeoutError: () => ImportTimeoutError,
5339
5372
  handleRunImport: () => handleRunImport
5340
5373
  });
5374
+ async function timeoutMessage(limit, runId, ours, options) {
5375
+ const head = `plune: run import did not finish in ${limit} s (--timeout / PLUNE_IMPORT_TIMEOUT; 0 turns the limit off); the platform may have received part of the results`;
5376
+ if (runId === null) return `${head}; the run id is not known, so nothing was terminated - look for an open run with this key in Plune and terminate it there, then import again.`;
5377
+ if (!ours) return `${head}; run ${runId} is shared with other jobs and was left open - close it with "plune run finish ${runId}" once they are done, then import this report again.`;
5378
+ const wait = options.terminateWaitMs ?? 15e3;
5379
+ let timer;
5380
+ const gaveUp = new Promise((resolve10) => timer = setTimeout(() => resolve10("gaveup"), wait));
5381
+ const quiet = () => void 0;
5382
+ const closed = handleRunFinish(runId, { ...options, write: quiet, terminate: true, reason: `import timed out after ${limit} s` }).then(
5383
+ () => true,
5384
+ () => false
5385
+ );
5386
+ const done = await Promise.race([closed, gaveUp]).finally(() => clearTimeout(timer)) === true;
5387
+ return done ? `${head}; run ${runId} was terminated - import the report again.` : `${head}; run ${runId} could not be terminated from here - run "plune run finish ${runId} --terminate" (the platform also closes a silent run after 30 minutes), then import again.`;
5388
+ }
5341
5389
  async function handleRunImport(options) {
5342
5390
  const write = options.write ?? ((line) => void process.stdout.write(`${line}
5343
5391
  `));
@@ -5364,31 +5412,47 @@ async function handleRunImport(options) {
5364
5412
  const apiUrl = resolveApiUrl(options.apiUrl ?? env.config.apiUrl);
5365
5413
  const token = options.token ?? env.config.token ?? loadToken() ?? "";
5366
5414
  if (token === "") throw new NoTokenError();
5367
- const session = await startRun(
5368
- {
5369
- apiUrl,
5370
- token,
5371
- ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
5372
- ...options.key !== void 0 ? { externalKey: options.key } : {},
5373
- ...options.create === true ? { offerDiscovered: true } : {},
5374
- ...options.uploadConcurrency !== void 0 ? { uploadConcurrency: options.uploadConcurrency } : {},
5375
- kind: "automated",
5376
- // The CI run the report names, never this machine's: whoever imports may be another job (#790).
5377
- // The commit and branch likewise, where the report names a commit; a report that names none —
5378
- // JUnit's never does — takes this job's own, which is where the tests ran when import follows
5379
- // them in one job (plune-ai/plune#927).
5380
- meta: { runner: format, ...ciUrl !== void 0 ? { ciUrl } : {}, ...ciCommit(ci) },
5381
- log: write
5382
- },
5383
- // The file is the complete list of what ran, so the platform can resolve every test in one
5384
- // request and derive what never ran from the same list (AC-09/AC-10) — the lazy path would be
5385
- // a lookup per result.
5386
- results.map((r) => r.keys)
5387
- );
5388
- for (const result2 of results) await session.add(result2);
5415
+ let session;
5416
+ const send = async () => {
5417
+ session = await startRun(
5418
+ {
5419
+ apiUrl,
5420
+ token,
5421
+ ...options.fetchImpl !== void 0 ? { fetchImpl: options.fetchImpl } : {},
5422
+ ...options.key !== void 0 ? { externalKey: options.key } : {},
5423
+ ...options.create === true ? { offerDiscovered: true } : {},
5424
+ ...options.uploadConcurrency !== void 0 ? { uploadConcurrency: options.uploadConcurrency } : {},
5425
+ kind: "automated",
5426
+ // The CI run the report names, never this machine's: whoever imports may be another job (#790).
5427
+ // The commit and branch likewise, where the report names a commit; a report that names none —
5428
+ // JUnit's never does — takes this job's own, which is where the tests ran when import follows
5429
+ // them in one job (plune-ai/plune#927).
5430
+ meta: { runner: format, ...ciUrl !== void 0 ? { ciUrl } : {}, ...ciCommit(ci) },
5431
+ log: write
5432
+ },
5433
+ // The file is the complete list of what ran, so the platform can resolve every test in one
5434
+ // request and derive what never ran from the same list (AC-09/AC-10) — the lazy path would be
5435
+ // a lookup per result.
5436
+ results.map((r) => r.keys)
5437
+ );
5438
+ for (const result2 of results) await session.add(result2);
5439
+ return env.keepOpen ? session.leaveOpen({ holdShardLines: true }) : session.finish();
5440
+ };
5441
+ const limit = options.timeoutSec ?? env.importTimeout ?? IMPORT_TIMEOUT.default;
5442
+ const sent = send();
5389
5443
  let frozen;
5390
- if (env.keepOpen) frozen = await session.leaveOpen({ holdShardLines: true });
5391
- else frozen = await session.finish();
5444
+ if (limit === 0) frozen = await sent;
5445
+ else {
5446
+ sent.catch(() => void 0);
5447
+ let timer;
5448
+ const late = new Promise((resolve10) => timer = setTimeout(() => resolve10("late"), limit * 1e3));
5449
+ const first = await Promise.race([sent, late]).finally(() => clearTimeout(timer));
5450
+ if (first === "late") {
5451
+ throw new ImportTimeoutError(await timeoutMessage(limit, session?.runId ?? null, !env.keepOpen, { ...options, apiUrl, token }));
5452
+ }
5453
+ frozen = first;
5454
+ }
5455
+ if (session === void 0) throw new RunCommandError("The import did not start.");
5392
5456
  const stats = session.stats;
5393
5457
  const result = {
5394
5458
  runId: session.runId,
@@ -5483,7 +5547,7 @@ function describe(result, apiUrl) {
5483
5547
  if (result.runId !== null) lines.push(runUrl2(apiUrl, result.runId));
5484
5548
  return lines;
5485
5549
  }
5486
- var fs12, NEXT;
5550
+ var fs12, ImportTimeoutError, NEXT;
5487
5551
  var init_run_import = __esm({
5488
5552
  "src/cli/commands/run-import.ts"() {
5489
5553
  "use strict";
@@ -5497,6 +5561,8 @@ var init_run_import = __esm({
5497
5561
  init_credentials();
5498
5562
  init_gate();
5499
5563
  init_env2();
5564
+ ImportTimeoutError = class extends RunCommandError {
5565
+ };
5500
5566
  NEXT = {
5501
5567
  queue: "the review queue is full. Approve or reject what is waiting, then import this report again to offer the rest.",
5502
5568
  cases: "the project is at its test case limit. Delete cases you no longer need, or ask an operator to raise the limit, then import this report again to offer the rest.",
@@ -6291,6 +6357,7 @@ __export(requirements_import_exports, {
6291
6357
  formatReport: () => formatReport2,
6292
6358
  handleRequirementsImport: () => handleRequirementsImport,
6293
6359
  parseMap: () => parseMap,
6360
+ parseTableSpec: () => parseTableSpec,
6294
6361
  reportRequirementsImportFailure: () => reportRequirementsImportFailure
6295
6362
  });
6296
6363
  function parseMap(flag2, raw) {
@@ -6306,25 +6373,63 @@ function parseMap(flag2, raw) {
6306
6373
  if (Object.keys(out).length === 0) usage(`${flag2} is empty. Write it like ${flag2} "Critical=Must-have,High=Should-have".`);
6307
6374
  return out;
6308
6375
  }
6376
+ function parseTableSpec(spec) {
6377
+ const at = spec.indexOf(":");
6378
+ const index = (at < 0 ? spec : spec.slice(0, at)).trim();
6379
+ if (!/^\d+$/.test(index)) usage(`--table "${spec}": "${index}" is not a table number. ${SPEC_HELP}`);
6380
+ const t = { table: Number(index) };
6381
+ for (const pair of (at < 0 ? "" : spec.slice(at + 1)).split(",")) {
6382
+ if (pair.trim() === "") continue;
6383
+ const eq = pair.indexOf("=");
6384
+ const name = (eq < 0 ? pair : pair.slice(0, eq)).trim().toLowerCase();
6385
+ const value2 = eq < 0 ? "" : pair.slice(eq + 1).trim();
6386
+ const field = TABLE_SPEC_KEYS[name];
6387
+ if (field === void 0) return usage(`--table "${spec}": "${name}" is not a column name. Use ${Object.keys(TABLE_SPEC_KEYS).join(", ")}.`);
6388
+ if (value2 === "") usage(`--table "${spec}": "${name}" has no value. ${SPEC_HELP}`);
6389
+ if (field in t) usage(`--table "${spec}": "${name}" is given twice.`);
6390
+ if (field === "fixedCategory") oneOf("fixed-category", value2, ["functional", "non-functional", "other"]);
6391
+ if (field === "fixedType") oneOf("fixed-type", value2, ["user_story", "feature", "epic"]);
6392
+ t[field] = value2;
6393
+ }
6394
+ if (t["titleColumn"] !== void 0 && t["textColumn"] !== void 0) usage(`--table "${spec}": give title= or text=, not both.`);
6395
+ if (t["titleColumn"] === void 0 && t["textColumn"] === void 0) usage(`--table "${spec}": each table needs title=<header> or text=<header>.`);
6396
+ return t;
6397
+ }
6309
6398
  function buildImportBody(f) {
6399
+ const tables = f.table === void 0 ? [] : Array.isArray(f.table) ? f.table : [f.table];
6310
6400
  const columnFlags = COLUMN_FLAGS.filter(([k]) => f[k] !== void 0);
6311
6401
  const sectionFlags = SECTION_FLAGS.filter(([k]) => f[k] !== void 0);
6312
- const anyMapping = f.table !== void 0 || f.sections !== void 0 || columnFlags.length > 0 || sectionFlags.length > 0 || f.mapPriority !== void 0 || f.mapCategory !== void 0 || f.type !== void 0;
6402
+ const anyMapping = tables.length > 0 || f.sections !== void 0 || columnFlags.length > 0 || sectionFlags.length > 0 || f.mapPriority !== void 0 || f.mapCategory !== void 0 || f.type !== void 0;
6313
6403
  if (!anyMapping) return null;
6314
- if (f.table !== void 0 && f.sections !== void 0) usage("Give --table or --sections, not both: one import reads one table or one section.");
6315
- if (f.table === void 0 && f.sections === void 0) {
6404
+ if (tables.length > 0 && f.sections !== void 0) usage("Give --table or --sections, not both: one import reads one table or one section.");
6405
+ if (tables.length === 0 && f.sections === void 0) {
6316
6406
  usage("The mapping flags need --table <n> (a table) or --sections <heading|n> (a section). Run without flags to see which the file has.");
6317
6407
  }
6318
6408
  const body = {};
6319
6409
  if (f.mapPriority !== void 0) body["priorityMap"] = parseMap("--map-priority", f.mapPriority);
6320
6410
  if (f.type !== void 0) body["defaultType"] = oneOf("--type", f.type, ["user_story", "feature", "epic"]);
6321
- if (f.table !== void 0) {
6322
- if (!/^\d+$/.test(f.table)) usage(`--table is the number of the table in the list (0, 1, ...), not "${f.table}".`);
6411
+ if (tables.length > 1 || tables.some((t) => t.includes(":"))) {
6412
+ if (sectionFlags.length > 0) usage(`${sectionFlags.map(([, n]) => n).join(", ")} ${sectionFlags.length > 1 ? "are" : "is"} for --sections, not --table.`);
6413
+ if (columnFlags.length > 0) {
6414
+ usage(`${columnFlags.map(([, n]) => n).join(", ")} cannot be used with --table "<n>:...": put the column inside each --table, e.g. --table "3:key=ID,text=Requirement".`);
6415
+ }
6416
+ const specs = tables.map(parseTableSpec);
6417
+ const seen = /* @__PURE__ */ new Set();
6418
+ for (const t of specs) {
6419
+ if (seen.has(t["table"])) usage(`Table ${String(t["table"])} is given twice in --table.`);
6420
+ seen.add(t["table"]);
6421
+ }
6422
+ body["mode"] = "table";
6423
+ const categoryMap = f.mapCategory !== void 0 ? parseMap("--map-category", f.mapCategory) : void 0;
6424
+ body["tables"] = specs.map((t) => categoryMap === void 0 ? t : { ...t, categoryMap });
6425
+ } else if (tables.length === 1) {
6426
+ const only = tables[0];
6427
+ if (!/^\d+$/.test(only)) usage(`--table is the number of the table in the list (0, 1, ...), not "${only}".`);
6323
6428
  if (sectionFlags.length > 0) usage(`${sectionFlags.map(([, n]) => n).join(", ")} ${sectionFlags.length > 1 ? "are" : "is"} for --sections, not --table.`);
6324
6429
  if (f.titleColumn !== void 0 && f.textColumn !== void 0) usage("Give --title-column or --text-column, not both.");
6325
6430
  if (f.titleColumn === void 0 && f.textColumn === void 0) usage("--table needs --title-column <header> or --text-column <header>.");
6326
6431
  body["mode"] = "table";
6327
- body["table"] = Number(f.table);
6432
+ body["table"] = Number(only);
6328
6433
  for (const [k, , key] of columnFlags) body[key] = f[k];
6329
6434
  if (f.mapCategory !== void 0) body["categoryMap"] = parseMap("--map-category", f.mapCategory);
6330
6435
  } else {
@@ -6434,13 +6539,19 @@ function formatDiscover(file, r) {
6434
6539
  "",
6435
6540
  "Pick one and map it (a dry run, nothing is written):",
6436
6541
  ` plune requirements import ${file} --table <n> --key-column <header> --title-column <header>`,
6437
- ` plune requirements import ${file} --sections <heading|n> --unit bullets|subheadings`
6542
+ ` plune requirements import ${file} --sections <heading|n> --unit bullets|subheadings`,
6543
+ "Several tables in one run, each with its own columns:",
6544
+ ` plune requirements import ${file} --table "<n>:key=<header>,text=<header>,fixed-category=functional" --table "<m>:key=<header>,text=<header>"`
6438
6545
  );
6439
6546
  return out.join("\n") + "\n";
6440
6547
  }
6441
6548
  function formatReport2(report) {
6442
6549
  const s = report.summary;
6443
6550
  const out = [`create ${s.create} \xB7 update ${s.update} \xB7 unchanged ${s.unchanged} \xB7 skip ${s.skip}`];
6551
+ if (report.emptyTemplate === true) {
6552
+ const said = report.message !== void 0 && !/empty template/i.test(report.message) ? ` ${flat(report.message)}` : "";
6553
+ out.push("", `this file is an empty template: every row is a placeholder, nothing to import.${said}`);
6554
+ }
6444
6555
  for (const action of ["create", "update", "skip", "unchanged"]) {
6445
6556
  const rows = report.items.filter((i) => i.action === action);
6446
6557
  if (rows.length === 0) continue;
@@ -6448,7 +6559,8 @@ function formatReport2(report) {
6448
6559
  for (const i of rows.slice(0, SHOWN)) {
6449
6560
  const changes = i.changes !== void 0 && i.changes.length > 0 ? `; changes: ${i.changes.join(", ")}` : "";
6450
6561
  const notes = i.notes !== void 0 && i.notes.length > 0 ? `; note: ${i.notes.join("; ")}` : "";
6451
- out.push(` line ${i.line} ${i.key ?? "-"} ${cut2(flat(i.title), 80)} - ${flat(i.reason)}${changes}${notes}`);
6562
+ const from = i.table === void 0 ? "" : `table #${i.table}${i.tableHeading ? ` "${cut2(flat(i.tableHeading), 40)}"` : ""} `;
6563
+ out.push(` ${from}line ${i.line} ${i.key ?? "-"} ${cut2(flat(i.title), 80)} - ${flat(i.reason)}${changes}${notes}`);
6452
6564
  }
6453
6565
  if (rows.length > SHOWN) out.push(` ... and ${rows.length - SHOWN} more (--json lists every row)`);
6454
6566
  }
@@ -6472,7 +6584,7 @@ function reportRequirementsImportFailure(err, write) {
6472
6584
  write(err.message + "\n");
6473
6585
  return err.exitCode;
6474
6586
  }
6475
- var import_node_fs7, import_node_path4, MAX_MARKDOWN_BYTES, RequirementsImportError, usage, COLUMN_FLAGS, SECTION_FLAGS, oneOf, SUPPORT, SHOWN, flat, cut2, plural;
6587
+ var import_node_fs7, import_node_path4, MAX_MARKDOWN_BYTES, RequirementsImportError, usage, COLUMN_FLAGS, SECTION_FLAGS, oneOf, TABLE_SPEC_KEYS, SPEC_HELP, SUPPORT, SHOWN, flat, cut2, plural;
6476
6588
  var init_requirements_import = __esm({
6477
6589
  "src/cli/commands/requirements-import.ts"() {
6478
6590
  "use strict";
@@ -6511,6 +6623,20 @@ var init_requirements_import = __esm({
6511
6623
  ["priorityFrom", "--priority-from"]
6512
6624
  ];
6513
6625
  oneOf = (flag2, value2, allowed) => allowed.includes(value2) ? value2 : usage(`${flag2} must be ${allowed.join(" or ")}, not "${value2}".`);
6626
+ TABLE_SPEC_KEYS = {
6627
+ key: "keyColumn",
6628
+ title: "titleColumn",
6629
+ text: "textColumn",
6630
+ description: "descriptionColumn",
6631
+ priority: "priorityColumn",
6632
+ category: "categoryColumn",
6633
+ type: "typeColumn",
6634
+ status: "statusColumn",
6635
+ criteria: "acceptanceCriteriaColumn",
6636
+ "fixed-category": "fixedCategory",
6637
+ "fixed-type": "fixedType"
6638
+ };
6639
+ SPEC_HELP = 'Write it like --table "3:key=ID,text=Requirement,fixed-category=functional".';
6514
6640
  SUPPORT = "This platform does not support requirements import yet. Nothing was sent to a project; try a newer Plune platform.";
6515
6641
  SHOWN = 50;
6516
6642
  flat = (s) => s.replace(/\s+/g, " ").trim();
@@ -6773,6 +6899,13 @@ function createProgram() {
6773
6899
  if (!/^\d+$/.test(v) || Number(v) < 1 || Number(v) > 32) throw new import_commander.InvalidArgumentError("a whole number from 1 to 32.");
6774
6900
  return Number(v);
6775
6901
  }
6902
+ ).option(
6903
+ "--timeout <seconds>",
6904
+ "Give up on the whole import after this many seconds, 0 for no limit (default 600; PLUNE_IMPORT_TIMEOUT sets it too). On a timeout the run this import opened is terminated, and the exit code is 1",
6905
+ (v) => {
6906
+ if (!/^\d+$/.test(v) || Number(v) > 86400) throw new import_commander.InvalidArgumentError("a whole number of seconds from 0 to 86400.");
6907
+ return Number(v);
6908
+ }
6776
6909
  ).option("--fail-on-failures", FAIL_ON_FAILURES, false).action(
6777
6910
  async (file, options, command) => {
6778
6911
  const [
@@ -6799,6 +6932,7 @@ function createProgram() {
6799
6932
  ...options.key !== void 0 ? { key: options.key } : {},
6800
6933
  ...options.create ? { create: true } : {},
6801
6934
  ...options.uploadConcurrency !== void 0 ? { uploadConcurrency: options.uploadConcurrency } : {},
6935
+ ...options.timeout !== void 0 ? { timeoutSec: options.timeout } : {},
6802
6936
  ...options.failOnFailures ? { failOnFailures: true } : {}
6803
6937
  });
6804
6938
  if (options.failOnMissingAttachments && imported.missingAttachments.length > 0) process.exitCode = 1;
@@ -7008,7 +7142,11 @@ function createProgram() {
7008
7142
  const requirementsCommand = program.command("requirements").description("Requirements on the platform \u2014 import them from a Markdown file");
7009
7143
  requirementsCommand.command("import").argument("<file>", "A Markdown file (UTF-8, at most 1 MiB)").description(
7010
7144
  "Import requirements from a Markdown file. With no mapping flags it lists the tables and sections found; with them it is a dry run until --yes"
7011
- ).option("--table <n>", "Read the table with this number (from the list)").option("--key-column <header>", "Table: the column holding the requirement key").option("--title-column <header>", "Table: the column holding the title").option("--text-column <header>", "Table: one text column; the title is its first line (not with --title-column)").option("--description-column <header>", "Table: the description column").option("--priority-column <header>", "Table: the priority column").option("--category-column <header>", "Table: the category column").option("--type-column <header>", "Table: the type column").option("--status-column <header>", "Table: the status column (draft, active or done)").option("--criteria-column <header>", "Table: the acceptance criteria column").option("--map-priority <pairs>", 'Rename priority values, e.g. "Critical=Must-have,High=Should-have"').option("--map-category <pairs>", 'Table: rename category values, e.g. "Quality=non-functional"').option("--sections <heading|n>", "Read the section with this heading or number (from the list)").option("--unit <unit>", "Sections: bullets or subheadings (default: sub-headings if it has any)").option("--key-prefix <text>", "Sections: text a bullet or sub-heading starts with that carries its key").option("--priority-from <source>", "Sections: subheading \u2014 bullets take their sub-heading as priority").option("--type <type>", "What a row without a type becomes: user_story, feature or epic").option("--project <id>", "The project (default: PLUNE_PROJECT, else the only project of the account)").option("--yes", "Write it. Without it the import is a dry run", false).option("--json", "Print the platform\u2019s answer as JSON", false).action(async (file, options, command) => {
7145
+ ).option(
7146
+ "--table <n|spec>",
7147
+ 'Read the table with this number (from the list); repeat as --table "<n>:key=ID,text=Requirement,fixed-category=functional" for several tables',
7148
+ (value2, previous = []) => [...previous, value2]
7149
+ ).option("--key-column <header>", "Table: the column holding the requirement key").option("--title-column <header>", "Table: the column holding the title").option("--text-column <header>", "Table: one text column; the title is its first line (not with --title-column)").option("--description-column <header>", "Table: the description column").option("--priority-column <header>", "Table: the priority column").option("--category-column <header>", "Table: the category column").option("--type-column <header>", "Table: the type column").option("--status-column <header>", "Table: the status column (draft, active or done)").option("--criteria-column <header>", "Table: the acceptance criteria column").option("--map-priority <pairs>", 'Rename priority values, e.g. "Critical=Must-have,High=Should-have"').option("--map-category <pairs>", 'Table: rename category values, e.g. "Quality=non-functional"').option("--sections <heading|n>", "Read the section with this heading or number (from the list)").option("--unit <unit>", "Sections: bullets or subheadings (default: sub-headings if it has any)").option("--key-prefix <text>", "Sections: text a bullet or sub-heading starts with that carries its key").option("--priority-from <source>", "Sections: subheading \u2014 bullets take their sub-heading as priority").option("--type <type>", "What a row without a type becomes: user_story, feature or epic").option("--project <id>", "The project (default: PLUNE_PROJECT, else the only project of the account)").option("--yes", "Write it. Without it the import is a dry run", false).option("--json", "Print the platform\u2019s answer as JSON", false).action(async (file, options, command) => {
7012
7150
  const verbose = command.optsWithGlobals().verbose === true;
7013
7151
  const { handleRequirementsImport: handleRequirementsImport2, formatDiscover: formatDiscover2, formatReport: formatReport3, reportRequirementsImportFailure: reportRequirementsImportFailure2 } = await Promise.resolve().then(() => (init_requirements_import(), requirements_import_exports));
7014
7152
  try {
@@ -95,12 +95,14 @@ function parseRetryAfter(raw, now = Date.now()) {
95
95
  if (!Number.isFinite(seconds)) return null;
96
96
  return Math.min(Math.max(seconds, 0) * 1e3, RETRY_AFTER_CAP_MS);
97
97
  }
98
+ var POOLS = { "http:": new import_node_http.Agent({ keepAlive: true }), "https:": new import_node_https.Agent({ keepAlive: true }) };
98
99
  var IDLE_MS = 3e5;
99
100
  function nodeTransport(url, init, idleMs = IDLE_MS) {
100
101
  return new Promise((resolve2, reject) => {
101
102
  const target = new URL(url);
102
103
  const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
103
- const req = send(target, { method: init.method, headers: init.headers }, (res) => {
104
+ const agent = POOLS[target.protocol === "https:" ? "https:" : "http:"];
105
+ const req = send(target, { method: init.method, headers: init.headers, agent }, (res) => {
104
106
  const chunks = [];
105
107
  res.on("data", (chunk2) => chunks.push(chunk2));
106
108
  res.on("error", reject);
@@ -314,6 +316,7 @@ function shardOf(env) {
314
316
  }
315
317
  var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 16 };
316
318
  var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
319
+ var IMPORT_TIMEOUT = { min: 0, max: 86400, default: 600 };
317
320
  function value(env, name) {
318
321
  const raw = env[name]?.trim();
319
322
  return raw === void 0 || raw === "" ? void 0 : raw;
@@ -351,6 +354,7 @@ function readEnv(env = process.env) {
351
354
  const warnings = [];
352
355
  const uploadConcurrency = bounded(env, "PLUNE_UPLOAD_CONCURRENCY", UPLOAD_CONCURRENCY, warnings);
353
356
  const uploadRetries = bounded(env, "PLUNE_UPLOAD_RETRIES", UPLOAD_RETRIES, warnings);
357
+ const importTimeout = bounded(env, "PLUNE_IMPORT_TIMEOUT", IMPORT_TIMEOUT, warnings);
354
358
  const title = value(env, "PLUNE_RUN_TITLE");
355
359
  const environment = value(env, "PLUNE_ENV");
356
360
  const labels = list(env, "PLUNE_LABELS");
@@ -382,6 +386,7 @@ function readEnv(env = process.env) {
382
386
  keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
383
387
  ...shard !== void 0 ? { shard } : {},
384
388
  ...shardError !== void 0 ? { shardError } : {},
389
+ ...importTimeout !== void 0 ? { importTimeout } : {},
385
390
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
386
391
  warnings
387
392
  };
@@ -591,6 +596,7 @@ var RESOLVE_CHUNK = 500;
591
596
  var BATCH_MAX = 500;
592
597
  var DISCOVER_MAX = 500;
593
598
  var BATCH_DEFAULT = 500;
599
+ var SEND_CONCURRENCY = 4;
594
600
  var EXPECTED_MAX = 1e4;
595
601
  var KEY_MAX = 1024;
596
602
  var TITLE_MAX = 300;
@@ -1344,7 +1350,29 @@ async function startRun(passed, declared) {
1344
1350
  else await uploadBatched(jobs);
1345
1351
  uploadMs += Date.now() - started;
1346
1352
  }
1353
+ const sending = /* @__PURE__ */ new Set();
1354
+ let sendError;
1355
+ let answered = false;
1356
+ async function dispatch(work) {
1357
+ while (sending.size >= (answered ? SEND_CONCURRENCY : 1)) await Promise.race(sending);
1358
+ const task = work().catch((err) => {
1359
+ sendError ??= err;
1360
+ }).finally(() => sending.delete(task));
1361
+ sending.add(task);
1362
+ }
1363
+ async function settle() {
1364
+ while (sending.size > 0) await Promise.all([...sending]);
1365
+ if (sendError !== void 0) {
1366
+ const err = sendError;
1367
+ sendError = void 0;
1368
+ throw err;
1369
+ }
1370
+ }
1347
1371
  async function flush() {
1372
+ await flushBuffer();
1373
+ await settle();
1374
+ }
1375
+ async function flushBuffer() {
1348
1376
  if (buffer.length === 0) return;
1349
1377
  const pending = buffer;
1350
1378
  buffer = [];
@@ -1352,6 +1380,9 @@ async function startRun(passed, declared) {
1352
1380
  defer(pending);
1353
1381
  return;
1354
1382
  }
1383
+ await dispatch(() => deliver(pending));
1384
+ }
1385
+ async function deliver(pending) {
1355
1386
  await resolveKeys(pending.flatMap((p) => p.keys));
1356
1387
  const submissions = [];
1357
1388
  const unanswered = [];
@@ -1392,6 +1423,7 @@ async function startRun(passed, declared) {
1392
1423
  for (const k of told) stats[k] += counts[k];
1393
1424
  progress();
1394
1425
  await upload(out.ok ? out.body?.items : void 0, filesByKey);
1426
+ answered = true;
1395
1427
  continue;
1396
1428
  }
1397
1429
  if (out.ok) {
@@ -1466,7 +1498,7 @@ async function startRun(passed, declared) {
1466
1498
  },
1467
1499
  async add(result) {
1468
1500
  buffer.push({ ...result, keys: bound(result.keys) });
1469
- if (buffer.length >= batchSize) await flush();
1501
+ if (buffer.length >= batchSize) await flushBuffer();
1470
1502
  },
1471
1503
  flush,
1472
1504
  async finish(reason) {
@@ -445,6 +445,8 @@ interface EnvSettings {
445
445
  shard?: ShardOf;
446
446
  /** `PLUNE_SHARD` was set to something that is not `k/N`. A configuration error: the caller refuses to start. */
447
447
  shardError?: string;
448
+ /** `PLUNE_IMPORT_TIMEOUT` in seconds, when set and usable. */
449
+ importTimeout?: number;
448
450
  /** Variables that were set and have nowhere to land. The caller says so once. */
449
451
  ignored: string[];
450
452
  /** Variables that were set to something unusable and fell back to their default. The caller says so once. */
@@ -445,6 +445,8 @@ interface EnvSettings {
445
445
  shard?: ShardOf;
446
446
  /** `PLUNE_SHARD` was set to something that is not `k/N`. A configuration error: the caller refuses to start. */
447
447
  shardError?: string;
448
+ /** `PLUNE_IMPORT_TIMEOUT` in seconds, when set and usable. */
449
+ importTimeout?: number;
448
450
  /** Variables that were set and have nowhere to land. The caller says so once. */
449
451
  ignored: string[];
450
452
  /** Variables that were set to something unusable and fell back to their default. The caller says so once. */
@@ -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);
@@ -265,6 +267,7 @@ function shardOf(env) {
265
267
  }
266
268
  var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 16 };
267
269
  var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
270
+ var IMPORT_TIMEOUT = { min: 0, max: 86400, default: 600 };
268
271
  function value(env, name) {
269
272
  const raw = env[name]?.trim();
270
273
  return raw === void 0 || raw === "" ? void 0 : raw;
@@ -302,6 +305,7 @@ function readEnv(env = process.env) {
302
305
  const warnings = [];
303
306
  const uploadConcurrency = bounded(env, "PLUNE_UPLOAD_CONCURRENCY", UPLOAD_CONCURRENCY, warnings);
304
307
  const uploadRetries = bounded(env, "PLUNE_UPLOAD_RETRIES", UPLOAD_RETRIES, warnings);
308
+ const importTimeout = bounded(env, "PLUNE_IMPORT_TIMEOUT", IMPORT_TIMEOUT, warnings);
305
309
  const title = value(env, "PLUNE_RUN_TITLE");
306
310
  const environment = value(env, "PLUNE_ENV");
307
311
  const labels = list(env, "PLUNE_LABELS");
@@ -333,6 +337,7 @@ function readEnv(env = process.env) {
333
337
  keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
334
338
  ...shard !== void 0 ? { shard } : {},
335
339
  ...shardError !== void 0 ? { shardError } : {},
340
+ ...importTimeout !== void 0 ? { importTimeout } : {},
336
341
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
337
342
  warnings
338
343
  };
@@ -542,6 +547,7 @@ var RESOLVE_CHUNK = 500;
542
547
  var BATCH_MAX = 500;
543
548
  var DISCOVER_MAX = 500;
544
549
  var BATCH_DEFAULT = 500;
550
+ var SEND_CONCURRENCY = 4;
545
551
  var EXPECTED_MAX = 1e4;
546
552
  var KEY_MAX = 1024;
547
553
  var TITLE_MAX = 300;
@@ -1295,7 +1301,29 @@ async function startRun(passed, declared) {
1295
1301
  else await uploadBatched(jobs);
1296
1302
  uploadMs += Date.now() - started;
1297
1303
  }
1304
+ const sending = /* @__PURE__ */ new Set();
1305
+ let sendError;
1306
+ let answered = false;
1307
+ async function dispatch(work) {
1308
+ while (sending.size >= (answered ? SEND_CONCURRENCY : 1)) await Promise.race(sending);
1309
+ const task = work().catch((err) => {
1310
+ sendError ??= err;
1311
+ }).finally(() => sending.delete(task));
1312
+ sending.add(task);
1313
+ }
1314
+ async function settle() {
1315
+ while (sending.size > 0) await Promise.all([...sending]);
1316
+ if (sendError !== void 0) {
1317
+ const err = sendError;
1318
+ sendError = void 0;
1319
+ throw err;
1320
+ }
1321
+ }
1298
1322
  async function flush() {
1323
+ await flushBuffer();
1324
+ await settle();
1325
+ }
1326
+ async function flushBuffer() {
1299
1327
  if (buffer.length === 0) return;
1300
1328
  const pending = buffer;
1301
1329
  buffer = [];
@@ -1303,6 +1331,9 @@ async function startRun(passed, declared) {
1303
1331
  defer(pending);
1304
1332
  return;
1305
1333
  }
1334
+ await dispatch(() => deliver(pending));
1335
+ }
1336
+ async function deliver(pending) {
1306
1337
  await resolveKeys(pending.flatMap((p) => p.keys));
1307
1338
  const submissions = [];
1308
1339
  const unanswered = [];
@@ -1343,6 +1374,7 @@ async function startRun(passed, declared) {
1343
1374
  for (const k of told) stats[k] += counts[k];
1344
1375
  progress();
1345
1376
  await upload(out.ok ? out.body?.items : void 0, filesByKey);
1377
+ answered = true;
1346
1378
  continue;
1347
1379
  }
1348
1380
  if (out.ok) {
@@ -1417,7 +1449,7 @@ async function startRun(passed, declared) {
1417
1449
  },
1418
1450
  async add(result) {
1419
1451
  buffer.push({ ...result, keys: bound(result.keys) });
1420
- if (buffer.length >= batchSize) await flush();
1452
+ if (buffer.length >= batchSize) await flushBuffer();
1421
1453
  },
1422
1454
  flush,
1423
1455
  async finish(reason) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.25.0",
3
+ "version": "0.27.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",