@bridge_gpt/mcp-server 0.2.42 → 0.2.43
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/README.md +321 -182
- package/build/agents.generated.js +2 -2
- package/build/claude-review-workflow.js +510 -45
- package/build/commands.generated.js +4 -3
- package/build/conduct-epic/cli.js +195 -0
- package/build/conductor/bridge-api-client.js +121 -0
- package/build/conductor/cli.js +63 -0
- package/build/conductor/recovery-cli.js +313 -0
- package/build/conductor/recovery-operations.js +219 -0
- package/build/conductor-bin.js +9 -5
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +13 -3
- package/build/drive-epic.js +375 -0
- package/build/executor/http-client.js +71 -3
- package/build/executor/job-errors.js +9 -0
- package/build/executor/job-runner.js +50 -3
- package/build/executor/observation.js +105 -13
- package/build/executor/runner.js +219 -0
- package/build/executor/worker-finalization.js +233 -56
- package/build/executor/worktree.js +8 -1
- package/build/index.js +2156 -98
- package/build/install-bridge.js +23 -9
- package/build/pipelines.generated.js +304 -14
- package/build/plane/cli.js +73 -7
- package/build/plane/defaults.js +14 -4
- package/build/plane/manifest.js +90 -0
- package/build/plane/preflight.js +19 -0
- package/build/plane/shutdown.js +71 -3
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +75 -5
- package/build/setup-epic.js +82 -8
- package/build/version.generated.js +2 -1
- package/build/worktree-core.js +31 -17
- package/package.json +1 -1
- package/pipelines/greenfield-setup.json +286 -0
|
@@ -45,6 +45,7 @@ import { acquireConductEpicLock, inspectConductEpicLock, isConductEpicLockOwnerA
|
|
|
45
45
|
import { discoverConductEpicPrState, discoverTicketWorktree, parseGitWorktreePorcelain, } from "./pr-state.js";
|
|
46
46
|
import { spawnConductEpicAgentTab, CONDUCT_EPIC_AGENTS, } from "./spawn.js";
|
|
47
47
|
import { MCP_PACKAGE_NAME } from "../mcp-identity.js";
|
|
48
|
+
import { fetchLatestVersion } from "../cli-release.js";
|
|
48
49
|
import { INDEX_SCOPE_CONFIGURATION_ERROR, validateOptionalIndexScope, } from "../index-scope-contract.js";
|
|
49
50
|
// BAPI-850: the exact-cut protocol, the scope-readiness poll bounds, and the
|
|
50
51
|
// local-git helpers live in ONE shared module that `setup-epic` drives too. This
|
|
@@ -125,6 +126,7 @@ export function createDefaultConductEpicDeps() {
|
|
|
125
126
|
log: (m) => console.log(m),
|
|
126
127
|
errorLog: (m) => console.error(m),
|
|
127
128
|
resolveAccess: resolveConductorBridgeApiAccess,
|
|
129
|
+
resolveLatestPublishedVersion: () => fetchLatestVersion({ fetch: globalThis.fetch }),
|
|
128
130
|
resolveRepoName: resolveRequiredStartTicketsRepoName,
|
|
129
131
|
};
|
|
130
132
|
}
|
|
@@ -568,6 +570,182 @@ function elapsedSeconds(from, now) {
|
|
|
568
570
|
function inFlightTicket(checkpoint) {
|
|
569
571
|
return checkpoint.tickets.find((ticket) => ticket.status !== "done") ?? null;
|
|
570
572
|
}
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// The published-build identity (BAPI-873)
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
/**
|
|
577
|
+
* Hard bound on launching the published package to read its identity. `npx` may
|
|
578
|
+
* have to download a tarball on a cold cache, so this is generous relative to
|
|
579
|
+
* the registry lookup — but it is a bound, because a wedged launch must never
|
|
580
|
+
* stall `init` indefinitely.
|
|
581
|
+
*/
|
|
582
|
+
export const PUBLISHED_IDENTITY_TIMEOUT_MS = 120_000;
|
|
583
|
+
/** The identity shape `--version` emits: 12 lowercase hex, optionally `-dirty`. */
|
|
584
|
+
const PUBLISHED_IDENTITY_PATTERN = /^commit: ([0-9a-f]{12})(-dirty)?$/;
|
|
585
|
+
/** The sentinel a build with no git metadata reports. */
|
|
586
|
+
const PUBLISHED_IDENTITY_UNKNOWN = "unknown";
|
|
587
|
+
/** Human wording for each unavailable category, for the fail-open advisory. */
|
|
588
|
+
export function describePublishedIdentityReason(reason) {
|
|
589
|
+
switch (reason) {
|
|
590
|
+
case "registry_unreadable":
|
|
591
|
+
return "the npm registry could not be read";
|
|
592
|
+
case "launch_failed":
|
|
593
|
+
return "the published package could not be launched";
|
|
594
|
+
case "unreadable_output":
|
|
595
|
+
return "the published package reported no readable build identity";
|
|
596
|
+
case "identity_unknown":
|
|
597
|
+
return "the published build reports an unknown build commit";
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Read the commit identity embedded in the LATEST PUBLISHED package.
|
|
602
|
+
*
|
|
603
|
+
* Two bounded steps: resolve the exact latest version through the shared
|
|
604
|
+
* registry lookup, then run THAT EXACT VERSION with `--version`. The exactness
|
|
605
|
+
* matters — invoking a moving `@latest` would read whatever the registry served
|
|
606
|
+
* at that instant, so the version reported and the version inspected could
|
|
607
|
+
* differ, and the gate would be comparing an identity to the wrong build.
|
|
608
|
+
*
|
|
609
|
+
* Every failure is `unavailable`, never a mismatch: not knowing what was
|
|
610
|
+
* published is a different fact from knowing it is wrong, and only the second
|
|
611
|
+
* may block a run.
|
|
612
|
+
*/
|
|
613
|
+
export async function readPublishedBuildIdentity(deps) {
|
|
614
|
+
const resolveVersion = deps.resolveLatestPublishedVersion ?? (() => fetchLatestVersion({ fetch: deps.fetchImpl }));
|
|
615
|
+
let version;
|
|
616
|
+
try {
|
|
617
|
+
version = await resolveVersion();
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return { kind: "unavailable", reason: "registry_unreadable" };
|
|
621
|
+
}
|
|
622
|
+
if (typeof version !== "string" || version.trim().length === 0) {
|
|
623
|
+
return { kind: "unavailable", reason: "registry_unreadable" };
|
|
624
|
+
}
|
|
625
|
+
const resolvedVersion = version.trim();
|
|
626
|
+
let probe;
|
|
627
|
+
try {
|
|
628
|
+
probe = await deps.runCommand("npx", ["-y", `${MCP_PACKAGE_NAME}@${resolvedVersion}`, "--version"], { cwd: deps.cwd, timeoutMs: PUBLISHED_IDENTITY_TIMEOUT_MS });
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
return { kind: "unavailable", reason: "launch_failed" };
|
|
632
|
+
}
|
|
633
|
+
if (!probe || probe.exitCode !== 0) {
|
|
634
|
+
return { kind: "unavailable", reason: "launch_failed" };
|
|
635
|
+
}
|
|
636
|
+
const lines = String(probe.stdout ?? "")
|
|
637
|
+
.split("\n")
|
|
638
|
+
.map((line) => line.trim())
|
|
639
|
+
.filter((line) => line.length > 0);
|
|
640
|
+
// The first line is the semver contract `--version` has always emitted. It
|
|
641
|
+
// must be the version we asked for, or the output does not describe the build
|
|
642
|
+
// this reader resolved.
|
|
643
|
+
if (lines[0] !== resolvedVersion) {
|
|
644
|
+
return { kind: "unavailable", reason: "unreadable_output" };
|
|
645
|
+
}
|
|
646
|
+
const commitLine = lines.slice(1).find((line) => line.startsWith("commit:"));
|
|
647
|
+
if (commitLine === undefined) {
|
|
648
|
+
return { kind: "unavailable", reason: "unreadable_output" };
|
|
649
|
+
}
|
|
650
|
+
if (commitLine === `commit: ${PUBLISHED_IDENTITY_UNKNOWN}`) {
|
|
651
|
+
return { kind: "unavailable", reason: "identity_unknown" };
|
|
652
|
+
}
|
|
653
|
+
const match = PUBLISHED_IDENTITY_PATTERN.exec(commitLine);
|
|
654
|
+
if (match === null) {
|
|
655
|
+
return { kind: "unavailable", reason: "unreadable_output" };
|
|
656
|
+
}
|
|
657
|
+
return { kind: "known", version: resolvedVersion, commit: match[1], dirty: match[2] !== undefined };
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Decide whether the PUBLISHED build carries the code this epic will be cut at.
|
|
661
|
+
*
|
|
662
|
+
* The gate is expressed as CONTAINMENT, not as a version floor and not as an
|
|
663
|
+
* exact-commit match. "The published build is at least as new as the commit we
|
|
664
|
+
* are conducting" is the property that actually matters, and it is the property
|
|
665
|
+
* a version number could never express: the same semver spanned three different
|
|
666
|
+
* contents, which is why the old floor was unverifiable.
|
|
667
|
+
*
|
|
668
|
+
* Blocking and fail-open are separated deliberately. Knowing the published build
|
|
669
|
+
* is wrong blocks. NOT knowing what was published — a registry outage, a cold
|
|
670
|
+
* npx launch that failed, a build with no git metadata — is an advisory, because
|
|
671
|
+
* a network problem must never stop a run.
|
|
672
|
+
*/
|
|
673
|
+
export async function evaluatePublishGate(deps, expectedCommitSha) {
|
|
674
|
+
if (expectedCommitSha === null) {
|
|
675
|
+
// The canonical-index check already recorded its own failure; adding a
|
|
676
|
+
// second one for the same root cause only pads the report.
|
|
677
|
+
return {
|
|
678
|
+
failures: [],
|
|
679
|
+
advisories: [
|
|
680
|
+
"advisory: the publish gate was not evaluated because no canonical indexed commit is available to check against.",
|
|
681
|
+
],
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
const expected = normalizeCommitSha(expectedCommitSha);
|
|
685
|
+
if (expected === null) {
|
|
686
|
+
return {
|
|
687
|
+
failures: [
|
|
688
|
+
"The publish gate cannot be evaluated: the expected commit is not a full 40-character SHA. " +
|
|
689
|
+
"Refusing rather than comparing an arbitrary prefix.",
|
|
690
|
+
],
|
|
691
|
+
advisories: [],
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
const identity = await readPublishedBuildIdentity(deps);
|
|
695
|
+
if (identity.kind === "unavailable") {
|
|
696
|
+
return {
|
|
697
|
+
failures: [],
|
|
698
|
+
advisories: [
|
|
699
|
+
`advisory: the publish gate could not be verified — ${describePublishedIdentityReason(identity.reason)}. ` +
|
|
700
|
+
`Initialization is continuing; the published ${MCP_PACKAGE_NAME} build was NOT confirmed to contain ${expected}.`,
|
|
701
|
+
],
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
if (identity.dirty) {
|
|
705
|
+
return {
|
|
706
|
+
failures: [
|
|
707
|
+
`The published ${MCP_PACKAGE_NAME}@${identity.version} reports build commit ${identity.commit}-dirty. ` +
|
|
708
|
+
"A dirty build carries content that no commit represents, so it cannot be verified to contain " +
|
|
709
|
+
`${expected}. Publish a build from a clean checkout.`,
|
|
710
|
+
],
|
|
711
|
+
advisories: [],
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
// The published SHA must be an object THIS checkout knows about before any
|
|
715
|
+
// ancestry claim is possible. It is a short SHA, so it cannot be fetched by
|
|
716
|
+
// name — an unresolvable one is "cannot verify", never "wrong".
|
|
717
|
+
const present = await git(deps, ["rev-parse", "--verify", "--quiet", `${identity.commit}^{commit}`]);
|
|
718
|
+
if (present.exitCode !== 0) {
|
|
719
|
+
return {
|
|
720
|
+
failures: [],
|
|
721
|
+
advisories: [
|
|
722
|
+
`advisory: the published ${MCP_PACKAGE_NAME}@${identity.version} build commit ${identity.commit} ` +
|
|
723
|
+
"is not present in this checkout, so the publish gate could not be verified. " +
|
|
724
|
+
`Initialization is continuing; fetch origin and confirm that build contains ${expected}.`,
|
|
725
|
+
],
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
const contains = await git(deps, ["merge-base", "--is-ancestor", expected, identity.commit]);
|
|
729
|
+
if (contains.exitCode === 0)
|
|
730
|
+
return { failures: [], advisories: [] };
|
|
731
|
+
if (contains.exitCode === 1) {
|
|
732
|
+
return {
|
|
733
|
+
failures: [
|
|
734
|
+
`The published ${MCP_PACKAGE_NAME}@${identity.version} was built from ${identity.commit}, ` +
|
|
735
|
+
`which does not contain ${expected} — the canonical indexed commit this epic is cut at. ` +
|
|
736
|
+
"Publish a build containing that commit before initializing.",
|
|
737
|
+
],
|
|
738
|
+
advisories: [],
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
return {
|
|
742
|
+
failures: [],
|
|
743
|
+
advisories: [
|
|
744
|
+
`advisory: the publish gate could not be verified — the ancestry of published build commit ` +
|
|
745
|
+
`${identity.commit} could not be determined locally. Initialization is continuing.`,
|
|
746
|
+
],
|
|
747
|
+
};
|
|
748
|
+
}
|
|
571
749
|
/**
|
|
572
750
|
* Run every independent `init` check and ACCUMULATE the failures.
|
|
573
751
|
*
|
|
@@ -588,6 +766,7 @@ function inFlightTicket(checkpoint) {
|
|
|
588
766
|
export async function collectConductEpicInitPreflight(deps, options) {
|
|
589
767
|
const failures = [];
|
|
590
768
|
const announcements = [];
|
|
769
|
+
const advisories = [];
|
|
591
770
|
const epicBranch = epicBranchFor(options.epicKey);
|
|
592
771
|
let pendingSupervisorConfig = null;
|
|
593
772
|
// (1) gh authentication.
|
|
@@ -760,6 +939,16 @@ export async function collectConductEpicInitPreflight(deps, options) {
|
|
|
760
939
|
}
|
|
761
940
|
}
|
|
762
941
|
}
|
|
942
|
+
// (13) BAPI-873: the publish gate. Evaluated HERE — after the cut commit is
|
|
943
|
+
// known and proven present locally, and still before anything mutates —
|
|
944
|
+
// because the gate's question is whether the PUBLISHED package (the one
|
|
945
|
+
// `start-tickets` spawns for every worker) already carries the code this
|
|
946
|
+
// epic is cut at. A readable mismatch joins `failures` so it is reported
|
|
947
|
+
// alongside every other readiness problem; an unreadable published identity
|
|
948
|
+
// becomes an advisory and the run continues.
|
|
949
|
+
const publishGate = await evaluatePublishGate(deps, cutCommitSha);
|
|
950
|
+
failures.push(...publishGate.failures);
|
|
951
|
+
advisories.push(...publishGate.advisories);
|
|
763
952
|
// (9) `epic/<EPIC>` must be absent on origin, or already at exactly the
|
|
764
953
|
// canonical indexed commit. An epic branch sitting at ANY other commit still
|
|
765
954
|
// fails closed — including the base tip, which is no longer special.
|
|
@@ -797,6 +986,7 @@ export async function collectConductEpicInitPreflight(deps, options) {
|
|
|
797
986
|
return {
|
|
798
987
|
failures,
|
|
799
988
|
announcements,
|
|
989
|
+
advisories,
|
|
800
990
|
access,
|
|
801
991
|
baseBranch,
|
|
802
992
|
baseSha,
|
|
@@ -925,6 +1115,11 @@ export async function runConductEpicInit(deps, options) {
|
|
|
925
1115
|
], { epic_key: options.epicKey, checkpoint_path: checkpointPath });
|
|
926
1116
|
}
|
|
927
1117
|
const preflight = await collectConductEpicInitPreflight(deps, options);
|
|
1118
|
+
// BAPI-873: fail-open advisories are reported before the outcome is decided,
|
|
1119
|
+
// on stderr, on both paths — an advisory that only printed on failure would
|
|
1120
|
+
// let a run proceed silently past an unverified publish gate.
|
|
1121
|
+
for (const line of preflight.advisories)
|
|
1122
|
+
deps.errorLog(line);
|
|
928
1123
|
if (preflight.failures.length > 0) {
|
|
929
1124
|
for (const line of preflight.announcements)
|
|
930
1125
|
deps.errorLog(line);
|
|
@@ -214,6 +214,20 @@ export function extractSanitizedErrorDiagnostics(body) {
|
|
|
214
214
|
diagnostics.errorCode = boundedErrorPreview(errorCode);
|
|
215
215
|
if (message)
|
|
216
216
|
diagnostics.bodyPreview = boundedErrorPreview(message);
|
|
217
|
+
// BAPI-872: extract the allowlisted stale-CAS `current_row_version`, from
|
|
218
|
+
// either the nested `detail` object or (defensively) the top level. Only a
|
|
219
|
+
// genuine non-negative safe integer is accepted — never a boolean (JS has no
|
|
220
|
+
// strict-int check, so `Number.isSafeInteger` alone would still accept `1`
|
|
221
|
+
// but not `true`), never a string, never negative — so a malformed value can
|
|
222
|
+
// never be mistaken for a usable CAS conflict downstream.
|
|
223
|
+
const rawCurrentRowVersion = detail && typeof detail === "object" && !Array.isArray(detail)
|
|
224
|
+
? detail["current_row_version"]
|
|
225
|
+
: record["current_row_version"];
|
|
226
|
+
if (typeof rawCurrentRowVersion === "number" &&
|
|
227
|
+
Number.isSafeInteger(rawCurrentRowVersion) &&
|
|
228
|
+
rawCurrentRowVersion >= 0) {
|
|
229
|
+
diagnostics.currentRowVersion = rawCurrentRowVersion;
|
|
230
|
+
}
|
|
217
231
|
return diagnostics;
|
|
218
232
|
}
|
|
219
233
|
/** Redact exact secret substrings (e.g. the API key from the request headers). */
|
|
@@ -231,6 +245,10 @@ function redactDiagnosticValues(diagnostics, secrets) {
|
|
|
231
245
|
out.errorCode = scrub(diagnostics.errorCode);
|
|
232
246
|
if (diagnostics.bodyPreview)
|
|
233
247
|
out.bodyPreview = scrub(diagnostics.bodyPreview);
|
|
248
|
+
// A number has nothing to scrub; carried through unchanged (BAPI-872).
|
|
249
|
+
if (typeof diagnostics.currentRowVersion === "number") {
|
|
250
|
+
out.currentRowVersion = diagnostics.currentRowVersion;
|
|
251
|
+
}
|
|
234
252
|
return out;
|
|
235
253
|
}
|
|
236
254
|
/**
|
|
@@ -267,6 +285,13 @@ export class ConductorBridgeApiError extends Error {
|
|
|
267
285
|
status;
|
|
268
286
|
errorCode;
|
|
269
287
|
bodyPreview;
|
|
288
|
+
/**
|
|
289
|
+
* The stale-CAS `current_row_version` (BAPI-872), when the backend supplied
|
|
290
|
+
* one. Deliberately NOT interpolated into `.message` (unlike `bodyPreview`) —
|
|
291
|
+
* every recovery CLI surface must never print a row-version value, and callers
|
|
292
|
+
* that need it read this field directly.
|
|
293
|
+
*/
|
|
294
|
+
currentRowVersion;
|
|
270
295
|
constructor(kindOrMessage, status, diagnostics) {
|
|
271
296
|
const isKnownKind = CONDUCTOR_BRIDGE_API_ERROR_KINDS.includes(kindOrMessage);
|
|
272
297
|
const errorCode = diagnostics?.errorCode;
|
|
@@ -293,6 +318,9 @@ export class ConductorBridgeApiError extends Error {
|
|
|
293
318
|
this.errorCode = errorCode;
|
|
294
319
|
if (bodyPreview)
|
|
295
320
|
this.bodyPreview = bodyPreview;
|
|
321
|
+
if (typeof diagnostics?.currentRowVersion === "number") {
|
|
322
|
+
this.currentRowVersion = diagnostics.currentRowVersion;
|
|
323
|
+
}
|
|
296
324
|
}
|
|
297
325
|
}
|
|
298
326
|
/**
|
|
@@ -873,6 +901,27 @@ export async function updateEpicRunStatus(access, request, fetchImpl = globalThi
|
|
|
873
901
|
const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
874
902
|
return parsed;
|
|
875
903
|
}
|
|
904
|
+
/**
|
|
905
|
+
* POST `/jira/epic-runs/runs/{epic_run_id}/stop` — the operator emergency brake
|
|
906
|
+
* (BAPI-732, wired to the conductor CLI in BAPI-872). Idempotent: a repeated stop
|
|
907
|
+
* returns HTTP 200 with `committed: false`; a terminal run (`done`/`abandoned`)
|
|
908
|
+
* returns a `409 RUN_TERMINAL` conflict, which surfaces as a thrown
|
|
909
|
+
* {@link ConductorBridgeApiError} with `status: 409` and `errorCode: "RUN_TERMINAL"`.
|
|
910
|
+
* Transport/auth/server failures throw a sanitized {@link ConductorBridgeApiError}.
|
|
911
|
+
*/
|
|
912
|
+
export async function stopEpicRun(access, request, fetchImpl = globalThis.fetch) {
|
|
913
|
+
requireNonEmptyString(request.epicRunId);
|
|
914
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicRunId)}/stop`);
|
|
915
|
+
const body = JSON.stringify({
|
|
916
|
+
repo_name: access.repoName,
|
|
917
|
+
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
918
|
+
...(request.expectedGeneration !== undefined
|
|
919
|
+
? { expected_generation: request.expectedGeneration }
|
|
920
|
+
: {}),
|
|
921
|
+
});
|
|
922
|
+
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
923
|
+
return parsed;
|
|
924
|
+
}
|
|
876
925
|
// ---------------------------------------------------------------------------
|
|
877
926
|
// Per-ticket CAS status advancement
|
|
878
927
|
// ---------------------------------------------------------------------------
|
|
@@ -937,6 +986,78 @@ export async function advanceEpicTicketStatus(access, request, fetchImpl = globa
|
|
|
937
986
|
const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
938
987
|
return parseAdvanceEpicTicketStatusResult(parsed);
|
|
939
988
|
}
|
|
989
|
+
// ---------------------------------------------------------------------------
|
|
990
|
+
// Operator recovery: ticket unpark + adopt-current-head-and-unpark (BAPI-872)
|
|
991
|
+
// ---------------------------------------------------------------------------
|
|
992
|
+
/**
|
|
993
|
+
* Catch a stale-CAS 400 thrown by the POST transport and translate it into the
|
|
994
|
+
* SAME `{ok: false, kind: "cas-conflict", current_row_version}` result
|
|
995
|
+
* {@link parseAdvanceEpicTicketStatusResult} already produces for the PATCH CAS
|
|
996
|
+
* endpoint — the previously dormant conflict branch that now becomes production
|
|
997
|
+
* behavior for both unpark lanes. Only a genuine stale-CAS shape (400 status with
|
|
998
|
+
* a validated `currentRowVersion`) is translated; every other failure re-throws
|
|
999
|
+
* unchanged, preserving `ConductorBridgeApiError` for terminal, validation,
|
|
1000
|
+
* authorization, and transport failures.
|
|
1001
|
+
*/
|
|
1002
|
+
async function postUnparkLikeRequest(url, headers, body, fetchImpl) {
|
|
1003
|
+
try {
|
|
1004
|
+
const parsed = await fetchConductorJsonPostWithTimeout(url, headers, body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1005
|
+
return parseAdvanceEpicTicketStatusResult(parsed);
|
|
1006
|
+
}
|
|
1007
|
+
catch (err) {
|
|
1008
|
+
if (err instanceof ConductorBridgeApiError &&
|
|
1009
|
+
err.status === 400 &&
|
|
1010
|
+
typeof err.currentRowVersion === "number") {
|
|
1011
|
+
return parseAdvanceEpicTicketStatusResult({
|
|
1012
|
+
ok: false,
|
|
1013
|
+
kind: "cas-conflict",
|
|
1014
|
+
current_row_version: err.currentRowVersion,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
throw err;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/unpark` — move a
|
|
1022
|
+
* parked `needs_human` ticket back into its gate machine (BAPI-536, wired to the
|
|
1023
|
+
* conductor CLI in BAPI-872). A stale `expectedRowVersion` surfaces as the
|
|
1024
|
+
* discriminated `{ok: false, kind: "cas-conflict", current_row_version}` result
|
|
1025
|
+
* rather than a thrown error, so a bounded caller-side retry can re-read state and
|
|
1026
|
+
* try again without special-casing an exception.
|
|
1027
|
+
*/
|
|
1028
|
+
export async function unparkEpicTicket(access, request, fetchImpl = globalThis.fetch) {
|
|
1029
|
+
requireNonEmptyString(request.epicRunId);
|
|
1030
|
+
requireNonEmptyString(request.ticketKey);
|
|
1031
|
+
requireNonNegativeSafeInteger(request.expectedRowVersion);
|
|
1032
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicRunId)}/tickets/${encodeURIComponent(request.ticketKey)}/unpark`);
|
|
1033
|
+
const body = JSON.stringify({
|
|
1034
|
+
repo_name: access.repoName,
|
|
1035
|
+
expected_row_version: request.expectedRowVersion,
|
|
1036
|
+
...(request.idempotencyKey !== undefined ? { idempotency_key: request.idempotencyKey } : {}),
|
|
1037
|
+
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
1038
|
+
});
|
|
1039
|
+
return postUnparkLikeRequest(url, conductorPostHeaders(access), body, fetchImpl);
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/adopt-current-head-and-unpark`
|
|
1043
|
+
* — the recovery lane for a human/external push that drifted the PR head off the
|
|
1044
|
+
* ticket's anchored `expected_head_sha` (BAPI-571 B3, wired to the conductor CLI
|
|
1045
|
+
* in BAPI-872). Same CAS-conflict/result shape as {@link unparkEpicTicket}; shares
|
|
1046
|
+
* {@link parseAdvanceEpicTicketStatusResult} rather than a second parser.
|
|
1047
|
+
*/
|
|
1048
|
+
export async function adoptCurrentHeadAndUnparkTicket(access, request, fetchImpl = globalThis.fetch) {
|
|
1049
|
+
requireNonEmptyString(request.epicRunId);
|
|
1050
|
+
requireNonEmptyString(request.ticketKey);
|
|
1051
|
+
requireNonNegativeSafeInteger(request.expectedRowVersion);
|
|
1052
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicRunId)}/tickets/${encodeURIComponent(request.ticketKey)}/adopt-current-head-and-unpark`);
|
|
1053
|
+
const body = JSON.stringify({
|
|
1054
|
+
repo_name: access.repoName,
|
|
1055
|
+
expected_row_version: request.expectedRowVersion,
|
|
1056
|
+
...(request.idempotencyKey !== undefined ? { idempotency_key: request.idempotencyKey } : {}),
|
|
1057
|
+
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
1058
|
+
});
|
|
1059
|
+
return postUnparkLikeRequest(url, conductorPostHeaders(access), body, fetchImpl);
|
|
1060
|
+
}
|
|
940
1061
|
/**
|
|
941
1062
|
* Idempotently seed an epic ticket status row via POST to the per-epic tickets
|
|
942
1063
|
* endpoint. The backend uses ON CONFLICT DO NOTHING so repeated seeding across
|
package/build/conductor/cli.js
CHANGED
|
@@ -65,6 +65,15 @@ export function getConductorUsage() {
|
|
|
65
65
|
" Run the reference-transaction producer (invoked by the hook)",
|
|
66
66
|
" file-scope-guard Warn-only: compare the branch diff against the declared",
|
|
67
67
|
" touched-file set (always exits 0; never blocks a PR)",
|
|
68
|
+
" stop-run Stop an epic run: block dispatch and cancel queued work",
|
|
69
|
+
" (the run record is preserved). Idempotent.",
|
|
70
|
+
" abandon-run Abandon an epic run — TERMINAL and IRREVERSIBLE.",
|
|
71
|
+
" Idempotent on an already-abandoned run.",
|
|
72
|
+
" unpark Move a parked (needs_human) ticket back into its gate",
|
|
73
|
+
" machine. Retries internally on a concurrent change.",
|
|
74
|
+
" adopt-current-head-and-unpark",
|
|
75
|
+
" Recover a ticket parked by a PR-head drift: adopt the",
|
|
76
|
+
" CURRENT PR head and unpark in one step.",
|
|
68
77
|
"",
|
|
69
78
|
"supervise options:",
|
|
70
79
|
" --run-id <id> Run/session identifier to supervise (required)",
|
|
@@ -157,6 +166,37 @@ export function getConductorUsage() {
|
|
|
157
166
|
" conductor approve-plan EPIC-405 --plan-version 2 --json",
|
|
158
167
|
" conductor epic-status --epic-key EPIC-405",
|
|
159
168
|
" conductor epic-status --epic-key EPIC-405 --json",
|
|
169
|
+
"",
|
|
170
|
+
"stop-run / abandon-run options:",
|
|
171
|
+
" --epic-run-id <id> Epic run identifier (required)",
|
|
172
|
+
" --json Print compact JSON result",
|
|
173
|
+
" --help Print this usage message",
|
|
174
|
+
" stop-run BLOCKS new dispatch and CANCELS queued work; the run record is",
|
|
175
|
+
" preserved and the run cannot resume on its own — it is an emergency brake,",
|
|
176
|
+
" not a pause. abandon-run is the SAME plus a TERMINAL, IRREVERSIBLE close-out:",
|
|
177
|
+
" once abandoned, the run can never resume or be reused. Both are idempotent —",
|
|
178
|
+
" repeating either is a safe no-op once the run is already stopped/abandoned.",
|
|
179
|
+
"",
|
|
180
|
+
"unpark / adopt-current-head-and-unpark options:",
|
|
181
|
+
" --epic-run-id <id> Epic run identifier (required)",
|
|
182
|
+
" --ticket-key <key> Ticket key (required)",
|
|
183
|
+
" --json Print compact JSON result",
|
|
184
|
+
" --help Print this usage message",
|
|
185
|
+
" unpark resumes a ticket PARKED with needs_human, once the underlying cause",
|
|
186
|
+
" is resolved. adopt-current-head-and-unpark additionally REBINDS the ticket's",
|
|
187
|
+
" anchored PR head to whatever the PR's head currently is, for a ticket parked",
|
|
188
|
+
" by a human/external push. Both retry internally on a concurrent row change —",
|
|
189
|
+
" neither accepts, nor ever asks for, a version counter.",
|
|
190
|
+
"",
|
|
191
|
+
"Incident examples:",
|
|
192
|
+
" # A run is misbehaving and must stop immediately:",
|
|
193
|
+
" conductor stop-run --epic-run-id 3f9c2b7e-...",
|
|
194
|
+
" # The run is done for good — close it out:",
|
|
195
|
+
" conductor abandon-run --epic-run-id 3f9c2b7e-...",
|
|
196
|
+
" # A ticket parked itself (needs_human) and the cause is now fixed:",
|
|
197
|
+
" conductor unpark --epic-run-id 3f9c2b7e-... --ticket-key BAPI-852",
|
|
198
|
+
" # A human pushed directly to the PR and the ticket parked on head drift:",
|
|
199
|
+
" conductor adopt-current-head-and-unpark --epic-run-id 3f9c2b7e-... --ticket-key BAPI-852",
|
|
160
200
|
].join("\n");
|
|
161
201
|
}
|
|
162
202
|
/**
|
|
@@ -180,6 +220,11 @@ const VALID_COMMANDS = new Set([
|
|
|
180
220
|
"install-git-hooks",
|
|
181
221
|
"git-hook",
|
|
182
222
|
"file-scope-guard",
|
|
223
|
+
// BAPI-872: CLI-only operator recovery verbs — never registered as MCP tools.
|
|
224
|
+
"stop-run",
|
|
225
|
+
"abandon-run",
|
|
226
|
+
"unpark",
|
|
227
|
+
"adopt-current-head-and-unpark",
|
|
183
228
|
// Private, and deliberately ABSENT from the usage text: `__hook-bin` exists as
|
|
184
229
|
// the bundled-artifact regression guard for BAPI-772 (mirroring `plane
|
|
185
230
|
// __entrypoint`), not as a supported operator workflow.
|
|
@@ -1152,6 +1197,24 @@ export async function runConductorCli(argv) {
|
|
|
1152
1197
|
case "file-scope-guard":
|
|
1153
1198
|
// BAPI-507 (N-2): warn-only worker file-scope guard. Always exits 0.
|
|
1154
1199
|
return runFileScopeGuardCli();
|
|
1200
|
+
case "stop-run": {
|
|
1201
|
+
// BAPI-872: lazily imported so the Bridge credential/HTTP graph never
|
|
1202
|
+
// loads for a local-only command (doctor, emit-event, etc.).
|
|
1203
|
+
const { runStopRunCommand } = await import("./recovery-cli.js");
|
|
1204
|
+
return await runStopRunCommand(parsed.argv);
|
|
1205
|
+
}
|
|
1206
|
+
case "abandon-run": {
|
|
1207
|
+
const { runAbandonRunCommand } = await import("./recovery-cli.js");
|
|
1208
|
+
return await runAbandonRunCommand(parsed.argv);
|
|
1209
|
+
}
|
|
1210
|
+
case "unpark": {
|
|
1211
|
+
const { runUnparkCommand } = await import("./recovery-cli.js");
|
|
1212
|
+
return await runUnparkCommand(parsed.argv);
|
|
1213
|
+
}
|
|
1214
|
+
case "adopt-current-head-and-unpark": {
|
|
1215
|
+
const { runAdoptCurrentHeadAndUnparkCommand } = await import("./recovery-cli.js");
|
|
1216
|
+
return await runAdoptCurrentHeadAndUnparkCommand(parsed.argv);
|
|
1217
|
+
}
|
|
1155
1218
|
default:
|
|
1156
1219
|
console.error('Error: Unknown command. Run "conductor --help" for usage.');
|
|
1157
1220
|
return 1;
|