Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 19 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
77 changes: 77 additions & 0 deletions scripts/cron-dev.mjs
Original file line number Diff line number Diff line change
@@ -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));
}