@bridge_gpt/mcp-server 0.2.53 → 0.2.55
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 +116 -56
- package/build/agent-guidance.generated.js +3 -0
- package/build/agent-launchers/claude-executor-adapter.js +3 -0
- package/build/agent-launchers/claude.js +3 -3
- package/build/agent-launchers/prompt.js +8 -11
- package/build/agent-notes.js +178 -0
- package/build/agent-registry.js +5 -2
- package/build/agent-utils.js +58 -0
- package/build/agents.generated.js +1 -1
- package/build/base-ref.js +33 -9
- package/build/bounded-wait.js +174 -0
- package/build/codex-skill-adapter.js +55 -0
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +235 -14
- package/build/conductor/bring-up-facts.js +187 -0
- package/build/conductor/claude-hook.js +7 -5
- package/build/conductor/cli.js +28 -0
- package/build/conductor/doctor.js +80 -9
- package/build/conductor/epic-implementer-cli.js +1298 -0
- package/build/conductor/epic-runtime.js +134 -98
- package/build/conductor/errors.js +2 -2
- package/build/conductor/git-ci-types.js +1 -1
- package/build/conductor/git-hooks.js +28 -14
- package/build/conductor/install-doctor.js +11 -5
- package/build/conductor/readiness-cli.js +10 -10
- package/build/conductor/readiness-sections.js +58 -9
- package/build/conductor/readiness.js +120 -4
- package/build/conductor/recovery-cli.js +289 -10
- package/build/conductor/recovery-operations.js +125 -2
- package/build/conductor/repair-contract.js +58 -0
- package/build/conductor/run-branch.js +137 -0
- package/build/conductor/store.js +2 -2
- package/build/conductor/supervisor-runtime.js +1 -1
- package/build/conductor/test-run-branch-vectors.js +165 -0
- package/build/conductor-bin.js +2 -139
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
- package/build/conductor-removed-stub-bin.js +30 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +145 -18
- package/build/drive-epic.js +752 -90
- package/build/epic-implementer-bin.js +145 -0
- package/build/epic-implementer-bundle-cli.js +264 -0
- package/build/epic-implementer-claude-hook-bin.js +3 -0
- package/build/epic-integration-pr.js +5 -3
- package/build/executor/claim-scope.js +104 -0
- package/build/executor/cli.js +14 -25
- package/build/executor/env-file-guard.js +82 -3
- package/build/executor/env.js +6 -0
- package/build/executor/interrupted-worktree.js +60 -0
- package/build/executor/job-errors.js +45 -0
- package/build/executor/job-runner.js +334 -9
- package/build/executor/job-types.js +25 -9
- package/build/executor/merge-tree-classifier.js +171 -0
- package/build/executor/reconcile-overlap-governance.js +129 -0
- package/build/executor/reconcile-overlap-job.js +989 -0
- package/build/executor/reconcile-overlap-types.js +14 -0
- package/build/executor/spawn-job-driver.js +1 -0
- package/build/executor/types.js +2 -0
- package/build/executor/worker-finalization.js +25 -2
- package/build/executor/worker-guard-hook.js +15 -7
- package/build/implement-epic/bridge-client.js +773 -0
- package/build/implement-epic/checkpoint-store.js +542 -0
- package/build/implement-epic/cli.js +3158 -0
- package/build/implement-epic/cut-protocol.js +392 -0
- package/build/implement-epic/lock.js +302 -0
- package/build/implement-epic/pr-state.js +286 -0
- package/build/implement-epic/spawn.js +113 -0
- package/build/index.js +657 -481
- package/build/init.js +72 -8
- package/build/install-bridge-conductor.js +5 -5
- package/build/install-bridge.js +403 -70
- package/build/local-artifact-storage.js +130 -0
- package/build/mcp-host-config.js +22 -60
- package/build/mcp-host-entry-adapter.js +18 -0
- package/build/mcp-host-targets.js +1 -21
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +23 -16
- package/build/plan-epic-conductor-eligibility.js +1 -1
- package/build/plane/cli.js +321 -41
- package/build/plane/manifest.js +209 -1
- package/build/plane/member-roster.js +70 -0
- package/build/plane/preflight.js +128 -12
- package/build/plane/shutdown.js +18 -5
- package/build/plane/status.js +35 -1
- package/build/plane/supervisor.js +546 -164
- package/build/plane/types.js +25 -2
- package/build/polling-policy.js +72 -0
- package/build/readiness-check.js +3 -3
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +219 -0
- package/build/run-unit-tests-launcher.js +6 -1
- package/build/setup-epic.js +567 -38
- package/build/start-tickets-conductor.js +8 -7
- package/build/ticket-key-utils.js +4 -3
- package/build/ticket-review-artifact-gate.js +461 -0
- package/build/upgrade-cli.js +5 -26
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +1 -1
- package/docs/CONDUCTOR.md +8 -6
- package/docs/install/mcp-tool-integrations.md +23 -1
- package/package.json +5 -3
- package/pipelines/review-ticket.json +17 -4
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge API client for the `implement-epic` CLI (BAPI-802).
|
|
3
|
+
*
|
|
4
|
+
* This module lives OUTSIDE `mcp_server/src/conductor/`, which is frozen v1. It
|
|
5
|
+
* imports from `conductor/bridge-api-client.ts` in the reuse-only direction the
|
|
6
|
+
* freeze allows — shared URL construction, the access type, and the timeout/
|
|
7
|
+
* sanitization fetch helpers — and adds nothing under `conductor/`. That import
|
|
8
|
+
* is recorded in `conductor/README.md`'s survivor table.
|
|
9
|
+
*
|
|
10
|
+
* Two contract properties, fixed by `implement-epic-bridge-client` and relied on by
|
|
11
|
+
* BAPI-803, which extends this same file:
|
|
12
|
+
*
|
|
13
|
+
* - **No function throws.** Every failure — transport, timeout, HTTP status,
|
|
14
|
+
* malformed body, even a bad `baseUrl` — resolves to
|
|
15
|
+
* `{ ok: false, status, error }`. A CLI that has to wrap each call in
|
|
16
|
+
* `try/catch` gets that wrong exactly once, in the recovery path where it
|
|
17
|
+
* matters most.
|
|
18
|
+
* - **The API key never leaves the `X-API-Key` header.** It is not in a URL, a
|
|
19
|
+
* query string, a request body, or any returned error text. Unrecognized
|
|
20
|
+
* throw values collapse to a stable generic message rather than being
|
|
21
|
+
* stringified, because a stringified unknown is exactly how a credential
|
|
22
|
+
* escapes.
|
|
23
|
+
*
|
|
24
|
+
* Credentials are NOT resolved here: the caller passes an access object it
|
|
25
|
+
* already obtained from `resolveConductorBridgeApiAccess()`, which reads
|
|
26
|
+
* `BAPI_API_KEY` then the user-scoped credential store.
|
|
27
|
+
*
|
|
28
|
+
* No MCP tool is registered from this module — the `tools/list` token budget is
|
|
29
|
+
* effectively frozen.
|
|
30
|
+
*/
|
|
31
|
+
import { buildConductorJiraUrl, buildConductorVcsUrl, fetchConductorJsonPostWithTimeout, fetchConductorJsonPutWithTimeout, fetchConductorJsonWithTimeout, fetchConductorReadiness, fetchEffectiveSupervisorConfig, fetchEffectiveSupervisorSetup, fetchEpicRunState, fetchParseStatus, fetchPrReviewStatus, pollCiChecksForCommit, ConductorBridgeApiError, CONDUCTOR_FETCH_TIMEOUT_MS, } from "../conductor/bridge-api-client.js";
|
|
32
|
+
/**
|
|
33
|
+
* The one message returned for any failure whose cause cannot be described
|
|
34
|
+
* without risking the credential. Stable so callers can match on it.
|
|
35
|
+
*/
|
|
36
|
+
const GENERIC_ERROR = "Bridge API request failed";
|
|
37
|
+
/** GET headers. The key travels in this header and nowhere else. */
|
|
38
|
+
function getHeaders(access) {
|
|
39
|
+
return { "X-API-Key": access.apiKey };
|
|
40
|
+
}
|
|
41
|
+
/** POST headers. Same rule, plus the JSON content type. */
|
|
42
|
+
function postHeaders(access) {
|
|
43
|
+
return { "X-API-Key": access.apiKey, "Content-Type": "application/json" };
|
|
44
|
+
}
|
|
45
|
+
/** PUT headers. Identical to {@link postHeaders}; named for the call site. */
|
|
46
|
+
function putHeaders(access) {
|
|
47
|
+
return { "X-API-Key": access.apiKey, "Content-Type": "application/json" };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Remove the exact API key from a message that is otherwise safe to surface.
|
|
51
|
+
*
|
|
52
|
+
* The shared helpers already redact header values from their diagnostics, so
|
|
53
|
+
* this is a second, independent pass over the final string: the belt is cheap
|
|
54
|
+
* and the failure it guards against is unrecoverable. Very short keys are left
|
|
55
|
+
* alone because scrubbing a 1-3 character string would mangle ordinary prose.
|
|
56
|
+
*/
|
|
57
|
+
function scrubKey(text, apiKey) {
|
|
58
|
+
if (!apiKey || apiKey.length < 4)
|
|
59
|
+
return text;
|
|
60
|
+
return text.split(apiKey).join("[REDACTED]");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Convert any thrown value into a `{ ok: false }` result.
|
|
64
|
+
*
|
|
65
|
+
* A `ConductorBridgeApiError` carries a message the shared helper already built
|
|
66
|
+
* from the coarse kind, HTTP status, backend error code, and a bounded,
|
|
67
|
+
* secret-redacted body preview — that is what lets a 409 name the override
|
|
68
|
+
* branch the server preserved. Anything else is collapsed to
|
|
69
|
+
* {@link GENERIC_ERROR}: an unknown throw value has unknown contents, and
|
|
70
|
+
* stringifying it is how a credential or a raw response body escapes.
|
|
71
|
+
*/
|
|
72
|
+
function toFailure(err, access) {
|
|
73
|
+
if (err instanceof ConductorBridgeApiError) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
status: typeof err.status === "number" ? err.status : null,
|
|
77
|
+
error: scrubKey(err.message || GENERIC_ERROR, access.apiKey),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
81
|
+
}
|
|
82
|
+
/** True when `value` is a plain (non-array, non-null) object. */
|
|
83
|
+
function isRecord(value) {
|
|
84
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
85
|
+
}
|
|
86
|
+
/** Read a `string | null` field, returning `undefined` when it is neither. */
|
|
87
|
+
function nullableString(value) {
|
|
88
|
+
if (value === null)
|
|
89
|
+
return null;
|
|
90
|
+
if (typeof value === "string")
|
|
91
|
+
return value;
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Run `operation` and convert any thrown value into a sanitized failure result.
|
|
96
|
+
*
|
|
97
|
+
* Every +803 read is this shape, so the try/catch lives in one place: a helper
|
|
98
|
+
* added later cannot forget it and reintroduce a throwing client.
|
|
99
|
+
*/
|
|
100
|
+
async function wrap(access, operation) {
|
|
101
|
+
try {
|
|
102
|
+
return { ok: true, value: await operation() };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return toFailure(err, access);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* `PUT /jira/epic-runs/supervisor-config/defaults/?repo_name=` — replace the
|
|
110
|
+
* project-default supervisor configuration.
|
|
111
|
+
*
|
|
112
|
+
* The complete {@link EpicSupervisorConfigRequest} is sent. `init` builds it by
|
|
113
|
+
* reading the effective config and flipping exactly one field, so enabling
|
|
114
|
+
* auto-merge never silently reverts another operator setting.
|
|
115
|
+
*/
|
|
116
|
+
export async function putSupervisorConfigDefaults(access, config, fetchImpl = globalThis.fetch) {
|
|
117
|
+
return wrap(access, () => {
|
|
118
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/epic-runs/supervisor-config/defaults/", { repo_name: access.repoName });
|
|
119
|
+
return fetchConductorJsonPutWithTimeout(url, putHeaders(access), JSON.stringify(config), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** `GET /jira/epic-runs/supervisor-config/defaults/` — the effective config. */
|
|
123
|
+
export async function getEffectiveSupervisorConfig(access, fetchImpl = globalThis.fetch) {
|
|
124
|
+
return wrap(access, () => fetchEffectiveSupervisorConfig(access, undefined, fetchImpl));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* `GET /jira/epic-runs/supervisor-setup/defaults/` — the effective setup.
|
|
128
|
+
*
|
|
129
|
+
* Returned verbatim, `done_gate_config` included and un-normalized: it is the
|
|
130
|
+
* raw operator-authored JSONB that `parseDoneGateConfig` is the sole authority
|
|
131
|
+
* on, and normalizing it here would fork that vocabulary.
|
|
132
|
+
*/
|
|
133
|
+
export async function getEffectiveSupervisorSetup(access, fetchImpl = globalThis.fetch) {
|
|
134
|
+
return wrap(access, () => fetchEffectiveSupervisorSetup(access, undefined, fetchImpl));
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* `GET /jira/epic-runs/conductor-readiness?repo_name=` — readiness facts.
|
|
138
|
+
*
|
|
139
|
+
* `init` reads three of them: `supervisor.auto_merge_enabled`,
|
|
140
|
+
* `supervisor.required_checks_count`, and `supervisor.required_checks_empty`.
|
|
141
|
+
* The underlying fetcher validates the body fail-closed, so a 200 whose body is
|
|
142
|
+
* missing `required_checks_empty` becomes a failure here rather than an
|
|
143
|
+
* apparently-healthy report.
|
|
144
|
+
*/
|
|
145
|
+
export async function getConductorReadiness(access, fetchImpl = globalThis.fetch) {
|
|
146
|
+
return wrap(access, () => fetchConductorReadiness(access, fetchImpl));
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* `POST /jira/resolve-ci-checks` — discover and classify the repository's CI
|
|
150
|
+
* checks, and warm the `poll-ci-checks` cache as a side effect.
|
|
151
|
+
*
|
|
152
|
+
* `commitRef` is optional and forwarded only when supplied. The response is the
|
|
153
|
+
* server's soft envelope, preserved verbatim inside `value`.
|
|
154
|
+
*/
|
|
155
|
+
export async function resolveCiChecks(access, commitRef, fetchImpl = globalThis.fetch) {
|
|
156
|
+
return wrap(access, () => {
|
|
157
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/resolve-ci-checks");
|
|
158
|
+
const payload = { repo_name: access.repoName };
|
|
159
|
+
if (typeof commitRef === "string" && commitRef.trim().length > 0) {
|
|
160
|
+
payload.commit_ref = commitRef;
|
|
161
|
+
}
|
|
162
|
+
return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify(payload), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* `GET /jira/poll-ci-checks?repo_name=&commit_ref=` — CI status for one SHA.
|
|
167
|
+
*
|
|
168
|
+
* Until `resolve-ci-checks` has run once for the repository this answers 200
|
|
169
|
+
* with `{ available: false, action: "Call resolve-ci-checks first…" }`. That is
|
|
170
|
+
* a successful result carrying an instruction, not an error, and it stays in
|
|
171
|
+
* `value` so `status` can act on it.
|
|
172
|
+
*/
|
|
173
|
+
export async function pollCiChecks(access, commitRef, fetchImpl = globalThis.fetch) {
|
|
174
|
+
return wrap(access, () => pollCiChecksForCommit(access, commitRef, fetchImpl));
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* `GET /vcs/pull-requests/{n}/reviews/status?repo_name=` — normalized review
|
|
178
|
+
* state. Root-mounted, NOT under `/jira`.
|
|
179
|
+
*
|
|
180
|
+
* `prNumber` is validated by the wrapped fetcher before any request is issued,
|
|
181
|
+
* so a zero/negative/non-integer number fails without a network round trip.
|
|
182
|
+
*/
|
|
183
|
+
export async function getPrReviewStatus(access, prNumber, fetchImpl = globalThis.fetch) {
|
|
184
|
+
return wrap(access, () => fetchPrReviewStatus(access, prNumber, fetchImpl));
|
|
185
|
+
}
|
|
186
|
+
/** `GET /jira/parse-status?repo_name=` — the durable parse-run status. */
|
|
187
|
+
export async function getParseStatus(access, fetchImpl = globalThis.fetch) {
|
|
188
|
+
return wrap(access, () => fetchParseStatus(access, fetchImpl));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* `GET /jira/epic-runs/runs/{epicKey}/state?repo_name=` — the durable epic-run
|
|
192
|
+
* state.
|
|
193
|
+
*
|
|
194
|
+
* A pure READ. `implement-epic` never creates an `epic_run`; `init` calls this
|
|
195
|
+
* only to refuse when the server is already conducting the same epic through
|
|
196
|
+
* the v2 reconciler, which would otherwise drive the same tickets in parallel.
|
|
197
|
+
*/
|
|
198
|
+
export async function getEpicRunState(access, epicKey, fetchImpl = globalThis.fetch) {
|
|
199
|
+
return wrap(access, () => fetchEpicRunState(access, epicKey, fetchImpl));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* `GET /jira/config-field/base_branch?repo_name=` — the repository's configured
|
|
203
|
+
* base branch.
|
|
204
|
+
*
|
|
205
|
+
* Normalized defensively: an absent `value`, an explicit `null`, and a
|
|
206
|
+
* blank string all become `base_branch: null`, which the caller reads as "not
|
|
207
|
+
* configured" and falls through to `main`. A body that is not an object at all
|
|
208
|
+
* is a failure — silently treating an unparseable response as "not configured"
|
|
209
|
+
* would provision the epic branch from the wrong base.
|
|
210
|
+
*/
|
|
211
|
+
export async function getConfigFieldBaseBranch(access, fetchImpl = globalThis.fetch) {
|
|
212
|
+
const result = await wrap(access, () => {
|
|
213
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/config-field/base_branch", {
|
|
214
|
+
repo_name: access.repoName,
|
|
215
|
+
});
|
|
216
|
+
return fetchConductorJsonWithTimeout(url, getHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
217
|
+
});
|
|
218
|
+
if (!result.ok)
|
|
219
|
+
return result;
|
|
220
|
+
if (!isRecord(result.value)) {
|
|
221
|
+
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
222
|
+
}
|
|
223
|
+
const raw = result.value["value"];
|
|
224
|
+
if (raw === null || raw === undefined) {
|
|
225
|
+
return { ok: true, value: { base_branch: null } };
|
|
226
|
+
}
|
|
227
|
+
if (typeof raw !== "string") {
|
|
228
|
+
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
229
|
+
}
|
|
230
|
+
const trimmed = raw.trim();
|
|
231
|
+
return { ok: true, value: { base_branch: trimmed.length === 0 ? null : trimmed } };
|
|
232
|
+
}
|
|
233
|
+
const INDEX_SCOPE_FRESHNESS_VALUES = new Set([
|
|
234
|
+
"fresh",
|
|
235
|
+
"pending",
|
|
236
|
+
"blocked",
|
|
237
|
+
"failed",
|
|
238
|
+
"unavailable",
|
|
239
|
+
"unobserved_advance",
|
|
240
|
+
]);
|
|
241
|
+
/**
|
|
242
|
+
* Read the server's freshness verdict, failing CLOSED.
|
|
243
|
+
*
|
|
244
|
+
* An absent or unrecognized value becomes `null` rather than a guess, and every
|
|
245
|
+
* caller treats `null` as "not fresh". A malformed control-plane response must
|
|
246
|
+
* never be the thing that lets a conductor proceed against a stale index.
|
|
247
|
+
*/
|
|
248
|
+
function parseFreshnessStatus(value) {
|
|
249
|
+
return typeof value === "string" && INDEX_SCOPE_FRESHNESS_VALUES.has(value)
|
|
250
|
+
? value
|
|
251
|
+
: null;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Read a soft envelope's refusal, or `null` when the body reports success.
|
|
255
|
+
*
|
|
256
|
+
* A body that is not an object at all is a refusal too: silently treating an
|
|
257
|
+
* unparseable response as success is how a cut gets recorded against a commit
|
|
258
|
+
* nobody verified.
|
|
259
|
+
*/
|
|
260
|
+
function softEnvelopeFailure(body) {
|
|
261
|
+
if (!isRecord(body))
|
|
262
|
+
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
263
|
+
if (body["ok"] === true)
|
|
264
|
+
return null;
|
|
265
|
+
const error = typeof body["error"] === "string" && body["error"].length > 0 ? body["error"] : GENERIC_ERROR;
|
|
266
|
+
const message = typeof body["message"] === "string" && body["message"].length > 0 ? body["message"] : null;
|
|
267
|
+
return { ok: false, status: null, error: message ? `${error}: ${message}` : error };
|
|
268
|
+
}
|
|
269
|
+
/** Read a required string field, or `null` when it is absent or empty. */
|
|
270
|
+
function requiredString(body, key) {
|
|
271
|
+
const value = body[key];
|
|
272
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* `POST /jira/index-scope/cut/begin` — lease a cut hold at the indexed commit.
|
|
276
|
+
*
|
|
277
|
+
* `candidateCommitSha` is the CLI's own preflight observation, sent as a
|
|
278
|
+
* cross-check. The server re-reads the canonical snapshot under the parse lock
|
|
279
|
+
* and refuses on disagreement, which is the "the index moved while I was
|
|
280
|
+
* checking" case the protocol re-drives rather than papers over.
|
|
281
|
+
*/
|
|
282
|
+
export async function beginIndexScopeCut(access, request, fetchImpl = globalThis.fetch) {
|
|
283
|
+
const result = await wrap(access, () => {
|
|
284
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/cut/begin");
|
|
285
|
+
return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({
|
|
286
|
+
repo_name: access.repoName,
|
|
287
|
+
feature_branch: request.featureBranch,
|
|
288
|
+
base_branch: request.baseBranch,
|
|
289
|
+
candidate_commit_sha: request.candidateCommitSha ?? null,
|
|
290
|
+
epic_run_id: request.epicRunId ?? null,
|
|
291
|
+
}), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
292
|
+
});
|
|
293
|
+
if (!result.ok)
|
|
294
|
+
return result;
|
|
295
|
+
const refusal = softEnvelopeFailure(result.value);
|
|
296
|
+
if (refusal)
|
|
297
|
+
return refusal;
|
|
298
|
+
const body = result.value;
|
|
299
|
+
const cutHoldId = requiredString(body, "cut_hold_id");
|
|
300
|
+
const scopeId = requiredString(body, "scope_id");
|
|
301
|
+
const shadowRepoName = requiredString(body, "shadow_repo_name");
|
|
302
|
+
const cutCommitSha = requiredString(body, "cut_commit_sha");
|
|
303
|
+
if (!cutHoldId || !scopeId || !shadowRepoName || !cutCommitSha) {
|
|
304
|
+
// An `ok: true` body missing any of these is not a usable lease, and acting
|
|
305
|
+
// on a partial one would push a ref at `undefined`.
|
|
306
|
+
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
307
|
+
}
|
|
308
|
+
return { ok: true, value: { cut_hold_id: cutHoldId, scope_id: scopeId, shadow_repo_name: shadowRepoName, cut_commit_sha: cutCommitSha } };
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* `POST /jira/index-scope/cut/commit` — record the immutable cut.
|
|
312
|
+
*
|
|
313
|
+
* `epicRefCommitSha` is what the CLI read back from `origin` AFTER creating the
|
|
314
|
+
* ref, not what it intended to create. The server proves the two agree.
|
|
315
|
+
*/
|
|
316
|
+
export async function commitIndexScopeCut(access, request, fetchImpl = globalThis.fetch) {
|
|
317
|
+
const result = await wrap(access, () => {
|
|
318
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/cut/commit");
|
|
319
|
+
return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({
|
|
320
|
+
repo_name: access.repoName,
|
|
321
|
+
scope_id: request.scopeId,
|
|
322
|
+
cut_hold_id: request.cutHoldId,
|
|
323
|
+
epic_ref_commit_sha: request.epicRefCommitSha,
|
|
324
|
+
}), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
325
|
+
});
|
|
326
|
+
if (!result.ok)
|
|
327
|
+
return result;
|
|
328
|
+
const refusal = softEnvelopeFailure(result.value);
|
|
329
|
+
if (refusal)
|
|
330
|
+
return refusal;
|
|
331
|
+
const body = result.value;
|
|
332
|
+
const scopeId = requiredString(body, "scope_id");
|
|
333
|
+
const shadowRepoName = requiredString(body, "shadow_repo_name");
|
|
334
|
+
const cutCommitSha = requiredString(body, "cut_commit_sha");
|
|
335
|
+
if (!scopeId || !shadowRepoName || !cutCommitSha) {
|
|
336
|
+
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
ok: true,
|
|
340
|
+
value: {
|
|
341
|
+
scope_id: scopeId,
|
|
342
|
+
shadow_repo_name: shadowRepoName,
|
|
343
|
+
cut_commit_sha: cutCommitSha,
|
|
344
|
+
outcome: requiredString(body, "outcome"),
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* `POST /jira/index-scope/cut/abandon` — release this CLI's own cut hold.
|
|
350
|
+
*
|
|
351
|
+
* Called from `init`'s `finally` on every pre-seed outcome. Idempotent: a hold
|
|
352
|
+
* already released (or already reclaimed) still resolves successfully, so the
|
|
353
|
+
* cleanup path can never turn a primary failure into a second one.
|
|
354
|
+
*/
|
|
355
|
+
export async function abandonIndexScopeCut(access, request, fetchImpl = globalThis.fetch) {
|
|
356
|
+
const result = await wrap(access, () => {
|
|
357
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/cut/abandon");
|
|
358
|
+
return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({
|
|
359
|
+
repo_name: access.repoName,
|
|
360
|
+
scope_id: request.scopeId,
|
|
361
|
+
cut_hold_id: request.cutHoldId,
|
|
362
|
+
}), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
363
|
+
});
|
|
364
|
+
if (!result.ok)
|
|
365
|
+
return result;
|
|
366
|
+
const refusal = softEnvelopeFailure(result.value);
|
|
367
|
+
if (refusal)
|
|
368
|
+
return refusal;
|
|
369
|
+
return { ok: true, value: true };
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* `POST /jira/index-scope/bootstrap` — seed the scope and drive its verifying
|
|
373
|
+
* parse.
|
|
374
|
+
*
|
|
375
|
+
* Accepted means SCHEDULED, never ready. Readiness is observed through
|
|
376
|
+
* {@link getIndexScopeStatus}, because the bootstrap copies a repository's whole
|
|
377
|
+
* parse cache and then runs a parse.
|
|
378
|
+
*/
|
|
379
|
+
export async function bootstrapIndexScope(access, request, fetchImpl = globalThis.fetch) {
|
|
380
|
+
const result = await wrap(access, () => {
|
|
381
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/bootstrap");
|
|
382
|
+
return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({ repo_name: access.repoName, scope_id: request.scopeId }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
383
|
+
});
|
|
384
|
+
if (!result.ok)
|
|
385
|
+
return result;
|
|
386
|
+
const refusal = softEnvelopeFailure(result.value);
|
|
387
|
+
if (refusal)
|
|
388
|
+
return refusal;
|
|
389
|
+
return { ok: true, value: true };
|
|
390
|
+
}
|
|
391
|
+
/** `GET /jira/index-scope/status?repo_name=&scope_id=` — the scope's own state. */
|
|
392
|
+
export async function getIndexScopeStatus(access, scopeId, fetchImpl = globalThis.fetch) {
|
|
393
|
+
const result = await wrap(access, () => {
|
|
394
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/status", {
|
|
395
|
+
repo_name: access.repoName,
|
|
396
|
+
scope_id: scopeId,
|
|
397
|
+
});
|
|
398
|
+
return fetchConductorJsonWithTimeout(url, getHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
399
|
+
});
|
|
400
|
+
if (!result.ok)
|
|
401
|
+
return result;
|
|
402
|
+
const refusal = softEnvelopeFailure(result.value);
|
|
403
|
+
if (refusal)
|
|
404
|
+
return refusal;
|
|
405
|
+
const body = result.value;
|
|
406
|
+
const resolvedScopeId = requiredString(body, "scope_id");
|
|
407
|
+
const shadowRepoName = requiredString(body, "shadow_repo_name");
|
|
408
|
+
const lifecycleState = requiredString(body, "lifecycle_state");
|
|
409
|
+
if (!resolvedScopeId || !shadowRepoName || !lifecycleState) {
|
|
410
|
+
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
ok: true,
|
|
414
|
+
value: {
|
|
415
|
+
scope_id: resolvedScopeId,
|
|
416
|
+
shadow_repo_name: shadowRepoName,
|
|
417
|
+
lifecycle_state: lifecycleState,
|
|
418
|
+
cut_commit_sha: nullableString(body["cut_commit_sha"]) ?? null,
|
|
419
|
+
required_commit_sha: nullableString(body["required_commit_sha"]) ?? null,
|
|
420
|
+
indexed_commit_sha: nullableString(body["indexed_commit_sha"]) ?? null,
|
|
421
|
+
seed_source_commit_sha: nullableString(body["seed_source_commit_sha"]) ?? null,
|
|
422
|
+
last_error: nullableString(body["last_error"]) ?? null,
|
|
423
|
+
freshness_status: parseFreshnessStatus(body["freshness_status"]),
|
|
424
|
+
blocked_reason: nullableString(body["blocked_reason"]) ?? null,
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* The bounded outcomes `POST /jira/index-scope/catch-up` may report (BAPI-932).
|
|
430
|
+
*
|
|
431
|
+
* Three are successes — the scope was behind and every replayed commit passed
|
|
432
|
+
* the gate (`repaired`); it was already current and nothing was written
|
|
433
|
+
* (`already_current`); it was pinned correctly but not yet indexed, so the
|
|
434
|
+
* existing scheduler was asked to re-drive the parse (`parse_scheduled`). The
|
|
435
|
+
* rest are refusals, and a refusal leaves both watermarks byte-identical.
|
|
436
|
+
*/
|
|
437
|
+
export const INDEX_SCOPE_CATCH_UP_OUTCOMES = [
|
|
438
|
+
"repaired",
|
|
439
|
+
"already_current",
|
|
440
|
+
"parse_scheduled",
|
|
441
|
+
"race_lost",
|
|
442
|
+
"blocked",
|
|
443
|
+
"history_limit_exceeded",
|
|
444
|
+
"refused_lifecycle",
|
|
445
|
+
"invalid_scope",
|
|
446
|
+
"unavailable",
|
|
447
|
+
// Route-minted, before the service is reached.
|
|
448
|
+
"unknown_index_scope",
|
|
449
|
+
"epic_shadow_index_lookup_failed",
|
|
450
|
+
"scope_does_not_belong_to_repository",
|
|
451
|
+
];
|
|
452
|
+
/**
|
|
453
|
+
* `POST /jira/index-scope/catch-up` — replay a scope's missed observations.
|
|
454
|
+
*
|
|
455
|
+
* Fails CLOSED in the same two ways the rest of this client does: a soft-envelope
|
|
456
|
+
* refusal becomes a `BridgeResult` failure, and an unrecognized `outcome` narrows
|
|
457
|
+
* to `null` rather than being passed through. A `null` outcome with `ok: true`
|
|
458
|
+
* cannot happen — an unrecognized success is downgraded to a failure, because
|
|
459
|
+
* "the server said something we do not understand" must never be read by a
|
|
460
|
+
* conductor as "the repair worked".
|
|
461
|
+
*/
|
|
462
|
+
export async function catchUpIndexScope(access, request, fetchImpl = globalThis.fetch) {
|
|
463
|
+
const result = await wrap(access, () => {
|
|
464
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/catch-up");
|
|
465
|
+
return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({ repo_name: access.repoName, scope_id: request.scopeId }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
466
|
+
});
|
|
467
|
+
if (!result.ok)
|
|
468
|
+
return result;
|
|
469
|
+
if (!isRecord(result.value))
|
|
470
|
+
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
471
|
+
const body = result.value;
|
|
472
|
+
const rawOutcome = body["outcome"];
|
|
473
|
+
const outcome = typeof rawOutcome === "string" && INDEX_SCOPE_CATCH_UP_OUTCOMES.includes(rawOutcome)
|
|
474
|
+
? rawOutcome
|
|
475
|
+
: null;
|
|
476
|
+
const ok = body["ok"] === true;
|
|
477
|
+
if (ok && outcome === null) {
|
|
478
|
+
// A success we cannot name is not a success we may act on.
|
|
479
|
+
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
480
|
+
}
|
|
481
|
+
if (!ok && outcome === null) {
|
|
482
|
+
// A refusal we cannot name still refuses; surface the server's own error
|
|
483
|
+
// token when it gave one, exactly as the soft-envelope helper would.
|
|
484
|
+
const refusal = softEnvelopeFailure(body);
|
|
485
|
+
if (refusal)
|
|
486
|
+
return refusal;
|
|
487
|
+
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
ok: true,
|
|
491
|
+
value: {
|
|
492
|
+
ok,
|
|
493
|
+
outcome,
|
|
494
|
+
reason: nullableString(body["reason"]) ?? null,
|
|
495
|
+
required_commit_sha: nullableString(body["required_commit_sha"]) ?? null,
|
|
496
|
+
parse_scheduled: body["parse_scheduled"] === true,
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
function nullableNumber(value) {
|
|
501
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Read a boolean field, defaulting to the SAFE value rather than to `false`.
|
|
505
|
+
*
|
|
506
|
+
* The safe default differs per field and each caller passes it, because guessing
|
|
507
|
+
* uniformly would be wrong in both directions: an unreadable `lease_valid` must
|
|
508
|
+
* read as `true` (assume it is live, do not offer to reclaim it) while an
|
|
509
|
+
* unreadable `retention_elapsed` must read as `false` (assume the window has not
|
|
510
|
+
* passed).
|
|
511
|
+
*/
|
|
512
|
+
function booleanOr(value, fallback) {
|
|
513
|
+
return typeof value === "boolean" ? value : fallback;
|
|
514
|
+
}
|
|
515
|
+
function parseLifecycleEntry(value) {
|
|
516
|
+
if (!isRecord(value))
|
|
517
|
+
return null;
|
|
518
|
+
const scopeId = requiredString(value, "scope_id");
|
|
519
|
+
if (!scopeId)
|
|
520
|
+
return null;
|
|
521
|
+
const blockers = Array.isArray(value["blockers"])
|
|
522
|
+
? value["blockers"].filter((entry) => typeof entry === "string")
|
|
523
|
+
: [];
|
|
524
|
+
return {
|
|
525
|
+
scope_id: scopeId,
|
|
526
|
+
shadow_repo_name: nullableString(value["shadow_repo_name"]) ?? null,
|
|
527
|
+
feature_branch: nullableString(value["feature_branch"]) ?? null,
|
|
528
|
+
epic_run_id: nullableString(value["epic_run_id"]) ?? null,
|
|
529
|
+
lifecycle_state: nullableString(value["lifecycle_state"]) ?? null,
|
|
530
|
+
lease_epoch: nullableNumber(value["lease_epoch"]),
|
|
531
|
+
lease_expires_at: nullableString(value["lease_expires_at"]) ?? null,
|
|
532
|
+
// Fail closed on each: an unreadable field must never make a scope look more
|
|
533
|
+
// reclaimable than it is.
|
|
534
|
+
lease_valid: booleanOr(value["lease_valid"], true),
|
|
535
|
+
retention_deadline: nullableString(value["retention_deadline"]) ?? null,
|
|
536
|
+
retention_elapsed: booleanOr(value["retention_elapsed"], false),
|
|
537
|
+
recoverable: booleanOr(value["recoverable"], false),
|
|
538
|
+
active_parse_run: booleanOr(value["active_parse_run"], true),
|
|
539
|
+
parse_lock_held: booleanOr(value["parse_lock_held"], true),
|
|
540
|
+
active_automation_run: booleanOr(value["active_automation_run"], true),
|
|
541
|
+
active_epic_run: booleanOr(value["active_epic_run"], true),
|
|
542
|
+
identity_valid: booleanOr(value["identity_valid"], false),
|
|
543
|
+
blockers,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
/** `GET /jira/index-scope/lifecycle?repo_name=` — every scope this repo owns. */
|
|
547
|
+
export async function getIndexScopeLifecycle(access, fetchImpl = globalThis.fetch) {
|
|
548
|
+
const result = await wrap(access, () => {
|
|
549
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/lifecycle", {
|
|
550
|
+
repo_name: access.repoName,
|
|
551
|
+
});
|
|
552
|
+
return fetchConductorJsonWithTimeout(url, getHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
553
|
+
});
|
|
554
|
+
if (!result.ok)
|
|
555
|
+
return result;
|
|
556
|
+
const refusal = softEnvelopeFailure(result.value);
|
|
557
|
+
if (refusal)
|
|
558
|
+
return refusal;
|
|
559
|
+
const body = result.value;
|
|
560
|
+
const rawScopes = Array.isArray(body["scopes"]) ? body["scopes"] : [];
|
|
561
|
+
const scopes = [];
|
|
562
|
+
for (const raw of rawScopes) {
|
|
563
|
+
const parsed = parseLifecycleEntry(raw);
|
|
564
|
+
// A malformed entry is DROPPED rather than failing the whole listing: the
|
|
565
|
+
// listing is a diagnostic, and showing an operator four readable scopes plus
|
|
566
|
+
// nothing about a fifth beats showing them nothing at all.
|
|
567
|
+
if (parsed !== null)
|
|
568
|
+
scopes.push(parsed);
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
ok: true,
|
|
572
|
+
value: { retention_seconds: nullableNumber(body["retention_seconds"]), scopes },
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
/** Shared parser for the three lease verbs, whose response shape is identical. */
|
|
576
|
+
function toLeaseResult(body) {
|
|
577
|
+
const refusal = softEnvelopeFailure(body);
|
|
578
|
+
if (refusal)
|
|
579
|
+
return refusal;
|
|
580
|
+
const record = body;
|
|
581
|
+
const scopeId = requiredString(record, "scope_id");
|
|
582
|
+
if (!scopeId)
|
|
583
|
+
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
584
|
+
return {
|
|
585
|
+
ok: true,
|
|
586
|
+
value: {
|
|
587
|
+
scope_id: scopeId,
|
|
588
|
+
lifecycle_state: nullableString(record["lifecycle_state"]) ?? null,
|
|
589
|
+
lease_epoch: nullableNumber(record["lease_epoch"]),
|
|
590
|
+
lease_expires_at: nullableString(record["lease_expires_at"]) ?? null,
|
|
591
|
+
already_retired: booleanOr(record["already_retired"], false),
|
|
592
|
+
},
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
async function postScopeLifecycle(access, path, body, fetchImpl) {
|
|
596
|
+
const result = await wrap(access, () => {
|
|
597
|
+
const url = buildConductorJiraUrl(access.baseUrl, path);
|
|
598
|
+
return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({ repo_name: access.repoName, ...body }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
599
|
+
});
|
|
600
|
+
if (!result.ok)
|
|
601
|
+
return result;
|
|
602
|
+
return toLeaseResult(result.value);
|
|
603
|
+
}
|
|
604
|
+
/** `POST /jira/index-scope/heartbeat` — renew the lease under `leaseEpoch`. */
|
|
605
|
+
export async function heartbeatIndexScope(access, request, fetchImpl = globalThis.fetch) {
|
|
606
|
+
return postScopeLifecycle(access, "/index-scope/heartbeat", { scope_id: request.scopeId, lease_epoch: request.leaseEpoch }, fetchImpl);
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* `POST /jira/index-scope/recover` — take a new ownership generation.
|
|
610
|
+
*
|
|
611
|
+
* Deliberately sends NO epoch: recovery exists for the case where the caller has
|
|
612
|
+
* no valid one, and the server's increment is what fences the previous owner.
|
|
613
|
+
*/
|
|
614
|
+
export async function recoverIndexScope(access, request, fetchImpl = globalThis.fetch) {
|
|
615
|
+
return postScopeLifecycle(access, "/index-scope/recover", { scope_id: request.scopeId }, fetchImpl);
|
|
616
|
+
}
|
|
617
|
+
/** `POST /jira/index-scope/retire` — start retention; delete nothing. */
|
|
618
|
+
export async function retireIndexScope(access, request, fetchImpl = globalThis.fetch) {
|
|
619
|
+
return postScopeLifecycle(access, "/index-scope/retire", { scope_id: request.scopeId, lease_epoch: request.leaseEpoch }, fetchImpl);
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* `POST /jira/index-scope/reclaim` — schedule the server-side teardown.
|
|
623
|
+
*
|
|
624
|
+
* There is deliberately no raw-deletion mode and no field that could name a
|
|
625
|
+
* namespace, table, or repository: the only knob is `overrideRetention`, which
|
|
626
|
+
* waives the two TIME blockers and nothing else. Everything actually deleted is
|
|
627
|
+
* derived server-side from the scope id.
|
|
628
|
+
*
|
|
629
|
+
* A refusal carries the server's ordered blocker tokens so the CLI can print WHY
|
|
630
|
+
* rather than a generic failure.
|
|
631
|
+
*/
|
|
632
|
+
export async function reclaimIndexScope(access, request, fetchImpl = globalThis.fetch) {
|
|
633
|
+
const result = await wrap(access, () => {
|
|
634
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/reclaim");
|
|
635
|
+
return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({
|
|
636
|
+
repo_name: access.repoName,
|
|
637
|
+
scope_id: request.scopeId,
|
|
638
|
+
override_retention: request.overrideRetention === true,
|
|
639
|
+
}), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
640
|
+
});
|
|
641
|
+
if (!result.ok)
|
|
642
|
+
return result;
|
|
643
|
+
const refusal = softEnvelopeFailure(result.value);
|
|
644
|
+
if (refusal) {
|
|
645
|
+
const body = isRecord(result.value) ? result.value : {};
|
|
646
|
+
const blockers = Array.isArray(body["blockers"])
|
|
647
|
+
? body["blockers"].filter((entry) => typeof entry === "string")
|
|
648
|
+
: undefined;
|
|
649
|
+
return blockers && blockers.length > 0 ? { ...refusal, blockers } : refusal;
|
|
650
|
+
}
|
|
651
|
+
const body = result.value;
|
|
652
|
+
return {
|
|
653
|
+
ok: true,
|
|
654
|
+
value: {
|
|
655
|
+
scope_id: nullableString(body["scope_id"]) ?? null,
|
|
656
|
+
scheduled: booleanOr(body["scheduled"], false),
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Epic-run statuses that mean a v2 conductor may still be driving the run.
|
|
662
|
+
*
|
|
663
|
+
* These are the NON-TERMINAL members of the server's `EpicRunStatus` vocabulary
|
|
664
|
+
* (`api/models/epic_run.py`: `planning | pending_approval | active | blocked |
|
|
665
|
+
* abandoned | done`); only `abandoned` and `done` are terminal. `active` is the
|
|
666
|
+
* state a run spends nearly all of its life in, so omitting it would make this
|
|
667
|
+
* whole advisory silent for exactly the overlap it exists to surface.
|
|
668
|
+
*
|
|
669
|
+
* The vocabulary matters and is easy to get wrong: `running`/`queued` belong to
|
|
670
|
+
* `ExecutorJobStatusValue` and `paused` to `RunControlState`, and none of the
|
|
671
|
+
* three is ever an `epic_runs.status`. Status alone is not liveness either — the
|
|
672
|
+
* caller additionally requires an unexpired lease, so a `blocked` run whose lease
|
|
673
|
+
* has lapsed is correctly read as wreckage rather than as a live conductor.
|
|
674
|
+
*/
|
|
675
|
+
const ACTIVE_EPIC_RUN_STATUSES = [
|
|
676
|
+
"planning",
|
|
677
|
+
"pending_approval",
|
|
678
|
+
"active",
|
|
679
|
+
"blocked",
|
|
680
|
+
];
|
|
681
|
+
/**
|
|
682
|
+
* `GET /automation/health?repo_name=` — read-only parse-dispatcher liveness.
|
|
683
|
+
*
|
|
684
|
+
* Root-mounted, NOT under `/jira`. The response is narrowed to two safe fields;
|
|
685
|
+
* signals, stale-ticket samples, queue contents, and every timestamp are
|
|
686
|
+
* deliberately dropped — an advisory line needs a verdict, not a report.
|
|
687
|
+
*
|
|
688
|
+
* `absent` is claimed ONLY on positive evidence: a `stale` or `never_seen`
|
|
689
|
+
* heartbeat together with a responding process that is not itself sweeping.
|
|
690
|
+
* Every other shape — `unknown`, a missing field, a malformed body, any read
|
|
691
|
+
* failure — is `unavailable`.
|
|
692
|
+
*/
|
|
693
|
+
export async function getParseDispatcherHealth(access, fetchImpl = globalThis.fetch) {
|
|
694
|
+
const result = await wrap(access, () => {
|
|
695
|
+
const url = new URL(buildConductorVcsUrl(access.baseUrl, "/automation/health"));
|
|
696
|
+
url.searchParams.set("repo_name", access.repoName);
|
|
697
|
+
return fetchConductorJsonWithTimeout(url.toString(), getHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
698
|
+
});
|
|
699
|
+
if (!result.ok)
|
|
700
|
+
return result;
|
|
701
|
+
if (!isRecord(result.value)) {
|
|
702
|
+
return {
|
|
703
|
+
ok: true,
|
|
704
|
+
value: { observation: "unavailable", heartbeatState: null, respondingSchedulerRunning: false },
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
const reconciler = isRecord(result.value["reconciler"]) ? result.value["reconciler"] : null;
|
|
708
|
+
const scheduler = isRecord(result.value["scheduler"]) ? result.value["scheduler"] : null;
|
|
709
|
+
const rawState = reconciler === null ? undefined : reconciler["state"];
|
|
710
|
+
const heartbeatState = typeof rawState === "string" ? rawState : null;
|
|
711
|
+
const respondingSchedulerRunning = scheduler !== null &&
|
|
712
|
+
scheduler["running"] === true &&
|
|
713
|
+
typeof scheduler["job_count"] === "number" &&
|
|
714
|
+
scheduler["job_count"] > 0;
|
|
715
|
+
let observation;
|
|
716
|
+
if (heartbeatState === "fresh" || respondingSchedulerRunning) {
|
|
717
|
+
observation = "observed";
|
|
718
|
+
}
|
|
719
|
+
else if (heartbeatState === "stale" || heartbeatState === "never_seen") {
|
|
720
|
+
observation = "absent";
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
observation = "unavailable";
|
|
724
|
+
}
|
|
725
|
+
return { ok: true, value: { observation, heartbeatState, respondingSchedulerRunning } };
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* `GET /jira/epic-runs/runs?repo_name=` — live v2 conductor runs on THIS repo.
|
|
729
|
+
*
|
|
730
|
+
* Scoped by the authenticated repository identity carried on `access`; no caller
|
|
731
|
+
* ever names a repository here. Liveness is judged on the LEASE, not on the
|
|
732
|
+
* status alone: an `active` row whose lease expired is a crashed run, and calling
|
|
733
|
+
* that a concurrent conductor would make the advisory cry wolf on exactly the
|
|
734
|
+
* wreckage `recover` exists to clean up.
|
|
735
|
+
*
|
|
736
|
+
* Only `epic_key` and a controlled state survive into the result. Lease owners,
|
|
737
|
+
* run ids, policies, and timestamps are dropped.
|
|
738
|
+
*/
|
|
739
|
+
export async function getLiveRepositoryConductors(access, now, fetchImpl = globalThis.fetch) {
|
|
740
|
+
const result = await wrap(access, () => {
|
|
741
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/epic-runs/runs", {
|
|
742
|
+
repo_name: access.repoName,
|
|
743
|
+
});
|
|
744
|
+
return fetchConductorJsonWithTimeout(url, getHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
745
|
+
});
|
|
746
|
+
if (!result.ok)
|
|
747
|
+
return result;
|
|
748
|
+
if (!isRecord(result.value) || !Array.isArray(result.value["runs"])) {
|
|
749
|
+
return { ok: true, value: { observation: "unavailable", conductors: [] } };
|
|
750
|
+
}
|
|
751
|
+
const nowMs = now.getTime();
|
|
752
|
+
const conductors = [];
|
|
753
|
+
for (const entry of result.value["runs"]) {
|
|
754
|
+
if (!isRecord(entry))
|
|
755
|
+
continue;
|
|
756
|
+
const status = entry["status"];
|
|
757
|
+
if (typeof status !== "string" || !ACTIVE_EPIC_RUN_STATUSES.includes(status))
|
|
758
|
+
continue;
|
|
759
|
+
const leaseRaw = nullableString(entry["lease_expires_at"]);
|
|
760
|
+
if (typeof leaseRaw !== "string")
|
|
761
|
+
continue;
|
|
762
|
+
const leaseMs = Date.parse(leaseRaw);
|
|
763
|
+
if (!Number.isFinite(leaseMs) || leaseMs <= nowMs)
|
|
764
|
+
continue;
|
|
765
|
+
conductors.push({
|
|
766
|
+
kind: "v2",
|
|
767
|
+
epicKey: nullableString(entry["epic_key"]) ?? null,
|
|
768
|
+
state: status,
|
|
769
|
+
livenessSource: "lease",
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
return { ok: true, value: { observation: conductors.length > 0 ? "observed" : "absent", conductors } };
|
|
773
|
+
}
|