diff --git a/apps/loopover-ui/src/lib/ams-env-reference.ts b/apps/loopover-ui/src/lib/ams-env-reference.ts index ca01dde79b..bcca7cfd6d 100644 --- a/apps/loopover-ui/src/lib/ams-env-reference.ts +++ b/apps/loopover-ui/src/lib/ams-env-reference.ts @@ -151,11 +151,6 @@ export const AMS_ENV_REFERENCE_ROWS: MinerEnvReferenceRow[] = [ firstReference: "lib/replay-snapshot.js", defaultValue: null, }, - { - name: "LOOPOVER_MINER_REPO_CLONE_DIR", - firstReference: "lib/repo-clone.js", - defaultValue: "", - }, { name: "LOOPOVER_MINER_RUN_STATE_DB", firstReference: "lib/run-state.js", @@ -249,7 +244,6 @@ export const AMS_ENV_REFERENCE_MARKDOWN = [ "| `LOOPOVER_MINER_PREDICTION_LEDGER_DB` | `lib/prediction-ledger.js` | (none) |", "| `LOOPOVER_MINER_RANKED_CANDIDATES_DB` | `lib/ranked-candidates.js` | (none) |", "| `LOOPOVER_MINER_REPLAY_SNAPSHOT_DB` | `lib/replay-snapshot.js` | (none) |", - '| `LOOPOVER_MINER_REPO_CLONE_DIR` | `lib/repo-clone.js` | `""` |', "| `LOOPOVER_MINER_RUN_STATE_DB` | `lib/run-state.js` | (none) |", "| `LOOPOVER_MINER_SENTRY_DSN` | `lib/sentry.js` | (none) |", '| `LOOPOVER_MINER_SENTRY_ENVIRONMENT` | `lib/sentry.js` | `"production"` |', diff --git a/packages/loopover-miner/docs/env-reference.md b/packages/loopover-miner/docs/env-reference.md index b042941a4f..b95e770147 100644 --- a/packages/loopover-miner/docs/env-reference.md +++ b/packages/loopover-miner/docs/env-reference.md @@ -33,7 +33,6 @@ Generated by `npm run miner:env-reference`. Do not edit manually. | `LOOPOVER_MINER_PREDICTION_LEDGER_DB` | `lib/prediction-ledger.js` | (none) | | `LOOPOVER_MINER_RANKED_CANDIDATES_DB` | `lib/ranked-candidates.js` | (none) | | `LOOPOVER_MINER_REPLAY_SNAPSHOT_DB` | `lib/replay-snapshot.js` | (none) | -| `LOOPOVER_MINER_REPO_CLONE_DIR` | `lib/repo-clone.js` | `""` | | `LOOPOVER_MINER_RUN_STATE_DB` | `lib/run-state.js` | (none) | | `LOOPOVER_MINER_SENTRY_DSN` | `lib/sentry.js` | (none) | | `LOOPOVER_MINER_SENTRY_ENVIRONMENT` | `lib/sentry.js` | `"production"` | diff --git a/packages/loopover-miner/lib/chat-action-registry.d.ts b/packages/loopover-miner/lib/chat-action-registry.d.ts index 3066db03e3..d7b370ba9a 100644 --- a/packages/loopover-miner/lib/chat-action-registry.d.ts +++ b/packages/loopover-miner/lib/chat-action-registry.d.ts @@ -1,42 +1,43 @@ export type ChatActionRequest = { - action?: string; - params?: unknown; - governorInput?: unknown; + action?: string; + params?: unknown; + governorInput?: unknown; }; - /** A handler produced by {@link governorGatedHandler}; the only shape {@link ChatActionRegistry.register} accepts. */ export type GovernorGatedHandler = (request: ChatActionRequest) => Promise>; - export type ChatActionDefinition = { - paramsValidator: (params: unknown) => boolean; - handler: GovernorGatedHandler; + paramsValidator: (params: unknown) => boolean; + handler: GovernorGatedHandler; }; - export type ChatActionEntry = { - paramsValidator: (params: unknown) => boolean; - handler: GovernorGatedHandler; + paramsValidator: (params: unknown) => boolean; + handler: GovernorGatedHandler; }; - export type ChatActionRegistry = { - register(name: string, definition: ChatActionDefinition): ChatActionEntry; - get(name: string): ChatActionEntry | undefined; - has(name: string): boolean; - names(): string[]; - readonly size: number; + register(name: string, definition: ChatActionDefinition): ChatActionEntry; + get(name: string): ChatActionEntry | undefined; + has(name: string): boolean; + names(): string[]; + readonly size: number; }; - -export function governorGatedHandler( - run: (request: ChatActionRequest, gate: unknown) => unknown, - options?: { +/** + * Wrap a local-write `run` function into a Governor-gated chat-action handler. The returned handler + * evaluates the write against the full precedence ladder (via `evaluateGovernorChokepointGate`) BEFORE + * running `run`, and only invokes `run` on a final `"allow"` verdict -- any other stage returns a gated + * result and `run` never executes. This is the ONLY factory that produces a handler `register` accepts. + */ +export declare function governorGatedHandler(run: (request: ChatActionRequest, gate: unknown) => unknown, options?: { evaluateGate?: (input: unknown, gateOptions?: unknown) => unknown; gateOptions?: unknown; - }, -): GovernorGatedHandler; - -export function isGovernorGatedHandler(handler: unknown): boolean; - -export function createChatActionRegistry(): ChatActionRegistry; - -export const chatActionRegistry: ChatActionRegistry; - -export function registerChatAction(name: string, definition: ChatActionDefinition): ChatActionEntry; +}): GovernorGatedHandler; +/** True only for a handler produced by {@link governorGatedHandler}. */ +export declare function isGovernorGatedHandler(handler: unknown): boolean; +/** + * Build an isolated chat-action registry. Child issues register into the shared {@link chatActionRegistry}; + * this factory exists so tests (and any future multi-registry consumer) can register without polluting it. + */ +export declare function createChatActionRegistry(): ChatActionRegistry; +/** The single shared registry the dispatch layer reads. Ships EMPTY (#6519); child issues register into it. */ +export declare const chatActionRegistry: ChatActionRegistry; +/** Register a chat action on the shared {@link chatActionRegistry}. */ +export declare function registerChatAction(name: string, definition: ChatActionDefinition): ChatActionEntry; diff --git a/packages/loopover-miner/lib/chat-action-registry.js b/packages/loopover-miner/lib/chat-action-registry.js index aa3f2837d5..88c0537e1e 100644 --- a/packages/loopover-miner/lib/chat-action-registry.js +++ b/packages/loopover-miner/lib/chat-action-registry.js @@ -11,93 +11,80 @@ // function can never be registered, a chat action can never perform a write on a path that bypasses the // Governor chokepoint -- the contract enforces it structurally, not by review discipline. This module adds // no second, competing safety check; it only forces every registered handler onto the existing one. - import { evaluateGovernorChokepointGate } from "./governor-chokepoint.js"; - // Private brand. Not exported, so external code cannot forge a "gated" marker onto a raw function: the only // way to obtain a handler that passes `isGovernorGatedHandler` is to build it through `governorGatedHandler`. const GOVERNOR_GATED = Symbol("loopover.chat-action.governor-gated"); - /** * Wrap a local-write `run` function into a Governor-gated chat-action handler. The returned handler * evaluates the write against the full precedence ladder (via `evaluateGovernorChokepointGate`) BEFORE * running `run`, and only invokes `run` on a final `"allow"` verdict -- any other stage returns a gated * result and `run` never executes. This is the ONLY factory that produces a handler `register` accepts. - * - * @param {(request: unknown, gate: object) => unknown} run the local write to perform once the gate allows - * @param {{ evaluateGate?: typeof evaluateGovernorChokepointGate, gateOptions?: object }} [options] - * @returns {((request: { governorInput?: unknown }) => Promise)} */ export function governorGatedHandler(run, options = {}) { - if (typeof run !== "function") { - throw new TypeError("governorGatedHandler(run): run must be a function"); - } - const evaluateGate = options.evaluateGate ?? evaluateGovernorChokepointGate; - if (typeof evaluateGate !== "function") { - throw new TypeError("governorGatedHandler: options.evaluateGate must be a function when supplied"); - } - - const handler = async (request) => { - const gate = evaluateGate(request?.governorInput, options.gateOptions); - if (gate?.decision?.stage !== "allow") { - return { ok: false, status: "gated", decision: gate?.decision ?? null }; + if (typeof run !== "function") { + throw new TypeError("governorGatedHandler(run): run must be a function"); } - const result = await run(request, gate); - return { ok: true, status: "executed", decision: gate.decision, result }; - }; - Object.defineProperty(handler, GOVERNOR_GATED, { value: true }); - return handler; + // Widen the default chokepoint evaluator to the registry's `unknown` input contract (chat requests carry + // opaque governorInput); runtime still passes the same value through unchanged. + const evaluateGate = options.evaluateGate ?? evaluateGovernorChokepointGate; + if (typeof evaluateGate !== "function") { + throw new TypeError("governorGatedHandler: options.evaluateGate must be a function when supplied"); + } + const handler = (async (request) => { + const gate = evaluateGate(request?.governorInput, options.gateOptions); + if (gate?.decision?.stage !== "allow") { + return { ok: false, status: "gated", decision: gate?.decision ?? null }; + } + const result = await run(request, gate); + return { ok: true, status: "executed", decision: gate.decision, result }; + }); + Object.defineProperty(handler, GOVERNOR_GATED, { value: true }); + return handler; } - /** True only for a handler produced by {@link governorGatedHandler}. */ export function isGovernorGatedHandler(handler) { - return typeof handler === "function" && handler[GOVERNOR_GATED] === true; + return typeof handler === "function" && handler[GOVERNOR_GATED] === true; } - /** * Build an isolated chat-action registry. Child issues register into the shared {@link chatActionRegistry}; * this factory exists so tests (and any future multi-registry consumer) can register without polluting it. */ export function createChatActionRegistry() { - const actions = new Map(); - - function register(name, definition = {}) { - if (typeof name !== "string" || name.trim() === "") { - throw new TypeError("registerChatAction(name): name must be a non-empty string"); - } - if (actions.has(name)) { - throw new Error(`registerChatAction: action "${name}" is already registered`); - } - const { paramsValidator, handler } = definition; - if (typeof paramsValidator !== "function") { - throw new TypeError(`registerChatAction("${name}"): paramsValidator must be a function`); - } - if (!isGovernorGatedHandler(handler)) { - throw new Error( - `registerChatAction("${name}"): handler must be produced by governorGatedHandler() so every ` + - "chat-triggered write routes through governor-chokepoint.js -- a raw handler is rejected.", - ); + const actions = new Map(); + function register(name, definition = {}) { + if (typeof name !== "string" || name.trim() === "") { + throw new TypeError("registerChatAction(name): name must be a non-empty string"); + } + if (actions.has(name)) { + throw new Error(`registerChatAction: action "${name}" is already registered`); + } + const { paramsValidator, handler } = definition; + if (typeof paramsValidator !== "function") { + throw new TypeError(`registerChatAction("${name}"): paramsValidator must be a function`); + } + if (!isGovernorGatedHandler(handler)) { + throw new Error(`registerChatAction("${name}"): handler must be produced by governorGatedHandler() so every ` + + "chat-triggered write routes through governor-chokepoint.js -- a raw handler is rejected."); + } + const entry = { paramsValidator, handler }; + actions.set(name, entry); + return entry; } - const entry = { paramsValidator, handler }; - actions.set(name, entry); - return entry; - } - - return { - register, - get: (name) => actions.get(name), - has: (name) => actions.has(name), - names: () => [...actions.keys()], - get size() { - return actions.size; - }, - }; + return { + register, + get: (name) => actions.get(name), + has: (name) => actions.has(name), + names: () => [...actions.keys()], + get size() { + return actions.size; + }, + }; } - /** The single shared registry the dispatch layer reads. Ships EMPTY (#6519); child issues register into it. */ export const chatActionRegistry = createChatActionRegistry(); - /** Register a chat action on the shared {@link chatActionRegistry}. */ export function registerChatAction(name, definition) { - return chatActionRegistry.register(name, definition); + return chatActionRegistry.register(name, definition); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2hhdC1hY3Rpb24tcmVnaXN0cnkuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJjaGF0LWFjdGlvbi1yZWdpc3RyeS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSw4RkFBOEY7QUFDOUYsRUFBRTtBQUNGLHlHQUF5RztBQUN6RywyR0FBMkc7QUFDM0csb0dBQW9HO0FBQ3BHLEVBQUU7QUFDRiw0R0FBNEc7QUFDNUcseUZBQXlGO0FBQ3pGLDZHQUE2RztBQUM3RyxzR0FBc0c7QUFDdEcsd0dBQXdHO0FBQ3hHLDJHQUEyRztBQUMzRyxvR0FBb0c7QUFFcEcsT0FBTyxFQUFFLDhCQUE4QixFQUFFLE1BQU0sMEJBQTBCLENBQUM7QUE2QjFFLDRHQUE0RztBQUM1Ryw4R0FBOEc7QUFDOUcsTUFBTSxjQUFjLEdBQUcsTUFBTSxDQUFDLHFDQUFxQyxDQUFDLENBQUM7QUFRckU7Ozs7O0dBS0c7QUFDSCxNQUFNLFVBQVUsb0JBQW9CLENBQ2xDLEdBQTJELEVBQzNELFVBR0ksRUFBRTtJQUVOLElBQUksT0FBTyxHQUFHLEtBQUssVUFBVSxFQUFFLENBQUM7UUFDOUIsTUFBTSxJQUFJLFNBQVMsQ0FBQyxtREFBbUQsQ0FBQyxDQUFDO0lBQzNFLENBQUM7SUFDRCx5R0FBeUc7SUFDekcsZ0ZBQWdGO0lBQ2hGLE1BQU0sWUFBWSxHQUNoQixPQUFPLENBQUMsWUFBWSxJQUFLLDhCQUFxRixDQUFDO0lBQ2pILElBQUksT0FBTyxZQUFZLEtBQUssVUFBVSxFQUFFLENBQUM7UUFDdkMsTUFBTSxJQUFJLFNBQVMsQ0FBQyw2RUFBNkUsQ0FBQyxDQUFDO0lBQ3JHLENBQUM7SUFFRCxNQUFNLE9BQU8sR0FBRyxDQUFDLEtBQUssRUFBRSxPQUEwQixFQUFvQyxFQUFFO1FBQ3RGLE1BQU0sSUFBSSxHQUFHLFlBQVksQ0FBQyxPQUFPLEVBQUUsYUFBYSxFQUFFLE9BQU8sQ0FBQyxXQUFXLENBQXlCLENBQUM7UUFDL0YsSUFBSSxJQUFJLEVBQUUsUUFBUSxFQUFFLEtB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/chat-action-registry.ts b/packages/loopover-miner/lib/chat-action-registry.ts new file mode 100644 index 0000000000..e90fa73c4a --- /dev/null +++ b/packages/loopover-miner/lib/chat-action-registry.ts @@ -0,0 +1,141 @@ +// Allowlist registry + governor-gated handler contract for chat-issued miner actions (#6519). +// +// Shared scaffolding ONLY: this module ships with ZERO registered actions. The three action-family child +// issues (portfolio release/requeue, governor pause/resume, discover/attempt) register their handlers into +// this registry -- none are added here, and the default `chatActionRegistry` instance starts empty. +// +// The registration contract is the safety boundary. `register` refuses any handler that was not produced by +// `governorGatedHandler()`, and `governorGatedHandler()` routes every invocation through +// `evaluateGovernorChokepointGate` (packages/loopover-miner/lib/governor-chokepoint.js) and, through it, the +// fail-closed precedence ladder in packages/loopover-engine/src/governor/chokepoint.ts. Because a raw +// function can never be registered, a chat action can never perform a write on a path that bypasses the +// Governor chokepoint -- the contract enforces it structurally, not by review discipline. This module adds +// no second, competing safety check; it only forces every registered handler onto the existing one. + +import { evaluateGovernorChokepointGate } from "./governor-chokepoint.js"; + +export type ChatActionRequest = { + action?: string; + params?: unknown; + governorInput?: unknown; +}; + +/** A handler produced by {@link governorGatedHandler}; the only shape {@link ChatActionRegistry.register} accepts. */ +export type GovernorGatedHandler = (request: ChatActionRequest) => Promise>; + +export type ChatActionDefinition = { + paramsValidator: (params: unknown) => boolean; + handler: GovernorGatedHandler; +}; + +export type ChatActionEntry = { + paramsValidator: (params: unknown) => boolean; + handler: GovernorGatedHandler; +}; + +export type ChatActionRegistry = { + register(name: string, definition: ChatActionDefinition): ChatActionEntry; + get(name: string): ChatActionEntry | undefined; + has(name: string): boolean; + names(): string[]; + readonly size: number; +}; + +// Private brand. Not exported, so external code cannot forge a "gated" marker onto a raw function: the only +// way to obtain a handler that passes `isGovernorGatedHandler` is to build it through `governorGatedHandler`. +const GOVERNOR_GATED = Symbol("loopover.chat-action.governor-gated"); + +type GovernorGatedBrand = { [GOVERNOR_GATED]?: true }; + +type ChokepointGateResult = { + decision?: { stage?: string } | null; +} | null | undefined; + +/** + * Wrap a local-write `run` function into a Governor-gated chat-action handler. The returned handler + * evaluates the write against the full precedence ladder (via `evaluateGovernorChokepointGate`) BEFORE + * running `run`, and only invokes `run` on a final `"allow"` verdict -- any other stage returns a gated + * result and `run` never executes. This is the ONLY factory that produces a handler `register` accepts. + */ +export function governorGatedHandler( + run: (request: ChatActionRequest, gate: unknown) => unknown, + options: { + evaluateGate?: (input: unknown, gateOptions?: unknown) => unknown; + gateOptions?: unknown; + } = {}, +): GovernorGatedHandler { + if (typeof run !== "function") { + throw new TypeError("governorGatedHandler(run): run must be a function"); + } + // Widen the default chokepoint evaluator to the registry's `unknown` input contract (chat requests carry + // opaque governorInput); runtime still passes the same value through unchanged. + const evaluateGate: (input: unknown, gateOptions?: unknown) => unknown = + options.evaluateGate ?? (evaluateGovernorChokepointGate as (input: unknown, gateOptions?: unknown) => unknown); + if (typeof evaluateGate !== "function") { + throw new TypeError("governorGatedHandler: options.evaluateGate must be a function when supplied"); + } + + const handler = (async (request: ChatActionRequest): Promise> => { + const gate = evaluateGate(request?.governorInput, options.gateOptions) as ChokepointGateResult; + if (gate?.decision?.stage !== "allow") { + return { ok: false, status: "gated", decision: gate?.decision ?? null }; + } + const result = await run(request, gate); + return { ok: true, status: "executed", decision: gate.decision, result }; + }) as GovernorGatedHandler & GovernorGatedBrand; + Object.defineProperty(handler, GOVERNOR_GATED, { value: true }); + return handler; +} + +/** True only for a handler produced by {@link governorGatedHandler}. */ +export function isGovernorGatedHandler(handler: unknown): boolean { + return typeof handler === "function" && (handler as GovernorGatedBrand)[GOVERNOR_GATED] === true; +} + +/** + * Build an isolated chat-action registry. Child issues register into the shared {@link chatActionRegistry}; + * this factory exists so tests (and any future multi-registry consumer) can register without polluting it. + */ +export function createChatActionRegistry(): ChatActionRegistry { + const actions = new Map(); + + function register(name: string, definition: ChatActionDefinition = {} as ChatActionDefinition): ChatActionEntry { + if (typeof name !== "string" || name.trim() === "") { + throw new TypeError("registerChatAction(name): name must be a non-empty string"); + } + if (actions.has(name)) { + throw new Error(`registerChatAction: action "${name}" is already registered`); + } + const { paramsValidator, handler } = definition; + if (typeof paramsValidator !== "function") { + throw new TypeError(`registerChatAction("${name}"): paramsValidator must be a function`); + } + if (!isGovernorGatedHandler(handler)) { + throw new Error( + `registerChatAction("${name}"): handler must be produced by governorGatedHandler() so every ` + + "chat-triggered write routes through governor-chokepoint.js -- a raw handler is rejected.", + ); + } + const entry: ChatActionEntry = { paramsValidator, handler }; + actions.set(name, entry); + return entry; + } + + return { + register, + get: (name) => actions.get(name), + has: (name) => actions.has(name), + names: () => [...actions.keys()], + get size() { + return actions.size; + }, + }; +} + +/** The single shared registry the dispatch layer reads. Ships EMPTY (#6519); child issues register into it. */ +export const chatActionRegistry: ChatActionRegistry = createChatActionRegistry(); + +/** Register a chat action on the shared {@link chatActionRegistry}. */ +export function registerChatAction(name: string, definition: ChatActionDefinition): ChatActionEntry { + return chatActionRegistry.register(name, definition); +} diff --git a/packages/loopover-miner/lib/contribution-profile.d.ts b/packages/loopover-miner/lib/contribution-profile.d.ts index b5797b839a..603eeca765 100644 --- a/packages/loopover-miner/lib/contribution-profile.d.ts +++ b/packages/loopover-miner/lib/contribution-profile.d.ts @@ -1,122 +1,92 @@ -// ContributionProfile schema (#6795) — the shape AMS uses to represent what it has learned about a repo's -// contribution-eligibility rules, before any extraction (#6796) or `discover` wiring (#6798) is built against -// it. Grounded in the real-repo signal inventory (#6794, packages/loopover-miner/docs/ams-contribution-signal- -// inventory.md), whose findings drove three schema decisions the abstract shape would have gotten wrong: -// 1. Eligibility labels are matchers over name AND description, not a fixed name list — rust/deno/kubernetes -// use their own taxonomies and encode the meaning in the description. -// 2. Every rule is INDEPENDENTLY absent: "absent" is a first-class confidence, distinct from "not yet -// extracted", because signal quality varies widely WITHIN a single repo. -// 3. The linked-issue requirement is NOT a core field — it is loopover-local, absent from the rest of the -// sample — so it lives in an optional `prBody` slot rather than the profile's spine. - /** How trustworthy a single extracted rule is. `explicit`: derived from an unambiguous, machine-readable * signal (a label whose name/description states eligibility, a CONTRIBUTING line that names a required label). * `inferred`: derived from a conventional-but-unstated signal (a `blocked` status label read as exclusionary). * `absent`: the repo exposes no signal of this kind at all — a real, common answer (3/10 of the #6794 sample * had no eligibility label), and deliberately distinct from `unknown`. */ -export type ContributionSignalConfidence = - "explicit" | "inferred" | "absent" | "unknown"; - +export type ContributionSignalConfidence = "explicit" | "inferred" | "absent" | "unknown"; /** Where a rule was derived from, for debuggability (#6794 found the primary source differs per repo — some * state rules only in agent docs, some only in labels). */ -export type ContributionSignalSource = - "labels" | "contributing_md" | "pr_template" | "agent_docs"; - +export type ContributionSignalSource = "labels" | "contributing_md" | "pr_template" | "agent_docs"; export interface ContributionSignalProvenance { - source: ContributionSignalSource; - /** Human-readable pointer to the exact signal, e.g. a label name or a doc path. Never secrets. */ - detail: string; + source: ContributionSignalSource; + /** Human-readable pointer to the exact signal, e.g. a label name or a doc path. Never secrets. */ + detail: string; } - /** A matcher for an eligibility/exclusion label. Matches over the label's NAME or DESCRIPTION — #6794 found * rust encodes "good first issue" semantics only in `E-easy`'s description, which a name-only match misses. */ export interface ContributionLabelMatcher { - /** Which field the pattern tests. */ - field: "name" | "description"; - /** Case-insensitive substring the field must contain (not a regex — kept simple and auditable). */ - contains: string; + /** Which field the pattern tests. */ + field: "name" | "description"; + /** Case-insensitive substring the field must contain (not a regex — kept simple and auditable). */ + contains: string; } - /** One extracted rule: its value, how confident the extractor was, and what it was derived from. `value` is * `null` when `confidence` is `absent`/`unknown`, so a consumer never mistakes "no rule" for "empty rule". */ export interface ContributionSignalRule { - value: T | null; - confidence: ContributionSignalConfidence; - provenance: ContributionSignalProvenance[]; + value: T | null; + confidence: ContributionSignalConfidence; + provenance: ContributionSignalProvenance[]; } - /** Optional PR-body requirements. Modelled as an optional slot rather than a spine field precisely because * #6794 found the linked-issue requirement is loopover-local, not an ecosystem norm. */ export interface ContributionPrBodyRequirements { - /** Does a PR need to reference an issue with a closing keyword (Closes/Fixes #N)? */ - requiresLinkedIssue: boolean; + /** Does a PR need to reference an issue with a closing keyword (Closes/Fixes #N)? */ + requiresLinkedIssue: boolean; } - /** The learned contribution-eligibility profile for one repo. */ export interface ContributionProfile { - repoFullName: string; - /** Bumped when the field set/semantics change, so a cached profile from an older extractor is detectable. */ - schemaVersion: number; - /** ISO timestamp the profile was built. */ - generatedAt: string; - /** Which label(s) mark an issue contributor-workable. `value` is an OR-list of matchers; `absent` when the - * repo exposes no eligibility label (a real outcome for 3/10 of the #6794 sample). */ - eligibilityLabels: ContributionSignalRule; - /** Which label(s) mark an issue maintainer-only / off-limits. Weaker/more inferential than eligibility per - * #6794 (nothing in the sample named exclusion in a label NAME), hence usually `inferred` or `absent`. */ - exclusionLabels: ContributionSignalRule; - /** Optional PR-body requirements (see the type). Absent for most repos. */ - prBody: ContributionSignalRule; - /** Overall completeness: the least-confident spine signal, so `discover` can treat a partial profile - * conservatively. NOT an average — one strong signal must not mask an absent one. */ - completeness: ContributionSignalConfidence; + repoFullName: string; + /** Bumped when the field set/semantics change, so a cached profile from an older extractor is detectable. */ + schemaVersion: number; + /** ISO timestamp the profile was built. */ + generatedAt: string; + /** Which label(s) mark an issue contributor-workable. `value` is an OR-list of matchers; `absent` when the + * repo exposes no eligibility label (a real outcome for 3/10 of the #6794 sample). */ + eligibilityLabels: ContributionSignalRule; + /** Which label(s) mark an issue maintainer-only / off-limits. Weaker/more inferential than eligibility per + * #6794 (nothing in the sample named exclusion in a label NAME), hence usually `inferred` or `absent`. */ + exclusionLabels: ContributionSignalRule; + /** Optional PR-body requirements (see the type). Absent for most repos. */ + prBody: ContributionSignalRule; + /** Overall completeness: the least-confident spine signal, so `discover` can treat a partial profile + * conservatively. NOT an average — one strong signal must not mask an absent one. */ + completeness: ContributionSignalConfidence; } - /** Assignee-exclusion (e.g. "not assigned to the repo owner") is deliberately NOT a profile field: #6794 found * it is not documented for most repos and is derivable from the issue's own `assignees` at query time. This * type names that runtime check so the implementation issues (#6796/#6798) treat it as a live filter, not a * cached rule. */ export interface ContributionAssigneeRuntimeCheck { - /** Exclude issues assigned to any of these logins (typically the repo owner). Applied at discover time. */ - excludeAssignedLogins: string[]; + /** Exclude issues assigned to any of these logins (typically the repo owner). Applied at discover time. */ + excludeAssignedLogins: string[]; } - /** A cached profile plus the metadata that governs when it is refreshed. Mirrors the miner's other local * SQLite stores (policy-doc-cache.js): keyed by repo, with a TTL, because labels and docs both change. */ export interface CachedContributionProfile { - profile: ContributionProfile; - /** ISO timestamp the profile was written to the cache. */ - fetchedAt: string; - /** True once `fetchedAt` is older than the store's TTL — the caller should re-extract. */ - stale: boolean; + profile: ContributionProfile; + /** ISO timestamp the profile was written to the cache. */ + fetchedAt: string; + /** True once `fetchedAt` is older than the store's TTL — the caller should re-extract. */ + stale: boolean; } - -export const CONTRIBUTION_PROFILE_SCHEMA_VERSION: 1; -export const CONTRIBUTION_SIGNAL_CONFIDENCE_LEVELS: readonly [ - "explicit", - "inferred", - "absent", - "unknown", -]; -export const CONTRIBUTION_SIGNAL_SOURCES: readonly [ - "labels", - "contributing_md", - "pr_template", - "agent_docs", -]; +/** Bumped when the field set/semantics change, so a cached profile from an older extractor is detectable. */ +export declare const CONTRIBUTION_PROFILE_SCHEMA_VERSION: 1; +/** Confidence vocabulary, weakest-last order used by weakestConfidence. `absent` (the repo has no such signal) + * is deliberately distinct from `unknown` (we have not looked / could not tell). */ +export declare const CONTRIBUTION_SIGNAL_CONFIDENCE_LEVELS: readonly ["explicit", "inferred", "absent", "unknown"]; +/** The signal sources a rule can be derived from (#6794 found the primary source differs per repo). */ +export declare const CONTRIBUTION_SIGNAL_SOURCES: readonly ["labels", "contributing_md", "pr_template", "agent_docs"]; /** Default cache TTL: 7 days. Labels/docs change slowly; a week bounds staleness without re-fetching per run. */ -export const CONTRIBUTION_PROFILE_CACHE_TTL_MS: number; +export declare const CONTRIBUTION_PROFILE_CACHE_TTL_MS: number; /** The local SQLite store table the cache (#6797) will use, named here so the schema owns it. */ -export const CONTRIBUTION_PROFILE_STORE_TABLE: "miner_contribution_profile"; - -/** Build an empty, fully-`absent`/`unknown` profile for a repo — the safe default before extraction has run, - * so `discover` treats an unprofiled repo conservatively rather than as "no restrictions". */ -export function emptyContributionProfile( - repoFullName: string, - generatedAt: string, -): ContributionProfile; - -/** The least-confident of a set of signal confidences, per the `completeness` rule (weakest wins). */ -export function weakestConfidence( - confidences: readonly ContributionSignalConfidence[], -): ContributionSignalConfidence; +export declare const CONTRIBUTION_PROFILE_STORE_TABLE: "miner_contribution_profile"; +/** + * Build an empty, fully-`absent` profile for a repo — the safe default before extraction has run, so `discover` + * treats an unprofiled repo conservatively rather than as "no restrictions". + */ +export declare function emptyContributionProfile(repoFullName: string, generatedAt: string): ContributionProfile; +/** + * The least-confident of a set of signal confidences — the rule behind a profile's `completeness`. Weakest + * wins, so one strong signal never masks an absent one. An empty set is `unknown` (nothing observed). + */ +export declare function weakestConfidence(confidences: readonly ContributionSignalConfidence[]): ContributionSignalConfidence; diff --git a/packages/loopover-miner/lib/contribution-profile.js b/packages/loopover-miner/lib/contribution-profile.js index eb6afe5f88..74aba0fad3 100644 --- a/packages/loopover-miner/lib/contribution-profile.js +++ b/packages/loopover-miner/lib/contribution-profile.js @@ -2,73 +2,59 @@ // (#6796) and no `discover` wiring (#6798) live here. The shapes are documented in contribution-profile.d.ts // and packages/loopover-miner/docs/contribution-profile.md; this file exists so the implementation issues have // concrete, importable constants and the two branch-free helpers they will build on. - /** Bumped when the field set/semantics change, so a cached profile from an older extractor is detectable. */ export const CONTRIBUTION_PROFILE_SCHEMA_VERSION = 1; - /** Confidence vocabulary, weakest-last order used by weakestConfidence. `absent` (the repo has no such signal) * is deliberately distinct from `unknown` (we have not looked / could not tell). */ export const CONTRIBUTION_SIGNAL_CONFIDENCE_LEVELS = Object.freeze([ - "explicit", - "inferred", - "absent", - "unknown", + "explicit", + "inferred", + "absent", + "unknown", ]); - /** The signal sources a rule can be derived from (#6794 found the primary source differs per repo). */ export const CONTRIBUTION_SIGNAL_SOURCES = Object.freeze([ - "labels", - "contributing_md", - "pr_template", - "agent_docs", + "labels", + "contributing_md", + "pr_template", + "agent_docs", ]); - /** Default cache TTL: 7 days. Labels/docs change slowly; a week bounds staleness without re-fetching per run. */ export const CONTRIBUTION_PROFILE_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; - /** The local SQLite store table the cache (#6797) will use, named here so the schema owns it. */ export const CONTRIBUTION_PROFILE_STORE_TABLE = "miner_contribution_profile"; - /** An `absent` signal rule with no value and no provenance — the safe default for a spine field. */ function absentRule() { - return { value: null, confidence: "absent", provenance: [] }; + return { value: null, confidence: "absent", provenance: [] }; } - /** * Build an empty, fully-`absent` profile for a repo — the safe default before extraction has run, so `discover` * treats an unprofiled repo conservatively rather than as "no restrictions". - * - * @param {string} repoFullName - * @param {string} generatedAt ISO timestamp (passed in rather than read from the clock, so callers/tests stay - * deterministic — mirrors how the other miner builders take their timestamp). - * @returns {import("./contribution-profile.js").ContributionProfile} */ export function emptyContributionProfile(repoFullName, generatedAt) { - return { - repoFullName, - schemaVersion: CONTRIBUTION_PROFILE_SCHEMA_VERSION, - generatedAt, - eligibilityLabels: absentRule(), - exclusionLabels: absentRule(), - prBody: absentRule(), - completeness: "absent", - }; + return { + repoFullName, + schemaVersion: CONTRIBUTION_PROFILE_SCHEMA_VERSION, + generatedAt, + eligibilityLabels: absentRule(), + exclusionLabels: absentRule(), + prBody: absentRule(), + completeness: "absent", + }; } - /** * The least-confident of a set of signal confidences — the rule behind a profile's `completeness`. Weakest * wins, so one strong signal never masks an absent one. An empty set is `unknown` (nothing observed). - * - * @param {readonly string[]} confidences - * @returns {string} */ export function weakestConfidence(confidences) { - let weakestIndex = -1; - for (const confidence of confidences) { - const index = CONTRIBUTION_SIGNAL_CONFIDENCE_LEVELS.indexOf(confidence); - if (index > weakestIndex) weakestIndex = index; - } - return weakestIndex === -1 - ? "unknown" - : CONTRIBUTION_SIGNAL_CONFIDENCE_LEVELS[weakestIndex]; + let weakestIndex = -1; + for (const confidence of confidences) { + const index = CONTRIBUTION_SIGNAL_CONFIDENCE_LEVELS.indexOf(confidence); + if (index > weakestIndex) + weakestIndex = index; + } + return weakestIndex === -1 + ? "unknown" + : CONTRIBUTION_SIGNAL_CONFIDENCE_LEVELS[weakestIndex]; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY29udHJpYnV0aW9uLXByb2ZpbGUuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJjb250cmlidXRpb24tcHJvZmlsZS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSw2R0FBNkc7QUFDN0csNkdBQTZHO0FBQzdHLCtHQUErRztBQUMvRyxxRkFBcUY7QUErRnJGLDZHQUE2RztBQUM3RyxNQUFNLENBQUMsTUFBTSxtQ0FBbUMsR0FBRyxDQUFVLENBQUM7QUFFOUQ7cUZBQ3FGO0FBQ3JGLE1BQU0sQ0FBQyxNQUFNLHFDQUFxQyxHQUFHLE1BQU0sQ0FBQyxNQUFNLENBQUM7SUFDakUsVUFBVTtJQUNWLFVBQVU7SUFDVixRQUFRO0lBQ1IsU0FBUztDQUNELENBQUMsQ0FBQztBQUVaLHVHQUF1RztBQUN2RyxNQUFNLENBQUMsTUFBTSwyQkFBMkIsR0FBRyxNQUFNLENBQUMsTUFBTSxDQUFDO0lBQ3ZELFFBQVE7SUFDUixpQkFBaUI7SUFDakIsYUFBYTtJQUNiLFlBQVk7Q0FDSixDQUFDLENBQUM7QUFFWixpSEFBaUg7QUFDakgsTUFBTSxDQUFDLE1BQU0saUNBQWlDLEdBQUcsQ0FBQyxHQUFHLEVBQUUsR0FBRyxFQUFFLEdBQUcsRUFBRSxHQUFHLElBQUksQ0FBQztBQUV6RSxpR0FBaUc7QUFDakcsTUFBTSxDQUFDLE1BQU0sZ0NBQWdDLEdBQUcsNEJBQXFDLENBQUM7QUFFdEYsb0dBQW9HO0FBQ3BHLFNBQVMsVUFBVTtJQUNqQixPQUFPLEVBQUUsS0FBSyxFQUFFLElBQUksRUFBRSxVQUFVLEVBQUUsUUFBUSxFQUFFLFVBQVUsRUFBRSxFQUFFLEVBQUUsQ0FBQztBQUMvRCxDQUFDO0FBRUQ7OztHQUdHO0FBQ0gsTUFBTSxVQUFVLHdCQUF3QixDQUFDLFlBQW9CLEVBQUUsV0FBbUI7SUFDaEYsT0FBTztRQUNMLFlBQVk7UUFDWixhQUFhLEVBQUUsbUNBQW1DO1FBQ2xELFdBQVc7UUFDWCxpQkFBaUIsRUFBRSxVQUFVLEVBQUU7UUFDL0IsZUFBZSxFQUFFLFVBQVUsRUFBRTtRQUM3QixNQUFNLEVBQUUsVUFBVSxFQUFFO1FBQ3BCLFlBQVksRUFBRSxRQUFRO0tBQ3ZCLENBQUM7QUFDSixDQUFDO0FBRUQ7OztHQUdHO0FBQ0gsTUFBTSxVQUFVLGlCQUFpQixDQUMvQixXQUFvRDtJQUVwRCxJQUFJLFlBQVksR0FBRyxDQUFDLENBQUMsQ0FBQztJQUN0QixLQUFLLE1BQU0sVUFBVSxJQUFJLFdBQVcsRUFBRSxDQUFDO1FBQ3JDLE1BQU0sS0FBSyxHQUFJLHFDQUEyRCxDQUFDLE9BQU8sQ0FBQyxVQUFVLENBQUMsQ0FBQztRQUMvRixJQUFJLEtBQUssR0FBRyxZQUFZO1lBQUUsWUFBWSxHQUFHLEtBQUssQ0FBQztJQUNqRCxDQUFDO0lBQ0QsT0FBTyxZQUFZLEtBQUssQ0FBQyxDQUFDO1FBQ3hCLENBQUMsQ0FBQyxTQUFTO1FBQ1gsQ0FBQyxDQUFDLHFDQUFxQyxDQUFDLFlBQVksQ0FBRSxDQUFDO0FBQzNELENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/contribution-profile.ts b/packages/loopover-miner/lib/contribution-profile.ts new file mode 100644 index 0000000000..a207175333 --- /dev/null +++ b/packages/loopover-miner/lib/contribution-profile.ts @@ -0,0 +1,161 @@ +// ContributionProfile schema constants + tiny pure helpers (#6795). Design/schema only — no extraction logic +// (#6796) and no `discover` wiring (#6798) live here. The shapes are documented in contribution-profile.d.ts +// and packages/loopover-miner/docs/contribution-profile.md; this file exists so the implementation issues have +// concrete, importable constants and the two branch-free helpers they will build on. + +// ContributionProfile schema (#6795) — the shape AMS uses to represent what it has learned about a repo's +// contribution-eligibility rules, before any extraction (#6796) or `discover` wiring (#6798) is built against +// it. Grounded in the real-repo signal inventory (#6794, packages/loopover-miner/docs/ams-contribution-signal- +// inventory.md), whose findings drove three schema decisions the abstract shape would have gotten wrong: +// 1. Eligibility labels are matchers over name AND description, not a fixed name list — rust/deno/kubernetes +// use their own taxonomies and encode the meaning in the description. +// 2. Every rule is INDEPENDENTLY absent: "absent" is a first-class confidence, distinct from "not yet +// extracted", because signal quality varies widely WITHIN a single repo. +// 3. The linked-issue requirement is NOT a core field — it is loopover-local, absent from the rest of the +// sample — so it lives in an optional `prBody` slot rather than the profile's spine. + +/** How trustworthy a single extracted rule is. `explicit`: derived from an unambiguous, machine-readable + * signal (a label whose name/description states eligibility, a CONTRIBUTING line that names a required label). + * `inferred`: derived from a conventional-but-unstated signal (a `blocked` status label read as exclusionary). + * `absent`: the repo exposes no signal of this kind at all — a real, common answer (3/10 of the #6794 sample + * had no eligibility label), and deliberately distinct from `unknown`. */ +export type ContributionSignalConfidence = + "explicit" | "inferred" | "absent" | "unknown"; + +/** Where a rule was derived from, for debuggability (#6794 found the primary source differs per repo — some + * state rules only in agent docs, some only in labels). */ +export type ContributionSignalSource = + "labels" | "contributing_md" | "pr_template" | "agent_docs"; + +export interface ContributionSignalProvenance { + source: ContributionSignalSource; + /** Human-readable pointer to the exact signal, e.g. a label name or a doc path. Never secrets. */ + detail: string; +} + +/** A matcher for an eligibility/exclusion label. Matches over the label's NAME or DESCRIPTION — #6794 found + * rust encodes "good first issue" semantics only in `E-easy`'s description, which a name-only match misses. */ +export interface ContributionLabelMatcher { + /** Which field the pattern tests. */ + field: "name" | "description"; + /** Case-insensitive substring the field must contain (not a regex — kept simple and auditable). */ + contains: string; +} + +/** One extracted rule: its value, how confident the extractor was, and what it was derived from. `value` is + * `null` when `confidence` is `absent`/`unknown`, so a consumer never mistakes "no rule" for "empty rule". */ +export interface ContributionSignalRule { + value: T | null; + confidence: ContributionSignalConfidence; + provenance: ContributionSignalProvenance[]; +} + +/** Optional PR-body requirements. Modelled as an optional slot rather than a spine field precisely because + * #6794 found the linked-issue requirement is loopover-local, not an ecosystem norm. */ +export interface ContributionPrBodyRequirements { + /** Does a PR need to reference an issue with a closing keyword (Closes/Fixes #N)? */ + requiresLinkedIssue: boolean; +} + +/** The learned contribution-eligibility profile for one repo. */ +export interface ContributionProfile { + repoFullName: string; + /** Bumped when the field set/semantics change, so a cached profile from an older extractor is detectable. */ + schemaVersion: number; + /** ISO timestamp the profile was built. */ + generatedAt: string; + /** Which label(s) mark an issue contributor-workable. `value` is an OR-list of matchers; `absent` when the + * repo exposes no eligibility label (a real outcome for 3/10 of the #6794 sample). */ + eligibilityLabels: ContributionSignalRule; + /** Which label(s) mark an issue maintainer-only / off-limits. Weaker/more inferential than eligibility per + * #6794 (nothing in the sample named exclusion in a label NAME), hence usually `inferred` or `absent`. */ + exclusionLabels: ContributionSignalRule; + /** Optional PR-body requirements (see the type). Absent for most repos. */ + prBody: ContributionSignalRule; + /** Overall completeness: the least-confident spine signal, so `discover` can treat a partial profile + * conservatively. NOT an average — one strong signal must not mask an absent one. */ + completeness: ContributionSignalConfidence; +} + +/** Assignee-exclusion (e.g. "not assigned to the repo owner") is deliberately NOT a profile field: #6794 found + * it is not documented for most repos and is derivable from the issue's own `assignees` at query time. This + * type names that runtime check so the implementation issues (#6796/#6798) treat it as a live filter, not a + * cached rule. */ +export interface ContributionAssigneeRuntimeCheck { + /** Exclude issues assigned to any of these logins (typically the repo owner). Applied at discover time. */ + excludeAssignedLogins: string[]; +} + +/** A cached profile plus the metadata that governs when it is refreshed. Mirrors the miner's other local + * SQLite stores (policy-doc-cache.js): keyed by repo, with a TTL, because labels and docs both change. */ +export interface CachedContributionProfile { + profile: ContributionProfile; + /** ISO timestamp the profile was written to the cache. */ + fetchedAt: string; + /** True once `fetchedAt` is older than the store's TTL — the caller should re-extract. */ + stale: boolean; +} + +/** Bumped when the field set/semantics change, so a cached profile from an older extractor is detectable. */ +export const CONTRIBUTION_PROFILE_SCHEMA_VERSION = 1 as const; + +/** Confidence vocabulary, weakest-last order used by weakestConfidence. `absent` (the repo has no such signal) + * is deliberately distinct from `unknown` (we have not looked / could not tell). */ +export const CONTRIBUTION_SIGNAL_CONFIDENCE_LEVELS = Object.freeze([ + "explicit", + "inferred", + "absent", + "unknown", +] as const); + +/** The signal sources a rule can be derived from (#6794 found the primary source differs per repo). */ +export const CONTRIBUTION_SIGNAL_SOURCES = Object.freeze([ + "labels", + "contributing_md", + "pr_template", + "agent_docs", +] as const); + +/** Default cache TTL: 7 days. Labels/docs change slowly; a week bounds staleness without re-fetching per run. */ +export const CONTRIBUTION_PROFILE_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +/** The local SQLite store table the cache (#6797) will use, named here so the schema owns it. */ +export const CONTRIBUTION_PROFILE_STORE_TABLE = "miner_contribution_profile" as const; + +/** An `absent` signal rule with no value and no provenance — the safe default for a spine field. */ +function absentRule(): ContributionSignalRule { + return { value: null, confidence: "absent", provenance: [] }; +} + +/** + * Build an empty, fully-`absent` profile for a repo — the safe default before extraction has run, so `discover` + * treats an unprofiled repo conservatively rather than as "no restrictions". + */ +export function emptyContributionProfile(repoFullName: string, generatedAt: string): ContributionProfile { + return { + repoFullName, + schemaVersion: CONTRIBUTION_PROFILE_SCHEMA_VERSION, + generatedAt, + eligibilityLabels: absentRule(), + exclusionLabels: absentRule(), + prBody: absentRule(), + completeness: "absent", + }; +} + +/** + * The least-confident of a set of signal confidences — the rule behind a profile's `completeness`. Weakest + * wins, so one strong signal never masks an absent one. An empty set is `unknown` (nothing observed). + */ +export function weakestConfidence( + confidences: readonly ContributionSignalConfidence[], +): ContributionSignalConfidence { + let weakestIndex = -1; + for (const confidence of confidences) { + const index = (CONTRIBUTION_SIGNAL_CONFIDENCE_LEVELS as readonly string[]).indexOf(confidence); + if (index > weakestIndex) weakestIndex = index; + } + return weakestIndex === -1 + ? "unknown" + : CONTRIBUTION_SIGNAL_CONFIDENCE_LEVELS[weakestIndex]!; +} diff --git a/packages/loopover-miner/lib/github-token-resolution.d.ts b/packages/loopover-miner/lib/github-token-resolution.d.ts index 6cc0fa8f16..d9120a73b1 100644 --- a/packages/loopover-miner/lib/github-token-resolution.d.ts +++ b/packages/loopover-miner/lib/github-token-resolution.d.ts @@ -1,22 +1,32 @@ -// A narrower shape than `typeof fetch` on purpose: this module only ever calls it with a string URL and a -// plain init object, and the ambient `fetch` type in this repo's TS program is Cloudflare-Workers-flavored -// (RequestInfo | URL), which is both irrelevant here (this package runs under plain Node) and -// stricter than any real caller needs -- same rationale as live-issue-snapshot.js's own LiveIssueSnapshotFetch. -export type GitHubTokenResolutionFetch = ( - url: string, - init?: { method?: string; headers?: Record; signal?: AbortSignal }, -) => Promise; - -export function resolveGitHubToken( - env?: NodeJS.ProcessEnv, - options?: { fetchImpl?: GitHubTokenResolutionFetch }, -): Promise; - -/** Same loopover-mcp session + API URL posture `resolveGitHubToken` uses (#6487). Null when no session. */ -export function resolveLoopoverBackendSession( - env?: NodeJS.ProcessEnv, -): { apiUrl: string; sessionToken: string } | null; - -export function resetGitHubTokenResolutionForTesting(): void; - -export function hasGitHubTokenSource(env?: NodeJS.ProcessEnv): boolean; +export type GitHubTokenResolutionFetch = (url: string, init?: { + method?: string; + headers?: Record; + signal?: AbortSignal; +}) => Promise; +/** + * Same loopover-mcp session + API URL posture `resolveGitHubToken` uses for backend calls (#6487). + * Returns null when there is no session token on disk (fully-standalone AMS / no `loopover-mcp login`). + */ +export declare function resolveLoopoverBackendSession(env?: NodeJS.ProcessEnv): { + apiUrl: string; + sessionToken: string; +} | null; +/** + * Resolve a GitHub token for AMS's git operations (#6116). Returns null when nothing is available: no + * GITHUB_TOKEN override, no loopover-mcp session on disk, or the session-token fetch fails for any reason -- + * callers already treat a missing token as "git operations requiring auth will fail," the same failure mode + * as before this feature existed. + */ +export declare function resolveGitHubToken(env?: NodeJS.ProcessEnv, options?: { + fetchImpl?: GitHubTokenResolutionFetch; +}): Promise; +/** Test-only: clear the process-lifetime cache so one test's resolution can't leak into the next. */ +export declare function resetGitHubTokenResolutionForTesting(): void; +/** + * Offline-only check: does resolveGitHubToken have ANYTHING to try (a GITHUB_TOKEN override, or a + * loopover-mcp session recorded on disk), without making the network call resolveGitHubToken itself would + * make to actually verify it still works. For `doctor`/`status`-style diagnostics (status.js's + * checkGitHubTokenPresent), which are deliberately offline-only -- a genuinely expired or revoked session + * still reports "present" here; only an actual attempt (or resolveGitHubToken itself) discovers that. + */ +export declare function hasGitHubTokenSource(env?: NodeJS.ProcessEnv): boolean; diff --git a/packages/loopover-miner/lib/github-token-resolution.js b/packages/loopover-miner/lib/github-token-resolution.js index 729570a7e8..a364a93400 100644 --- a/packages/loopover-miner/lib/github-token-resolution.js +++ b/packages/loopover-miner/lib/github-token-resolution.js @@ -12,135 +12,129 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; - const DEFAULT_API_URL = "https://api.loopover.ai"; const LEGACY_DEFAULT_API_URLS = new Set([ - "https://gittensory-api.zeronode.workers.dev", - "https://gittensory-api.aethereal.dev", + "https://gittensory-api.zeronode.workers.dev", + "https://gittensory-api.aethereal.dev", ]); const DEFAULT_PROFILE_NAME = "default"; const GITHUB_TOKEN_FETCH_TIMEOUT_MS = 10_000; - function loopoverConfigPath(env) { - if (env.LOOPOVER_CONFIG_PATH) return env.LOOPOVER_CONFIG_PATH; - if (env.LOOPOVER_CONFIG_DIR) return join(env.LOOPOVER_CONFIG_DIR, "config.json"); - return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "loopover", "config.json"); + if (env.LOOPOVER_CONFIG_PATH) + return env.LOOPOVER_CONFIG_PATH; + if (env.LOOPOVER_CONFIG_DIR) + return join(env.LOOPOVER_CONFIG_DIR, "config.json"); + return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "loopover", "config.json"); } - function loadLoopoverConfig(env) { - const configPath = loopoverConfigPath(env); - if (!existsSync(configPath)) return {}; - try { - const parsed = JSON.parse(readFileSync(configPath, "utf8")); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; - } catch { - return {}; - } + const configPath = loopoverConfigPath(env); + if (!existsSync(configPath)) + return {}; + try { + const parsed = JSON.parse(readFileSync(configPath, "utf8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } + catch { + return {}; + } } - // Only ever called with an already-truthy candidate name (see selectProfileName below) -- no nullish // fallback needed here, since a nullish/empty `value` never reaches this function in the first place. function normalizeProfileName(value) { - const name = String(value).trim().toLowerCase(); - return /^[a-z0-9][a-z0-9._-]{0,63}$/.test(name) ? name : DEFAULT_PROFILE_NAME; + const name = String(value).trim().toLowerCase(); + return /^[a-z0-9][a-z0-9._-]{0,63}$/.test(name) ? name : DEFAULT_PROFILE_NAME; } - // Mirrors loopover-mcp's own selectProfileName: an explicit request wins, else the config's own // activeProfile (only if it names a real profile entry), else "default". function selectProfileName(config, requestedName) { - if (requestedName) return normalizeProfileName(requestedName); - const configured = config.activeProfile ? normalizeProfileName(config.activeProfile) : DEFAULT_PROFILE_NAME; - return config.profiles?.[configured] ? configured : DEFAULT_PROFILE_NAME; + if (requestedName) + return normalizeProfileName(requestedName); + const configured = config.activeProfile ? normalizeProfileName(config.activeProfile) : DEFAULT_PROFILE_NAME; + return config.profiles?.[configured] ? configured : DEFAULT_PROFILE_NAME; } - function activeLoopoverProfile(env) { - const config = loadLoopoverConfig(env); - const profileName = selectProfileName(config, env.LOOPOVER_PROFILE); - return config.profiles?.[profileName] ?? {}; + const config = loadLoopoverConfig(env); + const profileName = selectProfileName(config, env.LOOPOVER_PROFILE); + return config.profiles?.[profileName] ?? {}; } - function loopoverSessionToken(env) { - const token = activeLoopoverProfile(env).session?.token; - return typeof token === "string" && token ? token : null; + const token = activeLoopoverProfile(env).session?.token; + return typeof token === "string" && token ? token : null; } - function loopoverApiUrl(env) { - if (env.LOOPOVER_API_URL) return env.LOOPOVER_API_URL.replace(/\/+$/, ""); - const profileApiUrl = activeLoopoverProfile(env).apiUrl; - if (typeof profileApiUrl === "string" && profileApiUrl.trim()) { - const normalized = profileApiUrl.replace(/\/+$/, ""); - if (!LEGACY_DEFAULT_API_URLS.has(normalized)) return normalized; - } - return DEFAULT_API_URL; + if (env.LOOPOVER_API_URL) + return env.LOOPOVER_API_URL.replace(/\/+$/, ""); + const profileApiUrl = activeLoopoverProfile(env).apiUrl; + if (typeof profileApiUrl === "string" && profileApiUrl.trim()) { + const normalized = profileApiUrl.replace(/\/+$/, ""); + if (!LEGACY_DEFAULT_API_URLS.has(normalized)) + return normalized; + } + return DEFAULT_API_URL; } - /** * Same loopover-mcp session + API URL posture `resolveGitHubToken` uses for backend calls (#6487). * Returns null when there is no session token on disk (fully-standalone AMS / no `loopover-mcp login`). - * @param {NodeJS.ProcessEnv} [env] - * @returns {{ apiUrl: string, sessionToken: string } | null} */ export function resolveLoopoverBackendSession(env = process.env) { - const sessionToken = loopoverSessionToken(env); - if (!sessionToken) return null; - return { apiUrl: loopoverApiUrl(env), sessionToken }; + const sessionToken = loopoverSessionToken(env); + if (!sessionToken) + return null; + return { apiUrl: loopoverApiUrl(env), sessionToken }; } - async function fetchLiveGitHubTokenFromSession(sessionToken, apiUrl, fetchImpl) { - try { - const response = await fetchImpl(`${apiUrl}/v1/auth/github/token`, { - method: "POST", - headers: { authorization: `Bearer ${sessionToken}`, accept: "application/json" }, - signal: AbortSignal.timeout(GITHUB_TOKEN_FETCH_TIMEOUT_MS), - }); - if (!response.ok) return null; - const payload = await response.json().catch(() => null); - return typeof payload?.token === "string" && payload.token ? payload.token : null; - } catch { - return null; - } + try { + const response = await fetchImpl(`${apiUrl}/v1/auth/github/token`, { + method: "POST", + headers: { authorization: `Bearer ${sessionToken}`, accept: "application/json" }, + signal: AbortSignal.timeout(GITHUB_TOKEN_FETCH_TIMEOUT_MS), + }); + if (!response.ok) + return null; + const payload = (await response.json().catch(() => null)); + return typeof payload?.token === "string" && payload.token ? payload.token : null; + } + catch { + return null; + } } - // Process-lifetime cache of a SUCCESSFUL resolution only. A failure (no session, expired session, transient // network error) is deliberately NOT cached -- it's retried on the next call instead, so a long-running AMS // process can self-heal from a transient blip rather than being stuck treating the token as permanently // unavailable for its entire remaining lifetime. let cachedToken; - /** * Resolve a GitHub token for AMS's git operations (#6116). Returns null when nothing is available: no * GITHUB_TOKEN override, no loopover-mcp session on disk, or the session-token fetch fails for any reason -- * callers already treat a missing token as "git operations requiring auth will fail," the same failure mode * as before this feature existed. - * @param {NodeJS.ProcessEnv} [env] - * @param {{ fetchImpl?: import("./github-token-resolution.d.ts").GitHubTokenResolutionFetch }} [options] - * @returns {Promise} */ export async function resolveGitHubToken(env = process.env, options = {}) { - if (env.GITHUB_TOKEN) return env.GITHUB_TOKEN; - if (cachedToken) return cachedToken; - const sessionToken = loopoverSessionToken(env); - if (!sessionToken) return null; - const fetchImpl = options.fetchImpl ?? fetch; - const fetched = await fetchLiveGitHubTokenFromSession(sessionToken, loopoverApiUrl(env), fetchImpl); - if (fetched) cachedToken = fetched; - return fetched; + if (env.GITHUB_TOKEN) + return env.GITHUB_TOKEN; + if (cachedToken) + return cachedToken; + const sessionToken = loopoverSessionToken(env); + if (!sessionToken) + return null; + const fetchImpl = options.fetchImpl ?? fetch; + const fetched = await fetchLiveGitHubTokenFromSession(sessionToken, loopoverApiUrl(env), fetchImpl); + if (fetched) + cachedToken = fetched; + return fetched; } - /** Test-only: clear the process-lifetime cache so one test's resolution can't leak into the next. */ export function resetGitHubTokenResolutionForTesting() { - cachedToken = undefined; + cachedToken = undefined; } - /** * Offline-only check: does resolveGitHubToken have ANYTHING to try (a GITHUB_TOKEN override, or a * loopover-mcp session recorded on disk), without making the network call resolveGitHubToken itself would * make to actually verify it still works. For `doctor`/`status`-style diagnostics (status.js's * checkGitHubTokenPresent), which are deliberately offline-only -- a genuinely expired or revoked session * still reports "present" here; only an actual attempt (or resolveGitHubToken itself) discovers that. - * @param {NodeJS.ProcessEnv} [env] - * @returns {boolean} */ export function hasGitHubTokenSource(env = process.env) { - return Boolean(env.GITHUB_TOKEN) || Boolean(loopoverSessionToken(env)); + return Boolean(env.GITHUB_TOKEN) || Boolean(loopoverSessionToken(env)); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiZ2l0aHViLXRva2VuLXJlc29sdXRpb24uanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJnaXRodWItdG9rZW4tcmVzb2x1dGlvbi50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxxR0FBcUc7QUFDckcsMEdBQTBHO0FBQzFHLDRHQUE0RztBQUM1RyxxR0FBcUc7QUFDckcsRUFBRTtBQUNGLDRHQUE0RztBQUM1RywyR0FBMkc7QUFDM0csMkdBQTJHO0FBQzNHLHFHQUFxRztBQUNyRyxxR0FBcUc7QUFDckcsMkdBQTJHO0FBQzNHLE9BQU8sRUFBRSxVQUFVLEVBQUUsWUFBWSxFQUFFLE1BQU0sU0FBUyxDQUFDO0FBQ25ELE9BQU8sRUFBRSxPQUFPLEVBQUUsTUFBTSxTQUFTLENBQUM7QUFDbEMsT0FBTyxFQUFFLElBQUksRUFBRSxNQUFNLFdBQVcsQ0FBQztBQXFCakMsTUFBTSxlQUFlLEdBQUcseUJBQXlCLENBQUM7QUFDbEQsTUFBTSx1QkFBdUIsR0FBRyxJQUFJLEdBQUcsQ0FBQztJQUN0Qyw2Q0FBNkM7SUFDN0Msc0NBQXNDO0NBQ3ZDLENBQUMsQ0FBQztBQUNILE1BQU0sb0JBQW9CLEdBQUcsU0FBUyxDQUFDO0FBQ3ZDLE1BQU0sNkJBQTZCLEdBQUcsTUFBTSxDQUFDO0FBRTdDLFNBQVMsa0JBQWtCLENBQUMsR0FBc0I7SUFDaEQsSUFBSSxHQUFHLENBQUMsb0JBQW9CO1FBQUUsT0FBTyxHQUFHLENBQUMsb0JBQW9CLENBQUM7SUFDOUQsSUFBSSxHQUFHLENBQUMsbUJBQW1CO1FBQUUsT0FBTyxJQUFJLENBQUMsR0FBRyxDQUFDLG1CQUFtQixFQUFFLGFBQWEsQ0FBQyxDQUFDO0lBQ2pGLE9BQU8sSUFBSSxDQUFDLEdBQUcsQ0FBQyxlQUFlLElBQUksSUFBSSxDQUFDLE9BQU8sRUFBRSxFQUFFLFNBQVMsQ0FBQyxFQUFFLFVBQVUsRUFBRSxhQUFhLENBQUMsQ0FBQztBQUM1RixDQUFDO0FBRUQsU0FBUyxrQkFBa0IsQ0FBQyxHQUFzQjtJ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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/github-token-resolution.ts b/packages/loopover-miner/lib/github-token-resolution.ts new file mode 100644 index 0000000000..f98f4f60e4 --- /dev/null +++ b/packages/loopover-miner/lib/github-token-resolution.ts @@ -0,0 +1,167 @@ +// GitHub-token resolution for AMS's git operations (#6116). Precedence: an explicit GITHUB_TOKEN env +// override always wins (a self-host operator's existing PAT setup keeps working, unchanged) -- otherwise, +// fetch a live token from the authenticated loopover-mcp session (POST /v1/auth/github/token, #6114/#6115), +// so `loopover-mcp login` alone becomes sufficient to run AMS against a repo the user has access to. +// +// Deliberately reimplements loopover-mcp's own config-file read here rather than depending on @loopover/mcp +// as a package: @loopover/miner and @loopover/mcp are separately-installable CLIs (the whole point of this +// milestone is that installing the GitHub App doesn't require BOTH), and a hard runtime dependency between +// them would mean installing one always pulls in the other just to read a config file format neither +// package publishes as a stable API. This mirrors loopover-mcp/bin/loopover-mcp.js's own configPath/ +// selectProfileName/apiUrl resolution logic (kept in sync by hand -- there is no shared module to import). +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +// A narrower shape than `typeof fetch` on purpose: this module only ever calls it with a string URL and a +// plain init object, and the ambient `fetch` type in this repo's TS program is Cloudflare-Workers-flavored +// (RequestInfo | URL), which is both irrelevant here (this package runs under plain Node) and +// stricter than any real caller needs -- same rationale as live-issue-snapshot.js's own LiveIssueSnapshotFetch. +export type GitHubTokenResolutionFetch = ( + url: string, + init?: { method?: string; headers?: Record; signal?: AbortSignal }, +) => Promise; + +type LoopoverConfigProfile = { + apiUrl?: unknown; + session?: { token?: unknown } | null | undefined; +}; + +type LoopoverConfig = { + activeProfile?: unknown; + profiles?: Record; +}; + +const DEFAULT_API_URL = "https://api.loopover.ai"; +const LEGACY_DEFAULT_API_URLS = new Set([ + "https://gittensory-api.zeronode.workers.dev", + "https://gittensory-api.aethereal.dev", +]); +const DEFAULT_PROFILE_NAME = "default"; +const GITHUB_TOKEN_FETCH_TIMEOUT_MS = 10_000; + +function loopoverConfigPath(env: NodeJS.ProcessEnv): string { + if (env.LOOPOVER_CONFIG_PATH) return env.LOOPOVER_CONFIG_PATH; + if (env.LOOPOVER_CONFIG_DIR) return join(env.LOOPOVER_CONFIG_DIR, "config.json"); + return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "loopover", "config.json"); +} + +function loadLoopoverConfig(env: NodeJS.ProcessEnv): LoopoverConfig { + const configPath = loopoverConfigPath(env); + if (!existsSync(configPath)) return {}; + try { + const parsed: unknown = JSON.parse(readFileSync(configPath, "utf8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as LoopoverConfig) : {}; + } catch { + return {}; + } +} + +// Only ever called with an already-truthy candidate name (see selectProfileName below) -- no nullish +// fallback needed here, since a nullish/empty `value` never reaches this function in the first place. +function normalizeProfileName(value: unknown): string { + const name = String(value).trim().toLowerCase(); + return /^[a-z0-9][a-z0-9._-]{0,63}$/.test(name) ? name : DEFAULT_PROFILE_NAME; +} + +// Mirrors loopover-mcp's own selectProfileName: an explicit request wins, else the config's own +// activeProfile (only if it names a real profile entry), else "default". +function selectProfileName(config: LoopoverConfig, requestedName: string | undefined): string { + if (requestedName) return normalizeProfileName(requestedName); + const configured = config.activeProfile ? normalizeProfileName(config.activeProfile) : DEFAULT_PROFILE_NAME; + return config.profiles?.[configured] ? configured : DEFAULT_PROFILE_NAME; +} + +function activeLoopoverProfile(env: NodeJS.ProcessEnv): LoopoverConfigProfile { + const config = loadLoopoverConfig(env); + const profileName = selectProfileName(config, env.LOOPOVER_PROFILE); + return config.profiles?.[profileName] ?? {}; +} + +function loopoverSessionToken(env: NodeJS.ProcessEnv): string | null { + const token = activeLoopoverProfile(env).session?.token; + return typeof token === "string" && token ? token : null; +} + +function loopoverApiUrl(env: NodeJS.ProcessEnv): string { + if (env.LOOPOVER_API_URL) return env.LOOPOVER_API_URL.replace(/\/+$/, ""); + const profileApiUrl = activeLoopoverProfile(env).apiUrl; + if (typeof profileApiUrl === "string" && profileApiUrl.trim()) { + const normalized = profileApiUrl.replace(/\/+$/, ""); + if (!LEGACY_DEFAULT_API_URLS.has(normalized)) return normalized; + } + return DEFAULT_API_URL; +} + +/** + * Same loopover-mcp session + API URL posture `resolveGitHubToken` uses for backend calls (#6487). + * Returns null when there is no session token on disk (fully-standalone AMS / no `loopover-mcp login`). + */ +export function resolveLoopoverBackendSession( + env: NodeJS.ProcessEnv = process.env, +): { apiUrl: string; sessionToken: string } | null { + const sessionToken = loopoverSessionToken(env); + if (!sessionToken) return null; + return { apiUrl: loopoverApiUrl(env), sessionToken }; +} + +async function fetchLiveGitHubTokenFromSession( + sessionToken: string, + apiUrl: string, + fetchImpl: GitHubTokenResolutionFetch, +): Promise { + try { + const response = await fetchImpl(`${apiUrl}/v1/auth/github/token`, { + method: "POST", + headers: { authorization: `Bearer ${sessionToken}`, accept: "application/json" }, + signal: AbortSignal.timeout(GITHUB_TOKEN_FETCH_TIMEOUT_MS), + }); + if (!response.ok) return null; + const payload = (await response.json().catch(() => null)) as { token?: unknown } | null; + return typeof payload?.token === "string" && payload.token ? payload.token : null; + } catch { + return null; + } +} + +// Process-lifetime cache of a SUCCESSFUL resolution only. A failure (no session, expired session, transient +// network error) is deliberately NOT cached -- it's retried on the next call instead, so a long-running AMS +// process can self-heal from a transient blip rather than being stuck treating the token as permanently +// unavailable for its entire remaining lifetime. +let cachedToken: string | undefined; + +/** + * Resolve a GitHub token for AMS's git operations (#6116). Returns null when nothing is available: no + * GITHUB_TOKEN override, no loopover-mcp session on disk, or the session-token fetch fails for any reason -- + * callers already treat a missing token as "git operations requiring auth will fail," the same failure mode + * as before this feature existed. + */ +export async function resolveGitHubToken( + env: NodeJS.ProcessEnv = process.env, + options: { fetchImpl?: GitHubTokenResolutionFetch } = {}, +): Promise { + if (env.GITHUB_TOKEN) return env.GITHUB_TOKEN; + if (cachedToken) return cachedToken; + const sessionToken = loopoverSessionToken(env); + if (!sessionToken) return null; + const fetchImpl = options.fetchImpl ?? (fetch as GitHubTokenResolutionFetch); + const fetched = await fetchLiveGitHubTokenFromSession(sessionToken, loopoverApiUrl(env), fetchImpl); + if (fetched) cachedToken = fetched; + return fetched; +} + +/** Test-only: clear the process-lifetime cache so one test's resolution can't leak into the next. */ +export function resetGitHubTokenResolutionForTesting(): void { + cachedToken = undefined; +} + +/** + * Offline-only check: does resolveGitHubToken have ANYTHING to try (a GITHUB_TOKEN override, or a + * loopover-mcp session recorded on disk), without making the network call resolveGitHubToken itself would + * make to actually verify it still works. For `doctor`/`status`-style diagnostics (status.js's + * checkGitHubTokenPresent), which are deliberately offline-only -- a genuinely expired or revoked session + * still reports "present" here; only an actual attempt (or resolveGitHubToken itself) discovers that. + */ +export function hasGitHubTokenSource(env: NodeJS.ProcessEnv = process.env): boolean { + return Boolean(env.GITHUB_TOKEN) || Boolean(loopoverSessionToken(env)); +} diff --git a/packages/loopover-miner/lib/http-retry.d.ts b/packages/loopover-miner/lib/http-retry.d.ts index 161bfa8ca5..c056b49e7f 100644 --- a/packages/loopover-miner/lib/http-retry.d.ts +++ b/packages/loopover-miner/lib/http-retry.d.ts @@ -1,13 +1,20 @@ -export function defaultRetryBackoffMs(attempt: number): number; - -export function fetchWithRetry( - fetchFn: (url: unknown, init?: unknown) => Promise, - url: unknown, - init?: unknown, - options?: { +export type FetchWithRetryOptions = { maxAttempts?: number; sleepFn?: (ms: number) => Promise; backoffMs?: (attempt: number) => number; timeoutMs?: number; - }, -): Promise; +}; +/** Exponential backoff from a base delay, capped: attempt 1 → base, 2 → 2×base, 3 → 4×base, … ≤ MAX_BACKOFF_MS. */ +export declare function defaultRetryBackoffMs(attempt: number): number; +/** + * Perform `fetchFn(url, init)` with bounded retry on a transient 5xx OR rate-limit (429 / secondary-403) response. + * A retryable status is retried (sleeping `Retry-After` or `backoffMs(attempt)`, whichever is longer, between + * attempts) up to `maxAttempts`; any other 2xx/3xx/4xx response is returned immediately, and after the last attempt + * a lingering retryable status is returned as-is (the caller's own error handling still runs). A THROWN + * error is NOT retried — it propagates to the caller (the pollers' #4281 failure-mode contract). When `timeoutMs` + * is given, each attempt gets its own fresh abort timeout (a stalled connection is exactly the kind of network- + * level failure #4281 already bubbles unretried, so a timed-out attempt propagates the same way). + */ +export declare function fetchWithRetry(fetchFn: (url: unknown, init?: unknown) => Promise, url: unknown, init?: unknown, options?: FetchWithRetryOptions): Promise; diff --git a/packages/loopover-miner/lib/http-retry.js b/packages/loopover-miner/lib/http-retry.js index 9d5790f582..8172ee0a58 100644 --- a/packages/loopover-miner/lib/http-retry.js +++ b/packages/loopover-miner/lib/http-retry.js @@ -8,31 +8,25 @@ // level failure) propagates unchanged rather than being retried — the pollers' existing failure-mode contract // (#4281) deliberately bubbles those to the caller. // Pure control flow over injected `fetchFn`/`sleepFn`/`backoffMs` — no real network or timers in tests. - const DEFAULT_MAX_ATTEMPTS = 3; const DEFAULT_BASE_BACKOFF_MS = 500; const MAX_BACKOFF_MS = 10_000; - /** Clamp `maxAttempts` to a positive integer, flooring BEFORE the positivity test so a fractional value below 1 * falls back to the default rather than becoming a 0 that would skip every attempt. */ function normalizeMaxAttempts(raw) { - const numeric = Math.floor(Number(raw)); - return Number.isFinite(numeric) && numeric >= 1 ? numeric : DEFAULT_MAX_ATTEMPTS; + const numeric = Math.floor(Number(raw)); + return Number.isFinite(numeric) && numeric >= 1 ? numeric : DEFAULT_MAX_ATTEMPTS; } - /** Exponential backoff from a base delay, capped: attempt 1 → base, 2 → 2×base, 3 → 4×base, … ≤ MAX_BACKOFF_MS. */ export function defaultRetryBackoffMs(attempt) { - return Math.min(MAX_BACKOFF_MS, DEFAULT_BASE_BACKOFF_MS * 2 ** (Math.max(1, attempt) - 1)); + return Math.min(MAX_BACKOFF_MS, DEFAULT_BASE_BACKOFF_MS * 2 ** (Math.max(1, attempt) - 1)); } - const defaultSleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)); - /** Read a response header defensively — works with a real `Headers` object or a test stub exposing `.get()`. */ function readHeader(response, name) { - const headers = response && response.headers; - return headers && typeof headers.get === "function" ? headers.get(name) : null; + const headers = response && response.headers; + return headers && typeof headers.get === "function" ? headers.get(name) : null; } - /** * A transient GitHub rate-limit response the poll should ride out rather than abort on (#6761): a 429 (primary * rate limit / abuse), or a SECONDARY-rate-limit 403 — identified by a `Retry-After` header or `x-ratelimit- @@ -40,32 +34,32 @@ function readHeader(response, name) { * limit: it can never succeed, so retrying it would only burn the bounded attempt budget. */ function isRateLimitStatus(response) { - if (response.status === 429) return true; - if (response.status !== 403) return false; - if (readHeader(response, "retry-after") != null) return true; - const remaining = readHeader(response, "x-ratelimit-remaining"); - return remaining != null && Number(remaining) === 0; + if (response.status === 429) + return true; + if (response.status !== 403) + return false; + if (readHeader(response, "retry-after") != null) + return true; + const remaining = readHeader(response, "x-ratelimit-remaining"); + return remaining != null && Number(remaining) === 0; } - /** Retry a transient SERVER error (5xx) OR a transient rate-limit response (429 / secondary-403). (#6761) */ function isRetryableStatus(response) { - return response.status >= 500 || isRateLimitStatus(response); + return response.status >= 500 || isRateLimitStatus(response); } - /** * Delay before the next attempt. Honor a `Retry-After` header (delta-seconds) when GitHub sends one — but never * below the computed exponential backoff (so a tiny/zero value can't hammer) and never above MAX_BACKOFF_MS; * otherwise fall back to the exponential backoff alone. (#6761) */ function retryDelayMs(response, attempt, backoffMs) { - const base = backoffMs(attempt); - const retryAfterSeconds = Number(readHeader(response, "retry-after")); - if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0) { - return Math.min(MAX_BACKOFF_MS, Math.max(base, retryAfterSeconds * 1000)); - } - return base; + const base = backoffMs(attempt); + const retryAfterSeconds = Number(readHeader(response, "retry-after")); + if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0) { + return Math.min(MAX_BACKOFF_MS, Math.max(base, retryAfterSeconds * 1000)); + } + return base; } - /** * Perform `fetchFn(url, init)` with bounded retry on a transient 5xx OR rate-limit (429 / secondary-403) response. * A retryable status is retried (sleeping `Retry-After` or `backoffMs(attempt)`, whichever is longer, between @@ -74,28 +68,22 @@ function retryDelayMs(response, attempt, backoffMs) { * error is NOT retried — it propagates to the caller (the pollers' #4281 failure-mode contract). When `timeoutMs` * is given, each attempt gets its own fresh abort timeout (a stalled connection is exactly the kind of network- * level failure #4281 already bubbles unretried, so a timed-out attempt propagates the same way). - * - * @param {(url: any, init?: any) => Promise} fetchFn - * @param {any} url - * @param {any} [init] - * @param {{ maxAttempts?: number, sleepFn?: (ms: number) => Promise, backoffMs?: (attempt: number) => number, timeoutMs?: number }} [options] - * @returns {Promise} the fetch response */ export async function fetchWithRetry(fetchFn, url, init, options = {}) { - const maxAttempts = normalizeMaxAttempts(options.maxAttempts); - const sleepFn = typeof options.sleepFn === "function" ? options.sleepFn : defaultSleep; - const backoffMs = typeof options.backoffMs === "function" ? options.backoffMs : defaultRetryBackoffMs; - for (let attempt = 1; ; attempt += 1) { - // A thrown error is intentionally NOT caught here — it propagates to the caller unchanged. - const response = await fetchOnce(fetchFn, url, init, options.timeoutMs); - // Retry transient SERVER errors (5xx) AND transient GitHub rate-limit responses (429 / secondary-403, #6761). - // Everything else (2xx/3xx/other 4xx incl. a plain permission 403) is returned immediately; on the final - // attempt a lingering retryable status is returned as-is so the caller's own error handling still runs. - if (!isRetryableStatus(response) || attempt >= maxAttempts) return response; - await sleepFn(retryDelayMs(response, attempt, backoffMs)); - } + const maxAttempts = normalizeMaxAttempts(options.maxAttempts); + const sleepFn = typeof options.sleepFn === "function" ? options.sleepFn : defaultSleep; + const backoffMs = typeof options.backoffMs === "function" ? options.backoffMs : defaultRetryBackoffMs; + for (let attempt = 1;; attempt += 1) { + // A thrown error is intentionally NOT caught here — it propagates to the caller unchanged. + const response = await fetchOnce(fetchFn, url, init, options.timeoutMs); + // Retry transient SERVER errors (5xx) AND transient GitHub rate-limit responses (429 / secondary-403, #6761). + // Everything else (2xx/3xx/other 4xx incl. a plain permission 403) is returned immediately; on the final + // attempt a lingering retryable status is returned as-is so the caller's own error handling still runs. + if (!isRetryableStatus(response) || attempt >= maxAttempts) + return response; + await sleepFn(retryDelayMs(response, attempt, backoffMs)); + } } - // A fresh AbortSignal.timeout() per attempt, never one shared across retries -- reusing a single signal would // leave every attempt after the first pre-aborted the instant it fired once. AbortSignal.timeout()'s own internal // timer is unref'd (verified: it never keeps a short-lived CLI process alive past its own work), so unlike a raw @@ -103,6 +91,8 @@ export async function fetchWithRetry(fetchFn, url, init, options = {}) { // no-op passthrough (no `init` copy) when `timeoutMs` is absent/non-positive, so every existing caller that // doesn't opt in sees zero behavior change. function fetchOnce(fetchFn, url, init, timeoutMs) { - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return fetchFn(url, init); - return fetchFn(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) + return fetchFn(url, init); + return fetchFn(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }); } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaHR0cC1yZXRyeS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbImh0dHAtcmV0cnkudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsMkdBQTJHO0FBQzNHLHlHQUF5RztBQUN6RyxpSEFBaUg7QUFDakgsaUhBQWlIO0FBQ2pILGdIQUFnSDtBQUNoSCxtSEFBbUg7QUFDbkgsb0hBQW9IO0FBQ3BILDhHQUE4RztBQUM5RyxvREFBb0Q7QUFDcEQsd0dBQXdHO0FBRXhHLE1BQU0sb0JBQW9CLEdBQUcsQ0FBQyxDQUFDO0FBQy9CLE1BQU0sdUJBQXVCLEdBQUcsR0FBRyxDQUFDO0FBQ3BDLE1BQU0sY0FBYyxHQUFHLE1BQU0sQ0FBQztBQWM5Qjt3RkFDd0Y7QUFDeEYsU0FBUyxvQkFBb0IsQ0FBQyxHQUFZO0lBQ3hDLE1BQU0sT0FBTyxHQUFHLElBQUksQ0FBQyxLQUFLLENBQUMsTUFBTSxDQUFDLEdBQUcsQ0FBQyxDQUFDLENBQUM7SUFDeEMsT0FBTyxNQUFNLENBQUMsUUFBUSxDQUFDLE9BQU8sQ0FBQyxJQUFJLE9BQU8sSUFBSSxDQUFDLENBQUMsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxDQUFDLENBQUMsb0JBQW9CLENBQUM7QUFDbkYsQ0FBQztBQUVELG1IQUFtSDtBQUNuSCxNQUFNLFVBQVUscUJBQXFCLENBQUMsT0FBZTtJQUNuRCxPQUFPLElBQUksQ0FBQyxHQUFHLENBQUMsY0FBYyxFQUFFLHVCQUF1QixHQUFHLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxHQUFHLENBQUMsQ0FBQyxFQUFFLE9BQU8sQ0FBQyxHQUFHLENBQUMsQ0FBQyxDQUFDLENBQUM7QUFDN0YsQ0FBQztBQUVELE1BQU0sWUFBWSxHQUFHLENBQUMsT0FBZSxFQUFpQixFQUFFLENBQUMsSUFBSSxPQUFPLENBQUMsQ0FBQyxPQUFPLEVBQUUsRUFBRSxDQUFDLFVBQVUsQ0FBQyxPQUFPLEVBQUUsT0FBTyxDQUFDLENBQUMsQ0FBQztBQUVoSCxnSEFBZ0g7QUFDaEgsU0FBUyxV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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/http-retry.ts b/packages/loopover-miner/lib/http-retry.ts new file mode 100644 index 0000000000..9809e2fb27 --- /dev/null +++ b/packages/loopover-miner/lib/http-retry.ts @@ -0,0 +1,128 @@ +// Bounded retry-with-backoff around a single HTTP call (#4829). The miner's pollers (ci-poller and others) +// previously let a single brief 5xx from GitHub kill the whole poll loop, because their own attempt loop +// only re-polls while a conclusion is genuinely "pending", never after a server error. This wraps ONE fetch so a +// transient SERVER error (a 5xx RESPONSE) or a transient GitHub RATE-LIMIT response (429 / secondary-403, #6761) +// is retried a bounded number of times, DISTINCT from that pending-polling, sleeping an exponential backoff (or +// the response's `Retry-After`, whichever is longer) between attempts and giving up after `maxAttempts`. Any other +// 2xx/3xx/4xx response — including a plain permission 403 — is returned immediately, and a THROWN error (a network- +// level failure) propagates unchanged rather than being retried — the pollers' existing failure-mode contract +// (#4281) deliberately bubbles those to the caller. +// Pure control flow over injected `fetchFn`/`sleepFn`/`backoffMs` — no real network or timers in tests. + +const DEFAULT_MAX_ATTEMPTS = 3; +const DEFAULT_BASE_BACKOFF_MS = 500; +const MAX_BACKOFF_MS = 10_000; + +export type FetchWithRetryOptions = { + maxAttempts?: number; + sleepFn?: (ms: number) => Promise; + backoffMs?: (attempt: number) => number; + timeoutMs?: number; +}; + +type RetryableResponse = { + status: number; + headers?: { get?: (name: string) => string | null } | null; +}; + +/** Clamp `maxAttempts` to a positive integer, flooring BEFORE the positivity test so a fractional value below 1 + * falls back to the default rather than becoming a 0 that would skip every attempt. */ +function normalizeMaxAttempts(raw: unknown): number { + const numeric = Math.floor(Number(raw)); + return Number.isFinite(numeric) && numeric >= 1 ? numeric : DEFAULT_MAX_ATTEMPTS; +} + +/** Exponential backoff from a base delay, capped: attempt 1 → base, 2 → 2×base, 3 → 4×base, … ≤ MAX_BACKOFF_MS. */ +export function defaultRetryBackoffMs(attempt: number): number { + return Math.min(MAX_BACKOFF_MS, DEFAULT_BASE_BACKOFF_MS * 2 ** (Math.max(1, attempt) - 1)); +} + +const defaultSleep = (delayMs: number): Promise => new Promise((resolve) => setTimeout(resolve, delayMs)); + +/** Read a response header defensively — works with a real `Headers` object or a test stub exposing `.get()`. */ +function readHeader(response: RetryableResponse, name: string): string | null { + const headers = response && response.headers; + return headers && typeof headers.get === "function" ? headers.get(name) : null; +} + +/** + * A transient GitHub rate-limit response the poll should ride out rather than abort on (#6761): a 429 (primary + * rate limit / abuse), or a SECONDARY-rate-limit 403 — identified by a `Retry-After` header or `x-ratelimit- + * remaining: 0`. A plain permission-denied 403 carries neither signal and is deliberately NOT treated as a rate + * limit: it can never succeed, so retrying it would only burn the bounded attempt budget. + */ +function isRateLimitStatus(response: RetryableResponse): boolean { + if (response.status === 429) return true; + if (response.status !== 403) return false; + if (readHeader(response, "retry-after") != null) return true; + const remaining = readHeader(response, "x-ratelimit-remaining"); + return remaining != null && Number(remaining) === 0; +} + +/** Retry a transient SERVER error (5xx) OR a transient rate-limit response (429 / secondary-403). (#6761) */ +function isRetryableStatus(response: RetryableResponse): boolean { + return response.status >= 500 || isRateLimitStatus(response); +} + +/** + * Delay before the next attempt. Honor a `Retry-After` header (delta-seconds) when GitHub sends one — but never + * below the computed exponential backoff (so a tiny/zero value can't hammer) and never above MAX_BACKOFF_MS; + * otherwise fall back to the exponential backoff alone. (#6761) + */ +function retryDelayMs( + response: RetryableResponse, + attempt: number, + backoffMs: (attempt: number) => number, +): number { + const base = backoffMs(attempt); + const retryAfterSeconds = Number(readHeader(response, "retry-after")); + if (Number.isFinite(retryAfterSeconds) && retryAfterSeconds >= 0) { + return Math.min(MAX_BACKOFF_MS, Math.max(base, retryAfterSeconds * 1000)); + } + return base; +} + +/** + * Perform `fetchFn(url, init)` with bounded retry on a transient 5xx OR rate-limit (429 / secondary-403) response. + * A retryable status is retried (sleeping `Retry-After` or `backoffMs(attempt)`, whichever is longer, between + * attempts) up to `maxAttempts`; any other 2xx/3xx/4xx response is returned immediately, and after the last attempt + * a lingering retryable status is returned as-is (the caller's own error handling still runs). A THROWN + * error is NOT retried — it propagates to the caller (the pollers' #4281 failure-mode contract). When `timeoutMs` + * is given, each attempt gets its own fresh abort timeout (a stalled connection is exactly the kind of network- + * level failure #4281 already bubbles unretried, so a timed-out attempt propagates the same way). + */ +export async function fetchWithRetry( + fetchFn: (url: unknown, init?: unknown) => Promise, + url: unknown, + init?: unknown, + options: FetchWithRetryOptions = {}, +): Promise { + const maxAttempts = normalizeMaxAttempts(options.maxAttempts); + const sleepFn = typeof options.sleepFn === "function" ? options.sleepFn : defaultSleep; + const backoffMs = typeof options.backoffMs === "function" ? options.backoffMs : defaultRetryBackoffMs; + for (let attempt = 1; ; attempt += 1) { + // A thrown error is intentionally NOT caught here — it propagates to the caller unchanged. + const response = await fetchOnce(fetchFn, url, init, options.timeoutMs); + // Retry transient SERVER errors (5xx) AND transient GitHub rate-limit responses (429 / secondary-403, #6761). + // Everything else (2xx/3xx/other 4xx incl. a plain permission 403) is returned immediately; on the final + // attempt a lingering retryable status is returned as-is so the caller's own error handling still runs. + if (!isRetryableStatus(response) || attempt >= maxAttempts) return response; + await sleepFn(retryDelayMs(response, attempt, backoffMs)); + } +} + +// A fresh AbortSignal.timeout() per attempt, never one shared across retries -- reusing a single signal would +// leave every attempt after the first pre-aborted the instant it fired once. AbortSignal.timeout()'s own internal +// timer is unref'd (verified: it never keeps a short-lived CLI process alive past its own work), so unlike a raw +// setTimeout it needs no manual clearTimeout -- mirrors src/github/client.ts's timeoutFetch in the main repo. A +// no-op passthrough (no `init` copy) when `timeoutMs` is absent/non-positive, so every existing caller that +// doesn't opt in sees zero behavior change. +function fetchOnce( + fetchFn: (url: unknown, init?: unknown) => Promise, + url: unknown, + init: unknown, + timeoutMs: number | undefined, +): Promise { + if (!Number.isFinite(timeoutMs) || (timeoutMs as number) <= 0) return fetchFn(url, init); + return fetchFn(url, { ...(init as object), signal: AbortSignal.timeout(timeoutMs as number) }); +} diff --git a/packages/loopover-miner/lib/policy-verdict-cache.d.ts b/packages/loopover-miner/lib/policy-verdict-cache.d.ts index 9616e46781..667c43f34e 100644 --- a/packages/loopover-miner/lib/policy-verdict-cache.d.ts +++ b/packages/loopover-miner/lib/policy-verdict-cache.d.ts @@ -1,37 +1,29 @@ import type { AiPolicyVerdict } from "@loopover/engine"; - export type PolicyVerdictDecisiveDoc = "AI-USAGE.md" | "CONTRIBUTING.md"; - export type PolicyVerdictCacheEntry = { - decisiveDoc: PolicyVerdictDecisiveDoc; - etag: string; - verdict: AiPolicyVerdict; + decisiveDoc: PolicyVerdictDecisiveDoc; + etag: string; + verdict: AiPolicyVerdict; }; - export type PolicyVerdictCacheWrite = PolicyVerdictCacheEntry & { - repoScope: string; - updatedAt: string; + repoScope: string; + updatedAt: string; }; - export type PolicyVerdictCacheStore = { - dbPath: string; - /** `repoScope` must uniquely identify a tenant forge host + repo (see `policyVerdictCacheKey` in - * opportunity-fanout.js) -- a bare `owner/repo` is not safe across multiple forge hosts. */ - get(repoScope: string): PolicyVerdictCacheEntry | null; - put( - repoScope: string, - decisiveDoc: PolicyVerdictDecisiveDoc, - etag: string, - verdict: AiPolicyVerdict, - ): PolicyVerdictCacheWrite; - /** Delete every cached verdict row for one repo scope (#6987); returns the number of rows removed. */ - purgeByRepo(repoScope: string): number; - close(): void; + dbPath: string; + /** `repoScope` must uniquely identify a tenant forge host + repo (see `policyVerdictCacheKey` in + * opportunity-fanout.js) -- a bare `owner/repo` is not safe across multiple forge hosts. */ + get(repoScope: string): PolicyVerdictCacheEntry | null; + put(repoScope: string, decisiveDoc: PolicyVerdictDecisiveDoc, etag: string, verdict: AiPolicyVerdict): PolicyVerdictCacheWrite; + /** Delete every cached verdict row for one repo scope (#6987); returns the number of rows removed. */ + purgeByRepo(repoScope: string): number; + close(): void; }; - /** The read/write surface opportunity-fanout.js needs to inject a cache without depending on the SQLite store. */ export type PolicyVerdictCache = Pick; - -export function resolvePolicyVerdictCacheDbPath(env?: Record): string; - -export function initPolicyVerdictCacheStore(dbPath?: string): PolicyVerdictCacheStore; +export declare function resolvePolicyVerdictCacheDbPath(env?: Record): string; +/** + * Opens the 100% local/client-side miner policy-verdict cache. The database only lives on this machine; this + * module never uploads, syncs, or phones home with its contents. (#4843) + */ +export declare function initPolicyVerdictCacheStore(dbPath?: string): PolicyVerdictCacheStore; diff --git a/packages/loopover-miner/lib/policy-verdict-cache.js b/packages/loopover-miner/lib/policy-verdict-cache.js index 83b4103009..1b1f6a7115 100644 --- a/packages/loopover-miner/lib/policy-verdict-cache.js +++ b/packages/loopover-miner/lib/policy-verdict-cache.js @@ -1,63 +1,46 @@ import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; import { POLICY_VERDICT_CACHE_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js"; - -// Local cache of resolved AI-usage-policy verdicts (#4843). Even with #4842's conditional-GET doc cache, the small -// but non-zero cost of resolving `resolveAiPolicyVerdict` from raw doc text was still paid on every discover run. -// This stores the verdict itself, keyed by repo SCOPE (the tenant's `apiBaseUrl` plus `owner/repo` -- see -// `policyVerdictCacheKey` in opportunity-fanout.js, same "the caller owns what makes a cache key" precedent as -// policy-doc-cache.js keying on the full request URL) + the ETag of whichever doc actually decided it, so a -// repeat run against an unchanged repo reuses the prior verdict outright once opportunity-fanout.js's same-run -// conditional-GET confirms that doc's ETag hasn't moved -- never served blindly, exactly the same "cheaper, never -// less correct" discipline as policy-doc-cache.js. `owner/repo` alone is NOT a safe key: two different tenant -// forge hosts can each have their own unrelated `acme/widgets`, and without the host in the key a verdict -// resolved against one host's docs could be served for the other's. 100% local/client-side, same as every other -// store this package owns via local-store.js: the file lives only on this machine and is never uploaded, synced, -// or phoned home with. - const defaultDbFileName = "policy-verdict-cache.sqlite3"; const DECISIVE_DOCS = new Set(["AI-USAGE.md", "CONTRIBUTING.md"]); - export function resolvePolicyVerdictCacheDbPath(env = process.env) { - return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB", env); + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB", env); } - function normalizeDbPath(dbPath) { - return normalizeLocalStoreDbPath(dbPath, resolvePolicyVerdictCacheDbPath(), "invalid_policy_verdict_cache_db_path"); + return normalizeLocalStoreDbPath(dbPath, resolvePolicyVerdictCacheDbPath(), "invalid_policy_verdict_cache_db_path"); } - function normalizeRepoScope(repoScope) { - if (typeof repoScope !== "string") throw new Error("invalid_policy_verdict_repo_scope"); - const trimmed = repoScope.trim(); - if (!trimmed) throw new Error("invalid_policy_verdict_repo_scope"); - return trimmed; + if (typeof repoScope !== "string") + throw new Error("invalid_policy_verdict_repo_scope"); + const trimmed = repoScope.trim(); + if (!trimmed) + throw new Error("invalid_policy_verdict_repo_scope"); + return trimmed; } - function normalizeDecisiveDoc(decisiveDoc) { - if (!DECISIVE_DOCS.has(decisiveDoc)) throw new Error("invalid_policy_verdict_decisive_doc"); - return decisiveDoc; + if (!DECISIVE_DOCS.has(decisiveDoc)) + throw new Error("invalid_policy_verdict_decisive_doc"); + return decisiveDoc; } - function normalizeEtag(etag) { - if (typeof etag !== "string" || !etag.trim()) throw new Error("invalid_policy_verdict_etag"); - return etag; + if (typeof etag !== "string" || !etag.trim()) + throw new Error("invalid_policy_verdict_etag"); + return etag; } - function serializeVerdict(verdict) { - if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) { - throw new Error("invalid_policy_verdict"); - } - return JSON.stringify(verdict); + if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) { + throw new Error("invalid_policy_verdict"); + } + return JSON.stringify(verdict); } - /** * Opens the 100% local/client-side miner policy-verdict cache. The database only lives on this machine; this * module never uploads, syncs, or phones home with its contents. (#4843) */ export function initPolicyVerdictCacheStore(dbPath = resolvePolicyVerdictCacheDbPath()) { - 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 policy_verdict_cache ( repo_scope TEXT PRIMARY KEY, decisive_doc TEXT NOT NULL, @@ -66,13 +49,10 @@ export function initPolicyVerdictCacheStore(dbPath = resolvePolicyVerdictCacheDb updated_at TEXT NOT NULL ) `); - // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet). - applySchemaMigrations(db, []); - - const getStatement = db.prepare( - "SELECT decisive_doc, etag, verdict FROM policy_verdict_cache WHERE repo_scope = ?", - ); - const putStatement = db.prepare(` + // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet). + applySchemaMigrations(db, []); + const getStatement = db.prepare("SELECT decisive_doc, etag, verdict FROM policy_verdict_cache WHERE repo_scope = ?"); + const putStatement = db.prepare(` INSERT INTO policy_verdict_cache (repo_scope, decisive_doc, etag, verdict, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(repo_scope) DO UPDATE SET @@ -81,35 +61,40 @@ export function initPolicyVerdictCacheStore(dbPath = resolvePolicyVerdictCacheDb verdict = excluded.verdict, updated_at = excluded.updated_at `); - - return { - dbPath: resolvedPath, - /** The last-known `{ decisiveDoc, etag, verdict }` for a repo scope, or null when it has never been cached. */ - get(repoScope) { - const row = getStatement.get(normalizeRepoScope(repoScope)); - if (!row) return null; - return { decisiveDoc: row.decisive_doc, etag: row.etag, verdict: JSON.parse(row.verdict) }; - }, - /** Record the resolved verdict against the ETag of the doc that decided it, so the next run can reuse it. */ - put(repoScope, decisiveDoc, etag, verdict) { - const normalizedRepoScope = normalizeRepoScope(repoScope); - const normalizedDecisiveDoc = normalizeDecisiveDoc(decisiveDoc); - const normalizedEtag = normalizeEtag(etag); - const serializedVerdict = serializeVerdict(verdict); - const updatedAt = new Date().toISOString(); - putStatement.run(normalizedRepoScope, normalizedDecisiveDoc, normalizedEtag, serializedVerdict, updatedAt); - return { repoScope: normalizedRepoScope, decisiveDoc: normalizedDecisiveDoc, etag: normalizedEtag, verdict, updatedAt }; - }, - /** - * Delete every cached verdict row for one repo scope (#6987) -- the right-to-be-forgotten path - * `loopover-miner purge` invokes. Returns the number of rows removed. Reuses store-maintenance.js's - * identifier-guarded purgeStoreByRepo, exactly like the other repo-scoped stores. - */ - purgeByRepo(repoScope) { - return purgeStoreByRepo(db, POLICY_VERDICT_CACHE_PURGE_SPEC, normalizeRepoScope(repoScope)); - }, - close() { - db.close(); - }, - }; + return { + dbPath: resolvedPath, + /** The last-known `{ decisiveDoc, etag, verdict }` for a repo scope, or null when it has never been cached. */ + get(repoScope) { + const row = getStatement.get(normalizeRepoScope(repoScope)); + if (!row) + return null; + return { + decisiveDoc: row.decisive_doc, + etag: row.etag, + verdict: JSON.parse(row.verdict), + }; + }, + /** Record the resolved verdict against the ETag of the doc that decided it, so the next run can reuse it. */ + put(repoScope, decisiveDoc, etag, verdict) { + const normalizedRepoScope = normalizeRepoScope(repoScope); + const normalizedDecisiveDoc = normalizeDecisiveDoc(decisiveDoc); + const normalizedEtag = normalizeEtag(etag); + const serializedVerdict = serializeVerdict(verdict); + const updatedAt = new Date().toISOString(); + putStatement.run(normalizedRepoScope, normalizedDecisiveDoc, normalizedEtag, serializedVerdict, updatedAt); + return { repoScope: normalizedRepoScope, decisiveDoc: normalizedDecisiveDoc, etag: normalizedEtag, verdict, updatedAt }; + }, + /** + * Delete every cached verdict row for one repo scope (#6987) -- the right-to-be-forgotten path + * `loopover-miner purge` invokes. Returns the number of rows removed. Reuses store-maintenance.js's + * identifier-guarded purgeStoreByRepo, exactly like the other repo-scoped stores. + */ + purgeByRepo(repoScope) { + return purgeStoreByRepo(db, POLICY_VERDICT_CACHE_PURGE_SPEC, normalizeRepoScope(repoScope)); + }, + close() { + db.close(); + }, + }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicG9saWN5LXZlcmRpY3QtY2FjaGUuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJwb2xpY3ktdmVyZGljdC1jYWNoZS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFDQSxPQUFPLEVBQUUseUJBQXlCLEVBQUUsZ0JBQWdCLEVBQUUsdUJBQXVCLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQUN4RyxPQUFPLEVBQUUscUJBQXFCLEVBQUUsTUFBTSxxQkFBcUIsQ0FBQztBQUM1RCxPQUFPLEVBQUUsK0JBQStCLEVBQUUsZ0JBQWdCLEVBQUUsTUFBTSx3QkFBd0IsQ0FBQztBQXFEM0YsTUFBTSxpQkFBaUIsR0FBRyw4QkFBOEIsQ0FBQztBQUN6RCxNQUFNLGFBQWEsR0FBRyxJQUFJLEdBQUcsQ0FBUyxDQUFDLGFBQWEsRUFBRSxpQkFBaUIsQ0FBQyxDQUFDLENBQUM7QUFFMUUsTUFBTSxVQUFVLCtCQUErQixDQUFDLE1BQTBDLE9BQU8sQ0FBQyxHQUFHO0lBQ25HLE9BQU8sdUJBQXVCLENBQUMsaUJBQWlCLEVBQUUsd0NBQXdDLEVBQUUsR0FBRyxDQUFDLENBQUM7QUFDbkcsQ0FBQztBQUVELFNBQVMsZUFBZSxDQUFDLE1BQWlDO0lBQ3hELE9BQU8seUJBQXlCLENBQUMsTUFBTSxFQUFFLCtCQUErQixFQUFFLEVBQUUsc0NBQXNDLENBQUMsQ0FBQztBQUN0SCxDQUFDO0FBRUQsU0FBUyxrQkFBa0IsQ0FBQyxTQUFrQjtJQUM1QyxJQUFJLE9BQU8sU0FBUyxLQUFLLFFBQVE7UUFBRSxNQUFNLElBQUksS0FBSyxDQUFDLG1DQUFtQyxDQUFDLENBQUM7SUFDeEYsTUFBTSxPQUFPLEdBQUcsU0FBUyxDQUFDLElBQUksRUFBRSxDQUFDO0lBQ2pDLElBQUksQ0FBQyxPQUFPO1FBQUUsTUFBTSxJQUFJLEtBQUssQ0FBQyxtQ0FBbUMsQ0FBQyxDQUFDO0lBQ25FLE9BQU8sT0FBTyxDQUFDO0FBQ2pCLENBQUM7QUFFRCxTQUFTLG9CQUFvQixDQUFDLFdBQW9CO0lB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/policy-verdict-cache.ts b/packages/loopover-miner/lib/policy-verdict-cache.ts new file mode 100644 index 0000000000..a43ce880e7 --- /dev/null +++ b/packages/loopover-miner/lib/policy-verdict-cache.ts @@ -0,0 +1,158 @@ +import type { AiPolicyVerdict } from "@loopover/engine"; +import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; +import { applySchemaMigrations } from "./schema-version.js"; +import { POLICY_VERDICT_CACHE_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js"; + +// Local cache of resolved AI-usage-policy verdicts (#4843). Even with #4842's conditional-GET doc cache, the small +// but non-zero cost of resolving `resolveAiPolicyVerdict` from raw doc text was still paid on every discover run. +// This stores the verdict itself, keyed by repo SCOPE (the tenant's `apiBaseUrl` plus `owner/repo` -- see +// `policyVerdictCacheKey` in opportunity-fanout.js, same "the caller owns what makes a cache key" precedent as +// policy-doc-cache.js keying on the full request URL) + the ETag of whichever doc actually decided it, so a +// repeat run against an unchanged repo reuses the prior verdict outright once opportunity-fanout.js's same-run +// conditional-GET confirms that doc's ETag hasn't moved -- never served blindly, exactly the same "cheaper, never +// less correct" discipline as policy-doc-cache.js. `owner/repo` alone is NOT a safe key: two different tenant +// forge hosts can each have their own unrelated `acme/widgets`, and without the host in the key a verdict +// resolved against one host's docs could be served for the other's. 100% local/client-side, same as every other +// store this package owns via local-store.js: the file lives only on this machine and is never uploaded, synced, +// or phoned home with. + +export type PolicyVerdictDecisiveDoc = "AI-USAGE.md" | "CONTRIBUTING.md"; + +export type PolicyVerdictCacheEntry = { + decisiveDoc: PolicyVerdictDecisiveDoc; + etag: string; + verdict: AiPolicyVerdict; +}; + +export type PolicyVerdictCacheWrite = PolicyVerdictCacheEntry & { + repoScope: string; + updatedAt: string; +}; + +export type PolicyVerdictCacheStore = { + dbPath: string; + /** `repoScope` must uniquely identify a tenant forge host + repo (see `policyVerdictCacheKey` in + * opportunity-fanout.js) -- a bare `owner/repo` is not safe across multiple forge hosts. */ + get(repoScope: string): PolicyVerdictCacheEntry | null; + put( + repoScope: string, + decisiveDoc: PolicyVerdictDecisiveDoc, + etag: string, + verdict: AiPolicyVerdict, + ): PolicyVerdictCacheWrite; + /** Delete every cached verdict row for one repo scope (#6987); returns the number of rows removed. */ + purgeByRepo(repoScope: string): number; + close(): void; +}; + +/** The read/write surface opportunity-fanout.js needs to inject a cache without depending on the SQLite store. */ +export type PolicyVerdictCache = Pick; + +type PolicyVerdictCacheRow = { + decisive_doc: string; + etag: string; + verdict: string; +}; + +const defaultDbFileName = "policy-verdict-cache.sqlite3"; +const DECISIVE_DOCS = new Set(["AI-USAGE.md", "CONTRIBUTING.md"]); + +export function resolvePolicyVerdictCacheDbPath(env: Record = process.env): string { + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_POLICY_VERDICT_CACHE_DB", env); +} + +function normalizeDbPath(dbPath: string | null | undefined): string { + return normalizeLocalStoreDbPath(dbPath, resolvePolicyVerdictCacheDbPath(), "invalid_policy_verdict_cache_db_path"); +} + +function normalizeRepoScope(repoScope: unknown): string { + if (typeof repoScope !== "string") throw new Error("invalid_policy_verdict_repo_scope"); + const trimmed = repoScope.trim(); + if (!trimmed) throw new Error("invalid_policy_verdict_repo_scope"); + return trimmed; +} + +function normalizeDecisiveDoc(decisiveDoc: unknown): PolicyVerdictDecisiveDoc { + if (!DECISIVE_DOCS.has(decisiveDoc as string)) throw new Error("invalid_policy_verdict_decisive_doc"); + return decisiveDoc as PolicyVerdictDecisiveDoc; +} + +function normalizeEtag(etag: unknown): string { + if (typeof etag !== "string" || !etag.trim()) throw new Error("invalid_policy_verdict_etag"); + return etag; +} + +function serializeVerdict(verdict: unknown): string { + if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) { + throw new Error("invalid_policy_verdict"); + } + return JSON.stringify(verdict); +} + +/** + * Opens the 100% local/client-side miner policy-verdict cache. The database only lives on this machine; this + * module never uploads, syncs, or phones home with its contents. (#4843) + */ +export function initPolicyVerdictCacheStore(dbPath: string = resolvePolicyVerdictCacheDbPath()): PolicyVerdictCacheStore { + const resolvedPath = normalizeDbPath(dbPath); + const db = openLocalStoreDb(resolvedPath); + db.exec(` + CREATE TABLE IF NOT EXISTS policy_verdict_cache ( + repo_scope TEXT PRIMARY KEY, + decisive_doc TEXT NOT NULL, + etag TEXT NOT NULL, + verdict TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); + // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet). + applySchemaMigrations(db, []); + + const getStatement = db.prepare( + "SELECT decisive_doc, etag, verdict FROM policy_verdict_cache WHERE repo_scope = ?", + ); + const putStatement = db.prepare(` + INSERT INTO policy_verdict_cache (repo_scope, decisive_doc, etag, verdict, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(repo_scope) DO UPDATE SET + decisive_doc = excluded.decisive_doc, + etag = excluded.etag, + verdict = excluded.verdict, + updated_at = excluded.updated_at + `); + + return { + dbPath: resolvedPath, + /** The last-known `{ decisiveDoc, etag, verdict }` for a repo scope, or null when it has never been cached. */ + get(repoScope) { + const row = getStatement.get(normalizeRepoScope(repoScope)) as PolicyVerdictCacheRow | undefined; + if (!row) return null; + return { + decisiveDoc: row.decisive_doc as PolicyVerdictDecisiveDoc, + etag: row.etag, + verdict: JSON.parse(row.verdict) as AiPolicyVerdict, + }; + }, + /** Record the resolved verdict against the ETag of the doc that decided it, so the next run can reuse it. */ + put(repoScope, decisiveDoc, etag, verdict) { + const normalizedRepoScope = normalizeRepoScope(repoScope); + const normalizedDecisiveDoc = normalizeDecisiveDoc(decisiveDoc); + const normalizedEtag = normalizeEtag(etag); + const serializedVerdict = serializeVerdict(verdict); + const updatedAt = new Date().toISOString(); + putStatement.run(normalizedRepoScope, normalizedDecisiveDoc, normalizedEtag, serializedVerdict, updatedAt); + return { repoScope: normalizedRepoScope, decisiveDoc: normalizedDecisiveDoc, etag: normalizedEtag, verdict, updatedAt }; + }, + /** + * Delete every cached verdict row for one repo scope (#6987) -- the right-to-be-forgotten path + * `loopover-miner purge` invokes. Returns the number of rows removed. Reuses store-maintenance.js's + * identifier-guarded purgeStoreByRepo, exactly like the other repo-scoped stores. + */ + purgeByRepo(repoScope) { + return purgeStoreByRepo(db, POLICY_VERDICT_CACHE_PURGE_SPEC, normalizeRepoScope(repoScope)); + }, + close() { + db.close(); + }, + }; +} diff --git a/packages/loopover-miner/lib/repo-clone.d.ts b/packages/loopover-miner/lib/repo-clone.d.ts index 7b4a22dcaf..413460dee7 100644 --- a/packages/loopover-miner/lib/repo-clone.d.ts +++ b/packages/loopover-miner/lib/repo-clone.d.ts @@ -1,45 +1,58 @@ -export function resolveRepoCloneBaseDir(env?: Record): string; - -export function resolveRepoCloneDir(repoFullName: string, env?: Record): string; - -export const REPO_SEGMENT_PATTERN: RegExp; - -export function isPathTraversalSegment(segment: string): boolean; - -export function isValidRepoSegment(segment: unknown): boolean; - -export type EnsureRepoClonedResult = { ok: boolean; repoPath: string; error?: string }; - -export type RunGitFn = (args: string[], cwd: string, timeoutMs: number) => Promise<{ ok: boolean; stdout: string; stderr: string }>; - +export type EnsureRepoClonedResult = { + ok: boolean; + repoPath: string; + error?: string; +}; +export type RunGitFn = (args: string[], cwd: string, timeoutMs: number) => Promise<{ + ok: boolean; + stdout: string; + stderr: string; +}>; export type RepoCloneLockOptions = { - lockTimeoutMs?: number; - lockStaleMs?: number; - lockPollMs?: number; - nowMs?: () => number; - lockSleep?: (ms: number) => Promise; - isProcessAlive?: (pid: number) => boolean; - openLock?: (lockPath: string) => number; - writeLock?: (fd: number, data: string) => void; + lockTimeoutMs?: number; + lockStaleMs?: number; + lockPollMs?: number; + nowMs?: () => number; + lockSleep?: (ms: number) => Promise; + isProcessAlive?: (pid: number) => boolean; + openLock?: (lockPath: string) => number; + writeLock?: (fd: number, data: string) => void; }; - -export function isRepoCloneLockStale( - lockPath: string, - nowMs: number, - staleMs: number, - isAlive?: (pid: number) => boolean, -): boolean; - -export function acquireRepoCloneLock(repoPath: string, options?: RepoCloneLockOptions): Promise<() => void>; - -export function ensureRepoCloned( - repoFullName: string, - options?: { +type EnsureRepoClonedOptions = { baseBranch?: string; cloneBaseDir?: string; env?: Record; timeoutMs?: number; remoteUrl?: string; runGit?: RunGitFn; - } & RepoCloneLockOptions, -): Promise; +} & RepoCloneLockOptions; +export declare function resolveRepoCloneBaseDir(env?: Record): string; +export declare const REPO_SEGMENT_PATTERN: RegExp; +export declare function isPathTraversalSegment(segment: string): boolean; +export declare function isValidRepoSegment(segment: unknown): boolean; +export declare function resolveRepoCloneDir(repoFullName: string, env?: Record): string; +/** + * Decide whether an existing clone lockfile is stale (reclaimable): true when the file is missing or its JSON is + * unreadable/partial (a crash mid-write), when its owner pid is confirmed dead within the SAME host's namespace, + * or -- ONLY for an owner this host cannot probe (a different host/container, or a malformed record with no + * usable pid) -- when it is older than `staleMs`. A same-host owner whose pid IS probeable is judged purely by + * liveness: a live one is never stale no matter how long its clone legitimately runs (age reclaim there would + * yank the lock out from under an in-progress clone -- a double-holder bug), and a dead one is stale at once. + */ +export declare function isRepoCloneLockStale(lockPath: string, nowMs: number, staleMs: number, isAlive?: (pid: number) => boolean): boolean; +/** + * Take the cross-process clone lock for `repoPath`, returning an idempotent `release()`. Atomically create-and-holds + * `${repoPath}.clone.lock` (open .., 'wx'); on contention it reclaims a stale lock (see {@link isRepoCloneLockStale}) + * or waits `lockPollMs` between retries until `lockTimeoutMs` elapses, then throws `repo_clone_lock_timeout` (fail + * closed). Registered for crash-safe cleanup so a SIGINT/SIGTERM releases it. `nowMs`/`lockSleep`/`isProcessAlive`/ + * `openLock`/`writeLock` are injectable for tests; every real caller relies on the defaults. + */ +export declare function acquireRepoCloneLock(repoPath: string, options?: RepoCloneLockOptions): Promise<() => void>; +/** + * Serialize the git mutations of {@link ensureRepoClonedUnlocked} per resolved repo path so concurrent + * same-repo attempts never race the shared base clone (#6762), while different repos still run in parallel. + * Resolves the same `repoPath` the unlocked step computes and uses it as the mutex key; throws (before + * locking) on a malformed `repoFullName`, matching the prior behaviour. + */ +export declare function ensureRepoCloned(repoFullName: string, options?: EnsureRepoClonedOptions): Promise; +export {}; diff --git a/packages/loopover-miner/lib/repo-clone.js b/packages/loopover-miner/lib/repo-clone.js index 00e99cabe0..4121a9a156 100644 --- a/packages/loopover-miner/lib/repo-clone.js +++ b/packages/loopover-miner/lib/repo-clone.js @@ -6,7 +6,6 @@ import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { registerCleanupResource } from "./process-lifecycle.js"; import { isProcessAlive } from "./worktree-allocator.js"; - // Per-repo base-clone cache (#5132, Wave 3.5 follow-up). packages/loopover-engine/src/miner/ // worktree-allocator.ts's real `addWorktree` primitive (git worktree add -b ) // requires an EXISTING git clone to branch off -- it has never been wired into this package because that @@ -15,22 +14,20 @@ import { isProcessAlive } from "./worktree-allocator.js"; // branches off real, fresh content. Relies entirely on whatever git/gh credentials are already configured // on this machine -- same assumption execute-local-write.js's `gh pr create` already makes; this module // never embeds a token in a clone URL. - const execFileAsync = promisify(execFile); const DEFAULT_CLONE_DIR_NAME = "repos"; const DEFAULT_BASE_BRANCH = "main"; - -export function resolveRepoCloneBaseDir(env = process.env) { - const explicitPath = typeof env.LOOPOVER_MINER_REPO_CLONE_DIR === "string" ? env.LOOPOVER_MINER_REPO_CLONE_DIR.trim() : ""; - if (explicitPath) return explicitPath; - - const explicitConfigDir = typeof env.LOOPOVER_MINER_CONFIG_DIR === "string" ? env.LOOPOVER_MINER_CONFIG_DIR.trim() : ""; - if (explicitConfigDir) return join(explicitConfigDir, DEFAULT_CLONE_DIR_NAME); - - const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim() ? env.XDG_CONFIG_HOME.trim() : join(homedir(), ".config"); - return join(configHome, "loopover-miner", DEFAULT_CLONE_DIR_NAME); +export function resolveRepoCloneBaseDir(env) { + const resolvedEnv = env === undefined ? process.env : env; + const explicitPath = typeof resolvedEnv.LOOPOVER_MINER_REPO_CLONE_DIR === "string" ? resolvedEnv.LOOPOVER_MINER_REPO_CLONE_DIR.trim() : ""; + if (explicitPath) + return explicitPath; + const explicitConfigDir = typeof resolvedEnv.LOOPOVER_MINER_CONFIG_DIR === "string" ? resolvedEnv.LOOPOVER_MINER_CONFIG_DIR.trim() : ""; + if (explicitConfigDir) + return join(explicitConfigDir, DEFAULT_CLONE_DIR_NAME); + const configHome = typeof resolvedEnv.XDG_CONFIG_HOME === "string" && resolvedEnv.XDG_CONFIG_HOME.trim() ? resolvedEnv.XDG_CONFIG_HOME.trim() : join(homedir(), ".config"); + return join(configHome, "loopover-miner", DEFAULT_CLONE_DIR_NAME); } - // GitHub owner/repo names are restricted to alphanumerics, hyphens, underscores, and periods, and are never // exactly "." or ".." -- both are rejected here so a value like "../foo" can't make resolveRepoCloneDir's // join(cloneBaseDir, owner, repo) escape the intended clone directory (a real path-traversal finding). @@ -38,43 +35,41 @@ export function resolveRepoCloneBaseDir(env = process.env) { // duplicating it (cross-repo-evaluation.js) or skipping it entirely (attempt-cli.js, claim-ledger-cli.js, // event-ledger-cli.js, claim-ledger.js). export const REPO_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/; - export function isPathTraversalSegment(segment) { - return segment === "." || segment === ".."; + return segment === "." || segment === ".."; } - export function isValidRepoSegment(segment) { - return typeof segment === "string" && REPO_SEGMENT_PATTERN.test(segment) && !isPathTraversalSegment(segment); + return typeof segment === "string" && REPO_SEGMENT_PATTERN.test(segment) && !isPathTraversalSegment(segment); } - // Reject values that git would interpret as options when passed as argv (e.g. `--upload-pack=...`). function isUnsafeGitArgValue(value) { - return typeof value === "string" && value.startsWith("-"); + return typeof value === "string" && value.startsWith("-"); } - function normalizeRepoFullName(repoFullName) { - if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); - const [owner, repo, extra] = repoFullName.trim().split("/"); - if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name"); - if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) throw new Error("invalid_repo_full_name"); - return { owner, repo, repoFullName: `${owner}/${repo}` }; + if (typeof repoFullName !== "string") + throw new Error("invalid_repo_full_name"); + const [owner, repo, extra] = repoFullName.trim().split("/"); + if (!owner || !repo || extra !== undefined) + throw new Error("invalid_repo_full_name"); + if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) + throw new Error("invalid_repo_full_name"); + return { owner, repo, repoFullName: `${owner}/${repo}` }; } - -export function resolveRepoCloneDir(repoFullName, env = process.env) { - const target = normalizeRepoFullName(repoFullName); - return join(resolveRepoCloneBaseDir(env), target.owner, target.repo); +export function resolveRepoCloneDir(repoFullName, env) { + const target = normalizeRepoFullName(repoFullName); + return join(resolveRepoCloneBaseDir(env), target.owner, target.repo); } - async function defaultRunGit(args, cwd, timeoutMs) { - try { - const { stdout, stderr } = await execFileAsync("git", args, { cwd, timeout: timeoutMs }); - return { ok: true, stdout, stderr }; - } catch (error) { - const stderr = typeof error?.stderr === "string" ? error.stderr : ""; - return { ok: false, stdout: "", stderr: stderr || (error instanceof Error ? error.message : String(error)) }; - } + try { + const { stdout, stderr } = await execFileAsync("git", args, { cwd, timeout: timeoutMs }); + return { ok: true, stdout: stdout, stderr: stderr }; + } + catch (error) { + const err = error; + const stderr = typeof err?.stderr === "string" ? err.stderr : ""; + return { ok: false, stdout: "", stderr: stderr || (error instanceof Error ? error.message : String(error)) }; + } } - // Per-repoPath in-process serialization for ensureRepoCloned (#6762). Two attempts for the SAME repo share // one deterministic base-clone path and mutate it in place (git fetch/checkout/reset --hard); worktree- // allocator.js only caps the TOTAL active-slot count, never per-repo exclusivity, so without this two @@ -84,28 +79,20 @@ async function defaultRunGit(args, cwd, timeoutMs) { // promise's handlers swallow, so it never rejects -- one failing attempt can neither reject a waiter nor // wedge the queue -- and the finally drops the entry once the chain drains, keeping the Map bounded. const repoCloneLocks = new Map(); - -/** - * @template T - * @param {string} repoPath key: the resolved base-clone path the git mutations run against. - * @param {() => Promise} fn the critical section (a single ensureRepoClonedUnlocked run). - * @returns {Promise} - */ +/** Run `fn` under the in-process per-`repoPath` mutex (critical section = one ensureRepoClonedUnlocked). */ async function withRepoCloneLock(repoPath, fn) { - const previous = repoCloneLocks.get(repoPath) ?? Promise.resolve(); - const run = previous.then(() => fn()); - const tail = run.then( - () => {}, - () => {}, - ); - repoCloneLocks.set(repoPath, tail); - try { - return await run; - } finally { - if (repoCloneLocks.get(repoPath) === tail) repoCloneLocks.delete(repoPath); - } + const previous = repoCloneLocks.get(repoPath) ?? Promise.resolve(); + const run = previous.then(() => fn()); + const tail = run.then(() => { }, () => { }); + repoCloneLocks.set(repoPath, tail); + try { + return await run; + } + finally { + if (repoCloneLocks.get(repoPath) === tail) + repoCloneLocks.delete(repoPath); + } } - // Cross-process serialization for ensureRepoCloned (#7084). The in-process `repoCloneLocks` Map above only // serializes callers sharing one Node event loop; fleet mode (DEPLOYMENT.md) runs multiple SEPARATE processes -- // distinct containers, no shared memory -- against one bind-mounted clone volume, so two of them can still @@ -115,16 +102,13 @@ async function withRepoCloneLock(repoPath, fn) { // records owner pid+host+timestamp so a holder that CRASHES mid-clone doesn't wedge the repo forever -- a same-host // dead-owner or an over-age lock is reclaimed (mirroring worktree-allocator.js's stale reclaim), and // registerCleanupResource unlinks it on SIGINT/SIGTERM like this package's other crash-safe resources (#4826). - const DEFAULT_LOCK_TIMEOUT_MS = 10 * 60 * 1000; // comfortably past a slow clone/fetch sequence const DEFAULT_LOCK_STALE_MS = 15 * 60 * 1000; // a lock older than this is presumed crashed const DEFAULT_LOCK_POLL_MS = 100; const defaultLockSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - function repoCloneLockPath(repoPath) { - return `${repoPath}.clone.lock`; + return `${repoPath}.clone.lock`; } - /** * Decide whether an existing clone lockfile is stale (reclaimable): true when the file is missing or its JSON is * unreadable/partial (a crash mid-write), when its owner pid is confirmed dead within the SAME host's namespace, @@ -132,191 +116,174 @@ function repoCloneLockPath(repoPath) { * usable pid) -- when it is older than `staleMs`. A same-host owner whose pid IS probeable is judged purely by * liveness: a live one is never stale no matter how long its clone legitimately runs (age reclaim there would * yank the lock out from under an in-progress clone -- a double-holder bug), and a dead one is stale at once. - * - * @param {string} lockPath - * @param {number} nowMs - * @param {number} staleMs - * @param {(pid: number) => boolean} [isAlive] */ export function isRepoCloneLockStale(lockPath, nowMs, staleMs, isAlive = isProcessAlive) { - let meta; - try { - meta = JSON.parse(readFileSync(lockPath, "utf8")); - } catch { - return true; - } - if (!meta || typeof meta !== "object") return true; - // Owner we can directly probe (same host, usable pid): trust liveness exclusively -- alive => held (never - // age-reclaim a still-running local clone), dead => reclaim now. The age backstop below is reserved for an - // owner whose liveness is genuinely unknowable from here. - if (meta.host === hostname() && Number.isInteger(meta.pid)) { - return !isAlive(meta.pid); - } - const atMs = Date.parse(meta.at); - if (!Number.isFinite(atMs)) return true; - return nowMs - atMs > staleMs; + let meta; + try { + meta = JSON.parse(readFileSync(lockPath, "utf8")); + } + catch { + return true; + } + if (!meta || typeof meta !== "object") + return true; + const record = meta; + // Owner we can directly probe (same host, usable pid): trust liveness exclusively -- alive => held (never + // age-reclaim a still-running local clone), dead => reclaim now. The age backstop below is reserved for an + // owner whose liveness is genuinely unknowable from here. + if (record.host === hostname() && Number.isInteger(record.pid)) { + return !isAlive(record.pid); + } + const atMs = Date.parse(record.at); + if (!Number.isFinite(atMs)) + return true; + return nowMs - atMs > staleMs; } - /** * Take the cross-process clone lock for `repoPath`, returning an idempotent `release()`. Atomically create-and-holds * `${repoPath}.clone.lock` (open .., 'wx'); on contention it reclaims a stale lock (see {@link isRepoCloneLockStale}) * or waits `lockPollMs` between retries until `lockTimeoutMs` elapses, then throws `repo_clone_lock_timeout` (fail * closed). Registered for crash-safe cleanup so a SIGINT/SIGTERM releases it. `nowMs`/`lockSleep`/`isProcessAlive`/ * `openLock`/`writeLock` are injectable for tests; every real caller relies on the defaults. - * - * @param {string} repoPath - * @param {{ lockTimeoutMs?: number, lockStaleMs?: number, lockPollMs?: number, nowMs?: () => number, - * lockSleep?: (ms: number) => Promise, isProcessAlive?: (pid: number) => boolean, - * openLock?: (lockPath: string) => number, writeLock?: (fd: number, data: string) => void }} [options] - * @returns {Promise<() => void>} */ export async function acquireRepoCloneLock(repoPath, options = {}) { - const lockPath = repoCloneLockPath(repoPath); - const timeoutMs = Number.isFinite(options.lockTimeoutMs) ? options.lockTimeoutMs : DEFAULT_LOCK_TIMEOUT_MS; - const staleMs = Number.isFinite(options.lockStaleMs) ? options.lockStaleMs : DEFAULT_LOCK_STALE_MS; - const pollMs = Number.isFinite(options.lockPollMs) ? options.lockPollMs : DEFAULT_LOCK_POLL_MS; - const now = typeof options.nowMs === "function" ? options.nowMs : Date.now; - const sleep = typeof options.lockSleep === "function" ? options.lockSleep : defaultLockSleep; - const isAlive = typeof options.isProcessAlive === "function" ? options.isProcessAlive : isProcessAlive; - const openLock = typeof options.openLock === "function" ? options.openLock : (path) => openSync(path, "wx", 0o600); - const writeLock = typeof options.writeLock === "function" ? options.writeLock : (fd, data) => writeSync(fd, data); - - mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 }); - const deadline = now() + timeoutMs; - for (;;) { - let fd; - try { - fd = openLock(lockPath); - } catch (error) { - if (!error || error.code !== "EEXIST") throw error; - if (isRepoCloneLockStale(lockPath, now(), staleMs, isAlive)) { + const lockPath = repoCloneLockPath(repoPath); + const timeoutMs = Number.isFinite(options.lockTimeoutMs) ? options.lockTimeoutMs : DEFAULT_LOCK_TIMEOUT_MS; + const staleMs = Number.isFinite(options.lockStaleMs) ? options.lockStaleMs : DEFAULT_LOCK_STALE_MS; + const pollMs = Number.isFinite(options.lockPollMs) ? options.lockPollMs : DEFAULT_LOCK_POLL_MS; + const now = typeof options.nowMs === "function" ? options.nowMs : Date.now; + const sleep = typeof options.lockSleep === "function" ? options.lockSleep : defaultLockSleep; + const isAlive = typeof options.isProcessAlive === "function" ? options.isProcessAlive : isProcessAlive; + const openLock = typeof options.openLock === "function" ? options.openLock : (path) => openSync(path, "wx", 0o600); + const writeLock = typeof options.writeLock === "function" ? options.writeLock : (fd, data) => writeSync(fd, data); + mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 }); + const deadline = now() + timeoutMs; + for (;;) { + let fd; try { - unlinkSync(lockPath); - } catch { - // Another waiter reclaimed it first -- just retry the open. + fd = openLock(lockPath); } - continue; - } - if (now() >= deadline) throw new Error("repo_clone_lock_timeout"); - await sleep(pollMs); - continue; - } - // A per-acquire token stamps THIS holder's ownership so release() can prove the on-disk lock is still ours - // before removing it -- if a peer reclaimed us as stale and re-acquired, the file now carries their token and - // we must not delete their lock (that would let a third caller double-hold). - const token = randomUUID(); - try { - writeLock(fd, JSON.stringify({ pid: process.pid, host: hostname(), at: new Date(now()).toISOString(), token })); - } catch (error) { - closeSync(fd); - try { - unlinkSync(lockPath); - } catch { - // best-effort cleanup of our own just-created lock - } - throw error; + catch (error) { + const err = error; + if (!err || err.code !== "EEXIST") + throw error; + if (isRepoCloneLockStale(lockPath, now(), staleMs, isAlive)) { + try { + unlinkSync(lockPath); + } + catch { + // Another waiter reclaimed it first -- just retry the open. + } + continue; + } + if (now() >= deadline) + throw new Error("repo_clone_lock_timeout"); + await sleep(pollMs); + continue; + } + // A per-acquire token stamps THIS holder's ownership so release() can prove the on-disk lock is still ours + // before removing it -- if a peer reclaimed us as stale and re-acquired, the file now carries their token and + // we must not delete their lock (that would let a third caller double-hold). + const token = randomUUID(); + try { + writeLock(fd, JSON.stringify({ pid: process.pid, host: hostname(), at: new Date(now()).toISOString(), token })); + } + catch (error) { + closeSync(fd); + try { + unlinkSync(lockPath); + } + catch { + // best-effort cleanup of our own just-created lock + } + throw error; + } + let released = false; + let unregister = () => { }; + const release = () => { + if (released) + return; + released = true; + unregister(); + try { + closeSync(fd); + } + catch { + // fd already closed + } + try { + // Only remove the lockfile while it still carries OUR token; if a peer reclaimed + re-acquired it, leave + // their lock intact. A missing/unreadable file just means our lock is already gone -- nothing to do. + const current = JSON.parse(readFileSync(lockPath, "utf8")); + if (current && current.token === token) + unlinkSync(lockPath); + } + catch { + // lock already removed or unreadable -- nothing of ours to clean up + } + }; + unregister = registerCleanupResource(release); + return release; } - let released = false; - let unregister = () => {}; - const release = () => { - if (released) return; - released = true; - unregister(); - try { - closeSync(fd); - } catch { - // fd already closed - } - try { - // Only remove the lockfile while it still carries OUR token; if a peer reclaimed + re-acquired it, leave - // their lock intact. A missing/unreadable file just means our lock is already gone -- nothing to do. - const current = JSON.parse(readFileSync(lockPath, "utf8")); - if (current && current.token === token) unlinkSync(lockPath); - } catch { - // lock already removed or unreadable -- nothing of ours to clean up - } - }; - unregister = registerCleanupResource(release); - return release; - } } - async function withRepoCloneCrossProcessLock(repoPath, options, fn) { - const release = await acquireRepoCloneLock(repoPath, options); - try { - return await fn(); - } finally { - release(); - } + const release = await acquireRepoCloneLock(repoPath, options); + try { + return await fn(); + } + finally { + release(); + } } - /** * Serialize the git mutations of {@link ensureRepoClonedUnlocked} per resolved repo path so concurrent * same-repo attempts never race the shared base clone (#6762), while different repos still run in parallel. * Resolves the same `repoPath` the unlocked step computes and uses it as the mutex key; throws (before * locking) on a malformed `repoFullName`, matching the prior behaviour. - * - * @param {string} repoFullName - * @param {{ - * baseBranch?: string, cloneBaseDir?: string, env?: Record, timeoutMs?: number, - * remoteUrl?: string, runGit?: (args: string[], cwd: string, timeoutMs: number) => Promise<{ ok: boolean, stdout: string, stderr: string }>, - * }} [options] - * @returns {Promise<{ ok: boolean, repoPath: string, error?: string }>} */ export async function ensureRepoCloned(repoFullName, options = {}) { - const target = normalizeRepoFullName(repoFullName); - const cloneBaseDir = typeof options.cloneBaseDir === "string" && options.cloneBaseDir.trim() ? options.cloneBaseDir.trim() : resolveRepoCloneBaseDir(options.env); - const repoPath = join(cloneBaseDir, target.owner, target.repo); - // Two nested locks: the in-process Map (#6762) keeps same-process callers cheap and ordered, and the - // cross-process lockfile (#7084) additionally serializes separate OS processes sharing the clone volume. - return withRepoCloneLock(repoPath, () => - withRepoCloneCrossProcessLock(repoPath, options, () => ensureRepoClonedUnlocked(repoFullName, options)), - ); + const target = normalizeRepoFullName(repoFullName); + const cloneBaseDir = typeof options.cloneBaseDir === "string" && options.cloneBaseDir.trim() ? options.cloneBaseDir.trim() : resolveRepoCloneBaseDir(options.env); + const repoPath = join(cloneBaseDir, target.owner, target.repo); + // Two nested locks: the in-process Map (#6762) keeps same-process callers cheap and ordered, and the + // cross-process lockfile (#7084) additionally serializes separate OS processes sharing the clone volume. + return withRepoCloneLock(repoPath, () => withRepoCloneCrossProcessLock(repoPath, options, () => ensureRepoClonedUnlocked(repoFullName, options))); } - /** * Ensure a real, current local clone of `repoFullName` exists at the deterministic per-repo cache path. * First use: `git clone`. Subsequent use: `git fetch origin` + hard-reset the base branch to * `origin/`, so every attempt branches off fresh content, not a stale prior checkout. - * - * @param {string} repoFullName - * @param {{ - * baseBranch?: string, cloneBaseDir?: string, env?: Record, timeoutMs?: number, - * remoteUrl?: string, runGit?: (args: string[], cwd: string, timeoutMs: number) => Promise<{ ok: boolean, stdout: string, stderr: string }>, - * }} [options] - * @returns {Promise<{ ok: boolean, repoPath: string, error?: string }>} */ async function ensureRepoClonedUnlocked(repoFullName, options = {}) { - const target = normalizeRepoFullName(repoFullName); - const baseBranch = typeof options.baseBranch === "string" && options.baseBranch.trim() ? options.baseBranch.trim() : DEFAULT_BASE_BRANCH; - const cloneBaseDir = typeof options.cloneBaseDir === "string" && options.cloneBaseDir.trim() ? options.cloneBaseDir.trim() : resolveRepoCloneBaseDir(options.env); - const repoPath = join(cloneBaseDir, target.owner, target.repo); - const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 120_000; - const runGit = options.runGit ?? defaultRunGit; - - if (isUnsafeGitArgValue(baseBranch)) { - return { ok: false, repoPath, error: "invalid_base_branch" }; - } - - if (!existsSync(repoPath)) { - mkdirSync(join(cloneBaseDir, target.owner), { recursive: true, mode: 0o700 }); - const cloneUrl = typeof options.remoteUrl === "string" && options.remoteUrl.trim() ? options.remoteUrl.trim() : `https://github.com/${target.owner}/${target.repo}.git`; - if (isUnsafeGitArgValue(cloneUrl)) { - return { ok: false, repoPath, error: "invalid_remote_url" }; + const target = normalizeRepoFullName(repoFullName); + const baseBranch = typeof options.baseBranch === "string" && options.baseBranch.trim() ? options.baseBranch.trim() : DEFAULT_BASE_BRANCH; + const cloneBaseDir = typeof options.cloneBaseDir === "string" && options.cloneBaseDir.trim() ? options.cloneBaseDir.trim() : resolveRepoCloneBaseDir(options.env); + const repoPath = join(cloneBaseDir, target.owner, target.repo); + const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 120_000; + const runGit = options.runGit ?? defaultRunGit; + if (isUnsafeGitArgValue(baseBranch)) { + return { ok: false, repoPath, error: "invalid_base_branch" }; + } + if (!existsSync(repoPath)) { + mkdirSync(join(cloneBaseDir, target.owner), { recursive: true, mode: 0o700 }); + const cloneUrl = typeof options.remoteUrl === "string" && options.remoteUrl.trim() ? options.remoteUrl.trim() : `https://github.com/${target.owner}/${target.repo}.git`; + if (isUnsafeGitArgValue(cloneUrl)) { + return { ok: false, repoPath, error: "invalid_remote_url" }; + } + const cloned = await runGit(["clone", cloneUrl, repoPath], cloneBaseDir, timeoutMs); + if (!cloned.ok) + return { ok: false, repoPath, error: cloned.stderr || "git_clone_failed" }; + return { ok: true, repoPath }; } - const cloned = await runGit(["clone", cloneUrl, repoPath], cloneBaseDir, timeoutMs); - if (!cloned.ok) return { ok: false, repoPath, error: cloned.stderr || "git_clone_failed" }; + const fetched = await runGit(["fetch", "origin"], repoPath, timeoutMs); + if (!fetched.ok) + return { ok: false, repoPath, error: fetched.stderr || "git_fetch_failed" }; + const checkedOut = await runGit(["checkout", baseBranch], repoPath, timeoutMs); + if (!checkedOut.ok) + return { ok: false, repoPath, error: checkedOut.stderr || "git_checkout_failed" }; + const reset = await runGit(["reset", "--hard", `origin/${baseBranch}`], repoPath, timeoutMs); + if (!reset.ok) + return { ok: false, repoPath, error: reset.stderr || "git_reset_failed" }; return { ok: true, repoPath }; - } - - const fetched = await runGit(["fetch", "origin"], repoPath, timeoutMs); - if (!fetched.ok) return { ok: false, repoPath, error: fetched.stderr || "git_fetch_failed" }; - - const checkedOut = await runGit(["checkout", baseBranch], repoPath, timeoutMs); - if (!checkedOut.ok) return { ok: false, repoPath, error: checkedOut.stderr || "git_checkout_failed" }; - - const reset = await runGit(["reset", "--hard", `origin/${baseBranch}`], repoPath, timeoutMs); - if (!reset.ok) return { ok: false, repoPath, error: reset.stderr || "git_reset_failed" }; - - return { ok: true, repoPath }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmVwby1jbG9uZS5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbInJlcG8tY2xvbmUudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLFFBQVEsRUFBRSxNQUFNLG9CQUFvQixDQUFDO0FBQzlDLE9BQU8sRUFBRSxVQUFVLEVBQUUsTUFBTSxhQUFhLENBQUM7QUFDekMsT0FBTyxFQUFFLFNBQVMsRUFBRSxVQUFVLEVBQUUsU0FBUyxFQUFFLFFBQVEsRUFBRSxZQUFZLEVBQUUsVUFBVSxFQUFFLFNBQVMsRUFBRSxNQUFNLFNBQVMsQ0FBQztBQUMxRyxPQUFPLEVBQUUsT0FBTyxFQUFFLFFBQVEsRUFBRSxNQUFNLFNBQVMsQ0FBQztBQUM1QyxPQUFPLEVBQUUsT0FBTyxFQUFFLElBQUksRUFBRSxNQUFNLFdBQVcsQ0FBQztBQUMxQyxPQUFPLEVBQUUsU0FBUyxFQUFFLE1BQU0sV0FBVyxDQUFDO0FBQ3RDLE9BQU8sRUFBRSx1QkFBdUIsRUFBRSxNQUFNLHdCQUF3QixDQUFDO0FBQ2pFLE9BQU8sRUFBRSxjQUFjLEVBQUUsTUFBTSx5QkFBeUIsQ0FBQztBQUV6RCw2RkFBNkY7QUFDN0YsMEdBQTBHO0FBQzFHLHlHQUF5RztBQUN6RywyR0FBMkc7QUFDM0csdUdBQXVHO0FBQ3ZHLDBHQUEwRztBQUMxRyx3R0FBd0c7QUFDeEcsdUNBQXVDO0FBRXZDLE1BQU0sYUFBYSxHQUFHLFNBQVMsQ0FBQyxRQUFRLENBQUMsQ0FBQztBQUMxQyxNQUFNLHNCQUFzQixHQUFHLE9BQU8sQ0FBQztBQUN2QyxNQUFNLG1CQUFtQixHQUFHLE1BQU0sQ0FBQztBQWlDbkMsTUFBTSxVQUFVLHVCQUF1QixDQUFDLEdBQXdDO0lBQzlFLE1BQU0sV0FBVyxHQUFHLEdBQUcsS0FBSyxTQUFTLENBQUMsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxHQUFHLENBQUMsQ0FBQyxDQUFDLEdBQUcsQ0FBQztJQUMxRCxNQUFNLFlBQVksR0FBRyxPQUFPLFdBQVcsQ0FBQyw2QkFB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LEVBQUUsVUFBVSxDQUFDLEVBQUUsUUFBUSxFQUFFLFNBQVMsQ0FBQyxDQUFDO0lBQy9FLElBQUksQ0FBQyxVQUFVLENBQUMsRUFBRTtRQUFFLE9BQU8sRUFBRSxFQUFFLEVBQUUsS0FBSyxFQUFFLFFBQVEsRUFBRSxLQUFLLEVBQUUsVUFBVSxDQUFDLE1BQU0sSUFBSSxxQkFBcUIsRUFBRSxDQUFDO0lBRXRHLE1BQU0sS0FBSyxHQUFHLE1BQU0sTUFBTSxDQUFDLENBQUMsT0FBTyxFQUFFLFFBQVEsRUFBRSxVQUFVLFVBQVUsRUFBRSxDQUFDLEVBQUUsUUFBUSxFQUFFLFNBQVMsQ0FBQyxDQUFDO0lBQzdGLElBQUksQ0FBQyxLQUFLLENBQUMsRUFBRTtRQUFFLE9BQU8sRUFBRSxFQUFFLEVBQUUsS0FBSyxFQUFFLFFBQVEsRUFBRSxLQUFLLEVBQUUsS0FBSyxDQUFDLE1BQU0sSUFBSSxrQkFBa0IsRUFBRSxDQUFDO0lBRXpGLE9BQU8sRUFBRSxFQUFFLEVBQUUsSUFBSSxFQUFFLFFBQVEsRUFBRSxDQUFDO0FBQ2hDLENBQUMifQ== \ No newline at end of file diff --git a/packages/loopover-miner/lib/repo-clone.ts b/packages/loopover-miner/lib/repo-clone.ts new file mode 100644 index 0000000000..64d28fbb56 --- /dev/null +++ b/packages/loopover-miner/lib/repo-clone.ts @@ -0,0 +1,342 @@ +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from "node:fs"; +import { homedir, hostname } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { registerCleanupResource } from "./process-lifecycle.js"; +import { isProcessAlive } from "./worktree-allocator.js"; + +// Per-repo base-clone cache (#5132, Wave 3.5 follow-up). packages/loopover-engine/src/miner/ +// worktree-allocator.ts's real `addWorktree` primitive (git worktree add -b ) +// requires an EXISTING git clone to branch off -- it has never been wired into this package because that +// clone-management step didn't exist yet. This module is that step: clone a target repo once, then keep it +// current (fetch + hard-reset to the base branch) on every subsequent attempt, so `addWorktree` always +// branches off real, fresh content. Relies entirely on whatever git/gh credentials are already configured +// on this machine -- same assumption execute-local-write.js's `gh pr create` already makes; this module +// never embeds a token in a clone URL. + +const execFileAsync = promisify(execFile); +const DEFAULT_CLONE_DIR_NAME = "repos"; +const DEFAULT_BASE_BRANCH = "main"; + +export type EnsureRepoClonedResult = { ok: boolean; repoPath: string; error?: string }; + +export type RunGitFn = (args: string[], cwd: string, timeoutMs: number) => Promise<{ ok: boolean; stdout: string; stderr: string }>; + +export type RepoCloneLockOptions = { + lockTimeoutMs?: number; + lockStaleMs?: number; + lockPollMs?: number; + nowMs?: () => number; + lockSleep?: (ms: number) => Promise; + isProcessAlive?: (pid: number) => boolean; + openLock?: (lockPath: string) => number; + writeLock?: (fd: number, data: string) => void; +}; + +type EnsureRepoClonedOptions = { + baseBranch?: string; + cloneBaseDir?: string; + env?: Record; + timeoutMs?: number; + remoteUrl?: string; + runGit?: RunGitFn; +} & RepoCloneLockOptions; + +type RepoCloneLockMeta = { + host?: unknown; + pid?: unknown; + at?: unknown; + token?: unknown; +}; + +export function resolveRepoCloneBaseDir(env?: Record): string { + const resolvedEnv = env === undefined ? process.env : env; + const explicitPath = typeof resolvedEnv.LOOPOVER_MINER_REPO_CLONE_DIR === "string" ? resolvedEnv.LOOPOVER_MINER_REPO_CLONE_DIR.trim() : ""; + if (explicitPath) return explicitPath; + + const explicitConfigDir = typeof resolvedEnv.LOOPOVER_MINER_CONFIG_DIR === "string" ? resolvedEnv.LOOPOVER_MINER_CONFIG_DIR.trim() : ""; + if (explicitConfigDir) return join(explicitConfigDir, DEFAULT_CLONE_DIR_NAME); + + const configHome = typeof resolvedEnv.XDG_CONFIG_HOME === "string" && resolvedEnv.XDG_CONFIG_HOME.trim() ? resolvedEnv.XDG_CONFIG_HOME.trim() : join(homedir(), ".config"); + return join(configHome, "loopover-miner", DEFAULT_CLONE_DIR_NAME); +} + +// GitHub owner/repo names are restricted to alphanumerics, hyphens, underscores, and periods, and are never +// exactly "." or ".." -- both are rejected here so a value like "../foo" can't make resolveRepoCloneDir's +// join(cloneBaseDir, owner, repo) escape the intended clone directory (a real path-traversal finding). +// Exported so every other owner/repo parser in this package (#5831) shares this one definition instead of +// duplicating it (cross-repo-evaluation.js) or skipping it entirely (attempt-cli.js, claim-ledger-cli.js, +// event-ledger-cli.js, claim-ledger.js). +export const REPO_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/; + +export function isPathTraversalSegment(segment: string): boolean { + return segment === "." || segment === ".."; +} + +export function isValidRepoSegment(segment: unknown): boolean { + return typeof segment === "string" && REPO_SEGMENT_PATTERN.test(segment) && !isPathTraversalSegment(segment); +} + +// Reject values that git would interpret as options when passed as argv (e.g. `--upload-pack=...`). +function isUnsafeGitArgValue(value: unknown): boolean { + return typeof value === "string" && value.startsWith("-"); +} + +function normalizeRepoFullName(repoFullName: unknown): { owner: string; repo: string; repoFullName: string } { + if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); + const [owner, repo, extra] = repoFullName.trim().split("/"); + if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name"); + if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) throw new Error("invalid_repo_full_name"); + return { owner, repo, repoFullName: `${owner}/${repo}` }; +} + +export function resolveRepoCloneDir(repoFullName: string, env?: Record): string { + const target = normalizeRepoFullName(repoFullName); + return join(resolveRepoCloneBaseDir(env), target.owner, target.repo); +} + +async function defaultRunGit(args: string[], cwd: string, timeoutMs: number): Promise<{ ok: boolean; stdout: string; stderr: string }> { + try { + const { stdout, stderr } = await execFileAsync("git", args, { cwd, timeout: timeoutMs }); + return { ok: true, stdout: stdout as string, stderr: stderr as string }; + } catch (error: unknown) { + const err = error as { stderr?: unknown } | null | undefined; + const stderr = typeof err?.stderr === "string" ? err.stderr : ""; + return { ok: false, stdout: "", stderr: stderr || (error instanceof Error ? error.message : String(error)) }; + } +} + +// Per-repoPath in-process serialization for ensureRepoCloned (#6762). Two attempts for the SAME repo share +// one deterministic base-clone path and mutate it in place (git fetch/checkout/reset --hard); worktree- +// allocator.js only caps the TOTAL active-slot count, never per-repo exclusivity, so without this two +// same-repo attempts can interleave git subprocesses on the same .git dir and corrupt the index/HEAD/refs or +// trip .git/index.lock. `repoCloneLocks` maps a resolved repoPath to the tail of its in-flight promise chain: +// same-repo calls run strictly one after another, while different repoPaths stay fully parallel. The tail +// promise's handlers swallow, so it never rejects -- one failing attempt can neither reject a waiter nor +// wedge the queue -- and the finally drops the entry once the chain drains, keeping the Map bounded. +const repoCloneLocks = new Map>(); + +/** Run `fn` under the in-process per-`repoPath` mutex (critical section = one ensureRepoClonedUnlocked). */ +async function withRepoCloneLock(repoPath: string, fn: () => Promise): Promise { + const previous = repoCloneLocks.get(repoPath) ?? Promise.resolve(); + const run = previous.then(() => fn()); + const tail = run.then( + () => {}, + () => {}, + ); + repoCloneLocks.set(repoPath, tail); + try { + return await run; + } finally { + if (repoCloneLocks.get(repoPath) === tail) repoCloneLocks.delete(repoPath); + } +} + +// Cross-process serialization for ensureRepoCloned (#7084). The in-process `repoCloneLocks` Map above only +// serializes callers sharing one Node event loop; fleet mode (DEPLOYMENT.md) runs multiple SEPARATE processes -- +// distinct containers, no shared memory -- against one bind-mounted clone volume, so two of them can still +// interleave git subprocesses on the same .git dir and corrupt the index/HEAD/refs. An OS-level exclusive lockfile +// (open(.., 'wx')) on a deterministic path derived from repoPath closes that gap: create-and-hold is atomic across +// processes, so exactly one holder mutates the clone while a loser waits (bounded) or fails closed. The lock +// records owner pid+host+timestamp so a holder that CRASHES mid-clone doesn't wedge the repo forever -- a same-host +// dead-owner or an over-age lock is reclaimed (mirroring worktree-allocator.js's stale reclaim), and +// registerCleanupResource unlinks it on SIGINT/SIGTERM like this package's other crash-safe resources (#4826). + +const DEFAULT_LOCK_TIMEOUT_MS = 10 * 60 * 1000; // comfortably past a slow clone/fetch sequence +const DEFAULT_LOCK_STALE_MS = 15 * 60 * 1000; // a lock older than this is presumed crashed +const DEFAULT_LOCK_POLL_MS = 100; +const defaultLockSleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +function repoCloneLockPath(repoPath: string): string { + return `${repoPath}.clone.lock`; +} + +/** + * Decide whether an existing clone lockfile is stale (reclaimable): true when the file is missing or its JSON is + * unreadable/partial (a crash mid-write), when its owner pid is confirmed dead within the SAME host's namespace, + * or -- ONLY for an owner this host cannot probe (a different host/container, or a malformed record with no + * usable pid) -- when it is older than `staleMs`. A same-host owner whose pid IS probeable is judged purely by + * liveness: a live one is never stale no matter how long its clone legitimately runs (age reclaim there would + * yank the lock out from under an in-progress clone -- a double-holder bug), and a dead one is stale at once. + */ +export function isRepoCloneLockStale( + lockPath: string, + nowMs: number, + staleMs: number, + isAlive: (pid: number) => boolean = isProcessAlive, +): boolean { + let meta: unknown; + try { + meta = JSON.parse(readFileSync(lockPath, "utf8")); + } catch { + return true; + } + if (!meta || typeof meta !== "object") return true; + const record = meta as RepoCloneLockMeta; + // Owner we can directly probe (same host, usable pid): trust liveness exclusively -- alive => held (never + // age-reclaim a still-running local clone), dead => reclaim now. The age backstop below is reserved for an + // owner whose liveness is genuinely unknowable from here. + if (record.host === hostname() && Number.isInteger(record.pid)) { + return !isAlive(record.pid as number); + } + const atMs = Date.parse(record.at as string); + if (!Number.isFinite(atMs)) return true; + return nowMs - atMs > staleMs; +} + +/** + * Take the cross-process clone lock for `repoPath`, returning an idempotent `release()`. Atomically create-and-holds + * `${repoPath}.clone.lock` (open .., 'wx'); on contention it reclaims a stale lock (see {@link isRepoCloneLockStale}) + * or waits `lockPollMs` between retries until `lockTimeoutMs` elapses, then throws `repo_clone_lock_timeout` (fail + * closed). Registered for crash-safe cleanup so a SIGINT/SIGTERM releases it. `nowMs`/`lockSleep`/`isProcessAlive`/ + * `openLock`/`writeLock` are injectable for tests; every real caller relies on the defaults. + */ +export async function acquireRepoCloneLock(repoPath: string, options: RepoCloneLockOptions = {}): Promise<() => void> { + const lockPath = repoCloneLockPath(repoPath); + const timeoutMs = Number.isFinite(options.lockTimeoutMs) ? options.lockTimeoutMs as number : DEFAULT_LOCK_TIMEOUT_MS; + const staleMs = Number.isFinite(options.lockStaleMs) ? options.lockStaleMs as number : DEFAULT_LOCK_STALE_MS; + const pollMs = Number.isFinite(options.lockPollMs) ? options.lockPollMs as number : DEFAULT_LOCK_POLL_MS; + const now = typeof options.nowMs === "function" ? options.nowMs : Date.now; + const sleep = typeof options.lockSleep === "function" ? options.lockSleep : defaultLockSleep; + const isAlive = typeof options.isProcessAlive === "function" ? options.isProcessAlive : isProcessAlive; + const openLock = typeof options.openLock === "function" ? options.openLock : (path: string) => openSync(path, "wx", 0o600); + const writeLock = typeof options.writeLock === "function" ? options.writeLock : (fd: number, data: string) => writeSync(fd, data); + + mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 }); + const deadline = now() + timeoutMs; + for (;;) { + let fd: number; + try { + fd = openLock(lockPath); + } catch (error: unknown) { + const err = error as NodeJS.ErrnoException | null | undefined; + if (!err || err.code !== "EEXIST") throw error; + if (isRepoCloneLockStale(lockPath, now(), staleMs, isAlive)) { + try { + unlinkSync(lockPath); + } catch { + // Another waiter reclaimed it first -- just retry the open. + } + continue; + } + if (now() >= deadline) throw new Error("repo_clone_lock_timeout"); + await sleep(pollMs); + continue; + } + // A per-acquire token stamps THIS holder's ownership so release() can prove the on-disk lock is still ours + // before removing it -- if a peer reclaimed us as stale and re-acquired, the file now carries their token and + // we must not delete their lock (that would let a third caller double-hold). + const token = randomUUID(); + try { + writeLock(fd, JSON.stringify({ pid: process.pid, host: hostname(), at: new Date(now()).toISOString(), token })); + } catch (error) { + closeSync(fd); + try { + unlinkSync(lockPath); + } catch { + // best-effort cleanup of our own just-created lock + } + throw error; + } + let released = false; + let unregister = (): void => {}; + const release = (): void => { + if (released) return; + released = true; + unregister(); + try { + closeSync(fd); + } catch { + // fd already closed + } + try { + // Only remove the lockfile while it still carries OUR token; if a peer reclaimed + re-acquired it, leave + // their lock intact. A missing/unreadable file just means our lock is already gone -- nothing to do. + const current = JSON.parse(readFileSync(lockPath, "utf8")) as RepoCloneLockMeta; + if (current && current.token === token) unlinkSync(lockPath); + } catch { + // lock already removed or unreadable -- nothing of ours to clean up + } + }; + unregister = registerCleanupResource(release); + return release; + } +} + +async function withRepoCloneCrossProcessLock( + repoPath: string, + options: RepoCloneLockOptions, + fn: () => Promise, +): Promise { + const release = await acquireRepoCloneLock(repoPath, options); + try { + return await fn(); + } finally { + release(); + } +} + +/** + * Serialize the git mutations of {@link ensureRepoClonedUnlocked} per resolved repo path so concurrent + * same-repo attempts never race the shared base clone (#6762), while different repos still run in parallel. + * Resolves the same `repoPath` the unlocked step computes and uses it as the mutex key; throws (before + * locking) on a malformed `repoFullName`, matching the prior behaviour. + */ +export async function ensureRepoCloned( + repoFullName: string, + options: EnsureRepoClonedOptions = {}, +): Promise { + const target = normalizeRepoFullName(repoFullName); + const cloneBaseDir = typeof options.cloneBaseDir === "string" && options.cloneBaseDir.trim() ? options.cloneBaseDir.trim() : resolveRepoCloneBaseDir(options.env); + const repoPath = join(cloneBaseDir, target.owner, target.repo); + // Two nested locks: the in-process Map (#6762) keeps same-process callers cheap and ordered, and the + // cross-process lockfile (#7084) additionally serializes separate OS processes sharing the clone volume. + return withRepoCloneLock(repoPath, () => + withRepoCloneCrossProcessLock(repoPath, options, () => ensureRepoClonedUnlocked(repoFullName, options)), + ); +} + +/** + * Ensure a real, current local clone of `repoFullName` exists at the deterministic per-repo cache path. + * First use: `git clone`. Subsequent use: `git fetch origin` + hard-reset the base branch to + * `origin/`, so every attempt branches off fresh content, not a stale prior checkout. + */ +async function ensureRepoClonedUnlocked( + repoFullName: string, + options: EnsureRepoClonedOptions = {}, +): Promise { + const target = normalizeRepoFullName(repoFullName); + const baseBranch = typeof options.baseBranch === "string" && options.baseBranch.trim() ? options.baseBranch.trim() : DEFAULT_BASE_BRANCH; + const cloneBaseDir = typeof options.cloneBaseDir === "string" && options.cloneBaseDir.trim() ? options.cloneBaseDir.trim() : resolveRepoCloneBaseDir(options.env); + const repoPath = join(cloneBaseDir, target.owner, target.repo); + const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs as number : 120_000; + const runGit = options.runGit ?? defaultRunGit; + + if (isUnsafeGitArgValue(baseBranch)) { + return { ok: false, repoPath, error: "invalid_base_branch" }; + } + + if (!existsSync(repoPath)) { + mkdirSync(join(cloneBaseDir, target.owner), { recursive: true, mode: 0o700 }); + const cloneUrl = typeof options.remoteUrl === "string" && options.remoteUrl.trim() ? options.remoteUrl.trim() : `https://github.com/${target.owner}/${target.repo}.git`; + if (isUnsafeGitArgValue(cloneUrl)) { + return { ok: false, repoPath, error: "invalid_remote_url" }; + } + const cloned = await runGit(["clone", cloneUrl, repoPath], cloneBaseDir, timeoutMs); + if (!cloned.ok) return { ok: false, repoPath, error: cloned.stderr || "git_clone_failed" }; + return { ok: true, repoPath }; + } + + const fetched = await runGit(["fetch", "origin"], repoPath, timeoutMs); + if (!fetched.ok) return { ok: false, repoPath, error: fetched.stderr || "git_fetch_failed" }; + + const checkedOut = await runGit(["checkout", baseBranch], repoPath, timeoutMs); + if (!checkedOut.ok) return { ok: false, repoPath, error: checkedOut.stderr || "git_checkout_failed" }; + + const reset = await runGit(["reset", "--hard", `origin/${baseBranch}`], repoPath, timeoutMs); + if (!reset.ok) return { ok: false, repoPath, error: reset.stderr || "git_reset_failed" }; + + return { ok: true, repoPath }; +} diff --git a/packages/loopover-miner/lib/submission-freshness-check.d.ts b/packages/loopover-miner/lib/submission-freshness-check.d.ts index fbc8864aa5..05a25325bf 100644 --- a/packages/loopover-miner/lib/submission-freshness-check.d.ts +++ b/packages/loopover-miner/lib/submission-freshness-check.d.ts @@ -1,42 +1,61 @@ -export const SUBMISSION_FRESHNESS_ABORT_EVENT: "submission_freshness_abort"; - +export declare const SUBMISSION_FRESHNESS_ABORT_EVENT: "submission_freshness_abort"; export type FreshnessAbortReason = "issue_closed" | "already_addressed" | "claim_superseded" | "live_state_unavailable"; - export type SubmissionFreshnessCandidate = { - repoFullName: string; - issueNumber: number; - minerLogin: string; + repoFullName: string; + issueNumber: number; + minerLogin: string; }; - export type LiveIssueSnapshot = { - state: "open" | "closed"; - referencingPrs: Array<{ number: number; state: "open" | "closed" | "merged"; authorLogin: string; createdAt: string | null }>; + state: "open" | "closed"; + referencingPrs: Array<{ + number: number; + state: "open" | "closed" | "merged"; + authorLogin: string; + createdAt: string | null; + }>; }; - export type SubmissionFreshnessClaimLedger = { - listClaims(filter: { repoFullName?: string; status?: string }): Array<{ repoFullName: string; issueNumber: number; status: string }>; + listClaims(filter: { + repoFullName?: string; + status?: string; + }): Array<{ + repoFullName: string; + issueNumber: number; + status: string; + }>; }; - export type SubmissionFreshnessEventLedger = { - appendEvent(event: { type: string; repoFullName?: string; payload: Record }): unknown; + appendEvent(event: { + type: string; + repoFullName?: string; + payload: Record; + }): unknown; }; - export type SubmissionFreshnessDeps = { - claimLedger: SubmissionFreshnessClaimLedger; - fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise; - eventLedger: SubmissionFreshnessEventLedger; + claimLedger: SubmissionFreshnessClaimLedger; + fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise; + eventLedger: SubmissionFreshnessEventLedger; +}; +export type SubmissionFreshnessResult = { + fresh: true; +} | { + fresh: false; + reason: FreshnessAbortReason; }; - -export type SubmissionFreshnessResult = { fresh: true } | { fresh: false; reason: FreshnessAbortReason }; - export type SubmissionFreshnessRetryOptions = { - maxAttempts?: number; - sleepFn?: (ms: number) => Promise; - backoffMs?: (attempt: number) => number; + maxAttempts?: number; + sleepFn?: (ms: number) => Promise; + backoffMs?: (attempt: number) => number; }; - -export function checkSubmissionFreshness( - candidate: SubmissionFreshnessCandidate, - deps: SubmissionFreshnessDeps, - options?: SubmissionFreshnessRetryOptions, -): Promise; +/** + * Evaluate whether a submission candidate's live repo state is still fresh enough to proceed toward open_pr. + * Checks the miner's own claim-ledger status first (local, free) before spending a network round-trip on the + * live issue/PR snapshot. Fails closed (throws) on a malformed candidate or missing dependency. + * + * Bounded retry for the live-state snapshot fetch (#7089): up to `maxAttempts` (default 3) attempts with + * `backoffMs(attempt)` backoff between them, returning as soon as a real (non-null, well-formed) snapshot is + * obtained. Optional -- every existing caller works unchanged. Pure over the injected `sleepFn`/`backoffMs` + * -- no real timers in tests. Only the fetch itself is retried; a well-formed snapshot's own signals + * (issue_closed / already_addressed) are decided once, never retried. + */ +export declare function checkSubmissionFreshness(candidate: SubmissionFreshnessCandidate, deps: SubmissionFreshnessDeps, options?: SubmissionFreshnessRetryOptions): Promise; diff --git a/packages/loopover-miner/lib/submission-freshness-check.js b/packages/loopover-miner/lib/submission-freshness-check.js index 22dfb106ee..49cdb0c9dc 100644 --- a/packages/loopover-miner/lib/submission-freshness-check.js +++ b/packages/loopover-miner/lib/submission-freshness-check.js @@ -25,104 +25,95 @@ // rejection-state-machine.js's DISENGAGED_OUTCOME (which handles an EXISTING PR a maintainer closed). "No PR, // no noisy failure" here just means: return a quiet not-fresh result, same shape as any other blocked gate // decision in this package -- never throw, never surface anything to the target repo. - import { defaultRetryBackoffMs } from "./http-retry.js"; - export const SUBMISSION_FRESHNESS_ABORT_EVENT = "submission_freshness_abort"; - // Bounded retry for the pre-submission live-state fetch (#7089), mirroring claim-conflict-resolver.js's // resolveClaimConflict (#6058): a few attempts with exponential backoff let a transient GitHub blur (a brief // 5xx, or GraphQL-index propagation lag) resolve itself before we fail closed, without an unbounded loop. const DEFAULT_SNAPSHOT_MAX_ATTEMPTS = 3; const defaultSnapshotSleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)); - /** * Evaluate whether a submission candidate's live repo state is still fresh enough to proceed toward open_pr. * Checks the miner's own claim-ledger status first (local, free) before spending a network round-trip on the * live issue/PR snapshot. Fails closed (throws) on a malformed candidate or missing dependency. * - * @param {{ repoFullName: string, issueNumber: number, minerLogin: string }} candidate - * @param {{ - * claimLedger: { listClaims(filter: { repoFullName?: string, status?: string }): Array<{ repoFullName: string, issueNumber: number, status: string }> }, - * fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise<{ state: "open"|"closed", referencingPrs: Array<{ number: number, state: "open"|"closed"|"merged", authorLogin: string }> } | null>, - * eventLedger: { appendEvent(event: { type: string, repoFullName?: string, payload: Record }): unknown }, - * }} deps - * @param {{ maxAttempts?: number, sleepFn?: (ms: number) => Promise, backoffMs?: (attempt: number) => number }} [options] - * Bounded retry for the live-state snapshot fetch (#7089): up to `maxAttempts` (default 3) attempts with - * `backoffMs(attempt)` backoff between them, returning as soon as a real (non-null, well-formed) snapshot is - * obtained. Optional -- every existing caller works unchanged. Pure over the injected `sleepFn`/`backoffMs` - * -- no real timers in tests. Only the fetch itself is retried; a well-formed snapshot's own signals - * (issue_closed / already_addressed) are decided once, never retried. + * Bounded retry for the live-state snapshot fetch (#7089): up to `maxAttempts` (default 3) attempts with + * `backoffMs(attempt)` backoff between them, returning as soon as a real (non-null, well-formed) snapshot is + * obtained. Optional -- every existing caller works unchanged. Pure over the injected `sleepFn`/`backoffMs` + * -- no real timers in tests. Only the fetch itself is retried; a well-formed snapshot's own signals + * (issue_closed / already_addressed) are decided once, never retried. */ export async function checkSubmissionFreshness(candidate, deps, options = {}) { - if (!candidate || typeof candidate !== "object") throw new Error("invalid_freshness_candidate"); - const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; - if (!repoFullName) throw new Error("invalid_repo_full_name"); - if (!Number.isInteger(candidate.issueNumber) || candidate.issueNumber < 1) throw new Error("invalid_issue_number"); - const minerLogin = typeof candidate.minerLogin === "string" ? candidate.minerLogin.trim() : ""; - if (!minerLogin) throw new Error("invalid_miner_login"); - - if (!deps || typeof deps !== "object") throw new Error("invalid_freshness_deps"); - const { claimLedger, fetchLiveIssueSnapshot, eventLedger } = deps; - if (!claimLedger || typeof claimLedger.listClaims !== "function") throw new Error("invalid_claim_ledger"); - if (typeof fetchLiveIssueSnapshot !== "function") throw new Error("invalid_live_state_fetcher"); - if (!eventLedger || typeof eventLedger.appendEvent !== "function") throw new Error("invalid_event_ledger"); - - 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; - - const claim = claimLedger.listClaims({ repoFullName }).find((c) => c.issueNumber === candidate.issueNumber); - if (!claim || claim.status !== "active") { - return abort(eventLedger, repoFullName, candidate.issueNumber, "claim_superseded"); - } - - let snapshot = null; - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - let current; - try { - current = await fetchLiveIssueSnapshot(repoFullName, candidate.issueNumber); - } catch { - current = null; + if (!candidate || typeof candidate !== "object") + throw new Error("invalid_freshness_candidate"); + const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; + if (!repoFullName) + throw new Error("invalid_repo_full_name"); + if (!Number.isInteger(candidate.issueNumber) || candidate.issueNumber < 1) + throw new Error("invalid_issue_number"); + const minerLogin = typeof candidate.minerLogin === "string" ? candidate.minerLogin.trim() : ""; + if (!minerLogin) + throw new Error("invalid_miner_login"); + if (!deps || typeof deps !== "object") + throw new Error("invalid_freshness_deps"); + const { claimLedger, fetchLiveIssueSnapshot, eventLedger } = deps; + if (!claimLedger || typeof claimLedger.listClaims !== "function") + throw new Error("invalid_claim_ledger"); + if (typeof fetchLiveIssueSnapshot !== "function") + throw new Error("invalid_live_state_fetcher"); + if (!eventLedger || typeof eventLedger.appendEvent !== "function") + throw new Error("invalid_event_ledger"); + 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; + const claim = claimLedger.listClaims({ repoFullName }).find((c) => c.issueNumber === candidate.issueNumber); + if (!claim || claim.status !== "active") { + return abort(eventLedger, repoFullName, candidate.issueNumber, "claim_superseded"); } - if (current && typeof current === "object") { - // A real, well-formed snapshot resolves the transient window: stop retrying and decide on it now. - snapshot = current; - break; + let snapshot = null; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + let current; + try { + current = await fetchLiveIssueSnapshot(repoFullName, candidate.issueNumber); + } + catch { + current = null; + } + if (current && typeof current === "object") { + // A real, well-formed snapshot resolves the transient window: stop retrying and decide on it now. + snapshot = current; + break; + } + // Back off before the next attempt (transient 5xx / index-propagation lag); never after the last one. + if (attempt < maxAttempts) + await sleepFn(backoffMs(attempt)); } - // Back off before the next attempt (transient 5xx / index-propagation lag); never after the last one. - if (attempt < maxAttempts) await sleepFn(backoffMs(attempt)); - } - if (!snapshot) { - // Retry budget exhausted with no usable snapshot -- fail closed exactly as before (#7089 only widens the window). - return abort(eventLedger, repoFullName, candidate.issueNumber, "live_state_unavailable"); - } - - if (snapshot.state === "closed") { - return abort(eventLedger, repoFullName, candidate.issueNumber, "issue_closed"); - } - - // GitHub logins are case-insensitive for identity purposes (the same account can be echoed back with - // different casing by different API responses), so a strict `!==` would misclassify the miner's own - // referencing PR as "another author" whenever the casing happens to differ -- compare case-normalized. - const minerLoginKey = minerLogin.toLowerCase(); - const referencingPrs = Array.isArray(snapshot.referencingPrs) ? snapshot.referencingPrs : []; - const addressedByAnotherAuthor = referencingPrs.some( - (pr) => typeof pr.authorLogin === "string" && pr.authorLogin.trim().toLowerCase() !== minerLoginKey && (pr.state === "merged" || pr.state === "open"), - ); - if (addressedByAnotherAuthor) { - return abort(eventLedger, repoFullName, candidate.issueNumber, "already_addressed"); - } - - return { fresh: true }; + if (!snapshot) { + // Retry budget exhausted with no usable snapshot -- fail closed exactly as before (#7089 only widens the window). + return abort(eventLedger, repoFullName, candidate.issueNumber, "live_state_unavailable"); + } + if (snapshot.state === "closed") { + return abort(eventLedger, repoFullName, candidate.issueNumber, "issue_closed"); + } + // GitHub logins are case-insensitive for identity purposes (the same account can be echoed back with + // different casing by different API responses), so a strict `!==` would misclassify the miner's own + // referencing PR as "another author" whenever the casing happens to differ -- compare case-normalized. + const minerLoginKey = minerLogin.toLowerCase(); + const referencingPrs = Array.isArray(snapshot.referencingPrs) ? snapshot.referencingPrs : []; + const addressedByAnotherAuthor = referencingPrs.some((pr) => typeof pr.authorLogin === "string" && pr.authorLogin.trim().toLowerCase() !== minerLoginKey && (pr.state === "merged" || pr.state === "open")); + if (addressedByAnotherAuthor) { + return abort(eventLedger, repoFullName, candidate.issueNumber, "already_addressed"); + } + return { fresh: true }; } - function abort(eventLedger, repoFullName, issueNumber, reason) { - eventLedger.appendEvent({ - type: SUBMISSION_FRESHNESS_ABORT_EVENT, - repoFullName, - payload: { issueNumber, reason }, - }); - return { fresh: false, reason }; + eventLedger.appendEvent({ + type: SUBMISSION_FRESHNESS_ABORT_EVENT, + repoFullName, + payload: { issueNumber, reason }, + }); + return { fresh: false, reason }; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic3VibWlzc2lvbi1mcmVzaG5lc3MtY2hlY2suanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyJzdWJtaXNzaW9uLWZyZXNobmVzcy1jaGVjay50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxzR0FBc0c7QUFDdEcsOEdBQThHO0FBQzlHLDBHQUEwRztBQUMxRywrR0FBK0c7QUFDL0csZ0hBQWdIO0FBQ2hILG1IQUFtSDtBQUNuSCxrSEFBa0g7QUFDbEgsbUdBQW1HO0FBQ25HLEVBQUU7QUFDRiwrR0FBK0c7QUFDL0csOEdBQThHO0FBQzlHLDhHQUE4RztBQUM5RyxtR0FBbUc7QUFDbkcsRUFBRTtBQUNGLDZHQUE2RztBQUM3Ryx5R0FBeUc7QUFDekcsOEdBQThHO0FBQzlHLDJHQUEyRztBQUMzRyw0R0FBNEc7QUFDNUcsK0dBQStHO0FBQy9HLHlHQUF5RztBQUN6RywwR0FBMEc7QUFDMUcsRUFBRTtBQUNGLDhHQUE4RztBQUM5Ryw4R0FBOEc7QUFDOUcsMkdBQTJHO0FBQzNHLHNGQUFzRjtBQUV0RixPQUFPLEVBQUUscUJBQXFCLEVBQUUsTUFBTSxpQkFBaUIsQ0FBQztBQUV4RCxNQUFNLENBQUMsTUFBTSxnQ0FBZ0MsR0FBRyw0QkFBcUMsQ0FBQztBQXFDdEYsd0dBQXdHO0FBQ3hHLDZHQUE2RztBQUM3RywwR0FBMEc7QUFDMUcsTUFBTSw2QkFBNkIsR0FBRyxDQUFDLENBQUM7QUFDeEMsTUFBTSxvQkFBb0IsR0FBRyxDQUFDLE9BQWUsRUFBaUIsRUFBRSxDQUFDLElBQUksT0FBTyxDQUFDLENBQUMsT0FBTyxFQUFFLEVBQUUsQ0FBQyxVQUFVLENBQUMsT0FBTyxFQUFFLE9BQU8sQ0FBQyxDQUFDLENBQUM7QUFFeEg7Ozs7Ozs7Ozs7R0FVRztBQUNILE1BQU0sQ0FBQyxLQUFLLFVBQVUsd0JBQXdCLENBQzVDLFNBQXVDLEVBQ3ZDLElBQTZCLEVBQzdCLFVBQTJDLEVBQUU7SUFFN0MsSUFBSSxDQUFDLFNBQVMsSUFBSSxPQUFPLFNBQVMsS0FB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/submission-freshness-check.ts b/packages/loopover-miner/lib/submission-freshness-check.ts new file mode 100644 index 0000000000..40f7dd49b8 --- /dev/null +++ b/packages/loopover-miner/lib/submission-freshness-check.ts @@ -0,0 +1,167 @@ +// Late-binding freshness check before open_pr fires (#3007). A soft-claim made at the start of a long +// create/iterate loop can go stale by the time a candidate reaches submission: the target issue may have been +// closed, already fixed by another author, or the miner's own claim may have been released/expired in the +// interim. This is a FINAL, read-only check immediately before open_pr spec construction -- complementing, not +// replacing, the claim-time check (src/miner/soft-claim.ts) -- so a stale submission never reaches the Governor +// chokepoint (governor-chokepoint.js) as a live write attempt: check freshness first, THEN prepareOpenPrSubmission +// (harness-submission-trigger.js), THEN the Governor. These are separate, sequentially-composed units, not nested +// calls -- attempt-runner.js (#2337) is the real call site that wires them together in that order. +// +// READ-ONLY BY CONTRACT: never writes anything except its own abort-reason audit event (on staleness only, not +// on every check -- mirrors this issue's own "log the abort reason" wording, not a per-decision audit trail). +// The live-state fetch is an injected dependency so this stays testable without real network I/O and agnostic +// to HOW the caller sources issue/PR state (raw GitHub API, loopover's own cached MCP data, etc.). +// +// FAIL CLOSED: an unreachable/failed live-state fetch is treated as stale (aborts), never as "no evidence of +// staleness, so proceed" -- mirrors this package's fail-closed convention elsewhere (harness-submission- +// trigger.js's predicted_gate_unavailable/slop_assessment_unavailable, iterate-loop.ts's ambiguous-on-error). +// That fail-closed OUTCOME is unchanged; a single transient blip just gets a bounded retry-with-backoff to +// resolve itself FIRST (#7089) -- the same window claim-conflict-resolver.js's resolveClaimConflict (#6058) +// already gives its own call to this identical fetchLiveIssueSnapshot, reusing http-retry.js's shared backoff. +// Aborting here discards a fully-completed create/iterate loop's local work, so riding out a brief 5xx / +// GraphQL-index propagation lag before failing closed matters more here than in the post-submission case. +// +// NOT a rejection outcome: staleness is caught BEFORE any PR exists, so it is not the same lifecycle event as +// rejection-state-machine.js's DISENGAGED_OUTCOME (which handles an EXISTING PR a maintainer closed). "No PR, +// no noisy failure" here just means: return a quiet not-fresh result, same shape as any other blocked gate +// decision in this package -- never throw, never surface anything to the target repo. + +import { defaultRetryBackoffMs } from "./http-retry.js"; + +export const SUBMISSION_FRESHNESS_ABORT_EVENT = "submission_freshness_abort" as const; + +export type FreshnessAbortReason = "issue_closed" | "already_addressed" | "claim_superseded" | "live_state_unavailable"; + +export type SubmissionFreshnessCandidate = { + repoFullName: string; + issueNumber: number; + minerLogin: string; +}; + +export type LiveIssueSnapshot = { + state: "open" | "closed"; + referencingPrs: Array<{ number: number; state: "open" | "closed" | "merged"; authorLogin: string; createdAt: string | null }>; +}; + +export type SubmissionFreshnessClaimLedger = { + listClaims(filter: { repoFullName?: string; status?: string }): Array<{ repoFullName: string; issueNumber: number; status: string }>; +}; + +export type SubmissionFreshnessEventLedger = { + appendEvent(event: { type: string; repoFullName?: string; payload: Record }): unknown; +}; + +export type SubmissionFreshnessDeps = { + claimLedger: SubmissionFreshnessClaimLedger; + fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise; + eventLedger: SubmissionFreshnessEventLedger; +}; + +export type SubmissionFreshnessResult = { fresh: true } | { fresh: false; reason: FreshnessAbortReason }; + +export type SubmissionFreshnessRetryOptions = { + maxAttempts?: number; + sleepFn?: (ms: number) => Promise; + backoffMs?: (attempt: number) => number; +}; + +// Bounded retry for the pre-submission live-state fetch (#7089), mirroring claim-conflict-resolver.js's +// resolveClaimConflict (#6058): a few attempts with exponential backoff let a transient GitHub blur (a brief +// 5xx, or GraphQL-index propagation lag) resolve itself before we fail closed, without an unbounded loop. +const DEFAULT_SNAPSHOT_MAX_ATTEMPTS = 3; +const defaultSnapshotSleep = (delayMs: number): Promise => new Promise((resolve) => setTimeout(resolve, delayMs)); + +/** + * Evaluate whether a submission candidate's live repo state is still fresh enough to proceed toward open_pr. + * Checks the miner's own claim-ledger status first (local, free) before spending a network round-trip on the + * live issue/PR snapshot. Fails closed (throws) on a malformed candidate or missing dependency. + * + * Bounded retry for the live-state snapshot fetch (#7089): up to `maxAttempts` (default 3) attempts with + * `backoffMs(attempt)` backoff between them, returning as soon as a real (non-null, well-formed) snapshot is + * obtained. Optional -- every existing caller works unchanged. Pure over the injected `sleepFn`/`backoffMs` + * -- no real timers in tests. Only the fetch itself is retried; a well-formed snapshot's own signals + * (issue_closed / already_addressed) are decided once, never retried. + */ +export async function checkSubmissionFreshness( + candidate: SubmissionFreshnessCandidate, + deps: SubmissionFreshnessDeps, + options: SubmissionFreshnessRetryOptions = {}, +): Promise { + if (!candidate || typeof candidate !== "object") throw new Error("invalid_freshness_candidate"); + const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; + if (!repoFullName) throw new Error("invalid_repo_full_name"); + if (!Number.isInteger(candidate.issueNumber) || candidate.issueNumber < 1) throw new Error("invalid_issue_number"); + const minerLogin = typeof candidate.minerLogin === "string" ? candidate.minerLogin.trim() : ""; + if (!minerLogin) throw new Error("invalid_miner_login"); + + if (!deps || typeof deps !== "object") throw new Error("invalid_freshness_deps"); + const { claimLedger, fetchLiveIssueSnapshot, eventLedger } = deps; + if (!claimLedger || typeof claimLedger.listClaims !== "function") throw new Error("invalid_claim_ledger"); + if (typeof fetchLiveIssueSnapshot !== "function") throw new Error("invalid_live_state_fetcher"); + if (!eventLedger || typeof eventLedger.appendEvent !== "function") throw new Error("invalid_event_ledger"); + + 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; + + const claim = claimLedger.listClaims({ repoFullName }).find((c) => c.issueNumber === candidate.issueNumber); + if (!claim || claim.status !== "active") { + return abort(eventLedger, repoFullName, candidate.issueNumber, "claim_superseded"); + } + + let snapshot: LiveIssueSnapshot | null = null; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + let current: LiveIssueSnapshot | null; + try { + current = await fetchLiveIssueSnapshot(repoFullName, candidate.issueNumber); + } catch { + current = null; + } + if (current && typeof current === "object") { + // A real, well-formed snapshot resolves the transient window: stop retrying and decide on it now. + snapshot = current; + break; + } + // Back off before the next attempt (transient 5xx / index-propagation lag); never after the last one. + if (attempt < maxAttempts) await sleepFn(backoffMs(attempt)); + } + if (!snapshot) { + // Retry budget exhausted with no usable snapshot -- fail closed exactly as before (#7089 only widens the window). + return abort(eventLedger, repoFullName, candidate.issueNumber, "live_state_unavailable"); + } + + if (snapshot.state === "closed") { + return abort(eventLedger, repoFullName, candidate.issueNumber, "issue_closed"); + } + + // GitHub logins are case-insensitive for identity purposes (the same account can be echoed back with + // different casing by different API responses), so a strict `!==` would misclassify the miner's own + // referencing PR as "another author" whenever the casing happens to differ -- compare case-normalized. + const minerLoginKey = minerLogin.toLowerCase(); + const referencingPrs = Array.isArray(snapshot.referencingPrs) ? snapshot.referencingPrs : []; + const addressedByAnotherAuthor = referencingPrs.some( + (pr) => typeof pr.authorLogin === "string" && pr.authorLogin.trim().toLowerCase() !== minerLoginKey && (pr.state === "merged" || pr.state === "open"), + ); + if (addressedByAnotherAuthor) { + return abort(eventLedger, repoFullName, candidate.issueNumber, "already_addressed"); + } + + return { fresh: true }; +} + +function abort( + eventLedger: SubmissionFreshnessEventLedger, + repoFullName: string, + issueNumber: number, + reason: FreshnessAbortReason, +): { fresh: false; reason: FreshnessAbortReason } { + eventLedger.appendEvent({ + type: SUBMISSION_FRESHNESS_ABORT_EVENT, + repoFullName, + payload: { issueNumber, reason }, + }); + return { fresh: false, reason }; +} diff --git a/packages/loopover-miner/lib/worktree-allocator.d.ts b/packages/loopover-miner/lib/worktree-allocator.d.ts index a0500d3136..02b4b23bdf 100644 --- a/packages/loopover-miner/lib/worktree-allocator.d.ts +++ b/packages/loopover-miner/lib/worktree-allocator.d.ts @@ -1,47 +1,43 @@ export type WorktreeAllocation = { - slotIndex: number; - worktreePath: string; - attemptId: string | null; - repoFullName: string | null; - status: "free" | "active"; - ownerPid: number | null; - ownerHost: string | null; - allocatedAt: string | null; + slotIndex: number; + worktreePath: string; + attemptId: string | null; + repoFullName: string | null; + status: "free" | "active"; + ownerPid: number | null; + ownerHost: string | null; + allocatedAt: string | null; }; - export type WorktreeAllocator = { - dbPath: string; - worktreeBaseDir: string; - maxConcurrency: number; - maxLeaseMs: number; - processPid: number; - hostId: string; - acquire(attemptId: string, repoFullName: string): WorktreeAllocation; - release(attemptId: string): WorktreeAllocation | null; - listSlots(): WorktreeAllocation[]; - close(): void; + dbPath: string; + worktreeBaseDir: string; + maxConcurrency: number; + maxLeaseMs: number; + processPid: number; + hostId: string; + acquire(attemptId: string, repoFullName: string): WorktreeAllocation; + release(attemptId: string): WorktreeAllocation | null; + listSlots(): WorktreeAllocation[]; + close(): void; }; - -export const DEFAULT_MAX_LEASE_MS: number; - -export function resolveWorktreeAllocatorDbPath(env?: Record): string; - -export function resolveWorktreeBaseDir(env?: Record): string; - -export function isProcessAlive(pid: number): boolean; - -export function openWorktreeAllocator(options?: { - dbPath?: string; - worktreeBaseDir?: string; - maxConcurrency?: number; - maxLeaseMs?: number; - processPid?: number; - hostId?: string; - nowMs?: number; +export declare const DEFAULT_MAX_LEASE_MS: number; +export declare function resolveWorktreeAllocatorDbPath(env?: Record): string; +export declare function resolveWorktreeBaseDir(env?: Record): string; +export declare function isProcessAlive(pid: number): boolean; +/** + * Opens the local worktree allocator store. On startup reclaims orphaned active slots — any slot past its + * `maxLeaseMs` age (the container-agnostic guarantee for fleet mode's shared store), plus, as a same-host fast + * path, any slot whose owner pid is confirmed dead in THIS host's PID namespace. + */ +export declare function openWorktreeAllocator(options?: { + dbPath?: string; + worktreeBaseDir?: string; + maxConcurrency?: number; + maxLeaseMs?: number; + processPid?: number; + hostId?: string; + nowMs?: number; }): WorktreeAllocator; - -export function acquireWorktree(attemptId: string, repoFullName: string): WorktreeAllocation; - -export function releaseWorktree(attemptId: string): WorktreeAllocation | null; - -export function closeDefaultWorktreeAllocator(): void; +export declare function acquireWorktree(attemptId: string, repoFullName: string): WorktreeAllocation; +export declare function releaseWorktree(attemptId: string): WorktreeAllocation | null; +export declare function closeDefaultWorktreeAllocator(): void; diff --git a/packages/loopover-miner/lib/worktree-allocator.js b/packages/loopover-miner/lib/worktree-allocator.js index 865edc0800..65203e03e1 100644 --- a/packages/loopover-miner/lib/worktree-allocator.js +++ b/packages/loopover-miner/lib/worktree-allocator.js @@ -5,21 +5,10 @@ import { mkdirSync } from "node:fs"; import { homedir, hostname } from "node:os"; import { join } from "node:path"; import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; - -// Git-worktree-per-attempt allocator (#4297): durable local bookkeeping for which worktree paths are -// allocated to which fleet attempts. Opens its handle through local-store.js's openLocalStoreDb (#4272), the -// same call run-state.js / claim-ledger.js / portfolio-queue.js use — plain JS + node:sqlite, never phones -// home. Going through openLocalStoreDb is what registers the handle for crash-safe cleanup -// (process-lifecycle.js, #4826), which matters most for exactly this store: a SIGINT/SIGTERM mid-write is what -// leaves a worktree slot leased to a process that no longer exists (#6600). It previously hand-rolled the -// identical mkdirSync/chmodSync/PRAGMA sequence and so was never registered, despite this comment already -// claiming to mirror those three files. - const defaultDbFileName = "worktree-allocator.sqlite3"; const defaultWorktreeDirName = "worktrees"; const defaultMaxConcurrency = 2; let defaultWorktreeAllocator = null; - // Age-based orphan reclaim (#7085). Fleet mode (see DEPLOYMENT.md) runs multiple separate CONTAINERS over one // shared data volume, each with its own PID namespace, so a stored `owner_pid` is meaningless the moment a // different container opens this store — `isProcessAlive` checks the CALLING process's own namespace, not the @@ -30,98 +19,99 @@ let defaultWorktreeAllocator = null; // agent run + push), which can legitimately run for hours; the same-host `isProcessAlive` fast path still frees a // crashed local owner immediately, so this age fallback only ever governs the cross-container case. export const DEFAULT_MAX_LEASE_MS = 6 * 60 * 60 * 1000; - export function resolveWorktreeAllocatorDbPath(env = process.env) { - return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_WORKTREE_ALLOCATOR_DB", env); + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_WORKTREE_ALLOCATOR_DB", env); } - export function resolveWorktreeBaseDir(env = process.env) { - const explicitPath = typeof env.LOOPOVER_MINER_WORKTREE_DIR === "string" - ? env.LOOPOVER_MINER_WORKTREE_DIR.trim() - : ""; - if (explicitPath) return explicitPath; - - const explicitConfigDir = typeof env.LOOPOVER_MINER_CONFIG_DIR === "string" - ? env.LOOPOVER_MINER_CONFIG_DIR.trim() - : ""; - if (explicitConfigDir) return join(explicitConfigDir, defaultWorktreeDirName); - - const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim() - ? env.XDG_CONFIG_HOME.trim() - : join(homedir(), ".config"); - return join(configHome, "loopover-miner", defaultWorktreeDirName); + const explicitPath = typeof env.LOOPOVER_MINER_WORKTREE_DIR === "string" + ? env.LOOPOVER_MINER_WORKTREE_DIR.trim() + : ""; + if (explicitPath) + return explicitPath; + const explicitConfigDir = typeof env.LOOPOVER_MINER_CONFIG_DIR === "string" + ? env.LOOPOVER_MINER_CONFIG_DIR.trim() + : ""; + if (explicitConfigDir) + return join(explicitConfigDir, defaultWorktreeDirName); + const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim() + ? env.XDG_CONFIG_HOME.trim() + : join(homedir(), ".config"); + return join(configHome, "loopover-miner", defaultWorktreeDirName); } - function normalizeDbPath(dbPath) { - return normalizeLocalStoreDbPath(dbPath, resolveWorktreeAllocatorDbPath(), "invalid_worktree_allocator_db_path"); + return normalizeLocalStoreDbPath(dbPath, resolveWorktreeAllocatorDbPath(), "invalid_worktree_allocator_db_path"); } - function normalizeWorktreeBaseDir(worktreeBaseDir) { - const path = (worktreeBaseDir ?? resolveWorktreeBaseDir()).trim(); - if (!path) throw new Error("invalid_worktree_base_dir"); - return path; + const path = (worktreeBaseDir ?? resolveWorktreeBaseDir()).trim(); + if (!path) + throw new Error("invalid_worktree_base_dir"); + return path; } - function normalizeMaxConcurrency(value) { - if (value === undefined || value === null) return defaultMaxConcurrency; - if (!Number.isInteger(value) || value < 1) throw new Error("invalid_max_concurrency"); - return value; + if (value === undefined || value === null) + return defaultMaxConcurrency; + if (!Number.isInteger(value) || value < 1) + throw new Error("invalid_max_concurrency"); + return value; } - function normalizeMaxLeaseMs(value) { - if (value === undefined || value === null) return DEFAULT_MAX_LEASE_MS; - if (!Number.isFinite(value) || value < 0) throw new Error("invalid_max_lease_ms"); - return value; + if (value === undefined || value === null) + return DEFAULT_MAX_LEASE_MS; + if (!Number.isFinite(value) || value < 0) + throw new Error("invalid_max_lease_ms"); + return value; } - function normalizeHostId(value) { - if (value === undefined || value === null) return hostname(); - if (typeof value !== "string" || !value.trim()) throw new Error("invalid_host_id"); - return value.trim(); + if (value === undefined || value === null) + return hostname(); + if (typeof value !== "string" || !value.trim()) + throw new Error("invalid_host_id"); + return value.trim(); } - function normalizeRepoFullName(repoFullName) { - if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); - const [owner, repo, extra] = repoFullName.trim().split("/"); - if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name"); - return `${owner}/${repo}`; + if (typeof repoFullName !== "string") + throw new Error("invalid_repo_full_name"); + const [owner, repo, extra] = repoFullName.trim().split("/"); + if (!owner || !repo || extra !== undefined) + throw new Error("invalid_repo_full_name"); + return `${owner}/${repo}`; } - function normalizeAttemptId(attemptId) { - if (typeof attemptId !== "string") throw new Error("invalid_attempt_id"); - const trimmed = attemptId.trim(); - if (!trimmed) throw new Error("invalid_attempt_id"); - return trimmed; + if (typeof attemptId !== "string") + throw new Error("invalid_attempt_id"); + const trimmed = attemptId.trim(); + if (!trimmed) + throw new Error("invalid_attempt_id"); + return trimmed; } - export function isProcessAlive(pid) { - if (!Number.isInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - // ESRCH = no such process; EPERM (or similar) means the process exists but we lack signal rights. - return typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH" - ? false - : true; - } + if (!Number.isInteger(pid) || pid <= 0) + return false; + try { + process.kill(pid, 0); + return true; + } + catch (error) { + // ESRCH = no such process; EPERM (or similar) means the process exists but we lack signal rights. + return typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH" + ? false + : true; + } } - function rowToAllocation(row) { - return { - slotIndex: row.slot_index, - worktreePath: row.worktree_path, - attemptId: row.attempt_id, - repoFullName: row.repo_full_name, - status: row.status, - ownerPid: row.owner_pid, - ownerHost: row.owner_host ?? null, - allocatedAt: row.allocated_at, - }; + return { + slotIndex: row.slot_index, + worktreePath: row.worktree_path, + attemptId: row.attempt_id, + repoFullName: row.repo_full_name, + status: row.status, + ownerPid: row.owner_pid, + ownerHost: row.owner_host ?? null, + allocatedAt: row.allocated_at, + }; } - function ensureSlotTable(db) { - db.exec(` + db.exec(` CREATE TABLE IF NOT EXISTS worktree_slots ( slot_index INTEGER PRIMARY KEY, worktree_path TEXT NOT NULL UNIQUE, @@ -133,40 +123,38 @@ function ensureSlotTable(db) { allocated_at TEXT ) `); - ensureOwnerHostColumn(db); + ensureOwnerHostColumn(db); } - // Add the owner_host column (#7085) to an on-disk file created before it existed. `CREATE TABLE IF NOT EXISTS` // above is a no-op against an already-existing table, so a pre-#7085 file needs this explicit ALTER — guarded by // a presence check (same technique as attempt-log.js's ensureOutcomeColumns). A migrated row keeps owner_host // NULL until its owner re-acquires, so the age-based reclaim (not the same-host pid fast path) governs it. function ensureOwnerHostColumn(db) { - const hasOwnerHost = db - .prepare("PRAGMA table_info(worktree_slots)") - .all() - .some((column) => column.name === "owner_host"); - if (!hasOwnerHost) db.exec("ALTER TABLE worktree_slots ADD COLUMN owner_host TEXT"); + const hasOwnerHost = db + .prepare("PRAGMA table_info(worktree_slots)") + .all() + .some((column) => column.name === "owner_host"); + if (!hasOwnerHost) + db.exec("ALTER TABLE worktree_slots ADD COLUMN owner_host TEXT"); } - function ensureSlots(db, worktreeBaseDir, maxConcurrency) { - mkdirSync(worktreeBaseDir, { recursive: true, mode: 0o700 }); - const insert = db.prepare(` + mkdirSync(worktreeBaseDir, { recursive: true, mode: 0o700 }); + const insert = db.prepare(` INSERT OR IGNORE INTO worktree_slots (slot_index, worktree_path, status) VALUES (?, ?, 'free') `); - for (let slotIndex = 0; slotIndex < maxConcurrency; slotIndex += 1) { - const worktreePath = join(worktreeBaseDir, `slot-${slotIndex}`); - insert.run(slotIndex, worktreePath); - mkdirSync(worktreePath, { recursive: true, mode: 0o700 }); - } + for (let slotIndex = 0; slotIndex < maxConcurrency; slotIndex += 1) { + const worktreePath = join(worktreeBaseDir, `slot-${slotIndex}`); + insert.run(slotIndex, worktreePath); + mkdirSync(worktreePath, { recursive: true, mode: 0o700 }); + } } - function allocationAgeMs(allocatedAt, nowMs) { - const allocatedMs = Date.parse(allocatedAt); - if (!Number.isFinite(allocatedMs)) return null; - return nowMs - allocatedMs; + const allocatedMs = Date.parse(allocatedAt); + if (!Number.isFinite(allocatedMs)) + return null; + return nowMs - allocatedMs; } - /** * Decide whether an `active` slot is orphaned and should be reclaimed. Two independent signals: * - Age (container-agnostic): a slot whose `allocated_at` is older than `maxLeaseMs` is reclaimed regardless of @@ -178,145 +166,138 @@ function allocationAgeMs(allocatedAt, nowMs) { * immediately without waiting out the lease. A foreign `owner_host` is never trusted for the pid check. */ function isSlotOrphaned(row, nowMs, maxLeaseMs, hostId) { - const ageMs = allocationAgeMs(row.allocated_at, nowMs); - if (ageMs !== null && ageMs > maxLeaseMs) return true; - if (row.owner_host !== null && row.owner_host === hostId) { - return row.owner_pid === null || !isProcessAlive(row.owner_pid); - } - return false; + const ageMs = allocationAgeMs(row.allocated_at, nowMs); + if (ageMs !== null && ageMs > maxLeaseMs) + return true; + if (row.owner_host !== null && row.owner_host === hostId) { + return row.owner_pid === null || !isProcessAlive(row.owner_pid); + } + return false; } - function reclaimOrphanedAllocations(db, nowMs, maxLeaseMs, hostId) { - const orphans = db - .prepare("SELECT slot_index, owner_pid, owner_host, allocated_at FROM worktree_slots WHERE status = 'active'") - .all(); - const reclaim = db.prepare(` + const orphans = db + .prepare("SELECT slot_index, owner_pid, owner_host, allocated_at FROM worktree_slots WHERE status = 'active'") + .all(); + const reclaim = db.prepare(` UPDATE worktree_slots SET status = 'free', attempt_id = NULL, repo_full_name = NULL, owner_pid = NULL, owner_host = NULL, allocated_at = NULL WHERE slot_index = ? `); - for (const row of orphans) { - if (isSlotOrphaned(row, nowMs, maxLeaseMs, hostId)) reclaim.run(row.slot_index); - } + for (const row of orphans) { + if (isSlotOrphaned(row, nowMs, maxLeaseMs, hostId)) + reclaim.run(row.slot_index); + } } - /** * Opens the local worktree allocator store. On startup reclaims orphaned active slots — any slot past its * `maxLeaseMs` age (the container-agnostic guarantee for fleet mode's shared store), plus, as a same-host fast * path, any slot whose owner pid is confirmed dead in THIS host's PID namespace. */ export function openWorktreeAllocator(options = {}) { - const resolvedPath = normalizeDbPath(options.dbPath); - const worktreeBaseDir = normalizeWorktreeBaseDir(options.worktreeBaseDir); - const maxConcurrency = normalizeMaxConcurrency(options.maxConcurrency); - const maxLeaseMs = normalizeMaxLeaseMs(options.maxLeaseMs); - const hostId = normalizeHostId(options.hostId); - const processPid = Number.isInteger(options.processPid) ? options.processPid : process.pid; - const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now(); - - const db = openLocalStoreDb(resolvedPath); - ensureSlotTable(db); - ensureSlots(db, worktreeBaseDir, maxConcurrency); - reclaimOrphanedAllocations(db, nowMs, maxLeaseMs, hostId); - - const getByAttempt = db.prepare( - "SELECT slot_index, worktree_path, attempt_id, repo_full_name, status, owner_pid, owner_host, allocated_at FROM worktree_slots WHERE attempt_id = ?", - ); - const countActive = db.prepare("SELECT COUNT(*) AS count FROM worktree_slots WHERE status = 'active'"); - const selectFreeSlot = db.prepare(` + const resolvedPath = normalizeDbPath(options.dbPath); + const worktreeBaseDir = normalizeWorktreeBaseDir(options.worktreeBaseDir); + const maxConcurrency = normalizeMaxConcurrency(options.maxConcurrency); + const maxLeaseMs = normalizeMaxLeaseMs(options.maxLeaseMs); + const hostId = normalizeHostId(options.hostId); + const processPid = Number.isInteger(options.processPid) ? options.processPid : process.pid; + const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now(); + const db = openLocalStoreDb(resolvedPath); + ensureSlotTable(db); + ensureSlots(db, worktreeBaseDir, maxConcurrency); + reclaimOrphanedAllocations(db, nowMs, maxLeaseMs, hostId); + const getByAttempt = db.prepare("SELECT slot_index, worktree_path, attempt_id, repo_full_name, status, owner_pid, owner_host, allocated_at FROM worktree_slots WHERE attempt_id = ?"); + const countActive = db.prepare("SELECT COUNT(*) AS count FROM worktree_slots WHERE status = 'active'"); + const selectFreeSlot = db.prepare(` SELECT slot_index, worktree_path, attempt_id, repo_full_name, status, owner_pid, owner_host, allocated_at FROM worktree_slots WHERE status = 'free' ORDER BY slot_index LIMIT 1 `); - const markActive = db.prepare(` + const markActive = db.prepare(` UPDATE worktree_slots SET status = 'active', attempt_id = ?, repo_full_name = ?, owner_pid = ?, owner_host = ?, allocated_at = ? WHERE slot_index = ? `); - const releaseByAttempt = db.prepare(` + const releaseByAttempt = db.prepare(` UPDATE worktree_slots SET status = 'free', attempt_id = NULL, repo_full_name = NULL, owner_pid = NULL, owner_host = NULL, allocated_at = NULL WHERE attempt_id = ? AND status = 'active' RETURNING slot_index, worktree_path, attempt_id, repo_full_name, status, owner_pid, owner_host, allocated_at `); - const listSlots = db.prepare( - "SELECT slot_index, worktree_path, attempt_id, repo_full_name, status, owner_pid, owner_host, allocated_at FROM worktree_slots ORDER BY slot_index", - ); - - const allocator = { - dbPath: resolvedPath, - worktreeBaseDir, - maxConcurrency, - maxLeaseMs, - processPid, - hostId, - acquire(attemptId, repoFullName) { - const normalizedAttempt = normalizeAttemptId(attemptId); - const normalizedRepo = normalizeRepoFullName(repoFullName); - const existing = getByAttempt.get(normalizedAttempt); - if (existing?.status === "active") return rowToAllocation(existing); - - db.exec("BEGIN IMMEDIATE"); - try { - const raced = getByAttempt.get(normalizedAttempt); - if (raced?.status === "active") { - db.exec("COMMIT"); - return rowToAllocation(raced); - } - const activeCount = countActive.get().count; - if (activeCount >= maxConcurrency) throw new Error("worktree_capacity_exceeded"); - const slot = selectFreeSlot.get(); - if (!slot) throw new Error("worktree_capacity_exceeded"); - const allocatedAt = new Date().toISOString(); - markActive.run(normalizedAttempt, normalizedRepo, processPid, hostId, allocatedAt, slot.slot_index); - db.exec("COMMIT"); - return rowToAllocation({ - ...slot, - attempt_id: normalizedAttempt, - repo_full_name: normalizedRepo, - status: "active", - owner_pid: processPid, - owner_host: hostId, - allocated_at: allocatedAt, - }); - } catch (error) { - db.exec("ROLLBACK"); - throw error; - } - }, - release(attemptId) { - const normalizedAttempt = normalizeAttemptId(attemptId); - const row = releaseByAttempt.get(normalizedAttempt); - return row ? rowToAllocation(row) : null; - }, - listSlots() { - return listSlots.all().map(rowToAllocation); - }, - close() { - db.close(); - }, - }; - - return allocator; + const listSlots = db.prepare("SELECT slot_index, worktree_path, attempt_id, repo_full_name, status, owner_pid, owner_host, allocated_at FROM worktree_slots ORDER BY slot_index"); + const allocator = { + dbPath: resolvedPath, + worktreeBaseDir, + maxConcurrency, + maxLeaseMs, + processPid, + hostId, + acquire(attemptId, repoFullName) { + const normalizedAttempt = normalizeAttemptId(attemptId); + const normalizedRepo = normalizeRepoFullName(repoFullName); + const existing = getByAttempt.get(normalizedAttempt); + if (existing?.status === "active") + return rowToAllocation(existing); + db.exec("BEGIN IMMEDIATE"); + try { + const raced = getByAttempt.get(normalizedAttempt); + if (raced?.status === "active") { + db.exec("COMMIT"); + return rowToAllocation(raced); + } + const activeCount = countActive.get().count; + if (activeCount >= maxConcurrency) + throw new Error("worktree_capacity_exceeded"); + const slot = selectFreeSlot.get(); + if (!slot) + throw new Error("worktree_capacity_exceeded"); + const allocatedAt = new Date().toISOString(); + markActive.run(normalizedAttempt, normalizedRepo, processPid, hostId, allocatedAt, slot.slot_index); + db.exec("COMMIT"); + return rowToAllocation({ + ...slot, + attempt_id: normalizedAttempt, + repo_full_name: normalizedRepo, + status: "active", + owner_pid: processPid, + owner_host: hostId, + allocated_at: allocatedAt, + }); + } + catch (error) { + db.exec("ROLLBACK"); + throw error; + } + }, + release(attemptId) { + const normalizedAttempt = normalizeAttemptId(attemptId); + const row = releaseByAttempt.get(normalizedAttempt); + return row ? rowToAllocation(row) : null; + }, + listSlots() { + return listSlots.all().map(rowToAllocation); + }, + close() { + db.close(); + }, + }; + return allocator; } - function getDefaultWorktreeAllocator() { - defaultWorktreeAllocator ??= openWorktreeAllocator(); - return defaultWorktreeAllocator; + defaultWorktreeAllocator ??= openWorktreeAllocator(); + return defaultWorktreeAllocator; } - export function acquireWorktree(attemptId, repoFullName) { - return getDefaultWorktreeAllocator().acquire(attemptId, repoFullName); + return getDefaultWorktreeAllocator().acquire(attemptId, repoFullName); } - export function releaseWorktree(attemptId) { - return getDefaultWorktreeAllocator().release(attemptId); + return getDefaultWorktreeAllocator().release(attemptId); } - export function closeDefaultWorktreeAllocator() { - if (!defaultWorktreeAllocator) return; - defaultWorktreeAllocator.close(); - defaultWorktreeAllocator = null; + if (!defaultWorktreeAllocator) + return; + defaultWorktreeAllocator.close(); + defaultWorktreeAllocator = null; } +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoid29ya3RyZWUtYWxsb2NhdG9yLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsid29ya3RyZWUtYWxsb2NhdG9yLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLCtHQUErRztBQUMvRyw4R0FBOEc7QUFDOUcsd0RBQXdEO0FBQ3hELE9BQU8sRUFBRSxTQUFTLEVBQUUsTUFBTSxTQUFTLENBQUM7QUFDcEMsT0FBTyxFQUFFLE9BQU8sRUFBRSxRQUFRLEVBQUUsTUFBTSxTQUFTLENBQUM7QUFDNUMsT0FBTyxFQUFFLElBQUksRUFBRSxNQUFNLFdBQVcsQ0FBQztBQUVqQyxPQUFPLEVBQUUseUJBQXlCLEVBQUUsZ0JBQWdCLEVBQUUsdUJBQXVCLEVBQUUsTUFBTSxrQkFBa0IsQ0FBQztBQTBEeEcsTUFBTSxpQkFBaUIsR0FBRyw0QkFBNEIsQ0FBQztBQUN2RCxNQUFNLHNCQUFzQixHQUFHLFdBQVcsQ0FBQztBQUMzQyxNQUFNLHFCQUFxQixHQUFHLENBQUMsQ0FBQztBQUNoQyxJQUFJLHdCQUF3QixHQUE2QixJQUFJLENBQUM7QUFFOUQsOEdBQThHO0FBQzlHLDJHQUEyRztBQUMzRyw4R0FBOEc7QUFDOUcsaUhBQWlIO0FBQ2pILGlIQUFpSDtBQUNqSCxzR0FBc0c7QUFDdEcsaUhBQWlIO0FBQ2pILGtIQUFrSDtBQUNsSCxvR0FBb0c7QUFDcEcsTUFBTSxDQUFDLE1BQU0sb0JBQW9CLEdBQUcsQ0FBQyxHQUFHLEVBQUUsR0FBRyxFQUFFLEdBQUcsSUFBSSxDQUFDO0FBRXZELE1BQU0sVUFBVSw4QkFBOEIsQ0FBQyxNQUEwQyxPQUFPLENBQUMsR0FBRztJQUNsRyxPQUFPLHVCQUF1QixDQUFDLGlCQUFpQixFQUFFLHNDQUFzQyxFQUFFLEdBQUcsQ0FBQyxDQUFDO0FBQ2pHLENBQUM7QUFFRCxNQUFNLFVBQVUsc0JBQXNCLENBQUMsTUFBMEMsT0FBTyxDQUFDLEdBQUc7SUFDMUYsTUFBTSxZQUFZLEdBQUcsT0FBTyxHQUFHLENBQUMsMkJBQTJCLEtB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 \ No newline at end of file diff --git a/packages/loopover-miner/lib/worktree-allocator.ts b/packages/loopover-miner/lib/worktree-allocator.ts new file mode 100644 index 0000000000..0e9811961c --- /dev/null +++ b/packages/loopover-miner/lib/worktree-allocator.ts @@ -0,0 +1,378 @@ +// mkdirSync is still needed for the git-worktree CHECKOUT dirs below (resolveWorktreeBaseDir's tree) — that is +// a filesystem directory, not a store DB path, and is deliberately out of this migration's scope. Only the DB +// handle's own mkdir/chmod moved into openLocalStoreDb. +import { mkdirSync } from "node:fs"; +import { homedir, hostname } from "node:os"; +import { join } from "node:path"; +import type { DatabaseSync } from "node:sqlite"; +import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; + +// Git-worktree-per-attempt allocator (#4297): durable local bookkeeping for which worktree paths are +// allocated to which fleet attempts. Opens its handle through local-store.js's openLocalStoreDb (#4272), the +// same call run-state.js / claim-ledger.js / portfolio-queue.js use — plain JS + node:sqlite, never phones +// home. Going through openLocalStoreDb is what registers the handle for crash-safe cleanup +// (process-lifecycle.js, #4826), which matters most for exactly this store: a SIGINT/SIGTERM mid-write is what +// leaves a worktree slot leased to a process that no longer exists (#6600). It previously hand-rolled the +// identical mkdirSync/chmodSync/PRAGMA sequence and so was never registered, despite this comment already +// claiming to mirror those three files. + +export type WorktreeAllocation = { + slotIndex: number; + worktreePath: string; + attemptId: string | null; + repoFullName: string | null; + status: "free" | "active"; + ownerPid: number | null; + ownerHost: string | null; + allocatedAt: string | null; +}; + +export type WorktreeAllocator = { + dbPath: string; + worktreeBaseDir: string; + maxConcurrency: number; + maxLeaseMs: number; + processPid: number; + hostId: string; + acquire(attemptId: string, repoFullName: string): WorktreeAllocation; + release(attemptId: string): WorktreeAllocation | null; + listSlots(): WorktreeAllocation[]; + close(): void; +}; + +/** SQLite `worktree_slots` row shape (StatementSync returns `Record`). */ +type WorktreeSlotRow = { + slot_index: number; + worktree_path: string; + attempt_id: string | null; + repo_full_name: string | null; + status: "free" | "active"; + owner_pid: number | null; + owner_host: string | null; + allocated_at: string | null; +}; + +type OrphanProbeRow = { + slot_index: number; + owner_pid: number | null; + owner_host: string | null; + allocated_at: string | null; +}; + +type CountRow = { count: number }; + +type TableInfoRow = { name: string }; + +const defaultDbFileName = "worktree-allocator.sqlite3"; +const defaultWorktreeDirName = "worktrees"; +const defaultMaxConcurrency = 2; +let defaultWorktreeAllocator: WorktreeAllocator | null = null; + +// Age-based orphan reclaim (#7085). Fleet mode (see DEPLOYMENT.md) runs multiple separate CONTAINERS over one +// shared data volume, each with its own PID namespace, so a stored `owner_pid` is meaningless the moment a +// different container opens this store — `isProcessAlive` checks the CALLING process's own namespace, not the +// one that recorded the pid. So we mirror the age-based convention every sibling shared-lease store already uses +// (portfolio-queue-expiry.js's DEFAULT_MAX_LEASE_MS / sweepStuckItems, claim-ledger's DEFAULT_MAX_CLAIM_AGE_MS): +// reclaim any `active` slot older than this regardless of what the pid check reports. Kept well above +// portfolio-queue-expiry's 30-minute floor because a single worktree lease spans a whole coding attempt (clone + +// agent run + push), which can legitimately run for hours; the same-host `isProcessAlive` fast path still frees a +// crashed local owner immediately, so this age fallback only ever governs the cross-container case. +export const DEFAULT_MAX_LEASE_MS = 6 * 60 * 60 * 1000; + +export function resolveWorktreeAllocatorDbPath(env: Record = process.env): string { + return resolveLocalStoreDbPath(defaultDbFileName, "LOOPOVER_MINER_WORKTREE_ALLOCATOR_DB", env); +} + +export function resolveWorktreeBaseDir(env: Record = process.env): string { + const explicitPath = typeof env.LOOPOVER_MINER_WORKTREE_DIR === "string" + ? env.LOOPOVER_MINER_WORKTREE_DIR.trim() + : ""; + if (explicitPath) return explicitPath; + + const explicitConfigDir = typeof env.LOOPOVER_MINER_CONFIG_DIR === "string" + ? env.LOOPOVER_MINER_CONFIG_DIR.trim() + : ""; + if (explicitConfigDir) return join(explicitConfigDir, defaultWorktreeDirName); + + const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim() + ? env.XDG_CONFIG_HOME.trim() + : join(homedir(), ".config"); + return join(configHome, "loopover-miner", defaultWorktreeDirName); +} + +function normalizeDbPath(dbPath: string | null | undefined): string { + return normalizeLocalStoreDbPath(dbPath, resolveWorktreeAllocatorDbPath(), "invalid_worktree_allocator_db_path"); +} + +function normalizeWorktreeBaseDir(worktreeBaseDir: string | null | undefined): string { + const path = (worktreeBaseDir ?? resolveWorktreeBaseDir()).trim(); + if (!path) throw new Error("invalid_worktree_base_dir"); + return path; +} + +function normalizeMaxConcurrency(value: number | null | undefined): number { + if (value === undefined || value === null) return defaultMaxConcurrency; + if (!Number.isInteger(value) || value < 1) throw new Error("invalid_max_concurrency"); + return value; +} + +function normalizeMaxLeaseMs(value: number | null | undefined): number { + if (value === undefined || value === null) return DEFAULT_MAX_LEASE_MS; + if (!Number.isFinite(value) || value < 0) throw new Error("invalid_max_lease_ms"); + return value; +} + +function normalizeHostId(value: unknown): string { + if (value === undefined || value === null) return hostname(); + if (typeof value !== "string" || !value.trim()) throw new Error("invalid_host_id"); + return value.trim(); +} + +function normalizeRepoFullName(repoFullName: unknown): string { + if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); + const [owner, repo, extra] = repoFullName.trim().split("/"); + if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name"); + return `${owner}/${repo}`; +} + +function normalizeAttemptId(attemptId: unknown): string { + if (typeof attemptId !== "string") throw new Error("invalid_attempt_id"); + const trimmed = attemptId.trim(); + if (!trimmed) throw new Error("invalid_attempt_id"); + return trimmed; +} + +export function isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + // ESRCH = no such process; EPERM (or similar) means the process exists but we lack signal rights. + return typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH" + ? false + : true; + } +} + +function rowToAllocation(row: WorktreeSlotRow): WorktreeAllocation { + return { + slotIndex: row.slot_index, + worktreePath: row.worktree_path, + attemptId: row.attempt_id, + repoFullName: row.repo_full_name, + status: row.status, + ownerPid: row.owner_pid, + ownerHost: row.owner_host ?? null, + allocatedAt: row.allocated_at, + }; +} + +function ensureSlotTable(db: DatabaseSync): void { + db.exec(` + CREATE TABLE IF NOT EXISTS worktree_slots ( + slot_index INTEGER PRIMARY KEY, + worktree_path TEXT NOT NULL UNIQUE, + attempt_id TEXT UNIQUE, + repo_full_name TEXT, + status TEXT NOT NULL CHECK (status IN ('free', 'active')), + owner_pid INTEGER, + owner_host TEXT, + allocated_at TEXT + ) + `); + ensureOwnerHostColumn(db); +} + +// Add the owner_host column (#7085) to an on-disk file created before it existed. `CREATE TABLE IF NOT EXISTS` +// above is a no-op against an already-existing table, so a pre-#7085 file needs this explicit ALTER — guarded by +// a presence check (same technique as attempt-log.js's ensureOutcomeColumns). A migrated row keeps owner_host +// NULL until its owner re-acquires, so the age-based reclaim (not the same-host pid fast path) governs it. +function ensureOwnerHostColumn(db: DatabaseSync): void { + const hasOwnerHost = db + .prepare("PRAGMA table_info(worktree_slots)") + .all() + .some((column) => (column as TableInfoRow).name === "owner_host"); + if (!hasOwnerHost) db.exec("ALTER TABLE worktree_slots ADD COLUMN owner_host TEXT"); +} + +function ensureSlots(db: DatabaseSync, worktreeBaseDir: string, maxConcurrency: number): void { + mkdirSync(worktreeBaseDir, { recursive: true, mode: 0o700 }); + const insert = db.prepare(` + INSERT OR IGNORE INTO worktree_slots (slot_index, worktree_path, status) + VALUES (?, ?, 'free') + `); + for (let slotIndex = 0; slotIndex < maxConcurrency; slotIndex += 1) { + const worktreePath = join(worktreeBaseDir, `slot-${slotIndex}`); + insert.run(slotIndex, worktreePath); + mkdirSync(worktreePath, { recursive: true, mode: 0o700 }); + } +} + +function allocationAgeMs(allocatedAt: string | null, nowMs: number): number | null { + const allocatedMs = Date.parse(allocatedAt as string); + if (!Number.isFinite(allocatedMs)) return null; + return nowMs - allocatedMs; +} + +/** + * Decide whether an `active` slot is orphaned and should be reclaimed. Two independent signals: + * - Age (container-agnostic): a slot whose `allocated_at` is older than `maxLeaseMs` is reclaimed regardless of + * what `isProcessAlive` reports, guaranteeing eventual reclaim even when a cross-container caller observes the + * owner's pid in the wrong PID namespace. This is the only signal that is sound across fleet mode's separate + * containers, so it must never be gated behind the pid check. + * - Same-host pid liveness (fast path): only when the slot was leased by a process on THIS host (`owner_host` + * matches) is `isProcessAlive` a meaningful signal — a confirmed-dead (or missing) local owner frees its slot + * immediately without waiting out the lease. A foreign `owner_host` is never trusted for the pid check. + */ +function isSlotOrphaned(row: OrphanProbeRow, nowMs: number, maxLeaseMs: number, hostId: string): boolean { + const ageMs = allocationAgeMs(row.allocated_at, nowMs); + if (ageMs !== null && ageMs > maxLeaseMs) return true; + if (row.owner_host !== null && row.owner_host === hostId) { + return row.owner_pid === null || !isProcessAlive(row.owner_pid); + } + return false; +} + +function reclaimOrphanedAllocations(db: DatabaseSync, nowMs: number, maxLeaseMs: number, hostId: string): void { + const orphans = db + .prepare("SELECT slot_index, owner_pid, owner_host, allocated_at FROM worktree_slots WHERE status = 'active'") + .all() as OrphanProbeRow[]; + const reclaim = db.prepare(` + UPDATE worktree_slots + SET status = 'free', attempt_id = NULL, repo_full_name = NULL, owner_pid = NULL, owner_host = NULL, allocated_at = NULL + WHERE slot_index = ? + `); + for (const row of orphans) { + if (isSlotOrphaned(row, nowMs, maxLeaseMs, hostId)) reclaim.run(row.slot_index); + } +} + +/** + * Opens the local worktree allocator store. On startup reclaims orphaned active slots — any slot past its + * `maxLeaseMs` age (the container-agnostic guarantee for fleet mode's shared store), plus, as a same-host fast + * path, any slot whose owner pid is confirmed dead in THIS host's PID namespace. + */ +export function openWorktreeAllocator(options: { + dbPath?: string; + worktreeBaseDir?: string; + maxConcurrency?: number; + maxLeaseMs?: number; + processPid?: number; + hostId?: string; + nowMs?: number; +} = {}): WorktreeAllocator { + const resolvedPath = normalizeDbPath(options.dbPath); + const worktreeBaseDir = normalizeWorktreeBaseDir(options.worktreeBaseDir); + const maxConcurrency = normalizeMaxConcurrency(options.maxConcurrency); + const maxLeaseMs = normalizeMaxLeaseMs(options.maxLeaseMs); + const hostId = normalizeHostId(options.hostId); + const processPid = Number.isInteger(options.processPid) ? options.processPid as number : process.pid; + const nowMs = Number.isFinite(options.nowMs) ? options.nowMs as number : Date.now(); + + const db = openLocalStoreDb(resolvedPath); + ensureSlotTable(db); + ensureSlots(db, worktreeBaseDir, maxConcurrency); + reclaimOrphanedAllocations(db, nowMs, maxLeaseMs, hostId); + + const getByAttempt = db.prepare( + "SELECT slot_index, worktree_path, attempt_id, repo_full_name, status, owner_pid, owner_host, allocated_at FROM worktree_slots WHERE attempt_id = ?", + ); + const countActive = db.prepare("SELECT COUNT(*) AS count FROM worktree_slots WHERE status = 'active'"); + const selectFreeSlot = db.prepare(` + SELECT slot_index, worktree_path, attempt_id, repo_full_name, status, owner_pid, owner_host, allocated_at + FROM worktree_slots + WHERE status = 'free' + ORDER BY slot_index + LIMIT 1 + `); + const markActive = db.prepare(` + UPDATE worktree_slots + SET status = 'active', attempt_id = ?, repo_full_name = ?, owner_pid = ?, owner_host = ?, allocated_at = ? + WHERE slot_index = ? + `); + const releaseByAttempt = db.prepare(` + UPDATE worktree_slots + SET status = 'free', attempt_id = NULL, repo_full_name = NULL, owner_pid = NULL, owner_host = NULL, allocated_at = NULL + WHERE attempt_id = ? AND status = 'active' + RETURNING slot_index, worktree_path, attempt_id, repo_full_name, status, owner_pid, owner_host, allocated_at + `); + const listSlots = db.prepare( + "SELECT slot_index, worktree_path, attempt_id, repo_full_name, status, owner_pid, owner_host, allocated_at FROM worktree_slots ORDER BY slot_index", + ); + + const allocator: WorktreeAllocator = { + dbPath: resolvedPath, + worktreeBaseDir, + maxConcurrency, + maxLeaseMs, + processPid, + hostId, + acquire(attemptId, repoFullName) { + const normalizedAttempt = normalizeAttemptId(attemptId); + const normalizedRepo = normalizeRepoFullName(repoFullName); + const existing = getByAttempt.get(normalizedAttempt) as WorktreeSlotRow | undefined; + if (existing?.status === "active") return rowToAllocation(existing); + + db.exec("BEGIN IMMEDIATE"); + try { + const raced = getByAttempt.get(normalizedAttempt) as WorktreeSlotRow | undefined; + if (raced?.status === "active") { + db.exec("COMMIT"); + return rowToAllocation(raced); + } + const activeCount = (countActive.get() as CountRow).count; + if (activeCount >= maxConcurrency) throw new Error("worktree_capacity_exceeded"); + const slot = selectFreeSlot.get() as WorktreeSlotRow | undefined; + if (!slot) throw new Error("worktree_capacity_exceeded"); + const allocatedAt = new Date().toISOString(); + markActive.run(normalizedAttempt, normalizedRepo, processPid, hostId, allocatedAt, slot.slot_index); + db.exec("COMMIT"); + return rowToAllocation({ + ...slot, + attempt_id: normalizedAttempt, + repo_full_name: normalizedRepo, + status: "active", + owner_pid: processPid, + owner_host: hostId, + allocated_at: allocatedAt, + }); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + }, + release(attemptId) { + const normalizedAttempt = normalizeAttemptId(attemptId); + const row = releaseByAttempt.get(normalizedAttempt) as WorktreeSlotRow | undefined; + return row ? rowToAllocation(row) : null; + }, + listSlots() { + return (listSlots.all() as WorktreeSlotRow[]).map(rowToAllocation); + }, + close() { + db.close(); + }, + }; + + return allocator; +} + +function getDefaultWorktreeAllocator(): WorktreeAllocator { + defaultWorktreeAllocator ??= openWorktreeAllocator(); + return defaultWorktreeAllocator; +} + +export function acquireWorktree(attemptId: string, repoFullName: string): WorktreeAllocation { + return getDefaultWorktreeAllocator().acquire(attemptId, repoFullName); +} + +export function releaseWorktree(attemptId: string): WorktreeAllocation | null { + return getDefaultWorktreeAllocator().release(attemptId); +} + +export function closeDefaultWorktreeAllocator(): void { + if (!defaultWorktreeAllocator) return; + defaultWorktreeAllocator.close(); + defaultWorktreeAllocator = null; +}