@plune-ai/cli 0.30.2 → 0.32.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,58 +1855,23 @@ 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/sync.ts
1859
- var NotLoggedInError = class extends Error {
1860
- constructor() {
1861
- super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
1862
- this.name = "NotLoggedInError";
1863
- }
1864
- };
1865
- var TokenRejectedError = class extends Error {
1866
- constructor() {
1867
- super(
1868
- 'Your API token was rejected (revoked or invalid). Run "plune login" with a fresh token.'
1869
- );
1870
- this.name = "TokenRejectedError";
1871
- }
1872
- };
1873
- var SyncFileError = class extends Error {
1874
- constructor(message) {
1875
- super(message);
1876
- this.name = "SyncFileError";
1877
- }
1878
- };
1879
- var SyncNetworkError = class extends Error {
1880
- constructor(apiUrl) {
1881
- super(
1882
- `Could not reach the Plune API at ${apiUrl}. Check your connection (or PLUNE_API_URL) and try again.`
1883
- );
1884
- this.name = "SyncNetworkError";
1885
- }
1886
- };
1887
- var SyncHttpError = class extends Error {
1888
- constructor(status, detail) {
1889
- super(`Sync failed \u2014 the server returned HTTP ${status}${detail ? ` (${detail})` : ""}.`);
1890
- this.status = status;
1891
- this.name = "SyncHttpError";
1892
- }
1893
- status;
1894
- };
1895
- var JOB_KIND = "sync";
1858
+ // src/cli/commands/job-reporter.ts
1896
1859
  var JOB_TIMEOUT_MS = 5e3;
1897
1860
  var JOB_SUBJECT_NAME_MAX = 200;
1898
1861
  var JOB_REASON_MAX = 2e3;
1899
1862
  var JobReporter = class {
1900
- constructor(doFetch, base, token) {
1863
+ constructor(doFetch, base, token, kind) {
1901
1864
  this.doFetch = doFetch;
1902
1865
  this.base = base;
1903
1866
  this.token = token;
1867
+ this.kind = kind;
1904
1868
  }
1905
1869
  doFetch;
1906
1870
  base;
1907
1871
  token;
1872
+ kind;
1908
1873
  id;
1909
- /** The run's rows, the job's `total`; `null` while there are none to count. */
1874
+ /** The upload's rows, the job's `total`; `null` while there are none to count. */
1910
1875
  size = null;
1911
1876
  /** The first report the platform did not take, for the CLI to say once. */
1912
1877
  problem;
@@ -1931,19 +1896,23 @@ var JobReporter = class {
1931
1896
  this.size = Number.isInteger(rows) && rows > 0 ? rows : null;
1932
1897
  const name = subjectName.trim().slice(0, JOB_SUBJECT_NAME_MAX);
1933
1898
  const body = await this.send("POST", "", {
1934
- kind: JOB_KIND,
1899
+ kind: this.kind,
1935
1900
  ...name !== "" ? { subjectName: name } : {},
1936
1901
  ...this.size !== null ? { progress: { done: 0, total: this.size } } : {}
1937
1902
  });
1938
1903
  if (typeof body?.id === "string") this.id = body.id;
1939
1904
  }
1940
- /** The upload is in: every row of the run is on the platform. */
1941
- async uploaded(cursor) {
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) {
1942
1910
  if (this.id === void 0) return;
1943
1911
  await this.send("PATCH", `/${encodeURIComponent(this.id)}/progress`, {
1944
1912
  done: this.size,
1945
1913
  total: this.size,
1946
- ...cursor !== void 0 ? { cursor } : {}
1914
+ ...at.cursor !== void 0 ? { cursor: at.cursor } : {},
1915
+ ...at.subjectId !== void 0 ? { subjectId: at.subjectId } : {}
1947
1916
  });
1948
1917
  }
1949
1918
  async finish(outcome) {
@@ -1957,6 +1926,44 @@ var JobReporter = class {
1957
1926
  );
1958
1927
  }
1959
1928
  };
1929
+
1930
+ // src/cli/commands/sync.ts
1931
+ var NotLoggedInError = class extends Error {
1932
+ constructor() {
1933
+ super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
1934
+ this.name = "NotLoggedInError";
1935
+ }
1936
+ };
1937
+ var TokenRejectedError = class extends Error {
1938
+ constructor() {
1939
+ super(
1940
+ 'Your API token was rejected (revoked or invalid). Run "plune login" with a fresh token.'
1941
+ );
1942
+ this.name = "TokenRejectedError";
1943
+ }
1944
+ };
1945
+ var SyncFileError = class extends Error {
1946
+ constructor(message) {
1947
+ super(message);
1948
+ this.name = "SyncFileError";
1949
+ }
1950
+ };
1951
+ var SyncNetworkError = class extends Error {
1952
+ constructor(apiUrl) {
1953
+ super(
1954
+ `Could not reach the Plune API at ${apiUrl}. Check your connection (or PLUNE_API_URL) and try again.`
1955
+ );
1956
+ this.name = "SyncNetworkError";
1957
+ }
1958
+ };
1959
+ var SyncHttpError = class extends Error {
1960
+ constructor(status, detail) {
1961
+ super(`Sync failed \u2014 the server returned HTTP ${status}${detail ? ` (${detail})` : ""}.`);
1962
+ this.status = status;
1963
+ this.name = "SyncHttpError";
1964
+ }
1965
+ status;
1966
+ };
1960
1967
  function readRun(cwd, file) {
1961
1968
  const target = file !== void 0 ? path5.resolve(cwd, file) : path5.join(cwd, ".plune", "last-run.json");
1962
1969
  let raw;
@@ -1998,7 +2005,7 @@ async function handleSync(deps = {}) {
1998
2005
  const payload = JSON.stringify(run);
1999
2006
  const idempotencyKey = (0, import_node_crypto2.createHash)("sha256").update(payload).digest("hex");
2000
2007
  const bearer = token;
2001
- const job = new JobReporter(doFetch, apiUrl, bearer);
2008
+ const job = new JobReporter(doFetch, apiUrl, bearer, "sync");
2002
2009
  await job.open(path5.basename(deps.file !== void 0 ? path5.resolve(cwd, deps.file) : cwd), run.summary.total);
2003
2010
  try {
2004
2011
  const result = await upload();
@@ -2032,7 +2039,7 @@ async function handleSync(deps = {}) {
2032
2039
  const body = await res.json().catch(() => ({}));
2033
2040
  const id = typeof body.id === "string" ? body.id : "";
2034
2041
  const result = { id, url: `${endpoint}/${id}` };
2035
- await job.uploaded(id || void 0);
2042
+ await job.uploaded({ cursor: id || void 0 });
2036
2043
  if (deps.failOnFailures === true) {
2037
2044
  result.gate = gateExit(await readVerdict(doFetch, `${endpoint}/${id}/summary`, bearer));
2038
2045
  }
@@ -2060,6 +2067,9 @@ function reportSyncFailure(err, write) {
2060
2067
  return null;
2061
2068
  }
2062
2069
 
2070
+ // src/cli/commands/ingest.ts
2071
+ var path7 = __toESM(require("path"), 1);
2072
+
2063
2073
  // src/cli/commands/cairn-artifact.ts
2064
2074
  var fs7 = __toESM(require("fs/promises"), 1);
2065
2075
  var path6 = __toESM(require("path"), 1);
@@ -2254,28 +2264,42 @@ async function handleIngest(deps) {
2254
2264
  const payload = await readCairnRun(dir);
2255
2265
  const doFetch = deps.fetchImpl ?? fetch;
2256
2266
  const endpoint = `${apiUrl}/v1/ingest/cairn`;
2257
- let res;
2267
+ const job = new JobReporter(doFetch, apiUrl, token, "ingest");
2268
+ await job.open(path7.basename(path7.resolve(dir)), payload.cases.length);
2258
2269
  try {
2259
- res = await doFetch(endpoint, {
2260
- method: "POST",
2261
- headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
2262
- body: JSON.stringify(payload)
2263
- });
2264
- } catch {
2265
- 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;
2266
2278
  }
2267
- if (!res.ok) {
2268
- const detail = await safeErrorDetail2(res);
2269
- if (res.status === 401) throw new IngestTokenRejectedError();
2270
- 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
+ };
2271
2302
  }
2272
- const body = await res.json().catch(() => ({}));
2273
- return {
2274
- runId: typeof body.runId === "string" ? body.runId : "",
2275
- linked: typeof body.linked === "number" ? body.linked : 0,
2276
- proposed: typeof body.proposed === "number" ? body.proposed : 0,
2277
- skipped: typeof body.skipped === "number" ? body.skipped : 0
2278
- };
2279
2303
  }
2280
2304
  function reportIngestFailure(err, write) {
2281
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
@@ -690,6 +690,8 @@ interface IngestResult {
690
690
  linked: number;
691
691
  proposed: number;
692
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;
693
695
  }
694
696
  declare function handleIngest(deps: IngestDeps): Promise<IngestResult>;
695
697
  /**
package/dist/index.d.ts CHANGED
@@ -690,6 +690,8 @@ interface IngestResult {
690
690
  linked: number;
691
691
  proposed: number;
692
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;
693
695
  }
694
696
  declare function handleIngest(deps: IngestDeps): Promise<IngestResult>;
695
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,58 +1795,23 @@ 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/sync.ts
1799
- var NotLoggedInError = class extends Error {
1800
- constructor() {
1801
- super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
1802
- this.name = "NotLoggedInError";
1803
- }
1804
- };
1805
- var TokenRejectedError = class extends Error {
1806
- constructor() {
1807
- super(
1808
- 'Your API token was rejected (revoked or invalid). Run "plune login" with a fresh token.'
1809
- );
1810
- this.name = "TokenRejectedError";
1811
- }
1812
- };
1813
- var SyncFileError = class extends Error {
1814
- constructor(message) {
1815
- super(message);
1816
- this.name = "SyncFileError";
1817
- }
1818
- };
1819
- var SyncNetworkError = class extends Error {
1820
- constructor(apiUrl) {
1821
- super(
1822
- `Could not reach the Plune API at ${apiUrl}. Check your connection (or PLUNE_API_URL) and try again.`
1823
- );
1824
- this.name = "SyncNetworkError";
1825
- }
1826
- };
1827
- var SyncHttpError = class extends Error {
1828
- constructor(status, detail) {
1829
- super(`Sync failed \u2014 the server returned HTTP ${status}${detail ? ` (${detail})` : ""}.`);
1830
- this.status = status;
1831
- this.name = "SyncHttpError";
1832
- }
1833
- status;
1834
- };
1835
- var JOB_KIND = "sync";
1798
+ // src/cli/commands/job-reporter.ts
1836
1799
  var JOB_TIMEOUT_MS = 5e3;
1837
1800
  var JOB_SUBJECT_NAME_MAX = 200;
1838
1801
  var JOB_REASON_MAX = 2e3;
1839
1802
  var JobReporter = class {
1840
- constructor(doFetch, base, token) {
1803
+ constructor(doFetch, base, token, kind) {
1841
1804
  this.doFetch = doFetch;
1842
1805
  this.base = base;
1843
1806
  this.token = token;
1807
+ this.kind = kind;
1844
1808
  }
1845
1809
  doFetch;
1846
1810
  base;
1847
1811
  token;
1812
+ kind;
1848
1813
  id;
1849
- /** The run's rows, the job's `total`; `null` while there are none to count. */
1814
+ /** The upload's rows, the job's `total`; `null` while there are none to count. */
1850
1815
  size = null;
1851
1816
  /** The first report the platform did not take, for the CLI to say once. */
1852
1817
  problem;
@@ -1871,19 +1836,23 @@ var JobReporter = class {
1871
1836
  this.size = Number.isInteger(rows) && rows > 0 ? rows : null;
1872
1837
  const name = subjectName.trim().slice(0, JOB_SUBJECT_NAME_MAX);
1873
1838
  const body = await this.send("POST", "", {
1874
- kind: JOB_KIND,
1839
+ kind: this.kind,
1875
1840
  ...name !== "" ? { subjectName: name } : {},
1876
1841
  ...this.size !== null ? { progress: { done: 0, total: this.size } } : {}
1877
1842
  });
1878
1843
  if (typeof body?.id === "string") this.id = body.id;
1879
1844
  }
1880
- /** The upload is in: every row of the run is on the platform. */
1881
- async uploaded(cursor) {
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) {
1882
1850
  if (this.id === void 0) return;
1883
1851
  await this.send("PATCH", `/${encodeURIComponent(this.id)}/progress`, {
1884
1852
  done: this.size,
1885
1853
  total: this.size,
1886
- ...cursor !== void 0 ? { cursor } : {}
1854
+ ...at.cursor !== void 0 ? { cursor: at.cursor } : {},
1855
+ ...at.subjectId !== void 0 ? { subjectId: at.subjectId } : {}
1887
1856
  });
1888
1857
  }
1889
1858
  async finish(outcome) {
@@ -1897,6 +1866,44 @@ var JobReporter = class {
1897
1866
  );
1898
1867
  }
1899
1868
  };
1869
+
1870
+ // src/cli/commands/sync.ts
1871
+ var NotLoggedInError = class extends Error {
1872
+ constructor() {
1873
+ super('Not logged in. Run "plune login" first, or set PLUNE_TOKEN.');
1874
+ this.name = "NotLoggedInError";
1875
+ }
1876
+ };
1877
+ var TokenRejectedError = class extends Error {
1878
+ constructor() {
1879
+ super(
1880
+ 'Your API token was rejected (revoked or invalid). Run "plune login" with a fresh token.'
1881
+ );
1882
+ this.name = "TokenRejectedError";
1883
+ }
1884
+ };
1885
+ var SyncFileError = class extends Error {
1886
+ constructor(message) {
1887
+ super(message);
1888
+ this.name = "SyncFileError";
1889
+ }
1890
+ };
1891
+ var SyncNetworkError = class extends Error {
1892
+ constructor(apiUrl) {
1893
+ super(
1894
+ `Could not reach the Plune API at ${apiUrl}. Check your connection (or PLUNE_API_URL) and try again.`
1895
+ );
1896
+ this.name = "SyncNetworkError";
1897
+ }
1898
+ };
1899
+ var SyncHttpError = class extends Error {
1900
+ constructor(status, detail) {
1901
+ super(`Sync failed \u2014 the server returned HTTP ${status}${detail ? ` (${detail})` : ""}.`);
1902
+ this.status = status;
1903
+ this.name = "SyncHttpError";
1904
+ }
1905
+ status;
1906
+ };
1900
1907
  function readRun(cwd, file) {
1901
1908
  const target = file !== void 0 ? path5.resolve(cwd, file) : path5.join(cwd, ".plune", "last-run.json");
1902
1909
  let raw;
@@ -1938,7 +1945,7 @@ async function handleSync(deps = {}) {
1938
1945
  const payload = JSON.stringify(run);
1939
1946
  const idempotencyKey = createHash2("sha256").update(payload).digest("hex");
1940
1947
  const bearer = token;
1941
- const job = new JobReporter(doFetch, apiUrl, bearer);
1948
+ const job = new JobReporter(doFetch, apiUrl, bearer, "sync");
1942
1949
  await job.open(path5.basename(deps.file !== void 0 ? path5.resolve(cwd, deps.file) : cwd), run.summary.total);
1943
1950
  try {
1944
1951
  const result = await upload();
@@ -1972,7 +1979,7 @@ async function handleSync(deps = {}) {
1972
1979
  const body = await res.json().catch(() => ({}));
1973
1980
  const id = typeof body.id === "string" ? body.id : "";
1974
1981
  const result = { id, url: `${endpoint}/${id}` };
1975
- await job.uploaded(id || void 0);
1982
+ await job.uploaded({ cursor: id || void 0 });
1976
1983
  if (deps.failOnFailures === true) {
1977
1984
  result.gate = gateExit(await readVerdict(doFetch, `${endpoint}/${id}/summary`, bearer));
1978
1985
  }
@@ -2000,6 +2007,9 @@ function reportSyncFailure(err, write) {
2000
2007
  return null;
2001
2008
  }
2002
2009
 
2010
+ // src/cli/commands/ingest.ts
2011
+ import * as path7 from "path";
2012
+
2003
2013
  // src/cli/commands/cairn-artifact.ts
2004
2014
  import * as fs7 from "fs/promises";
2005
2015
  import * as path6 from "path";
@@ -2194,28 +2204,42 @@ async function handleIngest(deps) {
2194
2204
  const payload = await readCairnRun(dir);
2195
2205
  const doFetch = deps.fetchImpl ?? fetch;
2196
2206
  const endpoint = `${apiUrl}/v1/ingest/cairn`;
2197
- let res;
2207
+ const job = new JobReporter(doFetch, apiUrl, token, "ingest");
2208
+ await job.open(path7.basename(path7.resolve(dir)), payload.cases.length);
2198
2209
  try {
2199
- res = await doFetch(endpoint, {
2200
- method: "POST",
2201
- headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
2202
- body: JSON.stringify(payload)
2203
- });
2204
- } catch {
2205
- 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;
2206
2218
  }
2207
- if (!res.ok) {
2208
- const detail = await safeErrorDetail2(res);
2209
- if (res.status === 401) throw new IngestTokenRejectedError();
2210
- 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
+ };
2211
2242
  }
2212
- const body = await res.json().catch(() => ({}));
2213
- return {
2214
- runId: typeof body.runId === "string" ? body.runId : "",
2215
- linked: typeof body.linked === "number" ? body.linked : 0,
2216
- proposed: typeof body.proposed === "number" ? body.proposed : 0,
2217
- skipped: typeof body.skipped === "number" ? body.skipped : 0
2218
- };
2219
2243
  }
2220
2244
  function reportIngestFailure(err, write) {
2221
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
  /**