@bridge_gpt/mcp-server 0.2.68 → 0.2.70

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 (166) hide show
  1. package/README.md +226 -320
  2. package/build/agent-launchers/claude-executor-adapter.js +17 -4
  3. package/build/agents.generated.js +4 -4
  4. package/build/artifact-diff.js +110 -0
  5. package/build/bin-entrypoint.js +36 -0
  6. package/build/bridge-permissions.js +650 -98
  7. package/build/check-update-cli.js +74 -0
  8. package/build/claude-review-workflow-drift.js +3 -3
  9. package/build/claude-settings.js +84 -2
  10. package/build/cli-help.js +7 -1
  11. package/build/codex-bridge-profile.js +1 -1
  12. package/build/codex-home.js +9 -1
  13. package/build/codex-launcher-pin.js +598 -0
  14. package/build/commands.generated.js +8 -8
  15. package/build/conductor/bridge-api-client.js +598 -19
  16. package/build/conductor/bring-up-facts.js +9 -9
  17. package/build/conductor/doctor.js +466 -18
  18. package/build/conductor/epic-child-continuation.js +54 -3
  19. package/build/conductor/epic-implementer-cli.js +132 -34
  20. package/build/conductor/epic-runtime.js +2 -2
  21. package/build/conductor/epic-state.js +1 -1
  22. package/build/conductor/hook-runtime-qualification.js +1 -1
  23. package/build/conductor/install-doctor.js +4 -2
  24. package/build/conductor/launch-need-probes.js +337 -0
  25. package/build/conductor/local-merge.js +241 -56
  26. package/build/conductor/merge-credential.js +172 -0
  27. package/build/conductor/merge-execution-eligibility.js +1 -1
  28. package/build/conductor/merged-child-check.js +95 -0
  29. package/build/conductor/plan-trim.js +30 -0
  30. package/build/conductor/pr-ci-producer.js +1 -1
  31. package/build/conductor/readiness-cli.js +11 -6
  32. package/build/conductor/readiness-prerequisites.js +41 -0
  33. package/build/conductor/readiness-sections.js +345 -55
  34. package/build/conductor/readiness.js +99 -87
  35. package/build/conductor/recovery-cli.js +606 -21
  36. package/build/conductor/recovery-operations.js +306 -7
  37. package/build/conductor/repair-contract.js +47 -2
  38. package/build/conductor/repository-workflow-remedy.js +8 -6
  39. package/build/conductor/stored-merge-credential.js +76 -0
  40. package/build/conductor/stored-merge-token-probe.js +28 -0
  41. package/build/conductor/temporary-plan.js +50 -0
  42. package/build/conductor/wait-run-cli.js +759 -0
  43. package/build/conductor/worker-database-doctor-inspector.js +49 -0
  44. package/build/conductor-bin.js +2 -2
  45. package/build/conductor-claude-hook-bin.js +2 -2
  46. package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
  47. package/build/conductor-removed-stub-bin.js +2 -5
  48. package/build/connect-github-api.js +33 -14
  49. package/build/credential-store.js +356 -13
  50. package/build/docs.generated.js +27 -14
  51. package/build/doctor.js +203 -38
  52. package/build/drive-epic-help.js +212 -0
  53. package/build/drive-epic.js +1840 -373
  54. package/build/epic-ci-workflow-drift.js +77 -0
  55. package/build/epic-ci-workflow-install.js +183 -0
  56. package/build/epic-ci-workflow.js +157 -0
  57. package/build/epic-contract-context.js +326 -0
  58. package/build/epic-implementer-bin.js +131 -120
  59. package/build/epic-implementer-claude-hook-bin.js +2 -2
  60. package/build/escalation-reachability.js +134 -0
  61. package/build/executor/cleanup-report.js +99 -0
  62. package/build/executor/cli.js +12 -1
  63. package/build/executor/completion-contract.js +68 -11
  64. package/build/executor/completion-supervision.js +99 -0
  65. package/build/executor/delivery-outcome.js +179 -0
  66. package/build/executor/deps.js +3 -0
  67. package/build/executor/env.js +71 -0
  68. package/build/executor/failure-reason-codes.js +126 -0
  69. package/build/executor/heartbeat.js +38 -0
  70. package/build/executor/http-client.js +115 -4
  71. package/build/executor/integration-job.js +298 -5
  72. package/build/executor/job-errors.js +36 -6
  73. package/build/executor/job-runner.js +720 -36
  74. package/build/executor/likely-tests.js +36 -2
  75. package/build/executor/merge-job.js +145 -2
  76. package/build/executor/observation.js +73 -4
  77. package/build/executor/permissions.js +193 -97
  78. package/build/executor/preflight.js +14 -3
  79. package/build/executor/process-containment.js +28 -2
  80. package/build/executor/prompt-spec.js +50 -5
  81. package/build/executor/rebase-finalization.js +38 -3
  82. package/build/executor/reconcile-overlap-job.js +35 -7
  83. package/build/executor/required-work-ledger.js +236 -81
  84. package/build/executor/resume-pre-spawn.js +38 -1
  85. package/build/executor/runner.js +91 -3
  86. package/build/executor/test-clock.js +8 -0
  87. package/build/executor/types.js +37 -2
  88. package/build/executor/worker-cli-launcher.js +259 -0
  89. package/build/executor/worker-command.js +41 -4
  90. package/build/executor/worker-database-target.js +91 -0
  91. package/build/executor/worker-database.js +908 -0
  92. package/build/executor/worker-finalization.js +106 -0
  93. package/build/executor/worker-guard-hook.js +203 -0
  94. package/build/executor/worker-resources.js +47 -0
  95. package/build/executor/worker-verification-guidance.js +15 -2
  96. package/build/executor/worktree.js +303 -15
  97. package/build/executor-launch.js +11 -1
  98. package/build/generated-assets-doctor.js +9 -1
  99. package/build/generated-assets-health.js +13 -4
  100. package/build/generated-assets-legacy.js +39 -0
  101. package/build/generated-assets-paths.js +12 -1
  102. package/build/generated-assets-repair.js +4 -1
  103. package/build/generated-assets-test-support.js +12 -0
  104. package/build/github-remote.js +33 -0
  105. package/build/github-sign-in.js +62 -0
  106. package/build/index.js +1214 -465
  107. package/build/init.js +39 -1
  108. package/build/install-bridge-conductor-gate.js +78 -0
  109. package/build/install-bridge-conductor-setup.js +413 -0
  110. package/build/install-bridge-conductor.js +1226 -267
  111. package/build/install-bridge.js +629 -350
  112. package/build/launcher-config-inspection.js +184 -19
  113. package/build/mcp-host-targets.js +4 -0
  114. package/build/mcp-identity.js +7 -0
  115. package/build/mcp-install-state.js +8 -2
  116. package/build/merge-pull-request.js +6 -0
  117. package/build/merge-workflow-install.js +763 -345
  118. package/build/phase-result-artifacts.js +75 -20
  119. package/build/pipeline-orchestrator.js +247 -4
  120. package/build/pipelines.generated.js +35 -35
  121. package/build/plan-discovery.js +15 -2
  122. package/build/plan-execution-ledger.js +306 -17
  123. package/build/plan-phase-routing.js +48 -13
  124. package/build/plane/cli.js +105 -51
  125. package/build/plane/defaults.js +3 -0
  126. package/build/plane/plane-freshness.js +294 -0
  127. package/build/plane/schema-compatibility.js +137 -0
  128. package/build/plane/shutdown-deps.js +38 -0
  129. package/build/plane/shutdown.js +83 -8
  130. package/build/plane/terminal-run-cleanup.js +303 -0
  131. package/build/printed-remedy-commands.js +26 -4
  132. package/build/readiness-check.js +6 -2
  133. package/build/readiness-prerequisites.generated.js +532 -0
  134. package/build/readme.generated.js +1 -1
  135. package/build/session-upgrade-notice.js +86 -0
  136. package/build/setup-epic.js +400 -30
  137. package/build/shared-resources.generated.js +5 -0
  138. package/build/start-tickets.js +43 -15
  139. package/build/test-command-detection.js +247 -0
  140. package/build/test-command-validation.js +73 -0
  141. package/build/testing-config-client.js +150 -0
  142. package/build/ticket-delivery-artifacts.js +258 -0
  143. package/build/ticket-delivery-enrollment.js +306 -0
  144. package/build/ticket-delivery.js +205 -0
  145. package/build/ticket-review-summary.js +14 -0
  146. package/build/tool-call-advisory.js +162 -0
  147. package/build/tool-surface-gating.js +28 -89
  148. package/build/update-check.js +97 -16
  149. package/build/update-status.js +61 -71
  150. package/build/upgrade-advice.js +15 -11
  151. package/build/upgrade-cli.js +360 -20
  152. package/build/v2-readiness-requirements.js +552 -0
  153. package/build/version.generated.js +3 -3
  154. package/build/worker-guard-hook-bin.js +2 -2
  155. package/build/worktree-core.js +22 -7
  156. package/docs/BRIDGE-DIRECTORY-README.md +48 -0
  157. package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
  158. package/docs/EPIC-IMPLEMENTER.md +104 -63
  159. package/docs/epic-implementer/getting-started.md +131 -0
  160. package/docs/epic-implementer/self-hosting.md +133 -0
  161. package/package.json +3 -3
  162. package/pipelines/idea-to-ticket.json +1 -1
  163. package/pipelines/implement-ticket.json +8 -8
  164. package/pipelines/review-ticket.json +2 -2
  165. package/design-assets/favicon/favicon-32x32.png +0 -0
  166. package/design-assets/just-logo-rough-draft.png +0 -0
@@ -0,0 +1,908 @@
1
+ /**
2
+ * Disposable worker database lifecycle (BAPI-1479).
3
+ *
4
+ * The single owner of every lifecycle stage of an executor-owned worker
5
+ * database: provisioning (verify role + boundary, create, comment, migrate),
6
+ * teardown, orphan sweeping, and read-only inspection for doctor. All database
7
+ * work runs in the EXECUTOR process, never in a worker.
8
+ *
9
+ * The exported names and types are the contract `job-runner.ts`, `runner.ts`,
10
+ * and `doctor.ts` consume.
11
+ *
12
+ * Client posture: every executor-side statement is one `psql` subprocess with an
13
+ * explicit loopback target (`-h 127.0.0.1 -p 5432 -U bridge_epic_worker -d ...`),
14
+ * `-X -w`, and a REPLACING environment (`buildPsqlEnv`), so no ambient database
15
+ * URL, password, service, `.pgpass`, or `.psqlrc` is ever used. Every
16
+ * destructive statement passes through one guarded function that re-validates
17
+ * the worker database name immediately before it runs.
18
+ */
19
+ import { spawn } from "node:child_process";
20
+ import { randomBytes } from "node:crypto";
21
+ import { constants as fsConstants } from "node:fs";
22
+ import { access, mkdir } from "node:fs/promises";
23
+ import path from "node:path";
24
+ import { parseAlembicRevisions, resolveRepositoryAlembicCommand } from "../plane/alembic-head.js";
25
+ import { CI_WORKER_ENV_CONSTANTS } from "./env.js";
26
+ import { POSTGRES_IDENTIFIER_MAX_LENGTH, WORKER_DATABASE_HOST, WORKER_DATABASE_PORT, WORKER_DATABASE_PREFIX, WORKER_DATABASE_ROLE, buildWorkerDatabaseEnv, isValidWorkerDatabaseName, validateWorkerDatabaseName, } from "./worker-database-target.js";
27
+ /** Runbook section every worker-database diagnostic points the host operator to. */
28
+ export const WORKER_DATABASE_RUNBOOK_SECTION = "docs/claude/runbooks/local-db-safety.md#worker-databases";
29
+ export const WORKER_DATABASE_PROVISION_BUDGET_MS = 120_000;
30
+ export const WORKER_DATABASE_DROP_BUDGET_MS = 20_000;
31
+ export const WORKER_DATABASE_SWEEP_BUDGET_MS = 60_000;
32
+ /** Per-statement cap for one executor-side `psql` command (inside the stage budget). */
33
+ export const WORKER_DATABASE_PSQL_STEP_TIMEOUT_MS = 15_000;
34
+ /** `PGCONNECT_TIMEOUT` (seconds) for every executor-side `psql` command. */
35
+ export const WORKER_DATABASE_CONNECT_TIMEOUT_SECONDS = "5";
36
+ /**
37
+ * Directory (under the executor state directory) used as `HOME` for PostgreSQL
38
+ * client and Alembic subprocesses. It holds nothing; it does not even need to
39
+ * exist, because every credential and service-file lookup is pointed at a
40
+ * nonexistent file explicitly (see {@link buildPsqlEnv}).
41
+ */
42
+ export const WORKER_DATABASE_CLIENT_HOME_DIR = "pg-client-home";
43
+ /** Version of the ownership comment written on every worker database. */
44
+ export const WORKER_DATABASE_METADATA_VERSION = 1;
45
+ /** The maintenance database every executor-side statement connects to. */
46
+ const MAINTENANCE_DATABASE = "postgres";
47
+ /** The operational database the worker role must be refused on. */
48
+ const OPERATIONAL_DATABASE = "bridgeapi";
49
+ /** The Python harness prefix; counted for doctor only, never a lifecycle candidate. */
50
+ const HARNESS_DATABASE_PREFIX = "conductor_harness_";
51
+ /** Bounded subprocess output captured by the default runner, per stream. */
52
+ const MAX_CAPTURED_OUTPUT_CHARS = 64 * 1024;
53
+ /** Grace between SIGTERM and SIGKILL when the default runner stops a subprocess. */
54
+ const KILL_GRACE_MS = 2_000;
55
+ /** Upper bound on worker-prefixed databases one listing will consider. */
56
+ const MAX_LISTED_DATABASES = 512;
57
+ /** Comments longer than this are not ours and are not parsed. */
58
+ const MAX_COMMENT_CHARS = 512;
59
+ /** Sweep stops (incomplete) rather than start a drop with less time than this. */
60
+ const MIN_SWEEP_DROP_MS = 1_000;
61
+ const HOST_DIGEST_PATTERN = /^[0-9a-f]{64}$/;
62
+ const RANDOM_SUFFIX_PATTERN = /^[0-9a-f]{4,16}$/;
63
+ const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
64
+ const SAFE_JOB_ID_FOR_LOG = /^[A-Za-z0-9_-]{1,64}$/;
65
+ const HEX_PATTERN = /^(?:[0-9a-f]{2})*$/;
66
+ const UNAVAILABLE_CATEGORIES = new Set([
67
+ "client_missing",
68
+ "postgres_unavailable",
69
+ "role_missing",
70
+ "role_misconfigured",
71
+ "bridgeapi_reachable",
72
+ "boundary_unknown",
73
+ "alembic_missing",
74
+ "create_failed",
75
+ "migration_failed",
76
+ "timeout",
77
+ "cancelled",
78
+ ]);
79
+ // ---------------------------------------------------------------------------
80
+ // Production seams
81
+ // ---------------------------------------------------------------------------
82
+ /**
83
+ * Production `WorkerDatabaseDeps`. `env` is the EXECUTOR's environment; only its
84
+ * `PATH` is read (to locate `psql`). Nothing else from it ever reaches a child.
85
+ */
86
+ export function createDefaultWorkerDatabaseDeps(overrides) {
87
+ const { stateDir, env, ...rest } = overrides;
88
+ return {
89
+ runProcess: runWorkerDatabaseProcess,
90
+ now: Date.now,
91
+ isPidAlive: defaultIsPidAlive,
92
+ isExecutable: defaultIsExecutable,
93
+ randomSuffix: () => randomBytes(4).toString("hex"),
94
+ platform: process.platform,
95
+ clientHome: path.join(stateDir, WORKER_DATABASE_CLIENT_HOME_DIR),
96
+ clientPath: typeof env.PATH === "string" && env.PATH.length > 0 ? env.PATH : "/usr/bin:/bin",
97
+ ...rest,
98
+ };
99
+ }
100
+ /**
101
+ * Create the executor-owned client home (0700) if it is missing. Optional: the
102
+ * client settings never depend on its contents. Never called by the read-only
103
+ * doctor inspection.
104
+ */
105
+ export async function ensureWorkerDatabaseClientHome(clientHome) {
106
+ await mkdir(clientHome, { recursive: true, mode: 0o700 });
107
+ }
108
+ function defaultIsPidAlive(pid) {
109
+ if (!Number.isSafeInteger(pid) || pid <= 0)
110
+ return null;
111
+ try {
112
+ process.kill(pid, 0);
113
+ return true;
114
+ }
115
+ catch (error) {
116
+ const code = error.code;
117
+ if (code === "ESRCH")
118
+ return false;
119
+ if (code === "EPERM")
120
+ return true;
121
+ return null;
122
+ }
123
+ }
124
+ async function defaultIsExecutable(filePath) {
125
+ try {
126
+ await access(filePath, fsConstants.X_OK);
127
+ return true;
128
+ }
129
+ catch {
130
+ return false;
131
+ }
132
+ }
133
+ function appendBounded(current, chunk) {
134
+ if (current.length >= MAX_CAPTURED_OUTPUT_CHARS)
135
+ return current;
136
+ return (current + chunk.toString("utf8")).slice(0, MAX_CAPTURED_OUTPUT_CHARS);
137
+ }
138
+ /**
139
+ * The production process runner: argument array, `shell: false`, the request's
140
+ * env as the child's COMPLETE environment, bounded capture, and SIGTERM then
141
+ * SIGKILL on timeout or abort. It resolves only after the child has CLOSED, so a
142
+ * caller that awaits it never races a still-running subprocess.
143
+ */
144
+ export function runWorkerDatabaseProcess(request) {
145
+ if (request.signal?.aborted) {
146
+ return Promise.resolve({
147
+ exitCode: null, stdout: "", stderr: "", timedOut: false, aborted: true, spawnFailed: false,
148
+ });
149
+ }
150
+ return new Promise((resolve) => {
151
+ let stdout = "";
152
+ let stderr = "";
153
+ let timedOut = false;
154
+ let aborted = false;
155
+ let settled = false;
156
+ let killTimer;
157
+ let child;
158
+ const finish = (result) => {
159
+ if (settled)
160
+ return;
161
+ settled = true;
162
+ clearTimeout(timeoutTimer);
163
+ if (killTimer)
164
+ clearTimeout(killTimer);
165
+ request.signal?.removeEventListener("abort", onAbort);
166
+ resolve(result);
167
+ };
168
+ const stop = () => {
169
+ try {
170
+ child.kill("SIGTERM");
171
+ }
172
+ catch {
173
+ // already gone
174
+ }
175
+ if (!killTimer) {
176
+ killTimer = setTimeout(() => {
177
+ try {
178
+ child.kill("SIGKILL");
179
+ }
180
+ catch {
181
+ // already gone
182
+ }
183
+ }, KILL_GRACE_MS);
184
+ }
185
+ };
186
+ const onAbort = () => {
187
+ aborted = true;
188
+ stop();
189
+ };
190
+ try {
191
+ child = spawn(request.file, [...request.args], {
192
+ shell: false,
193
+ env: { ...request.env },
194
+ cwd: request.cwd,
195
+ stdio: ["ignore", "pipe", "pipe"],
196
+ });
197
+ }
198
+ catch {
199
+ resolve({ exitCode: null, stdout: "", stderr: "", timedOut: false, aborted: false, spawnFailed: true });
200
+ return;
201
+ }
202
+ const timeoutTimer = setTimeout(() => {
203
+ timedOut = true;
204
+ stop();
205
+ }, Math.max(1, request.timeoutMs));
206
+ request.signal?.addEventListener("abort", onAbort, { once: true });
207
+ child.stdout?.on("data", (chunk) => {
208
+ stdout = appendBounded(stdout, chunk);
209
+ });
210
+ child.stderr?.on("data", (chunk) => {
211
+ stderr = appendBounded(stderr, chunk);
212
+ });
213
+ child.on("error", () => {
214
+ // A start failure (ENOENT, EACCES) never produces a pid and may not close.
215
+ if (child.pid === undefined) {
216
+ finish({ exitCode: null, stdout, stderr, timedOut, aborted, spawnFailed: true });
217
+ }
218
+ });
219
+ child.on("close", (code) => {
220
+ finish({
221
+ exitCode: timedOut || aborted ? null : code,
222
+ stdout,
223
+ stderr,
224
+ timedOut,
225
+ aborted,
226
+ spawnFailed: false,
227
+ });
228
+ });
229
+ });
230
+ }
231
+ // ---------------------------------------------------------------------------
232
+ // Client invocation
233
+ // ---------------------------------------------------------------------------
234
+ /**
235
+ * The COMPLETE environment for every executor-side `psql`. Nothing is copied from
236
+ * `process.env`, so an ambient `DATABASE_URL`, `PGPASSWORD`, `PGSERVICE`,
237
+ * `PGHOST`, or `PGUSER` can never reach the client. `PGPASSFILE`,
238
+ * `PGSERVICEFILE`, and `PGSYSCONFDIR` point at paths under the controlled home
239
+ * that do not exist, so no `.pgpass` or service file is ever read; `-X` skips
240
+ * `.psqlrc`. `LC_ALL`/`LC_MESSAGES=C` keep client-side messages in English.
241
+ */
242
+ export function buildPsqlEnv(deps) {
243
+ return {
244
+ PATH: deps.clientPath,
245
+ HOME: deps.clientHome,
246
+ PGCONNECT_TIMEOUT: WORKER_DATABASE_CONNECT_TIMEOUT_SECONDS,
247
+ LC_ALL: "C",
248
+ LC_MESSAGES: "C",
249
+ PGPASSFILE: path.join(deps.clientHome, "nonexistent.pgpass"),
250
+ PGSERVICEFILE: path.join(deps.clientHome, "nonexistent.pg_service.conf"),
251
+ PGSYSCONFDIR: path.join(deps.clientHome, "nonexistent-sysconf"),
252
+ };
253
+ }
254
+ /** The `psql` argument array: explicit loopback target, no psqlrc, never prompt. */
255
+ export function buildPsqlArgs(database, sql) {
256
+ return [
257
+ "-X",
258
+ "-w",
259
+ "-q",
260
+ "-A",
261
+ "-t",
262
+ "-v",
263
+ "ON_ERROR_STOP=1",
264
+ "-h",
265
+ WORKER_DATABASE_HOST,
266
+ "-p",
267
+ WORKER_DATABASE_PORT,
268
+ "-U",
269
+ WORKER_DATABASE_ROLE,
270
+ "-d",
271
+ database,
272
+ "-c",
273
+ sql,
274
+ ];
275
+ }
276
+ function runPsql(deps, database, sql, timeoutMs, signal) {
277
+ return deps.runProcess({
278
+ file: "psql",
279
+ args: buildPsqlArgs(database, sql),
280
+ env: buildPsqlEnv(deps),
281
+ timeoutMs: Math.max(1, timeoutMs),
282
+ signal,
283
+ });
284
+ }
285
+ /** Double-quote a VALIDATED worker database name. Throws for anything else. */
286
+ function quoteWorkerIdentifier(name) {
287
+ return `"${validateWorkerDatabaseName(name)}"`;
288
+ }
289
+ /** Single-quoted SQL literal. Callers pass only validated content; quotes are still escaped. */
290
+ function sqlLiteral(value) {
291
+ return `'${value.replace(/'/g, "''")}'`;
292
+ }
293
+ const succeeded = (r) => r.exitCode === 0 && !r.timedOut && !r.aborted && !r.spawnFailed;
294
+ // ---------------------------------------------------------------------------
295
+ // Role and boundary inspection (read-only)
296
+ // ---------------------------------------------------------------------------
297
+ /**
298
+ * One row about the worker role: login, createdb, the four forbidden attributes,
299
+ * and the number of roles it is a MEMBER of. Running it as the role against
300
+ * `postgres` is also the maintenance-connectivity proof.
301
+ */
302
+ const ROLE_INSPECTION_SQL = "SELECT r.rolcanlogin, r.rolcreatedb, r.rolsuper, r.rolcreaterole, r.rolreplication, r.rolbypassrls, " +
303
+ "(SELECT count(*) FROM pg_auth_members m WHERE m.member = r.oid) " +
304
+ `FROM pg_roles r WHERE r.rolname = ${sqlLiteral(WORKER_DATABASE_ROLE)}`;
305
+ function remaining(ctx) {
306
+ return ctx.deadline - ctx.deps.now();
307
+ }
308
+ function stepTimeout(ctx, cap) {
309
+ return Math.max(1, Math.min(cap, remaining(ctx)));
310
+ }
311
+ /** Map an interrupted subprocess to `cancelled`/`timeout`, or null when it was not interrupted. */
312
+ function interruption(ctx, r) {
313
+ if (r.aborted || ctx.signal?.aborted)
314
+ return "cancelled";
315
+ if (r.timedOut && remaining(ctx) <= 0)
316
+ return "timeout";
317
+ return null;
318
+ }
319
+ /**
320
+ * Classify a failed connection to the MAINTENANCE database. Only the client's
321
+ * own fixed messages and the server's startup messages are matched; nothing here
322
+ * is ever logged.
323
+ */
324
+ function classifyMaintenanceFailure(r) {
325
+ if (r.spawnFailed)
326
+ return "client_missing";
327
+ const text = r.stderr;
328
+ if (new RegExp(`role "${WORKER_DATABASE_ROLE}" does not exist`).test(text))
329
+ return "role_missing";
330
+ if (/password authentication failed|no password supplied|authentication failed|pg_hba\.conf|permission denied for database "postgres"|is not permitted to log in/.test(text)) {
331
+ return "role_misconfigured";
332
+ }
333
+ return "postgres_unavailable";
334
+ }
335
+ /**
336
+ * Parse the role row. Membership rule (conservative): ANY membership at all is a
337
+ * misconfiguration. The worker role needs none, and enumerating which roles
338
+ * elevate (directly, via SET ROLE, via INHERIT chains, or through predefined
339
+ * roles such as `pg_read_server_files`, `pg_write_server_files`,
340
+ * `pg_execute_server_program`, `pg_signal_backend`) is exactly the kind of list
341
+ * that silently goes stale across PostgreSQL versions.
342
+ */
343
+ export function evaluateWorkerRoleRow(stdout) {
344
+ const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
345
+ if (lines.length === 0)
346
+ return { kind: "unsafe", category: "role_missing" };
347
+ if (lines.length !== 1)
348
+ return { kind: "unknown", category: "postgres_unavailable" };
349
+ const fields = lines[0].split("|");
350
+ if (fields.length !== 7)
351
+ return { kind: "unknown", category: "postgres_unavailable" };
352
+ const flags = fields.slice(0, 6);
353
+ if (!flags.every((f) => f === "t" || f === "f"))
354
+ return { kind: "unknown", category: "postgres_unavailable" };
355
+ if (!/^\d+$/.test(fields[6]))
356
+ return { kind: "unknown", category: "postgres_unavailable" };
357
+ const [login, createdb, superuser, createrole, replication, bypassrls] = flags.map((f) => f === "t");
358
+ const memberships = Number(fields[6]);
359
+ if (!login || !createdb || superuser || createrole || replication || bypassrls || memberships !== 0) {
360
+ return { kind: "unsafe", category: "role_misconfigured" };
361
+ }
362
+ return { kind: "ok" };
363
+ }
364
+ async function checkWorkerRole(ctx) {
365
+ const r = await runPsql(ctx.deps, MAINTENANCE_DATABASE, ROLE_INSPECTION_SQL, stepTimeout(ctx, WORKER_DATABASE_PSQL_STEP_TIMEOUT_MS), ctx.signal);
366
+ const interrupted = interruption(ctx, r);
367
+ if (interrupted)
368
+ return { kind: "unknown", category: interrupted };
369
+ if (r.timedOut)
370
+ return { kind: "unknown", category: "postgres_unavailable" };
371
+ if (!succeeded(r)) {
372
+ const category = classifyMaintenanceFailure(r);
373
+ return category === "role_missing" || category === "role_misconfigured"
374
+ ? { kind: "unsafe", category }
375
+ : { kind: "unknown", category };
376
+ }
377
+ return evaluateWorkerRoleRow(r.stdout);
378
+ }
379
+ /**
380
+ * The recognized `bridgeapi` authorization denials, as `psql` prints them with
381
+ * `LC_ALL=C`. A connection-startup failure carries no SQLSTATE in psql's output
382
+ * (VERBOSITY does not apply before a session exists), so the message text is the
383
+ * only representation available; the English text assumes the server's default
384
+ * `lc_messages`. A localized server simply fails to match, which classifies as
385
+ * `boundary_unknown` — the safe direction.
386
+ * - 42501 insufficient_privilege: `FATAL: permission denied for database "bridgeapi"`
387
+ * - 28000 an explicit pg_hba `reject` naming this host, role, AND `bridgeapi`.
388
+ */
389
+ const BRIDGEAPI_DENIAL_PATTERNS = [
390
+ /FATAL:\s+permission denied for database "bridgeapi"(?=$|[\s.])/m,
391
+ new RegExp(`FATAL:\\s+pg_hba\\.conf rejects connection for host "127\\.0\\.0\\.1", user "${WORKER_DATABASE_ROLE}", database "bridgeapi"(?=$|[\\s,.])`, "m"),
392
+ ];
393
+ /** Classify one `bridgeapi` probe. Only a recognized denial is proof of safety. */
394
+ export function classifyBridgeapiProbe(r) {
395
+ if (r.spawnFailed || r.timedOut || r.aborted)
396
+ return "unknown";
397
+ if (r.exitCode === 0)
398
+ return "reachable";
399
+ if (r.exitCode === null)
400
+ return "unknown";
401
+ return BRIDGEAPI_DENIAL_PATTERNS.some((p) => p.test(r.stderr)) ? "denied" : "unknown";
402
+ }
403
+ async function probeBridgeapi(ctx) {
404
+ const r = await runPsql(ctx.deps, OPERATIONAL_DATABASE, "SELECT 1", stepTimeout(ctx, WORKER_DATABASE_PSQL_STEP_TIMEOUT_MS), ctx.signal);
405
+ const interrupted = interruption(ctx, r);
406
+ if (interrupted)
407
+ return { kind: "unknown", category: interrupted };
408
+ const verdict = classifyBridgeapiProbe(r);
409
+ if (verdict === "denied")
410
+ return { kind: "ok" };
411
+ if (verdict === "reachable")
412
+ return { kind: "unsafe", category: "bridgeapi_reachable" };
413
+ return { kind: "unknown", category: "boundary_unknown" };
414
+ }
415
+ // ---------------------------------------------------------------------------
416
+ // Names and ownership metadata
417
+ // ---------------------------------------------------------------------------
418
+ /** Lowercase `[a-z0-9_]` form of a job id, or `job` when nothing survives. */
419
+ export function sanitizeJobIdForDatabaseName(jobId, maxLength) {
420
+ const cleaned = String(jobId).toLowerCase().replace(/[^a-z0-9_]/g, "_").slice(0, Math.max(0, maxLength));
421
+ return cleaned.length > 0 ? cleaned : "job".slice(0, Math.max(1, maxLength));
422
+ }
423
+ /**
424
+ * `bridge_worker_job_<sanitized job id>_<random hex>`, at most 63 characters, or
425
+ * null when the random suffix is not lowercase hex.
426
+ */
427
+ export function buildWorkerDatabaseName(jobId, suffix) {
428
+ if (!RANDOM_SUFFIX_PATTERN.test(suffix))
429
+ return null;
430
+ const budget = POSTGRES_IDENTIFIER_MAX_LENGTH - WORKER_DATABASE_PREFIX.length - 1 - suffix.length;
431
+ if (budget < 1)
432
+ return null;
433
+ const name = `${WORKER_DATABASE_PREFIX}${sanitizeJobIdForDatabaseName(jobId, budget)}_${suffix}`;
434
+ return isValidWorkerDatabaseName(name) ? name : null;
435
+ }
436
+ const isPositivePid = (v) => typeof v === "number" && Number.isSafeInteger(v) && v > 0;
437
+ /** The exact comment text: `{"v":1,"executorPid":<int>,"hostDigest":"<sha256 hex>","createdAt":"<ISO>"}`. */
438
+ export function encodeWorkerDatabaseMetadata(metadata) {
439
+ return JSON.stringify({
440
+ v: metadata.v,
441
+ executorPid: metadata.executorPid,
442
+ hostDigest: metadata.hostDigest,
443
+ createdAt: metadata.createdAt,
444
+ });
445
+ }
446
+ /**
447
+ * Strict parser: exactly the four keys, version 1, a positive safe-integer pid, a
448
+ * 64-character lowercase hex host digest (the `hostIdentityDigest` shape), and a
449
+ * canonical ISO-8601 UTC timestamp. Anything else is null — never a guess.
450
+ */
451
+ export function parseWorkerDatabaseMetadata(comment) {
452
+ if (typeof comment !== "string" || comment.length === 0 || comment.length > MAX_COMMENT_CHARS)
453
+ return null;
454
+ let value;
455
+ try {
456
+ value = JSON.parse(comment);
457
+ }
458
+ catch {
459
+ return null;
460
+ }
461
+ if (typeof value !== "object" || value === null || Array.isArray(value))
462
+ return null;
463
+ const record = value;
464
+ const keys = Object.keys(record).sort();
465
+ if (keys.join(",") !== "createdAt,executorPid,hostDigest,v")
466
+ return null;
467
+ if (record.v !== WORKER_DATABASE_METADATA_VERSION)
468
+ return null;
469
+ if (!isPositivePid(record.executorPid))
470
+ return null;
471
+ if (typeof record.hostDigest !== "string" || !HOST_DIGEST_PATTERN.test(record.hostDigest))
472
+ return null;
473
+ const createdAt = record.createdAt;
474
+ if (typeof createdAt !== "string" || !ISO_TIMESTAMP_PATTERN.test(createdAt))
475
+ return null;
476
+ const parsed = new Date(createdAt);
477
+ if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== createdAt)
478
+ return null;
479
+ return { v: 1, executorPid: record.executorPid, hostDigest: record.hostDigest, createdAt };
480
+ }
481
+ // ---------------------------------------------------------------------------
482
+ // Inventory (read-only)
483
+ // ---------------------------------------------------------------------------
484
+ function exactPrefixPredicate(prefix) {
485
+ // `left(...) = '...'` is an exact comparison; an unescaped LIKE would treat
486
+ // every `_` in the prefix as a wildcard.
487
+ return `left(datname::text, ${prefix.length}) = ${sqlLiteral(prefix)}`;
488
+ }
489
+ /** Name and comment, both hex-encoded so no separator or newline in either can split a row. */
490
+ const LISTING_COLUMNS = "encode(convert_to(datname::text, 'UTF8'), 'hex'), " +
491
+ "CASE WHEN shobj_description(oid, 'pg_database') IS NULL THEN 'f' ELSE 't' END, " +
492
+ "encode(convert_to(coalesce(shobj_description(oid, 'pg_database'), ''), 'UTF8'), 'hex')";
493
+ const WORKER_LISTING_SQL = `SELECT ${LISTING_COLUMNS} FROM pg_database WHERE ${exactPrefixPredicate(WORKER_DATABASE_PREFIX)} ` +
494
+ `ORDER BY datname LIMIT ${MAX_LISTED_DATABASES}`;
495
+ const HARNESS_COUNT_SQL = `SELECT count(*) FROM pg_database WHERE ${exactPrefixPredicate(HARNESS_DATABASE_PREFIX)}`;
496
+ function singleDatabaseListingSql(name) {
497
+ return `SELECT ${LISTING_COLUMNS} FROM pg_database WHERE datname = ${sqlLiteral(validateWorkerDatabaseName(name))}`;
498
+ }
499
+ /**
500
+ * Parse listing rows. Rows whose name is not a VALID worker database name (for
501
+ * example a hand-made `bridge_worker_job_X`) are skipped: executor-generated
502
+ * names are always valid, so such a database is not ours and is never a
503
+ * candidate. Returns null on any unparseable row.
504
+ */
505
+ function parseListing(stdout) {
506
+ const rows = [];
507
+ for (const raw of stdout.split(/\r?\n/)) {
508
+ const line = raw.trim();
509
+ if (line.length === 0)
510
+ continue;
511
+ const fields = line.split("|");
512
+ if (fields.length !== 3)
513
+ return null;
514
+ const [nameHex, hasComment, commentHex] = fields;
515
+ if (!HEX_PATTERN.test(nameHex) || !HEX_PATTERN.test(commentHex))
516
+ return null;
517
+ if (hasComment !== "t" && hasComment !== "f")
518
+ return null;
519
+ const name = Buffer.from(nameHex, "hex").toString("utf8");
520
+ if (!isValidWorkerDatabaseName(name))
521
+ continue;
522
+ rows.push({ name, comment: hasComment === "t" ? Buffer.from(commentHex, "hex").toString("utf8") : null });
523
+ }
524
+ return rows;
525
+ }
526
+ function classifyOwnership(entry, hostDigest) {
527
+ if (entry.comment === null || entry.comment.length === 0)
528
+ return { kind: "retain", reason: "no_comment" };
529
+ const metadata = parseWorkerDatabaseMetadata(entry.comment);
530
+ if (!metadata)
531
+ return { kind: "retain", reason: "malformed_comment" };
532
+ if (metadata.hostDigest !== hostDigest)
533
+ return { kind: "retain", reason: "foreign_host" };
534
+ return { kind: "same_host", metadata };
535
+ }
536
+ async function listWorkerDatabases(ctx) {
537
+ const r = await runPsql(ctx.deps, MAINTENANCE_DATABASE, WORKER_LISTING_SQL, stepTimeout(ctx, WORKER_DATABASE_PSQL_STEP_TIMEOUT_MS), ctx.signal);
538
+ const interrupted = interruption(ctx, r);
539
+ if (interrupted)
540
+ return { kind: "unavailable", category: interrupted };
541
+ if (!succeeded(r)) {
542
+ return { kind: "unavailable", category: r.timedOut ? "postgres_unavailable" : classifyMaintenanceFailure(r) };
543
+ }
544
+ const rows = parseListing(r.stdout);
545
+ return rows ? { kind: "ok", rows } : { kind: "unavailable", category: "postgres_unavailable" };
546
+ }
547
+ function dropFailureCategory(r, signal) {
548
+ if (r.aborted || signal?.aborted)
549
+ return "cancelled";
550
+ if (r.timedOut)
551
+ return "timeout";
552
+ if (r.spawnFailed)
553
+ return "postgres_unavailable";
554
+ // psql exits 2 when the connection itself failed.
555
+ if (r.exitCode === 2)
556
+ return "postgres_unavailable";
557
+ return "drop_failed";
558
+ }
559
+ /**
560
+ * Every destructive statement in this module goes through here: normal
561
+ * teardown, provisioning rollback, comment-failure rollback, and orphan sweep.
562
+ * The throwing name guard runs immediately before EACH statement. Sessions are
563
+ * terminated only where `datname` exactly equals the validated name, excluding
564
+ * this connection and the server's own (superuser) workers, which a
565
+ * least-privilege role may not signal (see `conductor_harness/db.py`). No retry.
566
+ */
567
+ async function guardedDropWorkerDatabase(name, deps, budgetMs, signal) {
568
+ const validated = validateWorkerDatabaseName(name);
569
+ const deadline = deps.now() + Math.max(1, budgetMs);
570
+ const timeout = () => Math.max(1, deadline - deps.now());
571
+ const terminateSql = "SELECT count(pg_terminate_backend(pid)) FROM pg_stat_activity " +
572
+ `WHERE datname = ${sqlLiteral(validateWorkerDatabaseName(validated))} ` +
573
+ "AND pid <> pg_backend_pid() AND backend_type = 'client backend'";
574
+ const terminated = await runPsql(deps, MAINTENANCE_DATABASE, terminateSql, timeout(), signal);
575
+ if (!succeeded(terminated))
576
+ return { kind: "failed", category: dropFailureCategory(terminated, signal) };
577
+ const dropSql = `DROP DATABASE IF EXISTS ${quoteWorkerIdentifier(validated)}`;
578
+ const dropped = await runPsql(deps, MAINTENANCE_DATABASE, dropSql, timeout(), signal);
579
+ if (!succeeded(dropped))
580
+ return { kind: "failed", category: dropFailureCategory(dropped, signal) };
581
+ return { kind: "dropped" };
582
+ }
583
+ // ---------------------------------------------------------------------------
584
+ // Provisioning
585
+ // ---------------------------------------------------------------------------
586
+ /** An Alembic step's timeout is the remaining stage budget, so a timeout is a budget timeout. */
587
+ function alembicFailureCategory(ctx, r) {
588
+ return interruption(ctx, r) ?? (r.timedOut ? "timeout" : "migration_failed");
589
+ }
590
+ function buildAlembicEnv(name, deps) {
591
+ return {
592
+ ...CI_WORKER_ENV_CONSTANTS,
593
+ ...buildWorkerDatabaseEnv({ name }),
594
+ PATH: deps.clientPath,
595
+ HOME: deps.clientHome,
596
+ };
597
+ }
598
+ /**
599
+ * Verify the worker role and the `bridgeapi` boundary, create and comment a
600
+ * per-job database, and migrate it to the job worktree's head with the operator
601
+ * checkout's Alembic. Every failure after creation drops what was created; a
602
+ * failed rollback reports `retainedName`. Emits exactly one bounded line through
603
+ * `deps.diagnostic` on an unavailable result (plus the drop-failed line when a
604
+ * database is retained), so callers must not log the same line again.
605
+ *
606
+ * Budget: rollback time is RESERVED out of `budgetMs`, so a timed-out stage still
607
+ * leaves room to clean up. Rollback deliberately runs WITHOUT `signal`: a
608
+ * cancelled provisioning must still remove what it created. Every subprocess is
609
+ * awaited (the runner resolves only after exit) before rollback starts.
610
+ */
611
+ export async function provisionWorkerDatabase(options, deps) {
612
+ const budgetMs = Math.max(1, options.budgetMs ?? WORKER_DATABASE_PROVISION_BUDGET_MS);
613
+ const start = deps.now();
614
+ const reserve = Math.min(WORKER_DATABASE_DROP_BUDGET_MS, Math.floor(budgetMs / 4));
615
+ const finalDeadline = start + budgetMs;
616
+ const ctx = { deps, signal: options.signal, deadline: finalDeadline - reserve };
617
+ const unavailable = (category, retainedName) => {
618
+ deps.diagnostic?.(formatWorkerDatabaseUnavailableLine(options.jobId, category));
619
+ if (retainedName) {
620
+ deps.diagnostic?.(formatWorkerDatabaseDropFailedLine(options.jobId, retainedName));
621
+ return { kind: "unavailable", category, retainedName };
622
+ }
623
+ return { kind: "unavailable", category };
624
+ };
625
+ const rollback = async (name, category) => {
626
+ // A small floor keeps a rollback possible even when a runner overshot its timeout.
627
+ const rollbackBudget = Math.max(Math.min(reserve, 5_000), finalDeadline - deps.now(), 1);
628
+ const outcome = await guardedDropWorkerDatabase(name, deps, rollbackBudget);
629
+ return outcome.kind === "dropped" ? unavailable(category) : unavailable(category, name);
630
+ };
631
+ if (options.signal?.aborted)
632
+ return unavailable("cancelled");
633
+ if (!isPositivePid(options.executorPid) || !HOST_DIGEST_PATTERN.test(options.hostDigest)) {
634
+ return unavailable("create_failed");
635
+ }
636
+ // (a) role attributes and memberships — also the maintenance-connect proof.
637
+ const role = await checkWorkerRole(ctx);
638
+ if (role.kind !== "ok")
639
+ return unavailable(role.category);
640
+ // (b) the operational boundary.
641
+ const boundary = await probeBridgeapi(ctx);
642
+ if (boundary.kind !== "ok")
643
+ return unavailable(boundary.category);
644
+ // Alembic must exist before anything is created.
645
+ const alembic = resolveRepositoryAlembicCommand(options.operatorRepositoryRoot, deps.platform);
646
+ let alembicPresent = false;
647
+ try {
648
+ alembicPresent = await deps.isExecutable(alembic);
649
+ }
650
+ catch {
651
+ alembicPresent = false;
652
+ }
653
+ if (!alembicPresent)
654
+ return unavailable("alembic_missing");
655
+ // (c) create, then comment immediately.
656
+ const name = buildWorkerDatabaseName(options.jobId, deps.randomSuffix());
657
+ if (!name)
658
+ return unavailable("create_failed");
659
+ if (remaining(ctx) <= 0)
660
+ return unavailable("timeout");
661
+ const created = await runPsql(deps, MAINTENANCE_DATABASE, `CREATE DATABASE ${quoteWorkerIdentifier(name)}`, stepTimeout(ctx, WORKER_DATABASE_PSQL_STEP_TIMEOUT_MS), options.signal);
662
+ if (!succeeded(created)) {
663
+ const interrupted = interruption(ctx, created);
664
+ // exit 1 = the server rejected the statement (ON_ERROR_STOP): nothing exists.
665
+ // Anything else leaves creation uncertain, so the unique name is dropped.
666
+ if (created.exitCode === 1 && !interrupted)
667
+ return unavailable("create_failed");
668
+ if (created.spawnFailed)
669
+ return unavailable("client_missing");
670
+ return rollback(name, interrupted ?? "create_failed");
671
+ }
672
+ const metadata = encodeWorkerDatabaseMetadata({
673
+ v: 1,
674
+ executorPid: options.executorPid,
675
+ hostDigest: options.hostDigest,
676
+ createdAt: new Date(deps.now()).toISOString(),
677
+ });
678
+ const commented = await runPsql(deps, MAINTENANCE_DATABASE, `COMMENT ON DATABASE ${quoteWorkerIdentifier(name)} IS ${sqlLiteral(metadata)}`, stepTimeout(ctx, WORKER_DATABASE_PSQL_STEP_TIMEOUT_MS), options.signal);
679
+ if (!succeeded(commented))
680
+ return rollback(name, interruption(ctx, commented) ?? "create_failed");
681
+ // (d) migrate with the job worktree's migrations, then verify the head.
682
+ const env = buildAlembicEnv(name, deps);
683
+ const runAlembic = (args) => deps.runProcess({
684
+ file: alembic,
685
+ args,
686
+ cwd: options.worktreePath,
687
+ env,
688
+ timeoutMs: stepTimeout(ctx, Number.MAX_SAFE_INTEGER),
689
+ signal: options.signal,
690
+ });
691
+ if (remaining(ctx) <= 0)
692
+ return rollback(name, "timeout");
693
+ const upgraded = await runAlembic(["-c", "alembic.ini", "upgrade", "head"]);
694
+ if (!succeeded(upgraded))
695
+ return rollback(name, alembicFailureCategory(ctx, upgraded));
696
+ const outputs = [];
697
+ for (const args of [["-c", "alembic.ini", "heads"], ["-c", "alembic.ini", "current"]]) {
698
+ if (remaining(ctx) <= 0)
699
+ return rollback(name, "timeout");
700
+ const r = await runAlembic(args);
701
+ if (!succeeded(r))
702
+ return rollback(name, alembicFailureCategory(ctx, r));
703
+ outputs.push(r.stdout);
704
+ }
705
+ const heads = parseAlembicRevisions(outputs[0]);
706
+ const current = parseAlembicRevisions(outputs[1]);
707
+ if (!heads || heads.length !== 1 || !current || current.length !== 1 || heads[0] !== current[0]) {
708
+ return rollback(name, "migration_failed");
709
+ }
710
+ if (options.signal?.aborted)
711
+ return rollback(name, "cancelled");
712
+ return { kind: "provisioned", target: { name } };
713
+ }
714
+ // ---------------------------------------------------------------------------
715
+ // Teardown
716
+ // ---------------------------------------------------------------------------
717
+ /**
718
+ * Drop one validated worker database: terminate its client sessions, then drop
719
+ * it. Throws {@link WorkerDatabaseNameError} for any name that is not a worker
720
+ * database name, before any subprocess. Bounded, never retried; a failure leaves
721
+ * the ownership comment in place for the next sweep or the operator, and emits
722
+ * one bounded line through `deps.diagnostic`.
723
+ */
724
+ export async function dropWorkerDatabase(target, deps, options = {}) {
725
+ const name = validateWorkerDatabaseName(target?.name);
726
+ const outcome = await guardedDropWorkerDatabase(name, deps, options.budgetMs ?? WORKER_DATABASE_DROP_BUDGET_MS, options.signal);
727
+ if (outcome.kind === "dropped")
728
+ return { kind: "dropped" };
729
+ deps.diagnostic?.(formatWorkerDatabaseDropFailedLine(options.jobId ?? "unknown", name));
730
+ return { kind: "failed", category: outcome.category };
731
+ }
732
+ // ---------------------------------------------------------------------------
733
+ // Orphan sweep
734
+ // ---------------------------------------------------------------------------
735
+ /**
736
+ * Drop worker databases whose recorded owner is a DEAD executor on THIS host.
737
+ * Never uses age. Retains everything it cannot prove: live or unknown owners,
738
+ * foreign hosts, missing or malformed comments, and any record whose comment
739
+ * changed between listing and the re-read immediately before the drop. Emits no
740
+ * diagnostics: an unavailable sweep is routine on a host without the role.
741
+ */
742
+ export async function sweepOrphanedWorkerDatabases(options, deps) {
743
+ const ctx = {
744
+ deps,
745
+ signal: options.signal,
746
+ deadline: deps.now() + Math.max(1, options.budgetMs ?? WORKER_DATABASE_SWEEP_BUDGET_MS),
747
+ };
748
+ if (options.signal?.aborted)
749
+ return { kind: "unavailable", category: "cancelled" };
750
+ const listing = await listWorkerDatabases(ctx);
751
+ if (listing.kind !== "ok")
752
+ return { kind: "unavailable", category: listing.category };
753
+ const dropped = [];
754
+ const retained = [];
755
+ let incomplete = false;
756
+ for (const entry of listing.rows) {
757
+ const ownership = classifyOwnership(entry, options.hostDigest);
758
+ if (ownership.kind === "retain") {
759
+ retained.push({ name: entry.name, reason: ownership.reason });
760
+ continue;
761
+ }
762
+ const pid = ownership.metadata.executorPid;
763
+ if (pid === options.executorPid) {
764
+ retained.push({ name: entry.name, reason: "owner_alive" });
765
+ continue;
766
+ }
767
+ const alive = deps.isPidAlive(pid);
768
+ if (alive === true) {
769
+ retained.push({ name: entry.name, reason: "owner_alive" });
770
+ continue;
771
+ }
772
+ if (alive !== false) {
773
+ retained.push({ name: entry.name, reason: "owner_unknown" });
774
+ continue;
775
+ }
776
+ if (options.signal?.aborted || remaining(ctx) < MIN_SWEEP_DROP_MS) {
777
+ incomplete = true;
778
+ break;
779
+ }
780
+ // Re-read the comment immediately before the destructive step.
781
+ const reread = await runPsql(deps, MAINTENANCE_DATABASE, singleDatabaseListingSql(entry.name), stepTimeout(ctx, WORKER_DATABASE_PSQL_STEP_TIMEOUT_MS), options.signal);
782
+ if (!succeeded(reread)) {
783
+ incomplete = true;
784
+ break;
785
+ }
786
+ const current = parseListing(reread.stdout);
787
+ if (!current || current.length !== 1 || current[0].name !== entry.name || current[0].comment !== entry.comment) {
788
+ retained.push({ name: entry.name, reason: "ownership_changed" });
789
+ continue;
790
+ }
791
+ if (options.signal?.aborted || remaining(ctx) < MIN_SWEEP_DROP_MS) {
792
+ incomplete = true;
793
+ break;
794
+ }
795
+ const outcome = await guardedDropWorkerDatabase(entry.name, deps, Math.min(WORKER_DATABASE_DROP_BUDGET_MS, remaining(ctx)), options.signal);
796
+ if (outcome.kind === "dropped") {
797
+ dropped.push(entry.name);
798
+ }
799
+ else {
800
+ retained.push({ name: entry.name, reason: "drop_failed" });
801
+ if (outcome.category !== "drop_failed") {
802
+ incomplete = true;
803
+ break;
804
+ }
805
+ }
806
+ }
807
+ return { kind: incomplete ? "incomplete" : "completed", dropped, retained };
808
+ }
809
+ // ---------------------------------------------------------------------------
810
+ // Read-only inspection for doctor
811
+ // ---------------------------------------------------------------------------
812
+ /**
813
+ * Doctor's worker-database rows. STRICTLY read-only: only SELECT statements and a
814
+ * `bridgeapi` connection attempt — no CREATE, DROP, COMMENT, or session
815
+ * termination, and no directory creation.
816
+ */
817
+ export async function inspectWorkerDatabaseReadiness(options, deps) {
818
+ const ctx = {
819
+ deps,
820
+ signal: options.signal,
821
+ deadline: deps.now() + Math.max(1, options.budgetMs ?? WORKER_DATABASE_SWEEP_BUDGET_MS),
822
+ };
823
+ const roleOutcome = await checkWorkerRole(ctx);
824
+ const role = roleOutcome.kind === "ok"
825
+ ? { state: "healthy" }
826
+ : { state: roleOutcome.kind === "unsafe" ? "unsafe" : "unknown", category: roleOutcome.category };
827
+ // The boundary probe is meaningful only once the maintenance connection worked
828
+ // (a role row came back, healthy or not).
829
+ const maintenanceConnected = roleOutcome.kind === "ok" || (roleOutcome.kind === "unsafe" && roleOutcome.category === "role_misconfigured");
830
+ let boundary;
831
+ if (!maintenanceConnected) {
832
+ boundary = { state: "unknown", category: "boundary_unknown" };
833
+ }
834
+ else {
835
+ const b = await probeBridgeapi(ctx);
836
+ boundary =
837
+ b.kind === "ok"
838
+ ? { state: "healthy" }
839
+ : { state: b.kind === "unsafe" ? "unsafe" : "unknown", category: b.category };
840
+ }
841
+ let alembic;
842
+ try {
843
+ const exists = await deps.isExecutable(resolveRepositoryAlembicCommand(options.operatorRepositoryRoot, deps.platform));
844
+ alembic = { state: exists ? "healthy" : "unsafe" };
845
+ }
846
+ catch {
847
+ alembic = { state: "unknown" };
848
+ }
849
+ let inventory = { state: "unknown" };
850
+ const listing = await listWorkerDatabases(ctx);
851
+ if (listing.kind === "ok") {
852
+ const harness = await runPsql(deps, MAINTENANCE_DATABASE, HARNESS_COUNT_SQL, stepTimeout(ctx, WORKER_DATABASE_PSQL_STEP_TIMEOUT_MS), options.signal);
853
+ const harnessText = harness.stdout.trim();
854
+ if (succeeded(harness) && /^\d+$/.test(harnessText)) {
855
+ const value = {
856
+ active: [],
857
+ leftovers: [],
858
+ unverifiable: [],
859
+ harnessCount: Number(harnessText),
860
+ };
861
+ for (const entry of listing.rows) {
862
+ const ownership = classifyOwnership(entry, options.hostDigest);
863
+ if (ownership.kind === "retain") {
864
+ value.unverifiable.push({ name: entry.name, reason: ownership.reason });
865
+ continue;
866
+ }
867
+ const alive = deps.isPidAlive(ownership.metadata.executorPid);
868
+ if (alive === true)
869
+ value.active.push(entry.name);
870
+ else if (alive === false)
871
+ value.leftovers.push(entry.name);
872
+ else
873
+ value.unverifiable.push({ name: entry.name, reason: "owner_unknown" });
874
+ }
875
+ inventory = { state: "known", value };
876
+ }
877
+ }
878
+ return { role, boundary, alembic, inventory };
879
+ }
880
+ // ---------------------------------------------------------------------------
881
+ // Diagnostics
882
+ // ---------------------------------------------------------------------------
883
+ function safeJobIdForLog(jobId) {
884
+ const s = String(jobId);
885
+ return SAFE_JOB_ID_FOR_LOG.test(s) ? s : "unknown";
886
+ }
887
+ /**
888
+ * The ONE bounded, non-fatal job-log line for a provisioning failure. Names the
889
+ * host operator and the runbook section; `bridgeapi_reachable` gets distinct
890
+ * wording because it is an unsafe boundary, not a missing prerequisite.
891
+ */
892
+ export function formatWorkerDatabaseUnavailableLine(jobId, category) {
893
+ const id = safeJobIdForLog(jobId);
894
+ if (category === "bridgeapi_reachable") {
895
+ return (`[executor] job ${id}: UNSAFE worker database boundary: the worker role can connect to bridgeapi; ` +
896
+ `no worker database was created; continuing with the sentinel database — host operator: see ${WORKER_DATABASE_RUNBOOK_SECTION}`);
897
+ }
898
+ const safeCategory = UNAVAILABLE_CATEGORIES.has(category) ? category : "unknown";
899
+ return (`[executor] job ${id}: worker database unavailable (non-fatal): ${safeCategory}; ` +
900
+ `continuing with the sentinel database — host operator: see ${WORKER_DATABASE_RUNBOOK_SECTION}`);
901
+ }
902
+ /** The ONE bounded, non-fatal line for a teardown failure. */
903
+ export function formatWorkerDatabaseDropFailedLine(jobId, name) {
904
+ const id = safeJobIdForLog(jobId);
905
+ const subject = isValidWorkerDatabaseName(name) ? `worker database ${name}` : "a worker database";
906
+ return (`[executor] job ${id}: ${subject} could not be dropped (non-fatal); its ownership comment is kept ` +
907
+ `for review — host operator: see ${WORKER_DATABASE_RUNBOOK_SECTION}`);
908
+ }