From 1388a82345892838ae02e826155c3d588178fc59 Mon Sep 17 00:00:00 2001 From: oktofeesh1 <287075021+oktofeesh1@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:46:01 -0700 Subject: [PATCH] feat(miner): add local run-state store --- package-lock.json | 2 +- packages/gittensory-miner/lib/run-state.d.ts | 26 +++ packages/gittensory-miner/lib/run-state.js | 112 +++++++++++++ packages/gittensory-miner/package.json | 4 +- test/unit/miner-run-state.test.ts | 158 +++++++++++++++++++ 5 files changed, 299 insertions(+), 3 deletions(-) create mode 100644 packages/gittensory-miner/lib/run-state.d.ts create mode 100644 packages/gittensory-miner/lib/run-state.js create mode 100644 test/unit/miner-run-state.test.ts diff --git a/package-lock.json b/package-lock.json index 5296f75ee2..1dc8f914b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15537,7 +15537,7 @@ "gittensory-miner": "bin/gittensory-miner.js" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.13.0" } } } diff --git a/packages/gittensory-miner/lib/run-state.d.ts b/packages/gittensory-miner/lib/run-state.d.ts new file mode 100644 index 0000000000..7719f3e633 --- /dev/null +++ b/packages/gittensory-miner/lib/run-state.d.ts @@ -0,0 +1,26 @@ +export type RunState = "idle" | "discovering" | "planning" | "preparing"; + +export type RunStateWrite = { + repoFullName: string; + state: RunState; + updatedAt: string; +}; + +export type RunStateStore = { + dbPath: string; + getRunState(repoFullName: string): RunState | null; + setRunState(repoFullName: string, state: RunState): RunStateWrite; + close(): void; +}; + +export const RUN_STATES: readonly RunState[]; + +export function resolveRunStateDbPath(env?: Record): string; + +export function initRunStateStore(dbPath?: string): RunStateStore; + +export function getRunState(repoFullName: string): RunState | null; + +export function setRunState(repoFullName: string, state: RunState): RunStateWrite; + +export function closeDefaultRunStateStore(): void; diff --git a/packages/gittensory-miner/lib/run-state.js b/packages/gittensory-miner/lib/run-state.js new file mode 100644 index 0000000000..3d2850423c --- /dev/null +++ b/packages/gittensory-miner/lib/run-state.js @@ -0,0 +1,112 @@ +import { chmodSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +export const RUN_STATES = Object.freeze(["idle", "discovering", "planning", "preparing"]); + +const runStateSet = new Set(RUN_STATES); +const defaultDbFileName = "run-state.sqlite3"; +let defaultRunStateStore = null; + +export function resolveRunStateDbPath(env = process.env) { + const explicitPath = typeof env.GITTENSORY_MINER_RUN_STATE_DB === "string" + ? env.GITTENSORY_MINER_RUN_STATE_DB.trim() + : ""; + if (explicitPath) return explicitPath; + + const explicitConfigDir = typeof env.GITTENSORY_MINER_CONFIG_DIR === "string" + ? env.GITTENSORY_MINER_CONFIG_DIR.trim() + : ""; + if (explicitConfigDir) return join(explicitConfigDir, defaultDbFileName); + + const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim() + ? env.XDG_CONFIG_HOME.trim() + : join(homedir(), ".config"); + return join(configHome, "gittensory-miner", defaultDbFileName); +} + +function normalizeDbPath(dbPath) { + const path = (dbPath ?? resolveRunStateDbPath()).trim(); + if (!path) throw new Error("invalid_run_state_db_path"); + return path; +} + +function normalizeRepoFullName(repoFullName) { + if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); + const trimmed = repoFullName.trim(); + const [owner, repo, extra] = trimmed.split("/"); + if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name"); + return `${owner}/${repo}`; +} + +function normalizeRunState(state) { + if (runStateSet.has(state)) return state; + throw new Error("invalid_run_state"); +} + +/** + * Opens the 100% local/client-side miner run-state store. The database only lives on this machine; + * this module never uploads, syncs, or phones home with its contents. (#2289) + */ +export function initRunStateStore(dbPath = resolveRunStateDbPath()) { + const resolvedPath = normalizeDbPath(dbPath); + mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o700 }); + const db = new DatabaseSync(resolvedPath); + chmodSync(resolvedPath, 0o600); + db.exec(` + CREATE TABLE IF NOT EXISTS miner_run_state ( + repo_full_name TEXT PRIMARY KEY, + state TEXT NOT NULL CHECK (state IN ('idle', 'discovering', 'planning', 'preparing')), + updated_at TEXT NOT NULL + ) + `); + + const getStatement = db.prepare( + "SELECT state FROM miner_run_state WHERE repo_full_name = ?", + ); + const setStatement = db.prepare(` + INSERT INTO miner_run_state (repo_full_name, state, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(repo_full_name) DO UPDATE SET + state = excluded.state, + updated_at = excluded.updated_at + `); + + return { + dbPath: resolvedPath, + getRunState(repoFullName) { + const row = getStatement.get(normalizeRepoFullName(repoFullName)); + return runStateSet.has(row?.state) ? row.state : null; + }, + setRunState(repoFullName, state) { + const normalizedRepo = normalizeRepoFullName(repoFullName); + const normalizedState = normalizeRunState(state); + const updatedAt = new Date().toISOString(); + setStatement.run(normalizedRepo, normalizedState, updatedAt); + return { repoFullName: normalizedRepo, state: normalizedState, updatedAt }; + }, + close() { + db.close(); + }, + }; +} + +function getDefaultRunStateStore() { + defaultRunStateStore ??= initRunStateStore(); + return defaultRunStateStore; +} + +export function getRunState(repoFullName) { + return getDefaultRunStateStore().getRunState(repoFullName); +} + +export function setRunState(repoFullName, state) { + return getDefaultRunStateStore().setRunState(repoFullName, state); +} + +export function closeDefaultRunStateStore() { + if (!defaultRunStateStore) return; + defaultRunStateStore.close(); + defaultRunStateStore = null; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 0bfab3d0a2..3630b0a0c8 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -31,12 +31,12 @@ "lib" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/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" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.13.0" } } diff --git a/test/unit/miner-run-state.test.ts b/test/unit/miner-run-state.test.ts new file mode 100644 index 0000000000..b57b0b9894 --- /dev/null +++ b/test/unit/miner-run-state.test.ts @@ -0,0 +1,158 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + RUN_STATES, + closeDefaultRunStateStore, + getRunState, + initRunStateStore, + resolveRunStateDbPath, + setRunState, +} from "../../packages/gittensory-miner/lib/run-state.js"; + +const roots: string[] = []; + +function tempRoot(): string { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-run-state-")); + roots.push(root); + return root; +} + +afterEach(() => { + closeDefaultRunStateStore(); + vi.unstubAllEnvs(); + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("gittensory-miner run-state store (#2289)", () => { + it("keeps the package engine floor aligned with unflagged node:sqlite support", () => { + const packageJson = JSON.parse( + readFileSync("packages/gittensory-miner/package.json", "utf8"), + ) as { engines?: { node?: string } }; + + expect(packageJson.engines?.node).toBe(">=22.13.0"); + }); + + it("resolves the DB path from env override, miner config dir, XDG config, then the home default", () => { + expect(resolveRunStateDbPath({ GITTENSORY_MINER_RUN_STATE_DB: "/custom/state.sqlite3" })).toBe( + "/custom/state.sqlite3", + ); + expect(resolveRunStateDbPath({ GITTENSORY_MINER_CONFIG_DIR: "/custom/config" })).toBe( + "/custom/config/run-state.sqlite3", + ); + expect(resolveRunStateDbPath({ XDG_CONFIG_HOME: "/xdg" })).toBe( + "/xdg/gittensory-miner/run-state.sqlite3", + ); + expect(resolveRunStateDbPath({})).toMatch(/\/\.config\/gittensory-miner\/run-state\.sqlite3$/); + }); + + it("creates the SQLite table on first use and reads null before any write", () => { + const dbPath = join(tempRoot(), "nested", "run-state.sqlite3"); + const store = initRunStateStore(dbPath); + try { + expect(existsSync(dbPath)).toBe(true); + expect(statSync(dbPath).mode & 0o077).toBe(0); + expect(store.getRunState("JSONbored/gittensory")).toBeNull(); + + const db = new DatabaseSync(dbPath, { readOnly: true }); + try { + const row = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'miner_run_state'") + .get(); + expect(row).toEqual({ name: "miner_run_state" }); + } finally { + db.close(); + } + } finally { + store.close(); + } + }); + + it("round-trips every fixed run state and records updated_at timestamps", () => { + const dbPath = join(tempRoot(), "run-state.sqlite3"); + const store = initRunStateStore(dbPath); + try { + for (const state of RUN_STATES) { + const write = store.setRunState(" JSONbored/gittensory ", state); + expect(write.repoFullName).toBe("JSONbored/gittensory"); + expect(write.state).toBe(state); + expect(Date.parse(write.updatedAt)).not.toBeNaN(); + expect(store.getRunState("JSONbored/gittensory")).toBe(state); + } + } finally { + store.close(); + } + }); + + it("reopens an existing DB file without truncating stored repo state", () => { + const dbPath = join(tempRoot(), "run-state.sqlite3"); + const first = initRunStateStore(dbPath); + first.setRunState("acme/widgets", "planning"); + first.close(); + + const second = initRunStateStore(dbPath); + try { + expect(second.getRunState("acme/widgets")).toBe("planning"); + second.setRunState("acme/widgets", "preparing"); + expect(second.getRunState("acme/widgets")).toBe("preparing"); + } finally { + second.close(); + } + }); + + it("exposes module-level get/set helpers backed by the default local DB path", () => { + vi.stubEnv("GITTENSORY_MINER_RUN_STATE_DB", join(tempRoot(), "default.sqlite3")); + + expect(getRunState("acme/widgets")).toBeNull(); + expect(setRunState("acme/widgets", "discovering")).toMatchObject({ + repoFullName: "acme/widgets", + state: "discovering", + }); + expect(getRunState("acme/widgets")).toBe("discovering"); + + closeDefaultRunStateStore(); + expect(getRunState("acme/widgets")).toBe("discovering"); + }); + + it("rejects invalid DB paths, repo names, and run states before writing", () => { + expect(() => initRunStateStore(" ")).toThrow("invalid_run_state_db_path"); + + const dbPath = join(tempRoot(), "run-state.sqlite3"); + const store = initRunStateStore(dbPath); + try { + expect(() => store.getRunState("not-a-full-name")).toThrow("invalid_repo_full_name"); + expect(() => store.setRunState("owner/repo/extra", "idle")).toThrow("invalid_repo_full_name"); + expect(() => store.setRunState("owner/repo", "blocked" as never)).toThrow("invalid_run_state"); + expect(store.getRunState("owner/repo")).toBeNull(); + } finally { + store.close(); + } + }); + + it("fails closed to null when a legacy table contains an unknown state", () => { + const dbPath = join(tempRoot(), "legacy.sqlite3"); + const legacy = new DatabaseSync(dbPath); + legacy.exec(` + CREATE TABLE miner_run_state ( + repo_full_name TEXT PRIMARY KEY, + state TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); + legacy + .prepare("INSERT INTO miner_run_state (repo_full_name, state, updated_at) VALUES (?, ?, ?)") + .run("acme/widgets", "paused", "2026-07-02T00:00:00.000Z"); + legacy.close(); + + const store = initRunStateStore(dbPath); + try { + expect(store.getRunState("acme/widgets")).toBeNull(); + } finally { + store.close(); + } + }); +});