@bridge_gpt/mcp-server 0.2.67 → 0.2.69
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 +272 -51
- package/build/agent-launchers/claude-executor-adapter.js +27 -0
- package/build/agents.generated.js +22 -2
- package/build/bitbucket-workspace-remote.js +136 -0
- package/build/bootstrap-project-identity.js +24 -0
- package/build/bridge-permissions.js +1362 -0
- package/build/check-update-cli.js +74 -0
- package/build/claude-settings.js +102 -2
- package/build/cli-help.js +27 -0
- package/build/codex-home.js +9 -1
- package/build/codex-launcher-pin.js +598 -0
- package/build/command-assets-doctor.js +24 -70
- package/build/command-provisioning.js +29 -5
- package/build/commands.generated.js +9 -9
- package/build/conductor/bridge-api-client.js +714 -0
- package/build/conductor/command-drift-production.js +92 -0
- package/build/conductor/command-drift.js +89 -0
- package/build/conductor/doctor.js +69 -26
- package/build/conductor/epic-branch-ci-inspection.js +1375 -0
- package/build/conductor/epic-implementer-cli.js +76 -22
- package/build/conductor/gate-discovery.js +890 -0
- package/build/conductor/install-doctor.js +15 -3
- package/build/conductor/json-output.js +63 -0
- package/build/conductor/readiness-cli.js +95 -10
- package/build/conductor/readiness-sections.js +369 -3
- package/build/conductor/readiness.js +211 -8
- package/build/conductor/recovery-cli.js +110 -4
- package/build/conductor/recovery-operations.js +99 -1
- package/build/connect-bitbucket-api.js +41 -6
- package/build/connect-bitbucket.js +106 -33
- package/build/connect-github-api.js +33 -14
- package/build/credential-store.js +207 -47
- package/build/docs.generated.js +57 -3
- package/build/doctor.js +198 -4
- package/build/drive-epic.js +45 -8
- package/build/epic-coherence-input.test-fixtures.js +4 -3
- package/build/epic-implementer-bin.js +1369 -25
- package/build/epic-workarounds-path.js +39 -0
- package/build/executor/cli.js +30 -0
- package/build/executor/completion-contract.js +20 -5
- package/build/executor/deps.js +4 -1
- package/build/executor/gate-qualification-job.js +499 -0
- package/build/executor/guarded-fast-forward-push.js +129 -0
- package/build/executor/http-client.js +43 -4
- package/build/executor/integration-job.js +122 -8
- package/build/executor/job-errors.js +8 -1
- package/build/executor/job-runner.js +589 -47
- package/build/executor/likely-tests.js +290 -0
- package/build/executor/live-worker-registry.js +1 -1
- package/build/executor/observation.js +31 -2
- package/build/executor/packaged-recipe.js +106 -0
- package/build/executor/permissions.js +115 -28
- package/build/executor/platform.js +1 -1
- package/build/executor/preflight.js +61 -12
- package/build/executor/process-containment.js +36 -12
- package/build/executor/process.js +15 -7
- package/build/executor/prompt-spec.js +33 -1
- package/build/executor/recently-failed-tests-file.js +71 -0
- package/build/executor/reconcile-overlap-job.js +41 -23
- package/build/executor/required-verification.js +1 -1
- package/build/executor/required-work-ledger.js +105 -17
- package/build/executor/retirement.js +69 -0
- package/build/executor/runner.js +114 -11
- package/build/executor/test-clock.js +15 -0
- package/build/executor/types.js +32 -0
- package/build/executor/viewer-tabs.js +58 -8
- package/build/executor/worker-command.js +23 -7
- package/build/executor/worker-diagnostics.js +37 -0
- package/build/executor/worker-finalization.js +31 -5
- package/build/executor/worker-guard-hook.js +179 -25
- package/build/executor/worker-log.js +4 -3
- package/build/executor/worker-verification-guidance.js +37 -0
- package/build/executor/worktree.js +88 -11
- package/build/first-build-advisory.js +97 -0
- package/build/generated-assets-advisory.js +14 -0
- package/build/generated-assets-catalog.js +66 -0
- package/build/generated-assets-context.js +46 -0
- package/build/generated-assets-dependencies.js +26 -0
- package/build/generated-assets-doctor.js +50 -0
- package/build/generated-assets-health.js +65 -0
- package/build/generated-assets-host.js +17 -0
- package/build/generated-assets-inventory.js +17 -0
- package/build/generated-assets-paths.js +60 -0
- package/build/generated-assets-publisher.js +133 -0
- package/build/generated-assets-remedy.js +25 -0
- package/build/generated-assets-repair.js +72 -0
- package/build/generated-assets-session.js +71 -0
- package/build/generated-assets-test-support.js +35 -0
- package/build/index.js +1109 -695
- package/build/init.js +54 -177
- package/build/install-bridge-conductor-gate.js +1372 -0
- package/build/install-bridge-conductor.js +289 -36
- package/build/install-bridge.js +722 -252
- 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 +50 -4
- package/build/merge-workflow-install.js +3 -4
- package/build/pipeline-utils.js +6 -2
- package/build/pipelines.generated.js +22 -142
- package/build/plan-execution-ledger.js +2 -32
- package/build/plane/manifest.js +16 -1
- package/build/plane/preflight.js +54 -1
- package/build/printed-remedy-commands.js +16 -1
- package/build/readme.generated.js +1 -1
- package/build/repair-assets.js +93 -0
- package/build/run-unit-tests-launcher.js +130 -49
- package/build/secret-redaction.js +33 -0
- package/build/session-upgrade-notice.js +86 -0
- package/build/setup-epic.js +108 -3
- package/build/start-tickets-prereqs.js +70 -16
- package/build/start-tickets.js +44 -5
- package/build/tool-call-advisory.js +162 -0
- package/build/tool-surface-gating.js +32 -57
- 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 +398 -17
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +3 -2
- package/build/worker-guard-hook-main.js +1 -1
- package/build/worktree-core.js +15 -2
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +30 -12
- package/docs/EPIC-IMPLEMENTER.md +68 -2
- package/package.json +3 -3
- package/pipelines/greenfield-setup.json +4 -128
- package/pipelines/implement-ticket.json +3 -2
|
@@ -92,17 +92,17 @@
|
|
|
92
92
|
* unrelated additive change look like a breaking one here, or worse, hide a real
|
|
93
93
|
* one.
|
|
94
94
|
*/
|
|
95
|
-
import { PRINTED_CHECK_NAME_INSTRUCTION, PRINTED_CONDUCTOR_EXECUTOR_INSTALL_COMMAND, PRINTED_CONDUCTOR_INSTALL_COMMAND, PRINTED_CONNECT_GITHUB_COMMAND, PRINTED_EXECUTOR_ID_INSTRUCTION, } from "../printed-remedy-commands.js";
|
|
95
|
+
import { PRINTED_CHECK_NAME_INSTRUCTION, PRINTED_CONDUCTOR_EXECUTOR_INSTALL_COMMAND, PRINTED_CONDUCTOR_INSTALL_COMMAND, PRINTED_CONNECT_GITHUB_COMMAND, PRINTED_EXECUTOR_ID_INSTRUCTION, PRINTED_QUALIFY_GATE_COMMAND, } from "../printed-remedy-commands.js";
|
|
96
96
|
import { mapConductorDoctorReportToReadinessChecks, } from "./doctor.js";
|
|
97
97
|
import { mapInstallStatusChecksToReadinessChecks, } from "../install-doctor.js";
|
|
98
98
|
import { mapPlanePreflightToReadinessChecks, } from "../plane/preflight.js";
|
|
99
99
|
import { runConductorInstallDoctor, } from "./install-doctor.js";
|
|
100
|
-
import { BRING_UP_SECTION_LABELS, presentRequiredChecks, } from "./readiness-sections.js";
|
|
100
|
+
import { BRING_UP_SECTION_LABELS, describeGateProfile, describeGateProfileWarning, effectiveReviewModeIsRepositoryPolicy, EPIC_BRANCH_CI_SECTION_IDS, epicBranchCiSections, presentRequiredChecks, quotedSubject, } from "./readiness-sections.js";
|
|
101
101
|
import { classifyBringUpFacts } from "./bring-up-facts.js";
|
|
102
102
|
import { CI_WORKFLOW_PATH, REVIEW_WORKFLOW_PATH, ciWorkflowRemedy, repositoryWorkflowRemedy, } from "./repository-workflow-remedy.js";
|
|
103
103
|
import { classifyMergeExecutionEligibility, MERGE_EXECUTION_INELIGIBLE_REMEDIATION, MERGE_EXECUTION_UNVERIFIABLE_REMEDIATION, } from "./merge-execution-eligibility.js";
|
|
104
104
|
import { createReadinessCheck, createReadinessCheckSafely, summarizeReadinessChecks, validateReadinessChecks, } from "../readiness-check.js";
|
|
105
|
-
import { ADMISSION_EVIDENCE_SCHEMA_VERSION, } from "./bridge-api-client.js";
|
|
105
|
+
import { ADMISSION_EVIDENCE_SCHEMA_VERSION, gateProfileWaivesCiWorkflow, } from "./bridge-api-client.js";
|
|
106
106
|
/** This report's own schema version. See the module docstring. */
|
|
107
107
|
export const CONDUCTOR_READINESS_GATE_SCHEMA_VERSION = 1;
|
|
108
108
|
/** Fixed statement carried in every report, so no consumer can mistake it for a gate. */
|
|
@@ -359,7 +359,17 @@ export function mapServerReadinessToChecks(readiness, failure = "unreachable") {
|
|
|
359
359
|
});
|
|
360
360
|
// BAPI-1260 — appended ONLY when the server reported the block, so every
|
|
361
361
|
// pre-BAPI-1260 report keeps exactly the descriptor set above.
|
|
362
|
-
return [
|
|
362
|
+
return [
|
|
363
|
+
...described,
|
|
364
|
+
...admissionEvidenceChecks(readiness),
|
|
365
|
+
// BAPI-1390 — appended ONLY when the server reported a gate profile.
|
|
366
|
+
...gateProfileChecks(readiness),
|
|
367
|
+
// BAPI-1405 — always three warn-only epic-branch CI rows. An older server
|
|
368
|
+
// that omits the block renders them as "Unreported — upgrade the server",
|
|
369
|
+
// never as "no warning".
|
|
370
|
+
...epicBranchCiChecks(readiness),
|
|
371
|
+
...genericDiagnosticChecks(readiness),
|
|
372
|
+
];
|
|
363
373
|
}
|
|
364
374
|
function genericDiagnosticChecks(readiness) {
|
|
365
375
|
return (readiness.diagnostics ?? []).map((diagnostic, index) => {
|
|
@@ -432,10 +442,156 @@ function admissionReasonRemedy(record, readiness) {
|
|
|
432
442
|
return (`a repository administrator replaces ${RETIRED_GATE_NAME} with ${CURRENT_GATE_NAME} in the plan's ` +
|
|
433
443
|
"required_checks, in the supervisor done-gate checks, and in branch protection." +
|
|
434
444
|
(ciAbsent ? ` Also add ${CI_WORKFLOW_PATH} to the default branch so ${CURRENT_GATE_NAME} has a producer.` : ""));
|
|
445
|
+
default:
|
|
446
|
+
return gateProfileReasonRemedy(record.reason, record.verdict, record.subject ?? null);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/** The provider-evidence window the server searches, spelled once for the remedies. */
|
|
450
|
+
const PROVIDER_EVIDENCE_WINDOW = "the last 12 default-branch commits or 8 merged pull-request heads from the last 90 days";
|
|
451
|
+
/**
|
|
452
|
+
* BAPI-1390 — fixed remedies for the four gate-profile reasons. Everything is a
|
|
453
|
+
* literal except the validated `subject`. `null` for any other reason, so an
|
|
454
|
+
* unknown reason falls back to the owner's generic remedy.
|
|
455
|
+
*/
|
|
456
|
+
function gateProfileReasonRemedy(reason, verdict, subject) {
|
|
457
|
+
switch (reason) {
|
|
458
|
+
case "provider_check_unseen":
|
|
459
|
+
return (`a repository administrator confirms that the check ${quotedSubject(subject, "named in the gate profile")} ` +
|
|
460
|
+
`reports on the default branch — it was not seen on ${PROVIDER_EVIDENCE_WINDOW} — or edits the gate ` +
|
|
461
|
+
"profile; setup never grants it.");
|
|
462
|
+
case "provider_evidence_unavailable":
|
|
463
|
+
return (`Bridge could not read the provider's check history for ${quotedSubject(subject, "a gate-profile check")} ` +
|
|
464
|
+
`(${PROVIDER_EVIDENCE_WINDOW}), so it is unknown, not absent. Re-run readiness; if it persists, ` +
|
|
465
|
+
"escalate to Bridge operations.");
|
|
466
|
+
case "local_command_unqualified":
|
|
467
|
+
return verdict === "unable_to_verify"
|
|
468
|
+
? `the qualification of the local gate command ${quotedSubject(subject, "in the gate profile")} could not ` +
|
|
469
|
+
"be read, so it is unknown, not qualified. Re-run readiness; if it persists, escalate to Bridge operations."
|
|
470
|
+
: `the local gate command ${quotedSubject(subject, "in the gate profile")} is not qualified for the current ` +
|
|
471
|
+
"gate-profile revision (unqualified or stale). Qualify it with " +
|
|
472
|
+
`\`${PRINTED_QUALIFY_GATE_COMMAND}\`, or have a repository administrator ` +
|
|
473
|
+
"edit the gate profile.";
|
|
474
|
+
case "migration_guard_unset":
|
|
475
|
+
return verdict === "unable_to_verify"
|
|
476
|
+
? "Bridge could not confirm whether this repository has alembic.ini on its default branch, so it could " +
|
|
477
|
+
"not decide whether the gate profile needs a migration guard. Re-run readiness, or have a repository " +
|
|
478
|
+
"administrator set a migration guard in the gate profile."
|
|
479
|
+
: "this repository has alembic.ini on its default branch, so its gate profile must name a migration " +
|
|
480
|
+
"guard (a provider check or a local command). A repository administrator sets it in the gate " +
|
|
481
|
+
"profile; setup never grants it.";
|
|
435
482
|
default:
|
|
436
483
|
return null;
|
|
437
484
|
}
|
|
438
485
|
}
|
|
486
|
+
const GATE_PROFILE_WARNING_REMEDIATION = "no action is needed to launch; confirm the check reports once an epic branch runs CI.";
|
|
487
|
+
/**
|
|
488
|
+
* BAPI-1390 — the gate-profile row plus one warn row per warning. Appended
|
|
489
|
+
* ONLY when the server reported the block, so an older server's report is
|
|
490
|
+
* unchanged. The row is descriptive: the server's admission evidence is the
|
|
491
|
+
* authority, and the warning rows never hold a launch.
|
|
492
|
+
*/
|
|
493
|
+
export function gateProfileChecks(readiness) {
|
|
494
|
+
const profile = readiness.gate_profile;
|
|
495
|
+
if (profile === undefined)
|
|
496
|
+
return [];
|
|
497
|
+
if (profile === null) {
|
|
498
|
+
return [
|
|
499
|
+
createReadinessCheckSafely({
|
|
500
|
+
id: "server.gate-profile",
|
|
501
|
+
source: "server",
|
|
502
|
+
label: "Gate profile",
|
|
503
|
+
status: "fail",
|
|
504
|
+
detail: "the server reported no gate profile, so the CI gate is unverified",
|
|
505
|
+
remediation: SERVER_REMEDIATIONS.olderServer,
|
|
506
|
+
}),
|
|
507
|
+
];
|
|
508
|
+
}
|
|
509
|
+
const detail = describeGateProfile(profile);
|
|
510
|
+
let row;
|
|
511
|
+
if (profile.read_failed || profile.stored_malformed) {
|
|
512
|
+
row = createReadinessCheckSafely({
|
|
513
|
+
id: "server.gate-profile",
|
|
514
|
+
source: "server",
|
|
515
|
+
label: "Gate profile",
|
|
516
|
+
status: "fail",
|
|
517
|
+
detail: `${profile.read_failed ? "the stored gate profile could not be read" : "the stored gate profile is malformed"} — ${detail}`,
|
|
518
|
+
remediation: profile.read_failed
|
|
519
|
+
? "re-run readiness; if it persists, escalate to Bridge operations."
|
|
520
|
+
: "a repository administrator re-saves the gate profile; setup never grants it.",
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
else if (profile.verdict === "denied" || profile.verdict === "unable_to_verify") {
|
|
524
|
+
row = createReadinessCheckSafely({
|
|
525
|
+
id: "server.gate-profile",
|
|
526
|
+
source: "server",
|
|
527
|
+
label: "Gate profile",
|
|
528
|
+
status: "fail",
|
|
529
|
+
detail: `${profile.verdict === "denied" ? "DENIED" : "could not be verified"}${profile.reason ? ` (${profile.reason})` : ""} — ${detail}`,
|
|
530
|
+
remediation: gateProfileReasonRemedy(profile.reason, profile.verdict, profile.subject) ??
|
|
531
|
+
(profile.verdict === "denied"
|
|
532
|
+
? ADMISSION_OWNER_REMEDIATION.repository_admin
|
|
533
|
+
: ADMISSION_OWNER_REMEDIATION.bridge_operations),
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
else if (profile.verdict === "confirmed" && profile.kind !== "none") {
|
|
537
|
+
row = createReadinessCheckSafely({
|
|
538
|
+
id: "server.gate-profile",
|
|
539
|
+
source: "server",
|
|
540
|
+
label: "Gate profile",
|
|
541
|
+
status: "pass",
|
|
542
|
+
detail,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
row = createReadinessCheckSafely({
|
|
547
|
+
id: "server.gate-profile",
|
|
548
|
+
source: "server",
|
|
549
|
+
label: "Gate profile",
|
|
550
|
+
status: "warn",
|
|
551
|
+
detail,
|
|
552
|
+
remediation: profile.kind === "none"
|
|
553
|
+
? "no action is needed to launch; to let child merges proceed without approval, a repository administrator adds checks to the gate profile."
|
|
554
|
+
: "a repository administrator confirms the gate profile; the server's launch evidence decides whether a run is held.",
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
const warnings = profile.warnings.map((warning, index) => createReadinessCheckSafely({
|
|
558
|
+
id: `server.gate-profile-warning-${index + 1}`,
|
|
559
|
+
source: "server",
|
|
560
|
+
label: "Gate profile warning",
|
|
561
|
+
status: "warn",
|
|
562
|
+
detail: describeGateProfileWarning(warning.code, warning.name),
|
|
563
|
+
remediation: GATE_PROFILE_WARNING_REMEDIATION,
|
|
564
|
+
}));
|
|
565
|
+
return [row, ...warnings];
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* BAPI-1405 — the three warn-only epic-branch CI rows as `server.*` checks:
|
|
569
|
+
* `server.effective-review-mode`, `server.epic-branch-ci-decision`, and
|
|
570
|
+
* `server.epic-branch-ci-coverage-warning`.
|
|
571
|
+
*
|
|
572
|
+
* Built from the SAME shared sections the install doctor renders, so the two
|
|
573
|
+
* surfaces cannot word one fact apart. Status is only ever `pass` or `warn`:
|
|
574
|
+
* these rows never fail, never hold a launch, and feed no verdict.
|
|
575
|
+
*
|
|
576
|
+
* Unlike `gateProfileChecks`, an absent or `null` block (older server) still
|
|
577
|
+
* yields the three rows, each `warn` with "Unreported — upgrade the server" and
|
|
578
|
+
* the upgrade remediation. Emitting nothing would read as "no warning", which
|
|
579
|
+
* is the wrong answer: the server simply did not say. Omitted and `null` render
|
|
580
|
+
* identically. The install doctor's own copies of these sections are filtered
|
|
581
|
+
* by `INSTALL_SECTION_IDS_OWNED_ELSEWHERE`, so each row still appears once.
|
|
582
|
+
*/
|
|
583
|
+
export function epicBranchCiChecks(readiness) {
|
|
584
|
+
return epicBranchCiSections(readiness.epic_branch_ci).map((section) => createReadinessCheckSafely({
|
|
585
|
+
id: `server.${section.id}`,
|
|
586
|
+
source: "server",
|
|
587
|
+
label: section.label,
|
|
588
|
+
status: section.status === "ok" ? "pass" : "warn",
|
|
589
|
+
detail: section.detail,
|
|
590
|
+
...(section.status === "ok" || section.remediation === undefined
|
|
591
|
+
? {}
|
|
592
|
+
: { remediation: section.remediation }),
|
|
593
|
+
}));
|
|
594
|
+
}
|
|
439
595
|
/**
|
|
440
596
|
* Project the server's typed launch evidence (BAPI-1260) into `server.*`
|
|
441
597
|
* checks, read-only.
|
|
@@ -626,11 +782,12 @@ function unattendedOutcomes(readiness) {
|
|
|
626
782
|
detail: readiness.review_workflow?.probe_succeeded === true &&
|
|
627
783
|
readiness.review_workflow?.workflow_present === false
|
|
628
784
|
? `${REVIEW_WORKFLOW_PATH} is missing from the default branch — an unattended auto-merging run will be refused`
|
|
629
|
-
: readiness
|
|
785
|
+
: !gateProfileWaivesCiWorkflow(readiness) &&
|
|
786
|
+
readiness.conductor_ci_workflow?.probe_succeeded === true &&
|
|
630
787
|
readiness.conductor_ci_workflow?.workflow_present === false
|
|
631
788
|
? `${CI_WORKFLOW_PATH} is missing from the default branch — an unattended auto-merging run will be refused`
|
|
632
789
|
: "the review and epic-implementer-ci workflows are not both confirmed — an unattended auto-merging run will be refused",
|
|
633
|
-
remediation:
|
|
790
|
+
remediation: gateProfileAwareRepositoryWorkflowRemedy(readiness),
|
|
634
791
|
},
|
|
635
792
|
];
|
|
636
793
|
}
|
|
@@ -639,6 +796,15 @@ function reviewPolicyOutcome(readiness) {
|
|
|
639
796
|
const s = readiness.supervisor;
|
|
640
797
|
const alignment = s.review_policy_alignment;
|
|
641
798
|
if (alignment !== null) {
|
|
799
|
+
// BAPI-1405 — `repository_policy` requires no review verdict, so a divergent
|
|
800
|
+
// done-gate review signal is not a nag. `invalid` still warns.
|
|
801
|
+
if (alignment.status === "divergent" && effectiveReviewModeIsRepositoryPolicy(readiness)) {
|
|
802
|
+
return {
|
|
803
|
+
id: "review-policy",
|
|
804
|
+
status: "pass",
|
|
805
|
+
detail: "alignment not applicable: the effective review mode is 'repository_policy', which requires no review verdict",
|
|
806
|
+
};
|
|
807
|
+
}
|
|
642
808
|
if (alignment.status === "divergent" || alignment.status === "invalid") {
|
|
643
809
|
return {
|
|
644
810
|
id: "review-policy",
|
|
@@ -839,7 +1005,23 @@ function executorOutcome(readiness) {
|
|
|
839
1005
|
remediation: SERVER_REMEDIATIONS.executorNeverSeen,
|
|
840
1006
|
};
|
|
841
1007
|
}
|
|
842
|
-
/**
|
|
1008
|
+
/**
|
|
1009
|
+
* BAPI-1390 — the repository-workflow remedy under the server's gate-profile
|
|
1010
|
+
* rule: with a STORED gate profile the CI lane file is not required, so the
|
|
1011
|
+
* remedy never names it. Without one, this is exactly
|
|
1012
|
+
* {@link repositoryWorkflowRemedy}.
|
|
1013
|
+
*/
|
|
1014
|
+
export function gateProfileAwareRepositoryWorkflowRemedy(readiness) {
|
|
1015
|
+
return repositoryWorkflowRemedy(gateProfileWaivesCiWorkflow(readiness) ? { ...readiness, conductor_ci_workflow: null } : readiness);
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Review-workflow protocol facts. `null` means an older server, not health.
|
|
1019
|
+
*
|
|
1020
|
+
* BAPI-1390: the server now requires the review file only for a run whose review
|
|
1021
|
+
* policy source is `verdict_protocol`. This repository-scoped report carries no
|
|
1022
|
+
* run policy, so the row keeps its existing behavior; the per-run decision is the
|
|
1023
|
+
* server's (`unattended.repository_readiness_confirmed` and admission evidence).
|
|
1024
|
+
*/
|
|
843
1025
|
function reviewWorkflowOutcome(readiness) {
|
|
844
1026
|
const w = readiness.review_workflow;
|
|
845
1027
|
if (w === null) {
|
|
@@ -892,6 +1074,16 @@ function reviewWorkflowOutcome(readiness) {
|
|
|
892
1074
|
/** Conductor-CI migration-guard facts. `null` means an older server. */
|
|
893
1075
|
function ciWorkflowOutcome(readiness) {
|
|
894
1076
|
const c = readiness.conductor_ci_workflow;
|
|
1077
|
+
// BAPI-1390 — a STORED gate profile names its own checks and migration guard,
|
|
1078
|
+
// so the lane file is not required. A healthy file still reports a pass; any
|
|
1079
|
+
// other state is reported as not applicable rather than as a gap.
|
|
1080
|
+
if (gateProfileWaivesCiWorkflow(readiness) && !(c?.probe_succeeded === true && c.migration_guard_present === true)) {
|
|
1081
|
+
return {
|
|
1082
|
+
id: "conductor-ci-workflow",
|
|
1083
|
+
status: "skip",
|
|
1084
|
+
detail: `${CI_WORKFLOW_PATH} is not required: this repository has a stored gate profile that names its own checks`,
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
895
1087
|
if (c === null) {
|
|
896
1088
|
return {
|
|
897
1089
|
id: "conductor-ci-workflow",
|
|
@@ -974,6 +1166,15 @@ const INSTALL_SECTION_IDS_OWNED_ELSEWHERE = new Set([
|
|
|
974
1166
|
"supervisor-configuration",
|
|
975
1167
|
"supervisor-required-checks",
|
|
976
1168
|
"supervisor-review-alignment",
|
|
1169
|
+
// BAPI-1390 — owned by `gateProfileChecks`.
|
|
1170
|
+
"supervisor-gate-profile",
|
|
1171
|
+
"supervisor-gate-profile-epic-branch-unseen",
|
|
1172
|
+
"supervisor-gate-profile-epic-branch-search",
|
|
1173
|
+
"supervisor-gate-profile-warning",
|
|
1174
|
+
// BAPI-1405 — owned by `epicBranchCiChecks`.
|
|
1175
|
+
EPIC_BRANCH_CI_SECTION_IDS.reviewMode,
|
|
1176
|
+
EPIC_BRANCH_CI_SECTION_IDS.decision,
|
|
1177
|
+
EPIC_BRANCH_CI_SECTION_IDS.coverage,
|
|
977
1178
|
"github-credentials",
|
|
978
1179
|
"github-actions-permission",
|
|
979
1180
|
"reconciler-liveness",
|
|
@@ -1059,7 +1260,9 @@ export async function collectConductorReadinessGate(deps) {
|
|
|
1059
1260
|
...mapInstallStatusChecksToReadinessChecks((installReport?.legacyInstallChecks ?? null), installReport === null),
|
|
1060
1261
|
...mapConductorDoctorReportToReadinessChecks(installReport?.legacyConductor ?? null),
|
|
1061
1262
|
...mapInstallSectionsToReadinessChecks(installReport?.sections ?? null),
|
|
1062
|
-
...mapPlanePreflightToReadinessChecks(planeResult, deps.planeNotApplicable
|
|
1263
|
+
...mapPlanePreflightToReadinessChecks(planeResult, deps.planeNotApplicable, {
|
|
1264
|
+
...(deps.epicRunId !== undefined ? { epicRunId: deps.epicRunId } : {}),
|
|
1265
|
+
}),
|
|
1063
1266
|
...mapServerReadinessToChecks(installReport?.readiness ?? null, classifyServerFailure(installReport)),
|
|
1064
1267
|
];
|
|
1065
1268
|
// Validate the ASSEMBLED report, not just the pieces: an injected collector or
|
|
@@ -122,7 +122,7 @@ function parseRunRecoveryArgs(argv) {
|
|
|
122
122
|
return { epicRunId: epicRunId.trim(), json: bools.has("--json"), help: false };
|
|
123
123
|
}
|
|
124
124
|
function parseTicketRecoveryArgs(argv) {
|
|
125
|
-
const { values, bools } = tokenizeRecoveryFlags(argv, TICKET_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
|
|
125
|
+
const { values, bools } = tokenizeRecoveryFlags(argv, new Set([...TICKET_VALUE_FLAGS, "--reason", "--cleanup-resolution"]), RECOVERY_BOOL_FLAGS);
|
|
126
126
|
if (bools.has("--help")) {
|
|
127
127
|
return { epicRunId: "", ticketKey: "", json: bools.has("--json"), help: true };
|
|
128
128
|
}
|
|
@@ -137,6 +137,10 @@ function parseTicketRecoveryArgs(argv) {
|
|
|
137
137
|
return {
|
|
138
138
|
epicRunId: epicRunId.trim(),
|
|
139
139
|
ticketKey: ticketKey.trim(),
|
|
140
|
+
...(values.get("--reason") ? { reason: values.get("--reason").trim() } : {}),
|
|
141
|
+
...(values.get("--cleanup-resolution") ? {
|
|
142
|
+
cleanupResolution: values.get("--cleanup-resolution"),
|
|
143
|
+
} : {}),
|
|
140
144
|
json: bools.has("--json"),
|
|
141
145
|
help: false,
|
|
142
146
|
};
|
|
@@ -263,7 +267,7 @@ export async function runAbandonRunCommand(argv) {
|
|
|
263
267
|
// unpark / adopt-current-head-and-unpark (shared rendering)
|
|
264
268
|
// ---------------------------------------------------------------------------
|
|
265
269
|
const UNPARK_USAGE = [
|
|
266
|
-
"Usage: epic-implementer unpark --epic-run-id <id> --ticket-key <key> [--json]",
|
|
270
|
+
"Usage: epic-implementer unpark --epic-run-id <id> --ticket-key <key> [--cleanup-resolution confirmed|risk_accepted --reason <text>] [--json]",
|
|
267
271
|
"",
|
|
268
272
|
"Move a parked (needs_human) ticket back into its gate machine, once the",
|
|
269
273
|
"operator has resolved what parked it. Retries a bounded number of times on",
|
|
@@ -337,6 +341,13 @@ export function renderTicketRecoveryResult(result, json) {
|
|
|
337
341
|
export async function runUnparkCommand(argv) {
|
|
338
342
|
try {
|
|
339
343
|
const parsed = parseTicketRecoveryArgs(argv);
|
|
344
|
+
if (parsed.cleanupResolution !== undefined &&
|
|
345
|
+
!["confirmed", "risk_accepted"].includes(parsed.cleanupResolution)) {
|
|
346
|
+
throw new ConductorValidationError("--cleanup-resolution must be confirmed or risk_accepted.");
|
|
347
|
+
}
|
|
348
|
+
if (parsed.cleanupResolution !== undefined && !parsed.reason) {
|
|
349
|
+
throw new ConductorValidationError("--cleanup-resolution requires --reason.");
|
|
350
|
+
}
|
|
340
351
|
if (parsed.help) {
|
|
341
352
|
console.log(UNPARK_USAGE);
|
|
342
353
|
return 0;
|
|
@@ -348,6 +359,8 @@ export async function runUnparkCommand(argv) {
|
|
|
348
359
|
const result = await unparkEpicTicketWithRetry(access, {
|
|
349
360
|
epicRunId: parsed.epicRunId,
|
|
350
361
|
ticketKey: parsed.ticketKey,
|
|
362
|
+
reason: parsed.reason,
|
|
363
|
+
cleanupResolution: parsed.cleanupResolution,
|
|
351
364
|
});
|
|
352
365
|
return renderTicketRecoveryResult(result, parsed.json);
|
|
353
366
|
}
|
|
@@ -359,6 +372,9 @@ export async function runUnparkCommand(argv) {
|
|
|
359
372
|
export async function runAdoptCurrentHeadAndUnparkCommand(argv) {
|
|
360
373
|
try {
|
|
361
374
|
const parsed = parseTicketRecoveryArgs(argv);
|
|
375
|
+
if (parsed.cleanupResolution !== undefined || parsed.reason !== undefined) {
|
|
376
|
+
throw new ConductorValidationError("Cleanup resolution applies only to unpark.");
|
|
377
|
+
}
|
|
362
378
|
if (parsed.help) {
|
|
363
379
|
console.log(ADOPT_CURRENT_HEAD_USAGE);
|
|
364
380
|
return 0;
|
|
@@ -1320,11 +1336,11 @@ export async function runResumeRunCommand(argv) {
|
|
|
1320
1336
|
// ---------------------------------------------------------------------------
|
|
1321
1337
|
// continue-child / continuation-receipt (BAPI-1300)
|
|
1322
1338
|
// ---------------------------------------------------------------------------
|
|
1323
|
-
const CONTINUE_CHILD_VALUE_FLAGS = new Set(["--epic-run-id", "--ticket-key", "--reason"]);
|
|
1339
|
+
const CONTINUE_CHILD_VALUE_FLAGS = new Set(["--epic-run-id", "--ticket-key", "--reason", "--worker-exit-proof"]);
|
|
1324
1340
|
const CONTINUATION_RECEIPT_VALUE_FLAGS = new Set(["--epic-run-id", "--ticket-key", "--request-id"]);
|
|
1325
1341
|
const MAX_REASON_CHARS = 500;
|
|
1326
1342
|
const CONTINUE_CHILD_USAGE = [
|
|
1327
|
-
"Usage: epic-implementer continue-child --epic-run-id <id> --ticket-key <key> [--reason <text>] [--json]",
|
|
1343
|
+
"Usage: epic-implementer continue-child --epic-run-id <id> --ticket-key <key> [--reason <text> --worker-exit-proof exited|fenced] [--json]",
|
|
1328
1344
|
"",
|
|
1329
1345
|
"Continue a parked child's CURRENT park episode without resetting it: the same",
|
|
1330
1346
|
"attempt resumes on its retained work with only the allowance it has left (no",
|
|
@@ -1351,6 +1367,13 @@ function parseContinuationArgs(argv, valueFlags) {
|
|
|
1351
1367
|
const epicRunId = requireFlag(values, "--epic-run-id");
|
|
1352
1368
|
const ticketKey = requireFlag(values, "--ticket-key");
|
|
1353
1369
|
const reason = values.get("--reason")?.trim();
|
|
1370
|
+
const workerExitProof = values.get("--worker-exit-proof")?.trim();
|
|
1371
|
+
if (workerExitProof !== undefined && workerExitProof !== "exited" && workerExitProof !== "fenced") {
|
|
1372
|
+
throw new ConductorValidationError('Flag "--worker-exit-proof" must be exited or fenced.');
|
|
1373
|
+
}
|
|
1374
|
+
if (workerExitProof !== undefined && !reason) {
|
|
1375
|
+
throw new ConductorValidationError('Flag "--worker-exit-proof" requires --reason.');
|
|
1376
|
+
}
|
|
1354
1377
|
if (reason !== undefined && reason.length > MAX_REASON_CHARS) {
|
|
1355
1378
|
throw new ConductorValidationError(`Flag "--reason" must be at most ${MAX_REASON_CHARS} characters.`);
|
|
1356
1379
|
}
|
|
@@ -1362,6 +1385,7 @@ function parseContinuationArgs(argv, valueFlags) {
|
|
|
1362
1385
|
epicRunId,
|
|
1363
1386
|
ticketKey,
|
|
1364
1387
|
...(reason ? { reason } : {}),
|
|
1388
|
+
...(workerExitProof ? { workerExitProof: workerExitProof } : {}),
|
|
1365
1389
|
...(requestId ? { requestId } : {}),
|
|
1366
1390
|
json: bools.has("--json"),
|
|
1367
1391
|
help: false,
|
|
@@ -1579,6 +1603,7 @@ export async function runContinueChildCommand(argv) {
|
|
|
1579
1603
|
epicRunId: parsed.epicRunId,
|
|
1580
1604
|
ticketKey: parsed.ticketKey,
|
|
1581
1605
|
...(parsed.reason ? { reason: parsed.reason } : {}),
|
|
1606
|
+
...(parsed.workerExitProof ? { workerExitProof: parsed.workerExitProof } : {}),
|
|
1582
1607
|
});
|
|
1583
1608
|
return renderContinueChildResult(result, parsed, parsed.json);
|
|
1584
1609
|
}
|
|
@@ -1587,6 +1612,60 @@ export async function runContinueChildCommand(argv) {
|
|
|
1587
1612
|
return 1;
|
|
1588
1613
|
}
|
|
1589
1614
|
}
|
|
1615
|
+
/** Explicit, bounded grant for the current parked attempt. */
|
|
1616
|
+
export async function runGrantContinuationCommand(argv) {
|
|
1617
|
+
try {
|
|
1618
|
+
const { values, bools } = tokenizeRecoveryFlags(argv, new Set(["--epic-run-id", "--ticket-key", "--extra-continuations", "--extra-seconds", "--reason", "--request-id"]), RECOVERY_BOOL_FLAGS);
|
|
1619
|
+
if (bools.has("--help")) {
|
|
1620
|
+
console.log("Usage: epic-implementer grant-continuation --epic-run-id <id> --ticket-key <key> --reason <text> [--extra-continuations 1] [--extra-seconds <1..3600>] [--request-id <id>] [--json]");
|
|
1621
|
+
return 0;
|
|
1622
|
+
}
|
|
1623
|
+
const epicRunId = requireFlag(values, "--epic-run-id");
|
|
1624
|
+
const ticketKey = requireFlag(values, "--ticket-key");
|
|
1625
|
+
const reason = requireFlag(values, "--reason");
|
|
1626
|
+
const extraContinuations = Number(values.get("--extra-continuations") ?? "0");
|
|
1627
|
+
const extraSeconds = Number(values.get("--extra-seconds") ?? "0");
|
|
1628
|
+
if (!Number.isInteger(extraContinuations) || extraContinuations < 0 || extraContinuations > 1 ||
|
|
1629
|
+
!Number.isInteger(extraSeconds) || extraSeconds < 0 || extraSeconds > 3600 ||
|
|
1630
|
+
(extraContinuations === 0 && extraSeconds === 0) || reason.length > MAX_REASON_CHARS) {
|
|
1631
|
+
throw new ConductorValidationError("Grant amounts or reason are outside the allowed bounds.");
|
|
1632
|
+
}
|
|
1633
|
+
const explicitRequestId = values.get("--request-id");
|
|
1634
|
+
if (explicitRequestId !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:-]{7,63}$/.test(explicitRequestId)) {
|
|
1635
|
+
throw new ConductorValidationError("--request-id is malformed.");
|
|
1636
|
+
}
|
|
1637
|
+
const json = bools.has("--json");
|
|
1638
|
+
const access = await resolveRecoveryAccess(json);
|
|
1639
|
+
if (!access)
|
|
1640
|
+
return 1;
|
|
1641
|
+
const { grantContinuationOnce } = await import("./recovery-operations.js");
|
|
1642
|
+
const operation = await grantContinuationOnce(access, {
|
|
1643
|
+
epicRunId, ticketKey, extraContinuations, extraSeconds, reason,
|
|
1644
|
+
...(explicitRequestId !== undefined ? { requestId: explicitRequestId } : {}),
|
|
1645
|
+
});
|
|
1646
|
+
if (operation.kind === "not_parked") {
|
|
1647
|
+
throw new ConductorValidationError("The child is not parked at a continuable gate.");
|
|
1648
|
+
}
|
|
1649
|
+
if (operation.kind === "unavailable") {
|
|
1650
|
+
console.error(`Error: ${operation.message}`);
|
|
1651
|
+
return 1;
|
|
1652
|
+
}
|
|
1653
|
+
const { requestId, outcome: result } = operation;
|
|
1654
|
+
if (json)
|
|
1655
|
+
console.log(JSON.stringify({ ...result, request_id: requestId }));
|
|
1656
|
+
else if (result.kind === "result")
|
|
1657
|
+
console.log(`Grant ${result.replayed ? "replayed" : "committed"}: ${requestId}. The child remains parked.`);
|
|
1658
|
+
else if (result.kind === "unconfirmed")
|
|
1659
|
+
console.error(`Grant outcome unconfirmed. Rerun the same command; it reuses request ${requestId}.`);
|
|
1660
|
+
else
|
|
1661
|
+
console.error(`Grant refused (${result.status}${result.errorCode ? `, ${result.errorCode}` : ""}).`);
|
|
1662
|
+
return result.kind === "result" ? 0 : 1;
|
|
1663
|
+
}
|
|
1664
|
+
catch (error) {
|
|
1665
|
+
console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
|
|
1666
|
+
return 1;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1590
1669
|
export async function runContinuationReceiptCommand(argv) {
|
|
1591
1670
|
try {
|
|
1592
1671
|
const parsed = parseContinuationReceiptArgs(argv);
|
|
@@ -1845,3 +1924,30 @@ export async function runResumeCiCommand(argv) {
|
|
|
1845
1924
|
return RESUME_CI_EXIT_FAILED;
|
|
1846
1925
|
}
|
|
1847
1926
|
}
|
|
1927
|
+
/** No state mutation: callers halt the affected work before asking a question. */
|
|
1928
|
+
export async function runEscalateCommand(argv, deps = {}) {
|
|
1929
|
+
try {
|
|
1930
|
+
const { values, bools } = tokenizeRecoveryFlags(argv, new Set(["--epic-run-id", "--ticket-key", "--cause", "--action-needed"]), RECOVERY_BOOL_FLAGS);
|
|
1931
|
+
if (bools.has("--help")) {
|
|
1932
|
+
console.log("Usage: epic-implementer escalate --epic-run-id <id> --ticket-key <key> --cause <cause> --action-needed <text> [--json]");
|
|
1933
|
+
return 0;
|
|
1934
|
+
}
|
|
1935
|
+
const request = { epicRunId: requireFlag(values, "--epic-run-id"),
|
|
1936
|
+
ticketKey: requireFlag(values, "--ticket-key"), cause: requireFlag(values, "--cause"),
|
|
1937
|
+
actionNeeded: requireFlag(values, "--action-needed") };
|
|
1938
|
+
if (request.actionNeeded.length > 300) {
|
|
1939
|
+
console.error("Error: action-needed exceeds 300 characters");
|
|
1940
|
+
return 1;
|
|
1941
|
+
}
|
|
1942
|
+
const access = await (deps.resolveAccess ?? (() => resolveRecoveryAccess(false)))();
|
|
1943
|
+
if (!access)
|
|
1944
|
+
return 1;
|
|
1945
|
+
const escalate = deps.escalate ?? (await import("./bridge-api-client.js")).escalateTicket;
|
|
1946
|
+
console.log(JSON.stringify(await escalate(access, request)));
|
|
1947
|
+
return 0;
|
|
1948
|
+
}
|
|
1949
|
+
catch {
|
|
1950
|
+
console.error("Error: escalation request refused or unavailable");
|
|
1951
|
+
return 1;
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
@@ -18,7 +18,7 @@
|
|
|
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, 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, resumeRun, safeDiagnosticMessage, scopeCatchUp, scopeReparse, stopEpicRun, unparkEpicTicket, updateEpicRunStatus, fetchChildContinuationStatus, postContinueParkedChild, postGrantContinuation, resumeCiTicket, 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
24
|
import { canonicalSha256, clearContinuationReference, clearRecoveryReferenceFile, computeContinuationFingerprint, readRecoveryReferenceFile, writeRecoveryReferenceFile, isFullSha, newContinuationRequestId, readContinuationReference, writeContinuationReference, } from "./epic-child-continuation.js";
|
|
@@ -179,6 +179,7 @@ async function ticketRecoveryWithRetry(access, options, mutate) {
|
|
|
179
179
|
expectedRowVersion: ticket.row_version,
|
|
180
180
|
idempotencyKey,
|
|
181
181
|
reason: options.reason,
|
|
182
|
+
cleanupResolution: options.cleanupResolution,
|
|
182
183
|
});
|
|
183
184
|
}
|
|
184
185
|
catch (err) {
|
|
@@ -697,6 +698,7 @@ export async function continueChildOnce(access, options, deps = {}) {
|
|
|
697
698
|
requestId: ref.requestId,
|
|
698
699
|
requestFingerprint: ref.requestFingerprint,
|
|
699
700
|
reason: options.reason,
|
|
701
|
+
workerExitProof: options.workerExitProof,
|
|
700
702
|
});
|
|
701
703
|
if (outcome.kind === "result" && AUTHORITATIVE_OUTCOMES.has(outcome.result.outcome)) {
|
|
702
704
|
await clear();
|
|
@@ -724,6 +726,102 @@ export async function continueChildOnce(access, options, deps = {}) {
|
|
|
724
726
|
}
|
|
725
727
|
return { kind: "unconfirmed", requestId: ref.requestId, reusedReference, transport };
|
|
726
728
|
}
|
|
729
|
+
/** The namespace of pending `grant-continuation` requests (BAPI-1409). */
|
|
730
|
+
export const CONTINUATION_GRANT_REFERENCE_NAMESPACE = "epic-continuation-grants";
|
|
731
|
+
const GRANT_REQUEST_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,63}$/;
|
|
732
|
+
function pendingGrantMatches(raw, wanted) {
|
|
733
|
+
if (raw === null || typeof raw !== "object")
|
|
734
|
+
return null;
|
|
735
|
+
const ref = raw;
|
|
736
|
+
if (ref.version !== 1 ||
|
|
737
|
+
ref.repoName !== wanted.repoName ||
|
|
738
|
+
ref.parkedGate !== wanted.parkedGate ||
|
|
739
|
+
ref.sourceParkGeneration !== wanted.sourceParkGeneration ||
|
|
740
|
+
ref.extraContinuations !== wanted.extraContinuations ||
|
|
741
|
+
ref.extraSeconds !== wanted.extraSeconds ||
|
|
742
|
+
ref.reason !== wanted.reason ||
|
|
743
|
+
typeof ref.requestId !== "string" ||
|
|
744
|
+
!GRANT_REQUEST_ID_RE.test(ref.requestId)) {
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
return ref.requestId;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Grant bounded continuation capacity, at most one POST per invocation.
|
|
751
|
+
*
|
|
752
|
+
* The request id is pinned locally BEFORE the POST, keyed by run and ticket, and
|
|
753
|
+
* reused while the park episode, gate, amounts, and reason are unchanged. A rerun
|
|
754
|
+
* after a lost response therefore replays the same grant (the server answers it
|
|
755
|
+
* from the stored grant) instead of spending a second one. The reference is
|
|
756
|
+
* cleared once the outcome is authoritative and kept when it is unconfirmed.
|
|
757
|
+
*/
|
|
758
|
+
export async function grantContinuationOnce(access, options, deps = {}) {
|
|
759
|
+
const fetchStatus = deps.fetchStatus ?? fetchChildContinuationStatus;
|
|
760
|
+
const post = deps.post ?? postGrantContinuation;
|
|
761
|
+
const store = deps.store ?? {};
|
|
762
|
+
const key = { epicRunId: options.epicRunId, ticketKey: options.ticketKey };
|
|
763
|
+
let status;
|
|
764
|
+
try {
|
|
765
|
+
status = await fetchStatus(access, key);
|
|
766
|
+
}
|
|
767
|
+
catch (err) {
|
|
768
|
+
return { kind: "unavailable", message: safeDiagnosticMessage(err, "could not read the child") };
|
|
769
|
+
}
|
|
770
|
+
const current = status.current;
|
|
771
|
+
if (!current.parked || current.parked_gate === null || current.park_generation === null) {
|
|
772
|
+
return { kind: "not_parked" };
|
|
773
|
+
}
|
|
774
|
+
const wanted = {
|
|
775
|
+
version: 1,
|
|
776
|
+
repoName: access.repoName,
|
|
777
|
+
parkedGate: current.parked_gate,
|
|
778
|
+
sourceParkGeneration: current.park_generation,
|
|
779
|
+
extraContinuations: options.extraContinuations,
|
|
780
|
+
extraSeconds: options.extraSeconds,
|
|
781
|
+
reason: options.reason,
|
|
782
|
+
};
|
|
783
|
+
let requestId = options.requestId ?? null;
|
|
784
|
+
let reusedReference = false;
|
|
785
|
+
const pinned = requestId === null;
|
|
786
|
+
if (pinned) {
|
|
787
|
+
const stored = pendingGrantMatches(await readRecoveryReferenceFile(CONTINUATION_GRANT_REFERENCE_NAMESPACE, key, store), wanted);
|
|
788
|
+
if (stored !== null) {
|
|
789
|
+
requestId = stored;
|
|
790
|
+
reusedReference = true;
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
requestId = `grant_${(deps.newRequestId ?? randomUUID)()}`;
|
|
794
|
+
const reference = {
|
|
795
|
+
...wanted, requestId, createdAt: new Date().toISOString(),
|
|
796
|
+
};
|
|
797
|
+
try {
|
|
798
|
+
await writeRecoveryReferenceFile(CONTINUATION_GRANT_REFERENCE_NAMESPACE, key, reference, store);
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
return {
|
|
802
|
+
kind: "unavailable",
|
|
803
|
+
message: "could not store the local grant reference, so the request was not sent",
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
const id = requestId;
|
|
809
|
+
const outcome = await post(access, {
|
|
810
|
+
epicRunId: options.epicRunId,
|
|
811
|
+
ticketKey: options.ticketKey,
|
|
812
|
+
requestId: id,
|
|
813
|
+
parkedGate: current.parked_gate,
|
|
814
|
+
expectedRowVersion: current.row_version,
|
|
815
|
+
sourceParkGeneration: current.park_generation,
|
|
816
|
+
extraContinuations: options.extraContinuations,
|
|
817
|
+
extraSeconds: options.extraSeconds,
|
|
818
|
+
reason: options.reason,
|
|
819
|
+
});
|
|
820
|
+
if (pinned && outcome.kind !== "unconfirmed") {
|
|
821
|
+
await clearRecoveryReferenceFile(CONTINUATION_GRANT_REFERENCE_NAMESPACE, key, store);
|
|
822
|
+
}
|
|
823
|
+
return { kind: "posted", requestId: id, reusedReference, outcome };
|
|
824
|
+
}
|
|
727
825
|
/**
|
|
728
826
|
* Read a child's current episode and, when a local recovery reference exists (or
|
|
729
827
|
* `requestId` is given), that request's receipt. One bounded GET; read-only. A
|