From 642e4f3d9e27164d173871cf149cf045a4d02308 Mon Sep 17 00:00:00 2001 From: 7174Andy Date: Sat, 1 Aug 2026 19:24:03 -0700 Subject: [PATCH] feat: add npm run cron:dev to drive the dispatcher locally Nothing calls /api/cron/execute on localhost. Production points cron-job.org at the deployed URL and vercel.json declares no crons, so a due schedule stays "pending" indefinitely in development - scheduledAt is a WHERE filter in getDueScheduleIds, not a timer. This reads as a bug ("the job never fired") when it is really just a missing caller. The README already documented a hand-rolled curl loop for this. Wrap it as a script so it is one command, and so the two traps it hides are handled: - It loads .env.local ahead of .env, matching Next's precedence. Those files can hold different CRON_SECRETs, and a call made with the wrong one returns 401 - which again looks like "the job never fired". The script names that cause explicitly on a 401 rather than printing a bare status code. - It ticks before its first sleep, so Ctrl-C after one line is a single tick. Sequential rather than setInterval so a slow tick cannot overlap itself, and it keeps going when the dev server is down or restarting. Also corrects the README, which promised raw JSON the script does not print and described calling the endpoint repeatedly by hand. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 35 +++++++++++--------- package.json | 1 + scripts/cron-dev.mjs | 77 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 scripts/cron-dev.mjs 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)); +}