diff --git a/packages/loopover-miner/lib/claim-conflict-resolver.d.ts b/packages/loopover-miner/lib/claim-conflict-resolver.d.ts index 7377c70ee1..a5975dc454 100644 --- a/packages/loopover-miner/lib/claim-conflict-resolver.d.ts +++ b/packages/loopover-miner/lib/claim-conflict-resolver.d.ts @@ -1,39 +1,54 @@ -import type { LiveIssueSnapshot } from "./submission-freshness-check.js"; import type { ObservedClaim } from "./claim-adjudication.js"; import type { LocalWriteActionSpec } from "@loopover/engine"; - -export function assembleCompetingClaims( - snapshot: LiveIssueSnapshot | null | undefined, - selfPrNumber: number, - minerLogin: string, -): ObservedClaim[]; - +import type { LiveIssueSnapshot } from "./submission-freshness-check.js"; +/** + * Assemble the real competing-claims set from a fetched LiveIssueSnapshot: every OTHER open PR referencing + * the issue, excluding `selfPrNumber` and any PR authored by `minerLogin` itself (case-insensitive, mirrors + * checkSubmissionFreshness's own author comparison -- a login can be echoed back with different casing). + * Excluding same-author PRs is deliberate, not an edge case slipping through: a miner never competes against + * its own work, so if this login somehow has ANOTHER open PR on the same issue (e.g. a retry after a crash + * left a stale one behind), that PR is never treated as a competing claim to lose against -- only a genuinely + * different claimant's PR can trigger a real close. + * Pure given its inputs. + */ +export declare function assembleCompetingClaims(snapshot: LiveIssueSnapshot | null | undefined, selfPrNumber: number, minerLogin: string): ObservedClaim[]; export type ClaimConflictInput = { - repoFullName: string; - issueNumber: number; - selfPrNumber: number; - selfClaimedAt: string | null; - minerLogin: string; + repoFullName: string; + issueNumber: number; + selfPrNumber: number; + selfClaimedAt: string | null; + minerLogin: string; }; - export type ClaimConflictDeps = { - fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise; - executeLocalWrite: (spec: LocalWriteActionSpec) => Promise; + fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise; + executeLocalWrite: (spec: LocalWriteActionSpec) => Promise; +}; +export type ClaimConflictResult = { + checked: false; + reason: "live_state_unavailable"; +} | { + checked: true; + isWinner: true; + winnerNumber: number | null; + competingCount: number; +} | { + checked: true; + isWinner: false; + winnerNumber: number | null; + competingCount: number; + closeResult: unknown; }; - -export type ClaimConflictResult = - | { checked: false; reason: "live_state_unavailable" } - | { checked: true; isWinner: true; winnerNumber: number | null; competingCount: number } - | { checked: true; isWinner: false; winnerNumber: number | null; competingCount: number; closeResult: unknown }; - export type ClaimConflictRetryOptions = { - maxAttempts?: number; - sleepFn?: (ms: number) => Promise; - backoffMs?: (attempt: number) => number; + maxAttempts?: number; + sleepFn?: (ms: number) => Promise; + backoffMs?: (attempt: number) => number; }; - -export function resolveClaimConflict( - input: ClaimConflictInput, - deps: ClaimConflictDeps, - options?: ClaimConflictRetryOptions, -): Promise; +/** + * Resolve a real claim conflict for an already-submitted PR. Fails OPEN (never closes anything) when the live + * snapshot can't be fetched -- an unavailable check is not evidence of a lost claim. + * + * `options` is the bounded retry for the live-state snapshot fetch (#6058): up to `maxAttempts` (default 3) + * attempts with `backoffMs(attempt)` backoff between them, returning as soon as a competing claim is observed. + * Pure over the injected `sleepFn`/`backoffMs` -- no real timers in tests. + */ +export declare function resolveClaimConflict(input: ClaimConflictInput, deps: ClaimConflictDeps, options?: ClaimConflictRetryOptions): Promise; diff --git a/packages/loopover-miner/lib/claim-conflict-resolver.js b/packages/loopover-miner/lib/claim-conflict-resolver.js index 351427ec52..24191a6698 100644 --- a/packages/loopover-miner/lib/claim-conflict-resolver.js +++ b/packages/loopover-miner/lib/claim-conflict-resolver.js @@ -24,16 +24,13 @@ // a few attempts with exponential backoff (following http-retry.js's convention), returning as soon as a // competing claim is observed, and otherwise giving a late-propagating competitor time to surface before // this miner is declared the winner. The write-authorization boundary (#4833) is unchanged. - import { adjudicateSoftClaim } from "./claim-adjudication.js"; import { buildClosePrSpec } from "@loopover/engine"; import { defaultRetryBackoffMs } from "./http-retry.js"; - // Bounded retry for the post-submission live-state check (#6058): a few attempts give a competing PR that // hasn't propagated through GitHub's search/GraphQL index yet time to surface, without an unbounded loop. const DEFAULT_SNAPSHOT_MAX_ATTEMPTS = 3; const defaultSnapshotSleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)); - /** * Assemble the real competing-claims set from a fetched LiveIssueSnapshot: every OTHER open PR referencing * the issue, excluding `selfPrNumber` and any PR authored by `minerLogin` itself (case-insensitive, mirrors @@ -43,88 +40,68 @@ const defaultSnapshotSleep = (delayMs) => new Promise((resolve) => setTimeout(re * left a stale one behind), that PR is never treated as a competing claim to lose against -- only a genuinely * different claimant's PR can trigger a real close. * Pure given its inputs. - * - * @param {import("./submission-freshness-check.js").LiveIssueSnapshot | null | undefined} snapshot - * @param {number} selfPrNumber - * @param {string} minerLogin - * @returns {import("./claim-adjudication.js").ObservedClaim[]} */ export function assembleCompetingClaims(snapshot, selfPrNumber, minerLogin) { - const minerLoginKey = minerLogin.trim().toLowerCase(); - const referencingPrs = Array.isArray(snapshot?.referencingPrs) ? snapshot.referencingPrs : []; - return referencingPrs - .filter((pr) => pr.state === "open" && pr.number !== selfPrNumber) - .filter((pr) => typeof pr.authorLogin !== "string" || pr.authorLogin.trim().toLowerCase() !== minerLoginKey) - .map((pr) => ({ number: pr.number, claimedAt: pr.createdAt ?? null })); + const minerLoginKey = minerLogin.trim().toLowerCase(); + const referencingPrs = Array.isArray(snapshot?.referencingPrs) ? snapshot.referencingPrs : []; + return referencingPrs + .filter((pr) => pr.state === "open" && pr.number !== selfPrNumber) + .filter((pr) => typeof pr.authorLogin !== "string" || pr.authorLogin.trim().toLowerCase() !== minerLoginKey) + .map((pr) => ({ number: pr.number, claimedAt: pr.createdAt ?? null })); } - /** * Resolve a real claim conflict for an already-submitted PR. Fails OPEN (never closes anything) when the live * snapshot can't be fetched -- an unavailable check is not evidence of a lost claim. * - * @param {{ repoFullName: string, issueNumber: number, selfPrNumber: number, selfClaimedAt: string | null, minerLogin: string }} input - * @param {{ - * fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise, - * executeLocalWrite: (spec: import("@loopover/engine").LocalWriteActionSpec) => Promise, - * }} deps - * @param {{ maxAttempts?: number, sleepFn?: (ms: number) => Promise, backoffMs?: (attempt: number) => number }} [options] - * Bounded retry for the live-state snapshot fetch (#6058): up to `maxAttempts` (default 3) attempts with - * `backoffMs(attempt)` backoff between them, returning as soon as a competing claim is observed. Pure over - * the injected `sleepFn`/`backoffMs` -- no real timers in tests. - * @returns {Promise<{ - * checked: boolean, - * reason?: "live_state_unavailable", - * isWinner?: boolean, - * winnerNumber?: number | null, - * competingCount?: number, - * closeResult?: unknown, - * }>} + * `options` is the bounded retry for the live-state snapshot fetch (#6058): up to `maxAttempts` (default 3) + * attempts with `backoffMs(attempt)` backoff between them, returning as soon as a competing claim is observed. + * Pure over the injected `sleepFn`/`backoffMs` -- no real timers in tests. */ export async function resolveClaimConflict(input, deps, options = {}) { - const maxAttempts = - Number.isFinite(options.maxAttempts) && options.maxAttempts >= 1 ? Math.floor(options.maxAttempts) : DEFAULT_SNAPSHOT_MAX_ATTEMPTS; - const sleepFn = typeof options.sleepFn === "function" ? options.sleepFn : defaultSnapshotSleep; - const backoffMs = typeof options.backoffMs === "function" ? options.backoffMs : defaultRetryBackoffMs; - - let snapshot = null; - let competing = []; - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - let current; - try { - current = await deps.fetchLiveIssueSnapshot(input.repoFullName, input.issueNumber); - } catch { - current = null; + const maxAttempts = Number.isFinite(options.maxAttempts) && options.maxAttempts >= 1 + ? Math.floor(options.maxAttempts) + : DEFAULT_SNAPSHOT_MAX_ATTEMPTS; + const sleepFn = typeof options.sleepFn === "function" ? options.sleepFn : defaultSnapshotSleep; + const backoffMs = typeof options.backoffMs === "function" ? options.backoffMs : defaultRetryBackoffMs; + let snapshot = null; + let competing = []; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + let current; + try { + current = await deps.fetchLiveIssueSnapshot(input.repoFullName, input.issueNumber); + } + catch { + current = null; + } + if (current && typeof current === "object") { + snapshot = current; + competing = assembleCompetingClaims(current, input.selfPrNumber, input.minerLogin); + // A competing claim observed = GitHub's index has propagated it; stop retrying and act on it now. + if (competing.length > 0) + break; + } + // Back off before the next attempt (index-propagation lag / a transient fetch failure); never after the last. + if (attempt < maxAttempts) + await sleepFn(backoffMs(attempt)); + } + if (!snapshot) { + return { checked: false, reason: "live_state_unavailable" }; } - if (current && typeof current === "object") { - snapshot = current; - competing = assembleCompetingClaims(current, input.selfPrNumber, input.minerLogin); - // A competing claim observed = GitHub's index has propagated it; stop retrying and act on it now. - if (competing.length > 0) break; + const adjudication = adjudicateSoftClaim({ number: input.selfPrNumber, claimedAt: input.selfClaimedAt }, competing); + if (adjudication.isWinner) { + return { checked: true, isWinner: true, winnerNumber: adjudication.winnerNumber, competingCount: competing.length }; } - // Back off before the next attempt (index-propagation lag / a transient fetch failure); never after the last. - if (attempt < maxAttempts) await sleepFn(backoffMs(attempt)); - } - if (!snapshot) { - return { checked: false, reason: "live_state_unavailable" }; - } - - const adjudication = adjudicateSoftClaim({ number: input.selfPrNumber, claimedAt: input.selfClaimedAt }, competing); - - if (adjudication.isWinner) { - return { checked: true, isWinner: true, winnerNumber: adjudication.winnerNumber, competingCount: competing.length }; - } - - const comment = adjudication.winnerNumber - ? `Closing this PR: pull request #${adjudication.winnerNumber} claimed this issue first. This is an automated soft-claim conflict resolution -- no action needed from you.` - : `Closing this PR: another open pull request already claims this issue. This is an automated soft-claim conflict resolution -- no action needed from you.`; - const spec = buildClosePrSpec({ repoFullName: input.repoFullName, number: input.selfPrNumber, comment }); - const closeResult = await deps.executeLocalWrite(spec); - - return { - checked: true, - isWinner: false, - winnerNumber: adjudication.winnerNumber, - competingCount: competing.length, - closeResult, - }; + const comment = adjudication.winnerNumber + ? `Closing this PR: pull request #${adjudication.winnerNumber} claimed this issue first. This is an automated soft-claim conflict resolution -- no action needed from you.` + : `Closing this PR: another open pull request already claims this issue. This is an automated soft-claim conflict resolution -- no action needed from you.`; + const spec = buildClosePrSpec({ repoFullName: input.repoFullName, number: input.selfPrNumber, comment }); + const closeResult = await deps.executeLocalWrite(spec); + return { + checked: true, + isWinner: false, + winnerNumber: adjudication.winnerNumber, + competingCount: competing.length, + closeResult, + }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhaW0tY29uZmxpY3QtcmVzb2x2ZXIuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJjbGFpbS1jb25mbGljdC1yZXNvbHZlci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSwwR0FBMEc7QUFDMUcsOEdBQThHO0FBQzlHLDZHQUE2RztBQUM3Ryw2R0FBNkc7QUFDN0csMkdBQTJHO0FBQzNHLDRHQUE0RztBQUM1Ryx5R0FBeUc7QUFDekcsOEdBQThHO0FBQzlHLDhHQUE4RztBQUM5RywrR0FBK0c7QUFDL0csaUNBQWlDO0FBQ2pDLEVBQUU7QUFDRixzR0FBc0c7QUFDdEcsK0dBQStHO0FBQy9HLDJHQUEyRztBQUMzRyw2R0FBNkc7QUFDN0csNEdBQTRHO0FBQzVHLDJHQUEyRztBQUMzRyx1REFBdUQ7QUFDdkQsRUFBRTtBQUNGLHlHQUF5RztBQUN6Ryw2R0FBNkc7QUFDN0csMEdBQTBHO0FBQzFHLHlHQUF5RztBQUN6Ryx5R0FBeUc7QUFDekcsNEZBQTRGO0FBRTVGLE9BQU8sRUFBRSxtQkFBbUIsRUFBRSxNQUFNLHlCQUF5QixDQUFDO0FBRTlELE9BQU8sRUFBRSxnQkFBZ0IsRUFBRSxNQUFNLGtCQUFrQixDQUFDO0FBRXBELE9BQU8sRUFBRSxxQkFBcUIsRUFBRSxNQUFNLGlCQUFpQixDQUFDO0FBR3hELDBHQUEwRztBQUMxRywwR0FBMEc7QUFDMUcsTUFBTSw2QkFBNkIsR0FBRyxDQUFDLENBQUM7QUFDeEMsTUFBTSxvQkFBb0IsR0FBRyxDQUFDLE9BQWUsRUFBb0IsRUFBRSxDQUNqRSxJQUFJLE9BQU8sQ0FBQyxDQUFDLE9BQU8sRUFBRSxFQUFFLENBQUMsVUFBVSxDQUFDLE9BQU8sRUFBRSxPQUFPLENBQUMsQ0FBQyxDQUFDO0FBRXpEOzs7Ozs7Ozs7R0FTRztBQUNILE1BQU0sVUFBVSx1QkFBdUIsQ0FDckMsUUFBOEMsRUFDOUMsWUFBb0IsRUFDcEIsVUFBa0I7SUFFbEIsTUFBTSxhQUFhLEdBQUcsVUFBVSxDQUFDLElBQUksRUFB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/claim-conflict-resolver.ts b/packages/loopover-miner/lib/claim-conflict-resolver.ts new file mode 100644 index 0000000000..f3113ae863 --- /dev/null +++ b/packages/loopover-miner/lib/claim-conflict-resolver.ts @@ -0,0 +1,149 @@ +// Real claim-conflict resolution (#4848): the missing piece over claim-adjudication.js's own adjudicator, +// which is correct and well-tested in isolation but has no caller that assembles a REAL competing-claims set. +// checkSubmissionFreshness (submission-freshness-check.js) already catches the common case pre-submission -- +// aborting before open_pr if another author's PR already references the issue -- but that check can only see +// what's PUBLIC at the moment it runs. Two miners racing closely enough that BOTH pass their own freshness +// check before either's PR exists yet is a genuine TOCTOU window freshness cannot close. This module is the +// POST-submission reconciliation for exactly that window: once THIS miner's PR is real and public, check +// whether ANOTHER open PR also claims the same issue and, if this miner's claim loses the election, close its +// own just-opened PR (never anyone else's) -- the write action the contributor-vs-maintainer safety framework +// keeps maintainer-only (#4833's own scope note), since it means the autonomous loop acts on a race-resolution +// decision with no human review. +// +// CLAIM-TIME ASYMMETRY (documented, not accidental): `self`'s claimedAt is the miner's OWN real local +// claim-ledger timestamp (claim-ledger.js, recorded before work even started). A competing PR's claimedAt uses +// its real GitHub `createdAt` instead -- the maintainer gate's own duplicate-winner election uses loopover +// server's "first observed this PR's linked-issue set" timestamp, but that requires a continuous, persistent +// observation history this stateless client-side tool does not have for a PR it doesn't own. `createdAt` is +// the best real, publicly-observable proxy available for someone else's PR -- live-issue-snapshot.js's own +// comment on `createdAt` explains this in more detail. +// +// EVENTUAL CONSISTENCY: this checks GitHub's live state after submission. A competing PR that exists but +// hasn't yet propagated through GitHub's own search/GraphQL indexing in the first instant would be invisible +// to a single check, so the live-state snapshot fetch is wrapped in a bounded retry-with-backoff (#6058): +// a few attempts with exponential backoff (following http-retry.js's convention), returning as soon as a +// competing claim is observed, and otherwise giving a late-propagating competitor time to surface before +// this miner is declared the winner. The write-authorization boundary (#4833) is unchanged. + +import { adjudicateSoftClaim } from "./claim-adjudication.js"; +import type { ObservedClaim } from "./claim-adjudication.js"; +import { buildClosePrSpec } from "@loopover/engine"; +import type { LocalWriteActionSpec } from "@loopover/engine"; +import { defaultRetryBackoffMs } from "./http-retry.js"; +import type { LiveIssueSnapshot } from "./submission-freshness-check.js"; + +// Bounded retry for the post-submission live-state check (#6058): a few attempts give a competing PR that +// hasn't propagated through GitHub's search/GraphQL index yet time to surface, without an unbounded loop. +const DEFAULT_SNAPSHOT_MAX_ATTEMPTS = 3; +const defaultSnapshotSleep = (delayMs: number): Promise => + new Promise((resolve) => setTimeout(resolve, delayMs)); + +/** + * Assemble the real competing-claims set from a fetched LiveIssueSnapshot: every OTHER open PR referencing + * the issue, excluding `selfPrNumber` and any PR authored by `minerLogin` itself (case-insensitive, mirrors + * checkSubmissionFreshness's own author comparison -- a login can be echoed back with different casing). + * Excluding same-author PRs is deliberate, not an edge case slipping through: a miner never competes against + * its own work, so if this login somehow has ANOTHER open PR on the same issue (e.g. a retry after a crash + * left a stale one behind), that PR is never treated as a competing claim to lose against -- only a genuinely + * different claimant's PR can trigger a real close. + * Pure given its inputs. + */ +export function assembleCompetingClaims( + snapshot: LiveIssueSnapshot | null | undefined, + selfPrNumber: number, + minerLogin: string, +): ObservedClaim[] { + const minerLoginKey = minerLogin.trim().toLowerCase(); + const referencingPrs = Array.isArray(snapshot?.referencingPrs) ? snapshot.referencingPrs : []; + return referencingPrs + .filter((pr) => pr.state === "open" && pr.number !== selfPrNumber) + .filter((pr) => typeof pr.authorLogin !== "string" || pr.authorLogin.trim().toLowerCase() !== minerLoginKey) + .map((pr) => ({ number: pr.number, claimedAt: pr.createdAt ?? null })); +} + +export type ClaimConflictInput = { + repoFullName: string; + issueNumber: number; + selfPrNumber: number; + selfClaimedAt: string | null; + minerLogin: string; +}; + +export type ClaimConflictDeps = { + fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise; + executeLocalWrite: (spec: LocalWriteActionSpec) => Promise; +}; + +export type ClaimConflictResult = + | { checked: false; reason: "live_state_unavailable" } + | { checked: true; isWinner: true; winnerNumber: number | null; competingCount: number } + | { checked: true; isWinner: false; winnerNumber: number | null; competingCount: number; closeResult: unknown }; + +export type ClaimConflictRetryOptions = { + maxAttempts?: number; + sleepFn?: (ms: number) => Promise; + backoffMs?: (attempt: number) => number; +}; + +/** + * Resolve a real claim conflict for an already-submitted PR. Fails OPEN (never closes anything) when the live + * snapshot can't be fetched -- an unavailable check is not evidence of a lost claim. + * + * `options` is the bounded retry for the live-state snapshot fetch (#6058): up to `maxAttempts` (default 3) + * attempts with `backoffMs(attempt)` backoff between them, returning as soon as a competing claim is observed. + * Pure over the injected `sleepFn`/`backoffMs` -- no real timers in tests. + */ +export async function resolveClaimConflict( + input: ClaimConflictInput, + deps: ClaimConflictDeps, + options: ClaimConflictRetryOptions = {}, +): Promise { + const maxAttempts = + Number.isFinite(options.maxAttempts) && (options.maxAttempts as number) >= 1 + ? Math.floor(options.maxAttempts as number) + : DEFAULT_SNAPSHOT_MAX_ATTEMPTS; + const sleepFn = typeof options.sleepFn === "function" ? options.sleepFn : defaultSnapshotSleep; + const backoffMs = typeof options.backoffMs === "function" ? options.backoffMs : defaultRetryBackoffMs; + + let snapshot: LiveIssueSnapshot | null = null; + let competing: ObservedClaim[] = []; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + let current: LiveIssueSnapshot | null; + try { + current = await deps.fetchLiveIssueSnapshot(input.repoFullName, input.issueNumber); + } catch { + current = null; + } + if (current && typeof current === "object") { + snapshot = current; + competing = assembleCompetingClaims(current, input.selfPrNumber, input.minerLogin); + // A competing claim observed = GitHub's index has propagated it; stop retrying and act on it now. + if (competing.length > 0) break; + } + // Back off before the next attempt (index-propagation lag / a transient fetch failure); never after the last. + if (attempt < maxAttempts) await sleepFn(backoffMs(attempt)); + } + if (!snapshot) { + return { checked: false, reason: "live_state_unavailable" }; + } + + const adjudication = adjudicateSoftClaim({ number: input.selfPrNumber, claimedAt: input.selfClaimedAt }, competing); + + if (adjudication.isWinner) { + return { checked: true, isWinner: true, winnerNumber: adjudication.winnerNumber, competingCount: competing.length }; + } + + const comment = adjudication.winnerNumber + ? `Closing this PR: pull request #${adjudication.winnerNumber} claimed this issue first. This is an automated soft-claim conflict resolution -- no action needed from you.` + : `Closing this PR: another open pull request already claims this issue. This is an automated soft-claim conflict resolution -- no action needed from you.`; + const spec = buildClosePrSpec({ repoFullName: input.repoFullName, number: input.selfPrNumber, comment }); + const closeResult = await deps.executeLocalWrite(spec); + + return { + checked: true, + isWinner: false, + winnerNumber: adjudication.winnerNumber, + competingCount: competing.length, + closeResult, + }; +} diff --git a/packages/loopover-miner/lib/harness-submission-trigger.d.ts b/packages/loopover-miner/lib/harness-submission-trigger.d.ts index ebe0ed48e7..5dfc0854e4 100644 --- a/packages/loopover-miner/lib/harness-submission-trigger.d.ts +++ b/packages/loopover-miner/lib/harness-submission-trigger.d.ts @@ -1,69 +1,115 @@ -export const HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT: "harness_submission_trigger_decision"; - +export declare const HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT = "harness_submission_trigger_decision"; export type HarnessSubmissionSlopBand = "clean" | "low" | "elevated" | "high"; export type HarnessSubmissionMode = "observe" | "enforce"; export type HarnessSubmissionKillSwitchScope = "global" | "repo" | "none"; - export type HarnessSubmissionCandidateInput = { - /** Forwarded to shouldSubmit's own kill-switch check (#2339). */ - killSwitchScope: HarnessSubmissionKillSwitchScope; - repoFullName: string; - handoffPacket: { - worktreePath: string; - branchRef?: string; - diffSummary: string; - selfReviewVerdict: unknown; - attemptLogReference: string; - }; - slopThreshold: HarnessSubmissionSlopBand; - mode: HarnessSubmissionMode; - maxConsecutiveGateBlocks?: number; + /** Forwarded to shouldSubmit's own kill-switch check (#2339). */ + killSwitchScope: HarnessSubmissionKillSwitchScope; + repoFullName: string; + handoffPacket: { + worktreePath: string; + branchRef?: string; + diffSummary: string; + selfReviewVerdict: unknown; + attemptLogReference: string; + }; + slopThreshold: HarnessSubmissionSlopBand; + mode: HarnessSubmissionMode; + maxConsecutiveGateBlocks?: number; }; - export interface HarnessSubmissionEventLedger { - 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 type HarnessSubmissionDeps = { - eventLedger: HarnessSubmissionEventLedger; - sessionStartMs?: number; + eventLedger: HarnessSubmissionEventLedger; + sessionStartMs?: number; }; - export type HarnessSubmissionDecision = { - allow: boolean; - reasons: string[]; - circuitBreakerTripped: boolean; + allow: boolean; + reasons: string[]; + circuitBreakerTripped: boolean; }; - export type HarnessSubmissionResult = { - decision: HarnessSubmissionDecision; - event: { id: number; seq: number; type: string; repoFullName: string | null; payload: Record; createdAt: string }; + decision: HarnessSubmissionDecision; + event: { + id: number; + seq: number; + type: string; + repoFullName: string | null; + payload: Record; + createdAt: string; + }; }; - -export function countConsecutiveGateBlocks(eventLedger: HarnessSubmissionEventLedger, sinceMs: number): number; - -export function evaluateAndRecordHarnessSubmissionTrigger(candidate: HarnessSubmissionCandidateInput, deps: HarnessSubmissionDeps): HarnessSubmissionResult; - +/** Count consecutive `allow: false` decisions recorded at or after `sinceMs`, walking backward from the most + * recent decision until an `allow: true` breaks the streak (or history runs out). Session-scoped (not + * filtered by repo) to match the circuit breaker's own "pauses the run entirely" semantics. */ +export declare function countConsecutiveGateBlocks(eventLedger: HarnessSubmissionEventLedger, sinceMs: number): number; +/** + * Evaluate the harness submission trigger for one candidate handoff, reading real session history to compute + * the circuit-breaker tally, and always appending exactly one audit event. Fails closed (throws) on a + * malformed candidate or missing required dependency. + */ +export declare function evaluateAndRecordHarnessSubmissionTrigger(candidate: HarnessSubmissionCandidateInput, deps: HarnessSubmissionDeps): HarnessSubmissionResult; /** The exact input shape buildOpenPrSpec (`@loopover/engine`) expects. */ export type OpenPrInput = { - repoFullName: string; - base: string; - head: string; - title: string; - body: string; - draft: boolean; + repoFullName: string; + base: string; + head: string; + title: string; + body: string; + draft: boolean; }; - export type PrepareOpenPrSubmissionCandidate = HarnessSubmissionCandidateInput & { - base: string; - title: string; - body?: string; - draft?: boolean; + base: string; + title: string; + body?: string; + draft?: boolean; }; - -export type PrepareOpenPrSubmissionResult = - | { ready: true; decision: HarnessSubmissionDecision; event: HarnessSubmissionResult["event"]; openPrInput: OpenPrInput } - | { ready: false; decision: HarnessSubmissionDecision; event: HarnessSubmissionResult["event"] }; - -export function prepareOpenPrSubmission(candidate: PrepareOpenPrSubmissionCandidate, deps: HarnessSubmissionDeps): PrepareOpenPrSubmissionResult; +export type PrepareOpenPrSubmissionResult = { + ready: true; + decision: HarnessSubmissionDecision; + event: HarnessSubmissionResult["event"]; + openPrInput: OpenPrInput; +} | { + ready: false; + decision: HarnessSubmissionDecision; + event: HarnessSubmissionResult["event"]; +}; +/** + * Bridge one completed handoff through the submission gate to a submission-READY payload -- the exact input + * shape `buildOpenPrSpec` (`@loopover/engine`) expects (repoFullName/base/head/title/body/draft). On `allow: + * true` returns `{ ready: true, decision, event, openPrInput }`; otherwise `{ ready: false, decision, event }` + * -- the block reasons are on `decision.reasons` and already on the ledger via the wrapped call either way. + * Does NOT call `buildOpenPrSpec` itself: this stays a gate→payload bridge; `attempt-runner.js` (and MCP + * `loopover_open_pr` equivalents) take `openPrInput` from a `ready: true` result and call + * `buildOpenPrSpec`. The cross-package "unreachable from root src/" reason no longer applies (#5131/#5132 + * moved the builder into `@loopover/engine`), but the deliberate non-call layering is still necessary. + * + * Fails closed (throws) on a malformed candidate, mirroring evaluateAndRecordHarnessSubmissionTrigger's own + * validation -- a missing PR title/base is a caller bug that must never silently degrade into a garbage spec. + * The one field evaluateAndRecordHarnessSubmissionTrigger does NOT itself require -- handoffPacket.branchRef, + * optional there because iterate-loop.ts deliberately does not manage worktrees/branches -- IS required here, + * but only once the decision is known to be `allow: true`: a PR cannot be opened without a source branch, but a + * blocked candidate needs no branch at all, and must not throw for a reason unrelated to why it was blocked. + */ +export declare function prepareOpenPrSubmission(candidate: PrepareOpenPrSubmissionCandidate, deps: HarnessSubmissionDeps): PrepareOpenPrSubmissionResult; diff --git a/packages/loopover-miner/lib/harness-submission-trigger.js b/packages/loopover-miner/lib/harness-submission-trigger.js index ae1f923a59..fc91723fc3 100644 --- a/packages/loopover-miner/lib/harness-submission-trigger.js +++ b/packages/loopover-miner/lib/harness-submission-trigger.js @@ -1,5 +1,4 @@ import { evaluateHarnessSubmissionTrigger } from "@loopover/engine"; - // Harness submission-gate wiring orchestrator (#2337): the real-IO half of connecting the gated-submission // decision (`shouldSubmit`, wrapped by `evaluateHarnessSubmissionTrigger`, @loopover/engine) to a // real driving loop's own handoff signal. Reads the session's recent decision history to compute the @@ -21,72 +20,66 @@ import { evaluateHarnessSubmissionTrigger } from "@loopover/engine"; // counted across EVERY repo's decisions this session, not scoped to one repo -- distinct from #2338's loop- // reentry circuit breaker, which is deliberately per-repo (a rejection streak on one repo must not pause // unrelated repos). - export const HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT = "harness_submission_trigger_decision"; - /** Count consecutive `allow: false` decisions recorded at or after `sinceMs`, walking backward from the most * recent decision until an `allow: true` breaks the streak (or history runs out). Session-scoped (not * filtered by repo) to match the circuit breaker's own "pauses the run entirely" semantics. */ export function countConsecutiveGateBlocks(eventLedger, sinceMs) { - const decisions = eventLedger - .readEvents({}) - .filter((event) => event.type === HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT && Date.parse(event.createdAt) >= sinceMs); - let count = 0; - for (let i = decisions.length - 1; i >= 0; i -= 1) { - if (decisions[i].payload?.allow === true) break; - count += 1; - } - return count; + const decisions = eventLedger + .readEvents({}) + .filter((event) => event.type === HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT && Date.parse(event.createdAt) >= sinceMs); + let count = 0; + for (let i = decisions.length - 1; i >= 0; i -= 1) { + if (decisions[i]?.payload?.allow === true) + break; + count += 1; + } + return count; } - /** * Evaluate the harness submission trigger for one candidate handoff, reading real session history to compute * the circuit-breaker tally, and always appending exactly one audit event. Fails closed (throws) on a * malformed candidate or missing required dependency. - * - * @param {{ killSwitchScope: "global"|"repo"|"none", repoFullName: string, handoffPacket: object, slopThreshold: "clean"|"low"|"elevated"|"high", mode: "observe"|"enforce", maxConsecutiveGateBlocks?: number }} candidate - * @param {{ eventLedger: object, sessionStartMs?: number }} deps */ export function evaluateAndRecordHarnessSubmissionTrigger(candidate, deps) { - if (!candidate || typeof candidate !== "object") throw new Error("invalid_harness_submission_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 (!candidate.handoffPacket || typeof candidate.handoffPacket !== "object") throw new Error("invalid_handoff_packet"); - - if (!deps || typeof deps !== "object") throw new Error("invalid_harness_submission_deps"); - const { eventLedger, sessionStartMs = 0 } = deps; - if (!eventLedger || typeof eventLedger.appendEvent !== "function" || typeof eventLedger.readEvents !== "function") { - throw new Error("invalid_event_ledger"); - } - - const consecutiveGateBlocks = countConsecutiveGateBlocks(eventLedger, sessionStartMs); - - const decision = evaluateHarnessSubmissionTrigger({ - killSwitchScope: candidate.killSwitchScope, - handoffPacket: candidate.handoffPacket, - slopThreshold: candidate.slopThreshold, - mode: candidate.mode, - consecutiveGateBlocks, - maxConsecutiveGateBlocks: candidate.maxConsecutiveGateBlocks, - }); - - const event = eventLedger.appendEvent({ - type: HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT, - repoFullName, - payload: { - killSwitchScope: candidate.killSwitchScope, - allow: decision.allow, - reasons: decision.reasons, - circuitBreakerTripped: decision.circuitBreakerTripped, - consecutiveGateBlocks, - attemptLogReference: candidate.handoffPacket.attemptLogReference ?? null, - }, - }); - - return { decision, event }; + if (!candidate || typeof candidate !== "object") + throw new Error("invalid_harness_submission_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 (!candidate.handoffPacket || typeof candidate.handoffPacket !== "object") + throw new Error("invalid_handoff_packet"); + if (!deps || typeof deps !== "object") + throw new Error("invalid_harness_submission_deps"); + const { eventLedger, sessionStartMs = 0 } = deps; + if (!eventLedger || typeof eventLedger.appendEvent !== "function" || typeof eventLedger.readEvents !== "function") { + throw new Error("invalid_event_ledger"); + } + const consecutiveGateBlocks = countConsecutiveGateBlocks(eventLedger, sessionStartMs); + const decision = evaluateHarnessSubmissionTrigger({ + killSwitchScope: candidate.killSwitchScope, + handoffPacket: candidate.handoffPacket, + slopThreshold: candidate.slopThreshold, + mode: candidate.mode, + consecutiveGateBlocks, + maxConsecutiveGateBlocks: candidate.maxConsecutiveGateBlocks, + }); + const event = eventLedger.appendEvent({ + type: HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT, + repoFullName, + payload: { + killSwitchScope: candidate.killSwitchScope, + allow: decision.allow, + reasons: decision.reasons, + circuitBreakerTripped: decision.circuitBreakerTripped, + consecutiveGateBlocks, + attemptLogReference: candidate.handoffPacket.attemptLogReference ?? null, + }, + }); + return { decision, event }; } - /** * Bridge one completed handoff through the submission gate to a submission-READY payload -- the exact input * shape `buildOpenPrSpec` (`@loopover/engine`) expects (repoFullName/base/head/title/body/draft). On `allow: @@ -103,36 +96,36 @@ export function evaluateAndRecordHarnessSubmissionTrigger(candidate, deps) { * optional there because iterate-loop.ts deliberately does not manage worktrees/branches -- IS required here, * but only once the decision is known to be `allow: true`: a PR cannot be opened without a source branch, but a * blocked candidate needs no branch at all, and must not throw for a reason unrelated to why it was blocked. - * - * @param {{ killSwitchScope: "global"|"repo"|"none", repoFullName: string, handoffPacket: { branchRef?: string, [key: string]: unknown }, slopThreshold: "clean"|"low"|"elevated"|"high", mode: "observe"|"enforce", maxConsecutiveGateBlocks?: number, base: string, title: string, body?: string, draft?: boolean }} candidate - * @param {{ eventLedger: object, sessionStartMs?: number }} deps */ export function prepareOpenPrSubmission(candidate, deps) { - if (!candidate || typeof candidate !== "object") throw new Error("invalid_harness_submission_candidate"); - const base = typeof candidate.base === "string" ? candidate.base.trim() : ""; - if (!base) throw new Error("invalid_pr_base"); - const title = typeof candidate.title === "string" ? candidate.title.trim() : ""; - if (!title) throw new Error("invalid_pr_title"); - - const { decision, event } = evaluateAndRecordHarnessSubmissionTrigger(candidate, deps); - if (!decision.allow) return { ready: false, decision, event }; - - // Only reached once evaluateAndRecordHarnessSubmissionTrigger has already validated handoffPacket is a - // well-formed object -- safe to read .branchRef directly. - const head = typeof candidate.handoffPacket.branchRef === "string" ? candidate.handoffPacket.branchRef.trim() : ""; - if (!head) throw new Error("invalid_pr_head_branch"); - - return { - ready: true, - decision, - event, - openPrInput: { - repoFullName: candidate.repoFullName.trim(), - base, - head, - title, - body: typeof candidate.body === "string" ? candidate.body : "", - draft: candidate.draft === true, - }, - }; + if (!candidate || typeof candidate !== "object") + throw new Error("invalid_harness_submission_candidate"); + const base = typeof candidate.base === "string" ? candidate.base.trim() : ""; + if (!base) + throw new Error("invalid_pr_base"); + const title = typeof candidate.title === "string" ? candidate.title.trim() : ""; + if (!title) + throw new Error("invalid_pr_title"); + const { decision, event } = evaluateAndRecordHarnessSubmissionTrigger(candidate, deps); + if (!decision.allow) + return { ready: false, decision, event }; + // Only reached once evaluateAndRecordHarnessSubmissionTrigger has already validated handoffPacket is a + // well-formed object -- safe to read .branchRef directly. + const head = typeof candidate.handoffPacket.branchRef === "string" ? candidate.handoffPacket.branchRef.trim() : ""; + if (!head) + throw new Error("invalid_pr_head_branch"); + return { + ready: true, + decision, + event, + openPrInput: { + repoFullName: candidate.repoFullName.trim(), + base, + head, + title, + body: typeof candidate.body === "string" ? candidate.body : "", + draft: candidate.draft === true, + }, + }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaGFybmVzcy1zdWJtaXNzaW9uLXRyaWdnZXIuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJoYXJuZXNzLXN1Ym1pc3Npb24tdHJpZ2dlci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEVBQUUsZ0NBQWdDLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUdwRSwyR0FBMkc7QUFDM0csa0dBQWtHO0FBQ2xHLHFHQUFxRztBQUNyRyw0R0FBNEc7QUFDNUcsdUdBQXVHO0FBQ3ZHLEVBQUU7QUFDRiwrR0FBK0c7QUFDL0csNEdBQTRHO0FBQzVHLG9GQUFvRjtBQUNwRixnSEFBZ0g7QUFDaEgsNkdBQTZHO0FBQzdHLDhHQUE4RztBQUM5RywwR0FBMEc7QUFDMUcsOEdBQThHO0FBQzlHLHlHQUF5RztBQUN6Ryx5RkFBeUY7QUFDekYsRUFBRTtBQUNGLCtHQUErRztBQUMvRyw0R0FBNEc7QUFDNUcseUdBQXlHO0FBQ3pHLG9CQUFvQjtBQUVwQixNQUFNLENBQUMsTUFBTSx5Q0FBeUMsR0FBRyxxQ0FBcUMsQ0FBQztBQTJDL0Y7O2dHQUVnRztBQUNoRyxNQUFNLFVBQVUsMEJBQTBCLENBQUMsV0FBeUMsRUFBRSxPQUFlO0lBQ25HLE1BQU0sU0FBUyxHQUFHLFdBQVc7U0FDMUIsVUFBVSxDQUFDLEVBQUUsQ0FBQztTQUNkLE1BQU0sQ0FBQyxDQUFDLEtBQUssRUFBRSxFQUFFLENBQUMsS0FBSyxDQUFDLElBQUksS0FBSyx5Q0FBeUMsSUFBSSxJQUFJLENBQUMsS0FBSyxDQUFDLEtBQUssQ0FBQyxTQUFTLENBQUMsSUFBSSxPQUFPLENBQUMsQ0FBQztJQUN6SCxJQUFJLEtBQUssR0FBRyxDQUFDLENBQUM7SUFDZCxLQUFLLElBQUksQ0FBQyxHQUFHLFNBQVMsQ0FBQyxNQUFNLEdBQUcsQ0FBQyxFQUFFLENBQUMsSUFBSSxDQUFDLEVBQUUsQ0FBQyxJQUFJLENBQUMsRUFBRSxDQUFDO1FBQ2xELElBQUksU0FBUyxDQUFDLENBQUMsQ0FBQyxFQUFF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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/harness-submission-trigger.ts b/packages/loopover-miner/lib/harness-submission-trigger.ts new file mode 100644 index 0000000000..f48148a5d8 --- /dev/null +++ b/packages/loopover-miner/lib/harness-submission-trigger.ts @@ -0,0 +1,201 @@ +import { evaluateHarnessSubmissionTrigger } from "@loopover/engine"; +import type { HandoffPacket } from "@loopover/engine"; + +// Harness submission-gate wiring orchestrator (#2337): the real-IO half of connecting the gated-submission +// decision (`shouldSubmit`, wrapped by `evaluateHarnessSubmissionTrigger`, @loopover/engine) to a +// real driving loop's own handoff signal. Reads the session's recent decision history to compute the +// consecutive-block circuit-breaker tally, consults the pure decision, and always records exactly one audit +// event -- regardless of outcome, so a paused-pending-human-review session leaves a full trail of why. +// +// NOT WIRED INTO ANY AUTOMATIC SCHEDULE: per this issue's own "manual owner sign-off on the wiring before this +// ships to any default-on profile" deliverable. `prepareOpenPrSubmission` below is the gate→payload bridge: +// on `allow: true` it shapes the exact input `buildOpenPrSpec` (`@loopover/engine`, +// `packages/loopover-engine/src/miner/local-write-tools.ts`, re-exported from the engine public barrel) expects +// as `openPrInput`. It deliberately does NOT call `buildOpenPrSpec` itself -- that stays the caller's job so +// this module stays a decision-to-payload bridge. The in-package caller is `attempt-runner.js`, which imports +// `buildOpenPrSpec` from `@loopover/engine` and runs it after a `ready: true` result (the pre-#5131/#5132 +// "unreachable from root `src/mcp/`" boundary no longer applies, but the layering still does: gate evaluate → +// shape openPrInput here → build the runnable local-write spec in the driver). Equivalent MCP call sites +// (e.g. `loopover_open_pr`) can likewise take `openPrInput` from a `ready: true` result. +// +// SESSION-SCOPED, NOT PER-REPO: the circuit breaker's own "pauses the run entirely" wording means the tally is +// counted across EVERY repo's decisions this session, not scoped to one repo -- distinct from #2338's loop- +// reentry circuit breaker, which is deliberately per-repo (a rejection streak on one repo must not pause +// unrelated repos). + +export const HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT = "harness_submission_trigger_decision"; + +export type HarnessSubmissionSlopBand = "clean" | "low" | "elevated" | "high"; +export type HarnessSubmissionMode = "observe" | "enforce"; +export type HarnessSubmissionKillSwitchScope = "global" | "repo" | "none"; + +export type HarnessSubmissionCandidateInput = { + /** Forwarded to shouldSubmit's own kill-switch check (#2339). */ + killSwitchScope: HarnessSubmissionKillSwitchScope; + repoFullName: string; + handoffPacket: { + worktreePath: string; + branchRef?: string; + diffSummary: string; + selfReviewVerdict: unknown; + attemptLogReference: string; + }; + slopThreshold: HarnessSubmissionSlopBand; + mode: HarnessSubmissionMode; + maxConsecutiveGateBlocks?: number; +}; + +export interface HarnessSubmissionEventLedger { + 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 type HarnessSubmissionDeps = { + eventLedger: HarnessSubmissionEventLedger; + sessionStartMs?: number; +}; + +export type HarnessSubmissionDecision = { + allow: boolean; + reasons: string[]; + circuitBreakerTripped: boolean; +}; + +export type HarnessSubmissionResult = { + decision: HarnessSubmissionDecision; + event: { id: number; seq: number; type: string; repoFullName: string | null; payload: Record; createdAt: string }; +}; + +/** Count consecutive `allow: false` decisions recorded at or after `sinceMs`, walking backward from the most + * recent decision until an `allow: true` breaks the streak (or history runs out). Session-scoped (not + * filtered by repo) to match the circuit breaker's own "pauses the run entirely" semantics. */ +export function countConsecutiveGateBlocks(eventLedger: HarnessSubmissionEventLedger, sinceMs: number): number { + const decisions = eventLedger + .readEvents({}) + .filter((event) => event.type === HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT && Date.parse(event.createdAt) >= sinceMs); + let count = 0; + for (let i = decisions.length - 1; i >= 0; i -= 1) { + if (decisions[i]?.payload?.allow === true) break; + count += 1; + } + return count; +} + +/** + * Evaluate the harness submission trigger for one candidate handoff, reading real session history to compute + * the circuit-breaker tally, and always appending exactly one audit event. Fails closed (throws) on a + * malformed candidate or missing required dependency. + */ +export function evaluateAndRecordHarnessSubmissionTrigger( + candidate: HarnessSubmissionCandidateInput, + deps: HarnessSubmissionDeps, +): HarnessSubmissionResult { + if (!candidate || typeof candidate !== "object") throw new Error("invalid_harness_submission_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 (!candidate.handoffPacket || typeof candidate.handoffPacket !== "object") throw new Error("invalid_handoff_packet"); + + if (!deps || typeof deps !== "object") throw new Error("invalid_harness_submission_deps"); + const { eventLedger, sessionStartMs = 0 } = deps; + if (!eventLedger || typeof eventLedger.appendEvent !== "function" || typeof eventLedger.readEvents !== "function") { + throw new Error("invalid_event_ledger"); + } + + const consecutiveGateBlocks = countConsecutiveGateBlocks(eventLedger, sessionStartMs); + + const decision = evaluateHarnessSubmissionTrigger({ + killSwitchScope: candidate.killSwitchScope, + handoffPacket: candidate.handoffPacket as HandoffPacket, + slopThreshold: candidate.slopThreshold, + mode: candidate.mode, + consecutiveGateBlocks, + maxConsecutiveGateBlocks: candidate.maxConsecutiveGateBlocks, + }); + + const event = eventLedger.appendEvent({ + type: HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT, + repoFullName, + payload: { + killSwitchScope: candidate.killSwitchScope, + allow: decision.allow, + reasons: decision.reasons, + circuitBreakerTripped: decision.circuitBreakerTripped, + consecutiveGateBlocks, + attemptLogReference: candidate.handoffPacket.attemptLogReference ?? null, + }, + }); + + return { decision, event }; +} + +/** The exact input shape buildOpenPrSpec (`@loopover/engine`) expects. */ +export type OpenPrInput = { + repoFullName: string; + base: string; + head: string; + title: string; + body: string; + draft: boolean; +}; + +export type PrepareOpenPrSubmissionCandidate = HarnessSubmissionCandidateInput & { + base: string; + title: string; + body?: string; + draft?: boolean; +}; + +export type PrepareOpenPrSubmissionResult = + | { ready: true; decision: HarnessSubmissionDecision; event: HarnessSubmissionResult["event"]; openPrInput: OpenPrInput } + | { ready: false; decision: HarnessSubmissionDecision; event: HarnessSubmissionResult["event"] }; + +/** + * Bridge one completed handoff through the submission gate to a submission-READY payload -- the exact input + * shape `buildOpenPrSpec` (`@loopover/engine`) expects (repoFullName/base/head/title/body/draft). On `allow: + * true` returns `{ ready: true, decision, event, openPrInput }`; otherwise `{ ready: false, decision, event }` + * -- the block reasons are on `decision.reasons` and already on the ledger via the wrapped call either way. + * Does NOT call `buildOpenPrSpec` itself: this stays a gate→payload bridge; `attempt-runner.js` (and MCP + * `loopover_open_pr` equivalents) take `openPrInput` from a `ready: true` result and call + * `buildOpenPrSpec`. The cross-package "unreachable from root src/" reason no longer applies (#5131/#5132 + * moved the builder into `@loopover/engine`), but the deliberate non-call layering is still necessary. + * + * Fails closed (throws) on a malformed candidate, mirroring evaluateAndRecordHarnessSubmissionTrigger's own + * validation -- a missing PR title/base is a caller bug that must never silently degrade into a garbage spec. + * The one field evaluateAndRecordHarnessSubmissionTrigger does NOT itself require -- handoffPacket.branchRef, + * optional there because iterate-loop.ts deliberately does not manage worktrees/branches -- IS required here, + * but only once the decision is known to be `allow: true`: a PR cannot be opened without a source branch, but a + * blocked candidate needs no branch at all, and must not throw for a reason unrelated to why it was blocked. + */ +export function prepareOpenPrSubmission( + candidate: PrepareOpenPrSubmissionCandidate, + deps: HarnessSubmissionDeps, +): PrepareOpenPrSubmissionResult { + if (!candidate || typeof candidate !== "object") throw new Error("invalid_harness_submission_candidate"); + const base = typeof candidate.base === "string" ? candidate.base.trim() : ""; + if (!base) throw new Error("invalid_pr_base"); + const title = typeof candidate.title === "string" ? candidate.title.trim() : ""; + if (!title) throw new Error("invalid_pr_title"); + + const { decision, event } = evaluateAndRecordHarnessSubmissionTrigger(candidate, deps); + if (!decision.allow) return { ready: false, decision, event }; + + // Only reached once evaluateAndRecordHarnessSubmissionTrigger has already validated handoffPacket is a + // well-formed object -- safe to read .branchRef directly. + const head = typeof candidate.handoffPacket.branchRef === "string" ? candidate.handoffPacket.branchRef.trim() : ""; + if (!head) throw new Error("invalid_pr_head_branch"); + + return { + ready: true, + decision, + event, + openPrInput: { + repoFullName: candidate.repoFullName.trim(), + base, + head, + title, + body: typeof candidate.body === "string" ? candidate.body : "", + draft: candidate.draft === true, + }, + }; +} diff --git a/packages/loopover-miner/lib/opportunity-ranker.d.ts b/packages/loopover-miner/lib/opportunity-ranker.d.ts index 42738b4c10..2c26eeecd8 100644 --- a/packages/loopover-miner/lib/opportunity-ranker.d.ts +++ b/packages/loopover-miner/lib/opportunity-ranker.d.ts @@ -1,36 +1,29 @@ import type { MinerGoalSpec } from "@loopover/engine"; import type { RawCandidateIssue } from "./opportunity-fanout.js"; - export type RankedCandidateIssue = RawCandidateIssue & { - potential: number; - feasibility: number; - laneFit: number; - freshness: number; - dupRisk: number; - rankScore: number; + potential: number; + feasibility: number; + laneFit: number; + freshness: number; + dupRisk: number; + rankScore: number; }; - export type RankCandidateIssuesOptions = { - nowMs?: number; - highRiskDuplicateClusters?: number; - openPullRequests?: number; - goalSpecsByRepo?: Record; - goalSpecContentByRepo?: Record; + nowMs?: number; + highRiskDuplicateClusters?: number; + openPullRequests?: number; + goalSpecsByRepo?: Record; + goalSpecContentByRepo?: Record; }; - export type RankedCandidateSummary = { - issues: RankedCandidateIssue[]; - skippedInvalid: number; - usedDefaultGoalSpec: boolean; - defaultGoalSpec: MinerGoalSpec; + issues: RankedCandidateIssue[]; + skippedInvalid: number; + usedDefaultGoalSpec: boolean; + defaultGoalSpec: MinerGoalSpec; }; - -export function rankCandidateIssues( - candidates: RawCandidateIssue[], - options?: RankCandidateIssuesOptions, -): RankedCandidateIssue[]; - -export function rankCandidateIssuesWithSummary( - candidates: RawCandidateIssue[], - options?: RankCandidateIssuesOptions, -): RankedCandidateSummary; +/** + * Rank metadata-only fan-out candidates locally. Never clones source, never uploads metadata, and never writes to + * GitHub — it only composes deterministic engine signals and returns the sorted list. + */ +export declare function rankCandidateIssues(candidates: RawCandidateIssue[], options?: RankCandidateIssuesOptions): RankedCandidateIssue[]; +export declare function rankCandidateIssuesWithSummary(candidates: RawCandidateIssue[], options?: RankCandidateIssuesOptions): RankedCandidateSummary; diff --git a/packages/loopover-miner/lib/opportunity-ranker.js b/packages/loopover-miner/lib/opportunity-ranker.js index 993504207c..f58da8b76a 100644 --- a/packages/loopover-miner/lib/opportunity-ranker.js +++ b/packages/loopover-miner/lib/opportunity-ranker.js @@ -1,122 +1,116 @@ -import { - DEFAULT_MINER_GOAL_SPEC, - parseMinerGoalSpecContent, - rankMetadataOpportunities, -} from "@loopover/engine"; - +import { DEFAULT_MINER_GOAL_SPEC, parseMinerGoalSpecContent, rankMetadataOpportunities, } from "@loopover/engine"; function finiteEpochMs(value) { - return Number.isFinite(value) ? value : Date.now(); + return Number.isFinite(value) ? value : Date.now(); } - function finiteNonNegativeInt(value) { - if (!Number.isFinite(value)) return 0; - return Math.max(0, Math.floor(value)); + if (!Number.isFinite(value)) + return 0; + return Math.max(0, Math.floor(value)); } - function normalizeCandidate(candidate) { - if (!candidate || typeof candidate !== "object") return null; - const repoFullName = - typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; - const issueNumber = candidate.issueNumber; - const title = typeof candidate.title === "string" ? candidate.title.trim() : ""; - const [owner, repo, extra] = repoFullName.split("/"); - if (!owner || !repo || extra !== undefined) return null; - if (!Number.isInteger(issueNumber) || issueNumber <= 0 || !title) return null; - const canonicalRepoFullName = `${owner}/${repo}`; - const labels = Array.isArray(candidate.labels) - ? candidate.labels - .filter((label) => typeof label === "string" && label.trim()) - .map((label) => label.trim()) - : []; - return { - owner, - repo, - repoFullName: canonicalRepoFullName, - issueNumber, - title, - labels, - commentsCount: Number.isFinite(candidate.commentsCount) ? candidate.commentsCount : 0, - createdAt: typeof candidate.createdAt === "string" ? candidate.createdAt : null, - updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : null, - htmlUrl: typeof candidate.htmlUrl === "string" ? candidate.htmlUrl : null, - aiPolicyAllowed: candidate.aiPolicyAllowed !== false, - aiPolicySource: - candidate.aiPolicySource === "AI-USAGE.md" || - candidate.aiPolicySource === "CONTRIBUTING.md" || - candidate.aiPolicySource === "none" - ? candidate.aiPolicySource - : "none", - }; + if (!candidate || typeof candidate !== "object") + return null; + const c = candidate; + const repoFullName = typeof c.repoFullName === "string" ? c.repoFullName.trim() : ""; + const issueNumber = c.issueNumber; + const title = typeof c.title === "string" ? c.title.trim() : ""; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) + return null; + if (!Number.isInteger(issueNumber) || issueNumber <= 0 || !title) + return null; + const canonicalRepoFullName = `${owner}/${repo}`; + const labels = Array.isArray(c.labels) + ? c.labels + .filter((label) => typeof label === "string" && label.trim()) + .map((label) => label.trim()) + : []; + return { + owner, + repo, + repoFullName: canonicalRepoFullName, + issueNumber: issueNumber, + title, + labels, + commentsCount: Number.isFinite(c.commentsCount) ? c.commentsCount : 0, + createdAt: typeof c.createdAt === "string" ? c.createdAt : null, + updatedAt: typeof c.updatedAt === "string" ? c.updatedAt : null, + htmlUrl: typeof c.htmlUrl === "string" ? c.htmlUrl : null, + aiPolicyAllowed: c.aiPolicyAllowed !== false, + aiPolicySource: c.aiPolicySource === "AI-USAGE.md" || + c.aiPolicySource === "CONTRIBUTING.md" || + c.aiPolicySource === "none" + ? c.aiPolicySource + : "none", + }; } - function buildGoalSpecsByRepo(options = {}) { - const goalSpecsByRepo = { ...(options.goalSpecsByRepo ?? {}) }; - const rawContentByRepo = options.goalSpecContentByRepo ?? {}; - for (const [repoFullName, content] of Object.entries(rawContentByRepo)) { - if (typeof content !== "string" || !content.trim()) continue; - goalSpecsByRepo[repoFullName] = parseMinerGoalSpecContent(content).spec; - } - return goalSpecsByRepo; + const goalSpecsByRepo = { ...(options.goalSpecsByRepo ?? {}) }; + const rawContentByRepo = options.goalSpecContentByRepo ?? {}; + for (const [repoFullName, content] of Object.entries(rawContentByRepo)) { + if (typeof content !== "string" || !content.trim()) + continue; + goalSpecsByRepo[repoFullName] = parseMinerGoalSpecContent(content).spec; + } + return goalSpecsByRepo; } - function buildRankContext(options = {}) { - return { - nowMs: finiteEpochMs(options.nowMs), - highRiskDuplicateClusters: finiteNonNegativeInt(options.highRiskDuplicateClusters), - openPullRequests: finiteNonNegativeInt(options.openPullRequests), - goalSpecsByRepo: buildGoalSpecsByRepo(options), - }; + return { + nowMs: finiteEpochMs(options.nowMs), + highRiskDuplicateClusters: finiteNonNegativeInt(options.highRiskDuplicateClusters), + openPullRequests: finiteNonNegativeInt(options.openPullRequests), + goalSpecsByRepo: buildGoalSpecsByRepo(options), + }; } - function collectCandidates(candidates) { - const input = Array.isArray(candidates) ? candidates : []; - let skippedInvalid = 0; - const normalized = []; - const seen = new Set(); - for (const candidate of input) { - const entry = normalizeCandidate(candidate); - if (!entry) { - skippedInvalid += 1; - continue; + const input = Array.isArray(candidates) ? candidates : []; + let skippedInvalid = 0; + const normalized = []; + const seen = new Set(); + for (const candidate of input) { + const entry = normalizeCandidate(candidate); + if (!entry) { + skippedInvalid += 1; + continue; + } + const key = `${entry.repoFullName.toLowerCase()}#${entry.issueNumber}`; + if (seen.has(key)) + continue; + seen.add(key); + normalized.push(entry); } - const key = `${entry.repoFullName.toLowerCase()}#${entry.issueNumber}`; - if (seen.has(key)) continue; - seen.add(key); - normalized.push(entry); - } - return { normalized, skippedInvalid }; + return { normalized, skippedInvalid }; } - function rankedUsesDefaultGoalSpec(ranked, options = {}) { - const goalSpecsByRepo = buildGoalSpecsByRepo(options); - const specRepos = Object.keys(goalSpecsByRepo); - if (ranked.length === 0) return specRepos.length === 0; - // The "ranked with the built-in default goal spec (no per-tenant .loopover-miner.yml supplied)" note is only - // truthful when the WHOLE batch fell back to the default -- so require EVERY ranked repo to lack a supplied spec, - // not just any one of them (#7226). With `.some`, a single spec-less repo made a mixed batch (where other repos - // genuinely had a spec supplied and applied) print the blanket note as if none did. - return ranked.every((issue) => { - const target = issue.repoFullName.trim().toLowerCase(); - return !specRepos.some((repo) => repo.trim().toLowerCase() === target); - }); + const goalSpecsByRepo = buildGoalSpecsByRepo(options); + const specRepos = Object.keys(goalSpecsByRepo); + if (ranked.length === 0) + return specRepos.length === 0; + // The "ranked with the built-in default goal spec (no per-tenant .loopover-miner.yml supplied)" note is only + // truthful when the WHOLE batch fell back to the default -- so require EVERY ranked repo to lack a supplied spec, + // not just any one of them (#7226). With `.some`, a single spec-less repo made a mixed batch (where other repos + // genuinely had a spec supplied and applied) print the blanket note as if none did. + return ranked.every((issue) => { + const target = issue.repoFullName.trim().toLowerCase(); + return !specRepos.some((repo) => repo.trim().toLowerCase() === target); + }); } - /** * Rank metadata-only fan-out candidates locally. Never clones source, never uploads metadata, and never writes to * GitHub — it only composes deterministic engine signals and returns the sorted list. */ export function rankCandidateIssues(candidates, options = {}) { - const { normalized } = collectCandidates(candidates); - return rankMetadataOpportunities(normalized, buildRankContext(options)); + const { normalized } = collectCandidates(candidates); + return rankMetadataOpportunities(normalized, buildRankContext(options)); } - export function rankCandidateIssuesWithSummary(candidates, options = {}) { - const { normalized, skippedInvalid } = collectCandidates(candidates); - const ranked = rankMetadataOpportunities(normalized, buildRankContext(options)); - return { - issues: ranked, - skippedInvalid, - usedDefaultGoalSpec: rankedUsesDefaultGoalSpec(ranked, options), - defaultGoalSpec: DEFAULT_MINER_GOAL_SPEC, - }; + const { normalized, skippedInvalid } = collectCandidates(candidates); + const ranked = rankMetadataOpportunities(normalized, buildRankContext(options)); + return { + issues: ranked, + skippedInvalid, + usedDefaultGoalSpec: rankedUsesDefaultGoalSpec(ranked, options), + defaultGoalSpec: DEFAULT_MINER_GOAL_SPEC, + }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoib3Bwb3J0dW5pdHktcmFua2VyLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsib3Bwb3J0dW5pdHktcmFua2VyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFDTCx1QkFBdUIsRUFDdkIseUJBQXlCLEVBQ3pCLHlCQUF5QixHQUMxQixNQUFNLGtCQUFrQixDQUFDO0FBNkMxQixTQUFTLGFBQWEsQ0FBQyxLQUF5QjtJQUM5QyxPQUFPLE1BQU0sQ0FBQyxRQUFRLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQyxDQUFFLEtBQWdCLENBQUMsQ0FBQyxDQUFDLElBQUksQ0FBQyxHQUFHLEVBQUUsQ0FBQztBQUNqRSxDQUFDO0FBRUQsU0FBUyxvQkFBb0IsQ0FBQyxLQUF5QjtJQUNyRCxJQUFJLENBQUMsTUFBTSxDQUFDLFFBQVEsQ0FBQyxLQUFLLENBQUM7UUFBRSxPQUFPLENBQUMsQ0FBQztJQUN0QyxPQUFPLElBQUksQ0FBQyxHQUFHLENBQUMsQ0FBQyxFQUFFLElBQUksQ0FBQyxLQUFLLENBQUMsS0FBZSxDQUFDLENBQUMsQ0FBQztBQUNsRCxDQUFDO0FBRUQsU0FBUyxrQkFBa0IsQ0FBQyxTQUFrQjtJQUM1QyxJQUFJLENBQUMsU0FBUyxJQUFJLE9BQU8sU0FBUyxLQUFLLFFBQVE7UUFBRSxPQUFPLElBQUksQ0FBQztJQUM3RCxNQUFNLENBQUMsR0FBRyxTQUFvQyxDQUFDO0lBQy9DLE1BQU0sWUFBWSxHQUNoQixPQUFPLENBQUMsQ0FBQyxZQUFZLEtBQUssUUFBUSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsWUFBWSxDQUFDLElBQUksRUFBRSxDQUFDLENBQUMsQ0FBQyxFQUFFLENBQUM7SUFDbEUsTUFBTSxXQUFXLEdBQUcsQ0FBQyxDQUFDLFdBQVcsQ0FBQztJQUNsQyxNQUFNLEtBQUssR0FBRyxPQUFPLENBQUMsQ0FBQyxLQUFLLEtBQUssUUFBUSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsS0FBSyxDQUFDLElBQUksRUFB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/opportunity-ranker.ts b/packages/loopover-miner/lib/opportunity-ranker.ts new file mode 100644 index 0000000000..d4b010f9f6 --- /dev/null +++ b/packages/loopover-miner/lib/opportunity-ranker.ts @@ -0,0 +1,178 @@ +import { + DEFAULT_MINER_GOAL_SPEC, + parseMinerGoalSpecContent, + rankMetadataOpportunities, +} from "@loopover/engine"; +import type { MetadataRankContext, MinerGoalSpec } from "@loopover/engine"; +import type { RawCandidateIssue } from "./opportunity-fanout.js"; + +export type RankedCandidateIssue = RawCandidateIssue & { + potential: number; + feasibility: number; + laneFit: number; + freshness: number; + dupRisk: number; + rankScore: number; +}; + +export type RankCandidateIssuesOptions = { + nowMs?: number; + highRiskDuplicateClusters?: number; + openPullRequests?: number; + goalSpecsByRepo?: Record; + goalSpecContentByRepo?: Record; +}; + +export type RankedCandidateSummary = { + issues: RankedCandidateIssue[]; + skippedInvalid: number; + usedDefaultGoalSpec: boolean; + defaultGoalSpec: MinerGoalSpec; +}; + +/** Internal metadata-only candidate shape produced by {@link normalizeCandidate}; satisfies the engine's + * `MetadataCandidateIssue` constraint that `rankMetadataOpportunities` ranks over. */ +type NormalizedCandidate = { + owner: string; + repo: string; + repoFullName: string; + issueNumber: number; + title: string; + labels: string[]; + commentsCount: number; + createdAt: string | null; + updatedAt: string | null; + htmlUrl: string | null; + aiPolicyAllowed: boolean; + aiPolicySource: "AI-USAGE.md" | "CONTRIBUTING.md" | "none"; +}; + +function finiteEpochMs(value: number | undefined): number { + return Number.isFinite(value) ? (value as number) : Date.now(); +} + +function finiteNonNegativeInt(value: number | undefined): number { + if (!Number.isFinite(value)) return 0; + return Math.max(0, Math.floor(value as number)); +} + +function normalizeCandidate(candidate: unknown): NormalizedCandidate | null { + if (!candidate || typeof candidate !== "object") return null; + const c = candidate as Record; + const repoFullName = + typeof c.repoFullName === "string" ? c.repoFullName.trim() : ""; + const issueNumber = c.issueNumber; + const title = typeof c.title === "string" ? c.title.trim() : ""; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) return null; + if (!Number.isInteger(issueNumber) || (issueNumber as number) <= 0 || !title) return null; + const canonicalRepoFullName = `${owner}/${repo}`; + const labels = Array.isArray(c.labels) + ? c.labels + .filter((label) => typeof label === "string" && label.trim()) + .map((label) => label.trim()) + : []; + return { + owner, + repo, + repoFullName: canonicalRepoFullName, + issueNumber: issueNumber as number, + title, + labels, + commentsCount: Number.isFinite(c.commentsCount) ? (c.commentsCount as number) : 0, + createdAt: typeof c.createdAt === "string" ? c.createdAt : null, + updatedAt: typeof c.updatedAt === "string" ? c.updatedAt : null, + htmlUrl: typeof c.htmlUrl === "string" ? c.htmlUrl : null, + aiPolicyAllowed: c.aiPolicyAllowed !== false, + aiPolicySource: + c.aiPolicySource === "AI-USAGE.md" || + c.aiPolicySource === "CONTRIBUTING.md" || + c.aiPolicySource === "none" + ? c.aiPolicySource + : "none", + }; +} + +function buildGoalSpecsByRepo(options: RankCandidateIssuesOptions = {}): Record { + const goalSpecsByRepo: Record = { ...(options.goalSpecsByRepo ?? {}) }; + const rawContentByRepo = options.goalSpecContentByRepo ?? {}; + for (const [repoFullName, content] of Object.entries(rawContentByRepo)) { + if (typeof content !== "string" || !content.trim()) continue; + goalSpecsByRepo[repoFullName] = parseMinerGoalSpecContent(content).spec; + } + return goalSpecsByRepo; +} + +function buildRankContext(options: RankCandidateIssuesOptions = {}): MetadataRankContext { + return { + nowMs: finiteEpochMs(options.nowMs), + highRiskDuplicateClusters: finiteNonNegativeInt(options.highRiskDuplicateClusters), + openPullRequests: finiteNonNegativeInt(options.openPullRequests), + goalSpecsByRepo: buildGoalSpecsByRepo(options), + }; +} + +function collectCandidates(candidates: unknown): { + normalized: NormalizedCandidate[]; + skippedInvalid: number; +} { + const input = Array.isArray(candidates) ? candidates : []; + let skippedInvalid = 0; + const normalized: NormalizedCandidate[] = []; + const seen = new Set(); + for (const candidate of input) { + const entry = normalizeCandidate(candidate); + if (!entry) { + skippedInvalid += 1; + continue; + } + const key = `${entry.repoFullName.toLowerCase()}#${entry.issueNumber}`; + if (seen.has(key)) continue; + seen.add(key); + normalized.push(entry); + } + return { normalized, skippedInvalid }; +} + +function rankedUsesDefaultGoalSpec( + ranked: RankedCandidateIssue[], + options: RankCandidateIssuesOptions = {}, +): boolean { + const goalSpecsByRepo = buildGoalSpecsByRepo(options); + const specRepos = Object.keys(goalSpecsByRepo); + if (ranked.length === 0) return specRepos.length === 0; + // The "ranked with the built-in default goal spec (no per-tenant .loopover-miner.yml supplied)" note is only + // truthful when the WHOLE batch fell back to the default -- so require EVERY ranked repo to lack a supplied spec, + // not just any one of them (#7226). With `.some`, a single spec-less repo made a mixed batch (where other repos + // genuinely had a spec supplied and applied) print the blanket note as if none did. + return ranked.every((issue) => { + const target = issue.repoFullName.trim().toLowerCase(); + return !specRepos.some((repo) => repo.trim().toLowerCase() === target); + }); +} + +/** + * Rank metadata-only fan-out candidates locally. Never clones source, never uploads metadata, and never writes to + * GitHub — it only composes deterministic engine signals and returns the sorted list. + */ +export function rankCandidateIssues( + candidates: RawCandidateIssue[], + options: RankCandidateIssuesOptions = {}, +): RankedCandidateIssue[] { + const { normalized } = collectCandidates(candidates); + return rankMetadataOpportunities(normalized, buildRankContext(options)) as RankedCandidateIssue[]; +} + +export function rankCandidateIssuesWithSummary( + candidates: RawCandidateIssue[], + options: RankCandidateIssuesOptions = {}, +): RankedCandidateSummary { + const { normalized, skippedInvalid } = collectCandidates(candidates); + const ranked = rankMetadataOpportunities(normalized, buildRankContext(options)) as RankedCandidateIssue[]; + return { + issues: ranked, + skippedInvalid, + usedDefaultGoalSpec: rankedUsesDefaultGoalSpec(ranked, options), + defaultGoalSpec: DEFAULT_MINER_GOAL_SPEC, + }; +} diff --git a/packages/loopover-miner/lib/portfolio-queue-manager.d.ts b/packages/loopover-miner/lib/portfolio-queue-manager.d.ts index 8f7cb8937b..42877b35de 100644 --- a/packages/loopover-miner/lib/portfolio-queue-manager.d.ts +++ b/packages/loopover-miner/lib/portfolio-queue-manager.d.ts @@ -1,50 +1,56 @@ import type { PortfolioCaps } from "@loopover/engine"; import type { EnqueueItem, PortfolioQueueStore, QueueEntry } from "./portfolio-queue.js"; - export type PortfolioQueueClaimTarget = { - apiBaseUrl: string; - repoFullName: string; - identifier: string; -}; - -export function queueItemId(apiBaseUrl: string, repoFullName: string, identifier: string): string; - -export function parseQueueItemId(id: string): PortfolioQueueClaimTarget; - -export function normalizePortfolioCaps(caps?: Partial): PortfolioCaps; - -export function entriesToPortfolioQueue(entries: QueueEntry[]): { - buckets: Array<{ + apiBaseUrl: string; repoFullName: string; - items: Array<{ id: string; repoFullName: string; state: "queued" | "in_progress" }>; - }>; + identifier: string; +}; +/** + * Stable composite id for projecting SQLite rows into the engine's PortfolioQueueItem shape. Encodes apiBaseUrl + * too (#5563) — the engine's own selection logic has no forge dimension, but two hosts can now enqueue an item + * under the same repoFullName+identifier (post-#5563 scoping), and the id is the ONLY thing selectEligibleBatch's + * output threads back to batchClaim; without the host baked in here, a selected item's host would be lost and + * batchClaim would default to github.com, potentially claiming a DIFFERENT row than the one the engine selected. + */ +export declare function queueItemId(apiBaseUrl: string, repoFullName: string, identifier: string): string; +/** Reverse {@link queueItemId} after engine selection so claims can target SQLite primary keys. */ +export declare function parseQueueItemId(id: string): PortfolioQueueClaimTarget; +/** Coerce caps to finite non-negative integers (mirrors the engine's normalizeCaps posture). */ +export declare function normalizePortfolioCaps(caps?: Partial): PortfolioCaps; +/** Project persisted queue rows into the engine's in-memory PortfolioQueue (done rows omitted). Pure. */ +export declare function entriesToPortfolioQueue(entries: QueueEntry[]): { + buckets: Array<{ + repoFullName: string; + items: Array<{ + id: string; + repoFullName: string; + state: "queued" | "in_progress"; + }>; + }>; }; - -export function selectEligibleBatch( - entries: QueueEntry[], - caps: PortfolioCaps, -): PortfolioQueueClaimTarget[]; - +/** Select the next eligible batch from active rows using the engine primitive. Pure. */ +export declare function selectEligibleBatch(entries: QueueEntry[], caps: PortfolioCaps): PortfolioQueueClaimTarget[]; export type PortfolioQueueManager = { - caps: PortfolioCaps; - store: PortfolioQueueStore; - dbPath: string; - enqueue(item: EnqueueItem): QueueEntry; - listQueue(repoFullName?: string | null): QueueEntry[]; - markDone(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueEntry | null; - markFailed(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueEntry | null; - reclaimStuckItems(maxLeaseMs?: number): QueueEntry[]; - claimNextBatch(): QueueEntry[]; - close(): void; + caps: PortfolioCaps; + store: PortfolioQueueStore; + dbPath: string; + enqueue(item: EnqueueItem): QueueEntry; + listQueue(repoFullName?: string | null): QueueEntry[]; + markDone(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueEntry | null; + markFailed(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueEntry | null; + reclaimStuckItems(maxLeaseMs?: number): QueueEntry[]; + claimNextBatch(): QueueEntry[]; + close(): void; }; - export type InitPortfolioQueueManagerOptions = { - caps?: Partial; - store?: PortfolioQueueStore; - dbPath?: string; - staleLeaseMs?: number; + caps?: Partial; + store?: PortfolioQueueStore; + dbPath?: string; + staleLeaseMs?: number; }; - -export function initPortfolioQueueManager(options?: InitPortfolioQueueManagerOptions): PortfolioQueueManager; - -export function closeDefaultPortfolioQueueManager(): void; +/** + * Open a caps-aware portfolio queue manager backed by the local SQLite store. The existing single-row + * `dequeueNext()` CLI surface is untouched — this adds `claimNextBatch()` for fleet-style batch claiming. + */ +export declare function initPortfolioQueueManager(options?: InitPortfolioQueueManagerOptions): PortfolioQueueManager; +export declare function closeDefaultPortfolioQueueManager(): void; diff --git a/packages/loopover-miner/lib/portfolio-queue-manager.js b/packages/loopover-miner/lib/portfolio-queue-manager.js index c82617c54a..9173a0e72a 100644 --- a/packages/loopover-miner/lib/portfolio-queue-manager.js +++ b/packages/loopover-miner/lib/portfolio-queue-manager.js @@ -6,9 +6,7 @@ import { nextEligibleItems } from "@loopover/engine"; import { DEFAULT_FORGE_CONFIG } from "./forge-config.js"; import { initPortfolioQueueStore } from "./portfolio-queue.js"; import { DEFAULT_MAX_LEASE_MS, sweepStuckItems } from "./portfolio-queue-expiry.js"; - const ITEM_ID_SEPARATOR = "::"; - /** * Stable composite id for projecting SQLite rows into the engine's PortfolioQueueItem shape. Encodes apiBaseUrl * too (#5563) — the engine's own selection logic has no forge dimension, but two hosts can now enqueue an item @@ -17,128 +15,130 @@ const ITEM_ID_SEPARATOR = "::"; * batchClaim would default to github.com, potentially claiming a DIFFERENT row than the one the engine selected. */ export function queueItemId(apiBaseUrl, repoFullName, identifier) { - return `${apiBaseUrl}${ITEM_ID_SEPARATOR}${repoFullName}${ITEM_ID_SEPARATOR}${identifier}`; + return `${apiBaseUrl}${ITEM_ID_SEPARATOR}${repoFullName}${ITEM_ID_SEPARATOR}${identifier}`; } - /** Reverse {@link queueItemId} after engine selection so claims can target SQLite primary keys. */ export function parseQueueItemId(id) { - if (typeof id !== "string") throw new Error("invalid_queue_item_id"); - const lastSeparatorIndex = id.lastIndexOf(ITEM_ID_SEPARATOR); - if (lastSeparatorIndex <= 0) throw new Error("invalid_queue_item_id"); - const identifier = id.slice(lastSeparatorIndex + ITEM_ID_SEPARATOR.length); - if (!identifier) throw new Error("invalid_queue_item_id"); - const beforeIdentifier = id.slice(0, lastSeparatorIndex); - const secondLastSeparatorIndex = beforeIdentifier.lastIndexOf(ITEM_ID_SEPARATOR); - if (secondLastSeparatorIndex <= 0) throw new Error("invalid_queue_item_id"); - const repoFullName = beforeIdentifier.slice(secondLastSeparatorIndex + ITEM_ID_SEPARATOR.length); - if (!repoFullName) throw new Error("invalid_queue_item_id"); - const apiBaseUrl = beforeIdentifier.slice(0, secondLastSeparatorIndex); - if (!apiBaseUrl) throw new Error("invalid_queue_item_id"); - return { apiBaseUrl, repoFullName, identifier }; + if (typeof id !== "string") + throw new Error("invalid_queue_item_id"); + const lastSeparatorIndex = id.lastIndexOf(ITEM_ID_SEPARATOR); + if (lastSeparatorIndex <= 0) + throw new Error("invalid_queue_item_id"); + const identifier = id.slice(lastSeparatorIndex + ITEM_ID_SEPARATOR.length); + if (!identifier) + throw new Error("invalid_queue_item_id"); + const beforeIdentifier = id.slice(0, lastSeparatorIndex); + const secondLastSeparatorIndex = beforeIdentifier.lastIndexOf(ITEM_ID_SEPARATOR); + if (secondLastSeparatorIndex <= 0) + throw new Error("invalid_queue_item_id"); + const repoFullName = beforeIdentifier.slice(secondLastSeparatorIndex + ITEM_ID_SEPARATOR.length); + if (!repoFullName) + throw new Error("invalid_queue_item_id"); + const apiBaseUrl = beforeIdentifier.slice(0, secondLastSeparatorIndex); + /* v8 ignore next -- unreachable: apiBaseUrl is empty only when secondLastSeparatorIndex is 0, already rejected above. */ + if (!apiBaseUrl) + throw new Error("invalid_queue_item_id"); + return { apiBaseUrl, repoFullName, identifier }; } - /** Coerce caps to finite non-negative integers (mirrors the engine's normalizeCaps posture). */ export function normalizePortfolioCaps(caps = {}) { - const globalWipCap = Number.isFinite(caps.globalWipCap) ? Math.max(0, Math.trunc(caps.globalWipCap)) : 0; - const perRepoWipCap = Number.isFinite(caps.perRepoWipCap) ? Math.max(0, Math.trunc(caps.perRepoWipCap)) : 0; - return { globalWipCap, perRepoWipCap }; + const globalWipCap = Number.isFinite(caps.globalWipCap) ? Math.max(0, Math.trunc(caps.globalWipCap)) : 0; + const perRepoWipCap = Number.isFinite(caps.perRepoWipCap) ? Math.max(0, Math.trunc(caps.perRepoWipCap)) : 0; + return { globalWipCap, perRepoWipCap }; } - /** Project persisted queue rows into the engine's in-memory PortfolioQueue (done rows omitted). Pure. */ export function entriesToPortfolioQueue(entries) { - const activeEntries = Array.isArray(entries) ? entries.filter((entry) => entry?.status !== "done") : []; - const bucketsByRepo = new Map(); - const bucketOrder = []; - for (const entry of activeEntries) { - const repoFullName = typeof entry.repoFullName === "string" ? entry.repoFullName.trim() : ""; - const identifier = typeof entry.identifier === "string" ? entry.identifier.trim() : ""; - if (!repoFullName || !identifier) continue; - // Falls back to the github.com default (matching every store's own normalizeApiBaseUrl) so a row from - // before #5563 threaded apiBaseUrl through this fold still gets a valid, host-scoped id. - const apiBaseUrl = typeof entry.apiBaseUrl === "string" && entry.apiBaseUrl.trim() ? entry.apiBaseUrl.trim() : DEFAULT_FORGE_CONFIG.apiBaseUrl; - // Host-qualify the engine's per-repo WIP grouping key (#7224). nextEligibleItems groups its per-repo cap by each - // item's `repoFullName`, which it treats as an OPAQUE string -- the engine has no apiBaseUrl concept, the host is - // smuggled through the opaque `id` (queueItemId, #5563). The store keys rows by apiBaseUrl too, so two forge - // hosts' same-named repos are distinct backlogs; without qualifying the grouping key by host here, a per-repo cap - // was shared across them (e.g. perRepoWipCap: 1 let only ONE host's backlog advance). The `id` still carries the - // TRUE repoFullName and selectEligibleBatch maps results back via parseQueueItemId(id), so the real repo/host - // survive to the caller. Single-host behavior is unchanged: one apiBaseUrl means one grouping key per repo. - const repoLower = repoFullName.toLowerCase(); - const repoKey = `${apiBaseUrl}\n${repoLower}`; - if (!bucketsByRepo.has(repoKey)) { - // The bucket's own repoFullName stays the plain repo (display/diversification), while each ITEM carries the - // host-qualified key the engine groups on -- so the returned bucket shape is unchanged for single-host. - bucketsByRepo.set(repoKey, { repoFullName: repoLower, items: [] }); - bucketOrder.push(repoKey); + const activeEntries = Array.isArray(entries) ? entries.filter((entry) => entry?.status !== "done") : []; + const bucketsByRepo = new Map(); + const bucketOrder = []; + for (const entry of activeEntries) { + const repoFullName = typeof entry.repoFullName === "string" ? entry.repoFullName.trim() : ""; + const identifier = typeof entry.identifier === "string" ? entry.identifier.trim() : ""; + if (!repoFullName || !identifier) + continue; + // Falls back to the github.com default (matching every store's own normalizeApiBaseUrl) so a row from + // before #5563 threaded apiBaseUrl through this fold still gets a valid, host-scoped id. + const apiBaseUrl = typeof entry.apiBaseUrl === "string" && entry.apiBaseUrl.trim() ? entry.apiBaseUrl.trim() : DEFAULT_FORGE_CONFIG.apiBaseUrl; + // Host-qualify the engine's per-repo WIP grouping key (#7224). nextEligibleItems groups its per-repo cap by each + // item's `repoFullName`, which it treats as an OPAQUE string -- the engine has no apiBaseUrl concept, the host is + // smuggled through the opaque `id` (queueItemId, #5563). The store keys rows by apiBaseUrl too, so two forge + // hosts' same-named repos are distinct backlogs; without qualifying the grouping key by host here, a per-repo cap + // was shared across them (e.g. perRepoWipCap: 1 let only ONE host's backlog advance). The `id` still carries the + // TRUE repoFullName and selectEligibleBatch maps results back via parseQueueItemId(id), so the real repo/host + // survive to the caller. Single-host behavior is unchanged: one apiBaseUrl means one grouping key per repo. + const repoLower = repoFullName.toLowerCase(); + const repoKey = `${apiBaseUrl}\n${repoLower}`; + if (!bucketsByRepo.has(repoKey)) { + // The bucket's own repoFullName stays the plain repo (display/diversification), while each ITEM carries the + // host-qualified key the engine groups on -- so the returned bucket shape is unchanged for single-host. + bucketsByRepo.set(repoKey, { repoFullName: repoLower, items: [] }); + bucketOrder.push(repoKey); + } + bucketsByRepo.get(repoKey).items.push({ + id: queueItemId(apiBaseUrl, repoFullName, identifier), + repoFullName: repoKey, + state: entry.status === "in_progress" ? "in_progress" : "queued", + }); } - bucketsByRepo.get(repoKey).items.push({ - id: queueItemId(apiBaseUrl, repoFullName, identifier), - repoFullName: repoKey, - state: entry.status === "in_progress" ? "in_progress" : "queued", - }); - } - return { - buckets: bucketOrder.map((repoKey) => { - const bucket = bucketsByRepo.get(repoKey); - return { repoFullName: bucket.repoFullName, items: bucket.items }; - }), - }; + return { + buckets: bucketOrder.map((repoKey) => { + const bucket = bucketsByRepo.get(repoKey); + return { repoFullName: bucket.repoFullName, items: bucket.items }; + }), + }; } - /** Select the next eligible batch from active rows using the engine primitive. Pure. */ export function selectEligibleBatch(entries, caps) { - const normalizedCaps = normalizePortfolioCaps(caps); - const queue = entriesToPortfolioQueue(entries); - return nextEligibleItems(queue, normalizedCaps).map((item) => parseQueueItemId(item.id)); + const normalizedCaps = normalizePortfolioCaps(caps); + const queue = entriesToPortfolioQueue(entries); + return nextEligibleItems(queue, normalizedCaps).map((item) => parseQueueItemId(item.id)); } - /** * Open a caps-aware portfolio queue manager backed by the local SQLite store. The existing single-row * `dequeueNext()` CLI surface is untouched — this adds `claimNextBatch()` for fleet-style batch claiming. */ export function initPortfolioQueueManager(options = {}) { - const caps = normalizePortfolioCaps(options.caps ?? { globalWipCap: 1, perRepoWipCap: 1 }); - const store = options.store ?? initPortfolioQueueStore(options.dbPath); - // A lease older than this means the process that claimed the item almost certainly died; the item is swept back - // to 'queued' so it no longer occupies WIP capacity forever (#4827). - const staleLeaseMs = Number.isFinite(options.staleLeaseMs) ? options.staleLeaseMs : DEFAULT_MAX_LEASE_MS; - - return { - caps, - store, - dbPath: store.dbPath, - enqueue(item) { - return store.enqueue(item); - }, - listQueue(repoFullName) { - return store.listQueue(repoFullName); - }, - markDone(repoFullName, identifier, apiBaseUrl) { - return store.markDone(repoFullName, identifier, apiBaseUrl); - }, - markFailed(repoFullName, identifier, apiBaseUrl) { - return store.markFailed(repoFullName, identifier, apiBaseUrl); - }, - /** Sweep leases orphaned by a crashed/killed process back to 'queued', returning the reclaimed items (#4827). */ - reclaimStuckItems(maxLeaseMs = staleLeaseMs) { - return sweepStuckItems(store, Date.now(), maxLeaseMs); - }, - // The engine primitive itself (@loopover/engine's nextEligibleItems) has no apiBaseUrl concept -- - // it only ever sees the opaque `id` string. queueItemId/parseQueueItemId (#5563) smuggle the host through - // that id round-trip, so selectFn's output below correctly carries each selected item's OWN apiBaseUrl into - // batchClaim, instead of every claim defaulting to github.com regardless of which host's row was selected. - claimNextBatch() { - // Reclaim orphaned leases first, so an item stranded 'in_progress' by a dead process becomes eligible again - // instead of permanently consuming a WIP slot and starving the queue. - sweepStuckItems(store, Date.now(), staleLeaseMs); - return store.batchClaim((entries) => selectEligibleBatch(entries, caps)); - }, - close() { - store.close(); - }, - }; + const caps = normalizePortfolioCaps(options.caps ?? { globalWipCap: 1, perRepoWipCap: 1 }); + const store = options.store ?? initPortfolioQueueStore(options.dbPath); + // A lease older than this means the process that claimed the item almost certainly died; the item is swept back + // to 'queued' so it no longer occupies WIP capacity forever (#4827). + const staleLeaseMs = Number.isFinite(options.staleLeaseMs) ? options.staleLeaseMs : DEFAULT_MAX_LEASE_MS; + return { + caps, + store, + dbPath: store.dbPath, + enqueue(item) { + return store.enqueue(item); + }, + listQueue(repoFullName) { + return store.listQueue(repoFullName); + }, + markDone(repoFullName, identifier, apiBaseUrl) { + return store.markDone(repoFullName, identifier, apiBaseUrl); + }, + markFailed(repoFullName, identifier, apiBaseUrl) { + return store.markFailed(repoFullName, identifier, apiBaseUrl); + }, + /** Sweep leases orphaned by a crashed/killed process back to 'queued', returning the reclaimed items (#4827). */ + reclaimStuckItems(maxLeaseMs = staleLeaseMs) { + return sweepStuckItems(store, Date.now(), maxLeaseMs); + }, + // The engine primitive itself (@loopover/engine's nextEligibleItems) has no apiBaseUrl concept -- + // it only ever sees the opaque `id` string. queueItemId/parseQueueItemId (#5563) smuggle the host through + // that id round-trip, so selectFn's output below correctly carries each selected item's OWN apiBaseUrl into + // batchClaim, instead of every claim defaulting to github.com regardless of which host's row was selected. + claimNextBatch() { + // Reclaim orphaned leases first, so an item stranded 'in_progress' by a dead process becomes eligible again + // instead of permanently consuming a WIP slot and starving the queue. + sweepStuckItems(store, Date.now(), staleLeaseMs); + return store.batchClaim((entries) => selectEligibleBatch(entries, caps)); + }, + close() { + store.close(); + }, + }; } - export function closeDefaultPortfolioQueueManager() { - // Reserved for symmetry with other miner stores; managers are opened explicitly today. + // Reserved for symmetry with other miner stores; managers are opened explicitly today. } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicG9ydGZvbGlvLXF1ZXVlLW1hbmFnZXIuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJwb3J0Zm9saW8tcXVldWUtbWFuYWdlci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSw2RkFBNkY7QUFDN0YsMEdBQTBHO0FBQzFHLDZHQUE2RztBQUM3RyxvR0FBb0c7QUFDcEcsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFFckQsT0FBTyxFQUFFLG9CQUFvQixFQUFFLE1BQU0sbUJBQW1CLENBQUM7QUFDekQsT0FBTyxFQUFFLHVCQUF1QixFQUFFLE1BQU0sc0JBQXNCLENBQUM7QUFFL0QsT0FBTyxFQUFFLG9CQUFvQixFQUFFLGVBQWUsRUFBRSxNQUFNLDZCQUE2QixDQUFDO0FBUXBGLE1BQU0saUJBQWlCLEdBQUcsSUFBSSxDQUFDO0FBRS9COzs7Ozs7R0FNRztBQUNILE1BQU0sVUFBVSxXQUFXLENBQUMsVUFBa0IsRUFBRSxZQUFvQixFQUFFLFVBQWtCO0lBQ3RGLE9BQU8sR0FBRyxVQUFVLEdBQUcsaUJBQWlCLEdBQUcsWUFBWSxHQUFHLGlCQUFpQixHQUFHLFVBQVUsRUFBRSxDQUFDO0FBQzdGLENBQUM7QUFFRCxtR0FBbUc7QUFDbkcsTUFBTSxVQUFVLGdCQUFnQixDQUFDLEVBQVU7SUFDekMsSUFBSSxPQUFPLEVBQUUsS0FBSyxRQUFRO1FBQUUsTUFBTSxJQUFJLEtBQUssQ0FBQyx1QkFBdUIsQ0FBQyxDQUFDO0lBQ3JFLE1BQU0sa0JBQWtCLEdBQUcsRUFBRSxDQUFDLFdBQVcsQ0FBQyxpQkFBaUIsQ0FBQyxDQUFDO0lBQzdELElBQUksa0JBQWtCLElBQUksQ0FBQztRQUFFLE1BQU0sSUFBSSxLQUFLLENBQUMsdUJBQXVCLENBQUMsQ0FBQztJQUN0RSxNQUFNLFVBQVUsR0FBRyxFQUFFLENBQUMsS0FBSyxDQUFDLGtCQUFrQixHQUFHLGlCQUFpQixDQUFDLE1BQU0sQ0FBQyxDQUFDO0lBQzNFLElBQUksQ0FB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/portfolio-queue-manager.ts b/packages/loopover-miner/lib/portfolio-queue-manager.ts new file mode 100644 index 0000000000..b798e49ba8 --- /dev/null +++ b/packages/loopover-miner/lib/portfolio-queue-manager.ts @@ -0,0 +1,181 @@ +// Stateful PortfolioQueueManager (#4285): compose the persisted SQLite portfolio/queue store +// (portfolio-queue.js, #2292) with the pure engine selector (nextEligibleItems, queue.ts, #2326) so batch +// claiming respects global/per-repo WIP caps and cross-repo diversification instead of a naive priority-only +// single-row dequeue. Caps are plain constructor arguments — not wired to .loopover-miner.yml here. +import { nextEligibleItems } from "@loopover/engine"; +import type { PortfolioCaps } from "@loopover/engine"; +import { DEFAULT_FORGE_CONFIG } from "./forge-config.js"; +import { initPortfolioQueueStore } from "./portfolio-queue.js"; +import type { EnqueueItem, PortfolioQueueStore, QueueEntry } from "./portfolio-queue.js"; +import { DEFAULT_MAX_LEASE_MS, sweepStuckItems } from "./portfolio-queue-expiry.js"; + +export type PortfolioQueueClaimTarget = { + apiBaseUrl: string; + repoFullName: string; + identifier: string; +}; + +const ITEM_ID_SEPARATOR = "::"; + +/** + * Stable composite id for projecting SQLite rows into the engine's PortfolioQueueItem shape. Encodes apiBaseUrl + * too (#5563) — the engine's own selection logic has no forge dimension, but two hosts can now enqueue an item + * under the same repoFullName+identifier (post-#5563 scoping), and the id is the ONLY thing selectEligibleBatch's + * output threads back to batchClaim; without the host baked in here, a selected item's host would be lost and + * batchClaim would default to github.com, potentially claiming a DIFFERENT row than the one the engine selected. + */ +export function queueItemId(apiBaseUrl: string, repoFullName: string, identifier: string): string { + return `${apiBaseUrl}${ITEM_ID_SEPARATOR}${repoFullName}${ITEM_ID_SEPARATOR}${identifier}`; +} + +/** Reverse {@link queueItemId} after engine selection so claims can target SQLite primary keys. */ +export function parseQueueItemId(id: string): PortfolioQueueClaimTarget { + if (typeof id !== "string") throw new Error("invalid_queue_item_id"); + const lastSeparatorIndex = id.lastIndexOf(ITEM_ID_SEPARATOR); + if (lastSeparatorIndex <= 0) throw new Error("invalid_queue_item_id"); + const identifier = id.slice(lastSeparatorIndex + ITEM_ID_SEPARATOR.length); + if (!identifier) throw new Error("invalid_queue_item_id"); + const beforeIdentifier = id.slice(0, lastSeparatorIndex); + const secondLastSeparatorIndex = beforeIdentifier.lastIndexOf(ITEM_ID_SEPARATOR); + if (secondLastSeparatorIndex <= 0) throw new Error("invalid_queue_item_id"); + const repoFullName = beforeIdentifier.slice(secondLastSeparatorIndex + ITEM_ID_SEPARATOR.length); + if (!repoFullName) throw new Error("invalid_queue_item_id"); + const apiBaseUrl = beforeIdentifier.slice(0, secondLastSeparatorIndex); + /* v8 ignore next -- unreachable: apiBaseUrl is empty only when secondLastSeparatorIndex is 0, already rejected above. */ + if (!apiBaseUrl) throw new Error("invalid_queue_item_id"); + return { apiBaseUrl, repoFullName, identifier }; +} + +/** Coerce caps to finite non-negative integers (mirrors the engine's normalizeCaps posture). */ +export function normalizePortfolioCaps(caps: Partial = {}): PortfolioCaps { + const globalWipCap = Number.isFinite(caps.globalWipCap) ? Math.max(0, Math.trunc(caps.globalWipCap as number)) : 0; + const perRepoWipCap = Number.isFinite(caps.perRepoWipCap) ? Math.max(0, Math.trunc(caps.perRepoWipCap as number)) : 0; + return { globalWipCap, perRepoWipCap }; +} + +/** Project persisted queue rows into the engine's in-memory PortfolioQueue (done rows omitted). Pure. */ +export function entriesToPortfolioQueue(entries: QueueEntry[]): { + buckets: Array<{ + repoFullName: string; + items: Array<{ id: string; repoFullName: string; state: "queued" | "in_progress" }>; + }>; +} { + const activeEntries = Array.isArray(entries) ? entries.filter((entry) => entry?.status !== "done") : []; + const bucketsByRepo = new Map< + string, + { repoFullName: string; items: Array<{ id: string; repoFullName: string; state: "queued" | "in_progress" }> } + >(); + const bucketOrder: string[] = []; + for (const entry of activeEntries) { + const repoFullName = typeof entry.repoFullName === "string" ? entry.repoFullName.trim() : ""; + const identifier = typeof entry.identifier === "string" ? entry.identifier.trim() : ""; + if (!repoFullName || !identifier) continue; + // Falls back to the github.com default (matching every store's own normalizeApiBaseUrl) so a row from + // before #5563 threaded apiBaseUrl through this fold still gets a valid, host-scoped id. + const apiBaseUrl = typeof entry.apiBaseUrl === "string" && entry.apiBaseUrl.trim() ? entry.apiBaseUrl.trim() : DEFAULT_FORGE_CONFIG.apiBaseUrl; + // Host-qualify the engine's per-repo WIP grouping key (#7224). nextEligibleItems groups its per-repo cap by each + // item's `repoFullName`, which it treats as an OPAQUE string -- the engine has no apiBaseUrl concept, the host is + // smuggled through the opaque `id` (queueItemId, #5563). The store keys rows by apiBaseUrl too, so two forge + // hosts' same-named repos are distinct backlogs; without qualifying the grouping key by host here, a per-repo cap + // was shared across them (e.g. perRepoWipCap: 1 let only ONE host's backlog advance). The `id` still carries the + // TRUE repoFullName and selectEligibleBatch maps results back via parseQueueItemId(id), so the real repo/host + // survive to the caller. Single-host behavior is unchanged: one apiBaseUrl means one grouping key per repo. + const repoLower = repoFullName.toLowerCase(); + const repoKey = `${apiBaseUrl}\n${repoLower}`; + if (!bucketsByRepo.has(repoKey)) { + // The bucket's own repoFullName stays the plain repo (display/diversification), while each ITEM carries the + // host-qualified key the engine groups on -- so the returned bucket shape is unchanged for single-host. + bucketsByRepo.set(repoKey, { repoFullName: repoLower, items: [] }); + bucketOrder.push(repoKey); + } + bucketsByRepo.get(repoKey)!.items.push({ + id: queueItemId(apiBaseUrl, repoFullName, identifier), + repoFullName: repoKey, + state: entry.status === "in_progress" ? "in_progress" : "queued", + }); + } + return { + buckets: bucketOrder.map((repoKey) => { + const bucket = bucketsByRepo.get(repoKey)!; + return { repoFullName: bucket.repoFullName, items: bucket.items }; + }), + }; +} + +/** Select the next eligible batch from active rows using the engine primitive. Pure. */ +export function selectEligibleBatch(entries: QueueEntry[], caps: PortfolioCaps): PortfolioQueueClaimTarget[] { + const normalizedCaps = normalizePortfolioCaps(caps); + const queue = entriesToPortfolioQueue(entries); + return nextEligibleItems(queue, normalizedCaps).map((item) => parseQueueItemId(item.id)); +} + +export type PortfolioQueueManager = { + caps: PortfolioCaps; + store: PortfolioQueueStore; + dbPath: string; + enqueue(item: EnqueueItem): QueueEntry; + listQueue(repoFullName?: string | null): QueueEntry[]; + markDone(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueEntry | null; + markFailed(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueEntry | null; + reclaimStuckItems(maxLeaseMs?: number): QueueEntry[]; + claimNextBatch(): QueueEntry[]; + close(): void; +}; + +export type InitPortfolioQueueManagerOptions = { + caps?: Partial; + store?: PortfolioQueueStore; + dbPath?: string; + staleLeaseMs?: number; +}; + +/** + * Open a caps-aware portfolio queue manager backed by the local SQLite store. The existing single-row + * `dequeueNext()` CLI surface is untouched — this adds `claimNextBatch()` for fleet-style batch claiming. + */ +export function initPortfolioQueueManager(options: InitPortfolioQueueManagerOptions = {}): PortfolioQueueManager { + const caps = normalizePortfolioCaps(options.caps ?? { globalWipCap: 1, perRepoWipCap: 1 }); + const store = options.store ?? initPortfolioQueueStore(options.dbPath); + // A lease older than this means the process that claimed the item almost certainly died; the item is swept back + // to 'queued' so it no longer occupies WIP capacity forever (#4827). + const staleLeaseMs = Number.isFinite(options.staleLeaseMs) ? (options.staleLeaseMs as number) : DEFAULT_MAX_LEASE_MS; + + return { + caps, + store, + dbPath: store.dbPath, + enqueue(item) { + return store.enqueue(item); + }, + listQueue(repoFullName) { + return store.listQueue(repoFullName); + }, + markDone(repoFullName, identifier, apiBaseUrl) { + return store.markDone(repoFullName, identifier, apiBaseUrl); + }, + markFailed(repoFullName, identifier, apiBaseUrl) { + return store.markFailed(repoFullName, identifier, apiBaseUrl); + }, + /** Sweep leases orphaned by a crashed/killed process back to 'queued', returning the reclaimed items (#4827). */ + reclaimStuckItems(maxLeaseMs = staleLeaseMs) { + return sweepStuckItems(store, Date.now(), maxLeaseMs); + }, + // The engine primitive itself (@loopover/engine's nextEligibleItems) has no apiBaseUrl concept -- + // it only ever sees the opaque `id` string. queueItemId/parseQueueItemId (#5563) smuggle the host through + // that id round-trip, so selectFn's output below correctly carries each selected item's OWN apiBaseUrl into + // batchClaim, instead of every claim defaulting to github.com regardless of which host's row was selected. + claimNextBatch() { + // Reclaim orphaned leases first, so an item stranded 'in_progress' by a dead process becomes eligible again + // instead of permanently consuming a WIP slot and starving the queue. + sweepStuckItems(store, Date.now(), staleLeaseMs); + return store.batchClaim((entries) => selectEligibleBatch(entries, caps)); + }, + close() { + store.close(); + }, + }; +} + +export function closeDefaultPortfolioQueueManager(): void { + // Reserved for symmetry with other miner stores; managers are opened explicitly today. +} diff --git a/packages/loopover-miner/lib/pr-disposition-poller.d.ts b/packages/loopover-miner/lib/pr-disposition-poller.d.ts index 3ff490958a..bddf98adf0 100644 --- a/packages/loopover-miner/lib/pr-disposition-poller.d.ts +++ b/packages/loopover-miner/lib/pr-disposition-poller.d.ts @@ -1,27 +1,30 @@ export type PrDisposition = { - state: "open" | "closed"; - merged: boolean; - closedAt: string | null; - attempts: number; + state: "open" | "closed"; + merged: boolean; + closedAt: string | null; + attempts: number; }; - export type PollPrDispositionOptions = { - 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 pollPrDisposition( - repoFullName: string, - prNumber: number, - options?: PollPrDispositionOptions, -): Promise; - -export function classifyPrDisposition( - disposition: Pick, -): "merged" | "disengaged" | "other"; +/** + * Poll a real PR's own merge/close disposition (distinct from its CI check-run conclusion, ci-poller.js's + * concern) with exponential backoff, until it reaches a terminal `state: "closed"` or `maxAttempts` is + * exhausted -- whichever comes first. A still-`"open"` PR after the last attempt is returned as-is, not an + * error: an unattended loop cycle should treat "still open" as "not yet resolved", not fail. + */ +export declare function pollPrDisposition(repoFullName: string, prNumber: number, options?: PollPrDispositionOptions): Promise; +/** + * Classify a real, terminal PR disposition into loop-reentry.js's own `candidate.outcome` vocabulary + * (`"merged"|"disengaged"|"other"`). A still-open disposition (not yet resolved) classifies as `"other"` -- + * the same bucket a runMinerAttempt outcome that never opened a PR at all falls into (nothing to re-enter on + * yet, in either case). + */ +export declare function classifyPrDisposition(disposition: Pick): "merged" | "disengaged" | "other"; diff --git a/packages/loopover-miner/lib/pr-disposition-poller.js b/packages/loopover-miner/lib/pr-disposition-poller.js index 3e8e7cd35d..d5d52deea0 100644 --- a/packages/loopover-miner/lib/pr-disposition-poller.js +++ b/packages/loopover-miner/lib/pr-disposition-poller.js @@ -10,164 +10,144 @@ // check-run poll's "pending" means "wait for the SAME head commit's checks to finish"; a disposition poll's // "open" means "wait for a human to actually merge or close the PR", a potentially much longer, unbounded // wait) -- composing them into one poller would conflate two different backoff/timeout policies. - 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) { - return `${apiBaseUrl}${path}`; + return `${apiBaseUrl}${path}`; } - 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 payloadMessage = payload?.message; + const githubMessage = typeof payloadMessage === "string" && payloadMessage.trim() ? payloadMessage : null; + const message = githubMessage ? `${code}: ${githubMessage}` : code; + return Object.assign(new Error(message), { code, githubMessage }); } - async function fetchPullRequest(target, prNumber, options) { - // Retry transient network errors / 5xx around this single call (#4829), matching ci-poller.js's - // githubGetJsonResponse -- distinct from this poller's OWN outer pending-retry loop. requestTimeoutMs bounds - // each individual attempt with a fresh AbortSignal.timeout() (a stalled connection can't hang a poll cycle - // forever -- #miner-github-read-timeouts); the injected sleepFn keeps the retry backoff instant in tests. - const response = await fetchWithRetry( - options.fetchFn, - apiUrl(options.apiBaseUrl, repoPath(target, `/pulls/${prNumber}`)), - { 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; + // Retry transient network errors / 5xx around this single call (#4829), matching ci-poller.js's + // githubGetJsonResponse -- distinct from this poller's OWN outer pending-retry loop. requestTimeoutMs bounds + // each individual attempt with a fresh AbortSignal.timeout() (a stalled connection can't hang a poll cycle + // forever -- #miner-github-read-timeouts); the injected sleepFn keeps the retry backoff instant in tests. + const response = await fetchWithRetry(options.fetchFn, apiUrl(options.apiBaseUrl, repoPath(target, `/pulls/${prNumber}`)), { 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; } - /** GitHub's own vocabulary is `state: "open"|"closed"` plus a separate `merged: boolean` -- "closed and not * merged" is the disengaged case. A still-open PR is never terminal for this poller's purposes. */ function normalizeDisposition(payload) { - const state = payload?.state === "closed" ? "closed" : "open"; - const merged = Boolean(payload?.merged); - const closedAt = typeof payload?.closed_at === "string" ? payload.closed_at : null; - return { state, merged, closedAt }; + const p = payload; + const state = p?.state === "closed" ? "closed" : "open"; + const merged = Boolean(p?.merged); + const closedAt = typeof p?.closed_at === "string" ? p.closed_at : null; + return { state, merged, closedAt }; } - 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); } - /** * Poll a real PR's own merge/close disposition (distinct from its CI check-run conclusion, ci-poller.js's * concern) with exponential backoff, until it reaches a terminal `state: "closed"` or `maxAttempts` is * exhausted -- whichever comes first. A still-`"open"` PR after the last attempt is returned as-is, not an * error: an unattended loop cycle should treat "still open" as "not yet resolved", not fail. - * - * @param {string} repoFullName - * @param {number} prNumber - * @param {{ - * apiBaseUrl?: string, fetchFn?: typeof fetch, githubToken?: string, maxAttempts?: number, - * minIntervalMs?: number, maxIntervalMs?: number, sleepFn?: (delayMs: number) => Promise, - * }} [options] - * @returns {Promise<{ state: "open"|"closed", merged: boolean, closedAt: string|null, attempts: number }>} */ export async function pollPrDisposition(repoFullName, prNumber, options = {}) { - const target = parseRepoFullName(repoFullName); - const normalizedPrNumber = normalizePullNumber(prNumber); - const normalizedOptions = normalizeOptions(options); - - let latest = { state: "open", merged: false, closedAt: null, attempts: 0 }; - for (let attempt = 0; attempt < normalizedOptions.maxAttempts; attempt += 1) { - const payload = await fetchPullRequest(target, normalizedPrNumber, normalizedOptions); - latest = { ...normalizeDisposition(payload), attempts: attempt + 1 }; - if (latest.state === "closed") return latest; - if (attempt === normalizedOptions.maxAttempts - 1) return latest; - await normalizedOptions.sleepFn(backoffDelayMs(attempt, normalizedOptions)); - } - return latest; + const target = parseRepoFullName(repoFullName); + const normalizedPrNumber = normalizePullNumber(prNumber); + const normalizedOptions = normalizeOptions(options); + let latest = { state: "open", merged: false, closedAt: null, attempts: 0 }; + for (let attempt = 0; attempt < normalizedOptions.maxAttempts; attempt += 1) { + const payload = await fetchPullRequest(target, normalizedPrNumber, normalizedOptions); + latest = { ...normalizeDisposition(payload), attempts: attempt + 1 }; + if (latest.state === "closed") + return latest; + if (attempt === normalizedOptions.maxAttempts - 1) + return latest; + await normalizedOptions.sleepFn(backoffDelayMs(attempt, normalizedOptions)); + } + /* v8 ignore next -- unreachable: maxAttempts is normalized to >= 1, so the final iteration always returns above. */ + return latest; } - /** * Classify a real, terminal PR disposition into loop-reentry.js's own `candidate.outcome` vocabulary * (`"merged"|"disengaged"|"other"`). A still-open disposition (not yet resolved) classifies as `"other"` -- * the same bucket a runMinerAttempt outcome that never opened a PR at all falls into (nothing to re-enter on * yet, in either case). - * - * @param {{ state: "open"|"closed", merged: boolean }} disposition - * @returns {"merged"|"disengaged"|"other"} */ export function classifyPrDisposition(disposition) { - if (disposition.state !== "closed") return "other"; - return disposition.merged ? "merged" : "disengaged"; + if (disposition.state !== "closed") + return "other"; + return disposition.merged ? "merged" : "disengaged"; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicHItZGlzcG9zaXRpb24tcG9sbGVyLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsicHItZGlzcG9zaXRpb24tcG9sbGVyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLDRHQUE0RztBQUM1RywyR0FBMkc7QUFDM0csOEdBQThHO0FBQzlHLGtHQUFrRztBQUNsRywyR0FBMkc7QUFDM0csZ0ZBQWdGO0FBQ2hGLEVBQUU7QUFDRixxR0FBcUc7QUFDckcsd0dBQXdHO0FBQ3hHLDRHQUE0RztBQUM1RywwR0FBMEc7QUFDMUcsaUdBQWlHO0FBRWpHLE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSxpQkFBaUIsQ0FBQztBQStCakQsTUFBTSxpQkFBaUIsR0FBRyx3QkFBd0IsQ0FBQztBQUNuRCxNQUFNLG9CQUFvQixHQUFHLE1BQU0sQ0FBQztBQUNwQyxNQUFNLG9CQUFvQixHQUFHLENBQUMsR0FBRyxNQUFNLENBQUM7QUFDeEMsTUFBTSxrQkFBa0IsR0FBRyxDQUFDLENBQUM7QUFDN0IsTUFBTSx1QkFBdUIsR0FBRyxNQUFNLENBQUM7QUFDdkMsTUFBTSxnQkFBZ0IsR0FBRyxZQUFZLENBQUM7QUFFdEMsU0FBUyxtQkFBbUIsQ0FBQyxLQUFjO0lBQ3pDLElBQUksS0FBSyxLQUFLLFNBQVM7UUFBRSxPQUFPLGlCQUFpQixDQUFDO0lBQ2xELElBQUksT0FBTyxLQUFLLEtBQUssUUFBUSxJQUFJLENBQUMsS0FBSyxDQUFDLElBQUksRUFBRTtRQUFFLE9BQU8saUJBQWlCLENBQUM7SUFDekUsSUFBSSxNQUFXLENBQUM7SUFDaEIsSUFBSSxDQUFDO1FBQ0gsTUFBTSxHQUFHLElBQUksR0FBRyxDQUFDLEtBQUssQ0FBQyxJQUFJLEVBQUUsQ0FBQyxDQUFDO0lBQ2pDLENBQUM7SUFBQyxNQUFNLENBQUM7UUFDUCxNQUFNLElBQUksS0FBSyxDQUFDLHNCQUFzQixDQUFDLENBQUM7SUFDMUMsQ0FBQztJQUNELElBQUksTUFBTSxDQUFDLFFBQVEsS0FBSyxRQUFR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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/pr-disposition-poller.ts b/packages/loopover-miner/lib/pr-disposition-poller.ts new file mode 100644 index 0000000000..0c68c013c8 --- /dev/null +++ b/packages/loopover-miner/lib/pr-disposition-poller.ts @@ -0,0 +1,204 @@ +// Real PR-disposition poller (#5135, Wave 3.5 -- the autonomous loop). ci-poller.js already polls a PR's CI +// check-runs, but that answers a DIFFERENT question ("did the checks pass") from what the supervising loop +// needs at cycle-close time ("did the PR itself get merged or closed"). Nothing in this package answered that +// second question before this file: pr-outcome.js already has a real store for the classification +// (recordPrOutcomeSnapshot/readPrOutcomes), but every existing caller of it was a test -- this is the real +// GitHub fetch that produces the classification pr-outcome.js's writer expects. +// +// Deliberately its own module, not folded into ci-poller.js: the two pollers ask genuinely different +// questions (check-run conclusion vs. PR merge/close disposition) with different terminal conditions (a +// check-run poll's "pending" means "wait for the SAME head commit's checks to finish"; a disposition poll's +// "open" means "wait for a human to actually merge or close the PR", a potentially much longer, unbounded +// wait) -- composing them into one poller would conflate two different backoff/timeout policies. + +import { fetchWithRetry } from "./http-retry.js"; + +export type PrDisposition = { + state: "open" | "closed"; + merged: boolean; + closedAt: string | null; + attempts: number; +}; + +export type PollPrDispositionOptions = { + 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; +}; + +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?: string): 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: number | undefined, fallback: number, min: number, max: number): number { + if (!Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(min, Math.floor(value as number))); +} + +function normalizeOptions(options: PollPrDispositionOptions = {}): 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): { owner: string; repo: string } { + 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: { owner: string; repo: string }, suffix: string): string { + return `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}${suffix}`; +} + +function apiUrl(apiBaseUrl: string, path: string): string { + return `${apiBaseUrl}${path}`; +} + +function githubError(response: { status: number }, payload: unknown): Error { + const code = `github_${response.status}`; + const payloadMessage = (payload as { message?: unknown } | null)?.message; + const githubMessage = + typeof payloadMessage === "string" && payloadMessage.trim() ? payloadMessage : null; + const message = githubMessage ? `${code}: ${githubMessage}` : code; + return Object.assign(new Error(message), { code, githubMessage }); +} + +async function fetchPullRequest( + target: { owner: string; repo: string }, + prNumber: number, + options: NormalizedPollOptions, +): Promise { + // Retry transient network errors / 5xx around this single call (#4829), matching ci-poller.js's + // githubGetJsonResponse -- distinct from this poller's OWN outer pending-retry loop. requestTimeoutMs bounds + // each individual attempt with a fresh AbortSignal.timeout() (a stalled connection can't hang a poll cycle + // forever -- #miner-github-read-timeouts); the injected sleepFn keeps the retry backoff instant in tests. + const response = await fetchWithRetry( + options.fetchFn as (url: unknown, init?: unknown) => Promise, + apiUrl(options.apiBaseUrl, repoPath(target, `/pulls/${prNumber}`)), + { 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; +} + +/** GitHub's own vocabulary is `state: "open"|"closed"` plus a separate `merged: boolean` -- "closed and not + * merged" is the disengaged case. A still-open PR is never terminal for this poller's purposes. */ +function normalizeDisposition(payload: unknown): { state: "open" | "closed"; merged: boolean; closedAt: string | null } { + const p = payload as { state?: unknown; merged?: unknown; closed_at?: unknown } | null | undefined; + const state = p?.state === "closed" ? "closed" : "open"; + const merged = Boolean(p?.merged); + const closedAt = typeof p?.closed_at === "string" ? p.closed_at : null; + return { state, merged, closedAt }; +} + +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); +} + +/** + * Poll a real PR's own merge/close disposition (distinct from its CI check-run conclusion, ci-poller.js's + * concern) with exponential backoff, until it reaches a terminal `state: "closed"` or `maxAttempts` is + * exhausted -- whichever comes first. A still-`"open"` PR after the last attempt is returned as-is, not an + * error: an unattended loop cycle should treat "still open" as "not yet resolved", not fail. + */ +export async function pollPrDisposition( + repoFullName: string, + prNumber: number, + options: PollPrDispositionOptions = {}, +): Promise { + const target = parseRepoFullName(repoFullName); + const normalizedPrNumber = normalizePullNumber(prNumber); + const normalizedOptions = normalizeOptions(options); + + let latest: PrDisposition = { state: "open", merged: false, closedAt: null, attempts: 0 }; + for (let attempt = 0; attempt < normalizedOptions.maxAttempts; attempt += 1) { + const payload = await fetchPullRequest(target, normalizedPrNumber, normalizedOptions); + latest = { ...normalizeDisposition(payload), attempts: attempt + 1 }; + if (latest.state === "closed") return latest; + if (attempt === normalizedOptions.maxAttempts - 1) return latest; + await normalizedOptions.sleepFn(backoffDelayMs(attempt, normalizedOptions)); + } + /* v8 ignore next -- unreachable: maxAttempts is normalized to >= 1, so the final iteration always returns above. */ + return latest; +} + +/** + * Classify a real, terminal PR disposition into loop-reentry.js's own `candidate.outcome` vocabulary + * (`"merged"|"disengaged"|"other"`). A still-open disposition (not yet resolved) classifies as `"other"` -- + * the same bucket a runMinerAttempt outcome that never opened a PR at all falls into (nothing to re-enter on + * yet, in either case). + */ +export function classifyPrDisposition( + disposition: Pick, +): "merged" | "disengaged" | "other" { + if (disposition.state !== "closed") return "other"; + return disposition.merged ? "merged" : "disengaged"; +} diff --git a/packages/loopover-miner/lib/ranked-candidates.d.ts b/packages/loopover-miner/lib/ranked-candidates.d.ts index caa22ca9b9..e67a1f1363 100644 --- a/packages/loopover-miner/lib/ranked-candidates.d.ts +++ b/packages/loopover-miner/lib/ranked-candidates.d.ts @@ -1,51 +1,44 @@ export type RankedCandidateInput = { - repoFullName: string; - issueNumber: number; - title?: string; - htmlUrl?: string | null; - rankScore: number; - laneFit?: number; - freshness?: number; - potential?: number; - feasibility?: number; - dupRisk?: number; + repoFullName: string; + issueNumber: number; + title?: string; + htmlUrl?: string | null; + rankScore: number; + laneFit?: number; + freshness?: number; + potential?: number; + feasibility?: number; + dupRisk?: number; }; - export type RankedCandidateRow = { - repoFullName: string; - issueNumber: number; - title: string; - htmlUrl: string | null; - rankScore: number; - laneFit: number; - freshness: number; - potential: number; - feasibility: number; - dupRisk: number; - rankedAt: string; + repoFullName: string; + issueNumber: number; + title: string; + htmlUrl: string | null; + rankScore: number; + laneFit: number; + freshness: number; + potential: number; + feasibility: number; + dupRisk: number; + rankedAt: string; }; - export type RankedCandidatesSaveResult = { - count: number; - rankedAt: string; + count: number; + rankedAt: string; }; - export type RankedCandidatesStore = { - dbPath: string; - saveRankedCandidates(candidates: RankedCandidateInput[], nowMs?: number): RankedCandidatesSaveResult; - listRankedCandidates(): RankedCandidateRow[]; - close(): void; + dbPath: string; + saveRankedCandidates(candidates: RankedCandidateInput[], nowMs?: number): RankedCandidatesSaveResult; + listRankedCandidates(): RankedCandidateRow[]; + close(): void; }; - -export function resolveRankedCandidatesDbPath(env?: Record): string; - -export function initRankedCandidatesStore(dbPath?: string): RankedCandidatesStore; - -export function saveRankedCandidates( - candidates: RankedCandidateInput[], - nowMs?: number, -): RankedCandidatesSaveResult; - -export function listRankedCandidates(): RankedCandidateRow[]; - -export function closeDefaultRankedCandidatesStore(): void; +export declare function resolveRankedCandidatesDbPath(env?: Record): string; +/** + * Opens the 100% local/client-side ranked-candidates snapshot store. The database only lives on this machine; + * this module never uploads, syncs, or phones home with its contents. + */ +export declare function initRankedCandidatesStore(dbPath?: string): RankedCandidatesStore; +export declare function saveRankedCandidates(candidates: RankedCandidateInput[], nowMs?: number): RankedCandidatesSaveResult; +export declare function listRankedCandidates(): RankedCandidateRow[]; +export declare function closeDefaultRankedCandidatesStore(): void; diff --git a/packages/loopover-miner/lib/ranked-candidates.js b/packages/loopover-miner/lib/ranked-candidates.js index 830bfc2381..a0bdc05944 100644 --- a/packages/loopover-miner/lib/ranked-candidates.js +++ b/packages/loopover-miner/lib/ranked-candidates.js @@ -1,87 +1,72 @@ import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; - -// Last-discover-run ranked-candidates snapshot (#4859 prerequisite): `discover-cli.js`'s runDiscover already -// computes the FULL per-issue ranking breakdown (rankScore/laneFit/freshness/potential/feasibility/dupRisk, via -// opportunity-ranker.js) and prints it to stdout with `--json`, but nothing durable ever stores it -- the -// portfolio queue only carries a single derived `priority` number, not the per-dimension detail. The browser -// extension's opportunity badge (apps/loopover-miner-extension/opportunity-badge.js) needs exactly that detail -// to render its "why" reasoning, and today can only get it via a manual copy/paste of `discover --json`'s output -// (#4859's whole premise). This module gives that output a durable home so a local HTTP endpoint can serve it. -// -// Deliberately a SNAPSHOT, not a ledger: each real (non-dry-run) discover invocation REPLACES the whole table -// wholesale (this run's candidates are what's live-fetchable now; a stale prior run's rows would be actively -// misleading, not historically useful the way an append-only ledger's rows are). No forge (api_base_url) scoping -// either -- unlike the portfolio-queue/claim-ledger/governor-state stores, which track ongoing state across many -// runs and many repos over time, this is a disposable "the miner's current opinion" cache for one local -// operator's browsing session; if a later run targets a different forge, replacing the whole snapshot is exactly -// the right behavior, not a gap. - const defaultDbFileName = "ranked-candidates.sqlite3"; let defaultRankedCandidatesStore = null; - export function resolveRankedCandidatesDbPath(env = process.env) { - return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_RANKED_CANDIDATES_DB", env); + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_RANKED_CANDIDATES_DB", env); } - function normalizeDbPath(dbPath) { - return normalizeLocalStoreDbPath(dbPath, resolveRankedCandidatesDbPath(), "invalid_ranked_candidates_db_path"); + return normalizeLocalStoreDbPath(dbPath, resolveRankedCandidatesDbPath(), "invalid_ranked_candidates_db_path"); } - function normalizeFiniteRankDimension(value, fallback) { - return Number.isFinite(value) ? value : fallback; + return Number.isFinite(value) ? value : fallback; } - function normalizeCandidate(candidate) { - if (!candidate || typeof candidate !== "object") throw new Error("invalid_ranked_candidate"); - const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; - const [owner, repo, extra] = repoFullName.split("/"); - if (!owner || !repo || extra !== undefined) throw new Error("invalid_ranked_candidate"); - const issueNumber = candidate.issueNumber; - if (!Number.isInteger(issueNumber) || issueNumber <= 0) throw new Error("invalid_ranked_candidate"); - const rankScore = Number(candidate.rankScore); - if (!Number.isFinite(rankScore)) throw new Error("invalid_ranked_candidate"); - return { - repoFullName: `${owner}/${repo}`, - issueNumber, - title: typeof candidate.title === "string" ? candidate.title : "", - htmlUrl: typeof candidate.htmlUrl === "string" ? candidate.htmlUrl : null, - rankScore, - // A dimension the ranker didn't supply degrades to the SAME neutral defaults opportunity-ranker.js's own - // normalizeCandidate uses for a missing signal (0 for a benefit dimension, 1 -- max risk -- for dupRisk), - // rather than silently coercing a non-finite value to 0 across the board. - laneFit: normalizeFiniteRankDimension(candidate.laneFit, 0), - freshness: normalizeFiniteRankDimension(candidate.freshness, 0), - potential: normalizeFiniteRankDimension(candidate.potential, 0), - feasibility: normalizeFiniteRankDimension(candidate.feasibility, 0), - dupRisk: normalizeFiniteRankDimension(candidate.dupRisk, 1), - }; + if (!candidate || typeof candidate !== "object") + throw new Error("invalid_ranked_candidate"); + const c = candidate; + const repoFullName = typeof c.repoFullName === "string" ? c.repoFullName.trim() : ""; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) + throw new Error("invalid_ranked_candidate"); + const issueNumber = c.issueNumber; + if (!Number.isInteger(issueNumber) || issueNumber <= 0) + throw new Error("invalid_ranked_candidate"); + const rankScore = Number(c.rankScore); + if (!Number.isFinite(rankScore)) + throw new Error("invalid_ranked_candidate"); + return { + repoFullName: `${owner}/${repo}`, + issueNumber: issueNumber, + title: typeof c.title === "string" ? c.title : "", + htmlUrl: typeof c.htmlUrl === "string" ? c.htmlUrl : null, + rankScore, + // A dimension the ranker didn't supply degrades to the SAME neutral defaults opportunity-ranker.js's own + // normalizeCandidate uses for a missing signal (0 for a benefit dimension, 1 -- max risk -- for dupRisk), + // rather than silently coercing a non-finite value to 0 across the board. + laneFit: normalizeFiniteRankDimension(c.laneFit, 0), + freshness: normalizeFiniteRankDimension(c.freshness, 0), + potential: normalizeFiniteRankDimension(c.potential, 0), + feasibility: normalizeFiniteRankDimension(c.feasibility, 0), + dupRisk: normalizeFiniteRankDimension(c.dupRisk, 1), + }; } - function rowToCandidate(row) { - return { - repoFullName: row.repo_full_name, - issueNumber: row.issue_number, - title: row.title, - htmlUrl: row.html_url, - rankScore: row.rank_score, - laneFit: row.lane_fit, - freshness: row.freshness, - potential: row.potential, - feasibility: row.feasibility, - dupRisk: row.dup_risk, - rankedAt: row.ranked_at, - }; + return { + repoFullName: row.repo_full_name, + issueNumber: row.issue_number, + title: row.title, + htmlUrl: row.html_url, + rankScore: row.rank_score, + laneFit: row.lane_fit, + freshness: row.freshness, + potential: row.potential, + feasibility: row.feasibility, + dupRisk: row.dup_risk, + rankedAt: row.ranked_at, + }; +} +function asRankedCandidateDbRow(row) { + return row; } - /** * Opens the 100% local/client-side ranked-candidates snapshot store. The database only lives on this machine; * this module never uploads, syncs, or phones home with its contents. */ export function initRankedCandidatesStore(dbPath = resolveRankedCandidatesDbPath()) { - 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 miner_ranked_candidates ( repo_full_name TEXT NOT NULL, issue_number INTEGER NOT NULL, @@ -97,82 +82,66 @@ export function initRankedCandidatesStore(dbPath = resolveRankedCandidatesDbPath PRIMARY KEY (repo_full_name, issue_number) ) `); - // Schema-version convention (#4832): stamp the baseline. No post-baseline migrations yet -- this is a new store. - applySchemaMigrations(db, []); - - const deleteAllStatement = db.prepare("DELETE FROM miner_ranked_candidates"); - const insertStatement = db.prepare(` + // Schema-version convention (#4832): stamp the baseline. No post-baseline migrations yet -- this is a new store. + applySchemaMigrations(db, []); + const deleteAllStatement = db.prepare("DELETE FROM miner_ranked_candidates"); + const insertStatement = db.prepare(` INSERT INTO miner_ranked_candidates (repo_full_name, issue_number, title, html_url, rank_score, lane_fit, freshness, potential, feasibility, dup_risk, ranked_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); - const listStatement = db.prepare("SELECT * FROM miner_ranked_candidates ORDER BY rank_score DESC"); - - // Atomic replace: a reader between the DELETE and the INSERTs must never observe an empty table mid-write. - // node:sqlite's DatabaseSync has no `.transaction()` helper (unlike better-sqlite3) -- mirrors - // portfolio-queue.js's batchClaim: explicit BEGIN IMMEDIATE/COMMIT, ROLLBACK + rethrow on failure. - function replaceAll(normalizedCandidates, rankedAt) { - db.exec("BEGIN IMMEDIATE"); - try { - deleteAllStatement.run(); - for (const candidate of normalizedCandidates) { - insertStatement.run( - candidate.repoFullName, - candidate.issueNumber, - candidate.title, - candidate.htmlUrl, - candidate.rankScore, - candidate.laneFit, - candidate.freshness, - candidate.potential, - candidate.feasibility, - candidate.dupRisk, - rankedAt, - ); - } - db.exec("COMMIT"); - } catch (error) { - db.exec("ROLLBACK"); - throw error; + const listStatement = db.prepare("SELECT * FROM miner_ranked_candidates ORDER BY rank_score DESC"); + // Atomic replace: a reader between the DELETE and the INSERTs must never observe an empty table mid-write. + // node:sqlite's DatabaseSync has no `.transaction()` helper (unlike better-sqlite3) -- mirrors + // portfolio-queue.js's batchClaim: explicit BEGIN IMMEDIATE/COMMIT, ROLLBACK + rethrow on failure. + function replaceAll(normalizedCandidates, rankedAt) { + db.exec("BEGIN IMMEDIATE"); + try { + deleteAllStatement.run(); + for (const candidate of normalizedCandidates) { + insertStatement.run(candidate.repoFullName, candidate.issueNumber, candidate.title, candidate.htmlUrl, candidate.rankScore, candidate.laneFit, candidate.freshness, candidate.potential, candidate.feasibility, candidate.dupRisk, rankedAt); + } + db.exec("COMMIT"); + } + catch (error) { + db.exec("ROLLBACK"); + throw error; + } } - } - - return { - dbPath: resolvedPath, - /** Replaces the whole snapshot wholesale with this run's ranked candidates. `nowMs` is caller-supplied - * (never reads the clock internally) so tests get a deterministic `rankedAt`. */ - saveRankedCandidates(candidates, nowMs) { - const normalized = (Array.isArray(candidates) ? candidates : []).map(normalizeCandidate); - const rankedAt = new Date(Number.isFinite(nowMs) ? nowMs : Date.now()).toISOString(); - replaceAll(normalized, rankedAt); - return { count: normalized.length, rankedAt }; - }, - /** Every candidate from the last saved run, highest rankScore first. Empty (not an error) before any - * discover run has ever saved a snapshot, or if the last run found zero candidates. */ - listRankedCandidates() { - return listStatement.all().map(rowToCandidate); - }, - close() { - db.close(); - }, - }; + return { + dbPath: resolvedPath, + /** Replaces the whole snapshot wholesale with this run's ranked candidates. `nowMs` is caller-supplied + * (never reads the clock internally) so tests get a deterministic `rankedAt`. */ + saveRankedCandidates(candidates, nowMs) { + const normalized = (Array.isArray(candidates) ? candidates : []).map(normalizeCandidate); + const rankedAt = new Date(Number.isFinite(nowMs) ? nowMs : Date.now()).toISOString(); + replaceAll(normalized, rankedAt); + return { count: normalized.length, rankedAt }; + }, + /** Every candidate from the last saved run, highest rankScore first. Empty (not an error) before any + * discover run has ever saved a snapshot, or if the last run found zero candidates. */ + listRankedCandidates() { + return listStatement.all().map((row) => rowToCandidate(asRankedCandidateDbRow(row))); + }, + close() { + db.close(); + }, + }; } - function getDefaultRankedCandidatesStore() { - defaultRankedCandidatesStore ??= initRankedCandidatesStore(); - return defaultRankedCandidatesStore; + defaultRankedCandidatesStore ??= initRankedCandidatesStore(); + return defaultRankedCandidatesStore; } - export function saveRankedCandidates(candidates, nowMs) { - return getDefaultRankedCandidatesStore().saveRankedCandidates(candidates, nowMs); + return getDefaultRankedCandidatesStore().saveRankedCandidates(candidates, nowMs); } - export function listRankedCandidates() { - return getDefaultRankedCandidatesStore().listRankedCandidates(); + return getDefaultRankedCandidatesStore().listRankedCandidates(); } - export function closeDefaultRankedCandidatesStore() { - if (!defaultRankedCandidatesStore) return; - defaultRankedCandidatesStore.close(); - defaultRankedCandidatesStore = null; + if (!defaultRankedCandidatesStore) + return; + defaultRankedCandidatesStore.close(); + defaultRankedCandidatesStore = null; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmFua2VkLWNhbmRpZGF0ZXMuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJyYW5rZWQtY2FuZGlkYXRlcy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFDQSxPQUFPLEVBQUUseUJBQXlCLEVBQUUsZ0JBQWdCLEVBQUUsdUJBQXVCLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUN4RyxPQUFPLEVBQUUscUJBQXFCLEVBQUUsTUFBTSxxQkFBcUIsQ0FBQztBQXNGNUQsTUFBTSxpQkFBaUIsR0FBRywyQkFBMkIsQ0FBQztBQUN0RCxJQUFJLDRCQUE0QixHQUFpQyxJQUFJLENBQUM7QUFFdEUsTUFBTSxVQUFVLDZCQUE2QixDQUFDLE1BQTBDLE9BQU8sQ0FBQyxHQUFHO0lBQ2pHLE9BQU8sdUJBQXVCLENBQUMsaUJBQWlCLEVBQUUscUNBQXFDLEVBQUUsR0FBRyxDQUFDLENBQUM7QUFDaEcsQ0FBQztBQUVELFNBQVMsZUFBZSxDQUFDLE1BQWM7SUFDckMsT0FBTyx5QkFBeUIsQ0FBQyxNQUFNLEVBQUUsNkJBQTZCLEVBQUUsRUFBRSxtQ0FBbUMsQ0FBQyxDQUFDO0FBQ2pILENBQUM7QUFFRCxTQUFTLDRCQUE0QixDQUFDLEtBQWMsRUFBRSxRQUFnQjtJQUNwRSxPQUFPLE1BQU0sQ0FBQyxRQUFRLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQyxDQUFFLEtBQWdCLENBQUMsQ0FBQyxDQUFDLFFBQVEsQ0FBQztBQUMvRCxDQUFDO0FBRUQsU0FBUyxrQkFBa0IsQ0FBQyxTQUFrQjtJQUM1QyxJQUFJLENBQUMsU0FBUyxJQUFJLE9BQU8sU0FBUyxLQUFLLFFBQVE7UUFBRSxNQUFNLElBQUksS0FBSyxDQUFDLDBCQUEwQixDQUFDLENBQUM7SUFDN0YsTUFBTSxDQUFDLEdBQUcsU0FBb0MsQ0FBQztJQUMvQyxNQUFNLFlBQVksR0FBRyxPQUFPLENBQUMsQ0FBQyxZQUFZLEtBQUssUUFBUSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsWUFBWSxDQUFDLElBQUks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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/ranked-candidates.ts b/packages/loopover-miner/lib/ranked-candidates.ts new file mode 100644 index 0000000000..5c625f059b --- /dev/null +++ b/packages/loopover-miner/lib/ranked-candidates.ts @@ -0,0 +1,252 @@ +import type { SQLOutputValue } from "node:sqlite"; +import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; +import { applySchemaMigrations } from "./schema-version.js"; + +// Last-discover-run ranked-candidates snapshot (#4859 prerequisite): `discover-cli.js`'s runDiscover already +// computes the FULL per-issue ranking breakdown (rankScore/laneFit/freshness/potential/feasibility/dupRisk, via +// opportunity-ranker.js) and prints it to stdout with `--json`, but nothing durable ever stores it -- the +// portfolio queue only carries a single derived `priority` number, not the per-dimension detail. The browser +// extension's opportunity badge (apps/loopover-miner-extension/opportunity-badge.js) needs exactly that detail +// to render its "why" reasoning, and today can only get it via a manual copy/paste of `discover --json`'s output +// (#4859's whole premise). This module gives that output a durable home so a local HTTP endpoint can serve it. +// +// Deliberately a SNAPSHOT, not a ledger: each real (non-dry-run) discover invocation REPLACES the whole table +// wholesale (this run's candidates are what's live-fetchable now; a stale prior run's rows would be actively +// misleading, not historically useful the way an append-only ledger's rows are). No forge (api_base_url) scoping +// either -- unlike the portfolio-queue/claim-ledger/governor-state stores, which track ongoing state across many +// runs and many repos over time, this is a disposable "the miner's current opinion" cache for one local +// operator's browsing session; if a later run targets a different forge, replacing the whole snapshot is exactly +// the right behavior, not a gap. + +export type RankedCandidateInput = { + repoFullName: string; + issueNumber: number; + title?: string; + htmlUrl?: string | null; + rankScore: number; + laneFit?: number; + freshness?: number; + potential?: number; + feasibility?: number; + dupRisk?: number; +}; + +export type RankedCandidateRow = { + repoFullName: string; + issueNumber: number; + title: string; + htmlUrl: string | null; + rankScore: number; + laneFit: number; + freshness: number; + potential: number; + feasibility: number; + dupRisk: number; + rankedAt: string; +}; + +export type RankedCandidatesSaveResult = { + count: number; + rankedAt: string; +}; + +export type RankedCandidatesStore = { + dbPath: string; + saveRankedCandidates(candidates: RankedCandidateInput[], nowMs?: number): RankedCandidatesSaveResult; + listRankedCandidates(): RankedCandidateRow[]; + close(): void; +}; + +/** Internal validated/normalized candidate shape ready for insertion (all dimensions defaulted). */ +type NormalizedRankedCandidate = { + repoFullName: string; + issueNumber: number; + title: string; + htmlUrl: string | null; + rankScore: number; + laneFit: number; + freshness: number; + potential: number; + feasibility: number; + dupRisk: number; +}; + +/** Private shape of a `miner_ranked_candidates` SELECT * row after casting off `Record`. */ +type RankedCandidateDbRow = { + repo_full_name: string; + issue_number: number; + title: string; + html_url: string | null; + rank_score: number; + lane_fit: number; + freshness: number; + potential: number; + feasibility: number; + dup_risk: number; + ranked_at: string; +}; + +const defaultDbFileName = "ranked-candidates.sqlite3"; +let defaultRankedCandidatesStore: RankedCandidatesStore | null = null; + +export function resolveRankedCandidatesDbPath(env: Record = process.env): string { + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_RANKED_CANDIDATES_DB", env); +} + +function normalizeDbPath(dbPath: string): string { + return normalizeLocalStoreDbPath(dbPath, resolveRankedCandidatesDbPath(), "invalid_ranked_candidates_db_path"); +} + +function normalizeFiniteRankDimension(value: unknown, fallback: number): number { + return Number.isFinite(value) ? (value as number) : fallback; +} + +function normalizeCandidate(candidate: unknown): NormalizedRankedCandidate { + if (!candidate || typeof candidate !== "object") throw new Error("invalid_ranked_candidate"); + const c = candidate as Record; + const repoFullName = typeof c.repoFullName === "string" ? c.repoFullName.trim() : ""; + const [owner, repo, extra] = repoFullName.split("/"); + if (!owner || !repo || extra !== undefined) throw new Error("invalid_ranked_candidate"); + const issueNumber = c.issueNumber; + if (!Number.isInteger(issueNumber) || (issueNumber as number) <= 0) throw new Error("invalid_ranked_candidate"); + const rankScore = Number(c.rankScore); + if (!Number.isFinite(rankScore)) throw new Error("invalid_ranked_candidate"); + return { + repoFullName: `${owner}/${repo}`, + issueNumber: issueNumber as number, + title: typeof c.title === "string" ? c.title : "", + htmlUrl: typeof c.htmlUrl === "string" ? c.htmlUrl : null, + rankScore, + // A dimension the ranker didn't supply degrades to the SAME neutral defaults opportunity-ranker.js's own + // normalizeCandidate uses for a missing signal (0 for a benefit dimension, 1 -- max risk -- for dupRisk), + // rather than silently coercing a non-finite value to 0 across the board. + laneFit: normalizeFiniteRankDimension(c.laneFit, 0), + freshness: normalizeFiniteRankDimension(c.freshness, 0), + potential: normalizeFiniteRankDimension(c.potential, 0), + feasibility: normalizeFiniteRankDimension(c.feasibility, 0), + dupRisk: normalizeFiniteRankDimension(c.dupRisk, 1), + }; +} + +function rowToCandidate(row: RankedCandidateDbRow): RankedCandidateRow { + return { + repoFullName: row.repo_full_name, + issueNumber: row.issue_number, + title: row.title, + htmlUrl: row.html_url, + rankScore: row.rank_score, + laneFit: row.lane_fit, + freshness: row.freshness, + potential: row.potential, + feasibility: row.feasibility, + dupRisk: row.dup_risk, + rankedAt: row.ranked_at, + }; +} + +function asRankedCandidateDbRow(row: Record): RankedCandidateDbRow { + return row as unknown as RankedCandidateDbRow; +} + +/** + * Opens the 100% local/client-side ranked-candidates snapshot store. The database only lives on this machine; + * this module never uploads, syncs, or phones home with its contents. + */ +export function initRankedCandidatesStore(dbPath: string = resolveRankedCandidatesDbPath()): RankedCandidatesStore { + const resolvedPath = normalizeDbPath(dbPath); + const db = openLocalStoreDb(resolvedPath); + db.exec(` + CREATE TABLE IF NOT EXISTS miner_ranked_candidates ( + repo_full_name TEXT NOT NULL, + issue_number INTEGER NOT NULL, + title TEXT NOT NULL, + html_url TEXT, + rank_score REAL NOT NULL, + lane_fit REAL NOT NULL, + freshness REAL NOT NULL, + potential REAL NOT NULL, + feasibility REAL NOT NULL, + dup_risk REAL NOT NULL, + ranked_at TEXT NOT NULL, + PRIMARY KEY (repo_full_name, issue_number) + ) + `); + // Schema-version convention (#4832): stamp the baseline. No post-baseline migrations yet -- this is a new store. + applySchemaMigrations(db, []); + + const deleteAllStatement = db.prepare("DELETE FROM miner_ranked_candidates"); + const insertStatement = db.prepare(` + INSERT INTO miner_ranked_candidates + (repo_full_name, issue_number, title, html_url, rank_score, lane_fit, freshness, potential, feasibility, dup_risk, ranked_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + const listStatement = db.prepare("SELECT * FROM miner_ranked_candidates ORDER BY rank_score DESC"); + + // Atomic replace: a reader between the DELETE and the INSERTs must never observe an empty table mid-write. + // node:sqlite's DatabaseSync has no `.transaction()` helper (unlike better-sqlite3) -- mirrors + // portfolio-queue.js's batchClaim: explicit BEGIN IMMEDIATE/COMMIT, ROLLBACK + rethrow on failure. + function replaceAll(normalizedCandidates: NormalizedRankedCandidate[], rankedAt: string): void { + db.exec("BEGIN IMMEDIATE"); + try { + deleteAllStatement.run(); + for (const candidate of normalizedCandidates) { + insertStatement.run( + candidate.repoFullName, + candidate.issueNumber, + candidate.title, + candidate.htmlUrl, + candidate.rankScore, + candidate.laneFit, + candidate.freshness, + candidate.potential, + candidate.feasibility, + candidate.dupRisk, + rankedAt, + ); + } + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + } + + return { + dbPath: resolvedPath, + /** Replaces the whole snapshot wholesale with this run's ranked candidates. `nowMs` is caller-supplied + * (never reads the clock internally) so tests get a deterministic `rankedAt`. */ + saveRankedCandidates(candidates, nowMs) { + const normalized = (Array.isArray(candidates) ? candidates : []).map(normalizeCandidate); + const rankedAt = new Date(Number.isFinite(nowMs) ? (nowMs as number) : Date.now()).toISOString(); + replaceAll(normalized, rankedAt); + return { count: normalized.length, rankedAt }; + }, + /** Every candidate from the last saved run, highest rankScore first. Empty (not an error) before any + * discover run has ever saved a snapshot, or if the last run found zero candidates. */ + listRankedCandidates() { + return listStatement.all().map((row) => rowToCandidate(asRankedCandidateDbRow(row))); + }, + close() { + db.close(); + }, + }; +} + +function getDefaultRankedCandidatesStore(): RankedCandidatesStore { + defaultRankedCandidatesStore ??= initRankedCandidatesStore(); + return defaultRankedCandidatesStore; +} + +export function saveRankedCandidates(candidates: RankedCandidateInput[], nowMs?: number): RankedCandidatesSaveResult { + return getDefaultRankedCandidatesStore().saveRankedCandidates(candidates, nowMs); +} + +export function listRankedCandidates(): RankedCandidateRow[] { + return getDefaultRankedCandidatesStore().listRankedCandidates(); +} + +export function closeDefaultRankedCandidatesStore(): void { + if (!defaultRankedCandidatesStore) return; + defaultRankedCandidatesStore.close(); + defaultRankedCandidatesStore = null; +} diff --git a/packages/loopover-miner/lib/store-db-adapter.d.ts b/packages/loopover-miner/lib/store-db-adapter.d.ts index f858931a42..a25a3b2d90 100644 --- a/packages/loopover-miner/lib/store-db-adapter.d.ts +++ b/packages/loopover-miner/lib/store-db-adapter.d.ts @@ -1,30 +1,49 @@ import type { DatabaseSync } from "node:sqlite"; - /** Sync SQLite primitive both node:sqlite and (later) Postgres-backed drivers satisfy (#7175). */ export interface SqliteDriver { - query( - sql: string, - params: unknown[], - ): { rows: Record[]; changes: number; lastInsertRowid: number }; - exec(sql: string): void; + query(sql: string, params: unknown[]): { + rows: Record[]; + changes: number; + lastInsertRowid: number; + }; + exec(sql: string): void; } - /** Minimal D1-shaped surface returned by `createD1Adapter` (async wrappers over SqliteDriver). */ export interface MinerD1Database { - prepare(sql: string): MinerD1PreparedStatement; - batch(statements: MinerD1PreparedStatement[]): Promise; - exec(sql: string): Promise<{ count: number; duration: number }>; - dump(): Promise; + prepare(sql: string): MinerD1PreparedStatement; + batch(statements: MinerD1PreparedStatement[]): Promise; + exec(sql: string): Promise<{ + count: number; + duration: number; + }>; + dump(): Promise; } - export interface MinerD1PreparedStatement { - bind(...values: unknown[]): MinerD1PreparedStatement; - all(): Promise<{ results: T[]; success: true; meta: Record }>; - run(): Promise<{ results: T[]; success: true; meta: Record }>; - first(colName?: string): Promise; - raw(): Promise; + bind(...values: unknown[]): MinerD1PreparedStatement; + all(): Promise<{ + results: T[]; + success: true; + meta: Record; + }>; + run(): Promise<{ + results: T[]; + success: true; + meta: Record; + }>; + first(colName?: string): Promise; + raw(): Promise; } - -export function createD1Adapter(driver: SqliteDriver): MinerD1Database; - -export function nodeSqliteDriver(db: DatabaseSync): SqliteDriver; +/** + * Wrap a synchronous SqliteDriver as a D1-shaped database (async prepare/batch/exec). + */ +export declare function createD1Adapter(driver: SqliteDriver): MinerD1Database; +/** + * Build a SqliteDriver from a node:sqlite DatabaseSync. + * A statement with zero result columns is a WRITE; otherwise a READ. + * + * LIMITATION (#7175 follow-up): `INSERT/UPDATE/DELETE … RETURNING` statements report result columns, so + * this heuristic would treat them as reads and drop `changes`/`lastInsertRowid`. claim-ledger and other + * RETURNING callers must not migrate onto `driver.query` until the heuristic is sharpened (e.g. statement + * class detection) or those stores use `createD1Adapter`/`run` exclusively. + */ +export declare function nodeSqliteDriver(db: DatabaseSync): SqliteDriver; diff --git a/packages/loopover-miner/lib/store-db-adapter.js b/packages/loopover-miner/lib/store-db-adapter.js index 86eac0e99c..5083705fca 100644 --- a/packages/loopover-miner/lib/store-db-adapter.js +++ b/packages/loopover-miner/lib/store-db-adapter.js @@ -4,103 +4,84 @@ // inventing a second abstraction. Self-host default remains node:sqlite via `nodeSqliteDriver`. // Keep this surface in sync with the ORB module when either side grows (Postgres interactive txn / // `runOn` arrives in a later #7175 slice — not this file yet). - -/** - * @typedef {{ - * query: (sql: string, params: unknown[]) => { rows: Record[]; changes: number; lastInsertRowid: number }; - * exec: (sql: string) => void; - * }} SqliteDriver - */ - function meta(changes = 0, lastRowId = 0) { - return { - duration: 0, - size_after: 0, - rows_read: 0, - rows_written: changes, - last_row_id: lastRowId, - changed_db: changes > 0, - changes, - }; + return { + duration: 0, + size_after: 0, + rows_read: 0, + rows_written: changes, + last_row_id: lastRowId, + changed_db: changes > 0, + changes, + }; } - /** One prepared (and optionally bound) statement — D1 statements are immutable after bind. */ class Statement { - /** - * @param {SqliteDriver} driver - * @param {string} sql - * @param {unknown[]} [values] - */ - constructor(driver, sql, values = []) { - this.driver = driver; - this.sql = sql; - this.values = values; - } - - /** @param {...unknown} values */ - bind(...values) { - return new Statement(this.driver, this.sql, values); - } - - execSync() { - const r = this.driver.query(this.sql, this.values); - return { results: r.rows, success: true, meta: meta(r.changes, r.lastInsertRowid) }; - } - - async all() { - return this.execSync(); - } - - async run() { - return this.execSync(); - } - - /** @param {string} [colName] */ - async first(colName) { - const row = this.driver.query(this.sql, this.values).rows[0]; - if (row == null) return null; - return (colName != null ? row[colName] : row) ?? null; - } - - async raw() { - return this.driver.query(this.sql, this.values).rows.map((row) => Object.values(row)); - } + driver; + sql; + values; + constructor(driver, sql, values = []) { + this.driver = driver; + this.sql = sql; + this.values = values; + } + bind(...values) { + return new Statement(this.driver, this.sql, values); + } + execSync() { + const r = this.driver.query(this.sql, this.values); + return { results: r.rows, success: true, meta: meta(r.changes, r.lastInsertRowid) }; + } + async all() { + return this.execSync(); + } + async run() { + return this.execSync(); + } + async first(colName) { + const row = this.driver.query(this.sql, this.values).rows[0]; + if (row == null) + return null; + return ((colName != null ? row[colName] : row) ?? null); + } + async raw() { + return this.driver.query(this.sql, this.values).rows.map((row) => Object.values(row)); + } } - /** * Wrap a synchronous SqliteDriver as a D1-shaped database (async prepare/batch/exec). - * @param {SqliteDriver} driver */ export function createD1Adapter(driver) { - return { - prepare(sql) { - return new Statement(driver, sql); - }, - async batch(statements) { - driver.exec("BEGIN"); - try { - const out = statements.map((s) => s.execSync()); - driver.exec("COMMIT"); - return out; - } catch (error) { - try { - driver.exec("ROLLBACK"); - } catch { - /* ignore */ - } - throw error; - } - }, - async exec(sql) { - driver.exec(sql); - return { count: (sql.match(/;/g) ?? []).length || 1, duration: 0 }; - }, - async dump() { - return new ArrayBuffer(0); - }, - }; + return { + prepare(sql) { + return new Statement(driver, sql); + }, + async batch(statements) { + driver.exec("BEGIN"); + try { + const out = statements.map((s) => s.execSync()); + driver.exec("COMMIT"); + return out; + } + catch (error) { + try { + driver.exec("ROLLBACK"); + } + catch { + /* ignore */ + } + throw error; + } + }, + async exec(sql) { + driver.exec(sql); + return { count: (sql.match(/;/g) ?? []).length || 1, duration: 0 }; + }, + async dump() { + return new ArrayBuffer(0); + }, + }; } - /** * Build a SqliteDriver from a node:sqlite DatabaseSync. * A statement with zero result columns is a WRITE; otherwise a READ. @@ -109,21 +90,20 @@ export function createD1Adapter(driver) { * this heuristic would treat them as reads and drop `changes`/`lastInsertRowid`. claim-ledger and other * RETURNING callers must not migrate onto `driver.query` until the heuristic is sharpened (e.g. statement * class detection) or those stores use `createD1Adapter`/`run` exclusively. - * @param {{ prepare: (sql: string) => { columns: () => unknown[]; all: (...p: unknown[]) => unknown[]; run: (...p: unknown[]) => { changes: number | bigint; lastInsertRowid: number | bigint } }; exec: (sql: string) => void }} db - * @returns {SqliteDriver} */ export function nodeSqliteDriver(db) { - return { - query(sql, params) { - const stmt = db.prepare(sql); - if (stmt.columns().length > 0) { - return { rows: /** @type {Record[]} */ (stmt.all(...params)), changes: 0, lastInsertRowid: 0 }; - } - const info = stmt.run(...params); - return { rows: [], changes: Number(info.changes), lastInsertRowid: Number(info.lastInsertRowid) }; - }, - exec(sql) { - db.exec(sql); - }, - }; + return { + query(sql, params) { + const stmt = db.prepare(sql); + if (stmt.columns().length > 0) { + return { rows: stmt.all(...params), changes: 0, lastInsertRowid: 0 }; + } + const info = stmt.run(...params); + return { rows: [], changes: Number(info.changes), lastInsertRowid: Number(info.lastInsertRowid) }; + }, + exec(sql) { + db.exec(sql); + }, + }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic3RvcmUtZGItYWRhcHRlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInN0b3JlLWRiLWFkYXB0ZXIudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsNkVBQTZFO0FBQzdFLEVBQUU7QUFDRix1R0FBdUc7QUFDdkcsZ0dBQWdHO0FBQ2hHLG1HQUFtRztBQUNuRywrREFBK0Q7QUE2Qi9ELFNBQVMsSUFBSSxDQUFDLE9BQU8sR0FBRyxDQUFDLEVBQUUsU0FBUyxHQUFHLENBQUM7SUFDdEMsT0FBTztRQUNMLFFBQVEsRUFBRSxDQUFDO1FBQ1gsVUFBVSxFQUFFLENBQUM7UUFDYixTQUFTLEVBQUUsQ0FBQztRQUNaLFlBQVksRUFBRSxPQUFPO1FBQ3JCLFdBQVcsRUFBRSxTQUFTO1FBQ3RCLFVBQVUsRUFBRSxPQUFPLEdBQUcsQ0FBQztRQUN2QixPQUFPO0tBQ1IsQ0FBQztBQUNKLENBQUM7QUFFRCw4RkFBOEY7QUFDOUYsTUFBTSxTQUFTO0lBQ2IsTUFBTSxDQUFlO0lBQ3JCLEdBQUcsQ0FBUztJQUNaLE1BQU0sQ0FBWTtJQUVsQixZQUFZLE1BQW9CLEVBQUUsR0FBVyxFQUFFLFNBQW9CLEVBQUU7UUFDbkUsSUFBSSxDQUFDLE1BQU0sR0FBRyxNQUFNLENBQUM7UUFDckIsSUFBSSxDQUFDLEdBQUcsR0FBRyxHQUFHLENBQUM7UUFDZixJQUFJLENBQUMsTUFBTSxHQUFHLE1BQU0sQ0FBQztJQUN2QixDQUFDO0lBRUQsSUFBSSxDQUFDLEdBQUcsTUFBaUI7UUFDdkIsT0FBTyxJQUFJLFNBQVMsQ0FBQyxJQUFJLENBQUMsTUFBTSxFQUFFLElBQUksQ0FBQyxHQUFHLEVBQUUsTUFBTSxDQUFDLENBQUM7SUFDdEQsQ0FBQztJQUVELFFBQVE7UUFDTixNQUFNLENBQUMsR0FBRyxJQUFJLENBQUMsTUFBTSxDQUFDLEtBQUssQ0FBQyxJQUFJLENBQUMsR0FBRyxFQUFFLElBQUksQ0FBQyxNQUFNLENBQUMsQ0FBQztRQUNuRCxPQUFPLEVBQUUsT0FBTyxF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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/store-db-adapter.ts b/packages/loopover-miner/lib/store-db-adapter.ts new file mode 100644 index 0000000000..c99949ea46 --- /dev/null +++ b/packages/loopover-miner/lib/store-db-adapter.ts @@ -0,0 +1,143 @@ +// Shared SqliteDriver / D1 adapter seam for AMS local stores (#7175 part 1). +// +// Mirrors ORB's `src/selfhost/d1-adapter.ts` so hosted AMS can later swap in `createPgAdapter` without +// inventing a second abstraction. Self-host default remains node:sqlite via `nodeSqliteDriver`. +// Keep this surface in sync with the ORB module when either side grows (Postgres interactive txn / +// `runOn` arrives in a later #7175 slice — not this file yet). + +import type { DatabaseSync, SQLInputValue } from "node:sqlite"; + +/** Sync SQLite primitive both node:sqlite and (later) Postgres-backed drivers satisfy (#7175). */ +export interface SqliteDriver { + query( + sql: string, + params: unknown[], + ): { rows: Record[]; changes: number; lastInsertRowid: number }; + exec(sql: string): void; +} + +/** Minimal D1-shaped surface returned by `createD1Adapter` (async wrappers over SqliteDriver). */ +export interface MinerD1Database { + prepare(sql: string): MinerD1PreparedStatement; + batch(statements: MinerD1PreparedStatement[]): Promise; + exec(sql: string): Promise<{ count: number; duration: number }>; + dump(): Promise; +} + +export interface MinerD1PreparedStatement { + bind(...values: unknown[]): MinerD1PreparedStatement; + all(): Promise<{ results: T[]; success: true; meta: Record }>; + run(): Promise<{ results: T[]; success: true; meta: Record }>; + first(colName?: string): Promise; + raw(): Promise; +} + +function meta(changes = 0, lastRowId = 0): Record { + return { + duration: 0, + size_after: 0, + rows_read: 0, + rows_written: changes, + last_row_id: lastRowId, + changed_db: changes > 0, + changes, + }; +} + +/** One prepared (and optionally bound) statement — D1 statements are immutable after bind. */ +class Statement implements MinerD1PreparedStatement { + driver: SqliteDriver; + sql: string; + values: unknown[]; + + constructor(driver: SqliteDriver, sql: string, values: unknown[] = []) { + this.driver = driver; + this.sql = sql; + this.values = values; + } + + bind(...values: unknown[]): MinerD1PreparedStatement { + return new Statement(this.driver, this.sql, values); + } + + execSync(): { results: Record[]; success: true; meta: Record } { + const r = this.driver.query(this.sql, this.values); + return { results: r.rows, success: true, meta: meta(r.changes, r.lastInsertRowid) }; + } + + async all(): Promise<{ results: T[]; success: true; meta: Record }> { + return this.execSync() as unknown as { results: T[]; success: true; meta: Record }; + } + + async run(): Promise<{ results: T[]; success: true; meta: Record }> { + return this.execSync() as unknown as { results: T[]; success: true; meta: Record }; + } + + async first(colName?: string): Promise { + const row = this.driver.query(this.sql, this.values).rows[0]; + if (row == null) return null; + return ((colName != null ? row[colName] : row) ?? null) as T | null; + } + + async raw(): Promise { + return this.driver.query(this.sql, this.values).rows.map((row) => Object.values(row)) as unknown as T[]; + } +} + +/** + * Wrap a synchronous SqliteDriver as a D1-shaped database (async prepare/batch/exec). + */ +export function createD1Adapter(driver: SqliteDriver): MinerD1Database { + return { + prepare(sql: string) { + return new Statement(driver, sql); + }, + async batch(statements: MinerD1PreparedStatement[]) { + driver.exec("BEGIN"); + try { + const out = statements.map((s) => (s as Statement).execSync()); + driver.exec("COMMIT"); + return out; + } catch (error) { + try { + driver.exec("ROLLBACK"); + } catch { + /* ignore */ + } + throw error; + } + }, + async exec(sql: string) { + driver.exec(sql); + return { count: (sql.match(/;/g) ?? []).length || 1, duration: 0 }; + }, + async dump() { + return new ArrayBuffer(0); + }, + }; +} + +/** + * Build a SqliteDriver from a node:sqlite DatabaseSync. + * A statement with zero result columns is a WRITE; otherwise a READ. + * + * LIMITATION (#7175 follow-up): `INSERT/UPDATE/DELETE … RETURNING` statements report result columns, so + * this heuristic would treat them as reads and drop `changes`/`lastInsertRowid`. claim-ledger and other + * RETURNING callers must not migrate onto `driver.query` until the heuristic is sharpened (e.g. statement + * class detection) or those stores use `createD1Adapter`/`run` exclusively. + */ +export function nodeSqliteDriver(db: DatabaseSync): SqliteDriver { + return { + query(sql: string, params: unknown[]) { + const stmt = db.prepare(sql); + if (stmt.columns().length > 0) { + return { rows: stmt.all(...(params as SQLInputValue[])), changes: 0, lastInsertRowid: 0 }; + } + const info = stmt.run(...(params as SQLInputValue[])); + return { rows: [], changes: Number(info.changes), lastInsertRowid: Number(info.lastInsertRowid) }; + }, + exec(sql: string) { + db.exec(sql); + }, + }; +} diff --git a/test/unit/miner-opportunity-ranker.test.ts b/test/unit/miner-opportunity-ranker.test.ts index 72bf5eb0da..f2b777d0b3 100644 --- a/test/unit/miner-opportunity-ranker.test.ts +++ b/test/unit/miner-opportunity-ranker.test.ts @@ -281,4 +281,32 @@ describe("rankCandidateIssues (#2302 follow-up)", () => { expect(ranked[0]?.freshness).toBeGreaterThan(0); vi.useRealTimers(); }); + + it("drops slugs with extra path segments and normalizes malformed optional metadata", () => { + const summary = rankCandidateIssuesWithSummary( + [ + rawIssue({ repoFullName: "acme/widgets/extra" }), + { + repoFullName: "acme/widgets", + issueNumber: 7, + title: "Sparse metadata", + commentsCount: "lots", + aiPolicyAllowed: true, + aiPolicySource: "somethingelse", + } as unknown as ReturnType, + ], + { nowMs: NOW }, + ); + + // The three-segment slug is rejected the same as any other malformed repo. + expect(summary.skippedInvalid).toBe(1); + const issue = summary.issues[0]; + expect(issue?.issueNumber).toBe(7); + // Absent labels, non-finite commentsCount, missing timestamps and an unrecognized ai-policy source all fall back cleanly. + expect(issue?.labels).toEqual([]); + expect(issue?.commentsCount).toBe(0); + expect(issue?.createdAt).toBeNull(); + expect(issue?.updatedAt).toBeNull(); + expect(issue?.aiPolicySource).toBe("none"); + }); }); diff --git a/test/unit/miner-portfolio-queue-manager.test.ts b/test/unit/miner-portfolio-queue-manager.test.ts index 332c953db4..79d8fbc2da 100644 --- a/test/unit/miner-portfolio-queue-manager.test.ts +++ b/test/unit/miner-portfolio-queue-manager.test.ts @@ -198,4 +198,30 @@ describe("initPortfolioQueueManager().claimNextBatch() (#4285)", () => { expect(claimed.map((entry) => entry.identifier)).toEqual(["two"]); }); + + it("skips entries with a non-string repoFullName/identifier and tolerates non-array input", () => { + expect(entriesToPortfolioQueue(undefined as never).buckets).toEqual([]); + const buckets = entriesToPortfolioQueue([ + { repoFullName: 123, identifier: "x", priority: 0, status: "queued", enqueuedAt: "t1" }, + { repoFullName: "acme/alpha", identifier: 456, priority: 0, status: "queued", enqueuedAt: "t2" }, + { repoFullName: "acme/alpha", identifier: "ok", priority: 0, status: "queued", enqueuedAt: "t3" }, + ] as unknown as QueueEntry[]).buckets; + expect(buckets.map((bucket) => bucket.repoFullName)).toEqual(["acme/alpha"]); + expect(buckets[0]?.items.map((item) => item.id)).toHaveLength(1); + }); + + it("opens its own store and applies default caps/lease when only a dbPath is supplied", () => { + const manager = initPortfolioQueueManager({ dbPath: ":memory:", staleLeaseMs: 1000 }); + try { + // caps omitted -> the manager's own default of one global / one per-repo slot. + expect(manager.caps).toEqual({ globalWipCap: 1, perRepoWipCap: 1 }); + expect(manager.dbPath).toBe(":memory:"); + manager.enqueue({ repoFullName: "acme/alpha", identifier: "x" }); + // reclaimStuckItems is a no-op on a fresh lease, both with an explicit and the default lease bound. + expect(manager.reclaimStuckItems(1000)).toEqual([]); + expect(manager.reclaimStuckItems()).toEqual([]); + } finally { + manager.close(); + } + }); }); diff --git a/test/unit/miner-pr-disposition-poller.test.ts b/test/unit/miner-pr-disposition-poller.test.ts index 6c2c92234f..1f1cc1fba5 100644 --- a/test/unit/miner-pr-disposition-poller.test.ts +++ b/test/unit/miner-pr-disposition-poller.test.ts @@ -192,6 +192,68 @@ describe("PR disposition poller (#5135)", () => { expect(timeoutSpy).toHaveBeenCalledWith(3000); timeoutSpy.mockRestore(); }); + + it("treats a blank apiBaseUrl as the default GitHub API base URL", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + expect(String(input)).toBe("https://api.github.com/repos/acme/widgets/pulls/22"); + return prResponse({ state: "closed", merged: true }); + }); + + await expect( + pollPrDisposition("acme/widgets", 22, { apiBaseUrl: " ", fetchFn }), + ).resolves.toMatchObject({ merged: true }); + }); + + it("rejects a repoFullName that carries extra path segments or is not a string", async () => { + const fetchFn = vi.fn(); + await expect( + pollPrDisposition("acme/widgets/extra", 1, { apiBaseUrl: API, fetchFn }), + ).rejects.toThrow("invalid_repo_full_name"); + await expect( + pollPrDisposition(42 as never, 1, { apiBaseUrl: API, fetchFn }), + ).rejects.toThrow("invalid_repo_full_name"); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("surfaces a GitHub error response with no message body as the bare status code", async () => { + const fetchFn = vi.fn().mockResolvedValueOnce(jsonResponse({}, { status: 404 })); + await expect(pollPrDisposition("acme/widgets", 23, { apiBaseUrl: API, fetchFn })).rejects.toThrow( + /^github_404$/, + ); + }); + + it("falls back to the global fetch when no fetchFn is injected", async () => { + const globalFetch = vi.fn(async () => + prResponse({ state: "closed", merged: true, closed_at: "2026-07-12T00:00:00Z" }), + ); + vi.stubGlobal("fetch", globalFetch); + try { + const result = await pollPrDisposition("acme/widgets", 21, { apiBaseUrl: API, sleepFn: async () => {} }); + expect(result).toMatchObject({ state: "closed", merged: true }); + expect(globalFetch).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("uses the built-in setTimeout backoff when no sleepFn is injected", async () => { + const fetchFn = vi + .fn() + .mockResolvedValueOnce(prResponse({ state: "open" })) + .mockResolvedValueOnce(prResponse({ state: "closed", merged: true, closed_at: "2026-07-12T00:00:00Z" })); + + // No sleepFn -> the default `setTimeout`-backed sleep runs; a 1ms interval keeps the real timer instant. + const result = await pollPrDisposition("acme/widgets", 20, { + apiBaseUrl: API, + fetchFn, + maxAttempts: 2, + minIntervalMs: 1, + maxIntervalMs: 1, + }); + + expect(result).toMatchObject({ state: "closed", merged: true, attempts: 2 }); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); }); describe("classifyPrDisposition (#5135)", () => { diff --git a/test/unit/miner-store-db-adapter.test.ts b/test/unit/miner-store-db-adapter.test.ts index 5ab25d1346..7f6422cadd 100644 --- a/test/unit/miner-store-db-adapter.test.ts +++ b/test/unit/miner-store-db-adapter.test.ts @@ -70,4 +70,15 @@ describe("miner store-db-adapter seam (#7175 part 1)", () => { const db = new DatabaseSync(":memory:"); expect(await createD1Adapter(nodeSqliteDriver(db)).dump()).toBeInstanceOf(ArrayBuffer); }); + + it("first(colName) returns the named column, and null when that column's value is null", async () => { + const db = new DatabaseSync(":memory:"); + const d1 = createD1Adapter(nodeSqliteDriver(db)); + await d1.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)"); + await d1.prepare("INSERT INTO t (name) VALUES (?)").bind("a").run(); + await d1.prepare("INSERT INTO t (name) VALUES (NULL)").run(); + expect(await d1.prepare("SELECT name FROM t WHERE id = ?").bind(1).first("name")).toBe("a"); + // A present row whose selected column is SQL NULL still resolves to null, not undefined. + expect(await d1.prepare("SELECT name FROM t WHERE id = ?").bind(2).first("name")).toBeNull(); + }); });