From b5e0fb951132772b388f9feb2e4d255580476193 Mon Sep 17 00:00:00 2001 From: dhgoal <153369624+dhgoal@users.noreply.github.com> Date: Sat, 4 Jul 2026 04:32:30 +0900 Subject: [PATCH] feat(miner): add local plan-store persistence adapter Add packages/gittensory-miner/lib/plan-store.js: local SQLite persistence for the stateless MCP plan DAG so a miner can restore a plan across process restarts. openPlanStore/savePlan/loadPlan/listPlans; savePlan validates the plan against the planDagSchema shape and persists it with a single atomic INSERT...ON CONFLICT upsert, loadPlan re-validates on read so a corrupted row throws instead of returning a malformed plan, and listPlans filters by a derived plan status. Local-only, owner-only (0o600), never phones home; mirrors the run-state/portfolio-queue/event-ledger/claim-ledger pattern. Closes #2318. --- packages/gittensory-miner/lib/plan-store.d.ts | 51 +++++ packages/gittensory-miner/lib/plan-store.js | 193 ++++++++++++++++++ packages/gittensory-miner/package.json | 2 +- test/unit/miner-plan-store.test.ts | 119 +++++++++++ 4 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-miner/lib/plan-store.d.ts create mode 100644 packages/gittensory-miner/lib/plan-store.js create mode 100644 test/unit/miner-plan-store.test.ts diff --git a/packages/gittensory-miner/lib/plan-store.d.ts b/packages/gittensory-miner/lib/plan-store.d.ts new file mode 100644 index 0000000000..ed8195991f --- /dev/null +++ b/packages/gittensory-miner/lib/plan-store.d.ts @@ -0,0 +1,51 @@ +export type PlanStepStatus = "pending" | "running" | "completed" | "failed" | "skipped"; + +export type PlanStep = { + id: string; + title: string; + actionClass?: string; + dependsOn: string[]; + status: PlanStepStatus; + attempts: number; + maxAttempts: number; + lastError?: string | null; +}; + +export type PlanDag = { + steps: PlanStep[]; +}; + +export type PlanStatus = "pending" | "running" | "completed" | "failed"; + +export type PlanRecord = { + planId: string; + plan: PlanDag; + status: PlanStatus; + updatedAt: string; +}; + +export type ListPlansFilter = { + status?: PlanStatus; +}; + +export type PlanStore = { + dbPath: string; + savePlan(planId: string, plan: PlanDag): PlanRecord; + loadPlan(planId: string): PlanRecord | null; + listPlans(filter?: ListPlansFilter): PlanRecord[]; + close(): void; +}; + +export const PLAN_STATUSES: readonly PlanStatus[]; + +export function resolvePlanStoreDbPath(env?: Record): string; + +export function openPlanStore(dbPath?: string): PlanStore; + +export function savePlan(planId: string, plan: PlanDag): PlanRecord; + +export function loadPlan(planId: string): PlanRecord | null; + +export function listPlans(filter?: ListPlansFilter): PlanRecord[]; + +export function closeDefaultPlanStore(): void; diff --git a/packages/gittensory-miner/lib/plan-store.js b/packages/gittensory-miner/lib/plan-store.js new file mode 100644 index 0000000000..60be63be94 --- /dev/null +++ b/packages/gittensory-miner/lib/plan-store.js @@ -0,0 +1,193 @@ +import { chmodSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +// Local SQLite persistence for the stateless MCP plan DAG (#2318). `gittensory_build_plan`/`plan_status`/ +// `record_step_result` are stateless — the caller holds the plan and passes it back each call — so a miner running +// unattended across process restarts needs somewhere to persist the plan object between calls. This is local-only +// bookkeeping (no plan logic, no network), 100% client-side, mirroring the package's other local stores. Every +// plan is validated against the `planDagSchema` shape (src/mcp/server.ts) on BOTH save and load, so a corrupted +// local row fails loudly instead of feeding a malformed plan back into `gittensory_plan_status`. + +const PLAN_STEP_STATUSES = Object.freeze(["pending", "running", "completed", "failed", "skipped"]); +/** Derived plan-level status used for `listPlans({ status })`. */ +export const PLAN_STATUSES = Object.freeze(["pending", "running", "completed", "failed"]); + +const stepStatusSet = new Set(PLAN_STEP_STATUSES); +const planStatusSet = new Set(PLAN_STATUSES); +const defaultDbFileName = "plan-store.sqlite3"; +let defaultPlanStore = null; + +export function resolvePlanStoreDbPath(env = process.env) { + const explicitPath = typeof env.GITTENSORY_MINER_PLAN_STORE_DB === "string" + ? env.GITTENSORY_MINER_PLAN_STORE_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 raw = dbPath ?? resolvePlanStoreDbPath(); + if (typeof raw !== "string" || !raw.trim()) throw new Error("invalid_plan_store_db_path"); + return raw.trim(); +} + +function normalizePlanId(planId) { + if (typeof planId !== "string" || !planId.trim()) throw new Error("invalid_plan_id"); + return planId.trim(); +} + +function isBoundedString(value, min, max) { + return typeof value === "string" && value.length >= min && value.length <= max; +} + +function isBoundedInt(value, min, max) { + return Number.isInteger(value) && value >= min && value <= max; +} + +const STEP_KEYS = new Set(["id", "title", "actionClass", "dependsOn", "status", "attempts", "maxAttempts", "lastError"]); + +function isValidStep(step) { + if (!step || typeof step !== "object" || Array.isArray(step)) return false; + for (const key of Object.keys(step)) if (!STEP_KEYS.has(key)) return false; // strict: no unknown keys + if (!isBoundedString(step.id, 1, 100) || !isBoundedString(step.title, 1, 300)) return false; + if (step.actionClass !== undefined && !isBoundedString(step.actionClass, 1, 60)) return false; + if (!Array.isArray(step.dependsOn) || step.dependsOn.length > 50) return false; + if (!step.dependsOn.every((dep) => isBoundedString(dep, 1, 100))) return false; + if (!stepStatusSet.has(step.status)) return false; + if (!isBoundedInt(step.attempts, 0, Number.MAX_SAFE_INTEGER)) return false; + if (!isBoundedInt(step.maxAttempts, 1, 10)) return false; + if (step.lastError !== undefined && step.lastError !== null && !isBoundedString(step.lastError, 0, 2000)) return false; + return true; +} + +/** Validate a plan against the `planDagSchema` shape (strict `{ steps: PlanStep[] }`, ≤100 steps). Throws on any + * malformed field so a bad plan can neither be saved nor read back. */ +function validatePlanDag(plan) { + if (!plan || typeof plan !== "object" || Array.isArray(plan)) throw new Error("invalid_plan"); + const keys = Object.keys(plan); + if (keys.length !== 1 || keys[0] !== "steps") throw new Error("invalid_plan"); + if (!Array.isArray(plan.steps) || plan.steps.length > 100) throw new Error("invalid_plan"); + if (!plan.steps.every(isValidStep)) throw new Error("invalid_plan"); + return plan; +} + +/** Derive a plan-level status from its steps: any failed → failed; else any running → running; else all steps + * finished (completed/skipped) with at least one step → completed; otherwise pending. */ +function computePlanStatus(plan) { + const steps = plan.steps; + if (steps.some((step) => step.status === "failed")) return "failed"; + if (steps.some((step) => step.status === "running")) return "running"; + if (steps.length > 0 && steps.every((step) => step.status === "completed" || step.status === "skipped")) { + return "completed"; + } + return "pending"; +} + +function rowToRecord(row) { + let plan; + try { + plan = validatePlanDag(JSON.parse(row.plan_json)); + } catch { + throw new Error("corrupted_plan_row"); // stored blob no longer matches the plan shape + } + // Also fail closed on the status column: a manually-edited or legacy row (predating the CHECK constraint) could + // hold a status outside PLAN_STATUSES, which would otherwise violate the exported PlanRecord contract on read. + if (!planStatusSet.has(row.status)) throw new Error("corrupted_plan_row"); + return { planId: row.plan_id, plan, status: row.status, updatedAt: row.updated_at }; +} + +/** + * Opens the local plan store, creating the table on first use. `savePlan` is a single atomic INSERT…ON CONFLICT + * upsert keyed by `plan_id`; the plan JSON is validated on save AND re-validated on load, so a corrupted row is + * rejected rather than silently returned. (#2318) + */ +export function openPlanStore(dbPath = resolvePlanStoreDbPath()) { + const resolvedPath = normalizeDbPath(dbPath); + // The store is a persistent local file; the special in-memory path (':memory:') has no file to create or chmod. + if (resolvedPath !== ":memory:") { + mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o700 }); + } + const db = new DatabaseSync(resolvedPath); + if (resolvedPath !== ":memory:") chmodSync(resolvedPath, 0o600); + db.exec("PRAGMA busy_timeout = 5000"); + db.exec(` + CREATE TABLE IF NOT EXISTS miner_plans ( + plan_id TEXT PRIMARY KEY, + plan_json TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'completed', 'failed')), + updated_at TEXT NOT NULL + ) + `); + + const saveStatement = db.prepare(` + INSERT INTO miner_plans (plan_id, plan_json, status, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(plan_id) DO UPDATE SET + plan_json = excluded.plan_json, + status = excluded.status, + updated_at = excluded.updated_at + `); + const getStatement = db.prepare("SELECT * FROM miner_plans WHERE plan_id = ?"); + const listAllStatement = db.prepare("SELECT * FROM miner_plans ORDER BY plan_id ASC"); + const listStatusStatement = db.prepare("SELECT * FROM miner_plans WHERE status = ? ORDER BY plan_id ASC"); + + return { + dbPath: resolvedPath, + savePlan(planId, plan) { + const id = normalizePlanId(planId); + validatePlanDag(plan); + const status = computePlanStatus(plan); + const updatedAt = new Date().toISOString(); + saveStatement.run(id, JSON.stringify(plan), status, updatedAt); + return { planId: id, plan, status, updatedAt }; + }, + loadPlan(planId) { + const row = getStatement.get(normalizePlanId(planId)); + return row ? rowToRecord(row) : null; + }, + listPlans(filter = {}) { + if (filter.status !== undefined) { + if (!planStatusSet.has(filter.status)) throw new Error("invalid_status"); + return listStatusStatement.all(filter.status).map(rowToRecord); + } + return listAllStatement.all().map(rowToRecord); + }, + close() { + db.close(); + }, + }; +} + +function getDefaultPlanStore() { + defaultPlanStore ??= openPlanStore(); + return defaultPlanStore; +} + +export function savePlan(planId, plan) { + return getDefaultPlanStore().savePlan(planId, plan); +} + +export function loadPlan(planId) { + return getDefaultPlanStore().loadPlan(planId); +} + +export function listPlans(filter) { + return getDefaultPlanStore().listPlans(filter); +} + +export function closeDefaultPlanStore() { + if (!defaultPlanStore) return; + defaultPlanStore.close(); + defaultPlanStore = null; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index b67527107b..644bd221fd 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -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/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" + "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.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" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-plan-store.test.ts b/test/unit/miner-plan-store.test.ts new file mode 100644 index 0000000000..526b6e2c6a --- /dev/null +++ b/test/unit/miner-plan-store.test.ts @@ -0,0 +1,119 @@ +import { mkdtempSync, 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 } from "vitest"; +import { + PLAN_STATUSES, + closeDefaultPlanStore, + openPlanStore, + resolvePlanStoreDbPath, +} from "../../packages/gittensory-miner/lib/plan-store.js"; +import type { PlanDag } from "../../packages/gittensory-miner/lib/plan-store.js"; + +const roots: string[] = []; +const stores: Array<{ close(): void }> = []; + +function tempStore() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-plan-store-")); + roots.push(root); + const store = openPlanStore(join(root, "nested", "plan-store.sqlite3")); + stores.push(store); + return store; +} + +afterEach(() => { + for (const store of stores.splice(0)) store.close(); + closeDefaultPlanStore(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +const PLAN: PlanDag = { + steps: [ + { id: "s1", title: "Build the thing", dependsOn: [], status: "completed", attempts: 1, maxAttempts: 3 }, + { id: "s2", title: "Test it", dependsOn: ["s1"], status: "running", attempts: 0, maxAttempts: 3, actionClass: "test" }, + ], +}; + +describe("gittensory-miner plan store (#2318)", () => { + it("exposes the frozen plan-status vocabulary", () => { + expect(PLAN_STATUSES).toEqual(["pending", "running", "completed", "failed"]); + expect(Object.isFrozen(PLAN_STATUSES)).toBe(true); + }); + + it("resolves the DB path from env override, miner config dir, XDG config, then the home default", () => { + expect(resolvePlanStoreDbPath({ GITTENSORY_MINER_PLAN_STORE_DB: "/custom/p.sqlite3" })).toBe("/custom/p.sqlite3"); + expect(resolvePlanStoreDbPath({ GITTENSORY_MINER_CONFIG_DIR: "/custom/config" })).toBe( + "/custom/config/plan-store.sqlite3", + ); + expect(resolvePlanStoreDbPath({ XDG_CONFIG_HOME: "/xdg" })).toBe("/xdg/gittensory-miner/plan-store.sqlite3"); + expect(resolvePlanStoreDbPath({})).toMatch(/\/\.config\/gittensory-miner\/plan-store\.sqlite3$/); + }); + + it("creates the SQLite file with owner-only permissions and loads null before any save", () => { + const store = tempStore(); + expect(statSync(store.dbPath).mode & 0o077).toBe(0); + expect(store.loadPlan("missing")).toBeNull(); + expect(store.listPlans()).toEqual([]); + }); + + it("saves a plan and loads it back verbatim, deriving a plan-level status", () => { + const store = tempStore(); + const saved = store.savePlan("p1", PLAN); + expect(saved).toMatchObject({ planId: "p1", plan: PLAN, status: "running" }); // has a running step + const loaded = store.loadPlan("p1"); + expect(loaded?.plan).toEqual(PLAN); + expect(loaded?.status).toBe("running"); + }); + + it("upserts on the same planId and lists plans filtered by derived status", () => { + const store = tempStore(); + store.savePlan("running-plan", PLAN); + store.savePlan("done-plan", { + steps: [{ id: "a", title: "done", dependsOn: [], status: "completed", attempts: 1, maxAttempts: 1 }], + }); + // Re-save p1 as fully completed → status flips, no duplicate row. + store.savePlan("running-plan", { + steps: [{ id: "s1", title: "Build the thing", dependsOn: [], status: "completed", attempts: 1, maxAttempts: 3 }], + }); + expect(store.listPlans().map((r) => r.planId)).toEqual(["done-plan", "running-plan"]); // one row each + expect(store.listPlans({ status: "completed" }).map((r) => r.planId)).toEqual(["done-plan", "running-plan"]); + expect(store.listPlans({ status: "running" })).toEqual([]); + expect(() => store.listPlans({ status: "bogus" as never })).toThrow("invalid_status"); + }); + + it("rejects a malformed plan on save rather than persisting it", () => { + const store = tempStore(); + expect(() => store.savePlan("x", { steps: [{ id: "s1", title: "no status", dependsOn: [], attempts: 0, maxAttempts: 1 } as never] })).toThrow("invalid_plan"); + expect(() => store.savePlan("x", { steps: "nope" as never })).toThrow("invalid_plan"); + expect(() => store.savePlan("x", { steps: [], extra: 1 } as never)).toThrow("invalid_plan"); // strict: no unknown keys + expect(() => store.savePlan("", PLAN)).toThrow("invalid_plan_id"); + }); + + it("rejects a corrupted plan blob on load instead of returning a malformed plan", () => { + const store = tempStore(); + store.savePlan("p1", PLAN); + // Corrupt the stored blob via a raw connection, then read it back through the store. + const raw = new DatabaseSync(store.dbPath); + raw.prepare("UPDATE miner_plans SET plan_json = ? WHERE plan_id = ?").run('{"steps":[{"bad":true}]}', "p1"); + raw.close(); + expect(() => store.loadPlan("p1")).toThrow("corrupted_plan_row"); + }); + + it("fails closed on an out-of-vocabulary status column (legacy/foreign row without the CHECK)", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-plan-store-")); + roots.push(root); + const dbPath = join(root, "legacy.sqlite3"); + // Simulate a legacy/foreign table created before the status CHECK constraint, holding an invalid status. + const raw = new DatabaseSync(dbPath); + raw.exec( + "CREATE TABLE miner_plans (plan_id TEXT PRIMARY KEY, plan_json TEXT NOT NULL, status TEXT NOT NULL, updated_at TEXT NOT NULL)", + ); + raw.prepare("INSERT INTO miner_plans VALUES (?, ?, ?, ?)").run("p1", JSON.stringify(PLAN), "bogus", "2026-07-03T00:00:00Z"); + raw.close(); + const store = openPlanStore(dbPath); // CREATE TABLE IF NOT EXISTS is a no-op on the existing legacy table + stores.push(store); + expect(() => store.loadPlan("p1")).toThrow("corrupted_plan_row"); + expect(() => store.listPlans()).toThrow("corrupted_plan_row"); + }); +});