diff --git a/packages/loopover-miner/lib/deny-hook-synthesis.ts b/packages/loopover-miner/lib/deny-hook-synthesis.ts index 4033a18b2c..c0571d1c03 100644 --- a/packages/loopover-miner/lib/deny-hook-synthesis.ts +++ b/packages/loopover-miner/lib/deny-hook-synthesis.ts @@ -3,9 +3,8 @@ // this module is now a thin wrapper that re-exports those pure helpers and keeps the local SQLite store for // refresh + maintainer review before any synthesized rule takes effect. Approved rules merge with // {@link DEFAULT_DENY_RULES}; unapproved proposals never block tool calls. No behavior change. -import { chmodSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { aggregateBlockerHistory, @@ -23,6 +22,7 @@ import { } from "@loopover/engine"; import type { DenyRuleProposal, SynthesisConfig } from "@loopover/engine"; import { DEFAULT_FORGE_CONFIG } from "./forge-config.js"; +import { openLocalStoreDb } from "./local-store.js"; import type { DenyRule } from "./deny-hooks.js"; import { DENY_HOOK_SYNTHESIS_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js"; @@ -162,10 +162,9 @@ function ensureDenyRuleProposalsForgeScope(db: DatabaseSync): void { */ export function initDenyHookSynthesisStore(dbPath: string = resolveDenyHookSynthesisDbPath()): DenyHookSynthesisStore { const resolvedPath = normalizeDbPath(dbPath); - mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o700 }); - const db = new DatabaseSync(resolvedPath); - chmodSync(resolvedPath, 0o600); - db.exec("PRAGMA busy_timeout = 5000"); + // Delegate the crash-safe open (mkdir 0700 + chmod 0600 + busy_timeout + cleanup registration) to the shared + // helper (#8319) instead of hand-rolling it, matching plan-store.ts/attempt-log.ts. + const db = openLocalStoreDb(resolvedPath); db.exec(` CREATE TABLE IF NOT EXISTS deny_rule_proposals ( repo_full_name TEXT NOT NULL, diff --git a/packages/loopover-miner/lib/laptop-init.ts b/packages/loopover-miner/lib/laptop-init.ts index 17f7c24b80..dcaf59c4dc 100644 --- a/packages/loopover-miner/lib/laptop-init.ts +++ b/packages/loopover-miner/lib/laptop-init.ts @@ -1,8 +1,9 @@ -import { accessSync, chmodSync, constants, existsSync, mkdirSync } from "node:fs"; +import { accessSync, constants, existsSync } from "node:fs"; import { homedir } from "node:os"; import { delimiter, join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { applySchemaMigrations } from "./schema-version.js"; +import { openLocalStoreDb } from "./local-store.js"; import { reportCliFailure } from "./cli-error.js"; import { resolveGitHubToken } from "./github-token-resolution.js"; @@ -52,9 +53,10 @@ export function resolveLaptopStateDbPath(env: Record export function initLaptopState(env: Record = process.env): LaptopInitResult { const stateDir = resolveMinerStateDir(env); const dbPath = resolveLaptopStateDbPath(env); - mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + // Sample created before openLocalStoreDb opens (and thus creates) the file. The shared helper (#8319) does the + // mkdir 0700 (on the parent = stateDir), chmod 0600, busy_timeout, and crash-safe cleanup registration. const created = !existsSync(dbPath); - const db = new DatabaseSync(dbPath); + const db = openLocalStoreDb(dbPath); db.exec(` CREATE TABLE IF NOT EXISTS laptop_meta ( key TEXT PRIMARY KEY, @@ -67,7 +69,6 @@ export function initLaptopState(env: Record = proces db.prepare("INSERT INTO laptop_meta (key, value) VALUES ('initialized_at', ?)") .run(new Date().toISOString()); } - chmodSync(dbPath, 0o600); db.close(); return { stateDir, dbPath, created }; } diff --git a/packages/loopover-miner/lib/orb-export.ts b/packages/loopover-miner/lib/orb-export.ts index 911b78c31e..9c62a0ea1c 100644 --- a/packages/loopover-miner/lib/orb-export.ts +++ b/packages/loopover-miner/lib/orb-export.ts @@ -1,8 +1,7 @@ -import { chmodSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; -import { DatabaseSync } from "node:sqlite"; +import { join } from "node:path"; import { createHash, createHmac } from "node:crypto"; +import { openLocalStoreDb } from "./local-store.js"; import { generateAnonSecret, hmacAnonymize as engineHmacAnonymize } from "@loopover/engine"; import { readPrOutcomes } from "./pr-outcome.js"; import type { NormalizedPrOutcomePayload, PrOutcomeLedgerReader } from "./pr-outcome.js"; @@ -125,10 +124,9 @@ export function buildAnonymizedOrbBatch( */ export function openOrbExportStore(dbPath: string = resolveOrbExportDbPath()): OrbExportStore { const resolvedPath = normalizeDbPath(dbPath); - mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o700 }); - const db = new DatabaseSync(resolvedPath); - chmodSync(resolvedPath, 0o600); - db.exec("PRAGMA busy_timeout = 5000"); + // Delegate the crash-safe open (mkdir 0700 + chmod 0600 + busy_timeout + cleanup registration) to the shared + // helper (#8319) instead of hand-rolling it, matching plan-store.ts/attempt-log.ts. + const db = openLocalStoreDb(resolvedPath); db.exec(`CREATE TABLE IF NOT EXISTS orb_export_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`); const getStatement = db.prepare("SELECT value FROM orb_export_meta WHERE key = ?"); diff --git a/test/unit/miner-deny-hook-synthesis.test.ts b/test/unit/miner-deny-hook-synthesis.test.ts index 9da4426fd7..fedd9b009e 100644 --- a/test/unit/miner-deny-hook-synthesis.test.ts +++ b/test/unit/miner-deny-hook-synthesis.test.ts @@ -21,6 +21,10 @@ import type { DenyRuleProposal } from "../../packages/loopover-engine/src/miner/ // #7525: normalizeRepoFullName is defined in the engine and re-exported unchanged by the miner-lib module // above; import it from the engine source directly so the guard's src branches are the ones exercised. import { normalizeRepoFullName } from "../../packages/loopover-engine/src/miner/deny-hook-synthesis"; +import { + cleanupResourceCount, + resetProcessLifecycleForTesting, +} from "../../packages/loopover-miner/lib/process-lifecycle.js"; const tempDirs: string[] = []; const stores: Array<{ close(): void }> = []; @@ -163,6 +167,18 @@ describe("initDenyHookSynthesisStore() (#4522)", () => { expect(() => initDenyHookSynthesisStore(" ")).toThrow("invalid_deny_hook_synthesis_db_path"); }); + it("registers the store for crash-safe cleanup and unregisters it on close (#8319)", () => { + const dir = mkdtempSync(join(tmpdir(), "miner-deny-hook-synthesis-cleanup-")); + tempDirs.push(dir); + resetProcessLifecycleForTesting(); + expect(cleanupResourceCount()).toBe(0); + const store = initDenyHookSynthesisStore(join(dir, "deny-hook-synthesis.sqlite3")); + // openLocalStoreDb registered the handle for crash-safe close on SIGINT/SIGTERM; close() unregisters it. + expect(cleanupResourceCount()).toBe(1); + store.close(); + expect(cleanupResourceCount()).toBe(0); + }); + it("skips the forge-scope migration on a second open of an already-migrated file", () => { const dir = mkdtempSync(join(tmpdir(), "miner-deny-hook-synthesis-remigrate-")); tempDirs.push(dir); diff --git a/test/unit/miner-laptop-init.test.ts b/test/unit/miner-laptop-init.test.ts index 41b17f7208..5dd56c8ad9 100644 --- a/test/unit/miner-laptop-init.test.ts +++ b/test/unit/miner-laptop-init.test.ts @@ -36,6 +36,10 @@ import { resolveLaptopStateDbPath, runInit, } from "../../packages/loopover-miner/lib/laptop-init.js"; +import { + cleanupResourceCount, + resetProcessLifecycleForTesting, +} from "../../packages/loopover-miner/lib/process-lifecycle.js"; const roots: string[] = []; @@ -73,6 +77,17 @@ describe("loopover-miner laptop init (#2329)", () => { expect(checkLaptopStateSqlite(env).ok).toBe(true); }); + it("routes through the crash-safe openLocalStoreDb helper without leaking a cleanup registration (#8319)", () => { + const root = tempRoot(); + const env = { LOOPOVER_MINER_CONFIG_DIR: join(root, "state") }; + resetProcessLifecycleForTesting(); + expect(cleanupResourceCount()).toBe(0); + initLaptopState(env); + // openLocalStoreDb registered the bootstrap handle for crash-safe cleanup and initLaptopState closed it, + // which unregisters -- so a completed init leaves no leaked/half-written handle behind. + expect(cleanupResourceCount()).toBe(0); + }); + it("re-running init is idempotent and does not clobber existing metadata", () => { const root = tempRoot(); const env = { LOOPOVER_MINER_CONFIG_DIR: join(root, "state") }; diff --git a/test/unit/miner-orb-export.test.ts b/test/unit/miner-orb-export.test.ts index f41e708e16..86aa32ca23 100644 --- a/test/unit/miner-orb-export.test.ts +++ b/test/unit/miner-orb-export.test.ts @@ -19,6 +19,10 @@ import { sendAmsExportBatch, } from "../../packages/loopover-miner/lib/orb-export.js"; import type { OrbExportOutcome, OrbExportRow } from "../../packages/loopover-miner/lib/orb-export.js"; +import { + cleanupResourceCount, + resetProcessLifecycleForTesting, +} from "../../packages/loopover-miner/lib/process-lifecycle.js"; let dir: string; function storePath() { @@ -68,6 +72,17 @@ describe("orb-export store (#4277)", () => { expect(store.getCursor()).toBe("2026-01-02T00:00:00Z"); store.close(); }); + + it("registers the store for crash-safe cleanup and unregisters it on close (#8319)", () => { + resetProcessLifecycleForTesting(); + expect(cleanupResourceCount()).toBe(0); + const store = openOrbExportStore(storePath()); + // openLocalStoreDb registered the handle so a SIGINT/SIGTERM mid-write closes it instead of leaving the file + // half-written; the normal close() path unregisters, so the happy path never leaks or double-closes at exit. + expect(cleanupResourceCount()).toBe(1); + store.close(); + expect(cleanupResourceCount()).toBe(0); + }); }); describe("hmacAnonymize", () => {