From f337ad423b20bf92757390514e324830fdf8fb6a Mon Sep 17 00:00:00 2001 From: Sean Silvius Date: Mon, 27 Apr 2026 22:11:38 -0700 Subject: [PATCH 1/2] Add orphan sweep handler and hourly cron trigger Closes #100. Phase 1 of the uncertainty engine spike. Pure sweep function in api/lib/uncertainty/orphan-sweep.ts: flips state='emitted' -> 'orphaned' where orphan_after < now, returning the count and cutoff. Uses the existing (state, orphan_after) index from the migration. Exposed at POST /api/uncertainty/internal/orphan-sweep, gated by a CRON_SECRET bearer token. The Astro Cloudflare adapter does not expose a scheduled() entrypoint, so the wrangler hourly trigger calls the internal endpoint via an external scheduler (or a future companion worker) rather than a worker-internal scheduled handler. The trigger pattern "0 * * * *" is wired in wrangler.jsonc. Tests document the WHERE-clause shape so the index choice stays load-bearing in the unit suite. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/api/lib/uncertainty/orphan-sweep.ts | 24 +++++++++++++++++++ apps/web/src/api/routes/uncertainty.ts | 23 ++++++++++++++++++ .../api/lib/uncertainty/orphan-sweep.test.ts | 18 ++++++++++++++ apps/web/worker-configuration.d.ts | 6 ++++- apps/web/wrangler.jsonc | 12 ++++++++++ 5 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/api/lib/uncertainty/orphan-sweep.ts create mode 100644 apps/web/tests/api/lib/uncertainty/orphan-sweep.test.ts diff --git a/apps/web/src/api/lib/uncertainty/orphan-sweep.ts b/apps/web/src/api/lib/uncertainty/orphan-sweep.ts new file mode 100644 index 0000000..9befd73 --- /dev/null +++ b/apps/web/src/api/lib/uncertainty/orphan-sweep.ts @@ -0,0 +1,24 @@ +import { and, eq, lt } from "drizzle-orm"; +import type { Database } from "../../../db/client"; +import { uncertaintyPrediction } from "../../../db/schema/uncertainty"; + +export type SweepResult = { + swept: number; + cutoffMs: number; +}; + +export async function sweepOrphans(db: Database, now: Date = new Date()): Promise { + const cutoff = now; + const result = await db + .update(uncertaintyPrediction) + .set({ state: "orphaned" }) + .where( + and( + eq(uncertaintyPrediction.state, "emitted"), + lt(uncertaintyPrediction.orphanAfter, cutoff), + ), + ) + .returning({ id: uncertaintyPrediction.id }); + + return { swept: result.length, cutoffMs: cutoff.getTime() }; +} diff --git a/apps/web/src/api/routes/uncertainty.ts b/apps/web/src/api/routes/uncertainty.ts index eb4319d..a5ad7d1 100644 --- a/apps/web/src/api/routes/uncertainty.ts +++ b/apps/web/src/api/routes/uncertainty.ts @@ -7,9 +7,21 @@ import { createDb } from "../../db/client"; import { uncertaintyCalibrationSnapshot, uncertaintyPrediction } from "../../db/schema/uncertainty"; import { outcomeLabelSchema, surfaceSchema } from "../../db/schema/uncertainty.zod"; import { cohortKey, DAY_MS, DEFAULT_ORPHAN_TTL_DAYS } from "../lib/uncertainty/cohort"; +import { sweepOrphans } from "../lib/uncertainty/orphan-sweep"; import { requireAuth } from "../middleware/auth"; import type { HonoEnv } from "../types"; +function authorizeCron(c: { + req: { header: (n: string) => string | undefined }; + env: Env; +}): boolean { + const secret = c.env.CRON_SECRET; + if (!secret) return false; + const header = c.req.header("authorization"); + if (!header) return false; + return header === `Bearer ${secret}`; +} + const emitBodySchema = z.object({ surface: surfaceSchema, feature_key: z.string().min(1).max(128), @@ -130,4 +142,15 @@ const uncertaintyRoutes = new Hono() return c.json({ surfaces: rows }); }); +const internalRoutes = new Hono().post("/orphan-sweep", async (c) => { + if (!authorizeCron({ req: { header: (n) => c.req.header(n) }, env: c.env })) { + return c.json({ error: "Unauthorized" }, 401); + } + const db = createDb(c.env.DB); + const result = await sweepOrphans(db); + return c.json(result); +}); + +uncertaintyRoutes.route("/internal", internalRoutes); + export { uncertaintyRoutes }; diff --git a/apps/web/tests/api/lib/uncertainty/orphan-sweep.test.ts b/apps/web/tests/api/lib/uncertainty/orphan-sweep.test.ts new file mode 100644 index 0000000..bee2e7b --- /dev/null +++ b/apps/web/tests/api/lib/uncertainty/orphan-sweep.test.ts @@ -0,0 +1,18 @@ +import { and, eq, lt } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { uncertaintyPrediction } from "../../../../src/db/schema/uncertainty"; + +describe("orphan sweep WHERE clause", () => { + it("filters on state='emitted' AND orphan_after < cutoff", () => { + // Documents the predicate shape the sweep relies on. If either side + // shifts, the index (state, orphan_after) needs to be revisited. + const cutoff = new Date(1_700_000_000_000); + const clause = and( + eq(uncertaintyPrediction.state, "emitted"), + lt(uncertaintyPrediction.orphanAfter, cutoff), + ); + expect(clause).toBeDefined(); + expect(uncertaintyPrediction.state.name).toBe("state"); + expect(uncertaintyPrediction.orphanAfter.name).toBe("orphan_after"); + }); +}); diff --git a/apps/web/worker-configuration.d.ts b/apps/web/worker-configuration.d.ts index c48d9d7..11b26f9 100644 --- a/apps/web/worker-configuration.d.ts +++ b/apps/web/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: ebdf5e7161d935f565772396f56f7889) +// Generated by Wrangler by running `wrangler types` (hash: 17589c13ed4b5ad2f4cd96970db37c40) // Runtime types generated with workerd@1.20260317.1 2026-03-18 nodejs_compat declare namespace Cloudflare { interface Env { @@ -7,12 +7,14 @@ declare namespace Cloudflare { rafters_email: R2Bucket; rafters_logs: R2Bucket; DB: D1Database; + CRON_SECRET: ""; BETTER_AUTH_SECRET: string; BETTER_AUTH_URL: string; GITHUB_CLIENT_ID: string; GITHUB_CLIENT_SECRET: string; POLAR_ACCESS_TOKEN: string; POLAR_WEBHOOK_SECRET: string; + CF_WORKER_AI_KEY: string; } } interface Env extends Cloudflare.Env {} @@ -23,12 +25,14 @@ declare namespace NodeJS { interface ProcessEnv extends StringifyValues< Pick< Cloudflare.Env, + | "CRON_SECRET" | "BETTER_AUTH_SECRET" | "BETTER_AUTH_URL" | "GITHUB_CLIENT_ID" | "GITHUB_CLIENT_SECRET" | "POLAR_ACCESS_TOKEN" | "POLAR_WEBHOOK_SECRET" + | "CF_WORKER_AI_KEY" > > {} } diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index 869cb28..3432819 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -42,4 +42,16 @@ "binding": "rafters_logs", }, ], + // CRON_SECRET is a runtime secret. Declared here only so wrangler types + // includes it in Env. Set the real value with `wrangler secret put CRON_SECRET`. + "vars": { + "CRON_SECRET": "", + }, + // Hourly orphan sweep. The Astro adapter does not expose a scheduled() + // entrypoint, so the trigger calls POST /api/uncertainty/internal/orphan-sweep + // via an external scheduler (or a future companion worker) using the + // CRON_SECRET bearer token. + "triggers": { + "crons": ["0 * * * *"], + }, } From 38a959aba0d18ef5de62a78b4ad6fee17ae38cda Mon Sep 17 00:00:00 2001 From: Sean Silvius Date: Mon, 27 Apr 2026 22:39:25 -0700 Subject: [PATCH 2/2] Re-run CI on public repo