From 59cf11db473b8f23fe3a037a862fd2f8dce725d0 Mon Sep 17 00:00:00 2001 From: andriypolandki Date: Thu, 9 Jul 2026 21:52:43 -0700 Subject: [PATCH 1/3] feat(miner-hands): add driver attempt log persistence and JSONL export (#4294) --- .../src/miner/attempt-log.ts | 7 +- .../test/attempt-log.test.ts | 1 + .../docs/coding-agent-driver.md | 2 +- packages/gittensory-miner/lib/attempt-log.js | 177 +++++++++++++ packages/gittensory-miner/package.json | 2 +- test/unit/coding-agent-miner.test.ts | 1 + test/unit/miner-attempt-log.test.ts | 234 ++++++++++++++++++ 7 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 packages/gittensory-miner/lib/attempt-log.js create mode 100644 test/unit/miner-attempt-log.test.ts diff --git a/packages/gittensory-engine/src/miner/attempt-log.ts b/packages/gittensory-engine/src/miner/attempt-log.ts index a39f57cc8e..a231128b00 100644 --- a/packages/gittensory-engine/src/miner/attempt-log.ts +++ b/packages/gittensory-engine/src/miner/attempt-log.ts @@ -1,11 +1,12 @@ // Driver-level structured attempt log — pure event shapes (#4294). Mirrors `governor-ledger.ts`: fixed vocabulary, -// fail-closed normalization, JSON-round-trip-verified payloads. Persistence (SQLite/JSONL export) is a miner-package -// follow-up; this module is the engine-side contract the invoke layer writes into. +// fail-closed normalization, JSON-round-trip-verified payloads. SQLite persistence + JSONL export live in +// `packages/gittensory-miner/lib/attempt-log.js`, which imports this normalizer from the engine package. import type { CodingAgentExecutionMode } from "./coding-agent-mode.js"; export const ATTEMPT_LOG_EVENT_TYPES = Object.freeze([ "attempt_started", + "attempt_tool_edit", "attempt_shadow", "attempt_succeeded", "attempt_failed", @@ -104,7 +105,7 @@ export function formatAttemptLogJsonl(events: readonly NormalizedAttemptLogEvent return events.map((event) => JSON.stringify(event)).join("\n"); } -/** In-memory appender for tests and local tooling — production persistence imports the normalizer only. */ +/** In-memory appender for tests and local tooling — production persistence uses `gittensory-miner/lib/attempt-log.js`. */ export function createAttemptLogBuffer(): { append: (event: AttemptLogEvent) => NormalizedAttemptLogEvent; events: () => readonly NormalizedAttemptLogEvent[]; diff --git a/packages/gittensory-engine/test/attempt-log.test.ts b/packages/gittensory-engine/test/attempt-log.test.ts index 1346a5b298..1227638e88 100644 --- a/packages/gittensory-engine/test/attempt-log.test.ts +++ b/packages/gittensory-engine/test/attempt-log.test.ts @@ -10,6 +10,7 @@ import { test("ATTEMPT_LOG_EVENT_TYPES is a fixed vocabulary", () => { assert.deepEqual([...ATTEMPT_LOG_EVENT_TYPES], [ "attempt_started", + "attempt_tool_edit", "attempt_shadow", "attempt_succeeded", "attempt_failed", diff --git a/packages/gittensory-miner/docs/coding-agent-driver.md b/packages/gittensory-miner/docs/coding-agent-driver.md index deccc856e8..279e8a1f86 100644 --- a/packages/gittensory-miner/docs/coding-agent-driver.md +++ b/packages/gittensory-miner/docs/coding-agent-driver.md @@ -58,7 +58,7 @@ A driver never runs in isolation. The neighborhood it plugs into: | Execution mode | `coding-agent-mode.ts` | `paused` / `dry_run` / `live` with deny-toward-safety precedence; `codingAgentModeExecutes(mode)` is the single "should this attempt actually spawn?" boolean. A `dry_run` is a pure no-op at the driver boundary. | | Invocation | `coding-agent-invoke.ts` | `invokeCodingAgentDriver(driver, task, mode?, log?)` — gates on the mode, calls `driver.run`, and streams lifecycle events to an `AttemptLogSink`. | | Factory | `driver-factory.ts` | `createCodingAgentDriver(options)` resolves a driver by configured name; `resolveConfiguredCodingAgentDriverNames` / `isConfiguredCodingAgentDriver` deny unknown names by default; `runCodingAgentAttempt(options)` is the top-level "resolve + invoke" convenience. | -| Attempt log | `attempt-log.ts` (#4294) | `ATTEMPT_LOG_EVENT_TYPES` + `normalizeAttemptLogEvent` + `createAttemptLogBuffer` + `formatAttemptLogJsonl` — an append-only, JSONL-exportable event trace per attempt, independent of any driver's own transcript. | +| Attempt log | `attempt-log.ts` (#4294) | `ATTEMPT_LOG_EVENT_TYPES` + `normalizeAttemptLogEvent` + `createAttemptLogBuffer` + `formatAttemptLogJsonl` — an append-only, JSONL-exportable event trace per attempt, independent of any driver's own transcript. Durable persistence: `packages/gittensory-miner/lib/attempt-log.js` (sibling SQLite store; imports the engine normalizer). | | Metering | `attempt-metering.ts` (#4311) | `accumulateAttemptUsage` / `meterAttemptUsage` / `evaluateAttemptBudget` over `AttemptBudgetAxis` (`tokens` / `turns` / `wallClockMs` / `costUsd`). | | Acceptance criteria | (#4271) | The immutable criteria file at `task.acceptanceCriteriaPath`, written before the driver starts. | | Worktree isolation | (#4269) | Each attempt's `task.workingDirectory` is a dedicated git worktree; a driver must never edit outside it. | diff --git a/packages/gittensory-miner/lib/attempt-log.js b/packages/gittensory-miner/lib/attempt-log.js new file mode 100644 index 0000000000..e54b062307 --- /dev/null +++ b/packages/gittensory-miner/lib/attempt-log.js @@ -0,0 +1,177 @@ +import { formatAttemptLogJsonl, normalizeAttemptLogEvent } from "@jsonbored/gittensory-engine"; +import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js"; + +// Append-only driver attempt log (#4294): a structured, attempt-scoped event trace for every CodingAgentDriver run +// (started, tool/edit, succeeded/failed/aborted). IMMUTABILITY INVARIANT: INSERT + SELECT only — rows are never +// rewritten or removed after append. +// +// Why a sibling store instead of extending event-ledger.js: event-ledger is the general miner-loop audit trail +// (discovered_issue, plan_built, pr_prepared, …) keyed by repo scope with a growing free-form type vocabulary. +// Attempt events are keyed by attempt_id, validated against the engine's fixed ATTEMPT_LOG_EVENT_TYPES, and are +// exported per attempt as JSONL — mixing both into one table would couple unrelated lifecycles and complicate the +// per-attempt dump path. This module mirrors governor-ledger.js: engine holds pure normalization, miner holds SQLite. + +const defaultDbFileName = "attempt-log.sqlite3"; +let defaultAttemptLog = null; + +export function resolveAttemptLogDbPath(env = process.env) { + return resolveLocalStoreDbPath(defaultDbFileName, "GITTENSORY_MINER_ATTEMPT_LOG_DB", env); +} + +function normalizeDbPath(dbPath) { + return normalizeLocalStoreDbPath(dbPath, resolveAttemptLogDbPath(), "invalid_attempt_log_db_path"); +} + +/** Read-filter attempt scope: omitted/nullish → unscoped (all events); otherwise a non-empty attempt id. */ +function normalizeReadAttemptIdFilter(attemptId) { + if (attemptId === undefined || attemptId === null) return undefined; + if (typeof attemptId !== "string") throw new Error("invalid_attempt_id"); + const trimmed = attemptId.trim(); + if (!trimmed) throw new Error("invalid_attempt_id"); + return trimmed; +} + +/** Export requires an explicit attempt id — JSONL dumps are always per attempt. */ +function normalizeRequiredAttemptId(attemptId) { + const normalized = normalizeReadAttemptIdFilter(attemptId); + if (normalized === undefined) throw new Error("invalid_attempt_id"); + return normalized; +} + +function rowToEntry(row) { + let payload; + try { + payload = JSON.parse(row.payload_json); + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("corrupted_attempt_log_row"); + } + } catch { + throw new Error("corrupted_attempt_log_row"); + } + return { + id: row.id, + seq: row.seq, + eventType: row.event_type, + attemptId: row.attempt_id, + actionClass: row.action_class, + mode: row.mode, + reason: row.reason, + payload, + createdAt: row.created_at, + }; +} + +function rowToNormalized(row) { + return { + eventType: row.event_type, + attemptId: row.attempt_id, + actionClass: row.action_class, + mode: row.mode, + reason: row.reason, + payloadJson: row.payload_json, + }; +} + +/** + * Opens the append-only attempt log, creating the table on first use. `seq` is a monotonically increasing counter + * maintained by this module (next = current MAX(seq) + 1) with a UNIQUE(seq) constraint. Rows read back in seq ASC + * order. (#4294) + */ +export function initAttemptLog(dbPath = resolveAttemptLogDbPath()) { + const resolvedPath = normalizeDbPath(dbPath); + const db = openLocalStoreDb(resolvedPath); + db.exec(` + CREATE TABLE IF NOT EXISTS attempt_log_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + seq INTEGER NOT NULL UNIQUE, + attempt_id TEXT NOT NULL, + event_type TEXT NOT NULL, + action_class TEXT NOT NULL, + mode TEXT NOT NULL, + reason TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `); + db.exec( + "CREATE INDEX IF NOT EXISTS idx_attempt_log_attempt ON attempt_log_events (attempt_id, seq)", + ); + + const nextSeqStatement = db.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS nextSeq FROM attempt_log_events"); + const appendStatement = db.prepare(` + INSERT INTO attempt_log_events ( + seq, attempt_id, event_type, action_class, mode, reason, payload_json, created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + const getByIdStatement = db.prepare("SELECT * FROM attempt_log_events WHERE id = ?"); + const readAllStatement = db.prepare("SELECT * FROM attempt_log_events ORDER BY seq ASC"); + const readByAttemptStatement = db.prepare( + "SELECT * FROM attempt_log_events WHERE attempt_id = ? ORDER BY seq ASC", + ); + + return { + dbPath: resolvedPath, + appendAttemptLogEvent(event) { + const normalized = normalizeAttemptLogEvent(event); + const createdAt = new Date().toISOString(); + db.exec("BEGIN IMMEDIATE"); + try { + const { nextSeq } = nextSeqStatement.get(); + const result = appendStatement.run( + nextSeq, + normalized.attemptId, + normalized.eventType, + normalized.actionClass, + normalized.mode, + normalized.reason, + normalized.payloadJson, + createdAt, + ); + const entry = rowToEntry(getByIdStatement.get(Number(result.lastInsertRowid))); + db.exec("COMMIT"); + return entry; + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + }, + readAttemptLogEvents(filter = {}) { + const attemptId = normalizeReadAttemptIdFilter(filter.attemptId); + const rows = + attemptId === undefined ? readAllStatement.all() : readByAttemptStatement.all(attemptId); + return rows.map(rowToEntry); + }, + exportAttemptLogJsonl(attemptId) { + const scopedAttemptId = normalizeRequiredAttemptId(attemptId); + const rows = readByAttemptStatement.all(scopedAttemptId); + return formatAttemptLogJsonl(rows.map(rowToNormalized)); + }, + close() { + db.close(); + }, + }; +} + +function getDefaultAttemptLog() { + defaultAttemptLog ??= initAttemptLog(); + return defaultAttemptLog; +} + +export function appendAttemptLogEvent(event) { + return getDefaultAttemptLog().appendAttemptLogEvent(event); +} + +export function readAttemptLogEvents(filter) { + return getDefaultAttemptLog().readAttemptLogEvents(filter); +} + +export function exportAttemptLogJsonl(attemptId) { + return getDefaultAttemptLog().exportAttemptLogJsonl(attemptId); +} + +export function closeDefaultAttemptLog() { + if (!defaultAttemptLog) return; + defaultAttemptLog.close(); + defaultAttemptLog = null; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index f1fe499d71..09bd7bf88a 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -32,7 +32,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/local-store.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/worktree-allocator.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/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-adjudication.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/local-store.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/worktree-allocator.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/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-adjudication.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/attempt-log.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js" }, "dependencies": { "@jsonbored/gittensory-engine": ">=0.1.0 <1.0.0" diff --git a/test/unit/coding-agent-miner.test.ts b/test/unit/coding-agent-miner.test.ts index 0ac6530b4a..e0a0a8f5b6 100644 --- a/test/unit/coding-agent-miner.test.ts +++ b/test/unit/coding-agent-miner.test.ts @@ -110,6 +110,7 @@ describe("attempt log normalization (#4294)", () => { it("exposes a frozen event vocabulary", () => { expect([...ATTEMPT_LOG_EVENT_TYPES]).toEqual([ "attempt_started", + "attempt_tool_edit", "attempt_shadow", "attempt_succeeded", "attempt_failed", diff --git a/test/unit/miner-attempt-log.test.ts b/test/unit/miner-attempt-log.test.ts new file mode 100644 index 0000000000..73a2352d10 --- /dev/null +++ b/test/unit/miner-attempt-log.test.ts @@ -0,0 +1,234 @@ +import { 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"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/miner/attempt-log.js"); +}); + +import { + appendAttemptLogEvent, + closeDefaultAttemptLog, + exportAttemptLogJsonl, + initAttemptLog, + readAttemptLogEvents, + resolveAttemptLogDbPath, +} from "../../packages/gittensory-miner/lib/attempt-log.js"; + +const roots: string[] = []; +const logs: Array<{ close(): void }> = []; + +function tempAttemptLog() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-attempt-log-")); + roots.push(root); + const log = initAttemptLog(join(root, "nested", "attempt-log.sqlite3")); + logs.push(log); + return log; +} + +const baseEvent = { + attemptId: "attempt-1", + actionClass: "codegen", + mode: "live", + reason: "live run", +} as const; + +afterEach(() => { + for (const log of logs.splice(0)) log.close(); + closeDefaultAttemptLog(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("gittensory-miner attempt log (#4294)", () => { + it("resolves the DB path from env override, miner config dir, XDG config, then the home default", () => { + expect(resolveAttemptLogDbPath({ GITTENSORY_MINER_ATTEMPT_LOG_DB: "/custom/a.sqlite3" })).toBe( + "/custom/a.sqlite3", + ); + expect(resolveAttemptLogDbPath({ GITTENSORY_MINER_CONFIG_DIR: "/custom/config" })).toBe( + "/custom/config/attempt-log.sqlite3", + ); + expect(resolveAttemptLogDbPath({ XDG_CONFIG_HOME: "/xdg" })).toBe( + "/xdg/gittensory-miner/attempt-log.sqlite3", + ); + expect(resolveAttemptLogDbPath({})).toMatch(/\/\.config\/gittensory-miner\/attempt-log\.sqlite3$/); + }); + + it("creates the SQLite file with owner-only permissions and reads empty before any append", () => { + const log = tempAttemptLog(); + expect(statSync(log.dbPath).mode & 0o077).toBe(0); + expect(log.readAttemptLogEvents()).toEqual([]); + }); + + it("appends an event and reads it back verbatim (JSON payload round-trip)", () => { + const log = tempAttemptLog(); + const entry = log.appendAttemptLogEvent({ + eventType: "attempt_started", + ...baseEvent, + payload: { workingDirectory: "/tmp/work" }, + }); + expect(entry).toMatchObject({ + seq: 1, + eventType: "attempt_started", + attemptId: "attempt-1", + actionClass: "codegen", + mode: "live", + reason: "live run", + payload: { workingDirectory: "/tmp/work" }, + }); + expect(typeof entry.id).toBe("number"); + expect(typeof entry.createdAt).toBe("string"); + expect(log.readAttemptLogEvents()).toEqual([entry]); + }); + + it("accepts attempt_tool_edit events for tool/edit tracing", () => { + const log = tempAttemptLog(); + const entry = log.appendAttemptLogEvent({ + eventType: "attempt_tool_edit", + ...baseEvent, + reason: "edited src/a.ts", + payload: { path: "src/a.ts", operation: "edit" }, + }); + expect(entry.eventType).toBe("attempt_tool_edit"); + expect(entry.payload).toEqual({ path: "src/a.ts", operation: "edit" }); + }); + + it("assigns a strictly monotonic, gapless, unique seq across many appends", () => { + const log = tempAttemptLog(); + for (let i = 0; i < 50; i += 1) { + log.appendAttemptLogEvent({ + eventType: "attempt_tool_edit", + ...baseEvent, + reason: `edit ${i}`, + payload: { i }, + }); + } + const seqs = log.readAttemptLogEvents().map((entry) => entry.seq); + expect(seqs).toEqual(Array.from({ length: 50 }, (_unused, i) => i + 1)); + expect(new Set(seqs).size).toBe(50); + }); + + it("filters by attemptId and treats a null filter as unscoped", () => { + const log = tempAttemptLog(); + log.appendAttemptLogEvent({ eventType: "attempt_started", ...baseEvent, attemptId: "a-1" }); + log.appendAttemptLogEvent({ + eventType: "attempt_succeeded", + ...baseEvent, + attemptId: "a-2", + reason: "done", + }); + log.appendAttemptLogEvent({ + eventType: "attempt_tool_edit", + ...baseEvent, + attemptId: "a-1", + reason: "edit", + payload: { path: "x.ts" }, + }); + expect(log.readAttemptLogEvents({ attemptId: "a-1" }).map((entry) => entry.eventType)).toEqual([ + "attempt_started", + "attempt_tool_edit", + ]); + expect(log.readAttemptLogEvents({ attemptId: null }).map((entry) => entry.attemptId)).toEqual([ + "a-1", + "a-2", + "a-1", + ]); + }); + + it("exports one attempt's trace as JSONL using the engine formatter", () => { + const log = tempAttemptLog(); + log.appendAttemptLogEvent({ eventType: "attempt_started", ...baseEvent, attemptId: "trace-1" }); + log.appendAttemptLogEvent({ + eventType: "attempt_succeeded", + ...baseEvent, + attemptId: "trace-1", + reason: "done", + }); + log.appendAttemptLogEvent({ eventType: "attempt_started", ...baseEvent, attemptId: "trace-2" }); + const jsonl = log.exportAttemptLogJsonl("trace-1"); + const lines = jsonl.split("\n"); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0]!).eventType).toBe("attempt_started"); + expect(JSON.parse(lines[1]!).eventType).toBe("attempt_succeeded"); + expect(log.exportAttemptLogJsonl("missing")).toBe(""); + }); + + it("rejects malformed events before insert and preserves insertion order", () => { + const log = tempAttemptLog(); + log.appendAttemptLogEvent({ eventType: "attempt_started", ...baseEvent }); + expect(() => + log.appendAttemptLogEvent({ + eventType: "bogus", + ...baseEvent, + }), + ).toThrow(/invalid_event_type/); + expect(log.readAttemptLogEvents()).toHaveLength(1); + }); + + it("rejects invalid attemptId filter types before querying SQLite", () => { + const log = tempAttemptLog(); + expect(() => log.readAttemptLogEvents({ attemptId: 42 as unknown as string })).toThrow( + /invalid_attempt_id/, + ); + expect(() => log.exportAttemptLogJsonl(" ")).toThrow(/invalid_attempt_id/); + }); + + it("rejects a payload JSON would not round-trip verbatim, and accepts a nested JSON-safe one", () => { + const log = tempAttemptLog(); + expect(() => + log.appendAttemptLogEvent({ + eventType: "attempt_tool_edit", + ...baseEvent, + reason: "bad", + payload: { a: undefined }, + }), + ).toThrow(/invalid_payload/); + expect(() => + log.appendAttemptLogEvent({ + eventType: "attempt_tool_edit", + ...baseEvent, + reason: "bad", + payload: { a: Number.NaN }, + }), + ).toThrow(/invalid_payload/); + const entry = log.appendAttemptLogEvent({ + eventType: "attempt_tool_edit", + ...baseEvent, + reason: "ok", + payload: { a: { b: [1, "two", true, null] } }, + }); + expect(log.readAttemptLogEvents()).toContainEqual(entry); + }); + + it("rejects a corrupted payload blob on read instead of returning malformed data", () => { + const log = tempAttemptLog(); + log.appendAttemptLogEvent({ eventType: "attempt_started", ...baseEvent }); + const raw = new DatabaseSync(log.dbPath); + raw.prepare("UPDATE attempt_log_events SET payload_json = ? WHERE id = 1").run("{bad"); + raw.close(); + expect(() => log.readAttemptLogEvents()).toThrow("corrupted_attempt_log_row"); + }); + + it("uses the default singleton helpers and closes cleanly", () => { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-attempt-log-default-")); + roots.push(root); + const previousConfigDir = process.env.GITTENSORY_MINER_CONFIG_DIR; + process.env.GITTENSORY_MINER_CONFIG_DIR = root; + try { + const entry = appendAttemptLogEvent({ eventType: "attempt_started", ...baseEvent }); + expect(readAttemptLogEvents()).toEqual([entry]); + expect(exportAttemptLogJsonl("attempt-1")).toBeTruthy(); + closeDefaultAttemptLog(); + closeDefaultAttemptLog(); + } finally { + if (previousConfigDir === undefined) delete process.env.GITTENSORY_MINER_CONFIG_DIR; + else process.env.GITTENSORY_MINER_CONFIG_DIR = previousConfigDir; + } + }); + + it("is append-only: the module source issues no UPDATE or DELETE against the ledger", () => { + const source = readFileSync("packages/gittensory-miner/lib/attempt-log.js", "utf8"); + expect(source).not.toMatch(/\b(UPDATE|DELETE)\b/i); + }); +}); From 69e3fd2417ca23b5ab070c429dfa4567b781cbfc Mon Sep 17 00:00:00 2001 From: andriypolandki Date: Thu, 9 Jul 2026 22:03:13 -0700 Subject: [PATCH 2/3] fix: ran npm run typecheck --- test/unit/miner-attempt-log.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/miner-attempt-log.test.ts b/test/unit/miner-attempt-log.test.ts index 73a2352d10..c5f9402120 100644 --- a/test/unit/miner-attempt-log.test.ts +++ b/test/unit/miner-attempt-log.test.ts @@ -159,7 +159,7 @@ describe("gittensory-miner attempt log (#4294)", () => { log.appendAttemptLogEvent({ eventType: "attempt_started", ...baseEvent }); expect(() => log.appendAttemptLogEvent({ - eventType: "bogus", + eventType: "bogus" as "attempt_started", ...baseEvent, }), ).toThrow(/invalid_event_type/); From 4f3c184aa7c479c3cf68d10fe5ed09e314692903 Mon Sep 17 00:00:00 2001 From: andriypolandki Date: Thu, 9 Jul 2026 22:06:34 -0700 Subject: [PATCH 3/3] fix: ran npm run typecheck --- .../gittensory-miner/lib/attempt-log.d.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 packages/gittensory-miner/lib/attempt-log.d.ts diff --git a/packages/gittensory-miner/lib/attempt-log.d.ts b/packages/gittensory-miner/lib/attempt-log.d.ts new file mode 100644 index 0000000000..6bc3a36898 --- /dev/null +++ b/packages/gittensory-miner/lib/attempt-log.d.ts @@ -0,0 +1,37 @@ +import type { AttemptLogEvent } from "@jsonbored/gittensory-engine"; + +export type AttemptLogEntry = { + id: number; + seq: number; + eventType: string; + attemptId: string; + actionClass: string; + mode: string; + reason: string; + payload: Record; + createdAt: string; +}; + +export type ReadAttemptLogEventsFilter = { + attemptId?: string | null; +}; + +export type AttemptLog = { + dbPath: string; + appendAttemptLogEvent(event: AttemptLogEvent): AttemptLogEntry; + readAttemptLogEvents(filter?: ReadAttemptLogEventsFilter): AttemptLogEntry[]; + exportAttemptLogJsonl(attemptId: string): string; + close(): void; +}; + +export function resolveAttemptLogDbPath(env?: Record): string; + +export function initAttemptLog(dbPath?: string): AttemptLog; + +export function appendAttemptLogEvent(event: AttemptLogEvent): AttemptLogEntry; + +export function readAttemptLogEvents(filter?: ReadAttemptLogEventsFilter): AttemptLogEntry[]; + +export function exportAttemptLogJsonl(attemptId: string): string; + +export function closeDefaultAttemptLog(): void;