@bridge_gpt/mcp-server 0.2.62 → 0.2.65
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 +47 -8
- package/build/agent-launchers/claude-executor-adapter.js +134 -3
- package/build/agent-launchers/executor-adapter.js +54 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +335 -22
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +324 -0
- package/build/conductor/epic-implementer-cli.js +142 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/readiness-sections.js +5 -4
- package/build/conductor/readiness.js +22 -11
- package/build/conductor/recovery-cli.js +354 -13
- package/build/conductor/recovery-operations.js +251 -5
- package/build/conductor/repair-contract.js +8 -0
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +50 -12
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +77 -11
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1211 -116
- package/build/epic-coherence-input-cli.js +137 -0
- package/build/epic-coherence-input.js +920 -0
- package/build/epic-coherence-input.test-fixtures.js +175 -0
- package/build/epic-implementer-bin.js +35 -29
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-launch-profile.js +14 -5
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +39 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +286 -0
- package/build/executor/credentials.js +3 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +6 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +76 -4
- package/build/executor/job-errors.js +48 -0
- package/build/executor/job-runner.js +1294 -26
- package/build/executor/job-types.js +10 -2
- package/build/executor/preflight.js +99 -0
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +262 -1
- package/build/executor/provider-checks.js +69 -0
- package/build/executor/rebase-finalization.js +351 -0
- package/build/executor/reconcile-overlap-job.js +77 -84
- package/build/executor/required-verification.js +313 -0
- package/build/executor/required-work-ledger.js +1121 -0
- package/build/executor/results.js +142 -0
- package/build/executor/resume-pre-spawn.js +87 -4
- package/build/executor/runner.js +145 -0
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +12 -0
- package/build/executor/worker-command.js +49 -13
- package/build/executor/worker-finalization.js +88 -11
- package/build/executor/worker-guard-hook.js +29 -4
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +537 -943
- package/build/install-bridge-conductor.js +231 -41
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +8 -7
- package/build/plan-discovery.js +137 -0
- package/build/plan-epic-implementer-eligibility.js +2 -2
- package/build/plane/cli.js +37 -3
- package/build/plane/member-logs.js +3 -0
- package/build/plane/member-roster.js +7 -1
- package/build/plane/shutdown.js +59 -1
- package/build/plane/supervisor.js +142 -0
- package/build/readme.generated.js +1 -1
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +28 -17
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/docs/CONDUCTOR.md +116 -2
- package/package.json +2 -1
|
@@ -16,6 +16,7 @@ import { readEpicLaunchProfile, resolveEpicLaunchTarget, } from "../epic-launch-
|
|
|
16
16
|
import { resolveStartTicketsRepoName } from "../start-tickets-repo.js";
|
|
17
17
|
import { normalizePrNumber, normalizeSha } from "./git-ci-types.js";
|
|
18
18
|
import { ConductorValidationError } from "./errors.js";
|
|
19
|
+
import { parseContinuationStatusResult, parseContinueParkedChildResult, } from "./epic-child-continuation.js";
|
|
19
20
|
import { resolveDeclaredRunBaseBranch } from "./run-branch.js";
|
|
20
21
|
/** Default Bridge API base URL when `BAPI_BASE_URL` is unset. */
|
|
21
22
|
export const CONDUCTOR_DEFAULT_BASE_URL = "https://bridgegpt-api.com";
|
|
@@ -70,12 +71,15 @@ export async function resolveConductorBridgeApiAccess(deps = {}) {
|
|
|
70
71
|
*
|
|
71
72
|
* 1. the selected profile is read and validated (host-local, read-only);
|
|
72
73
|
* 2. the TARGET is decided from `--base-url`, the profile, and `BAPI_BASE_URL`
|
|
73
|
-
* — conflicts refuse
|
|
74
|
-
* {@link CONDUCTOR_DEFAULT_BASE_URL};
|
|
74
|
+
* — conflicts refuse. With none of them set, the target is
|
|
75
|
+
* {@link CONDUCTOR_DEFAULT_BASE_URL} (BAPI-1272);
|
|
75
76
|
* 3. the repository is decided (profile binding, then `--repo`, then the
|
|
76
77
|
* shared resolver);
|
|
77
78
|
* 4. only then is a credential looked up, coupled to that target AND that
|
|
78
|
-
* repository ({@link resolveEpicLaunchCredential}).
|
|
79
|
+
* repository ({@link resolveEpicLaunchCredential}). A stored key with no
|
|
80
|
+
* binding is accepted only when the target is production; a key bound to
|
|
81
|
+
* another endpoint, or an ambient key without a matching `BAPI_BASE_URL`,
|
|
82
|
+
* is still refused.
|
|
79
83
|
*
|
|
80
84
|
* Nothing is sent anywhere by this function. {@link resolveConductorBridgeApiAccess}
|
|
81
85
|
* is deliberately left as it is for every other client.
|
|
@@ -104,6 +108,7 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
|
|
|
104
108
|
flagRepo: deps.repoName,
|
|
105
109
|
profile,
|
|
106
110
|
env,
|
|
111
|
+
defaultBaseUrl: CONDUCTOR_DEFAULT_BASE_URL,
|
|
107
112
|
});
|
|
108
113
|
if (!target.ok)
|
|
109
114
|
return { ok: false, kind: target.kind, error: target.message };
|
|
@@ -119,13 +124,7 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
|
|
|
119
124
|
}
|
|
120
125
|
let cred;
|
|
121
126
|
try {
|
|
122
|
-
cred = await resolveEpicLaunchCredential(repoName, target.baseUrl, {
|
|
123
|
-
env,
|
|
124
|
-
homedir,
|
|
125
|
-
platform,
|
|
126
|
-
readFile: readFileImpl,
|
|
127
|
-
stat: statImpl,
|
|
128
|
-
});
|
|
127
|
+
cred = await resolveEpicLaunchCredential(repoName, target.baseUrl, { env, homedir, platform, readFile: readFileImpl, stat: statImpl }, { productionBaseUrl: CONDUCTOR_DEFAULT_BASE_URL });
|
|
129
128
|
}
|
|
130
129
|
catch {
|
|
131
130
|
return { ok: false, kind: "credentials-unavailable", error: "failed to resolve Bridge API credentials" };
|
|
@@ -750,6 +749,18 @@ export async function remediateEpicTicket(access, request, fetchImpl = globalThi
|
|
|
750
749
|
throw err;
|
|
751
750
|
}
|
|
752
751
|
}
|
|
752
|
+
/**
|
|
753
|
+
* BAPI-1310 (E1) — the server's refusal codes for the unexplained review-head
|
|
754
|
+
* stop. Each is an HTTP 409 that leaves the ticket parked.
|
|
755
|
+
*/
|
|
756
|
+
export const REVIEW_HEAD_REFUSAL_CODES = [
|
|
757
|
+
"REVIEW_HEAD_ADOPTION_REQUIRED",
|
|
758
|
+
"REVIEW_HEAD_EPISODE_CONFLICT",
|
|
759
|
+
"REVIEW_HEAD_STATE_INVALID",
|
|
760
|
+
"REVIEW_HEAD_BINDING_CHANGED",
|
|
761
|
+
"REVIEW_HEAD_LIVE_WORK",
|
|
762
|
+
"REVIEW_HEAD_UNAVAILABLE",
|
|
763
|
+
];
|
|
753
764
|
// ---------------------------------------------------------------------------
|
|
754
765
|
// Local boundary validators (reject before any fetch call)
|
|
755
766
|
// ---------------------------------------------------------------------------
|
|
@@ -1251,6 +1262,108 @@ export async function unparkEpicTicket(access, request, fetchImpl = globalThis.f
|
|
|
1251
1262
|
});
|
|
1252
1263
|
return postUnparkLikeRequest(url, conductorPostHeaders(access), body, fetchImpl);
|
|
1253
1264
|
}
|
|
1265
|
+
const TYPED_CONTINUATION_STATUSES = new Set([200, 201, 409, 503]);
|
|
1266
|
+
const SAFE_ERROR_CODE_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
1267
|
+
function continuationChildPath(epicRunId, ticketKey) {
|
|
1268
|
+
return `${epicRunApiPath(epicRunId)}/tickets/${encodeURIComponent(ticketKey)}`;
|
|
1269
|
+
}
|
|
1270
|
+
function safeDetailErrorCode(body) {
|
|
1271
|
+
if (body === null || typeof body !== "object")
|
|
1272
|
+
return null;
|
|
1273
|
+
const detail = body.detail;
|
|
1274
|
+
if (detail === null || typeof detail !== "object")
|
|
1275
|
+
return null;
|
|
1276
|
+
const code = detail.error_code;
|
|
1277
|
+
return typeof code === "string" && SAFE_ERROR_CODE_RE.test(code) ? code : null;
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* POST `.../tickets/{ticket_key}/continue` exactly once and classify the answer.
|
|
1281
|
+
*
|
|
1282
|
+
* Never throws for an HTTP or transport outcome — every one of them is returned
|
|
1283
|
+
* as a {@link ContinuationPostOutcome}, so no caller can accidentally turn a
|
|
1284
|
+
* timeout into "refused" by catching an exception. Never falls back to unpark.
|
|
1285
|
+
* The API key travels only in the header; the body carries no credential and no
|
|
1286
|
+
* caller-asserted authority.
|
|
1287
|
+
*/
|
|
1288
|
+
export async function postContinueParkedChild(access, request, fetchImpl = globalThis.fetch, timeoutMs = CONDUCTOR_FETCH_TIMEOUT_MS) {
|
|
1289
|
+
const b = request.bindings;
|
|
1290
|
+
requireNonEmptyString(b.epicRunId);
|
|
1291
|
+
requireNonEmptyString(b.ticketKey);
|
|
1292
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${continuationChildPath(b.epicRunId, b.ticketKey)}/continue`);
|
|
1293
|
+
const body = JSON.stringify({
|
|
1294
|
+
repo_name: b.repoName,
|
|
1295
|
+
request_id: request.requestId,
|
|
1296
|
+
parked_gate: b.parkedGate,
|
|
1297
|
+
expected_row_version: b.expectedRowVersion,
|
|
1298
|
+
expected_head_sha: b.expectedHeadSha,
|
|
1299
|
+
source_park_generation: b.sourceParkGeneration,
|
|
1300
|
+
request_fingerprint: request.requestFingerprint,
|
|
1301
|
+
...(request.reason ? { reason: request.reason } : {}),
|
|
1302
|
+
});
|
|
1303
|
+
const controller = new AbortController();
|
|
1304
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1305
|
+
try {
|
|
1306
|
+
let resp;
|
|
1307
|
+
try {
|
|
1308
|
+
resp = await fetchImpl(url, {
|
|
1309
|
+
method: "POST",
|
|
1310
|
+
headers: conductorPostHeaders(access),
|
|
1311
|
+
body,
|
|
1312
|
+
signal: controller.signal,
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
catch {
|
|
1316
|
+
return { kind: "uncertain", reason: controller.signal.aborted ? "timeout" : "network" };
|
|
1317
|
+
}
|
|
1318
|
+
let parsed;
|
|
1319
|
+
let parsedOk = true;
|
|
1320
|
+
try {
|
|
1321
|
+
parsed = await resp.json();
|
|
1322
|
+
}
|
|
1323
|
+
catch {
|
|
1324
|
+
parsedOk = false;
|
|
1325
|
+
}
|
|
1326
|
+
if (TYPED_CONTINUATION_STATUSES.has(resp.status) && parsedOk) {
|
|
1327
|
+
try {
|
|
1328
|
+
return { kind: "result", status: resp.status, result: parseContinueParkedChildResult(parsed) };
|
|
1329
|
+
}
|
|
1330
|
+
catch {
|
|
1331
|
+
// Fall through: an untyped body on these statuses is classified below.
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
if (resp.status >= 200 && resp.status < 300) {
|
|
1335
|
+
return { kind: "uncertain", reason: "malformed", status: resp.status };
|
|
1336
|
+
}
|
|
1337
|
+
if (resp.status >= 500) {
|
|
1338
|
+
return { kind: "uncertain", reason: "server", status: resp.status };
|
|
1339
|
+
}
|
|
1340
|
+
// A 4xx without a typed body is an authoritative pre-mutation rejection.
|
|
1341
|
+
return {
|
|
1342
|
+
kind: "refused_http",
|
|
1343
|
+
status: resp.status,
|
|
1344
|
+
errorCode: parsedOk ? safeDetailErrorCode(parsed) : null,
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
finally {
|
|
1348
|
+
clearTimeout(timer);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* GET `.../tickets/{ticket_key}/continuation` — the child's current episode and,
|
|
1353
|
+
* with `requestId`, that request's receipt. Read-only; bounded by the standard
|
|
1354
|
+
* conductor timeout. Throws {@link ConductorBridgeApiError} on transport or HTTP
|
|
1355
|
+
* failure and {@link ContinuationParseError} on a malformed body.
|
|
1356
|
+
*/
|
|
1357
|
+
export async function fetchChildContinuationStatus(access, query, fetchImpl = globalThis.fetch) {
|
|
1358
|
+
requireNonEmptyString(query.epicRunId);
|
|
1359
|
+
requireNonEmptyString(query.ticketKey);
|
|
1360
|
+
const params = { repo_name: access.repoName };
|
|
1361
|
+
if (query.requestId)
|
|
1362
|
+
params.request_id = query.requestId;
|
|
1363
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${continuationChildPath(query.epicRunId, query.ticketKey)}/continuation`, params);
|
|
1364
|
+
const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1365
|
+
return parseContinuationStatusResult(parsed);
|
|
1366
|
+
}
|
|
1254
1367
|
/**
|
|
1255
1368
|
* POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/adopt-current-head-and-unpark`
|
|
1256
1369
|
* — the recovery lane for a human/external push that drifted the PR head off the
|
|
@@ -1441,8 +1554,13 @@ const REPAIR_TOKEN_RE = /^[a-z0-9_.:-]{1,120}$/;
|
|
|
1441
1554
|
const POLICY_DIGEST_TOKEN_RE = /^policy:[0-9a-f]{12}$/;
|
|
1442
1555
|
/** A dotted policy-path NAME. Never a value. */
|
|
1443
1556
|
const POLICY_PATH_RE = /^[A-Za-z0-9_-]{1,100}(?:\.[A-Za-z0-9_-]{1,100}){0,15}$/;
|
|
1444
|
-
/**
|
|
1445
|
-
|
|
1557
|
+
/**
|
|
1558
|
+
* `resume-run`'s closed PRIOR-state vocabulary. `blocked` is the qualifying
|
|
1559
|
+
* integration block the run-addressed route may lift; it is never a resulting state.
|
|
1560
|
+
*/
|
|
1561
|
+
const RESUME_RUN_BEFORE_STATES = ["blocked", "paused", "quarantined", "none"];
|
|
1562
|
+
/** `resume-run`'s closed RESULTING-state vocabulary: a resume always leaves no control. */
|
|
1563
|
+
const RESUME_RUN_AFTER_STATES = ["none"];
|
|
1446
1564
|
function requireOnlyRepairKeys(p, extraKeys) {
|
|
1447
1565
|
for (const key of Object.keys(p)) {
|
|
1448
1566
|
if (!REPAIR_BASE_KEYS.includes(key) && !extraKeys.includes(key)) {
|
|
@@ -1509,18 +1627,29 @@ function parseResumeRunResult(parsed) {
|
|
|
1509
1627
|
requireOnlyRepairKeys(p, []);
|
|
1510
1628
|
const before = p["before"];
|
|
1511
1629
|
const after = p["after"];
|
|
1630
|
+
const committed = p["committed"];
|
|
1631
|
+
const eventRecorded = p["event_recorded"];
|
|
1512
1632
|
if (typeof before !== "string" ||
|
|
1513
|
-
!
|
|
1633
|
+
!RESUME_RUN_BEFORE_STATES.includes(before) ||
|
|
1514
1634
|
typeof after !== "string" ||
|
|
1515
|
-
!
|
|
1635
|
+
!RESUME_RUN_AFTER_STATES.includes(after)) {
|
|
1636
|
+
throw new ConductorBridgeApiError("server");
|
|
1637
|
+
}
|
|
1638
|
+
// Exactly two families: an applied resume (`blocked`/`paused`/`quarantined` →
|
|
1639
|
+
// `none`, committed, either audit outcome) or the already-current no-op
|
|
1640
|
+
// (`none` → `none`, nothing committed, no event). Any other combination is
|
|
1641
|
+
// unconfirmed, never coerced into a result.
|
|
1642
|
+
const applied = before !== "none" && committed;
|
|
1643
|
+
const noOp = before === "none" && !committed && !eventRecorded;
|
|
1644
|
+
if (!applied && !noOp) {
|
|
1516
1645
|
throw new ConductorBridgeApiError("server");
|
|
1517
1646
|
}
|
|
1518
1647
|
return {
|
|
1519
1648
|
epic_run_id: p["epic_run_id"],
|
|
1520
1649
|
before,
|
|
1521
1650
|
after,
|
|
1522
|
-
committed
|
|
1523
|
-
event_recorded:
|
|
1651
|
+
committed,
|
|
1652
|
+
event_recorded: eventRecorded,
|
|
1524
1653
|
};
|
|
1525
1654
|
}
|
|
1526
1655
|
/**
|
|
@@ -2333,6 +2462,20 @@ function requireNullableString(o, key) {
|
|
|
2333
2462
|
throw readinessShapeError();
|
|
2334
2463
|
return v;
|
|
2335
2464
|
}
|
|
2465
|
+
/**
|
|
2466
|
+
* BAPI-1279 — the executor coverage fields. Each is omitted when the server did
|
|
2467
|
+
* not send it (an older deploy), and a present non-boolean fails CLOSED to
|
|
2468
|
+
* `false`: coverage that cannot be read is coverage that is not confirmed.
|
|
2469
|
+
*/
|
|
2470
|
+
function parseOptionalExecutorCoverage(o) {
|
|
2471
|
+
const out = {};
|
|
2472
|
+
for (const key of ["process_heartbeat_fresh", "repo_wide_executor_live"]) {
|
|
2473
|
+
if (!(key in o))
|
|
2474
|
+
continue;
|
|
2475
|
+
out[key] = o[key] === true;
|
|
2476
|
+
}
|
|
2477
|
+
return out;
|
|
2478
|
+
}
|
|
2336
2479
|
function requireNullableBool(o, key) {
|
|
2337
2480
|
const v = o[key];
|
|
2338
2481
|
if (v === null || v === undefined)
|
|
@@ -2519,6 +2662,7 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2519
2662
|
last_seen_at: requireNullableString(exec, "last_seen_at"),
|
|
2520
2663
|
last_seen_age_seconds: requireNullableInt(exec, "last_seen_age_seconds"),
|
|
2521
2664
|
ready: requireNullableBool(exec, "ready"),
|
|
2665
|
+
...parseOptionalExecutorCoverage(exec),
|
|
2522
2666
|
},
|
|
2523
2667
|
thresholds: {
|
|
2524
2668
|
reconciler_stale_after_seconds: requireInt(thr, "reconciler_stale_after_seconds"),
|
|
@@ -2704,8 +2848,9 @@ function parseConductorCiWorkflow(body) {
|
|
|
2704
2848
|
*
|
|
2705
2849
|
* `notify_webhook_default_verified` is the one tri-state: absent is coerced to
|
|
2706
2850
|
* `null` rather than to `false`, because "not verified here" and "verified as
|
|
2707
|
-
* broken" are different statements and only one of them is evidence.
|
|
2708
|
-
*
|
|
2851
|
+
* broken" are different statements and only one of them is evidence.
|
|
2852
|
+
* `escalation_email_available` (BAPI-1274) is lenient: anything but `true` is
|
|
2853
|
+
* `false`. Every other field is a strict boolean and a wrong type throws.
|
|
2709
2854
|
*/
|
|
2710
2855
|
function parseUnattended(body) {
|
|
2711
2856
|
const raw = body.unattended;
|
|
@@ -2719,6 +2864,10 @@ function parseUnattended(body) {
|
|
|
2719
2864
|
? null
|
|
2720
2865
|
: requireNullableBool(o, "notify_webhook_default_verified"),
|
|
2721
2866
|
repository_readiness_confirmed: requireBool(o, "repository_readiness_confirmed"),
|
|
2867
|
+
// BAPI-1274 — optional and lenient: absent, null, or any non-boolean
|
|
2868
|
+
// normalizes to `false` rather than throwing, because a server older than
|
|
2869
|
+
// BAPI-1274 omits it and must keep today's behavior.
|
|
2870
|
+
escalation_email_available: o.escalation_email_available === true,
|
|
2722
2871
|
};
|
|
2723
2872
|
}
|
|
2724
2873
|
/**
|
|
@@ -2735,6 +2884,15 @@ export async function fetchConductorReadiness(access, fetchImpl = globalThis.fet
|
|
|
2735
2884
|
const body = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
2736
2885
|
return parseConductorReadinessResponse(body);
|
|
2737
2886
|
}
|
|
2887
|
+
const ROW_OUTCOMES = new Set(["created", "changed", "preserved"]);
|
|
2888
|
+
function optionalRowOutcome(o, key) {
|
|
2889
|
+
const v = o[key];
|
|
2890
|
+
if (v === undefined || v === null)
|
|
2891
|
+
return null;
|
|
2892
|
+
if (typeof v !== "string" || !ROW_OUTCOMES.has(v))
|
|
2893
|
+
throw readinessShapeError();
|
|
2894
|
+
return v;
|
|
2895
|
+
}
|
|
2738
2896
|
/** Validate a raw bootstrap body, fail-closed (see {@link parseConductorReadinessResponse}). */
|
|
2739
2897
|
export function parseConductorSupervisorBootstrapResponse(body) {
|
|
2740
2898
|
const o = requireObject(body);
|
|
@@ -2749,6 +2907,8 @@ export function parseConductorSupervisorBootstrapResponse(body) {
|
|
|
2749
2907
|
repo_name: repoName,
|
|
2750
2908
|
setup_written: requireBool(o, "setup_written"),
|
|
2751
2909
|
config_written: requireBool(o, "config_written"),
|
|
2910
|
+
setup_outcome: optionalRowOutcome(o, "setup_outcome"),
|
|
2911
|
+
config_outcome: optionalRowOutcome(o, "config_outcome"),
|
|
2752
2912
|
setup_source: requireEnum(o, "setup_source", READINESS_SOURCES),
|
|
2753
2913
|
config_source: requireEnum(o, "config_source", READINESS_SOURCES),
|
|
2754
2914
|
setup_created_at: requireNullableString(o, "setup_created_at"),
|
|
@@ -2762,14 +2922,167 @@ export function parseConductorSupervisorBootstrapResponse(body) {
|
|
|
2762
2922
|
/**
|
|
2763
2923
|
* POST `/jira/epic-runs/supervisor-bootstrap?repo_name=<repo>`.
|
|
2764
2924
|
*
|
|
2765
|
-
*
|
|
2766
|
-
*
|
|
2767
|
-
*
|
|
2925
|
+
* Since BAPI-694 it may also carry the optional repository-default
|
|
2926
|
+
* `review_policy`; omitting that field keeps the stored default. Since BAPI-1288
|
|
2927
|
+
* a relaxation is refused with 409 unless `approval` carries the exact token a
|
|
2928
|
+
* {@link previewConductorSupervisorBootstrap} of the SAME request returned.
|
|
2929
|
+
* A 409 is never retried here — see {@link classifySupervisorBootstrapFailure}.
|
|
2768
2930
|
*/
|
|
2769
2931
|
export async function bootstrapConductorSupervisorDefaults(access, request, fetchImpl) {
|
|
2770
2932
|
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/supervisor-bootstrap`, {
|
|
2771
2933
|
repo_name: access.repoName,
|
|
2772
2934
|
});
|
|
2773
|
-
const body = await
|
|
2935
|
+
const body = await fetchConductorJsonWithMethodAndTimeout("POST", url, conductorPostHeaders(access), JSON.stringify(request), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl, { readErrorField: true });
|
|
2774
2936
|
return parseConductorSupervisorBootstrapResponse(body);
|
|
2775
2937
|
}
|
|
2938
|
+
// ---------------------------------------------------------------------------
|
|
2939
|
+
// BAPI-1288 — preview of a protected project-default change
|
|
2940
|
+
// ---------------------------------------------------------------------------
|
|
2941
|
+
/** Closed vocabularies of a server-authored gate-change effect. */
|
|
2942
|
+
const GATE_CHANGE_REQUIREMENTS = new Set([
|
|
2943
|
+
"done_gate",
|
|
2944
|
+
"required_ci_checks",
|
|
2945
|
+
"done_gate_review",
|
|
2946
|
+
"review_policy_default",
|
|
2947
|
+
]);
|
|
2948
|
+
const GATE_CHANGE_KINDS = new Set(["removed", "weakened", "replaced"]);
|
|
2949
|
+
const GATE_OBLIGATIONS = new Set([
|
|
2950
|
+
"ci_check_green",
|
|
2951
|
+
"sticky_verdict_approved",
|
|
2952
|
+
"native_review_approved",
|
|
2953
|
+
"approval_minimum",
|
|
2954
|
+
"verdictless_review_parks",
|
|
2955
|
+
]);
|
|
2956
|
+
const CHECK_REF_RE = /^ci-[0-9a-f]{12}$/;
|
|
2957
|
+
/** Bounds the client enforces on server text before it may be displayed. */
|
|
2958
|
+
const MAX_EFFECTS = 16;
|
|
2959
|
+
const MAX_DESCRIPTION_LENGTH = 300;
|
|
2960
|
+
const MAX_CHECK_REFS = 50;
|
|
2961
|
+
const MAX_APPROVAL_TOKEN_LENGTH = 1024;
|
|
2962
|
+
function requireObligationList(o, key) {
|
|
2963
|
+
const v = o[key];
|
|
2964
|
+
if (!Array.isArray(v) || v.length > GATE_OBLIGATIONS.size)
|
|
2965
|
+
throw readinessShapeError();
|
|
2966
|
+
for (const item of v) {
|
|
2967
|
+
if (typeof item !== "string" || !GATE_OBLIGATIONS.has(item))
|
|
2968
|
+
throw readinessShapeError();
|
|
2969
|
+
}
|
|
2970
|
+
return v;
|
|
2971
|
+
}
|
|
2972
|
+
function parseGateChangeEffect(raw) {
|
|
2973
|
+
const o = requireObject(raw);
|
|
2974
|
+
const refs = o.removed_ci_check_refs;
|
|
2975
|
+
if (!Array.isArray(refs) ||
|
|
2976
|
+
refs.length > MAX_CHECK_REFS ||
|
|
2977
|
+
refs.some((r) => typeof r !== "string" || !CHECK_REF_RE.test(r))) {
|
|
2978
|
+
throw readinessShapeError();
|
|
2979
|
+
}
|
|
2980
|
+
const description = o.description;
|
|
2981
|
+
if (typeof description !== "string" ||
|
|
2982
|
+
description.length === 0 ||
|
|
2983
|
+
description.length > MAX_DESCRIPTION_LENGTH ||
|
|
2984
|
+
/[\u0000-\u001F\u007F]/.test(description)) {
|
|
2985
|
+
throw readinessShapeError();
|
|
2986
|
+
}
|
|
2987
|
+
const count = requireInt(o, "removed_ci_check_count");
|
|
2988
|
+
if (count < 0)
|
|
2989
|
+
throw readinessShapeError();
|
|
2990
|
+
return {
|
|
2991
|
+
row: requireEnum(o, "row", new Set(["setup", "config"])),
|
|
2992
|
+
requirement: requireEnum(o, "requirement", GATE_CHANGE_REQUIREMENTS),
|
|
2993
|
+
change: requireEnum(o, "change", GATE_CHANGE_KINDS),
|
|
2994
|
+
removed_obligations: requireObligationList(o, "removed_obligations"),
|
|
2995
|
+
replacement_obligations: requireObligationList(o, "replacement_obligations"),
|
|
2996
|
+
removed_ci_check_count: count,
|
|
2997
|
+
removed_ci_check_refs: refs,
|
|
2998
|
+
stored_min_approvals: requireNullableInt(o, "stored_min_approvals"),
|
|
2999
|
+
proposed_min_approvals: requireNullableInt(o, "proposed_min_approvals"),
|
|
3000
|
+
description,
|
|
3001
|
+
};
|
|
3002
|
+
}
|
|
3003
|
+
/**
|
|
3004
|
+
* Validate a raw preview body, fail-closed.
|
|
3005
|
+
*
|
|
3006
|
+
* Every field is checked against the server's closed contract; anything else —
|
|
3007
|
+
* an unknown outcome, an effect category this client does not know, an
|
|
3008
|
+
* over-long or control-character description, a token without
|
|
3009
|
+
* `approval_required`, a writer or repository that does not match the request —
|
|
3010
|
+
* is a shape error, so an unbounded server string can never reach the operator's
|
|
3011
|
+
* terminal as if it were a vetted description.
|
|
3012
|
+
*/
|
|
3013
|
+
export function parseConductorSupervisorBootstrapPreview(body, expectedRepoName) {
|
|
3014
|
+
const o = requireObject(body);
|
|
3015
|
+
if (o.repo_name !== expectedRepoName)
|
|
3016
|
+
throw readinessShapeError();
|
|
3017
|
+
if (o.writer !== "bootstrap")
|
|
3018
|
+
throw readinessShapeError();
|
|
3019
|
+
const names = o.changed_field_names;
|
|
3020
|
+
if (!Array.isArray(names) || names.some((n) => typeof n !== "string" || n.length > 100)) {
|
|
3021
|
+
throw readinessShapeError();
|
|
3022
|
+
}
|
|
3023
|
+
const effectsRaw = o.effects;
|
|
3024
|
+
if (!Array.isArray(effectsRaw) || effectsRaw.length > MAX_EFFECTS)
|
|
3025
|
+
throw readinessShapeError();
|
|
3026
|
+
const effects = effectsRaw.map(parseGateChangeEffect);
|
|
3027
|
+
const approvalRequired = requireBool(o, "approval_required");
|
|
3028
|
+
const token = requireNullableString(o, "approval_token");
|
|
3029
|
+
if (approvalRequired !== effects.length > 0)
|
|
3030
|
+
throw readinessShapeError();
|
|
3031
|
+
if (approvalRequired !== (token !== null))
|
|
3032
|
+
throw readinessShapeError();
|
|
3033
|
+
if (token !== null && (token.length === 0 || token.length > MAX_APPROVAL_TOKEN_LENGTH)) {
|
|
3034
|
+
throw readinessShapeError();
|
|
3035
|
+
}
|
|
3036
|
+
return {
|
|
3037
|
+
repo_name: expectedRepoName,
|
|
3038
|
+
writer: "bootstrap",
|
|
3039
|
+
setup_outcome: requireEnum(o, "setup_outcome", ROW_OUTCOMES),
|
|
3040
|
+
config_outcome: requireEnum(o, "config_outcome", ROW_OUTCOMES),
|
|
3041
|
+
changed_field_names: names,
|
|
3042
|
+
approval_required: approvalRequired,
|
|
3043
|
+
effects,
|
|
3044
|
+
approval_token: token,
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
3047
|
+
/**
|
|
3048
|
+
* POST `/jira/epic-runs/supervisor-bootstrap/preview?repo_name=<repo>` — what a
|
|
3049
|
+
* bootstrap with `request` WOULD do. Read-only on the server.
|
|
3050
|
+
*
|
|
3051
|
+
* Send the same body to {@link bootstrapConductorSupervisorDefaults} afterwards
|
|
3052
|
+
* (plus `approval` when the preview required it); any difference makes the
|
|
3053
|
+
* server's token check fail with a fresh-preview conflict.
|
|
3054
|
+
*/
|
|
3055
|
+
export async function previewConductorSupervisorBootstrap(access, request, fetchImpl) {
|
|
3056
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/supervisor-bootstrap/preview`, { repo_name: access.repoName });
|
|
3057
|
+
const body = await fetchConductorJsonWithMethodAndTimeout("POST", url, conductorPostHeaders(access), JSON.stringify(request), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl, { readErrorField: true });
|
|
3058
|
+
return parseConductorSupervisorBootstrapPreview(body, access.repoName);
|
|
3059
|
+
}
|
|
3060
|
+
const GATE_CHANGE_CODE_TO_KIND = Object.freeze({
|
|
3061
|
+
APPROVAL_REQUIRED: "approval-required",
|
|
3062
|
+
STALE_PREVIEW: "stale-preview",
|
|
3063
|
+
UNKNOWN_STORED_STATE: "unknown-stored-state",
|
|
3064
|
+
NON_RELAXABLE: "non-relaxable",
|
|
3065
|
+
INVALID_PROPOSAL: "invalid-proposal",
|
|
3066
|
+
STATE_UNAVAILABLE: "state-unavailable",
|
|
3067
|
+
});
|
|
3068
|
+
export function classifySupervisorBootstrapFailure(err) {
|
|
3069
|
+
if (!(err instanceof ConductorBridgeApiError))
|
|
3070
|
+
return "network";
|
|
3071
|
+
const mapped = err.errorCode ? GATE_CHANGE_CODE_TO_KIND[err.errorCode] : undefined;
|
|
3072
|
+
if (mapped)
|
|
3073
|
+
return mapped;
|
|
3074
|
+
if (err.kind === "invalid-input")
|
|
3075
|
+
return "unsupported-protocol";
|
|
3076
|
+
if (err.kind === "unauthorized")
|
|
3077
|
+
return "unauthorized";
|
|
3078
|
+
if (err.kind === "timeout")
|
|
3079
|
+
return "timeout";
|
|
3080
|
+
if (err.kind === "network")
|
|
3081
|
+
return "network";
|
|
3082
|
+
if (err.status === 404 || err.status === 405 || err.status === 422) {
|
|
3083
|
+
return "unsupported-protocol";
|
|
3084
|
+
}
|
|
3085
|
+
if (err.kind === "server")
|
|
3086
|
+
return "server";
|
|
3087
|
+
return "rejected";
|
|
3088
|
+
}
|
|
@@ -35,7 +35,9 @@ export const WORKER_NOT_PRESENT_REMEDIATION = "start a worker (`python worker.py
|
|
|
35
35
|
export const SECOND_RECONCILER_REMEDIATION = "find and stop the extra reconciler: check `ps aux | grep worker.py` for a reparented " +
|
|
36
36
|
`survivor of an earlier plane (${BRING_UP_RUNBOOK} §6), and note that a dev server started ` +
|
|
37
37
|
"without DISABLE_SCHEDULER=true runs a reconciler too. A reconciler stopped in the last 120 " +
|
|
38
|
-
"seconds still counts — heartbeat rows are not deleted on shutdown — so wait and retry."
|
|
38
|
+
"seconds still counts — heartbeat rows are not deleted on shutdown — so wait and retry. " +
|
|
39
|
+
"On a hosted deployment, check that exactly one worker process is scaled (for example one " +
|
|
40
|
+
"Heroku `worker` dyno): each worker runs its own reconciler.";
|
|
39
41
|
// ---------------------------------------------------------------------------
|
|
40
42
|
// Classification
|
|
41
43
|
// ---------------------------------------------------------------------------
|
|
@@ -82,8 +82,9 @@ export const MCP_PROFILE_UNSET = "(unset)";
|
|
|
82
82
|
/**
|
|
83
83
|
* The CLI-only verbs that call the Bridge API from a shell-spawned process and
|
|
84
84
|
* therefore resolve credentials themselves (CLAUDE.md "Shell-Spawned Credential
|
|
85
|
-
* Resolution"): the four BAPI-872 recovery verbs
|
|
86
|
-
* verbs (BAPI-1154's three and BAPI-1176's three)
|
|
85
|
+
* Resolution"): the four BAPI-872 recovery verbs, the six run-addressed repair
|
|
86
|
+
* verbs (BAPI-1154's three and BAPI-1176's three), and BAPI-1300's parked-child
|
|
87
|
+
* continuation pair. `.mcp.json` env is invisible to
|
|
87
88
|
* them — only `BAPI_API_KEY` in the parent env and the user-scoped credential
|
|
88
89
|
* store count.
|
|
89
90
|
*/
|
|
@@ -98,6 +99,9 @@ export const CREDENTIALED_RECOVERY_COMMANDS = [
|
|
|
98
99
|
"scope-catch-up",
|
|
99
100
|
"patch-policy",
|
|
100
101
|
"resume-run",
|
|
102
|
+
// BAPI-1300: parked-child continuation and its read-only receipt lookup.
|
|
103
|
+
"continue-child",
|
|
104
|
+
"continuation-receipt",
|
|
101
105
|
];
|
|
102
106
|
/** Remediation for recovery/repair verbs that cannot resolve Bridge credentials. */
|
|
103
107
|
export const REPAIR_CREDENTIAL_REMEDIATION = "export BAPI_API_KEY in the shell that runs `conductor`, or store a credential for this " +
|
|
@@ -803,11 +807,14 @@ export function formatConductorDoctorReport(report) {
|
|
|
803
807
|
lines.push(`hooks dir: ${git_hooks.hooks_dir ?? "n/a"}`);
|
|
804
808
|
lines.push(`degraded: ${git_hooks.degraded}`);
|
|
805
809
|
for (const hook of git_hooks.hooks) {
|
|
806
|
-
// BAPI-772
|
|
807
|
-
//
|
|
808
|
-
//
|
|
810
|
+
// BAPI-772 made a dead embedded target visible here; BAPI-1290 replaces the
|
|
811
|
+
// raw path with the shared per-hook verdict, because a file that EXISTS can
|
|
812
|
+
// still be a prunable cache entry or a runtime whose native binding does not
|
|
813
|
+
// load. The embedded path stays in `--json` output only. Advisory only:
|
|
814
|
+
// never changes the exit code. Read defensively — a literal fixture may
|
|
815
|
+
// predate `state`/`usable`, and unknown is never healthy.
|
|
809
816
|
lines.push(` ${hook.name}: exists=${hook.exists} executable=${hook.executable} managed=${hook.managed_block_present} ` +
|
|
810
|
-
`
|
|
817
|
+
`state=${hook.state ?? "unknown"} usable=${hook.usable === true}`);
|
|
811
818
|
}
|
|
812
819
|
if (git_hooks.warnings.length > 0) {
|
|
813
820
|
lines.push("git hook warnings:");
|
|
@@ -962,8 +969,8 @@ export const CONDUCTOR_READINESS_DESCRIPTORS = [
|
|
|
962
969
|
export const CONDUCTOR_LEDGER_REMEDIATION = "run `epic-implementer doctor` for the ledger detail, then re-create the local ledger directory with " +
|
|
963
970
|
"owner-only permissions; the ledger is local observability and never blocks a run.";
|
|
964
971
|
/** Remediation for degraded or missing managed git hooks. */
|
|
965
|
-
export const CONDUCTOR_GIT_HOOKS_REMEDIATION = "run `epic-implementer install-git-hooks` to install the managed post-commit and
|
|
966
|
-
"hooks; they are local, opportunistic, and bypassable.";
|
|
972
|
+
export const CONDUCTOR_GIT_HOOKS_REMEDIATION = "run `epic-implementer install-git-hooks` from a persistent install to install the managed post-commit and " +
|
|
973
|
+
"reference-transaction hooks; they are local, opportunistic, and bypassable, and never gate engine admission.";
|
|
967
974
|
/** Remediation for a Claude login this host cannot confirm. */
|
|
968
975
|
export const CONDUCTOR_CLAUDE_LOGIN_REMEDIATION = "run `claude login` on the host that will run conductor workers; Bridge stores no Anthropic " +
|
|
969
976
|
"credential and can only advise here.";
|
|
@@ -1014,22 +1021,24 @@ function gitHooksOutcome(report) {
|
|
|
1014
1021
|
};
|
|
1015
1022
|
}
|
|
1016
1023
|
// Hook NAMES and booleans only — `hooks_dir` is absolute and never projected.
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1024
|
+
// BAPI-1290: the boolean is the shared per-hook `usable` verdict (qualified
|
|
1025
|
+
// persistent runtime, loadable native binding), not mere file presence; a
|
|
1026
|
+
// hook without that verdict is unknown, and unknown is never healthy.
|
|
1027
|
+
const rendered = hooks.hooks.map((hook) => `${hook.name}=${hook.usable === true}`).join(", ");
|
|
1028
|
+
const allUsable = hooks.hooks.length > 0 && hooks.hooks.every((hook) => hook.usable === true);
|
|
1029
|
+
return hooks.degraded || !allUsable
|
|
1021
1030
|
? {
|
|
1022
1031
|
id: "git-hooks",
|
|
1023
1032
|
label: "Conductor git hooks",
|
|
1024
1033
|
status: "warn",
|
|
1025
|
-
detail: `managed hooks
|
|
1034
|
+
detail: `managed hooks usable: ${rendered}`,
|
|
1026
1035
|
remediation: CONDUCTOR_GIT_HOOKS_REMEDIATION,
|
|
1027
1036
|
}
|
|
1028
1037
|
: {
|
|
1029
1038
|
id: "git-hooks",
|
|
1030
1039
|
label: "Conductor git hooks",
|
|
1031
1040
|
status: "pass",
|
|
1032
|
-
detail: `managed hooks
|
|
1041
|
+
detail: `managed hooks usable: ${rendered}`,
|
|
1033
1042
|
};
|
|
1034
1043
|
}
|
|
1035
1044
|
/**
|