@bridge_gpt/mcp-server 0.2.36 → 0.2.37
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 +44 -6
- package/build/base-url.js +79 -0
- package/build/bridge-api-urls.js +9 -0
- package/build/chain-orchestrator.js +93 -15
- package/build/claude-user-config-doctor.js +317 -0
- package/build/commands.generated.js +2 -1
- package/build/conductor/bridge-api-client.js +178 -4
- package/build/conductor-bin.js +1 -1
- package/build/conductor-bundle-artifacts.js +7 -6
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +239 -80
- package/build/executor/cli.js +51 -1
- package/build/executor/credentials.js +1 -7
- package/build/executor/deps.js +18 -1
- package/build/executor/env.js +51 -25
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +422 -22
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +104 -8
- package/build/executor/preflight.js +32 -0
- package/build/executor/runner.js +8 -0
- package/build/executor/test-clock.js +67 -3
- package/build/executor/types.js +4 -1
- package/build/executor/worker-command.js +11 -3
- package/build/executor/worker-config-isolation.js +287 -0
- package/build/executor/worker-finalization.js +68 -14
- package/build/executor/worktree.js +46 -4
- package/build/index.js +614 -244
- package/build/init.js +363 -73
- package/build/install-bridge.js +568 -80
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +30 -7
- package/build/mcp-registration-doctor.js +14 -5
- package/build/notifications.js +553 -0
- package/build/pipeline-orchestrator.js +146 -4
- package/build/pipeline-utils.js +3 -0
- package/build/pipelines.generated.js +22 -9
- package/build/plan-execution-ledger.js +550 -0
- package/build/plan-phase-routing.js +272 -0
- package/build/plane/alembic-head.js +110 -0
- package/build/plane/build-freshness.js +167 -0
- package/build/plane/cli.js +480 -0
- package/build/plane/defaults.js +266 -0
- package/build/plane/manifest.js +377 -0
- package/build/plane/member-logs.js +147 -0
- package/build/plane/member-roster.js +147 -0
- package/build/plane/preflight.js +289 -0
- package/build/plane/shutdown.js +195 -0
- package/build/plane/status.js +125 -0
- package/build/plane/supervisor.js +569 -0
- package/build/plane/test-fakes.js +156 -0
- package/build/plane/types.js +75 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +2 -0
- package/build/setup-epic.js +662 -27
- package/build/sfcc/log-gate.js +38 -11
- package/build/sfcc/log-query.js +55 -15
- package/build/sfcc/ocapi-shape.js +51 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-projection.js +181 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +29 -18
- package/build/sfcc/reads-site-preference.js +75 -29
- package/build/sfcc/reads-system-object.js +40 -34
- package/build/sfcc/sfcc-result.js +106 -0
- package/build/sfcc/tool-wrapper.js +56 -13
- package/build/sfcc/write-grants.js +45 -22
- package/build/sfcc/write-guard.js +21 -13
- package/build/sfcc/write-result.js +61 -14
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-system-object.js +11 -50
- package/build/start-tickets-prereqs.js +129 -0
- package/build/start-tickets.js +17 -13
- package/build/ticket-backend-metadata.js +59 -0
- package/build/ticket-key-utils.js +92 -0
- package/build/tool-error-envelope.js +71 -0
- package/build/tool-surface-gating.js +72 -0
- package/build/update-status.js +102 -0
- package/build/upgrade-advice.js +47 -0
- package/build/upgrade-cli.js +417 -101
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +23 -8
- package/package.json +3 -3
- package/pipelines/implement-ticket.json +15 -5
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BAPI-763 — the orchestration seam between a pipeline recipe and the plan ledger.
|
|
3
|
+
*
|
|
4
|
+
* {@link ./plan-execution-ledger.js} holds the domain rules. This module is the thin
|
|
5
|
+
* layer that connects them to a running pipeline: which recipe steps are routed
|
|
6
|
+
* phases, how plan metadata is recovered from the plan-generation step's result, what
|
|
7
|
+
* routing context is injected into a paused agent task, and how a returned phase
|
|
8
|
+
* result is parsed and merged.
|
|
9
|
+
*
|
|
10
|
+
* Two constraints shape it.
|
|
11
|
+
*
|
|
12
|
+
* First, an `agent_task` is not executed by the orchestrator — the recipe pauses, the
|
|
13
|
+
* calling agent does the work, and it resumes with free-form `agent_result` text. So
|
|
14
|
+
* the phase-result envelope has to survive a round trip through prose, which is why
|
|
15
|
+
* it is a fenced JSON block that {@link parsePhaseResultEnvelope} extracts rather
|
|
16
|
+
* than a typed return value.
|
|
17
|
+
*
|
|
18
|
+
* Second, this contract is new and only three instruction files implement it. Every
|
|
19
|
+
* other pipeline (review-ticket, full-automation, plan-epic) still returns ordinary
|
|
20
|
+
* prose. Routing therefore applies only to {@link ROUTED_PHASE_INSTRUCTIONS}; an
|
|
21
|
+
* unrouted step is untouched.
|
|
22
|
+
*
|
|
23
|
+
* Pure: no I/O, no transport. The orchestrator supplies the persisted state.
|
|
24
|
+
*/
|
|
25
|
+
import { PlanLedgerError, assertStepClassCoverage, mergePlanStepRecords, resolveOwnedParts, resolveOwnedSteps, sanitizePlanStepRecords, validatePhaseResult, validatePlanMetadata, } from "./plan-execution-ledger.js";
|
|
26
|
+
/** Instruction file -> the phase whose steps it must settle. */
|
|
27
|
+
export const ROUTED_PHASE_INSTRUCTIONS = Object.freeze({
|
|
28
|
+
"execute-plan.md": "produce",
|
|
29
|
+
"execute-plan-verification.md": "pre_pr_verification",
|
|
30
|
+
"verify-plan.md": "post_pr_gap_close",
|
|
31
|
+
});
|
|
32
|
+
export function phaseForInstructionFile(instructionFile) {
|
|
33
|
+
if (!instructionFile)
|
|
34
|
+
return undefined;
|
|
35
|
+
return ROUTED_PHASE_INSTRUCTIONS[instructionFile];
|
|
36
|
+
}
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Recovering plan metadata from the plan-generation step
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
/**
|
|
41
|
+
* Pull the provenance envelope out of whatever `request_plan_generation` returned.
|
|
42
|
+
*
|
|
43
|
+
* The tool result reaches the orchestrator as MCP content, so the metadata may be an
|
|
44
|
+
* object already or JSON embedded in a text block. Returns `undefined` when there is
|
|
45
|
+
* nothing to find; the caller decides whether that is fatal.
|
|
46
|
+
*/
|
|
47
|
+
export function extractPlanMetadata(result) {
|
|
48
|
+
const candidate = findPlanMetadataCandidate(result, 0);
|
|
49
|
+
if (candidate === undefined)
|
|
50
|
+
return undefined;
|
|
51
|
+
return validatePlanMetadata(candidate);
|
|
52
|
+
}
|
|
53
|
+
function findPlanMetadataCandidate(value, depth) {
|
|
54
|
+
if (depth > 6 || value === null || value === undefined)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (typeof value === "string") {
|
|
57
|
+
const trimmed = value.trim();
|
|
58
|
+
if (!trimmed.startsWith("{") && !trimmed.includes("plan_metadata"))
|
|
59
|
+
return undefined;
|
|
60
|
+
try {
|
|
61
|
+
return findPlanMetadataCandidate(JSON.parse(trimmed), depth + 1);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(value)) {
|
|
68
|
+
for (const entry of value) {
|
|
69
|
+
const found = findPlanMetadataCandidate(entry, depth + 1);
|
|
70
|
+
if (found !== undefined)
|
|
71
|
+
return found;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
if (typeof value === "object") {
|
|
76
|
+
const record = value;
|
|
77
|
+
if (record.plan_metadata !== undefined && record.plan_metadata !== null) {
|
|
78
|
+
return record.plan_metadata;
|
|
79
|
+
}
|
|
80
|
+
// An already-unwrapped envelope.
|
|
81
|
+
if (record.version !== undefined && Array.isArray(record.parts)) {
|
|
82
|
+
return record;
|
|
83
|
+
}
|
|
84
|
+
for (const entry of Object.values(record)) {
|
|
85
|
+
const found = findPlanMetadataCandidate(entry, depth + 1);
|
|
86
|
+
if (found !== undefined)
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
export function buildPhaseRoutingContext(metadata, phase, ledger, checkpoint) {
|
|
93
|
+
return {
|
|
94
|
+
phase,
|
|
95
|
+
ownedSteps: resolveOwnedSteps(metadata, phase),
|
|
96
|
+
ownedRanges: resolveOwnedParts(metadata, phase).map((part) => ({
|
|
97
|
+
partId: part.part_id,
|
|
98
|
+
provenanceClass: part.provenance_class,
|
|
99
|
+
startStep: part.start_step,
|
|
100
|
+
endStep: part.end_step,
|
|
101
|
+
declaredAdvisory: part.declared_advisory,
|
|
102
|
+
})),
|
|
103
|
+
ledger: sanitizePlanStepRecords(ledger),
|
|
104
|
+
...(checkpoint ? { checkpoint } : {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Append the routing context to the instruction text a paused phase receives.
|
|
109
|
+
*
|
|
110
|
+
* Injected through the instruction rather than left to conversation on purpose: the
|
|
111
|
+
* durable record has to survive an approval pause, a compaction, and a resumed
|
|
112
|
+
* session, and the conversational hand-off it replaces survived none of those.
|
|
113
|
+
*/
|
|
114
|
+
export function injectPhaseRoutingContext(instruction, context) {
|
|
115
|
+
const block = JSON.stringify(context, null, 2);
|
|
116
|
+
return (`${instruction}\n\n` +
|
|
117
|
+
`---\n\n` +
|
|
118
|
+
`## Routed phase context (BAPI-763 — orchestration-supplied, authoritative)\n\n` +
|
|
119
|
+
`You are executing the \`${context.phase}\` phase. The block below is the plan's ` +
|
|
120
|
+
`provenance routing and the durable ledger as it stands right now. Settle every ` +
|
|
121
|
+
`step in \`ownedSteps\` — each must end as \`executed\`, \`adapted\`, \`escalated\`, ` +
|
|
122
|
+
`or \`unrun-advisory\`. Steps outside \`ownedSteps\` belong to another phase.\n\n` +
|
|
123
|
+
`Entries already present in \`ledger\` are done; do not redo them.\n\n` +
|
|
124
|
+
"```json\n" +
|
|
125
|
+
`${block}\n` +
|
|
126
|
+
"```\n\n" +
|
|
127
|
+
`When the phase finishes, end your result with a fenced \`json\` block tagged ` +
|
|
128
|
+
`\`bapi-phase-result\` in this shape:\n\n` +
|
|
129
|
+
"```bapi-phase-result\n" +
|
|
130
|
+
`{"version":1,"phase":"${context.phase}","records":[{"stepId":<n>,"provenanceClass":"<class>","disposition":"executed","evidence":"<what ran and what was observed>"}]}\n` +
|
|
131
|
+
"```\n");
|
|
132
|
+
}
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Parsing the returned phase result
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
const PHASE_RESULT_FENCE = /```(?:bapi-phase-result|json)\s*\n([\s\S]*?)\n?```/g;
|
|
137
|
+
/**
|
|
138
|
+
* Extract the phase-result envelope from an agent's free-form result text.
|
|
139
|
+
*
|
|
140
|
+
* Scans every candidate fence and takes the last one that parses into an envelope
|
|
141
|
+
* shape, so an instruction's own example block earlier in the text cannot be mistaken
|
|
142
|
+
* for the real result.
|
|
143
|
+
*/
|
|
144
|
+
export function parsePhaseResultEnvelope(agentResult) {
|
|
145
|
+
if (!agentResult)
|
|
146
|
+
return undefined;
|
|
147
|
+
let found;
|
|
148
|
+
PHASE_RESULT_FENCE.lastIndex = 0;
|
|
149
|
+
let match;
|
|
150
|
+
while ((match = PHASE_RESULT_FENCE.exec(agentResult)) !== null) {
|
|
151
|
+
try {
|
|
152
|
+
const parsed = JSON.parse(match[1]);
|
|
153
|
+
if (parsed && Array.isArray(parsed.records) && parsed.phase !== undefined) {
|
|
154
|
+
found = parsed;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Not an envelope; keep scanning.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return found;
|
|
162
|
+
}
|
|
163
|
+
const CHECKPOINT_FENCE = /```(?:bapi-checkpoint|json)\s*\n([\s\S]*?)\n?```/g;
|
|
164
|
+
/**
|
|
165
|
+
* Extract the checkpoint result `checkpoint-work.md` returns.
|
|
166
|
+
*
|
|
167
|
+
* Only a checkpoint that reports its work durable on origin is accepted: a block
|
|
168
|
+
* claiming `pushed: false` or `remoteMatchesHead: false` describes a checkpoint that
|
|
169
|
+
* did not achieve the one thing it exists for, so treating it as one would defeat
|
|
170
|
+
* the guarantee. Returns undefined when no usable block is present, which the caller
|
|
171
|
+
* distinguishes from a durable one.
|
|
172
|
+
*/
|
|
173
|
+
export function parseCheckpointResult(agentResult) {
|
|
174
|
+
if (!agentResult)
|
|
175
|
+
return undefined;
|
|
176
|
+
let found;
|
|
177
|
+
CHECKPOINT_FENCE.lastIndex = 0;
|
|
178
|
+
let match;
|
|
179
|
+
while ((match = CHECKPOINT_FENCE.exec(agentResult)) !== null) {
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(match[1]);
|
|
182
|
+
if (typeof parsed.branch === "string" &&
|
|
183
|
+
parsed.branch.trim() !== "" &&
|
|
184
|
+
typeof parsed.sha === "string" &&
|
|
185
|
+
parsed.sha.trim() !== "" &&
|
|
186
|
+
parsed.pushed === true &&
|
|
187
|
+
parsed.remoteMatchesHead === true) {
|
|
188
|
+
found = {
|
|
189
|
+
branch: parsed.branch,
|
|
190
|
+
sha: parsed.sha,
|
|
191
|
+
pushed: true,
|
|
192
|
+
remoteMatchesHead: true,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// Not a checkpoint block; keep scanning.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return found;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Settle a routed phase: parse, validate, and merge its result into the ledger.
|
|
204
|
+
*
|
|
205
|
+
* The two failure modes are treated differently, deliberately.
|
|
206
|
+
*
|
|
207
|
+
* A **malformed or incomplete envelope** is a contract violation by a phase that
|
|
208
|
+
* tried to report — it is flagged with `contractViolation`, and the orchestrator
|
|
209
|
+
* refuses to advance on it. The phase made a claim that does not hold, and advancing
|
|
210
|
+
* would record that claim as the durable truth.
|
|
211
|
+
*
|
|
212
|
+
* A **missing envelope** records an escalation for every owned step instead. This
|
|
213
|
+
* path exists because only three instruction files implement the contract and an
|
|
214
|
+
* agent's formatting can drift; turning that into a hard stop would wedge the
|
|
215
|
+
* pipeline. Crucially it is not silent — each owned step lands in the durable record
|
|
216
|
+
* as `escalated` with the reason, which is what surfaces as unresolved. The failure
|
|
217
|
+
* this ticket exists to fix was work vanishing with no trace, not work being flagged.
|
|
218
|
+
*/
|
|
219
|
+
export function settlePhaseResult(agentResult, metadata, phase, existingLedger) {
|
|
220
|
+
const envelope = parsePhaseResultEnvelope(agentResult);
|
|
221
|
+
if (!envelope) {
|
|
222
|
+
const owned = resolveOwnedSteps(metadata, phase);
|
|
223
|
+
if (owned.length === 0) {
|
|
224
|
+
return { ledger: [...existingLedger], contractViolation: false };
|
|
225
|
+
}
|
|
226
|
+
const reason = `phase '${phase}' returned no machine-readable result envelope, so the outcome of ` +
|
|
227
|
+
`its owned steps is unknown and is recorded as escalated rather than assumed complete`;
|
|
228
|
+
const records = owned.map((stepId) => ({
|
|
229
|
+
stepId,
|
|
230
|
+
provenanceClass: classForStep(metadata, stepId),
|
|
231
|
+
phase,
|
|
232
|
+
disposition: "escalated",
|
|
233
|
+
escalationReason: reason,
|
|
234
|
+
}));
|
|
235
|
+
return {
|
|
236
|
+
ledger: mergePlanStepRecords(existingLedger, records),
|
|
237
|
+
diagnostic: reason,
|
|
238
|
+
contractViolation: false,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const validated = validatePhaseResult(envelope, metadata, phase);
|
|
243
|
+
return {
|
|
244
|
+
ledger: mergePlanStepRecords(existingLedger, sanitizePlanStepRecords(validated)),
|
|
245
|
+
contractViolation: false,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
const message = err instanceof PlanLedgerError ? err.message : String(err);
|
|
250
|
+
return {
|
|
251
|
+
ledger: [...existingLedger],
|
|
252
|
+
diagnostic: `phase '${phase}' returned an invalid result envelope: ${message}`,
|
|
253
|
+
contractViolation: true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function classForStep(metadata, stepId) {
|
|
258
|
+
const part = metadata.parts.find((entry) => stepId >= entry.start_step && stepId <= entry.end_step);
|
|
259
|
+
// resolveOwnedSteps only emits step IDs that came from a part, so the fallback is
|
|
260
|
+
// unreachable in practice; it exists so a future caller cannot crash here.
|
|
261
|
+
return part ? part.provenance_class : "implementation";
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Validate metadata and prove complete class coverage before any phase runs.
|
|
265
|
+
*
|
|
266
|
+
* Deliberately called before the first routed phase, not lazily at each one: the
|
|
267
|
+
* point is to discover an unowned step class while nothing has been produced yet,
|
|
268
|
+
* rather than after a pull request is already open.
|
|
269
|
+
*/
|
|
270
|
+
export function assertPlanIsRoutable(metadata) {
|
|
271
|
+
assertStepClassCoverage(metadata);
|
|
272
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alembic head preflight check (BAPI-756).
|
|
3
|
+
*
|
|
4
|
+
* A database behind the migration head blocks conductor dispatch
|
|
5
|
+
* (`CONTRACT_MIGRATION_BEHIND`), so catching it before anything spawns saves an
|
|
6
|
+
* operator a confusing run that starts cleanly and then refuses every job.
|
|
7
|
+
*
|
|
8
|
+
* This check is deliberately asymmetric: it **blocks only on a confirmed
|
|
9
|
+
* mismatch** — both commands ran, both parsed, and the revision sets differ.
|
|
10
|
+
* Every other outcome (no repository virtualenv, a command failure, output it
|
|
11
|
+
* cannot parse) is a named "could not verify" warning that lets startup
|
|
12
|
+
* proceed. A false refusal here is worse than a skipped check, because the
|
|
13
|
+
* condition it guards is already reported loudly by the server at dispatch time.
|
|
14
|
+
*/
|
|
15
|
+
import path from "path";
|
|
16
|
+
/** Remediation embedded in a confirmed-mismatch diagnostic. */
|
|
17
|
+
export const PLANE_ALEMBIC_REMEDIATION = "alembic -c alembic.ini upgrade head";
|
|
18
|
+
/** Fixed prefix of the fail-open warning, so tests and greps can match it. */
|
|
19
|
+
export const PLANE_ALEMBIC_UNVERIFIED_PREFIX = "could not verify database migration head";
|
|
20
|
+
/**
|
|
21
|
+
* Path to the repository virtualenv's Alembic executable.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately NOT a bare `alembic` from `PATH`: the repository venv is the one
|
|
24
|
+
* whose installed migrations and driver match `alembic.ini`, and an unrelated
|
|
25
|
+
* global Alembic would answer a different question.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveRepositoryAlembicCommand(repoRoot, platform) {
|
|
28
|
+
return platform === "win32"
|
|
29
|
+
? path.join(repoRoot, ".venv", "Scripts", "alembic.exe")
|
|
30
|
+
: path.join(repoRoot, ".venv", "bin", "alembic");
|
|
31
|
+
}
|
|
32
|
+
// Log lines Alembic writes alongside its answer. Skipped before token parsing
|
|
33
|
+
// so a logging configuration change cannot be mistaken for a revision id.
|
|
34
|
+
const LOG_LINE_PATTERN = /^(INFO|WARNING|ERROR|DEBUG|CRITICAL)\b|\[alembic/i;
|
|
35
|
+
// This repository's revision ids are slugged (`0004_add_error_count`), not the
|
|
36
|
+
// hex default, so the pattern must accept letters, digits, `_`, `-`, and `.`.
|
|
37
|
+
const REVISION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z_.-]*$/;
|
|
38
|
+
// Tokens Alembic prints that are annotations or sentinels rather than revisions.
|
|
39
|
+
const NON_REVISION_TOKENS = new Set(["head", "heads", "base", "none", "(head)"]);
|
|
40
|
+
/**
|
|
41
|
+
* Parse `alembic heads` / `alembic current` output into a normalized revision
|
|
42
|
+
* set.
|
|
43
|
+
*
|
|
44
|
+
* Returns `null` when no revision token is found — including for empty output,
|
|
45
|
+
* which is what `current` prints for a database stamped at base. The caller
|
|
46
|
+
* turns `null` into the fail-open warning rather than a mismatch, per the
|
|
47
|
+
* ratified "never refuse on an unconfirmed reading" policy.
|
|
48
|
+
*/
|
|
49
|
+
export function parseAlembicRevisions(output) {
|
|
50
|
+
const revisions = new Set();
|
|
51
|
+
for (const rawLine of output.split(/\r?\n/)) {
|
|
52
|
+
const line = rawLine.trim();
|
|
53
|
+
if (line.length === 0)
|
|
54
|
+
continue;
|
|
55
|
+
if (LOG_LINE_PATTERN.test(line))
|
|
56
|
+
continue;
|
|
57
|
+
const token = line.split(/\s+/)[0];
|
|
58
|
+
if (!token || NON_REVISION_TOKENS.has(token.toLowerCase()))
|
|
59
|
+
continue;
|
|
60
|
+
if (!REVISION_PATTERN.test(token))
|
|
61
|
+
continue;
|
|
62
|
+
revisions.add(token);
|
|
63
|
+
}
|
|
64
|
+
if (revisions.size === 0)
|
|
65
|
+
return null;
|
|
66
|
+
return [...revisions].sort();
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Compare the repository's migration heads with the database's current
|
|
70
|
+
* revisions. Blocks only on a confirmed mismatch; warns on anything unverifiable.
|
|
71
|
+
*/
|
|
72
|
+
export async function checkAlembicHead(repoRoot, deps) {
|
|
73
|
+
const alembic = resolveRepositoryAlembicCommand(repoRoot, deps.platform);
|
|
74
|
+
if (!(await deps.fileExists(alembic))) {
|
|
75
|
+
return unverified("the repository virtualenv Alembic executable was not found");
|
|
76
|
+
}
|
|
77
|
+
const heads = await deps.execFile(alembic, ["-c", "alembic.ini", "heads"], { cwd: repoRoot });
|
|
78
|
+
if (!heads.ok) {
|
|
79
|
+
return unverified(`\`alembic heads\` did not complete (${heads.error})`);
|
|
80
|
+
}
|
|
81
|
+
const current = await deps.execFile(alembic, ["-c", "alembic.ini", "current"], { cwd: repoRoot });
|
|
82
|
+
if (!current.ok) {
|
|
83
|
+
return unverified(`\`alembic current\` did not complete (${current.error})`);
|
|
84
|
+
}
|
|
85
|
+
const headRevisions = parseAlembicRevisions(heads.stdout);
|
|
86
|
+
const currentRevisions = parseAlembicRevisions(current.stdout);
|
|
87
|
+
if (headRevisions === null || currentRevisions === null) {
|
|
88
|
+
return unverified("Alembic output did not contain a readable revision");
|
|
89
|
+
}
|
|
90
|
+
const matches = headRevisions.length === currentRevisions.length &&
|
|
91
|
+
headRevisions.every((rev, i) => rev === currentRevisions[i]);
|
|
92
|
+
if (matches)
|
|
93
|
+
return null;
|
|
94
|
+
return {
|
|
95
|
+
check: "alembic-head",
|
|
96
|
+
severity: "blocking",
|
|
97
|
+
message: `the database is not at the migration head — heads [${headRevisions.join(", ")}], ` +
|
|
98
|
+
`current [${currentRevisions.join(", ")}]. A database behind the head blocks ` +
|
|
99
|
+
`conductor dispatch. Run \`${PLANE_ALEMBIC_REMEDIATION}\``,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function unverified(detail) {
|
|
103
|
+
return {
|
|
104
|
+
check: "alembic-head",
|
|
105
|
+
severity: "warning",
|
|
106
|
+
message: `${PLANE_ALEMBIC_UNVERIFIED_PREFIX}: ${detail}. Startup continues — verify with ` +
|
|
107
|
+
`\`alembic -c alembic.ini heads\` and \`alembic -c alembic.ini current\` if a run ` +
|
|
108
|
+
"later reports CONTRACT_MIGRATION_BEHIND.",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor build-freshness preflight check (BAPI-756).
|
|
3
|
+
*
|
|
4
|
+
* A stale `mcp_server/build/` is one of the quietest traps in an attended run:
|
|
5
|
+
* the executor starts, reports healthy, and runs yesterday's code. Comparing
|
|
6
|
+
* the newest `src/**` modification time against the build's own timestamp turns
|
|
7
|
+
* that silent wrong-code condition into a refusal naming `npm run build`.
|
|
8
|
+
*
|
|
9
|
+
* The authoritative build timestamp is `build/index.js`, not the directory.
|
|
10
|
+
* `npm run build` runs tsc, then re-bundles the bin entry points with esbuild,
|
|
11
|
+
* then prepends shebangs — so `build/index.js` is written at the END of the
|
|
12
|
+
* pipeline, while the directory's mtime only tracks direct child creation and
|
|
13
|
+
* can be older than a rebuild that overwrote files in place.
|
|
14
|
+
*/
|
|
15
|
+
import path from "path";
|
|
16
|
+
/** Remediation text embedded in every blocking build diagnostic. */
|
|
17
|
+
export const PLANE_BUILD_REMEDIATION = "cd mcp_server && npm run build";
|
|
18
|
+
/**
|
|
19
|
+
* Source subtrees and files excluded from the freshness comparison.
|
|
20
|
+
*
|
|
21
|
+
* `*.generated.ts` files are written by the bundle scripts during the SAME
|
|
22
|
+
* `npm run build` that produces `build/index.js`, so they are always newer than
|
|
23
|
+
* a tsc run that preceded them and would stale every build immediately.
|
|
24
|
+
*/
|
|
25
|
+
const EXCLUDED_DIR_NAMES = new Set(["node_modules", "build"]);
|
|
26
|
+
function isRelevantSourceFile(name) {
|
|
27
|
+
if (!name.endsWith(".ts"))
|
|
28
|
+
return false;
|
|
29
|
+
if (name.endsWith(".generated.ts"))
|
|
30
|
+
return false;
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Newest modification time across the compiled package's TypeScript sources.
|
|
35
|
+
*
|
|
36
|
+
* Returns `null` when the tree contains no relevant source file at all, which
|
|
37
|
+
* the caller treats as "nothing to be stale against" rather than as an error.
|
|
38
|
+
*/
|
|
39
|
+
export async function findNewestSourceMtime(srcDir, deps) {
|
|
40
|
+
let newest = null;
|
|
41
|
+
const walk = async (dir) => {
|
|
42
|
+
let entries;
|
|
43
|
+
try {
|
|
44
|
+
entries = await deps.fs.readdir(dir);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
return sanitize(err);
|
|
48
|
+
}
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
if (EXCLUDED_DIR_NAMES.has(entry.name))
|
|
52
|
+
continue;
|
|
53
|
+
const error = await walk(path.join(dir, entry.name));
|
|
54
|
+
if (error)
|
|
55
|
+
return error;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (!entry.isFile() || !isRelevantSourceFile(entry.name))
|
|
59
|
+
continue;
|
|
60
|
+
try {
|
|
61
|
+
const stat = await deps.fs.stat(path.join(dir, entry.name));
|
|
62
|
+
if (newest === null || stat.mtimeMs > newest)
|
|
63
|
+
newest = stat.mtimeMs;
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
return sanitize(err);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
};
|
|
71
|
+
const error = await walk(srcDir);
|
|
72
|
+
if (error)
|
|
73
|
+
return { ok: false, error };
|
|
74
|
+
return { ok: true, mtimeMs: newest };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The authoritative build timestamp: the mtime of the compiled entrypoint the
|
|
78
|
+
* executor members are actually launched from.
|
|
79
|
+
*/
|
|
80
|
+
export async function findBuildMtime(executorEntrypoint, deps) {
|
|
81
|
+
try {
|
|
82
|
+
const stat = await deps.fs.stat(executorEntrypoint);
|
|
83
|
+
return { ok: true, mtimeMs: stat.mtimeMs };
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
return { ok: false, error: sanitize(err) };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Verify the executor build exists and is not older than its sources.
|
|
91
|
+
*
|
|
92
|
+
* Returns a blocking diagnostic (never a warning): running an executor from a
|
|
93
|
+
* stale or absent build is exactly the failure this check exists to prevent, so
|
|
94
|
+
* unlike the Alembic check there is no fail-open branch.
|
|
95
|
+
*/
|
|
96
|
+
export async function checkPlaneBuildFreshness(repoRoot, deps) {
|
|
97
|
+
const packageRoot = path.join(repoRoot, "mcp_server");
|
|
98
|
+
const buildDir = path.join(packageRoot, "build");
|
|
99
|
+
const executorEntrypoint = path.join(buildDir, "index.js");
|
|
100
|
+
const srcDir = path.join(packageRoot, "src");
|
|
101
|
+
const buildMtime = await findBuildMtime(executorEntrypoint, deps);
|
|
102
|
+
if (!buildMtime.ok) {
|
|
103
|
+
return {
|
|
104
|
+
check: "executor-build",
|
|
105
|
+
severity: "blocking",
|
|
106
|
+
message: `the executor build entrypoint is missing or unreadable at ` +
|
|
107
|
+
`mcp_server/build/index.js — run \`${PLANE_BUILD_REMEDIATION}\``,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const sourceMtime = await findNewestSourceMtime(srcDir, deps);
|
|
111
|
+
if (!sourceMtime.ok) {
|
|
112
|
+
return {
|
|
113
|
+
check: "executor-build",
|
|
114
|
+
severity: "blocking",
|
|
115
|
+
message: `could not read mcp_server/src to compare against the build ` +
|
|
116
|
+
`(${sourceMtime.error}) — run \`${PLANE_BUILD_REMEDIATION}\``,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// Equality passes: a build written in the same millisecond as its newest
|
|
120
|
+
// source is current, and treating the boundary as stale would refuse a
|
|
121
|
+
// perfectly good build on a coarse-resolution filesystem.
|
|
122
|
+
if (sourceMtime.mtimeMs !== null && sourceMtime.mtimeMs > buildMtime.mtimeMs) {
|
|
123
|
+
return {
|
|
124
|
+
check: "executor-build",
|
|
125
|
+
severity: "blocking",
|
|
126
|
+
message: "mcp_server/build/ is STALE — a source file under mcp_server/src is newer " +
|
|
127
|
+
`than mcp_server/build/index.js, so executors would run old code. ` +
|
|
128
|
+
`Run \`${PLANE_BUILD_REMEDIATION}\``,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Fixed refusal prose for an unresolvable runtime re-exec target (BAPI-768).
|
|
135
|
+
*
|
|
136
|
+
* Exported so the private entrypoint diagnostic and the preflight refusal say
|
|
137
|
+
* the same thing, and so a test can pin the wording rather than a substring.
|
|
138
|
+
*/
|
|
139
|
+
export const PLANE_RUNTIME_ENTRYPOINT_REFUSAL = "the currently executing MCP build could not be re-entered — `plane up` re-execs this " +
|
|
140
|
+
"package's own compiled entrypoint to create the detached runtime, and no existing " +
|
|
141
|
+
`entrypoint belonging to it could be found. Run \`${PLANE_BUILD_REMEDIATION}\` and retry ` +
|
|
142
|
+
"from the rebuilt CLI.";
|
|
143
|
+
/**
|
|
144
|
+
* Verify the launcher can identify the compiled entrypoint it is running from.
|
|
145
|
+
*
|
|
146
|
+
* Separate from {@link checkPlaneBuildFreshness} because the two answer
|
|
147
|
+
* different questions about different files. Build freshness asks whether *this
|
|
148
|
+
* repository's* executor build is current; this asks whether the *executing*
|
|
149
|
+
* artifact can re-enter itself. BAPI-768 was a failure of the second while the
|
|
150
|
+
* first was perfectly green, so collapsing them would hide the next one.
|
|
151
|
+
*
|
|
152
|
+
* Blocking, never a warning: a plane whose runtime cannot start is not a
|
|
153
|
+
* degraded plane, it is no plane at all.
|
|
154
|
+
*/
|
|
155
|
+
export function checkPlaneRuntimeEntrypoint(resolution) {
|
|
156
|
+
if (resolution.ok)
|
|
157
|
+
return null;
|
|
158
|
+
return {
|
|
159
|
+
check: "runtime-entrypoint",
|
|
160
|
+
severity: "blocking",
|
|
161
|
+
message: PLANE_RUNTIME_ENTRYPOINT_REFUSAL,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function sanitize(err) {
|
|
165
|
+
const code = err?.code;
|
|
166
|
+
return typeof code === "string" ? code : "unreadable";
|
|
167
|
+
}
|