diff --git a/packages/loopover-miner/lib/ams-policy.d.ts b/packages/loopover-miner/lib/ams-policy.d.ts index 6fbac40645..e7ee82e60a 100644 --- a/packages/loopover-miner/lib/ams-policy.d.ts +++ b/packages/loopover-miner/lib/ams-policy.d.ts @@ -1,20 +1,28 @@ import type { AmsPolicySpec } from "@loopover/engine"; -export function resolveAmsPolicyConfigPath(env?: Record): string; - export type AmsPolicySource = "local" | "default"; - export type ResolvedAmsPolicy = { - spec: AmsPolicySpec; - source: AmsPolicySource; - warnings: string[]; + spec: AmsPolicySpec; + source: AmsPolicySource; + warnings: string[]; }; - -export function resolveAmsPolicy( - repoFullName: string, - options?: { +export type AmsPolicyOptions = { + /** Accepted for forward/API compatibility with callers that pass a fetch override; unused today since this + * resolver never fetches (see the module doc comment above). */ fetchImpl?: unknown; readFileSync?: (path: string, encoding: "utf8") => string; existsSync?: (path: string) => boolean; env?: Record; - }, -): Promise; +}; +/** Resolve the operator's local AMS policy file path: explicit env var > `LOOPOVER_MINER_CONFIG_DIR` > + * `XDG_CONFIG_HOME`/`~/.config`, mirroring every other local-store path in this package. */ +export declare function resolveAmsPolicyConfigPath(env?: Record): string; +/** + * Resolve the real, effective AMS execution policy for one attempt: the operator's own local + * `.loopover-ams.yml` when present (source: "local"), else the engine's safe defaults (source: "default"). + * Never throws -- an unreadable/malformed local file degrades through the tolerant parser to the safe + * defaults, same discipline as every other tolerant parser in this pipeline. + * + * `repoFullName` is accepted for API compatibility with callers that resolve policy per target repo, but the + * resolver intentionally does not fetch or trust target-repository AMS policy content. + */ +export declare function resolveAmsPolicy(repoFullName: string, options?: AmsPolicyOptions): Promise; diff --git a/packages/loopover-miner/lib/ams-policy.js b/packages/loopover-miner/lib/ams-policy.js index 3574b2b15e..0fbbfe17f9 100644 --- a/packages/loopover-miner/lib/ams-policy.js +++ b/packages/loopover-miner/lib/ams-policy.js @@ -1,7 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; import { DEFAULT_AMS_POLICY_SPEC, parseAmsPolicySpecContent } from "@loopover/engine"; import { resolveLocalStoreDbPath } from "./local-store.js"; - // Resolver for the operator-local `.loopover-ams.yml` (#5132, Wave 3.5 follow-up). AmsPolicySpec // (ams-policy-spec.ts, engine package) is the type/parser surface; this module is the actual local // read+resolve caller. @@ -9,35 +8,32 @@ import { resolveLocalStoreDbPath } from "./local-store.js"; // This is deliberately NOT the same resolution shape as self-review-context.js/rejection-signal.js, which // read from the target repo: AmsPolicySpec's fields are the OPERATOR's own execution-risk policy, so an // untrusted target repo must never get final say over them. - const AMS_POLICY_FILENAME = ".loopover-ams.yml"; - /** Resolve the operator's local AMS policy file path: explicit env var > `LOOPOVER_MINER_CONFIG_DIR` > * `XDG_CONFIG_HOME`/`~/.config`, mirroring every other local-store path in this package. */ export function resolveAmsPolicyConfigPath(env = process.env) { - return resolveLocalStoreDbPath(AMS_POLICY_FILENAME, "LOOPOVER_MINER_AMS_POLICY_PATH", env); + return resolveLocalStoreDbPath(AMS_POLICY_FILENAME, "LOOPOVER_MINER_AMS_POLICY_PATH", env); } - function normalizeOptions(options = {}) { - return { - readFileSync: options.readFileSync ?? readFileSync, - existsSync: options.existsSync ?? existsSync, - env: options.env ?? process.env, - }; + return { + readFileSync: options.readFileSync ?? readFileSync, + existsSync: options.existsSync ?? existsSync, + env: options.env ?? process.env, + }; } - /** Read the operator's own local `.loopover-ams.yml`, if one exists. Never throws: an unreadable file is * treated the same as an absent one, falling through to the next resolution layer. */ function readLocalAmsPolicyContent(resolved) { - const path = resolveAmsPolicyConfigPath(resolved.env); - if (!resolved.existsSync(path)) return null; - try { - return resolved.readFileSync(path, "utf8"); - } catch { - return null; - } + const path = resolveAmsPolicyConfigPath(resolved.env); + if (!resolved.existsSync(path)) + return null; + try { + return resolved.readFileSync(path, "utf8"); + } + catch { + return null; + } } - /** * Resolve the real, effective AMS execution policy for one attempt: the operator's own local * `.loopover-ams.yml` when present (source: "local"), else the engine's safe defaults (source: "default"). @@ -46,23 +42,15 @@ function readLocalAmsPolicyContent(resolved) { * * `repoFullName` is accepted for API compatibility with callers that resolve policy per target repo, but the * resolver intentionally does not fetch or trust target-repository AMS policy content. - * - * @param {string} repoFullName - * @param {{ - * readFileSync?: (path: string, encoding: "utf8") => string, existsSync?: (path: string) => boolean, - * env?: Record, - * }} [options] - * @returns {Promise<{ spec: import("@loopover/engine").AmsPolicySpec, source: "local"|"default", warnings: string[] }>} */ export async function resolveAmsPolicy(repoFullName, options = {}) { - void repoFullName; - const resolved = normalizeOptions(options); - - const localContent = readLocalAmsPolicyContent(resolved); - if (localContent !== null) { - const parsed = parseAmsPolicySpecContent(localContent); - return { spec: parsed.spec, source: "local", warnings: parsed.warnings }; - } - - return { spec: DEFAULT_AMS_POLICY_SPEC, source: "default", warnings: [] }; + void repoFullName; + const resolved = normalizeOptions(options); + const localContent = readLocalAmsPolicyContent(resolved); + if (localContent !== null) { + const parsed = parseAmsPolicySpecContent(localContent); + return { spec: parsed.spec, source: "local", warnings: parsed.warnings }; + } + return { spec: DEFAULT_AMS_POLICY_SPEC, source: "default", warnings: [] }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYW1zLXBvbGljeS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImFtcy1wb2xpY3kudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLFVBQVUsRUFBRSxZQUFZLEVBQUUsTUFBTSxTQUFTLENBQUM7QUFFbkQsT0FBTyxFQUFFLHVCQUF1QixFQUFFLHlCQUF5QixFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFDdEYsT0FBTyxFQUFFLHVCQUF1QixFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFFM0QsaUdBQWlHO0FBQ2pHLG1HQUFtRztBQUNuRyx1QkFBdUI7QUFDdkIsRUFBRTtBQUNGLDBHQUEwRztBQUMxRyx3R0FBd0c7QUFDeEcsNERBQTREO0FBRTVELE1BQU0sbUJBQW1CLEdBQUcsbUJBQW1CLENBQUM7QUF5QmhEOzZGQUM2RjtBQUM3RixNQUFNLFVBQVUsMEJBQTBCLENBQUMsTUFBMEMsT0FBTyxDQUFDLEdBQUc7SUFDOUYsT0FBTyx1QkFBdUIsQ0FBQyxtQkFBbUIsRUFBRSxnQ0FBZ0MsRUFBRSxHQUFHLENBQUMsQ0FBQztBQUM3RixDQUFDO0FBRUQsU0FBUyxnQkFBZ0IsQ0FBQyxVQUE0QixFQUFFO0lBQ3RELE9BQU87UUFDTCxZQUFZLEVBQUUsT0FBTyxDQUFDLFlBQVksSUFBSSxZQUFZO1FBQ2xELFVBQVUsRUFBRSxPQUFPLENBQUMsVUFBVSxJQUFJLFVBQVU7UUFDNUMsR0FBRyxFQUFFLE9BQU8sQ0FBQyxHQUFHLElBQUksT0FBTyxDQUFDLEdBQUc7S0FDaEMsQ0FBQztBQUNKLENBQUM7QUFFRDt1RkFDdUY7QUFDdkYsU0FBUyx5QkFBeUIsQ0FBQyxRQUFvQztJQUNyRSxNQUFNLElBQUksR0FBRywwQkFBMEIsQ0FBQyxRQUFRLENBQUMsR0FBRyxDQUFDLENBQUM7SUFDdEQsSUFBSSxDQUFDLFFBQVEsQ0FBQyxVQUFVLENBQUMsSUFBSSxDQUFDO1FBQUUsT0FBTyxJQUFJLENBQUM7SUFDNUMsSUFBSSxDQUFDO1FBQ0gsT0FBTyxRQUFRLENBQUMsWUFBWSxDQUFDLElBQUksRUFBRSxNQUFNLENBQUMsQ0FBQztJQUM3QyxDQUFDO0lBQUMsTUFBTSxDQUFDO1FBQ1AsT0FBTyxJQUFJLENBQUM7SUFDZCxDQUFDO0FBQ0gsQ0FBQztBQUVEOzs7Ozs7OztHQVFHO0FBQ0gsTUFBTSxDQUFDLEtBQUssVUFBVSxnQkFBZ0IsQ0FDcEMsWUFBb0IsRUFDcEIsVUFBNEIsRUFBRTtJQUU5QixLQUFLLFlBQVksQ0FBQztJQUNsQixNQUFNLFFBQVEsR0FBRyxnQkFBZ0IsQ0FBQyxPQUFPLENBQUMsQ0FBQztJQUUzQyxNQUFNLFlBQVksR0FBRyx5QkFBeUIsQ0FBQyxRQUFRLENBQUMsQ0FBQztJQUN6RCxJQUFJLFlBQVksS0FBSyxJQUFJLEVBQUUsQ0FBQztRQUMxQixNQUFNLE1BQU0sR0FBRyx5QkFBeUIsQ0FBQyxZQUFZLENBQUMsQ0FBQztRQUN2RCxPQUFPLEVBQUUsSUFBSSxFQUFFLE1BQU0sQ0FBQyxJQUFJLEVBQUUsTUFBTSxFQUFFLE9BQU8sRUFBRSxRQUFRLEVBQUUsTUFBTSxDQUFDLFFBQVEsRUFBRSxDQUFDO0lBQzNFLENBQUM7SUFFRCxPQUFPLEVBQUUsSUFBSSxFQUFFLHVCQUF1QixFQUFFLE1BQU0sRUFBRSxTQUFTLEVBQUUsUUFBUSxFQUFFLEVBQUUsRUFBRSxDQUFDO0FBQzVFLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/ams-policy.ts b/packages/loopover-miner/lib/ams-policy.ts new file mode 100644 index 0000000000..19dad08b41 --- /dev/null +++ b/packages/loopover-miner/lib/ams-policy.ts @@ -0,0 +1,88 @@ +import { existsSync, readFileSync } from "node:fs"; +import type { AmsPolicySpec } from "@loopover/engine"; +import { DEFAULT_AMS_POLICY_SPEC, parseAmsPolicySpecContent } from "@loopover/engine"; +import { resolveLocalStoreDbPath } from "./local-store.js"; + +// Resolver for the operator-local `.loopover-ams.yml` (#5132, Wave 3.5 follow-up). AmsPolicySpec +// (ams-policy-spec.ts, engine package) is the type/parser surface; this module is the actual local +// read+resolve caller. +// +// This is deliberately NOT the same resolution shape as self-review-context.js/rejection-signal.js, which +// read from the target repo: AmsPolicySpec's fields are the OPERATOR's own execution-risk policy, so an +// untrusted target repo must never get final say over them. + +const AMS_POLICY_FILENAME = ".loopover-ams.yml"; + +export type AmsPolicySource = "local" | "default"; + +export type ResolvedAmsPolicy = { + spec: AmsPolicySpec; + source: AmsPolicySource; + warnings: string[]; +}; + +export type AmsPolicyOptions = { + /** Accepted for forward/API compatibility with callers that pass a fetch override; unused today since this + * resolver never fetches (see the module doc comment above). */ + fetchImpl?: unknown; + readFileSync?: (path: string, encoding: "utf8") => string; + existsSync?: (path: string) => boolean; + env?: Record; +}; + +type NormalizedAmsPolicyOptions = { + readFileSync: (path: string, encoding: "utf8") => string; + existsSync: (path: string) => boolean; + env: Record; +}; + +/** Resolve the operator's local AMS policy file path: explicit env var > `LOOPOVER_MINER_CONFIG_DIR` > + * `XDG_CONFIG_HOME`/`~/.config`, mirroring every other local-store path in this package. */ +export function resolveAmsPolicyConfigPath(env: Record = process.env): string { + return resolveLocalStoreDbPath(AMS_POLICY_FILENAME, "LOOPOVER_MINER_AMS_POLICY_PATH", env); +} + +function normalizeOptions(options: AmsPolicyOptions = {}): NormalizedAmsPolicyOptions { + return { + readFileSync: options.readFileSync ?? readFileSync, + existsSync: options.existsSync ?? existsSync, + env: options.env ?? process.env, + }; +} + +/** Read the operator's own local `.loopover-ams.yml`, if one exists. Never throws: an unreadable file is + * treated the same as an absent one, falling through to the next resolution layer. */ +function readLocalAmsPolicyContent(resolved: NormalizedAmsPolicyOptions): string | null { + const path = resolveAmsPolicyConfigPath(resolved.env); + if (!resolved.existsSync(path)) return null; + try { + return resolved.readFileSync(path, "utf8"); + } catch { + return null; + } +} + +/** + * Resolve the real, effective AMS execution policy for one attempt: the operator's own local + * `.loopover-ams.yml` when present (source: "local"), else the engine's safe defaults (source: "default"). + * Never throws -- an unreadable/malformed local file degrades through the tolerant parser to the safe + * defaults, same discipline as every other tolerant parser in this pipeline. + * + * `repoFullName` is accepted for API compatibility with callers that resolve policy per target repo, but the + * resolver intentionally does not fetch or trust target-repository AMS policy content. + */ +export async function resolveAmsPolicy( + repoFullName: string, + options: AmsPolicyOptions = {}, +): Promise { + void repoFullName; + const resolved = normalizeOptions(options); + + const localContent = readLocalAmsPolicyContent(resolved); + if (localContent !== null) { + const parsed = parseAmsPolicySpecContent(localContent); + return { spec: parsed.spec, source: "local", warnings: parsed.warnings }; + } + + return { spec: DEFAULT_AMS_POLICY_SPEC, source: "default", warnings: [] }; +} diff --git a/packages/loopover-miner/lib/attempt-runner.d.ts b/packages/loopover-miner/lib/attempt-runner.d.ts index 0e966049f9..b440eb31e9 100644 --- a/packages/loopover-miner/lib/attempt-runner.d.ts +++ b/packages/loopover-miner/lib/attempt-runner.d.ts @@ -1,63 +1,67 @@ -import type { - CodingAgentDriver, - GovernorDecision, - IterateLoopInput, - IterateLoopResult, - LocalWriteActionSpec, -} from "@loopover/engine"; -import type { HarnessSubmissionDecision, HarnessSubmissionEventLedger } from "./harness-submission-trigger.js"; -import type { SubmissionFreshnessClaimLedger, LiveIssueSnapshot, FreshnessAbortReason } from "./submission-freshness-check.js"; +import type { CodingAgentDriver, GovernorDecision, IterateLoopInput, IterateLoopResult, IterateLoopShouldAbort, LocalWriteActionSpec } from "@loopover/engine"; +import type { FreshnessAbortReason, LiveIssueSnapshot, SubmissionFreshnessClaimLedger } from "./submission-freshness-check.js"; import type { GovernorChokepointInputPersisted } from "./governor-chokepoint-persisted.js"; import type { GovernorState } from "./governor-state.js"; - -export const ATTEMPT_OUTCOMES: readonly ["abandon", "stale", "blocked", "governed", "submitted"]; - -// rateLimitBuckets/rateLimitBackoffAttempts/capUsage are optional here (via GovernorChokepointInputPersisted, -// not the engine's own GovernorChokepointInput) so a caller can omit them and let evaluateGovernorChokepointGatePersisted -// (#5134) auto-supply real persisted state -- forcing them required at this layer would make every caller -// hand-thread honest-but-stale zero defaults on every invocation, silently defeating that persistence. +import type { HarnessSubmissionDecision, HarnessSubmissionEventLedger } from "./harness-submission-trigger.js"; +export declare const ATTEMPT_OUTCOMES: readonly ["abandon", "stale", "blocked", "governed", "submitted"]; export type AttemptGovernorContext = Omit; - export type AttemptInput = { - loopInput: IterateLoopInput; - issueNumber: number; - minerLogin: string; - base: string; - killSwitchScope: "global" | "repo" | "none"; - slopThreshold: "clean" | "low" | "elevated" | "high"; - submissionMode: "observe" | "enforce"; - maxConsecutiveGateBlocks?: number; - draft?: boolean; - governor: AttemptGovernorContext; + loopInput: IterateLoopInput; + issueNumber: number; + minerLogin: string; + base: string; + killSwitchScope: "global" | "repo" | "none"; + slopThreshold: "clean" | "low" | "elevated" | "high"; + submissionMode: "observe" | "enforce"; + maxConsecutiveGateBlocks?: number; + draft?: boolean; + governor: AttemptGovernorContext; }; - export type AttemptDeps = { - driver: CodingAgentDriver; - runSlopAssessment: (input: unknown) => unknown; - appendAttemptLogEvent: (event: unknown) => void; - claimLedger: SubmissionFreshnessClaimLedger; - fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise; - eventLedger: HarnessSubmissionEventLedger; - /** Injected governor-ledger append (mirrors evaluateGovernorChokepointGate's own `options.append`); omitted - * falls back to that function's own default (the real default governor ledger). */ - governorLedgerAppend?: (event: unknown) => unknown; - /** Injected governor-state store (#5134); omitted falls back to evaluateGovernorChokepointGatePersisted's - * own default (opens + closes the real default governor-state store for this one call). */ - governorState?: GovernorState; - sessionStartMs?: number; - nowMs: number; - executeLocalWrite: (spec: LocalWriteActionSpec) => Promise; - /** Mid-attempt kill-switch probe threaded into `runIterateLoop` (#5670). */ - shouldAbort?: () => import("@loopover/engine").IterateLoopShouldAbort; - /** Live kill-switch scope resolver after handoff (#5670); defaults to the frozen attempt-start scope. */ - resolveKillSwitchScope?: () => "global" | "repo" | "none"; + driver: CodingAgentDriver; + runSlopAssessment: (input: unknown) => unknown; + appendAttemptLogEvent: (event: unknown) => void; + claimLedger: SubmissionFreshnessClaimLedger; + fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise; + eventLedger: HarnessSubmissionEventLedger; + /** Injected governor-ledger append (mirrors evaluateGovernorChokepointGate's own `options.append`); omitted + * falls back to that function's own default (the real default governor ledger). */ + governorLedgerAppend?: (event: unknown) => unknown; + /** Injected governor-state store (#5134); omitted falls back to evaluateGovernorChokepointGatePersisted's + * own default (opens + closes the real default governor-state store for this one call). */ + governorState?: GovernorState; + sessionStartMs?: number; + nowMs: number; + executeLocalWrite: (spec: LocalWriteActionSpec) => Promise; + /** Mid-attempt kill-switch probe threaded into `runIterateLoop` (#5670). */ + shouldAbort?: () => IterateLoopShouldAbort; + /** Live kill-switch scope resolver after handoff (#5670); defaults to the frozen attempt-start scope. */ + resolveKillSwitchScope?: () => "global" | "repo" | "none"; +}; +export type AttemptResult = { + outcome: "abandon"; + loopResult: IterateLoopResult; +} | { + outcome: "stale"; + reason: FreshnessAbortReason; + loopResult: IterateLoopResult; +} | { + outcome: "blocked"; + decision: HarnessSubmissionDecision; + loopResult: IterateLoopResult; +} | { + outcome: "governed"; + decision: GovernorDecision; + loopResult: IterateLoopResult; +} | { + outcome: "submitted"; + spec: LocalWriteActionSpec; + execResult: unknown; + loopResult: IterateLoopResult; }; - -export type AttemptResult = - | { outcome: "abandon"; loopResult: IterateLoopResult } - | { outcome: "stale"; reason: FreshnessAbortReason; loopResult: IterateLoopResult } - | { outcome: "blocked"; decision: HarnessSubmissionDecision; loopResult: IterateLoopResult } - | { outcome: "governed"; decision: GovernorDecision; loopResult: IterateLoopResult } - | { outcome: "submitted"; spec: LocalWriteActionSpec; execResult: unknown; loopResult: IterateLoopResult }; - -export function runMinerAttempt(input: AttemptInput, deps: AttemptDeps): Promise; +/** + * Run one full attempt end to end: iterate-loop -> (on handoff) freshness -> submission-gate -> Governor + * chokepoint -> (on allowed:true) build + execute the real open_pr command. Fails closed (throws) on malformed + * input/deps, mirroring every sibling module in this pipeline. + */ +export declare function runMinerAttempt(input: AttemptInput, deps: AttemptDeps): Promise; diff --git a/packages/loopover-miner/lib/attempt-runner.js b/packages/loopover-miner/lib/attempt-runner.js index cb41aee258..90764dae16 100644 --- a/packages/loopover-miner/lib/attempt-runner.js +++ b/packages/loopover-miner/lib/attempt-runner.js @@ -5,7 +5,13 @@ import { evaluateGovernorChokepointGatePersisted } from "./governor-chokepoint-p import { listRecentOwnSubmissions } from "./governor-state.js"; import { prepareOpenPrSubmission } from "./harness-submission-trigger.js"; import { captureMinerError } from "./sentry.js"; - +export const ATTEMPT_OUTCOMES = Object.freeze([ + "abandon", + "stale", + "blocked", + "governed", + "submitted", +]); // The real driving-loop entrypoint (#2337): the missing link between #2333's iterate-loop orchestrator and an // actual, executed open_pr write. Composes, in order: runIterateLoop (create -> score -> self-review -> decide, // #2333) -> on handoff, checkSubmissionFreshness (#3007) -> prepareOpenPrSubmission (#2336/#2337) -> the @@ -41,185 +47,174 @@ import { captureMinerError } from "./sentry.js"; // listRecentOwnSubmissions) are computed late -- right before the chokepoint call, after handoff, where the // changed files first exist -- and passed in, so chokepoint.ts's selfPlagiarismCheck finally runs on real data. // `input.governor.reputationHistory` remains a caller-supplied optional field, not auto-loaded here yet. - -/** True once the loop reaches handoff AND every downstream gate (freshness, submission, governor) allows. */ -export const ATTEMPT_OUTCOMES = Object.freeze(["abandon", "stale", "blocked", "governed", "submitted"]); - function assertFn(value, name) { - if (typeof value !== "function") throw new Error(`invalid_${name}`); + if (typeof value !== "function") + throw new Error(`invalid_${name}`); } - function assertDeps(deps) { - if (!deps || typeof deps !== "object") throw new Error("invalid_attempt_deps"); - assertFn(deps.runSlopAssessment, "run_slop_assessment"); - assertFn(deps.appendAttemptLogEvent, "append_attempt_log_event"); - assertFn(deps.fetchLiveIssueSnapshot, "fetch_live_issue_snapshot"); - assertFn(deps.executeLocalWrite, "execute_local_write"); - if (!deps.driver || typeof deps.driver.run !== "function") throw new Error("invalid_driver"); - if (!deps.claimLedger || typeof deps.claimLedger.listClaims !== "function") throw new Error("invalid_claim_ledger"); - if (!deps.eventLedger || typeof deps.eventLedger.appendEvent !== "function") throw new Error("invalid_event_ledger"); - if (typeof deps.nowMs !== "number" || !Number.isFinite(deps.nowMs)) throw new Error("invalid_now_ms"); + if (!deps || typeof deps !== "object") + throw new Error("invalid_attempt_deps"); + const candidate = deps; + assertFn(candidate.runSlopAssessment, "run_slop_assessment"); + assertFn(candidate.appendAttemptLogEvent, "append_attempt_log_event"); + assertFn(candidate.fetchLiveIssueSnapshot, "fetch_live_issue_snapshot"); + assertFn(candidate.executeLocalWrite, "execute_local_write"); + const driver = candidate.driver; + if (!driver || typeof driver.run !== "function") + throw new Error("invalid_driver"); + const claimLedger = candidate.claimLedger; + if (!claimLedger || typeof claimLedger.listClaims !== "function") + throw new Error("invalid_claim_ledger"); + const eventLedger = candidate.eventLedger; + if (!eventLedger || typeof eventLedger.appendEvent !== "function") + throw new Error("invalid_event_ledger"); + if (typeof candidate.nowMs !== "number" || !Number.isFinite(candidate.nowMs)) + throw new Error("invalid_now_ms"); } - function assertInput(input) { - if (!input || typeof input !== "object") throw new Error("invalid_attempt_input"); - if (!input.loopInput || typeof input.loopInput !== "object") throw new Error("invalid_loop_input"); - if (!Number.isInteger(input.issueNumber) || input.issueNumber < 1) throw new Error("invalid_issue_number"); - if (typeof input.minerLogin !== "string" || !input.minerLogin.trim()) throw new Error("invalid_miner_login"); - if (typeof input.base !== "string" || !input.base.trim()) throw new Error("invalid_base"); - if (!["global", "repo", "none"].includes(input.killSwitchScope)) throw new Error("invalid_kill_switch_scope"); - if (!["clean", "low", "elevated", "high"].includes(input.slopThreshold)) throw new Error("invalid_slop_threshold"); - if (!["observe", "enforce"].includes(input.submissionMode)) throw new Error("invalid_submission_mode"); - if (!input.governor || typeof input.governor !== "object") throw new Error("invalid_governor_context"); + if (!input || typeof input !== "object") + throw new Error("invalid_attempt_input"); + const candidate = input; + if (!candidate.loopInput || typeof candidate.loopInput !== "object") + throw new Error("invalid_loop_input"); + if (!Number.isInteger(candidate.issueNumber) || candidate.issueNumber < 1) { + throw new Error("invalid_issue_number"); + } + if (typeof candidate.minerLogin !== "string" || !candidate.minerLogin.trim()) + throw new Error("invalid_miner_login"); + if (typeof candidate.base !== "string" || !candidate.base.trim()) + throw new Error("invalid_base"); + if (!["global", "repo", "none"].includes(candidate.killSwitchScope)) { + throw new Error("invalid_kill_switch_scope"); + } + if (!["clean", "low", "elevated", "high"].includes(candidate.slopThreshold)) { + throw new Error("invalid_slop_threshold"); + } + if (!["observe", "enforce"].includes(candidate.submissionMode)) + throw new Error("invalid_submission_mode"); + if (!candidate.governor || typeof candidate.governor !== "object") + throw new Error("invalid_governor_context"); } - /** * Run one full attempt end to end: iterate-loop -> (on handoff) freshness -> submission-gate -> Governor * chokepoint -> (on allowed:true) build + execute the real open_pr command. Fails closed (throws) on malformed * input/deps, mirroring every sibling module in this pipeline. - * - * @param {{ - * loopInput: import("@loopover/engine").IterateLoopInput, - * issueNumber: number, - * minerLogin: string, - * base: string, - * killSwitchScope: "global"|"repo"|"none", - * slopThreshold: "clean"|"low"|"elevated"|"high", - * submissionMode: "observe"|"enforce", - * maxConsecutiveGateBlocks?: number, - * draft?: boolean, - * governor: Omit, - * }} input - * @param {{ - * driver: import("@loopover/engine").CodingAgentDriver, - * runSlopAssessment: Function, - * appendAttemptLogEvent: Function, - * claimLedger: object, - * fetchLiveIssueSnapshot: Function, - * eventLedger: object, - * governorLedgerAppend?: Function, - * governorState?: import("./governor-state.js").GovernorState, - * sessionStartMs?: number, - * nowMs: number, - * executeLocalWrite: (spec: import("@loopover/engine").LocalWriteActionSpec) => Promise, - * shouldAbort?: () => import("@loopover/engine").IterateLoopShouldAbort, - * resolveKillSwitchScope?: () => "global"|"repo"|"none", - * }} deps */ export async function runMinerAttempt(input, deps) { - assertInput(input); - assertDeps(deps); - - const loopResult = await runIterateLoop(input.loopInput, { - driver: deps.driver, - runSlopAssessment: deps.runSlopAssessment, - appendAttemptLogEvent: deps.appendAttemptLogEvent, - ...(typeof deps.shouldAbort === "function" ? { shouldAbort: deps.shouldAbort } : {}), - }); - - if (loopResult.outcome === "abandon") { - return { outcome: "abandon", loopResult }; - } - - const handoffPacket = loopResult.handoffPacket; - - // Re-check kill-switch AFTER handoff and BEFORE any write (#5670) when a live resolver is supplied. - // Without a live resolver, preserve pre-#5670 behavior: the frozen attempt-start scope is threaded into - // prepareOpenPrSubmission / the submission gate (which itself denies active kill scopes). - if (typeof deps.resolveKillSwitchScope === "function") { - const liveKillSwitchScope = deps.resolveKillSwitchScope(); - if (liveKillSwitchScope !== "none") { - return { - outcome: "abandon", - loopResult: { - ...loopResult, - outcome: "abandon", - finalDecision: { - action: "abandon", - abandonReason: "kill_switch_engaged", - reason: `Kill-switch (${liveKillSwitchScope}) engaged after handoff; refusing to open a PR.`, - }, - handoffPacket: undefined, - }, - }; + assertInput(input); + assertDeps(deps); + const loopResult = await runIterateLoop(input.loopInput, { + driver: deps.driver, + // AttemptDeps deliberately keeps this loosely typed (`(input: unknown) => unknown`) at the public boundary -- + // the real, narrower shape (SelfReviewAdapterDeps["runSlopAssessment"]) is an engine-internal detail callers + // shouldn't need to import just to satisfy this dependency's type. + runSlopAssessment: deps.runSlopAssessment, + appendAttemptLogEvent: deps.appendAttemptLogEvent, + ...(typeof deps.shouldAbort === "function" ? { shouldAbort: deps.shouldAbort } : {}), + }); + if (loopResult.outcome === "abandon") { + return { outcome: "abandon", loopResult }; + } + // Populated by design whenever outcome !== "abandon" (IterateLoopOutcome is only ever "handoff" | "abandon", + // and handoffPacket is only ever set on "handoff") -- runIterateLoop's own type keeps the field optional + // because it models both outcomes in one result shape. + const handoffPacket = loopResult.handoffPacket; + // Re-check kill-switch AFTER handoff and BEFORE any write (#5670) when a live resolver is supplied. + // Without a live resolver, preserve pre-#5670 behavior: the frozen attempt-start scope is threaded into + // prepareOpenPrSubmission / the submission gate (which itself denies active kill scopes). + if (typeof deps.resolveKillSwitchScope === "function") { + const liveKillSwitchScope = deps.resolveKillSwitchScope(); + if (liveKillSwitchScope !== "none") { + return { + outcome: "abandon", + loopResult: { + ...loopResult, + outcome: "abandon", + finalDecision: { + action: "abandon", + abandonReason: "kill_switch_engaged", + reason: `Kill-switch (${liveKillSwitchScope}) engaged after handoff; refusing to open a PR.`, + }, + handoffPacket: undefined, + }, + }; + } + } + const freshness = await checkSubmissionFreshness({ repoFullName: input.loopInput.repoFullName, issueNumber: input.issueNumber, minerLogin: input.minerLogin }, { claimLedger: deps.claimLedger, fetchLiveIssueSnapshot: deps.fetchLiveIssueSnapshot, eventLedger: deps.eventLedger }); + if (!freshness.fresh) { + return { outcome: "stale", reason: freshness.reason, loopResult }; + } + const submission = await prepareOpenPrSubmission({ + killSwitchScope: input.killSwitchScope, + repoFullName: input.loopInput.repoFullName, + // HandoffPacket's optional fields are typed `| undefined` (engine's exactOptionalPropertyTypes style); + // HarnessSubmissionCandidateInput's narrower local field type omits that -- both describe the same real + // shape, so the cast just bridges the two independently-declared-but-compatible types. + handoffPacket: handoffPacket, + slopThreshold: input.slopThreshold, + mode: input.submissionMode, + // Spread-omit rather than pass `undefined` explicitly -- PrepareOpenPrSubmissionCandidate's optional + // fields don't declare `| undefined`, and exactOptionalPropertyTypes treats those as different. + ...(input.maxConsecutiveGateBlocks !== undefined + ? { maxConsecutiveGateBlocks: input.maxConsecutiveGateBlocks } + : {}), + base: input.base, + title: input.loopInput.title, + body: input.loopInput.body ?? "", + ...(input.draft !== undefined ? { draft: input.draft } : {}), + }, { eventLedger: deps.eventLedger, ...(deps.sessionStartMs !== undefined ? { sessionStartMs: deps.sessionStartMs } : {}) }); + if (!submission.ready) { + return { outcome: "blocked", decision: submission.decision, loopResult }; + } + // Late-augment the self-plagiarism inputs (#5676): the prospective submission's real diff fingerprint and the + // miner's real recent-submission history only exist HERE, after handoff -- attempt-cli.js's single early + // governor snapshot (buildAttemptGovernorContext) is built before any changed files exist, so it cannot carry + // them. This finally feeds chokepoint.ts's selfPlagiarismCheck, which was previously always skipped for lack of + // data. Read the history from the SAME governor-state store the chokepoint itself uses (deps.governorState when + // provided, else the persisted default via the module-level export). Fail open on a read failure so a + // history-store hiccup never blocks an otherwise-allowed real submission. + /* v8 ignore next -- buildHandoffPacket always populates changedFiles; the `?? []` only guards a hand-built packet */ + const changedFilePaths = (handoffPacket.changedFiles ?? []).map((file) => file.path); + const selfPlagiarismCandidate = { + repoFullName: input.loopInput.repoFullName, + fingerprint: fingerprintFromChangedFiles(changedFilePaths), + // The prospective submission's own time is "now" -- selfPlagiarismCheck needs a real submittedAt on the + // candidate (it denies a candidate lacking one) and uses it for earliest-claimant election vs the priors. + submittedAt: new Date(deps.nowMs).toISOString(), + }; + let selfPlagiarismRecentSubmissions; + try { + selfPlagiarismRecentSubmissions = deps.governorState + ? deps.governorState.listRecentOwnSubmissions({ repoFullName: input.loopInput.repoFullName }) + : listRecentOwnSubmissions({ repoFullName: input.loopInput.repoFullName }); + } + catch (error) { + // Fail-open is deliberate (see the comment above) -- this only makes the fallback VISIBLE. A broken + // governor-state store silently disabling the self-plagiarism safety check had zero trace anywhere (#6011). + captureMinerError(error, { kind: "self_plagiarism_history_read_failed", repoFullName: input.loopInput.repoFullName }); + selfPlagiarismRecentSubmissions = []; + } + const governed = evaluateGovernorChokepointGatePersisted({ + actionClass: "open_pr", + repoFullName: input.loopInput.repoFullName, + nowMs: deps.nowMs, + wouldBeAction: submission.openPrInput, + ...input.governor, + selfPlagiarismCandidate, + selfPlagiarismRecentSubmissions, + }, { + // AttemptDeps deliberately keeps governorLedgerAppend loosely typed (`(event: unknown) => unknown`) at the + // public boundary, same as runSlopAssessment above -- cast to the engine-internal signature here. + ...(deps.governorLedgerAppend + ? { append: deps.governorLedgerAppend } + : {}), + ...(deps.governorState ? { governorState: deps.governorState } : {}), + }); + if (!governed.decision.allowed) { + return { outcome: "governed", decision: governed.decision, loopResult }; } - } - - const freshness = await checkSubmissionFreshness( - { repoFullName: input.loopInput.repoFullName, issueNumber: input.issueNumber, minerLogin: input.minerLogin }, - { claimLedger: deps.claimLedger, fetchLiveIssueSnapshot: deps.fetchLiveIssueSnapshot, eventLedger: deps.eventLedger }, - ); - if (!freshness.fresh) { - return { outcome: "stale", reason: freshness.reason, loopResult }; - } - - const submission = await prepareOpenPrSubmission( - { - killSwitchScope: input.killSwitchScope, - repoFullName: input.loopInput.repoFullName, - handoffPacket, - slopThreshold: input.slopThreshold, - mode: input.submissionMode, - maxConsecutiveGateBlocks: input.maxConsecutiveGateBlocks, - base: input.base, - title: input.loopInput.title, - body: input.loopInput.body ?? "", - draft: input.draft, - }, - { eventLedger: deps.eventLedger, sessionStartMs: deps.sessionStartMs }, - ); - if (!submission.ready) { - return { outcome: "blocked", decision: submission.decision, loopResult }; - } - - // Late-augment the self-plagiarism inputs (#5676): the prospective submission's real diff fingerprint and the - // miner's real recent-submission history only exist HERE, after handoff -- attempt-cli.js's single early - // governor snapshot (buildAttemptGovernorContext) is built before any changed files exist, so it cannot carry - // them. This finally feeds chokepoint.ts's selfPlagiarismCheck, which was previously always skipped for lack of - // data. Read the history from the SAME governor-state store the chokepoint itself uses (deps.governorState when - // provided, else the persisted default via the module-level export). Fail open on a read failure so a - // history-store hiccup never blocks an otherwise-allowed real submission. - /* v8 ignore next -- buildHandoffPacket always populates changedFiles; the `?? []` only guards a hand-built packet */ - const changedFilePaths = (handoffPacket.changedFiles ?? []).map((file) => file.path); - const selfPlagiarismCandidate = { - repoFullName: input.loopInput.repoFullName, - fingerprint: fingerprintFromChangedFiles(changedFilePaths), - // The prospective submission's own time is "now" -- selfPlagiarismCheck needs a real submittedAt on the - // candidate (it denies a candidate lacking one) and uses it for earliest-claimant election vs the priors. - submittedAt: new Date(deps.nowMs).toISOString(), - }; - let selfPlagiarismRecentSubmissions; - try { - selfPlagiarismRecentSubmissions = deps.governorState - ? deps.governorState.listRecentOwnSubmissions({ repoFullName: input.loopInput.repoFullName }) - : listRecentOwnSubmissions({ repoFullName: input.loopInput.repoFullName }); - } catch (error) { - // Fail-open is deliberate (see the comment above) -- this only makes the fallback VISIBLE. A broken - // governor-state store silently disabling the self-plagiarism safety check had zero trace anywhere (#6011). - captureMinerError(error, { kind: "self_plagiarism_history_read_failed", repoFullName: input.loopInput.repoFullName }); - selfPlagiarismRecentSubmissions = []; - } - - const governed = evaluateGovernorChokepointGatePersisted( - { - actionClass: "open_pr", - repoFullName: input.loopInput.repoFullName, - nowMs: deps.nowMs, - wouldBeAction: submission.openPrInput, - ...input.governor, - selfPlagiarismCandidate, - selfPlagiarismRecentSubmissions, - }, - { - ...(deps.governorLedgerAppend ? { append: deps.governorLedgerAppend } : {}), - ...(deps.governorState ? { governorState: deps.governorState } : {}), - }, - ); - if (!governed.decision.allowed) { - return { outcome: "governed", decision: governed.decision, loopResult }; - } - - const spec = buildOpenPrSpec(submission.openPrInput); - const execResult = await deps.executeLocalWrite(spec); - return { outcome: "submitted", spec, execResult, loopResult }; + const spec = buildOpenPrSpec(submission.openPrInput); + const execResult = await deps.executeLocalWrite(spec); + return { outcome: "submitted", spec, execResult, loopResult }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYXR0ZW1wdC1ydW5uZXIuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJhdHRlbXB0LXJ1bm5lci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFVQSxPQUFPLEVBQUUsZUFBZSxFQUFFLDJCQUEyQixFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFDaEYsT0FBTyxFQUFFLGNBQWMsRUFBRSxNQUFNLGtCQUFrQixDQUFDO0FBTWxELE9BQU8sRUFBRSx3QkFBd0IsRUFBRSxNQUFNLGlDQUFpQyxDQUFDO0FBRTNFLE9BQU8sRUFBRSx1Q0FBdUMsRUFBRSxNQUFNLG9DQUFvQyxDQUFDO0FBRzdGLE9BQU8sRUFBRSx3QkFBd0IsRUFBRSxNQUFNLHFCQUFxQixDQUFDO0FBTS9ELE9BQU8sRUFBRSx1QkFBdUIsRUFBRSxNQUFNLGlDQUFpQyxDQUFDO0FBQzFFLE9BQU8sRUFBRSxpQkFBaUIsRUFBRSxNQUFNLGFBQWEsQ0FBQztBQUVoRCxNQUFNLENBQUMsTUFBTSxnQkFBZ0IsR0FBc0UsTUFBTSxDQUFDLE1BQU0sQ0FBQztJQUMvRyxTQUFTO0lBQ1QsT0FBTztJQUNQLFNBQVM7SUFDVCxVQUFVO0lBQ1YsV0FBVztDQUNaLENBQUMsQ0FBQztBQXFESCw4R0FBOEc7QUFDOUcsZ0hBQWdIO0FBQ2hILHlHQUF5RztBQUN6Ryx3R0FBd0c7QUFDeEcsK0dBQStHO0FBQy9HLHNHQUFzRztBQUN0RyxvR0FBb0c7QUFDcEcsRUFBRTtBQUNGLDhHQUE4RztBQUM5RywyR0FBMkc7QUFDM0csK0ZBQStGO0FBQy9GLHlHQUF5RztBQUN6RywrRkFBK0Y7QUFDL0YsRUFBRTtBQUNGLDhHQUE4RztBQUM5Ryw2R0FBNkc7QUFDN0csaUhBQWlIO0FBQ2pILCtHQUErRztBQUMvRyw0R0FBNEc7QUFDNUcsNkdBQTZHO0FBQzdHLDZHQUE2RztBQUM3Ryx3R0FBd0c7QUFDeEcsYUFBYTtBQUNiLEVBQUU7QUFDRiwyR0FBMkc7QUFDM0csOEVBQThFO0FBQzlFLDhHQUE4RztBQUM5Ryw4R0FB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/attempt-runner.ts b/packages/loopover-miner/lib/attempt-runner.ts new file mode 100644 index 0000000000..3484911788 --- /dev/null +++ b/packages/loopover-miner/lib/attempt-runner.ts @@ -0,0 +1,304 @@ +import type { + CodingAgentDriver, + GovernorDecision, + HandoffPacket, + IterateLoopInput, + IterateLoopResult, + IterateLoopShouldAbort, + LocalWriteActionSpec, + SelfReviewAdapterDeps, +} from "@loopover/engine"; +import { buildOpenPrSpec, fingerprintFromChangedFiles } from "@loopover/engine"; +import { runIterateLoop } from "@loopover/engine"; +import type { + FreshnessAbortReason, + LiveIssueSnapshot, + SubmissionFreshnessClaimLedger, +} from "./submission-freshness-check.js"; +import { checkSubmissionFreshness } from "./submission-freshness-check.js"; +import type { GovernorChokepointInputPersisted } from "./governor-chokepoint-persisted.js"; +import { evaluateGovernorChokepointGatePersisted } from "./governor-chokepoint-persisted.js"; +import type { AppendGovernorEventInput, GovernorLedgerEntry } from "./governor-ledger.js"; +import type { GovernorState } from "./governor-state.js"; +import { listRecentOwnSubmissions } from "./governor-state.js"; +import type { + HarnessSubmissionCandidateInput, + HarnessSubmissionDecision, + HarnessSubmissionEventLedger, +} from "./harness-submission-trigger.js"; +import { prepareOpenPrSubmission } from "./harness-submission-trigger.js"; +import { captureMinerError } from "./sentry.js"; + +export const ATTEMPT_OUTCOMES: readonly ["abandon", "stale", "blocked", "governed", "submitted"] = Object.freeze([ + "abandon", + "stale", + "blocked", + "governed", + "submitted", +]); + +// rateLimitBuckets/rateLimitBackoffAttempts/capUsage are optional here (via GovernorChokepointInputPersisted, +// not the engine's own GovernorChokepointInput) so a caller can omit them and let evaluateGovernorChokepointGatePersisted +// (#5134) auto-supply real persisted state -- forcing them required at this layer would make every caller +// hand-thread honest-but-stale zero defaults on every invocation, silently defeating that persistence. +export type AttemptGovernorContext = Omit< + GovernorChokepointInputPersisted, + "actionClass" | "repoFullName" | "nowMs" | "wouldBeAction" +>; + +export type AttemptInput = { + loopInput: IterateLoopInput; + issueNumber: number; + minerLogin: string; + base: string; + killSwitchScope: "global" | "repo" | "none"; + slopThreshold: "clean" | "low" | "elevated" | "high"; + submissionMode: "observe" | "enforce"; + maxConsecutiveGateBlocks?: number; + draft?: boolean; + governor: AttemptGovernorContext; +}; + +export type AttemptDeps = { + driver: CodingAgentDriver; + runSlopAssessment: (input: unknown) => unknown; + appendAttemptLogEvent: (event: unknown) => void; + claimLedger: SubmissionFreshnessClaimLedger; + fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise; + eventLedger: HarnessSubmissionEventLedger; + /** Injected governor-ledger append (mirrors evaluateGovernorChokepointGate's own `options.append`); omitted + * falls back to that function's own default (the real default governor ledger). */ + governorLedgerAppend?: (event: unknown) => unknown; + /** Injected governor-state store (#5134); omitted falls back to evaluateGovernorChokepointGatePersisted's + * own default (opens + closes the real default governor-state store for this one call). */ + governorState?: GovernorState; + sessionStartMs?: number; + nowMs: number; + executeLocalWrite: (spec: LocalWriteActionSpec) => Promise; + /** Mid-attempt kill-switch probe threaded into `runIterateLoop` (#5670). */ + shouldAbort?: () => IterateLoopShouldAbort; + /** Live kill-switch scope resolver after handoff (#5670); defaults to the frozen attempt-start scope. */ + resolveKillSwitchScope?: () => "global" | "repo" | "none"; +}; + +export type AttemptResult = + | { outcome: "abandon"; loopResult: IterateLoopResult } + | { outcome: "stale"; reason: FreshnessAbortReason; loopResult: IterateLoopResult } + | { outcome: "blocked"; decision: HarnessSubmissionDecision; loopResult: IterateLoopResult } + | { outcome: "governed"; decision: GovernorDecision; loopResult: IterateLoopResult } + | { outcome: "submitted"; spec: LocalWriteActionSpec; execResult: unknown; loopResult: IterateLoopResult }; + +// The real driving-loop entrypoint (#2337): the missing link between #2333's iterate-loop orchestrator and an +// actual, executed open_pr write. Composes, in order: runIterateLoop (create -> score -> self-review -> decide, +// #2333) -> on handoff, checkSubmissionFreshness (#3007) -> prepareOpenPrSubmission (#2336/#2337) -> the +// Governor chokepoint (#2340, which itself composes kill-switch, dry-run, rate-limit, budget caps, non- +// convergence, self-reputation-throttle, and self-plagiarism -- see chokepoint.ts's own module doc comment for +// the exact precedence ladder) -> on allowed:true, builds the REAL open_pr command via the now-shared +// buildOpenPrSpec (@loopover/engine, moved from root src/mcp/local-write-tools.ts) and executes it. +// +// WORKTREE LIFECYCLE IS NOT THIS MODULE'S JOB: runIterateLoop already takes a plain `workingDirectory` string +// (packages/loopover-engine/src/miner/iterate-loop.ts's own IterateLoopInput), deliberately agnostic about +// where it came from. Allocating one is the caller's job, via the already-built slot allocator +// (worktree-allocator.js, #4297) -- this module composes the create/review/gate/submit sequence #2337 is +// actually about, not worktree allocation policy, which is a separate, already-solved concern. +// +// `deps.runSlopAssessment` stays INJECTED rather than imported here (this module composes the sequence and is +// agnostic about the scorer behind the seam), but it is no longer unwired: slop-assessment.js (#5133) is its +// real production binding -- a direct pass-through to the engine's own buildSlopAssessment -- and attempt-cli.js +// wires that binding in on the production path. The seam was injected-but-unwired when this module was written +// only because the deterministic scorer was not yet portable; #5133 extracted src/signals/slop.ts's PR-side +// scorer into packages/loopover-engine/src/signals/slop.ts (byte-parity-verified against the live gate's own +// copy), closing the gap this header used to document. This function still requires a real implementation be +// injected rather than silently stubbing a result that would either always pass (unsafe) or always fail +// (useless). +// +// `input.governor`'s cross-attempt state (rate-limit buckets, backoff attempts, budget-cap usage) DOES now +// persist across separate process invocations (#5134, governor-state.js), via +// evaluateGovernorChokepointGatePersisted -- callers no longer need to hand-thread honest empty/zero defaults +// on every invocation; `capUsage` is loaded from that same store but its post-attempt save stays the caller's +// job (see governor-chokepoint-persisted.js's own header for why: nothing computes "the next capUsage" from a +// verdict, only the attempt's real outcome does). Self-plagiarism state is now wired here (#5676): the +// prospective submission's real diff `selfPlagiarismCandidate` (fingerprintFromChangedFiles over the handoff +// packet's changed files) and the miner's real `selfPlagiarismRecentSubmissions` (governor-state.js's +// listRecentOwnSubmissions) are computed late -- right before the chokepoint call, after handoff, where the +// changed files first exist -- and passed in, so chokepoint.ts's selfPlagiarismCheck finally runs on real data. +// `input.governor.reputationHistory` remains a caller-supplied optional field, not auto-loaded here yet. + +function assertFn(value: unknown, name: string): void { + if (typeof value !== "function") throw new Error(`invalid_${name}`); +} + +function assertDeps(deps: unknown): asserts deps is AttemptDeps { + if (!deps || typeof deps !== "object") throw new Error("invalid_attempt_deps"); + const candidate = deps as Record; + assertFn(candidate.runSlopAssessment, "run_slop_assessment"); + assertFn(candidate.appendAttemptLogEvent, "append_attempt_log_event"); + assertFn(candidate.fetchLiveIssueSnapshot, "fetch_live_issue_snapshot"); + assertFn(candidate.executeLocalWrite, "execute_local_write"); + const driver = candidate.driver as { run?: unknown } | undefined; + if (!driver || typeof driver.run !== "function") throw new Error("invalid_driver"); + const claimLedger = candidate.claimLedger as { listClaims?: unknown } | undefined; + if (!claimLedger || typeof claimLedger.listClaims !== "function") throw new Error("invalid_claim_ledger"); + const eventLedger = candidate.eventLedger as { appendEvent?: unknown } | undefined; + if (!eventLedger || typeof eventLedger.appendEvent !== "function") throw new Error("invalid_event_ledger"); + if (typeof candidate.nowMs !== "number" || !Number.isFinite(candidate.nowMs)) throw new Error("invalid_now_ms"); +} + +function assertInput(input: unknown): asserts input is AttemptInput { + if (!input || typeof input !== "object") throw new Error("invalid_attempt_input"); + const candidate = input as Record; + if (!candidate.loopInput || typeof candidate.loopInput !== "object") throw new Error("invalid_loop_input"); + if (!Number.isInteger(candidate.issueNumber) || (candidate.issueNumber as number) < 1) { + throw new Error("invalid_issue_number"); + } + if (typeof candidate.minerLogin !== "string" || !candidate.minerLogin.trim()) throw new Error("invalid_miner_login"); + if (typeof candidate.base !== "string" || !candidate.base.trim()) throw new Error("invalid_base"); + if (!["global", "repo", "none"].includes(candidate.killSwitchScope as string)) { + throw new Error("invalid_kill_switch_scope"); + } + if (!["clean", "low", "elevated", "high"].includes(candidate.slopThreshold as string)) { + throw new Error("invalid_slop_threshold"); + } + if (!["observe", "enforce"].includes(candidate.submissionMode as string)) throw new Error("invalid_submission_mode"); + if (!candidate.governor || typeof candidate.governor !== "object") throw new Error("invalid_governor_context"); +} + +/** + * Run one full attempt end to end: iterate-loop -> (on handoff) freshness -> submission-gate -> Governor + * chokepoint -> (on allowed:true) build + execute the real open_pr command. Fails closed (throws) on malformed + * input/deps, mirroring every sibling module in this pipeline. + */ +export async function runMinerAttempt(input: AttemptInput, deps: AttemptDeps): Promise { + assertInput(input); + assertDeps(deps); + + const loopResult = await runIterateLoop(input.loopInput, { + driver: deps.driver, + // AttemptDeps deliberately keeps this loosely typed (`(input: unknown) => unknown`) at the public boundary -- + // the real, narrower shape (SelfReviewAdapterDeps["runSlopAssessment"]) is an engine-internal detail callers + // shouldn't need to import just to satisfy this dependency's type. + runSlopAssessment: deps.runSlopAssessment as SelfReviewAdapterDeps["runSlopAssessment"], + appendAttemptLogEvent: deps.appendAttemptLogEvent, + ...(typeof deps.shouldAbort === "function" ? { shouldAbort: deps.shouldAbort } : {}), + }); + + if (loopResult.outcome === "abandon") { + return { outcome: "abandon", loopResult }; + } + + // Populated by design whenever outcome !== "abandon" (IterateLoopOutcome is only ever "handoff" | "abandon", + // and handoffPacket is only ever set on "handoff") -- runIterateLoop's own type keeps the field optional + // because it models both outcomes in one result shape. + const handoffPacket = loopResult.handoffPacket as HandoffPacket; + + // Re-check kill-switch AFTER handoff and BEFORE any write (#5670) when a live resolver is supplied. + // Without a live resolver, preserve pre-#5670 behavior: the frozen attempt-start scope is threaded into + // prepareOpenPrSubmission / the submission gate (which itself denies active kill scopes). + if (typeof deps.resolveKillSwitchScope === "function") { + const liveKillSwitchScope = deps.resolveKillSwitchScope(); + if (liveKillSwitchScope !== "none") { + return { + outcome: "abandon", + loopResult: { + ...loopResult, + outcome: "abandon", + finalDecision: { + action: "abandon", + abandonReason: "kill_switch_engaged", + reason: `Kill-switch (${liveKillSwitchScope}) engaged after handoff; refusing to open a PR.`, + }, + handoffPacket: undefined, + }, + }; + } + } + + const freshness = await checkSubmissionFreshness( + { repoFullName: input.loopInput.repoFullName, issueNumber: input.issueNumber, minerLogin: input.minerLogin }, + { claimLedger: deps.claimLedger, fetchLiveIssueSnapshot: deps.fetchLiveIssueSnapshot, eventLedger: deps.eventLedger }, + ); + if (!freshness.fresh) { + return { outcome: "stale", reason: freshness.reason, loopResult }; + } + + const submission = await prepareOpenPrSubmission( + { + killSwitchScope: input.killSwitchScope, + repoFullName: input.loopInput.repoFullName, + // HandoffPacket's optional fields are typed `| undefined` (engine's exactOptionalPropertyTypes style); + // HarnessSubmissionCandidateInput's narrower local field type omits that -- both describe the same real + // shape, so the cast just bridges the two independently-declared-but-compatible types. + handoffPacket: handoffPacket as HarnessSubmissionCandidateInput["handoffPacket"], + slopThreshold: input.slopThreshold, + mode: input.submissionMode, + // Spread-omit rather than pass `undefined` explicitly -- PrepareOpenPrSubmissionCandidate's optional + // fields don't declare `| undefined`, and exactOptionalPropertyTypes treats those as different. + ...(input.maxConsecutiveGateBlocks !== undefined + ? { maxConsecutiveGateBlocks: input.maxConsecutiveGateBlocks } + : {}), + base: input.base, + title: input.loopInput.title, + body: input.loopInput.body ?? "", + ...(input.draft !== undefined ? { draft: input.draft } : {}), + }, + { eventLedger: deps.eventLedger, ...(deps.sessionStartMs !== undefined ? { sessionStartMs: deps.sessionStartMs } : {}) }, + ); + if (!submission.ready) { + return { outcome: "blocked", decision: submission.decision, loopResult }; + } + + // Late-augment the self-plagiarism inputs (#5676): the prospective submission's real diff fingerprint and the + // miner's real recent-submission history only exist HERE, after handoff -- attempt-cli.js's single early + // governor snapshot (buildAttemptGovernorContext) is built before any changed files exist, so it cannot carry + // them. This finally feeds chokepoint.ts's selfPlagiarismCheck, which was previously always skipped for lack of + // data. Read the history from the SAME governor-state store the chokepoint itself uses (deps.governorState when + // provided, else the persisted default via the module-level export). Fail open on a read failure so a + // history-store hiccup never blocks an otherwise-allowed real submission. + /* v8 ignore next -- buildHandoffPacket always populates changedFiles; the `?? []` only guards a hand-built packet */ + const changedFilePaths = (handoffPacket.changedFiles ?? []).map((file) => file.path); + const selfPlagiarismCandidate = { + repoFullName: input.loopInput.repoFullName, + fingerprint: fingerprintFromChangedFiles(changedFilePaths), + // The prospective submission's own time is "now" -- selfPlagiarismCheck needs a real submittedAt on the + // candidate (it denies a candidate lacking one) and uses it for earliest-claimant election vs the priors. + submittedAt: new Date(deps.nowMs).toISOString(), + }; + let selfPlagiarismRecentSubmissions: ReturnType; + try { + selfPlagiarismRecentSubmissions = deps.governorState + ? deps.governorState.listRecentOwnSubmissions({ repoFullName: input.loopInput.repoFullName }) + : listRecentOwnSubmissions({ repoFullName: input.loopInput.repoFullName }); + } catch (error) { + // Fail-open is deliberate (see the comment above) -- this only makes the fallback VISIBLE. A broken + // governor-state store silently disabling the self-plagiarism safety check had zero trace anywhere (#6011). + captureMinerError(error, { kind: "self_plagiarism_history_read_failed", repoFullName: input.loopInput.repoFullName }); + selfPlagiarismRecentSubmissions = []; + } + + const governed = evaluateGovernorChokepointGatePersisted( + { + actionClass: "open_pr", + repoFullName: input.loopInput.repoFullName, + nowMs: deps.nowMs, + wouldBeAction: submission.openPrInput, + ...input.governor, + selfPlagiarismCandidate, + selfPlagiarismRecentSubmissions, + }, + { + // AttemptDeps deliberately keeps governorLedgerAppend loosely typed (`(event: unknown) => unknown`) at the + // public boundary, same as runSlopAssessment above -- cast to the engine-internal signature here. + ...(deps.governorLedgerAppend + ? { append: deps.governorLedgerAppend as (event: AppendGovernorEventInput) => GovernorLedgerEntry } + : {}), + ...(deps.governorState ? { governorState: deps.governorState } : {}), + }, + ); + if (!governed.decision.allowed) { + return { outcome: "governed", decision: governed.decision, loopResult }; + } + + const spec = buildOpenPrSpec(submission.openPrInput); + const execResult = await deps.executeLocalWrite(spec); + return { outcome: "submitted", spec, execResult, loopResult }; +} diff --git a/packages/loopover-miner/lib/calibration-types.d.ts b/packages/loopover-miner/lib/calibration-types.d.ts index 097cc40352..2aee19a1d5 100644 --- a/packages/loopover-miner/lib/calibration-types.d.ts +++ b/packages/loopover-miner/lib/calibration-types.d.ts @@ -1,46 +1,39 @@ /** A single gate-prediction row the miner will replay against observed outcomes. */ export type PredictedVerdictRecord = { - targetId: string; - project: string; - predictedDecision: string; - recordedAt: string; - source?: string; + targetId: string; + project: string; + predictedDecision: string; + recordedAt: string; + source?: string; }; - /** The realized human outcome for a previously predicted target. */ export type ObservedOutcomeRecord = { - targetId: string; - project: string; - outcomeDecision: string; - recordedAt: string; + targetId: string; + project: string; + outcomeDecision: string; + recordedAt: string; }; - /** Per-project confusion-matrix row — field names mirror `GateEvalRow` in `src/review/parity.ts`. */ export type CalibrationRow = { - project: string; - wouldMerge: number; - mergeConfirmed: number; - mergeFalse: number; - wouldClose: number; - closeConfirmed: number; - closeFalse: number; - hold: number; - decided: number; - mergePrecision: number | null; - closePrecision: number | null; + project: string; + wouldMerge: number; + mergeConfirmed: number; + mergeFalse: number; + wouldClose: number; + closeConfirmed: number; + closeFalse: number; + hold: number; + decided: number; + mergePrecision: number | null; + closePrecision: number | null; }; - /** Aggregate calibration report over one or more projects. */ export type CalibrationReport = { - rows: CalibrationRow[]; - /** True once at least one project has enough decided samples to read meaningfully. */ - hasSignal: boolean; + rows: CalibrationRow[]; + /** True once at least one project has enough decided samples to read meaningfully. */ + hasSignal: boolean; }; - -export function isPredictedVerdictRecord(value: unknown): value is PredictedVerdictRecord; - -export function isObservedOutcomeRecord(value: unknown): value is ObservedOutcomeRecord; - -export function isCalibrationRow(value: unknown): value is CalibrationRow; - -export function isCalibrationReport(value: unknown): value is CalibrationReport; +export declare function isPredictedVerdictRecord(value: unknown): value is PredictedVerdictRecord; +export declare function isObservedOutcomeRecord(value: unknown): value is ObservedOutcomeRecord; +export declare function isCalibrationRow(value: unknown): value is CalibrationRow; +export declare function isCalibrationReport(value: unknown): value is CalibrationReport; diff --git a/packages/loopover-miner/lib/calibration-types.js b/packages/loopover-miner/lib/calibration-types.js index a857b1f7f7..a80b60b496 100644 --- a/packages/loopover-miner/lib/calibration-types.js +++ b/packages/loopover-miner/lib/calibration-types.js @@ -1,74 +1,59 @@ // Shared calibration shapes for the miner self-improvement phase (#2332). Types-only scaffolding — // report/ledger/metrics issues build on this module. Field names mirror `GateEvalRow` / // `GateEvalReport` in `src/review/parity.ts` for easy mental mapping without importing cloud code. - function isNonEmptyString(value) { - return typeof value === "string" && value.trim().length > 0; + return typeof value === "string" && value.trim().length > 0; } - function isOptionalString(value) { - return value === undefined || isNonEmptyString(value); + return value === undefined || isNonEmptyString(value); } - function isNonNegativeInteger(value) { - return typeof value === "number" && Number.isInteger(value) && value >= 0; + return typeof value === "number" && Number.isInteger(value) && value >= 0; } - function isNullableRatio(value) { - return value === null || (typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1); + return value === null || (typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1); } - -/** @param {unknown} value */ export function isPredictedVerdictRecord(value) { - if (value === null || typeof value !== "object" || Array.isArray(value)) return false; - const record = /** @type {Record} */ (value); - return ( - isNonEmptyString(record.targetId) - && isNonEmptyString(record.project) - && isNonEmptyString(record.predictedDecision) - && isNonEmptyString(record.recordedAt) - && isOptionalString(record.source) - ); + if (value === null || typeof value !== "object" || Array.isArray(value)) + return false; + const record = value; + return (isNonEmptyString(record.targetId) + && isNonEmptyString(record.project) + && isNonEmptyString(record.predictedDecision) + && isNonEmptyString(record.recordedAt) + && isOptionalString(record.source)); } - -/** @param {unknown} value */ export function isObservedOutcomeRecord(value) { - if (value === null || typeof value !== "object" || Array.isArray(value)) return false; - const record = /** @type {Record} */ (value); - return ( - isNonEmptyString(record.targetId) - && isNonEmptyString(record.project) - && isNonEmptyString(record.outcomeDecision) - && isNonEmptyString(record.recordedAt) - ); + if (value === null || typeof value !== "object" || Array.isArray(value)) + return false; + const record = value; + return (isNonEmptyString(record.targetId) + && isNonEmptyString(record.project) + && isNonEmptyString(record.outcomeDecision) + && isNonEmptyString(record.recordedAt)); } - -/** @param {unknown} value */ export function isCalibrationRow(value) { - if (value === null || typeof value !== "object" || Array.isArray(value)) return false; - const row = /** @type {Record} */ (value); - return ( - isNonEmptyString(row.project) - && isNonNegativeInteger(row.wouldMerge) - && isNonNegativeInteger(row.mergeConfirmed) - && isNonNegativeInteger(row.mergeFalse) - && isNonNegativeInteger(row.wouldClose) - && isNonNegativeInteger(row.closeConfirmed) - && isNonNegativeInteger(row.closeFalse) - && isNonNegativeInteger(row.hold) - && isNonNegativeInteger(row.decided) - && isNullableRatio(row.mergePrecision) - && isNullableRatio(row.closePrecision) - ); + if (value === null || typeof value !== "object" || Array.isArray(value)) + return false; + const row = value; + return (isNonEmptyString(row.project) + && isNonNegativeInteger(row.wouldMerge) + && isNonNegativeInteger(row.mergeConfirmed) + && isNonNegativeInteger(row.mergeFalse) + && isNonNegativeInteger(row.wouldClose) + && isNonNegativeInteger(row.closeConfirmed) + && isNonNegativeInteger(row.closeFalse) + && isNonNegativeInteger(row.hold) + && isNonNegativeInteger(row.decided) + && isNullableRatio(row.mergePrecision) + && isNullableRatio(row.closePrecision)); } - -/** @param {unknown} value */ export function isCalibrationReport(value) { - if (value === null || typeof value !== "object" || Array.isArray(value)) return false; - const report = /** @type {Record} */ (value); - return ( - typeof report.hasSignal === "boolean" - && Array.isArray(report.rows) - && report.rows.every((row) => isCalibrationRow(row)) - ); -} + if (value === null || typeof value !== "object" || Array.isArray(value)) + return false; + const report = value; + return (typeof report.hasSignal === "boolean" + && Array.isArray(report.rows) + && report.rows.every((row) => isCalibrationRow(row))); +} +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2FsaWJyYXRpb24tdHlwZXMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJjYWxpYnJhdGlvbi10eXBlcy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxtR0FBbUc7QUFDbkcsd0ZBQXdGO0FBQ3hGLG1HQUFtRztBQXlDbkcsU0FBUyxnQkFBZ0IsQ0FBQyxLQUFjO0lBQ3RDLE9BQU8sT0FBTyxLQUFLLEtBQUssUUFBUSxJQUFJLEtBQUssQ0FBQyxJQUFJLEVBQUUsQ0FBQyxNQUFNLEdBQUcsQ0FBQyxDQUFDO0FBQzlELENBQUM7QUFFRCxTQUFTLGdCQUFnQixDQUFDLEtBQWM7SUFDdEMsT0FBTyxLQUFLLEtBQUssU0FBUyxJQUFJLGdCQUFnQixDQUFDLEtBQUssQ0FBQyxDQUFDO0FBQ3hELENBQUM7QUFFRCxTQUFTLG9CQUFvQixDQUFDLEtBQWM7SUFDMUMsT0FBTyxPQUFPLEtBQUssS0FBSyxRQUFRLElBQUksTUFBTSxDQUFDLFNBQVMsQ0FBQyxLQUFLLENBQUMsSUFBSSxLQUFLLElBQUksQ0FBQyxDQUFDO0FBQzVFLENBQUM7QUFFRCxTQUFTLGVBQWUsQ0FBQyxLQUFjO0lBQ3JDLE9BQU8sS0FBSyxLQUFLLElBQUksSUFBSSxDQUFDLE9BQU8sS0FBSyxLQUFLLFFBQVEsSUFBSSxNQUFNLENBQUMsUUFBUSxDQUFDLEtBQUssQ0FBQyxJQUFJLEtBQUssSUFBSSxDQUFDLElBQUksS0FBSyxJQUFJLENBQUMsQ0FBQyxDQUFDO0FBQzdHLENBQUM7QUFFRCxNQUFNLFVBQVUsd0JBQXdCLENBQUMsS0FBYztJQUNyRCxJQUFJLEtBQUssS0FBSyxJQUFJLElBQUksT0FBTyxLQUFLLEtBQUssUUFBUSxJQUFJLEtBQUssQ0FBQyxPQUFPLENBQUMsS0FBSyxDQUFDO1FBQUUsT0FBTyxLQUFLLENBQUM7SUFDdEYsTUFBTSxNQUFNLEdBQUcsS0FBZ0MsQ0FBQztJQUNoRCxPQUFPLENBQ0wsZ0JBQWdCLENBQUMsTUFBTSxDQUFDLFFBQVEsQ0FBQztXQUM5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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/calibration-types.ts b/packages/loopover-miner/lib/calibration-types.ts new file mode 100644 index 0000000000..4687f0413e --- /dev/null +++ b/packages/loopover-miner/lib/calibration-types.ts @@ -0,0 +1,109 @@ +// Shared calibration shapes for the miner self-improvement phase (#2332). Types-only scaffolding — +// report/ledger/metrics issues build on this module. Field names mirror `GateEvalRow` / +// `GateEvalReport` in `src/review/parity.ts` for easy mental mapping without importing cloud code. + +/** A single gate-prediction row the miner will replay against observed outcomes. */ +export type PredictedVerdictRecord = { + targetId: string; + project: string; + predictedDecision: string; + recordedAt: string; + source?: string; +}; + +/** The realized human outcome for a previously predicted target. */ +export type ObservedOutcomeRecord = { + targetId: string; + project: string; + outcomeDecision: string; + recordedAt: string; +}; + +/** Per-project confusion-matrix row — field names mirror `GateEvalRow` in `src/review/parity.ts`. */ +export type CalibrationRow = { + project: string; + wouldMerge: number; + mergeConfirmed: number; + mergeFalse: number; + wouldClose: number; + closeConfirmed: number; + closeFalse: number; + hold: number; + decided: number; + mergePrecision: number | null; + closePrecision: number | null; +}; + +/** Aggregate calibration report over one or more projects. */ +export type CalibrationReport = { + rows: CalibrationRow[]; + /** True once at least one project has enough decided samples to read meaningfully. */ + hasSignal: boolean; +}; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isOptionalString(value: unknown): value is string | undefined { + return value === undefined || isNonEmptyString(value); +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 0; +} + +function isNullableRatio(value: unknown): value is number | null { + return value === null || (typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1); +} + +export function isPredictedVerdictRecord(value: unknown): value is PredictedVerdictRecord { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return ( + isNonEmptyString(record.targetId) + && isNonEmptyString(record.project) + && isNonEmptyString(record.predictedDecision) + && isNonEmptyString(record.recordedAt) + && isOptionalString(record.source) + ); +} + +export function isObservedOutcomeRecord(value: unknown): value is ObservedOutcomeRecord { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + return ( + isNonEmptyString(record.targetId) + && isNonEmptyString(record.project) + && isNonEmptyString(record.outcomeDecision) + && isNonEmptyString(record.recordedAt) + ); +} + +export function isCalibrationRow(value: unknown): value is CalibrationRow { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const row = value as Record; + return ( + isNonEmptyString(row.project) + && isNonNegativeInteger(row.wouldMerge) + && isNonNegativeInteger(row.mergeConfirmed) + && isNonNegativeInteger(row.mergeFalse) + && isNonNegativeInteger(row.wouldClose) + && isNonNegativeInteger(row.closeConfirmed) + && isNonNegativeInteger(row.closeFalse) + && isNonNegativeInteger(row.hold) + && isNonNegativeInteger(row.decided) + && isNullableRatio(row.mergePrecision) + && isNullableRatio(row.closePrecision) + ); +} + +export function isCalibrationReport(value: unknown): value is CalibrationReport { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const report = value as Record; + return ( + typeof report.hasSignal === "boolean" + && Array.isArray(report.rows) + && report.rows.every((row) => isCalibrationRow(row)) + ); +} diff --git a/packages/loopover-miner/lib/contribution-profile-cache.d.ts b/packages/loopover-miner/lib/contribution-profile-cache.d.ts index 6a953d9831..63f40c433c 100644 --- a/packages/loopover-miner/lib/contribution-profile-cache.d.ts +++ b/packages/loopover-miner/lib/contribution-profile-cache.d.ts @@ -1,38 +1,25 @@ -import type { - CachedContributionProfile, - ContributionProfile, -} from "./contribution-profile.js"; - +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 }; - /** Delete the cached profile for one repo (#7091); returns rows removed (0 or 1). */ - purgeByRepo(repoFullName: string): number; - close(): void; + 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; + }; + /** Delete the cached profile for one repo (#7091); returns rows removed (0 or 1). */ + purgeByRepo(repoFullName: string): number; + 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; +export declare function resolveContributionProfileCacheDbPath(env?: Record): string; +/** + * Open the 100%-local contribution-profile cache. The DB only lives on this machine (#6797). + */ +export declare function initContributionProfileCache(dbPath?: string): ContributionProfileCache; +export declare function getCachedContributionProfile(repoFullName: string, nowMs?: number): CachedContributionProfile | null; +export declare function putCachedContributionProfile(profile: ContributionProfile, nowMs?: number): { + repoFullName: string; + fetchedAt: string; +}; +export declare function closeDefaultContributionProfileCache(): void; diff --git a/packages/loopover-miner/lib/contribution-profile-cache.js b/packages/loopover-miner/lib/contribution-profile-cache.js index 6f4810f5b9..1e23b974cc 100644 --- a/packages/loopover-miner/lib/contribution-profile-cache.js +++ b/packages/loopover-miner/lib/contribution-profile-cache.js @@ -1,152 +1,106 @@ -// 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 { 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"; -import { - CONTRIBUTION_PROFILE_CACHE_PURGE_SPEC, - purgeStoreByRepo, -} from "./store-maintenance.js"; - +import { CONTRIBUTION_PROFILE_CACHE_PURGE_SPEC, purgeStoreByRepo, } from "./store-maintenance.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, - ); + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB", env); } - function normalizeDbPath(dbPath) { - return normalizeLocalStoreDbPath( - dbPath, - resolveContributionProfileCacheDbPath(), - "invalid_contribution_profile_cache_db_path", - ); + 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}`; + 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(` +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(` + // 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 }; - }, - /** - * Delete the cached profile for one repo (#7091) — the right-to-be-forgotten path `loopover-miner purge` - * invokes. Returns the number of rows removed (0 or 1, since repo_full_name is the primary key). Reuses - * store-maintenance.js's identifier-guarded purgeStoreByRepo, exactly like the other repo-scoped stores. - * - * @param {string} repoFullName - * @returns {number} rows deleted - */ - purgeByRepo(repoFullName) { - return purgeStoreByRepo(db, CONTRIBUTION_PROFILE_CACHE_PURGE_SPEC, normalizeRepoFullName(repoFullName)); - }, - close() { - db.close(); - }, - }; + 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. + */ + 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. + */ + 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 }; + }, + /** + * Delete the cached profile for one repo (#7091) — the right-to-be-forgotten path `loopover-miner purge` + * invokes. Returns the number of rows removed (0 or 1, since repo_full_name is the primary key). Reuses + * store-maintenance.js's identifier-guarded purgeStoreByRepo, exactly like the other repo-scoped stores. + */ + purgeByRepo(repoFullName) { + return purgeStoreByRepo(db, CONTRIBUTION_PROFILE_CACHE_PURGE_SPEC, normalizeRepoFullName(repoFullName)); + }, + close() { + db.close(); + }, + }; } - function getDefaultContributionProfileCache() { - defaultContributionProfileCache ??= initContributionProfileCache(); - return defaultContributionProfileCache; + defaultContributionProfileCache ??= initContributionProfileCache(); + return defaultContributionProfileCache; } - export function getCachedContributionProfile(repoFullName, nowMs) { - return getDefaultContributionProfileCache().get(repoFullName, nowMs); + return getDefaultContributionProfileCache().get(repoFullName, nowMs); } - export function putCachedContributionProfile(profile, nowMs) { - return getDefaultContributionProfileCache().put(profile, nowMs); + return getDefaultContributionProfileCache().put(profile, nowMs); } - export function closeDefaultContributionProfileCache() { - if (!defaultContributionProfileCache) return; - defaultContributionProfileCache.close(); - defaultContributionProfileCache = null; + if (!defaultContributionProfileCache) + return; + defaultContributionProfileCache.close(); + defaultContributionProfileCache = null; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29udHJpYnV0aW9uLXByb2ZpbGUtY2FjaGUuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJjb250cmlidXRpb24tcHJvZmlsZS1jYWNoZS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFNQSxPQUFPLEVBQ0wsaUNBQWlDLEVBQ2pDLGdDQUFnQyxHQUNqQyxNQUFNLDJCQUEyQixDQUFDO0FBQ25DLE9BQU8sRUFDTCx5QkFBeUIsRUFDekIsZ0JBQWdCLEVBQ2hCLHVCQUF1QixHQUN4QixNQUFNLGtCQUFrQixDQUFDO0FBQzFCLE9BQU8sRUFBRSxxQkFBcUIsRUFBRSxNQUFNLHFCQUFxQixDQUFDO0FBQzVELE9BQU8sRUFDTCxxQ0FBcUMsRUFDckMsZ0JBQWdCLEdBQ2pCLE1BQU0sd0JBQXdCLENBQUM7QUFnQmhDLE1BQU0saUJBQWlCLEdBQUcsb0NBQW9DLENBQUM7QUFDL0QsSUFBSSwrQkFBK0IsR0FBb0MsSUFBSSxDQUFDO0FBRTVFLE1BQU0sVUFBVSxxQ0FBcUMsQ0FBQyxNQUEwQyxPQUFPLENBQUMsR0FBRztJQUN6RyxPQUFPLHVCQUF1QixDQUM1QixpQkFBaUIsRUFDakIsOENBQThDLEVBQzlDLEdBQUcsQ0FDSixDQUFDO0FBQ0osQ0FBQztBQUVELFNBQVMsZUFBZSxDQUFDLE1BQWlDO0lBQ3hELE9BQU8seUJBQXlCLENBQzlCLE1BQU0sRUFDTixxQ0FBcUMsRUFBRSxFQUN2Qyw0Q0FBNEMsQ0FDN0MsQ0FBQztBQUNKLENBQUM7QUFFRCxTQUFTLHFCQUFxQixDQUFDLFlBQXFCO0lBQ2xELElBQUksT0FBTyxZQUFZLEtBQUssUUFBUTtRQUNsQyxNQUFNLElBQUksS0FBSyxDQUFDLHdCQUF3QixDQUFDLENBQUM7SUFDNUMsTUFBTSxDQUFDLEtBQUssRUFBRSxJQUFJLEVBQUUsS0FBSyxDQUFDLEdBQUcsWUFBWSxDQUFDLElBQUksRUFBRSxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUMsQ0FBQztJQUM1RCxJQUFJLENBQUMsS0FBSyxJQUFJLENB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/contribution-profile-cache.ts b/packages/loopover-miner/lib/contribution-profile-cache.ts new file mode 100644 index 0000000000..d8cb8fdd67 --- /dev/null +++ b/packages/loopover-miner/lib/contribution-profile-cache.ts @@ -0,0 +1,161 @@ +// 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 type { CachedContributionProfile, ContributionProfile } from "./contribution-profile.js"; +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"; +import { + CONTRIBUTION_PROFILE_CACHE_PURGE_SPEC, + purgeStoreByRepo, +} from "./store-maintenance.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 }; + /** Delete the cached profile for one repo (#7091); returns rows removed (0 or 1). */ + purgeByRepo(repoFullName: string): number; + close(): void; +}; + +const defaultDbFileName = "contribution-profile-cache.sqlite3"; +let defaultContributionProfileCache: ContributionProfileCache | null = null; + +export function resolveContributionProfileCacheDbPath(env: Record = process.env): string { + return resolveLocalStoreDbPath( + defaultDbFileName, + "LOOPOVER_MINER_CONTRIBUTION_PROFILE_CACHE_DB", + env, + ); +} + +function normalizeDbPath(dbPath: string | null | undefined): string { + return normalizeLocalStoreDbPath( + dbPath, + resolveContributionProfileCacheDbPath(), + "invalid_contribution_profile_cache_db_path", + ); +} + +function normalizeRepoFullName(repoFullName: unknown): string { + 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). + */ +export function initContributionProfileCache( + dbPath: string = resolveContributionProfileCacheDbPath(), +): ContributionProfileCache { + 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. + */ + get(repoFullName: string, nowMs: number = Date.now()): CachedContributionProfile | null { + const row = getStatement.get(normalizeRepoFullName(repoFullName)) as + | { profile_json: string; fetched_at: string } + | undefined; + 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. + */ + put(profile: ContributionProfile, nowMs: number = Date.now()): { repoFullName: string; fetchedAt: string } { + const repoFullName = normalizeRepoFullName(profile?.repoFullName); + const fetchedAt = new Date(nowMs).toISOString(); + putStatement.run(repoFullName, JSON.stringify(profile), fetchedAt); + return { repoFullName, fetchedAt }; + }, + /** + * Delete the cached profile for one repo (#7091) — the right-to-be-forgotten path `loopover-miner purge` + * invokes. Returns the number of rows removed (0 or 1, since repo_full_name is the primary key). Reuses + * store-maintenance.js's identifier-guarded purgeStoreByRepo, exactly like the other repo-scoped stores. + */ + purgeByRepo(repoFullName: string): number { + return purgeStoreByRepo(db, CONTRIBUTION_PROFILE_CACHE_PURGE_SPEC, normalizeRepoFullName(repoFullName)); + }, + close() { + db.close(); + }, + }; +} + +function getDefaultContributionProfileCache(): ContributionProfileCache { + defaultContributionProfileCache ??= initContributionProfileCache(); + return defaultContributionProfileCache; +} + +export function getCachedContributionProfile(repoFullName: string, nowMs?: number): CachedContributionProfile | null { + return getDefaultContributionProfileCache().get(repoFullName, nowMs); +} + +export function putCachedContributionProfile( + profile: ContributionProfile, + nowMs?: number, +): { repoFullName: string; fetchedAt: string } { + return getDefaultContributionProfileCache().put(profile, nowMs); +} + +export function closeDefaultContributionProfileCache() { + if (!defaultContributionProfileCache) return; + defaultContributionProfileCache.close(); + defaultContributionProfileCache = null; +} diff --git a/packages/loopover-miner/lib/discovery-index-client.d.ts b/packages/loopover-miner/lib/discovery-index-client.d.ts index 435baf44d2..c0bcbee7f7 100644 --- a/packages/loopover-miner/lib/discovery-index-client.d.ts +++ b/packages/loopover-miner/lib/discovery-index-client.d.ts @@ -1,43 +1,61 @@ +/** Opt-in client for the hosted discovery-index service (#4250, #7164/#7166/#7168). Complete no-op unless + * LOOPOVER_MINER_DISCOVERY_PLANE is set -- mirrors sentry.js's own-project opt-in posture: nothing here is ever + * auto-enabled or phones home by default. Supplements (never replaces) opportunity-fanout.js's per-instance + * GitHub fan-out with results from the shared, centrally-cached index, and submits soft-claim coordination at + * work-start/work-end -- the fleet-wide rate-limit mitigation packages/loopover-miner/docs/discovery-plane- + * operator-guide.md documents. Every call here is fail-open (never throws, degrades to "no supplement"/"no + * telemetry sent" on any error), matching orb-export.js's sendAmsExportBatch: an optional-plane hiccup must + * never break the miner's real discover/attempt work. Uses @loopover/engine's discovery-index-contract.ts and + * discovery-soft-claim.ts exports directly rather than re-implementing the shapes. */ import type { DiscoveryIndexQuery, DiscoveryIndexResponse } from "@loopover/engine"; - -export const DISCOVERY_PLANE_FLAG: string; -export const DISCOVERY_INDEX_URL_FLAG: string; -export const DISCOVERY_TELEMETRY_FLAG: string; - +export declare const DISCOVERY_PLANE_FLAG = "LOOPOVER_MINER_DISCOVERY_PLANE"; +export declare const DISCOVERY_INDEX_URL_FLAG = "LOOPOVER_MINER_DISCOVERY_INDEX_URL"; +export declare const DISCOVERY_TELEMETRY_FLAG = "LOOPOVER_MINER_DISCOVERY_TELEMETRY"; export type DiscoveryIndexClientOptions = { - env?: Record; - /** Always called as `fetchImpl(url, init)` with a plain string URL -- narrower than `typeof fetch` on - * purpose, since that's the only shape this module ever actually calls it with. */ - fetchImpl?: (url: string, init: RequestInit) => Promise; - requestTimeoutMs?: number; + env?: Record; + /** Always called as `fetchImpl(url, init)` with a plain string URL -- narrower than `typeof fetch` on + * purpose, since that's the only shape this module ever actually calls it with. */ + fetchImpl?: (url: string, init: RequestInit) => Promise; + requestTimeoutMs?: number; }; - /** The shape claim-ledger.js's rowToClaim (and claimIssueWithinCap(...).claim) already produces -- passed * straight into @loopover/engine's buildSoftClaimRequest with no translation. */ export type SoftClaimLedgerRecord = { - repoFullName: string; - issueNumber: number; - claimedAt: string; - status: "active" | "released" | "expired"; - note?: string | null; + repoFullName: string; + issueNumber: number; + claimedAt: string; + status: "active" | "released" | "expired"; + note?: string | null; }; - -export function isDiscoveryPlaneEnabled(env?: Record): boolean; - -export function isDiscoveryTelemetryEnabled(env?: Record): boolean; - -export function queryDiscoveryIndex( - query: Partial, - options?: DiscoveryIndexClientOptions, -): Promise; - -export function submitSoftClaim( - claim: SoftClaimLedgerRecord, - options?: DiscoveryIndexClientOptions, -): Promise<{ sent: boolean }>; - -export function recordDiscoveryTelemetry( - event: string, - outcome: string, - options?: { env?: Record }, -): void; +/** Master opt-in (default off). When false, no discovery-index traffic and no telemetry may be emitted. */ +export declare function isDiscoveryPlaneEnabled(env?: Record): boolean; +/** Second, independent opt-in (default off) for anonymized operational telemetry -- can stay off while the + * plane itself is queried/claimed against. */ +export declare function isDiscoveryTelemetryEnabled(env?: Record): boolean; +/** + * Query the hosted discovery-index for supplementary candidates. Returns `EMPTY_QUERY_RESPONSE` (never throws) + * when the plane is disabled, the URL is unconfigured, or the request fails for any reason -- callers can + * always safely concatenate `.candidates` onto their own locally-discovered set with zero special-casing. + */ +export declare function queryDiscoveryIndex(query: Partial, options?: DiscoveryIndexClientOptions): Promise; +/** + * Best-effort soft-claim submission at work-start/work-end. `claim` is the object returned by claim-ledger.js's + * claimIssueWithinCap(...).claim (or its rowToClaim shape generally) -- passed straight into buildSoftClaimRequest + * with no translation, per that builder's own contract. Fire-and-forget: never throws, and the caller's real + * work (already underway once this is called) is never blocked or aborted by a plane hiccup. Returns + * `{sent: boolean}` for callers that want to log/test the outcome without depending on it for control flow. + */ +export declare function submitSoftClaim(claim: SoftClaimLedgerRecord, options?: DiscoveryIndexClientOptions): Promise<{ + sent: boolean; +}>; +/** + * Emit anonymized, low-cardinality operational telemetry about the plane itself (never per-issue business + * data) -- gated separately behind LOOPOVER_MINER_DISCOVERY_TELEMETRY, per the operator guide's invariant list + * ("low-cardinality reason buckets", mirroring orb-export.js's `reasonBucket` convention). No hosted telemetry + * collector endpoint exists for the discovery plane yet (out of scope for #7164/#7166), so this emits a + * structured local log line via this package's own logger -- the gate, shape, and off-by-default behavior are + * real and tested; swapping in a real remote sink later is a logger-call change, not a design change. + */ +export declare function recordDiscoveryTelemetry(event: string, outcome: string, options?: { + env?: Record; +}): void; diff --git a/packages/loopover-miner/lib/discovery-index-client.js b/packages/loopover-miner/lib/discovery-index-client.js index fad1b3d8cb..6ce63f78ed 100644 --- a/packages/loopover-miner/lib/discovery-index-client.js +++ b/packages/loopover-miner/lib/discovery-index-client.js @@ -1,88 +1,73 @@ -/** Opt-in client for the hosted discovery-index service (#4250, #7164/#7166/#7168). Complete no-op unless - * LOOPOVER_MINER_DISCOVERY_PLANE is set -- mirrors sentry.js's own-project opt-in posture: nothing here is ever - * auto-enabled or phones home by default. Supplements (never replaces) opportunity-fanout.js's per-instance - * GitHub fan-out with results from the shared, centrally-cached index, and submits soft-claim coordination at - * work-start/work-end -- the fleet-wide rate-limit mitigation packages/loopover-miner/docs/discovery-plane- - * operator-guide.md documents. Every call here is fail-open (never throws, degrades to "no supplement"/"no - * telemetry sent" on any error), matching orb-export.js's sendAmsExportBatch: an optional-plane hiccup must - * never break the miner's real discover/attempt work. Uses @loopover/engine's discovery-index-contract.ts and - * discovery-soft-claim.ts exports directly rather than re-implementing the shapes. */ import { normalizeDiscoveryIndexRequest, normalizeDiscoveryIndexResponse, buildSoftClaimRequest } from "@loopover/engine"; import { fetchWithRetry } from "./http-retry.js"; import { describeCliError } from "./cli-error.js"; import { getLogger } from "./logger.js"; - export const DISCOVERY_PLANE_FLAG = "LOOPOVER_MINER_DISCOVERY_PLANE"; export const DISCOVERY_INDEX_URL_FLAG = "LOOPOVER_MINER_DISCOVERY_INDEX_URL"; export const DISCOVERY_TELEMETRY_FLAG = "LOOPOVER_MINER_DISCOVERY_TELEMETRY"; - const TRUTHY_ENV_VALUE = /^(1|true|yes|on)$/i; const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; - function isTruthyEnvValue(value) { - return TRUTHY_ENV_VALUE.test(value.trim()); + return TRUTHY_ENV_VALUE.test(value.trim()); } - // Reads below use literal `env.LOOPOVER_MINER_*` property access (not the exported *_FLAG constants above, // which exist for callers/tests to reference the exact name without a typo) because // scripts/generate-env-reference.mjs statically greps for exactly this `env.NAME ?? "default"` shape to keep // packages/loopover-miner/docs/env-reference.md honest -- a dynamic `env[SOME_CONST]` lookup is invisible to it. - /** Master opt-in (default off). When false, no discovery-index traffic and no telemetry may be emitted. */ export function isDiscoveryPlaneEnabled(env = process.env) { - return isTruthyEnvValue(env.LOOPOVER_MINER_DISCOVERY_PLANE ?? ""); + return isTruthyEnvValue(env.LOOPOVER_MINER_DISCOVERY_PLANE ?? ""); } - /** Second, independent opt-in (default off) for anonymized operational telemetry -- can stay off while the * plane itself is queried/claimed against. */ export function isDiscoveryTelemetryEnabled(env = process.env) { - return isTruthyEnvValue(env.LOOPOVER_MINER_DISCOVERY_TELEMETRY ?? ""); + return isTruthyEnvValue(env.LOOPOVER_MINER_DISCOVERY_TELEMETRY ?? ""); } - function resolveDiscoveryIndexUrl(env) { - const raw = (env.LOOPOVER_MINER_DISCOVERY_INDEX_URL ?? "").trim(); - return raw ? raw.replace(/\/+$/, "") : null; + const raw = (env.LOOPOVER_MINER_DISCOVERY_INDEX_URL ?? "").trim(); + return raw ? raw.replace(/\/+$/, "") : null; } - function authHeaders(env) { - const secret = typeof env.LOOPOVER_MINER_DISCOVERY_SHARED_SECRET === "string" ? env.LOOPOVER_MINER_DISCOVERY_SHARED_SECRET.trim() : ""; - return secret ? { authorization: `Bearer ${secret}` } : {}; + const secret = typeof env.LOOPOVER_MINER_DISCOVERY_SHARED_SECRET === "string" ? env.LOOPOVER_MINER_DISCOVERY_SHARED_SECRET.trim() : ""; + return secret ? { authorization: `Bearer ${secret}` } : {}; } - -const EMPTY_QUERY_RESPONSE = Object.freeze({ contractVersion: 1, candidates: [], nextCursor: null }); - +const EMPTY_QUERY_RESPONSE = Object.freeze({ + contractVersion: 1, + candidates: [], + nextCursor: null, +}); /** * Query the hosted discovery-index for supplementary candidates. Returns `EMPTY_QUERY_RESPONSE` (never throws) * when the plane is disabled, the URL is unconfigured, or the request fails for any reason -- callers can * always safely concatenate `.candidates` onto their own locally-discovered set with zero special-casing. */ export async function queryDiscoveryIndex(query, options = {}) { - const env = options.env ?? process.env; - if (!isDiscoveryPlaneEnabled(env)) return EMPTY_QUERY_RESPONSE; - const baseUrl = resolveDiscoveryIndexUrl(env); - if (!baseUrl) return EMPTY_QUERY_RESPONSE; - - const fetchImpl = options.fetchImpl ?? fetch; - const { request } = normalizeDiscoveryIndexRequest(query); - try { - const response = await fetchWithRetry( - fetchImpl, - `${baseUrl}/v1/discovery-index/query`, - { - method: "POST", - headers: { "content-type": "application/json", ...authHeaders(env) }, - body: JSON.stringify(request.query), - }, - { timeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS }, - ); - if (!response.ok) return EMPTY_QUERY_RESPONSE; - const payload = await response.json().catch(() => null); - return normalizeDiscoveryIndexResponse(payload).response; - } catch { - return EMPTY_QUERY_RESPONSE; - } + const env = options.env ?? process.env; + if (!isDiscoveryPlaneEnabled(env)) + return EMPTY_QUERY_RESPONSE; + const baseUrl = resolveDiscoveryIndexUrl(env); + if (!baseUrl) + return EMPTY_QUERY_RESPONSE; + // fetchWithRetry's signature is deliberately untyped-permissive (`(url: unknown, init?: unknown) => + // Promise`) so it can wrap any fetch-shaped function; the narrower, more useful public + // DiscoveryIndexClientOptions#fetchImpl type is cast at this one boundary rather than widened repo-wide. + const fetchImpl = (options.fetchImpl ?? fetch); + const { request } = normalizeDiscoveryIndexRequest(query); + try { + const response = await fetchWithRetry(fetchImpl, `${baseUrl}/v1/discovery-index/query`, { + method: "POST", + headers: { "content-type": "application/json", ...authHeaders(env) }, + body: JSON.stringify(request.query), + }, { timeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS }); + if (!response.ok) + return EMPTY_QUERY_RESPONSE; + const payload = await response.json().catch(() => null); + return normalizeDiscoveryIndexResponse(payload).response; + } + catch { + return EMPTY_QUERY_RESPONSE; + } } - /** * Best-effort soft-claim submission at work-start/work-end. `claim` is the object returned by claim-ledger.js's * claimIssueWithinCap(...).claim (or its rowToClaim shape generally) -- passed straight into buildSoftClaimRequest @@ -91,33 +76,32 @@ export async function queryDiscoveryIndex(query, options = {}) { * `{sent: boolean}` for callers that want to log/test the outcome without depending on it for control flow. */ export async function submitSoftClaim(claim, options = {}) { - const env = options.env ?? process.env; - if (!isDiscoveryPlaneEnabled(env)) return { sent: false }; - const baseUrl = resolveDiscoveryIndexUrl(env); - if (!baseUrl) return { sent: false }; - - const request = buildSoftClaimRequest(claim); - if (request === null) return { sent: false }; - - const fetchImpl = options.fetchImpl ?? fetch; - try { - const response = await fetchWithRetry( - fetchImpl, - `${baseUrl}/v1/discovery-index/soft-claim`, - { - method: "POST", - headers: { "content-type": "application/json", ...authHeaders(env) }, - body: JSON.stringify(request), - }, - { timeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS }, - ); - return { sent: response.ok }; - } catch (error) { - getLogger().debug("discovery_plane_soft_claim_failed", { error: describeCliError(error) }); - return { sent: false }; - } + const env = options.env ?? process.env; + if (!isDiscoveryPlaneEnabled(env)) + return { sent: false }; + const baseUrl = resolveDiscoveryIndexUrl(env); + if (!baseUrl) + return { sent: false }; + const request = buildSoftClaimRequest(claim); + if (request === null) + return { sent: false }; + // fetchWithRetry's signature is deliberately untyped-permissive (`(url: unknown, init?: unknown) => + // Promise`) so it can wrap any fetch-shaped function; the narrower, more useful public + // DiscoveryIndexClientOptions#fetchImpl type is cast at this one boundary rather than widened repo-wide. + const fetchImpl = (options.fetchImpl ?? fetch); + try { + const response = await fetchWithRetry(fetchImpl, `${baseUrl}/v1/discovery-index/soft-claim`, { + method: "POST", + headers: { "content-type": "application/json", ...authHeaders(env) }, + body: JSON.stringify(request), + }, { timeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS }); + return { sent: response.ok }; + } + catch (error) { + getLogger().debug("discovery_plane_soft_claim_failed", { error: describeCliError(error) }); + return { sent: false }; + } } - /** * Emit anonymized, low-cardinality operational telemetry about the plane itself (never per-issue business * data) -- gated separately behind LOOPOVER_MINER_DISCOVERY_TELEMETRY, per the operator guide's invariant list @@ -127,7 +111,9 @@ export async function submitSoftClaim(claim, options = {}) { * real and tested; swapping in a real remote sink later is a logger-call change, not a design change. */ export function recordDiscoveryTelemetry(event, outcome, options = {}) { - const env = options.env ?? process.env; - if (!isDiscoveryPlaneEnabled(env) || !isDiscoveryTelemetryEnabled(env)) return; - getLogger().info("discovery_plane_telemetry", { event, outcome }); + const env = options.env ?? process.env; + if (!isDiscoveryPlaneEnabled(env) || !isDiscoveryTelemetryEnabled(env)) + return; + getLogger().info("discovery_plane_telemetry", { event, outcome }); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGlzY292ZXJ5LWluZGV4LWNsaWVudC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImRpc2NvdmVyeS1pbmRleC1jbGllbnQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBVUEsT0FBTyxFQUFFLDhCQUE4QixFQUFFLCtCQUErQixFQUFFLHFCQUFxQixFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFDMUgsT0FBTyxFQUFFLGNBQWMsRUFBRSxNQUFNLGlCQUFpQixDQUFDO0FBQ2pELE9BQU8sRUFBRSxnQkFBZ0IsRUFBRSxNQUFNLGdCQUFnQixDQUFDO0FBQ2xELE9BQU8sRUFBRSxTQUFTLEVBQUUsTUFBTSxhQUFhLENBQUM7QUFFeEMsTUFBTSxDQUFDLE1BQU0sb0JBQW9CLEdBQUcsZ0NBQWdDLENBQUM7QUFDckUsTUFBTSxDQUFDLE1BQU0sd0JBQXdCLEdBQUcsb0NBQW9DLENBQUM7QUFDN0UsTUFBTSxDQUFDLE1BQU0sd0JBQXdCLEdBQUcsb0NBQW9DLENBQUM7QUFvQjdFLE1BQU0sZ0JBQWdCLEdBQUcsb0JBQW9CLENBQUM7QUFDOUMsTUFBTSwwQkFBMEIsR0FBRyxNQUFNLENBQUM7QUFFMUMsU0FBUyxnQkFBZ0IsQ0FBQyxLQUFhO0lBQ3JDLE9BQU8sZ0JBQWdCLENBQUMsSUFBSSxDQUFDLEtBQUssQ0FBQyxJQUFJLEVBQUUsQ0FBQyxDQUFDO0FBQzdDLENBQUM7QUFFRCwyR0FBMkc7QUFDM0csb0ZBQW9GO0FBQ3BGLDZHQUE2RztBQUM3RyxpSEFBaUg7QUFFakgsMkdBQTJHO0FBQzNHLE1BQU0sVUFBVSx1QkFBdUIsQ0FBQyxNQUEwQyxPQUFPLENBQUMsR0FBRztJQUMzRixPQUFPLGdCQUFnQixDQUFDLEdBQUcsQ0FBQyw4QkFBOEIsSUFBSSxFQUFFLENBQUMsQ0FBQztBQUNwRSxDQUFDO0FBRUQ7K0NBQytDO0FBQy9DLE1BQU0sVUFBVSwyQkFBMkIsQ0FBQyxNQUEwQyxPQUFPLENBQUMsR0FBRztJQUMvRixPQUFPLGdCQUFnQixDQUFDLEdB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/discovery-index-client.ts b/packages/loopover-miner/lib/discovery-index-client.ts new file mode 100644 index 0000000000..ab40472906 --- /dev/null +++ b/packages/loopover-miner/lib/discovery-index-client.ts @@ -0,0 +1,172 @@ +/** Opt-in client for the hosted discovery-index service (#4250, #7164/#7166/#7168). Complete no-op unless + * LOOPOVER_MINER_DISCOVERY_PLANE is set -- mirrors sentry.js's own-project opt-in posture: nothing here is ever + * auto-enabled or phones home by default. Supplements (never replaces) opportunity-fanout.js's per-instance + * GitHub fan-out with results from the shared, centrally-cached index, and submits soft-claim coordination at + * work-start/work-end -- the fleet-wide rate-limit mitigation packages/loopover-miner/docs/discovery-plane- + * operator-guide.md documents. Every call here is fail-open (never throws, degrades to "no supplement"/"no + * telemetry sent" on any error), matching orb-export.js's sendAmsExportBatch: an optional-plane hiccup must + * never break the miner's real discover/attempt work. Uses @loopover/engine's discovery-index-contract.ts and + * discovery-soft-claim.ts exports directly rather than re-implementing the shapes. */ +import type { DiscoveryIndexQuery, DiscoveryIndexResponse } from "@loopover/engine"; +import { normalizeDiscoveryIndexRequest, normalizeDiscoveryIndexResponse, buildSoftClaimRequest } from "@loopover/engine"; +import { fetchWithRetry } from "./http-retry.js"; +import { describeCliError } from "./cli-error.js"; +import { getLogger } from "./logger.js"; + +export const DISCOVERY_PLANE_FLAG = "LOOPOVER_MINER_DISCOVERY_PLANE"; +export const DISCOVERY_INDEX_URL_FLAG = "LOOPOVER_MINER_DISCOVERY_INDEX_URL"; +export const DISCOVERY_TELEMETRY_FLAG = "LOOPOVER_MINER_DISCOVERY_TELEMETRY"; + +export type DiscoveryIndexClientOptions = { + env?: Record; + /** Always called as `fetchImpl(url, init)` with a plain string URL -- narrower than `typeof fetch` on + * purpose, since that's the only shape this module ever actually calls it with. */ + fetchImpl?: (url: string, init: RequestInit) => Promise; + requestTimeoutMs?: number; +}; + +/** The shape claim-ledger.js's rowToClaim (and claimIssueWithinCap(...).claim) already produces -- passed + * straight into @loopover/engine's buildSoftClaimRequest with no translation. */ +export type SoftClaimLedgerRecord = { + repoFullName: string; + issueNumber: number; + claimedAt: string; + status: "active" | "released" | "expired"; + note?: string | null; +}; + +const TRUTHY_ENV_VALUE = /^(1|true|yes|on)$/i; +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; + +function isTruthyEnvValue(value: string): boolean { + return TRUTHY_ENV_VALUE.test(value.trim()); +} + +// Reads below use literal `env.LOOPOVER_MINER_*` property access (not the exported *_FLAG constants above, +// which exist for callers/tests to reference the exact name without a typo) because +// scripts/generate-env-reference.mjs statically greps for exactly this `env.NAME ?? "default"` shape to keep +// packages/loopover-miner/docs/env-reference.md honest -- a dynamic `env[SOME_CONST]` lookup is invisible to it. + +/** Master opt-in (default off). When false, no discovery-index traffic and no telemetry may be emitted. */ +export function isDiscoveryPlaneEnabled(env: Record = process.env): boolean { + return isTruthyEnvValue(env.LOOPOVER_MINER_DISCOVERY_PLANE ?? ""); +} + +/** Second, independent opt-in (default off) for anonymized operational telemetry -- can stay off while the + * plane itself is queried/claimed against. */ +export function isDiscoveryTelemetryEnabled(env: Record = process.env): boolean { + return isTruthyEnvValue(env.LOOPOVER_MINER_DISCOVERY_TELEMETRY ?? ""); +} + +function resolveDiscoveryIndexUrl(env: Record): string | null { + const raw = (env.LOOPOVER_MINER_DISCOVERY_INDEX_URL ?? "").trim(); + return raw ? raw.replace(/\/+$/, "") : null; +} + +function authHeaders(env: Record): { authorization: string } | Record { + const secret = typeof env.LOOPOVER_MINER_DISCOVERY_SHARED_SECRET === "string" ? env.LOOPOVER_MINER_DISCOVERY_SHARED_SECRET.trim() : ""; + return secret ? { authorization: `Bearer ${secret}` } : {}; +} + +const EMPTY_QUERY_RESPONSE: DiscoveryIndexResponse = Object.freeze({ + contractVersion: 1, + candidates: [], + nextCursor: null, +}); + +/** + * Query the hosted discovery-index for supplementary candidates. Returns `EMPTY_QUERY_RESPONSE` (never throws) + * when the plane is disabled, the URL is unconfigured, or the request fails for any reason -- callers can + * always safely concatenate `.candidates` onto their own locally-discovered set with zero special-casing. + */ +export async function queryDiscoveryIndex( + query: Partial, + options: DiscoveryIndexClientOptions = {}, +): Promise { + const env = options.env ?? process.env; + if (!isDiscoveryPlaneEnabled(env)) return EMPTY_QUERY_RESPONSE; + const baseUrl = resolveDiscoveryIndexUrl(env); + if (!baseUrl) return EMPTY_QUERY_RESPONSE; + + // fetchWithRetry's signature is deliberately untyped-permissive (`(url: unknown, init?: unknown) => + // Promise`) so it can wrap any fetch-shaped function; the narrower, more useful public + // DiscoveryIndexClientOptions#fetchImpl type is cast at this one boundary rather than widened repo-wide. + const fetchImpl = (options.fetchImpl ?? fetch) as (url: unknown, init?: unknown) => Promise; + const { request } = normalizeDiscoveryIndexRequest(query); + try { + const response = await fetchWithRetry( + fetchImpl, + `${baseUrl}/v1/discovery-index/query`, + { + method: "POST", + headers: { "content-type": "application/json", ...authHeaders(env) }, + body: JSON.stringify(request.query), + }, + { timeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS }, + ); + if (!response.ok) return EMPTY_QUERY_RESPONSE; + const payload = await response.json().catch(() => null); + return normalizeDiscoveryIndexResponse(payload).response; + } catch { + return EMPTY_QUERY_RESPONSE; + } +} + +/** + * Best-effort soft-claim submission at work-start/work-end. `claim` is the object returned by claim-ledger.js's + * claimIssueWithinCap(...).claim (or its rowToClaim shape generally) -- passed straight into buildSoftClaimRequest + * with no translation, per that builder's own contract. Fire-and-forget: never throws, and the caller's real + * work (already underway once this is called) is never blocked or aborted by a plane hiccup. Returns + * `{sent: boolean}` for callers that want to log/test the outcome without depending on it for control flow. + */ +export async function submitSoftClaim( + claim: SoftClaimLedgerRecord, + options: DiscoveryIndexClientOptions = {}, +): Promise<{ sent: boolean }> { + const env = options.env ?? process.env; + if (!isDiscoveryPlaneEnabled(env)) return { sent: false }; + const baseUrl = resolveDiscoveryIndexUrl(env); + if (!baseUrl) return { sent: false }; + + const request = buildSoftClaimRequest(claim); + if (request === null) return { sent: false }; + + // fetchWithRetry's signature is deliberately untyped-permissive (`(url: unknown, init?: unknown) => + // Promise`) so it can wrap any fetch-shaped function; the narrower, more useful public + // DiscoveryIndexClientOptions#fetchImpl type is cast at this one boundary rather than widened repo-wide. + const fetchImpl = (options.fetchImpl ?? fetch) as (url: unknown, init?: unknown) => Promise; + try { + const response = await fetchWithRetry( + fetchImpl, + `${baseUrl}/v1/discovery-index/soft-claim`, + { + method: "POST", + headers: { "content-type": "application/json", ...authHeaders(env) }, + body: JSON.stringify(request), + }, + { timeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS }, + ); + return { sent: response.ok }; + } catch (error) { + getLogger().debug("discovery_plane_soft_claim_failed", { error: describeCliError(error) }); + return { sent: false }; + } +} + +/** + * Emit anonymized, low-cardinality operational telemetry about the plane itself (never per-issue business + * data) -- gated separately behind LOOPOVER_MINER_DISCOVERY_TELEMETRY, per the operator guide's invariant list + * ("low-cardinality reason buckets", mirroring orb-export.js's `reasonBucket` convention). No hosted telemetry + * collector endpoint exists for the discovery plane yet (out of scope for #7164/#7166), so this emits a + * structured local log line via this package's own logger -- the gate, shape, and off-by-default behavior are + * real and tested; swapping in a real remote sink later is a logger-call change, not a design change. + */ +export function recordDiscoveryTelemetry( + event: string, + outcome: string, + options: { env?: Record } = {}, +): void { + const env = options.env ?? process.env; + if (!isDiscoveryPlaneEnabled(env) || !isDiscoveryTelemetryEnabled(env)) return; + getLogger().info("discovery_plane_telemetry", { event, outcome }); +} diff --git a/packages/loopover-miner/lib/opportunity-fanout.d.ts b/packages/loopover-miner/lib/opportunity-fanout.d.ts index 5f47a66a83..4fd88e0f6a 100644 --- a/packages/loopover-miner/lib/opportunity-fanout.d.ts +++ b/packages/loopover-miner/lib/opportunity-fanout.d.ts @@ -1,89 +1,62 @@ 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; - repo: string; + owner: string; + repo: string; }; - /** 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). `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; - concurrency?: number; - rateLimitLowWaterMark?: number; - rateLimitHighWaterMark?: number; - perPage?: number; - maxPages?: number; - requestTimeoutMs?: number; - sleepFn?: (ms: number) => Promise; - policyDocCache?: PolicyDocCache | null; - policyVerdictCache?: PolicyVerdictCache | null; + apiBaseUrl?: string; + forge?: Partial; + concurrency?: number; + rateLimitLowWaterMark?: number; + rateLimitHighWaterMark?: number; + perPage?: number; + maxPages?: number; + requestTimeoutMs?: number; + sleepFn?: (ms: number) => Promise; + policyDocCache?: PolicyDocCache | null; + policyVerdictCache?: PolicyVerdictCache | null; }; - export type RawCandidateIssue = { - owner: string; - repo: string; - repoFullName: string; - issueNumber: number; - title: string; - labels: string[]; - /** Assignee logins (#7040), already present in the same list/search payload as labels — no extra request. */ - assignees: string[]; - commentsCount: number; - createdAt: string | null; - updatedAt: string | null; - htmlUrl: string | null; - aiPolicyAllowed: true; - aiPolicySource: "AI-USAGE.md" | "CONTRIBUTING.md" | "none"; + owner: string; + repo: string; + repoFullName: string; + issueNumber: number; + title: string; + labels: string[]; + /** Assignee logins (#7040), already present in the same list/search payload as labels — no extra request. */ + assignees: string[]; + commentsCount: number; + createdAt: string | null; + updatedAt: string | null; + htmlUrl: string | null; + aiPolicyAllowed: true; + aiPolicySource: "AI-USAGE.md" | "CONTRIBUTING.md" | "none"; }; - export type CandidateIssueWarning = { - repoFullName: string; - stage: string; - message: string; + repoFullName: string; + stage: string; + message: string; }; - export type CandidateIssueSummary = { - issues: RawCandidateIssue[]; - rateLimitRemaining: number | null; - rateLimitResetAt: string | null; - warnings: CandidateIssueWarning[]; + issues: RawCandidateIssue[]; + rateLimitRemaining: number | null; + rateLimitResetAt: string | null; + warnings: CandidateIssueWarning[]; }; - -export function mapWithConcurrency( - items: T[], - maxConcurrency: number, - worker: (item: T, index: number) => Promise, - resolveLimit: () => number, - sleepFn?: (ms: number) => Promise, -): Promise; - -export function fetchCandidateIssuesWithSummary( - targets: FanoutTarget[], - githubToken: string, - options?: FanoutOptions, -): Promise; - -export function fetchCandidateIssues( - targets: FanoutTarget[], - githubToken: string, - options?: FanoutOptions, -): Promise; - -export function searchCandidateIssuesWithSummary( - searchQuery: string, - githubToken: string, - options?: FanoutOptions, -): Promise; - -export function searchCandidateIssues( - searchQuery: string, - githubToken: string, - options?: FanoutOptions, -): Promise; +export declare function mapWithConcurrency(items: T[], maxConcurrency: number, worker: (item: T, index: number) => Promise, resolveLimit: () => number, sleepFn?: (ms: number) => Promise): Promise; +export declare function fetchCandidateIssuesWithSummary(targets: FanoutTarget[], githubToken: string, options?: FanoutOptions): Promise; +/** + * Metadata-only GitHub discovery (#2307): never clones source, never fetches blobs beyond small policy docs, + * never uploads source, and never performs writes. Call the WithSummary variant when rate-limit telemetry is + * needed. + */ +export declare function fetchCandidateIssues(targets: FanoutTarget[], githubToken: string, options?: FanoutOptions): Promise; +export declare function searchCandidateIssuesWithSummary(searchQuery: string, githubToken: string, options?: FanoutOptions): Promise; +export declare function searchCandidateIssues(searchQuery: string, githubToken: string, options?: FanoutOptions): Promise; diff --git a/packages/loopover-miner/lib/opportunity-fanout.js b/packages/loopover-miner/lib/opportunity-fanout.js index afd0ff7954..10ab566243 100644 --- a/packages/loopover-miner/lib/opportunity-fanout.js +++ b/packages/loopover-miner/lib/opportunity-fanout.js @@ -1,13 +1,8 @@ import { Buffer } from "node:buffer"; import { resolveAiPolicyVerdict } from "@loopover/engine"; import { resolveForgeConfig } from "./forge-config.js"; -import { - DEFAULT_RATE_LIMIT_HIGH_WATER_MARK, - DEFAULT_RATE_LIMIT_LOW_WATER_MARK, - resolveThrottledConcurrency, -} from "./discovery-throttle.js"; +import { DEFAULT_RATE_LIMIT_HIGH_WATER_MARK, DEFAULT_RATE_LIMIT_LOW_WATER_MARK, resolveThrottledConcurrency, } from "./discovery-throttle.js"; import { fetchWithRetry } from "./http-retry.js"; - const defaultConcurrency = 5; // How long a parked worker waits before re-checking the live rate-limit-derived concurrency limit (#4844). const throttleParkMs = 25; @@ -16,598 +11,534 @@ const defaultPerPage = 100; // truncated (#4831); cap the follow loop so a pathological Link chain can't run away. const defaultMaxPages = 10; const defaultRequestTimeoutMs = 10_000; - function normalizeLimit(value, fallback, min, max) { - if (!Number.isFinite(value)) return fallback; - return Math.min(max, Math.max(min, Math.floor(value))); + if (!Number.isFinite(value)) + return fallback; + return Math.min(max, Math.max(min, Math.floor(value))); } - function targetKey(target) { - return `${target.owner.toLowerCase()}/${target.repo.toLowerCase()}`; + return `${target.owner.toLowerCase()}/${target.repo.toLowerCase()}`; } - function normalizeTargets(targets) { - const seen = new Set(); - const normalized = []; - for (const target of Array.isArray(targets) ? targets : []) { - const owner = typeof target?.owner === "string" ? target.owner.trim() : ""; - const repo = typeof target?.repo === "string" ? target.repo.trim() : ""; - if (!owner || !repo) continue; - const key = targetKey({ owner, repo }); - if (seen.has(key)) continue; - seen.add(key); - normalized.push({ owner, repo, repoFullName: `${owner}/${repo}` }); - } - return normalized; -} - + const seen = new Set(); + const normalized = []; + for (const target of Array.isArray(targets) ? targets : []) { + const owner = typeof target?.owner === "string" ? target.owner.trim() : ""; + const repo = typeof target?.repo === "string" ? target.repo.trim() : ""; + if (!owner || !repo) + continue; + const key = targetKey({ owner, repo }); + if (seen.has(key)) + continue; + seen.add(key); + normalized.push({ owner, repo, repoFullName: `${owner}/${repo}` }); + } + return normalized; +} function targetFromFullName(fullName) { - if (typeof fullName !== "string") return null; - const [owner, repo, extra] = fullName.split("/"); - if (!owner || !repo || extra) return null; - return { owner, repo, repoFullName: `${owner}/${repo}` }; + if (typeof fullName !== "string") + return null; + const [owner, repo, extra] = fullName.split("/"); + if (!owner || !repo || extra) + return null; + return { owner, repo, repoFullName: `${owner}/${repo}` }; } - function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } - // Derive owner/repo from a search hit when `repository.full_name` is absent, using the tenant forge's own // `repoPathPrefix` for the API `repository_url` and a forge-agnostic host for the web `html_url` (#4784). Hardcoding // `/repos/` and `github.com` here dropped every custom-forge search result whose payload omitted `full_name`. function targetFromSearchIssue(issue, forge) { - const repositoryFullName = targetFromFullName(issue?.repository?.full_name); - if (repositoryFullName) return repositoryFullName; - - const repoPathPrefix = escapeRegExp(forge.repoPathPrefix.replace(/\/+$/, "")); - const repositoryUrl = - typeof issue?.repository_url === "string" - ? issue.repository_url.match(new RegExp(`${repoPathPrefix}/([^/?#]+)/([^/?#]+)(?:[?#].*)?$`)) - : null; - if (repositoryUrl) { - const owner = decodeURIComponent(repositoryUrl[1]); - const repo = decodeURIComponent(repositoryUrl[2]); - return { owner, repo, repoFullName: `${owner}/${repo}` }; - } - - const htmlUrl = - typeof issue?.html_url === "string" - ? issue.html_url.match(/^https:\/\/[^/]+\/([^/]+)\/([^/]+)\/issues\/\d+(?:[?#].*)?$/) - : null; - if (htmlUrl) { - const owner = decodeURIComponent(htmlUrl[1]); - const repo = decodeURIComponent(htmlUrl[2]); - return { owner, repo, repoFullName: `${owner}/${repo}` }; - } - - return null; + const repositoryFullName = targetFromFullName(issue?.repository?.full_name); + if (repositoryFullName) + return repositoryFullName; + const repoPathPrefix = escapeRegExp(forge.repoPathPrefix.replace(/\/+$/, "")); + const repositoryUrl = typeof issue?.repository_url === "string" + ? issue.repository_url.match(new RegExp(`${repoPathPrefix}/([^/?#]+)/([^/?#]+)(?:[?#].*)?$`)) + : null; + if (repositoryUrl) { + const owner = decodeURIComponent(repositoryUrl[1]); + const repo = decodeURIComponent(repositoryUrl[2]); + return { owner, repo, repoFullName: `${owner}/${repo}` }; + } + const htmlUrl = typeof issue?.html_url === "string" + ? issue.html_url.match(/^https:\/\/[^/]+\/([^/]+)\/([^/]+)\/issues\/\d+(?:[?#].*)?$/) + : null; + if (htmlUrl) { + const owner = decodeURIComponent(htmlUrl[1]); + const repo = decodeURIComponent(htmlUrl[2]); + return { owner, repo, repoFullName: `${owner}/${repo}` }; + } + return null; } - function githubHeaders(githubToken, forge) { - const headers = { - accept: forge.acceptHeader, - "user-agent": forge.userAgent, - [forge.apiVersionHeader]: forge.apiVersion, - }; - const token = typeof githubToken === "string" ? githubToken.trim() : ""; - if (token) headers.authorization = `Bearer ${token}`; - return headers; -} - + const headers = { + accept: forge.acceptHeader, + "user-agent": forge.userAgent, + [forge.apiVersionHeader]: forge.apiVersion, + }; + const token = typeof githubToken === "string" ? githubToken.trim() : ""; + if (token) + headers.authorization = `Bearer ${token}`; + return headers; +} function apiUrl(apiBaseUrl, path, query = "") { - return `${apiBaseUrl.replace(/\/+$/, "")}${path}${query}`; + return `${apiBaseUrl.replace(/\/+$/, "")}${path}${query}`; } - function repoPath(forge, target, suffix) { - return `${forge.repoPathPrefix}/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}${suffix}`; + return `${forge.repoPathPrefix}/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}${suffix}`; } - function recordRateLimit(summary, response) { - const remaining = Number(response.headers.get("x-ratelimit-remaining")); - if (Number.isFinite(remaining)) { - summary.rateLimitRemaining = - summary.rateLimitRemaining === null - ? remaining - : Math.min(summary.rateLimitRemaining, remaining); - } - const resetSeconds = Number(response.headers.get("x-ratelimit-reset")); - if (Number.isFinite(resetSeconds) && resetSeconds > 0) { - const resetAt = new Date(resetSeconds * 1000).toISOString(); - summary.rateLimitResetAt = - summary.rateLimitResetAt === null || resetAt > summary.rateLimitResetAt - ? resetAt - : summary.rateLimitResetAt; - } -} - + const remaining = Number(response.headers.get("x-ratelimit-remaining")); + if (Number.isFinite(remaining)) { + summary.rateLimitRemaining = + summary.rateLimitRemaining === null + ? remaining + : Math.min(summary.rateLimitRemaining, remaining); + } + const resetSeconds = Number(response.headers.get("x-ratelimit-reset")); + if (Number.isFinite(resetSeconds) && resetSeconds > 0) { + const resetAt = new Date(resetSeconds * 1000).toISOString(); + summary.rateLimitResetAt = + summary.rateLimitResetAt === null || resetAt > summary.rateLimitResetAt + ? resetAt + : summary.rateLimitResetAt; + } +} async function githubGetJson(url, githubToken, summary, options, extraHeaders = {}) { - // Retry a transient 5xx from GitHub before dropping this target's results for the whole run (#4830) — the same - // discipline as the CI/gate-verdict pollers. A thrown network error still propagates to each caller's try/catch. - // `extraHeaders` carries per-call additions (e.g. a policy-doc If-None-Match, #4842) on top of the base auth set. - // requestTimeoutMs bounds each individual attempt so a stalled connection can't hang discovery forever - // (#miner-github-read-timeouts) -- fetchWithRetry gives each retry its own fresh AbortSignal.timeout(). - const response = await fetchWithRetry( - fetch, - url, - { method: "GET", headers: { ...githubHeaders(githubToken, options.forge), ...extraHeaders } }, - { sleepFn: options?.sleepFn, timeoutMs: options?.requestTimeoutMs }, - ); - recordRateLimit(summary, response); - const payload = await response.json().catch(() => null); - return { response, payload }; -} - + // Retry a transient 5xx from GitHub before dropping this target's results for the whole run (#4830) — the same + // discipline as the CI/gate-verdict pollers. A thrown network error still propagates to each caller's try/catch. + // `extraHeaders` carries per-call additions (e.g. a policy-doc If-None-Match, #4842) on top of the base auth set. + // requestTimeoutMs bounds each individual attempt so a stalled connection can't hang discovery forever + // (#miner-github-read-timeouts) -- fetchWithRetry gives each retry its own fresh AbortSignal.timeout(). + // Spread-omit sleepFn when absent rather than passing `sleepFn: undefined` -- FetchWithRetryOptions doesn't + // widen its optional properties to `| undefined`, and exactOptionalPropertyTypes treats those as different. + const retryOptions = { ...(options?.sleepFn ? { sleepFn: options.sleepFn } : {}), timeoutMs: options?.requestTimeoutMs }; + const response = await fetchWithRetry(fetch, url, { method: "GET", headers: { ...githubHeaders(githubToken, options.forge), ...extraHeaders } }, retryOptions); + recordRateLimit(summary, response); + const payload = await response.json().catch(() => null); + return { response, payload }; +} function decodeContentPayload(payload) { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; - if (typeof payload.content !== "string") return null; - if (payload.encoding === "base64") { - return Buffer.from(payload.content.replace(/\s/g, ""), "base64").toString("utf8"); - } - return payload.content; -} - + if (!payload || typeof payload !== "object" || Array.isArray(payload)) + return null; + const content = payload; + if (typeof content.content !== "string") + return null; + if (content.encoding === "base64") { + return Buffer.from(content.content.replace(/\s/g, ""), "base64").toString("utf8"); + } + return content.content; +} function warning(target, stage, message) { - return { repoFullName: target.repoFullName, stage, message }; + return { repoFullName: target.repoFullName, stage, message }; } - // Read a URL's prior ETag so an unchanged doc can be revalidated with a conditional GET (#4842). A cache that is // absent, or whose read throws (corrupt/locked file), is treated as a plain miss: the caller does a full fetch, // per the "never risk a stale policy" rule — the cache only ever makes discovery cheaper, never less correct. function readCachedPolicyDoc(cache, url) { - if (!cache) return null; - try { - return cache.get(url); - } catch { - return null; - } + if (!cache) + return null; + try { + return cache.get(url); + } + catch { + return null; + } } - function readEtagHeader(response) { - const etag = response.headers.get("etag"); - return typeof etag === "string" && etag.trim() ? etag : null; + 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, 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. - } -} - + 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}`; + 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; - } + 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. - } -} - + 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, - repoPath(options.forge, target, `/contents/${encodeURIComponent(path)}`), - ); - const cached = readCachedPolicyDoc(options.policyDocCache, url); - const conditionalHeaders = cached ? { "if-none-match": cached.etag } : {}; - try { - 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 { 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 { content: null, etag: null }; + const url = apiUrl(options.apiBaseUrl, repoPath(options.forge, target, `/contents/${encodeURIComponent(path)}`)); + const cached = readCachedPolicyDoc(options.policyDocCache, url); + const conditionalHeaders = cached ? { "if-none-match": cached.etag } : {}; + try { + 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 { 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 { content: null, etag: null }; + } + const content = decodeContentPayload(payload); + 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 { content: null, etag: null }; } - const content = decodeContentPayload(payload); - 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 { 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; -} - + 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 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 resolveOrCacheVerdict(options.policyVerdictCache, repoScope, "AI-USAGE.md", aiUsageEtag, () => - resolveAiPolicyVerdict({ aiUsage, contributing: null }), - ); - } - const { content: contributing, etag: contributingEtag } = await fetchRepoDoc( - target, - "CONTRIBUTING.md", - githubToken, - options, - summary, - warnings, - ); - return resolveOrCacheVerdict( - options.policyVerdictCache, - repoScope, - "CONTRIBUTING.md", - contributingEtag, - () => resolveAiPolicyVerdict({ aiUsage: null, contributing }), - ); -} - + 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 resolveOrCacheVerdict(options.policyVerdictCache, repoScope, "AI-USAGE.md", aiUsageEtag, () => resolveAiPolicyVerdict({ aiUsage, contributing: null })); + } + const { content: contributing, etag: contributingEtag } = await fetchRepoDoc(target, "CONTRIBUTING.md", githubToken, options, summary, warnings); + return resolveOrCacheVerdict(options.policyVerdictCache, repoScope, "CONTRIBUTING.md", contributingEtag, () => resolveAiPolicyVerdict({ aiUsage: null, contributing })); +} function labelNames(labels) { - if (!Array.isArray(labels)) return []; - return labels - .map((label) => { - if (typeof label === "string") return label; - if (label && typeof label === "object" && typeof label.name === "string") return label.name; - return ""; + if (!Array.isArray(labels)) + return []; + return labels + .map((label) => { + if (typeof label === "string") + return label; + if (label && typeof label === "object" && typeof label.name === "string") { + return label.name; + } + return ""; }) - .filter((name) => name.length > 0); + .filter((name) => name.length > 0); } - // Assignee logins (#7040): GitHub's issue-list/search payloads already carry `assignees` in the same response // that supplies labels/comments/etc. -- no extra request needed. contribution-profile-filter.js's // assignee-exclusion rule uses this to drop candidates assigned to a login the target repo considers off-limits // (its own owner, by default). function assigneeLogins(assignees) { - if (!Array.isArray(assignees)) return []; - return assignees - .map((assignee) => (assignee && typeof assignee === "object" && typeof assignee.login === "string" ? assignee.login : "")) - .filter((login) => login.length > 0); + if (!Array.isArray(assignees)) + return []; + return assignees + .map((assignee) => assignee && typeof assignee === "object" && typeof assignee.login === "string" + ? assignee.login + : "") + .filter((login) => login.length > 0); } - function normalizeIssue(target, issue, policySource) { - if (!issue || typeof issue !== "object" || issue.pull_request) return null; - if (!Number.isInteger(issue.number) || issue.number <= 0) return null; - if (typeof issue.title !== "string" || issue.title.trim().length === 0) return null; - return { - owner: target.owner, - repo: target.repo, - repoFullName: target.repoFullName, - issueNumber: issue.number, - title: issue.title, - labels: labelNames(issue.labels), - assignees: assigneeLogins(issue.assignees), - commentsCount: Number.isFinite(issue.comments) ? issue.comments : 0, - createdAt: typeof issue.created_at === "string" ? issue.created_at : null, - updatedAt: typeof issue.updated_at === "string" ? issue.updated_at : null, - htmlUrl: typeof issue.html_url === "string" ? issue.html_url : null, - aiPolicyAllowed: true, - aiPolicySource: policySource, - }; -} - + if (!issue || typeof issue !== "object" || issue.pull_request) + return null; + const candidate = issue; + if (!Number.isInteger(candidate.number) || candidate.number <= 0) + return null; + if (typeof candidate.title !== "string" || candidate.title.trim().length === 0) + return null; + return { + owner: target.owner, + repo: target.repo, + repoFullName: target.repoFullName, + issueNumber: candidate.number, + title: candidate.title, + labels: labelNames(candidate.labels), + assignees: assigneeLogins(candidate.assignees), + commentsCount: Number.isFinite(candidate.comments) ? candidate.comments : 0, + createdAt: typeof candidate.created_at === "string" ? candidate.created_at : null, + updatedAt: typeof candidate.updated_at === "string" ? candidate.updated_at : null, + htmlUrl: typeof candidate.html_url === "string" ? candidate.html_url : null, + aiPolicyAllowed: true, + aiPolicySource: policySource, + }; +} function searchQueryWithIssueQualifiers(searchQuery, forge) { - const trimmed = typeof searchQuery === "string" ? searchQuery.trim() : ""; - if (!trimmed) return ""; - return `${trimmed} ${forge.searchQualifiers}`; + const trimmed = typeof searchQuery === "string" ? searchQuery.trim() : ""; + if (!trimmed) + return ""; + return `${trimmed} ${forge.searchQualifiers}`; } - // The URL of the next page from a GitHub Link header (`; rel="next"`), constrained to the current // token-bearing GitHub API endpoint so a forged Link header cannot redirect credentials off-origin. function nextPageUrl(response, apiBaseUrl, expectedPath) { - const linkHeader = response.headers.get("link") ?? ""; - const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/); - if (match === null) return null; - - let nextUrl; - let expectedUrl; - try { - expectedUrl = new URL(apiUrl(apiBaseUrl, expectedPath)); - nextUrl = new URL(match[1], expectedUrl); - } catch { - return null; - } - - if ( - nextUrl.protocol !== "https:" || - nextUrl.origin !== expectedUrl.origin || - nextUrl.pathname !== expectedUrl.pathname - ) { - return null; - } - return nextUrl.toString(); + const linkHeader = response.headers.get("link") ?? ""; + const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/); + if (match === null) + return null; + let nextUrl; + let expectedUrl; + try { + expectedUrl = new URL(apiUrl(apiBaseUrl, expectedPath)); + nextUrl = new URL(match[1], expectedUrl); + } + catch { + return null; + } + if (nextUrl.protocol !== "https:" || + nextUrl.origin !== expectedUrl.origin || + nextUrl.pathname !== expectedUrl.pathname) { + return null; + } + return nextUrl.toString(); } - async function fetchTargetIssues(target, githubToken, options, summary, warnings) { - const verdict = await resolveRepoAiPolicy(target, githubToken, options, summary, warnings); - if (!verdict.allowed) return []; - - const issuesPath = repoPath(options.forge, target, "/issues"); - let url = apiUrl(options.apiBaseUrl, issuesPath, `?state=open&per_page=${options.perPage}`); - const issues = []; - try { - for (let page = 0; url !== null && page < options.maxPages; page += 1) { - const { response, payload } = await githubGetJson(url, githubToken, summary, options); - if (!response.ok) { - warnings.push(warning(target, "issues", `GitHub returned ${response.status}`)); + const verdict = await resolveRepoAiPolicy(target, githubToken, options, summary, warnings); + if (!verdict.allowed) + return []; + const issuesPath = repoPath(options.forge, target, "/issues"); + let url = apiUrl(options.apiBaseUrl, issuesPath, `?state=open&per_page=${options.perPage}`); + const issues = []; + try { + for (let page = 0; url !== null && page < options.maxPages; page += 1) { + const { response, payload } = await githubGetJson(url, githubToken, summary, options); + if (!response.ok) { + warnings.push(warning(target, "issues", `GitHub returned ${response.status}`)); + return issues; + } + if (!Array.isArray(payload)) { + warnings.push(warning(target, "issues", "GitHub returned a non-array issues payload")); + return issues; + } + for (const issue of payload) { + const normalized = normalizeIssue(target, issue, verdict.source); + if (normalized !== null) + issues.push(normalized); + } + url = nextPageUrl(response, options.apiBaseUrl, issuesPath); + } return issues; - } - if (!Array.isArray(payload)) { - warnings.push(warning(target, "issues", "GitHub returned a non-array issues payload")); + } + catch (error) { + warnings.push(warning(target, "issues", error instanceof Error ? error.message : "issue fetch failed")); return issues; - } - for (const issue of payload) { - const normalized = normalizeIssue(target, issue, verdict.source); - if (normalized !== null) issues.push(normalized); - } - url = nextPageUrl(response, options.apiBaseUrl, issuesPath); } - return issues; - } catch (error) { - warnings.push( - warning(target, "issues", error instanceof Error ? error.message : "issue fetch failed"), - ); - return issues; - } -} - +} async function fetchSearchIssues(searchQuery, githubToken, options, summary, warnings) { - const qualifiedQuery = searchQueryWithIssueQualifiers(searchQuery, options.forge); - if (!qualifiedQuery) return []; - - const searchPath = options.forge.searchEndpoint; - let url = apiUrl( - options.apiBaseUrl, - searchPath, - `?q=${encodeURIComponent(qualifiedQuery)}&per_page=${options.perPage}`, - ); - const items = []; - try { - for (let page = 0; url !== null && page < options.maxPages; page += 1) { - const { response, payload } = await githubGetJson(url, githubToken, summary, options); - if (!response.ok) { - warnings.push({ - repoFullName: "*", - stage: "search", - message: `GitHub returned ${response.status}`, - }); + const qualifiedQuery = searchQueryWithIssueQualifiers(searchQuery, options.forge); + if (!qualifiedQuery) + return []; + const searchPath = options.forge.searchEndpoint; + let url = apiUrl(options.apiBaseUrl, searchPath, `?q=${encodeURIComponent(qualifiedQuery)}&per_page=${options.perPage}`); + const items = []; + try { + for (let page = 0; url !== null && page < options.maxPages; page += 1) { + const { response, payload } = await githubGetJson(url, githubToken, summary, options); + if (!response.ok) { + warnings.push({ + repoFullName: "*", + stage: "search", + message: `GitHub returned ${response.status}`, + }); + return items; + } + const searchPayload = payload; + if (!searchPayload || typeof searchPayload !== "object" || !Array.isArray(searchPayload.items)) { + warnings.push({ + repoFullName: "*", + stage: "search", + message: "GitHub returned a non-array search payload", + }); + return items; + } + items.push(...searchPayload.items); + url = nextPageUrl(response, options.apiBaseUrl, searchPath); + } return items; - } - if (!payload || typeof payload !== "object" || !Array.isArray(payload.items)) { + } + catch (error) { warnings.push({ - repoFullName: "*", - stage: "search", - message: "GitHub returned a non-array search payload", + repoFullName: "*", + stage: "search", + message: error instanceof Error ? error.message : "issue search failed", }); return items; - } - items.push(...payload.items); - url = nextPageUrl(response, options.apiBaseUrl, searchPath); } - return items; - } catch (error) { - warnings.push({ - repoFullName: "*", - stage: "search", - message: error instanceof Error ? error.message : "issue search failed", - }); - return items; - } -} - +} function delay(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } - // Run `worker` over `items` with a dynamic in-flight cap (#4844). The pool spawns `maxConcurrency` loops, but a // loop parks (re-checking every `throttleParkMs`) whenever the live `resolveLimit()` — derived from the recorded // rate-limit budget — is already met by the number of in-flight workers, so effective concurrency tapers off as // the budget drops instead of sprinting into a 403. `sleepFn` lets tests inject an instant wait for the park. export async function mapWithConcurrency(items, maxConcurrency, worker, resolveLimit, sleepFn) { - const results = new Array(items.length); - const sleep = sleepFn ?? delay; - let next = 0; - let active = 0; - const runOne = async () => { - while (next < items.length) { - // Park while the live limit is already saturated. The check and the `active`/`next` bumps below run without - // an intervening await, so two loops can never claim the same slot. - while (active >= resolveLimit()) { - await sleep(throttleParkMs); - } - // The shared cursor can be drained by other loops while this one is parked, so re-check before claiming. - if (next >= items.length) return; - const index = next; - next += 1; - active += 1; - try { - results[index] = await worker(items[index], index); - } finally { - active -= 1; - } - } - }; - const workers = Array.from({ length: Math.min(maxConcurrency, items.length) }, runOne); - await Promise.all(workers); - return results; + const results = new Array(items.length); + const sleep = sleepFn ?? delay; + let next = 0; + let active = 0; + const runOne = async () => { + while (next < items.length) { + // Park while the live limit is already saturated. The check and the `active`/`next` bumps below run without + // an intervening await, so two loops can never claim the same slot. + while (active >= resolveLimit()) { + await sleep(throttleParkMs); + } + // The shared cursor can be drained by other loops while this one is parked, so re-check before claiming. + if (next >= items.length) + return; + const index = next; + next += 1; + active += 1; + try { + results[index] = await worker(items[index], index); + } + finally { + active -= 1; + } + } + }; + const workers = Array.from({ length: Math.min(maxConcurrency, items.length) }, runOne); + await Promise.all(workers); + return results; } - /** A live limit resolver for `mapWithConcurrency`, reading the summary's rate-limit budget as it is updated (#4844). */ function liveConcurrencyResolver(normalizedOptions, summary) { - return () => - resolveThrottledConcurrency( - normalizedOptions.concurrency, - summary.rateLimitRemaining, - normalizedOptions.rateLimitLowWaterMark, - normalizedOptions.rateLimitHighWaterMark, - ); -} - + return () => resolveThrottledConcurrency(normalizedOptions.concurrency, summary.rateLimitRemaining, normalizedOptions.rateLimitLowWaterMark, normalizedOptions.rateLimitHighWaterMark); +} function normalizeOptions(options = {}) { - // A legacy top-level `apiBaseUrl` (the pre-#4784 GitHub-Enterprise override every existing caller uses) still wins - // over `forge.apiBaseUrl`, so nothing that already passes `apiBaseUrl` changes behavior. - const apiBaseUrlOverride = - typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() - ? { apiBaseUrl: options.apiBaseUrl } - : {}; - const forge = resolveForgeConfig({ ...(options.forge ?? {}), ...apiBaseUrlOverride }); - return { - forge, - apiBaseUrl: forge.apiBaseUrl, - concurrency: normalizeLimit(options.concurrency, defaultConcurrency, 1, 10), - // Below/above these recorded-rate-limit-remaining marks the fanout serializes / runs at full concurrency; in - // between it scales down linearly (#4844). - rateLimitLowWaterMark: normalizeLimit( - options.rateLimitLowWaterMark, - DEFAULT_RATE_LIMIT_LOW_WATER_MARK, - 0, - 1_000_000, - ), - rateLimitHighWaterMark: normalizeLimit( - options.rateLimitHighWaterMark, - DEFAULT_RATE_LIMIT_HIGH_WATER_MARK, - 1, - 1_000_000, - ), - perPage: normalizeLimit(options.perPage, defaultPerPage, 1, 100), - maxPages: normalizeLimit(options.maxPages, defaultMaxPages, 1, 100), - requestTimeoutMs: normalizeLimit(options.requestTimeoutMs, defaultRequestTimeoutMs, 1, 60_000), - // Passed through to the per-fetch retry so tests can inject an instant sleep; undefined uses the real backoff. - sleepFn: typeof options.sleepFn === "function" ? options.sleepFn : undefined, - // 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, - }; -} - + // A legacy top-level `apiBaseUrl` (the pre-#4784 GitHub-Enterprise override every existing caller uses) still wins + // over `forge.apiBaseUrl`, so nothing that already passes `apiBaseUrl` changes behavior. + const apiBaseUrlOverride = typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() + ? { apiBaseUrl: options.apiBaseUrl } + : {}; + const forge = resolveForgeConfig({ ...(options.forge ?? {}), ...apiBaseUrlOverride }); + return { + forge, + apiBaseUrl: forge.apiBaseUrl, + concurrency: normalizeLimit(options.concurrency, defaultConcurrency, 1, 10), + // Below/above these recorded-rate-limit-remaining marks the fanout serializes / runs at full concurrency; in + // between it scales down linearly (#4844). + rateLimitLowWaterMark: normalizeLimit(options.rateLimitLowWaterMark, DEFAULT_RATE_LIMIT_LOW_WATER_MARK, 0, 1_000_000), + rateLimitHighWaterMark: normalizeLimit(options.rateLimitHighWaterMark, DEFAULT_RATE_LIMIT_HIGH_WATER_MARK, 1, 1_000_000), + perPage: normalizeLimit(options.perPage, defaultPerPage, 1, 100), + maxPages: normalizeLimit(options.maxPages, defaultMaxPages, 1, 100), + requestTimeoutMs: normalizeLimit(options.requestTimeoutMs, defaultRequestTimeoutMs, 1, 60_000), + // Passed through to the per-fetch retry so tests can inject an instant sleep; undefined uses the real backoff. + sleepFn: typeof options.sleepFn === "function" ? options.sleepFn : undefined, + // 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, + }; +} export async function fetchCandidateIssuesWithSummary(targets, githubToken, options = {}) { - const normalizedOptions = normalizeOptions(options); - const normalizedTargets = normalizeTargets(targets); - const summary = { - rateLimitRemaining: null, - rateLimitResetAt: null, - }; - const warnings = []; - const batches = await mapWithConcurrency( - normalizedTargets, - normalizedOptions.concurrency, - (target) => fetchTargetIssues(target, githubToken, normalizedOptions, summary, warnings), - liveConcurrencyResolver(normalizedOptions, summary), - normalizedOptions.sleepFn, - ); - return { - issues: batches.flat(), - rateLimitRemaining: summary.rateLimitRemaining, - rateLimitResetAt: summary.rateLimitResetAt, - warnings, - }; -} - + const normalizedOptions = normalizeOptions(options); + const normalizedTargets = normalizeTargets(targets); + const summary = { + rateLimitRemaining: null, + rateLimitResetAt: null, + }; + const warnings = []; + const batches = await mapWithConcurrency(normalizedTargets, normalizedOptions.concurrency, (target) => fetchTargetIssues(target, githubToken, normalizedOptions, summary, warnings), liveConcurrencyResolver(normalizedOptions, summary), normalizedOptions.sleepFn); + return { + issues: batches.flat(), + rateLimitRemaining: summary.rateLimitRemaining, + rateLimitResetAt: summary.rateLimitResetAt, + warnings, + }; +} /** * Metadata-only GitHub discovery (#2307): never clones source, never fetches blobs beyond small policy docs, * never uploads source, and never performs writes. Call the WithSummary variant when rate-limit telemetry is * needed. */ export async function fetchCandidateIssues(targets, githubToken, options = {}) { - const result = await fetchCandidateIssuesWithSummary(targets, githubToken, options); - return result.issues; + const result = await fetchCandidateIssuesWithSummary(targets, githubToken, options); + return result.issues; } - export async function searchCandidateIssuesWithSummary(searchQuery, githubToken, options = {}) { - const normalizedOptions = normalizeOptions(options); - const summary = { - rateLimitRemaining: null, - rateLimitResetAt: null, - }; - const warnings = []; - const searchItems = await fetchSearchIssues(searchQuery, githubToken, normalizedOptions, summary, warnings); - const targetsByKey = new Map(); - for (const item of searchItems) { - if (!item || typeof item !== "object" || item.pull_request) continue; - const target = targetFromSearchIssue(item, normalizedOptions.forge); - if (target && !targetsByKey.has(targetKey(target))) targetsByKey.set(targetKey(target), target); - } - - const policyEntries = await mapWithConcurrency( - [...targetsByKey.values()], - normalizedOptions.concurrency, - async (target) => { - const verdict = await resolveRepoAiPolicy(target, githubToken, normalizedOptions, summary, warnings); - return [targetKey(target), verdict]; - }, - liveConcurrencyResolver(normalizedOptions, summary), - normalizedOptions.sleepFn, - ); - const policiesByKey = new Map(policyEntries); - const issues = []; - for (const item of searchItems) { - const target = targetFromSearchIssue(item, normalizedOptions.forge); - if (!target) continue; - const policy = policiesByKey.get(targetKey(target)); - if (!policy?.allowed) continue; - const normalizedIssue = normalizeIssue(target, item, policy.source); - if (normalizedIssue) issues.push(normalizedIssue); - } - - return { - issues, - rateLimitRemaining: summary.rateLimitRemaining, - rateLimitResetAt: summary.rateLimitResetAt, - warnings, - }; -} - + const normalizedOptions = normalizeOptions(options); + const summary = { + rateLimitRemaining: null, + rateLimitResetAt: null, + }; + const warnings = []; + const searchItems = await fetchSearchIssues(searchQuery, githubToken, normalizedOptions, summary, warnings); + const targetsByKey = new Map(); + for (const item of searchItems) { + if (!item || typeof item !== "object" || item.pull_request) + continue; + const target = targetFromSearchIssue(item, normalizedOptions.forge); + if (target && !targetsByKey.has(targetKey(target))) + targetsByKey.set(targetKey(target), target); + } + const policyEntries = await mapWithConcurrency([...targetsByKey.values()], normalizedOptions.concurrency, async (target) => { + const verdict = await resolveRepoAiPolicy(target, githubToken, normalizedOptions, summary, warnings); + return [targetKey(target), verdict]; + }, liveConcurrencyResolver(normalizedOptions, summary), normalizedOptions.sleepFn); + const policiesByKey = new Map(policyEntries); + const issues = []; + for (const item of searchItems) { + const target = targetFromSearchIssue(item, normalizedOptions.forge); + if (!target) + continue; + const policy = policiesByKey.get(targetKey(target)); + if (!policy?.allowed) + continue; + const normalizedIssue = normalizeIssue(target, item, policy.source); + if (normalizedIssue) + issues.push(normalizedIssue); + } + return { + issues, + rateLimitRemaining: summary.rateLimitRemaining, + rateLimitResetAt: summary.rateLimitResetAt, + warnings, + }; +} export async function searchCandidateIssues(searchQuery, githubToken, options = {}) { - const result = await searchCandidateIssuesWithSummary(searchQuery, githubToken, options); - return result.issues; + const result = await searchCandidateIssuesWithSummary(searchQuery, githubToken, options); + return result.issues; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoib3Bwb3J0dW5pdHktZmFub3V0LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsib3Bwb3J0dW5pdHktZmFub3V0LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxNQUFNLEVBQUUsTUFBTSxhQUFhLENBQUM7QUFFckMsT0FBTyxFQUFFLHNCQUFzQixFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFFMUQsT0FBTyxFQUFFLGtCQUFrQixFQUFFLE1BQU0sbUJBQW1CLENBQUM7QUFHdkQsT0FBTyxFQUNMLGtDQUFrQyxFQUNsQyxpQ0FBaUMsRUFDakMsMkJBQTJCLEdBQzVCLE1BQU0seUJBQXlCLENBQUM7QUFDakMsT0FBTyxFQUFFLGNBQWMsRUFBRSxNQUFNLGlCQUFpQixDQUFDO0FBa0ZqRCxNQUFNLGtCQUFrQixHQUFHLENBQUMsQ0FBQztBQUM3QiwyR0FBMkc7QUFDM0csTUFBTSxjQUFjLEdBQUcsRUFBRSxDQUFDO0FBQzFCLE1BQU0sY0FBYyxHQUFHLEdBQUcsQ0FBQztBQUMzQiwwR0FBMEc7QUFDMUcsc0ZBQXNGO0FBQ3RGLE1BQU0sZUFBZSxHQUFHLEVBQUUsQ0FBQztBQUMzQixNQUFNLHVCQUF1QixHQUFHLE1BQU0sQ0FBQztBQXVCdkMsU0FBUyxjQUFjLENBQUMsS0FBYyxFQUFFLFFBQWdCLEVBQUUsR0FBVyxFQUFFLEdBQVc7SUFDaEYsSUFBSSxDQUFDLE1BQU0sQ0FBQyxRQUFRLENBQUMsS0FBSyxDQUFDO1FBQUUsT0FBTyxRQUFRLENBQUM7SUFDN0MsT0FBTyxJQUFJLENBQUMsR0FBRyxDQUFDLEdBQUcsRUFBRSxJQUFJLENBQUMsR0FBRyxDQUFDLEdBQUcsRUFBRSxJQUFJLENBQUMsS0FBSyxDQUFDLEtBQWUsQ0FBQyxDQUFDLENBQUMsQ0FBQztBQUNuRSxDQUFDO0FBRUQsU0FBUyxTQUFTLENBQUMsTUFBdUM7SUFDeEQsT0FBTyxHQUFHLE1BQU0sQ0FBQyxLQUFLLENBQUMsV0FBVyxFQUFFLElBQUksTUFBTSxDQUFD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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/opportunity-fanout.ts b/packages/loopover-miner/lib/opportunity-fanout.ts new file mode 100644 index 0000000000..d4172de922 --- /dev/null +++ b/packages/loopover-miner/lib/opportunity-fanout.ts @@ -0,0 +1,795 @@ +import { Buffer } from "node:buffer"; +import type { AiPolicyVerdict } from "@loopover/engine"; +import { resolveAiPolicyVerdict } from "@loopover/engine"; +import type { ForgeConfig } from "./forge-config.js"; +import { resolveForgeConfig } from "./forge-config.js"; +import type { PolicyDocCache } from "./policy-doc-cache.js"; +import type { PolicyVerdictCache } from "./policy-verdict-cache.js"; +import { + DEFAULT_RATE_LIMIT_HIGH_WATER_MARK, + DEFAULT_RATE_LIMIT_LOW_WATER_MARK, + resolveThrottledConcurrency, +} from "./discovery-throttle.js"; +import { fetchWithRetry } from "./http-retry.js"; + +export type FanoutTarget = { + owner: string; + repo: string; +}; + +/** 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). `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; + concurrency?: number; + rateLimitLowWaterMark?: number; + rateLimitHighWaterMark?: number; + perPage?: number; + maxPages?: number; + requestTimeoutMs?: number; + sleepFn?: (ms: number) => Promise; + policyDocCache?: PolicyDocCache | null; + policyVerdictCache?: PolicyVerdictCache | null; +}; + +export type RawCandidateIssue = { + owner: string; + repo: string; + repoFullName: string; + issueNumber: number; + title: string; + labels: string[]; + /** Assignee logins (#7040), already present in the same list/search payload as labels — no extra request. */ + assignees: string[]; + commentsCount: number; + createdAt: string | null; + updatedAt: string | null; + htmlUrl: string | null; + aiPolicyAllowed: true; + aiPolicySource: "AI-USAGE.md" | "CONTRIBUTING.md" | "none"; +}; + +export type CandidateIssueWarning = { + repoFullName: string; + stage: string; + message: string; +}; + +export type CandidateIssueSummary = { + issues: RawCandidateIssue[]; + rateLimitRemaining: number | null; + rateLimitResetAt: string | null; + warnings: CandidateIssueWarning[]; +}; + +/** A normalized fan-out target: `owner`/`repo` plus the derived `owner/repo` display/key form. */ +type Target = { + owner: string; + repo: string; + repoFullName: string; +}; + +type RateLimitSummary = { + rateLimitRemaining: number | null; + rateLimitResetAt: string | null; +}; + +type NormalizedOptions = { + forge: ForgeConfig; + apiBaseUrl: string; + concurrency: number; + rateLimitLowWaterMark: number; + rateLimitHighWaterMark: number; + perPage: number; + maxPages: number; + requestTimeoutMs: number; + sleepFn?: ((ms: number) => Promise) | undefined; + policyDocCache: PolicyDocCache | null; + policyVerdictCache: PolicyVerdictCache | null; +}; + +const defaultConcurrency = 5; +// How long a parked worker waits before re-checking the live rate-limit-derived concurrency limit (#4844). +const throttleParkMs = 25; +const defaultPerPage = 100; +// Follow the GitHub Link header past the first page so a repo/search with >100 open issues isn't silently +// truncated (#4831); cap the follow loop so a pathological Link chain can't run away. +const defaultMaxPages = 10; +const defaultRequestTimeoutMs = 10_000; + +/** Minimal shape of a raw GitHub issue/search-hit payload -- every field is read defensively (`typeof` / + * `Array.isArray` guarded) since it comes straight off the wire. */ +type GithubIssuePayload = { + pull_request?: unknown; + number?: unknown; + title?: unknown; + labels?: unknown; + assignees?: unknown; + comments?: unknown; + created_at?: unknown; + updated_at?: unknown; + html_url?: unknown; + repository?: { full_name?: unknown } | null; + repository_url?: unknown; +}; + +type ContentPayload = { + content?: unknown; + encoding?: unknown; +}; + +function normalizeLimit(value: unknown, fallback: number, min: number, max: number): number { + if (!Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(min, Math.floor(value as number))); +} + +function targetKey(target: { owner: string; repo: string }): string { + return `${target.owner.toLowerCase()}/${target.repo.toLowerCase()}`; +} + +function normalizeTargets(targets: unknown): Target[] { + const seen = new Set(); + const normalized: Target[] = []; + for (const target of Array.isArray(targets) ? targets : []) { + const owner = typeof target?.owner === "string" ? target.owner.trim() : ""; + const repo = typeof target?.repo === "string" ? target.repo.trim() : ""; + if (!owner || !repo) continue; + const key = targetKey({ owner, repo }); + if (seen.has(key)) continue; + seen.add(key); + normalized.push({ owner, repo, repoFullName: `${owner}/${repo}` }); + } + return normalized; +} + +function targetFromFullName(fullName: unknown): Target | null { + if (typeof fullName !== "string") return null; + const [owner, repo, extra] = fullName.split("/"); + if (!owner || !repo || extra) return null; + return { owner, repo, repoFullName: `${owner}/${repo}` }; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// Derive owner/repo from a search hit when `repository.full_name` is absent, using the tenant forge's own +// `repoPathPrefix` for the API `repository_url` and a forge-agnostic host for the web `html_url` (#4784). Hardcoding +// `/repos/` and `github.com` here dropped every custom-forge search result whose payload omitted `full_name`. +function targetFromSearchIssue(issue: GithubIssuePayload, forge: ForgeConfig): Target | null { + const repositoryFullName = targetFromFullName(issue?.repository?.full_name); + if (repositoryFullName) return repositoryFullName; + + const repoPathPrefix = escapeRegExp(forge.repoPathPrefix.replace(/\/+$/, "")); + const repositoryUrl = + typeof issue?.repository_url === "string" + ? issue.repository_url.match(new RegExp(`${repoPathPrefix}/([^/?#]+)/([^/?#]+)(?:[?#].*)?$`)) + : null; + if (repositoryUrl) { + const owner = decodeURIComponent(repositoryUrl[1]!); + const repo = decodeURIComponent(repositoryUrl[2]!); + return { owner, repo, repoFullName: `${owner}/${repo}` }; + } + + const htmlUrl = + typeof issue?.html_url === "string" + ? issue.html_url.match(/^https:\/\/[^/]+\/([^/]+)\/([^/]+)\/issues\/\d+(?:[?#].*)?$/) + : null; + if (htmlUrl) { + const owner = decodeURIComponent(htmlUrl[1]!); + const repo = decodeURIComponent(htmlUrl[2]!); + return { owner, repo, repoFullName: `${owner}/${repo}` }; + } + + return null; +} + +function githubHeaders(githubToken: unknown, forge: ForgeConfig): Record { + const headers: Record = { + accept: forge.acceptHeader, + "user-agent": forge.userAgent, + [forge.apiVersionHeader]: forge.apiVersion, + }; + const token = typeof githubToken === "string" ? githubToken.trim() : ""; + if (token) headers.authorization = `Bearer ${token}`; + return headers; +} + +function apiUrl(apiBaseUrl: string, path: string, query = ""): string { + return `${apiBaseUrl.replace(/\/+$/, "")}${path}${query}`; +} + +function repoPath(forge: ForgeConfig, target: Target, suffix: string): string { + return `${forge.repoPathPrefix}/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}${suffix}`; +} + +function recordRateLimit(summary: RateLimitSummary, response: Response): void { + const remaining = Number(response.headers.get("x-ratelimit-remaining")); + if (Number.isFinite(remaining)) { + summary.rateLimitRemaining = + summary.rateLimitRemaining === null + ? remaining + : Math.min(summary.rateLimitRemaining, remaining); + } + const resetSeconds = Number(response.headers.get("x-ratelimit-reset")); + if (Number.isFinite(resetSeconds) && resetSeconds > 0) { + const resetAt = new Date(resetSeconds * 1000).toISOString(); + summary.rateLimitResetAt = + summary.rateLimitResetAt === null || resetAt > summary.rateLimitResetAt + ? resetAt + : summary.rateLimitResetAt; + } +} + +async function githubGetJson( + url: string, + githubToken: unknown, + summary: RateLimitSummary, + options: NormalizedOptions, + extraHeaders: Record = {}, +): Promise<{ response: Response; payload: unknown }> { + // Retry a transient 5xx from GitHub before dropping this target's results for the whole run (#4830) — the same + // discipline as the CI/gate-verdict pollers. A thrown network error still propagates to each caller's try/catch. + // `extraHeaders` carries per-call additions (e.g. a policy-doc If-None-Match, #4842) on top of the base auth set. + // requestTimeoutMs bounds each individual attempt so a stalled connection can't hang discovery forever + // (#miner-github-read-timeouts) -- fetchWithRetry gives each retry its own fresh AbortSignal.timeout(). + // Spread-omit sleepFn when absent rather than passing `sleepFn: undefined` -- FetchWithRetryOptions doesn't + // widen its optional properties to `| undefined`, and exactOptionalPropertyTypes treats those as different. + const retryOptions = { ...(options?.sleepFn ? { sleepFn: options.sleepFn } : {}), timeoutMs: options?.requestTimeoutMs }; + const response = await fetchWithRetry( + fetch as (url: unknown, init?: unknown) => Promise, + url, + { method: "GET", headers: { ...githubHeaders(githubToken, options.forge), ...extraHeaders } }, + retryOptions, + ); + recordRateLimit(summary, response); + const payload = await response.json().catch(() => null); + return { response, payload }; +} + +function decodeContentPayload(payload: unknown): string | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const content = payload as ContentPayload; + if (typeof content.content !== "string") return null; + if (content.encoding === "base64") { + return Buffer.from(content.content.replace(/\s/g, ""), "base64").toString("utf8"); + } + return content.content; +} + +function warning(target: Target, stage: string, message: string): CandidateIssueWarning { + return { repoFullName: target.repoFullName, stage, message }; +} + +// Read a URL's prior ETag so an unchanged doc can be revalidated with a conditional GET (#4842). A cache that is +// absent, or whose read throws (corrupt/locked file), is treated as a plain miss: the caller does a full fetch, +// per the "never risk a stale policy" rule — the cache only ever makes discovery cheaper, never less correct. +function readCachedPolicyDoc(cache: PolicyDocCache | null, url: string) { + if (!cache) return null; + try { + return cache.get(url); + } catch { + return null; + } +} + +function readEtagHeader(response: Response): string | null { + 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: PolicyDocCache | null, url: string, etag: string | null, content: string | null): void { + 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: string, repoFullName: string): string { + 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: PolicyVerdictCache | null, repoScope: string) { + 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: PolicyVerdictCache | null, + repoScope: string, + decisiveDoc: "AI-USAGE.md" | "CONTRIBUTING.md", + etag: string | null, + verdict: AiPolicyVerdict, +): void { + 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: Target, + path: string, + githubToken: unknown, + options: NormalizedOptions, + summary: RateLimitSummary, + warnings: CandidateIssueWarning[], +): Promise<{ content: string | null; etag: string | null }> { + const url = apiUrl( + options.apiBaseUrl, + repoPath(options.forge, target, `/contents/${encodeURIComponent(path)}`), + ); + const cached = readCachedPolicyDoc(options.policyDocCache, url); + const conditionalHeaders: Record = cached ? { "if-none-match": cached.etag } : {}; + try { + 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 { 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 { content: null, etag: null }; + } + const content = decodeContentPayload(payload); + 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 { 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: PolicyVerdictCache | null, + repoScope: string, + decisiveDoc: "AI-USAGE.md" | "CONTRIBUTING.md", + etag: string | null, + computeVerdict: () => AiPolicyVerdict, +): AiPolicyVerdict { + 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: Target, + githubToken: unknown, + options: NormalizedOptions, + summary: RateLimitSummary, + warnings: CandidateIssueWarning[], +): Promise { + 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 resolveOrCacheVerdict(options.policyVerdictCache, repoScope, "AI-USAGE.md", aiUsageEtag, () => + resolveAiPolicyVerdict({ aiUsage, contributing: null }), + ); + } + const { content: contributing, etag: contributingEtag } = await fetchRepoDoc( + target, + "CONTRIBUTING.md", + githubToken, + options, + summary, + warnings, + ); + return resolveOrCacheVerdict( + options.policyVerdictCache, + repoScope, + "CONTRIBUTING.md", + contributingEtag, + () => resolveAiPolicyVerdict({ aiUsage: null, contributing }), + ); +} + +function labelNames(labels: unknown): string[] { + if (!Array.isArray(labels)) return []; + return labels + .map((label) => { + if (typeof label === "string") return label; + if (label && typeof label === "object" && typeof (label as { name?: unknown }).name === "string") { + return (label as { name: string }).name; + } + return ""; + }) + .filter((name) => name.length > 0); +} + +// Assignee logins (#7040): GitHub's issue-list/search payloads already carry `assignees` in the same response +// that supplies labels/comments/etc. -- no extra request needed. contribution-profile-filter.js's +// assignee-exclusion rule uses this to drop candidates assigned to a login the target repo considers off-limits +// (its own owner, by default). +function assigneeLogins(assignees: unknown): string[] { + if (!Array.isArray(assignees)) return []; + return assignees + .map((assignee) => + assignee && typeof assignee === "object" && typeof (assignee as { login?: unknown }).login === "string" + ? (assignee as { login: string }).login + : "", + ) + .filter((login) => login.length > 0); +} + +function normalizeIssue(target: Target, issue: unknown, policySource: RawCandidateIssue["aiPolicySource"]): RawCandidateIssue | null { + if (!issue || typeof issue !== "object" || (issue as GithubIssuePayload).pull_request) return null; + const candidate = issue as GithubIssuePayload; + if (!Number.isInteger(candidate.number) || (candidate.number as number) <= 0) return null; + if (typeof candidate.title !== "string" || candidate.title.trim().length === 0) return null; + return { + owner: target.owner, + repo: target.repo, + repoFullName: target.repoFullName, + issueNumber: candidate.number as number, + title: candidate.title, + labels: labelNames(candidate.labels), + assignees: assigneeLogins(candidate.assignees), + commentsCount: Number.isFinite(candidate.comments) ? (candidate.comments as number) : 0, + createdAt: typeof candidate.created_at === "string" ? candidate.created_at : null, + updatedAt: typeof candidate.updated_at === "string" ? candidate.updated_at : null, + htmlUrl: typeof candidate.html_url === "string" ? candidate.html_url : null, + aiPolicyAllowed: true, + aiPolicySource: policySource, + }; +} + +function searchQueryWithIssueQualifiers(searchQuery: unknown, forge: ForgeConfig): string { + const trimmed = typeof searchQuery === "string" ? searchQuery.trim() : ""; + if (!trimmed) return ""; + return `${trimmed} ${forge.searchQualifiers}`; +} + +// The URL of the next page from a GitHub Link header (`; rel="next"`), constrained to the current +// token-bearing GitHub API endpoint so a forged Link header cannot redirect credentials off-origin. +function nextPageUrl(response: Response, apiBaseUrl: string, expectedPath: string): string | null { + const linkHeader = response.headers.get("link") ?? ""; + const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/); + if (match === null) return null; + + let nextUrl: URL; + let expectedUrl: URL; + try { + expectedUrl = new URL(apiUrl(apiBaseUrl, expectedPath)); + nextUrl = new URL(match[1]!, expectedUrl); + } catch { + return null; + } + + if ( + nextUrl.protocol !== "https:" || + nextUrl.origin !== expectedUrl.origin || + nextUrl.pathname !== expectedUrl.pathname + ) { + return null; + } + return nextUrl.toString(); +} + +async function fetchTargetIssues( + target: Target, + githubToken: unknown, + options: NormalizedOptions, + summary: RateLimitSummary, + warnings: CandidateIssueWarning[], +): Promise { + const verdict = await resolveRepoAiPolicy(target, githubToken, options, summary, warnings); + if (!verdict.allowed) return []; + + const issuesPath = repoPath(options.forge, target, "/issues"); + let url: string | null = apiUrl(options.apiBaseUrl, issuesPath, `?state=open&per_page=${options.perPage}`); + const issues: RawCandidateIssue[] = []; + try { + for (let page = 0; url !== null && page < options.maxPages; page += 1) { + const { response, payload } = await githubGetJson(url, githubToken, summary, options); + if (!response.ok) { + warnings.push(warning(target, "issues", `GitHub returned ${response.status}`)); + return issues; + } + if (!Array.isArray(payload)) { + warnings.push(warning(target, "issues", "GitHub returned a non-array issues payload")); + return issues; + } + for (const issue of payload) { + const normalized = normalizeIssue(target, issue, verdict.source); + if (normalized !== null) issues.push(normalized); + } + url = nextPageUrl(response, options.apiBaseUrl, issuesPath); + } + return issues; + } catch (error) { + warnings.push( + warning(target, "issues", error instanceof Error ? error.message : "issue fetch failed"), + ); + return issues; + } +} + +async function fetchSearchIssues( + searchQuery: unknown, + githubToken: unknown, + options: NormalizedOptions, + summary: RateLimitSummary, + warnings: CandidateIssueWarning[], +): Promise { + const qualifiedQuery = searchQueryWithIssueQualifiers(searchQuery, options.forge); + if (!qualifiedQuery) return []; + + const searchPath = options.forge.searchEndpoint; + let url: string | null = apiUrl( + options.apiBaseUrl, + searchPath, + `?q=${encodeURIComponent(qualifiedQuery)}&per_page=${options.perPage}`, + ); + const items: GithubIssuePayload[] = []; + try { + for (let page = 0; url !== null && page < options.maxPages; page += 1) { + const { response, payload } = await githubGetJson(url, githubToken, summary, options); + if (!response.ok) { + warnings.push({ + repoFullName: "*", + stage: "search", + message: `GitHub returned ${response.status}`, + }); + return items; + } + const searchPayload = payload as { items?: unknown } | null; + if (!searchPayload || typeof searchPayload !== "object" || !Array.isArray(searchPayload.items)) { + warnings.push({ + repoFullName: "*", + stage: "search", + message: "GitHub returned a non-array search payload", + }); + return items; + } + items.push(...(searchPayload.items as GithubIssuePayload[])); + url = nextPageUrl(response, options.apiBaseUrl, searchPath); + } + return items; + } catch (error) { + warnings.push({ + repoFullName: "*", + stage: "search", + message: error instanceof Error ? error.message : "issue search failed", + }); + return items; + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Run `worker` over `items` with a dynamic in-flight cap (#4844). The pool spawns `maxConcurrency` loops, but a +// loop parks (re-checking every `throttleParkMs`) whenever the live `resolveLimit()` — derived from the recorded +// rate-limit budget — is already met by the number of in-flight workers, so effective concurrency tapers off as +// the budget drops instead of sprinting into a 403. `sleepFn` lets tests inject an instant wait for the park. +export async function mapWithConcurrency( + items: T[], + maxConcurrency: number, + worker: (item: T, index: number) => Promise, + resolveLimit: () => number, + sleepFn?: (ms: number) => Promise, +): Promise { + const results = new Array(items.length); + const sleep = sleepFn ?? delay; + let next = 0; + let active = 0; + const runOne = async () => { + while (next < items.length) { + // Park while the live limit is already saturated. The check and the `active`/`next` bumps below run without + // an intervening await, so two loops can never claim the same slot. + while (active >= resolveLimit()) { + await sleep(throttleParkMs); + } + // The shared cursor can be drained by other loops while this one is parked, so re-check before claiming. + if (next >= items.length) return; + const index = next; + next += 1; + active += 1; + try { + results[index] = await worker(items[index]!, index); + } finally { + active -= 1; + } + } + }; + const workers = Array.from({ length: Math.min(maxConcurrency, items.length) }, runOne); + await Promise.all(workers); + return results; +} + +/** A live limit resolver for `mapWithConcurrency`, reading the summary's rate-limit budget as it is updated (#4844). */ +function liveConcurrencyResolver(normalizedOptions: NormalizedOptions, summary: RateLimitSummary): () => number { + return () => + resolveThrottledConcurrency( + normalizedOptions.concurrency, + summary.rateLimitRemaining, + normalizedOptions.rateLimitLowWaterMark, + normalizedOptions.rateLimitHighWaterMark, + ); +} + +function normalizeOptions(options: FanoutOptions = {}): NormalizedOptions { + // A legacy top-level `apiBaseUrl` (the pre-#4784 GitHub-Enterprise override every existing caller uses) still wins + // over `forge.apiBaseUrl`, so nothing that already passes `apiBaseUrl` changes behavior. + const apiBaseUrlOverride = + typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() + ? { apiBaseUrl: options.apiBaseUrl } + : {}; + const forge = resolveForgeConfig({ ...(options.forge ?? {}), ...apiBaseUrlOverride }); + return { + forge, + apiBaseUrl: forge.apiBaseUrl, + concurrency: normalizeLimit(options.concurrency, defaultConcurrency, 1, 10), + // Below/above these recorded-rate-limit-remaining marks the fanout serializes / runs at full concurrency; in + // between it scales down linearly (#4844). + rateLimitLowWaterMark: normalizeLimit( + options.rateLimitLowWaterMark, + DEFAULT_RATE_LIMIT_LOW_WATER_MARK, + 0, + 1_000_000, + ), + rateLimitHighWaterMark: normalizeLimit( + options.rateLimitHighWaterMark, + DEFAULT_RATE_LIMIT_HIGH_WATER_MARK, + 1, + 1_000_000, + ), + perPage: normalizeLimit(options.perPage, defaultPerPage, 1, 100), + maxPages: normalizeLimit(options.maxPages, defaultMaxPages, 1, 100), + requestTimeoutMs: normalizeLimit(options.requestTimeoutMs, defaultRequestTimeoutMs, 1, 60_000), + // Passed through to the per-fetch retry so tests can inject an instant sleep; undefined uses the real backoff. + sleepFn: typeof options.sleepFn === "function" ? options.sleepFn : undefined, + // 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, + }; +} + +export async function fetchCandidateIssuesWithSummary( + targets: FanoutTarget[], + githubToken: string, + options: FanoutOptions = {}, +): Promise { + const normalizedOptions = normalizeOptions(options); + const normalizedTargets = normalizeTargets(targets); + const summary: RateLimitSummary = { + rateLimitRemaining: null, + rateLimitResetAt: null, + }; + const warnings: CandidateIssueWarning[] = []; + const batches = await mapWithConcurrency( + normalizedTargets, + normalizedOptions.concurrency, + (target) => fetchTargetIssues(target, githubToken, normalizedOptions, summary, warnings), + liveConcurrencyResolver(normalizedOptions, summary), + normalizedOptions.sleepFn, + ); + return { + issues: batches.flat(), + rateLimitRemaining: summary.rateLimitRemaining, + rateLimitResetAt: summary.rateLimitResetAt, + warnings, + }; +} + +/** + * Metadata-only GitHub discovery (#2307): never clones source, never fetches blobs beyond small policy docs, + * never uploads source, and never performs writes. Call the WithSummary variant when rate-limit telemetry is + * needed. + */ +export async function fetchCandidateIssues( + targets: FanoutTarget[], + githubToken: string, + options: FanoutOptions = {}, +): Promise { + const result = await fetchCandidateIssuesWithSummary(targets, githubToken, options); + return result.issues; +} + +export async function searchCandidateIssuesWithSummary( + searchQuery: string, + githubToken: string, + options: FanoutOptions = {}, +): Promise { + const normalizedOptions = normalizeOptions(options); + const summary: RateLimitSummary = { + rateLimitRemaining: null, + rateLimitResetAt: null, + }; + const warnings: CandidateIssueWarning[] = []; + const searchItems = await fetchSearchIssues(searchQuery, githubToken, normalizedOptions, summary, warnings); + const targetsByKey = new Map(); + for (const item of searchItems) { + if (!item || typeof item !== "object" || item.pull_request) continue; + const target = targetFromSearchIssue(item, normalizedOptions.forge); + if (target && !targetsByKey.has(targetKey(target))) targetsByKey.set(targetKey(target), target); + } + + const policyEntries = await mapWithConcurrency( + [...targetsByKey.values()], + normalizedOptions.concurrency, + async (target): Promise<[string, AiPolicyVerdict]> => { + const verdict = await resolveRepoAiPolicy(target, githubToken, normalizedOptions, summary, warnings); + return [targetKey(target), verdict]; + }, + liveConcurrencyResolver(normalizedOptions, summary), + normalizedOptions.sleepFn, + ); + const policiesByKey = new Map(policyEntries); + const issues: RawCandidateIssue[] = []; + for (const item of searchItems) { + const target = targetFromSearchIssue(item, normalizedOptions.forge); + if (!target) continue; + const policy = policiesByKey.get(targetKey(target)); + if (!policy?.allowed) continue; + const normalizedIssue = normalizeIssue(target, item, policy.source); + if (normalizedIssue) issues.push(normalizedIssue); + } + + return { + issues, + rateLimitRemaining: summary.rateLimitRemaining, + rateLimitResetAt: summary.rateLimitResetAt, + warnings, + }; +} + +export async function searchCandidateIssues( + searchQuery: string, + githubToken: string, + options: FanoutOptions = {}, +): Promise { + const result = await searchCandidateIssuesWithSummary(searchQuery, githubToken, options); + return result.issues; +} diff --git a/packages/loopover-miner/lib/plan-store.d.ts b/packages/loopover-miner/lib/plan-store.d.ts index 3d3cddf98a..13d9fdc804 100644 --- a/packages/loopover-miner/lib/plan-store.d.ts +++ b/packages/loopover-miner/lib/plan-store.d.ts @@ -1,51 +1,44 @@ export type PlanStepStatus = "pending" | "running" | "completed" | "failed" | "skipped"; - export type PlanStep = { - id: string; - title: string; - actionClass?: string; - dependsOn: string[]; - status: PlanStepStatus; - attempts: number; - maxAttempts: number; - lastError?: string | null; + id: string; + title: string; + actionClass?: string; + dependsOn: string[]; + status: PlanStepStatus; + attempts: number; + maxAttempts: number; + lastError?: string | null; }; - export type PlanDag = { - steps: PlanStep[]; + steps: PlanStep[]; }; - export type PlanStatus = "pending" | "running" | "completed" | "failed"; - export type PlanRecord = { - planId: string; - plan: PlanDag; - status: PlanStatus; - updatedAt: string; + planId: string; + plan: PlanDag; + status: PlanStatus; + updatedAt: string; }; - export type ListPlansFilter = { - status?: PlanStatus | null; + status?: PlanStatus | null; }; - export type PlanStore = { - dbPath: string; - savePlan(planId: string, plan: PlanDag): PlanRecord; - loadPlan(planId: string): PlanRecord | null; - listPlans(filter?: ListPlansFilter): PlanRecord[]; - close(): void; + dbPath: string; + savePlan(planId: string, plan: PlanDag): PlanRecord; + loadPlan(planId: string): PlanRecord | null; + listPlans(filter?: ListPlansFilter): PlanRecord[]; + close(): void; }; - -export const PLAN_STATUSES: readonly PlanStatus[]; - -export function resolvePlanStoreDbPath(env?: Record): string; - -export function openPlanStore(dbPath?: string): PlanStore; - -export function savePlan(planId: string, plan: PlanDag): PlanRecord; - -export function loadPlan(planId: string): PlanRecord | null; - -export function listPlans(filter?: ListPlansFilter): PlanRecord[]; - -export function closeDefaultPlanStore(): void; +/** Derived plan-level status used for `listPlans({ status })`. */ +export declare const PLAN_STATUSES: readonly PlanStatus[]; +export declare function resolvePlanStoreDbPath(env?: Record): string; +/** + * Opens the local plan store, creating the table on first use. `savePlan` is a single atomic INSERT…ON CONFLICT + * upsert keyed by `plan_id`; the plan JSON is validated on save AND re-validated on load, so a corrupted row is + * rejected rather than silently returned. (#2318) + */ +export declare function openPlanStore(dbPath?: string): PlanStore; +export declare function savePlan(planId: string, plan: PlanDag): PlanRecord; +export declare function loadPlan(planId: string): PlanRecord | null; +export declare function listPlans(filter?: ListPlansFilter): PlanRecord[]; +export declare function closeDefaultPlanStore(): void; diff --git a/packages/loopover-miner/lib/plan-store.js b/packages/loopover-miner/lib/plan-store.js index 01274d8844..e5aca1dc86 100644 --- a/packages/loopover-miner/lib/plan-store.js +++ b/packages/loopover-miner/lib/plan-store.js @@ -1,153 +1,171 @@ import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; - -// Local SQLite persistence for the stateless MCP plan DAG (#2318). `loopover_build_plan`/`plan_status`/ -// `record_step_result` are stateless — the caller holds the plan and passes it back each call — so a miner running -// unattended across process restarts needs somewhere to persist the plan object between calls. This is local-only -// bookkeeping (no plan logic, no network), 100% client-side, mirroring the package's other local stores. Every -// plan is validated against the `planDagSchema` shape (src/mcp/server.ts) on BOTH save and load, so a corrupted -// local row fails loudly instead of feeding a malformed plan back into `loopover_plan_status`. - -const PLAN_STEP_STATUSES = Object.freeze(["pending", "running", "completed", "failed", "skipped"]); +const PLAN_STEP_STATUSES = Object.freeze([ + "pending", + "running", + "completed", + "failed", + "skipped", +]); /** Derived plan-level status used for `listPlans({ status })`. */ export const PLAN_STATUSES = Object.freeze(["pending", "running", "completed", "failed"]); - const stepStatusSet = new Set(PLAN_STEP_STATUSES); const planStatusSet = new Set(PLAN_STATUSES); const defaultDbFileName = "plan-store.sqlite3"; let defaultPlanStore = null; - export function resolvePlanStoreDbPath(env = process.env) { - return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_PLAN_STORE_DB", env); + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_PLAN_STORE_DB", env); } - function normalizeDbPath(dbPath) { - return normalizeLocalStoreDbPath(dbPath, resolvePlanStoreDbPath(), "invalid_plan_store_db_path"); + return normalizeLocalStoreDbPath(dbPath, resolvePlanStoreDbPath(), "invalid_plan_store_db_path"); } - function normalizePlanId(planId) { - if (typeof planId !== "string" || !planId.trim()) throw new Error("invalid_plan_id"); - return planId.trim(); + if (typeof planId !== "string" || !planId.trim()) + throw new Error("invalid_plan_id"); + return planId.trim(); } - function normalizePlanStatusFilter(status) { - if (status === undefined || status === null) return undefined; - if (!planStatusSet.has(status)) throw new Error("invalid_status"); - return status; + if (status === undefined || status === null) + return undefined; + if (!planStatusSet.has(status)) + throw new Error("invalid_status"); + return status; } - function isBoundedString(value, min, max) { - return typeof value === "string" && value.length >= min && value.length <= max; + return typeof value === "string" && value.length >= min && value.length <= max; } - function isBoundedInt(value, min, max) { - return Number.isInteger(value) && value >= min && value <= max; + return Number.isInteger(value) && value >= min && value <= max; } - const STEP_KEYS = new Set(["id", "title", "actionClass", "dependsOn", "status", "attempts", "maxAttempts", "lastError"]); - function isValidStep(step) { - if (!step || typeof step !== "object" || Array.isArray(step)) return false; - for (const key of Object.keys(step)) if (!STEP_KEYS.has(key)) return false; // strict: no unknown keys - if (!isBoundedString(step.id, 1, 100) || !isBoundedString(step.title, 1, 300)) return false; - if (step.actionClass !== undefined && !isBoundedString(step.actionClass, 1, 60)) return false; - if (!Array.isArray(step.dependsOn) || step.dependsOn.length > 50) return false; - if (!step.dependsOn.every((dep) => isBoundedString(dep, 1, 100))) return false; - if (!stepStatusSet.has(step.status)) return false; - if (!isBoundedInt(step.attempts, 0, Number.MAX_SAFE_INTEGER)) return false; - if (!isBoundedInt(step.maxAttempts, 1, 10)) return false; - if (step.lastError !== undefined && step.lastError !== null && !isBoundedString(step.lastError, 0, 2000)) return false; - return true; -} - + if (!step || typeof step !== "object" || Array.isArray(step)) + return false; + for (const key of Object.keys(step)) + if (!STEP_KEYS.has(key)) + return false; // strict: no unknown keys + const candidate = step; + if (!isBoundedString(candidate.id, 1, 100) || !isBoundedString(candidate.title, 1, 300)) + return false; + if (candidate.actionClass !== undefined && !isBoundedString(candidate.actionClass, 1, 60)) + return false; + if (!Array.isArray(candidate.dependsOn) || candidate.dependsOn.length > 50) + return false; + if (!candidate.dependsOn.every((dep) => isBoundedString(dep, 1, 100))) + return false; + if (!stepStatusSet.has(candidate.status)) + return false; + if (!isBoundedInt(candidate.attempts, 0, Number.MAX_SAFE_INTEGER)) + return false; + if (!isBoundedInt(candidate.maxAttempts, 1, 10)) + return false; + if (candidate.lastError !== undefined + && candidate.lastError !== null + && !isBoundedString(candidate.lastError, 0, 2000)) { + return false; + } + return true; +} /** Validate a plan against the `planDagSchema` shape (strict `{ steps: PlanStep[] }`, ≤100 steps). Throws on any * malformed field so a bad plan can neither be saved nor read back. */ function validatePlanDag(plan) { - if (!plan || typeof plan !== "object" || Array.isArray(plan)) throw new Error("invalid_plan"); - const keys = Object.keys(plan); - if (keys.length !== 1 || keys[0] !== "steps") throw new Error("invalid_plan"); - if (!Array.isArray(plan.steps) || plan.steps.length === 0 || plan.steps.length > 100) { - throw new Error("invalid_plan"); - } - if (!plan.steps.every(isValidStep)) throw new Error("invalid_plan"); - const seenStepIds = new Set(); - for (const step of plan.steps) { - if (seenStepIds.has(step.id)) throw new Error("invalid_plan"); - seenStepIds.add(step.id); - } - for (const step of plan.steps) { - for (const dep of step.dependsOn) { - if (dep === step.id || !seenStepIds.has(dep)) throw new Error("invalid_plan"); + if (!plan || typeof plan !== "object" || Array.isArray(plan)) + throw new Error("invalid_plan"); + const keys = Object.keys(plan); + if (keys.length !== 1 || keys[0] !== "steps") + throw new Error("invalid_plan"); + const candidate = plan; + if (!Array.isArray(candidate.steps) || candidate.steps.length === 0 || candidate.steps.length > 100) { + throw new Error("invalid_plan"); + } + if (!candidate.steps.every(isValidStep)) + throw new Error("invalid_plan"); + const steps = candidate.steps; + const seenStepIds = new Set(); + for (const step of steps) { + if (seenStepIds.has(step.id)) + throw new Error("invalid_plan"); + seenStepIds.add(step.id); } - } - const color = new Map(); - const byId = new Map(plan.steps.map((step) => [step.id, step])); - const hasCycle = (id) => { - color.set(id, 1); - for (const dep of byId.get(id)?.dependsOn ?? []) { - const depColor = color.get(dep) ?? 0; - if (depColor === 1) return true; - if (depColor === 0 && byId.has(dep) && hasCycle(dep)) return true; + for (const step of steps) { + for (const dep of step.dependsOn) { + if (dep === step.id || !seenStepIds.has(dep)) + throw new Error("invalid_plan"); + } } - color.set(id, 2); - return false; - }; - for (const step of plan.steps) { - if ((color.get(step.id) ?? 0) === 0 && hasCycle(step.id)) { - throw new Error("invalid_plan"); + const color = new Map(); + const byId = new Map(steps.map((step) => [step.id, step])); + const hasCycle = (id) => { + color.set(id, 1); + // Non-null: every `id` this is called with is either a step's own id (inserted into `byId` above) or a + // `dep` whose `byId.has(dep)` was just confirmed true by the caller -- `byId.get(id)` can never miss here. + for (const dep of byId.get(id).dependsOn) { + const depColor = color.get(dep) ?? 0; + if (depColor === 1) + return true; + if (depColor === 0 && byId.has(dep) && hasCycle(dep)) + return true; + } + color.set(id, 2); + return false; + }; + for (const step of steps) { + if ((color.get(step.id) ?? 0) === 0 && hasCycle(step.id)) { + throw new Error("invalid_plan"); + } } - } - return plan; + return { steps }; } - /** Derive a plan-level status from its steps: any failed → failed; else any running → running; else all steps * finished (completed/skipped) with at least one step → completed; otherwise pending. */ function computePlanStatus(plan) { - const steps = plan.steps; - if (steps.some((step) => step.status === "failed")) return "failed"; - if (steps.some((step) => step.status === "running")) return "running"; - if (steps.length > 0 && steps.every((step) => step.status === "completed" || step.status === "skipped")) { - return "completed"; - } - return "pending"; -} - + const steps = plan.steps; + if (steps.some((step) => step.status === "failed")) + return "failed"; + if (steps.some((step) => step.status === "running")) + return "running"; + if (steps.length > 0 && steps.every((step) => step.status === "completed" || step.status === "skipped")) { + return "completed"; + } + return "pending"; +} function rowToRecord(row) { - let plan; - try { - plan = validatePlanDag(JSON.parse(row.plan_json)); - } catch { - throw new Error("corrupted_plan_row"); // stored blob no longer matches the plan shape - } - // Also fail closed on the status column: a manually-edited or legacy row (predating the CHECK constraint) could - // hold a status outside PLAN_STATUSES, which would otherwise violate the exported PlanRecord contract on read. - if (!planStatusSet.has(row.status)) throw new Error("corrupted_plan_row"); - return { planId: row.plan_id, plan, status: row.status, updatedAt: row.updated_at }; -} - + let plan; + try { + plan = validatePlanDag(JSON.parse(row.plan_json)); + } + catch { + throw new Error("corrupted_plan_row"); // stored blob no longer matches the plan shape + } + // Also fail closed on the status column: a manually-edited or legacy row (predating the CHECK constraint) could + // hold a status outside PLAN_STATUSES, which would otherwise violate the exported PlanRecord contract on read. + if (!planStatusSet.has(row.status)) + throw new Error("corrupted_plan_row"); + return { planId: row.plan_id, plan, status: row.status, updatedAt: row.updated_at }; +} // v1 -> v2 (#4939/#6597): additive tenant-scoping column, a prerequisite for any hosted, multi-tenant use of // this same store's logic. NULL for every row today -- self-host behavior is byte-identical, since nothing // reads or writes it yet. Same defensive column-presence guard as this file's sibling stores' own additive // migrations (e.g. event-ledger.js's addTenantIdColumn). function addTenantIdColumn(db) { - const hasTenantIdColumn = db - .prepare("PRAGMA table_info(miner_plans)") - .all() - .some((column) => column.name === "tenant_id"); - if (!hasTenantIdColumn) db.exec("ALTER TABLE miner_plans ADD COLUMN tenant_id TEXT"); + const hasTenantIdColumn = db + .prepare("PRAGMA table_info(miner_plans)") + .all() + .some((column) => column.name === "tenant_id"); + if (!hasTenantIdColumn) + db.exec("ALTER TABLE miner_plans ADD COLUMN tenant_id TEXT"); } - /** * Opens the local plan store, creating the table on first use. `savePlan` is a single atomic INSERT…ON CONFLICT * upsert keyed by `plan_id`; the plan JSON is validated on save AND re-validated on load, so a corrupted row is * rejected rather than silently returned. (#2318) */ export function openPlanStore(dbPath = resolvePlanStoreDbPath()) { - const resolvedPath = normalizeDbPath(dbPath); - // openLocalStoreDb centralizes the mkdir(0o700)/chmod(0o600)/busy_timeout + crash-safe cleanup registration and - // treats ':memory:' as a no-file special case, so this store no longer hand-rolls that boilerplate (#4826). - const db = openLocalStoreDb(resolvedPath); - db.exec(` + const resolvedPath = normalizeDbPath(dbPath); + // openLocalStoreDb centralizes the mkdir(0o700)/chmod(0o600)/busy_timeout + crash-safe cleanup registration and + // treats ':memory:' as a no-file special case, so this store no longer hand-rolls that boilerplate (#4826). + const db = openLocalStoreDb(resolvedPath); + db.exec(` CREATE TABLE IF NOT EXISTS miner_plans ( plan_id TEXT PRIMARY KEY, plan_json TEXT NOT NULL, @@ -155,10 +173,9 @@ export function openPlanStore(dbPath = resolvePlanStoreDbPath()) { updated_at TEXT NOT NULL ) `); - // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations. - applySchemaMigrations(db, [addTenantIdColumn]); - - const saveStatement = db.prepare(` + // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations. + applySchemaMigrations(db, [addTenantIdColumn]); + const saveStatement = db.prepare(` INSERT INTO miner_plans (plan_id, plan_json, status, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(plan_id) DO UPDATE SET @@ -166,56 +183,50 @@ export function openPlanStore(dbPath = resolvePlanStoreDbPath()) { status = excluded.status, updated_at = excluded.updated_at `); - const getStatement = db.prepare("SELECT * FROM miner_plans WHERE plan_id = ?"); - const listAllStatement = db.prepare("SELECT * FROM miner_plans ORDER BY plan_id ASC"); - const listStatusStatement = db.prepare("SELECT * FROM miner_plans WHERE status = ? ORDER BY plan_id ASC"); - - return { - dbPath: resolvedPath, - savePlan(planId, plan) { - const id = normalizePlanId(planId); - validatePlanDag(plan); - const status = computePlanStatus(plan); - const updatedAt = new Date().toISOString(); - saveStatement.run(id, JSON.stringify(plan), status, updatedAt); - return { planId: id, plan, status, updatedAt }; - }, - loadPlan(planId) { - const row = getStatement.get(normalizePlanId(planId)); - return row ? rowToRecord(row) : null; - }, - listPlans(filter = {}) { - const status = normalizePlanStatusFilter(filter.status); - if (status !== undefined) { - return listStatusStatement.all(status).map(rowToRecord); - } - return listAllStatement.all().map(rowToRecord); - }, - close() { - db.close(); - }, - }; -} - + const getStatement = db.prepare("SELECT * FROM miner_plans WHERE plan_id = ?"); + const listAllStatement = db.prepare("SELECT * FROM miner_plans ORDER BY plan_id ASC"); + const listStatusStatement = db.prepare("SELECT * FROM miner_plans WHERE status = ? ORDER BY plan_id ASC"); + return { + dbPath: resolvedPath, + savePlan(planId, plan) { + const id = normalizePlanId(planId); + validatePlanDag(plan); + const status = computePlanStatus(plan); + const updatedAt = new Date().toISOString(); + saveStatement.run(id, JSON.stringify(plan), status, updatedAt); + return { planId: id, plan, status, updatedAt }; + }, + loadPlan(planId) { + const row = getStatement.get(normalizePlanId(planId)); + return row ? rowToRecord(row) : null; + }, + listPlans(filter = {}) { + const status = normalizePlanStatusFilter(filter.status); + const rows = (status !== undefined ? listStatusStatement.all(status) : listAllStatement.all()); + return rows.map(rowToRecord); + }, + close() { + db.close(); + }, + }; +} function getDefaultPlanStore() { - defaultPlanStore ??= openPlanStore(); - return defaultPlanStore; + defaultPlanStore ??= openPlanStore(); + return defaultPlanStore; } - export function savePlan(planId, plan) { - return getDefaultPlanStore().savePlan(planId, plan); + return getDefaultPlanStore().savePlan(planId, plan); } - export function loadPlan(planId) { - return getDefaultPlanStore().loadPlan(planId); + return getDefaultPlanStore().loadPlan(planId); } - export function listPlans(filter) { - return getDefaultPlanStore().listPlans(filter); + return getDefaultPlanStore().listPlans(filter); } - export function closeDefaultPlanStore() { - if (!defaultPlanStore) return; - defaultPlanStore.close(); - defaultPlanStore = null; + if (!defaultPlanStore) + return; + defaultPlanStore.close(); + defaultPlanStore = null; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicGxhbi1zdG9yZS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInBsYW4tc3RvcmUudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQ0EsT0FBTyxFQUFFLHlCQUF5QixFQUFFLGdCQUFnQixFQUFFLHVCQUF1QixFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFDeEcsT0FBTyxFQUFFLHFCQUFxQixFQUFFLE1BQU0scUJBQXFCLENBQUM7QUErQzVELE1BQU0sa0JBQWtCLEdBQThCLE1BQU0sQ0FBQyxNQUFNLENBQUM7SUFDbEUsU0FBUztJQUNULFNBQVM7SUFDVCxXQUFXO0lBQ1gsUUFBUTtJQUNSLFNBQVM7Q0FDVixDQUFDLENBQUM7QUFDSCxrRUFBa0U7QUFDbEUsTUFBTSxDQUFDLE1BQU0sYUFBYSxHQUEwQixNQUFNLENBQUMsTUFBTSxDQUFDLENBQUMsU0FBUyxFQUFFLFNBQVMsRUFBRSxXQUFXLEVBQUUsUUFBUSxDQUFDLENBQUMsQ0FBQztBQUVqSCxNQUFNLGFBQWEsR0FBZ0IsSUFBSSxHQUFHLENBQUMsa0JBQWtCLENBQUMsQ0FBQztBQUMvRCxNQUFNLGFBQWEsR0FBZ0IsSUFBSSxHQUFHLENBQUMsYUFBYSxDQUFDLENBQUM7QUFDMUQsTUFBTSxpQkFBaUIsR0FBRyxvQkFBb0IsQ0FBQztBQUMvQyxJQUFJLGdCQUFnQixHQUFxQixJQUFJLENBQUM7QUFFOUMsTUFBTSxVQUFVLHNCQUFzQixDQUFDLE1BQTBDLE9BQU8sQ0FBQyxHQUFHO0lBQzFGLE9BQU8sdUJBQXVCLENBQUMsaUJBQWlCLEVBQUUsOEJBQThCLEVBQUUsR0FBRyxDQUFDLENBQUM7QUFDekYsQ0FBQztBQUVELFNBQVMsZUFBZSxDQUFDLE1BQWlDO0lBQ3hELE9BQU8seUJBQXlCLENBQUMsTUFBTSxFQUFFLHNCQUFzQixFQUFFLEVBQUUsNEJBQTRCLENBQUMsQ0FBQztBQUNuRyxDQUFDO0FBRUQsU0FBUyxlQUFlLENBQUMsTUFBZTtJQUN0QyxJQUFJLE9BQU8sTUFBTSxLQUFLLFFB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/plan-store.ts b/packages/loopover-miner/lib/plan-store.ts new file mode 100644 index 0000000000..64236a7405 --- /dev/null +++ b/packages/loopover-miner/lib/plan-store.ts @@ -0,0 +1,279 @@ +import type { DatabaseSync } from "node:sqlite"; +import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; +import { applySchemaMigrations } from "./schema-version.js"; + +// Local SQLite persistence for the stateless MCP plan DAG (#2318). `loopover_build_plan`/`plan_status`/ +// `record_step_result` are stateless — the caller holds the plan and passes it back each call — so a miner running +// unattended across process restarts needs somewhere to persist the plan object between calls. This is local-only +// bookkeeping (no plan logic, no network), 100% client-side, mirroring the package's other local stores. Every +// plan is validated against the `planDagSchema` shape (src/mcp/server.ts) on BOTH save and load, so a corrupted +// local row fails loudly instead of feeding a malformed plan back into `loopover_plan_status`. + +export type PlanStepStatus = "pending" | "running" | "completed" | "failed" | "skipped"; + +export type PlanStep = { + id: string; + title: string; + actionClass?: string; + dependsOn: string[]; + status: PlanStepStatus; + attempts: number; + maxAttempts: number; + lastError?: string | null; +}; + +export type PlanDag = { + steps: PlanStep[]; +}; + +export type PlanStatus = "pending" | "running" | "completed" | "failed"; + +export type PlanRecord = { + planId: string; + plan: PlanDag; + status: PlanStatus; + updatedAt: string; +}; + +export type ListPlansFilter = { + status?: PlanStatus | null; +}; + +export type PlanStore = { + dbPath: string; + savePlan(planId: string, plan: PlanDag): PlanRecord; + loadPlan(planId: string): PlanRecord | null; + listPlans(filter?: ListPlansFilter): PlanRecord[]; + close(): void; +}; + +const PLAN_STEP_STATUSES: readonly PlanStepStatus[] = Object.freeze([ + "pending", + "running", + "completed", + "failed", + "skipped", +]); +/** Derived plan-level status used for `listPlans({ status })`. */ +export const PLAN_STATUSES: readonly PlanStatus[] = Object.freeze(["pending", "running", "completed", "failed"]); + +const stepStatusSet: Set = new Set(PLAN_STEP_STATUSES); +const planStatusSet: Set = new Set(PLAN_STATUSES); +const defaultDbFileName = "plan-store.sqlite3"; +let defaultPlanStore: PlanStore | null = null; + +export function resolvePlanStoreDbPath(env: Record = process.env): string { + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_PLAN_STORE_DB", env); +} + +function normalizeDbPath(dbPath: string | null | undefined): string { + return normalizeLocalStoreDbPath(dbPath, resolvePlanStoreDbPath(), "invalid_plan_store_db_path"); +} + +function normalizePlanId(planId: unknown): string { + if (typeof planId !== "string" || !planId.trim()) throw new Error("invalid_plan_id"); + return planId.trim(); +} + +function normalizePlanStatusFilter(status: PlanStatus | null | undefined): PlanStatus | undefined { + if (status === undefined || status === null) return undefined; + if (!planStatusSet.has(status)) throw new Error("invalid_status"); + return status; +} + +function isBoundedString(value: unknown, min: number, max: number): value is string { + return typeof value === "string" && value.length >= min && value.length <= max; +} + +function isBoundedInt(value: unknown, min: number, max: number): value is number { + return Number.isInteger(value) && (value as number) >= min && (value as number) <= max; +} + +const STEP_KEYS = new Set(["id", "title", "actionClass", "dependsOn", "status", "attempts", "maxAttempts", "lastError"]); + +function isValidStep(step: unknown): step is PlanStep { + if (!step || typeof step !== "object" || Array.isArray(step)) return false; + for (const key of Object.keys(step)) if (!STEP_KEYS.has(key)) return false; // strict: no unknown keys + const candidate = step as Record; + if (!isBoundedString(candidate.id, 1, 100) || !isBoundedString(candidate.title, 1, 300)) return false; + if (candidate.actionClass !== undefined && !isBoundedString(candidate.actionClass, 1, 60)) return false; + if (!Array.isArray(candidate.dependsOn) || candidate.dependsOn.length > 50) return false; + if (!candidate.dependsOn.every((dep: unknown) => isBoundedString(dep, 1, 100))) return false; + if (!stepStatusSet.has(candidate.status as string)) return false; + if (!isBoundedInt(candidate.attempts, 0, Number.MAX_SAFE_INTEGER)) return false; + if (!isBoundedInt(candidate.maxAttempts, 1, 10)) return false; + if ( + candidate.lastError !== undefined + && candidate.lastError !== null + && !isBoundedString(candidate.lastError, 0, 2000) + ) { + return false; + } + return true; +} + +/** Validate a plan against the `planDagSchema` shape (strict `{ steps: PlanStep[] }`, ≤100 steps). Throws on any + * malformed field so a bad plan can neither be saved nor read back. */ +function validatePlanDag(plan: unknown): PlanDag { + if (!plan || typeof plan !== "object" || Array.isArray(plan)) throw new Error("invalid_plan"); + const keys = Object.keys(plan); + if (keys.length !== 1 || keys[0] !== "steps") throw new Error("invalid_plan"); + const candidate = plan as { steps: unknown }; + if (!Array.isArray(candidate.steps) || candidate.steps.length === 0 || candidate.steps.length > 100) { + throw new Error("invalid_plan"); + } + if (!candidate.steps.every(isValidStep)) throw new Error("invalid_plan"); + const steps = candidate.steps as PlanStep[]; + const seenStepIds = new Set(); + for (const step of steps) { + if (seenStepIds.has(step.id)) throw new Error("invalid_plan"); + seenStepIds.add(step.id); + } + for (const step of steps) { + for (const dep of step.dependsOn) { + if (dep === step.id || !seenStepIds.has(dep)) throw new Error("invalid_plan"); + } + } + const color = new Map(); + const byId = new Map(steps.map((step) => [step.id, step])); + const hasCycle = (id: string): boolean => { + color.set(id, 1); + // Non-null: every `id` this is called with is either a step's own id (inserted into `byId` above) or a + // `dep` whose `byId.has(dep)` was just confirmed true by the caller -- `byId.get(id)` can never miss here. + for (const dep of byId.get(id)!.dependsOn) { + const depColor = color.get(dep) ?? 0; + if (depColor === 1) return true; + if (depColor === 0 && byId.has(dep) && hasCycle(dep)) return true; + } + color.set(id, 2); + return false; + }; + for (const step of steps) { + if ((color.get(step.id) ?? 0) === 0 && hasCycle(step.id)) { + throw new Error("invalid_plan"); + } + } + return { steps }; +} + +/** Derive a plan-level status from its steps: any failed → failed; else any running → running; else all steps + * finished (completed/skipped) with at least one step → completed; otherwise pending. */ +function computePlanStatus(plan: PlanDag): PlanStatus { + const steps = plan.steps; + if (steps.some((step) => step.status === "failed")) return "failed"; + if (steps.some((step) => step.status === "running")) return "running"; + if (steps.length > 0 && steps.every((step) => step.status === "completed" || step.status === "skipped")) { + return "completed"; + } + return "pending"; +} + +function rowToRecord(row: { plan_id: string; plan_json: string; status: string; updated_at: string }): PlanRecord { + let plan: PlanDag; + try { + plan = validatePlanDag(JSON.parse(row.plan_json)); + } catch { + throw new Error("corrupted_plan_row"); // stored blob no longer matches the plan shape + } + // Also fail closed on the status column: a manually-edited or legacy row (predating the CHECK constraint) could + // hold a status outside PLAN_STATUSES, which would otherwise violate the exported PlanRecord contract on read. + if (!planStatusSet.has(row.status)) throw new Error("corrupted_plan_row"); + return { planId: row.plan_id, plan, status: row.status as PlanStatus, updatedAt: row.updated_at }; +} + +// v1 -> v2 (#4939/#6597): additive tenant-scoping column, a prerequisite for any hosted, multi-tenant use of +// this same store's logic. NULL for every row today -- self-host behavior is byte-identical, since nothing +// reads or writes it yet. Same defensive column-presence guard as this file's sibling stores' own additive +// migrations (e.g. event-ledger.js's addTenantIdColumn). +function addTenantIdColumn(db: DatabaseSync): void { + const hasTenantIdColumn = db + .prepare("PRAGMA table_info(miner_plans)") + .all() + .some((column) => (column as { name: string }).name === "tenant_id"); + if (!hasTenantIdColumn) db.exec("ALTER TABLE miner_plans ADD COLUMN tenant_id TEXT"); +} + +/** + * Opens the local plan store, creating the table on first use. `savePlan` is a single atomic INSERT…ON CONFLICT + * upsert keyed by `plan_id`; the plan JSON is validated on save AND re-validated on load, so a corrupted row is + * rejected rather than silently returned. (#2318) + */ +export function openPlanStore(dbPath: string = resolvePlanStoreDbPath()): PlanStore { + const resolvedPath = normalizeDbPath(dbPath); + // openLocalStoreDb centralizes the mkdir(0o700)/chmod(0o600)/busy_timeout + crash-safe cleanup registration and + // treats ':memory:' as a no-file special case, so this store no longer hand-rolls that boilerplate (#4826). + const db = openLocalStoreDb(resolvedPath); + db.exec(` + CREATE TABLE IF NOT EXISTS miner_plans ( + plan_id TEXT PRIMARY KEY, + plan_json TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'completed', 'failed')), + updated_at TEXT NOT NULL + ) + `); + // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations. + applySchemaMigrations(db, [addTenantIdColumn]); + + const saveStatement = db.prepare(` + INSERT INTO miner_plans (plan_id, plan_json, status, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(plan_id) DO UPDATE SET + plan_json = excluded.plan_json, + status = excluded.status, + updated_at = excluded.updated_at + `); + const getStatement = db.prepare("SELECT * FROM miner_plans WHERE plan_id = ?"); + const listAllStatement = db.prepare("SELECT * FROM miner_plans ORDER BY plan_id ASC"); + const listStatusStatement = db.prepare("SELECT * FROM miner_plans WHERE status = ? ORDER BY plan_id ASC"); + + return { + dbPath: resolvedPath, + savePlan(planId: string, plan: PlanDag): PlanRecord { + const id = normalizePlanId(planId); + validatePlanDag(plan); + const status = computePlanStatus(plan); + const updatedAt = new Date().toISOString(); + saveStatement.run(id, JSON.stringify(plan), status, updatedAt); + return { planId: id, plan, status, updatedAt }; + }, + loadPlan(planId: string): PlanRecord | null { + const row = getStatement.get(normalizePlanId(planId)) as + | { plan_id: string; plan_json: string; status: string; updated_at: string } + | undefined; + return row ? rowToRecord(row) : null; + }, + listPlans(filter: ListPlansFilter = {}): PlanRecord[] { + const status = normalizePlanStatusFilter(filter.status); + const rows = ( + status !== undefined ? listStatusStatement.all(status) : listAllStatement.all() + ) as { plan_id: string; plan_json: string; status: string; updated_at: string }[]; + return rows.map(rowToRecord); + }, + close() { + db.close(); + }, + }; +} + +function getDefaultPlanStore(): PlanStore { + defaultPlanStore ??= openPlanStore(); + return defaultPlanStore; +} + +export function savePlan(planId: string, plan: PlanDag): PlanRecord { + return getDefaultPlanStore().savePlan(planId, plan); +} + +export function loadPlan(planId: string): PlanRecord | null { + return getDefaultPlanStore().loadPlan(planId); +} + +export function listPlans(filter?: ListPlansFilter): PlanRecord[] { + return getDefaultPlanStore().listPlans(filter); +} + +export function closeDefaultPlanStore() { + if (!defaultPlanStore) return; + defaultPlanStore.close(); + defaultPlanStore = null; +} diff --git a/packages/loopover-miner/lib/rejection-templates.d.ts b/packages/loopover-miner/lib/rejection-templates.d.ts index 62bced6f5b..bbf51de490 100644 --- a/packages/loopover-miner/lib/rejection-templates.d.ts +++ b/packages/loopover-miner/lib/rejection-templates.d.ts @@ -1,12 +1,24 @@ export type RejectionReason = "gate_close" | "maintainer_close_no_reason" | "superseded_by_duplicate"; - export type RejectionContext = { - repoFullName: string; - prNumber: number; + repoFullName: string; + prNumber: number; }; - -export const REJECTION_REASONS: readonly RejectionReason[]; - -export function containsPrivateLanguage(text: string): boolean; - -export function renderRejectionMessage(reason: RejectionReason, context: RejectionContext): string; +/** The supported rejection-reason buckets, in declaration order. */ +export declare const REJECTION_REASONS: readonly RejectionReason[]; +/** True when the given text contains any banned private-language token. */ +export declare function containsPrivateLanguage(text: string): boolean; +/** + * Substitute every `{placeholder}` token in `template` from `values`, throwing rather than emitting a + * half-rendered note if any placeholder is unmapped (`missing_placeholder:`) or if a substituted value's + * own text still leaves what looks like an unresolved placeholder in the output (`unresolved_placeholder`) -- + * defense-in-depth against a future template-authoring bug, not a route `renderRejectionMessage` itself can hit + * today given the fixed {@link REJECTION_REASONS} vocabulary (exported so both guards are directly unit-testable + * without needing a malformed production template). Pure and deterministic. + */ +export declare function resolvePlaceholders(template: string, values: Record): string; +/** + * Render the courtesy note for a closed/rejected PR. `reason` must be one of {@link REJECTION_REASONS}; `context` + * supplies `repoFullName` (`owner/repo`) and `prNumber` (a positive integer). Throws on an unknown reason or a + * malformed context. Pure and deterministic. + */ +export declare function renderRejectionMessage(reason: RejectionReason, context: RejectionContext): string; diff --git a/packages/loopover-miner/lib/rejection-templates.js b/packages/loopover-miner/lib/rejection-templates.js index bfb192bf95..57d5b6da0d 100644 --- a/packages/loopover-miner/lib/rejection-templates.js +++ b/packages/loopover-miner/lib/rejection-templates.js @@ -3,69 +3,74 @@ // write action, out of scope here). The note must be courteous, non-defensive, and never re-litigate the // maintainer's decision. This module is pure content/formatting: static template strings + a deterministic // renderer — no GitHub calls, no LLM, no network. Same inputs always render the same message. - // Templates keyed by rejection-reason bucket. Every placeholder is `{name}`; the renderer resolves the structured // context (a PR number + a repo) and never interpolates free-form/private text. const REASON_TEMPLATES = { - gate_close: - "The automated review gate closed PR #{prNumber} on {repoFullName}. Thanks for the review — I'll address the flagged points and open a fresh PR if the change still fits.", - maintainer_close_no_reason: - "PR #{prNumber} on {repoFullName} was closed by the maintainer. Thanks for taking the time to look — I'll leave it here unless you'd like me to revisit it.", - superseded_by_duplicate: - "PR #{prNumber} on {repoFullName} looks superseded by other work on the same issue, so I'm closing it on my side to avoid duplication. Thanks to whoever is carrying it forward.", + gate_close: "The automated review gate closed PR #{prNumber} on {repoFullName}. Thanks for the review — I'll address the flagged points and open a fresh PR if the change still fits.", + maintainer_close_no_reason: "PR #{prNumber} on {repoFullName} was closed by the maintainer. Thanks for taking the time to look — I'll leave it here unless you'd like me to revisit it.", + superseded_by_duplicate: "PR #{prNumber} on {repoFullName} looks superseded by other work on the same issue, so I'm closing it on my side to avoid duplication. Thanks to whoever is carrying it forward.", }; - /** The supported rejection-reason buckets, in declaration order. */ export const REJECTION_REASONS = Object.freeze(Object.keys(REASON_TEMPLATES)); - // Private-language tokens that must never surface in a public-facing courtesy note (mirrors the redaction set in // `sanitizePublicComment`, src/github/commands.ts). Templates are authored clean and this is asserted in tests; // the structured context (a PR number + a validated `owner/repo`) carries no private scoring/reward/wallet data, // so — deliberately — no value-level redaction is applied that could mangle a legitimate repo name. -const PRIVATE_LANGUAGE = - /\b(?:raw trust scores?|trust scores?|wallets?|hotkeys?|coldkeys?|seed phrases?|mnemonics?|payouts?|rewards?)\b/i; - +const PRIVATE_LANGUAGE = /\b(?:raw trust scores?|trust scores?|wallets?|hotkeys?|coldkeys?|seed phrases?|mnemonics?|payouts?|rewards?)\b/i; /** True when the given text contains any banned private-language token. */ export function containsPrivateLanguage(text) { - return PRIVATE_LANGUAGE.test(text); + return PRIVATE_LANGUAGE.test(text); } - // A GitHub `owner/repo`: owner is 1-39 chars of alphanumerics/hyphens starting alphanumeric; repo is // alphanumerics/`.`/`_`/`-`. Anchored + character-class-restricted so control characters, whitespace, markup, or an // extra `/` (e.g. `owner/repo\nextra`, `owner/`) are rejected — the note interpolates this text directly, so a // malformed value must throw rather than leak caller-controlled display text. const GITHUB_FULL_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/; - function normalizeRepoFullName(repoFullName) { - if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); - const trimmed = repoFullName.trim(); - if (!GITHUB_FULL_NAME.test(trimmed)) throw new Error("invalid_repo_full_name"); - return trimmed; + if (typeof repoFullName !== "string") + throw new Error("invalid_repo_full_name"); + const trimmed = repoFullName.trim(); + if (!GITHUB_FULL_NAME.test(trimmed)) + throw new Error("invalid_repo_full_name"); + return trimmed; } - function normalizePrNumber(prNumber) { - if (!Number.isInteger(prNumber) || prNumber < 1) throw new Error("invalid_pr_number"); - return prNumber; + if (!Number.isInteger(prNumber) || prNumber < 1) + throw new Error("invalid_pr_number"); + return prNumber; +} +/** + * Substitute every `{placeholder}` token in `template` from `values`, throwing rather than emitting a + * half-rendered note if any placeholder is unmapped (`missing_placeholder:`) or if a substituted value's + * own text still leaves what looks like an unresolved placeholder in the output (`unresolved_placeholder`) -- + * defense-in-depth against a future template-authoring bug, not a route `renderRejectionMessage` itself can hit + * today given the fixed {@link REJECTION_REASONS} vocabulary (exported so both guards are directly unit-testable + * without needing a malformed production template). Pure and deterministic. + */ +export function resolvePlaceholders(template, values) { + const rendered = template.replace(/\{(\w+)\}/g, (_match, key) => { + const value = values[key]; + if (value === undefined) + throw new Error(`missing_placeholder:${key}`); + return String(value); + }); + if (/\{[^}]+\}/.test(rendered)) + throw new Error("unresolved_placeholder"); + return rendered; } - /** * Render the courtesy note for a closed/rejected PR. `reason` must be one of {@link REJECTION_REASONS}; `context` - * supplies `repoFullName` (`owner/repo`) and `prNumber` (a positive integer). Throws on an unknown reason, a - * malformed context, or (defensively) any placeholder a template leaves unresolved — so a caller can never emit a - * half-rendered note. Pure and deterministic. + * supplies `repoFullName` (`owner/repo`) and `prNumber` (a positive integer). Throws on an unknown reason or a + * malformed context. Pure and deterministic. */ -export function renderRejectionMessage(reason, context = {}) { - const template = REASON_TEMPLATES[reason]; - if (template === undefined) throw new Error("invalid_rejection_reason"); - const values = { - repoFullName: normalizeRepoFullName(context.repoFullName), - prNumber: normalizePrNumber(context.prNumber), - }; - const rendered = template.replace(/\{(\w+)\}/g, (_match, key) => { - const value = values[key]; - if (value === undefined) throw new Error(`missing_placeholder:${key}`); - return String(value); - }); - if (/\{[^}]+\}/.test(rendered)) throw new Error("unresolved_placeholder"); - return rendered; +export function renderRejectionMessage(reason, context) { + const template = REASON_TEMPLATES[reason]; + if (template === undefined) + throw new Error("invalid_rejection_reason"); + const values = { + repoFullName: normalizeRepoFullName(context.repoFullName), + prNumber: normalizePrNumber(context.prNumber), + }; + return resolvePlaceholders(template, values); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmVqZWN0aW9uLXRlbXBsYXRlcy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInJlamVjdGlvbi10ZW1wbGF0ZXMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsa0hBQWtIO0FBQ2xILG1IQUFtSDtBQUNuSCx5R0FBeUc7QUFDekcsMkdBQTJHO0FBQzNHLDhGQUE4RjtBQVM5RixrSEFBa0g7QUFDbEgsZ0ZBQWdGO0FBQ2hGLE1BQU0sZ0JBQWdCLEdBQW9DO0lBQ3hELFVBQVUsRUFDUiwwS0FBMEs7SUFDNUssMEJBQTBCLEVBQ3hCLDRKQUE0SjtJQUM5Six1QkFBdUIsRUFDckIsaUxBQWlMO0NBQ3BMLENBQUM7QUFFRixvRUFBb0U7QUFDcEUsTUFBTSxDQUFDLE1BQU0saUJBQWlCLEdBQStCLE1BQU0sQ0FBQyxNQUFNLENBQ3hFLE1BQU0sQ0FBQyxJQUFJLENBQUMsZ0JBQWdCLENBQXNCLENBQ25ELENBQUM7QUFFRixpSEFBaUg7QUFDakgsZ0hBQWdIO0FBQ2hILGlIQUFpSDtBQUNqSCxvR0FBb0c7QUFDcEcsTUFBTSxnQkFBZ0IsR0FDcEIsaUhBQWlILENBQUM7QUFFcEgsMkVBQTJFO0FBQzNFLE1BQU0sVUFBVSx1QkFBdUIsQ0FBQyxJQUFZO0lBQ2xELE9BQU8sZ0JBQWdCLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxDQUFDO0FBQ3JDLENBQUM7QUFFRCxxR0FBcUc7QUFDckcsb0hBQW9IO0FBQ3BILHFIQUFxSDtBQUNySCw4RUFBOEU7QUFDOUUsTUFBTSxnQkFBZ0IsR0FBRyw0REFBNEQsQ0FBQztBQUV0RixTQUFTLHFCQUFxQixDQUFDLFlBQXFCO0lBQ2xELElBQUksT0FBTyxZQUFZLEtBQUssUUFBUTtRQUFFLE1BQU0sSUFBSSxLQUFLLENBQUMsd0JBQXdCLENBQUMsQ0FBQztJQUNoRixNQUFNLE9BQU8sR0FBRyxZQUFZLENBQUMsSUFBSSxFQUFFLENBQUM7SUFDcEMsSUFBSSxDQUFDLGdCQUFnQixDQUFDLElBQUksQ0FBQyxPQUFPLENBQUM7UUFBRSxNQUFNLElBQUksS0FBSyxDQUFDLHdCQUF3QixDQUFDLENBQUM7SUFDL0UsT0FBTyxPQUFPLENBQUM7QUFDakIsQ0FBQztBQUVELFNBQVMsaUJBQWlCLENBQUMsUUFBaUI7SUFDMUMsSUFBSSxDQUFDLE1BQU0sQ0FBQyxTQUFTLENBQUMsUUFBUSxDQUFDLElBQUssUUFBbUIsR0FBRyxDQUFDO1FBQUUsTUFBTSxJQUFJLEtBQUssQ0FBQyxtQkFBbUIsQ0FBQyxDQUFDO0lBQ2xHLE9BQU8sUUFBa0IsQ0FBQztBQUM1QixDQUFDO0FBRUQ7Ozs7Ozs7R0FPRztBQUNILE1BQU0sVUFBVSxtQkFBbUIsQ0FBQyxRQUFnQixFQUFFLE1BQXVDO0lBQzNGLE1BQU0sUUFBUSxHQUFHLFFBQVEsQ0FBQyxPQUFPLENBQUMsWUFBWSxFQUFFLENBQUMsTUFBTSxFQUFFLEdBQUcsRUFBRSxFQUFFO1FBQzlELE1BQU0sS0FBSyxHQUFHLE1BQU0sQ0FBQyxHQUFHLENBQUMsQ0FBQztRQUMxQixJQUFJLEtBQUssS0FBSyxTQUFTO1lBQUUsTUFBTSxJQUFJLEtBQUssQ0FBQyx1QkFBdUIsR0FBRyxFQUFFLENBQUMsQ0FBQztRQUN2RSxPQUFPLE1BQU0sQ0FBQyxLQUFLLENBQUMsQ0FBQztJQUN2QixDQUFDLENBQUMsQ0FBQztJQUNILElBQUksV0FBVyxDQUFDLElBQUksQ0FBQyxRQUFRLENBQUM7UUFBRSxNQUFNLElBQUksS0FBSyxDQUFDLHdCQUF3QixDQUFDLENBQUM7SUFDMUUsT0FBTyxRQUFRLENBQUM7QUFDbEIsQ0FBQztBQUVEOzs7O0dBSUc7QUFDSCxNQUFNLFVBQVUsc0JBQXNCLENBQUMsTUFBdUIsRUFBRSxPQUF5QjtJQUN2RixNQUFNLFFBQVEsR0FBRyxnQkFBZ0IsQ0FBQyxNQUFNLENBQUMsQ0FBQztJQUMxQyxJQUFJLFFBQVEsS0FBSyxTQUFTO1FBQUUsTUFBTSxJQUFJLEtBQUssQ0FBQywwQkFBMEIsQ0FBQyxDQUFDO0lBQ3hFLE1BQU0sTUFBTSxHQUFvQztRQUM5QyxZQUFZLEVBQUUscUJBQXFCLENBQUMsT0FBTyxDQUFDLFlBQVksQ0FBQztRQUN6RCxRQUFRLEVBQUUsaUJBQWlCLENBQUMsT0FBTyxDQUFDLFFBQVEsQ0FBQztLQUM5QyxDQUFDO0lBQ0YsT0FBTyxtQkFBbUIsQ0FBQyxRQUFRLEVBQUUsTUFBTSxDQUFDLENBQUM7QUFDL0MsQ0FBQyJ9 \ No newline at end of file diff --git a/packages/loopover-miner/lib/rejection-templates.ts b/packages/loopover-miner/lib/rejection-templates.ts new file mode 100644 index 0000000000..366fda5c88 --- /dev/null +++ b/packages/loopover-miner/lib/rejection-templates.ts @@ -0,0 +1,91 @@ +// CoC-compliant rejection message templates (#2324). When one of the miner's PRs is closed/rejected, it may leave +// a single, final, human-readable local note (e.g. in a run summary or CLI output — posting anywhere is a separate +// write action, out of scope here). The note must be courteous, non-defensive, and never re-litigate the +// maintainer's decision. This module is pure content/formatting: static template strings + a deterministic +// renderer — no GitHub calls, no LLM, no network. Same inputs always render the same message. + +export type RejectionReason = "gate_close" | "maintainer_close_no_reason" | "superseded_by_duplicate"; + +export type RejectionContext = { + repoFullName: string; + prNumber: number; +}; + +// Templates keyed by rejection-reason bucket. Every placeholder is `{name}`; the renderer resolves the structured +// context (a PR number + a repo) and never interpolates free-form/private text. +const REASON_TEMPLATES: Record = { + gate_close: + "The automated review gate closed PR #{prNumber} on {repoFullName}. Thanks for the review — I'll address the flagged points and open a fresh PR if the change still fits.", + maintainer_close_no_reason: + "PR #{prNumber} on {repoFullName} was closed by the maintainer. Thanks for taking the time to look — I'll leave it here unless you'd like me to revisit it.", + superseded_by_duplicate: + "PR #{prNumber} on {repoFullName} looks superseded by other work on the same issue, so I'm closing it on my side to avoid duplication. Thanks to whoever is carrying it forward.", +}; + +/** The supported rejection-reason buckets, in declaration order. */ +export const REJECTION_REASONS: readonly RejectionReason[] = Object.freeze( + Object.keys(REASON_TEMPLATES) as RejectionReason[], +); + +// Private-language tokens that must never surface in a public-facing courtesy note (mirrors the redaction set in +// `sanitizePublicComment`, src/github/commands.ts). Templates are authored clean and this is asserted in tests; +// the structured context (a PR number + a validated `owner/repo`) carries no private scoring/reward/wallet data, +// so — deliberately — no value-level redaction is applied that could mangle a legitimate repo name. +const PRIVATE_LANGUAGE = + /\b(?:raw trust scores?|trust scores?|wallets?|hotkeys?|coldkeys?|seed phrases?|mnemonics?|payouts?|rewards?)\b/i; + +/** True when the given text contains any banned private-language token. */ +export function containsPrivateLanguage(text: string): boolean { + return PRIVATE_LANGUAGE.test(text); +} + +// A GitHub `owner/repo`: owner is 1-39 chars of alphanumerics/hyphens starting alphanumeric; repo is +// alphanumerics/`.`/`_`/`-`. Anchored + character-class-restricted so control characters, whitespace, markup, or an +// extra `/` (e.g. `owner/repo\nextra`, `owner/`) are rejected — the note interpolates this text directly, so a +// malformed value must throw rather than leak caller-controlled display text. +const GITHUB_FULL_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/; + +function normalizeRepoFullName(repoFullName: unknown): string { + if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); + const trimmed = repoFullName.trim(); + if (!GITHUB_FULL_NAME.test(trimmed)) throw new Error("invalid_repo_full_name"); + return trimmed; +} + +function normalizePrNumber(prNumber: unknown): number { + if (!Number.isInteger(prNumber) || (prNumber as number) < 1) throw new Error("invalid_pr_number"); + return prNumber as number; +} + +/** + * Substitute every `{placeholder}` token in `template` from `values`, throwing rather than emitting a + * half-rendered note if any placeholder is unmapped (`missing_placeholder:`) or if a substituted value's + * own text still leaves what looks like an unresolved placeholder in the output (`unresolved_placeholder`) -- + * defense-in-depth against a future template-authoring bug, not a route `renderRejectionMessage` itself can hit + * today given the fixed {@link REJECTION_REASONS} vocabulary (exported so both guards are directly unit-testable + * without needing a malformed production template). Pure and deterministic. + */ +export function resolvePlaceholders(template: string, values: Record): string { + const rendered = template.replace(/\{(\w+)\}/g, (_match, key) => { + const value = values[key]; + if (value === undefined) throw new Error(`missing_placeholder:${key}`); + return String(value); + }); + if (/\{[^}]+\}/.test(rendered)) throw new Error("unresolved_placeholder"); + return rendered; +} + +/** + * Render the courtesy note for a closed/rejected PR. `reason` must be one of {@link REJECTION_REASONS}; `context` + * supplies `repoFullName` (`owner/repo`) and `prNumber` (a positive integer). Throws on an unknown reason or a + * malformed context. Pure and deterministic. + */ +export function renderRejectionMessage(reason: RejectionReason, context: RejectionContext): string { + const template = REASON_TEMPLATES[reason]; + if (template === undefined) throw new Error("invalid_rejection_reason"); + const values: Record = { + repoFullName: normalizeRepoFullName(context.repoFullName), + prNumber: normalizePrNumber(context.prNumber), + }; + return resolvePlaceholders(template, values); +} diff --git a/test/unit/miner-ams-policy.test.ts b/test/unit/miner-ams-policy.test.ts index 7ba4394bbf..dcbbbe1bc6 100644 --- a/test/unit/miner-ams-policy.test.ts +++ b/test/unit/miner-ams-policy.test.ts @@ -94,4 +94,25 @@ describe("resolveAmsPolicy (#5132)", () => { expect(result).toEqual({ spec: DEFAULT_AMS_POLICY_SPEC, source: "default", warnings: [] }); expect(fetchImpl).not.toHaveBeenCalled(); }); + + it("treats a readFileSync that throws (e.g. a race where the file vanishes after existsSync) the same as absent", async () => { + const root = tempRoot(); + const readFileSync = () => { + throw new Error("EACCES: permission denied"); + }; + const result = await resolveAmsPolicy("acme/widgets", { + env: { LOOPOVER_MINER_CONFIG_DIR: root }, + existsSync: () => true, + readFileSync, + }); + expect(result).toEqual({ spec: DEFAULT_AMS_POLICY_SPEC, source: "default", warnings: [] }); + }); + + it("defaults env to process.env when no env override is supplied", async () => { + // No LOOPOVER_MINER_AMS_POLICY_PATH/LOOPOVER_MINER_CONFIG_DIR/XDG_CONFIG_HOME override set for this test + // process, so this exercises the real `options.env ?? process.env` default path and resolves to the real + // (almost certainly absent, on a test machine) `~/.config/loopover-miner/.loopover-ams.yml`. + const result = await resolveAmsPolicy("acme/widgets", { existsSync: () => false }); + expect(result).toEqual({ spec: DEFAULT_AMS_POLICY_SPEC, source: "default", warnings: [] }); + }); }); diff --git a/test/unit/miner-attempt-runner.test.ts b/test/unit/miner-attempt-runner.test.ts index 4c4c32f374..48be8acde9 100644 --- a/test/unit/miner-attempt-runner.test.ts +++ b/test/unit/miner-attempt-runner.test.ts @@ -333,6 +333,16 @@ describe("runMinerAttempt (#2337) — the real create->review->gate->submit pipe expect(second.decision.stage).toBe("rate_limit"); }); + it("threads an explicit maxConsecutiveGateBlocks/sessionStartMs through, and tolerates an omitted draft", async () => { + const deps = baseDeps({ sessionStartMs: 9_000 }); + const input = baseAttemptInput({ maxConsecutiveGateBlocks: 3 }); + delete (input as { draft?: unknown }).draft; + + const result = await runMinerAttempt(input, deps); + + expect(result.outcome).toBe("submitted"); + }); + it("fails closed on malformed input", async () => { const deps = baseDeps(); await expect(runMinerAttempt(null as never, deps)).rejects.toThrow("invalid_attempt_input"); diff --git a/test/unit/miner-calibration-types.test.ts b/test/unit/miner-calibration-types.test.ts index 67dfa40a47..77d4ad0987 100644 --- a/test/unit/miner-calibration-types.test.ts +++ b/test/unit/miner-calibration-types.test.ts @@ -67,4 +67,12 @@ describe("loopover-miner calibration types scaffold (#2332)", () => { expect(isCalibrationReport({ rows: [row], hasSignal: "yes" })).toBe(false); expect(isCalibrationReport({ rows: [{ ...row, project: "" }], hasSignal: true })).toBe(false); }); + + it("rejects null, non-object, and array values for every shape's own top-level guard", () => { + for (const bad of [null, "not-an-object", 42, ["array", "not", "object"]]) { + expect(isObservedOutcomeRecord(bad)).toBe(false); + expect(isCalibrationRow(bad)).toBe(false); + expect(isCalibrationReport(bad)).toBe(false); + } + }); }); diff --git a/test/unit/miner-contribution-profile-cache.test.ts b/test/unit/miner-contribution-profile-cache.test.ts index 97a0fda853..621b23c536 100644 --- a/test/unit/miner-contribution-profile-cache.test.ts +++ b/test/unit/miner-contribution-profile-cache.test.ts @@ -176,6 +176,19 @@ describe("contribution-profile cache store (#6797)", () => { false, ); }); + + it("purgeByRepo deletes only the given repo's cached profile and returns the count (#7091)", () => { + const store = tempStore(); + store.put(profile("acme/widgets"), AT_MS); + store.put(profile("acme/other"), AT_MS); + expect(store.purgeByRepo("acme/widgets")).toBe(1); + expect(store.get("acme/widgets")).toBeNull(); + expect(store.get("acme/other")).not.toBeNull(); + }); + + it("purgeByRepo returns 0 when the repo has no cached profile", () => { + expect(tempStore().purgeByRepo("acme/widgets")).toBe(0); + }); }); function tempRootForDefault(): string { diff --git a/test/unit/miner-opportunity-fanout.test.ts b/test/unit/miner-opportunity-fanout.test.ts index 47772bdeef..3e75d6071f 100644 --- a/test/unit/miner-opportunity-fanout.test.ts +++ b/test/unit/miner-opportunity-fanout.test.ts @@ -8,6 +8,7 @@ vi.mock("@loopover/engine", async () => { import { fetchCandidateIssues, fetchCandidateIssuesWithSummary, + searchCandidateIssues, searchCandidateIssuesWithSummary, } from "../../packages/loopover-miner/lib/opportunity-fanout.js"; @@ -420,4 +421,236 @@ describe("fetchCandidateIssues (#2307)", () => { expect(timeoutSpy.mock.calls.every(([ms]) => ms === 10_000)).toBe(true); timeoutSpy.mockRestore(); }); + + it("tolerates non-array targets and malformed target entries, making zero requests for zero valid targets", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + + const fromNonArray = await fetchCandidateIssuesWithSummary("not-an-array" as never, "token", { apiBaseUrl: API }); + expect(fromNonArray.issues).toEqual([]); + + const fromMalformedEntries = await fetchCandidateIssuesWithSummary( + [{ owner: 123, repo: null }, {}, { owner: "acme" }] as never, + "token", + { apiBaseUrl: API }, + ); + expect(fromMalformedEntries.issues).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("omits the Authorization header when githubToken is not a string", async () => { + const authorizations: Array = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + authorizations.push( + init?.headers instanceof Headers + ? init.headers.get("authorization") + : (init?.headers as Record | undefined)?.authorization, + ); + const url = String(input); + if (url.endsWith("/contents/AI-USAGE.md")) return jsonResponse({}, { status: 404 }); + if (url.endsWith("/contents/CONTRIBUTING.md")) return contentResponse("Contributions welcome."); + if (url.includes("/issues?")) return jsonResponse([issue(1)]); + return jsonResponse({}, { status: 404 }); + }); + + await fetchCandidateIssues([{ owner: "acme", repo: "widgets" }], undefined as never, { apiBaseUrl: API }); + + expect(authorizations.every((value) => value == null)).toBe(true); + }); + + it("tolerates a non-numeric x-ratelimit-remaining header (never records a NaN budget)", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + // "unknown" -> Number("unknown") is NaN, so Number.isFinite(remaining) is false -- unlike a genuinely + // ABSENT header, where headers.get() returns null and Number(null) is 0 (finite), not NaN. + const headers = { "x-ratelimit-remaining": "unknown" }; + if (url.endsWith("/contents/AI-USAGE.md")) return Response.json({}, { status: 404, headers }); + if (url.endsWith("/contents/CONTRIBUTING.md")) return contentResponse("Contributions welcome."); + if (url.includes("/issues?")) return Response.json([issue(3)], { headers }); + return Response.json({}, { status: 404, headers }); + }); + + const result = await fetchCandidateIssuesWithSummary([{ owner: "acme", repo: "widgets" }], "token", { + apiBaseUrl: API, + }); + + // CONTRIBUTING.md's response (contentResponse) DOES carry a real numeric remaining (42) -- proving the + // "unknown" responses were skipped rather than poisoning the running minimum with NaN. + expect(result.rateLimitRemaining).toBe(42); + }); + + it("treats a malformed (array, not object) policy-doc payload as absent content, and passes through non-base64-encoded content unchanged", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + // A malformed AI-USAGE.md payload (array, not `{content, encoding}`) decodes to null content -> falls + // through to CONTRIBUTING.md, whose content here is NOT base64-encoded (encoding: "none"). + if (url.endsWith("/contents/AI-USAGE.md")) return jsonResponse([1, 2, 3]); + if (url.endsWith("/contents/CONTRIBUTING.md")) { + return jsonResponse({ type: "file", encoding: "none", content: "Contributions welcome, AI included." }); + } + if (url.includes("/issues?")) return jsonResponse([issue(5)]); + return jsonResponse({}, { status: 404 }); + }); + + const result = await fetchCandidateIssues([{ owner: "acme", repo: "widgets" }], "token", { apiBaseUrl: API }); + + expect(result.map((entry) => entry.issueNumber)).toEqual([5]); + expect(result[0]?.aiPolicySource).toBe("CONTRIBUTING.md"); + }); + + it("normalizes a non-Error thrown while fetching a policy doc to a generic warning message, still falling through", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/contents/AI-USAGE.md")) throw "boom"; // eslint-disable-line no-throw-literal -- deliberately non-Error + if (url.endsWith("/contents/CONTRIBUTING.md")) return contentResponse("Contributions welcome."); + if (url.includes("/issues?")) return jsonResponse([issue(9)]); + return jsonResponse({}, { status: 404 }); + }); + + const result = await fetchCandidateIssuesWithSummary([{ owner: "acme", repo: "widgets" }], "token", { + apiBaseUrl: API, + }); + + expect(result.warnings).toEqual([ + { repoFullName: "acme/widgets", stage: "policy:AI-USAGE.md", message: "policy fetch failed" }, + ]); + expect(result.issues.map((entry) => entry.issueNumber)).toEqual([9]); + }); + + it("normalizes a non-Error thrown while listing issues to a generic warning message", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/contents/AI-USAGE.md")) return jsonResponse({}, { status: 404 }); + if (url.endsWith("/contents/CONTRIBUTING.md")) return contentResponse("Contributions welcome."); + if (url.includes("/issues?")) throw { code: "ECONNRESET" }; // eslint-disable-line no-throw-literal -- deliberately non-Error + return jsonResponse({}, { status: 404 }); + }); + + const result = await fetchCandidateIssuesWithSummary([{ owner: "acme", repo: "widgets" }], "token", { + apiBaseUrl: API, + }); + + expect(result.warnings).toEqual([{ repoFullName: "acme/widgets", stage: "issues", message: "issue fetch failed" }]); + }); + + it("drops malformed issue entries and defaults every optional field a minimal-but-valid entry omits", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/contents/AI-USAGE.md")) return jsonResponse({}, { status: 404 }); + if (url.endsWith("/contents/CONTRIBUTING.md")) return contentResponse("Contributions welcome."); + if (url.includes("/issues?")) { + return jsonResponse([ + { number: 0, title: "Invalid number (<= 0)" }, + { number: 5, title: "" }, // blank title + { number: 6, title: "Minimal issue" }, // no labels/assignees/comments/created_at/updated_at/html_url + ]); + } + return jsonResponse({}, { status: 404 }); + }); + + const result = await fetchCandidateIssues([{ owner: "acme", repo: "widgets" }], "token", { apiBaseUrl: API }); + + expect(result).toEqual([ + { + owner: "acme", + repo: "widgets", + repoFullName: "acme/widgets", + issueNumber: 6, + title: "Minimal issue", + labels: [], + assignees: [], + commentsCount: 0, + createdAt: null, + updatedAt: null, + htmlUrl: null, + aiPolicyAllowed: true, + aiPolicySource: "CONTRIBUTING.md", + }, + ]); + }); + + it("returns no results for a blank/whitespace-only search query, without ever calling fetch", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + + const result = await searchCandidateIssuesWithSummary(" ", "token", { apiBaseUrl: API }); + + expect(result.issues).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns no results for a non-string search query, without ever calling fetch", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + + const result = await searchCandidateIssuesWithSummary(undefined as never, "token", { apiBaseUrl: API }); + + expect(result.issues).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("normalizes a non-Error thrown while searching to a generic warning message", async () => { + vi.stubGlobal("fetch", async () => { + throw "search boom"; // eslint-disable-line no-throw-literal -- deliberately non-Error + }); + + const result = await searchCandidateIssuesWithSummary("label:bug", "token", { apiBaseUrl: API }); + + expect(result.warnings).toEqual([{ repoFullName: "*", stage: "search", message: "issue search failed" }]); + }); + + it("resolves a search hit whose repository.full_name is malformed via its html_url fallback instead", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/search/issues?")) { + return jsonResponse({ + items: [ + { + ...issue(60), + repository: { full_name: "no-slash-here" }, // malformed: no "/", falls through to html_url + html_url: "https://github.com/acme/widgets/issues/60", + }, + ], + }); + } + if (url.endsWith("/contents/AI-USAGE.md")) return jsonResponse({}, { status: 404 }); + if (url.endsWith("/contents/CONTRIBUTING.md")) return contentResponse("Contributions welcome."); + return jsonResponse({}, { status: 404 }); + }); + + const result = await searchCandidateIssuesWithSummary("label:bug", "token", { apiBaseUrl: API }); + + expect(result.issues.map((entry) => [entry.repoFullName, entry.issueNumber])).toEqual([["acme/widgets", 60]]); + }); + + it("silently drops a search hit with no full_name, no matching repository_url, and no html_url at all", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/search/issues?")) { + return jsonResponse({ items: [{ number: 61, title: "Unresolvable target" }] }); + } + return jsonResponse({}, { status: 404 }); + }); + + const result = await searchCandidateIssuesWithSummary("label:bug", "token", { apiBaseUrl: API }); + + expect(result.issues).toEqual([]); + expect(result.warnings).toEqual([]); + }); + + it("searchCandidateIssues returns just the issues array, defaulting options when omitted", async () => { + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/search/issues?")) { + return jsonResponse({ items: [{ ...issue(70), repository: { full_name: "acme/widgets" } }] }); + } + if (url.endsWith("/contents/AI-USAGE.md")) return jsonResponse({}, { status: 404 }); + if (url.endsWith("/contents/CONTRIBUTING.md")) return contentResponse("Contributions welcome."); + return jsonResponse({}, { status: 404 }); + }); + + const result = await searchCandidateIssues("label:bug", "token"); + + expect(result.map((entry) => entry.issueNumber)).toEqual([70]); + }); }); diff --git a/test/unit/miner-plan-store.test.ts b/test/unit/miner-plan-store.test.ts index 94b1076599..8e158761a5 100644 --- a/test/unit/miner-plan-store.test.ts +++ b/test/unit/miner-plan-store.test.ts @@ -2,12 +2,15 @@ import { mkdtempSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { PLAN_STATUSES, closeDefaultPlanStore, + listPlans, + loadPlan, openPlanStore, resolvePlanStoreDbPath, + savePlan, } from "../../packages/loopover-miner/lib/plan-store.js"; import type { PlanDag } from "../../packages/loopover-miner/lib/plan-store.js"; import { readSchemaVersion } from "../../packages/loopover-miner/lib/schema-version.js"; @@ -26,6 +29,7 @@ function tempStore() { afterEach(() => { for (const store of stores.splice(0)) store.close(); closeDefaultPlanStore(); + vi.unstubAllEnvs(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); @@ -92,6 +96,41 @@ describe("loopover-miner plan store (#2318)", () => { expect(store.listPlans({ status: null }).map((record) => record.planId)).toEqual(["a", "b"]); }); + it("derives 'failed' status once any step failed, even alongside other statuses", () => { + const store = tempStore(); + const saved = store.savePlan("failed-plan", { + steps: [ + { id: "a", title: "A", dependsOn: [], status: "completed", attempts: 1, maxAttempts: 1 }, + { id: "b", title: "B", dependsOn: [], status: "failed", attempts: 1, maxAttempts: 1 }, + ], + }); + expect(saved.status).toBe("failed"); + }); + + it("derives 'pending' status when no step is failed/running and not every step is finished", () => { + const store = tempStore(); + const saved = store.savePlan("pending-plan", { + steps: [ + { id: "a", title: "A", dependsOn: [], status: "completed", attempts: 1, maxAttempts: 1 }, + { id: "b", title: "B", dependsOn: [], status: "pending", attempts: 0, maxAttempts: 1 }, + ], + }); + expect(saved.status).toBe("pending"); + }); + + it("exposes module-level savePlan/loadPlan/listPlans helpers backed by the default DB path", () => { + const root = mkdtempSync(join(tmpdir(), "loopover-miner-plan-store-default-")); + roots.push(root); + vi.stubEnv("LOOPOVER_MINER_PLAN_STORE_DB", join(root, "default.sqlite3")); + + expect(loadPlan("p1")).toBeNull(); + const saved = savePlan("p1", PLAN); + expect(saved.planId).toBe("p1"); + expect(loadPlan("p1")?.plan).toEqual(PLAN); + expect(listPlans().map((record) => record.planId)).toEqual(["p1"]); + expect(listPlans({ status: "running" }).map((record) => record.planId)).toEqual(["p1"]); + }); + it("rejects a malformed plan on save rather than persisting it", () => { const store = tempStore(); expect(() => store.savePlan("x", { steps: [{ id: "s1", title: "no status", dependsOn: [], attempts: 0, maxAttempts: 1 } as never] })).toThrow("invalid_plan"); @@ -109,6 +148,36 @@ describe("loopover-miner plan store (#2318)", () => { ).toThrow("invalid_plan"); }); + it("rejects a plan that is itself null, a non-object, or an array", () => { + const store = tempStore(); + expect(() => store.savePlan("x", null as never)).toThrow("invalid_plan"); + expect(() => store.savePlan("x", "nope" as never)).toThrow("invalid_plan"); + expect(() => store.savePlan("x", [] as never)).toThrow("invalid_plan"); + }); + + it("rejects every individually-malformed step field on save", () => { + const store = tempStore(); + const valid = { id: "a", title: "A", dependsOn: [] as string[], status: "pending" as const, attempts: 0, maxAttempts: 1 }; + // A non-object step (e.g. a bare string) in the steps array. + expect(() => store.savePlan("x", { steps: ["not-an-object" as never] })).toThrow("invalid_plan"); + // Invalid id/title. + expect(() => store.savePlan("x", { steps: [{ ...valid, id: "" }] })).toThrow("invalid_plan"); + expect(() => store.savePlan("x", { steps: [{ ...valid, title: "" }] })).toThrow("invalid_plan"); + // Invalid actionClass (present but out-of-bounds). + expect(() => store.savePlan("x", { steps: [{ ...valid, actionClass: "" }] })).toThrow("invalid_plan"); + // dependsOn not an array, and dependsOn with a non-string entry. + expect(() => store.savePlan("x", { steps: [{ ...valid, dependsOn: "nope" as never }] })).toThrow("invalid_plan"); + expect(() => store.savePlan("x", { steps: [{ ...valid, dependsOn: [1 as never] }] })).toThrow("invalid_plan"); + // Invalid attempts/maxAttempts. + expect(() => store.savePlan("x", { steps: [{ ...valid, attempts: -1 }] })).toThrow("invalid_plan"); + expect(() => store.savePlan("x", { steps: [{ ...valid, maxAttempts: 0 }] })).toThrow("invalid_plan"); + // Invalid lastError (present but out-of-bounds type). + expect(() => store.savePlan("x", { steps: [{ ...valid, lastError: 123 as never }] })).toThrow("invalid_plan"); + // A well-formed lastError (string, or explicit null) is accepted. + expect(() => store.savePlan("x", { steps: [{ ...valid, lastError: "boom" }] })).not.toThrow(); + expect(() => store.savePlan("y", { steps: [{ ...valid, lastError: null }] })).not.toThrow(); + }); + it("rejects unknown or self-referential dependsOn entries on save", () => { const store = tempStore(); const pendingStep = { id: "a", title: "A", dependsOn: [] as string[], status: "pending" as const, attempts: 0, maxAttempts: 1 }; diff --git a/test/unit/miner-rejection-templates.test.ts b/test/unit/miner-rejection-templates.test.ts index 1ecb4dcc4c..3886f08e19 100644 --- a/test/unit/miner-rejection-templates.test.ts +++ b/test/unit/miner-rejection-templates.test.ts @@ -3,6 +3,7 @@ import { REJECTION_REASONS, containsPrivateLanguage, renderRejectionMessage, + resolvePlaceholders, } from "../../packages/loopover-miner/lib/rejection-templates.js"; const CONTEXT = { repoFullName: "JSONbored/loopover", prNumber: 2751 } as const; @@ -64,4 +65,33 @@ describe("loopover-miner rejection templates (#2324)", () => { "JSONbored/loopover.io_test-1", ); }); + + it("rejects a non-string repoFullName", () => { + for (const bad of [null, undefined, 42, {}]) { + // @ts-expect-error — exercising the runtime guard against a non-string repoFullName + expect(() => renderRejectionMessage("gate_close", { repoFullName: bad, prNumber: 1 })).toThrow( + "invalid_repo_full_name", + ); + } + }); + + describe("resolvePlaceholders (the defense-in-depth guard behind renderRejectionMessage)", () => { + it("renders every placeholder present in values", () => { + expect(resolvePlaceholders("PR #{prNumber} on {repoFullName}", { prNumber: 7, repoFullName: "o/r" })).toBe( + "PR #7 on o/r", + ); + }); + + it("throws missing_placeholder for a template referencing a key values doesn't supply", () => { + expect(() => resolvePlaceholders("Hello {missingKey}", {})).toThrow("missing_placeholder:missingKey"); + }); + + it("throws unresolved_placeholder when a substituted value's own text still looks like a placeholder", () => { + // Not reachable via renderRejectionMessage (GITHUB_FULL_NAME forbids `{`/`}` in repoFullName), but a real + // guard against any future caller of this pure helper that doesn't pre-validate its values the same way. + expect(() => resolvePlaceholders("{repoFullName}", { repoFullName: "{injected}" })).toThrow( + "unresolved_placeholder", + ); + }); + }); });