@bridge_gpt/mcp-server 0.2.39 → 0.2.42
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 +10 -10
- package/build/agent-capabilities/cli.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/claude-user-config-doctor.js +42 -11
- package/build/cli-release.js +2 -1
- package/build/commands.generated.js +4 -4
- package/build/conduct-epic/bridge-client.js +354 -113
- package/build/conduct-epic/checkpoint-store.js +75 -2
- package/build/conduct-epic/cli.js +795 -109
- package/build/conduct-epic/cut-protocol.js +327 -0
- package/build/conduct-epic/pr-state.js +113 -24
- package/build/conduct-epic/spawn.js +14 -2
- package/build/conductor/bridge-api-client.js +27 -1
- package/build/conductor/cli.js +46 -1
- package/build/conductor/doctor.js +101 -16
- package/build/conductor/epic-reconcile.js +72 -19
- package/build/conductor/epic-runtime.js +15 -3
- package/build/conductor/errors.js +47 -0
- package/build/conductor/git-hooks.js +205 -11
- package/build/conductor/install-doctor.js +230 -1
- package/build/conductor/local-merge.js +130 -28
- package/build/conductor/tools.js +32 -3
- package/build/conductor/worker-ledger-cli.js +27 -1
- package/build/conductor-bin.js +15 -15
- package/build/credentials-cli.js +3 -2
- package/build/doctor.js +107 -41
- package/build/executor/cli.js +48 -1
- package/build/executor/env.js +21 -0
- package/build/executor/index-scope.js +39 -0
- package/build/executor/job-log-registry.js +69 -0
- package/build/executor/job-runner.js +148 -26
- package/build/executor/live-worker-registry.js +83 -0
- package/build/executor/observation.js +167 -6
- package/build/executor/platform.js +147 -3
- package/build/executor/process.js +58 -14
- package/build/executor/runner.js +235 -48
- package/build/executor/test-clock.js +3 -2
- package/build/index-scope-contract.js +96 -0
- package/build/index.js +153 -204
- package/build/init.js +83 -22
- package/build/install-bridge-conductor.js +323 -14
- package/build/install-bridge.js +202 -38
- package/build/install-doctor.js +23 -9
- package/build/install-reexec.js +2 -1
- package/build/launcher-config-inspection.js +83 -22
- package/build/mcp-host-config.js +331 -67
- package/build/mcp-host-targets.js +45 -21
- package/build/mcp-identity.js +92 -0
- package/build/mcp-install-state.js +94 -1
- package/build/mcp-invoke.js +2 -1
- package/build/mcp-provisioning.js +45 -12
- package/build/mcp-registration-doctor.js +35 -13
- package/build/mcp-server-invocation.js +4 -2
- package/build/merge-pull-request.js +208 -9
- package/build/pipelines.generated.js +3 -3
- package/build/plane/defaults.js +4 -1
- package/build/plane/preflight.js +81 -10
- package/build/plane/test-fakes.js +9 -1
- package/build/readme.generated.js +1 -1
- package/build/regression-check.js +3 -2
- package/build/review-tickets.js +8 -7
- package/build/run-unit-tests-launcher.js +74 -1
- package/build/schedule-run.js +3 -2
- package/build/setup-epic.js +453 -78
- package/build/sfcc/tool-wrapper.js +15 -0
- package/build/start-tickets-prereqs.js +11 -6
- package/build/start-tickets.js +91 -85
- package/build/update-check.js +3 -2
- package/build/upgrade-advice.js +2 -1
- package/build/upgrade-cli.js +50 -18
- package/build/version.generated.js +1 -1
- package/docs/CONDUCTOR.md +22 -0
- package/docs/install/mcp-tool-integrations.md +19 -3
- package/package.json +2 -2
|
@@ -91,119 +91,6 @@ function nullableString(value) {
|
|
|
91
91
|
return value;
|
|
92
92
|
return undefined;
|
|
93
93
|
}
|
|
94
|
-
/** Parse the `override` member, or `undefined` when it is malformed. */
|
|
95
|
-
function parseOverride(value) {
|
|
96
|
-
if (value === null || value === undefined)
|
|
97
|
-
return null;
|
|
98
|
-
if (!isRecord(value))
|
|
99
|
-
return undefined;
|
|
100
|
-
const original = nullableString(value["original_base_branch"]);
|
|
101
|
-
if (typeof value["repo_name"] !== "string" ||
|
|
102
|
-
original === undefined ||
|
|
103
|
-
typeof value["override_branch"] !== "string" ||
|
|
104
|
-
typeof value["created_at"] !== "string") {
|
|
105
|
-
return undefined;
|
|
106
|
-
}
|
|
107
|
-
return {
|
|
108
|
-
repo_name: value["repo_name"],
|
|
109
|
-
original_base_branch: original,
|
|
110
|
-
override_branch: value["override_branch"],
|
|
111
|
-
created_at: value["created_at"],
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* Validate a 200 body against {@link IndexBranchStatus}, returning `null` when it
|
|
116
|
-
* does not match.
|
|
117
|
-
*
|
|
118
|
-
* A body that parsed as JSON but is not this shape is treated as a failure
|
|
119
|
-
* rather than cast through: `changed` and `current_base_branch` drive
|
|
120
|
-
* destructive branch decisions in the caller, and a missing `changed` read as
|
|
121
|
-
* `undefined` would silently mean "nothing happened".
|
|
122
|
-
*/
|
|
123
|
-
function parseStatus(body) {
|
|
124
|
-
if (!isRecord(body))
|
|
125
|
-
return null;
|
|
126
|
-
const current = nullableString(body["current_base_branch"]);
|
|
127
|
-
const override = parseOverride(body["override"]);
|
|
128
|
-
if (typeof body["repo_name"] !== "string" ||
|
|
129
|
-
current === undefined ||
|
|
130
|
-
override === undefined ||
|
|
131
|
-
typeof body["changed"] !== "boolean") {
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
return {
|
|
135
|
-
repo_name: body["repo_name"],
|
|
136
|
-
current_base_branch: current,
|
|
137
|
-
override,
|
|
138
|
-
changed: body["changed"],
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
/** Wrap a parsed body into a success result, or a generic failure if malformed. */
|
|
142
|
-
function toStatusResult(body) {
|
|
143
|
-
const status = parseStatus(body);
|
|
144
|
-
if (status === null) {
|
|
145
|
-
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
146
|
-
}
|
|
147
|
-
return { ok: true, value: status };
|
|
148
|
-
}
|
|
149
|
-
/**
|
|
150
|
-
* `POST /jira/index-branch/repoint` — point the indexed base branch at `branch`.
|
|
151
|
-
*
|
|
152
|
-
* A repeat call with the same branch resolves `{ ok: true }` with
|
|
153
|
-
* `value.changed === false`. A call for a *different* branch while an override is
|
|
154
|
-
* active resolves `{ ok: false, status: 409 }` with an error naming the override
|
|
155
|
-
* the server preserved — it is not an exception, because a conflict is an
|
|
156
|
-
* expected outcome the CLI must report and continue from.
|
|
157
|
-
*/
|
|
158
|
-
export async function repointIndexBranch(access, input, fetchImpl = globalThis.fetch) {
|
|
159
|
-
try {
|
|
160
|
-
// `buildConductorJiraUrl` already appends the `/jira` segment.
|
|
161
|
-
const url = buildConductorJiraUrl(access.baseUrl, "/index-branch/repoint");
|
|
162
|
-
const body = await fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({ repo_name: access.repoName, branch: input.branch }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
163
|
-
return toStatusResult(body);
|
|
164
|
-
}
|
|
165
|
-
catch (err) {
|
|
166
|
-
return toFailure(err, access);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
/**
|
|
170
|
-
* `POST /jira/index-branch/restore` — write the stored original branch back and
|
|
171
|
-
* drop the override.
|
|
172
|
-
*
|
|
173
|
-
* Idempotent: with no active override the call resolves `{ ok: true }` with
|
|
174
|
-
* `value.changed === false` and `value.override === null`. That matters because
|
|
175
|
-
* restore is the recovery path — it must be safe to run after a crash whose
|
|
176
|
-
* position is unknown.
|
|
177
|
-
*/
|
|
178
|
-
export async function restoreIndexBranch(access, fetchImpl = globalThis.fetch) {
|
|
179
|
-
try {
|
|
180
|
-
const url = buildConductorJiraUrl(access.baseUrl, "/index-branch/restore");
|
|
181
|
-
const body = await fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({ repo_name: access.repoName }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
182
|
-
return toStatusResult(body);
|
|
183
|
-
}
|
|
184
|
-
catch (err) {
|
|
185
|
-
return toFailure(err, access);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
/**
|
|
189
|
-
* `GET /jira/index-branch?repo_name=` — report the current indexed branch and any
|
|
190
|
-
* active override.
|
|
191
|
-
*
|
|
192
|
-
* This is how a stale override left behind by a crashed loop becomes visible;
|
|
193
|
-
* `value.changed` is always `false` because a read changes nothing.
|
|
194
|
-
*/
|
|
195
|
-
export async function getIndexBranch(access, fetchImpl = globalThis.fetch) {
|
|
196
|
-
try {
|
|
197
|
-
const url = buildConductorJiraUrl(access.baseUrl, "/index-branch", {
|
|
198
|
-
repo_name: access.repoName,
|
|
199
|
-
});
|
|
200
|
-
const body = await fetchConductorJsonWithTimeout(url, getHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
201
|
-
return toStatusResult(body);
|
|
202
|
-
}
|
|
203
|
-
catch (err) {
|
|
204
|
-
return toFailure(err, access);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
94
|
/**
|
|
208
95
|
* Run `operation` and convert any thrown value into a sanitized failure result.
|
|
209
96
|
*
|
|
@@ -343,3 +230,357 @@ export async function getConfigFieldBaseBranch(access, fetchImpl = globalThis.fe
|
|
|
343
230
|
const trimmed = raw.trim();
|
|
344
231
|
return { ok: true, value: { base_branch: trimmed.length === 0 ? null : trimmed } };
|
|
345
232
|
}
|
|
233
|
+
const INDEX_SCOPE_FRESHNESS_VALUES = new Set([
|
|
234
|
+
"fresh",
|
|
235
|
+
"pending",
|
|
236
|
+
"blocked",
|
|
237
|
+
"failed",
|
|
238
|
+
"unavailable",
|
|
239
|
+
]);
|
|
240
|
+
/**
|
|
241
|
+
* Read the server's freshness verdict, failing CLOSED.
|
|
242
|
+
*
|
|
243
|
+
* An absent or unrecognized value becomes `null` rather than a guess, and every
|
|
244
|
+
* caller treats `null` as "not fresh". A malformed control-plane response must
|
|
245
|
+
* never be the thing that lets a conductor proceed against a stale index.
|
|
246
|
+
*/
|
|
247
|
+
function parseFreshnessStatus(value) {
|
|
248
|
+
return typeof value === "string" && INDEX_SCOPE_FRESHNESS_VALUES.has(value)
|
|
249
|
+
? value
|
|
250
|
+
: null;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Read a soft envelope's refusal, or `null` when the body reports success.
|
|
254
|
+
*
|
|
255
|
+
* A body that is not an object at all is a refusal too: silently treating an
|
|
256
|
+
* unparseable response as success is how a cut gets recorded against a commit
|
|
257
|
+
* nobody verified.
|
|
258
|
+
*/
|
|
259
|
+
function softEnvelopeFailure(body) {
|
|
260
|
+
if (!isRecord(body))
|
|
261
|
+
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
262
|
+
if (body["ok"] === true)
|
|
263
|
+
return null;
|
|
264
|
+
const error = typeof body["error"] === "string" && body["error"].length > 0 ? body["error"] : GENERIC_ERROR;
|
|
265
|
+
const message = typeof body["message"] === "string" && body["message"].length > 0 ? body["message"] : null;
|
|
266
|
+
return { ok: false, status: null, error: message ? `${error}: ${message}` : error };
|
|
267
|
+
}
|
|
268
|
+
/** Read a required string field, or `null` when it is absent or empty. */
|
|
269
|
+
function requiredString(body, key) {
|
|
270
|
+
const value = body[key];
|
|
271
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* `POST /jira/index-scope/cut/begin` — lease a cut hold at the indexed commit.
|
|
275
|
+
*
|
|
276
|
+
* `candidateCommitSha` is the CLI's own preflight observation, sent as a
|
|
277
|
+
* cross-check. The server re-reads the canonical snapshot under the parse lock
|
|
278
|
+
* and refuses on disagreement, which is the "the index moved while I was
|
|
279
|
+
* checking" case the protocol re-drives rather than papers over.
|
|
280
|
+
*/
|
|
281
|
+
export async function beginIndexScopeCut(access, request, fetchImpl = globalThis.fetch) {
|
|
282
|
+
const result = await wrap(access, () => {
|
|
283
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/cut/begin");
|
|
284
|
+
return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({
|
|
285
|
+
repo_name: access.repoName,
|
|
286
|
+
feature_branch: request.featureBranch,
|
|
287
|
+
base_branch: request.baseBranch,
|
|
288
|
+
candidate_commit_sha: request.candidateCommitSha ?? null,
|
|
289
|
+
epic_run_id: request.epicRunId ?? null,
|
|
290
|
+
}), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
291
|
+
});
|
|
292
|
+
if (!result.ok)
|
|
293
|
+
return result;
|
|
294
|
+
const refusal = softEnvelopeFailure(result.value);
|
|
295
|
+
if (refusal)
|
|
296
|
+
return refusal;
|
|
297
|
+
const body = result.value;
|
|
298
|
+
const cutHoldId = requiredString(body, "cut_hold_id");
|
|
299
|
+
const scopeId = requiredString(body, "scope_id");
|
|
300
|
+
const shadowRepoName = requiredString(body, "shadow_repo_name");
|
|
301
|
+
const cutCommitSha = requiredString(body, "cut_commit_sha");
|
|
302
|
+
if (!cutHoldId || !scopeId || !shadowRepoName || !cutCommitSha) {
|
|
303
|
+
// An `ok: true` body missing any of these is not a usable lease, and acting
|
|
304
|
+
// on a partial one would push a ref at `undefined`.
|
|
305
|
+
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
306
|
+
}
|
|
307
|
+
return { ok: true, value: { cut_hold_id: cutHoldId, scope_id: scopeId, shadow_repo_name: shadowRepoName, cut_commit_sha: cutCommitSha } };
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* `POST /jira/index-scope/cut/commit` — record the immutable cut.
|
|
311
|
+
*
|
|
312
|
+
* `epicRefCommitSha` is what the CLI read back from `origin` AFTER creating the
|
|
313
|
+
* ref, not what it intended to create. The server proves the two agree.
|
|
314
|
+
*/
|
|
315
|
+
export async function commitIndexScopeCut(access, request, fetchImpl = globalThis.fetch) {
|
|
316
|
+
const result = await wrap(access, () => {
|
|
317
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/cut/commit");
|
|
318
|
+
return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({
|
|
319
|
+
repo_name: access.repoName,
|
|
320
|
+
scope_id: request.scopeId,
|
|
321
|
+
cut_hold_id: request.cutHoldId,
|
|
322
|
+
epic_ref_commit_sha: request.epicRefCommitSha,
|
|
323
|
+
}), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
324
|
+
});
|
|
325
|
+
if (!result.ok)
|
|
326
|
+
return result;
|
|
327
|
+
const refusal = softEnvelopeFailure(result.value);
|
|
328
|
+
if (refusal)
|
|
329
|
+
return refusal;
|
|
330
|
+
const body = result.value;
|
|
331
|
+
const scopeId = requiredString(body, "scope_id");
|
|
332
|
+
const shadowRepoName = requiredString(body, "shadow_repo_name");
|
|
333
|
+
const cutCommitSha = requiredString(body, "cut_commit_sha");
|
|
334
|
+
if (!scopeId || !shadowRepoName || !cutCommitSha) {
|
|
335
|
+
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
ok: true,
|
|
339
|
+
value: {
|
|
340
|
+
scope_id: scopeId,
|
|
341
|
+
shadow_repo_name: shadowRepoName,
|
|
342
|
+
cut_commit_sha: cutCommitSha,
|
|
343
|
+
outcome: requiredString(body, "outcome"),
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* `POST /jira/index-scope/cut/abandon` — release this CLI's own cut hold.
|
|
349
|
+
*
|
|
350
|
+
* Called from `init`'s `finally` on every pre-seed outcome. Idempotent: a hold
|
|
351
|
+
* already released (or already reclaimed) still resolves successfully, so the
|
|
352
|
+
* cleanup path can never turn a primary failure into a second one.
|
|
353
|
+
*/
|
|
354
|
+
export async function abandonIndexScopeCut(access, request, fetchImpl = globalThis.fetch) {
|
|
355
|
+
const result = await wrap(access, () => {
|
|
356
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/cut/abandon");
|
|
357
|
+
return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({
|
|
358
|
+
repo_name: access.repoName,
|
|
359
|
+
scope_id: request.scopeId,
|
|
360
|
+
cut_hold_id: request.cutHoldId,
|
|
361
|
+
}), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
362
|
+
});
|
|
363
|
+
if (!result.ok)
|
|
364
|
+
return result;
|
|
365
|
+
const refusal = softEnvelopeFailure(result.value);
|
|
366
|
+
if (refusal)
|
|
367
|
+
return refusal;
|
|
368
|
+
return { ok: true, value: true };
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* `POST /jira/index-scope/bootstrap` — seed the scope and drive its verifying
|
|
372
|
+
* parse.
|
|
373
|
+
*
|
|
374
|
+
* Accepted means SCHEDULED, never ready. Readiness is observed through
|
|
375
|
+
* {@link getIndexScopeStatus}, because the bootstrap copies a repository's whole
|
|
376
|
+
* parse cache and then runs a parse.
|
|
377
|
+
*/
|
|
378
|
+
export async function bootstrapIndexScope(access, request, fetchImpl = globalThis.fetch) {
|
|
379
|
+
const result = await wrap(access, () => {
|
|
380
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/bootstrap");
|
|
381
|
+
return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({ repo_name: access.repoName, scope_id: request.scopeId }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
382
|
+
});
|
|
383
|
+
if (!result.ok)
|
|
384
|
+
return result;
|
|
385
|
+
const refusal = softEnvelopeFailure(result.value);
|
|
386
|
+
if (refusal)
|
|
387
|
+
return refusal;
|
|
388
|
+
return { ok: true, value: true };
|
|
389
|
+
}
|
|
390
|
+
/** `GET /jira/index-scope/status?repo_name=&scope_id=` — the scope's own state. */
|
|
391
|
+
export async function getIndexScopeStatus(access, scopeId, fetchImpl = globalThis.fetch) {
|
|
392
|
+
const result = await wrap(access, () => {
|
|
393
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/status", {
|
|
394
|
+
repo_name: access.repoName,
|
|
395
|
+
scope_id: scopeId,
|
|
396
|
+
});
|
|
397
|
+
return fetchConductorJsonWithTimeout(url, getHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
398
|
+
});
|
|
399
|
+
if (!result.ok)
|
|
400
|
+
return result;
|
|
401
|
+
const refusal = softEnvelopeFailure(result.value);
|
|
402
|
+
if (refusal)
|
|
403
|
+
return refusal;
|
|
404
|
+
const body = result.value;
|
|
405
|
+
const resolvedScopeId = requiredString(body, "scope_id");
|
|
406
|
+
const shadowRepoName = requiredString(body, "shadow_repo_name");
|
|
407
|
+
const lifecycleState = requiredString(body, "lifecycle_state");
|
|
408
|
+
if (!resolvedScopeId || !shadowRepoName || !lifecycleState) {
|
|
409
|
+
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
ok: true,
|
|
413
|
+
value: {
|
|
414
|
+
scope_id: resolvedScopeId,
|
|
415
|
+
shadow_repo_name: shadowRepoName,
|
|
416
|
+
lifecycle_state: lifecycleState,
|
|
417
|
+
cut_commit_sha: nullableString(body["cut_commit_sha"]) ?? null,
|
|
418
|
+
required_commit_sha: nullableString(body["required_commit_sha"]) ?? null,
|
|
419
|
+
indexed_commit_sha: nullableString(body["indexed_commit_sha"]) ?? null,
|
|
420
|
+
seed_source_commit_sha: nullableString(body["seed_source_commit_sha"]) ?? null,
|
|
421
|
+
last_error: nullableString(body["last_error"]) ?? null,
|
|
422
|
+
freshness_status: parseFreshnessStatus(body["freshness_status"]),
|
|
423
|
+
blocked_reason: nullableString(body["blocked_reason"]) ?? null,
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
function nullableNumber(value) {
|
|
428
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Read a boolean field, defaulting to the SAFE value rather than to `false`.
|
|
432
|
+
*
|
|
433
|
+
* The safe default differs per field and each caller passes it, because guessing
|
|
434
|
+
* uniformly would be wrong in both directions: an unreadable `lease_valid` must
|
|
435
|
+
* read as `true` (assume it is live, do not offer to reclaim it) while an
|
|
436
|
+
* unreadable `retention_elapsed` must read as `false` (assume the window has not
|
|
437
|
+
* passed).
|
|
438
|
+
*/
|
|
439
|
+
function booleanOr(value, fallback) {
|
|
440
|
+
return typeof value === "boolean" ? value : fallback;
|
|
441
|
+
}
|
|
442
|
+
function parseLifecycleEntry(value) {
|
|
443
|
+
if (!isRecord(value))
|
|
444
|
+
return null;
|
|
445
|
+
const scopeId = requiredString(value, "scope_id");
|
|
446
|
+
if (!scopeId)
|
|
447
|
+
return null;
|
|
448
|
+
const blockers = Array.isArray(value["blockers"])
|
|
449
|
+
? value["blockers"].filter((entry) => typeof entry === "string")
|
|
450
|
+
: [];
|
|
451
|
+
return {
|
|
452
|
+
scope_id: scopeId,
|
|
453
|
+
shadow_repo_name: nullableString(value["shadow_repo_name"]) ?? null,
|
|
454
|
+
feature_branch: nullableString(value["feature_branch"]) ?? null,
|
|
455
|
+
epic_run_id: nullableString(value["epic_run_id"]) ?? null,
|
|
456
|
+
lifecycle_state: nullableString(value["lifecycle_state"]) ?? null,
|
|
457
|
+
lease_epoch: nullableNumber(value["lease_epoch"]),
|
|
458
|
+
lease_expires_at: nullableString(value["lease_expires_at"]) ?? null,
|
|
459
|
+
// Fail closed on each: an unreadable field must never make a scope look more
|
|
460
|
+
// reclaimable than it is.
|
|
461
|
+
lease_valid: booleanOr(value["lease_valid"], true),
|
|
462
|
+
retention_deadline: nullableString(value["retention_deadline"]) ?? null,
|
|
463
|
+
retention_elapsed: booleanOr(value["retention_elapsed"], false),
|
|
464
|
+
recoverable: booleanOr(value["recoverable"], false),
|
|
465
|
+
active_parse_run: booleanOr(value["active_parse_run"], true),
|
|
466
|
+
parse_lock_held: booleanOr(value["parse_lock_held"], true),
|
|
467
|
+
active_automation_run: booleanOr(value["active_automation_run"], true),
|
|
468
|
+
active_epic_run: booleanOr(value["active_epic_run"], true),
|
|
469
|
+
identity_valid: booleanOr(value["identity_valid"], false),
|
|
470
|
+
blockers,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
/** `GET /jira/index-scope/lifecycle?repo_name=` — every scope this repo owns. */
|
|
474
|
+
export async function getIndexScopeLifecycle(access, fetchImpl = globalThis.fetch) {
|
|
475
|
+
const result = await wrap(access, () => {
|
|
476
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/lifecycle", {
|
|
477
|
+
repo_name: access.repoName,
|
|
478
|
+
});
|
|
479
|
+
return fetchConductorJsonWithTimeout(url, getHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
480
|
+
});
|
|
481
|
+
if (!result.ok)
|
|
482
|
+
return result;
|
|
483
|
+
const refusal = softEnvelopeFailure(result.value);
|
|
484
|
+
if (refusal)
|
|
485
|
+
return refusal;
|
|
486
|
+
const body = result.value;
|
|
487
|
+
const rawScopes = Array.isArray(body["scopes"]) ? body["scopes"] : [];
|
|
488
|
+
const scopes = [];
|
|
489
|
+
for (const raw of rawScopes) {
|
|
490
|
+
const parsed = parseLifecycleEntry(raw);
|
|
491
|
+
// A malformed entry is DROPPED rather than failing the whole listing: the
|
|
492
|
+
// listing is a diagnostic, and showing an operator four readable scopes plus
|
|
493
|
+
// nothing about a fifth beats showing them nothing at all.
|
|
494
|
+
if (parsed !== null)
|
|
495
|
+
scopes.push(parsed);
|
|
496
|
+
}
|
|
497
|
+
return {
|
|
498
|
+
ok: true,
|
|
499
|
+
value: { retention_seconds: nullableNumber(body["retention_seconds"]), scopes },
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/** Shared parser for the three lease verbs, whose response shape is identical. */
|
|
503
|
+
function toLeaseResult(body) {
|
|
504
|
+
const refusal = softEnvelopeFailure(body);
|
|
505
|
+
if (refusal)
|
|
506
|
+
return refusal;
|
|
507
|
+
const record = body;
|
|
508
|
+
const scopeId = requiredString(record, "scope_id");
|
|
509
|
+
if (!scopeId)
|
|
510
|
+
return { ok: false, status: null, error: GENERIC_ERROR };
|
|
511
|
+
return {
|
|
512
|
+
ok: true,
|
|
513
|
+
value: {
|
|
514
|
+
scope_id: scopeId,
|
|
515
|
+
lifecycle_state: nullableString(record["lifecycle_state"]) ?? null,
|
|
516
|
+
lease_epoch: nullableNumber(record["lease_epoch"]),
|
|
517
|
+
lease_expires_at: nullableString(record["lease_expires_at"]) ?? null,
|
|
518
|
+
already_retired: booleanOr(record["already_retired"], false),
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
async function postScopeLifecycle(access, path, body, fetchImpl) {
|
|
523
|
+
const result = await wrap(access, () => {
|
|
524
|
+
const url = buildConductorJiraUrl(access.baseUrl, path);
|
|
525
|
+
return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({ repo_name: access.repoName, ...body }), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
526
|
+
});
|
|
527
|
+
if (!result.ok)
|
|
528
|
+
return result;
|
|
529
|
+
return toLeaseResult(result.value);
|
|
530
|
+
}
|
|
531
|
+
/** `POST /jira/index-scope/heartbeat` — renew the lease under `leaseEpoch`. */
|
|
532
|
+
export async function heartbeatIndexScope(access, request, fetchImpl = globalThis.fetch) {
|
|
533
|
+
return postScopeLifecycle(access, "/index-scope/heartbeat", { scope_id: request.scopeId, lease_epoch: request.leaseEpoch }, fetchImpl);
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* `POST /jira/index-scope/recover` — take a new ownership generation.
|
|
537
|
+
*
|
|
538
|
+
* Deliberately sends NO epoch: recovery exists for the case where the caller has
|
|
539
|
+
* no valid one, and the server's increment is what fences the previous owner.
|
|
540
|
+
*/
|
|
541
|
+
export async function recoverIndexScope(access, request, fetchImpl = globalThis.fetch) {
|
|
542
|
+
return postScopeLifecycle(access, "/index-scope/recover", { scope_id: request.scopeId }, fetchImpl);
|
|
543
|
+
}
|
|
544
|
+
/** `POST /jira/index-scope/retire` — start retention; delete nothing. */
|
|
545
|
+
export async function retireIndexScope(access, request, fetchImpl = globalThis.fetch) {
|
|
546
|
+
return postScopeLifecycle(access, "/index-scope/retire", { scope_id: request.scopeId, lease_epoch: request.leaseEpoch }, fetchImpl);
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* `POST /jira/index-scope/reclaim` — schedule the server-side teardown.
|
|
550
|
+
*
|
|
551
|
+
* There is deliberately no raw-deletion mode and no field that could name a
|
|
552
|
+
* namespace, table, or repository: the only knob is `overrideRetention`, which
|
|
553
|
+
* waives the two TIME blockers and nothing else. Everything actually deleted is
|
|
554
|
+
* derived server-side from the scope id.
|
|
555
|
+
*
|
|
556
|
+
* A refusal carries the server's ordered blocker tokens so the CLI can print WHY
|
|
557
|
+
* rather than a generic failure.
|
|
558
|
+
*/
|
|
559
|
+
export async function reclaimIndexScope(access, request, fetchImpl = globalThis.fetch) {
|
|
560
|
+
const result = await wrap(access, () => {
|
|
561
|
+
const url = buildConductorJiraUrl(access.baseUrl, "/index-scope/reclaim");
|
|
562
|
+
return fetchConductorJsonPostWithTimeout(url, postHeaders(access), JSON.stringify({
|
|
563
|
+
repo_name: access.repoName,
|
|
564
|
+
scope_id: request.scopeId,
|
|
565
|
+
override_retention: request.overrideRetention === true,
|
|
566
|
+
}), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
567
|
+
});
|
|
568
|
+
if (!result.ok)
|
|
569
|
+
return result;
|
|
570
|
+
const refusal = softEnvelopeFailure(result.value);
|
|
571
|
+
if (refusal) {
|
|
572
|
+
const body = isRecord(result.value) ? result.value : {};
|
|
573
|
+
const blockers = Array.isArray(body["blockers"])
|
|
574
|
+
? body["blockers"].filter((entry) => typeof entry === "string")
|
|
575
|
+
: undefined;
|
|
576
|
+
return blockers && blockers.length > 0 ? { ...refusal, blockers } : refusal;
|
|
577
|
+
}
|
|
578
|
+
const body = result.value;
|
|
579
|
+
return {
|
|
580
|
+
ok: true,
|
|
581
|
+
value: {
|
|
582
|
+
scope_id: nullableString(body["scope_id"]) ?? null,
|
|
583
|
+
scheduled: booleanOr(body["scheduled"], false),
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
}
|
|
@@ -87,6 +87,45 @@ export function resolveConductEpicPromptsDirectory(repoName, epicKey, deps) {
|
|
|
87
87
|
function isRecord(value) {
|
|
88
88
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Supply `null` for parse-request fields a pre-BAPI-825 version-1 ticket does
|
|
92
|
+
* not carry, returning a NEW document rather than mutating the input.
|
|
93
|
+
*
|
|
94
|
+
* The two fields were added without incrementing
|
|
95
|
+
* {@link CONDUCT_EPIC_CHECKPOINT_VERSION}, because bumping the version would
|
|
96
|
+
* make every checkpoint written by an in-flight pilot run instantly
|
|
97
|
+
* `unsupported-version` — a hard error whose documented recovery is a human
|
|
98
|
+
* editing the file. Normalizing an absent field to `null` is the compatible
|
|
99
|
+
* alternative: an old document stays valid, and every downstream TypeScript
|
|
100
|
+
* reader sees a real `null` instead of `undefined`.
|
|
101
|
+
*
|
|
102
|
+
* Only ABSENT keys are filled. A key that is present but malformed is left
|
|
103
|
+
* exactly as it is, so validation still rejects it rather than having it
|
|
104
|
+
* quietly repaired into a legal value.
|
|
105
|
+
*/
|
|
106
|
+
export function normalizeConductEpicCheckpoint(value) {
|
|
107
|
+
if (!isRecord(value) || !Array.isArray(value.tickets))
|
|
108
|
+
return value;
|
|
109
|
+
return {
|
|
110
|
+
...value,
|
|
111
|
+
// BAPI-844: a checkpoint written before the scope field existed reads as an
|
|
112
|
+
// unscoped epic rather than as invalid state.
|
|
113
|
+
index_scope_id: "index_scope_id" in value ? value.index_scope_id : null,
|
|
114
|
+
// BAPI-846: a checkpoint written before the lease field existed reads as
|
|
115
|
+
// "no lease held", not as invalid state.
|
|
116
|
+
index_scope_lease_epoch: "index_scope_lease_epoch" in value ? value.index_scope_lease_epoch : null,
|
|
117
|
+
tickets: value.tickets.map((ticket) => {
|
|
118
|
+
if (!isRecord(ticket))
|
|
119
|
+
return ticket;
|
|
120
|
+
const normalized = { ...ticket };
|
|
121
|
+
if (!("parse_requested_at" in normalized))
|
|
122
|
+
normalized.parse_requested_at = null;
|
|
123
|
+
if (!("parse_requested_for_sha" in normalized))
|
|
124
|
+
normalized.parse_requested_for_sha = null;
|
|
125
|
+
return normalized;
|
|
126
|
+
}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
90
129
|
/** A non-empty string. Blank-only values are rejected everywhere. */
|
|
91
130
|
function isText(value) {
|
|
92
131
|
return typeof value === "string" && value.trim().length > 0;
|
|
@@ -147,6 +186,14 @@ function validateTicket(value, index) {
|
|
|
147
186
|
const counters = validateCounters(value.counters, where);
|
|
148
187
|
if (!counters.ok)
|
|
149
188
|
return counters;
|
|
189
|
+
// Present-but-malformed is rejected; ABSENT is impossible here, because
|
|
190
|
+
// `normalizeConductEpicCheckpoint` runs before every validation and fills a
|
|
191
|
+
// missing parse-request field with `null` (BAPI-825/A2).
|
|
192
|
+
for (const key of ["parse_requested_at", "parse_requested_for_sha"]) {
|
|
193
|
+
if (!isNullableText(value[key])) {
|
|
194
|
+
return fail(`${where}.${key} must be a non-empty string or null`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
150
197
|
if (!Array.isArray(value.journal) || value.journal.some((line) => typeof line !== "string")) {
|
|
151
198
|
return fail(`${where}.journal must be an array of strings`);
|
|
152
199
|
}
|
|
@@ -203,8 +250,14 @@ function validateLock(value) {
|
|
|
203
250
|
* this guards against is a partially-hand-edited checkpoint (an operator
|
|
204
251
|
* unparking a run edits this file by hand) being accepted because its top-level
|
|
205
252
|
* keys still exist, and then driving the loop from a nonsense value.
|
|
253
|
+
*
|
|
254
|
+
* The candidate is passed through {@link normalizeConductEpicCheckpoint} first,
|
|
255
|
+
* so a version-1 document written before the parse-request fields existed
|
|
256
|
+
* validates unchanged. Normalization only fills ABSENT keys, so it can never
|
|
257
|
+
* launder a malformed present value past the rules below.
|
|
206
258
|
*/
|
|
207
|
-
export function validateConductEpicCheckpoint(
|
|
259
|
+
export function validateConductEpicCheckpoint(candidate) {
|
|
260
|
+
const value = normalizeConductEpicCheckpoint(candidate);
|
|
208
261
|
if (!isRecord(value))
|
|
209
262
|
return fail("checkpoint must be a JSON object");
|
|
210
263
|
if (value.version !== CONDUCT_EPIC_CHECKPOINT_VERSION) {
|
|
@@ -218,6 +271,15 @@ export function validateConductEpicCheckpoint(value) {
|
|
|
218
271
|
if (!isText(value[key]))
|
|
219
272
|
return fail(`${key} must be a timestamp string`);
|
|
220
273
|
}
|
|
274
|
+
if (!isNullableText(value.index_scope_id)) {
|
|
275
|
+
return fail("index_scope_id must be a non-empty string or null");
|
|
276
|
+
}
|
|
277
|
+
if (value.index_scope_lease_epoch !== null &&
|
|
278
|
+
(typeof value.index_scope_lease_epoch !== "number" ||
|
|
279
|
+
!Number.isInteger(value.index_scope_lease_epoch) ||
|
|
280
|
+
value.index_scope_lease_epoch < 0)) {
|
|
281
|
+
return fail("index_scope_lease_epoch must be a non-negative integer or null");
|
|
282
|
+
}
|
|
221
283
|
const deadlines = value.deadlines;
|
|
222
284
|
if (!isRecord(deadlines))
|
|
223
285
|
return fail("deadlines must be an object");
|
|
@@ -301,7 +363,14 @@ export async function readConductEpicCheckpoint(checkpointPath, fs) {
|
|
|
301
363
|
if (!validation.ok) {
|
|
302
364
|
return { kind: "invalid", error: `checkpoint at ${checkpointPath} is invalid: ${validation.error}` };
|
|
303
365
|
}
|
|
304
|
-
|
|
366
|
+
// Return the NORMALIZED document, not the parsed one. Validation normalizes a
|
|
367
|
+
// private copy, so handing back `parsed` would give every caller `undefined`
|
|
368
|
+
// where the interface promises `string | null` — and `undefined` is exactly
|
|
369
|
+
// the value a `!== null` causal check reads as "a request was recorded".
|
|
370
|
+
return {
|
|
371
|
+
kind: "ok",
|
|
372
|
+
checkpoint: normalizeConductEpicCheckpoint(parsed),
|
|
373
|
+
};
|
|
305
374
|
}
|
|
306
375
|
/** Build the initial document: every ticket `pending`, every counter zero. */
|
|
307
376
|
export function createInitialConductEpicCheckpoint(input) {
|
|
@@ -311,6 +380,8 @@ export function createInitialConductEpicCheckpoint(input) {
|
|
|
311
380
|
repo_name: input.repoName,
|
|
312
381
|
epic_branch: input.epicBranch,
|
|
313
382
|
base_branch_original: input.baseBranchOriginal,
|
|
383
|
+
index_scope_id: input.indexScopeId ?? null,
|
|
384
|
+
index_scope_lease_epoch: input.indexScopeLeaseEpoch ?? null,
|
|
314
385
|
created_at: input.now,
|
|
315
386
|
updated_at: input.now,
|
|
316
387
|
deadlines: input.deadlines ?? {
|
|
@@ -328,6 +399,8 @@ export function createInitialConductEpicCheckpoint(input) {
|
|
|
328
399
|
respawns: 0,
|
|
329
400
|
conflict_attempts: 0,
|
|
330
401
|
counters: { sessions_spawned: 0, plan_generations_observed: 0, merge_attempts: 0 },
|
|
402
|
+
parse_requested_at: null,
|
|
403
|
+
parse_requested_for_sha: null,
|
|
331
404
|
journal: [],
|
|
332
405
|
})),
|
|
333
406
|
counters: { iterations: 0, merges: 0 },
|