@bridge_gpt/mcp-server 0.2.62 → 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 +47 -8
- 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 +6 -5
- package/build/conductor/bridge-api-client.js +335 -22
- 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/readiness-sections.js +5 -4
- package/build/conductor/readiness.js +22 -11
- 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 +50 -12
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +77 -11
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1211 -116
- 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-launch-profile.js +14 -5
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +39 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +286 -0
- package/build/executor/credentials.js +3 -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 +76 -4
- package/build/executor/job-errors.js +48 -0
- package/build/executor/job-runner.js +1294 -26
- package/build/executor/job-types.js +10 -2
- 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 +145 -0
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +12 -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 +537 -943
- package/build/install-bridge-conductor.js +231 -41
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +8 -7
- 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 +142 -0
- package/build/readme.generated.js +1 -1
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +28 -17
- 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,
|
|
@@ -1443,16 +1443,21 @@ function normalizeBoundBaseUrl(raw) {
|
|
|
1443
1443
|
* - An ambient `BAPI_API_KEY` is used only when the same environment also sets
|
|
1444
1444
|
* `BAPI_BASE_URL` to exactly the target — the pair is its provenance.
|
|
1445
1445
|
* Anything else is `ambient-key-unbound`, and the key is NOT sent.
|
|
1446
|
-
* - A stored key is used
|
|
1447
|
-
* {@link BAPI_EPIC_LAUNCH_BOUND_BASE_URL_FIELD} equal to the target.
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
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.
|
|
1450
1455
|
*
|
|
1451
1456
|
* Read-only and migration-free: it never writes the store. Errors name the
|
|
1452
1457
|
* logical target and the store path only — never a secret or the entry's
|
|
1453
1458
|
* contents.
|
|
1454
1459
|
*/
|
|
1455
|
-
export async function resolveEpicLaunchCredential(repoName, targetBaseUrl, deps) {
|
|
1460
|
+
export async function resolveEpicLaunchCredential(repoName, targetBaseUrl, deps, options = {}) {
|
|
1456
1461
|
const target = normalizeBoundBaseUrl(targetBaseUrl);
|
|
1457
1462
|
const bundleKey = `bapi:${repoName}`;
|
|
1458
1463
|
if (target === null || repoName.trim().length === 0) {
|
|
@@ -1482,27 +1487,60 @@ export async function resolveEpicLaunchCredential(repoName, targetBaseUrl, deps)
|
|
|
1482
1487
|
});
|
|
1483
1488
|
if (!stored.ok)
|
|
1484
1489
|
return { ok: false, kind: stored.kind, error: stored.error };
|
|
1485
|
-
const
|
|
1490
|
+
const binding = await readStoredBindingState(bundleKey, deps);
|
|
1486
1491
|
const primaryPath = getPrimaryCredentialStorePath(deps);
|
|
1487
|
-
if (
|
|
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
|
+
}
|
|
1488
1497
|
return {
|
|
1489
1498
|
ok: false,
|
|
1490
1499
|
kind: "binding-unbound",
|
|
1491
|
-
error: `The stored credential "${bundleKey}" has not been approved for
|
|
1492
|
-
`(${primaryPath}).
|
|
1493
|
-
|
|
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}.`,
|
|
1494
1503
|
};
|
|
1495
1504
|
}
|
|
1496
|
-
|
|
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) {
|
|
1497
1508
|
return {
|
|
1498
1509
|
ok: false,
|
|
1499
1510
|
kind: "binding-mismatch",
|
|
1500
1511
|
error: `The stored credential "${bundleKey}" is approved for a different Bridge API endpoint than the ` +
|
|
1501
|
-
"selected target. It was not sent.
|
|
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>.",
|
|
1502
1515
|
};
|
|
1503
1516
|
}
|
|
1504
1517
|
return { ok: true, apiKey: stored.values.BAPI_API_KEY, source: "file", binding: "store-bound" };
|
|
1505
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
|
+
}
|
|
1506
1544
|
/**
|
|
1507
1545
|
* Read the non-secret bound base URL for one bundle, or `null` when the store,
|
|
1508
1546
|
* the entry, or the field is absent or unreadable. Never throws.
|