@bridge_gpt/mcp-server 0.2.34 → 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 +490 -366
- package/build/agent-capabilities/probe-context.js +8 -1
- package/build/agent-capabilities/probes.js +7 -1
- package/build/agents.generated.js +1 -1
- 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-review-workflow.js +264 -0
- package/build/claude-user-config-doctor.js +317 -0
- package/build/cli-release.js +53 -0
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +393 -4
- package/build/conductor/deny-enforcement-preflight.js +1 -0
- package/build/conductor/done-gate.js +44 -5
- package/build/conductor/epic-reconcile.js +6 -0
- package/build/conductor/install-doctor.js +462 -0
- package/build/conductor-bin.js +3 -3
- package/build/conductor-bundle-artifacts.js +37 -15
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +473 -81
- package/build/executor/cli.js +83 -6
- package/build/executor/credentials.js +39 -11
- package/build/executor/deps.js +32 -1
- package/build/executor/env.js +71 -28
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/index.js +4 -0
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +530 -20
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +114 -8
- package/build/executor/preflight.js +127 -8
- package/build/executor/prompt-spec.js +51 -0
- package/build/executor/runner.js +23 -2
- package/build/executor/service-unit.js +876 -0
- package/build/executor/test-clock.js +75 -3
- package/build/executor/types.js +4 -18
- package/build/executor/worker-command.js +73 -12
- 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 +1039 -237
- package/build/init.js +503 -111
- package/build/install-bridge-conductor.js +491 -0
- package/build/install-bridge.js +1188 -247
- package/build/install-reexec.js +233 -0
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-host-config.js +11 -1
- package/build/mcp-install-state.js +32 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +52 -13
- 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 +32 -13
- 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 +259 -0
- package/build/setup-epic.js +772 -28
- 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 +412 -110
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +132 -6
- package/docs/install/mcp-tool-integrations.md +29 -21
- package/package.json +9 -6
- package/pipelines/implement-ticket.json +19 -4
- package/build/conductor/supervisor-judgment-python.js +0 -141
- package/build/conductor/supervisor-judgment.js +0 -215
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-I/O dependency factories for the plane (BAPI-756).
|
|
3
|
+
*
|
|
4
|
+
* Every plane module takes its filesystem, network, process, clock, and spawn
|
|
5
|
+
* capabilities as injected dependencies so unit tests never touch a real
|
|
6
|
+
* socket, credential store, or child process. This file is the one place those
|
|
7
|
+
* dependencies are bound to the actual platform, which keeps the seam honest:
|
|
8
|
+
* if a module reached for `node:fs` directly, it would show up here as an
|
|
9
|
+
* unused factory rather than hiding inside the logic.
|
|
10
|
+
*/
|
|
11
|
+
import { createWriteStream, realpathSync, statSync } from "fs";
|
|
12
|
+
import { mkdir, open, readdir, readFile, rename, stat, unlink, writeFile } from "fs/promises";
|
|
13
|
+
import { execFile as execFileCb, spawn as nodeSpawn } from "child_process";
|
|
14
|
+
import net from "net";
|
|
15
|
+
import os from "os";
|
|
16
|
+
import path from "path";
|
|
17
|
+
import { fileURLToPath } from "url";
|
|
18
|
+
import { isProcessAlive } from "./manifest.js";
|
|
19
|
+
/** Filesystem primitives bound to `node:fs/promises`. */
|
|
20
|
+
export function createPlaneFsDeps() {
|
|
21
|
+
return {
|
|
22
|
+
readFile: (filePath) => readFile(filePath, "utf-8"),
|
|
23
|
+
writeFile: (filePath, data) => writeFile(filePath, data, "utf-8"),
|
|
24
|
+
createExclusive: async (filePath, data) => {
|
|
25
|
+
// `wx` is the atomic mutual-exclusion primitive the manifest claim relies
|
|
26
|
+
// on: it fails with EEXIST rather than truncating a competing plane's file.
|
|
27
|
+
const handle = await open(filePath, "wx");
|
|
28
|
+
try {
|
|
29
|
+
await handle.writeFile(data, "utf-8");
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await handle.close();
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
rename: (from, to) => rename(from, to),
|
|
36
|
+
unlink: (filePath) => unlink(filePath),
|
|
37
|
+
mkdir: async (dirPath) => {
|
|
38
|
+
await mkdir(dirPath, { recursive: true });
|
|
39
|
+
},
|
|
40
|
+
stat: async (filePath) => {
|
|
41
|
+
const result = await stat(filePath);
|
|
42
|
+
return { mtimeMs: result.mtimeMs };
|
|
43
|
+
},
|
|
44
|
+
readdir: async (dirPath) => {
|
|
45
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
46
|
+
return entries.map((entry) => ({
|
|
47
|
+
name: entry.name,
|
|
48
|
+
isDirectory: () => entry.isDirectory(),
|
|
49
|
+
isFile: () => entry.isFile(),
|
|
50
|
+
}));
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Wall-clock time and real sleeps. */
|
|
55
|
+
export function createPlaneClock() {
|
|
56
|
+
return {
|
|
57
|
+
now: () => new Date(),
|
|
58
|
+
sleep: (ms) => new Promise((resolve) => {
|
|
59
|
+
setTimeout(resolve, ms).unref?.();
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Liveness and signal delivery bound to `process.kill`. */
|
|
64
|
+
export function createPlaneProcessDeps() {
|
|
65
|
+
return {
|
|
66
|
+
isAlive: (pid) => isProcessAlive(pid, (target, signal) => process.kill(target, signal)),
|
|
67
|
+
signal: (pid, signal) => {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, signal);
|
|
70
|
+
return "sent";
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
const code = err?.code;
|
|
74
|
+
if (code === "ESRCH")
|
|
75
|
+
return "no-such-process";
|
|
76
|
+
if (code === "EPERM")
|
|
77
|
+
return "permission-denied";
|
|
78
|
+
return "error";
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Bounded TCP connect probe.
|
|
85
|
+
*
|
|
86
|
+
* Connect-only, never bind: binding to test a port would itself occupy it, and
|
|
87
|
+
* a failed unbind would leave the plane unable to start.
|
|
88
|
+
*/
|
|
89
|
+
export function probeTcpPort(host, port, timeoutMs) {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const socket = new net.Socket();
|
|
92
|
+
let settled = false;
|
|
93
|
+
const finish = (result) => {
|
|
94
|
+
if (settled)
|
|
95
|
+
return;
|
|
96
|
+
settled = true;
|
|
97
|
+
socket.destroy();
|
|
98
|
+
resolve(result);
|
|
99
|
+
};
|
|
100
|
+
socket.setTimeout(timeoutMs);
|
|
101
|
+
socket.once("connect", () => finish({ kind: "connected" }));
|
|
102
|
+
socket.once("timeout", () => finish({ kind: "error", error: "probe timed out" }));
|
|
103
|
+
socket.once("error", (err) => {
|
|
104
|
+
if (err.code === "ECONNREFUSED")
|
|
105
|
+
finish({ kind: "refused" });
|
|
106
|
+
else
|
|
107
|
+
finish({ kind: "error", error: err.code ?? "probe failed" });
|
|
108
|
+
});
|
|
109
|
+
socket.connect(port, host);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/** `execFile`, argument arrays only. No shell, ever. */
|
|
113
|
+
export function createPlaneExecFile() {
|
|
114
|
+
return (file, args, options) => new Promise((resolve) => {
|
|
115
|
+
execFileCb(file, args, { cwd: options.cwd, shell: false, windowsHide: true, timeout: 30_000 }, (error, stdout, stderr) => {
|
|
116
|
+
if (error) {
|
|
117
|
+
const code = error.code;
|
|
118
|
+
resolve({ ok: false, error: typeof code === "string" ? code : "command failed" });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
resolve({ ok: true, stdout: String(stdout), stderr: String(stderr) });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/** Child spawning. `shell: false` is pinned: argv arrays only, never a string. */
|
|
126
|
+
export function createPlaneSpawn() {
|
|
127
|
+
return (command, args, options) => {
|
|
128
|
+
const child = nodeSpawn(command, args, {
|
|
129
|
+
cwd: options.cwd,
|
|
130
|
+
env: options.env,
|
|
131
|
+
detached: options.detached,
|
|
132
|
+
shell: false,
|
|
133
|
+
windowsHide: true,
|
|
134
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
135
|
+
});
|
|
136
|
+
return child;
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** Append-mode member log streams. */
|
|
140
|
+
export function createMemberLogDeps() {
|
|
141
|
+
return {
|
|
142
|
+
openAppendStream: (absolutePath) => {
|
|
143
|
+
const stream = createWriteStream(absolutePath, { flags: "a" });
|
|
144
|
+
stream.on("error", () => {
|
|
145
|
+
/* a log write failure degrades observability, never the plane */
|
|
146
|
+
});
|
|
147
|
+
return {
|
|
148
|
+
write: (chunk) => {
|
|
149
|
+
stream.write(chunk);
|
|
150
|
+
},
|
|
151
|
+
end: () => stream.end(),
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
/** Registers a signal handler and returns a disposer. */
|
|
157
|
+
export function registerSignalHandler(signal, handler) {
|
|
158
|
+
process.on(signal, handler);
|
|
159
|
+
return () => {
|
|
160
|
+
process.off(signal, handler);
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Absolute path of the compiled entrypoint belonging to the artifact that is
|
|
165
|
+
* currently executing (BAPI-768).
|
|
166
|
+
*
|
|
167
|
+
* ## Why this cannot be path arithmetic
|
|
168
|
+
*
|
|
169
|
+
* This package ships in two layouts, and `npm run build` produces both:
|
|
170
|
+
*
|
|
171
|
+
* - **tsc-emitted** — this module is `build/plane/defaults.js`, and the
|
|
172
|
+
* entrypoint is one directory up at `build/index.js`.
|
|
173
|
+
* - **esbuild-bundled** — `scripts/bundle-esbuild.js` inlines this module *into*
|
|
174
|
+
* `build/index.js` and overwrites it, so `import.meta.url` becomes the
|
|
175
|
+
* entrypoint itself and the directory hop lands outside `build/` entirely.
|
|
176
|
+
*
|
|
177
|
+
* The original implementation hard-coded the first mapping. In the shipped
|
|
178
|
+
* bundle it returned `mcp_server/index.js`, which has never existed; the
|
|
179
|
+
* detached runtime spawn of that path died with `ERR_MODULE_NOT_FOUND` before it
|
|
180
|
+
* could write anything, and `plane up` printed its success banner over the
|
|
181
|
+
* corpse. So this resolver *probes* instead of assuming, and every candidate
|
|
182
|
+
* must be an existing regular file before it is returned.
|
|
183
|
+
*
|
|
184
|
+
* ## Why identity, not location
|
|
185
|
+
*
|
|
186
|
+
* Candidates come only from this module's own URL and from Node's actual
|
|
187
|
+
* invocation target. `repoRoot`, `process.cwd()`, and any assumed package
|
|
188
|
+
* location are excluded on purpose: re-execing "an entrypoint that looks right"
|
|
189
|
+
* from a sibling worktree would silently run a *different build* than the one
|
|
190
|
+
* the operator launched, which is the stale-build trap wearing a new hat.
|
|
191
|
+
*/
|
|
192
|
+
export function resolvePackageEntrypoint(overrides = {}) {
|
|
193
|
+
const deps = {
|
|
194
|
+
moduleUrl: overrides.moduleUrl ?? import.meta.url,
|
|
195
|
+
argv1: overrides.argv1 ?? process.argv[1],
|
|
196
|
+
realpath: overrides.realpath ?? defaultRealpath,
|
|
197
|
+
isFile: overrides.isFile ?? defaultIsFile,
|
|
198
|
+
};
|
|
199
|
+
const moduleFile = moduleFilePath(deps.moduleUrl);
|
|
200
|
+
const candidates = [];
|
|
201
|
+
if (moduleFile !== null) {
|
|
202
|
+
const dir = path.dirname(moduleFile);
|
|
203
|
+
// Bundled layout: this module IS the entrypoint. Checked first so the
|
|
204
|
+
// parent-directory candidate below can never win in a bundle.
|
|
205
|
+
if (path.basename(moduleFile) === "index.js")
|
|
206
|
+
candidates.push(moduleFile);
|
|
207
|
+
// A sibling entrypoint covers a bundle emitted under a different bin name.
|
|
208
|
+
candidates.push(path.join(dir, "index.js"));
|
|
209
|
+
// tsc layout: `build/plane/defaults.js` → `build/index.js`.
|
|
210
|
+
candidates.push(path.join(dir, "..", "index.js"));
|
|
211
|
+
}
|
|
212
|
+
// Last resort, and only ever a *validated* one: a packaged install launches
|
|
213
|
+
// through a `node_modules/.bin` symlink, so the invocation target must be
|
|
214
|
+
// canonicalized before it can identify the real package build.
|
|
215
|
+
if (typeof deps.argv1 === "string" && deps.argv1.length > 0) {
|
|
216
|
+
candidates.push(deps.argv1);
|
|
217
|
+
}
|
|
218
|
+
const seen = new Set();
|
|
219
|
+
for (const candidate of candidates) {
|
|
220
|
+
// Canonicalize BEFORE probing: it collapses duplicates that differ only by
|
|
221
|
+
// symlink or `..` segment, and it rejects a dangling symlink outright rather
|
|
222
|
+
// than letting it reach the regular-file check.
|
|
223
|
+
const canonical = deps.realpath(candidate);
|
|
224
|
+
if (canonical === null)
|
|
225
|
+
continue;
|
|
226
|
+
if (seen.has(canonical))
|
|
227
|
+
continue;
|
|
228
|
+
seen.add(canonical);
|
|
229
|
+
if (deps.isFile(canonical))
|
|
230
|
+
return { ok: true, entrypoint: canonical };
|
|
231
|
+
}
|
|
232
|
+
return { ok: false, candidatesChecked: seen.size };
|
|
233
|
+
}
|
|
234
|
+
/** `file://` URL → filesystem path, or `null` when the URL is unusable. */
|
|
235
|
+
function moduleFilePath(moduleUrl) {
|
|
236
|
+
try {
|
|
237
|
+
return fileURLToPath(moduleUrl);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function defaultRealpath(filePath) {
|
|
244
|
+
try {
|
|
245
|
+
return realpathSync(filePath);
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function defaultIsFile(filePath) {
|
|
252
|
+
try {
|
|
253
|
+
return statSync(filePath).isFile();
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Node executable used for the runtime re-exec and executor members. */
|
|
260
|
+
export function resolveNodeExecutable() {
|
|
261
|
+
return process.execPath;
|
|
262
|
+
}
|
|
263
|
+
/** Home directory seam used by the credential resolver. */
|
|
264
|
+
export function resolveHomedir() {
|
|
265
|
+
return os.homedir();
|
|
266
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plane manifest: the durable source of lifecycle truth (BAPI-756).
|
|
3
|
+
*
|
|
4
|
+
* `plane status` and `plane down` run in a different process from the
|
|
5
|
+
* supervisor, so this file is the only thing they can trust. Three properties
|
|
6
|
+
* make that safe:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Strict parsing precedes every action.** A pid is never signalled, and a
|
|
9
|
+
* file is never replaced, until the whole record has validated. An
|
|
10
|
+
* attacker-controlled or corrupted manifest must not become a kill list.
|
|
11
|
+
* 2. **Ownership is atomic.** Claiming uses an exclusive create, so two
|
|
12
|
+
* successful preflights cannot both start spawning. A manifest that preflight
|
|
13
|
+
* classified as stale is re-validated while the claim is held, because the
|
|
14
|
+
* gap between "preflight looked" and "we are about to overwrite" is exactly
|
|
15
|
+
* where a competing plane can appear.
|
|
16
|
+
* 3. **Nothing secret is persisted.** The schema has no environment object, no
|
|
17
|
+
* argv, and no credential field, and unknown keys are rejected outright.
|
|
18
|
+
*/
|
|
19
|
+
import path from "path";
|
|
20
|
+
import { PLANE_MANIFEST_FILENAME, PLANE_MANIFEST_SCHEMA_VERSION, PLANE_RUNTIME_DIR, } from "./types.js";
|
|
21
|
+
/**
|
|
22
|
+
* Derive every runtime path beneath a validated repository root.
|
|
23
|
+
*
|
|
24
|
+
* Member log paths are a pure function of the fixed member name, so no
|
|
25
|
+
* externally supplied value — repository name, credential metadata, operator
|
|
26
|
+
* argument — can steer a write outside `.bridge/plane/`.
|
|
27
|
+
*/
|
|
28
|
+
export function getPlanePaths(repoRoot) {
|
|
29
|
+
const planeDir = path.join(repoRoot, ".bridge", "plane");
|
|
30
|
+
return {
|
|
31
|
+
planeDir,
|
|
32
|
+
manifestPath: path.join(planeDir, PLANE_MANIFEST_FILENAME),
|
|
33
|
+
logPathFor: (member) => path.join(planeDir, `${member}.log`),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Repository-relative log path recorded in the manifest for a member. */
|
|
37
|
+
export function relativeLogPathFor(member) {
|
|
38
|
+
return `${PLANE_RUNTIME_DIR}/${member}.log`;
|
|
39
|
+
}
|
|
40
|
+
const VALID_STATES = new Set([
|
|
41
|
+
"spawning",
|
|
42
|
+
"running",
|
|
43
|
+
"ready",
|
|
44
|
+
"exited",
|
|
45
|
+
]);
|
|
46
|
+
const MANIFEST_KEYS = new Set([
|
|
47
|
+
"schemaVersion",
|
|
48
|
+
"planeId",
|
|
49
|
+
"repoRoot",
|
|
50
|
+
"supervisorPid",
|
|
51
|
+
"supervisorPgid",
|
|
52
|
+
"createdAt",
|
|
53
|
+
"updatedAt",
|
|
54
|
+
"members",
|
|
55
|
+
]);
|
|
56
|
+
const MEMBER_KEYS = new Set([
|
|
57
|
+
"name",
|
|
58
|
+
"pid",
|
|
59
|
+
"state",
|
|
60
|
+
"exitCode",
|
|
61
|
+
"exitSignal",
|
|
62
|
+
"logPath",
|
|
63
|
+
]);
|
|
64
|
+
/** `server`, `worker`, or `executor-<positive integer>`. Nothing else. */
|
|
65
|
+
export function isPlaneMemberName(value) {
|
|
66
|
+
if (typeof value !== "string")
|
|
67
|
+
return false;
|
|
68
|
+
if (value === "server" || value === "worker")
|
|
69
|
+
return true;
|
|
70
|
+
return /^executor-[1-9][0-9]*$/.test(value);
|
|
71
|
+
}
|
|
72
|
+
function isPositiveInteger(value) {
|
|
73
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
74
|
+
}
|
|
75
|
+
function isIsoTimestamp(value) {
|
|
76
|
+
return typeof value === "string" && value.length > 0 && !Number.isNaN(Date.parse(value));
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Validate an untrusted parsed-JSON value into a manifest.
|
|
80
|
+
*
|
|
81
|
+
* Unknown keys are a hard rejection rather than an ignored extra: that is what
|
|
82
|
+
* makes "the manifest cannot carry a secret" a checkable property instead of a
|
|
83
|
+
* convention someone can quietly break.
|
|
84
|
+
*/
|
|
85
|
+
export function parsePlaneManifest(value) {
|
|
86
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
87
|
+
return { ok: false, error: "manifest is not an object" };
|
|
88
|
+
}
|
|
89
|
+
const record = value;
|
|
90
|
+
for (const key of Object.keys(record)) {
|
|
91
|
+
if (!MANIFEST_KEYS.has(key)) {
|
|
92
|
+
return { ok: false, error: `manifest has an unsupported field '${key}'` };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (record.schemaVersion !== PLANE_MANIFEST_SCHEMA_VERSION) {
|
|
96
|
+
return { ok: false, error: "manifest schema version is not supported" };
|
|
97
|
+
}
|
|
98
|
+
if (typeof record.planeId !== "string" || !/^[0-9a-f-]{8,}$/i.test(record.planeId)) {
|
|
99
|
+
return { ok: false, error: "manifest plane identity is missing or malformed" };
|
|
100
|
+
}
|
|
101
|
+
if (typeof record.repoRoot !== "string" || record.repoRoot.length === 0) {
|
|
102
|
+
return { ok: false, error: "manifest repository root is missing" };
|
|
103
|
+
}
|
|
104
|
+
if (!isPositiveInteger(record.supervisorPid)) {
|
|
105
|
+
return { ok: false, error: "manifest supervisor pid is not a positive integer" };
|
|
106
|
+
}
|
|
107
|
+
if (!isPositiveInteger(record.supervisorPgid)) {
|
|
108
|
+
return { ok: false, error: "manifest supervisor process-group id is not a positive integer" };
|
|
109
|
+
}
|
|
110
|
+
if (!isIsoTimestamp(record.createdAt) || !isIsoTimestamp(record.updatedAt)) {
|
|
111
|
+
return { ok: false, error: "manifest timestamps are missing or malformed" };
|
|
112
|
+
}
|
|
113
|
+
if (!Array.isArray(record.members) || record.members.length === 0) {
|
|
114
|
+
return { ok: false, error: "manifest members are missing" };
|
|
115
|
+
}
|
|
116
|
+
const members = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const raw of record.members) {
|
|
119
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
120
|
+
return { ok: false, error: "manifest member is not an object" };
|
|
121
|
+
}
|
|
122
|
+
const member = raw;
|
|
123
|
+
for (const key of Object.keys(member)) {
|
|
124
|
+
if (!MEMBER_KEYS.has(key)) {
|
|
125
|
+
return { ok: false, error: `manifest member has an unsupported field '${key}'` };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (!isPlaneMemberName(member.name)) {
|
|
129
|
+
return { ok: false, error: "manifest member name is not a recognized plane member" };
|
|
130
|
+
}
|
|
131
|
+
if (seen.has(member.name)) {
|
|
132
|
+
return { ok: false, error: `manifest lists member '${member.name}' more than once` };
|
|
133
|
+
}
|
|
134
|
+
seen.add(member.name);
|
|
135
|
+
if (member.pid !== null && !isPositiveInteger(member.pid)) {
|
|
136
|
+
return { ok: false, error: `manifest member '${member.name}' has an invalid pid` };
|
|
137
|
+
}
|
|
138
|
+
if (typeof member.state !== "string" || !VALID_STATES.has(member.state)) {
|
|
139
|
+
return { ok: false, error: `manifest member '${member.name}' has an invalid state` };
|
|
140
|
+
}
|
|
141
|
+
if (member.exitCode !== null && !Number.isSafeInteger(member.exitCode)) {
|
|
142
|
+
return { ok: false, error: `manifest member '${member.name}' has an invalid exit code` };
|
|
143
|
+
}
|
|
144
|
+
if (member.exitSignal !== null && typeof member.exitSignal !== "string") {
|
|
145
|
+
return { ok: false, error: `manifest member '${member.name}' has an invalid exit signal` };
|
|
146
|
+
}
|
|
147
|
+
// Containment is exact-match, not prefix-match: a `..` segment or an
|
|
148
|
+
// absolute path can never satisfy the one legal spelling for this member.
|
|
149
|
+
if (member.logPath !== relativeLogPathFor(member.name)) {
|
|
150
|
+
return { ok: false, error: `manifest member '${member.name}' has an unexpected log path` };
|
|
151
|
+
}
|
|
152
|
+
members.push({
|
|
153
|
+
name: member.name,
|
|
154
|
+
pid: member.pid,
|
|
155
|
+
state: member.state,
|
|
156
|
+
exitCode: member.exitCode,
|
|
157
|
+
exitSignal: member.exitSignal,
|
|
158
|
+
logPath: member.logPath,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
manifest: {
|
|
164
|
+
schemaVersion: PLANE_MANIFEST_SCHEMA_VERSION,
|
|
165
|
+
planeId: record.planeId,
|
|
166
|
+
repoRoot: record.repoRoot,
|
|
167
|
+
supervisorPid: record.supervisorPid,
|
|
168
|
+
supervisorPgid: record.supervisorPgid,
|
|
169
|
+
createdAt: record.createdAt,
|
|
170
|
+
updatedAt: record.updatedAt,
|
|
171
|
+
members,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Read and validate the manifest, distinguishing every outcome.
|
|
177
|
+
*
|
|
178
|
+
* `malformed` and `unreadable` deliberately carry no pid: a record that failed
|
|
179
|
+
* validation must not hand the caller an actionable kill target.
|
|
180
|
+
*/
|
|
181
|
+
export async function readPlaneManifest(repoRoot, fs) {
|
|
182
|
+
const { manifestPath } = getPlanePaths(repoRoot);
|
|
183
|
+
let raw;
|
|
184
|
+
try {
|
|
185
|
+
raw = await fs.readFile(manifestPath);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
const code = err?.code;
|
|
189
|
+
if (code === "ENOENT")
|
|
190
|
+
return { kind: "missing" };
|
|
191
|
+
return { kind: "unreadable", error: typeof code === "string" ? code : "read failed" };
|
|
192
|
+
}
|
|
193
|
+
let parsed;
|
|
194
|
+
try {
|
|
195
|
+
parsed = JSON.parse(raw);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return { kind: "malformed", error: "manifest is not valid JSON" };
|
|
199
|
+
}
|
|
200
|
+
const result = parsePlaneManifest(parsed);
|
|
201
|
+
if (!result.ok)
|
|
202
|
+
return { kind: "malformed", error: result.error };
|
|
203
|
+
return { kind: "valid", manifest: result.manifest };
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Signal-zero liveness.
|
|
207
|
+
*
|
|
208
|
+
* `EPERM` means the process exists but belongs to someone else — evidence of
|
|
209
|
+
* life, never of death. Any other error is `unknown`, which callers must not
|
|
210
|
+
* collapse into "safely gone".
|
|
211
|
+
*/
|
|
212
|
+
export function isProcessAlive(pid, kill) {
|
|
213
|
+
if (!isPositiveInteger(pid))
|
|
214
|
+
return "unknown";
|
|
215
|
+
try {
|
|
216
|
+
kill(pid, 0);
|
|
217
|
+
return "alive";
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
const code = err?.code;
|
|
221
|
+
if (code === "ESRCH")
|
|
222
|
+
return "dead";
|
|
223
|
+
if (code === "EPERM")
|
|
224
|
+
return "alive";
|
|
225
|
+
return "unknown";
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** Combine recorded state with a fresh liveness probe. Never mutates anything. */
|
|
229
|
+
export function probeManifestMembers(manifest, proc) {
|
|
230
|
+
return manifest.members.map((member) => {
|
|
231
|
+
if (member.state === "exited" || member.pid === null) {
|
|
232
|
+
return { member, liveness: "dead" };
|
|
233
|
+
}
|
|
234
|
+
return { member, liveness: proc.isAlive(member.pid) };
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/** True when the supervisor or any recorded member is alive or unaccounted for. */
|
|
238
|
+
export function manifestHasLiveProcess(manifest, proc) {
|
|
239
|
+
if (proc.isAlive(manifest.supervisorPid) !== "dead")
|
|
240
|
+
return true;
|
|
241
|
+
return probeManifestMembers(manifest, proc).some((probe) => probe.liveness !== "dead");
|
|
242
|
+
}
|
|
243
|
+
/** Serialize deterministically: 2-space indent plus a trailing newline. */
|
|
244
|
+
export function formatPlaneManifest(manifest) {
|
|
245
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Persist a state transition through a same-directory temporary file and an
|
|
249
|
+
* atomic rename, so a crash mid-write can never leave a half-written manifest
|
|
250
|
+
* that a subsequent `plane down` would read as a kill list.
|
|
251
|
+
*/
|
|
252
|
+
export async function writePlaneManifest(manifest, fs) {
|
|
253
|
+
const { manifestPath } = getPlanePaths(manifest.repoRoot);
|
|
254
|
+
const tempPath = `${manifestPath}.${manifest.planeId}.tmp`;
|
|
255
|
+
await fs.writeFile(tempPath, formatPlaneManifest(manifest));
|
|
256
|
+
await fs.rename(tempPath, manifestPath);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Remove the manifest, but only when it still belongs to `planeId`.
|
|
260
|
+
*
|
|
261
|
+
* The ownership check is what stops a slow shutdown from deleting the manifest
|
|
262
|
+
* of a *newer* plane that has since claimed the same path.
|
|
263
|
+
*/
|
|
264
|
+
export async function clearPlaneManifest(repoRoot, planeId, fs) {
|
|
265
|
+
const read = await readPlaneManifest(repoRoot, fs);
|
|
266
|
+
if (read.kind === "missing")
|
|
267
|
+
return { ok: true, removed: false };
|
|
268
|
+
if (read.kind !== "valid") {
|
|
269
|
+
return {
|
|
270
|
+
ok: false,
|
|
271
|
+
reason: "ambiguous",
|
|
272
|
+
message: `refusing to remove an unvalidated manifest (${read.error})`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
if (read.manifest.planeId !== planeId) {
|
|
276
|
+
return {
|
|
277
|
+
ok: false,
|
|
278
|
+
reason: "not-owned",
|
|
279
|
+
message: "refusing to remove a manifest owned by a different plane",
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
await fs.unlink(getPlanePaths(repoRoot).manifestPath);
|
|
284
|
+
return { ok: true, removed: true };
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
const code = err?.code;
|
|
288
|
+
if (code === "ENOENT")
|
|
289
|
+
return { ok: true, removed: false };
|
|
290
|
+
return {
|
|
291
|
+
ok: false,
|
|
292
|
+
reason: "error",
|
|
293
|
+
message: `manifest could not be removed (${typeof code === "string" ? code : "unlink failed"})`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Take exclusive ownership of `.bridge/plane/plane.json`.
|
|
299
|
+
*
|
|
300
|
+
* The exclusive create is the mutual-exclusion primitive: whoever wins it owns
|
|
301
|
+
* the plane. On `EEXIST` the existing record is re-read and re-probed **while
|
|
302
|
+
* the loser is still holding nothing** — a stale classification from preflight
|
|
303
|
+
* is never trusted to authorize a replacement, because a competing plane can
|
|
304
|
+
* have started in between.
|
|
305
|
+
*/
|
|
306
|
+
export async function claimPlaneManifest(params) {
|
|
307
|
+
const { manifest, fs, proc } = params;
|
|
308
|
+
const { planeDir, manifestPath } = getPlanePaths(manifest.repoRoot);
|
|
309
|
+
try {
|
|
310
|
+
await fs.mkdir(planeDir);
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
const code = err?.code;
|
|
314
|
+
if (code !== "EEXIST") {
|
|
315
|
+
return {
|
|
316
|
+
ok: false,
|
|
317
|
+
reason: "error",
|
|
318
|
+
message: `could not create ${PLANE_RUNTIME_DIR}/ (${typeof code === "string" ? code : "mkdir failed"})`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const serialized = formatPlaneManifest(manifest);
|
|
323
|
+
try {
|
|
324
|
+
await fs.createExclusive(manifestPath, serialized);
|
|
325
|
+
return { ok: true, manifest };
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
const code = err?.code;
|
|
329
|
+
if (code !== "EEXIST") {
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
reason: "error",
|
|
333
|
+
message: `could not claim the plane manifest (${typeof code === "string" ? code : "create failed"})`,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Something is already there. Re-validate under the claim attempt.
|
|
338
|
+
const existing = await readPlaneManifest(manifest.repoRoot, fs);
|
|
339
|
+
if (existing.kind === "missing") {
|
|
340
|
+
// It vanished between our create and our read — another claimant is
|
|
341
|
+
// actively racing. Refuse rather than retry into an unbounded loop.
|
|
342
|
+
return {
|
|
343
|
+
ok: false,
|
|
344
|
+
reason: "conflict",
|
|
345
|
+
message: "another `plane up` is claiming the manifest right now",
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
if (existing.kind !== "valid") {
|
|
349
|
+
return {
|
|
350
|
+
ok: false,
|
|
351
|
+
reason: "ambiguous",
|
|
352
|
+
message: `an existing ${PLANE_RUNTIME_DIR}/${PLANE_MANIFEST_FILENAME} could not be validated ` +
|
|
353
|
+
`(${existing.error}); refusing to signal or replace it — inspect and remove it by hand`,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
if (manifestHasLiveProcess(existing.manifest, proc)) {
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
reason: "live-plane",
|
|
360
|
+
message: `a plane is already running (supervisor pid ${existing.manifest.supervisorPid}); ` +
|
|
361
|
+
"run `plane status` to inspect it, or `plane down` to wind it down",
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
// Confirmed dead under the claim: replace it atomically.
|
|
365
|
+
try {
|
|
366
|
+
await writePlaneManifest(manifest, fs);
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
const code = err?.code;
|
|
370
|
+
return {
|
|
371
|
+
ok: false,
|
|
372
|
+
reason: "error",
|
|
373
|
+
message: `could not replace the stale plane manifest (${typeof code === "string" ? code : "write failed"})`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
return { ok: true, manifest };
|
|
377
|
+
}
|