diff --git a/src/api/routes.ts b/src/api/routes.ts index 1efb667f11..6c98ee24aa 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -265,6 +265,7 @@ import { buildRepoOutcomeCalibration } from "../services/outcome-calibration"; import { loadGatePrecisionReport } from "../services/gate-precision"; import { computeOpsStats, isOpsEnabled } from "../review/ops-wire"; import { deleteLiveOverride, listOverrideAudit, sanitizeOverridePayload, type StorageEnv } from "../review/auto-apply"; +import { handleInternalCalibration, handleInternalDecision, type OpsAgentConfig } from "../review/ops"; import { computeParityReadiness, isParityAuditEnabled } from "../review/parity-wire"; import { computePredictedGateAgreement } from "../review/predicted-gate-agreement"; import { isRagEnabled } from "../review/rag-wire"; @@ -926,6 +927,16 @@ export function isCloudflareWorkerRuntime(): boolean { return typeof navigator !== "undefined" && navigator.userAgent === "Cloudflare-Workers"; } +/** The {@link OpsAgentConfig} the `/v1/internal/decision` + `/v1/internal/calibration` operator read endpoints + * run under: the app slug (the `project` namespace the review agent records its `review_targets`/`review_audit` + * rows under — the same `GITHUB_APP_SLUG` fallback operator-dashboard's config uses) plus the + * `INTERNAL_JOB_TOKEN` secret name. The handlers' own `requireInternalAuth` re-checks that bearer, so they gate + * on the SAME `INTERNAL_JOB_TOKEN` the `/v1/internal/*` middleware already enforces — one logical gate. */ +function internalOpsAgentConfig(env: Env): OpsAgentConfig { + const slug = env.GITHUB_APP_SLUG?.trim() || "loopover"; + return { slug, secrets: { internalSecret: "INTERNAL_JOB_TOKEN" } }; +} + export function createApp() { const app = new Hono(); // Registered FIRST/outermost (Sentry's own guidance) so it wraps every other middleware and route below, @@ -3773,6 +3784,17 @@ export function createApp() { return c.json(await computePredictedGateAgreement(c.env, { days: 90, nowMs: Date.now() })); }); + // Operator decision-trail: the full state + cached terminal decision + audit log for ONE review target, so any + // gate verdict is explainable on demand (?repo=&number=[&kind=pull_request|issue]). Bearer-gated + // by the `/v1/internal/*` middleware (INTERNAL_JOB_TOKEN); handleInternalDecision re-checks that same token and + // 400s a missing/invalid repo+number, 404s an unknown target. Aggregate review state only — no PR content. + app.get("/v1/internal/decision", (c) => handleInternalDecision(c.req.raw, c.env, internalOpsAgentConfig(c.env))); + + // Operator calibration: confidence-vs-outcome curve + a recommended confidence floor for the review agent. + // Bearer-gated by the `/v1/internal/*` middleware (INTERNAL_JOB_TOKEN); handleInternalCalibration re-checks it. + // Fails safe to an empty-but-shaped report when there is no review signal yet. Aggregate counts only. + app.get("/v1/internal/calibration", (c) => handleInternalCalibration(c.req.raw, c.env, internalOpsAgentConfig(c.env))); + app.post("/v1/internal/jobs/refresh-registry", async (c) => { const message: JobMessage = { type: "refresh-registry", requestedBy: "api" }; await c.env.JOBS.send(message); diff --git a/src/review/stats.ts b/src/review/stats.ts index 741520fddb..b675c763a3 100644 --- a/src/review/stats.ts +++ b/src/review/stats.ts @@ -481,34 +481,6 @@ export async function computeStats( }; } -/** GET //internal/parity?days=90&shadow=loopover — bearer-gated, CORS-open cross-system gate - * parity feed (the per-repo cutover gate). Scoped to the agent's own project. Mirrors handleStats. */ -export async function handleParity( - request: Request, - env: Env, - project: string, - deps: StatsEvalDeps = defaultStatsEvalDeps, -): Promise { - if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS_HEADERS }); - const expected = readSecret(env, STATS_TOKEN_SECRET); - const provided = request.headers.get("authorization") ?? ""; - if (!expected || !timingSafeEqual(provided, `Bearer ${expected}`)) { - return new Response("unauthorized", { status: 401, headers: CORS_HEADERS }); - } - const params = new URL(request.url).searchParams; - const authoritative = params.get("authoritative"); - const shadow = params.get("shadow"); - const parity = await deps.computeGateParity(env, { - days: Number(params.get("days") ?? 90), - nowMs: Date.now(), - project, - ...(authoritative !== null ? { authoritative } : {}), - ...(shadow !== null ? { shadow } : {}), - }); - const cutoverReady = parity.rows.map((r) => ({ project: r.project, ready: isParityCutoverReady(r) })); - return Response.json({ ...parity, cutoverReady }, { headers: CORS_HEADERS }); -} - /** GET /stats/data?days=90&bucket=day — bearer-gated, CORS-open aggregate feed for the local dashboard. */ export async function handleStats(request: Request, env: Env, deps: StatsEvalDeps = defaultStatsEvalDeps): Promise { if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS_HEADERS }); diff --git a/test/unit/routes-internal-decision-calibration.test.ts b/test/unit/routes-internal-decision-calibration.test.ts new file mode 100644 index 0000000000..75b7a7ea65 --- /dev/null +++ b/test/unit/routes-internal-decision-calibration.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { createTestEnv } from "../helpers/d1"; + +// The `/v1/internal/decision` + `/v1/internal/calibration` operator read endpoints wire the ops.ts handlers +// (handleInternalDecision / handleInternalCalibration) into real routes. Bearer-gated by the `/v1/internal/*` +// middleware (INTERNAL_JOB_TOKEN); the handlers re-check that same token via their own requireInternalAuth. +const bearer = (env: Env) => ({ authorization: `Bearer ${env.INTERNAL_JOB_TOKEN}` }); + +describe("GET /v1/internal/decision — operator decision-trail endpoint", () => { + it("401s without the internal token (the /v1/internal/* middleware gate)", async () => { + const app = createApp(); + const env = createTestEnv(); + expect((await app.request("/v1/internal/decision?repo=owner/repo&number=5", {}, env)).status).toBe(401); + expect((await app.request("/v1/internal/decision?repo=owner/repo&number=5", { headers: { authorization: "Bearer nope" } }, env)).status).toBe(401); + }); + + it("400s on a missing/invalid repo+number", async () => { + const app = createApp(); + const env = createTestEnv(); + const res = await app.request("/v1/internal/decision?repo=bad", { headers: bearer(env) }, env); + expect(res.status).toBe(400); + }); + + it("404s when the target does not exist", async () => { + const app = createApp(); + const env = createTestEnv(); + const res = await app.request("/v1/internal/decision?repo=owner/repo&number=999", { headers: bearer(env) }, env); + expect(res.status).toBe(404); + }); + + it("200s with the decision trail for a seeded target, scoped to the app slug", async () => { + const app = createApp(); + const env = createTestEnv(); // GITHUB_APP_SLUG defaults to "gittensory" + // review_targets is raw-SQL-only (migration 0050) — seed the row the endpoint reads back. Its id is the + // project-namespaced natural key `${slug}:${kind}:${repo}#${number}` (rowId). + await env.DB.prepare( + `INSERT INTO review_targets (id, project, kind, repo, number, status, verdict, head_sha, decided_sha, attempt_count, terminal_at, decision_json) + VALUES (?, ?, 'pull_request', ?, ?, 'merged', 'merge', 'abc123', 'abc123', 1, '2026-07-01T00:00:00Z', ?)`, + ) + .bind("gittensory:pull_request:owner/repo#5", "gittensory", "owner/repo", 5, JSON.stringify({ action: "merge", confidence: 0.9 })) + .run(); + await env.DB.prepare( + `INSERT INTO review_audit (id, project, target_id, event_type, decision, summary, created_at) + VALUES ('a1', 'gittensory', ?, 'reviewed', 'merge', 'looks good', '2026-07-01T00:00:00Z')`, + ) + .bind("gittensory:pull_request:owner/repo#5") + .run(); + const res = await app.request("/v1/internal/decision?repo=owner/repo&number=5", { headers: bearer(env) }, env); + expect(res.status).toBe(200); + const body = (await res.json()) as { project: string; target: { number: number; status: string }; decision: unknown; audit: Array<{ event: string }> }; + expect(body.project).toBe("gittensory"); + expect(body.target.number).toBe(5); + expect(body.target.status).toBe("merged"); + expect(body.decision).toEqual({ action: "merge", confidence: 0.9 }); + expect(body.audit.map((a) => a.event)).toContain("reviewed"); + // Privacy: aggregate review state only — never actor logins / trust internals. + expect(JSON.stringify(body)).not.toMatch(/login|actor|reward|payout|trust|wallet|hotkey/i); + }); +}); + +describe("GET /v1/internal/calibration — operator calibration endpoint", () => { + it("401s without the internal token (the /v1/internal/* middleware gate)", async () => { + const app = createApp(); + const env = createTestEnv(); + expect((await app.request("/v1/internal/calibration", {}, env)).status).toBe(401); + }); + + it("200s with the calibration report (fail-safe empty), scoped to the app slug", async () => { + const app = createApp(); + const env = createTestEnv(); + const res = await app.request("/v1/internal/calibration", { headers: bearer(env) }, env); + expect(res.status).toBe(200); + const body = (await res.json()) as { project: string; calibration: { currentFloor: number; note: string } }; + expect(body.project).toBe("gittensory"); + expect(typeof body.calibration.currentFloor).toBe("number"); + expect(typeof body.calibration.note).toBe("string"); + }); + + it("falls back to the 'loopover' project slug when GITHUB_APP_SLUG is unset (the || default branch)", async () => { + const app = createApp(); + const env = createTestEnv({ GITHUB_APP_SLUG: "" }); + const res = await app.request("/v1/internal/calibration", { headers: bearer(env) }, env); + expect(res.status).toBe(200); + expect(((await res.json()) as { project: string }).project).toBe("loopover"); + }); +}); diff --git a/test/unit/stats.test.ts b/test/unit/stats.test.ts index 095f571054..456a6216d3 100644 --- a/test/unit/stats.test.ts +++ b/test/unit/stats.test.ts @@ -10,7 +10,6 @@ import { cycleTimeMs, EMPTY_CYCLE_TIME, EMPTY_FINDING_ACCEPTANCE, - handleParity, handleStats, isParityCutoverReady, MIN_PARITY_SAMPLE, @@ -288,93 +287,6 @@ describe("computeStats — gate-decision read is fail-safe", () => { }); }); -describe("handleParity — bearer-gated, CORS-open cross-system parity feed", () => { - const PARITY_DEPS: StatsEvalDeps = { - computeGateEval: async () => ({ rows: [], hasSignal: false }), - computeTuningRecommendations: () => [], - computeGateParity: async () => ({ - authoritative: "reviewbot", - shadow: "gittensory", - hasSignal: true, - rows: [ - { project: "gittensory", pairedSamples: 40, bothMerge: 40, bothClose: 0, bothHold: 0, disagree: 0, agreementRate: 1, unsafeDisagreements: 0, byReasonCode: [] }, - { project: "gittensory", pairedSamples: 5, bothMerge: 5, bothClose: 0, bothHold: 0, disagree: 0, agreementRate: 1, unsafeDisagreements: 0, byReasonCode: [] }, - ], - }), - }; - const req = (headers: Record = {}, method = "GET") => - new Request("https://w.dev/gittensory/internal/parity?days=90&shadow=gittensory", { method, headers }); - - it("204s a CORS preflight with no auth", async () => { - const res = await handleParity(req({}, "OPTIONS"), stubEnv({ LOOPOVER_REVIEW_STATS_TOKEN: "s3cret" }), "gittensory"); - expect(res.status).toBe(204); - expect(res.headers.get("access-control-allow-origin")).toBe("*"); - }); - - it("401s when the token is unset or wrong", async () => { - expect((await handleParity(req({ authorization: "Bearer anything" }), stubEnv(), "gittensory")).status).toBe(401); // unset - const env = stubEnv({ LOOPOVER_REVIEW_STATS_TOKEN: "s3cret" }); - expect((await handleParity(req(), env, "gittensory")).status).toBe(401); // no header - expect((await handleParity(req({ authorization: "Bearer nope" }), env, "gittensory")).status).toBe(401); // wrong - }); - - it("200s with the parity report + per-row cutoverReady for the correct token", async () => { - const res = await handleParity(req({ authorization: "Bearer s3cret" }), stubEnv({ LOOPOVER_REVIEW_STATS_TOKEN: "s3cret" }), "gittensory", PARITY_DEPS); - expect(res.status).toBe(200); - expect(res.headers.get("access-control-allow-origin")).toBe("*"); - const body = (await res.json()) as { authoritative: string; shadow: string; cutoverReady: Array<{ project: string; ready: boolean }> }; - expect(body.authoritative).toBe("reviewbot"); - expect(body.shadow).toBe("gittensory"); - // first row (40 paired, perfect agreement, 0 unsafe) is cutover-ready; the thin 5-sample row is not. - expect(body.cutoverReady).toEqual([{ project: "gittensory", ready: true }, { project: "gittensory", ready: false }]); - }); - - it("forwards the ?authoritative / ?shadow params (non-null branch) and uses ?days override", async () => { - let seen: { days: number; authoritative?: string; shadow?: string } | undefined; - const deps: StatsEvalDeps = { - computeGateEval: async () => ({ rows: [], hasSignal: false }), - computeTuningRecommendations: () => [], - computeGateParity: async (_env, o) => { - seen = { days: o.days, ...(o.authoritative !== undefined ? { authoritative: o.authoritative } : {}), ...(o.shadow !== undefined ? { shadow: o.shadow } : {}) }; - return { authoritative: o.authoritative ?? "a", shadow: o.shadow ?? "s", hasSignal: false, rows: [] }; - }, - }; - const r = new Request("https://w.dev/gittensory/internal/parity?days=14&authoritative=reviewbot&shadow=gittensory", { - method: "GET", - headers: { authorization: "Bearer s3cret" }, - }); - const res = await handleParity(r, stubEnv({ LOOPOVER_REVIEW_STATS_TOKEN: "s3cret" }), "gittensory", deps); - expect(res.status).toBe(200); - expect(seen).toEqual({ days: 14, authoritative: "reviewbot", shadow: "gittensory" }); - }); - - it("omits authoritative/shadow (the {} branch) and defaults days to 90 when those params are absent", async () => { - let seen: { days: number; hasAuthoritative: boolean; hasShadow: boolean } | undefined; - const deps: StatsEvalDeps = { - computeGateEval: async () => ({ rows: [], hasSignal: false }), - computeTuningRecommendations: () => [], - computeGateParity: async (_env, o) => { - seen = { days: o.days, hasAuthoritative: "authoritative" in o, hasShadow: "shadow" in o }; - return { authoritative: "reviewbot", shadow: "gittensory", hasSignal: false, rows: [] }; - }, - }; - // No days / authoritative / shadow params → days defaults to 90, both spreads collapse to {}. - const r = new Request("https://w.dev/gittensory/internal/parity", { method: "GET", headers: { authorization: "Bearer s3cret" } }); - const res = await handleParity(r, stubEnv({ LOOPOVER_REVIEW_STATS_TOKEN: "s3cret" }), "gittensory", deps); - expect(res.status).toBe(200); - expect(seen).toEqual({ days: 90, hasAuthoritative: false, hasShadow: false }); - }); - - it("uses the default deps (defaultStatsEvalDeps) when none are injected — empty parity, no cutoverReady rows", async () => { - const res = await handleParity(req({ authorization: "Bearer s3cret" }), stubEnv({ LOOPOVER_REVIEW_STATS_TOKEN: "s3cret" }), "gittensory"); - expect(res.status).toBe(200); - const body = (await res.json()) as { authoritative: string; cutoverReady: unknown[] }; - // default emptyParity uses the URL's shadow=gittensory and a default authoritative=reviewbot. - expect(body.authoritative).toBe("reviewbot"); - expect(body.cutoverReady).toEqual([]); - }); -}); - describe("handleStats — query-param default branches", () => { it("defaults days→90 and bucket→day when those params are absent (the ?? fallbacks)", async () => { let captured: { days: number; bucket: string } | undefined;