@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/index.cjs CHANGED
@@ -375,7 +375,7 @@ function normalizeProviderError(err, secret, envVar) {
375
375
  }
376
376
 
377
377
  // src/providers/retry.ts
378
- var defaultSleep = (ms) => new Promise((resolve5) => setTimeout(resolve5, ms));
378
+ var defaultSleep = (ms) => new Promise((resolve6) => setTimeout(resolve6, ms));
379
379
  function retryAfterMs(err) {
380
380
  if (typeof err !== "object" || err === null || !("headers" in err)) return void 0;
381
381
  const raw = readHeader(err.headers, "retry-after");
@@ -1855,6 +1855,78 @@ function gateExit(verdict, runnerCode) {
1855
1855
  return n("failed") + n("broken") > 0 || notRun > 0 ? { code: 1, line: `plune: gate failed \u2014 ${said}` } : { code: 0, line: `plune: gate passed \u2014 ${said}` };
1856
1856
  }
1857
1857
 
1858
+ // src/cli/commands/job-reporter.ts
1859
+ var JOB_TIMEOUT_MS = 5e3;
1860
+ var JOB_SUBJECT_NAME_MAX = 200;
1861
+ var JOB_REASON_MAX = 2e3;
1862
+ var JobReporter = class {
1863
+ constructor(doFetch, base, token, kind) {
1864
+ this.doFetch = doFetch;
1865
+ this.base = base;
1866
+ this.token = token;
1867
+ this.kind = kind;
1868
+ }
1869
+ doFetch;
1870
+ base;
1871
+ token;
1872
+ kind;
1873
+ id;
1874
+ /** The upload's rows, the job's `total`; `null` while there are none to count. */
1875
+ size = null;
1876
+ /** The first report the platform did not take, for the CLI to say once. */
1877
+ problem;
1878
+ async send(method, route, body) {
1879
+ try {
1880
+ const res = await this.doFetch(`${this.base}/v1/jobs${route}`, {
1881
+ method,
1882
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
1883
+ body: JSON.stringify(body),
1884
+ signal: AbortSignal.timeout(JOB_TIMEOUT_MS)
1885
+ });
1886
+ const json = await res.json().catch(() => void 0);
1887
+ if (res.ok) return json ?? {};
1888
+ if (route === "" && (res.status === 404 || res.status === 405)) return void 0;
1889
+ this.problem ??= `HTTP ${res.status}${typeof json?.error === "string" ? ` \u2014 ${json.error}` : ""}`;
1890
+ } catch (err) {
1891
+ this.problem ??= err instanceof Error ? err.message : String(err);
1892
+ }
1893
+ return void 0;
1894
+ }
1895
+ async open(subjectName, rows) {
1896
+ this.size = Number.isInteger(rows) && rows > 0 ? rows : null;
1897
+ const name = subjectName.trim().slice(0, JOB_SUBJECT_NAME_MAX);
1898
+ const body = await this.send("POST", "", {
1899
+ kind: this.kind,
1900
+ ...name !== "" ? { subjectName: name } : {},
1901
+ ...this.size !== null ? { progress: { done: 0, total: this.size } } : {}
1902
+ });
1903
+ if (typeof body?.id === "string") this.id = body.id;
1904
+ }
1905
+ /**
1906
+ * The upload is in: every row is on the platform. `subjectId` is what the job works on, learnt from the upload's
1907
+ * answer (the run an ingest landed in): the platform takes it while the job names none (#1279).
1908
+ */
1909
+ async uploaded(at) {
1910
+ if (this.id === void 0) return;
1911
+ await this.send("PATCH", `/${encodeURIComponent(this.id)}/progress`, {
1912
+ done: this.size,
1913
+ total: this.size,
1914
+ ...at.cursor !== void 0 ? { cursor: at.cursor } : {},
1915
+ ...at.subjectId !== void 0 ? { subjectId: at.subjectId } : {}
1916
+ });
1917
+ }
1918
+ async finish(outcome) {
1919
+ if (this.id === void 0) return;
1920
+ const id = this.id;
1921
+ this.id = void 0;
1922
+ await this.send(
1923
+ "PATCH",
1924
+ `/${encodeURIComponent(id)}/state`,
1925
+ outcome.ok ? { state: "completed" } : { state: "failed", reason: outcome.reason.slice(0, JOB_REASON_MAX) }
1926
+ );
1927
+ }
1928
+ };
1929
+
1858
1930
  // src/cli/commands/sync.ts
1859
1931
  var NotLoggedInError = class extends Error {
1860
1932
  constructor() {
@@ -1892,58 +1964,6 @@ var SyncHttpError = class extends Error {
1892
1964
  }
1893
1965
  status;
1894
1966
  };
1895
- var JOB_KIND = "cli-sync";
1896
- var JOB_TIMEOUT_MS = 5e3;
1897
- var JobReporter = class {
1898
- constructor(doFetch, base, token) {
1899
- this.doFetch = doFetch;
1900
- this.base = base;
1901
- this.token = token;
1902
- }
1903
- doFetch;
1904
- base;
1905
- token;
1906
- id;
1907
- async send(method, url, body) {
1908
- try {
1909
- const res = await this.doFetch(url, {
1910
- method,
1911
- headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
1912
- body: JSON.stringify(body),
1913
- signal: AbortSignal.timeout(JOB_TIMEOUT_MS)
1914
- });
1915
- const json = await res.json().catch(() => void 0);
1916
- return res.ok ? json ?? {} : void 0;
1917
- } catch {
1918
- return void 0;
1919
- }
1920
- }
1921
- async open(subjectName) {
1922
- const body = await this.send("POST", `${this.base}/v1/jobs`, {
1923
- kind: JOB_KIND,
1924
- subjectName,
1925
- progress: 0
1926
- });
1927
- if (typeof body?.id === "string") this.id = body.id;
1928
- }
1929
- async report(progress, cursor) {
1930
- if (this.id === void 0) return;
1931
- await this.send("PATCH", `${this.base}/v1/jobs/${encodeURIComponent(this.id)}`, {
1932
- progress,
1933
- ...cursor !== void 0 ? { cursor } : {}
1934
- });
1935
- }
1936
- async finish(outcome) {
1937
- if (this.id === void 0) return;
1938
- const id = this.id;
1939
- this.id = void 0;
1940
- await this.send(
1941
- "PATCH",
1942
- `${this.base}/v1/jobs/${encodeURIComponent(id)}`,
1943
- outcome.ok ? { status: "succeeded", progress: 100 } : { status: "failed", reason: outcome.reason }
1944
- );
1945
- }
1946
- };
1947
1967
  function readRun(cwd, file) {
1948
1968
  const target = file !== void 0 ? path5.resolve(cwd, file) : path5.join(cwd, ".plune", "last-run.json");
1949
1969
  let raw;
@@ -1985,11 +2005,12 @@ async function handleSync(deps = {}) {
1985
2005
  const payload = JSON.stringify(run);
1986
2006
  const idempotencyKey = (0, import_node_crypto2.createHash)("sha256").update(payload).digest("hex");
1987
2007
  const bearer = token;
1988
- const job = new JobReporter(doFetch, apiUrl, bearer);
1989
- await job.open(path5.basename(deps.file !== void 0 ? path5.resolve(cwd, deps.file) : cwd));
2008
+ const job = new JobReporter(doFetch, apiUrl, bearer, "sync");
2009
+ await job.open(path5.basename(deps.file !== void 0 ? path5.resolve(cwd, deps.file) : cwd), run.summary.total);
1990
2010
  try {
1991
2011
  const result = await upload();
1992
2012
  await job.finish({ ok: true });
2013
+ if (job.problem !== void 0) result.jobProblem = job.problem;
1993
2014
  return result;
1994
2015
  } catch (err) {
1995
2016
  await job.finish({ ok: false, reason: err instanceof Error ? err.message : "Sync failed." });
@@ -2018,7 +2039,7 @@ async function handleSync(deps = {}) {
2018
2039
  const body = await res.json().catch(() => ({}));
2019
2040
  const id = typeof body.id === "string" ? body.id : "";
2020
2041
  const result = { id, url: `${endpoint}/${id}` };
2021
- await job.report(deps.failOnFailures === true ? 80 : 95, id || void 0);
2042
+ await job.uploaded({ cursor: id || void 0 });
2022
2043
  if (deps.failOnFailures === true) {
2023
2044
  result.gate = gateExit(await readVerdict(doFetch, `${endpoint}/${id}/summary`, bearer));
2024
2045
  }
@@ -2046,6 +2067,9 @@ function reportSyncFailure(err, write) {
2046
2067
  return null;
2047
2068
  }
2048
2069
 
2070
+ // src/cli/commands/ingest.ts
2071
+ var path7 = __toESM(require("path"), 1);
2072
+
2049
2073
  // src/cli/commands/cairn-artifact.ts
2050
2074
  var fs7 = __toESM(require("fs/promises"), 1);
2051
2075
  var path6 = __toESM(require("path"), 1);
@@ -2240,28 +2264,42 @@ async function handleIngest(deps) {
2240
2264
  const payload = await readCairnRun(dir);
2241
2265
  const doFetch = deps.fetchImpl ?? fetch;
2242
2266
  const endpoint = `${apiUrl}/v1/ingest/cairn`;
2243
- let res;
2267
+ const job = new JobReporter(doFetch, apiUrl, token, "ingest");
2268
+ await job.open(path7.basename(path7.resolve(dir)), payload.cases.length);
2244
2269
  try {
2245
- res = await doFetch(endpoint, {
2246
- method: "POST",
2247
- headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
2248
- body: JSON.stringify(payload)
2249
- });
2250
- } catch {
2251
- throw new IngestNetworkError(apiUrl);
2270
+ const result = await upload();
2271
+ await job.uploaded({ subjectId: result.runId || void 0 });
2272
+ await job.finish({ ok: true });
2273
+ if (job.problem !== void 0) result.jobProblem = job.problem;
2274
+ return result;
2275
+ } catch (err) {
2276
+ await job.finish({ ok: false, reason: err instanceof Error && err.message.trim() || "Ingest failed." });
2277
+ throw err;
2252
2278
  }
2253
- if (!res.ok) {
2254
- const detail = await safeErrorDetail2(res);
2255
- if (res.status === 401) throw new IngestTokenRejectedError();
2256
- throw new IngestHttpError(res.status, detail);
2279
+ async function upload() {
2280
+ let res;
2281
+ try {
2282
+ res = await doFetch(endpoint, {
2283
+ method: "POST",
2284
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
2285
+ body: JSON.stringify(payload)
2286
+ });
2287
+ } catch {
2288
+ throw new IngestNetworkError(apiUrl);
2289
+ }
2290
+ if (!res.ok) {
2291
+ const detail = await safeErrorDetail2(res);
2292
+ if (res.status === 401) throw new IngestTokenRejectedError();
2293
+ throw new IngestHttpError(res.status, detail);
2294
+ }
2295
+ const body = await res.json().catch(() => ({}));
2296
+ return {
2297
+ runId: typeof body.runId === "string" ? body.runId : "",
2298
+ linked: typeof body.linked === "number" ? body.linked : 0,
2299
+ proposed: typeof body.proposed === "number" ? body.proposed : 0,
2300
+ skipped: typeof body.skipped === "number" ? body.skipped : 0
2301
+ };
2257
2302
  }
2258
- const body = await res.json().catch(() => ({}));
2259
- return {
2260
- runId: typeof body.runId === "string" ? body.runId : "",
2261
- linked: typeof body.linked === "number" ? body.linked : 0,
2262
- proposed: typeof body.proposed === "number" ? body.proposed : 0,
2263
- skipped: typeof body.skipped === "number" ? body.skipped : 0
2264
- };
2265
2303
  }
2266
2304
  function reportIngestFailure(err, write) {
2267
2305
  if (err instanceof IngestNotLoggedInError || err instanceof IngestTokenRejectedError || err instanceof CairnRunNotFoundError || err instanceof NoCairnRunsError || err instanceof CairnRunVersionError || err instanceof CairnRunPartialError || err instanceof CairnEvidenceAmbiguousError) {
package/dist/index.d.cts CHANGED
@@ -364,6 +364,8 @@ interface SyncResult {
364
364
  code: number;
365
365
  line: string;
366
366
  };
367
+ /** Why the platform's job queue did not take this sync's report: the first refusal or error. The upload went in. */
368
+ jobProblem?: string;
367
369
  }
368
370
  /**
369
371
  * Upload the latest (or `--file`) local run to `POST {apiUrl}/v1/runs` with the stored Bearer token.
@@ -688,6 +690,8 @@ interface IngestResult {
688
690
  linked: number;
689
691
  proposed: number;
690
692
  skipped: number;
693
+ /** Why the platform's job queue did not take this ingest's report: the first refusal or error. The run is recorded. */
694
+ jobProblem?: string;
691
695
  }
692
696
  declare function handleIngest(deps: IngestDeps): Promise<IngestResult>;
693
697
  /**
package/dist/index.d.ts CHANGED
@@ -364,6 +364,8 @@ interface SyncResult {
364
364
  code: number;
365
365
  line: string;
366
366
  };
367
+ /** Why the platform's job queue did not take this sync's report: the first refusal or error. The upload went in. */
368
+ jobProblem?: string;
367
369
  }
368
370
  /**
369
371
  * Upload the latest (or `--file`) local run to `POST {apiUrl}/v1/runs` with the stored Bearer token.
@@ -688,6 +690,8 @@ interface IngestResult {
688
690
  linked: number;
689
691
  proposed: number;
690
692
  skipped: number;
693
+ /** Why the platform's job queue did not take this ingest's report: the first refusal or error. The run is recorded. */
694
+ jobProblem?: string;
691
695
  }
692
696
  declare function handleIngest(deps: IngestDeps): Promise<IngestResult>;
693
697
  /**
package/dist/index.js CHANGED
@@ -315,7 +315,7 @@ function normalizeProviderError(err, secret, envVar) {
315
315
  }
316
316
 
317
317
  // src/providers/retry.ts
318
- var defaultSleep = (ms) => new Promise((resolve5) => setTimeout(resolve5, ms));
318
+ var defaultSleep = (ms) => new Promise((resolve6) => setTimeout(resolve6, ms));
319
319
  function retryAfterMs(err) {
320
320
  if (typeof err !== "object" || err === null || !("headers" in err)) return void 0;
321
321
  const raw = readHeader(err.headers, "retry-after");
@@ -1795,6 +1795,78 @@ function gateExit(verdict, runnerCode) {
1795
1795
  return n("failed") + n("broken") > 0 || notRun > 0 ? { code: 1, line: `plune: gate failed \u2014 ${said}` } : { code: 0, line: `plune: gate passed \u2014 ${said}` };
1796
1796
  }
1797
1797
 
1798
+ // src/cli/commands/job-reporter.ts
1799
+ var JOB_TIMEOUT_MS = 5e3;
1800
+ var JOB_SUBJECT_NAME_MAX = 200;
1801
+ var JOB_REASON_MAX = 2e3;
1802
+ var JobReporter = class {
1803
+ constructor(doFetch, base, token, kind) {
1804
+ this.doFetch = doFetch;
1805
+ this.base = base;
1806
+ this.token = token;
1807
+ this.kind = kind;
1808
+ }
1809
+ doFetch;
1810
+ base;
1811
+ token;
1812
+ kind;
1813
+ id;
1814
+ /** The upload's rows, the job's `total`; `null` while there are none to count. */
1815
+ size = null;
1816
+ /** The first report the platform did not take, for the CLI to say once. */
1817
+ problem;
1818
+ async send(method, route, body) {
1819
+ try {
1820
+ const res = await this.doFetch(`${this.base}/v1/jobs${route}`, {
1821
+ method,
1822
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
1823
+ body: JSON.stringify(body),
1824
+ signal: AbortSignal.timeout(JOB_TIMEOUT_MS)
1825
+ });
1826
+ const json = await res.json().catch(() => void 0);
1827
+ if (res.ok) return json ?? {};
1828
+ if (route === "" && (res.status === 404 || res.status === 405)) return void 0;
1829
+ this.problem ??= `HTTP ${res.status}${typeof json?.error === "string" ? ` \u2014 ${json.error}` : ""}`;
1830
+ } catch (err) {
1831
+ this.problem ??= err instanceof Error ? err.message : String(err);
1832
+ }
1833
+ return void 0;
1834
+ }
1835
+ async open(subjectName, rows) {
1836
+ this.size = Number.isInteger(rows) && rows > 0 ? rows : null;
1837
+ const name = subjectName.trim().slice(0, JOB_SUBJECT_NAME_MAX);
1838
+ const body = await this.send("POST", "", {
1839
+ kind: this.kind,
1840
+ ...name !== "" ? { subjectName: name } : {},
1841
+ ...this.size !== null ? { progress: { done: 0, total: this.size } } : {}
1842
+ });
1843
+ if (typeof body?.id === "string") this.id = body.id;
1844
+ }
1845
+ /**
1846
+ * The upload is in: every row is on the platform. `subjectId` is what the job works on, learnt from the upload's
1847
+ * answer (the run an ingest landed in): the platform takes it while the job names none (#1279).
1848
+ */
1849
+ async uploaded(at) {
1850
+ if (this.id === void 0) return;
1851
+ await this.send("PATCH", `/${encodeURIComponent(this.id)}/progress`, {
1852
+ done: this.size,
1853
+ total: this.size,
1854
+ ...at.cursor !== void 0 ? { cursor: at.cursor } : {},
1855
+ ...at.subjectId !== void 0 ? { subjectId: at.subjectId } : {}
1856
+ });
1857
+ }
1858
+ async finish(outcome) {
1859
+ if (this.id === void 0) return;
1860
+ const id = this.id;
1861
+ this.id = void 0;
1862
+ await this.send(
1863
+ "PATCH",
1864
+ `/${encodeURIComponent(id)}/state`,
1865
+ outcome.ok ? { state: "completed" } : { state: "failed", reason: outcome.reason.slice(0, JOB_REASON_MAX) }
1866
+ );
1867
+ }
1868
+ };
1869
+
1798
1870
  // src/cli/commands/sync.ts
1799
1871
  var NotLoggedInError = class extends Error {
1800
1872
  constructor() {
@@ -1832,58 +1904,6 @@ var SyncHttpError = class extends Error {
1832
1904
  }
1833
1905
  status;
1834
1906
  };
1835
- var JOB_KIND = "cli-sync";
1836
- var JOB_TIMEOUT_MS = 5e3;
1837
- var JobReporter = class {
1838
- constructor(doFetch, base, token) {
1839
- this.doFetch = doFetch;
1840
- this.base = base;
1841
- this.token = token;
1842
- }
1843
- doFetch;
1844
- base;
1845
- token;
1846
- id;
1847
- async send(method, url, body) {
1848
- try {
1849
- const res = await this.doFetch(url, {
1850
- method,
1851
- headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
1852
- body: JSON.stringify(body),
1853
- signal: AbortSignal.timeout(JOB_TIMEOUT_MS)
1854
- });
1855
- const json = await res.json().catch(() => void 0);
1856
- return res.ok ? json ?? {} : void 0;
1857
- } catch {
1858
- return void 0;
1859
- }
1860
- }
1861
- async open(subjectName) {
1862
- const body = await this.send("POST", `${this.base}/v1/jobs`, {
1863
- kind: JOB_KIND,
1864
- subjectName,
1865
- progress: 0
1866
- });
1867
- if (typeof body?.id === "string") this.id = body.id;
1868
- }
1869
- async report(progress, cursor) {
1870
- if (this.id === void 0) return;
1871
- await this.send("PATCH", `${this.base}/v1/jobs/${encodeURIComponent(this.id)}`, {
1872
- progress,
1873
- ...cursor !== void 0 ? { cursor } : {}
1874
- });
1875
- }
1876
- async finish(outcome) {
1877
- if (this.id === void 0) return;
1878
- const id = this.id;
1879
- this.id = void 0;
1880
- await this.send(
1881
- "PATCH",
1882
- `${this.base}/v1/jobs/${encodeURIComponent(id)}`,
1883
- outcome.ok ? { status: "succeeded", progress: 100 } : { status: "failed", reason: outcome.reason }
1884
- );
1885
- }
1886
- };
1887
1907
  function readRun(cwd, file) {
1888
1908
  const target = file !== void 0 ? path5.resolve(cwd, file) : path5.join(cwd, ".plune", "last-run.json");
1889
1909
  let raw;
@@ -1925,11 +1945,12 @@ async function handleSync(deps = {}) {
1925
1945
  const payload = JSON.stringify(run);
1926
1946
  const idempotencyKey = createHash2("sha256").update(payload).digest("hex");
1927
1947
  const bearer = token;
1928
- const job = new JobReporter(doFetch, apiUrl, bearer);
1929
- await job.open(path5.basename(deps.file !== void 0 ? path5.resolve(cwd, deps.file) : cwd));
1948
+ const job = new JobReporter(doFetch, apiUrl, bearer, "sync");
1949
+ await job.open(path5.basename(deps.file !== void 0 ? path5.resolve(cwd, deps.file) : cwd), run.summary.total);
1930
1950
  try {
1931
1951
  const result = await upload();
1932
1952
  await job.finish({ ok: true });
1953
+ if (job.problem !== void 0) result.jobProblem = job.problem;
1933
1954
  return result;
1934
1955
  } catch (err) {
1935
1956
  await job.finish({ ok: false, reason: err instanceof Error ? err.message : "Sync failed." });
@@ -1958,7 +1979,7 @@ async function handleSync(deps = {}) {
1958
1979
  const body = await res.json().catch(() => ({}));
1959
1980
  const id = typeof body.id === "string" ? body.id : "";
1960
1981
  const result = { id, url: `${endpoint}/${id}` };
1961
- await job.report(deps.failOnFailures === true ? 80 : 95, id || void 0);
1982
+ await job.uploaded({ cursor: id || void 0 });
1962
1983
  if (deps.failOnFailures === true) {
1963
1984
  result.gate = gateExit(await readVerdict(doFetch, `${endpoint}/${id}/summary`, bearer));
1964
1985
  }
@@ -1986,6 +2007,9 @@ function reportSyncFailure(err, write) {
1986
2007
  return null;
1987
2008
  }
1988
2009
 
2010
+ // src/cli/commands/ingest.ts
2011
+ import * as path7 from "path";
2012
+
1989
2013
  // src/cli/commands/cairn-artifact.ts
1990
2014
  import * as fs7 from "fs/promises";
1991
2015
  import * as path6 from "path";
@@ -2180,28 +2204,42 @@ async function handleIngest(deps) {
2180
2204
  const payload = await readCairnRun(dir);
2181
2205
  const doFetch = deps.fetchImpl ?? fetch;
2182
2206
  const endpoint = `${apiUrl}/v1/ingest/cairn`;
2183
- let res;
2207
+ const job = new JobReporter(doFetch, apiUrl, token, "ingest");
2208
+ await job.open(path7.basename(path7.resolve(dir)), payload.cases.length);
2184
2209
  try {
2185
- res = await doFetch(endpoint, {
2186
- method: "POST",
2187
- headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
2188
- body: JSON.stringify(payload)
2189
- });
2190
- } catch {
2191
- throw new IngestNetworkError(apiUrl);
2210
+ const result = await upload();
2211
+ await job.uploaded({ subjectId: result.runId || void 0 });
2212
+ await job.finish({ ok: true });
2213
+ if (job.problem !== void 0) result.jobProblem = job.problem;
2214
+ return result;
2215
+ } catch (err) {
2216
+ await job.finish({ ok: false, reason: err instanceof Error && err.message.trim() || "Ingest failed." });
2217
+ throw err;
2192
2218
  }
2193
- if (!res.ok) {
2194
- const detail = await safeErrorDetail2(res);
2195
- if (res.status === 401) throw new IngestTokenRejectedError();
2196
- throw new IngestHttpError(res.status, detail);
2219
+ async function upload() {
2220
+ let res;
2221
+ try {
2222
+ res = await doFetch(endpoint, {
2223
+ method: "POST",
2224
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
2225
+ body: JSON.stringify(payload)
2226
+ });
2227
+ } catch {
2228
+ throw new IngestNetworkError(apiUrl);
2229
+ }
2230
+ if (!res.ok) {
2231
+ const detail = await safeErrorDetail2(res);
2232
+ if (res.status === 401) throw new IngestTokenRejectedError();
2233
+ throw new IngestHttpError(res.status, detail);
2234
+ }
2235
+ const body = await res.json().catch(() => ({}));
2236
+ return {
2237
+ runId: typeof body.runId === "string" ? body.runId : "",
2238
+ linked: typeof body.linked === "number" ? body.linked : 0,
2239
+ proposed: typeof body.proposed === "number" ? body.proposed : 0,
2240
+ skipped: typeof body.skipped === "number" ? body.skipped : 0
2241
+ };
2197
2242
  }
2198
- const body = await res.json().catch(() => ({}));
2199
- return {
2200
- runId: typeof body.runId === "string" ? body.runId : "",
2201
- linked: typeof body.linked === "number" ? body.linked : 0,
2202
- proposed: typeof body.proposed === "number" ? body.proposed : 0,
2203
- skipped: typeof body.skipped === "number" ? body.skipped : 0
2204
- };
2205
2243
  }
2206
2244
  function reportIngestFailure(err, write) {
2207
2245
  if (err instanceof IngestNotLoggedInError || err instanceof IngestTokenRejectedError || err instanceof CairnRunNotFoundError || err instanceof NoCairnRunsError || err instanceof CairnRunVersionError || err instanceof CairnRunPartialError || err instanceof CairnEvidenceAmbiguousError) {
@@ -315,6 +315,25 @@ function shardOf(env) {
315
315
  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.`
316
316
  };
317
317
  }
318
+ function configurationOf(env) {
319
+ const raw = value(env, "PLUNE_CONFIGURATION");
320
+ if (raw === void 0) return {};
321
+ const pairs = [];
322
+ for (const pair of raw.split(";").map((s) => s.trim()).filter((s) => s !== "")) {
323
+ const at = pair.indexOf("=");
324
+ const name = at < 0 ? "" : pair.slice(0, at).trim();
325
+ const label = at < 0 ? "" : pair.slice(at + 1).trim();
326
+ const twice = pairs.find(([seen]) => seen.toLowerCase() === name.toLowerCase());
327
+ 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;
328
+ if (why !== void 0) {
329
+ return {
330
+ 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.`
331
+ };
332
+ }
333
+ pairs.push([name, label]);
334
+ }
335
+ return pairs.length === 0 ? {} : { configuration: Object.fromEntries(pairs) };
336
+ }
318
337
  var UPLOAD_CONCURRENCY = { min: 1, max: 32, default: 16 };
319
338
  var UPLOAD_RETRIES = { min: 0, max: 20, default: 6 };
320
339
  var IMPORT_TIMEOUT = { min: 0, max: 86400, default: 600 };
@@ -362,6 +381,7 @@ function readEnv(env = process.env) {
362
381
  const offerDiscovered = flag(env, "PLUNE_CREATE");
363
382
  const full = flag(env, "PLUNE_FULL_RUN");
364
383
  const { shard, error: shardError } = shardOf(env);
384
+ const { configuration, error: configurationError } = configurationOf(env);
365
385
  return {
366
386
  config: {
367
387
  ...externalKey !== void 0 ? { externalKey } : {},
@@ -374,6 +394,7 @@ function readEnv(env = process.env) {
374
394
  ...title !== void 0 ? { title } : {},
375
395
  ...environment !== void 0 ? { environment } : {},
376
396
  ...labels !== void 0 ? { labels } : {},
397
+ ...configuration !== void 0 ? { configuration } : {},
377
398
  // Only when set: `false` here would outrank a config file that asked for it, and an unset
378
399
  // variable is not an instruction.
379
400
  ...offerDiscovered ? { offerDiscovered } : {},
@@ -387,6 +408,7 @@ function readEnv(env = process.env) {
387
408
  keepOpen: flag(env, "PLUNE_SHARED_RUN") || flag(env, "PLUNE_PROCEED") || shard !== void 0 || shardError !== void 0,
388
409
  ...shard !== void 0 ? { shard } : {},
389
410
  ...shardError !== void 0 ? { shardError } : {},
411
+ ...configurationError !== void 0 ? { configurationError } : {},
390
412
  ...importTimeout !== void 0 ? { importTimeout } : {},
391
413
  ignored: UNSUPPORTED_VARS.filter((name) => value(env, name) !== void 0),
392
414
  warnings
@@ -597,6 +619,7 @@ function appendBatch(file, batch) {
597
619
  externalKey: batch.externalKey,
598
620
  ...batch.session !== void 0 ? { session: batch.session } : {},
599
621
  ...batch.meta !== void 0 ? { meta: batch.meta } : {},
622
+ ...batch.configuration !== void 0 ? { configuration: batch.configuration } : {},
600
623
  results: batch.results
601
624
  };
602
625
  fs.appendFileSync(file, `${JSON.stringify(line)}
@@ -747,8 +770,10 @@ function warnIfDropped(sent, run, log) {
747
770
  async function startRun(passed, declared) {
748
771
  const env = readEnv();
749
772
  if (env.shardError !== void 0) throw new Error(env.shardError);
773
+ if (env.configurationError !== void 0 && passed.configuration === void 0) throw new Error(env.configurationError);
750
774
  const stated = Object.fromEntries(Object.entries(passed).filter(([, v]) => v !== void 0));
751
775
  const cfg = { ...env.config, ...stated };
776
+ const configuration = cfg.configuration !== void 0 && Object.keys(cfg.configuration).length > 0 ? cfg.configuration : void 0;
752
777
  const log = cfg.log ?? ((line) => void process.stderr.write(`${line}
753
778
  `));
754
779
  const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
@@ -832,6 +857,7 @@ async function startRun(passed, declared) {
832
857
  externalKey,
833
858
  session: sessionMarker,
834
859
  ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
860
+ ...configuration !== void 0 ? { configuration } : {},
835
861
  results: results.map(onDisk)
836
862
  });
837
863
  stats.deferred += results.length;
@@ -856,7 +882,7 @@ async function startRun(passed, declared) {
856
882
  if (!offline) {
857
883
  if (expected !== void 0) await resolveKeys(expected.flat());
858
884
  const stated2 = expected?.slice(0, EXPECTED_MAX) ?? [];
859
- const configuration = stated2.length === 0 ? void 0 : {
885
+ const configuration2 = stated2.length === 0 ? void 0 : {
860
886
  expected: stated2.flatMap((keys) => {
861
887
  const hit = keys.map((k) => resolved.get(k.value)).find((v) => typeof v === "string");
862
888
  if (typeof hit === "string") return [{ testCaseId: hit }];
@@ -880,7 +906,7 @@ async function startRun(passed, declared) {
880
906
  kind: cfg.kind ?? "automated",
881
907
  ...externalKey !== null ? { externalKey } : {},
882
908
  ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
883
- ...configuration !== void 0 ? { configuration } : {},
909
+ ...configuration2 !== void 0 ? { configuration: configuration2 } : {},
884
910
  // Named even when nobody named it — the list has to read without opening a row. Only what
885
911
  // the caller DESCRIBED goes to `warnIfDropped` below: a title this side invented is not one
886
912
  // a person will miss, and a deployment older than the field must not be nagged on every run.
@@ -888,7 +914,7 @@ async function startRun(passed, declared) {
888
914
  ...described
889
915
  };
890
916
  let start = await client.post("/v1/runs", request);
891
- if (!start.ok && start.status === 413 && configuration !== void 0) {
917
+ if (!start.ok && start.status === 413 && configuration2 !== void 0) {
892
918
  log(
893
919
  `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.`
894
920
  );
@@ -1434,7 +1460,8 @@ async function startRun(passed, declared) {
1434
1460
  }
1435
1461
  const out = await client.post(
1436
1462
  `/v1/runs/${runId}/results`,
1437
- { results: part }
1463
+ // The job's combination on every batch (plune-ai/plune#1251); a refusal of it is a 422 said below as it came.
1464
+ { results: part, ...configuration !== void 0 ? { configuration } : {} }
1438
1465
  );
1439
1466
  const counts = out.ok ? out.body?.counts : void 0;
1440
1467
  const told = COUNTED.filter((k) => typeof counts?.[k] === "number");
@@ -324,6 +324,12 @@ interface ReporterConfig {
324
324
  environment?: string;
325
325
  /** How the run is marked — smoke, nightly, a team. */
326
326
  labels?: string[];
327
+ /**
328
+ * The combination this job runs, group name → value label (`PLUNE_CONFIGURATION`, plune-ai/plune#1251):
329
+ * sent as `configuration` on every results batch. Not the run's own `configuration` (what it expects).
330
+ * Stated, it is all there is: the variable is not read, and `{}` sends none (what a replay passes for a line that recorded none).
331
+ */
332
+ configuration?: Record<string, string>;
327
333
  /** Results per request. The platform's ceiling is 500. */
328
334
  batchSize?: number;
329
335
  /** Files in flight at once (`PLUNE_UPLOAD_CONCURRENCY`, 1 to 32, default 16). */
@@ -445,6 +451,8 @@ interface EnvSettings {
445
451
  shard?: ShardOf;
446
452
  /** `PLUNE_SHARD` was set to something that is not `k/N`. A configuration error: the caller refuses to start. */
447
453
  shardError?: string;
454
+ /** `PLUNE_CONFIGURATION` was set to something that is not `Group=Value;…`. Refused the same way. */
455
+ configurationError?: string;
448
456
  /** `PLUNE_IMPORT_TIMEOUT` in seconds, when set and usable. */
449
457
  importTimeout?: number;
450
458
  /** Variables that were set and have nowhere to land. The caller says so once. */
@@ -498,12 +506,17 @@ declare const DEFAULT_FALLBACK_PATH: string;
498
506
  * A run of 24 tests at a batch of 10 is three lines, and replay opens one run for the lines that share a
499
507
  * marker rather than one for each. A line written before the field existed has none, and is one run's
500
508
  * worth by itself — nothing in it says otherwise.
509
+ *
510
+ * `configuration` is the combination the job named (`PLUNE_CONFIGURATION`, plune-ai/plune#1251), recorded for
511
+ * the same reason as `meta`: replay sends the batch into the combination it ran in, whatever the machine that
512
+ * replays it has set. A line without it was written by a job that named none, or before the field existed.
501
513
  */
502
514
  interface DeferredBatch {
503
515
  runId: string | null;
504
516
  externalKey: string | null;
505
517
  session?: string;
506
518
  meta?: RunMeta;
519
+ configuration?: Record<string, string>;
507
520
  results: DeferredResult[];
508
521
  }
509
522
  /**