diff --git a/packages/loopover-miner/lib/attempt-cli.d.ts b/packages/loopover-miner/lib/attempt-cli.d.ts index 75cbfa47e9..da9eda8923 100644 --- a/packages/loopover-miner/lib/attempt-cli.d.ts +++ b/packages/loopover-miner/lib/attempt-cli.d.ts @@ -1,108 +1,125 @@ import type { CodingAgentExecutionMode, FeasibilityVerdict, LocalWriteActionSpec } from "@loopover/engine"; -import type { AttemptDeps, AttemptResult as RunMinerAttemptResult, runMinerAttempt } from "./attempt-runner.js"; import type { ClaimLedger } from "./claim-ledger.js"; +import type { ClaimConflictResult, resolveClaimConflict as ResolveClaimConflictFn } from "./claim-conflict-resolver.js"; import type { EventLedger } from "./event-ledger.js"; import type { AttemptLog } from "./attempt-log.js"; import type { GovernorLedger } from "./governor-ledger.js"; import type { WorktreeAllocator } from "./worktree-allocator.js"; -import type { resolveRejectionSignaled } from "./rejection-signal.js"; -import type { SelfReviewContextFetch, fetchSelfReviewContext } from "./self-review-context.js"; -import type { cleanupAttemptWorktree, prepareAttemptWorktree } from "./attempt-worktree.js"; -import type { buildCodingTaskSpec } from "./coding-task-spec.js"; -import type { resolveAmsPolicy } from "./ams-policy.js"; -import type { checkMinerKillSwitch } from "./governor-kill-switch.js"; -import type { resolveMinerGoalSpec } from "./miner-goal-spec.js"; -import type { ClaimConflictResult, resolveClaimConflict } from "./claim-conflict-resolver.js"; -import type { recordOwnSubmission } from "./governor-state.js"; -import type { getAttemptHistory } from "./portfolio-queue.js"; -import type { submitSoftClaim } from "./discovery-index-client.js"; - +import type { resolveRejectionSignaled as ResolveRejectionSignaledFn } from "./rejection-signal.js"; +import type { cleanupAttemptWorktree as CleanupAttemptWorktreeFn, prepareAttemptWorktree as PrepareAttemptWorktreeFn } from "./attempt-worktree.js"; +import type { SelfReviewContextFetch, fetchSelfReviewContext as FetchSelfReviewContextFn } from "./self-review-context.js"; +import type { buildCodingTaskSpec as BuildCodingTaskSpecFn } from "./coding-task-spec.js"; +import type { resolveAmsPolicy as ResolveAmsPolicyFn } from "./ams-policy.js"; +import type { checkMinerKillSwitch as CheckMinerKillSwitchFn } from "./governor-kill-switch.js"; +import type { getAttemptHistory as GetAttemptHistoryFn } from "./portfolio-queue.js"; +import type { recordOwnSubmission as RecordOwnSubmissionFn } from "./governor-state.js"; +import type { AttemptDeps, AttemptResult as RunMinerAttemptResult, runMinerAttempt as RunMinerAttemptFn } from "./attempt-runner.js"; +import type { submitSoftClaim as SubmitSoftClaimFn } from "./discovery-index-client.js"; +import type { resolveMinerGoalSpec as ResolveMinerGoalSpecFn } from "./miner-goal-spec.js"; type CommonAttemptResultFields = { - repoFullName: string; - issueNumber: number; - minerLogin: string; - base: string; - mode: CodingAgentExecutionMode; - attemptId: string; + repoFullName: string; + issueNumber: number; + minerLogin: string; + base: string; + mode: CodingAgentExecutionMode; + attemptId: string; }; - /** The result runAttempt reports at every real return point, threaded to `options.onResult` (in addition to * the plain exit-code return runAttempt itself still returns, unchanged, so bin/loopover-miner.js's own * `process.exit(exitCode)` usage never breaks) -- the loop orchestrator's real caller for this data. */ -export type AttemptCliResult = - | (CommonAttemptResultFields & { outcome: "dry_run" }) - | (CommonAttemptResultFields & { outcome: "blocked_rejection_signaled"; reason: string }) - | (CommonAttemptResultFields & { outcome: "blocked_worktree_preparation_failed"; reason: string }) - | (CommonAttemptResultFields & { - outcome: "blocked_infeasible"; - reason: string; - verdict: FeasibilityVerdict; - avoidReasons: string[]; - raiseReasons: string[]; - }) - | (CommonAttemptResultFields & { - outcome: `attempt_${RunMinerAttemptResult["outcome"]}`; - submissionMode: "observe" | "enforce"; - totalTurnsUsed: number; - totalCostUsd: number; - totalTokensUsed: number; - iterationsUsed: number; - reason?: string; - decision?: unknown; - spec?: LocalWriteActionSpec; - execResult?: unknown; - claimConflict?: ClaimConflictResult; - }); - -export type ParsedAttemptArgs = - | { error: string } - | { - repoFullName: string; - issueNumber: number; - minerLogin: string; - base: string; - live: boolean; - dryRun: boolean; - json: boolean; - }; - -export function parseAttemptArgs(args: string[]): ParsedAttemptArgs; - -export function buildAttemptDeps( - env: Record, - ledgers: { claimLedger: ClaimLedger; eventLedger: EventLedger; attemptLog: AttemptLog; governorLedger: GovernorLedger; nowMs: number }, -): AttemptDeps; - +export type AttemptCliResult = (CommonAttemptResultFields & { + outcome: "dry_run"; +}) | (CommonAttemptResultFields & { + outcome: "blocked_rejection_signaled"; + reason: string; +}) | (CommonAttemptResultFields & { + outcome: "blocked_worktree_preparation_failed"; + reason: string; +}) | (CommonAttemptResultFields & { + outcome: "blocked_infeasible"; + reason: string; + verdict: FeasibilityVerdict; + avoidReasons: string[]; + raiseReasons: string[]; +}) | (CommonAttemptResultFields & { + outcome: `attempt_${RunMinerAttemptResult["outcome"]}`; + submissionMode: "observe" | "enforce"; + totalTurnsUsed: number; + totalCostUsd: number; + totalTokensUsed: number; + iterationsUsed: number; + reason?: string; + decision?: unknown; + spec?: LocalWriteActionSpec; + execResult?: unknown; + claimConflict?: ClaimConflictResult; +}); +export type ParsedAttemptArgs = { + error: string; +} | { + repoFullName: string; + issueNumber: number; + minerLogin: string; + base: string; + live: boolean; + dryRun: boolean; + json: boolean; +}; export type RunAttemptOptions = { - env?: Record; - nowMs?: number; - attemptId?: string; - resolveCodingAgentModeFromConfig?: (config: { env?: Record }) => CodingAgentExecutionMode; - openWorktreeAllocator?: () => WorktreeAllocator; - openClaimLedger?: () => ClaimLedger; - initEventLedger?: () => EventLedger; - initAttemptLog?: () => AttemptLog; - initGovernorLedger?: () => GovernorLedger; - buildAttemptDeps?: typeof buildAttemptDeps; - resolveRejectionSignaled?: typeof resolveRejectionSignaled; - fetchImpl?: SelfReviewContextFetch; - prepareAttemptWorktree?: typeof prepareAttemptWorktree; - cleanupAttemptWorktree?: typeof cleanupAttemptWorktree; - fetchSelfReviewContext?: typeof fetchSelfReviewContext; - buildCodingTaskSpec?: typeof buildCodingTaskSpec; - resolveAmsPolicy?: typeof resolveAmsPolicy; - checkMinerKillSwitch?: typeof checkMinerKillSwitch; - resolveMinerGoalSpec?: typeof resolveMinerGoalSpec; - runMinerAttempt?: typeof runMinerAttempt; - resolveClaimConflict?: typeof resolveClaimConflict; - recordOwnSubmission?: typeof recordOwnSubmission; - getAttemptHistory?: typeof getAttemptHistory; - /** Hosted soft-claim coordination at work-start/work-end, when the plane is enabled (#7168). Defaults to - * discovery-index-client.js's own submitSoftClaim. */ - submitSoftClaim?: typeof submitSoftClaim; - /** Invoked with the real structured result at every return point, in addition to (never instead of) the - * plain exit-code return -- the loop orchestrator's real hook into what actually happened. */ - onResult?: (result: AttemptCliResult) => void; + env?: Record; + nowMs?: number; + attemptId?: string; + resolveCodingAgentModeFromConfig?: (config: { + env?: Record; + }) => CodingAgentExecutionMode; + openWorktreeAllocator?: () => WorktreeAllocator; + openClaimLedger?: () => ClaimLedger; + initEventLedger?: () => EventLedger; + initAttemptLog?: () => AttemptLog; + initGovernorLedger?: () => GovernorLedger; + buildAttemptDeps?: typeof buildAttemptDeps; + resolveRejectionSignaled?: typeof ResolveRejectionSignaledFn; + fetchImpl?: SelfReviewContextFetch; + prepareAttemptWorktree?: typeof PrepareAttemptWorktreeFn; + cleanupAttemptWorktree?: typeof CleanupAttemptWorktreeFn; + fetchSelfReviewContext?: typeof FetchSelfReviewContextFn; + buildCodingTaskSpec?: typeof BuildCodingTaskSpecFn; + resolveAmsPolicy?: typeof ResolveAmsPolicyFn; + checkMinerKillSwitch?: typeof CheckMinerKillSwitchFn; + resolveMinerGoalSpec?: typeof ResolveMinerGoalSpecFn; + runMinerAttempt?: typeof RunMinerAttemptFn; + resolveClaimConflict?: typeof ResolveClaimConflictFn; + recordOwnSubmission?: typeof RecordOwnSubmissionFn; + getAttemptHistory?: typeof GetAttemptHistoryFn; + /** Hosted soft-claim coordination at work-start/work-end, when the plane is enabled (#7168). Defaults to + * discovery-index-client.js's own submitSoftClaim. */ + submitSoftClaim?: typeof SubmitSoftClaimFn; + /** Invoked with the real structured result at every return point, in addition to (never instead of) the + * plain exit-code return -- the loop orchestrator's real hook into what actually happened. */ + onResult?: (result: AttemptCliResult) => void; }; - -export function runAttempt(args: string[], options?: RunAttemptOptions): Promise; +export declare function parseAttemptArgs(args: string[]): ParsedAttemptArgs; +/** + * Assemble a real AttemptDeps object: every field wired to a genuine implementation (the #5131 driver, the + * #5133 slop assessor, the four real ledgers passed in, and the fetchLiveIssueSnapshot/executeLocalWrite + * built alongside this file). Throws if the coding-agent driver is unconfigured (fails closed, matching + * constructProductionCodingAgentDriver's own contract) -- callers should report that clearly rather than + * silently falling back to a driver that could never run. + */ +export declare function buildAttemptDeps(env: Record, ledgers: { + claimLedger: ClaimLedger; + eventLedger: EventLedger; + attemptLog: AttemptLog; + governorLedger: GovernorLedger; + nowMs: number; +}): AttemptDeps; +/** + * Run the `attempt` CLI subcommand end to end: resolveRejectionSignaled (before consuming a worktree slot) -> + * acquire a concurrency slot -> assemble real AttemptDeps -> prepare a REAL git worktree -> fetch a real + * SelfReviewContext -> build a real coding-task spec (blocks on an infeasible verdict) -> resolve the real + * AmsPolicySpec execution policy -> assemble the real IterateLoopInput + Governor context -> call + * runMinerAttempt for real. The worktree is cleaned up (or retained, per the real outcome) in `finally`. + * See this file's header for the documented gaps (real convergence history). + */ +export declare function runAttempt(args: string[], options?: RunAttemptOptions): Promise; +export {}; diff --git a/packages/loopover-miner/lib/attempt-cli.js b/packages/loopover-miner/lib/attempt-cli.js index 6346bff91f..495a16a3d2 100644 --- a/packages/loopover-miner/lib/attempt-cli.js +++ b/packages/loopover-miner/lib/attempt-cli.js @@ -12,7 +12,6 @@ // governor.selfPlagiarismCandidate/selfPlagiarismRecentSubmissions are omitted (chokepoint.ts's own design treats // that as "skip that stage entirely"). governor.convergenceInput is now a real per-issue portfolio-queue.js read // (#5654) and governor.reputationHistory a real per-repo governor-state.js read (#5675), not placeholders. - import { fingerprintFromChangedFiles, resolveCodingAgentModeFromConfig, resolveFirstConfiguredCodingAgentDriverName } from "@loopover/engine"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; import { constructProductionCodingAgentDriver } from "./coding-agent-construction.js"; @@ -41,115 +40,112 @@ import { loadReputationHistory, recordOwnSubmission } from "./governor-state.js" import { runMinerAttempt } from "./attempt-runner.js"; import { resolveGitHubToken } from "./github-token-resolution.js"; import { isDiscoveryPlaneEnabled, submitSoftClaim } from "./discovery-index-client.js"; - -const ATTEMPT_USAGE = - "Usage: loopover-miner attempt --miner-login [--base ] [--live] [--dry-run] [--json]"; - +const ATTEMPT_USAGE = "Usage: loopover-miner attempt --miner-login [--base ] [--live] [--dry-run] [--json]"; function parseRepoTarget(value) { - const trimmed = typeof value === "string" ? value.trim() : ""; - const [owner, repo, extra] = trimmed.split("/"); - if (!owner || !repo || extra !== undefined) return null; - if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) return null; - return `${owner}/${repo}`; + const trimmed = value.trim(); + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) + return null; + if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) + return null; + return `${owner}/${repo}`; } - export function parseAttemptArgs(args) { - const options = { json: false, minerLogin: null, base: "main", live: false, dryRun: false }; - const positional = []; - - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === "--json") { - options.json = true; - continue; - } - // Opt-in only: resolveCodingAgentModeFromConfig's own default (no agentDryRun override) is "live", not - // "dry_run" -- so #5132's "dry-run is default" acceptance criteria (#2342) has to be enforced HERE, by - // requiring an explicit --live flag before this command will ever request live mode. - if (token === "--live") { - options.live = true; - continue; - } - // #4847: distinct from --live's absence above -- --live only ever gated the coding-agent DRIVER's mode, - // but a non---live run still opened every store and made real worktree/claim/ledger writes. --dry-run - // short-circuits BEFORE any of that infrastructure is even opened, guaranteeing zero writes rather than - // merely skipping the driver. - if (token === "--dry-run") { - options.dryRun = true; - continue; - } - if (token === "--miner-login") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) return { error: ATTEMPT_USAGE }; - options.minerLogin = value; - index += 1; - continue; + const options = { json: false, minerLogin: null, base: "main", live: false, dryRun: false }; + const positional = []; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + // Opt-in only: resolveCodingAgentModeFromConfig's own default (no agentDryRun override) is "live", not + // "dry_run" -- so #5132's "dry-run is default" acceptance criteria (#2342) has to be enforced HERE, by + // requiring an explicit --live flag before this command will ever request live mode. + if (token === "--live") { + options.live = true; + continue; + } + // #4847: distinct from --live's absence above -- --live only ever gated the coding-agent DRIVER's mode, + // but a non---live run still opened every store and made real worktree/claim/ledger writes. --dry-run + // short-circuits BEFORE any of that infrastructure is even opened, guaranteeing zero writes rather than + // merely skipping the driver. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--miner-login") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) + return { error: ATTEMPT_USAGE }; + options.minerLogin = value; + index += 1; + continue; + } + if (token === "--base") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) + return { error: ATTEMPT_USAGE }; + options.base = value; + index += 1; + continue; + } + if (token.startsWith("-")) + return { error: `Unknown option: ${token}` }; + positional.push(token); } - if (token === "--base") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) return { error: ATTEMPT_USAGE }; - options.base = value; - index += 1; - continue; + if (positional.length !== 2) + return { error: ATTEMPT_USAGE }; + const repoFullName = parseRepoTarget(positional[0]); + if (!repoFullName) + return { error: `Repository must be in owner/repo form: ${positional[0]}` }; + const issueNumber = Number(positional[1]); + if (!Number.isInteger(issueNumber) || issueNumber < 1) { + return { error: `Issue number must be a positive integer: ${positional[1]}` }; } - if (token.startsWith("-")) return { error: `Unknown option: ${token}` }; - positional.push(token); - } - - if (positional.length !== 2) return { error: ATTEMPT_USAGE }; - const repoFullName = parseRepoTarget(positional[0]); - if (!repoFullName) return { error: `Repository must be in owner/repo form: ${positional[0]}` }; - const issueNumber = Number(positional[1]); - if (!Number.isInteger(issueNumber) || issueNumber < 1) { - return { error: `Issue number must be a positive integer: ${positional[1]}` }; - } - if (!options.minerLogin) return { error: `--miner-login is required. ${ATTEMPT_USAGE}` }; - - return { - repoFullName, - issueNumber, - minerLogin: options.minerLogin, - base: options.base, - live: options.live, - dryRun: options.dryRun, - json: options.json, - }; + if (!options.minerLogin) + return { error: `--miner-login is required. ${ATTEMPT_USAGE}` }; + return { + repoFullName, + issueNumber, + minerLogin: options.minerLogin, + base: options.base, + live: options.live, + dryRun: options.dryRun, + json: options.json, + }; } - /** * Assemble a real AttemptDeps object: every field wired to a genuine implementation (the #5131 driver, the * #5133 slop assessor, the four real ledgers passed in, and the fetchLiveIssueSnapshot/executeLocalWrite * built alongside this file). Throws if the coding-agent driver is unconfigured (fails closed, matching * constructProductionCodingAgentDriver's own contract) -- callers should report that clearly rather than * silently falling back to a driver that could never run. - * - * @param {Record} env - * @param {{ - * claimLedger: import("./claim-ledger.js").ClaimLedger, - * eventLedger: import("./event-ledger.js").EventLedger, - * attemptLog: import("./attempt-log.js").AttemptLog, - * governorLedger: import("./governor-ledger.js").GovernorLedger, - * nowMs: number, - * }} ledgers - * @returns {import("./attempt-runner.js").AttemptDeps} */ export function buildAttemptDeps(env, ledgers) { - return { - driver: constructProductionCodingAgentDriver(env), - runSlopAssessment: (input) => runSlopAssessment(input), - appendAttemptLogEvent: (event) => ledgers.attemptLog.appendAttemptLogEvent(event), - claimLedger: ledgers.claimLedger, - // resolveGitHubToken (#6116): GITHUB_TOKEN env override wins outright, else a live token from the - // authenticated `loopover-mcp login` session -- cached in memory, so repeat calls within this process - // don't repeatedly hit the session-fetch endpoint after the first successful resolution. - fetchLiveIssueSnapshot: async (repoFullName, issueNumber) => fetchLiveIssueSnapshot(repoFullName, issueNumber, { githubToken: await resolveGitHubToken(env) }), - eventLedger: ledgers.eventLedger, - governorLedgerAppend: (event) => ledgers.governorLedger.appendGovernorEvent(event), - nowMs: ledgers.nowMs, - executeLocalWrite: (spec) => executeLocalWrite(spec), - }; + // AttemptDeps' claimLedger/callback parameter types are looser structural stubs than the real ledgers + // (pre-existing .d.ts drift on attempt-runner); cast preserves the same runtime wiring the .js had. + return { + driver: constructProductionCodingAgentDriver(env), + runSlopAssessment: (input) => runSlopAssessment(input), + appendAttemptLogEvent: (event) => { + ledgers.attemptLog.appendAttemptLogEvent(event); + }, + claimLedger: ledgers.claimLedger, + // resolveGitHubToken (#6116): GITHUB_TOKEN env override wins outright, else a live token from the + // authenticated `loopover-mcp login` session -- cached in memory, so repeat calls within this process + // don't repeatedly hit the session-fetch endpoint after the first successful resolution. + fetchLiveIssueSnapshot: async (repoFullName, issueNumber) => { + // resolveGitHubToken returns string | null; exactOptionalPropertyTypes forbids explicit undefined. + const githubToken = await resolveGitHubToken(env); + return fetchLiveIssueSnapshot(repoFullName, issueNumber, githubToken !== null ? { githubToken } : {}); + }, + eventLedger: ledgers.eventLedger, + governorLedgerAppend: (event) => ledgers.governorLedger.appendGovernorEvent(event), + nowMs: ledgers.nowMs, + executeLocalWrite: (spec) => executeLocalWrite(spec), + }; } - /** * Run the `attempt` CLI subcommand end to end: resolveRejectionSignaled (before consuming a worktree slot) -> * acquire a concurrency slot -> assemble real AttemptDeps -> prepare a REAL git worktree -> fetch a real @@ -159,573 +155,544 @@ export function buildAttemptDeps(env, ledgers) { * See this file's header for the documented gaps (real convergence history). */ export async function runAttempt(args, options = {}) { - const parsed = parseAttemptArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - const env = options.env ?? process.env; - const nowMs = options.nowMs ?? Date.now(); - const resolveMode = options.resolveCodingAgentModeFromConfig ?? resolveCodingAgentModeFromConfig; - const mode = resolveMode({ env, agentDryRun: !parsed.live }); - - if (mode === "paused") { - return reportCliFailure( - parsed.json, - `Coding-agent execution is globally paused (MINER_CODING_AGENT_PAUSED). Not running attempt for ${parsed.repoFullName}#${parsed.issueNumber}.`, - 3, - ); - } - - const attemptId = options.attemptId ?? `${parsed.repoFullName.replace("/", "_")}-${parsed.issueNumber}-${nowMs}`; - - // #4847: reports what a real run would do and returns BEFORE any store (allocator/claim/event/attempt-log/ - // governor ledger) is even opened, so this is a provable zero-write path -- not just "opened but didn't - // write to" the local stores, and nowhere near the real worktree clone, claim, or coding-agent driver. - if (parsed.dryRun) { - const dryRunResult = { - outcome: "dry_run", - repoFullName: parsed.repoFullName, - issueNumber: parsed.issueNumber, - minerLogin: parsed.minerLogin, - base: parsed.base, - mode, - attemptId, - }; - if (parsed.json) { - console.log(JSON.stringify(dryRunResult, null, 2)); - } else { - console.log( - `DRY RUN: would attempt ${parsed.repoFullName}#${parsed.issueNumber} for ${parsed.minerLogin} (mode: ${mode}, base: ${parsed.base}). No worktree, claim, or ledger writes were made.`, - ); - } - options.onResult?.(dryRunResult); - return 0; - } - - let allocator = null; - let claimLedger = null; - let eventLedger = null; - let attemptLog = null; - let governorLedger = null; - let allocation = null; - let worktreeResult = null; - let claimedIssue = false; - let claimRecord = null; - - try { - allocator = (options.openWorktreeAllocator ?? openWorktreeAllocator)(); - claimLedger = (options.openClaimLedger ?? openClaimLedger)(); - eventLedger = (options.initEventLedger ?? initEventLedger)(); - attemptLog = (options.initAttemptLog ?? initAttemptLog)(); - governorLedger = (options.initGovernorLedger ?? initGovernorLedger)(); - - // Checked before acquiring a worktree slot: a rejection-signaled repo should never consume one. - // resolveRejectionSignaled resolves both documented triggers (#5132 policy ban, #5655 own-rejection - // history) and returns a trigger-specific reason string for accurate audit-trail labeling. - const resolveRejection = options.resolveRejectionSignaled ?? resolveRejectionSignaled; - const rejectionSignal = await resolveRejection(parsed.repoFullName, { fetchImpl: options.fetchImpl }); - if (rejectionSignal) { - const reason = - rejectionSignal === true ? REJECTION_REASON_AI_USAGE_POLICY_BAN : rejectionSignal; - attemptLog.appendAttemptLogEvent({ - eventType: "attempt_aborted", - attemptId, - actionClass: "open_pr", - mode, - reason, - payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber }, - }); - eventLedger.appendEvent({ - type: "attempt_blocked", - repoFullName: parsed.repoFullName, - payload: { issueNumber: parsed.issueNumber, reason }, - }); - const rejectedResult = { - outcome: "blocked_rejection_signaled", - reason, - repoFullName: parsed.repoFullName, - issueNumber: parsed.issueNumber, - minerLogin: parsed.minerLogin, - base: parsed.base, - mode, - attemptId, - }; - if (parsed.json) { - console.log(JSON.stringify(rejectedResult, null, 2)); - } else { - console.error( - reason === REJECTION_REASON_OWN_SUBMISSION_REJECTED - ? `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this miner was previously rejected on this repo.` - : `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this repo's AI-usage policy bans automated/AI-authored contributions.`, - ); - } - options.onResult?.(rejectedResult); - return 5; + const parsed = parseAttemptArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); } - - allocation = allocator.acquire(attemptId, parsed.repoFullName); - - let deps; - try { - const buildDeps = options.buildAttemptDeps ?? buildAttemptDeps; - deps = buildDeps(env, { claimLedger, eventLedger, attemptLog, governorLedger, nowMs }); - } catch (error) { - const reason = describeCliError(error); - return reportCliFailure( - parsed.json, - `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: ${reason}`, - 3, - ); + const env = options.env ?? process.env; + const nowMs = options.nowMs ?? Date.now(); + const resolveMode = options.resolveCodingAgentModeFromConfig ?? resolveCodingAgentModeFromConfig; + // resolveCodingAgentModeFromConfig accepts agentDryRun at runtime; RunAttemptOptions injectable omits it (.d.ts drift). + const mode = resolveMode({ env, agentDryRun: !parsed.live }); + if (mode === "paused") { + return reportCliFailure(parsed.json, `Coding-agent execution is globally paused (MINER_CODING_AGENT_PAUSED). Not running attempt for ${parsed.repoFullName}#${parsed.issueNumber}.`, 3); } - - // Real worktree preparation (repo-clone.js + attempt-worktree.js, #5237): the allocator above only - // reserves a concurrency SLOT (worktree-allocator.js's own `slot-N` placeholder dirs never receive real - // git content) -- this is the step that actually clones/fetches the target repo and creates a real - // `git worktree` for this attempt. Its own path, NOT the allocator's slot path, is the real - // workingDirectory a future runMinerAttempt call must use. - const prepareWorktree = options.prepareAttemptWorktree ?? prepareAttemptWorktree; - worktreeResult = await prepareWorktree(parsed.repoFullName, attemptId, { baseBranch: parsed.base, env }); - if (!worktreeResult.ok) { - const reason = worktreeResult.error; - attemptLog.appendAttemptLogEvent({ - eventType: "attempt_aborted", - attemptId, - actionClass: "open_pr", - mode, - reason, - payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber }, - }); - eventLedger.appendEvent({ - type: "attempt_blocked", - repoFullName: parsed.repoFullName, - payload: { issueNumber: parsed.issueNumber, reason }, - }); - const worktreeFailureResult = { - outcome: "blocked_worktree_preparation_failed", - reason, - repoFullName: parsed.repoFullName, - issueNumber: parsed.issueNumber, - minerLogin: parsed.minerLogin, - base: parsed.base, - mode, - attemptId, - }; - if (parsed.json) { - console.log(JSON.stringify(worktreeFailureResult, null, 2)); - } else { - console.error(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: real worktree preparation failed: ${reason}`); - } - options.onResult?.(worktreeFailureResult); - return 6; - } - - // Real SelfReviewContext (#5145): issue/PR/manifest data at live-gate fidelity for the target repo. - const fetchReviewContext = options.fetchSelfReviewContext ?? fetchSelfReviewContext; - const reviewContext = await fetchReviewContext(parsed.repoFullName, { - githubToken: await resolveGitHubToken(env), - contributorLogin: parsed.minerLogin, - linkedIssues: [parsed.issueNumber], - }); - - // The target issue's own real record, when present in the fetched context. When absent (e.g. already - // closed, or genuinely not found), buildCodingTaskSpec's own feasibility check reports target_not_found - // and this placeholder's empty title/body are never surfaced anywhere -- not fabricated content, just an - // inert shape for a verdict that immediately blocks. - const targetIssue = reviewContext.issues.find((candidate) => candidate.number === parsed.issueNumber) ?? { - number: parsed.issueNumber, - title: "", - body: null, - labels: [], - }; - - const buildTaskSpec = options.buildCodingTaskSpec ?? buildCodingTaskSpec; - const codingTaskSpec = buildTaskSpec({ - repoFullName: parsed.repoFullName, - issue: targetIssue, - context: { issues: reviewContext.issues, pullRequests: reviewContext.pullRequests }, - claimLedger, - workingDirectory: worktreeResult.worktreePath, - }); - - if (!codingTaskSpec.ready) { - const reason = `infeasible_${codingTaskSpec.verdict}`; - attemptLog.appendAttemptLogEvent({ - eventType: "attempt_aborted", - attemptId, - actionClass: "open_pr", - mode, - reason, - payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber, feasibility: codingTaskSpec.feasibility }, - }); - eventLedger.appendEvent({ - type: "attempt_blocked", - repoFullName: parsed.repoFullName, - payload: { issueNumber: parsed.issueNumber, reason }, - }); - const infeasibleResult = { - outcome: "blocked_infeasible", - reason, - verdict: codingTaskSpec.verdict, - avoidReasons: codingTaskSpec.feasibility.avoidReasons, - raiseReasons: codingTaskSpec.feasibility.raiseReasons, - repoFullName: parsed.repoFullName, - issueNumber: parsed.issueNumber, - minerLogin: parsed.minerLogin, - base: parsed.base, - mode, - attemptId, - }; - if (parsed.json) { - console.log(JSON.stringify(infeasibleResult, null, 2)); - } else { - console.error( - `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: feasibility verdict "${codingTaskSpec.verdict}" (${[...codingTaskSpec.feasibility.avoidReasons, ...codingTaskSpec.feasibility.raiseReasons].join(", ")}).`, - ); - } - options.onResult?.(infeasibleResult); - return 4; - } - - const amsPolicy = await (options.resolveAmsPolicy ?? resolveAmsPolicy)(parsed.repoFullName, { env }); - - // Real per-repo pause (#5392): read straight from the already-cloned worktree's own .loopover-miner.yml - // (resolveMinerGoalSpec never throws -- a missing/malformed file degrades to killSwitch.paused: false, so - // this can't fail this attempt on its own). Threaded into BOTH checkMinerKillSwitch (killSwitchScope, used - // by the freshness/submission gate) and the governor context (killSwitchRepoPaused, used by the Governor - // chokepoint) -- the same two places the GLOBAL kill switch already reaches. - const resolveGoalSpec = options.resolveMinerGoalSpec ?? resolveMinerGoalSpec; - const minerGoalSpec = resolveGoalSpec(worktreeResult.repoPath); - const repoPaused = minerGoalSpec.spec.killSwitch.paused; - - const checkKillSwitch = options.checkMinerKillSwitch ?? checkMinerKillSwitch; - const recordKillTransition = options.recordMinerKillSwitchTransition ?? recordMinerKillSwitchTransition; - let killSwitchScope = checkKillSwitch({ env, repoPaused }).scope; - let previousKillSwitchScope = killSwitchScope; - - const resolveLiveKillSwitch = () => { - // Re-read the YAML flag each probe so an on-disk unpause/pause is reflected mid-attempt (#5670). - const liveRepoPaused = resolveGoalSpec(worktreeResult.repoPath).spec.killSwitch.paused; - const live = checkKillSwitch({ env, repoPaused: liveRepoPaused }); - if (live.scope !== previousKillSwitchScope) { - try { - recordKillTransition({ + const attemptId = options.attemptId ?? `${parsed.repoFullName.replace("/", "_")}-${parsed.issueNumber}-${nowMs}`; + // #4847: reports what a real run would do and returns BEFORE any store (allocator/claim/event/attempt-log/ + // governor ledger) is even opened, so this is a provable zero-write path -- not just "opened but didn't + // write to" the local stores, and nowhere near the real worktree clone, claim, or coding-agent driver. + if (parsed.dryRun) { + const dryRunResult = { + outcome: "dry_run", repoFullName: parsed.repoFullName, - actionClass: "attempt", - previousScope: previousKillSwitchScope, - scope: live.scope, - }); - } catch (error) { - // Ledger append must never crash an aborting attempt (kept), but was previously silent -- a - // kill-switch flip mid-attempt (a compliance-relevant event) could vanish with no record (#6011). - captureMinerError(error, { kind: "kill_switch_transition_record_failed", repoFullName: parsed.repoFullName, scope: live.scope }); + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); } - previousKillSwitchScope = live.scope; - } - killSwitchScope = live.scope; - return live; - }; - - const shouldAbort = () => { - const live = resolveLiveKillSwitch(); - if (!live.active) return false; - return { - abort: true, - reason: `Kill-switch (${live.scope}) engaged mid-attempt; abandoning without starting another driver iteration.`, - }; - }; - - const loopInput = buildAttemptLoopInput({ - codingTaskSpec, - reviewContext, - worktreePath: worktreeResult.worktreePath, - attemptId, - mode, - repoFullName: parsed.repoFullName, - minerLogin: parsed.minerLogin, - rejectionSignaled: false, - amsPolicySpec: amsPolicy.spec, - branchRef: worktreeResult.branchName, - }); - - // Real per-issue attempt history (#5654): portfolio-queue.js's own claim/reclaim/requeue/done counters, - // keyed the same way opportunity-fanout.js enqueues issue-shaped candidates (`issue:`). No - // apiBaseUrl: this file has no multi-forge host context of its own today, so this reads (and every - // pre-#5563 single-forge caller already reads) the github.com default. - const readAttemptHistory = options.getAttemptHistory ?? getAttemptHistory; - const convergenceInput = readAttemptHistory(parsed.repoFullName, `issue:${parsed.issueNumber}`); - // Real per-repo reputation history (#5675): the miner's own decided/unfavorable outcome streak for this repo, - // read from governor-state.js so the chokepoint's self-reputation throttle sees real data instead of nothing. - const readReputationHistory = options.loadReputationHistory ?? loadReputationHistory; - const reputationHistory = readReputationHistory(parsed.repoFullName); - const governor = buildAttemptGovernorContext(env, amsPolicy.spec, repoPaused, convergenceInput, reputationHistory); - - // Real maxConcurrentClaims enforcement (#6758): the repo's .loopover-miner.yml cap is honored ATOMICALLY by - // the ledger's count-and-claim, not by a listActiveClaims pre-check here. The old check-then-act split -- read - // the count in this file, then record the claim in a separate claimLedger call -- let two sibling miner - // processes racing the same repo both pass a stale sub-cap count and both claim, exceeding the cap. - // claimIssueWithinCap fuses the count and the insert into one transaction; the loser gets `claimed: false` - // and is reported below rather than silently dropped. This is also the real soft-claim (#5393): once it - // returns claimed, a sibling process sees it via claimLedger.listActiveClaims while this attempt is in - // flight, it is released in `finally` on every terminal outcome (mirroring the worktree allocation slot's - // acquire-then-always-release), and its claimedAt feeds the post-submission conflict check further down (#4848). - const claimResult = claimLedger.claimIssueWithinCap( - parsed.repoFullName, - parsed.issueNumber, - `attempt:${attemptId}`, - undefined, - minerGoalSpec.spec.maxConcurrentClaims, - ); - if (!claimResult.claimed) { - const reason = "max_concurrent_claims_exceeded"; - attemptLog.appendAttemptLogEvent({ - eventType: "attempt_aborted", - attemptId, - actionClass: "open_pr", - mode, - reason, - payload: { - repoFullName: parsed.repoFullName, - issueNumber: parsed.issueNumber, - maxConcurrentClaims: minerGoalSpec.spec.maxConcurrentClaims, - activeClaimCount: claimResult.activeClaimCount, - }, - }); - eventLedger.appendEvent({ - type: "attempt_blocked", - repoFullName: parsed.repoFullName, - payload: { issueNumber: parsed.issueNumber, reason }, - }); - const blockedResult = { - outcome: "blocked_max_concurrent_claims", - reason, - maxConcurrentClaims: minerGoalSpec.spec.maxConcurrentClaims, - activeClaimCount: claimResult.activeClaimCount, - repoFullName: parsed.repoFullName, - issueNumber: parsed.issueNumber, - minerLogin: parsed.minerLogin, - base: parsed.base, - mode, - attemptId, - }; - if (parsed.json) { - console.log(JSON.stringify(blockedResult, null, 2)); - } else { - console.error( - `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this repo's maxConcurrentClaims cap (${minerGoalSpec.spec.maxConcurrentClaims}) is already met (${claimResult.activeClaimCount} active claim(s)).`, - ); - } - options.onResult?.(blockedResult); - return 11; - } - - claimRecord = claimResult.claim; - claimedIssue = true; - // Hosted soft-claim coordination (#7168), opt-in via LOOPOVER_MINER_DISCOVERY_PLANE -- gated HERE at the - // call site (not left to submitSoftClaim's own internal check alone) so a disabled plane costs zero calls, - // matching discover-cli.js's supplementWithDiscoveryIndex gating; a caller-injected options.submitSoftClaim - // (tests, or a future programmatic caller) can't accidentally bypass the opt-in this way either. Awaited - // (not fire-and-forget) so a sibling instance racing the same issue is genuinely less likely to start - // duplicate work in the window before this attempt's claim reaches the shared index -- the whole point of - // coordinating BEFORE work begins, not after. - if (isDiscoveryPlaneEnabled(env)) { - const submitClaim = options.submitSoftClaim ?? submitSoftClaim; - await submitClaim(claimRecord, { env }); + else { + console.log(`DRY RUN: would attempt ${parsed.repoFullName}#${parsed.issueNumber} for ${parsed.minerLogin} (mode: ${mode}, base: ${parsed.base}). No worktree, claim, or ledger writes were made.`); + } + options.onResult?.(dryRunResult); + return 0; } - - const runAttemptPipeline = options.runMinerAttempt ?? runMinerAttempt; - let result; + let allocator = null; + let claimLedger = null; + let eventLedger = null; + let attemptLog = null; + let governorLedger = null; + let allocation = null; + let worktreeResult = null; + let claimedIssue = false; + let claimRecord = null; try { - result = await runAttemptPipeline( - { - loopInput, - issueNumber: parsed.issueNumber, - minerLogin: parsed.minerLogin, - base: parsed.base, - killSwitchScope, - slopThreshold: amsPolicy.spec.slopThreshold, - submissionMode: amsPolicy.spec.submissionMode, - governor, - }, - { - ...deps, - shouldAbort, - resolveKillSwitchScope: () => resolveLiveKillSwitch().scope, - }, - ); - } catch (error) { - // A real attempt that CRASHED is exactly the case that most needs its worktree kept for post-mortem - // inspection, so record the failure explicitly before unwinding. Without this, `attemptOk` stayed - // `undefined` and the finally block's `?? true` default (meant for the earlier blocked paths that never - // ran anything in the worktree) deleted it -- inverting shouldRetainWorktree's documented policy. - worktreeResult.attemptOk = false; - throw error; - } - - worktreeResult.attemptOk = result.outcome === "submitted"; - - // Real claim-conflict resolution (#4848): only meaningful once a real PR exists, so this only ever runs - // on a real "submitted" outcome. checkSubmissionFreshness (inside runMinerAttempt) already caught the - // common pre-submission case; this closes the narrower TOCTOU window where two miners raced past that - // check almost simultaneously -- see claim-conflict-resolver.js's own header for why the adjudicator - // can only run POST-submission (it needs a real PR number on both sides of the election). - let claimConflict; - if (result.outcome === "submitted") { - const selfPrNumber = parsePrNumberFromExecResult(result.execResult, parsed.repoFullName); - if (selfPrNumber !== null) { - const resolveConflict = options.resolveClaimConflict ?? resolveClaimConflict; - claimConflict = await resolveConflict( - { + allocator = (options.openWorktreeAllocator ?? openWorktreeAllocator)(); + claimLedger = (options.openClaimLedger ?? openClaimLedger)(); + eventLedger = (options.initEventLedger ?? initEventLedger)(); + attemptLog = (options.initAttemptLog ?? initAttemptLog)(); + governorLedger = (options.initGovernorLedger ?? initGovernorLedger)(); + // Checked before acquiring a worktree slot: a rejection-signaled repo should never consume one. + // resolveRejectionSignaled resolves both documented triggers (#5132 policy ban, #5655 own-rejection + // history) and returns a trigger-specific reason string for accurate audit-trail labeling. + const resolveRejection = options.resolveRejectionSignaled ?? resolveRejectionSignaled; + // Pass fetchImpl through even when unset (same shape the .js always produced); cast for + // exactOptionalPropertyTypes vs RejectionSignaledOptions (pre-existing optional-prop drift). + const rejectionSignal = await resolveRejection(parsed.repoFullName, { + fetchImpl: options.fetchImpl, + }); + if (rejectionSignal) { + const reason = rejectionSignal === true ? REJECTION_REASON_AI_USAGE_POLICY_BAN : rejectionSignal; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const rejectedResult = { + outcome: "blocked_rejection_signaled", + reason, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(rejectedResult, null, 2)); + } + else { + console.error(reason === REJECTION_REASON_OWN_SUBMISSION_REJECTED + ? `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this miner was previously rejected on this repo.` + : `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this repo's AI-usage policy bans automated/AI-authored contributions.`); + } + options.onResult?.(rejectedResult); + return 5; + } + allocation = allocator.acquire(attemptId, parsed.repoFullName); + let deps; + try { + const buildDeps = options.buildAttemptDeps ?? buildAttemptDeps; + deps = buildDeps(env, { claimLedger, eventLedger, attemptLog, governorLedger, nowMs }); + } + catch (error) { + const reason = describeCliError(error); + return reportCliFailure(parsed.json, `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: ${reason}`, 3); + } + // Real worktree preparation (repo-clone.js + attempt-worktree.js, #5237): the allocator above only + // reserves a concurrency SLOT (worktree-allocator.js's own `slot-N` placeholder dirs never receive real + // git content) -- this is the step that actually clones/fetches the target repo and creates a real + // `git worktree` for this attempt. Its own path, NOT the allocator's slot path, is the real + // workingDirectory a future runMinerAttempt call must use. + const prepareWorktree = options.prepareAttemptWorktree ?? prepareAttemptWorktree; + worktreeResult = await prepareWorktree(parsed.repoFullName, attemptId, { baseBranch: parsed.base, env }); + if (!worktreeResult.ok) { + const reason = worktreeResult.error; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const worktreeFailureResult = { + outcome: "blocked_worktree_preparation_failed", + reason, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(worktreeFailureResult, null, 2)); + } + else { + console.error(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: real worktree preparation failed: ${reason}`); + } + options.onResult?.(worktreeFailureResult); + return 6; + } + // Real SelfReviewContext (#5145): issue/PR/manifest data at live-gate fidelity for the target repo. + const fetchReviewContext = options.fetchSelfReviewContext ?? fetchSelfReviewContext; + const reviewGithubToken = await resolveGitHubToken(env); + const reviewContext = await fetchReviewContext(parsed.repoFullName, { + ...(reviewGithubToken !== null ? { githubToken: reviewGithubToken } : {}), + contributorLogin: parsed.minerLogin, + linkedIssues: [parsed.issueNumber], + }); + // The target issue's own real record, when present in the fetched context. When absent (e.g. already + // closed, or genuinely not found), buildCodingTaskSpec's own feasibility check reports target_not_found + // and this placeholder's empty title/body are never surfaced anywhere -- not fabricated content, just an + // inert shape for a verdict that immediately blocks. + const targetIssue = reviewContext.issues.find((candidate) => candidate.number === parsed.issueNumber) ?? { + number: parsed.issueNumber, + title: "", + body: null, + labels: [], + }; + const buildTaskSpec = options.buildCodingTaskSpec ?? buildCodingTaskSpec; + // CodingTaskClaimLedger's listClaims filter types status as plain string (pre-existing .d.ts drift). + const codingTaskSpec = buildTaskSpec({ + repoFullName: parsed.repoFullName, + issue: targetIssue, + context: { issues: reviewContext.issues, pullRequests: reviewContext.pullRequests }, + claimLedger: claimLedger, + workingDirectory: worktreeResult.worktreePath, + }); + if (!codingTaskSpec.ready) { + const reason = `infeasible_${codingTaskSpec.verdict}`; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber, feasibility: codingTaskSpec.feasibility }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const infeasibleResult = { + outcome: "blocked_infeasible", + reason, + verdict: codingTaskSpec.verdict, + avoidReasons: codingTaskSpec.feasibility.avoidReasons, + raiseReasons: codingTaskSpec.feasibility.raiseReasons, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(infeasibleResult, null, 2)); + } + else { + console.error(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: feasibility verdict "${codingTaskSpec.verdict}" (${[...codingTaskSpec.feasibility.avoidReasons, ...codingTaskSpec.feasibility.raiseReasons].join(", ")}).`); + } + options.onResult?.(infeasibleResult); + return 4; + } + const amsPolicy = await (options.resolveAmsPolicy ?? resolveAmsPolicy)(parsed.repoFullName, { env }); + // Real per-repo pause (#5392): read straight from the already-cloned worktree's own .loopover-miner.yml + // (resolveMinerGoalSpec never throws -- a missing/malformed file degrades to killSwitch.paused: false, so + // this can't fail this attempt on its own). Threaded into BOTH checkMinerKillSwitch (killSwitchScope, used + // by the freshness/submission gate) and the governor context (killSwitchRepoPaused, used by the Governor + // chokepoint) -- the same two places the GLOBAL kill switch already reaches. + const resolveGoalSpec = options.resolveMinerGoalSpec ?? resolveMinerGoalSpec; + const minerGoalSpec = resolveGoalSpec(worktreeResult.repoPath); + const repoPaused = minerGoalSpec.spec.killSwitch.paused; + const checkKillSwitch = options.checkMinerKillSwitch ?? checkMinerKillSwitch; + // recordMinerKillSwitchTransition is used at runtime but omitted from RunAttemptOptions (.d.ts drift). + const recordKillTransition = options + .recordMinerKillSwitchTransition ?? recordMinerKillSwitchTransition; + let killSwitchScope = checkKillSwitch({ env, repoPaused }).scope; + let previousKillSwitchScope = killSwitchScope; + // Captured after the ok-check above so the mid-attempt kill-switch probe can't see a null worktreeResult. + const preparedWorktree = worktreeResult; + const resolveLiveKillSwitch = () => { + // Re-read the YAML flag each probe so an on-disk unpause/pause is reflected mid-attempt (#5670). + const liveRepoPaused = resolveGoalSpec(preparedWorktree.repoPath).spec.killSwitch.paused; + const live = checkKillSwitch({ env, repoPaused: liveRepoPaused }); + if (live.scope !== previousKillSwitchScope) { + try { + recordKillTransition({ + repoFullName: parsed.repoFullName, + actionClass: "attempt", + previousScope: previousKillSwitchScope, + scope: live.scope, + }); + } + catch (error) { + // Ledger append must never crash an aborting attempt (kept), but was previously silent -- a + // kill-switch flip mid-attempt (a compliance-relevant event) could vanish with no record (#6011). + captureMinerError(error, { kind: "kill_switch_transition_record_failed", repoFullName: parsed.repoFullName, scope: live.scope }); + } + previousKillSwitchScope = live.scope; + } + killSwitchScope = live.scope; + return live; + }; + const shouldAbort = () => { + const live = resolveLiveKillSwitch(); + if (!live.active) + return false; + return { + abort: true, + reason: `Kill-switch (${live.scope}) engaged mid-attempt; abandoning without starting another driver iteration.`, + }; + }; + const loopInput = buildAttemptLoopInput({ + codingTaskSpec, + reviewContext, + worktreePath: worktreeResult.worktreePath, + attemptId, + mode, repoFullName: parsed.repoFullName, - issueNumber: parsed.issueNumber, - selfPrNumber, - selfClaimedAt: claimRecord.claimedAt, minerLogin: parsed.minerLogin, - }, - { fetchLiveIssueSnapshot: deps.fetchLiveIssueSnapshot, executeLocalWrite: deps.executeLocalWrite }, - ); - } - - // Real own-submission history (#5655 follow-up): governor-state.js's recordOwnSubmission/ - // listRecentOwnSubmissions store (#5134) existed and was already READ by resolveOwnRejectionHistory - // (#5655), but nothing ever WROTE to it -- attempt-runner.js's own header names this exact gap - // ("real persistence primitives... but isn't auto-loaded here yet"). Left unfixed, that trigger is a - // silent no-op in every real deployment: an empty table always resolves "no prior submissions found." - // The fingerprint is the real changed-files set from the loop's own handoff packet (never fabricated) -- - // omitted (not recorded as an empty placeholder) when the packet reports no changed files at all. A - // logging failure must never fail an otherwise-successful attempt, matching the summary-event write below. - const changedFiles = result.loopResult.handoffPacket?.changedFiles?.map((file) => file.path) ?? []; - const fingerprint = fingerprintFromChangedFiles(changedFiles); - if (fingerprint) { + rejectionSignaled: false, + amsPolicySpec: amsPolicy.spec, + branchRef: worktreeResult.branchName, + }); + // Real per-issue attempt history (#5654): portfolio-queue.js's own claim/reclaim/requeue/done counters, + // keyed the same way opportunity-fanout.js enqueues issue-shaped candidates (`issue:`). No + // apiBaseUrl: this file has no multi-forge host context of its own today, so this reads (and every + // pre-#5563 single-forge caller already reads) the github.com default. + const readAttemptHistory = options.getAttemptHistory ?? getAttemptHistory; + const convergenceInput = readAttemptHistory(parsed.repoFullName, `issue:${parsed.issueNumber}`); + // Real per-repo reputation history (#5675): the miner's own decided/unfavorable outcome streak for this repo, + // read from governor-state.js so the chokepoint's self-reputation throttle sees real data instead of nothing. + // loadReputationHistory is used at runtime but omitted from RunAttemptOptions (.d.ts drift). + const readReputationHistory = options.loadReputationHistory ?? + loadReputationHistory; + const reputationHistory = readReputationHistory(parsed.repoFullName); + const governor = buildAttemptGovernorContext(env, amsPolicy.spec, repoPaused, convergenceInput, reputationHistory); + // Real maxConcurrentClaims enforcement (#6758): the repo's .loopover-miner.yml cap is honored ATOMICALLY by + // the ledger's count-and-claim, not by a listActiveClaims pre-check here. The old check-then-act split -- read + // the count in this file, then record the claim in a separate claimLedger call -- let two sibling miner + // processes racing the same repo both pass a stale sub-cap count and both claim, exceeding the cap. + // claimIssueWithinCap fuses the count and the insert into one transaction; the loser gets `claimed: false` + // and is reported below rather than silently dropped. This is also the real soft-claim (#5393): once it + // returns claimed, a sibling process sees it via claimLedger.listActiveClaims while this attempt is in + // flight, it is released in `finally` on every terminal outcome (mirroring the worktree allocation slot's + // acquire-then-always-release), and its claimedAt feeds the post-submission conflict check further down (#4848). + const claimResult = claimLedger.claimIssueWithinCap(parsed.repoFullName, parsed.issueNumber, `attempt:${attemptId}`, undefined, minerGoalSpec.spec.maxConcurrentClaims); + if (!claimResult.claimed) { + const reason = "max_concurrent_claims_exceeded"; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + maxConcurrentClaims: minerGoalSpec.spec.maxConcurrentClaims, + activeClaimCount: claimResult.activeClaimCount, + }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const blockedResult = { + outcome: "blocked_max_concurrent_claims", + reason, + maxConcurrentClaims: minerGoalSpec.spec.maxConcurrentClaims, + activeClaimCount: claimResult.activeClaimCount, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(blockedResult, null, 2)); + } + else { + console.error(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this repo's maxConcurrentClaims cap (${minerGoalSpec.spec.maxConcurrentClaims}) is already met (${claimResult.activeClaimCount} active claim(s)).`); + } + // blocked_max_concurrent_claims is a real runtime outcome omitted from AttemptCliResult (.d.ts drift). + options.onResult?.(blockedResult); + return 11; + } + claimRecord = claimResult.claim; + claimedIssue = true; + // Hosted soft-claim coordination (#7168), opt-in via LOOPOVER_MINER_DISCOVERY_PLANE -- gated HERE at the + // call site (not left to submitSoftClaim's own internal check alone) so a disabled plane costs zero calls, + // matching discover-cli.js's supplementWithDiscoveryIndex gating; a caller-injected options.submitSoftClaim + // (tests, or a future programmatic caller) can't accidentally bypass the opt-in this way either. Awaited + // (not fire-and-forget) so a sibling instance racing the same issue is genuinely less likely to start + // duplicate work in the window before this attempt's claim reaches the shared index -- the whole point of + // coordinating BEFORE work begins, not after. + if (isDiscoveryPlaneEnabled(env)) { + const submitClaim = options.submitSoftClaim ?? submitSoftClaim; + await submitClaim(claimRecord, { env }); + } + const runAttemptPipeline = options.runMinerAttempt ?? runMinerAttempt; + let result; try { - const record = options.recordOwnSubmission ?? recordOwnSubmission; - record({ + result = await runAttemptPipeline({ + loopInput, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + killSwitchScope, + slopThreshold: amsPolicy.spec.slopThreshold, + submissionMode: amsPolicy.spec.submissionMode, + governor, + }, { + ...deps, + shouldAbort, + resolveKillSwitchScope: () => resolveLiveKillSwitch().scope, + }); + } + catch (error) { + // A real attempt that CRASHED is exactly the case that most needs its worktree kept for post-mortem + // inspection, so record the failure explicitly before unwinding. Without this, `attemptOk` stayed + // `undefined` and the finally block's `?? true` default (meant for the earlier blocked paths that never + // ran anything in the worktree) deleted it -- inverting shouldRetainWorktree's documented policy. + worktreeResult.attemptOk = false; + throw error; + } + worktreeResult.attemptOk = result.outcome === "submitted"; + // Real claim-conflict resolution (#4848): only meaningful once a real PR exists, so this only ever runs + // on a real "submitted" outcome. checkSubmissionFreshness (inside runMinerAttempt) already caught the + // common pre-submission case; this closes the narrower TOCTOU window where two miners raced past that + // check almost simultaneously -- see claim-conflict-resolver.js's own header for why the adjudicator + // can only run POST-submission (it needs a real PR number on both sides of the election). + let claimConflict; + if (result.outcome === "submitted") { + const selfPrNumber = parsePrNumberFromExecResult(result.execResult, parsed.repoFullName); + if (selfPrNumber !== null) { + const resolveConflict = options.resolveClaimConflict ?? resolveClaimConflict; + claimConflict = await resolveConflict({ + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + selfPrNumber, + selfClaimedAt: claimRecord.claimedAt, + minerLogin: parsed.minerLogin, + }, { fetchLiveIssueSnapshot: deps.fetchLiveIssueSnapshot, executeLocalWrite: deps.executeLocalWrite }); + } + // Real own-submission history (#5655 follow-up): governor-state.js's recordOwnSubmission/ + // listRecentOwnSubmissions store (#5134) existed and was already READ by resolveOwnRejectionHistory + // (#5655), but nothing ever WROTE to it -- attempt-runner.js's own header names this exact gap + // ("real persistence primitives... but isn't auto-loaded here yet"). Left unfixed, that trigger is a + // silent no-op in every real deployment: an empty table always resolves "no prior submissions found." + // The fingerprint is the real changed-files set from the loop's own handoff packet (never fabricated) -- + // omitted (not recorded as an empty placeholder) when the packet reports no changed files at all. A + // logging failure must never fail an otherwise-successful attempt, matching the summary-event write below. + const changedFiles = result.loopResult.handoffPacket?.changedFiles?.map((file) => file.path) ?? []; + const fingerprint = fingerprintFromChangedFiles(changedFiles); + if (fingerprint) { + try { + const record = options.recordOwnSubmission ?? recordOwnSubmission; + record({ + repoFullName: parsed.repoFullName, + fingerprint, + submittedAt: new Date(nowMs).toISOString(), + pullRequestNumber: selfPrNumber, + issueNumber: parsed.issueNumber, + }); + } + catch (error) { + // A logging failure must never fail an otherwise-successful attempt (kept), but was previously + // silent -- if this write fails AFTER a real PR has already opened, future self-plagiarism checks go + // permanently blind to this exact submission with nobody told (#6011). + captureMinerError(error, { kind: "record_own_submission_failed", repoFullName: parsed.repoFullName, pullRequestNumber: selfPrNumber }); + } + } + } + const finalResult = { + outcome: `attempt_${result.outcome}`, repoFullName: parsed.repoFullName, - fingerprint, - submittedAt: new Date(nowMs).toISOString(), - pullRequestNumber: selfPrNumber, issueNumber: parsed.issueNumber, - }); - } catch (error) { - // A logging failure must never fail an otherwise-successful attempt (kept), but was previously - // silent -- if this write fails AFTER a real PR has already opened, future self-plagiarism checks go - // permanently blind to this exact submission with nobody told (#6011). - captureMinerError(error, { kind: "record_own_submission_failed", repoFullName: parsed.repoFullName, pullRequestNumber: selfPrNumber }); + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + submissionMode: amsPolicy.spec.submissionMode, + // Every runMinerAttempt outcome carries a real loopResult (#5135's loop needs its genuine turn-usage and + // cost to save real GovernorCapUsage via governor-state.js's saveCapUsage -- nothing else in the codebase + // calls it yet). Surfaced flat rather than the whole loopResult object, matching this result's own + // shallow shape. costUsd is real only for the agent-sdk provider (its own SDK result message reports + // total_cost_usd); CLI-subprocess providers (claude-cli/codex-cli) report no cost signal today, so this + // is 0 for those -- an honest absence, not a fabricated number. + totalTurnsUsed: result.loopResult.totalTurnsUsed, + totalCostUsd: result.loopResult.totalCostUsd, + // Real accumulated tokens (#5653) -- read from finalMeterTotals rather than a flat totalTokensUsed field + // (IterateLoopResult has no such flat field, unlike turns/cost). 0 when no driver reported a token signal + // on any iteration this attempt ran, never fabricated. + totalTokensUsed: result.loopResult.finalMeterTotals.tokens, + iterationsUsed: result.loopResult.iterationsUsed, + ...(result.outcome === "abandon" && result.loopResult.finalDecision?.abandonReason + ? { abandonReason: result.loopResult.finalDecision.abandonReason } + : {}), + ...("reason" in result ? { reason: result.reason } : {}), + ...("decision" in result ? { decision: result.decision } : {}), + ...("spec" in result ? { spec: result.spec } : {}), + ...("execResult" in result ? { execResult: result.execResult } : {}), + // Present only on a real "submitted" outcome whose PR number was recoverable from execResult -- omitted + // (not fabricated as "checked: false") on every other outcome, and on a submitted outcome where the new + // PR's number genuinely couldn't be parsed (an honest gap, not silently swallowed). + ...(claimConflict !== undefined ? { claimConflict } : {}), + }; + // One summary row per completed attempt (#5185), for the Grafana per-provider usage dashboard the redacted + // AMS reporting export exposes -- distinct from the per-iteration attempt_started/attempt_tool_edit/... trail + // iterate-loop.ts already writes. No fallback for an unconfigured provider: buildAttemptDeps already fails + // closed (throws) on the same env before a worktree is even allocated, so reaching this point guarantees + // resolveFirstConfiguredCodingAgentDriverName(env) resolves a real name. costUsd/tokensUsed are both real, + // driver-reported accumulated totals (#5653) -- 0 when no iteration's driver reported a signal, never + // fabricated. A logging failure must never fail an otherwise-successful attempt -- mirrors iterate-loop.ts's + // own safeAppendAttemptLogEvent non-fatal handling. + try { + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_outcome_summary", + attemptId, + actionClass: finalResult.outcome, + mode, + reason: `attempt finished with outcome: ${result.outcome}`, + provider: resolveFirstConfiguredCodingAgentDriverName(env), + costUsd: finalResult.totalCostUsd, + tokensUsed: finalResult.totalTokensUsed, + }); + } + catch (error) { + // A logging failure must never fail an otherwise-successful attempt (kept), but was previously silent -- + // per docs/observability.md this row feeds the Grafana per-provider cost/usage dashboard, so a failure + // here silently drops the attempt from operator-facing metrics with nobody told (#6011). + captureMinerError(error, { kind: "attempt_outcome_summary_append_failed", attemptId, repoFullName: parsed.repoFullName }); + } + if (parsed.json) { + console.log(JSON.stringify(finalResult, null, 2)); + } + else { + console.log(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} finished with outcome: ${result.outcome}.`); + } + options.onResult?.(finalResult); + switch (result.outcome) { + case "submitted": + return 0; + case "abandon": + return 7; + case "stale": + return 8; + case "blocked": + return 9; + case "governed": + return 10; + default: + return 2; } - } - } - - const finalResult = { - outcome: `attempt_${result.outcome}`, - repoFullName: parsed.repoFullName, - issueNumber: parsed.issueNumber, - minerLogin: parsed.minerLogin, - base: parsed.base, - mode, - attemptId, - submissionMode: amsPolicy.spec.submissionMode, - // Every runMinerAttempt outcome carries a real loopResult (#5135's loop needs its genuine turn-usage and - // cost to save real GovernorCapUsage via governor-state.js's saveCapUsage -- nothing else in the codebase - // calls it yet). Surfaced flat rather than the whole loopResult object, matching this result's own - // shallow shape. costUsd is real only for the agent-sdk provider (its own SDK result message reports - // total_cost_usd); CLI-subprocess providers (claude-cli/codex-cli) report no cost signal today, so this - // is 0 for those -- an honest absence, not a fabricated number. - totalTurnsUsed: result.loopResult.totalTurnsUsed, - totalCostUsd: result.loopResult.totalCostUsd, - // Real accumulated tokens (#5653) -- read from finalMeterTotals rather than a flat totalTokensUsed field - // (IterateLoopResult has no such flat field, unlike turns/cost). 0 when no driver reported a token signal - // on any iteration this attempt ran, never fabricated. - totalTokensUsed: result.loopResult.finalMeterTotals.tokens, - iterationsUsed: result.loopResult.iterationsUsed, - ...(result.outcome === "abandon" && result.loopResult.finalDecision?.abandonReason - ? { abandonReason: result.loopResult.finalDecision.abandonReason } - : {}), - ...("reason" in result ? { reason: result.reason } : {}), - ...("decision" in result ? { decision: result.decision } : {}), - ...("spec" in result ? { spec: result.spec } : {}), - ...("execResult" in result ? { execResult: result.execResult } : {}), - // Present only on a real "submitted" outcome whose PR number was recoverable from execResult -- omitted - // (not fabricated as "checked: false") on every other outcome, and on a submitted outcome where the new - // PR's number genuinely couldn't be parsed (an honest gap, not silently swallowed). - ...(claimConflict !== undefined ? { claimConflict } : {}), - }; - - // One summary row per completed attempt (#5185), for the Grafana per-provider usage dashboard the redacted - // AMS reporting export exposes -- distinct from the per-iteration attempt_started/attempt_tool_edit/... trail - // iterate-loop.ts already writes. No fallback for an unconfigured provider: buildAttemptDeps already fails - // closed (throws) on the same env before a worktree is even allocated, so reaching this point guarantees - // resolveFirstConfiguredCodingAgentDriverName(env) resolves a real name. costUsd/tokensUsed are both real, - // driver-reported accumulated totals (#5653) -- 0 when no iteration's driver reported a signal, never - // fabricated. A logging failure must never fail an otherwise-successful attempt -- mirrors iterate-loop.ts's - // own safeAppendAttemptLogEvent non-fatal handling. - try { - attemptLog.appendAttemptLogEvent({ - eventType: "attempt_outcome_summary", - attemptId, - actionClass: finalResult.outcome, - mode, - reason: `attempt finished with outcome: ${result.outcome}`, - provider: resolveFirstConfiguredCodingAgentDriverName(env), - costUsd: finalResult.totalCostUsd, - tokensUsed: finalResult.totalTokensUsed, - }); - } catch (error) { - // A logging failure must never fail an otherwise-successful attempt (kept), but was previously silent -- - // per docs/observability.md this row feeds the Grafana per-provider cost/usage dashboard, so a failure - // here silently drops the attempt from operator-facing metrics with nobody told (#6011). - captureMinerError(error, { kind: "attempt_outcome_summary_append_failed", attemptId, repoFullName: parsed.repoFullName }); - } - - if (parsed.json) { - console.log(JSON.stringify(finalResult, null, 2)); - } else { - console.log(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} finished with outcome: ${result.outcome}.`); - } - options.onResult?.(finalResult); - - switch (result.outcome) { - case "submitted": - return 0; - case "abandon": - return 7; - case "stale": - return 8; - case "blocked": - return 9; - case "governed": - return 10; - default: - return 2; } - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } finally { - // worktreeResult.attemptOk is set to the REAL runMinerAttempt outcome (submitted = true) once that call - // happens, and explicitly to `false` when that call THROWS -- a crashed attempt is precisely what needs a - // retained worktree to postmortem, so it must never fall through to the `?? true` default below. Every - // earlier blocked path (rejection/worktree-prep-failure/infeasible) never sets it, since nothing ran in - // the worktree to postmortem -- those are the cases that default to `true` (nothing to retain), matching - // cleanupAttemptWorktree's own retention policy (a failed REAL attempt is what gets retained). - if (worktreeResult?.ok) { - const cleanupWorktree = options.cleanupAttemptWorktree ?? cleanupAttemptWorktree; - await cleanupWorktree(worktreeResult.repoPath, worktreeResult.worktreePath, worktreeResult.attemptOk ?? true); + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); } - // Every terminal outcome past the claim point (submitted/abandon/stale/blocked/governed, or an - // unexpected throw) releases the soft-claim -- a claim that outlives its own attempt process would - // wrongly tell a sibling miner this issue is still in flight. - if (claimedIssue && claimLedger) claimLedger.releaseClaim(parsed.repoFullName, parsed.issueNumber); - // Paired hosted release (#7168): same call-site opt-in gate as the claim submission above. Only fires when - // the initial claim submission actually ran (claimRecord is only set once claimedIssue is), so a run that - // never reached the claim point (e.g. blocked_max_concurrent_claims) has nothing to release remotely. - if (claimedIssue && claimRecord && isDiscoveryPlaneEnabled(env)) { - const submitClaim = options.submitSoftClaim ?? submitSoftClaim; - await submitClaim({ ...claimRecord, status: "released" }, { env }); + finally { + // worktreeResult.attemptOk is set to the REAL runMinerAttempt outcome (submitted = true) once that call + // happens, and explicitly to `false` when that call THROWS -- a crashed attempt is precisely what needs a + // retained worktree to postmortem, so it must never fall through to the `?? true` default below. Every + // earlier blocked path (rejection/worktree-prep-failure/infeasible) never sets it, since nothing ran in + // the worktree to postmortem -- those are the cases that default to `true` (nothing to retain), matching + // cleanupAttemptWorktree's own retention policy (a failed REAL attempt is what gets retained). + if (worktreeResult?.ok) { + const cleanupWorktree = options.cleanupAttemptWorktree ?? cleanupAttemptWorktree; + await cleanupWorktree(worktreeResult.repoPath, worktreeResult.worktreePath, worktreeResult.attemptOk ?? true); + } + // Every terminal outcome past the claim point (submitted/abandon/stale/blocked/governed, or an + // unexpected throw) releases the soft-claim -- a claim that outlives its own attempt process would + // wrongly tell a sibling miner this issue is still in flight. + if (claimedIssue && claimLedger) + claimLedger.releaseClaim(parsed.repoFullName, parsed.issueNumber); + // Paired hosted release (#7168): same call-site opt-in gate as the claim submission above. Only fires when + // the initial claim submission actually ran (claimRecord is only set once claimedIssue is), so a run that + // never reached the claim point (e.g. blocked_max_concurrent_claims) has nothing to release remotely. + if (claimedIssue && claimRecord && isDiscoveryPlaneEnabled(env)) { + const submitClaim = options.submitSoftClaim ?? submitSoftClaim; + await submitClaim({ ...claimRecord, status: "released" }, { env }); + } + if (allocation && allocator) + allocator.release(attemptId); + allocator?.close(); + claimLedger?.close(); + eventLedger?.close(); + attemptLog?.close(); + governorLedger?.close(); } - if (allocation && allocator) allocator.release(attemptId); - allocator?.close(); - claimLedger?.close(); - eventLedger?.close(); - attemptLog?.close(); - governorLedger?.close(); - } } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYXR0ZW1wdC1jbGkuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJhdHRlbXB0LWNsaS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxvSEFBb0g7QUFDcEgscUdBQXFHO0FBQ3JHLDBHQUEwRztBQUMxRyw2R0FBNkc7QUFDN0csOEdBQThHO0FBQzlHLDJHQUEyRztBQUMzRyxxR0FBcUc7QUFDckcsMkZBQTJGO0FBQzNGLHFGQUFxRjtBQUNyRixFQUFFO0FBQ0YsMEdBQTBHO0FBQzFHLGtIQUFrSDtBQUNsSCxpSEFBaUg7QUFDakgsMkdBQTJHO0FBRTNHLE9BQU8sRUFBRSwyQkFBMkIsRUFBRSxnQ0FBZ0MsRUFBRSwyQ0FBMkMsRUFBRSxNQUFNLGtCQUFrQixDQUFDO0FBRTlJLE9BQU8sRUFBRSxZQUFZLEVBQUUsZ0JBQWdCLEVBQUUsZ0JBQWdCLEVBQUUsTUFBTSxnQkFBZ0IsQ0FBQztBQUNsRixPQUFPLEVBQUUsb0NBQW9DLEVBQUUsTUFBTSxnQ0FBZ0MsQ0FBQztBQUN0RixPQUFPLEVBQUUsaUJBQWlCLEVBQUUsTUFBTSxzQkFBc0IsQ0FBQztBQUN6RCxPQUFPLEVBQUUsc0JBQXNCLEVBQUUsTUFBTSwwQkFBMEIsQ0FBQztBQUNsRSxPQUFPLEVBQUUsaUJBQWlCLEVBQUUsTUFBTSwwQkFBMEIsQ0FBQztBQUM3RCxPQUFPLEVBQUUsZUFBZSxFQUFFLE1BQU0sbUJBQW1CLENBQUM7QUFFcEQsT0FBTyxFQUFFLG9CQUFvQixFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFDNUQsT0FBTyxFQUFFLG9CQUFvQixFQUFFLE1BQU0sOEJBQThCLENBQUM7QUFFcEUsT0FBTyxFQUFFLDJCQUEyQixFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFDbkUsT0FBTyxFQUFFLGVBQWUsRUFBRSxNQUFNLG1CQUFtQixDQUFDO0FBRXBELE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUVsRCxPQUFPLEVBQUUsa0JBQWtCLEVBQUUsTUFBTSxzQkFB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/attempt-cli.ts b/packages/loopover-miner/lib/attempt-cli.ts new file mode 100644 index 0000000000..2367774f46 --- /dev/null +++ b/packages/loopover-miner/lib/attempt-cli.ts @@ -0,0 +1,866 @@ +// CLI dispatch for the real attempt pipeline (#5132, Wave 3.5 -- the final assembly). Wires bin/loopover-miner.js's +// `attempt` subcommand to real infrastructure end to end: worktree allocation + real git preparation +// (worktree-allocator.js + attempt-worktree.js), the four ledgers (claim/event/attempt-log/governor), the +// real coding-agent driver (#5131) and slop assessor (#5133), a live SelfReviewContext fetch (#5145), a real +// coding-task spec (#5239), the operator's AmsPolicySpec execution policy (#5249), rejectionSignaled (#5241), +// a real runMinerAttempt call -- the first point in this epic where a real coding agent actually runs, not +// just checks-and-reports-blocked -- and, only on a real "submitted" outcome, a real post-submission +// claim-conflict resolution (#4848, claim-conflict-resolver.js) for the narrow race window +// checkSubmissionFreshness cannot see (two miners submitting almost simultaneously). +// +// KNOWN, DOCUMENTED GAPS (not fabricated -- see attempt-input-builder.js's own header for the full list): +// governor.selfPlagiarismCandidate/selfPlagiarismRecentSubmissions are omitted (chokepoint.ts's own design treats +// that as "skip that stage entirely"). governor.convergenceInput is now a real per-issue portfolio-queue.js read +// (#5654) and governor.reputationHistory a real per-repo governor-state.js read (#5675), not placeholders. + +import { fingerprintFromChangedFiles, resolveCodingAgentModeFromConfig, resolveFirstConfiguredCodingAgentDriverName } from "@loopover/engine"; +import type { CodingAgentExecutionMode, FeasibilityVerdict, LocalWriteActionSpec } from "@loopover/engine"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; +import { constructProductionCodingAgentDriver } from "./coding-agent-construction.js"; +import { runSlopAssessment } from "./slop-assessment.js"; +import { fetchLiveIssueSnapshot } from "./live-issue-snapshot.js"; +import { executeLocalWrite } from "./execute-local-write.js"; +import { openClaimLedger } from "./claim-ledger.js"; +import type { ClaimEntry, ClaimLedger } from "./claim-ledger.js"; +import { resolveMinerGoalSpec } from "./miner-goal-spec.js"; +import { resolveClaimConflict } from "./claim-conflict-resolver.js"; +import type { ClaimConflictResult, resolveClaimConflict as ResolveClaimConflictFn } from "./claim-conflict-resolver.js"; +import { parsePrNumberFromExecResult } from "./pr-number-parse.js"; +import { initEventLedger } from "./event-ledger.js"; +import type { EventLedger } from "./event-ledger.js"; +import { initAttemptLog } from "./attempt-log.js"; +import type { AttemptLog } from "./attempt-log.js"; +import { initGovernorLedger } from "./governor-ledger.js"; +import type { GovernorLedger } from "./governor-ledger.js"; +import { openWorktreeAllocator } from "./worktree-allocator.js"; +import type { WorktreeAllocation, WorktreeAllocator } from "./worktree-allocator.js"; +import { isValidRepoSegment } from "./repo-clone.js"; +import { REJECTION_REASON_AI_USAGE_POLICY_BAN, REJECTION_REASON_OWN_SUBMISSION_REJECTED, resolveRejectionSignaled } from "./rejection-signal.js"; +import type { resolveRejectionSignaled as ResolveRejectionSignaledFn } from "./rejection-signal.js"; +import { cleanupAttemptWorktree, prepareAttemptWorktree } from "./attempt-worktree.js"; +import type { + cleanupAttemptWorktree as CleanupAttemptWorktreeFn, + prepareAttemptWorktree as PrepareAttemptWorktreeFn, + PrepareAttemptWorktreeResult, +} from "./attempt-worktree.js"; +import { fetchSelfReviewContext } from "./self-review-context.js"; +import type { SelfReviewContextFetch, fetchSelfReviewContext as FetchSelfReviewContextFn } from "./self-review-context.js"; +import { buildCodingTaskSpec } from "./coding-task-spec.js"; +import type { buildCodingTaskSpec as BuildCodingTaskSpecFn } from "./coding-task-spec.js"; +import { resolveAmsPolicy } from "./ams-policy.js"; +import type { resolveAmsPolicy as ResolveAmsPolicyFn } from "./ams-policy.js"; +import { checkMinerKillSwitch, recordMinerKillSwitchTransition } from "./governor-kill-switch.js"; +import type { checkMinerKillSwitch as CheckMinerKillSwitchFn } from "./governor-kill-switch.js"; +import { captureMinerError } from "./sentry.js"; +import { buildAttemptGovernorContext, buildAttemptLoopInput } from "./attempt-input-builder.js"; +import { getAttemptHistory } from "./portfolio-queue.js"; +import type { getAttemptHistory as GetAttemptHistoryFn } from "./portfolio-queue.js"; +import { loadReputationHistory, recordOwnSubmission } from "./governor-state.js"; +import type { recordOwnSubmission as RecordOwnSubmissionFn } from "./governor-state.js"; +import { runMinerAttempt } from "./attempt-runner.js"; +import type { AttemptDeps, AttemptResult as RunMinerAttemptResult, runMinerAttempt as RunMinerAttemptFn } from "./attempt-runner.js"; +import { resolveGitHubToken } from "./github-token-resolution.js"; +import { isDiscoveryPlaneEnabled, submitSoftClaim } from "./discovery-index-client.js"; +import type { submitSoftClaim as SubmitSoftClaimFn } from "./discovery-index-client.js"; +import type { resolveMinerGoalSpec as ResolveMinerGoalSpecFn } from "./miner-goal-spec.js"; + + +type CommonAttemptResultFields = { + repoFullName: string; + issueNumber: number; + minerLogin: string; + base: string; + mode: CodingAgentExecutionMode; + attemptId: string; +}; + +/** The result runAttempt reports at every real return point, threaded to `options.onResult` (in addition to + * the plain exit-code return runAttempt itself still returns, unchanged, so bin/loopover-miner.js's own + * `process.exit(exitCode)` usage never breaks) -- the loop orchestrator's real caller for this data. */ +export type AttemptCliResult = + | (CommonAttemptResultFields & { outcome: "dry_run" }) + | (CommonAttemptResultFields & { outcome: "blocked_rejection_signaled"; reason: string }) + | (CommonAttemptResultFields & { outcome: "blocked_worktree_preparation_failed"; reason: string }) + | (CommonAttemptResultFields & { + outcome: "blocked_infeasible"; + reason: string; + verdict: FeasibilityVerdict; + avoidReasons: string[]; + raiseReasons: string[]; + }) + | (CommonAttemptResultFields & { + outcome: `attempt_${RunMinerAttemptResult["outcome"]}`; + submissionMode: "observe" | "enforce"; + totalTurnsUsed: number; + totalCostUsd: number; + totalTokensUsed: number; + iterationsUsed: number; + reason?: string; + decision?: unknown; + spec?: LocalWriteActionSpec; + execResult?: unknown; + claimConflict?: ClaimConflictResult; + }); + +export type ParsedAttemptArgs = + | { error: string } + | { + repoFullName: string; + issueNumber: number; + minerLogin: string; + base: string; + live: boolean; + dryRun: boolean; + json: boolean; + }; + +export type RunAttemptOptions = { + env?: Record; + nowMs?: number; + attemptId?: string; + resolveCodingAgentModeFromConfig?: (config: { env?: Record }) => CodingAgentExecutionMode; + openWorktreeAllocator?: () => WorktreeAllocator; + openClaimLedger?: () => ClaimLedger; + initEventLedger?: () => EventLedger; + initAttemptLog?: () => AttemptLog; + initGovernorLedger?: () => GovernorLedger; + buildAttemptDeps?: typeof buildAttemptDeps; + resolveRejectionSignaled?: typeof ResolveRejectionSignaledFn; + fetchImpl?: SelfReviewContextFetch; + prepareAttemptWorktree?: typeof PrepareAttemptWorktreeFn; + cleanupAttemptWorktree?: typeof CleanupAttemptWorktreeFn; + fetchSelfReviewContext?: typeof FetchSelfReviewContextFn; + buildCodingTaskSpec?: typeof BuildCodingTaskSpecFn; + resolveAmsPolicy?: typeof ResolveAmsPolicyFn; + checkMinerKillSwitch?: typeof CheckMinerKillSwitchFn; + resolveMinerGoalSpec?: typeof ResolveMinerGoalSpecFn; + runMinerAttempt?: typeof RunMinerAttemptFn; + resolveClaimConflict?: typeof ResolveClaimConflictFn; + recordOwnSubmission?: typeof RecordOwnSubmissionFn; + getAttemptHistory?: typeof GetAttemptHistoryFn; + /** Hosted soft-claim coordination at work-start/work-end, when the plane is enabled (#7168). Defaults to + * discovery-index-client.js's own submitSoftClaim. */ + submitSoftClaim?: typeof SubmitSoftClaimFn; + /** Invoked with the real structured result at every return point, in addition to (never instead of) the + * plain exit-code return -- the loop orchestrator's real hook into what actually happened. */ + onResult?: (result: AttemptCliResult) => void; +}; + +const ATTEMPT_USAGE = + "Usage: loopover-miner attempt --miner-login [--base ] [--live] [--dry-run] [--json]"; + +function parseRepoTarget(value: string): string | null { + const trimmed = value.trim(); + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) return null; + if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) return null; + return `${owner}/${repo}`; +} + +export function parseAttemptArgs(args: string[]): ParsedAttemptArgs { + const options: { + json: boolean; + minerLogin: string | null; + base: string; + live: boolean; + dryRun: boolean; + } = { json: false, minerLogin: null, base: "main", live: false, dryRun: false }; + const positional: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--json") { + options.json = true; + continue; + } + // Opt-in only: resolveCodingAgentModeFromConfig's own default (no agentDryRun override) is "live", not + // "dry_run" -- so #5132's "dry-run is default" acceptance criteria (#2342) has to be enforced HERE, by + // requiring an explicit --live flag before this command will ever request live mode. + if (token === "--live") { + options.live = true; + continue; + } + // #4847: distinct from --live's absence above -- --live only ever gated the coding-agent DRIVER's mode, + // but a non---live run still opened every store and made real worktree/claim/ledger writes. --dry-run + // short-circuits BEFORE any of that infrastructure is even opened, guaranteeing zero writes rather than + // merely skipping the driver. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--miner-login") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: ATTEMPT_USAGE }; + options.minerLogin = value; + index += 1; + continue; + } + if (token === "--base") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: ATTEMPT_USAGE }; + options.base = value; + index += 1; + continue; + } + if (token.startsWith("-")) return { error: `Unknown option: ${token}` }; + positional.push(token); + } + + if (positional.length !== 2) return { error: ATTEMPT_USAGE }; + const repoFullName = parseRepoTarget(positional[0]!); + if (!repoFullName) return { error: `Repository must be in owner/repo form: ${positional[0]}` }; + const issueNumber = Number(positional[1]); + if (!Number.isInteger(issueNumber) || issueNumber < 1) { + return { error: `Issue number must be a positive integer: ${positional[1]}` }; + } + if (!options.minerLogin) return { error: `--miner-login is required. ${ATTEMPT_USAGE}` }; + + return { + repoFullName, + issueNumber, + minerLogin: options.minerLogin, + base: options.base, + live: options.live, + dryRun: options.dryRun, + json: options.json, + }; +} + +/** + * Assemble a real AttemptDeps object: every field wired to a genuine implementation (the #5131 driver, the + * #5133 slop assessor, the four real ledgers passed in, and the fetchLiveIssueSnapshot/executeLocalWrite + * built alongside this file). Throws if the coding-agent driver is unconfigured (fails closed, matching + * constructProductionCodingAgentDriver's own contract) -- callers should report that clearly rather than + * silently falling back to a driver that could never run. + */ +export function buildAttemptDeps( + env: Record, + ledgers: { claimLedger: ClaimLedger; eventLedger: EventLedger; attemptLog: AttemptLog; governorLedger: GovernorLedger; nowMs: number }, +): AttemptDeps { + // AttemptDeps' claimLedger/callback parameter types are looser structural stubs than the real ledgers + // (pre-existing .d.ts drift on attempt-runner); cast preserves the same runtime wiring the .js had. + return { + driver: constructProductionCodingAgentDriver(env), + runSlopAssessment: (input) => runSlopAssessment(input as Parameters[0]), + appendAttemptLogEvent: (event) => { + ledgers.attemptLog.appendAttemptLogEvent(event as Parameters[0]); + }, + claimLedger: ledgers.claimLedger as AttemptDeps["claimLedger"], + // resolveGitHubToken (#6116): GITHUB_TOKEN env override wins outright, else a live token from the + // authenticated `loopover-mcp login` session -- cached in memory, so repeat calls within this process + // don't repeatedly hit the session-fetch endpoint after the first successful resolution. + fetchLiveIssueSnapshot: async (repoFullName: string, issueNumber: number) => { + // resolveGitHubToken returns string | null; exactOptionalPropertyTypes forbids explicit undefined. + const githubToken = await resolveGitHubToken(env as NodeJS.ProcessEnv); + return fetchLiveIssueSnapshot( + repoFullName, + issueNumber, + githubToken !== null ? { githubToken } : {}, + ); + }, + eventLedger: ledgers.eventLedger, + governorLedgerAppend: (event) => + ledgers.governorLedger.appendGovernorEvent(event as Parameters[0]), + nowMs: ledgers.nowMs, + executeLocalWrite: (spec) => executeLocalWrite(spec as Parameters[0]), + }; +} + +/** + * Run the `attempt` CLI subcommand end to end: resolveRejectionSignaled (before consuming a worktree slot) -> + * acquire a concurrency slot -> assemble real AttemptDeps -> prepare a REAL git worktree -> fetch a real + * SelfReviewContext -> build a real coding-task spec (blocks on an infeasible verdict) -> resolve the real + * AmsPolicySpec execution policy -> assemble the real IterateLoopInput + Governor context -> call + * runMinerAttempt for real. The worktree is cleaned up (or retained, per the real outcome) in `finally`. + * See this file's header for the documented gaps (real convergence history). + */ +export async function runAttempt(args: string[], options: RunAttemptOptions = {}): Promise { + const parsed = parseAttemptArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + const env = options.env ?? process.env; + const nowMs = options.nowMs ?? Date.now(); + const resolveMode = options.resolveCodingAgentModeFromConfig ?? resolveCodingAgentModeFromConfig; + // resolveCodingAgentModeFromConfig accepts agentDryRun at runtime; RunAttemptOptions injectable omits it (.d.ts drift). + const mode = resolveMode({ env, agentDryRun: !parsed.live } as { env?: Record }); + + if (mode === "paused") { + return reportCliFailure( + parsed.json, + `Coding-agent execution is globally paused (MINER_CODING_AGENT_PAUSED). Not running attempt for ${parsed.repoFullName}#${parsed.issueNumber}.`, + 3, + ); + } + + const attemptId = options.attemptId ?? `${parsed.repoFullName.replace("/", "_")}-${parsed.issueNumber}-${nowMs}`; + + // #4847: reports what a real run would do and returns BEFORE any store (allocator/claim/event/attempt-log/ + // governor ledger) is even opened, so this is a provable zero-write path -- not just "opened but didn't + // write to" the local stores, and nowhere near the real worktree clone, claim, or coding-agent driver. + if (parsed.dryRun) { + const dryRunResult = { + outcome: "dry_run", + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log( + `DRY RUN: would attempt ${parsed.repoFullName}#${parsed.issueNumber} for ${parsed.minerLogin} (mode: ${mode}, base: ${parsed.base}). No worktree, claim, or ledger writes were made.`, + ); + } + options.onResult?.(dryRunResult as AttemptCliResult); + return 0; + } + + let allocator: WorktreeAllocator | null = null; + let claimLedger: ClaimLedger | null = null; + let eventLedger: EventLedger | null = null; + let attemptLog: AttemptLog | null = null; + let governorLedger: GovernorLedger | null = null; + let allocation: WorktreeAllocation | null = null; + let worktreeResult: (PrepareAttemptWorktreeResult & { attemptOk?: boolean }) | null = null; + let claimedIssue = false; + let claimRecord: ClaimEntry | null = null; + + try { + allocator = (options.openWorktreeAllocator ?? openWorktreeAllocator)(); + claimLedger = (options.openClaimLedger ?? openClaimLedger)(); + eventLedger = (options.initEventLedger ?? initEventLedger)(); + attemptLog = (options.initAttemptLog ?? initAttemptLog)(); + governorLedger = (options.initGovernorLedger ?? initGovernorLedger)(); + + // Checked before acquiring a worktree slot: a rejection-signaled repo should never consume one. + // resolveRejectionSignaled resolves both documented triggers (#5132 policy ban, #5655 own-rejection + // history) and returns a trigger-specific reason string for accurate audit-trail labeling. + const resolveRejection = options.resolveRejectionSignaled ?? resolveRejectionSignaled; + // Pass fetchImpl through even when unset (same shape the .js always produced); cast for + // exactOptionalPropertyTypes vs RejectionSignaledOptions (pre-existing optional-prop drift). + const rejectionSignal = await resolveRejection(parsed.repoFullName, { + fetchImpl: options.fetchImpl, + } as Parameters[1]); + if (rejectionSignal) { + const reason = + rejectionSignal === true ? REJECTION_REASON_AI_USAGE_POLICY_BAN : rejectionSignal; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const rejectedResult = { + outcome: "blocked_rejection_signaled", + reason, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(rejectedResult, null, 2)); + } else { + console.error( + reason === REJECTION_REASON_OWN_SUBMISSION_REJECTED + ? `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this miner was previously rejected on this repo.` + : `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this repo's AI-usage policy bans automated/AI-authored contributions.`, + ); + } + options.onResult?.(rejectedResult as AttemptCliResult); + return 5; + } + + allocation = allocator.acquire(attemptId, parsed.repoFullName); + + let deps; + try { + const buildDeps = options.buildAttemptDeps ?? buildAttemptDeps; + deps = buildDeps(env, { claimLedger, eventLedger, attemptLog, governorLedger, nowMs }); + } catch (error) { + const reason = describeCliError(error); + return reportCliFailure( + parsed.json, + `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: ${reason}`, + 3, + ); + } + + // Real worktree preparation (repo-clone.js + attempt-worktree.js, #5237): the allocator above only + // reserves a concurrency SLOT (worktree-allocator.js's own `slot-N` placeholder dirs never receive real + // git content) -- this is the step that actually clones/fetches the target repo and creates a real + // `git worktree` for this attempt. Its own path, NOT the allocator's slot path, is the real + // workingDirectory a future runMinerAttempt call must use. + const prepareWorktree = options.prepareAttemptWorktree ?? prepareAttemptWorktree; + worktreeResult = await prepareWorktree(parsed.repoFullName, attemptId, { baseBranch: parsed.base, env }); + if (!worktreeResult.ok) { + const reason = worktreeResult.error; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const worktreeFailureResult = { + outcome: "blocked_worktree_preparation_failed", + reason, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(worktreeFailureResult, null, 2)); + } else { + console.error(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: real worktree preparation failed: ${reason}`); + } + options.onResult?.(worktreeFailureResult as AttemptCliResult); + return 6; + } + + // Real SelfReviewContext (#5145): issue/PR/manifest data at live-gate fidelity for the target repo. + const fetchReviewContext = options.fetchSelfReviewContext ?? fetchSelfReviewContext; + const reviewGithubToken = await resolveGitHubToken(env as NodeJS.ProcessEnv); + const reviewContext = await fetchReviewContext(parsed.repoFullName, { + ...(reviewGithubToken !== null ? { githubToken: reviewGithubToken } : {}), + contributorLogin: parsed.minerLogin, + linkedIssues: [parsed.issueNumber], + }); + + // The target issue's own real record, when present in the fetched context. When absent (e.g. already + // closed, or genuinely not found), buildCodingTaskSpec's own feasibility check reports target_not_found + // and this placeholder's empty title/body are never surfaced anywhere -- not fabricated content, just an + // inert shape for a verdict that immediately blocks. + const targetIssue = reviewContext.issues.find((candidate) => candidate.number === parsed.issueNumber) ?? { + number: parsed.issueNumber, + title: "", + body: null, + labels: [], + }; + + const buildTaskSpec = options.buildCodingTaskSpec ?? buildCodingTaskSpec; + // CodingTaskClaimLedger's listClaims filter types status as plain string (pre-existing .d.ts drift). + const codingTaskSpec = buildTaskSpec({ + repoFullName: parsed.repoFullName, + issue: targetIssue, + context: { issues: reviewContext.issues, pullRequests: reviewContext.pullRequests }, + claimLedger: claimLedger as Parameters[0]["claimLedger"], + workingDirectory: worktreeResult.worktreePath, + }); + + if (!codingTaskSpec.ready) { + const reason = `infeasible_${codingTaskSpec.verdict}`; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber, feasibility: codingTaskSpec.feasibility }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const infeasibleResult = { + outcome: "blocked_infeasible", + reason, + verdict: codingTaskSpec.verdict, + avoidReasons: codingTaskSpec.feasibility.avoidReasons, + raiseReasons: codingTaskSpec.feasibility.raiseReasons, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(infeasibleResult, null, 2)); + } else { + console.error( + `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: feasibility verdict "${codingTaskSpec.verdict}" (${[...codingTaskSpec.feasibility.avoidReasons, ...codingTaskSpec.feasibility.raiseReasons].join(", ")}).`, + ); + } + options.onResult?.(infeasibleResult as AttemptCliResult); + return 4; + } + + const amsPolicy = await (options.resolveAmsPolicy ?? resolveAmsPolicy)(parsed.repoFullName, { env }); + + // Real per-repo pause (#5392): read straight from the already-cloned worktree's own .loopover-miner.yml + // (resolveMinerGoalSpec never throws -- a missing/malformed file degrades to killSwitch.paused: false, so + // this can't fail this attempt on its own). Threaded into BOTH checkMinerKillSwitch (killSwitchScope, used + // by the freshness/submission gate) and the governor context (killSwitchRepoPaused, used by the Governor + // chokepoint) -- the same two places the GLOBAL kill switch already reaches. + const resolveGoalSpec = options.resolveMinerGoalSpec ?? resolveMinerGoalSpec; + const minerGoalSpec = resolveGoalSpec(worktreeResult.repoPath); + const repoPaused = minerGoalSpec.spec.killSwitch.paused; + + const checkKillSwitch = options.checkMinerKillSwitch ?? checkMinerKillSwitch; + // recordMinerKillSwitchTransition is used at runtime but omitted from RunAttemptOptions (.d.ts drift). + const recordKillTransition = + (options as RunAttemptOptions & { recordMinerKillSwitchTransition?: typeof recordMinerKillSwitchTransition }) + .recordMinerKillSwitchTransition ?? recordMinerKillSwitchTransition; + let killSwitchScope = checkKillSwitch({ env, repoPaused }).scope; + let previousKillSwitchScope = killSwitchScope; + + // Captured after the ok-check above so the mid-attempt kill-switch probe can't see a null worktreeResult. + const preparedWorktree = worktreeResult; + const resolveLiveKillSwitch = () => { + // Re-read the YAML flag each probe so an on-disk unpause/pause is reflected mid-attempt (#5670). + const liveRepoPaused = resolveGoalSpec(preparedWorktree.repoPath).spec.killSwitch.paused; + const live = checkKillSwitch({ env, repoPaused: liveRepoPaused }); + if (live.scope !== previousKillSwitchScope) { + try { + recordKillTransition({ + repoFullName: parsed.repoFullName, + actionClass: "attempt", + previousScope: previousKillSwitchScope, + scope: live.scope, + }); + } catch (error) { + // Ledger append must never crash an aborting attempt (kept), but was previously silent -- a + // kill-switch flip mid-attempt (a compliance-relevant event) could vanish with no record (#6011). + captureMinerError(error, { kind: "kill_switch_transition_record_failed", repoFullName: parsed.repoFullName, scope: live.scope }); + } + previousKillSwitchScope = live.scope; + } + killSwitchScope = live.scope; + return live; + }; + + const shouldAbort = () => { + const live = resolveLiveKillSwitch(); + if (!live.active) return false; + return { + abort: true, + reason: `Kill-switch (${live.scope}) engaged mid-attempt; abandoning without starting another driver iteration.`, + }; + }; + + const loopInput = buildAttemptLoopInput({ + codingTaskSpec, + reviewContext, + worktreePath: worktreeResult.worktreePath, + attemptId, + mode, + repoFullName: parsed.repoFullName, + minerLogin: parsed.minerLogin, + rejectionSignaled: false, + amsPolicySpec: amsPolicy.spec, + branchRef: worktreeResult.branchName, + }); + + // Real per-issue attempt history (#5654): portfolio-queue.js's own claim/reclaim/requeue/done counters, + // keyed the same way opportunity-fanout.js enqueues issue-shaped candidates (`issue:`). No + // apiBaseUrl: this file has no multi-forge host context of its own today, so this reads (and every + // pre-#5563 single-forge caller already reads) the github.com default. + const readAttemptHistory = options.getAttemptHistory ?? getAttemptHistory; + const convergenceInput = readAttemptHistory(parsed.repoFullName, `issue:${parsed.issueNumber}`); + // Real per-repo reputation history (#5675): the miner's own decided/unfavorable outcome streak for this repo, + // read from governor-state.js so the chokepoint's self-reputation throttle sees real data instead of nothing. + // loadReputationHistory is used at runtime but omitted from RunAttemptOptions (.d.ts drift). + const readReputationHistory = + (options as RunAttemptOptions & { loadReputationHistory?: typeof loadReputationHistory }).loadReputationHistory ?? + loadReputationHistory; + const reputationHistory = readReputationHistory(parsed.repoFullName); + const governor = buildAttemptGovernorContext(env, amsPolicy.spec, repoPaused, convergenceInput, reputationHistory); + + // Real maxConcurrentClaims enforcement (#6758): the repo's .loopover-miner.yml cap is honored ATOMICALLY by + // the ledger's count-and-claim, not by a listActiveClaims pre-check here. The old check-then-act split -- read + // the count in this file, then record the claim in a separate claimLedger call -- let two sibling miner + // processes racing the same repo both pass a stale sub-cap count and both claim, exceeding the cap. + // claimIssueWithinCap fuses the count and the insert into one transaction; the loser gets `claimed: false` + // and is reported below rather than silently dropped. This is also the real soft-claim (#5393): once it + // returns claimed, a sibling process sees it via claimLedger.listActiveClaims while this attempt is in + // flight, it is released in `finally` on every terminal outcome (mirroring the worktree allocation slot's + // acquire-then-always-release), and its claimedAt feeds the post-submission conflict check further down (#4848). + const claimResult = claimLedger.claimIssueWithinCap( + parsed.repoFullName, + parsed.issueNumber, + `attempt:${attemptId}`, + undefined, + minerGoalSpec.spec.maxConcurrentClaims, + ); + if (!claimResult.claimed) { + const reason = "max_concurrent_claims_exceeded"; + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_aborted", + attemptId, + actionClass: "open_pr", + mode, + reason, + payload: { + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + maxConcurrentClaims: minerGoalSpec.spec.maxConcurrentClaims, + activeClaimCount: claimResult.activeClaimCount, + }, + }); + eventLedger.appendEvent({ + type: "attempt_blocked", + repoFullName: parsed.repoFullName, + payload: { issueNumber: parsed.issueNumber, reason }, + }); + const blockedResult = { + outcome: "blocked_max_concurrent_claims", + reason, + maxConcurrentClaims: minerGoalSpec.spec.maxConcurrentClaims, + activeClaimCount: claimResult.activeClaimCount, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + }; + if (parsed.json) { + console.log(JSON.stringify(blockedResult, null, 2)); + } else { + console.error( + `Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: this repo's maxConcurrentClaims cap (${minerGoalSpec.spec.maxConcurrentClaims}) is already met (${claimResult.activeClaimCount} active claim(s)).`, + ); + } + // blocked_max_concurrent_claims is a real runtime outcome omitted from AttemptCliResult (.d.ts drift). + options.onResult?.(blockedResult as AttemptCliResult); + return 11; + } + + claimRecord = claimResult.claim; + claimedIssue = true; + // Hosted soft-claim coordination (#7168), opt-in via LOOPOVER_MINER_DISCOVERY_PLANE -- gated HERE at the + // call site (not left to submitSoftClaim's own internal check alone) so a disabled plane costs zero calls, + // matching discover-cli.js's supplementWithDiscoveryIndex gating; a caller-injected options.submitSoftClaim + // (tests, or a future programmatic caller) can't accidentally bypass the opt-in this way either. Awaited + // (not fire-and-forget) so a sibling instance racing the same issue is genuinely less likely to start + // duplicate work in the window before this attempt's claim reaches the shared index -- the whole point of + // coordinating BEFORE work begins, not after. + if (isDiscoveryPlaneEnabled(env)) { + const submitClaim = options.submitSoftClaim ?? submitSoftClaim; + await submitClaim(claimRecord as Parameters[0], { env }); + } + + const runAttemptPipeline = options.runMinerAttempt ?? runMinerAttempt; + let result; + try { + result = await runAttemptPipeline( + { + loopInput, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + killSwitchScope, + slopThreshold: amsPolicy.spec.slopThreshold, + submissionMode: amsPolicy.spec.submissionMode, + governor, + }, + { + ...deps, + shouldAbort, + resolveKillSwitchScope: () => resolveLiveKillSwitch().scope, + }, + ); + } catch (error) { + // A real attempt that CRASHED is exactly the case that most needs its worktree kept for post-mortem + // inspection, so record the failure explicitly before unwinding. Without this, `attemptOk` stayed + // `undefined` and the finally block's `?? true` default (meant for the earlier blocked paths that never + // ran anything in the worktree) deleted it -- inverting shouldRetainWorktree's documented policy. + worktreeResult.attemptOk = false; + throw error; + } + + worktreeResult.attemptOk = result.outcome === "submitted"; + + // Real claim-conflict resolution (#4848): only meaningful once a real PR exists, so this only ever runs + // on a real "submitted" outcome. checkSubmissionFreshness (inside runMinerAttempt) already caught the + // common pre-submission case; this closes the narrower TOCTOU window where two miners raced past that + // check almost simultaneously -- see claim-conflict-resolver.js's own header for why the adjudicator + // can only run POST-submission (it needs a real PR number on both sides of the election). + let claimConflict: ClaimConflictResult | undefined; + if (result.outcome === "submitted") { + const selfPrNumber = parsePrNumberFromExecResult( + result.execResult as Parameters[0], + parsed.repoFullName, + ); + if (selfPrNumber !== null) { + const resolveConflict = options.resolveClaimConflict ?? resolveClaimConflict; + claimConflict = await resolveConflict( + { + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + selfPrNumber, + selfClaimedAt: claimRecord.claimedAt, + minerLogin: parsed.minerLogin, + }, + { fetchLiveIssueSnapshot: deps.fetchLiveIssueSnapshot, executeLocalWrite: deps.executeLocalWrite }, + ); + } + + // Real own-submission history (#5655 follow-up): governor-state.js's recordOwnSubmission/ + // listRecentOwnSubmissions store (#5134) existed and was already READ by resolveOwnRejectionHistory + // (#5655), but nothing ever WROTE to it -- attempt-runner.js's own header names this exact gap + // ("real persistence primitives... but isn't auto-loaded here yet"). Left unfixed, that trigger is a + // silent no-op in every real deployment: an empty table always resolves "no prior submissions found." + // The fingerprint is the real changed-files set from the loop's own handoff packet (never fabricated) -- + // omitted (not recorded as an empty placeholder) when the packet reports no changed files at all. A + // logging failure must never fail an otherwise-successful attempt, matching the summary-event write below. + const changedFiles = result.loopResult.handoffPacket?.changedFiles?.map((file: { path: string }) => file.path) ?? []; + const fingerprint = fingerprintFromChangedFiles(changedFiles); + if (fingerprint) { + try { + const record = options.recordOwnSubmission ?? recordOwnSubmission; + record({ + repoFullName: parsed.repoFullName, + fingerprint, + submittedAt: new Date(nowMs).toISOString(), + pullRequestNumber: selfPrNumber, + issueNumber: parsed.issueNumber, + }); + } catch (error) { + // A logging failure must never fail an otherwise-successful attempt (kept), but was previously + // silent -- if this write fails AFTER a real PR has already opened, future self-plagiarism checks go + // permanently blind to this exact submission with nobody told (#6011). + captureMinerError(error, { kind: "record_own_submission_failed", repoFullName: parsed.repoFullName, pullRequestNumber: selfPrNumber }); + } + } + } + + const finalResult = { + outcome: `attempt_${result.outcome}`, + repoFullName: parsed.repoFullName, + issueNumber: parsed.issueNumber, + minerLogin: parsed.minerLogin, + base: parsed.base, + mode, + attemptId, + submissionMode: amsPolicy.spec.submissionMode, + // Every runMinerAttempt outcome carries a real loopResult (#5135's loop needs its genuine turn-usage and + // cost to save real GovernorCapUsage via governor-state.js's saveCapUsage -- nothing else in the codebase + // calls it yet). Surfaced flat rather than the whole loopResult object, matching this result's own + // shallow shape. costUsd is real only for the agent-sdk provider (its own SDK result message reports + // total_cost_usd); CLI-subprocess providers (claude-cli/codex-cli) report no cost signal today, so this + // is 0 for those -- an honest absence, not a fabricated number. + totalTurnsUsed: result.loopResult.totalTurnsUsed, + totalCostUsd: result.loopResult.totalCostUsd, + // Real accumulated tokens (#5653) -- read from finalMeterTotals rather than a flat totalTokensUsed field + // (IterateLoopResult has no such flat field, unlike turns/cost). 0 when no driver reported a token signal + // on any iteration this attempt ran, never fabricated. + totalTokensUsed: result.loopResult.finalMeterTotals.tokens, + iterationsUsed: result.loopResult.iterationsUsed, + ...(result.outcome === "abandon" && result.loopResult.finalDecision?.abandonReason + ? { abandonReason: result.loopResult.finalDecision.abandonReason } + : {}), + ...("reason" in result ? { reason: result.reason } : {}), + ...("decision" in result ? { decision: result.decision } : {}), + ...("spec" in result ? { spec: result.spec } : {}), + ...("execResult" in result ? { execResult: result.execResult } : {}), + // Present only on a real "submitted" outcome whose PR number was recoverable from execResult -- omitted + // (not fabricated as "checked: false") on every other outcome, and on a submitted outcome where the new + // PR's number genuinely couldn't be parsed (an honest gap, not silently swallowed). + ...(claimConflict !== undefined ? { claimConflict } : {}), + }; + + // One summary row per completed attempt (#5185), for the Grafana per-provider usage dashboard the redacted + // AMS reporting export exposes -- distinct from the per-iteration attempt_started/attempt_tool_edit/... trail + // iterate-loop.ts already writes. No fallback for an unconfigured provider: buildAttemptDeps already fails + // closed (throws) on the same env before a worktree is even allocated, so reaching this point guarantees + // resolveFirstConfiguredCodingAgentDriverName(env) resolves a real name. costUsd/tokensUsed are both real, + // driver-reported accumulated totals (#5653) -- 0 when no iteration's driver reported a signal, never + // fabricated. A logging failure must never fail an otherwise-successful attempt -- mirrors iterate-loop.ts's + // own safeAppendAttemptLogEvent non-fatal handling. + try { + attemptLog.appendAttemptLogEvent({ + eventType: "attempt_outcome_summary", + attemptId, + actionClass: finalResult.outcome, + mode, + reason: `attempt finished with outcome: ${result.outcome}`, + provider: resolveFirstConfiguredCodingAgentDriverName(env), + costUsd: finalResult.totalCostUsd, + tokensUsed: finalResult.totalTokensUsed, + }); + } catch (error) { + // A logging failure must never fail an otherwise-successful attempt (kept), but was previously silent -- + // per docs/observability.md this row feeds the Grafana per-provider cost/usage dashboard, so a failure + // here silently drops the attempt from operator-facing metrics with nobody told (#6011). + captureMinerError(error, { kind: "attempt_outcome_summary_append_failed", attemptId, repoFullName: parsed.repoFullName }); + } + + if (parsed.json) { + console.log(JSON.stringify(finalResult, null, 2)); + } else { + console.log(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} finished with outcome: ${result.outcome}.`); + } + options.onResult?.(finalResult as AttemptCliResult); + + switch (result.outcome) { + case "submitted": + return 0; + case "abandon": + return 7; + case "stale": + return 8; + case "blocked": + return 9; + case "governed": + return 10; + default: + return 2; + } + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } finally { + // worktreeResult.attemptOk is set to the REAL runMinerAttempt outcome (submitted = true) once that call + // happens, and explicitly to `false` when that call THROWS -- a crashed attempt is precisely what needs a + // retained worktree to postmortem, so it must never fall through to the `?? true` default below. Every + // earlier blocked path (rejection/worktree-prep-failure/infeasible) never sets it, since nothing ran in + // the worktree to postmortem -- those are the cases that default to `true` (nothing to retain), matching + // cleanupAttemptWorktree's own retention policy (a failed REAL attempt is what gets retained). + if (worktreeResult?.ok) { + const cleanupWorktree = options.cleanupAttemptWorktree ?? cleanupAttemptWorktree; + await cleanupWorktree(worktreeResult.repoPath, worktreeResult.worktreePath, worktreeResult.attemptOk ?? true); + } + // Every terminal outcome past the claim point (submitted/abandon/stale/blocked/governed, or an + // unexpected throw) releases the soft-claim -- a claim that outlives its own attempt process would + // wrongly tell a sibling miner this issue is still in flight. + if (claimedIssue && claimLedger) claimLedger.releaseClaim(parsed.repoFullName, parsed.issueNumber); + // Paired hosted release (#7168): same call-site opt-in gate as the claim submission above. Only fires when + // the initial claim submission actually ran (claimRecord is only set once claimedIssue is), so a run that + // never reached the claim point (e.g. blocked_max_concurrent_claims) has nothing to release remotely. + if (claimedIssue && claimRecord && isDiscoveryPlaneEnabled(env)) { + const submitClaim = options.submitSoftClaim ?? submitSoftClaim; + await submitClaim({ ...claimRecord, status: "released" } as Parameters[0], { env }); + } + if (allocation && allocator) allocator.release(attemptId); + allocator?.close(); + claimLedger?.close(); + eventLedger?.close(); + attemptLog?.close(); + governorLedger?.close(); + } +} diff --git a/packages/loopover-miner/lib/attempt-log.d.ts b/packages/loopover-miner/lib/attempt-log.d.ts index 9f6528803e..1d5354d12e 100644 --- a/packages/loopover-miner/lib/attempt-log.d.ts +++ b/packages/loopover-miner/lib/attempt-log.d.ts @@ -1,45 +1,41 @@ import type { AttemptLogEvent } from "@loopover/engine"; - export type AttemptLogEntry = { - id: number; - seq: number; - eventType: string; - attemptId: string; - actionClass: string; - mode: string; - reason: string; - payload: Record; - /** Coding-agent provider name, when the event set one (#5185). Null for every event type that predates this - * field. */ - provider: string | null; - /** Real dollar cost, when the event set one (#5185). Null (not 0) when absent -- never fabricated. */ - costUsd: number | null; - /** Real token count, when some future driver reports one (#5185). Always null today -- no driver reports real - * token usage yet (#5395). */ - tokensUsed: number | null; - createdAt: string; + id: number; + seq: number; + eventType: string; + attemptId: string; + actionClass: string; + mode: string; + reason: string; + payload: Record; + /** Coding-agent provider name, when the event set one (#5185). Null for every event type that predates this + * field. */ + provider: string | null; + /** Real dollar cost, when the event set one (#5185). Null (not 0) when absent -- never fabricated. */ + costUsd: number | null; + /** Real token count, when some future driver reports one (#5185). Always null today -- no driver reports real + * token usage yet (#5395). */ + tokensUsed: number | null; + createdAt: string; }; - export type ReadAttemptLogEventsFilter = { - attemptId?: string | null; + attemptId?: string | null; }; - export type AttemptLog = { - dbPath: string; - appendAttemptLogEvent(event: AttemptLogEvent): AttemptLogEntry; - readAttemptLogEvents(filter?: ReadAttemptLogEventsFilter): AttemptLogEntry[]; - exportAttemptLogJsonl(attemptId: string): string; - close(): void; + dbPath: string; + appendAttemptLogEvent(event: AttemptLogEvent): AttemptLogEntry; + readAttemptLogEvents(filter?: ReadAttemptLogEventsFilter): AttemptLogEntry[]; + exportAttemptLogJsonl(attemptId: string): string; + close(): void; }; - -export function resolveAttemptLogDbPath(env?: Record): string; - -export function initAttemptLog(dbPath?: string): AttemptLog; - -export function appendAttemptLogEvent(event: AttemptLogEvent): AttemptLogEntry; - -export function readAttemptLogEvents(filter?: ReadAttemptLogEventsFilter): AttemptLogEntry[]; - -export function exportAttemptLogJsonl(attemptId: string): string; - -export function closeDefaultAttemptLog(): void; +export declare function resolveAttemptLogDbPath(env?: Record): string; +/** + * Opens the append-only attempt log, creating the table on first use. `seq` is a monotonically increasing counter + * maintained by this module (next = current MAX(seq) + 1) with a UNIQUE(seq) constraint. Rows read back in seq ASC + * order. (#4294) + */ +export declare function initAttemptLog(dbPath?: string): AttemptLog; +export declare function appendAttemptLogEvent(event: AttemptLogEvent): AttemptLogEntry; +export declare function readAttemptLogEvents(filter?: ReadAttemptLogEventsFilter): AttemptLogEntry[]; +export declare function exportAttemptLogJsonl(attemptId: string): string; +export declare function closeDefaultAttemptLog(): void; diff --git a/packages/loopover-miner/lib/attempt-log.js b/packages/loopover-miner/lib/attempt-log.js index b16be599aa..f8c05fd08f 100644 --- a/packages/loopover-miner/lib/attempt-log.js +++ b/packages/loopover-miner/lib/attempt-log.js @@ -1,6 +1,5 @@ import { formatAttemptLogJsonl, normalizeAttemptLogEvent } from "@loopover/engine"; import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; - // Append-only driver attempt log (#4294): a structured, attempt-scoped event trace for every CodingAgentDriver run // (started, tool/edit, succeeded/failed/aborted). IMMUTABILITY INVARIANT: INSERT + SELECT only — rows are never // rewritten or removed after append. @@ -10,102 +9,97 @@ import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } // Attempt events are keyed by attempt_id, validated against the engine's fixed ATTEMPT_LOG_EVENT_TYPES, and are // exported per attempt as JSONL — mixing both into one table would couple unrelated lifecycles and complicate the // per-attempt dump path. This module mirrors governor-ledger.js: engine holds pure normalization, miner holds SQLite. - const defaultDbFileName = "attempt-log.sqlite3"; let defaultAttemptLog = null; - export function resolveAttemptLogDbPath(env = process.env) { - return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_ATTEMPT_LOG_DB", env); + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_ATTEMPT_LOG_DB", env); } - function normalizeDbPath(dbPath) { - return normalizeLocalStoreDbPath(dbPath, resolveAttemptLogDbPath(), "invalid_attempt_log_db_path"); + return normalizeLocalStoreDbPath(dbPath, resolveAttemptLogDbPath(), "invalid_attempt_log_db_path"); } - /** Read-filter attempt scope: omitted/nullish → unscoped (all events); otherwise a non-empty attempt id. */ function normalizeReadAttemptIdFilter(attemptId) { - if (attemptId === undefined || attemptId === null) return undefined; - if (typeof attemptId !== "string") throw new Error("invalid_attempt_id"); - const trimmed = attemptId.trim(); - if (!trimmed) throw new Error("invalid_attempt_id"); - return trimmed; + if (attemptId === undefined || attemptId === null) + return undefined; + if (typeof attemptId !== "string") + throw new Error("invalid_attempt_id"); + const trimmed = attemptId.trim(); + if (!trimmed) + throw new Error("invalid_attempt_id"); + return trimmed; } - /** Export requires an explicit attempt id — JSONL dumps are always per attempt. */ function normalizeRequiredAttemptId(attemptId) { - const normalized = normalizeReadAttemptIdFilter(attemptId); - if (normalized === undefined) throw new Error("invalid_attempt_id"); - return normalized; + const normalized = normalizeReadAttemptIdFilter(attemptId); + if (normalized === undefined) + throw new Error("invalid_attempt_id"); + return normalized; } - function rowToEntry(row) { - let payload; - try { - payload = JSON.parse(row.payload_json); - if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { - throw new Error("corrupted_attempt_log_row"); + let payload; + try { + const parsed = JSON.parse(row.payload_json); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("corrupted_attempt_log_row"); + } + payload = parsed; } - } catch { - throw new Error("corrupted_attempt_log_row"); - } - return { - id: row.id, - seq: row.seq, - eventType: row.event_type, - attemptId: row.attempt_id, - actionClass: row.action_class, - mode: row.mode, - reason: row.reason, - payload, - provider: row.provider, - costUsd: row.cost_usd, - tokensUsed: row.tokens_used, - createdAt: row.created_at, - }; + catch { + throw new Error("corrupted_attempt_log_row"); + } + return { + id: row.id, + seq: row.seq, + eventType: row.event_type, + attemptId: row.attempt_id, + actionClass: row.action_class, + mode: row.mode, + reason: row.reason, + payload, + provider: row.provider, + costUsd: row.cost_usd, + tokensUsed: row.tokens_used, + createdAt: row.created_at, + }; } - function rowToNormalized(row) { - return { - eventType: row.event_type, - attemptId: row.attempt_id, - actionClass: row.action_class, - mode: row.mode, - reason: row.reason, - payloadJson: row.payload_json, - provider: row.provider, - costUsd: row.cost_usd, - tokensUsed: row.tokens_used, - }; + return { + eventType: row.event_type, + attemptId: row.attempt_id, + actionClass: row.action_class, + mode: row.mode, + reason: row.reason, + payloadJson: row.payload_json, + provider: row.provider, + costUsd: row.cost_usd, + tokensUsed: row.tokens_used, + }; } - // Add the provider/cost_usd/tokens_used columns (#5185) to an on-disk file created before they existed. `CREATE // TABLE IF NOT EXISTS` above is a no-op against an already-existing table, so a pre-#5185 file needs this // explicit ALTER -- guarded by a per-column presence check (same technique as governor-state.js's own // ensurePauseColumns) so a file missing only one of the three still gets exactly what it's missing. function ensureOutcomeColumns(db) { - const existingColumns = new Set( - db.prepare("PRAGMA table_info(attempt_log_events)").all().map((column) => column.name), - ); - if (!existingColumns.has("provider")) { - db.exec("ALTER TABLE attempt_log_events ADD COLUMN provider TEXT"); - } - if (!existingColumns.has("cost_usd")) { - db.exec("ALTER TABLE attempt_log_events ADD COLUMN cost_usd REAL"); - } - if (!existingColumns.has("tokens_used")) { - db.exec("ALTER TABLE attempt_log_events ADD COLUMN tokens_used INTEGER"); - } + const existingColumns = new Set(db.prepare("PRAGMA table_info(attempt_log_events)").all().map((column) => column.name)); + if (!existingColumns.has("provider")) { + db.exec("ALTER TABLE attempt_log_events ADD COLUMN provider TEXT"); + } + if (!existingColumns.has("cost_usd")) { + db.exec("ALTER TABLE attempt_log_events ADD COLUMN cost_usd REAL"); + } + if (!existingColumns.has("tokens_used")) { + db.exec("ALTER TABLE attempt_log_events ADD COLUMN tokens_used INTEGER"); + } } - /** * Opens the append-only attempt log, creating the table on first use. `seq` is a monotonically increasing counter * maintained by this module (next = current MAX(seq) + 1) with a UNIQUE(seq) constraint. Rows read back in seq ASC * order. (#4294) */ export function initAttemptLog(dbPath = resolveAttemptLogDbPath()) { - const resolvedPath = normalizeDbPath(dbPath); - const db = openLocalStoreDb(resolvedPath); - db.exec(` + const resolvedPath = normalizeDbPath(dbPath); + const db = openLocalStoreDb(resolvedPath); + db.exec(` CREATE TABLE IF NOT EXISTS attempt_log_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, seq INTEGER NOT NULL UNIQUE, @@ -118,90 +112,72 @@ export function initAttemptLog(dbPath = resolveAttemptLogDbPath()) { created_at TEXT NOT NULL ) `); - ensureOutcomeColumns(db); - db.exec( - "CREATE INDEX IF NOT EXISTS idx_attempt_log_attempt ON attempt_log_events (attempt_id, seq)", - ); - - const nextSeqStatement = db.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS nextSeq FROM attempt_log_events"); - const appendStatement = db.prepare(` + ensureOutcomeColumns(db); + db.exec("CREATE INDEX IF NOT EXISTS idx_attempt_log_attempt ON attempt_log_events (attempt_id, seq)"); + const nextSeqStatement = db.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS nextSeq FROM attempt_log_events"); + const appendStatement = db.prepare(` INSERT INTO attempt_log_events ( seq, attempt_id, event_type, action_class, mode, reason, payload_json, provider, cost_usd, tokens_used, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); - const getByIdStatement = db.prepare("SELECT * FROM attempt_log_events WHERE id = ?"); - const readAllStatement = db.prepare("SELECT * FROM attempt_log_events ORDER BY seq ASC"); - const readByAttemptStatement = db.prepare( - "SELECT * FROM attempt_log_events WHERE attempt_id = ? ORDER BY seq ASC", - ); - - return { - dbPath: resolvedPath, - appendAttemptLogEvent(event) { - const normalized = normalizeAttemptLogEvent(event); - const createdAt = new Date().toISOString(); - db.exec("BEGIN IMMEDIATE"); - try { - const { nextSeq } = nextSeqStatement.get(); - const result = appendStatement.run( - nextSeq, - normalized.attemptId, - normalized.eventType, - normalized.actionClass, - normalized.mode, - normalized.reason, - normalized.payloadJson, - normalized.provider, - normalized.costUsd, - normalized.tokensUsed, - createdAt, - ); - const entry = rowToEntry(getByIdStatement.get(Number(result.lastInsertRowid))); - db.exec("COMMIT"); - return entry; - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } - }, - readAttemptLogEvents(filter = {}) { - const attemptId = normalizeReadAttemptIdFilter(filter.attemptId); - const rows = - attemptId === undefined ? readAllStatement.all() : readByAttemptStatement.all(attemptId); - return rows.map(rowToEntry); - }, - exportAttemptLogJsonl(attemptId) { - const scopedAttemptId = normalizeRequiredAttemptId(attemptId); - const rows = readByAttemptStatement.all(scopedAttemptId); - return formatAttemptLogJsonl(rows.map(rowToNormalized)); - }, - close() { - db.close(); - }, - }; + const getByIdStatement = db.prepare("SELECT * FROM attempt_log_events WHERE id = ?"); + const readAllStatement = db.prepare("SELECT * FROM attempt_log_events ORDER BY seq ASC"); + const readByAttemptStatement = db.prepare("SELECT * FROM attempt_log_events WHERE attempt_id = ? ORDER BY seq ASC"); + return { + dbPath: resolvedPath, + appendAttemptLogEvent(event) { + const normalized = normalizeAttemptLogEvent(event); + const createdAt = new Date().toISOString(); + db.exec("BEGIN IMMEDIATE"); + try { + const nextSeqRow = nextSeqStatement.get(); + const nextSeq = nextSeqRow.nextSeq; + const result = appendStatement.run(nextSeq, normalized.attemptId, normalized.eventType, normalized.actionClass, normalized.mode, normalized.reason, normalized.payloadJson, normalized.provider, normalized.costUsd, normalized.tokensUsed, createdAt); + const entry = rowToEntry(getByIdStatement.get(Number(result.lastInsertRowid))); + db.exec("COMMIT"); + return entry; + } + catch (error) { + db.exec("ROLLBACK"); + throw error; + } + }, + readAttemptLogEvents(filter = {}) { + const attemptId = normalizeReadAttemptIdFilter(filter.attemptId); + const rows = attemptId === undefined + ? readAllStatement.all() + : readByAttemptStatement.all(attemptId); + return rows.map(rowToEntry); + }, + exportAttemptLogJsonl(attemptId) { + const scopedAttemptId = normalizeRequiredAttemptId(attemptId); + const rows = readByAttemptStatement.all(scopedAttemptId); + return formatAttemptLogJsonl(rows.map(rowToNormalized)); + }, + close() { + db.close(); + }, + }; } - function getDefaultAttemptLog() { - defaultAttemptLog ??= initAttemptLog(); - return defaultAttemptLog; + defaultAttemptLog ??= initAttemptLog(); + return defaultAttemptLog; } - export function appendAttemptLogEvent(event) { - return getDefaultAttemptLog().appendAttemptLogEvent(event); + return getDefaultAttemptLog().appendAttemptLogEvent(event); } - export function readAttemptLogEvents(filter) { - return getDefaultAttemptLog().readAttemptLogEvents(filter); + return getDefaultAttemptLog().readAttemptLogEvents(filter); } - export function exportAttemptLogJsonl(attemptId) { - return getDefaultAttemptLog().exportAttemptLogJsonl(attemptId); + return getDefaultAttemptLog().exportAttemptLogJsonl(attemptId); } - export function closeDefaultAttemptLog() { - if (!defaultAttemptLog) return; - defaultAttemptLog.close(); - defaultAttemptLog = null; + if (!defaultAttemptLog) + return; + defaultAttemptLog.close(); + defaultAttemptLog = null; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiYXR0ZW1wdC1sb2cuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJhdHRlbXB0LWxvZy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQUUscUJBQXFCLEVBQUUsd0JBQXdCLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUduRixPQUFPLEVBQUUseUJBQXlCLEVBQUUsZ0JBQWdCLEVBQUUsdUJBQXVCLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUV4RyxtSEFBbUg7QUFDbkgsZ0hBQWdIO0FBQ2hILHFDQUFxQztBQUNyQyxFQUFFO0FBQ0YsK0dBQStHO0FBQy9HLCtHQUErRztBQUMvRyxnSEFBZ0g7QUFDaEgsa0hBQWtIO0FBQ2xILHNIQUFzSDtBQUV0SCxNQUFNLGlCQUFpQixHQUFHLHFCQUFxQixDQUFDO0FBQ2hELElBQUksaUJBQWlCLEdBQXNCLElBQUksQ0FBQztBQW1EaEQsTUFBTSxVQUFVLHVCQUF1QixDQUFDLE1BQTBDLE9BQU8sQ0FBQyxHQUFHO0lBQzNGLE9BQU8sdUJBQXVCLENBQUMsaUJBQWlCLEVBQUUsK0JBQStCLEVBQUUsR0FBRyxDQUFDLENBQUM7QUFDMUYsQ0FBQztBQUVELFNBQVMsZUFBZSxDQUFDLE1BQWM7SUFDckMsT0FBTyx5QkFBeUIsQ0FBQyxNQUFNLEVBQUUsdUJBQXVCLEVBQUUsRUFBRSw2QkFBNkIsQ0FBQyxDQUFDO0FBQ3JHLENBQUM7QUFFRCw0R0FBNEc7QUFDNUcsU0FBUyw0QkFBNEIsQ0FBQyxTQUFrQjtJQUN0RCxJQUFJLFNBQVMsS0FBSyxTQUFTLElBQUksU0FBUyxLQUFLLElBQUk7UUFBRSxPQUFPLFNBQVMsQ0FBQztJQUNwRSxJQUFJLE9BQU8sU0FBUyxLQUFLLFFBQVE7UUFBRSxNQUFNLElBQUksS0FBSyxDQUFDLG9CQUFvQixDQUFDLENBQUM7SUFDekUsTUFBTSxPQUFPLEdBQUcsU0FBUyxDQUFDLElBQUksRUFBRSxDQUFDO0lBQ2pDLElBQUksQ0FBQyxPQUFPO1FBQUUsTUFBTSxJQUFJ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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/attempt-log.ts b/packages/loopover-miner/lib/attempt-log.ts new file mode 100644 index 0000000000..3e5333682e --- /dev/null +++ b/packages/loopover-miner/lib/attempt-log.ts @@ -0,0 +1,262 @@ +import { formatAttemptLogJsonl, normalizeAttemptLogEvent } from "@loopover/engine"; +import type { AttemptLogEvent } from "@loopover/engine"; +import type { DatabaseSync } from "node:sqlite"; +import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; + +// Append-only driver attempt log (#4294): a structured, attempt-scoped event trace for every CodingAgentDriver run +// (started, tool/edit, succeeded/failed/aborted). IMMUTABILITY INVARIANT: INSERT + SELECT only — rows are never +// rewritten or removed after append. +// +// Why a sibling store instead of extending event-ledger.js: event-ledger is the general miner-loop audit trail +// (discovered_issue, plan_built, pr_prepared, …) keyed by repo scope with a growing free-form type vocabulary. +// Attempt events are keyed by attempt_id, validated against the engine's fixed ATTEMPT_LOG_EVENT_TYPES, and are +// exported per attempt as JSONL — mixing both into one table would couple unrelated lifecycles and complicate the +// per-attempt dump path. This module mirrors governor-ledger.js: engine holds pure normalization, miner holds SQLite. + +const defaultDbFileName = "attempt-log.sqlite3"; +let defaultAttemptLog: AttemptLog | null = null; + +export type AttemptLogEntry = { + id: number; + seq: number; + eventType: string; + attemptId: string; + actionClass: string; + mode: string; + reason: string; + payload: Record; + /** Coding-agent provider name, when the event set one (#5185). Null for every event type that predates this + * field. */ + provider: string | null; + /** Real dollar cost, when the event set one (#5185). Null (not 0) when absent -- never fabricated. */ + costUsd: number | null; + /** Real token count, when some future driver reports one (#5185). Always null today -- no driver reports real + * token usage yet (#5395). */ + tokensUsed: number | null; + createdAt: string; +}; + +export type ReadAttemptLogEventsFilter = { + attemptId?: string | null; +}; + +export type AttemptLog = { + dbPath: string; + appendAttemptLogEvent(event: AttemptLogEvent): AttemptLogEntry; + readAttemptLogEvents(filter?: ReadAttemptLogEventsFilter): AttemptLogEntry[]; + exportAttemptLogJsonl(attemptId: string): string; + close(): void; +}; + +type AttemptLogRow = { + id: number; + seq: number; + event_type: string; + attempt_id: string; + action_class: string; + mode: string; + reason: string; + payload_json: string; + provider: string | null; + cost_usd: number | null; + tokens_used: number | null; + created_at: string; +}; + +type TableInfoRow = { name: string }; + +export function resolveAttemptLogDbPath(env: Record = process.env): string { + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_ATTEMPT_LOG_DB", env); +} + +function normalizeDbPath(dbPath: string): string { + return normalizeLocalStoreDbPath(dbPath, resolveAttemptLogDbPath(), "invalid_attempt_log_db_path"); +} + +/** Read-filter attempt scope: omitted/nullish → unscoped (all events); otherwise a non-empty attempt id. */ +function normalizeReadAttemptIdFilter(attemptId: unknown): string | undefined { + if (attemptId === undefined || attemptId === null) return undefined; + if (typeof attemptId !== "string") throw new Error("invalid_attempt_id"); + const trimmed = attemptId.trim(); + if (!trimmed) throw new Error("invalid_attempt_id"); + return trimmed; +} + +/** Export requires an explicit attempt id — JSONL dumps are always per attempt. */ +function normalizeRequiredAttemptId(attemptId: unknown): string { + const normalized = normalizeReadAttemptIdFilter(attemptId); + if (normalized === undefined) throw new Error("invalid_attempt_id"); + return normalized; +} + +function rowToEntry(row: AttemptLogRow): AttemptLogEntry { + let payload: Record; + try { + const parsed: unknown = JSON.parse(row.payload_json); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("corrupted_attempt_log_row"); + } + payload = parsed as Record; + } catch { + throw new Error("corrupted_attempt_log_row"); + } + return { + id: row.id, + seq: row.seq, + eventType: row.event_type, + attemptId: row.attempt_id, + actionClass: row.action_class, + mode: row.mode, + reason: row.reason, + payload, + provider: row.provider, + costUsd: row.cost_usd, + tokensUsed: row.tokens_used, + createdAt: row.created_at, + }; +} + +function rowToNormalized(row: AttemptLogRow) { + return { + eventType: row.event_type, + attemptId: row.attempt_id, + actionClass: row.action_class, + mode: row.mode, + reason: row.reason, + payloadJson: row.payload_json, + provider: row.provider, + costUsd: row.cost_usd, + tokensUsed: row.tokens_used, + }; +} + +// Add the provider/cost_usd/tokens_used columns (#5185) to an on-disk file created before they existed. `CREATE +// TABLE IF NOT EXISTS` above is a no-op against an already-existing table, so a pre-#5185 file needs this +// explicit ALTER -- guarded by a per-column presence check (same technique as governor-state.js's own +// ensurePauseColumns) so a file missing only one of the three still gets exactly what it's missing. +function ensureOutcomeColumns(db: DatabaseSync): void { + const existingColumns = new Set( + db.prepare("PRAGMA table_info(attempt_log_events)").all().map((column) => (column as TableInfoRow).name), + ); + if (!existingColumns.has("provider")) { + db.exec("ALTER TABLE attempt_log_events ADD COLUMN provider TEXT"); + } + if (!existingColumns.has("cost_usd")) { + db.exec("ALTER TABLE attempt_log_events ADD COLUMN cost_usd REAL"); + } + if (!existingColumns.has("tokens_used")) { + db.exec("ALTER TABLE attempt_log_events ADD COLUMN tokens_used INTEGER"); + } +} + +/** + * Opens the append-only attempt log, creating the table on first use. `seq` is a monotonically increasing counter + * maintained by this module (next = current MAX(seq) + 1) with a UNIQUE(seq) constraint. Rows read back in seq ASC + * order. (#4294) + */ +export function initAttemptLog(dbPath: string = resolveAttemptLogDbPath()): AttemptLog { + const resolvedPath = normalizeDbPath(dbPath); + const db = openLocalStoreDb(resolvedPath); + db.exec(` + CREATE TABLE IF NOT EXISTS attempt_log_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + seq INTEGER NOT NULL UNIQUE, + attempt_id TEXT NOT NULL, + event_type TEXT NOT NULL, + action_class TEXT NOT NULL, + mode TEXT NOT NULL, + reason TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `); + ensureOutcomeColumns(db); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_attempt_log_attempt ON attempt_log_events (attempt_id, seq)", + ); + + const nextSeqStatement = db.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS nextSeq FROM attempt_log_events"); + const appendStatement = db.prepare(` + INSERT INTO attempt_log_events ( + seq, attempt_id, event_type, action_class, mode, reason, payload_json, provider, cost_usd, tokens_used, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const getByIdStatement = db.prepare("SELECT * FROM attempt_log_events WHERE id = ?"); + const readAllStatement = db.prepare("SELECT * FROM attempt_log_events ORDER BY seq ASC"); + const readByAttemptStatement = db.prepare( + "SELECT * FROM attempt_log_events WHERE attempt_id = ? ORDER BY seq ASC", + ); + + return { + dbPath: resolvedPath, + appendAttemptLogEvent(event) { + const normalized = normalizeAttemptLogEvent(event); + const createdAt = new Date().toISOString(); + db.exec("BEGIN IMMEDIATE"); + try { + const nextSeqRow = nextSeqStatement.get() as { nextSeq: number } | undefined; + const nextSeq = nextSeqRow!.nextSeq; + const result = appendStatement.run( + nextSeq, + normalized.attemptId, + normalized.eventType, + normalized.actionClass, + normalized.mode, + normalized.reason, + normalized.payloadJson, + normalized.provider, + normalized.costUsd, + normalized.tokensUsed, + createdAt, + ); + const entry = rowToEntry(getByIdStatement.get(Number(result.lastInsertRowid)) as AttemptLogRow); + db.exec("COMMIT"); + return entry; + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + }, + readAttemptLogEvents(filter = {}) { + const attemptId = normalizeReadAttemptIdFilter(filter.attemptId); + const rows = + attemptId === undefined + ? (readAllStatement.all() as AttemptLogRow[]) + : (readByAttemptStatement.all(attemptId) as AttemptLogRow[]); + return rows.map(rowToEntry); + }, + exportAttemptLogJsonl(attemptId) { + const scopedAttemptId = normalizeRequiredAttemptId(attemptId); + const rows = readByAttemptStatement.all(scopedAttemptId) as AttemptLogRow[]; + return formatAttemptLogJsonl(rows.map(rowToNormalized) as Parameters[0]); + }, + close() { + db.close(); + }, + }; +} + +function getDefaultAttemptLog(): AttemptLog { + defaultAttemptLog ??= initAttemptLog(); + return defaultAttemptLog; +} + +export function appendAttemptLogEvent(event: AttemptLogEvent): AttemptLogEntry { + return getDefaultAttemptLog().appendAttemptLogEvent(event); +} + +export function readAttemptLogEvents(filter?: ReadAttemptLogEventsFilter): AttemptLogEntry[] { + return getDefaultAttemptLog().readAttemptLogEvents(filter); +} + +export function exportAttemptLogJsonl(attemptId: string): string { + return getDefaultAttemptLog().exportAttemptLogJsonl(attemptId); +} + +export function closeDefaultAttemptLog(): void { + if (!defaultAttemptLog) return; + defaultAttemptLog.close(); + defaultAttemptLog = null; +} diff --git a/packages/loopover-miner/lib/ci-poller.d.ts b/packages/loopover-miner/lib/ci-poller.d.ts index 08718c4a8f..fe1d647282 100644 --- a/packages/loopover-miner/lib/ci-poller.d.ts +++ b/packages/loopover-miner/lib/ci-poller.d.ts @@ -1,34 +1,26 @@ export type CheckRunConclusion = "pending" | "success" | "failure" | "neutral"; - export type NormalizedCheckRun = { - name: string; - status: string; - conclusion: CheckRunConclusion; - detailsUrl: string | null; - startedAt: string | null; - completedAt: string | null; + name: string; + status: string; + conclusion: CheckRunConclusion; + detailsUrl: string | null; + startedAt: string | null; + completedAt: string | null; }; - export type PollCheckRunsResult = { - conclusion: CheckRunConclusion; - checks: NormalizedCheckRun[]; - headSha: string; - attempts: number; + conclusion: CheckRunConclusion; + checks: NormalizedCheckRun[]; + headSha: string; + attempts: number; }; - export type PollCheckRunsOptions = { - apiBaseUrl?: string; - fetchFn?: typeof fetch; - githubToken?: string; - maxAttempts?: number; - minIntervalMs?: number; - maxIntervalMs?: number; - requestTimeoutMs?: number; - sleepFn?: (delayMs: number) => Promise; + apiBaseUrl?: string; + fetchFn?: typeof fetch; + githubToken?: string; + maxAttempts?: number; + minIntervalMs?: number; + maxIntervalMs?: number; + requestTimeoutMs?: number; + sleepFn?: (delayMs: number) => Promise; }; - -export function pollCheckRuns( - repoFullName: string, - prNumber: number, - options?: PollCheckRunsOptions, -): Promise; +export declare function pollCheckRuns(repoFullName: string, prNumber: number, options?: PollCheckRunsOptions): Promise; diff --git a/packages/loopover-miner/lib/ci-poller.js b/packages/loopover-miner/lib/ci-poller.js index 538a50b1de..e7804f1774 100644 --- a/packages/loopover-miner/lib/ci-poller.js +++ b/packages/loopover-miner/lib/ci-poller.js @@ -1,237 +1,215 @@ import { fetchWithRetry } from "./http-retry.js"; - const defaultApiBaseUrl = "https://api.github.com"; const defaultMinIntervalMs = 60_000; const defaultMaxIntervalMs = 5 * 60_000; const defaultMaxAttempts = 1; const defaultRequestTimeoutMs = 10_000; const githubApiVersion = "2022-11-28"; - function normalizeApiBaseUrl(value) { - if (value === undefined) return defaultApiBaseUrl; - if (typeof value !== "string" || !value.trim()) return defaultApiBaseUrl; - let parsed; - try { - parsed = new URL(value.trim()); - } catch { - throw new Error("invalid_api_base_url"); - } - if (parsed.protocol !== "https:" || parsed.hostname !== "api.github.com") { - throw new Error("invalid_api_base_url"); - } - parsed.pathname = parsed.pathname.replace(/\/+$/, ""); - parsed.search = ""; - parsed.hash = ""; - return parsed.toString().replace(/\/+$/, ""); -} - + if (value === undefined) + return defaultApiBaseUrl; + if (typeof value !== "string" || !value.trim()) + return defaultApiBaseUrl; + let parsed; + try { + parsed = new URL(value.trim()); + } + catch { + throw new Error("invalid_api_base_url"); + } + if (parsed.protocol !== "https:" || parsed.hostname !== "api.github.com") { + throw new Error("invalid_api_base_url"); + } + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/+$/, ""); +} function normalizePositiveInt(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 normalizeOptions(options = {}) { - return { - apiBaseUrl: normalizeApiBaseUrl(options.apiBaseUrl), - fetchFn: options.fetchFn ?? fetch, - githubToken: typeof options.githubToken === "string" ? options.githubToken.trim() : "", - maxAttempts: normalizePositiveInt(options.maxAttempts, defaultMaxAttempts, 1, 20), - minIntervalMs: normalizePositiveInt(options.minIntervalMs, defaultMinIntervalMs, 1, 60 * 60_000), - maxIntervalMs: normalizePositiveInt(options.maxIntervalMs, defaultMaxIntervalMs, 1, 60 * 60_000), - requestTimeoutMs: normalizePositiveInt(options.requestTimeoutMs, defaultRequestTimeoutMs, 1, 60_000), - sleepFn: - options.sleepFn ?? - ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))), - }; -} - + return { + apiBaseUrl: normalizeApiBaseUrl(options.apiBaseUrl), + fetchFn: options.fetchFn ?? fetch, + githubToken: typeof options.githubToken === "string" ? options.githubToken.trim() : "", + maxAttempts: normalizePositiveInt(options.maxAttempts, defaultMaxAttempts, 1, 20), + minIntervalMs: normalizePositiveInt(options.minIntervalMs, defaultMinIntervalMs, 1, 60 * 60_000), + maxIntervalMs: normalizePositiveInt(options.maxIntervalMs, defaultMaxIntervalMs, 1, 60 * 60_000), + requestTimeoutMs: normalizePositiveInt(options.requestTimeoutMs, defaultRequestTimeoutMs, 1, 60_000), + sleepFn: options.sleepFn ?? + ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))), + }; +} function parseRepoFullName(repoFullName) { - if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); - const [owner, repo, extra] = repoFullName.split("/"); - if (!owner?.trim() || !repo?.trim() || extra !== undefined) { - throw new Error("invalid_repo_full_name"); - } - return { owner: owner.trim(), repo: repo.trim() }; -} - + if (typeof repoFullName !== "string") + throw new Error("invalid_repo_full_name"); + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner?.trim() || !repo?.trim() || extra !== undefined) { + throw new Error("invalid_repo_full_name"); + } + return { owner: owner.trim(), repo: repo.trim() }; +} function normalizePullNumber(value) { - if (!Number.isInteger(value) || value <= 0) throw new Error("invalid_pr_number"); - return value; + if (!Number.isInteger(value) || value <= 0) + throw new Error("invalid_pr_number"); + return value; } - function githubHeaders(githubToken) { - const headers = { - accept: "application/vnd.github+json", - "user-agent": "loopover-miner", - "x-github-api-version": githubApiVersion, - }; - if (githubToken) headers.authorization = `Bearer ${githubToken}`; - return headers; -} - + const headers = { + accept: "application/vnd.github+json", + "user-agent": "loopover-miner", + "x-github-api-version": githubApiVersion, + }; + if (githubToken) + headers.authorization = `Bearer ${githubToken}`; + return headers; +} function repoPath(target, suffix) { - return `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}${suffix}`; + return `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}${suffix}`; } - function apiUrl(apiBaseUrl, path, query = "") { - return `${apiBaseUrl}${path}${query}`; + return `${apiBaseUrl}${path}${query}`; } - function githubError(response, payload) { - const code = `github_${response.status}`; - const githubMessage = - typeof payload?.message === "string" && payload.message.trim() ? payload.message : null; - const message = githubMessage ? `${code}: ${githubMessage}` : code; - return Object.assign(new Error(message), { code, githubMessage }); + const code = `github_${response.status}`; + const record = payload; + const githubMessage = typeof record?.message === "string" && record.message.trim() ? record.message : null; + const message = githubMessage ? `${code}: ${githubMessage}` : code; + return Object.assign(new Error(message), { code, githubMessage }); } - async function githubGetJsonResponse(url, options) { - // Retry transient network errors / 5xx around this single call (#4829), distinct from the poller's own - // pending-retry loop; the poller's injected sleepFn keeps tests instant. requestTimeoutMs bounds each - // individual attempt (a stalled connection previously hung this call forever -- #miner-github-read-timeouts). - const response = await fetchWithRetry( - options.fetchFn, - url, - { method: "GET", headers: githubHeaders(options.githubToken) }, - { sleepFn: options.sleepFn, timeoutMs: options.requestTimeoutMs }, - ); - const payload = await response.json().catch(() => null); - if (!response.ok) { - throw githubError(response, payload); - } - return { payload, response }; -} - + // Retry transient network errors / 5xx around this single call (#4829), distinct from the poller's own + // pending-retry loop; the poller's injected sleepFn keeps tests instant. requestTimeoutMs bounds each + // individual attempt (a stalled connection previously hung this call forever -- #miner-github-read-timeouts). + const response = (await fetchWithRetry(options.fetchFn, url, { method: "GET", headers: githubHeaders(options.githubToken) }, { sleepFn: options.sleepFn, timeoutMs: options.requestTimeoutMs })); + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw githubError(response, payload); + } + return { payload, response }; +} async function githubGetJson(url, options) { - const { payload } = await githubGetJsonResponse(url, options); - return payload; + const { payload } = await githubGetJsonResponse(url, options); + return payload; } - function hasNextLink(response) { - return /<[^>]+>;\s*rel="next"/.test(response.headers.get("link") ?? ""); + return /<[^>]+>;\s*rel="next"/.test(response.headers.get("link") ?? ""); } - function payloadTotalCount(payload) { - const totalCount = Number(payload?.total_count); - return Number.isInteger(totalCount) && totalCount >= 0 ? totalCount : null; + const totalCount = Number(payload?.total_count); + return Number.isInteger(totalCount) && totalCount >= 0 ? totalCount : null; } - function normalizeConclusion(checkRun) { - if (!checkRun || typeof checkRun !== "object") return "pending"; - if (checkRun.status !== "completed") return "pending"; - switch (checkRun.conclusion) { - case "success": - case "skipped": - return "success"; - case "neutral": - return "neutral"; - case "failure": - case "cancelled": - case "timed_out": - case "action_required": - case "stale": - case "startup_failure": - return "failure"; - default: - return "pending"; - } -} - + if (!checkRun || typeof checkRun !== "object") + return "pending"; + const run = checkRun; + if (run.status !== "completed") + return "pending"; + switch (run.conclusion) { + case "success": + case "skipped": + return "success"; + case "neutral": + return "neutral"; + case "failure": + case "cancelled": + case "timed_out": + case "action_required": + case "stale": + case "startup_failure": + return "failure"; + default: + return "pending"; + } +} function normalizeCheckRun(checkRun) { - return { - name: typeof checkRun?.name === "string" ? checkRun.name : "", - status: typeof checkRun?.status === "string" ? checkRun.status : "unknown", - conclusion: normalizeConclusion(checkRun), - detailsUrl: typeof checkRun?.details_url === "string" ? checkRun.details_url : null, - startedAt: typeof checkRun?.started_at === "string" ? checkRun.started_at : null, - completedAt: typeof checkRun?.completed_at === "string" ? checkRun.completed_at : null, - }; -} - + const run = checkRun; + return { + name: typeof run?.name === "string" ? run.name : "", + status: typeof run?.status === "string" ? run.status : "unknown", + conclusion: normalizeConclusion(checkRun), + detailsUrl: typeof run?.details_url === "string" ? run.details_url : null, + startedAt: typeof run?.started_at === "string" ? run.started_at : null, + completedAt: typeof run?.completed_at === "string" ? run.completed_at : null, + }; +} function aggregateConclusion(checks) { - if (checks.length === 0) return "pending"; - if (checks.some((check) => check.conclusion === "failure")) return "failure"; - if (checks.some((check) => check.conclusion === "pending")) return "pending"; - if (checks.every((check) => check.conclusion === "success")) return "success"; - return "neutral"; + if (checks.length === 0) + return "pending"; + if (checks.some((check) => check.conclusion === "failure")) + return "failure"; + if (checks.some((check) => check.conclusion === "pending")) + return "pending"; + if (checks.every((check) => check.conclusion === "success")) + return "success"; + return "neutral"; } - function backoffDelayMs(attemptIndex, options) { - const exponent = Math.min(10, Math.max(0, attemptIndex)); - return Math.min(options.maxIntervalMs, options.minIntervalMs * 2 ** exponent); + const exponent = Math.min(10, Math.max(0, attemptIndex)); + return Math.min(options.maxIntervalMs, options.minIntervalMs * 2 ** exponent); } - async function fetchHeadSha(target, prNumber, options) { - const payload = await githubGetJson( - apiUrl(options.apiBaseUrl, repoPath(target, `/pulls/${prNumber}`)), - options, - ); - const headSha = payload?.head?.sha; - if (typeof headSha !== "string" || !headSha) throw new Error("github_pr_head_sha_missing"); - return headSha; -} - + const payload = (await githubGetJson(apiUrl(options.apiBaseUrl, repoPath(target, `/pulls/${prNumber}`)), options)); + const headSha = payload?.head?.sha; + if (typeof headSha !== "string" || !headSha) + throw new Error("github_pr_head_sha_missing"); + return headSha; +} async function fetchCheckRuns(target, headSha, options) { - const checks = []; - let page = 1; - let expectedTotalCount = null; - while (true) { - const { payload, response } = await githubGetJsonResponse( - apiUrl( - options.apiBaseUrl, - repoPath(target, `/commits/${encodeURIComponent(headSha)}/check-runs`), - `?per_page=100&page=${page}`, - ), - options, - ); - if (!Array.isArray(payload?.check_runs)) { - throw new Error("github_check_runs_malformed"); - } - const pageChecks = payload.check_runs.map(normalizeCheckRun); - checks.push(...pageChecks); - expectedTotalCount = payloadTotalCount(payload) ?? expectedTotalCount; - if (!hasNextLink(response) && (expectedTotalCount === null || checks.length >= expectedTotalCount)) { - return checks; - } - if (pageChecks.length === 0) { - throw new Error("github_check_runs_pagination_incomplete"); + const checks = []; + let page = 1; + let expectedTotalCount = null; + while (true) { + const { payload, response } = await githubGetJsonResponse(apiUrl(options.apiBaseUrl, repoPath(target, `/commits/${encodeURIComponent(headSha)}/check-runs`), `?per_page=100&page=${page}`), options); + const body = payload; + if (!Array.isArray(body?.check_runs)) { + throw new Error("github_check_runs_malformed"); + } + const pageChecks = body.check_runs.map(normalizeCheckRun); + checks.push(...pageChecks); + expectedTotalCount = payloadTotalCount(payload) ?? expectedTotalCount; + if (!hasNextLink(response) && (expectedTotalCount === null || checks.length >= expectedTotalCount)) { + return checks; + } + if (pageChecks.length === 0) { + throw new Error("github_check_runs_pagination_incomplete"); + } + page += 1; } - page += 1; - } } - export async function pollCheckRuns(repoFullName, prNumber, options = {}) { - const target = parseRepoFullName(repoFullName); - const normalizedPrNumber = normalizePullNumber(prNumber); - const normalizedOptions = normalizeOptions(options); - - let latest = { conclusion: "pending", checks: [], headSha: "", attempts: 0 }; - for (let attempt = 0; attempt < normalizedOptions.maxAttempts; attempt += 1) { - const headSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions); - const checks = await fetchCheckRuns(target, headSha, normalizedOptions); - latest = { - conclusion: aggregateConclusion(checks), - checks, - headSha, - attempts: attempt + 1, - }; - if (latest.conclusion !== "pending") { - const currentHeadSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions); - if (currentHeadSha === headSha) { - return latest; - } - latest = { - conclusion: "pending", - checks: [], - headSha: currentHeadSha, - attempts: attempt + 1, - }; - } - if (attempt === normalizedOptions.maxAttempts - 1) { - return latest; + const target = parseRepoFullName(repoFullName); + const normalizedPrNumber = normalizePullNumber(prNumber); + const normalizedOptions = normalizeOptions(options); + let latest = { conclusion: "pending", checks: [], headSha: "", attempts: 0 }; + for (let attempt = 0; attempt < normalizedOptions.maxAttempts; attempt += 1) { + const headSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions); + const checks = await fetchCheckRuns(target, headSha, normalizedOptions); + latest = { + conclusion: aggregateConclusion(checks), + checks, + headSha, + attempts: attempt + 1, + }; + if (latest.conclusion !== "pending") { + const currentHeadSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions); + if (currentHeadSha === headSha) { + return latest; + } + latest = { + conclusion: "pending", + checks: [], + headSha: currentHeadSha, + attempts: attempt + 1, + }; + } + if (attempt < normalizedOptions.maxAttempts - 1) { + await normalizedOptions.sleepFn(backoffDelayMs(attempt, normalizedOptions)); + } } - await normalizedOptions.sleepFn(backoffDelayMs(attempt, normalizedOptions)); - } - - return latest; + return latest; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2ktcG9sbGVyLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiY2ktcG9sbGVyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSxpQkFBaUIsQ0FBQztBQUVqRCxNQUFNLGlCQUFpQixHQUFHLHdCQUF3QixDQUFDO0FBQ25ELE1BQU0sb0JBQW9CLEdBQUcsTUFBTSxDQUFDO0FBQ3BDLE1BQU0sb0JBQW9CLEdBQUcsQ0FBQyxHQUFHLE1BQU0sQ0FBQztBQUN4QyxNQUFNLGtCQUFrQixHQUFHLENBQUMsQ0FBQztBQUM3QixNQUFNLHVCQUF1QixHQUFHLE1BQU0sQ0FBQztBQUN2QyxNQUFNLGdCQUFnQixHQUFHLFlBQVksQ0FBQztBQTRDdEMsU0FBUyxtQkFBbUIsQ0FBQyxLQUFjO0lBQ3pDLElBQUksS0FBSyxLQUFLLFNBQVM7UUFBRSxPQUFPLGlCQUFpQixDQUFDO0lBQ2xELElBQUksT0FBTyxLQUFLLEtBQUssUUFBUSxJQUFJLENBQUMsS0FBSyxDQUFDLElBQUksRUFBRTtRQUFFLE9BQU8saUJBQWlCLENBQUM7SUFDekUsSUFBSSxNQUFXLENBQUM7SUFDaEIsSUFBSSxDQUFDO1FBQ0gsTUFBTSxHQUFHLElBQUksR0FBRyxDQUFDLEtBQUssQ0FBQyxJQUFJLEVBQUUsQ0FBQyxDQUFDO0lBQ2pDLENBQUM7SUFBQyxNQUFNLENBQUM7UUFDUCxNQUFNLElBQUksS0FBSyxDQUFDLHNCQUFzQixDQUFDLENBQUM7SUFDMUMsQ0FBQztJQUNELElBQUksTUFBTSxDQUFDLFFBQVEsS0FBSyxRQUFRLElBQUksTUFBTSxDQUFDLFFBQVEsS0FBSyxnQkFBZ0IsRUFBRSxDQUFDO1FBQ3pFLE1BQU0sSUFBSSxLQUFLLENBQUMsc0JBQXNCLENBQUMsQ0FBQztJQUMxQyxDQUFDO0lBQ0QsTUFBTSxDQUFDLFFBQVEsR0FBRyxNQUFNLENBQUMsUUFBUSxDQUFDLE9BQU8sQ0FBQyxNQUFNLEVBQUUsRUFBRSxDQUFDLENBQUM7SUFDdEQsTUFBTSxDQUFDLE1BQU0sR0FBRyxFQUFFLENBQUM7SUFDbkIsTUFBTSxDQUFDLElBQUksR0FBRyxFQUFFLENBQUM7SUFDakIsT0FBTyxNQUFNLENBQUMsUUFBUSxFQUFFLENBQUMsT0FBTyxDQUFDLE1BQU0sRUFBRSxFQUFFLENBQUMsQ0FBQztBQUMvQyxDQUFDO0FBRUQsU0FBUyxvQkFBb0IsQ0FBQyxLQUFjLEVBQUUsUUFBZ0IsRUFBRSxHQUFXLEVBQUUsR0FBVztJQUN0RixJQUFJLENBQUMsTUFBTSxDQUFDLFFBQVEsQ0FBQyxLQUFlLENBQUM7UUFBRSxPQUFPLFFBQVEsQ0FBQztJQUN2RCxPQUFPLElBQUksQ0FBQyxHQUFHLENBQUMsR0FBRyxFQUFFLElBQUksQ0FBQyxHQUFHLENBQUMsR0FBRyxFQUFFLElBQUksQ0FBQyxLQUFLLENBQUMsS0FBZSxDQUFDLENBQUMsQ0FBQyxDQUFDO0FBQ25FLENBQUM7QUFFRCxTQUFTLGdCQUFnQixDQUFDLFVBQWdDLEVBQUU7SUFDMUQsT0FBTztRQUNMLFVBQVUsRUFBRSxtQkFBbUIsQ0FBQyxPQUFPLENBQUMsVUFBVSxDQUFDO1FBQ25ELE9BQU8sRUFBRSxPQUFPLENBQUMsT0FBTyxJQUFJLEtBQUs7UUFDakMsV0FBVyxFQUFFLE9BQU8sT0FBTyxDQUFDLFdBQVcsS0FBSyxRQUFRLENBQUMsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxXQUFXLENBQUMsSUFBSSxFQUFFLENBQUMsQ0FBQyxDQUFDLEVBQUU7UUFDdEYsV0FBVyxFQUFFLG9CQUFvQixDQUFDLE9BQU8sQ0FBQyxXQUFXLEVBQUUsa0JBQWtCLEVBQUUsQ0FBQyxFQUFFLEVBQUUsQ0FBQztRQUNqRixhQUFhLEVBQUUsb0JBQW9CLENBQUMsT0FBTyxDQUFDLGFBQWEsRUFBRSxvQkFBb0IsRUFBRSxDQUFDLEVBQUUsRUFBRSxHQUFHLE1BQU0sQ0FBQztRQUNoRyxhQUFhLEVBQUUsb0JBQW9CLENBQUMsT0FBTyxDQUFDLGFBQWEsRUFBRSxvQkFBb0IsRUFBRSxDQUFDLEVBQUUsRUFBRSxHQUFHLE1BQU0sQ0FBQztRQUNoRyxnQkFBZ0IsRUFBRSxvQkFBb0IsQ0FBQyxPQUFPLENBQUMsZ0JBQWdCLEVBQUUsdUJBQXVCLEVBQUUsQ0FBQyxFQUFFLE1BQU0sQ0FBQztRQUNwRyxPQUFPLEVBQ0wsT0FBTyxDQUFDLE9BQU87WUFDZixDQUFDLENBQUMsT0FBZSxFQUFFLEVBQUUsQ0FBQyxJQUFJLE9BQU8sQ0FBQyxDQUFDLE9BQU8sRUFBRSxFQUFFLENBQUMsVUFBVSxDQUFDLE9BQU8sRUFBRSxPQUFPLENBQUMsQ0FBQyxDQUFDO0tBQ2hGLENBQUM7QUFDSixDQUFDO0FBRUQsU0FBUyxpQkFBaUIsQ0FBQyxZQUFvQjtJQUM3QyxJQUFJLE9BQU8sWUFBWSxLQUFLLFFBQVE7UUFBRSxNQUFNLElBQUksS0FBSyxDQUFDLHdCQUF3QixDQUFDLENBQUM7SUFDaEYsTUFBTSxDQUFDLEtBQUssRUFBRSxJQUFJLEVBQUUsS0FBSyxDQUFDLEdBQUcsWUFBWSxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUMsQ0FBQztJQUNyRCxJQUFJLENBQUMsS0FBSyxFQUFFLElBQUksRUFBRSxJQUFJLENBQUMsSUFBSSxFQUFFLElBQUksRUFBRSxJQUFJLEtBQUssS0FBSyxTQUFTLEVBQUUsQ0FBQztRQUMzRCxNQUFNLElBQUksS0FBSyxDQUFDLHdCQUF3QixDQUFDLENBQUM7SUFDNUMsQ0FBQztJQUNELE9BQU8sRUFBRSxLQUFLLEVBQUUsS0FBSyxDQUFDLElBQUksRUFBRSxFQUFFLElBQUksRUFBRSxJQUFJLENBQUMsSUFBSSxFQUFFLEVBQUUsQ0FBQztBQUNwRCxDQUFDO0FBRUQsU0FBUyxtQkFBbUIsQ0FBQyxLQUFhO0lBQ3hDLElBQUksQ0FBQyxNQUFNLENBQUMsU0FBUyxDQUFDLEtBQUssQ0FBQyxJQUFJLEtBQUssSUFBSSxDQUFDO1FBQUUsTUFBTSxJQUFJLEtBQUssQ0FBQyxtQkFBbUIsQ0FBQyxDQUFDO0lBQ2pGLE9BQU8sS0FBSyxDQUFDO0FBQ2YsQ0FBQztBQUVELFNBQVMsYUFBYSxDQUFDLFdBQW1CO0lBQ3hDLE1BQU0sT0FBTyxHQUEyQjtRQUN0QyxNQUFNLEVBQUUsNkJBQTZCO1FBQ3JDLFlBQVksRUFBRSxnQkFBZ0I7UUFDOUIsc0JBQXNCLEVBQUUsZ0JBQWdCO0tBQ3pDLENBQUM7SUFDRixJQUFJLFdBQVc7UUFBRSxPQUFPLENBQUMsYUFBYSxHQUFHLFVBQVUsV0FBVyxFQUFFLENBQUM7SUFDakUsT0FBTyxPQUFPLENBQUM7QUFDakIsQ0FBQztBQUVELFNBQVMsUUFBUSxDQUFDLE1BQWtCLEVBQUUsTUFBYztJQUNsRCxPQUFPLFVBQVUsa0JBQWtCLENBQUMsTUFBTSxDQUFDLEtBQUssQ0FBQyxJQUFJLGtCQUFrQixDQUFDLE1BQU0sQ0FBQyxJQUFJLENBQUMsR0FBRyxNQUFNLEVBQUUsQ0FBQztBQUNsRyxDQUFDO0FBRUQsU0FBUyxNQUFNLENBQUMsVUFBa0IsRUFBRSxJQUFZLEVBQUUsS0FBSyxHQUFHLEVBQUU7SUFDMUQsT0FBTyxHQUFHLFVBQVUsR0FBRyxJQUFJLEdBQUcsS0FBSyxFQUFFLENBQUM7QUFDeEMsQ0FBQztBQUVELFNBQVMsV0FBVyxDQUFDLFFBQTRCLEVBQUUsT0FBZ0I7SUFDakUsTUFBTSxJQUFJLEdBQUcsVUFBVSxRQUFRLENBQUMsTUFBTSxFQUFFLENBQUM7SUFDekMsTUFBTSxNQUFNLEdBQUcsT0FBdUMsQ0FBQztJQUN2RCxNQUFNLGFBQWEsR0FDakIsT0FBTyxNQUFNLEVBQUUsT0FBTyxLQUFLLFFBQVEsSUFBSSxNQUFNLENBQUMsT0FBTyxDQUFDLElBQUksRUFBRSxDQUFDLENBQUMsQ0FBQyxNQUFNLENBQUMsT0FBTyxDQUFDLENBQUMsQ0FBQyxJQUFJLENBQUM7SUFDdkYsTUFBTSxPQUFPLEdBQUcsYUFBYSxDQUFDLENBQUMsQ0FBQyxHQUFHLElBQUksS0FBSyxhQUFhLEVBQUUsQ0FBQyxDQUFDLENBQUMsSUFBSSxDQUFDO0lBQ25FLE9BQU8sTUFBTSxDQUFDLE1BQU0sQ0FBQyxJQUFJLEtBQUssQ0FBQyxPQUFPLENBQUMsRUFBRSxFQUFFLElBQUksRUFBRSxhQUFhLEVBQUUsQ0FBQyxDQUFDO0FBQ3BFLENBQUM7QUFFRCxLQUFLLFVBQVUscUJBQXFCLENBQ2xDLEdBQVcsRUFDWCxPQUE4QjtJQUU5Qix1R0FBdUc7SUFDdkcsc0dBQXNHO0lBQ3RHLDhHQUE4RztJQUM5RyxNQUFNLFFBQVEsR0FBRyxDQUFDLE1BQU0sY0FBYyxDQUNwQyxPQUFPLENBQUMsT0FBK0MsRUFDdkQsR0FBRyxFQUNILEVBQUUsTUFBTSxFQUFFLEtBQUssRUFBRSxPQUFPLEVBQUUsYUFBYSxDQUFDLE9BQU8sQ0FBQyxXQUFXLENBQUMsRUFBRSxFQUM5RCxFQUFFLE9BQU8sRUFBRSxPQUFPLENBQUMsT0FBTyxFQUFFLFNBQVMsRUFBRSxPQUFPLENBQUMsZ0JBQWdCLEVBQUUsQ0FDbEUsQ0FBYSxDQUFDO0lBQ2YsTUFBTSxPQUFPLEdBQUcsTUFBTSxRQUFRLENBQUMsSUFBSSxFQUFFLENBQUMsS0FBSyxDQUFDLEdBQUcsRUFBRSxDQUFDLElBQUksQ0FBQyxDQUFDO0lBQ3hELElBQUksQ0FBQyxRQUFRLENBQUMsRUFBRSxFQUFFLENBQUM7UUFDakIsTUFBTSxXQUFXLENBQUMsUUFBUSxFQUFFLE9BQU8sQ0FBQyxDQUFDO0lBQ3ZDLENBQUM7SUFDRCxPQUFPLEVBQUUsT0FBTyxFQUFFLFFBQVEsRUFBRSxDQUFDO0FBQy9CLENBQUM7QUFFRCxLQUFLLFVBQVUsYUFBYSxDQUFDLEdBQVcsRUFBRSxPQUE4QjtJQUN0RSxNQUFNLEVBQUUsT0FBTyxFQUFFLEdBQUcsTUFBTSxxQkFBcUIsQ0FBQyxHQUFHLEVBQUUsT0FBTyxDQUFDLENBQUM7SUFDOUQsT0FBTyxPQUFPLENBQUM7QUFDakIsQ0FBQztBQUVELFNBQVMsV0FBVyxDQUFDLFFBQWtCO0lBQ3JDLE9BQU8sdUJBQXVCLENBQUMsSUFBSSxDQUFDLFFBQVEsQ0FBQyxPQUFPLENBQUMsR0FBRyxDQUFDLE1BQU0sQ0FBQyxJQUFJLEVBQUUsQ0FBQyxDQUFDO0FBQzFFLENBQUM7QUFFRCxTQUFTLGlCQUFpQixDQUFDLE9BQWdCO0lBQ3pDLE1BQU0sVUFBVSxHQUFHLE1BQU0sQ0FBRSxPQUE0QyxFQUFFLFdBQVcsQ0FBQyxDQUFDO0lBQ3RGLE9BQU8sTUFBTSxDQUFDLFNBQVMsQ0FBQyxVQUFVLENBQUMsSUFBSSxVQUFVLElBQUksQ0FBQyxDQUFDLENBQUMsQ0FBQyxVQUFVLENBQUMsQ0FBQyxDQUFDLElBQUksQ0FBQztBQUM3RSxDQUFDO0FBRUQsU0FBUyxtQkFBbUIsQ0FBQyxRQUFpQjtJQUM1QyxJQUFJLENBQUMsUUFBUSxJQUFJLE9BQU8sUUFBUSxLQUFLLFFBQVE7UUFBRSxPQUFPLFNBQVMsQ0FBQztJQUNoRSxNQUFNLEdBQUcsR0FBRyxRQUFzRCxDQUFDO0lBQ25FLElBQUksR0FBRyxDQUFDLE1BQU0sS0FBSyxXQUFXO1FBQUUsT0FBTyxTQUFTLENBQUM7SUFDakQsUUFBUSxHQUFHLENBQUMsVUFBVSxFQUFFLENBQUM7UUFDdkIsS0FBSyxTQUFTLENBQUM7UUFDZixLQUFLLFNBQVM7WUFDWixPQUFPLFNBQVMsQ0FBQztRQUNuQixLQUFLLFNBQVM7WUFDWixPQUFPLFNBQVMsQ0FBQztRQUNuQixLQUFLLFNBQVMsQ0FBQztRQUNmLEtBQUssV0FBVyxDQUFDO1FBQ2pCLEtBQUssV0FBVyxDQUFDO1FBQ2pCLEtBQUssaUJBQWlCLENBQUM7UUFDdkIsS0FBSyxPQUFPLENBQUM7UUFDYixLQUFLLGlCQUFpQjtZQUNwQixPQUFPLFNBQVMsQ0FBQztRQUNuQjtZQUNFLE9BQU8sU0FBUyxDQUFDO0lBQ3JCLENBQUM7QUFDSCxDQUFDO0FBRUQsU0FBUyxpQkFBaUIsQ0FBQyxRQUFpQjtJQUMxQyxNQUFNLEdBQUcsR0FBRyxRQU1KLENBQUM7SUFDVCxPQUFPO1FBQ0wsSUFBSSxFQUFFLE9BQU8sR0FBRyxFQUFFLElBQUksS0FBSyxRQUFRLENBQUMsQ0FBQyxDQUFDLEdBQUcsQ0FBQyxJQUFJLENBQUMsQ0FBQyxDQUFDLEVBQUU7UUFDbkQsTUFBTSxFQUFFLE9BQU8sR0FBRyxFQUFFLE1BQU0sS0FBSyxRQUFRLENBQUMsQ0FBQyxDQUFDLEdBQUcsQ0FBQyxNQUFNLENBQUMsQ0FBQyxDQUFDLFNBQVM7UUFDaEUsVUFBVSxFQUFFLG1CQUFtQixDQUFDLFFBQVEsQ0FBQztRQUN6QyxVQUFVLEVBQUUsT0FBTyxHQUFHLEVBQUUsV0FBVyxLQUFLLFFBQVEsQ0FBQyxDQUFDLENBQUMsR0FBRyxDQUFDLFdBQVcsQ0FBQyxDQUFDLENBQUMsSUFBSTtRQUN6RSxTQUFTLEVBQUUsT0FBTyxHQUFHLEVBQUUsVUFBVSxLQUFLLFFBQVEsQ0FBQyxDQUFDLENBQUMsR0FBRyxDQUFDLFVBQVUsQ0FBQyxDQUFDLENBQUMsSUFBSTtRQUN0RSxXQUFXLEVBQUUsT0FBTyxHQUFHLEVBQUUsWUFBWSxLQUFLLFFBQVEsQ0FBQyxDQUFDLENBQUMsR0FBRyxDQUFDLFlBQVksQ0FBQyxDQUFDLENBQUMsSUFBSTtLQUM3RSxDQUFDO0FBQ0osQ0FBQztBQUVELFNBQVMsbUJBQW1CLENBQUMsTUFBNEI7SUFDdkQsSUFBSSxNQUFNLENBQUMsTUFBTSxLQUFLLENBQUM7UUFBRSxPQUFPLFNBQVMsQ0FBQztJQUMxQyxJQUFJLE1BQU0sQ0FBQyxJQUFJLENBQUMsQ0FBQyxLQUFLLEVBQUUsRUFBRSxDQUFDLEtBQUssQ0FBQyxVQUFVLEtBQUssU0FBUyxDQUFDO1FBQUUsT0FBTyxTQUFTLENBQUM7SUFDN0UsSUFBSSxNQUFNLENBQUMsSUFBSSxDQUFDLENBQUMsS0FBSyxFQUFFLEVBQUUsQ0FBQyxLQUFLLENBQUMsVUFBVSxLQUFLLFNBQVMsQ0FBQztRQUFFLE9BQU8sU0FBUyxDQUFDO0lBQzdFLElBQUksTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDLEtBQUssRUFBRSxFQUFFLENBQUMsS0FBSyxDQUFDLFVBQVUsS0FBSyxTQUFTLENBQUM7UUFBRSxPQUFPLFNBQVMsQ0FBQztJQUM5RSxPQUFPLFNBQVMsQ0FBQztBQUNuQixDQUFDO0FBRUQsU0FBUyxjQUFjLENBQUMsWUFBb0IsRUFBRSxPQUE4QjtJQUMxRSxNQUFNLFFBQVEsR0FBRyxJQUFJLENBQUMsR0FBRyxDQUFDLEVBQUUsRUFBRSxJQUFJLENBQUMsR0FBRyxDQUFDLENBQUMsRUFBRSxZQUFZLENBQUMsQ0FBQyxDQUFDO0lBQ3pELE9BQU8sSUFBSSxDQUFDLEdBQUcsQ0FBQyxPQUFPLENBQUMsYUFBYSxFQUFFLE9BQU8sQ0FBQyxhQUFhLEdBQUcsQ0FBQyxJQUFJLFFBQVEsQ0FBQyxDQUFDO0FBQ2hGLENBQUM7QUFFRCxLQUFLLFVBQVUsWUFBWSxDQUFDLE1BQWtCLEVBQUUsUUFBZ0IsRUFBRSxPQUE4QjtJQUM5RixNQUFNLE9BQU8sR0FBRyxDQUFDLE1BQU0sYUFBYSxDQUNsQyxNQUFNLENBQUMsT0FBTyxDQUFDLFVBQVUsRUFBRSxRQUFRLENBQUMsTUFBTSxFQUFFLFVBQVUsUUFBUSxFQUFFLENBQUMsQ0FBQyxFQUNsRSxPQUFPLENBQ1IsQ0FBd0MsQ0FBQztJQUMxQyxNQUFNLE9BQU8sR0FBRyxPQUFPLEVBQUUsSUFBSSxFQUFFLEdBQUcsQ0FBQztJQUNuQyxJQUFJLE9BQU8sT0FBTyxLQUFLLFFBQVEsSUFBSSxDQUFDLE9BQU87UUFBRSxNQUFNLElBQUksS0FBSyxDQUFDLDRCQUE0QixDQUFDLENBQUM7SUFDM0YsT0FBTyxPQUFPLENBQUM7QUFDakIsQ0FBQztBQUVELEtBQUssVUFBVSxjQUFjLENBQzNCLE1BQWtCLEVBQ2xCLE9BQWUsRUFDZixPQUE4QjtJQUU5QixNQUFNLE1BQU0sR0FBeUIsRUFBRSxDQUFDO0lBQ3hDLElBQUksSUFBSSxHQUFHLENBQUMsQ0FBQztJQUNiLElBQUksa0JBQWtCLEdBQWtCLElBQUksQ0FBQztJQUM3QyxPQUFPLElBQUksRUFBRSxDQUFDO1FBQ1osTUFBTSxFQUFFLE9BQU8sRUFBRSxRQUFRLEVBQUUsR0FBRyxNQUFNLHFCQUFxQixDQUN2RCxNQUFNLENBQ0osT0FBTyxDQUFDLFVBQVUsRUFDbEIsUUFBUSxDQUFDLE1BQU0sRUFBRSxZQUFZLGtCQUFrQixDQUFDLE9BQU8sQ0FBQyxhQUFhLENBQUMsRUFDdEUsc0JBQXNCLElBQUksRUFBRSxDQUM3QixFQUNELE9BQU8sQ0FDUixDQUFDO1FBQ0YsTUFBTSxJQUFJLEdBQUcsT0FBMEMsQ0FBQztRQUN4RCxJQUFJLENBQUMsS0FBSyxDQUFDLE9BQU8sQ0FBQyxJQUFJLEVBQUUsVUFBVSxDQUFDLEVBQUUsQ0FBQztZQUNyQyxNQUFNLElBQUksS0FBSyxDQUFDLDZCQUE2QixDQUFDLENBQUM7UUFDakQsQ0FBQztRQUNELE1BQU0sVUFBVSxHQUFHLElBQUksQ0FBQyxVQUFVLENBQUMsR0FBRyxDQUFDLGlCQUFpQixDQUFDLENBQUM7UUFDMUQsTUFBTSxDQUFDLElBQUksQ0FBQyxHQUFHLFVBQVUsQ0FBQyxDQUFDO1FBQzNCLGtCQUFrQixHQUFHLGlCQUFpQixDQUFDLE9BQU8sQ0FBQyxJQUFJLGtCQUFrQixDQUFDO1FBQ3RFLElBQUksQ0FBQyxXQUFXLENBQUMsUUFBUSxDQUFDLElBQUksQ0FBQyxrQkFBa0IsS0FBSyxJQUFJLElBQUksTUFBTSxDQUFDLE1BQU0sSUFBSSxrQkFBa0IsQ0FBQyxFQUFFLENBQUM7WUFDbkcsT0FBTyxNQUFNLENBQUM7UUFDaEIsQ0FBQztRQUNELElBQUksVUFBVSxDQUFDLE1BQU0sS0FBSyxDQUFDLEVBQUUsQ0FBQztZQUM1QixNQUFNLElBQUksS0FBSyxDQUFDLHlDQUF5QyxDQUFDLENBQUM7UUFDN0QsQ0FBQztRQUNELElBQUksSUFBSSxDQUFDLENBQUM7SUFDWixDQUFDO0FBQ0gsQ0FBQztBQUVELE1BQU0sQ0FBQyxLQUFLLFVBQVUsYUFBYSxDQUNqQyxZQUFvQixFQUNwQixRQUFnQixFQUNoQixVQUFnQyxFQUFFO0lBRWxDLE1BQU0sTUFBTSxHQUFHLGlCQUFpQixDQUFDLFlBQVksQ0FBQyxDQUFDO0lBQy9DLE1BQU0sa0JBQWtCLEdBQUcsbUJBQW1CLENBQUMsUUFBUSxDQUFDLENBQUM7SUFDekQsTUFBTSxpQkFBaUIsR0FBRyxnQkFBZ0IsQ0FBQyxPQUFPLENBQUMsQ0FBQztJQUVwRCxJQUFJLE1BQU0sR0FBd0IsRUFBRSxVQUFVLEVBQUUsU0FBUyxFQUFFLE1BQU0sRUFBRSxFQUFFLEVBQUUsT0FBTyxFQUFFLEVBQUUsRUFBRSxRQUFRLEVBQUUsQ0FBQyxFQUFFLENBQUM7SUFDbEcsS0FBSyxJQUFJLE9BQU8sR0FBRyxDQUFDLEVBQUUsT0FBTyxHQUFHLGlCQUFpQixDQUFDLFdBQVcsRUFBRSxPQUFPLElBQUksQ0FBQyxFQUFFLENBQUM7UUFDNUUsTUFBTSxPQUFPLEdBQUcsTUFBTSxZQUFZLENBQUMsTUFBTSxFQUFFLGtCQUFrQixFQUFFLGlCQUFpQixDQUFDLENBQUM7UUFDbEYsTUFBTSxNQUFNLEdBQUcsTUFBTSxjQUFjLENBQUMsTUFBTSxFQUFFLE9BQU8sRUFBRSxpQkFBaUIsQ0FBQyxDQUFDO1FBQ3hFLE1BQU0sR0FBRztZQUNQLFVBQVUsRUFBRSxtQkFBbUIsQ0FBQyxNQUFNLENBQUM7WUFDdkMsTUFBTTtZQUNOLE9BQU87WUFDUCxRQUFRLEVBQUUsT0FBTyxHQUFHLENBQUM7U0FDdEIsQ0FBQztRQUNGLElBQUksTUFBTSxDQUFDLFVBQVUsS0FBSyxTQUFTLEVBQUUsQ0FBQztZQUNwQyxNQUFNLGNBQWMsR0FBRyxNQUFNLFlBQVksQ0FBQyxNQUFNLEVBQUUsa0JBQWtCLEVBQUUsaUJBQWlCLENBQUMsQ0FBQztZQUN6RixJQUFJLGNBQWMsS0FBSyxPQUFPLEVBQUUsQ0FBQztnQkFDL0IsT0FBTyxNQUFNLENBQUM7WUFDaEIsQ0FBQztZQUNELE1BQU0sR0FBRztnQkFDUCxVQUFVLEVBQUUsU0FBUztnQkFDckIsTUFBTSxFQUFFLEVBQUU7Z0JBQ1YsT0FBTyxFQUFFLGNBQWM7Z0JBQ3ZCLFFBQVEsRUFBRSxPQUFPLEdBQUcsQ0FBQzthQUN0QixDQUFDO1FBQ0osQ0FBQztRQUNELElBQUksT0FBTyxHQUFHLGlCQUFpQixDQUFDLFdBQVcsR0FBRyxDQUFDLEVBQUUsQ0FBQztZQUNoRCxNQUFNLGlCQUFpQixDQUFDLE9BQU8sQ0FBQyxjQUFjLENBQUMsT0FBTyxFQUFFLGlCQUFpQixDQUFDLENBQUMsQ0FBQztRQUM5RSxDQUFDO0lBQ0gsQ0FBQztJQUVELE9BQU8sTUFBTSxDQUFDO0FBQ2hCLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/ci-poller.ts b/packages/loopover-miner/lib/ci-poller.ts new file mode 100644 index 0000000000..97641b535f --- /dev/null +++ b/packages/loopover-miner/lib/ci-poller.ts @@ -0,0 +1,299 @@ +import { fetchWithRetry } from "./http-retry.js"; + +const defaultApiBaseUrl = "https://api.github.com"; +const defaultMinIntervalMs = 60_000; +const defaultMaxIntervalMs = 5 * 60_000; +const defaultMaxAttempts = 1; +const defaultRequestTimeoutMs = 10_000; +const githubApiVersion = "2022-11-28"; + +export type CheckRunConclusion = "pending" | "success" | "failure" | "neutral"; + +export type NormalizedCheckRun = { + name: string; + status: string; + conclusion: CheckRunConclusion; + detailsUrl: string | null; + startedAt: string | null; + completedAt: string | null; +}; + +export type PollCheckRunsResult = { + conclusion: CheckRunConclusion; + checks: NormalizedCheckRun[]; + headSha: string; + attempts: number; +}; + +export type PollCheckRunsOptions = { + apiBaseUrl?: string; + fetchFn?: typeof fetch; + githubToken?: string; + maxAttempts?: number; + minIntervalMs?: number; + maxIntervalMs?: number; + requestTimeoutMs?: number; + sleepFn?: (delayMs: number) => Promise; +}; + +type NormalizedPollOptions = { + apiBaseUrl: string; + fetchFn: typeof fetch; + githubToken: string; + maxAttempts: number; + minIntervalMs: number; + maxIntervalMs: number; + requestTimeoutMs: number; + sleepFn: (delayMs: number) => Promise; +}; + +type RepoTarget = { owner: string; repo: string }; + +function normalizeApiBaseUrl(value: unknown): string { + if (value === undefined) return defaultApiBaseUrl; + if (typeof value !== "string" || !value.trim()) return defaultApiBaseUrl; + let parsed: URL; + try { + parsed = new URL(value.trim()); + } catch { + throw new Error("invalid_api_base_url"); + } + if (parsed.protocol !== "https:" || parsed.hostname !== "api.github.com") { + throw new Error("invalid_api_base_url"); + } + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/+$/, ""); +} + +function normalizePositiveInt(value: unknown, fallback: number, min: number, max: number): number { + if (!Number.isFinite(value as number)) return fallback; + return Math.min(max, Math.max(min, Math.floor(value as number))); +} + +function normalizeOptions(options: PollCheckRunsOptions = {}): NormalizedPollOptions { + return { + apiBaseUrl: normalizeApiBaseUrl(options.apiBaseUrl), + fetchFn: options.fetchFn ?? fetch, + githubToken: typeof options.githubToken === "string" ? options.githubToken.trim() : "", + maxAttempts: normalizePositiveInt(options.maxAttempts, defaultMaxAttempts, 1, 20), + minIntervalMs: normalizePositiveInt(options.minIntervalMs, defaultMinIntervalMs, 1, 60 * 60_000), + maxIntervalMs: normalizePositiveInt(options.maxIntervalMs, defaultMaxIntervalMs, 1, 60 * 60_000), + requestTimeoutMs: normalizePositiveInt(options.requestTimeoutMs, defaultRequestTimeoutMs, 1, 60_000), + sleepFn: + options.sleepFn ?? + ((delayMs: number) => new Promise((resolve) => setTimeout(resolve, delayMs))), + }; +} + +function parseRepoFullName(repoFullName: string): RepoTarget { + if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner?.trim() || !repo?.trim() || extra !== undefined) { + throw new Error("invalid_repo_full_name"); + } + return { owner: owner.trim(), repo: repo.trim() }; +} + +function normalizePullNumber(value: number): number { + if (!Number.isInteger(value) || value <= 0) throw new Error("invalid_pr_number"); + return value; +} + +function githubHeaders(githubToken: string): Record { + const headers: Record = { + accept: "application/vnd.github+json", + "user-agent": "loopover-miner", + "x-github-api-version": githubApiVersion, + }; + if (githubToken) headers.authorization = `Bearer ${githubToken}`; + return headers; +} + +function repoPath(target: RepoTarget, suffix: string): string { + return `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}${suffix}`; +} + +function apiUrl(apiBaseUrl: string, path: string, query = ""): string { + return `${apiBaseUrl}${path}${query}`; +} + +function githubError(response: { status: number }, payload: unknown): Error { + const code = `github_${response.status}`; + const record = payload as { message?: unknown } | null; + const githubMessage = + typeof record?.message === "string" && record.message.trim() ? record.message : null; + const message = githubMessage ? `${code}: ${githubMessage}` : code; + return Object.assign(new Error(message), { code, githubMessage }); +} + +async function githubGetJsonResponse( + url: string, + options: NormalizedPollOptions, +): Promise<{ payload: unknown; response: Response }> { + // Retry transient network errors / 5xx around this single call (#4829), distinct from the poller's own + // pending-retry loop; the poller's injected sleepFn keeps tests instant. requestTimeoutMs bounds each + // individual attempt (a stalled connection previously hung this call forever -- #miner-github-read-timeouts). + const response = (await fetchWithRetry( + options.fetchFn as Parameters[0], + url, + { method: "GET", headers: githubHeaders(options.githubToken) }, + { sleepFn: options.sleepFn, timeoutMs: options.requestTimeoutMs }, + )) as Response; + const payload = await response.json().catch(() => null); + if (!response.ok) { + throw githubError(response, payload); + } + return { payload, response }; +} + +async function githubGetJson(url: string, options: NormalizedPollOptions): Promise { + const { payload } = await githubGetJsonResponse(url, options); + return payload; +} + +function hasNextLink(response: Response): boolean { + return /<[^>]+>;\s*rel="next"/.test(response.headers.get("link") ?? ""); +} + +function payloadTotalCount(payload: unknown): number | null { + const totalCount = Number((payload as { total_count?: unknown } | null)?.total_count); + return Number.isInteger(totalCount) && totalCount >= 0 ? totalCount : null; +} + +function normalizeConclusion(checkRun: unknown): CheckRunConclusion { + if (!checkRun || typeof checkRun !== "object") return "pending"; + const run = checkRun as { status?: unknown; conclusion?: unknown }; + if (run.status !== "completed") return "pending"; + switch (run.conclusion) { + case "success": + case "skipped": + return "success"; + case "neutral": + return "neutral"; + case "failure": + case "cancelled": + case "timed_out": + case "action_required": + case "stale": + case "startup_failure": + return "failure"; + default: + return "pending"; + } +} + +function normalizeCheckRun(checkRun: unknown): NormalizedCheckRun { + const run = checkRun as { + name?: unknown; + status?: unknown; + details_url?: unknown; + started_at?: unknown; + completed_at?: unknown; + } | null; + return { + name: typeof run?.name === "string" ? run.name : "", + status: typeof run?.status === "string" ? run.status : "unknown", + conclusion: normalizeConclusion(checkRun), + detailsUrl: typeof run?.details_url === "string" ? run.details_url : null, + startedAt: typeof run?.started_at === "string" ? run.started_at : null, + completedAt: typeof run?.completed_at === "string" ? run.completed_at : null, + }; +} + +function aggregateConclusion(checks: NormalizedCheckRun[]): CheckRunConclusion { + if (checks.length === 0) return "pending"; + if (checks.some((check) => check.conclusion === "failure")) return "failure"; + if (checks.some((check) => check.conclusion === "pending")) return "pending"; + if (checks.every((check) => check.conclusion === "success")) return "success"; + return "neutral"; +} + +function backoffDelayMs(attemptIndex: number, options: NormalizedPollOptions): number { + const exponent = Math.min(10, Math.max(0, attemptIndex)); + return Math.min(options.maxIntervalMs, options.minIntervalMs * 2 ** exponent); +} + +async function fetchHeadSha(target: RepoTarget, prNumber: number, options: NormalizedPollOptions): Promise { + const payload = (await githubGetJson( + apiUrl(options.apiBaseUrl, repoPath(target, `/pulls/${prNumber}`)), + options, + )) as { head?: { sha?: unknown } } | null; + const headSha = payload?.head?.sha; + if (typeof headSha !== "string" || !headSha) throw new Error("github_pr_head_sha_missing"); + return headSha; +} + +async function fetchCheckRuns( + target: RepoTarget, + headSha: string, + options: NormalizedPollOptions, +): Promise { + const checks: NormalizedCheckRun[] = []; + let page = 1; + let expectedTotalCount: number | null = null; + while (true) { + const { payload, response } = await githubGetJsonResponse( + apiUrl( + options.apiBaseUrl, + repoPath(target, `/commits/${encodeURIComponent(headSha)}/check-runs`), + `?per_page=100&page=${page}`, + ), + options, + ); + const body = payload as { check_runs?: unknown } | null; + if (!Array.isArray(body?.check_runs)) { + throw new Error("github_check_runs_malformed"); + } + const pageChecks = body.check_runs.map(normalizeCheckRun); + checks.push(...pageChecks); + expectedTotalCount = payloadTotalCount(payload) ?? expectedTotalCount; + if (!hasNextLink(response) && (expectedTotalCount === null || checks.length >= expectedTotalCount)) { + return checks; + } + if (pageChecks.length === 0) { + throw new Error("github_check_runs_pagination_incomplete"); + } + page += 1; + } +} + +export async function pollCheckRuns( + repoFullName: string, + prNumber: number, + options: PollCheckRunsOptions = {}, +): Promise { + const target = parseRepoFullName(repoFullName); + const normalizedPrNumber = normalizePullNumber(prNumber); + const normalizedOptions = normalizeOptions(options); + + let latest: PollCheckRunsResult = { conclusion: "pending", checks: [], headSha: "", attempts: 0 }; + for (let attempt = 0; attempt < normalizedOptions.maxAttempts; attempt += 1) { + const headSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions); + const checks = await fetchCheckRuns(target, headSha, normalizedOptions); + latest = { + conclusion: aggregateConclusion(checks), + checks, + headSha, + attempts: attempt + 1, + }; + if (latest.conclusion !== "pending") { + const currentHeadSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions); + if (currentHeadSha === headSha) { + return latest; + } + latest = { + conclusion: "pending", + checks: [], + headSha: currentHeadSha, + attempts: attempt + 1, + }; + } + if (attempt < normalizedOptions.maxAttempts - 1) { + await normalizedOptions.sleepFn(backoffDelayMs(attempt, normalizedOptions)); + } + } + + return latest; +} diff --git a/packages/loopover-miner/lib/coding-task-spec.d.ts b/packages/loopover-miner/lib/coding-task-spec.d.ts index 3398e8696d..3a34d43fe8 100644 --- a/packages/loopover-miner/lib/coding-task-spec.d.ts +++ b/packages/loopover-miner/lib/coding-task-spec.d.ts @@ -1,47 +1,89 @@ import type { AcceptanceCriteria, FeasibilityGateResult, FeasibilityVerdict, IssueRecord, PullRequestRecord } from "@loopover/engine"; import type { RepoStackResult } from "./stack-detection.js"; - -export type CodingTaskIssue = { number: number; title: string; body?: string | null | undefined; labels?: string[] | undefined }; - +export type CodingTaskIssue = { + number: number; + title: string; + body?: string | null | undefined; + labels?: string[] | undefined; +}; export type CodingTaskClaimLedger = { - listClaims(filter: { repoFullName: string; status: string }): Array<{ issueNumber: number }>; + listClaims(filter: { + repoFullName: string; + status: string; + }): Array<{ + issueNumber: number; + }>; +}; +export type CodingTaskContext = { + issues: IssueRecord[]; + pullRequests: PullRequestRecord[]; }; - -export type CodingTaskContext = { issues: IssueRecord[]; pullRequests: PullRequestRecord[] }; - -export function buildCodingTaskFeasibility( - repoFullName: string, - issue: CodingTaskIssue, - context: CodingTaskContext, - claimLedger: CodingTaskClaimLedger, -): FeasibilityGateResult; - -export function buildCodingTaskAcceptanceCriteria(issue: CodingTaskIssue, feasibility: FeasibilityGateResult): AcceptanceCriteria; - -export function writeAcceptanceCriteriaFile(workingDirectory: string, acceptanceCriteria: AcceptanceCriteria): { written: boolean; path: string | null }; - export type CodingTaskSpecInput = { - repoFullName: string; - issue: CodingTaskIssue; - context: CodingTaskContext; - claimLedger: CodingTaskClaimLedger; - workingDirectory: string; - /** Injectable stack detector (#4786); omitted falls back to stack-detection.js's real `detectRepoStack`. */ - detectRepoStack?: (repoPath: string) => RepoStackResult; + repoFullName: string; + issue: CodingTaskIssue; + context: CodingTaskContext; + claimLedger: CodingTaskClaimLedger; + workingDirectory: string; + /** Injectable stack detector (#4786); omitted falls back to stack-detection.js's real `detectRepoStack`. */ + detectRepoStack?: (repoPath: string) => RepoStackResult; +}; +export type CodingTaskSpecResult = { + ready: false; + verdict: FeasibilityVerdict; + feasibility: FeasibilityGateResult; +} | { + ready: true; + verdict: FeasibilityVerdict; + feasibility: FeasibilityGateResult; + acceptanceCriteriaPath: string; + instructions: string; + title: string; + body: string | undefined; + labels: string[] | undefined; + linkedIssues: number[]; +}; +/** + * Compute the feasibility verdict for one target issue, from real signals: whether the issue is present in + * the fetched context, its real claim status (the claim ledger), and its real duplicate-cluster risk + * (buildCollisionReport over the fetched issues/pullRequests). issueStatus is left to its documented + * "ready" default -- see this file's header for why that's honest, not fabricated. + * + * @param {string} repoFullName + * @param {{ number: number }} issue + * @param {{ issues: Array<{ number: number }>, pullRequests: unknown[] }} context + * @param {{ listClaims: (filter: { repoFullName: string, status: string }) => Array<{ issueNumber: number }> }} claimLedger + * @returns {import("@loopover/engine").FeasibilityGateResult} + */ +export declare function buildCodingTaskFeasibility(repoFullName: string, issue: CodingTaskIssue, context: CodingTaskContext, claimLedger: CodingTaskClaimLedger): FeasibilityGateResult; +/** + * Compose the immutable AcceptanceCriteria document for one target issue + its feasibility verdict. + * + * @param {{ title: string, body?: string | null, labels?: string[] }} issue + * @param {import("@loopover/engine").FeasibilityGateResult} feasibility + * @returns {import("@loopover/engine").AcceptanceCriteria} + */ +export declare function buildCodingTaskAcceptanceCriteria(issue: CodingTaskIssue, feasibility: FeasibilityGateResult): AcceptanceCriteria; +export declare function writeAcceptanceCriteriaFile(workingDirectory: string, acceptanceCriteria: AcceptanceCriteria): { + written: boolean; + path: string | null; }; - -export type CodingTaskSpecResult = - | { ready: false; verdict: FeasibilityVerdict; feasibility: FeasibilityGateResult } - | { - ready: true; - verdict: FeasibilityVerdict; - feasibility: FeasibilityGateResult; - acceptanceCriteriaPath: string; - instructions: string; - title: string; - body: string | undefined; - labels: string[] | undefined; - linkedIssues: number[]; - }; - -export function buildCodingTaskSpec(input: CodingTaskSpecInput): CodingTaskSpecResult; +/** + * Full composition: feasibility -> acceptance criteria -> (if authorized) write the file -> detect the + * target-repo stack (#4786) -> instructions. Returns `ready: false` (with the computed feasibility verdict, + * for the caller to report) when the verdict is `raise`/`avoid` -- the caller should abandon the attempt + * rather than proceed with no real acceptance-criteria file on disk. + * + * `detectRepoStack` is injectable so tests can assert both the detected and fail-closed undiscovered stack + * branches without depending on real filesystem probes; omitted falls back to stack-detection.js's real + * `detectRepoStack` (the production default). + * + * @param {{ + * repoFullName: string, issue: { number: number, title: string, body?: string | null, labels?: string[] }, + * context: { issues: Array<{ number: number }>, pullRequests: unknown[] }, + * claimLedger: { listClaims: (filter: { repoFullName: string, status: string }) => Array<{ issueNumber: number }> }, + * workingDirectory: string, + * detectRepoStack?: (repoPath: string) => import("./stack-detection.js").RepoStackResult, + * }} input + * @returns {import("./coding-task-spec.js").CodingTaskSpecResult} + */ +export declare function buildCodingTaskSpec(input: CodingTaskSpecInput): CodingTaskSpecResult; diff --git a/packages/loopover-miner/lib/coding-task-spec.js b/packages/loopover-miner/lib/coding-task-spec.js index 1071548910..a320f8ba84 100644 --- a/packages/loopover-miner/lib/coding-task-spec.js +++ b/packages/loopover-miner/lib/coding-task-spec.js @@ -1,18 +1,8 @@ import { closeSync, constants as fsConstants, openSync, realpathSync, writeFileSync } from "node:fs"; import { isAbsolute, join, relative } from "node:path"; -import { - ACCEPTANCE_CRITERIA_FILENAME, - buildAcceptanceCriteria, - buildCollisionReport, - buildFeasibilityVerdict, - buildPromptPacket, - feasibilityInputFromPreStartCheck, - serializeAcceptanceCriteria, - shouldWriteAcceptanceCriteria, -} from "@loopover/engine"; +import { ACCEPTANCE_CRITERIA_FILENAME, buildAcceptanceCriteria, buildCollisionReport, buildFeasibilityVerdict, buildPromptPacket, feasibilityInputFromPreStartCheck, serializeAcceptanceCriteria, shouldWriteAcceptanceCriteria, } from "@loopover/engine"; import { neutralizePromptInjection } from "./prompt-injection-defense.js"; import { detectRepoStack, renderStackSummary } from "./stack-detection.js"; - // Coding-task-spec builder (#5132, Wave 3.5 follow-up). The second gap discovered alongside #5132's CLI // wiring: `IterateLoopInput.title`/`instructions`/`acceptanceCriteriaPath` had no builder anywhere in this // package. `packages/loopover-engine/src/miner/acceptance-criteria.ts` already composes a PromptPacket + @@ -41,31 +31,27 @@ import { detectRepoStack, renderStackSummary } from "./stack-detection.js"; // DIFFERENT concern from prompt-packet.ts's sanitizePromptPacketField (already applied downstream to // taskBrief via buildPromptPacket): that scrubs economic/identity terms and local paths, not // manipulation-shaped instructions, so both layers run and neither substitutes for the other. - function buildTaskBrief(issue) { - const title = neutralizePromptInjection(issue.title).text; - const body = neutralizePromptInjection((issue.body ?? "").trim()).text; - return body ? `${title}\n\n${body}` : title; + const title = neutralizePromptInjection(issue.title).text; + const body = neutralizePromptInjection((issue.body ?? "").trim()).text; + return body ? `${title}\n\n${body}` : title; } - function buildConstraints(issue) { - if (!Array.isArray(issue.labels) || issue.labels.length === 0) return ""; - return `Labels on this issue: ${issue.labels.join(", ")}.`; + if (!Array.isArray(issue.labels) || issue.labels.length === 0) + return ""; + return `Labels on this issue: ${issue.labels.join(", ")}.`; } - function buildFeasibilityNotes(feasibility) { - return [feasibility.summary, ...feasibility.avoidReasons, ...feasibility.raiseReasons].join("\n"); + return [feasibility.summary, ...feasibility.avoidReasons, ...feasibility.raiseReasons].join("\n"); } - // Only ever resolves to "claimed"/"unclaimed": the claim ledger's own ClaimStatus vocabulary // ("active"|"released"|"expired") has no "solved" concept for FeasibilityClaimStatus's "solved" value to // map from -- that would need real evidence a PR already resolved the issue (e.g. a merged, linked PR), // which this function doesn't have access to. Not fabricated; genuinely undetectable from claim data alone. function resolveClaimStatus(claimLedger, repoFullName, issueNumber) { - const claims = claimLedger.listClaims({ repoFullName, status: "active" }); - return claims.some((claim) => claim.issueNumber === issueNumber) ? "claimed" : "unclaimed"; + const claims = claimLedger.listClaims({ repoFullName, status: "active" }); + return claims.some((claim) => claim.issueNumber === issueNumber) ? "claimed" : "unclaimed"; } - // The target issue's own raw cluster risk from buildCollisionReport (newly exported from // @loopover/engine's public barrel) -- "none" when the issue isn't part of any cluster at all. // DELIBERATELY does NOT apply #5145's ">= 2 pull_request items" threshold: that gate exists specifically to @@ -75,11 +61,10 @@ function resolveClaimStatus(claimLedger, repoFullName, issueNumber) { // against it (buildCollisionReport's pairwise "shared linked issue" rule, which fires at "high" for exactly // one PR) is a meaningful, real caution signal, not a false positive to filter out. function resolveDuplicateClusterRisk(repoFullName, issues, pullRequests, issueNumber) { - const report = buildCollisionReport(repoFullName, issues, pullRequests); - const cluster = report.clusters.find((entry) => entry.items.some((item) => item.type === "issue" && item.number === issueNumber)); - return cluster ? cluster.risk : "none"; + const report = buildCollisionReport(repoFullName, issues, pullRequests); + const cluster = report.clusters.find((entry) => entry.items.some((item) => item.type === "issue" && item.number === issueNumber)); + return cluster ? cluster.risk : "none"; } - /** * Compute the feasibility verdict for one target issue, from real signals: whether the issue is present in * the fetched context, its real claim status (the claim ledger), and its real duplicate-cluster risk @@ -93,13 +78,12 @@ function resolveDuplicateClusterRisk(repoFullName, issues, pullRequests, issueNu * @returns {import("@loopover/engine").FeasibilityGateResult} */ export function buildCodingTaskFeasibility(repoFullName, issue, context, claimLedger) { - const found = context.issues.some((candidate) => candidate.number === issue.number); - const claimStatus = resolveClaimStatus(claimLedger, repoFullName, issue.number); - const duplicateClusterRisk = resolveDuplicateClusterRisk(repoFullName, context.issues, context.pullRequests, issue.number); - const feasibilityInput = feasibilityInputFromPreStartCheck({ found, claimStatus, duplicateClusterRisk }); - return buildFeasibilityVerdict(feasibilityInput); + const found = context.issues.some((candidate) => candidate.number === issue.number); + const claimStatus = resolveClaimStatus(claimLedger, repoFullName, issue.number); + const duplicateClusterRisk = resolveDuplicateClusterRisk(repoFullName, context.issues, context.pullRequests, issue.number); + const feasibilityInput = feasibilityInputFromPreStartCheck({ found, claimStatus, duplicateClusterRisk }); + return buildFeasibilityVerdict(feasibilityInput); } - /** * Compose the immutable AcceptanceCriteria document for one target issue + its feasibility verdict. * @@ -108,15 +92,14 @@ export function buildCodingTaskFeasibility(repoFullName, issue, context, claimLe * @returns {import("@loopover/engine").AcceptanceCriteria} */ export function buildCodingTaskAcceptanceCriteria(issue, feasibility) { - const promptPacket = buildPromptPacket({ - taskBrief: buildTaskBrief(issue), - constraints: buildConstraints(issue), - feasibilityNotes: buildFeasibilityNotes(feasibility), - retrievalContext: "", - }); - return buildAcceptanceCriteria({ promptPacket, feasibility }); + const promptPacket = buildPromptPacket({ + taskBrief: buildTaskBrief(issue), + constraints: buildConstraints(issue), + feasibilityNotes: buildFeasibilityNotes(feasibility), + retrievalContext: "", + }); + return buildAcceptanceCriteria({ promptPacket, feasibility }); } - /** * Write the acceptance-criteria document into the prepared worktree -- only when its own verdict authorizes * it (shouldWriteAcceptanceCriteria: verdict === "go"). A raise/avoid verdict writes nothing; the caller is @@ -127,28 +110,28 @@ export function buildCodingTaskAcceptanceCriteria(issue, feasibility) { * @returns {{ written: boolean, path: string | null }} */ function assertContainedPath(root, path) { - const relativePath = relative(root, path); - if (relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))) return; - throw new Error(`Refusing to write acceptance criteria outside the worktree: ${path}`); + const relativePath = relative(root, path); + if (relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))) + return; + throw new Error(`Refusing to write acceptance criteria outside the worktree: ${path}`); } - export function writeAcceptanceCriteriaFile(workingDirectory, acceptanceCriteria) { - if (!shouldWriteAcceptanceCriteria(acceptanceCriteria.verdict)) return { written: false, path: null }; - const root = realpathSync(workingDirectory); - const path = join(root, ACCEPTANCE_CRITERIA_FILENAME); - assertContainedPath(root, path); - - let fd; - try { - fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600); - writeFileSync(fd, serializeAcceptanceCriteria(acceptanceCriteria), "utf8"); - } finally { - if (fd !== undefined) closeSync(fd); - } - - return { written: true, path }; + if (!shouldWriteAcceptanceCriteria(acceptanceCriteria.verdict)) + return { written: false, path: null }; + const root = realpathSync(workingDirectory); + const path = join(root, ACCEPTANCE_CRITERIA_FILENAME); + assertContainedPath(root, path); + let fd; + try { + fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600); + writeFileSync(fd, serializeAcceptanceCriteria(acceptanceCriteria), "utf8"); + } + finally { + if (fd !== undefined) + closeSync(fd); + } + return { written: true, path }; } - /** * Prompt guidance derived from a real `detectRepoStack` result (#4786). Lists only commands the detector * confidently inferred -- a `null` command stays omitted rather than guessed -- and always tells the agent @@ -158,31 +141,28 @@ export function writeAcceptanceCriteriaFile(workingDirectory, acceptanceCriteria * @returns {string} */ function buildValidationGuidance(stack) { - const lines = [ - `Detected target-repo stack: ${renderStackSummary(stack)}`, - "", - "Validate your change with THIS repository's own build/test/lint tooling from the stack summary above.", - "Do not assume LoopOver/loopover CI conventions, Codecov patch coverage, or `npm run test:ci` unless those commands appear in the detected stack.", - ]; - if (stack?.detected === true) { - const commands = [ - stack.testCommand ? `- test: \`${stack.testCommand}\`` : null, - stack.lintCommand ? `- lint: \`${stack.lintCommand}\`` : null, - stack.buildCommand ? `- build: \`${stack.buildCommand}\`` : null, - stack.formatCommand ? `- format: \`${stack.formatCommand}\`` : null, - ].filter((entry) => entry !== null); - if (commands.length > 0) { - lines.push("", "Run these commands before finishing:", ...commands); - } else { - lines.push( + const lines = [ + `Detected target-repo stack: ${renderStackSummary(stack)}`, "", - "No build/test/lint/format commands were confidently inferred — discover and use this repo's own tooling rather than guessing.", - ); + "Validate your change with THIS repository's own build/test/lint tooling from the stack summary above.", + "Do not assume LoopOver/loopover CI conventions, Codecov patch coverage, or `npm run test:ci` unless those commands appear in the detected stack.", + ]; + if (stack?.detected === true) { + const commands = [ + stack.testCommand ? `- test: \`${stack.testCommand}\`` : null, + stack.lintCommand ? `- lint: \`${stack.lintCommand}\`` : null, + stack.buildCommand ? `- build: \`${stack.buildCommand}\`` : null, + stack.formatCommand ? `- format: \`${stack.formatCommand}\`` : null, + ].filter((entry) => entry !== null); + if (commands.length > 0) { + lines.push("", "Run these commands before finishing:", ...commands); + } + else { + lines.push("", "No build/test/lint/format commands were confidently inferred — discover and use this repo's own tooling rather than guessing."); + } } - } - return lines.join("\n"); + return lines.join("\n"); } - /** * The coding-agent driver's own prompt text (agent-sdk-driver.ts's header: "forwarded verbatim as the * prompt -- the acceptance-criteria document already lives inside the worktree", so this points to it @@ -198,28 +178,25 @@ function buildValidationGuidance(stack) { * @param {import("./stack-detection.js").RepoStackResult} stack */ function buildInstructions(issue, acceptanceCriteriaPath, stack) { - const title = neutralizePromptInjection(issue.title); - const body = neutralizePromptInjection((issue.body ?? "").trim()); - if (title.injected || body.injected) { - console.log( - JSON.stringify({ - event: "prompt_injection_neutralized", - issueNumber: issue.number, - fields: [title.injected ? "title" : null, body.injected ? "body" : null].filter(Boolean), - }), - ); - } - return [ - `Resolve the following GitHub issue in this repository: #${issue.number} -- ${title.text}`, - "", - body.text, - "", - `A structured acceptance-criteria document describing what "done" means for this attempt is at ${acceptanceCriteriaPath} -- read it and ensure your change satisfies every criterion before finishing.`, - "", - buildValidationGuidance(stack), - ].join("\n"); + const title = neutralizePromptInjection(issue.title); + const body = neutralizePromptInjection((issue.body ?? "").trim()); + if (title.injected || body.injected) { + console.log(JSON.stringify({ + event: "prompt_injection_neutralized", + issueNumber: issue.number, + fields: [title.injected ? "title" : null, body.injected ? "body" : null].filter(Boolean), + })); + } + return [ + `Resolve the following GitHub issue in this repository: #${issue.number} -- ${title.text}`, + "", + body.text, + "", + `A structured acceptance-criteria document describing what "done" means for this attempt is at ${acceptanceCriteriaPath} -- read it and ensure your change satisfies every criterion before finishing.`, + "", + buildValidationGuidance(stack), + ].join("\n"); } - /** * Full composition: feasibility -> acceptance criteria -> (if authorized) write the file -> detect the * target-repo stack (#4786) -> instructions. Returns `ready: false` (with the computed feasibility verdict, @@ -240,29 +217,28 @@ function buildInstructions(issue, acceptanceCriteriaPath, stack) { * @returns {import("./coding-task-spec.js").CodingTaskSpecResult} */ export function buildCodingTaskSpec(input) { - const feasibility = buildCodingTaskFeasibility(input.repoFullName, input.issue, input.context, input.claimLedger); - const acceptanceCriteria = buildCodingTaskAcceptanceCriteria(input.issue, feasibility); - const writeResult = writeAcceptanceCriteriaFile(input.workingDirectory, acceptanceCriteria); - - if (!writeResult.written) { - return { ready: false, verdict: feasibility.verdict, feasibility }; - } - - // Real target-repo stack (#4786): detected from the prepared worktree's own manifests, not guessed from - // loopover conventions. Fail-closed `{ detected: false }` results still reach the prompt (via - // renderStackSummary) so the agent is told detection failed rather than silently defaulting to npm/Codecov. - const detect = input.detectRepoStack ?? detectRepoStack; - const stack = detect(input.workingDirectory); - - return { - ready: true, - verdict: feasibility.verdict, - feasibility, - acceptanceCriteriaPath: writeResult.path, - instructions: buildInstructions(input.issue, writeResult.path, stack), - title: input.issue.title, - body: input.issue.body ?? undefined, - labels: input.issue.labels, - linkedIssues: [input.issue.number], - }; + const feasibility = buildCodingTaskFeasibility(input.repoFullName, input.issue, input.context, input.claimLedger); + const acceptanceCriteria = buildCodingTaskAcceptanceCriteria(input.issue, feasibility); + const writeResult = writeAcceptanceCriteriaFile(input.workingDirectory, acceptanceCriteria); + if (!writeResult.written) { + return { ready: false, verdict: feasibility.verdict, feasibility }; + } + // Real target-repo stack (#4786): detected from the prepared worktree's own manifests, not guessed from + // loopover conventions. Fail-closed `{ detected: false }` results still reach the prompt (via + // renderStackSummary) so the agent is told detection failed rather than silently defaulting to npm/Codecov. + const detect = input.detectRepoStack ?? detectRepoStack; + const stack = detect(input.workingDirectory); + const acceptanceCriteriaPath = writeResult.path; + return { + ready: true, + verdict: feasibility.verdict, + feasibility, + acceptanceCriteriaPath, + instructions: buildInstructions(input.issue, acceptanceCriteriaPath, stack), + title: input.issue.title, + body: input.issue.body ?? undefined, + labels: input.issue.labels, + linkedIssues: [input.issue.number], + }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29kaW5nLXRhc2stc3BlYy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImNvZGluZy10YXNrLXNwZWMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLFNBQVMsRUFBRSxTQUFTLElBQUksV0FBVyxFQUFFLFFBQVEsRUFBRSxZQUFZLEVBQUUsYUFBYSxFQUFFLE1BQU0sU0FBUyxDQUFDO0FBQ3JHLE9BQU8sRUFBRSxVQUFVLEVBQUUsSUFBSSxFQUFFLFFBQVEsRUFBRSxNQUFNLFdBQVcsQ0FBQztBQUN2RCxPQUFPLEVBQ0wsNEJBQTRCLEVBQzVCLHVCQUF1QixFQUN2QixvQkFBb0IsRUFDcEIsdUJBQXVCLEVBQ3ZCLGlCQUFpQixFQUNqQixpQ0FBaUMsRUFDakMsMkJBQTJCLEVBQzNCLDZCQUE2QixHQUM5QixNQUFNLGtCQUFrQixDQUFDO0FBUTFCLE9BQU8sRUFBRSx5QkFBeUIsRUFBRSxNQUFNLCtCQUErQixDQUFDO0FBQzFFLE9BQU8sRUFBRSxlQUFlLEVBQUUsa0JBQWtCLEVBQUUsTUFBTSxzQkFBc0IsQ0FBQztBQXdDM0Usd0dBQXdHO0FBQ3hHLDJHQUEyRztBQUMzRyx5R0FBeUc7QUFDekcsNkdBQTZHO0FBQzdHLDRHQUE0RztBQUM1RyxxR0FBcUc7QUFDckcsNERBQTREO0FBQzVELEVBQUU7QUFDRiw0R0FBNEc7QUFDNUcseUdBQXlHO0FBQ3pHLDZGQUE2RjtBQUM3RiwwRkFBMEY7QUFDMUYsa0dBQWtHO0FBQ2xHLEVBQUU7QUFDRix5R0FBeUc7QUFDekcsNEdBQTRHO0FBQzVHLDJHQUEyRztBQUMzRywwR0FBMEc7QUFDMUcsc0dBQXNHO0FBQ3RHLEVBQUU7QUFDRix1R0FBdUc7QUFDdkcsd0dBQXdHO0FBQ3hHLHlHQUF5RztBQUN6RyxzR0FBc0c7QUFDdEcsbUdBQW1HO0FBQ25HLHFHQUFxRztBQUNyRyw2RkFBNkY7QUFDN0YsOEZBQThGO0FBRTlGLFNBQVMsY0FBYyxDQUFDLEtBQVU7SUFDaEMsTUFBTSxLQUFL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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/coding-task-spec.ts b/packages/loopover-miner/lib/coding-task-spec.ts new file mode 100644 index 0000000000..e893769071 --- /dev/null +++ b/packages/loopover-miner/lib/coding-task-spec.ts @@ -0,0 +1,325 @@ +import { closeSync, constants as fsConstants, openSync, realpathSync, writeFileSync } from "node:fs"; +import { isAbsolute, join, relative } from "node:path"; +import { + ACCEPTANCE_CRITERIA_FILENAME, + buildAcceptanceCriteria, + buildCollisionReport, + buildFeasibilityVerdict, + buildPromptPacket, + feasibilityInputFromPreStartCheck, + serializeAcceptanceCriteria, + shouldWriteAcceptanceCriteria, +} from "@loopover/engine"; +import type { + AcceptanceCriteria, + FeasibilityGateResult, + FeasibilityVerdict, + IssueRecord, + PullRequestRecord, +} from "@loopover/engine"; +import { neutralizePromptInjection } from "./prompt-injection-defense.js"; +import { detectRepoStack, renderStackSummary } from "./stack-detection.js"; +import type { RepoStackResult } from "./stack-detection.js"; + +export type CodingTaskIssue = { + number: number; + title: string; + body?: string | null | undefined; + labels?: string[] | undefined; +}; + +export type CodingTaskClaimLedger = { + listClaims(filter: { repoFullName: string; status: string }): Array<{ issueNumber: number }>; +}; + +export type CodingTaskContext = { issues: IssueRecord[]; pullRequests: PullRequestRecord[] }; + +export type CodingTaskSpecInput = { + repoFullName: string; + issue: CodingTaskIssue; + context: CodingTaskContext; + claimLedger: CodingTaskClaimLedger; + workingDirectory: string; + /** Injectable stack detector (#4786); omitted falls back to stack-detection.js's real `detectRepoStack`. */ + detectRepoStack?: (repoPath: string) => RepoStackResult; +}; + +export type CodingTaskSpecResult = + | { ready: false; verdict: FeasibilityVerdict; feasibility: FeasibilityGateResult } + | { + ready: true; + verdict: FeasibilityVerdict; + feasibility: FeasibilityGateResult; + acceptanceCriteriaPath: string; + instructions: string; + title: string; + body: string | undefined; + labels: string[] | undefined; + linkedIssues: number[]; + }; + +// Coding-task-spec builder (#5132, Wave 3.5 follow-up). The second gap discovered alongside #5132's CLI +// wiring: `IterateLoopInput.title`/`instructions`/`acceptanceCriteriaPath` had no builder anywhere in this +// package. `packages/loopover-engine/src/miner/acceptance-criteria.ts` already composes a PromptPacket + +// FeasibilityGateResult into an immutable AcceptanceCriteria document (and deliberately does NOT write it -- +// "actually writing it into the attempt's worktree is the worktree primitive's job", per its own header) -- +// this module is that caller: derives the four inputs from a real target issue + the already-fetched +// SelfReviewContext (#5145), then writes the file for real. +// +// issueStatus is intentionally left undefined when computing feasibility: buildIssueQualityReport (the only +// thing that could supply it) lives only in root src/signals/engine.ts and has never been extracted into +// @loopover/engine (same gap #5145's own header documents for `issueQuality`). This is not a +// fabrication -- feasibilityInputFromPreStartCheck's OWN documented default for a missing +// issueQualityStatus/lifecycle is "ready", the same honest-default precedent already established. +// +// Target-repo stack detection (#4786 / #4785 follow-up): `detectRepoStack` already returned a structured +// language/package-manager/command description, but nothing in the attempt path consumed it -- instructions +// were issue text + an acceptance-criteria path only. This module now appends that real stack summary (and +// any confidently-inferred validation commands) to the coding-agent prompt so the agent validates against +// THIS repository's tooling rather than assuming LoopOver/loopover CI, Codecov, or `npm run test:ci`. +// +// Prompt-injection defense (#4795): a target issue's title/body is a customer repo's own content -- on +// Rent-a-Loop, anyone who can open an issue on that repo can shape text the coding agent later reads as +// part of its own instructions. `neutralizePromptInjection` runs on both fields before they reach either +// the coding agent's instructions (buildInstructions) or the acceptance-criteria document's taskBrief +// (buildTaskBrief) -- the two places raw issue text is embedded into agent-facing prose. This is a +// DIFFERENT concern from prompt-packet.ts's sanitizePromptPacketField (already applied downstream to +// taskBrief via buildPromptPacket): that scrubs economic/identity terms and local paths, not +// manipulation-shaped instructions, so both layers run and neither substitutes for the other. + +function buildTaskBrief(issue: any) { + const title = neutralizePromptInjection(issue.title).text; + const body = neutralizePromptInjection((issue.body ?? "").trim()).text; + return body ? `${title}\n\n${body}` : title; +} + +function buildConstraints(issue: any) { + if (!Array.isArray(issue.labels) || issue.labels.length === 0) return ""; + return `Labels on this issue: ${issue.labels.join(", ")}.`; +} + +function buildFeasibilityNotes(feasibility: any) { + return [feasibility.summary, ...feasibility.avoidReasons, ...feasibility.raiseReasons].join("\n"); +} + +// Only ever resolves to "claimed"/"unclaimed": the claim ledger's own ClaimStatus vocabulary +// ("active"|"released"|"expired") has no "solved" concept for FeasibilityClaimStatus's "solved" value to +// map from -- that would need real evidence a PR already resolved the issue (e.g. a merged, linked PR), +// which this function doesn't have access to. Not fabricated; genuinely undetectable from claim data alone. +function resolveClaimStatus(claimLedger: any, repoFullName: any, issueNumber: any) { + const claims = claimLedger.listClaims({ repoFullName, status: "active" }); + return claims.some((claim: any) => claim.issueNumber === issueNumber) ? "claimed" : "unclaimed"; +} + +// The target issue's own raw cluster risk from buildCollisionReport (newly exported from +// @loopover/engine's public barrel) -- "none" when the issue isn't part of any cluster at all. +// DELIBERATELY does NOT apply #5145's ">= 2 pull_request items" threshold: that gate exists specifically to +// stop inDuplicateCluster (self-review, "does MY OWN just-created submission look redundant") from firing on +// the ordinary case of one existing PR already legitimately closing the issue. Feasibility asks a different +// question -- "should I even START working on this issue" -- where an issue already having ANY open PR +// against it (buildCollisionReport's pairwise "shared linked issue" rule, which fires at "high" for exactly +// one PR) is a meaningful, real caution signal, not a false positive to filter out. +function resolveDuplicateClusterRisk(repoFullName: any, issues: any, pullRequests: any, issueNumber: any) { + const report = buildCollisionReport(repoFullName, issues, pullRequests); + const cluster = report.clusters.find((entry) => entry.items.some((item) => item.type === "issue" && item.number === issueNumber)); + return cluster ? cluster.risk : "none"; +} + +/** + * Compute the feasibility verdict for one target issue, from real signals: whether the issue is present in + * the fetched context, its real claim status (the claim ledger), and its real duplicate-cluster risk + * (buildCollisionReport over the fetched issues/pullRequests). issueStatus is left to its documented + * "ready" default -- see this file's header for why that's honest, not fabricated. + * + * @param {string} repoFullName + * @param {{ number: number }} issue + * @param {{ issues: Array<{ number: number }>, pullRequests: unknown[] }} context + * @param {{ listClaims: (filter: { repoFullName: string, status: string }) => Array<{ issueNumber: number }> }} claimLedger + * @returns {import("@loopover/engine").FeasibilityGateResult} + */ +export function buildCodingTaskFeasibility( + repoFullName: string, + issue: CodingTaskIssue, + context: CodingTaskContext, + claimLedger: CodingTaskClaimLedger, +): FeasibilityGateResult { + const found = context.issues.some((candidate: any) => candidate.number === issue.number); + const claimStatus = resolveClaimStatus(claimLedger, repoFullName, issue.number); + const duplicateClusterRisk = resolveDuplicateClusterRisk(repoFullName, context.issues, context.pullRequests, issue.number); + const feasibilityInput = feasibilityInputFromPreStartCheck({ found, claimStatus, duplicateClusterRisk }); + return buildFeasibilityVerdict(feasibilityInput); +} + +/** + * Compose the immutable AcceptanceCriteria document for one target issue + its feasibility verdict. + * + * @param {{ title: string, body?: string | null, labels?: string[] }} issue + * @param {import("@loopover/engine").FeasibilityGateResult} feasibility + * @returns {import("@loopover/engine").AcceptanceCriteria} + */ +export function buildCodingTaskAcceptanceCriteria( + issue: CodingTaskIssue, + feasibility: FeasibilityGateResult, +): AcceptanceCriteria { + const promptPacket = buildPromptPacket({ + taskBrief: buildTaskBrief(issue), + constraints: buildConstraints(issue), + feasibilityNotes: buildFeasibilityNotes(feasibility), + retrievalContext: "", + }); + return buildAcceptanceCriteria({ promptPacket, feasibility }); +} + +/** + * Write the acceptance-criteria document into the prepared worktree -- only when its own verdict authorizes + * it (shouldWriteAcceptanceCriteria: verdict === "go"). A raise/avoid verdict writes nothing; the caller is + * expected to abandon the attempt rather than start it, per acceptance-criteria.ts's own documented design. + * + * @param {string} workingDirectory + * @param {import("@loopover/engine").AcceptanceCriteria} acceptanceCriteria + * @returns {{ written: boolean, path: string | null }} + */ +function assertContainedPath(root: any, path: any) { + const relativePath = relative(root, path); + if (relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath))) return; + throw new Error(`Refusing to write acceptance criteria outside the worktree: ${path}`); +} + +export function writeAcceptanceCriteriaFile( + workingDirectory: string, + acceptanceCriteria: AcceptanceCriteria, +): { written: boolean; path: string | null } { + if (!shouldWriteAcceptanceCriteria(acceptanceCriteria.verdict)) return { written: false, path: null }; + const root = realpathSync(workingDirectory); + const path = join(root, ACCEPTANCE_CRITERIA_FILENAME); + assertContainedPath(root, path); + + let fd; + try { + fd = openSync(path, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600); + writeFileSync(fd, serializeAcceptanceCriteria(acceptanceCriteria), "utf8"); + } finally { + if (fd !== undefined) closeSync(fd); + } + + return { written: true, path }; +} + +/** + * Prompt guidance derived from a real `detectRepoStack` result (#4786). Lists only commands the detector + * confidently inferred -- a `null` command stays omitted rather than guessed -- and always tells the agent + * not to assume LoopOver/loopover's own CI/coverage conventions. + * + * @param {import("./stack-detection.js").RepoStackResult} stack + * @returns {string} + */ +function buildValidationGuidance(stack: any) { + const lines = [ + `Detected target-repo stack: ${renderStackSummary(stack)}`, + "", + "Validate your change with THIS repository's own build/test/lint tooling from the stack summary above.", + "Do not assume LoopOver/loopover CI conventions, Codecov patch coverage, or `npm run test:ci` unless those commands appear in the detected stack.", + ]; + if (stack?.detected === true) { + const commands = [ + stack.testCommand ? `- test: \`${stack.testCommand}\`` : null, + stack.lintCommand ? `- lint: \`${stack.lintCommand}\`` : null, + stack.buildCommand ? `- build: \`${stack.buildCommand}\`` : null, + stack.formatCommand ? `- format: \`${stack.formatCommand}\`` : null, + ].filter((entry) => entry !== null); + if (commands.length > 0) { + lines.push("", "Run these commands before finishing:", ...commands); + } else { + lines.push( + "", + "No build/test/lint/format commands were confidently inferred — discover and use this repo's own tooling rather than guessing.", + ); + } + } + return lines.join("\n"); +} + +/** + * The coding-agent driver's own prompt text (agent-sdk-driver.ts's header: "forwarded verbatim as the + * prompt -- the acceptance-criteria document already lives inside the worktree", so this points to it + * rather than repeating its content). Also carries the target repo's detected stack + validation commands + * (#4786) so the agent does not default to loopover-specific CI assumptions. + * + * The issue's title/body are neutralized against prompt-injection (#4795) before embedding -- this is the + * literal `prompt:` handoff to the coding agent (agent-sdk-driver.ts), so it's the primary place untrusted + * repo content could otherwise redirect agent behavior. + * + * @param {{ number: number, title: string, body?: string | null }} issue + * @param {string} acceptanceCriteriaPath + * @param {import("./stack-detection.js").RepoStackResult} stack + */ +function buildInstructions(issue: any, acceptanceCriteriaPath: any, stack: any) { + const title = neutralizePromptInjection(issue.title); + const body = neutralizePromptInjection((issue.body ?? "").trim()); + if (title.injected || body.injected) { + console.log( + JSON.stringify({ + event: "prompt_injection_neutralized", + issueNumber: issue.number, + fields: [title.injected ? "title" : null, body.injected ? "body" : null].filter(Boolean), + }), + ); + } + return [ + `Resolve the following GitHub issue in this repository: #${issue.number} -- ${title.text}`, + "", + body.text, + "", + `A structured acceptance-criteria document describing what "done" means for this attempt is at ${acceptanceCriteriaPath} -- read it and ensure your change satisfies every criterion before finishing.`, + "", + buildValidationGuidance(stack), + ].join("\n"); +} + +/** + * Full composition: feasibility -> acceptance criteria -> (if authorized) write the file -> detect the + * target-repo stack (#4786) -> instructions. Returns `ready: false` (with the computed feasibility verdict, + * for the caller to report) when the verdict is `raise`/`avoid` -- the caller should abandon the attempt + * rather than proceed with no real acceptance-criteria file on disk. + * + * `detectRepoStack` is injectable so tests can assert both the detected and fail-closed undiscovered stack + * branches without depending on real filesystem probes; omitted falls back to stack-detection.js's real + * `detectRepoStack` (the production default). + * + * @param {{ + * repoFullName: string, issue: { number: number, title: string, body?: string | null, labels?: string[] }, + * context: { issues: Array<{ number: number }>, pullRequests: unknown[] }, + * claimLedger: { listClaims: (filter: { repoFullName: string, status: string }) => Array<{ issueNumber: number }> }, + * workingDirectory: string, + * detectRepoStack?: (repoPath: string) => import("./stack-detection.js").RepoStackResult, + * }} input + * @returns {import("./coding-task-spec.js").CodingTaskSpecResult} + */ +export function buildCodingTaskSpec(input: CodingTaskSpecInput): CodingTaskSpecResult { + const feasibility = buildCodingTaskFeasibility(input.repoFullName, input.issue, input.context, input.claimLedger); + const acceptanceCriteria = buildCodingTaskAcceptanceCriteria(input.issue, feasibility); + const writeResult = writeAcceptanceCriteriaFile(input.workingDirectory, acceptanceCriteria); + + if (!writeResult.written) { + return { ready: false, verdict: feasibility.verdict, feasibility }; + } + + // Real target-repo stack (#4786): detected from the prepared worktree's own manifests, not guessed from + // loopover conventions. Fail-closed `{ detected: false }` results still reach the prompt (via + // renderStackSummary) so the agent is told detection failed rather than silently defaulting to npm/Codecov. + const detect = input.detectRepoStack ?? detectRepoStack; + const stack = detect(input.workingDirectory); + const acceptanceCriteriaPath = writeResult.path as string; + + return { + ready: true, + verdict: feasibility.verdict, + feasibility, + acceptanceCriteriaPath, + instructions: buildInstructions(input.issue, acceptanceCriteriaPath, stack), + title: input.issue.title, + body: input.issue.body ?? undefined, + labels: input.issue.labels, + linkedIssues: [input.issue.number], + }; +} diff --git a/packages/loopover-miner/lib/contribution-profile-extract.d.ts b/packages/loopover-miner/lib/contribution-profile-extract.d.ts index 6f0ea199bb..fa7fe673e7 100644 --- a/packages/loopover-miner/lib/contribution-profile-extract.d.ts +++ b/packages/loopover-miner/lib/contribution-profile-extract.d.ts @@ -1,13 +1,5 @@ import type { ContributionProfile } from "./contribution-profile.js"; - -/** - * Extract a best-effort ContributionProfile for a repo from its published label taxonomy and contribution docs. - * Never throws: any fetch/parse failure degrades the relevant signal to `absent`/`unknown`. Generic — no - * loopover-specific hardcoding. - */ -export function extractContributionProfile( - repoFullName: string, - options?: { +type ExtractContributionProfileOptions = { fetchImpl?: typeof fetch; githubToken?: string; apiBaseUrl?: string; @@ -15,5 +7,9 @@ export function extractContributionProfile( generatedAt?: string; /** Sleep seam for the transient-5xx/rate-limit retry (via fetchWithRetry). Injected so tests use no real timers. */ sleepFn?: (ms: number) => Promise; - }, -): Promise; +}; +/** + * Extract a best-effort ContributionProfile for a repo from what it actually publishes. + */ +export declare function extractContributionProfile(repoFullName: string, options?: ExtractContributionProfileOptions): Promise; +export {}; diff --git a/packages/loopover-miner/lib/contribution-profile-extract.js b/packages/loopover-miner/lib/contribution-profile-extract.js index 6f8aee6fcb..7ded7b9bbe 100644 --- a/packages/loopover-miner/lib/contribution-profile-extract.js +++ b/packages/loopover-miner/lib/contribution-profile-extract.js @@ -1,256 +1,196 @@ -// ContributionProfile extraction (#6796). Reads a repo's real, published signals — label taxonomy + contribution -// docs — and produces a populated ContributionProfile per the #6795 schema. GENERIC by design: it recognizes -// conventional OSS eligibility/exclusion vocabulary and matches over label name AND description, with NO -// loopover-specific keyword hardcoding (the #6794 inventory found loopover's own `gittensor:*` labels are the -// exception, not the shape to generalize from). Never throws: any fetch/parse failure degrades a signal to -// `absent`/`unknown` rather than erroring, so an unreachable or docs-less repo yields a low-confidence profile. -import { - CONTRIBUTION_PROFILE_SCHEMA_VERSION, - emptyContributionProfile, - weakestConfidence, -} from "./contribution-profile.js"; +import { CONTRIBUTION_PROFILE_SCHEMA_VERSION, emptyContributionProfile, weakestConfidence, } from "./contribution-profile.js"; import { fetchWithRetry } from "./http-retry.js"; - const DEFAULT_API_BASE_URL = "https://api.github.com"; const GITHUB_API_VERSION = "2022-11-28"; const REQUEST_TIMEOUT_MS = 10_000; /** A CONTRIBUTING.md smaller than this is treated as a signpost (a link to an external guide), not the rules - * themselves — #6794 found react's is 208 B and kubernetes' 525 B, both just pointers. */ + * themselves — #6794 found react's is 208 B and laravel' 525 B, both just pointers. */ const CONTRIBUTING_SIGNPOST_MAX_BYTES = 600; - /** Canonical eligibility vocabulary — recognized OSS "contributor-workable" conventions. Matched case-insensitively * as a substring over a label's name AND description. Not loopover-specific. */ const ELIGIBILITY_TERMS = Object.freeze([ - "good first issue", - "good-first-issue", - "help wanted", - "help-wanted", - "up for grabs", - "beginner", - "easy", - "starter", + "good first issue", + "good-first-issue", + "help wanted", + "help-wanted", + "up for grabs", + "beginner", + "easy", + "starter", ]); - /** Conventional exclusion/off-limits vocabulary. These are UNstated conventions (#6794 found no repo names * exclusion in a label NAME explicitly), so a match yields `inferred`, never `explicit`. */ const EXCLUSION_TERMS = Object.freeze([ - "blocked", - "on hold", - "on-hold", - "do not merge", - "wontfix", - "invalid", - "needs triage", - "work in progress", - "wip", - "maintainer only", - "internal", + "blocked", + "on hold", + "on-hold", + "do not merge", + "wontfix", + "invalid", + "needs triage", + "work in progress", + "wip", + "maintainer only", + "internal", ]); - /** Closing-keyword / linked-issue language in a CONTRIBUTING.md. */ const LINKED_ISSUE_TERMS = Object.freeze([ - "closes #", - "fixes #", - "resolves #", - "linked issue", - "reference an issue", - "link to an issue", + "closes #", + "fixes #", + "resolves #", + "linked issue", + "reference an issue", + "link to an issue", ]); - -/** @param {string} repoFullName @returns {{owner:string,repo:string}|null} */ function parseRepoFullName(repoFullName) { - if (typeof repoFullName !== "string") return null; - const [owner, repo, extra] = repoFullName.split("/"); - if (!owner?.trim() || !repo?.trim() || extra !== undefined) return null; - return { owner: owner.trim(), repo: repo.trim() }; + if (typeof repoFullName !== "string") + return null; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner?.trim() || !repo?.trim() || extra !== undefined) + return null; + return { owner: owner.trim(), repo: repo.trim() }; } - -/** @param {string|undefined} githubToken */ function githubHeaders(githubToken) { - const headers = { - accept: "application/vnd.github+json", - "user-agent": "loopover-miner", - "x-github-api-version": GITHUB_API_VERSION, - }; - if (githubToken) headers.authorization = `Bearer ${githubToken}`; - return headers; + const headers = { + accept: "application/vnd.github+json", + "user-agent": "loopover-miner", + "x-github-api-version": GITHUB_API_VERSION, + }; + if (githubToken) + headers.authorization = `Bearer ${githubToken}`; + return headers; } - /** Bounded, never-throwing JSON GET. Rides out a transient GitHub 5xx or rate-limit response (429 / secondary-403) * via `fetchWithRetry` — the same discipline opportunity-fanout.js's sibling `githubGetJson` already uses — before * falling back to its fail-open contract: returns null on a non-retryable/exhausted HTTP, transport, or parse * failure. `timeoutMs` gives each attempt its own fresh `AbortSignal.timeout` (preserving the per-request bound), * and `sleepFn` is the injectable no-real-timers seam every other `fetchWithRetry` call site exposes. */ async function getJson(url, headers, fetchImpl, sleepFn) { - let response; - try { - response = await fetchWithRetry( - fetchImpl, - url, - { method: "GET", headers }, - { sleepFn, timeoutMs: REQUEST_TIMEOUT_MS }, - ); - } catch { - return null; - } - if (!response.ok) return null; - return response.json().catch(() => null); + let response; + try { + // Cast: the JS always passes `sleepFn` (possibly undefined); EOPT rejects an explicit undefined optional. + response = await fetchWithRetry(fetchImpl, url, { method: "GET", headers }, { sleepFn, timeoutMs: REQUEST_TIMEOUT_MS }); + } + catch { + return null; + } + if (!response.ok) + return null; + return response.json().catch(() => null); } - /** * Match one label against a term list, preferring the NAME but falling back to the DESCRIPTION (the rust * `E-easy` finding: a label can carry its eligibility meaning only in the description). Returns the matcher + * a provenance detail, or null when neither field matches. */ function matchLabel(label, terms) { - const rawName = typeof label?.name === "string" ? label.name : ""; - const name = rawName.toLowerCase(); - const description = - typeof label?.description === "string" - ? label.description.toLowerCase() - : ""; - const detail = rawName || "(unnamed label)"; - const nameTerm = terms.find((term) => name.includes(term)); - if (nameTerm !== undefined) - return { matcher: { field: "name", contains: nameTerm }, detail }; - const descriptionTerm = terms.find((term) => description.includes(term)); - if (descriptionTerm !== undefined) - return { - matcher: { field: "description", contains: descriptionTerm }, - detail, - }; - return null; + const rawName = typeof label?.name === "string" ? label.name : ""; + const name = rawName.toLowerCase(); + const description = typeof label?.description === "string" + ? label.description.toLowerCase() + : ""; + const detail = rawName || "(unnamed label)"; + const nameTerm = terms.find((term) => name.includes(term)); + if (nameTerm !== undefined) + return { matcher: { field: "name", contains: nameTerm }, detail }; + const descriptionTerm = terms.find((term) => description.includes(term)); + if (descriptionTerm !== undefined) + return { + matcher: { field: "description", contains: descriptionTerm }, + detail, + }; + return null; } - /** Classify labels into a SignalRule of the given confidence. Recognized labels build an OR-list of matchers; * no match ⇒ `absent`. Eligibility passes `explicit` (a recognized convention IS an explicit statement); * exclusion passes `inferred` (conventional but unstated). */ function classifyLabels(labels, terms, matchedConfidence) { - const matchers = []; - const provenance = []; - for (const label of labels) { - const hit = matchLabel(label, terms); - if (hit === null) continue; - matchers.push(hit.matcher); - provenance.push({ source: "labels", detail: hit.detail }); - } - if (matchers.length === 0) - return { value: null, confidence: "absent", provenance: [] }; - return { value: matchers, confidence: matchedConfidence, provenance }; + const matchers = []; + const provenance = []; + for (const label of labels) { + const hit = matchLabel(label, terms); + if (hit === null) + continue; + matchers.push(hit.matcher); + provenance.push({ source: "labels", detail: hit.detail }); + } + if (matchers.length === 0) + return { value: null, confidence: "absent", provenance: [] }; + return { value: matchers, confidence: matchedConfidence, provenance }; } - /** Decode a GitHub contents API response body to text. Returns null when absent or not base64. Buffer.from over * a string never throws, so no error path is needed here. */ function decodeContents(payload) { - if ( - !payload || - typeof payload.content !== "string" || - payload.encoding !== "base64" - ) - return null; - return Buffer.from(payload.content, "base64").toString("utf8"); + if (!payload || + typeof payload !== "object" || + typeof payload.content !== "string" || + payload.encoding !== "base64") + return null; + return Buffer.from(payload.content, "base64").toString("utf8"); } - /** Fetch CONTRIBUTING.md, probing the repo root then `.github/` (#6794: 6/10 at root, 2/10 under `.github/`). */ async function fetchContributing(base, target, headers, fetchImpl, sleepFn) { - for (const path of ["CONTRIBUTING.md", ".github/CONTRIBUTING.md"]) { - const payload = await getJson( - `${base}/repos/${target.owner}/${target.repo}/contents/${path}`, - headers, - fetchImpl, - sleepFn, - ); - const text = decodeContents(payload); - if (text !== null) return text; - } - return null; + for (const path of ["CONTRIBUTING.md", ".github/CONTRIBUTING.md"]) { + const payload = await getJson(`${base}/repos/${target.owner}/${target.repo}/contents/${path}`, headers, fetchImpl, sleepFn); + const text = decodeContents(payload); + if (text !== null) + return text; + } + return null; } - /** Extract the PR-body linked-issue requirement from CONTRIBUTING.md. A very small file is a signpost, not the * rules, so it yields `absent` rather than a false negative dressed as a real one. */ function extractPrBody(contributing) { - if (contributing === null) - return { value: null, confidence: "absent", provenance: [] }; - if (contributing.length < CONTRIBUTING_SIGNPOST_MAX_BYTES) - return { value: null, confidence: "unknown", provenance: [] }; - const lower = contributing.toLowerCase(); - const requiresLinkedIssue = LINKED_ISSUE_TERMS.some((term) => - lower.includes(term), - ); - // A real, sufficiently-sized CONTRIBUTING.md is an explicit source either way: present-with-keyword is an - // explicit requirement, present-without is an explicit "no such rule". - return { - value: { requiresLinkedIssue }, - confidence: "explicit", - provenance: [{ source: "contributing_md", detail: "CONTRIBUTING.md" }], - }; + if (contributing === null) + return { value: null, confidence: "absent", provenance: [] }; + if (contributing.length < CONTRIBUTING_SIGNPOST_MAX_BYTES) + return { value: null, confidence: "unknown", provenance: [] }; + const lower = contributing.toLowerCase(); + const requiresLinkedIssue = LINKED_ISSUE_TERMS.some((term) => lower.includes(term)); + // A real, sufficiently-sized CONTRIBUTING.md is an explicit source either way: present-with-keyword is an + // explicit requirement, present-without is an explicit "no such rule". + return { + value: { requiresLinkedIssue }, + confidence: "explicit", + provenance: [{ source: "contributing_md", detail: "CONTRIBUTING.md" }], + }; } - /** * Extract a best-effort ContributionProfile for a repo from what it actually publishes. - * - * @param {string} repoFullName owner/repo - * @param {{ fetchImpl?: typeof fetch, githubToken?: string, apiBaseUrl?: string, generatedAt?: string, sleepFn?: (ms: number) => Promise }} [options] - * @returns {Promise} */ export async function extractContributionProfile(repoFullName, options = {}) { - const generatedAt = - typeof options.generatedAt === "string" - ? options.generatedAt - : new Date().toISOString(); - const target = parseRepoFullName(repoFullName); - // A malformed name can't be fetched — return the safe, fully-absent default rather than throwing. - if (target === null) - return emptyContributionProfile( - typeof repoFullName === "string" ? repoFullName : "", - generatedAt, - ); - - /* v8 ignore next -- the global-fetch default is the production path; every test injects fetchImpl. */ - const fetchImpl = options.fetchImpl ?? fetch; - const base = - typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() - ? options.apiBaseUrl.replace(/\/+$/, "") - : DEFAULT_API_BASE_URL; - const headers = githubHeaders( - options.githubToken ?? process.env.GITHUB_TOKEN, - ); - - const sleepFn = options.sleepFn; - const labelsPayload = await getJson( - `${base}/repos/${target.owner}/${target.repo}/labels?per_page=100`, - headers, - fetchImpl, - sleepFn, - ); - const labels = Array.isArray(labelsPayload) ? labelsPayload : []; - const contributing = await fetchContributing( - base, - target, - headers, - fetchImpl, - sleepFn, - ); - - const eligibilityLabels = classifyLabels( - labels, - ELIGIBILITY_TERMS, - "explicit", - ); - const exclusionLabels = classifyLabels(labels, EXCLUSION_TERMS, "inferred"); - const prBody = extractPrBody(contributing); - - return { - repoFullName: `${target.owner}/${target.repo}`, - schemaVersion: CONTRIBUTION_PROFILE_SCHEMA_VERSION, - generatedAt, - eligibilityLabels, - exclusionLabels, - prBody, - completeness: weakestConfidence([ - eligibilityLabels.confidence, - exclusionLabels.confidence, - prBody.confidence, - ]), - }; + const generatedAt = typeof options.generatedAt === "string" + ? options.generatedAt + : new Date().toISOString(); + const target = parseRepoFullName(repoFullName); + // A malformed name can't be fetched — return the safe, fully-absent default rather than throwing. + if (target === null) + return emptyContributionProfile(typeof repoFullName === "string" ? repoFullName : "", generatedAt); + /* v8 ignore next -- the global-fetch default is the production path; every test injects fetchImpl. */ + const fetchImpl = options.fetchImpl ?? fetch; + const base = typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() + ? options.apiBaseUrl.replace(/\/+$/, "") + : DEFAULT_API_BASE_URL; + const headers = githubHeaders(options.githubToken ?? process.env.GITHUB_TOKEN); + const sleepFn = options.sleepFn; + const labelsPayload = await getJson(`${base}/repos/${target.owner}/${target.repo}/labels?per_page=100`, headers, fetchImpl, sleepFn); + const labels = Array.isArray(labelsPayload) ? labelsPayload : []; + const contributing = await fetchContributing(base, target, headers, fetchImpl, sleepFn); + const eligibilityLabels = classifyLabels(labels, ELIGIBILITY_TERMS, "explicit"); + const exclusionLabels = classifyLabels(labels, EXCLUSION_TERMS, "inferred"); + const prBody = extractPrBody(contributing); + return { + repoFullName: `${target.owner}/${target.repo}`, + schemaVersion: CONTRIBUTION_PROFILE_SCHEMA_VERSION, + generatedAt, + eligibilityLabels, + exclusionLabels, + prBody, + completeness: weakestConfidence([ + eligibilityLabels.confidence, + exclusionLabels.confidence, + prBody.confidence, + ]), + }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29udHJpYnV0aW9uLXByb2ZpbGUtZXh0cmFjdC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImNvbnRyaWJ1dGlvbi1wcm9maWxlLWV4dHJhY3QudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBY0EsT0FBTyxFQUNMLG1DQUFtQyxFQUNuQyx3QkFBd0IsRUFDeEIsaUJBQWlCLEdBQ2xCLE1BQU0sMkJBQTJCLENBQUM7QUFDbkMsT0FBTyxFQUFFLGNBQWMsRUFBOEIsTUFBTSxpQkFBaUIsQ0FBQztBQUU3RSxNQUFNLG9CQUFvQixHQUFHLHdCQUF3QixDQUFDO0FBQ3RELE1BQU0sa0JBQWtCLEdBQUcsWUFBWSxDQUFDO0FBQ3hDLE1BQU0sa0JBQWtCLEdBQUcsTUFBTSxDQUFDO0FBQ2xDO3dGQUN3RjtBQUN4RixNQUFNLCtCQUErQixHQUFHLEdBQUcsQ0FBQztBQUU1QztpRkFDaUY7QUFDakYsTUFBTSxpQkFBaUIsR0FBRyxNQUFNLENBQUMsTUFBTSxDQUFDO0lBQ3RDLGtCQUFrQjtJQUNsQixrQkFBa0I7SUFDbEIsYUFBYTtJQUNiLGFBQWE7SUFDYixjQUFjO0lBQ2QsVUFBVTtJQUNWLE1BQU07SUFDTixTQUFTO0NBQ1YsQ0FBQyxDQUFDO0FBRUg7NkZBQzZGO0FBQzdGLE1BQU0sZUFBZSxHQUFHLE1BQU0sQ0FBQyxNQUFNLENBQUM7SUFDcEMsU0FBUztJQUNULFNBQVM7SUFDVCxTQUFTO0lBQ1QsY0FBYztJQUNkLFNBQVM7SUFDVCxTQUFTO0lBQ1QsY0FBYztJQUNkLGtCQUFrQjtJQUNsQixLQUFLO0lBQ0wsaUJBQWlCO0lBQ2pCLFVBQVU7Q0FDWCxDQUFDLENBQUM7QUFFSCxvRUFBb0U7QUFDcEUsTUFBTSxrQkFBa0IsR0FBRyxNQUFNLENBQUMsTUFBTSxDQUFDO0lBQ3ZDLFVBQVU7SUFDVixTQUFTO0lBQ1QsWUFBWTtJQUNaLGNBQWM7SUFDZCxvQkFBb0I7SUFDcEIsa0JBQWtCO0NBQ25CLENBQUMsQ0FBQztBQWNILFNBQVMsaUJBQWlCLENBQUMsWUFBcUI7SUFDOUMsSUFBSSxPQUFPLFlBQVksS0FBSyxRQUFRO1FBQUUsT0FBTyxJQUFJLENBQUM7SUFDbEQsTUFBTSxDQUFDLEtBQUssRUFBRSxJQUFJLEVBQUUsS0FBSyxDQUFDLEdBQUcsWUFBWSxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUMsQ0FBQztJQUNyRCxJQUFJLENBQUMsS0FBSyxFQUFFLElBQUksRUFBRSxJQUFJLENBQUMsSUFBSSxFQUFFLElBQUksRUFBRSxJQUFJLEtBQUssS0FBSyxTQUFTO1FBQUUsT0FBTyxJQUFJLENBQUM7SUFDeEUsT0FBTyxFQUFFLEtBQUssRUFBRSxLQUFLLENBQUMsSUFBSSxFQUFFLEVBQUUsSUFBSSxFQUFFLElBQUksQ0FBQyxJQUFJLEVBQUUsRUFBRSxDQUFDO0FBQ3BELENBQUM7QUFFRCxTQUFTLGFBQWEsQ0FBQyxXQUErQjtJQUNwRCxNQUFNLE9BQU8sR0FBMkI7UUFDdEMsTUFBTSxFQUFFLDZCQUE2QjtRQUNyQyxZQUFZLEVBQUUsZ0JBQWdCO1FBQzlCLHNCQUFzQixFQUFFLGtCQUFrQjtLQUMzQyxDQUFDO0lBQ0YsSUFBSSxXQUFXO1FBQUUsT0FBTyxDQUFDLGFBQWEsR0FBRyxVQUFVLFdBQVcsRUFBRSxDQUFDO0lBQ2pFLE9BQU8sT0FBTyxDQUFDO0FBQ2pCLENBQUM7QUFFRDs7OzswR0FJMEc7QUFDMUcsS0FBSyxVQUFVLE9BQU8sQ0FDcEIsR0FBVyxFQUNYLE9BQStCLEVBQy9CLFNBQXVCLEVBQ3ZCLE9BQXVEO0lBRXZELElBQUksUUFBa0IsQ0FBQztJQUN2QixJQUFJLENBQUM7UUFDSCwwR0FBMEc7UUFDMUcsUUFBUSxHQUFHLE1BQU0sY0FBYyxDQUM3QixTQUFnRSxFQUNoRSxHQUFHLEVBQ0gsRUFBRSxNQUFNLEVBQUUsS0FBSyxFQUFFLE9BQU8sRUFBRSxFQUMxQixFQUFFLE9BQU8sRUFBRSxTQUFTLEVBQUUsa0JBQWtCLEVBQTJCLENBQ3BFLENBQUM7SUFDSixDQUFDO0lBQUMsTUFBTSxDQUFDO1FBQ1AsT0FBTyxJQUFJLENBQUM7SUFDZCxDQUFDO0lBQ0QsSUFBSSxDQUFDLFFBQVEsQ0FBQyxFQUFFO1FBQUUsT0FBTyxJQUFJLENBQUM7SUFDOUIsT0FBTyxRQUFRLENBQUMsSUFBSSxFQUFFLENBQUMsS0FBSyxDQUFDLEdBQUcsRUFBRSxDQUFDLElBQUksQ0FBQyxDQUFDO0FBQzNDLENBQUM7QUFFRDs7OztHQUlHO0FBQ0gsU0FBUyxVQUFVLENBQ2pCLEtBQWtCLEVBQ2xCLEtBQXdCO0lBRXhCLE1BQU0sT0FBTyxHQUFHLE9BQU8sS0FBSyxFQUFFLElBQUksS0FBSyxRQUFRLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxJQUFJLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQztJQUNsRSxNQUFNLElBQUksR0FBRyxPQUFPLENBQUMsV0FBVyxFQUFFLENBQUM7SUFDbkMsTUFBTSxXQUFXLEdBQ2YsT0FBTyxLQUFLLEVBQUUsV0FBVyxLQUFLLFFBQVE7UUFDcEMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxXQUFXLENBQUMsV0FBVyxFQUFFO1FBQ2pDLENBQUMsQ0FBQyxFQUFFLENBQUM7SUFDVCxNQUFNLE1BQU0sR0FBRyxPQUFPLElBQUksaUJBQWlCLENBQUM7SUFDNUMsTUFBTSxRQUFRLEdBQUcsS0FBSyxDQUFDLElBQUksQ0FBQyxDQUFDLElBQUksRUFBRSxFQUFFLENBQUMsSUFBSSxDQUFDLFFBQVEsQ0FBQyxJQUFJLENBQUMsQ0FBQyxDQUFDO0lBQzNELElBQUksUUFBUSxLQUFLLFNBQVM7UUFDeEIsT0FBTyxFQUFFLE9BQU8sRUFBRSxFQUFFLEtBQUssRUFBRSxNQUFNLEVBQUUsUUFBUSxFQUFFLFFBQVEsRUFBRSxFQUFFLE1BQU0sRUFBRSxDQUFDO0lBQ3BFLE1BQU0sZUFBZSxHQUFHLEtBQUssQ0FBQyxJQUFJLENBQUMsQ0FBQyxJQUFJLEVBQUUsRUFBRSxDQUFDLFdBQVcsQ0FBQyxRQUFRLENBQUMsSUFBSSxDQUFDLENBQUMsQ0FBQztJQUN6RSxJQUFJLGVBQWUsS0FBSyxTQUFTO1FBQy9CLE9BQU87WUFDTCxPQUFPLEVBQUUsRUFBRSxLQUFLLEVBQUUsYUFBYSxFQUFFLFFBQVEsRUFBRSxlQUFlLEVBQUU7WUFDNUQsTUFBTTtTQUNQLENBQUM7SUFDSixPQUFPLElBQUksQ0FBQztBQUNkLENBQUM7QUFFRDs7K0RBRStEO0FBQy9ELFNBQVMsY0FBYyxDQUNyQixNQUFxQixFQUNyQixLQUF3QixFQUN4QixpQkFBK0M7SUFFL0MsTUFBTSxRQUFRLEdBQStCLEVBQUUsQ0FBQztJQUNoRCxNQUFNLFVBQVUsR0FBbUMsRUFBRSxDQUFDO0lBQ3RELEtBQUssTUFBTSxLQUFLLElBQUksTUFBTSxFQUFFLENBQUM7UUFDM0IsTUFBTSxHQUFHLEdBQUcsVUFBVSxDQUFDLEtBQUssRUFBRSxLQUFLLENBQUMsQ0FBQztRQUNyQyxJQUFJLEdBQUcsS0FBSyxJQUFJO1lBQUUsU0FBUztRQUMzQixRQUFRLENBQUMsSUFBSSxDQUFDLEdBQUcsQ0FBQyxPQUFPLENBQUMsQ0FBQztRQUMzQixVQUFVLENBQUMsSUFBSSxDQUFDLEVBQUUsTUFBTSxFQUFFLFFBQVEsRUFBRSxNQUFNLEVBQUUsR0FBRyxDQUFDLE1BQU0sRUFBRSxDQUFDLENBQUM7SUFDNUQsQ0FBQztJQUNELElBQUksUUFBUSxDQUFDLE1BQU0sS0FBSyxDQUFDO1FBQ3ZCLE9BQU8sRUFBRSxLQUFLLEVBQUUsSUFBSSxFQUFFLFVBQVUsRUFBRSxRQUFRLEVBQUUsVUFBVSxFQUFFLEVBQUUsRUFBRSxDQUFDO0lBQy9ELE9BQU8sRUFBRSxLQUFLLEVBQUUsUUFBUSxFQUFFLFVBQVUsRUFBRSxpQkFBaUIsRUFBRSxVQUFVLEVBQUUsQ0FBQztBQUN4RSxDQUFDO0FBRUQ7OERBQzhEO0FBQzlELFNBQVMsY0FBYyxDQUFDLE9BQWdCO0lBQ3RDLElBQ0UsQ0FBQyxPQUFPO1FBQ1IsT0FBTyxPQUFPLEtBQUssUUFBUTtRQUMzQixPQUFRLE9BQWlDLENBQUMsT0FBTyxLQUFLLFFBQVE7UUFDN0QsT0FBa0MsQ0FBQyxRQUFRLEtBQUssUUFBUTtRQUV6RCxPQUFPLElBQUksQ0FBQztJQUNkLE9BQU8sTUFBTSxDQUFDLElBQUksQ0FBRSxPQUErQixDQUFDLE9BQU8sRUFBRSxRQUFRLENBQUMsQ0FBQyxRQUFRLENBQUMsTUFBTSxDQUFDLENBQUM7QUFDMUYsQ0FBQztBQUVELGlIQUFpSDtBQUNqSCxLQUFLLFVBQVUsaUJBQWlCLENBQzlCLElBQVksRUFDWixNQUF1QyxFQUN2QyxPQUErQixFQUMvQixTQUF1QixFQUN2QixPQUF1RDtJQUV2RCxLQUFLLE1BQU0sSUFBSSxJQUFJLENBQUMsaUJBQWlCLEVBQUUseUJBQXlCLENBQUMsRUFBRSxDQUFDO1FBQ2xFLE1BQU0sT0FBTyxHQUFHLE1BQU0sT0FBTyxDQUMzQixHQUFHLElBQUksVUFBVSxNQUFNLENBQUMsS0FBSyxJQUFJLE1BQU0sQ0FBQyxJQUFJLGFBQWEsSUFBSSxFQUFFLEVBQy9ELE9BQU8sRUFDUCxTQUFTLEVBQ1QsT0FBTyxDQUNSLENBQUM7UUFDRixNQUFNLElBQUksR0FBRyxjQUFjLENBQUMsT0FBTyxDQUFDLENBQUM7UUFDckMsSUFBSSxJQUFJLEtBQUssSUFBSTtZQUFFLE9BQU8sSUFBSSxDQUFDO0lBQ2pDLENBQUM7SUFDRCxPQUFPLElBQUksQ0FBQztBQUNkLENBQUM7QUFFRDt1RkFDdUY7QUFDdkYsU0FBUyxhQUFhLENBQ3BCLFlBQTJCO0lBRTNCLElBQUksWUFBWSxLQUFLLElBQUk7UUFDdkIsT0FBTyxFQUFFLEtBQUssRUFBRSxJQUFJLEVBQUUsVUFBVSxFQUFFLFFBQVEsRUFBRSxVQUFVLEVBQUUsRUFBRSxFQUFFLENBQUM7SUFDL0QsSUFBSSxZQUFZLENBQUMsTUFBTSxHQUFHLCtCQUErQjtRQUN2RCxPQUFPLEVBQUUsS0FBSyxFQUFFLElBQUksRUFBRSxVQUFVLEVBQUUsU0FBUyxFQUFFLFVBQVUsRUFBRSxFQUFFLEVBQUUsQ0FBQztJQUNoRSxNQUFNLEtBQUssR0FBRyxZQUFZLENBQUMsV0FBVyxFQUFFLENBQUM7SUFDekMsTUFBTSxtQkFBbUIsR0FBRyxrQkFBa0IsQ0FBQyxJQUFJLENBQUMsQ0FBQyxJQUFJLEVBQUUsRUFBRSxDQUMzRCxLQUFLLENBQUMsUUFBUSxDQUFDLElBQUksQ0FBQyxDQUNyQixDQUFDO0lBQ0YsMEdBQTBHO0lBQzFHLHVFQUF1RTtJQUN2RSxPQUFPO1FBQ0wsS0FBSyxFQUFFLEVBQUUsbUJBQW1CLEVBQUU7UUFDOUIsVUFBVSxFQUFFLFVBQVU7UUFDdEIsVUFBVSxFQUFFLENBQUMsRUFBRSxNQUFNLEVBQUUsaUJBQWlCLEVBQUUsTUFBTSxFQUFFLGlCQUFpQixFQUFFLENBQUM7S0FDdkUsQ0FBQztBQUNKLENBQUM7QUFFRDs7R0FFRztBQUNILE1BQU0sQ0FBQyxLQUFLLFVBQVUsMEJBQTBCLENBQzlDLFlBQW9CLEVBQ3BCLFVBQTZDLEVBQUU7SUFFL0MsTUFBTSxXQUFXLEdBQ2YsT0FBTyxPQUFPLENBQUMsV0FBVyxLQUFLLFFBQVE7UUFDckMsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxXQUFXO1FBQ3JCLENBQUMsQ0FBQyxJQUFJLElBQUksRUFBRSxDQUFDLFdBQVcsRUFBRSxDQUFDO0lBQy9CLE1BQU0sTUFBTSxHQUFHLGlCQUFpQixDQUFDLFlBQVksQ0FBQyxDQUFDO0lBQy9DLGtHQUFrRztJQUNsRyxJQUFJLE1BQU0sS0FBSyxJQUFJO1FBQ2pCLE9BQU8sd0JBQXdCLENBQzdCLE9BQU8sWUFBWSxLQUFLLFFBQVEsQ0FBQyxDQUFDLENBQUMsWUFBWSxDQUFDLENBQUMsQ0FBQyxFQUFFLEVBQ3BELFdBQVcsQ0FDWixDQUFDO0lBRUosc0dBQXNHO0lBQ3RHLE1BQU0sU0FBUyxHQUFHLE9BQU8sQ0FBQyxTQUFTLElBQUksS0FBSyxDQUFDO0lBQzdDLE1BQU0sSUFBSSxHQUNSLE9BQU8sT0FBTyxDQUFDLFVBQVUsS0FBSyxRQUFRLElBQUksT0FBTyxDQUFDLFVBQVUsQ0FBQyxJQUFJLEVBQUU7UUFDakUsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxVQUFVLENBQUMsT0FBTyxDQUFDLE1BQU0sRUFBRSxFQUFFLENBQUM7UUFDeEMsQ0FBQyxDQUFDLG9CQUFvQixDQUFDO0lBQzNCLE1BQU0sT0FBTyxHQUFHLGFBQWEsQ0FDM0IsT0FBTyxDQUFDLFdBQVcsSUFBSSxPQUFPLENBQUMsR0FBRyxDQUFDLFlBQVksQ0FDaEQsQ0FBQztJQUVGLE1BQU0sT0FBTyxHQUFHLE9BQU8sQ0FBQyxPQUFPLENBQUM7SUFDaEMsTUFBTSxhQUFhLEdBQUcsTUFBTSxPQUFPLENBQ2pDLEdBQUcsSUFBSSxVQUFVLE1BQU0sQ0FBQyxLQUFLLElBQUksTUFBTSxDQUFDLElBQUksc0JBQXNCLEVBQ2xFLE9BQU8sRUFDUCxTQUFTLEVBQ1QsT0FBTyxDQUNSLENBQUM7SUFDRixNQUFNLE1BQU0sR0FBRyxLQUFLLENBQUMsT0FBTyxDQUFDLGFBQWEsQ0FBQyxDQUFDLENBQUMsQ0FBRSxhQUErQixDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUM7SUFDcEYsTUFBTSxZQUFZLEdBQUcsTUFBTSxpQkFBaUIsQ0FDMUMsSUFBSSxFQUNKLE1BQU0sRUFDTixPQUFPLEVBQ1AsU0FBUyxFQUNULE9BQU8sQ0FDUixDQUFDO0lBRUYsTUFBTSxpQkFBaUIsR0FBRyxjQUFjLENBQ3RDLE1BQU0sRUFDTixpQkFBaUIsRUFDakIsVUFBVSxDQUNYLENBQUM7SUFDRixNQUFNLGVBQWUsR0FBRyxjQUFjLENBQUMsTUFBTSxFQUFFLGVBQWUsRUFBRSxVQUFVLENBQUMsQ0FBQztJQUM1RSxNQUFNLE1BQU0sR0FBRyxhQUFhLENBQUMsWUFBWSxDQUFDLENBQUM7SUFFM0MsT0FBTztRQUNMLFlBQVksRUFBRSxHQUFHLE1BQU0sQ0FBQyxLQUFLLElBQUksTUFBTSxDQUFDLElBQUksRUFBRTtRQUM5QyxhQUFhLEVBQUUsbUNBQW1DO1FBQ2xELFdBQVc7UUFDWCxpQkFBaUI7UUFDakIsZUFBZTtRQUNmLE1BQU07UUFDTixZQUFZLEVBQUUsaUJBQWlCLENBQUM7WUFDOUIsaUJBQWlCLENBQUMsVUFBVTtZQUM1QixlQUFlLENBQUMsVUFBVTtZQUMxQixNQUFNLENBQUMsVUFBVTtTQUNsQixDQUFDO0tBQ0gsQ0FBQztBQUNKLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/contribution-profile-extract.ts b/packages/loopover-miner/lib/contribution-profile-extract.ts new file mode 100644 index 0000000000..af928c868a --- /dev/null +++ b/packages/loopover-miner/lib/contribution-profile-extract.ts @@ -0,0 +1,295 @@ +// ContributionProfile extraction (#6796). Reads a repo's real, published signals — label taxonomy + contribution +// docs — and produces a populated ContributionProfile per the #6795 schema. GENERIC by design: it recognizes +// conventional OSS eligibility/exclusion vocabulary and matches over label name AND description, with NO +// loopover-specific keyword hardcoding (the #6794 inventory found loopover's own `gittensor:*` labels are the +// exception, not the shape to generalize from). Never throws: any fetch/parse failure degrades a signal to +// `absent`/`unknown` rather than erroring, so an unreachable or docs-less repo yields a low-confidence profile. +import type { + ContributionLabelMatcher, + ContributionProfile, + ContributionPrBodyRequirements, + ContributionSignalConfidence, + ContributionSignalProvenance, + ContributionSignalRule, +} from "./contribution-profile.js"; +import { + CONTRIBUTION_PROFILE_SCHEMA_VERSION, + emptyContributionProfile, + weakestConfidence, +} from "./contribution-profile.js"; +import { fetchWithRetry, type FetchWithRetryOptions } from "./http-retry.js"; + +const DEFAULT_API_BASE_URL = "https://api.github.com"; +const GITHUB_API_VERSION = "2022-11-28"; +const REQUEST_TIMEOUT_MS = 10_000; +/** A CONTRIBUTING.md smaller than this is treated as a signpost (a link to an external guide), not the rules + * themselves — #6794 found react's is 208 B and laravel' 525 B, both just pointers. */ +const CONTRIBUTING_SIGNPOST_MAX_BYTES = 600; + +/** Canonical eligibility vocabulary — recognized OSS "contributor-workable" conventions. Matched case-insensitively + * as a substring over a label's name AND description. Not loopover-specific. */ +const ELIGIBILITY_TERMS = Object.freeze([ + "good first issue", + "good-first-issue", + "help wanted", + "help-wanted", + "up for grabs", + "beginner", + "easy", + "starter", +]); + +/** Conventional exclusion/off-limits vocabulary. These are UNstated conventions (#6794 found no repo names + * exclusion in a label NAME explicitly), so a match yields `inferred`, never `explicit`. */ +const EXCLUSION_TERMS = Object.freeze([ + "blocked", + "on hold", + "on-hold", + "do not merge", + "wontfix", + "invalid", + "needs triage", + "work in progress", + "wip", + "maintainer only", + "internal", +]); + +/** Closing-keyword / linked-issue language in a CONTRIBUTING.md. */ +const LINKED_ISSUE_TERMS = Object.freeze([ + "closes #", + "fixes #", + "resolves #", + "linked issue", + "reference an issue", + "link to an issue", +]); + +type GithubLabel = { name?: unknown; description?: unknown }; + +type ExtractContributionProfileOptions = { + fetchImpl?: typeof fetch; + githubToken?: string; + apiBaseUrl?: string; + /** ISO timestamp for the profile's generatedAt; defaults to now. Injected so tests stay deterministic. */ + generatedAt?: string; + /** Sleep seam for the transient-5xx/rate-limit retry (via fetchWithRetry). Injected so tests use no real timers. */ + sleepFn?: (ms: number) => Promise; +}; + +function parseRepoFullName(repoFullName: unknown): { owner: string; repo: string } | null { + if (typeof repoFullName !== "string") return null; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner?.trim() || !repo?.trim() || extra !== undefined) return null; + return { owner: owner.trim(), repo: repo.trim() }; +} + +function githubHeaders(githubToken: string | undefined): Record { + const headers: Record = { + accept: "application/vnd.github+json", + "user-agent": "loopover-miner", + "x-github-api-version": GITHUB_API_VERSION, + }; + if (githubToken) headers.authorization = `Bearer ${githubToken}`; + return headers; +} + +/** Bounded, never-throwing JSON GET. Rides out a transient GitHub 5xx or rate-limit response (429 / secondary-403) + * via `fetchWithRetry` — the same discipline opportunity-fanout.js's sibling `githubGetJson` already uses — before + * falling back to its fail-open contract: returns null on a non-retryable/exhausted HTTP, transport, or parse + * failure. `timeoutMs` gives each attempt its own fresh `AbortSignal.timeout` (preserving the per-request bound), + * and `sleepFn` is the injectable no-real-timers seam every other `fetchWithRetry` call site exposes. */ +async function getJson( + url: string, + headers: Record, + fetchImpl: typeof fetch, + sleepFn: ((ms: number) => Promise) | undefined, +): Promise { + let response: Response; + try { + // Cast: the JS always passes `sleepFn` (possibly undefined); EOPT rejects an explicit undefined optional. + response = await fetchWithRetry( + fetchImpl as (url: unknown, init?: unknown) => Promise, + url, + { method: "GET", headers }, + { sleepFn, timeoutMs: REQUEST_TIMEOUT_MS } as FetchWithRetryOptions, + ); + } catch { + return null; + } + if (!response.ok) return null; + return response.json().catch(() => null); +} + +/** + * Match one label against a term list, preferring the NAME but falling back to the DESCRIPTION (the rust + * `E-easy` finding: a label can carry its eligibility meaning only in the description). Returns the matcher + + * a provenance detail, or null when neither field matches. + */ +function matchLabel( + label: GithubLabel, + terms: readonly string[], +): { matcher: ContributionLabelMatcher; detail: string } | null { + const rawName = typeof label?.name === "string" ? label.name : ""; + const name = rawName.toLowerCase(); + const description = + typeof label?.description === "string" + ? label.description.toLowerCase() + : ""; + const detail = rawName || "(unnamed label)"; + const nameTerm = terms.find((term) => name.includes(term)); + if (nameTerm !== undefined) + return { matcher: { field: "name", contains: nameTerm }, detail }; + const descriptionTerm = terms.find((term) => description.includes(term)); + if (descriptionTerm !== undefined) + return { + matcher: { field: "description", contains: descriptionTerm }, + detail, + }; + return null; +} + +/** Classify labels into a SignalRule of the given confidence. Recognized labels build an OR-list of matchers; + * no match ⇒ `absent`. Eligibility passes `explicit` (a recognized convention IS an explicit statement); + * exclusion passes `inferred` (conventional but unstated). */ +function classifyLabels( + labels: GithubLabel[], + terms: readonly string[], + matchedConfidence: ContributionSignalConfidence, +): ContributionSignalRule { + const matchers: ContributionLabelMatcher[] = []; + const provenance: ContributionSignalProvenance[] = []; + for (const label of labels) { + const hit = matchLabel(label, terms); + if (hit === null) continue; + matchers.push(hit.matcher); + provenance.push({ source: "labels", detail: hit.detail }); + } + if (matchers.length === 0) + return { value: null, confidence: "absent", provenance: [] }; + return { value: matchers, confidence: matchedConfidence, provenance }; +} + +/** Decode a GitHub contents API response body to text. Returns null when absent or not base64. Buffer.from over + * a string never throws, so no error path is needed here. */ +function decodeContents(payload: unknown): string | null { + if ( + !payload || + typeof payload !== "object" || + typeof (payload as { content?: unknown }).content !== "string" || + (payload as { encoding?: unknown }).encoding !== "base64" + ) + return null; + return Buffer.from((payload as { content: string }).content, "base64").toString("utf8"); +} + +/** Fetch CONTRIBUTING.md, probing the repo root then `.github/` (#6794: 6/10 at root, 2/10 under `.github/`). */ +async function fetchContributing( + base: string, + target: { owner: string; repo: string }, + headers: Record, + fetchImpl: typeof fetch, + sleepFn: ((ms: number) => Promise) | undefined, +): Promise { + for (const path of ["CONTRIBUTING.md", ".github/CONTRIBUTING.md"]) { + const payload = await getJson( + `${base}/repos/${target.owner}/${target.repo}/contents/${path}`, + headers, + fetchImpl, + sleepFn, + ); + const text = decodeContents(payload); + if (text !== null) return text; + } + return null; +} + +/** Extract the PR-body linked-issue requirement from CONTRIBUTING.md. A very small file is a signpost, not the + * rules, so it yields `absent` rather than a false negative dressed as a real one. */ +function extractPrBody( + contributing: string | null, +): ContributionSignalRule { + if (contributing === null) + return { value: null, confidence: "absent", provenance: [] }; + if (contributing.length < CONTRIBUTING_SIGNPOST_MAX_BYTES) + return { value: null, confidence: "unknown", provenance: [] }; + const lower = contributing.toLowerCase(); + const requiresLinkedIssue = LINKED_ISSUE_TERMS.some((term) => + lower.includes(term), + ); + // A real, sufficiently-sized CONTRIBUTING.md is an explicit source either way: present-with-keyword is an + // explicit requirement, present-without is an explicit "no such rule". + return { + value: { requiresLinkedIssue }, + confidence: "explicit", + provenance: [{ source: "contributing_md", detail: "CONTRIBUTING.md" }], + }; +} + +/** + * Extract a best-effort ContributionProfile for a repo from what it actually publishes. + */ +export async function extractContributionProfile( + repoFullName: string, + options: ExtractContributionProfileOptions = {}, +): Promise { + const generatedAt = + typeof options.generatedAt === "string" + ? options.generatedAt + : new Date().toISOString(); + const target = parseRepoFullName(repoFullName); + // A malformed name can't be fetched — return the safe, fully-absent default rather than throwing. + if (target === null) + return emptyContributionProfile( + typeof repoFullName === "string" ? repoFullName : "", + generatedAt, + ); + + /* v8 ignore next -- the global-fetch default is the production path; every test injects fetchImpl. */ + const fetchImpl = options.fetchImpl ?? fetch; + const base = + typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() + ? options.apiBaseUrl.replace(/\/+$/, "") + : DEFAULT_API_BASE_URL; + const headers = githubHeaders( + options.githubToken ?? process.env.GITHUB_TOKEN, + ); + + const sleepFn = options.sleepFn; + const labelsPayload = await getJson( + `${base}/repos/${target.owner}/${target.repo}/labels?per_page=100`, + headers, + fetchImpl, + sleepFn, + ); + const labels = Array.isArray(labelsPayload) ? (labelsPayload as GithubLabel[]) : []; + const contributing = await fetchContributing( + base, + target, + headers, + fetchImpl, + sleepFn, + ); + + const eligibilityLabels = classifyLabels( + labels, + ELIGIBILITY_TERMS, + "explicit", + ); + const exclusionLabels = classifyLabels(labels, EXCLUSION_TERMS, "inferred"); + const prBody = extractPrBody(contributing); + + return { + repoFullName: `${target.owner}/${target.repo}`, + schemaVersion: CONTRIBUTION_PROFILE_SCHEMA_VERSION, + generatedAt, + eligibilityLabels, + exclusionLabels, + prBody, + completeness: weakestConfidence([ + eligibilityLabels.confidence, + exclusionLabels.confidence, + prBody.confidence, + ]), + }; +} diff --git a/packages/loopover-miner/lib/contribution-profile-filter.d.ts b/packages/loopover-miner/lib/contribution-profile-filter.d.ts index 099d1489c2..98cd33e560 100644 --- a/packages/loopover-miner/lib/contribution-profile-filter.d.ts +++ b/packages/loopover-miner/lib/contribution-profile-filter.d.ts @@ -1,24 +1,30 @@ import type { ContributionProfile } from "./contribution-profile.js"; - -export const ELIGIBILITY_EXCLUSION_REASONS: { - readonly EXCLUSION_LABEL: "exclusion_label"; - readonly MISSING_ELIGIBILITY_LABEL: "missing_eligibility_label"; - readonly CONFLICTING_SIGNALS: "conflicting_signals"; - readonly EXCLUDED_ASSIGNEE: "excluded_assignee"; -}; - +/** Why a candidate was excluded. */ +export declare const ELIGIBILITY_EXCLUSION_REASONS: Readonly<{ + /** The issue carries a label the profile identified as maintainer-only / off-limits. */ + readonly EXCLUSION_LABEL: "exclusion_label"; + /** The repo has a trustworthy eligibility convention, and the issue carries none of its eligibility labels. */ + readonly MISSING_ELIGIBILITY_LABEL: "missing_eligibility_label"; + /** The issue carries BOTH an eligibility and an exclusion label — conflicting signals; exclusion wins. */ + readonly CONFLICTING_SIGNALS: "conflicting_signals"; + /** The issue is assigned to the repo's own owner login (#7040) — structural, not profile-derived. */ + readonly EXCLUDED_ASSIGNEE: "excluded_assignee"; +}>; export type EligibilityExclusion = { - candidate: T; - reason: - | "exclusion_label" - | "missing_eligibility_label" - | "conflicting_signals" - | "excluded_assignee"; + candidate: T; + reason: "exclusion_label" | "missing_eligibility_label" | "conflicting_signals" | "excluded_assignee"; +}; +type FilterCandidate = { + repoFullName: string; + owner?: string; + labels?: string[]; + assignees?: string[]; +}; +/** + * Partition candidates into kept + excluded against per-repo ContributionProfiles. + */ +export declare function filterCandidatesByProfiles(candidates: T[], profilesByRepo: Map): { + kept: T[]; + excluded: EligibilityExclusion[]; }; - -export function filterCandidatesByProfiles< - T extends { repoFullName: string; owner?: string; labels?: string[]; assignees?: string[] }, ->( - candidates: T[], - profilesByRepo: Map, -): { kept: T[]; excluded: EligibilityExclusion[] }; +export {}; diff --git a/packages/loopover-miner/lib/contribution-profile-filter.js b/packages/loopover-miner/lib/contribution-profile-filter.js index 463471a140..ba2828672f 100644 --- a/packages/loopover-miner/lib/contribution-profile-filter.js +++ b/packages/loopover-miner/lib/contribution-profile-filter.js @@ -12,105 +12,101 @@ // contribution-profile.d.ts), it is deliberately NOT a profile field — it's a structural fact derivable from the // issue's own assignees at query time, not something extraction infers with variable confidence. It therefore // applies to EVERY candidate unconditionally, independent of the repo's ContributionProfile (or lack of one). - /** Why a candidate was excluded. */ export const ELIGIBILITY_EXCLUSION_REASONS = Object.freeze({ - /** The issue carries a label the profile identified as maintainer-only / off-limits. */ - EXCLUSION_LABEL: "exclusion_label", - /** The repo has a trustworthy eligibility convention, and the issue carries none of its eligibility labels. */ - MISSING_ELIGIBILITY_LABEL: "missing_eligibility_label", - /** The issue carries BOTH an eligibility and an exclusion label — conflicting signals; exclusion wins. */ - CONFLICTING_SIGNALS: "conflicting_signals", - /** The issue is assigned to the repo's own owner login (#7040) — structural, not profile-derived. */ - EXCLUDED_ASSIGNEE: "excluded_assignee", + /** The issue carries a label the profile identified as maintainer-only / off-limits. */ + EXCLUSION_LABEL: "exclusion_label", + /** The repo has a trustworthy eligibility convention, and the issue carries none of its eligibility labels. */ + MISSING_ELIGIBILITY_LABEL: "missing_eligibility_label", + /** The issue carries BOTH an eligibility and an exclusion label — conflicting signals; exclusion wins. */ + CONFLICTING_SIGNALS: "conflicting_signals", + /** The issue is assigned to the repo's own owner login (#7040) — structural, not profile-derived. */ + EXCLUDED_ASSIGNEE: "excluded_assignee", }); - /** True when the candidate is assigned to its own repo's owner login (case-insensitive). Always-on: unlike the * label rules below, this never depends on the profile's confidence — see the header comment. */ function isAssignedToRepoOwner(candidate) { - const owner = typeof candidate?.owner === "string" ? candidate.owner.toLowerCase() : ""; - if (!owner) return false; - for (const login of candidate?.assignees ?? []) { - if (typeof login === "string" && login.toLowerCase() === owner) return true; - } - return false; + const owner = typeof candidate?.owner === "string" ? candidate.owner.toLowerCase() : ""; + if (!owner) + return false; + for (const login of candidate?.assignees ?? []) { + if (typeof login === "string" && login.toLowerCase() === owner) + return true; + } + return false; } - /** The actual repo label names a signal rule was derived from (its provenance details), lowercased for match. */ function labelNamesFromRule(rule) { - const names = new Set(); - for (const entry of rule?.provenance ?? []) { - if (typeof entry?.detail === "string") - names.add(entry.detail.toLowerCase()); - } - return names; + const names = new Set(); + for (const entry of rule?.provenance ?? []) { + if (typeof entry?.detail === "string") + names.add(entry.detail.toLowerCase()); + } + return names; } - /** Does the candidate carry any label whose name is in `names`? Case-insensitive. */ function candidateHasAnyLabel(candidate, names) { - if (names.size === 0) return false; - for (const label of candidate?.labels ?? []) { - if (typeof label === "string" && names.has(label.toLowerCase())) - return true; - } - return false; + if (names.size === 0) + return false; + for (const label of candidate?.labels ?? []) { + if (typeof label === "string" && names.has(label.toLowerCase())) + return true; + } + return false; } - /** * Partition candidates into kept + excluded against per-repo ContributionProfiles. - * - * @param {Array<{ repoFullName: string, owner?: string, labels?: string[], assignees?: string[] }>} candidates the fanned-out discover candidates - * @param {Map} profilesByRepo profile per repoFullName - * @returns {{ kept: object[], excluded: Array<{ candidate: object, reason: string }> }} */ export function filterCandidatesByProfiles(candidates, profilesByRepo) { - const kept = []; - const excluded = []; - for (const candidate of candidates) { - // Always-on, ahead of the label rules' safe-default gate (#7040) — see the header comment. - if (isAssignedToRepoOwner(candidate)) { - excluded.push({ - candidate, - reason: ELIGIBILITY_EXCLUSION_REASONS.EXCLUDED_ASSIGNEE, - }); - continue; - } - const profile = profilesByRepo?.get(candidate.repoFullName); - // Trust gate: only an EXPLICIT eligibility signal is trustworthy enough to filter on. Anything weaker - // (absent/inferred/unknown, or no profile at all) keeps every candidate — the safe default. - if (profile?.eligibilityLabels?.confidence !== "explicit") { - kept.push(candidate); - continue; - } - const eligibilityNames = labelNamesFromRule(profile.eligibilityLabels); - const exclusionNames = labelNamesFromRule(profile.exclusionLabels); - const hasEligibility = candidateHasAnyLabel(candidate, eligibilityNames); - const hasExclusion = candidateHasAnyLabel(candidate, exclusionNames); - if (hasExclusion && hasEligibility) { - // Conservative resolution for conflicting signals: exclusion wins. A maintainer marking an issue - // off-limits outranks its also carrying an eligibility label — better to skip than to attempt work the - // repo's own gate would reject. - excluded.push({ - candidate, - reason: ELIGIBILITY_EXCLUSION_REASONS.CONFLICTING_SIGNALS, - }); - continue; - } - if (hasExclusion) { - excluded.push({ - candidate, - reason: ELIGIBILITY_EXCLUSION_REASONS.EXCLUSION_LABEL, - }); - continue; - } - if (!hasEligibility) { - excluded.push({ - candidate, - reason: ELIGIBILITY_EXCLUSION_REASONS.MISSING_ELIGIBILITY_LABEL, - }); - continue; + const kept = []; + const excluded = []; + for (const candidate of candidates) { + // Always-on, ahead of the label rules' safe-default gate (#7040) — see the header comment. + if (isAssignedToRepoOwner(candidate)) { + excluded.push({ + candidate, + reason: ELIGIBILITY_EXCLUSION_REASONS.EXCLUDED_ASSIGNEE, + }); + continue; + } + // Optional chaining preserved from the JS (tests may inject a nullish map via cast). + const profile = profilesByRepo?.get(candidate.repoFullName); + // Trust gate: only an EXPLICIT eligibility signal is trustworthy enough to filter on. Anything weaker + // (absent/inferred/unknown, or no profile at all) keeps every candidate — the safe default. + if (profile?.eligibilityLabels?.confidence !== "explicit") { + kept.push(candidate); + continue; + } + const eligibilityNames = labelNamesFromRule(profile.eligibilityLabels); + const exclusionNames = labelNamesFromRule(profile.exclusionLabels); + const hasEligibility = candidateHasAnyLabel(candidate, eligibilityNames); + const hasExclusion = candidateHasAnyLabel(candidate, exclusionNames); + if (hasExclusion && hasEligibility) { + // Conservative resolution for conflicting signals: exclusion wins. A maintainer marking an issue + // off-limits outranks its also carrying an eligibility label — better to skip than to attempt work the + // repo's own gate would reject. + excluded.push({ + candidate, + reason: ELIGIBILITY_EXCLUSION_REASONS.CONFLICTING_SIGNALS, + }); + continue; + } + if (hasExclusion) { + excluded.push({ + candidate, + reason: ELIGIBILITY_EXCLUSION_REASONS.EXCLUSION_LABEL, + }); + continue; + } + if (!hasEligibility) { + excluded.push({ + candidate, + reason: ELIGIBILITY_EXCLUSION_REASONS.MISSING_ELIGIBILITY_LABEL, + }); + continue; + } + kept.push(candidate); } - kept.push(candidate); - } - return { kept, excluded }; + return { kept, excluded }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29udHJpYnV0aW9uLXByb2ZpbGUtZmlsdGVyLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiY29udHJpYnV0aW9uLXByb2ZpbGUtZmlsdGVyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLGdIQUFnSDtBQUNoSCw4R0FBOEc7QUFDOUcsNEdBQTRHO0FBQzVHLEVBQUU7QUFDRixnSEFBZ0g7QUFDaEgsaUZBQWlGO0FBQ2pGLGdIQUFnSDtBQUNoSCwrR0FBK0c7QUFDL0csd0RBQXdEO0FBQ3hELEVBQUU7QUFDRiw2RkFBNkY7QUFDN0YsaUhBQWlIO0FBQ2pILDhHQUE4RztBQUM5Ryw4R0FBOEc7QUFJOUcsb0NBQW9DO0FBQ3BDLE1BQU0sQ0FBQyxNQUFNLDZCQUE2QixHQUFHLE1BQU0sQ0FBQyxNQUFNLENBQUM7SUFDekQsd0ZBQXdGO0lBQ3hGLGVBQWUsRUFBRSxpQkFBaUI7SUFDbEMsK0dBQStHO0lBQy9HLHlCQUF5QixFQUFFLDJCQUEyQjtJQUN0RCwwR0FBMEc7SUFDMUcsbUJBQW1CLEVBQUUscUJBQXFCO0lBQzFDLHFHQUFxRztJQUNyRyxpQkFBaUIsRUFBRSxtQkFBbUI7Q0FDOUIsQ0FBQyxDQUFDO0FBa0JaO2tHQUNrRztBQUNsRyxTQUFTLHFCQUFxQixDQUFDLFNBQTBCO0lBQ3ZELE1BQU0sS0FBSyxHQUFHLE9BQU8sU0FBUyxFQUFFLEtBQUssS0FBSyxRQUFRLENBQUMsQ0FBQyxDQUFDLFNBQVMsQ0FBQyxLQUFLLENBQUMsV0FBVyxFQUFFLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQztJQUN4RixJQUFJLENBQUMsS0FBSztRQUFFLE9BQU8sS0FBSyxDQUFDO0lBQ3pCLEtBQUssTUFBTSxLQUFLLElBQUksU0FBUyxFQUFFLFNBQVMsSUFBSSxFQUFFLEVBQUUsQ0FBQztRQUMvQyxJQUFJLE9BQU8sS0FBSyxLQUFLLFFBQVEsSUFBSSxLQUFLLENBQUMsV0FBVyxFQUFFLEtBQUssS0FBSztZQUFFLE9BQU8sSUFBSSxDQUFDO0lBQzlF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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/contribution-profile-filter.ts b/packages/loopover-miner/lib/contribution-profile-filter.ts new file mode 100644 index 0000000000..27aa8ee386 --- /dev/null +++ b/packages/loopover-miner/lib/contribution-profile-filter.ts @@ -0,0 +1,134 @@ +// Eligibility filtering of discover candidates against a ContributionProfile (#6798). Pure: given the candidate +// list and a per-repo profile map, it partitions candidates into kept + excluded-with-reason. No fetching, no +// side effects — discover-cli.js resolves the profiles and renders the result; this owns only the decision. +// +// SAFE-DEFAULT POSTURE (the load-bearing requirement) applies to the three LABEL-based rules only: filtering on +// them activates ONLY when a repo's profile has a trustworthy eligibility signal +// (eligibilityLabels.confidence === "explicit"). A repo with no profile, or a low-confidence/empty one — a repo +// whose conventions AMS simply couldn't read — has every candidate kept via those rules, so a weak profile can +// never cause AMS to silently skip real, eligible work. +// +// ASSIGNEE-EXCLUSION IS DIFFERENT (#7040): per the schema (ContributionAssigneeRuntimeCheck, +// contribution-profile.d.ts), it is deliberately NOT a profile field — it's a structural fact derivable from the +// issue's own assignees at query time, not something extraction infers with variable confidence. It therefore +// applies to EVERY candidate unconditionally, independent of the repo's ContributionProfile (or lack of one). + +import type { ContributionProfile, ContributionSignalRule } from "./contribution-profile.js"; + +/** Why a candidate was excluded. */ +export const ELIGIBILITY_EXCLUSION_REASONS = Object.freeze({ + /** The issue carries a label the profile identified as maintainer-only / off-limits. */ + EXCLUSION_LABEL: "exclusion_label", + /** The repo has a trustworthy eligibility convention, and the issue carries none of its eligibility labels. */ + MISSING_ELIGIBILITY_LABEL: "missing_eligibility_label", + /** The issue carries BOTH an eligibility and an exclusion label — conflicting signals; exclusion wins. */ + CONFLICTING_SIGNALS: "conflicting_signals", + /** The issue is assigned to the repo's own owner login (#7040) — structural, not profile-derived. */ + EXCLUDED_ASSIGNEE: "excluded_assignee", +} as const); + +export type EligibilityExclusion = { + candidate: T; + reason: + | "exclusion_label" + | "missing_eligibility_label" + | "conflicting_signals" + | "excluded_assignee"; +}; + +type FilterCandidate = { + repoFullName: string; + owner?: string; + labels?: string[]; + assignees?: string[]; +}; + +/** True when the candidate is assigned to its own repo's owner login (case-insensitive). Always-on: unlike the + * label rules below, this never depends on the profile's confidence — see the header comment. */ +function isAssignedToRepoOwner(candidate: FilterCandidate): boolean { + const owner = typeof candidate?.owner === "string" ? candidate.owner.toLowerCase() : ""; + if (!owner) return false; + for (const login of candidate?.assignees ?? []) { + if (typeof login === "string" && login.toLowerCase() === owner) return true; + } + return false; +} + +/** The actual repo label names a signal rule was derived from (its provenance details), lowercased for match. */ +function labelNamesFromRule(rule: ContributionSignalRule | null | undefined): Set { + const names = new Set(); + for (const entry of rule?.provenance ?? []) { + if (typeof entry?.detail === "string") + names.add(entry.detail.toLowerCase()); + } + return names; +} + +/** Does the candidate carry any label whose name is in `names`? Case-insensitive. */ +function candidateHasAnyLabel(candidate: FilterCandidate, names: Set): boolean { + if (names.size === 0) return false; + for (const label of candidate?.labels ?? []) { + if (typeof label === "string" && names.has(label.toLowerCase())) + return true; + } + return false; +} + +/** + * Partition candidates into kept + excluded against per-repo ContributionProfiles. + */ +export function filterCandidatesByProfiles( + candidates: T[], + profilesByRepo: Map, +): { kept: T[]; excluded: EligibilityExclusion[] } { + const kept: T[] = []; + const excluded: EligibilityExclusion[] = []; + for (const candidate of candidates) { + // Always-on, ahead of the label rules' safe-default gate (#7040) — see the header comment. + if (isAssignedToRepoOwner(candidate)) { + excluded.push({ + candidate, + reason: ELIGIBILITY_EXCLUSION_REASONS.EXCLUDED_ASSIGNEE, + }); + continue; + } + // Optional chaining preserved from the JS (tests may inject a nullish map via cast). + const profile = (profilesByRepo as Map | null | undefined)?.get(candidate.repoFullName); + // Trust gate: only an EXPLICIT eligibility signal is trustworthy enough to filter on. Anything weaker + // (absent/inferred/unknown, or no profile at all) keeps every candidate — the safe default. + if (profile?.eligibilityLabels?.confidence !== "explicit") { + kept.push(candidate); + continue; + } + const eligibilityNames = labelNamesFromRule(profile.eligibilityLabels); + const exclusionNames = labelNamesFromRule(profile.exclusionLabels); + const hasEligibility = candidateHasAnyLabel(candidate, eligibilityNames); + const hasExclusion = candidateHasAnyLabel(candidate, exclusionNames); + if (hasExclusion && hasEligibility) { + // Conservative resolution for conflicting signals: exclusion wins. A maintainer marking an issue + // off-limits outranks its also carrying an eligibility label — better to skip than to attempt work the + // repo's own gate would reject. + excluded.push({ + candidate, + reason: ELIGIBILITY_EXCLUSION_REASONS.CONFLICTING_SIGNALS, + }); + continue; + } + if (hasExclusion) { + excluded.push({ + candidate, + reason: ELIGIBILITY_EXCLUSION_REASONS.EXCLUSION_LABEL, + }); + continue; + } + if (!hasEligibility) { + excluded.push({ + candidate, + reason: ELIGIBILITY_EXCLUSION_REASONS.MISSING_ELIGIBILITY_LABEL, + }); + continue; + } + kept.push(candidate); + } + return { kept, excluded }; +} diff --git a/packages/loopover-miner/lib/discover-cli.d.ts b/packages/loopover-miner/lib/discover-cli.d.ts index 155ff2b6a8..e72237e64b 100644 --- a/packages/loopover-miner/lib/discover-cli.d.ts +++ b/packages/loopover-miner/lib/discover-cli.d.ts @@ -1,135 +1,108 @@ import type { ForgeConfig } from "./forge-config.js"; -import type { - CandidateIssueWarning, - FanoutOptions, - FanoutTarget, - RawCandidateIssue, -} from "./opportunity-fanout.js"; -import type { - RankCandidateIssuesOptions, - RankedCandidateIssue, - RankedCandidateSummary, -} from "./opportunity-ranker.js"; +import type { CandidateIssueWarning, FanoutOptions, FanoutTarget, RawCandidateIssue } from "./opportunity-fanout.js"; +import type { RankCandidateIssuesOptions, RankedCandidateIssue, RankedCandidateSummary } from "./opportunity-ranker.js"; import type { PolicyDocCacheStore } from "./policy-doc-cache.js"; import type { PolicyVerdictCacheStore } from "./policy-verdict-cache.js"; import type { EnqueueRankedDiscoverySummary } from "./portfolio-discovery.js"; import type { PortfolioQueueStore } from "./portfolio-queue.js"; import type { RankedCandidatesStore } from "./ranked-candidates.js"; -import type { queryDiscoveryIndex } from "./discovery-index-client.js"; - -export type ParsedDiscoverArgs = - | { - targets: FanoutTarget[]; - search: string | null; - dryRun: boolean; - json: boolean; - /** Present only when `--api-base-url` is supplied (#4784); threads the tenant's forge host to the fan-out. */ - apiBaseUrl?: string; - /** Present only when `--token-env` is supplied (#4784); names the credential env var to read. */ - tokenEnv?: string; - } - | { error: string }; - +import type { queryDiscoveryIndex as QueryDiscoveryIndexFn } from "./discovery-index-client.js"; +export type ParsedDiscoverArgs = { + targets: FanoutTarget[]; + search: string | null; + dryRun: boolean; + json: boolean; + /** Present only when `--api-base-url` is supplied (#4784); threads the tenant's forge host to the fan-out. */ + apiBaseUrl?: string; + /** Present only when `--token-env` is supplied (#4784); names the credential env var to read. */ + tokenEnv?: string; +} | { + error: string; +}; /** The subset of `CandidateIssueSummary` runDiscover actually reads. It surfaces the rate-limit telemetry (#4837), * so a fake must supply it. A real `fetchCandidateIssuesWithSummary` result satisfies this, since it is a superset. */ export type DiscoverFanOutSummary = { - issues: RawCandidateIssue[]; - warnings: CandidateIssueWarning[]; - rateLimitRemaining: number | null; - rateLimitResetAt: string | null; + issues: RawCandidateIssue[]; + warnings: CandidateIssueWarning[]; + rateLimitRemaining: number | null; + rateLimitResetAt: string | null; }; - /** The subset of a ranked entry that `renderDiscoverSummary` reads for its top-candidates listing. */ -export type DiscoverRankedEntry = Pick< - RankedCandidateIssue, - "repoFullName" | "issueNumber" | "title" | "rankScore" ->; - +export type DiscoverRankedEntry = Pick; export type DiscoverResult = { - fanOutCount: number; - warnings: CandidateIssueWarning[]; - rateLimitRemaining: number | null; - rateLimitResetAt: string | null; - ranked: DiscoverRankedEntry[]; - /** Candidates the eligibility filter dropped, each with the repo/issue and the reason (#6798). */ - excluded?: Array<{ - repoFullName: string; - issueNumber: number; - reason: string; - }>; - /** True when ranking fell back to the built-in default goal spec because no per-tenant spec was supplied (#4784). */ - usedDefaultGoalSpec?: boolean; - enqueueSummary: EnqueueRankedDiscoverySummary; + fanOutCount: number; + warnings: CandidateIssueWarning[]; + rateLimitRemaining: number | null; + rateLimitResetAt: string | null; + ranked: DiscoverRankedEntry[]; + /** Candidates the eligibility filter dropped, each with the repo/issue and the reason (#6798). */ + excluded?: Array<{ + repoFullName: string; + issueNumber: number; + reason: string; + }>; + /** True when ranking fell back to the built-in default goal spec because no per-tenant spec was supplied (#4784). */ + usedDefaultGoalSpec?: boolean; + enqueueSummary: EnqueueRankedDiscoverySummary; }; - export type RunDiscoverOptions = { - /** Read for the discovery-index opt-in gate (#7168) -- defaults to `process.env`. */ - env?: Record; - githubToken?: string; - apiBaseUrl?: string; - /** Per-tenant credential env var name (#4784); defaults to GITHUB_TOKEN. Overridden by a `--token-env` flag. */ - tokenEnv?: string; - /** Per-tenant forge knobs beyond the host (#4784), forwarded to the fan-out. */ - forge?: Partial; - nowMs?: number; - /** Per-tenant goal specs threaded to the ranker so lane fit uses the tenant's conventions, not the defaults (#4784). */ - goalSpecsByRepo?: RankCandidateIssuesOptions["goalSpecsByRepo"]; - goalSpecContentByRepo?: RankCandidateIssuesOptions["goalSpecContentByRepo"]; - initPortfolioQueue?: () => PortfolioQueueStore; - initPolicyDocCache?: () => PolicyDocCacheStore; - initPolicyVerdictCache?: () => PolicyVerdictCacheStore; - initRankedCandidatesStore?: () => RankedCandidatesStore; - fetchCandidateIssuesWithSummary?: ( - targets: FanoutTarget[], - githubToken: string, - options?: FanoutOptions, - ) => Promise; - searchCandidateIssuesWithSummary?: ( - searchQuery: string, - githubToken: string, - options?: FanoutOptions, - ) => Promise; - rankCandidateIssuesWithSummary?: ( - candidates: RawCandidateIssue[], - options?: RankCandidateIssuesOptions, - ) => RankedCandidateSummary; - enqueueRankedDiscovery?: ( - rankedIssues: RankedCandidateIssue[], - options: { queueStore: PortfolioQueueStore }, - ) => EnqueueRankedDiscoverySummary; - /** Supplements the local fan-out with hosted discovery-index results for the same scope, when the plane is - * enabled (#7168). Defaults to discovery-index-client.js's own queryDiscoveryIndex. */ - queryDiscoveryIndex?: typeof queryDiscoveryIndex; - /** Invoked with the real structured result at each success return point (dry-run and full-run), in addition - * to (never instead of) the plain exit-code return -- mirrors `RunAttemptOptions.onResult`. Never fires on a - * parse-error/unexpected-error `reportCliFailure` branch, matching runAttempt's own asymmetry (#6522). */ - onResult?: (result: DiscoverResult) => void; - /** Resolve each candidate repo's ContributionProfile for eligibility filtering (#6798). Defaults to - * resolveContributionProfilesForDiscover; injectable so tests avoid the network. */ - resolveContributionProfiles?: ( - repoFullNames: string[], - ctx: { githubToken?: string; apiBaseUrl?: string; nowMs?: number }, - ) => Promise>; + /** Read for the discovery-index opt-in gate (#7168) -- defaults to `process.env`. */ + env?: Record; + githubToken?: string; + apiBaseUrl?: string; + /** Per-tenant credential env var name (#4784); defaults to GITHUB_TOKEN. Overridden by a `--token-env` flag. */ + tokenEnv?: string; + /** Per-tenant forge knobs beyond the host (#4784), forwarded to the fan-out. */ + forge?: Partial; + nowMs?: number; + /** Per-tenant goal specs threaded to the ranker so lane fit uses the tenant's conventions, not the defaults (#4784). */ + goalSpecsByRepo?: RankCandidateIssuesOptions["goalSpecsByRepo"]; + goalSpecContentByRepo?: RankCandidateIssuesOptions["goalSpecContentByRepo"]; + initPortfolioQueue?: () => PortfolioQueueStore; + initPolicyDocCache?: () => PolicyDocCacheStore; + initPolicyVerdictCache?: () => PolicyVerdictCacheStore; + initRankedCandidatesStore?: () => RankedCandidatesStore; + fetchCandidateIssuesWithSummary?: (targets: FanoutTarget[], githubToken: string, options?: FanoutOptions) => Promise; + searchCandidateIssuesWithSummary?: (searchQuery: string, githubToken: string, options?: FanoutOptions) => Promise; + rankCandidateIssuesWithSummary?: (candidates: RawCandidateIssue[], options?: RankCandidateIssuesOptions) => RankedCandidateSummary; + enqueueRankedDiscovery?: (rankedIssues: RankedCandidateIssue[], options: { + queueStore: PortfolioQueueStore; + }) => EnqueueRankedDiscoverySummary; + /** Supplements the local fan-out with hosted discovery-index results for the same scope, when the plane is + * enabled (#7168). Defaults to discovery-index-client.js's own queryDiscoveryIndex. */ + queryDiscoveryIndex?: typeof QueryDiscoveryIndexFn; + /** Invoked with the real structured result at each success return point (dry-run and full-run), in addition + * to (never instead of) the plain exit-code return -- mirrors `RunAttemptOptions.onResult`. Never fires on a + * parse-error/unexpected-error `reportCliFailure` branch, matching runAttempt's own asymmetry (#6522). */ + onResult?: (result: DiscoverResult) => void; + /** Resolve each candidate repo's ContributionProfile for eligibility filtering (#6798). Defaults to + * resolveContributionProfilesForDiscover; injectable so tests avoid the network. */ + resolveContributionProfiles?: (repoFullNames: string[], ctx: { + githubToken?: string; + apiBaseUrl?: string; + nowMs?: number; + }) => Promise>; }; - -export function resolveContributionProfilesForDiscover( - repoFullNames: string[], - ctx?: { +export declare function sanitizeDiscoverDisplayText(value: unknown): string; +export declare function parseDiscoverArgs(args: string[]): ParsedDiscoverArgs; +export declare function renderDiscoverSummary(result: DiscoverResult): string; +/** + * Default per-repo ContributionProfile resolver (#6798): reads the local cache and, on a miss/stale entry, + * extracts a fresh profile and caches it. Returns a Map keyed by repoFullName. + * + * WITHOUT a github token this returns an empty map and does no network work at all — AMS can't reliably read a + * repo's label taxonomy/docs unauthenticated (rate limits), so it safe-defaults to no eligibility filtering. + * That also keeps callers that don't supply a token (the common CLI path, and every test) hermetic. + * + * @param {string[]} repoFullNames unique repos among the fanned-out candidates + * @param {{ githubToken?: string, apiBaseUrl?: string, nowMs?: number, initCache?: typeof initContributionProfileCache, extract?: typeof extractContributionProfile }} ctx + * @returns {Promise>} + */ +export declare function resolveContributionProfilesForDiscover(repoFullNames: string[], ctx?: { githubToken?: string; apiBaseUrl?: string; nowMs?: number; initCache?: unknown; extract?: unknown; - }, -): Promise>; - -export function parseDiscoverArgs(args: string[]): ParsedDiscoverArgs; - -export function sanitizeDiscoverDisplayText(value: unknown): string; - -export function renderDiscoverSummary(result: DiscoverResult): string; - -export function runDiscover( - args: string[], - options?: RunDiscoverOptions, -): Promise; +}): Promise>; +export declare function runDiscover(args: string[], options?: RunDiscoverOptions): Promise; diff --git a/packages/loopover-miner/lib/discover-cli.js b/packages/loopover-miner/lib/discover-cli.js index d24881d42f..e162957b25 100644 --- a/packages/loopover-miner/lib/discover-cli.js +++ b/packages/loopover-miner/lib/discover-cli.js @@ -1,10 +1,7 @@ /** `discover` CLI command (#4247): wires the existing fanout -> rank -> enqueue pipeline together so a miner * can actually run it. Every piece already exists and is independently tested; this module only composes them. */ import { resolveForgeConfig } from "./forge-config.js"; -import { - fetchCandidateIssuesWithSummary, - searchCandidateIssuesWithSummary, -} from "./opportunity-fanout.js"; +import { fetchCandidateIssuesWithSummary, searchCandidateIssuesWithSummary, } from "./opportunity-fanout.js"; import { rankCandidateIssuesWithSummary } from "./opportunity-ranker.js"; import { initPolicyDocCacheStore } from "./policy-doc-cache.js"; import { initPolicyVerdictCacheStore } from "./policy-verdict-cache.js"; @@ -16,31 +13,25 @@ import { initContributionProfileCache } from "./contribution-profile-cache.js"; import { filterCandidatesByProfiles } from "./contribution-profile-filter.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; import { isDiscoveryPlaneEnabled, queryDiscoveryIndex, recordDiscoveryTelemetry } from "./discovery-index-client.js"; - -const DISCOVER_USAGE = - "Usage: loopover-miner discover [...] | --search [--dry-run] [--json] [--api-base-url ] [--token-env ]"; - +const DISCOVER_USAGE = "Usage: loopover-miner discover [...] | --search [--dry-run] [--json] [--api-base-url ] [--token-env ]"; const MAX_DISCOVER_TITLE_DISPLAY_LENGTH = 240; const OSC_SEQUENCE_PATTERN = /\u001b\][\s\S]*?(?:\u0007|\u001b\\)/g; const ANSI_ESCAPE_PATTERN = /\u001b(?:\[[0-?]*[ -/]*[@-~]|[@-_])/g; const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f]/g; const BIDI_CONTROL_PATTERN = /[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; - export function sanitizeDiscoverDisplayText(value) { - return String(value ?? "") - .replace(OSC_SEQUENCE_PATTERN, "") - .replace(ANSI_ESCAPE_PATTERN, "") - .replace(CONTROL_CHARACTER_PATTERN, " ") - .replace(BIDI_CONTROL_PATTERN, "") - .replace(/\s+/g, " ") - .trim() - .slice(0, MAX_DISCOVER_TITLE_DISPLAY_LENGTH); + return String(value ?? "") + .replace(OSC_SEQUENCE_PATTERN, "") + .replace(ANSI_ESCAPE_PATTERN, "") + .replace(CONTROL_CHARACTER_PATTERN, " ") + .replace(BIDI_CONTROL_PATTERN, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, MAX_DISCOVER_TITLE_DISPLAY_LENGTH); } - function dedupeKey(repoFullName, issueNumber) { - return `${repoFullName.toLowerCase()}#${issueNumber}`; + return `${repoFullName.toLowerCase()}#${issueNumber}`; } - /** * Supplements `fanOut.issues` with hosted discovery-index results for the same scope (#7168) -- a complete * no-op (returns `fanOut` unchanged) unless the plane is enabled, so a run with the flag unset behaves exactly @@ -51,139 +42,138 @@ function dedupeKey(repoFullName, issueNumber) { * filter.js's assignee-exclusion rule treats that identically to "no assignees on this issue". */ async function supplementWithDiscoveryIndex(fanOut, queryScope, options) { - const env = options.env ?? process.env; - if (!isDiscoveryPlaneEnabled(env)) return fanOut; - const queryIndex = options.queryDiscoveryIndex ?? queryDiscoveryIndex; - const response = await queryIndex(queryScope, { env }); - recordDiscoveryTelemetry("discover_query", response.candidates.length > 0 ? "supplemented" : "empty", { env }); - if (response.candidates.length === 0) return fanOut; - - const seen = new Set(fanOut.issues.map((issue) => dedupeKey(issue.repoFullName, issue.issueNumber))); - const supplemented = response.candidates - .filter((candidate) => !seen.has(dedupeKey(candidate.repoFullName, candidate.issueNumber))) - .map((candidate) => ({ ...candidate, assignees: [] })); - if (supplemented.length === 0) return fanOut; - return { ...fanOut, issues: [...fanOut.issues, ...supplemented] }; + const env = options.env ?? process.env; + if (!isDiscoveryPlaneEnabled(env)) + return fanOut; + const queryIndex = options.queryDiscoveryIndex ?? queryDiscoveryIndex; + const response = await queryIndex(queryScope, { env }); + recordDiscoveryTelemetry("discover_query", response.candidates.length > 0 ? "supplemented" : "empty", { env }); + if (response.candidates.length === 0) + return fanOut; + const seen = new Set(fanOut.issues.map((issue) => dedupeKey(issue.repoFullName, issue.issueNumber))); + const supplemented = response.candidates + .filter((candidate) => !seen.has(dedupeKey(candidate.repoFullName, candidate.issueNumber))) + // DiscoveryIndexCandidate is a near-superset of RawCandidateIssue; assignees is absent from the hosted + // contract (#7168) so we annotate [] — cast preserves pre-existing runtime shape rather than re-mapping. + .map((candidate) => ({ ...candidate, assignees: [], labels: [...candidate.labels] })); + if (supplemented.length === 0) + return fanOut; + return { ...fanOut, issues: [...fanOut.issues, ...supplemented] }; } - function parseRepoTarget(value) { - const trimmed = typeof value === "string" ? value.trim() : ""; - const [owner, repo, extra] = trimmed.split("/"); - if (!owner || !repo || extra !== undefined) return null; - return { owner, repo }; + const trimmed = value.trim(); + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) + return null; + return { owner, repo }; } - export function parseDiscoverArgs(args) { - // `--api-base-url` and `--token-env` (#4784) thread the tenant's forge host and credential env var into the - // fan-out; they are kept off the parsed result unless supplied, so callers that pass neither see the exact - // pre-#4784 `{ targets, search, json }` shape. - const options = { json: false, dryRun: false, search: null, apiBaseUrl: null, tokenEnv: null }; - const targets = []; - - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === "--json") { - options.json = true; - continue; + // `--api-base-url` and `--token-env` (#4784) thread the tenant's forge host and credential env var into the + // fan-out; they are kept off the parsed result unless supplied, so callers that pass neither see the exact + // pre-#4784 `{ targets, search, json }` shape. + const options = { json: false, dryRun: false, search: null, apiBaseUrl: null, tokenEnv: null }; + const targets = []; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + // #4847: fetches + ranks exactly as a real run, but skips opening any local store and makes zero writes. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--search") { + const query = args[index + 1]; + if (!query || query.startsWith("-")) + return { error: DISCOVER_USAGE }; + options.search = query; + index += 1; + continue; + } + if (token === "--api-base-url") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) + return { error: DISCOVER_USAGE }; + options.apiBaseUrl = value; + index += 1; + continue; + } + if (token === "--token-env") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) + return { error: DISCOVER_USAGE }; + options.tokenEnv = value; + index += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + const target = parseRepoTarget(token); + if (!target) + return { error: `Repository must be in owner/repo form: ${token}` }; + targets.push(target); } - // #4847: fetches + ranks exactly as a real run, but skips opening any local store and makes zero writes. - if (token === "--dry-run") { - options.dryRun = true; - continue; + if (options.search === null && targets.length === 0) { + return { error: DISCOVER_USAGE }; } - if (token === "--search") { - const query = args[index + 1]; - if (!query || query.startsWith("-")) return { error: DISCOVER_USAGE }; - options.search = query; - index += 1; - continue; + if (options.search !== null && targets.length > 0) { + return { error: "Pass either repository targets or --search, not both." }; } - if (token === "--api-base-url") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) return { error: DISCOVER_USAGE }; - options.apiBaseUrl = value; - index += 1; - continue; - } - if (token === "--token-env") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) return { error: DISCOVER_USAGE }; - options.tokenEnv = value; - index += 1; - continue; - } - if (token.startsWith("-")) { - return { error: `Unknown option: ${token}` }; - } - const target = parseRepoTarget(token); - if (!target) return { error: `Repository must be in owner/repo form: ${token}` }; - targets.push(target); - } - - if (options.search === null && targets.length === 0) { - return { error: DISCOVER_USAGE }; - } - if (options.search !== null && targets.length > 0) { - return { error: "Pass either repository targets or --search, not both." }; - } - - return { - targets, - search: options.search, - dryRun: options.dryRun, - json: options.json, - ...(options.apiBaseUrl !== null ? { apiBaseUrl: options.apiBaseUrl } : {}), - ...(options.tokenEnv !== null ? { tokenEnv: options.tokenEnv } : {}), - }; + return { + targets, + search: options.search, + dryRun: options.dryRun, + json: options.json, + ...(options.apiBaseUrl !== null ? { apiBaseUrl: options.apiBaseUrl } : {}), + ...(options.tokenEnv !== null ? { tokenEnv: options.tokenEnv } : {}), + }; } - // The rate-limit line surfaces the telemetry the fanout already records (#4837) so an operator sees how close a // `discover` run is to being throttled without running a separate command. `unknown` covers the no-fetch/no-header // case where the fanout captured no remaining count. function renderRateLimitLine(result) { - const remaining = result.rateLimitRemaining === null ? "unknown" : String(result.rateLimitRemaining); - const resetSuffix = result.rateLimitResetAt === null ? "" : ` (resets ${result.rateLimitResetAt})`; - return `rate-limit remaining: ${remaining}${resetSuffix}`; + const remaining = result.rateLimitRemaining === null ? "unknown" : String(result.rateLimitRemaining); + const resetSuffix = result.rateLimitResetAt === null ? "" : ` (resets ${result.rateLimitResetAt})`; + return `rate-limit remaining: ${remaining}${resetSuffix}`; } - export function renderDiscoverSummary(result) { - const lines = [ - `fanned out: ${result.fanOutCount} candidate issue(s)`, - `ai-policy warnings: ${result.warnings.length}`, - `ranked: ${result.ranked.length}`, - `enqueued: ${result.enqueueSummary.enqueued}`, - renderRateLimitLine(result), - ]; - if (result.enqueueSummary.skippedBelowMinRank > 0) { - lines.push(`skipped (below min rank): ${result.enqueueSummary.skippedBelowMinRank}`); - } - // #6798: surface what the eligibility filter dropped and why, so a human sees AMS's inference. - const excluded = result.excluded ?? []; - if (excluded.length > 0) { - lines.push(`excluded (eligibility): ${excluded.length}`); - for (const entry of excluded.slice(0, 10)) { - lines.push(` ${entry.repoFullName}#${entry.issueNumber} ${entry.reason}`); + const lines = [ + `fanned out: ${result.fanOutCount} candidate issue(s)`, + `ai-policy warnings: ${result.warnings.length}`, + `ranked: ${result.ranked.length}`, + `enqueued: ${result.enqueueSummary.enqueued}`, + renderRateLimitLine(result), + ]; + if (result.enqueueSummary.skippedBelowMinRank > 0) { + lines.push(`skipped (below min rank): ${result.enqueueSummary.skippedBelowMinRank}`); + } + // #6798: surface what the eligibility filter dropped and why, so a human sees AMS's inference. + const excluded = result.excluded ?? []; + if (excluded.length > 0) { + lines.push(`excluded (eligibility): ${excluded.length}`); + for (const entry of excluded.slice(0, 10)) { + lines.push(` ${entry.repoFullName}#${entry.issueNumber} ${entry.reason}`); + } + } + // Make the fall-back to loopover's built-in rubric explicit instead of silent (#4784): when no per-tenant goal + // spec is supplied, lane fit reflects loopover's defaults, not the target repo's own conventions. + if (result.usedDefaultGoalSpec) { + lines.push("note: ranked with the built-in default goal spec (no per-tenant .loopover-miner.yml supplied)"); + } + if (result.ranked.length === 0) { + lines.push("", "no candidates found."); + return lines.join("\n"); + } + lines.push("", "top candidates:"); + for (const entry of result.ranked.slice(0, 10)) { + const title = sanitizeDiscoverDisplayText(entry.title); + lines.push(` ${entry.repoFullName}#${entry.issueNumber} score=${entry.rankScore.toFixed(4)} ${title}`); } - } - // Make the fall-back to loopover's built-in rubric explicit instead of silent (#4784): when no per-tenant goal - // spec is supplied, lane fit reflects loopover's defaults, not the target repo's own conventions. - if (result.usedDefaultGoalSpec) { - lines.push( - "note: ranked with the built-in default goal spec (no per-tenant .loopover-miner.yml supplied)", - ); - } - if (result.ranked.length === 0) { - lines.push("", "no candidates found."); return lines.join("\n"); - } - lines.push("", "top candidates:"); - for (const entry of result.ranked.slice(0, 10)) { - const title = sanitizeDiscoverDisplayText(entry.title); - lines.push(` ${entry.repoFullName}#${entry.issueNumber} score=${entry.rankScore.toFixed(4)} ${title}`); - } - return lines.join("\n"); } - /** * Default per-repo ContributionProfile resolver (#6798): reads the local cache and, on a miss/stale entry, * extracts a fresh profile and caches it. Returns a Map keyed by repoFullName. @@ -197,233 +187,269 @@ export function renderDiscoverSummary(result) { * @returns {Promise>} */ export async function resolveContributionProfilesForDiscover(repoFullNames, ctx = {}) { - const profiles = new Map(); - if (!ctx.githubToken) return profiles; - const initCache = ctx.initCache ?? initContributionProfileCache; - const extract = ctx.extract ?? extractContributionProfile; - const cache = initCache(); - try { - for (const repoFullName of repoFullNames) { - const cached = cache.get(repoFullName, ctx.nowMs); - if (cached && !cached.stale) { - profiles.set(repoFullName, cached.profile); - continue; - } - const profile = await extract(repoFullName, { githubToken: ctx.githubToken, apiBaseUrl: ctx.apiBaseUrl }); - cache.put(profile, ctx.nowMs); - profiles.set(repoFullName, profile); + const profiles = new Map(); + if (!ctx.githubToken) + return profiles; + const initCache = ctx.initCache ?? initContributionProfileCache; + const extract = ctx.extract ?? extractContributionProfile; + const cache = initCache(); + try { + for (const repoFullName of repoFullNames) { + const cached = cache.get(repoFullName, ctx.nowMs); + if (cached && !cached.stale) { + profiles.set(repoFullName, cached.profile); + continue; + } + const profile = await extract(repoFullName, { + githubToken: ctx.githubToken, + // exactOptionalPropertyTypes: omit apiBaseUrl when unset (pre-existing optional-prop shape). + ...(ctx.apiBaseUrl !== undefined ? { apiBaseUrl: ctx.apiBaseUrl } : {}), + }); + cache.put(profile, ctx.nowMs); + profiles.set(repoFullName, profile); + } } - } finally { - cache.close(); - } - return profiles; + finally { + cache.close(); + } + return profiles; } - export async function runDiscover(args, options = {}) { - const parsed = parseDiscoverArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - // Credential env var is per-tenant (#4784): a `--token-env FORGE_PAT` flag (or `options.tokenEnv`) reads a - // non-`GITHUB_TOKEN` variable so a non-github.com forge's token is reachable. The default falls through to the - // forge adapter's own `tokenEnvVar` (github.com's `GITHUB_TOKEN`), so there's a single source of truth for the - // default credential env instead of a second hardcoded literal that could drift from `DEFAULT_FORGE_CONFIG`. - const tokenEnv = parsed.tokenEnv ?? options.tokenEnv ?? resolveForgeConfig(options.forge).tokenEnvVar; - const githubToken = options.githubToken ?? process.env[tokenEnv] ?? ""; - // A `--api-base-url` flag (or `options.apiBaseUrl`) surfaces the fan-out's existing forge-host override at the CLI - // (#4784); `options.forge` carries any remaining per-tenant forge knobs for a programmatic caller. - const apiBaseUrl = parsed.apiBaseUrl ?? options.apiBaseUrl; - const fetchTargets = options.fetchCandidateIssuesWithSummary ?? fetchCandidateIssuesWithSummary; - const searchTargets = options.searchCandidateIssuesWithSummary ?? searchCandidateIssuesWithSummary; - const rankIssues = options.rankCandidateIssuesWithSummary ?? rankCandidateIssuesWithSummary; - const enqueue = options.enqueueRankedDiscovery ?? enqueueRankedDiscovery; - // Eligibility filtering (#6798): resolve each candidate repo's ContributionProfile and drop candidates the - // repo's own conventions would reject, BEFORE ranking. Safe by default -- see resolveContributionProfilesForDiscover. - const resolveProfiles = options.resolveContributionProfiles ?? resolveContributionProfilesForDiscover; - // Same scope this run already asks GitHub about (#7168) -- the discovery-index supplement, when enabled, - // asks the shared hosted index about the identical targets/search rather than a different query entirely. - const discoveryQueryScope = - parsed.search !== null - ? { repos: [], orgs: [], searchTerms: [parsed.search] } - : { repos: parsed.targets.map((target) => `${target.owner}/${target.repo}`), orgs: [], searchTerms: [] }; - - // #4847: fetch + rank are read-only GitHub GETs and pure local computation, so a dry run still does them for - // real (that's the useful "what would this discover?" output) -- but it never opens any local store (portfolio - // queue, policy-doc cache, policy-verdict cache), since opening a not-yet-existing SQLite store file is itself - // a write. The ranked issues are fed through a no-op queue stub so enqueueRankedDiscovery's own classification - // logic (valid/invalid, below-min-rank) still runs for real, just without ever touching the real queue. - if (parsed.dryRun) { - const fanOutOptions = { apiBaseUrl, forge: options.forge, policyDocCache: null, policyVerdictCache: null }; + const parsed = parseDiscoverArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + // Credential env var is per-tenant (#4784): a `--token-env FORGE_PAT` flag (or `options.tokenEnv`) reads a + // non-`GITHUB_TOKEN` variable so a non-github.com forge's token is reachable. The default falls through to the + // forge adapter's own `tokenEnvVar` (github.com's `GITHUB_TOKEN`), so there's a single source of truth for the + // default credential env instead of a second hardcoded literal that could drift from `DEFAULT_FORGE_CONFIG`. + const tokenEnv = parsed.tokenEnv ?? options.tokenEnv ?? resolveForgeConfig(options.forge).tokenEnvVar; + const githubToken = options.githubToken ?? process.env[tokenEnv] ?? ""; + // A `--api-base-url` flag (or `options.apiBaseUrl`) surfaces the fan-out's existing forge-host override at the CLI + // (#4784); `options.forge` carries any remaining per-tenant forge knobs for a programmatic caller. + const apiBaseUrl = parsed.apiBaseUrl ?? options.apiBaseUrl; + const fetchTargets = options.fetchCandidateIssuesWithSummary ?? fetchCandidateIssuesWithSummary; + const searchTargets = options.searchCandidateIssuesWithSummary ?? searchCandidateIssuesWithSummary; + const rankIssues = options.rankCandidateIssuesWithSummary ?? rankCandidateIssuesWithSummary; + const enqueue = options.enqueueRankedDiscovery ?? enqueueRankedDiscovery; + // Eligibility filtering (#6798): resolve each candidate repo's ContributionProfile and drop candidates the + // repo's own conventions would reject, BEFORE ranking. Safe by default -- see resolveContributionProfilesForDiscover. + const resolveProfiles = options.resolveContributionProfiles ?? resolveContributionProfilesForDiscover; + // Same scope this run already asks GitHub about (#7168) -- the discovery-index supplement, when enabled, + // asks the shared hosted index about the identical targets/search rather than a different query entirely. + const discoveryQueryScope = parsed.search !== null + ? { repos: [], orgs: [], searchTerms: [parsed.search] } + : { repos: parsed.targets.map((target) => `${target.owner}/${target.repo}`), orgs: [], searchTerms: [] }; + // #4847: fetch + rank are read-only GitHub GETs and pure local computation, so a dry run still does them for + // real (that's the useful "what would this discover?" output) -- but it never opens any local store (portfolio + // queue, policy-doc cache, policy-verdict cache), since opening a not-yet-existing SQLite store file is itself + // a write. The ranked issues are fed through a no-op queue stub so enqueueRankedDiscovery's own classification + // logic (valid/invalid, below-min-rank) still runs for real, just without ever touching the real queue. + if (parsed.dryRun) { + // exactOptionalPropertyTypes: cast through FanoutOptions — apiBaseUrl/forge may be unset at runtime. + const fanOutOptions = { + apiBaseUrl, + forge: options.forge, + policyDocCache: null, + policyVerdictCache: null, + }; + try { + let fanOut = parsed.search !== null + ? await searchTargets(parsed.search, githubToken, fanOutOptions) + : await fetchTargets(parsed.targets, githubToken, fanOutOptions); + fanOut = await supplementWithDiscoveryIndex(fanOut, discoveryQueryScope, options); + // #6798: same eligibility filter as the real path, so a dry run shows the exact candidate set a real run + // would enqueue (and the same excluded set), rather than an unfiltered preview. + const repoFullNames = [...new Set(fanOut.issues.map((issue) => issue.repoFullName))]; + const profilesByRepo = await resolveProfiles(repoFullNames, { + githubToken, + ...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}), + ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}), + }); + // RunDiscoverOptions.resolveContributionProfiles is typed as Map (pre-existing .d.ts); + // the filter expects ContributionProfile values — same runtime objects. + const { kept, excluded } = filterCandidatesByProfiles(fanOut.issues, profilesByRepo); + const rankedSummary = rankIssues(kept, { + ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}), + ...(options.goalSpecsByRepo !== undefined ? { goalSpecsByRepo: options.goalSpecsByRepo } : {}), + ...(options.goalSpecContentByRepo !== undefined + ? { goalSpecContentByRepo: options.goalSpecContentByRepo } + : {}), + }); + const noopQueueStore = { enqueue: () => { } }; + const enqueueSummary = enqueue(rankedSummary.issues, { queueStore: noopQueueStore }); + const result = { + outcome: "dry_run", + fanOutCount: fanOut.issues.length, + warnings: fanOut.warnings, + rateLimitRemaining: fanOut.rateLimitRemaining, + rateLimitResetAt: fanOut.rateLimitResetAt, + ranked: rankedSummary.issues, + excluded: excluded.map((entry) => ({ + repoFullName: entry.candidate.repoFullName, + issueNumber: entry.candidate.issueNumber, + reason: entry.reason, + })), + usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, + enqueueSummary, + }; + // Structured-outcome hook (#6522), mirroring runAttempt's onResult convention: fires only at a real + // structured success point (never the reportCliFailure branches), in addition to -- never instead of -- + // the plain exit-code return, so a non-CLI caller (the /api/discover route) can read the result. + // Dry-run result adds `outcome: "dry_run"` at runtime; DiscoverResult/.d.ts omits it — pre-existing drift. + options.onResult?.(result); + if (parsed.json) { + console.log(JSON.stringify(result, null, 2)); + } + else { + console.log(renderDiscoverSummary(result)); + console.log("\nDRY RUN: no portfolio-queue write was made."); + } + return 0; + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } + } + const ownsPortfolioQueue = options.initPortfolioQueue === undefined; + let portfolioQueue; try { - let fanOut = - parsed.search !== null - ? await searchTargets(parsed.search, githubToken, fanOutOptions) - : await fetchTargets(parsed.targets, githubToken, fanOutOptions); - fanOut = await supplementWithDiscoveryIndex(fanOut, discoveryQueryScope, options); - // #6798: same eligibility filter as the real path, so a dry run shows the exact candidate set a real run - // would enqueue (and the same excluded set), rather than an unfiltered preview. - const repoFullNames = [...new Set(fanOut.issues.map((issue) => issue.repoFullName))]; - const profilesByRepo = await resolveProfiles(repoFullNames, { githubToken, apiBaseUrl, nowMs: options.nowMs }); - const { kept, excluded } = filterCandidatesByProfiles(fanOut.issues, profilesByRepo); - const rankedSummary = rankIssues(kept, { - nowMs: options.nowMs, - goalSpecsByRepo: options.goalSpecsByRepo, - goalSpecContentByRepo: options.goalSpecContentByRepo, - }); - const noopQueueStore = { enqueue: () => {} }; - const enqueueSummary = enqueue(rankedSummary.issues, { queueStore: noopQueueStore }); - const result = { - outcome: "dry_run", - fanOutCount: fanOut.issues.length, - warnings: fanOut.warnings, - rateLimitRemaining: fanOut.rateLimitRemaining, - rateLimitResetAt: fanOut.rateLimitResetAt, - ranked: rankedSummary.issues, - excluded: excluded.map((entry) => ({ - repoFullName: entry.candidate.repoFullName, - issueNumber: entry.candidate.issueNumber, - reason: entry.reason, - })), - usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, - enqueueSummary, - }; - // Structured-outcome hook (#6522), mirroring runAttempt's onResult convention: fires only at a real - // structured success point (never the reportCliFailure branches), in addition to -- never instead of -- - // the plain exit-code return, so a non-CLI caller (the /api/discover route) can read the result. - options.onResult?.(result); - if (parsed.json) { - console.log(JSON.stringify(result, null, 2)); - } else { - console.log(renderDiscoverSummary(result)); - console.log("\nDRY RUN: no portfolio-queue write was made."); - } - return 0; - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); + portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); } - } - - const ownsPortfolioQueue = options.initPortfolioQueue === undefined; - let portfolioQueue; - try { - portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } - - // Local ETag cache so a repeated discover revalidates each repo's policy docs with a conditional GET instead of - // re-downloading them (#4842). Opened inside its OWN try/catch, separate from the portfolio queue above: the - // queue is required infrastructure (discovery genuinely cannot enqueue anything without it, so a real open - // failure should abort the run), but the policy-doc cache is a pure performance optimization -- a corrupt or - // unwritable cache DB must degrade to "no cache" (every doc fetched in full, exactly as before #4842) rather - // than fail discovery outright. - let policyDocCache = null; - let ownsPolicyDocCache = false; - try { - ownsPolicyDocCache = options.initPolicyDocCache === undefined; - policyDocCache = (options.initPolicyDocCache ?? initPolicyDocCacheStore)(); - } catch { - policyDocCache = null; - ownsPolicyDocCache = false; - } - - // Persisted cache of resolved policy verdicts (#4843), same "own try/catch, degrade to null" discipline as the - // doc cache above and for the same reason: purely a performance optimization the feature is inert without, so a - // corrupt/unwritable cache DB must never abort a run. - let policyVerdictCache = null; - let ownsPolicyVerdictCache = false; - try { - ownsPolicyVerdictCache = options.initPolicyVerdictCache === undefined; - policyVerdictCache = (options.initPolicyVerdictCache ?? initPolicyVerdictCacheStore)(); - } catch { - policyVerdictCache = null; - ownsPolicyVerdictCache = false; - } - - // Snapshot of this run's full ranked output (#4859 prerequisite), so a local HTTP endpoint (and eventually the - // miner-ui/browser-extension live-fetch it's meant for) can serve the same per-issue breakdown `--json` prints, - // without the operator re-running discover or hand-pasting its output. Same "own try/catch, degrade to null" - // discipline as the two caches above: a corrupt/unwritable snapshot store must never abort discovery's actual - // job (fan out, rank, enqueue). Unlike the caches, this store is a WRITE target, not a read optimization -- the - // save call itself gets its own try/catch below for the same reason. - let rankedCandidatesStore = null; - let ownsRankedCandidatesStore = false; - try { - ownsRankedCandidatesStore = options.initRankedCandidatesStore === undefined; - rankedCandidatesStore = (options.initRankedCandidatesStore ?? initRankedCandidatesStore)(); - } catch { - rankedCandidatesStore = null; - ownsRankedCandidatesStore = false; - } - const fanOutOptions = { apiBaseUrl, forge: options.forge, policyDocCache, policyVerdictCache }; - - try { - let fanOut = - parsed.search !== null - ? await searchTargets(parsed.search, githubToken, fanOutOptions) - : await fetchTargets(parsed.targets, githubToken, fanOutOptions); - fanOut = await supplementWithDiscoveryIndex(fanOut, discoveryQueryScope, options); - - // Eligibility filter (#6798): drop candidates a target repo's own conventions would reject, before ranking. - // A repo with no trustworthy eligibility profile keeps every candidate (filterCandidatesByProfiles' safe - // default), so this never silently skips real work on a repo whose conventions AMS couldn't read. - const repoFullNames = [...new Set(fanOut.issues.map((issue) => issue.repoFullName))]; - const profilesByRepo = await resolveProfiles(repoFullNames, { githubToken, apiBaseUrl, nowMs: options.nowMs }); - const { kept, excluded } = filterCandidatesByProfiles(fanOut.issues, profilesByRepo); - - // Pass any caller-supplied per-tenant goal specs through to the ranker so lane fit uses the tenant's - // conventions instead of silently falling back to loopover's defaults (#4784); the fallback is surfaced via - // `usedDefaultGoalSpec` below rather than hidden. - const rankedSummary = rankIssues(kept, { - nowMs: options.nowMs, - goalSpecsByRepo: options.goalSpecsByRepo, - goalSpecContentByRepo: options.goalSpecContentByRepo, - }); - const enqueueSummary = enqueue(rankedSummary.issues, { queueStore: portfolioQueue, apiBaseUrl }); - + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } + // Local ETag cache so a repeated discover revalidates each repo's policy docs with a conditional GET instead of + // re-downloading them (#4842). Opened inside its OWN try/catch, separate from the portfolio queue above: the + // queue is required infrastructure (discovery genuinely cannot enqueue anything without it, so a real open + // failure should abort the run), but the policy-doc cache is a pure performance optimization -- a corrupt or + // unwritable cache DB must degrade to "no cache" (every doc fetched in full, exactly as before #4842) rather + // than fail discovery outright. + let policyDocCache = null; + let ownsPolicyDocCache = false; + try { + ownsPolicyDocCache = options.initPolicyDocCache === undefined; + policyDocCache = (options.initPolicyDocCache ?? initPolicyDocCacheStore)(); + } + catch { + policyDocCache = null; + ownsPolicyDocCache = false; + } + // Persisted cache of resolved policy verdicts (#4843), same "own try/catch, degrade to null" discipline as the + // doc cache above and for the same reason: purely a performance optimization the feature is inert without, so a + // corrupt/unwritable cache DB must never abort a run. + let policyVerdictCache = null; + let ownsPolicyVerdictCache = false; try { - // Optional chaining rather than an `if (rankedCandidatesStore)` guard: a null store (open failed above) - // short-circuits to a no-op read, so the same try/catch below also covers the open-failed case without a - // second explicit branch. - rankedCandidatesStore?.saveRankedCandidates(rankedSummary.issues, options.nowMs); - } catch { - // Non-fatal: the ranked-candidates snapshot is a nice-to-have for the local HTTP endpoint, not a - // requirement for discover's own job (fan out, rank, enqueue), which already succeeded above. + ownsPolicyVerdictCache = options.initPolicyVerdictCache === undefined; + policyVerdictCache = (options.initPolicyVerdictCache ?? initPolicyVerdictCacheStore)(); + } + catch { + policyVerdictCache = null; + ownsPolicyVerdictCache = false; } - - const result = { - fanOutCount: fanOut.issues.length, - warnings: fanOut.warnings, - rateLimitRemaining: fanOut.rateLimitRemaining, - rateLimitResetAt: fanOut.rateLimitResetAt, - ranked: rankedSummary.issues, - // #6798: candidates the eligibility filter dropped, each with the repo + issue + reason, so a human sees - // what AMS inferred and why a candidate was skipped. Empty when no profile was trustworthy enough to filter. - excluded: excluded.map((entry) => ({ - repoFullName: entry.candidate.repoFullName, - issueNumber: entry.candidate.issueNumber, - reason: entry.reason, - })), - usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, - enqueueSummary, + // Snapshot of this run's full ranked output (#4859 prerequisite), so a local HTTP endpoint (and eventually the + // miner-ui/browser-extension live-fetch it's meant for) can serve the same per-issue breakdown `--json` prints, + // without the operator re-running discover or hand-pasting its output. Same "own try/catch, degrade to null" + // discipline as the two caches above: a corrupt/unwritable snapshot store must never abort discovery's actual + // job (fan out, rank, enqueue). Unlike the caches, this store is a WRITE target, not a read optimization -- the + // save call itself gets its own try/catch below for the same reason. + let rankedCandidatesStore = null; + let ownsRankedCandidatesStore = false; + try { + ownsRankedCandidatesStore = options.initRankedCandidatesStore === undefined; + rankedCandidatesStore = (options.initRankedCandidatesStore ?? initRankedCandidatesStore)(); + } + catch { + rankedCandidatesStore = null; + ownsRankedCandidatesStore = false; + } + const fanOutOptions = { + apiBaseUrl, + forge: options.forge, + policyDocCache, + policyVerdictCache, }; - - // Structured-outcome hook (#6522) for the full-run success point -- same convention as the dry-run branch - // above and as runAttempt's onResult: real result only, additive to the unchanged exit-code return. - options.onResult?.(result); - if (parsed.json) { - console.log(JSON.stringify(result, null, 2)); - } else { - console.log(renderDiscoverSummary(result)); + try { + let fanOut = parsed.search !== null + ? await searchTargets(parsed.search, githubToken, fanOutOptions) + : await fetchTargets(parsed.targets, githubToken, fanOutOptions); + fanOut = await supplementWithDiscoveryIndex(fanOut, discoveryQueryScope, options); + // Eligibility filter (#6798): drop candidates a target repo's own conventions would reject, before ranking. + // A repo with no trustworthy eligibility profile keeps every candidate (filterCandidatesByProfiles' safe + // default), so this never silently skips real work on a repo whose conventions AMS couldn't read. + const repoFullNames = [...new Set(fanOut.issues.map((issue) => issue.repoFullName))]; + const profilesByRepo = await resolveProfiles(repoFullNames, { + githubToken, + ...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}), + ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}), + }); + // RunDiscoverOptions.resolveContributionProfiles is typed as Map (pre-existing .d.ts); + // the filter expects ContributionProfile values — same runtime objects. + const { kept, excluded } = filterCandidatesByProfiles(fanOut.issues, profilesByRepo); + // Pass any caller-supplied per-tenant goal specs through to the ranker so lane fit uses the tenant's + // conventions instead of silently falling back to loopover's defaults (#4784); the fallback is surfaced via + // `usedDefaultGoalSpec` below rather than hidden. + const rankedSummary = rankIssues(kept, { + ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}), + ...(options.goalSpecsByRepo !== undefined ? { goalSpecsByRepo: options.goalSpecsByRepo } : {}), + ...(options.goalSpecContentByRepo !== undefined + ? { goalSpecContentByRepo: options.goalSpecContentByRepo } + : {}), + }); + const enqueueSummary = enqueue(rankedSummary.issues, { + queueStore: portfolioQueue, + ...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}), + }); + try { + // Optional chaining rather than an `if (rankedCandidatesStore)` guard: a null store (open failed above) + // short-circuits to a no-op read, so the same try/catch below also covers the open-failed case without a + // second explicit branch. + rankedCandidatesStore?.saveRankedCandidates(rankedSummary.issues, options.nowMs); + } + catch { + // Non-fatal: the ranked-candidates snapshot is a nice-to-have for the local HTTP endpoint, not a + // requirement for discover's own job (fan out, rank, enqueue), which already succeeded above. + } + const result = { + fanOutCount: fanOut.issues.length, + warnings: fanOut.warnings, + rateLimitRemaining: fanOut.rateLimitRemaining, + rateLimitResetAt: fanOut.rateLimitResetAt, + ranked: rankedSummary.issues, + // #6798: candidates the eligibility filter dropped, each with the repo + issue + reason, so a human sees + // what AMS inferred and why a candidate was skipped. Empty when no profile was trustworthy enough to filter. + excluded: excluded.map((entry) => ({ + repoFullName: entry.candidate.repoFullName, + issueNumber: entry.candidate.issueNumber, + reason: entry.reason, + })), + usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, + enqueueSummary, + }; + // Structured-outcome hook (#6522) for the full-run success point -- same convention as the dry-run branch + // above and as runAttempt's onResult: real result only, additive to the unchanged exit-code return. + options.onResult?.(result); + if (parsed.json) { + console.log(JSON.stringify(result, null, 2)); + } + else { + console.log(renderDiscoverSummary(result)); + } + return 0; + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } + finally { + if (ownsPortfolioQueue && portfolioQueue) + portfolioQueue.close(); + if (ownsPolicyDocCache && policyDocCache) + policyDocCache.close(); + if (ownsPolicyVerdictCache && policyVerdictCache) + policyVerdictCache.close(); + if (ownsRankedCandidatesStore && rankedCandidatesStore) + rankedCandidatesStore.close(); } - return 0; - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } finally { - if (ownsPortfolioQueue && portfolioQueue) portfolioQueue.close(); - if (ownsPolicyDocCache && policyDocCache) policyDocCache.close(); - if (ownsPolicyVerdictCache && policyVerdictCache) policyVerdictCache.close(); - if (ownsRankedCandidatesStore && rankedCandidatesStore) rankedCandidatesStore.close(); - } } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZGlzY292ZXItY2xpLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiZGlzY292ZXItY2xpLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBO2tIQUNrSDtBQUNsSCxPQUFPLEVBQUUsa0JBQWtCLEVBQUUsTUFBTSxtQkFBbUIsQ0FBQztBQUV2RCxPQUFPLEVBQ0wsK0JBQStCLEVBQy9CLGdDQUFnQyxHQUNqQyxNQUFNLHlCQUF5QixDQUFDO0FBT2pDLE9BQU8sRUFBRSw4QkFBOEIsRUFBRSxNQUFNLHlCQUF5QixDQUFDO0FBTXpFLE9BQU8sRUFBRSx1QkFBdUIsRUFBRSxNQUFNLHVCQUF1QixDQUFDO0FBRWhFLE9BQU8sRUFBRSwyQkFBMkIsRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBRXhFLE9BQU8sRUFBRSxzQkFBc0IsRUFBRSxNQUFNLDBCQUEwQixDQUFDO0FBRWxFLE9BQU8sRUFBRSx1QkFBdUIsRUFBRSxNQUFNLHNCQUFzQixDQUFDO0FBRS9ELE9BQU8sRUFBRSx5QkFBeUIsRUFBRSxNQUFNLHdCQUF3QixDQUFDO0FBRW5FLE9BQU8sRUFBRSwwQkFBMEIsRUFBRSxNQUFNLG1DQUFtQyxDQUFDO0FBQy9FLE9BQU8sRUFBRSw0QkFBNEIsRUFBRSxNQUFNLGlDQUFpQyxDQUFDO0FBQy9FLE9BQU8sRUFBRSwwQkFBMEIsRUFBRSxNQUFNLGtDQUFrQyxDQUFDO0FBRTlFLE9BQU8sRUFBRSxZQUFZLEVBQUUsZ0JBQWdCLEVBQUUsZ0JBQWdCLEVBQUUsTUFBTSxnQkFBZ0IsQ0FBQztBQUNsRixPQUFPLEVBQUUsdUJBQXVCLEVBQUUsbUJBQW1CLEVBQUUsd0JBQXdCLEVBQUUsTUFBTSw2QkFBNkIsQ0FBQztBQW9HckgsTUFBTSxjQUFjLEdBQ2xCLGtKQUFrSixDQUFDO0FBRXJKLE1BQU0saUNBQWlDLEdBQUcsR0FBRyxDQUFDO0FBQzlDLE1BQU0sb0JBQW9CLEdBQUcsc0NBQXNDLENBQUM7QUFDcEUsTUFBTSxtQkFBbUIsR0FBRyxzQ0FBc0MsQ0FB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/discover-cli.ts b/packages/loopover-miner/lib/discover-cli.ts new file mode 100644 index 0000000000..40f9c7a089 --- /dev/null +++ b/packages/loopover-miner/lib/discover-cli.ts @@ -0,0 +1,607 @@ +/** `discover` CLI command (#4247): wires the existing fanout -> rank -> enqueue pipeline together so a miner + * can actually run it. Every piece already exists and is independently tested; this module only composes them. */ +import { resolveForgeConfig } from "./forge-config.js"; +import type { ForgeConfig } from "./forge-config.js"; +import { + fetchCandidateIssuesWithSummary, + searchCandidateIssuesWithSummary, +} from "./opportunity-fanout.js"; +import type { + CandidateIssueWarning, + FanoutOptions, + FanoutTarget, + RawCandidateIssue, +} from "./opportunity-fanout.js"; +import { rankCandidateIssuesWithSummary } from "./opportunity-ranker.js"; +import type { + RankCandidateIssuesOptions, + RankedCandidateIssue, + RankedCandidateSummary, +} from "./opportunity-ranker.js"; +import { initPolicyDocCacheStore } from "./policy-doc-cache.js"; +import type { PolicyDocCacheStore } from "./policy-doc-cache.js"; +import { initPolicyVerdictCacheStore } from "./policy-verdict-cache.js"; +import type { PolicyVerdictCacheStore } from "./policy-verdict-cache.js"; +import { enqueueRankedDiscovery } from "./portfolio-discovery.js"; +import type { EnqueueRankedDiscoverySummary } from "./portfolio-discovery.js"; +import { initPortfolioQueueStore } from "./portfolio-queue.js"; +import type { PortfolioQueueStore } from "./portfolio-queue.js"; +import { initRankedCandidatesStore } from "./ranked-candidates.js"; +import type { RankedCandidatesStore } from "./ranked-candidates.js"; +import { extractContributionProfile } from "./contribution-profile-extract.js"; +import { initContributionProfileCache } from "./contribution-profile-cache.js"; +import { filterCandidatesByProfiles } from "./contribution-profile-filter.js"; +import type { ContributionProfile } from "./contribution-profile.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; +import { isDiscoveryPlaneEnabled, queryDiscoveryIndex, recordDiscoveryTelemetry } from "./discovery-index-client.js"; +import type { queryDiscoveryIndex as QueryDiscoveryIndexFn } from "./discovery-index-client.js"; +import type { DiscoveryIndexQuery } from "@loopover/engine"; + + +export type ParsedDiscoverArgs = + | { + targets: FanoutTarget[]; + search: string | null; + dryRun: boolean; + json: boolean; + /** Present only when `--api-base-url` is supplied (#4784); threads the tenant's forge host to the fan-out. */ + apiBaseUrl?: string; + /** Present only when `--token-env` is supplied (#4784); names the credential env var to read. */ + tokenEnv?: string; + } + | { error: string }; + +/** The subset of `CandidateIssueSummary` runDiscover actually reads. It surfaces the rate-limit telemetry (#4837), + * so a fake must supply it. A real `fetchCandidateIssuesWithSummary` result satisfies this, since it is a superset. */ +export type DiscoverFanOutSummary = { + issues: RawCandidateIssue[]; + warnings: CandidateIssueWarning[]; + rateLimitRemaining: number | null; + rateLimitResetAt: string | null; +}; + +/** The subset of a ranked entry that `renderDiscoverSummary` reads for its top-candidates listing. */ +export type DiscoverRankedEntry = Pick< + RankedCandidateIssue, + "repoFullName" | "issueNumber" | "title" | "rankScore" +>; + +export type DiscoverResult = { + fanOutCount: number; + warnings: CandidateIssueWarning[]; + rateLimitRemaining: number | null; + rateLimitResetAt: string | null; + ranked: DiscoverRankedEntry[]; + /** Candidates the eligibility filter dropped, each with the repo/issue and the reason (#6798). */ + excluded?: Array<{ + repoFullName: string; + issueNumber: number; + reason: string; + }>; + /** True when ranking fell back to the built-in default goal spec because no per-tenant spec was supplied (#4784). */ + usedDefaultGoalSpec?: boolean; + enqueueSummary: EnqueueRankedDiscoverySummary; +}; + +export type RunDiscoverOptions = { + /** Read for the discovery-index opt-in gate (#7168) -- defaults to `process.env`. */ + env?: Record; + githubToken?: string; + apiBaseUrl?: string; + /** Per-tenant credential env var name (#4784); defaults to GITHUB_TOKEN. Overridden by a `--token-env` flag. */ + tokenEnv?: string; + /** Per-tenant forge knobs beyond the host (#4784), forwarded to the fan-out. */ + forge?: Partial; + nowMs?: number; + /** Per-tenant goal specs threaded to the ranker so lane fit uses the tenant's conventions, not the defaults (#4784). */ + goalSpecsByRepo?: RankCandidateIssuesOptions["goalSpecsByRepo"]; + goalSpecContentByRepo?: RankCandidateIssuesOptions["goalSpecContentByRepo"]; + initPortfolioQueue?: () => PortfolioQueueStore; + initPolicyDocCache?: () => PolicyDocCacheStore; + initPolicyVerdictCache?: () => PolicyVerdictCacheStore; + initRankedCandidatesStore?: () => RankedCandidatesStore; + fetchCandidateIssuesWithSummary?: ( + targets: FanoutTarget[], + githubToken: string, + options?: FanoutOptions, + ) => Promise; + searchCandidateIssuesWithSummary?: ( + searchQuery: string, + githubToken: string, + options?: FanoutOptions, + ) => Promise; + rankCandidateIssuesWithSummary?: ( + candidates: RawCandidateIssue[], + options?: RankCandidateIssuesOptions, + ) => RankedCandidateSummary; + enqueueRankedDiscovery?: ( + rankedIssues: RankedCandidateIssue[], + options: { queueStore: PortfolioQueueStore }, + ) => EnqueueRankedDiscoverySummary; + /** Supplements the local fan-out with hosted discovery-index results for the same scope, when the plane is + * enabled (#7168). Defaults to discovery-index-client.js's own queryDiscoveryIndex. */ + queryDiscoveryIndex?: typeof QueryDiscoveryIndexFn; + /** Invoked with the real structured result at each success return point (dry-run and full-run), in addition + * to (never instead of) the plain exit-code return -- mirrors `RunAttemptOptions.onResult`. Never fires on a + * parse-error/unexpected-error `reportCliFailure` branch, matching runAttempt's own asymmetry (#6522). */ + onResult?: (result: DiscoverResult) => void; + /** Resolve each candidate repo's ContributionProfile for eligibility filtering (#6798). Defaults to + * resolveContributionProfilesForDiscover; injectable so tests avoid the network. */ + resolveContributionProfiles?: ( + repoFullNames: string[], + ctx: { githubToken?: string; apiBaseUrl?: string; nowMs?: number }, + ) => Promise>; +}; + +const DISCOVER_USAGE = + "Usage: loopover-miner discover [...] | --search [--dry-run] [--json] [--api-base-url ] [--token-env ]"; + +const MAX_DISCOVER_TITLE_DISPLAY_LENGTH = 240; +const OSC_SEQUENCE_PATTERN = /\u001b\][\s\S]*?(?:\u0007|\u001b\\)/g; +const ANSI_ESCAPE_PATTERN = /\u001b(?:\[[0-?]*[ -/]*[@-~]|[@-_])/g; +const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f]/g; +const BIDI_CONTROL_PATTERN = /[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; + +export function sanitizeDiscoverDisplayText(value: unknown): string { + return String(value ?? "") + .replace(OSC_SEQUENCE_PATTERN, "") + .replace(ANSI_ESCAPE_PATTERN, "") + .replace(CONTROL_CHARACTER_PATTERN, " ") + .replace(BIDI_CONTROL_PATTERN, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, MAX_DISCOVER_TITLE_DISPLAY_LENGTH); +} + +function dedupeKey(repoFullName: string, issueNumber: number): string { + return `${repoFullName.toLowerCase()}#${issueNumber}`; +} + +/** + * Supplements `fanOut.issues` with hosted discovery-index results for the same scope (#7168) -- a complete + * no-op (returns `fanOut` unchanged) unless the plane is enabled, so a run with the flag unset behaves exactly + * as before this feature existed. Local results always win on a duplicate issue (the discovery-index candidate + * is dropped, not merged over it) -- this instance's own live fan-out is more current than a cached shared + * index entry. Discovery-index candidates lack `assignees` (not part of the public contract), so they're + * annotated with an empty array to match opportunity-fanout.js's own candidate shape; contribution-profile- + * filter.js's assignee-exclusion rule treats that identically to "no assignees on this issue". + */ +async function supplementWithDiscoveryIndex( + fanOut: DiscoverFanOutSummary, + queryScope: Partial, + options: RunDiscoverOptions, +): Promise { + const env = options.env ?? process.env; + if (!isDiscoveryPlaneEnabled(env)) return fanOut; + const queryIndex = options.queryDiscoveryIndex ?? queryDiscoveryIndex; + const response = await queryIndex(queryScope, { env }); + recordDiscoveryTelemetry("discover_query", response.candidates.length > 0 ? "supplemented" : "empty", { env }); + if (response.candidates.length === 0) return fanOut; + + const seen = new Set(fanOut.issues.map((issue) => dedupeKey(issue.repoFullName, issue.issueNumber))); + const supplemented = response.candidates + .filter((candidate) => !seen.has(dedupeKey(candidate.repoFullName, candidate.issueNumber))) + // DiscoveryIndexCandidate is a near-superset of RawCandidateIssue; assignees is absent from the hosted + // contract (#7168) so we annotate [] — cast preserves pre-existing runtime shape rather than re-mapping. + .map((candidate) => ({ ...candidate, assignees: [], labels: [...candidate.labels] }) as RawCandidateIssue); + if (supplemented.length === 0) return fanOut; + return { ...fanOut, issues: [...fanOut.issues, ...supplemented] }; +} + +function parseRepoTarget(value: string): FanoutTarget | null { + const trimmed = value.trim(); + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) return null; + return { owner, repo }; +} + +export function parseDiscoverArgs(args: string[]): ParsedDiscoverArgs { + // `--api-base-url` and `--token-env` (#4784) thread the tenant's forge host and credential env var into the + // fan-out; they are kept off the parsed result unless supplied, so callers that pass neither see the exact + // pre-#4784 `{ targets, search, json }` shape. + const options: { + json: boolean; + dryRun: boolean; + search: string | null; + apiBaseUrl: string | null; + tokenEnv: string | null; + } = { json: false, dryRun: false, search: null, apiBaseUrl: null, tokenEnv: null }; + const targets: FanoutTarget[] = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--json") { + options.json = true; + continue; + } + // #4847: fetches + ranks exactly as a real run, but skips opening any local store and makes zero writes. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--search") { + const query = args[index + 1]; + if (!query || query.startsWith("-")) return { error: DISCOVER_USAGE }; + options.search = query; + index += 1; + continue; + } + if (token === "--api-base-url") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: DISCOVER_USAGE }; + options.apiBaseUrl = value; + index += 1; + continue; + } + if (token === "--token-env") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: DISCOVER_USAGE }; + options.tokenEnv = value; + index += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + const target = parseRepoTarget(token); + if (!target) return { error: `Repository must be in owner/repo form: ${token}` }; + targets.push(target); + } + + if (options.search === null && targets.length === 0) { + return { error: DISCOVER_USAGE }; + } + if (options.search !== null && targets.length > 0) { + return { error: "Pass either repository targets or --search, not both." }; + } + + return { + targets, + search: options.search, + dryRun: options.dryRun, + json: options.json, + ...(options.apiBaseUrl !== null ? { apiBaseUrl: options.apiBaseUrl } : {}), + ...(options.tokenEnv !== null ? { tokenEnv: options.tokenEnv } : {}), + }; +} + +// The rate-limit line surfaces the telemetry the fanout already records (#4837) so an operator sees how close a +// `discover` run is to being throttled without running a separate command. `unknown` covers the no-fetch/no-header +// case where the fanout captured no remaining count. +function renderRateLimitLine(result: Pick): string { + const remaining = result.rateLimitRemaining === null ? "unknown" : String(result.rateLimitRemaining); + const resetSuffix = result.rateLimitResetAt === null ? "" : ` (resets ${result.rateLimitResetAt})`; + return `rate-limit remaining: ${remaining}${resetSuffix}`; +} + +export function renderDiscoverSummary(result: DiscoverResult): string { + const lines = [ + `fanned out: ${result.fanOutCount} candidate issue(s)`, + `ai-policy warnings: ${result.warnings.length}`, + `ranked: ${result.ranked.length}`, + `enqueued: ${result.enqueueSummary.enqueued}`, + renderRateLimitLine(result), + ]; + if (result.enqueueSummary.skippedBelowMinRank > 0) { + lines.push(`skipped (below min rank): ${result.enqueueSummary.skippedBelowMinRank}`); + } + // #6798: surface what the eligibility filter dropped and why, so a human sees AMS's inference. + const excluded = result.excluded ?? []; + if (excluded.length > 0) { + lines.push(`excluded (eligibility): ${excluded.length}`); + for (const entry of excluded.slice(0, 10)) { + lines.push(` ${entry.repoFullName}#${entry.issueNumber} ${entry.reason}`); + } + } + // Make the fall-back to loopover's built-in rubric explicit instead of silent (#4784): when no per-tenant goal + // spec is supplied, lane fit reflects loopover's defaults, not the target repo's own conventions. + if (result.usedDefaultGoalSpec) { + lines.push( + "note: ranked with the built-in default goal spec (no per-tenant .loopover-miner.yml supplied)", + ); + } + if (result.ranked.length === 0) { + lines.push("", "no candidates found."); + return lines.join("\n"); + } + lines.push("", "top candidates:"); + for (const entry of result.ranked.slice(0, 10)) { + const title = sanitizeDiscoverDisplayText(entry.title); + lines.push(` ${entry.repoFullName}#${entry.issueNumber} score=${entry.rankScore.toFixed(4)} ${title}`); + } + return lines.join("\n"); +} + +/** + * Default per-repo ContributionProfile resolver (#6798): reads the local cache and, on a miss/stale entry, + * extracts a fresh profile and caches it. Returns a Map keyed by repoFullName. + * + * WITHOUT a github token this returns an empty map and does no network work at all — AMS can't reliably read a + * repo's label taxonomy/docs unauthenticated (rate limits), so it safe-defaults to no eligibility filtering. + * That also keeps callers that don't supply a token (the common CLI path, and every test) hermetic. + * + * @param {string[]} repoFullNames unique repos among the fanned-out candidates + * @param {{ githubToken?: string, apiBaseUrl?: string, nowMs?: number, initCache?: typeof initContributionProfileCache, extract?: typeof extractContributionProfile }} ctx + * @returns {Promise>} + */ +export async function resolveContributionProfilesForDiscover( + repoFullNames: string[], + ctx: { + githubToken?: string; + apiBaseUrl?: string; + nowMs?: number; + initCache?: unknown; + extract?: unknown; + } = {}, +): Promise> { + const profiles = new Map(); + if (!ctx.githubToken) return profiles; + const initCache = (ctx.initCache as typeof initContributionProfileCache | undefined) ?? initContributionProfileCache; + const extract = (ctx.extract as typeof extractContributionProfile | undefined) ?? extractContributionProfile; + const cache = initCache(); + try { + for (const repoFullName of repoFullNames) { + const cached = cache.get(repoFullName, ctx.nowMs); + if (cached && !cached.stale) { + profiles.set(repoFullName, cached.profile); + continue; + } + const profile = await extract(repoFullName, { + githubToken: ctx.githubToken, + // exactOptionalPropertyTypes: omit apiBaseUrl when unset (pre-existing optional-prop shape). + ...(ctx.apiBaseUrl !== undefined ? { apiBaseUrl: ctx.apiBaseUrl } : {}), + } as Parameters[1]); + cache.put(profile, ctx.nowMs); + profiles.set(repoFullName, profile); + } + } finally { + cache.close(); + } + return profiles; +} + +export async function runDiscover(args: string[], options: RunDiscoverOptions = {}): Promise { + const parsed = parseDiscoverArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + // Credential env var is per-tenant (#4784): a `--token-env FORGE_PAT` flag (or `options.tokenEnv`) reads a + // non-`GITHUB_TOKEN` variable so a non-github.com forge's token is reachable. The default falls through to the + // forge adapter's own `tokenEnvVar` (github.com's `GITHUB_TOKEN`), so there's a single source of truth for the + // default credential env instead of a second hardcoded literal that could drift from `DEFAULT_FORGE_CONFIG`. + const tokenEnv = parsed.tokenEnv ?? options.tokenEnv ?? resolveForgeConfig(options.forge).tokenEnvVar; + const githubToken = options.githubToken ?? process.env[tokenEnv] ?? ""; + // A `--api-base-url` flag (or `options.apiBaseUrl`) surfaces the fan-out's existing forge-host override at the CLI + // (#4784); `options.forge` carries any remaining per-tenant forge knobs for a programmatic caller. + const apiBaseUrl = parsed.apiBaseUrl ?? options.apiBaseUrl; + const fetchTargets = options.fetchCandidateIssuesWithSummary ?? fetchCandidateIssuesWithSummary; + const searchTargets = options.searchCandidateIssuesWithSummary ?? searchCandidateIssuesWithSummary; + const rankIssues = options.rankCandidateIssuesWithSummary ?? rankCandidateIssuesWithSummary; + const enqueue = options.enqueueRankedDiscovery ?? enqueueRankedDiscovery; + // Eligibility filtering (#6798): resolve each candidate repo's ContributionProfile and drop candidates the + // repo's own conventions would reject, BEFORE ranking. Safe by default -- see resolveContributionProfilesForDiscover. + const resolveProfiles = options.resolveContributionProfiles ?? resolveContributionProfilesForDiscover; + // Same scope this run already asks GitHub about (#7168) -- the discovery-index supplement, when enabled, + // asks the shared hosted index about the identical targets/search rather than a different query entirely. + const discoveryQueryScope = + parsed.search !== null + ? { repos: [], orgs: [], searchTerms: [parsed.search] } + : { repos: parsed.targets.map((target) => `${target.owner}/${target.repo}`), orgs: [], searchTerms: [] }; + + // #4847: fetch + rank are read-only GitHub GETs and pure local computation, so a dry run still does them for + // real (that's the useful "what would this discover?" output) -- but it never opens any local store (portfolio + // queue, policy-doc cache, policy-verdict cache), since opening a not-yet-existing SQLite store file is itself + // a write. The ranked issues are fed through a no-op queue stub so enqueueRankedDiscovery's own classification + // logic (valid/invalid, below-min-rank) still runs for real, just without ever touching the real queue. + if (parsed.dryRun) { + // exactOptionalPropertyTypes: cast through FanoutOptions — apiBaseUrl/forge may be unset at runtime. + const fanOutOptions = { + apiBaseUrl, + forge: options.forge, + policyDocCache: null, + policyVerdictCache: null, + } as FanoutOptions; + try { + let fanOut = + parsed.search !== null + ? await searchTargets(parsed.search, githubToken, fanOutOptions) + : await fetchTargets(parsed.targets, githubToken, fanOutOptions); + fanOut = await supplementWithDiscoveryIndex(fanOut, discoveryQueryScope, options); + // #6798: same eligibility filter as the real path, so a dry run shows the exact candidate set a real run + // would enqueue (and the same excluded set), rather than an unfiltered preview. + const repoFullNames = [...new Set(fanOut.issues.map((issue) => issue.repoFullName))]; + const profilesByRepo = await resolveProfiles(repoFullNames, { + githubToken, + ...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}), + ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}), + }); + // RunDiscoverOptions.resolveContributionProfiles is typed as Map (pre-existing .d.ts); + // the filter expects ContributionProfile values — same runtime objects. + const { kept, excluded } = filterCandidatesByProfiles( + fanOut.issues, + profilesByRepo as Map, + ); + const rankedSummary = rankIssues(kept, { + ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}), + ...(options.goalSpecsByRepo !== undefined ? { goalSpecsByRepo: options.goalSpecsByRepo } : {}), + ...(options.goalSpecContentByRepo !== undefined + ? { goalSpecContentByRepo: options.goalSpecContentByRepo } + : {}), + }); + const noopQueueStore = { enqueue: () => {} } as unknown as PortfolioQueueStore; + const enqueueSummary = enqueue(rankedSummary.issues, { queueStore: noopQueueStore }); + const result = { + outcome: "dry_run", + fanOutCount: fanOut.issues.length, + warnings: fanOut.warnings, + rateLimitRemaining: fanOut.rateLimitRemaining, + rateLimitResetAt: fanOut.rateLimitResetAt, + ranked: rankedSummary.issues, + excluded: excluded.map((entry) => ({ + repoFullName: entry.candidate.repoFullName, + issueNumber: entry.candidate.issueNumber, + reason: entry.reason, + })), + usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, + enqueueSummary, + }; + // Structured-outcome hook (#6522), mirroring runAttempt's onResult convention: fires only at a real + // structured success point (never the reportCliFailure branches), in addition to -- never instead of -- + // the plain exit-code return, so a non-CLI caller (the /api/discover route) can read the result. + // Dry-run result adds `outcome: "dry_run"` at runtime; DiscoverResult/.d.ts omits it — pre-existing drift. + options.onResult?.(result as DiscoverResult); + if (parsed.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(renderDiscoverSummary(result as DiscoverResult)); + console.log("\nDRY RUN: no portfolio-queue write was made."); + } + return 0; + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } + } + + const ownsPortfolioQueue = options.initPortfolioQueue === undefined; + let portfolioQueue: PortfolioQueueStore | undefined; + try { + portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } + + // Local ETag cache so a repeated discover revalidates each repo's policy docs with a conditional GET instead of + // re-downloading them (#4842). Opened inside its OWN try/catch, separate from the portfolio queue above: the + // queue is required infrastructure (discovery genuinely cannot enqueue anything without it, so a real open + // failure should abort the run), but the policy-doc cache is a pure performance optimization -- a corrupt or + // unwritable cache DB must degrade to "no cache" (every doc fetched in full, exactly as before #4842) rather + // than fail discovery outright. + let policyDocCache: PolicyDocCacheStore | null = null; + let ownsPolicyDocCache = false; + try { + ownsPolicyDocCache = options.initPolicyDocCache === undefined; + policyDocCache = (options.initPolicyDocCache ?? initPolicyDocCacheStore)(); + } catch { + policyDocCache = null; + ownsPolicyDocCache = false; + } + + // Persisted cache of resolved policy verdicts (#4843), same "own try/catch, degrade to null" discipline as the + // doc cache above and for the same reason: purely a performance optimization the feature is inert without, so a + // corrupt/unwritable cache DB must never abort a run. + let policyVerdictCache: PolicyVerdictCacheStore | null = null; + let ownsPolicyVerdictCache = false; + try { + ownsPolicyVerdictCache = options.initPolicyVerdictCache === undefined; + policyVerdictCache = (options.initPolicyVerdictCache ?? initPolicyVerdictCacheStore)(); + } catch { + policyVerdictCache = null; + ownsPolicyVerdictCache = false; + } + + // Snapshot of this run's full ranked output (#4859 prerequisite), so a local HTTP endpoint (and eventually the + // miner-ui/browser-extension live-fetch it's meant for) can serve the same per-issue breakdown `--json` prints, + // without the operator re-running discover or hand-pasting its output. Same "own try/catch, degrade to null" + // discipline as the two caches above: a corrupt/unwritable snapshot store must never abort discovery's actual + // job (fan out, rank, enqueue). Unlike the caches, this store is a WRITE target, not a read optimization -- the + // save call itself gets its own try/catch below for the same reason. + let rankedCandidatesStore: RankedCandidatesStore | null = null; + let ownsRankedCandidatesStore = false; + try { + ownsRankedCandidatesStore = options.initRankedCandidatesStore === undefined; + rankedCandidatesStore = (options.initRankedCandidatesStore ?? initRankedCandidatesStore)(); + } catch { + rankedCandidatesStore = null; + ownsRankedCandidatesStore = false; + } + const fanOutOptions = { + apiBaseUrl, + forge: options.forge, + policyDocCache, + policyVerdictCache, + } as FanoutOptions; + + try { + let fanOut = + parsed.search !== null + ? await searchTargets(parsed.search, githubToken, fanOutOptions) + : await fetchTargets(parsed.targets, githubToken, fanOutOptions); + fanOut = await supplementWithDiscoveryIndex(fanOut, discoveryQueryScope, options); + + // Eligibility filter (#6798): drop candidates a target repo's own conventions would reject, before ranking. + // A repo with no trustworthy eligibility profile keeps every candidate (filterCandidatesByProfiles' safe + // default), so this never silently skips real work on a repo whose conventions AMS couldn't read. + const repoFullNames = [...new Set(fanOut.issues.map((issue) => issue.repoFullName))]; + const profilesByRepo = await resolveProfiles(repoFullNames, { + githubToken, + ...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}), + ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}), + }); + // RunDiscoverOptions.resolveContributionProfiles is typed as Map (pre-existing .d.ts); + // the filter expects ContributionProfile values — same runtime objects. + const { kept, excluded } = filterCandidatesByProfiles( + fanOut.issues, + profilesByRepo as Map, + ); + + // Pass any caller-supplied per-tenant goal specs through to the ranker so lane fit uses the tenant's + // conventions instead of silently falling back to loopover's defaults (#4784); the fallback is surfaced via + // `usedDefaultGoalSpec` below rather than hidden. + const rankedSummary = rankIssues(kept, { + ...(options.nowMs !== undefined ? { nowMs: options.nowMs } : {}), + ...(options.goalSpecsByRepo !== undefined ? { goalSpecsByRepo: options.goalSpecsByRepo } : {}), + ...(options.goalSpecContentByRepo !== undefined + ? { goalSpecContentByRepo: options.goalSpecContentByRepo } + : {}), + }); + const enqueueSummary = enqueue(rankedSummary.issues, { + queueStore: portfolioQueue, + ...(apiBaseUrl !== undefined ? { apiBaseUrl } : {}), + }); + + try { + // Optional chaining rather than an `if (rankedCandidatesStore)` guard: a null store (open failed above) + // short-circuits to a no-op read, so the same try/catch below also covers the open-failed case without a + // second explicit branch. + rankedCandidatesStore?.saveRankedCandidates(rankedSummary.issues, options.nowMs); + } catch { + // Non-fatal: the ranked-candidates snapshot is a nice-to-have for the local HTTP endpoint, not a + // requirement for discover's own job (fan out, rank, enqueue), which already succeeded above. + } + + const result = { + fanOutCount: fanOut.issues.length, + warnings: fanOut.warnings, + rateLimitRemaining: fanOut.rateLimitRemaining, + rateLimitResetAt: fanOut.rateLimitResetAt, + ranked: rankedSummary.issues, + // #6798: candidates the eligibility filter dropped, each with the repo + issue + reason, so a human sees + // what AMS inferred and why a candidate was skipped. Empty when no profile was trustworthy enough to filter. + excluded: excluded.map((entry) => ({ + repoFullName: entry.candidate.repoFullName, + issueNumber: entry.candidate.issueNumber, + reason: entry.reason, + })), + usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec, + enqueueSummary, + }; + + // Structured-outcome hook (#6522) for the full-run success point -- same convention as the dry-run branch + // above and as runAttempt's onResult: real result only, additive to the unchanged exit-code return. + options.onResult?.(result); + if (parsed.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(renderDiscoverSummary(result)); + } + return 0; + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } finally { + if (ownsPortfolioQueue && portfolioQueue) portfolioQueue.close(); + if (ownsPolicyDocCache && policyDocCache) policyDocCache.close(); + if (ownsPolicyVerdictCache && policyVerdictCache) policyVerdictCache.close(); + if (ownsRankedCandidatesStore && rankedCandidatesStore) rankedCandidatesStore.close(); + } +} diff --git a/packages/loopover-miner/lib/live-issue-snapshot.d.ts b/packages/loopover-miner/lib/live-issue-snapshot.d.ts index a18165ebbc..f6ba293b4f 100644 --- a/packages/loopover-miner/lib/live-issue-snapshot.d.ts +++ b/packages/loopover-miner/lib/live-issue-snapshot.d.ts @@ -1,16 +1,24 @@ import type { LiveIssueSnapshot } from "./submission-freshness-check.js"; - -// A narrower shape than `typeof fetch` on purpose: this module only ever calls it with a string URL and a -// plain POST init, and the ambient `fetch` type in this repo's TS program is Cloudflare-Workers-flavored -// (RequestInfo | URL), which is both irrelevant here (this package runs under plain Node) and -// stricter than any real caller needs. -export type LiveIssueSnapshotFetch = ( - url: string, - init: { method: string; headers: Record; body: string }, -) => Promise<{ ok: boolean; status: number; json: () => Promise }>; - -export function fetchLiveIssueSnapshot( - repoFullName: string, - issueNumber: number, - options?: { githubToken?: string; graphqlUrl?: string; fetchImpl?: LiveIssueSnapshotFetch; requestTimeoutMs?: number }, -): Promise; +export type LiveIssueSnapshotFetch = (url: string, init: { + method: string; + headers: Record; + body: string; +}) => Promise<{ + ok: boolean; + status: number; + json: () => Promise; +}>; +type LiveIssueSnapshotOptions = { + githubToken?: string; + graphqlUrl?: string; + fetchImpl?: LiveIssueSnapshotFetch; + requestTimeoutMs?: number; +}; +/** + * Real fetchLiveIssueSnapshot implementation: the live-state answer AttemptDeps/SubmissionFreshnessDeps + * need, built from a single GraphQL round-trip. Returns null on any malformed input, transport failure, or + * unrecognized GitHub response -- callers already treat a null snapshot as "state unavailable", so this + * never throws. + */ +export declare function fetchLiveIssueSnapshot(repoFullName: string, issueNumber: number, options?: LiveIssueSnapshotOptions): Promise; +export {}; diff --git a/packages/loopover-miner/lib/live-issue-snapshot.js b/packages/loopover-miner/lib/live-issue-snapshot.js index cb2257baab..bf2c5c9c5b 100644 --- a/packages/loopover-miner/lib/live-issue-snapshot.js +++ b/packages/loopover-miner/lib/live-issue-snapshot.js @@ -6,12 +6,10 @@ // own authoritative, closing-keyword-aware answer to "which PRs will close this issue" -- the same signal // the platform itself uses to auto-close on merge, not a regex we'd have to keep in sync with GitHub's own // closing-keyword parsing. - const DEFAULT_GRAPHQL_URL = "https://api.github.com/graphql"; const GITHUB_API_VERSION = "2022-11-28"; const MAX_REFERENCING_PRS = 50; const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; - const LIVE_ISSUE_SNAPSHOT_QUERY = ` query($owner: String!, $repo: String!, $number: Int!, $maxPrs: Int!) { repository(owner: $owner, name: $repo) { @@ -29,96 +27,98 @@ const LIVE_ISSUE_SNAPSHOT_QUERY = ` } } `; - function githubGraphqlHeaders(githubToken) { - const headers = { - accept: "application/vnd.github+json", - "content-type": "application/json", - "user-agent": "loopover-miner", - "x-github-api-version": GITHUB_API_VERSION, - }; - const token = typeof githubToken === "string" ? githubToken.trim() : ""; - if (token) headers.authorization = `Bearer ${token}`; - return headers; + const headers = { + accept: "application/vnd.github+json", + "content-type": "application/json", + "user-agent": "loopover-miner", + "x-github-api-version": GITHUB_API_VERSION, + }; + const token = typeof githubToken === "string" ? githubToken.trim() : ""; + if (token) + headers.authorization = `Bearer ${token}`; + return headers; } - function normalizeIssueOrPrState(rawState) { - return typeof rawState === "string" ? rawState.toLowerCase() : ""; + return typeof rawState === "string" ? rawState.toLowerCase() : ""; } - function normalizeReferencingPr(node) { - if (!node || typeof node !== "object") return null; - if (!Number.isInteger(node.number) || node.number <= 0) return null; - const state = normalizeIssueOrPrState(node.state); - if (state !== "open" && state !== "closed" && state !== "merged") return null; - const authorLogin = typeof node.author?.login === "string" ? node.author.login : ""; - // GitHub's real PR creation timestamp (ISO 8601), when present -- null otherwise (never fabricated). Not - // an ordering signal for the maintainer gate's own duplicate-cluster election (duplicate-winner.ts's own - // doc explains why: a PR can be backdated by editing an old placeholder to add the linked issue later), but - // it's the only real, publicly-observable claim-time proxy claim-conflict-resolver.js's own client-side - // caller has for a THIRD-PARTY PR -- unlike loopover's own server, the miner has no continuous observation - // history to derive a true "first linked" timestamp from. - const createdAt = typeof node.createdAt === "string" ? node.createdAt : null; - return { number: node.number, state, authorLogin, createdAt }; + if (!node || typeof node !== "object") + return null; + const record = node; + if (!Number.isInteger(record.number) || record.number <= 0) + return null; + const state = normalizeIssueOrPrState(record.state); + if (state !== "open" && state !== "closed" && state !== "merged") + return null; + const authorLogin = typeof record.author?.login === "string" ? record.author.login : ""; + // GitHub's real PR creation timestamp (ISO 8601), when present -- null otherwise (never fabricated). Not + // an ordering signal for the maintainer gate's own duplicate-cluster election (duplicate-winner.ts's own + // doc explains why: a PR can be backdated by editing an old placeholder to add the linked issue later), but + // it's the only real, publicly-observable claim-time proxy claim-conflict-resolver.js's own client-side + // caller has for a THIRD-PARTY PR -- unlike loopover's own server, the miner has no continuous observation + // history to derive a true "first linked" timestamp from. + const createdAt = typeof record.createdAt === "string" ? record.createdAt : null; + return { number: record.number, state, authorLogin, createdAt }; } - function parseRepoFullName(repoFullName) { - if (typeof repoFullName !== "string") return null; - const [owner, repo, extra] = repoFullName.split("/"); - if (!owner || !repo || extra !== undefined) return null; - return { owner, repo }; + if (typeof repoFullName !== "string") + return null; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) + return null; + return { owner, repo }; } - /** * Real fetchLiveIssueSnapshot implementation: the live-state answer AttemptDeps/SubmissionFreshnessDeps * need, built from a single GraphQL round-trip. Returns null on any malformed input, transport failure, or * unrecognized GitHub response -- callers already treat a null snapshot as "state unavailable", so this * never throws. - * - * @param {string} repoFullName - * @param {number} issueNumber - * @param {{ githubToken?: string, graphqlUrl?: string, fetchImpl?: typeof fetch, requestTimeoutMs?: number }} [options] - * @returns {Promise} */ export async function fetchLiveIssueSnapshot(repoFullName, issueNumber, options = {}) { - const target = parseRepoFullName(repoFullName); - if (!target || !Number.isInteger(issueNumber) || issueNumber <= 0) return null; - - const graphqlUrl = - typeof options.graphqlUrl === "string" && options.graphqlUrl.trim() ? options.graphqlUrl.trim() : DEFAULT_GRAPHQL_URL; - const githubToken = options.githubToken ?? process.env.GITHUB_TOKEN ?? ""; - const fetchImpl = options.fetchImpl ?? fetch; - const requestTimeoutMs = Number.isInteger(options.requestTimeoutMs) && options.requestTimeoutMs > 0 ? options.requestTimeoutMs : DEFAULT_REQUEST_TIMEOUT_MS; - - // Bounded so a stalled connection can't hang this "never throws" fetcher forever (#miner-github-read-timeouts): - // a timeout falls into the SAME catch as any other transport failure, which the caller (checkSubmissionFreshness) - // already treats as "live_state_unavailable" -- a fail-closed abort distinct from "issue_closed"/"already_addressed", - // never confused with a confirmed-gone issue. - let response; - try { - response = await fetchImpl(graphqlUrl, { - method: "POST", - headers: githubGraphqlHeaders(githubToken), - body: JSON.stringify({ - query: LIVE_ISSUE_SNAPSHOT_QUERY, - variables: { owner: target.owner, repo: target.repo, number: issueNumber, maxPrs: MAX_REFERENCING_PRS }, - }), - signal: AbortSignal.timeout(requestTimeoutMs), - }); - } catch { - return null; - } - if (!response.ok) return null; - - const payload = await response.json().catch(() => null); - if (!payload || typeof payload !== "object" || payload.errors) return null; - - const issue = payload.data?.repository?.issue; - const state = normalizeIssueOrPrState(issue?.state); - if (state !== "open" && state !== "closed") return null; - - const nodes = Array.isArray(issue?.closedByPullRequestsReferences?.nodes) ? issue.closedByPullRequestsReferences.nodes : []; - const referencingPrs = nodes.map(normalizeReferencingPr).filter((pr) => pr !== null); - - return { state, referencingPrs }; + const target = parseRepoFullName(repoFullName); + if (!target || !Number.isInteger(issueNumber) || issueNumber <= 0) + return null; + const graphqlUrl = typeof options.graphqlUrl === "string" && options.graphqlUrl.trim() ? options.graphqlUrl.trim() : DEFAULT_GRAPHQL_URL; + const githubToken = options.githubToken ?? process.env.GITHUB_TOKEN ?? ""; + // Cast: ambient `fetch` is CF-Workers-flavored; the public inject seam is the narrower LiveIssueSnapshotFetch. + const fetchImpl = (options.fetchImpl ?? fetch); + const requestTimeoutMs = Number.isInteger(options.requestTimeoutMs) && options.requestTimeoutMs > 0 + ? options.requestTimeoutMs + : DEFAULT_REQUEST_TIMEOUT_MS; + // Bounded so a stalled connection can't hang this "never throws" fetcher forever (#miner-github-read-timeouts): + // a timeout falls into the SAME catch as any other transport failure, which the caller (checkSubmissionFreshness) + // already treats as "live_state_unavailable" -- a fail-closed abort distinct from "issue_closed"/"already_addressed", + // never confused with a confirmed-gone issue. + let response; + try { + // Cast: runtime always passes `signal`; the public LiveIssueSnapshotFetch init omits it (mock-friendly). + response = await fetchImpl(graphqlUrl, { + method: "POST", + headers: githubGraphqlHeaders(githubToken), + body: JSON.stringify({ + query: LIVE_ISSUE_SNAPSHOT_QUERY, + variables: { owner: target.owner, repo: target.repo, number: issueNumber, maxPrs: MAX_REFERENCING_PRS }, + }), + signal: AbortSignal.timeout(requestTimeoutMs), + }); + } + catch { + return null; + } + if (!response.ok) + return null; + const payload = await response.json().catch(() => null); + if (!payload || typeof payload !== "object" || payload.errors) + return null; + const issue = payload.data?.repository?.issue; + const state = normalizeIssueOrPrState(issue?.state); + if (state !== "open" && state !== "closed") + return null; + const nodes = Array.isArray(issue?.closedByPullRequestsReferences?.nodes) + ? issue.closedByPullRequestsReferences.nodes + : []; + const referencingPrs = nodes.map(normalizeReferencingPr).filter((pr) => pr !== null); + return { state, referencingPrs }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibGl2ZS1pc3N1ZS1zbmFwc2hvdC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImxpdmUtaXNzdWUtc25hcHNob3QudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsc0dBQXNHO0FBQ3RHLHVHQUF1RztBQUN2RyxpR0FBaUc7QUFDakcsZ0ZBQWdGO0FBQ2hGLDBHQUEwRztBQUMxRywwR0FBMEc7QUFDMUcsMkdBQTJHO0FBQzNHLDJCQUEyQjtBQUkzQixNQUFNLG1CQUFtQixHQUFHLGdDQUFnQyxDQUFDO0FBQzdELE1BQU0sa0JBQWtCLEdBQUcsWUFBWSxDQUFDO0FBQ3hDLE1BQU0sbUJBQW1CLEdBQUcsRUFBRSxDQUFDO0FBQy9CLE1BQU0sMEJBQTBCLEdBQUcsTUFBTSxDQUFDO0FBRTFDLE1BQU0seUJBQXlCLEdBQUc7Ozs7Ozs7Ozs7Ozs7Ozs7Q0FnQmpDLENBQUM7QUFvQkYsU0FBUyxvQkFBb0IsQ0FBQyxXQUFvQjtJQUNoRCxNQUFNLE9BQU8sR0FBMkI7UUFDdEMsTUFBTSxFQUFFLDZCQUE2QjtRQUNyQyxjQUFjLEVBQUUsa0JBQWtCO1FBQ2xDLFlBQVksRUFBRSxnQkFBZ0I7UUFDOUIsc0JBQXNCLEVBQUUsa0JBQWtCO0tBQzNDLENBQUM7SUFDRixNQUFNLEtBQUssR0FBRyxPQUFPLFdBQVcsS0FBSyxRQUFRLENBQUMsQ0FBQyxDQUFDLFdBQVcsQ0FBQyxJQUFJLEVBQUUsQ0FBQyxDQUFDLENBQUMsRUFBRSxDQUFDO0lBQ3hFLElBQUksS0FBSztRQUFFLE9BQU8sQ0FBQyxhQUFhLEdBQUcsVUFBVSxLQUFLLEVBQUUsQ0FBQztJQUNyRCxPQUFPLE9BQU8sQ0FBQztBQUNqQixDQUFDO0FBRUQsU0FBUyx1QkFBdUIsQ0FBQyxRQUFpQjtJQUNoRCxPQUFPLE9BQU8sUUFBUSxLQUFLLFFBQVEsQ0FBQyxDQUFDLENBQUMsUUFBUSxDQUFDLFdBQVcsRUFBRSxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUM7QUFDcEUsQ0FBQztBQUVELFNBQVMsc0JB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/live-issue-snapshot.ts b/packages/loopover-miner/lib/live-issue-snapshot.ts new file mode 100644 index 0000000000..2dac64bf9b --- /dev/null +++ b/packages/loopover-miner/lib/live-issue-snapshot.ts @@ -0,0 +1,160 @@ +// Real GitHub-backed fetchLiveIssueSnapshot (#5132, Wave 3.5). AttemptDeps.fetchLiveIssueSnapshot and +// SubmissionFreshnessDeps.fetchLiveIssueSnapshot (submission-freshness-check.js) share this one shape: +// "is this issue still open, and is it already addressed by another PR" -- the live-state answer +// checkSubmissionFreshness needs before every submission. Uses GitHub's GraphQL +// `closedByPullRequestsReferences` connection rather than a body-text/search-API heuristic: it's GitHub's +// own authoritative, closing-keyword-aware answer to "which PRs will close this issue" -- the same signal +// the platform itself uses to auto-close on merge, not a regex we'd have to keep in sync with GitHub's own +// closing-keyword parsing. + +import type { LiveIssueSnapshot } from "./submission-freshness-check.js"; + +const DEFAULT_GRAPHQL_URL = "https://api.github.com/graphql"; +const GITHUB_API_VERSION = "2022-11-28"; +const MAX_REFERENCING_PRS = 50; +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; + +const LIVE_ISSUE_SNAPSHOT_QUERY = ` + query($owner: String!, $repo: String!, $number: Int!, $maxPrs: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + state + closedByPullRequestsReferences(first: $maxPrs) { + nodes { + number + state + author { login } + createdAt + } + } + } + } + } +`; + +// A narrower shape than `typeof fetch` on purpose: this module only ever calls it with a string URL and a +// plain POST init, and the ambient `fetch` type in this repo's TS program is Cloudflare-Workers-flavored +// (RequestInfo | URL), which is both irrelevant here (this package runs under plain Node) and +// stricter than any real caller needs. +export type LiveIssueSnapshotFetch = ( + url: string, + init: { method: string; headers: Record; body: string }, +) => Promise<{ ok: boolean; status: number; json: () => Promise }>; + +type LiveIssueSnapshotOptions = { + githubToken?: string; + graphqlUrl?: string; + fetchImpl?: LiveIssueSnapshotFetch; + requestTimeoutMs?: number; +}; + +type ReferencingPr = LiveIssueSnapshot["referencingPrs"][number]; + +function githubGraphqlHeaders(githubToken: unknown): Record { + const headers: Record = { + accept: "application/vnd.github+json", + "content-type": "application/json", + "user-agent": "loopover-miner", + "x-github-api-version": GITHUB_API_VERSION, + }; + const token = typeof githubToken === "string" ? githubToken.trim() : ""; + if (token) headers.authorization = `Bearer ${token}`; + return headers; +} + +function normalizeIssueOrPrState(rawState: unknown): string { + return typeof rawState === "string" ? rawState.toLowerCase() : ""; +} + +function normalizeReferencingPr(node: unknown): ReferencingPr | null { + if (!node || typeof node !== "object") return null; + const record = node as { + number?: unknown; + state?: unknown; + author?: { login?: unknown }; + createdAt?: unknown; + }; + if (!Number.isInteger(record.number) || (record.number as number) <= 0) return null; + const state = normalizeIssueOrPrState(record.state); + if (state !== "open" && state !== "closed" && state !== "merged") return null; + const authorLogin = typeof record.author?.login === "string" ? record.author.login : ""; + // GitHub's real PR creation timestamp (ISO 8601), when present -- null otherwise (never fabricated). Not + // an ordering signal for the maintainer gate's own duplicate-cluster election (duplicate-winner.ts's own + // doc explains why: a PR can be backdated by editing an old placeholder to add the linked issue later), but + // it's the only real, publicly-observable claim-time proxy claim-conflict-resolver.js's own client-side + // caller has for a THIRD-PARTY PR -- unlike loopover's own server, the miner has no continuous observation + // history to derive a true "first linked" timestamp from. + const createdAt = typeof record.createdAt === "string" ? record.createdAt : null; + return { number: record.number as number, state, authorLogin, createdAt }; +} + +function parseRepoFullName(repoFullName: unknown): { owner: string; repo: string } | null { + if (typeof repoFullName !== "string") return null; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) return null; + return { owner, repo }; +} + +/** + * Real fetchLiveIssueSnapshot implementation: the live-state answer AttemptDeps/SubmissionFreshnessDeps + * need, built from a single GraphQL round-trip. Returns null on any malformed input, transport failure, or + * unrecognized GitHub response -- callers already treat a null snapshot as "state unavailable", so this + * never throws. + */ +export async function fetchLiveIssueSnapshot( + repoFullName: string, + issueNumber: number, + options: LiveIssueSnapshotOptions = {}, +): Promise { + const target = parseRepoFullName(repoFullName); + if (!target || !Number.isInteger(issueNumber) || issueNumber <= 0) return null; + + const graphqlUrl = + typeof options.graphqlUrl === "string" && options.graphqlUrl.trim() ? options.graphqlUrl.trim() : DEFAULT_GRAPHQL_URL; + const githubToken = options.githubToken ?? process.env.GITHUB_TOKEN ?? ""; + // Cast: ambient `fetch` is CF-Workers-flavored; the public inject seam is the narrower LiveIssueSnapshotFetch. + const fetchImpl = (options.fetchImpl ?? fetch) as LiveIssueSnapshotFetch; + const requestTimeoutMs = Number.isInteger(options.requestTimeoutMs) && (options.requestTimeoutMs as number) > 0 + ? (options.requestTimeoutMs as number) + : DEFAULT_REQUEST_TIMEOUT_MS; + + // Bounded so a stalled connection can't hang this "never throws" fetcher forever (#miner-github-read-timeouts): + // a timeout falls into the SAME catch as any other transport failure, which the caller (checkSubmissionFreshness) + // already treats as "live_state_unavailable" -- a fail-closed abort distinct from "issue_closed"/"already_addressed", + // never confused with a confirmed-gone issue. + let response: Awaited>; + try { + // Cast: runtime always passes `signal`; the public LiveIssueSnapshotFetch init omits it (mock-friendly). + response = await fetchImpl(graphqlUrl, { + method: "POST", + headers: githubGraphqlHeaders(githubToken), + body: JSON.stringify({ + query: LIVE_ISSUE_SNAPSHOT_QUERY, + variables: { owner: target.owner, repo: target.repo, number: issueNumber, maxPrs: MAX_REFERENCING_PRS }, + }), + signal: AbortSignal.timeout(requestTimeoutMs), + } as { method: string; headers: Record; body: string }); + } catch { + return null; + } + if (!response.ok) return null; + + const payload = await response.json().catch(() => null); + if (!payload || typeof payload !== "object" || (payload as { errors?: unknown }).errors) return null; + + const issue = (payload as { + data?: { repository?: { issue?: { + state?: unknown; + closedByPullRequestsReferences?: { nodes?: unknown }; + } } }; + }).data?.repository?.issue; + const state = normalizeIssueOrPrState(issue?.state); + if (state !== "open" && state !== "closed") return null; + + const nodes = Array.isArray(issue?.closedByPullRequestsReferences?.nodes) + ? issue.closedByPullRequestsReferences.nodes + : []; + const referencingPrs = nodes.map(normalizeReferencingPr).filter((pr): pr is ReferencingPr => pr !== null); + + return { state, referencingPrs }; +} diff --git a/packages/loopover-miner/lib/logger.d.ts b/packages/loopover-miner/lib/logger.d.ts index 37ab973f2a..e0c89f84df 100644 --- a/packages/loopover-miner/lib/logger.d.ts +++ b/packages/loopover-miner/lib/logger.d.ts @@ -1,58 +1,89 @@ export type LogLevel = "silent" | "error" | "warn" | "info" | "debug"; - -export const LOG_LEVELS: readonly LogLevel[]; -export const DEFAULT_LOG_LEVEL: LogLevel; - -export function isLogLevel(value: unknown): value is LogLevel; - -export function resolveLogLevel(signals?: { - level?: string | undefined; - quiet?: boolean | undefined; - verbose?: boolean | undefined; - envLevel?: string | undefined; -}): LogLevel; - -export function extractLogOptions(argv: string[]): { - options: { quiet: boolean; verbose: boolean; level: string | undefined }; - rest: string[]; -}; - -export function formatFields(fields?: Record | null | undefined): string; - -export function formatLine(line: { - level: string; - message: string; - fields?: Record | null | undefined; - pretty?: boolean | undefined; - timestamp?: string | undefined; -}): string; - +/** Supported log levels, least to most verbose. `silent` suppresses everything. */ +export declare const LOG_LEVELS: readonly LogLevel[]; +/** The level used when nothing (flag, env var, or explicit option) selects one. */ +export declare const DEFAULT_LOG_LEVEL: LogLevel; export interface LoggerStreams { - stdout?: { write(chunk: string): unknown } | undefined; - stderr?: { write(chunk: string): unknown } | undefined; + stdout?: { + write(chunk: string): unknown; + } | undefined; + stderr?: { + write(chunk: string): unknown; + } | undefined; } - export interface LoggerOptions { - level?: string | undefined; - quiet?: boolean | undefined; - verbose?: boolean | undefined; - pretty?: boolean | undefined; - fields?: Record | undefined; - env?: Record | undefined; - streams?: LoggerStreams | undefined; - now?: (() => string) | undefined; + level?: string | undefined; + quiet?: boolean | undefined; + verbose?: boolean | undefined; + pretty?: boolean | undefined; + fields?: Record | undefined; + env?: Record | undefined; + streams?: LoggerStreams | undefined; + now?: (() => string) | undefined; } - export interface Logger { - level: LogLevel; - isLevelEnabled(level: string): boolean; - error(message: string, fields?: Record): void; - warn(message: string, fields?: Record): void; - info(message: string, fields?: Record): void; - debug(message: string, fields?: Record): void; - child(fields: Record): Logger; + level: LogLevel; + isLevelEnabled(level: string): boolean; + error(message: string, fields?: Record): void; + warn(message: string, fields?: Record): void; + info(message: string, fields?: Record): void; + debug(message: string, fields?: Record): void; + child(fields: Record): Logger; } - -export function createLogger(options?: LoggerOptions): Logger; -export function configureLogger(options?: LoggerOptions): Logger; -export function getLogger(): Logger; +/** True when `value` names a supported log level. Non-string input is never a level (so an absent option or a + * typo'd env var falls through to the next signal instead of throwing). */ +export declare function isLogLevel(value: unknown): value is LogLevel; +/** + * Resolve the active level from the available signals, most explicit first: an explicit `level` wins, then + * `--quiet` (→ `error`), then `--verbose` (→ `debug`), then the env-provided level, else the default. `quiet` + * beats `verbose` when both are set, so the safer/quieter choice wins a contradictory invocation. An + * unrecognized `level`/`envLevel` is ignored rather than throwing — a typo logs at the default, never crashes. + */ +export declare function resolveLogLevel({ level, quiet, verbose, envLevel, }?: { + level?: string | undefined; + quiet?: boolean | undefined; + verbose?: boolean | undefined; + envLevel?: string | undefined; +}): LogLevel; +/** + * Split the global logging flags out of a CLI argv slice, returning the parsed options plus `rest` — the argv + * with those flags (and any `--log-level` value) removed so downstream command parsing never sees them. + * Recognizes `--quiet`, `--verbose`, `--log-level `, and `--log-level=`. No short aliases: `-v` + * is already `--version` and `-h` is `--help` in the CLI entrypoint. + */ +export declare function extractLogOptions(argv: string[]): { + options: { + quiet: boolean; + verbose: boolean; + level: string | undefined; + }; + rest: string[]; +}; +/** + * Render structured fields as a stable, sorted ` key=value` suffix (sorted so output is deterministic across + * runs). `undefined` values are dropped; an empty/absent field set yields an empty string. + */ +export declare function formatFields(fields?: Record | null | undefined): string; +/** + * Format one log line. Plain mode (the default) is just `message` + any field suffix, keeping human CLI output + * identical to a bare `console.log`. Pretty mode prefixes an optional timestamp and the uppercased level tag, + * for operators who want machine-scannable diagnostics. + */ +export declare function formatLine({ level, message, fields, pretty, timestamp, }: { + level: string; + message: string; + fields?: Record | null | undefined; + pretty?: boolean | undefined; + timestamp?: string | undefined; +}): string; +/** + * Build a level-aware logger. All I/O is injectable for tests: `streams` (defaults to process stdout/stderr), + * `now` (defaults to an ISO-8601 clock, only consulted in `pretty` mode), and `env` (defaults to process.env, + * read for `LOOPOVER_MINER_LOG_LEVEL`). `fields` seeds every line with contextual fields; `child(extra)` + * returns a logger that merges additional fields onto this one. + */ +export declare function createLogger(options?: LoggerOptions): Logger; +/** Reconfigure the process-wide logger from resolved startup options and return it. */ +export declare function configureLogger(options?: LoggerOptions): Logger; +/** The process-wide logger configured by `configureLogger` (a default-level logger before then). */ +export declare function getLogger(): Logger; diff --git a/packages/loopover-miner/lib/logger.js b/packages/loopover-miner/lib/logger.js index 33c389019a..af3c1e76d1 100644 --- a/packages/loopover-miner/lib/logger.js +++ b/packages/loopover-miner/lib/logger.js @@ -8,160 +8,146 @@ // or below L's rank (so `error` always survives except at `silent`, and `debug` only shows at the most verbose // setting). `error`/`warn` go to stderr, `info`/`debug` to stdout, matching the existing convention where the // update-check nudge writes to stderr and normal command output writes to stdout. - /** Supported log levels, least to most verbose. `silent` suppresses everything. */ export const LOG_LEVELS = ["silent", "error", "warn", "info", "debug"]; - /** The level used when nothing (flag, env var, or explicit option) selects one. */ export const DEFAULT_LOG_LEVEL = "info"; - // Numeric severity rank per level (higher = more verbose). A method emits when its rank <= the active rank. const LEVEL_RANK = { silent: 0, error: 1, warn: 2, info: 3, debug: 4 }; - const defaultClock = () => new Date().toISOString(); - /** True when `value` names a supported log level. Non-string input is never a level (so an absent option or a * typo'd env var falls through to the next signal instead of throwing). */ export function isLogLevel(value) { - return typeof value === "string" && Object.prototype.hasOwnProperty.call(LEVEL_RANK, value); + return typeof value === "string" && Object.prototype.hasOwnProperty.call(LEVEL_RANK, value); } - /** * Resolve the active level from the available signals, most explicit first: an explicit `level` wins, then * `--quiet` (→ `error`), then `--verbose` (→ `debug`), then the env-provided level, else the default. `quiet` * beats `verbose` when both are set, so the safer/quieter choice wins a contradictory invocation. An * unrecognized `level`/`envLevel` is ignored rather than throwing — a typo logs at the default, never crashes. - * @param {{ level?: string, quiet?: boolean, verbose?: boolean, envLevel?: string }} [signals] - * @returns {string} */ -export function resolveLogLevel({ level, quiet = false, verbose = false, envLevel } = {}) { - if (isLogLevel(level)) return level; - if (quiet) return "error"; - if (verbose) return "debug"; - if (isLogLevel(envLevel)) return envLevel; - return DEFAULT_LOG_LEVEL; +export function resolveLogLevel({ level, quiet = false, verbose = false, envLevel, } = {}) { + if (isLogLevel(level)) + return level; + if (quiet) + return "error"; + if (verbose) + return "debug"; + if (isLogLevel(envLevel)) + return envLevel; + return DEFAULT_LOG_LEVEL; } - /** * Split the global logging flags out of a CLI argv slice, returning the parsed options plus `rest` — the argv * with those flags (and any `--log-level` value) removed so downstream command parsing never sees them. * Recognizes `--quiet`, `--verbose`, `--log-level `, and `--log-level=`. No short aliases: `-v` * is already `--version` and `-h` is `--help` in the CLI entrypoint. - * @param {string[]} argv - * @returns {{ options: { quiet: boolean, verbose: boolean, level: string | undefined }, rest: string[] }} */ export function extractLogOptions(argv) { - let quiet = false; - let verbose = false; - let level; - const rest = []; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === "--quiet") { - quiet = true; - continue; + let quiet = false; + let verbose = false; + let level; + const rest = []; + for (let index = 0; index < argv.length; index += 1) { + // noUncheckedIndexedAccess: in-bounds access is always defined at runtime. + const arg = argv[index]; + if (arg === "--quiet") { + quiet = true; + continue; + } + if (arg === "--verbose") { + verbose = true; + continue; + } + if (arg === "--log-level") { + level = argv[index + 1]; + index += 1; + continue; + } + if (arg.startsWith("--log-level=")) { + level = arg.slice("--log-level=".length); + continue; + } + rest.push(arg); } - if (arg === "--verbose") { - verbose = true; - continue; - } - if (arg === "--log-level") { - level = argv[index + 1]; - index += 1; - continue; - } - if (arg.startsWith("--log-level=")) { - level = arg.slice("--log-level=".length); - continue; - } - rest.push(arg); - } - return { options: { quiet, verbose, level }, rest }; + return { options: { quiet, verbose, level }, rest }; } - function formatFieldValue(value) { - // Quote a string only when it contains whitespace (so it stays one token); serialize everything else as JSON. - if (typeof value === "string") return /\s/.test(value) ? JSON.stringify(value) : value; - return JSON.stringify(value); + // Quote a string only when it contains whitespace (so it stays one token); serialize everything else as JSON. + if (typeof value === "string") + return /\s/.test(value) ? JSON.stringify(value) : value; + return JSON.stringify(value); } - /** * Render structured fields as a stable, sorted ` key=value` suffix (sorted so output is deterministic across * runs). `undefined` values are dropped; an empty/absent field set yields an empty string. - * @param {Record | null | undefined} fields - * @returns {string} */ export function formatFields(fields) { - if (!fields) return ""; - const parts = []; - for (const key of Object.keys(fields).sort()) { - const value = fields[key]; - if (value === undefined) continue; - parts.push(`${key}=${formatFieldValue(value)}`); - } - return parts.length > 0 ? ` ${parts.join(" ")}` : ""; + if (!fields) + return ""; + const parts = []; + for (const key of Object.keys(fields).sort()) { + const value = fields[key]; + if (value === undefined) + continue; + parts.push(`${key}=${formatFieldValue(value)}`); + } + return parts.length > 0 ? ` ${parts.join(" ")}` : ""; } - /** * Format one log line. Plain mode (the default) is just `message` + any field suffix, keeping human CLI output * identical to a bare `console.log`. Pretty mode prefixes an optional timestamp and the uppercased level tag, * for operators who want machine-scannable diagnostics. - * @param {{ level: string, message: string, fields?: Record | null, pretty?: boolean, timestamp?: string }} line - * @returns {string} */ -export function formatLine({ level, message, fields, pretty, timestamp }) { - const suffix = formatFields(fields); - if (!pretty) return `${message}${suffix}`; - const stamp = timestamp ? `[${timestamp}] ` : ""; - return `${stamp}${level.toUpperCase()} ${message}${suffix}`; +export function formatLine({ level, message, fields, pretty, timestamp, }) { + const suffix = formatFields(fields); + if (!pretty) + return `${message}${suffix}`; + const stamp = timestamp ? `[${timestamp}] ` : ""; + return `${stamp}${level.toUpperCase()} ${message}${suffix}`; } - /** * Build a level-aware logger. All I/O is injectable for tests: `streams` (defaults to process stdout/stderr), * `now` (defaults to an ISO-8601 clock, only consulted in `pretty` mode), and `env` (defaults to process.env, * read for `LOOPOVER_MINER_LOG_LEVEL`). `fields` seeds every line with contextual fields; `child(extra)` * returns a logger that merges additional fields onto this one. - * @param {import("./logger.js").LoggerOptions} [options] - * @returns {import("./logger.js").Logger} */ export function createLogger(options = {}) { - const { level, quiet, verbose, pretty = false, fields: baseFields, env = process.env, streams, now } = options; - const stdout = streams?.stdout ?? process.stdout; - const stderr = streams?.stderr ?? process.stderr; - const clock = now ?? defaultClock; - const envLevel = env.LOOPOVER_MINER_LOG_LEVEL ?? ""; - const activeLevel = resolveLogLevel({ level, quiet, verbose, envLevel }); - const threshold = LEVEL_RANK[activeLevel]; - - function emit(methodLevel, stream, message, fields) { - if (LEVEL_RANK[methodLevel] > threshold) return; - const merged = baseFields || fields ? { ...baseFields, ...fields } : undefined; - const timestamp = pretty ? clock() : undefined; - stream.write(`${formatLine({ level: methodLevel, message, fields: merged, pretty, timestamp })}\n`); - } - - return { - level: activeLevel, - isLevelEnabled: (methodLevel) => LEVEL_RANK[methodLevel] <= threshold, - error: (message, fields) => emit("error", stderr, message, fields), - warn: (message, fields) => emit("warn", stderr, message, fields), - info: (message, fields) => emit("info", stdout, message, fields), - debug: (message, fields) => emit("debug", stdout, message, fields), - child: (childFields) => createLogger({ ...options, fields: { ...baseFields, ...childFields } }), - }; + const { level, quiet, verbose, pretty = false, fields: baseFields, env = process.env, streams, now } = options; + const stdout = streams?.stdout ?? process.stdout; + const stderr = streams?.stderr ?? process.stderr; + const clock = now ?? defaultClock; + const envLevel = env.LOOPOVER_MINER_LOG_LEVEL ?? ""; + const activeLevel = resolveLogLevel({ level, quiet, verbose, envLevel }); + const threshold = LEVEL_RANK[activeLevel]; + function emit(methodLevel, stream, message, fields) { + if (LEVEL_RANK[methodLevel] > threshold) + return; + const merged = baseFields || fields ? { ...baseFields, ...fields } : undefined; + const timestamp = pretty ? clock() : undefined; + stream.write(`${formatLine({ level: methodLevel, message, fields: merged, pretty, timestamp })}\n`); + } + return { + level: activeLevel, + // Cast: public API takes `string`; unknown levels are undefined in LEVEL_RANK, and `undefined <= n` is false. + isLevelEnabled: (methodLevel) => LEVEL_RANK[methodLevel] <= threshold, + error: (message, fields) => emit("error", stderr, message, fields), + warn: (message, fields) => emit("warn", stderr, message, fields), + info: (message, fields) => emit("info", stdout, message, fields), + debug: (message, fields) => emit("debug", stdout, message, fields), + child: (childFields) => createLogger({ ...options, fields: { ...baseFields, ...childFields } }), + }; } - // Process-wide logger. The CLI entrypoint calls `configureLogger` once from the parsed global flags/env so every // command shares one configured instance via `getLogger`; until then this default-level instance is used. let processLogger = createLogger(); - /** Reconfigure the process-wide logger from resolved startup options and return it. */ export function configureLogger(options) { - processLogger = createLogger(options); - return processLogger; + processLogger = createLogger(options); + return processLogger; } - /** The process-wide logger configured by `configureLogger` (a default-level logger before then). */ export function getLogger() { - return processLogger; + return processLogger; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibG9nZ2VyLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsibG9nZ2VyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLDBHQUEwRztBQUMxRyx5R0FBeUc7QUFDekcsNkdBQTZHO0FBQzdHLDZHQUE2RztBQUM3Ryx3R0FBd0c7QUFDeEcsRUFBRTtBQUNGLGdIQUFnSDtBQUNoSCwrR0FBK0c7QUFDL0csOEdBQThHO0FBQzlHLGtGQUFrRjtBQUlsRixtRkFBbUY7QUFDbkYsTUFBTSxDQUFDLE1BQU0sVUFBVSxHQUF3QixDQUFDLFFBQVEsRUFBRSxPQUFPLEVBQUUsTUFBTSxFQUFFLE1BQU0sRUFBRSxPQUFPLENBQUMsQ0FBQztBQUU1RixtRkFBbUY7QUFDbkYsTUFBTSxDQUFDLE1BQU0saUJBQWlCLEdBQWEsTUFBTSxDQUFDO0FBRWxELDRHQUE0RztBQUM1RyxNQUFNLFVBQVUsR0FBNkIsRUFBRSxNQUFNLEVBQUUsQ0FBQyxFQUFFLEtBQUssRUFBRSxDQUFDLEVBQUUsSUFBSSxFQUFFLENBQUMsRUFBRSxJQUFJLEVBQUUsQ0FBQyxFQUFFLEtBQUssRUFBRSxDQUFDLEVBQUUsQ0FBQztBQUVqRyxNQUFNLFlBQVksR0FBRyxHQUFXLEVBQUUsQ0FBQyxJQUFJLElBQUksRUFBRSxDQUFDLFdBQVcsRUFBRSxDQUFDO0FBNEI1RDs0RUFDNEU7QUFDNUUsTUFBTSxVQUFVLFVBQVUsQ0FBQyxLQUFjO0lBQ3ZDLE9BQU8sT0FBTyxLQUFLLEtBQUssUUFBUSxJQUFJLE1BQU0sQ0FBQyxTQUFTLENBQUMsY0FBYyxDQUFDLElBQUksQ0FBQyxVQUFVLEVBQUUsS0FBSyxDQUFDLENBQUM7QUFDOUYsQ0FBQztBQUVEOzs7OztHQUtHO0FBQ0gsTUFBTSxVQUFVLGVBQWUsQ0FBQyxFQUM5QixLQUFLLEVBQ0wsS0FBSyxHQUFHLEtBQUssRUFDYixPQUFPLEdBQUcsS0FBSyxFQUNmLFFBQVEsTUFNTixFQUFFO0lBQ0osSUFBSSxVQUFVLENBQUMsS0FBSyxDQUFDO1FBQUUs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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/logger.ts b/packages/loopover-miner/lib/logger.ts new file mode 100644 index 0000000000..194aa45732 --- /dev/null +++ b/packages/loopover-miner/lib/logger.ts @@ -0,0 +1,217 @@ +// Level-aware logging abstraction for the miner CLI (#4835): every CLI file previously reached for ad hoc +// `console.log`/`console.error` with no shared level control, so an operator could neither quiet routine +// chatter nor turn on verbose diagnostics. This module is the one dependency-light logger the CLI configures +// once at startup and every command shares. It is deliberately pure/injectable — `streams`, `now`, and `env` +// are all overridable — so the branchy level/format logic is unit-testable without touching real stdio. +// +// Levels are ordered by severity; a logger at level L emits a method only when the method's severity rank is at +// or below L's rank (so `error` always survives except at `silent`, and `debug` only shows at the most verbose +// setting). `error`/`warn` go to stderr, `info`/`debug` to stdout, matching the existing convention where the +// update-check nudge writes to stderr and normal command output writes to stdout. + +export type LogLevel = "silent" | "error" | "warn" | "info" | "debug"; + +/** Supported log levels, least to most verbose. `silent` suppresses everything. */ +export const LOG_LEVELS: readonly LogLevel[] = ["silent", "error", "warn", "info", "debug"]; + +/** The level used when nothing (flag, env var, or explicit option) selects one. */ +export const DEFAULT_LOG_LEVEL: LogLevel = "info"; + +// Numeric severity rank per level (higher = more verbose). A method emits when its rank <= the active rank. +const LEVEL_RANK: Record = { silent: 0, error: 1, warn: 2, info: 3, debug: 4 }; + +const defaultClock = (): string => new Date().toISOString(); + +export interface LoggerStreams { + stdout?: { write(chunk: string): unknown } | undefined; + stderr?: { write(chunk: string): unknown } | undefined; +} + +export interface LoggerOptions { + level?: string | undefined; + quiet?: boolean | undefined; + verbose?: boolean | undefined; + pretty?: boolean | undefined; + fields?: Record | undefined; + env?: Record | undefined; + streams?: LoggerStreams | undefined; + now?: (() => string) | undefined; +} + +export interface Logger { + level: LogLevel; + isLevelEnabled(level: string): boolean; + error(message: string, fields?: Record): void; + warn(message: string, fields?: Record): void; + info(message: string, fields?: Record): void; + debug(message: string, fields?: Record): void; + child(fields: Record): Logger; +} + +/** True when `value` names a supported log level. Non-string input is never a level (so an absent option or a + * typo'd env var falls through to the next signal instead of throwing). */ +export function isLogLevel(value: unknown): value is LogLevel { + return typeof value === "string" && Object.prototype.hasOwnProperty.call(LEVEL_RANK, value); +} + +/** + * Resolve the active level from the available signals, most explicit first: an explicit `level` wins, then + * `--quiet` (→ `error`), then `--verbose` (→ `debug`), then the env-provided level, else the default. `quiet` + * beats `verbose` when both are set, so the safer/quieter choice wins a contradictory invocation. An + * unrecognized `level`/`envLevel` is ignored rather than throwing — a typo logs at the default, never crashes. + */ +export function resolveLogLevel({ + level, + quiet = false, + verbose = false, + envLevel, +}: { + level?: string | undefined; + quiet?: boolean | undefined; + verbose?: boolean | undefined; + envLevel?: string | undefined; +} = {}): LogLevel { + if (isLogLevel(level)) return level; + if (quiet) return "error"; + if (verbose) return "debug"; + if (isLogLevel(envLevel)) return envLevel; + return DEFAULT_LOG_LEVEL; +} + +/** + * Split the global logging flags out of a CLI argv slice, returning the parsed options plus `rest` — the argv + * with those flags (and any `--log-level` value) removed so downstream command parsing never sees them. + * Recognizes `--quiet`, `--verbose`, `--log-level `, and `--log-level=`. No short aliases: `-v` + * is already `--version` and `-h` is `--help` in the CLI entrypoint. + */ +export function extractLogOptions(argv: string[]): { + options: { quiet: boolean; verbose: boolean; level: string | undefined }; + rest: string[]; +} { + let quiet = false; + let verbose = false; + let level: string | undefined; + const rest: string[] = []; + for (let index = 0; index < argv.length; index += 1) { + // noUncheckedIndexedAccess: in-bounds access is always defined at runtime. + const arg = argv[index] as string; + if (arg === "--quiet") { + quiet = true; + continue; + } + if (arg === "--verbose") { + verbose = true; + continue; + } + if (arg === "--log-level") { + level = argv[index + 1]; + index += 1; + continue; + } + if (arg.startsWith("--log-level=")) { + level = arg.slice("--log-level=".length); + continue; + } + rest.push(arg); + } + return { options: { quiet, verbose, level }, rest }; +} + +function formatFieldValue(value: unknown): string { + // Quote a string only when it contains whitespace (so it stays one token); serialize everything else as JSON. + if (typeof value === "string") return /\s/.test(value) ? JSON.stringify(value) : value; + return JSON.stringify(value); +} + +/** + * Render structured fields as a stable, sorted ` key=value` suffix (sorted so output is deterministic across + * runs). `undefined` values are dropped; an empty/absent field set yields an empty string. + */ +export function formatFields(fields?: Record | null | undefined): string { + if (!fields) return ""; + const parts: string[] = []; + for (const key of Object.keys(fields).sort()) { + const value = fields[key]; + if (value === undefined) continue; + parts.push(`${key}=${formatFieldValue(value)}`); + } + return parts.length > 0 ? ` ${parts.join(" ")}` : ""; +} + +/** + * Format one log line. Plain mode (the default) is just `message` + any field suffix, keeping human CLI output + * identical to a bare `console.log`. Pretty mode prefixes an optional timestamp and the uppercased level tag, + * for operators who want machine-scannable diagnostics. + */ +export function formatLine({ + level, + message, + fields, + pretty, + timestamp, +}: { + level: string; + message: string; + fields?: Record | null | undefined; + pretty?: boolean | undefined; + timestamp?: string | undefined; +}): string { + const suffix = formatFields(fields); + if (!pretty) return `${message}${suffix}`; + const stamp = timestamp ? `[${timestamp}] ` : ""; + return `${stamp}${level.toUpperCase()} ${message}${suffix}`; +} + +/** + * Build a level-aware logger. All I/O is injectable for tests: `streams` (defaults to process stdout/stderr), + * `now` (defaults to an ISO-8601 clock, only consulted in `pretty` mode), and `env` (defaults to process.env, + * read for `LOOPOVER_MINER_LOG_LEVEL`). `fields` seeds every line with contextual fields; `child(extra)` + * returns a logger that merges additional fields onto this one. + */ +export function createLogger(options: LoggerOptions = {}): Logger { + const { level, quiet, verbose, pretty = false, fields: baseFields, env = process.env, streams, now } = options; + const stdout = streams?.stdout ?? process.stdout; + const stderr = streams?.stderr ?? process.stderr; + const clock = now ?? defaultClock; + const envLevel = env.LOOPOVER_MINER_LOG_LEVEL ?? ""; + const activeLevel = resolveLogLevel({ level, quiet, verbose, envLevel }); + const threshold = LEVEL_RANK[activeLevel]; + + function emit( + methodLevel: LogLevel, + stream: { write(chunk: string): unknown }, + message: string, + fields?: Record, + ): void { + if (LEVEL_RANK[methodLevel] > threshold) return; + const merged = baseFields || fields ? { ...baseFields, ...fields } : undefined; + const timestamp = pretty ? clock() : undefined; + stream.write(`${formatLine({ level: methodLevel, message, fields: merged, pretty, timestamp })}\n`); + } + + return { + level: activeLevel, + // Cast: public API takes `string`; unknown levels are undefined in LEVEL_RANK, and `undefined <= n` is false. + isLevelEnabled: (methodLevel) => LEVEL_RANK[methodLevel as LogLevel] <= threshold, + error: (message, fields) => emit("error", stderr, message, fields), + warn: (message, fields) => emit("warn", stderr, message, fields), + info: (message, fields) => emit("info", stdout, message, fields), + debug: (message, fields) => emit("debug", stdout, message, fields), + child: (childFields) => createLogger({ ...options, fields: { ...baseFields, ...childFields } }), + }; +} + +// Process-wide logger. The CLI entrypoint calls `configureLogger` once from the parsed global flags/env so every +// command shares one configured instance via `getLogger`; until then this default-level instance is used. +let processLogger = createLogger(); + +/** Reconfigure the process-wide logger from resolved startup options and return it. */ +export function configureLogger(options?: LoggerOptions): Logger { + processLogger = createLogger(options); + return processLogger; +} + +/** The process-wide logger configured by `configureLogger` (a default-level logger before then). */ +export function getLogger(): Logger { + return processLogger; +} diff --git a/packages/loopover-miner/lib/loop-cli.d.ts b/packages/loopover-miner/lib/loop-cli.d.ts index 8ad9c178c8..68dfde88b0 100644 --- a/packages/loopover-miner/lib/loop-cli.d.ts +++ b/packages/loopover-miner/lib/loop-cli.d.ts @@ -1,66 +1,106 @@ -import type { AttemptCliResult } from "./attempt-cli.js"; -import type { PortfolioQueueStore } from "./portfolio-queue.js"; import type { GovernorState } from "./governor-state.js"; -import type { EventLedger } from "./event-ledger.js"; import type { GovernorLedger } from "./governor-ledger.js"; +import type { EventLedger } from "./event-ledger.js"; +import type { PortfolioQueueStore } from "./portfolio-queue.js"; import type { RunStateStore } from "./run-state.js"; +import type { AttemptCliResult } from "./attempt-cli.js"; import type { PollPrDispositionOptions } from "./pr-disposition-poller.js"; import type { CheckRunConclusion, PollCheckRunsOptions } from "./ci-poller.js"; - -export type ParsedLoopArgs = - | { error: string } - | { - targets: string[]; - search: string | null; - minerLogin: string; - base: string; - live: boolean; - dryRun: boolean; - maxCycles: number | undefined; - cycleDelayMs: number; - json: boolean; - }; - -export function parseLoopArgs(args: string[]): ParsedLoopArgs; - +export type ParsedLoopArgs = { + error: string; +} | { + targets: string[]; + search: string | null; + minerLogin: string; + base: string; + live: boolean; + dryRun: boolean; + maxCycles: number | undefined; + cycleDelayMs: number; + json: boolean; +}; export type LoopCycleSummary = { - cycle: number; - outcome: "idle_queue_empty" | "halted" | "attempted" | "skipped_malformed_identifier"; - reason?: string; - repoFullName?: string; - identifier?: string; - attemptOutcome?: AttemptCliResult["outcome"] | "attempt_error"; - reentryOutcome?: "merged" | "disengaged" | "other"; - prNumber?: number | null; - ciConclusion?: CheckRunConclusion | null; - reentered?: boolean; - reasons?: string[]; + cycle: number; + outcome: "idle_queue_empty" | "halted" | "attempted" | "skipped_malformed_identifier"; + reason?: string; + repoFullName?: string; + identifier?: string; + attemptOutcome?: AttemptCliResult["outcome"] | "attempt_error"; + reentryOutcome?: "merged" | "disengaged" | "other"; + prNumber?: number | null; + ciConclusion?: CheckRunConclusion | null; + reentered?: boolean; + reasons?: string[]; }; - export type RunLoopOptions = { - env?: Record; - nowMs?: number; - githubToken?: string; - apiBaseUrl?: string; - sleepFn?: (delayMs: number) => Promise; - openGovernorState?: () => GovernorState; - initEventLedger?: () => EventLedger; - initGovernorLedger?: () => GovernorLedger; - initPortfolioQueue?: () => PortfolioQueueStore; - initRunStateStore?: () => RunStateStore; - runDiscover?: (args: string[], options?: Record) => Promise; - runAttempt?: (args: string[], options?: Record) => Promise; - resolveAmsPolicy?: (repoFullName: string, options?: Record) => Promise<{ spec: Record; source: string; warnings: string[] }>; - checkMinerKillSwitch?: (input?: { env?: Record; repoPaused?: boolean }) => { scope: "global" | "repo" | "none"; active: boolean }; - evaluateRunLoopBoundaryGate?: (input: unknown, options?: unknown) => { verdict: { reason: string }; canClaimNext: boolean }; - pollPrDisposition?: (repoFullName: string, prNumber: number, options?: PollPrDispositionOptions) => Promise<{ state: "open" | "closed"; merged: boolean; closedAt: string | null; attempts: number }>; - pollCheckRuns?: (repoFullName: string, prNumber: number, options?: PollCheckRunsOptions) => Promise<{ conclusion: CheckRunConclusion; checks: unknown[]; headSha: string; attempts: number }>; - recordPrOutcomeSnapshot?: (input: unknown, options?: unknown) => unknown; - buildLoopClosureSummary?: (sources: unknown, options?: unknown) => { sinceSeq: number | null; lastSeq: number }; - attemptLoopReentry?: (candidate: unknown, deps: unknown) => { decision: { reenter: boolean; reasons: string[] }; dequeued: { repoFullName: string; identifier: string; priority: number; status: string; enqueuedAt: string } | null }; - attemptOptions?: Record; - prDispositionOptions?: PollPrDispositionOptions; - ciPollOptions?: PollCheckRunsOptions; + env?: Record; + nowMs?: number; + githubToken?: string; + apiBaseUrl?: string; + sleepFn?: (delayMs: number) => Promise; + openGovernorState?: () => GovernorState; + initEventLedger?: () => EventLedger; + initGovernorLedger?: () => GovernorLedger; + initPortfolioQueue?: () => PortfolioQueueStore; + initRunStateStore?: () => RunStateStore; + runDiscover?: (args: string[], options?: Record) => Promise; + runAttempt?: (args: string[], options?: Record) => Promise; + resolveAmsPolicy?: (repoFullName: string, options?: Record) => Promise<{ + spec: Record; + source: string; + warnings: string[]; + }>; + checkMinerKillSwitch?: (input?: { + env?: Record; + repoPaused?: boolean; + }) => { + scope: "global" | "repo" | "none"; + active: boolean; + }; + evaluateRunLoopBoundaryGate?: (input: unknown, options?: unknown) => { + verdict: { + reason: string; + }; + canClaimNext: boolean; + }; + pollPrDisposition?: (repoFullName: string, prNumber: number, options?: PollPrDispositionOptions) => Promise<{ + state: "open" | "closed"; + merged: boolean; + closedAt: string | null; + attempts: number; + }>; + pollCheckRuns?: (repoFullName: string, prNumber: number, options?: PollCheckRunsOptions) => Promise<{ + conclusion: CheckRunConclusion; + checks: unknown[]; + headSha: string; + attempts: number; + }>; + recordPrOutcomeSnapshot?: (input: unknown, options?: unknown) => unknown; + buildLoopClosureSummary?: (sources: unknown, options?: unknown) => { + sinceSeq: number | null; + lastSeq: number; + }; + attemptLoopReentry?: (candidate: unknown, deps: unknown) => { + decision: { + reenter: boolean; + reasons: string[]; + }; + dequeued: { + repoFullName: string; + identifier: string; + priority: number; + status: string; + enqueuedAt: string; + } | null; + }; + attemptOptions?: Record; + prDispositionOptions?: PollPrDispositionOptions; + ciPollOptions?: PollCheckRunsOptions; }; - -export function runLoop(args: string[], options?: RunLoopOptions): Promise; +export declare function parseLoopArgs(args: string[]): ParsedLoopArgs; +/** + * Run one full discover -> claim -> attempt -> observe -> reenter cycle repeatedly until a kill-switch trips, + * the run-loop boundary gate halts (non-convergence or a real budget/turn/elapsed cap), re-entry is declined, + * or `--max-cycles` is reached. Fails closed: refuses to start at all if governor state cannot be loaded. + */ +export declare function runLoop(args: string[], options?: RunLoopOptions): Promise; diff --git a/packages/loopover-miner/lib/loop-cli.js b/packages/loopover-miner/lib/loop-cli.js index 5133f62249..2dc48b7156 100644 --- a/packages/loopover-miner/lib/loop-cli.js +++ b/packages/loopover-miner/lib/loop-cli.js @@ -21,7 +21,6 @@ // dequeueNext claim + markDone/markFailed calls below already maintain -- the same source a one-shot `attempt` // invocation reads (#5654), so both share one source of truth and the counters survive a loop-daemon restart // (crash/deploy/systemd bounce) instead of resetting with the process (#5677). - import { checkMinerKillSwitch } from "./governor-kill-switch.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; import { evaluateRunLoopBoundaryGate } from "./governor-run-halt.js"; @@ -42,549 +41,492 @@ import { attemptLoopReentry } from "./loop-reentry.js"; import { parsePrNumberFromExecResult } from "./pr-number-parse.js"; import { resolveGitHubToken } from "./github-token-resolution.js"; import { DEFAULT_AMS_POLICY_SPEC } from "@loopover/engine"; - -const LOOP_USAGE = - "Usage: loopover-miner loop [...] | --search --miner-login [--base ] [--live] [--dry-run] [--max-cycles ] [--cycle-delay-ms ] [--json]"; +const LOOP_USAGE = "Usage: loopover-miner loop [...] | --search --miner-login [--base ] [--live] [--dry-run] [--max-cycles ] [--cycle-delay-ms ] [--json]"; const DEFAULT_CYCLE_DELAY_MS = 60_000; const ISSUE_IDENTIFIER_PATTERN = /^issue:(\d+)$/; - function parseRepoTarget(value) { - const trimmed = typeof value === "string" ? value.trim() : ""; - const [owner, repo, extra] = trimmed.split("/"); - if (!owner || !repo || extra !== undefined) return null; - return `${owner}/${repo}`; + const trimmed = value.trim(); + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) + return null; + return `${owner}/${repo}`; } - function normalizeOptionalPositiveInt(value, label) { - const parsedValue = Number(value); - if (!Number.isFinite(parsedValue) || !Number.isInteger(parsedValue) || parsedValue < 0) { - throw new Error(`${label} must be a non-negative integer: ${value}`); - } - return parsedValue; + const parsedValue = Number(value); + if (!Number.isFinite(parsedValue) || !Number.isInteger(parsedValue) || parsedValue < 0) { + throw new Error(`${label} must be a non-negative integer: ${value}`); + } + return parsedValue; } - export function parseLoopArgs(args) { - const options = { - json: false, - minerLogin: null, - base: "main", - live: false, - dryRun: false, - search: null, - maxCycles: undefined, - cycleDelayMs: DEFAULT_CYCLE_DELAY_MS, - }; - const targets = []; - - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === "--json") { - options.json = true; - continue; - } - if (token === "--live") { - options.live = true; - continue; - } - // #4847: see attempt-cli.js's own --dry-run comment -- distinct from --live's absence, this short-circuits - // BEFORE governor state or any other store is opened, guaranteeing zero discovery/queue/ledger writes. - if (token === "--dry-run") { - options.dryRun = true; - continue; - } - if (token === "--search") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; - options.search = value; - index += 1; - continue; - } - if (token === "--miner-login") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; - options.minerLogin = value; - index += 1; - continue; - } - if (token === "--base") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; - options.base = value; - index += 1; - continue; - } - if (token === "--max-cycles") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; - try { - options.maxCycles = normalizeOptionalPositiveInt(value, "--max-cycles"); - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; - } - index += 1; - continue; - } - if (token === "--cycle-delay-ms") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; - try { - options.cycleDelayMs = normalizeOptionalPositiveInt(value, "--cycle-delay-ms"); - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; - } - index += 1; - continue; + const options = { + json: false, + minerLogin: null, + base: "main", + live: false, + dryRun: false, + search: null, + maxCycles: undefined, + cycleDelayMs: DEFAULT_CYCLE_DELAY_MS, + }; + const targets = []; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--live") { + options.live = true; + continue; + } + // #4847: see attempt-cli.js's own --dry-run comment -- distinct from --live's absence, this short-circuits + // BEFORE governor state or any other store is opened, guaranteeing zero discovery/queue/ledger writes. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--search") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) + return { error: LOOP_USAGE }; + options.search = value; + index += 1; + continue; + } + if (token === "--miner-login") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) + return { error: LOOP_USAGE }; + options.minerLogin = value; + index += 1; + continue; + } + if (token === "--base") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) + return { error: LOOP_USAGE }; + options.base = value; + index += 1; + continue; + } + if (token === "--max-cycles") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) + return { error: LOOP_USAGE }; + try { + options.maxCycles = normalizeOptionalPositiveInt(value, "--max-cycles"); + } + catch (error) { + return { error: describeCliError(error) }; + } + index += 1; + continue; + } + if (token === "--cycle-delay-ms") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) + return { error: LOOP_USAGE }; + try { + options.cycleDelayMs = normalizeOptionalPositiveInt(value, "--cycle-delay-ms"); + } + catch (error) { + return { error: describeCliError(error) }; + } + index += 1; + continue; + } + if (token.startsWith("-")) + return { error: `Unknown option: ${token}` }; + const target = parseRepoTarget(token); + if (!target) + return { error: `Repository must be in owner/repo form: ${token}` }; + targets.push(target); } - if (token.startsWith("-")) return { error: `Unknown option: ${token}` }; - const target = parseRepoTarget(token); - if (!target) return { error: `Repository must be in owner/repo form: ${token}` }; - targets.push(target); - } - - if (options.search === null && targets.length === 0) return { error: LOOP_USAGE }; - if (options.search !== null && targets.length > 0) return { error: "Pass either repository targets or --search, not both." }; - if (!options.minerLogin) return { error: `--miner-login is required. ${LOOP_USAGE}` }; - - return { - targets, - search: options.search, - minerLogin: options.minerLogin, - base: options.base, - live: options.live, - dryRun: options.dryRun, - maxCycles: options.maxCycles, - cycleDelayMs: options.cycleDelayMs, - json: options.json, - }; + if (options.search === null && targets.length === 0) + return { error: LOOP_USAGE }; + if (options.search !== null && targets.length > 0) + return { error: "Pass either repository targets or --search, not both." }; + if (!options.minerLogin) + return { error: `--miner-login is required. ${LOOP_USAGE}` }; + return { + targets, + search: options.search, + minerLogin: options.minerLogin, + base: options.base, + live: options.live, + dryRun: options.dryRun, + maxCycles: options.maxCycles, + cycleDelayMs: options.cycleDelayMs, + json: options.json, + }; } - function discoverArgv(parsed) { - return parsed.search !== null ? ["--search", parsed.search] : [...parsed.targets]; + return parsed.search !== null ? ["--search", parsed.search] : [...parsed.targets]; } - function parseIssueNumberFromIdentifier(identifier) { - const match = typeof identifier === "string" ? identifier.match(ISSUE_IDENTIFIER_PATTERN) : null; - return match ? Number(match[1]) : null; + const match = typeof identifier === "string" ? identifier.match(ISSUE_IDENTIFIER_PATTERN) : null; + return match ? Number(match[1]) : null; +} +function defaultSleep(delayMs) { + return new Promise((resolve) => setTimeout(resolve, delayMs)); } - /** * Run one full discover -> claim -> attempt -> observe -> reenter cycle repeatedly until a kill-switch trips, * the run-loop boundary gate halts (non-convergence or a real budget/turn/elapsed cap), re-entry is declined, * or `--max-cycles` is reached. Fails closed: refuses to start at all if governor state cannot be loaded. - * - * @param {string[]} args - * @param {{ - * env?: Record, - * nowMs?: number, - * githubToken?: string, - * apiBaseUrl?: string, - * sleepFn?: (delayMs: number) => Promise, - * openGovernorState?: typeof openGovernorState, - * initEventLedger?: typeof initEventLedger, - * initGovernorLedger?: typeof initGovernorLedger, - * initPortfolioQueue?: () => import("./portfolio-queue.js").PortfolioQueueStore, - * initRunStateStore?: typeof initRunStateStore, - * runDiscover?: typeof runDiscover, - * runAttempt?: typeof runAttempt, - * resolveAmsPolicy?: typeof resolveAmsPolicy, - * checkMinerKillSwitch?: typeof checkMinerKillSwitch, - * evaluateRunLoopBoundaryGate?: typeof evaluateRunLoopBoundaryGate, - * pollPrDisposition?: typeof pollPrDisposition, - * pollCheckRuns?: typeof pollCheckRuns, - * recordPrOutcomeSnapshot?: typeof recordPrOutcomeSnapshot, - * buildLoopClosureSummary?: typeof buildLoopClosureSummary, - * attemptLoopReentry?: typeof attemptLoopReentry, - * attemptOptions?: Record, - * prDispositionOptions?: Record, - * ciPollOptions?: Record, - * }} [options] - * @returns {Promise} */ export async function runLoop(args, options = {}) { - const parsed = parseLoopArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - const env = options.env ?? process.env; - const sleepFn = options.sleepFn ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))); - const nowMsFn = () => options.nowMs ?? Date.now(); - const sessionStartMs = nowMsFn(); - - // #4847: reports what a real loop invocation would target and returns BEFORE governor state or any other - // store (event/governor ledger, portfolio queue, run state) is opened -- a provable zero-write path, not just - // "opened but didn't write." The loop's own discovery call enqueues newly-found candidates into the LOCAL - // portfolio queue even before any attempt happens, so a faithful dry run cannot call it either. - if (parsed.dryRun) { - const dryRunResult = { - outcome: "dry_run", - targets: parsed.targets, - search: parsed.search, - minerLogin: parsed.minerLogin, - base: parsed.base, - live: parsed.live, - maxCycles: parsed.maxCycles ?? null, - }; - if (parsed.json) { - console.log(JSON.stringify(dryRunResult, null, 2)); - } else { - const target = parsed.search !== null ? `--search ${parsed.search}` : parsed.targets.join(", "); - console.log( - `DRY RUN: would run an autonomous loop against ${target} for ${parsed.minerLogin} (base: ${parsed.base}, live: ${parsed.live}). No discovery, queue, or ledger writes were made.`, - ); - } - return 0; - } - - let governorState; - try { - governorState = (options.openGovernorState ?? openGovernorState)(); - } catch (error) { - return reportCliFailure( - parsed.json, - `Loop refuses to start: governor state cannot be loaded: ${describeCliError(error)}`, - 3, - ); - } - - const eventLedger = (options.initEventLedger ?? initEventLedger)(); - const governorLedger = (options.initGovernorLedger ?? initGovernorLedger)(); - const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); - const runState = (options.initRunStateStore ?? initRunStateStore)(); - - const runDiscoverFn = options.runDiscover ?? runDiscover; - const runAttemptFn = options.runAttempt ?? runAttempt; - const resolveAmsPolicyFn = options.resolveAmsPolicy ?? resolveAmsPolicy; - const checkKillSwitchFn = options.checkMinerKillSwitch ?? checkMinerKillSwitch; - const evaluateBoundaryGateFn = options.evaluateRunLoopBoundaryGate ?? evaluateRunLoopBoundaryGate; - const pollPrDispositionFn = options.pollPrDisposition ?? pollPrDisposition; - const pollCheckRunsFn = options.pollCheckRuns ?? pollCheckRuns; - const recordPrOutcomeSnapshotFn = options.recordPrOutcomeSnapshot ?? recordPrOutcomeSnapshot; - const buildLoopClosureSummaryFn = options.buildLoopClosureSummary ?? buildLoopClosureSummary; - const attemptLoopReentryFn = options.attemptLoopReentry ?? attemptLoopReentry; - - // Resolved ONCE, at the CLI-entrypoint layer, mirroring manage-poll.js's own runManagePoll (its - // recordManagePollSnapshot callee has no env fallback of its own either -- the top-level CLI function is - // where the GitHub token gets resolved, then threaded down explicitly to every real GitHub caller). - // pollPrDisposition (unlike runDiscover, which falls back to process.env.GITHUB_TOKEN internally) has NO - // such fallback -- an unresolved githubToken here would silently poll unauthenticated. - // resolveGitHubToken (#6116): GITHUB_TOKEN env override wins outright, else a live token from the - // authenticated `loopover-mcp login` session -- cached in memory for this process's lifetime. - const githubToken = options.githubToken ?? (await resolveGitHubToken(env)) ?? ""; - - async function runDiscoveryOnce() { - await runDiscoverFn(discoverArgv(parsed), { - initPortfolioQueue: () => portfolioQueue, - githubToken, - apiBaseUrl: options.apiBaseUrl, - nowMs: nowMsFn(), - }); - } - - let usage = governorState.loadCapUsage(); - const cycles = []; - let sinceSeq = eventLedger.readEvents({}).at(-1)?.seq ?? 0; - let haltReason = null; - - try { - // Checked BEFORE any work at all -- including the very first discovery call -- so an already-active kill - // switch OR an already-active pause (#4851) halts the loop without ever touching GitHub or the queue. The - // pause flag is real, persisted, operator/governor-writable state on governorState (toggled via - // `loopover-miner governor pause`/`resume`) -- unlike the kill switch, a paused run resumes simply by being - // re-invoked: every piece of per-cycle state this loop reads (portfolioQueue, runState, governorState's own - // cap usage) is already durable, so clearing the flag and restarting continues exactly where it left off. - const initialKillSwitch = checkKillSwitchFn({ env }); - const initialPauseState = governorState.loadPauseState(); - let claimed = null; - if (initialKillSwitch.active) { - haltReason = `kill_switch_${initialKillSwitch.scope}`; - cycles.push({ cycle: 1, outcome: "halted", reason: haltReason }); - } else if (initialPauseState.paused) { - haltReason = "paused"; - cycles.push({ cycle: 1, outcome: "halted", reason: haltReason }); - } else { - await runDiscoveryOnce(); - claimed = portfolioQueue.dequeueNext(); + const parsed = parseLoopArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); } - - let cycleIndex = haltReason !== null ? 1 : 0; - while (haltReason === null && (parsed.maxCycles === undefined || cycleIndex < parsed.maxCycles)) { - cycleIndex += 1; - - const killSwitch = checkKillSwitchFn({ env }); - if (killSwitch.active) { - haltReason = `kill_switch_${killSwitch.scope}`; - // Release the in-flight claim so left state is defined (#5670 / mirrors run-halt's markFailed). - if (claimed) { - portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + // Narrow for nested closures (TS resets control-flow narrowing inside nested functions). + const loopArgs = parsed; + const env = options.env ?? process.env; + const sleepFn = options.sleepFn ?? defaultSleep; + const nowMsFn = () => options.nowMs ?? Date.now(); + const sessionStartMs = nowMsFn(); + // #4847: reports what a real loop invocation would target and returns BEFORE governor state or any other + // store (event/governor ledger, portfolio queue, run state) is opened -- a provable zero-write path, not just + // "opened but didn't write." The loop's own discovery call enqueues newly-found candidates into the LOCAL + // portfolio queue even before any attempt happens, so a faithful dry run cannot call it either. + if (parsed.dryRun) { + const dryRunResult = { + outcome: "dry_run", + targets: parsed.targets, + search: parsed.search, + minerLogin: parsed.minerLogin, + base: parsed.base, + live: parsed.live, + maxCycles: parsed.maxCycles ?? null, + }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); } - cycles.push({ - cycle: cycleIndex, - outcome: "halted", - reason: haltReason, - ...(claimed - ? { repoFullName: claimed.repoFullName, identifier: claimed.identifier } - : {}), - }); - break; - } - - const pauseState = governorState.loadPauseState(); - if (pauseState.paused) { - haltReason = "paused"; - if (claimed) { - portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + else { + const target = parsed.search !== null ? `--search ${parsed.search}` : parsed.targets.join(", "); + console.log(`DRY RUN: would run an autonomous loop against ${target} for ${parsed.minerLogin} (base: ${parsed.base}, live: ${parsed.live}). No discovery, queue, or ledger writes were made.`); } - cycles.push({ - cycle: cycleIndex, - outcome: "halted", - reason: haltReason, - ...(claimed - ? { repoFullName: claimed.repoFullName, identifier: claimed.identifier } - : {}), - }); - break; - } - - if (!claimed) { - cycles.push({ cycle: cycleIndex, outcome: "idle_queue_empty" }); - await sleepFn(parsed.cycleDelayMs); - await runDiscoveryOnce(); - claimed = portfolioQueue.dequeueNext(); - continue; - } - - const issueNumber = parseIssueNumberFromIdentifier(claimed.identifier); - if (issueNumber === null) { - // Never produced by enqueueRankedDiscovery in practice (always "issue:N") -- fail soft rather than - // crash the whole run: this exact item can never be attempted, so it will never resolve on retry. - portfolioQueue.markDone(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); - cycles.push({ cycle: cycleIndex, outcome: "skipped_malformed_identifier", identifier: claimed.identifier }); - claimed = portfolioQueue.dequeueNext(); - continue; - } - - const amsPolicy = await resolveAmsPolicyFn(claimed.repoFullName, { env }); - // Real, SQLite-persisted per-item convergence history (#5677): the dequeueNext claim above already recorded - // this attempt and the markDone/markFailed calls below record the outcome, so reading it back here shares one - // source of truth with attempt-cli.js (#5654) and survives a loop-daemon restart instead of resetting. - const convergenceInput = portfolioQueue.getAttemptHistory( - claimed.repoFullName, - claimed.identifier, - claimed.apiBaseUrl, - ); - - const boundary = evaluateBoundaryGateFn( - { - runHalted: false, - usage, - limits: amsPolicy.spec.capLimits ?? DEFAULT_AMS_POLICY_SPEC.capLimits, - convergence: convergenceInput, - convergenceThresholds: amsPolicy.spec.convergenceThresholds ?? DEFAULT_AMS_POLICY_SPEC.convergenceThresholds, - inFlightItem: { repoFullName: claimed.repoFullName, identifier: claimed.identifier }, - // Echoes claimed.apiBaseUrl (#5563), NOT the callback's own repoFullName/identifier alone -- two forge - // hosts can share an in-flight item with the same repo name+identifier. - markFailed: (repoFullName, identifier) => portfolioQueue.markFailed(repoFullName, identifier, claimed.apiBaseUrl), - }, - { append: (event) => governorLedger.appendGovernorEvent(event) }, - ); - - if (!boundary.canClaimNext) { - haltReason = `boundary_${boundary.verdict.reason}`; - cycles.push({ cycle: cycleIndex, outcome: "halted", reason: haltReason, repoFullName: claimed.repoFullName, identifier: claimed.identifier }); - break; - } - - const cycleStartMs = nowMsFn(); - let lastResult = null; - const attemptArgv = [ - claimed.repoFullName, - String(issueNumber), - "--miner-login", - parsed.minerLogin, - "--base", - parsed.base, - ...(parsed.live ? ["--live"] : []), - ]; - await runAttemptFn(attemptArgv, { - ...(options.attemptOptions ?? {}), - env, - onResult: (result) => { - lastResult = result; - }, - }); - const cycleElapsedMs = nowMsFn() - cycleStartMs; - - usage = { - // Real for the agent-sdk provider (its own SDK result message reports total_cost_usd, wired through - // runMinerAttempt's real loopResult.totalCostUsd); the CLI-subprocess providers (claude-cli/codex-cli) - // report no cost signal today, so this contributes 0 for those runs -- an honest absence, not a - // fabricated number. A capLimits.budget dimension only ever meaningfully trips against agent-sdk spend. - budgetSpent: usage.budgetSpent + (lastResult?.totalCostUsd ?? 0), - turnsTaken: usage.turnsTaken + (lastResult?.totalTurnsUsed ?? 0), - elapsedMs: usage.elapsedMs + cycleElapsedMs, - }; - governorState.saveCapUsage(usage); - - const attemptOutcome = lastResult?.outcome ?? "attempt_error"; - const submitted = attemptOutcome === "attempt_submitted"; - // A repo-wide AI-usage-policy ban will never resolve on retry -- stop re-queuing it (matches - // rejection-signal.js's own "this repo bans automated contributions" semantics). Every other blocked/ - // abandoned/stale/governed outcome MAY resolve on a later retry (transient infra, contention, a - // different iteration budget) and is requeued -- a genuinely stuck item is caught by non-convergence - // (reenqueues threshold) rather than silently retried forever. - const permanentBlock = attemptOutcome === "blocked_rejection_signaled"; - // Mid-attempt kill-switch abandon (#5670): stop the outer loop immediately instead of waiting for the - // next between-cycle probe, and treat the item like any other re-queued abandon via markFailed below. - const killSwitchAbandon = lastResult?.abandonReason === "kill_switch_engaged"; - - if (submitted || permanentBlock) { - // Both terminal -- a submitted PR is done, and a repo-wide AI-usage-policy ban never resolves on retry -- - // so neither is re-queued. markDone also clears the persisted consecutive-failure streak. - portfolioQueue.markDone(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); - } else { - // Any other blocked/abandoned/stale/governed outcome may resolve on a later retry, so requeue it; markFailed - // records the re-enqueue + consecutive failure the non-convergence detector reads on the next cycle. - portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); - } - - if (killSwitchAbandon) { - const liveKill = checkKillSwitchFn({ env }); - haltReason = liveKill.active ? `kill_switch_${liveKill.scope}` : "kill_switch_engaged"; - cycles.push({ - cycle: cycleIndex, - outcome: "halted", - reason: haltReason, - repoFullName: claimed.repoFullName, - identifier: claimed.identifier, - attemptOutcome, - }); - break; - } - - let reentryOutcome = "other"; - let prNumber = null; - let prDisposition = null; - let ciConclusion = null; - if (submitted) { - prNumber = parsePrNumberFromExecResult(lastResult?.execResult, claimed.repoFullName); - if (prNumber !== null) { - // Real CI-status observation (#5394): recorded BEFORE the disposition poll below, so a submitted - // PR's check-run state is captured even while it's still open, not just at its eventual merge/close. - // ci-poller.js's real GitHub check-run polling is a heuristic proxy for the gate verdict; the - // authoritative terminal merge/close outcome comes from pollPrDispositionFn below, sourced directly - // from GitHub's own PR state rather than a server-internal endpoint (#5450). - const ciStatus = await pollCheckRunsFn(claimed.repoFullName, prNumber, { - githubToken, - apiBaseUrl: options.apiBaseUrl, - ...(options.ciPollOptions ?? {}), - }); - ciConclusion = ciStatus.conclusion; - eventLedger.appendEvent({ - type: "ci_status_observed", - repoFullName: claimed.repoFullName, - payload: { prNumber, conclusion: ciStatus.conclusion, checkCount: ciStatus.checks.length, source: "ci-poller" }, - }); - - prDisposition = await pollPrDispositionFn(claimed.repoFullName, prNumber, { + return 0; + } + let governorState; + try { + governorState = (options.openGovernorState ?? openGovernorState)(); + } + catch (error) { + return reportCliFailure(parsed.json, `Loop refuses to start: governor state cannot be loaded: ${describeCliError(error)}`, 3); + } + const eventLedger = (options.initEventLedger ?? initEventLedger)(); + const governorLedger = (options.initGovernorLedger ?? initGovernorLedger)(); + const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); + const runState = (options.initRunStateStore ?? initRunStateStore)(); + const runDiscoverFn = options.runDiscover ?? runDiscover; + const runAttemptFn = options.runAttempt ?? runAttempt; + const resolveAmsPolicyFn = options.resolveAmsPolicy ?? resolveAmsPolicy; + const checkKillSwitchFn = options.checkMinerKillSwitch ?? checkMinerKillSwitch; + const evaluateBoundaryGateFn = options.evaluateRunLoopBoundaryGate ?? evaluateRunLoopBoundaryGate; + const pollPrDispositionFn = options.pollPrDisposition ?? pollPrDisposition; + const pollCheckRunsFn = options.pollCheckRuns ?? pollCheckRuns; + const recordPrOutcomeSnapshotFn = options.recordPrOutcomeSnapshot ?? recordPrOutcomeSnapshot; + const buildLoopClosureSummaryFn = options.buildLoopClosureSummary ?? buildLoopClosureSummary; + const attemptLoopReentryFn = options.attemptLoopReentry ?? attemptLoopReentry; + // Resolved ONCE, at the CLI-entrypoint layer, mirroring manage-poll.js's own runManagePoll (its + // recordManagePollSnapshot callee has no env fallback of its own either -- the top-level CLI function is + // where the GitHub token gets resolved, then threaded down explicitly to every real GitHub caller). + // pollPrDisposition (unlike runDiscover, which falls back to process.env.GITHUB_TOKEN internally) has NO + // such fallback -- an unresolved githubToken here would silently poll unauthenticated. + // resolveGitHubToken (#6116): GITHUB_TOKEN env override wins outright, else a live token from the + // authenticated `loopover-mcp login` session -- cached in memory for this process's lifetime. + const githubToken = options.githubToken ?? (await resolveGitHubToken(env)) ?? ""; + async function runDiscoveryOnce() { + await runDiscoverFn(discoverArgv(loopArgs), { + initPortfolioQueue: () => portfolioQueue, githubToken, - apiBaseUrl: options.apiBaseUrl, - ...(options.prDispositionOptions ?? {}), - }); - if (prDisposition.state === "closed") { - recordPrOutcomeSnapshotFn( - { + ...(options.apiBaseUrl !== undefined ? { apiBaseUrl: options.apiBaseUrl } : {}), + nowMs: nowMsFn(), + }); + } + let usage = governorState.loadCapUsage(); + const cycles = []; + let sinceSeq = eventLedger.readEvents({}).at(-1)?.seq ?? 0; + let haltReason = null; + try { + // Checked BEFORE any work at all -- including the very first discovery call -- so an already-active kill + // switch OR an already-active pause (#4851) halts the loop without ever touching GitHub or the queue. The + // pause flag is real, persisted, operator/governor-writable state on governorState (toggled via + // `loopover-miner governor pause`/`resume`) -- unlike the kill switch, a paused run resumes simply by being + // re-invoked: every piece of per-cycle state this loop reads (portfolioQueue, runState, governorState's own + // cap usage) is already durable, so clearing the flag and restarting continues exactly where it left off. + const initialKillSwitch = checkKillSwitchFn({ env }); + const initialPauseState = governorState.loadPauseState(); + let claimed = null; + if (initialKillSwitch.active) { + haltReason = `kill_switch_${initialKillSwitch.scope}`; + cycles.push({ cycle: 1, outcome: "halted", reason: haltReason }); + } + else if (initialPauseState.paused) { + haltReason = "paused"; + cycles.push({ cycle: 1, outcome: "halted", reason: haltReason }); + } + else { + await runDiscoveryOnce(); + claimed = portfolioQueue.dequeueNext(); + } + let cycleIndex = haltReason !== null ? 1 : 0; + while (haltReason === null && (parsed.maxCycles === undefined || cycleIndex < parsed.maxCycles)) { + cycleIndex += 1; + const killSwitch = checkKillSwitchFn({ env }); + if (killSwitch.active) { + haltReason = `kill_switch_${killSwitch.scope}`; + // Release the in-flight claim so left state is defined (#5670 / mirrors run-halt's markFailed). + if (claimed) { + portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + } + cycles.push({ + cycle: cycleIndex, + outcome: "halted", + reason: haltReason, + ...(claimed + ? { repoFullName: claimed.repoFullName, identifier: claimed.identifier } + : {}), + }); + break; + } + const pauseState = governorState.loadPauseState(); + if (pauseState.paused) { + haltReason = "paused"; + if (claimed) { + portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + } + cycles.push({ + cycle: cycleIndex, + outcome: "halted", + reason: haltReason, + ...(claimed + ? { repoFullName: claimed.repoFullName, identifier: claimed.identifier } + : {}), + }); + break; + } + if (!claimed) { + cycles.push({ cycle: cycleIndex, outcome: "idle_queue_empty" }); + await sleepFn(parsed.cycleDelayMs); + await runDiscoveryOnce(); + claimed = portfolioQueue.dequeueNext(); + continue; + } + const issueNumber = parseIssueNumberFromIdentifier(claimed.identifier); + if (issueNumber === null) { + // Never produced by enqueueRankedDiscovery in practice (always "issue:N") -- fail soft rather than + // crash the whole run: this exact item can never be attempted, so it will never resolve on retry. + portfolioQueue.markDone(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + cycles.push({ cycle: cycleIndex, outcome: "skipped_malformed_identifier", identifier: claimed.identifier }); + claimed = portfolioQueue.dequeueNext(); + continue; + } + // Capture for the boundary-gate markFailed callback (claimed is reassigned later in the loop). + const claimedEntry = claimed; + const amsPolicy = await resolveAmsPolicyFn(claimedEntry.repoFullName, { env }); + // Real, SQLite-persisted per-item convergence history (#5677): the dequeueNext claim above already recorded + // this attempt and the markDone/markFailed calls below record the outcome, so reading it back here shares one + // source of truth with attempt-cli.js (#5654) and survives a loop-daemon restart instead of resetting. + const convergenceInput = portfolioQueue.getAttemptHistory(claimedEntry.repoFullName, claimedEntry.identifier, claimedEntry.apiBaseUrl); + // RunLoopOptions.resolveAmsPolicy types spec as Record; fall back when fields are absent. + const limits = amsPolicy.spec.capLimits ?? + DEFAULT_AMS_POLICY_SPEC.capLimits; + const convergenceThresholds = amsPolicy.spec.convergenceThresholds ?? + DEFAULT_AMS_POLICY_SPEC.convergenceThresholds; + const boundary = evaluateBoundaryGateFn({ + runHalted: false, + usage, + limits, + convergence: convergenceInput, + convergenceThresholds, + inFlightItem: { repoFullName: claimedEntry.repoFullName, identifier: claimedEntry.identifier }, + // Echoes claimed.apiBaseUrl (#5563), NOT the callback's own repoFullName/identifier alone -- two forge + // hosts can share an in-flight item with the same repo name+identifier. + markFailed: (repoFullName, identifier) => portfolioQueue.markFailed(repoFullName, identifier, claimedEntry.apiBaseUrl), + }, { append: (event) => governorLedger.appendGovernorEvent(event) }); + if (!boundary.canClaimNext) { + haltReason = `boundary_${boundary.verdict.reason}`; + cycles.push({ cycle: cycleIndex, outcome: "halted", reason: haltReason, repoFullName: claimedEntry.repoFullName, identifier: claimedEntry.identifier }); + break; + } + const cycleStartMs = nowMsFn(); + // Local result bag: AttemptCliResult is a discriminant union; CFA after the onResult callback + // collapses typed bags to `never`, so keep this local untyped (runtime shape unchanged). + let lastResult = null; + const attemptArgv = [ + claimedEntry.repoFullName, + String(issueNumber), + "--miner-login", + parsed.minerLogin, + "--base", + parsed.base, + ...(parsed.live ? ["--live"] : []), + ]; + await runAttemptFn(attemptArgv, { + ...(options.attemptOptions ?? {}), + env, + onResult: (result) => { + lastResult = result; + }, + }); + const cycleElapsedMs = nowMsFn() - cycleStartMs; + usage = { + // Real for the agent-sdk provider (its own SDK result message reports total_cost_usd, wired through + // runMinerAttempt's real loopResult.totalCostUsd); the CLI-subprocess providers (claude-cli/codex-cli) + // report no cost signal today, so this contributes 0 for those runs -- an honest absence, not a + // fabricated number. A capLimits.budget dimension only ever meaningfully trips against agent-sdk spend. + budgetSpent: usage.budgetSpent + (lastResult?.totalCostUsd ?? 0), + turnsTaken: usage.turnsTaken + (lastResult?.totalTurnsUsed ?? 0), + elapsedMs: usage.elapsedMs + cycleElapsedMs, + }; + governorState.saveCapUsage(usage); + const attemptOutcome = lastResult?.outcome ?? "attempt_error"; + const submitted = attemptOutcome === "attempt_submitted"; + // A repo-wide AI-usage-policy ban will never resolve on retry -- stop re-queuing it (matches + // rejection-signal.js's own "this repo bans automated contributions" semantics). Every other blocked/ + // abandoned/stale/governed outcome MAY resolve on a later retry (transient infra, contention, a + // different iteration budget) and is requeued -- a genuinely stuck item is caught by non-convergence + // (reenqueues threshold) rather than silently retried forever. + const permanentBlock = attemptOutcome === "blocked_rejection_signaled"; + // Mid-attempt kill-switch abandon (#5670): stop the outer loop immediately instead of waiting for the + // next between-cycle probe, and treat the item like any other re-queued abandon via markFailed below. + const killSwitchAbandon = lastResult?.abandonReason === "kill_switch_engaged"; + if (submitted || permanentBlock) { + // Both terminal -- a submitted PR is done, and a repo-wide AI-usage-policy ban never resolves on retry -- + // so neither is re-queued. markDone also clears the persisted consecutive-failure streak. + portfolioQueue.markDone(claimedEntry.repoFullName, claimedEntry.identifier, claimedEntry.apiBaseUrl); + } + else { + // Any other blocked/abandoned/stale/governed outcome may resolve on a later retry, so requeue it; markFailed + // records the re-enqueue + consecutive failure the non-convergence detector reads on the next cycle. + portfolioQueue.markFailed(claimedEntry.repoFullName, claimedEntry.identifier, claimedEntry.apiBaseUrl); + } + if (killSwitchAbandon) { + const liveKill = checkKillSwitchFn({ env }); + haltReason = liveKill.active ? `kill_switch_${liveKill.scope}` : "kill_switch_engaged"; + cycles.push({ + cycle: cycleIndex, + outcome: "halted", + reason: haltReason, + repoFullName: claimedEntry.repoFullName, + identifier: claimedEntry.identifier, + attemptOutcome, + }); + break; + } + let reentryOutcome = "other"; + let prNumber = null; + let prDisposition = null; + let ciConclusion = null; + if (submitted) { + prNumber = parsePrNumberFromExecResult(lastResult?.execResult, claimedEntry.repoFullName); + if (prNumber !== null) { + // Real CI-status observation (#5394): recorded BEFORE the disposition poll below, so a submitted + // PR's check-run state is captured even while it's still open, not just at its eventual merge/close. + // ci-poller.js's real GitHub check-run polling is a heuristic proxy for the gate verdict; the + // authoritative terminal merge/close outcome comes from pollPrDispositionFn below, sourced directly + // from GitHub's own PR state rather than a server-internal endpoint (#5450). + const ciStatus = await pollCheckRunsFn(claimedEntry.repoFullName, prNumber, { + githubToken, + ...(options.apiBaseUrl !== undefined ? { apiBaseUrl: options.apiBaseUrl } : {}), + ...(options.ciPollOptions ?? {}), + }); + ciConclusion = ciStatus.conclusion; + eventLedger.appendEvent({ + type: "ci_status_observed", + repoFullName: claimedEntry.repoFullName, + payload: { prNumber, conclusion: ciStatus.conclusion, checkCount: ciStatus.checks.length, source: "ci-poller" }, + }); + prDisposition = await pollPrDispositionFn(claimedEntry.repoFullName, prNumber, { + githubToken, + ...(options.apiBaseUrl !== undefined ? { apiBaseUrl: options.apiBaseUrl } : {}), + ...(options.prDispositionOptions ?? {}), + }); + if (prDisposition.state === "closed") { + recordPrOutcomeSnapshotFn({ + repoFullName: claimedEntry.repoFullName, + prNumber, + decision: prDisposition.merged ? "merged" : "closed", + closedAt: prDisposition.closedAt, + }, { eventLedger }); + // Real per-repo reputation history (#5675): a resolved terminal outcome updates the decided/unfavorable + // counts the Governor's self-reputation throttle reads on this repo's next attempt. `decided` always; + // `unfavorable` only on a closed-without-merge (rejection-state-machine.js's isRejectedPr, matching + // #5655's own-rejection classification). Forge-scoped by claimed.apiBaseUrl (#5563), like every other + // governor-state write here. + const priorReputation = governorState.loadReputationHistory(claimed.repoFullName, claimed.apiBaseUrl); + governorState.saveReputationHistory(claimed.repoFullName, { + decided: priorReputation.decided + 1, + unfavorable: priorReputation.unfavorable + (isRejectedPr(prDisposition) ? 1 : 0), + }, claimed.apiBaseUrl); + reentryOutcome = classifyPrDisposition(prDisposition); + } + } + } + const loopSummary = buildLoopClosureSummaryFn({ eventLedger, portfolioQueue, runState }, { sinceSeq, repoFullName: claimed.repoFullName }); + sinceSeq = loopSummary.lastSeq; + const reentry = attemptLoopReentryFn({ killSwitchScope: killSwitch.scope, repoFullName: claimed.repoFullName, outcome: reentryOutcome }, { eventLedger, portfolioQueue, runState, nowMs: nowMsFn(), sessionStartMs, loopSummary }); + cycles.push({ + cycle: cycleIndex, + outcome: "attempted", repoFullName: claimed.repoFullName, + identifier: claimed.identifier, + attemptOutcome, + reentryOutcome, prNumber, - decision: prDisposition.merged ? "merged" : "closed", - closedAt: prDisposition.closedAt, - }, - { eventLedger }, - ); - // Real per-repo reputation history (#5675): a resolved terminal outcome updates the decided/unfavorable - // counts the Governor's self-reputation throttle reads on this repo's next attempt. `decided` always; - // `unfavorable` only on a closed-without-merge (rejection-state-machine.js's isRejectedPr, matching - // #5655's own-rejection classification). Forge-scoped by claimed.apiBaseUrl (#5563), like every other - // governor-state write here. - const priorReputation = governorState.loadReputationHistory(claimed.repoFullName, claimed.apiBaseUrl); - governorState.saveReputationHistory( - claimed.repoFullName, - { - decided: priorReputation.decided + 1, - unfavorable: priorReputation.unfavorable + (isRejectedPr(prDisposition) ? 1 : 0), - }, - claimed.apiBaseUrl, - ); - reentryOutcome = classifyPrDisposition(prDisposition); - } + ciConclusion, + reentered: reentry.decision.reenter, + reasons: reentry.decision.reasons, + }); + if (!reentry.decision.reenter) { + haltReason = `reentry_declined:${reentry.decision.reasons.join(",")}`; + break; + } + if (reentry.dequeued) { + // attemptLoopReentry's injectable .d.ts types dequeued.status as string; QueueEntry wants QueueStatus. + claimed = reentry.dequeued; + await sleepFn(parsed.cycleDelayMs); + } + else { + await sleepFn(parsed.cycleDelayMs); + await runDiscoveryOnce(); + claimed = portfolioQueue.dequeueNext(); + } + } + if (haltReason === null && parsed.maxCycles !== undefined) { + haltReason = "max_cycles_reached"; + // The next cycle's item is primed (dequeued → 'in_progress') BEFORE the while-condition re-checks + // maxCycles -- both at the initial priming above and at each cycle's tail -- so exhausting maxCycles + // ends the run holding a claim no cycle ever processed. Release it, mirroring the kill-switch/pause + // halts (#5670): dequeueNext() only pulls 'queued' rows, so an unreleased claim is invisible to every + // future loop/attempt run until an out-of-band stale-lease sweep reclaims it. + if (claimed) { + portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + } + } + // After the max-cycles release block above, haltReason is always set on a clean exit. + const summary = { haltReason, cyclesRun: cycles.length, cycles }; + if (parsed.json) { + console.log(JSON.stringify(summary, null, 2)); + } + else { + console.log(`Loop finished after ${cycles.length} cycle(s): ${haltReason}.`); } - } - - const loopSummary = buildLoopClosureSummaryFn( - { eventLedger, portfolioQueue, runState }, - { sinceSeq, repoFullName: claimed.repoFullName }, - ); - sinceSeq = loopSummary.lastSeq; - - const reentry = attemptLoopReentryFn( - { killSwitchScope: killSwitch.scope, repoFullName: claimed.repoFullName, outcome: reentryOutcome }, - { eventLedger, portfolioQueue, runState, nowMs: nowMsFn(), sessionStartMs, loopSummary }, - ); - - cycles.push({ - cycle: cycleIndex, - outcome: "attempted", - repoFullName: claimed.repoFullName, - identifier: claimed.identifier, - attemptOutcome, - reentryOutcome, - prNumber, - ciConclusion, - reentered: reentry.decision.reenter, - reasons: reentry.decision.reasons, - }); - - if (!reentry.decision.reenter) { - haltReason = `reentry_declined:${reentry.decision.reasons.join(",")}`; - break; - } - - if (reentry.dequeued) { - claimed = reentry.dequeued; - await sleepFn(parsed.cycleDelayMs); - } else { - await sleepFn(parsed.cycleDelayMs); - await runDiscoveryOnce(); - claimed = portfolioQueue.dequeueNext(); - } + return 0; } - - if (haltReason === null && parsed.maxCycles !== undefined) { - haltReason = "max_cycles_reached"; - // The next cycle's item is primed (dequeued → 'in_progress') BEFORE the while-condition re-checks - // maxCycles -- both at the initial priming above and at each cycle's tail -- so exhausting maxCycles - // ends the run holding a claim no cycle ever processed. Release it, mirroring the kill-switch/pause - // halts (#5670): dequeueNext() only pulls 'queued' rows, so an unreleased claim is invisible to every - // future loop/attempt run until an out-of-band stale-lease sweep reclaims it. - if (claimed) { - portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); - } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); } - - const summary = { haltReason, cyclesRun: cycles.length, cycles }; - if (parsed.json) { - console.log(JSON.stringify(summary, null, 2)); - } else { - console.log(`Loop finished after ${cycles.length} cycle(s): ${haltReason ?? "unknown"}.`); + finally { + governorState.close(); + eventLedger.close(); + governorLedger.close(); + portfolioQueue.close(); + runState.close(); } - return 0; - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } finally { - governorState.close(); - eventLedger.close(); - governorLedger.close(); - portfolioQueue.close(); - runState.close(); - } } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibG9vcC1jbGkuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJsb29wLWNsaS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxzR0FBc0c7QUFDdEcsaUdBQWlHO0FBQ2pHLHlHQUF5RztBQUN6RyxtR0FBbUc7QUFDbkcsRUFBRTtBQUNGLHFHQUFxRztBQUNyRyx5R0FBeUc7QUFDekcscUZBQXFGO0FBQ3JGLDZHQUE2RztBQUM3RyxzREFBc0Q7QUFDdEQscUdBQXFHO0FBQ3JHLDBHQUEwRztBQUMxRyw4R0FBOEc7QUFDOUcsNkdBQTZHO0FBQzdHLDJEQUEyRDtBQUMzRCxFQUFFO0FBQ0YsdUdBQXVHO0FBQ3ZHLDBHQUEwRztBQUMxRyw4R0FBOEc7QUFDOUcsNEdBQTRHO0FBQzVHLCtHQUErRztBQUMvRyw2R0FBNkc7QUFDN0csK0VBQStFO0FBRS9FLE9BQU8sRUFBRSxvQkFBb0IsRUFBRSxNQUFNLDJCQUEyQixDQUFDO0FBQ2pFLE9BQU8sRUFBRSxZQUFZLEVBQUUsZ0JBQWdCLEVBQUUsZ0JBQWdCLEVBQUUsTUFBTSxnQkFBZ0IsQ0FBQztBQUNsRixPQUFPLEVBQUUsMkJBQTJCLEVBQUUsTUFBTSx3QkFBd0IsQ0FBQztBQUNyRSxPQUFPLEVBQUUsaUJBQWlCLEVBQUUsTUFBTSxxQkFBcUIsQ0FBQztBQUV4RCxPQUFPLEVBQUUsa0JBQWtCLEVBQUUsTUFBTSxzQkFBc0IsQ0FBQztBQUUxRCxPQUFPLEVBQUUsZUFBZSxFQUFFLE1BQU0sbUJBQW1CLENBQUM7QUFFcEQsT0FBTyxFQUFFLHVCQUF1QixFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFFL0QsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0sZ0JBQWdCLENBQUM7QUFFbkQsT0FBTyxFQUFFLFdBQVcsRUFBRSxNQUFNLG1CQUFtQixDQUFDO0FBQ2hELE9BQU8sRUFBRSxVQUFVLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUU5QyxPQUFPLEVBQUUsZ0JBQWdCLEVBQUUsTUFBTSxpQkFBaUIsQ0FB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O1FBQ0QsSUFBSSxLQUFLLEtBQUssVUFBVSxFQUFFLENBQUM7WUFDekIsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FBQztZQUM5QixJQUFJLENBQUMsS0FBSyxJQUFJLEtBQUssQ0FBQyxVQUFVLENBQUMsR0FBRyxDQUFDO2dCQUFFLE9BQU8sRUFBRSxLQUFLLEVBQUUsVUFBVSxFQUFFLENBQUM7WUFDbEUsT0FBTyxDQUFDLE1BQU0sR0FBRyxLQUFLLENBQUM7WUFDdkIsS0FBSyxJQUFJLENBQUMsQ0FBQztZQUNYLFNBQVM7UUFDWCxDQUFDO1FBQ0QsSUFBSSxLQUFLLEtBQUssZUFBZSxFQUFFLENBQUM7WUFDOUIsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FBQztZQUM5QixJQUFJLENBQUMsS0FBSyxJQUFJLEtBQUssQ0FBQyxVQUFVLENBQUMsR0FBRyxDQUFDO2dCQUFFLE9BQU8sRUFBRSxLQUFLLEVBQUUsVUFBVSxFQUFFLENBQUM7WUFDbEUsT0FBTyxDQUFDLFVBQVUsR0FBRyxLQUFLLENBQUM7WUFDM0IsS0FBSyxJQUFJLENBQUMsQ0FBQztZQUNYLFNBQVM7UUFDWCxDQUFDO1FBQ0QsSUFBSSxLQUFLLEtBQUssUUFBUSxFQUFFLENBQUM7WUFDdkIsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FBQztZQUM5QixJQUFJLENBQUMsS0FBSyxJQUFJLEtBQUssQ0FBQyxVQUFVLENBQUMsR0FBRyxDQUFDO2dCQUFFLE9BQU8sRUFBRSxLQUFLLEVBQUUsVUFBVSxFQUFFLENBQUM7WUFDbEUsT0FBTyxDQUFDLElBQUksR0FBRyxLQUFLLENBQUM7WUFDckIsS0FBSyxJQUFJLENBQUMsQ0FBQztZQUNYLFNBQVM7UUFDWCxDQUFDO1FBQ0QsSUFBSSxLQUFLLEtBQUssY0FBYyxFQUFFLENBQUM7WUFDN0IsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/loop-cli.ts b/packages/loopover-miner/lib/loop-cli.ts new file mode 100644 index 0000000000..676362879a --- /dev/null +++ b/packages/loopover-miner/lib/loop-cli.ts @@ -0,0 +1,660 @@ +// The autonomous supervising loop (#5135, Wave 3.5): the missing daemon/watch layer over the one-shot +// `discover`/`attempt` subcommands. Every existing piece it composes -- runDiscover, runAttempt, +// evaluateRunLoopBoundaryGate, attemptLoopReentry, buildLoopClosureSummary, governor-state.js -- already +// existed; this is the first caller that actually chains them into a real repeat-until-halted run. +// +// STRUCTURE (one cycle): kill-switch check -> pause-flag check (#4851, governor-state.js's persisted +// paused/reason/pausedAt) -> real-per-repo-policy-aware run-loop boundary gate (before claiming) -> real +// runAttempt -> real CI-status poll (ci-poller.js, #5394) + real PR-disposition poll +// (pr-disposition-poller.js, on a submitted outcome) -> real loop-closure summary -> real attemptLoopReentry +// decision. `attemptLoopReentry`'s own dequeue is the +// AUTHORITATIVE claim for every cycle after the first (its own doc: "if allowed -- dequeues the next +// candidate") -- this loop does not ALSO call portfolioQueue.dequeueNext() on a successful reentry, which +// would silently double-claim (the reentry's own claim would then leak as a permanently 'in_progress', never- +// attempted row). A manual dequeueNext() is used only to prime the very first cycle (no prior outcome exists +// yet to reenter from) and to refill after an empty queue. +// +// REAL, NOT FABRICATED: this loop is the first production caller of governor-state.js's `saveCapUsage` +// (turnsTaken from runMinerAttempt's own real `loopResult.totalTurnsUsed`, elapsedMs from real wall-clock +// measurement). Its per-identifier convergence history (attempts/consecutiveFailures/reenqueues) is the real, +// SQLite-persisted portfolio-queue attempt-history (portfolio-queue.js's getAttemptHistory, #5654) that the +// dequeueNext claim + markDone/markFailed calls below already maintain -- the same source a one-shot `attempt` +// invocation reads (#5654), so both share one source of truth and the counters survive a loop-daemon restart +// (crash/deploy/systemd bounce) instead of resetting with the process (#5677). + +import { checkMinerKillSwitch } from "./governor-kill-switch.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; +import { evaluateRunLoopBoundaryGate } from "./governor-run-halt.js"; +import { openGovernorState } from "./governor-state.js"; +import type { GovernorState } from "./governor-state.js"; +import { initGovernorLedger } from "./governor-ledger.js"; +import type { GovernorLedger } from "./governor-ledger.js"; +import { initEventLedger } from "./event-ledger.js"; +import type { EventLedger } from "./event-ledger.js"; +import { initPortfolioQueueStore } from "./portfolio-queue.js"; +import type { PortfolioQueueStore, QueueEntry } from "./portfolio-queue.js"; +import { initRunStateStore } from "./run-state.js"; +import type { RunStateStore } from "./run-state.js"; +import { runDiscover } from "./discover-cli.js"; +import { runAttempt } from "./attempt-cli.js"; +import type { AttemptCliResult } from "./attempt-cli.js"; +import { resolveAmsPolicy } from "./ams-policy.js"; +import { pollPrDisposition, classifyPrDisposition } from "./pr-disposition-poller.js"; +import type { PollPrDispositionOptions } from "./pr-disposition-poller.js"; +import { pollCheckRuns } from "./ci-poller.js"; +import type { CheckRunConclusion, PollCheckRunsOptions } from "./ci-poller.js"; +import { recordPrOutcomeSnapshot } from "./pr-outcome.js"; +import { isRejectedPr } from "./rejection-state-machine.js"; +import { buildLoopClosureSummary } from "./loop-closure.js"; +import { attemptLoopReentry } from "./loop-reentry.js"; +import { parsePrNumberFromExecResult } from "./pr-number-parse.js"; +import { resolveGitHubToken } from "./github-token-resolution.js"; +import { DEFAULT_AMS_POLICY_SPEC } from "@loopover/engine"; +import type { GovernorCapUsage } from "@loopover/engine"; + + +export type ParsedLoopArgs = + | { error: string } + | { + targets: string[]; + search: string | null; + minerLogin: string; + base: string; + live: boolean; + dryRun: boolean; + maxCycles: number | undefined; + cycleDelayMs: number; + json: boolean; + }; + +export type LoopCycleSummary = { + cycle: number; + outcome: "idle_queue_empty" | "halted" | "attempted" | "skipped_malformed_identifier"; + reason?: string; + repoFullName?: string; + identifier?: string; + attemptOutcome?: AttemptCliResult["outcome"] | "attempt_error"; + reentryOutcome?: "merged" | "disengaged" | "other"; + prNumber?: number | null; + ciConclusion?: CheckRunConclusion | null; + reentered?: boolean; + reasons?: string[]; +}; + +export type RunLoopOptions = { + env?: Record; + nowMs?: number; + githubToken?: string; + apiBaseUrl?: string; + sleepFn?: (delayMs: number) => Promise; + openGovernorState?: () => GovernorState; + initEventLedger?: () => EventLedger; + initGovernorLedger?: () => GovernorLedger; + initPortfolioQueue?: () => PortfolioQueueStore; + initRunStateStore?: () => RunStateStore; + runDiscover?: (args: string[], options?: Record) => Promise; + runAttempt?: (args: string[], options?: Record) => Promise; + resolveAmsPolicy?: (repoFullName: string, options?: Record) => Promise<{ spec: Record; source: string; warnings: string[] }>; + checkMinerKillSwitch?: (input?: { env?: Record; repoPaused?: boolean }) => { scope: "global" | "repo" | "none"; active: boolean }; + evaluateRunLoopBoundaryGate?: (input: unknown, options?: unknown) => { verdict: { reason: string }; canClaimNext: boolean }; + pollPrDisposition?: (repoFullName: string, prNumber: number, options?: PollPrDispositionOptions) => Promise<{ state: "open" | "closed"; merged: boolean; closedAt: string | null; attempts: number }>; + pollCheckRuns?: (repoFullName: string, prNumber: number, options?: PollCheckRunsOptions) => Promise<{ conclusion: CheckRunConclusion; checks: unknown[]; headSha: string; attempts: number }>; + recordPrOutcomeSnapshot?: (input: unknown, options?: unknown) => unknown; + buildLoopClosureSummary?: (sources: unknown, options?: unknown) => { sinceSeq: number | null; lastSeq: number }; + attemptLoopReentry?: (candidate: unknown, deps: unknown) => { decision: { reenter: boolean; reasons: string[] }; dequeued: { repoFullName: string; identifier: string; priority: number; status: string; enqueuedAt: string } | null }; + attemptOptions?: Record; + prDispositionOptions?: PollPrDispositionOptions; + ciPollOptions?: PollCheckRunsOptions; +}; + +const LOOP_USAGE = + "Usage: loopover-miner loop [...] | --search --miner-login [--base ] [--live] [--dry-run] [--max-cycles ] [--cycle-delay-ms ] [--json]"; +const DEFAULT_CYCLE_DELAY_MS = 60_000; +const ISSUE_IDENTIFIER_PATTERN = /^issue:(\d+)$/; + +function parseRepoTarget(value: string): string | null { + const trimmed = value.trim(); + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) return null; + return `${owner}/${repo}`; +} + +function normalizeOptionalPositiveInt(value: unknown, label: string): number { + const parsedValue = Number(value); + if (!Number.isFinite(parsedValue) || !Number.isInteger(parsedValue) || parsedValue < 0) { + throw new Error(`${label} must be a non-negative integer: ${value}`); + } + return parsedValue; +} + +export function parseLoopArgs(args: string[]): ParsedLoopArgs { + const options: { + json: boolean; + minerLogin: string | null; + base: string; + live: boolean; + dryRun: boolean; + search: string | null; + maxCycles: number | undefined; + cycleDelayMs: number; + } = { + json: false, + minerLogin: null, + base: "main", + live: false, + dryRun: false, + search: null, + maxCycles: undefined, + cycleDelayMs: DEFAULT_CYCLE_DELAY_MS, + }; + const targets: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--live") { + options.live = true; + continue; + } + // #4847: see attempt-cli.js's own --dry-run comment -- distinct from --live's absence, this short-circuits + // BEFORE governor state or any other store is opened, guaranteeing zero discovery/queue/ledger writes. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--search") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; + options.search = value; + index += 1; + continue; + } + if (token === "--miner-login") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; + options.minerLogin = value; + index += 1; + continue; + } + if (token === "--base") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; + options.base = value; + index += 1; + continue; + } + if (token === "--max-cycles") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; + try { + options.maxCycles = normalizeOptionalPositiveInt(value, "--max-cycles"); + } catch (error) { + return { error: describeCliError(error) }; + } + index += 1; + continue; + } + if (token === "--cycle-delay-ms") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) return { error: LOOP_USAGE }; + try { + options.cycleDelayMs = normalizeOptionalPositiveInt(value, "--cycle-delay-ms"); + } catch (error) { + return { error: describeCliError(error) }; + } + index += 1; + continue; + } + if (token.startsWith("-")) return { error: `Unknown option: ${token}` }; + const target = parseRepoTarget(token); + if (!target) return { error: `Repository must be in owner/repo form: ${token}` }; + targets.push(target); + } + + if (options.search === null && targets.length === 0) return { error: LOOP_USAGE }; + if (options.search !== null && targets.length > 0) return { error: "Pass either repository targets or --search, not both." }; + if (!options.minerLogin) return { error: `--miner-login is required. ${LOOP_USAGE}` }; + + return { + targets, + search: options.search, + minerLogin: options.minerLogin, + base: options.base, + live: options.live, + dryRun: options.dryRun, + maxCycles: options.maxCycles, + cycleDelayMs: options.cycleDelayMs, + json: options.json, + }; +} + +function discoverArgv(parsed: Exclude): string[] { + return parsed.search !== null ? ["--search", parsed.search] : [...parsed.targets]; +} + +function parseIssueNumberFromIdentifier(identifier: unknown): number | null { + const match = typeof identifier === "string" ? identifier.match(ISSUE_IDENTIFIER_PATTERN) : null; + return match ? Number(match[1]) : null; +} + +function defaultSleep(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +/** + * Run one full discover -> claim -> attempt -> observe -> reenter cycle repeatedly until a kill-switch trips, + * the run-loop boundary gate halts (non-convergence or a real budget/turn/elapsed cap), re-entry is declined, + * or `--max-cycles` is reached. Fails closed: refuses to start at all if governor state cannot be loaded. + */ +export async function runLoop(args: string[], options: RunLoopOptions = {}): Promise { + const parsed = parseLoopArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + // Narrow for nested closures (TS resets control-flow narrowing inside nested functions). + const loopArgs = parsed; + + const env = options.env ?? process.env; + const sleepFn = options.sleepFn ?? defaultSleep; + const nowMsFn = () => options.nowMs ?? Date.now(); + const sessionStartMs = nowMsFn(); + + // #4847: reports what a real loop invocation would target and returns BEFORE governor state or any other + // store (event/governor ledger, portfolio queue, run state) is opened -- a provable zero-write path, not just + // "opened but didn't write." The loop's own discovery call enqueues newly-found candidates into the LOCAL + // portfolio queue even before any attempt happens, so a faithful dry run cannot call it either. + if (parsed.dryRun) { + const dryRunResult = { + outcome: "dry_run", + targets: parsed.targets, + search: parsed.search, + minerLogin: parsed.minerLogin, + base: parsed.base, + live: parsed.live, + maxCycles: parsed.maxCycles ?? null, + }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + const target = parsed.search !== null ? `--search ${parsed.search}` : parsed.targets.join(", "); + console.log( + `DRY RUN: would run an autonomous loop against ${target} for ${parsed.minerLogin} (base: ${parsed.base}, live: ${parsed.live}). No discovery, queue, or ledger writes were made.`, + ); + } + return 0; + } + + let governorState: GovernorState; + try { + governorState = (options.openGovernorState ?? openGovernorState)(); + } catch (error) { + return reportCliFailure( + parsed.json, + `Loop refuses to start: governor state cannot be loaded: ${describeCliError(error)}`, + 3, + ); + } + + const eventLedger = (options.initEventLedger ?? initEventLedger)(); + const governorLedger = (options.initGovernorLedger ?? initGovernorLedger)(); + const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); + const runState = (options.initRunStateStore ?? initRunStateStore)(); + + const runDiscoverFn = options.runDiscover ?? runDiscover; + const runAttemptFn = options.runAttempt ?? runAttempt; + const resolveAmsPolicyFn = options.resolveAmsPolicy ?? resolveAmsPolicy; + const checkKillSwitchFn = options.checkMinerKillSwitch ?? checkMinerKillSwitch; + const evaluateBoundaryGateFn = options.evaluateRunLoopBoundaryGate ?? evaluateRunLoopBoundaryGate; + const pollPrDispositionFn = options.pollPrDisposition ?? pollPrDisposition; + const pollCheckRunsFn = options.pollCheckRuns ?? pollCheckRuns; + const recordPrOutcomeSnapshotFn = options.recordPrOutcomeSnapshot ?? recordPrOutcomeSnapshot; + const buildLoopClosureSummaryFn = options.buildLoopClosureSummary ?? buildLoopClosureSummary; + const attemptLoopReentryFn = options.attemptLoopReentry ?? attemptLoopReentry; + + // Resolved ONCE, at the CLI-entrypoint layer, mirroring manage-poll.js's own runManagePoll (its + // recordManagePollSnapshot callee has no env fallback of its own either -- the top-level CLI function is + // where the GitHub token gets resolved, then threaded down explicitly to every real GitHub caller). + // pollPrDisposition (unlike runDiscover, which falls back to process.env.GITHUB_TOKEN internally) has NO + // such fallback -- an unresolved githubToken here would silently poll unauthenticated. + // resolveGitHubToken (#6116): GITHUB_TOKEN env override wins outright, else a live token from the + // authenticated `loopover-mcp login` session -- cached in memory for this process's lifetime. + const githubToken = options.githubToken ?? (await resolveGitHubToken(env as NodeJS.ProcessEnv)) ?? ""; + + async function runDiscoveryOnce() { + await runDiscoverFn(discoverArgv(loopArgs), { + initPortfolioQueue: () => portfolioQueue, + githubToken, + ...(options.apiBaseUrl !== undefined ? { apiBaseUrl: options.apiBaseUrl } : {}), + nowMs: nowMsFn(), + }); + } + + let usage: GovernorCapUsage = governorState.loadCapUsage(); + const cycles: LoopCycleSummary[] = []; + let sinceSeq = eventLedger.readEvents({}).at(-1)?.seq ?? 0; + let haltReason: string | null = null; + + try { + // Checked BEFORE any work at all -- including the very first discovery call -- so an already-active kill + // switch OR an already-active pause (#4851) halts the loop without ever touching GitHub or the queue. The + // pause flag is real, persisted, operator/governor-writable state on governorState (toggled via + // `loopover-miner governor pause`/`resume`) -- unlike the kill switch, a paused run resumes simply by being + // re-invoked: every piece of per-cycle state this loop reads (portfolioQueue, runState, governorState's own + // cap usage) is already durable, so clearing the flag and restarting continues exactly where it left off. + const initialKillSwitch = checkKillSwitchFn({ env }); + const initialPauseState = governorState.loadPauseState(); + let claimed: QueueEntry | null = null; + if (initialKillSwitch.active) { + haltReason = `kill_switch_${initialKillSwitch.scope}`; + cycles.push({ cycle: 1, outcome: "halted", reason: haltReason }); + } else if (initialPauseState.paused) { + haltReason = "paused"; + cycles.push({ cycle: 1, outcome: "halted", reason: haltReason }); + } else { + await runDiscoveryOnce(); + claimed = portfolioQueue.dequeueNext(); + } + + let cycleIndex = haltReason !== null ? 1 : 0; + while (haltReason === null && (parsed.maxCycles === undefined || cycleIndex < parsed.maxCycles)) { + cycleIndex += 1; + + const killSwitch = checkKillSwitchFn({ env }); + if (killSwitch.active) { + haltReason = `kill_switch_${killSwitch.scope}`; + // Release the in-flight claim so left state is defined (#5670 / mirrors run-halt's markFailed). + if (claimed) { + portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + } + cycles.push({ + cycle: cycleIndex, + outcome: "halted", + reason: haltReason, + ...(claimed + ? { repoFullName: claimed.repoFullName, identifier: claimed.identifier } + : {}), + }); + break; + } + + const pauseState = governorState.loadPauseState(); + if (pauseState.paused) { + haltReason = "paused"; + if (claimed) { + portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + } + cycles.push({ + cycle: cycleIndex, + outcome: "halted", + reason: haltReason, + ...(claimed + ? { repoFullName: claimed.repoFullName, identifier: claimed.identifier } + : {}), + }); + break; + } + + if (!claimed) { + cycles.push({ cycle: cycleIndex, outcome: "idle_queue_empty" }); + await sleepFn(parsed.cycleDelayMs); + await runDiscoveryOnce(); + claimed = portfolioQueue.dequeueNext(); + continue; + } + + const issueNumber = parseIssueNumberFromIdentifier(claimed.identifier); + if (issueNumber === null) { + // Never produced by enqueueRankedDiscovery in practice (always "issue:N") -- fail soft rather than + // crash the whole run: this exact item can never be attempted, so it will never resolve on retry. + portfolioQueue.markDone(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + cycles.push({ cycle: cycleIndex, outcome: "skipped_malformed_identifier", identifier: claimed.identifier }); + claimed = portfolioQueue.dequeueNext(); + continue; + } + + // Capture for the boundary-gate markFailed callback (claimed is reassigned later in the loop). + const claimedEntry = claimed; + + const amsPolicy = await resolveAmsPolicyFn(claimedEntry.repoFullName, { env }); + // Real, SQLite-persisted per-item convergence history (#5677): the dequeueNext claim above already recorded + // this attempt and the markDone/markFailed calls below record the outcome, so reading it back here shares one + // source of truth with attempt-cli.js (#5654) and survives a loop-daemon restart instead of resetting. + const convergenceInput = portfolioQueue.getAttemptHistory( + claimedEntry.repoFullName, + claimedEntry.identifier, + claimedEntry.apiBaseUrl, + ); + + // RunLoopOptions.resolveAmsPolicy types spec as Record; fall back when fields are absent. + const limits = + (amsPolicy.spec.capLimits as typeof DEFAULT_AMS_POLICY_SPEC.capLimits | undefined) ?? + DEFAULT_AMS_POLICY_SPEC.capLimits; + const convergenceThresholds = + (amsPolicy.spec.convergenceThresholds as typeof DEFAULT_AMS_POLICY_SPEC.convergenceThresholds | undefined) ?? + DEFAULT_AMS_POLICY_SPEC.convergenceThresholds; + const boundary = evaluateBoundaryGateFn( + { + runHalted: false, + usage, + limits, + convergence: convergenceInput, + convergenceThresholds, + inFlightItem: { repoFullName: claimedEntry.repoFullName, identifier: claimedEntry.identifier }, + // Echoes claimed.apiBaseUrl (#5563), NOT the callback's own repoFullName/identifier alone -- two forge + // hosts can share an in-flight item with the same repo name+identifier. + markFailed: (repoFullName: string, identifier: string) => + portfolioQueue.markFailed(repoFullName, identifier, claimedEntry.apiBaseUrl), + }, + { append: (event: unknown) => governorLedger.appendGovernorEvent(event as Parameters[0]) }, + ); + + if (!boundary.canClaimNext) { + haltReason = `boundary_${boundary.verdict.reason}`; + cycles.push({ cycle: cycleIndex, outcome: "halted", reason: haltReason, repoFullName: claimedEntry.repoFullName, identifier: claimedEntry.identifier }); + break; + } + + const cycleStartMs = nowMsFn(); + // Local result bag: AttemptCliResult is a discriminant union; CFA after the onResult callback + // collapses typed bags to `never`, so keep this local untyped (runtime shape unchanged). + let lastResult: any = null; + const attemptArgv = [ + claimedEntry.repoFullName, + String(issueNumber), + "--miner-login", + parsed.minerLogin, + "--base", + parsed.base, + ...(parsed.live ? ["--live"] : []), + ]; + await runAttemptFn(attemptArgv, { + ...(options.attemptOptions ?? {}), + env, + onResult: (result: AttemptCliResult) => { + lastResult = result; + }, + }); + const cycleElapsedMs = nowMsFn() - cycleStartMs; + + usage = { + // Real for the agent-sdk provider (its own SDK result message reports total_cost_usd, wired through + // runMinerAttempt's real loopResult.totalCostUsd); the CLI-subprocess providers (claude-cli/codex-cli) + // report no cost signal today, so this contributes 0 for those runs -- an honest absence, not a + // fabricated number. A capLimits.budget dimension only ever meaningfully trips against agent-sdk spend. + budgetSpent: usage.budgetSpent + (lastResult?.totalCostUsd ?? 0), + turnsTaken: usage.turnsTaken + (lastResult?.totalTurnsUsed ?? 0), + elapsedMs: usage.elapsedMs + cycleElapsedMs, + }; + governorState.saveCapUsage(usage); + + const attemptOutcome = lastResult?.outcome ?? "attempt_error"; + const submitted = attemptOutcome === "attempt_submitted"; + // A repo-wide AI-usage-policy ban will never resolve on retry -- stop re-queuing it (matches + // rejection-signal.js's own "this repo bans automated contributions" semantics). Every other blocked/ + // abandoned/stale/governed outcome MAY resolve on a later retry (transient infra, contention, a + // different iteration budget) and is requeued -- a genuinely stuck item is caught by non-convergence + // (reenqueues threshold) rather than silently retried forever. + const permanentBlock = attemptOutcome === "blocked_rejection_signaled"; + // Mid-attempt kill-switch abandon (#5670): stop the outer loop immediately instead of waiting for the + // next between-cycle probe, and treat the item like any other re-queued abandon via markFailed below. + const killSwitchAbandon = lastResult?.abandonReason === "kill_switch_engaged"; + + if (submitted || permanentBlock) { + // Both terminal -- a submitted PR is done, and a repo-wide AI-usage-policy ban never resolves on retry -- + // so neither is re-queued. markDone also clears the persisted consecutive-failure streak. + portfolioQueue.markDone(claimedEntry.repoFullName, claimedEntry.identifier, claimedEntry.apiBaseUrl); + } else { + // Any other blocked/abandoned/stale/governed outcome may resolve on a later retry, so requeue it; markFailed + // records the re-enqueue + consecutive failure the non-convergence detector reads on the next cycle. + portfolioQueue.markFailed(claimedEntry.repoFullName, claimedEntry.identifier, claimedEntry.apiBaseUrl); + } + + if (killSwitchAbandon) { + const liveKill = checkKillSwitchFn({ env }); + haltReason = liveKill.active ? `kill_switch_${liveKill.scope}` : "kill_switch_engaged"; + cycles.push({ + cycle: cycleIndex, + outcome: "halted", + reason: haltReason, + repoFullName: claimedEntry.repoFullName, + identifier: claimedEntry.identifier, + attemptOutcome, + }); + break; + } + + let reentryOutcome: "merged" | "disengaged" | "other" = "other"; + let prNumber: number | null = null; + let prDisposition: { state: "open" | "closed"; merged: boolean; closedAt: string | null; attempts: number } | null = null; + let ciConclusion: CheckRunConclusion | null = null; + if (submitted) { + prNumber = parsePrNumberFromExecResult( + lastResult?.execResult as Parameters[0], + claimedEntry.repoFullName, + ); + if (prNumber !== null) { + // Real CI-status observation (#5394): recorded BEFORE the disposition poll below, so a submitted + // PR's check-run state is captured even while it's still open, not just at its eventual merge/close. + // ci-poller.js's real GitHub check-run polling is a heuristic proxy for the gate verdict; the + // authoritative terminal merge/close outcome comes from pollPrDispositionFn below, sourced directly + // from GitHub's own PR state rather than a server-internal endpoint (#5450). + const ciStatus = await pollCheckRunsFn(claimedEntry.repoFullName, prNumber, { + githubToken, + ...(options.apiBaseUrl !== undefined ? { apiBaseUrl: options.apiBaseUrl } : {}), + ...(options.ciPollOptions ?? {}), + } as PollCheckRunsOptions); + ciConclusion = ciStatus.conclusion; + eventLedger.appendEvent({ + type: "ci_status_observed", + repoFullName: claimedEntry.repoFullName, + payload: { prNumber, conclusion: ciStatus.conclusion, checkCount: ciStatus.checks.length, source: "ci-poller" }, + }); + + prDisposition = await pollPrDispositionFn(claimedEntry.repoFullName, prNumber, { + githubToken, + ...(options.apiBaseUrl !== undefined ? { apiBaseUrl: options.apiBaseUrl } : {}), + ...(options.prDispositionOptions ?? {}), + } as PollPrDispositionOptions); + if (prDisposition.state === "closed") { + recordPrOutcomeSnapshotFn( + { + repoFullName: claimedEntry.repoFullName, + prNumber, + decision: prDisposition.merged ? "merged" : "closed", + closedAt: prDisposition.closedAt, + }, + { eventLedger }, + ); + // Real per-repo reputation history (#5675): a resolved terminal outcome updates the decided/unfavorable + // counts the Governor's self-reputation throttle reads on this repo's next attempt. `decided` always; + // `unfavorable` only on a closed-without-merge (rejection-state-machine.js's isRejectedPr, matching + // #5655's own-rejection classification). Forge-scoped by claimed.apiBaseUrl (#5563), like every other + // governor-state write here. + const priorReputation = governorState.loadReputationHistory(claimed.repoFullName, claimed.apiBaseUrl); + governorState.saveReputationHistory( + claimed.repoFullName, + { + decided: priorReputation.decided + 1, + unfavorable: priorReputation.unfavorable + (isRejectedPr(prDisposition) ? 1 : 0), + }, + claimed.apiBaseUrl, + ); + reentryOutcome = classifyPrDisposition(prDisposition) as "merged" | "disengaged" | "other"; + } + } + } + + const loopSummary = buildLoopClosureSummaryFn( + { eventLedger, portfolioQueue, runState }, + { sinceSeq, repoFullName: claimed.repoFullName }, + ); + sinceSeq = loopSummary.lastSeq; + + const reentry = attemptLoopReentryFn( + { killSwitchScope: killSwitch.scope, repoFullName: claimed.repoFullName, outcome: reentryOutcome }, + { eventLedger, portfolioQueue, runState, nowMs: nowMsFn(), sessionStartMs, loopSummary }, + ); + + cycles.push({ + cycle: cycleIndex, + outcome: "attempted", + repoFullName: claimed.repoFullName, + identifier: claimed.identifier, + attemptOutcome, + reentryOutcome, + prNumber, + ciConclusion, + reentered: reentry.decision.reenter, + reasons: reentry.decision.reasons, + }); + + if (!reentry.decision.reenter) { + haltReason = `reentry_declined:${reentry.decision.reasons.join(",")}`; + break; + } + + if (reentry.dequeued) { + // attemptLoopReentry's injectable .d.ts types dequeued.status as string; QueueEntry wants QueueStatus. + claimed = reentry.dequeued as QueueEntry; + await sleepFn(parsed.cycleDelayMs); + } else { + await sleepFn(parsed.cycleDelayMs); + await runDiscoveryOnce(); + claimed = portfolioQueue.dequeueNext(); + } + } + + if (haltReason === null && parsed.maxCycles !== undefined) { + haltReason = "max_cycles_reached"; + // The next cycle's item is primed (dequeued → 'in_progress') BEFORE the while-condition re-checks + // maxCycles -- both at the initial priming above and at each cycle's tail -- so exhausting maxCycles + // ends the run holding a claim no cycle ever processed. Release it, mirroring the kill-switch/pause + // halts (#5670): dequeueNext() only pulls 'queued' rows, so an unreleased claim is invisible to every + // future loop/attempt run until an out-of-band stale-lease sweep reclaims it. + if (claimed) { + portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl); + } + } + + // After the max-cycles release block above, haltReason is always set on a clean exit. + const summary = { haltReason, cyclesRun: cycles.length, cycles }; + if (parsed.json) { + console.log(JSON.stringify(summary, null, 2)); + } else { + console.log(`Loop finished after ${cycles.length} cycle(s): ${haltReason}.`); + } + return 0; + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } finally { + governorState.close(); + eventLedger.close(); + governorLedger.close(); + portfolioQueue.close(); + runState.close(); + } +} diff --git a/packages/loopover-miner/lib/loop-reentry.d.ts b/packages/loopover-miner/lib/loop-reentry.d.ts index ab4b3721bf..6b894881c0 100644 --- a/packages/loopover-miner/lib/loop-reentry.d.ts +++ b/packages/loopover-miner/lib/loop-reentry.d.ts @@ -1,51 +1,94 @@ -export const LOOP_REENTRY_DECISION_EVENT: "loop_reentry_decision"; - +export declare const LOOP_REENTRY_DECISION_EVENT: "loop_reentry_decision"; export type LoopReentryOutcome = "merged" | "disengaged" | "other"; export type LoopReentryKillSwitchScope = "global" | "repo" | "none"; - export type LoopReentryCandidateInput = { - /** Checked FIRST by the pure `shouldReenter` policy, before any other logic. */ - killSwitchScope: LoopReentryKillSwitchScope; - repoFullName: string; - outcome: LoopReentryOutcome; - maxConsecutiveDisengagements?: number; - maxReentriesPerHour?: number; - maxReentriesPerSession?: number; + /** Checked FIRST by the pure `shouldReenter` policy, before any other logic. */ + killSwitchScope: LoopReentryKillSwitchScope; + repoFullName: string; + outcome: LoopReentryOutcome; + maxConsecutiveDisengagements?: number; + maxReentriesPerHour?: number; + maxReentriesPerSession?: number; }; - export interface LoopReentryEventLedger { - appendEvent(event: { type: string; repoFullName?: string; payload: Record }): { id: number; seq: number; type: string; repoFullName: string | null; payload: Record; createdAt: string }; - readEvents(filter?: { since?: number; repoFullName?: string }): Array<{ type: string; repoFullName?: string | null; payload?: Record; createdAt: string }>; + appendEvent(event: { + type: string; + repoFullName?: string; + payload: Record; + }): { + id: number; + seq: number; + type: string; + repoFullName: string | null; + payload: Record; + createdAt: string; + }; + readEvents(filter?: { + since?: number; + repoFullName?: string; + }): Array<{ + type: string; + repoFullName?: string | null; + payload?: Record; + createdAt: string; + }>; } - export interface LoopReentryPortfolioQueue { - dequeueNext(): { repoFullName: string; identifier: string; priority: number; status: string; enqueuedAt: string } | null; + dequeueNext(): { + repoFullName: string; + identifier: string; + priority: number; + status: string; + enqueuedAt: string; + } | null; } - export interface LoopReentryRunState { - setRunState(repoFullName: string, state: string): unknown; + setRunState(repoFullName: string, state: string): unknown; } - export type LoopReentryDeps = { - eventLedger: LoopReentryEventLedger; - portfolioQueue: LoopReentryPortfolioQueue; - runState?: LoopReentryRunState; - nowMs?: number; - sessionStartMs?: number; - /** The just-completed cycle's read-only summary (loop-closure.js's `buildLoopClosureSummary`), threaded - * through verbatim into the audit event's payload for traceability. Not used to compute the circuit- - * breaker/rate-cap tallies -- see loop-reentry.js's own comment on why. */ - loopSummary?: unknown; + eventLedger: LoopReentryEventLedger; + portfolioQueue: LoopReentryPortfolioQueue; + runState?: LoopReentryRunState; + nowMs?: number; + sessionStartMs?: number; + /** The just-completed cycle's read-only summary (loop-closure.js's `buildLoopClosureSummary`), threaded + * through verbatim into the audit event's payload for traceability. Not used to compute the circuit- + * breaker/rate-cap tallies -- see loop-reentry.js's own comment on why. */ + loopSummary?: unknown; }; - export type LoopReentryResult = { - decision: { reenter: boolean; reasons: string[] }; - dequeued: { repoFullName: string; identifier: string; priority: number; status: string; enqueuedAt: string } | null; - event: { id: number; seq: number; type: string; repoFullName: string | null; payload: Record; createdAt: string }; + decision: { + reenter: boolean; + reasons: string[]; + }; + dequeued: { + repoFullName: string; + identifier: string; + priority: number; + status: string; + enqueuedAt: string; + } | null; + event: { + id: number; + seq: number; + type: string; + repoFullName: string | null; + payload: Record; + createdAt: string; + }; }; - -export function countConsecutiveDisengagements(eventLedger: LoopReentryEventLedger, repoFullName: string): number; - -export function countReentriesSince(eventLedger: LoopReentryEventLedger, sinceMs: number): number; - -export function attemptLoopReentry(candidate: LoopReentryCandidateInput, deps: LoopReentryDeps): LoopReentryResult; +/** + * Count a repo's CONSECUTIVE disengaged (closed-without-merge) PR outcomes, walking backward from the most + * recently recorded PR for that repo until a merged outcome breaks the streak (or history runs out). + */ +export declare function countConsecutiveDisengagements(eventLedger: LoopReentryEventLedger, repoFullName: string): number; +/** Count prior re-entries (successful, i.e. `reentered: true`) recorded at or after `sinceMs`. */ +export declare function countReentriesSince(eventLedger: LoopReentryEventLedger, sinceMs: number): number; +/** + * Evaluate and (if allowed) PERFORM re-entry for one resolved outcome: reads real history to compute the + * circuit-breaker and rate-cap tallies, consults the pure `shouldReenter` policy, and -- only when it allows -- + * dequeues the next candidate and transitions run-state to `"discovering"`. Always appends exactly one audit + * event. Fails closed (throws) on a malformed candidate or missing required dependency, mirroring + * `recordManagePollSnapshot`'s own validation style. + */ +export declare function attemptLoopReentry(candidate: LoopReentryCandidateInput, deps: LoopReentryDeps): LoopReentryResult; diff --git a/packages/loopover-miner/lib/loop-reentry.js b/packages/loopover-miner/lib/loop-reentry.js index d183502384..8314472f6d 100644 --- a/packages/loopover-miner/lib/loop-reentry.js +++ b/packages/loopover-miner/lib/loop-reentry.js @@ -1,7 +1,5 @@ import { shouldReenter } from "@loopover/engine"; - import { readPrOutcomes } from "./pr-outcome.js"; - // Closed-loop discovery re-entry orchestrator (#2338): the real-IO half of "on a resolved outcome (merged, or // rejected-and-disengaged), automatically re-invoke discovery to select the next candidate." The DECISION // itself (shouldReenter, @loopover/engine) is pure; this module owns everything that decision @@ -16,109 +14,105 @@ import { readPrOutcomes } from "./pr-outcome.js"; // AUDITABILITY: every call appends exactly one `loop_reentry_decision` event to the ledger, whether or not the // decision allowed re-entry, so the full decision trail (including every suppressed re-entry and why) survives // independently of this function's own return value. - export const LOOP_REENTRY_DECISION_EVENT = "loop_reentry_decision"; const HOUR_MS = 60 * 60 * 1000; - /** A `pr_outcome` "closed" decision is this module's practical proxy for "disengaged" -- pr-outcome.js's own * vocabulary is exactly `"merged" | "closed"` (no separate "disengaged" literal); a PR that closed without * merging IS the rejected/disengaged case rejection-state-machine.js's own `isRejectedPr` checks for. */ function isDisengagedOutcome(outcome) { - return outcome?.decision === "closed"; + return outcome?.decision === "closed"; } - /** * Count a repo's CONSECUTIVE disengaged (closed-without-merge) PR outcomes, walking backward from the most * recently recorded PR for that repo until a merged outcome breaks the streak (or history runs out). */ export function countConsecutiveDisengagements(eventLedger, repoFullName) { - const outcomes = [...readPrOutcomes(eventLedger, { repoFullName }).values()]; - let count = 0; - for (let i = outcomes.length - 1; i >= 0; i -= 1) { - if (!isDisengagedOutcome(outcomes[i])) break; - count += 1; - } - return count; + const outcomes = [...readPrOutcomes(eventLedger, { repoFullName }).values()]; + let count = 0; + for (let i = outcomes.length - 1; i >= 0; i -= 1) { + if (!isDisengagedOutcome(outcomes[i])) + break; + count += 1; + } + return count; } - /** Count prior re-entries (successful, i.e. `reentered: true`) recorded at or after `sinceMs`. */ export function countReentriesSince(eventLedger, sinceMs) { - return eventLedger - .readEvents({}) - .filter((event) => event.type === LOOP_REENTRY_DECISION_EVENT && event.payload?.reentered === true && Date.parse(event.createdAt) >= sinceMs) - .length; + return eventLedger + .readEvents({}) + .filter((event) => event.type === LOOP_REENTRY_DECISION_EVENT && + event.payload?.reentered === true && + Date.parse(event.createdAt) >= sinceMs).length; } - /** * Evaluate and (if allowed) PERFORM re-entry for one resolved outcome: reads real history to compute the * circuit-breaker and rate-cap tallies, consults the pure `shouldReenter` policy, and -- only when it allows -- * dequeues the next candidate and transitions run-state to `"discovering"`. Always appends exactly one audit * event. Fails closed (throws) on a malformed candidate or missing required dependency, mirroring * `recordManagePollSnapshot`'s own validation style. - * - * @param {{ killSwitchScope: "global"|"repo"|"none", repoFullName: string, outcome: "merged"|"disengaged"|"other", maxConsecutiveDisengagements?: number, maxReentriesPerHour?: number, maxReentriesPerSession?: number }} candidate - * @param {{ eventLedger: object, portfolioQueue: object, runState?: object, nowMs?: number, sessionStartMs?: number }} deps */ export function attemptLoopReentry(candidate, deps) { - if (!candidate || typeof candidate !== "object") throw new Error("invalid_loop_reentry_candidate"); - if (!["global", "repo", "none"].includes(candidate.killSwitchScope)) throw new Error("invalid_kill_switch_scope"); - const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; - if (!repoFullName) throw new Error("invalid_repo_full_name"); - if (!["merged", "disengaged", "other"].includes(candidate.outcome)) throw new Error("invalid_outcome"); - - if (!deps || typeof deps !== "object") throw new Error("invalid_loop_reentry_deps"); - const { eventLedger, portfolioQueue, runState, nowMs = Date.now(), sessionStartMs = 0 } = deps; - if (!eventLedger || typeof eventLedger.appendEvent !== "function" || typeof eventLedger.readEvents !== "function") { - throw new Error("invalid_event_ledger"); - } - if (!portfolioQueue || typeof portfolioQueue.dequeueNext !== "function") { - throw new Error("invalid_portfolio_queue"); - } - - const consecutiveDisengagements = countConsecutiveDisengagements(eventLedger, repoFullName); - const reentriesThisHour = countReentriesSince(eventLedger, nowMs - HOUR_MS); - const reentriesThisSession = countReentriesSince(eventLedger, sessionStartMs); - - const decision = shouldReenter({ - killSwitchScope: candidate.killSwitchScope, - repoFullName, - outcome: candidate.outcome, - consecutiveDisengagements, - maxConsecutiveDisengagements: candidate.maxConsecutiveDisengagements, - reentriesThisHour, - maxReentriesPerHour: candidate.maxReentriesPerHour, - reentriesThisSession, - maxReentriesPerSession: candidate.maxReentriesPerSession, - }); - - let dequeued = null; - if (decision.reenter) { - dequeued = portfolioQueue.dequeueNext(); - if (runState && typeof runState.setRunState === "function") { - runState.setRunState(repoFullName, "discovering"); + // Runtime guards retained from the JS (tests may cast malformed inputs past the public types). + if (!candidate || typeof candidate !== "object") + throw new Error("invalid_loop_reentry_candidate"); + if (!["global", "repo", "none"].includes(candidate.killSwitchScope)) + throw new Error("invalid_kill_switch_scope"); + const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; + if (!repoFullName) + throw new Error("invalid_repo_full_name"); + if (!["merged", "disengaged", "other"].includes(candidate.outcome)) + throw new Error("invalid_outcome"); + if (!deps || typeof deps !== "object") + throw new Error("invalid_loop_reentry_deps"); + const { eventLedger, portfolioQueue, runState, nowMs = Date.now(), sessionStartMs = 0 } = deps; + if (!eventLedger || typeof eventLedger.appendEvent !== "function" || typeof eventLedger.readEvents !== "function") { + throw new Error("invalid_event_ledger"); + } + if (!portfolioQueue || typeof portfolioQueue.dequeueNext !== "function") { + throw new Error("invalid_portfolio_queue"); + } + const consecutiveDisengagements = countConsecutiveDisengagements(eventLedger, repoFullName); + const reentriesThisHour = countReentriesSince(eventLedger, nowMs - HOUR_MS); + const reentriesThisSession = countReentriesSince(eventLedger, sessionStartMs); + // Cast: public optional fields omit `| undefined`; engine accepts `number | undefined` under EOPT. + const decision = shouldReenter({ + killSwitchScope: candidate.killSwitchScope, + repoFullName, + outcome: candidate.outcome, + consecutiveDisengagements, + maxConsecutiveDisengagements: candidate.maxConsecutiveDisengagements, + reentriesThisHour, + maxReentriesPerHour: candidate.maxReentriesPerHour, + reentriesThisSession, + maxReentriesPerSession: candidate.maxReentriesPerSession, + }); + let dequeued = null; + if (decision.reenter) { + dequeued = portfolioQueue.dequeueNext(); + if (runState && typeof runState.setRunState === "function") { + runState.setRunState(repoFullName, "discovering"); + } } - } - - const event = eventLedger.appendEvent({ - type: LOOP_REENTRY_DECISION_EVENT, - repoFullName, - payload: { - killSwitchScope: candidate.killSwitchScope, - outcome: candidate.outcome, - reentered: decision.reenter, - reasons: decision.reasons, - consecutiveDisengagements, - reentriesThisHour, - reentriesThisSession, - dequeuedIdentifier: dequeued ? dequeued.identifier : null, - // The just-completed cycle's read-only summary (loop-closure.js's buildLoopClosureSummary), when the - // caller supplies one -- threaded through verbatim for audit traceability. Optional: the circuit-breaker - // and rate-cap tallies above are computed directly from pr-outcome/event-ledger history (a - // LoopClosureSummary's own byType COUNTS aren't detailed enough to derive a per-repo consecutive- - // disengagement streak from), so this is context, not a computational input. - loopSummary: deps.loopSummary ?? null, - }, - }); - - return { decision, dequeued, event }; + const event = eventLedger.appendEvent({ + type: LOOP_REENTRY_DECISION_EVENT, + repoFullName, + payload: { + killSwitchScope: candidate.killSwitchScope, + outcome: candidate.outcome, + reentered: decision.reenter, + reasons: decision.reasons, + consecutiveDisengagements, + reentriesThisHour, + reentriesThisSession, + dequeuedIdentifier: dequeued ? dequeued.identifier : null, + // The just-completed cycle's read-only summary (loop-closure.js's buildLoopClosureSummary), when the + // caller supplies one -- threaded through verbatim for audit traceability. Optional: the circuit-breaker + // and rate-cap tallies above are computed directly from pr-outcome/event-ledger history (a + // LoopClosureSummary's own byType COUNTS aren't detailed enough to derive a per-repo consecutive- + // disengagement streak from), so this is context, not a computational input. + loopSummary: deps.loopSummary ?? null, + }, + }); + return { decision, dequeued, event }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoibG9vcC1yZWVudHJ5LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsibG9vcC1yZWVudHJ5LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxhQUFhLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUVqRCxPQUFPLEVBQUUsY0FBYyxFQUFFLE1BQU0saUJBQWlCLENBQUM7QUFFakQsOEdBQThHO0FBQzlHLDBHQUEwRztBQUMxRyw4RkFBOEY7QUFDOUYseUdBQXlHO0FBQ3pHLCtHQUErRztBQUMvRyxxRUFBcUU7QUFDckUsRUFBRTtBQUNGLHdHQUF3RztBQUN4RyxnSEFBZ0g7QUFDaEgsa0VBQWtFO0FBQ2xFLEVBQUU7QUFDRiwrR0FBK0c7QUFDL0csK0dBQStHO0FBQy9HLHFEQUFxRDtBQUVyRCxNQUFNLENBQUMsTUFBTSwyQkFBMkIsR0FBRyx1QkFBZ0MsQ0FBQztBQUM1RSxNQUFNLE9BQU8sR0FBRyxFQUFFLEdBQUcsRUFBRSxHQUFHLElBQUksQ0FBQztBQWlGL0I7OzBHQUUwRztBQUMxRyxTQUFTLG1CQUFtQixDQUFDLE9BQWtEO0lBQzdFLE9BQU8sT0FBTyxFQUFFLFFBQVEsS0FBSyxRQUFRLENBQUM7QUFDeEMsQ0FBQztBQUVEOzs7R0FHRztBQUNILE1BQU0sVUFBVSw4QkFBOEIsQ0FDNUMsV0FBbUMsRUFDbkMsWUFBb0I7SUFFcEIsTUFBTSxRQUFRLEdBQUcsQ0FBQyxHQUFHLGNBQWMsQ0FBQyxXQUFXLEVBQUUsRUFBRSxZQUFZLEVBQUUsQ0FBQyxDQUFDLE1BQU0sRUFBRSxDQUFDLENBQUM7SUFDN0UsSUFBSSxLQUFLLEdBQUcsQ0FBQyxDQUFDO0lBQ2QsS0FBSyxJQUFJLENBQUMsR0FBRyxRQUFRLENBQUMsTUFBTSxHQUFHLENBQUMsRUFBRSxDQUFDLElBQUksQ0FBQyxFQUFFLENBQUMsSUFBSSxDQUFDLEVBQUUsQ0FBQztRQUNqRCxJQUFJLENBQUMsbUJBQW1CLENBQUMsUUFBUSxDQUFDLENBQUMsQ0FBQyxDQUFDO1lB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/loop-reentry.ts b/packages/loopover-miner/lib/loop-reentry.ts new file mode 100644 index 0000000000..80b997f8f0 --- /dev/null +++ b/packages/loopover-miner/lib/loop-reentry.ts @@ -0,0 +1,212 @@ +import { shouldReenter } from "@loopover/engine"; + +import { readPrOutcomes } from "./pr-outcome.js"; + +// Closed-loop discovery re-entry orchestrator (#2338): the real-IO half of "on a resolved outcome (merged, or +// rejected-and-disengaged), automatically re-invoke discovery to select the next candidate." The DECISION +// itself (shouldReenter, @loopover/engine) is pure; this module owns everything that decision +// needs real state for -- reading the repo's own pr_outcome history to compute the per-repo consecutive- +// disengagement tally, reading recent re-entry events for the hourly/session rate cap, and (only when allowed) +// actually dequeuing the next candidate and transitioning run-state. +// +// NOT WIRED INTO ANY AUTOMATIC SCHEDULE: per this issue's own "manual owner sign-off before enabling by +// default in any profile" deliverable, this is a callable function ready for that sign-off -- it is not invoked +// by manage-poll.js or any cron/scheduler as part of this change. +// +// AUDITABILITY: every call appends exactly one `loop_reentry_decision` event to the ledger, whether or not the +// decision allowed re-entry, so the full decision trail (including every suppressed re-entry and why) survives +// independently of this function's own return value. + +export const LOOP_REENTRY_DECISION_EVENT = "loop_reentry_decision" as const; +const HOUR_MS = 60 * 60 * 1000; + +export type LoopReentryOutcome = "merged" | "disengaged" | "other"; +export type LoopReentryKillSwitchScope = "global" | "repo" | "none"; + +export type LoopReentryCandidateInput = { + /** Checked FIRST by the pure `shouldReenter` policy, before any other logic. */ + killSwitchScope: LoopReentryKillSwitchScope; + repoFullName: string; + outcome: LoopReentryOutcome; + maxConsecutiveDisengagements?: number; + maxReentriesPerHour?: number; + maxReentriesPerSession?: number; +}; + +export interface LoopReentryEventLedger { + appendEvent(event: { + type: string; + repoFullName?: string; + payload: Record; + }): { + id: number; + seq: number; + type: string; + repoFullName: string | null; + payload: Record; + createdAt: string; + }; + readEvents(filter?: { since?: number; repoFullName?: string }): Array<{ + type: string; + repoFullName?: string | null; + payload?: Record; + createdAt: string; + }>; +} + +export interface LoopReentryPortfolioQueue { + dequeueNext(): { + repoFullName: string; + identifier: string; + priority: number; + status: string; + enqueuedAt: string; + } | null; +} + +export interface LoopReentryRunState { + setRunState(repoFullName: string, state: string): unknown; +} + +export type LoopReentryDeps = { + eventLedger: LoopReentryEventLedger; + portfolioQueue: LoopReentryPortfolioQueue; + runState?: LoopReentryRunState; + nowMs?: number; + sessionStartMs?: number; + /** The just-completed cycle's read-only summary (loop-closure.js's `buildLoopClosureSummary`), threaded + * through verbatim into the audit event's payload for traceability. Not used to compute the circuit- + * breaker/rate-cap tallies -- see loop-reentry.js's own comment on why. */ + loopSummary?: unknown; +}; + +export type LoopReentryResult = { + decision: { reenter: boolean; reasons: string[] }; + dequeued: { + repoFullName: string; + identifier: string; + priority: number; + status: string; + enqueuedAt: string; + } | null; + event: { + id: number; + seq: number; + type: string; + repoFullName: string | null; + payload: Record; + createdAt: string; + }; +}; + +/** A `pr_outcome` "closed" decision is this module's practical proxy for "disengaged" -- pr-outcome.js's own + * vocabulary is exactly `"merged" | "closed"` (no separate "disengaged" literal); a PR that closed without + * merging IS the rejected/disengaged case rejection-state-machine.js's own `isRejectedPr` checks for. */ +function isDisengagedOutcome(outcome: { decision?: unknown } | null | undefined): boolean { + return outcome?.decision === "closed"; +} + +/** + * Count a repo's CONSECUTIVE disengaged (closed-without-merge) PR outcomes, walking backward from the most + * recently recorded PR for that repo until a merged outcome breaks the streak (or history runs out). + */ +export function countConsecutiveDisengagements( + eventLedger: LoopReentryEventLedger, + repoFullName: string, +): number { + const outcomes = [...readPrOutcomes(eventLedger, { repoFullName }).values()]; + let count = 0; + for (let i = outcomes.length - 1; i >= 0; i -= 1) { + if (!isDisengagedOutcome(outcomes[i])) break; + count += 1; + } + return count; +} + +/** Count prior re-entries (successful, i.e. `reentered: true`) recorded at or after `sinceMs`. */ +export function countReentriesSince(eventLedger: LoopReentryEventLedger, sinceMs: number): number { + return eventLedger + .readEvents({}) + .filter( + (event) => + event.type === LOOP_REENTRY_DECISION_EVENT && + event.payload?.reentered === true && + Date.parse(event.createdAt) >= sinceMs, + ).length; +} + +/** + * Evaluate and (if allowed) PERFORM re-entry for one resolved outcome: reads real history to compute the + * circuit-breaker and rate-cap tallies, consults the pure `shouldReenter` policy, and -- only when it allows -- + * dequeues the next candidate and transitions run-state to `"discovering"`. Always appends exactly one audit + * event. Fails closed (throws) on a malformed candidate or missing required dependency, mirroring + * `recordManagePollSnapshot`'s own validation style. + */ +export function attemptLoopReentry( + candidate: LoopReentryCandidateInput, + deps: LoopReentryDeps, +): LoopReentryResult { + // Runtime guards retained from the JS (tests may cast malformed inputs past the public types). + if (!candidate || typeof candidate !== "object") throw new Error("invalid_loop_reentry_candidate"); + if (!["global", "repo", "none"].includes(candidate.killSwitchScope)) throw new Error("invalid_kill_switch_scope"); + const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; + if (!repoFullName) throw new Error("invalid_repo_full_name"); + if (!["merged", "disengaged", "other"].includes(candidate.outcome)) throw new Error("invalid_outcome"); + + if (!deps || typeof deps !== "object") throw new Error("invalid_loop_reentry_deps"); + const { eventLedger, portfolioQueue, runState, nowMs = Date.now(), sessionStartMs = 0 } = deps; + if (!eventLedger || typeof eventLedger.appendEvent !== "function" || typeof eventLedger.readEvents !== "function") { + throw new Error("invalid_event_ledger"); + } + if (!portfolioQueue || typeof portfolioQueue.dequeueNext !== "function") { + throw new Error("invalid_portfolio_queue"); + } + + const consecutiveDisengagements = countConsecutiveDisengagements(eventLedger, repoFullName); + const reentriesThisHour = countReentriesSince(eventLedger, nowMs - HOUR_MS); + const reentriesThisSession = countReentriesSince(eventLedger, sessionStartMs); + + // Cast: public optional fields omit `| undefined`; engine accepts `number | undefined` under EOPT. + const decision = shouldReenter({ + killSwitchScope: candidate.killSwitchScope, + repoFullName, + outcome: candidate.outcome, + consecutiveDisengagements, + maxConsecutiveDisengagements: candidate.maxConsecutiveDisengagements, + reentriesThisHour, + maxReentriesPerHour: candidate.maxReentriesPerHour, + reentriesThisSession, + maxReentriesPerSession: candidate.maxReentriesPerSession, + } as Parameters[0]); + + let dequeued: LoopReentryResult["dequeued"] = null; + if (decision.reenter) { + dequeued = portfolioQueue.dequeueNext(); + if (runState && typeof runState.setRunState === "function") { + runState.setRunState(repoFullName, "discovering"); + } + } + + const event = eventLedger.appendEvent({ + type: LOOP_REENTRY_DECISION_EVENT, + repoFullName, + payload: { + killSwitchScope: candidate.killSwitchScope, + outcome: candidate.outcome, + reentered: decision.reenter, + reasons: decision.reasons, + consecutiveDisengagements, + reentriesThisHour, + reentriesThisSession, + dequeuedIdentifier: dequeued ? dequeued.identifier : null, + // The just-completed cycle's read-only summary (loop-closure.js's buildLoopClosureSummary), when the + // caller supplies one -- threaded through verbatim for audit traceability. Optional: the circuit-breaker + // and rate-cap tallies above are computed directly from pr-outcome/event-ledger history (a + // LoopClosureSummary's own byType COUNTS aren't detailed enough to derive a per-repo consecutive- + // disengagement streak from), so this is context, not a computational input. + loopSummary: deps.loopSummary ?? null, + }, + }); + + return { decision, dequeued, event }; +} diff --git a/packages/loopover-miner/lib/oauth-device-flow.d.ts b/packages/loopover-miner/lib/oauth-device-flow.d.ts index fcb3456235..bc0200285d 100644 --- a/packages/loopover-miner/lib/oauth-device-flow.d.ts +++ b/packages/loopover-miner/lib/oauth-device-flow.d.ts @@ -1,44 +1,53 @@ -export function resolveAmsOauthClientId(env?: Record): string; - -export class DeviceFlowError extends Error { - constructor(code: string, message?: string); - code: string; -} - export type DeviceCode = { - deviceCode: string; - userCode: string; - verificationUri: string; - expiresInSeconds: number; - intervalSeconds: number; + deviceCode: string; + userCode: string; + verificationUri: string; + expiresInSeconds: number; + intervalSeconds: number; }; - export type DeviceFlowTokenResult = { - accessToken: string; - scope: string; + accessToken: string; + scope: string; }; - -export function requestDeviceCode(options: { - clientId: string; - scope?: string; - fetchFn?: typeof fetch; +/** The centrally-held loopover-ams App's OAuth client id -- public (not secret), so it's safe to read from a + * plain env var. Empty/unset means device-flow authorization isn't available in this build/deployment. */ +export declare function resolveAmsOauthClientId(env?: Record): string; +export declare class DeviceFlowError extends Error { + code: string; + constructor(code: string, message?: string); +} +/** Step 1 of the device flow: request a device code + the short user-facing code from GitHub. */ +export declare function requestDeviceCode({ clientId, scope, fetchFn, }?: { + clientId: string; + scope?: string; + fetchFn?: typeof fetch; }): Promise; - -export function pollForAccessToken(options: { - clientId: string; - deviceCode: string; - intervalSeconds?: number; - expiresInSeconds?: number; - fetchFn?: typeof fetch; - sleepFn?: (ms: number) => Promise; - now?: () => number; +/** + * Step 2: poll for the access token, honoring GitHub's device-flow polling protocol -- + * `authorization_pending` keeps polling at the current interval, `slow_down` increases it (to GitHub's own + * requested value when given), `expired_token`/`access_denied` are terminal failures, anything else is an + * unexpected terminal failure. Bounded by `expiresInSeconds` so a caller can never poll forever. + */ +export declare function pollForAccessToken({ clientId, deviceCode, intervalSeconds, expiresInSeconds, fetchFn, sleepFn, now, }?: { + clientId: string; + deviceCode: string; + intervalSeconds?: number; + expiresInSeconds?: number; + fetchFn?: typeof fetch; + sleepFn?: (ms: number) => Promise; + now?: () => number; }): Promise; - -export function runDeviceFlowAuthorization(options: { - clientId: string; - scope?: string; - onCode: (code: DeviceCode) => void | Promise; - fetchFn?: typeof fetch; - sleepFn?: (ms: number) => Promise; - now?: () => number; +/** + * Run the full device-flow authorization end to end: request a code, hand it to the caller's `onCode` (so the + * caller can display it however it likes -- CLI text, structured JSON, etc.), then poll until the user + * completes, declines, or the code expires. Returns the resulting access token; throws a DeviceFlowError on + * any failure -- the caller decides whether to fall back to another auth method. + */ +export declare function runDeviceFlowAuthorization({ clientId, scope, onCode, fetchFn, sleepFn, now, }: { + clientId: string; + scope?: string; + onCode: (code: DeviceCode) => void | Promise; + fetchFn?: typeof fetch; + sleepFn?: (ms: number) => Promise; + now?: () => number; }): Promise; diff --git a/packages/loopover-miner/lib/oauth-device-flow.js b/packages/loopover-miner/lib/oauth-device-flow.js index 5a1c3dac7b..0eaf9c4655 100644 --- a/packages/loopover-miner/lib/oauth-device-flow.js +++ b/packages/loopover-miner/lib/oauth-device-flow.js @@ -9,7 +9,6 @@ // in @loopover/engine's local-write-tools.ts). This is deliberately NOT the installation-token mechanism Orb // uses: an installation token requires the repo owner to install the App on their own repo, which is // mechanically incompatible with contributing to third-party repos AMS doesn't own. - const DEVICE_CODE_URL = "https://github.com/login/device/code"; const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; const DEFAULT_SCOPE = "repo"; @@ -18,116 +17,117 @@ const DEFAULT_INTERVAL_SECONDS = 5; // #miner-github-read-timeouts: matches github-token-resolution.js's GITHUB_TOKEN_FETCH_TIMEOUT_MS -- a stalled // connection can't hang forever, here or anywhere else this package talks to GitHub. const DEVICE_FLOW_FETCH_TIMEOUT_MS = 10_000; - /** The centrally-held loopover-ams App's OAuth client id -- public (not secret), so it's safe to read from a * plain env var. Empty/unset means device-flow authorization isn't available in this build/deployment. */ export function resolveAmsOauthClientId(env = process.env) { - return typeof env.LOOPOVER_MINER_AMS_OAUTH_CLIENT_ID === "string" ? env.LOOPOVER_MINER_AMS_OAUTH_CLIENT_ID.trim() : ""; + return typeof env.LOOPOVER_MINER_AMS_OAUTH_CLIENT_ID === "string" ? env.LOOPOVER_MINER_AMS_OAUTH_CLIENT_ID.trim() : ""; } - export class DeviceFlowError extends Error { - constructor(code, message) { - super(message || code); - this.code = code; - } + code; + constructor(code, message) { + super(message || code); + this.code = code; + } } - function defaultSleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } - /** Step 1 of the device flow: request a device code + the short user-facing code from GitHub. */ -export async function requestDeviceCode({ clientId, scope = DEFAULT_SCOPE, fetchFn = fetch } = {}) { - if (!clientId) throw new DeviceFlowError("missing_client_id", "no OAuth client id configured for device-flow authorization"); - const res = await fetchFn(DEVICE_CODE_URL, { - method: "POST", - headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ client_id: clientId, scope }).toString(), - signal: AbortSignal.timeout(DEVICE_FLOW_FETCH_TIMEOUT_MS), - }); - if (!res.ok) throw new DeviceFlowError("device_code_request_failed", `GitHub returned HTTP ${res.status} requesting a device code`); - const data = await res.json(); - if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string") { - throw new DeviceFlowError("device_code_response_invalid", "GitHub's device-code response was missing required fields"); - } - return { - deviceCode: data.device_code, - userCode: data.user_code, - verificationUri: data.verification_uri, - expiresInSeconds: typeof data.expires_in === "number" ? data.expires_in : DEFAULT_EXPIRES_IN_SECONDS, - intervalSeconds: typeof data.interval === "number" ? data.interval : DEFAULT_INTERVAL_SECONDS, - }; +export async function requestDeviceCode({ clientId, scope = DEFAULT_SCOPE, fetchFn = fetch, } = {}) { + if (!clientId) + throw new DeviceFlowError("missing_client_id", "no OAuth client id configured for device-flow authorization"); + // Cast: ambient fetch is CF-Workers-flavored; this module only POSTs string URLs. + const resolvedFetch = fetchFn; + const res = await resolvedFetch(DEVICE_CODE_URL, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ client_id: clientId, scope }).toString(), + signal: AbortSignal.timeout(DEVICE_FLOW_FETCH_TIMEOUT_MS), + }); + if (!res.ok) + throw new DeviceFlowError("device_code_request_failed", `GitHub returned HTTP ${res.status} requesting a device code`); + const data = (await res.json()); + if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string") { + throw new DeviceFlowError("device_code_response_invalid", "GitHub's device-code response was missing required fields"); + } + return { + deviceCode: data.device_code, + userCode: data.user_code, + verificationUri: data.verification_uri, + expiresInSeconds: typeof data.expires_in === "number" ? data.expires_in : DEFAULT_EXPIRES_IN_SECONDS, + intervalSeconds: typeof data.interval === "number" ? data.interval : DEFAULT_INTERVAL_SECONDS, + }; } - /** * Step 2: poll for the access token, honoring GitHub's device-flow polling protocol -- * `authorization_pending` keeps polling at the current interval, `slow_down` increases it (to GitHub's own * requested value when given), `expired_token`/`access_denied` are terminal failures, anything else is an * unexpected terminal failure. Bounded by `expiresInSeconds` so a caller can never poll forever. */ -export async function pollForAccessToken({ - clientId, - deviceCode, - intervalSeconds = DEFAULT_INTERVAL_SECONDS, - expiresInSeconds = DEFAULT_EXPIRES_IN_SECONDS, - fetchFn = fetch, - sleepFn = defaultSleep, - now = () => Date.now(), -} = {}) { - const deadline = now() + expiresInSeconds * 1000; - let interval = intervalSeconds; - for (;;) { - if (now() >= deadline) throw new DeviceFlowError("expired_token", "the device code expired before authorization completed"); - await sleepFn(interval * 1000); - let res; - try { - res = await fetchFn(ACCESS_TOKEN_URL, { - method: "POST", - headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: clientId, - device_code: deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }).toString(), - signal: AbortSignal.timeout(DEVICE_FLOW_FETCH_TIMEOUT_MS), - }); - } catch { - // A stalled/timed-out attempt is a per-attempt failure, not a fatal one -- the existing deadline check - // at the top of the loop still bounds total polling time, so this just costs one wasted interval. - continue; - } - const data = await res.json().catch(() => ({})); - if (data && typeof data.access_token === "string" && data.access_token) { - return { accessToken: data.access_token, scope: typeof data.scope === "string" ? data.scope : "" }; - } - const error = data && typeof data.error === "string" ? data.error : null; - if (error === "authorization_pending") continue; - if (error === "slow_down") { - interval = typeof data.interval === "number" ? data.interval : interval + 5; - continue; +export async function pollForAccessToken({ clientId, deviceCode, intervalSeconds = DEFAULT_INTERVAL_SECONDS, expiresInSeconds = DEFAULT_EXPIRES_IN_SECONDS, fetchFn = fetch, sleepFn = defaultSleep, now = () => Date.now(), } = {}) { + const deadline = now() + expiresInSeconds * 1000; + let interval = intervalSeconds; + // Cast: ambient fetch is CF-Workers-flavored; this module only POSTs string URLs. + const resolvedFetch = fetchFn; + for (;;) { + if (now() >= deadline) + throw new DeviceFlowError("expired_token", "the device code expired before authorization completed"); + await sleepFn(interval * 1000); + let res; + try { + res = await resolvedFetch(ACCESS_TOKEN_URL, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }).toString(), + signal: AbortSignal.timeout(DEVICE_FLOW_FETCH_TIMEOUT_MS), + }); + } + catch { + // A stalled/timed-out attempt is a per-attempt failure, not a fatal one -- the existing deadline check + // at the top of the loop still bounds total polling time, so this just costs one wasted interval. + continue; + } + const data = (await res.json().catch(() => ({}))); + if (data && typeof data.access_token === "string" && data.access_token) { + return { accessToken: data.access_token, scope: typeof data.scope === "string" ? data.scope : "" }; + } + const error = data && typeof data.error === "string" ? data.error : null; + if (error === "authorization_pending") + continue; + if (error === "slow_down") { + interval = typeof data.interval === "number" ? data.interval : interval + 5; + continue; + } + if (error === "expired_token") + throw new DeviceFlowError("expired_token", "the device code expired before authorization completed"); + if (error === "access_denied") + throw new DeviceFlowError("access_denied", "authorization was declined"); + throw new DeviceFlowError(error || "device_flow_failed", (typeof data.error_description === "string" ? data.error_description : undefined) || + `unexpected device-flow response (HTTP ${res.status})`); } - if (error === "expired_token") throw new DeviceFlowError("expired_token", "the device code expired before authorization completed"); - if (error === "access_denied") throw new DeviceFlowError("access_denied", "authorization was declined"); - throw new DeviceFlowError(error || "device_flow_failed", (data && data.error_description) || `unexpected device-flow response (HTTP ${res.status})`); - } } - /** * Run the full device-flow authorization end to end: request a code, hand it to the caller's `onCode` (so the * caller can display it however it likes -- CLI text, structured JSON, etc.), then poll until the user * completes, declines, or the code expires. Returns the resulting access token; throws a DeviceFlowError on * any failure -- the caller decides whether to fall back to another auth method. */ -export async function runDeviceFlowAuthorization({ clientId, scope, onCode, fetchFn = fetch, sleepFn, now }) { - const code = await requestDeviceCode({ clientId, scope, fetchFn }); - await onCode(code); - return pollForAccessToken({ - clientId, - deviceCode: code.deviceCode, - intervalSeconds: code.intervalSeconds, - expiresInSeconds: code.expiresInSeconds, - fetchFn, - sleepFn, - now, - }); +export async function runDeviceFlowAuthorization({ clientId, scope, onCode, fetchFn = fetch, sleepFn, now, }) { + // Cast: optional scope/sleepFn/now may be undefined; keep the JS's always-pass shape under EOPT. + const code = await requestDeviceCode({ clientId, scope, fetchFn }); + await onCode(code); + return pollForAccessToken({ + clientId, + deviceCode: code.deviceCode, + intervalSeconds: code.intervalSeconds, + expiresInSeconds: code.expiresInSeconds, + fetchFn, + sleepFn, + now, + }); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoib2F1dGgtZGV2aWNlLWZsb3cuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJvYXV0aC1kZXZpY2UtZmxvdy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxxR0FBcUc7QUFDckcsMEdBQTBHO0FBQzFHLDZEQUE2RDtBQUM3RCwwR0FBMEc7QUFDMUcsNkZBQTZGO0FBQzdGLEVBQUU7QUFDRiwwR0FBMEc7QUFDMUcsK0dBQStHO0FBQy9HLDZHQUE2RztBQUM3RyxxR0FBcUc7QUFDckcsb0ZBQW9GO0FBRXBGLE1BQU0sZUFBZSxHQUFHLHNDQUFzQyxDQUFDO0FBQy9ELE1BQU0sZ0JBQWdCLEdBQUcsNkNBQTZDLENBQUM7QUFDdkUsTUFBTSxhQUFhLEdBQUcsTUFBTSxDQUFDO0FBQzdCLE1BQU0sMEJBQTBCLEdBQUcsR0FBRyxDQUFDO0FBQ3ZDLE1BQU0sd0JBQXdCLEdBQUcsQ0FBQyxDQUFDO0FBQ25DLCtHQUErRztBQUMvRyxxRkFBcUY7QUFDckYsTUFBTSw0QkFBNEIsR0FBRyxNQUFNLENBQUM7QUFlNUM7MkdBQzJHO0FBQzNHLE1BQU0sVUFBVSx1QkFBdUIsQ0FBQyxNQUEwQyxPQUFPLENBQUMsR0FBRztJQUMzRixPQUFPLE9BQU8sR0FBRyxDQUFDLGtDQUFrQyxLQUFLLFFBQVEsQ0FBQyxDQUFDLENBQUMsR0FBRyxDQUFDLGtDQUFrQyxDQUFDLElBQUksRUFBRSxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUM7QUFDekgsQ0FBQztBQUVELE1BQU0sT0FBTyxlQUFnQixTQUFRLEtBQUs7SUFDeEMsSUFBSSxDQUFTO0lBQ2IsWUFBWSxJQUFZLEVBQUUsT0FBZ0I7UUFDeEMsS0FBSyxDQUFDLE9BQU8sSUFBSSxJQUFJLENBQUMsQ0FBQztRQUN2QixJQUFJLENBQUMsSUFBSSxHQUFHLElBQUksQ0FBQztJQUNuQixDQUFDO0NBQ0Y7QUFFRCxTQUFTLFlBQVksQ0FBQyxFQUFVO0lBQzlCLE9BQU8sSUFBSSxPQUFPLENBQUMsQ0FBQyxPQUFPLEVBQUUs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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/oauth-device-flow.ts b/packages/loopover-miner/lib/oauth-device-flow.ts new file mode 100644 index 0000000000..3da1a7a1d0 --- /dev/null +++ b/packages/loopover-miner/lib/oauth-device-flow.ts @@ -0,0 +1,201 @@ +// GitHub OAuth Device Flow client (#5682) for the centrally-held `loopover-ams` GitHub App -- lets a +// contributor authorize loopover-miner by visiting a URL and entering a short code, instead of generating +// and pasting a PAT. Uses GitHub's PUBLIC-client device flow +// (https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow): no +// client secret is required or ever held by this CLI, only the App's public OAuth client id. +// +// The resulting user-to-server access token acts AS the authorizing human's own account, within their own +// GitHub permissions -- the exact same identity/attribution as a manually pasted PAT (see LOCAL_WRITE_BOUNDARY +// in @loopover/engine's local-write-tools.ts). This is deliberately NOT the installation-token mechanism Orb +// uses: an installation token requires the repo owner to install the App on their own repo, which is +// mechanically incompatible with contributing to third-party repos AMS doesn't own. + +const DEVICE_CODE_URL = "https://github.com/login/device/code"; +const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; +const DEFAULT_SCOPE = "repo"; +const DEFAULT_EXPIRES_IN_SECONDS = 900; +const DEFAULT_INTERVAL_SECONDS = 5; +// #miner-github-read-timeouts: matches github-token-resolution.js's GITHUB_TOKEN_FETCH_TIMEOUT_MS -- a stalled +// connection can't hang forever, here or anywhere else this package talks to GitHub. +const DEVICE_FLOW_FETCH_TIMEOUT_MS = 10_000; + +export type DeviceCode = { + deviceCode: string; + userCode: string; + verificationUri: string; + expiresInSeconds: number; + intervalSeconds: number; +}; + +export type DeviceFlowTokenResult = { + accessToken: string; + scope: string; +}; + +/** The centrally-held loopover-ams App's OAuth client id -- public (not secret), so it's safe to read from a + * plain env var. Empty/unset means device-flow authorization isn't available in this build/deployment. */ +export function resolveAmsOauthClientId(env: Record = process.env): string { + return typeof env.LOOPOVER_MINER_AMS_OAUTH_CLIENT_ID === "string" ? env.LOOPOVER_MINER_AMS_OAUTH_CLIENT_ID.trim() : ""; +} + +export class DeviceFlowError extends Error { + code: string; + constructor(code: string, message?: string) { + super(message || code); + this.code = code; + } +} + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Narrower than ambient CF-flavored `typeof fetch` for the same reason as live-issue-snapshot's inject seam. +type DeviceFlowFetch = ( + url: string, + init: { + method: string; + headers: Record; + body: string; + signal?: AbortSignal; + }, +) => Promise<{ ok: boolean; status: number; json: () => Promise }>; + +/** Step 1 of the device flow: request a device code + the short user-facing code from GitHub. */ +export async function requestDeviceCode({ + clientId, + scope = DEFAULT_SCOPE, + fetchFn = fetch, +}: { + clientId: string; + scope?: string; + fetchFn?: typeof fetch; +} = {} as { clientId: string }): Promise { + if (!clientId) throw new DeviceFlowError("missing_client_id", "no OAuth client id configured for device-flow authorization"); + // Cast: ambient fetch is CF-Workers-flavored; this module only POSTs string URLs. + const resolvedFetch = fetchFn as unknown as DeviceFlowFetch; + const res = await resolvedFetch(DEVICE_CODE_URL, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ client_id: clientId, scope }).toString(), + signal: AbortSignal.timeout(DEVICE_FLOW_FETCH_TIMEOUT_MS), + }); + if (!res.ok) throw new DeviceFlowError("device_code_request_failed", `GitHub returned HTTP ${res.status} requesting a device code`); + const data = (await res.json()) as Record; + if (!data || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string") { + throw new DeviceFlowError("device_code_response_invalid", "GitHub's device-code response was missing required fields"); + } + return { + deviceCode: data.device_code, + userCode: data.user_code, + verificationUri: data.verification_uri, + expiresInSeconds: typeof data.expires_in === "number" ? data.expires_in : DEFAULT_EXPIRES_IN_SECONDS, + intervalSeconds: typeof data.interval === "number" ? data.interval : DEFAULT_INTERVAL_SECONDS, + }; +} + +/** + * Step 2: poll for the access token, honoring GitHub's device-flow polling protocol -- + * `authorization_pending` keeps polling at the current interval, `slow_down` increases it (to GitHub's own + * requested value when given), `expired_token`/`access_denied` are terminal failures, anything else is an + * unexpected terminal failure. Bounded by `expiresInSeconds` so a caller can never poll forever. + */ +export async function pollForAccessToken({ + clientId, + deviceCode, + intervalSeconds = DEFAULT_INTERVAL_SECONDS, + expiresInSeconds = DEFAULT_EXPIRES_IN_SECONDS, + fetchFn = fetch, + sleepFn = defaultSleep, + now = () => Date.now(), +}: { + clientId: string; + deviceCode: string; + intervalSeconds?: number; + expiresInSeconds?: number; + fetchFn?: typeof fetch; + sleepFn?: (ms: number) => Promise; + now?: () => number; +} = {} as { clientId: string; deviceCode: string }): Promise { + const deadline = now() + expiresInSeconds * 1000; + let interval = intervalSeconds; + // Cast: ambient fetch is CF-Workers-flavored; this module only POSTs string URLs. + const resolvedFetch = fetchFn as unknown as DeviceFlowFetch; + for (;;) { + if (now() >= deadline) throw new DeviceFlowError("expired_token", "the device code expired before authorization completed"); + await sleepFn(interval * 1000); + let res: Awaited>; + try { + res = await resolvedFetch(ACCESS_TOKEN_URL, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientId, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }).toString(), + signal: AbortSignal.timeout(DEVICE_FLOW_FETCH_TIMEOUT_MS), + }); + } catch { + // A stalled/timed-out attempt is a per-attempt failure, not a fatal one -- the existing deadline check + // at the top of the loop still bounds total polling time, so this just costs one wasted interval. + continue; + } + const data = (await res.json().catch(() => ({}))) as Record; + if (data && typeof data.access_token === "string" && data.access_token) { + return { accessToken: data.access_token, scope: typeof data.scope === "string" ? data.scope : "" }; + } + const error = data && typeof data.error === "string" ? data.error : null; + if (error === "authorization_pending") continue; + if (error === "slow_down") { + interval = typeof data.interval === "number" ? data.interval : interval + 5; + continue; + } + if (error === "expired_token") throw new DeviceFlowError("expired_token", "the device code expired before authorization completed"); + if (error === "access_denied") throw new DeviceFlowError("access_denied", "authorization was declined"); + throw new DeviceFlowError( + error || "device_flow_failed", + (typeof data.error_description === "string" ? data.error_description : undefined) || + `unexpected device-flow response (HTTP ${res.status})`, + ); + } +} + +/** + * Run the full device-flow authorization end to end: request a code, hand it to the caller's `onCode` (so the + * caller can display it however it likes -- CLI text, structured JSON, etc.), then poll until the user + * completes, declines, or the code expires. Returns the resulting access token; throws a DeviceFlowError on + * any failure -- the caller decides whether to fall back to another auth method. + */ +export async function runDeviceFlowAuthorization({ + clientId, + scope, + onCode, + fetchFn = fetch, + sleepFn, + now, +}: { + clientId: string; + scope?: string; + onCode: (code: DeviceCode) => void | Promise; + fetchFn?: typeof fetch; + sleepFn?: (ms: number) => Promise; + now?: () => number; +}): Promise { + // Cast: optional scope/sleepFn/now may be undefined; keep the JS's always-pass shape under EOPT. + const code = await requestDeviceCode({ clientId, scope, fetchFn } as { + clientId: string; + scope?: string; + fetchFn?: typeof fetch; + }); + await onCode(code); + return pollForAccessToken({ + clientId, + deviceCode: code.deviceCode, + intervalSeconds: code.intervalSeconds, + expiresInSeconds: code.expiresInSeconds, + fetchFn, + sleepFn, + now, + } as Parameters[0]); +} diff --git a/packages/loopover-miner/lib/policy-doc-cache.d.ts b/packages/loopover-miner/lib/policy-doc-cache.d.ts index 4cd5a5e5c6..a90dddb97a 100644 --- a/packages/loopover-miner/lib/policy-doc-cache.d.ts +++ b/packages/loopover-miner/lib/policy-doc-cache.d.ts @@ -1,25 +1,28 @@ export type PolicyDocCacheEntry = { - etag: string; - content: string; + etag: string; + content: string; }; - export type PolicyDocCacheWrite = { - url: string; - etag: string; - content: string; - updatedAt: string; + url: string; + etag: string; + content: string; + updatedAt: string; }; - export type PolicyDocCacheStore = { - dbPath: string; - get(url: string): PolicyDocCacheEntry | null; - put(url: string, etag: string, content: string): PolicyDocCacheWrite; - close(): void; + dbPath: string; + get(url: string): PolicyDocCacheEntry | null; + put(url: string, etag: string, content: string): PolicyDocCacheWrite; + close(): void; }; - /** The read/write surface opportunity-fanout.js needs to inject a cache without depending on the SQLite store. */ export type PolicyDocCache = Pick; - -export function resolvePolicyDocCacheDbPath(env?: Record): string; - -export function initPolicyDocCacheStore(dbPath?: string): PolicyDocCacheStore; +export declare function resolvePolicyDocCacheDbPath(env?: Record): string; +/** + * Opens the 100% local/client-side miner policy-doc ETag cache. The database only lives on this machine; this + * module never uploads, syncs, or phones home with its contents. (#4842) + * + * Opened through the #7175 SqliteDriver seam (`openLocalStoreAdapter`): CRUD goes through `driver.query`, + * while schema creation/migrations still use the underlying DatabaseSync until those helpers are migrated. + * Public API stays synchronous so callers need no async cascade in this part-1 slice. + */ +export declare function initPolicyDocCacheStore(dbPath?: string): PolicyDocCacheStore; diff --git a/packages/loopover-miner/lib/policy-doc-cache.js b/packages/loopover-miner/lib/policy-doc-cache.js index 5cfd206c78..49bb1ce9e3 100644 --- a/packages/loopover-miner/lib/policy-doc-cache.js +++ b/packages/loopover-miner/lib/policy-doc-cache.js @@ -1,6 +1,5 @@ import { normalizeLocalStoreDbPath, openLocalStoreAdapter, resolveLocalStoreDbPath } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; - // Local ETag cache for discovery's small policy-doc fetches (#4842). `discover` refetches each target repo's // AI-USAGE.md/CONTRIBUTING.md on every run even though they rarely change, spending rate-limit budget on static // content; this store lets opportunity-fanout.js revalidate with a conditional GET (If-None-Match) instead, and @@ -9,24 +8,21 @@ import { applySchemaMigrations } from "./schema-version.js"; // can never surface a stale policy that would wrongly permit autonomous work on an opted-out repo. Same 100% // local/client-side discipline (mirrors run-state.js and the other stores this package owns via local-store.js): // the file lives only on this machine and is never uploaded, synced, or phoned home with. - const defaultDbFileName = "policy-doc-cache.sqlite3"; - export function resolvePolicyDocCacheDbPath(env = process.env) { - return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_POLICY_DOC_CACHE_DB", env); + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_POLICY_DOC_CACHE_DB", env); } - function normalizeDbPath(dbPath) { - return normalizeLocalStoreDbPath(dbPath, resolvePolicyDocCacheDbPath(), "invalid_policy_doc_cache_db_path"); + return normalizeLocalStoreDbPath(dbPath, resolvePolicyDocCacheDbPath(), "invalid_policy_doc_cache_db_path"); } - function normalizeUrl(url) { - if (typeof url !== "string") throw new Error("invalid_policy_doc_url"); - const trimmed = url.trim(); - if (!trimmed) throw new Error("invalid_policy_doc_url"); - return trimmed; + if (typeof url !== "string") + throw new Error("invalid_policy_doc_url"); + const trimmed = url.trim(); + if (!trimmed) + throw new Error("invalid_policy_doc_url"); + return trimmed; } - /** * Opens the 100% local/client-side miner policy-doc ETag cache. The database only lives on this machine; this * module never uploads, syncs, or phones home with its contents. (#4842) @@ -36,9 +32,9 @@ function normalizeUrl(url) { * Public API stays synchronous so callers need no async cascade in this part-1 slice. */ export function initPolicyDocCacheStore(dbPath = resolvePolicyDocCacheDbPath()) { - const resolvedPath = normalizeDbPath(dbPath); - const { db, driver } = openLocalStoreAdapter(resolvedPath); - db.exec(` + const resolvedPath = normalizeDbPath(dbPath); + const { db, driver } = openLocalStoreAdapter(resolvedPath); + db.exec(` CREATE TABLE IF NOT EXISTS policy_doc_cache ( url TEXT PRIMARY KEY, etag TEXT NOT NULL, @@ -46,11 +42,10 @@ export function initPolicyDocCacheStore(dbPath = resolvePolicyDocCacheDbPath()) updated_at TEXT NOT NULL ) `); - // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet). - applySchemaMigrations(db, []); - - const getSql = "SELECT etag, content FROM policy_doc_cache WHERE url = ?"; - const putSql = ` + // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet). + applySchemaMigrations(db, []); + const getSql = "SELECT etag, content FROM policy_doc_cache WHERE url = ?"; + const putSql = ` INSERT INTO policy_doc_cache (url, etag, content, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(url) DO UPDATE SET @@ -58,27 +53,29 @@ export function initPolicyDocCacheStore(dbPath = resolvePolicyDocCacheDbPath()) content = excluded.content, updated_at = excluded.updated_at `; - - return { - dbPath: resolvedPath, - /** The last-known `{ etag, content }` for a policy-doc URL, or null when it has never been cached. Both columns - * are `TEXT NOT NULL`, so a present row always carries string values. */ - get(url) { - const { rows } = driver.query(getSql, [normalizeUrl(url)]); - const row = rows[0]; - return row ? { etag: row.etag, content: row.content } : null; - }, - /** Record the fresh ETag + body so the next run can revalidate it with a conditional GET. */ - put(url, etag, content) { - const normalizedUrl = normalizeUrl(url); - if (typeof etag !== "string" || !etag.trim()) throw new Error("invalid_policy_doc_etag"); - if (typeof content !== "string") throw new Error("invalid_policy_doc_content"); - const updatedAt = new Date().toISOString(); - driver.query(putSql, [normalizedUrl, etag, content, updatedAt]); - return { url: normalizedUrl, etag, content, updatedAt }; - }, - close() { - db.close(); - }, - }; + return { + dbPath: resolvedPath, + /** The last-known `{ etag, content }` for a policy-doc URL, or null when it has never been cached. Both columns + * are `TEXT NOT NULL`, so a present row always carries string values. */ + get(url) { + const { rows } = driver.query(getSql, [normalizeUrl(url)]); + const row = rows[0]; + return row ? { etag: row.etag, content: row.content } : null; + }, + /** Record the fresh ETag + body so the next run can revalidate it with a conditional GET. */ + put(url, etag, content) { + const normalizedUrl = normalizeUrl(url); + if (typeof etag !== "string" || !etag.trim()) + throw new Error("invalid_policy_doc_etag"); + if (typeof content !== "string") + throw new Error("invalid_policy_doc_content"); + const updatedAt = new Date().toISOString(); + driver.query(putSql, [normalizedUrl, etag, content, updatedAt]); + return { url: normalizedUrl, etag, content, updatedAt }; + }, + close() { + db.close(); + }, + }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicG9saWN5LWRvYy1jYWNoZS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInBvbGljeS1kb2MtY2FjaGUudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLHlCQUF5QixFQUFFLHFCQUFxQixFQUFFLHVCQUF1QixFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFDN0csT0FBTyxFQUFFLHFCQUFxQixFQUFFLE1BQU0scUJBQXFCLENBQUM7QUFFNUQsNkdBQTZHO0FBQzdHLGdIQUFnSDtBQUNoSCxnSEFBZ0g7QUFDaEgsa0hBQWtIO0FBQ2xILG1IQUFtSDtBQUNuSCw2R0FBNkc7QUFDN0csaUhBQWlIO0FBQ2pILDBGQUEwRjtBQUUxRixNQUFNLGlCQUFpQixHQUFHLDBCQUEwQixDQUFDO0FBd0JyRCxNQUFNLFVBQVUsMkJBQTJCLENBQUMsTUFBMEMsT0FBTyxDQUFDLEdBQUc7SUFDL0YsT0FBTyx1QkFBdUIsQ0FBQyxpQkFBaUIsRUFBRSxvQ0FBb0MsRUFBRSxHQUFHLENBQUMsQ0FBQztBQUMvRixDQUFDO0FBRUQsU0FBUyxlQUFlLENBQUMsTUFBYztJQUNyQyxPQUFPLHlCQUF5QixDQUFDLE1BQU0sRUFBRSwyQkFBMkIsRUFBRSxFQUFFLGtDQUFrQyxDQUFDLENBQUM7QUFDOUcsQ0FBQztBQUVELFNBQVMsWUFBWSxDQUFDLEdBQVk7SUFDaEMsSUFBSSxPQUFPLEdBQUcsS0FBSyxRQUFRO1FBQUUsTUFBTSxJQUFJLEtBQUssQ0FBQyx3QkFBd0IsQ0FBQyxDQUFDO0lBQ3ZFLE1BQU0sT0FBTyxHQUFHLEdBQUcsQ0FBQyxJQUFJLEVBQUUsQ0FBQztJQUMzQixJQUFJLENBQUMsT0FBTztRQUFFLE1BQU0sSUFBSSxLQUFLLENBQUMsd0JBQXdCLENBQUMsQ0FBQztJQUN4RCxPQUFPLE9BQU8sQ0FBQztBQUNqQixDQUFDO0FBRUQ7Ozs7Ozs7R0FPRztBQUNILE1BQU0sVUFBVSx1QkFBdUIsQ0FBQyxTQUFpQiwyQkFBMkIsRUFBRTtJQUNwRixNQUFNLFlBQVksR0FBRyxlQUFlLENBQUMsTUFBTSxDQUFDLENBQUM7SUFDN0MsTUFBTSxFQUFFLEVBQUUsRUFBRSxNQUFNLEVBQUUsR0FBRyxxQkFBcUIsQ0FBQyxZQUFZLENBQUMsQ0FBQztJQUMzRCxFQUFFLENBQUMsSUFBSSxDQUFDOzs7Ozs7O0dBT1AsQ0FBQyxDQUFDO0lBQ0gseUdBQXlHO0lBQ3pHLHFCQUFxQixDQUFDLEVBQUUsRUFBRSxFQUFFLENBQUMsQ0FBQztJQUU5QixNQUFNLE1BQU0sR0FBRywwREFBMEQsQ0FBQztJQUMxRSxNQUFNLE1BQU0sR0FBRzs7Ozs7OztHQU9kLENBQUM7SUFFRixPQUFPO1FBQ0wsTUFBTSxFQUFFLFlBQVk7UUFDcEI7a0ZBQzBFO1FBQzFFLEdBQUcsQ0FBQyxHQUFHO1lBQ0wsTUFBTSxFQUFFLElBQUksRUFBRSxHQUFHLE1BQU0sQ0FBQyxLQUFLLENBQUMsTUFBTSxFQUFFLENBQUMsWUFBWSxDQUFDLEdBQUcsQ0FBQyxDQUFDLENBQUMsQ0FBQztZQUMzRCxNQUFNLEdBQUcsR0FBRyxJQUFJLENBQUMsQ0FBQyxDQUFrRCxDQUFDO1lBQ3JFLE9BQU8sR0FBRyxDQUFDLENBQUMsQ0FBQyxFQUFFLElBQUksRUFBRSxHQUFHLENBQUMsSUFBSSxFQUFFLE9BQU8sRUFBRSxHQUFHLENBQUMsT0FBTyxFQUFFLENBQUMsQ0FBQyxDQUFDLElBQUksQ0FBQztRQUMvRCxDQUFDO1FBQ0QsNkZBQTZGO1FBQzdGLEdBQUcsQ0FBQyxHQUFHLEVBQUUsSUFBSSxFQUFFLE9BQU87WUFDcEIsTUFBTSxhQUFhLEdBQUcsWUFBWSxDQUFDLEdBQUcsQ0FBQyxDQUFDO1lBQ3hDLElBQUksT0FBTyxJQUFJLEtBQUssUUFBUSxJQUFJLENBQUMsSUFBSSxDQUFDLElBQUksRUFBRTtnQkFBRSxNQUFNLElBQUksS0FBSyxDQUFDLHlCQUF5QixDQUFDLENBQUM7WUFDekYsSUFBSSxPQUFPLE9BQU8sS0FBSyxRQUFRO2dCQUFFLE1BQU0sSUFBSSxLQUFLLENBQUMsNEJBQTRCLENBQUMsQ0FBQztZQUMvRSxNQUFNLFNBQVMsR0FBRyxJQUFJLElBQUksRUFBRSxDQUFDLFdBQVcsRUFBRSxDQUFDO1lBQzNDLE1BQU0sQ0FBQyxLQUFLLENBQUMsTUFBTSxFQUFFLENBQUMsYUFBYSxFQUFFLElBQUksRUFBRSxPQUFPLEVBQUUsU0FBUyxDQUFDLENBQUMsQ0FBQztZQUNoRSxPQUFPLEVBQUUsR0FBRyxFQUFFLGFBQWEsRUFBRSxJQUFJLEVBQUUsT0FBTyxFQUFFLFNBQVMsRUFBRSxDQUFDO1FBQzFELENBQUM7UUFDRCxLQUFLO1lBQ0gsRUFBRSxDQUFDLEtBQUssRUFBRSxDQUFDO1FBQ2IsQ0FBQztLQUNGLENBQUM7QUFDSixDQUFDIn0= \ No newline at end of file diff --git a/packages/loopover-miner/lib/policy-doc-cache.ts b/packages/loopover-miner/lib/policy-doc-cache.ts new file mode 100644 index 0000000000..689b704489 --- /dev/null +++ b/packages/loopover-miner/lib/policy-doc-cache.ts @@ -0,0 +1,106 @@ +import { normalizeLocalStoreDbPath, openLocalStoreAdapter, resolveLocalStoreDbPath } from "./local-store.js"; +import { applySchemaMigrations } from "./schema-version.js"; + +// Local ETag cache for discovery's small policy-doc fetches (#4842). `discover` refetches each target repo's +// AI-USAGE.md/CONTRIBUTING.md on every run even though they rarely change, spending rate-limit budget on static +// content; this store lets opportunity-fanout.js revalidate with a conditional GET (If-None-Match) instead, and +// GitHub answers an unchanged doc with a 304 that costs no primary rate-limit budget. A 304 is a GitHub-confirmed +// unchanged body -- the cached content is only ever served AFTER a same-run revalidation, never blindly -- so this +// can never surface a stale policy that would wrongly permit autonomous work on an opted-out repo. Same 100% +// local/client-side discipline (mirrors run-state.js and the other stores this package owns via local-store.js): +// the file lives only on this machine and is never uploaded, synced, or phoned home with. + +const defaultDbFileName = "policy-doc-cache.sqlite3"; + +export type PolicyDocCacheEntry = { + etag: string; + content: string; +}; + +export type PolicyDocCacheWrite = { + url: string; + etag: string; + content: string; + updatedAt: string; +}; + +export type PolicyDocCacheStore = { + dbPath: string; + get(url: string): PolicyDocCacheEntry | null; + put(url: string, etag: string, content: string): PolicyDocCacheWrite; + close(): void; +}; + +/** The read/write surface opportunity-fanout.js needs to inject a cache without depending on the SQLite store. */ +export type PolicyDocCache = Pick; + +export function resolvePolicyDocCacheDbPath(env: Record = process.env): string { + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_POLICY_DOC_CACHE_DB", env); +} + +function normalizeDbPath(dbPath: string): string { + return normalizeLocalStoreDbPath(dbPath, resolvePolicyDocCacheDbPath(), "invalid_policy_doc_cache_db_path"); +} + +function normalizeUrl(url: unknown): string { + if (typeof url !== "string") throw new Error("invalid_policy_doc_url"); + const trimmed = url.trim(); + if (!trimmed) throw new Error("invalid_policy_doc_url"); + return trimmed; +} + +/** + * Opens the 100% local/client-side miner policy-doc ETag cache. The database only lives on this machine; this + * module never uploads, syncs, or phones home with its contents. (#4842) + * + * Opened through the #7175 SqliteDriver seam (`openLocalStoreAdapter`): CRUD goes through `driver.query`, + * while schema creation/migrations still use the underlying DatabaseSync until those helpers are migrated. + * Public API stays synchronous so callers need no async cascade in this part-1 slice. + */ +export function initPolicyDocCacheStore(dbPath: string = resolvePolicyDocCacheDbPath()): PolicyDocCacheStore { + const resolvedPath = normalizeDbPath(dbPath); + const { db, driver } = openLocalStoreAdapter(resolvedPath); + db.exec(` + CREATE TABLE IF NOT EXISTS policy_doc_cache ( + url TEXT PRIMARY KEY, + etag TEXT NOT NULL, + content TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); + // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet). + applySchemaMigrations(db, []); + + const getSql = "SELECT etag, content FROM policy_doc_cache WHERE url = ?"; + const putSql = ` + INSERT INTO policy_doc_cache (url, etag, content, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(url) DO UPDATE SET + etag = excluded.etag, + content = excluded.content, + updated_at = excluded.updated_at + `; + + return { + dbPath: resolvedPath, + /** The last-known `{ etag, content }` for a policy-doc URL, or null when it has never been cached. Both columns + * are `TEXT NOT NULL`, so a present row always carries string values. */ + get(url) { + const { rows } = driver.query(getSql, [normalizeUrl(url)]); + const row = rows[0] as { etag: string; content: string } | undefined; + return row ? { etag: row.etag, content: row.content } : null; + }, + /** Record the fresh ETag + body so the next run can revalidate it with a conditional GET. */ + put(url, etag, content) { + const normalizedUrl = normalizeUrl(url); + if (typeof etag !== "string" || !etag.trim()) throw new Error("invalid_policy_doc_etag"); + if (typeof content !== "string") throw new Error("invalid_policy_doc_content"); + const updatedAt = new Date().toISOString(); + driver.query(putSql, [normalizedUrl, etag, content, updatedAt]); + return { url: normalizedUrl, etag, content, updatedAt }; + }, + close() { + db.close(); + }, + }; +} diff --git a/packages/loopover-miner/lib/portfolio-queue-cli.d.ts b/packages/loopover-miner/lib/portfolio-queue-cli.d.ts index f2cf0ddb3b..dc7865070b 100644 --- a/packages/loopover-miner/lib/portfolio-queue-cli.d.ts +++ b/packages/loopover-miner/lib/portfolio-queue-cli.d.ts @@ -1,101 +1,116 @@ import type { PortfolioQueueStore, QueueEntry } from "./portfolio-queue.js"; import type { PortfolioQueueManager } from "./portfolio-queue-manager.js"; - -export type ParsedQueueListArgs = - | { - json: boolean; - repoFullName: string | null; - } - | { error: string }; - -export type ParsedQueueNextArgs = - | { json: boolean; dryRun: boolean; globalWipCap: number | undefined; perRepoWipCap: number | undefined } - | { error: string }; - -export type QueueClaimTarget = { repoFullName: string; identifier: string; apiBaseUrl: string }; - -export function selectNextEligibleTarget( - entries: Array<{ repoFullName: string; identifier: string; apiBaseUrl: string; status: string }>, - caps: { globalWipCap: number; perRepoWipCap: number } | null, -): QueueClaimTarget[]; - -export type ParsedQueueDoneArgs = - | { - repoFullName: string; - identifier: string; - dryRun: boolean; - json: boolean; - apiBaseUrl: string | undefined; - } - | { error: string }; - -export function parseQueueListArgs(args: string[]): ParsedQueueListArgs; - -export function parseQueueNextArgs(args: string[]): ParsedQueueNextArgs; - -export function parseQueueDoneArgs(args: string[]): ParsedQueueDoneArgs; - -export function parseQueueReleaseArgs(args: string[]): ParsedQueueDoneArgs; - -export function parseQueueRequeueArgs(args: string[]): ParsedQueueDoneArgs; - -export type ParsedQueueClaimBatchArgs = - | { json: boolean; dryRun: boolean; globalWipCap: number; perRepoWipCap: number } - | { error: string }; - -export function parseQueueClaimBatchArgs(args: string[]): ParsedQueueClaimBatchArgs; - -export function renderQueueTable(entries: QueueEntry[]): string; - -export function runQueueList( - args: string[], - options?: { initPortfolioQueue?: () => PortfolioQueueStore }, -): number; - -export function runQueueNext( - args: string[], - options?: { initPortfolioQueue?: () => PortfolioQueueStore }, -): number; - -export function runQueueDone( - args: string[], - options?: { initPortfolioQueue?: () => PortfolioQueueStore }, -): number; - -export function runQueueRelease( - args: string[], - options?: { initPortfolioQueue?: () => PortfolioQueueStore }, -): number; - -export function runQueueRequeue( - args: string[], - options?: { initPortfolioQueue?: () => PortfolioQueueStore }, -): number; - -export function runQueueClaimBatch( - args: string[], - options?: { initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager }, -): number; - -export const QUEUE_ITEMS: string; -export const QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS: string; - -export function renderPortfolioQueueMetrics( - queueEntries: Array<{ status: string }>, - leaseEntries: Array<{ leasedAt: string | null }>, - nowMs: number, -): string; - -export function runQueueMetrics( - args: string[], - options?: { initPortfolioQueue?: () => PortfolioQueueStore; nowMs?: number }, -): number; - -export function runQueueCli( - subcommand: string | undefined, - args: string[], - options?: { +export type ParsedQueueListArgs = { + json: boolean; + repoFullName: string | null; +} | { + error: string; +}; +export type ParsedQueueNextArgs = { + json: boolean; + dryRun: boolean; + globalWipCap: number | undefined; + perRepoWipCap: number | undefined; +} | { + error: string; +}; +export type QueueClaimTarget = { + repoFullName: string; + identifier: string; + apiBaseUrl: string; +}; +export type ParsedQueueDoneArgs = { + repoFullName: string; + identifier: string; + dryRun: boolean; + json: boolean; + apiBaseUrl: string | undefined; +} | { + error: string; +}; +export type ParsedQueueClaimBatchArgs = { + json: boolean; + dryRun: boolean; + globalWipCap: number; + perRepoWipCap: number; +} | { + error: string; +}; +export declare function parseQueueListArgs(args: string[]): ParsedQueueListArgs; +export declare function parseQueueNextArgs(args: string[]): ParsedQueueNextArgs; +/** + * Pick at most one atomically-claimable target from the store's already-priority-ordered active rows (queued + * AND in_progress interleaved, exactly `batchClaim`'s own `entries` shape). `caps` of `null` replicates the + * pre-#4850 behavior: the single highest-priority queued row, unconditionally. When caps are set, refuses to + * select anything once the global or the target row's own per-repo in-progress count has reached its cap -- + * "stops claiming once the cap is reached" (#4850), not a diversifying batch selection (that remains + * claim-batch's job via the engine's own `nextEligibleItems`). + * @param {Array<{ repoFullName: string, identifier: string, apiBaseUrl: string, status: string }>} entries + * @param {{ globalWipCap: number, perRepoWipCap: number } | null} caps + */ +export declare function selectNextEligibleTarget(entries: Array<{ + repoFullName: string; + identifier: string; + apiBaseUrl: string; + status: string; +}>, caps: { + globalWipCap: number; + perRepoWipCap: number; +} | null): QueueClaimTarget[]; +export declare function parseQueueDoneArgs(args: string[]): ParsedQueueDoneArgs; +export declare function parseQueueReleaseArgs(args: string[]): ParsedQueueDoneArgs; +export declare function parseQueueRequeueArgs(args: string[]): ParsedQueueDoneArgs; +export declare function renderQueueTable(entries: QueueEntry[]): string; +export declare function runQueueList(args: string[], options?: { + initPortfolioQueue?: () => PortfolioQueueStore; +}): number; +export declare function runQueueNext(args: string[], options?: { + initPortfolioQueue?: () => PortfolioQueueStore; +}): number; +export declare function runQueueDone(args: string[], options?: { + initPortfolioQueue?: () => PortfolioQueueStore; +}): number; +/** `release `: manually give up a CLAIMED (in_progress) item, returning it to the queue + * (the manual counterpart to the automated stuck-lease sweep). Exit 2 when there is no in-flight item to release. */ +export declare function runQueueRelease(args: string[], options?: { + initPortfolioQueue?: () => PortfolioQueueStore; +}): number; +/** `requeue `: manually put a COMPLETED (done) item back on the queue so it is picked up + * again, keeping its original FIFO position. Exit 2 when there is no done item to requeue (already queued, + * in-flight — release it instead — or absent). */ +export declare function runQueueRequeue(args: string[], options?: { + initPortfolioQueue?: () => PortfolioQueueStore; +}): number; +export declare function parseQueueClaimBatchArgs(args: string[]): ParsedQueueClaimBatchArgs; +/** Claim the next caps-aware batch via the WIP-cap-aware batch claimer (portfolio-queue-manager.js), which also + * reclaims any leases orphaned by a crashed process first (#4833 wires the previously caller-less claimer). */ +export declare function runQueueClaimBatch(args: string[], options?: { + initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager; +}): number; +export declare const QUEUE_ITEMS = "loopover_miner_portfolio_queue_items"; +export declare const QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS = "loopover_miner_portfolio_queue_oldest_in_progress_lease_age_seconds"; +/** + * Render portfolio-queue backlog health as Prometheus text-exposition gauges: current item count per status, and + * the age of the OLDEST still-in-flight lease -- the concrete "is anything stuck" signal a + * `loopover_queue_oldest_maintenance_pending_age_seconds`-style alert rule can threshold on (#5186). Pure and + * side-effect-free: the caller supplies the rows and `nowMs` (no internal clock read, matching + * store-maintenance.js's pruneLedgerByRetention convention) and prints the result. Deterministic (status series + * sorted); always emits HELP/TYPE so an empty queue is still a well-formed exposition document, and the lease-age + * gauge reads 0 (never stuck) rather than being omitted when nothing is in-flight. + * @param {Array<{ status: string }>} queueEntries - every row, any status (e.g. store.listQueue()'s output). + * @param {Array<{ leasedAt: string | null }>} leaseEntries - in-flight rows only (store.listInProgress()'s output). + * @param {number} nowMs + */ +export declare function renderPortfolioQueueMetrics(queueEntries: Array<{ + status: string; +}>, leaseEntries: Array<{ + leasedAt: string | null; +}>, nowMs: number): string; +export declare function runQueueMetrics(args: string[], options?: { + initPortfolioQueue?: () => PortfolioQueueStore; + nowMs?: number; +}): number; +export declare function runQueueCli(subcommand: string | undefined, args: string[], options?: { initPortfolioQueue?: () => PortfolioQueueStore; initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager; - }, -): number; +}): number; diff --git a/packages/loopover-miner/lib/portfolio-queue-cli.js b/packages/loopover-miner/lib/portfolio-queue-cli.js index 96a6e8e6dc..0604ffa500 100644 --- a/packages/loopover-miner/lib/portfolio-queue-cli.js +++ b/packages/loopover-miner/lib/portfolio-queue-cli.js @@ -2,104 +2,93 @@ import { initPortfolioQueueStore } from "./portfolio-queue.js"; import { initPortfolioQueueManager } from "./portfolio-queue-manager.js"; import { runPortfolioDashboard } from "./portfolio-dashboard.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; - const QUEUE_LIST_USAGE = "Usage: loopover-miner queue list [--repo ] [--json]"; -const QUEUE_NEXT_USAGE = - "Usage: loopover-miner queue next [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]"; -const QUEUE_DONE_USAGE = - "Usage: loopover-miner queue done [--api-base-url ] [--dry-run] [--json]"; -const QUEUE_RELEASE_USAGE = - "Usage: loopover-miner queue release [--api-base-url ] [--dry-run] [--json]"; -const QUEUE_REQUEUE_USAGE = - "Usage: loopover-miner queue requeue [--api-base-url ] [--dry-run] [--json]"; -const QUEUE_CLAIM_BATCH_USAGE = - "Usage: loopover-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]"; - +const QUEUE_NEXT_USAGE = "Usage: loopover-miner queue next [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]"; +const QUEUE_DONE_USAGE = "Usage: loopover-miner queue done [--api-base-url ] [--dry-run] [--json]"; +const QUEUE_RELEASE_USAGE = "Usage: loopover-miner queue release [--api-base-url ] [--dry-run] [--json]"; +const QUEUE_REQUEUE_USAGE = "Usage: loopover-miner queue requeue [--api-base-url ] [--dry-run] [--json]"; +const QUEUE_CLAIM_BATCH_USAGE = "Usage: loopover-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]"; function parseRepoArg(value, usage) { - if (!value) return { error: usage }; - const trimmed = value.trim(); - const [owner, repo, extra] = trimmed.split("/"); - if (!owner || !repo || extra !== undefined) { - return { error: "Repository must be in owner/repo form." }; - } - return { repoFullName: `${owner}/${repo}` }; + if (!value) + return { error: usage }; + const trimmed = value.trim(); + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) { + return { error: "Repository must be in owner/repo form." }; + } + return { repoFullName: `${owner}/${repo}` }; } - export function parseQueueListArgs(args) { - const options = { json: false, repoFullName: null }; - const positional = []; - - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === "--json") { - options.json = true; - continue; - } - if (token === "--repo") { - const repoArg = args[index + 1]; - if (!repoArg || repoArg.startsWith("-")) { + const options = { json: false, repoFullName: null }; + const positional = []; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--repo") { + const repoArg = args[index + 1]; + if (!repoArg || repoArg.startsWith("-")) { + return { error: QUEUE_LIST_USAGE }; + } + const repo = parseRepoArg(repoArg, QUEUE_LIST_USAGE); + if ("error" in repo) + return repo; + options.repoFullName = repo.repoFullName; + index += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + if (positional.length > 0) { return { error: QUEUE_LIST_USAGE }; - } - const repo = parseRepoArg(repoArg, QUEUE_LIST_USAGE); - if ("error" in repo) return repo; - options.repoFullName = repo.repoFullName; - index += 1; - continue; - } - if (token.startsWith("-")) { - return { error: `Unknown option: ${token}` }; - } - positional.push(token); - } - - if (positional.length > 0) { - return { error: QUEUE_LIST_USAGE }; - } - - return options; + } + return options; } - // #4850: --global-wip/--per-repo-wip are OMITTED (undefined) by default -- queue next stays uncapped, byte- // identical to its pre-#4850 behavior, unless an operator explicitly opts in. Mirrors queue claim-batch's own // flag names (portfolio-queue-manager.js's WIP-cap-aware claimer), but claim-batch's OWN default of 1/1 is not // reused here: claim-batch's whole purpose is cap enforcement, while queue next has always been a plain // highest-priority dequeue and must not silently start capping existing callers that never asked for it. export function parseQueueNextArgs(args) { - const options = { json: false, dryRun: false, globalWipCap: undefined, perRepoWipCap: undefined }; - const positional = []; - - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === "--json") { - options.json = true; - continue; - } - if (token === "--dry-run") { - options.dryRun = true; - continue; - } - if (token === "--global-wip" || token === "--per-repo-wip") { - const value = Number(args[index + 1]); - if (args[index + 1] === undefined || !Number.isFinite(value) || value < 0) { + const options = { json: false, dryRun: false, globalWipCap: undefined, perRepoWipCap: undefined }; + const positional = []; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--global-wip" || token === "--per-repo-wip") { + const value = Number(args[index + 1]); + if (args[index + 1] === undefined || !Number.isFinite(value) || value < 0) { + return { error: QUEUE_NEXT_USAGE }; + } + if (token === "--global-wip") + options.globalWipCap = value; + else + options.perRepoWipCap = value; + index += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + if (positional.length > 0) { return { error: QUEUE_NEXT_USAGE }; - } - if (token === "--global-wip") options.globalWipCap = value; - else options.perRepoWipCap = value; - index += 1; - continue; - } - if (token.startsWith("-")) { - return { error: `Unknown option: ${token}` }; - } - positional.push(token); - } - - if (positional.length > 0) { - return { error: QUEUE_NEXT_USAGE }; - } - return options; + } + return options; } - /** * Pick at most one atomically-claimable target from the store's already-priority-ordered active rows (queued * AND in_progress interleaved, exactly `batchClaim`'s own `entries` shape). `caps` of `null` replicates the @@ -111,392 +100,389 @@ export function parseQueueNextArgs(args) { * @param {{ globalWipCap: number, perRepoWipCap: number } | null} caps */ export function selectNextEligibleTarget(entries, caps) { - const topQueued = entries.find((entry) => entry.status === "queued"); - if (!topQueued) return []; - if (!caps) { + const topQueued = entries.find((entry) => entry.status === "queued"); + if (!topQueued) + return []; + if (!caps) { + return [{ repoFullName: topQueued.repoFullName, identifier: topQueued.identifier, apiBaseUrl: topQueued.apiBaseUrl }]; + } + const globalActiveCount = entries.filter((entry) => entry.status === "in_progress").length; + if (globalActiveCount >= caps.globalWipCap) + return []; + // Host-scope the per-repo active count (#7224): a same-named repo on a DIFFERENT forge host is a distinct backlog + // (the store keys rows by apiBaseUrl too, #5563), so an in-progress item on host A must not consume host B's + // per-repo WIP budget. Single-host is unchanged: every entry shares one apiBaseUrl, so the added match is always true. + const repoActiveCount = entries.filter((entry) => entry.status === "in_progress" && + entry.repoFullName === topQueued.repoFullName && + entry.apiBaseUrl === topQueued.apiBaseUrl).length; + if (repoActiveCount >= caps.perRepoWipCap) + return []; return [{ repoFullName: topQueued.repoFullName, identifier: topQueued.identifier, apiBaseUrl: topQueued.apiBaseUrl }]; - } - const globalActiveCount = entries.filter((entry) => entry.status === "in_progress").length; - if (globalActiveCount >= caps.globalWipCap) return []; - // Host-scope the per-repo active count (#7224): a same-named repo on a DIFFERENT forge host is a distinct backlog - // (the store keys rows by apiBaseUrl too, #5563), so an in-progress item on host A must not consume host B's - // per-repo WIP budget. Single-host is unchanged: every entry shares one apiBaseUrl, so the added match is always true. - const repoActiveCount = entries.filter( - (entry) => - entry.status === "in_progress" && - entry.repoFullName === topQueued.repoFullName && - entry.apiBaseUrl === topQueued.apiBaseUrl, - ).length; - if (repoActiveCount >= caps.perRepoWipCap) return []; - return [{ repoFullName: topQueued.repoFullName, identifier: topQueued.identifier, apiBaseUrl: topQueued.apiBaseUrl }]; } - /** Shared ` [--api-base-url ] [--json]` parse for the item-targeting subcommands * (done/release/requeue). `usage` is the command-specific message surfaced on a malformed argv. */ function parseRepoIdentifierArgs(args, usage) { - const options = { json: false, dryRun: false, apiBaseUrl: undefined }; - const positional = []; - - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === "--json") { - options.json = true; - continue; - } - // #4847: reports what a real mutation would do and returns before opening the portfolio queue at all. - if (token === "--dry-run") { - options.dryRun = true; - continue; - } - // #5563: scope the target to a non-default forge host, so it doesn't collide with (or get confused for) a - // same-named repo on the default github.com host. - if (token === "--api-base-url") { - const value = args[index + 1]; - if (!value || value.startsWith("-")) { + const options = { + json: false, + dryRun: false, + apiBaseUrl: undefined, + }; + const positional = []; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + // #4847: reports what a real mutation would do and returns before opening the portfolio queue at all. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + // #5563: scope the target to a non-default forge host, so it doesn't collide with (or get confused for) a + // same-named repo on the default github.com host. + if (token === "--api-base-url") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + return { error: usage }; + } + options.apiBaseUrl = value; + index += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + if (positional.length !== 2) { return { error: usage }; - } - options.apiBaseUrl = value; - index += 1; - continue; - } - if (token.startsWith("-")) { - return { error: `Unknown option: ${token}` }; - } - positional.push(token); - } - - if (positional.length !== 2) { - return { error: usage }; - } - - const repo = parseRepoArg(positional[0], usage); - if ("error" in repo) return repo; - - const identifier = positional[1]?.trim(); - if (!identifier) { - return { error: usage }; - } - - return { - repoFullName: repo.repoFullName, - identifier, - dryRun: options.dryRun, - json: options.json, - apiBaseUrl: options.apiBaseUrl, - }; + } + const repo = parseRepoArg(positional[0], usage); + if ("error" in repo) + return repo; + const identifier = positional[1]?.trim(); + if (!identifier) { + return { error: usage }; + } + return { + repoFullName: repo.repoFullName, + identifier, + dryRun: options.dryRun, + json: options.json, + apiBaseUrl: options.apiBaseUrl, + }; } - export function parseQueueDoneArgs(args) { - return parseRepoIdentifierArgs(args, QUEUE_DONE_USAGE); + return parseRepoIdentifierArgs(args, QUEUE_DONE_USAGE); } - export function parseQueueReleaseArgs(args) { - return parseRepoIdentifierArgs(args, QUEUE_RELEASE_USAGE); + return parseRepoIdentifierArgs(args, QUEUE_RELEASE_USAGE); } - export function parseQueueRequeueArgs(args) { - return parseRepoIdentifierArgs(args, QUEUE_REQUEUE_USAGE); + return parseRepoIdentifierArgs(args, QUEUE_REQUEUE_USAGE); } - function display(value) { - if (value === null || value === undefined) return "-"; - return String(value); + if (value === null || value === undefined) + return "-"; + return String(value); } - export function renderQueueTable(entries) { - if (!Array.isArray(entries) || entries.length === 0) return "no portfolio queue entries"; - const header = [ - "repo".padEnd(24), - "identifier".padEnd(16), - // #7225: surface the host so a reader of the plain-text table can supply the `--api-base-url` a follow-up - // done/release/requeue needs to disambiguate two rows sharing a repo+identifier across forge hosts. - "host".padEnd(30), - "status".padEnd(12), - "pri".padStart(4), - "enqueued-at".padEnd(24), - ].join(" "); - const lines = entries.map((entry) => - [ - entry.repoFullName.padEnd(24), - entry.identifier.padEnd(16), - display(entry.apiBaseUrl).padEnd(30), - entry.status.padEnd(12), - display(entry.priority).padStart(4), - display(entry.enqueuedAt).padEnd(24), - ].join(" "), - ); - return [header, ...lines].join("\n"); + if (!Array.isArray(entries) || entries.length === 0) + return "no portfolio queue entries"; + const header = [ + "repo".padEnd(24), + "identifier".padEnd(16), + // #7225: surface the host so a reader of the plain-text table can supply the `--api-base-url` a follow-up + // done/release/requeue needs to disambiguate two rows sharing a repo+identifier across forge hosts. + "host".padEnd(30), + "status".padEnd(12), + "pri".padStart(4), + "enqueued-at".padEnd(24), + ].join(" "); + const lines = entries.map((entry) => [ + entry.repoFullName.padEnd(24), + entry.identifier.padEnd(16), + display(entry.apiBaseUrl).padEnd(30), + entry.status.padEnd(12), + display(entry.priority).padStart(4), + display(entry.enqueuedAt).padEnd(24), + ].join(" ")); + return [header, ...lines].join("\n"); } - function withPortfolioQueue(options, run) { - const ownsStore = options.initPortfolioQueue === undefined; - const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); - try { - return run(portfolioQueue); - } finally { - if (ownsStore) portfolioQueue.close(); - } + const ownsStore = options.initPortfolioQueue === undefined; + const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); + try { + return run(portfolioQueue); + } + finally { + if (ownsStore) + portfolioQueue.close(); + } } - export function runQueueList(args, options = {}) { - const parsed = parseQueueListArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - try { - return withPortfolioQueue(options, (portfolioQueue) => { - const entries = portfolioQueue.listQueue(parsed.repoFullName); - if (parsed.json) { - console.log(JSON.stringify({ entries }, null, 2)); - } else { - console.log(renderQueueTable(entries)); - } - return 0; - }); - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } + const parsed = parseQueueListArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entries = portfolioQueue.listQueue(parsed.repoFullName); + if (parsed.json) { + console.log(JSON.stringify({ entries }, null, 2)); + } + else { + console.log(renderQueueTable(entries)); + } + return 0; + }); + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } } - export function runQueueNext(args, options = {}) { - const parsed = parseQueueNextArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - const capsRequested = parsed.globalWipCap !== undefined || parsed.perRepoWipCap !== undefined; - if (parsed.dryRun) { - const dryRunResult = capsRequested - ? { outcome: "dry_run", globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap } - : { outcome: "dry_run" }; - if (parsed.json) { - console.log(JSON.stringify(dryRunResult, null, 2)); - } else if (capsRequested) { - console.log( - `DRY RUN: would dequeue the highest-priority queued item within WIP caps (global-wip: ${parsed.globalWipCap ?? "unset"}, per-repo-wip: ${parsed.perRepoWipCap ?? "unset"}). No portfolio-queue write was made.`, - ); - } else { - console.log("DRY RUN: would dequeue the highest-priority queued item. No portfolio-queue write was made."); - } - return 0; - } - - try { - return withPortfolioQueue(options, (portfolioQueue) => { - let entry; - if (capsRequested) { - // Unset dimensions stay genuinely uncapped (Infinity), not silently defaulted to 1 like claim-batch. - const caps = { - globalWipCap: parsed.globalWipCap ?? Number.POSITIVE_INFINITY, - perRepoWipCap: parsed.perRepoWipCap ?? Number.POSITIVE_INFINITY, - }; - const claimed = portfolioQueue.batchClaim((entries) => selectNextEligibleTarget(entries, caps)); - entry = claimed[0] ?? null; - } else { - entry = portfolioQueue.dequeueNext(); - } - if (parsed.json) { - console.log(JSON.stringify({ entry }, null, 2)); - } else { - console.log(entry ? entry.identifier : "none"); - } - return 0; - }); - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } + const parsed = parseQueueNextArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + const capsRequested = parsed.globalWipCap !== undefined || parsed.perRepoWipCap !== undefined; + if (parsed.dryRun) { + const dryRunResult = capsRequested + ? { outcome: "dry_run", globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap } + : { outcome: "dry_run" }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } + else if (capsRequested) { + console.log(`DRY RUN: would dequeue the highest-priority queued item within WIP caps (global-wip: ${parsed.globalWipCap ?? "unset"}, per-repo-wip: ${parsed.perRepoWipCap ?? "unset"}). No portfolio-queue write was made.`); + } + else { + console.log("DRY RUN: would dequeue the highest-priority queued item. No portfolio-queue write was made."); + } + return 0; + } + try { + return withPortfolioQueue(options, (portfolioQueue) => { + let entry; + if (capsRequested) { + // Unset dimensions stay genuinely uncapped (Infinity), not silently defaulted to 1 like claim-batch. + const caps = { + globalWipCap: parsed.globalWipCap ?? Number.POSITIVE_INFINITY, + perRepoWipCap: parsed.perRepoWipCap ?? Number.POSITIVE_INFINITY, + }; + const claimed = portfolioQueue.batchClaim((entries) => selectNextEligibleTarget(entries, caps)); + entry = claimed[0] ?? null; + } + else { + entry = portfolioQueue.dequeueNext(); + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } + else { + console.log(entry ? entry.identifier : "none"); + } + return 0; + }); + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } } - export function runQueueDone(args, options = {}) { - const parsed = parseQueueDoneArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - if (parsed.dryRun) { - const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; - if (parsed.json) { - console.log(JSON.stringify(dryRunResult, null, 2)); - } else { - console.log(`DRY RUN: would mark ${parsed.repoFullName} ${parsed.identifier} done. No portfolio-queue write was made.`); - } - return 0; - } - - try { - return withPortfolioQueue(options, (portfolioQueue) => { - const entry = portfolioQueue.markDone(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); - if (!entry) { - return reportCliFailure(parsed.json, "queue_entry_not_found"); - } - if (parsed.json) { - console.log(JSON.stringify({ entry }, null, 2)); - } else { - console.log(entry.status); - } - return 0; - }); - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } + const parsed = parseQueueDoneArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } + else { + console.log(`DRY RUN: would mark ${parsed.repoFullName} ${parsed.identifier} done. No portfolio-queue write was made.`); + } + return 0; + } + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.markDone(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); + if (!entry) { + return reportCliFailure(parsed.json, "queue_entry_not_found"); + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } + else { + console.log(entry.status); + } + return 0; + }); + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } } - /** `release `: manually give up a CLAIMED (in_progress) item, returning it to the queue * (the manual counterpart to the automated stuck-lease sweep). Exit 2 when there is no in-flight item to release. */ export function runQueueRelease(args, options = {}) { - const parsed = parseQueueReleaseArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - if (parsed.dryRun) { - const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; - if (parsed.json) { - console.log(JSON.stringify(dryRunResult, null, 2)); - } else { - console.log(`DRY RUN: would release ${parsed.repoFullName} ${parsed.identifier} back to the queue. No portfolio-queue write was made.`); - } - return 0; - } - - try { - return withPortfolioQueue(options, (portfolioQueue) => { - const entry = portfolioQueue.reclaimStuckItem(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); - if (!entry) { - return reportCliFailure(parsed.json, "queue_entry_not_in_progress"); - } - if (parsed.json) { - console.log(JSON.stringify({ entry }, null, 2)); - } else { - console.log(entry.status); - } - return 0; - }); - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } + const parsed = parseQueueReleaseArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } + else { + console.log(`DRY RUN: would release ${parsed.repoFullName} ${parsed.identifier} back to the queue. No portfolio-queue write was made.`); + } + return 0; + } + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.reclaimStuckItem(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); + if (!entry) { + return reportCliFailure(parsed.json, "queue_entry_not_in_progress"); + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } + else { + console.log(entry.status); + } + return 0; + }); + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } } - /** `requeue `: manually put a COMPLETED (done) item back on the queue so it is picked up * again, keeping its original FIFO position. Exit 2 when there is no done item to requeue (already queued, * in-flight — release it instead — or absent). */ export function runQueueRequeue(args, options = {}) { - const parsed = parseQueueRequeueArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - if (parsed.dryRun) { - const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; - if (parsed.json) { - console.log(JSON.stringify(dryRunResult, null, 2)); - } else { - console.log(`DRY RUN: would requeue ${parsed.repoFullName} ${parsed.identifier}. No portfolio-queue write was made.`); - } - return 0; - } - - try { - return withPortfolioQueue(options, (portfolioQueue) => { - const entry = portfolioQueue.requeueItem(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); - if (!entry) { - return reportCliFailure(parsed.json, "queue_entry_not_requeuable"); - } - if (parsed.json) { - console.log(JSON.stringify({ entry }, null, 2)); - } else { - console.log(entry.status); - } - return 0; - }); - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } + const parsed = parseQueueRequeueArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } + else { + console.log(`DRY RUN: would requeue ${parsed.repoFullName} ${parsed.identifier}. No portfolio-queue write was made.`); + } + return 0; + } + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.requeueItem(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); + if (!entry) { + return reportCliFailure(parsed.json, "queue_entry_not_requeuable"); + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } + else { + console.log(entry.status); + } + return 0; + }); + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } } - export function parseQueueClaimBatchArgs(args) { - const options = { json: false, dryRun: false, globalWipCap: 1, perRepoWipCap: 1 }; - for (let index = 0; index < args.length; index += 1) { - const token = args[index]; - if (token === "--json") { - options.json = true; - continue; - } - if (token === "--dry-run") { - options.dryRun = true; - continue; - } - if (token === "--global-wip" || token === "--per-repo-wip") { - const value = Number(args[index + 1]); - if (args[index + 1] === undefined || !Number.isFinite(value) || value < 0) { + const options = { + json: false, + dryRun: false, + globalWipCap: 1, + perRepoWipCap: 1, + }; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--global-wip" || token === "--per-repo-wip") { + const value = Number(args[index + 1]); + if (args[index + 1] === undefined || !Number.isFinite(value) || value < 0) { + return { error: QUEUE_CLAIM_BATCH_USAGE }; + } + if (token === "--global-wip") + options.globalWipCap = value; + else + options.perRepoWipCap = value; + index += 1; + continue; + } return { error: QUEUE_CLAIM_BATCH_USAGE }; - } - if (token === "--global-wip") options.globalWipCap = value; - else options.perRepoWipCap = value; - index += 1; - continue; - } - return { error: QUEUE_CLAIM_BATCH_USAGE }; - } - return options; + } + return options; } - /** Claim the next caps-aware batch via the WIP-cap-aware batch claimer (portfolio-queue-manager.js), which also * reclaims any leases orphaned by a crashed process first (#4833 wires the previously caller-less claimer). */ export function runQueueClaimBatch(args, options = {}) { - const parsed = parseQueueClaimBatchArgs(args); - if ("error" in parsed) { - return reportCliFailure(argsWantJson(args), parsed.error); - } - - if (parsed.dryRun) { - const dryRunResult = { outcome: "dry_run", globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }; - if (parsed.json) { - console.log(JSON.stringify(dryRunResult, null, 2)); - } else { - console.log( - `DRY RUN: would claim a batch (global-wip: ${parsed.globalWipCap}, per-repo-wip: ${parsed.perRepoWipCap}). No portfolio-queue write was made.`, - ); - } - return 0; - } - - // Open the manager INSIDE the try so a store open failure returns 2 instead of crashing; the finally guards the - // close with `?.` since the initializer may have thrown before assigning. - const ownsManager = options.initPortfolioQueueManager === undefined; - let manager; - try { - manager = (options.initPortfolioQueueManager ?? initPortfolioQueueManager)({ - caps: { globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }, - }); - const claimed = manager.claimNextBatch(); - if (parsed.json) { - console.log(JSON.stringify({ claimed }, null, 2)); - } else { - console.log(claimed.length === 0 ? "none" : claimed.map((entry) => entry.identifier).join("\n")); - } - return 0; - } catch (error) { - return reportCliFailure(parsed.json, describeCliError(error)); - } finally { - if (ownsManager) manager?.close(); - } + const parsed = parseQueueClaimBatchArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } + else { + console.log(`DRY RUN: would claim a batch (global-wip: ${parsed.globalWipCap}, per-repo-wip: ${parsed.perRepoWipCap}). No portfolio-queue write was made.`); + } + return 0; + } + // Open the manager INSIDE the try so a store open failure returns 2 instead of crashing; the finally guards the + // close with `?.` since the initializer may have thrown before assigning. + const ownsManager = options.initPortfolioQueueManager === undefined; + let manager; + try { + manager = (options.initPortfolioQueueManager ?? initPortfolioQueueManager)({ + caps: { globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }, + }); + const claimed = manager.claimNextBatch(); + if (parsed.json) { + console.log(JSON.stringify({ claimed }, null, 2)); + } + else { + console.log(claimed.length === 0 ? "none" : claimed.map((entry) => entry.identifier).join("\n")); + } + return 0; + } + catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } + finally { + if (ownsManager) + manager?.close(); + } } - const QUEUE_METRICS_USAGE = "Usage: loopover-miner queue metrics"; - // Prometheus metric names for the portfolio-queue gauges (#5186). Mirrors the `loopover_miner_*` naming and // HELP/TYPE/label conventions of event-ledger-cli.js's renderEventLedgerMetrics / the engine's // renderMinerPredictionMetrics, rather than importing across the package boundary. export const QUEUE_ITEMS = "loopover_miner_portfolio_queue_items"; export const QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS = "loopover_miner_portfolio_queue_oldest_in_progress_lease_age_seconds"; - /** HELP-text escaping — backslash + newline (mirrors miner-prediction-metrics.ts's escapeHelpText). */ function escapeMetricsHelpText(help) { - return help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"); + return help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"); } - /** * Render portfolio-queue backlog health as Prometheus text-exposition gauges: current item count per status, and * the age of the OLDEST still-in-flight lease -- the concrete "is anything stuck" signal a @@ -510,64 +496,65 @@ function escapeMetricsHelpText(help) { * @param {number} nowMs */ export function renderPortfolioQueueMetrics(queueEntries, leaseEntries, nowMs) { - const countByStatus = new Map(); - for (const entry of queueEntries) { - countByStatus.set(entry.status, (countByStatus.get(entry.status) ?? 0) + 1); - } - - let oldestLeaseAgeSeconds = 0; - for (const lease of leaseEntries) { - const leasedAtMs = Date.parse(lease.leasedAt ?? ""); - if (!Number.isFinite(leasedAtMs)) continue; - const ageSeconds = Math.max(0, (nowMs - leasedAtMs) / 1000); - if (ageSeconds > oldestLeaseAgeSeconds) oldestLeaseAgeSeconds = ageSeconds; - } - - const lines = [ - `# HELP ${QUEUE_ITEMS} ${escapeMetricsHelpText("Current portfolio-queue item count, by status.")}`, - `# TYPE ${QUEUE_ITEMS} gauge`, - ]; - for (const [status, count] of [...countByStatus.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { - lines.push(`${QUEUE_ITEMS}{status="${status}"} ${count}`); - } - - lines.push( - `# HELP ${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} ${escapeMetricsHelpText("Age in seconds of the oldest still-in-flight (in_progress) claim lease. 0 when nothing is in-flight.")}`, - ); - lines.push(`# TYPE ${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} gauge`); - lines.push(`${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} ${oldestLeaseAgeSeconds}`); - - return `${lines.join("\n")}\n`; + const countByStatus = new Map(); + for (const entry of queueEntries) { + countByStatus.set(entry.status, (countByStatus.get(entry.status) ?? 0) + 1); + } + let oldestLeaseAgeSeconds = 0; + for (const lease of leaseEntries) { + const leasedAtMs = Date.parse(lease.leasedAt ?? ""); + if (!Number.isFinite(leasedAtMs)) + continue; + const ageSeconds = Math.max(0, (nowMs - leasedAtMs) / 1000); + if (ageSeconds > oldestLeaseAgeSeconds) + oldestLeaseAgeSeconds = ageSeconds; + } + const lines = [ + `# HELP ${QUEUE_ITEMS} ${escapeMetricsHelpText("Current portfolio-queue item count, by status.")}`, + `# TYPE ${QUEUE_ITEMS} gauge`, + ]; + for (const [status, count] of [...countByStatus.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + lines.push(`${QUEUE_ITEMS}{status="${status}"} ${count}`); + } + lines.push(`# HELP ${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} ${escapeMetricsHelpText("Age in seconds of the oldest still-in-flight (in_progress) claim lease. 0 when nothing is in-flight.")}`); + lines.push(`# TYPE ${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} gauge`); + lines.push(`${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} ${oldestLeaseAgeSeconds}`); + return `${lines.join("\n")}\n`; } - export function runQueueMetrics(args, options = {}) { - if (args.length > 0) { - return reportCliFailure(argsWantJson(args), QUEUE_METRICS_USAGE); - } - - try { - return withPortfolioQueue(options, (portfolioQueue) => { - const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now(); - // renderPortfolioQueueMetrics returns a newline-terminated document; console.log re-adds the terminator, so - // trim it to emit exactly one trailing newline (mirrors metrics-cli.js's runMetrics). - console.log( - renderPortfolioQueueMetrics(portfolioQueue.listQueue(), portfolioQueue.listInProgress(), nowMs).trimEnd(), - ); - return 0; - }); - } catch (error) { - return reportCliFailure(argsWantJson(args), describeCliError(error)); - } + if (args.length > 0) { + return reportCliFailure(argsWantJson(args), QUEUE_METRICS_USAGE); + } + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const nowMs = typeof options.nowMs === "number" && Number.isFinite(options.nowMs) ? options.nowMs : Date.now(); + // renderPortfolioQueueMetrics returns a newline-terminated document; console.log re-adds the terminator, so + // trim it to emit exactly one trailing newline (mirrors metrics-cli.js's runMetrics). + console.log(renderPortfolioQueueMetrics(portfolioQueue.listQueue(), portfolioQueue.listInProgress(), nowMs).trimEnd()); + return 0; + }); + } + catch (error) { + return reportCliFailure(argsWantJson(args), describeCliError(error)); + } } - export function runQueueCli(subcommand, args, options = {}) { - if (subcommand === "list") return runQueueList(args, options); - if (subcommand === "next") return runQueueNext(args, options); - if (subcommand === "done") return runQueueDone(args, options); - if (subcommand === "release") return runQueueRelease(args, options); - if (subcommand === "requeue") return runQueueRequeue(args, options); - if (subcommand === "claim-batch") return runQueueClaimBatch(args, options); - if (subcommand === "metrics") return runQueueMetrics(args, options); - if (subcommand === "dashboard") return runPortfolioDashboard(args, options); - return reportCliFailure(argsWantJson(args), `Unknown queue subcommand: ${subcommand ?? ""}. ${QUEUE_LIST_USAGE}`); + if (subcommand === "list") + return runQueueList(args, options); + if (subcommand === "next") + return runQueueNext(args, options); + if (subcommand === "done") + return runQueueDone(args, options); + if (subcommand === "release") + return runQueueRelease(args, options); + if (subcommand === "requeue") + return runQueueRequeue(args, options); + if (subcommand === "claim-batch") + return runQueueClaimBatch(args, options); + if (subcommand === "metrics") + return runQueueMetrics(args, options); + if (subcommand === "dashboard") + return runPortfolioDashboard(args, options); + return reportCliFailure(argsWantJson(args), `Unknown queue subcommand: ${subcommand ?? ""}. ${QUEUE_LIST_USAGE}`); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicG9ydGZvbGlvLXF1ZXVlLWNsaS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInBvcnRmb2xpby1xdWV1ZS1jbGkudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLHVCQUF1QixFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFFL0QsT0FBTyxFQUFFLHlCQUF5QixFQUFFLE1BQU0sOEJBQThCLENBQUM7QUFFekUsT0FBTyxFQUFFLHFCQUFxQixFQUFFLE1BQU0sMEJBQTBCLENBQUM7QUFDakUsT0FBTyxFQUFFLFlBQVksRUFBRSxnQkFBZ0IsRUFBRSxnQkFBZ0IsRUFBRSxNQUFNLGdCQUFnQixDQUFDO0FBRWxGLE1BQU0sZ0JBQWdCLEdBQUcsaUVBQWlFLENBQUM7QUFDM0YsTUFBTSxnQkFBZ0IsR0FDcEIsK0ZBQStGLENBQUM7QUFDbEcsTUFBTSxnQkFBZ0IsR0FDcEIsd0dBQXdHLENBQUM7QUFDM0csTUFBTSxtQkFBbUIsR0FDdkIsMkdBQTJHLENBQUM7QUFDOUcsTUFBTSxtQkFBbUIsR0FDdkIsMkdBQTJHLENBQUM7QUFDOUcsTUFBTSx1QkFBdUIsR0FDM0Isc0dBQXNHLENBQUM7QUFtQ3pHLFNBQVMsWUFBWSxDQUFDLEtBQXlCLEVBQUUsS0FBYTtJQUM1RCxJQUFJLENBQUMsS0FBSztRQUFFLE9BQU8sRUFBRSxLQUFLLEVBQUUsS0FBSyxFQUFFLENBQUM7SUFDcEMsTUFBTSxPQUFPLEdBQUcsS0FBSyxDQUFDLElBQUksRUFBRSxDQUFDO0lBQzdCLE1BQU0sQ0FBQyxLQUFLLEVBQUUsSUFBSSxFQUFFLEtBQUssQ0FBQyxHQUFHLE9BQU8sQ0FBQyxLQUFLLENBQUMsR0FBRyxDQUFDLENBQUM7SUFDaEQsSUFBSSxDQUFDLEtBQUssSUFBSSxDQUFDLElBQUksSUFBSSxLQUFLLEtBQUssU0FBUyxFQUFFLENBQUM7UUFDM0MsT0FBTyxFQUFFLEtBQUssRUFBRSx3Q0FBd0MsRUFBRSxDQUFDO0lBQzdELENBQUM7SUFDRCxPQUFPLEVB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/portfolio-queue-cli.ts b/packages/loopover-miner/lib/portfolio-queue-cli.ts new file mode 100644 index 0000000000..2a6149bcd6 --- /dev/null +++ b/packages/loopover-miner/lib/portfolio-queue-cli.ts @@ -0,0 +1,639 @@ +import { initPortfolioQueueStore } from "./portfolio-queue.js"; +import type { PortfolioQueueStore, QueueEntry } from "./portfolio-queue.js"; +import { initPortfolioQueueManager } from "./portfolio-queue-manager.js"; +import type { PortfolioQueueManager } from "./portfolio-queue-manager.js"; +import { runPortfolioDashboard } from "./portfolio-dashboard.js"; +import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; + +const QUEUE_LIST_USAGE = "Usage: loopover-miner queue list [--repo ] [--json]"; +const QUEUE_NEXT_USAGE = + "Usage: loopover-miner queue next [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]"; +const QUEUE_DONE_USAGE = + "Usage: loopover-miner queue done [--api-base-url ] [--dry-run] [--json]"; +const QUEUE_RELEASE_USAGE = + "Usage: loopover-miner queue release [--api-base-url ] [--dry-run] [--json]"; +const QUEUE_REQUEUE_USAGE = + "Usage: loopover-miner queue requeue [--api-base-url ] [--dry-run] [--json]"; +const QUEUE_CLAIM_BATCH_USAGE = + "Usage: loopover-miner queue claim-batch [--global-wip ] [--per-repo-wip ] [--dry-run] [--json]"; + +export type ParsedQueueListArgs = + | { + json: boolean; + repoFullName: string | null; + } + | { error: string }; + +export type ParsedQueueNextArgs = + | { json: boolean; dryRun: boolean; globalWipCap: number | undefined; perRepoWipCap: number | undefined } + | { error: string }; + +export type QueueClaimTarget = { repoFullName: string; identifier: string; apiBaseUrl: string }; + +export type ParsedQueueDoneArgs = + | { + repoFullName: string; + identifier: string; + dryRun: boolean; + json: boolean; + apiBaseUrl: string | undefined; + } + | { error: string }; + +export type ParsedQueueClaimBatchArgs = + | { json: boolean; dryRun: boolean; globalWipCap: number; perRepoWipCap: number } + | { error: string }; + +type PortfolioQueueCliOptions = { + initPortfolioQueue?: () => PortfolioQueueStore; + initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager; + nowMs?: number; +}; + +function parseRepoArg(value: string | undefined, usage: string): { error: string } | { repoFullName: string } { + if (!value) return { error: usage }; + const trimmed = value.trim(); + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) { + return { error: "Repository must be in owner/repo form." }; + } + return { repoFullName: `${owner}/${repo}` }; +} + +export function parseQueueListArgs(args: string[]): ParsedQueueListArgs { + const options: { json: boolean; repoFullName: string | null } = { json: false, repoFullName: null }; + const positional: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--repo") { + const repoArg = args[index + 1]; + if (!repoArg || repoArg.startsWith("-")) { + return { error: QUEUE_LIST_USAGE }; + } + const repo = parseRepoArg(repoArg, QUEUE_LIST_USAGE); + if ("error" in repo) return repo; + options.repoFullName = repo.repoFullName; + index += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + + if (positional.length > 0) { + return { error: QUEUE_LIST_USAGE }; + } + + return options; +} + +// #4850: --global-wip/--per-repo-wip are OMITTED (undefined) by default -- queue next stays uncapped, byte- +// identical to its pre-#4850 behavior, unless an operator explicitly opts in. Mirrors queue claim-batch's own +// flag names (portfolio-queue-manager.js's WIP-cap-aware claimer), but claim-batch's OWN default of 1/1 is not +// reused here: claim-batch's whole purpose is cap enforcement, while queue next has always been a plain +// highest-priority dequeue and must not silently start capping existing callers that never asked for it. +export function parseQueueNextArgs(args: string[]): ParsedQueueNextArgs { + const options: { + json: boolean; + dryRun: boolean; + globalWipCap: number | undefined; + perRepoWipCap: number | undefined; + } = { json: false, dryRun: false, globalWipCap: undefined, perRepoWipCap: undefined }; + const positional: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--global-wip" || token === "--per-repo-wip") { + const value = Number(args[index + 1]); + if (args[index + 1] === undefined || !Number.isFinite(value) || value < 0) { + return { error: QUEUE_NEXT_USAGE }; + } + if (token === "--global-wip") options.globalWipCap = value; + else options.perRepoWipCap = value; + index += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + + if (positional.length > 0) { + return { error: QUEUE_NEXT_USAGE }; + } + return options; +} + +/** + * Pick at most one atomically-claimable target from the store's already-priority-ordered active rows (queued + * AND in_progress interleaved, exactly `batchClaim`'s own `entries` shape). `caps` of `null` replicates the + * pre-#4850 behavior: the single highest-priority queued row, unconditionally. When caps are set, refuses to + * select anything once the global or the target row's own per-repo in-progress count has reached its cap -- + * "stops claiming once the cap is reached" (#4850), not a diversifying batch selection (that remains + * claim-batch's job via the engine's own `nextEligibleItems`). + * @param {Array<{ repoFullName: string, identifier: string, apiBaseUrl: string, status: string }>} entries + * @param {{ globalWipCap: number, perRepoWipCap: number } | null} caps + */ +export function selectNextEligibleTarget( + entries: Array<{ repoFullName: string; identifier: string; apiBaseUrl: string; status: string }>, + caps: { globalWipCap: number; perRepoWipCap: number } | null, +): QueueClaimTarget[] { + const topQueued = entries.find((entry) => entry.status === "queued"); + if (!topQueued) return []; + if (!caps) { + return [{ repoFullName: topQueued.repoFullName, identifier: topQueued.identifier, apiBaseUrl: topQueued.apiBaseUrl }]; + } + const globalActiveCount = entries.filter((entry) => entry.status === "in_progress").length; + if (globalActiveCount >= caps.globalWipCap) return []; + // Host-scope the per-repo active count (#7224): a same-named repo on a DIFFERENT forge host is a distinct backlog + // (the store keys rows by apiBaseUrl too, #5563), so an in-progress item on host A must not consume host B's + // per-repo WIP budget. Single-host is unchanged: every entry shares one apiBaseUrl, so the added match is always true. + const repoActiveCount = entries.filter( + (entry) => + entry.status === "in_progress" && + entry.repoFullName === topQueued.repoFullName && + entry.apiBaseUrl === topQueued.apiBaseUrl, + ).length; + if (repoActiveCount >= caps.perRepoWipCap) return []; + return [{ repoFullName: topQueued.repoFullName, identifier: topQueued.identifier, apiBaseUrl: topQueued.apiBaseUrl }]; +} + +/** Shared ` [--api-base-url ] [--json]` parse for the item-targeting subcommands + * (done/release/requeue). `usage` is the command-specific message surfaced on a malformed argv. */ +function parseRepoIdentifierArgs(args: string[], usage: string): ParsedQueueDoneArgs { + const options: { json: boolean; dryRun: boolean; apiBaseUrl: string | undefined } = { + json: false, + dryRun: false, + apiBaseUrl: undefined, + }; + const positional: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--json") { + options.json = true; + continue; + } + // #4847: reports what a real mutation would do and returns before opening the portfolio queue at all. + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + // #5563: scope the target to a non-default forge host, so it doesn't collide with (or get confused for) a + // same-named repo on the default github.com host. + if (token === "--api-base-url") { + const value = args[index + 1]; + if (!value || value.startsWith("-")) { + return { error: usage }; + } + options.apiBaseUrl = value; + index += 1; + continue; + } + if (token.startsWith("-")) { + return { error: `Unknown option: ${token}` }; + } + positional.push(token); + } + + if (positional.length !== 2) { + return { error: usage }; + } + + const repo = parseRepoArg(positional[0], usage); + if ("error" in repo) return repo; + + const identifier = positional[1]?.trim(); + if (!identifier) { + return { error: usage }; + } + + return { + repoFullName: repo.repoFullName, + identifier, + dryRun: options.dryRun, + json: options.json, + apiBaseUrl: options.apiBaseUrl, + }; +} + +export function parseQueueDoneArgs(args: string[]): ParsedQueueDoneArgs { + return parseRepoIdentifierArgs(args, QUEUE_DONE_USAGE); +} + +export function parseQueueReleaseArgs(args: string[]): ParsedQueueDoneArgs { + return parseRepoIdentifierArgs(args, QUEUE_RELEASE_USAGE); +} + +export function parseQueueRequeueArgs(args: string[]): ParsedQueueDoneArgs { + return parseRepoIdentifierArgs(args, QUEUE_REQUEUE_USAGE); +} + +function display(value: unknown): string { + if (value === null || value === undefined) return "-"; + return String(value); +} + +export function renderQueueTable(entries: QueueEntry[]): string { + if (!Array.isArray(entries) || entries.length === 0) return "no portfolio queue entries"; + const header = [ + "repo".padEnd(24), + "identifier".padEnd(16), + // #7225: surface the host so a reader of the plain-text table can supply the `--api-base-url` a follow-up + // done/release/requeue needs to disambiguate two rows sharing a repo+identifier across forge hosts. + "host".padEnd(30), + "status".padEnd(12), + "pri".padStart(4), + "enqueued-at".padEnd(24), + ].join(" "); + const lines = entries.map((entry) => + [ + entry.repoFullName.padEnd(24), + entry.identifier.padEnd(16), + display(entry.apiBaseUrl).padEnd(30), + entry.status.padEnd(12), + display(entry.priority).padStart(4), + display(entry.enqueuedAt).padEnd(24), + ].join(" "), + ); + return [header, ...lines].join("\n"); +} + +function withPortfolioQueue( + options: { initPortfolioQueue?: () => PortfolioQueueStore }, + run: (portfolioQueue: PortfolioQueueStore) => T, +): T { + const ownsStore = options.initPortfolioQueue === undefined; + const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)(); + try { + return run(portfolioQueue); + } finally { + if (ownsStore) portfolioQueue.close(); + } +} + +export function runQueueList(args: string[], options: { initPortfolioQueue?: () => PortfolioQueueStore } = {}): number { + const parsed = parseQueueListArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entries = portfolioQueue.listQueue(parsed.repoFullName); + if (parsed.json) { + console.log(JSON.stringify({ entries }, null, 2)); + } else { + console.log(renderQueueTable(entries)); + } + return 0; + }); + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} + +export function runQueueNext(args: string[], options: { initPortfolioQueue?: () => PortfolioQueueStore } = {}): number { + const parsed = parseQueueNextArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + const capsRequested = parsed.globalWipCap !== undefined || parsed.perRepoWipCap !== undefined; + if (parsed.dryRun) { + const dryRunResult = capsRequested + ? { outcome: "dry_run", globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap } + : { outcome: "dry_run" }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else if (capsRequested) { + console.log( + `DRY RUN: would dequeue the highest-priority queued item within WIP caps (global-wip: ${parsed.globalWipCap ?? "unset"}, per-repo-wip: ${parsed.perRepoWipCap ?? "unset"}). No portfolio-queue write was made.`, + ); + } else { + console.log("DRY RUN: would dequeue the highest-priority queued item. No portfolio-queue write was made."); + } + return 0; + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + let entry; + if (capsRequested) { + // Unset dimensions stay genuinely uncapped (Infinity), not silently defaulted to 1 like claim-batch. + const caps = { + globalWipCap: parsed.globalWipCap ?? Number.POSITIVE_INFINITY, + perRepoWipCap: parsed.perRepoWipCap ?? Number.POSITIVE_INFINITY, + }; + const claimed = portfolioQueue.batchClaim((entries) => selectNextEligibleTarget(entries, caps)); + entry = claimed[0] ?? null; + } else { + entry = portfolioQueue.dequeueNext(); + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } else { + console.log(entry ? entry.identifier : "none"); + } + return 0; + }); + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} + +export function runQueueDone(args: string[], options: { initPortfolioQueue?: () => PortfolioQueueStore } = {}): number { + const parsed = parseQueueDoneArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log(`DRY RUN: would mark ${parsed.repoFullName} ${parsed.identifier} done. No portfolio-queue write was made.`); + } + return 0; + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.markDone(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); + if (!entry) { + return reportCliFailure(parsed.json, "queue_entry_not_found"); + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } else { + console.log(entry.status); + } + return 0; + }); + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} + +/** `release `: manually give up a CLAIMED (in_progress) item, returning it to the queue + * (the manual counterpart to the automated stuck-lease sweep). Exit 2 when there is no in-flight item to release. */ +export function runQueueRelease(args: string[], options: { initPortfolioQueue?: () => PortfolioQueueStore } = {}): number { + const parsed = parseQueueReleaseArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log(`DRY RUN: would release ${parsed.repoFullName} ${parsed.identifier} back to the queue. No portfolio-queue write was made.`); + } + return 0; + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.reclaimStuckItem(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); + if (!entry) { + return reportCliFailure(parsed.json, "queue_entry_not_in_progress"); + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } else { + console.log(entry.status); + } + return 0; + }); + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} + +/** `requeue `: manually put a COMPLETED (done) item back on the queue so it is picked up + * again, keeping its original FIFO position. Exit 2 when there is no done item to requeue (already queued, + * in-flight — release it instead — or absent). */ +export function runQueueRequeue(args: string[], options: { initPortfolioQueue?: () => PortfolioQueueStore } = {}): number { + const parsed = parseQueueRequeueArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", repoFullName: parsed.repoFullName, identifier: parsed.identifier }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log(`DRY RUN: would requeue ${parsed.repoFullName} ${parsed.identifier}. No portfolio-queue write was made.`); + } + return 0; + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const entry = portfolioQueue.requeueItem(parsed.repoFullName, parsed.identifier, parsed.apiBaseUrl); + if (!entry) { + return reportCliFailure(parsed.json, "queue_entry_not_requeuable"); + } + if (parsed.json) { + console.log(JSON.stringify({ entry }, null, 2)); + } else { + console.log(entry.status); + } + return 0; + }); + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } +} + +export function parseQueueClaimBatchArgs(args: string[]): ParsedQueueClaimBatchArgs { + const options: { json: boolean; dryRun: boolean; globalWipCap: number; perRepoWipCap: number } = { + json: false, + dryRun: false, + globalWipCap: 1, + perRepoWipCap: 1, + }; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--dry-run") { + options.dryRun = true; + continue; + } + if (token === "--global-wip" || token === "--per-repo-wip") { + const value = Number(args[index + 1]); + if (args[index + 1] === undefined || !Number.isFinite(value) || value < 0) { + return { error: QUEUE_CLAIM_BATCH_USAGE }; + } + if (token === "--global-wip") options.globalWipCap = value; + else options.perRepoWipCap = value; + index += 1; + continue; + } + return { error: QUEUE_CLAIM_BATCH_USAGE }; + } + return options; +} + +/** Claim the next caps-aware batch via the WIP-cap-aware batch claimer (portfolio-queue-manager.js), which also + * reclaims any leases orphaned by a crashed process first (#4833 wires the previously caller-less claimer). */ +export function runQueueClaimBatch(args: string[], options: { initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager } = {}): number { + const parsed = parseQueueClaimBatchArgs(args); + if ("error" in parsed) { + return reportCliFailure(argsWantJson(args), parsed.error); + } + + if (parsed.dryRun) { + const dryRunResult = { outcome: "dry_run", globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }; + if (parsed.json) { + console.log(JSON.stringify(dryRunResult, null, 2)); + } else { + console.log( + `DRY RUN: would claim a batch (global-wip: ${parsed.globalWipCap}, per-repo-wip: ${parsed.perRepoWipCap}). No portfolio-queue write was made.`, + ); + } + return 0; + } + + // Open the manager INSIDE the try so a store open failure returns 2 instead of crashing; the finally guards the + // close with `?.` since the initializer may have thrown before assigning. + const ownsManager = options.initPortfolioQueueManager === undefined; + let manager: PortfolioQueueManager | undefined; + try { + manager = (options.initPortfolioQueueManager ?? initPortfolioQueueManager)({ + caps: { globalWipCap: parsed.globalWipCap, perRepoWipCap: parsed.perRepoWipCap }, + }); + const claimed = manager.claimNextBatch(); + if (parsed.json) { + console.log(JSON.stringify({ claimed }, null, 2)); + } else { + console.log(claimed.length === 0 ? "none" : claimed.map((entry) => entry.identifier).join("\n")); + } + return 0; + } catch (error) { + return reportCliFailure(parsed.json, describeCliError(error)); + } finally { + if (ownsManager) manager?.close(); + } +} + +const QUEUE_METRICS_USAGE = "Usage: loopover-miner queue metrics"; + +// Prometheus metric names for the portfolio-queue gauges (#5186). Mirrors the `loopover_miner_*` naming and +// HELP/TYPE/label conventions of event-ledger-cli.js's renderEventLedgerMetrics / the engine's +// renderMinerPredictionMetrics, rather than importing across the package boundary. +export const QUEUE_ITEMS = "loopover_miner_portfolio_queue_items"; +export const QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS = "loopover_miner_portfolio_queue_oldest_in_progress_lease_age_seconds"; + +/** HELP-text escaping — backslash + newline (mirrors miner-prediction-metrics.ts's escapeHelpText). */ +function escapeMetricsHelpText(help: string): string { + return help.replace(/\\/g, "\\\\").replace(/\n/g, "\\n"); +} + +/** + * Render portfolio-queue backlog health as Prometheus text-exposition gauges: current item count per status, and + * the age of the OLDEST still-in-flight lease -- the concrete "is anything stuck" signal a + * `loopover_queue_oldest_maintenance_pending_age_seconds`-style alert rule can threshold on (#5186). Pure and + * side-effect-free: the caller supplies the rows and `nowMs` (no internal clock read, matching + * store-maintenance.js's pruneLedgerByRetention convention) and prints the result. Deterministic (status series + * sorted); always emits HELP/TYPE so an empty queue is still a well-formed exposition document, and the lease-age + * gauge reads 0 (never stuck) rather than being omitted when nothing is in-flight. + * @param {Array<{ status: string }>} queueEntries - every row, any status (e.g. store.listQueue()'s output). + * @param {Array<{ leasedAt: string | null }>} leaseEntries - in-flight rows only (store.listInProgress()'s output). + * @param {number} nowMs + */ +export function renderPortfolioQueueMetrics( + queueEntries: Array<{ status: string }>, + leaseEntries: Array<{ leasedAt: string | null }>, + nowMs: number, +): string { + const countByStatus = new Map(); + for (const entry of queueEntries) { + countByStatus.set(entry.status, (countByStatus.get(entry.status) ?? 0) + 1); + } + + let oldestLeaseAgeSeconds = 0; + for (const lease of leaseEntries) { + const leasedAtMs = Date.parse(lease.leasedAt ?? ""); + if (!Number.isFinite(leasedAtMs)) continue; + const ageSeconds = Math.max(0, (nowMs - leasedAtMs) / 1000); + if (ageSeconds > oldestLeaseAgeSeconds) oldestLeaseAgeSeconds = ageSeconds; + } + + const lines = [ + `# HELP ${QUEUE_ITEMS} ${escapeMetricsHelpText("Current portfolio-queue item count, by status.")}`, + `# TYPE ${QUEUE_ITEMS} gauge`, + ]; + for (const [status, count] of [...countByStatus.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + lines.push(`${QUEUE_ITEMS}{status="${status}"} ${count}`); + } + + lines.push( + `# HELP ${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} ${escapeMetricsHelpText("Age in seconds of the oldest still-in-flight (in_progress) claim lease. 0 when nothing is in-flight.")}`, + ); + lines.push(`# TYPE ${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} gauge`); + lines.push(`${QUEUE_OLDEST_IN_PROGRESS_LEASE_AGE_SECONDS} ${oldestLeaseAgeSeconds}`); + + return `${lines.join("\n")}\n`; +} + +export function runQueueMetrics(args: string[], options: { initPortfolioQueue?: () => PortfolioQueueStore; nowMs?: number } = {}): number { + if (args.length > 0) { + return reportCliFailure(argsWantJson(args), QUEUE_METRICS_USAGE); + } + + try { + return withPortfolioQueue(options, (portfolioQueue) => { + const nowMs = typeof options.nowMs === "number" && Number.isFinite(options.nowMs) ? options.nowMs : Date.now(); + // renderPortfolioQueueMetrics returns a newline-terminated document; console.log re-adds the terminator, so + // trim it to emit exactly one trailing newline (mirrors metrics-cli.js's runMetrics). + console.log( + renderPortfolioQueueMetrics(portfolioQueue.listQueue(), portfolioQueue.listInProgress(), nowMs).trimEnd(), + ); + return 0; + }); + } catch (error) { + return reportCliFailure(argsWantJson(args), describeCliError(error)); + } +} + +export function runQueueCli( + subcommand: string | undefined, + args: string[], + options: { + initPortfolioQueue?: () => PortfolioQueueStore; + initPortfolioQueueManager?: (opts: unknown) => PortfolioQueueManager; + } = {}, +): number { + if (subcommand === "list") return runQueueList(args, options); + if (subcommand === "next") return runQueueNext(args, options); + if (subcommand === "done") return runQueueDone(args, options); + if (subcommand === "release") return runQueueRelease(args, options); + if (subcommand === "requeue") return runQueueRequeue(args, options); + if (subcommand === "claim-batch") return runQueueClaimBatch(args, options); + if (subcommand === "metrics") return runQueueMetrics(args, options); + if (subcommand === "dashboard") return runPortfolioDashboard(args, options); + return reportCliFailure(argsWantJson(args), `Unknown queue subcommand: ${subcommand ?? ""}. ${QUEUE_LIST_USAGE}`); +} diff --git a/packages/loopover-miner/lib/self-review-context.d.ts b/packages/loopover-miner/lib/self-review-context.d.ts index 3fdcd3bcdc..509432d1b5 100644 --- a/packages/loopover-miner/lib/self-review-context.d.ts +++ b/packages/loopover-miner/lib/self-review-context.d.ts @@ -1,58 +1,66 @@ -import type { SelfReviewContext, FocusManifest } from "@loopover/engine"; - -// `bounties` is always omitted (see this file's own header comment for why), so the result is -// SelfReviewContext minus that optional field rather than the full type. `issueQuality` is populated (#6057). +import type { FocusManifest, SelfReviewContext } from "@loopover/engine"; export type SelfReviewContextResult = Omit; - -// A narrower shape than `typeof fetch` on purpose: this module only ever calls it with a string URL and a -// plain GET init, and the ambient `fetch` type in this repo's TS program is Cloudflare-Workers-flavored -// (RequestInfo | URL), which is both irrelevant here (this package runs under plain Node) and -// stricter than any real caller needs -- same rationale as live-issue-snapshot.js's own LiveIssueSnapshotFetch. -export type SelfReviewContextFetch = ( - url: string, - init?: { method?: string; headers?: Record; signal?: AbortSignal }, -) => Promise<{ - ok: boolean; - status: number; - json: () => Promise; - text: () => Promise; +export type SelfReviewContextFetch = (url: string, init?: { + method?: string; + headers?: Record; + signal?: AbortSignal; +}) => Promise<{ + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; }>; - export type LiveGateThresholdFields = { - confidence_floor: number | null; - scope_cap_files: number | null; - scope_cap_lines: number | null; + confidence_floor: number | null; + scope_cap_files: number | null; + scope_cap_lines: number | null; }; - export type LoopoverBackendSessionAuth = { - apiUrl?: string; - sessionToken: string; + apiUrl?: string; + sessionToken: string; }; - export type FetchSelfReviewContextOptions = { - githubToken?: string; - contributorLogin?: string; - linkedIssues?: number[]; - apiBaseUrl?: string; - rawContentBaseUrl?: string; - gittensorApiBase?: string; - fetchImpl?: SelfReviewContextFetch; - perPage?: number; - maxPages?: number; - requestTimeoutMs?: number; - /** Short ORB live-gate-thresholds probe budget (#6487). Default 400ms. */ - liveGateProbeTimeoutMs?: number; - /** Explicit session auth for the ORB probe; `null` forces standalone (skip probe). */ - loopoverAuth?: LoopoverBackendSessionAuth | null; - /** Env used to resolve loopover-mcp session when `loopoverAuth` is omitted. */ - env?: NodeJS.ProcessEnv; + githubToken?: string; + contributorLogin?: string; + linkedIssues?: number[]; + apiBaseUrl?: string; + rawContentBaseUrl?: string; + gittensorApiBase?: string; + fetchImpl?: SelfReviewContextFetch; + perPage?: number; + maxPages?: number; + requestTimeoutMs?: number; + /** Short ORB live-gate-thresholds probe budget (#6487). Default 400ms. */ + liveGateProbeTimeoutMs?: number; + /** Explicit session auth for the ORB probe; `null` forces standalone (skip probe). */ + loopoverAuth?: LoopoverBackendSessionAuth | null; + /** Env used to resolve loopover-mcp session when `loopoverAuth` is omitted. */ + env?: NodeJS.ProcessEnv; }; - -export function parseLiveGateThresholdFields(payload: unknown): LiveGateThresholdFields | null; - -export function applyLiveGateThresholdsToManifest( - manifest: FocusManifest, - fields: LiveGateThresholdFields | null, -): FocusManifest; - -export function fetchSelfReviewContext(repoFullName: string, options?: FetchSelfReviewContextOptions): Promise; +/** Validate the field-limited #6486/#6487 payload; null when nothing usable is present. */ +export declare function parseLiveGateThresholdFields(payload: unknown): LiveGateThresholdFields | null; +/** + * Overlay live ORB thresholds onto a statically-reconstructed FocusManifest (#6487). + * - confidence_floor → raise-only readinessMinScore (mirrors applySelfTuneOverrideToSettings). + * - scope_cap_files / scope_cap_lines → prefer live sizeMaxFiles / sizeMaxLines when present. + * Other gate fields are left untouched. + */ +export declare function applyLiveGateThresholdsToManifest(manifest: FocusManifest, fields: LiveGateThresholdFields | null): FocusManifest; +/** + * Build a real SelfReviewContext from live GitHub data, at the same fidelity the live gate's own DB-backed + * construction produces. See this file's header for the one field (bounties) deliberately left undefined + * and why; issueQuality is populated from the live GitHub snapshot. Optionally overlays ORB live gate + * thresholds onto the static `.loopover.yml` reconstruction (#6487). + * + * @param {string} repoFullName + * @param {{ + * githubToken?: string, contributorLogin?: string, linkedIssues?: number[], + * apiBaseUrl?: string, rawContentBaseUrl?: string, gittensorApiBase?: string, + * fetchImpl?: typeof fetch, perPage?: number, maxPages?: number, requestTimeoutMs?: number, + * liveGateProbeTimeoutMs?: number, + * loopoverAuth?: { apiUrl?: string, sessionToken: string } | null, + * env?: NodeJS.ProcessEnv, + * }} [options] + * @returns {Promise} + */ +export declare function fetchSelfReviewContext(repoFullName: string, options?: FetchSelfReviewContextOptions): Promise; diff --git a/packages/loopover-miner/lib/self-review-context.js b/packages/loopover-miner/lib/self-review-context.js index bc5682ef54..abaf522995 100644 --- a/packages/loopover-miner/lib/self-review-context.js +++ b/packages/loopover-miner/lib/self-review-context.js @@ -1,11 +1,5 @@ -import { - buildCollisionReport, - buildIssueQualityReport, - MAX_FOCUS_MANIFEST_BYTES, - parseFocusManifestContent, -} from "@loopover/engine"; +import { buildCollisionReport, buildIssueQualityReport, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifestContent, } from "@loopover/engine"; import { resolveLoopoverBackendSession } from "./github-token-resolution.js"; - // Real SelfReviewContext fetcher (#5145, Wave 3.5). Builds the context object the miner's self-review pass // (packages/loopover-engine/src/miner/self-review-adapter.ts) needs, at the SAME fidelity the live gate's // own DB-backed construction produces (src/db/repositories.ts's toRepositoryRecord/toIssueRecord/ @@ -24,7 +18,6 @@ import { resolveLoopoverBackendSession } from "./github-token-resolution.js"; // (same loopover-mcp session posture as resolveGitHubToken). On success, overlay confidence_floor / // scope_cap_files / scope_cap_lines onto the parsed manifest gate; on 403/timeout/404/no-session, keep the // static reconstruction unchanged. Fully-standalone (ORB-absent) paths stay byte-identical. - const GITHUB_API_VERSION = "2022-11-28"; const DEFAULT_API_BASE_URL = "https://api.github.com"; const DEFAULT_RAW_CONTENT_BASE_URL = "https://raw.githubusercontent.com"; @@ -34,78 +27,72 @@ const DEFAULT_MAX_PAGES = 10; const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; /** Short ORB probe budget (#6487) — must never make discover/gate-prediction meaningfully slower when ORB is absent. */ const DEFAULT_LIVE_GATE_PROBE_TIMEOUT_MS = 400; - // Mirrors src/signals/focus-manifest-loader.ts's MANIFEST_FILE_CANDIDATES exactly -- first candidate that // resolves wins, same as the live gate's own lookup order. const MANIFEST_FILE_CANDIDATES = [".loopover.yml", ".github/loopover.yml", ".loopover.json", ".github/loopover.json"]; - function parseRepoFullName(repoFullName) { - if (typeof repoFullName !== "string") return null; - const [owner, repo, extra] = repoFullName.split("/"); - if (!owner || !repo || extra !== undefined) return null; - return { owner, repo }; + if (typeof repoFullName !== "string") + return null; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) + return null; + return { owner, repo }; } - function githubHeaders(githubToken) { - const headers = { - accept: "application/vnd.github+json", - "user-agent": "loopover-miner", - "x-github-api-version": GITHUB_API_VERSION, - }; - const token = typeof githubToken === "string" ? githubToken.trim() : ""; - if (token) headers.authorization = `Bearer ${token}`; - return headers; + const headers = { + accept: "application/vnd.github+json", + "user-agent": "loopover-miner", + "x-github-api-version": GITHUB_API_VERSION, + }; + const token = typeof githubToken === "string" ? githubToken.trim() : ""; + if (token) + headers.authorization = `Bearer ${token}`; + return headers; } - function normalizeOptions(options = {}) { - const env = options.env ?? process.env; - // Explicit null skips the probe (tests / forced-standalone). Undefined ⇒ resolve from loopover-mcp session. - const loopoverAuth = - options.loopoverAuth === null - ? null - : options.loopoverAuth && typeof options.loopoverAuth.sessionToken === "string" && options.loopoverAuth.sessionToken - ? { - apiUrl: - typeof options.loopoverAuth.apiUrl === "string" && options.loopoverAuth.apiUrl.trim() - ? options.loopoverAuth.apiUrl.replace(/\/+$/, "") - : (resolveLoopoverBackendSession(env)?.apiUrl ?? "https://api.loopover.ai"), - sessionToken: options.loopoverAuth.sessionToken, - } - : resolveLoopoverBackendSession(env); - return { - githubToken: options.githubToken ?? env.GITHUB_TOKEN ?? "", - apiBaseUrl: typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() ? options.apiBaseUrl.trim() : DEFAULT_API_BASE_URL, - rawContentBaseUrl: - typeof options.rawContentBaseUrl === "string" && options.rawContentBaseUrl.trim() ? options.rawContentBaseUrl.trim() : DEFAULT_RAW_CONTENT_BASE_URL, - gittensorApiBase: - typeof options.gittensorApiBase === "string" && options.gittensorApiBase.trim() ? options.gittensorApiBase.trim() : DEFAULT_GITTENSOR_API_BASE, - fetchImpl: options.fetchImpl ?? fetch, - perPage: Number.isInteger(options.perPage) && options.perPage > 0 ? options.perPage : DEFAULT_PER_PAGE, - maxPages: Number.isInteger(options.maxPages) && options.maxPages > 0 ? options.maxPages : DEFAULT_MAX_PAGES, - contributorLogin: typeof options.contributorLogin === "string" ? options.contributorLogin.trim() : "", - linkedIssues: Array.isArray(options.linkedIssues) ? options.linkedIssues.filter((n) => Number.isInteger(n)) : [], - requestTimeoutMs: Number.isInteger(options.requestTimeoutMs) && options.requestTimeoutMs > 0 ? options.requestTimeoutMs : DEFAULT_REQUEST_TIMEOUT_MS, - liveGateProbeTimeoutMs: - Number.isInteger(options.liveGateProbeTimeoutMs) && options.liveGateProbeTimeoutMs > 0 - ? options.liveGateProbeTimeoutMs - : DEFAULT_LIVE_GATE_PROBE_TIMEOUT_MS, - loopoverAuth, - }; + const env = options.env ?? process.env; + // Explicit null skips the probe (tests / forced-standalone). Undefined ⇒ resolve from loopover-mcp session. + const loopoverAuth = options.loopoverAuth === null + ? null + : options.loopoverAuth && typeof options.loopoverAuth.sessionToken === "string" && options.loopoverAuth.sessionToken + ? { + apiUrl: typeof options.loopoverAuth.apiUrl === "string" && options.loopoverAuth.apiUrl.trim() + ? options.loopoverAuth.apiUrl.replace(/\/+$/, "") + : (resolveLoopoverBackendSession(env)?.apiUrl ?? "https://api.loopover.ai"), + sessionToken: options.loopoverAuth.sessionToken, + } + : resolveLoopoverBackendSession(env); + return { + githubToken: options.githubToken ?? env.GITHUB_TOKEN ?? "", + apiBaseUrl: typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() ? options.apiBaseUrl.trim() : DEFAULT_API_BASE_URL, + rawContentBaseUrl: typeof options.rawContentBaseUrl === "string" && options.rawContentBaseUrl.trim() ? options.rawContentBaseUrl.trim() : DEFAULT_RAW_CONTENT_BASE_URL, + gittensorApiBase: typeof options.gittensorApiBase === "string" && options.gittensorApiBase.trim() ? options.gittensorApiBase.trim() : DEFAULT_GITTENSOR_API_BASE, + fetchImpl: (options.fetchImpl ?? fetch), + perPage: Number.isInteger(options.perPage) && options.perPage > 0 ? options.perPage : DEFAULT_PER_PAGE, + maxPages: Number.isInteger(options.maxPages) && options.maxPages > 0 ? options.maxPages : DEFAULT_MAX_PAGES, + contributorLogin: typeof options.contributorLogin === "string" ? options.contributorLogin.trim() : "", + linkedIssues: Array.isArray(options.linkedIssues) ? options.linkedIssues.filter((n) => Number.isInteger(n)) : [], + requestTimeoutMs: Number.isInteger(options.requestTimeoutMs) && options.requestTimeoutMs > 0 ? options.requestTimeoutMs : DEFAULT_REQUEST_TIMEOUT_MS, + liveGateProbeTimeoutMs: Number.isInteger(options.liveGateProbeTimeoutMs) && options.liveGateProbeTimeoutMs > 0 + ? options.liveGateProbeTimeoutMs + : DEFAULT_LIVE_GATE_PROBE_TIMEOUT_MS, + loopoverAuth, + }; } - /** Validate the field-limited #6486/#6487 payload; null when nothing usable is present. */ export function parseLiveGateThresholdFields(payload) { - if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; - const confidence_floor = - typeof payload.confidence_floor === "number" && payload.confidence_floor >= 0 && payload.confidence_floor <= 1 - ? payload.confidence_floor - : null; - const scope_cap_files = typeof payload.scope_cap_files === "number" && payload.scope_cap_files > 0 ? payload.scope_cap_files : null; - const scope_cap_lines = typeof payload.scope_cap_lines === "number" && payload.scope_cap_lines > 0 ? payload.scope_cap_lines : null; - if (confidence_floor === null && scope_cap_files === null && scope_cap_lines === null) return null; - return { confidence_floor, scope_cap_files, scope_cap_lines }; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) + return null; + const record = payload; + const confidence_floor = typeof record.confidence_floor === "number" && record.confidence_floor >= 0 && record.confidence_floor <= 1 + ? record.confidence_floor + : null; + const scope_cap_files = typeof record.scope_cap_files === "number" && record.scope_cap_files > 0 ? record.scope_cap_files : null; + const scope_cap_lines = typeof record.scope_cap_lines === "number" && record.scope_cap_lines > 0 ? record.scope_cap_lines : null; + if (confidence_floor === null && scope_cap_files === null && scope_cap_lines === null) + return null; + return { confidence_floor, scope_cap_files, scope_cap_lines }; } - /** * Overlay live ORB thresholds onto a statically-reconstructed FocusManifest (#6487). * - confidence_floor → raise-only readinessMinScore (mirrors applySelfTuneOverrideToSettings). @@ -113,276 +100,270 @@ export function parseLiveGateThresholdFields(payload) { * Other gate fields are left untouched. */ export function applyLiveGateThresholdsToManifest(manifest, fields) { - if (!manifest || !fields) return manifest; - const gate = { ...manifest.gate }; - if (typeof fields.confidence_floor === "number") { - const floorScore = Math.max(0, Math.min(100, Math.round(fields.confidence_floor * 100))); - if (typeof gate.readinessMinScore === "number" && floorScore > gate.readinessMinScore) { - gate.readinessMinScore = floorScore; + if (!manifest || !fields) + return manifest; + const gate = { ...manifest.gate }; + if (typeof fields.confidence_floor === "number") { + const floorScore = Math.max(0, Math.min(100, Math.round(fields.confidence_floor * 100))); + if (typeof gate.readinessMinScore === "number" && floorScore > gate.readinessMinScore) { + gate.readinessMinScore = floorScore; + } } - } - if (typeof fields.scope_cap_files === "number" && fields.scope_cap_files > 0) { - gate.sizeMaxFiles = fields.scope_cap_files; - } - if (typeof fields.scope_cap_lines === "number" && fields.scope_cap_lines > 0) { - gate.sizeMaxLines = fields.scope_cap_lines; - } - return { ...manifest, gate }; + if (typeof fields.scope_cap_files === "number" && fields.scope_cap_files > 0) { + gate.sizeMaxFiles = fields.scope_cap_files; + } + if (typeof fields.scope_cap_lines === "number" && fields.scope_cap_lines > 0) { + gate.sizeMaxLines = fields.scope_cap_lines; + } + return { ...manifest, gate }; } - async function probeLiveGateThresholds(target, resolved) { - const auth = resolved.loopoverAuth; - if (!auth?.sessionToken) return null; - const url = `${auth.apiUrl}/v1/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/live-gate-thresholds`; - try { - const response = await fetchWithTimeout( - resolved.fetchImpl, - url, - { - method: "GET", - headers: { - authorization: `Bearer ${auth.sessionToken}`, - accept: "application/json", - "user-agent": "loopover-miner", - }, - }, - resolved.liveGateProbeTimeoutMs, - ); - if (!response.ok) return null; - const payload = await response.json().catch(() => null); - return parseLiveGateThresholdFields(payload); - } catch { - return null; - } + const auth = resolved.loopoverAuth; + if (!auth?.sessionToken) + return null; + const url = `${auth.apiUrl}/v1/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/live-gate-thresholds`; + try { + const response = await fetchWithTimeout(resolved.fetchImpl, url, { + method: "GET", + headers: { + authorization: `Bearer ${auth.sessionToken}`, + accept: "application/json", + "user-agent": "loopover-miner", + }, + }, resolved.liveGateProbeTimeoutMs); + if (!response.ok) + return null; + const payload = await response.json().catch(() => null); + return parseLiveGateThresholdFields(payload); + } + catch { + return null; + } } - // A fresh AbortSignal.timeout() per call, so a stalled connection can't hang context construction forever // (#miner-github-read-timeouts) -- shared by this file's three independent fetch call sites (GitHub REST, raw // manifest content, the Gittensor contributor lookup). async function fetchWithTimeout(fetchImpl, url, init, timeoutMs) { - return fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); + return fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); } - async function githubGetJson(url, resolved) { - const response = await fetchWithTimeout(resolved.fetchImpl, url, { method: "GET", headers: githubHeaders(resolved.githubToken) }, resolved.requestTimeoutMs); - const payload = await response.json().catch(() => null); - return { response, payload }; + const response = await fetchWithTimeout(resolved.fetchImpl, url, { method: "GET", headers: githubHeaders(resolved.githubToken) }, resolved.requestTimeoutMs); + const payload = await response.json().catch(() => null); + return { response, payload }; } - -async function fetchPaginated(pathWithQuery, resolved) { - const results = []; - for (let page = 1; page <= resolved.maxPages; page += 1) { - const separator = pathWithQuery.includes("?") ? "&" : "?"; - const url = `${resolved.apiBaseUrl}${pathWithQuery}${separator}per_page=${resolved.perPage}&page=${page}`; - const { response, payload } = await githubGetJson(url, resolved); - if (!response.ok || !Array.isArray(payload)) break; - results.push(...payload); - if (payload.length < resolved.perPage) break; - } - return results; +async function fetchPaginated(path, query, resolved) { + const results = []; + for (let page = 1; page <= resolved.maxPages; page += 1) { + const params = new URLSearchParams({ ...query, per_page: String(resolved.perPage), page: String(page) }); + const url = `${resolved.apiBaseUrl}${path}?${params}`; + const { response, payload } = await githubGetJson(url, resolved); + if (!response.ok || !Array.isArray(payload)) + break; + results.push(...payload); + if (payload.length < resolved.perPage) + break; + } + return results; } - // Mirrors src/db/repositories.ts's toRepositoryRecord + upsertRepositoryFromGitHub's field mapping. The // miner has no App installation/DB, so installationId/isInstalled/isRegistered/registryConfig are honest // "unregistered" defaults, not values pulled from GitHub -- GitHub's own repo payload carries none of them. async function fetchRepositoryRecord(target, resolved) { - const url = `${resolved.apiBaseUrl}/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}`; - const { response, payload } = await githubGetJson(url, resolved); - if (!response.ok || !payload || typeof payload !== "object") return null; - return { - fullName: `${target.owner}/${target.repo}`, - owner: payload.owner?.login ?? target.owner, - name: payload.name ?? target.repo, - installationId: undefined, - isInstalled: false, - isRegistered: false, - isPrivate: payload.private ?? false, - htmlUrl: payload.html_url ?? null, - defaultBranch: payload.default_branch ?? null, - registryConfig: null, - }; + const url = `${resolved.apiBaseUrl}/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}`; + const { response, payload } = await githubGetJson(url, resolved); + if (!response.ok || !payload || typeof payload !== "object") + return null; + return { + fullName: `${target.owner}/${target.repo}`, + owner: payload.owner?.login ?? target.owner, + name: payload.name ?? target.repo, + installationId: undefined, + isInstalled: false, + isRegistered: false, + isPrivate: payload.private ?? false, + htmlUrl: payload.html_url ?? null, + defaultBranch: payload.default_branch ?? null, + registryConfig: null, + }; } - // Mirrors src/db/repositories.ts's extractLinkedPrNumbers: a real link needs a CLOSING KEYWORD, not a bare // mention (#6769). Without the keyword prefix, an incidental "similar to what we saw in PR #501" in an issue // body counted as a linked PR, so the issue-quality report read the issue as "already references a PR" and the // miner skipped an available issue (the host's own #issue-body-pr-mention-pollution fix, never ported here). const LINKED_PR_PATTERN = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:PR|pull request)\s+#(\d+)\b/gi; function extractLinkedPrNumbers(body) { - const numbers = []; - for (const match of body.matchAll(LINKED_PR_PATTERN)) { - const number = Number(match[1]); - if (Number.isInteger(number) && number > 0) numbers.push(number); - } - return numbers; + const numbers = []; + for (const match of body.matchAll(LINKED_PR_PATTERN)) { + const number = Number(match[1]); + if (Number.isInteger(number) && number > 0) + numbers.push(number); + } + return numbers; } - // Mirrors src/db/repositories.ts's extractLinkedIssueNumbers: GitHub's own closing-keyword vocabulary, only // counting a fully-qualified owner/repo#N reference when it targets the SAME repo being fetched. const LINKED_ISSUE_PATTERN = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:([\w.-]+\/[\w.-]+)#|#)(\d+)\b/gi; function extractLinkedIssueNumbers(body, repoFullName) { - // Strip backtick code spans first so a closing-keyword pattern quoted as example code doesn't count. - const withoutCodeSpans = body.replace(/`[^`]*`/g, ""); - const numbers = []; - const normalizedRepo = repoFullName.toLowerCase(); - for (const match of withoutCodeSpans.matchAll(LINKED_ISSUE_PATTERN)) { - const qualifiedRepo = match[1]; - if (qualifiedRepo !== undefined && qualifiedRepo.toLowerCase() !== normalizedRepo) continue; - const number = Number(match[2]); - if (Number.isInteger(number) && number > 0) numbers.push(number); - } - return numbers; + // Strip backtick code spans first so a closing-keyword pattern quoted as example code doesn't count. + const withoutCodeSpans = body.replace(/`[^`]*`/g, ""); + const numbers = []; + const normalizedRepo = repoFullName.toLowerCase(); + for (const match of withoutCodeSpans.matchAll(LINKED_ISSUE_PATTERN)) { + const qualifiedRepo = match[1]; + if (qualifiedRepo !== undefined && qualifiedRepo.toLowerCase() !== normalizedRepo) + continue; + const number = Number(match[2]); + if (Number.isInteger(number) && number > 0) + numbers.push(number); + } + return numbers; } - function labelNames(labels) { - if (!Array.isArray(labels)) return []; - return labels.flatMap((label) => (label && typeof label === "object" && typeof label.name === "string" ? [label.name] : [])); + if (!Array.isArray(labels)) + return []; + return labels.flatMap((label) => (label && typeof label === "object" && typeof label.name === "string" ? [label.name] : [])); } - // Mirrors src/db/repositories.ts's toIssueRecord, populated straight from the live payload (createdAt/ // updatedAt/closedAt come from the DB-row read path there only as a caching artifact, not a semantic // transform -- the live REST fields are the real source). function toIssueRecord(repoFullName, issue) { - const body = issue.body ?? ""; - return { - repoFullName, - number: issue.number, - title: issue.title, - state: issue.state, - authorLogin: issue.user?.login ?? null, - authorAssociation: issue.author_association ?? null, - htmlUrl: issue.html_url ?? null, - body, - createdAt: issue.created_at ?? null, - updatedAt: issue.updated_at ?? null, - closedAt: issue.closed_at ?? null, - labels: labelNames(issue.labels), - linkedPrs: extractLinkedPrNumbers(body), - }; + const body = issue.body ?? ""; + return { + repoFullName, + number: issue.number, + title: issue.title, + state: issue.state, + authorLogin: issue.user?.login ?? null, + authorAssociation: issue.author_association ?? null, + htmlUrl: issue.html_url ?? null, + body, + createdAt: issue.created_at ?? null, + updatedAt: issue.updated_at ?? null, + closedAt: issue.closed_at ?? null, + labels: labelNames(issue.labels), + linkedPrs: extractLinkedPrNumbers(body), + }; } - async function fetchOpenIssueRecords(target, resolved) { - const payloads = await fetchPaginated( - `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/issues?state=open&sort=created&direction=asc`, - resolved, - ); - // GitHub's Issues endpoint also returns pull requests -- filter them out, same as the live gate's own fetch. - return payloads.filter((issue) => issue && typeof issue === "object" && !issue.pull_request).map((issue) => toIssueRecord(`${target.owner}/${target.repo}`, issue)); + const payloads = await fetchPaginated(`/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/issues`, { state: "open", sort: "created", direction: "asc" }, resolved); + // GitHub's Issues endpoint also returns pull requests -- filter them out, same as the live gate's own fetch. + return payloads.filter((issue) => issue && typeof issue === "object" && !issue.pull_request).map((issue) => toIssueRecord(`${target.owner}/${target.repo}`, issue)); } - function mergeableBooleanState(mergeable) { - if (mergeable === true) return "clean"; - if (mergeable === false) return "dirty"; - return null; + if (mergeable === true) + return "clean"; + if (mergeable === false) + return "dirty"; + return null; } - // Mirrors src/db/repositories.ts's toPullRequestRecord. Only the fields SelfReviewContext/buildCollisionReport // actually consume are populated with real precision; merge/RC3 gate-plumbing fields the live gate's fuller // PullRequestRecord carries (mergeAttemptCount, approvedHeadSha, ...) don't exist on the engine package's // leaner mirror type and aren't meaningful for a miner attempt anyway. function toPullRequestRecord(repoFullName, pr) { - const body = pr.body ?? ""; - return { - repoFullName, - number: pr.number, - title: pr.title, - state: pr.state, - authorLogin: pr.user?.login ?? null, - authorAssociation: pr.author_association ?? null, - headSha: pr.head?.sha ?? null, - headRef: pr.head?.ref ?? null, - baseRef: pr.base?.ref ?? null, - htmlUrl: pr.html_url ?? null, - mergedAt: pr.merged_at ?? null, - isDraft: pr.draft ?? null, - mergeableState: pr.mergeable_state ?? mergeableBooleanState(pr.mergeable), - reviewDecision: null, - body, - createdAt: pr.created_at ?? null, - updatedAt: pr.updated_at ?? null, - closedAt: pr.closed_at ?? null, - labels: labelNames(pr.labels), - linkedIssues: extractLinkedIssueNumbers(body, repoFullName), - }; + const body = pr.body ?? ""; + return { + repoFullName, + number: pr.number, + title: pr.title, + state: pr.state, + authorLogin: pr.user?.login ?? null, + authorAssociation: pr.author_association ?? null, + headSha: pr.head?.sha ?? null, + headRef: pr.head?.ref ?? null, + baseRef: pr.base?.ref ?? null, + htmlUrl: pr.html_url ?? null, + mergedAt: pr.merged_at ?? null, + isDraft: pr.draft ?? null, + mergeableState: pr.mergeable_state ?? mergeableBooleanState(pr.mergeable), + reviewDecision: null, + body, + createdAt: pr.created_at ?? null, + updatedAt: pr.updated_at ?? null, + closedAt: pr.closed_at ?? null, + labels: labelNames(pr.labels), + linkedIssues: extractLinkedIssueNumbers(body, repoFullName), + }; } - async function fetchOpenPullRequestRecords(target, resolved) { - const payloads = await fetchPaginated( - `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/pulls?state=open&sort=created&direction=asc`, - resolved, - ); - return payloads.map((pr) => toPullRequestRecord(`${target.owner}/${target.repo}`, pr)); + const payloads = await fetchPaginated(`/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/pulls`, { state: "open", sort: "created", direction: "asc" }, resolved); + return payloads.map((pr) => toPullRequestRecord(`${target.owner}/${target.repo}`, pr)); } - // Mirrors src/signals/focus-manifest-loader.ts's raw-content lookup order and bounded body read: // first candidate path that resolves wins, but hostile manifests never exceed the parser byte cap in memory. async function readBoundedManifestResponseText(response) { - const contentLength = response.headers?.get?.("content-length") ?? null; - if (contentLength !== null) { - const parsedLength = Number.parseInt(contentLength, 10); - if (Number.isFinite(parsedLength) && parsedLength > MAX_FOCUS_MANIFEST_BYTES) return null; - } - if (!response.body?.getReader) { - const text = await response.text(); - if (typeof text !== "string") return null; - return new TextEncoder().encode(text).byteLength > MAX_FOCUS_MANIFEST_BYTES ? null : text; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let totalBytes = 0; - let text = ""; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - totalBytes += value.byteLength; - if (totalBytes > MAX_FOCUS_MANIFEST_BYTES) { - await reader.cancel(); - return null; - } - text += decoder.decode(value, { stream: true }); + const contentLength = response.headers?.get?.("content-length") ?? null; + if (contentLength !== null) { + const parsedLength = Number.parseInt(contentLength, 10); + if (Number.isFinite(parsedLength) && parsedLength > MAX_FOCUS_MANIFEST_BYTES) + return null; + } + if (!response.body?.getReader) { + const text = await response.text(); + if (typeof text !== "string") + return null; + return new TextEncoder().encode(text).byteLength > MAX_FOCUS_MANIFEST_BYTES ? null : text; + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let totalBytes = 0; + let text = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) + break; + totalBytes += value.byteLength; + if (totalBytes > MAX_FOCUS_MANIFEST_BYTES) { + await reader.cancel(); + return null; + } + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + return text; + } + finally { + reader.releaseLock(); } - text += decoder.decode(); - return text; - } finally { - reader.releaseLock(); - } } - async function fetchManifestContent(target, resolved) { - for (const path of MANIFEST_FILE_CANDIDATES) { - const url = `${resolved.rawContentBaseUrl}/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/HEAD/${path}`; - try { - const response = await fetchWithTimeout(resolved.fetchImpl, url, { method: "GET", headers: { accept: "application/json", "user-agent": "loopover-miner" } }, resolved.requestTimeoutMs); - if (response.ok) { - const text = await readBoundedManifestResponseText(response); - if (typeof text === "string") return text; - } - } catch { - // Try the next candidate path. + for (const path of MANIFEST_FILE_CANDIDATES) { + const url = `${resolved.rawContentBaseUrl}/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/HEAD/${path}`; + try { + const response = await fetchWithTimeout(resolved.fetchImpl, url, { method: "GET", headers: { accept: "application/json", "user-agent": "loopover-miner" } }, resolved.requestTimeoutMs); + if (response.ok) { + const text = await readBoundedManifestResponseText(response); + if (typeof text === "string") + return text; + } + } + catch { + // Try the next candidate path. + } } - } - return null; + return null; } - // Mirrors src/gittensor/api.ts's fetchGittensorContributorSnapshot/fetchOfficialGittensorMiner: a public, // unauthenticated GET against the Gittensor API (not GitHub) -- confirmed only when a real entry with a // matching GitHub login is found; any transport/parse failure fails closed to "not confirmed", never throws. async function fetchConfirmedContributor(login, resolved) { - if (!login) return false; - try { - const response = await fetchWithTimeout(resolved.fetchImpl, `${resolved.gittensorApiBase}/miners`, { method: "GET", headers: { accept: "application/json" } }, resolved.requestTimeoutMs); - if (!response.ok) return false; - const payload = await response.json().catch(() => null); - if (!Array.isArray(payload)) return false; - const normalizedLogin = login.toLowerCase(); - return payload.some((miner) => typeof miner?.githubUsername === "string" && miner.githubUsername.toLowerCase() === normalizedLogin); - } catch { - return false; - } + if (!login) + return false; + try { + const response = await fetchWithTimeout(resolved.fetchImpl, `${resolved.gittensorApiBase}/miners`, { method: "GET", headers: { accept: "application/json" } }, resolved.requestTimeoutMs); + if (!response.ok) + return false; + const payload = await response.json().catch(() => null); + if (!Array.isArray(payload)) + return false; + const normalizedLogin = login.toLowerCase(); + return payload.some((miner) => typeof miner?.githubUsername === "string" && miner.githubUsername.toLowerCase() === normalizedLogin); + } + catch { + return false; + } } - // Per self-review-adapter.ts's own doc comment: the caller computes inDuplicateCluster "the same way the // live gate's collision report would" -- adapted from src/signals/engine.ts's real // isPullRequestInDuplicateCluster (root src/, not extracted to the engine package), which requires >= 2 @@ -394,15 +375,12 @@ async function fetchConfirmedContributor(login, resolved) { // not-yet-existing PR number, since the miner's own submission doesn't exist as a real PullRequestRecord yet. // Takes a prebuilt CollisionReport so issueQuality and inDuplicateCluster share one collision pass. function computeInDuplicateCluster(collisionReport, targetIssueNumbers) { - if (targetIssueNumbers.length === 0) return false; - return collisionReport.clusters.some( - (cluster) => - cluster.risk === "high" && - cluster.items.filter((item) => item.type === "pull_request").length >= 2 && - cluster.items.some((item) => item.type === "issue" && targetIssueNumbers.includes(item.number)), - ); + if (targetIssueNumbers.length === 0) + return false; + return collisionReport.clusters.some((cluster) => cluster.risk === "high" && + cluster.items.filter((item) => item.type === "pull_request").length >= 2 && + cluster.items.some((item) => item.type === "issue" && targetIssueNumbers.includes(item.number))); } - /** * Build a real SelfReviewContext from live GitHub data, at the same fidelity the live gate's own DB-backed * construction produces. See this file's header for the one field (bounties) deliberately left undefined @@ -421,36 +399,35 @@ function computeInDuplicateCluster(collisionReport, targetIssueNumbers) { * @returns {Promise} */ export async function fetchSelfReviewContext(repoFullName, options = {}) { - const target = parseRepoFullName(repoFullName); - if (!target) throw new Error("invalid_repo_full_name"); - const resolved = normalizeOptions(options); - - const [repo, issues, pullRequests, manifestContent, confirmedContributor, liveGateThresholds] = await Promise.all([ - fetchRepositoryRecord(target, resolved), - fetchOpenIssueRecords(target, resolved), - fetchOpenPullRequestRecords(target, resolved), - fetchManifestContent(target, resolved), - fetchConfirmedContributor(resolved.contributorLogin, resolved), - probeLiveGateThresholds(target, resolved), - ]); - - const staticManifest = parseFocusManifestContent(manifestContent, "repo_file"); - const manifest = applyLiveGateThresholdsToManifest(staticManifest, liveGateThresholds); - // Positional args match buildIssueQualityReport(repo, issues, pullRequests, fullName, bounties, collisions, recentMerged): - // repo is the full RepositoryRecord from fetchRepositoryRecord (not a string); empty bounties/recentMerged - // because this fetcher has no external bounty source and does not yet pull merge history. - const fullName = `${target.owner}/${target.repo}`; - const collisions = buildCollisionReport(fullName, issues, pullRequests); - const inDuplicateCluster = computeInDuplicateCluster(collisions, resolved.linkedIssues); - const issueQuality = buildIssueQualityReport(repo, issues, pullRequests, fullName, [], collisions, []); - - return { - manifest, - repo, - issues, - pullRequests, - confirmedContributor, - inDuplicateCluster, - issueQuality, - }; + const target = parseRepoFullName(repoFullName); + if (!target) + throw new Error("invalid_repo_full_name"); + const resolved = normalizeOptions(options); + const [repo, issues, pullRequests, manifestContent, confirmedContributor, liveGateThresholds] = await Promise.all([ + fetchRepositoryRecord(target, resolved), + fetchOpenIssueRecords(target, resolved), + fetchOpenPullRequestRecords(target, resolved), + fetchManifestContent(target, resolved), + fetchConfirmedContributor(resolved.contributorLogin, resolved), + probeLiveGateThresholds(target, resolved), + ]); + const staticManifest = parseFocusManifestContent(manifestContent, "repo_file"); + const manifest = applyLiveGateThresholdsToManifest(staticManifest, liveGateThresholds); + // Positional args match buildIssueQualityReport(repo, issues, pullRequests, fullName, bounties, collisions, recentMerged): + // repo is the full RepositoryRecord from fetchRepositoryRecord (not a string); empty bounties/recentMerged + // because this fetcher has no external bounty source and does not yet pull merge history. + const fullName = `${target.owner}/${target.repo}`; + const collisions = buildCollisionReport(fullName, issues, pullRequests); + const inDuplicateCluster = computeInDuplicateCluster(collisions, resolved.linkedIssues); + const issueQuality = buildIssueQualityReport(repo, issues, pullRequests, fullName, [], collisions, []); + return { + manifest, + repo, + issues, + pullRequests, + confirmedContributor, + inDuplicateCluster, + issueQuality, + }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2VsZi1yZXZpZXctY29udGV4dC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInNlbGYtcmV2aWV3LWNvbnRleHQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUNMLG9CQUFvQixFQUNwQix1QkFBdUIsRUFDdkIsd0JBQXdCLEVBQ3hCLHlCQUF5QixHQUMxQixNQUFNLGtCQUFrQixDQUFDO0FBRTFCLE9BQU8sRUFBRSw2QkFBNkIsRUFBRSxNQUFNLDhCQUE4QixDQUFDO0FBa0Q3RSwyR0FBMkc7QUFDM0csMEdBQTBHO0FBQzFHLGtHQUFrRztBQUNsRyw2R0FBNkc7QUFDN0csd0dBQXdHO0FBQ3hHLEVBQUU7QUFDRix3R0FBd0c7QUFDeEcsMkdBQTJHO0FBQzNHLHlFQUF5RTtBQUN6RSxFQUFFO0FBQ0YsNkdBQTZHO0FBQzdHLDRHQUE0RztBQUM1RyxnSEFBZ0g7QUFDaEgsRUFBRTtBQUNGLCtHQUErRztBQUMvRyxvR0FBb0c7QUFDcEcsMkdBQTJHO0FBQzNHLDRGQUE0RjtBQUU1RixNQUFNLGtCQUFrQixHQUFHLFlBQVksQ0FBQztBQUN4QyxNQUFNLG9CQUFvQixHQUFHLHdCQUF3QixDQUFDO0FBQ3RELE1BQU0sNEJBQTRCLEdBQUcsbUNBQW1DLENBQUM7QUFDekUsTUFBTSwwQkFBMEIsR0FBRywwQkFBMEIsQ0FBQztBQUM5RCxNQUFNLGdCQUFnQixHQUFHLEdBQUcsQ0FBQztBQUM3QixNQUFNLGlCQUFpQixHQUFHLEVBQUUsQ0FBQztBQUM3QixNQUFNLDBCQUEwQixHQUFHLE1BQU0sQ0FBQztBQUMxQyx3SEFBd0g7QUFDeEgsTUFBTSxrQ0FBa0MsR0FBRyxHQUFHLENBQUM7QUFFL0MsMEdBQTBHO0FBQzFHLDJEQUEyRDtBQUMzRCxNQUFNLHdCQUF3QixHQUFHLENBQUMsZUFBZSxFQUFFLHNCQUFzQixFQUFFLGdCQUFnQixFQUFFLHVCQUF1QixDQUFDLENBQUM7QUFFdEgsU0FBUyxpQkFBaUIsQ0FBQyxZQUFpQjtJQUMxQyxJ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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/self-review-context.ts b/packages/loopover-miner/lib/self-review-context.ts new file mode 100644 index 0000000000..81ae73dce2 --- /dev/null +++ b/packages/loopover-miner/lib/self-review-context.ts @@ -0,0 +1,514 @@ +import { + buildCollisionReport, + buildIssueQualityReport, + MAX_FOCUS_MANIFEST_BYTES, + parseFocusManifestContent, +} from "@loopover/engine"; +import type { FocusManifest, SelfReviewContext } from "@loopover/engine"; +import { resolveLoopoverBackendSession } from "./github-token-resolution.js"; + +// `bounties` is always omitted (see this file's own header comment for why), so the result is +// SelfReviewContext minus that optional field rather than the full type. `issueQuality` is populated (#6057). +export type SelfReviewContextResult = Omit; + +// A narrower shape than `typeof fetch` on purpose: this module only ever calls it with a string URL and a +// plain GET init, and the ambient `fetch` type in this repo's TS program is Cloudflare-Workers-flavored +// (RequestInfo | URL), which is both irrelevant here (this package runs under plain Node) and +// stricter than any real caller needs -- same rationale as live-issue-snapshot.js's own LiveIssueSnapshotFetch. +export type SelfReviewContextFetch = ( + url: string, + init?: { method?: string; headers?: Record; signal?: AbortSignal }, +) => Promise<{ + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}>; + +export type LiveGateThresholdFields = { + confidence_floor: number | null; + scope_cap_files: number | null; + scope_cap_lines: number | null; +}; + +export type LoopoverBackendSessionAuth = { + apiUrl?: string; + sessionToken: string; +}; + +export type FetchSelfReviewContextOptions = { + githubToken?: string; + contributorLogin?: string; + linkedIssues?: number[]; + apiBaseUrl?: string; + rawContentBaseUrl?: string; + gittensorApiBase?: string; + fetchImpl?: SelfReviewContextFetch; + perPage?: number; + maxPages?: number; + requestTimeoutMs?: number; + /** Short ORB live-gate-thresholds probe budget (#6487). Default 400ms. */ + liveGateProbeTimeoutMs?: number; + /** Explicit session auth for the ORB probe; `null` forces standalone (skip probe). */ + loopoverAuth?: LoopoverBackendSessionAuth | null; + /** Env used to resolve loopover-mcp session when `loopoverAuth` is omitted. */ + env?: NodeJS.ProcessEnv; +}; + +// Real SelfReviewContext fetcher (#5145, Wave 3.5). Builds the context object the miner's self-review pass +// (packages/loopover-engine/src/miner/self-review-adapter.ts) needs, at the SAME fidelity the live gate's +// own DB-backed construction produces (src/db/repositories.ts's toRepositoryRecord/toIssueRecord/ +// toPullRequestRecord) -- just built fresh from live GitHub data instead of a DB round-trip, since the miner +// has no database. One of SelfReviewContext's eight fields is DELIBERATELY left undefined, not stubbed: +// +// - `bounties`: bounty data is not GitHub-native in this codebase -- it comes from an external "Gitt" +// system that PUSHES data into the live gate's own internal ingest route (src/api/routes.ts). There is +// no public endpoint the miner could legitimately pull from instead. +// +// `issueQuality` is populated via buildIssueQualityReport (exported from @loopover/engine as a package-local +// twin of the host engine helper — see #6057). Bounty rows and recent-merged PR history are passed as empty +// arrays because this fetcher does not yet pull either source. `bounties` remains omitted for the reason above. +// +// #6487: after the static `.loopover.yml` reconstruction, optionally probe ORB's live-gate-thresholds endpoint +// (same loopover-mcp session posture as resolveGitHubToken). On success, overlay confidence_floor / +// scope_cap_files / scope_cap_lines onto the parsed manifest gate; on 403/timeout/404/no-session, keep the +// static reconstruction unchanged. Fully-standalone (ORB-absent) paths stay byte-identical. + +const GITHUB_API_VERSION = "2022-11-28"; +const DEFAULT_API_BASE_URL = "https://api.github.com"; +const DEFAULT_RAW_CONTENT_BASE_URL = "https://raw.githubusercontent.com"; +const DEFAULT_GITTENSOR_API_BASE = "https://api.gittensor.io"; +const DEFAULT_PER_PAGE = 100; +const DEFAULT_MAX_PAGES = 10; +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; +/** Short ORB probe budget (#6487) — must never make discover/gate-prediction meaningfully slower when ORB is absent. */ +const DEFAULT_LIVE_GATE_PROBE_TIMEOUT_MS = 400; + +// Mirrors src/signals/focus-manifest-loader.ts's MANIFEST_FILE_CANDIDATES exactly -- first candidate that +// resolves wins, same as the live gate's own lookup order. +const MANIFEST_FILE_CANDIDATES = [".loopover.yml", ".github/loopover.yml", ".loopover.json", ".github/loopover.json"]; + +function parseRepoFullName(repoFullName: any) { + if (typeof repoFullName !== "string") return null; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) return null; + return { owner, repo }; +} + +function githubHeaders(githubToken: any) { + const headers: Record = { + accept: "application/vnd.github+json", + "user-agent": "loopover-miner", + "x-github-api-version": GITHUB_API_VERSION, + }; + const token = typeof githubToken === "string" ? githubToken.trim() : ""; + if (token) headers.authorization = `Bearer ${token}`; + return headers; +} + +function normalizeOptions(options: any = {}) { + const env = options.env ?? process.env; + // Explicit null skips the probe (tests / forced-standalone). Undefined ⇒ resolve from loopover-mcp session. + const loopoverAuth = + options.loopoverAuth === null + ? null + : options.loopoverAuth && typeof options.loopoverAuth.sessionToken === "string" && options.loopoverAuth.sessionToken + ? { + apiUrl: + typeof options.loopoverAuth.apiUrl === "string" && options.loopoverAuth.apiUrl.trim() + ? options.loopoverAuth.apiUrl.replace(/\/+$/, "") + : (resolveLoopoverBackendSession(env)?.apiUrl ?? "https://api.loopover.ai"), + sessionToken: options.loopoverAuth.sessionToken, + } + : resolveLoopoverBackendSession(env); + return { + githubToken: options.githubToken ?? env.GITHUB_TOKEN ?? "", + apiBaseUrl: typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() ? options.apiBaseUrl.trim() : DEFAULT_API_BASE_URL, + rawContentBaseUrl: + typeof options.rawContentBaseUrl === "string" && options.rawContentBaseUrl.trim() ? options.rawContentBaseUrl.trim() : DEFAULT_RAW_CONTENT_BASE_URL, + gittensorApiBase: + typeof options.gittensorApiBase === "string" && options.gittensorApiBase.trim() ? options.gittensorApiBase.trim() : DEFAULT_GITTENSOR_API_BASE, + fetchImpl: (options.fetchImpl ?? fetch) as SelfReviewContextFetch, + perPage: Number.isInteger(options.perPage) && options.perPage > 0 ? options.perPage : DEFAULT_PER_PAGE, + maxPages: Number.isInteger(options.maxPages) && options.maxPages > 0 ? options.maxPages : DEFAULT_MAX_PAGES, + contributorLogin: typeof options.contributorLogin === "string" ? options.contributorLogin.trim() : "", + linkedIssues: Array.isArray(options.linkedIssues) ? options.linkedIssues.filter((n: any) => Number.isInteger(n)) : [], + requestTimeoutMs: Number.isInteger(options.requestTimeoutMs) && options.requestTimeoutMs > 0 ? options.requestTimeoutMs : DEFAULT_REQUEST_TIMEOUT_MS, + liveGateProbeTimeoutMs: + Number.isInteger(options.liveGateProbeTimeoutMs) && options.liveGateProbeTimeoutMs > 0 + ? options.liveGateProbeTimeoutMs + : DEFAULT_LIVE_GATE_PROBE_TIMEOUT_MS, + loopoverAuth, + }; +} + +/** Validate the field-limited #6486/#6487 payload; null when nothing usable is present. */ +export function parseLiveGateThresholdFields(payload: unknown): LiveGateThresholdFields | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const record = payload as Record; + const confidence_floor = + typeof record.confidence_floor === "number" && record.confidence_floor >= 0 && record.confidence_floor <= 1 + ? record.confidence_floor + : null; + const scope_cap_files = typeof record.scope_cap_files === "number" && record.scope_cap_files > 0 ? record.scope_cap_files : null; + const scope_cap_lines = typeof record.scope_cap_lines === "number" && record.scope_cap_lines > 0 ? record.scope_cap_lines : null; + if (confidence_floor === null && scope_cap_files === null && scope_cap_lines === null) return null; + return { confidence_floor, scope_cap_files, scope_cap_lines }; +} + +/** + * Overlay live ORB thresholds onto a statically-reconstructed FocusManifest (#6487). + * - confidence_floor → raise-only readinessMinScore (mirrors applySelfTuneOverrideToSettings). + * - scope_cap_files / scope_cap_lines → prefer live sizeMaxFiles / sizeMaxLines when present. + * Other gate fields are left untouched. + */ +export function applyLiveGateThresholdsToManifest( + manifest: FocusManifest, + fields: LiveGateThresholdFields | null, +): FocusManifest { + if (!manifest || !fields) return manifest; + const gate = { ...manifest.gate }; + if (typeof fields.confidence_floor === "number") { + const floorScore = Math.max(0, Math.min(100, Math.round(fields.confidence_floor * 100))); + if (typeof gate.readinessMinScore === "number" && floorScore > gate.readinessMinScore) { + gate.readinessMinScore = floorScore; + } + } + if (typeof fields.scope_cap_files === "number" && fields.scope_cap_files > 0) { + gate.sizeMaxFiles = fields.scope_cap_files; + } + if (typeof fields.scope_cap_lines === "number" && fields.scope_cap_lines > 0) { + gate.sizeMaxLines = fields.scope_cap_lines; + } + return { ...manifest, gate }; +} + +async function probeLiveGateThresholds(target: any, resolved: any) { + const auth = resolved.loopoverAuth; + if (!auth?.sessionToken) return null; + const url = `${auth.apiUrl}/v1/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/live-gate-thresholds`; + try { + const response = await fetchWithTimeout( + resolved.fetchImpl, + url, + { + method: "GET", + headers: { + authorization: `Bearer ${auth.sessionToken}`, + accept: "application/json", + "user-agent": "loopover-miner", + }, + }, + resolved.liveGateProbeTimeoutMs, + ); + if (!response.ok) return null; + const payload = await response.json().catch(() => null); + return parseLiveGateThresholdFields(payload); + } catch { + return null; + } +} + +// A fresh AbortSignal.timeout() per call, so a stalled connection can't hang context construction forever +// (#miner-github-read-timeouts) -- shared by this file's three independent fetch call sites (GitHub REST, raw +// manifest content, the Gittensor contributor lookup). +async function fetchWithTimeout(fetchImpl: any, url: any, init: any, timeoutMs: any) { + return fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); +} + +async function githubGetJson(url: any, resolved: any) { + const response = await fetchWithTimeout(resolved.fetchImpl, url, { method: "GET", headers: githubHeaders(resolved.githubToken) }, resolved.requestTimeoutMs); + const payload = await response.json().catch(() => null); + return { response, payload }; +} + +async function fetchPaginated(path: any, query: any, resolved: any) { + const results = []; + for (let page = 1; page <= resolved.maxPages; page += 1) { + const params = new URLSearchParams({ ...query, per_page: String(resolved.perPage), page: String(page) }); + const url = `${resolved.apiBaseUrl}${path}?${params}`; + const { response, payload } = await githubGetJson(url, resolved); + if (!response.ok || !Array.isArray(payload)) break; + results.push(...payload); + if (payload.length < resolved.perPage) break; + } + return results; +} + +// Mirrors src/db/repositories.ts's toRepositoryRecord + upsertRepositoryFromGitHub's field mapping. The +// miner has no App installation/DB, so installationId/isInstalled/isRegistered/registryConfig are honest +// "unregistered" defaults, not values pulled from GitHub -- GitHub's own repo payload carries none of them. +async function fetchRepositoryRecord(target: any, resolved: any) { + const url = `${resolved.apiBaseUrl}/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}`; + const { response, payload } = await githubGetJson(url, resolved); + if (!response.ok || !payload || typeof payload !== "object") return null; + return { + fullName: `${target.owner}/${target.repo}`, + owner: payload.owner?.login ?? target.owner, + name: payload.name ?? target.repo, + installationId: undefined, + isInstalled: false, + isRegistered: false, + isPrivate: payload.private ?? false, + htmlUrl: payload.html_url ?? null, + defaultBranch: payload.default_branch ?? null, + registryConfig: null, + }; +} + +// Mirrors src/db/repositories.ts's extractLinkedPrNumbers: a real link needs a CLOSING KEYWORD, not a bare +// mention (#6769). Without the keyword prefix, an incidental "similar to what we saw in PR #501" in an issue +// body counted as a linked PR, so the issue-quality report read the issue as "already references a PR" and the +// miner skipped an available issue (the host's own #issue-body-pr-mention-pollution fix, never ported here). +const LINKED_PR_PATTERN = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:PR|pull request)\s+#(\d+)\b/gi; +function extractLinkedPrNumbers(body: any) { + const numbers = []; + for (const match of body.matchAll(LINKED_PR_PATTERN)) { + const number = Number(match[1]); + if (Number.isInteger(number) && number > 0) numbers.push(number); + } + return numbers; +} + +// Mirrors src/db/repositories.ts's extractLinkedIssueNumbers: GitHub's own closing-keyword vocabulary, only +// counting a fully-qualified owner/repo#N reference when it targets the SAME repo being fetched. +const LINKED_ISSUE_PATTERN = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:([\w.-]+\/[\w.-]+)#|#)(\d+)\b/gi; +function extractLinkedIssueNumbers(body: any, repoFullName: any) { + // Strip backtick code spans first so a closing-keyword pattern quoted as example code doesn't count. + const withoutCodeSpans = body.replace(/`[^`]*`/g, ""); + const numbers = []; + const normalizedRepo = repoFullName.toLowerCase(); + for (const match of withoutCodeSpans.matchAll(LINKED_ISSUE_PATTERN)) { + const qualifiedRepo = match[1]; + if (qualifiedRepo !== undefined && qualifiedRepo.toLowerCase() !== normalizedRepo) continue; + const number = Number(match[2]); + if (Number.isInteger(number) && number > 0) numbers.push(number); + } + return numbers; +} + +function labelNames(labels: any) { + if (!Array.isArray(labels)) return []; + return labels.flatMap((label) => (label && typeof label === "object" && typeof label.name === "string" ? [label.name] : [])); +} + +// Mirrors src/db/repositories.ts's toIssueRecord, populated straight from the live payload (createdAt/ +// updatedAt/closedAt come from the DB-row read path there only as a caching artifact, not a semantic +// transform -- the live REST fields are the real source). +function toIssueRecord(repoFullName: any, issue: any) { + const body = issue.body ?? ""; + return { + repoFullName, + number: issue.number, + title: issue.title, + state: issue.state, + authorLogin: issue.user?.login ?? null, + authorAssociation: issue.author_association ?? null, + htmlUrl: issue.html_url ?? null, + body, + createdAt: issue.created_at ?? null, + updatedAt: issue.updated_at ?? null, + closedAt: issue.closed_at ?? null, + labels: labelNames(issue.labels), + linkedPrs: extractLinkedPrNumbers(body), + }; +} + +async function fetchOpenIssueRecords(target: any, resolved: any) { + const payloads = await fetchPaginated( + `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/issues`, + { state: "open", sort: "created", direction: "asc" }, + resolved, + ); + // GitHub's Issues endpoint also returns pull requests -- filter them out, same as the live gate's own fetch. + return payloads.filter((issue) => issue && typeof issue === "object" && !issue.pull_request).map((issue) => toIssueRecord(`${target.owner}/${target.repo}`, issue)); +} + +function mergeableBooleanState(mergeable: any) { + if (mergeable === true) return "clean"; + if (mergeable === false) return "dirty"; + return null; +} + +// Mirrors src/db/repositories.ts's toPullRequestRecord. Only the fields SelfReviewContext/buildCollisionReport +// actually consume are populated with real precision; merge/RC3 gate-plumbing fields the live gate's fuller +// PullRequestRecord carries (mergeAttemptCount, approvedHeadSha, ...) don't exist on the engine package's +// leaner mirror type and aren't meaningful for a miner attempt anyway. +function toPullRequestRecord(repoFullName: any, pr: any) { + const body = pr.body ?? ""; + return { + repoFullName, + number: pr.number, + title: pr.title, + state: pr.state, + authorLogin: pr.user?.login ?? null, + authorAssociation: pr.author_association ?? null, + headSha: pr.head?.sha ?? null, + headRef: pr.head?.ref ?? null, + baseRef: pr.base?.ref ?? null, + htmlUrl: pr.html_url ?? null, + mergedAt: pr.merged_at ?? null, + isDraft: pr.draft ?? null, + mergeableState: pr.mergeable_state ?? mergeableBooleanState(pr.mergeable), + reviewDecision: null, + body, + createdAt: pr.created_at ?? null, + updatedAt: pr.updated_at ?? null, + closedAt: pr.closed_at ?? null, + labels: labelNames(pr.labels), + linkedIssues: extractLinkedIssueNumbers(body, repoFullName), + }; +} + +async function fetchOpenPullRequestRecords(target: any, resolved: any) { + const payloads = await fetchPaginated( + `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/pulls`, + { state: "open", sort: "created", direction: "asc" }, + resolved, + ); + return payloads.map((pr) => toPullRequestRecord(`${target.owner}/${target.repo}`, pr)); +} + +// Mirrors src/signals/focus-manifest-loader.ts's raw-content lookup order and bounded body read: +// first candidate path that resolves wins, but hostile manifests never exceed the parser byte cap in memory. +async function readBoundedManifestResponseText(response: any) { + const contentLength = response.headers?.get?.("content-length") ?? null; + if (contentLength !== null) { + const parsedLength = Number.parseInt(contentLength, 10); + if (Number.isFinite(parsedLength) && parsedLength > MAX_FOCUS_MANIFEST_BYTES) return null; + } + if (!response.body?.getReader) { + const text = await response.text(); + if (typeof text !== "string") return null; + return new TextEncoder().encode(text).byteLength > MAX_FOCUS_MANIFEST_BYTES ? null : text; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let totalBytes = 0; + let text = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_FOCUS_MANIFEST_BYTES) { + await reader.cancel(); + return null; + } + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + return text; + } finally { + reader.releaseLock(); + } +} + +async function fetchManifestContent(target: any, resolved: any) { + for (const path of MANIFEST_FILE_CANDIDATES) { + const url = `${resolved.rawContentBaseUrl}/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/HEAD/${path}`; + try { + const response = await fetchWithTimeout(resolved.fetchImpl, url, { method: "GET", headers: { accept: "application/json", "user-agent": "loopover-miner" } }, resolved.requestTimeoutMs); + if (response.ok) { + const text = await readBoundedManifestResponseText(response); + if (typeof text === "string") return text; + } + } catch { + // Try the next candidate path. + } + } + return null; +} + +// Mirrors src/gittensor/api.ts's fetchGittensorContributorSnapshot/fetchOfficialGittensorMiner: a public, +// unauthenticated GET against the Gittensor API (not GitHub) -- confirmed only when a real entry with a +// matching GitHub login is found; any transport/parse failure fails closed to "not confirmed", never throws. +async function fetchConfirmedContributor(login: any, resolved: any) { + if (!login) return false; + try { + const response = await fetchWithTimeout(resolved.fetchImpl, `${resolved.gittensorApiBase}/miners`, { method: "GET", headers: { accept: "application/json" } }, resolved.requestTimeoutMs); + if (!response.ok) return false; + const payload = await response.json().catch(() => null); + if (!Array.isArray(payload)) return false; + const normalizedLogin = login.toLowerCase(); + return payload.some((miner) => typeof miner?.githubUsername === "string" && miner.githubUsername.toLowerCase() === normalizedLogin); + } catch { + return false; + } +} + +// Per self-review-adapter.ts's own doc comment: the caller computes inDuplicateCluster "the same way the +// live gate's collision report would" -- adapted from src/signals/engine.ts's real +// isPullRequestInDuplicateCluster (root src/, not extracted to the engine package), which requires >= 2 +// PULL REQUEST items in a high-risk cluster, not just any high-risk cluster containing the target. That +// threshold matters: buildCollisionReport's own pairwise "shared linked issue" rule already marks an +// issue+its-one-legitimately-closing-PR pair as a HIGH-risk cluster (confirmed empirically) -- without the +// >= 2 threshold, inDuplicateCluster would fire on the completely normal case of "one PR already closes +// this issue," not genuine overlapping/duplicate work. Checks the target ISSUE's presence instead of a +// not-yet-existing PR number, since the miner's own submission doesn't exist as a real PullRequestRecord yet. +// Takes a prebuilt CollisionReport so issueQuality and inDuplicateCluster share one collision pass. +function computeInDuplicateCluster(collisionReport: any, targetIssueNumbers: any) { + if (targetIssueNumbers.length === 0) return false; + return collisionReport.clusters.some( + (cluster: any) => + cluster.risk === "high" && + cluster.items.filter((item: any) => item.type === "pull_request").length >= 2 && + cluster.items.some((item: any) => item.type === "issue" && targetIssueNumbers.includes(item.number)), + ); +} + +/** + * Build a real SelfReviewContext from live GitHub data, at the same fidelity the live gate's own DB-backed + * construction produces. See this file's header for the one field (bounties) deliberately left undefined + * and why; issueQuality is populated from the live GitHub snapshot. Optionally overlays ORB live gate + * thresholds onto the static `.loopover.yml` reconstruction (#6487). + * + * @param {string} repoFullName + * @param {{ + * githubToken?: string, contributorLogin?: string, linkedIssues?: number[], + * apiBaseUrl?: string, rawContentBaseUrl?: string, gittensorApiBase?: string, + * fetchImpl?: typeof fetch, perPage?: number, maxPages?: number, requestTimeoutMs?: number, + * liveGateProbeTimeoutMs?: number, + * loopoverAuth?: { apiUrl?: string, sessionToken: string } | null, + * env?: NodeJS.ProcessEnv, + * }} [options] + * @returns {Promise} + */ +export async function fetchSelfReviewContext( + repoFullName: string, + options: FetchSelfReviewContextOptions = {}, +): Promise { + const target = parseRepoFullName(repoFullName); + if (!target) throw new Error("invalid_repo_full_name"); + const resolved = normalizeOptions(options); + + const [repo, issues, pullRequests, manifestContent, confirmedContributor, liveGateThresholds] = await Promise.all([ + fetchRepositoryRecord(target, resolved), + fetchOpenIssueRecords(target, resolved), + fetchOpenPullRequestRecords(target, resolved), + fetchManifestContent(target, resolved), + fetchConfirmedContributor(resolved.contributorLogin, resolved), + probeLiveGateThresholds(target, resolved), + ]); + + const staticManifest = parseFocusManifestContent(manifestContent, "repo_file"); + const manifest = applyLiveGateThresholdsToManifest(staticManifest, liveGateThresholds); + // Positional args match buildIssueQualityReport(repo, issues, pullRequests, fullName, bounties, collisions, recentMerged): + // repo is the full RepositoryRecord from fetchRepositoryRecord (not a string); empty bounties/recentMerged + // because this fetcher has no external bounty source and does not yet pull merge history. + const fullName = `${target.owner}/${target.repo}`; + const collisions = buildCollisionReport(fullName, issues, pullRequests); + const inDuplicateCluster = computeInDuplicateCluster(collisions, resolved.linkedIssues); + const issueQuality = buildIssueQualityReport(repo, issues, pullRequests, fullName, [], collisions, []); + + return { + manifest, + repo, + issues, + pullRequests, + confirmedContributor, + inDuplicateCluster, + issueQuality, + } as SelfReviewContextResult; +} diff --git a/packages/loopover-miner/lib/stack-detection.d.ts b/packages/loopover-miner/lib/stack-detection.d.ts index e69b216fae..4ee25696d9 100644 --- a/packages/loopover-miner/lib/stack-detection.d.ts +++ b/packages/loopover-miner/lib/stack-detection.d.ts @@ -1,41 +1,37 @@ -/** Stack auto-detection (#4785). `detectRepoStack` inspects an already-cloned repo's manifest / lockfile / config - * files and returns a structured stack description, or an explicit fail-closed result when the stack can't be - * confidently identified (no guessing). */ - /** Which manifest (and lockfile, when present) drove the detection. */ export type StackEvidence = { - manifest: string; - lockfile: string | null; + manifest: string; + lockfile: string | null; }; - /** A confidently-detected stack. Command fields are `null` when the command can't be inferred without guessing. */ export type DetectedRepoStack = { - detected: true; - language: string; - packageManager: string | null; - buildCommand: string | null; - testCommand: string | null; - lintCommand: string | null; - formatCommand: string | null; - evidence: StackEvidence; + detected: true; + language: string; + packageManager: string | null; + buildCommand: string | null; + testCommand: string | null; + lintCommand: string | null; + formatCommand: string | null; + evidence: StackEvidence; }; - /** A repo whose stack could not be confidently identified. */ export type UndetectedRepoStack = { - detected: false; - reason: string; + detected: false; + reason: string; }; - export type RepoStackResult = DetectedRepoStack | UndetectedRepoStack; - export type DetectRepoStackOptions = { - existsSync?: (path: string) => boolean; - readFileSync?: (path: string, encoding: "utf8") => string; + existsSync?: (path: string) => boolean; + readFileSync?: (path: string, encoding: "utf8") => string; }; - -/** Manifests, in the precedence order detection tries them (first match wins). */ -export const RECOGNIZED_MANIFESTS: readonly string[]; - -export function detectRepoStack(repoPath: string, options?: DetectRepoStackOptions): RepoStackResult; - -export function renderStackSummary(stack: RepoStackResult): string; +/** Manifests, in the precedence order detection tries them; the first matching primary manifest wins. A caller with + * a known polyglot repo can inspect `evidence.manifest` to see which one was chosen. */ +export declare const RECOGNIZED_MANIFESTS: readonly string[]; +/** + * Detect the stack of an already-cloned repository at `repoPath`. Returns `{ detected: true, ... }` with the + * language, package manager, and any confidently-inferred commands, or `{ detected: false, reason }` when no + * recognized manifest is present. Never throws. + */ +export declare function detectRepoStack(repoPath: string, options?: DetectRepoStackOptions): RepoStackResult; +/** One-line human summary of a detection result, suitable for a coding-agent prompt or an operator log. */ +export declare function renderStackSummary(stack: RepoStackResult): string; diff --git a/packages/loopover-miner/lib/stack-detection.js b/packages/loopover-miner/lib/stack-detection.js index ab2830afad..b22ed3baf4 100644 --- a/packages/loopover-miner/lib/stack-detection.js +++ b/packages/loopover-miner/lib/stack-detection.js @@ -7,242 +7,234 @@ * can't be confidently identified returns an explicit `{ detected: false, reason }` instead of guessing. */ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; - /** Manifests, in the precedence order detection tries them; the first matching primary manifest wins. A caller with * a known polyglot repo can inspect `evidence.manifest` to see which one was chosen. */ export const RECOGNIZED_MANIFESTS = Object.freeze([ - "package.json", - "pyproject.toml", - "setup.py", - "setup.cfg", - "requirements.txt", - "Pipfile", - "Cargo.toml", - "go.mod", - "pom.xml", - "build.gradle", - "build.gradle.kts", + "package.json", + "pyproject.toml", + "setup.py", + "setup.cfg", + "requirements.txt", + "Pipfile", + "Cargo.toml", + "go.mod", + "pom.xml", + "build.gradle", + "build.gradle.kts", ]); - -const NO_MANIFEST_REASON = - "No recognized dependency manifest (package.json, pyproject.toml, Cargo.toml, go.mod, pom.xml, or build.gradle) was found at the repository root."; - +const NO_MANIFEST_REASON = "No recognized dependency manifest (package.json, pyproject.toml, Cargo.toml, go.mod, pom.xml, or build.gradle) was found at the repository root."; const NODE_PACKAGE_MANAGERS = Object.freeze(["npm", "yarn", "pnpm", "bun"]); const NODE_LOCKFILES = Object.freeze([ - ["pnpm-lock.yaml", "pnpm"], - ["yarn.lock", "yarn"], - ["bun.lockb", "bun"], - ["package-lock.json", "npm"], + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["bun.lockb", "bun"], + ["package-lock.json", "npm"], ]); - /** Build a never-throwing accessor over the cloned repo. `exists` and `read` both swallow fs errors so the detector * treats an EACCES/ENOENT/binary file as simply "absent" instead of crashing the attempt. */ function makeAccess(repoPath, options) { - const existsImpl = options.existsSync ?? existsSync; - const readImpl = options.readFileSync ?? readFileSync; - const exists = (relativePath) => { + const existsImpl = options.existsSync ?? existsSync; + const readImpl = options.readFileSync ?? readFileSync; + const exists = (relativePath) => { + try { + return existsImpl(join(repoPath, relativePath)) === true; + } + catch { + return false; + } + }; + const read = (relativePath) => { + try { + if (!exists(relativePath)) + return null; + const content = readImpl(join(repoPath, relativePath), "utf8"); + return typeof content === "string" ? content : null; + } + catch { + return null; + } + }; + return { exists, read }; +} +function parseJson(text) { + if (typeof text !== "string") + return null; try { - return existsImpl(join(repoPath, relativePath)) === true; - } catch { - return false; + const parsed = JSON.parse(text); + return parsed && typeof parsed === "object" ? parsed : null; } - }; - const read = (relativePath) => { - try { - if (!exists(relativePath)) return null; - const content = readImpl(join(repoPath, relativePath), "utf8"); - return typeof content === "string" ? content : null; - } catch { - return null; + catch { + return null; } - }; - return { exists, read }; } - -function parseJson(text) { - if (typeof text !== "string") return null; - try { - const parsed = JSON.parse(text); - return parsed && typeof parsed === "object" ? parsed : null; - } catch { - return null; - } -} - /** Pick a package.json script by exact name first, then by pattern, considering only string-valued scripts. */ function pickScript(scripts, exactName, pattern) { - const names = Object.keys(scripts).filter((name) => typeof scripts[name] === "string"); - if (names.includes(exactName)) return exactName; - return names.find((name) => pattern.test(name)) ?? null; + const names = Object.keys(scripts).filter((name) => typeof scripts[name] === "string"); + if (names.includes(exactName)) + return exactName; + return names.find((name) => pattern.test(name)) ?? null; } - function nodeLockfile(exists) { - const match = NODE_LOCKFILES.find(([file]) => exists(file)); - return match ? match[0] : null; + const match = NODE_LOCKFILES.find(([file]) => exists(file)); + return match ? match[0] : null; } - function nodePackageManager(pkg, lockfile) { - const corepack = - typeof pkg?.packageManager === "string" ? pkg.packageManager.split("@")[0].trim().toLowerCase() : ""; - if (NODE_PACKAGE_MANAGERS.includes(corepack)) return corepack; - const byLock = NODE_LOCKFILES.find(([file]) => file === lockfile); - // A package.json with no lockfile is still a Node project; npm is its default runner (a default, not a guess). - return byLock ? byLock[1] : "npm"; + const corepack = typeof pkg?.packageManager === "string" ? pkg.packageManager.split("@")[0].trim().toLowerCase() : ""; + if (NODE_PACKAGE_MANAGERS.includes(corepack)) + return corepack; + const byLock = NODE_LOCKFILES.find(([file]) => file === lockfile); + // A package.json with no lockfile is still a Node project; npm is its default runner (a default, not a guess). + return byLock ? byLock[1] : "npm"; } - function hasTypescriptDependency(pkg) { - const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) }; - return typeof deps.typescript === "string"; + const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) }; + return typeof deps.typescript === "string"; } - function detectNode({ exists, read }) { - if (!exists("package.json")) return null; - const pkg = parseJson(read("package.json")); - const scripts = - pkg && typeof pkg.scripts === "object" && pkg.scripts && !Array.isArray(pkg.scripts) ? pkg.scripts : {}; - const language = exists("tsconfig.json") || hasTypescriptDependency(pkg) ? "typescript" : "javascript"; - const lockfile = nodeLockfile(exists); - const packageManager = nodePackageManager(pkg, lockfile); - - const buildName = pickScript(scripts, "build", /^(build|compile|bundle)(:|$)/i); - const testName = pickScript(scripts, "test", /(^|:)test(:|$)/i); - const lintName = pickScript(scripts, "lint", /(^|:)lint(:|$)/i); - const formatName = pickScript(scripts, "format", /(^|:)(format|fmt)(:|$)/i); - - return { - language, - packageManager, - buildCommand: buildName ? `${packageManager} run ${buildName}` : null, - // ` test` is the built-in test lifecycle across npm/yarn/pnpm/bun; a non-"test" script uses `run`. - testCommand: testName ? (testName === "test" ? `${packageManager} test` : `${packageManager} run ${testName}`) : null, - lintCommand: lintName ? `${packageManager} run ${lintName}` : null, - formatCommand: formatName ? `${packageManager} run ${formatName}` : null, - evidence: { manifest: "package.json", lockfile }, - }; + if (!exists("package.json")) + return null; + const pkg = parseJson(read("package.json")); + const scripts = pkg && typeof pkg.scripts === "object" && pkg.scripts && !Array.isArray(pkg.scripts) ? pkg.scripts : {}; + const language = exists("tsconfig.json") || hasTypescriptDependency(pkg) ? "typescript" : "javascript"; + const lockfile = nodeLockfile(exists); + const packageManager = nodePackageManager(pkg, lockfile); + const buildName = pickScript(scripts, "build", /^(build|compile|bundle)(:|$)/i); + const testName = pickScript(scripts, "test", /(^|:)test(:|$)/i); + const lintName = pickScript(scripts, "lint", /(^|:)lint(:|$)/i); + const formatName = pickScript(scripts, "format", /(^|:)(format|fmt)(:|$)/i); + return { + language, + packageManager, + buildCommand: buildName ? `${packageManager} run ${buildName}` : null, + // ` test` is the built-in test lifecycle across npm/yarn/pnpm/bun; a non-"test" script uses `run`. + testCommand: testName ? (testName === "test" ? `${packageManager} test` : `${packageManager} run ${testName}`) : null, + lintCommand: lintName ? `${packageManager} run ${lintName}` : null, + formatCommand: formatName ? `${packageManager} run ${formatName}` : null, + evidence: { manifest: "package.json", lockfile }, + }; } - function detectPython({ exists, read }) { - const manifest = ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"].find(exists); - if (manifest === undefined) return null; - const pyproject = read("pyproject.toml") ?? ""; - - let packageManager; - let lockfile = null; - if (exists("poetry.lock") || /\[tool\.poetry\]/.test(pyproject)) { - packageManager = "poetry"; - lockfile = exists("poetry.lock") ? "poetry.lock" : null; - } else if (exists("uv.lock")) { - packageManager = "uv"; - lockfile = "uv.lock"; - } else if (exists("Pipfile") || exists("Pipfile.lock")) { - packageManager = "pipenv"; - lockfile = exists("Pipfile.lock") ? "Pipfile.lock" : null; - } else { - packageManager = "pip"; - } - - // Commands are inferred only from real config so an undeclared tool is never guessed (acceptance: fail safe). - const hasRuff = exists("ruff.toml") || exists(".ruff.toml") || /\[tool\.ruff\]/.test(pyproject); - const hasPytest = exists("pytest.ini") || exists("tox.ini") || /\[tool\.pytest\b/.test(pyproject); - - return { - language: "python", - packageManager, - buildCommand: /\[build-system\]/.test(pyproject) ? (packageManager === "poetry" ? "poetry build" : "python -m build") : null, - testCommand: hasPytest ? "pytest" : null, - lintCommand: hasRuff ? "ruff check ." : null, - formatCommand: hasRuff ? "ruff format ." : null, - evidence: { manifest, lockfile }, - }; + const manifest = ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"].find(exists); + if (manifest === undefined) + return null; + const pyproject = read("pyproject.toml") ?? ""; + let packageManager; + let lockfile = null; + if (exists("poetry.lock") || /\[tool\.poetry\]/.test(pyproject)) { + packageManager = "poetry"; + lockfile = exists("poetry.lock") ? "poetry.lock" : null; + } + else if (exists("uv.lock")) { + packageManager = "uv"; + lockfile = "uv.lock"; + } + else if (exists("Pipfile") || exists("Pipfile.lock")) { + packageManager = "pipenv"; + lockfile = exists("Pipfile.lock") ? "Pipfile.lock" : null; + } + else { + packageManager = "pip"; + } + // Commands are inferred only from real config so an undeclared tool is never guessed (acceptance: fail safe). + const hasRuff = exists("ruff.toml") || exists(".ruff.toml") || /\[tool\.ruff\]/.test(pyproject); + const hasPytest = exists("pytest.ini") || exists("tox.ini") || /\[tool\.pytest\b/.test(pyproject); + return { + language: "python", + packageManager, + buildCommand: /\[build-system\]/.test(pyproject) ? (packageManager === "poetry" ? "poetry build" : "python -m build") : null, + testCommand: hasPytest ? "pytest" : null, + lintCommand: hasRuff ? "ruff check ." : null, + formatCommand: hasRuff ? "ruff format ." : null, + evidence: { manifest, lockfile }, + }; } - function detectRust({ exists }) { - if (!exists("Cargo.toml")) return null; - return { - language: "rust", - packageManager: "cargo", - buildCommand: "cargo build", - testCommand: "cargo test", - lintCommand: "cargo clippy", - formatCommand: "cargo fmt", - evidence: { manifest: "Cargo.toml", lockfile: exists("Cargo.lock") ? "Cargo.lock" : null }, - }; + if (!exists("Cargo.toml")) + return null; + return { + language: "rust", + packageManager: "cargo", + buildCommand: "cargo build", + testCommand: "cargo test", + lintCommand: "cargo clippy", + formatCommand: "cargo fmt", + evidence: { manifest: "Cargo.toml", lockfile: exists("Cargo.lock") ? "Cargo.lock" : null }, + }; } - function detectGo({ exists }) { - if (!exists("go.mod")) return null; - const hasGolangci = exists(".golangci.yml") || exists(".golangci.yaml") || exists(".golangci.toml"); - return { - language: "go", - packageManager: "go", - buildCommand: "go build ./...", - testCommand: "go test ./...", - lintCommand: hasGolangci ? "golangci-lint run" : "go vet ./...", - formatCommand: "gofmt -l .", - evidence: { manifest: "go.mod", lockfile: exists("go.sum") ? "go.sum" : null }, - }; + if (!exists("go.mod")) + return null; + const hasGolangci = exists(".golangci.yml") || exists(".golangci.yaml") || exists(".golangci.toml"); + return { + language: "go", + packageManager: "go", + buildCommand: "go build ./...", + testCommand: "go test ./...", + lintCommand: hasGolangci ? "golangci-lint run" : "go vet ./...", + formatCommand: "gofmt -l .", + evidence: { manifest: "go.mod", lockfile: exists("go.sum") ? "go.sum" : null }, + }; } - function detectMaven({ exists }) { - if (!exists("pom.xml")) return null; - return { - language: "java", - packageManager: "maven", - buildCommand: "mvn -B package", - testCommand: "mvn -B test", - lintCommand: null, - formatCommand: null, - evidence: { manifest: "pom.xml", lockfile: null }, - }; + if (!exists("pom.xml")) + return null; + return { + language: "java", + packageManager: "maven", + buildCommand: "mvn -B package", + testCommand: "mvn -B test", + lintCommand: null, + formatCommand: null, + evidence: { manifest: "pom.xml", lockfile: null }, + }; } - function detectGradle({ exists }) { - const manifest = exists("build.gradle") ? "build.gradle" : exists("build.gradle.kts") ? "build.gradle.kts" : null; - if (manifest === null) return null; - const runner = exists("gradlew") ? "./gradlew" : "gradle"; - return { - language: "java", - packageManager: "gradle", - buildCommand: `${runner} build`, - testCommand: `${runner} test`, - lintCommand: null, - formatCommand: null, - evidence: { manifest, lockfile: null }, - }; + const manifest = exists("build.gradle") ? "build.gradle" : exists("build.gradle.kts") ? "build.gradle.kts" : null; + if (manifest === null) + return null; + const runner = exists("gradlew") ? "./gradlew" : "gradle"; + return { + language: "java", + packageManager: "gradle", + buildCommand: `${runner} build`, + testCommand: `${runner} test`, + lintCommand: null, + formatCommand: null, + evidence: { manifest, lockfile: null }, + }; } - const DETECTORS = Object.freeze([detectNode, detectPython, detectRust, detectGo, detectMaven, detectGradle]); - /** * Detect the stack of an already-cloned repository at `repoPath`. Returns `{ detected: true, ... }` with the * language, package manager, and any confidently-inferred commands, or `{ detected: false, reason }` when no * recognized manifest is present. Never throws. */ export function detectRepoStack(repoPath, options = {}) { - if (typeof repoPath !== "string" || !repoPath.trim()) { - return { detected: false, reason: "A repository path is required to detect the stack." }; - } - const access = makeAccess(repoPath, options); - for (const detector of DETECTORS) { - const detected = detector(access); - if (detected !== null) { - return { detected: true, ...detected }; + if (typeof repoPath !== "string" || !repoPath.trim()) { + return { detected: false, reason: "A repository path is required to detect the stack." }; } - } - return { detected: false, reason: NO_MANIFEST_REASON }; + const access = makeAccess(repoPath, options); + for (const detector of DETECTORS) { + const detected = detector(access); + if (detected !== null) { + // Detectors may leave lockfile undefined; DetectedRepoStack wants string | null (pre-existing shape). + return { detected: true, ...detected, evidence: { ...detected.evidence, lockfile: detected.evidence.lockfile ?? null } }; + } + } + return { detected: false, reason: NO_MANIFEST_REASON }; } - /** One-line human summary of a detection result, suitable for a coding-agent prompt or an operator log. */ export function renderStackSummary(stack) { - if (!stack || stack.detected !== true) { - return `stack not detected: ${stack?.reason ?? "unknown reason"}`; - } - const commands = [ - stack.buildCommand ? `build=\`${stack.buildCommand}\`` : null, - stack.testCommand ? `test=\`${stack.testCommand}\`` : null, - stack.lintCommand ? `lint=\`${stack.lintCommand}\`` : null, - stack.formatCommand ? `format=\`${stack.formatCommand}\`` : null, - ].filter((entry) => entry !== null); - const suffix = commands.length > 0 ? ` (${commands.join(", ")})` : " (no validation commands detected)"; - return `${stack.language} via ${stack.packageManager ?? "unknown"}${suffix}`; + if (!stack || stack.detected !== true) { + return `stack not detected: ${stack?.reason ?? "unknown reason"}`; + } + const commands = [ + stack.buildCommand ? `build=\`${stack.buildCommand}\`` : null, + stack.testCommand ? `test=\`${stack.testCommand}\`` : null, + stack.lintCommand ? `lint=\`${stack.lintCommand}\`` : null, + stack.formatCommand ? `format=\`${stack.formatCommand}\`` : null, + ].filter((entry) => entry !== null); + const suffix = commands.length > 0 ? ` (${commands.join(", ")})` : " (no validation commands detected)"; + return `${stack.language} via ${stack.packageManager ?? "unknown"}${suffix}`; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic3RhY2stZGV0ZWN0aW9uLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsic3RhY2stZGV0ZWN0aW9uLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7Ozs7NEdBTTRHO0FBQzVHLE9BQU8sRUFBRSxVQUFVLEVBQUUsWUFBWSxFQUFFLE1BQU0sU0FBUyxDQUFDO0FBQ25ELE9BQU8sRUFBRSxJQUFJLEVBQUUsTUFBTSxXQUFXLENBQUM7QUFpQ2pDO3dGQUN3RjtBQUN4RixNQUFNLENBQUMsTUFBTSxvQkFBb0IsR0FBRyxNQUFNLENBQUMsTUFBTSxDQUFDO0lBQ2hELGNBQWM7SUFDZCxnQkFBZ0I7SUFDaEIsVUFBVTtJQUNWLFdBQVc7SUFDWCxrQkFBa0I7SUFDbEIsU0FBUztJQUNULFlBQVk7SUFDWixRQUFRO0lBQ1IsU0FBUztJQUNULGNBQWM7SUFDZCxrQkFBa0I7Q0FDbkIsQ0FBQyxDQUFDO0FBRUgsTUFBTSxrQkFBa0IsR0FDdEIsa0pBQWtKLENBQUM7QUFFckosTUFBTSxxQkFBcUIsR0FBRyxNQUFNLENBQUMsTUFBTSxDQUFDLENBQUMsS0FBSyxFQUFFLE1BQU0sRUFBRSxNQUFNLEVBQUUsS0FBSyxDQUFDLENBQUMsQ0FBQztBQUM1RSxNQUFNLGNBQWMsR0FBRyxNQUFNLENBQUMsTUFBTSxDQUFDO0lBQ25DLENBQUMsZ0JBQWdCLEVBQUUsTUFBTSxDQUFDO0lBQzFCLENBQUMsV0FBVyxFQUFFLE1BQU0sQ0FBQztJQUNyQixDQUFDLFdBQVcsRUFBRSxLQUFLLENBQUM7SUFDcEIsQ0FBQyxtQkFBbUIsRUFBRSxLQUFLLENBQUM7Q0FDN0IsQ0FBQyxDQUFDO0FBRUg7NkZBQzZGO0FBQzdGLFNBQVMsVUFBVSxDQUFDLFFBQWEsRUFBRSxPQUFZO0lBQzdDLE1BQU0sVUFBVSxHQUFHLE9BQU8sQ0FBQyxVQUFVLElBQUksVUFBVSxDQUFDO0lBQ3BELE1BQU0sUUFBUSxHQUFHLE9BQU8sQ0FBQyxZQUFZLElBQUksWUFBWSxDQUFDO0lBQ3RELE1B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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/stack-detection.ts b/packages/loopover-miner/lib/stack-detection.ts new file mode 100644 index 0000000000..f749f1c82c --- /dev/null +++ b/packages/loopover-miner/lib/stack-detection.ts @@ -0,0 +1,280 @@ +/** Stack auto-detection (#4785): inspect an already-cloned target repo's manifest / lockfile / config files and + * infer a structured description of its stack — language, package manager, and the build / test / lint / format + * commands — before any code-generation step runs. Like `miner-goal-spec.js` this reads the ALREADY-CLONED repo on + * disk (attempt-worktree.js's prepareAttemptWorktree runs first), so the injected `existsSync` / `readFileSync` + * always receive the FULL joined path, mirroring node:fs. It is pure and NEVER throws: an unreadable/unparseable + * file degrades to "no evidence" rather than crashing, and — per the acceptance criteria — a repo whose stack + * can't be confidently identified returns an explicit `{ detected: false, reason }` instead of guessing. */ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +/** Which manifest (and lockfile, when present) drove the detection. */ +export type StackEvidence = { + manifest: string; + lockfile: string | null; +}; + +/** A confidently-detected stack. Command fields are `null` when the command can't be inferred without guessing. */ +export type DetectedRepoStack = { + detected: true; + language: string; + packageManager: string | null; + buildCommand: string | null; + testCommand: string | null; + lintCommand: string | null; + formatCommand: string | null; + evidence: StackEvidence; +}; + +/** A repo whose stack could not be confidently identified. */ +export type UndetectedRepoStack = { + detected: false; + reason: string; +}; + +export type RepoStackResult = DetectedRepoStack | UndetectedRepoStack; + +export type DetectRepoStackOptions = { + existsSync?: (path: string) => boolean; + readFileSync?: (path: string, encoding: "utf8") => string; +}; + +/** Manifests, in the precedence order detection tries them; the first matching primary manifest wins. A caller with + * a known polyglot repo can inspect `evidence.manifest` to see which one was chosen. */ +export const RECOGNIZED_MANIFESTS = Object.freeze([ + "package.json", + "pyproject.toml", + "setup.py", + "setup.cfg", + "requirements.txt", + "Pipfile", + "Cargo.toml", + "go.mod", + "pom.xml", + "build.gradle", + "build.gradle.kts", +]); + +const NO_MANIFEST_REASON = + "No recognized dependency manifest (package.json, pyproject.toml, Cargo.toml, go.mod, pom.xml, or build.gradle) was found at the repository root."; + +const NODE_PACKAGE_MANAGERS = Object.freeze(["npm", "yarn", "pnpm", "bun"]); +const NODE_LOCKFILES = Object.freeze([ + ["pnpm-lock.yaml", "pnpm"], + ["yarn.lock", "yarn"], + ["bun.lockb", "bun"], + ["package-lock.json", "npm"], +]); + +/** Build a never-throwing accessor over the cloned repo. `exists` and `read` both swallow fs errors so the detector + * treats an EACCES/ENOENT/binary file as simply "absent" instead of crashing the attempt. */ +function makeAccess(repoPath: any, options: any) { + const existsImpl = options.existsSync ?? existsSync; + const readImpl = options.readFileSync ?? readFileSync; + const exists = (relativePath: string) => { + try { + return existsImpl(join(repoPath, relativePath)) === true; + } catch { + return false; + } + }; + const read = (relativePath: string) => { + try { + if (!exists(relativePath)) return null; + const content = readImpl(join(repoPath, relativePath), "utf8"); + return typeof content === "string" ? content : null; + } catch { + return null; + } + }; + return { exists, read }; +} + +function parseJson(text: any) { + if (typeof text !== "string") return null; + try { + const parsed = JSON.parse(text); + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +/** Pick a package.json script by exact name first, then by pattern, considering only string-valued scripts. */ +function pickScript(scripts: any, exactName: any, pattern: any) { + const names = Object.keys(scripts).filter((name) => typeof scripts[name] === "string"); + if (names.includes(exactName)) return exactName; + return names.find((name) => pattern.test(name)) ?? null; +} + +function nodeLockfile(exists: any) { + const match = NODE_LOCKFILES.find(([file]) => exists(file)); + return match ? match[0] : null; +} + +function nodePackageManager(pkg: any, lockfile: any) { + const corepack = + typeof pkg?.packageManager === "string" ? pkg.packageManager.split("@")[0].trim().toLowerCase() : ""; + if (NODE_PACKAGE_MANAGERS.includes(corepack)) return corepack; + const byLock = NODE_LOCKFILES.find(([file]) => file === lockfile); + // A package.json with no lockfile is still a Node project; npm is its default runner (a default, not a guess). + return byLock ? byLock[1] : "npm"; +} + +function hasTypescriptDependency(pkg: any) { + const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) }; + return typeof deps.typescript === "string"; +} + +function detectNode({ exists, read }: any) { + if (!exists("package.json")) return null; + const pkg = parseJson(read("package.json")); + const scripts = + pkg && typeof pkg.scripts === "object" && pkg.scripts && !Array.isArray(pkg.scripts) ? pkg.scripts : {}; + const language = exists("tsconfig.json") || hasTypescriptDependency(pkg) ? "typescript" : "javascript"; + const lockfile = nodeLockfile(exists); + const packageManager = nodePackageManager(pkg, lockfile); + + const buildName = pickScript(scripts, "build", /^(build|compile|bundle)(:|$)/i); + const testName = pickScript(scripts, "test", /(^|:)test(:|$)/i); + const lintName = pickScript(scripts, "lint", /(^|:)lint(:|$)/i); + const formatName = pickScript(scripts, "format", /(^|:)(format|fmt)(:|$)/i); + + return { + language, + packageManager, + buildCommand: buildName ? `${packageManager} run ${buildName}` : null, + // ` test` is the built-in test lifecycle across npm/yarn/pnpm/bun; a non-"test" script uses `run`. + testCommand: testName ? (testName === "test" ? `${packageManager} test` : `${packageManager} run ${testName}`) : null, + lintCommand: lintName ? `${packageManager} run ${lintName}` : null, + formatCommand: formatName ? `${packageManager} run ${formatName}` : null, + evidence: { manifest: "package.json", lockfile }, + }; +} + +function detectPython({ exists, read }: any) { + const manifest = ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"].find(exists); + if (manifest === undefined) return null; + const pyproject = read("pyproject.toml") ?? ""; + + let packageManager; + let lockfile = null; + if (exists("poetry.lock") || /\[tool\.poetry\]/.test(pyproject)) { + packageManager = "poetry"; + lockfile = exists("poetry.lock") ? "poetry.lock" : null; + } else if (exists("uv.lock")) { + packageManager = "uv"; + lockfile = "uv.lock"; + } else if (exists("Pipfile") || exists("Pipfile.lock")) { + packageManager = "pipenv"; + lockfile = exists("Pipfile.lock") ? "Pipfile.lock" : null; + } else { + packageManager = "pip"; + } + + // Commands are inferred only from real config so an undeclared tool is never guessed (acceptance: fail safe). + const hasRuff = exists("ruff.toml") || exists(".ruff.toml") || /\[tool\.ruff\]/.test(pyproject); + const hasPytest = exists("pytest.ini") || exists("tox.ini") || /\[tool\.pytest\b/.test(pyproject); + + return { + language: "python", + packageManager, + buildCommand: /\[build-system\]/.test(pyproject) ? (packageManager === "poetry" ? "poetry build" : "python -m build") : null, + testCommand: hasPytest ? "pytest" : null, + lintCommand: hasRuff ? "ruff check ." : null, + formatCommand: hasRuff ? "ruff format ." : null, + evidence: { manifest, lockfile }, + }; +} + +function detectRust({ exists }: any) { + if (!exists("Cargo.toml")) return null; + return { + language: "rust", + packageManager: "cargo", + buildCommand: "cargo build", + testCommand: "cargo test", + lintCommand: "cargo clippy", + formatCommand: "cargo fmt", + evidence: { manifest: "Cargo.toml", lockfile: exists("Cargo.lock") ? "Cargo.lock" : null }, + }; +} + +function detectGo({ exists }: any) { + if (!exists("go.mod")) return null; + const hasGolangci = exists(".golangci.yml") || exists(".golangci.yaml") || exists(".golangci.toml"); + return { + language: "go", + packageManager: "go", + buildCommand: "go build ./...", + testCommand: "go test ./...", + lintCommand: hasGolangci ? "golangci-lint run" : "go vet ./...", + formatCommand: "gofmt -l .", + evidence: { manifest: "go.mod", lockfile: exists("go.sum") ? "go.sum" : null }, + }; +} + +function detectMaven({ exists }: any) { + if (!exists("pom.xml")) return null; + return { + language: "java", + packageManager: "maven", + buildCommand: "mvn -B package", + testCommand: "mvn -B test", + lintCommand: null, + formatCommand: null, + evidence: { manifest: "pom.xml", lockfile: null }, + }; +} + +function detectGradle({ exists }: any) { + const manifest = exists("build.gradle") ? "build.gradle" : exists("build.gradle.kts") ? "build.gradle.kts" : null; + if (manifest === null) return null; + const runner = exists("gradlew") ? "./gradlew" : "gradle"; + return { + language: "java", + packageManager: "gradle", + buildCommand: `${runner} build`, + testCommand: `${runner} test`, + lintCommand: null, + formatCommand: null, + evidence: { manifest, lockfile: null }, + }; +} + +const DETECTORS = Object.freeze([detectNode, detectPython, detectRust, detectGo, detectMaven, detectGradle]); + +/** + * Detect the stack of an already-cloned repository at `repoPath`. Returns `{ detected: true, ... }` with the + * language, package manager, and any confidently-inferred commands, or `{ detected: false, reason }` when no + * recognized manifest is present. Never throws. + */ +export function detectRepoStack(repoPath: string, options: DetectRepoStackOptions = {}): RepoStackResult { + if (typeof repoPath !== "string" || !repoPath.trim()) { + return { detected: false, reason: "A repository path is required to detect the stack." }; + } + const access = makeAccess(repoPath, options); + for (const detector of DETECTORS) { + const detected = detector(access); + if (detected !== null) { + // Detectors may leave lockfile undefined; DetectedRepoStack wants string | null (pre-existing shape). + return { detected: true, ...detected, evidence: { ...detected.evidence, lockfile: detected.evidence.lockfile ?? null } } as DetectedRepoStack; + } + } + return { detected: false, reason: NO_MANIFEST_REASON }; +} + +/** One-line human summary of a detection result, suitable for a coding-agent prompt or an operator log. */ +export function renderStackSummary(stack: RepoStackResult): string { + if (!stack || stack.detected !== true) { + return `stack not detected: ${stack?.reason ?? "unknown reason"}`; + } + const commands = [ + stack.buildCommand ? `build=\`${stack.buildCommand}\`` : null, + stack.testCommand ? `test=\`${stack.testCommand}\`` : null, + stack.lintCommand ? `lint=\`${stack.lintCommand}\`` : null, + stack.formatCommand ? `format=\`${stack.formatCommand}\`` : null, + ].filter((entry) => entry !== null); + const suffix = commands.length > 0 ? ` (${commands.join(", ")})` : " (no validation commands detected)"; + return `${stack.language} via ${stack.packageManager ?? "unknown"}${suffix}`; +} diff --git a/packages/loopover-miner/tsconfig.json b/packages/loopover-miner/tsconfig.json index 4e46de9078..0f697159d5 100644 --- a/packages/loopover-miner/tsconfig.json +++ b/packages/loopover-miner/tsconfig.json @@ -21,9 +21,9 @@ // repo root and corrupt each other's incremental state. "tsBuildInfoFile": "./.tsbuildinfo" }, - // Only files already converted to real TypeScript are included -- everything else in bin/lib stays - // plain, hand-maintained .js + .d.ts until its own migration PR lands (#7290). No edits needed here as - // later phases convert more files: the glob picks them up automatically. + // Every bin/lib runtime module is real TypeScript (#7290 / #7317): tsc owns the in-place .js + .d.ts + // emit. The glob stays the include surface; test/unit/miner-typescript-migration-complete.test.ts + // fails closed if a hand-maintained .js/.d.ts orphan ever reappears. "include": ["bin/**/*.ts", "lib/**/*.ts"], // Without this, tsc's default exclude list (which always adds outDir) resolves to "." -- the whole // package root -- and silently excludes every include match, since outDir is "." for in-place emit. diff --git a/test/unit/miner-attempt-cli.test.ts b/test/unit/miner-attempt-cli.test.ts index ffddf1fc25..ca3f725b24 100644 --- a/test/unit/miner-attempt-cli.test.ts +++ b/test/unit/miner-attempt-cli.test.ts @@ -17,6 +17,19 @@ import { closeDefaultPortfolioQueueStore } from "../../packages/loopover-miner/l import { closeDefaultGovernorState } from "../../packages/loopover-miner/lib/governor-state.js"; import { buildAttemptDeps, parseAttemptArgs, runAttempt } from "../../packages/loopover-miner/lib/attempt-cli.js"; import * as minerSentryModule from "../../packages/loopover-miner/lib/sentry.js"; +import * as liveIssueSnapshotModule from "../../packages/loopover-miner/lib/live-issue-snapshot.js"; +import * as githubTokenResolutionModule from "../../packages/loopover-miner/lib/github-token-resolution.js"; +import * as worktreeAllocatorModule from "../../packages/loopover-miner/lib/worktree-allocator.js"; +import * as claimLedgerModule from "../../packages/loopover-miner/lib/claim-ledger.js"; +import * as eventLedgerModule from "../../packages/loopover-miner/lib/event-ledger.js"; +import * as attemptLogModule from "../../packages/loopover-miner/lib/attempt-log.js"; +import * as governorLedgerModule from "../../packages/loopover-miner/lib/governor-ledger.js"; +import * as rejectionSignalModule from "../../packages/loopover-miner/lib/rejection-signal.js"; +import * as attemptWorktreeModule from "../../packages/loopover-miner/lib/attempt-worktree.js"; +import * as selfReviewContextModule from "../../packages/loopover-miner/lib/self-review-context.js"; +import * as codingTaskSpecModule from "../../packages/loopover-miner/lib/coding-task-spec.js"; +import * as amsPolicyModule from "../../packages/loopover-miner/lib/ams-policy.js"; +import * as attemptRunnerModule from "../../packages/loopover-miner/lib/attempt-runner.js"; import type { PrepareAttemptWorktreeResult } from "../../packages/loopover-miner/lib/attempt-worktree.js"; import { REJECTION_REASON_AI_USAGE_POLICY_BAN, @@ -212,6 +225,12 @@ describe("parseAttemptArgs (#5132)", () => { error: "Unknown option: --verbose", }); }); + + it("REGRESSION: rejects a three-segment repo target (owner/repo/extra)", () => { + expect(parseAttemptArgs(["acme/widgets/extra", "7", "--miner-login", "alice"])).toEqual({ + error: "Repository must be in owner/repo form: acme/widgets/extra", + }); + }); }); describe("buildAttemptDeps (#5132)", () => { @@ -263,6 +282,69 @@ describe("buildAttemptDeps (#5132)", () => { /unconfigured_coding_agent_driver/, ); }); + + it("wires runSlopAssessment through to the real assessor for a minimal valid input", () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + closeables.push(allocator, claimLedger, eventLedger, attemptLog, governorLedger); + const deps = buildAttemptDeps( + { MINER_CODING_AGENT_PROVIDER: "noop" }, + { claimLedger, eventLedger, attemptLog, governorLedger, nowMs: 1 }, + ); + + expect(() => + deps.runSlopAssessment({ + description: "Add retry helper", + commitMessages: ["feat: add retry helper"], + changedFiles: [{ path: "src/retry.ts", additions: 10, deletions: 0 }], + tests: ["test/retry.test.ts"], + hasLinkedIssue: true, + }), + ).not.toThrow(); + }); + + it("wires executeLocalWrite through to a safe noop command", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + closeables.push(allocator, claimLedger, eventLedger, attemptLog, governorLedger); + const deps = buildAttemptDeps( + { MINER_CODING_AGENT_PROVIDER: "noop" }, + { claimLedger, eventLedger, attemptLog, governorLedger, nowMs: 1 }, + ); + + await expect( + deps.executeLocalWrite({ + action: "open_pr", + description: "noop", + inputs: {}, + command: "true", + boundary: "boundary", + }), + ).resolves.toMatchObject({ action: "open_pr", code: 0, timedOut: false }); + }); + + it("REGRESSION: fetchLiveIssueSnapshot omits githubToken when unset and includes it when GITHUB_TOKEN is set", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + closeables.push(allocator, claimLedger, eventLedger, attemptLog, governorLedger); + const fetchSpy = vi + .spyOn(liveIssueSnapshotModule, "fetchLiveIssueSnapshot") + .mockResolvedValue({ state: "open" as const, referencingPrs: [] }); + const resolveSpy = vi.spyOn(githubTokenResolutionModule, "resolveGitHubToken"); + + const depsNoToken = buildAttemptDeps( + { MINER_CODING_AGENT_PROVIDER: "noop" }, + { claimLedger, eventLedger, attemptLog, governorLedger, nowMs: 1 }, + ); + resolveSpy.mockResolvedValueOnce(null); + await depsNoToken.fetchLiveIssueSnapshot("acme/widgets", 7); + expect(fetchSpy).toHaveBeenCalledWith("acme/widgets", 7, {}); + + const depsWithToken = buildAttemptDeps( + { MINER_CODING_AGENT_PROVIDER: "noop", GITHUB_TOKEN: "ghp_test_token" }, + { claimLedger, eventLedger, attemptLog, governorLedger, nowMs: 1 }, + ); + resolveSpy.mockResolvedValueOnce("ghp_test_token"); + await depsWithToken.fetchLiveIssueSnapshot("acme/widgets", 7); + expect(fetchSpy).toHaveBeenCalledWith("acme/widgets", 7, { githubToken: "ghp_test_token" }); + }); }); describe("runAttempt (#5132)", () => { @@ -1050,6 +1132,110 @@ describe("runAttempt (#5132)", () => { expect(cleanupAttemptWorktreeSpy).toHaveBeenCalledWith(expect.any(String), expect.any(String), true); }); + it("REGRESSION: infeasible WITHOUT --json prints the feasibility verdict on stderr and exits 4", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + ...readyPipelineOptions({ + buildCodingTaskSpec: () => ({ + ready: false, + verdict: "raise", + feasibility: { + verdict: "raise", + avoidReasons: [], + raiseReasons: ["target_not_found"], + summary: "issue not found", + }, + }), + runMinerAttempt: vi.fn(), + }), + }); + + expect(exitCode).toBe(4); + expect(String(error.mock.calls[0]?.[0])).toContain('feasibility verdict "raise"'); + expect(String(error.mock.calls[0]?.[0])).toContain("target_not_found"); + }); + + it("REGRESSION: an unexpected runMinerAttempt outcome falls through to exit 2", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + openWorktreeAllocator: () => allocator, + openClaimLedger: () => claimLedger, + initEventLedger: () => eventLedger, + initAttemptLog: () => attemptLog, + initGovernorLedger: () => governorLedger, + ...readyPipelineOptions({ + runMinerAttempt: async () => + ({ outcome: "totally_unexpected", loopResult: fakeLoopResult() }) as never, + }), + }); + + expect(exitCode).toBe(2); + }); + + it("REGRESSION: exercises the default module fallbacks when injectables are omitted", async () => { + const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + + vi.spyOn(worktreeAllocatorModule, "openWorktreeAllocator").mockReturnValue(allocator); + vi.spyOn(claimLedgerModule, "openClaimLedger").mockReturnValue(claimLedger); + vi.spyOn(eventLedgerModule, "initEventLedger").mockReturnValue(eventLedger); + vi.spyOn(attemptLogModule, "initAttemptLog").mockReturnValue(attemptLog); + vi.spyOn(governorLedgerModule, "initGovernorLedger").mockReturnValue(governorLedger); + vi.spyOn(rejectionSignalModule, "resolveRejectionSignaled").mockResolvedValue(false); + vi.spyOn(attemptWorktreeModule, "prepareAttemptWorktree").mockResolvedValue(fakeWorktreeResult()); + vi.spyOn(attemptWorktreeModule, "cleanupAttemptWorktree").mockResolvedValue({ ok: true, removed: true }); + vi.spyOn(selfReviewContextModule, "fetchSelfReviewContext").mockResolvedValue(fakeReviewContext() as never); + vi.spyOn(codingTaskSpecModule, "buildCodingTaskSpec").mockReturnValue(fakeCodingTaskSpec() as never); + vi.spyOn(amsPolicyModule, "resolveAmsPolicy").mockResolvedValue({ + spec: DEFAULT_AMS_POLICY_SPEC, + source: "default", + warnings: [], + }); + vi.spyOn(attemptRunnerModule, "runMinerAttempt").mockResolvedValue({ + outcome: "abandon", + loopResult: fakeLoopResult(), + } as never); + + // Deliberately omit every injectable that has a module-level default, so the `?? default` arms run. + const exitCode = await runAttempt(["acme/widgets", "7", "--miner-login", "alice", "--json"], { + env: { MINER_CODING_AGENT_PROVIDER: "noop" }, + ...readyPipelineOptions({ + resolveRejectionSignaled: undefined, + prepareAttemptWorktree: undefined, + cleanupAttemptWorktree: undefined, + fetchSelfReviewContext: undefined, + buildCodingTaskSpec: undefined, + resolveAmsPolicy: undefined, + runMinerAttempt: undefined, + }), + }); + + expect(exitCode).toBe(7); + expect(worktreeAllocatorModule.openWorktreeAllocator).toHaveBeenCalled(); + expect(claimLedgerModule.openClaimLedger).toHaveBeenCalled(); + expect(eventLedgerModule.initEventLedger).toHaveBeenCalled(); + expect(attemptLogModule.initAttemptLog).toHaveBeenCalled(); + expect(governorLedgerModule.initGovernorLedger).toHaveBeenCalled(); + expect(rejectionSignalModule.resolveRejectionSignaled).toHaveBeenCalled(); + expect(attemptWorktreeModule.prepareAttemptWorktree).toHaveBeenCalled(); + expect(attemptWorktreeModule.cleanupAttemptWorktree).toHaveBeenCalled(); + expect(selfReviewContextModule.fetchSelfReviewContext).toHaveBeenCalled(); + expect(codingTaskSpecModule.buildCodingTaskSpec).toHaveBeenCalled(); + expect(amsPolicyModule.resolveAmsPolicy).toHaveBeenCalled(); + expect(attemptRunnerModule.runMinerAttempt).toHaveBeenCalled(); + }); + it("reports and cleans up when the coding-agent driver is unconfigured, still releasing the worktree slot", async () => { const { allocator, claimLedger, eventLedger, attemptLog, governorLedger } = tempLedgers(); const error = vi.spyOn(console, "error").mockImplementation(() => undefined); diff --git a/test/unit/miner-attempt-log.test.ts b/test/unit/miner-attempt-log.test.ts index 7e43c2c35c..b44f154a32 100644 --- a/test/unit/miner-attempt-log.test.ts +++ b/test/unit/miner-attempt-log.test.ts @@ -210,6 +210,39 @@ describe("loopover-miner attempt log (#4294)", () => { expect(() => log.readAttemptLogEvents()).toThrow("corrupted_attempt_log_row"); }); + it("rejects a JSON array / null / primitive payload blob on read (object-shaped only)", () => { + const log = tempAttemptLog(); + log.appendAttemptLogEvent({ eventType: "attempt_started", ...baseEvent }); + for (const bad of ["[]", "null", "42", '"hi"']) { + const raw = new DatabaseSync(log.dbPath); + raw.prepare("UPDATE attempt_log_events SET payload_json = ? WHERE id = 1").run(bad); + raw.close(); + expect(() => log.readAttemptLogEvents()).toThrow("corrupted_attempt_log_row"); + } + }); + + it("rolls back the append transaction when the inserted row cannot be re-hydrated", () => { + const log = tempAttemptLog(); + // Same connection's AFTER INSERT trigger rewrites payload to a JSON array so rowToEntry throws inside the txn. + const raw = new DatabaseSync(log.dbPath); + raw.exec(` + CREATE TRIGGER corrupt_payload_after_insert AFTER INSERT ON attempt_log_events + BEGIN + UPDATE attempt_log_events SET payload_json = '[]' WHERE id = NEW.id; + END; + `); + raw.close(); + expect(() => + log.appendAttemptLogEvent({ eventType: "attempt_started", ...baseEvent }), + ).toThrow("corrupted_attempt_log_row"); + expect(log.readAttemptLogEvents()).toHaveLength(0); + }); + + it("rejects undefined attemptId on export (required filter)", () => { + const log = tempAttemptLog(); + expect(() => log.exportAttemptLogJsonl(undefined as unknown as string)).toThrow(/invalid_attempt_id/); + }); + it("uses the default singleton helpers and closes cleanly", () => { const root = mkdtempSync(join(tmpdir(), "loopover-miner-attempt-log-default-")); roots.push(root); diff --git a/test/unit/miner-ci-poller.test.ts b/test/unit/miner-ci-poller.test.ts index ba83895174..4bc37f0f52 100644 --- a/test/unit/miner-ci-poller.test.ts +++ b/test/unit/miner-ci-poller.test.ts @@ -432,4 +432,176 @@ describe("miner CI check-run poller (#2323)", () => { expect(timeoutSpy.mock.calls.every(([ms]) => ms === 2500)).toBe(true); timeoutSpy.mockRestore(); }); + + it("rejects a non-string repo full name", async () => { + await expect( + pollCheckRuns(null as unknown as string, 1, { apiBaseUrl: API, fetchFn: vi.fn() }), + ).rejects.toThrow("invalid_repo_full_name"); + }); + + it("falls back to the default API base for non-string and blank apiBaseUrl", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + expect(url.startsWith("https://api.github.com/")).toBe(true); + if (url.includes("/pulls/")) return prResponse("sha-default"); + if (url.includes("/check-runs")) { + return checksResponse([ + null, + { status: "completed", conclusion: "startup_failure" }, + { name: "mystery", status: "completed", conclusion: "mystery" }, + { name: "skipped", status: "completed", conclusion: "skipped" }, + { name: "neutral", status: "completed", conclusion: "neutral" }, + { name: "cancelled", status: "completed", conclusion: "cancelled" }, + { name: "action_required", status: "completed", conclusion: "action_required" }, + checkRun("ok", "completed", "success"), + ]); + } + return jsonResponse({}, { status: 404 }); + }); + await expect( + pollCheckRuns("acme/widgets", 9, { + apiBaseUrl: 42 as unknown as string, + githubToken: " tok ", + fetchFn, + maxAttempts: Number.NaN, + minIntervalMs: Number.NaN, + maxIntervalMs: Number.NaN, + requestTimeoutMs: Number.NaN, + sleepFn: vi.fn(async () => {}), + }), + ).resolves.toMatchObject({ conclusion: "failure" }); + + const blankBase = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/pulls/")) return prResponse("sha-blank"); + if (url.includes("/check-runs")) return checksResponse([checkRun("ok", "completed", "success")]); + return jsonResponse({}, { status: 404 }); + }); + await expect( + pollCheckRuns("acme/widgets", 10, { apiBaseUrl: " ", fetchFn: blankBase, sleepFn: vi.fn(async () => {}) }), + ).resolves.toMatchObject({ conclusion: "success" }); + }); + + it("surfaces a GitHub error with a whitespace-only message as a bare status code", async () => { + const bareError = vi.fn().mockResolvedValue(jsonResponse({ message: " " }, { status: 404 })); + await expect( + pollCheckRuns("acme/widgets", 11, { apiBaseUrl: API, fetchFn: bareError, sleepFn: vi.fn(async () => {}) }), + ).rejects.toThrow("github_404"); + }); + + it("throws when pagination ends with an empty page before the reported total", async () => { + const incompletePage = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/pulls/13")) return prResponse("page-sha"); + // Match `&page=N` (not `per_page=`) so page 2 is not mis-routed as page 1. + if (url.includes("&page=1")) { + return jsonResponse({ total_count: 2, check_runs: [checkRun("a", "completed", "success")] }); + } + if (url.includes("&page=2")) { + return jsonResponse({ total_count: 2, check_runs: [] }); + } + return jsonResponse({}, { status: 404 }); + }); + await expect( + pollCheckRuns("acme/widgets", 13, { + apiBaseUrl: API, + fetchFn: incompletePage, + sleepFn: vi.fn(async () => {}), + }), + ).rejects.toThrow("github_check_runs_pagination_incomplete"); + }); + + it("returns pending after exhausting maxAttempts", async () => { + const pendingForever = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/pulls/")) return prResponse("pending-sha"); + if (url.includes("/check-runs")) return checksResponse([checkRun("validate", "queued")]); + return jsonResponse({}, { status: 404 }); + }); + await expect( + pollCheckRuns("acme/widgets", 14, { + apiBaseUrl: API, + fetchFn: pendingForever, + sleepFn: vi.fn(async () => {}), + maxAttempts: 2, + minIntervalMs: 1, + maxIntervalMs: 2, + }), + ).resolves.toMatchObject({ conclusion: "pending", attempts: 2 }); + }); + + it("uses global fetch when fetchFn is omitted", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/pulls/15")) return prResponse("global-sha"); + if (url.includes("/check-runs")) return checksResponse([checkRun("validate", "queued")]); + return jsonResponse({}, { status: 404 }); + }) as typeof fetch; + try { + await expect( + pollCheckRuns("acme/widgets", 15, { + apiBaseUrl: API, + maxAttempts: 1, + sleepFn: vi.fn(async () => {}), + }), + ).resolves.toMatchObject({ conclusion: "pending", attempts: 1 }); + expect(globalThis.fetch).toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("keeps a prior total_count when a later page omits a valid count, and rejects a negative total_count", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/pulls/17")) return prResponse("neg-sha"); + if (url.includes("&page=1")) { + return jsonResponse( + { total_count: 2, check_runs: [checkRun("a", "completed", "success")] }, + { + headers: { + link: `<${API}/repos/acme/widgets/commits/neg-sha/check-runs?per_page=100&page=2>; rel="next"`, + }, + }, + ); + } + if (url.includes("&page=2")) { + // Invalid/negative total_count → payloadTotalCount null, so ?? keeps the prior expected total. + return jsonResponse({ total_count: -1, check_runs: [checkRun("b", "completed", "success")] }); + } + return jsonResponse({}, { status: 404 }); + }); + await expect( + pollCheckRuns("acme/widgets", 17, { + apiBaseUrl: API, + fetchFn, + sleepFn: vi.fn(async () => {}), + }), + ).resolves.toMatchObject({ conclusion: "success", checks: [{ name: "a" }, { name: "b" }] }); + }); + + it("uses the default sleepFn (setTimeout) between pending polls when sleepFn is omitted", async () => { + vi.useFakeTimers(); + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/pulls/")) return prResponse("sleep-sha"); + if (url.includes("/check-runs")) return checksResponse([checkRun("validate", "queued")]); + return jsonResponse({}, { status: 404 }); + }); + try { + const pending = pollCheckRuns("acme/widgets", 16, { + apiBaseUrl: API, + fetchFn, + maxAttempts: 2, + minIntervalMs: 10, + maxIntervalMs: 10, + requestTimeoutMs: 1000, + }); + await vi.runAllTimersAsync(); + await expect(pending).resolves.toMatchObject({ conclusion: "pending", attempts: 2 }); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/test/unit/miner-coding-task-spec-path-guard.test.ts b/test/unit/miner-coding-task-spec-path-guard.test.ts new file mode 100644 index 0000000000..ca6563de6f --- /dev/null +++ b/test/unit/miner-coding-task-spec-path-guard.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative as realRelative } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:path", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + relative: vi.fn(actual.relative), + }; +}); + +vi.mock("@loopover/engine", async () => { + return import("../../packages/loopover-engine/src/index"); +}); + +import { relative } from "node:path"; +import { + buildCodingTaskAcceptanceCriteria, + buildCodingTaskFeasibility, + writeAcceptanceCriteriaFile, +} from "../../packages/loopover-miner/lib/coding-task-spec.js"; + +const roots: string[] = []; + +afterEach(() => { + vi.mocked(relative).mockImplementation(realRelative); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function tempDir() { + const root = mkdtempSync(join(tmpdir(), "loopover-miner-coding-task-path-")); + roots.push(root); + return realpathSync(root); +} + +function issue() { + return { + repoFullName: "acme/widgets", + number: 7, + title: "Uploads should retry on 5xx", + state: "open", + authorLogin: "reporter", + authorAssociation: "NONE", + htmlUrl: "https://github.com/acme/widgets/issues/7", + body: "Uploads fail silently.", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + closedAt: null, + labels: ["bug"], + linkedPrs: [], + }; +} + +function claimLedger() { + return { listClaims: () => [] }; +} + +describe("writeAcceptanceCriteriaFile path containment (#5132 / #7313)", () => { + it("refuses to write when the resolved path escapes the worktree root", () => { + const dir = tempDir(); + vi.mocked(relative).mockReturnValue("../escape"); + const target = issue(); + const feasibility = buildCodingTaskFeasibility( + "acme/widgets", + target, + { issues: [target], pullRequests: [] }, + claimLedger() as never, + ); + const doc = buildCodingTaskAcceptanceCriteria( + { number: 7, title: target.title, body: target.body, labels: target.labels }, + feasibility, + ); + + expect(() => writeAcceptanceCriteriaFile(dir, doc)).toThrow( + /Refusing to write acceptance criteria outside the worktree/, + ); + }); +}); diff --git a/test/unit/miner-discover-cli.test.ts b/test/unit/miner-discover-cli.test.ts index 7a0cf34a54..5e39e28224 100644 --- a/test/unit/miner-discover-cli.test.ts +++ b/test/unit/miner-discover-cli.test.ts @@ -126,6 +126,12 @@ describe("parseDiscoverArgs (#4247)", () => { }); }); + it("REGRESSION: rejects a three-segment repo target", () => { + expect(parseDiscoverArgs(["acme/widgets/extra"])).toEqual({ + error: "Repository must be in owner/repo form: acme/widgets/extra", + }); + }); + it("rejects mixing repo targets with --search", () => { expect(parseDiscoverArgs(["acme/widgets", "--search", "x"])).toEqual({ error: "Pass either repository targets or --search, not both.", @@ -524,6 +530,144 @@ describe("runDiscover (#4247)", () => { ); }); + it("REGRESSION: dry-run omits nowMs when unset and forwards goalSpecsByRepo when set", async () => { + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [fanOutIssue({ issueNumber: 1 })], + warnings: [], + rateLimitRemaining: null, + rateLimitResetAt: null, + })); + const goalSpecsByRepo = { "acme/widgets": { minerEnabled: true } }; + const rankCandidateIssuesWithSummary = vi.fn((_issues: unknown[], _opts?: Record) => ({ + issues: [ + { + ...fanOutIssue({ issueNumber: 1 }), + potential: 0.5, + feasibility: 0.5, + laneFit: 0.5, + freshness: 0.5, + dupRisk: 0, + rankScore: 0.5, + }, + ], + skippedInvalid: 0, + usedDefaultGoalSpec: false, + defaultGoalSpec: {} as never, + })); + const resolveContributionProfiles = vi.fn( + async (_repos: string[], _ctx?: Record) => new Map(), + ); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + + const exitCode = await runDiscover(["acme/widgets", "--dry-run", "--json"], { + // deliberately omit nowMs — exercise the omit arm of the optional spreads + fetchCandidateIssuesWithSummary, + rankCandidateIssuesWithSummary, + resolveContributionProfiles, + goalSpecsByRepo: goalSpecsByRepo as never, + }); + + expect(exitCode).toBe(0); + const resolveCtx = resolveContributionProfiles.mock.calls[0]?.[1] as Record | undefined; + expect(resolveCtx).not.toHaveProperty("nowMs"); + expect(resolveCtx).not.toHaveProperty("apiBaseUrl"); + + expect(rankCandidateIssuesWithSummary).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ goalSpecsByRepo }), + ); + const rankOpts = rankCandidateIssuesWithSummary.mock.calls[0]?.[1] as Record | undefined; + expect(rankOpts).not.toHaveProperty("nowMs"); + expect(JSON.parse(String(log.mock.calls[0]?.[0])).outcome).toBe("dry_run"); + + // Include arms: nowMs + apiBaseUrl present; goalSpecsByRepo omitted. + resolveContributionProfiles.mockClear(); + rankCandidateIssuesWithSummary.mockClear(); + await runDiscover(["acme/widgets", "--dry-run", "--json"], { + nowMs: NOW, + apiBaseUrl: "https://ghe.example.com/api/v3", + fetchCandidateIssuesWithSummary, + rankCandidateIssuesWithSummary, + resolveContributionProfiles, + }); + expect(resolveContributionProfiles.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + nowMs: NOW, + apiBaseUrl: "https://ghe.example.com/api/v3", + }), + ); + const rankOptsWithNow = rankCandidateIssuesWithSummary.mock.calls[0]?.[1] as Record | undefined; + expect(rankOptsWithNow).toMatchObject({ nowMs: NOW }); + expect(rankOptsWithNow).not.toHaveProperty("goalSpecsByRepo"); + + // Dry-run include arm for goalSpecContentByRepo. + rankCandidateIssuesWithSummary.mockClear(); + await runDiscover(["acme/widgets", "--dry-run", "--json"], { + nowMs: NOW, + fetchCandidateIssuesWithSummary, + rankCandidateIssuesWithSummary, + resolveContributionProfiles, + goalSpecContentByRepo: { "acme/widgets": "minerEnabled: true\n" }, + }); + expect(rankCandidateIssuesWithSummary).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ goalSpecContentByRepo: { "acme/widgets": "minerEnabled: true\n" } }), + ); + }); + + it("REGRESSION: non-dry-run omits nowMs when unset and includes goalSpecsByRepo when set", async () => { + const portfolioQueue = tempQueueStore(); + const fetchCandidateIssuesWithSummary = vi.fn(async () => ({ + issues: [fanOutIssue({ issueNumber: 1 })], + warnings: [], + rateLimitRemaining: null, + rateLimitResetAt: null, + })); + const goalSpecsByRepo = { "acme/widgets": { minerEnabled: true } }; + const rankCandidateIssuesWithSummary = vi.fn((_issues: unknown[], _opts?: Record) => ({ + issues: [ + { + ...fanOutIssue({ issueNumber: 1 }), + potential: 0.5, + feasibility: 0.5, + laneFit: 0.5, + freshness: 0.5, + dupRisk: 0, + rankScore: 0.5, + }, + ], + skippedInvalid: 0, + usedDefaultGoalSpec: false, + defaultGoalSpec: {} as never, + })); + const resolveContributionProfiles = vi.fn( + async (_repos: string[], _ctx?: Record) => new Map(), + ); + vi.spyOn(console, "log").mockImplementation(() => undefined); + + const exitCode = await runDiscover(["acme/widgets", "--json"], { + // omit nowMs + initPortfolioQueue: () => portfolioQueue, + initPolicyDocCache: () => tempPolicyDocCacheStore(), + initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), + initRankedCandidatesStore: () => tempRankedCandidatesStore(), + fetchCandidateIssuesWithSummary, + rankCandidateIssuesWithSummary, + resolveContributionProfiles, + goalSpecsByRepo: goalSpecsByRepo as never, + }); + + expect(exitCode).toBe(0); + const resolveCtx = resolveContributionProfiles.mock.calls[0]?.[1] as Record | undefined; + expect(resolveCtx).not.toHaveProperty("nowMs"); + expect(rankCandidateIssuesWithSummary).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ goalSpecsByRepo }), + ); + const rankOpts = rankCandidateIssuesWithSummary.mock.calls[0]?.[1] as Record | undefined; + expect(rankOpts).not.toHaveProperty("nowMs"); + }); + it("#4847: --dry-run reports fan-out failures and exits non-zero without opening any local store", async () => { const initPortfolioQueue = vi.fn(); const fetchCandidateIssuesWithSummary = vi.fn(async () => { @@ -798,8 +942,6 @@ describe("runDiscover (#4247)", () => { initPolicyVerdictCache: () => tempPolicyVerdictCacheStore(), initRankedCandidatesStore: () => tempRankedCandidatesStore(), fetchCandidateIssuesWithSummary, - // A token is set, so the default profile resolver would otherwise reach the network — no-op it (#6798). - resolveContributionProfiles: async () => new Map(), }, ); @@ -1534,6 +1676,36 @@ describe("runDiscover onResult hook (#6522)", () => { }); }); + it("REGRESSION: extract omits apiBaseUrl when unset and includes it when set", async () => { + const { resolveContributionProfilesForDiscover } = + await import("../../packages/loopover-miner/lib/discover-cli.js"); + const cache = { get: vi.fn(() => null), put: vi.fn(), close: vi.fn() }; + const extract = vi.fn(async (repoFullName: string, _opts?: Record) => ({ + ...trustworthyProfile, + repoFullName, + })); + + await resolveContributionProfilesForDiscover(["acme/widgets"], { + githubToken: "tok", + initCache: (() => cache) as never, + extract: extract as never, + }); + expect(extract.mock.calls[0]?.[1]).toEqual({ githubToken: "tok" }); + expect(extract.mock.calls[0]?.[1]).not.toHaveProperty("apiBaseUrl"); + + extract.mockClear(); + await resolveContributionProfilesForDiscover(["acme/widgets"], { + githubToken: "tok", + apiBaseUrl: "https://ghe.example.com/api/v3", + initCache: (() => cache) as never, + extract: extract as never, + }); + expect(extract.mock.calls[0]?.[1]).toEqual({ + githubToken: "tok", + apiBaseUrl: "https://ghe.example.com/api/v3", + }); + }); + it("the default resolver serves a fresh cached profile without re-extracting", async () => { const { resolveContributionProfilesForDiscover } = await import("../../packages/loopover-miner/lib/discover-cli.js"); diff --git a/test/unit/miner-loop-cli.test.ts b/test/unit/miner-loop-cli.test.ts index 2ef6db018e..a28cd431dc 100644 --- a/test/unit/miner-loop-cli.test.ts +++ b/test/unit/miner-loop-cli.test.ts @@ -14,6 +14,14 @@ import { initPortfolioQueueStore } from "../../packages/loopover-miner/lib/portf import { initRunStateStore } from "../../packages/loopover-miner/lib/run-state.js"; import { openGovernorState } from "../../packages/loopover-miner/lib/governor-state.js"; import { DEFAULT_AMS_POLICY_SPEC } from "../../packages/loopover-engine/src/index"; +import * as governorStateModule from "../../packages/loopover-miner/lib/governor-state.js"; +import * as eventLedgerModule from "../../packages/loopover-miner/lib/event-ledger.js"; +import * as governorLedgerModule from "../../packages/loopover-miner/lib/governor-ledger.js"; +import * as portfolioQueueModule from "../../packages/loopover-miner/lib/portfolio-queue.js"; +import * as runStateModule from "../../packages/loopover-miner/lib/run-state.js"; +import * as discoverCliModule from "../../packages/loopover-miner/lib/discover-cli.js"; +import * as amsPolicyModule from "../../packages/loopover-miner/lib/ams-policy.js"; +import * as killSwitchModule from "../../packages/loopover-miner/lib/governor-kill-switch.js"; const roots: string[] = []; // Fresh, separate connections opened AFTER a runLoop call to inspect real persisted state -- runLoop's own @@ -157,6 +165,52 @@ describe("parseLoopArgs (#5135)", () => { error: "Unknown option: --bogus", }); }); + + it("rejects flags that are missing their values", () => { + expect(parseLoopArgs(["acme/widgets", "--search"])).toEqual({ error: expect.stringContaining("Usage:") }); + expect(parseLoopArgs(["acme/widgets", "--miner-login"])).toEqual({ error: expect.stringContaining("Usage:") }); + expect(parseLoopArgs(["acme/widgets", "--miner-login", "alice", "--base"])).toEqual({ + error: expect.stringContaining("Usage:"), + }); + expect(parseLoopArgs(["acme/widgets", "--miner-login", "alice", "--max-cycles"])).toEqual({ + error: expect.stringContaining("Usage:"), + }); + expect(parseLoopArgs(["acme/widgets", "--miner-login", "alice", "--cycle-delay-ms"])).toEqual({ + error: expect.stringContaining("Usage:"), + }); + expect(parseLoopArgs(["acme/widgets", "--miner-login", "--json"])).toEqual({ + error: expect.stringContaining("Usage:"), + }); + expect(parseLoopArgs(["acme/widgets", "--miner-login", "alice", "--base", "--json"])).toEqual({ + error: expect.stringContaining("Usage:"), + }); + }); + + it("REGRESSION: stringifies a non-Error thrown while parsing numeric flags", () => { + const originalNumber = globalThis.Number; + // Force normalizeOptionalPositiveInt's Number() call to throw a non-Error so the catch's String(error) arm runs. + // eslint-disable-next-line no-global-assign, @typescript-eslint/no-explicit-any + (globalThis as any).Number = (value: unknown) => { + if (value === "777") throw "not_an_error_object"; + return originalNumber(value as never); + }; + try { + expect(parseLoopArgs(["acme/widgets", "--miner-login", "alice", "--max-cycles", "777"])).toEqual({ + error: "not_an_error_object", + }); + expect(parseLoopArgs(["acme/widgets", "--miner-login", "alice", "--cycle-delay-ms", "777"])).toEqual({ + error: "not_an_error_object", + }); + } finally { + globalThis.Number = originalNumber; + } + }); + + it("REGRESSION: rejects a three-segment repo target", () => { + expect(parseLoopArgs(["acme/widgets/extra", "--miner-login", "alice"])).toEqual({ + error: "Repository must be in owner/repo form: acme/widgets/extra", + }); + }); }); describe("runLoop (#5135)", () => { @@ -1083,4 +1137,352 @@ describe("runLoop (#5135)", () => { expect(printed.haltReason).toBe("paused"); expect(printed.cycles.at(-1)).toEqual({ cycle: 1, outcome: "halted", reason: "paused" }); }); + + it("short-circuits on bad args before opening governor state", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const openGovernorStateSpy = vi.fn(); + + const exitCode = await runLoop(["--miner-login", "alice"], { + openGovernorState: openGovernorStateSpy, + }); + + expect(exitCode).toBe(2); + expect(openGovernorStateSpy).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith(expect.stringContaining("Usage:")); + }); + + it("REGRESSION: reentry_declined halts and the human summary prints Loop finished", async () => { + const { eventLedger, governorLedger, portfolioQueue, runState, governorState } = tempStores(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:7" }); + const runAttemptSpy = vi.fn(async (_args: string[], options?: Record) => { + (options?.onResult as ((result: unknown) => void) | undefined)?.({ + outcome: "attempt_abandon", + abandonReason: "no_progress", + totalTurnsUsed: 1, + totalCostUsd: 0, + }); + return 0; + }); + + const exitCode = await runLoop(["acme/widgets", "--miner-login", "alice", "--max-cycles", "3"], { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover: async () => 0, + runAttempt: runAttemptSpy, + attemptLoopReentry: () => ({ + decision: { reenter: false, reasons: ["non_convergence"] }, + dequeued: null, + }), + ...readyLoopOptions(), + }); + + expect(exitCode).toBe(0); + expect(runAttemptSpy).toHaveBeenCalledTimes(1); + const printed = String(log.mock.calls[0]?.[0]); + expect(printed).toContain("Loop finished"); + expect(printed).toContain("reentry_declined:non_convergence"); + }); + + it("REGRESSION: threads options.apiBaseUrl into discover when set, and omits it when unset", async () => { + const withUrl = tempStores(); + const withoutUrl = tempStores(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const runDiscoverWith = vi.fn(async (_args: string[], opts?: Record) => { + // Invoke the callback so coverage counts the `initPortfolioQueue: () => portfolioQueue` line. + if (typeof opts?.initPortfolioQueue === "function") (opts.initPortfolioQueue as () => unknown)(); + return 0; + }); + const runDiscoverWithout = vi.fn(async (_args: string[], opts?: Record) => { + if (typeof opts?.initPortfolioQueue === "function") (opts.initPortfolioQueue as () => unknown)(); + return 0; + }); + + await runLoop(["acme/widgets", "--miner-login", "alice", "--max-cycles", "0", "--json"], { + openGovernorState: () => withUrl.governorState, + initEventLedger: () => withUrl.eventLedger, + initGovernorLedger: () => withUrl.governorLedger, + initPortfolioQueue: () => withUrl.portfolioQueue, + initRunStateStore: () => withUrl.runState, + runDiscover: runDiscoverWith, + githubToken: "explicit-token", + apiBaseUrl: "https://ghe.example.com/api/v3", + ...readyLoopOptions(), + }); + expect(runDiscoverWith).toHaveBeenCalledWith( + ["acme/widgets"], + expect.objectContaining({ + githubToken: "explicit-token", + apiBaseUrl: "https://ghe.example.com/api/v3", + }), + ); + + await runLoop(["acme/widgets", "--miner-login", "alice", "--max-cycles", "0", "--json"], { + openGovernorState: () => withoutUrl.governorState, + initEventLedger: () => withoutUrl.eventLedger, + initGovernorLedger: () => withoutUrl.governorLedger, + initPortfolioQueue: () => withoutUrl.portfolioQueue, + initRunStateStore: () => withoutUrl.runState, + runDiscover: runDiscoverWithout, + githubToken: "explicit-token", + ...readyLoopOptions(), + }); + const discoverOpts = runDiscoverWithout.mock.calls[0]?.[1] as Record; + expect(discoverOpts).toMatchObject({ githubToken: "explicit-token" }); + expect(discoverOpts).not.toHaveProperty("apiBaseUrl"); + }); + + it("REGRESSION: threads options.apiBaseUrl into CI/PR pollers on a submitted cycle", async () => { + const { eventLedger, governorLedger, portfolioQueue, runState, governorState } = tempStores(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:7" }); + const runAttemptSpy = vi.fn(async (_args: string[], options?: Record) => { + (options?.onResult as ((result: unknown) => void) | undefined)?.({ + outcome: "attempt_submitted", + repoFullName: "acme/widgets", + issueNumber: 7, + minerLogin: "alice", + base: "main", + mode: "dry_run", + attemptId: "loop-attempt-api", + submissionMode: "observe", + totalTurnsUsed: 1, + totalCostUsd: 0, + iterationsUsed: 1, + execResult: { + action: "open_pr", + stdout: "https://github.com/acme/widgets/pull/99\n", + stderr: "", + code: 0, + timedOut: false, + }, + }); + return 0; + }); + const pollCheckRunsSpy = vi.fn().mockResolvedValue({ + conclusion: "success", + checks: [], + headSha: "abc", + attempts: 1, + }); + const pollPrDispositionSpy = vi.fn().mockResolvedValue({ + state: "open", + merged: false, + closedAt: null, + attempts: 1, + }); + + await runLoop(["acme/widgets", "--miner-login", "alice", "--max-cycles", "1", "--json"], { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover: async () => 0, + runAttempt: runAttemptSpy, + pollCheckRuns: pollCheckRunsSpy, + pollPrDisposition: pollPrDispositionSpy, + apiBaseUrl: "https://ghe.example.com/api/v3", + githubToken: "explicit-token", + attemptLoopReentry: () => ({ + decision: { reenter: false, reasons: ["done"] }, + dequeued: null, + }), + ...readyLoopOptions(), + }); + + expect(pollCheckRunsSpy).toHaveBeenCalledWith( + "acme/widgets", + 99, + expect.objectContaining({ apiBaseUrl: "https://ghe.example.com/api/v3" }), + ); + expect(pollPrDispositionSpy).toHaveBeenCalledWith( + "acme/widgets", + 99, + expect.objectContaining({ apiBaseUrl: "https://ghe.example.com/api/v3" }), + ); + }); + + it("REGRESSION: resolves githubToken from resolveGitHubToken when options.githubToken is omitted", async () => { + const { eventLedger, governorLedger, portfolioQueue, runState, governorState } = tempStores(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const runDiscover = vi.fn(async (_args: string[], opts?: Record) => { + if (typeof opts?.initPortfolioQueue === "function") (opts.initPortfolioQueue as () => unknown)(); + return 0; + }); + const tokenModule = await import("../../packages/loopover-miner/lib/github-token-resolution.js"); + const resolveSpy = vi.spyOn(tokenModule, "resolveGitHubToken").mockResolvedValue("resolved-from-session"); + try { + await runLoop(["acme/widgets", "--miner-login", "alice", "--max-cycles", "0", "--json"], { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover, + env: {}, + ...readyLoopOptions(), + }); + expect(resolveSpy).toHaveBeenCalled(); + expect(runDiscover.mock.calls[0]?.[1]).toMatchObject({ githubToken: "resolved-from-session" }); + } finally { + resolveSpy.mockRestore(); + } + }); + + it("REGRESSION: falls back to an empty githubToken when resolution returns null", async () => { + const { eventLedger, governorLedger, portfolioQueue, runState, governorState } = tempStores(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const runDiscover = vi.fn(async (_args: string[], opts?: Record) => { + if (typeof opts?.initPortfolioQueue === "function") (opts.initPortfolioQueue as () => unknown)(); + return 0; + }); + const tokenModule = await import("../../packages/loopover-miner/lib/github-token-resolution.js"); + const resolveSpy = vi.spyOn(tokenModule, "resolveGitHubToken").mockResolvedValue(null); + try { + await runLoop(["acme/widgets", "--miner-login", "alice", "--max-cycles", "0", "--json"], { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover, + env: {}, + ...readyLoopOptions(), + }); + expect(runDiscover.mock.calls[0]?.[1]).toMatchObject({ githubToken: "" }); + } finally { + resolveSpy.mockRestore(); + } + }); + + it("#4847: --dry-run with repo targets prints a human-readable message by default", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const exitCode = await runLoop(["acme/widgets", "--miner-login", "alice", "--dry-run"]); + expect(exitCode).toBe(0); + expect(String(log.mock.calls[0]?.[0])).toContain( + "DRY RUN: would run an autonomous loop against acme/widgets for alice", + ); + }); + + it("REGRESSION: --live, missing onResult, sparse amsPolicy, and --search discovery paths", async () => { + const { eventLedger, governorLedger, portfolioQueue, runState, governorState } = tempStores(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:7" }); + const runAttemptSpy = vi.fn(async () => 0); // never calls onResult → attempt_error + const runDiscoverSpy = vi.fn(async () => 0); + + const exitCode = await runLoop( + ["--search", "label:bug", "--miner-login", "alice", "--live", "--max-cycles", "1", "--json"], + { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover: runDiscoverSpy, + runAttempt: runAttemptSpy, + ...readyLoopOptions({ + resolveAmsPolicy: async () => ({ + // Explicit undefined (not merely absent keys) so both ?? DEFAULT arms are taken. + spec: { capLimits: undefined, convergenceThresholds: undefined }, + source: "default", + warnings: [], + }), + }), + attemptLoopReentry: () => ({ + decision: { reenter: false, reasons: ["attempt_error"] }, + dequeued: null, + }), + }, + ); + + expect(exitCode).toBe(0); + expect(runDiscoverSpy).toHaveBeenCalledWith(["--search", "label:bug"], expect.any(Object)); + expect(runAttemptSpy).toHaveBeenCalledWith(expect.arrayContaining(["--live"]), expect.any(Object)); + const printed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(printed.cycles[0]).toMatchObject({ attemptOutcome: "attempt_error" }); + }); + + it("REGRESSION: exercises module-level defaults when store/discover injectables are omitted", async () => { + const { eventLedger, governorLedger, portfolioQueue, runState, governorState } = tempStores(); + vi.spyOn(console, "log").mockImplementation(() => undefined); + // Prime queue items so the loop sleeps between cycles via the real default sleepFn. + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:7" }); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:8" }); + + vi.spyOn(governorStateModule, "openGovernorState").mockReturnValue(governorState); + vi.spyOn(eventLedgerModule, "initEventLedger").mockReturnValue(eventLedger); + vi.spyOn(governorLedgerModule, "initGovernorLedger").mockReturnValue(governorLedger); + vi.spyOn(portfolioQueueModule, "initPortfolioQueueStore").mockReturnValue(portfolioQueue); + vi.spyOn(runStateModule, "initRunStateStore").mockReturnValue(runState); + vi.spyOn(discoverCliModule, "runDiscover").mockResolvedValue(0); + vi.spyOn(amsPolicyModule, "resolveAmsPolicy").mockResolvedValue({ + spec: DEFAULT_AMS_POLICY_SPEC, + source: "default", + warnings: [], + }); + vi.spyOn(killSwitchModule, "checkMinerKillSwitch").mockReturnValue({ + scope: "none", + active: false, + }); + + const exitCode = await runLoop( + ["acme/widgets", "--miner-login", "alice", "--max-cycles", "1", "--json", "--cycle-delay-ms", "0"], + { + runAttempt: async (_args, options) => { + (options?.onResult as ((r: unknown) => void) | undefined)?.({ + outcome: "attempt_abandon", + abandonReason: "no_progress", + totalTurnsUsed: 0, + totalCostUsd: 0, + }); + return 0; + }, + githubToken: "tok", + attemptLoopReentry: () => ({ + decision: { reenter: true, reasons: [] }, + dequeued: portfolioQueue.dequeueNext(), + }), + }, + ); + + expect(exitCode).toBe(0); + expect(governorStateModule.openGovernorState).toHaveBeenCalled(); + expect(eventLedgerModule.initEventLedger).toHaveBeenCalled(); + expect(discoverCliModule.runDiscover).toHaveBeenCalled(); + }); + + it("REGRESSION: a non-string queue identifier is treated as malformed and skipped", async () => { + const { eventLedger, governorLedger, portfolioQueue, runState, governorState } = tempStores(); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue:1" }); + const originalDequeue = portfolioQueue.dequeueNext.bind(portfolioQueue); + portfolioQueue.dequeueNext = () => { + const entry = originalDequeue(); + if (!entry) return entry; + return { ...entry, identifier: 123 as unknown as string }; + }; + vi.spyOn(portfolioQueue, "markDone").mockReturnValue(null); + + const exitCode = await runLoop(["acme/widgets", "--miner-login", "alice", "--max-cycles", "1", "--json"], { + openGovernorState: () => governorState, + initEventLedger: () => eventLedger, + initGovernorLedger: () => governorLedger, + initPortfolioQueue: () => portfolioQueue, + initRunStateStore: () => runState, + runDiscover: async () => 0, + runAttempt: vi.fn(), + ...readyLoopOptions(), + }); + + expect(exitCode).toBe(0); + const printed = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(printed.cycles.some((c: { outcome: string }) => c.outcome === "skipped_malformed_identifier")).toBe( + true, + ); + }); }); diff --git a/test/unit/miner-package-skeleton.test.ts b/test/unit/miner-package-skeleton.test.ts index 8589efe91e..816f5d14df 100644 --- a/test/unit/miner-package-skeleton.test.ts +++ b/test/unit/miner-package-skeleton.test.ts @@ -41,8 +41,8 @@ describe("loopover-miner package skeleton (#2287)", () => { expect(miner.dependencies["@loopover/engine"]).toBeDefined(); expect(miner.engines.node).toMatch(/^>=22(?:\.\d+){0,2}$/); expect(miner.files).toEqual(expect.arrayContaining(["bin", "lib"])); - // build is split into build:tsc (the real tsc compile, cacheable-by-turbo-but-not-turbo-restorable - // since its output is committed to git alongside hand-written siblings tsc never touches) and + // build is split into build:tsc (the real tsc compile; output is committed alongside sources as the + // package's published in-place emit) and // build:verify (a glob-driven node --check pass over every bin/lib .js file, replacing a previously // hand-listed ~119-file chain here that had to be kept in sync by hand). expect(miner.scripts.build).toBe("npm run build:tsc && npm run build:verify"); diff --git a/test/unit/miner-portfolio-queue-cli.test.ts b/test/unit/miner-portfolio-queue-cli.test.ts index e694f34846..dff5a5644b 100644 --- a/test/unit/miner-portfolio-queue-cli.test.ts +++ b/test/unit/miner-portfolio-queue-cli.test.ts @@ -12,6 +12,7 @@ import { parseQueueNextArgs, parseQueueReleaseArgs, parseQueueRequeueArgs, + parseQueueClaimBatchArgs, renderPortfolioQueueMetrics, renderQueueTable, runQueueCli, @@ -21,6 +22,7 @@ import { runQueueNext, runQueueRelease, runQueueRequeue, + runQueueClaimBatch, selectNextEligibleTarget, } from "../../packages/loopover-miner/lib/portfolio-queue-cli.js"; import type { QueueEntry } from "../../packages/loopover-miner/lib/portfolio-queue.d.ts"; @@ -60,6 +62,18 @@ describe("loopover-miner portfolio queue CLI (#2292)", () => { expect(parseQueueDoneArgs(["acme/widgets"])).toEqual({ error: expect.stringContaining("Usage: loopover-miner queue done"), }); + expect(parseQueueDoneArgs(["", "issue:1"])).toEqual({ + error: expect.stringContaining("Usage: loopover-miner queue done"), + }); + }); + + it("REGRESSION: parseQueueListArgs rejects a missing --repo value, a dashed follow-on, and positionals", () => { + expect(parseQueueListArgs(["--repo"])).toEqual({ error: expect.stringContaining("Usage:") }); + expect(parseQueueListArgs(["--repo", "--json"])).toEqual({ error: expect.stringContaining("Usage:") }); + expect(parseQueueListArgs(["extra"])).toEqual({ error: expect.stringContaining("Usage:") }); + expect(parseQueueListArgs(["--repo", "notarepo"])).toEqual({ + error: "Repository must be in owner/repo form.", + }); }); it("renderQueueTable formats numeric priority and empty output", () => { @@ -76,6 +90,19 @@ describe("loopover-miner portfolio queue CLI (#2292)", () => { expect(renderQueueTable([])).toBe("no portfolio queue entries"); expect(renderQueueTable(entries)).toContain(" 42"); expect(renderQueueTable(entries)).toContain("issue:7"); + // Nullish host/priority render as "-" (display() null/undefined arm). + expect( + renderQueueTable([ + { + apiBaseUrl: null as unknown as string, + repoFullName: "acme/widgets", + identifier: "issue:8", + status: "queued", + priority: null as unknown as number, + enqueuedAt: null as unknown as string, + }, + ]), + ).toContain("-"); }); it("renderQueueTable distinguishes two same-repo/identifier rows on different forge hosts (#7225)", () => { @@ -411,6 +438,10 @@ describe("loopover-miner portfolio queue CLI (#2292)", () => { expect(runQueueList(["--verbose"])).toBe(2); expect(String(error.mock.calls[0]?.[0])).toContain("Unknown queue subcommand"); error.mockClear(); + // Undefined subcommand hits the `subcommand ?? ""` display arm. + expect(runQueueCli(undefined, [])).toBe(2); + expect(String(error.mock.calls[0]?.[0])).toContain("Unknown queue subcommand:"); + error.mockClear(); const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runQueueCli("peek", ["--json"])).toBe(2); expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ @@ -420,6 +451,154 @@ describe("loopover-miner portfolio queue CLI (#2292)", () => { expect(error).not.toHaveBeenCalled(); }); + it("REGRESSION: runQueueList opens the default store when initPortfolioQueue is omitted", () => { + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(runQueueList(["--json"])).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ entries: expect.any(Array) }); + }); + + it("REGRESSION: runQueueCli dispatches the dashboard subcommand", async () => { + const dashboardModule = await import("../../packages/loopover-miner/lib/portfolio-dashboard.js"); + const spy = vi.spyOn(dashboardModule, "runPortfolioDashboard").mockReturnValue(0); + expect(runQueueCli("dashboard", ["--json"])).toBe(0); + expect(spy).toHaveBeenCalledWith(["--json"], expect.any(Object)); + spy.mockRestore(); + }); + + it("REGRESSION: list/next/done fail-safe (exit 2) when the store throws", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const throwingStore = { + listQueue() { + throw new Error("db_locked"); + }, + dequeueNext() { + throw new Error("db_locked"); + }, + markDone() { + throw new Error("db_locked"); + }, + close() {}, + } as unknown as ReturnType; + + expect(runQueueList([], { initPortfolioQueue: () => throwingStore })).toBe(2); + expect(runQueueNext([], { initPortfolioQueue: () => throwingStore })).toBe(2); + expect(runQueueDone(["acme/widgets", "issue:1"], { initPortfolioQueue: () => throwingStore })).toBe(2); + expect(error).toHaveBeenCalledWith("db_locked"); + }); + + it("REGRESSION: next/done/claim-batch surface parse errors before touching the store", () => { + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const initPortfolioQueue = vi.fn(); + const initPortfolioQueueManager = vi.fn(); + + expect(runQueueNext(["--bogus"], { initPortfolioQueue })).toBe(2); + expect(initPortfolioQueue).not.toHaveBeenCalled(); + expect(String(error.mock.calls[0]?.[0])).toContain("Unknown option"); + + error.mockClear(); + expect(runQueueDone(["only-one"], { initPortfolioQueue })).toBe(2); + expect(initPortfolioQueue).not.toHaveBeenCalled(); + expect(String(error.mock.calls[0]?.[0])).toContain("queue done"); + + error.mockClear(); + expect(parseQueueClaimBatchArgs(["--global-wip"])).toEqual({ + error: expect.stringContaining("Usage:"), + }); + expect(runQueueClaimBatch(["--bogus"], { initPortfolioQueueManager })).toBe(2); + expect(initPortfolioQueueManager).not.toHaveBeenCalled(); + expect(String(error.mock.calls[0]?.[0])).toContain("Usage:"); + }); + + it("REGRESSION: parseQueueClaimBatchArgs and runQueueClaimBatch cover dry-run, caps, and store paths", async () => { + expect(parseQueueClaimBatchArgs(["--json", "--dry-run", "--global-wip", "3", "--per-repo-wip", "2"])).toEqual({ + json: true, + dryRun: true, + globalWipCap: 3, + perRepoWipCap: 2, + }); + expect(parseQueueClaimBatchArgs(["--per-repo-wip", "4"])).toEqual({ + json: false, + dryRun: false, + globalWipCap: 1, + perRepoWipCap: 4, + }); + expect(parseQueueClaimBatchArgs(["--global-wip", "-1"])).toEqual({ + error: expect.stringContaining("Usage:"), + }); + + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(runQueueClaimBatch(["--dry-run", "--json"])).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ + outcome: "dry_run", + globalWipCap: 1, + perRepoWipCap: 1, + }); + log.mockClear(); + expect(runQueueClaimBatch(["--dry-run", "--global-wip", "2", "--per-repo-wip", "2"])).toBe(0); + expect(String(log.mock.calls[0]?.[0])).toContain("DRY RUN: would claim a batch"); + + const claimedEntries = [ + { + apiBaseUrl: "https://api.github.com", + repoFullName: "acme/widgets", + identifier: "issue:1", + status: "in_progress", + priority: 1, + enqueuedAt: "2026-07-04T12:00:00.000Z", + }, + ]; + const manager = { + claimNextBatch: vi.fn().mockReturnValueOnce(claimedEntries).mockReturnValueOnce([]), + close: vi.fn(), + }; + const initPortfolioQueueManager = vi.fn().mockReturnValue(manager); + + log.mockClear(); + expect( + runQueueClaimBatch(["--json", "--global-wip", "2"], { initPortfolioQueueManager }), + ).toBe(0); + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ claimed: claimedEntries }); + + log.mockClear(); + manager.claimNextBatch.mockReset().mockReturnValue(claimedEntries); + expect(runQueueClaimBatch(["--global-wip", "2"], { initPortfolioQueueManager })).toBe(0); + expect(String(log.mock.calls.at(-1)?.[0])).toBe("issue:1"); + + log.mockClear(); + manager.claimNextBatch.mockReturnValue([]); + expect(runQueueClaimBatch([], { initPortfolioQueueManager })).toBe(0); + expect(String(log.mock.calls.at(-1)?.[0])).toBe("none"); + + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + const throwingManager = { + claimNextBatch() { + throw new Error("db_locked"); + }, + close: vi.fn(), + }; + expect( + runQueueClaimBatch([], { initPortfolioQueueManager: () => throwingManager as never }), + ).toBe(2); + expect(error).toHaveBeenCalledWith("db_locked"); + + // Default manager factory path (ownsManager=true) still closes after a successful claim. + const ownedManager = { + claimNextBatch: vi.fn().mockReturnValue([]), + close: vi.fn(), + }; + const pqManagerModule = await import("../../packages/loopover-miner/lib/portfolio-queue-manager.js"); + const initSpy = vi.spyOn(pqManagerModule, "initPortfolioQueueManager").mockReturnValue(ownedManager as never); + try { + log.mockClear(); + expect(runQueueClaimBatch([])).toBe(0); + expect(String(log.mock.calls.at(-1)?.[0])).toBe("none"); + expect(ownedManager.close).toHaveBeenCalled(); + } finally { + initSpy.mockRestore(); + } + expect(runQueueCli("claim-batch", ["--dry-run", "--json"], { initPortfolioQueueManager })).toBe(0); + }); + describe("renderPortfolioQueueMetrics() / runQueueMetrics (#5186)", () => { it("emits per-status counts and the oldest in-flight lease age", () => { const now = Date.parse("2026-07-13T12:00:00.000Z"); diff --git a/test/unit/miner-self-review-context.test.ts b/test/unit/miner-self-review-context.test.ts index 3ae49eece4..2bf5f92759 100644 --- a/test/unit/miner-self-review-context.test.ts +++ b/test/unit/miner-self-review-context.test.ts @@ -528,6 +528,25 @@ describe("fetchSelfReviewContext (#5145)", () => { expect(result.pullRequests.find((pr) => pr.number === 52)?.mergeableState).toBeNull(); }); + it("uses global fetch when fetchImpl is omitted", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn(async (url: string) => { + if (url.includes("/repos/acme/widgets/issues")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets/pulls")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets")) return jsonResponse(REPO_PAYLOAD); + if (url.includes("raw.githubusercontent.com")) return jsonResponse(null, 404); + if (url.includes("api.gittensor.io/miners")) return jsonResponse([]); + return jsonResponse(null, 404); + }) as unknown as typeof fetch; + try { + const result = await fetchSelfReviewContext("acme/widgets", { loopoverAuth: null }); + expect(result.repo?.fullName).toBe("acme/widgets"); + expect(globalThis.fetch).toHaveBeenCalled(); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("defaults GITHUB_TOKEN from process.env when not supplied", async () => { const original = process.env.GITHUB_TOKEN; process.env.GITHUB_TOKEN = "env-token"; @@ -629,6 +648,17 @@ describe("live gate thresholds probe (#6487)", () => { expect(parseLiveGateThresholdFields(null)).toBeNull(); }); + it("applyLiveGateThresholdsToManifest skips a non-numeric confidence_floor", () => { + const base = parseFocusManifestContent("gate:\n readiness:\n mode: block\n minScore: 70\n", "repo_file"); + const overlaid = applyLiveGateThresholdsToManifest(base, { + confidence_floor: null, + scope_cap_files: 3, + scope_cap_lines: null, + }); + expect(overlaid.gate.readinessMinScore).toBe(70); + expect(overlaid.gate.sizeMaxFiles).toBe(3); + }); + it("applyLiveGateThresholdsToManifest raises readinessMinScore and prefers live scope caps", () => { const base = parseFocusManifestContent("gate:\n readiness:\n mode: block\n minScore: 70\n size:\n mode: block\n maxFiles: 20\n maxLines: 500\n", "repo_file"); const overlaid = applyLiveGateThresholdsToManifest(base, { @@ -743,4 +773,221 @@ describe("live gate thresholds probe (#6487)", () => { expect(timeoutSpy.mock.calls.some(([ms]) => ms === 350)).toBe(true); timeoutSpy.mockRestore(); }); + + it("covers option defaults, sparse GitHub payloads, streaming manifest success, and label/date fallbacks", async () => { + expect(parseLiveGateThresholdFields([])).toBeNull(); + expect(parseLiveGateThresholdFields({ scope_cap_files: 0, scope_cap_lines: -1 })).toBeNull(); + expect(applyLiveGateThresholdsToManifest(null as never, { confidence_floor: 0.9, scope_cap_files: null, scope_cap_lines: null })).toBeNull(); + expect( + applyLiveGateThresholdsToManifest({ gate: { readinessMinScore: "x" } } as never, { + confidence_floor: 0.9, + scope_cap_files: null, + scope_cap_lines: null, + }), + ).toEqual({ gate: { readinessMinScore: "x" } }); + const baseForFloor = parseFocusManifestContent("gate:\n readiness:\n mode: block\n minScore: 10\n", "repo_file"); + expect( + applyLiveGateThresholdsToManifest(baseForFloor, { confidence_floor: 0.9, scope_cap_files: 0, scope_cap_lines: 0 }), + ).toMatchObject({ gate: { readinessMinScore: 90 } }); + + const chunks = [new TextEncoder().encode("gate:\n duplicates: advisory\n")]; + const fetchImpl = async (url: string) => { + if (url.includes("raw.githubusercontent.com")) return chunkedManifestResponse(chunks, () => {}); + if (url.includes("/repos/acme/widgets/issues")) { + return jsonResponse([ + issuePayload({ + user: undefined, + author_association: undefined, + html_url: undefined, + body: undefined, + created_at: undefined, + updated_at: undefined, + closed_at: undefined, + labels: "bug", + }), + issuePayload({ number: 8, labels: [{}, { name: 1 }, { name: "ok" }, null] }), + null, + { pull_request: {} }, + ]); + } + if (url.includes("/repos/acme/widgets/pulls")) { + return jsonResponse([ + prPayload({ + user: undefined, + author_association: undefined, + html_url: undefined, + body: undefined, + created_at: undefined, + updated_at: undefined, + closed_at: undefined, + labels: null, + head: { sha: undefined, ref: undefined }, + base: { ref: undefined }, + }), + ]); + } + if (url.includes("/repos/acme/widgets")) { + return jsonResponse({ + private: undefined, + html_url: undefined, + default_branch: undefined, + owner: {}, + }); + } + if (url.includes("api.gittensor.io/miners")) return jsonResponse({ not: "array" }); + return jsonResponse(null, 404); + }; + + const result = await fetchSelfReviewContext("acme/widgets", { + fetchImpl: fetchImpl as never, + loopoverAuth: null, + apiBaseUrl: " ", + rawContentBaseUrl: " ", + gittensorApiBase: "\t", + githubToken: 12 as unknown as string, + perPage: -1, + maxPages: 0, + contributorLogin: " Miner ", + linkedIssues: [7, "x", 8.5, null, 8] as unknown as number[], + liveGateProbeTimeoutMs: -1, + requestTimeoutMs: 0, + }); + + expect(result.manifest.present).toBe(true); + expect(result.repo).toMatchObject({ + owner: "acme", + name: "widgets", + isPrivate: false, + htmlUrl: null, + defaultBranch: null, + }); + expect(result.issues.some((issue) => issue.number === 8 && issue.labels.includes("ok"))).toBe(true); + expect(result.issues.find((issue) => issue.number === 7)?.authorLogin).toBeNull(); + expect(result.confirmedContributor).toBe(false); + expect(result.pullRequests[0]?.authorLogin).toBeNull(); + expect(result.pullRequests[0]?.labels).toEqual([]); + }); + + it("resolves loopoverAuth apiUrl from an explicit session when apiUrl is blank", async () => { + const seen: string[] = []; + const fetchImpl = async (url: string) => { + seen.push(url); + if (url.includes("/live-gate-thresholds")) { + return jsonResponse({ confidence_floor: 0.8, scope_cap_files: null, scope_cap_lines: null }); + } + if (url.includes("/repos/acme/widgets/issues")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets/pulls")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets")) return jsonResponse(REPO_PAYLOAD); + if (url.includes("raw.githubusercontent.com")) return jsonResponse(null, 404); + if (url.includes("api.gittensor.io/miners")) return jsonResponse([]); + return jsonResponse(null, 404); + }; + + await fetchSelfReviewContext("acme/widgets", { + fetchImpl: fetchImpl as never, + loopoverAuth: { apiUrl: " ", sessionToken: "tok" }, + env: {} as NodeJS.ProcessEnv, + }); + + expect(seen.some((url) => url.startsWith("https://api.loopover.ai/v1/repos/"))).toBe(true); + }); + + it("rejects a non-string repoFullName and a three-segment path", async () => { + await expect(fetchSelfReviewContext(12 as unknown as string)).rejects.toThrow("invalid_repo_full_name"); + await expect(fetchSelfReviewContext("acme/widgets/extra")).rejects.toThrow("invalid_repo_full_name"); + }); + + it("honors explicit non-blank API base URLs, pagination caps, and ignores PR #0 links", async () => { + const seen: string[] = []; + const fetchImpl = async (url: string) => { + seen.push(url); + if (url.includes("raw.example.test")) { + return { + ok: true, + status: 200, + headers: new Headers({ "content-length": "32" }), + text: async () => "gate:\n duplicates: advisory\n", + }; + } + if (url.includes("api.example.test") && url.includes("/issues")) { + return jsonResponse([ + issuePayload({ body: "Closes PR #0 and also Closes PR #7" }), + ]); + } + if (url.includes("api.example.test") && url.includes("/pulls")) { + return jsonResponse([ + prPayload({ body: "Closes #0 and Closes other/repo#9 and Closes #7", draft: undefined }), + ]); + } + if (url.includes("api.example.test") && url.includes("/repos/")) return jsonResponse(REPO_PAYLOAD); + if (url.includes("gittensor.example.test/miners")) return jsonResponse([]); + return jsonResponse(null, 404); + }; + + const result = await fetchSelfReviewContext("acme/widgets", { + fetchImpl: fetchImpl as never, + loopoverAuth: null, + apiBaseUrl: "https://api.example.test", + rawContentBaseUrl: "https://raw.example.test", + gittensorApiBase: "https://gittensor.example.test", + perPage: 1, + maxPages: 1, + contributorLogin: "miner-bot", + }); + + expect(seen.some((url) => url.startsWith("https://api.example.test/"))).toBe(true); + expect(seen.some((url) => url.startsWith("https://raw.example.test/"))).toBe(true); + expect(seen.some((url) => url.startsWith("https://gittensor.example.test/"))).toBe(true); + expect(result.issues[0]?.linkedPrs).toEqual([7]); + expect(result.pullRequests[0]?.linkedIssues).toEqual([7]); + expect(result.pullRequests[0]?.isDraft).toBeNull(); + expect(result.manifest.present).toBe(true); + }); + + it("falls back when a non-streaming manifest response is oversized by encoded byte length", async () => { + const encodeSpy = vi.spyOn(TextEncoder.prototype, "encode").mockReturnValue(new Uint8Array(MAX_FOCUS_MANIFEST_BYTES + 1)); + try { + const fetchImpl = async (url: string) => { + if (url.includes("raw.githubusercontent.com")) { + return { + ok: true, + status: 200, + headers: new Headers(), + text: async () => "gate:\n duplicates: advisory\n", + }; + } + if (url.includes("/repos/acme/widgets/issues")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets/pulls")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets")) return jsonResponse(REPO_PAYLOAD); + if (url.includes("api.gittensor.io/miners")) return jsonResponse([]); + return jsonResponse(null, 404); + }; + + const result = await fetchSelfReviewContext("acme/widgets", { fetchImpl: fetchImpl as never, loopoverAuth: null }); + expect(result.manifest.present).toBe(false); + } finally { + encodeSpy.mockRestore(); + } + }); + + it("treats a non-string text() body on a non-streaming manifest as absent", async () => { + const fetchImpl = async (url: string) => { + if (url.includes("raw.githubusercontent.com")) { + return { + ok: true, + status: 200, + headers: new Headers(), + text: async () => 123, + }; + } + if (url.includes("/repos/acme/widgets/issues")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets/pulls")) return jsonResponse([]); + if (url.includes("/repos/acme/widgets")) return jsonResponse(REPO_PAYLOAD); + if (url.includes("api.gittensor.io/miners")) return jsonResponse([]); + return jsonResponse(null, 404); + }; + + const result = await fetchSelfReviewContext("acme/widgets", { fetchImpl: fetchImpl as never, loopoverAuth: null }); + expect(result.manifest.present).toBe(false); + }); }); diff --git a/test/unit/miner-stack-detection.test.ts b/test/unit/miner-stack-detection.test.ts index dd4d005eaa..3132f81d45 100644 --- a/test/unit/miner-stack-detection.test.ts +++ b/test/unit/miner-stack-detection.test.ts @@ -130,6 +130,9 @@ describe("detectRepoStack — Node (#4785)", () => { expect(detect({ "package.json": "{ not json" })).toMatchObject({ detected: true, language: "javascript", buildCommand: null }); // Present but unreadable (read throws -> null). expect(detect({ "package.json": null })).toMatchObject({ detected: true, language: "javascript" }); + // Valid JSON that is not an object (number / bool / string) → parseJson returns null, still Node. + expect(detect({ "package.json": "42" })).toMatchObject({ detected: true, language: "javascript", buildCommand: null }); + expect(detect({ "package.json": "true" })).toMatchObject({ detected: true, language: "javascript", buildCommand: null }); }); it("treats a non-string readFileSync result as no content", () => { diff --git a/test/unit/miner-typescript-migration-complete.test.ts b/test/unit/miner-typescript-migration-complete.test.ts new file mode 100644 index 0000000000..7b020c941d --- /dev/null +++ b/test/unit/miner-typescript-migration-complete.test.ts @@ -0,0 +1,37 @@ +import { readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const minerRoot = join(process.cwd(), "packages/loopover-miner"); +const dirs = ["bin", "lib"] as const; + +function listBasenames(dir: string, predicate: (name: string) => boolean): string[] { + return readdirSync(join(minerRoot, dir)) + .filter(predicate) + .map((name) => name.replace(/\.(?:d\.ts|ts|js)$/, "")) + .sort(); +} + +/** + * #7317 closing guard for the #7290 miner TypeScript migration: every runtime file under + * packages/loopover-miner/{bin,lib} must be compiler-owned (.ts source → emitted .js + .d.ts). + * A lone hand-maintained .js/.d.ts pair is the drift gap the migration was filed to close. + */ +describe("loopover-miner TypeScript migration complete (#7317)", () => { + it("has zero hand-maintained .js/.d.ts orphans — every basename has a real .ts source", () => { + for (const dir of dirs) { + const sources = new Set(listBasenames(dir, (name) => name.endsWith(".ts") && !name.endsWith(".d.ts"))); + const scripts = listBasenames(dir, (name) => name.endsWith(".js")); + const declarations = listBasenames(dir, (name) => name.endsWith(".d.ts")); + + const jsWithoutTs = scripts.filter((base) => !sources.has(base)); + const dtsWithoutTs = declarations.filter((base) => !sources.has(base)); + + expect(jsWithoutTs, `${dir}/ .js without sibling .ts`).toEqual([]); + expect(dtsWithoutTs, `${dir}/ .d.ts without sibling .ts`).toEqual([]); + // Emitted declarations track sources 1:1 (tsc declaration:true in-place emit). + expect(declarations).toEqual([...sources].sort()); + expect(scripts).toEqual([...sources].sort()); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index dc1f7f12ea..c43c00e315 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -35,7 +35,7 @@ export default defineConfig({ "src/**/*.ts", "packages/loopover-engine/src/**/*.ts", "packages/loopover-miner/lib/**/*.js", - // Files already converted to real TypeScript (#7290) execute as the compiled .js above, but + // Files converted to real TypeScript (#7290 / #7317) execute as the compiled .js above, but // v8's coverage provider remaps through the inline sourcemap tsc emits, attributing coverage to // the .ts source instead -- this entry is what keeps that remapped file in the report. "packages/loopover-miner/lib/**/*.ts", @@ -75,8 +75,8 @@ export default defineConfig({ // build+boot path, not unit-coverable without actually binding a port. See codecov.yml's matching // ignore entry; app.ts (everything server.ts wires together) is what tests actually import. // - // packages/loopover-miner/lib/**/*.ts (above) also glob-matches its own still-hand-maintained - // *.d.ts siblings (a ".d.ts" path ends in ".ts" too) -- those aren't real modules and can't be + // packages/loopover-miner/lib/**/*.ts (above) also glob-matches its own emitted *.d.ts siblings + // (a ".d.ts" path ends in ".ts" too) -- those aren't real modules and can't be // parsed as coverage source, so they're excluded the same way src/env.d.ts already is. Same story // for the *.ts entries under packages/loopover-miner/bin/** and packages/loopover-mcp/{lib,bin}/** // added above -- each glob-matches its own *.d.ts siblings too.