@bridge_gpt/mcp-server 0.2.66 → 0.2.67
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 +252 -77
- package/build/agent-capabilities/probes.js +15 -5
- package/build/agent-launchers/claude-executor-adapter.js +43 -16
- package/build/agent-launchers/executor-adapter.js +7 -4
- package/build/agents.generated.js +1 -1
- package/build/bounded-wait.js +67 -10
- package/build/commands.generated.js +11 -11
- package/build/conductor/bridge-api-client.js +143 -1
- package/build/conductor/bring-up-facts.js +1 -1
- package/build/conductor/doctor.js +3 -3
- package/build/conductor/epic-implementer-cli.js +22 -6
- package/build/conductor/errors.js +4 -3
- package/build/conductor/guided-repair.js +6 -2
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/merge-execution-contract.js +27 -8
- package/build/conductor/merge-execution-eligibility.js +3 -2
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +18 -2
- package/build/conductor/readiness-sections.js +41 -6
- package/build/conductor/readiness.js +181 -29
- package/build/conductor/recovery-cli.js +200 -8
- package/build/conductor/recovery-operations.js +69 -3
- package/build/conductor/repair-contract.js +54 -3
- package/build/conductor/repository-workflow-remedy.js +125 -0
- package/build/conductor/tools.js +2 -2
- package/build/conductor-bundle-artifacts.js +8 -0
- package/build/docs.generated.js +8 -3
- package/build/drive-epic-key.js +6 -5
- package/build/drive-epic.js +85 -54
- package/build/epic-coherence-input.js +2 -1
- package/build/epic-implementer-bin.js +22 -20
- package/build/epic-implementer-bundle-cli.js +2 -2
- package/build/executor/cli.js +3 -2
- package/build/executor/completion-contract.js +48 -5
- package/build/executor/completion-supervision.js +4 -0
- package/build/executor/env.js +19 -0
- package/build/executor/heartbeat.js +1 -1
- package/build/executor/http-client.js +20 -0
- package/build/executor/job-errors.js +25 -0
- package/build/executor/job-runner.js +225 -41
- package/build/executor/observation.js +6 -1
- package/build/executor/preflight.js +51 -14
- package/build/executor/required-work-ledger.js +6 -1
- package/build/executor/resume-pre-spawn.js +2 -0
- package/build/executor/runner.js +8 -2
- package/build/executor/service-unit.js +7 -1
- package/build/executor/worker-finalization.js +82 -29
- package/build/executor/worker-guard-hook.js +137 -33
- package/build/executor/worktree.js +82 -1
- package/build/index.js +403 -429
- package/build/install-bridge-conductor.js +8 -6
- package/build/install-bridge.js +3 -3
- package/build/phase-context-git-probe.js +68 -0
- package/build/phase-result-artifacts.js +173 -5
- package/build/pipelines.generated.js +18 -24
- package/build/plane/alembic-head.js +12 -3
- package/build/plane/base-worktree-guard.js +147 -0
- package/build/plane/cli.js +217 -17
- package/build/plane/code-snapshot.js +468 -0
- package/build/plane/defaults.js +48 -3
- package/build/plane/endpoint.js +41 -0
- package/build/plane/manifest.js +120 -1
- package/build/plane/member-roster.js +94 -19
- package/build/plane/preflight.js +30 -2
- package/build/plane/process-cwd.js +67 -0
- package/build/plane/restart.js +542 -0
- package/build/plane/shutdown.js +31 -0
- package/build/plane/status.js +143 -8
- package/build/plane/supervisor.js +586 -179
- package/build/plane/types.js +54 -2
- package/build/printed-remedy-commands.js +37 -0
- package/build/readiness-check.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +22 -0
- package/build/setup-epic.js +44 -19
- package/build/sfcc/config.js +9 -0
- package/build/sfcc/credentials.js +100 -38
- package/build/sfcc/hostname.js +67 -0
- package/build/sfcc/permissions.js +17 -13
- package/build/sfcc/register.js +1 -1
- package/build/sfcc/tool-wrapper.js +39 -43
- package/build/sfcc/write-grants.js +17 -11
- package/build/sfcc/write-result.js +4 -3
- package/build/sfcc/writes-site-preference.js +20 -8
- package/build/start-tickets.js +179 -31
- package/build/tool-error-envelope.js +61 -0
- package/build/version.generated.js +3 -3
- package/build/visual-diff.js +22 -1
- package/build/worker-guard-hook-bin.js +3 -6
- package/build/worker-guard-hook-main.js +47 -0
- package/build/worktree-core.js +130 -4
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
- package/docs/EPIC-IMPLEMENTER.md +662 -0
- package/docs/install/mcp-tool-integrations.md +24 -15
- package/docs/install/sfcc-integration.md +12 -4
- package/package.json +1 -1
- package/pipelines/plan-epic.json +0 -5
- package/pipelines/review-ticket.json +1 -1
- package/docs/CONDUCTOR.md +0 -605
- 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
|
+
}
|
package/build/plane/defaults.js
CHANGED
|
@@ -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, {
|
|
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
|
+
}
|