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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions packages/gittensory-miner/lib/run-state.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export type RunState = "idle" | "discovering" | "planning" | "preparing";

Check notice on line 1 in packages/gittensory-miner/lib/run-state.d.ts

View check run for this annotation

Loopover ORB / Gittensory Context

Review queue is busy

This repo has a busy review queue in the local Gittensory cache.

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, string | undefined>): 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;
112 changes: 112 additions & 0 deletions packages/gittensory-miner/lib/run-state.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { chmodSync, mkdirSync } from "node:fs";

Check notice on line 1 in packages/gittensory-miner/lib/run-state.js

View check run for this annotation

Loopover ORB / Gittensory Context

Review queue is busy

This repo has a busy review queue in the local Gittensory cache.
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;
}
4 changes: 2 additions & 2 deletions packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@
},
"files": [
"bin",
"lib"

Check notice on line 31 in packages/gittensory-miner/package.json

View check run for this annotation

Loopover ORB / Gittensory Context

Review queue is busy

This repo has a busy review queue in the local Gittensory cache.
],
"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"
}
}
158 changes: 158 additions & 0 deletions test/unit/miner-run-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";

Check notice on line 1 in test/unit/miner-run-state.test.ts

View check run for this annotation

Loopover ORB / Gittensory Context

Review queue is busy

This repo has a busy review queue in the local Gittensory cache.
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();
}
});
});
Loading