@tokenoftrust/cli 2.3.0 → 2.4.1

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
@@ -61,6 +61,7 @@ const COMMANDS = [
61
61
  { invocation: "report go-live --pages <dir>", blurb: "your store's go-live readiness report, from its page-check results" },
62
62
  { invocation: "dev", blurb: "run your store locally with save→reload" },
63
63
  { invocation: "preview", blurb: "push your store to a reviewable preview" },
64
+ { invocation: "status", blurb: "the pipeline verdict for your active tenant — progressing / stuck / idle, and why" },
64
65
  { invocation: "ship", blurb: "publish the tenant's current green aggregate live (plan → confirm → ship)" },
65
66
  { invocation: "accept / tot merge", blurb: "queue a PR into the preview aggregate — operator verb, no go-live (plan → confirm → integrate)" },
66
67
  { invocation: "sync", blurb: "fetch `preview` and merge it into your local branch (accept-conflict recovery)" },
@@ -201,6 +202,11 @@ async function dispatch(cmd, rest, ctx) {
201
202
  return run(rest, ctx);
202
203
  }
203
204
 
205
+ if (cmd === "status") {
206
+ const { run } = await import("../src/commands/status.mjs");
207
+ return run(rest, ctx);
208
+ }
209
+
204
210
  if (cmd === "submit" || cmd === "deploy") {
205
211
  const { run } = await import("../src/commands/preview.mjs");
206
212
  return run(rest, ctx, { alias: cmd });
@@ -213,8 +219,8 @@ async function dispatch(cmd, rest, ctx) {
213
219
 
214
220
  // `accept` queue-integrates a PR into the protected `preview` aggregate —
215
221
  // NO merge-to-main, NO go-live; going live is `tot ship`, which publishes the
216
- // whole current GREEN AGGREGATE. Backed by POST /api/changes/integrate
217
- // (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
218
224
  // first-class alias, not a teaching nudge: both dispatch straight to the same command.
219
225
  if (cmd === "accept" || cmd === "merge") {
220
226
  const { run } = await import("../src/commands/accept.mjs");
@@ -239,8 +245,8 @@ async function dispatch(cmd, rest, ctx) {
239
245
  // `revert --preview <PR|change-id>` undoes an already-integrated PR out of the
240
246
  // protected `preview` aggregate by opening a NEW corrective candidate and
241
247
  // merging it through the SAME accept pipeline — NO force-reset, NO touch to
242
- // main/live. Backed by POST /api/changes/revert (TenantIntegrationQueue.enqueue,
243
- // 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,
244
250
  // that's `tot rollback`, not this. See revert.mjs's header.
245
251
  if (cmd === "revert") {
246
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.3.0",
3
+ "version": "2.4.1",
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(
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `tot status` — the pipeline verdict for your active tenant (migration DAG §U4):
3
+ * the SAME `kind`/`reason`/`nextAction` the Publish console and `tot preview`/`tot
4
+ * submit` print, available on its own without pushing anything. Reads
5
+ * `store_status` (tenant/repo-scoped, zero live Gitea calls) and prints its
6
+ * `verdict` field — pipeline state comes from there, never re-derived here.
7
+ *
8
+ * Dependency-free (global fetch via mcp.mjs's client).
9
+ */
10
+ import { createMcpClient } from "../mcp.mjs";
11
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
12
+ import { fail } from "../errors.mjs";
13
+ import { scopeToTenant } from "../tenant-scope.mjs";
14
+ import { resolveRepoAndTenant } from "./branches.mjs";
15
+ import { formatVerdictBlock } from "./submit.mjs";
16
+
17
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
18
+
19
+ const USAGE = `tot status — the pipeline verdict for your active tenant
20
+
21
+ tot status verdict for the current checkout's repo
22
+ tot status --tenant <d> verdict for a specific store (e.g. --tenant acme.com)
23
+ tot status --json machine-readable output
24
+
25
+ Options:
26
+ --tenant <appDomain> the store to read (default: the current checkout / its remote)
27
+ --repo <repo> an explicit repo name, when it differs from --tenant
28
+ --mcp <url> override the MCP base URL
29
+ --json print the raw { verdict, ... } payload instead of prose
30
+ `;
31
+
32
+ /**
33
+ * @param {string[]} argv
34
+ * @returns {{ tenant: string|null, repo: string|null, mcp: string|null, json: boolean, help: boolean }}
35
+ */
36
+ function parseStatusArgs(argv) {
37
+ const args = /** @type {{ tenant: string|null, repo: string|null, mcp: string|null, json: boolean, help: boolean }} */ ({
38
+ tenant: null, repo: null, mcp: null, json: false, help: false,
39
+ });
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const a = argv[i];
42
+ if (a === "--help" || a === "-h") args.help = true;
43
+ else if (a === "--json") args.json = true;
44
+ else if (a === "--tenant") args.tenant = argv[++i] ?? null;
45
+ else if (a === "--repo") args.repo = argv[++i] ?? null;
46
+ else if (a === "--mcp") args.mcp = argv[++i] ?? null;
47
+ }
48
+ return args;
49
+ }
50
+
51
+ /** @param {string[]} argv @param {any} ctx */
52
+ export async function run(argv, ctx) {
53
+ const env = process.env;
54
+ const args = parseStatusArgs(argv);
55
+ if (args.help) {
56
+ console.log(USAGE);
57
+ return 0;
58
+ }
59
+
60
+ const { repo, tenant, error } = resolveRepoAndTenant(args, ctx);
61
+ if (error) {
62
+ console.error(error);
63
+ return 2;
64
+ }
65
+
66
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
67
+ const client = createMcpClient(baseUrl);
68
+ try {
69
+ await establishSession(client, { env });
70
+ const scoped = await scopeToTenant(client, /** @type {string} */ (tenant));
71
+ const result = await scoped.callTool("store_status", { repo, include: ["branches"] });
72
+
73
+ if (result?.status === "error" || result?.status === "invalid_input" || result?.error) {
74
+ console.error(
75
+ fail(
76
+ `couldn't read status for ${repo}: ${result.error || result.message || "unknown error"}`,
77
+ "check --tenant / --repo, then re-run",
78
+ ),
79
+ );
80
+ return 1;
81
+ }
82
+
83
+ if (args.json) {
84
+ console.log(JSON.stringify({ verdict: result?.verdict ?? null, tenant: repo }, null, 2));
85
+ return 0;
86
+ }
87
+
88
+ const verdict = result?.verdict ?? null;
89
+ if (!verdict) {
90
+ console.log(` (no pipeline verdict available for ${repo} right now.)`);
91
+ return 0;
92
+ }
93
+ console.log(`${repo}:`);
94
+ for (const line of formatVerdictBlock(verdict)) console.log(line);
95
+ return 0;
96
+ } catch (e) {
97
+ if (e instanceof AuthUnavailableError) {
98
+ console.error(fail("sign in to read status", e.hint || "run `tot login`, then re-run"));
99
+ return 1;
100
+ }
101
+ console.error(
102
+ fail(
103
+ `couldn't reach the status service: ${String(e?.message || e)}`,
104
+ "check your connection and that you're signed in, then re-run",
105
+ ),
106
+ );
107
+ return 1;
108
+ }
109
+ }
@@ -962,14 +962,28 @@ export function chooseChangeId({ tenant, actorKey, branch = null, active = null,
962
962
  * @param {{ repo: string, changeId: string }} opts
963
963
  * @returns {Promise<string|null>}
964
964
  */
965
+ /**
966
+ * The FULL candidate_status record for one candidate (changeId/prNumber/branch/headSha/
967
+ * state/…), tolerant of the same three response shapes `candidateStateFor` tolerates
968
+ * (a bare candidate, `{candidates:[…]}`, or a plain array). Null when the tool throws or
969
+ * names no matching candidate — never thrown. Shared by `candidateStateFor` (state only)
970
+ * and `submitCandidate`'s 502-despite-success recovery (the full record).
971
+ * @param {{callTool:Function}} scoped the tenant-scoped handle from `scopeToTenant` (or a mock)
972
+ * @param {{ repo: string, changeId: string }} opts
973
+ */
974
+ export async function fetchCandidateStatus(scoped, { repo, changeId }) {
975
+ const r = await scoped.callTool("candidate_status", { repo, changeId });
976
+ const c = Array.isArray(r)
977
+ ? r.find((x) => x?.changeId === changeId)
978
+ : Array.isArray(r?.candidates)
979
+ ? r.candidates.find((x) => x?.changeId === changeId)
980
+ : r;
981
+ return c ?? null;
982
+ }
983
+
965
984
  export async function candidateStateFor(scoped, { repo, changeId }) {
966
985
  try {
967
- const r = await scoped.callTool("candidate_status", { repo, changeId });
968
- const c = Array.isArray(r)
969
- ? r.find((x) => x?.changeId === changeId)
970
- : Array.isArray(r?.candidates)
971
- ? r.candidates.find((x) => x?.changeId === changeId)
972
- : r;
986
+ const c = await fetchCandidateStatus(scoped, { repo, changeId });
973
987
  return c && typeof c.state === "string" ? c.state : null;
974
988
  } catch (error) {
975
989
  recordDiagnostic(error, {
@@ -1086,6 +1100,28 @@ export async function submitCandidate(scoped, { repo, changeId, ref, headSha, ch
1086
1100
  return result;
1087
1101
  } catch (e) {
1088
1102
  if (e instanceof CandidateRegistrationError) throw e;
1103
+ // "502 despite success" (migration DAG §U4): candidate_open's forge write is
1104
+ // idempotent and may have LANDED even though its HTTP response never reached us
1105
+ // (a 5xx between MCP and its own caller, a dropped connection, …). Before reporting
1106
+ // a fatal "candidate not created," re-read it by its stable changeId — if it's
1107
+ // already there with the exact ref/head we just pushed, this submit DID succeed;
1108
+ // report that instead of a false failure. Purely a recovery path: any error from
1109
+ // the re-read (including "not found," which is the ordinary case for a genuine
1110
+ // registration failure) falls through to the original fatal error, unchanged.
1111
+ const httpStatus = /** @type {any} */ (e)?.diagnostic?.httpStatus;
1112
+ if (typeof httpStatus === "number" && httpStatus >= 500) {
1113
+ const existing = await fetchCandidateStatus(scoped, { repo, changeId }).catch(() => null);
1114
+ if (
1115
+ existing
1116
+ && typeof existing.prNumber === "number"
1117
+ && existing.branch === ref
1118
+ && existing.headSha === headSha
1119
+ && !isTerminalCandidateState(existing.state)
1120
+ ) {
1121
+ reportCandidate(existing, changeId, { quiet });
1122
+ return existing;
1123
+ }
1124
+ }
1089
1125
  throw new CandidateRegistrationError(`Candidate not created: ${describeReadbackError(e)}`, e);
1090
1126
  }
1091
1127
  }
@@ -1134,7 +1170,7 @@ function reportCandidate(result, changeId, { quiet = false } = {}) {
1134
1170
  * candidate?: {prNumber?: number, number?: number, state?: string, url?: string, headSha?: string|null}|null,
1135
1171
  * status?: {status?: string, reconcile?: object|null, compliance?: object|null,
1136
1172
  * previewUrl?: string|null, shipped?: object|null, dispatched?: boolean|null,
1137
- * notDispatched?: boolean, delivery?: object|null}|null,
1173
+ * notDispatched?: boolean, delivery?: object|null, verdict?: object|null}|null,
1138
1174
  * previewPrUrl?: string|null, error?: string|null, note?: string|null, noChanges?: boolean,
1139
1175
  * degraded?: boolean, diagnosticId?: string|null }} input
1140
1176
  */
@@ -1160,6 +1196,7 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
1160
1196
  notDispatched: status?.notDispatched ?? false,
1161
1197
  forwardFailed: /** @type {any} */ (status)?.forwardFailed ?? false,
1162
1198
  delivery: status?.delivery ?? null,
1199
+ verdict: status?.verdict ?? null,
1163
1200
  previewPrUrl,
1164
1201
  ...(degraded ? { degraded: true } : {}),
1165
1202
  ...(diagnosticId ? { diagnosticId } : {}),
@@ -1868,6 +1905,10 @@ export function normalizePreviewStatus(r) {
1868
1905
  shipped: r?.shipped ?? null,
1869
1906
  delivery,
1870
1907
  dispatched: delivery != null,
1908
+ // The ONE pipeline-status read every surface (console, `preview_status`, `tot
1909
+ // status`) shares (migration DAG §U4) — null when it could not be read. Printed
1910
+ // alongside the legacy fields above, never replacing them.
1911
+ verdict: r?.verdict ?? null,
1871
1912
  raw: r,
1872
1913
  };
1873
1914
  }
@@ -2122,6 +2163,29 @@ export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
2122
2163
  ];
2123
2164
  }
2124
2165
 
2166
+ /**
2167
+ * The pipeline-verdict lines (migration DAG §U4): kind, reason, next action — the SAME
2168
+ * verdict the Publish console and `tot status` print, so a developer never sees a
2169
+ * different story in two places. On `stuck`, also prints the support bundle text when
2170
+ * the caller fetched one (preview_status attaches it only when `contactSupport` is
2171
+ * true). Pure — unit-tested. Returns `[]` when there is nothing to print (no verdict,
2172
+ * or a verdict object that isn't shaped as expected — never throws on a malformed one).
2173
+ * @param {any} verdict @param {string|null} [supportBundleText]
2174
+ * @returns {string[]}
2175
+ */
2176
+ export function formatVerdictBlock(verdict, supportBundleText = null) {
2177
+ if (!verdict || typeof verdict !== "object" || typeof verdict.kind !== "string") return [];
2178
+ const lines = [`\n verdict: ${verdict.kind}${verdict.reason ? ` — ${verdict.reason}` : ""}`];
2179
+ const next = verdict.nextAction;
2180
+ if (next && typeof next.label === "string") {
2181
+ lines.push(` next: ${next.label}${next.command ? ` (${next.command})` : ""}`);
2182
+ }
2183
+ if (verdict.kind === "stuck" && supportBundleText) {
2184
+ lines.push("", " — support bundle (share this with support) —", supportBundleText);
2185
+ }
2186
+ return lines;
2187
+ }
2188
+
2125
2189
  /**
2126
2190
  * Print the reconcile/compliance/preview result and, on a clean reconcile with a
2127
2191
  * preview URL, open it in the browser (unless opts.open === false). `quiet`
@@ -2135,6 +2199,7 @@ export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
2135
2199
  * @param {{ open?: boolean, quiet?: boolean, commit?: string|null, ref?: string|null, verb?: string, noChanges?: boolean }} [opts]
2136
2200
  */
2137
2201
  function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview", noChanges = false } = {}) {
2202
+ if (!quiet) for (const line of formatVerdictBlock(s?.verdict, s?.supportBundleText)) console.log(line);
2138
2203
  if (!s || s.status === "unknown") {
2139
2204
  if (!quiet) {
2140
2205
  console.log(
@@ -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
+ }
@@ -51,6 +51,7 @@ export const PIN_SCOPED_TOOLS = Object.freeze(new Set([
51
51
  "candidate_list",
52
52
  "candidate_delete",
53
53
  "candidate_refresh",
54
+ "store_status",
54
55
  ]));
55
56
 
56
57
  /**