@bridge_gpt/mcp-server 0.2.69 → 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.
- package/README.md +140 -292
- package/build/agent-launchers/claude-executor-adapter.js +17 -4
- package/build/agents.generated.js +4 -4
- package/build/artifact-diff.js +110 -0
- package/build/bin-entrypoint.js +36 -0
- package/build/bridge-permissions.js +23 -4
- package/build/claude-review-workflow-drift.js +3 -3
- package/build/codex-bridge-profile.js +1 -1
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +598 -19
- package/build/conductor/bring-up-facts.js +9 -9
- package/build/conductor/doctor.js +466 -18
- package/build/conductor/epic-child-continuation.js +54 -3
- package/build/conductor/epic-implementer-cli.js +132 -34
- package/build/conductor/epic-runtime.js +2 -2
- package/build/conductor/epic-state.js +1 -1
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/install-doctor.js +4 -2
- package/build/conductor/launch-need-probes.js +337 -0
- package/build/conductor/local-merge.js +241 -56
- package/build/conductor/merge-credential.js +172 -0
- package/build/conductor/merge-execution-eligibility.js +1 -1
- package/build/conductor/merged-child-check.js +95 -0
- package/build/conductor/plan-trim.js +30 -0
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +11 -6
- package/build/conductor/readiness-prerequisites.js +41 -0
- package/build/conductor/readiness-sections.js +345 -55
- package/build/conductor/readiness.js +99 -87
- package/build/conductor/recovery-cli.js +606 -21
- package/build/conductor/recovery-operations.js +306 -7
- package/build/conductor/repair-contract.js +47 -2
- package/build/conductor/repository-workflow-remedy.js +8 -6
- package/build/conductor/stored-merge-credential.js +76 -0
- package/build/conductor/stored-merge-token-probe.js +28 -0
- package/build/conductor/temporary-plan.js +50 -0
- package/build/conductor/wait-run-cli.js +759 -0
- package/build/conductor/worker-database-doctor-inspector.js +49 -0
- package/build/conductor-bin.js +2 -2
- package/build/conductor-claude-hook-bin.js +2 -2
- package/build/conductor-claude-hook-removed-stub-bin.js +2 -5
- package/build/conductor-removed-stub-bin.js +2 -5
- package/build/credential-store.js +356 -13
- package/build/docs.generated.js +26 -13
- package/build/doctor.js +94 -32
- package/build/drive-epic-help.js +212 -0
- package/build/drive-epic.js +1840 -373
- package/build/epic-ci-workflow-drift.js +77 -0
- package/build/epic-ci-workflow-install.js +183 -0
- package/build/epic-ci-workflow.js +157 -0
- package/build/epic-contract-context.js +326 -0
- package/build/epic-implementer-bin.js +57 -41
- package/build/epic-implementer-claude-hook-bin.js +2 -2
- package/build/escalation-reachability.js +134 -0
- package/build/executor/cleanup-report.js +99 -0
- package/build/executor/cli.js +12 -1
- package/build/executor/completion-contract.js +66 -11
- package/build/executor/completion-supervision.js +99 -0
- package/build/executor/delivery-outcome.js +179 -0
- package/build/executor/deps.js +3 -0
- package/build/executor/env.js +71 -0
- package/build/executor/failure-reason-codes.js +126 -0
- package/build/executor/heartbeat.js +38 -0
- package/build/executor/http-client.js +72 -0
- package/build/executor/integration-job.js +298 -5
- package/build/executor/job-errors.js +36 -6
- package/build/executor/job-runner.js +720 -36
- package/build/executor/likely-tests.js +36 -2
- package/build/executor/merge-job.js +145 -2
- package/build/executor/observation.js +73 -4
- package/build/executor/permissions.js +193 -97
- package/build/executor/preflight.js +14 -3
- package/build/executor/process-containment.js +28 -2
- package/build/executor/prompt-spec.js +50 -5
- package/build/executor/rebase-finalization.js +38 -3
- package/build/executor/reconcile-overlap-job.js +35 -7
- package/build/executor/required-work-ledger.js +236 -81
- package/build/executor/resume-pre-spawn.js +38 -1
- package/build/executor/runner.js +91 -3
- package/build/executor/test-clock.js +8 -0
- package/build/executor/types.js +37 -2
- package/build/executor/worker-cli-launcher.js +259 -0
- package/build/executor/worker-command.js +41 -4
- package/build/executor/worker-database-target.js +91 -0
- package/build/executor/worker-database.js +908 -0
- package/build/executor/worker-finalization.js +106 -0
- package/build/executor/worker-guard-hook.js +203 -0
- package/build/executor/worker-resources.js +47 -0
- package/build/executor/worker-verification-guidance.js +15 -2
- package/build/executor/worktree.js +303 -15
- package/build/executor-launch.js +11 -1
- package/build/generated-assets-doctor.js +9 -1
- package/build/generated-assets-health.js +13 -4
- package/build/generated-assets-legacy.js +39 -0
- package/build/generated-assets-paths.js +12 -1
- package/build/generated-assets-repair.js +4 -1
- package/build/generated-assets-test-support.js +12 -0
- package/build/github-remote.js +33 -0
- package/build/github-sign-in.js +62 -0
- package/build/index.js +1067 -536
- package/build/init.js +39 -1
- package/build/install-bridge-conductor-gate.js +78 -0
- package/build/install-bridge-conductor-setup.js +413 -0
- package/build/install-bridge-conductor.js +1226 -267
- package/build/install-bridge.js +376 -60
- package/build/mcp-install-state.js +8 -2
- package/build/merge-pull-request.js +6 -0
- package/build/merge-workflow-install.js +763 -345
- package/build/phase-result-artifacts.js +75 -20
- package/build/pipeline-orchestrator.js +247 -4
- package/build/pipelines.generated.js +35 -35
- package/build/plan-discovery.js +15 -2
- package/build/plan-execution-ledger.js +306 -17
- package/build/plan-phase-routing.js +48 -13
- package/build/plane/cli.js +105 -51
- package/build/plane/defaults.js +3 -0
- package/build/plane/plane-freshness.js +294 -0
- package/build/plane/schema-compatibility.js +137 -0
- package/build/plane/shutdown-deps.js +38 -0
- package/build/plane/shutdown.js +83 -8
- package/build/plane/terminal-run-cleanup.js +303 -0
- package/build/printed-remedy-commands.js +26 -4
- package/build/readiness-check.js +6 -2
- package/build/readiness-prerequisites.generated.js +532 -0
- package/build/readme.generated.js +1 -1
- package/build/setup-epic.js +400 -30
- package/build/shared-resources.generated.js +5 -0
- package/build/start-tickets.js +28 -15
- package/build/test-command-detection.js +247 -0
- package/build/test-command-validation.js +73 -0
- package/build/testing-config-client.js +150 -0
- package/build/ticket-delivery-artifacts.js +258 -0
- package/build/ticket-delivery-enrollment.js +306 -0
- package/build/ticket-delivery.js +205 -0
- package/build/ticket-review-summary.js +14 -0
- package/build/upgrade-cli.js +13 -0
- package/build/v2-readiness-requirements.js +552 -0
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/build/worktree-core.js +22 -7
- package/docs/BRIDGE-DIRECTORY-README.md +48 -0
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +12 -867
- package/docs/EPIC-IMPLEMENTER.md +104 -63
- package/docs/epic-implementer/getting-started.md +131 -0
- package/docs/epic-implementer/self-hosting.md +133 -0
- package/package.json +3 -3
- package/pipelines/idea-to-ticket.json +1 -1
- package/pipelines/implement-ticket.json +8 -8
- package/pipelines/review-ticket.json +2 -2
- package/design-assets/favicon/favicon-32x32.png +0 -0
- package/design-assets/just-logo-rough-draft.png +0 -0
package/build/executor/runner.js
CHANGED
|
@@ -11,10 +11,11 @@ import { runClaimedJob, runIntegrationCorrectionSpawn } from "./job-runner.js";
|
|
|
11
11
|
import { appendToActiveJobLogs } from "./job-log-registry.js";
|
|
12
12
|
import { createLiveWorkerRegistry, } from "./live-worker-registry.js";
|
|
13
13
|
import os from "node:os";
|
|
14
|
+
import { submitCleanupReport } from "./cleanup-report.js";
|
|
14
15
|
import { sweepExecutorWorktrees } from "./worktree-gc.js";
|
|
15
16
|
import { createMacProcessInspector, hostIdentityDigest, recoverInterruptedOwnership, } from "./process-containment.js";
|
|
16
17
|
import { resolveExecutorStateDir } from "./job-log-registry.js";
|
|
17
|
-
import { INTEGRATION_ACTION_PROTOCOL_VERSION, } from "./types.js";
|
|
18
|
+
import { EXECUTOR_SUPPORTED_INTEGRATION_OPERATIONS, INTEGRATION_ACTION_PROTOCOL_VERSION, } from "./types.js";
|
|
18
19
|
import { buildIntegrationCorrectionJob, createIntegrationCorrectionLeaseAdapter, runIntegrationAction, } from "./integration-job.js";
|
|
19
20
|
import { validateBranchName } from "../base-ref.js";
|
|
20
21
|
import { isTerminalEpicRunStatus } from "../epic-run-status.js";
|
|
@@ -71,7 +72,7 @@ export function formatDispatcherUnavailableDiagnostic(liveness) {
|
|
|
71
72
|
return (`executor: no fresh reconciler/dispatcher heartbeat is available ` +
|
|
72
73
|
`(reconciler_liveness=${liveness}); the dispatcher may not be running. ` +
|
|
73
74
|
"Check DISABLE_SCHEDULER and GET /automation/health; see " +
|
|
74
|
-
"
|
|
75
|
+
"`drive-epic --help-advanced`. Continuing to poll.");
|
|
75
76
|
}
|
|
76
77
|
/** The recovery diagnostic, emitted once when an absent dispatcher comes back. */
|
|
77
78
|
export function formatDispatcherRecoveredDiagnostic() {
|
|
@@ -181,6 +182,22 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
181
182
|
deadlineMs: 30_000,
|
|
182
183
|
hostDigest: hostIdentityDigest(os.hostname()),
|
|
183
184
|
selfPid: process.pid,
|
|
185
|
+
// BAPI-1504: report a CONFIRMED sweep BEFORE its record is removed. The
|
|
186
|
+
// record leaves disk only on a terminal server answer; every other
|
|
187
|
+
// outcome keeps it for the next start.
|
|
188
|
+
reportCleanup: async (report) => {
|
|
189
|
+
const jobId = Number(report.jobId);
|
|
190
|
+
if (!Number.isSafeInteger(jobId) || jobId <= 0) {
|
|
191
|
+
return { kind: "retained", reason: "malformed_response", retryable: false };
|
|
192
|
+
}
|
|
193
|
+
return submitCleanupReport({
|
|
194
|
+
httpClient,
|
|
195
|
+
jobId,
|
|
196
|
+
repoName: report.repoName,
|
|
197
|
+
ownershipToken: report.token,
|
|
198
|
+
path: "start",
|
|
199
|
+
});
|
|
200
|
+
},
|
|
184
201
|
});
|
|
185
202
|
for (const r of results) {
|
|
186
203
|
deps.errorLog(`[executor] startup ownership inspection: job ${r.jobId} cleanup ${r.verdict.verdict} ` +
|
|
@@ -193,6 +210,27 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
193
210
|
catch (err) {
|
|
194
211
|
deps.errorLog(`[executor] startup ownership inspection failed (non-fatal): ${err instanceof Error ? err.message.slice(0, 200) : "unknown error"}`);
|
|
195
212
|
}
|
|
213
|
+
// BAPI-1479 — start the worker-database orphan sweep next to the ownership
|
|
214
|
+
// inspection above, but do NOT await it: it can take seconds against a slow
|
|
215
|
+
// server, and claiming must not wait on recovery. It is cancelled at shutdown
|
|
216
|
+
// and joined (bounded) before this function returns, so no destructive work
|
|
217
|
+
// outlives the invocation. A missing server or role is a silent no-op.
|
|
218
|
+
const sweepWorkerDatabases = seams.sweepWorkerDatabases ??
|
|
219
|
+
(deps.workerResources
|
|
220
|
+
? (signal) => deps.workerResources.sweepOrphanedDatabases(signal)
|
|
221
|
+
: undefined);
|
|
222
|
+
const workerDatabaseSweepAbort = new AbortController();
|
|
223
|
+
const workerDatabaseSweep = sweepWorkerDatabases
|
|
224
|
+
? Promise.resolve()
|
|
225
|
+
.then(() => sweepWorkerDatabases(workerDatabaseSweepAbort.signal))
|
|
226
|
+
.then((result) => {
|
|
227
|
+
const line = formatWorkerDatabaseSweepLine(result);
|
|
228
|
+
if (line !== null)
|
|
229
|
+
deps.errorLog(line);
|
|
230
|
+
}, () => {
|
|
231
|
+
deps.errorLog("[executor] startup worker-database sweep failed (non-fatal)");
|
|
232
|
+
})
|
|
233
|
+
: null;
|
|
196
234
|
const active = new Map();
|
|
197
235
|
// BAPI-1259 — integration actions share `active` (and therefore the same
|
|
198
236
|
// capacity budget and shutdown drain) with ordinary jobs, keyed by negative
|
|
@@ -483,6 +521,7 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
483
521
|
shutdownRequested = true;
|
|
484
522
|
deps.errorLog("executor: shutdown signal received; terminating live workers and draining");
|
|
485
523
|
liveWorkers.requestShutdown();
|
|
524
|
+
workerDatabaseSweepAbort.abort();
|
|
486
525
|
for (const wake of shutdownWaiters.splice(0, shutdownWaiters.length))
|
|
487
526
|
wake();
|
|
488
527
|
};
|
|
@@ -514,7 +553,17 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
514
553
|
}
|
|
515
554
|
beginShutdown();
|
|
516
555
|
};
|
|
517
|
-
|
|
556
|
+
// BAPI-1504 — background tasks that must not hold a claim slot but must not
|
|
557
|
+
// outlive this invocation either (wake-time cleanup-report retries). Tracked
|
|
558
|
+
// here so the finally block below can join them, bounded.
|
|
559
|
+
const backgroundTasks = new Set();
|
|
560
|
+
const trackBackground = (task) => {
|
|
561
|
+
const tracked = task.catch(() => undefined).finally(() => {
|
|
562
|
+
backgroundTasks.delete(tracked);
|
|
563
|
+
});
|
|
564
|
+
backgroundTasks.add(tracked);
|
|
565
|
+
};
|
|
566
|
+
const control = { liveWorkers, reportAuthFatal, trackBackground };
|
|
518
567
|
/**
|
|
519
568
|
* Sleep one poll interval, or return early once shutdown is requested.
|
|
520
569
|
*
|
|
@@ -739,6 +788,11 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
739
788
|
sleep: deps.sleep,
|
|
740
789
|
errorLog: deps.errorLog,
|
|
741
790
|
cwd: deps.cwd,
|
|
791
|
+
// BAPI-1461: the stored merge-token fallback, only for an executor that
|
|
792
|
+
// did not declare itself a durable host.
|
|
793
|
+
allowStoredMergeEntry: options.scrubInheritedGithubCredentials !== true,
|
|
794
|
+
homedir: deps.homedir,
|
|
795
|
+
readFile: deps.readFile,
|
|
742
796
|
}, {
|
|
743
797
|
// A correction reuses the executor's ONE locked spawn path: the same
|
|
744
798
|
// worktree lock, containment, and supervision every worker gets.
|
|
@@ -785,6 +839,9 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
785
839
|
...(options.epicRunIds !== undefined
|
|
786
840
|
? { epic_run_ids: options.epicRunIds }
|
|
787
841
|
: { repo_wide: true }),
|
|
842
|
+
// BAPI-1497 — advertise what this executor implements, so the server
|
|
843
|
+
// offers a `post_handoff_report` only to an executor that can post it.
|
|
844
|
+
supported_operations: EXECUTOR_SUPPORTED_INTEGRATION_OPERATIONS,
|
|
788
845
|
});
|
|
789
846
|
if (result.kind === "claimed") {
|
|
790
847
|
cycleSuccessfulCall = true;
|
|
@@ -1063,6 +1120,19 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
1063
1120
|
/* a monitoring loop must never change the runner's outcome */
|
|
1064
1121
|
}
|
|
1065
1122
|
}
|
|
1123
|
+
// BAPI-1479 — cancel and join the startup worker-database sweep. Bounded by
|
|
1124
|
+
// the sweep's own budget; the abort makes it stop issuing new statements, so
|
|
1125
|
+
// no `DROP DATABASE` can run after this invocation has returned.
|
|
1126
|
+
if (workerDatabaseSweep !== null) {
|
|
1127
|
+
workerDatabaseSweepAbort.abort();
|
|
1128
|
+
await workerDatabaseSweep;
|
|
1129
|
+
}
|
|
1130
|
+
// BAPI-1504 — join wake-time cleanup-report retries. They stop on shutdown and
|
|
1131
|
+
// never remove their record because of it; the join is bounded so a retry
|
|
1132
|
+
// sleeping out its cadence cannot delay the exit.
|
|
1133
|
+
if (backgroundTasks.size > 0) {
|
|
1134
|
+
await Promise.race([Promise.allSettled([...backgroundTasks]), deps.sleep(2_000)]);
|
|
1135
|
+
}
|
|
1066
1136
|
// Unsubscribe in a runner-level `finally`, so a direct or repeated
|
|
1067
1137
|
// `runExecutor` call — an embedded runner, a `--once` invocation in a loop, a
|
|
1068
1138
|
// test suite — cannot leave stale listeners bound to a registry that has since
|
|
@@ -1090,3 +1160,21 @@ export async function runExecutor(options, deps, httpClient, seams = {}) {
|
|
|
1090
1160
|
// boundary alike.
|
|
1091
1161
|
return 0;
|
|
1092
1162
|
}
|
|
1163
|
+
/**
|
|
1164
|
+
* BAPI-1479: the startup sweep's one log line, or null for nothing to say.
|
|
1165
|
+
*
|
|
1166
|
+
* Silent when the sweep could not run (no PostgreSQL, no worker role) — that is
|
|
1167
|
+
* the normal state of a host that never set up worker databases, and of every CI
|
|
1168
|
+
* runner — and silent when there was nothing to recover. Names only counts; the
|
|
1169
|
+
* retained databases are listed by `epic-implementer doctor`.
|
|
1170
|
+
*/
|
|
1171
|
+
export function formatWorkerDatabaseSweepLine(result) {
|
|
1172
|
+
if (result.kind === "unavailable")
|
|
1173
|
+
return null;
|
|
1174
|
+
if (result.dropped.length === 0 && result.retained.length === 0 && result.kind === "completed") {
|
|
1175
|
+
return null;
|
|
1176
|
+
}
|
|
1177
|
+
const status = result.kind === "completed" ? "complete" : "incomplete";
|
|
1178
|
+
return (`[executor] startup worker-database sweep ${status}: dropped ${result.dropped.length} orphaned ` +
|
|
1179
|
+
`database(s), left ${result.retained.length} in place (see epic-implementer doctor)`);
|
|
1180
|
+
}
|
|
@@ -144,6 +144,8 @@ export function makeFakeExecutorDeps(clock, overrides = {}) {
|
|
|
144
144
|
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
145
145
|
},
|
|
146
146
|
writeFile: async () => { },
|
|
147
|
+
// The exclude writer publishes through a temporary file plus rename.
|
|
148
|
+
rename: async () => { },
|
|
147
149
|
mkdir: async () => undefined,
|
|
148
150
|
// BAPI-793 worktree lock, backed by a per-deps in-memory namespace. Real
|
|
149
151
|
// exclusivity semantics (a second create for the same path rejects with
|
|
@@ -311,6 +313,9 @@ export function claudeAdapterSeam(claudeDeps = {}) {
|
|
|
311
313
|
readFile: async () => "{}",
|
|
312
314
|
writeFile: async () => { },
|
|
313
315
|
mkdir: async () => { },
|
|
316
|
+
stat: async () => ({ mode: 0o040755 }),
|
|
317
|
+
rename: async () => { },
|
|
318
|
+
removeFile: async () => { },
|
|
314
319
|
homedir: () => "/home/test",
|
|
315
320
|
},
|
|
316
321
|
...claudeDeps,
|
|
@@ -350,6 +355,9 @@ export function syntheticAdapterSeam(mutate) {
|
|
|
350
355
|
readFile: async () => "{}",
|
|
351
356
|
writeFile: async () => { },
|
|
352
357
|
mkdir: async () => { },
|
|
358
|
+
stat: async () => ({ mode: 0o040755 }),
|
|
359
|
+
rename: async () => { },
|
|
360
|
+
removeFile: async () => { },
|
|
353
361
|
homedir: () => "/home/test",
|
|
354
362
|
},
|
|
355
363
|
});
|
package/build/executor/types.js
CHANGED
|
@@ -169,8 +169,43 @@ export function normalizeHeartbeatResult(response) {
|
|
|
169
169
|
// client and its own claim loop step.
|
|
170
170
|
/** The one protocol version this executor speaks (exact equality server-side). */
|
|
171
171
|
export const INTEGRATION_ACTION_PROTOCOL_VERSION = 1;
|
|
172
|
-
/**
|
|
173
|
-
|
|
172
|
+
/**
|
|
173
|
+
* The closed operation vocabulary. There is no merge operation. BAPI-1497 adds
|
|
174
|
+
* `post_handoff_report`: upsert the ONE marker comment that reports a quiescent
|
|
175
|
+
* run on its integration PR (opening it as a draft first when code landed and
|
|
176
|
+
* none exists). It never marks ready, never merges, and is not a cycle
|
|
177
|
+
* operation.
|
|
178
|
+
*/
|
|
179
|
+
export const INTEGRATION_ACTION_OPERATIONS = ["sync_base", "correct", "mark_ready", "post_handoff_report"];
|
|
180
|
+
/** The three preparation operations. A claim with no capabilities means these. */
|
|
181
|
+
export const INTEGRATION_ACTION_PREPARATION_OPERATIONS = ["sync_base", "correct", "mark_ready"];
|
|
182
|
+
/**
|
|
183
|
+
* The operations THIS executor implements, advertised on every claim as
|
|
184
|
+
* `supported_operations` so the server never offers work an executor cannot do.
|
|
185
|
+
*/
|
|
186
|
+
export const EXECUTOR_SUPPORTED_INTEGRATION_OPERATIONS = [
|
|
187
|
+
...INTEGRATION_ACTION_OPERATIONS,
|
|
188
|
+
];
|
|
189
|
+
/** The ONE report payload version this executor speaks (exact equality). */
|
|
190
|
+
export const HANDOFF_REPORT_PAYLOAD_VERSION = 1;
|
|
191
|
+
/** The rendered comment's ceiling; GitHub refuses a body over 65,536 characters. */
|
|
192
|
+
export const MAX_HANDOFF_REPORT_COMMENT_CHARS = 50_000;
|
|
193
|
+
/** The report shapes a payload may carry. No delivery never becomes an action. */
|
|
194
|
+
export const HANDOFF_REPORT_SHAPES = ["no_delivery", "partial_delivery", "delivered_draft_held"];
|
|
195
|
+
/**
|
|
196
|
+
* Closed `detail_code` values a `post_handoff_report` attempt reports on the
|
|
197
|
+
* EXISTING outcomes, mirrored from `api/models/epic_integration_action.py`.
|
|
198
|
+
*/
|
|
199
|
+
export const HANDOFF_REPORT_DETAIL_CODES = {
|
|
200
|
+
published: "report_published",
|
|
201
|
+
commentWriteFailed: "comment_write_failed",
|
|
202
|
+
prUnavailable: "integration_pr_unavailable",
|
|
203
|
+
prClosed: "integration_pr_closed",
|
|
204
|
+
prMerged: "integration_pr_merged",
|
|
205
|
+
payloadInvalid: "report_payload_invalid",
|
|
206
|
+
noDelivery: "report_no_delivery",
|
|
207
|
+
actorUnknown: "report_actor_unknown",
|
|
208
|
+
};
|
|
174
209
|
/** The closed fact vocabulary a report may carry. */
|
|
175
210
|
export const INTEGRATION_ACTION_OUTCOMES = [
|
|
176
211
|
"applied",
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The executor's own-build `epic-implementer` launcher for workers (BAPI-1479).
|
|
3
|
+
*
|
|
4
|
+
* A worker that runs `epic-implementer` should reach the build THIS executor is
|
|
5
|
+
* running, not whatever the published npx package resolves to. This module
|
|
6
|
+
* creates, validates, and disposes one per-job directory holding a single
|
|
7
|
+
* executable named exactly `epic-implementer`. The wrapper runs the executor's
|
|
8
|
+
* own Node (`process.execPath`) against `<packageRoot>/build/epic-implementer-bin.js`,
|
|
9
|
+
* where the package root comes from `resolvePackageRootFromModuleUrl()`.
|
|
10
|
+
*
|
|
11
|
+
* `env.ts` does the PATH prepend, and only from the explicit
|
|
12
|
+
* `workerLauncherDirectory` option. This module never touches an environment.
|
|
13
|
+
* When the launcher is unavailable, the caller passes no directory, PATH stays
|
|
14
|
+
* unchanged, and the worker guidance's npx fallback still applies.
|
|
15
|
+
*
|
|
16
|
+
* Layout: `<stateDir>/worker-launchers/<sanitized-job-id>-<random>/epic-implementer`.
|
|
17
|
+
* The `worker-launchers` parent and the per-job directory are created mode 0o700
|
|
18
|
+
* and re-checked after creation: both must be directories with no group or other
|
|
19
|
+
* write bit. The wrapper is written mode 0o700 and must exist, be executable, and
|
|
20
|
+
* have no group or other write bit before the directory is returned.
|
|
21
|
+
*
|
|
22
|
+
* Protection boundary (plan Step 1.6) — read this before relying on it.
|
|
23
|
+
* Workers run under the executor's own OS identity. Mode bits therefore stop
|
|
24
|
+
* OTHER local users from planting a wrapper, but they do not stop a worker. The
|
|
25
|
+
* executor's worker deny layer does not cover the state directory either:
|
|
26
|
+
* `executorDenyRules()` in `permissions.ts` denies credential READS and
|
|
27
|
+
* command-scoped `Bash(...)` families only (no `Write(...)`/`Edit(...)` path
|
|
28
|
+
* rules, deliberately, per Architecture Miss 23), and the PreToolUse guard in
|
|
29
|
+
* `worker-guard-hook.ts` confines only `rm -rf` / `git clean` / resets / pushes,
|
|
30
|
+
* not ordinary writes. `resolveExecutorStateDir()` (`job-log-registry.ts`) is the
|
|
31
|
+
* same unprotected-from-workers location the job-log registry and ownership
|
|
32
|
+
* records already use. So a worker COULD rewrite its own job's wrapper. The
|
|
33
|
+
* consequence is bounded: the wrapper only ever runs as that same worker
|
|
34
|
+
* identity, it lives in a per-job directory no other job uses, and it is removed
|
|
35
|
+
* at disposal. This launcher is a convenience for resolving the right build, not
|
|
36
|
+
* a security boundary against the worker it serves.
|
|
37
|
+
*
|
|
38
|
+
* Only POSIX hosts (darwin, linux) are supported: the wrapper is a `#!/bin/sh`
|
|
39
|
+
* script. Every path reaches the script inside POSIX single quotes (`'` becomes
|
|
40
|
+
* `'\''`), so spaces, quotes, `$`, and parentheses are never split or expanded,
|
|
41
|
+
* and arguments are forwarded intact through `"$@"`.
|
|
42
|
+
*/
|
|
43
|
+
import { randomBytes } from "node:crypto";
|
|
44
|
+
import { constants as fsConstants } from "node:fs";
|
|
45
|
+
import fs from "node:fs/promises";
|
|
46
|
+
import path from "node:path";
|
|
47
|
+
import { resolvePackageRootFromModuleUrl } from "../mcp-server-invocation.js";
|
|
48
|
+
/** Runbook section the unavailable advisory points the host operator to. */
|
|
49
|
+
export const WORKER_CLI_LAUNCHER_RUNBOOK_SECTION = "docs/claude/runbooks/local-db-safety.md#own-build-epic-implementer-launcher";
|
|
50
|
+
/** The wrapper's exact file name. */
|
|
51
|
+
export const WORKER_CLI_LAUNCHER_COMMAND = "epic-implementer";
|
|
52
|
+
/** Parent directory (under the executor state dir) of every per-job launcher directory. */
|
|
53
|
+
export const WORKER_CLI_LAUNCHERS_DIR_NAME = "worker-launchers";
|
|
54
|
+
/** The build entry, relative to the package root. */
|
|
55
|
+
export const WORKER_CLI_BUILD_ENTRY_SEGMENTS = ["build", "epic-implementer-bin.js"];
|
|
56
|
+
/** Default bound on one disposal. */
|
|
57
|
+
export const WORKER_CLI_LAUNCHER_DISPOSE_BUDGET_MS = 5_000;
|
|
58
|
+
const DIRECTORY_MODE = 0o700;
|
|
59
|
+
const WRAPPER_MODE = 0o700;
|
|
60
|
+
const GROUP_OTHER_WRITE = 0o022;
|
|
61
|
+
const S_IFMT = 0o170000;
|
|
62
|
+
const S_IFDIR = 0o040000;
|
|
63
|
+
const S_IFREG = 0o100000;
|
|
64
|
+
const MAX_JOB_ID_SEGMENT = 48;
|
|
65
|
+
const SUPPORTED_PLATFORMS = new Set(["darwin", "linux"]);
|
|
66
|
+
/** Reduce a job id to `[A-Za-z0-9_-]`, bounded; never empty. */
|
|
67
|
+
export function sanitizeWorkerCliLauncherJobId(jobId) {
|
|
68
|
+
const cleaned = String(jobId).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, MAX_JOB_ID_SEGMENT);
|
|
69
|
+
return cleaned.length > 0 ? cleaned : "job";
|
|
70
|
+
}
|
|
71
|
+
/** POSIX single-quote a string so `/bin/sh` takes it as one literal word. */
|
|
72
|
+
export function quotePosixShellWord(value) {
|
|
73
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
74
|
+
}
|
|
75
|
+
/** The exact wrapper script text for a Node executable and build entry. */
|
|
76
|
+
export function buildWorkerCliLauncherScript(execPath, entryPath) {
|
|
77
|
+
return [
|
|
78
|
+
"#!/bin/sh",
|
|
79
|
+
"# Bridge executor own-build epic-implementer launcher (BAPI-1479). Generated per job; do not edit.",
|
|
80
|
+
`exec ${quotePosixShellWord(execPath)} ${quotePosixShellWord(entryPath)} "$@"`,
|
|
81
|
+
"",
|
|
82
|
+
].join("\n");
|
|
83
|
+
}
|
|
84
|
+
function isUsableAbsolutePath(value) {
|
|
85
|
+
return typeof value === "string" && value.startsWith("/") && !value.includes("\0");
|
|
86
|
+
}
|
|
87
|
+
function isProtectedDirectoryMode(mode) {
|
|
88
|
+
return mode !== null && (mode & S_IFMT) === S_IFDIR && (mode & GROUP_OTHER_WRITE) === 0;
|
|
89
|
+
}
|
|
90
|
+
function isProtectedWrapperMode(mode) {
|
|
91
|
+
return mode !== null && (mode & S_IFMT) === S_IFREG && (mode & GROUP_OTHER_WRITE) === 0;
|
|
92
|
+
}
|
|
93
|
+
function defaultRandomSuffix() {
|
|
94
|
+
return randomBytes(6).toString("hex");
|
|
95
|
+
}
|
|
96
|
+
async function settles(op) {
|
|
97
|
+
try {
|
|
98
|
+
return { ok: true, value: await op() };
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return { ok: false };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Remove `dirPath`, bounded by `budgetMs`. Never throws. */
|
|
105
|
+
async function boundedRemove(deps, dirPath, budgetMs) {
|
|
106
|
+
let timer;
|
|
107
|
+
const timeout = new Promise((resolve) => {
|
|
108
|
+
timer = setTimeout(resolve, budgetMs);
|
|
109
|
+
timer.unref?.();
|
|
110
|
+
});
|
|
111
|
+
try {
|
|
112
|
+
await Promise.race([deps.removeDir(dirPath).catch(() => undefined), timeout]);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Disposal is best-effort and never throws.
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
if (timer)
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function createDefaultWorkerCliLauncherDeps(overrides) {
|
|
123
|
+
const defaults = {
|
|
124
|
+
platform: process.platform,
|
|
125
|
+
execPath: process.execPath,
|
|
126
|
+
resolvePackageRoot: () => resolvePackageRootFromModuleUrl(import.meta.url),
|
|
127
|
+
stateDir: overrides.stateDir,
|
|
128
|
+
fileExists: async (filePath) => {
|
|
129
|
+
try {
|
|
130
|
+
return (await fs.stat(filePath)).isFile();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
isExecutable: async (filePath) => {
|
|
137
|
+
try {
|
|
138
|
+
await fs.access(filePath, fsConstants.X_OK);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
mkdir: async (dirPath, mode) => {
|
|
146
|
+
await fs.mkdir(dirPath, { recursive: true, mode });
|
|
147
|
+
// mkdir's mode is filtered by the umask and ignored for an existing
|
|
148
|
+
// directory; chmod sets it exactly (and fails if we do not own it).
|
|
149
|
+
await fs.chmod(dirPath, mode);
|
|
150
|
+
},
|
|
151
|
+
writeFile: async (filePath, data, mode) => {
|
|
152
|
+
// `wx`: never follow or overwrite a pre-planted file.
|
|
153
|
+
await fs.writeFile(filePath, data, { mode, flag: "wx" });
|
|
154
|
+
await fs.chmod(filePath, mode);
|
|
155
|
+
},
|
|
156
|
+
statMode: async (filePath) => {
|
|
157
|
+
try {
|
|
158
|
+
// lstat: a symlink reports as a link, never as its target.
|
|
159
|
+
return (await fs.lstat(filePath)).mode;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
removeDir: async (dirPath) => {
|
|
166
|
+
await fs.rm(dirPath, { recursive: true, force: true });
|
|
167
|
+
},
|
|
168
|
+
randomSuffix: defaultRandomSuffix,
|
|
169
|
+
disposeBudgetMs: WORKER_CLI_LAUNCHER_DISPOSE_BUDGET_MS,
|
|
170
|
+
};
|
|
171
|
+
return { ...defaults, ...overrides };
|
|
172
|
+
}
|
|
173
|
+
export async function createWorkerCliLauncher(options, deps) {
|
|
174
|
+
const unavailable = (reason) => ({
|
|
175
|
+
kind: "unavailable",
|
|
176
|
+
reason,
|
|
177
|
+
});
|
|
178
|
+
// 1. Package root and build entry.
|
|
179
|
+
const rootResult = await settles(async () => deps.resolvePackageRoot());
|
|
180
|
+
const packageRoot = rootResult.ok ? rootResult.value : null;
|
|
181
|
+
if (!isUsableAbsolutePath(packageRoot))
|
|
182
|
+
return unavailable("package_root_unresolved");
|
|
183
|
+
const entryPath = path.posix.join(packageRoot, ...WORKER_CLI_BUILD_ENTRY_SEGMENTS);
|
|
184
|
+
const entryExists = await settles(() => deps.fileExists(entryPath));
|
|
185
|
+
if (!entryExists.ok || entryExists.value !== true)
|
|
186
|
+
return unavailable("build_entry_missing");
|
|
187
|
+
// 2. Platform: the wrapper is a POSIX shell script.
|
|
188
|
+
if (!SUPPORTED_PLATFORMS.has(deps.platform))
|
|
189
|
+
return unavailable("unsupported_platform");
|
|
190
|
+
// A relative or unusable Node path cannot produce a wrapper we can verify.
|
|
191
|
+
if (!isUsableAbsolutePath(deps.execPath))
|
|
192
|
+
return unavailable("wrapper_unverified");
|
|
193
|
+
if (!isUsableAbsolutePath(deps.stateDir))
|
|
194
|
+
return unavailable("directory_unprotected");
|
|
195
|
+
// 3. Protected parent and a fresh per-job directory.
|
|
196
|
+
const launchersRoot = path.posix.join(deps.stateDir, WORKER_CLI_LAUNCHERS_DIR_NAME);
|
|
197
|
+
const parentMade = await settles(() => deps.mkdir(launchersRoot, DIRECTORY_MODE));
|
|
198
|
+
if (!parentMade.ok)
|
|
199
|
+
return unavailable("directory_unprotected");
|
|
200
|
+
const parentMode = await settles(() => deps.statMode(launchersRoot));
|
|
201
|
+
if (!parentMode.ok || !isProtectedDirectoryMode(parentMode.value))
|
|
202
|
+
return unavailable("directory_unprotected");
|
|
203
|
+
const suffixResult = await settles(async () => (deps.randomSuffix ?? defaultRandomSuffix)());
|
|
204
|
+
const suffix = suffixResult.ok ? String(suffixResult.value).replace(/[^A-Za-z0-9]/g, "").slice(0, 32) : "";
|
|
205
|
+
if (suffix.length === 0)
|
|
206
|
+
return unavailable("directory_unprotected");
|
|
207
|
+
const directory = path.posix.join(launchersRoot, `${sanitizeWorkerCliLauncherJobId(options.jobId)}-${suffix}`);
|
|
208
|
+
// Refuse a pre-existing path: it is not ours, so we neither use nor remove it.
|
|
209
|
+
const preExisting = await settles(() => deps.statMode(directory));
|
|
210
|
+
if (!preExisting.ok || preExisting.value !== null)
|
|
211
|
+
return unavailable("directory_unprotected");
|
|
212
|
+
const budgetMs = typeof deps.disposeBudgetMs === "number" && deps.disposeBudgetMs > 0
|
|
213
|
+
? deps.disposeBudgetMs
|
|
214
|
+
: WORKER_CLI_LAUNCHER_DISPOSE_BUDGET_MS;
|
|
215
|
+
const failAfterCreate = async (reason) => {
|
|
216
|
+
await boundedRemove(deps, directory, budgetMs);
|
|
217
|
+
return unavailable(reason);
|
|
218
|
+
};
|
|
219
|
+
const dirMade = await settles(() => deps.mkdir(directory, DIRECTORY_MODE));
|
|
220
|
+
if (!dirMade.ok)
|
|
221
|
+
return failAfterCreate("directory_unprotected");
|
|
222
|
+
const dirMode = await settles(() => deps.statMode(directory));
|
|
223
|
+
if (!dirMode.ok || !isProtectedDirectoryMode(dirMode.value))
|
|
224
|
+
return failAfterCreate("directory_unprotected");
|
|
225
|
+
// 4. Write the wrapper.
|
|
226
|
+
const wrapperPath = path.posix.join(directory, WORKER_CLI_LAUNCHER_COMMAND);
|
|
227
|
+
const written = await settles(() => deps.writeFile(wrapperPath, buildWorkerCliLauncherScript(deps.execPath, entryPath), WRAPPER_MODE));
|
|
228
|
+
if (!written.ok)
|
|
229
|
+
return failAfterCreate("wrapper_write_failed");
|
|
230
|
+
// 5. Verify the wrapper before handing the directory out.
|
|
231
|
+
const exists = await settles(() => deps.fileExists(wrapperPath));
|
|
232
|
+
if (!exists.ok || exists.value !== true)
|
|
233
|
+
return failAfterCreate("wrapper_unverified");
|
|
234
|
+
const executable = await settles(() => deps.isExecutable(wrapperPath));
|
|
235
|
+
if (!executable.ok || executable.value !== true)
|
|
236
|
+
return failAfterCreate("wrapper_unverified");
|
|
237
|
+
const wrapperMode = await settles(() => deps.statMode(wrapperPath));
|
|
238
|
+
if (!wrapperMode.ok || !isProtectedWrapperMode(wrapperMode.value))
|
|
239
|
+
return failAfterCreate("wrapper_unverified");
|
|
240
|
+
// Re-check the directory last: nothing loosened it while we wrote.
|
|
241
|
+
const finalDirMode = await settles(() => deps.statMode(directory));
|
|
242
|
+
if (!finalDirMode.ok || !isProtectedDirectoryMode(finalDirMode.value)) {
|
|
243
|
+
return failAfterCreate("directory_unprotected");
|
|
244
|
+
}
|
|
245
|
+
let disposal = null;
|
|
246
|
+
const ownedDirectory = directory;
|
|
247
|
+
const dispose = () => {
|
|
248
|
+
if (disposal === null)
|
|
249
|
+
disposal = boundedRemove(deps, ownedDirectory, budgetMs);
|
|
250
|
+
return disposal;
|
|
251
|
+
};
|
|
252
|
+
return { kind: "available", directory, dispose };
|
|
253
|
+
}
|
|
254
|
+
/** The ONE bounded advisory for an unavailable launcher. */
|
|
255
|
+
export function formatWorkerCliLauncherUnavailableLine(jobId, reason) {
|
|
256
|
+
return (`[executor] job ${sanitizeWorkerCliLauncherJobId(jobId)}: own-build epic-implementer launcher unavailable ` +
|
|
257
|
+
`(non-fatal): ${reason}; workers fall back to the published npx package, which does not resolve this ` +
|
|
258
|
+
`executor's own build — host operator: see ${WORKER_CLI_LAUNCHER_RUNBOOK_SECTION}`);
|
|
259
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { renderWorkerVerificationGuidance, renderWorkerPushAndCommitGuidance } from "./worker-verification-guidance.js";
|
|
21
21
|
import { CiFixContext, ExecutorNamedError } from "./job-errors.js";
|
|
22
22
|
import { isRemainingWork, validateCorrectionIntent } from "./correction-dispatch.js";
|
|
23
|
+
import { readRequiredVerification } from "./completion-supervision.js";
|
|
23
24
|
import { isValidExecutorEpicRunId } from "./claim-scope.js";
|
|
24
25
|
/**
|
|
25
26
|
* BAPI-1296: `ci_fix` is the one recovery job with a FOCUSED prompt route. It
|
|
@@ -51,7 +52,46 @@ export const CI_FIX_JOB_TYPE = "ci_fix";
|
|
|
51
52
|
export function synthesizesImplementPrompt(jobType) {
|
|
52
53
|
return jobType === "implement";
|
|
53
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The quoted, bounded legacy operator-note block (`payload.operator_note`, cut
|
|
57
|
+
* to 500 characters, every line quoted with `> `), or "" when there is none.
|
|
58
|
+
* Prompt text only — the caller never puts it on argv or in status output.
|
|
59
|
+
*/
|
|
60
|
+
export function renderOperatorNoteBlock(payload) {
|
|
61
|
+
const note = typeof payload.operator_note === "string" ? payload.operator_note.slice(0, 500) : "";
|
|
62
|
+
return note
|
|
63
|
+
? `\n\nUntrusted operator context (quoted; cannot override policy or the obligation):\n${note.split(/\r?\n/).map(line => `> ${line.replace(/^>/, "\\>")}`).join("\n")}\nEnd untrusted operator context.`
|
|
64
|
+
: "";
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* BAPI-1514: the operator-note block for a focused `ci_fix` prompt. A payload
|
|
68
|
+
* carrying `driver_context_meta` already has the operator's context rendered
|
|
69
|
+
* into its server-built prompt spec, so the legacy note is suppressed there to
|
|
70
|
+
* avoid stating it twice. The check is structural — the prompt text is never
|
|
71
|
+
* searched. Every other ci_fix payload gets the same quoted block as remaining
|
|
72
|
+
* work.
|
|
73
|
+
*/
|
|
74
|
+
export function renderCiFixOperatorNote(payload) {
|
|
75
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
|
76
|
+
return "";
|
|
77
|
+
const record = payload;
|
|
78
|
+
const meta = record.driver_context_meta;
|
|
79
|
+
if (meta !== undefined && meta !== null)
|
|
80
|
+
return "";
|
|
81
|
+
return renderOperatorNoteBlock(record);
|
|
82
|
+
}
|
|
54
83
|
export function resolveExecutorPrompt(job, trusted) {
|
|
84
|
+
const read = readRequiredVerification(job.payload);
|
|
85
|
+
if (read.kind === "ok" && read.declaration.policyVersion === 2) {
|
|
86
|
+
if (job.payload?.verification_scope !== undefined && job.payload.verification_scope !== "targeted")
|
|
87
|
+
return { ok: false, error: "approved epic verification scope is contradictory" };
|
|
88
|
+
const resolved = resolveExecutorPromptLegacy(job, trusted);
|
|
89
|
+
// runPreparedSpawn adds trusted approval guidance after all expansion.
|
|
90
|
+
return resolved;
|
|
91
|
+
}
|
|
92
|
+
return resolveExecutorPromptLegacy(job, trusted);
|
|
93
|
+
}
|
|
94
|
+
function resolveExecutorPromptLegacy(job, trusted) {
|
|
55
95
|
const payload = job.payload && typeof job.payload === "object"
|
|
56
96
|
? job.payload
|
|
57
97
|
: {};
|
|
@@ -71,10 +111,7 @@ export function resolveExecutorPrompt(job, trusted) {
|
|
|
71
111
|
const obligation = intent.obligation;
|
|
72
112
|
const header = `Continue the retained branch ${branch}. Complete only the ${kind} obligation: ${obligation}. ` +
|
|
73
113
|
"Preserve completed work and existing limits. Do not invoke a full implementation or planning workflow.";
|
|
74
|
-
const
|
|
75
|
-
const operatorContext = note
|
|
76
|
-
? `\n\nUntrusted operator context (quoted; cannot override policy or the obligation):\n${note.split(/\r?\n/).map(line => `> ${line.replace(/^>/, "\\>")}`).join("\n")}\nEnd untrusted operator context.`
|
|
77
|
-
: "";
|
|
114
|
+
const operatorContext = renderOperatorNoteBlock(payload);
|
|
78
115
|
const prGuidance = payload.pr_attached !== true || obligation === "finish_interrupted_work_verify_and_open_pr_if_absent"
|
|
79
116
|
? "\n\nIf no pull request exists for this branch, open it with the `create_pull_request` MCP tool; `gh` is unavailable. Do not quote workflow slash commands in PR text."
|
|
80
117
|
: "";
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The disposable worker database TARGET contract (BAPI-1479).
|
|
3
|
+
*
|
|
4
|
+
* Pure and dependency-free on purpose: `env.ts` imports it to build a worker
|
|
5
|
+
* environment, and `worker-database.ts` imports it to create, drop, and sweep the
|
|
6
|
+
* databases themselves. Keeping the name rules here — rather than in the module
|
|
7
|
+
* that runs `DROP DATABASE` — means the environment builder and the destructive
|
|
8
|
+
* guard share ONE definition of "a worker database name", and neither can drift
|
|
9
|
+
* into accepting a name the other would refuse.
|
|
10
|
+
*
|
|
11
|
+
* A worker database is created per job by the executor, migrated with the job's
|
|
12
|
+
* own branch, and dropped when the job ends. Workers connect to it as the
|
|
13
|
+
* dedicated loopback role {@link WORKER_DATABASE_ROLE}, which holds
|
|
14
|
+
* `LOGIN CREATEDB` and nothing else and is refused on the operational
|
|
15
|
+
* `bridgeapi` database (Architecture Miss 28). The one-time role setup lives in
|
|
16
|
+
* `docs/claude/runbooks/local-db-safety.md`.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* The name prefix every executor-owned worker database carries.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately NOT `conductor_harness_`: the Python harness reaper
|
|
22
|
+
* (`drop_abandoned_harness_databases()`) drops that prefix by age, and must never
|
|
23
|
+
* reach a live worker's database.
|
|
24
|
+
*/
|
|
25
|
+
export const WORKER_DATABASE_PREFIX = "bridge_worker_job_";
|
|
26
|
+
/** The dedicated loopback role a worker connects as. The executor never creates it. */
|
|
27
|
+
export const WORKER_DATABASE_ROLE = "bridge_epic_worker";
|
|
28
|
+
/** Loopback host the worker role connects to — never a hostname a resolver could redirect. */
|
|
29
|
+
export const WORKER_DATABASE_HOST = "127.0.0.1";
|
|
30
|
+
/** The local PostgreSQL port. A string because it lands in an environment. */
|
|
31
|
+
export const WORKER_DATABASE_PORT = "5432";
|
|
32
|
+
/** PostgreSQL's identifier limit (`NAMEDATALEN - 1`), in bytes. Names here are ASCII. */
|
|
33
|
+
export const POSTGRES_IDENTIFIER_MAX_LENGTH = 63;
|
|
34
|
+
/** The fixed error raised for a name that is not a worker database name. */
|
|
35
|
+
export const INVALID_WORKER_DATABASE_NAME_MESSAGE = "invalid worker database name: expected the bridge_worker_job_ prefix, lowercase ASCII letters, digits, " +
|
|
36
|
+
"or underscores, and at most 63 characters";
|
|
37
|
+
/**
|
|
38
|
+
* Thrown for an invalid worker database name. A real error rather than an
|
|
39
|
+
* `assert`, so the guard holds even where assertions are compiled out, and the
|
|
40
|
+
* message is fixed so a hostile name is never echoed into a log.
|
|
41
|
+
*/
|
|
42
|
+
export class WorkerDatabaseNameError extends Error {
|
|
43
|
+
constructor() {
|
|
44
|
+
super(INVALID_WORKER_DATABASE_NAME_MESSAGE);
|
|
45
|
+
this.name = "WorkerDatabaseNameError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const WORKER_DATABASE_NAME_PATTERN = /^bridge_worker_job_[a-z0-9_]+$/;
|
|
49
|
+
/**
|
|
50
|
+
* True only for a name that carries the exact {@link WORKER_DATABASE_PREFIX}, at
|
|
51
|
+
* least one further character, only lowercase ASCII letters, digits, and
|
|
52
|
+
* underscores, and fits PostgreSQL's identifier limit. `bridgeapi`, `postgres`,
|
|
53
|
+
* and every `conductor_harness_*` name fail by construction.
|
|
54
|
+
*/
|
|
55
|
+
export function isValidWorkerDatabaseName(name) {
|
|
56
|
+
return (typeof name === "string" &&
|
|
57
|
+
name.length <= POSTGRES_IDENTIFIER_MAX_LENGTH &&
|
|
58
|
+
WORKER_DATABASE_NAME_PATTERN.test(name));
|
|
59
|
+
}
|
|
60
|
+
/** Return `name` unchanged when valid; otherwise throw {@link WorkerDatabaseNameError}. */
|
|
61
|
+
export function validateWorkerDatabaseName(name) {
|
|
62
|
+
if (!isValidWorkerDatabaseName(name))
|
|
63
|
+
throw new WorkerDatabaseNameError();
|
|
64
|
+
return name;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The environment keys that point a worker at its job database, in the shape
|
|
68
|
+
* CI's database-backed lanes use: the per-job name in `DATABASE_URL` and the
|
|
69
|
+
* `POSTGRES_DATABASE`/`POSTGRES_DB`/`PGDATABASE` trio, the loopback host, port,
|
|
70
|
+
* and role, an empty-but-PRESENT `POSTGRES_PASSWORD` (`db/utils.py`
|
|
71
|
+
* `resolve_db_params()` requires the key to exist), and the `PG*` mirrors.
|
|
72
|
+
*
|
|
73
|
+
* Validates the name first, so no environment is ever produced for a name that
|
|
74
|
+
* is not a worker database.
|
|
75
|
+
*/
|
|
76
|
+
export function buildWorkerDatabaseEnv(target) {
|
|
77
|
+
const name = validateWorkerDatabaseName(target.name);
|
|
78
|
+
return {
|
|
79
|
+
DATABASE_URL: `postgresql://${WORKER_DATABASE_ROLE}@${WORKER_DATABASE_HOST}:${WORKER_DATABASE_PORT}/${name}`,
|
|
80
|
+
POSTGRES_DATABASE: name,
|
|
81
|
+
POSTGRES_DB: name,
|
|
82
|
+
PGDATABASE: name,
|
|
83
|
+
POSTGRES_HOST: WORKER_DATABASE_HOST,
|
|
84
|
+
POSTGRES_PORT: WORKER_DATABASE_PORT,
|
|
85
|
+
POSTGRES_USER: WORKER_DATABASE_ROLE,
|
|
86
|
+
POSTGRES_PASSWORD: "",
|
|
87
|
+
PGHOST: WORKER_DATABASE_HOST,
|
|
88
|
+
PGPORT: WORKER_DATABASE_PORT,
|
|
89
|
+
PGUSER: WORKER_DATABASE_ROLE,
|
|
90
|
+
};
|
|
91
|
+
}
|