@bridge_gpt/mcp-server 0.2.62 → 0.2.66

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.
Files changed (113) hide show
  1. package/README.md +66 -15
  2. package/build/agent-launchers/claude-executor-adapter.js +145 -20
  3. package/build/agent-launchers/executor-adapter.js +81 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/bridge-config.js +104 -0
  7. package/build/codex-bridge-profile.js +10 -1
  8. package/build/commands.generated.js +7 -6
  9. package/build/conductor/bridge-api-client.js +565 -32
  10. package/build/conductor/bring-up-facts.js +3 -1
  11. package/build/conductor/doctor.js +23 -14
  12. package/build/conductor/epic-child-continuation.js +351 -0
  13. package/build/conductor/epic-implementer-cli.js +154 -17
  14. package/build/conductor/git-hooks.js +529 -118
  15. package/build/conductor/guided-repair.js +323 -0
  16. package/build/conductor/hook-runtime-qualification.js +340 -0
  17. package/build/conductor/readiness-cli.js +30 -9
  18. package/build/conductor/readiness-sections.js +5 -4
  19. package/build/conductor/readiness.js +45 -12
  20. package/build/conductor/recovery-cli.js +859 -27
  21. package/build/conductor/recovery-operations.js +574 -5
  22. package/build/conductor/repair-contract.js +69 -3
  23. package/build/conductor-bundle-artifacts.js +1 -0
  24. package/build/credential-store.js +52 -13
  25. package/build/diagnostic-text.js +36 -0
  26. package/build/docs.generated.js +3 -2
  27. package/build/doctor.js +147 -21
  28. package/build/drive-epic-key.js +12 -0
  29. package/build/drive-epic.js +1537 -125
  30. package/build/epic-coherence-input-cli.js +137 -0
  31. package/build/epic-coherence-input.js +920 -0
  32. package/build/epic-coherence-input.test-fixtures.js +175 -0
  33. package/build/epic-implementer-bin.js +41 -33
  34. package/build/epic-implementer-bundle-cli.js +14 -1
  35. package/build/epic-launch-diagnostic.js +31 -0
  36. package/build/epic-launch-profile.js +58 -19
  37. package/build/epic-run-status.js +5 -0
  38. package/build/epic-shared/bridge-client.js +46 -4
  39. package/build/executor/cli.js +39 -0
  40. package/build/executor/completion-contract.js +423 -0
  41. package/build/executor/completion-supervision.js +291 -0
  42. package/build/executor/correction-dispatch.js +60 -0
  43. package/build/executor/credentials.js +3 -1
  44. package/build/executor/deps.js +65 -1
  45. package/build/executor/env.js +12 -0
  46. package/build/executor/heartbeat.js +13 -3
  47. package/build/executor/http-client.js +91 -4
  48. package/build/executor/integration-job.js +6 -1
  49. package/build/executor/job-errors.js +61 -4
  50. package/build/executor/job-log-registry.js +340 -73
  51. package/build/executor/job-runner.js +1550 -55
  52. package/build/executor/job-types.js +9 -6
  53. package/build/executor/preflight.js +189 -1
  54. package/build/executor/process-containment.js +756 -0
  55. package/build/executor/process.js +235 -5
  56. package/build/executor/prompt-spec.js +366 -1
  57. package/build/executor/provider-checks.js +69 -0
  58. package/build/executor/rebase-finalization.js +351 -0
  59. package/build/executor/reconcile-overlap-job.js +77 -84
  60. package/build/executor/required-verification.js +313 -0
  61. package/build/executor/required-work-ledger.js +1121 -0
  62. package/build/executor/results.js +142 -0
  63. package/build/executor/resume-pre-spawn.js +87 -4
  64. package/build/executor/runner.js +168 -0
  65. package/build/executor/stale-artifacts.js +5 -4
  66. package/build/executor/test-declaration.js +20 -0
  67. package/build/executor/types.js +13 -0
  68. package/build/executor/watch-cli.js +35 -8
  69. package/build/executor/worker-command.js +70 -34
  70. package/build/executor/worker-finalization.js +168 -26
  71. package/build/executor/worker-guard-hook.js +75 -14
  72. package/build/executor/worker-log.js +137 -70
  73. package/build/executor/worktree-gc.js +30 -6
  74. package/build/executor/worktree.js +13 -0
  75. package/build/executor-launch.js +658 -0
  76. package/build/index.js +594 -1152
  77. package/build/init.js +107 -4
  78. package/build/install-bridge-conductor.js +231 -41
  79. package/build/install-bridge.js +284 -20
  80. package/build/install-doctor.js +172 -30
  81. package/build/install-reexec.js +69 -7
  82. package/build/macos-terminal-permission-guidance.js +16 -0
  83. package/build/merge-pull-request.js +1 -1
  84. package/build/model-thresholds-tool.js +185 -0
  85. package/build/pipelines.generated.js +9 -8
  86. package/build/plan-discovery.js +137 -0
  87. package/build/plan-epic-implementer-eligibility.js +2 -2
  88. package/build/plane/cli.js +37 -3
  89. package/build/plane/member-logs.js +3 -0
  90. package/build/plane/member-roster.js +7 -1
  91. package/build/plane/shutdown.js +59 -1
  92. package/build/plane/status.js +4 -0
  93. package/build/plane/supervisor.js +146 -0
  94. package/build/plane/worker-exit-code.js +2 -0
  95. package/build/project-root.js +99 -0
  96. package/build/readme.generated.js +1 -1
  97. package/build/review-tickets.js +156 -13
  98. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  99. package/build/setup-epic.js +208 -125
  100. package/build/sfcc/setup-status.js +2 -0
  101. package/build/sfcc/tool-wrapper.js +2 -0
  102. package/build/sfcc/tools-configured.js +96 -0
  103. package/build/start-tickets.js +163 -58
  104. package/build/upgrade-cli.js +109 -24
  105. package/build/version.generated.js +3 -3
  106. package/build/windows-npm-entry.js +103 -0
  107. package/build/worker-guard-hook-bin.js +5 -4
  108. package/build/worktree-core.js +6 -5
  109. package/docs/CONDUCTOR.md +116 -2
  110. package/docs/install/mcp-tool-integrations.md +1 -0
  111. package/docs/install/sfcc-integration.md +12 -7
  112. package/package.json +2 -1
  113. package/pipelines/implement-ticket.json +1 -1
@@ -16,11 +16,33 @@ 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";
21
+ import { safeEpicLaunchTargetUrl } from "../epic-launch-diagnostic.js";
22
+ import { validateHttpBaseUrl } from "../base-url.js";
23
+ import { isCiResumeRefusalCode } from "./repair-contract.js";
20
24
  /** Default Bridge API base URL when `BAPI_BASE_URL` is unset. */
21
25
  export const CONDUCTOR_DEFAULT_BASE_URL = "https://bridgegpt-api.com";
22
26
  /** Default per-request timeout for conductor Bridge API calls. */
23
27
  export const CONDUCTOR_FETCH_TIMEOUT_MS = 30_000;
28
+ /** Keep an explicit readiness target from borrowing a key bound to another ambient endpoint. */
29
+ export function readinessEndpointEnvironments(env, baseUrl) {
30
+ if (baseUrl === undefined)
31
+ return { reportEnv: env, resolverEnv: env };
32
+ const ambientBase = env.BAPI_BASE_URL ? validateHttpBaseUrl(env.BAPI_BASE_URL) : null;
33
+ const ambientMatchesOverride = ambientBase?.ok && ambientBase.baseUrl === baseUrl;
34
+ return {
35
+ reportEnv: { ...env, BAPI_BASE_URL: baseUrl, BAPI_EPIC_LAUNCH_PROFILE: undefined,
36
+ BAPI_API_KEY: ambientMatchesOverride ? env.BAPI_API_KEY : undefined },
37
+ resolverEnv: {
38
+ ...env,
39
+ BAPI_EPIC_LAUNCH_PROFILE: undefined,
40
+ // Never manufacture an ambient key's endpoint pairing. A stored key
41
+ // still passes its own binding check against the explicit target.
42
+ BAPI_BASE_URL: ambientMatchesOverride ? baseUrl : undefined,
43
+ },
44
+ };
45
+ }
24
46
  /**
25
47
  * Resolve `{ repoName, apiKey, baseUrl }` from the environment, the shared repo
26
48
  * resolver, and the shared credential store. Never throws and never embeds the
@@ -70,12 +92,15 @@ export async function resolveConductorBridgeApiAccess(deps = {}) {
70
92
  *
71
93
  * 1. the selected profile is read and validated (host-local, read-only);
72
94
  * 2. the TARGET is decided from `--base-url`, the profile, and `BAPI_BASE_URL`
73
- * — conflicts refuse, and an absent target is refused, never defaulted to
74
- * {@link CONDUCTOR_DEFAULT_BASE_URL};
95
+ * — conflicts refuse. With none of them set, the target is
96
+ * {@link CONDUCTOR_DEFAULT_BASE_URL} (BAPI-1272);
75
97
  * 3. the repository is decided (profile binding, then `--repo`, then the
76
98
  * shared resolver);
77
99
  * 4. only then is a credential looked up, coupled to that target AND that
78
- * repository ({@link resolveEpicLaunchCredential}).
100
+ * repository ({@link resolveEpicLaunchCredential}). A stored key with no
101
+ * binding is accepted only when the target is production; a key bound to
102
+ * another endpoint, or an ambient key without a matching `BAPI_BASE_URL`,
103
+ * is still refused.
79
104
  *
80
105
  * Nothing is sent anywhere by this function. {@link resolveConductorBridgeApiAccess}
81
106
  * is deliberately left as it is for every other client.
@@ -87,9 +112,11 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
87
112
  const platform = deps.platform ?? process.platform;
88
113
  const readFileImpl = deps.readFile ?? ((p) => readFile(p, "utf-8"));
89
114
  const statImpl = deps.stat ?? ((p) => stat(p));
115
+ const environmentProfile = env.BAPI_EPIC_LAUNCH_PROFILE?.trim();
116
+ const profileName = selection.profileName ?? (environmentProfile || undefined);
90
117
  let profile = null;
91
- if (selection.profileName !== undefined) {
92
- const read = await readEpicLaunchProfile(selection.profileName, {
118
+ if (profileName !== undefined) {
119
+ const read = await readEpicLaunchProfile(profileName, {
93
120
  env,
94
121
  homedir,
95
122
  readFile: readFileImpl,
@@ -103,10 +130,13 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
103
130
  flagBaseUrl: selection.baseUrl,
104
131
  flagRepo: deps.repoName,
105
132
  profile,
133
+ profileSource: selection.profileName !== undefined ? "profile" : profile ? "profile-env" : undefined,
106
134
  env,
135
+ defaultBaseUrl: CONDUCTOR_DEFAULT_BASE_URL,
107
136
  });
108
137
  if (!target.ok)
109
138
  return { ok: false, kind: target.kind, error: target.message };
139
+ const reportableTarget = safeEpicLaunchTargetUrl(target.baseUrl) ? { baseUrl: target.baseUrl } : {};
110
140
  const repoName = target.repoName ||
111
141
  deps.repoName?.trim() ||
112
142
  (await resolveStartTicketsRepoName({ env, cwd, readFile: readFileImpl }));
@@ -115,28 +145,23 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
115
145
  ok: false,
116
146
  kind: "repo-missing",
117
147
  error: "could not resolve repo name (set BAPI_REPO_NAME or add a valid .bridge/config)",
148
+ ...reportableTarget,
118
149
  };
119
150
  }
120
151
  let cred;
121
152
  try {
122
- cred = await resolveEpicLaunchCredential(repoName, target.baseUrl, {
123
- env,
124
- homedir,
125
- platform,
126
- readFile: readFileImpl,
127
- stat: statImpl,
128
- });
153
+ cred = await resolveEpicLaunchCredential(repoName, target.baseUrl, { env, homedir, platform, readFile: readFileImpl, stat: statImpl }, { productionBaseUrl: CONDUCTOR_DEFAULT_BASE_URL });
129
154
  }
130
155
  catch {
131
- return { ok: false, kind: "credentials-unavailable", error: "failed to resolve Bridge API credentials" };
156
+ return { ok: false, kind: "credentials-unavailable", error: "failed to resolve Bridge API credentials", ...reportableTarget };
132
157
  }
133
158
  if (!cred.ok) {
134
159
  const bindingFailure = cred.kind === "binding-unbound" || cred.kind === "binding-mismatch" || cred.kind === "ambient-key-unbound";
135
160
  // The binding messages are fixed sentences naming the target KEY and the
136
161
  // store path only; the generic store failures stay generic as before.
137
162
  return bindingFailure
138
- ? { ok: false, kind: "credential-unbound", error: cred.error }
139
- : { ok: false, kind: "credentials-unavailable", error: "Bridge API credentials unavailable" };
163
+ ? { ok: false, kind: "credential-unbound", error: cred.error, ...reportableTarget }
164
+ : { ok: false, kind: "credentials-unavailable", error: "Bridge API credentials unavailable", ...reportableTarget };
140
165
  }
141
166
  return {
142
167
  ok: true,
@@ -750,6 +775,18 @@ export async function remediateEpicTicket(access, request, fetchImpl = globalThi
750
775
  throw err;
751
776
  }
752
777
  }
778
+ /**
779
+ * BAPI-1310 (E1) — the server's refusal codes for the unexplained review-head
780
+ * stop. Each is an HTTP 409 that leaves the ticket parked.
781
+ */
782
+ export const REVIEW_HEAD_REFUSAL_CODES = [
783
+ "REVIEW_HEAD_ADOPTION_REQUIRED",
784
+ "REVIEW_HEAD_EPISODE_CONFLICT",
785
+ "REVIEW_HEAD_STATE_INVALID",
786
+ "REVIEW_HEAD_BINDING_CHANGED",
787
+ "REVIEW_HEAD_LIVE_WORK",
788
+ "REVIEW_HEAD_UNAVAILABLE",
789
+ ];
753
790
  // ---------------------------------------------------------------------------
754
791
  // Local boundary validators (reject before any fetch call)
755
792
  // ---------------------------------------------------------------------------
@@ -1251,6 +1288,108 @@ export async function unparkEpicTicket(access, request, fetchImpl = globalThis.f
1251
1288
  });
1252
1289
  return postUnparkLikeRequest(url, conductorPostHeaders(access), body, fetchImpl);
1253
1290
  }
1291
+ const TYPED_CONTINUATION_STATUSES = new Set([200, 201, 409, 503]);
1292
+ const SAFE_ERROR_CODE_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
1293
+ function continuationChildPath(epicRunId, ticketKey) {
1294
+ return `${epicRunApiPath(epicRunId)}/tickets/${encodeURIComponent(ticketKey)}`;
1295
+ }
1296
+ function safeDetailErrorCode(body) {
1297
+ if (body === null || typeof body !== "object")
1298
+ return null;
1299
+ const detail = body.detail;
1300
+ if (detail === null || typeof detail !== "object")
1301
+ return null;
1302
+ const code = detail.error_code;
1303
+ return typeof code === "string" && SAFE_ERROR_CODE_RE.test(code) ? code : null;
1304
+ }
1305
+ /**
1306
+ * POST `.../tickets/{ticket_key}/continue` exactly once and classify the answer.
1307
+ *
1308
+ * Never throws for an HTTP or transport outcome — every one of them is returned
1309
+ * as a {@link ContinuationPostOutcome}, so no caller can accidentally turn a
1310
+ * timeout into "refused" by catching an exception. Never falls back to unpark.
1311
+ * The API key travels only in the header; the body carries no credential and no
1312
+ * caller-asserted authority.
1313
+ */
1314
+ export async function postContinueParkedChild(access, request, fetchImpl = globalThis.fetch, timeoutMs = CONDUCTOR_FETCH_TIMEOUT_MS) {
1315
+ const b = request.bindings;
1316
+ requireNonEmptyString(b.epicRunId);
1317
+ requireNonEmptyString(b.ticketKey);
1318
+ const url = buildConductorJiraUrl(access.baseUrl, `${continuationChildPath(b.epicRunId, b.ticketKey)}/continue`);
1319
+ const body = JSON.stringify({
1320
+ repo_name: b.repoName,
1321
+ request_id: request.requestId,
1322
+ parked_gate: b.parkedGate,
1323
+ expected_row_version: b.expectedRowVersion,
1324
+ expected_head_sha: b.expectedHeadSha,
1325
+ source_park_generation: b.sourceParkGeneration,
1326
+ request_fingerprint: request.requestFingerprint,
1327
+ ...(request.reason ? { reason: request.reason } : {}),
1328
+ });
1329
+ const controller = new AbortController();
1330
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1331
+ try {
1332
+ let resp;
1333
+ try {
1334
+ resp = await fetchImpl(url, {
1335
+ method: "POST",
1336
+ headers: conductorPostHeaders(access),
1337
+ body,
1338
+ signal: controller.signal,
1339
+ });
1340
+ }
1341
+ catch {
1342
+ return { kind: "uncertain", reason: controller.signal.aborted ? "timeout" : "network" };
1343
+ }
1344
+ let parsed;
1345
+ let parsedOk = true;
1346
+ try {
1347
+ parsed = await resp.json();
1348
+ }
1349
+ catch {
1350
+ parsedOk = false;
1351
+ }
1352
+ if (TYPED_CONTINUATION_STATUSES.has(resp.status) && parsedOk) {
1353
+ try {
1354
+ return { kind: "result", status: resp.status, result: parseContinueParkedChildResult(parsed) };
1355
+ }
1356
+ catch {
1357
+ // Fall through: an untyped body on these statuses is classified below.
1358
+ }
1359
+ }
1360
+ if (resp.status >= 200 && resp.status < 300) {
1361
+ return { kind: "uncertain", reason: "malformed", status: resp.status };
1362
+ }
1363
+ if (resp.status >= 500) {
1364
+ return { kind: "uncertain", reason: "server", status: resp.status };
1365
+ }
1366
+ // A 4xx without a typed body is an authoritative pre-mutation rejection.
1367
+ return {
1368
+ kind: "refused_http",
1369
+ status: resp.status,
1370
+ errorCode: parsedOk ? safeDetailErrorCode(parsed) : null,
1371
+ };
1372
+ }
1373
+ finally {
1374
+ clearTimeout(timer);
1375
+ }
1376
+ }
1377
+ /**
1378
+ * GET `.../tickets/{ticket_key}/continuation` — the child's current episode and,
1379
+ * with `requestId`, that request's receipt. Read-only; bounded by the standard
1380
+ * conductor timeout. Throws {@link ConductorBridgeApiError} on transport or HTTP
1381
+ * failure and {@link ContinuationParseError} on a malformed body.
1382
+ */
1383
+ export async function fetchChildContinuationStatus(access, query, fetchImpl = globalThis.fetch) {
1384
+ requireNonEmptyString(query.epicRunId);
1385
+ requireNonEmptyString(query.ticketKey);
1386
+ const params = { repo_name: access.repoName };
1387
+ if (query.requestId)
1388
+ params.request_id = query.requestId;
1389
+ const url = buildConductorJiraUrl(access.baseUrl, `${continuationChildPath(query.epicRunId, query.ticketKey)}/continuation`, params);
1390
+ const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
1391
+ return parseContinuationStatusResult(parsed);
1392
+ }
1254
1393
  /**
1255
1394
  * POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/adopt-current-head-and-unpark`
1256
1395
  * — the recovery lane for a human/external push that drifted the PR head off the
@@ -1365,6 +1504,9 @@ async function postRepairRequest(access, apiPath, body, fetchImpl) {
1365
1504
  // The server's refusal explanation is the operator-facing message here.
1366
1505
  { readErrorField: true });
1367
1506
  }
1507
+ function withGuard(body, guard) {
1508
+ return guard === undefined ? body : { ...body, guard };
1509
+ }
1368
1510
  /**
1369
1511
  * POST `/jira/epic-runs/runs/{epic_run_id}/scope-reparse` — re-drive the run's
1370
1512
  * OWN index scope through the retry-reparse lifecycle. Body: `{repo_name,
@@ -1374,7 +1516,7 @@ async function postRepairRequest(access, apiPath, body, fetchImpl) {
1374
1516
  export async function scopeReparse(access, request, fetchImpl = globalThis.fetch) {
1375
1517
  requireNonEmptyString(request.epicRunId);
1376
1518
  requireNonEmptyString(request.scopeId);
1377
- const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/scope-reparse`, { scope_id: request.scopeId }, fetchImpl);
1519
+ const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/scope-reparse`, withGuard({ scope_id: request.scopeId }, request.guard), fetchImpl);
1378
1520
  return parseScopeReparseResult(parsed);
1379
1521
  }
1380
1522
  /**
@@ -1387,7 +1529,7 @@ export async function dispatchRemediation(access, request, fetchImpl = globalThi
1387
1529
  requireNonEmptyString(request.epicRunId);
1388
1530
  requireNonEmptyString(request.ticketKey);
1389
1531
  requireNonEmptyString(request.context);
1390
- const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/tickets/${encodeURIComponent(request.ticketKey)}/dispatch-remediation`, { context: request.context }, fetchImpl);
1532
+ const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/tickets/${encodeURIComponent(request.ticketKey)}/dispatch-remediation`, withGuard({ context: request.context }, request.guard), fetchImpl);
1391
1533
  return parseDispatchRemediationResult(parsed);
1392
1534
  }
1393
1535
  /**
@@ -1441,8 +1583,13 @@ const REPAIR_TOKEN_RE = /^[a-z0-9_.:-]{1,120}$/;
1441
1583
  const POLICY_DIGEST_TOKEN_RE = /^policy:[0-9a-f]{12}$/;
1442
1584
  /** A dotted policy-path NAME. Never a value. */
1443
1585
  const POLICY_PATH_RE = /^[A-Za-z0-9_-]{1,100}(?:\.[A-Za-z0-9_-]{1,100}){0,15}$/;
1444
- /** `resume-run`'s closed run-control state vocabulary. */
1445
- const RESUME_RUN_STATES = ["paused", "quarantined", "none"];
1586
+ /**
1587
+ * `resume-run`'s closed PRIOR-state vocabulary. `blocked` is the qualifying
1588
+ * integration block the run-addressed route may lift; it is never a resulting state.
1589
+ */
1590
+ const RESUME_RUN_BEFORE_STATES = ["blocked", "paused", "quarantined", "none"];
1591
+ /** `resume-run`'s closed RESULTING-state vocabulary: a resume always leaves no control. */
1592
+ const RESUME_RUN_AFTER_STATES = ["none"];
1446
1593
  function requireOnlyRepairKeys(p, extraKeys) {
1447
1594
  for (const key of Object.keys(p)) {
1448
1595
  if (!REPAIR_BASE_KEYS.includes(key) && !extraKeys.includes(key)) {
@@ -1509,18 +1656,29 @@ function parseResumeRunResult(parsed) {
1509
1656
  requireOnlyRepairKeys(p, []);
1510
1657
  const before = p["before"];
1511
1658
  const after = p["after"];
1659
+ const committed = p["committed"];
1660
+ const eventRecorded = p["event_recorded"];
1512
1661
  if (typeof before !== "string" ||
1513
- !RESUME_RUN_STATES.includes(before) ||
1662
+ !RESUME_RUN_BEFORE_STATES.includes(before) ||
1514
1663
  typeof after !== "string" ||
1515
- !RESUME_RUN_STATES.includes(after)) {
1664
+ !RESUME_RUN_AFTER_STATES.includes(after)) {
1665
+ throw new ConductorBridgeApiError("server");
1666
+ }
1667
+ // Exactly two families: an applied resume (`blocked`/`paused`/`quarantined` →
1668
+ // `none`, committed, either audit outcome) or the already-current no-op
1669
+ // (`none` → `none`, nothing committed, no event). Any other combination is
1670
+ // unconfirmed, never coerced into a result.
1671
+ const applied = before !== "none" && committed;
1672
+ const noOp = before === "none" && !committed && !eventRecorded;
1673
+ if (!applied && !noOp) {
1516
1674
  throw new ConductorBridgeApiError("server");
1517
1675
  }
1518
1676
  return {
1519
1677
  epic_run_id: p["epic_run_id"],
1520
1678
  before,
1521
1679
  after,
1522
- committed: p["committed"],
1523
- event_recorded: p["event_recorded"],
1680
+ committed,
1681
+ event_recorded: eventRecorded,
1524
1682
  };
1525
1683
  }
1526
1684
  /**
@@ -1533,7 +1691,7 @@ function parseResumeRunResult(parsed) {
1533
1691
  export async function scopeCatchUp(access, request, fetchImpl = globalThis.fetch) {
1534
1692
  requireNonEmptyString(request.epicRunId);
1535
1693
  requireNonEmptyString(request.scopeId);
1536
- const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/scope-catch-up`, { scope_id: request.scopeId }, fetchImpl);
1694
+ const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/scope-catch-up`, withGuard({ scope_id: request.scopeId }, request.guard), fetchImpl);
1537
1695
  return parseScopeCatchUpResult(parsed);
1538
1696
  }
1539
1697
  /**
@@ -1553,7 +1711,7 @@ export async function patchPolicy(access, request, fetchImpl = globalThis.fetch)
1553
1711
  Object.keys(patch).length === 0) {
1554
1712
  throw new ConductorBridgeApiError("invalid-input");
1555
1713
  }
1556
- const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/patch-policy`, { merge_patch: patch }, fetchImpl);
1714
+ const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/patch-policy`, withGuard({ merge_patch: patch }, request.guard), fetchImpl);
1557
1715
  return parsePatchPolicyResult(parsed);
1558
1716
  }
1559
1717
  /**
@@ -1566,7 +1724,7 @@ export async function resumeRun(access, request, fetchImpl = globalThis.fetch) {
1566
1724
  if (typeof request.clearQuarantine !== "boolean") {
1567
1725
  throw new ConductorBridgeApiError("invalid-input");
1568
1726
  }
1569
- const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/resume-run`, { clear_quarantine: request.clearQuarantine }, fetchImpl);
1727
+ const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/resume-run`, withGuard({ clear_quarantine: request.clearQuarantine }, request.guard), fetchImpl);
1570
1728
  return parseResumeRunResult(parsed);
1571
1729
  }
1572
1730
  /**
@@ -2333,6 +2491,20 @@ function requireNullableString(o, key) {
2333
2491
  throw readinessShapeError();
2334
2492
  return v;
2335
2493
  }
2494
+ /**
2495
+ * BAPI-1279 — the executor coverage fields. Each is omitted when the server did
2496
+ * not send it (an older deploy), and a present non-boolean fails CLOSED to
2497
+ * `false`: coverage that cannot be read is coverage that is not confirmed.
2498
+ */
2499
+ function parseOptionalExecutorCoverage(o) {
2500
+ const out = {};
2501
+ for (const key of ["process_heartbeat_fresh", "repo_wide_executor_live"]) {
2502
+ if (!(key in o))
2503
+ continue;
2504
+ out[key] = o[key] === true;
2505
+ }
2506
+ return out;
2507
+ }
2336
2508
  function requireNullableBool(o, key) {
2337
2509
  const v = o[key];
2338
2510
  if (v === null || v === undefined)
@@ -2519,6 +2691,7 @@ export function parseConductorReadinessResponse(body) {
2519
2691
  last_seen_at: requireNullableString(exec, "last_seen_at"),
2520
2692
  last_seen_age_seconds: requireNullableInt(exec, "last_seen_age_seconds"),
2521
2693
  ready: requireNullableBool(exec, "ready"),
2694
+ ...parseOptionalExecutorCoverage(exec),
2522
2695
  },
2523
2696
  thresholds: {
2524
2697
  reconciler_stale_after_seconds: requireInt(thr, "reconciler_stale_after_seconds"),
@@ -2534,8 +2707,29 @@ export function parseConductorReadinessResponse(body) {
2534
2707
  const evidence = parseAdmissionEvidence(root);
2535
2708
  return evidence === undefined ? {} : { admission_evidence: evidence };
2536
2709
  })(),
2710
+ ...(() => {
2711
+ const diagnostics = parseReadinessDiagnostics(root);
2712
+ return diagnostics === undefined ? {} : { diagnostics };
2713
+ })(),
2537
2714
  };
2538
2715
  }
2716
+ function parseReadinessDiagnostics(body) {
2717
+ if (body.diagnostics === undefined || body.diagnostics === null)
2718
+ return undefined;
2719
+ if (!Array.isArray(body.diagnostics) || body.diagnostics.length > 64)
2720
+ throw readinessShapeError();
2721
+ return body.diagnostics.map((entry) => {
2722
+ const item = requireObject(entry);
2723
+ if (typeof item.id !== "string" || !/^[a-z][a-z0-9_.-]{0,79}$/.test(item.id))
2724
+ throw readinessShapeError();
2725
+ const severity = item.severity === "mandatory" || item.severity === "advisory" ? item.severity : null;
2726
+ const applicability = item.applicability === "applicable" || item.applicability === "inapplicable"
2727
+ ? item.applicability : null;
2728
+ const verdict = item.verdict === "passed" || item.verdict === "failed" || item.verdict === "unknown"
2729
+ ? item.verdict : "unknown";
2730
+ return { id: item.id, severity, applicability, verdict };
2731
+ });
2732
+ }
2539
2733
  const ADMISSION_EVIDENCE_OWNERS = new Set(["operator", "repository_admin", "bridge_operations"]);
2540
2734
  const BOUNDED_TOKEN = /^[a-z0-9][a-z0-9_.:-]{0,79}$/;
2541
2735
  /**
@@ -2704,8 +2898,9 @@ function parseConductorCiWorkflow(body) {
2704
2898
  *
2705
2899
  * `notify_webhook_default_verified` is the one tri-state: absent is coerced to
2706
2900
  * `null` rather than to `false`, because "not verified here" and "verified as
2707
- * broken" are different statements and only one of them is evidence. Every other
2708
- * field is a strict boolean and a wrong type throws.
2901
+ * broken" are different statements and only one of them is evidence.
2902
+ * `escalation_email_available` (BAPI-1274) is lenient: anything but `true` is
2903
+ * `false`. Every other field is a strict boolean and a wrong type throws.
2709
2904
  */
2710
2905
  function parseUnattended(body) {
2711
2906
  const raw = body.unattended;
@@ -2719,6 +2914,10 @@ function parseUnattended(body) {
2719
2914
  ? null
2720
2915
  : requireNullableBool(o, "notify_webhook_default_verified"),
2721
2916
  repository_readiness_confirmed: requireBool(o, "repository_readiness_confirmed"),
2917
+ // BAPI-1274 — optional and lenient: absent, null, or any non-boolean
2918
+ // normalizes to `false` rather than throwing, because a server older than
2919
+ // BAPI-1274 omits it and must keep today's behavior.
2920
+ escalation_email_available: o.escalation_email_available === true,
2722
2921
  };
2723
2922
  }
2724
2923
  /**
@@ -2735,6 +2934,15 @@ export async function fetchConductorReadiness(access, fetchImpl = globalThis.fet
2735
2934
  const body = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
2736
2935
  return parseConductorReadinessResponse(body);
2737
2936
  }
2937
+ const ROW_OUTCOMES = new Set(["created", "changed", "preserved"]);
2938
+ function optionalRowOutcome(o, key) {
2939
+ const v = o[key];
2940
+ if (v === undefined || v === null)
2941
+ return null;
2942
+ if (typeof v !== "string" || !ROW_OUTCOMES.has(v))
2943
+ throw readinessShapeError();
2944
+ return v;
2945
+ }
2738
2946
  /** Validate a raw bootstrap body, fail-closed (see {@link parseConductorReadinessResponse}). */
2739
2947
  export function parseConductorSupervisorBootstrapResponse(body) {
2740
2948
  const o = requireObject(body);
@@ -2749,6 +2957,8 @@ export function parseConductorSupervisorBootstrapResponse(body) {
2749
2957
  repo_name: repoName,
2750
2958
  setup_written: requireBool(o, "setup_written"),
2751
2959
  config_written: requireBool(o, "config_written"),
2960
+ setup_outcome: optionalRowOutcome(o, "setup_outcome"),
2961
+ config_outcome: optionalRowOutcome(o, "config_outcome"),
2752
2962
  setup_source: requireEnum(o, "setup_source", READINESS_SOURCES),
2753
2963
  config_source: requireEnum(o, "config_source", READINESS_SOURCES),
2754
2964
  setup_created_at: requireNullableString(o, "setup_created_at"),
@@ -2762,14 +2972,337 @@ export function parseConductorSupervisorBootstrapResponse(body) {
2762
2972
  /**
2763
2973
  * POST `/jira/epic-runs/supervisor-bootstrap?repo_name=<repo>`.
2764
2974
  *
2765
- * The ONLY write this client performs. Since BAPI-694 it may also carry the
2766
- * optional repository-default `review_policy`; omitting that field stores no
2767
- * default rather than a neutral one.
2975
+ * Since BAPI-694 it may also carry the optional repository-default
2976
+ * `review_policy`; omitting that field keeps the stored default. Since BAPI-1288
2977
+ * a relaxation is refused with 409 unless `approval` carries the exact token a
2978
+ * {@link previewConductorSupervisorBootstrap} of the SAME request returned.
2979
+ * A 409 is never retried here — see {@link classifySupervisorBootstrapFailure}.
2768
2980
  */
2769
2981
  export async function bootstrapConductorSupervisorDefaults(access, request, fetchImpl) {
2770
2982
  const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/supervisor-bootstrap`, {
2771
2983
  repo_name: access.repoName,
2772
2984
  });
2773
- const body = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), JSON.stringify(request), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
2985
+ const body = await fetchConductorJsonWithMethodAndTimeout("POST", url, conductorPostHeaders(access), JSON.stringify(request), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl, { readErrorField: true });
2774
2986
  return parseConductorSupervisorBootstrapResponse(body);
2775
2987
  }
2988
+ // ---------------------------------------------------------------------------
2989
+ // BAPI-1288 — preview of a protected project-default change
2990
+ // ---------------------------------------------------------------------------
2991
+ /** Closed vocabularies of a server-authored gate-change effect. */
2992
+ const GATE_CHANGE_REQUIREMENTS = new Set([
2993
+ "done_gate",
2994
+ "required_ci_checks",
2995
+ "done_gate_review",
2996
+ "review_policy_default",
2997
+ ]);
2998
+ const GATE_CHANGE_KINDS = new Set(["removed", "weakened", "replaced"]);
2999
+ const GATE_OBLIGATIONS = new Set([
3000
+ "ci_check_green",
3001
+ "sticky_verdict_approved",
3002
+ "native_review_approved",
3003
+ "approval_minimum",
3004
+ "verdictless_review_parks",
3005
+ ]);
3006
+ const CHECK_REF_RE = /^ci-[0-9a-f]{12}$/;
3007
+ /** Bounds the client enforces on server text before it may be displayed. */
3008
+ const MAX_EFFECTS = 16;
3009
+ const MAX_DESCRIPTION_LENGTH = 300;
3010
+ const MAX_CHECK_REFS = 50;
3011
+ const MAX_APPROVAL_TOKEN_LENGTH = 1024;
3012
+ function requireObligationList(o, key) {
3013
+ const v = o[key];
3014
+ if (!Array.isArray(v) || v.length > GATE_OBLIGATIONS.size)
3015
+ throw readinessShapeError();
3016
+ for (const item of v) {
3017
+ if (typeof item !== "string" || !GATE_OBLIGATIONS.has(item))
3018
+ throw readinessShapeError();
3019
+ }
3020
+ return v;
3021
+ }
3022
+ function parseGateChangeEffect(raw) {
3023
+ const o = requireObject(raw);
3024
+ const refs = o.removed_ci_check_refs;
3025
+ if (!Array.isArray(refs) ||
3026
+ refs.length > MAX_CHECK_REFS ||
3027
+ refs.some((r) => typeof r !== "string" || !CHECK_REF_RE.test(r))) {
3028
+ throw readinessShapeError();
3029
+ }
3030
+ const description = o.description;
3031
+ if (typeof description !== "string" ||
3032
+ description.length === 0 ||
3033
+ description.length > MAX_DESCRIPTION_LENGTH ||
3034
+ /[\u0000-\u001F\u007F]/.test(description)) {
3035
+ throw readinessShapeError();
3036
+ }
3037
+ const count = requireInt(o, "removed_ci_check_count");
3038
+ if (count < 0)
3039
+ throw readinessShapeError();
3040
+ return {
3041
+ row: requireEnum(o, "row", new Set(["setup", "config"])),
3042
+ requirement: requireEnum(o, "requirement", GATE_CHANGE_REQUIREMENTS),
3043
+ change: requireEnum(o, "change", GATE_CHANGE_KINDS),
3044
+ removed_obligations: requireObligationList(o, "removed_obligations"),
3045
+ replacement_obligations: requireObligationList(o, "replacement_obligations"),
3046
+ removed_ci_check_count: count,
3047
+ removed_ci_check_refs: refs,
3048
+ stored_min_approvals: requireNullableInt(o, "stored_min_approvals"),
3049
+ proposed_min_approvals: requireNullableInt(o, "proposed_min_approvals"),
3050
+ description,
3051
+ };
3052
+ }
3053
+ /**
3054
+ * Validate a raw preview body, fail-closed.
3055
+ *
3056
+ * Every field is checked against the server's closed contract; anything else —
3057
+ * an unknown outcome, an effect category this client does not know, an
3058
+ * over-long or control-character description, a token without
3059
+ * `approval_required`, a writer or repository that does not match the request —
3060
+ * is a shape error, so an unbounded server string can never reach the operator's
3061
+ * terminal as if it were a vetted description.
3062
+ */
3063
+ export function parseConductorSupervisorBootstrapPreview(body, expectedRepoName) {
3064
+ const o = requireObject(body);
3065
+ if (o.repo_name !== expectedRepoName)
3066
+ throw readinessShapeError();
3067
+ if (o.writer !== "bootstrap")
3068
+ throw readinessShapeError();
3069
+ const names = o.changed_field_names;
3070
+ if (!Array.isArray(names) || names.some((n) => typeof n !== "string" || n.length > 100)) {
3071
+ throw readinessShapeError();
3072
+ }
3073
+ const effectsRaw = o.effects;
3074
+ if (!Array.isArray(effectsRaw) || effectsRaw.length > MAX_EFFECTS)
3075
+ throw readinessShapeError();
3076
+ const effects = effectsRaw.map(parseGateChangeEffect);
3077
+ const approvalRequired = requireBool(o, "approval_required");
3078
+ const token = requireNullableString(o, "approval_token");
3079
+ if (approvalRequired !== effects.length > 0)
3080
+ throw readinessShapeError();
3081
+ if (approvalRequired !== (token !== null))
3082
+ throw readinessShapeError();
3083
+ if (token !== null && (token.length === 0 || token.length > MAX_APPROVAL_TOKEN_LENGTH)) {
3084
+ throw readinessShapeError();
3085
+ }
3086
+ return {
3087
+ repo_name: expectedRepoName,
3088
+ writer: "bootstrap",
3089
+ setup_outcome: requireEnum(o, "setup_outcome", ROW_OUTCOMES),
3090
+ config_outcome: requireEnum(o, "config_outcome", ROW_OUTCOMES),
3091
+ changed_field_names: names,
3092
+ approval_required: approvalRequired,
3093
+ effects,
3094
+ approval_token: token,
3095
+ };
3096
+ }
3097
+ /**
3098
+ * POST `/jira/epic-runs/supervisor-bootstrap/preview?repo_name=<repo>` — what a
3099
+ * bootstrap with `request` WOULD do. Read-only on the server.
3100
+ *
3101
+ * Send the same body to {@link bootstrapConductorSupervisorDefaults} afterwards
3102
+ * (plus `approval` when the preview required it); any difference makes the
3103
+ * server's token check fail with a fresh-preview conflict.
3104
+ */
3105
+ export async function previewConductorSupervisorBootstrap(access, request, fetchImpl) {
3106
+ const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/supervisor-bootstrap/preview`, { repo_name: access.repoName });
3107
+ const body = await fetchConductorJsonWithMethodAndTimeout("POST", url, conductorPostHeaders(access), JSON.stringify(request), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl, { readErrorField: true });
3108
+ return parseConductorSupervisorBootstrapPreview(body, access.repoName);
3109
+ }
3110
+ const GATE_CHANGE_CODE_TO_KIND = Object.freeze({
3111
+ APPROVAL_REQUIRED: "approval-required",
3112
+ STALE_PREVIEW: "stale-preview",
3113
+ UNKNOWN_STORED_STATE: "unknown-stored-state",
3114
+ NON_RELAXABLE: "non-relaxable",
3115
+ INVALID_PROPOSAL: "invalid-proposal",
3116
+ STATE_UNAVAILABLE: "state-unavailable",
3117
+ });
3118
+ export function classifySupervisorBootstrapFailure(err) {
3119
+ if (!(err instanceof ConductorBridgeApiError))
3120
+ return "network";
3121
+ const mapped = err.errorCode ? GATE_CHANGE_CODE_TO_KIND[err.errorCode] : undefined;
3122
+ if (mapped)
3123
+ return mapped;
3124
+ if (err.kind === "invalid-input")
3125
+ return "unsupported-protocol";
3126
+ if (err.kind === "unauthorized")
3127
+ return "unauthorized";
3128
+ if (err.kind === "timeout")
3129
+ return "timeout";
3130
+ if (err.kind === "network")
3131
+ return "network";
3132
+ if (err.status === 404 || err.status === 405 || err.status === 422) {
3133
+ return "unsupported-protocol";
3134
+ }
3135
+ if (err.kind === "server")
3136
+ return "server";
3137
+ return "rejected";
3138
+ }
3139
+ const CI_STOP_REASONS = new Set(["wait_expired", "verdict_unavailable", "repair_exhausted"]);
3140
+ const RESUME_CI_AUDIT_WARNING_MAX = 200;
3141
+ function isPlainRecord(value) {
3142
+ return value !== null && typeof value === "object" && !Array.isArray(value);
3143
+ }
3144
+ function isNonNegativeInt(value) {
3145
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
3146
+ }
3147
+ function nullableNonNegativeInt(value) {
3148
+ return value === null || value === undefined || isNonNegativeInt(value);
3149
+ }
3150
+ function nullableString(value) {
3151
+ return value === null || value === undefined || typeof value === "string";
3152
+ }
3153
+ function parseCiResumeOperation(value) {
3154
+ if (!isPlainRecord(value))
3155
+ throw new ConductorBridgeApiError("server");
3156
+ const v = value;
3157
+ if (typeof v.resumed_at !== "string" ||
3158
+ !CI_STOP_REASONS.has(v.stop_reason) ||
3159
+ !isNonNegativeInt(v.park_generation) ||
3160
+ !isNonNegativeInt(v.resulting_park_generation) ||
3161
+ v.resulting_park_generation !== v.park_generation + 1 ||
3162
+ v.resumed_gate !== "ci" ||
3163
+ v.ci_gate_status !== "in_progress" ||
3164
+ v.allowance_effect !== "repair_allowances_preserved" ||
3165
+ !isNonNegativeInt(v.preserved_attempts) ||
3166
+ !nullableNonNegativeInt(v.preserved_ci_infra_rerun_attempts_used) ||
3167
+ !nullableNonNegativeInt(v.preserved_entry_attempts) ||
3168
+ !nullableString(v.deadline_at) ||
3169
+ !(v.deadline_source === null || v.deadline_source === undefined ||
3170
+ v.deadline_source === "original_gate" || v.deadline_source === "post_repair") ||
3171
+ // The three fixed consequences are part of the contract, not decoration: a
3172
+ // response claiming an extended deadline, a created job, or a CI pass is not
3173
+ // a resume-ci success and is refused as malformed.
3174
+ v.deadline_extended !== false ||
3175
+ v.jobs_created !== 0 ||
3176
+ v.ci_passed_by_action !== false) {
3177
+ throw new ConductorBridgeApiError("server");
3178
+ }
3179
+ return {
3180
+ resumed_at: v.resumed_at,
3181
+ stop_reason: v.stop_reason,
3182
+ park_generation: v.park_generation,
3183
+ resulting_park_generation: v.resulting_park_generation,
3184
+ resumed_gate: "ci",
3185
+ ci_gate_status: "in_progress",
3186
+ allowance_effect: "repair_allowances_preserved",
3187
+ preserved_attempts: v.preserved_attempts,
3188
+ preserved_ci_infra_rerun_attempts_used: (v.preserved_ci_infra_rerun_attempts_used ?? null),
3189
+ preserved_entry_attempts: (v.preserved_entry_attempts ?? null),
3190
+ deadline_at: (v.deadline_at ?? null),
3191
+ deadline_source: (v.deadline_source ?? null),
3192
+ deadline_extended: false,
3193
+ jobs_created: 0,
3194
+ ci_passed_by_action: false,
3195
+ };
3196
+ }
3197
+ function parseCiResumeCurrentState(value) {
3198
+ if (!isPlainRecord(value))
3199
+ throw new ConductorBridgeApiError("server");
3200
+ const v = value;
3201
+ if (!nullableString(v.ticket_status) ||
3202
+ !nullableString(v.current_gate) ||
3203
+ !nullableString(v.ci_gate_status) ||
3204
+ typeof v.held_by_run_control !== "boolean") {
3205
+ throw new ConductorBridgeApiError("server");
3206
+ }
3207
+ return {
3208
+ ticket_status: (v.ticket_status ?? null),
3209
+ current_gate: (v.current_gate ?? null),
3210
+ ci_gate_status: (v.ci_gate_status ?? null),
3211
+ held_by_run_control: v.held_by_run_control,
3212
+ };
3213
+ }
3214
+ /**
3215
+ * Strictly parse a `resume-ci` success body. Anything short of the full
3216
+ * contract — an unknown outcome, a missing history or current-state block, a
3217
+ * non-boolean audit flag, an over-long warning — throws, and the caller treats
3218
+ * that as an uncertain outcome, never as success.
3219
+ */
3220
+ export function parseResumeCiResult(value) {
3221
+ if (!isPlainRecord(value))
3222
+ throw new ConductorBridgeApiError("server");
3223
+ const v = value;
3224
+ if (typeof v.epic_run_id !== "string" || !v.epic_run_id ||
3225
+ typeof v.ticket_key !== "string" || !v.ticket_key ||
3226
+ (v.outcome !== "committed" && v.outcome !== "replayed") ||
3227
+ typeof v.audit_recorded !== "boolean" ||
3228
+ !(v.audit_warning === null || v.audit_warning === undefined ||
3229
+ (typeof v.audit_warning === "string" && v.audit_warning.length <= RESUME_CI_AUDIT_WARNING_MAX))) {
3230
+ throw new ConductorBridgeApiError("server");
3231
+ }
3232
+ return {
3233
+ epic_run_id: v.epic_run_id,
3234
+ ticket_key: v.ticket_key,
3235
+ outcome: v.outcome,
3236
+ operation: parseCiResumeOperation(v.operation),
3237
+ current: parseCiResumeCurrentState(v.current),
3238
+ audit_recorded: v.audit_recorded,
3239
+ audit_warning: (v.audit_warning ?? null),
3240
+ };
3241
+ }
3242
+ /**
3243
+ * POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/resume-ci`.
3244
+ *
3245
+ * Sends the pinned operation key and stop binding only in the authenticated
3246
+ * body. Never throws for an HTTP or transport outcome: every response is
3247
+ * classified into a {@link ResumeCiPostOutcome}, so the caller — not this
3248
+ * function — decides whether an uncertain outcome is retried with the SAME
3249
+ * operation.
3250
+ */
3251
+ export async function resumeCiTicket(access, request, fetchImpl = globalThis.fetch, timeoutMs = CONDUCTOR_FETCH_TIMEOUT_MS) {
3252
+ requireNonEmptyString(request.epicRunId);
3253
+ requireNonEmptyString(request.ticketKey);
3254
+ requireNonEmptyString(request.operationKey);
3255
+ requireNonNegativeSafeInteger(request.expectedRowVersion);
3256
+ const url = buildConductorJiraUrl(access.baseUrl, `${continuationChildPath(request.epicRunId, request.ticketKey)}/resume-ci`);
3257
+ const body = JSON.stringify({
3258
+ repo_name: access.repoName,
3259
+ expected_row_version: request.expectedRowVersion,
3260
+ operation_key: request.operationKey,
3261
+ stop_binding: request.stopBinding,
3262
+ });
3263
+ const controller = new AbortController();
3264
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
3265
+ try {
3266
+ let resp;
3267
+ try {
3268
+ resp = await fetchImpl(url, {
3269
+ method: "POST",
3270
+ headers: conductorPostHeaders(access),
3271
+ body,
3272
+ signal: controller.signal,
3273
+ });
3274
+ }
3275
+ catch {
3276
+ return { kind: "uncertain", reason: controller.signal.aborted ? "timeout" : "network" };
3277
+ }
3278
+ let parsed;
3279
+ let parsedOk = true;
3280
+ try {
3281
+ parsed = await resp.json();
3282
+ }
3283
+ catch {
3284
+ parsedOk = false;
3285
+ }
3286
+ if (resp.status >= 200 && resp.status < 300) {
3287
+ if (!parsedOk)
3288
+ return { kind: "uncertain", reason: "malformed", status: resp.status };
3289
+ try {
3290
+ return { kind: "result", result: parseResumeCiResult(parsed) };
3291
+ }
3292
+ catch {
3293
+ return { kind: "uncertain", reason: "malformed", status: resp.status };
3294
+ }
3295
+ }
3296
+ const errorCode = parsedOk ? safeDetailErrorCode(parsed) : null;
3297
+ if (errorCode !== null && isCiResumeRefusalCode(errorCode)) {
3298
+ return { kind: "refused", status: resp.status, errorCode };
3299
+ }
3300
+ if (resp.status >= 500) {
3301
+ return { kind: "uncertain", reason: "server", status: resp.status };
3302
+ }
3303
+ return { kind: "rejected", status: resp.status, errorCode };
3304
+ }
3305
+ finally {
3306
+ clearTimeout(timer);
3307
+ }
3308
+ }