@bridge_gpt/mcp-server 0.2.66 → 0.2.68

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 (171) hide show
  1. package/README.md +421 -83
  2. package/build/agent-capabilities/probes.js +15 -5
  3. package/build/agent-launchers/claude-executor-adapter.js +70 -16
  4. package/build/agent-launchers/executor-adapter.js +7 -4
  5. package/build/agents.generated.js +22 -2
  6. package/build/bitbucket-workspace-remote.js +136 -0
  7. package/build/bootstrap-project-identity.js +24 -0
  8. package/build/bounded-wait.js +67 -10
  9. package/build/bridge-permissions.js +829 -0
  10. package/build/claude-settings.js +18 -0
  11. package/build/cli-help.js +21 -0
  12. package/build/command-assets-doctor.js +24 -70
  13. package/build/command-provisioning.js +29 -5
  14. package/build/commands.generated.js +12 -12
  15. package/build/conductor/bridge-api-client.js +857 -1
  16. package/build/conductor/bring-up-facts.js +1 -1
  17. package/build/conductor/command-drift-production.js +92 -0
  18. package/build/conductor/command-drift.js +89 -0
  19. package/build/conductor/doctor.js +72 -29
  20. package/build/conductor/epic-branch-ci-inspection.js +1375 -0
  21. package/build/conductor/epic-implementer-cli.js +98 -28
  22. package/build/conductor/errors.js +4 -3
  23. package/build/conductor/gate-discovery.js +890 -0
  24. package/build/conductor/guided-repair.js +6 -2
  25. package/build/conductor/hook-runtime-qualification.js +1 -1
  26. package/build/conductor/install-doctor.js +15 -3
  27. package/build/conductor/json-output.js +63 -0
  28. package/build/conductor/merge-execution-contract.js +27 -8
  29. package/build/conductor/merge-execution-eligibility.js +3 -2
  30. package/build/conductor/pr-ci-producer.js +1 -1
  31. package/build/conductor/readiness-cli.js +113 -12
  32. package/build/conductor/readiness-sections.js +410 -9
  33. package/build/conductor/readiness.js +388 -33
  34. package/build/conductor/recovery-cli.js +310 -12
  35. package/build/conductor/recovery-operations.js +167 -3
  36. package/build/conductor/repair-contract.js +54 -3
  37. package/build/conductor/repository-workflow-remedy.js +125 -0
  38. package/build/conductor/tools.js +2 -2
  39. package/build/conductor-bundle-artifacts.js +8 -0
  40. package/build/connect-bitbucket-api.js +41 -6
  41. package/build/connect-bitbucket.js +106 -33
  42. package/build/credential-store.js +207 -47
  43. package/build/docs.generated.js +62 -3
  44. package/build/doctor.js +92 -1
  45. package/build/drive-epic-key.js +6 -5
  46. package/build/drive-epic.js +129 -61
  47. package/build/epic-coherence-input.js +2 -1
  48. package/build/epic-coherence-input.test-fixtures.js +4 -3
  49. package/build/epic-implementer-bin.js +1379 -28
  50. package/build/epic-implementer-bundle-cli.js +2 -2
  51. package/build/epic-workarounds-path.js +39 -0
  52. package/build/executor/cli.js +33 -2
  53. package/build/executor/completion-contract.js +66 -10
  54. package/build/executor/completion-supervision.js +4 -0
  55. package/build/executor/deps.js +4 -1
  56. package/build/executor/env.js +19 -0
  57. package/build/executor/gate-qualification-job.js +499 -0
  58. package/build/executor/guarded-fast-forward-push.js +129 -0
  59. package/build/executor/heartbeat.js +1 -1
  60. package/build/executor/http-client.js +20 -0
  61. package/build/executor/integration-job.js +122 -8
  62. package/build/executor/job-errors.js +33 -1
  63. package/build/executor/job-runner.js +813 -87
  64. package/build/executor/likely-tests.js +290 -0
  65. package/build/executor/live-worker-registry.js +1 -1
  66. package/build/executor/observation.js +37 -3
  67. package/build/executor/packaged-recipe.js +106 -0
  68. package/build/executor/permissions.js +115 -28
  69. package/build/executor/platform.js +1 -1
  70. package/build/executor/preflight.js +112 -26
  71. package/build/executor/process-containment.js +36 -12
  72. package/build/executor/process.js +15 -7
  73. package/build/executor/prompt-spec.js +33 -1
  74. package/build/executor/recently-failed-tests-file.js +71 -0
  75. package/build/executor/reconcile-overlap-job.js +41 -23
  76. package/build/executor/required-verification.js +1 -1
  77. package/build/executor/required-work-ledger.js +111 -18
  78. package/build/executor/resume-pre-spawn.js +2 -0
  79. package/build/executor/retirement.js +69 -0
  80. package/build/executor/runner.js +122 -13
  81. package/build/executor/service-unit.js +7 -1
  82. package/build/executor/test-clock.js +15 -0
  83. package/build/executor/types.js +32 -0
  84. package/build/executor/viewer-tabs.js +58 -8
  85. package/build/executor/worker-command.js +23 -7
  86. package/build/executor/worker-diagnostics.js +37 -0
  87. package/build/executor/worker-finalization.js +113 -34
  88. package/build/executor/worker-guard-hook.js +316 -58
  89. package/build/executor/worker-log.js +4 -3
  90. package/build/executor/worker-verification-guidance.js +37 -0
  91. package/build/executor/worktree.js +166 -8
  92. package/build/first-build-advisory.js +97 -0
  93. package/build/generated-assets-advisory.js +14 -0
  94. package/build/generated-assets-catalog.js +66 -0
  95. package/build/generated-assets-context.js +46 -0
  96. package/build/generated-assets-dependencies.js +26 -0
  97. package/build/generated-assets-doctor.js +50 -0
  98. package/build/generated-assets-health.js +65 -0
  99. package/build/generated-assets-host.js +17 -0
  100. package/build/generated-assets-inventory.js +17 -0
  101. package/build/generated-assets-paths.js +60 -0
  102. package/build/generated-assets-publisher.js +133 -0
  103. package/build/generated-assets-remedy.js +25 -0
  104. package/build/generated-assets-repair.js +72 -0
  105. package/build/generated-assets-session.js +71 -0
  106. package/build/generated-assets-test-support.js +35 -0
  107. package/build/index.js +1224 -1054
  108. package/build/init.js +54 -177
  109. package/build/install-bridge-conductor-gate.js +1372 -0
  110. package/build/install-bridge-conductor.js +296 -41
  111. package/build/install-bridge.js +547 -40
  112. package/build/mcp-install-state.js +50 -4
  113. package/build/merge-workflow-install.js +3 -4
  114. package/build/phase-context-git-probe.js +68 -0
  115. package/build/phase-result-artifacts.js +173 -5
  116. package/build/pipeline-utils.js +6 -2
  117. package/build/pipelines.generated.js +34 -160
  118. package/build/plan-execution-ledger.js +2 -32
  119. package/build/plane/alembic-head.js +12 -3
  120. package/build/plane/base-worktree-guard.js +147 -0
  121. package/build/plane/cli.js +217 -17
  122. package/build/plane/code-snapshot.js +468 -0
  123. package/build/plane/defaults.js +48 -3
  124. package/build/plane/endpoint.js +41 -0
  125. package/build/plane/manifest.js +136 -2
  126. package/build/plane/member-roster.js +94 -19
  127. package/build/plane/preflight.js +83 -2
  128. package/build/plane/process-cwd.js +67 -0
  129. package/build/plane/restart.js +542 -0
  130. package/build/plane/shutdown.js +31 -0
  131. package/build/plane/status.js +143 -8
  132. package/build/plane/supervisor.js +586 -179
  133. package/build/plane/types.js +54 -2
  134. package/build/printed-remedy-commands.js +52 -0
  135. package/build/readiness-check.js +1 -1
  136. package/build/readme.generated.js +1 -1
  137. package/build/repair-assets.js +93 -0
  138. package/build/review-generation.js +22 -0
  139. package/build/run-unit-tests-launcher.js +130 -49
  140. package/build/secret-redaction.js +33 -0
  141. package/build/setup-epic.js +152 -22
  142. package/build/sfcc/config.js +9 -0
  143. package/build/sfcc/credentials.js +100 -38
  144. package/build/sfcc/hostname.js +67 -0
  145. package/build/sfcc/permissions.js +17 -13
  146. package/build/sfcc/register.js +1 -1
  147. package/build/sfcc/tool-wrapper.js +39 -43
  148. package/build/sfcc/write-grants.js +17 -11
  149. package/build/sfcc/write-result.js +4 -3
  150. package/build/sfcc/writes-site-preference.js +20 -8
  151. package/build/start-tickets-prereqs.js +70 -16
  152. package/build/start-tickets.js +208 -36
  153. package/build/tool-error-envelope.js +61 -0
  154. package/build/tool-surface-gating.js +36 -0
  155. package/build/upgrade-cli.js +55 -1
  156. package/build/version.generated.js +3 -3
  157. package/build/visual-diff.js +22 -1
  158. package/build/worker-guard-hook-bin.js +4 -6
  159. package/build/worker-guard-hook-main.js +47 -0
  160. package/build/worktree-core.js +145 -6
  161. package/docs/EPIC-IMPLEMENTER-INSTALL.md +867 -0
  162. package/docs/EPIC-IMPLEMENTER.md +728 -0
  163. package/docs/install/mcp-tool-integrations.md +24 -15
  164. package/docs/install/sfcc-integration.md +12 -4
  165. package/package.json +3 -3
  166. package/pipelines/greenfield-setup.json +4 -128
  167. package/pipelines/implement-ticket.json +3 -2
  168. package/pipelines/plan-epic.json +0 -5
  169. package/pipelines/review-ticket.json +1 -1
  170. package/docs/CONDUCTOR.md +0 -605
  171. package/pipelines/idea-to-pr.json +0 -49
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Plane-owned detached code snapshots (BAPI-1362).
3
+ *
4
+ * ## Why a plane needs its own checkout
5
+ *
6
+ * The plane's Python members — the server, the reconciler worker, and the
7
+ * dead-man observer — import application code from their working directory, and
8
+ * the working directory used to be the operator's own checkout. That checkout
9
+ * moves: an operator who fast-forwards `main`, or a `start-tickets` refresh, changes
10
+ * the files under a live plane. `worker.py` fingerprints its code and exits 75
11
+ * when it sees the change, so a routine `git pull` brought the plane's
12
+ * reconciler down in the middle of a bound run.
13
+ *
14
+ * A snapshot gives those members a code tree nobody else moves: a Git worktree,
15
+ * detached at one exact commit, at `.bridge/plane/snapshots/<full-sha>`. It is a
16
+ * worktree rather than a copy so it costs a checkout rather than a clone, and
17
+ * Git itself records that it exists.
18
+ *
19
+ * ## The safety posture
20
+ *
21
+ * This module creates, locks, and removes directories, so every rule below is
22
+ * about NOT doing so on a guess:
23
+ *
24
+ * - **Identity is checked, never assumed.** A snapshot is reused only when Git
25
+ * registers it at the expected path, detached, at the expected SHA, AND a read
26
+ * of the checkout itself reports that SHA. A directory that merely has the
27
+ * right name is not evidence.
28
+ * - **Nothing is repointed.** A registration at the right path but the wrong
29
+ * commit is refused. Resetting or re-checking-out a worktree that a live plane
30
+ * may be importing from would change its code in place, which is the exact
31
+ * failure this module exists to prevent.
32
+ * - **Ambiguity refuses.** An existing directory whose ownership cannot be
33
+ * established, a symlink, a path that escapes `.bridge/plane/snapshots/`, or a
34
+ * registration inspection that failed — all stop with a named reason, and none
35
+ * deletes, prunes, or overwrites.
36
+ * - **Removal needs positive proof.** An obsolete snapshot is removed only when
37
+ * it is unlocked AND no live, or inconclusively live, plane references it, and
38
+ * only through a non-forced `git worktree remove`, which itself refuses a dirty
39
+ * tree.
40
+ * - **A lock outlives a doubt.** A snapshot is unlocked only after a wind-down
41
+ * confirmed every process gone and the manifest cleared. A failed, partial, or
42
+ * unclear shutdown leaves it locked — the conservative direction, because a
43
+ * leaked lock costs a few megabytes and a wrongly released one lets the
44
+ * directory be pruned from under a running plane.
45
+ *
46
+ * All I/O arrives through {@link CodeSnapshotDeps}, so the unit tests never run
47
+ * real Git. Messages are fixed prose plus a path and a SHA, never raw command
48
+ * output: the exec seam reports an error CODE only, and this module does not
49
+ * widen that.
50
+ */
51
+ import path from "path";
52
+ import { getPlanePaths, isFullCommitSha, manifestHasLiveProcess, planeManifestSnapshot, } from "./manifest.js";
53
+ /** Budget for `git worktree add`, which checks out the whole tree. */
54
+ export const SNAPSHOT_CREATE_TIMEOUT_MS = 180_000;
55
+ /** The lock reason prefix. Identifies a lock this module took. */
56
+ export const SNAPSHOT_LOCK_REASON_PREFIX = "bridge-plane";
57
+ /**
58
+ * Parse `git worktree list --porcelain`.
59
+ *
60
+ * Blocks are separated by a blank line; each starts with `worktree <path>`. A
61
+ * keyword this parser does not know is ignored rather than fatal, so a newer Git
62
+ * that adds one cannot turn every bring-up into a refusal.
63
+ */
64
+ export function parseWorktreeList(output) {
65
+ const registrations = [];
66
+ let current = null;
67
+ for (const rawLine of output.split(/\r?\n/)) {
68
+ if (rawLine.length === 0) {
69
+ if (current)
70
+ registrations.push(current);
71
+ current = null;
72
+ continue;
73
+ }
74
+ if (rawLine.startsWith("worktree ")) {
75
+ if (current)
76
+ registrations.push(current);
77
+ current = {
78
+ path: rawLine.slice("worktree ".length),
79
+ head: null,
80
+ detached: false,
81
+ locked: false,
82
+ lockReason: null,
83
+ prunable: false,
84
+ };
85
+ continue;
86
+ }
87
+ if (!current)
88
+ continue;
89
+ if (rawLine.startsWith("HEAD "))
90
+ current.head = rawLine.slice("HEAD ".length).trim();
91
+ else if (rawLine === "detached")
92
+ current.detached = true;
93
+ else if (rawLine === "locked" || rawLine.startsWith("locked ")) {
94
+ current.locked = true;
95
+ current.lockReason = rawLine.length > "locked".length ? rawLine.slice("locked ".length) : null;
96
+ }
97
+ else if (rawLine === "prunable" || rawLine.startsWith("prunable "))
98
+ current.prunable = true;
99
+ }
100
+ if (current)
101
+ registrations.push(current);
102
+ return registrations;
103
+ }
104
+ /** Resolve the operator checkout's `HEAD` to a full commit id, or null. */
105
+ async function resolveHeadSha(repoRoot, deps) {
106
+ const result = await deps.execFile("git", ["rev-parse", "--verify", "HEAD^{commit}"], {
107
+ cwd: repoRoot,
108
+ });
109
+ if (!result.ok)
110
+ return null;
111
+ const sha = result.stdout.trim();
112
+ return isFullCommitSha(sha) ? sha : null;
113
+ }
114
+ /** The commit a snapshot checkout reports as its own `HEAD`, or null. */
115
+ async function readCheckoutSha(snapshotPath, deps) {
116
+ const result = await deps.execFile("git", ["rev-parse", "--verify", "HEAD^{commit}"], {
117
+ cwd: snapshotPath,
118
+ });
119
+ if (!result.ok)
120
+ return null;
121
+ const sha = result.stdout.trim();
122
+ return isFullCommitSha(sha) ? sha : null;
123
+ }
124
+ async function listRegistrations(repoRoot, deps) {
125
+ const result = await deps.execFile("git", ["worktree", "list", "--porcelain"], { cwd: repoRoot });
126
+ if (!result.ok)
127
+ return null;
128
+ return parseWorktreeList(result.stdout);
129
+ }
130
+ /** Realpath that resolves to null instead of throwing. */
131
+ async function tryRealpath(filePath, deps) {
132
+ try {
133
+ return await deps.fs.realpath(filePath);
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ /**
140
+ * The snapshot path as Git REGISTERS it.
141
+ *
142
+ * `git worktree list` reports real paths, so a repository reached through a
143
+ * symlinked prefix (`/tmp` → `/private/tmp` is the everyday case) registers its
144
+ * snapshots under the resolved prefix. The lexical path in the manifest is the
145
+ * contract; this is the same location spelled the way Git spells it.
146
+ */
147
+ async function registeredSnapshotPath(repoRoot, sha, deps) {
148
+ const realRoot = await tryRealpath(repoRoot, deps);
149
+ if (realRoot === null)
150
+ return null;
151
+ return getPlanePaths(realRoot).snapshotPathFor(sha);
152
+ }
153
+ function fail(reason, message) {
154
+ return { ok: false, reason, message };
155
+ }
156
+ /**
157
+ * Does a live, or inconclusively live, plane reference this snapshot directory?
158
+ *
159
+ * `in-use` and `unknown` are both "do not touch": the conservative reading of a
160
+ * probe that could not answer is that something might be using the directory.
161
+ */
162
+ async function snapshotMayBeInUse(repoRoot, snapshotPath, deps) {
163
+ const read = await deps.readManifest(repoRoot);
164
+ if (read.kind === "missing")
165
+ return "free";
166
+ if (read.kind !== "valid")
167
+ return "unknown";
168
+ const pinned = planeManifestSnapshot(read.manifest);
169
+ if (pinned.kind !== "pinned" || pinned.snapshot.path !== snapshotPath)
170
+ return "free";
171
+ return manifestHasLiveProcess(read.manifest, deps.proc) ? "in-use" : "free";
172
+ }
173
+ /**
174
+ * Prepare the snapshot for the operator checkout's current commit.
175
+ *
176
+ * Resolves `HEAD` once, to a full SHA, and works on the deterministic location
177
+ * for that SHA. Create-or-reuse, verify, then best-effort prune of obsolete
178
+ * snapshots. It does NOT lock: locking is a separate step the launcher takes once
179
+ * the manifest claim has succeeded, so a bring-up that fails a later preflight
180
+ * check never leaves a lock behind for a plane that did not start.
181
+ */
182
+ export async function prepareCodeSnapshot(repoRoot, deps) {
183
+ const sha = await resolveHeadSha(repoRoot, deps);
184
+ if (sha === null) {
185
+ return fail("head-unresolved", "the operator checkout's HEAD could not be resolved to a full commit id, so no code snapshot can be pinned");
186
+ }
187
+ const planePaths = getPlanePaths(repoRoot);
188
+ const snapshotPath = planePaths.snapshotPathFor(sha);
189
+ const snapshot = { path: snapshotPath, sha };
190
+ try {
191
+ await deps.fs.mkdir(planePaths.snapshotsDir);
192
+ }
193
+ catch {
194
+ return fail("unsafe-location", "the snapshot directory under .bridge/plane/ could not be created");
195
+ }
196
+ // The directory that will hold every snapshot must be what it says it is. A
197
+ // symlink here would send `git worktree add` — and, later, removal — to
198
+ // wherever it points.
199
+ const realSnapshotsDir = await tryRealpath(planePaths.snapshotsDir, deps);
200
+ const realRoot = await tryRealpath(repoRoot, deps);
201
+ if (realSnapshotsDir === null ||
202
+ realRoot === null ||
203
+ realSnapshotsDir !== getPlanePaths(realRoot).snapshotsDir) {
204
+ return fail("unsafe-location", "the snapshot directory under .bridge/plane/ resolves outside the repository, so no snapshot was created");
205
+ }
206
+ const registrations = await listRegistrations(repoRoot, deps);
207
+ if (registrations === null) {
208
+ return fail("registration-unreadable", "Git's worktree registrations could not be read, so snapshot ownership cannot be established");
209
+ }
210
+ const registeredPath = await registeredSnapshotPath(repoRoot, sha, deps);
211
+ if (registeredPath === null) {
212
+ return fail("unsafe-location", "the repository root could not be resolved");
213
+ }
214
+ const registration = registrations.find((entry) => entry.path === registeredPath) ?? null;
215
+ const stat = await lstatOrNull(snapshotPath, deps);
216
+ if (stat === "error") {
217
+ return fail("ownership-ambiguous", "the snapshot path could not be inspected");
218
+ }
219
+ let reused = false;
220
+ if (stat !== null) {
221
+ // Something is on disk at the snapshot path.
222
+ if (stat.isSymbolicLink || !stat.isDirectory) {
223
+ return fail("ownership-ambiguous", `${relativeSnapshotLabel(sha)} exists but is a symlink or not a directory, so it was left alone`);
224
+ }
225
+ if (registration === null) {
226
+ return fail("ownership-ambiguous", `${relativeSnapshotLabel(sha)} exists but Git does not register it as a worktree, so its ownership cannot be established and it was left alone`);
227
+ }
228
+ if (!registration.detached || registration.head !== sha) {
229
+ return fail("identity-mismatch", `${relativeSnapshotLabel(sha)} is registered at a different commit than ${sha.slice(0, 12)}; it was not reset or repointed`);
230
+ }
231
+ const real = await tryRealpath(snapshotPath, deps);
232
+ const checkoutSha = await readCheckoutSha(snapshotPath, deps);
233
+ if (real !== registeredPath || checkoutSha !== sha) {
234
+ return fail("identity-mismatch", `${relativeSnapshotLabel(sha)} does not report commit ${sha.slice(0, 12)}; it was not reset or repointed`);
235
+ }
236
+ reused = true;
237
+ }
238
+ else {
239
+ if (registration !== null) {
240
+ // Registered, but the directory is gone: a crash left a stale registration.
241
+ // Repair it narrowly — and only with proof nothing is using it.
242
+ const inUse = await snapshotMayBeInUse(repoRoot, snapshotPath, deps);
243
+ if (inUse !== "free") {
244
+ return fail("in-use", `${relativeSnapshotLabel(sha)} has a stale Git registration but a plane may still reference it, so it was left alone`);
245
+ }
246
+ if (registration.locked) {
247
+ const unlocked = await deps.execFile("git", ["worktree", "unlock", registeredPath], {
248
+ cwd: repoRoot,
249
+ });
250
+ if (!unlocked.ok) {
251
+ return fail("create-failed", "a stale snapshot lock could not be cleared");
252
+ }
253
+ }
254
+ const pruned = await deps.execFile("git", ["worktree", "prune"], { cwd: repoRoot });
255
+ if (!pruned.ok) {
256
+ return fail("create-failed", "the stale snapshot registration could not be pruned");
257
+ }
258
+ }
259
+ const created = await deps.execFile("git", ["worktree", "add", "--detach", snapshotPath, sha], { cwd: repoRoot, timeoutMs: SNAPSHOT_CREATE_TIMEOUT_MS });
260
+ if (!created.ok) {
261
+ return fail("create-failed", `the snapshot for commit ${sha.slice(0, 12)} could not be created`);
262
+ }
263
+ const verifiedSha = await readCheckoutSha(snapshotPath, deps);
264
+ const real = await tryRealpath(snapshotPath, deps);
265
+ if (verifiedSha !== sha || real !== registeredPath) {
266
+ return fail("verify-failed", `the new snapshot for commit ${sha.slice(0, 12)} did not verify as that commit`);
267
+ }
268
+ }
269
+ const prunedSnapshots = await pruneObsoleteSnapshots(repoRoot, sha, deps);
270
+ return { ok: true, snapshot, reused, prunedSnapshots };
271
+ }
272
+ function relativeSnapshotLabel(sha) {
273
+ return `.bridge/plane/snapshots/${sha}`;
274
+ }
275
+ async function lstatOrNull(filePath, deps) {
276
+ try {
277
+ return await deps.fs.lstat(filePath);
278
+ }
279
+ catch {
280
+ return "error";
281
+ }
282
+ }
283
+ /**
284
+ * Remove snapshots for OTHER commits that are provably unused.
285
+ *
286
+ * Best effort and silent on failure: pruning is housekeeping, and a bring-up must
287
+ * never fail because an old snapshot would not go. Each removal needs all of:
288
+ * registered by Git under this repository's snapshot directory, not the pinned
289
+ * SHA, not locked, and not referenced by a plane that is live or whose liveness
290
+ * could not be determined. `git worktree remove` is run without `--force`, so Git
291
+ * itself refuses a worktree with local changes.
292
+ *
293
+ * If the manifest cannot be read or validated, NOTHING is removed: an unreadable
294
+ * record might describe a live plane, and "could not tell" is not "unused".
295
+ */
296
+ export async function pruneObsoleteSnapshots(repoRoot, keepSha, deps) {
297
+ const removed = [];
298
+ try {
299
+ const realRoot = await tryRealpath(repoRoot, deps);
300
+ if (realRoot === null)
301
+ return removed;
302
+ const read = await deps.readManifest(repoRoot);
303
+ if (read.kind !== "missing" && read.kind !== "valid")
304
+ return removed;
305
+ const heldPath = read.kind === "valid" ? planeManifestSnapshot(read.manifest) : { kind: "legacy" };
306
+ const planeIsLive = read.kind === "valid" && manifestHasLiveProcess(read.manifest, deps.proc);
307
+ const registrations = await listRegistrations(repoRoot, deps);
308
+ if (registrations === null)
309
+ return removed;
310
+ const snapshotsDir = getPlanePaths(realRoot).snapshotsDir;
311
+ const keepPath = getPlanePaths(realRoot).snapshotPathFor(keepSha);
312
+ for (const entry of registrations) {
313
+ if (path.dirname(entry.path) !== snapshotsDir)
314
+ continue;
315
+ const leaf = path.basename(entry.path);
316
+ if (!isFullCommitSha(leaf) || entry.path === keepPath)
317
+ continue;
318
+ if (entry.locked)
319
+ continue;
320
+ // A plane that is live or unaccounted for and pinned to this directory
321
+ // protects it. A plane that holds NO pin (legacy) protects nothing here.
322
+ if (planeIsLive &&
323
+ heldPath.kind === "pinned" &&
324
+ (await tryRealpath(heldPath.snapshot.path, deps)) === entry.path) {
325
+ continue;
326
+ }
327
+ const result = await deps.execFile("git", ["worktree", "remove", entry.path], { cwd: repoRoot });
328
+ if (result.ok)
329
+ removed.push(leaf);
330
+ }
331
+ }
332
+ catch {
333
+ /* housekeeping must never change a bring-up's outcome */
334
+ }
335
+ return removed;
336
+ }
337
+ /**
338
+ * Lock the snapshot for a live plane, so Git will not prune or remove it.
339
+ *
340
+ * Idempotent for a lock this module took earlier (a crashed plane leaves one
341
+ * behind, and its manifest — already proven stale by preflight — is the only
342
+ * thing that could make the new plane's claim legal). A lock with a reason this
343
+ * module did not write belongs to someone else and is refused.
344
+ */
345
+ export async function lockCodeSnapshot(repoRoot, snapshot, planeId, deps) {
346
+ const registrations = await listRegistrations(repoRoot, deps);
347
+ const registeredPath = await registeredSnapshotPath(repoRoot, snapshot.sha, deps);
348
+ const entry = registrations && registeredPath
349
+ ? (registrations.find((candidate) => candidate.path === registeredPath) ?? null)
350
+ : null;
351
+ if (entry === null) {
352
+ return { ok: false, reason: "lock-failed", message: "the snapshot is not registered with Git, so it cannot be locked" };
353
+ }
354
+ if (entry.locked) {
355
+ if (entry.lockReason !== null && entry.lockReason.startsWith(SNAPSHOT_LOCK_REASON_PREFIX)) {
356
+ return { ok: true };
357
+ }
358
+ return {
359
+ ok: false,
360
+ reason: "lock-failed",
361
+ message: "the snapshot is already locked by something other than a plane",
362
+ };
363
+ }
364
+ const locked = await deps.execFile("git", ["worktree", "lock", "--reason", `${SNAPSHOT_LOCK_REASON_PREFIX} ${planeId}`, registeredPath], { cwd: repoRoot });
365
+ return locked.ok
366
+ ? { ok: true }
367
+ : { ok: false, reason: "lock-failed", message: "the snapshot could not be locked" };
368
+ }
369
+ /**
370
+ * Unlock a snapshot after its plane is confirmed gone. Never removes it.
371
+ *
372
+ * The CALLER decides that "confirmed gone" holds; this only performs the unlock,
373
+ * and reports whether the snapshot is now unlocked. "Already unlocked" IS the goal
374
+ * state, so it is success: `git worktree unlock` on an unlocked worktree exits
375
+ * non-zero and the exec seam cannot tell that apart from a real failure, so the
376
+ * registration is read first. This matters in practice, because a `plane down`
377
+ * and the supervisor it signals both wind the same plane down and both release
378
+ * its snapshot — the loser of that race must not report a failure for a lock that
379
+ * is, correctly, already gone.
380
+ */
381
+ export async function unlockCodeSnapshot(repoRoot, snapshot, deps) {
382
+ const realRoot = await tryRealpath(repoRoot, deps);
383
+ if (realRoot === null)
384
+ return { ok: false };
385
+ const target = getPlanePaths(realRoot).snapshotPathFor(snapshot.sha);
386
+ const registrations = await listRegistrations(repoRoot, deps);
387
+ if (registrations !== null) {
388
+ const entry = registrations.find((candidate) => candidate.path === target);
389
+ if (entry === undefined || !entry.locked)
390
+ return { ok: true };
391
+ }
392
+ const result = await deps.execFile("git", ["worktree", "unlock", target], { cwd: repoRoot });
393
+ return { ok: result.ok };
394
+ }
395
+ /**
396
+ * Release a snapshot for a manifest whose plane has just been confirmed gone.
397
+ *
398
+ * The single place the "unlock only after confirmed wind-down" rule is applied to
399
+ * a manifest: a legacy manifest has nothing to release, and a pinned one is
400
+ * unlocked and KEPT on disk. Removal is left to a later bring-up's prune, which
401
+ * has its own proof requirements.
402
+ */
403
+ export async function releaseCodeSnapshotForManifest(manifest, deps) {
404
+ const pinned = planeManifestSnapshot(manifest);
405
+ if (pinned.kind !== "pinned")
406
+ return { released: false };
407
+ const result = await unlockCodeSnapshot(manifest.repoRoot, pinned.snapshot, deps);
408
+ return { released: result.ok };
409
+ }
410
+ /**
411
+ * Confirm a recorded snapshot is still the commit it was pinned to.
412
+ *
413
+ * Used by the detached runtime and by `plane restart`, which act on what the
414
+ * MANIFEST says and must not consult the moving operator checkout. It asks only
415
+ * whether the recorded directory still exists, is a real directory (not a
416
+ * symlink), and still reports the recorded SHA. It never resolves a replacement
417
+ * SHA and never repairs anything: a moved or missing snapshot is refused, not
418
+ * upgraded.
419
+ */
420
+ export async function revalidateCodeSnapshot(repoRoot, snapshot, deps) {
421
+ if (!isFullCommitSha(snapshot.sha)) {
422
+ return { ok: false, reason: "identity-mismatch", message: "the recorded snapshot SHA is not a full commit id" };
423
+ }
424
+ const expected = getPlanePaths(repoRoot).snapshotPathFor(snapshot.sha);
425
+ if (snapshot.path !== expected) {
426
+ return {
427
+ ok: false,
428
+ reason: "unsafe-location",
429
+ message: "the recorded snapshot path is not the canonical location for its commit",
430
+ };
431
+ }
432
+ const stat = await lstatOrNull(snapshot.path, deps);
433
+ if (stat === "error") {
434
+ return { ok: false, reason: "missing", message: `${relativeSnapshotLabel(snapshot.sha)} could not be inspected` };
435
+ }
436
+ if (stat === null) {
437
+ return {
438
+ ok: false,
439
+ reason: "missing",
440
+ message: `${relativeSnapshotLabel(snapshot.sha)} no longer exists; the plane's pinned code is gone`,
441
+ };
442
+ }
443
+ if (stat.isSymbolicLink || !stat.isDirectory) {
444
+ return {
445
+ ok: false,
446
+ reason: "unsafe-location",
447
+ message: `${relativeSnapshotLabel(snapshot.sha)} is now a symlink or not a directory`,
448
+ };
449
+ }
450
+ const realSnapshot = await tryRealpath(snapshot.path, deps);
451
+ const expectedReal = await registeredSnapshotPath(repoRoot, snapshot.sha, deps);
452
+ if (realSnapshot === null || expectedReal === null || realSnapshot !== expectedReal) {
453
+ return {
454
+ ok: false,
455
+ reason: "moved",
456
+ message: `${relativeSnapshotLabel(snapshot.sha)} no longer resolves to its recorded location`,
457
+ };
458
+ }
459
+ const checkoutSha = await readCheckoutSha(snapshot.path, deps);
460
+ if (checkoutSha !== snapshot.sha) {
461
+ return {
462
+ ok: false,
463
+ reason: "identity-mismatch",
464
+ message: `${relativeSnapshotLabel(snapshot.sha)} no longer reports commit ${snapshot.sha.slice(0, 12)}`,
465
+ };
466
+ }
467
+ return { ok: true };
468
+ }
@@ -9,14 +9,14 @@
9
9
  * unused factory rather than hiding inside the logic.
10
10
  */
11
11
  import { createWriteStream, realpathSync, statSync } from "fs";
12
- import { mkdir, open, readdir, readFile, rename, stat, unlink, writeFile } from "fs/promises";
12
+ import { lstat, mkdir, open, readdir, readFile, realpath, rename, stat, unlink, writeFile, } from "fs/promises";
13
13
  import { execFile as execFileCb, spawn as nodeSpawn } from "child_process";
14
14
  import net from "net";
15
15
  import os from "os";
16
16
  import path from "path";
17
17
  import { fileURLToPath } from "url";
18
18
  import { PLANE_HEARTBEAT_HEALTH_STATES, } from "./types.js";
19
- import { isProcessAlive } from "./manifest.js";
19
+ import { isProcessAlive, readPlaneManifest } from "./manifest.js";
20
20
  /** Filesystem primitives bound to `node:fs/promises`. */
21
21
  export function createPlaneFsDeps() {
22
22
  return {
@@ -126,7 +126,12 @@ export function probeTcpPort(host, port, timeoutMs) {
126
126
  /** `execFile`, argument arrays only. No shell, ever. */
127
127
  export function createPlaneExecFile() {
128
128
  return (file, args, options) => new Promise((resolve) => {
129
- execFileCb(file, args, { cwd: options.cwd, shell: false, windowsHide: true, timeout: 30_000 }, (error, stdout, stderr) => {
129
+ execFileCb(file, args, {
130
+ cwd: options.cwd,
131
+ shell: false,
132
+ windowsHide: true,
133
+ timeout: options.timeoutMs ?? 30_000,
134
+ }, (error, stdout, stderr) => {
130
135
  if (error) {
131
136
  const code = error.code;
132
137
  resolve({ ok: false, error: typeof code === "string" ? code : "command failed" });
@@ -136,6 +141,46 @@ export function createPlaneExecFile() {
136
141
  });
137
142
  });
138
143
  }
144
+ /** Synchronous existence probe for a file, used by the roster's virtualenv lookup. */
145
+ export function planeFileExistsSync(filePath) {
146
+ try {
147
+ return statSync(filePath).isFile();
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ }
153
+ /**
154
+ * Real-I/O bindings for the code-snapshot module (BAPI-1362).
155
+ *
156
+ * `lstat` (not `stat`) on purpose: the snapshot module must be able to tell a
157
+ * symlink from a directory, and `stat` follows the link and hides the difference.
158
+ * A missing path is reported as `null`; any other failure rejects so the caller
159
+ * treats it as ambiguous rather than absent.
160
+ */
161
+ export function createCodeSnapshotDeps(proc = createPlaneProcessDeps()) {
162
+ const fs = createPlaneFsDeps();
163
+ return {
164
+ execFile: createPlaneExecFile(),
165
+ fs: {
166
+ realpath: (filePath) => realpath(filePath),
167
+ lstat: async (filePath) => {
168
+ try {
169
+ const result = await lstat(filePath);
170
+ return { isSymbolicLink: result.isSymbolicLink(), isDirectory: result.isDirectory() };
171
+ }
172
+ catch (err) {
173
+ if (err?.code === "ENOENT")
174
+ return null;
175
+ throw err;
176
+ }
177
+ },
178
+ mkdir: (dirPath) => fs.mkdir(dirPath),
179
+ },
180
+ readManifest: (repoRoot) => readPlaneManifest(repoRoot, fs),
181
+ proc,
182
+ };
183
+ }
139
184
  /** Child spawning. `shell: false` is pinned: argv arrays only, never a string. */
140
185
  export function createPlaneSpawn() {
141
186
  return (command, args, options) => {
@@ -0,0 +1,41 @@
1
+ /** Loopback spellings that name the plane's `127.0.0.1` host. */
2
+ const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
3
+ /**
4
+ * Does `baseUrl` name the plane's own loopback endpoint? Pure.
5
+ *
6
+ * Cold start and attach are decisions about THE PLANE, so they only make sense
7
+ * when the Bridge API the command talks to IS the plane's server. The conductor
8
+ * default base URL is production; a readiness failure against it says nothing
9
+ * about a local port, and must keep escalating exactly as before. Loopback
10
+ * spellings are equivalent (`localhost` and `127.0.0.1` reach the same socket);
11
+ * the port must match exactly.
12
+ */
13
+ export function baseUrlNamesPlaneEndpoint(baseUrl, endpoint) {
14
+ let url;
15
+ try {
16
+ url = new URL(baseUrl);
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ if (url.protocol !== "http:")
22
+ return false;
23
+ const port = url.port === "" ? 80 : Number(url.port);
24
+ return LOOPBACK_HOSTNAMES.has(url.hostname) && port === endpoint.port;
25
+ }
26
+ /**
27
+ * The plane endpoint, when `baseUrl` names it; otherwise `null`.
28
+ *
29
+ * `null` for an unresolvable plane port too: with no endpoint there is no plane
30
+ * to cold-start, attach to, or probe.
31
+ */
32
+ export function planeEndpointForTarget(resolution, baseUrl) {
33
+ if (!resolution.ok)
34
+ return null;
35
+ return baseUrlNamesPlaneEndpoint(baseUrl, resolution.endpoint) ? resolution.endpoint : null;
36
+ }
37
+ export function planeNotApplicableForTarget(resolution, baseUrl) {
38
+ if (typeof baseUrl !== "string" || baseUrl.length === 0 || !resolution.ok)
39
+ return "unresolvable";
40
+ return baseUrlNamesPlaneEndpoint(baseUrl, resolution.endpoint) ? null : "remote";
41
+ }