@bridge_gpt/mcp-server 0.2.69 → 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 +140 -292
- 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 +23 -4
- package/build/claude-review-workflow-drift.js +3 -3
- package/build/codex-bridge-profile.js +1 -1
- package/build/commands.generated.js +6 -6
- 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/credential-store.js +356 -13
- package/build/docs.generated.js +26 -13
- package/build/doctor.js +94 -32
- 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 +57 -41
- 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 +66 -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 +72 -0
- 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 +1067 -536
- 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 +376 -60
- 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/setup-epic.js +400 -30
- package/build/shared-resources.generated.js +5 -0
- package/build/start-tickets.js +28 -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/upgrade-cli.js +13 -0
- 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
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BAPI-1426 — the local synchronization journal for ticket delivery evidence.
|
|
3
|
+
*
|
|
4
|
+
* Phase results are written to disk first (see {@link ./phase-result-artifacts.js})
|
|
5
|
+
* and synchronized with the server second. This journal is what makes that order
|
|
6
|
+
* safe: it holds the binding reference, a stable registration key, every batch
|
|
7
|
+
* still waiting to be accepted, and the receipts for batches the server answered.
|
|
8
|
+
* A sync failure, a re-entry, or a lost process therefore never loses local work —
|
|
9
|
+
* the pending batch is still here with the same submission id, so a retry is
|
|
10
|
+
* idempotent on the server.
|
|
11
|
+
*
|
|
12
|
+
* Rules that matter:
|
|
13
|
+
*
|
|
14
|
+
* - **Deterministic submission ids.** The id is derived from the canonical content
|
|
15
|
+
* of the batch, so enqueueing the same batch twice yields the same entry.
|
|
16
|
+
* - **Fail closed.** A corrupt journal throws {@link PlanLedgerError}; it is never
|
|
17
|
+
* silently replaced with an empty one, because an empty journal would forget
|
|
18
|
+
* pending evidence and the binding.
|
|
19
|
+
* - **No ledger fields.** Receipts and ordering live here, never on a
|
|
20
|
+
* `PlanStepRecord`.
|
|
21
|
+
*
|
|
22
|
+
* Filesystem only: no network, no git, no subprocesses, no MCP transport.
|
|
23
|
+
*/
|
|
24
|
+
import { createHash, randomUUID } from "crypto";
|
|
25
|
+
import { PLAN_PHASES, PlanLedgerError } from "./plan-execution-ledger.js";
|
|
26
|
+
import { PHASE_RESULT_SUBDIR, assertDocsDir, assertValidTicketKey, readJsonArtifact, resolveWithinRoot, writeAtomically, } from "./phase-result-artifacts.js";
|
|
27
|
+
export const DELIVERY_JOURNAL_SUFFIX = "delivery";
|
|
28
|
+
export const DELIVERY_JOURNAL_VERSION = 1;
|
|
29
|
+
/** Matches the server's identifier rule for registration keys and submission ids. */
|
|
30
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/;
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Layout
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/** `phase-results/<TICKET>-delivery.json`, relative to the docs dir. */
|
|
35
|
+
export function deliveryJournalRelativePath(ticketKey) {
|
|
36
|
+
return `${PHASE_RESULT_SUBDIR}/${assertValidTicketKey(ticketKey)}-${DELIVERY_JOURNAL_SUFFIX}.json`;
|
|
37
|
+
}
|
|
38
|
+
function journalPaths(docsDir, ticketKey) {
|
|
39
|
+
const root = assertDocsDir(docsDir);
|
|
40
|
+
const key = assertValidTicketKey(ticketKey);
|
|
41
|
+
const relativePath = deliveryJournalRelativePath(key);
|
|
42
|
+
return { key, relativePath, absolutePath: resolveWithinRoot(root, relativePath) };
|
|
43
|
+
}
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Validation
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
function isPlainObject(value) {
|
|
48
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
function isNonEmptyString(value) {
|
|
51
|
+
return typeof value === "string" && value.trim() !== "";
|
|
52
|
+
}
|
|
53
|
+
function corrupt(relativePath, detail) {
|
|
54
|
+
return new PlanLedgerError(`${relativePath} is corrupt (${detail}); refusing to treat a damaged delivery journal as an empty one`);
|
|
55
|
+
}
|
|
56
|
+
function validateBinding(value, relativePath) {
|
|
57
|
+
if (value === null)
|
|
58
|
+
return null;
|
|
59
|
+
if (!isPlainObject(value))
|
|
60
|
+
throw corrupt(relativePath, "binding is not an object");
|
|
61
|
+
for (const field of [
|
|
62
|
+
"attempt_id",
|
|
63
|
+
"repo_name",
|
|
64
|
+
"ticket_number",
|
|
65
|
+
"source_branch",
|
|
66
|
+
"base_branch",
|
|
67
|
+
]) {
|
|
68
|
+
if (!isNonEmptyString(value[field])) {
|
|
69
|
+
throw corrupt(relativePath, `binding is missing ${field}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
attempt_id: value.attempt_id,
|
|
74
|
+
repo_name: value.repo_name,
|
|
75
|
+
ticket_number: value.ticket_number,
|
|
76
|
+
source_branch: value.source_branch,
|
|
77
|
+
base_branch: value.base_branch,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function validatePending(value, relativePath) {
|
|
81
|
+
if (!isPlainObject(value))
|
|
82
|
+
throw corrupt(relativePath, "pending entry is not an object");
|
|
83
|
+
if (!isNonEmptyString(value.submission_id) || !IDENTIFIER_PATTERN.test(value.submission_id)) {
|
|
84
|
+
throw corrupt(relativePath, "pending entry has an invalid submission_id");
|
|
85
|
+
}
|
|
86
|
+
if (typeof value.phase !== "string" ||
|
|
87
|
+
!PLAN_PHASES.includes(value.phase)) {
|
|
88
|
+
throw corrupt(relativePath, "pending entry has an unknown phase");
|
|
89
|
+
}
|
|
90
|
+
if (!isNonEmptyString(value.content_digest)) {
|
|
91
|
+
throw corrupt(relativePath, "pending entry has no content_digest");
|
|
92
|
+
}
|
|
93
|
+
if (!isPlainObject(value.payload)) {
|
|
94
|
+
throw corrupt(relativePath, "pending entry payload is not an object");
|
|
95
|
+
}
|
|
96
|
+
if (!isNonEmptyString(value.created_at)) {
|
|
97
|
+
throw corrupt(relativePath, "pending entry has no created_at");
|
|
98
|
+
}
|
|
99
|
+
return value;
|
|
100
|
+
}
|
|
101
|
+
function validateReceipt(value, relativePath) {
|
|
102
|
+
if (!isPlainObject(value))
|
|
103
|
+
throw corrupt(relativePath, "receipt is not an object");
|
|
104
|
+
if (!isNonEmptyString(value.submission_id)) {
|
|
105
|
+
throw corrupt(relativePath, "receipt has no submission_id");
|
|
106
|
+
}
|
|
107
|
+
if (!isNonEmptyString(value.operation_status)) {
|
|
108
|
+
throw corrupt(relativePath, "receipt has no operation_status");
|
|
109
|
+
}
|
|
110
|
+
const revision = value.accepted_evidence_revision;
|
|
111
|
+
if (revision !== null && !(typeof revision === "number" && Number.isInteger(revision))) {
|
|
112
|
+
throw corrupt(relativePath, "receipt has an invalid accepted_evidence_revision");
|
|
113
|
+
}
|
|
114
|
+
if (!isNonEmptyString(value.recorded_at)) {
|
|
115
|
+
throw corrupt(relativePath, "receipt has no recorded_at");
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
function validateJournal(parsed, relativePath, ticketKey) {
|
|
120
|
+
if (!isPlainObject(parsed))
|
|
121
|
+
throw corrupt(relativePath, "not an object");
|
|
122
|
+
if (parsed.artifact_version !== DELIVERY_JOURNAL_VERSION) {
|
|
123
|
+
throw corrupt(relativePath, `unsupported artifact_version ${String(parsed.artifact_version)}`);
|
|
124
|
+
}
|
|
125
|
+
if (parsed.ticket_key !== ticketKey) {
|
|
126
|
+
throw corrupt(relativePath, `records ticket '${String(parsed.ticket_key)}'`);
|
|
127
|
+
}
|
|
128
|
+
if (!isNonEmptyString(parsed.registration_key) ||
|
|
129
|
+
!IDENTIFIER_PATTERN.test(parsed.registration_key)) {
|
|
130
|
+
throw corrupt(relativePath, "registration_key is missing or invalid");
|
|
131
|
+
}
|
|
132
|
+
if (!("binding" in parsed))
|
|
133
|
+
throw corrupt(relativePath, "binding is missing");
|
|
134
|
+
if (!Array.isArray(parsed.pending))
|
|
135
|
+
throw corrupt(relativePath, "pending is not an array");
|
|
136
|
+
if (!Array.isArray(parsed.receipts))
|
|
137
|
+
throw corrupt(relativePath, "receipts is not an array");
|
|
138
|
+
return {
|
|
139
|
+
artifact_version: DELIVERY_JOURNAL_VERSION,
|
|
140
|
+
ticket_key: ticketKey,
|
|
141
|
+
registration_key: parsed.registration_key,
|
|
142
|
+
binding: validateBinding(parsed.binding, relativePath),
|
|
143
|
+
pending: parsed.pending.map((entry) => validatePending(entry, relativePath)),
|
|
144
|
+
receipts: parsed.receipts.map((entry) => validateReceipt(entry, relativePath)),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Canonical content
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
/** JSON with object keys sorted at every level, so equal content hashes equally. */
|
|
151
|
+
function canonicalJson(value) {
|
|
152
|
+
if (Array.isArray(value))
|
|
153
|
+
return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
|
|
154
|
+
if (isPlainObject(value)) {
|
|
155
|
+
const keys = Object.keys(value)
|
|
156
|
+
.filter((key) => value[key] !== undefined)
|
|
157
|
+
.sort();
|
|
158
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
159
|
+
}
|
|
160
|
+
return JSON.stringify(value) ?? "null";
|
|
161
|
+
}
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Public API
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
/** Read the journal; `undefined` when absent. Throws {@link PlanLedgerError} on corruption. */
|
|
166
|
+
export async function readDeliveryJournal(docsDir, ticketKey) {
|
|
167
|
+
const { key, relativePath, absolutePath } = journalPaths(docsDir, ticketKey);
|
|
168
|
+
const parsed = await readJsonArtifact(absolutePath, relativePath);
|
|
169
|
+
if (parsed === undefined)
|
|
170
|
+
return undefined;
|
|
171
|
+
return validateJournal(parsed, relativePath, key);
|
|
172
|
+
}
|
|
173
|
+
async function writeJournal(docsDir, journal) {
|
|
174
|
+
const { relativePath, absolutePath } = journalPaths(docsDir, journal.ticket_key);
|
|
175
|
+
await writeAtomically(absolutePath, relativePath, journal);
|
|
176
|
+
}
|
|
177
|
+
/** Read the journal, creating it with a fresh random registration key when absent. */
|
|
178
|
+
export async function ensureDeliveryJournal(docsDir, ticketKey) {
|
|
179
|
+
const existing = await readDeliveryJournal(docsDir, ticketKey);
|
|
180
|
+
if (existing)
|
|
181
|
+
return existing;
|
|
182
|
+
const journal = {
|
|
183
|
+
artifact_version: DELIVERY_JOURNAL_VERSION,
|
|
184
|
+
ticket_key: assertValidTicketKey(ticketKey),
|
|
185
|
+
registration_key: `bapi-reg-${randomUUID()}`,
|
|
186
|
+
binding: null,
|
|
187
|
+
pending: [],
|
|
188
|
+
receipts: [],
|
|
189
|
+
};
|
|
190
|
+
await writeJournal(docsDir, journal);
|
|
191
|
+
return journal;
|
|
192
|
+
}
|
|
193
|
+
/** Remember which server binding this ticket's local work belongs to. */
|
|
194
|
+
export async function recordBindingReference(docsDir, ticketKey, binding) {
|
|
195
|
+
const journal = await ensureDeliveryJournal(docsDir, ticketKey);
|
|
196
|
+
const validated = validateBinding(binding, deliveryJournalRelativePath(journal.ticket_key));
|
|
197
|
+
const updated = { ...journal, binding: validated };
|
|
198
|
+
await writeJournal(docsDir, updated);
|
|
199
|
+
return updated;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Queue one evidence batch for synchronization.
|
|
203
|
+
*
|
|
204
|
+
* Idempotent: the submission id comes from the canonical content of
|
|
205
|
+
* `{phase, payload}`, so the same batch maps to the same id. When that id is
|
|
206
|
+
* already pending, the existing entry is returned; when it already has a receipt,
|
|
207
|
+
* a matching entry is returned without re-queueing it.
|
|
208
|
+
*/
|
|
209
|
+
export async function enqueuePendingSubmission(docsDir, ticketKey, input) {
|
|
210
|
+
if (!PLAN_PHASES.includes(input.phase)) {
|
|
211
|
+
throw new PlanLedgerError(`unknown phase '${String(input.phase)}'`);
|
|
212
|
+
}
|
|
213
|
+
if (!isPlainObject(input.payload)) {
|
|
214
|
+
throw new PlanLedgerError("pending submission payload must be an object");
|
|
215
|
+
}
|
|
216
|
+
const journal = await ensureDeliveryJournal(docsDir, ticketKey);
|
|
217
|
+
const digest = createHash("sha256")
|
|
218
|
+
.update(canonicalJson({ phase: input.phase, payload: input.payload }))
|
|
219
|
+
.digest("hex");
|
|
220
|
+
const submissionId = `${journal.ticket_key}.${input.phase}.${digest.slice(0, 32)}`;
|
|
221
|
+
const pending = journal.pending.find((entry) => entry.submission_id === submissionId);
|
|
222
|
+
if (pending)
|
|
223
|
+
return pending;
|
|
224
|
+
const entry = {
|
|
225
|
+
submission_id: submissionId,
|
|
226
|
+
phase: input.phase,
|
|
227
|
+
content_digest: digest,
|
|
228
|
+
payload: JSON.parse(JSON.stringify(input.payload)),
|
|
229
|
+
created_at: new Date().toISOString(),
|
|
230
|
+
};
|
|
231
|
+
const receipt = journal.receipts.find((item) => item.submission_id === submissionId);
|
|
232
|
+
if (receipt) {
|
|
233
|
+
// Already answered by the server; re-queueing it would only replay it.
|
|
234
|
+
return { ...entry, created_at: receipt.recorded_at };
|
|
235
|
+
}
|
|
236
|
+
await writeJournal(docsDir, { ...journal, pending: [...journal.pending, entry] });
|
|
237
|
+
return entry;
|
|
238
|
+
}
|
|
239
|
+
/** Record the server's answer for one submission and drop that pending entry only. */
|
|
240
|
+
export async function recordSubmissionReceipt(docsDir, ticketKey, receipt) {
|
|
241
|
+
const journal = await ensureDeliveryJournal(docsDir, ticketKey);
|
|
242
|
+
const stored = validateReceipt({ ...receipt, recorded_at: new Date().toISOString() }, deliveryJournalRelativePath(journal.ticket_key));
|
|
243
|
+
const updated = {
|
|
244
|
+
...journal,
|
|
245
|
+
pending: journal.pending.filter((entry) => entry.submission_id !== receipt.submission_id),
|
|
246
|
+
receipts: [
|
|
247
|
+
...journal.receipts.filter((entry) => entry.submission_id !== receipt.submission_id),
|
|
248
|
+
stored,
|
|
249
|
+
],
|
|
250
|
+
};
|
|
251
|
+
await writeJournal(docsDir, updated);
|
|
252
|
+
return updated;
|
|
253
|
+
}
|
|
254
|
+
/** Pending batches in the order they were queued; empty when no journal exists. */
|
|
255
|
+
export async function listPendingSubmissions(docsDir, ticketKey) {
|
|
256
|
+
const journal = await readDeliveryJournal(docsDir, ticketKey);
|
|
257
|
+
return journal ? [...journal.pending] : [];
|
|
258
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BAPI-1426 — the shared adapter between local phase work and the server's
|
|
3
|
+
* ticket delivery authority.
|
|
4
|
+
*
|
|
5
|
+
* Both execution paths use it: the inline MCP tools in `index.ts` and the
|
|
6
|
+
* orchestrated pipeline in `pipeline-orchestrator.ts`. It does three things and
|
|
7
|
+
* nothing more:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Ensure a binding.** Use the binding the local journal remembers; else ask
|
|
10
|
+
* the server to recover the active attempt for this ticket and branch; else
|
|
11
|
+
* register a new one with the journal's stable registration key. A restarted
|
|
12
|
+
* process therefore recovers its attempt instead of inventing a new one.
|
|
13
|
+
* 2. **Synchronize evidence.** Retry pending batches oldest first. A batch the
|
|
14
|
+
* server stored (accepted, replayed, retained as history, or refused for good)
|
|
15
|
+
* gets a receipt and leaves the pending list. A batch the server did not store
|
|
16
|
+
* (`retryable: true`, a network failure, an unavailable server) stays pending.
|
|
17
|
+
* 3. **Fetch the accepted ledger,** which is the only thing that may give the
|
|
18
|
+
* post-PR phase ownership.
|
|
19
|
+
*
|
|
20
|
+
* It never decides readiness. It reports what the server said, separately from
|
|
21
|
+
* whether local storage succeeded, and it never throws for a server or network
|
|
22
|
+
* problem: enrollment degrades to `"unavailable"` and the local work stays put.
|
|
23
|
+
*/
|
|
24
|
+
import { ensureDeliveryJournal, enqueuePendingSubmission, listPendingSubmissions, readDeliveryJournal, recordBindingReference, recordSubmissionReceipt, } from "./ticket-delivery-artifacts.js";
|
|
25
|
+
import { PLAN_PHASES, } from "./plan-execution-ledger.js";
|
|
26
|
+
import { getAcceptedLedger, recoverExecution, registerExecution, sanitizeDeliveryMessage, submitEvidence, } from "./ticket-delivery.js";
|
|
27
|
+
export const DEFAULT_DELIVERY_JOURNAL_STORE = {
|
|
28
|
+
ensureDeliveryJournal,
|
|
29
|
+
readDeliveryJournal,
|
|
30
|
+
recordBindingReference,
|
|
31
|
+
enqueuePendingSubmission,
|
|
32
|
+
recordSubmissionReceipt,
|
|
33
|
+
listPendingSubmissions,
|
|
34
|
+
};
|
|
35
|
+
function storeOf(deps) {
|
|
36
|
+
return deps.store ?? DEFAULT_DELIVERY_JOURNAL_STORE;
|
|
37
|
+
}
|
|
38
|
+
function unavailable(reason) {
|
|
39
|
+
return {
|
|
40
|
+
enrollment: "unavailable",
|
|
41
|
+
newlyRegistered: false,
|
|
42
|
+
reason: sanitizeDeliveryMessage(reason),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Find or create this ticket's managed binding. Never throws.
|
|
47
|
+
*
|
|
48
|
+
* Order: journal reference (when it names this repo and branch) → server recovery
|
|
49
|
+
* → registration. A 409 `ENROLLMENT_DISABLED` continues unbound as `"disabled"`;
|
|
50
|
+
* anything else that goes wrong continues unbound as `"unavailable"`.
|
|
51
|
+
*/
|
|
52
|
+
export async function ensureDeliveryBinding(deps, ticketKey) {
|
|
53
|
+
const store = storeOf(deps);
|
|
54
|
+
try {
|
|
55
|
+
const journal = await store.ensureDeliveryJournal(deps.docsDir, ticketKey);
|
|
56
|
+
let sourceBranch;
|
|
57
|
+
try {
|
|
58
|
+
sourceBranch = (await deps.resolveSourceBranch())?.trim() || undefined;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
sourceBranch = undefined;
|
|
62
|
+
}
|
|
63
|
+
const remembered = journal.binding;
|
|
64
|
+
if (remembered &&
|
|
65
|
+
remembered.repo_name === deps.repoName &&
|
|
66
|
+
(sourceBranch === undefined || remembered.source_branch === sourceBranch)) {
|
|
67
|
+
return { enrollment: "resumed", binding: remembered, newlyRegistered: false };
|
|
68
|
+
}
|
|
69
|
+
if (!sourceBranch || sourceBranch === "HEAD") {
|
|
70
|
+
return unavailable("the source branch could not be read (detached HEAD or git unavailable)");
|
|
71
|
+
}
|
|
72
|
+
const ticketNumber = journal.ticket_key;
|
|
73
|
+
const recovered = await recoverExecution(deps.client, {
|
|
74
|
+
repo_name: deps.repoName,
|
|
75
|
+
ticket_number: ticketNumber,
|
|
76
|
+
source_branch: sourceBranch,
|
|
77
|
+
});
|
|
78
|
+
if (recovered.ok) {
|
|
79
|
+
const binding = referenceFrom(recovered.body.binding);
|
|
80
|
+
await store.recordBindingReference(deps.docsDir, ticketKey, binding);
|
|
81
|
+
return { enrollment: "recovered", binding, newlyRegistered: false };
|
|
82
|
+
}
|
|
83
|
+
if (recovered.status !== 404) {
|
|
84
|
+
return unavailable(`recovery failed (${recovered.error}): ${recovered.message}`);
|
|
85
|
+
}
|
|
86
|
+
const registered = await registerExecution(deps.client, {
|
|
87
|
+
repo_name: deps.repoName,
|
|
88
|
+
ticket_number: ticketNumber,
|
|
89
|
+
source_branch: sourceBranch,
|
|
90
|
+
base_branch: deps.resolvedBaseBranch,
|
|
91
|
+
registration_key: journal.registration_key,
|
|
92
|
+
...(deps.executorJobId !== undefined ? { executor_job_id: deps.executorJobId } : {}),
|
|
93
|
+
});
|
|
94
|
+
if (registered.ok) {
|
|
95
|
+
const binding = referenceFrom(registered.body.binding);
|
|
96
|
+
await store.recordBindingReference(deps.docsDir, ticketKey, binding);
|
|
97
|
+
const replayed = registered.body.operation_status === "resumed";
|
|
98
|
+
return {
|
|
99
|
+
enrollment: replayed ? "resumed" : "registered",
|
|
100
|
+
binding,
|
|
101
|
+
newlyRegistered: !replayed,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (registered.status === 409 && registered.error === "ENROLLMENT_DISABLED") {
|
|
105
|
+
return {
|
|
106
|
+
enrollment: "disabled",
|
|
107
|
+
newlyRegistered: false,
|
|
108
|
+
reason: registered.message,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return unavailable(`registration failed (${registered.error}): ${registered.message}`);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
return unavailable(err);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function referenceFrom(view) {
|
|
118
|
+
return {
|
|
119
|
+
attempt_id: view.attempt_id,
|
|
120
|
+
repo_name: view.repo_name,
|
|
121
|
+
ticket_number: view.ticket_number,
|
|
122
|
+
source_branch: view.source_branch,
|
|
123
|
+
base_branch: view.base_branch,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Send every pending batch, oldest first. Never throws.
|
|
128
|
+
*
|
|
129
|
+
* The pass stops at the first batch the server did not store, so a later batch
|
|
130
|
+
* never overtakes an earlier one. Stored outcomes, including deterministic
|
|
131
|
+
* refusals, are receipted and leave the pending list.
|
|
132
|
+
*/
|
|
133
|
+
export async function syncPendingSubmissions(deps, ticketKey, binding) {
|
|
134
|
+
const store = storeOf(deps);
|
|
135
|
+
const reports = [];
|
|
136
|
+
const errors = [];
|
|
137
|
+
let readiness = null;
|
|
138
|
+
let pending = [];
|
|
139
|
+
try {
|
|
140
|
+
pending = await store.listPendingSubmissions(deps.docsDir, ticketKey);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
errors.push(sanitizeDeliveryMessage(err));
|
|
144
|
+
return { reports, readiness, pending: 0, errors };
|
|
145
|
+
}
|
|
146
|
+
let remaining = pending.length;
|
|
147
|
+
for (const entry of pending) {
|
|
148
|
+
const response = await submitEvidence(deps.client, binding.attempt_id, {
|
|
149
|
+
...entry.payload,
|
|
150
|
+
repo_name: deps.repoName,
|
|
151
|
+
submission_id: entry.submission_id,
|
|
152
|
+
});
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
const error = `${response.error}: ${response.message}`;
|
|
155
|
+
errors.push(error);
|
|
156
|
+
reports.push({
|
|
157
|
+
submission_id: entry.submission_id,
|
|
158
|
+
phase: entry.phase,
|
|
159
|
+
operation_status: null,
|
|
160
|
+
retryable: true,
|
|
161
|
+
refusals: [],
|
|
162
|
+
error,
|
|
163
|
+
});
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
const body = response.body;
|
|
167
|
+
if (body.readiness)
|
|
168
|
+
readiness = body.readiness;
|
|
169
|
+
const report = {
|
|
170
|
+
submission_id: entry.submission_id,
|
|
171
|
+
phase: entry.phase,
|
|
172
|
+
operation_status: body.operation_status,
|
|
173
|
+
retryable: body.retryable === true,
|
|
174
|
+
refusals: Array.isArray(body.refusals) ? body.refusals : [],
|
|
175
|
+
...(body.recovery_guidance ? { recovery_guidance: body.recovery_guidance } : {}),
|
|
176
|
+
};
|
|
177
|
+
reports.push(report);
|
|
178
|
+
if (report.retryable)
|
|
179
|
+
break;
|
|
180
|
+
try {
|
|
181
|
+
await store.recordSubmissionReceipt(deps.docsDir, ticketKey, {
|
|
182
|
+
submission_id: entry.submission_id,
|
|
183
|
+
operation_status: body.operation_status,
|
|
184
|
+
accepted_evidence_revision: typeof body.accepted_evidence_revision === "number" ? body.accepted_evidence_revision : null,
|
|
185
|
+
});
|
|
186
|
+
remaining -= 1;
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
// The server stored it; a replay of the same id is idempotent, so keeping
|
|
190
|
+
// the entry pending is safe. Report it and stop.
|
|
191
|
+
errors.push(sanitizeDeliveryMessage(err));
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { reports, readiness, pending: remaining, errors };
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Queue one batch and then run a sync pass. The local phase artifact must already
|
|
199
|
+
* be written. Returns the queued entry and the pass result; never throws for a
|
|
200
|
+
* server problem (a journal write failure does throw, since local state is then
|
|
201
|
+
* unknown).
|
|
202
|
+
*/
|
|
203
|
+
export async function enqueueAndSync(deps, ticketKey, binding, input) {
|
|
204
|
+
const submission = await storeOf(deps).enqueuePendingSubmission(deps.docsDir, ticketKey, input);
|
|
205
|
+
if (!binding)
|
|
206
|
+
return { submission, sync: null };
|
|
207
|
+
return { submission, sync: await syncPendingSubmissions(deps, ticketKey, binding) };
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Queue the latest local record per step for every phase that already has local
|
|
211
|
+
* records. Used once, right after a new attempt is registered for a ticket that
|
|
212
|
+
* already did work, so the server can reconcile that work.
|
|
213
|
+
*/
|
|
214
|
+
export async function enqueueExistingLocalRecords(deps, ticketKey, ledger) {
|
|
215
|
+
let queued = 0;
|
|
216
|
+
for (const phase of PLAN_PHASES) {
|
|
217
|
+
const latest = new Map();
|
|
218
|
+
for (const record of ledger) {
|
|
219
|
+
if (record.phase === phase)
|
|
220
|
+
latest.set(record.stepId, record);
|
|
221
|
+
}
|
|
222
|
+
if (latest.size === 0)
|
|
223
|
+
continue;
|
|
224
|
+
const records = [...latest.values()].sort((a, b) => a.stepId - b.stepId);
|
|
225
|
+
await storeOf(deps).enqueuePendingSubmission(deps.docsDir, ticketKey, {
|
|
226
|
+
phase,
|
|
227
|
+
payload: { phase_result: { version: 1, phase, records } },
|
|
228
|
+
});
|
|
229
|
+
queued += 1;
|
|
230
|
+
}
|
|
231
|
+
return queued;
|
|
232
|
+
}
|
|
233
|
+
const DISPOSITIONS = new Set(["executed", "adapted", "escalated", "unrun-advisory"]);
|
|
234
|
+
const PASS_THROUGH_FIELDS = [
|
|
235
|
+
"plan_digest",
|
|
236
|
+
"specification_identity",
|
|
237
|
+
"verification_failed",
|
|
238
|
+
"invalidated_by",
|
|
239
|
+
// BAPI-1492: the accepted verification hand-off, kept exactly as the server stored it.
|
|
240
|
+
"handoff",
|
|
241
|
+
];
|
|
242
|
+
/** The structured fields the server reported on a record, copied only when present. */
|
|
243
|
+
function passThroughFields(record) {
|
|
244
|
+
const out = {};
|
|
245
|
+
for (const field of PASS_THROUGH_FIELDS) {
|
|
246
|
+
if (Object.prototype.hasOwnProperty.call(record, field))
|
|
247
|
+
out[field] = record[field];
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
/** Keep only well-formed server records; the ledger module decides what they mean. */
|
|
252
|
+
export function toAcceptedLedgerRecords(raw) {
|
|
253
|
+
const out = [];
|
|
254
|
+
for (const record of raw ?? []) {
|
|
255
|
+
if (!record || typeof record !== "object")
|
|
256
|
+
continue;
|
|
257
|
+
const stepId = record.step_id;
|
|
258
|
+
const phase = record.phase;
|
|
259
|
+
const disposition = record.disposition;
|
|
260
|
+
if (typeof stepId !== "number" || !Number.isInteger(stepId) || stepId < 1)
|
|
261
|
+
continue;
|
|
262
|
+
if (typeof phase !== "string" || !PLAN_PHASES.includes(phase))
|
|
263
|
+
continue;
|
|
264
|
+
if (typeof disposition !== "string" || !DISPOSITIONS.has(disposition))
|
|
265
|
+
continue;
|
|
266
|
+
out.push({
|
|
267
|
+
step_id: stepId,
|
|
268
|
+
phase: phase,
|
|
269
|
+
disposition: disposition,
|
|
270
|
+
pr_dependency: typeof record.pr_dependency === "string"
|
|
271
|
+
? record.pr_dependency
|
|
272
|
+
: null,
|
|
273
|
+
...(typeof record.revision === "number" ? { revision: record.revision } : {}),
|
|
274
|
+
// BAPI-1488: pass the identity and failure facts through exactly as reported.
|
|
275
|
+
// A missing field stays missing and a malformed one stays malformed, so the
|
|
276
|
+
// ledger's carry-forward predicates can reject it; nothing is defaulted clean.
|
|
277
|
+
...passThroughFields(record),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
/** Fetch the server-accepted ledger for a binding. Never throws. */
|
|
283
|
+
export async function fetchAcceptedLedgerContext(deps, binding) {
|
|
284
|
+
const response = await getAcceptedLedger(deps.client, binding.attempt_id, {
|
|
285
|
+
repo_name: deps.repoName,
|
|
286
|
+
});
|
|
287
|
+
if (!response.ok)
|
|
288
|
+
return { ok: false, error: `${response.error}: ${response.message}` };
|
|
289
|
+
const body = response.body;
|
|
290
|
+
return {
|
|
291
|
+
ok: true,
|
|
292
|
+
context: {
|
|
293
|
+
...(body.verification_context ? { verificationContext: body.verification_context } : {}),
|
|
294
|
+
acceptedRecords: toAcceptedLedgerRecords(Array.isArray(body.accepted_records) ? body.accepted_records : []),
|
|
295
|
+
},
|
|
296
|
+
postPrOwnedSteps: Array.isArray(body.post_pr_owned_steps)
|
|
297
|
+
? body.post_pr_owned_steps.filter((step) => Number.isInteger(step))
|
|
298
|
+
: [],
|
|
299
|
+
evidenceRevision: typeof body.evidence_revision === "number" ? body.evidence_revision : 0,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/** A git SHA the server accepts (40-char lowercase hex), else undefined. */
|
|
303
|
+
export function serverShaOrUndefined(value) {
|
|
304
|
+
const sha = value?.trim().toLowerCase();
|
|
305
|
+
return sha && /^[0-9a-f]{40}$/.test(sha) ? sha : undefined;
|
|
306
|
+
}
|