diff --git a/.env.example b/.env.example index 43689465fd..e6e861f685 100644 --- a/.env.example +++ b/.env.example @@ -495,6 +495,14 @@ REDIS_URL=redis://redis:6379 # REQUIRED for the self-host review # GRAFANA_REPORTING_EXPORT_INTERVAL_SECONDS=30 # refresh cadence for the redacted reporting SQLite export # GITTENSORY_REPORTING_SOURCE_DB=/appdb/gittensory.sqlite # if DATABASE_PATH=/data/custom.sqlite, set /appdb/custom.sqlite # +# AMS (gittensory-miner) ledger dashboards — only useful when a miner ALSO runs on this same host (see +# packages/gittensory-miner/docs/observability.md). Requires --profile ams-observability, separate from the +# general observability profile above: an engine-only deployment has nothing for this exporter to read. +# Grafana never mounts the live ledgers directly — this exporter reads them read-only and writes a redacted +# snapshot (free-form attempt_log_events.reason/.payload_json dropped) into the same reporting volume. +# GITTENSORY_MINER_CONFIG_DIR=~/.config/gittensory-miner # host dir the exporter mounts read-only at /ams-ledgers +# GITTENSORY_AMS_REPORTING_EXPORT_INTERVAL_SECONDS=30 # refresh cadence for the redacted AMS ledger exports +# # Claude usage telemetry → OTEL collector → Prometheus → the Claude usage dashboard. OFF by default. # CLAUDE_CODE_ENABLE_TELEMETRY=1 # enable; needs --profile observability (starts the otel-collector) # OTEL_TRACES_EXPORTER=otlp # also enables app job/provider spans → Tempo; empty = no app traces diff --git a/docker-compose.yml b/docker-compose.yml index 71b5be7edc..b8999ed90e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -835,6 +835,56 @@ services: start_period: 30s retries: 3 + # Redacted AMS (gittensory-miner) ledger export -- a DEDICATED profile, not bundled into `observability`: unlike + # the engine's own reporting-exporter above, this only does anything useful when a miner is ALSO running on this + # same host with its ledgers under GITTENSORY_MINER_CONFIG_DIR (an engine-only maintainer deployment has nothing + # for it to read). Grafana must never mount that directory directly -- attempt_log_events.reason/.payload_json + # are free-form and can carry arbitrary internal detail (#5184 follow-up / PR #5471's flagged confidentiality + # gap) -- so this exporter reads the live ledgers read-only and writes a redacted snapshot into the SAME shared + # `grafana-reporting-data:/reporting` volume the engine's own exporter already writes to; only Grafana's + # ams-ledgers.yml datasources (pointed at /reporting/ams-*.sqlite) ever read from there. + ams-reporting-exporter: + image: alpine:3.20 + restart: unless-stopped + <<: *default-logging + profiles: ["ams-observability"] + volumes: + # Read-only live-ledger access for the exporter only -- Grafana does not get this mount (mirrors the + # /appdb:ro pattern above). Point this at the SAME host directory your miner uses as GITTENSORY_MINER_CONFIG_DIR. + - "${GITTENSORY_MINER_CONFIG_DIR:-~/.config/gittensory-miner}:/ams-ledgers:ro" + - grafana-reporting-data:/reporting + # Directory bind mount, not a single-file one -- see reporting-exporter's own comment above for why + # (a single-file mount pins to the pre-`git pull` inode and silently never picks up a script update). + - ./scripts:/scripts:ro + environment: + GITTENSORY_AMS_ATTEMPT_LOG_SOURCE_DB: "${GITTENSORY_AMS_ATTEMPT_LOG_SOURCE_DB:-/ams-ledgers/attempt-log.sqlite3}" + GITTENSORY_AMS_PREDICTION_LEDGER_SOURCE_DB: "${GITTENSORY_AMS_PREDICTION_LEDGER_SOURCE_DB:-/ams-ledgers/prediction-ledger.sqlite3}" + GITTENSORY_REPORTING_DIR: /reporting + GITTENSORY_AMS_ATTEMPT_LOG_REPORTING_DB: "${GITTENSORY_AMS_ATTEMPT_LOG_REPORTING_DB:-/reporting/ams-attempt-log.sqlite}" + GITTENSORY_AMS_PREDICTION_LEDGER_REPORTING_DB: "${GITTENSORY_AMS_PREDICTION_LEDGER_REPORTING_DB:-/reporting/ams-prediction-ledger.sqlite}" + GITTENSORY_AMS_REPORTING_EXPORT_INTERVAL_SECONDS: "${GITTENSORY_AMS_REPORTING_EXPORT_INTERVAL_SECONDS:-30}" + command: + - /bin/sh + - -c + - >- + apk add --no-cache sqlite >/dev/null 2>&1 && + while true; do + sh /scripts/export-ams-reporting-db.sh || echo '[ams-reporting] export failed'; + interval="$${GITTENSORY_AMS_REPORTING_EXPORT_INTERVAL_SECONDS:-30}"; + case "$$interval" in ''|*[!0-9]*) interval=30;; esac; + sleep "$$interval"; + done + healthcheck: + test: + [ + "CMD-SHELL", + "db=\"$${GITTENSORY_AMS_ATTEMPT_LOG_REPORTING_DB:-/reporting/ams-attempt-log.sqlite}\"; test -s \"$$db\" && sqlite3 \"$$db\" 'PRAGMA quick_check;' | grep -q '^ok$'", + ] + interval: 30s + timeout: 5s + start_period: 30s + retries: 3 + # ── Log pipeline (--profile observability) ───────────────────────────────── # Loki stores logs; Promtail discovers every container in this compose project via the read-only # docker-proxy (NOT a raw socket) and ships their logs to Loki. Browse in Grafana → Explore → diff --git a/grafana/provisioning/datasources/ams-ledgers.yml b/grafana/provisioning/datasources/ams-ledgers.yml index 6b03c3f00f..bd98513ca5 100644 --- a/grafana/provisioning/datasources/ams-ledgers.yml +++ b/grafana/provisioning/datasources/ams-ledgers.yml @@ -1,16 +1,13 @@ -# AMS (gittensory-miner) local SQLite ledger datasources (#5184). +# AMS (gittensory-miner) redacted SQLite reporting datasources (#5184). # -# The miner keeps append-only SQLite ledgers under GITTENSORY_MINER_CONFIG_DIR (default ~/.config/gittensory-miner -# on a laptop, or /data/miner in the fleet Docker image). This wires two scrape-free `frser-sqlite-datasource` -# entries so a follow-on AMS usage dashboard can query them read-only — mirroring the maintainer `GittensoryDB` -# entry in sqlite.yml. This file is purely additive (it touches no existing datasource) and provisions NO -# dashboards or queries — that is the follow-on dashboard issue's scope. +# Grafana must not mount the miner's live GITTENSORY_MINER_CONFIG_DIR ledgers. The attempt log and prediction +# ledger contain free-form reasons, payload JSON, repo/target identifiers, readiness scores, and blocker/warning +# codes that are private AMS intelligence. Instead, expose only redacted reporting-export snapshots, mirroring the +# maintainer GittensoryDB pattern in sqlite.yml. # -# PATH — the absolute path varies per install, so `path:` below is a documented placeholder. Mount your miner's -# ledger directory (GITTENSORY_MINER_CONFIG_DIR) into the Grafana container at /ams-ledgers, e.g.: -# volumes: [ "${GITTENSORY_MINER_CONFIG_DIR:-~/.config/gittensory-miner}:/ams-ledgers:ro" ] -# then Grafana reads the files below. The `:ro` mount + this read-only query plugin mean Grafana can never write -# to the live ledgers. Adjust the two `path:` values if you mount elsewhere. +# PATH — the absolute path varies per install, so `path:` below is a documented placeholder. Publish sanitized AMS +# reporting exports into Grafana's existing reporting mount (for example /reporting/ams-attempt-log.sqlite and +# /reporting/ams-prediction-ledger.sqlite) and keep the live ledger directory private to the miner. apiVersion: 1 datasources: - name: AMS Attempt Log @@ -19,11 +16,11 @@ datasources: access: proxy editable: false jsonData: - path: /ams-ledgers/attempt-log.sqlite3 + path: /reporting/ams-attempt-log.sqlite - name: AMS Prediction Ledger type: frser-sqlite-datasource uid: ams-prediction-ledger access: proxy editable: false jsonData: - path: /ams-ledgers/prediction-ledger.sqlite3 + path: /reporting/ams-prediction-ledger.sqlite diff --git a/packages/gittensory-miner/docs/observability.md b/packages/gittensory-miner/docs/observability.md index db85e4220d..11f3732693 100644 --- a/packages/gittensory-miner/docs/observability.md +++ b/packages/gittensory-miner/docs/observability.md @@ -1,7 +1,8 @@ # Observing your miner -How to point Grafana at a running miner's local SQLite ledgers to see its attempt and prediction history. This -covers the **miner-specific** observability wiring only; for general self-host operations, see your ops runbook. +How to point Grafana at redacted miner reporting exports to see attempt and prediction history without exposing the +miner's live local ledgers. This covers the **miner-specific** observability wiring only; for general self-host +operations, see your ops runbook. ## What's observable @@ -13,12 +14,17 @@ The miner writes append-only SQLite ledgers under `GITTENSORY_MINER_CONFIG_DIR` timestamps), table `attempt_log_events`. - **`prediction-ledger.sqlite3`** — recorded predicted-gate verdicts for later scoring. -## Point Grafana at the ledgers +Those live files can contain free-form payloads, repo/target identifiers, readiness scores, and blocker/warning +codes. Keep `GITTENSORY_MINER_CONFIG_DIR` private to the miner. Grafana should read only sanitized reporting +exports that operators create from those ledgers. + +## Point Grafana at reporting exports The repo ships datasource provisioning at [`grafana/provisioning/datasources/ams-ledgers.yml`](../../../grafana/provisioning/datasources/ams-ledgers.yml) — two **read-only** `frser-sqlite-datasource` entries: `AMS Attempt Log` (uid `ams-attempt-log`) and -`AMS Prediction Ledger` (uid `ams-prediction-ledger`). +`AMS Prediction Ledger` (uid `ams-prediction-ledger`). Their default paths live under Grafana's reporting mount, +not under the miner config directory. 1. **Install the SQLite plugin** in Grafana — the same one the maintainer `GittensoryDB` datasource uses: @@ -26,18 +32,21 @@ The repo ships datasource provisioning at GF_INSTALL_PLUGINS=frser-sqlite-datasource ``` -2. **Mount your ledger directory** into the Grafana container, read-only, at `/ams-ledgers` so the provisioned - `path:` values resolve (the `:ro` mount plus the query-only plugin mean Grafana can never write the live - ledgers): +2. **Run the AMS reporting exporter**, a dedicated compose profile (only useful when a miner also runs on this + same host — an engine-only deployment has nothing for it to read): - ```yaml - # in your Grafana service (docker-compose) - volumes: - - "${GITTENSORY_MINER_CONFIG_DIR:-~/.config/gittensory-miner}:/ams-ledgers:ro" + ```sh + docker compose --profile ams-observability up -d ``` - The two datasources point at `/ams-ledgers/attempt-log.sqlite3` and `/ams-ledgers/prediction-ledger.sqlite3`. - If you mount elsewhere, edit the two `path:` values in `ams-ledgers.yml` to match. + Set `GITTENSORY_MINER_CONFIG_DIR` in your `.env` (see [`.env.example`](../../../.env.example)) to the same + directory your miner uses. The `ams-reporting-exporter` container mounts it **read-only**, runs + [`scripts/export-ams-reporting-db.sh`](../../../scripts/export-ams-reporting-db.sh) on an interval + (`GITTENSORY_AMS_REPORTING_EXPORT_INTERVAL_SECONDS`, default 30s), and writes the redacted snapshots into the + same `reporting` volume Grafana already reads — Grafana itself never mounts the live ledgers. The exported + schema drops `attempt_log_events.reason`/`.payload_json` (the free-form fields) entirely; every other column, + including the `predictions` table's `blocker_codes_json`/`warning_codes_json` (fixed, engine-defined codes — + never free text), passes through unchanged. 3. **Restart Grafana.** The two datasources appear under **Connections → Data sources**, already provisioned (non-editable) so they survive restarts. @@ -45,8 +54,7 @@ The repo ships datasource provisioning at ## Load a dashboard Dashboards live in [`grafana/dashboards/`](../../../grafana/dashboards/) and are auto-provisioned from that -directory. To visualize the ledgers, add an AMS dashboard JSON there — or import one at runtime via the Grafana -UI (**Dashboards → Import**) — and point its panels at the `AMS Attempt Log` / `AMS Prediction Ledger` -datasources above. Panels query the ledger tables directly (e.g. `SELECT * FROM attempt_log_events`); the -`frser-sqlite-datasource` plugin also supports `json_extract(payload_json, '$.…')` to read fields nested inside -an event's payload. +directory. To visualize AMS activity, add a dashboard JSON there — or import one at runtime via the Grafana UI +(**Dashboards → Import**) — and point its panels at the `AMS Attempt Log` / `AMS Prediction Ledger` datasources +above. Panels should query only the redacted reporting schema (e.g. `SELECT * FROM attempt_log_events`), never a +`payload_json`/`reason` column — the exporter drops both, so a panel referencing them returns no such column. diff --git a/scripts/export-ams-reporting-db.sh b/scripts/export-ams-reporting-db.sh new file mode 100644 index 0000000000..29ce3549ed --- /dev/null +++ b/scripts/export-ams-reporting-db.sh @@ -0,0 +1,185 @@ +#!/bin/sh +set -eu + +# AMS (gittensory-miner) redacted reporting export (#5184 follow-up; closes the gap PR #5471 flagged: Grafana must +# never mount the miner's live GITTENSORY_MINER_CONFIG_DIR ledgers directly -- attempt_log_events.reason and +# .payload_json are free-form and can carry arbitrary internal detail). Mirrors export-grafana-reporting-db.sh's +# shape (incremental fingerprint fast-path, atomic tmp-then-move, fail-open on a missing/unreadable source) but is +# deliberately a SEPARATE script: the two AMS ledgers are SQLite-only (the miner has no Postgres mode) and each +# source table is INSERT-only (attempt_log_events' own header states this invariant; prediction-ledger.js's is the +# same), so neither needs the mutable-table full-content-hash path the main script carries for pull_requests/ +# review_targets. +# +# Exports TWO independent ledgers in one run -- a missing/corrupt attempt log must never block the prediction +# ledger's export or vice versa, so each runs its own fail-open pass. +# +# Bump whenever this script's own mapping/redaction logic changes (not just when a source table gains a column): +# the incremental fast-path below only fingerprints SOURCE ROW COUNT + latest timestamp, so a logic-only edit +# would otherwise serve the previous run's output forever. +SCRIPT_VERSION="${GITTENSORY_AMS_REPORTING_SCRIPT_VERSION:-1}" + +OUT_DIR="${GITTENSORY_REPORTING_DIR:-/reporting}" +ATTEMPT_LOG_SOURCE_DB="${GITTENSORY_AMS_ATTEMPT_LOG_SOURCE_DB:-/ams-ledgers/attempt-log.sqlite3}" +ATTEMPT_LOG_OUT_DB="${GITTENSORY_AMS_ATTEMPT_LOG_REPORTING_DB:-$OUT_DIR/ams-attempt-log.sqlite}" +PREDICTION_LEDGER_SOURCE_DB="${GITTENSORY_AMS_PREDICTION_LEDGER_SOURCE_DB:-/ams-ledgers/prediction-ledger.sqlite3}" +PREDICTION_LEDGER_OUT_DB="${GITTENSORY_AMS_PREDICTION_LEDGER_REPORTING_DB:-$OUT_DIR/ams-prediction-ledger.sqlite}" + +mkdir -p "$OUT_DIR" + +hash_stdin() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 | awk '{print $1}' + else + cat >/dev/null + return 1 + fi +} + +source_table_exists() { + db="$1" + tbl="$2" + sqlite3 "$db" "SELECT 1 FROM sqlite_master WHERE type='table' AND name='$tbl' LIMIT 1" | grep -q 1 +} + +# Insert-only source: a row COUNT + MAX(created_at) aggregate can never miss a real change (nothing UPDATEs a row +# in place, matching attempt_log_events'/predictions' own append-only invariants), and stays O(1)-ish instead of +# an O(row-count) full-table hash as each ledger grows without bound. +append_only_fingerprint() { + db="$1" + tbl="$2" + time_col="$3" + sqlite3 "$db" "SELECT COUNT(*) || ':' || COALESCE(MAX($time_col), '') FROM $tbl" +} + +reporting_db_ok() { + db="$1" + [ -s "$db" ] || return 1 + sqlite3 "$db" "PRAGMA quick_check;" 2>/dev/null | grep -qx "ok" +} + +persist_fingerprint() { + fingerprint="$1" + file="$2" + [ -n "$fingerprint" ] || return 0 + printf '%s' "$fingerprint" >"${file}.tmp" + mv "${file}.tmp" "$file" +} + +# One ledger's full fail-open/fingerprint/atomic-export pass. Never propagates a failure to the caller (this +# script exports two independent ledgers per run and a bad one must not block the other) -- always returns 0, +# logging to stderr on any skip/failure path. +# +# $1 label (for log lines) $2 source db $3 out db $4 source table $5 time column +# $6 redacted CREATE TABLE DDL $7 redacted SELECT column list (source-table column names, in DDL column order) +export_ledger() { + label="$1" + src="$2" + out="$3" + tbl="$4" + time_col="$5" + ddl="$6" + select_cols="$7" + + tmp="${out}.tmp" + fp_file="${out}.fingerprint" + rm -f "$tmp" "$tmp-wal" "$tmp-shm" + + if [ ! -s "$src" ]; then + if [ -s "$out" ]; then + echo "[ams-reporting:$label] export skipped: source missing at $src; preserving last-good $out" >&2 + else + sqlite3 "$tmp" "$ddl" + sqlite3 "$tmp" "PRAGMA quick_check;" | grep -qx "ok" + mv "$tmp" "$out" + rm -f "$tmp-wal" "$tmp-shm" + echo "[ams-reporting:$label] export empty: source missing at $src" >&2 + fi + return 0 + fi + + if ! source_table_exists "$src" "$tbl"; then + if [ -s "$out" ]; then + echo "[ams-reporting:$label] export skipped: table $tbl absent in $src; preserving last-good $out" >&2 + else + sqlite3 "$tmp" "$ddl" + sqlite3 "$tmp" "PRAGMA quick_check;" | grep -qx "ok" + mv "$tmp" "$out" + rm -f "$tmp-wal" "$tmp-shm" + echo "[ams-reporting:$label] export empty: table $tbl absent in $src" >&2 + fi + return 0 + fi + + fingerprint="script=$SCRIPT_VERSION;$(append_only_fingerprint "$src" "$tbl" "$time_col")" + if reporting_db_ok "$out" && [ -s "$fp_file" ] && [ "$(cat "$fp_file")" = "$fingerprint" ]; then + echo "[ams-reporting:$label] export skipped: source unchanged since last export" + return 0 + fi + + sqlite3 "$tmp" "$ddl" + out_sql="$(printf "%s" "$tmp" | sed "s/'/''/g")" + sqlite3 -cmd ".timeout 5000" "$src" " +ATTACH '$out_sql' AS report; +INSERT INTO report.$tbl SELECT $select_cols FROM main.$tbl; +DETACH report; +" + if ! sqlite3 "$tmp" "PRAGMA quick_check;" | grep -qx "ok"; then + rm -f "$tmp" "$tmp-wal" "$tmp-shm" + echo "[ams-reporting:$label] export failed: rebuilt database failed quick_check, preserving last-good $out" >&2 + return 0 + fi + mv "$tmp" "$out" + rm -f "$tmp-wal" "$tmp-shm" + persist_fingerprint "$fingerprint" "$fp_file" + echo "[ams-reporting:$label] export complete: $out" +} + +# attempt_log_events: DROP `reason` and `payload_json` -- both free-form (payload_json in particular can nest +# arbitrary per-event-type detail, up to and including file paths/diffs/prompt fragments). Every other column is +# a bounded-vocabulary identifier/enum/timestamp, safe for a shared reporting export. +export_ledger \ + "attempt-log" \ + "$ATTEMPT_LOG_SOURCE_DB" \ + "$ATTEMPT_LOG_OUT_DB" \ + "attempt_log_events" \ + "created_at" \ + "CREATE TABLE attempt_log_events ( + id INTEGER PRIMARY KEY, + seq INTEGER NOT NULL, + attempt_id TEXT NOT NULL, + event_type TEXT NOT NULL, + action_class TEXT NOT NULL, + mode TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX attempt_log_events_attempt_idx ON attempt_log_events(attempt_id, seq); + CREATE INDEX attempt_log_events_created_idx ON attempt_log_events(created_at);" \ + "id, seq, attempt_id, event_type, action_class, mode, created_at" + +# predictions: kept as-is. Unlike attempt_log_events, every column here is already a bounded identifier, enum, +# score, or a fixed-vocabulary code array (blocker_codes_json/warning_codes_json -- engine-defined codes, never +# free text) -- exactly the kind of structured signal a "prediction ledger" dashboard needs to be useful at all. +export_ledger \ + "prediction-ledger" \ + "$PREDICTION_LEDGER_SOURCE_DB" \ + "$PREDICTION_LEDGER_OUT_DB" \ + "predictions" \ + "ts" \ + "CREATE TABLE predictions ( + id INTEGER PRIMARY KEY, + ts TEXT NOT NULL, + repo_full_name TEXT NOT NULL, + target_id INTEGER NOT NULL, + head_sha TEXT, + conclusion TEXT NOT NULL, + pack TEXT NOT NULL, + readiness_score REAL, + blocker_codes_json TEXT NOT NULL, + warning_codes_json TEXT NOT NULL, + engine_version TEXT NOT NULL + ); + CREATE INDEX predictions_repo_idx ON predictions(repo_full_name, id); + CREATE INDEX predictions_ts_idx ON predictions(ts);" \ + "id, ts, repo_full_name, target_id, head_sha, conclusion, pack, readiness_score, blocker_codes_json, warning_codes_json, engine_version" diff --git a/test/unit/ams-ledgers-datasource.test.ts b/test/unit/ams-ledgers-datasource.test.ts index a565998381..5c83ce1e64 100644 --- a/test/unit/ams-ledgers-datasource.test.ts +++ b/test/unit/ams-ledgers-datasource.test.ts @@ -4,9 +4,9 @@ import { describe, expect, it } from "vitest"; import { parse } from "yaml"; // Validation for the AMS ledger Grafana datasource provisioning (#5184). Config under grafana/provisioning/, not -// `src/**`, so Codecov doesn't gate it — but the read-only invariant (Grafana can only READ the miner's SQLite -// ledgers, never write) and the purely-additive contract (no UID collision, existing datasources untouched) are -// asserted here as real tests. +// `src/**`, so Codecov doesn't gate it — but the boundary invariant (Grafana reads only redacted reporting +// exports, never live miner ledgers) and the purely-additive contract (no UID collision, existing datasources +// untouched) are asserted here as real tests. const DS_DIR = join(process.cwd(), "grafana/provisioning/datasources"); const amsDoc = parse( readFileSync(join(DS_DIR, "ams-ledgers.yml"), "utf8"), @@ -14,24 +14,28 @@ const amsDoc = parse( const amsDatasources = (amsDoc.datasources as Array>) ?? []; describe("AMS ledger Grafana datasources (#5184)", () => { - it("provisions read-only frser-sqlite datasources for the attempt-log and prediction-ledger", () => { + it("provisions read-only frser-sqlite datasources for redacted AMS reporting exports", () => { expect(amsDatasources).toHaveLength(2); const paths = amsDatasources.map((d) => d.jsonData?.path); - expect(paths).toContain("/ams-ledgers/attempt-log.sqlite3"); - expect(paths).toContain("/ams-ledgers/prediction-ledger.sqlite3"); + expect(paths).toContain("/reporting/ams-attempt-log.sqlite"); + expect(paths).toContain("/reporting/ams-prediction-ledger.sqlite"); for (const ds of amsDatasources) { expect(ds.type).toBe("frser-sqlite-datasource"); } }); - it("is read-only and never grants write access to the ledgers", () => { + it("is read-only and never mounts live miner ledgers", () => { for (const ds of amsDatasources) { expect(ds.editable).toBe(false); // provisioned datasources are immutable in the UI expect(ds.access).toBe("proxy"); - // no write-enabling knob is present; the frser-sqlite query plugin only reads the file + // no write-enabling knob is present, and Grafana only sees reporting exports expect(JSON.stringify(ds)).not.toMatch( /write|allowUpdate|readonly"?:\s*false/i, ); + expect(ds.jsonData?.path).toMatch(/^\/reporting\//); + expect(ds.jsonData?.path).not.toMatch( + /\/ams-ledgers\/|attempt-log\.sqlite3|prediction-ledger\.sqlite3/, + ); } }); diff --git a/test/unit/selfhost-ams-reporting.test.ts b/test/unit/selfhost-ams-reporting.test.ts new file mode 100644 index 0000000000..9d23a5a7aa --- /dev/null +++ b/test/unit/selfhost-ams-reporting.test.ts @@ -0,0 +1,256 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +// Export coverage for scripts/export-ams-reporting-db.sh (#5184 follow-up / PR #5471's flagged confidentiality +// gap): Grafana must never mount the miner's live GITTENSORY_MINER_CONFIG_DIR ledgers directly, so this script +// reads them read-only and writes a redacted snapshot for ams-ledgers-datasource.test.ts's provisioning to point +// at. Mirrors selfhost-grafana-reporting.test.ts's spawn-the-real-script style. +const tmpRoots: string[] = []; + +function tmpRoot(): string { + const dir = mkdtempSync(join(tmpdir(), "gittensory-ams-reporting-")); + tmpRoots.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) rmSync(dir, { force: true, recursive: true }); +}); + +function sqlite(db: string, sql: string): string { + return execFileSync("sqlite3", [db, sql], { encoding: "utf8" }).trim(); +} + +function runExporter( + root: string, + overrides: Partial<{ + attemptLogSource: string; + predictionLedgerSource: string; + reportingDir: string; + scriptVersion: string; + intervalHint: string; + }> = {}, +): string { + const reportingDir = overrides.reportingDir ?? join(root, "reporting"); + return execFileSync("sh", ["scripts/export-ams-reporting-db.sh"], { + cwd: process.cwd(), + env: { + ...process.env, + GITTENSORY_AMS_ATTEMPT_LOG_SOURCE_DB: overrides.attemptLogSource ?? join(root, "attempt-log.sqlite3"), + GITTENSORY_AMS_PREDICTION_LEDGER_SOURCE_DB: overrides.predictionLedgerSource ?? join(root, "prediction-ledger.sqlite3"), + GITTENSORY_REPORTING_DIR: reportingDir, + ...(overrides.scriptVersion ? { GITTENSORY_AMS_REPORTING_SCRIPT_VERSION: overrides.scriptVersion } : {}), + }, + stdio: "pipe", + encoding: "utf8", + }); +} + +function seedAttemptLog(db: string, rows: Array<{ seq: number; attemptId: string; eventType: string; actionClass: string; mode: string; reason: string; payloadJson: string; createdAt: string }>): void { + sqlite( + db, + ` + CREATE TABLE 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 + ); + ${rows + .map( + (r) => + `INSERT INTO attempt_log_events (seq, attempt_id, event_type, action_class, mode, reason, payload_json, created_at) VALUES (${r.seq}, '${r.attemptId}', '${r.eventType}', '${r.actionClass}', '${r.mode}', '${r.reason.replace(/'/g, "''")}', '${r.payloadJson.replace(/'/g, "''")}', '${r.createdAt}');`, + ) + .join("\n")} + `, + ); +} + +function seedPredictionLedger(db: string, rows: Array<{ ts: string; repo: string; targetId: number; headSha: string; conclusion: string; pack: string; readinessScore: number; blockerCodesJson: string; warningCodesJson: string; engineVersion: string }>): void { + sqlite( + db, + ` + CREATE TABLE predictions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + repo_full_name TEXT NOT NULL, + target_id INTEGER NOT NULL, + head_sha TEXT, + conclusion TEXT NOT NULL, + pack TEXT NOT NULL, + readiness_score REAL, + blocker_codes_json TEXT NOT NULL, + warning_codes_json TEXT NOT NULL, + engine_version TEXT NOT NULL + ); + ${rows + .map( + (r) => + `INSERT INTO predictions (ts, repo_full_name, target_id, head_sha, conclusion, pack, readiness_score, blocker_codes_json, warning_codes_json, engine_version) VALUES ('${r.ts}', '${r.repo}', ${r.targetId}, '${r.headSha}', '${r.conclusion}', '${r.pack}', ${r.readinessScore}, '${r.blockerCodesJson.replace(/'/g, "''")}', '${r.warningCodesJson.replace(/'/g, "''")}', '${r.engineVersion}');`, + ) + .join("\n")} + `, + ); +} + +describe("scripts/export-ams-reporting-db.sh", () => { + it("exports attempt_log_events but drops the free-form reason and payload_json columns", () => { + const root = tmpRoot(); + const src = join(root, "attempt-log.sqlite3"); + seedAttemptLog(src, [ + { + seq: 1, + attemptId: "attempt-1", + eventType: "started", + actionClass: "write", + mode: "live", + reason: "internal reasoning that must never leave the miner's own box", + payloadJson: '{"diff":"private file contents"}', + createdAt: "2026-07-12T00:00:00Z", + }, + ]); + + runExporter(root, { attemptLogSource: src }); + + const outDb = join(root, "reporting", "ams-attempt-log.sqlite"); + expect(sqlite(outDb, "PRAGMA quick_check;")).toBe("ok"); + expect(sqlite(outDb, "SELECT count(*) FROM attempt_log_events;")).toBe("1"); + expect( + sqlite( + outDb, + "SELECT seq || '|' || attempt_id || '|' || event_type || '|' || action_class || '|' || mode || '|' || created_at FROM attempt_log_events;", + ), + ).toBe("1|attempt-1|started|write|live|2026-07-12T00:00:00Z"); + expect(sqlite(outDb, "SELECT count(*) FROM pragma_table_info('attempt_log_events') WHERE name IN ('reason', 'payload_json');")).toBe("0"); + }); + + it("exports predictions with every column intact (already bounded/structured, no free text)", () => { + const root = tmpRoot(); + const src = join(root, "prediction-ledger.sqlite3"); + seedPredictionLedger(src, [ + { + ts: "2026-07-12T00:00:00Z", + repo: "acme/widgets", + targetId: 42, + headSha: "abc123", + conclusion: "merge", + pack: "default", + readinessScore: 0.92, + blockerCodesJson: "[]", + warningCodesJson: '["stale_branch"]', + engineVersion: "1.0.0", + }, + ]); + + runExporter(root, { predictionLedgerSource: src }); + + const outDb = join(root, "reporting", "ams-prediction-ledger.sqlite"); + expect(sqlite(outDb, "PRAGMA quick_check;")).toBe("ok"); + expect( + sqlite( + outDb, + "SELECT repo_full_name || '|' || target_id || '|' || conclusion || '|' || readiness_score || '|' || warning_codes_json FROM predictions;", + ), + ).toBe("acme/widgets|42|merge|0.92|[\"stale_branch\"]"); + }); + + it("fails open with an empty, valid reporting DB when a source ledger is missing, without blocking the other ledger", () => { + const root = tmpRoot(); + const predictionSrc = join(root, "prediction-ledger.sqlite3"); + seedPredictionLedger(predictionSrc, [ + { ts: "2026-07-12T00:00:00Z", repo: "acme/widgets", targetId: 1, headSha: "s", conclusion: "merge", pack: "default", readinessScore: 1, blockerCodesJson: "[]", warningCodesJson: "[]", engineVersion: "1.0.0" }, + ]); + + runExporter(root, { + attemptLogSource: join(root, "does-not-exist-attempt-log.sqlite3"), + predictionLedgerSource: predictionSrc, + }); + + const attemptOut = join(root, "reporting", "ams-attempt-log.sqlite"); + const predictionOut = join(root, "reporting", "ams-prediction-ledger.sqlite"); + expect(sqlite(attemptOut, "PRAGMA quick_check;")).toBe("ok"); + expect(sqlite(attemptOut, "SELECT count(*) FROM attempt_log_events;")).toBe("0"); + expect(sqlite(predictionOut, "SELECT count(*) FROM predictions;")).toBe("1"); + }); + + it("skips the rebuild on a second run when the source is unchanged (#3895-style incremental fast path)", () => { + const root = tmpRoot(); + const src = join(root, "attempt-log.sqlite3"); + seedAttemptLog(src, [{ seq: 1, attemptId: "a1", eventType: "started", actionClass: "write", mode: "live", reason: "r", payloadJson: "{}", createdAt: "2026-07-12T00:00:00Z" }]); + + const first = runExporter(root, { attemptLogSource: src }); + expect(first).toContain("export complete"); + const second = runExporter(root, { attemptLogSource: src }); + expect(second).toContain("export skipped: source unchanged"); + }); + + it("redoes the rebuild once the source actually changes, reflecting the new row", () => { + const root = tmpRoot(); + const src = join(root, "attempt-log.sqlite3"); + seedAttemptLog(src, [{ seq: 1, attemptId: "a1", eventType: "started", actionClass: "write", mode: "live", reason: "r", payloadJson: "{}", createdAt: "2026-07-12T00:00:00Z" }]); + runExporter(root, { attemptLogSource: src }); + + sqlite(src, "INSERT INTO attempt_log_events (seq, attempt_id, event_type, action_class, mode, reason, payload_json, created_at) VALUES (2, 'a1', 'succeeded', 'write', 'live', 'r2', '{}', '2026-07-12T00:01:00Z');"); + const second = runExporter(root, { attemptLogSource: src }); + + expect(second).toContain("export complete"); + const outDb = join(root, "reporting", "ams-attempt-log.sqlite"); + expect(sqlite(outDb, "SELECT count(*) FROM attempt_log_events;")).toBe("2"); + }); + + it("forces a fresh rebuild when the script's own logic version changes, even with source data unchanged", () => { + const root = tmpRoot(); + const src = join(root, "attempt-log.sqlite3"); + seedAttemptLog(src, [{ seq: 1, attemptId: "a1", eventType: "started", actionClass: "write", mode: "live", reason: "r", payloadJson: "{}", createdAt: "2026-07-12T00:00:00Z" }]); + runExporter(root, { attemptLogSource: src, scriptVersion: "1" }); + + const second = runExporter(root, { attemptLogSource: src, scriptVersion: "2" }); + + expect(second).toContain("export complete"); + }); + + it("preserves the last-good reporting DB when the source table goes missing after a prior successful export", () => { + const root = tmpRoot(); + const src = join(root, "attempt-log.sqlite3"); + seedAttemptLog(src, [{ seq: 1, attemptId: "a1", eventType: "started", actionClass: "write", mode: "live", reason: "r", payloadJson: "{}", createdAt: "2026-07-12T00:00:00Z" }]); + runExporter(root, { attemptLogSource: src }); + + sqlite(src, "DROP TABLE attempt_log_events;"); + // The "table absent" log line goes to stderr (matching export-grafana-reporting-db.sh's own convention of + // routing degraded/anomaly paths to stderr, not stdout) -- not captured by runExporter's stdout-only pipe, + // so this test asserts on the BEHAVIOR (last-good output preserved) rather than the log text. + runExporter(root, { attemptLogSource: src }); + + const outDb = join(root, "reporting", "ams-attempt-log.sqlite"); + expect(sqlite(outDb, "PRAGMA quick_check;")).toBe("ok"); + expect(sqlite(outDb, "SELECT count(*) FROM attempt_log_events;")).toBe("1"); + }); + + it("still detects a new row via the cheap count+max fast path even when the row count itself stays the same size class", () => { + const root = tmpRoot(); + const src = join(root, "prediction-ledger.sqlite3"); + seedPredictionLedger(src, [ + { ts: "2026-07-12T00:00:00Z", repo: "acme/widgets", targetId: 1, headSha: "s1", conclusion: "merge", pack: "default", readinessScore: 1, blockerCodesJson: "[]", warningCodesJson: "[]", engineVersion: "1.0.0" }, + ]); + runExporter(root, { predictionLedgerSource: src }); + + sqlite(src, "DELETE FROM predictions;"); + sqlite( + src, + "INSERT INTO predictions (ts, repo_full_name, target_id, head_sha, conclusion, pack, readiness_score, blocker_codes_json, warning_codes_json, engine_version) VALUES ('2026-07-12T00:05:00Z', 'acme/widgets', 2, 's2', 'close', 'default', 0.1, '[\"linked_issue_missing\"]', '[]', '1.0.0');", + ); + const second = runExporter(root, { predictionLedgerSource: src }); + + expect(second).toContain("export complete"); + const outDb = join(root, "reporting", "ams-prediction-ledger.sqlite"); + expect(sqlite(outDb, "SELECT target_id || '|' || conclusion FROM predictions;")).toBe("2|close"); + }); +});