@bridge_gpt/mcp-server 0.2.6 → 0.2.10
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 +58 -5
- package/build/commands.generated.js +1 -1
- package/build/conductor/bridge-api-client.js +262 -35
- package/build/conductor/cli.js +22 -1
- package/build/conductor/doctor.js +34 -1
- package/build/conductor/done-gate.js +301 -58
- package/build/conductor/epic-reconcile.js +121 -4
- package/build/conductor/epic-runtime.js +299 -13
- package/build/conductor/epic-state.js +108 -9
- package/build/conductor/git-ci-types.js +6 -0
- package/build/conductor/pr-ci-producer.js +114 -15
- package/build/conductor/pr-review-producer.js +116 -0
- package/build/conductor/store.js +8 -1
- package/build/conductor/supervisor-message-relay.js +31 -0
- package/build/conductor/taxonomy.js +3 -0
- package/build/conductor/tools.js +2 -2
- package/build/index.js +356 -1086
- package/build/init.js +481 -0
- package/build/install-bridge.js +692 -0
- package/build/mcp-profile.js +43 -0
- package/build/readme.generated.js +1 -1
- package/build/start-tickets-conductor.js +1 -0
- package/build/start-tickets.js +328 -36
- package/build/upgrade-cli.js +154 -0
- package/build/version.generated.js +1 -1
- package/package.json +3 -2
|
@@ -13,7 +13,7 @@ import os from "node:os";
|
|
|
13
13
|
import { readFile, stat } from "node:fs/promises";
|
|
14
14
|
import { resolveBapiCredentials } from "../credential-store.js";
|
|
15
15
|
import { resolveStartTicketsRepoName } from "../start-tickets-repo.js";
|
|
16
|
-
import {
|
|
16
|
+
import { normalizePrNumber, normalizeSha } from "./git-ci-types.js";
|
|
17
17
|
import { ConductorValidationError } from "./errors.js";
|
|
18
18
|
/** Default Bridge API base URL when `BAPI_BASE_URL` is unset. */
|
|
19
19
|
export const CONDUCTOR_DEFAULT_BASE_URL = "https://bridgegpt-api.com";
|
|
@@ -95,7 +95,7 @@ function conductorGetHeaders(access) {
|
|
|
95
95
|
* non-2xx response — never including the response body, headers, or API key. The
|
|
96
96
|
* timer is always cleared.
|
|
97
97
|
*/
|
|
98
|
-
export async function fetchConductorJsonWithTimeout(url, headers, timeoutMs, fetchImpl = fetch) {
|
|
98
|
+
export async function fetchConductorJsonWithTimeout(url, headers, timeoutMs, fetchImpl = globalThis.fetch) {
|
|
99
99
|
const controller = new AbortController();
|
|
100
100
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
101
101
|
try {
|
|
@@ -132,7 +132,7 @@ export async function fetchConductorJsonWithTimeout(url, headers, timeoutMs, fet
|
|
|
132
132
|
* `value` payload (or `undefined` when the envelope lacks a `value`). The field
|
|
133
133
|
* name is URL-encoded; the repo scope is always included.
|
|
134
134
|
*/
|
|
135
|
-
export async function fetchConductorConfigField(access, fieldName, fetchImpl = fetch) {
|
|
135
|
+
export async function fetchConductorConfigField(access, fieldName, fetchImpl = globalThis.fetch) {
|
|
136
136
|
const url = buildConductorJiraUrl(access.baseUrl, `/config-field/${encodeURIComponent(fieldName)}`, {
|
|
137
137
|
repo_name: access.repoName,
|
|
138
138
|
});
|
|
@@ -142,9 +142,41 @@ export async function fetchConductorConfigField(access, fieldName, fetchImpl = f
|
|
|
142
142
|
}
|
|
143
143
|
return undefined;
|
|
144
144
|
}
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Fetch the effective supervisor setup for a repo.
|
|
147
|
+
*
|
|
148
|
+
* When ``epicKey`` is provided, hits ``GET /jira/epic-runs/runs/{epicKey}/supervisor-setup/``
|
|
149
|
+
* (per-epic row with project-default fallback). When omitted, hits
|
|
150
|
+
* ``GET /jira/epic-runs/supervisor-setup/defaults/`` (project-default only).
|
|
151
|
+
* Fails closed (throws ``ConductorBridgeApiError``) on any network or server error.
|
|
152
|
+
*/
|
|
153
|
+
export async function fetchEffectiveSupervisorSetup(access, epicKey, fetchImpl = globalThis.fetch) {
|
|
154
|
+
const apiPath = epicKey
|
|
155
|
+
? `${EPIC_RUNS_API_PREFIX}/runs/${encodeURIComponent(epicKey)}/supervisor-setup/`
|
|
156
|
+
: `${EPIC_RUNS_API_PREFIX}/supervisor-setup/defaults/`;
|
|
157
|
+
const url = buildConductorJiraUrl(access.baseUrl, apiPath, {
|
|
158
|
+
repo_name: access.repoName,
|
|
159
|
+
});
|
|
160
|
+
const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
161
|
+
return parsed;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Fetch the effective supervisor config for a repo.
|
|
165
|
+
*
|
|
166
|
+
* When ``epicKey`` is provided, hits ``GET /jira/epic-runs/runs/{epicKey}/supervisor-config/``
|
|
167
|
+
* (per-epic row with project-default fallback). When omitted, hits
|
|
168
|
+
* ``GET /jira/epic-runs/supervisor-config/defaults/`` (project-default only).
|
|
169
|
+
* Fails closed (throws ``ConductorBridgeApiError``) on any network or server error.
|
|
170
|
+
*/
|
|
171
|
+
export async function fetchEffectiveSupervisorConfig(access, epicKey, fetchImpl = globalThis.fetch) {
|
|
172
|
+
const apiPath = epicKey
|
|
173
|
+
? `${EPIC_RUNS_API_PREFIX}/runs/${encodeURIComponent(epicKey)}/supervisor-config/`
|
|
174
|
+
: `${EPIC_RUNS_API_PREFIX}/supervisor-config/defaults/`;
|
|
175
|
+
const url = buildConductorJiraUrl(access.baseUrl, apiPath, {
|
|
176
|
+
repo_name: access.repoName,
|
|
177
|
+
});
|
|
178
|
+
const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
179
|
+
return parsed;
|
|
148
180
|
}
|
|
149
181
|
/**
|
|
150
182
|
* Poll CI checks for a commit through the existing `/poll-ci-checks` endpoint.
|
|
@@ -152,7 +184,7 @@ export function fetchDoneGateConfigField(access, fetchImpl = fetch) {
|
|
|
152
184
|
* request when it is not a valid SHA. Returns the endpoint response verbatim —
|
|
153
185
|
* the client never invents `pr_number`/`head_sha` fields.
|
|
154
186
|
*/
|
|
155
|
-
export async function pollCiChecksForCommit(access, commitRef, fetchImpl = fetch) {
|
|
187
|
+
export async function pollCiChecksForCommit(access, commitRef, fetchImpl = globalThis.fetch) {
|
|
156
188
|
const sha = normalizeSha(commitRef);
|
|
157
189
|
if (sha === null) {
|
|
158
190
|
throw new ConductorBridgeApiError("invalid-input");
|
|
@@ -164,6 +196,26 @@ export async function pollCiChecksForCommit(access, commitRef, fetchImpl = fetch
|
|
|
164
196
|
return fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
165
197
|
}
|
|
166
198
|
// ---------------------------------------------------------------------------
|
|
199
|
+
// Conductor BAPI-440: PR review status GET client
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
/**
|
|
202
|
+
* Fetch the normalized PR review status from the backend-owned review-status
|
|
203
|
+
* endpoint. Validates `prNumber` with {@link normalizePrNumber} before issuing
|
|
204
|
+
* the request. Returns the endpoint response verbatim — the client never invents
|
|
205
|
+
* review fields. Throws a sanitized {@link ConductorBridgeApiError} on any failure
|
|
206
|
+
* so the producer can fail-closed without leaking body/headers/key.
|
|
207
|
+
*/
|
|
208
|
+
export async function fetchPrReviewStatus(access, prNumber, fetchImpl = globalThis.fetch) {
|
|
209
|
+
const pr = normalizePrNumber(prNumber);
|
|
210
|
+
if (pr === null) {
|
|
211
|
+
throw new ConductorBridgeApiError("invalid-input");
|
|
212
|
+
}
|
|
213
|
+
const url = buildConductorVcsUrl(access.baseUrl, `/vcs/pull-requests/${pr}/reviews/status`);
|
|
214
|
+
const fullUrl = new URL(url);
|
|
215
|
+
fullUrl.searchParams.set("repo_name", access.repoName);
|
|
216
|
+
return fetchConductorJsonWithTimeout(fullUrl.toString(), conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
217
|
+
}
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
167
219
|
// Conductor C6 (BAPI-398): protected VCS merge POST client
|
|
168
220
|
// ---------------------------------------------------------------------------
|
|
169
221
|
/**
|
|
@@ -181,19 +233,20 @@ function conductorPostHeaders(access) {
|
|
|
181
233
|
return { "X-API-Key": access.apiKey, "Content-Type": "application/json" };
|
|
182
234
|
}
|
|
183
235
|
/**
|
|
184
|
-
*
|
|
185
|
-
* {@link fetchConductorJsonWithTimeout}. Throws a sanitized
|
|
236
|
+
* Send a JSON request body with the given HTTP method and an `AbortController`
|
|
237
|
+
* timeout, mirroring {@link fetchConductorJsonWithTimeout}. Throws a sanitized
|
|
186
238
|
* {@link ConductorBridgeApiError} on abort/timeout, network failure, or any
|
|
187
239
|
* non-2xx response — never including the response body, headers, API key, or
|
|
188
|
-
* request body in the error. The timer is always cleared.
|
|
240
|
+
* request body in the error. The timer is always cleared. Shared core for the
|
|
241
|
+
* POST and PATCH wrappers below.
|
|
189
242
|
*/
|
|
190
|
-
|
|
243
|
+
async function fetchConductorJsonWithMethodAndTimeout(method, url, headers, body, timeoutMs, fetchImpl) {
|
|
191
244
|
const controller = new AbortController();
|
|
192
245
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
193
246
|
try {
|
|
194
247
|
let resp;
|
|
195
248
|
try {
|
|
196
|
-
resp = await fetchImpl(url, { method
|
|
249
|
+
resp = await fetchImpl(url, { method, headers, body, signal: controller.signal });
|
|
197
250
|
}
|
|
198
251
|
catch {
|
|
199
252
|
throw new ConductorBridgeApiError(controller.signal.aborted ? "timeout" : "network");
|
|
@@ -218,6 +271,29 @@ export async function fetchConductorJsonPostWithTimeout(url, headers, body, time
|
|
|
218
271
|
clearTimeout(timer);
|
|
219
272
|
}
|
|
220
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* POST JSON with an `AbortController` timeout. Thin wrapper over
|
|
276
|
+
* {@link fetchConductorJsonWithMethodAndTimeout}.
|
|
277
|
+
*/
|
|
278
|
+
export async function fetchConductorJsonPostWithTimeout(url, headers, body, timeoutMs, fetchImpl) {
|
|
279
|
+
return fetchConductorJsonWithMethodAndTimeout("POST", url, headers, body, timeoutMs, fetchImpl);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* PATCH JSON with an `AbortController` timeout. Thin wrapper over
|
|
283
|
+
* {@link fetchConductorJsonWithMethodAndTimeout}. Used by the per-ticket CAS
|
|
284
|
+
* status endpoint, which the backend declares as PATCH.
|
|
285
|
+
*/
|
|
286
|
+
export async function fetchConductorJsonPatchWithTimeout(url, headers, body, timeoutMs, fetchImpl) {
|
|
287
|
+
return fetchConductorJsonWithMethodAndTimeout("PATCH", url, headers, body, timeoutMs, fetchImpl);
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* PUT JSON with an `AbortController` timeout. Thin wrapper over
|
|
291
|
+
* {@link fetchConductorJsonWithMethodAndTimeout}. Used by endpoints that the
|
|
292
|
+
* backend declares as PUT (e.g. the Jira status transition endpoint).
|
|
293
|
+
*/
|
|
294
|
+
export async function fetchConductorJsonPutWithTimeout(url, headers, body, timeoutMs, fetchImpl) {
|
|
295
|
+
return fetchConductorJsonWithMethodAndTimeout("PUT", url, headers, body, timeoutMs, fetchImpl);
|
|
296
|
+
}
|
|
221
297
|
/**
|
|
222
298
|
* Call the protected `POST /vcs/pull-requests/{pr_number}/merge` endpoint. The PR
|
|
223
299
|
* number lives ONLY in the path; the API key travels ONLY in headers; the body
|
|
@@ -225,7 +301,7 @@ export async function fetchConductorJsonPostWithTimeout(url, headers, body, time
|
|
|
225
301
|
* before the request is issued. Throws a sanitized {@link ConductorBridgeApiError}
|
|
226
302
|
* on any failure.
|
|
227
303
|
*/
|
|
228
|
-
export async function mergePullRequestForGate(access, request, fetchImpl = fetch) {
|
|
304
|
+
export async function mergePullRequestForGate(access, request, fetchImpl = globalThis.fetch) {
|
|
229
305
|
const pr = normalizePrNumber(request.pr_number);
|
|
230
306
|
const sha = normalizeSha(request.expected_head_sha);
|
|
231
307
|
if (pr === null || sha === null || !request.action_key || !request.repo_name) {
|
|
@@ -244,6 +320,51 @@ export async function mergePullRequestForGate(access, request, fetchImpl = fetch
|
|
|
244
320
|
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
245
321
|
return parsed;
|
|
246
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Call the protected `POST /vcs/pull-requests/{pr_number}/remediate` endpoint.
|
|
325
|
+
* The PR number lives ONLY in the path; the API key travels ONLY in headers; the
|
|
326
|
+
* body carries no branch name or provider token. A 409 (idempotency-key replay)
|
|
327
|
+
* is caught and returned as `{ ok: true, conflict: true }`; all other failures
|
|
328
|
+
* throw a sanitized {@link ConductorBridgeApiError}.
|
|
329
|
+
*/
|
|
330
|
+
export async function remediateEpicTicket(access, request, fetchImpl = globalThis.fetch) {
|
|
331
|
+
const pr = normalizePrNumber(request.pr_number);
|
|
332
|
+
if (pr === null ||
|
|
333
|
+
!request.epic_run_id ||
|
|
334
|
+
!request.ticket_key ||
|
|
335
|
+
!request.head_sha ||
|
|
336
|
+
!request.idempotency_key) {
|
|
337
|
+
throw new ConductorBridgeApiError("invalid-input");
|
|
338
|
+
}
|
|
339
|
+
requireNonNegativeSafeInteger(request.expected_row_version);
|
|
340
|
+
if (request.attempt_kind !== "nudge" && request.attempt_kind !== "redispatch") {
|
|
341
|
+
throw new ConductorBridgeApiError("invalid-input");
|
|
342
|
+
}
|
|
343
|
+
const url = buildConductorVcsUrl(access.baseUrl, `/vcs/pull-requests/${pr}/remediate`);
|
|
344
|
+
// The body omits pr_number (it is in the path) and never includes a branch name
|
|
345
|
+
// or provider token; extra fields would be rejected server-side (extra=forbid).
|
|
346
|
+
const body = JSON.stringify({
|
|
347
|
+
repo_name: access.repoName,
|
|
348
|
+
epic_run_id: request.epic_run_id,
|
|
349
|
+
ticket_key: request.ticket_key,
|
|
350
|
+
expected_row_version: request.expected_row_version,
|
|
351
|
+
head_sha: request.head_sha,
|
|
352
|
+
idempotency_key: request.idempotency_key,
|
|
353
|
+
attempt_kind: request.attempt_kind,
|
|
354
|
+
...(request.reason ? { reason: request.reason } : {}),
|
|
355
|
+
});
|
|
356
|
+
try {
|
|
357
|
+
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
358
|
+
return { ok: true, conflict: false, response: parsed };
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
if (err instanceof ConductorBridgeApiError && err.kind === "http" && err.status === 409) {
|
|
362
|
+
// Idempotency-key replay: the attempt was already recorded on a prior tick.
|
|
363
|
+
return { ok: true, conflict: true };
|
|
364
|
+
}
|
|
365
|
+
throw err;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
247
368
|
// ---------------------------------------------------------------------------
|
|
248
369
|
// Local boundary validators (reject before any fetch call)
|
|
249
370
|
// ---------------------------------------------------------------------------
|
|
@@ -268,7 +389,7 @@ function requireNoSlashPathSegment(value) {
|
|
|
268
389
|
}
|
|
269
390
|
}
|
|
270
391
|
const EPIC_TICKET_STATUS_VALUES = [
|
|
271
|
-
"planned", "ready", "dispatched", "running", "blocked", "abandoned", "done",
|
|
392
|
+
"planned", "ready", "dispatched", "running", "blocked", "abandoned", "done", "ready_for_review",
|
|
272
393
|
];
|
|
273
394
|
function requireEpicTicketStatusValue(value) {
|
|
274
395
|
if (typeof value !== "string" || !EPIC_TICKET_STATUS_VALUES.includes(value)) {
|
|
@@ -299,15 +420,21 @@ function epicDispatchTransitionApiPath(dispatchKey, nextStatus) {
|
|
|
299
420
|
* Build the canonical dispatch idempotency key in lock-step with the server-side
|
|
300
421
|
* `build_dispatch_key` Python helper.
|
|
301
422
|
*
|
|
302
|
-
* Format: `dispatch:{epicKey}:{ticketKey}:{planVersion}`.
|
|
423
|
+
* Format: `dispatch:{epicKey}:{ticketKey}:{planVersion}`. For a remediation
|
|
424
|
+
* re-dispatch (BAPI-441), pass `attempt > 0` to append an `:r{attempt}` suffix
|
|
425
|
+
* (e.g. `dispatch:BAPI-405:BAPI-441:3:r2`) so the re-dispatch claims a *distinct*
|
|
426
|
+
* key from the original epic dispatch and is not deduped against it. `attempt`
|
|
427
|
+
* defaults to 0, which yields the original (un-suffixed) key for normal dispatch.
|
|
303
428
|
*
|
|
304
429
|
* // TODO(epic-run-store): the epic component maps to the server-side epic_run_id component used by build_dispatch_key; confirm naming if the sibling renames it at integration.
|
|
305
430
|
*/
|
|
306
|
-
export function buildEpicDispatchKey(epicKey, ticketKey, planVersion) {
|
|
431
|
+
export function buildEpicDispatchKey(epicKey, ticketKey, planVersion, attempt = 0) {
|
|
307
432
|
requireNonEmptyString(epicKey);
|
|
308
433
|
requireNonEmptyString(ticketKey);
|
|
309
434
|
requireNonNegativeSafeInteger(planVersion);
|
|
310
|
-
|
|
435
|
+
requireNonNegativeSafeInteger(attempt);
|
|
436
|
+
const base = `dispatch:${epicKey}:${ticketKey}:${planVersion}`;
|
|
437
|
+
return attempt > 0 ? `${base}:r${attempt}` : base;
|
|
311
438
|
}
|
|
312
439
|
// ---------------------------------------------------------------------------
|
|
313
440
|
// Epic supervision lease claim/renew
|
|
@@ -334,7 +461,7 @@ function parseEpicSupervisionLeaseResult(parsed) {
|
|
|
334
461
|
* acquired/renewed from held-by-other and terminal so the control loop can decide
|
|
335
462
|
* whether to act or exit as an observer. Validates inputs before the request.
|
|
336
463
|
*/
|
|
337
|
-
export async function claimEpicSupervisionLease(access, request, fetchImpl = fetch) {
|
|
464
|
+
export async function claimEpicSupervisionLease(access, request, fetchImpl = globalThis.fetch) {
|
|
338
465
|
requireNonEmptyString(request.epicKey);
|
|
339
466
|
requireNonEmptyString(request.leaseOwner);
|
|
340
467
|
requirePositiveSafeInteger(request.ttlSeconds);
|
|
@@ -355,12 +482,31 @@ export async function claimEpicSupervisionLease(access, request, fetchImpl = fet
|
|
|
355
482
|
* Returns the server payload verbatim — no ready-set computation, plan-hash
|
|
356
483
|
* assertion, or derived control-loop fields are added.
|
|
357
484
|
*/
|
|
358
|
-
export async function fetchEpicRunState(access, epicKey, fetchImpl = fetch) {
|
|
485
|
+
export async function fetchEpicRunState(access, epicKey, fetchImpl = globalThis.fetch) {
|
|
359
486
|
requireNonEmptyString(epicKey);
|
|
360
487
|
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(epicKey)}/state`, { repo_name: access.repoName });
|
|
361
488
|
const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
362
489
|
return parsed;
|
|
363
490
|
}
|
|
491
|
+
/**
|
|
492
|
+
* GET `/jira/epic-runs/runs?repo_name=<repo>&status=active` and return the
|
|
493
|
+
* list of active epic runs. Caps at 20 results — enough for typical deployments
|
|
494
|
+
* and prevents runaway iteration in the producer resolution seam.
|
|
495
|
+
*/
|
|
496
|
+
export async function fetchActiveEpicRuns(access, fetchImpl = globalThis.fetch) {
|
|
497
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/runs`, {
|
|
498
|
+
repo_name: access.repoName,
|
|
499
|
+
status: "active",
|
|
500
|
+
limit: "20",
|
|
501
|
+
});
|
|
502
|
+
const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
503
|
+
if (parsed && typeof parsed === "object") {
|
|
504
|
+
const runs = parsed.runs;
|
|
505
|
+
if (Array.isArray(runs))
|
|
506
|
+
return runs;
|
|
507
|
+
}
|
|
508
|
+
return [];
|
|
509
|
+
}
|
|
364
510
|
// ---------------------------------------------------------------------------
|
|
365
511
|
// Per-ticket CAS status advancement
|
|
366
512
|
// ---------------------------------------------------------------------------
|
|
@@ -393,13 +539,14 @@ function parseAdvanceEpicTicketStatusResult(parsed) {
|
|
|
393
539
|
throw new ConductorBridgeApiError("server");
|
|
394
540
|
}
|
|
395
541
|
/**
|
|
396
|
-
*
|
|
397
|
-
*
|
|
542
|
+
* PATCH the per-ticket CAS status endpoint
|
|
543
|
+
* (`PATCH /runs/{epic_run_id}/tickets/{ticket_key}`). Surfaces structured CAS
|
|
544
|
+
* conflicts as a distinct non-throwing outcome where the backend returns one;
|
|
545
|
+
* the current backend instead raises a 400 VALIDATION on a stale row_version,
|
|
546
|
+
* which surfaces as a thrown `ConductorBridgeApiError("http", 400)`.
|
|
398
547
|
* Transport/auth/server failures still throw a sanitized {@link ConductorBridgeApiError}.
|
|
399
|
-
*
|
|
400
|
-
* // TODO(epic-run-store): ticket requires POST but sibling currently declares PATCH /runs/{epic_run_id}/tickets/{ticket_key}; reconcile HTTP method at integration.
|
|
401
548
|
*/
|
|
402
|
-
export async function advanceEpicTicketStatus(access, request, fetchImpl = fetch) {
|
|
549
|
+
export async function advanceEpicTicketStatus(access, request, fetchImpl = globalThis.fetch) {
|
|
403
550
|
requireNonEmptyString(request.epicKey);
|
|
404
551
|
requireNonEmptyString(request.ticketKey);
|
|
405
552
|
requireNonNegativeSafeInteger(request.expectedRowVersion);
|
|
@@ -408,7 +555,10 @@ export async function advanceEpicTicketStatus(access, request, fetchImpl = fetch
|
|
|
408
555
|
if (request.dispatchRunId !== undefined) {
|
|
409
556
|
requireNonEmptyString(request.dispatchRunId);
|
|
410
557
|
}
|
|
411
|
-
//
|
|
558
|
+
// The backend declares this endpoint as PATCH /runs/{epic_run_id}/tickets/{ticket_key}
|
|
559
|
+
// (api/routes/epic_runs.py:489). The body matches PatchEpicTicketStatusRequest:
|
|
560
|
+
// repo_name + expected_row_version are required; status/plan_version/dispatch_run_id
|
|
561
|
+
// are optional and accepted (the model does not forbid extras).
|
|
412
562
|
const CAS_ENDPOINT_PATH = `${epicRunApiPath(request.epicKey)}/tickets/${encodeURIComponent(request.ticketKey)}`;
|
|
413
563
|
const url = buildConductorJiraUrl(access.baseUrl, CAS_ENDPOINT_PATH);
|
|
414
564
|
const body = JSON.stringify({
|
|
@@ -418,7 +568,7 @@ export async function advanceEpicTicketStatus(access, request, fetchImpl = fetch
|
|
|
418
568
|
expected_row_version: request.expectedRowVersion,
|
|
419
569
|
...(request.dispatchRunId ? { dispatch_run_id: request.dispatchRunId } : {}),
|
|
420
570
|
});
|
|
421
|
-
const parsed = await
|
|
571
|
+
const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
422
572
|
return parseAdvanceEpicTicketStatusResult(parsed);
|
|
423
573
|
}
|
|
424
574
|
/**
|
|
@@ -427,7 +577,7 @@ export async function advanceEpicTicketStatus(access, request, fetchImpl = fetch
|
|
|
427
577
|
* ticks and re-plans is safe. Throws a sanitized {@link ConductorBridgeApiError}
|
|
428
578
|
* on any transport/auth/server error.
|
|
429
579
|
*/
|
|
430
|
-
export async function createEpicTicketStatus(access, request, fetchImpl = fetch) {
|
|
580
|
+
export async function createEpicTicketStatus(access, request, fetchImpl = globalThis.fetch) {
|
|
431
581
|
requireNonEmptyString(request.epicKey);
|
|
432
582
|
requireNonEmptyString(request.ticketKey);
|
|
433
583
|
requireEpicTicketStatusValue(request.status);
|
|
@@ -486,13 +636,13 @@ function parseEpicDispatchResult(parsed) {
|
|
|
486
636
|
* outcome so a crashed-then-retried tick never double-dispatches. The dispatch key
|
|
487
637
|
* is composed exclusively via {@link buildEpicDispatchKey}.
|
|
488
638
|
*/
|
|
489
|
-
export async function recordEpicDispatch(access, request, fetchImpl = fetch) {
|
|
639
|
+
export async function recordEpicDispatch(access, request, fetchImpl = globalThis.fetch) {
|
|
490
640
|
requireNonEmptyString(request.epicKey);
|
|
491
641
|
requireNonEmptyString(request.ticketKey);
|
|
492
642
|
requireNonEmptyString(request.leaseOwner);
|
|
493
643
|
requireNonNegativeSafeInteger(request.planVersion);
|
|
494
644
|
requirePositiveSafeInteger(request.ttlSeconds);
|
|
495
|
-
const dispatchKey = buildEpicDispatchKey(request.epicKey, request.ticketKey, request.planVersion);
|
|
645
|
+
const dispatchKey = buildEpicDispatchKey(request.epicKey, request.ticketKey, request.planVersion, request.attempt ?? 0);
|
|
496
646
|
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/dispatch/claim`);
|
|
497
647
|
const body = JSON.stringify({
|
|
498
648
|
repo_name: access.repoName,
|
|
@@ -515,7 +665,7 @@ export async function recordEpicDispatch(access, request, fetchImpl = fetch) {
|
|
|
515
665
|
* Dispatch keys containing `/` are rejected before the request because transition
|
|
516
666
|
* endpoints embed the key as a URL path segment.
|
|
517
667
|
*/
|
|
518
|
-
export async function transitionEpicDispatch(access, request, fetchImpl = fetch) {
|
|
668
|
+
export async function transitionEpicDispatch(access, request, fetchImpl = globalThis.fetch) {
|
|
519
669
|
requireNonEmptyString(request.dispatchKey);
|
|
520
670
|
requireNoSlashPathSegment(request.dispatchKey);
|
|
521
671
|
requireEpicDispatchTransitionStatus(request.nextStatus);
|
|
@@ -537,7 +687,7 @@ export async function transitionEpicDispatch(access, request, fetchImpl = fetch)
|
|
|
537
687
|
* in the `X-API-Key` header, never in the URL. Throws a sanitized
|
|
538
688
|
* {@link ConductorBridgeApiError} on any transport/auth/server error.
|
|
539
689
|
*/
|
|
540
|
-
export async function storeEpicPlan(access, request, fetchImpl = fetch) {
|
|
690
|
+
export async function storeEpicPlan(access, request, fetchImpl = globalThis.fetch) {
|
|
541
691
|
requireNonEmptyString(request.epicKey);
|
|
542
692
|
requirePositiveSafeInteger(request.planVersion);
|
|
543
693
|
requireNonEmptyString(request.planHash);
|
|
@@ -562,7 +712,7 @@ export async function storeEpicPlan(access, request, fetchImpl = fetch) {
|
|
|
562
712
|
* monotonic constraint violation). Other errors throw a sanitized
|
|
563
713
|
* {@link ConductorBridgeApiError}.
|
|
564
714
|
*/
|
|
565
|
-
export async function approveEpicPlan(access, request, fetchImpl = fetch) {
|
|
715
|
+
export async function approveEpicPlan(access, request, fetchImpl = globalThis.fetch) {
|
|
566
716
|
requireNonEmptyString(request.epicKey);
|
|
567
717
|
requirePositiveSafeInteger(request.planVersion);
|
|
568
718
|
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/approve-plan`);
|
|
@@ -589,7 +739,7 @@ export async function approveEpicPlan(access, request, fetchImpl = fetch) {
|
|
|
589
739
|
* Returns the response as a {@link GetEpicPlanResponse}. The API key travels
|
|
590
740
|
* ONLY in the `X-API-Key` header, never in the URL.
|
|
591
741
|
*/
|
|
592
|
-
export async function getEpicPlan(access, epicKey, planVersion, fetchImpl = fetch) {
|
|
742
|
+
export async function getEpicPlan(access, epicKey, planVersion, fetchImpl = globalThis.fetch) {
|
|
593
743
|
requireNonEmptyString(epicKey);
|
|
594
744
|
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(epicKey)}/plan`, { repo_name: access.repoName, plan_version: String(planVersion) });
|
|
595
745
|
const parsed = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
@@ -604,7 +754,7 @@ export async function getEpicPlan(access, epicKey, planVersion, fetchImpl = fetc
|
|
|
604
754
|
* holds the lock) and `"idle"` (no lock is held). Throws a sanitized
|
|
605
755
|
* {@link ConductorBridgeApiError} on any transport/auth/server failure.
|
|
606
756
|
*/
|
|
607
|
-
export async function fetchParseStatus(access, fetchImpl = fetch) {
|
|
757
|
+
export async function fetchParseStatus(access, fetchImpl = globalThis.fetch) {
|
|
608
758
|
const url = buildConductorJiraUrl(access.baseUrl, "/parse-status", {
|
|
609
759
|
repo_name: access.repoName,
|
|
610
760
|
});
|
|
@@ -618,8 +768,85 @@ export async function fetchParseStatus(access, fetchImpl = fetch) {
|
|
|
618
768
|
* safe. Returns the response envelope verbatim. Throws a sanitized
|
|
619
769
|
* {@link ConductorBridgeApiError} on any transport/auth/server failure.
|
|
620
770
|
*/
|
|
621
|
-
export async function triggerRepositoryParse(access, fetchImpl = fetch) {
|
|
771
|
+
export async function triggerRepositoryParse(access, fetchImpl = globalThis.fetch) {
|
|
622
772
|
const url = buildConductorJiraUrl(access.baseUrl, "/parse-repository");
|
|
623
773
|
const body = JSON.stringify({ repo_name: access.repoName });
|
|
624
774
|
return fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
625
775
|
}
|
|
776
|
+
/**
|
|
777
|
+
* Call the protected `DELETE /vcs/pull-requests/{pr_number}/branch` endpoint.
|
|
778
|
+
* Treats 404-equivalent responses as idempotent success (already deleted).
|
|
779
|
+
* Throws a sanitized {@link ConductorBridgeApiError} on auth/server/network
|
|
780
|
+
* failures.
|
|
781
|
+
*/
|
|
782
|
+
export async function deletePullRequestBranch(access, prNumber, expectedHeadSha, fetchImpl = globalThis.fetch) {
|
|
783
|
+
const pr = normalizePrNumber(prNumber);
|
|
784
|
+
if (pr === null) {
|
|
785
|
+
throw new ConductorBridgeApiError("invalid-input");
|
|
786
|
+
}
|
|
787
|
+
const url = buildConductorVcsUrl(access.baseUrl, `/vcs/pull-requests/${pr}/branch?repo_name=${encodeURIComponent(access.repoName)}&expected_head_sha=${encodeURIComponent(expectedHeadSha)}`);
|
|
788
|
+
const controller = new AbortController();
|
|
789
|
+
const timer = setTimeout(() => controller.abort(), CONDUCTOR_FETCH_TIMEOUT_MS);
|
|
790
|
+
try {
|
|
791
|
+
let resp;
|
|
792
|
+
try {
|
|
793
|
+
resp = await fetchImpl(url, {
|
|
794
|
+
method: "DELETE",
|
|
795
|
+
headers: { "X-API-Key": access.apiKey },
|
|
796
|
+
body: "",
|
|
797
|
+
signal: controller.signal,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
throw new ConductorBridgeApiError(controller.signal.aborted ? "timeout" : "network");
|
|
802
|
+
}
|
|
803
|
+
// 404 from the provider → branch already gone → idempotent success
|
|
804
|
+
if (resp.status === 404) {
|
|
805
|
+
return { deleted: false, branch: null, reason: "not_found" };
|
|
806
|
+
}
|
|
807
|
+
if (!resp.ok) {
|
|
808
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
809
|
+
throw new ConductorBridgeApiError("unauthorized", resp.status);
|
|
810
|
+
}
|
|
811
|
+
if (resp.status >= 500) {
|
|
812
|
+
throw new ConductorBridgeApiError("server", resp.status);
|
|
813
|
+
}
|
|
814
|
+
throw new ConductorBridgeApiError("http", resp.status);
|
|
815
|
+
}
|
|
816
|
+
try {
|
|
817
|
+
return (await resp.json());
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
throw new ConductorBridgeApiError("network");
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
finally {
|
|
824
|
+
clearTimeout(timer);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Call `PUT /jira/tickets/{ticketNumber}/jira-status` to transition the ticket's
|
|
829
|
+
* Jira status on truly-done. Uses `target_status: "auto"` for server-side resolution.
|
|
830
|
+
* Treats HTTP 400 (no matching transition / already in target) as a benign skip.
|
|
831
|
+
* Throws a sanitized {@link ConductorBridgeApiError} on auth/server/network failures.
|
|
832
|
+
*/
|
|
833
|
+
export async function transitionJiraStatus(access, ticketNumber, targetStatus = "auto", fetchImpl = globalThis.fetch) {
|
|
834
|
+
if (!ticketNumber) {
|
|
835
|
+
throw new ConductorBridgeApiError("invalid-input");
|
|
836
|
+
}
|
|
837
|
+
// repo_name travels in the JSON body only (TransitionJiraStatusRequest) — the
|
|
838
|
+
// backend ignores any query param, so don't duplicate it in the URL.
|
|
839
|
+
const url = buildConductorJiraUrl(access.baseUrl, `/tickets/${encodeURIComponent(ticketNumber)}/jira-status`);
|
|
840
|
+
const body = JSON.stringify({ repo_name: access.repoName, target_status: targetStatus });
|
|
841
|
+
try {
|
|
842
|
+
await fetchConductorJsonPutWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
843
|
+
return { status: "transitioned" };
|
|
844
|
+
}
|
|
845
|
+
catch (err) {
|
|
846
|
+
// HTTP 400 = no matching transition / already in target → benign skip.
|
|
847
|
+
if (err instanceof ConductorBridgeApiError && err.kind === "http" && err.status === 400) {
|
|
848
|
+
return { status: "skipped" };
|
|
849
|
+
}
|
|
850
|
+
throw err;
|
|
851
|
+
}
|
|
852
|
+
}
|
package/build/conductor/cli.js
CHANGED
|
@@ -528,7 +528,7 @@ export async function runDoctorCommand(argv) {
|
|
|
528
528
|
// scheduleDeps omitted: buildConductorDoctorReport lazily loads schedule-run.
|
|
529
529
|
const report = await buildConductorDoctorReport({});
|
|
530
530
|
if (bools.has("--json")) {
|
|
531
|
-
console.log(JSON.stringify({ ...report.ledger, git_hooks: report.git_hooks, epic_tick: report.epic_tick }));
|
|
531
|
+
console.log(JSON.stringify({ ...report.ledger, git_hooks: report.git_hooks, epic_tick: report.epic_tick, mcp_profile: report.mcp_profile }));
|
|
532
532
|
return 0;
|
|
533
533
|
}
|
|
534
534
|
console.log(formatConductorDoctorReport(report));
|
|
@@ -662,6 +662,24 @@ export function parseEpicTickArgs(argv) {
|
|
|
662
662
|
const leaseTtlSeconds = parsePositiveIntFlag(values, "--lease-ttl-seconds");
|
|
663
663
|
return { epicKey: epicKeyRaw.trim(), scheduledAt, leaseTtlSeconds, help: false };
|
|
664
664
|
}
|
|
665
|
+
/**
|
|
666
|
+
* Default the PreToolUse hook ON for epic-dispatched workers so the supervisor
|
|
667
|
+
* gets worker-liveness (`tool.intent`) signals without the operator having to
|
|
668
|
+
* export `BAPI_CONDUCTOR_ENABLE_PRE_TOOL_USE=1` (BAPI-441/A1 finding).
|
|
669
|
+
*
|
|
670
|
+
* Both the child-env copy and the hook registration key off this SAME
|
|
671
|
+
* parent-process env flag, read at dispatch time, so setting it once at the
|
|
672
|
+
* epic-tick boundary covers the whole tick. Only defaults when the flag is
|
|
673
|
+
* UNSET — an explicit "0"/"" still disables it, because `isConductorFlagEnabled`
|
|
674
|
+
* treats those as false. Mutates the supplied env in place (defaults to
|
|
675
|
+
* `process.env`) and returns it.
|
|
676
|
+
*/
|
|
677
|
+
export function applyEpicTickPreToolUseDefault(env = process.env) {
|
|
678
|
+
if (env.BAPI_CONDUCTOR_ENABLE_PRE_TOOL_USE === undefined) {
|
|
679
|
+
env.BAPI_CONDUCTOR_ENABLE_PRE_TOOL_USE = "1";
|
|
680
|
+
}
|
|
681
|
+
return env;
|
|
682
|
+
}
|
|
665
683
|
/**
|
|
666
684
|
* Run the `epic-tick` command. Lazily imports the epic runtime so a plain
|
|
667
685
|
* `conductor doctor` / `emit-event` invocation never eagerly resolves the
|
|
@@ -673,6 +691,9 @@ export async function runEpicTickCommand(argv) {
|
|
|
673
691
|
console.log(getConductorUsage());
|
|
674
692
|
return 0;
|
|
675
693
|
}
|
|
694
|
+
// Enable supervisor worker-liveness by default for the whole tick (see the
|
|
695
|
+
// helper's doc comment); set before runEpicTick dispatches any worker.
|
|
696
|
+
applyEpicTickPreToolUseDefault();
|
|
676
697
|
// `runEpicTick` is imported LAZILY so the store/supervisor graph is not
|
|
677
698
|
// evaluated for other conductor CLI commands that never need it.
|
|
678
699
|
const { runEpicTick, buildProductionEpicRuntimeDeps } = await import("./epic-runtime.js");
|
|
@@ -86,6 +86,25 @@ export async function inspectEpicTickSchedule(deps, orchestrateListOverride) {
|
|
|
86
86
|
};
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Inspect the BRIDGE_MCP_PROFILE environment variable and warn when a
|
|
91
|
+
* conductor/epic context is detected but the profile is not "conductor".
|
|
92
|
+
* Strictly read-only, never throws.
|
|
93
|
+
*/
|
|
94
|
+
export function inspectMcpProfile(env, epicTick) {
|
|
95
|
+
const resolved_profile = env.BRIDGE_MCP_PROFILE || "core";
|
|
96
|
+
const conductor_context_detected = env.BAPI_CONDUCTOR_ENABLED === "1" ||
|
|
97
|
+
env.BAPI_CONDUCTOR_ENABLED === "true" ||
|
|
98
|
+
epicTick.registered;
|
|
99
|
+
const degraded = conductor_context_detected && resolved_profile !== "conductor";
|
|
100
|
+
const warnings = [];
|
|
101
|
+
if (degraded) {
|
|
102
|
+
warnings.push(`BRIDGE_MCP_PROFILE is "${resolved_profile}" but a conductor context is active. ` +
|
|
103
|
+
`Expected "conductor" — conductor/event/supervisor tools may be missing. ` +
|
|
104
|
+
`Ensure the spawner injects BRIDGE_MCP_PROFILE=conductor into the worker shell environment.`);
|
|
105
|
+
}
|
|
106
|
+
return { resolved_profile, conductor_context_detected, degraded, warnings };
|
|
107
|
+
}
|
|
89
108
|
/**
|
|
90
109
|
* Build the combined read-only doctor report. Composes the existing ledger
|
|
91
110
|
* doctor, git hook inspection, and the epic-tick schedule enablement check.
|
|
@@ -95,10 +114,12 @@ export async function buildConductorDoctorReport(deps = {}) {
|
|
|
95
114
|
const doctorLedger = deps.doctorLedger ?? doctorConductorLedger;
|
|
96
115
|
const inspectHooks = deps.inspectHooks ?? inspectConductorGitHooks;
|
|
97
116
|
const epicTick = await inspectEpicTickSchedule(deps.scheduleDeps, deps.orchestrateList);
|
|
117
|
+
const mcp_profile = inspectMcpProfile(deps.env ?? process.env, epicTick);
|
|
98
118
|
return {
|
|
99
119
|
ledger: doctorLedger(),
|
|
100
120
|
git_hooks: inspectHooks(deps.hooksDeps),
|
|
101
121
|
epic_tick: epicTick,
|
|
122
|
+
mcp_profile,
|
|
102
123
|
};
|
|
103
124
|
}
|
|
104
125
|
/**
|
|
@@ -107,7 +128,7 @@ export async function buildConductorDoctorReport(deps = {}) {
|
|
|
107
128
|
* status tags consistent with the git hooks section's visual hierarchy.
|
|
108
129
|
*/
|
|
109
130
|
export function formatConductorDoctorReport(report) {
|
|
110
|
-
const { ledger, git_hooks, epic_tick } = report;
|
|
131
|
+
const { ledger, git_hooks, epic_tick, mcp_profile } = report;
|
|
111
132
|
const lines = [
|
|
112
133
|
"Conductor ledger doctor",
|
|
113
134
|
"───────────────────────",
|
|
@@ -160,5 +181,17 @@ export function formatConductorDoctorReport(report) {
|
|
|
160
181
|
for (const w of epic_tick.warnings)
|
|
161
182
|
lines.push(` - ${w}`);
|
|
162
183
|
}
|
|
184
|
+
lines.push("");
|
|
185
|
+
lines.push("MCP Profile (optional, local)");
|
|
186
|
+
lines.push("─────────────────────────────");
|
|
187
|
+
const profileTag = mcp_profile.degraded ? "[WARNING] degraded" : "[OK]";
|
|
188
|
+
lines.push(`resolved profile: ${mcp_profile.resolved_profile} ${profileTag}`);
|
|
189
|
+
lines.push(`conductor context: ${mcp_profile.conductor_context_detected}`);
|
|
190
|
+
lines.push(`degraded: ${mcp_profile.degraded}`);
|
|
191
|
+
if (mcp_profile.warnings.length > 0) {
|
|
192
|
+
lines.push("mcp profile warnings:");
|
|
193
|
+
for (const w of mcp_profile.warnings)
|
|
194
|
+
lines.push(` - ${w}`);
|
|
195
|
+
}
|
|
163
196
|
return lines.join("\n");
|
|
164
197
|
}
|