@bridge_gpt/mcp-server 0.2.60 → 0.2.65
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 +58 -11
- package/build/agent-launchers/claude-executor-adapter.js +134 -3
- package/build/agent-launchers/executor-adapter.js +54 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/commands.generated.js +8 -7
- package/build/conductor/bridge-api-client.js +678 -14
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +324 -0
- package/build/conductor/epic-implementer-cli.js +142 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/merge-execution-contract.js +30 -5
- package/build/conductor/merge-execution-eligibility.js +10 -9
- package/build/conductor/readiness-sections.js +104 -20
- package/build/conductor/readiness.js +129 -25
- package/build/conductor/recovery-cli.js +354 -13
- package/build/conductor/recovery-operations.js +251 -5
- package/build/conductor/repair-contract.js +8 -0
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +194 -0
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +161 -7
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1318 -111
- package/build/epic-coherence-input-cli.js +137 -0
- package/build/epic-coherence-input.js +920 -0
- package/build/epic-coherence-input.test-fixtures.js +175 -0
- package/build/epic-implementer-bin.js +35 -29
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-integration-pr.js +61 -15
- package/build/epic-launch-profile.js +464 -0
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +48 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +286 -0
- package/build/executor/credentials.js +19 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +6 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +117 -1
- package/build/executor/integration-job.js +759 -0
- package/build/executor/job-errors.js +48 -0
- package/build/executor/job-runner.js +1386 -35
- package/build/executor/job-types.js +10 -2
- package/build/executor/observation.js +114 -7
- package/build/executor/preflight.js +99 -0
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +262 -1
- package/build/executor/provider-checks.js +69 -0
- package/build/executor/rebase-finalization.js +351 -0
- package/build/executor/reconcile-overlap-job.js +77 -84
- package/build/executor/required-verification.js +313 -0
- package/build/executor/required-work-ledger.js +1121 -0
- package/build/executor/results.js +142 -0
- package/build/executor/resume-pre-spawn.js +87 -4
- package/build/executor/runner.js +246 -1
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +55 -0
- package/build/executor/worker-command.js +49 -13
- package/build/executor/worker-finalization.js +88 -11
- package/build/executor/worker-guard-hook.js +29 -4
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +772 -954
- package/build/install-bridge-conductor.js +622 -40
- package/build/install-bridge.js +92 -16
- package/build/merge-pull-request.js +1 -1
- package/build/merge-workflow-config.js +54 -0
- package/build/merge-workflow-drift.js +35 -4
- package/build/merge-workflow.js +386 -2
- package/build/pipelines.generated.js +9 -8
- package/build/plan-discovery.js +137 -0
- package/build/plan-epic-implementer-eligibility.js +2 -2
- package/build/plane/cli.js +37 -3
- package/build/plane/member-logs.js +3 -0
- package/build/plane/member-roster.js +7 -1
- package/build/plane/shutdown.js +59 -1
- package/build/plane/supervisor.js +148 -0
- package/build/readme.generated.js +1 -1
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +130 -48
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/docs/CONDUCTOR.md +116 -2
- package/package.json +2 -1
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
* callers: `recovery-cli.ts` and `plane/shutdown.ts`.
|
|
19
19
|
*/
|
|
20
20
|
import { randomUUID } from "crypto";
|
|
21
|
-
import { adoptCurrentHeadAndUnparkTicket, completeMergeChild, ConductorBridgeApiError, dispatchRemediation, fetchEpicRunState, mergeChild, patchPolicy, resumeRun, safeDiagnosticMessage, scopeCatchUp, scopeReparse, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, } from "./bridge-api-client.js";
|
|
21
|
+
import { adoptCurrentHeadAndUnparkTicket, completeMergeChild, ConductorBridgeApiError, dispatchRemediation, fetchEpicRunState, mergeChild, patchPolicy, resumeRun, safeDiagnosticMessage, scopeCatchUp, scopeReparse, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, fetchChildContinuationStatus, postContinueParkedChild, REVIEW_HEAD_REFUSAL_CODES, } from "./bridge-api-client.js";
|
|
22
22
|
import { DEFAULT_GATE_NAME } from "./git-ci-types.js";
|
|
23
|
+
import { clearContinuationReference, computeContinuationFingerprint, isFullSha, newContinuationRequestId, readContinuationReference, writeContinuationReference, } from "./epic-child-continuation.js";
|
|
23
24
|
import { resolveLocalMergeCredential, resolveLocalMergeMethod, runApprovedLocalMerge, } from "./local-merge.js";
|
|
25
|
+
import { RESUME_RUN_UNCONFIRMED_MESSAGE } from "./repair-contract.js";
|
|
24
26
|
/**
|
|
25
27
|
* Bounded total attempts for a ticket-recovery CAS retry loop. Each attempt
|
|
26
28
|
* begins with a fresh, authoritative state read — so this bounds full
|
|
@@ -179,6 +181,17 @@ async function ticketRecoveryWithRetry(access, options, mutate) {
|
|
|
179
181
|
});
|
|
180
182
|
}
|
|
181
183
|
catch (err) {
|
|
184
|
+
const refusal = reviewHeadRefusal(err);
|
|
185
|
+
if (refusal !== null) {
|
|
186
|
+
return {
|
|
187
|
+
ok: false,
|
|
188
|
+
kind: "refused",
|
|
189
|
+
epicRunId: options.epicRunId,
|
|
190
|
+
ticketKey: options.ticketKey,
|
|
191
|
+
errorCode: refusal.errorCode,
|
|
192
|
+
message: refusal.message,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
182
195
|
return {
|
|
183
196
|
ok: false,
|
|
184
197
|
kind: "unavailable",
|
|
@@ -188,12 +201,14 @@ async function ticketRecoveryWithRetry(access, options, mutate) {
|
|
|
188
201
|
};
|
|
189
202
|
}
|
|
190
203
|
if (result.ok) {
|
|
204
|
+
const recovery = result.ticket_status.review_head_recovery;
|
|
191
205
|
return {
|
|
192
206
|
ok: true,
|
|
193
207
|
kind: "unparked",
|
|
194
208
|
epicRunId: options.epicRunId,
|
|
195
209
|
ticketKey: options.ticketKey,
|
|
196
210
|
status: result.ticket_status.status,
|
|
211
|
+
...(recovery ? { reviewHeadRecovery: recovery } : {}),
|
|
197
212
|
};
|
|
198
213
|
}
|
|
199
214
|
// `result.kind === "cas-conflict"`: a concurrent write moved the ticket's
|
|
@@ -207,6 +222,31 @@ async function ticketRecoveryWithRetry(access, options, mutate) {
|
|
|
207
222
|
ticketKey: options.ticketKey,
|
|
208
223
|
};
|
|
209
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* BAPI-1310 (E1): fixed operator sentences for the review-head refusal codes.
|
|
227
|
+
* Chosen by code so no server or provider text reaches the terminal.
|
|
228
|
+
*/
|
|
229
|
+
const REVIEW_HEAD_REFUSAL_MESSAGES = {
|
|
230
|
+
REVIEW_HEAD_ADOPTION_REQUIRED: "the ticket is parked on an unexplained review-head change; accept the current " +
|
|
231
|
+
"head with `epic-implementer adopt-current-head-and-unpark` (keeps spent repair " +
|
|
232
|
+
"allowances, resumes at CI). A generic unpark or remediation is refused",
|
|
233
|
+
REVIEW_HEAD_EPISODE_CONFLICT: "the ticket is no longer on the stop this request was made for; re-check its " +
|
|
234
|
+
"status (explain-run) before trying again",
|
|
235
|
+
REVIEW_HEAD_STATE_INVALID: "the ticket's stored recovery state cannot support an allowance-preserving " +
|
|
236
|
+
"adoption",
|
|
237
|
+
REVIEW_HEAD_BINDING_CHANGED: "the ticket's pull request could not be confirmed open with an unchanged identity",
|
|
238
|
+
REVIEW_HEAD_LIVE_WORK: "an executor job is, or may be, active for this ticket; retry when it has finished",
|
|
239
|
+
REVIEW_HEAD_UNAVAILABLE: "the current head or the ticket specification could not be read; retry shortly",
|
|
240
|
+
};
|
|
241
|
+
/** Map a thrown client error to an E1 refusal, or `null` for anything else. */
|
|
242
|
+
function reviewHeadRefusal(err) {
|
|
243
|
+
if (!(err instanceof ConductorBridgeApiError) || err.status !== 409)
|
|
244
|
+
return null;
|
|
245
|
+
const code = err.errorCode;
|
|
246
|
+
if (typeof code !== "string" || !REVIEW_HEAD_REFUSAL_CODES.includes(code))
|
|
247
|
+
return null;
|
|
248
|
+
return { errorCode: code, message: REVIEW_HEAD_REFUSAL_MESSAGES[code] ?? "refused" };
|
|
249
|
+
}
|
|
210
250
|
/**
|
|
211
251
|
* Unpark a parked `needs_human` ticket, with a bounded authoritative-reread
|
|
212
252
|
* retry on a concurrent `row_version` bump.
|
|
@@ -385,9 +425,17 @@ export async function patchPolicyRepair(access, options, deps = {}) {
|
|
|
385
425
|
}
|
|
386
426
|
}
|
|
387
427
|
/**
|
|
388
|
-
* Resume a paused run,
|
|
389
|
-
* re-evaluates activation and owns the
|
|
390
|
-
* reads or sends a generation.
|
|
428
|
+
* Resume a paused run, a quarantined one with `clearQuarantine`, or a run held by
|
|
429
|
+
* an eligible integration block. The server re-evaluates activation and owns the
|
|
430
|
+
* control-generation CAS retry; nothing here reads or sends a generation. It makes
|
|
431
|
+
* exactly ONE mutation call and returns a parsed success verbatim — no read, no
|
|
432
|
+
* retry, no poll, and no legacy `/resume` fallback.
|
|
433
|
+
*
|
|
434
|
+
* Only a structured 4xx is an authoritative refusal (the server decided and wrote
|
|
435
|
+
* nothing). A 5xx — even one carrying an `error_code` — a timeout, a dropped
|
|
436
|
+
* connection, or an unparseable/unexpected 2xx cannot prove whether the resume
|
|
437
|
+
* committed, so each is `unavailable` with {@link RESUME_RUN_UNCONFIRMED_MESSAGE}.
|
|
438
|
+
* Other repair verbs keep {@link classifyRepairError} unchanged.
|
|
391
439
|
*/
|
|
392
440
|
export async function resumeRunRepair(access, options, deps = {}) {
|
|
393
441
|
try {
|
|
@@ -395,6 +443,204 @@ export async function resumeRunRepair(access, options, deps = {}) {
|
|
|
395
443
|
return { ok: true, result: await call(access, options) };
|
|
396
444
|
}
|
|
397
445
|
catch (err) {
|
|
398
|
-
|
|
446
|
+
if (err instanceof ConductorBridgeApiError &&
|
|
447
|
+
typeof err.status === "number" &&
|
|
448
|
+
err.status >= 400 &&
|
|
449
|
+
err.status < 500 &&
|
|
450
|
+
err.errorCode) {
|
|
451
|
+
return classifyRepairError(err, "resume-run request failed");
|
|
452
|
+
}
|
|
453
|
+
return { ok: false, kind: "unavailable", message: RESUME_RUN_UNCONFIRMED_MESSAGE };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const AUTHORITATIVE_OUTCOMES = new Set([
|
|
457
|
+
"committed",
|
|
458
|
+
"already_continued",
|
|
459
|
+
"stale_historical_episode",
|
|
460
|
+
"refused",
|
|
461
|
+
"unsupported",
|
|
462
|
+
]);
|
|
463
|
+
/**
|
|
464
|
+
* Look the request's receipt up exactly once. Returns a decided result only when
|
|
465
|
+
* a durable receipt for THIS request id proves the commit; anything else is
|
|
466
|
+
* `null` (still unconfirmed). Never re-reads CAS tokens and never mutates.
|
|
467
|
+
*/
|
|
468
|
+
async function lookupOwnReceipt(access, reference, fetchStatus) {
|
|
469
|
+
let status;
|
|
470
|
+
try {
|
|
471
|
+
status = await fetchStatus(access, {
|
|
472
|
+
epicRunId: reference.bindings.epicRunId,
|
|
473
|
+
ticketKey: reference.bindings.ticketKey,
|
|
474
|
+
requestId: reference.requestId,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
if (!status.found || status.receipt === null)
|
|
481
|
+
return null;
|
|
482
|
+
if (status.receipt.request_id !== reference.requestId)
|
|
483
|
+
return null;
|
|
484
|
+
const outcome = status.outcome === "stale_historical_episode" ? "stale_historical_episode" : "committed";
|
|
485
|
+
return {
|
|
486
|
+
outcome,
|
|
487
|
+
reason_code: outcome === "committed" ? null : "stale_episode",
|
|
488
|
+
explanation: outcome === "committed"
|
|
489
|
+
? "The receipt proves this request's continuation committed."
|
|
490
|
+
: "The receipt proves this request's continuation committed; the child has since parked again.",
|
|
491
|
+
replayed: true,
|
|
492
|
+
retryable: false,
|
|
493
|
+
receipt: status.receipt,
|
|
494
|
+
current: status.current,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Continue a parked child through the server's atomic continuation, at most one
|
|
499
|
+
* POST per invocation.
|
|
500
|
+
*
|
|
501
|
+
* A pending recovery reference for this child is REUSED as-is — same request id,
|
|
502
|
+
* same bindings — so a lost response is resolved by a replay the server answers
|
|
503
|
+
* from its receipt, and a restarted process can never approve a new head or
|
|
504
|
+
* episode by re-reading the child. Only when no reference exists are the
|
|
505
|
+
* bindings read (once), fingerprinted, and stored BEFORE the POST is sent.
|
|
506
|
+
*
|
|
507
|
+
* After a transport failure, a malformed success body, or a 5xx, exactly one
|
|
508
|
+
* receipt lookup follows. No second POST, no new request id, no polling for
|
|
509
|
+
* completion, and never a fallback to unpark.
|
|
510
|
+
*/
|
|
511
|
+
export async function continueChildOnce(access, options, deps = {}) {
|
|
512
|
+
const fetchStatus = deps.fetchStatus ?? fetchChildContinuationStatus;
|
|
513
|
+
const post = deps.post ?? postContinueParkedChild;
|
|
514
|
+
const store = deps.store ?? {};
|
|
515
|
+
let reference = await readContinuationReference(options.epicRunId, options.ticketKey, store);
|
|
516
|
+
if (reference !== null && reference.bindings.repoName !== access.repoName) {
|
|
517
|
+
reference = null;
|
|
518
|
+
}
|
|
519
|
+
const reusedReference = reference !== null;
|
|
520
|
+
if (reference === null) {
|
|
521
|
+
let status;
|
|
522
|
+
try {
|
|
523
|
+
status = await fetchStatus(access, {
|
|
524
|
+
epicRunId: options.epicRunId,
|
|
525
|
+
ticketKey: options.ticketKey,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
catch (err) {
|
|
529
|
+
return { kind: "unavailable", message: safeDiagnosticMessage(err, "could not read the child") };
|
|
530
|
+
}
|
|
531
|
+
const current = status.current;
|
|
532
|
+
if (!current.parked ||
|
|
533
|
+
!current.continuation_available ||
|
|
534
|
+
current.parked_gate === null ||
|
|
535
|
+
current.park_generation === null) {
|
|
536
|
+
return { kind: "not_continuable", current };
|
|
537
|
+
}
|
|
538
|
+
const head = current.expected_head_sha === null ? null : current.expected_head_sha.toLowerCase();
|
|
539
|
+
if (head !== null && !isFullSha(head)) {
|
|
540
|
+
return { kind: "unavailable", message: "the child's bound head is not a full SHA" };
|
|
541
|
+
}
|
|
542
|
+
const bindings = {
|
|
543
|
+
repoName: access.repoName,
|
|
544
|
+
epicRunId: status.epic_run_id,
|
|
545
|
+
ticketKey: status.ticket_key,
|
|
546
|
+
parkedGate: current.parked_gate,
|
|
547
|
+
expectedRowVersion: current.row_version,
|
|
548
|
+
expectedHeadSha: head,
|
|
549
|
+
sourceParkGeneration: current.park_generation,
|
|
550
|
+
};
|
|
551
|
+
reference = {
|
|
552
|
+
version: 1,
|
|
553
|
+
bindings,
|
|
554
|
+
requestId: (deps.newRequestId ?? newContinuationRequestId)(),
|
|
555
|
+
requestFingerprint: computeContinuationFingerprint(bindings),
|
|
556
|
+
createdAt: new Date().toISOString(),
|
|
557
|
+
};
|
|
558
|
+
try {
|
|
559
|
+
// Also keyed by the caller's own addressing so the next invocation (which
|
|
560
|
+
// may use the epic key rather than the resolved id) finds it.
|
|
561
|
+
await writeContinuationReference(reference, store);
|
|
562
|
+
if (options.epicRunId !== bindings.epicRunId || options.ticketKey !== bindings.ticketKey) {
|
|
563
|
+
await writeContinuationReference(reference, store, {
|
|
564
|
+
epicRunId: options.epicRunId,
|
|
565
|
+
ticketKey: options.ticketKey,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
return {
|
|
571
|
+
kind: "unavailable",
|
|
572
|
+
message: "could not store the local recovery reference, so the request was not sent",
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const ref = reference;
|
|
577
|
+
const clear = async () => {
|
|
578
|
+
await clearContinuationReference(options.epicRunId, options.ticketKey, store);
|
|
579
|
+
await clearContinuationReference(ref.bindings.epicRunId, ref.bindings.ticketKey, store);
|
|
580
|
+
};
|
|
581
|
+
const outcome = await post(access, {
|
|
582
|
+
bindings: ref.bindings,
|
|
583
|
+
requestId: ref.requestId,
|
|
584
|
+
requestFingerprint: ref.requestFingerprint,
|
|
585
|
+
reason: options.reason,
|
|
586
|
+
});
|
|
587
|
+
if (outcome.kind === "result" && AUTHORITATIVE_OUTCOMES.has(outcome.result.outcome)) {
|
|
588
|
+
await clear();
|
|
589
|
+
return { kind: "decided", requestId: ref.requestId, reusedReference, result: outcome.result };
|
|
590
|
+
}
|
|
591
|
+
if (outcome.kind === "refused_http") {
|
|
592
|
+
await clear();
|
|
593
|
+
return {
|
|
594
|
+
kind: "refused_http",
|
|
595
|
+
requestId: ref.requestId,
|
|
596
|
+
status: outcome.status,
|
|
597
|
+
errorCode: outcome.errorCode,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
// Uncertain (transport, malformed 2xx, 5xx, or the server's own unconfirmed).
|
|
601
|
+
const transport = outcome.kind === "uncertain"
|
|
602
|
+
? outcome.status !== undefined
|
|
603
|
+
? `${outcome.reason} (status ${outcome.status})`
|
|
604
|
+
: outcome.reason
|
|
605
|
+
: "indeterminate commit";
|
|
606
|
+
const proven = await lookupOwnReceipt(access, ref, fetchStatus);
|
|
607
|
+
if (proven !== null) {
|
|
608
|
+
await clear();
|
|
609
|
+
return { kind: "decided", requestId: ref.requestId, reusedReference, result: proven };
|
|
610
|
+
}
|
|
611
|
+
return { kind: "unconfirmed", requestId: ref.requestId, reusedReference, transport };
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Read a child's current episode and, when a local recovery reference exists (or
|
|
615
|
+
* `requestId` is given), that request's receipt. One bounded GET; read-only. A
|
|
616
|
+
* reference is cleared only when the lookup PROVES its commit; `not_found` keeps
|
|
617
|
+
* it, because the absence of a receipt right now does not prove the request will
|
|
618
|
+
* never have committed if it is still in flight.
|
|
619
|
+
*/
|
|
620
|
+
export async function lookupContinuationReceipt(access, options, deps = {}) {
|
|
621
|
+
const fetchStatus = deps.fetchStatus ?? fetchChildContinuationStatus;
|
|
622
|
+
const store = deps.store ?? {};
|
|
623
|
+
const reference = options.requestId
|
|
624
|
+
? null
|
|
625
|
+
: await readContinuationReference(options.epicRunId, options.ticketKey, store);
|
|
626
|
+
const requestId = options.requestId ?? reference?.requestId ?? null;
|
|
627
|
+
let status;
|
|
628
|
+
try {
|
|
629
|
+
status = await fetchStatus(access, {
|
|
630
|
+
epicRunId: reference?.bindings.epicRunId ?? options.epicRunId,
|
|
631
|
+
ticketKey: reference?.bindings.ticketKey ?? options.ticketKey,
|
|
632
|
+
...(requestId ? { requestId } : {}),
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
catch (err) {
|
|
636
|
+
return { kind: "unavailable", message: safeDiagnosticMessage(err, "could not read the child") };
|
|
637
|
+
}
|
|
638
|
+
if (status.found && status.receipt !== null) {
|
|
639
|
+
if (reference !== null && status.receipt.request_id === reference.requestId) {
|
|
640
|
+
await clearContinuationReference(options.epicRunId, options.ticketKey, store);
|
|
641
|
+
await clearContinuationReference(reference.bindings.epicRunId, reference.bindings.ticketKey, store);
|
|
642
|
+
}
|
|
643
|
+
return { kind: "found", requestId, status };
|
|
399
644
|
}
|
|
645
|
+
return { kind: "not_found", requestId, status };
|
|
400
646
|
}
|
|
@@ -43,6 +43,14 @@ export const DRIVER_CONTEXT_MAX_BYTES = 16384;
|
|
|
43
43
|
* never parsed, printed, or transmitted.
|
|
44
44
|
*/
|
|
45
45
|
export const POLICY_PATCH_MAX_BYTES = 16384;
|
|
46
|
+
/**
|
|
47
|
+
* BAPI-1294 — the bounded `unavailable` message for a `resume-run` whose outcome
|
|
48
|
+
* the client cannot confirm (a 5xx, timeout, dropped connection, or unreadable
|
|
49
|
+
* 2xx). It never repeats server body text and never claims either outcome.
|
|
50
|
+
*/
|
|
51
|
+
export const RESUME_RUN_UNCONFIRMED_MESSAGE = "resume-run outcome could not be confirmed: the server may or may not have " +
|
|
52
|
+
"committed the resume. Inspect the run read-only before choosing any separately " +
|
|
53
|
+
"authorized mutation";
|
|
46
54
|
/**
|
|
47
55
|
* The closed refusal-code vocabulary, mirroring the server table.
|
|
48
56
|
*
|
|
@@ -225,6 +225,7 @@ export async function validateConductorBundleInputs(args, fs) {
|
|
|
225
225
|
epic_plan_path: path.join(resolvedEpicDir.value, "epic-plan.md"),
|
|
226
226
|
sidecar_path: path.join(resolvedEpicDir.value, "epic-plan.dag.json"),
|
|
227
227
|
manifest_path: path.join(resolvedEpicDir.value, "sibling-ticket-manifest.json"),
|
|
228
|
+
meta_path: path.join(resolvedEpicDir.value, "epic-plan.meta.json"),
|
|
228
229
|
report_path: path.join(resolvedEpicDir.value, "harmonization-report.json"),
|
|
229
230
|
plan_version: planVersion.value,
|
|
230
231
|
mappings: resolvedMappings,
|
|
@@ -1370,6 +1370,12 @@ export async function upsertBapiCredential(repoName, apiKey, deps) {
|
|
|
1370
1370
|
const action = hadKey ? "updated" : "created";
|
|
1371
1371
|
// Preserve sibling secret names; replace ONLY BAPI_API_KEY.
|
|
1372
1372
|
const nextEntry = { ...(existingEntry ?? {}), BAPI_API_KEY: trimmedKey };
|
|
1373
|
+
// BAPI-1260: an epic-launch binding approves ONE key for one endpoint. A
|
|
1374
|
+
// different key is a different credential, so its approval does not carry
|
|
1375
|
+
// over — guided setup re-binds it explicitly.
|
|
1376
|
+
if (existingEntry && existingEntry.BAPI_API_KEY !== trimmedKey) {
|
|
1377
|
+
delete nextEntry[BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD];
|
|
1378
|
+
}
|
|
1373
1379
|
const next = { ...base, [target]: nextEntry };
|
|
1374
1380
|
const dir = path.dirname(primaryPath);
|
|
1375
1381
|
const suffix = (deps.tempSuffix ?? defaultTempSuffix)();
|
|
@@ -1408,3 +1414,191 @@ export async function upsertBapiCredential(repoName, apiKey, deps) {
|
|
|
1408
1414
|
return { ok: true, path: primaryPath, target, action, migratedFallback };
|
|
1409
1415
|
}, (error) => ({ ok: false, path: primaryPath, target, kind: "lock-error", error }));
|
|
1410
1416
|
}
|
|
1417
|
+
// ---------------------------------------------------------------------------
|
|
1418
|
+
// Epic-launch credential binding (BAPI-1260)
|
|
1419
|
+
// ---------------------------------------------------------------------------
|
|
1420
|
+
/**
|
|
1421
|
+
* NON-SECRET sibling field under `bapi:<repo>` naming the one normalized Bridge
|
|
1422
|
+
* API base URL the stored `BAPI_API_KEY` was approved for by guided setup.
|
|
1423
|
+
*
|
|
1424
|
+
* Every pre-existing reader asks for `BAPI_API_KEY` only, so the extra field is
|
|
1425
|
+
* invisible to them — existing credential clients and the public decoding of
|
|
1426
|
+
* the store are unchanged. Only the epic-launch contract below reads it.
|
|
1427
|
+
*/
|
|
1428
|
+
export const BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD = "BAPI_EPIC_LAUNCH_BOUND_BASE_URL";
|
|
1429
|
+
/** Trim and drop trailing slashes — the executor's own base-URL normalization. */
|
|
1430
|
+
function normalizeBoundBaseUrl(raw) {
|
|
1431
|
+
if (typeof raw !== "string")
|
|
1432
|
+
return null;
|
|
1433
|
+
const normalized = raw.trim().replace(/\/+$/, "");
|
|
1434
|
+
return normalized.length > 0 ? normalized : null;
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Resolve the `BAPI_API_KEY` for an EPIC LAUNCH against one selected target.
|
|
1438
|
+
*
|
|
1439
|
+
* Unlike {@link resolveBapiCredentials}, the credential is coupled to the
|
|
1440
|
+
* normalized base URL AND the repository, so a repo-only credential is never
|
|
1441
|
+
* silently sent to a different endpoint:
|
|
1442
|
+
*
|
|
1443
|
+
* - An ambient `BAPI_API_KEY` is used only when the same environment also sets
|
|
1444
|
+
* `BAPI_BASE_URL` to exactly the target — the pair is its provenance.
|
|
1445
|
+
* Anything else is `ambient-key-unbound`, and the key is NOT sent.
|
|
1446
|
+
* - A stored key is used when its entry carries
|
|
1447
|
+
* {@link BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD} equal to the target.
|
|
1448
|
+
* - BAPI-1272: a stored key with NO binding field is also used when the
|
|
1449
|
+
* target normalizes to `options.productionBaseUrl` (`production-default`).
|
|
1450
|
+
* For any other target an entry with no binding is `binding-unbound` (a
|
|
1451
|
+
* legacy credential, upgraded once through guided setup).
|
|
1452
|
+
* - A binding to a different endpoint — or a binding field that is present
|
|
1453
|
+
* but empty — is `binding-mismatch`, even when the target is production:
|
|
1454
|
+
* a key approved for a local server is never sent to production.
|
|
1455
|
+
*
|
|
1456
|
+
* Read-only and migration-free: it never writes the store. Errors name the
|
|
1457
|
+
* logical target and the store path only — never a secret or the entry's
|
|
1458
|
+
* contents.
|
|
1459
|
+
*/
|
|
1460
|
+
export async function resolveEpicLaunchCredential(repoName, targetBaseUrl, deps, options = {}) {
|
|
1461
|
+
const target = normalizeBoundBaseUrl(targetBaseUrl);
|
|
1462
|
+
const bundleKey = `bapi:${repoName}`;
|
|
1463
|
+
if (target === null || repoName.trim().length === 0) {
|
|
1464
|
+
return { ok: false, kind: "missing-key", error: "An epic launch needs a target and a repository." };
|
|
1465
|
+
}
|
|
1466
|
+
const envKey = (deps.env.BAPI_API_KEY ?? "").trim();
|
|
1467
|
+
if (envKey.length > 0) {
|
|
1468
|
+
const envBase = normalizeBoundBaseUrl(deps.env.BAPI_BASE_URL);
|
|
1469
|
+
if (envBase !== target) {
|
|
1470
|
+
return {
|
|
1471
|
+
ok: false,
|
|
1472
|
+
kind: "ambient-key-unbound",
|
|
1473
|
+
error: "BAPI_API_KEY is set in this environment, but BAPI_BASE_URL does not name the selected " +
|
|
1474
|
+
"target, so the key's endpoint cannot be shown. It was not sent. Unset BAPI_API_KEY to use " +
|
|
1475
|
+
"the credential approved by setup, or set BAPI_BASE_URL to the selected target.",
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
return { ok: true, apiKey: envKey, source: "env", binding: "env-paired" };
|
|
1479
|
+
}
|
|
1480
|
+
// Read the stored entry exactly as the shared resolver would, but with the
|
|
1481
|
+
// environment cleared so the ambient branch above stays the only env path.
|
|
1482
|
+
// XDG_CONFIG_HOME is kept: it locates the store, and dropping it would read the
|
|
1483
|
+
// key from a different file than the binding below (and than setup wrote).
|
|
1484
|
+
const stored = await resolveCredentialBundle(bundleKey, ["BAPI_API_KEY"], {
|
|
1485
|
+
...deps,
|
|
1486
|
+
env: { XDG_CONFIG_HOME: deps.env.XDG_CONFIG_HOME },
|
|
1487
|
+
});
|
|
1488
|
+
if (!stored.ok)
|
|
1489
|
+
return { ok: false, kind: stored.kind, error: stored.error };
|
|
1490
|
+
const binding = await readStoredBindingState(bundleKey, deps);
|
|
1491
|
+
const primaryPath = getPrimaryCredentialStorePath(deps);
|
|
1492
|
+
if (binding.state === "absent" || binding.state === "no-entry") {
|
|
1493
|
+
const production = normalizeBoundBaseUrl(options.productionBaseUrl);
|
|
1494
|
+
if (binding.state === "absent" && production !== null && production === target) {
|
|
1495
|
+
return { ok: true, apiKey: stored.values.BAPI_API_KEY, source: "file", binding: "production-default" };
|
|
1496
|
+
}
|
|
1497
|
+
return {
|
|
1498
|
+
ok: false,
|
|
1499
|
+
kind: "binding-unbound",
|
|
1500
|
+
error: `The stored credential "${bundleKey}" has not been approved for this Bridge API endpoint ` +
|
|
1501
|
+
`(${primaryPath}). Production needs no approval; any other endpoint does. Run guided setup ` +
|
|
1502
|
+
`once to bind it: install conductor --launch-profile <name> --base-url ${target}.`,
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
// An unreadable read-back is refused like a mismatch: the key was read a
|
|
1506
|
+
// moment ago, so failing closed is the only safe reading.
|
|
1507
|
+
if (binding.state === "invalid" || binding.state === "unreadable" || binding.baseUrl !== target) {
|
|
1508
|
+
return {
|
|
1509
|
+
ok: false,
|
|
1510
|
+
kind: "binding-mismatch",
|
|
1511
|
+
error: `The stored credential "${bundleKey}" is approved for a different Bridge API endpoint than the ` +
|
|
1512
|
+
"selected target. It was not sent. A key approved for a local server is never sent to " +
|
|
1513
|
+
"production: pass that server's --launch-profile <name> or --base-url <url>, or bind the key " +
|
|
1514
|
+
"once with install conductor --launch-profile <name> --base-url <url>.",
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
return { ok: true, apiKey: stored.values.BAPI_API_KEY, source: "file", binding: "store-bound" };
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Read the binding field of one bundle in the three states of
|
|
1521
|
+
* {@link StoredBindingState}. Never throws.
|
|
1522
|
+
*/
|
|
1523
|
+
export async function readStoredBindingState(bundleKey, deps) {
|
|
1524
|
+
try {
|
|
1525
|
+
const resolution = await resolveCredentialStorePath(deps);
|
|
1526
|
+
if (!resolution.found)
|
|
1527
|
+
return { state: "no-entry" };
|
|
1528
|
+
const parsed = parseCredentialStoreJson(await deps.readFile(resolution.path));
|
|
1529
|
+
if (!parsed.ok)
|
|
1530
|
+
return { state: "unreadable" };
|
|
1531
|
+
const entry = parsed.value[bundleKey];
|
|
1532
|
+
if (entry === undefined)
|
|
1533
|
+
return { state: "no-entry" };
|
|
1534
|
+
if (!Object.prototype.hasOwnProperty.call(entry, BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD)) {
|
|
1535
|
+
return { state: "absent" };
|
|
1536
|
+
}
|
|
1537
|
+
const baseUrl = normalizeBoundBaseUrl(entry[BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD]);
|
|
1538
|
+
return baseUrl === null ? { state: "invalid" } : { state: "bound", baseUrl };
|
|
1539
|
+
}
|
|
1540
|
+
catch {
|
|
1541
|
+
return { state: "unreadable" };
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
/**
|
|
1545
|
+
* Read the non-secret bound base URL for one bundle, or `null` when the store,
|
|
1546
|
+
* the entry, or the field is absent or unreadable. Never throws.
|
|
1547
|
+
*/
|
|
1548
|
+
export async function readStoredBinding(bundleKey, deps) {
|
|
1549
|
+
try {
|
|
1550
|
+
const resolution = await resolveCredentialStorePath(deps);
|
|
1551
|
+
if (!resolution.found)
|
|
1552
|
+
return null;
|
|
1553
|
+
const parsed = parseCredentialStoreJson(await deps.readFile(resolution.path));
|
|
1554
|
+
if (!parsed.ok)
|
|
1555
|
+
return null;
|
|
1556
|
+
return normalizeBoundBaseUrl(parsed.value[bundleKey]?.[BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD]);
|
|
1557
|
+
}
|
|
1558
|
+
catch {
|
|
1559
|
+
return null;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* The explicit ONE-TIME upgrade path: approve the stored `bapi:<repo>` key for
|
|
1564
|
+
* one normalized base URL. Called only by guided setup after it has re-verified
|
|
1565
|
+
* access and administrative authorization at that target — never by a launch.
|
|
1566
|
+
*
|
|
1567
|
+
* Writes only the non-secret binding field, under the shared lock, through the
|
|
1568
|
+
* durable replacement; every other entry and sibling field is preserved. A store
|
|
1569
|
+
* with no key for the repository is refused rather than creating one.
|
|
1570
|
+
*/
|
|
1571
|
+
export async function bindEpicLaunchCredential(repoName, targetBaseUrl, deps) {
|
|
1572
|
+
const target = `bapi:${repoName.trim()}`;
|
|
1573
|
+
const baseUrl = normalizeBoundBaseUrl(targetBaseUrl);
|
|
1574
|
+
if (repoName.trim().length === 0 || baseUrl === null) {
|
|
1575
|
+
return { ok: false, target, kind: "invalid-input", error: "A repository and a target are required." };
|
|
1576
|
+
}
|
|
1577
|
+
const primaryPath = getPrimaryCredentialStorePath(deps);
|
|
1578
|
+
return withCredentialStoreLock(deps, async () => {
|
|
1579
|
+
const primary = await readCredentialStoreJsonIfPresent(primaryPath, deps);
|
|
1580
|
+
if (primary.state === "error") {
|
|
1581
|
+
return { ok: false, target, kind: primary.kind, error: primary.error };
|
|
1582
|
+
}
|
|
1583
|
+
const entry = primary.state === "present" ? primary.value[target] : undefined;
|
|
1584
|
+
if (!entry || typeof entry.BAPI_API_KEY !== "string" || entry.BAPI_API_KEY.trim() === "") {
|
|
1585
|
+
return {
|
|
1586
|
+
ok: false,
|
|
1587
|
+
target,
|
|
1588
|
+
kind: "not-found",
|
|
1589
|
+
error: `No stored BAPI_API_KEY under "${target}" in ${primaryPath} to bind.`,
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
const previous = normalizeBoundBaseUrl(entry[BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD]);
|
|
1593
|
+
if (previous === baseUrl)
|
|
1594
|
+
return { ok: true, target, action: "unchanged", path: primaryPath };
|
|
1595
|
+
const next = {
|
|
1596
|
+
...(primary.state === "present" ? primary.value : {}),
|
|
1597
|
+
[target]: { ...entry, [BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD]: baseUrl },
|
|
1598
|
+
};
|
|
1599
|
+
const written = await durablyReplaceCredentialStoreJson(primaryPath, next, deps);
|
|
1600
|
+
if (!written.ok)
|
|
1601
|
+
return { ok: false, target, kind: written.kind, error: written.error };
|
|
1602
|
+
return { ok: true, target, action: previous === null ? "bound" : "rebound", path: primaryPath };
|
|
1603
|
+
}, (error) => ({ ok: false, target, kind: "lock-error", error }));
|
|
1604
|
+
}
|