From aabd55d2610f1090a09bb6929d76e79271f69bd3 Mon Sep 17 00:00:00 2001 From: andriypolandki Date: Thu, 9 Jul 2026 14:08:13 -0700 Subject: [PATCH 1/2] feat(miner-manage): add optional Orb telemetry export for miner outcomes (#4277) --- packages/gittensory-miner/lib/orb-export.d.ts | 78 +++++ packages/gittensory-miner/lib/orb-export.js | 269 ++++++++++++++++++ packages/gittensory-miner/package.json | 2 +- test/unit/miner-orb-export.test.ts | 237 +++++++++++++++ 4 files changed, 585 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-miner/lib/orb-export.d.ts create mode 100644 packages/gittensory-miner/lib/orb-export.js create mode 100644 test/unit/miner-orb-export.test.ts diff --git a/packages/gittensory-miner/lib/orb-export.d.ts b/packages/gittensory-miner/lib/orb-export.d.ts new file mode 100644 index 0000000000..1d481a4134 --- /dev/null +++ b/packages/gittensory-miner/lib/orb-export.d.ts @@ -0,0 +1,78 @@ +export function bucketReasonCode(summary: string | null | undefined): string; + +export function isMinerOrbExportEnabled( + env?: Record, + config?: { orbExport?: boolean }, +): boolean; + +export function resolveOrbExportStateDbPath(env?: Record): string; + +export interface OrbExportStateStore { + dbPath: string; + getFlag(key: string): string | null; + setFlag(key: string, value: string): void; + close(): void; +} + +export function initOrbExportStateStore(dbPath?: string): OrbExportStateStore; + +export function closeDefaultOrbExportStateStore(): void; + +export function minerInstanceId(anonSecret: string): string; + +export function hmacField(value: string, secret: string): string; + +export function buildTargetId(repoFullName: string, prNumber: number): string; + +export function getOrCreateAnonSecret(stateStore: OrbExportStateStore): string; + +export function readLastExportedSeq(stateStore: OrbExportStateStore): number; + +export function writeLastExportedSeq(stateStore: OrbExportStateStore, seq: number): void; + +export interface MinerOrbFleetEvent { + repo_hash: string; + pr_hash: string; + gate_verdict: string | null; + outcome: string; + reversal_flag: "none"; + gate_reasoncode_bucket: string; + time_to_close_ms: null; + decision_timestamp: null; + outcome_timestamp: string; +} + +export interface MinerOrbLedgerEntry { + seq: number; + type: string; + repoFullName: string; + payload: unknown; + createdAt?: string; +} + +export function ledgerEntryToFleetEvent( + entry: MinerOrbLedgerEntry, + options?: { secret?: string; anonymize?: boolean }, +): MinerOrbFleetEvent | null; + +export interface MinerOrbEventLedger { + readEvents(filter?: { since?: number; repoFullName?: string }): unknown[]; +} + +export function selectPrOutcomeEvents( + eventLedger: MinerOrbEventLedger, + since: number, + batchSize: number, +): MinerOrbLedgerEntry[]; + +export interface ExportMinerOrbBatchOptions { + env?: Record; + config?: { orbExport?: boolean }; + eventLedger?: MinerOrbEventLedger; + stateStore?: OrbExportStateStore; + stateDbPath?: string; + batchSize?: number; + fetchFn?: typeof fetch; +} + +export function exportMinerOrbBatch(options?: ExportMinerOrbBatchOptions): Promise; diff --git a/packages/gittensory-miner/lib/orb-export.js b/packages/gittensory-miner/lib/orb-export.js new file mode 100644 index 0000000000..a5bcd8d5b3 --- /dev/null +++ b/packages/gittensory-miner/lib/orb-export.js @@ -0,0 +1,269 @@ +// Optional anonymized Orb telemetry export for miner PR outcomes (#4277). Mirrors the self-host fleet exporter +// posture in src/selfhost/orb-collector.ts — HMAC-anonymized repo/PR identifiers, bucketed reason codes, signed +// POST to the central collector — but OPT-IN (default OFF) because the miner runs on a contributor laptop with +// no GitHub App key and a higher consent bar than a maintainer's self-hosted instance. +// +// GITTENSORY_MINER_ORB_EXPORT=1 — explicit opt-in (or config.orbExport === true) +// ORB_AIR_GAP=true — air-gapped/offline: compute locally, never send (symmetry with self-host) +// ORB_ANONYMIZE=true — HMAC-hash repo/PR before export (default: true) +// ORB_COLLECTOR_URL= — endpoint (default: gittensory's hosted collector) +// ORB_COLLECTOR_TOKEN= — bearer credential for the hosted collector +// +// Source rows are miner-local {@link MINER_PR_OUTCOME_EVENT} entries from the injected event ledger (the sibling +// pr-outcome.js writer), polled via readEvents({ since }) — the same seq cursor pattern as event-ledger.js. A +// dedicated per-miner anonymization secret is persisted locally (never a GitHub token). No diffs, code, comments, +// logins, or commit SHAs leave the machine. +import { chmodSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { createHash, createHmac, randomBytes } from "node:crypto"; +import { DatabaseSync } from "node:sqlite"; +import { MINER_PR_OUTCOME_EVENT, normalizePrOutcomePayload } from "./pr-outcome.js"; + +const ANON_SECRET_KEY = "orb:anon_secret"; +const LAST_EXPORTED_SEQ_KEY = "orb:last_exported_seq"; +const DEFAULT_COLLECTOR_URL = "https://gittensory-api.aethereal.dev/v1/orb/ingest"; +const defaultDbFileName = "orb-export-state.sqlite3"; +let defaultOrbExportStateStore = null; + +/** Map the gate's free-text reasonCode to a fixed, low-cardinality category — ported verbatim from + * src/selfhost/orb-collector.ts so the fleet shares one taxonomy across self-host and miner exporters. */ +export function bucketReasonCode(summary) { + if (!summary) return "none"; + const s = summary.toLowerCase(); + if (s.includes("linked_issue") || s.includes("linked issue")) return "issue_policy"; + if (s.includes("duplicate")) return "duplicate_risk"; + if (s.includes("slop")) return "slop_advisory"; + if (s.includes("ai_review") || s.includes("ai_consensus") || s.includes("consensus")) return "ai_quality"; + if (s.includes("self_authored") || s.includes("author") || s.includes("maintainer_cut")) return "author_policy"; + if (s.includes("ci_") || s.includes("ci state") || s.includes("ci passed")) return "ci_readiness"; + return "other"; +} + +/** True when the miner Orb exporter is explicitly enabled and not air-gapped. Default OFF. */ +export function isMinerOrbExportEnabled(env = process.env, config = {}) { + if ((env.ORB_AIR_GAP ?? "").toLowerCase() === "true") return false; + if (config.orbExport === true) return true; + return (env.GITTENSORY_MINER_ORB_EXPORT ?? "").trim() === "1"; +} + +export function resolveOrbExportStateDbPath(env = process.env) { + const explicitPath = typeof env.GITTENSORY_MINER_ORB_EXPORT_STATE_DB === "string" + ? env.GITTENSORY_MINER_ORB_EXPORT_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 ?? resolveOrbExportStateDbPath()).trim(); + if (!path) throw new Error("invalid_orb_export_state_db_path"); + return path; +} + +function normalizeOptionalSince(since) { + if (since === undefined || since === null) return 0; + if (typeof since !== "number" || !Number.isInteger(since) || since < 0) { + throw new Error("invalid_since"); + } + return since; +} + +/** + * Local SQLite store for the miner's dedicated anonymization secret and export seq watermark — mirrors + * getOrCreateAnonSecret's system_flags persistence without requiring D1. + */ +export function initOrbExportStateStore(dbPath = resolveOrbExportStateDbPath()) { + const resolvedPath = normalizeDbPath(dbPath); + mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o700 }); + const db = new DatabaseSync(resolvedPath); + chmodSync(resolvedPath, 0o600); + db.exec("PRAGMA busy_timeout = 5000"); + db.exec(` + CREATE TABLE IF NOT EXISTS orb_export_flags ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); + + const getStatement = db.prepare("SELECT value FROM orb_export_flags WHERE key = ?"); + const setStatement = db.prepare(` + INSERT INTO orb_export_flags (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at + `); + + return { + dbPath: resolvedPath, + getFlag(key) { + if (typeof key !== "string" || !key.trim()) throw new Error("invalid_flag_key"); + const row = getStatement.get(key.trim()); + return typeof row?.value === "string" ? row.value : null; + }, + setFlag(key, value) { + if (typeof key !== "string" || !key.trim()) throw new Error("invalid_flag_key"); + if (typeof value !== "string" || !value.trim()) throw new Error("invalid_flag_value"); + setStatement.run(key.trim(), value.trim(), new Date().toISOString()); + }, + close() { + db.close(); + }, + }; +} + +function getDefaultOrbExportStateStore() { + defaultOrbExportStateStore ??= initOrbExportStateStore(); + return defaultOrbExportStateStore; +} + +export function closeDefaultOrbExportStateStore() { + if (!defaultOrbExportStateStore) return; + defaultOrbExportStateStore.close(); + defaultOrbExportStateStore = null; +} + +/** Stable miner instance identifier derived from the dedicated anonymization secret (no PII, no GitHub tokens). */ +export function minerInstanceId(anonSecret) { + return createHash("sha256").update(`miner:${anonSecret}`).digest("hex").slice(0, 16); +} + +/** HMAC a string with the miner's dedicated anonymization secret. */ +export function hmacField(value, secret) { + return createHmac("sha256", secret).update(value).digest("hex").slice(0, 24); +} + +export function buildTargetId(repoFullName, prNumber) { + return `${repoFullName}#${prNumber}`; +} + +/** + * The miner's DEDICATED anonymization secret: a 256-bit random key generated once and persisted locally, + * single-purpose — never a GitHub token (key separation). The collector never holds it. + */ +export function getOrCreateAnonSecret(stateStore) { + if (!stateStore || typeof stateStore.getFlag !== "function" || typeof stateStore.setFlag !== "function") { + throw new Error("invalid_orb_export_state_store"); + } + const existing = stateStore.getFlag(ANON_SECRET_KEY); + if (existing) return existing; + const generated = randomBytes(32).toString("hex"); + stateStore.setFlag(ANON_SECRET_KEY, generated); + return stateStore.getFlag(ANON_SECRET_KEY) ?? generated; +} + +export function readLastExportedSeq(stateStore) { + const raw = stateStore.getFlag(LAST_EXPORTED_SEQ_KEY); + if (raw === null) return 0; + const parsed = Number.parseInt(raw, 10); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0; +} + +export function writeLastExportedSeq(stateStore, seq) { + if (!Number.isInteger(seq) || seq < 0) throw new Error("invalid_export_seq"); + stateStore.setFlag(LAST_EXPORTED_SEQ_KEY, String(seq)); +} + +/** Map one validated pr_outcome ledger entry to the hosted collector's fleet event shape. */ +export function ledgerEntryToFleetEvent(entry, options = {}) { + const secret = options.secret; + if (typeof secret !== "string" || !secret.trim()) throw new Error("invalid_anon_secret"); + const anonymize = options.anonymize !== false; + if (typeof entry?.repoFullName !== "string" || !entry.repoFullName.trim()) return null; + const payload = normalizePrOutcomePayload(entry.payload); + if (!payload) return null; + const repoFullName = entry.repoFullName.trim(); + const targetId = buildTargetId(repoFullName, payload.prNumber); + const outcomeAt = payload.closedAt ?? entry.createdAt ?? new Date().toISOString(); + return { + repo_hash: anonymize ? hmacField(repoFullName, secret) : repoFullName, + pr_hash: anonymize ? hmacField(targetId, secret) : targetId, + gate_verdict: null, + outcome: payload.decision, + reversal_flag: "none", + gate_reasoncode_bucket: bucketReasonCode(payload.reason), + time_to_close_ms: null, + decision_timestamp: null, + outcome_timestamp: outcomeAt, + }; +} + +/** Read pr_outcome ledger rows strictly after `since`, preserving seq order, capped at `batchSize`. */ +export function selectPrOutcomeEvents(eventLedger, since, batchSize) { + const normalizedSince = normalizeOptionalSince(since); + if (!eventLedger || typeof eventLedger.readEvents !== "function") throw new Error("invalid_event_ledger"); + if (!Number.isInteger(batchSize) || batchSize <= 0) throw new Error("invalid_batch_size"); + const events = eventLedger.readEvents({ since: normalizedSince }); + const selected = []; + for (const entry of Array.isArray(events) ? events : []) { + if (entry?.type !== MINER_PR_OUTCOME_EVENT) continue; + if (!normalizePrOutcomePayload(entry.payload)) continue; + selected.push(entry); + if (selected.length >= batchSize) break; + } + return selected; +} + +/** + * Export newly-recorded miner PR outcomes (since the local seq watermark) to the central collector. Opt-in only; + * returns the number of events exported (0 when disabled, air-gapped, or nothing new). + */ +export async function exportMinerOrbBatch(options = {}) { + const env = options.env ?? process.env; + const config = options.config ?? {}; + if (!isMinerOrbExportEnabled(env, config)) return 0; + if ((env.ORB_AIR_GAP ?? "").toLowerCase() === "true") return 0; + + const eventLedger = options.eventLedger; + if (!eventLedger || typeof eventLedger.readEvents !== "function") throw new Error("invalid_event_ledger"); + + const stateStore = options.stateStore ?? getDefaultOrbExportStateStore(); + const fetchFn = options.fetchFn ?? fetch; + const batchSize = options.batchSize ?? 200; + + const secret = getOrCreateAnonSecret(stateStore); + const anonymize = (env.ORB_ANONYMIZE ?? "true").toLowerCase() !== "false"; + const instance = minerInstanceId(secret); + const since = readLastExportedSeq(stateStore); + const entries = selectPrOutcomeEvents(eventLedger, since, batchSize); + if (entries.length === 0) return 0; + + const fleetEvents = entries.map((entry) => ledgerEntryToFleetEvent(entry, { secret, anonymize })).filter(Boolean); + if (fleetEvents.length === 0) return 0; + + const payload = { instance_id: instance, events: fleetEvents }; + const body = JSON.stringify(payload); + const signature = createHmac("sha256", secret).update(body).digest("hex"); + const collectorUrl = env.ORB_COLLECTOR_URL ?? DEFAULT_COLLECTOR_URL; + const collectorToken = env.ORB_COLLECTOR_TOKEN; + + try { + const res = await fetchFn(collectorUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "x-orb-signature": `sha256=${signature}`, + "x-orb-instance": instance, + ...(collectorToken ? { authorization: `Bearer ${collectorToken}` } : {}), + }, + body, + }); + if (!res.ok) return 0; + } catch { + return 0; + } + + writeLastExportedSeq(stateStore, entries[entries.length - 1].seq); + return fleetEvents.length; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 91a2971093..720f346ce0 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/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-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/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-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/pr-outcome.js && node --check lib/orb-export.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/miner-orb-export.test.ts b/test/unit/miner-orb-export.test.ts new file mode 100644 index 0000000000..ac6735ba72 --- /dev/null +++ b/test/unit/miner-orb-export.test.ts @@ -0,0 +1,237 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { MINER_PR_OUTCOME_EVENT } from "../../packages/gittensory-miner/lib/pr-outcome.js"; +import { + bucketReasonCode, + exportMinerOrbBatch, + getOrCreateAnonSecret, + initOrbExportStateStore, + isMinerOrbExportEnabled, + ledgerEntryToFleetEvent, + readLastExportedSeq, + selectPrOutcomeEvents, + writeLastExportedSeq, +} from "../../packages/gittensory-miner/lib/orb-export.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }); + } +}); + +function tempStateStore() { + const dir = mkdtempSync(join(tmpdir(), "miner-orb-export-")); + tempDirs.push(dir); + return initOrbExportStateStore(join(dir, "orb-export-state.sqlite3")); +} + +function mockLedger() { + const events: Array> = []; + let seq = 0; + return { + append(type: string, repoFullName: string, payload: Record, createdAt?: string) { + const entry = { + seq: ++seq, + type, + repoFullName, + payload, + createdAt: createdAt ?? "2026-07-09T12:00:00.000Z", + }; + events.push(entry); + return entry; + }, + readEvents(filter: { since?: number; repoFullName?: string } = {}) { + return events.filter((event) => { + if (filter.repoFullName !== undefined && event.repoFullName !== filter.repoFullName) return false; + if (filter.since !== undefined && (event.seq as number) <= filter.since) return false; + return true; + }); + }, + _events: events, + }; +} + +describe("bucketReasonCode() (#4277)", () => { + it("shares the self-host orb-collector taxonomy", () => { + expect(bucketReasonCode(null)).toBe("none"); + expect(bucketReasonCode("superseded_by_duplicate")).toBe("duplicate_risk"); + expect(bucketReasonCode("missing_linked_issue")).toBe("issue_policy"); + expect(bucketReasonCode("gate_close")).toBe("other"); + }); +}); + +describe("isMinerOrbExportEnabled() (#4277)", () => { + it("defaults OFF unless explicitly enabled and not air-gapped", () => { + expect(isMinerOrbExportEnabled({}, {})).toBe(false); + expect(isMinerOrbExportEnabled({ GITTENSORY_MINER_ORB_EXPORT: "1" }, {})).toBe(true); + expect(isMinerOrbExportEnabled({}, { orbExport: true })).toBe(true); + expect(isMinerOrbExportEnabled({ GITTENSORY_MINER_ORB_EXPORT: "1", ORB_AIR_GAP: "true" }, {})).toBe(false); + }); +}); + +describe("getOrCreateAnonSecret() (#4277)", () => { + it("generates a dedicated 256-bit secret once and reuses it", () => { + const store = tempStateStore(); + const first = getOrCreateAnonSecret(store); + const second = getOrCreateAnonSecret(store); + expect(first).toMatch(/^[0-9a-f]{64}$/); + expect(second).toBe(first); + }); +}); + +describe("ledgerEntryToFleetEvent() (#4277)", () => { + it("HMACs repo/PR identifiers and buckets the rejection reason", () => { + const store = tempStateStore(); + const secret = getOrCreateAnonSecret(store); + const event = ledgerEntryToFleetEvent( + { + seq: 1, + type: MINER_PR_OUTCOME_EVENT, + repoFullName: "acme/widgets", + payload: { prNumber: 12, decision: "closed", reason: "superseded_by_duplicate", closedAt: "2026-07-09T00:00:00Z" }, + createdAt: "2026-07-09T12:00:00.000Z", + }, + { secret, anonymize: true }, + ); + expect(event?.repo_hash).not.toBe("acme/widgets"); + expect(event?.pr_hash).not.toBe("acme/widgets#12"); + expect(event?.gate_reasoncode_bucket).toBe("duplicate_risk"); + expect(event?.outcome).toBe("closed"); + expect(event?.gate_verdict).toBeNull(); + }); + + it("can emit plaintext identifiers when anonymize is disabled", () => { + const store = tempStateStore(); + const secret = getOrCreateAnonSecret(store); + const event = ledgerEntryToFleetEvent( + { + seq: 1, + type: MINER_PR_OUTCOME_EVENT, + repoFullName: "acme/widgets", + payload: { prNumber: 12, decision: "merged" }, + }, + { secret, anonymize: false }, + ); + expect(event?.repo_hash).toBe("acme/widgets"); + expect(event?.pr_hash).toBe("acme/widgets#12"); + }); +}); + +describe("selectPrOutcomeEvents() (#4277)", () => { + it("returns only pr_outcome rows strictly after since, in seq order", () => { + const ledger = mockLedger(); + ledger.append("plan_built", "acme/widgets", { step: 1 }); + ledger.append(MINER_PR_OUTCOME_EVENT, "acme/widgets", { prNumber: 1, decision: "merged" }); + ledger.append(MINER_PR_OUTCOME_EVENT, "acme/a", { prNumber: 2, decision: "closed", reason: "gate_close" }); + expect(selectPrOutcomeEvents(ledger, 0, 10)).toHaveLength(2); + expect(selectPrOutcomeEvents(ledger, 2, 10)).toHaveLength(1); + expect(selectPrOutcomeEvents(ledger, 3, 10)).toHaveLength(0); + }); +}); + +describe("exportMinerOrbBatch() (#4277)", () => { + it("is a no-op when export is disabled", async () => { + const ledger = mockLedger(); + ledger.append(MINER_PR_OUTCOME_EVENT, "acme/widgets", { prNumber: 1, decision: "merged" }); + let called = false; + const n = await exportMinerOrbBatch({ + env: {}, + eventLedger: ledger, + stateStore: tempStateStore(), + fetchFn: async () => { + called = true; + return new Response(null, { status: 200 }); + }, + }); + expect(n).toBe(0); + expect(called).toBe(false); + }); + + it("respects ORB_AIR_GAP even when opt-in is set", async () => { + const ledger = mockLedger(); + ledger.append(MINER_PR_OUTCOME_EVENT, "acme/widgets", { prNumber: 1, decision: "merged" }); + let called = false; + const n = await exportMinerOrbBatch({ + env: { GITTENSORY_MINER_ORB_EXPORT: "1", ORB_AIR_GAP: "true" }, + eventLedger: ledger, + stateStore: tempStateStore(), + fetchFn: async () => { + called = true; + return new Response(null, { status: 200 }); + }, + }); + expect(n).toBe(0); + expect(called).toBe(false); + }); + + it("ships anonymized payloads and advances the seq cursor on success", async () => { + const ledger = mockLedger(); + ledger.append(MINER_PR_OUTCOME_EVENT, "acme/widgets", { prNumber: 1, decision: "merged" }); + ledger.append(MINER_PR_OUTCOME_EVENT, "acme/widgets", { prNumber: 2, decision: "closed", reason: "gate_close" }); + const stateStore = tempStateStore(); + const bodies: unknown[] = []; + + expect(await exportMinerOrbBatch({ + env: { GITTENSORY_MINER_ORB_EXPORT: "1" }, + eventLedger: ledger, + stateStore, + batchSize: 1, + fetchFn: async (_url, init) => { + bodies.push(JSON.parse(String(init?.body))); + return new Response(null, { status: 200 }); + }, + })).toBe(1); + + expect(readLastExportedSeq(stateStore)).toBe(1); + const firstPayload = bodies[0] as { events: Array<{ repo_hash: string; pr_hash: string }> }; + expect(firstPayload.events[0]?.repo_hash).not.toBe("acme/widgets"); + expect(firstPayload.events[0]?.pr_hash).not.toContain("acme/widgets"); + + expect(await exportMinerOrbBatch({ + env: { GITTENSORY_MINER_ORB_EXPORT: "1" }, + eventLedger: ledger, + stateStore, + batchSize: 1, + fetchFn: async (_url, init) => { + bodies.push(JSON.parse(String(init?.body))); + return new Response(null, { status: 200 }); + }, + })).toBe(1); + + expect(readLastExportedSeq(stateStore)).toBe(2); + expect(bodies).toHaveLength(2); + + expect(await exportMinerOrbBatch({ + env: { GITTENSORY_MINER_ORB_EXPORT: "1" }, + eventLedger: ledger, + stateStore, + fetchFn: async () => new Response(null, { status: 200 }), + })).toBe(0); + }); + + it("does not advance the cursor when the collector rejects the batch", async () => { + const ledger = mockLedger(); + ledger.append(MINER_PR_OUTCOME_EVENT, "acme/widgets", { prNumber: 1, decision: "merged" }); + const stateStore = tempStateStore(); + writeLastExportedSeq(stateStore, 0); + + expect(await exportMinerOrbBatch({ + env: { GITTENSORY_MINER_ORB_EXPORT: "1" }, + eventLedger: ledger, + stateStore, + fetchFn: async () => new Response(null, { status: 503 }), + })).toBe(0); + expect(readLastExportedSeq(stateStore)).toBe(0); + }); + + it("throws when the injected event ledger is unusable", async () => { + await expect(exportMinerOrbBatch({ + env: { GITTENSORY_MINER_ORB_EXPORT: "1" }, + stateStore: tempStateStore(), + })).rejects.toThrow("invalid_event_ledger"); + }); +}); From 91ca9e200672e3ebbeb307381bb3cea92bb0a655 Mon Sep 17 00:00:00 2001 From: andriypolandki Date: Thu, 9 Jul 2026 14:23:28 -0700 Subject: [PATCH 2/2] fix --- packages/gittensory-miner/lib/orb-export.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gittensory-miner/lib/orb-export.js b/packages/gittensory-miner/lib/orb-export.js index a5bcd8d5b3..6b3ff3c7a9 100644 --- a/packages/gittensory-miner/lib/orb-export.js +++ b/packages/gittensory-miner/lib/orb-export.js @@ -7,7 +7,7 @@ // ORB_AIR_GAP=true — air-gapped/offline: compute locally, never send (symmetry with self-host) // ORB_ANONYMIZE=true — HMAC-hash repo/PR before export (default: true) // ORB_COLLECTOR_URL= — endpoint (default: gittensory's hosted collector) -// ORB_COLLECTOR_TOKEN= — bearer credential for the hosted collector +// ORB_COLLECTOR_TOKEN — bearer credential for the hosted collector (env var) // // Source rows are miner-local {@link MINER_PR_OUTCOME_EVENT} entries from the injected event ledger (the sibling // pr-outcome.js writer), polled via readEvents({ since }) — the same seq cursor pattern as event-ledger.js. A