diff --git a/packages/loopover-miner/lib/contribution-profile-cache.d.ts b/packages/loopover-miner/lib/contribution-profile-cache.d.ts new file mode 100644 index 0000000000..4d4ce1d4b0 --- /dev/null +++ b/packages/loopover-miner/lib/contribution-profile-cache.d.ts @@ -0,0 +1,36 @@ +import type { + CachedContributionProfile, + ContributionProfile, +} from "./contribution-profile.js"; + +export type ContributionProfileCache = { + dbPath: string; + /** Read a cached profile, or null when absent or unparseable. `stale` is true past the TTL. */ + get(repoFullName: string, nowMs?: number): CachedContributionProfile | null; + /** Cache a profile keyed by its own repoFullName, stamped with `nowMs` (defaults to now). */ + put( + profile: ContributionProfile, + nowMs?: number, + ): { repoFullName: string; fetchedAt: string }; + close(): void; +}; + +export function resolveContributionProfileCacheDbPath( + env?: Record, +): string; + +export function initContributionProfileCache( + dbPath?: string, +): ContributionProfileCache; + +export function getCachedContributionProfile( + repoFullName: string, + nowMs?: number, +): CachedContributionProfile | null; + +export function putCachedContributionProfile( + profile: ContributionProfile, + nowMs?: number, +): { repoFullName: string; fetchedAt: string }; + +export function closeDefaultContributionProfileCache(): void; diff --git a/packages/loopover-miner/lib/contribution-profile-cache.js b/packages/loopover-miner/lib/contribution-profile-cache.js new file mode 100644 index 0000000000..5cb5d874d6 --- /dev/null +++ b/packages/loopover-miner/lib/contribution-profile-cache.js @@ -0,0 +1,137 @@ +// ContributionProfile local cache store (#6797). Persists the extraction output (#6796) keyed by repo, so a +// repeated `discover` run within the freshness window doesn't re-fetch/re-parse the same labels + docs. 100% +// local/client-side, like every other miner store: never uploads, syncs, or phones home. Follows the shared +// local-store.js pattern (openLocalStoreDb + resolveLocalStoreDbPath + the schema-version stamp) so it is +// picked up by `doctor`'s store-integrity sweep and `migrate` the same way its siblings are. +import { + CONTRIBUTION_PROFILE_CACHE_TTL_MS, + CONTRIBUTION_PROFILE_STORE_TABLE, +} from "./contribution-profile.js"; +import { + normalizeLocalStoreDbPath, + openLocalStoreDb, + resolveLocalStoreDbPath, +} from "./local-store.js"; +import { applySchemaMigrations } from "./schema-version.js"; + +const defaultDbFileName = "contribution-profile-cache.sqlite3"; +let defaultContributionProfileCache = null; + +export function resolveContributionProfileCacheDbPath(env = process.env) { + return resolveLocalStoreDbPath( + defaultDbFileName, + "LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB", + env, + ); +} + +function normalizeDbPath(dbPath) { + return normalizeLocalStoreDbPath( + dbPath, + resolveContributionProfileCacheDbPath(), + "invalid_contribution_profile_cache_db_path", + ); +} + +function normalizeRepoFullName(repoFullName) { + if (typeof repoFullName !== "string") + throw new Error("invalid_repo_full_name"); + const [owner, repo, extra] = repoFullName.trim().split("/"); + if (!owner || !repo || extra !== undefined) + throw new Error("invalid_repo_full_name"); + return `${owner}/${repo}`; +} + +/** + * Open the 100%-local contribution-profile cache. The DB only lives on this machine (#6797). + * + * @param {string} [dbPath] + */ +export function initContributionProfileCache( + dbPath = resolveContributionProfileCacheDbPath(), +) { + const resolvedPath = normalizeDbPath(dbPath); + const db = openLocalStoreDb(resolvedPath); + db.exec(` + CREATE TABLE IF NOT EXISTS ${CONTRIBUTION_PROFILE_STORE_TABLE} ( + repo_full_name TEXT PRIMARY KEY, + profile_json TEXT NOT NULL, + fetched_at TEXT NOT NULL + ) + `); + // Schema-version convention (#4832): stamp the baseline. No post-baseline migrations for this v1 store yet. + applySchemaMigrations(db, []); + + const getStatement = db.prepare( + `SELECT profile_json, fetched_at FROM ${CONTRIBUTION_PROFILE_STORE_TABLE} WHERE repo_full_name = ?`, + ); + const putStatement = db.prepare(` + INSERT INTO ${CONTRIBUTION_PROFILE_STORE_TABLE} (repo_full_name, profile_json, fetched_at) + VALUES (?, ?, ?) + ON CONFLICT(repo_full_name) DO UPDATE SET + profile_json = excluded.profile_json, + fetched_at = excluded.fetched_at + `); + + return { + dbPath: resolvedPath, + /** + * Read a cached profile. Returns { profile, fetchedAt, stale } or null when absent. `stale` is true once + * the row is older than the TTL, so a caller re-extracts. A row whose JSON is unparseable is treated as a + * miss (fail closed) rather than throwing — a corrupted/hand-edited file must not break discover. + * + * @param {string} repoFullName + * @param {number} [nowMs] current time in ms, injectable for deterministic tests + */ + get(repoFullName, nowMs = Date.now()) { + const row = getStatement.get(normalizeRepoFullName(repoFullName)); + if (!row) return null; + let profile; + try { + profile = JSON.parse(row.profile_json); + } catch { + return null; + } + const fetchedMs = Date.parse(row.fetched_at); + // An unparseable timestamp fails closed to stale, so a corrupted row is re-extracted rather than trusted. + const stale = + Number.isNaN(fetchedMs) || + nowMs - fetchedMs > CONTRIBUTION_PROFILE_CACHE_TTL_MS; + return { profile, fetchedAt: row.fetched_at, stale }; + }, + /** + * Cache a profile, stamping it with the current time. The profile's own repoFullName is the key. + * + * @param {{ repoFullName: string }} profile a ContributionProfile + * @param {number} [nowMs] current time in ms, injectable for deterministic tests + */ + put(profile, nowMs = Date.now()) { + const repoFullName = normalizeRepoFullName(profile?.repoFullName); + const fetchedAt = new Date(nowMs).toISOString(); + putStatement.run(repoFullName, JSON.stringify(profile), fetchedAt); + return { repoFullName, fetchedAt }; + }, + close() { + db.close(); + }, + }; +} + +function getDefaultContributionProfileCache() { + defaultContributionProfileCache ??= initContributionProfileCache(); + return defaultContributionProfileCache; +} + +export function getCachedContributionProfile(repoFullName, nowMs) { + return getDefaultContributionProfileCache().get(repoFullName, nowMs); +} + +export function putCachedContributionProfile(profile, nowMs) { + return getDefaultContributionProfileCache().put(profile, nowMs); +} + +export function closeDefaultContributionProfileCache() { + if (!defaultContributionProfileCache) return; + defaultContributionProfileCache.close(); + defaultContributionProfileCache = null; +} diff --git a/packages/loopover-miner/lib/migrate-cli.js b/packages/loopover-miner/lib/migrate-cli.js index 4e0631e5cb..65af977cc8 100644 --- a/packages/loopover-miner/lib/migrate-cli.js +++ b/packages/loopover-miner/lib/migrate-cli.js @@ -22,6 +22,7 @@ import { openGovernorState, resolveGovernorStateDbPath } from "./governor-state. import { initAttemptLog, resolveAttemptLogDbPath } from "./attempt-log.js"; import { openReplaySnapshotStore, resolveReplaySnapshotDbPath } from "./replay-snapshot.js"; import { openWorktreeAllocator, resolveWorktreeAllocatorDbPath } from "./worktree-allocator.js"; +import { initContributionProfileCache, resolveContributionProfileCacheDbPath } from "./contribution-profile-cache.js"; const MIGRATE_USAGE = "Usage: loopover-miner migrate [--json]"; @@ -37,6 +38,7 @@ const STORES = [ { name: "attempt-log", resolveDbPath: resolveAttemptLogDbPath, open: initAttemptLog }, { name: "replay-snapshot", resolveDbPath: resolveReplaySnapshotDbPath, open: openReplaySnapshotStore }, { name: "worktree-allocator", resolveDbPath: resolveWorktreeAllocatorDbPath, open: (dbPath) => openWorktreeAllocator({ dbPath }) }, + { name: "contribution-profile", resolveDbPath: resolveContributionProfileCacheDbPath, open: initContributionProfileCache }, ]; /** Read a store file's stamped schema version without ever creating it -- matches checkStoreIntegrity's diff --git a/packages/loopover-miner/lib/status.js b/packages/loopover-miner/lib/status.js index 3a465684de..ec6b9252c7 100644 --- a/packages/loopover-miner/lib/status.js +++ b/packages/loopover-miner/lib/status.js @@ -25,6 +25,7 @@ import { resolveGovernorStateDbPath } from "./governor-state.js"; import { resolveAttemptLogDbPath } from "./attempt-log.js"; import { resolveReplaySnapshotDbPath } from "./replay-snapshot.js"; import { resolveWorktreeAllocatorDbPath } from "./worktree-allocator.js"; +import { resolveContributionProfileCacheDbPath } from "./contribution-profile-cache.js"; // Slim laptop-mode CLI commands (#2288): `status` (what's installed + where local state lives) and `doctor` (is // this laptop set up correctly). Both are read-only and 100% local — no repo-scanning, no coding-agent invocation, @@ -316,6 +317,7 @@ function storeIntegrityChecks(env) { ["attempt-log", resolveAttemptLogDbPath(env)], ["replay-snapshot", resolveReplaySnapshotDbPath(env)], ["worktree-allocator", resolveWorktreeAllocatorDbPath(env)], + ["contribution-profile", resolveContributionProfileCacheDbPath(env)], ]; return stores.map(([name, dbPath]) => checkStoreIntegrity(`store-integrity:${name}`, dbPath)); } diff --git a/packages/loopover-miner/package.json b/packages/loopover-miner/package.json index 4d9a286bde..6792bcff47 100644 --- a/packages/loopover-miner/package.json +++ b/packages/loopover-miner/package.json @@ -38,7 +38,7 @@ "scripts": { "benchmark": "node scripts/benchmark.mjs", "cross-repo-eval": "node scripts/cross-repo-evaluation.mjs", - "build": "node --check bin/loopover-miner.js && node --check bin/loopover-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/chat-action-dispatch.js && node --check lib/chat-action-registry.js && node --check lib/chat-discover-attempt-actions.js && node --check lib/chat-governor-actions.js && node --check lib/chat-portfolio-actions.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/contribution-profile.js && node --check lib/contribution-profile-extract.js && node --check lib/cross-repo-evaluation.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-metrics-cli.js && node --check lib/governor-pause-cli.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/harness-submission-trigger.js && node --check lib/init-wizard.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/logger.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/purge-cli.js && node --check lib/ranked-candidates.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-bridge.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/sentry.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/loopover-miner.js && node --check bin/loopover-miner-mcp.js && node --check lib/ams-policy.js && node --check lib/attempt-cli.js && node --check lib/attempt-input-builder.js && node --check lib/attempt-log.js && node --check lib/attempt-runner.js && node --check lib/attempt-worktree.js && node --check lib/calibration-run.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/chat-action-dispatch.js && node --check lib/chat-action-registry.js && node --check lib/chat-discover-attempt-actions.js && node --check lib/chat-governor-actions.js && node --check lib/chat-portfolio-actions.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-conflict-resolver.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-construction.js && node --check lib/coding-agent-house-rules.js && node --check lib/coding-task-spec.js && node --check lib/contribution-profile.js && node --check lib/contribution-profile-cache.js && node --check lib/contribution-profile-extract.js && node --check lib/cross-repo-evaluation.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/deployment-docs-audit.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/execute-local-write.js && node --check lib/feasibility-cli.js && node --check lib/governor-chokepoint-persisted.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-metrics-cli.js && node --check lib/governor-pause-cli.js && node --check lib/governor-run-halt.js && node --check lib/governor-state.js && node --check lib/harness-submission-trigger.js && node --check lib/init-wizard.js && node --check lib/laptop-init.js && node --check lib/live-issue-snapshot.js && node --check lib/local-store.js && node --check lib/logger.js && node --check lib/loop-cli.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/metrics-cli.js && node --check lib/miner-goal-spec.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/policy-doc-cache.js && node --check lib/policy-verdict-cache.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-expiry.js && node --check lib/pr-disposition-poller.js && node --check lib/pr-number-parse.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/purge-cli.js && node --check lib/ranked-candidates.js && node --check lib/rejection-signal.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-snapshot.js && node --check lib/replay-task-bridge.js && node --check lib/replay-task-generation.js && node --check lib/repo-clone.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/self-review-context.js && node --check lib/sentry.js && node --check lib/slop-assessment.js && node --check lib/stack-detection.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@loopover/engine": "^3.0.0", diff --git a/test/unit/miner-contribution-profile-cache.test.ts b/test/unit/miner-contribution-profile-cache.test.ts new file mode 100644 index 0000000000..97a0fda853 --- /dev/null +++ b/test/unit/miner-contribution-profile-cache.test.ts @@ -0,0 +1,185 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + CONTRIBUTION_PROFILE_CACHE_TTL_MS, + CONTRIBUTION_PROFILE_STORE_TABLE, + emptyContributionProfile, +} from "../../packages/loopover-miner/lib/contribution-profile.js"; +import { + closeDefaultContributionProfileCache, + getCachedContributionProfile, + initContributionProfileCache, + putCachedContributionProfile, + resolveContributionProfileCacheDbPath, +} from "../../packages/loopover-miner/lib/contribution-profile-cache.js"; + +const roots: string[] = []; +const stores: Array<{ close(): void }> = []; + +function tempStore() { + const root = mkdtempSync(join(tmpdir(), "loopover-miner-cp-cache-")); + roots.push(root); + const store = initContributionProfileCache( + join(root, "nested", "contribution-profile-cache.sqlite3"), + ); + stores.push(store); + return store; +} + +const AT_MS = Date.parse("2026-07-18T00:00:00.000Z"); +const profile = (repo = "acme/widgets") => + emptyContributionProfile(repo, new Date(AT_MS).toISOString()); + +afterEach(() => { + for (const store of stores.splice(0)) store.close(); + closeDefaultContributionProfileCache(); + vi.unstubAllEnvs(); + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }); +}); + +describe("contribution-profile cache store (#6797)", () => { + it("resolves the DB path from the env override then the config-dir convention", () => { + // The explicit-DB override is returned verbatim (platform-independent); the config-dir path is asserted by + // suffix so the assertion is stable across `/` and `\` separators. + expect( + resolveContributionProfileCacheDbPath({ + LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB: "/custom/cp.sqlite3", + }), + ).toBe("/custom/cp.sqlite3"); + expect( + resolveContributionProfileCacheDbPath({ + LOOPOVER_MINER_CONFIG_DIR: "/cfg", + }), + ).toMatch(/[/\\]cfg[/\\]contribution-profile-cache\.sqlite3$/); + }); + + it("creates the table on first use and returns null before any write", () => { + const store = tempStore(); + expect(store.get("acme/widgets")).toBeNull(); + const db = new DatabaseSync(store.dbPath, { readOnly: true }); + try { + const row = db + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name = ?", + ) + .get(CONTRIBUTION_PROFILE_STORE_TABLE); + expect(row).toEqual({ name: CONTRIBUTION_PROFILE_STORE_TABLE }); + } finally { + db.close(); + } + }); + + it("round-trips a stored profile and reports it fresh within the TTL", () => { + const store = tempStore(); + const write = store.put(profile(), AT_MS); + expect(write).toEqual({ + repoFullName: "acme/widgets", + fetchedAt: "2026-07-18T00:00:00.000Z", + }); + + const cached = store.get( + "acme/widgets", + AT_MS + CONTRIBUTION_PROFILE_CACHE_TTL_MS - 1, + ); + expect(cached).not.toBeNull(); + expect(cached!.stale).toBe(false); + expect(cached!.fetchedAt).toBe("2026-07-18T00:00:00.000Z"); + expect(cached!.profile).toEqual(profile()); + }); + + it("marks a profile stale once it is older than the TTL", () => { + const store = tempStore(); + store.put(profile(), AT_MS); + // Exactly at the TTL boundary is still fresh; one ms past it is stale. + expect( + store.get("acme/widgets", AT_MS + CONTRIBUTION_PROFILE_CACHE_TTL_MS)! + .stale, + ).toBe(false); + expect( + store.get("acme/widgets", AT_MS + CONTRIBUTION_PROFILE_CACHE_TTL_MS + 1)! + .stale, + ).toBe(true); + }); + + it("overwrites an existing repo's cached profile on re-put, updating the timestamp", () => { + const store = tempStore(); + store.put(profile(), AT_MS); + const later = AT_MS + 60_000; + store.put(profile(), later); + const cached = store.get("acme/widgets", later); + expect(cached!.fetchedAt).toBe(new Date(later).toISOString()); + }); + + it("keeps repos independent — one repo's profile does not leak into another", () => { + const store = tempStore(); + store.put(profile("acme/widgets"), AT_MS); + expect(store.get("acme/other", AT_MS)).toBeNull(); + expect(store.get("acme/widgets", AT_MS)!.profile.repoFullName).toBe( + "acme/widgets", + ); + }); + + it("fails closed to null on a row whose JSON is corrupt, rather than throwing", () => { + const store = tempStore(); + const db = new DatabaseSync(store.dbPath); + db.prepare( + `INSERT INTO ${CONTRIBUTION_PROFILE_STORE_TABLE} (repo_full_name, profile_json, fetched_at) VALUES (?, ?, ?)`, + ).run("acme/widgets", "{not valid json", "2026-07-18T00:00:00.000Z"); + db.close(); + expect(store.get("acme/widgets", AT_MS)).toBeNull(); + }); + + it("fails closed to stale on a row with an unparseable timestamp", () => { + const store = tempStore(); + const db = new DatabaseSync(store.dbPath); + db.prepare( + `INSERT INTO ${CONTRIBUTION_PROFILE_STORE_TABLE} (repo_full_name, profile_json, fetched_at) VALUES (?, ?, ?)`, + ).run("acme/widgets", JSON.stringify(profile()), "not-a-date"); + db.close(); + expect(store.get("acme/widgets", AT_MS)!.stale).toBe(true); + }); + + it("rejects an invalid DB path and invalid repo names before writing", () => { + expect(() => initContributionProfileCache(" ")).toThrow( + "invalid_contribution_profile_cache_db_path", + ); + const store = tempStore(); + expect(() => store.get("not-a-full-name")).toThrow( + "invalid_repo_full_name", + ); + expect(() => + store.put({ repoFullName: "owner/repo/extra" } as never), + ).toThrow("invalid_repo_full_name"); + expect(() => store.put({ repoFullName: 42 } as never)).toThrow( + "invalid_repo_full_name", + ); + }); + + it("exposes module-level get/put helpers backed by the default DB path", () => { + vi.stubEnv( + "LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB", + join(tempRootForDefault(), "default.sqlite3"), + ); + expect(getCachedContributionProfile("acme/widgets", AT_MS)).toBeNull(); + putCachedContributionProfile(profile(), AT_MS); + expect( + getCachedContributionProfile("acme/widgets", AT_MS)!.profile.repoFullName, + ).toBe("acme/widgets"); + // Closing the default store and reading again re-opens it against the same file — the data persists. + closeDefaultContributionProfileCache(); + expect(getCachedContributionProfile("acme/widgets", AT_MS)!.stale).toBe( + false, + ); + }); +}); + +function tempRootForDefault(): string { + const root = mkdtempSync(join(tmpdir(), "loopover-miner-cp-cache-default-")); + roots.push(root); + return root; +} diff --git a/test/unit/miner-migrate-cli.test.ts b/test/unit/miner-migrate-cli.test.ts index 5d5ba15a10..3b9125c7d3 100644 --- a/test/unit/miner-migrate-cli.test.ts +++ b/test/unit/miner-migrate-cli.test.ts @@ -29,6 +29,7 @@ const STORE_NAMES = [ "attempt-log", "replay-snapshot", "worktree-allocator", + "contribution-profile", ]; afterEach(() => { @@ -37,7 +38,7 @@ afterEach(() => { }); describe("loopover-miner migrate (#4871)", () => { - it("covers the exact same eleven 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 twelve 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); diff --git a/test/unit/miner-status.test.ts b/test/unit/miner-status.test.ts index 31524dccf4..921f0017f0 100644 --- a/test/unit/miner-status.test.ts +++ b/test/unit/miner-status.test.ts @@ -129,6 +129,7 @@ describe("loopover-miner status/doctor (#2288)", () => { "store-integrity:attempt-log", "store-integrity:replay-snapshot", "store-integrity:worktree-allocator", + "store-integrity:contribution-profile", ]); // REGRESSION (#6768): doctor previously omitted these four durable local stores from the integrity sweep. expect(checks.map((check) => check.name)).toEqual(