@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.
Files changed (109) hide show
  1. package/README.md +490 -366
  2. package/build/agent-capabilities/probe-context.js +8 -1
  3. package/build/agent-capabilities/probes.js +7 -1
  4. package/build/agents.generated.js +1 -1
  5. package/build/base-url.js +79 -0
  6. package/build/bridge-api-urls.js +9 -0
  7. package/build/chain-orchestrator.js +93 -15
  8. package/build/claude-review-workflow.js +264 -0
  9. package/build/claude-user-config-doctor.js +317 -0
  10. package/build/cli-release.js +53 -0
  11. package/build/commands.generated.js +6 -5
  12. package/build/conductor/bridge-api-client.js +393 -4
  13. package/build/conductor/deny-enforcement-preflight.js +1 -0
  14. package/build/conductor/done-gate.js +44 -5
  15. package/build/conductor/epic-reconcile.js +6 -0
  16. package/build/conductor/install-doctor.js +462 -0
  17. package/build/conductor-bin.js +3 -3
  18. package/build/conductor-bundle-artifacts.js +37 -15
  19. package/build/credential-store.js +205 -4
  20. package/build/direct-ticket-tools.js +70 -0
  21. package/build/doctor.js +473 -81
  22. package/build/executor/cli.js +83 -6
  23. package/build/executor/credentials.js +39 -11
  24. package/build/executor/deps.js +32 -1
  25. package/build/executor/env.js +71 -28
  26. package/build/executor/heartbeat.js +138 -17
  27. package/build/executor/http-client.js +49 -8
  28. package/build/executor/index.js +4 -0
  29. package/build/executor/job-errors.js +4 -0
  30. package/build/executor/job-runner.js +530 -20
  31. package/build/executor/observation.js +130 -0
  32. package/build/executor/permissions.js +114 -8
  33. package/build/executor/preflight.js +127 -8
  34. package/build/executor/prompt-spec.js +51 -0
  35. package/build/executor/runner.js +23 -2
  36. package/build/executor/service-unit.js +876 -0
  37. package/build/executor/test-clock.js +75 -3
  38. package/build/executor/types.js +4 -18
  39. package/build/executor/worker-command.js +73 -12
  40. package/build/executor/worker-config-isolation.js +287 -0
  41. package/build/executor/worker-finalization.js +68 -14
  42. package/build/executor/worktree.js +46 -4
  43. package/build/index.js +1039 -237
  44. package/build/init.js +503 -111
  45. package/build/install-bridge-conductor.js +491 -0
  46. package/build/install-bridge.js +1188 -247
  47. package/build/install-reexec.js +233 -0
  48. package/build/launcher-config-inspection.js +351 -0
  49. package/build/mcp-host-config.js +11 -1
  50. package/build/mcp-install-state.js +32 -0
  51. package/build/mcp-invoke.js +49 -6
  52. package/build/mcp-provisioning.js +52 -13
  53. package/build/mcp-registration-doctor.js +14 -5
  54. package/build/notifications.js +553 -0
  55. package/build/pipeline-orchestrator.js +146 -4
  56. package/build/pipeline-utils.js +3 -0
  57. package/build/pipelines.generated.js +32 -13
  58. package/build/plan-execution-ledger.js +550 -0
  59. package/build/plan-phase-routing.js +272 -0
  60. package/build/plane/alembic-head.js +110 -0
  61. package/build/plane/build-freshness.js +167 -0
  62. package/build/plane/cli.js +480 -0
  63. package/build/plane/defaults.js +266 -0
  64. package/build/plane/manifest.js +377 -0
  65. package/build/plane/member-logs.js +147 -0
  66. package/build/plane/member-roster.js +147 -0
  67. package/build/plane/preflight.js +289 -0
  68. package/build/plane/shutdown.js +195 -0
  69. package/build/plane/status.js +125 -0
  70. package/build/plane/supervisor.js +569 -0
  71. package/build/plane/test-fakes.js +156 -0
  72. package/build/plane/types.js +75 -0
  73. package/build/readme.generated.js +1 -1
  74. package/build/run-unit-tests-launcher.js +259 -0
  75. package/build/setup-epic.js +772 -28
  76. package/build/sfcc/log-gate.js +38 -11
  77. package/build/sfcc/log-query.js +55 -15
  78. package/build/sfcc/ocapi-shape.js +51 -14
  79. package/build/sfcc/output.js +41 -11
  80. package/build/sfcc/permissions.js +24 -2
  81. package/build/sfcc/read-projection.js +181 -0
  82. package/build/sfcc/read-result.js +158 -0
  83. package/build/sfcc/reads-custom-object-def.js +29 -18
  84. package/build/sfcc/reads-site-preference.js +75 -29
  85. package/build/sfcc/reads-system-object.js +40 -34
  86. package/build/sfcc/sfcc-result.js +106 -0
  87. package/build/sfcc/tool-wrapper.js +56 -13
  88. package/build/sfcc/write-grants.js +45 -22
  89. package/build/sfcc/write-guard.js +21 -13
  90. package/build/sfcc/write-result.js +61 -14
  91. package/build/sfcc/write-tool-common.js +126 -32
  92. package/build/sfcc/writes-system-object.js +11 -50
  93. package/build/start-tickets-prereqs.js +129 -0
  94. package/build/start-tickets.js +17 -13
  95. package/build/ticket-backend-metadata.js +59 -0
  96. package/build/ticket-key-utils.js +92 -0
  97. package/build/tool-error-envelope.js +71 -0
  98. package/build/tool-surface-gating.js +72 -0
  99. package/build/update-status.js +102 -0
  100. package/build/upgrade-advice.js +47 -0
  101. package/build/upgrade-cli.js +412 -110
  102. package/build/version.generated.js +1 -1
  103. package/build/worktree-core.js +73 -0
  104. package/docs/CONDUCTOR.md +132 -6
  105. package/docs/install/mcp-tool-integrations.md +29 -21
  106. package/package.json +9 -6
  107. package/pipelines/implement-ticket.json +19 -4
  108. package/build/conductor/supervisor-judgment-python.js +0 -141
  109. 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
+ }