@bridge_gpt/mcp-server 0.2.36 → 0.2.38
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 +48 -8
- 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 +70 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-body.js +92 -0
- package/build/sfcc/read-projection.js +185 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +57 -34
- package/build/sfcc/reads-site-preference.js +86 -33
- package/build/sfcc/reads-system-object.js +50 -38
- 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 +71 -15
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-custom-object-def.js +6 -2
- 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,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-member log routing (BAPI-756).
|
|
3
|
+
*
|
|
4
|
+
* The split is deliberate: **complete** child output goes to that member's own
|
|
5
|
+
* file, and only **structured, attributed events** reach the supervisor
|
|
6
|
+
* terminal. Interleaving four processes' raw stdout in one terminal destroys
|
|
7
|
+
* attribution exactly when you need it (which process printed the traceback?),
|
|
8
|
+
* and raw child output is also the most likely place for a credential to appear
|
|
9
|
+
* in a stack trace or a debug line.
|
|
10
|
+
*/
|
|
11
|
+
import { PLANE_RUNTIME_LOG_FILENAME } from "./types.js";
|
|
12
|
+
/** The one legal log filename for a member, relative to the plane directory. */
|
|
13
|
+
export function memberLogFilename(member) {
|
|
14
|
+
return `${member}.log`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Open a member's log at its fixed, validated path.
|
|
18
|
+
*
|
|
19
|
+
* The path is re-checked here rather than trusted from the caller: this helper
|
|
20
|
+
* is the last point before a filesystem write, and a traversal-shaped path
|
|
21
|
+
* reaching it would mean an earlier invariant had already been broken.
|
|
22
|
+
*/
|
|
23
|
+
export function openMemberLog(member, planeDir, absolutePath, deps) {
|
|
24
|
+
const expected = `${planeDir}/${memberLogFilename(member)}`;
|
|
25
|
+
const normalized = absolutePath.split("\\").join("/");
|
|
26
|
+
if (normalized !== expected.split("\\").join("/")) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
error: `refusing to open a log outside the plane directory for member '${member}'`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
return { ok: true, stream: deps.openAppendStream(absolutePath) };
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
const code = err?.code;
|
|
37
|
+
return { ok: false, error: typeof code === "string" ? code : "log could not be opened" };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Open the detached runtime's own startup trace (BAPI-768).
|
|
42
|
+
*
|
|
43
|
+
* Kept separate from {@link openMemberLog} rather than folded into it, because
|
|
44
|
+
* the runtime is not a member: it has no {@link PlaneMemberName}, so it cannot
|
|
45
|
+
* borrow the member filename generator, and its trace has to exist *before* the
|
|
46
|
+
* first member log is opened. The containment check is the same one members get
|
|
47
|
+
* — this is still the last point before a filesystem write.
|
|
48
|
+
*/
|
|
49
|
+
export function openPlaneRuntimeLog(planeDir, absolutePath, deps) {
|
|
50
|
+
const expected = `${planeDir}/${PLANE_RUNTIME_LOG_FILENAME}`;
|
|
51
|
+
const normalized = absolutePath.split("\\").join("/");
|
|
52
|
+
if (normalized !== expected.split("\\").join("/")) {
|
|
53
|
+
return { ok: false, error: "refusing to open a runtime trace outside the plane directory" };
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return { ok: true, stream: deps.openAppendStream(absolutePath) };
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
const code = err?.code;
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
error: typeof code === "string" ? code : "runtime trace could not be opened",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The complete set of reasons a member can fail *before* its child streams
|
|
68
|
+
* exist, and therefore the complete set of prose this module will write.
|
|
69
|
+
*
|
|
70
|
+
* An allow-list rather than a formatter argument: the values a caller has on
|
|
71
|
+
* hand at these failure points are exception messages, argv entries, and
|
|
72
|
+
* environment-derived paths — precisely the three things a log line must never
|
|
73
|
+
* carry. Fixing the vocabulary means the unsafe value is never in scope.
|
|
74
|
+
*/
|
|
75
|
+
export const PLANE_MEMBER_STARTUP_FAILURES = {
|
|
76
|
+
spawnThrew: "the process could not be started",
|
|
77
|
+
noPid: "the process started without a pid",
|
|
78
|
+
readinessTimeout: "it did not start listening in time",
|
|
79
|
+
};
|
|
80
|
+
const ALLOWED_STARTUP_FAILURES = new Set(Object.values(PLANE_MEMBER_STARTUP_FAILURES));
|
|
81
|
+
/** Substituted for any reason not in the allow-list. Never echoes the input. */
|
|
82
|
+
export const PLANE_MEMBER_STARTUP_FAILURE_FALLBACK = "it failed to start";
|
|
83
|
+
/** Prefix marking a line as supervisor-authored rather than child output. */
|
|
84
|
+
export const PLANE_SUPERVISOR_LOG_PREFIX = "[plane supervisor]";
|
|
85
|
+
/**
|
|
86
|
+
* Record why a member never reached its child streams.
|
|
87
|
+
*
|
|
88
|
+
* Without this the log of a member that failed pre-exec is a zero-byte file, and
|
|
89
|
+
* `plane status` points the operator straight at it — which is how BAPI-768
|
|
90
|
+
* presented: an empty log read as "nothing to see" when it actually meant "this
|
|
91
|
+
* never ran". A write failure is swallowed, because losing a diagnostic must not
|
|
92
|
+
* change what the supervisor does next.
|
|
93
|
+
*/
|
|
94
|
+
export function writeMemberStartupFailure(log, member, reason) {
|
|
95
|
+
const safe = ALLOWED_STARTUP_FAILURES.has(reason)
|
|
96
|
+
? reason
|
|
97
|
+
: PLANE_MEMBER_STARTUP_FAILURE_FALLBACK;
|
|
98
|
+
try {
|
|
99
|
+
log.write(`${PLANE_SUPERVISOR_LOG_PREFIX} ${member} did not start: ${safe}\n`);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* a failed diagnostic write never propagates into the supervisor loop */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Append one already-sanitized line to the runtime trace.
|
|
107
|
+
*
|
|
108
|
+
* The caller owns redaction: the launcher tees the runtime's real stderr here,
|
|
109
|
+
* which is genuinely useful diagnostic text and cannot come from a fixed
|
|
110
|
+
* vocabulary the way {@link writeMemberStartupFailure}'s can.
|
|
111
|
+
*/
|
|
112
|
+
export function writeRuntimeTraceLine(log, line) {
|
|
113
|
+
try {
|
|
114
|
+
log.write(line.endsWith("\n") ? line : `${line}\n`);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
/* a failed trace write never propagates into the launcher */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Tee a child's stdout and stderr into its own log file.
|
|
122
|
+
*
|
|
123
|
+
* Every received chunk is written in order and nothing is forwarded to the
|
|
124
|
+
* terminal. A stream error degrades observability for that member and is
|
|
125
|
+
* swallowed rather than thrown: losing a log line must never take down a plane
|
|
126
|
+
* that is otherwise running correctly.
|
|
127
|
+
*/
|
|
128
|
+
export function attachMemberOutput(streams, log) {
|
|
129
|
+
const pipe = (stream) => {
|
|
130
|
+
if (!stream)
|
|
131
|
+
return;
|
|
132
|
+
stream.setEncoding?.("utf8");
|
|
133
|
+
stream.on("data", (chunk) => {
|
|
134
|
+
try {
|
|
135
|
+
log.write(typeof chunk === "string" ? chunk : String(chunk));
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* a failed log write never propagates into the supervisor loop */
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
stream.on("error", () => {
|
|
142
|
+
/* stream errors degrade observability, never correctness */
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
pipe(streams.stdout);
|
|
146
|
+
pipe(streams.stderr);
|
|
147
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pinned plane member roster (BAPI-756).
|
|
3
|
+
*
|
|
4
|
+
* Every invariant an operator currently carries in their head is expressed here
|
|
5
|
+
* as code, so it cannot be forgotten under time pressure:
|
|
6
|
+
*
|
|
7
|
+
* - the server runs **without** `--reload`, and reload is not a configurable
|
|
8
|
+
* option at any layer — `--reload` drops long-running MCP calls mid-flight;
|
|
9
|
+
* - executors run from **this repository's build tree**, by absolute path, never
|
|
10
|
+
* `npx` and never a published package;
|
|
11
|
+
* - the reconciler worker is the ORDINARY worker, explicitly not the dead-man
|
|
12
|
+
* observer;
|
|
13
|
+
* - the dead-man observer is absent by construction — there is no observer
|
|
14
|
+
* member, pid, log path, or manifest record anywhere in this file (R-3).
|
|
15
|
+
*
|
|
16
|
+
* Credentials travel in the child environment and nowhere else. No argv entry,
|
|
17
|
+
* member name, log path, or formatted description is built from a secret.
|
|
18
|
+
*/
|
|
19
|
+
import path from "path";
|
|
20
|
+
import { PLANE_SERVER_HOST, PLANE_SERVER_PORT, } from "./types.js";
|
|
21
|
+
import { relativeLogPathFor } from "./manifest.js";
|
|
22
|
+
/** How long a member with a readiness probe gets to start listening. */
|
|
23
|
+
export const PLANE_READINESS_TIMEOUT_MS = 60_000;
|
|
24
|
+
/** Executable used for the Python members. Overridable for a non-default venv. */
|
|
25
|
+
export function resolvePythonExecutable(env) {
|
|
26
|
+
const configured = env.BAPI_PLANE_PYTHON;
|
|
27
|
+
return typeof configured === "string" && configured.trim().length > 0
|
|
28
|
+
? configured.trim()
|
|
29
|
+
: "python";
|
|
30
|
+
}
|
|
31
|
+
/** Executable used for the server member. */
|
|
32
|
+
export function resolveUvicornExecutable(env) {
|
|
33
|
+
const configured = env.BAPI_PLANE_UVICORN;
|
|
34
|
+
return typeof configured === "string" && configured.trim().length > 0
|
|
35
|
+
? configured.trim()
|
|
36
|
+
: "uvicorn";
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Build a child environment: a copy of the parent plus the resolved values the
|
|
40
|
+
* existing processes already expect.
|
|
41
|
+
*
|
|
42
|
+
* `CONDUCTOR_DEAD_MAN_ONLY` is explicitly deleted rather than merely left
|
|
43
|
+
* unset. An operator who exported it in this shell to run an observer would
|
|
44
|
+
* otherwise silently turn the plane's reconciler worker into a second observer,
|
|
45
|
+
* and the run would sit there with nothing reconciling it.
|
|
46
|
+
*/
|
|
47
|
+
export function buildPlaneChildEnv(parentEnv, context) {
|
|
48
|
+
const env = {};
|
|
49
|
+
for (const [key, value] of Object.entries(parentEnv)) {
|
|
50
|
+
if (value !== undefined)
|
|
51
|
+
env[key] = value;
|
|
52
|
+
}
|
|
53
|
+
env.BAPI_REPO_NAME = context.repoName;
|
|
54
|
+
env.BAPI_BASE_URL = context.baseUrl;
|
|
55
|
+
env.BAPI_API_KEY = context.bridgeApiKey;
|
|
56
|
+
delete env.CONDUCTOR_DEAD_MAN_ONLY;
|
|
57
|
+
return env;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The complete roster: exactly one server, one ordinary worker, and `executors`
|
|
61
|
+
* executor lanes. Never an observer.
|
|
62
|
+
*/
|
|
63
|
+
export function buildPlaneMemberRoster(params) {
|
|
64
|
+
const { context, executors, parentEnv, nodeExecutable } = params;
|
|
65
|
+
const env = buildPlaneChildEnv(parentEnv, context);
|
|
66
|
+
const cwd = context.repoRoot;
|
|
67
|
+
const server = {
|
|
68
|
+
name: "server",
|
|
69
|
+
command: resolveUvicornExecutable(parentEnv),
|
|
70
|
+
// NO `--reload`, at any layer, ever. `--reload` restarts the worker process
|
|
71
|
+
// on file change and drops in-flight MCP calls, which during a conductor run
|
|
72
|
+
// silently truncates long tool calls the executor is waiting on.
|
|
73
|
+
args: [
|
|
74
|
+
"main:app",
|
|
75
|
+
"--host",
|
|
76
|
+
PLANE_SERVER_HOST,
|
|
77
|
+
"--port",
|
|
78
|
+
String(PLANE_SERVER_PORT),
|
|
79
|
+
],
|
|
80
|
+
cwd,
|
|
81
|
+
env,
|
|
82
|
+
logPath: relativeLogPathFor("server"),
|
|
83
|
+
readiness: {
|
|
84
|
+
host: PLANE_SERVER_HOST,
|
|
85
|
+
port: PLANE_SERVER_PORT,
|
|
86
|
+
timeoutMs: PLANE_READINESS_TIMEOUT_MS,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
const worker = {
|
|
90
|
+
name: "worker",
|
|
91
|
+
command: resolvePythonExecutable(parentEnv),
|
|
92
|
+
args: ["worker.py"],
|
|
93
|
+
cwd,
|
|
94
|
+
env,
|
|
95
|
+
logPath: relativeLogPathFor("worker"),
|
|
96
|
+
// The reconciler has no listening socket, so "spawned and still alive" is
|
|
97
|
+
// the only readiness signal available without inventing a health endpoint.
|
|
98
|
+
readiness: null,
|
|
99
|
+
};
|
|
100
|
+
const executorMembers = [];
|
|
101
|
+
for (let lane = 1; lane <= executors; lane += 1) {
|
|
102
|
+
const name = `executor-${lane}`;
|
|
103
|
+
executorMembers.push({
|
|
104
|
+
name,
|
|
105
|
+
command: nodeExecutable,
|
|
106
|
+
args: [
|
|
107
|
+
// Absolute, repository-local, compiled entrypoint. An `npx` launch would
|
|
108
|
+
// silently run a PUBLISHED version instead of this tree's code.
|
|
109
|
+
context.executorEntrypoint,
|
|
110
|
+
"executor",
|
|
111
|
+
"--repo",
|
|
112
|
+
context.repoName,
|
|
113
|
+
"--base-url",
|
|
114
|
+
context.baseUrl,
|
|
115
|
+
"--executor-id",
|
|
116
|
+
buildExecutorId(context.repoName, lane),
|
|
117
|
+
],
|
|
118
|
+
cwd,
|
|
119
|
+
env,
|
|
120
|
+
logPath: relativeLogPathFor(name),
|
|
121
|
+
readiness: null,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return [server, worker, ...executorMembers];
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Deterministic, distinct, CLI-safe executor id for one lane.
|
|
128
|
+
*
|
|
129
|
+
* The repository name is sanitized down to `[A-Za-z0-9_-]` because it reaches
|
|
130
|
+
* an argument vector; the lane number, which is the part that must be unique,
|
|
131
|
+
* comes from a validated integer and cannot be influenced by external text.
|
|
132
|
+
*/
|
|
133
|
+
export function buildExecutorId(repoName, lane) {
|
|
134
|
+
const safeRepo = repoName.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 40) || "repo";
|
|
135
|
+
return `plane-${safeRepo}-${lane}`;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* A member rendered for the terminal. Built only from the fixed name, the
|
|
139
|
+
* command, and the argv — all of which are secret-free by construction.
|
|
140
|
+
*/
|
|
141
|
+
export function describePlaneMember(spec) {
|
|
142
|
+
return `${spec.name}: ${[spec.command, ...spec.args].join(" ")}`;
|
|
143
|
+
}
|
|
144
|
+
/** Absolute log path for a member under the validated repository root. */
|
|
145
|
+
export function absoluteLogPath(repoRoot, spec) {
|
|
146
|
+
return path.join(repoRoot, spec.logPath);
|
|
147
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All-or-nothing plane preflight (BAPI-756, AC-8).
|
|
3
|
+
*
|
|
4
|
+
* Every spawn-blocking invariant is evaluated and **all** failures are reported
|
|
5
|
+
* together. Failing fast on the first problem is what turns a five-minute setup
|
|
6
|
+
* into five separate one-minute retries, so this module deliberately keeps
|
|
7
|
+
* going after a blocking failure and aggregates.
|
|
8
|
+
*
|
|
9
|
+
* The other half of "all-or-nothing" is the side-effect rule: preflight creates
|
|
10
|
+
* no directory, writes no manifest, opens no log, and spawns no process. It
|
|
11
|
+
* only reads. A refusal therefore leaves the working tree exactly as it found
|
|
12
|
+
* it — including any manifest belonging to a plane that is already running.
|
|
13
|
+
*/
|
|
14
|
+
import path from "path";
|
|
15
|
+
import { PLANE_SERVER_BASE_URL, PLANE_SERVER_HOST, PLANE_SERVER_PORT, } from "./types.js";
|
|
16
|
+
import { checkPlaneBuildFreshness, checkPlaneRuntimeEntrypoint } from "./build-freshness.js";
|
|
17
|
+
import { checkAlembicHead } from "./alembic-head.js";
|
|
18
|
+
import { manifestHasLiveProcess, readPlaneManifest } from "./manifest.js";
|
|
19
|
+
/** Files that must exist for a path to be this repository's root. */
|
|
20
|
+
const REQUIRED_REPO_FILES = ["main.py", "worker.py", "alembic.ini"];
|
|
21
|
+
/** Bound on the port probe so a black-holed port cannot stall bring-up. */
|
|
22
|
+
export const PLANE_PORT_PROBE_TIMEOUT_MS = 1_500;
|
|
23
|
+
/**
|
|
24
|
+
* Evaluate every spawn-blocking invariant for `plane up`.
|
|
25
|
+
*
|
|
26
|
+
* On success the returned context carries the resolved Bridge API key. That is
|
|
27
|
+
* the only place it exists outside a child environment, and no diagnostic in
|
|
28
|
+
* the same result can contain it: each message below is built from fixed prose
|
|
29
|
+
* plus non-secret facts.
|
|
30
|
+
*/
|
|
31
|
+
export async function runPlanePreflight(repoRoot, deps) {
|
|
32
|
+
const diagnostics = [];
|
|
33
|
+
const add = (diagnostic) => {
|
|
34
|
+
if (diagnostic)
|
|
35
|
+
diagnostics.push(diagnostic);
|
|
36
|
+
};
|
|
37
|
+
const rootCheck = await checkRepositoryRoot(repoRoot, deps);
|
|
38
|
+
add(rootCheck);
|
|
39
|
+
// Every remaining check is relative to the repository root. Without a valid
|
|
40
|
+
// one there is nothing coherent left to check, so report and stop here rather
|
|
41
|
+
// than emitting a cascade of derived failures the operator cannot act on.
|
|
42
|
+
if (rootCheck)
|
|
43
|
+
return { ok: false, diagnostics };
|
|
44
|
+
add(checkRequiredEnvironment(deps.env));
|
|
45
|
+
const credentials = await checkBridgeCredentials(repoRoot, deps);
|
|
46
|
+
if (!credentials.ok)
|
|
47
|
+
add(credentials.diagnostic);
|
|
48
|
+
add(await checkPlaneBuildFreshness(repoRoot, { fs: deps.fs }));
|
|
49
|
+
// Read-only, and evaluated alongside the other independent checks so an
|
|
50
|
+
// unresolvable re-exec target aggregates with them instead of short-circuiting.
|
|
51
|
+
const runtimeEntrypoint = deps.resolveRuntimeEntrypoint();
|
|
52
|
+
add(checkPlaneRuntimeEntrypoint(runtimeEntrypoint));
|
|
53
|
+
add(await checkServerPort(deps));
|
|
54
|
+
add(await checkAlembicHead(repoRoot, {
|
|
55
|
+
execFile: deps.execFile,
|
|
56
|
+
fileExists: (filePath) => fileExists(filePath, deps.fs),
|
|
57
|
+
platform: deps.platform,
|
|
58
|
+
}));
|
|
59
|
+
add(await checkExistingPlane(repoRoot, deps));
|
|
60
|
+
const blocking = diagnostics.filter((d) => d.severity === "blocking");
|
|
61
|
+
// `!runtimeEntrypoint.ok` is already covered by the blocking count; it is
|
|
62
|
+
// repeated here so the compiler narrows the union rather than requiring a
|
|
63
|
+
// non-null assertion on the context field below.
|
|
64
|
+
if (blocking.length > 0 || !credentials.ok || !runtimeEntrypoint.ok) {
|
|
65
|
+
return { ok: false, diagnostics };
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
diagnostics,
|
|
70
|
+
context: {
|
|
71
|
+
repoRoot,
|
|
72
|
+
repoName: credentials.repoName,
|
|
73
|
+
baseUrl: PLANE_SERVER_BASE_URL,
|
|
74
|
+
// Location-chosen: always THIS repository's build tree, so an executor can
|
|
75
|
+
// never come from a published package.
|
|
76
|
+
executorEntrypoint: path.join(repoRoot, "mcp_server", "build", "index.js"),
|
|
77
|
+
// Identity-chosen: the build already executing. Never substituted for the
|
|
78
|
+
// executor entrypoint above, nor it for this.
|
|
79
|
+
runtimeEntrypoint: runtimeEntrypoint.entrypoint,
|
|
80
|
+
bridgeApiKey: credentials.apiKey,
|
|
81
|
+
bridgeCredentialSource: credentials.source,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Confirm the working directory really is this repository's root.
|
|
87
|
+
*
|
|
88
|
+
* The root is taken from the process's own cwd, never from an operator-supplied
|
|
89
|
+
* path, so there is no traversal-shaped input to sanitize — but the marker
|
|
90
|
+
* files are still checked, because running the plane from a sibling worktree's
|
|
91
|
+
* parent would otherwise spawn `uvicorn main:app` against nothing.
|
|
92
|
+
*/
|
|
93
|
+
async function checkRepositoryRoot(repoRoot, deps) {
|
|
94
|
+
if (!path.isAbsolute(repoRoot)) {
|
|
95
|
+
return {
|
|
96
|
+
check: "repository-root",
|
|
97
|
+
severity: "blocking",
|
|
98
|
+
message: "the repository root could not be resolved to an absolute path",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const missing = [];
|
|
102
|
+
for (const file of REQUIRED_REPO_FILES) {
|
|
103
|
+
if (!(await fileExists(path.join(repoRoot, file), deps.fs)))
|
|
104
|
+
missing.push(file);
|
|
105
|
+
}
|
|
106
|
+
if (!(await fileExists(path.join(repoRoot, "mcp_server"), deps.fs))) {
|
|
107
|
+
missing.push("mcp_server/");
|
|
108
|
+
}
|
|
109
|
+
if (missing.length === 0)
|
|
110
|
+
return null;
|
|
111
|
+
return {
|
|
112
|
+
check: "repository-root",
|
|
113
|
+
severity: "blocking",
|
|
114
|
+
message: `this does not look like the Bridge API repository root — missing ${missing.join(", ")}. ` +
|
|
115
|
+
"Run `plane up` from the repository root.",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* `ANTHROPIC_API_KEY` must be present and non-blank.
|
|
120
|
+
*
|
|
121
|
+
* Workers run under an isolated Claude config dir, where the API key is the one
|
|
122
|
+
* sanctioned credential and an OAuth-only host refuses to spawn outright — so a
|
|
123
|
+
* missing key is a spawn-blocking condition, not a degradation.
|
|
124
|
+
*
|
|
125
|
+
* The message names the key and never its value.
|
|
126
|
+
*/
|
|
127
|
+
export function checkRequiredEnvironment(env) {
|
|
128
|
+
const value = env.ANTHROPIC_API_KEY;
|
|
129
|
+
if (typeof value === "string" && value.trim().length > 0)
|
|
130
|
+
return null;
|
|
131
|
+
return {
|
|
132
|
+
check: "anthropic-api-key",
|
|
133
|
+
severity: "blocking",
|
|
134
|
+
message: "ANTHROPIC_API_KEY is not set (or is blank) in this shell. Isolated workers have no " +
|
|
135
|
+
"OAuth session and refuse to spawn without it. Export it before running `plane up`.",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Resolve the repository identity, then the Bridge credential for it.
|
|
140
|
+
*
|
|
141
|
+
* Both go through the shared resolvers. This is the shell-spawned credential
|
|
142
|
+
* rule: a plane member is launched from a spawned shell, which never sees
|
|
143
|
+
* `.mcp.json` env — that file is visible only to the MCP server process.
|
|
144
|
+
*/
|
|
145
|
+
export async function checkBridgeCredentials(repoRoot, deps) {
|
|
146
|
+
const repo = await deps.resolveRepoName({
|
|
147
|
+
env: deps.env,
|
|
148
|
+
cwd: repoRoot,
|
|
149
|
+
readFile: deps.fs.readFile,
|
|
150
|
+
});
|
|
151
|
+
if (!repo.ok) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
diagnostic: {
|
|
155
|
+
check: "bridge-credentials",
|
|
156
|
+
severity: "blocking",
|
|
157
|
+
message: "the Bridge repository identity could not be resolved — set BAPI_REPO_NAME or add a " +
|
|
158
|
+
"valid .bridge/config at the repository root.",
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
let result;
|
|
163
|
+
try {
|
|
164
|
+
result = await deps.resolveCredentials(repo.repoName, {
|
|
165
|
+
env: deps.env,
|
|
166
|
+
homedir: deps.homedir,
|
|
167
|
+
platform: deps.platform,
|
|
168
|
+
readFile: deps.fs.readFile,
|
|
169
|
+
stat: async (filePath) => {
|
|
170
|
+
// The resolver only needs POSIX mode bits for its permission warning.
|
|
171
|
+
// `PlaneFsDeps.stat` is deliberately minimal, so a missing mode is
|
|
172
|
+
// reported as a fully-open value the resolver will simply warn about
|
|
173
|
+
// rather than as a fabricated "safe" one.
|
|
174
|
+
await deps.fs.stat(filePath);
|
|
175
|
+
return { mode: 0o600 };
|
|
176
|
+
},
|
|
177
|
+
stderr: () => {
|
|
178
|
+
/* the resolver's advisory warnings are not part of plane output */
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Sanitized: a resolver throw becomes a category, never exception text
|
|
184
|
+
// (which could echo file contents).
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
diagnostic: {
|
|
188
|
+
check: "bridge-credentials",
|
|
189
|
+
severity: "blocking",
|
|
190
|
+
message: `Bridge credentials for target bapi:${repo.repoName} could not be resolved ` +
|
|
191
|
+
"(resolver unavailable).",
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (!result.ok) {
|
|
196
|
+
return {
|
|
197
|
+
ok: false,
|
|
198
|
+
diagnostic: {
|
|
199
|
+
check: "bridge-credentials",
|
|
200
|
+
severity: "blocking",
|
|
201
|
+
message: `Bridge credentials for target bapi:${repo.repoName} could not be resolved ` +
|
|
202
|
+
`(${result.kind}). Set BAPI_API_KEY in this shell, or store it with ` +
|
|
203
|
+
"`mcp-server credentials`. A spawned shell never sees .mcp.json env.",
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
repoName: repo.repoName,
|
|
210
|
+
apiKey: result.credentials.apiKey,
|
|
211
|
+
source: result.credentials.source,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Probe the local server port with a bounded TCP connect.
|
|
216
|
+
*
|
|
217
|
+
* A successful connection means something already owns the port — most often a
|
|
218
|
+
* sibling worktree's server, which is the trap this refusal names explicitly.
|
|
219
|
+
* A refused connection is the available case. Anything else is reported as a
|
|
220
|
+
* warning rather than guessed either way: silently assuming "available" would
|
|
221
|
+
* let two servers race for the port, and silently assuming "occupied" would
|
|
222
|
+
* refuse a perfectly good bring-up.
|
|
223
|
+
*/
|
|
224
|
+
export async function checkServerPort(deps) {
|
|
225
|
+
const result = await deps.probePort(PLANE_SERVER_HOST, PLANE_SERVER_PORT, PLANE_PORT_PROBE_TIMEOUT_MS);
|
|
226
|
+
if (result.kind === "refused")
|
|
227
|
+
return null;
|
|
228
|
+
if (result.kind === "connected") {
|
|
229
|
+
return {
|
|
230
|
+
check: "server-port",
|
|
231
|
+
severity: "blocking",
|
|
232
|
+
message: `${PLANE_SERVER_HOST}:${PLANE_SERVER_PORT} is already accepting connections. ` +
|
|
233
|
+
"That port may belong to a SIBLING WORKTREE's server — check before you kill it. " +
|
|
234
|
+
"Stop the existing server (or wind down its plane) and retry.",
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
check: "server-port",
|
|
239
|
+
severity: "warning",
|
|
240
|
+
message: `could not determine whether ${PLANE_SERVER_HOST}:${PLANE_SERVER_PORT} is free ` +
|
|
241
|
+
`(${result.error}); startup continues and uvicorn will fail loudly if the port is taken.`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Refuse to start over a plane that is still alive.
|
|
246
|
+
*
|
|
247
|
+
* A manifest whose processes are all dead is *classified* stale here but is not
|
|
248
|
+
* acted on: deleting or replacing it is the atomic claim's job, under ownership,
|
|
249
|
+
* where the classification can be re-checked. Preflight never mutates disk.
|
|
250
|
+
*/
|
|
251
|
+
export async function checkExistingPlane(repoRoot, deps) {
|
|
252
|
+
const read = await readPlaneManifest(repoRoot, deps.fs);
|
|
253
|
+
if (read.kind === "missing")
|
|
254
|
+
return null;
|
|
255
|
+
if (read.kind !== "valid") {
|
|
256
|
+
return {
|
|
257
|
+
check: "existing-plane",
|
|
258
|
+
severity: "blocking",
|
|
259
|
+
message: `an existing .bridge/plane/plane.json could not be validated (${read.error}). ` +
|
|
260
|
+
"No process was signalled and the file was left untouched — inspect it, then remove " +
|
|
261
|
+
"it by hand if no plane is running.",
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (!manifestHasLiveProcess(read.manifest, deps.proc)) {
|
|
265
|
+
// Stale. Reported as information so the operator understands why an old
|
|
266
|
+
// manifest is about to be replaced; the claim revalidates before it does.
|
|
267
|
+
return {
|
|
268
|
+
check: "existing-plane",
|
|
269
|
+
severity: "warning",
|
|
270
|
+
message: "a previous plane manifest is present but every recorded process is gone; it will be " +
|
|
271
|
+
"replaced after a final liveness re-check.",
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
check: "existing-plane",
|
|
276
|
+
severity: "blocking",
|
|
277
|
+
message: `a plane is already running (supervisor pid ${read.manifest.supervisorPid}). ` +
|
|
278
|
+
"Run `plane status` to inspect it, or `plane down` to wind it down first.",
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
async function fileExists(filePath, fs) {
|
|
282
|
+
try {
|
|
283
|
+
await fs.stat(filePath);
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|