Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions packages/loopover-miner/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<NAME>_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_<NAME>_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).

Expand Down
19 changes: 13 additions & 6 deletions packages/loopover-miner/lib/laptop-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,9 @@ export function resolveLaptopStateDbPath(env: Record<string, string | undefined>
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<string, string | undefined> = 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);
Expand All @@ -66,6 +63,16 @@ export function initLaptopState(env: Record<string, string | undefined> = 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<string, string | undefined> = 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());
Expand Down
3 changes: 3 additions & 0 deletions packages/loopover-miner/lib/migrate-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]";

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/loopover-miner/lib/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -409,6 +410,9 @@ function storeIntegrityChecks(env: Record<string, string | undefined>): 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));
}
Expand Down
28 changes: 27 additions & 1 deletion test/unit/miner-migrate-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -35,6 +36,7 @@ const STORE_NAMES = [
"ranked-candidates",
"deny-hook-synthesis",
"orb-export",
"laptop-state",
];

afterEach(() => {
Expand All @@ -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);

Expand All @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
37 changes: 35 additions & 2 deletions test/unit/miner-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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). */
Expand Down Expand Up @@ -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(
Expand All @@ -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();
});
Expand All @@ -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());
Expand Down