From eecad9d1e69fe4f39dbc10659652dc9ef965cbe4 Mon Sep 17 00:00:00 2001 From: philluiz2323 Date: Fri, 24 Jul 2026 19:46:29 +0400 Subject: [PATCH] fix(miner): route orb-export, deny-hook-synthesis, and laptop-init through openLocalStoreDb (#8319) Three of the package's local SQLite stores hand-rolled the same open boilerplate directly against node:sqlite's DatabaseSync instead of routing through local-store.ts's openLocalStoreDb, bypassing its crash-safety registration entirely -- a SIGINT/SIGTERM/uncaught-exception mid-write was not guaranteed to close the handle cleanly the way every openLocalStoreDb-routed store already is (#4826, closed issue #6595 previously fixed this exact bypass for governor-ledger/prediction-ledger/ plan-store). - orb-export.ts's openOrbExportStore and deny-hook-synthesis.ts's initDenyHookSynthesisStore: replace the manual mkdirSync/new DatabaseSync/chmodSync/PRAGMA busy_timeout sequence with openLocalStoreDb(resolvedPath), dropping the now-redundant steps. - laptop-init.ts's initLaptopState: same substitution -- this was the one store with no busy-timeout and no crash-safety registration at all. No on-disk contract changes (table names, permissions, busy-timeout value). Adds a crash-safe-cleanup-registration test to each file's test suite, mirroring local-store.ts's own openLocalStoreDb registration test (cleanupResourceCount()/resetProcessLifecycleForTesting()). checkLaptopStateSqlite's separate read-only DatabaseSync open and laptop-init.ts's local resolveMinerStateDir duplication are untouched, per the issue's explicit scope. --- packages/loopover-miner/lib/deny-hook-synthesis.ts | 11 +++++------ packages/loopover-miner/lib/laptop-init.ts | 10 ++++++---- packages/loopover-miner/lib/orb-export.ts | 12 +++++------- test/unit/miner-deny-hook-synthesis.test.ts | 11 +++++++++++ test/unit/miner-laptop-init.test.ts | 11 +++++++++++ test/unit/miner-orb-export.test.ts | 10 ++++++++++ 6 files changed, 48 insertions(+), 17 deletions(-) diff --git a/packages/loopover-miner/lib/deny-hook-synthesis.ts b/packages/loopover-miner/lib/deny-hook-synthesis.ts index 4033a18b2c..321a4285c3 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, @@ -25,6 +24,7 @@ import type { DenyRuleProposal, SynthesisConfig } from "@loopover/engine"; import { DEFAULT_FORGE_CONFIG } from "./forge-config.js"; import type { DenyRule } from "./deny-hooks.js"; import { DENY_HOOK_SYNTHESIS_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js"; +import { openLocalStoreDb } from "./local-store.js"; // Re-export the pure synthesis helpers from the engine so this module's public API is unchanged after #5667 // moved derivation/audit into @loopover/engine. Only the SQLite store below (and its forge/db-path helpers) is @@ -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"); + // openLocalStoreDb centralizes the mkdir(0o700)/chmod(0o600)/busy_timeout + crash-safe cleanup registration + // (#8319) -- so a SIGINT/SIGTERM/uncaught-exception mid-refresh doesn't leave this file half-written. + 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..985943b46f 100644 --- a/packages/loopover-miner/lib/laptop-init.ts +++ b/packages/loopover-miner/lib/laptop-init.ts @@ -1,10 +1,11 @@ -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 { reportCliFailure } from "./cli-error.js"; import { resolveGitHubToken } from "./github-token-resolution.js"; +import { openLocalStoreDb } from "./local-store.js"; const githubApiBaseUrl = "https://api.github.com"; const githubApiVersion = "2022-11-28"; @@ -52,9 +53,11 @@ 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 }); const created = !existsSync(dbPath); - const db = new DatabaseSync(dbPath); + // openLocalStoreDb centralizes the mkdir(0o700)/chmod(0o600)/busy_timeout + crash-safe cleanup registration + // (#8319) -- this was previously the one store in the package with no busy-timeout and no crash-safety + // registration at all, so a SIGINT/SIGTERM/uncaught-exception mid-bootstrap could leave the file half-written. + const db = openLocalStoreDb(dbPath); db.exec(` CREATE TABLE IF NOT EXISTS laptop_meta ( key TEXT PRIMARY KEY, @@ -67,7 +70,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..2c30b5c234 100644 --- a/packages/loopover-miner/lib/orb-export.ts +++ b/packages/loopover-miner/lib/orb-export.ts @@ -1,13 +1,12 @@ -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 { generateAnonSecret, hmacAnonymize as engineHmacAnonymize } from "@loopover/engine"; import { readPrOutcomes } from "./pr-outcome.js"; import type { NormalizedPrOutcomePayload, PrOutcomeLedgerReader } from "./pr-outcome.js"; import { initEventLedger } from "./event-ledger.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; +import { openLocalStoreDb } from "./local-store.js"; // Optional anonymized Orb telemetry export (#4277, network send wired in #5681). The self-host Orb collector // (src/selfhost/orb-collector.ts, #1255) is ALWAYS-ON for a maintainer's own instance; a miner runs on a @@ -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"); + // openLocalStoreDb centralizes the mkdir(0o700)/chmod(0o600)/busy_timeout + crash-safe cleanup registration + // (#8319) -- so a SIGINT/SIGTERM/uncaught-exception mid-export doesn't leave this file half-written. + 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..99d889ed3b 100644 --- a/test/unit/miner-deny-hook-synthesis.test.ts +++ b/test/unit/miner-deny-hook-synthesis.test.ts @@ -21,6 +21,7 @@ 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 +164,16 @@ describe("initDenyHookSynthesisStore() (#4522)", () => { expect(() => initDenyHookSynthesisStore(" ")).toThrow("invalid_deny_hook_synthesis_db_path"); }); + it("registers the store for crash-safe cleanup via openLocalStoreDb, and unregisters it on close (#8319)", () => { + resetProcessLifecycleForTesting(); + expect(cleanupResourceCount()).toBe(0); + const store = tempStore(); + expect(cleanupResourceCount()).toBe(1); + stores.splice(stores.indexOf(store), 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..145757e3ec 100644 --- a/test/unit/miner-laptop-init.test.ts +++ b/test/unit/miner-laptop-init.test.ts @@ -36,6 +36,7 @@ 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[] = []; @@ -83,6 +84,16 @@ describe("loopover-miner laptop init (#2329)", () => { expect(readFileSync(join(first.stateDir, "marker.txt"), "utf8")).toBe("keep-me"); }); + it("opens its store via openLocalStoreDb, registering and unregistering it for crash-safe cleanup within the call (#8319)", () => { + resetProcessLifecycleForTesting(); + expect(cleanupResourceCount()).toBe(0); + const root = tempRoot(); + initLaptopState({ LOOPOVER_MINER_CONFIG_DIR: join(root, "state") }); + // initLaptopState closes its own handle internally before returning (it exposes no db handle), so a + // leftover registration here would mean the register→unregister cycle didn't complete cleanly. + expect(cleanupResourceCount()).toBe(0); + }); + it("runInit prints human text (0) and machine JSON with --json", async () => { 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..0ce455e9e2 100644 --- a/test/unit/miner-orb-export.test.ts +++ b/test/unit/miner-orb-export.test.ts @@ -19,6 +19,7 @@ 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 +69,15 @@ describe("orb-export store (#4277)", () => { expect(store.getCursor()).toBe("2026-01-02T00:00:00Z"); store.close(); }); + + it("registers the store for crash-safe cleanup via openLocalStoreDb, and unregisters it on close (#8319)", () => { + resetProcessLifecycleForTesting(); + expect(cleanupResourceCount()).toBe(0); + const store = openOrbExportStore(storePath()); + expect(cleanupResourceCount()).toBe(1); + store.close(); + expect(cleanupResourceCount()).toBe(0); + }); }); describe("hmacAnonymize", () => {