Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/gittensory-miner/lib/claim-ledger-expiry.d.ts
Original file line number Diff line number Diff line change
@@ -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[];
39 changes: 39 additions & 0 deletions packages/gittensory-miner/lib/claim-ledger-expiry.js
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions packages/gittensory-miner/lib/claim-ledger.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -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;
15 changes: 15 additions & 0 deletions packages/gittensory-miner/lib/claim-ledger.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
117 changes: 117 additions & 0 deletions test/unit/miner-claim-ledger-expiry.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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();
});
});
Loading