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
36 changes: 36 additions & 0 deletions packages/loopover-miner/lib/contribution-profile-cache.d.ts
Original file line number Diff line number Diff line change
@@ -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, string | undefined>,
): 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;
137 changes: 137 additions & 0 deletions packages/loopover-miner/lib/contribution-profile-cache.js
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 2 additions & 0 deletions packages/loopover-miner/lib/migrate-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]";

Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/loopover-miner/lib/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
}
Expand Down
2 changes: 1 addition & 1 deletion packages/loopover-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading