@bridge_gpt/mcp-server 0.2.42 → 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.
@@ -0,0 +1,313 @@
1
+ /**
2
+ * `conductor` operator recovery verbs (BAPI-872): `stop-run`, `abandon-run`,
3
+ * `unpark`, `adopt-current-head-and-unpark`.
4
+ *
5
+ * CLI-only by design — this is an explicit ticket non-goal, not an oversight:
6
+ * none of these four command names is ever registered as an MCP tool, tool
7
+ * description, or generated tool-schema input (`index-static.test.ts` guards
8
+ * this). They exist so an operator (or `plane down`, via
9
+ * `recovery-operations.ts`) can recover a stuck run or ticket WITHOUT
10
+ * hand-rolling an HTTP request — the exact thing setup-epic's old raw-PATCH
11
+ * guidance used to ask for.
12
+ *
13
+ * Every ticket command's `row_version` CAS handling is entirely internal to
14
+ * `recovery-operations.ts`. This surface never accepts, echoes, or asks the
15
+ * operator for a version counter — see {@link REJECTED_VERSION_FLAGS}.
16
+ */
17
+ import { ConductorValidationError, toConductorErrorEnvelope } from "./errors.js";
18
+ // ---------------------------------------------------------------------------
19
+ // Flag parsing
20
+ // ---------------------------------------------------------------------------
21
+ const RUN_VALUE_FLAGS = new Set(["--epic-run-id"]);
22
+ const TICKET_VALUE_FLAGS = new Set(["--epic-run-id", "--ticket-key"]);
23
+ const RECOVERY_BOOL_FLAGS = new Set(["--json", "--help"]);
24
+ /**
25
+ * Every spelling of an operator-supplied version counter this surface must
26
+ * refuse outright (BAPI-872) — row_version handling is internal to
27
+ * {@link module:./recovery-operations}, and no ticket command may accept one.
28
+ */
29
+ const REJECTED_VERSION_FLAGS = new Set([
30
+ "--row-version",
31
+ "--expected-row-version",
32
+ "--row_version",
33
+ "--expected_row_version",
34
+ ]);
35
+ /**
36
+ * Tokenize `--flag value` / `--flag=value` / boolean flags for the recovery
37
+ * verbs. A rejected version flag throws BEFORE its value (if any) is ever
38
+ * read, so the offending value is never echoed, normalized, or invited.
39
+ */
40
+ function tokenizeRecoveryFlags(argv, valueFlags, boolFlags) {
41
+ const values = new Map();
42
+ const bools = new Set();
43
+ for (let i = 0; i < argv.length; i += 1) {
44
+ const token = argv[i];
45
+ if (token === "-h") {
46
+ bools.add("--help");
47
+ continue;
48
+ }
49
+ if (!token.startsWith("--")) {
50
+ throw new ConductorValidationError(`Unexpected argument "${token}".`);
51
+ }
52
+ const eq = token.indexOf("=");
53
+ const name = eq >= 0 ? token.slice(0, eq) : token;
54
+ if (REJECTED_VERSION_FLAGS.has(name)) {
55
+ throw new ConductorValidationError(`Flag "${name}" is not supported here — row-version handling is internal to ` +
56
+ "recovery and cannot be supplied by the operator. Omit it; recovery reads the " +
57
+ "current version itself.");
58
+ }
59
+ if (boolFlags.has(name)) {
60
+ bools.add(name);
61
+ continue;
62
+ }
63
+ if (!valueFlags.has(name)) {
64
+ throw new ConductorValidationError(`Unknown flag "${name}".`);
65
+ }
66
+ let value;
67
+ if (eq >= 0) {
68
+ value = token.slice(eq + 1);
69
+ }
70
+ else {
71
+ const next = argv[i + 1];
72
+ if (next === undefined) {
73
+ throw new ConductorValidationError(`Flag "${name}" requires a value.`);
74
+ }
75
+ value = next;
76
+ i += 1;
77
+ }
78
+ values.set(name, value);
79
+ }
80
+ return { values, bools };
81
+ }
82
+ function parseRunRecoveryArgs(argv) {
83
+ const { values, bools } = tokenizeRecoveryFlags(argv, RUN_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
84
+ if (bools.has("--help")) {
85
+ return { epicRunId: "", json: bools.has("--json"), help: true };
86
+ }
87
+ const epicRunId = values.get("--epic-run-id");
88
+ if (epicRunId === undefined || epicRunId.trim().length === 0) {
89
+ throw new ConductorValidationError('Flag "--epic-run-id" is required and must be non-empty.');
90
+ }
91
+ return { epicRunId: epicRunId.trim(), json: bools.has("--json"), help: false };
92
+ }
93
+ function parseTicketRecoveryArgs(argv) {
94
+ const { values, bools } = tokenizeRecoveryFlags(argv, TICKET_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
95
+ if (bools.has("--help")) {
96
+ return { epicRunId: "", ticketKey: "", json: bools.has("--json"), help: true };
97
+ }
98
+ const epicRunId = values.get("--epic-run-id");
99
+ if (epicRunId === undefined || epicRunId.trim().length === 0) {
100
+ throw new ConductorValidationError('Flag "--epic-run-id" is required and must be non-empty.');
101
+ }
102
+ const ticketKey = values.get("--ticket-key");
103
+ if (ticketKey === undefined || ticketKey.trim().length === 0) {
104
+ throw new ConductorValidationError('Flag "--ticket-key" is required and must be non-empty.');
105
+ }
106
+ return {
107
+ epicRunId: epicRunId.trim(),
108
+ ticketKey: ticketKey.trim(),
109
+ json: bools.has("--json"),
110
+ help: false,
111
+ };
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // Shared access resolution
115
+ // ---------------------------------------------------------------------------
116
+ /**
117
+ * Resolve Bridge API access through the shared credential-store resolution
118
+ * (never `BAPI_API_KEY` read directly). Prints the sanitized failure itself
119
+ * and returns `null` on failure so the caller can return exit code `1`
120
+ * without duplicating the rendering.
121
+ */
122
+ async function resolveRecoveryAccess(json) {
123
+ const { resolveConductorBridgeApiAccess } = await import("./bridge-api-client.js");
124
+ const result = await resolveConductorBridgeApiAccess();
125
+ if (!result.ok) {
126
+ if (json) {
127
+ console.log(JSON.stringify({ ok: false, kind: "unauthorized", error: result.error }));
128
+ }
129
+ else {
130
+ console.error(`Error: ${result.error}`);
131
+ }
132
+ return null;
133
+ }
134
+ return result.access;
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // stop-run
138
+ // ---------------------------------------------------------------------------
139
+ const STOP_RUN_USAGE = [
140
+ "Usage: conductor stop-run --epic-run-id <id> [--json]",
141
+ "",
142
+ "Stop an epic run: block new dispatch and cancel its queued work. The run",
143
+ "record is preserved (never deleted) so its history stays inspectable.",
144
+ "Idempotent — repeating it once stopped is a safe no-op.",
145
+ ].join("\n");
146
+ function renderStopRunResult(result, json) {
147
+ if (json) {
148
+ console.log(JSON.stringify(result));
149
+ return result.ok ? 0 : 1;
150
+ }
151
+ if (result.ok && result.kind === "committed") {
152
+ console.log(`Run ${result.epicRunId}: stopped. Its queued work was cancelled; the run record ` +
153
+ "remains available for inspection.");
154
+ return 0;
155
+ }
156
+ if (result.ok && result.kind === "already-stopped") {
157
+ console.log(`Run ${result.epicRunId}: already stopped; no further action.`);
158
+ return 0;
159
+ }
160
+ if (!result.ok && result.kind === "terminal") {
161
+ console.error(`Run ${result.epicRunId}: terminal (${result.status}) and cannot be stopped.`);
162
+ return 1;
163
+ }
164
+ console.error(`Run ${result.epicRunId}: could not be stopped — ${result.message}.`);
165
+ return 1;
166
+ }
167
+ export async function runStopRunCommand(argv) {
168
+ try {
169
+ const parsed = parseRunRecoveryArgs(argv);
170
+ if (parsed.help) {
171
+ console.log(STOP_RUN_USAGE);
172
+ return 0;
173
+ }
174
+ const access = await resolveRecoveryAccess(parsed.json);
175
+ if (!access)
176
+ return 1;
177
+ const { stopEpicRunRecovery } = await import("./recovery-operations.js");
178
+ const result = await stopEpicRunRecovery(access, { epicRunId: parsed.epicRunId });
179
+ return renderStopRunResult(result, parsed.json);
180
+ }
181
+ catch (error) {
182
+ console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
183
+ return 1;
184
+ }
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // abandon-run
188
+ // ---------------------------------------------------------------------------
189
+ const ABANDON_RUN_USAGE = [
190
+ "Usage: conductor abandon-run --epic-run-id <id> [--json]",
191
+ "",
192
+ "Abandon an epic run — a TERMINAL, IRREVERSIBLE transition. An abandoned run",
193
+ "can never resume or be reused; start a new run instead. Idempotent —",
194
+ "repeating it on an already-abandoned run is a safe no-op.",
195
+ ].join("\n");
196
+ function renderAbandonRunResult(result, json) {
197
+ if (json) {
198
+ console.log(JSON.stringify(result));
199
+ return result.ok ? 0 : 1;
200
+ }
201
+ if (result.ok && result.kind === "abandoned") {
202
+ console.log(`Run ${result.epicRunId}: abandoned (terminal).`);
203
+ return 0;
204
+ }
205
+ if (result.ok && result.kind === "already-abandoned") {
206
+ console.log(`Run ${result.epicRunId}: already abandoned (terminal); no further action.`);
207
+ return 0;
208
+ }
209
+ console.error(`Run ${result.epicRunId}: could not be abandoned — ${result.message}.`);
210
+ return 1;
211
+ }
212
+ export async function runAbandonRunCommand(argv) {
213
+ try {
214
+ const parsed = parseRunRecoveryArgs(argv);
215
+ if (parsed.help) {
216
+ console.log(ABANDON_RUN_USAGE);
217
+ return 0;
218
+ }
219
+ const access = await resolveRecoveryAccess(parsed.json);
220
+ if (!access)
221
+ return 1;
222
+ const { abandonEpicRunRecovery } = await import("./recovery-operations.js");
223
+ const result = await abandonEpicRunRecovery(access, { epicRunId: parsed.epicRunId });
224
+ return renderAbandonRunResult(result, parsed.json);
225
+ }
226
+ catch (error) {
227
+ console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
228
+ return 1;
229
+ }
230
+ }
231
+ // ---------------------------------------------------------------------------
232
+ // unpark / adopt-current-head-and-unpark (shared rendering)
233
+ // ---------------------------------------------------------------------------
234
+ const UNPARK_USAGE = [
235
+ "Usage: conductor unpark --epic-run-id <id> --ticket-key <key> [--json]",
236
+ "",
237
+ "Move a parked (needs_human) ticket back into its gate machine, once the",
238
+ "operator has resolved what parked it. Retries a bounded number of times on",
239
+ "its own if the ticket changes concurrently — never accepts a version counter.",
240
+ ].join("\n");
241
+ const ADOPT_CURRENT_HEAD_USAGE = [
242
+ "Usage: conductor adopt-current-head-and-unpark --epic-run-id <id> --ticket-key <key> [--json]",
243
+ "",
244
+ "Recover a ticket parked because a human/external push drifted the PR head",
245
+ "off its anchored commit: adopt the CURRENT PR head and unpark in one step.",
246
+ "Retries a bounded number of times on its own if the ticket changes",
247
+ "concurrently — never accepts a version counter.",
248
+ ].join("\n");
249
+ function renderTicketRecoveryResult(result, json) {
250
+ if (json) {
251
+ console.log(JSON.stringify(result));
252
+ return result.ok ? 0 : 1;
253
+ }
254
+ if (result.ok) {
255
+ console.log(`Ticket ${result.ticketKey} (run ${result.epicRunId}): unparked — status: ${result.status}.`);
256
+ return 0;
257
+ }
258
+ if (result.kind === "ticket-not-found") {
259
+ console.error(`Ticket ${result.ticketKey}: not found in run ${result.epicRunId}.`);
260
+ return 1;
261
+ }
262
+ if (result.kind === "concurrent-change-exhausted") {
263
+ console.error(`Ticket ${result.ticketKey} (run ${result.epicRunId}): could not recover — it kept ` +
264
+ "changing concurrently. Re-check the run/ticket status and try again.");
265
+ return 1;
266
+ }
267
+ console.error(`Ticket ${result.ticketKey} (run ${result.epicRunId}): could not recover — ${result.message}.`);
268
+ return 1;
269
+ }
270
+ export async function runUnparkCommand(argv) {
271
+ try {
272
+ const parsed = parseTicketRecoveryArgs(argv);
273
+ if (parsed.help) {
274
+ console.log(UNPARK_USAGE);
275
+ return 0;
276
+ }
277
+ const access = await resolveRecoveryAccess(parsed.json);
278
+ if (!access)
279
+ return 1;
280
+ const { unparkEpicTicketWithRetry } = await import("./recovery-operations.js");
281
+ const result = await unparkEpicTicketWithRetry(access, {
282
+ epicRunId: parsed.epicRunId,
283
+ ticketKey: parsed.ticketKey,
284
+ });
285
+ return renderTicketRecoveryResult(result, parsed.json);
286
+ }
287
+ catch (error) {
288
+ console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
289
+ return 1;
290
+ }
291
+ }
292
+ export async function runAdoptCurrentHeadAndUnparkCommand(argv) {
293
+ try {
294
+ const parsed = parseTicketRecoveryArgs(argv);
295
+ if (parsed.help) {
296
+ console.log(ADOPT_CURRENT_HEAD_USAGE);
297
+ return 0;
298
+ }
299
+ const access = await resolveRecoveryAccess(parsed.json);
300
+ if (!access)
301
+ return 1;
302
+ const { adoptCurrentHeadAndUnparkWithRetry } = await import("./recovery-operations.js");
303
+ const result = await adoptCurrentHeadAndUnparkWithRetry(access, {
304
+ epicRunId: parsed.epicRunId,
305
+ ticketKey: parsed.ticketKey,
306
+ });
307
+ return renderTicketRecoveryResult(result, parsed.json);
308
+ }
309
+ catch (error) {
310
+ console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
311
+ return 1;
312
+ }
313
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Shared Bridge API operator-recovery operations (BAPI-872).
3
+ *
4
+ * Centralizes stop, abandon, and ticket unpark/adopt-current-head-and-unpark so
5
+ * the conductor CLI recovery verbs (`recovery-cli.ts`) and `plane down`
6
+ * (`../plane/shutdown.ts`, wired through `../plane/cli.ts`) share EXACTLY the
7
+ * same stop semantics, and so this module — never a caller — owns every
8
+ * ticket-recovery `row_version` read, retry, and idempotency-key decision.
9
+ *
10
+ * This module is a pure result-classification layer over the typed Bridge API
11
+ * client (`bridge-api-client.ts`). It prints nothing, never touches argv, and
12
+ * never renders operator-facing text — every result here is a discriminated
13
+ * union carrying only semantic outcomes and identifiers, and deliberately never
14
+ * a `row_version` or other CAS-internal value. Rendering belongs to the two
15
+ * callers: `recovery-cli.ts` and `plane/shutdown.ts`.
16
+ */
17
+ import { randomUUID } from "crypto";
18
+ import { adoptCurrentHeadAndUnparkTicket, ConductorBridgeApiError, fetchEpicRunState, safeDiagnosticMessage, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, } from "./bridge-api-client.js";
19
+ /**
20
+ * Bounded total attempts for a ticket-recovery CAS retry loop. Each attempt
21
+ * begins with a fresh, authoritative state read — so this bounds full
22
+ * read-then-mutate cycles, not raw HTTP calls.
23
+ */
24
+ export const RECOVERY_TICKET_RETRY_LIMIT = 3;
25
+ /**
26
+ * Stop an epic run through the existing `stop` endpoint, classifying every
27
+ * outcome the endpoint can produce: a freshly committed stop, an idempotent
28
+ * repeat, a terminal refusal, or an unreachable/failed API.
29
+ *
30
+ * State is re-read ONLY on a terminal refusal, to report the run's actual
31
+ * `done`/`abandoned` status — never on the ordinary committed or idempotent
32
+ * paths, which need no second read.
33
+ */
34
+ export async function stopEpicRunRecovery(access, options) {
35
+ try {
36
+ const response = await stopEpicRun(access, {
37
+ epicRunId: options.epicRunId,
38
+ reason: options.reason,
39
+ });
40
+ return response.committed
41
+ ? { ok: true, kind: "committed", epicRunId: options.epicRunId }
42
+ : { ok: true, kind: "already-stopped", epicRunId: options.epicRunId };
43
+ }
44
+ catch (err) {
45
+ if (err instanceof ConductorBridgeApiError &&
46
+ err.status === 409 &&
47
+ err.errorCode === "RUN_TERMINAL") {
48
+ const status = await readTerminalRunStatus(access, options.epicRunId);
49
+ return { ok: false, kind: "terminal", epicRunId: options.epicRunId, status };
50
+ }
51
+ return {
52
+ ok: false,
53
+ kind: "unavailable",
54
+ epicRunId: options.epicRunId,
55
+ message: safeDiagnosticMessage(err, "stop request failed"),
56
+ };
57
+ }
58
+ }
59
+ /**
60
+ * Best-effort read of a run's actual status for a terminal-refusal report.
61
+ * Never throws: an unreadable state still renders SOME terminal report rather
62
+ * than losing the stop refusal itself to a secondary read failure.
63
+ */
64
+ async function readTerminalRunStatus(access, epicRunId) {
65
+ try {
66
+ const state = await fetchEpicRunState(access, epicRunId);
67
+ return state.epic_run.status;
68
+ }
69
+ catch {
70
+ return "terminal";
71
+ }
72
+ }
73
+ /**
74
+ * Abandon an epic run through the existing status-CAS PATCH lane
75
+ * (`updateEpicRunStatus`), reading the run's current status first so the PATCH
76
+ * always carries a freshly observed `expectedStatus` — never issued without
77
+ * one. An already-abandoned run is a safe no-op success; a lost CAS (the
78
+ * status moved concurrently) fails safely rather than retrying an unobserved
79
+ * transition or re-issuing an unguarded PATCH.
80
+ */
81
+ export async function abandonEpicRunRecovery(access, options) {
82
+ let state;
83
+ try {
84
+ state = await fetchEpicRunState(access, options.epicRunId);
85
+ }
86
+ catch (err) {
87
+ return {
88
+ ok: false,
89
+ kind: "unavailable",
90
+ epicRunId: options.epicRunId,
91
+ message: safeDiagnosticMessage(err, "could not read run state"),
92
+ };
93
+ }
94
+ const currentStatus = state.epic_run.status;
95
+ if (currentStatus === "abandoned") {
96
+ return { ok: true, kind: "already-abandoned", epicRunId: options.epicRunId };
97
+ }
98
+ try {
99
+ await updateEpicRunStatus(access, {
100
+ epicKey: options.epicRunId,
101
+ status: "abandoned",
102
+ expectedStatus: currentStatus,
103
+ });
104
+ return { ok: true, kind: "abandoned", epicRunId: options.epicRunId };
105
+ }
106
+ catch (err) {
107
+ if (err instanceof ConductorBridgeApiError && err.status === 400) {
108
+ // The status-CAS PATCH refused: the run's status moved concurrently since
109
+ // the read above. Never retried here — an unobserved transition must not
110
+ // be guessed at, and this is precisely why abandon reads state fresh on
111
+ // every invocation rather than caching or reusing a prior read.
112
+ return {
113
+ ok: false,
114
+ kind: "concurrent-change",
115
+ epicRunId: options.epicRunId,
116
+ message: "the run's status changed concurrently; re-check its state and retry",
117
+ };
118
+ }
119
+ return {
120
+ ok: false,
121
+ kind: "unavailable",
122
+ epicRunId: options.epicRunId,
123
+ message: safeDiagnosticMessage(err, "abandon request failed"),
124
+ };
125
+ }
126
+ }
127
+ /**
128
+ * The shared bounded-retry engine behind both ticket recovery operations.
129
+ *
130
+ * Every attempt — including every retry after a CAS conflict — begins with a
131
+ * fresh, authoritative `fetchEpicRunState` read. A conflict's own
132
+ * `current_row_version` is NEVER substituted for that reread: the reread is
133
+ * what proves the version is authoritative, not merely the latest one the
134
+ * conflict payload happened to report. Bounded to
135
+ * {@link RECOVERY_TICKET_RETRY_LIMIT} full read-then-mutate cycles; exhausting
136
+ * it is reported as `concurrent-change-exhausted`, never as an infinite loop.
137
+ *
138
+ * ONE idempotency key is generated per invocation and reused across every
139
+ * retry within it — a distinct recovery-operation call gets its own key.
140
+ */
141
+ async function ticketRecoveryWithRetry(access, options, mutate) {
142
+ const idempotencyKey = randomUUID();
143
+ for (let attempt = 0; attempt < RECOVERY_TICKET_RETRY_LIMIT; attempt += 1) {
144
+ let state;
145
+ try {
146
+ state = await fetchEpicRunState(access, options.epicRunId);
147
+ }
148
+ catch (err) {
149
+ return {
150
+ ok: false,
151
+ kind: "unavailable",
152
+ epicRunId: options.epicRunId,
153
+ ticketKey: options.ticketKey,
154
+ message: safeDiagnosticMessage(err, "could not read run state"),
155
+ };
156
+ }
157
+ const ticket = state.ticket_statuses.find((t) => t.ticket_key === options.ticketKey);
158
+ if (!ticket) {
159
+ return {
160
+ ok: false,
161
+ kind: "ticket-not-found",
162
+ epicRunId: options.epicRunId,
163
+ ticketKey: options.ticketKey,
164
+ };
165
+ }
166
+ let result;
167
+ try {
168
+ result = await mutate(access, {
169
+ epicRunId: options.epicRunId,
170
+ ticketKey: options.ticketKey,
171
+ expectedRowVersion: ticket.row_version,
172
+ idempotencyKey,
173
+ reason: options.reason,
174
+ });
175
+ }
176
+ catch (err) {
177
+ return {
178
+ ok: false,
179
+ kind: "unavailable",
180
+ epicRunId: options.epicRunId,
181
+ ticketKey: options.ticketKey,
182
+ message: safeDiagnosticMessage(err, "recovery request failed"),
183
+ };
184
+ }
185
+ if (result.ok) {
186
+ return {
187
+ ok: true,
188
+ kind: "unparked",
189
+ epicRunId: options.epicRunId,
190
+ ticketKey: options.ticketKey,
191
+ status: result.ticket_status.status,
192
+ };
193
+ }
194
+ // `result.kind === "cas-conflict"`: a concurrent write moved the ticket's
195
+ // row_version between our read and our mutate. Loop back to a fresh read —
196
+ // never reuse `result.current_row_version` here.
197
+ }
198
+ return {
199
+ ok: false,
200
+ kind: "concurrent-change-exhausted",
201
+ epicRunId: options.epicRunId,
202
+ ticketKey: options.ticketKey,
203
+ };
204
+ }
205
+ /**
206
+ * Unpark a parked `needs_human` ticket, with a bounded authoritative-reread
207
+ * retry on a concurrent `row_version` bump.
208
+ */
209
+ export async function unparkEpicTicketWithRetry(access, options) {
210
+ return ticketRecoveryWithRetry(access, options, (a, args) => unparkEpicTicket(a, args));
211
+ }
212
+ /**
213
+ * Adopt the current PR head and unpark a parked ticket, sharing the identical
214
+ * reread-and-retry discipline. The adopt-current-head client — never the
215
+ * ordinary unpark client — is called for every attempt.
216
+ */
217
+ export async function adoptCurrentHeadAndUnparkWithRetry(access, options) {
218
+ return ticketRecoveryWithRetry(access, options, (a, args) => adoptCurrentHeadAndUnparkTicket(a, args));
219
+ }