diff --git a/migrations/0148_ams_signals.sql b/migrations/0148_ams_signals.sql new file mode 100644 index 0000000000..add6a4e5eb --- /dev/null +++ b/migrations/0148_ams_signals.sql @@ -0,0 +1,19 @@ +-- Gittensory AMS (#5681) — central telemetry collector store, mirroring orb_signals' pattern for the miner +-- product. Receives anonymized PR-outcome batches from opt-in AMS instances (orb-export.js). repo_hash and +-- pr_hash are HMAC-anonymized by the sender before this table ever sees them — no repo names, owner +-- identifiers, or PR content is stored here. A separate table from orb_signals rather than a shared +-- discriminator column: AMS has no gate_verdict/reversal_flag concept (a miner submission isn't gated the +-- way a reviewed PR is), so forcing both products into one row shape would mean a pile of always-null +-- Orb-only columns on every AMS row. +CREATE TABLE IF NOT EXISTS ams_signals ( + id INTEGER PRIMARY KEY, + instance_id TEXT NOT NULL, + repo_hash TEXT NOT NULL, + pr_hash TEXT NOT NULL, + decision TEXT NOT NULL CHECK (decision IN ('merged', 'closed')), + reason_bucket TEXT, + closed_at TEXT, + received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (instance_id, pr_hash) +); +CREATE INDEX IF NOT EXISTS ams_signals_instance ON ams_signals (instance_id, received_at); diff --git a/migrations/0149_ams_instances.sql b/migrations/0149_ams_instances.sql new file mode 100644 index 0000000000..4c4bdb1e0f --- /dev/null +++ b/migrations/0149_ams_instances.sql @@ -0,0 +1,14 @@ +-- Gittensory AMS (#5681) — instance registration gate, mirroring orb_instances (see that table's own +-- migration for the full trust-model rationale). Every AMS instance that POSTs an anonymized batch to +-- /v1/ams/ingest is recorded here on first contact, but signals only count toward any future AMS-side +-- aggregate until an operator explicitly registers it (registered=1) — same das-github-mirror-modeled +-- trust anchor Orb already uses, so a stranger can't move an aggregate until a human opts them in. +CREATE TABLE IF NOT EXISTS ams_instances ( + instance_id TEXT PRIMARY KEY NOT NULL, + registered INTEGER NOT NULL DEFAULT 0, + first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + registered_at TEXT +); + +CREATE INDEX IF NOT EXISTS ams_instances_registered_idx ON ams_instances(registered); diff --git a/packages/gittensory-miner/bin/gittensory-miner.js b/packages/gittensory-miner/bin/gittensory-miner.js index 9cbc8dad31..3336d38cfb 100755 --- a/packages/gittensory-miner/bin/gittensory-miner.js +++ b/packages/gittensory-miner/bin/gittensory-miner.js @@ -99,7 +99,7 @@ if (cliArgs[0] === "queue") { } if (cliArgs[0] === "orb" && cliArgs[1] === "export") { - process.exit(runOrbExportCli(cliArgs.slice(2))); + process.exit(await runOrbExportCli(cliArgs.slice(2))); } if (cliArgs[0] === "claim") { diff --git a/packages/gittensory-miner/docs/env-reference.md b/packages/gittensory-miner/docs/env-reference.md index f74b2e6800..dd05f72fc5 100644 --- a/packages/gittensory-miner/docs/env-reference.md +++ b/packages/gittensory-miner/docs/env-reference.md @@ -4,6 +4,8 @@ Generated by `npm run miner:env-reference`. Do not edit manually. | Name | First reference | Default | | --- | --- | --- | +| `GITTENSORY_MINER_AMS_COLLECTOR_TOKEN` | `lib/orb-export.js` | `""` | +| `GITTENSORY_MINER_AMS_COLLECTOR_URL` | `lib/orb-export.js` | `""` | | `GITTENSORY_MINER_AMS_POLICY_PATH` | `lib/ams-policy.js` | (none) | | `GITTENSORY_MINER_ATTEMPT_LOG_DB` | `lib/attempt-log.js` | (none) | | `GITTENSORY_MINER_CLAIM_LEDGER_DB` | `lib/claim-ledger.js` | (none) | diff --git a/packages/gittensory-miner/docs/observability.md b/packages/gittensory-miner/docs/observability.md index 8cfaca7fb3..b66f1dc688 100644 --- a/packages/gittensory-miner/docs/observability.md +++ b/packages/gittensory-miner/docs/observability.md @@ -94,3 +94,32 @@ Then point your own `prometheus.yml` at node_exporter as usual — no changes to are needed. See [`prometheus/rules/alerts.yml`](../../../prometheus/rules/alerts.yml)'s `gittensory-miner-prediction` / `gittensory-miner-portfolio-queue` / `gittensory-miner-governor` rule groups for alert rules that already target these exact metric names. + +## Anonymized central telemetry (opt-in, off by default) + +Everything above stays entirely on your own machine. Separately, the miner can send a small, anonymized batch +of its own PR-outcome history to gittensory's hosted AMS collector — the same fleet-growth/usage telemetry Orb's +self-host collector already sends for maintainers, mirrored for contributors: + +```sh +gittensory-miner orb export --enable --send +``` + +- **`--enable`** alone only builds and prints the anonymized batch locally — no network call, so you can inspect + exactly what would be sent before ever transmitting anything. +- **`--enable --send`** additionally POSTs that batch to the collector and advances a local cursor, so the next + run only sends events since the last successful send. + +**What's sent:** for each of your own resolved PRs — an HMAC-anonymized repo hash and PR hash (a per-instance +secret generated once and kept only on your machine; the collector never holds it and can't reverse the hash), the +`merged`/`closed` decision, a fixed low-cardinality rejection-reason bucket, and the close timestamp. No repo +names, PR numbers, diffs, code, or free text ever leave your machine. + +**Nothing is sent unless you explicitly opt in.** There is no default-on behavior here (unlike Orb's own +maintainer-side collector) — every invocation requires `--enable --send` explicitly. + +| Variable | Purpose | +| --- | --- | +| `GITTENSORY_MINER_AMS_COLLECTOR_URL` | Override the collector endpoint (default: gittensory's hosted collector). | +| `GITTENSORY_MINER_AMS_COLLECTOR_TOKEN` | Optional bearer credential, only needed if your collector requires one. | +| `GITTENSORY_MINER_ORB_EXPORT_DB` | Override the local secret+cursor store path (default: `orb-export.sqlite3` under `GITTENSORY_MINER_CONFIG_DIR`). | diff --git a/packages/gittensory-miner/lib/cli.js b/packages/gittensory-miner/lib/cli.js index 2d528b36c3..4524cea08b 100644 --- a/packages/gittensory-miner/lib/cli.js +++ b/packages/gittensory-miner/lib/cli.js @@ -55,7 +55,7 @@ export function printHelp(input) { " gittensory-miner hooks check --tool --input [--json]", " gittensory-miner state get [--json]", " gittensory-miner state set [--dry-run] [--json]", - " gittensory-miner orb export [--enable] [--dry-run] [--json] Build the opt-in anonymized telemetry batch", + " gittensory-miner orb export [--enable] [--send] [--dry-run] [--json] Build (and optionally send) the opt-in anonymized telemetry batch", " gittensory-miner purge --repo [--dry-run] [--json]", " Right-to-be-forgotten: delete a repo's rows from every local store", "", diff --git a/packages/gittensory-miner/lib/orb-export.d.ts b/packages/gittensory-miner/lib/orb-export.d.ts index c7b749909c..c14b601959 100644 --- a/packages/gittensory-miner/lib/orb-export.d.ts +++ b/packages/gittensory-miner/lib/orb-export.d.ts @@ -21,6 +21,10 @@ export interface OrbExportStore { close(): void; } +/** Result of sending a batch to the AMS collector — `error` present only on a non-2xx response, a network + * failure, or an empty batch (never thrown). */ +export type AmsExportSendResult = { sent: number; error?: string }; + /** A pr_outcome record as produced by `readPrOutcomes` (the local ledger's latest-per-PR reduction). */ export type OrbExportOutcome = NormalizedPrOutcomePayload & { repoFullName: string }; @@ -41,7 +45,25 @@ export function collectOrbExportBatch(options?: { enabled?: boolean; }): OrbExportRow[] | null; -export type ParsedOrbExportArgs = { json: boolean; enable: boolean; dryRun: boolean } | { error: string }; +export function amsInstanceId(secret: string): string; + +export function filterBatchSinceCursor(batch: OrbExportRow[], cursor: string | null): OrbExportRow[]; + +export function latestClosedAt(batch: OrbExportRow[]): string | null; + +export const DEFAULT_AMS_COLLECTOR_URL: string; + +export function resolveAmsCollectorUrl(env?: Record): string; + +export function sendAmsExportBatch(options: { + batch: OrbExportRow[]; + secret: string; + collectorUrl?: string; + collectorToken?: string | undefined; + fetchFn?: typeof fetch; +}): Promise; + +export type ParsedOrbExportArgs = { json: boolean; enable: boolean; send: boolean; dryRun: boolean } | { error: string }; export function parseOrbExportArgs(args: string[]): ParsedOrbExportArgs; @@ -50,5 +72,11 @@ export function runOrbExportCli( options?: { openOrbExportStore?: () => OrbExportStore; initEventLedger?: () => PrOutcomeLedgerReader; + sendAmsExportBatch?: (options: { + batch: OrbExportRow[]; + secret: string; + collectorToken?: string | undefined; + }) => Promise; + env?: Record; }, -): number; +): Promise; diff --git a/packages/gittensory-miner/lib/orb-export.js b/packages/gittensory-miner/lib/orb-export.js index 19b5fc9f2b..265f023982 100644 --- a/packages/gittensory-miner/lib/orb-export.js +++ b/packages/gittensory-miner/lib/orb-export.js @@ -2,19 +2,21 @@ import { chmodSync, mkdirSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { DatabaseSync } from "node:sqlite"; -import { createHmac, randomBytes } from "node:crypto"; +import { createHash, createHmac } from "node:crypto"; +import { generateAnonSecret, hmacAnonymize as engineHmacAnonymize } from "@loopover/engine"; import { readPrOutcomes } from "./pr-outcome.js"; import { initEventLedger } from "./event-ledger.js"; import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js"; -// Optional anonymized Orb telemetry export (#4277). The self-host Orb collector (src/selfhost/orb-collector.ts, -// #1255) is ALWAYS-ON for a maintainer's own instance; a miner runs on a third-party contributor's laptop with a -// much lower consent bar, so this export is OPT-IN (default OFF) — hence "optional". It mirrors the collector's -// privacy posture: repo/PR identifiers are HMAC-anonymized with a per-instance DEDICATED secret (generated once, -// persisted locally, single-purpose), and only a fixed low-cardinality reason bucket + the decision leave — never -// raw repo names or free text. The data source is the local pr_outcome ledger (pr-outcome.js), not a hosted D1. -// This module builds the anonymized batch and manages the local secret + cursor; performing the network POST is the -// caller's job, so this stays pure over its inputs + local store and needs no network to test. +// Optional anonymized Orb telemetry export (#4277, network send wired in #5681). The self-host Orb collector +// (src/selfhost/orb-collector.ts, #1255) is ALWAYS-ON for a maintainer's own instance; a miner runs on a +// third-party contributor's laptop with a much lower consent bar, so this export is OPT-IN (default OFF) — +// hence "optional". It mirrors the collector's privacy posture: repo/PR identifiers are HMAC-anonymized with a +// per-instance DEDICATED secret (generated once, persisted locally, single-purpose), and only a fixed +// low-cardinality reason bucket + the decision leave — never raw repo names or free text. The data source is +// the local pr_outcome ledger (pr-outcome.js), not a hosted D1. `generateAnonSecret`/`hmacAnonymize` are the +// same primitive src/selfhost/orb-collector.ts uses (@loopover/engine, #5680) — one anonymization +// implementation shared by both products instead of two independently-maintained copies. /** OPT-IN: a laptop miner exports nothing unless a contributor explicitly turns it on. */ export const ORB_EXPORT_ENABLED_BY_DEFAULT = false; @@ -45,10 +47,12 @@ function normalizeDbPath(dbPath) { return path; } -/** HMAC a value with the per-instance secret — mirrors orb-collector.ts's hmacField (sha256, first 24 hex). */ +/** HMAC a value with the per-instance secret. Validates the secret (the shared engine primitive stays pure + * and doesn't), then delegates the actual hash to @loopover/engine's hmacAnonymize — the same primitive + * src/selfhost/orb-collector.ts uses, so both products anonymize identically. */ export function hmacAnonymize(value, secret) { if (typeof secret !== "string" || !secret) throw new Error("invalid_anon_secret"); - return createHmac("sha256", secret).update(String(value)).digest("hex").slice(0, 24); + return engineHmacAnonymize(String(value), secret); } /** @@ -104,7 +108,7 @@ export function openOrbExportStore(dbPath = resolveOrbExportDbPath()) { getOrCreateAnonSecret() { const existing = readValue(ANON_SECRET_KEY); if (existing) return existing; - const generated = randomBytes(32).toString("hex"); + const generated = generateAnonSecret(); setStatement.run(ANON_SECRET_KEY, generated); return generated; }, @@ -134,10 +138,72 @@ export function collectOrbExportBatch({ store, eventLedger, enabled = ORB_EXPORT return buildAnonymizedOrbBatch(outcomes, store.getOrCreateAnonSecret()); } -const ORB_EXPORT_USAGE = "Usage: gittensory-miner orb export [--enable] [--dry-run] [--json]"; +/** Stable per-instance identifier: a hash of the instance's own anon secret (no App-id concept on the AMS side, + * unlike orb-collector.ts's instanceId — a miner laptop has no GitHub App). */ +export function amsInstanceId(secret) { + return createHash("sha256").update(String(secret)).digest("hex").slice(0, 16); +} + +/** Drop rows already sent in a prior export: everything with a `closedAt` at/before the cursor. A row with no + * `closedAt` (shouldn't happen for a resolved PR, but defensive) is always included, since there is no + * watermark to compare it against. A null/unset cursor means "first export" — everything goes. */ +export function filterBatchSinceCursor(batch, cursor) { + if (!cursor) return batch; + return batch.filter((row) => !row.closedAt || row.closedAt > cursor); +} + +/** The newest `closedAt` among a batch's rows, or `null` if none carry one — the next cursor value to persist + * after a successful send. */ +export function latestClosedAt(batch) { + let latest = null; + for (const row of batch) { + if (row.closedAt && (latest === null || row.closedAt > latest)) latest = row.closedAt; + } + return latest; +} + +/** gittensory's hosted AMS collector — mirrors orb-collector.ts's ORB_COLLECTOR_URL default pattern. */ +export const DEFAULT_AMS_COLLECTOR_URL = "https://api.loopover.ai/v1/ams/ingest"; + +export function resolveAmsCollectorUrl(env = process.env) { + const explicit = typeof env.GITTENSORY_MINER_AMS_COLLECTOR_URL === "string" ? env.GITTENSORY_MINER_AMS_COLLECTOR_URL.trim() : ""; + return explicit || DEFAULT_AMS_COLLECTOR_URL; +} + +/** + * POST an already-anonymized batch to the AMS ingest collector, signed the same way orb-collector.ts signs its + * own export (a full-length HMAC over the JSON body, distinct from the per-field hmacAnonymize truncated hash + * above — a body signature and a field anonymization hash are different concerns). Returns `{ sent }` on a 2xx + * response, `{ sent: 0, error }` otherwise — a network failure or non-2xx never throws, matching this module's + * fail-open posture (a telemetry hiccup must never break the miner's real work). + */ +export async function sendAmsExportBatch({ batch, secret, collectorUrl = resolveAmsCollectorUrl(), collectorToken, fetchFn = fetch }) { + if (!Array.isArray(batch) || batch.length === 0) return { sent: 0 }; + const instanceId = amsInstanceId(secret); + const body = JSON.stringify({ instanceId, events: batch }); + const signature = createHmac("sha256", secret).update(body).digest("hex"); + try { + const res = await fetchFn(collectorUrl, { + method: "POST", + headers: { + "content-type": "application/json", + "x-ams-signature": `sha256=${signature}`, + "x-ams-instance": instanceId, + ...(collectorToken ? { authorization: `Bearer ${collectorToken}` } : {}), + }, + body, + }); + if (!res.ok) return { sent: 0, error: `http_${res.status}` }; + } catch (error) { + return { sent: 0, error: describeCliError(error) }; + } + return { sent: batch.length }; +} + +const ORB_EXPORT_USAGE = "Usage: gittensory-miner orb export [--enable] [--send] [--dry-run] [--json]"; export function parseOrbExportArgs(args) { - const options = { json: false, enable: false, dryRun: false }; + const options = { json: false, enable: false, send: false, dryRun: false }; for (const token of args) { if (token === "--json") { options.json = true; @@ -147,6 +213,13 @@ export function parseOrbExportArgs(args) { options.enable = true; continue; } + // Distinct from --enable: --enable alone only builds+prints the anonymized batch locally (no network I/O), + // so a contributor can inspect exactly what would be sent before ever transmitting it. --send additionally + // POSTs that batch to the collector and advances the cursor — the previously-missing network step (#5681). + if (token === "--send") { + options.send = true; + continue; + } // #4847: openOrbExportStore() itself creates the local SQLite file (a real write) even before any secret is // generated, so a dry run reports what would happen and returns before opening any store at all. if (token === "--dry-run") { @@ -158,19 +231,24 @@ export function parseOrbExportArgs(args) { return options; } -/** CLI entry for the anonymized Orb telemetry batch-builder (#4833 wires the previously caller-less exporter). - * OPT-IN: prints nothing to export unless `--enable` is passed. Only builds the anonymized batch (repo/PR - * identifiers HMAC-hashed) — never performs the network POST. */ -export function runOrbExportCli(args, options = {}) { +/** CLI entry for the anonymized Orb telemetry batch-builder + sender (#4833 wired the caller-less exporter's + * batch-building; #5681 wired the network send). OPT-IN: prints nothing to export unless `--enable` is + * passed. `--enable` alone only builds+prints the anonymized batch locally — no network I/O, so a contributor + * can inspect exactly what would be sent first. `--enable --send` additionally POSTs the (cursor-filtered) + * batch to the AMS collector and advances the cursor on success, so a re-run doesn't resend history that was + * already delivered. */ +export async function runOrbExportCli(args, options = {}) { const parsed = parseOrbExportArgs(args); if ("error" in parsed) { return reportCliFailure(argsWantJson(args), parsed.error); } if (parsed.dryRun) { - const dryRunResult = { outcome: "dry_run", enabled: parsed.enable }; + const dryRunResult = { outcome: "dry_run", enabled: parsed.enable, send: parsed.send }; if (parsed.json) { console.log(JSON.stringify(dryRunResult, null, 2)); + } else if (parsed.enable && parsed.send) { + console.log("DRY RUN: would build an anonymized Orb export batch and send it to the collector. No local writes or network calls were made."); } else if (parsed.enable) { console.log("DRY RUN: would build and report an anonymized Orb export batch. No local writes were made."); } else { @@ -181,6 +259,8 @@ export function runOrbExportCli(args, options = {}) { // Open the stores INSIDE the try so a bad config path / SQLite open failure returns 2 instead of crashing the // process; the finally guards each close with `?.` since either initializer may have thrown before assigning. + // The --send path's await happens INSIDE this try so `finally` (which closes the store) can never run before + // the cursor advance below it -- resolving the send result AFTER the store closed would write to a dead handle. const ownsStore = options.openOrbExportStore === undefined; const ownsLedger = options.initEventLedger === undefined; let store; @@ -194,9 +274,34 @@ export function runOrbExportCli(args, options = {}) { else console.log("orb export is opt-in and disabled — pass --enable to build an anonymized batch"); return 0; } - if (parsed.json) console.log(JSON.stringify({ enabled: true, batch }, null, 2)); - else console.log(`${batch.length} anonymized event(s)`); - return 0; + + if (!parsed.send) { + if (parsed.json) console.log(JSON.stringify({ enabled: true, sent: false, batch }, null, 2)); + else console.log(`${batch.length} anonymized event(s) — pass --send to transmit them to the collector`); + return 0; + } + + const cursor = store.getCursor(); + const toSend = filterBatchSinceCursor(batch, cursor); + if (toSend.length === 0) { + if (parsed.json) console.log(JSON.stringify({ enabled: true, sent: 0, skipped: batch.length }, null, 2)); + else console.log("no new events since the last export"); + return 0; + } + + const send = options.sendAmsExportBatch ?? sendAmsExportBatch; + const secret = store.getOrCreateAnonSecret(); + const env = options.env ?? process.env; + const collectorToken = env.GITTENSORY_MINER_AMS_COLLECTOR_TOKEN ?? ""; + const sendResult = await send({ batch: toSend, secret, collectorToken }); + if (sendResult.sent > 0) { + const nextCursor = latestClosedAt(toSend); + if (nextCursor) store.setCursor(nextCursor); + } + if (parsed.json) console.log(JSON.stringify({ enabled: true, ...sendResult, skipped: batch.length - toSend.length }, null, 2)); + else if (sendResult.error) console.log(`export failed: ${sendResult.error}`); + else console.log(`sent ${sendResult.sent} anonymized event(s)`); + return sendResult.error ? 1 : 0; } catch (error) { return reportCliFailure(parsed.json, describeCliError(error)); } finally { diff --git a/scripts/check-schema-drift.mjs b/scripts/check-schema-drift.mjs index c4f9895557..b700f7287e 100755 --- a/scripts/check-schema-drift.mjs +++ b/scripts/check-schema-drift.mjs @@ -37,6 +37,8 @@ const MIGRATIONS_DIR = process.env.CHECK_SCHEMA_DRIFT_DIR || "migrations"; // table here without also confirming it is genuinely raw-SQL-only is a reviewer-visible diff, not a silent // gap this check would otherwise catch. export const RAW_SQL_ONLY_TABLES = new Set([ + "ams_instances", + "ams_signals", "contributor_gate_history", "global_agent_controls", "global_contributor_blacklist", diff --git a/src/ams/ingest.ts b/src/ams/ingest.ts new file mode 100644 index 0000000000..c1a0c74b14 --- /dev/null +++ b/src/ams/ingest.ts @@ -0,0 +1,96 @@ +// Gittensory AMS (#5681) — central telemetry collector receiver, mirroring Orb's own (`src/orb/ingest.ts`) +// registration-gate + best-effort-upsert pattern. Accepts anonymized PR-outcome batches from opt-in AMS +// instances (packages/gittensory-miner/lib/orb-export.js). No raw repo names, owner identifiers, or PR +// content — only HMAC-anonymized hashes + a decision + a low-cardinality reason bucket. + +const MAX_BATCH = 500; +const MAX_INSTANCE_ID_CHARS = 64; +const MAX_HASH_CHARS = 128; +const MAX_BUCKET_CHARS = 64; +const VALID_DECISIONS = new Set(["merged", "closed"]); + +interface AmsIngestEvent { + repoHash: string; + prHash: string; + decision: string; + reasonBucket?: string | null; + closedAt?: string | null; +} + +interface AmsIngestPayload { + instanceId: string; + events: AmsIngestEvent[]; +} + +export type AmsIngestResult = { accepted: number } | { error: string }; + +export async function handleAmsIngest(body: string, db: D1Database): Promise { + let payload: unknown; + try { + payload = JSON.parse(body); + } catch { + return { error: "invalid_json" }; + } + + if ( + typeof (payload as AmsIngestPayload)?.instanceId !== "string" || + !Array.isArray((payload as AmsIngestPayload)?.events) + ) { + return { error: "invalid_payload" }; + } + + const { instanceId, events } = payload as AmsIngestPayload; + if (!instanceId || instanceId.length > MAX_INSTANCE_ID_CHARS || events.length === 0) { + return { error: "invalid_payload" }; + } + + // Record the instance on first contact (registered=0 by default) and bump last_seen — same trust anchor + // Orb's orb_instances uses: every source is seen, but nothing counts toward a fleet-wide aggregate until + // an operator opts it in. + try { + await db + .prepare(`INSERT INTO ams_instances (instance_id) VALUES (?) ON CONFLICT(instance_id) DO UPDATE SET last_seen_at = CURRENT_TIMESTAMP`) + .bind(instanceId) + .run(); + } catch { + // best-effort: never fail ingest because the instance bookkeeping hiccupped + } + + const batch = events.slice(0, MAX_BATCH); + let accepted = 0; + + for (const event of batch) { + if ( + typeof event.repoHash !== "string" || !event.repoHash || event.repoHash.length > MAX_HASH_CHARS || + typeof event.prHash !== "string" || !event.prHash || event.prHash.length > MAX_HASH_CHARS || + !VALID_DECISIONS.has(event.decision) + ) { + continue; + } + + try { + // OR REPLACE: a re-exported PR (e.g. a decision that changed) upserts the freshest outcome on the + // (instance_id, pr_hash) dedup key. + const result = await db + .prepare( + `INSERT OR REPLACE INTO ams_signals + (instance_id, repo_hash, pr_hash, decision, reason_bucket, closed_at, received_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, + ) + .bind( + instanceId, + event.repoHash, + event.prHash, + event.decision, + typeof event.reasonBucket === "string" && event.reasonBucket.length <= MAX_BUCKET_CHARS ? event.reasonBucket : null, + typeof event.closedAt === "string" ? event.closedAt : null, + ) + .run(); + if (result.meta.changes > 0) accepted++; + } catch { + // best-effort — skip rows that violate constraints or hit transient errors + } + } + + return { accepted }; +} diff --git a/src/api/routes.ts b/src/api/routes.ts index 8bb4e71ce2..b52bd6ea0b 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -137,6 +137,7 @@ import { } from "../github/commands"; import { handleGitHubWebhook, handleOrbRelay } from "../github/webhook"; import { handleOrbIngest, readOrbIngestBody } from "../orb/ingest"; +import { handleAmsIngest } from "../ams/ingest"; import { handleOrbWebhook } from "../orb/webhook"; import { handleOrbOAuthCallback } from "../orb/oauth"; import { brokerOrbToken, isOrbBrokerEnabled, issueOrbEnrollment } from "../orb/broker"; @@ -3482,6 +3483,19 @@ export function createApp() { return c.json(result, 200); }); + // Gittensory AMS (#5681) — central telemetry collector for the miner product, mirroring the Orb ingest + // route above (same optional bearer-token gate, same hard body ceiling — readOrbIngestBody is generic over + // request bytes despite the name, so it's reused as-is rather than duplicated). + app.post("/v1/ams/ingest", async (c) => { + if (!(await isAuthorizedAmsIngest(c.env, extractBearerToken(c.req.header("authorization"))))) return c.json({ error: "unauthorized" }, 401); + const body = await readOrbIngestBody(c.req.raw, c.req.header("content-length")); + if (body === null) return c.json({ error: "payload_too_large" }, 413); + if (!body) return c.json({ error: "invalid_request" }, 400); + const result = await handleAmsIngest(body, c.env.DB); + if ("error" in result) return c.json(result, 400); + return c.json(result, 200); + }); + // Fleet calibration analytics over the collected orb_signals — gate accuracy (precision / FP / reversal / // cycle-time) aggregated median-robustly across the self-host fleet. Owner-only: bearer-gated by the // `/v1/internal/*` middleware (INTERNAL_JOB_TOKEN). `?days=` windows the lookback (default 90). @@ -5701,6 +5715,14 @@ async function isAuthorizedOrbIngest(env: Env, token: string | undefined): Promi return timingSafeEqual(token, env.ORB_INGEST_TOKEN); } +// Optional AMS-ingest auth (#5681), same fail-open shape as isAuthorizedOrbIngest: unset ⇒ open ingress, set ⇒ +// the collector requires an exact bearer match. A separate token/env var from ORB_INGEST_TOKEN so the two +// products' collector credentials can be rotated or locked down independently. +async function isAuthorizedAmsIngest(env: Env, token: string | undefined): Promise { + if (!env.AMS_INGEST_TOKEN) return true; + return timingSafeEqual(token, env.AMS_INGEST_TOKEN); +} + function requiresApiToken(path: string): boolean { if (path === "/health") return false; if (path === "/v1/mcp/compatibility") return false; @@ -5723,6 +5745,7 @@ function requiresApiToken(path: string): boolean { if (path === "/v1/orb/relay/register") return false; if (path === "/v1/orb/relay/pull") return false; if (path === "/v1/orb/ingest") return false; + if (path === "/v1/ams/ingest") return false; if (path.startsWith("/v1/internal/")) return false; return path.startsWith("/v1/"); } diff --git a/src/env.d.ts b/src/env.d.ts index 310342f83c..30da857d71 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -274,6 +274,10 @@ declare global { MCP_READ_REPO_ALLOWLIST?: string; /** Shared bearer secret required by the hosted Orb ingest collector. */ ORB_INGEST_TOKEN?: string; + /** Shared bearer secret required by the hosted AMS ingest collector (#5681) — same optional, fail-open + * gate as ORB_INGEST_TOKEN, kept as a separate secret so the two products' collector credentials never + * overlap. */ + AMS_INGEST_TOKEN?: string; /** AES-256-GCM master secret for maintainer BYOK provider keys (encrypt/decrypt at rest). A Worker/self-host * secret, never a public var. When absent, BYOK is unavailable and review uses the configured instance * reviewer when available. */ diff --git a/test/integration/ams-ingest.test.ts b/test/integration/ams-ingest.test.ts new file mode 100644 index 0000000000..0d114057c5 --- /dev/null +++ b/test/integration/ams-ingest.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { handleAmsIngest } from "../../src/ams/ingest"; +import { MAX_ORB_INGEST_BODY_BYTES } from "../../src/orb/ingest"; +import { createTestEnv, TestD1Database } from "../helpers/d1"; + +describe("handleAmsIngest()", () => { + function makeDb(): D1Database { + return new TestD1Database() as unknown as D1Database; + } + const ev = (o: Record = {}) => ({ repoHash: "rh", prHash: "ph", decision: "merged", ...o }); + const ingest = (db: D1Database, events: Array>, instanceId = "inst1") => handleAmsIngest(JSON.stringify({ instanceId, events }), db); + const col = async (db: D1Database, pr: string, c: string) => + (await (db as unknown as TestD1Database).prepare(`SELECT ${c} AS v FROM ams_signals WHERE pr_hash=?`).bind(pr).first<{ v: unknown }>())?.v; + + it("accepts a valid batch and returns the accepted count", async () => { + expect(await ingest(makeDb(), [ev({ prHash: "p1" })])).toEqual({ accepted: 1 }); + }); + + it("returns invalid_json on unparseable body", async () => { + expect(await handleAmsIngest("{not json}", makeDb())).toEqual({ error: "invalid_json" }); + }); + + it("returns invalid_payload: instanceId not a string / events not an array / empty/oversized instance / empty events", async () => { + const db = makeDb(); + expect(await handleAmsIngest(JSON.stringify({ instanceId: 123, events: [] }), db)).toEqual({ error: "invalid_payload" }); + expect(await handleAmsIngest(JSON.stringify({ instanceId: "abc", events: "bad" }), db)).toEqual({ error: "invalid_payload" }); + expect(await handleAmsIngest(JSON.stringify({ instanceId: "", events: [ev()] }), db)).toEqual({ error: "invalid_payload" }); + expect(await handleAmsIngest(JSON.stringify({ instanceId: "abc", events: [] }), db)).toEqual({ error: "invalid_payload" }); + expect(await handleAmsIngest(JSON.stringify({ instanceId: "i".repeat(65), events: [ev()] }), db)).toEqual({ error: "invalid_payload" }); + }); + + it("skips events with bad repoHash / prHash / decision", async () => { + expect(await ingest(makeDb(), [ev({ repoHash: 99 })])).toEqual({ accepted: 0 }); + expect(await ingest(makeDb(), [ev({ repoHash: "" })])).toEqual({ accepted: 0 }); + expect(await ingest(makeDb(), [ev({ repoHash: "r".repeat(129) })])).toEqual({ accepted: 0 }); + expect(await ingest(makeDb(), [ev({ prHash: null })])).toEqual({ accepted: 0 }); + expect(await ingest(makeDb(), [ev({ prHash: "" })])).toEqual({ accepted: 0 }); + expect(await ingest(makeDb(), [ev({ prHash: "p".repeat(129) })])).toEqual({ accepted: 0 }); + expect(await ingest(makeDb(), [ev({ decision: "opened" })])).toEqual({ accepted: 0 }); + }); + + it("stores reasonBucket string vs null, dropping an oversized bucket", async () => { + const db = makeDb(); + await ingest(db, [ + ev({ prHash: "b1", reasonBucket: "gate_close" }), + ev({ prHash: "b2" }), + ev({ prHash: "b3", reasonBucket: "b".repeat(65) }), + ]); + expect(await col(db, "b1", "reason_bucket")).toBe("gate_close"); + expect(await col(db, "b2", "reason_bucket")).toBeNull(); + expect(await col(db, "b3", "reason_bucket")).toBeNull(); + }); + + it("stores closedAt string vs null", async () => { + const db = makeDb(); + await ingest(db, [ev({ prHash: "c1", closedAt: "2026-01-01T00:00:00Z" }), ev({ prHash: "c2" })]); + expect(await col(db, "c1", "closed_at")).toBe("2026-01-01T00:00:00Z"); + expect(await col(db, "c2", "closed_at")).toBeNull(); + }); + + it("UPSERTs on (instance, pr_hash): a re-export updates the freshest decision", async () => { + const db = makeDb(); + await ingest(db, [ev({ prHash: "u1", decision: "closed" })]); + expect(await col(db, "u1", "decision")).toBe("closed"); + const second = await ingest(db, [ev({ prHash: "u1", decision: "merged" })]); + expect(second).toEqual({ accepted: 1 }); + expect(await col(db, "u1", "decision")).toBe("merged"); + const cnt = await (db as unknown as TestD1Database).prepare("SELECT COUNT(*) AS n FROM ams_signals WHERE pr_hash='u1'").first<{ n: number }>(); + expect(cnt?.n).toBe(1); + }); + + it("different instances reviewing the same pr hash do NOT collide", async () => { + const db = makeDb(); + await ingest(db, [ev({ prHash: "same" })], "instA"); + await ingest(db, [ev({ prHash: "same" })], "instB"); + const cnt = await (db as unknown as TestD1Database).prepare("SELECT COUNT(*) AS n FROM ams_signals WHERE pr_hash='same'").first<{ n: number }>(); + expect(cnt?.n).toBe(2); + }); + + it("counts accepted vs skipped in one batch; caps at 500", async () => { + const db = makeDb(); + expect(await ingest(db, [ev({ prHash: "ok" }), ev({ repoHash: "" }), ev({ decision: "x" })])).toEqual({ accepted: 1 }); + const many = Array.from({ length: 501 }, (_, i) => ev({ prHash: `m${i}` })); + expect(await ingest(makeDb(), many)).toEqual({ accepted: 500 }); + }); + + it("swallows a DB error (inner catch)", async () => { + const brokenDb = { prepare: () => ({ bind: () => ({ run: () => Promise.reject(new Error("boom")) }) }) } as unknown as D1Database; + expect(await ingest(brokenDb, [ev()])).toEqual({ accepted: 0 }); + }); + + it("does not count a row when the write reports no change (changes === 0)", async () => { + const db = { prepare: () => ({ bind: () => ({ run: () => Promise.resolve({ meta: { changes: 0 } }) }) }) } as unknown as D1Database; + expect(await ingest(db, [ev()])).toEqual({ accepted: 0 }); + }); + + it("records the instance on first contact (registered=0) and bumps last_seen on re-ingest", async () => { + const db = makeDb(); + await ingest(db, [ev({ prHash: "i1" })], "instX"); + const row = await (db as unknown as TestD1Database) + .prepare("SELECT registered, first_seen_at, last_seen_at FROM ams_instances WHERE instance_id=?") + .bind("instX") + .first<{ registered: number; first_seen_at: string; last_seen_at: string }>(); + expect(row?.registered).toBe(0); + await ingest(db, [ev({ prHash: "i2" })], "instX"); + const cnt = await (db as unknown as TestD1Database).prepare("SELECT COUNT(*) AS n FROM ams_instances WHERE instance_id=?").bind("instX").first<{ n: number }>(); + expect(cnt?.n).toBe(1); + }); + + it("does not fail ingest if the instance bookkeeping upsert throws", async () => { + let call = 0; + const db = { + prepare: (sql: string) => { + call++; + if (sql.includes("ams_instances")) return { bind: () => ({ run: () => Promise.reject(new Error("boom")) }) }; + return new TestD1Database().prepare(sql); + }, + } as unknown as D1Database; + expect(await ingest(db, [ev()])).toBeTruthy(); + expect(call).toBeGreaterThan(0); + }); +}); + +describe("POST /v1/ams/ingest route", () => { + const app = createApp(); + + it("returns 200 + accepted count for a valid batch", async () => { + const env = createTestEnv(); + const body = JSON.stringify({ instanceId: "abc0", events: [{ repoHash: "rhash", prHash: "phash", decision: "merged" }] }); + const res = await app.request("/v1/ams/ingest", { method: "POST", headers: { "content-type": "application/json" }, body }, env); + expect(res.status).toBe(200); + expect(((await res.json()) as { accepted: number }).accepted).toBe(1); + }); + + it("returns 400 for invalid JSON", async () => { + const res = await app.request("/v1/ams/ingest", { method: "POST", headers: { "content-type": "application/json" }, body: "{bad" }, createTestEnv()); + expect(res.status).toBe(400); + expect(((await res.json()) as { error: string }).error).toBe("invalid_json"); + }); + + it("returns 400 for an empty body", async () => { + const res = await app.request("/v1/ams/ingest", { method: "POST", body: "" }, createTestEnv()); + expect(res.status).toBe(400); + }); + + it("returns 413 when the body exceeds the shared ingest byte ceiling", async () => { + const huge = "x".repeat(MAX_ORB_INGEST_BODY_BYTES + 16); + const res = await app.request("/v1/ams/ingest", { method: "POST", body: huge }, createTestEnv()); + expect(res.status).toBe(413); + expect(((await res.json()) as { error: string }).error).toBe("payload_too_large"); + }); + + it("optional collector token: open when unset; enforced once AMS_INGEST_TOKEN is set", async () => { + const body = JSON.stringify({ instanceId: "abc0", events: [{ repoHash: "rhash", prHash: "phash", decision: "merged" }] }); + const post = (env: Env, authorization?: string) => + app.request("/v1/ams/ingest", { method: "POST", headers: { "content-type": "application/json", ...(authorization ? { authorization } : {}) }, body }, env); + + expect((await post(createTestEnv())).status).toBe(200); + const env = createTestEnv({ AMS_INGEST_TOKEN: "fleet-secret" }); + expect((await post(env)).status).toBe(401); + expect((await post(env, "Bearer wrong")).status).toBe(401); + expect((await post(env, "Bearer fleet-secret")).status).toBe(200); + }); +}); diff --git a/test/unit/miner-orb-export.test.ts b/test/unit/miner-orb-export.test.ts index 6e6f5ab76c..fbe132ded6 100644 --- a/test/unit/miner-orb-export.test.ts +++ b/test/unit/miner-orb-export.test.ts @@ -1,16 +1,22 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ORB_EXPORT_ENABLED_BY_DEFAULT, + DEFAULT_AMS_COLLECTOR_URL, + amsInstanceId, buildAnonymizedOrbBatch, collectOrbExportBatch, + filterBatchSinceCursor, hmacAnonymize, + latestClosedAt, openOrbExportStore, + resolveAmsCollectorUrl, + sendAmsExportBatch, } from "../../packages/gittensory-miner/lib/orb-export.js"; -import type { OrbExportOutcome } from "../../packages/gittensory-miner/lib/orb-export.js"; +import type { OrbExportOutcome, OrbExportRow } from "../../packages/gittensory-miner/lib/orb-export.js"; let dir: string; function storePath() { @@ -140,3 +146,89 @@ describe("collectOrbExportBatch", () => { ).toThrow(/invalid_orb_export_store/); }); }); + +describe("amsInstanceId (#5681)", () => { + it("is deterministic per secret, 16 hex chars, and differs across secrets", () => { + const id = amsInstanceId("a".repeat(64)); + expect(id).toBe(amsInstanceId("a".repeat(64))); + expect(id).toMatch(/^[0-9a-f]{16}$/); + expect(amsInstanceId("b".repeat(64))).not.toBe(id); + }); +}); + +describe("filterBatchSinceCursor / latestClosedAt (#5681)", () => { + const row = (prHash: string, closedAt: string | null): OrbExportRow => ({ repoHash: "rh", prHash, decision: "merged", reasonBucket: "none", closedAt }); + + it("returns everything when the cursor is null (first export)", () => { + const batch = [row("a", "2026-01-01T00:00:00Z"), row("b", "2026-01-02T00:00:00Z")]; + expect(filterBatchSinceCursor(batch, null)).toEqual(batch); + }); + + it("drops rows at/before the cursor; keeps rows strictly after it", () => { + const batch = [row("a", "2026-01-01T00:00:00Z"), row("b", "2026-01-02T00:00:00Z"), row("c", "2026-01-03T00:00:00Z")]; + expect(filterBatchSinceCursor(batch, "2026-01-02T00:00:00Z").map((r) => r.prHash)).toEqual(["c"]); + }); + + it("always keeps a row with no closedAt (defensive — no watermark to compare)", () => { + const batch = [row("a", null)]; + expect(filterBatchSinceCursor(batch, "2099-01-01T00:00:00Z")).toEqual(batch); + }); + + it("latestClosedAt finds the max closedAt, ignoring nulls; null on an all-null/empty batch", () => { + expect(latestClosedAt([row("a", "2026-01-01T00:00:00Z"), row("b", "2026-01-03T00:00:00Z"), row("c", null)])).toBe("2026-01-03T00:00:00Z"); + expect(latestClosedAt([row("a", null)])).toBeNull(); + expect(latestClosedAt([])).toBeNull(); + }); +}); + +describe("resolveAmsCollectorUrl (#5681)", () => { + it("defaults to gittensory's hosted collector; an explicit env var overrides it", () => { + expect(resolveAmsCollectorUrl({})).toBe(DEFAULT_AMS_COLLECTOR_URL); + expect(resolveAmsCollectorUrl({ GITTENSORY_MINER_AMS_COLLECTOR_URL: " " })).toBe(DEFAULT_AMS_COLLECTOR_URL); + expect(resolveAmsCollectorUrl({ GITTENSORY_MINER_AMS_COLLECTOR_URL: "https://example.test/ingest" })).toBe("https://example.test/ingest"); + }); +}); + +describe("sendAmsExportBatch (#5681)", () => { + const batch: OrbExportRow[] = [{ repoHash: "rh", prHash: "ph", decision: "merged", reasonBucket: "none", closedAt: "2026-01-01T00:00:00Z" }]; + + it("returns { sent: 0 } without calling fetch for an empty batch", async () => { + const fetchFn = vi.fn(); + expect(await sendAmsExportBatch({ batch: [], secret: "s".repeat(64), fetchFn })).toEqual({ sent: 0 }); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it("POSTs a signed, instance-tagged payload and reports sent on a 2xx response", async () => { + const fetchFn = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + const result = await sendAmsExportBatch({ batch, secret: "s".repeat(64), collectorUrl: "https://example.test/ingest", fetchFn }); + expect(result).toEqual({ sent: 1 }); + expect(fetchFn).toHaveBeenCalledTimes(1); + const [url, init] = fetchFn.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://example.test/ingest"); + expect(init.method).toBe("POST"); + const headers = init.headers as Record; + expect(headers["x-ams-signature"]).toMatch(/^sha256=[0-9a-f]{64}$/); + expect(headers["x-ams-instance"]).toBe(amsInstanceId("s".repeat(64))); + expect(headers.authorization).toBeUndefined(); + expect(JSON.parse(String(init.body))).toEqual({ instanceId: amsInstanceId("s".repeat(64)), events: batch }); + }); + + it("includes a bearer authorization header only when a collectorToken is provided", async () => { + const fetchFn = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + await sendAmsExportBatch({ batch, secret: "s".repeat(64), collectorToken: "tok123", fetchFn }); + const [, init] = fetchFn.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record).authorization).toBe("Bearer tok123"); + }); + + it("reports { sent: 0, error } on a non-2xx response, without throwing", async () => { + const fetchFn = vi.fn().mockResolvedValue({ ok: false, status: 503 }); + expect(await sendAmsExportBatch({ batch, secret: "s".repeat(64), fetchFn })).toEqual({ sent: 0, error: "http_503" }); + }); + + it("reports { sent: 0, error } on a network failure, without throwing", async () => { + const fetchFn = vi.fn().mockRejectedValue(new Error("network_down")); + const result = await sendAmsExportBatch({ batch, secret: "s".repeat(64), fetchFn }); + expect(result.sent).toBe(0); + expect(result.error).toBeTruthy(); + }); +}); diff --git a/test/unit/miner-wire-cli-modules.test.ts b/test/unit/miner-wire-cli-modules.test.ts index 155ecbed0a..2ccac40e64 100644 --- a/test/unit/miner-wire-cli-modules.test.ts +++ b/test/unit/miner-wire-cli-modules.test.ts @@ -14,6 +14,7 @@ import { openOrbExportStore, } from "../../packages/gittensory-miner/lib/orb-export.js"; import { initEventLedger } from "../../packages/gittensory-miner/lib/event-ledger.js"; +import { recordPrOutcomeSnapshot } from "../../packages/gittensory-miner/lib/pr-outcome.js"; const roots: string[] = []; const closeables: Array<{ close(): void }> = []; @@ -119,22 +120,22 @@ describe("orb export — wires the anonymized telemetry batch-builder (#4833)", return { openOrbExportStore: () => store, initEventLedger: () => ledger }; }; - it("#4847: --dry-run reports what an export would do and returns 0 without opening any store", () => { + it("#4847: --dry-run reports what an export would do and returns 0 without opening any store", async () => { const openOrbExportStoreSpy = vi.fn(); const initEventLedgerSpy = vi.fn(); const spy = captureLog(); - const disabledCode = runOrbExportCli(["--dry-run", "--json"], { + const disabledCode = await runOrbExportCli(["--dry-run", "--json"], { openOrbExportStore: openOrbExportStoreSpy, initEventLedger: initEventLedgerSpy, }); expect(disabledCode).toBe(0); expect(openOrbExportStoreSpy).not.toHaveBeenCalled(); expect(initEventLedgerSpy).not.toHaveBeenCalled(); - expect(JSON.parse(logs.join(""))).toEqual({ outcome: "dry_run", enabled: false }); + expect(JSON.parse(logs.join(""))).toEqual({ outcome: "dry_run", enabled: false, send: false }); logs = []; - const enabledCode = runOrbExportCli(["--enable", "--dry-run"], { + const enabledCode = await runOrbExportCli(["--enable", "--dry-run"], { openOrbExportStore: openOrbExportStoreSpy, initEventLedger: initEventLedgerSpy, }); @@ -143,7 +144,16 @@ describe("orb export — wires the anonymized telemetry batch-builder (#4833)", expect(logs.join("")).toContain("DRY RUN: would build and report an anonymized Orb export batch"); logs = []; - const disabledTextCode = runOrbExportCli(["--dry-run"], { + const enabledSendCode = await runOrbExportCli(["--enable", "--send", "--dry-run"], { + openOrbExportStore: openOrbExportStoreSpy, + initEventLedger: initEventLedgerSpy, + }); + expect(enabledSendCode).toBe(0); + expect(openOrbExportStoreSpy).not.toHaveBeenCalled(); + expect(logs.join("")).toContain("DRY RUN: would build an anonymized Orb export batch and send it to the collector"); + + logs = []; + const disabledTextCode = await runOrbExportCli(["--dry-run"], { openOrbExportStore: openOrbExportStoreSpy, initEventLedger: initEventLedgerSpy, }); @@ -153,32 +163,134 @@ describe("orb export — wires the anonymized telemetry batch-builder (#4833)", spy.mockRestore(); }); - it("is opt-in: exports nothing (null batch) without --enable", () => { + it("is opt-in: exports nothing (null batch) without --enable", async () => { const spy = captureLog(); - const code = runOrbExportCli(["--json"], stores()); + const code = await runOrbExportCli(["--json"], stores()); spy.mockRestore(); expect(code).toBe(0); expect(JSON.parse(logs.join(""))).toEqual({ enabled: false, batch: null }); }); - it("builds an anonymized batch when --enable is passed (empty ledger → empty batch)", () => { + it("builds (but does not send) an anonymized batch when --enable is passed without --send (empty ledger → empty batch)", async () => { + const spy = captureLog(); + const code = await runOrbExportCli(["--enable", "--json"], stores()); + spy.mockRestore(); + expect(code).toBe(0); + expect(JSON.parse(logs.join(""))).toEqual({ enabled: true, sent: false, batch: [] }); + }); + + it("--enable --send with an empty batch reports 0 sent without invoking the sender", async () => { + const sendSpy = vi.fn(); + const spy = captureLog(); + const code = await runOrbExportCli(["--enable", "--send", "--json"], { ...stores(), sendAmsExportBatch: sendSpy }); + spy.mockRestore(); + expect(code).toBe(0); + expect(sendSpy).not.toHaveBeenCalled(); + expect(JSON.parse(logs.join(""))).toEqual({ enabled: true, sent: 0, skipped: 0 }); + }); + + it("text mode: reports opt-in-disabled, enable-without-send, and no-new-events phrasing", async () => { + const spy = captureLog(); + + let code = await runOrbExportCli([], stores()); + expect(code).toBe(0); + expect(logs.join("")).toBe("orb export is opt-in and disabled — pass --enable to build an anonymized batch"); + + logs = []; + code = await runOrbExportCli(["--enable"], stores()); + expect(code).toBe(0); + expect(logs.join("")).toBe("0 anonymized event(s) — pass --send to transmit them to the collector"); + + logs = []; + code = await runOrbExportCli(["--enable", "--send"], { ...stores(), sendAmsExportBatch: vi.fn() }); + expect(code).toBe(0); + expect(logs.join("")).toBe("no new events since the last export"); + + spy.mockRestore(); + }); + + it("REGRESSION (#5681): --enable --send actually delivers a real seeded outcome, advances the cursor, and a re-run sends nothing new", async () => { + const s = stores(); + const store = s.openOrbExportStore(); + const ledger = s.initEventLedger(); + recordPrOutcomeSnapshot( + { repoFullName: "acme/widgets", prNumber: 7, decision: "merged", closedAt: "2026-01-01T00:00:00Z", reason: null }, + { eventLedger: ledger }, + ); + + const sendAmsExportBatchSpy = vi.fn().mockResolvedValue({ sent: 1 }); const spy = captureLog(); - const code = runOrbExportCli(["--enable", "--json"], stores()); + const code = await runOrbExportCli(["--enable", "--send", "--json"], { ...s, sendAmsExportBatch: sendAmsExportBatchSpy }); spy.mockRestore(); + expect(code).toBe(0); - expect(JSON.parse(logs.join(""))).toEqual({ enabled: true, batch: [] }); + expect(sendAmsExportBatchSpy).toHaveBeenCalledTimes(1); + const call = sendAmsExportBatchSpy.mock.calls[0]![0] as { batch: unknown[]; secret: string }; + expect(call.batch).toHaveLength(1); + expect(typeof call.secret).toBe("string"); + expect(JSON.parse(logs.join(""))).toEqual({ enabled: true, sent: 1, skipped: 0 }); + expect(store.getCursor()).toBe("2026-01-01T00:00:00Z"); // cursor advanced to the sent row's closedAt + + // Re-run: the same outcome is now at/before the cursor, so nothing new is sent. + logs = []; + const secondSpy = captureLog(); + const secondCode = await runOrbExportCli(["--enable", "--send", "--json"], { ...s, sendAmsExportBatch: sendAmsExportBatchSpy }); + secondSpy.mockRestore(); + expect(secondCode).toBe(0); + expect(sendAmsExportBatchSpy).toHaveBeenCalledTimes(1); // not called again + expect(JSON.parse(logs.join(""))).toEqual({ enabled: true, sent: 0, skipped: 1 }); }); - it("rejects an unknown flag", () => { + it("text mode: reports a successful send", async () => { + const s = stores(); + recordPrOutcomeSnapshot( + { repoFullName: "acme/widgets", prNumber: 1, decision: "merged", closedAt: "2026-01-01T00:00:00Z", reason: null }, + { eventLedger: s.initEventLedger() }, + ); + const spy = captureLog(); + const code = await runOrbExportCli(["--enable", "--send"], { ...s, sendAmsExportBatch: vi.fn().mockResolvedValue({ sent: 1 }) }); + spy.mockRestore(); + expect(code).toBe(0); + expect(logs.join("")).toBe("sent 1 anonymized event(s)"); + }); + + it("REGRESSION: a send failure (non-2xx / network error) is reported and returns exit code 1, without advancing the cursor", async () => { + const s = stores(); + const store = s.openOrbExportStore(); + recordPrOutcomeSnapshot( + { repoFullName: "acme/widgets", prNumber: 1, decision: "merged", closedAt: "2026-01-01T00:00:00Z", reason: null }, + { eventLedger: s.initEventLedger() }, + ); + + const spy = captureLog(); + const jsonCode = await runOrbExportCli(["--enable", "--send", "--json"], { + ...s, + sendAmsExportBatch: vi.fn().mockResolvedValue({ sent: 0, error: "http_503" }), + }); + expect(jsonCode).toBe(1); + expect(JSON.parse(logs.join(""))).toEqual({ enabled: true, sent: 0, error: "http_503", skipped: 0 }); + expect(store.getCursor()).toBeNull(); // no successful send → cursor untouched + + logs = []; + const textCode = await runOrbExportCli(["--enable", "--send"], { + ...s, + sendAmsExportBatch: vi.fn().mockResolvedValue({ sent: 0, error: "network_down" }), + }); + expect(textCode).toBe(1); + expect(logs.join("")).toBe("export failed: network_down"); + spy.mockRestore(); + }); + + it("rejects an unknown flag", async () => { expect(parseOrbExportArgs(["--nope"])).toHaveProperty("error"); const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - expect(runOrbExportCli(["--nope"], stores())).toBe(2); + expect(await runOrbExportCli(["--nope"], stores())).toBe(2); errSpy.mockRestore(); }); - it("returns 2 (not a crash) when the store fails to open — the open is inside the try", () => { + it("returns 2 (not a crash) when the store fails to open — the open is inside the try", async () => { const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const code = runOrbExportCli(["--enable"], { + const code = await runOrbExportCli(["--enable"], { openOrbExportStore: () => { throw new Error("bad_config_path"); }, @@ -189,4 +301,39 @@ describe("orb export — wires the anonymized telemetry batch-builder (#4833)", errSpy.mockRestore(); expect(code).toBe(2); }); + + it("REGRESSION (#5681 follow-up): when openOrbExportStore/initEventLedger/sendAmsExportBatch are all omitted, runOrbExportCli falls back to the REAL defaults", async () => { + // Isolated tmp DB paths (never touches a real ~/.config/gittensory-miner), matching the pattern already + // established for the analogous getAttemptHistory/recordOwnSubmission DI-fallback tests. + const dir = tempDir(); + vi.stubEnv("GITTENSORY_MINER_ORB_EXPORT_DB", join(dir, "orb-export.sqlite3")); + vi.stubEnv("GITTENSORY_MINER_EVENT_LEDGER_DB", join(dir, "ledger.sqlite3")); + + // Seed via the SAME real default ledger path runOrbExportCli will open — closedAt omitted (→ null) so the + // send path's `latestClosedAt` also exercises its null branch (no cursor advance possible). + const seedLedger = initEventLedger(); + recordPrOutcomeSnapshot({ repoFullName: "acme/widgets", prNumber: 3, decision: "merged", reason: null }, { eventLedger: seedLedger }); + seedLedger.close(); + + const fetchSpy = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal("fetch", fetchSpy); + + const spy = captureLog(); + const code = await runOrbExportCli(["--enable", "--send", "--json"], {}); + spy.mockRestore(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + + expect(code).toBe(0); + expect(fetchSpy).toHaveBeenCalledTimes(1); // the REAL sendAmsExportBatch default really POSTed + const [url] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.loopover.ai/v1/ams/ingest"); + expect(JSON.parse(logs.join(""))).toEqual({ enabled: true, sent: 1, skipped: 0 }); + + // Verify against the REAL default store directly: no cursor was persisted (closedAt was null on the only + // sent row, so latestClosedAt returned null and the `if (nextCursor)` guard never ran setCursor). + const store = openOrbExportStore(join(dir, "orb-export.sqlite3")); + expect(store.getCursor()).toBeNull(); + store.close(); + }); });