@plune-ai/cli 0.30.1 → 0.31.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/dist/cli.cjs CHANGED
@@ -512,7 +512,7 @@ var init_retry = __esm({
512
512
  "use strict";
513
513
  init_cjs_shims();
514
514
  init_errors2();
515
- defaultSleep = (ms) => new Promise((resolve10) => setTimeout(resolve10, ms));
515
+ defaultSleep = (ms) => new Promise((resolve11) => setTimeout(resolve11, ms));
516
516
  }
517
517
  });
518
518
 
@@ -2458,7 +2458,7 @@ function parseRetryAfter(raw, now = Date.now()) {
2458
2458
  return Math.min(Math.max(seconds, 0) * 1e3, RETRY_AFTER_CAP_MS);
2459
2459
  }
2460
2460
  function nodeTransport(url, init, idleMs = IDLE_MS) {
2461
- return new Promise((resolve10, reject) => {
2461
+ return new Promise((resolve11, reject) => {
2462
2462
  const target = new URL(url);
2463
2463
  const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
2464
2464
  const agent = POOLS[target.protocol === "https:" ? "https:" : "http:"];
@@ -2469,7 +2469,7 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
2469
2469
  res.on("end", () => {
2470
2470
  const status = res.statusCode ?? 0;
2471
2471
  const text2 = Buffer.concat(chunks).toString("utf8");
2472
- resolve10({
2472
+ resolve11({
2473
2473
  ok: status >= 200 && status <= 299,
2474
2474
  status,
2475
2475
  headers: {
@@ -2488,12 +2488,12 @@ function nodeTransport(url, init, idleMs = IDLE_MS) {
2488
2488
  });
2489
2489
  }
2490
2490
  function nodePut(url, headers, source, idleMs) {
2491
- return new Promise((resolve10, reject) => {
2491
+ return new Promise((resolve11, reject) => {
2492
2492
  const target = new URL(url);
2493
2493
  const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
2494
2494
  const length = "body" in source ? source.body.length : source.size;
2495
2495
  let answered;
2496
- const settle = (err) => answered !== void 0 ? resolve10(answered) : reject(err);
2496
+ const settle = (err) => answered !== void 0 ? resolve11(answered) : reject(err);
2497
2497
  const req = send(
2498
2498
  target,
2499
2499
  { method: "PUT", headers: { ...withoutLength(headers), "content-length": String(length) } },
@@ -2502,7 +2502,7 @@ function nodePut(url, headers, source, idleMs) {
2502
2502
  answered = { status: res.statusCode ?? 0, retryAfter: Array.isArray(retry) ? retry[0] ?? null : retry ?? null };
2503
2503
  res.on("error", settle);
2504
2504
  res.on("close", () => {
2505
- resolve10(answered);
2505
+ resolve11(answered);
2506
2506
  req.destroy();
2507
2507
  });
2508
2508
  res.resume();
@@ -2565,7 +2565,7 @@ function createClient(opts) {
2565
2565
  }
2566
2566
  const base = opts.apiUrl.replace(/\/+$/, "");
2567
2567
  const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
2568
- async function send(method, path17, payload, retries = DEFAULT_RETRIES) {
2568
+ async function send(method, path18, payload, retries = DEFAULT_RETRIES) {
2569
2569
  let last = {
2570
2570
  kind: "unavailable",
2571
2571
  status: null,
@@ -2576,7 +2576,7 @@ function createClient(opts) {
2576
2576
  await respectPause(mine);
2577
2577
  let res;
2578
2578
  try {
2579
- res = await doFetch(`${base}${path17}`, {
2579
+ res = await doFetch(`${base}${path18}`, {
2580
2580
  method,
2581
2581
  headers: { "content-type": payload?.type ?? "application/json", authorization: `Bearer ${opts.token}` },
2582
2582
  // A DELETE carries none. Sending `"undefined"` as a body is what a naive
@@ -2615,10 +2615,10 @@ function createClient(opts) {
2615
2615
  return { ok: false, ...last };
2616
2616
  }
2617
2617
  return {
2618
- post: (path17, body, retries) => send("POST", path17, { body: JSON.stringify(body), type: "application/json" }, retries),
2619
- upload: (path17, bytes, contentType) => send("POST", path17, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
2620
- del: (path17) => send("DELETE", path17),
2621
- get: (path17) => send("GET", path17),
2618
+ post: (path18, body, retries) => send("POST", path18, { body: JSON.stringify(body), type: "application/json" }, retries),
2619
+ upload: (path18, bytes, contentType) => send("POST", path18, { body: bytes, type: contentType }, uploadRetries ?? DEFAULT_RETRIES),
2620
+ del: (path18) => send("DELETE", path18),
2621
+ get: (path18) => send("GET", path18),
2622
2622
  async put(url, headers, source) {
2623
2623
  const putRetries = uploadRetries ?? 3;
2624
2624
  let last = { ok: false, status: null, code: "network" };
@@ -2688,6 +2688,25 @@ function shardOf(env) {
2688
2688
  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.`
2689
2689
  };
2690
2690
  }
2691
+ function configurationOf(env) {
2692
+ const raw = value(env, "PLUNE_CONFIGURATION");
2693
+ if (raw === void 0) return {};
2694
+ const pairs = [];
2695
+ for (const pair of raw.split(";").map((s) => s.trim()).filter((s) => s !== "")) {
2696
+ const at = pair.indexOf("=");
2697
+ const name = at < 0 ? "" : pair.slice(0, at).trim();
2698
+ const label = at < 0 ? "" : pair.slice(at + 1).trim();
2699
+ const twice = pairs.find(([seen]) => seen.toLowerCase() === name.toLowerCase());
2700
+ const why = at < 0 ? 'has no "="' : name === "" ? "has no group name" : label === "" ? "has no value" : twice !== void 0 ? `names the group ${JSON.stringify(twice[0])} a second time` : void 0;
2701
+ if (why !== void 0) {
2702
+ return {
2703
+ error: `PLUNE_CONFIGURATION: ${JSON.stringify(pair.slice(0, 40))} ${why} - write it as "Group=Value;Group=Value" (for example "Browser=Chrome;Operating system=Windows") - nothing was sent.`
2704
+ };
2705
+ }
2706
+ pairs.push([name, label]);
2707
+ }
2708
+ return pairs.length === 0 ? {} : { configuration: Object.fromEntries(pairs) };
2709
+ }
2691
2710
  function value(env, name) {
2692
2711
  const raw = env[name]?.trim();
2693
2712
  return raw === void 0 || raw === "" ? void 0 : raw;
@@ -2732,6 +2751,7 @@ function readEnv(env = process.env) {
2732
2751
  const offerDiscovered = flag(env, "PLUNE_CREATE");
2733
2752
  const full = flag(env, "PLUNE_FULL_RUN");
2734
2753
  const { shard, error: shardError } = shardOf(env);
2754
+ const { configuration, error: configurationError } = configurationOf(env);
2735
2755
  return {
2736
2756
  config: {
2737
2757
  ...externalKey !== void 0 ? { externalKey } : {},
@@ -2744,6 +2764,7 @@ function readEnv(env = process.env) {
2744
2764
  ...title !== void 0 ? { title } : {},
2745
2765
  ...environment !== void 0 ? { environment } : {},
2746
2766
  ...labels !== void 0 ? { labels } : {},
2767
+ ...configuration !== void 0 ? { configuration } : {},
2747
2768
  // Only when set: `false` here would outrank a config file that asked for it, and an unset
2748
2769
  // variable is not an instruction.
2749
2770
  ...offerDiscovered ? { offerDiscovered } : {},
@@ -2757,6 +2778,7 @@ function readEnv(env = process.env) {
2757
2778
  keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
2758
2779
  ...shard !== void 0 ? { shard } : {},
2759
2780
  ...shardError !== void 0 ? { shardError } : {},
2781
+ ...configurationError !== void 0 ? { configurationError } : {},
2760
2782
  ...importTimeout !== void 0 ? { importTimeout } : {},
2761
2783
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
2762
2784
  warnings
@@ -2813,6 +2835,7 @@ function appendBatch(file, batch) {
2813
2835
  externalKey: batch.externalKey,
2814
2836
  ...batch.session !== void 0 ? { session: batch.session } : {},
2815
2837
  ...batch.meta !== void 0 ? { meta: batch.meta } : {},
2838
+ ...batch.configuration !== void 0 ? { configuration: batch.configuration } : {},
2816
2839
  results: batch.results
2817
2840
  };
2818
2841
  fs6.appendFileSync(file, `${JSON.stringify(line)}
@@ -2876,12 +2899,12 @@ function machineless(raw, repoRoot, home) {
2876
2899
  if (repoRoot === void 0) text2 = text2.replace(ANY_HOME, "~");
2877
2900
  return text2;
2878
2901
  }
2879
- function pathIn(path17) {
2880
- const parts = path17.split(/[\\/]+/);
2902
+ function pathIn(path18) {
2903
+ const parts = path18.split(/[\\/]+/);
2881
2904
  while (parts.length > 1 && parts.at(-1) === "") parts.pop();
2882
2905
  if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
2883
2906
  const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
2884
- const escaped = /^\/(?!\/)/.test(path17) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
2907
+ const escaped = /^\/(?!\/)/.test(path18) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
2885
2908
  const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
2886
2909
  return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
2887
2910
  }
@@ -2927,12 +2950,12 @@ function placeOf(error, testFile) {
2927
2950
  }
2928
2951
  function fileOf(raw) {
2929
2952
  if (!raw.startsWith("file://")) return raw;
2930
- let path17 = raw.slice("file://".length);
2953
+ let path18 = raw.slice("file://".length);
2931
2954
  try {
2932
- path17 = decodeURIComponent(path17);
2955
+ path18 = decodeURIComponent(path18);
2933
2956
  } catch {
2934
2957
  }
2935
- return /^\/[A-Za-z]:\//.test(path17) ? path17.slice(1) : path17;
2958
+ return /^\/[A-Za-z]:\//.test(path18) ? path18.slice(1) : path18;
2936
2959
  }
2937
2960
  function fromRoot(place, root) {
2938
2961
  if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
@@ -2972,14 +2995,14 @@ var init_failure_detail = __esm({
2972
2995
  FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
2973
2996
  FRAME_MAX = 8192;
2974
2997
  firstLine = (text2) => (0, import_node_util.stripVTControlCharacters)(text2).split("\n").map((line) => line.trim()).find((line) => line !== "");
2975
- normal = (path17) => import_node_path2.posix.normalize(path17.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
2998
+ normal = (path18) => import_node_path2.posix.normalize(path18.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
2976
2999
  }
2977
3000
  });
2978
3001
 
2979
3002
  // src/reporter-core/files-batch.ts
2980
- async function sha256File(path17) {
3003
+ async function sha256File(path18) {
2981
3004
  const hash = (0, import_node_crypto2.createHash)("sha256");
2982
- for await (const part of (0, import_node_fs4.createReadStream)(path17)) hash.update(part);
3005
+ for await (const part of (0, import_node_fs4.createReadStream)(path18)) hash.update(part);
2983
3006
  return hash.digest("hex");
2984
3007
  }
2985
3008
  var import_node_crypto2, import_node_fs4, FILES_BATCH_MAX, DIRECT_BATCH_BYTES, BATCH_PIPELINES, sha256Bytes, BUCKET_CODES;
@@ -3102,8 +3125,10 @@ function warnIfDropped(sent, run, log) {
3102
3125
  async function startRun(passed, declared2) {
3103
3126
  const env = readEnv();
3104
3127
  if (env.shardError !== void 0) throw new Error(env.shardError);
3128
+ if (env.configurationError !== void 0 && passed.configuration === void 0) throw new Error(env.configurationError);
3105
3129
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
3106
3130
  const cfg = { ...env.config, ...stated };
3131
+ const configuration = cfg.configuration !== void 0 && Object.keys(cfg.configuration).length > 0 ? cfg.configuration : void 0;
3107
3132
  const log = cfg.log ?? ((line) => void process.stderr.write(`${line}
3108
3133
  `));
3109
3134
  const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
@@ -3187,6 +3212,7 @@ async function startRun(passed, declared2) {
3187
3212
  externalKey,
3188
3213
  session: sessionMarker,
3189
3214
  ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
3215
+ ...configuration !== void 0 ? { configuration } : {},
3190
3216
  results: results.map(onDisk)
3191
3217
  });
3192
3218
  stats.deferred += results.length;
@@ -3211,7 +3237,7 @@ async function startRun(passed, declared2) {
3211
3237
  if (!offline) {
3212
3238
  if (expected !== void 0) await resolveKeys(expected.flat());
3213
3239
  const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
3214
- const configuration = stated2.length === 0 ? void 0 : {
3240
+ const configuration2 = stated2.length === 0 ? void 0 : {
3215
3241
  expected: stated2.flatMap((keys) => {
3216
3242
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
3217
3243
  if (typeof hit === "string") return [{ testCaseId: hit }];
@@ -3235,7 +3261,7 @@ async function startRun(passed, declared2) {
3235
3261
  kind: cfg.kind ?? "automated",
3236
3262
  ...externalKey !== null ? { externalKey } : {},
3237
3263
  ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
3238
- ...configuration !== void 0 ? { configuration } : {},
3264
+ ...configuration2 !== void 0 ? { configuration: configuration2 } : {},
3239
3265
  // Named even when nobody named it — the list has to read without opening a row. Only what
3240
3266
  // the caller DESCRIBED goes to `warnIfDropped` below: a title this side invented is not one
3241
3267
  // a person will miss, and a deployment older than the field must not be nagged on every run.
@@ -3243,7 +3269,7 @@ async function startRun(passed, declared2) {
3243
3269
  ...described
3244
3270
  };
3245
3271
  let start = await client.post("/v1/runs", request);
3246
- if (!start.ok && start.status === 413 && configuration !== void 0) {
3272
+ if (!start.ok && start.status === 413 && configuration2 !== void 0) {
3247
3273
  log(
3248
3274
  `plune: the platform refused the run's list of expected tests as too large (${start.detail || start.kind}) \u2014 starting without it: the results still land, but this run counts no tests that did not run, and claims no full run.`
3249
3275
  );
@@ -3403,7 +3429,7 @@ async function startRun(passed, declared2) {
3403
3429
  let free = uploadsAtOnce;
3404
3430
  const waiting = [];
3405
3431
  async function gated(work) {
3406
- if (free === 0) await new Promise((resolve10) => waiting.push(resolve10));
3432
+ if (free === 0) await new Promise((resolve11) => waiting.push(resolve11));
3407
3433
  else free -= 1;
3408
3434
  try {
3409
3435
  return await work();
@@ -3789,7 +3815,8 @@ async function startRun(passed, declared2) {
3789
3815
  }
3790
3816
  const out = await client.post(
3791
3817
  `/v1/runs/${runId}/results`,
3792
- { results: part }
3818
+ // The job's combination on every batch (plune-ai/plune#1251); a refusal of it is a 422 said below as it came.
3819
+ { results: part, ...configuration !== void 0 ? { configuration } : {} }
3793
3820
  );
3794
3821
  const counts = out.ok ? out.body?.counts : void 0;
3795
3822
  const told = COUNTED.filter((k) => typeof counts?.[k] === "number");
@@ -4121,14 +4148,14 @@ async function handleRunExec(options) {
4121
4148
  if (command === void 0) throw new RunCommandError("Nothing to run \u2014 pass a command after `--`.");
4122
4149
  const run = await handleRunStart(options);
4123
4150
  const spawnImpl = options.spawnImpl ?? import_node_child_process.spawn;
4124
- const code = await new Promise((resolve10) => {
4151
+ const code = await new Promise((resolve11) => {
4125
4152
  const child = spawnImpl(command, args, {
4126
4153
  stdio: "inherit",
4127
4154
  shell: process.platform === "win32",
4128
4155
  env: { ...process.env, PLUNE_RUN: run.externalKey, PLUNE_PROCEED: "1" }
4129
4156
  });
4130
- child.on("close", (exitCode) => resolve10(exitCode ?? 1));
4131
- child.on("error", () => resolve10(1));
4157
+ child.on("close", (exitCode) => resolve11(exitCode ?? 1));
4158
+ child.on("error", () => resolve11(1));
4132
4159
  });
4133
4160
  return handleRunFinish(run.id, options, code);
4134
4161
  }
@@ -4162,7 +4189,10 @@ async function handleRunReport(options = {}) {
4162
4189
  for (const line of group) {
4163
4190
  const ready = line.results.filter((r) => !isPending(r));
4164
4191
  if (line.runId !== null && ready.length > 0) {
4165
- const out = await client.post(`/v1/runs/${line.runId}/results`, { results: ready });
4192
+ const out = await client.post(`/v1/runs/${line.runId}/results`, {
4193
+ results: ready,
4194
+ ...line.configuration !== void 0 ? { configuration: line.configuration } : {}
4195
+ });
4166
4196
  if (out.ok) result.sent += ready.length;
4167
4197
  else {
4168
4198
  result.failed += ready.length;
@@ -4182,6 +4212,8 @@ async function handleRunReport(options = {}) {
4182
4212
  ...first.externalKey !== null ? { externalKey: first.externalKey } : {},
4183
4213
  // Where the run came from, as the reporter recorded it (plune#927) — never this machine's own.
4184
4214
  ...first.meta !== void 0 ? { meta: first.meta } : {},
4215
+ // The combination the line recorded, or none (`{}`) — never this machine's `PLUNE_CONFIGURATION` (plune-ai/plune#1251).
4216
+ configuration: first.configuration ?? {},
4185
4217
  // The replay does not know whether the run is complete, so it must not say that it is.
4186
4218
  log: write
4187
4219
  });
@@ -4323,6 +4355,85 @@ var init_run_result_schema = __esm({
4323
4355
  }
4324
4356
  });
4325
4357
 
4358
+ // src/cli/commands/job-reporter.ts
4359
+ var JOB_TIMEOUT_MS, JOB_SUBJECT_NAME_MAX, JOB_REASON_MAX, JobReporter;
4360
+ var init_job_reporter = __esm({
4361
+ "src/cli/commands/job-reporter.ts"() {
4362
+ "use strict";
4363
+ init_cjs_shims();
4364
+ JOB_TIMEOUT_MS = 5e3;
4365
+ JOB_SUBJECT_NAME_MAX = 200;
4366
+ JOB_REASON_MAX = 2e3;
4367
+ JobReporter = class {
4368
+ constructor(doFetch, base, token, kind) {
4369
+ this.doFetch = doFetch;
4370
+ this.base = base;
4371
+ this.token = token;
4372
+ this.kind = kind;
4373
+ }
4374
+ doFetch;
4375
+ base;
4376
+ token;
4377
+ kind;
4378
+ id;
4379
+ /** The upload's rows, the job's `total`; `null` while there are none to count. */
4380
+ size = null;
4381
+ /** The first report the platform did not take, for the CLI to say once. */
4382
+ problem;
4383
+ async send(method, route, body) {
4384
+ try {
4385
+ const res = await this.doFetch(`${this.base}/v1/jobs${route}`, {
4386
+ method,
4387
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
4388
+ body: JSON.stringify(body),
4389
+ signal: AbortSignal.timeout(JOB_TIMEOUT_MS)
4390
+ });
4391
+ const json = await res.json().catch(() => void 0);
4392
+ if (res.ok) return json ?? {};
4393
+ if (route === "" && (res.status === 404 || res.status === 405)) return void 0;
4394
+ this.problem ??= `HTTP ${res.status}${typeof json?.error === "string" ? ` \u2014 ${json.error}` : ""}`;
4395
+ } catch (err) {
4396
+ this.problem ??= err instanceof Error ? err.message : String(err);
4397
+ }
4398
+ return void 0;
4399
+ }
4400
+ async open(subjectName, rows) {
4401
+ this.size = Number.isInteger(rows) && rows > 0 ? rows : null;
4402
+ const name = subjectName.trim().slice(0, JOB_SUBJECT_NAME_MAX);
4403
+ const body = await this.send("POST", "", {
4404
+ kind: this.kind,
4405
+ ...name !== "" ? { subjectName: name } : {},
4406
+ ...this.size !== null ? { progress: { done: 0, total: this.size } } : {}
4407
+ });
4408
+ if (typeof body?.id === "string") this.id = body.id;
4409
+ }
4410
+ /**
4411
+ * The upload is in: every row is on the platform. `subjectId` is what the job works on, learnt from the upload's
4412
+ * answer (the run an ingest landed in): the platform takes it while the job names none (#1279).
4413
+ */
4414
+ async uploaded(at) {
4415
+ if (this.id === void 0) return;
4416
+ await this.send("PATCH", `/${encodeURIComponent(this.id)}/progress`, {
4417
+ done: this.size,
4418
+ total: this.size,
4419
+ ...at.cursor !== void 0 ? { cursor: at.cursor } : {},
4420
+ ...at.subjectId !== void 0 ? { subjectId: at.subjectId } : {}
4421
+ });
4422
+ }
4423
+ async finish(outcome) {
4424
+ if (this.id === void 0) return;
4425
+ const id = this.id;
4426
+ this.id = void 0;
4427
+ await this.send(
4428
+ "PATCH",
4429
+ `/${encodeURIComponent(id)}/state`,
4430
+ outcome.ok ? { state: "completed" } : { state: "failed", reason: outcome.reason.slice(0, JOB_REASON_MAX) }
4431
+ );
4432
+ }
4433
+ };
4434
+ }
4435
+ });
4436
+
4326
4437
  // src/cli/commands/sync.ts
4327
4438
  var sync_exports = {};
4328
4439
  __export(sync_exports, {
@@ -4375,11 +4486,12 @@ async function handleSync(deps = {}) {
4375
4486
  const payload = JSON.stringify(run);
4376
4487
  const idempotencyKey = (0, import_node_crypto4.createHash)("sha256").update(payload).digest("hex");
4377
4488
  const bearer = token;
4378
- const job = new JobReporter(doFetch, apiUrl, bearer);
4379
- await job.open(path9.basename(deps.file !== void 0 ? path9.resolve(cwd, deps.file) : cwd));
4489
+ const job = new JobReporter(doFetch, apiUrl, bearer, "sync");
4490
+ await job.open(path9.basename(deps.file !== void 0 ? path9.resolve(cwd, deps.file) : cwd), run.summary.total);
4380
4491
  try {
4381
4492
  const result = await upload();
4382
4493
  await job.finish({ ok: true });
4494
+ if (job.problem !== void 0) result.jobProblem = job.problem;
4383
4495
  return result;
4384
4496
  } catch (err) {
4385
4497
  await job.finish({ ok: false, reason: err instanceof Error ? err.message : "Sync failed." });
@@ -4408,7 +4520,7 @@ async function handleSync(deps = {}) {
4408
4520
  const body = await res.json().catch(() => ({}));
4409
4521
  const id = typeof body.id === "string" ? body.id : "";
4410
4522
  const result = { id, url: `${endpoint}/${id}` };
4411
- await job.report(deps.failOnFailures === true ? 80 : 95, id || void 0);
4523
+ await job.uploaded({ cursor: id || void 0 });
4412
4524
  if (deps.failOnFailures === true) {
4413
4525
  result.gate = gateExit(await readVerdict(doFetch, `${endpoint}/${id}/summary`, bearer));
4414
4526
  }
@@ -4435,7 +4547,7 @@ function reportSyncFailure(err, write) {
4435
4547
  }
4436
4548
  return null;
4437
4549
  }
4438
- var import_node_crypto4, fs8, path9, NotLoggedInError, TokenRejectedError, SyncFileError, SyncNetworkError, SyncHttpError, JOB_KIND, JOB_TIMEOUT_MS, JobReporter;
4550
+ var import_node_crypto4, fs8, path9, NotLoggedInError, TokenRejectedError, SyncFileError, SyncNetworkError, SyncHttpError;
4439
4551
  var init_sync = __esm({
4440
4552
  "src/cli/commands/sync.ts"() {
4441
4553
  "use strict";
@@ -4447,6 +4559,7 @@ var init_sync = __esm({
4447
4559
  init_api_url();
4448
4560
  init_credentials();
4449
4561
  init_gate();
4562
+ init_job_reporter();
4450
4563
  NotLoggedInError = class extends Error {
4451
4564
  constructor() {
4452
4565
  super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
@@ -4483,58 +4596,6 @@ var init_sync = __esm({
4483
4596
  }
4484
4597
  status;
4485
4598
  };
4486
- JOB_KIND = "cli-sync";
4487
- JOB_TIMEOUT_MS = 5e3;
4488
- JobReporter = class {
4489
- constructor(doFetch, base, token) {
4490
- this.doFetch = doFetch;
4491
- this.base = base;
4492
- this.token = token;
4493
- }
4494
- doFetch;
4495
- base;
4496
- token;
4497
- id;
4498
- async send(method, url, body) {
4499
- try {
4500
- const res = await this.doFetch(url, {
4501
- method,
4502
- headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
4503
- body: JSON.stringify(body),
4504
- signal: AbortSignal.timeout(JOB_TIMEOUT_MS)
4505
- });
4506
- const json = await res.json().catch(() => void 0);
4507
- return res.ok ? json ?? {} : void 0;
4508
- } catch {
4509
- return void 0;
4510
- }
4511
- }
4512
- async open(subjectName) {
4513
- const body = await this.send("POST", `${this.base}/v1/jobs`, {
4514
- kind: JOB_KIND,
4515
- subjectName,
4516
- progress: 0
4517
- });
4518
- if (typeof body?.id === "string") this.id = body.id;
4519
- }
4520
- async report(progress, cursor) {
4521
- if (this.id === void 0) return;
4522
- await this.send("PATCH", `${this.base}/v1/jobs/${encodeURIComponent(this.id)}`, {
4523
- progress,
4524
- ...cursor !== void 0 ? { cursor } : {}
4525
- });
4526
- }
4527
- async finish(outcome) {
4528
- if (this.id === void 0) return;
4529
- const id = this.id;
4530
- this.id = void 0;
4531
- await this.send(
4532
- "PATCH",
4533
- `${this.base}/v1/jobs/${encodeURIComponent(id)}`,
4534
- outcome.ok ? { status: "succeeded", progress: 100 } : { status: "failed", reason: outcome.reason }
4535
- );
4536
- }
4537
- };
4538
4599
  }
4539
4600
  });
4540
4601
 
@@ -4571,10 +4632,10 @@ function platformGet(deps) {
4571
4632
  const token = (deps.loadToken ?? loadToken)();
4572
4633
  if (token === null) throw new NotLoggedInError();
4573
4634
  const doFetch = deps.fetchImpl ?? fetch;
4574
- return async (path17) => {
4635
+ return async (path18) => {
4575
4636
  let res;
4576
4637
  try {
4577
- res = await doFetch(`${apiUrl}${path17}`, { headers: { authorization: `Bearer ${token}` } });
4638
+ res = await doFetch(`${apiUrl}${path18}`, { headers: { authorization: `Bearer ${token}` } });
4578
4639
  } catch {
4579
4640
  throw new SyncNetworkError(apiUrl);
4580
4641
  }
@@ -4915,9 +4976,9 @@ var init_attachments = __esm({
4915
4976
  });
4916
4977
 
4917
4978
  // src/importers/allure.ts
4918
- function isFolder(path17) {
4979
+ function isFolder(path18) {
4919
4980
  try {
4920
- return fs11.statSync(path17).isDirectory();
4981
+ return fs11.statSync(path18).isDirectory();
4921
4982
  } catch {
4922
4983
  return false;
4923
4984
  }
@@ -5425,10 +5486,10 @@ function attemptOf(result, file, ctx) {
5425
5486
  ...typeof at?.file === "string" && typeof at.line === "number" ? { location: { file: at.file, line: at.line, ...typeof at.column === "number" ? { column: at.column } : {} } } : {}
5426
5487
  })),
5427
5488
  steps: steps(result.steps),
5428
- attachments: (result.attachments ?? []).map(({ name, contentType, path: path17, body }) => ({
5489
+ attachments: (result.attachments ?? []).map(({ name, contentType, path: path18, body }) => ({
5429
5490
  name: name ?? "",
5430
5491
  ...contentType !== void 0 ? { contentType } : {},
5431
- ...path17 !== void 0 ? { path: path17 } : {},
5492
+ ...path18 !== void 0 ? { path: path18 } : {},
5432
5493
  // Decoded only where the platform would take it (plune-ai/plune#928): a report of screenshots kept as
5433
5494
  // bodies is megabytes of base64 that nothing here will read.
5434
5495
  ...typeof body === "string" && isTextType(contentType) ? { body: Buffer.from(body, "base64") } : {}
@@ -5615,7 +5676,7 @@ async function timeoutMessage(limit, runId, ours, options) {
5615
5676
  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.`;
5616
5677
  const wait = options.terminateWaitMs ?? 15e3;
5617
5678
  let timer;
5618
- const gaveUp = new Promise((resolve10) => timer = setTimeout(() => resolve10("gaveup"), wait));
5679
+ const gaveUp = new Promise((resolve11) => timer = setTimeout(() => resolve11("gaveup"), wait));
5619
5680
  const quiet = () => void 0;
5620
5681
  const closed = handleRunFinish(runId, { ...options, write: quiet, terminate: true, reason: `import timed out after ${limit} s` }).then(
5621
5682
  () => true,
@@ -5684,7 +5745,7 @@ async function handleRunImport(options) {
5684
5745
  else {
5685
5746
  sent.catch(() => void 0);
5686
5747
  let timer;
5687
- const late = new Promise((resolve10) => timer = setTimeout(() => resolve10("late"), limit * 1e3));
5748
+ const late = new Promise((resolve11) => timer = setTimeout(() => resolve11("late"), limit * 1e3));
5688
5749
  const first = await Promise.race([sent, late]).finally(() => clearTimeout(timer));
5689
5750
  if (first === "late") {
5690
5751
  throw new ImportTimeoutError(await timeoutMessage(limit, session?.runId ?? null, !env.keepOpen, { ...options, apiUrl, token }));
@@ -6503,28 +6564,42 @@ async function handleIngest(deps) {
6503
6564
  const payload = await readCairnRun(dir);
6504
6565
  const doFetch = deps.fetchImpl ?? fetch;
6505
6566
  const endpoint = `${apiUrl}/v1/ingest/cairn`;
6506
- let res;
6567
+ const job = new JobReporter(doFetch, apiUrl, token, "ingest");
6568
+ await job.open(path17.basename(path17.resolve(dir)), payload.cases.length);
6507
6569
  try {
6508
- res = await doFetch(endpoint, {
6509
- method: "POST",
6510
- headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
6511
- body: JSON.stringify(payload)
6512
- });
6513
- } catch {
6514
- throw new IngestNetworkError(apiUrl);
6570
+ const result = await upload();
6571
+ await job.uploaded({ subjectId: result.runId || void 0 });
6572
+ await job.finish({ ok: true });
6573
+ if (job.problem !== void 0) result.jobProblem = job.problem;
6574
+ return result;
6575
+ } catch (err) {
6576
+ await job.finish({ ok: false, reason: err instanceof Error && err.message.trim() || "Ingest failed." });
6577
+ throw err;
6515
6578
  }
6516
- if (!res.ok) {
6517
- const detail = await safeErrorDetail4(res);
6518
- if (res.status === 401) throw new IngestTokenRejectedError();
6519
- throw new IngestHttpError(res.status, detail);
6579
+ async function upload() {
6580
+ let res;
6581
+ try {
6582
+ res = await doFetch(endpoint, {
6583
+ method: "POST",
6584
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
6585
+ body: JSON.stringify(payload)
6586
+ });
6587
+ } catch {
6588
+ throw new IngestNetworkError(apiUrl);
6589
+ }
6590
+ if (!res.ok) {
6591
+ const detail = await safeErrorDetail4(res);
6592
+ if (res.status === 401) throw new IngestTokenRejectedError();
6593
+ throw new IngestHttpError(res.status, detail);
6594
+ }
6595
+ const body = await res.json().catch(() => ({}));
6596
+ return {
6597
+ runId: typeof body.runId === "string" ? body.runId : "",
6598
+ linked: typeof body.linked === "number" ? body.linked : 0,
6599
+ proposed: typeof body.proposed === "number" ? body.proposed : 0,
6600
+ skipped: typeof body.skipped === "number" ? body.skipped : 0
6601
+ };
6520
6602
  }
6521
- const body = await res.json().catch(() => ({}));
6522
- return {
6523
- runId: typeof body.runId === "string" ? body.runId : "",
6524
- linked: typeof body.linked === "number" ? body.linked : 0,
6525
- proposed: typeof body.proposed === "number" ? body.proposed : 0,
6526
- skipped: typeof body.skipped === "number" ? body.skipped : 0
6527
- };
6528
6603
  }
6529
6604
  function reportIngestFailure(err, write) {
6530
6605
  if (err instanceof IngestNotLoggedInError || err instanceof IngestTokenRejectedError || err instanceof CairnRunNotFoundError || err instanceof NoCairnRunsError || err instanceof CairnRunVersionError || err instanceof CairnRunPartialError || err instanceof CairnEvidenceAmbiguousError) {
@@ -6549,14 +6624,16 @@ function formatIngestResult(r) {
6549
6624
  return `${lines.join("\n")}
6550
6625
  `;
6551
6626
  }
6552
- var IngestNotLoggedInError, IngestTokenRejectedError, IngestNetworkError, IngestHttpError;
6627
+ var path17, IngestNotLoggedInError, IngestTokenRejectedError, IngestNetworkError, IngestHttpError;
6553
6628
  var init_ingest = __esm({
6554
6629
  "src/cli/commands/ingest.ts"() {
6555
6630
  "use strict";
6556
6631
  init_cjs_shims();
6632
+ path17 = __toESM(require("path"), 1);
6557
6633
  init_api_url();
6558
6634
  init_credentials();
6559
6635
  init_cairn_artifact();
6636
+ init_job_reporter();
6560
6637
  IngestNotLoggedInError = class extends Error {
6561
6638
  constructor() {
6562
6639
  super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
@@ -7630,10 +7707,10 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7630
7707
  const { handleLogin: handleLogin2, reportLoginFailure: reportLoginFailure2 } = await Promise.resolve().then(() => (init_login(), login_exports));
7631
7708
  try {
7632
7709
  const token = options.token ?? await readTokenFromStdin();
7633
- const { path: path17, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
7710
+ const { path: path18, verified } = await handleLogin2({ token, skipVerify: options.skipVerify });
7634
7711
  process.stdout.write(
7635
- verified ? `Logged in. Token checked against the API and saved to ${path17}
7636
- ` : `Token saved to ${path17} WITHOUT being checked (--skip-verify).
7712
+ verified ? `Logged in. Token checked against the API and saved to ${path18}
7713
+ ` : `Token saved to ${path18} WITHOUT being checked (--skip-verify).
7637
7714
  `
7638
7715
  );
7639
7716
  } catch (err) {
@@ -7650,9 +7727,9 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7650
7727
  const verbose = command.optsWithGlobals().verbose === true;
7651
7728
  const { handleLogout: handleLogout2 } = await Promise.resolve().then(() => (init_logout(), logout_exports));
7652
7729
  try {
7653
- const { removed, path: path17 } = handleLogout2();
7730
+ const { removed, path: path18 } = handleLogout2();
7654
7731
  process.stdout.write(
7655
- removed ? `Logged out. Removed ${path17}
7732
+ removed ? `Logged out. Removed ${path18}
7656
7733
  ` : "Not logged in \u2014 nothing to remove.\n"
7657
7734
  );
7658
7735
  } catch (err) {
@@ -7665,13 +7742,17 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7665
7742
  const { handleSync: handleSync2, reportSyncFailure: reportSyncFailure2 } = await Promise.resolve().then(() => (init_sync(), sync_exports));
7666
7743
  const cwd = globals.config !== void 0 ? (0, import_node_path6.dirname)((0, import_node_path6.resolve)(globals.config)) : process.cwd();
7667
7744
  try {
7668
- const { id, url, gate } = await handleSync2({
7745
+ const { id, url, gate, jobProblem } = await handleSync2({
7669
7746
  cwd,
7670
7747
  ...options.file !== void 0 ? { file: options.file } : {},
7671
7748
  ...options.failOnFailures ? { failOnFailures: true } : {}
7672
7749
  });
7673
7750
  process.stdout.write(`Synced run ${id} \u2192 ${url}
7674
7751
  `);
7752
+ if (jobProblem !== void 0) {
7753
+ process.stderr.write(`plune: could not report this sync to the job queue (${jobProblem}) \u2014 the run is uploaded.
7754
+ `);
7755
+ }
7675
7756
  if (gate !== void 0) {
7676
7757
  process.stdout.write(`${gate.line}
7677
7758
  `);
@@ -7692,9 +7773,12 @@ ${missed.map((m) => ` - ${m}`).join("\n")}
7692
7773
  const verbose = globals.verbose === true;
7693
7774
  const { handleIngest: handleIngest2, reportIngestFailure: reportIngestFailure2, formatIngestResult: formatIngestResult2 } = await Promise.resolve().then(() => (init_ingest(), ingest_exports));
7694
7775
  try {
7695
- process.stdout.write(
7696
- formatIngestResult2(await handleIngest2({ ...dir !== void 0 ? { dir } : {} }))
7697
- );
7776
+ const result = await handleIngest2({ ...dir !== void 0 ? { dir } : {} });
7777
+ process.stdout.write(formatIngestResult2(result));
7778
+ if (result.jobProblem !== void 0) {
7779
+ process.stderr.write(`plune: could not report this ingest to the job queue (${result.jobProblem}) \u2014 the run is recorded.
7780
+ `);
7781
+ }
7698
7782
  } catch (err) {
7699
7783
  const code = reportIngestFailure2(err, (s) => process.stderr.write(s));
7700
7784
  if (code !== null) {