@bridge_gpt/mcp-server 0.2.50 → 0.2.52

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 (76) hide show
  1. package/README.md +24 -8
  2. package/build/agent-capabilities/probe-context.js +15 -7
  3. package/build/agent-capabilities/probes.js +42 -6
  4. package/build/agent-launchers/claude-executor-adapter.js +98 -14
  5. package/build/commands.generated.js +1 -1
  6. package/build/conduct-epic/bridge-client.js +115 -1
  7. package/build/conduct-epic/cli.js +351 -33
  8. package/build/conduct-epic/cut-protocol.js +65 -0
  9. package/build/conductor/bridge-api-client.js +171 -5
  10. package/build/conductor/deny-enforcement-preflight.js +107 -10
  11. package/build/conductor/local-merge.js +170 -11
  12. package/build/conductor-bin.js +2 -2
  13. package/build/connect-bitbucket-api.js +370 -0
  14. package/build/connect-bitbucket.js +437 -0
  15. package/build/docs.generated.js +1 -1
  16. package/build/doctor.js +230 -1
  17. package/build/drive-epic.js +423 -11
  18. package/build/env-file-link.js +164 -0
  19. package/build/epic-integration-pr.js +290 -0
  20. package/build/executor/cli.js +41 -6
  21. package/build/executor/deps.js +5 -1
  22. package/build/executor/env-file-guard.js +113 -0
  23. package/build/executor/env.js +78 -1
  24. package/build/executor/heartbeat.js +9 -0
  25. package/build/executor/http-client.js +90 -22
  26. package/build/executor/job-errors.js +43 -2
  27. package/build/executor/job-runner.js +137 -29
  28. package/build/executor/merge-job.js +102 -6
  29. package/build/executor/permissions.js +106 -0
  30. package/build/executor/preflight.js +38 -13
  31. package/build/executor/resume-pre-spawn.js +2 -1
  32. package/build/executor/runner.js +175 -4
  33. package/build/executor/service-unit.js +15 -0
  34. package/build/executor/terminal-mutation.js +22 -1
  35. package/build/executor/types.js +86 -0
  36. package/build/executor/worker-command.js +21 -5
  37. package/build/executor/worker-guard-hook.js +939 -0
  38. package/build/executor/worker-log.js +56 -0
  39. package/build/executor/worktree.js +11 -0
  40. package/build/git-reachability.js +147 -0
  41. package/build/index.js +535 -95
  42. package/build/install-bridge.js +95 -0
  43. package/build/pipelines.generated.js +10 -2
  44. package/build/plan-epic-conductor-eligibility.js +213 -0
  45. package/build/plane/cli.js +78 -15
  46. package/build/plane/defaults.js +165 -0
  47. package/build/plane/manifest.js +63 -8
  48. package/build/plane/member-logs.js +6 -0
  49. package/build/plane/member-roster.js +195 -11
  50. package/build/plane/preflight.js +43 -0
  51. package/build/plane/shutdown.js +25 -3
  52. package/build/plane/status.js +11 -0
  53. package/build/plane/supervisor.js +343 -14
  54. package/build/plane/test-fakes.js +43 -0
  55. package/build/plane/types.js +82 -11
  56. package/build/pr-base-contract.js +20 -0
  57. package/build/readme.generated.js +1 -1
  58. package/build/review-synthesis-config.js +60 -0
  59. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  60. package/build/setup-epic.js +592 -139
  61. package/build/sfcc/log-query.js +2 -1
  62. package/build/sfcc/reads-custom-object-def.js +10 -13
  63. package/build/sfcc/reads-site-preference.js +5 -5
  64. package/build/sfcc/reads-system-object.js +4 -4
  65. package/build/sfcc/writes-custom-object-def.js +7 -7
  66. package/build/sfcc/writes-site-preference.js +4 -3
  67. package/build/sfcc/writes-system-object.js +7 -6
  68. package/build/start-tickets-conductor.js +11 -2
  69. package/build/start-tickets.js +69 -2
  70. package/build/version.generated.js +3 -3
  71. package/build/worker-containment-diagnostic.js +97 -0
  72. package/build/worker-guard-hook-bin.js +6 -0
  73. package/docs/CONDUCTOR.md +27 -0
  74. package/docs/install/mcp-tool-integrations.md +3 -2
  75. package/package.json +5 -3
  76. package/pipelines/plan-epic.json +5 -0
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Combined merge/review conductor-eligibility assessment for `plan-epic` (BAPI-964).
3
+ *
4
+ * Runs once the epic decomposition's child manifest is frozen and every
5
+ * sub-task's exploration text is written, so this module can predict — before
6
+ * any conductor worker is spawned — which children are likely to trip the
7
+ * runtime workflow-file merge guard (`api/library/vcs/conductor_merge_service.py`)
8
+ * and which of those additionally touch `claude-review.yml` itself, where
9
+ * automated review cannot run at all (BAPI-941's supply-chain preflight refuses
10
+ * to review a workflow that differs from the base branch's copy).
11
+ *
12
+ * The BROADER merge classification is delegated entirely to the Python
13
+ * classifier via one Bridge API call for the complete child set — this module
14
+ * never reimplements "what counts as a workflow file". The NARROWER review
15
+ * subclass is classified locally by checking each child's combined text for the
16
+ * full `CLAUDE_REVIEW_WORKFLOW_RELPATH`, imported (not re-declared) from
17
+ * `claude-review-workflow-drift.js`.
18
+ *
19
+ * Fails open, deliberately: any validation failure, classification failure, or
20
+ * internal contradiction (a review-subset child the backend did not also mark
21
+ * merge-blocked) becomes the explicit `unavailable` result — never a fabricated
22
+ * zero count. A caller that cannot get a real answer must say so, not guess.
23
+ */
24
+ import { CLAUDE_REVIEW_WORKFLOW_RELPATH } from "./claude-review-workflow-drift.js";
25
+ /**
26
+ * Build the real network-backed classification client that POSTs the complete
27
+ * child set to `/jira/planning/epic-workflow-eligibility` exactly once.
28
+ */
29
+ export function createBridgeEpicConductorEligibilityClient(deps) {
30
+ return {
31
+ async classifyChildren(children) {
32
+ const fetchImpl = deps.fetchImpl ?? fetch;
33
+ const resp = await fetchImpl(deps.buildUrl("/planning/epic-workflow-eligibility"), {
34
+ method: "POST",
35
+ headers: await deps.getPostHeaders(),
36
+ body: JSON.stringify({ repo_name: deps.repoName, children }),
37
+ });
38
+ if (!resp.ok) {
39
+ throw new Error(`epic-workflow-eligibility classification request failed: ${resp.status}`);
40
+ }
41
+ return (await resp.json());
42
+ },
43
+ };
44
+ }
45
+ const UNAVAILABLE = { status: "unavailable" };
46
+ function isNonBlankString(value) {
47
+ return typeof value === "string" && value.trim().length > 0;
48
+ }
49
+ /** Shape-validate an evidence array: every element must be a string. */
50
+ function isStringArray(value) {
51
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
52
+ }
53
+ /** Treat a missing/undefined optional field as empty text; reject a non-string value. */
54
+ function optionalTextOrNull(value) {
55
+ if (value === undefined || value === null)
56
+ return "";
57
+ return typeof value === "string" ? value : null;
58
+ }
59
+ /** Deterministically assemble one child's scan text from its authoritative fields. */
60
+ function buildCombinedText(child) {
61
+ return [child.title, child.scope, child.description, child.requirements]
62
+ .filter((part) => typeof part === "string" && part.length > 0)
63
+ .join("\n\n");
64
+ }
65
+ /** Validate the child collection at the module boundary. `null` means malformed. */
66
+ function validateChildren(children) {
67
+ if (!Array.isArray(children) || children.length === 0)
68
+ return null;
69
+ const validated = [];
70
+ for (const child of children) {
71
+ if (child === null || typeof child !== "object")
72
+ return null;
73
+ if (!isNonBlankString(child.id) || !isNonBlankString(child.title))
74
+ return null;
75
+ const scope = optionalTextOrNull(child.scope);
76
+ const description = optionalTextOrNull(child.description);
77
+ const requirements = optionalTextOrNull(child.requirements);
78
+ if (scope === null || description === null || requirements === null)
79
+ return null;
80
+ validated.push({
81
+ id: child.id,
82
+ title: child.title,
83
+ combinedText: buildCombinedText({
84
+ id: child.id,
85
+ title: child.title,
86
+ scope,
87
+ description,
88
+ requirements,
89
+ }),
90
+ });
91
+ }
92
+ return validated;
93
+ }
94
+ /**
95
+ * Assess conductor merge/review eligibility for one frozen epic child set.
96
+ *
97
+ * Sends the complete ordered child set to the injected Bridge client exactly
98
+ * once. Never throws — every failure path (malformed input, client rejection,
99
+ * malformed backend response, an internal merge/review contradiction) resolves
100
+ * to the explicit `unavailable` result, diagnosed with `console.error` (never
101
+ * `console.log`, which is reserved for the MCP stdio transport).
102
+ */
103
+ export async function assessEpicConductorEligibility(children, deps) {
104
+ const validated = validateChildren(children);
105
+ if (validated === null) {
106
+ console.error("assessEpicConductorEligibility: malformed child collection; unavailable.");
107
+ return UNAVAILABLE;
108
+ }
109
+ let response;
110
+ try {
111
+ response = await deps.client.classifyChildren(validated.map((child) => ({ child_id: child.id, combined_text: child.combinedText })));
112
+ }
113
+ catch (err) {
114
+ console.error("assessEpicConductorEligibility: Bridge classification call failed:", err);
115
+ return UNAVAILABLE;
116
+ }
117
+ if (response === null ||
118
+ typeof response !== "object" ||
119
+ !Array.isArray(response.predictions) ||
120
+ !Number.isInteger(response.total_children) ||
121
+ response.total_children < 0 ||
122
+ !Number.isInteger(response.predicted_workflow_children) ||
123
+ response.predicted_workflow_children < 0 ||
124
+ !Number.isInteger(response.containment_children) ||
125
+ response.containment_children < 0 ||
126
+ typeof response.reason !== "string") {
127
+ console.error("assessEpicConductorEligibility: malformed backend response shape; unavailable.");
128
+ return UNAVAILABLE;
129
+ }
130
+ if (response.predictions.length !== validated.length) {
131
+ console.error("assessEpicConductorEligibility: backend prediction count does not match child count; unavailable.");
132
+ return UNAVAILABLE;
133
+ }
134
+ // The backend is contractually order-preserving; still verify identity and
135
+ // order rather than trusting it, so a reordered/duplicate/unknown response
136
+ // never silently mislabels a child's prediction.
137
+ const predictionById = new Map();
138
+ for (const prediction of response.predictions) {
139
+ if (prediction === null ||
140
+ typeof prediction !== "object" ||
141
+ !isNonBlankString(prediction.child_id) ||
142
+ typeof prediction.predicted_workflow_modified !== "boolean" ||
143
+ !isStringArray(prediction.matched_paths) ||
144
+ typeof prediction.predicted_containment_hazard !== "boolean" ||
145
+ !isStringArray(prediction.matched_hazards)) {
146
+ console.error("assessEpicConductorEligibility: malformed per-child prediction; unavailable.");
147
+ return UNAVAILABLE;
148
+ }
149
+ if (prediction.predicted_containment_hazard !== (prediction.matched_hazards.length > 0)) {
150
+ console.error(`assessEpicConductorEligibility: child ${prediction.child_id} has a containment flag that ` +
151
+ "disagrees with whether matched hazard evidence is present; unavailable.");
152
+ return UNAVAILABLE;
153
+ }
154
+ if (predictionById.has(prediction.child_id)) {
155
+ console.error("assessEpicConductorEligibility: duplicate child_id in backend response; unavailable.");
156
+ return UNAVAILABLE;
157
+ }
158
+ predictionById.set(prediction.child_id, prediction);
159
+ }
160
+ const affectedChildren = [];
161
+ let reviewSubsetChildren = 0;
162
+ let predictedWorkflowChildren = 0;
163
+ let containmentChildren = 0;
164
+ for (const child of validated) {
165
+ const prediction = predictionById.get(child.id);
166
+ if (!prediction) {
167
+ console.error(`assessEpicConductorEligibility: backend response is missing child ${child.id}; unavailable.`);
168
+ return UNAVAILABLE;
169
+ }
170
+ const reviewPathReferenced = child.combinedText.includes(CLAUDE_REVIEW_WORKFLOW_RELPATH);
171
+ if (reviewPathReferenced && !prediction.predicted_workflow_modified) {
172
+ // Subset invariant violated: a child naming claude-review.yml itself must
173
+ // also be in the broader merge-blocked set. Publishing this as a
174
+ // "review-only" child would contradict the merge count, so refuse instead.
175
+ console.error(`assessEpicConductorEligibility: child ${child.id} references the review workflow ` +
176
+ "but the backend did not mark it merge-blocked; refusing contradictory result.");
177
+ return UNAVAILABLE;
178
+ }
179
+ if (prediction.predicted_workflow_modified) {
180
+ predictedWorkflowChildren += 1;
181
+ if (reviewPathReferenced)
182
+ reviewSubsetChildren += 1;
183
+ }
184
+ if (prediction.predicted_containment_hazard)
185
+ containmentChildren += 1;
186
+ // A child is affected — and therefore included — when it is workflow-modifying,
187
+ // containment-flagged, or both. Skip only a child that is neither.
188
+ if (!prediction.predicted_workflow_modified && !prediction.predicted_containment_hazard)
189
+ continue;
190
+ affectedChildren.push({
191
+ id: child.id,
192
+ title: child.title,
193
+ matchedPaths: [...prediction.matched_paths],
194
+ requiresHandReview: reviewPathReferenced,
195
+ requiresContainmentReview: prediction.predicted_containment_hazard,
196
+ matchedHazards: [...prediction.matched_hazards],
197
+ });
198
+ }
199
+ if (containmentChildren !== response.containment_children) {
200
+ console.error("assessEpicConductorEligibility: containment_children count disagrees with the validated " +
201
+ "per-child containment flags; unavailable.");
202
+ return UNAVAILABLE;
203
+ }
204
+ return {
205
+ status: "assessed",
206
+ totalChildren: validated.length,
207
+ predictedWorkflowChildren,
208
+ reason: response.reason,
209
+ reviewSubsetChildren,
210
+ containmentChildren,
211
+ affectedChildren,
212
+ };
213
+ }
@@ -12,7 +12,7 @@
12
12
  * if it is ever reused on the server path.
13
13
  */
14
14
  import { randomUUID } from "crypto";
15
- import { PLANE_ENTRYPOINT_ACTION, PLANE_ID_ENV_VAR, PLANE_MANIFEST_SCHEMA_VERSION, PLANE_OBSERVER_CHANNEL_TYPE_ENV, PLANE_OBSERVER_COMMAND, PLANE_OBSERVER_DESTINATION_ENV, PLANE_RUNTIME_ACTION, PLANE_RUNTIME_DIR, } from "./types.js";
15
+ import { PLANE_ENTRYPOINT_ACTION, PLANE_ID_ENV_VAR, PLANE_MANIFEST_SCHEMA_VERSION, PLANE_OBSERVER_CHANNEL_TYPE_ENV, PLANE_OBSERVER_COMMAND, PLANE_OBSERVER_DESTINATION_ENV, PLANE_RUNTIME_ACTION, PLANE_RUNTIME_DIR, PLANE_RUNTIME_LOG_PATH, } from "./types.js";
16
16
  import { PLANE_RUNTIME_ENTRYPOINT_REFUSAL } from "./build-freshness.js";
17
17
  import { relativeLogPathFor } from "./manifest.js";
18
18
  import { claimPlaneManifest } from "./manifest.js";
@@ -21,7 +21,7 @@ import { buildPlaneMemberRoster, resolvePlaneServerEndpoint } from "./member-ros
21
21
  import { getPlaneStatus, formatPlaneStatus } from "./status.js";
22
22
  import { shutdownPlane, formatPlaneShutdown } from "./shutdown.js";
23
23
  import { launchPlaneSupervisor, runPlaneRuntime, } from "./supervisor.js";
24
- import { createMemberLogDeps, createPlaneClock, createPlaneExecFile, createPlaneFsDeps, createPlaneProcessDeps, createPlaneSpawn, probeTcpPort, registerSignalHandler, resolveHomedir, resolveNodeExecutable, resolvePackageEntrypoint, } from "./defaults.js";
24
+ import { createMemberLogDeps, createPlaneClock, createPlaneExecFile, createPlaneFsDeps, createPlaneProcessDeps, createPlaneSpawn, probeAutomationHealth, probeTcpPort, registerSignalHandler, resolveHomedir, resolveHostname, resolveNodeExecutable, resolvePackageEntrypoint, } from "./defaults.js";
25
25
  import { resolveBapiCredentials } from "../credential-store.js";
26
26
  import { resolveRequiredStartTicketsRepoName } from "../start-tickets-repo.js";
27
27
  /** Upper bound on executor lanes. Guards a typo from forking a fleet. */
@@ -128,7 +128,15 @@ export function getPlaneUsage() {
128
128
  "Usage: mcp-server plane <up|status|down> [options]",
129
129
  "",
130
130
  "Brings up (and winds down) the attended conductor plane in one command:",
131
- "the Bridge API server, the reconciler worker, and one executor per lane.",
131
+ "the Bridge API server, the dead-man observer, the reconciler worker, and",
132
+ "one executor per lane.",
133
+ "",
134
+ "This is the MANUAL / two-step path (BAPI-1054). To stand a plane up and create",
135
+ "the run together, use `drive-epic <EPIC> --plan-file <path>`, which starts the",
136
+ "plane for you when that is the only thing missing. Reach for `plane up` directly",
137
+ "when you want the runtime without a run, when you are driving an epic whose plane",
138
+ "is already live, or to recover after a composed bring-up reported a live plane it",
139
+ "did not wind down. Nothing about this command's behavior has changed.",
132
140
  "",
133
141
  "Actions:",
134
142
  " plane up [--executors N] Preflight, then start the plane. Refuses as a",
@@ -151,18 +159,32 @@ export function getPlaneUsage() {
151
159
  " (mcp_server/build/index.js) — never npx, never a published package.",
152
160
  " * A member that exits is reported loudly and is NOT restarted. There is no",
153
161
  " auto-restart, retry, or backoff: a crash is a finding, not noise.",
154
- " * The dead-man observer is NOT part of the plane. It is started and stopped",
155
- " by you, so its failure domain stays independent of the supervisor's.",
156
- ` Start it separately: ${PLANE_OBSERVER_COMMAND}`,
162
+ " * The dead-man observer runs as an INDEPENDENT same-host OS process, started",
163
+ " by `plane up`, listed in `plane status`, and stopped by `plane down`. It is",
164
+ " started before the reconciler worker, so it is already watching while the",
165
+ " worker comes up. Same-host only — this is not separate-host resilience.",
166
+ " * The reconciler worker is not reported ready until it has published a FRESH",
167
+ " durable heartbeat, read back through GET /automation/health. `plane up`",
168
+ " cannot print a success banner over a reconciler that never ticked.",
157
169
  "",
158
170
  `Runtime artifacts (manifest + per-member logs) live under ${PLANE_RUNTIME_DIR}/.`,
159
171
  ].join("\n");
160
172
  }
161
- /** The observer reminder printed at the end of a successful startup banner. */
162
- export function getPlaneObserverReminder() {
173
+ /**
174
+ * How to start the dead-man observer BY HAND.
175
+ *
176
+ * No longer printed by `plane up` (BAPI-1029): the plane starts the observer
177
+ * itself, and telling an operator to start a second one would give them two
178
+ * evaluators racing the same outage. It is retained for the documented
179
+ * session-background FALLBACK, which runs the reconciler outside the plane and
180
+ * therefore has no observer of its own — the one arrangement where this command
181
+ * is still the right instruction.
182
+ */
183
+ export function getPlaneObserverFallbackInstructions() {
163
184
  return [
164
185
  "",
165
- "Dead-man observer (independent not started, listed, or stopped by the plane):",
186
+ "Dead-man observer for the SESSION-BACKGROUND FALLBACK only (`plane up` starts",
187
+ "its own observer and needs none of this):",
166
188
  ` ${PLANE_OBSERVER_COMMAND}`,
167
189
  ` with ${PLANE_OBSERVER_CHANNEL_TYPE_ENV} (slack_webhook|generic_webhook) and`,
168
190
  ` ${PLANE_OBSERVER_DESTINATION_ENV} (the NAME of an env var holding the URL).`,
@@ -327,7 +349,7 @@ async function runUpAction(executors, sinks, overrides) {
327
349
  const env = overrides.env ?? process.env;
328
350
  const preflight = overrides.preflight
329
351
  ? await overrides.preflight(repoRoot)
330
- : await runPlanePreflight(repoRoot, buildPreflightDeps(env));
352
+ : await runDefaultPlanePreflight(repoRoot, env);
331
353
  for (const diagnostic of preflight.diagnostics.filter((d) => d.severity === "warning")) {
332
354
  sinks.stdout(formatDiagnostic(diagnostic));
333
355
  }
@@ -366,9 +388,16 @@ export const PLANE_STARTUP_FAILED = "plane up FAILED — the runtime never came
366
388
  */
367
389
  export function formatPlaneLaunchFailure(launch) {
368
390
  if (!launch.startup) {
369
- // No child exit information exists, so none is invented this is the
370
- // pre-existing generic spawn-failure path, preserved verbatim in substance.
371
- return [`plane up FAILED: ${launch.error}`];
391
+ // No child exit information exists, so none is invented. The trace pointer
392
+ // is still emitted (BAPI-1029): a launch that never produced a usable child
393
+ // used to print one bare line with nowhere to look next, which is the same
394
+ // dead end BAPI-768 presented as. The file may be empty or absent — saying
395
+ // where it would be costs nothing and is the only lead this branch has.
396
+ return [
397
+ `plane up FAILED: ${launch.error}`,
398
+ ` trace ${PLANE_RUNTIME_LOG_PATH} (may be empty — the runtime never started)`,
399
+ "The plane is not running. `no processes were started`.",
400
+ ];
372
401
  }
373
402
  const { exitCode, signal, stderrExcerpt, tracePath } = launch.startup;
374
403
  const status = signal !== null ? `signal ${signal}` : `exit code ${exitCode ?? "unknown"}`;
@@ -457,9 +486,8 @@ function printStartupBanner(sinks, context, memberCount, executors, launch) {
457
486
  sinks.stdout(` server ${context.endpoint.baseUrl} (no --reload)`);
458
487
  sinks.stdout(` supervisor pid ${launch.supervisorPid}, process group ${launch.supervisorPgid}`);
459
488
  sinks.stdout(` logs ${PLANE_RUNTIME_DIR}/`);
489
+ sinks.stdout(" observer dead-man-only worker.py, its own OS process (same host)");
460
490
  sinks.stdout(" crash policy members are NOT restarted; a member exit is reported loudly.");
461
- for (const line of getPlaneObserverReminder())
462
- sinks.stdout(line);
463
491
  sinks.stdout("");
464
492
  }
465
493
  /** The private detached-runtime action. Never part of the documented surface. */
@@ -520,6 +548,16 @@ async function runRuntimeAction(executors, overrides) {
520
548
  log: createMemberLogDeps(),
521
549
  onSignal: registerSignalHandler,
522
550
  probePort: probeTcpPort,
551
+ // The heartbeat readiness reader (BAPI-1029; component-aware since
552
+ // BAPI-1036). Bound here beside the TCP probe rather than constructed inside
553
+ // the supervisor, so a unit test substitutes it the same way it substitutes
554
+ // every other real-I/O seam.
555
+ probeHealth: probeAutomationHealth,
556
+ // Half of the local reconciler's published identity (`<hostname>:<pid>`).
557
+ // Injected for the same reason as every other platform read here: the
558
+ // supervisor must rebuild a string another process minted, and a test has to
559
+ // be able to pin that reconstruction without a real hostname.
560
+ hostname: resolveHostname,
523
561
  selfPid: process.pid,
524
562
  planeId,
525
563
  });
@@ -550,6 +588,31 @@ function buildPreflightDeps(env) {
550
588
  resolveCredentials: resolveBapiCredentials,
551
589
  };
552
590
  }
591
+ /**
592
+ * The production preflight, as a named seam other commands can reuse (BAPI-1054).
593
+ *
594
+ * `drive-epic` composes `plane up`, and it must inspect the SAME preflight this
595
+ * command runs — same endpoint resolution, same credential resolution, same port
596
+ * probe, same worktree predicate. Re-resolving any of that at the caller is how
597
+ * two answers to one question appear: a caller could refuse on a port the plane
598
+ * was never going to use, or approve one it was.
599
+ *
600
+ * Thin by design. It owns no policy — only the `(repoRoot, env)` -> result
601
+ * binding, so there is exactly one place that knows how a real preflight is built.
602
+ */
603
+ export function runDefaultPlanePreflight(repoRoot, env = process.env) {
604
+ return runPlanePreflight(repoRoot, buildPreflightDeps(env));
605
+ }
606
+ /**
607
+ * Render ONE diagnostic exactly as `plane up` renders it (BAPI-1054).
608
+ *
609
+ * Exported so a composing caller surfaces a plane refusal in the plane's own
610
+ * words. A second renderer would drift, and an operator reading two different
611
+ * spellings of one check has to work out whether they are the same finding.
612
+ */
613
+ export function formatPlaneDiagnostic(diagnostic) {
614
+ return formatDiagnostic(diagnostic);
615
+ }
553
616
  function formatDiagnostic(diagnostic) {
554
617
  const label = diagnostic.severity === "blocking" ? "BLOCKED" : "WARN ";
555
618
  return ` ${label} [${diagnostic.check}] ${diagnostic.message}`;
@@ -15,6 +15,7 @@ import net from "net";
15
15
  import os from "os";
16
16
  import path from "path";
17
17
  import { fileURLToPath } from "url";
18
+ import { PLANE_HEARTBEAT_HEALTH_STATES, } from "./types.js";
18
19
  import { isProcessAlive } from "./manifest.js";
19
20
  /** Filesystem primitives bound to `node:fs/promises`. */
20
21
  export function createPlaneFsDeps() {
@@ -269,6 +270,170 @@ function defaultIsFile(filePath) {
269
270
  return false;
270
271
  }
271
272
  }
273
+ /**
274
+ * Per-request budget for one automation-health readiness poll.
275
+ *
276
+ * Deliberately much smaller than the readiness budget it runs inside: a hung
277
+ * request must cost one poll, not the whole 120-second window. Without a
278
+ * per-request bound, a server that accepts the connection and never answers
279
+ * would consume the entire budget in a single call and the failure would be
280
+ * reported as "the reconciler never became fresh" rather than as a hung route.
281
+ */
282
+ export const PLANE_HEALTH_PROBE_TIMEOUT_MS = 5_000;
283
+ /**
284
+ * Read one component's durable-heartbeat verdict from `GET /automation/health`.
285
+ *
286
+ * ## What this does not do
287
+ *
288
+ * It does not compute an age, compare against a threshold, or decide what
289
+ * "fresh" means. `api/library/reconciler_liveness.py` already derives the
290
+ * verdict against `RECONCILER_STALE_SECONDS` / `EXECUTOR_STALE_SECONDS` and
291
+ * every other consumer reads that same derivation; a second threshold here is
292
+ * exactly how a launcher and a health page end up disagreeing at the boundary.
293
+ * This transports the backend's answer, normalizes its observation timestamp,
294
+ * and classifies the ways the answer can fail to arrive.
295
+ *
296
+ * ## Instance scoping (BAPI-1036)
297
+ *
298
+ * `component` selects which block of the response is read. `instanceId`, when
299
+ * given, additionally asks the route to derive THAT block from one published
300
+ * identity. The two travel together or not at all, because the route refuses a
301
+ * partial pair with a 422 — which this probe would classify as `unavailable`
302
+ * and then wait out, turning a caller's mistake into a budget burn.
303
+ *
304
+ * ## Secret handling
305
+ *
306
+ * The API key is sent in `X-API-Key`, the convention every other Bridge client
307
+ * in this package uses. It is never placed in the URL (which reaches proxy logs
308
+ * and error strings), never returned, and never rendered: every failure arm of
309
+ * {@link PlaneHealthProbeResult} is a bare tag with no body, header, status
310
+ * text, or exception message attached. The observation timestamp returned
311
+ * BESIDE that classification is a server-derived time and nothing else; see
312
+ * {@link PlaneHealthObservation} for why it is kept outside the union.
313
+ */
314
+ export async function probeAutomationHealth(request) {
315
+ let url;
316
+ try {
317
+ const target = new URL("/automation/health", request.baseUrl);
318
+ target.searchParams.set("repo_name", request.repoName);
319
+ // BOTH or NEITHER. The route validates the pair and 422s on half of it.
320
+ if (request.instanceId !== undefined) {
321
+ target.searchParams.set("component", request.component);
322
+ target.searchParams.set("instance_id", request.instanceId);
323
+ }
324
+ url = target.toString();
325
+ }
326
+ catch {
327
+ // An unparseable base URL is a configuration fault, not a transport blip.
328
+ return unclassified({ kind: "malformed" });
329
+ }
330
+ const doFetch = request.fetchImpl ?? fetch;
331
+ let response;
332
+ try {
333
+ response = await doFetch(url, {
334
+ method: "GET",
335
+ headers: { "X-API-Key": request.apiKey, Accept: "application/json" },
336
+ signal: AbortSignal.timeout(request.timeoutMs ?? PLANE_HEALTH_PROBE_TIMEOUT_MS),
337
+ });
338
+ }
339
+ catch {
340
+ // Connection refused, DNS failure, abort on timeout — all retryable, and all
341
+ // capable of carrying the request URL in their message. None is rendered.
342
+ return unclassified({ kind: "unavailable" });
343
+ }
344
+ // 401/403 are terminal for readiness: the key in the member environment is not
345
+ // accepted, and polling for two more minutes will not change that.
346
+ if (response.status === 401 || response.status === 403) {
347
+ return unclassified({ kind: "unauthorized" });
348
+ }
349
+ if (!response.ok)
350
+ return unclassified({ kind: "unavailable" });
351
+ let body;
352
+ try {
353
+ body = await response.json();
354
+ }
355
+ catch {
356
+ return unclassified({ kind: "malformed" });
357
+ }
358
+ const reading = readComponentObservation(body, request.component);
359
+ if (reading === null)
360
+ return unclassified({ kind: "malformed" });
361
+ return { result: { kind: "state", state: reading.state }, observedAtMs: reading.observedAtMs };
362
+ }
363
+ /** A reading that carries a classification and no observation. */
364
+ function unclassified(result) {
365
+ return { result, observedAtMs: null };
366
+ }
367
+ /**
368
+ * The requested component's state and observation time, or `null` for a body
369
+ * this code does not recognize.
370
+ *
371
+ * Every hop is guarded rather than asserted. An unrecognized string is
372
+ * `malformed`, NOT `unknown`: the backend's `unknown` means "the durable source
373
+ * could not be read" and is a retryable waiting state, whereas a state string
374
+ * this code does not recognize means the contract moved — and quietly treating
375
+ * a renamed state as a transient read failure would make the launcher wait out
376
+ * its whole budget and then report the wrong reason.
377
+ *
378
+ * The two blocks sit at different paths and spell their timestamp field
379
+ * differently, which is precisely why this reads them by component rather than
380
+ * from one hardcoded key:
381
+ *
382
+ * - reconciler → `body.reconciler.state` / `body.reconciler.heartbeat_last_seen_at`
383
+ * - executor → `body.executor.process_heartbeat.state` / `…​.last_seen_at`
384
+ */
385
+ function readComponentObservation(body, component) {
386
+ if (typeof body !== "object" || body === null)
387
+ return null;
388
+ let block;
389
+ let timestampKey;
390
+ if (component === "reconciler") {
391
+ block = body.reconciler;
392
+ timestampKey = "heartbeat_last_seen_at";
393
+ }
394
+ else {
395
+ const executor = body.executor;
396
+ if (typeof executor !== "object" || executor === null)
397
+ return null;
398
+ block = executor.process_heartbeat;
399
+ timestampKey = "last_seen_at";
400
+ }
401
+ if (typeof block !== "object" || block === null)
402
+ return null;
403
+ const state = block.state;
404
+ if (typeof state !== "string")
405
+ return null;
406
+ if (!PLANE_HEARTBEAT_HEALTH_STATES.includes(state))
407
+ return null;
408
+ const known = state;
409
+ const raw = block[timestampKey];
410
+ if (raw === null || raw === undefined) {
411
+ // A timestamp is REQUIRED for the two states derived from an actual row.
412
+ // `never_seen` has no row and `unknown` means the source could not be read,
413
+ // so for those an absent time is the contract, not a violation.
414
+ return known === "fresh" || known === "stale" ? null : { state: known, observedAtMs: null };
415
+ }
416
+ if (typeof raw !== "string")
417
+ return null;
418
+ const observedAtMs = Date.parse(raw);
419
+ // An unparseable timestamp on a state that must have one is a moved contract,
420
+ // not a transient failure — the same reasoning as an unrecognized state.
421
+ if (Number.isNaN(observedAtMs))
422
+ return null;
423
+ return { state: known, observedAtMs };
424
+ }
425
+ /**
426
+ * The host identity the local reconciler publishes half of its instance id from.
427
+ *
428
+ * A seam, not a convenience: `_resolve_process_instance_id` builds
429
+ * `<hostname>:<pid>` from Python's `socket.gethostname()`, and the supervisor
430
+ * must reproduce that string exactly to scope a readiness probe to the process
431
+ * it just spawned. Injecting it keeps the resolver unit-testable without a real
432
+ * hostname, and keeps the comparison visible rather than buried in a global.
433
+ */
434
+ export function resolveHostname() {
435
+ return os.hostname();
436
+ }
272
437
  /** Node executable used for the runtime re-exec and executor members. */
273
438
  export function resolveNodeExecutable() {
274
439
  return process.execPath;