diff --git a/packages/gittensory-miner/lib/claim-ledger-expiry.d.ts b/packages/gittensory-miner/lib/claim-ledger-expiry.d.ts new file mode 100644 index 0000000000..e35a98242e --- /dev/null +++ b/packages/gittensory-miner/lib/claim-ledger-expiry.d.ts @@ -0,0 +1,20 @@ +import type { ClaimEntry } from "./claim-ledger.js"; + +export declare const DEFAULT_MAX_CLAIM_AGE_MS: number; + +export type ClaimLedgerExpiryStore = { + listClaims(filter?: { status?: "active" }): ClaimEntry[]; + expireClaim(repoFullName: string, issueNumber: number): ClaimEntry | null; +}; + +export function findExpiredClaims( + claims: ClaimEntry[], + nowMs: number, + maxAgeMs: number, +): ClaimEntry[]; + +export function sweepExpiredClaims( + store: ClaimLedgerExpiryStore, + nowMs: number, + maxAgeMs?: number, +): ClaimEntry[]; diff --git a/packages/gittensory-miner/lib/claim-ledger-expiry.js b/packages/gittensory-miner/lib/claim-ledger-expiry.js new file mode 100644 index 0000000000..8866e28fd8 --- /dev/null +++ b/packages/gittensory-miner/lib/claim-ledger-expiry.js @@ -0,0 +1,39 @@ +/** PURE — no IO, no Date, no random (#2316). */ + +export const DEFAULT_MAX_CLAIM_AGE_MS = 14 * 24 * 60 * 60 * 1000; + +function claimAgeMs(claim, nowMs) { + const claimedAtMs = Date.parse(claim.claimedAt); + if (!Number.isFinite(claimedAtMs)) return null; + return nowMs - claimedAtMs; +} + +/** + * Return active claims whose age is strictly greater than `maxAgeMs`. A claim whose age equals `maxAgeMs` exactly + * is still considered within the window (not expired). + */ +export function findExpiredClaims(claims, nowMs, maxAgeMs) { + if (!Number.isFinite(nowMs) || nowMs < 0) throw new Error("invalid_now_ms"); + if (!Number.isFinite(maxAgeMs) || maxAgeMs < 0) throw new Error("invalid_max_age_ms"); + if (!Array.isArray(claims)) throw new Error("invalid_claims"); + + const expired = []; + for (const claim of claims) { + if (claim?.status !== "active") continue; + const ageMs = claimAgeMs(claim, nowMs); + if (ageMs === null) continue; + if (ageMs > maxAgeMs) expired.push(claim); + } + return expired; +} + +export function sweepExpiredClaims(store, nowMs, maxAgeMs = DEFAULT_MAX_CLAIM_AGE_MS) { + const activeClaims = store.listClaims({ status: "active" }); + const expired = findExpiredClaims(activeClaims, nowMs, maxAgeMs); + const transitioned = []; + for (const claim of expired) { + const updated = store.expireClaim(claim.repoFullName, claim.issueNumber); + if (updated) transitioned.push(updated); + } + return transitioned; +} diff --git a/packages/gittensory-miner/lib/claim-ledger.d.ts b/packages/gittensory-miner/lib/claim-ledger.d.ts index 06f58fe497..122e1eff1b 100644 --- a/packages/gittensory-miner/lib/claim-ledger.d.ts +++ b/packages/gittensory-miner/lib/claim-ledger.d.ts @@ -24,6 +24,7 @@ export type ClaimLedger = { dbPath: string; recordClaim(claim: RecordClaimInput): ClaimEntry; releaseClaim(repoFullName: string, issueNumber: number): ClaimEntry | null; + expireClaim(repoFullName: string, issueNumber: number): ClaimEntry | null; listClaims(filter?: ListClaimsFilter): ClaimEntry[]; close(): void; }; @@ -38,6 +39,8 @@ export function recordClaim(claim: RecordClaimInput): ClaimEntry; export function releaseClaim(repoFullName: string, issueNumber: number): ClaimEntry | null; +export function expireClaim(repoFullName: string, issueNumber: number): ClaimEntry | null; + export function listClaims(filter?: ListClaimsFilter): ClaimEntry[]; export function closeDefaultClaimLedger(): void; diff --git a/packages/gittensory-miner/lib/claim-ledger.js b/packages/gittensory-miner/lib/claim-ledger.js index ca6ca24150..784cd97bd7 100644 --- a/packages/gittensory-miner/lib/claim-ledger.js +++ b/packages/gittensory-miner/lib/claim-ledger.js @@ -108,6 +108,9 @@ export function openClaimLedger(dbPath = resolveClaimLedgerDbPath()) { const releaseStatement = db.prepare( "UPDATE miner_claims SET status = 'released' WHERE repo_full_name = ? AND issue_number = ? AND status = 'active'", ); + const expireStatement = db.prepare( + "UPDATE miner_claims SET status = 'expired' WHERE repo_full_name = ? AND issue_number = ? AND status = 'active'", + ); const listAllStatement = db.prepare("SELECT * FROM miner_claims ORDER BY id ASC"); const listRepoStatement = db.prepare( "SELECT * FROM miner_claims WHERE repo_full_name = ? ORDER BY id ASC", @@ -143,6 +146,14 @@ export function openClaimLedger(dbPath = resolveClaimLedgerDbPath()) { const row = getStatement.get(normalizedRepo, normalizedIssue); return row ? rowToClaim(row) : null; }, + expireClaim(repoFullName, issueNumber) { + const normalizedRepo = normalizeRepoFullName(repoFullName); + const normalizedIssue = normalizeIssueNumber(issueNumber); + const result = expireStatement.run(normalizedRepo, normalizedIssue); + if (result.changes === 0) return null; + const row = getStatement.get(normalizedRepo, normalizedIssue); + return row ? rowToClaim(row) : null; + }, listClaims(filter = {}) { const repoFullName = filter.repoFullName === undefined ? undefined @@ -180,6 +191,10 @@ export function releaseClaim(repoFullName, issueNumber) { return getDefaultClaimLedger().releaseClaim(repoFullName, issueNumber); } +export function expireClaim(repoFullName, issueNumber) { + return getDefaultClaimLedger().expireClaim(repoFullName, issueNumber); +} + export function listClaims(filter) { return getDefaultClaimLedger().listClaims(filter); } diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index a4003e613d..2f5c90b3f8 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -31,7 +31,7 @@ "lib" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/claim-ledger.js && node --check lib/portfolio-queue.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/status.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/portfolio-queue.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/status.js" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-claim-ledger-expiry.test.ts b/test/unit/miner-claim-ledger-expiry.test.ts new file mode 100644 index 0000000000..b856b49e72 --- /dev/null +++ b/test/unit/miner-claim-ledger-expiry.test.ts @@ -0,0 +1,117 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + DEFAULT_MAX_CLAIM_AGE_MS, + findExpiredClaims, + sweepExpiredClaims, +} from "../../packages/gittensory-miner/lib/claim-ledger-expiry.js"; +import { + closeDefaultClaimLedger, + openClaimLedger, +} from "../../packages/gittensory-miner/lib/claim-ledger.js"; + +const roots: string[] = []; +const ledgers: Array<{ close(): void }> = []; + +function tempLedger() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-claim-expiry-")); + roots.push(root); + const ledger = openClaimLedger(join(root, "claim-ledger.sqlite3")); + ledgers.push(ledger); + return ledger; +} + +function claim(overrides: Record = {}) { + return { + id: 1, + repoFullName: "o/a", + issueNumber: 1, + claimedAt: "2026-01-01T00:00:00.000Z", + status: "active" as const, + note: null, + ...overrides, + }; +} + +afterEach(() => { + for (const ledger of ledgers.splice(0)) ledger.close(); + closeDefaultClaimLedger(); + vi.useRealTimers(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("gittensory-miner claim ledger expiry (#2316)", () => { + it("documents a 14-day default max age", () => { + expect(DEFAULT_MAX_CLAIM_AGE_MS).toBe(14 * 24 * 60 * 60 * 1000); + }); + + it("findExpiredClaims returns no rows when every active claim is within the window", () => { + const nowMs = Date.parse("2026-07-03T00:00:00.000Z"); + const maxAgeMs = 7 * 24 * 60 * 60 * 1000; + const claims = [ + claim({ issueNumber: 1, claimedAt: "2026-07-01T00:00:00.000Z" }), + claim({ issueNumber: 2, claimedAt: "2026-06-27T00:00:00.000Z" }), // age === maxAgeMs + ]; + expect(findExpiredClaims(claims, nowMs, maxAgeMs)).toEqual([]); + }); + + it("findExpiredClaims returns every stale active claim when all are older than maxAgeMs", () => { + const nowMs = Date.parse("2026-07-03T00:00:00.000Z"); + const maxAgeMs = 1 * 24 * 60 * 60 * 1000; + const staleA = claim({ issueNumber: 1, claimedAt: "2026-06-30T00:00:00.000Z" }); + const staleB = claim({ issueNumber: 2, claimedAt: "2026-06-01T00:00:00.000Z" }); + expect(findExpiredClaims([staleA, staleB], nowMs, maxAgeMs)).toEqual([staleA, staleB]); + }); + + it("findExpiredClaims ignores non-active rows and keeps only strictly stale actives in mixed input", () => { + const nowMs = Date.parse("2026-07-03T00:00:00.000Z"); + const maxAgeMs = 2 * 24 * 60 * 60 * 1000; + const fresh = claim({ issueNumber: 1, claimedAt: "2026-07-02T12:00:00.000Z" }); + const stale = claim({ issueNumber: 2, claimedAt: "2026-06-28T00:00:00.000Z" }); + const released = claim({ issueNumber: 3, claimedAt: "2026-01-01T00:00:00.000Z", status: "released" }); + expect(findExpiredClaims([fresh, stale, released], nowMs, maxAgeMs)).toEqual([stale]); + }); + + it("findExpiredClaims treats age === maxAgeMs as still active (boundary)", () => { + const nowMs = Date.parse("2026-07-10T00:00:00.000Z"); + const maxAgeMs = 7 * 24 * 60 * 60 * 1000; + const boundary = claim({ claimedAt: "2026-07-03T00:00:00.000Z" }); + const justExpired = claim({ issueNumber: 2, claimedAt: "2026-07-02T23:59:59.999Z" }); + expect(findExpiredClaims([boundary], nowMs, maxAgeMs)).toEqual([]); + expect(findExpiredClaims([justExpired], nowMs, maxAgeMs)).toEqual([justExpired]); + }); + + it("findExpiredClaims rejects invalid inputs", () => { + expect(() => findExpiredClaims([], Number.NaN, 1)).toThrow("invalid_now_ms"); + expect(() => findExpiredClaims([], 0, -1)).toThrow("invalid_max_age_ms"); + expect(() => findExpiredClaims(null as never, 0, 1)).toThrow("invalid_claims"); + }); + + it("sweepExpiredClaims transitions stale active rows to expired in SQLite", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-03T00:00:00.000Z")); + const ledger = tempLedger(); + const maxAgeMs = 7 * 24 * 60 * 60 * 1000; + const nowMs = Date.parse("2026-07-03T00:00:00.000Z"); + + vi.setSystemTime(new Date("2026-06-20T00:00:00.000Z")); + ledger.recordClaim({ repoFullName: "o/a", issueNumber: 1 }); + vi.setSystemTime(new Date("2026-07-02T00:00:00.000Z")); + ledger.recordClaim({ repoFullName: "o/a", issueNumber: 2 }); + + expect(sweepExpiredClaims(ledger, nowMs, maxAgeMs).map((entry) => entry.issueNumber)).toEqual([1]); + expect(ledger.listClaims({ status: "active" }).map((entry) => entry.issueNumber)).toEqual([2]); + expect(ledger.listClaims({ status: "expired" }).map((entry) => entry.issueNumber)).toEqual([1]); + expect(sweepExpiredClaims(ledger, nowMs, maxAgeMs)).toEqual([]); + }); + + it("expireClaim is a no-op for non-active rows and returns null on a second sweep", () => { + const ledger = tempLedger(); + ledger.recordClaim({ repoFullName: "o/a", issueNumber: 9 }); + ledger.releaseClaim("o/a", 9); + expect(ledger.expireClaim("o/a", 9)).toBeNull(); + expect(ledger.expireClaim("o/a", 404)).toBeNull(); + }); +});