@bridge_gpt/mcp-server 0.2.34 → 0.2.37
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 +490 -366
- package/build/agent-capabilities/probe-context.js +8 -1
- package/build/agent-capabilities/probes.js +7 -1
- package/build/agents.generated.js +1 -1
- package/build/base-url.js +79 -0
- package/build/bridge-api-urls.js +9 -0
- package/build/chain-orchestrator.js +93 -15
- package/build/claude-review-workflow.js +264 -0
- package/build/claude-user-config-doctor.js +317 -0
- package/build/cli-release.js +53 -0
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +393 -4
- package/build/conductor/deny-enforcement-preflight.js +1 -0
- package/build/conductor/done-gate.js +44 -5
- package/build/conductor/epic-reconcile.js +6 -0
- package/build/conductor/install-doctor.js +462 -0
- package/build/conductor-bin.js +3 -3
- package/build/conductor-bundle-artifacts.js +37 -15
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +473 -81
- package/build/executor/cli.js +83 -6
- package/build/executor/credentials.js +39 -11
- package/build/executor/deps.js +32 -1
- package/build/executor/env.js +71 -28
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/index.js +4 -0
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +530 -20
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +114 -8
- package/build/executor/preflight.js +127 -8
- package/build/executor/prompt-spec.js +51 -0
- package/build/executor/runner.js +23 -2
- package/build/executor/service-unit.js +876 -0
- package/build/executor/test-clock.js +75 -3
- package/build/executor/types.js +4 -18
- package/build/executor/worker-command.js +73 -12
- package/build/executor/worker-config-isolation.js +287 -0
- package/build/executor/worker-finalization.js +68 -14
- package/build/executor/worktree.js +46 -4
- package/build/index.js +1039 -237
- package/build/init.js +503 -111
- package/build/install-bridge-conductor.js +491 -0
- package/build/install-bridge.js +1188 -247
- package/build/install-reexec.js +233 -0
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-host-config.js +11 -1
- package/build/mcp-install-state.js +32 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +52 -13
- package/build/mcp-registration-doctor.js +14 -5
- package/build/notifications.js +553 -0
- package/build/pipeline-orchestrator.js +146 -4
- package/build/pipeline-utils.js +3 -0
- package/build/pipelines.generated.js +32 -13
- package/build/plan-execution-ledger.js +550 -0
- package/build/plan-phase-routing.js +272 -0
- package/build/plane/alembic-head.js +110 -0
- package/build/plane/build-freshness.js +167 -0
- package/build/plane/cli.js +480 -0
- package/build/plane/defaults.js +266 -0
- package/build/plane/manifest.js +377 -0
- package/build/plane/member-logs.js +147 -0
- package/build/plane/member-roster.js +147 -0
- package/build/plane/preflight.js +289 -0
- package/build/plane/shutdown.js +195 -0
- package/build/plane/status.js +125 -0
- package/build/plane/supervisor.js +569 -0
- package/build/plane/test-fakes.js +156 -0
- package/build/plane/types.js +75 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +259 -0
- package/build/setup-epic.js +772 -28
- package/build/sfcc/log-gate.js +38 -11
- package/build/sfcc/log-query.js +55 -15
- package/build/sfcc/ocapi-shape.js +51 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-projection.js +181 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +29 -18
- package/build/sfcc/reads-site-preference.js +75 -29
- package/build/sfcc/reads-system-object.js +40 -34
- package/build/sfcc/sfcc-result.js +106 -0
- package/build/sfcc/tool-wrapper.js +56 -13
- package/build/sfcc/write-grants.js +45 -22
- package/build/sfcc/write-guard.js +21 -13
- package/build/sfcc/write-result.js +61 -14
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-system-object.js +11 -50
- package/build/start-tickets-prereqs.js +129 -0
- package/build/start-tickets.js +17 -13
- package/build/ticket-backend-metadata.js +59 -0
- package/build/ticket-key-utils.js +92 -0
- package/build/tool-error-envelope.js +71 -0
- package/build/tool-surface-gating.js +72 -0
- package/build/update-status.js +102 -0
- package/build/upgrade-advice.js +47 -0
- package/build/upgrade-cli.js +412 -110
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +132 -6
- package/docs/install/mcp-tool-integrations.md +29 -21
- package/package.json +9 -6
- package/pipelines/implement-ticket.json +19 -4
- package/build/conductor/supervisor-judgment-python.js +0 -141
- package/build/conductor/supervisor-judgment.js +0 -215
|
@@ -90,9 +90,49 @@ const CONDUCTOR_ERROR_PREVIEW_MAX = 200;
|
|
|
90
90
|
* backend message that happens to echo a token/header never reaches a log line.
|
|
91
91
|
*/
|
|
92
92
|
function redactErrorPreview(text) {
|
|
93
|
-
return text
|
|
93
|
+
return (text
|
|
94
94
|
.replace(/sk-[A-Za-z0-9_-]{8,}/g, "[REDACTED]")
|
|
95
|
-
.replace(/(Bearer|X-API-Key|api[_-]?key)\b\s*[:=]?\s*\S+/gi, "$1 [REDACTED]")
|
|
95
|
+
.replace(/(Bearer|X-API-Key|api[_-]?key)\b\s*[:=]?\s*\S+/gi, "$1 [REDACTED]")
|
|
96
|
+
// BAPI-754: the notification webhook URL is the run policy's one
|
|
97
|
+
// secret-bearing value (it can carry userinfo credentials or a query
|
|
98
|
+
// token). `stripWebhookUrlsDeep` removes it structurally before extraction,
|
|
99
|
+
// which handles every body the server actually emits; these two patterns are
|
|
100
|
+
// the textual backstop for a message that INTERPOLATED the URL into prose
|
|
101
|
+
// rather than carrying it as its own JSON leaf.
|
|
102
|
+
//
|
|
103
|
+
// Both are deliberately narrow — they match only a URL-shaped value. A
|
|
104
|
+
// broader "everything after webhook_url" rule would eat the field-path
|
|
105
|
+
// messages a 422 exists to deliver ("policy_json.notify.webhook_url: Input
|
|
106
|
+
// should be a valid URL"), leaving the operator with a redaction where their
|
|
107
|
+
// actionable error used to be.
|
|
108
|
+
.replace(/(webhook[_-]?url)\b["']?\s*[:=]?\s*["']?https?:\/\/\S+/gi, "$1 [REDACTED]")
|
|
109
|
+
// A URL carrying userinfo credentials is unsafe wherever it appears.
|
|
110
|
+
.replace(/https?:\/\/[^\s/@]+:[^\s/@]+@\S+/gi, "[REDACTED]"));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Return a deep copy of a parsed error body with every `webhook_url` key removed,
|
|
114
|
+
* at any depth.
|
|
115
|
+
*
|
|
116
|
+
* Structural removal, not pattern matching: a regex over the rendered JSON would
|
|
117
|
+
* depend on the exact serialization of a value we do not control, and a URL that
|
|
118
|
+
* failed to match would be printed in full. Removing the KEY cannot miss.
|
|
119
|
+
* Non-object input is returned unchanged, and cyclic input is left alone rather
|
|
120
|
+
* than throwing — this runs on an error path and must never raise.
|
|
121
|
+
*/
|
|
122
|
+
function stripWebhookUrlsDeep(value, depth = 0) {
|
|
123
|
+
if (depth > 12)
|
|
124
|
+
return undefined;
|
|
125
|
+
if (Array.isArray(value))
|
|
126
|
+
return value.map((item) => stripWebhookUrlsDeep(item, depth + 1));
|
|
127
|
+
if (!value || typeof value !== "object")
|
|
128
|
+
return value;
|
|
129
|
+
const out = {};
|
|
130
|
+
for (const [key, item] of Object.entries(value)) {
|
|
131
|
+
if (key === "webhook_url")
|
|
132
|
+
continue;
|
|
133
|
+
out[key] = stripWebhookUrlsDeep(item, depth + 1);
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
96
136
|
}
|
|
97
137
|
/** Collapse whitespace, redact secrets, and bound a string to the preview cap. */
|
|
98
138
|
function boundedErrorPreview(text) {
|
|
@@ -201,7 +241,7 @@ function redactDiagnosticValues(diagnostics, secrets) {
|
|
|
201
241
|
*/
|
|
202
242
|
async function readSanitizedErrorDiagnostics(resp, headers = {}) {
|
|
203
243
|
try {
|
|
204
|
-
const diagnostics = extractSanitizedErrorDiagnostics(await resp.json());
|
|
244
|
+
const diagnostics = extractSanitizedErrorDiagnostics(stripWebhookUrlsDeep(await resp.json()));
|
|
205
245
|
const secrets = Object.entries(headers)
|
|
206
246
|
.filter(([k]) => /key|authorization|token/i.test(k))
|
|
207
247
|
.map(([, v]) => v);
|
|
@@ -435,6 +475,21 @@ function conductorPostHeaders(access) {
|
|
|
435
475
|
* POST and PATCH wrappers below.
|
|
436
476
|
*/
|
|
437
477
|
async function fetchConductorJsonWithMethodAndTimeout(method, url, headers, body, timeoutMs, fetchImpl) {
|
|
478
|
+
const { body: parsed } = await fetchConductorJsonWithMethodStatusAndTimeout(method, url, headers, body, timeoutMs, fetchImpl);
|
|
479
|
+
return parsed;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Same transport as {@link fetchConductorJsonWithMethodAndTimeout}, but returns
|
|
483
|
+
* the successful response's HTTP status alongside the parsed body.
|
|
484
|
+
*
|
|
485
|
+
* BAPI-754: `POST /jira/epic-runs/runs` is idempotent and answers `201` for a
|
|
486
|
+
* fresh run and `200` for a reused one. That distinction is the ONLY signal
|
|
487
|
+
* separating "the policy I sent is now in force" from "a policy I did not send is
|
|
488
|
+
* already in force", and the body is byte-identical in both cases — so a caller
|
|
489
|
+
* that only sees the body has to issue another request to learn which happened.
|
|
490
|
+
* Success statuses only; every non-2xx path still throws exactly as before.
|
|
491
|
+
*/
|
|
492
|
+
async function fetchConductorJsonWithMethodStatusAndTimeout(method, url, headers, body, timeoutMs, fetchImpl) {
|
|
438
493
|
const controller = new AbortController();
|
|
439
494
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
440
495
|
try {
|
|
@@ -456,7 +511,7 @@ async function fetchConductorJsonWithMethodAndTimeout(method, url, headers, body
|
|
|
456
511
|
throw new ConductorBridgeApiError("http", resp.status, diagnostics);
|
|
457
512
|
}
|
|
458
513
|
try {
|
|
459
|
-
return await resp.json();
|
|
514
|
+
return { status: resp.status, body: await resp.json() };
|
|
460
515
|
}
|
|
461
516
|
catch {
|
|
462
517
|
throw new ConductorBridgeApiError("network");
|
|
@@ -737,6 +792,67 @@ export async function createEpicRun(access, request, fetchImpl = globalThis.fetc
|
|
|
737
792
|
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), JSON.stringify(body), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
738
793
|
return parsed;
|
|
739
794
|
}
|
|
795
|
+
/**
|
|
796
|
+
* The same create call as {@link createEpicRun}, plus the disposition the HTTP
|
|
797
|
+
* status carries.
|
|
798
|
+
*
|
|
799
|
+
* `created: true` means HTTP `201` — a fresh run, so any `policyJson` sent is now
|
|
800
|
+
* the run's policy. `created: false` means HTTP `200` — the server returned an
|
|
801
|
+
* existing live run and IGNORED the submitted policy, because create is
|
|
802
|
+
* idempotent and never retargets a run. A caller that supplied a policy must
|
|
803
|
+
* therefore reconcile `run.policy_json` (already server-redacted) against what it
|
|
804
|
+
* asked for rather than assume it took effect.
|
|
805
|
+
*
|
|
806
|
+
* Added as a separate export rather than by widening {@link createEpicRun}'s
|
|
807
|
+
* return type, so existing callers that only need the record are untouched.
|
|
808
|
+
*/
|
|
809
|
+
export async function createEpicRunWithDisposition(access, request, fetchImpl = globalThis.fetch) {
|
|
810
|
+
requireNonEmptyString(request.epicKey);
|
|
811
|
+
const body = {
|
|
812
|
+
repo_name: access.repoName,
|
|
813
|
+
epic_key: request.epicKey,
|
|
814
|
+
status: request.status ?? "planning",
|
|
815
|
+
current_plan_version: request.currentPlanVersion ?? 0,
|
|
816
|
+
};
|
|
817
|
+
if (request.policyJson !== undefined)
|
|
818
|
+
body.policy_json = request.policyJson;
|
|
819
|
+
if (request.budgetWallClockSeconds !== undefined) {
|
|
820
|
+
body.budget_wall_clock_seconds = request.budgetWallClockSeconds;
|
|
821
|
+
}
|
|
822
|
+
if (request.budgetCostCents !== undefined) {
|
|
823
|
+
body.budget_cost_cents = request.budgetCostCents;
|
|
824
|
+
}
|
|
825
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/runs`);
|
|
826
|
+
const { status, body: parsed } = await fetchConductorJsonWithMethodStatusAndTimeout("POST", url, conductorPostHeaders(access), JSON.stringify(body), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
827
|
+
return { run: parsed, created: status === 201 };
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* PATCH `/jira/epic-runs/runs/{identifier}` with a COMPLETE replacement of the
|
|
831
|
+
* operator-managed run policy.
|
|
832
|
+
*
|
|
833
|
+
* The endpoint's `policy_json` is replace-not-merge: keys absent from the request
|
|
834
|
+
* are removed and revert to resolver defaults. That is exactly the semantic a
|
|
835
|
+
* `--replace-policy` operator wants — the policy file is the whole policy — and it
|
|
836
|
+
* is why this helper sends the composed object verbatim instead of reading the
|
|
837
|
+
* stored policy and merging into it. A read-modify-write here would silently
|
|
838
|
+
* resurrect keys the operator deleted from their file.
|
|
839
|
+
*
|
|
840
|
+
* Two server-retained paths are deliberately NOT sent back:
|
|
841
|
+
* `notify.webhook_url` (absent from every response, so a client has nothing to
|
|
842
|
+
* echo; omitting it retains the stored URL) and the server-minted
|
|
843
|
+
* `feature_branch_provisioning` (retained automatically, and re-authoring it is a
|
|
844
|
+
* provenance conflict the server rejects).
|
|
845
|
+
*/
|
|
846
|
+
export async function replaceEpicRunPolicy(access, request, fetchImpl = globalThis.fetch) {
|
|
847
|
+
requireNonEmptyString(request.epicRunId);
|
|
848
|
+
const url = buildConductorJiraUrl(access.baseUrl, epicRunApiPath(request.epicRunId));
|
|
849
|
+
const body = JSON.stringify({
|
|
850
|
+
repo_name: access.repoName,
|
|
851
|
+
policy_json: request.policyJson,
|
|
852
|
+
});
|
|
853
|
+
const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
854
|
+
return parsed;
|
|
855
|
+
}
|
|
740
856
|
/**
|
|
741
857
|
* PATCH `/jira/epic-runs/runs/{identifier}` to transition an epic run's
|
|
742
858
|
* lifecycle status. Drives the same backend `update_epic_run` CAS path the
|
|
@@ -985,6 +1101,15 @@ export async function reconcileShadowMerge(access, request, fetchImpl = globalTh
|
|
|
985
1101
|
* authoritative commit-watermark verdict for one ready ticket. The caller treats
|
|
986
1102
|
* `covered`/`not_applicable` as dispatchable, `stale` as hold, and `failed` as a
|
|
987
1103
|
* terminal shadow failure (the backend has already persisted the blocked state).
|
|
1104
|
+
*
|
|
1105
|
+
* BAPI-678: `stale` now covers two backend situations, and deliberately does not
|
|
1106
|
+
* distinguish them on the wire — ordinary unproven freshness, and a `failed`
|
|
1107
|
+
* shadow lifecycle DOWNGRADED by the backend's `policy_json.shadow.warn_only`
|
|
1108
|
+
* posture. Both mean exactly the same thing to this caller (hold, do not claim a
|
|
1109
|
+
* dispatch key), which is why no new verdict was introduced. The backend also
|
|
1110
|
+
* bounds the warn-only hold with its own stale deadline and parks the ticket if
|
|
1111
|
+
* it expires, so a hold here is not unbounded even though nothing on this side
|
|
1112
|
+
* tracks its age. See {@link ShadowFreshnessVerdict}.
|
|
988
1113
|
*/
|
|
989
1114
|
export async function fetchShadowDispatchFreshness(access, request, fetchImpl = globalThis.fetch) {
|
|
990
1115
|
requireNonEmptyString(request.epicKey);
|
|
@@ -995,6 +1120,64 @@ export async function fetchShadowDispatchFreshness(access, request, fetchImpl =
|
|
|
995
1120
|
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
996
1121
|
return parseShadowDispatchFreshnessResult(parsed);
|
|
997
1122
|
}
|
|
1123
|
+
/**
|
|
1124
|
+
* POST `/jira/epic-runs/plan/validate` to validate a plan blob without storing it.
|
|
1125
|
+
*
|
|
1126
|
+
* The point of the call is what it does NOT do: no run is created, no plan is
|
|
1127
|
+
* stored, and no automation-start debit is charged. `setup-epic` runs this before
|
|
1128
|
+
* every create so a plan the server would reject costs an operator nothing.
|
|
1129
|
+
*
|
|
1130
|
+
* Error handling is load-bearing for the CLI's branching, so it is spelled out:
|
|
1131
|
+
*
|
|
1132
|
+
* - A **`404`** surfaces as `ConductorBridgeApiError.status === 404` and means the
|
|
1133
|
+
* server predates this lane. That is the ONE case the CLI is allowed to fall
|
|
1134
|
+
* back from, so it must stay distinguishable from a validation failure.
|
|
1135
|
+
* - A **transport failure** throws with NO `status`, which is how the CLI knows to
|
|
1136
|
+
* say "server unreachable, cannot fully validate" rather than blaming the plan.
|
|
1137
|
+
* - A **`400`** is the plan's own field-named rejection and is terminal.
|
|
1138
|
+
*
|
|
1139
|
+
* A nominal `200` whose body does not match the strict success shape is treated as
|
|
1140
|
+
* a protocol error rather than a partial success: reporting "valid" on a body we
|
|
1141
|
+
* could not fully verify is precisely the false assurance this lane exists to
|
|
1142
|
+
* remove.
|
|
1143
|
+
*/
|
|
1144
|
+
export async function validateEpicPlan(access, request, fetchImpl = globalThis.fetch) {
|
|
1145
|
+
requirePositiveSafeInteger(request.planVersion);
|
|
1146
|
+
const blobVersion = request.planBlob.plan_version;
|
|
1147
|
+
if (blobVersion !== undefined && blobVersion !== request.planVersion) {
|
|
1148
|
+
throw new ConductorValidationError(`planVersion mismatch: request.planVersion=${request.planVersion} but planBlob.plan_version=${blobVersion}`);
|
|
1149
|
+
}
|
|
1150
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/plan/validate`);
|
|
1151
|
+
const body = {
|
|
1152
|
+
repo_name: access.repoName,
|
|
1153
|
+
plan_version: request.planVersion,
|
|
1154
|
+
plan_blob: request.planBlob,
|
|
1155
|
+
};
|
|
1156
|
+
if (request.epicKey !== undefined)
|
|
1157
|
+
body.epic_key = request.epicKey;
|
|
1158
|
+
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), JSON.stringify(body), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1159
|
+
return parseValidateEpicPlanResult(parsed);
|
|
1160
|
+
}
|
|
1161
|
+
/** Strict parse of the validate lane's success body; throws on any deviation. */
|
|
1162
|
+
function parseValidateEpicPlanResult(parsed) {
|
|
1163
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1164
|
+
throw new ConductorBridgeApiError("server");
|
|
1165
|
+
}
|
|
1166
|
+
const p = parsed;
|
|
1167
|
+
const planHash = p["plan_hash"];
|
|
1168
|
+
const serializationEnabled = p["serialization_enabled"];
|
|
1169
|
+
const insertedEdges = p["inserted_edges"];
|
|
1170
|
+
if (p["valid"] !== true ||
|
|
1171
|
+
typeof planHash !== "string" ||
|
|
1172
|
+
planHash.trim() === "" ||
|
|
1173
|
+
typeof serializationEnabled !== "boolean" ||
|
|
1174
|
+
typeof insertedEdges !== "number" ||
|
|
1175
|
+
!Number.isSafeInteger(insertedEdges) ||
|
|
1176
|
+
insertedEdges < 0) {
|
|
1177
|
+
throw new ConductorBridgeApiError("server");
|
|
1178
|
+
}
|
|
1179
|
+
return { planHash, serializationEnabled, insertedEdges };
|
|
1180
|
+
}
|
|
998
1181
|
/**
|
|
999
1182
|
* POST the immutable plan blob to the durable-store endpoint. The blob is
|
|
1000
1183
|
* written once per `(epic_run_id, plan_version)` and never mutated — a
|
|
@@ -1019,6 +1202,38 @@ export async function storeEpicPlan(access, request, fetchImpl = globalThis.fetc
|
|
|
1019
1202
|
});
|
|
1020
1203
|
return fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1021
1204
|
}
|
|
1205
|
+
/**
|
|
1206
|
+
* PATCH one approved plan node's `ticket_spec` without re-storing or re-approving
|
|
1207
|
+
* the plan (BAPI-722).
|
|
1208
|
+
*
|
|
1209
|
+
* This is how a ticket specification too large for Jira's 32,767-character
|
|
1210
|
+
* description cap reaches the reviewer intact: it is stored on the approved plan
|
|
1211
|
+
* node, which the reconciler already consults FIRST when resolving a spec-review
|
|
1212
|
+
* prompt. Nothing else about the run changes — not the plan version, not the
|
|
1213
|
+
* approval pointers, and not a single `epic_ticket_status` row — which is what
|
|
1214
|
+
* makes it safe against a live epic.
|
|
1215
|
+
*
|
|
1216
|
+
* `expectedPlanHash` is the optimistic-concurrency token; a stale token on a real
|
|
1217
|
+
* content change returns HTTP 409. The API key travels ONLY in the `X-API-Key`
|
|
1218
|
+
* header, and neither the spec text nor any credential is ever incorporated into a
|
|
1219
|
+
* thrown error message. Errors surface as a sanitized
|
|
1220
|
+
* {@link ConductorBridgeApiError}.
|
|
1221
|
+
*/
|
|
1222
|
+
export async function updateApprovedPlanNodeTicketSpec(access, request, fetchImpl = globalThis.fetch) {
|
|
1223
|
+
requireNonEmptyString(request.epicKey);
|
|
1224
|
+
requireNonEmptyString(request.ticketKey);
|
|
1225
|
+
requireNonEmptyString(request.expectedPlanHash);
|
|
1226
|
+
requireNonEmptyString(request.ticketSpec);
|
|
1227
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/plan/nodes/` +
|
|
1228
|
+
`${encodeURIComponent(request.ticketKey)}/ticket-spec`);
|
|
1229
|
+
const body = JSON.stringify({
|
|
1230
|
+
repo_name: access.repoName,
|
|
1231
|
+
expected_plan_hash: request.expectedPlanHash,
|
|
1232
|
+
ticket_spec: request.ticketSpec,
|
|
1233
|
+
});
|
|
1234
|
+
const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1235
|
+
return parsed;
|
|
1236
|
+
}
|
|
1022
1237
|
/**
|
|
1023
1238
|
* Atomically approve a plan version. Bumps `epic_runs.approved_plan_hash` to
|
|
1024
1239
|
* the named version's `plan_hash` in a single CAS write — never a
|
|
@@ -1203,3 +1418,177 @@ export async function transitionJiraStatus(access, ticketNumber, targetStatus =
|
|
|
1203
1418
|
throw err;
|
|
1204
1419
|
}
|
|
1205
1420
|
}
|
|
1421
|
+
const READINESS_SOURCES = new Set(["epic", "project_default", "none"]);
|
|
1422
|
+
const ACTIONS_LEVELS = new Set(["write", "read", "none", "unknown"]);
|
|
1423
|
+
function readinessShapeError() {
|
|
1424
|
+
// Deliberately carries NO field name, value, or payload excerpt: a malformed
|
|
1425
|
+
// body is untrusted input and its contents must not reach output.
|
|
1426
|
+
return new ConductorBridgeApiError("invalid-input", undefined, {
|
|
1427
|
+
errorCode: "READINESS_SHAPE_INVALID",
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
function requireObject(value) {
|
|
1431
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1432
|
+
throw readinessShapeError();
|
|
1433
|
+
}
|
|
1434
|
+
return value;
|
|
1435
|
+
}
|
|
1436
|
+
function requireBool(o, key) {
|
|
1437
|
+
const v = o[key];
|
|
1438
|
+
if (typeof v !== "boolean")
|
|
1439
|
+
throw readinessShapeError();
|
|
1440
|
+
return v;
|
|
1441
|
+
}
|
|
1442
|
+
function requireInt(o, key) {
|
|
1443
|
+
const v = o[key];
|
|
1444
|
+
if (typeof v !== "number" || !Number.isInteger(v))
|
|
1445
|
+
throw readinessShapeError();
|
|
1446
|
+
return v;
|
|
1447
|
+
}
|
|
1448
|
+
function requireNullableInt(o, key) {
|
|
1449
|
+
const v = o[key];
|
|
1450
|
+
if (v === null || v === undefined)
|
|
1451
|
+
return null;
|
|
1452
|
+
if (typeof v !== "number" || !Number.isInteger(v))
|
|
1453
|
+
throw readinessShapeError();
|
|
1454
|
+
return v;
|
|
1455
|
+
}
|
|
1456
|
+
function requireNullableString(o, key) {
|
|
1457
|
+
const v = o[key];
|
|
1458
|
+
if (v === null || v === undefined)
|
|
1459
|
+
return null;
|
|
1460
|
+
if (typeof v !== "string")
|
|
1461
|
+
throw readinessShapeError();
|
|
1462
|
+
return v;
|
|
1463
|
+
}
|
|
1464
|
+
function requireNullableBool(o, key) {
|
|
1465
|
+
const v = o[key];
|
|
1466
|
+
if (v === null || v === undefined)
|
|
1467
|
+
return null;
|
|
1468
|
+
if (typeof v !== "boolean")
|
|
1469
|
+
throw readinessShapeError();
|
|
1470
|
+
return v;
|
|
1471
|
+
}
|
|
1472
|
+
function requireEnum(o, key, allowed) {
|
|
1473
|
+
const v = o[key];
|
|
1474
|
+
if (typeof v !== "string" || !allowed.has(v))
|
|
1475
|
+
throw readinessShapeError();
|
|
1476
|
+
return v;
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Validate a raw readiness body into the typed response, FAIL-CLOSED.
|
|
1480
|
+
*
|
|
1481
|
+
* Every field the composed doctor makes a safety decision on is checked for
|
|
1482
|
+
* presence and exact type. This is not defensive nicety: a 200 whose body is
|
|
1483
|
+
* missing `required_checks_empty`, or carries it as the string `"false"`, would
|
|
1484
|
+
* otherwise be cast straight into an apparently-healthy report and the operator
|
|
1485
|
+
* would be told a trivially-passing gate was fine.
|
|
1486
|
+
*/
|
|
1487
|
+
export function parseConductorReadinessResponse(body) {
|
|
1488
|
+
const root = requireObject(body);
|
|
1489
|
+
const repoName = root.repo_name;
|
|
1490
|
+
if (typeof repoName !== "string" || repoName.length === 0)
|
|
1491
|
+
throw readinessShapeError();
|
|
1492
|
+
const sup = requireObject(root.supervisor);
|
|
1493
|
+
const gh = requireObject(root.github);
|
|
1494
|
+
const rec = requireObject(root.reconciler);
|
|
1495
|
+
const exec = requireObject(root.executor);
|
|
1496
|
+
const thr = requireObject(root.thresholds);
|
|
1497
|
+
return {
|
|
1498
|
+
repo_name: repoName,
|
|
1499
|
+
supervisor: {
|
|
1500
|
+
setup_present: requireBool(sup, "setup_present"),
|
|
1501
|
+
setup_source: requireEnum(sup, "setup_source", READINESS_SOURCES),
|
|
1502
|
+
setup_created_at: requireNullableString(sup, "setup_created_at"),
|
|
1503
|
+
setup_updated_at: requireNullableString(sup, "setup_updated_at"),
|
|
1504
|
+
config_present: requireBool(sup, "config_present"),
|
|
1505
|
+
config_source: requireEnum(sup, "config_source", READINESS_SOURCES),
|
|
1506
|
+
config_created_at: requireNullableString(sup, "config_created_at"),
|
|
1507
|
+
config_updated_at: requireNullableString(sup, "config_updated_at"),
|
|
1508
|
+
required_checks_count: requireInt(sup, "required_checks_count"),
|
|
1509
|
+
required_checks_empty: requireBool(sup, "required_checks_empty"),
|
|
1510
|
+
auto_merge_enabled: requireBool(sup, "auto_merge_enabled"),
|
|
1511
|
+
merge_approval_required_set: requireBool(sup, "merge_approval_required_set"),
|
|
1512
|
+
},
|
|
1513
|
+
github: {
|
|
1514
|
+
credentials_readable: requireBool(gh, "credentials_readable"),
|
|
1515
|
+
owner_resolved: requireBool(gh, "owner_resolved"),
|
|
1516
|
+
repo_id_resolved: requireBool(gh, "repo_id_resolved"),
|
|
1517
|
+
installation_id_resolved: requireBool(gh, "installation_id_resolved"),
|
|
1518
|
+
credentials_complete: requireBool(gh, "credentials_complete"),
|
|
1519
|
+
actions_probe_succeeded: requireBool(gh, "actions_probe_succeeded"),
|
|
1520
|
+
actions_permission_present: requireBool(gh, "actions_permission_present"),
|
|
1521
|
+
actions_permission_level: requireEnum(gh, "actions_permission_level", ACTIONS_LEVELS),
|
|
1522
|
+
actions_write: requireBool(gh, "actions_write"),
|
|
1523
|
+
},
|
|
1524
|
+
reconciler: {
|
|
1525
|
+
liveness_readable: requireBool(rec, "liveness_readable"),
|
|
1526
|
+
last_tick_at: requireNullableString(rec, "last_tick_at"),
|
|
1527
|
+
last_tick_age_seconds: requireNullableInt(rec, "last_tick_age_seconds"),
|
|
1528
|
+
stale: requireBool(rec, "stale"),
|
|
1529
|
+
active_run_count: requireInt(rec, "active_run_count"),
|
|
1530
|
+
expired_lease_count: requireInt(rec, "expired_lease_count"),
|
|
1531
|
+
},
|
|
1532
|
+
executor: {
|
|
1533
|
+
liveness_readable: requireBool(exec, "liveness_readable"),
|
|
1534
|
+
last_seen_at: requireNullableString(exec, "last_seen_at"),
|
|
1535
|
+
last_seen_age_seconds: requireNullableInt(exec, "last_seen_age_seconds"),
|
|
1536
|
+
ready: requireNullableBool(exec, "ready"),
|
|
1537
|
+
},
|
|
1538
|
+
thresholds: {
|
|
1539
|
+
reconciler_stale_after_seconds: requireInt(thr, "reconciler_stale_after_seconds"),
|
|
1540
|
+
executor_stale_after_seconds: requireInt(thr, "executor_stale_after_seconds"),
|
|
1541
|
+
},
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* GET `/jira/epic-runs/conductor-readiness?repo_name=<repo>`.
|
|
1546
|
+
*
|
|
1547
|
+
* The API key travels ONLY in `X-API-Key`; `repo_name` is URL-encoded into the
|
|
1548
|
+
* query string because the endpoint scopes on it. Fails closed on transport,
|
|
1549
|
+
* auth, and shape errors alike.
|
|
1550
|
+
*/
|
|
1551
|
+
export async function fetchConductorReadiness(access, fetchImpl = globalThis.fetch) {
|
|
1552
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/conductor-readiness`, {
|
|
1553
|
+
repo_name: access.repoName,
|
|
1554
|
+
});
|
|
1555
|
+
const body = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1556
|
+
return parseConductorReadinessResponse(body);
|
|
1557
|
+
}
|
|
1558
|
+
/** Validate a raw bootstrap body, fail-closed (see {@link parseConductorReadinessResponse}). */
|
|
1559
|
+
export function parseConductorSupervisorBootstrapResponse(body) {
|
|
1560
|
+
const o = requireObject(body);
|
|
1561
|
+
const repoName = o.repo_name;
|
|
1562
|
+
if (typeof repoName !== "string" || repoName.length === 0)
|
|
1563
|
+
throw readinessShapeError();
|
|
1564
|
+
const names = o.audited_field_names;
|
|
1565
|
+
if (!Array.isArray(names) || names.some((n) => typeof n !== "string")) {
|
|
1566
|
+
throw readinessShapeError();
|
|
1567
|
+
}
|
|
1568
|
+
return {
|
|
1569
|
+
repo_name: repoName,
|
|
1570
|
+
setup_written: requireBool(o, "setup_written"),
|
|
1571
|
+
config_written: requireBool(o, "config_written"),
|
|
1572
|
+
setup_source: requireEnum(o, "setup_source", READINESS_SOURCES),
|
|
1573
|
+
config_source: requireEnum(o, "config_source", READINESS_SOURCES),
|
|
1574
|
+
setup_created_at: requireNullableString(o, "setup_created_at"),
|
|
1575
|
+
setup_updated_at: requireNullableString(o, "setup_updated_at"),
|
|
1576
|
+
config_created_at: requireNullableString(o, "config_created_at"),
|
|
1577
|
+
config_updated_at: requireNullableString(o, "config_updated_at"),
|
|
1578
|
+
required_checks_count: requireInt(o, "required_checks_count"),
|
|
1579
|
+
audited_field_names: names,
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* POST `/jira/epic-runs/supervisor-bootstrap?repo_name=<repo>`.
|
|
1584
|
+
*
|
|
1585
|
+
* The ONLY write this client performs. Deliberately carries no `review_policy`
|
|
1586
|
+
* field: repository-default review policy is owned by BAPI-694.
|
|
1587
|
+
*/
|
|
1588
|
+
export async function bootstrapConductorSupervisorDefaults(access, request, fetchImpl) {
|
|
1589
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/supervisor-bootstrap`, {
|
|
1590
|
+
repo_name: access.repoName,
|
|
1591
|
+
});
|
|
1592
|
+
const body = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), JSON.stringify(request), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1593
|
+
return parseConductorSupervisorBootstrapResponse(body);
|
|
1594
|
+
}
|
|
@@ -47,6 +47,7 @@ export async function runDenyEnforcementPreflight(opts = {}) {
|
|
|
47
47
|
const { result, layer } = await runDenyEnforcementCheck(ctx, {
|
|
48
48
|
model: opts.model,
|
|
49
49
|
timeoutMs: opts.timeoutMs,
|
|
50
|
+
permissionPosture: opts.permissionPosture,
|
|
50
51
|
});
|
|
51
52
|
if (result.status === "pass" && layer === "settings-deny") {
|
|
52
53
|
return {
|
|
@@ -83,15 +83,49 @@ function parseCiChecksCondition(entry) {
|
|
|
83
83
|
}
|
|
84
84
|
return { type: REQUIRED_CI_CHECKS_GREEN, required_checks: normalized };
|
|
85
85
|
}
|
|
86
|
-
/** Valid review source values. */
|
|
87
|
-
const VALID_REVIEW_SOURCES = new Set(["
|
|
86
|
+
/** Valid — i.e. CANONICAL — review source values (BAPI-689). */
|
|
87
|
+
const VALID_REVIEW_SOURCES = new Set(["verdict_protocol", "native_review_decision", "min_approvals", "combination"]);
|
|
88
|
+
/**
|
|
89
|
+
* Historical review-source spellings accepted on INPUT only.
|
|
90
|
+
*
|
|
91
|
+
* BAPI-689 unified three vocabularies onto one, but `done_gate_config` is
|
|
92
|
+
* free-form operator-authored JSONB that this module only ever reads — there is
|
|
93
|
+
* no migration that rewrites it. So already-stored configs keep naming the
|
|
94
|
+
* sticky-verdict source `sticky_verdict` (this module's own former spelling) or
|
|
95
|
+
* `claude_review_sticky` / `github_review_decision` (the former supervisor
|
|
96
|
+
* spellings), and they must stay readable.
|
|
97
|
+
*
|
|
98
|
+
* Mapping is applied at the parser seam and the CANONICAL value is what the
|
|
99
|
+
* parsed condition carries, so no alias survives into the config hash, the gate
|
|
100
|
+
* evaluator, or any serialized output.
|
|
101
|
+
*/
|
|
102
|
+
const REVIEW_SOURCE_ALIASES = Object.freeze({
|
|
103
|
+
sticky_verdict: "verdict_protocol",
|
|
104
|
+
claude_review_sticky: "verdict_protocol",
|
|
105
|
+
github_review_decision: "native_review_decision",
|
|
106
|
+
});
|
|
107
|
+
/**
|
|
108
|
+
* Canonicalize a raw stored review-source value.
|
|
109
|
+
*
|
|
110
|
+
* Returns the canonical spelling for a known alias, the value unchanged when it
|
|
111
|
+
* is already canonical, and the value unchanged when it is neither — so an
|
|
112
|
+
* unsupported spelling still reaches (and fails) `VALID_REVIEW_SOURCES` rather
|
|
113
|
+
* than being coerced into a default. Exact-match only: no case folding and no
|
|
114
|
+
* trimming, matching the strictness of every other field in this parser.
|
|
115
|
+
*/
|
|
116
|
+
function normalizeReviewSource(source) {
|
|
117
|
+
return REVIEW_SOURCE_ALIASES[source] ?? source;
|
|
118
|
+
}
|
|
88
119
|
/**
|
|
89
120
|
* Parse and validate a single `review_state` condition entry.
|
|
90
121
|
* Returns the normalized condition or `null` on any validation failure.
|
|
91
122
|
*/
|
|
92
123
|
function parseReviewStateCondition(entry) {
|
|
93
|
-
const
|
|
94
|
-
if (typeof
|
|
124
|
+
const rawSource = entry.source;
|
|
125
|
+
if (typeof rawSource !== "string")
|
|
126
|
+
return null;
|
|
127
|
+
const source = normalizeReviewSource(rawSource);
|
|
128
|
+
if (!VALID_REVIEW_SOURCES.has(source))
|
|
95
129
|
return null;
|
|
96
130
|
const condition = { type: REVIEW_STATE, source: source };
|
|
97
131
|
if (entry.require_sticky_verdict !== undefined) {
|
|
@@ -425,7 +459,12 @@ export function evaluateReviewCondition(condition, snapshot) {
|
|
|
425
459
|
return { passed: false, changesRequested: false, reason: "review snapshot unavailable" };
|
|
426
460
|
}
|
|
427
461
|
const source = condition.source;
|
|
428
|
-
|
|
462
|
+
// BAPI-689: the condition's `source` is always CANONICAL here — every path to a
|
|
463
|
+
// `ReviewStateCondition` goes through `parseReviewStateCondition`, which maps
|
|
464
|
+
// the historical spellings before validating. `snapshot.sticky_verdict` is a
|
|
465
|
+
// different thing entirely (the parsed value OF the sticky comment) and keeps
|
|
466
|
+
// its name.
|
|
467
|
+
if (source === "verdict_protocol") {
|
|
429
468
|
if (snapshot.sticky_verdict === "approved")
|
|
430
469
|
return { passed: true, changesRequested: false, reason: "sticky verdict approved" };
|
|
431
470
|
if (snapshot.sticky_verdict === "changes_requested")
|
|
@@ -171,6 +171,12 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
171
171
|
deps.log(`[epic-reconcile] shadow-freshness error for ${ticketKey}: ${safeDiagnosticMessage(err, "freshness error")}; holding`);
|
|
172
172
|
}
|
|
173
173
|
if (verdict === "stale") {
|
|
174
|
+
// BAPI-678: this covers ordinary unproven freshness AND the backend's
|
|
175
|
+
// warn-only downgrade of a `failed` shadow lifecycle. Identical handling
|
|
176
|
+
// is intentional — either way the watermark is unproven, so hold before
|
|
177
|
+
// claiming a dispatch key. The backend clocks the hold and parks the
|
|
178
|
+
// ticket if its own stale deadline expires, so "hold" is bounded there
|
|
179
|
+
// rather than repeating forever here.
|
|
174
180
|
deps.log(`[epic-reconcile] holding ${ticketKey}: shadow watermark stale`);
|
|
175
181
|
continue;
|
|
176
182
|
}
|