From 263d26e193add014e91a2f682aa2d7b6da596a35 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 23 Jul 2026 05:25:05 -0700 Subject: [PATCH 1/2] feat(miner): wire the Neon branch-per-attempt DB fork into runAttempt Completes #7858's self-host scope: createAttemptDbFork/discardAttemptDbFork (#8205) are now called from runAttempt's real lifecycle, mirroring the worktree allocator's own acquire/finally-release shape exactly. Config-gated via LOOPOVER_MINER_NEON_API_KEY/_PROJECT_ID/_PARENT_BRANCH_ID (attempt-db-fork-config.ts) -- an operator who hasn't configured all three sees zero behavior change, proven by running the existing 84-test miner-attempt-cli.test.ts suite unmodified alongside the new tests. Fork creation failure aborts the attempt rather than proceeding without isolation, since this feature exists specifically so the coding agent's writes never reach the tenant's real database -- silently continuing on a fork failure would defeat that safety property. Discard failure is captured to Sentry but never blocks the rest of cleanup (same discipline this file already applies to its kill-switch ledger append). Boundary explicitly not crossed here: the resulting connection string is not threaded into the coding agent's own subprocess environment (CODING_AGENT_ENV_ALLOWLIST in cli-subprocess-driver.ts). That's a separate, security-relevant interface change to the driver/spawn layer -- widening what reaches the untrusted, attacker-content-processing subprocess -- and deserves its own focused review rather than a bolt-on here. --- packages/loopover-miner/lib/attempt-cli.ts | 53 ++++- .../lib/attempt-db-fork-config.ts | 25 ++ test/unit/miner-attempt-cli.test.ts | 223 ++++++++++++++++++ .../unit/miner-attempt-db-fork-config.test.ts | 56 +++++ 4 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 packages/loopover-miner/lib/attempt-db-fork-config.ts create mode 100644 test/unit/miner-attempt-db-fork-config.test.ts diff --git a/packages/loopover-miner/lib/attempt-cli.ts b/packages/loopover-miner/lib/attempt-cli.ts index fe8e7be86e..a896ec5bcc 100644 --- a/packages/loopover-miner/lib/attempt-cli.ts +++ b/packages/loopover-miner/lib/attempt-cli.ts @@ -13,9 +13,16 @@ // that as "skip that stage entirely"). governor.convergenceInput is now a real per-issue portfolio-queue.js read // (#5654) and governor.reputationHistory a real per-repo governor-state.js read (#5675), not placeholders. -import { fingerprintFromChangedFiles, resolveCodingAgentModeFromConfig, resolveFirstConfiguredCodingAgentDriverName } from "@loopover/engine"; -import type { CodingAgentExecutionMode, FeasibilityVerdict, LocalWriteActionSpec } from "@loopover/engine"; +import { + createAttemptDbFork, + discardAttemptDbFork, + fingerprintFromChangedFiles, + resolveCodingAgentModeFromConfig, + resolveFirstConfiguredCodingAgentDriverName, +} from "@loopover/engine"; +import type { AttemptDbFork, AttemptDbForkConfig, CodingAgentExecutionMode, FeasibilityVerdict, LocalWriteActionSpec } from "@loopover/engine"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; +import { resolveAttemptDbForkConfig } from "./attempt-db-fork-config.js"; import { constructProductionCodingAgentDriver } from "./coding-agent-construction.js"; import { runSlopAssessment } from "./slop-assessment.js"; import { fetchLiveIssueSnapshot } from "./live-issue-snapshot.js"; @@ -138,6 +145,13 @@ export type RunAttemptOptions = { fetchSelfReviewContext?: typeof FetchSelfReviewContextFn; buildCodingTaskSpec?: typeof BuildCodingTaskSpecFn; resolveAmsPolicy?: typeof ResolveAmsPolicyFn; + /** Neon branch-per-attempt disposable DB fork (#7858). Defaults to reading LOOPOVER_MINER_NEON_* env vars + * (attempt-db-fork-config.js) and, only when all three are set, the real @loopover/engine fork functions. + * An operator who hasn't configured Neon sees zero behavior change -- resolveAttemptDbForkConfig returns + * null and neither fork function is ever called. */ + resolveAttemptDbForkConfig?: typeof resolveAttemptDbForkConfig; + createAttemptDbFork?: typeof createAttemptDbFork; + discardAttemptDbFork?: typeof discardAttemptDbFork; checkMinerKillSwitch?: typeof CheckMinerKillSwitchFn; resolveMinerGoalSpec?: typeof ResolveMinerGoalSpecFn; runMinerAttempt?: typeof RunMinerAttemptFn; @@ -340,6 +354,10 @@ export async function runAttempt(args: string[], options: RunAttemptOptions = {} let worktreeResult: (PrepareAttemptWorktreeResult & { attemptOk?: boolean }) | null = null; let claimedIssue = false; let claimRecord: ClaimEntry | null = null; + // #7858: resolved once so create/discard always agree on the same config, even if env somehow changed + // mid-attempt (it never does in practice -- this just avoids re-reading env twice for the same answer). + const dbForkConfig: AttemptDbForkConfig | null = (options.resolveAttemptDbForkConfig ?? resolveAttemptDbForkConfig)(env); + let dbFork: AttemptDbFork | null = null; try { allocator = (options.openWorktreeAllocator ?? openWorktreeAllocator)(); @@ -398,6 +416,24 @@ export async function runAttempt(args: string[], options: RunAttemptOptions = {} allocation = allocator.acquire(attemptId, parsed.repoFullName); + // #7858: only when the operator has configured Neon (dbForkConfig !== null) -- otherwise a complete + // no-op, zero behavior change. A failure here ABORTS the attempt rather than proceeding without + // isolation: this feature exists specifically so the coding agent's writes never reach the tenant's real + // database, so silently continuing on a fork failure would defeat the entire safety property it provides. + if (dbForkConfig) { + try { + const createFork = options.createAttemptDbFork ?? createAttemptDbFork; + dbFork = await createFork(dbForkConfig, attemptId); + } catch (error) { + const reason = describeCliError(error); + return reportCliFailure( + parsed.json, + `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: could not create the disposable DB fork: ${reason}`, + 3, + ); + } + } + let deps; try { const buildDeps = options.buildAttemptDeps ?? buildAttemptDeps; @@ -907,6 +943,19 @@ export async function runAttempt(args: string[], options: RunAttemptOptions = {} await submitClaim({ ...claimRecord, status: "released" } as Parameters[0], { env }); } if (allocation && allocator) allocator.release(attemptId); + // #7858: discard the disposable DB fork on every terminal outcome, mirroring the worktree release above. + // Never merged back into the parent -- a hard requirement #7649 ratified explicitly. A discard failure + // must never crash the rest of this cleanup sequence (same discipline as the kill-switch ledger append + // above) -- it leaks one Neon branch rather than losing the claim/ledger release that follows it, and is + // still surfaced to Sentry so an operator can clean it up by hand. + if (dbFork && dbForkConfig) { + try { + const discardFork = options.discardAttemptDbFork ?? discardAttemptDbFork; + await discardFork(dbForkConfig, attemptId); + } catch (error) { + captureMinerError(error, { kind: "attempt_db_fork_discard_failed", repoFullName: parsed.repoFullName, attemptId, branchId: dbFork.branchId }); + } + } allocator?.close(); claimLedger?.close(); eventLedger?.close(); diff --git a/packages/loopover-miner/lib/attempt-db-fork-config.ts b/packages/loopover-miner/lib/attempt-db-fork-config.ts new file mode 100644 index 0000000000..0e972cdc25 --- /dev/null +++ b/packages/loopover-miner/lib/attempt-db-fork-config.ts @@ -0,0 +1,25 @@ +import type { AttemptDbForkConfig } from "@loopover/engine"; + +const ENV_KEYS = { + apiKey: "LOOPOVER_MINER_NEON_API_KEY", + projectId: "LOOPOVER_MINER_NEON_PROJECT_ID", + parentBranchId: "LOOPOVER_MINER_NEON_PARENT_BRANCH_ID", +} as const; + +/** + * Resolve the operator's Neon branch-per-attempt fork config (#7858) from env vars -- never from + * `.loopover-ams.yml`: an API key is a SECRET, and this codebase's own established convention keeps every + * secret in an env var, never a YAML config file (matching every other credential in this repo -- + * LOOPOVER_API_TOKEN, LOOPOVER_MCP_TOKEN, etc. -- none of which live in a config-as-code file). + * + * Requires all three vars set (trimmed, non-blank). Any one missing disables the feature entirely (returns + * null), so an operator who hasn't configured Neon sees zero behavior change -- no branch is ever created, + * the fork step in `runAttempt` becomes a complete no-op. + */ +export function resolveAttemptDbForkConfig(env: Record = process.env): AttemptDbForkConfig | null { + const apiKey = env[ENV_KEYS.apiKey]?.trim(); + const projectId = env[ENV_KEYS.projectId]?.trim(); + const parentBranchId = env[ENV_KEYS.parentBranchId]?.trim(); + if (!apiKey || !projectId || !parentBranchId) return null; + return { apiKey, projectId, parentBranchId }; +} diff --git a/test/unit/miner-attempt-cli.test.ts b/test/unit/miner-attempt-cli.test.ts index d758abaced..e7f3231cd2 100644 --- a/test/unit/miner-attempt-cli.test.ts +++ b/test/unit/miner-attempt-cli.test.ts @@ -2214,3 +2214,226 @@ describe("runAttempt: hosted soft-claim submission (#7168)", () => { expect(exitCode).toBe(7); }); }); + +describe("runAttempt: Neon branch-per-attempt DB fork (#7858)", () => { + const fakeDbForkConfig = { apiKey: "neon-test-key", projectId: "proj-1", parentBranchId: "br-parent" }; + + it("is a complete no-op when resolveAttemptDbForkConfig resolves null (default -- no Neon env vars configured)", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const createSpy = vi.fn(); + const discardSpy = vi.fn(); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + resolveAttemptDbForkConfig: () => null, + createAttemptDbFork: createSpy, + discardAttemptDbFork: discardSpy, + ...readyPipelineOptions({ runMinerAttempt: async () => ({ outcome: "abandon", loopResult: fakeLoopResult() }) }), + }); + + expect(exitCode).toBe(7); + expect(createSpy).not.toHaveBeenCalled(); + expect(discardSpy).not.toHaveBeenCalled(); + }); + + it("creates the fork after the worktree slot is acquired, and discards it in finally on a submitted attempt", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const worktreeResult = fakeWorktreeResult(); + const createSpy = vi.fn().mockResolvedValue({ branchId: "br-attempt-1", connectionString: "postgres://fake" }); + const discardSpy = vi.fn().mockResolvedValue(undefined); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + attemptId: "fixed-attempt-id", + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + resolveAttemptDbForkConfig: () => fakeDbForkConfig, + createAttemptDbFork: createSpy, + discardAttemptDbFork: discardSpy, + ...readyPipelineOptions({ + runMinerAttempt: async () => ({ + outcome: "submitted", + spec: { command: "gh pr create", cwd: worktreeResult.worktreePath, timeoutMs: 1000 }, + execResult: { code: 0 }, + loopResult: fakeLoopResult(), + }), + }), + }); + + expect(exitCode).toBe(0); + expect(createSpy).toHaveBeenCalledExactlyOnceWith(fakeDbForkConfig, "fixed-attempt-id"); + expect(discardSpy).toHaveBeenCalledExactlyOnceWith(fakeDbForkConfig, "fixed-attempt-id"); + // Created before discarded, not the other way around. + expect(createSpy.mock.invocationCallOrder[0]).toBeLessThan(discardSpy.mock.invocationCallOrder[0]!); + }); + + it("also discards the fork on a non-submitted (abandoned) outcome -- cleanup isn't conditional on success", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const createSpy = vi.fn().mockResolvedValue({ branchId: "br-attempt-1", connectionString: "postgres://fake" }); + const discardSpy = vi.fn().mockResolvedValue(undefined); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + attemptId: "fixed-attempt-id", + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + resolveAttemptDbForkConfig: () => fakeDbForkConfig, + createAttemptDbFork: createSpy, + discardAttemptDbFork: discardSpy, + ...readyPipelineOptions({ runMinerAttempt: async () => ({ outcome: "abandon", loopResult: fakeLoopResult() }) }), + }); + + expect(exitCode).toBe(7); + expect(createSpy).toHaveBeenCalledOnce(); + expect(discardSpy).toHaveBeenCalledExactlyOnceWith(fakeDbForkConfig, "fixed-attempt-id"); + }); + + it("aborts the attempt with a clear reason when fork creation fails, and never calls discard (nothing was created)", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const createSpy = vi.fn().mockRejectedValue(new Error("Neon API POST /branches failed (500): internal error")); + const discardSpy = vi.fn(); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + resolveAttemptDbForkConfig: () => fakeDbForkConfig, + createAttemptDbFork: createSpy, + discardAttemptDbFork: discardSpy, + ...readyPipelineOptions(), + }); + + expect(exitCode).toBe(3); + expect(errorSpy.mock.calls.some((call) => String(call[0]).includes("could not create the disposable DB fork"))).toBe(true); + expect(discardSpy).not.toHaveBeenCalled(); + }); + + it("still discards a successfully-created fork even when worktree preparation subsequently fails -- no leaked branch on an early abort", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const createSpy = vi.fn().mockResolvedValue({ branchId: "br-attempt-1", connectionString: "postgres://fake" }); + const discardSpy = vi.fn().mockResolvedValue(undefined); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + attemptId: "fixed-attempt-id", + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + resolveAttemptDbForkConfig: () => fakeDbForkConfig, + createAttemptDbFork: createSpy, + discardAttemptDbFork: discardSpy, + ...readyPipelineOptions({ prepareAttemptWorktree: async () => ({ ok: false, error: "clone failed: repository not found" }) }), + }); + + expect(exitCode).toBe(6); // blocked_worktree_preparation_failed + expect(createSpy).toHaveBeenCalledOnce(); + expect(discardSpy).toHaveBeenCalledExactlyOnceWith(fakeDbForkConfig, "fixed-attempt-id"); + }); + + it("REGRESSION: a discard failure is captured to Sentry but never prevents the rest of cleanup (claim release still runs)", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const captureSpy = vi.spyOn(minerSentryModule, "captureMinerError").mockImplementation(() => undefined); + const createSpy = vi.fn().mockResolvedValue({ branchId: "br-attempt-1", connectionString: "postgres://fake" }); + const discardSpy = vi.fn().mockRejectedValue(new Error("Neon API DELETE /branches/br-attempt-1 failed (503): unavailable")); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop", LOOPOVER_MINER_DISCOVERY_PLANE: "true" }, + attemptId: "fixed-attempt-id", + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + resolveAttemptDbForkConfig: () => fakeDbForkConfig, + createAttemptDbFork: createSpy, + discardAttemptDbFork: discardSpy, + ...readyPipelineOptions({ runMinerAttempt: async () => ({ outcome: "abandon", loopResult: fakeLoopResult() }) }), + }); + + // The attempt's own outcome is unaffected by the discard failure -- it already returned before `finally` ran. + expect(exitCode).toBe(7); + expect(discardSpy).toHaveBeenCalledOnce(); + expect(captureSpy).toHaveBeenCalledExactlyOnceWith( + expect.any(Error), + expect.objectContaining({ kind: "attempt_db_fork_discard_failed", repoFullName: "acme/widgets", attemptId: "fixed-attempt-id", branchId: "br-attempt-1" }), + ); + }); + + it("falls back to the REAL createAttemptDbFork/discardAttemptDbFork (not just resolveAttemptDbForkConfig) when only those two are omitted -- exercises the actual @loopover/engine functions against a mocked Neon API", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const originalFetch = globalThis.fetch; + const responses: unknown[] = [ + { branches: [] }, // create: list -> not found + { databases: [{ name: "tenant-db" }] }, // create: parent database name + { branch: { id: "br-real-1", name: "attempt-real-fallback-attempt" }, endpoints: [{ host: "ep.neon.tech" }], operations: [] }, // create: branch + { role: { name: "attempt-real-fallback-attempt", password: "pw" }, operations: [] }, // create: role + { branches: [{ id: "br-real-1", name: "attempt-real-fallback-attempt" }] }, // discard: list -> found + { operations: [] }, // discard: delete + ]; + globalThis.fetch = (async () => new Response(JSON.stringify(responses.shift()), { status: 200 })) as unknown as typeof fetch; + + try { + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + attemptId: "real-fallback-attempt", + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + // Only the config resolver is overridden -- createAttemptDbFork/discardAttemptDbFork are deliberately + // left at their real @loopover/engine defaults, exercising the options.X ?? realX fallback itself. + resolveAttemptDbForkConfig: () => ({ apiKey: "k", projectId: "p", parentBranchId: "br-parent" }), + ...readyPipelineOptions({ runMinerAttempt: async () => ({ outcome: "abandon", loopResult: fakeLoopResult() }) }), + }); + + expect(exitCode).toBe(7); + expect(responses).toHaveLength(0); // every queued response was consumed -- the real create+discard round-trip ran + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("falls back to the real resolveAttemptDbForkConfig/createAttemptDbFork/discardAttemptDbFork when no override is injected (still a no-op without configured env vars)", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + // Deliberately no resolveAttemptDbForkConfig/createAttemptDbFork/discardAttemptDbFork override, and no + // LOOPOVER_MINER_NEON_* vars in env -- proves the real default resolver correctly disables the feature + // for every existing operator/test that has never heard of it, matching every OTHER test in this file. + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + ...readyPipelineOptions({ runMinerAttempt: async () => ({ outcome: "abandon", loopResult: fakeLoopResult() }) }), + }); + + expect(exitCode).toBe(7); + }); +}); diff --git a/test/unit/miner-attempt-db-fork-config.test.ts b/test/unit/miner-attempt-db-fork-config.test.ts new file mode 100644 index 0000000000..c09b2e090a --- /dev/null +++ b/test/unit/miner-attempt-db-fork-config.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveAttemptDbForkConfig } from "../../packages/loopover-miner/lib/attempt-db-fork-config.js"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("resolveAttemptDbForkConfig (#7858)", () => { + const ALL_SET = { + LOOPOVER_MINER_NEON_API_KEY: "neon-key", + LOOPOVER_MINER_NEON_PROJECT_ID: "proj-1", + LOOPOVER_MINER_NEON_PARENT_BRANCH_ID: "br-parent", + }; + + it("resolves a config when all three env vars are set", () => { + expect(resolveAttemptDbForkConfig(ALL_SET)).toEqual({ apiKey: "neon-key", projectId: "proj-1", parentBranchId: "br-parent" }); + }); + + it("trims whitespace from each value", () => { + expect( + resolveAttemptDbForkConfig({ + LOOPOVER_MINER_NEON_API_KEY: " neon-key ", + LOOPOVER_MINER_NEON_PROJECT_ID: "\tproj-1\t", + LOOPOVER_MINER_NEON_PARENT_BRANCH_ID: "\nbr-parent\n", + }), + ).toEqual({ apiKey: "neon-key", projectId: "proj-1", parentBranchId: "br-parent" }); + }); + + it("returns null when no env vars are set at all", () => { + expect(resolveAttemptDbForkConfig({})).toBeNull(); + }); + + it.each(["LOOPOVER_MINER_NEON_API_KEY", "LOOPOVER_MINER_NEON_PROJECT_ID", "LOOPOVER_MINER_NEON_PARENT_BRANCH_ID"] as const)( + "returns null when only %s is missing -- all three are required, not a majority", + (missingKey) => { + const partial = { ...ALL_SET, [missingKey]: undefined }; + expect(resolveAttemptDbForkConfig(partial)).toBeNull(); + }, + ); + + it.each(["LOOPOVER_MINER_NEON_API_KEY", "LOOPOVER_MINER_NEON_PROJECT_ID", "LOOPOVER_MINER_NEON_PARENT_BRANCH_ID"] as const)( + "treats a blank/whitespace-only %s the same as unset", + (blankKey) => { + const partial = { ...ALL_SET, [blankKey]: " " }; + expect(resolveAttemptDbForkConfig(partial)).toBeNull(); + }, + ); + + it("defaults to real process.env when no env argument is passed", () => { + vi.stubEnv("LOOPOVER_MINER_NEON_API_KEY", "neon-key"); + vi.stubEnv("LOOPOVER_MINER_NEON_PROJECT_ID", "proj-1"); + vi.stubEnv("LOOPOVER_MINER_NEON_PARENT_BRANCH_ID", "br-parent"); + + expect(resolveAttemptDbForkConfig()).toEqual({ apiKey: "neon-key", projectId: "proj-1", parentBranchId: "br-parent" }); + }); +}); From b976d5ba8ae46500d95083f9f179f1e78aec205e Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Thu, 23 Jul 2026 05:33:44 -0700 Subject: [PATCH 2/2] fix(docs): document the Neon fork env vars in miner DEPLOYMENT.md npm run test:miner-deployment-docs-audit failed CI: LOOPOVER_MINER_NEON_API_KEY/ _PROJECT_ID/_PARENT_BRANCH_ID (attempt-db-fork-config.ts) were read but undocumented. --- packages/loopover-miner/DEPLOYMENT.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/loopover-miner/DEPLOYMENT.md b/packages/loopover-miner/DEPLOYMENT.md index 4651bb0d2b..0a10b00086 100644 --- a/packages/loopover-miner/DEPLOYMENT.md +++ b/packages/loopover-miner/DEPLOYMENT.md @@ -199,6 +199,7 @@ and `LOOPOVER_MINER_CONFIG_DIR` are covered above under the fleet/state notes; t | `LOOPOVER_MINER_CHAT_ACTIONS` | `lib/chat-action-dispatch.js` | Truthy string enables the chat-action-dispatch flag (off by default). | | `LOOPOVER_MINER_LEDGER_RETENTION_DAYS`, `LOOPOVER_MINER_LEDGER_RETENTION_MAX_ROWS` | `lib/store-maintenance.js` | Opt-in ledger retention window / row cap. | | `LOOPOVER_MINER_LOG_LEVEL` | `lib/logger.js` | Log verbosity override. | +| `LOOPOVER_MINER_NEON_API_KEY`, `LOOPOVER_MINER_NEON_PROJECT_ID`, `LOOPOVER_MINER_NEON_PARENT_BRANCH_ID` | `lib/attempt-db-fork-config.js` | Neon branch-per-attempt disposable DB fork ([#7858](https://github.com/JSONbored/loopover/issues/7858)). All three required together — any one missing disables the feature entirely (no branch is ever created). | | `LOOPOVER_MINER_NO_UPDATE_CHECK` | `lib/update-check.js` | Truthy string disables the background update check. | | `LOOPOVER_MINER_REPO_CLONE_DIR` | `lib/repo-clone.js` | Base directory for cloned target repos. | | `LOOPOVER_MINER_WORKTREE_DIR` | `lib/worktree-allocator.js` | Base directory for per-attempt git worktrees. |