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
3 changes: 3 additions & 0 deletions packages/gittensory-miner/lib/event-ledger.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { isDeepStrictEqual } from "node:util";
import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js";
import { applySchemaMigrations } from "./schema-version.js";
import { pruneLedgerByRetention, resolveLedgerRetentionPolicy, EVENT_LEDGER_RETENTION_SPEC } from "./store-maintenance.js";

// The miner's local, append-only event ledger (#2290): an immutable audit trail of every significant miner-loop
// event (discovered_issue, plan_built, plan_step_completed, pr_prepared, … — a small fixed vocabulary for this
Expand Down Expand Up @@ -106,6 +107,8 @@ export function initEventLedger(dbPath = resolveEventLedgerDbPath()) {
`);
// Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet).
applySchemaMigrations(db, []);
// Opt-in retention (#4834): prune aged/excess rows when an operator has enabled it; a no-op by default.
pruneLedgerByRetention(db, EVENT_LEDGER_RETENTION_SPEC, resolveLedgerRetentionPolicy(), Date.now());

const nextSeqStatement = db.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS nextSeq FROM miner_event_ledger");
const appendStatement = db.prepare(`
Expand Down
3 changes: 3 additions & 0 deletions packages/gittensory-miner/lib/governor-ledger.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { normalizeGovernorLedgerEvent } from "@jsonbored/gittensory-engine";
import { applySchemaMigrations } from "./schema-version.js";
import { pruneLedgerByRetention, resolveLedgerRetentionPolicy, GOVERNOR_LEDGER_RETENTION_SPEC } from "./store-maintenance.js";

// Append-only governor decision ledger (#2328): every allowed/denied/throttled/kill-switch outcome lands in a
// local SQLite table for contributor audit. IMMUTABILITY INVARIANT: INSERT + SELECT only — never UPDATE/DELETE.
Expand Down Expand Up @@ -90,6 +91,8 @@ export function initGovernorLedger(dbPath = resolveGovernorLedgerDbPath()) {
db.exec("CREATE INDEX IF NOT EXISTS idx_governor_events_repo ON governor_events (repo_full_name, id)");
// Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet).
applySchemaMigrations(db, []);
// Opt-in retention (#4834): prune aged/excess rows when an operator has enabled it; a no-op by default.
pruneLedgerByRetention(db, GOVERNOR_LEDGER_RETENTION_SPEC, resolveLedgerRetentionPolicy(), Date.now());

const appendStatement = db.prepare(`
INSERT INTO governor_events (ts, event_type, repo_full_name, action_class, decision, reason, payload_json)
Expand Down
3 changes: 3 additions & 0 deletions packages/gittensory-miner/lib/prediction-ledger.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { chmodSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { pruneLedgerByRetention, resolveLedgerRetentionPolicy, PREDICTION_LEDGER_RETENTION_SPEC } from "./store-maintenance.js";

// Append-only prediction ledger (#4263): every predicted-gate verdict the miner computes for a target lands in
// a local SQLite table so a later self-improve pass can score the prediction against the realized pr_outcome.
Expand Down Expand Up @@ -145,6 +146,8 @@ export function initPredictionLedger(dbPath = resolvePredictionLedgerDbPath()) {
)
`);
db.exec("CREATE INDEX IF NOT EXISTS idx_predictions_repo ON predictions (repo_full_name, id)");
// Opt-in retention (#4834): prune aged/excess rows when an operator has enabled it; a no-op by default.
pruneLedgerByRetention(db, PREDICTION_LEDGER_RETENTION_SPEC, resolveLedgerRetentionPolicy(), Date.now());

const appendStatement = db.prepare(`
INSERT INTO predictions
Expand Down
24 changes: 24 additions & 0 deletions packages/gittensory-miner/lib/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ import {
findExecutableOnPath,
} from "./laptop-init.js";
import { resolveMinerVersion } from "./version.js";
import { checkStoreIntegrity } from "./store-maintenance.js";
import { resolveEventLedgerDbPath } from "./event-ledger.js";
import { resolveGovernorLedgerDbPath } from "./governor-ledger.js";
import { resolvePredictionLedgerDbPath } from "./prediction-ledger.js";
import { resolvePortfolioQueueDbPath } from "./portfolio-queue.js";
import { resolveClaimLedgerDbPath } from "./claim-ledger.js";
import { resolveRunStateDbPath } from "./run-state.js";
import { resolvePlanStoreDbPath } from "./plan-store.js";

// Slim laptop-mode CLI commands (#2288): `status` (what's installed + where local state lives) and `doctor` (is
// this laptop set up correctly). Both are read-only and 100% local — no repo-scanning, no coding-agent invocation,
Expand Down Expand Up @@ -271,6 +279,21 @@ function checkStateDirWritable(stateDir) {
}
}

/** Per-store `PRAGMA integrity_check` sweep for `doctor` (#4834) — flags a corrupted store instead of probing
* only one with `SELECT 1`. A store file that does not exist yet is healthy by absence. */
function storeIntegrityChecks(env) {
const stores = [
["event-ledger", resolveEventLedgerDbPath(env)],
["governor-ledger", resolveGovernorLedgerDbPath(env)],
["prediction-ledger", resolvePredictionLedgerDbPath(env)],
["portfolio-queue", resolvePortfolioQueueDbPath(env)],
["claim-ledger", resolveClaimLedgerDbPath(env)],
["run-state", resolveRunStateDbPath(env)],
["plan-store", resolvePlanStoreDbPath(env)],
];
return stores.map(([name, dbPath]) => checkStoreIntegrity(`store-integrity:${name}`, dbPath));
}

/** Run the doctor checks. Returns an array of { name, ok, detail }; only writes a transient probe in the state dir,
* never touches the network. */
export function runDoctorChecks(env = process.env) {
Expand All @@ -294,6 +317,7 @@ export function runDoctorChecks(env = process.env) {
checkDockerPresent(),
checkClaudeCliPresent({ env }),
checkCodexCliPresent({ env }),
...storeIntegrityChecks(env),
];
}

Expand Down
23 changes: 23 additions & 0 deletions packages/gittensory-miner/lib/store-maintenance.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { DatabaseSync } from "node:sqlite";

export const LEDGER_RETENTION_DAYS_ENV: string;
export const LEDGER_RETENTION_MAX_ROWS_ENV: string;

export type LedgerRetentionSpec = { table: string; timestampColumn: string; orderColumn: string };
export const EVENT_LEDGER_RETENTION_SPEC: LedgerRetentionSpec;
export const GOVERNOR_LEDGER_RETENTION_SPEC: LedgerRetentionSpec;
export const PREDICTION_LEDGER_RETENTION_SPEC: LedgerRetentionSpec;

export type StoreIntegrityResult = { name: string; ok: boolean; detail: string };
export type LedgerRetentionPolicy = { maxAgeMs?: number; maxRows?: number };

export function describeError(error: unknown): string;
export function classifyIntegrityRows(rows: Array<{ integrity_check?: unknown }>): { ok: boolean; note: string };
export function checkStoreIntegrity(name: string, dbPath: string): StoreIntegrityResult;
export function resolveLedgerRetentionPolicy(env?: Record<string, string | undefined>): LedgerRetentionPolicy | null;
export function pruneLedgerByRetention(
db: DatabaseSync,
spec: LedgerRetentionSpec,
policy: LedgerRetentionPolicy | null,
nowMs: number,
): number;
134 changes: 134 additions & 0 deletions packages/gittensory-miner/lib/store-maintenance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Local-store maintenance for the miner (#4834): SQLite integrity checks + append-only ledger retention.
//
// Two independent, side-effect-light helpers used by `doctor` and the ledgers:
// 1. checkStoreIntegrity — run `PRAGMA integrity_check` on one store file and report health, so `doctor` can
// flag a corrupted store instead of only probing a single one with `SELECT 1`.
// 2. resolveLedgerRetentionPolicy / pruneLedgerByRetention — an opt-in, age- and/or size-based retention
// policy for the unbounded append-only ledgers (event, governor, prediction), which otherwise grow forever.
// OFF by default: retention only runs when an operator sets the env opt-in.
// Pure control flow over injected inputs (a DB handle, an env object, a caller-supplied clock) — no network, and
// no internal clock read in the prune path so it stays deterministic and unit-testable.
import { existsSync } from "node:fs";
import { DatabaseSync } from "node:sqlite";

/** Env opt-ins for ledger retention (unset ⇒ retention disabled). */
export const LEDGER_RETENTION_DAYS_ENV = "GITTENSORY_MINER_LEDGER_RETENTION_DAYS";
export const LEDGER_RETENTION_MAX_ROWS_ENV = "GITTENSORY_MINER_LEDGER_RETENTION_MAX_ROWS";

/** Fixed retention specs for the three append-only ledgers. These identifiers are INTERNAL constants — never
* caller/user text — and are validated as plain identifiers before interpolation as defence in depth. */
export const EVENT_LEDGER_RETENTION_SPEC = { table: "miner_event_ledger", timestampColumn: "created_at", orderColumn: "id" };
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" };

const SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;

/** A readable message for a caught value, whether or not it is an Error. */
export function describeError(error) {
return error instanceof Error ? error.message : String(error);
}

/**
* Classify raw `PRAGMA integrity_check` rows. A healthy database yields a single `"ok"` row; a corrupt one yields
* one row per problem. Pure — extracted so both the healthy and problem paths are testable without a genuinely
* corrupt file (which SQLite typically refuses to open at all, i.e. the catch path below).
* @param {Array<{ integrity_check?: unknown }>} rows
* @returns {{ ok: boolean, note: string }}
*/
export function classifyIntegrityRows(rows) {
const problems = rows.map((row) => String(row.integrity_check)).filter((value) => value !== "ok");
return problems.length === 0 ? { ok: true, note: "ok" } : { ok: false, note: problems.join("; ") };
}

/**
* Run `PRAGMA integrity_check` on a single store file. A store that does not exist yet is healthy by absence
* (nothing to corrupt). Never throws: a store that cannot be opened or read is reported as not-ok, so one bad
* store cannot abort the whole doctor sweep.
* @param {string} name - the check label (e.g. "event-ledger").
* @param {string} dbPath - the store file path.
* @returns {{ name: string, ok: boolean, detail: string }}
*/
export function checkStoreIntegrity(name, dbPath) {
if (!existsSync(dbPath)) {
return { name, ok: true, detail: `${dbPath}: not created yet` };
}
let db;
try {
db = new DatabaseSync(dbPath, { readonly: true });
const { ok, note } = classifyIntegrityRows(db.prepare("PRAGMA integrity_check").all());
return { name, ok, detail: `${dbPath}: ${note}` };
} catch (error) {
return { name, ok: false, detail: `${dbPath}: ${describeError(error)}` };
} finally {
db?.close();
}
}

/** Coerce an env value to a positive integer, or null (unset/blank/zero/negative/non-finite ⇒ null ⇒ disabled).
* Floors BEFORE the positivity test, so a fractional value below 1 (e.g. "0.5") floors to 0 and disables the
* bound rather than becoming a dangerous 0 that would prune the whole ledger. */
function positiveIntOrNull(raw) {
if (raw === undefined || raw === null || String(raw).trim() === "") return null;
const numeric = Math.floor(Number(raw));
return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
}

/**
* Resolve the opt-in ledger retention policy from an env object. OFF by default: returns null unless at least
* one bound is set to a positive value. A zero/negative/non-numeric value is treated as unset. When set, returns
* `{ maxAgeMs? }` (from a day count) and/or `{ maxRows? }`.
* @param {NodeJS.ProcessEnv} [env]
* @returns {{ maxAgeMs?: number, maxRows?: number } | null}
*/
export function resolveLedgerRetentionPolicy(env = process.env) {
const maxAgeDays = positiveIntOrNull(env[LEDGER_RETENTION_DAYS_ENV]);
const maxRows = positiveIntOrNull(env[LEDGER_RETENTION_MAX_ROWS_ENV]);
if (maxAgeDays === null && maxRows === null) return null;
const policy = {};
if (maxAgeDays !== null) policy.maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
if (maxRows !== null) policy.maxRows = maxRows;
return policy;
}

/**
* Prune one append-only ledger per a resolved retention policy: delete rows older than the age bound AND rows
* beyond the row-count bound (keeping the newest `maxRows` by `orderColumn`), atomically. A null policy is a
* no-op. `nowMs` is caller-supplied (no internal clock). Timestamp columns are UTC ISO-8601 strings, which sort
* lexicographically in chronological order, so a string comparison against the ISO cutoff selects older rows.
* @param {import("node:sqlite").DatabaseSync} db
* @param {{ table: string, timestampColumn: string, orderColumn: string }} spec
* @param {{ maxAgeMs?: number, maxRows?: number } | null} policy
* @param {number} nowMs
* @returns {number} rows deleted
*/
export function pruneLedgerByRetention(db, spec, policy, nowMs) {
if (!policy) return 0;
for (const identifier of [spec.table, spec.timestampColumn, spec.orderColumn]) {
if (!SQL_IDENTIFIER.test(identifier)) throw new Error(`unsafe SQL identifier: ${identifier}`);
}
let deleted = 0;
db.exec("BEGIN");
try {
// Both bounds are guarded to be strictly positive as defence in depth: a 0 age would prune everything older
// than `now`, and a 0 row-cap makes `LIMIT 0` match no rows so `NOT IN (empty)` would delete the whole ledger.
if (policy.maxAgeMs !== undefined && policy.maxAgeMs > 0) {
const cutoff = new Date(nowMs - policy.maxAgeMs).toISOString();
const info = db.prepare(`DELETE FROM ${spec.table} WHERE ${spec.timestampColumn} < ?`).run(cutoff);
deleted += Number(info.changes);
}
if (policy.maxRows !== undefined && policy.maxRows >= 1) {
const info = db
.prepare(
`DELETE FROM ${spec.table} WHERE ${spec.orderColumn} NOT IN ` +
`(SELECT ${spec.orderColumn} FROM ${spec.table} ORDER BY ${spec.orderColumn} DESC LIMIT ?)`,
)
.run(policy.maxRows);
deleted += Number(info.changes);
}
db.exec("COMMIT");
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
return deleted;
}
20 changes: 19 additions & 1 deletion test/unit/miner-status.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { resolveEventLedgerDbPath } from "../../packages/gittensory-miner/lib/event-ledger.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildEngineVersionSkewCheck,
Expand Down Expand Up @@ -92,11 +93,28 @@ describe("gittensory-miner status/doctor (#2288)", () => {
"docker-present",
"claude-cli-present",
"codex-cli-present",
"store-integrity:event-ledger",
"store-integrity:governor-ledger",
"store-integrity:prediction-ledger",
"store-integrity:portfolio-queue",
"store-integrity:claim-ledger",
"store-integrity:run-state",
"store-integrity:plan-store",
]);
expect(runDoctor([], env)).toBe(0);
expect(log).toHaveBeenCalled();
});

it("doctor flags a corrupted store (#4834)", () => {
const env = { GITTENSORY_MINER_CONFIG_DIR: join(tempRoot(), "state") };
const eventLedgerPath = resolveEventLedgerDbPath(env);
mkdirSync(dirname(eventLedgerPath), { recursive: true });
writeFileSync(eventLedgerPath, "this is not a sqlite database");
const checks = runDoctorChecks(env);
expect(checks.find((check) => check.name === "store-integrity:event-ledger")?.ok).toBe(false);
expect(runDoctor([], env)).toBe(1); // a failed check makes doctor exit non-zero
});

it("engine version skew helpers compare installed vs expected semver", () => {
expect(compareInstalledEngineVersion("0.2.0", "0.2.0")).toBe(0);
expect(compareInstalledEngineVersion("0.1.0", "0.2.0")).toBe(-1);
Expand Down
Loading