diff --git a/README.md b/README.md index d4f403d..44ff731 100644 --- a/README.md +++ b/README.md @@ -227,15 +227,22 @@ empty dropdown means the session exists but the token is unusable. ### 7. Test the cron endpoint -Nothing schedules the cron locally, so trigger it yourself. This reads -`CRON_SECRET` from `.env.local` rather than making you paste it: +Nothing schedules the cron locally — production points cron-job.org at the +deployed URL, and `vercel.json` declares no crons. So on localhost a due +schedule stays `pending` indefinitely: `scheduledAt` is a `WHERE` filter in +`getDueScheduleIds`, not a timer. Run the dispatcher yourself: ```bash -SECRET=$(node -e 'require("dotenv").config({path:".env.local",quiet:true});process.stdout.write(process.env.CRON_SECRET)') -curl -s -H "Authorization: Bearer $SECRET" http://localhost:3000/api/cron/execute +npm run cron:dev ``` -With nothing due you get `{"message":"No schedules due",...}`. To exercise the +It ticks immediately, then every 60s until Ctrl-C — production's cadence. So +Ctrl-C after the first line gives you a single tick. It reads `CRON_SECRET` +from `.env.local` (the same precedence Next uses) rather than making you paste +it. If `.env` and `.env.local` hold different secrets, `.env.local` is the one +the server loaded — a 401 says they disagree, and the script tells you so. + +Each tick prints one line, `processed 0` when nothing is due. To exercise the whole path, add a workflow to a repository you don't mind dispatching: ```yaml @@ -249,18 +256,14 @@ jobs: - run: echo "dispatched at $(date -u)" ``` -Schedule it a couple of minutes out, then call the endpoint again — `triggered` -becomes 1. Call it once more a minute later and the resolution pass fills in -`runId`, `runUrl`, and `runConclusion`. `npm run db:studio` shows the rows. - -Or run a loop to imitate production: +Schedule it a couple of minutes out and leave the loop running. The tick after +`scheduledAt` passes reports `triggered 1`; a later one fills in `runId`, +`runUrl`, and `runConclusion` via the resolution pass and reports `resolved 1`. +`npm run db:studio` shows the rows. -```bash -while true; do - curl -s -H "Authorization: Bearer $SECRET" http://localhost:3000/api/cron/execute - sleep 60 -done -``` +Override the target or cadence with `CRON_DEV_URL` and `CRON_DEV_INTERVAL_MS`. +This calls the real dispatcher, so it dispatches real workflow runs against +real repositories. ## Checks diff --git a/package.json b/package.json index bb516e8..d6b383b 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "next start", "lint": "eslint", "test": "vitest run", + "cron:dev": "node scripts/cron-dev.mjs", "db:up": "docker compose -f docker/docker-compose.yml up -d --wait postgres", "db:down": "docker compose -f docker/docker-compose.yml stop postgres", "db:deploy": "node scripts/prisma.mjs migrate deploy", diff --git a/scripts/cron-dev.mjs b/scripts/cron-dev.mjs new file mode 100644 index 0000000..2196a98 --- /dev/null +++ b/scripts/cron-dev.mjs @@ -0,0 +1,77 @@ +/** + * Imitates the production cron against a local dev server. + * + * Nothing calls /api/cron/execute locally: production points cron-job.org at + * the deployed URL and vercel.json declares no crons, so on localhost a due + * schedule stays "pending" forever -- scheduledAt is a WHERE filter in + * getDueScheduleIds, not a timer. This calls the endpoint on the same cadence + * production uses. + * + * This dispatches REAL workflow runs against real repositories. It is the + * production dispatcher, not a simulation of it. + * + * npm run cron:dev # ticks immediately, then every 60s until Ctrl-C + * + * Override with CRON_DEV_URL and CRON_DEV_INTERVAL_MS. + */ +import { config } from "dotenv"; + +// First file wins, so .env.local overrides .env exactly as it does in Next -- +// which matters here because the two files may hold different CRON_SECRETs, +// and the app reads .env.local. +config({ path: [".env.local", ".env"], quiet: true }); + +const secret = process.env.CRON_SECRET?.trim(); +const url = + process.env.CRON_DEV_URL ?? "http://localhost:3000/api/cron/execute"; +const intervalMs = Number(process.env.CRON_DEV_INTERVAL_MS ?? 60_000); + +if (!secret) { + console.error( + "CRON_SECRET is not set in .env.local or .env\n\n" + + "The endpoint authenticates with it, so without it every call is a 401.\n" + + "Generate one with: openssl rand -base64 32\n", + ); + process.exit(1); +} + +async function tick() { + const at = new Date().toISOString().slice(11, 19); + + try { + const response = await fetch(url, { + headers: { Authorization: `Bearer ${secret}` }, + }); + const body = await response.json().catch(() => null); + + if (!response.ok) { + const hint = + response.status === 401 + ? " (CRON_SECRET here does not match the one the server loaded)" + : ""; + console.error( + `${at} HTTP ${response.status} ${body?.error ?? response.statusText}${hint}`, + ); + return; + } + + const { processed = 0, triggered = 0, failed = 0, resolution } = body ?? {}; + console.log( + `${at} processed ${processed} triggered ${triggered} failed ${failed}` + + ` linked ${resolution?.linked ?? 0} resolved ${resolution?.resolved ?? 0}`, + ); + } catch (error) { + // Dev server down or mid-restart. Not fatal - the next tick retries. + console.error(`${at} unreachable: ${error.message}`); + } +} + +console.warn("! dispatches real workflow runs against real repositories"); +console.log(`↻ ${url} every ${intervalMs / 1000}s - Ctrl-C to stop\n`); + +// Sequential rather than setInterval so a slow tick can never overlap itself. +// Ticks before the first sleep, so Ctrl-C after one line is a single tick. +while (true) { + await tick(); + await new Promise((resolve) => setTimeout(resolve, intervalMs)); +}