@bridge_gpt/mcp-server 0.2.65 → 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.
- package/README.md +25 -13
- package/build/agent-launchers/claude-executor-adapter.js +12 -18
- package/build/agent-launchers/executor-adapter.js +27 -0
- package/build/bridge-config.js +104 -0
- package/build/codex-bridge-profile.js +10 -1
- package/build/commands.generated.js +4 -4
- package/build/conductor/bridge-api-client.js +230 -10
- package/build/conductor/epic-child-continuation.js +61 -34
- package/build/conductor/epic-implementer-cli.js +12 -0
- package/build/conductor/guided-repair.js +323 -0
- package/build/conductor/readiness-cli.js +30 -9
- package/build/conductor/readiness.js +23 -1
- package/build/conductor/recovery-cli.js +507 -16
- package/build/conductor/recovery-operations.js +325 -2
- package/build/conductor/repair-contract.js +61 -3
- package/build/credential-store.js +5 -4
- package/build/diagnostic-text.js +36 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +70 -10
- package/build/drive-epic.js +395 -78
- package/build/epic-implementer-bin.js +24 -22
- package/build/epic-launch-diagnostic.js +31 -0
- package/build/epic-launch-profile.js +44 -14
- package/build/executor/completion-supervision.js +5 -0
- package/build/executor/correction-dispatch.js +60 -0
- package/build/executor/env.js +6 -0
- package/build/executor/http-client.js +15 -0
- package/build/executor/integration-job.js +6 -1
- package/build/executor/job-errors.js +13 -4
- package/build/executor/job-log-registry.js +340 -73
- package/build/executor/job-runner.js +260 -33
- package/build/executor/job-types.js +8 -13
- package/build/executor/preflight.js +91 -2
- package/build/executor/prompt-spec.js +113 -9
- package/build/executor/runner.js +23 -0
- package/build/executor/stale-artifacts.js +5 -4
- package/build/executor/types.js +1 -0
- package/build/executor/watch-cli.js +35 -8
- package/build/executor/worker-command.js +43 -43
- package/build/executor/worker-finalization.js +80 -15
- package/build/executor/worker-guard-hook.js +46 -10
- package/build/executor/worker-log.js +137 -70
- package/build/executor/worktree-gc.js +30 -6
- package/build/index.js +177 -329
- package/build/init.js +107 -4
- package/build/install-bridge.js +284 -20
- package/build/install-doctor.js +172 -30
- package/build/install-reexec.js +69 -7
- package/build/macos-terminal-permission-guidance.js +16 -0
- package/build/model-thresholds-tool.js +185 -0
- package/build/pipelines.generated.js +1 -1
- package/build/plane/status.js +4 -0
- package/build/plane/supervisor.js +4 -0
- package/build/plane/worker-exit-code.js +2 -0
- package/build/project-root.js +99 -0
- package/build/readme.generated.js +1 -1
- package/build/review-tickets.js +156 -13
- package/build/setup-epic.js +180 -108
- package/build/sfcc/setup-status.js +2 -0
- package/build/sfcc/tool-wrapper.js +2 -0
- package/build/sfcc/tools-configured.js +96 -0
- package/build/start-tickets.js +163 -58
- package/build/upgrade-cli.js +109 -24
- package/build/version.generated.js +3 -3
- package/build/windows-npm-entry.js +103 -0
- package/build/worker-guard-hook-bin.js +4 -3
- package/build/worktree-core.js +6 -5
- package/docs/install/mcp-tool-integrations.md +1 -0
- package/docs/install/sfcc-integration.md +12 -7
- package/package.json +1 -1
- package/pipelines/implement-ticket.json +1 -1
|
@@ -18,10 +18,31 @@ import { normalizePrNumber, normalizeSha } from "./git-ci-types.js";
|
|
|
18
18
|
import { ConductorValidationError } from "./errors.js";
|
|
19
19
|
import { parseContinuationStatusResult, parseContinueParkedChildResult, } from "./epic-child-continuation.js";
|
|
20
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";
|
|
21
24
|
/** Default Bridge API base URL when `BAPI_BASE_URL` is unset. */
|
|
22
25
|
export const CONDUCTOR_DEFAULT_BASE_URL = "https://bridgegpt-api.com";
|
|
23
26
|
/** Default per-request timeout for conductor Bridge API calls. */
|
|
24
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
|
+
}
|
|
25
46
|
/**
|
|
26
47
|
* Resolve `{ repoName, apiKey, baseUrl }` from the environment, the shared repo
|
|
27
48
|
* resolver, and the shared credential store. Never throws and never embeds the
|
|
@@ -91,9 +112,11 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
|
|
|
91
112
|
const platform = deps.platform ?? process.platform;
|
|
92
113
|
const readFileImpl = deps.readFile ?? ((p) => readFile(p, "utf-8"));
|
|
93
114
|
const statImpl = deps.stat ?? ((p) => stat(p));
|
|
115
|
+
const environmentProfile = env.BAPI_EPIC_LAUNCH_PROFILE?.trim();
|
|
116
|
+
const profileName = selection.profileName ?? (environmentProfile || undefined);
|
|
94
117
|
let profile = null;
|
|
95
|
-
if (
|
|
96
|
-
const read = await readEpicLaunchProfile(
|
|
118
|
+
if (profileName !== undefined) {
|
|
119
|
+
const read = await readEpicLaunchProfile(profileName, {
|
|
97
120
|
env,
|
|
98
121
|
homedir,
|
|
99
122
|
readFile: readFileImpl,
|
|
@@ -107,11 +130,13 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
|
|
|
107
130
|
flagBaseUrl: selection.baseUrl,
|
|
108
131
|
flagRepo: deps.repoName,
|
|
109
132
|
profile,
|
|
133
|
+
profileSource: selection.profileName !== undefined ? "profile" : profile ? "profile-env" : undefined,
|
|
110
134
|
env,
|
|
111
135
|
defaultBaseUrl: CONDUCTOR_DEFAULT_BASE_URL,
|
|
112
136
|
});
|
|
113
137
|
if (!target.ok)
|
|
114
138
|
return { ok: false, kind: target.kind, error: target.message };
|
|
139
|
+
const reportableTarget = safeEpicLaunchTargetUrl(target.baseUrl) ? { baseUrl: target.baseUrl } : {};
|
|
115
140
|
const repoName = target.repoName ||
|
|
116
141
|
deps.repoName?.trim() ||
|
|
117
142
|
(await resolveStartTicketsRepoName({ env, cwd, readFile: readFileImpl }));
|
|
@@ -120,6 +145,7 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
|
|
|
120
145
|
ok: false,
|
|
121
146
|
kind: "repo-missing",
|
|
122
147
|
error: "could not resolve repo name (set BAPI_REPO_NAME or add a valid .bridge/config)",
|
|
148
|
+
...reportableTarget,
|
|
123
149
|
};
|
|
124
150
|
}
|
|
125
151
|
let cred;
|
|
@@ -127,15 +153,15 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
|
|
|
127
153
|
cred = await resolveEpicLaunchCredential(repoName, target.baseUrl, { env, homedir, platform, readFile: readFileImpl, stat: statImpl }, { productionBaseUrl: CONDUCTOR_DEFAULT_BASE_URL });
|
|
128
154
|
}
|
|
129
155
|
catch {
|
|
130
|
-
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 };
|
|
131
157
|
}
|
|
132
158
|
if (!cred.ok) {
|
|
133
159
|
const bindingFailure = cred.kind === "binding-unbound" || cred.kind === "binding-mismatch" || cred.kind === "ambient-key-unbound";
|
|
134
160
|
// The binding messages are fixed sentences naming the target KEY and the
|
|
135
161
|
// store path only; the generic store failures stay generic as before.
|
|
136
162
|
return bindingFailure
|
|
137
|
-
? { ok: false, kind: "credential-unbound", error: cred.error }
|
|
138
|
-
: { 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 };
|
|
139
165
|
}
|
|
140
166
|
return {
|
|
141
167
|
ok: true,
|
|
@@ -1478,6 +1504,9 @@ async function postRepairRequest(access, apiPath, body, fetchImpl) {
|
|
|
1478
1504
|
// The server's refusal explanation is the operator-facing message here.
|
|
1479
1505
|
{ readErrorField: true });
|
|
1480
1506
|
}
|
|
1507
|
+
function withGuard(body, guard) {
|
|
1508
|
+
return guard === undefined ? body : { ...body, guard };
|
|
1509
|
+
}
|
|
1481
1510
|
/**
|
|
1482
1511
|
* POST `/jira/epic-runs/runs/{epic_run_id}/scope-reparse` — re-drive the run's
|
|
1483
1512
|
* OWN index scope through the retry-reparse lifecycle. Body: `{repo_name,
|
|
@@ -1487,7 +1516,7 @@ async function postRepairRequest(access, apiPath, body, fetchImpl) {
|
|
|
1487
1516
|
export async function scopeReparse(access, request, fetchImpl = globalThis.fetch) {
|
|
1488
1517
|
requireNonEmptyString(request.epicRunId);
|
|
1489
1518
|
requireNonEmptyString(request.scopeId);
|
|
1490
|
-
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);
|
|
1491
1520
|
return parseScopeReparseResult(parsed);
|
|
1492
1521
|
}
|
|
1493
1522
|
/**
|
|
@@ -1500,7 +1529,7 @@ export async function dispatchRemediation(access, request, fetchImpl = globalThi
|
|
|
1500
1529
|
requireNonEmptyString(request.epicRunId);
|
|
1501
1530
|
requireNonEmptyString(request.ticketKey);
|
|
1502
1531
|
requireNonEmptyString(request.context);
|
|
1503
|
-
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);
|
|
1504
1533
|
return parseDispatchRemediationResult(parsed);
|
|
1505
1534
|
}
|
|
1506
1535
|
/**
|
|
@@ -1662,7 +1691,7 @@ function parseResumeRunResult(parsed) {
|
|
|
1662
1691
|
export async function scopeCatchUp(access, request, fetchImpl = globalThis.fetch) {
|
|
1663
1692
|
requireNonEmptyString(request.epicRunId);
|
|
1664
1693
|
requireNonEmptyString(request.scopeId);
|
|
1665
|
-
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);
|
|
1666
1695
|
return parseScopeCatchUpResult(parsed);
|
|
1667
1696
|
}
|
|
1668
1697
|
/**
|
|
@@ -1682,7 +1711,7 @@ export async function patchPolicy(access, request, fetchImpl = globalThis.fetch)
|
|
|
1682
1711
|
Object.keys(patch).length === 0) {
|
|
1683
1712
|
throw new ConductorBridgeApiError("invalid-input");
|
|
1684
1713
|
}
|
|
1685
|
-
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);
|
|
1686
1715
|
return parsePatchPolicyResult(parsed);
|
|
1687
1716
|
}
|
|
1688
1717
|
/**
|
|
@@ -1695,7 +1724,7 @@ export async function resumeRun(access, request, fetchImpl = globalThis.fetch) {
|
|
|
1695
1724
|
if (typeof request.clearQuarantine !== "boolean") {
|
|
1696
1725
|
throw new ConductorBridgeApiError("invalid-input");
|
|
1697
1726
|
}
|
|
1698
|
-
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);
|
|
1699
1728
|
return parseResumeRunResult(parsed);
|
|
1700
1729
|
}
|
|
1701
1730
|
/**
|
|
@@ -2678,8 +2707,29 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2678
2707
|
const evidence = parseAdmissionEvidence(root);
|
|
2679
2708
|
return evidence === undefined ? {} : { admission_evidence: evidence };
|
|
2680
2709
|
})(),
|
|
2710
|
+
...(() => {
|
|
2711
|
+
const diagnostics = parseReadinessDiagnostics(root);
|
|
2712
|
+
return diagnostics === undefined ? {} : { diagnostics };
|
|
2713
|
+
})(),
|
|
2681
2714
|
};
|
|
2682
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
|
+
}
|
|
2683
2733
|
const ADMISSION_EVIDENCE_OWNERS = new Set(["operator", "repository_admin", "bridge_operations"]);
|
|
2684
2734
|
const BOUNDED_TOKEN = /^[a-z0-9][a-z0-9_.:-]{0,79}$/;
|
|
2685
2735
|
/**
|
|
@@ -3086,3 +3136,173 @@ export function classifySupervisorBootstrapFailure(err) {
|
|
|
3086
3136
|
return "server";
|
|
3087
3137
|
return "rejected";
|
|
3088
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
|
+
}
|
|
@@ -222,21 +222,74 @@ export function parseContinuationStatusResult(value) {
|
|
|
222
222
|
receipt,
|
|
223
223
|
};
|
|
224
224
|
}
|
|
225
|
-
/**
|
|
226
|
-
export
|
|
225
|
+
/** The namespace (subdirectory) of the parked-child continuation references. */
|
|
226
|
+
export const CONTINUATION_REFERENCE_NAMESPACE = "epic-child-continuations";
|
|
227
|
+
/**
|
|
228
|
+
* Directory holding one namespace of recovery references, beside the user
|
|
229
|
+
* credential store. BAPI-1326's `resume-ci` keeps its pinned operations in its
|
|
230
|
+
* own namespace through this same store, so the two recoveries share one file
|
|
231
|
+
* format, one permission model, and one atomic-write path.
|
|
232
|
+
*/
|
|
233
|
+
export function getRecoveryReferenceDirectory(namespace, deps = {}) {
|
|
227
234
|
const credentials = getPrimaryCredentialStorePath({
|
|
228
235
|
env: deps.env ?? process.env,
|
|
229
236
|
homedir: deps.homedir ?? os.homedir,
|
|
230
237
|
});
|
|
231
|
-
return path.join(path.dirname(credentials),
|
|
238
|
+
return path.join(path.dirname(credentials), namespace);
|
|
239
|
+
}
|
|
240
|
+
/** Directory holding continuation recovery references. */
|
|
241
|
+
export function getContinuationReferenceDirectory(deps = {}) {
|
|
242
|
+
return getRecoveryReferenceDirectory(CONTINUATION_REFERENCE_NAMESPACE, deps);
|
|
232
243
|
}
|
|
233
244
|
function referenceFileName(epicRunId, ticketKey) {
|
|
234
245
|
// Hashed so an arbitrary run id or ticket key can never form a path segment.
|
|
235
246
|
const digest = createHash("sha256").update(`${epicRunId}\u0000${ticketKey}`, "utf8").digest("hex");
|
|
236
247
|
return `${digest.slice(0, 32)}.json`;
|
|
237
248
|
}
|
|
249
|
+
/** The file one (run, ticket) reference occupies within a namespace. */
|
|
250
|
+
export function getRecoveryReferencePath(namespace, epicRunId, ticketKey, deps = {}) {
|
|
251
|
+
return path.join(getRecoveryReferenceDirectory(namespace, deps), referenceFileName(epicRunId, ticketKey));
|
|
252
|
+
}
|
|
238
253
|
export function getContinuationReferencePath(epicRunId, ticketKey, deps = {}) {
|
|
239
|
-
return
|
|
254
|
+
return getRecoveryReferencePath(CONTINUATION_REFERENCE_NAMESPACE, epicRunId, ticketKey, deps);
|
|
255
|
+
}
|
|
256
|
+
/** Read one namespaced reference as parsed JSON, or `null` when absent or unreadable. */
|
|
257
|
+
export async function readRecoveryReferenceFile(namespace, key, deps = {}) {
|
|
258
|
+
const read = deps.readFile ?? fsReadFile;
|
|
259
|
+
let text;
|
|
260
|
+
try {
|
|
261
|
+
text = (await read(getRecoveryReferencePath(namespace, key.epicRunId, key.ticketKey, deps), "utf8"));
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
return JSON.parse(text);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Durably write one namespaced reference (0700 directory, 0600 file, write-then-rename). */
|
|
274
|
+
export async function writeRecoveryReferenceFile(namespace, key, content, deps = {}) {
|
|
275
|
+
const mkdir = deps.mkdir ?? fsMkdir;
|
|
276
|
+
const writeFile = deps.writeFile ?? fsWriteFile;
|
|
277
|
+
const rename = deps.rename ?? fsRename;
|
|
278
|
+
await mkdir(getRecoveryReferenceDirectory(namespace, deps), { recursive: true, mode: 0o700 });
|
|
279
|
+
const target = getRecoveryReferencePath(namespace, key.epicRunId, key.ticketKey, deps);
|
|
280
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
281
|
+
await writeFile(temp, `${JSON.stringify(content, null, 2)}\n`, { mode: 0o600 });
|
|
282
|
+
await rename(temp, target);
|
|
283
|
+
}
|
|
284
|
+
/** Remove one namespaced reference. Never throws. */
|
|
285
|
+
export async function clearRecoveryReferenceFile(namespace, key, deps = {}) {
|
|
286
|
+
const unlink = deps.unlink ?? fsUnlink;
|
|
287
|
+
try {
|
|
288
|
+
await unlink(getRecoveryReferencePath(namespace, key.epicRunId, key.ticketKey, deps));
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Already gone — nothing to clear.
|
|
292
|
+
}
|
|
240
293
|
}
|
|
241
294
|
function parseReference(raw) {
|
|
242
295
|
if (!isRecord(raw) || raw.version !== 1 || !isRecord(raw.bindings))
|
|
@@ -276,44 +329,18 @@ function parseReference(raw) {
|
|
|
276
329
|
}
|
|
277
330
|
/** Read the in-flight reference for a child, or `null` when none (or unusable). */
|
|
278
331
|
export async function readContinuationReference(epicRunId, ticketKey, deps = {}) {
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
try {
|
|
282
|
-
text = (await read(getContinuationReferencePath(epicRunId, ticketKey, deps), "utf8"));
|
|
283
|
-
}
|
|
284
|
-
catch {
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
try {
|
|
288
|
-
return parseReference(JSON.parse(text));
|
|
289
|
-
}
|
|
290
|
-
catch {
|
|
291
|
-
return null;
|
|
292
|
-
}
|
|
332
|
+
const raw = await readRecoveryReferenceFile(CONTINUATION_REFERENCE_NAMESPACE, { epicRunId, ticketKey }, deps);
|
|
333
|
+
return raw === null ? null : parseReference(raw);
|
|
293
334
|
}
|
|
294
335
|
/** Durably write a reference (0700 directory, 0600 file, write-then-rename). */
|
|
295
336
|
export async function writeContinuationReference(reference, deps = {}, key = reference.bindings) {
|
|
296
|
-
const mkdir = deps.mkdir ?? fsMkdir;
|
|
297
|
-
const writeFile = deps.writeFile ?? fsWriteFile;
|
|
298
|
-
const rename = deps.rename ?? fsRename;
|
|
299
|
-
const dir = getContinuationReferenceDirectory(deps);
|
|
300
|
-
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
301
337
|
// ``key`` only names the file (the caller may address the run by its epic key);
|
|
302
338
|
// the stored bindings are always the exact, fingerprinted ones.
|
|
303
|
-
|
|
304
|
-
const temp = `${target}.${process.pid}.tmp`;
|
|
305
|
-
await writeFile(temp, `${JSON.stringify(reference, null, 2)}\n`, { mode: 0o600 });
|
|
306
|
-
await rename(temp, target);
|
|
339
|
+
await writeRecoveryReferenceFile(CONTINUATION_REFERENCE_NAMESPACE, key, reference, deps);
|
|
307
340
|
}
|
|
308
341
|
/** Remove a child's reference once its outcome is authoritative. Never throws. */
|
|
309
342
|
export async function clearContinuationReference(epicRunId, ticketKey, deps = {}) {
|
|
310
|
-
|
|
311
|
-
try {
|
|
312
|
-
await unlink(getContinuationReferencePath(epicRunId, ticketKey, deps));
|
|
313
|
-
}
|
|
314
|
-
catch {
|
|
315
|
-
// Already gone — nothing to clear.
|
|
316
|
-
}
|
|
343
|
+
await clearRecoveryReferenceFile(CONTINUATION_REFERENCE_NAMESPACE, { epicRunId, ticketKey }, deps);
|
|
317
344
|
}
|
|
318
345
|
/** A fresh request id: a UUID, which satisfies the server's request-id bound. */
|
|
319
346
|
export function newContinuationRequestId() {
|
|
@@ -112,6 +112,9 @@ export function getConductorUsage() {
|
|
|
112
112
|
" continuation-receipt --epic-run-id <id> --ticket-key <key> [--request-id <id>] [--json]",
|
|
113
113
|
" Read-only: the child's current episode and whether a continuation",
|
|
114
114
|
" request's receipt proves it committed (use after a lost response).",
|
|
115
|
+
" resume-ci --epic-run-id <id> --ticket-key <key> [--json]",
|
|
116
|
+
" Resume a child parked on a qualified CI stop once CI is green: CI",
|
|
117
|
+
" returns to evaluation with NO new wait, job, or repair allowance.",
|
|
115
118
|
"",
|
|
116
119
|
"supervise options:",
|
|
117
120
|
" --run-id <id> Run/session identifier to supervise (required)",
|
|
@@ -254,6 +257,8 @@ export function getConductorUsage() {
|
|
|
254
257
|
" epic-implementer continue-child --epic-run-id 3f9c2b7e-... --ticket-key BAPI-852",
|
|
255
258
|
" # The continue-child response was lost — did it commit?",
|
|
256
259
|
" epic-implementer continuation-receipt --epic-run-id 3f9c2b7e-... --ticket-key BAPI-852",
|
|
260
|
+
" # CI finished green after the child parked on an expired CI wait:",
|
|
261
|
+
" epic-implementer resume-ci --epic-run-id 3f9c2b7e-... --ticket-key BAPI-852",
|
|
257
262
|
].join("\n");
|
|
258
263
|
}
|
|
259
264
|
/**
|
|
@@ -305,6 +310,8 @@ const VALID_COMMANDS = new Set([
|
|
|
305
310
|
// BAPI-1300: CLI-only parked-child continuation pair — never registered as MCP tools.
|
|
306
311
|
"continue-child",
|
|
307
312
|
"continuation-receipt",
|
|
313
|
+
// BAPI-1326: CLI-only allowance-preserving CI resume — never an MCP tool.
|
|
314
|
+
"resume-ci",
|
|
308
315
|
// Private, and deliberately ABSENT from the usage text: `__hook-bin` exists as
|
|
309
316
|
// the bundled-artifact regression guard for BAPI-772 (mirroring `plane
|
|
310
317
|
// __entrypoint`), not as a supported operator workflow.
|
|
@@ -1439,6 +1446,11 @@ export async function runEpicImplementerCli(argv) {
|
|
|
1439
1446
|
const { runContinuationReceiptCommand } = await import("./recovery-cli.js");
|
|
1440
1447
|
return await runContinuationReceiptCommand(parsed.argv);
|
|
1441
1448
|
}
|
|
1449
|
+
case "resume-ci": {
|
|
1450
|
+
// BAPI-1326: delegates only to the pinned CI recovery operation.
|
|
1451
|
+
const { runResumeCiCommand } = await import("./recovery-cli.js");
|
|
1452
|
+
return await runResumeCiCommand(parsed.argv);
|
|
1453
|
+
}
|
|
1442
1454
|
default:
|
|
1443
1455
|
console.error('Error: Unknown command. Run "epic-implementer --help" for usage.');
|
|
1444
1456
|
return 1;
|