From ff57f4558b3adbe3602c1ba0cd438da8978d4383 Mon Sep 17 00:00:00 2001 From: thomasalvaedison7777-lgtm Date: Thu, 16 Jul 2026 12:09:16 -0700 Subject: [PATCH] feat(purge): add purgeByRepo method to portfolio queue and run state stores - Implemented `purgeByRepo` method in both `PortfolioQueueStore` and `RunStateStore` to allow explicit deletion of all entries associated with a specific repository. - Updated related types and specifications in `store-maintenance` to accommodate the new purge functionality. - Enhanced CLI and test coverage to ensure proper functionality and error handling for the new purge feature. Closes #5564, #6599 --- .../loopover-miner/lib/portfolio-queue.d.ts | 1 + .../loopover-miner/lib/portfolio-queue.js | 5 + packages/loopover-miner/lib/purge-cli.d.ts | 4 + packages/loopover-miner/lib/purge-cli.js | 20 ++- packages/loopover-miner/lib/run-state.d.ts | 1 + packages/loopover-miner/lib/run-state.js | 5 + .../loopover-miner/lib/store-maintenance.d.ts | 2 + .../loopover-miner/lib/store-maintenance.js | 4 +- test/unit/miner-portfolio-queue.test.ts | 26 +++ test/unit/miner-purge-cli.test.ts | 159 ++++++++++++++++-- test/unit/miner-run-state.test.ts | 40 +++++ 11 files changed, 245 insertions(+), 22 deletions(-) diff --git a/packages/loopover-miner/lib/portfolio-queue.d.ts b/packages/loopover-miner/lib/portfolio-queue.d.ts index 9a316a0d10..7c2053e278 100644 --- a/packages/loopover-miner/lib/portfolio-queue.d.ts +++ b/packages/loopover-miner/lib/portfolio-queue.d.ts @@ -50,6 +50,7 @@ export type PortfolioQueueStore = { ) => Array<{ repoFullName: string; identifier: string; apiBaseUrl?: string }>, ): QueueEntry[]; getAttemptHistory(repoFullName: string, identifier: string, apiBaseUrl?: string): QueueAttemptHistory; + purgeByRepo(repoFullName: string): number; close(): void; }; diff --git a/packages/loopover-miner/lib/portfolio-queue.js b/packages/loopover-miner/lib/portfolio-queue.js index f93c43f9b3..63c8afbe3e 100644 --- a/packages/loopover-miner/lib/portfolio-queue.js +++ b/packages/loopover-miner/lib/portfolio-queue.js @@ -1,6 +1,7 @@ import { DEFAULT_FORGE_CONFIG } from "./forge-config.js"; import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; +import { PORTFOLIO_QUEUE_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js"; // The miner's local portfolio/queue store (#2292): a 100% client-side, prioritized backlog of candidate work // items across every repo the miner has been pointed at ("what should I look at next, across everything I'm @@ -385,6 +386,10 @@ export function initPortfolioQueueStore(dbPath = resolvePortfolioQueueDbPath()) reachedDone: row.status === "done", }; }, + // Explicit, operator-invoked right-to-be-forgotten purge (#5564, #6599) — never runs automatically. + purgeByRepo(repoFullName) { + return purgeStoreByRepo(db, PORTFOLIO_QUEUE_PURGE_SPEC, normalizeRepoFullName(repoFullName)); + }, close() { db.close(); }, diff --git a/packages/loopover-miner/lib/purge-cli.d.ts b/packages/loopover-miner/lib/purge-cli.d.ts index bbba9301bc..0f84a01e6d 100644 --- a/packages/loopover-miner/lib/purge-cli.d.ts +++ b/packages/loopover-miner/lib/purge-cli.d.ts @@ -2,6 +2,8 @@ import type { ClaimLedger } from "./claim-ledger.js"; import type { EventLedger } from "./event-ledger.js"; import type { GovernorLedger } from "./governor-ledger.js"; import type { PredictionLedger } from "./prediction-ledger.js"; +import type { PortfolioQueueStore } from "./portfolio-queue.js"; +import type { RunStateStore } from "./run-state.js"; export const ATTEMPT_LOG_NOT_PURGEABLE_NOTE: string; @@ -33,6 +35,8 @@ export type PurgeCliOptions = { initEventLedger?: () => EventLedger; initGovernorLedger?: () => GovernorLedger; initPredictionLedger?: () => PredictionLedger; + initPortfolioQueueStore?: () => PortfolioQueueStore; + initRunStateStore?: () => RunStateStore; resolveDbPaths?: Record string>; }; diff --git a/packages/loopover-miner/lib/purge-cli.js b/packages/loopover-miner/lib/purge-cli.js index 91b2bc81e1..bfcd09cf3f 100644 --- a/packages/loopover-miner/lib/purge-cli.js +++ b/packages/loopover-miner/lib/purge-cli.js @@ -1,10 +1,10 @@ -// `loopover-miner purge` (#5564): an explicit, operator-invoked right-to-be-forgotten path across the local -// ledgers. Deletes every row for one repo from the four stores that have a real `repoColumn` (claim-ledger, -// event-ledger, governor-ledger, prediction-ledger), via each store's own `purgeByRepo` method (which reuses -// `store-maintenance.js`'s shared, identifier-guarded `purgeStoreByRepo`). `attempt-log.js` is deliberately -// reported as not-purgeable rather than silently skipped or approximated: its payload is a free-form -// `Record` with no dedicated repo column, so a precise per-repo match isn't possible there -// without risking false matches -- see store-maintenance.js's own purge-spec doc comment. +// `loopover-miner purge` (#5564, #6599): an explicit, operator-invoked right-to-be-forgotten path across the local +// ledgers. Deletes every row for one repo from the six stores that have a real `repoColumn` (claim-ledger, +// event-ledger, governor-ledger, prediction-ledger, portfolio-queue, run-state), via each store's own +// `purgeByRepo` method (which reuses `store-maintenance.js`'s shared, identifier-guarded `purgeStoreByRepo`). +// `attempt-log.js` is deliberately reported as not-purgeable rather than silently skipped or approximated: its +// payload is a free-form `Record` with no dedicated repo column, so a precise per-repo match +// isn't possible there without risking false matches -- see store-maintenance.js's own purge-spec doc comment. // // Every purge is audit-observable by design (#5564's own acceptance criteria): the real (non-dry-run) path // always prints a per-store summary, even under --json, so a purge can never be silent. A failure in one store @@ -15,12 +15,16 @@ import { openClaimLedger, resolveClaimLedgerDbPath } from "./claim-ledger.js"; import { initEventLedger, resolveEventLedgerDbPath } from "./event-ledger.js"; import { initGovernorLedger, resolveGovernorLedgerDbPath } from "./governor-ledger.js"; import { initPredictionLedger, resolvePredictionLedgerDbPath } from "./prediction-ledger.js"; +import { initPortfolioQueueStore, resolvePortfolioQueueDbPath } from "./portfolio-queue.js"; +import { initRunStateStore, resolveRunStateDbPath } from "./run-state.js"; import { resolveAttemptLogDbPath } from "./attempt-log.js"; import { CLAIM_LEDGER_PURGE_SPEC, EVENT_LEDGER_PURGE_SPEC, GOVERNOR_LEDGER_PURGE_SPEC, PREDICTION_LEDGER_PURGE_SPEC, + PORTFOLIO_QUEUE_PURGE_SPEC, + RUN_STATE_PURGE_SPEC, countStoreByRepo, describeError, } from "./store-maintenance.js"; @@ -36,6 +40,8 @@ const REAL_PURGE_TARGETS = [ { name: "event-ledger", optionKey: "initEventLedger", opener: initEventLedger, resolveDbPath: resolveEventLedgerDbPath, spec: EVENT_LEDGER_PURGE_SPEC }, { name: "governor-ledger", optionKey: "initGovernorLedger", opener: initGovernorLedger, resolveDbPath: resolveGovernorLedgerDbPath, spec: GOVERNOR_LEDGER_PURGE_SPEC }, { name: "prediction-ledger", optionKey: "initPredictionLedger", opener: initPredictionLedger, resolveDbPath: resolvePredictionLedgerDbPath, spec: PREDICTION_LEDGER_PURGE_SPEC }, + { name: "portfolio-queue", optionKey: "initPortfolioQueueStore", opener: initPortfolioQueueStore, resolveDbPath: resolvePortfolioQueueDbPath, spec: PORTFOLIO_QUEUE_PURGE_SPEC }, + { name: "run-state", optionKey: "initRunStateStore", opener: initRunStateStore, resolveDbPath: resolveRunStateDbPath, spec: RUN_STATE_PURGE_SPEC }, ]; function parseRepoArg(value, usage) { diff --git a/packages/loopover-miner/lib/run-state.d.ts b/packages/loopover-miner/lib/run-state.d.ts index 2446e12142..fb1bed014d 100644 --- a/packages/loopover-miner/lib/run-state.d.ts +++ b/packages/loopover-miner/lib/run-state.d.ts @@ -19,6 +19,7 @@ export type RunStateStore = { getRunState(repoFullName: string, apiBaseUrl?: string): RunState | null; setRunState(repoFullName: string, state: RunState, apiBaseUrl?: string): RunStateWrite; listRunStates(): RunStateRow[]; + purgeByRepo(repoFullName: string): number; close(): void; }; diff --git a/packages/loopover-miner/lib/run-state.js b/packages/loopover-miner/lib/run-state.js index de9bfaa843..88fe406b86 100644 --- a/packages/loopover-miner/lib/run-state.js +++ b/packages/loopover-miner/lib/run-state.js @@ -1,6 +1,7 @@ import { DEFAULT_FORGE_CONFIG } from "./forge-config.js"; import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; +import { RUN_STATE_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js"; export const RUN_STATES = Object.freeze(["idle", "discovering", "planning", "preparing"]); @@ -133,6 +134,10 @@ export function initRunStateStore(dbPath = resolveRunStateDbPath()) { updatedAt: row.updated_at, })); }, + // Explicit, operator-invoked right-to-be-forgotten purge (#5564, #6599) — never runs automatically. + purgeByRepo(repoFullName) { + return purgeStoreByRepo(db, RUN_STATE_PURGE_SPEC, normalizeRepoFullName(repoFullName)); + }, close() { db.close(); }, diff --git a/packages/loopover-miner/lib/store-maintenance.d.ts b/packages/loopover-miner/lib/store-maintenance.d.ts index f54483b6bb..438c38d383 100644 --- a/packages/loopover-miner/lib/store-maintenance.d.ts +++ b/packages/loopover-miner/lib/store-maintenance.d.ts @@ -13,6 +13,8 @@ export const CLAIM_LEDGER_PURGE_SPEC: LedgerPurgeSpec; export const EVENT_LEDGER_PURGE_SPEC: LedgerPurgeSpec; export const GOVERNOR_LEDGER_PURGE_SPEC: LedgerPurgeSpec; export const PREDICTION_LEDGER_PURGE_SPEC: LedgerPurgeSpec; +export const PORTFOLIO_QUEUE_PURGE_SPEC: LedgerPurgeSpec; +export const RUN_STATE_PURGE_SPEC: LedgerPurgeSpec; export type StoreIntegrityResult = { name: string; ok: boolean; detail: string }; export type LedgerRetentionPolicy = { maxAgeMs?: number; maxRows?: number }; diff --git a/packages/loopover-miner/lib/store-maintenance.js b/packages/loopover-miner/lib/store-maintenance.js index 073d2a1bfe..70a4243212 100644 --- a/packages/loopover-miner/lib/store-maintenance.js +++ b/packages/loopover-miner/lib/store-maintenance.js @@ -24,7 +24,7 @@ export const EVENT_LEDGER_RETENTION_SPEC = { table: "miner_event_ledger", timest export const GOVERNOR_LEDGER_RETENTION_SPEC = { table: "governor_events", timestampColumn: "ts", orderColumn: "id" }; export const PREDICTION_LEDGER_RETENTION_SPEC = { table: "predictions", timestampColumn: "ts", orderColumn: "id" }; -/** Fixed purge specs (#5564) for the four stores whose rows are directly scoped by a `repoColumn`. Same +/** Fixed purge specs (#5564, #6599) for the six stores whose rows are directly scoped by a `repoColumn`. Same * internal-constant-only discipline as the retention specs above. `attempt-log.js` is deliberately absent: its * payload is a free-form `Record` with no dedicated repo column, so a precise per-repo purge * isn't possible there without risking false matches — `purge-cli.js` reports it as not-purgeable instead. */ @@ -32,6 +32,8 @@ export const CLAIM_LEDGER_PURGE_SPEC = { table: "miner_claims", repoColumn: "rep export const EVENT_LEDGER_PURGE_SPEC = { table: "miner_event_ledger", repoColumn: "repo_full_name" }; export const GOVERNOR_LEDGER_PURGE_SPEC = { table: "governor_events", repoColumn: "repo_full_name" }; export const PREDICTION_LEDGER_PURGE_SPEC = { table: "predictions", repoColumn: "repo_full_name" }; +export const PORTFOLIO_QUEUE_PURGE_SPEC = { table: "miner_portfolio_queue", repoColumn: "repo_full_name" }; +export const RUN_STATE_PURGE_SPEC = { table: "miner_run_state", repoColumn: "repo_full_name" }; const SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; diff --git a/test/unit/miner-portfolio-queue.test.ts b/test/unit/miner-portfolio-queue.test.ts index 84f4b70d3d..631d0dd299 100644 --- a/test/unit/miner-portfolio-queue.test.ts +++ b/test/unit/miner-portfolio-queue.test.ts @@ -644,4 +644,30 @@ describe("loopover-miner portfolio/queue store (#2292)", () => { }).not.toThrow(); }); }); + + describe("purgeByRepo (#5564, #6599)", () => { + it("deletes every queue row for one repo and leaves other repos untouched", () => { + const store = tempStore(); + store.enqueue({ repoFullName: "owner/repo-a", identifier: "1" }); + store.enqueue({ repoFullName: "owner/repo-a", identifier: "2" }); + store.enqueue({ repoFullName: "owner/repo-b", identifier: "3" }); + + expect(store.purgeByRepo("owner/repo-a")).toBe(2); + expect(store.listQueue("owner/repo-a")).toEqual([]); + expect(store.listQueue()).toHaveLength(1); + }); + + it("returns 0 when nothing matches the repo", () => { + const store = tempStore(); + store.enqueue({ repoFullName: "owner/repo-b", identifier: "1" }); + expect(store.purgeByRepo("owner/repo-a")).toBe(0); + expect(store.listQueue()).toHaveLength(1); + }); + + it("rejects a missing/malformed repoFullName rather than silently no-opping", () => { + const store = tempStore(); + expect(() => store.purgeByRepo(undefined as never)).toThrow("invalid_repo_full_name"); + expect(() => store.purgeByRepo("no-slash")).toThrow("invalid_repo_full_name"); + }); + }); }); diff --git a/test/unit/miner-purge-cli.test.ts b/test/unit/miner-purge-cli.test.ts index 89f6d79385..d6429b68bf 100644 --- a/test/unit/miner-purge-cli.test.ts +++ b/test/unit/miner-purge-cli.test.ts @@ -6,6 +6,11 @@ import { openClaimLedger, closeDefaultClaimLedger } from "../../packages/loopove import { initEventLedger, closeDefaultEventLedger } from "../../packages/loopover-miner/lib/event-ledger.js"; import { initGovernorLedger, closeDefaultGovernorLedger } from "../../packages/loopover-miner/lib/governor-ledger.js"; import { initPredictionLedger, closeDefaultPredictionLedger } from "../../packages/loopover-miner/lib/prediction-ledger.js"; +import { + initPortfolioQueueStore, + closeDefaultPortfolioQueueStore, +} from "../../packages/loopover-miner/lib/portfolio-queue.js"; +import { initRunStateStore, closeDefaultRunStateStore } from "../../packages/loopover-miner/lib/run-state.js"; import { initAttemptLog, closeDefaultAttemptLog } from "../../packages/loopover-miner/lib/attempt-log.js"; import { ATTEMPT_LOG_NOT_PURGEABLE_NOTE, @@ -28,6 +33,8 @@ afterEach(() => { closeDefaultEventLedger(); closeDefaultGovernorLedger(); closeDefaultPredictionLedger(); + closeDefaultPortfolioQueueStore(); + closeDefaultRunStateStore(); closeDefaultAttemptLog(); vi.restoreAllMocks(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); @@ -68,13 +75,15 @@ describe("parsePurgeArgs (#5564)", () => { }); }); -describe("runPurge --dry-run (#5564)", () => { - it("counts matching rows across the four real stores without writing anything, and reports attempt-log as not-purgeable", async () => { +describe("runPurge --dry-run (#5564, #6599)", () => { + it("counts matching rows across the six real stores without writing anything, and reports attempt-log as not-purgeable", async () => { const root = tempDir(); const claimDbPath = join(root, "claim-ledger.sqlite3"); const eventDbPath = join(root, "event-ledger.sqlite3"); const governorDbPath = join(root, "governor-ledger.sqlite3"); const predictionDbPath = join(root, "prediction-ledger.sqlite3"); + const portfolioDbPath = join(root, "portfolio-queue.sqlite3"); + const runStateDbPath = join(root, "run-state.sqlite3"); const attemptLogDbPath = join(root, "attempt-log.sqlite3"); // never created — dry run must not touch it const claimLedger = openClaimLedger(claimDbPath); @@ -108,11 +117,24 @@ describe("runPurge --dry-run (#5564)", () => { }); predictionLedger.close(); + const portfolioQueue = initPortfolioQueueStore(portfolioDbPath); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue-1" }); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue-2" }); + portfolioQueue.enqueue({ repoFullName: "acme/other", identifier: "issue-3" }); + portfolioQueue.close(); + + const runState = initRunStateStore(runStateDbPath); + runState.setRunState("acme/widgets", "planning"); + runState.setRunState("acme/other", "idle"); + runState.close(); + const resolveDbPaths = { "claim-ledger": () => claimDbPath, "event-ledger": () => eventDbPath, "governor-ledger": () => governorDbPath, "prediction-ledger": () => predictionDbPath, + "portfolio-queue": () => portfolioDbPath, + "run-state": () => runStateDbPath, "attempt-log": () => attemptLogDbPath, }; @@ -127,6 +149,8 @@ describe("runPurge --dry-run (#5564)", () => { { store: "event-ledger", wouldPurge: 1 }, { store: "governor-ledger", wouldPurge: 1 }, { store: "prediction-ledger", wouldPurge: 0 }, + { store: "portfolio-queue", wouldPurge: 2 }, + { store: "run-state", wouldPurge: 1 }, ], attemptLogNote: ATTEMPT_LOG_NOT_PURGEABLE_NOTE, attemptLogTotalRows: 0, @@ -142,6 +166,8 @@ describe("runPurge --dry-run (#5564)", () => { const text = String(log.mock.calls[0]?.[0]); expect(text).toContain("DRY RUN: would purge acme/widgets from:"); expect(text).toContain("claim-ledger=2"); + expect(text).toContain("portfolio-queue=2"); + expect(text).toContain("run-state=1"); expect(text).toContain(ATTEMPT_LOG_NOT_PURGEABLE_NOTE); }); @@ -152,11 +178,14 @@ describe("runPurge --dry-run (#5564)", () => { "event-ledger": () => join(root, "event-ledger.sqlite3"), "governor-ledger": () => join(root, "governor-ledger.sqlite3"), "prediction-ledger": () => join(root, "prediction-ledger.sqlite3"), + "portfolio-queue": () => join(root, "portfolio-queue.sqlite3"), + "run-state": () => join(root, "run-state.sqlite3"), "attempt-log": () => join(root, "attempt-log.sqlite3"), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--dry-run", "--json"], { resolveDbPaths })).toBe(0); const result = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(result.stores).toHaveLength(6); expect(result.stores.every((entry: { wouldPurge: number }) => entry.wouldPurge === 0)).toBe(true); expect(result.attemptLogTotalRows).toBe(0); for (const resolve of Object.values(resolveDbPaths)) { @@ -189,6 +218,8 @@ describe("runPurge --dry-run (#5564)", () => { "event-ledger": () => join(root, "event-ledger.sqlite3"), "governor-ledger": () => join(root, "governor-ledger.sqlite3"), "prediction-ledger": () => join(root, "prediction-ledger.sqlite3"), + "portfolio-queue": () => join(root, "portfolio-queue.sqlite3"), + "run-state": () => join(root, "run-state.sqlite3"), "attempt-log": () => attemptLogDbPath, }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -212,6 +243,8 @@ describe("runPurge --dry-run (#5564)", () => { "event-ledger": () => eventDbPath, "governor-ledger": () => join(root, "governor-ledger.sqlite3"), "prediction-ledger": () => join(root, "prediction-ledger.sqlite3"), + "portfolio-queue": () => join(root, "portfolio-queue.sqlite3"), + "run-state": () => join(root, "run-state.sqlite3"), "attempt-log": () => join(root, "attempt-log.sqlite3"), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -248,17 +281,22 @@ describe("runPurge --dry-run (#5564)", () => { LOOPOVER_MINER_EVENT_LEDGER_DB: process.env.LOOPOVER_MINER_EVENT_LEDGER_DB, LOOPOVER_MINER_GOVERNOR_LEDGER_DB: process.env.LOOPOVER_MINER_GOVERNOR_LEDGER_DB, LOOPOVER_MINER_PREDICTION_LEDGER_DB: process.env.LOOPOVER_MINER_PREDICTION_LEDGER_DB, + LOOPOVER_MINER_PORTFOLIO_QUEUE_DB: process.env.LOOPOVER_MINER_PORTFOLIO_QUEUE_DB, + LOOPOVER_MINER_RUN_STATE_DB: process.env.LOOPOVER_MINER_RUN_STATE_DB, LOOPOVER_MINER_ATTEMPT_LOG_DB: process.env.LOOPOVER_MINER_ATTEMPT_LOG_DB, }; process.env.LOOPOVER_MINER_CLAIM_LEDGER_DB = join(root, "claim-ledger.sqlite3"); process.env.LOOPOVER_MINER_EVENT_LEDGER_DB = join(root, "event-ledger.sqlite3"); process.env.LOOPOVER_MINER_GOVERNOR_LEDGER_DB = join(root, "governor-ledger.sqlite3"); process.env.LOOPOVER_MINER_PREDICTION_LEDGER_DB = join(root, "prediction-ledger.sqlite3"); + process.env.LOOPOVER_MINER_PORTFOLIO_QUEUE_DB = join(root, "portfolio-queue.sqlite3"); + process.env.LOOPOVER_MINER_RUN_STATE_DB = join(root, "run-state.sqlite3"); process.env.LOOPOVER_MINER_ATTEMPT_LOG_DB = join(root, "attempt-log.sqlite3"); try { const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--dry-run", "--json"])).toBe(0); const result = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(result.stores).toHaveLength(6); expect(result.stores.every((entry: { wouldPurge: number }) => entry.wouldPurge === 0)).toBe(true); // Nothing was created — dry run against nonexistent default-path stores makes zero writes. expect(existsSync(process.env.LOOPOVER_MINER_CLAIM_LEDGER_DB)).toBe(false); @@ -271,7 +309,7 @@ describe("runPurge --dry-run (#5564)", () => { }); }); -describe("runPurge (real, #5564)", () => { +describe("runPurge (real, #5564, #6599)", () => { function fakeStore(purged: number) { const store = { purgeByRepo: vi.fn(() => purged), close: vi.fn() }; closeables.push(store); @@ -283,11 +321,15 @@ describe("runPurge (real, #5564)", () => { const event = fakeStore(1); const governor = fakeStore(0); const prediction = fakeStore(3); + const portfolio = fakeStore(4); + const runState = fakeStore(1); const options = { openClaimLedger: () => claim, initEventLedger: () => event, initGovernorLedger: () => governor, initPredictionLedger: () => prediction, + initPortfolioQueueStore: () => portfolio, + initRunStateStore: () => runState, }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -296,29 +338,33 @@ describe("runPurge (real, #5564)", () => { expect(summary).toMatchObject({ outcome: "purged", repoFullName: "acme/widgets", - totalPurged: 6, + totalPurged: 11, stores: [ { store: "claim-ledger", purged: 2 }, { store: "event-ledger", purged: 1 }, { store: "governor-ledger", purged: 0 }, { store: "prediction-ledger", purged: 3 }, + { store: "portfolio-queue", purged: 4 }, + { store: "run-state", purged: 1 }, { store: "attempt-log", purged: null, note: ATTEMPT_LOG_NOT_PURGEABLE_NOTE }, ], }); expect(typeof summary.purgedAt).toBe("string"); - for (const store of [claim, event, governor, prediction]) { + for (const store of [claim, event, governor, prediction, portfolio, runState]) { expect(store.purgeByRepo).toHaveBeenCalledWith("acme/widgets"); } // Injected stores are caller-owned: runPurge must not close them. - for (const store of [claim, event, governor, prediction]) { + for (const store of [claim, event, governor, prediction, portfolio, runState]) { expect(store.close).not.toHaveBeenCalled(); } log.mockClear(); expect(runPurge(["--repo", "acme/widgets"], options as never)).toBe(0); const text = String(log.mock.calls[0]?.[0]); - expect(text).toContain("Purged 6 row(s) for acme/widgets"); + expect(text).toContain("Purged 11 row(s) for acme/widgets"); expect(text).toContain("claim-ledger=2"); + expect(text).toContain("portfolio-queue=4"); + expect(text).toContain("run-state=1"); expect(text).toContain(ATTEMPT_LOG_NOT_PURGEABLE_NOTE); }); @@ -327,6 +373,8 @@ describe("runPurge (real, #5564)", () => { const event = fakeStore(1); const governorOpenError = new Error("governor-ledger disk full"); const prediction = fakeStore(3); + const portfolio = fakeStore(0); + const runState = fakeStore(0); const options = { openClaimLedger: () => claim, initEventLedger: () => event, @@ -334,6 +382,8 @@ describe("runPurge (real, #5564)", () => { throw governorOpenError; }, initPredictionLedger: () => prediction, + initPortfolioQueueStore: () => portfolio, + initRunStateStore: () => runState, }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); @@ -345,6 +395,8 @@ describe("runPurge (real, #5564)", () => { expect(summary.stores).toContainEqual({ store: "claim-ledger", purged: 2 }); expect(summary.stores).toContainEqual({ store: "event-ledger", purged: 1 }); expect(summary.stores).toContainEqual({ store: "prediction-ledger", purged: 3 }); + expect(summary.stores).toContainEqual({ store: "portfolio-queue", purged: 0 }); + expect(summary.stores).toContainEqual({ store: "run-state", purged: 0 }); expect(summary.stores).toContainEqual({ store: "governor-ledger", purged: null, error: "governor-ledger disk full" }); log.mockClear(); @@ -363,6 +415,8 @@ describe("runPurge (real, #5564)", () => { }, initGovernorLedger: () => fakeStore(0), initPredictionLedger: () => fakeStore(0), + initPortfolioQueueStore: () => fakeStore(0), + initRunStateStore: () => fakeStore(0), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--json"], options as never)).toBe(2); @@ -378,6 +432,8 @@ describe("runPurge (real, #5564)", () => { initEventLedger: () => fakeStore(0), initGovernorLedger: () => fakeStore(0), initPredictionLedger: () => fakeStore(0), + initPortfolioQueueStore: () => fakeStore(0), + initRunStateStore: () => fakeStore(0), }; const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--json"], options as never)).toBe(2); @@ -395,17 +451,29 @@ describe("runPurge (real, #5564)", () => { LOOPOVER_MINER_EVENT_LEDGER_DB: process.env.LOOPOVER_MINER_EVENT_LEDGER_DB, LOOPOVER_MINER_GOVERNOR_LEDGER_DB: process.env.LOOPOVER_MINER_GOVERNOR_LEDGER_DB, LOOPOVER_MINER_PREDICTION_LEDGER_DB: process.env.LOOPOVER_MINER_PREDICTION_LEDGER_DB, + LOOPOVER_MINER_PORTFOLIO_QUEUE_DB: process.env.LOOPOVER_MINER_PORTFOLIO_QUEUE_DB, + LOOPOVER_MINER_RUN_STATE_DB: process.env.LOOPOVER_MINER_RUN_STATE_DB, }; const claimDbPath = join(root, "claim-ledger.sqlite3"); + const portfolioDbPath = join(root, "portfolio-queue.sqlite3"); + const runStateDbPath = join(root, "run-state.sqlite3"); process.env.LOOPOVER_MINER_CLAIM_LEDGER_DB = claimDbPath; process.env.LOOPOVER_MINER_EVENT_LEDGER_DB = join(root, "event-ledger.sqlite3"); process.env.LOOPOVER_MINER_GOVERNOR_LEDGER_DB = join(root, "governor-ledger.sqlite3"); process.env.LOOPOVER_MINER_PREDICTION_LEDGER_DB = join(root, "prediction-ledger.sqlite3"); + process.env.LOOPOVER_MINER_PORTFOLIO_QUEUE_DB = portfolioDbPath; + process.env.LOOPOVER_MINER_RUN_STATE_DB = runStateDbPath; try { - // Seed a real claim via the default store path before purging through it. - const seeded = openClaimLedger(claimDbPath); - seeded.claimIssue("acme/widgets", 1); - seeded.close(); + // Seed real rows via the default store paths before purging through them. + const seededClaim = openClaimLedger(claimDbPath); + seededClaim.claimIssue("acme/widgets", 1); + seededClaim.close(); + const seededPortfolio = initPortfolioQueueStore(portfolioDbPath); + seededPortfolio.enqueue({ repoFullName: "acme/widgets", identifier: "issue-1" }); + seededPortfolio.close(); + const seededRunState = initRunStateStore(runStateDbPath); + seededRunState.setRunState("acme/widgets", "planning"); + seededRunState.close(); const log = vi.spyOn(console, "log").mockImplementation(() => undefined); expect(runPurge(["--repo", "acme/widgets", "--json"])).toBe(0); @@ -413,11 +481,23 @@ describe("runPurge (real, #5564)", () => { expect(summary.stores.find((entry: { store: string }) => entry.store === "claim-ledger")).toMatchObject({ purged: 1, }); + expect(summary.stores.find((entry: { store: string }) => entry.store === "portfolio-queue")).toMatchObject({ + purged: 1, + }); + expect(summary.stores.find((entry: { store: string }) => entry.store === "run-state")).toMatchObject({ + purged: 1, + }); // Reopening confirms the purge was actually persisted through the default (owned, closed) code path. - const reopened = openClaimLedger(claimDbPath); - closeables.push(reopened); - expect(reopened.listClaims()).toEqual([]); + const reopenedClaim = openClaimLedger(claimDbPath); + closeables.push(reopenedClaim); + expect(reopenedClaim.listClaims()).toEqual([]); + const reopenedPortfolio = initPortfolioQueueStore(portfolioDbPath); + closeables.push(reopenedPortfolio); + expect(reopenedPortfolio.listQueue()).toEqual([]); + const reopenedRunState = initRunStateStore(runStateDbPath); + closeables.push(reopenedRunState); + expect(reopenedRunState.listRunStates()).toEqual([]); } finally { for (const [key, value] of Object.entries(previousDirs)) { if (value === undefined) delete process.env[key]; @@ -425,4 +505,55 @@ describe("runPurge (real, #5564)", () => { } } }); + + it("REGRESSION (#6599): dry-run and real purge both report portfolio-queue and run-state in per-store output", () => { + const root = tempDir(); + const portfolioDbPath = join(root, "portfolio-queue.sqlite3"); + const runStateDbPath = join(root, "run-state.sqlite3"); + + const portfolio = initPortfolioQueueStore(portfolioDbPath); + portfolio.enqueue({ repoFullName: "acme/widgets", identifier: "a" }); + portfolio.enqueue({ repoFullName: "acme/widgets", identifier: "b" }); + portfolio.close(); + + const runState = initRunStateStore(runStateDbPath); + runState.setRunState("acme/widgets", "discovering"); + runState.close(); + + const resolveDbPaths = { + "claim-ledger": () => join(root, "claim-ledger.sqlite3"), + "event-ledger": () => join(root, "event-ledger.sqlite3"), + "governor-ledger": () => join(root, "governor-ledger.sqlite3"), + "prediction-ledger": () => join(root, "prediction-ledger.sqlite3"), + "portfolio-queue": () => portfolioDbPath, + "run-state": () => runStateDbPath, + "attempt-log": () => join(root, "attempt-log.sqlite3"), + }; + + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + expect(runPurge(["--repo", "acme/widgets", "--dry-run", "--json"], { resolveDbPaths })).toBe(0); + const dryRun = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(dryRun.stores).toContainEqual({ store: "portfolio-queue", wouldPurge: 2 }); + expect(dryRun.stores).toContainEqual({ store: "run-state", wouldPurge: 1 }); + + log.mockClear(); + const portfolioStore = initPortfolioQueueStore(portfolioDbPath); + const runStateStore = initRunStateStore(runStateDbPath); + closeables.push(portfolioStore, runStateStore); + expect( + runPurge(["--repo", "acme/widgets", "--json"], { + openClaimLedger: () => fakeStore(0), + initEventLedger: () => fakeStore(0), + initGovernorLedger: () => fakeStore(0), + initPredictionLedger: () => fakeStore(0), + initPortfolioQueueStore: () => portfolioStore, + initRunStateStore: () => runStateStore, + } as never), + ).toBe(0); + const purged = JSON.parse(String(log.mock.calls[0]?.[0])); + expect(purged.stores).toContainEqual({ store: "portfolio-queue", purged: 2 }); + expect(purged.stores).toContainEqual({ store: "run-state", purged: 1 }); + expect(portfolioStore.listQueue()).toEqual([]); + expect(runStateStore.listRunStates()).toEqual([]); + }); }); diff --git a/test/unit/miner-run-state.test.ts b/test/unit/miner-run-state.test.ts index 6d84e31f42..90b1bc7fa1 100644 --- a/test/unit/miner-run-state.test.ts +++ b/test/unit/miner-run-state.test.ts @@ -359,4 +359,44 @@ describe("loopover-miner run-state store (#2289)", () => { }).not.toThrow(); }); }); + + describe("purgeByRepo (#5564, #6599)", () => { + it("deletes every run-state row for one repo and leaves other repos untouched", () => { + const store = initRunStateStore(join(tempRoot(), "run-state.sqlite3")); + try { + store.setRunState("owner/repo-a", "planning"); + store.setRunState("owner/repo-a", "preparing", "https://ghe.example.com/api/v3"); + store.setRunState("owner/repo-b", "idle"); + + expect(store.purgeByRepo("owner/repo-a")).toBe(2); + expect(store.getRunState("owner/repo-a")).toBeNull(); + expect(store.getRunState("owner/repo-a", "https://ghe.example.com/api/v3")).toBeNull(); + expect(store.listRunStates()).toHaveLength(1); + expect(store.getRunState("owner/repo-b")).toBe("idle"); + } finally { + store.close(); + } + }); + + it("returns 0 when nothing matches the repo", () => { + const store = initRunStateStore(join(tempRoot(), "run-state.sqlite3")); + try { + store.setRunState("owner/repo-b", "planning"); + expect(store.purgeByRepo("owner/repo-a")).toBe(0); + expect(store.listRunStates()).toHaveLength(1); + } finally { + store.close(); + } + }); + + it("rejects a missing/malformed repoFullName rather than silently no-opping", () => { + const store = initRunStateStore(join(tempRoot(), "run-state.sqlite3")); + try { + expect(() => store.purgeByRepo(undefined as never)).toThrow("invalid_repo_full_name"); + expect(() => store.purgeByRepo("no-slash")).toThrow("invalid_repo_full_name"); + } finally { + store.close(); + } + }); + }); });