diff --git a/packages/loopover-miner/DEPLOYMENT.md b/packages/loopover-miner/DEPLOYMENT.md index 4651bb0d2..0a10b0008 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. | diff --git a/packages/loopover-miner/lib/attempt-cli.ts b/packages/loopover-miner/lib/attempt-cli.ts index fe8e7be86..a896ec5bc 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 000000000..0e972cdc2 --- /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 d758abace..e7f3231cd 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 000000000..c09b2e090 --- /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" }); + }); +});