@bridge_gpt/mcp-server 0.2.68 → 0.2.70
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 +226 -320
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +4 -4
- package/build/artifact-diff.js +110 -0
- package/build/bin-entrypoint.js +36 -0
- package/build/bridge-permissions.js +650 -98
- package/build/check-update-cli.js +74 -0
- package/build/claude-review-workflow-drift.js +3 -3
- package/build/claude-settings.js +84 -2
- package/build/cli-help.js +7 -1
- package/build/codex-bridge-profile.js +1 -1
- package/build/codex-home.js +9 -1
- package/build/codex-launcher-pin.js +598 -0
- package/build/commands.generated.js +8 -8
- package/build/conductor/bridge-api-client.js +598 -19
- package/build/conductor/bring-up-facts.js +9 -9
- package/build/conductor/doctor.js +466 -18
- package/build/conductor/epic-child-continuation.js +54 -3
- package/build/conductor/epic-implementer-cli.js +132 -34
- package/build/conductor/epic-runtime.js +2 -2
- package/build/conductor/epic-state.js +1 -1
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +4 -2
- package/build/conductor/launch-need-probes.js +337 -0
- package/build/conductor/local-merge.js +241 -56
- package/build/conductor/merge-credential.js +172 -0
- package/build/conductor/merge-execution-eligibility.js +1 -1
- package/build/conductor/merged-child-check.js +95 -0
- package/build/conductor/plan-trim.js +30 -0
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +11 -6
- package/build/conductor/readiness-prerequisites.js +41 -0
- package/build/conductor/readiness-sections.js +345 -55
- package/build/conductor/readiness.js +99 -87
- package/build/conductor/recovery-cli.js +606 -21
- package/build/conductor/recovery-operations.js +306 -7
- package/build/conductor/repair-contract.js +47 -2
- package/build/conductor/repository-workflow-remedy.js +8 -6
- package/build/conductor/stored-merge-credential.js +76 -0
- package/build/conductor/stored-merge-token-probe.js +28 -0
- package/build/conductor/temporary-plan.js +50 -0
- package/build/conductor/wait-run-cli.js +759 -0
- package/build/conductor/worker-database-doctor-inspector.js +49 -0
- package/build/conductor-bin.js +2 -2
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
- package/build/conductor-removed-stub-bin.js +2 -5
- package/build/connect-github-api.js +33 -14
- package/build/credential-store.js +356 -13
- package/build/docs.generated.js +27 -14
- package/build/doctor.js +203 -38
- package/build/drive-epic-help.js +212 -0
- package/build/drive-epic.js +1840 -373
- package/build/epic-ci-workflow-drift.js +77 -0
- package/build/epic-ci-workflow-install.js +183 -0
- package/build/epic-ci-workflow.js +157 -0
- package/build/epic-contract-context.js +326 -0
- package/build/epic-implementer-bin.js +131 -120
- package/build/epic-implementer-claude-hook-bin.js +2 -2
- package/build/escalation-reachability.js +134 -0
- package/build/executor/cleanup-report.js +99 -0
- package/build/executor/cli.js +12 -1
- package/build/executor/completion-contract.js +68 -11
- package/build/executor/completion-supervision.js +99 -0
- package/build/executor/delivery-outcome.js +179 -0
- package/build/executor/deps.js +3 -0
- package/build/executor/env.js +71 -0
- package/build/executor/failure-reason-codes.js +126 -0
- package/build/executor/heartbeat.js +38 -0
- package/build/executor/http-client.js +115 -4
- package/build/executor/integration-job.js +298 -5
- package/build/executor/job-errors.js +36 -6
- package/build/executor/job-runner.js +720 -36
- package/build/executor/likely-tests.js +36 -2
- package/build/executor/merge-job.js +145 -2
- package/build/executor/observation.js +73 -4
- package/build/executor/permissions.js +193 -97
- package/build/executor/preflight.js +14 -3
- package/build/executor/process-containment.js +28 -2
- package/build/executor/prompt-spec.js +50 -5
- package/build/executor/rebase-finalization.js +38 -3
- package/build/executor/reconcile-overlap-job.js +35 -7
- package/build/executor/required-work-ledger.js +236 -81
- package/build/executor/resume-pre-spawn.js +38 -1
- package/build/executor/runner.js +91 -3
- package/build/executor/test-clock.js +8 -0
- package/build/executor/types.js +37 -2
- package/build/executor/worker-cli-launcher.js +259 -0
- package/build/executor/worker-command.js +41 -4
- package/build/executor/worker-database-target.js +91 -0
- package/build/executor/worker-database.js +908 -0
- package/build/executor/worker-finalization.js +106 -0
- package/build/executor/worker-guard-hook.js +203 -0
- package/build/executor/worker-resources.js +47 -0
- package/build/executor/worker-verification-guidance.js +15 -2
- package/build/executor/worktree.js +303 -15
- package/build/executor-launch.js +11 -1
- package/build/generated-assets-doctor.js +9 -1
- package/build/generated-assets-health.js +13 -4
- package/build/generated-assets-legacy.js +39 -0
- package/build/generated-assets-paths.js +12 -1
- package/build/generated-assets-repair.js +4 -1
- package/build/generated-assets-test-support.js +12 -0
- package/build/github-remote.js +33 -0
- package/build/github-sign-in.js +62 -0
- package/build/index.js +1214 -465
- package/build/init.js +39 -1
- package/build/install-bridge-conductor-gate.js +78 -0
- package/build/install-bridge-conductor-setup.js +413 -0
- package/build/install-bridge-conductor.js +1226 -267
- package/build/install-bridge.js +629 -350
- package/build/launcher-config-inspection.js +184 -19
- package/build/mcp-host-targets.js +4 -0
- package/build/mcp-identity.js +7 -0
- package/build/mcp-install-state.js +8 -2
- package/build/merge-pull-request.js +6 -0
- package/build/merge-workflow-install.js +763 -345
- package/build/phase-result-artifacts.js +75 -20
- package/build/pipeline-orchestrator.js +247 -4
- package/build/pipelines.generated.js +35 -35
- package/build/plan-discovery.js +15 -2
- package/build/plan-execution-ledger.js +306 -17
- package/build/plan-phase-routing.js +48 -13
- package/build/plane/cli.js +105 -51
- package/build/plane/defaults.js +3 -0
- package/build/plane/plane-freshness.js +294 -0
- package/build/plane/schema-compatibility.js +137 -0
- package/build/plane/shutdown-deps.js +38 -0
- package/build/plane/shutdown.js +83 -8
- package/build/plane/terminal-run-cleanup.js +303 -0
- package/build/printed-remedy-commands.js +26 -4
- package/build/readiness-check.js +6 -2
- package/build/readiness-prerequisites.generated.js +532 -0
- package/build/readme.generated.js +1 -1
- package/build/session-upgrade-notice.js +86 -0
- package/build/setup-epic.js +400 -30
- package/build/shared-resources.generated.js +5 -0
- package/build/start-tickets.js +43 -15
- package/build/test-command-detection.js +247 -0
- package/build/test-command-validation.js +73 -0
- package/build/testing-config-client.js +150 -0
- package/build/ticket-delivery-artifacts.js +258 -0
- package/build/ticket-delivery-enrollment.js +306 -0
- package/build/ticket-delivery.js +205 -0
- package/build/ticket-review-summary.js +14 -0
- package/build/tool-call-advisory.js +162 -0
- package/build/tool-surface-gating.js +28 -89
- package/build/update-check.js +97 -16
- package/build/update-status.js +61 -71
- package/build/upgrade-advice.js +15 -11
- package/build/upgrade-cli.js +360 -20
- package/build/v2-readiness-requirements.js +552 -0
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/build/worktree-core.js +22 -7
- package/docs/BRIDGE-DIRECTORY-README.md +48 -0
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
- package/docs/EPIC-IMPLEMENTER.md +104 -63
- package/docs/epic-implementer/getting-started.md +131 -0
- package/docs/epic-implementer/self-hosting.md +133 -0
- package/package.json +3 -3
- package/pipelines/idea-to-ticket.json +1 -1
- package/pipelines/implement-ticket.json +8 -8
- package/pipelines/review-ticket.json +2 -2
- package/design-assets/favicon/favicon-32x32.png +0 -0
- package/design-assets/just-logo-rough-draft.png +0 -0
|
@@ -18,11 +18,12 @@
|
|
|
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, RERUN_CI_ROUTE_UNAVAILABLE_CODE, RERUN_CI_ROUTE_UNAVAILABLE_MESSAGE, rerunCi, resumeRun, safeDiagnosticMessage, scopeCatchUp, scopeReparse, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, fetchChildContinuationStatus, postContinueParkedChild, postGrantContinuation, resumeCiTicket, REVIEW_HEAD_REFUSAL_CODES, } from "./bridge-api-client.js";
|
|
21
|
+
import { adoptCurrentHeadAndUnparkTicket, completeMergeChild, ConductorBridgeApiError, dispatchRemediation, fetchEpicRunState, mergeChild, patchPolicy, RERUN_CI_ROUTE_UNAVAILABLE_CODE, RERUN_CI_ROUTE_UNAVAILABLE_MESSAGE, rerunCi, resolveHandoff, resumeRun, safeDiagnosticMessage, scopeCatchUp, scopeReparse, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, fetchChildContinuationStatus, postContinueParkedChild, postGrantContinuation, resumeCiTicket, regeneratePlanTicket, REVIEW_HEAD_REFUSAL_CODES, } from "./bridge-api-client.js";
|
|
22
22
|
import { clearGuidedReference, guardForBinding, GUIDED_PAYLOAD_CONFLICT_MESSAGE, GUIDED_REFERENCE_STORE_FAILED_MESSAGE, guidedPayloadDigest, prepareGuidedRequest, } from "./guided-repair.js";
|
|
23
23
|
import { DEFAULT_GATE_NAME } from "./git-ci-types.js";
|
|
24
|
-
import { canonicalSha256, clearContinuationReference, clearRecoveryReferenceFile, computeContinuationFingerprint, readRecoveryReferenceFile, writeRecoveryReferenceFile, isFullSha, newContinuationRequestId, readContinuationReference, writeContinuationReference, } from "./epic-child-continuation.js";
|
|
24
|
+
import { bindingsDriverContextSha256, CONTINUATION_CI_WAITING_REASONS, canonicalSha256, clearContinuationReference, clearRecoveryReferenceFile, computeContinuationFingerprint, driverContextSha256, readRecoveryReferenceFile, writeRecoveryReferenceFile, isFullSha, newContinuationRequestId, readContinuationReference, writeContinuationReference, } from "./epic-child-continuation.js";
|
|
25
25
|
import { resolveLocalMergeCredential, resolveLocalMergeMethod, runApprovedLocalMerge, } from "./local-merge.js";
|
|
26
|
+
import { resolveMergeCredentialAtAction, } from "./stored-merge-credential.js";
|
|
26
27
|
import { RERUN_CI_UNCONFIRMED_REASON, RESUME_RUN_UNCONFIRMED_MESSAGE } from "./repair-contract.js";
|
|
27
28
|
/**
|
|
28
29
|
* Bounded total attempts for a ticket-recovery CAS retry loop. Each attempt
|
|
@@ -296,6 +297,19 @@ export async function scopeReparseRepair(access, options, deps = {}) {
|
|
|
296
297
|
return classifyRepairError(err, "scope-reparse request failed");
|
|
297
298
|
}
|
|
298
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* BAPI-1494 — record the owner's decision on a ticket's verification hand-offs.
|
|
302
|
+
* The server records the verified request actor and decides every refusal.
|
|
303
|
+
*/
|
|
304
|
+
export async function resolveHandoffRepair(access, options, deps = {}) {
|
|
305
|
+
try {
|
|
306
|
+
const call = deps.resolveHandoff ?? resolveHandoff;
|
|
307
|
+
return { ok: true, result: await call(access, options) };
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
return classifyRepairError(err, "resolve-handoff request failed");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
299
313
|
/**
|
|
300
314
|
* Dispatch a remediation job with driver context. `context` must already be
|
|
301
315
|
* validated against `DRIVER_CONTEXT_MAX_BYTES`; the server selects the job type.
|
|
@@ -341,6 +355,17 @@ export async function mergeChildRepair(access, options, deps = {}) {
|
|
|
341
355
|
return { ok: true, result: serverResult };
|
|
342
356
|
}
|
|
343
357
|
const runLocal = deps.runLocalMerge ?? runApprovedLocalMerge;
|
|
358
|
+
const env = deps.env ?? process.env;
|
|
359
|
+
// BAPI-1461: the repository identity is resolved before any store lookup, and
|
|
360
|
+
// an unresolved or conflicting identity reads nothing.
|
|
361
|
+
const resolution = deps.storedMergeCredential
|
|
362
|
+
? await resolveMergeCredentialAtAction(env, "local", deps.storedMergeCredential)
|
|
363
|
+
: null;
|
|
364
|
+
const credential = resolution === null
|
|
365
|
+
? resolveLocalMergeCredential(env, "local")
|
|
366
|
+
: resolution.status === "resolved"
|
|
367
|
+
? resolution.credential
|
|
368
|
+
: null;
|
|
344
369
|
let succeeded = false;
|
|
345
370
|
let alreadyMerged = false;
|
|
346
371
|
let failureReason = "gh_merge_failed";
|
|
@@ -357,10 +382,7 @@ export async function mergeChildRepair(access, options, deps = {}) {
|
|
|
357
382
|
}, { method: resolveLocalMergeMethod(local.merge_method) },
|
|
358
383
|
// BAPI-1136: an explicitly configured local credential, never the ambient
|
|
359
384
|
// login. `env` remains the source of the allowlisted operational base only.
|
|
360
|
-
{
|
|
361
|
-
env: deps.env ?? process.env,
|
|
362
|
-
credential: resolveLocalMergeCredential(deps.env ?? process.env, "local"),
|
|
363
|
-
});
|
|
385
|
+
{ env, credential });
|
|
364
386
|
succeeded = localResult.status === "succeeded";
|
|
365
387
|
alreadyMerged = succeeded && localResult.reason === "already_merged";
|
|
366
388
|
failureReason = boundedLocalMergeReason(localResult.reason);
|
|
@@ -632,6 +654,22 @@ export async function continueChildOnce(access, options, deps = {}) {
|
|
|
632
654
|
reference = null;
|
|
633
655
|
}
|
|
634
656
|
const reusedReference = reference !== null;
|
|
657
|
+
// BAPI-1514 — a reused request is bound to the context it was first sent with
|
|
658
|
+
// (by hash). Re-sending it with a different context, or with/without one, would
|
|
659
|
+
// be a different request under the same id; refuse locally, sending nothing.
|
|
660
|
+
if (reference !== null) {
|
|
661
|
+
const storedSha = bindingsDriverContextSha256(reference.bindings);
|
|
662
|
+
const wantedSha = options.driverContext === undefined ? undefined : driverContextSha256(options.driverContext);
|
|
663
|
+
if (storedSha !== wantedSha) {
|
|
664
|
+
return {
|
|
665
|
+
kind: "unavailable",
|
|
666
|
+
message: `a pending continuation request (${reference.requestId}) was sent ` +
|
|
667
|
+
(storedSha === undefined ? "without --context-file" : "with a different --context-file") +
|
|
668
|
+
"; re-run with the same --context-file (or none, as before) to settle that same " +
|
|
669
|
+
"request, or look it up with continuation-receipt",
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
}
|
|
635
673
|
if (reference === null) {
|
|
636
674
|
let status;
|
|
637
675
|
try {
|
|
@@ -662,6 +700,7 @@ export async function continueChildOnce(access, options, deps = {}) {
|
|
|
662
700
|
expectedRowVersion: current.row_version,
|
|
663
701
|
expectedHeadSha: head,
|
|
664
702
|
sourceParkGeneration: current.park_generation,
|
|
703
|
+
...(options.driverContext !== undefined ? { driverContext: options.driverContext } : {}),
|
|
665
704
|
};
|
|
666
705
|
reference = {
|
|
667
706
|
version: 1,
|
|
@@ -699,9 +738,15 @@ export async function continueChildOnce(access, options, deps = {}) {
|
|
|
699
738
|
requestFingerprint: ref.requestFingerprint,
|
|
700
739
|
reason: options.reason,
|
|
701
740
|
workerExitProof: options.workerExitProof,
|
|
741
|
+
...(options.driverContext !== undefined ? { driverContext: options.driverContext } : {}),
|
|
702
742
|
});
|
|
703
743
|
if (outcome.kind === "result" && AUTHORITATIVE_OUTCOMES.has(outcome.result.outcome)) {
|
|
704
|
-
|
|
744
|
+
const keepForRetry = outcome.result.outcome === "refused" &&
|
|
745
|
+
outcome.result.retryable &&
|
|
746
|
+
outcome.result.reason_code !== null &&
|
|
747
|
+
CONTINUATION_CI_WAITING_REASONS.has(outcome.result.reason_code);
|
|
748
|
+
if (!keepForRetry)
|
|
749
|
+
await clear();
|
|
705
750
|
return { kind: "decided", requestId: ref.requestId, reusedReference, result: outcome.result };
|
|
706
751
|
}
|
|
707
752
|
if (outcome.kind === "refused_http") {
|
|
@@ -711,6 +756,7 @@ export async function continueChildOnce(access, options, deps = {}) {
|
|
|
711
756
|
requestId: ref.requestId,
|
|
712
757
|
status: outcome.status,
|
|
713
758
|
errorCode: outcome.errorCode,
|
|
759
|
+
...(options.driverContext !== undefined ? { driverContextSent: true } : {}),
|
|
714
760
|
};
|
|
715
761
|
}
|
|
716
762
|
// Uncertain (transport, malformed 2xx, 5xx, or the server's own unconfirmed).
|
|
@@ -1131,3 +1177,256 @@ export async function resumeCiOnce(access, options, deps = {}) {
|
|
|
1131
1177
|
: "indeterminate";
|
|
1132
1178
|
return { kind: "unconfirmed", transport, reusedReference, attempts: RESUME_CI_POST_LIMIT };
|
|
1133
1179
|
}
|
|
1180
|
+
// ---------------------------------------------------------------------------
|
|
1181
|
+
// BAPI-1532 — `regenerate-plan`: a pinned, bounded answer to a named plan failure
|
|
1182
|
+
// ---------------------------------------------------------------------------
|
|
1183
|
+
/** The recovery-reference namespace of pinned `regenerate-plan` operations. */
|
|
1184
|
+
export const REGENERATE_PLAN_REFERENCE_NAMESPACE = "epic-plan-regenerations";
|
|
1185
|
+
/**
|
|
1186
|
+
* Bounded POSTs per invocation. Every POST of one invocation carries the SAME
|
|
1187
|
+
* operation key, route, reason, and park generation; only the row version may be
|
|
1188
|
+
* refreshed, and only while the park identity still matches.
|
|
1189
|
+
*/
|
|
1190
|
+
export const REGENERATE_PLAN_POST_LIMIT = 3;
|
|
1191
|
+
const REGENERATE_PLAN_RETRY_DELAY_MS = 250;
|
|
1192
|
+
const REGENERATE_PLAN_OPERATION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/;
|
|
1193
|
+
const FINALIZATION_CAUSE = "worker_finalization_missing_branch_and_pr";
|
|
1194
|
+
/** The longest driver reason the request carries; the server bounds it again. */
|
|
1195
|
+
export const REGENERATE_PLAN_REASON_MAX_CHARS = 500;
|
|
1196
|
+
/**
|
|
1197
|
+
* Capture the park identity a `regenerate-plan` is pinned to, from authenticated
|
|
1198
|
+
* ticket state, mirroring the server's selection: the ticket is `needs_human`,
|
|
1199
|
+
* its single parked gate is `implement`, and either that gate holds a `parked`
|
|
1200
|
+
* `plan_failure` record for its own park generation, or its committed cause is the
|
|
1201
|
+
* BAPI-1487 finalization park (which the server admits through its own predicate
|
|
1202
|
+
* and which names no planner reason). Never derived from the park's reason text.
|
|
1203
|
+
*/
|
|
1204
|
+
export function captureRegeneratePlanSubject(ticket) {
|
|
1205
|
+
if (ticket.status !== "needs_human")
|
|
1206
|
+
return { ok: false, reason: "not_parked" };
|
|
1207
|
+
const gates = isRecordValue(ticket.gates) ? ticket.gates : {};
|
|
1208
|
+
const parked = Object.entries(gates).filter(([, state]) => isRecordValue(state) && state.status === "needs_human");
|
|
1209
|
+
if (parked.length !== 1 || parked[0][0] !== "implement") {
|
|
1210
|
+
return { ok: false, reason: "not_implement_park" };
|
|
1211
|
+
}
|
|
1212
|
+
const gate = parked[0][1];
|
|
1213
|
+
const generation = isNonNegativeInteger(gate.park_generation) ? gate.park_generation : 0;
|
|
1214
|
+
const record = gate.plan_failure;
|
|
1215
|
+
if (isRecordValue(record)) {
|
|
1216
|
+
if (record.phase !== "parked")
|
|
1217
|
+
return { ok: false, reason: "park_superseded" };
|
|
1218
|
+
if (!isNonNegativeInteger(record.park_generation) || record.park_generation !== generation) {
|
|
1219
|
+
return { ok: false, reason: "park_superseded" };
|
|
1220
|
+
}
|
|
1221
|
+
return { ok: true, parkGeneration: generation };
|
|
1222
|
+
}
|
|
1223
|
+
if (gate.terminal_failure_cause === FINALIZATION_CAUSE) {
|
|
1224
|
+
return { ok: true, parkGeneration: generation };
|
|
1225
|
+
}
|
|
1226
|
+
return { ok: false, reason: "no_plan_failure" };
|
|
1227
|
+
}
|
|
1228
|
+
/** The fingerprint of a pinned operation's identity (the row version excluded). */
|
|
1229
|
+
export function computeRegeneratePlanFingerprint(ref) {
|
|
1230
|
+
return canonicalSha256({
|
|
1231
|
+
v: 1,
|
|
1232
|
+
operation: "regenerate_plan",
|
|
1233
|
+
target: { repo_name: ref.repoName, epic_run_id: ref.epicRunId, ticket_key: ref.ticketKey },
|
|
1234
|
+
route: ref.route,
|
|
1235
|
+
reason: ref.reason,
|
|
1236
|
+
park_generation: ref.parkGeneration,
|
|
1237
|
+
operation_key: ref.operationKey,
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
function parseRegeneratePlanReference(raw) {
|
|
1241
|
+
if (!isRecordValue(raw) || raw.version !== 1 || raw.operation !== "regenerate_plan")
|
|
1242
|
+
return null;
|
|
1243
|
+
if (typeof raw.repoName !== "string" || !raw.repoName ||
|
|
1244
|
+
typeof raw.epicRunId !== "string" || !raw.epicRunId ||
|
|
1245
|
+
typeof raw.ticketKey !== "string" || !raw.ticketKey ||
|
|
1246
|
+
(raw.route !== "test" && raw.route !== "implementation") ||
|
|
1247
|
+
typeof raw.reason !== "string" || !raw.reason ||
|
|
1248
|
+
!isNonNegativeInteger(raw.parkGeneration) ||
|
|
1249
|
+
!isNonNegativeInteger(raw.expectedRowVersion) ||
|
|
1250
|
+
typeof raw.operationKey !== "string" ||
|
|
1251
|
+
!REGENERATE_PLAN_OPERATION_KEY_PATTERN.test(raw.operationKey) ||
|
|
1252
|
+
typeof raw.fingerprint !== "string") {
|
|
1253
|
+
return null;
|
|
1254
|
+
}
|
|
1255
|
+
const ref = {
|
|
1256
|
+
version: 1,
|
|
1257
|
+
operation: "regenerate_plan",
|
|
1258
|
+
repoName: raw.repoName,
|
|
1259
|
+
epicRunId: raw.epicRunId,
|
|
1260
|
+
ticketKey: raw.ticketKey,
|
|
1261
|
+
route: raw.route,
|
|
1262
|
+
reason: raw.reason,
|
|
1263
|
+
parkGeneration: raw.parkGeneration,
|
|
1264
|
+
expectedRowVersion: raw.expectedRowVersion,
|
|
1265
|
+
operationKey: raw.operationKey,
|
|
1266
|
+
fingerprint: raw.fingerprint,
|
|
1267
|
+
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : "",
|
|
1268
|
+
};
|
|
1269
|
+
// A reference whose fingerprint no longer matches its own identity was
|
|
1270
|
+
// tampered with or truncated; it never authorizes a replay.
|
|
1271
|
+
return computeRegeneratePlanFingerprint(ref) === ref.fingerprint ? ref : null;
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Answer a named plan failure with one forced regeneration, its operation pinned.
|
|
1275
|
+
*
|
|
1276
|
+
* A pinned reference for this ticket is REUSED as-is — same operation key, same
|
|
1277
|
+
* route, same park generation — so a lost response or a restarted process is
|
|
1278
|
+
* answered from the server's durable record, and a later park is never silently
|
|
1279
|
+
* targeted. Only when no reference exists is the park identity read (once) from
|
|
1280
|
+
* authenticated ticket state, and the reference is stored BEFORE the first POST.
|
|
1281
|
+
*
|
|
1282
|
+
* At most {@link REGENERATE_PLAN_POST_LIMIT} POSTs, all with the same key, route
|
|
1283
|
+
* and park generation. A row-version conflict refreshes the row version ONLY while
|
|
1284
|
+
* the park identity still matches; otherwise the result is `stale_subject` and
|
|
1285
|
+
* nothing more is sent. There is no fallback to unpark or any other mutation.
|
|
1286
|
+
*/
|
|
1287
|
+
export async function regeneratePlanOnce(access, options, deps = {}) {
|
|
1288
|
+
const fetchState = deps.fetchState ?? fetchEpicRunState;
|
|
1289
|
+
const post = deps.post ?? regeneratePlanTicket;
|
|
1290
|
+
const store = deps.store ?? {};
|
|
1291
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1292
|
+
const callerKey = { epicRunId: options.epicRunId, ticketKey: options.ticketKey };
|
|
1293
|
+
let reference = parseRegeneratePlanReference(await readRecoveryReferenceFile(REGENERATE_PLAN_REFERENCE_NAMESPACE, callerKey, store));
|
|
1294
|
+
if (reference !== null && reference.repoName !== access.repoName)
|
|
1295
|
+
reference = null;
|
|
1296
|
+
const reusedReference = reference !== null;
|
|
1297
|
+
if (reference !== null && reference.route !== options.route) {
|
|
1298
|
+
// An earlier operation is pinned on a different route and may be unconfirmed.
|
|
1299
|
+
// Sending a second route could answer the same park twice, so nothing is sent.
|
|
1300
|
+
return { kind: "pinned_mismatch", pinnedRoute: reference.route };
|
|
1301
|
+
}
|
|
1302
|
+
const persist = async (ref) => {
|
|
1303
|
+
await writeRecoveryReferenceFile(REGENERATE_PLAN_REFERENCE_NAMESPACE, callerKey, ref, store);
|
|
1304
|
+
if (ref.epicRunId !== callerKey.epicRunId || ref.ticketKey !== callerKey.ticketKey) {
|
|
1305
|
+
await writeRecoveryReferenceFile(REGENERATE_PLAN_REFERENCE_NAMESPACE, { epicRunId: ref.epicRunId, ticketKey: ref.ticketKey }, ref, store);
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
const clear = async (ref) => {
|
|
1309
|
+
await clearRecoveryReferenceFile(REGENERATE_PLAN_REFERENCE_NAMESPACE, callerKey, store);
|
|
1310
|
+
await clearRecoveryReferenceFile(REGENERATE_PLAN_REFERENCE_NAMESPACE, { epicRunId: ref.epicRunId, ticketKey: ref.ticketKey }, store);
|
|
1311
|
+
};
|
|
1312
|
+
const readTicket = async () => {
|
|
1313
|
+
try {
|
|
1314
|
+
const state = await fetchState(access, options.epicRunId);
|
|
1315
|
+
return state.ticket_statuses.find((t) => t.ticket_key === options.ticketKey) ?? null;
|
|
1316
|
+
}
|
|
1317
|
+
catch {
|
|
1318
|
+
return "unavailable";
|
|
1319
|
+
}
|
|
1320
|
+
};
|
|
1321
|
+
if (reference === null) {
|
|
1322
|
+
const ticket = await readTicket();
|
|
1323
|
+
if (ticket === "unavailable") {
|
|
1324
|
+
return { kind: "unavailable", message: "could not read the ticket's state, so nothing was sent" };
|
|
1325
|
+
}
|
|
1326
|
+
if (ticket === null)
|
|
1327
|
+
return { kind: "not_eligible", reason: "ticket_not_found" };
|
|
1328
|
+
const captured = captureRegeneratePlanSubject(ticket);
|
|
1329
|
+
if (!captured.ok)
|
|
1330
|
+
return { kind: "not_eligible", reason: captured.reason };
|
|
1331
|
+
const identity = {
|
|
1332
|
+
repoName: access.repoName,
|
|
1333
|
+
epicRunId: ticket.epic_run_id,
|
|
1334
|
+
ticketKey: ticket.ticket_key,
|
|
1335
|
+
route: options.route,
|
|
1336
|
+
reason: options.reason.slice(0, REGENERATE_PLAN_REASON_MAX_CHARS),
|
|
1337
|
+
parkGeneration: captured.parkGeneration,
|
|
1338
|
+
operationKey: (deps.newOperationKey ?? randomUUID)(),
|
|
1339
|
+
};
|
|
1340
|
+
reference = {
|
|
1341
|
+
version: 1,
|
|
1342
|
+
operation: "regenerate_plan",
|
|
1343
|
+
...identity,
|
|
1344
|
+
expectedRowVersion: ticket.row_version,
|
|
1345
|
+
fingerprint: computeRegeneratePlanFingerprint(identity),
|
|
1346
|
+
createdAt: (deps.now ?? (() => new Date()))().toISOString(),
|
|
1347
|
+
};
|
|
1348
|
+
try {
|
|
1349
|
+
await persist(reference);
|
|
1350
|
+
}
|
|
1351
|
+
catch {
|
|
1352
|
+
return {
|
|
1353
|
+
kind: "unavailable",
|
|
1354
|
+
message: "could not store the local recovery reference, so the request was not sent",
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
let ref = reference;
|
|
1359
|
+
let last = null;
|
|
1360
|
+
for (let attempt = 1; attempt <= REGENERATE_PLAN_POST_LIMIT; attempt += 1) {
|
|
1361
|
+
if (attempt > 1)
|
|
1362
|
+
await sleep(REGENERATE_PLAN_RETRY_DELAY_MS * (attempt - 1));
|
|
1363
|
+
const outcome = await post(access, {
|
|
1364
|
+
epicRunId: ref.epicRunId,
|
|
1365
|
+
ticketKey: ref.ticketKey,
|
|
1366
|
+
route: ref.route,
|
|
1367
|
+
reason: ref.reason,
|
|
1368
|
+
expectedRowVersion: ref.expectedRowVersion,
|
|
1369
|
+
parkGeneration: ref.parkGeneration,
|
|
1370
|
+
operationKey: ref.operationKey,
|
|
1371
|
+
});
|
|
1372
|
+
last = outcome;
|
|
1373
|
+
if (outcome.kind === "result") {
|
|
1374
|
+
await clear(ref);
|
|
1375
|
+
return { kind: outcome.result.outcome, result: outcome.result, reusedReference };
|
|
1376
|
+
}
|
|
1377
|
+
if (outcome.kind === "unsupported_server") {
|
|
1378
|
+
// Nothing was written, and no other mutation stands in for this one.
|
|
1379
|
+
await clear(ref);
|
|
1380
|
+
return { kind: "unsupported_server", status: outcome.status };
|
|
1381
|
+
}
|
|
1382
|
+
if (outcome.kind === "uncertain")
|
|
1383
|
+
continue;
|
|
1384
|
+
if (outcome.kind === "refused" && outcome.errorCode === "PLAN_REGEN_BUSY")
|
|
1385
|
+
continue;
|
|
1386
|
+
if (outcome.kind === "refused" && outcome.errorCode === "CONFLICT") {
|
|
1387
|
+
// Refresh the row version ONLY while the park identity is the same one.
|
|
1388
|
+
const ticket = await readTicket();
|
|
1389
|
+
if (ticket === "unavailable" || ticket === null) {
|
|
1390
|
+
return { kind: "refused", errorCode: outcome.errorCode, status: outcome.status, reusedReference };
|
|
1391
|
+
}
|
|
1392
|
+
const current = captureRegeneratePlanSubject(ticket);
|
|
1393
|
+
if (!current.ok || current.parkGeneration !== ref.parkGeneration) {
|
|
1394
|
+
return { kind: "stale_subject", reusedReference };
|
|
1395
|
+
}
|
|
1396
|
+
ref = { ...ref, expectedRowVersion: ticket.row_version };
|
|
1397
|
+
try {
|
|
1398
|
+
await persist(ref);
|
|
1399
|
+
}
|
|
1400
|
+
catch {
|
|
1401
|
+
return {
|
|
1402
|
+
kind: "unavailable",
|
|
1403
|
+
message: "could not update the local recovery reference, so the retry was not sent",
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
// A stale park is the subject changing: back to explain_run, nothing written.
|
|
1409
|
+
if (outcome.kind === "refused" && outcome.errorCode === "PLAN_REGEN_STALE_PARK") {
|
|
1410
|
+
await clear(ref);
|
|
1411
|
+
return { kind: "stale_subject", reusedReference };
|
|
1412
|
+
}
|
|
1413
|
+
// Every other refusal or rejection is authoritative: nothing was written,
|
|
1414
|
+
// and this operation is finished.
|
|
1415
|
+
await clear(ref);
|
|
1416
|
+
return {
|
|
1417
|
+
kind: "refused",
|
|
1418
|
+
errorCode: outcome.kind === "refused" ? outcome.errorCode : outcome.errorCode ?? "REQUEST_REJECTED",
|
|
1419
|
+
status: outcome.status,
|
|
1420
|
+
reusedReference,
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
if (last !== null && last.kind === "refused") {
|
|
1424
|
+
// Busy or row-version churn for the whole budget: nothing was written, and
|
|
1425
|
+
// the pinned reference is kept for the next invocation.
|
|
1426
|
+
return { kind: "refused", errorCode: last.errorCode, status: last.status, reusedReference };
|
|
1427
|
+
}
|
|
1428
|
+
const transport = last !== null && last.kind === "uncertain"
|
|
1429
|
+
? last.status !== undefined ? `${last.reason} (status ${last.status})` : last.reason
|
|
1430
|
+
: "indeterminate";
|
|
1431
|
+
return { kind: "unconfirmed", transport, reusedReference, attempts: REGENERATE_PLAN_POST_LIMIT };
|
|
1432
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The shared contract for the
|
|
2
|
+
* The shared contract for the nine operator repair verbs: BAPI-1154's
|
|
3
3
|
* `scope-reparse`, `dispatch-remediation`, and `merge-child`, BAPI-1176's
|
|
4
4
|
* `scope-catch-up`, `patch-policy`, and `resume-run`, BAPI-1326's
|
|
5
|
-
* ticket-addressed `resume-ci`,
|
|
5
|
+
* ticket-addressed `resume-ci`, BAPI-1388's ticket-addressed `rerun-ci`, and
|
|
6
|
+
* BAPI-1494's ticket-addressed `resolve-handoff`.
|
|
6
7
|
*
|
|
7
8
|
* CLI-only, exactly like the BAPI-872 recovery verbs: none of these names is ever
|
|
8
9
|
* registered as an MCP tool (`index-static.test.ts` pins that). This module is the
|
|
@@ -23,6 +24,7 @@ export const VERB_PATCH_POLICY = "patch-policy";
|
|
|
23
24
|
export const VERB_RESUME_RUN = "resume-run";
|
|
24
25
|
export const VERB_RESUME_CI = "resume-ci";
|
|
25
26
|
export const VERB_RERUN_CI = "rerun-ci";
|
|
27
|
+
export const VERB_RESOLVE_HANDOFF = "resolve-handoff";
|
|
26
28
|
/** Every repair verb, in the order an operator meets them. */
|
|
27
29
|
export const OPERATOR_REPAIR_VERBS = [
|
|
28
30
|
VERB_SCOPE_REPARSE,
|
|
@@ -33,6 +35,7 @@ export const OPERATOR_REPAIR_VERBS = [
|
|
|
33
35
|
VERB_RESUME_RUN,
|
|
34
36
|
VERB_RESUME_CI,
|
|
35
37
|
VERB_RERUN_CI,
|
|
38
|
+
VERB_RESOLVE_HANDOFF,
|
|
36
39
|
];
|
|
37
40
|
/**
|
|
38
41
|
* The ONE cap on driver context, in UTF-8 BYTES — the same number the server
|
|
@@ -113,6 +116,14 @@ export const OPERATOR_REPAIR_REFUSAL_CODES = [
|
|
|
113
116
|
// BAPI-1333
|
|
114
117
|
"GUIDED_REPAIR_DISABLED",
|
|
115
118
|
"REPAIR_SUBJECT_STALE",
|
|
119
|
+
// BAPI-1494
|
|
120
|
+
"HANDOFF_NOT_PENDING",
|
|
121
|
+
"HANDOFF_ALREADY_RESOLVED",
|
|
122
|
+
"RESOLUTION_ACTOR_UNAVAILABLE",
|
|
123
|
+
"EPIC_HEAD_UNREADABLE",
|
|
124
|
+
"HANDOFF_LEDGER_UNREADABLE",
|
|
125
|
+
"HANDOFF_RESOLUTION_CAPACITY",
|
|
126
|
+
"HANDOFF_SELECTION_CHANGED",
|
|
116
127
|
];
|
|
117
128
|
/** True when `code` is one of the closed repair refusal codes. */
|
|
118
129
|
export function isOperatorRepairRefusalCode(code) {
|
|
@@ -207,3 +218,37 @@ export const RERUN_CI_NOT_RUNNABLE_REASONS = [
|
|
|
207
218
|
];
|
|
208
219
|
/** The closed overall outcomes of an applied `rerun-ci`. */
|
|
209
220
|
export const RERUN_CI_OUTCOMES = ["enqueued", "partial", "nothing_to_enqueue"];
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// BAPI-1494 — `resolve-handoff`
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
/**
|
|
225
|
+
* The closed `resolve-handoff` refusal subset — mirrors
|
|
226
|
+
* `RESOLVE_HANDOFF_REFUSAL_CODES` in `operator_repair_contract.py`.
|
|
227
|
+
*/
|
|
228
|
+
export const RESOLVE_HANDOFF_REFUSAL_CODES = [
|
|
229
|
+
"RESOLUTION_ACTOR_UNAVAILABLE",
|
|
230
|
+
"TICKET_NOT_IN_RUN",
|
|
231
|
+
"EPIC_HEAD_UNREADABLE",
|
|
232
|
+
"HANDOFF_LEDGER_UNREADABLE",
|
|
233
|
+
"HANDOFF_NOT_PENDING",
|
|
234
|
+
"HANDOFF_ALREADY_RESOLVED",
|
|
235
|
+
"HANDOFF_RESOLUTION_CAPACITY",
|
|
236
|
+
"HANDOFF_SELECTION_CHANGED",
|
|
237
|
+
"ROW_VERSION_CONFLICT",
|
|
238
|
+
];
|
|
239
|
+
/** True when `code` is one of the closed `resolve-handoff` refusal codes. */
|
|
240
|
+
export function isResolveHandoffRefusalCode(code) {
|
|
241
|
+
return (typeof code === "string" &&
|
|
242
|
+
RESOLVE_HANDOFF_REFUSAL_CODES.includes(code));
|
|
243
|
+
}
|
|
244
|
+
/** The two decision kinds. `risk_accepted` is never shown as verified or passed. */
|
|
245
|
+
export const HANDOFF_RESOLUTION_KINDS = ["verified", "risk_accepted"];
|
|
246
|
+
/** The display label for each kind — the same labels the server uses. */
|
|
247
|
+
export const HANDOFF_RESOLUTION_KIND_LABELS = {
|
|
248
|
+
verified: "verified",
|
|
249
|
+
risk_accepted: "risk accepted",
|
|
250
|
+
};
|
|
251
|
+
/** Bounds shared with the server (`HANDOFF_RESOLUTION_*` in `api/models/epic_run.py`). */
|
|
252
|
+
export const HANDOFF_RESOLUTION_MAX_STEPS = 50;
|
|
253
|
+
export const HANDOFF_RESOLUTION_EVIDENCE_REF_MAX_CHARS = 500;
|
|
254
|
+
export const HANDOFF_RESOLUTION_REASON_MAX_CHARS = 2000;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CONSUMER_GUIDE_POINTER } from "../printed-remedy-commands.js";
|
|
1
2
|
/** The two fixed workflow files unattended auto-merge reads. Named once here. */
|
|
2
3
|
export const REVIEW_WORKFLOW_PATH = ".github/workflows/claude-review.yml";
|
|
3
4
|
export const CI_WORKFLOW_PATH = ".github/workflows/epic-implementer-ci.yml";
|
|
@@ -5,8 +6,9 @@ export const CI_WORKFLOW_PATH = ".github/workflows/epic-implementer-ci.yml";
|
|
|
5
6
|
export const MIGRATION_GUARD_JOB_ID = "migration-guard";
|
|
6
7
|
export const MIGRATION_GUARD_CHECKER_PATH = "scripts/check_alembic_migration_graph.py";
|
|
7
8
|
export const MIGRATION_GUARD_INVOCATION = `python ${MIGRATION_GUARD_CHECKER_PATH}`;
|
|
8
|
-
const RETRY = "
|
|
9
|
-
|
|
9
|
+
const RETRY = "Retry readiness, or use --attended.";
|
|
10
|
+
/** Every workflow remedy ends with the guide a consumer can actually install. */
|
|
11
|
+
const GUIDE_SENTENCE = `See ${CONSUMER_GUIDE_POINTER}.`;
|
|
10
12
|
const TEMPLATES = {
|
|
11
13
|
review_workflow_file: `${REVIEW_WORKFLOW_PATH} is missing from the default branch. Add it, or pass --attended.`,
|
|
12
14
|
review_run_id_marker: `${REVIEW_WORKFLOW_PATH} does not emit the run-id: marker in its verdict comment. ` +
|
|
@@ -29,7 +31,7 @@ const TEMPLATES = {
|
|
|
29
31
|
"Add that job, or pass --attended.",
|
|
30
32
|
};
|
|
31
33
|
/** Shown when the report is missing, incomplete, or not in a shape we can name. */
|
|
32
|
-
const UNVERIFIABLE_FALLBACK =
|
|
34
|
+
const UNVERIFIABLE_FALLBACK = `Bridge could not verify the review and CI workflows on the default branch. ${RETRY} ${GUIDE_SENTENCE}`;
|
|
33
35
|
function classifyReview(w) {
|
|
34
36
|
if (w === null || w === undefined || typeof w !== "object")
|
|
35
37
|
return "malformed";
|
|
@@ -86,7 +88,7 @@ function classifyCi(c) {
|
|
|
86
88
|
* unreadable, and otherwise the empty result below.
|
|
87
89
|
*/
|
|
88
90
|
export function describeRepositoryWorkflowRemedy(readiness) {
|
|
89
|
-
const review = classifyReview(readiness?.review_workflow);
|
|
91
|
+
const review = readiness?.epic_branch_ci?.effective_review_mode === "verdict_protocol" ? classifyReview(readiness?.review_workflow) : [];
|
|
90
92
|
if (review === "malformed") {
|
|
91
93
|
return { section: null, missing: [], text: UNVERIFIABLE_FALLBACK };
|
|
92
94
|
}
|
|
@@ -101,7 +103,7 @@ export function describeRepositoryWorkflowRemedy(readiness) {
|
|
|
101
103
|
return { section: null, missing: [], text: "" };
|
|
102
104
|
}
|
|
103
105
|
function build(section, missing) {
|
|
104
|
-
return { section, missing, text: missing.map((token) => TEMPLATES[token]).join(" ") };
|
|
106
|
+
return { section, missing, text: `${missing.map((token) => TEMPLATES[token]).join(" ")} ${GUIDE_SENTENCE}` };
|
|
105
107
|
}
|
|
106
108
|
/**
|
|
107
109
|
* The remedy sentence for a repository whose workflows are not confirmed. Never
|
|
@@ -121,5 +123,5 @@ export function ciWorkflowRemedy(c) {
|
|
|
121
123
|
const tokens = classifyCi(c);
|
|
122
124
|
if (tokens === "malformed" || tokens.length === 0)
|
|
123
125
|
return UNVERIFIABLE_FALLBACK;
|
|
124
|
-
return tokens.map((token) => TEMPLATES[token]).join(" ")
|
|
126
|
+
return `${tokens.map((token) => TEMPLATES[token]).join(" ")} ${GUIDE_SENTENCE}`;
|
|
125
127
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store-aware merge-credential resolution at privileged action boundaries
|
|
3
|
+
* (BAPI-1461).
|
|
4
|
+
*
|
|
5
|
+
* `merge-credential.ts` owns the precedence and stays free of filesystem and
|
|
6
|
+
* process dependencies. This module is the thin layer that binds it to one
|
|
7
|
+
* repository's stored entry, so every action boundary — a merge job, the
|
|
8
|
+
* `sync_base` integration action, the merge-child recovery verb, the
|
|
9
|
+
* `drive-epic` launch probe, and doctor — resolves the token the same way:
|
|
10
|
+
*
|
|
11
|
+
* `GITHUB_MERGE_TOKEN` → `BAPI_LOCAL_MERGE_TOKEN` → the stored
|
|
12
|
+
* `github-merge:github.com/<owner>/<repo>` entry.
|
|
13
|
+
*
|
|
14
|
+
* The store is read afresh at each call, never cached, so an executor that
|
|
15
|
+
* started before the token was stored still finds it when a merge runs.
|
|
16
|
+
*
|
|
17
|
+
* ELIGIBILITY is always an explicit input. A durable-host service executor
|
|
18
|
+
* (started with `--scrub-inherited-github-credentials`) passes `false` and so
|
|
19
|
+
* never reads a stored GitHub credential. Machine-user mode never reads it.
|
|
20
|
+
*/
|
|
21
|
+
import { execFile } from "node:child_process";
|
|
22
|
+
import { readFile } from "node:fs/promises";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import { readGithubMergeCredential, resolveGithubMergeRepositoryIdentity, } from "../credential-store.js";
|
|
25
|
+
import { parseGithubRemote } from "../github-remote.js";
|
|
26
|
+
import { resolveLocalMergeCredentialDetailedAsync, } from "./merge-credential.js";
|
|
27
|
+
/** Real host-local read deps for the user-scoped credential store. */
|
|
28
|
+
export function nodeGithubMergeCredentialReadDeps(env) {
|
|
29
|
+
return { env, homedir: os.homedir, readFile: (p) => readFile(p, "utf-8") };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Resolve one kind for a privileged action. The repository identity is settled
|
|
33
|
+
* BEFORE any store lookup: an unresolved or conflicting identity reads nothing,
|
|
34
|
+
* so a process can never pick up another repository's entry.
|
|
35
|
+
*/
|
|
36
|
+
export async function resolveMergeCredentialAtAction(env, kind, context) {
|
|
37
|
+
const allowed = kind === "local" &&
|
|
38
|
+
context.allowStoredEntry === true &&
|
|
39
|
+
(context.storeDeps !== undefined || context.readStored !== undefined);
|
|
40
|
+
const identity = allowed
|
|
41
|
+
? resolveGithubMergeRepositoryIdentity({
|
|
42
|
+
providerRepository: context.providerRepository,
|
|
43
|
+
localRemote: context.localRemote,
|
|
44
|
+
})
|
|
45
|
+
: null;
|
|
46
|
+
const { storeDeps, readStored } = context;
|
|
47
|
+
const reader = identity === null || !identity.ok
|
|
48
|
+
? undefined
|
|
49
|
+
: readStored !== undefined
|
|
50
|
+
? () => readStored(identity.identity)
|
|
51
|
+
: storeDeps !== undefined
|
|
52
|
+
? () => readGithubMergeCredential(identity.identity, storeDeps)
|
|
53
|
+
: undefined;
|
|
54
|
+
return resolveLocalMergeCredentialDetailedAsync(env, kind, {
|
|
55
|
+
allowStoredEntry: allowed && reader !== undefined,
|
|
56
|
+
...(reader !== undefined ? { readStoredEntry: reader } : {}),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** Bound on the one `git remote get-url origin` read. */
|
|
60
|
+
const GIT_REMOTE_TIMEOUT_MS = 5_000;
|
|
61
|
+
const defaultGitRemoteReader = (cwd) => new Promise((resolve) => {
|
|
62
|
+
execFile("git", ["remote", "get-url", "origin"], { cwd, timeout: GIT_REMOTE_TIMEOUT_MS, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }, (error, stdout) => resolve(error ? null : String(stdout)));
|
|
63
|
+
});
|
|
64
|
+
/**
|
|
65
|
+
* The working tree's github.com `origin` as `owner/repo`, or null. Read-only;
|
|
66
|
+
* any failure (no git, no remote, a non-GitHub host) is null, never an error.
|
|
67
|
+
*/
|
|
68
|
+
export async function readLocalGithubRemote(cwd, readRemote = defaultGitRemoteReader) {
|
|
69
|
+
try {
|
|
70
|
+
const url = await readRemote(cwd);
|
|
71
|
+
return url === null ? null : parseGithubRemote(url);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The production probe doctor and setup run against a STORED merge token
|
|
3
|
+
* (BAPI-1461).
|
|
4
|
+
*
|
|
5
|
+
* Kept out of `conductor/doctor.ts` so doctor never imports the merge executor:
|
|
6
|
+
* this module binds the shared, bounded, read-only capability probe
|
|
7
|
+
* (`probeLocalMergeCapability`: `gh --version`, `gh auth status`, two `gh api`
|
|
8
|
+
* GETs) and classifies its answer into the closed verdict doctor understands.
|
|
9
|
+
* The token reaches only the probe's restricted child environment; nothing here
|
|
10
|
+
* returns, logs, or prints it.
|
|
11
|
+
*/
|
|
12
|
+
import { classifyLocalGhPreflight, probeLocalMergeCapability, } from "./local-merge.js";
|
|
13
|
+
/** Probe one stored token against one `owner/repo`, returning a closed verdict. */
|
|
14
|
+
export async function probeStoredMergeToken(args) {
|
|
15
|
+
try {
|
|
16
|
+
const verdict = classifyLocalGhPreflight(await probeLocalMergeCapability({
|
|
17
|
+
kind: "local",
|
|
18
|
+
repository: args.repository,
|
|
19
|
+
resolved: { credential: args.credential, invalid: false },
|
|
20
|
+
}, { env: args.env ?? process.env }));
|
|
21
|
+
if (verdict === "passed" || verdict === "credential_invalid" || verdict === "no_access")
|
|
22
|
+
return verdict;
|
|
23
|
+
return "unavailable";
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return "unavailable";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped private temporary plan file for a trimmed epic plan (BAPI-1510).
|
|
3
|
+
*
|
|
4
|
+
* `withPlanPath` hands a callback either the operator's original plan path (when
|
|
5
|
+
* nothing is skipped — no filesystem work happens) or a private path holding the
|
|
6
|
+
* trimmed JSON. The temporary directory is created only when needed, the file is
|
|
7
|
+
* created exclusively with mode 0600, and the whole directory is removed in a
|
|
8
|
+
* `finally` after success and failure alike. A cleanup failure produces one
|
|
9
|
+
* sanitized warning (no path, no exception text) and never replaces the callback's
|
|
10
|
+
* result or error.
|
|
11
|
+
*/
|
|
12
|
+
import { mkdtemp, open, rm } from "node:fs/promises";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
export function createDefaultTemporaryPlanFs() {
|
|
16
|
+
return {
|
|
17
|
+
mkdtemp: (prefix) => mkdtemp(prefix),
|
|
18
|
+
writeExclusive: async (path, data, mode) => {
|
|
19
|
+
const handle = await open(path, "wx", mode);
|
|
20
|
+
try {
|
|
21
|
+
await handle.writeFile(data, "utf8");
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
await handle.close();
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
rmDir: (path) => rm(path, { recursive: true, force: true }),
|
|
28
|
+
tmpdir: () => tmpdir(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export const TEMPORARY_PLAN_CLEANUP_WARNING = "Could not remove a temporary plan file; it is private to your user and safe to delete.";
|
|
32
|
+
export async function withPlanPath(options, callback) {
|
|
33
|
+
if (!options.skipped)
|
|
34
|
+
return callback(options.originalPath);
|
|
35
|
+
const fs = options.fs ?? createDefaultTemporaryPlanFs();
|
|
36
|
+
const dir = await fs.mkdtemp(join(fs.tmpdir(), "bridge-trimmed-plan-"));
|
|
37
|
+
try {
|
|
38
|
+
const planPath = join(dir, "epic-plan.dag.json");
|
|
39
|
+
await fs.writeExclusive(planPath, options.trimmedJson, 0o600);
|
|
40
|
+
return await callback(planPath);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
try {
|
|
44
|
+
await fs.rmDir(dir);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
options.warn(TEMPORARY_PLAN_CLEANUP_WARNING);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|