diff --git a/packages/loopover-miner/DEPLOYMENT.md b/packages/loopover-miner/DEPLOYMENT.md index a8327f8c66..ea39cd3ad7 100644 --- a/packages/loopover-miner/DEPLOYMENT.md +++ b/packages/loopover-miner/DEPLOYMENT.md @@ -70,12 +70,14 @@ For provider selection and the CLI-specific model/timeout overrides, see Not every file appears immediately: `laptop-state` is written by `init`, and each of the others is created the first time its subsystem actually runs (an attempt, a discovery pass, a replay, an Orb export, …), so a - fresh install that has only run `status`/`doctor` will show a subset. All sixteen default into this one + fresh install that has only run `status`/`doctor` will show a subset. All eighteen default into this one directory. Override the directory for every store at once with `LOOPOVER_MINER_CONFIG_DIR` or - `XDG_CONFIG_HOME` (same resolution chain as `@loopover/mcp`); every store except `laptop-state.sqlite3` - (directory only) also honors its own `LOOPOVER_MINER__DB` path override — e.g. - `LOOPOVER_MINER_PORTFOLIO_QUEUE_DB` — to relocate an individual file. `doctor`'s `store-integrity:*` checks - report the persistent stores, so it is the quickest way to confirm what exists and is readable on disk. + `XDG_CONFIG_HOME` (same resolution chain as `@loopover/mcp`); every store — including + `laptop-state.sqlite3` via `LOOPOVER_MINER_LAPTOP_STATE_DB` — also honors its own + `LOOPOVER_MINER__DB` path override — e.g. `LOOPOVER_MINER_PORTFOLIO_QUEUE_DB` — to relocate an + individual file. `doctor`'s `store-integrity:*` checks (including `store-integrity:laptop-state`) run a + deep `PRAGMA integrity_check` on each persistent store, so it is the quickest way to confirm what exists + and is readable on disk. 4. Optional per-repo miner goals: copy [`.loopover-miner.yml.example`](../../.loopover-miner.yml.example) to a target repo as `.loopover-miner.yml`. See [`docs/miner-goal-spec.md`](docs/miner-goal-spec.md). diff --git a/packages/loopover-miner/lib/laptop-init.ts b/packages/loopover-miner/lib/laptop-init.ts index e604b09df9..940f057703 100644 --- a/packages/loopover-miner/lib/laptop-init.ts +++ b/packages/loopover-miner/lib/laptop-init.ts @@ -49,12 +49,9 @@ export function resolveLaptopStateDbPath(env: Record return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_LAPTOP_STATE_DB", env); } -/** Create the state dir and SQLite file. Re-running is idempotent and never clobbers existing rows. */ -export function initLaptopState(env: Record = process.env): LaptopInitResult { - const stateDir = resolveMinerStateDir(env); - const dbPath = resolveLaptopStateDbPath(env); - // Sample before openLocalStoreDb: the helper creates the parent dir + file, so `created` must be read first. - const created = !existsSync(dbPath); +/** Open an on-disk laptop-state DB at an explicit path and apply schema migrations (#8641). Used by + * `migrate`'s STORES list — same open/migrate contract as every sibling store's `open(dbPath)` entry. */ +export function openLaptopStateStore(dbPath: string): DatabaseSync { // openLocalStoreDb centralizes the mkdir(0o700)/chmod(0o600)/busy_timeout + crash-safe cleanup registration // (#8319) -- this was previously the one store with no busy-timeout and no crash-safety registration at all. const db = openLocalStoreDb(dbPath); @@ -66,6 +63,16 @@ export function initLaptopState(env: Record = proces `); // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet). applySchemaMigrations(db, []); + return db; +} + +/** Create the state dir and SQLite file. Re-running is idempotent and never clobbers existing rows. */ +export function initLaptopState(env: Record = process.env): LaptopInitResult { + const stateDir = resolveMinerStateDir(env); + const dbPath = resolveLaptopStateDbPath(env); + // Sample before open: the helper creates the parent dir + file, so `created` must be read first. + const created = !existsSync(dbPath); + const db = openLaptopStateStore(dbPath); if (created) { db.prepare("INSERT INTO laptop_meta (key, value) VALUES ('initialized_at', ?)") .run(new Date().toISOString()); diff --git a/packages/loopover-miner/lib/migrate-cli.ts b/packages/loopover-miner/lib/migrate-cli.ts index 69797069ce..62a5309217 100644 --- a/packages/loopover-miner/lib/migrate-cli.ts +++ b/packages/loopover-miner/lib/migrate-cli.ts @@ -28,6 +28,7 @@ import { initPolicyDocCacheStore, resolvePolicyDocCacheDbPath } from "./policy-d import { initRankedCandidatesStore, resolveRankedCandidatesDbPath } from "./ranked-candidates.js"; import { initDenyHookSynthesisStore, resolveDenyHookSynthesisDbPath } from "./deny-hook-synthesis.js"; import { openOrbExportStore, resolveOrbExportDbPath } from "./orb-export.js"; +import { openLaptopStateStore, resolveLaptopStateDbPath } from "./laptop-init.js"; const MIGRATE_USAGE = "Usage: loopover-miner migrate [--json]"; @@ -84,6 +85,8 @@ const STORES: MigrateStoreDescriptor[] = [ // #8318: orb-export.sqlite3 (the opt-in Orb telemetry export's HMAC secret + cursor, #4277/#5681) is a // durable local store like every entry above, but was never added when it shipped. { name: "orb-export", resolveDbPath: resolveOrbExportDbPath, open: openOrbExportStore }, + // #8641: laptop-state.sqlite3 twin of the doctor store-integrity entry above. + { name: "laptop-state", resolveDbPath: resolveLaptopStateDbPath, open: openLaptopStateStore }, ]; /** Read a store file's stamped schema version without ever creating it -- matches checkStoreIntegrity's diff --git a/packages/loopover-miner/lib/status.ts b/packages/loopover-miner/lib/status.ts index d35e3b4df6..f97b64bf72 100644 --- a/packages/loopover-miner/lib/status.ts +++ b/packages/loopover-miner/lib/status.ts @@ -10,6 +10,7 @@ import { checkLaptopStateSqlite, findExecutableOnPath, resolveCodexAuthPath, + resolveLaptopStateDbPath, } from "./laptop-init.js"; import { resolveMinerVersion } from "./version.js"; import { checkStoreIntegrity, describeError } from "./store-maintenance.js"; @@ -409,6 +410,9 @@ function storeIntegrityChecks(env: Record): DoctorCh // #8318: orb-export.sqlite3 (the opt-in Orb telemetry export's HMAC secret + cursor, #4277/#5681) is a // durable local store like every entry above, but was never added when it shipped. ["orb-export", resolveOrbExportDbPath(env)], + // #8641: laptop-state.sqlite3 (laptop-mode bootstrap meta) uses the same resolveLocalStoreDbPath + + // applySchemaMigrations pattern, but was never added to either twin list when it shipped. + ["laptop-state", resolveLaptopStateDbPath(env)], ]; return stores.map(([name, dbPath]) => checkStoreIntegrity(`store-integrity:${name}`, dbPath)); } diff --git a/test/unit/miner-migrate-cli.test.ts b/test/unit/miner-migrate-cli.test.ts index 87918f120f..8565ab291f 100644 --- a/test/unit/miner-migrate-cli.test.ts +++ b/test/unit/miner-migrate-cli.test.ts @@ -8,6 +8,7 @@ import { initPortfolioQueueStore, resolvePortfolioQueueDbPath } from "../../pack import { resolveEventLedgerDbPath } from "../../packages/loopover-miner/lib/event-ledger.js"; import { applySchemaMigrations, BASELINE_SCHEMA_VERSION } from "../../packages/loopover-miner/lib/schema-version.js"; import { openWorktreeAllocator, resolveWorktreeAllocatorDbPath } from "../../packages/loopover-miner/lib/worktree-allocator.js"; +import { openLaptopStateStore, resolveLaptopStateDbPath } from "../../packages/loopover-miner/lib/laptop-init.js"; const roots: string[] = []; @@ -35,6 +36,7 @@ const STORE_NAMES = [ "ranked-candidates", "deny-hook-synthesis", "orb-export", + "laptop-state", ]; afterEach(() => { @@ -43,7 +45,7 @@ afterEach(() => { }); describe("loopover-miner migrate (#4871)", () => { - it("covers the exact same seventeen stores doctor's store-integrity sweep covers, in the same order, and skips every one when nothing has been created yet", () => { + it("covers the exact same eighteen stores doctor's store-integrity sweep covers, in the same order, and skips every one when nothing has been created yet", () => { const env = tempEnv(); const results = runMigrateChecks(env); @@ -55,6 +57,9 @@ describe("loopover-miner migrate (#4871)", () => { // REGRESSION (#8318): orb-export.sqlite3 (the opt-in Orb telemetry export's HMAC secret + cursor) was // never added to either list when it shipped, so a corrupted store was invisible to doctor and migrate. expect(STORE_NAMES).toEqual(expect.arrayContaining(["orb-export"])); + // REGRESSION (#8641): laptop-state.sqlite3 used the same resolveLocalStoreDbPath pattern but was never + // added to either twin list, so doctor only ran a shallow SELECT 1 and migrate never touched it. + expect(STORE_NAMES).toEqual(expect.arrayContaining(["laptop-state"])); for (const result of results) { expect(result.ok).toBe(true); expect(result.status).toBe("skipped"); @@ -93,6 +98,27 @@ describe("loopover-miner migrate (#4871)", () => { expect(row?.versionBefore).toEqual(expect.any(Number)); }); + it("REGRESSION (#8641): opens laptop-state through migrate's open adapter and migrates a pre-baseline file", () => { + const env = tempEnv(); + const dbPath = resolveLaptopStateDbPath(env); + mkdirSync(dirname(dbPath), { recursive: true }); + // Pre-baseline on-disk file (user_version 0, empty) — openLaptopStateStore stamps the baseline. + new DatabaseSync(dbPath).close(); + + const row = runMigrateChecks(env).find((result) => result.name === "laptop-state"); + expect(row).toMatchObject({ + ok: true, + status: "migrated", + versionBefore: 0, + versionAfter: BASELINE_SCHEMA_VERSION, + }); + + // Freshly-migrated file is then reported up-to-date on a second pass (open adapter still runs). + openLaptopStateStore(dbPath).close(); + const again = runMigrateChecks(env).find((result) => result.name === "laptop-state"); + expect(again).toMatchObject({ ok: true, status: "up-to-date", versionBefore: BASELINE_SCHEMA_VERSION }); + }); + it("actually migrates a pre-existing older-schema portfolio-queue file, bumping its stamped version and adding the missing column", () => { const env = tempEnv(); const dbPath = resolvePortfolioQueueDbPath(env); diff --git a/test/unit/miner-status.test.ts b/test/unit/miner-status.test.ts index 139f044efd..56c48d2342 100644 --- a/test/unit/miner-status.test.ts +++ b/test/unit/miner-status.test.ts @@ -20,7 +20,7 @@ import { runDoctorChecks, runStatus, } from "../../packages/loopover-miner/lib/status.js"; -import { initLaptopState } from "../../packages/loopover-miner/lib/laptop-init.js"; +import { initLaptopState, resolveLaptopStateDbPath } from "../../packages/loopover-miner/lib/laptop-init.js"; // Read live, never hardcode: a hardcoded snapshot of this range (e.g. "^3.0.0") goes stale the moment // packages/loopover-miner/package.json's @loopover/engine dependency is bumped by a real release, and @@ -41,7 +41,15 @@ function tempRoot() { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + // On Windows, node:sqlite can briefly keep a handle after close(); ignore EBUSY so cleanup + // failures don't mask assertion results (CI Linux does not hit this). + for (const root of roots.splice(0)) { + try { + rmSync(root, { recursive: true, force: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EBUSY") throw error; + } + } }); /** Creates an executable file `name` in a fresh bin dir and returns that dir (usable directly as PATH). */ @@ -146,6 +154,7 @@ describe("loopover-miner status/doctor (#2288)", () => { "store-integrity:ranked-candidates", "store-integrity:deny-hook-synthesis", "store-integrity:orb-export", + "store-integrity:laptop-state", ]); // REGRESSION (#6768): doctor previously omitted these four durable local stores from the integrity sweep. expect(checks.map((check) => check.name)).toEqual( @@ -160,6 +169,8 @@ describe("loopover-miner status/doctor (#2288)", () => { expect(checks.map((check) => check.name)).toEqual( expect.arrayContaining(["store-integrity:ranked-candidates", "store-integrity:deny-hook-synthesis"]), ); + // REGRESSION (#8641): laptop-state was only covered by a shallow SELECT 1, not the deep integrity sweep. + expect(checks.map((check) => check.name)).toEqual(expect.arrayContaining(["store-integrity:laptop-state"])); expect(runDoctor([], env, cwd)).toBe(0); expect(log).toHaveBeenCalled(); }); @@ -174,6 +185,28 @@ describe("loopover-miner status/doctor (#2288)", () => { expect(runDoctor([], env)).toBe(1); // a failed check makes doctor exit non-zero }); + it("doctor flags a corrupted laptop-state store via the deep integrity sweep (#8641)", () => { + const env = { LOOPOVER_MINER_CONFIG_DIR: join(tempRoot(), "state") }; + const laptopPath = resolveLaptopStateDbPath(env); + mkdirSync(dirname(laptopPath), { recursive: true }); + writeFileSync(laptopPath, "this is not a sqlite database"); + const checks = runDoctorChecks(env); + expect(checks.find((check) => check.name === "store-integrity:laptop-state")?.ok).toBe(false); + + // Healthy absence still passes: a missing laptop-state file is not a failed deep check. + const cleanEnv = { LOOPOVER_MINER_CONFIG_DIR: join(tempRoot(), "clean-state") }; + const clean = runDoctorChecks(cleanEnv).find((check) => check.name === "store-integrity:laptop-state"); + expect(clean?.ok).toBe(true); + expect(clean?.detail).toMatch(/not created yet/); + + // Healthy presence: a real initialized file must pass the deep PRAGMA integrity_check path. + const healthyEnv = { LOOPOVER_MINER_CONFIG_DIR: join(tempRoot(), "healthy-state") }; + initLaptopState(healthyEnv); + const healthy = runDoctorChecks(healthyEnv).find((check) => check.name === "store-integrity:laptop-state"); + expect(healthy?.ok).toBe(true); + expect(healthy?.detail).toMatch(/: ok$/); + }); + describe("checkConfigContent (#4873)", () => { it("is a clean pass when no config file is present (defaults apply)", () => { const result = checkConfigContent(tempRoot());