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
51 changes: 51 additions & 0 deletions packages/gittensory-miner/lib/plan-store.d.ts
Original file line number Diff line number Diff line change
@@ -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, string | undefined>): 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;
193 changes: 193 additions & 0 deletions packages/gittensory-miner/lib/plan-store.js
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
119 changes: 119 additions & 0 deletions test/unit/miner-plan-store.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading