@bridge_gpt/mcp-server 0.2.41 → 0.2.43
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 +330 -191
- package/build/agent-capabilities/cli.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +2 -2
- package/build/claude-review-workflow.js +510 -45
- package/build/claude-user-config-doctor.js +42 -11
- package/build/cli-release.js +2 -1
- package/build/commands.generated.js +6 -5
- package/build/conduct-epic/bridge-client.js +354 -113
- package/build/conduct-epic/checkpoint-store.js +17 -0
- package/build/conduct-epic/cli.js +947 -99
- package/build/conduct-epic/cut-protocol.js +327 -0
- package/build/conduct-epic/spawn.js +14 -2
- package/build/conductor/bridge-api-client.js +148 -1
- package/build/conductor/cli.js +109 -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/recovery-cli.js +313 -0
- package/build/conductor/recovery-operations.js +219 -0
- package/build/conductor/tools.js +32 -3
- package/build/conductor/worker-ledger-cli.js +27 -1
- package/build/conductor-bin.js +20 -16
- package/build/credentials-cli.js +3 -2
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +120 -44
- package/build/drive-epic.js +375 -0
- package/build/executor/cli.js +48 -1
- package/build/executor/env.js +21 -0
- package/build/executor/http-client.js +71 -3
- package/build/executor/index-scope.js +39 -0
- package/build/executor/job-errors.js +9 -0
- package/build/executor/job-log-registry.js +69 -0
- package/build/executor/job-runner.js +198 -29
- package/build/executor/live-worker-registry.js +83 -0
- package/build/executor/observation.js +259 -6
- package/build/executor/platform.js +147 -3
- package/build/executor/process.js +58 -14
- package/build/executor/runner.js +454 -48
- package/build/executor/test-clock.js +3 -2
- package/build/executor/worker-finalization.js +233 -56
- package/build/executor/worktree.js +8 -1
- package/build/index-scope-contract.js +96 -0
- package/build/index.js +2277 -270
- package/build/init.js +83 -22
- package/build/install-bridge-conductor.js +323 -14
- package/build/install-bridge.js +225 -47
- 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 +305 -15
- package/build/plane/cli.js +73 -7
- package/build/plane/defaults.js +18 -5
- package/build/plane/manifest.js +90 -0
- package/build/plane/preflight.js +100 -10
- package/build/plane/shutdown.js +71 -3
- 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 +149 -6
- package/build/schedule-run.js +3 -2
- package/build/setup-epic.js +531 -82
- 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 +2 -1
- package/build/worktree-core.js +31 -17
- package/docs/CONDUCTOR.md +22 -0
- package/docs/install/mcp-tool-integrations.md +19 -3
- package/package.json +2 -2
- package/pipelines/greenfield-setup.json +286 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared EXACT-CUT protocol and scope-readiness poll (BAPI-843; BAPI-850).
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `conduct-epic init` so that the LLM-conductor pilot and the v2
|
|
5
|
+
* `setup-epic --feature-branch` entry point drive ONE implementation of the
|
|
6
|
+
* local cut — the same local `git` fetch, the same remote-ref existence check,
|
|
7
|
+
* the same exact-SHA push, and the same mismatch classification. This module is
|
|
8
|
+
* the single owner of those operations; neither CLI re-implements any of them.
|
|
9
|
+
*
|
|
10
|
+
* What the cut is: the server leases a hold on the canonical parse lock and
|
|
11
|
+
* names the commit the canonical index actually covers (`cut/begin`); this
|
|
12
|
+
* module creates `origin/<feature>` at EXACTLY that commit with the operator's
|
|
13
|
+
* own `git` — never the GitHub App, which is `contents: read` and cannot create
|
|
14
|
+
* refs — reads the ref back, and asks the server to record it as the scope's
|
|
15
|
+
* immutable cut (`cut/commit`). The hold is released on every outcome.
|
|
16
|
+
*
|
|
17
|
+
* Contract properties, fixed here and relied on by both CLIs:
|
|
18
|
+
*
|
|
19
|
+
* - **Never force-updates an existing branch.** The push refspec has no leading
|
|
20
|
+
* `+`, so it can only create. A branch that already exists at any commit other
|
|
21
|
+
* than the held cut commit is a refusal, never a repoint.
|
|
22
|
+
* - **Takes an already validated branch and SHA.** Callers validate the branch
|
|
23
|
+
* name (`validateBranchName`) and normalize the candidate SHA before calling;
|
|
24
|
+
* this module re-proves the SHA shape it pushes, but it is not a parser.
|
|
25
|
+
* - **No credential ever reaches argv.** The only subprocess is `git`, and its
|
|
26
|
+
* arguments are refs, remotes, and SHAs. Bridge and GitHub credentials travel
|
|
27
|
+
* only inside the typed client's headers.
|
|
28
|
+
* - **Every failure is classified, never thrown.** Callers get a discriminated
|
|
29
|
+
* outcome with operator-ready sentences and the bounded facts (expected and
|
|
30
|
+
* observed SHAs) they need to report; there is nothing to `try/catch`.
|
|
31
|
+
*/
|
|
32
|
+
import { execFile } from "node:child_process";
|
|
33
|
+
import { abandonIndexScopeCut, beginIndexScopeCut, commitIndexScopeCut, getIndexScopeStatus, } from "./bridge-client.js";
|
|
34
|
+
/**
|
|
35
|
+
* Poll bound for a scope bootstrap, shared by `conduct-epic init` and
|
|
36
|
+
* `setup-epic`. A seed copies a repository's whole parse cache and the
|
|
37
|
+
* verifying parse then downloads and change-detects it, so the ceiling is
|
|
38
|
+
* generous; the interval is what keeps the poll cheap.
|
|
39
|
+
*/
|
|
40
|
+
export const SCOPE_BOOTSTRAP_POLL_INTERVAL_MS = 5_000;
|
|
41
|
+
export const SCOPE_BOOTSTRAP_MAX_POLLS = 240; // ~20 minutes at the interval above.
|
|
42
|
+
/**
|
|
43
|
+
* Build the default list-argument subprocess runner both CLIs use for `git`.
|
|
44
|
+
*
|
|
45
|
+
* One body rather than two copies: `execFile` with `shell: false`, a generous
|
|
46
|
+
* buffer for porcelain output, and an exit code that never throws — a missing
|
|
47
|
+
* binary resolves to a non-zero code the caller classifies.
|
|
48
|
+
*/
|
|
49
|
+
export function createExecFileRunCommand() {
|
|
50
|
+
return (file, args, options) => new Promise((resolve) => {
|
|
51
|
+
execFile(file, args, {
|
|
52
|
+
cwd: options?.cwd,
|
|
53
|
+
// Git porcelain output for a many-worktree checkout can be large.
|
|
54
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
55
|
+
encoding: "utf-8",
|
|
56
|
+
timeout: options?.timeoutMs,
|
|
57
|
+
// Explicit: arguments are a list, never a concatenated shell string.
|
|
58
|
+
shell: false,
|
|
59
|
+
}, (error, stdout, stderr) => {
|
|
60
|
+
const code = error?.code;
|
|
61
|
+
resolve({
|
|
62
|
+
stdout: stdout ?? "",
|
|
63
|
+
stderr: stderr ?? "",
|
|
64
|
+
exitCode: typeof code === "number" ? code : error ? 1 : 0,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** Run `git` in the operator's checkout. */
|
|
70
|
+
export function runGit(deps, args) {
|
|
71
|
+
return Promise.resolve(deps.runCommand("git", args, { cwd: deps.cwd }));
|
|
72
|
+
}
|
|
73
|
+
/** The single trimmed line a `git rev-parse`-style command produced, or null. */
|
|
74
|
+
export function firstOutputLine(result) {
|
|
75
|
+
const value = result.stdout.split("\n")[0]?.trim() ?? "";
|
|
76
|
+
return value.length === 0 ? null : value;
|
|
77
|
+
}
|
|
78
|
+
/** The SHA from `git ls-remote --heads origin <ref>` output, or null. */
|
|
79
|
+
export function lsRemoteSha(result) {
|
|
80
|
+
const line = firstOutputLine(result);
|
|
81
|
+
if (line === null)
|
|
82
|
+
return null;
|
|
83
|
+
const sha = line.split(/\s+/)[0]?.trim() ?? "";
|
|
84
|
+
return sha.length === 0 ? null : sha;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Normalize a commit SHA to its canonical 40-character lowercase form, or `null`.
|
|
88
|
+
*
|
|
89
|
+
* Mirrors the server's own guard so a malformed value is refused HERE — before it
|
|
90
|
+
* becomes a `git push` refspec — rather than becoming an opaque git error or, far
|
|
91
|
+
* worse, a ref pushed at something that is not a commit.
|
|
92
|
+
*/
|
|
93
|
+
export function normalizeCommitSha(value) {
|
|
94
|
+
if (typeof value !== "string")
|
|
95
|
+
return null;
|
|
96
|
+
const normalized = value.trim().toLowerCase();
|
|
97
|
+
return /^[0-9a-f]{40}$/.test(normalized) ? normalized : null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Read the head of `origin/<branch>` WITHOUT mutating anything.
|
|
101
|
+
*
|
|
102
|
+
* `sha: null` means the branch does not exist on origin. A failed `ls-remote`
|
|
103
|
+
* (no remote, no network, no git) is its own outcome, never "absent": treating an
|
|
104
|
+
* unanswered read as absence is how a push lands on top of a branch nobody saw.
|
|
105
|
+
*/
|
|
106
|
+
export async function readRemoteBranchHead(deps, branch) {
|
|
107
|
+
const result = await runGit(deps, ["ls-remote", "--heads", "origin", `refs/heads/${branch}`]);
|
|
108
|
+
if (result.exitCode !== 0) {
|
|
109
|
+
return { ok: false, error: `git ls-remote could not read origin/${branch}.` };
|
|
110
|
+
}
|
|
111
|
+
return { ok: true, sha: lsRemoteSha(result) };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Make sure the exact cut object is resolvable locally, fetching it if needed.
|
|
115
|
+
*
|
|
116
|
+
* The cut is pushed BY SHA, so the object must exist in the operator's
|
|
117
|
+
* repository. A base-branch fetch usually brings it along; when it did not — the
|
|
118
|
+
* index covers a commit that is no longer an ancestor of the base tip — one
|
|
119
|
+
* targeted, NON-MUTATING fetch of that SHA is attempted (it updates no ref,
|
|
120
|
+
* creates no branch, and checks nothing out). Returns `true` when the commit
|
|
121
|
+
* resolves.
|
|
122
|
+
*/
|
|
123
|
+
export async function ensureCommitResolvableLocally(deps, commitSha) {
|
|
124
|
+
const present = await runGit(deps, ["rev-parse", "--verify", "--quiet", `${commitSha}^{commit}`]);
|
|
125
|
+
if (present.exitCode === 0)
|
|
126
|
+
return true;
|
|
127
|
+
await runGit(deps, ["fetch", "origin", commitSha]);
|
|
128
|
+
const retry = await runGit(deps, ["rev-parse", "--verify", "--quiet", `${commitSha}^{commit}`]);
|
|
129
|
+
return retry.exitCode === 0;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Drive the exact cut: `cut/begin` → re-check the remote ref under the hold →
|
|
133
|
+
* create the ref at the held commit (if absent) → read it back → `cut/commit`,
|
|
134
|
+
* releasing the hold on EVERY outcome.
|
|
135
|
+
*
|
|
136
|
+
* Everything between `begin` and the release happens while the SERVER holds the
|
|
137
|
+
* canonical repository's parse lock, so the commit the index covers cannot move
|
|
138
|
+
* underneath the ref this module creates.
|
|
139
|
+
*/
|
|
140
|
+
export async function performExactIndexScopeCut(deps, access, request) {
|
|
141
|
+
const { featureBranch, baseBranch, candidateCommitSha } = request;
|
|
142
|
+
const lease = await beginIndexScopeCut(access, {
|
|
143
|
+
featureBranch,
|
|
144
|
+
baseBranch,
|
|
145
|
+
candidateCommitSha,
|
|
146
|
+
epicRunId: request.epicRunId ?? null,
|
|
147
|
+
}, deps.fetchImpl);
|
|
148
|
+
if (!lease.ok) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
kind: "begin_refused",
|
|
152
|
+
failures: [`The index-scope cut could not begin: ${lease.error}`],
|
|
153
|
+
lease: null,
|
|
154
|
+
expectedSha: candidateCommitSha,
|
|
155
|
+
observedSha: null,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const cut = lease.value;
|
|
159
|
+
try {
|
|
160
|
+
if (cut.cut_commit_sha !== candidateCommitSha) {
|
|
161
|
+
// The server re-read the canonical snapshot under its own hold and named a
|
|
162
|
+
// different commit. Nothing has been pushed yet, so refusing costs nothing
|
|
163
|
+
// and continuing would cut the epic at a commit the preflight never checked.
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
kind: "canonical_moved",
|
|
167
|
+
failures: [
|
|
168
|
+
`The canonical index moved while init was preparing ` +
|
|
169
|
+
`(preflight saw ${candidateCommitSha}, the cut hold names ${cut.cut_commit_sha}). Re-run init.`,
|
|
170
|
+
],
|
|
171
|
+
lease: cut,
|
|
172
|
+
expectedSha: candidateCommitSha,
|
|
173
|
+
observedSha: cut.cut_commit_sha,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
// Re-check `origin/<feature>` WHILE the hold is active. A preflight
|
|
177
|
+
// observation is evidence for a refusal, never for a mutation: another
|
|
178
|
+
// operator may have created the ref in between.
|
|
179
|
+
const held = await readRemoteBranchHead(deps, featureBranch);
|
|
180
|
+
if (!held.ok) {
|
|
181
|
+
return {
|
|
182
|
+
ok: false,
|
|
183
|
+
kind: "ls_remote_failed",
|
|
184
|
+
failures: [held.error],
|
|
185
|
+
lease: cut,
|
|
186
|
+
expectedSha: cut.cut_commit_sha,
|
|
187
|
+
observedSha: null,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const heldSha = held.sha;
|
|
191
|
+
if (heldSha !== null && heldSha !== cut.cut_commit_sha) {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
kind: "existing_ref_mismatch",
|
|
195
|
+
failures: [
|
|
196
|
+
`origin/${featureBranch} exists at ${heldSha}, which is not the canonical indexed ` +
|
|
197
|
+
`commit ${cut.cut_commit_sha}. Delete it or finish the previous run first.`,
|
|
198
|
+
],
|
|
199
|
+
lease: cut,
|
|
200
|
+
expectedSha: cut.cut_commit_sha,
|
|
201
|
+
observedSha: heldSha,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
let branchCreated = false;
|
|
205
|
+
if (heldSha === null) {
|
|
206
|
+
// Create the ref from the EXACT commit, with the operator's own git. No
|
|
207
|
+
// local checkout, no branch, no worktree — and deliberately not the GitHub
|
|
208
|
+
// App, which is `contents: read` and cannot create refs. Not a force push:
|
|
209
|
+
// the refspec has no leading `+`, so it can only create.
|
|
210
|
+
const pushed = await runGit(deps, [
|
|
211
|
+
"push",
|
|
212
|
+
"origin",
|
|
213
|
+
`${cut.cut_commit_sha}:refs/heads/${featureBranch}`,
|
|
214
|
+
]);
|
|
215
|
+
if (pushed.exitCode !== 0) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
kind: "push_failed",
|
|
219
|
+
failures: [
|
|
220
|
+
`Could not create origin/${featureBranch} at the canonical indexed commit ${cut.cut_commit_sha}.`,
|
|
221
|
+
],
|
|
222
|
+
lease: cut,
|
|
223
|
+
expectedSha: cut.cut_commit_sha,
|
|
224
|
+
observedSha: null,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
branchCreated = true;
|
|
228
|
+
}
|
|
229
|
+
// Read the ref BACK from origin. What matters is what the remote now holds,
|
|
230
|
+
// not what this process intended to push.
|
|
231
|
+
const confirmed = await readRemoteBranchHead(deps, featureBranch);
|
|
232
|
+
const confirmedSha = confirmed.ok ? confirmed.sha : null;
|
|
233
|
+
if (confirmedSha !== cut.cut_commit_sha) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
kind: "confirm_failed",
|
|
237
|
+
failures: [`origin/${featureBranch} did not resolve to ${cut.cut_commit_sha} after the push.`],
|
|
238
|
+
lease: cut,
|
|
239
|
+
expectedSha: cut.cut_commit_sha,
|
|
240
|
+
observedSha: confirmedSha,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const committed = await commitIndexScopeCut(access, { scopeId: cut.scope_id, cutHoldId: cut.cut_hold_id, epicRefCommitSha: confirmedSha }, deps.fetchImpl);
|
|
244
|
+
if (!committed.ok) {
|
|
245
|
+
return {
|
|
246
|
+
ok: false,
|
|
247
|
+
kind: "commit_refused",
|
|
248
|
+
failures: [`The index-scope cut could not be recorded: ${committed.error}`],
|
|
249
|
+
lease: cut,
|
|
250
|
+
expectedSha: cut.cut_commit_sha,
|
|
251
|
+
observedSha: confirmedSha,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return { ok: true, lease: cut, branchCreated, outcome: committed.value.outcome };
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
// The hold is released on EVERY pre-seed outcome, including the success path
|
|
258
|
+
// (where the server already released it — abandon is idempotent). The seed
|
|
259
|
+
// acquires this same canonical lock itself and it is not reentrant, so
|
|
260
|
+
// handing off while still holding it would deadlock the epic against its own
|
|
261
|
+
// seed. A failed release never masks the primary failure: it is reported and
|
|
262
|
+
// the original outcome stands.
|
|
263
|
+
const abandoned = await abandonIndexScopeCut(access, { scopeId: cut.scope_id, cutHoldId: cut.cut_hold_id }, deps.fetchImpl);
|
|
264
|
+
if (!abandoned.ok) {
|
|
265
|
+
deps.errorLog(`announced: the cut hold could not be released cleanly: ${abandoned.error}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Scope-readiness poll (BAPI-843; shared with `setup-epic` by BAPI-850)
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
/** The operator-facing label for each lifecycle state the poll reports. */
|
|
273
|
+
export const SCOPE_LIFECYCLE_LABELS = Object.freeze({
|
|
274
|
+
provisioning: "Provisioning",
|
|
275
|
+
seeding: "Seeding",
|
|
276
|
+
verifying: "Verifying",
|
|
277
|
+
ready: "Ready",
|
|
278
|
+
failed: "Failed",
|
|
279
|
+
});
|
|
280
|
+
/**
|
|
281
|
+
* Poll a scope's lifecycle until it is `ready`, `failed`, or the bounded wait
|
|
282
|
+
* elapses, reporting each NEWLY observed lifecycle transition exactly once, in
|
|
283
|
+
* order, through `onTransition`.
|
|
284
|
+
*
|
|
285
|
+
* Readiness is a server-side fact this poll observes rather than concludes from
|
|
286
|
+
* any request of its own: `ready` is accepted ONLY when the status also proves
|
|
287
|
+
* `indexed_commit_sha == cut_commit_sha`. A `ready` whose watermark disagrees is
|
|
288
|
+
* reported as `ready_mismatch` — the control plane contradicting itself, which
|
|
289
|
+
* is worth refusing rather than proceeding on.
|
|
290
|
+
*
|
|
291
|
+
* A transient read failure is not a verdict: the poll keeps going and lets the
|
|
292
|
+
* bound be the thing that gives up. The interval and cap are the shared
|
|
293
|
+
* {@link SCOPE_BOOTSTRAP_POLL_INTERVAL_MS} / {@link SCOPE_BOOTSTRAP_MAX_POLLS}.
|
|
294
|
+
*/
|
|
295
|
+
export async function pollIndexScopeLifecycle(deps, access, scopeId, options = {}) {
|
|
296
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
297
|
+
const maxPolls = options.maxPolls ?? SCOPE_BOOTSTRAP_MAX_POLLS;
|
|
298
|
+
const intervalMs = options.intervalMs ?? SCOPE_BOOTSTRAP_POLL_INTERVAL_MS;
|
|
299
|
+
let lastState = "unknown";
|
|
300
|
+
let lastStatus = null;
|
|
301
|
+
let lastReportedState = null;
|
|
302
|
+
for (let poll = 0; poll < maxPolls; poll += 1) {
|
|
303
|
+
await sleep(intervalMs);
|
|
304
|
+
const status = await getIndexScopeStatus(access, scopeId, deps.fetchImpl);
|
|
305
|
+
if (!status.ok) {
|
|
306
|
+
lastState = `unreadable (${status.error})`;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
lastStatus = status.value;
|
|
310
|
+
lastState = status.value.lifecycle_state;
|
|
311
|
+
if (lastState !== lastReportedState) {
|
|
312
|
+
lastReportedState = lastState;
|
|
313
|
+
options.onTransition?.(lastState, status.value);
|
|
314
|
+
}
|
|
315
|
+
if (lastState === "ready") {
|
|
316
|
+
if (status.value.indexed_commit_sha !== null &&
|
|
317
|
+
status.value.indexed_commit_sha === status.value.cut_commit_sha) {
|
|
318
|
+
return { kind: "ready", status: status.value };
|
|
319
|
+
}
|
|
320
|
+
return { kind: "ready_mismatch", status: status.value };
|
|
321
|
+
}
|
|
322
|
+
if (lastState === "failed") {
|
|
323
|
+
return { kind: "failed", status: status.value, reason: status.value.last_error ?? "unknown" };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return { kind: "timeout", lastState, lastStatus };
|
|
327
|
+
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* argv would be visible to every process on the machine via `ps`.
|
|
22
22
|
*/
|
|
23
23
|
import { powershellSquote, shSquoteInner, } from "../start-tickets.js";
|
|
24
|
+
import { INDEX_SCOPE_ENV_VAR } from "../index-scope-contract.js";
|
|
24
25
|
/** The agents `conduct-epic spawn` may launch. */
|
|
25
26
|
export const CONDUCT_EPIC_AGENTS = ["claude", "cursor-agent"];
|
|
26
27
|
/** The repository's established default agent. */
|
|
@@ -52,6 +53,11 @@ export function resolveConductEpicAgent(agent) {
|
|
|
52
53
|
* Returns the command as a string rather than an argv array because that is what
|
|
53
54
|
* every terminal spawner in this repository consumes — a tab is opened by handing
|
|
54
55
|
* a shell a command line, not by `exec`ing a process.
|
|
56
|
+
*
|
|
57
|
+
* BAPI-844: a validated `indexScope` is prefixed as an environment assignment
|
|
58
|
+
* using the SAME quoting helpers as the rest of the command — the shell's own
|
|
59
|
+
* environment mechanism, not an argument the agent can see. An absent scope emits
|
|
60
|
+
* no prefix, so the unscoped command is unchanged byte-for-byte.
|
|
55
61
|
*/
|
|
56
62
|
export function buildConductEpicAgentCommand(input) {
|
|
57
63
|
const resolved = resolveConductEpicAgent(input.agent);
|
|
@@ -61,15 +67,21 @@ export function buildConductEpicAgentCommand(input) {
|
|
|
61
67
|
return { ok: false, error: "A worktree path is required to build the agent command." };
|
|
62
68
|
}
|
|
63
69
|
if (input.platform === "win32") {
|
|
70
|
+
const scopePrefix = input.indexScope
|
|
71
|
+
? `$env:${INDEX_SCOPE_ENV_VAR} = ${powershellSquote(input.indexScope)}; `
|
|
72
|
+
: "";
|
|
64
73
|
return {
|
|
65
74
|
ok: true,
|
|
66
|
-
command:
|
|
75
|
+
command: `${scopePrefix}Set-Location -LiteralPath ${powershellSquote(input.worktreePath)}; ` +
|
|
67
76
|
`${resolved.agent} ${powershellSquote(input.prompt)}`,
|
|
68
77
|
};
|
|
69
78
|
}
|
|
79
|
+
const scopePrefix = input.indexScope
|
|
80
|
+
? `export ${INDEX_SCOPE_ENV_VAR}='${shSquoteInner(input.indexScope)}' && `
|
|
81
|
+
: "";
|
|
70
82
|
return {
|
|
71
83
|
ok: true,
|
|
72
|
-
command:
|
|
84
|
+
command: `${scopePrefix}cd '${shSquoteInner(input.worktreePath)}' && ` +
|
|
73
85
|
`${resolved.agent} '${shSquoteInner(input.prompt)}'`,
|
|
74
86
|
};
|
|
75
87
|
}
|
|
@@ -214,6 +214,20 @@ export function extractSanitizedErrorDiagnostics(body) {
|
|
|
214
214
|
diagnostics.errorCode = boundedErrorPreview(errorCode);
|
|
215
215
|
if (message)
|
|
216
216
|
diagnostics.bodyPreview = boundedErrorPreview(message);
|
|
217
|
+
// BAPI-872: extract the allowlisted stale-CAS `current_row_version`, from
|
|
218
|
+
// either the nested `detail` object or (defensively) the top level. Only a
|
|
219
|
+
// genuine non-negative safe integer is accepted — never a boolean (JS has no
|
|
220
|
+
// strict-int check, so `Number.isSafeInteger` alone would still accept `1`
|
|
221
|
+
// but not `true`), never a string, never negative — so a malformed value can
|
|
222
|
+
// never be mistaken for a usable CAS conflict downstream.
|
|
223
|
+
const rawCurrentRowVersion = detail && typeof detail === "object" && !Array.isArray(detail)
|
|
224
|
+
? detail["current_row_version"]
|
|
225
|
+
: record["current_row_version"];
|
|
226
|
+
if (typeof rawCurrentRowVersion === "number" &&
|
|
227
|
+
Number.isSafeInteger(rawCurrentRowVersion) &&
|
|
228
|
+
rawCurrentRowVersion >= 0) {
|
|
229
|
+
diagnostics.currentRowVersion = rawCurrentRowVersion;
|
|
230
|
+
}
|
|
217
231
|
return diagnostics;
|
|
218
232
|
}
|
|
219
233
|
/** Redact exact secret substrings (e.g. the API key from the request headers). */
|
|
@@ -231,6 +245,10 @@ function redactDiagnosticValues(diagnostics, secrets) {
|
|
|
231
245
|
out.errorCode = scrub(diagnostics.errorCode);
|
|
232
246
|
if (diagnostics.bodyPreview)
|
|
233
247
|
out.bodyPreview = scrub(diagnostics.bodyPreview);
|
|
248
|
+
// A number has nothing to scrub; carried through unchanged (BAPI-872).
|
|
249
|
+
if (typeof diagnostics.currentRowVersion === "number") {
|
|
250
|
+
out.currentRowVersion = diagnostics.currentRowVersion;
|
|
251
|
+
}
|
|
234
252
|
return out;
|
|
235
253
|
}
|
|
236
254
|
/**
|
|
@@ -267,6 +285,13 @@ export class ConductorBridgeApiError extends Error {
|
|
|
267
285
|
status;
|
|
268
286
|
errorCode;
|
|
269
287
|
bodyPreview;
|
|
288
|
+
/**
|
|
289
|
+
* The stale-CAS `current_row_version` (BAPI-872), when the backend supplied
|
|
290
|
+
* one. Deliberately NOT interpolated into `.message` (unlike `bodyPreview`) —
|
|
291
|
+
* every recovery CLI surface must never print a row-version value, and callers
|
|
292
|
+
* that need it read this field directly.
|
|
293
|
+
*/
|
|
294
|
+
currentRowVersion;
|
|
270
295
|
constructor(kindOrMessage, status, diagnostics) {
|
|
271
296
|
const isKnownKind = CONDUCTOR_BRIDGE_API_ERROR_KINDS.includes(kindOrMessage);
|
|
272
297
|
const errorCode = diagnostics?.errorCode;
|
|
@@ -293,6 +318,9 @@ export class ConductorBridgeApiError extends Error {
|
|
|
293
318
|
this.errorCode = errorCode;
|
|
294
319
|
if (bodyPreview)
|
|
295
320
|
this.bodyPreview = bodyPreview;
|
|
321
|
+
if (typeof diagnostics?.currentRowVersion === "number") {
|
|
322
|
+
this.currentRowVersion = diagnostics.currentRowVersion;
|
|
323
|
+
}
|
|
296
324
|
}
|
|
297
325
|
}
|
|
298
326
|
/**
|
|
@@ -873,6 +901,27 @@ export async function updateEpicRunStatus(access, request, fetchImpl = globalThi
|
|
|
873
901
|
const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
874
902
|
return parsed;
|
|
875
903
|
}
|
|
904
|
+
/**
|
|
905
|
+
* POST `/jira/epic-runs/runs/{epic_run_id}/stop` — the operator emergency brake
|
|
906
|
+
* (BAPI-732, wired to the conductor CLI in BAPI-872). Idempotent: a repeated stop
|
|
907
|
+
* returns HTTP 200 with `committed: false`; a terminal run (`done`/`abandoned`)
|
|
908
|
+
* returns a `409 RUN_TERMINAL` conflict, which surfaces as a thrown
|
|
909
|
+
* {@link ConductorBridgeApiError} with `status: 409` and `errorCode: "RUN_TERMINAL"`.
|
|
910
|
+
* Transport/auth/server failures throw a sanitized {@link ConductorBridgeApiError}.
|
|
911
|
+
*/
|
|
912
|
+
export async function stopEpicRun(access, request, fetchImpl = globalThis.fetch) {
|
|
913
|
+
requireNonEmptyString(request.epicRunId);
|
|
914
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicRunId)}/stop`);
|
|
915
|
+
const body = JSON.stringify({
|
|
916
|
+
repo_name: access.repoName,
|
|
917
|
+
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
918
|
+
...(request.expectedGeneration !== undefined
|
|
919
|
+
? { expected_generation: request.expectedGeneration }
|
|
920
|
+
: {}),
|
|
921
|
+
});
|
|
922
|
+
const parsed = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
923
|
+
return parsed;
|
|
924
|
+
}
|
|
876
925
|
// ---------------------------------------------------------------------------
|
|
877
926
|
// Per-ticket CAS status advancement
|
|
878
927
|
// ---------------------------------------------------------------------------
|
|
@@ -937,6 +986,78 @@ export async function advanceEpicTicketStatus(access, request, fetchImpl = globa
|
|
|
937
986
|
const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
938
987
|
return parseAdvanceEpicTicketStatusResult(parsed);
|
|
939
988
|
}
|
|
989
|
+
// ---------------------------------------------------------------------------
|
|
990
|
+
// Operator recovery: ticket unpark + adopt-current-head-and-unpark (BAPI-872)
|
|
991
|
+
// ---------------------------------------------------------------------------
|
|
992
|
+
/**
|
|
993
|
+
* Catch a stale-CAS 400 thrown by the POST transport and translate it into the
|
|
994
|
+
* SAME `{ok: false, kind: "cas-conflict", current_row_version}` result
|
|
995
|
+
* {@link parseAdvanceEpicTicketStatusResult} already produces for the PATCH CAS
|
|
996
|
+
* endpoint — the previously dormant conflict branch that now becomes production
|
|
997
|
+
* behavior for both unpark lanes. Only a genuine stale-CAS shape (400 status with
|
|
998
|
+
* a validated `currentRowVersion`) is translated; every other failure re-throws
|
|
999
|
+
* unchanged, preserving `ConductorBridgeApiError` for terminal, validation,
|
|
1000
|
+
* authorization, and transport failures.
|
|
1001
|
+
*/
|
|
1002
|
+
async function postUnparkLikeRequest(url, headers, body, fetchImpl) {
|
|
1003
|
+
try {
|
|
1004
|
+
const parsed = await fetchConductorJsonPostWithTimeout(url, headers, body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1005
|
+
return parseAdvanceEpicTicketStatusResult(parsed);
|
|
1006
|
+
}
|
|
1007
|
+
catch (err) {
|
|
1008
|
+
if (err instanceof ConductorBridgeApiError &&
|
|
1009
|
+
err.status === 400 &&
|
|
1010
|
+
typeof err.currentRowVersion === "number") {
|
|
1011
|
+
return parseAdvanceEpicTicketStatusResult({
|
|
1012
|
+
ok: false,
|
|
1013
|
+
kind: "cas-conflict",
|
|
1014
|
+
current_row_version: err.currentRowVersion,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
throw err;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/unpark` — move a
|
|
1022
|
+
* parked `needs_human` ticket back into its gate machine (BAPI-536, wired to the
|
|
1023
|
+
* conductor CLI in BAPI-872). A stale `expectedRowVersion` surfaces as the
|
|
1024
|
+
* discriminated `{ok: false, kind: "cas-conflict", current_row_version}` result
|
|
1025
|
+
* rather than a thrown error, so a bounded caller-side retry can re-read state and
|
|
1026
|
+
* try again without special-casing an exception.
|
|
1027
|
+
*/
|
|
1028
|
+
export async function unparkEpicTicket(access, request, fetchImpl = globalThis.fetch) {
|
|
1029
|
+
requireNonEmptyString(request.epicRunId);
|
|
1030
|
+
requireNonEmptyString(request.ticketKey);
|
|
1031
|
+
requireNonNegativeSafeInteger(request.expectedRowVersion);
|
|
1032
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicRunId)}/tickets/${encodeURIComponent(request.ticketKey)}/unpark`);
|
|
1033
|
+
const body = JSON.stringify({
|
|
1034
|
+
repo_name: access.repoName,
|
|
1035
|
+
expected_row_version: request.expectedRowVersion,
|
|
1036
|
+
...(request.idempotencyKey !== undefined ? { idempotency_key: request.idempotencyKey } : {}),
|
|
1037
|
+
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
1038
|
+
});
|
|
1039
|
+
return postUnparkLikeRequest(url, conductorPostHeaders(access), body, fetchImpl);
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/adopt-current-head-and-unpark`
|
|
1043
|
+
* — the recovery lane for a human/external push that drifted the PR head off the
|
|
1044
|
+
* ticket's anchored `expected_head_sha` (BAPI-571 B3, wired to the conductor CLI
|
|
1045
|
+
* in BAPI-872). Same CAS-conflict/result shape as {@link unparkEpicTicket}; shares
|
|
1046
|
+
* {@link parseAdvanceEpicTicketStatusResult} rather than a second parser.
|
|
1047
|
+
*/
|
|
1048
|
+
export async function adoptCurrentHeadAndUnparkTicket(access, request, fetchImpl = globalThis.fetch) {
|
|
1049
|
+
requireNonEmptyString(request.epicRunId);
|
|
1050
|
+
requireNonEmptyString(request.ticketKey);
|
|
1051
|
+
requireNonNegativeSafeInteger(request.expectedRowVersion);
|
|
1052
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicRunId)}/tickets/${encodeURIComponent(request.ticketKey)}/adopt-current-head-and-unpark`);
|
|
1053
|
+
const body = JSON.stringify({
|
|
1054
|
+
repo_name: access.repoName,
|
|
1055
|
+
expected_row_version: request.expectedRowVersion,
|
|
1056
|
+
...(request.idempotencyKey !== undefined ? { idempotency_key: request.idempotencyKey } : {}),
|
|
1057
|
+
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
1058
|
+
});
|
|
1059
|
+
return postUnparkLikeRequest(url, conductorPostHeaders(access), body, fetchImpl);
|
|
1060
|
+
}
|
|
940
1061
|
/**
|
|
941
1062
|
* Idempotently seed an epic ticket status row via POST to the per-epic tickets
|
|
942
1063
|
* endpoint. The backend uses ON CONFLICT DO NOTHING so repeated seeding across
|
|
@@ -1078,6 +1199,13 @@ function parseShadowDispatchFreshnessResult(parsed) {
|
|
|
1078
1199
|
indexedCommitSha: typeof obj.indexed_commit_sha === "string" ? obj.indexed_commit_sha : null,
|
|
1079
1200
|
shadowRepoName: typeof obj.shadow_repo_name === "string" ? obj.shadow_repo_name : null,
|
|
1080
1201
|
lastError: typeof obj.last_error === "string" ? obj.last_error : null,
|
|
1202
|
+
// Fail closed: only an explicit `true` expires a deadline. An older backend
|
|
1203
|
+
// that does not send the field at all keeps the previous behavior (hold
|
|
1204
|
+
// forever on this side, bounded by the Python reconciler's own park).
|
|
1205
|
+
deadlineExpired: obj.deadline_expired === true,
|
|
1206
|
+
blockedAdvanceReason: typeof obj.blocked_advance_reason === "string" && obj.blocked_advance_reason.length > 0
|
|
1207
|
+
? obj.blocked_advance_reason
|
|
1208
|
+
: null,
|
|
1081
1209
|
};
|
|
1082
1210
|
}
|
|
1083
1211
|
/**
|
|
@@ -1176,7 +1304,26 @@ function parseValidateEpicPlanResult(parsed) {
|
|
|
1176
1304
|
insertedEdges < 0) {
|
|
1177
1305
|
throw new ConductorBridgeApiError("server");
|
|
1178
1306
|
}
|
|
1179
|
-
|
|
1307
|
+
// BAPI-848 — the coverage diagnostics are read TOLERANTLY, unlike the fields
|
|
1308
|
+
// above. A server that predates them is not a protocol error, and the CLI must
|
|
1309
|
+
// keep working against one; the defaults below are the honest reading of an
|
|
1310
|
+
// absent field (nothing reported), and the renderer states the scope it was
|
|
1311
|
+
// actually given rather than inventing coverage it cannot see.
|
|
1312
|
+
return {
|
|
1313
|
+
planHash,
|
|
1314
|
+
serializationEnabled,
|
|
1315
|
+
insertedEdges,
|
|
1316
|
+
overlappingPairsFound: safeCount(p["overlapping_pairs_found"]),
|
|
1317
|
+
undeclaredNodes: safeCount(p["undeclared_nodes"]),
|
|
1318
|
+
undeclaredPairsSkipped: safeCount(p["undeclared_pairs_skipped"]),
|
|
1319
|
+
coverageScope: typeof p["coverage_scope"] === "string" && p["coverage_scope"].trim() !== ""
|
|
1320
|
+
? p["coverage_scope"]
|
|
1321
|
+
: "unreported",
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
/** Non-negative safe integer, or 0 for anything else (absent field included). */
|
|
1325
|
+
function safeCount(value) {
|
|
1326
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
1180
1327
|
}
|
|
1181
1328
|
/**
|
|
1182
1329
|
* POST the immutable plan blob to the durable-store endpoint. The blob is
|