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
16 changes: 10 additions & 6 deletions packages/gittensory-miner/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,16 @@ See [`docs/env-reference.md`](docs/env-reference.md) for the full `GITTENSORY_MI
| Worktree allocator | `worktree-allocator.sqlite3` | `worktree_slots` | `worktree-allocator.js` | `GITTENSORY_MINER_WORKTREE_ALLOCATOR_DB` |
| Orb export | `orb-export.sqlite3` | `orb_export_meta` | `orb-export.js` | `GITTENSORY_MINER_ORB_EXPORT_DB` |
| Policy-doc cache | `policy-doc-cache.sqlite3` | `policy_doc_cache` | `policy-doc-cache.js` | `GITTENSORY_MINER_POLICY_DOC_CACHE_DB` |

The policy-doc cache is the only store above that holds no miner state of its own: it caches the last-known ETag +
body of each target repo's fetched policy docs (AI-USAGE.md/CONTRIBUTING.md) so a repeated `discover` revalidates
them with a conditional GET (`If-None-Match`) instead of re-downloading static content, spending no extra
rate-limit budget when GitHub answers `304 Not Modified`. It is pure optimization — deleting the file only forces
the next run to refetch in full (#4842).
| Policy-verdict cache | `policy-verdict-cache.sqlite3` | `policy_verdict_cache` | `policy-verdict-cache.js` | `GITTENSORY_MINER_POLICY_VERDICT_CACHE_DB` |

The policy-doc and policy-verdict caches are the only stores above that hold no miner state of their own — both are
pure optimization, and deleting either file only forces the next run to redo the work it would have skipped. The
policy-doc cache caches the last-known ETag + body of each target repo's fetched policy docs
(AI-USAGE.md/CONTRIBUTING.md) so a repeated `discover` revalidates them with a conditional GET (`If-None-Match`)
instead of re-downloading static content, spending no extra rate-limit budget when GitHub answers
`304 Not Modified` (#4842). The policy-verdict cache goes one step further: once a repo's deciding doc's ETag is
confirmed unchanged, it reuses the already-resolved AI-usage-policy verdict instead of re-resolving it from the
(identical) doc text (#4843).

Every store resolves its file the same way: the store-specific env var above, else `GITTENSORY_MINER_CONFIG_DIR`,
else `XDG_CONFIG_HOME` (falling back to `~/.config`), joined with `gittensory-miner/<file>`. Every store also opens
Expand Down
1 change: 1 addition & 0 deletions packages/gittensory-miner/docs/env-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Generated by `npm run miner:env-reference`. Do not edit manually.
| `GITTENSORY_MINER_ORB_EXPORT_DB` | `lib/orb-export.js` | `""` |
| `GITTENSORY_MINER_PLAN_STORE_DB` | `lib/plan-store.js` | `""` |
| `GITTENSORY_MINER_POLICY_DOC_CACHE_DB` | `lib/policy-doc-cache.js` | (none) |
| `GITTENSORY_MINER_POLICY_VERDICT_CACHE_DB` | `lib/policy-verdict-cache.js` | (none) |
| `GITTENSORY_MINER_PORTFOLIO_QUEUE_DB` | `lib/portfolio-queue.js` | (none) |
| `GITTENSORY_MINER_PREDICTION_LEDGER_DB` | `lib/prediction-ledger.js` | `""` |
| `GITTENSORY_MINER_REPLAY_SNAPSHOT_DB` | `lib/replay-snapshot.js` | (none) |
Expand Down
2 changes: 2 additions & 0 deletions packages/gittensory-miner/lib/discover-cli.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
RankedCandidateSummary,
} from "./opportunity-ranker.js";
import type { PolicyDocCacheStore } from "./policy-doc-cache.js";
import type { PolicyVerdictCacheStore } from "./policy-verdict-cache.js";
import type { EnqueueRankedDiscoverySummary } from "./portfolio-discovery.js";
import type { PortfolioQueueStore } from "./portfolio-queue.js";

Expand Down Expand Up @@ -62,6 +63,7 @@ export type RunDiscoverOptions = {
goalSpecContentByRepo?: RankCandidateIssuesOptions["goalSpecContentByRepo"];
initPortfolioQueue?: () => PortfolioQueueStore;
initPolicyDocCache?: () => PolicyDocCacheStore;
initPolicyVerdictCache?: () => PolicyVerdictCacheStore;
fetchCandidateIssuesWithSummary?: (
targets: FanoutTarget[],
githubToken: string,
Expand Down
17 changes: 16 additions & 1 deletion packages/gittensory-miner/lib/discover-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "./opportunity-fanout.js";
import { rankCandidateIssuesWithSummary } from "./opportunity-ranker.js";
import { initPolicyDocCacheStore } from "./policy-doc-cache.js";
import { initPolicyVerdictCacheStore } from "./policy-verdict-cache.js";
import { enqueueRankedDiscovery } from "./portfolio-discovery.js";
import { initPortfolioQueueStore } from "./portfolio-queue.js";

Expand Down Expand Up @@ -173,7 +174,20 @@ export async function runDiscover(args, options = {}) {
policyDocCache = null;
ownsPolicyDocCache = false;
}
const fanOutOptions = { apiBaseUrl, forge: options.forge, policyDocCache };

// Persisted cache of resolved policy verdicts (#4843), same "own try/catch, degrade to null" discipline as the
// doc cache above and for the same reason: purely a performance optimization the feature is inert without, so a
// corrupt/unwritable cache DB must never abort a run.
let policyVerdictCache = null;
let ownsPolicyVerdictCache = false;
try {
ownsPolicyVerdictCache = options.initPolicyVerdictCache === undefined;
policyVerdictCache = (options.initPolicyVerdictCache ?? initPolicyVerdictCacheStore)();
} catch {
policyVerdictCache = null;
ownsPolicyVerdictCache = false;
}
const fanOutOptions = { apiBaseUrl, forge: options.forge, policyDocCache, policyVerdictCache };

try {
const fanOut =
Expand Down Expand Up @@ -213,5 +227,6 @@ export async function runDiscover(args, options = {}) {
} finally {
if (ownsPortfolioQueue) portfolioQueue.close();
if (ownsPolicyDocCache && policyDocCache) policyDocCache.close();
if (ownsPolicyVerdictCache && policyVerdictCache) policyVerdictCache.close();
}
}
5 changes: 4 additions & 1 deletion packages/gittensory-miner/lib/opportunity-fanout.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ForgeConfig } from "./forge-config.js";
import type { PolicyDocCache } from "./policy-doc-cache.js";
import type { PolicyVerdictCache } from "./policy-verdict-cache.js";

export type FanoutTarget = {
owner: string;
Expand All @@ -9,7 +10,8 @@ export type FanoutTarget = {
/** Options shared by every fan-out entry point. `apiBaseUrl` is the legacy top-level forge-host override (it still
* wins over `forge.apiBaseUrl`); `forge` (#4784) carries the rest of the per-tenant forge knobs. `policyDocCache`,
* when supplied, lets discovery revalidate each repo's policy docs with a conditional GET instead of a full
* refetch (#4842). */
* refetch (#4842). `policyVerdictCache`, when supplied, lets discovery reuse an already-resolved verdict once its
* deciding doc's ETag is confirmed unchanged, instead of re-resolving it (#4843). */
export type FanoutOptions = {
apiBaseUrl?: string;
forge?: Partial<ForgeConfig>;
Expand All @@ -20,6 +22,7 @@ export type FanoutOptions = {
maxPages?: number;
sleepFn?: (ms: number) => Promise<unknown>;
policyDocCache?: PolicyDocCache | null;
policyVerdictCache?: PolicyVerdictCache | null;
};

export type RawCandidateIssue = {
Expand Down
97 changes: 83 additions & 14 deletions packages/gittensory-miner/lib/opportunity-fanout.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,20 +159,55 @@ function readCachedPolicyDoc(cache, url) {
}
}

function readEtagHeader(response) {
const etag = response.headers.get("etag");
return typeof etag === "string" && etag.trim() ? etag : null;
}

// Persist the fresh ETag + body so the NEXT discover run can revalidate instead of re-downloading. Only a real
// ETag paired with decoded content is stored, and a write that throws must never fail discovery (same stale-safe
// rule) — it degrades to "not cached", so the next run simply refetches in full.
function writeCachedPolicyDoc(cache, url, response, content) {
if (!cache || content === null) return;
const etag = response.headers.get("etag");
if (typeof etag !== "string" || !etag.trim()) return;
function writeCachedPolicyDoc(cache, url, etag, content) {
if (!cache || content === null || etag === null) return;
try {
cache.put(url, etag, content);
} catch {
// Leave this URL uncached; the next run refetches fully rather than serving anything stale.
}
}

// A bare `owner/repo` is NOT a safe policy-verdict cache key: two different tenant forge hosts (#4784's
// per-tenant `apiBaseUrl`) can each have their own unrelated repo of the same name, and their policy docs are
// wholly independent. Scope the key by host, mirroring policy-doc-cache.js's own precedent of keying on the full
// request URL rather than a bare path.
function policyVerdictCacheKey(apiBaseUrl, repoFullName) {
return `${apiBaseUrl}::${repoFullName}`;
}

// Read a repo scope's previously-resolved verdict for the SAME decisive doc + ETag (#4843). A cache that is
// absent, or whose read throws (corrupt/locked file), is treated as a plain miss — the caller resolves the
// verdict fresh, per the same "never risk a stale policy" rule as the doc cache above.
function readCachedPolicyVerdict(cache, repoScope) {
if (!cache) return null;
try {
return cache.get(repoScope);
} catch {
return null;
}
}

// Persist the freshly-resolved verdict against the ETag of the doc that decided it, so the next run can reuse it
// outright once that ETag is confirmed unchanged. Only ever called with a real ETag; a write that throws must
// never fail discovery — it degrades to "not cached", so the next run just resolves the verdict again.
function writeCachedPolicyVerdict(cache, repoScope, decisiveDoc, etag, verdict) {
if (!cache || etag === null) return;
try {
cache.put(repoScope, decisiveDoc, etag, verdict);
} catch {
// Leave this repo scope uncached; the next run just resolves the verdict fresh again.
}
}

async function fetchRepoDoc(target, path, githubToken, options, summary, warnings) {
const url = apiUrl(
options.apiBaseUrl,
Expand All @@ -184,41 +219,72 @@ async function fetchRepoDoc(target, path, githubToken, options, summary, warning
const { response, payload } = await githubGetJson(url, githubToken, summary, options, conditionalHeaders);
// A 304 only ever follows the If-None-Match we send above, which we only send when `cached` exists — so the
// cached body is the GitHub-confirmed current content, served with no extra rate-limit spend.
if (response.status === 304) return cached.content;
if (response.status === 404) return null;
if (response.status === 304) return { content: cached.content, etag: cached.etag };
if (response.status === 404) return { content: null, etag: null };
if (!response.ok) {
warnings.push(warning(target, `policy:${path}`, `GitHub returned ${response.status}`));
return null;
return { content: null, etag: null };
}
const content = decodeContentPayload(payload);
writeCachedPolicyDoc(options.policyDocCache, url, response, content);
return content;
const etag = readEtagHeader(response);
writeCachedPolicyDoc(options.policyDocCache, url, etag, content);
return { content, etag };
} catch (error) {
warnings.push(
warning(target, `policy:${path}`, error instanceof Error ? error.message : "policy fetch failed"),
);
return null;
return { content: null, etag: null };
}
}

// Resolve a repo scope's AI-usage-policy verdict, reusing a cached one when the deciding doc's ETag hasn't moved
// since it was last resolved (#4843). Only ever consulted with an ETag that a same-run conditional-GET just
// confirmed is current, so a cache hit is exactly as correct as recomputing — it just skips the (cheap, but not
// free) parse.
function resolveOrCacheVerdict(cache, repoScope, decisiveDoc, etag, computeVerdict) {
if (etag !== null) {
const cached = readCachedPolicyVerdict(cache, repoScope);
if (cached && cached.decisiveDoc === decisiveDoc && cached.etag === etag) return cached.verdict;
}
const verdict = computeVerdict();
writeCachedPolicyVerdict(cache, repoScope, decisiveDoc, etag, verdict);
return verdict;
}

async function resolveRepoAiPolicy(target, githubToken, options, summary, warnings) {
const aiUsage = await fetchRepoDoc(target, "AI-USAGE.md", githubToken, options, summary, warnings);
const repoScope = policyVerdictCacheKey(options.apiBaseUrl, target.repoFullName);
const { content: aiUsage, etag: aiUsageEtag } = await fetchRepoDoc(
target,
"AI-USAGE.md",
githubToken,
options,
summary,
warnings,
);
// Short-circuit only on AI-USAGE.md that has real content. A present-but-blank AI-USAGE.md must still fall
// through to CONTRIBUTING.md — otherwise a stub AI-USAGE.md silently fails open and swallows a ban declared in
// CONTRIBUTING.md (the exact case resolveAiPolicyVerdict was fixed to handle in #2900, which can only fire if
// both docs reach it).
if (aiUsage !== null && aiUsage.trim().length > 0) {
return resolveAiPolicyVerdict({ aiUsage, contributing: null });
return resolveOrCacheVerdict(options.policyVerdictCache, repoScope, "AI-USAGE.md", aiUsageEtag, () =>
resolveAiPolicyVerdict({ aiUsage, contributing: null }),
);
}
const contributing = await fetchRepoDoc(
const { content: contributing, etag: contributingEtag } = await fetchRepoDoc(
target,
"CONTRIBUTING.md",
githubToken,
options,
summary,
warnings,
);
return resolveAiPolicyVerdict({ aiUsage: null, contributing });
return resolveOrCacheVerdict(
options.policyVerdictCache,
repoScope,
"CONTRIBUTING.md",
contributingEtag,
() => resolveAiPolicyVerdict({ aiUsage: null, contributing }),
);
}

function labelNames(labels) {
Expand Down Expand Up @@ -442,6 +508,9 @@ function normalizeOptions(options = {}) {
// Optional local ETag cache for policy-doc revalidation (#4842). Absent (null) => every policy doc is fetched
// in full, exactly as before; discover-cli.js supplies the real on-disk store for a live run.
policyDocCache: options.policyDocCache ?? null,
// Optional local cache of resolved policy verdicts (#4843). Absent (null) => every verdict is resolved fresh,
// exactly as before; discover-cli.js supplies the real on-disk store for a live run.
policyVerdictCache: options.policyVerdictCache ?? null,
};
}

Expand Down
35 changes: 35 additions & 0 deletions packages/gittensory-miner/lib/policy-verdict-cache.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { AiPolicyVerdict } from "@jsonbored/gittensory-engine";

export type PolicyVerdictDecisiveDoc = "AI-USAGE.md" | "CONTRIBUTING.md";

export type PolicyVerdictCacheEntry = {
decisiveDoc: PolicyVerdictDecisiveDoc;
etag: string;
verdict: AiPolicyVerdict;
};

export type PolicyVerdictCacheWrite = PolicyVerdictCacheEntry & {
repoScope: string;
updatedAt: string;
};

export type PolicyVerdictCacheStore = {
dbPath: string;
/** `repoScope` must uniquely identify a tenant forge host + repo (see `policyVerdictCacheKey` in
* opportunity-fanout.js) -- a bare `owner/repo` is not safe across multiple forge hosts. */
get(repoScope: string): PolicyVerdictCacheEntry | null;
put(
repoScope: string,
decisiveDoc: PolicyVerdictDecisiveDoc,
etag: string,
verdict: AiPolicyVerdict,
): PolicyVerdictCacheWrite;
close(): void;
};

/** The read/write surface opportunity-fanout.js needs to inject a cache without depending on the SQLite store. */
export type PolicyVerdictCache = Pick<PolicyVerdictCacheStore, "get" | "put">;

export function resolvePolicyVerdictCacheDbPath(env?: Record<string, string | undefined>): string;

export function initPolicyVerdictCacheStore(dbPath?: string): PolicyVerdictCacheStore;
Loading