@tokenoftrust/cli 2.0.11 → 2.0.14
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 +22 -3
- package/package.json +1 -1
- package/src/activity-log.mjs +2 -1
- package/src/activity.mjs +1 -1
- package/src/candidate-state.mjs +52 -27
- package/src/commands/clone.mjs +6 -1
- package/src/commands/dev.mjs +12 -2
- package/src/commands/feedback.mjs +18 -8
- package/src/commands/grants.mjs +5 -1
- package/src/commands/link.mjs +9 -1
- package/src/commands/submit.mjs +267 -211
- package/src/commands/sync.mjs +4 -0
- package/src/commands/whoami.mjs +5 -1
- package/src/diagnostics.mjs +224 -0
- package/src/errors.mjs +6 -1
- package/src/mcp.mjs +213 -28
- package/src/oauth.mjs +107 -37
- package/src/open.mjs +18 -3
package/src/commands/submit.mjs
CHANGED
|
@@ -4,9 +4,8 @@
|
|
|
4
4
|
* From inside a tenant checkout:
|
|
5
5
|
* 1. validate locally and refuse on errors (fail fast before anything leaves the machine),
|
|
6
6
|
* 2. push your committed work to YOUR OWN isolated candidate ref — `candidate/<changeId>`
|
|
7
|
-
* (b03 — no shared `preview` ref is ever force-pushed
|
|
8
|
-
*
|
|
9
|
-
* 2b. open/update a PR-BACKED CANDIDATE for the same committed diff (g1b's
|
|
7
|
+
* (b03 — no shared `preview` ref is ever force-pushed) — which triggers reconcile,
|
|
8
|
+
* 2b. register that exact ref + asserted HEAD as a PR-BACKED CANDIDATE (g1b's
|
|
10
9
|
* `candidate_open`, unit c1 — the local-dev-loop half of the "PR-Backed Hosted
|
|
11
10
|
* Review Loop" milestone, symmetric with the hosted s6 draft-as-PR path), and
|
|
12
11
|
* print the resulting changeId/PR number/URL,
|
|
@@ -14,14 +13,12 @@
|
|
|
14
13
|
*
|
|
15
14
|
* This is submit-for-PREVIEW, not ship-to-live (`change_accept`/`candidate_accept` /
|
|
16
15
|
* a future `tot ship` is the separate ship gate — this command is submit-only, never
|
|
17
|
-
* accept/reject). Step 2b
|
|
18
|
-
*
|
|
19
|
-
* refusal is REQUIRED and exits non-zero with identity-recovery guidance. The preview
|
|
20
|
-
* push may already have landed, but no unaudited PR is created. Step 3 calls
|
|
16
|
+
* accept/reject). Step 2b fails closed: a push without an exact, verified PR registration
|
|
17
|
+
* exits non-zero and keeps its stable retry identity. Step 3 calls
|
|
21
18
|
* the MCP `preview_status` read-back: given the commit just pushed it returns
|
|
22
19
|
* { status, reconcile, compliance, previewUrl } and we poll it while reconcile is
|
|
23
|
-
* pending.
|
|
24
|
-
*
|
|
20
|
+
* pending. A later read-back failure preserves the candidate receipt and returns a
|
|
21
|
+
* successful but explicitly degraded result with a diagnostic ID.
|
|
25
22
|
*
|
|
26
23
|
* Polling (E2): every call carries `waitMs` so a preview_status-aware MCP long-polls
|
|
27
24
|
* (blocks up to waitMs, waking immediately on arrival) instead of us sleeping blind
|
|
@@ -57,6 +54,11 @@ import { openBrowser } from "../open.mjs";
|
|
|
57
54
|
import { startProgress } from "../progress.mjs";
|
|
58
55
|
import { fail } from "../errors.mjs";
|
|
59
56
|
import { emitActivity } from "../activity.mjs";
|
|
57
|
+
import {
|
|
58
|
+
diagnosticReference,
|
|
59
|
+
invocationDegradedResult,
|
|
60
|
+
recordDiagnostic,
|
|
61
|
+
} from "../diagnostics.mjs";
|
|
60
62
|
import {
|
|
61
63
|
defaultCandidateStatePath,
|
|
62
64
|
readActiveChangeId,
|
|
@@ -161,7 +163,7 @@ export function renderUsage(verb = "preview") {
|
|
|
161
163
|
merge, surfaces a resolve card on a genuine overlap — the
|
|
162
164
|
default), "ours" (keep yours), "theirs" (keep the store's)
|
|
163
165
|
tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
|
|
164
|
-
tot ${verb} --ref <name>
|
|
166
|
+
tot ${verb} --ref <name> assert the generated candidate/<changeId> ref (custom refs are refused)
|
|
165
167
|
tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
|
|
166
168
|
tot ${verb} --summary "<text>" longer description to accompany the title
|
|
167
169
|
tot ${verb} --summary-file <path> structured summary from a file — JSON
|
|
@@ -446,15 +448,9 @@ export function baseCommitsBehind(git, branch) {
|
|
|
446
448
|
}
|
|
447
449
|
|
|
448
450
|
/**
|
|
449
|
-
* Resolve the git ref to
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
* patch — so the patch MUST be the FULL delta of your branch vs its fork point off
|
|
453
|
-
* `preview`, never "what changed since my last candidate push". Diffing against your
|
|
454
|
-
* own candidate tracking ref is what silently produced an empty patch (and the wrong
|
|
455
|
-
* "no file changes" skip, so no PR opened) after a push that landed but failed to open
|
|
456
|
-
* its PR: that ref already equals HEAD, so the since-last-push delta is empty even
|
|
457
|
-
* though the change vs preview is 19 files. Prefer the merge-base (fork point) with
|
|
451
|
+
* Resolve the git ref used to summarize the candidate's complete delta. Diffing
|
|
452
|
+
* against the already-pushed candidate tracking ref would be empty on retry even
|
|
453
|
+
* though the change vs preview is non-empty. Prefer the merge-base (fork point) with
|
|
458
454
|
* `origin/<baseBranch>`; fall back to the candidate tracking ref, then HEAD~1, then ""
|
|
459
455
|
* (single-commit `git show`) when no base ref resolves. Pure git I/O via the injected
|
|
460
456
|
* runner — unit-tested.
|
|
@@ -510,7 +506,13 @@ export async function runBornRebased(client, { repo, changeId, strategy }) {
|
|
|
510
506
|
const raw = await client.callTool("candidate_refresh", { repo, changeId, strategy });
|
|
511
507
|
return normalizeRefreshResult(raw);
|
|
512
508
|
} catch (e) {
|
|
513
|
-
|
|
509
|
+
const diagnostic = recordDiagnostic(e, {
|
|
510
|
+
command: "submit", operation: "candidate_refresh", required: false, degraded: true,
|
|
511
|
+
});
|
|
512
|
+
return {
|
|
513
|
+
...normalizeRefreshResult(null), status: "error", message: String(e?.message || e),
|
|
514
|
+
degraded: true, diagnosticId: diagnostic.diagnosticId,
|
|
515
|
+
};
|
|
514
516
|
}
|
|
515
517
|
}
|
|
516
518
|
|
|
@@ -766,38 +768,6 @@ export function parseNameStatus(text) {
|
|
|
766
768
|
.filter((e) => e.path);
|
|
767
769
|
}
|
|
768
770
|
|
|
769
|
-
/**
|
|
770
|
-
* Build the g1b `candidate_open` FileChange[] patch from parsed name-status
|
|
771
|
-
* entries — one entry per changed path, `delete:true` for removals (and a
|
|
772
|
-
* rename's old path). Content is read from the committed HEAD blob via
|
|
773
|
-
* `readBlob` (injected — never the working tree, so the patch matches exactly
|
|
774
|
-
* what was pushed). Binary content (doesn't round-trip as clean utf8, or
|
|
775
|
-
* contains a NUL) is sent base64; everything else goes as plain utf8 text.
|
|
776
|
-
* @param {{status:string, path:string, from?:string}[]} entries
|
|
777
|
-
* @param {(path: string) => Buffer} readBlob
|
|
778
|
-
* @returns {Array<{path: string, content?: string, contentEncoding?: "base64", delete?: true}>}
|
|
779
|
-
*/
|
|
780
|
-
export function buildFilePatch(entries, readBlob) {
|
|
781
|
-
/** @type {Array<{ path: string, content?: string, contentEncoding?: "base64", delete?: true }>} */
|
|
782
|
-
const patch = [];
|
|
783
|
-
for (const e of entries) {
|
|
784
|
-
if (e.status === "R") patch.push({ path: /** @type {string} */ (e.from), delete: true });
|
|
785
|
-
if (e.status === "D") {
|
|
786
|
-
patch.push({ path: e.path, delete: true });
|
|
787
|
-
continue;
|
|
788
|
-
}
|
|
789
|
-
const buf = readBlob(e.path);
|
|
790
|
-
const asUtf8 = buf.toString("utf8");
|
|
791
|
-
const isCleanUtf8 = !asUtf8.includes("\x00") && Buffer.from(asUtf8, "utf8").equals(buf);
|
|
792
|
-
patch.push(
|
|
793
|
-
isCleanUtf8
|
|
794
|
-
? { path: e.path, content: asUtf8 }
|
|
795
|
-
: { path: e.path, content: buf.toString("base64"), contentEncoding: "base64" },
|
|
796
|
-
);
|
|
797
|
-
}
|
|
798
|
-
return patch;
|
|
799
|
-
}
|
|
800
|
-
|
|
801
771
|
/**
|
|
802
772
|
* Derive the g1b forge repo name (`"<tenant>-<tag>"`, e.g. `"acme.com-main"`)
|
|
803
773
|
* from the checkout's authenticated origin remote — the SAME name
|
|
@@ -1001,7 +971,10 @@ export async function candidateStateFor(client, { repo, changeId }) {
|
|
|
1001
971
|
? r.candidates.find((x) => x?.changeId === changeId)
|
|
1002
972
|
: r;
|
|
1003
973
|
return c && typeof c.state === "string" ? c.state : null;
|
|
1004
|
-
} catch {
|
|
974
|
+
} catch (error) {
|
|
975
|
+
recordDiagnostic(error, {
|
|
976
|
+
command: "submit", operation: "candidate_status", required: false, degraded: true,
|
|
977
|
+
});
|
|
1005
978
|
return null;
|
|
1006
979
|
}
|
|
1007
980
|
}
|
|
@@ -1034,17 +1007,18 @@ export async function resolveActivePointer(client, { repo, active }) {
|
|
|
1034
1007
|
|
|
1035
1008
|
/**
|
|
1036
1009
|
* The ref `tot preview`/`tot submit` pushes to (b03 — stop force-pushing the
|
|
1037
|
-
* SHARED `preview` ref).
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
* isolated candidate ref, so two developers — or the same developer on two
|
|
1041
|
-
* branches — never force-push over each other or each other's preview. Pure —
|
|
1042
|
-
* unit-tested.
|
|
1010
|
+
* SHARED `preview` ref). The only valid target is the exact ref derived from the
|
|
1011
|
+
* stable candidate identity. `--ref` may assert that value, but cannot override it.
|
|
1012
|
+
* Pure — unit-tested.
|
|
1043
1013
|
* @param {{ ref?: string|null, changeId: string }} opts
|
|
1044
1014
|
* @returns {string}
|
|
1045
1015
|
*/
|
|
1046
1016
|
export function resolvePushRef({ ref, changeId }) {
|
|
1047
|
-
|
|
1017
|
+
const expected = candidateRefFor(changeId);
|
|
1018
|
+
if (ref && ref !== expected) {
|
|
1019
|
+
throw new Error(`--ref must be exactly ${expected}; custom refs cannot be registered as this candidate.`);
|
|
1020
|
+
}
|
|
1021
|
+
return expected;
|
|
1048
1022
|
}
|
|
1049
1023
|
|
|
1050
1024
|
const REQUIRED_ATTRIBUTION_REFUSALS = new Set([
|
|
@@ -1052,40 +1026,32 @@ const REQUIRED_ATTRIBUTION_REFUSALS = new Set([
|
|
|
1052
1026
|
"audit_unavailable",
|
|
1053
1027
|
]);
|
|
1054
1028
|
|
|
1055
|
-
class
|
|
1056
|
-
constructor(message) {
|
|
1057
|
-
super(message);
|
|
1058
|
-
this.name = "
|
|
1029
|
+
export class CandidateRegistrationError extends Error {
|
|
1030
|
+
constructor(message, cause) {
|
|
1031
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
1032
|
+
this.name = "CandidateRegistrationError";
|
|
1059
1033
|
}
|
|
1060
1034
|
}
|
|
1061
1035
|
|
|
1062
1036
|
/**
|
|
1063
1037
|
* Open/update the PR-backed candidate for this submit (g1b `candidate_open`,
|
|
1064
1038
|
* unit c1 — the local-dev-loop half of the "PR-Backed Hosted Review Loop"
|
|
1065
|
-
* milestone, symmetric with the hosted s6 draft-as-PR path).
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
* older MCP, version control not configured, preview-access capability, …) is
|
|
1070
|
-
* reported and swallowed — except an audit/identity refusal, which is required
|
|
1071
|
-
* and propagates so the command exits non-zero with the MCP's recovery guidance.
|
|
1039
|
+
* milestone, symmetric with the hosted s6 draft-as-PR path). The Git-capable CLI
|
|
1040
|
+
* has already pushed its real commits, so this call sends only the exact candidate
|
|
1041
|
+
* ref and asserted HEAD plus PR metadata. Any registration failure is fatal: a
|
|
1042
|
+
* pushed ref without a review PR is not a successful submit.
|
|
1072
1043
|
* @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
|
|
1073
|
-
* @param {{ repo: string|null, changeId: string,
|
|
1074
|
-
*
|
|
1044
|
+
* @param {{ repo: string|null, changeId: string, ref: string, headSha: string,
|
|
1045
|
+
* changeSummary: {title:string, body:string[]}, hasChanges: boolean,
|
|
1075
1046
|
* quiet?: boolean }} opts `quiet` (--json) suppresses the human print; the same
|
|
1076
1047
|
* result is still returned for the caller's JSON payload.
|
|
1077
1048
|
*/
|
|
1078
|
-
export async function submitCandidate(client, { repo, changeId,
|
|
1049
|
+
export async function submitCandidate(client, { repo, changeId, ref, headSha, changeSummary, hasChanges, quiet = false }) {
|
|
1079
1050
|
if (!repo) {
|
|
1080
|
-
|
|
1081
|
-
return null;
|
|
1051
|
+
throw new CandidateRegistrationError("Candidate not created: couldn't derive the forge repo from origin.");
|
|
1082
1052
|
}
|
|
1053
|
+
if (!hasChanges) throw new CandidateRegistrationError("Candidate not created: no content changes were found.");
|
|
1083
1054
|
try {
|
|
1084
|
-
const patch = buildFilePatch(patchEntries, readBlob);
|
|
1085
|
-
if (patch.length === 0) {
|
|
1086
|
-
if (!quiet) console.log(` ~ no file changes to open a PR-backed candidate for.`);
|
|
1087
|
-
return null;
|
|
1088
|
-
}
|
|
1089
1055
|
const result = await client.callTool("candidate_open", {
|
|
1090
1056
|
repo,
|
|
1091
1057
|
changeId,
|
|
@@ -1094,22 +1060,33 @@ export async function submitCandidate(client, { repo, changeId, changeSummary, p
|
|
|
1094
1060
|
// truncated PR title/body instead of an entirely-avoidable tool refusal.
|
|
1095
1061
|
title: changeSummary.title.slice(0, 200),
|
|
1096
1062
|
body: changeSummary.body.length ? changeSummary.body.join("\n").slice(0, 4000) : undefined,
|
|
1097
|
-
|
|
1063
|
+
ref,
|
|
1064
|
+
headSha,
|
|
1065
|
+
idempotencyKey: `candidate-open:${repo}:${changeId}:${headSha}`,
|
|
1098
1066
|
});
|
|
1099
|
-
if (REQUIRED_ATTRIBUTION_REFUSALS.has(result?.status)) {
|
|
1100
|
-
throw new
|
|
1101
|
-
result?.message || "Candidate not created:
|
|
1067
|
+
if (REQUIRED_ATTRIBUTION_REFUSALS.has(result?.status) || result?.status === "error") {
|
|
1068
|
+
throw new CandidateRegistrationError(
|
|
1069
|
+
result?.message || "Candidate not created: the MCP refused registration.",
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
if (typeof result?.prNumber !== "number") {
|
|
1073
|
+
throw new CandidateRegistrationError(
|
|
1074
|
+
result?.message || "Candidate not created: candidate_open returned no pull request.",
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
if (result.branch !== ref || result.headSha !== headSha) {
|
|
1078
|
+
throw new CandidateRegistrationError(
|
|
1079
|
+
`Candidate not created: MCP registered ${result.branch || "an unknown ref"}@${result.headSha || "unknown"}, expected ${ref}@${headSha}.`,
|
|
1102
1080
|
);
|
|
1103
1081
|
}
|
|
1082
|
+
if (isTerminalCandidateState(result.state)) {
|
|
1083
|
+
throw new CandidateRegistrationError(`Candidate not created: candidate_open returned terminal state ${result.state}.`);
|
|
1084
|
+
}
|
|
1104
1085
|
reportCandidate(result, changeId, { quiet });
|
|
1105
1086
|
return result;
|
|
1106
1087
|
} catch (e) {
|
|
1107
|
-
if (e instanceof
|
|
1108
|
-
|
|
1109
|
-
console.log(` ~ couldn't open/update the PR-backed candidate: ${String(e?.message || e)}`);
|
|
1110
|
-
console.log(` (best-effort — your push is still in; this doesn't block reconcile.)`);
|
|
1111
|
-
}
|
|
1112
|
-
return null;
|
|
1088
|
+
if (e instanceof CandidateRegistrationError) throw e;
|
|
1089
|
+
throw new CandidateRegistrationError(`Candidate not created: ${describeReadbackError(e)}`, e);
|
|
1113
1090
|
}
|
|
1114
1091
|
}
|
|
1115
1092
|
|
|
@@ -1158,9 +1135,10 @@ function reportCandidate(result, changeId, { quiet = false } = {}) {
|
|
|
1158
1135
|
* status?: {status?: string, reconcile?: object|null, compliance?: object|null,
|
|
1159
1136
|
* previewUrl?: string|null, shipped?: object|null, dispatched?: boolean|null,
|
|
1160
1137
|
* notDispatched?: boolean, delivery?: object|null}|null,
|
|
1161
|
-
* previewPrUrl?: string|null, error?: string|null, note?: string|null, noChanges?: boolean
|
|
1138
|
+
* previewPrUrl?: string|null, error?: string|null, note?: string|null, noChanges?: boolean,
|
|
1139
|
+
* degraded?: boolean, diagnosticId?: string|null }} input
|
|
1162
1140
|
*/
|
|
1163
|
-
export function buildJsonResult({ ok, ref = null, commit = null, changeId = null, candidate = null, status = null, previewPrUrl = null, error = null, note = null, noChanges = false }) {
|
|
1141
|
+
export function buildJsonResult({ ok, ref = null, commit = null, changeId = null, candidate = null, status = null, previewPrUrl = null, error = null, note = null, noChanges = false, degraded = false, diagnosticId = null }) {
|
|
1164
1142
|
return {
|
|
1165
1143
|
ok,
|
|
1166
1144
|
ref,
|
|
@@ -1183,6 +1161,8 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
|
|
|
1183
1161
|
forwardFailed: /** @type {any} */ (status)?.forwardFailed ?? false,
|
|
1184
1162
|
delivery: status?.delivery ?? null,
|
|
1185
1163
|
previewPrUrl,
|
|
1164
|
+
...(degraded ? { degraded: true } : {}),
|
|
1165
|
+
...(diagnosticId ? { diagnosticId } : {}),
|
|
1186
1166
|
// Honest-diagnosis parity with the human-readable formatNotDispatchedBlock: a
|
|
1187
1167
|
// `--json` caller gets the SAME "why" signal a human sees on the console. Without
|
|
1188
1168
|
// this, `candidate:null, notDispatched:true, delivery:null` reads identically for
|
|
@@ -1195,6 +1175,45 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
|
|
|
1195
1175
|
};
|
|
1196
1176
|
}
|
|
1197
1177
|
|
|
1178
|
+
export function candidateFailureOutcome({
|
|
1179
|
+
error, candidateRequired, candidate, operation, command, env,
|
|
1180
|
+
ref, commit, changeId, previewPrUrl,
|
|
1181
|
+
}) {
|
|
1182
|
+
const required = candidateRequired && !candidate;
|
|
1183
|
+
const diagnostic = recordDiagnostic(error, {
|
|
1184
|
+
command, operation, required, degraded: !required,
|
|
1185
|
+
}, env);
|
|
1186
|
+
if (required) {
|
|
1187
|
+
const detail = error instanceof CandidateRegistrationError
|
|
1188
|
+
? error.message
|
|
1189
|
+
: String(error?.message || error);
|
|
1190
|
+
const note = `push succeeded but no review candidate was created: ${detail}`;
|
|
1191
|
+
return {
|
|
1192
|
+
required,
|
|
1193
|
+
exitCode: 1,
|
|
1194
|
+
note,
|
|
1195
|
+
diagnostic,
|
|
1196
|
+
result: buildJsonResult({
|
|
1197
|
+
ok: false, ref, commit, changeId, candidate: null, error: note,
|
|
1198
|
+
diagnosticId: diagnostic.diagnosticId,
|
|
1199
|
+
}),
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
const note = error instanceof AuthUnavailableError
|
|
1203
|
+
? `candidate created; sign in to read its reconcile/compliance result — ${error.hint || "developer sign-in pending"}`
|
|
1204
|
+
: `candidate created; result read-back is degraded: ${describeReadbackError(error)}`;
|
|
1205
|
+
return {
|
|
1206
|
+
required,
|
|
1207
|
+
exitCode: 0,
|
|
1208
|
+
note,
|
|
1209
|
+
diagnostic,
|
|
1210
|
+
result: buildJsonResult({
|
|
1211
|
+
ok: true, ref, commit, changeId, candidate, previewPrUrl, note,
|
|
1212
|
+
degraded: true, diagnosticId: diagnostic.diagnosticId,
|
|
1213
|
+
}),
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1198
1217
|
/**
|
|
1199
1218
|
* The sha `preview_status` actually resolves against. The candidate the forge opened
|
|
1200
1219
|
* may carry a DIFFERENT head than the commit you pushed (it is rebuilt onto the
|
|
@@ -1247,10 +1266,18 @@ function emitJson(args, payload) {
|
|
|
1247
1266
|
* `--json` (args.json) suppresses the human-readable stdout narration in favor of
|
|
1248
1267
|
* one structured result object at the end (see buildJsonResult) — stderr
|
|
1249
1268
|
* diagnostics (fail(), `~ …` progress lines) still print either way.
|
|
1250
|
-
* @param {string[]} argv @param {any} ctx
|
|
1269
|
+
* @param {string[]} argv @param {any} ctx
|
|
1270
|
+
* @param {{ verb?: string, env?: NodeJS.ProcessEnv,
|
|
1271
|
+
* createClient?: typeof createMcpClient, establish?: typeof establishSession,
|
|
1272
|
+
* checkout?: typeof checkoutTenant }} [opts]
|
|
1251
1273
|
*/
|
|
1252
|
-
export async function run(argv, ctx, {
|
|
1253
|
-
|
|
1274
|
+
export async function run(argv, ctx, {
|
|
1275
|
+
verb = "preview",
|
|
1276
|
+
env = process.env,
|
|
1277
|
+
createClient = createMcpClient,
|
|
1278
|
+
establish = establishSession,
|
|
1279
|
+
checkout = checkoutTenant,
|
|
1280
|
+
} = {}) {
|
|
1254
1281
|
const args = parseArgs(argv);
|
|
1255
1282
|
if (args.help) {
|
|
1256
1283
|
console.log(renderUsage(verb));
|
|
@@ -1302,8 +1329,10 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1302
1329
|
// expired. Best-effort — never blocks the actual preview on a migration hiccup.
|
|
1303
1330
|
try {
|
|
1304
1331
|
ensureTokenlessRemote(git);
|
|
1305
|
-
} catch {
|
|
1306
|
-
|
|
1332
|
+
} catch (error) {
|
|
1333
|
+
recordDiagnostic(error, {
|
|
1334
|
+
command: verb, operation: "credential_helper_install", required: false, degraded: true,
|
|
1335
|
+
}, env);
|
|
1307
1336
|
}
|
|
1308
1337
|
|
|
1309
1338
|
// How far the base has drifted since this candidate forked (unit c2), hoisted to the
|
|
@@ -1441,9 +1470,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1441
1470
|
// push, because `git push` fast-forwards the local origin/<ref> tracking ref and
|
|
1442
1471
|
// would zero out the "vs what's live in preview" diff. Explicit -m/--summary win;
|
|
1443
1472
|
// otherwise it's generated from git so the change record is never blank. This
|
|
1444
|
-
// same diff
|
|
1445
|
-
// doubles as the source of the PR-backed candidate's file patch (step 2b, below)
|
|
1446
|
-
// — one git read, two consumers, so the PR always matches what's printed here.
|
|
1473
|
+
// same diff supplies the PR summary and the pre-push no-change gate.
|
|
1447
1474
|
// Parameterized on `ref` (b03 — isolated candidate refs): the diff base is YOUR
|
|
1448
1475
|
// candidate ref's own tracking ref, not a shared one, so the summary always reads
|
|
1449
1476
|
// "vs your own last push" once the push ref is known (computed just below).
|
|
@@ -1454,85 +1481,54 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1454
1481
|
return "";
|
|
1455
1482
|
}
|
|
1456
1483
|
};
|
|
1457
|
-
function
|
|
1484
|
+
function buildSubmitSummary(ref) {
|
|
1458
1485
|
const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
|
|
1459
|
-
// Diff against the fork point off `preview`
|
|
1460
|
-
//
|
|
1461
|
-
// and re-applies this patch). NOT the candidate's own tracking ref: after a push
|
|
1462
|
-
// that landed but failed to open its PR, that ref equals HEAD → empty patch →
|
|
1463
|
-
// wrong "no file changes" → no PR. See resolvePatchBase.
|
|
1486
|
+
// Diff against the fork point off `preview` so retries keep the complete summary
|
|
1487
|
+
// even when the previously pushed candidate tracking ref already equals HEAD.
|
|
1464
1488
|
const base = resolvePatchBase(gitSafe, ref);
|
|
1465
1489
|
const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
|
|
1466
|
-
const
|
|
1467
|
-
const files =
|
|
1490
|
+
const changedEntries = parseNameStatus(gitSafe(statusCmd));
|
|
1491
|
+
const files = changedEntries.map((e) => e.path);
|
|
1468
1492
|
const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
|
|
1469
1493
|
const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
|
|
1470
|
-
return { changeSummary,
|
|
1494
|
+
return { changeSummary, changedEntries };
|
|
1471
1495
|
}
|
|
1472
1496
|
|
|
1473
1497
|
// The MCP session is needed BOTH to mint a fresh forge push credential (decision
|
|
1474
1498
|
// B — right below) and for the candidate/preview read-back after, so establish it
|
|
1475
1499
|
// ONCE, up front, and reuse it for the whole flow.
|
|
1476
1500
|
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
1477
|
-
const client =
|
|
1501
|
+
const client = createClient(baseUrl);
|
|
1478
1502
|
const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
|
|
1479
1503
|
|
|
1480
1504
|
// Branch-bound (u4) + the isolated candidate ref (b03): resolved BEFORE the push,
|
|
1481
1505
|
// since the push target itself depends on it — so the raw git push and the
|
|
1482
1506
|
// PR-backed candidate (step 2b, below) always land on the SAME branch. `active`/
|
|
1483
1507
|
// `statePath` are local filesystem reads (candidate-state.mjs) — no session
|
|
1484
|
-
// needed — so they're available
|
|
1508
|
+
// needed — so they're available before any remote write.
|
|
1485
1509
|
const branch = currentBranch(gitSafe);
|
|
1486
1510
|
const statePath = defaultCandidateStatePath(env);
|
|
1487
1511
|
let active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
|
|
1488
1512
|
|
|
1489
1513
|
let session;
|
|
1490
1514
|
try {
|
|
1491
|
-
session = await
|
|
1515
|
+
session = await establish(client, { env, prefer: args.identity || undefined });
|
|
1492
1516
|
} catch (e) {
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
// still lands if that embedded token is live. actorKeyFor(null) degrades to the
|
|
1497
|
-
// generic "developer" key — still isolated PER BRANCH (never the shared ref),
|
|
1498
|
-
// just not per-developer until sign-in succeeds.
|
|
1499
|
-
const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, forkCandidate: args.forkCandidate });
|
|
1500
|
-
const ref = resolvePushRef({ ref: args.ref, changeId });
|
|
1501
|
-
const { changeSummary } = buildSummaryAndPatch(ref);
|
|
1502
|
-
console.error(`~ pushing ${short} → ${ref} (origin)`);
|
|
1503
|
-
try {
|
|
1504
|
-
const out = git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
|
|
1505
|
-
if (out.trim()) console.error(redactUrl(out.trim()));
|
|
1506
|
-
emitGitOp("push", true, { command: verb });
|
|
1507
|
-
} catch (pushErr) {
|
|
1508
|
-
emitGitOp("push", false, {
|
|
1509
|
-
command: verb,
|
|
1510
|
-
errorClass: isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr) ? "forge_auth" : "git_push_failed",
|
|
1511
|
-
});
|
|
1512
|
-
const msg = `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`;
|
|
1513
|
-
// This is the NO-SESSION path pushing the clone-time embedded credential —
|
|
1514
|
-
// which rotation kills the moment any fresh mint happens elsewhere. An auth
|
|
1515
|
-
// failure here is therefore almost always "you're not signed in IN THIS
|
|
1516
|
-
// SHELL", not a network problem; the old remote-is-reachable hint sent a
|
|
1517
|
-
// human down the wrong path live (Trello-13075 polish).
|
|
1518
|
-
const hint = isForgeAuthError(pushErr?.stderr || pushErr?.message || pushErr)
|
|
1519
|
-
? "you're not signed in in this shell, and the checkout's embedded credential has likely been rotated — run `tot login` (check TOT_PROFILE if you use per-terminal identities), then re-run"
|
|
1520
|
-
: "check your commit and that the checkout's remote is reachable, then re-run";
|
|
1521
|
-
console.error(fail(msg, hint));
|
|
1522
|
-
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: msg }));
|
|
1523
|
-
return 1;
|
|
1524
|
-
}
|
|
1525
|
-
if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
|
|
1526
|
-
printChangeSummary(changeSummary, { quiet: args.json });
|
|
1517
|
+
const diagnostic = recordDiagnostic(e, {
|
|
1518
|
+
command: verb, operation: "session_establish", required: true, degraded: false,
|
|
1519
|
+
}, env);
|
|
1527
1520
|
const note = e instanceof AuthUnavailableError
|
|
1528
|
-
? `
|
|
1529
|
-
: `couldn't reach Token of Trust
|
|
1521
|
+
? `Candidate not created: ${e.hint || "developer sign-in is required"}.`
|
|
1522
|
+
: `Candidate not created: couldn't reach Token of Trust (${describeReadbackError(e)}).`;
|
|
1530
1523
|
if (!args.json) {
|
|
1531
|
-
console.
|
|
1532
|
-
console.
|
|
1524
|
+
console.error(fail(note, "run `tot login`, then re-run; no candidate ref was pushed"));
|
|
1525
|
+
console.error(` → ${diagnosticReference(diagnostic, env)}`);
|
|
1533
1526
|
}
|
|
1534
|
-
emitJson(args, buildJsonResult({
|
|
1535
|
-
|
|
1527
|
+
emitJson(args, buildJsonResult({
|
|
1528
|
+
ok: false, commit, candidate: null, error: note,
|
|
1529
|
+
diagnosticId: diagnostic.diagnosticId,
|
|
1530
|
+
}));
|
|
1531
|
+
return 1;
|
|
1536
1532
|
}
|
|
1537
1533
|
|
|
1538
1534
|
// u17 — before REUSING a remembered active pointer, confirm its PR is still open.
|
|
@@ -1546,7 +1542,11 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1546
1542
|
if (active && repo && !args.forkCandidate) {
|
|
1547
1543
|
try {
|
|
1548
1544
|
await client.callTool("client_switch", { tenant });
|
|
1549
|
-
} catch {
|
|
1545
|
+
} catch (error) {
|
|
1546
|
+
recordDiagnostic(error, {
|
|
1547
|
+
command: verb, operation: "client_switch", required: false, degraded: true,
|
|
1548
|
+
}, env);
|
|
1549
|
+
}
|
|
1550
1550
|
const resolved = await resolveActivePointer(client, { repo, active });
|
|
1551
1551
|
if (resolved.dropped) {
|
|
1552
1552
|
console.error(
|
|
@@ -1564,9 +1564,49 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1564
1564
|
// (right below) and the PR-backed candidate (step 2b): the two never point at
|
|
1565
1565
|
// different branches. See chooseChangeId's doc for the --fork-candidate /
|
|
1566
1566
|
// active-pointer rules.
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1567
|
+
const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, forkCandidate: args.forkCandidate });
|
|
1568
|
+
let ref;
|
|
1569
|
+
try {
|
|
1570
|
+
ref = resolvePushRef({ ref: args.ref, changeId });
|
|
1571
|
+
} catch (e) {
|
|
1572
|
+
const note = String(e?.message || e);
|
|
1573
|
+
if (!args.json) console.error(fail(note, "omit --ref and re-run"));
|
|
1574
|
+
emitJson(args, buildJsonResult({ ok: false, commit, changeId, error: note }));
|
|
1575
|
+
return 1;
|
|
1576
|
+
}
|
|
1577
|
+
const { changeSummary, changedEntries } = buildSubmitSummary(ref);
|
|
1578
|
+
if (changedEntries.length === 0) {
|
|
1579
|
+
const note = "Candidate not created: no content changes were found.";
|
|
1580
|
+
if (!args.json) console.error(fail(note, "commit a content/public/theme.json change, then re-run"));
|
|
1581
|
+
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note, noChanges: true }));
|
|
1582
|
+
return 1;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
// Persist the chosen identity before any remote write. A failed push or MCP
|
|
1586
|
+
// registration therefore retries the same candidate/ref instead of minting an
|
|
1587
|
+
// orphan. Failing to persist a forked identity is itself unsafe, so refuse.
|
|
1588
|
+
if (!repo) {
|
|
1589
|
+
const note = "Candidate not created: couldn't derive the forge repo from origin.";
|
|
1590
|
+
if (!args.json) console.error(fail(note, "run this command inside a `tot clone` checkout"));
|
|
1591
|
+
emitJson(args, buildJsonResult({ ok: false, ref, commit, changeId, error: note }));
|
|
1592
|
+
return 1;
|
|
1593
|
+
}
|
|
1594
|
+
try {
|
|
1595
|
+
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
|
|
1596
|
+
} catch (error) {
|
|
1597
|
+
const note = "Candidate not created: couldn't persist retry identity.";
|
|
1598
|
+
const diagnostic = recordDiagnostic(error, {
|
|
1599
|
+
command: verb, operation: "candidate_pointer_write", required: true, degraded: false,
|
|
1600
|
+
}, env);
|
|
1601
|
+
if (!args.json) {
|
|
1602
|
+
console.error(fail(note, "fix ~/.tot permissions, then re-run; no candidate ref was pushed"));
|
|
1603
|
+
console.error(` → ${diagnosticReference(diagnostic, env)}`);
|
|
1604
|
+
}
|
|
1605
|
+
emitJson(args, buildJsonResult({
|
|
1606
|
+
ok: false, ref, commit, changeId, error: note, diagnosticId: diagnostic.diagnosticId,
|
|
1607
|
+
}));
|
|
1608
|
+
return 1;
|
|
1609
|
+
}
|
|
1570
1610
|
|
|
1571
1611
|
// 2. push the preview ref with a FRESHLY-MINTED, short-lived forge credential
|
|
1572
1612
|
// (decision B). The token baked into `.git/config` at clone time expires within
|
|
@@ -1577,9 +1617,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1577
1617
|
const tag = tagFromRepoName(repo, tenant);
|
|
1578
1618
|
const mintRemote = async () => {
|
|
1579
1619
|
try {
|
|
1580
|
-
const res = await
|
|
1620
|
+
const res = await checkout(client, { tenant, tag, cloneDir: null, redact: redactUrl });
|
|
1581
1621
|
return res.gitRemote || null;
|
|
1582
1622
|
} catch (e) {
|
|
1623
|
+
recordDiagnostic(e, {
|
|
1624
|
+
command: verb, operation: "tenant_checkout", required: false, degraded: true,
|
|
1625
|
+
}, env);
|
|
1583
1626
|
console.error(
|
|
1584
1627
|
`~ couldn't mint a fresh push credential (${redactUrl(String(e?.message || e))}) — using the checkout's remote`,
|
|
1585
1628
|
);
|
|
@@ -1598,8 +1641,14 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1598
1641
|
errorClass: isForgeAuthError(e?.stderr || e?.message || e) ? "forge_auth" : "git_push_failed",
|
|
1599
1642
|
});
|
|
1600
1643
|
const msg = `push failed: ${redactUrl(String(e.stderr || e.message || e))}`;
|
|
1644
|
+
const diagnostic = recordDiagnostic(new Error(msg, { cause: e }), {
|
|
1645
|
+
command: verb, operation: "git_push", required: true, degraded: false,
|
|
1646
|
+
}, env);
|
|
1601
1647
|
console.error(fail(msg, "check your commit and that the checkout's remote is reachable, then re-run"));
|
|
1602
|
-
|
|
1648
|
+
if (!args.json) console.error(` → ${diagnosticReference(diagnostic, env)}`);
|
|
1649
|
+
emitJson(args, buildJsonResult({
|
|
1650
|
+
ok: false, ref, commit, changeId, error: msg, diagnosticId: diagnostic.diagnosticId,
|
|
1651
|
+
}));
|
|
1603
1652
|
return 1;
|
|
1604
1653
|
}
|
|
1605
1654
|
if (!args.json) console.log(`\n+ submitted ${short} to ${ref}.`);
|
|
@@ -1608,31 +1657,30 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1608
1657
|
// 2b + 3. open/update the PR-backed candidate, then report reconcile +
|
|
1609
1658
|
// compliance + preview URL from the MCP — reusing the session established above.
|
|
1610
1659
|
let progress = null;
|
|
1660
|
+
let candidate = null;
|
|
1661
|
+
let previewPrUrl = null;
|
|
1662
|
+
let operation = "client_switch";
|
|
1663
|
+
const candidateRequired = changedEntries.length > 0;
|
|
1611
1664
|
try {
|
|
1612
1665
|
// Bind the active tenant so preview_status/candidate_open read the right scope
|
|
1613
1666
|
// (idempotent — checkoutTenant already switched when the fresh mint succeeded).
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
// push ref too); if the chosen candidate turns out to be merged/closed, roll to
|
|
1622
|
-
// a fresh one so a re-submit is never wedged on a dead PR. (`repo` was derived
|
|
1623
|
-
// above.)
|
|
1624
|
-
const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
1625
|
-
|
|
1626
|
-
let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
|
|
1627
|
-
|
|
1628
|
-
if (candidate && isTerminalCandidateState(candidate.state)) {
|
|
1629
|
-
const rolled = mintFreshChangeId(stableId);
|
|
1630
|
-
if (!args.json) console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
|
|
1631
|
-
changeId = rolled;
|
|
1632
|
-
persist = true;
|
|
1633
|
-
candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob, quiet: args.json });
|
|
1667
|
+
try {
|
|
1668
|
+
await client.callTool("client_switch", { tenant });
|
|
1669
|
+
} catch (e) {
|
|
1670
|
+
throw new CandidateRegistrationError(
|
|
1671
|
+
`Candidate not created: tenant scope could not be bound (${describeReadbackError(e)}).`,
|
|
1672
|
+
e,
|
|
1673
|
+
);
|
|
1634
1674
|
}
|
|
1635
1675
|
|
|
1676
|
+
// 2b. Register the exact ref and asserted commit that git just pushed. No file
|
|
1677
|
+
// bytes cross MCP and the server does not reconstruct or replace these commits.
|
|
1678
|
+
operation = "candidate_open";
|
|
1679
|
+
candidate = await submitCandidate(client, {
|
|
1680
|
+
repo, changeId, ref, headSha: commit, changeSummary,
|
|
1681
|
+
hasChanges: changedEntries.length > 0, quiet: args.json,
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1636
1684
|
// c3 — BORN-REBASED at submit (shift-left prevention #2). The candidate is open;
|
|
1637
1685
|
// if the base has drifted (the SAME signal c2 warned on, pre-push) rebuild it from
|
|
1638
1686
|
// the CURRENT base tip via candidate_refresh BEFORE finalizing, so it enters the
|
|
@@ -1651,6 +1699,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1651
1699
|
// never blocking the push that already landed. Gated on --skip-freshness via
|
|
1652
1700
|
// baseDrift (0 when skipped).
|
|
1653
1701
|
if (repo && baseDrift > 0 && candidate && !isTerminalCandidateState(candidate.state) && changeId) {
|
|
1702
|
+
operation = "candidate_refresh";
|
|
1654
1703
|
const rebased = await runBornRebased(client, { repo, changeId, strategy: bornRebasedStrategy });
|
|
1655
1704
|
if (rebased.ok) {
|
|
1656
1705
|
for (const line of formatBornRebasedSuccess({ behind: baseDrift, strategy: rebased.strategy || bornRebasedStrategy, refreshedFiles: rebased.refreshedFiles })) {
|
|
@@ -1663,18 +1712,12 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1663
1712
|
if (freshPrUrl && !args.json) {
|
|
1664
1713
|
console.log(`\n ▸ Your fresh preview will appear at:\n ${freshPrUrl}\n (building on the current store — this link goes live once reconcile completes)`);
|
|
1665
1714
|
}
|
|
1666
|
-
// The rebuild keeps the STABLE changeId, so the persisted active pointer stays
|
|
1667
|
-
// valid — record it (best-effort) exactly as the normal open path does below.
|
|
1668
|
-
if (persist && repo) {
|
|
1669
|
-
try {
|
|
1670
|
-
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
|
|
1671
|
-
} catch { /* best-effort local hint */ }
|
|
1672
|
-
}
|
|
1673
1715
|
emitJson(args, buildJsonResult({
|
|
1674
1716
|
ok: true, ref, commit, changeId,
|
|
1675
1717
|
candidate: { ...candidate, prNumber: freshPr ?? candidate.prNumber },
|
|
1676
1718
|
previewPrUrl: freshPrUrl,
|
|
1677
1719
|
note: `born-rebased on the current base (${rebased.strategy || bornRebasedStrategy})`,
|
|
1720
|
+
...invocationDegradedResult(),
|
|
1678
1721
|
}));
|
|
1679
1722
|
return 0;
|
|
1680
1723
|
}
|
|
@@ -1695,7 +1738,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1695
1738
|
// the server-minted `previewUrl` (formatShareableUrlBlock) that only shows
|
|
1696
1739
|
// up once reconcile actually completes. Honest framing: it's printed as
|
|
1697
1740
|
// "building", never as "ready".
|
|
1698
|
-
|
|
1741
|
+
previewPrUrl =
|
|
1699
1742
|
candidate && !isTerminalCandidateState(candidate.state) && typeof candidate.prNumber === "number"
|
|
1700
1743
|
? shareablePrUrl(env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL, tenant, candidate.prNumber)
|
|
1701
1744
|
: null;
|
|
@@ -1703,14 +1746,6 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1703
1746
|
console.log(`\n ▸ Your preview will appear at:\n ${previewPrUrl}\n (building — this link goes live once reconcile completes)`);
|
|
1704
1747
|
}
|
|
1705
1748
|
|
|
1706
|
-
// Remember the active candidate only on a real, non-terminal open (best-effort;
|
|
1707
|
-
// never let a state-write failure break the submit).
|
|
1708
|
-
if (persist && repo && candidate && !isTerminalCandidateState(candidate.state)) {
|
|
1709
|
-
try {
|
|
1710
|
-
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
|
|
1711
|
-
} catch { /* best-effort local hint — a miss just re-derives the stable id */ }
|
|
1712
|
-
}
|
|
1713
|
-
|
|
1714
1749
|
// Poll the sha `preview_status` actually resolves against — the candidate head, not
|
|
1715
1750
|
// the local commit, whenever the candidate names its own. Printed first (when they
|
|
1716
1751
|
// differ) so the developer reads the result against the right sha.
|
|
@@ -1721,6 +1756,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1721
1756
|
}
|
|
1722
1757
|
|
|
1723
1758
|
let status;
|
|
1759
|
+
operation = "preview_status";
|
|
1724
1760
|
if (args.noWait) {
|
|
1725
1761
|
status = normalizePreviewStatus(await client.callTool("preview_status", { commit: statusSha }));
|
|
1726
1762
|
} else {
|
|
@@ -1763,27 +1799,41 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1763
1799
|
// — automation doesn't want a browser popping up.
|
|
1764
1800
|
// `commit: statusSha` — the diagnostic blocks name the sha that was actually polled,
|
|
1765
1801
|
// so a "never dispatched" hint points at a sha `preview_status` knows about.
|
|
1766
|
-
const noChanges =
|
|
1802
|
+
const noChanges = changedEntries.length === 0;
|
|
1767
1803
|
reportStatus(status, tenant, { open: !args.noOpen && !args.json, quiet: args.json, commit: statusSha, ref, verb, noChanges });
|
|
1768
|
-
|
|
1769
|
-
|
|
1804
|
+
const ok = previewSubmitSucceeded(status);
|
|
1805
|
+
const degradation = invocationDegradedResult();
|
|
1806
|
+
if (degradation.degraded && !args.json) {
|
|
1807
|
+
console.log(` ~ completed with degraded optional diagnostics — ${diagnosticReference({ diagnosticId: degradation.diagnosticId }, env)}`);
|
|
1808
|
+
}
|
|
1809
|
+
emitJson(args, buildJsonResult({
|
|
1810
|
+
ok, ref, commit, changeId, candidate, status,
|
|
1811
|
+
previewPrUrl, noChanges, ...degradation,
|
|
1812
|
+
}));
|
|
1813
|
+
return ok ? 0 : 1;
|
|
1770
1814
|
} catch (e) {
|
|
1771
1815
|
progress?.stop();
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1816
|
+
const outcome = candidateFailureOutcome({
|
|
1817
|
+
error: e, candidateRequired, candidate, operation, command: verb, env,
|
|
1818
|
+
ref, commit, changeId, previewPrUrl,
|
|
1819
|
+
});
|
|
1820
|
+
if (outcome.required) {
|
|
1821
|
+
if (!args.json) {
|
|
1822
|
+
const next = e instanceof CandidateRegistrationError
|
|
1823
|
+
? `re-run \`tot ${verb}\`; retry will reuse ${ref}`
|
|
1824
|
+
: `fix the reported MCP failure, then re-run \`tot ${verb}\``;
|
|
1825
|
+
console.error(fail(outcome.note, next));
|
|
1826
|
+
console.error(` → ${diagnosticReference(outcome.diagnostic, env)}`);
|
|
1827
|
+
}
|
|
1828
|
+
emitJson(args, outcome.result);
|
|
1829
|
+
return outcome.exitCode;
|
|
1777
1830
|
}
|
|
1778
|
-
const note = e instanceof AuthUnavailableError
|
|
1779
|
-
? `sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"}`
|
|
1780
|
-
: `reconcile is running — the result read-back isn't available yet: ${describeReadbackError(e)}`;
|
|
1781
1831
|
if (!args.json) {
|
|
1782
|
-
console.log(` (${note})`);
|
|
1783
|
-
console.log(`
|
|
1832
|
+
console.log(` (${outcome.note})`);
|
|
1833
|
+
console.log(` ${diagnosticReference(outcome.diagnostic, env)}`);
|
|
1784
1834
|
}
|
|
1785
|
-
emitJson(args,
|
|
1786
|
-
return
|
|
1835
|
+
emitJson(args, outcome.result);
|
|
1836
|
+
return outcome.exitCode;
|
|
1787
1837
|
}
|
|
1788
1838
|
}
|
|
1789
1839
|
|
|
@@ -1819,6 +1869,12 @@ export function normalizePreviewStatus(r) {
|
|
|
1819
1869
|
};
|
|
1820
1870
|
}
|
|
1821
1871
|
|
|
1872
|
+
/** A submit succeeds only when reconcile has neither failed nor failed to start. */
|
|
1873
|
+
export function previewSubmitSucceeded(status) {
|
|
1874
|
+
const knownSuccessState = status?.status === "pending" || status?.status === "reconciled";
|
|
1875
|
+
return knownSuccessState && !status?.notDispatched && !status?.forwardFailed;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1822
1878
|
/**
|
|
1823
1879
|
* Poll `preview_status` while reconcile is pending (and, with untilShipped, while
|
|
1824
1880
|
* reconciled-but-not-yet-shipped). E2: every call carries `waitMs` so a
|
|
@@ -2026,7 +2082,7 @@ export function formatNotDispatchedBlock({ commit = null, ref = null, noChanges
|
|
|
2026
2082
|
// down two wrong debugging paths).
|
|
2027
2083
|
const causes = [
|
|
2028
2084
|
...(noChanges
|
|
2029
|
-
? [` • your submit contained NO content changes — a candidate with no diff opens no PR and builds nothing (make an edit,
|
|
2085
|
+
? [` • your submit contained NO content changes — a candidate with no diff opens no PR and builds nothing (make and commit an edit, then re-run \`tot ${verb}\`),`]
|
|
2030
2086
|
: []),
|
|
2031
2087
|
` • the store's reconcile webhook isn't registered yet (an operator must (re-)provision it), or`,
|
|
2032
2088
|
` • your session is scoped to a different store than the one you pushed.`,
|