@tokenoftrust/cli 2.4.0 → 2.4.2

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/bin/tot.mjs CHANGED
@@ -219,8 +219,8 @@ async function dispatch(cmd, rest, ctx) {
219
219
 
220
220
  // `accept` queue-integrates a PR into the protected `preview` aggregate —
221
221
  // NO merge-to-main, NO go-live; going live is `tot ship`, which publishes the
222
- // whole current GREEN AGGREGATE. Backed by POST /api/changes/integrate
223
- // (TenantIntegrationQueue.enqueue); see accept.mjs's header. `merge` is a
222
+ // whole current GREEN AGGREGATE. Backed by POST /api/changes/integrate (an
223
+ // `integrate` job in the store's pipeline); see accept.mjs's header. `merge` is a
224
224
  // first-class alias, not a teaching nudge: both dispatch straight to the same command.
225
225
  if (cmd === "accept" || cmd === "merge") {
226
226
  const { run } = await import("../src/commands/accept.mjs");
@@ -245,8 +245,8 @@ async function dispatch(cmd, rest, ctx) {
245
245
  // `revert --preview <PR|change-id>` undoes an already-integrated PR out of the
246
246
  // protected `preview` aggregate by opening a NEW corrective candidate and
247
247
  // merging it through the SAME accept pipeline — NO force-reset, NO touch to
248
- // main/live. Backed by POST /api/changes/revert (TenantIntegrationQueue.enqueue,
249
- // the same pipeline `tot accept` drives). To undo something already LIVE,
248
+ // main/live. Backed by POST /api/changes/revert (a `revert` job in the store's
249
+ // pipeline, the same steps `tot accept` runs). To undo something already LIVE,
250
250
  // that's `tot rollback`, not this. See revert.mjs's header.
251
251
  if (cmd === "revert") {
252
252
  const { run } = await import("../src/commands/revert.mjs");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/cli",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Token of Trust",
@@ -18,13 +18,13 @@
18
18
  * chg-id). An explicit `--change-id` is still accepted (targets a specific record),
19
19
  * but is no longer REQUIRED — a PR number alone is enough.
20
20
  *
21
- * TRANSPORT — the honest accept path is `POST /api/changes/integrate` (the ONE call
22
- * site of `TenantIntegrationQueue.enqueue`). Like `tot ship --pr` and
23
- * `tot pr list --tenant`, the CLI reaches it with the OPERATOR-SECRET Bearer
24
- * transport (`resolveOperatorSecret` + `X-Tot-Owner` + `x-tot-capability`), since the
25
- * CLI holds no storefront cookie. The response is the honest `IntegrateOutcome` —
26
- * `queueState` / `runState` / `pointerMoved` / `aggregateSha` / `statusMessage` —
27
- * which this verb renders VERBATIM, never a bare "merged".
21
+ * TRANSPORT — the honest accept path is `POST /api/changes/integrate`. Like `tot ship --pr`
22
+ * and `tot pr list --tenant`, the CLI reaches it with the OPERATOR-SECRET Bearer transport
23
+ * (`resolveOperatorSecret` + `X-Tot-Owner` + `x-tot-capability`), since the CLI holds no
24
+ * storefront cookie. The route submits an `integrate` job to the store's pipeline and answers
25
+ * `202 {jobId}`; this verb waits on `GET /api/pipeline/jobs/<jobId>` for the job's outcome —
26
+ * `jobState` / `runState` / `pointerMoved` / `aggregateSha` / `statusMessage` — and renders it
27
+ * VERBATIM, never a bare "merged". The job runs on the platform whether or not this waits.
28
28
  *
29
29
  * HUMAN GATE — integrating into the shared preview is a decision a human makes, so
30
30
  * this ALWAYS states the EXACT plan (shared `planForAction`: which PR,
@@ -53,6 +53,7 @@ import { resolveViewerTransport } from "../viewer-session.mjs";
53
53
  // "here's what's blocking you" summary over the SAME transport we just used, so a
54
54
  // developer doesn't have to remember to run `tot preview doctor` themselves.
55
55
  import { autoSurfaceDoctor } from "../merge-doctor-report.mjs";
56
+ import { isJobAccepted, waitForPipelineJob } from "../pipeline-job.mjs";
56
57
 
57
58
  const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
58
59
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
@@ -150,7 +151,7 @@ export function parseAcceptArgs(argv) {
150
151
  * fields this verb renders. Reads defensively so a plausible field rename degrades
151
152
  * rather than crashes. Pure — unit-tested.
152
153
  * @param {any} data
153
- * @returns {{ ok:boolean, queueState:string|null, runState:string|null,
154
+ * @returns {{ ok:boolean, pending:boolean, jobId:string|null, jobState:string|null, runState:string|null,
154
155
  * pointerMoved:boolean, aggregateSha:string|null, statusMessage:string|null,
155
156
  * reason:string|null, changeId:string|null, prNumber:number|null, error:string|null, raw:any }}
156
157
  */
@@ -158,7 +159,9 @@ export function normalizeIntegrateResponse(data) {
158
159
  const o = data && typeof data === "object" ? data : {};
159
160
  return {
160
161
  ok: o.ok === true,
161
- queueState: typeof o.queueState === "string" ? o.queueState : null,
162
+ pending: o.pending === true,
163
+ jobId: typeof o.jobId === "string" ? o.jobId : null,
164
+ jobState: typeof o.jobState === "string" ? o.jobState : null,
162
165
  runState: typeof o.runState === "string" ? o.runState : null,
163
166
  pointerMoved: o.pointerMoved === true,
164
167
  aggregateSha: typeof o.aggregateSha === "string" ? o.aggregateSha : null,
@@ -388,8 +391,11 @@ async function handleNotMergeable(
388
391
  return { integrate: true, pr: rr.prNumber ?? pr, changeId };
389
392
  }
390
393
 
391
- /** POST `/api/changes/integrate` and normalise the outcome. Returns { res, result }. */
392
- async function postIntegrate({ base, authHeaders, body }, fetchImpl) {
394
+ /**
395
+ * POST `/api/changes/integrate`, wait on the job it submitted, and normalise the outcome.
396
+ * Returns { res, result } — `res.ok` is the submit's transport verdict.
397
+ */
398
+ async function postIntegrate({ base, authHeaders, body }, fetchImpl, waitOpts = {}) {
393
399
  const res = await fetchImpl(`${base}/api/changes/integrate`, {
394
400
  method: "POST",
395
401
  headers: { "content-type": "application/json", ...authHeaders },
@@ -401,6 +407,11 @@ async function postIntegrate({ base, authHeaders, body }, fetchImpl) {
401
407
  } catch {
402
408
  /* non-JSON / empty body */
403
409
  }
410
+ if (isJobAccepted(res, data)) {
411
+ console.log(` submitted as job ${data.jobId} — waiting for it to finish (it continues on the platform if you stop waiting)…`);
412
+ const { outcome } = await waitForPipelineJob({ base, authHeaders, jobId: data.jobId }, fetchImpl, waitOpts);
413
+ return { res, result: normalizeIntegrateResponse({ ...outcome, jobId: data.jobId }) };
414
+ }
404
415
  return { res, result: normalizeIntegrateResponse(data) };
405
416
  }
406
417
 
@@ -422,7 +433,13 @@ function integrateBody({ tenant, pr, changeId, headSha }) {
422
433
  * @returns {number}
423
434
  */
424
435
  export function reportIntegrated(result, { tenant, label }) {
425
- const state = `${result.queueState ?? "?"}/${result.runState ?? "—"}`;
436
+ const state = `${result.jobState ?? "?"}/${result.runState ?? "—"}`;
437
+ if (result.pending) {
438
+ console.log(`\n … ${label} is still being added to ${tenant}'s preview aggregate — it finishes on the platform.`);
439
+ if (result.jobId) console.log(` job: ${result.jobId}`);
440
+ console.log(" → next: re-check the queue (`tot pr list --tenant " + tenant + "`) in a few minutes.");
441
+ return 0;
442
+ }
426
443
  if (result.ok) {
427
444
  console.log(`\n ✓ queued ${label} into ${tenant}'s preview aggregate — it is GREEN.`);
428
445
  console.log(` aggregate: ${state}${result.aggregateSha ? ` (${result.aggregateSha})` : ""}`);
@@ -458,7 +475,8 @@ export function reportIntegrated(result, { tenant, label }) {
458
475
  * secret:string, storefrontUrl?:string|null, yes?:boolean, refresh?:boolean,
459
476
  * strategy?:string, mcpUrl?:string|null, identity?:string|null, env?:NodeJS.ProcessEnv }} params
460
477
  * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
461
- * resolveViewerTransport?:typeof resolveViewerTransport }} [deps]
478
+ * resolveViewerTransport?:typeof resolveViewerTransport,
479
+ * wait?:Parameters<typeof waitForPipelineJob>[2] }} [deps]
462
480
  * @returns {Promise<number>} process exit code
463
481
  */
464
482
  export async function runIntegrate(
@@ -575,6 +593,7 @@ export async function runIntegrate(
575
593
  ({ res, result } = await postIntegrate(
576
594
  { base, authHeaders, body: integrateBody({ tenant, pr: curPr, changeId: curChangeId, headSha: curHead }) },
577
595
  fetchImpl,
596
+ deps.wait,
578
597
  ));
579
598
  } catch (e) {
580
599
  console.error(
@@ -585,7 +604,7 @@ export async function runIntegrate(
585
604
 
586
605
  // A pre-flight error (auth, unknown tenant, candidate not found) is an HTTP 4xx
587
606
  // with `{ error }` and no queue verdict — surface it distinctly from a red run.
588
- if (!res.ok && result.queueState == null) {
607
+ if (!res.ok) {
589
608
  return reportIntegrateHttpError(result, res, { tenant, label });
590
609
  }
591
610
 
@@ -612,6 +631,7 @@ export async function runIntegrate(
612
631
  }),
613
632
  },
614
633
  fetchImpl,
634
+ deps.wait,
615
635
  ));
616
636
  } catch (e) {
617
637
  console.error(
@@ -619,7 +639,7 @@ export async function runIntegrate(
619
639
  );
620
640
  return 1;
621
641
  }
622
- if (!res.ok && result.queueState == null) {
642
+ if (!res.ok) {
623
643
  return reportIntegrateHttpError(result, res, { tenant, label });
624
644
  }
625
645
  }
@@ -22,15 +22,14 @@
22
22
  * anything — when there's nothing TO undo (never integrated, no longer part of
23
23
  * the current aggregate, or already undone).
24
24
  *
25
- * TRANSPORT — `POST /api/changes/revert` (opens the corrective candidate, then
26
- * runs it through the SAME `TenantIntegrationQueue.enqueue` accept pipeline
27
- * `tot accept` drives). Like `tot accept`, the CLI reaches it with either the
28
- * operator-secret Bearer transport (`resolveOperatorSecret` + `X-Tot-Owner` +
29
- * `x-tot-capability`) or a signed-in developer's viewer session when they hold
30
- * a live `ship-on-behalf` grant. The response is the honest terminal aggregate
31
- * state — `queueState` / `runState` / `pointerMoved` / `aggregateSha` /
32
- * `changeId` (the corrective candidate's own id) / `statusMessage` — rendered
33
- * VERBATIM, never a bare "reverted".
25
+ * TRANSPORT — `POST /api/changes/revert` submits a `revert` job to the store's pipeline (open
26
+ * the corrective candidate, then merge → build → evidence → advance the preview, exactly as
27
+ * `tot accept`) and answers `202 {jobId}`; this verb waits on `GET /api/pipeline/jobs/<jobId>`.
28
+ * Like `tot accept`, the CLI reaches it with either the operator-secret Bearer transport
29
+ * (`resolveOperatorSecret` + `X-Tot-Owner` + `x-tot-capability`) or a signed-in developer's
30
+ * viewer session when they hold a live `ship-on-behalf` grant. The outcome — `jobState` /
31
+ * `runState` / `pointerMoved` / `aggregateSha` / `changeId` (the corrective candidate's own id) /
32
+ * `statusMessage` — is rendered VERBATIM, never a bare "reverted".
34
33
  *
35
34
  * HUMAN GATE — correcting the shared preview is a decision a human makes, so
36
35
  * this ALWAYS states the EXACT plan (a NEW corrective candidate, merged through
@@ -48,6 +47,7 @@ import { resolveOperatorSecret } from "./ship.mjs";
48
47
  // No-operator-secret path: mint a viewer session from the developer's OWN `tot`
49
48
  // login. The server still gates the request on their live ship-on-behalf grant.
50
49
  import { resolveViewerTransport } from "../viewer-session.mjs";
50
+ import { isJobAccepted, waitForPipelineJob } from "../pipeline-job.mjs";
51
51
 
52
52
  const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
53
53
 
@@ -163,7 +163,9 @@ export function normalizeRevertResponse(data) {
163
163
  const o = data && typeof data === "object" ? data : {};
164
164
  return {
165
165
  ok: o.ok === true,
166
- queueState: typeof o.queueState === "string" ? o.queueState : null,
166
+ pending: o.pending === true,
167
+ jobId: typeof o.jobId === "string" ? o.jobId : null,
168
+ jobState: typeof o.jobState === "string" ? o.jobState : null,
167
169
  runState: typeof o.runState === "string" ? o.runState : null,
168
170
  pointerMoved: o.pointerMoved === true,
169
171
  aggregateSha: typeof o.aggregateSha === "string" ? o.aggregateSha : null,
@@ -184,7 +186,12 @@ export function normalizeRevertResponse(data) {
184
186
  * @returns {number}
185
187
  */
186
188
  export function reportReverted(result, { tenant, label }) {
187
- const state = `${result.queueState ?? "?"}/${result.runState ?? "—"}`;
189
+ const state = `${result.jobState ?? "?"}/${result.runState ?? "—"}`;
190
+ if (result.pending) {
191
+ console.log(`\n … undoing ${label} in ${tenant}'s preview aggregate is still running — it finishes on the platform.`);
192
+ if (result.jobId) console.log(` job: ${result.jobId}`);
193
+ return 0;
194
+ }
188
195
  if (result.ok) {
189
196
  console.log(`\n ✓ undid ${label} in ${tenant}'s preview aggregate — it is GREEN.`);
190
197
  console.log(` aggregate: ${state}${result.aggregateSha ? ` (${result.aggregateSha})` : ""}`);
@@ -227,7 +234,8 @@ export function reportReverted(result, { tenant, label }) {
227
234
  * message:string|null, secret:string, storefrontUrl?:string|null, yes?:boolean,
228
235
  * env?:NodeJS.ProcessEnv }} params
229
236
  * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
230
- * resolveViewerTransport?:typeof resolveViewerTransport }} [deps]
237
+ * resolveViewerTransport?:typeof resolveViewerTransport,
238
+ * wait?:Parameters<typeof waitForPipelineJob>[2] }} [deps]
231
239
  * @returns {Promise<number>} process exit code
232
240
  */
233
241
  export async function runRevert(
@@ -314,11 +322,16 @@ export async function runRevert(
314
322
  } catch {
315
323
  /* non-JSON / empty body */
316
324
  }
325
+ if (isJobAccepted(res, data)) {
326
+ console.log(` submitted as job ${data.jobId} — waiting for it to finish (it continues on the platform if you stop waiting)…`);
327
+ const { outcome } = await waitForPipelineJob({ base, authHeaders, jobId: data.jobId }, fetchImpl, deps.wait);
328
+ data = { ...outcome, jobId: data.jobId };
329
+ }
317
330
  const result = normalizeRevertResponse(data);
318
331
 
319
332
  // A pre-flight error (auth, unknown tenant, bad body) is an HTTP 4xx with
320
- // `{ error }` and no queue verdict — surface it distinctly from a red run.
321
- if (!res.ok && result.queueState == null) {
333
+ // `{ error }` and no job outcome — surface it distinctly from a red run.
334
+ if (!res.ok) {
322
335
  const msg = result.error || `HTTP ${res.status}`;
323
336
  console.error(
324
337
  fail(
@@ -28,11 +28,12 @@
28
28
  * needed. `--watch` just raises the budget (longer waitMs, more attempts) so an
29
29
  * attached dev can stay and watch reconcile+compliance resolve live.
30
30
  *
31
- * Watching through to accept (E1b): `--watch` also sets untilShipped, so once
32
- * reconcile resolves cleanly the loop keeps going (preview_status reuses the
33
- * SAME waitMs budget server-side to long-poll for a ship decision too — one
34
- * poll axis, not two) until change_accept ships it or the attempts budget
35
- * runs out. A failed reconcile never waits for ship — it can't have shipped.
31
+ * Watching through to live (E1b): `--watch` also sets untilShipped, so once
32
+ * reconcile resolves cleanly the loop keeps going until the pipeline verdict
33
+ * (`preview_status.verdict`) shows this commit live — on Cloudflare once the
34
+ * pointer flip is proven, on CloudFront once the CI publish is confirmed — or the
35
+ * attempts budget runs out. A failed reconcile never waits for ship — it can't
36
+ * have shipped.
36
37
  * Detached devs get the E1 email instead; this is the attached half.
37
38
  *
38
39
  * Dependency-free (global fetch + `git`).
@@ -1798,7 +1799,7 @@ export async function run(argv, ctx, {
1798
1799
  let status;
1799
1800
  operation = "preview_status";
1800
1801
  if (args.noWait) {
1801
- status = normalizePreviewStatus(await scoped.callTool("preview_status", { commit: statusSha }));
1802
+ status = normalizePreviewStatus(await scoped.callTool("preview_status", { commit: statusSha }), statusSha);
1802
1803
  } else {
1803
1804
  // One in-place status line (TTY: a spinner with an elapsed-seconds counter;
1804
1805
  // non-TTY: a ~10s heartbeat) instead of a newline per poll — the wait reads
@@ -1880,10 +1881,10 @@ export async function run(argv, ctx, {
1880
1881
  /**
1881
1882
  * Normalize a `preview_status` tool response to the contract shape the CLI reports
1882
1883
  * on: { status, reconcile:{ok,errors}, compliance:{verdict,detail}, previewUrl,
1883
- * shipped, delivery, dispatched }. A live tool always returns status
1884
+ * shipped, delivery, dispatched, verdict }. A live tool always returns status
1884
1885
  * pending|reconciled|failed; anything else (an older MCP without the tool's flat
1885
1886
  * fields) normalizes to "unknown" so the CLI degrades visibly instead of pretending
1886
- * it reconciled. `shipped` (E1b) is null until change_accept ships this exact commit.
1887
+ * it reconciled. `shipped` (E1b) is derived from the verdict: null until this commit is live.
1887
1888
  *
1888
1889
  * `delivery` is the MCP's reconcile-observability triad for the Gitea webhook that
1889
1890
  * fired on this commit ({ target, actual, evidence, drift? }) — or null when NO
@@ -1893,7 +1894,11 @@ export async function run(argv, ctx, {
1893
1894
  * read under a different tenant scope than it was pushed to), which is a permanent
1894
1895
  * dead-end the CLI must not report as "still reconciling". Pure — unit-tested.
1895
1896
  */
1896
- export function normalizePreviewStatus(r) {
1897
+ /**
1898
+ * @param {any} r the `preview_status` response
1899
+ * @param {string|null} [commit] the sha the status was asked for (what `shipped` is decided against)
1900
+ */
1901
+ export function normalizePreviewStatus(r, commit = null) {
1897
1902
  const status = r?.status;
1898
1903
  const known = status === "pending" || status === "reconciled" || status === "failed";
1899
1904
  const delivery = r?.delivery ?? null;
@@ -1902,7 +1907,7 @@ export function normalizePreviewStatus(r) {
1902
1907
  reconcile: r?.reconcile ?? null,
1903
1908
  compliance: r?.compliance ?? null,
1904
1909
  previewUrl: r?.previewUrl ?? null,
1905
- shipped: r?.shipped ?? null,
1910
+ shipped: shippedFromVerdict(r?.verdict ?? null, commit),
1906
1911
  delivery,
1907
1912
  dispatched: delivery != null,
1908
1913
  // The ONE pipeline-status read every surface (console, `preview_status`, `tot
@@ -1913,6 +1918,26 @@ export function normalizePreviewStatus(r) {
1913
1918
  };
1914
1919
  }
1915
1920
 
1921
+ /**
1922
+ * Whether the pipeline verdict shows `commit` live, and where: the live release IS this commit, or
1923
+ * the live release is the next release this commit's change was merged into (its membership names
1924
+ * the commit as a change head). Per provider, the verdict's live release is recorded only once
1925
+ * delivery is proven — the Cloudflare pointer flip read back, or the CloudFront CI publish
1926
+ * confirmed. Null when not live (or no verdict). Pure — unit-tested.
1927
+ * @param {any} verdict @param {string|null} commit
1928
+ * @returns {{ sha: string, receiptId: string|null, provider: string|null, provedAt: string|null }|null}
1929
+ */
1930
+ export function shippedFromVerdict(verdict, commit) {
1931
+ if (!commit || verdict?.kind !== "idle" || !verdict.live?.sha) return null;
1932
+ const live = verdict.live;
1933
+ const merged =
1934
+ verdict.preview?.sha === live.sha &&
1935
+ Array.isArray(verdict.preview?.membership) &&
1936
+ verdict.preview.membership.some((m) => m?.headSha === commit);
1937
+ if (live.sha !== commit && !merged) return null;
1938
+ return { sha: live.sha, receiptId: live.receiptId ?? null, provider: live.provider ?? null, provedAt: live.provedAt ?? null };
1939
+ }
1940
+
1916
1941
  /** A submit succeeds only when reconcile has neither failed nor failed to start. */
1917
1942
  export function previewSubmitSucceeded(status) {
1918
1943
  const knownSuccessState = status?.status === "pending" || status?.status === "reconciled";
@@ -1971,7 +1996,7 @@ export async function pollPreviewStatus(
1971
1996
  for (let i = 0; i < attempts; i++) {
1972
1997
  const startedAt = now();
1973
1998
  const args = waitMs ? { commit, waitMs } : { commit };
1974
- last = normalizePreviewStatus(await scoped.callTool("preview_status", args));
1999
+ last = normalizePreviewStatus(await scoped.callTool("preview_status", args), commit);
1975
2000
  if (last.dispatched) everDispatched = true;
1976
2001
  last.everDispatched = everDispatched;
1977
2002
  if (onTick) onTick(last, i);
@@ -2194,6 +2219,25 @@ export function formatVerdictBlock(verdict, supportBundleText = null) {
2194
2219
  * --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
2195
2220
  * feed the honest never-dispatched block.
2196
2221
  */
2222
+ /**
2223
+ * The ship line after a watched submit: where this commit is live (per provider) once the verdict
2224
+ * shows it, or that a reviewer still has to accept it. Null when reconcile has not settled. Pure.
2225
+ * @param {{ status?: string, shipped?: { sha: string, receiptId: string|null, provider: string|null, provedAt: string|null }|null }} s
2226
+ */
2227
+ export function formatShipLine(s) {
2228
+ if (s?.shipped) {
2229
+ const where =
2230
+ s.shipped.provider === "s3-overwrite" ? "CloudFront" : s.shipped.provider === "worker-r2-pointer" ? "Cloudflare" : "your live site";
2231
+ return (
2232
+ `\n ✓ shipped — live at ${s.shipped.sha.slice(0, 9)} on ${where}` +
2233
+ (s.shipped.receiptId ? ` (receipt ${s.shipped.receiptId})` : "") +
2234
+ (s.shipped.provedAt ? `, proven ${s.shipped.provedAt}` : "")
2235
+ );
2236
+ }
2237
+ if (s?.status === "reconciled") return `\n ~ not yet shipped — a reviewer still needs to run change_accept.`;
2238
+ return null;
2239
+ }
2240
+
2197
2241
  /**
2198
2242
  * @param {any} s @param {string} tenant
2199
2243
  * @param {{ open?: boolean, quiet?: boolean, commit?: string|null, ref?: string|null, verb?: string, noChanges?: boolean }} [opts]
@@ -2249,11 +2293,8 @@ function reportStatus(s, tenant, { open = true, quiet = false, commit = null, re
2249
2293
  console.log(` compliance: ${s.compliance.verdict}${s.compliance.detail ? ` — ${s.compliance.detail}` : ""}`);
2250
2294
  for (const c of s.compliance.checks ?? []) reportComplianceCheck(c);
2251
2295
  }
2252
- if (s.shipped) {
2253
- console.log(`\n ✓ shipped — change ${s.shipped.changeId} accepted at ${s.shipped.shippedAt}`);
2254
- } else if (s.status === "reconciled") {
2255
- console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
2256
- }
2296
+ const shipLine = formatShipLine(s);
2297
+ if (shipLine) console.log(shipLine);
2257
2298
  for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
2258
2299
  }
2259
2300
  if (s.previewUrl && open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Waiting on a store pipeline job. Add to release (`tot accept`) and Undo (`tot revert`) run as
3
+ * jobs in the store's pipeline: the route answers `202 {jobId}` at once, and the job's outcome is
4
+ * read from `GET /api/pipeline/jobs/<jobId>` until it is no longer pending. The job runs on the
5
+ * platform whether or not anything is waiting for it — stopping here (Ctrl-C, a timeout) stops
6
+ * only the wait.
7
+ *
8
+ * Dependency-free (global fetch).
9
+ */
10
+
11
+ const DEFAULT_INTERVAL_MS = 3_000;
12
+ const DEFAULT_TIMEOUT_MS = 25 * 60_000;
13
+
14
+ /**
15
+ * Poll the job until its outcome is terminal, or `timeoutMs` passes. Resolves with
16
+ * `{ outcome, timedOut }` — `outcome` is the last read (`pending: true` when it timed out).
17
+ * Network faults between polls are retried; a non-2xx read ends the wait with that status.
18
+ *
19
+ * @param {{ base:string, authHeaders:Record<string,string>, jobId:string }} target
20
+ * @param {typeof fetch} fetchImpl
21
+ * @param {{ intervalMs?:number, timeoutMs?:number, sleep?:(ms:number)=>Promise<void>,
22
+ * now?:()=>number, onPoll?:(outcome:any)=>void }} [opts]
23
+ */
24
+ export async function waitForPipelineJob({ base, authHeaders, jobId }, fetchImpl, opts = {}) {
25
+ const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
26
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
27
+ const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
28
+ const now = opts.now ?? (() => Date.now());
29
+ const started = now();
30
+ let last = { ok: false, pending: true, jobId };
31
+ for (;;) {
32
+ let res = null;
33
+ try {
34
+ res = await fetchImpl(`${base}/api/pipeline/jobs/${encodeURIComponent(jobId)}`, {
35
+ method: "GET",
36
+ headers: { accept: "application/json", ...authHeaders },
37
+ });
38
+ } catch {
39
+ res = null;
40
+ }
41
+ if (res) {
42
+ let data = {};
43
+ try {
44
+ data = await res.json();
45
+ } catch {
46
+ data = {};
47
+ }
48
+ if (!res.ok) {
49
+ return { outcome: { ok: false, pending: true, jobId, error: data.error || `job read failed (${res.status})` }, timedOut: false };
50
+ }
51
+ last = data && data.outcome && typeof data.outcome === "object" ? data.outcome : last;
52
+ opts.onPoll?.(last);
53
+ if (last.pending !== true) return { outcome: last, timedOut: false };
54
+ }
55
+ if (now() - started >= timeoutMs) return { outcome: last, timedOut: true };
56
+ await sleep(intervalMs);
57
+ }
58
+ }
59
+
60
+ /** True when a submit answered with a job to wait on (`202 {jobId}`). */
61
+ export function isJobAccepted(res, data) {
62
+ return Boolean(res && res.status === 202 && data && typeof data.jobId === "string");
63
+ }