diff --git a/AGENTS.md b/AGENTS.md index d44fc05b..bf6da828 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,8 +41,11 @@ npm run db:deploy # Deploy migrations (prod) | `DISPATCH_DATABASE_URL` | No | Alternative database URL alias — used if `DATABASE_URL` is not set | | `NEXTAUTH_SECRET` | No | NextAuth.js secret | | `NEXTAUTH_URL` | No | NextAuth.js URL | -| `DISPATCH_SCHEDULER_ENABLED` | No | `true` runs periodic jobs (issue sync) in-process instead of via external cronjobs. Off by default. Confine to a single replica. | -| `DISPATCH_SYNC_INTERVAL_MS` | No | Scheduled-sync interval when the in-app scheduler is enabled (default 900000 = 15m) | +| `DISPATCH_SCHEDULER_ENABLED` | No | `true` runs periodic jobs (sync, groomer, PR-followup, prune-closed) in-process instead of via external cronjobs. Off by default. Confine to a single replica. | +| `DISPATCH_SYNC_INTERVAL_MS` | No | Scheduled-sync interval when the scheduler is enabled (default 900000 = 15m; `0` disables the job) | +| `DISPATCH_GROOMER_INTERVAL_MS` | No | Groomer run interval (default 600000 = 10m; `0` disables) | +| `DISPATCH_PR_FOLLOWUP_INTERVAL_MS` | No | PR-followup sync interval (default 900000 = 15m; `0` disables) | +| `DISPATCH_PRUNE_CLOSED_INTERVAL_MS` | No | Closed-issue prune interval (default 86400000 = 24h; `0` disables) | Resolution order: `DATABASE_URL` > `DISPATCH_DATABASE_URL`. `DISPATCH_AGENT_TOKEN` for agent API bearer auth. diff --git a/src/lib/scheduler.test.ts b/src/lib/scheduler.test.ts index 42eb2435..d993e162 100644 --- a/src/lib/scheduler.test.ts +++ b/src/lib/scheduler.test.ts @@ -38,10 +38,33 @@ describe("schedulerConfigFromEnv", () => { }); it("honors DISPATCH_SYNC_INTERVAL_MS and falls back on garbage", () => { - expect(schedulerConfigFromEnv({ DISPATCH_SYNC_INTERVAL_MS: "60000" }).jobs[0].intervalMs).toBe(60000); - expect(schedulerConfigFromEnv({ DISPATCH_SYNC_INTERVAL_MS: "nope" }).jobs[0].intervalMs).toBe(15 * 60 * 1000); + const byName = (env: Record, name: string) => + schedulerConfigFromEnv(env).jobs.find((j) => j.name === name)!; + expect(byName({ DISPATCH_SYNC_INTERVAL_MS: "60000" }, "sync").intervalMs).toBe(60000); + expect(byName({ DISPATCH_SYNC_INTERVAL_MS: "nope" }, "sync").intervalMs).toBe(15 * 60 * 1000); expect(schedulerConfigFromEnv({ PORT: "" }).baseUrl).toBe("http://127.0.0.1:3000"); }); + + it("configures sync + groomer + pr-followup + prune-closed with defaults", () => { + const jobs = schedulerConfigFromEnv({}).jobs; + expect(jobs.map((j) => j.name)).toEqual(["sync", "groomer", "pr-followup", "prune-closed"]); + const byName = (n: string) => jobs.find((j) => j.name === n)!; + expect(byName("groomer").path).toBe("/api/groomer/run"); + expect(byName("groomer").intervalMs).toBe(10 * 60 * 1000); + expect(byName("pr-followup").path).toBe("/api/pr-followup/sync"); + expect(byName("pr-followup").intervalMs).toBe(15 * 60 * 1000); + expect(byName("prune-closed").path).toBe("/api/issues/prune-closed"); + expect(byName("prune-closed").intervalMs).toBe(24 * 60 * 60 * 1000); + }); + + it("disables an individual job when its interval env is 0", () => { + const jobs = schedulerConfigFromEnv({ DISPATCH_GROOMER_INTERVAL_MS: "0", DISPATCH_PRUNE_CLOSED_INTERVAL_MS: "0" }).jobs; + const names = jobs.map((j) => j.name); + expect(names).toContain("sync"); + expect(names).toContain("pr-followup"); + expect(names).not.toContain("groomer"); + expect(names).not.toContain("prune-closed"); + }); }); describe("runJob", () => { diff --git a/src/lib/scheduler.ts b/src/lib/scheduler.ts index bc6d8b05..4b001dbb 100644 --- a/src/lib/scheduler.ts +++ b/src/lib/scheduler.ts @@ -47,6 +47,9 @@ export interface SchedulerDeps { } const DEFAULT_SYNC_INTERVAL_MS = 15 * 60 * 1000; // 15m +const DEFAULT_GROOMER_INTERVAL_MS = 10 * 60 * 1000; // 10m +const DEFAULT_PR_FOLLOWUP_INTERVAL_MS = 15 * 60 * 1000; // 15m +const DEFAULT_PRUNE_CLOSED_INTERVAL_MS = 24 * 60 * 60 * 1000; // daily const DEFAULT_STARTUP_DELAY_MS = 5 * 1000; function intFromEnv(raw: string | undefined, fallback: number): number { @@ -55,25 +58,54 @@ function intFromEnv(raw: string | undefined, fallback: number): number { } /** - * Build the scheduler config from the environment. Only the sync job is wired - * today; groomer/pr-followup/prune-closed are added in follow-up issues (they - * need their own concurrency guards first). + * Per-job interval: unset → fallback; explicit "0" → 0 (job disabled and + * filtered out); any other positive value overrides. + */ +function jobIntervalFromEnv(raw: string | undefined, fallback: number): number { + if (raw !== undefined && raw.trim() === "0") return 0; + return intFromEnv(raw, fallback); +} + +/** + * Build the scheduler config from the environment. All periodic jobs run via + * loopback POSTs to the existing endpoints; each is authed with + * DISPATCH_AGENT_TOKEN (the groomer endpoint accepts it too) and can be + * disabled individually by setting its interval env to "0". */ export function schedulerConfigFromEnv(env: Record): SchedulerConfig { const port = env.PORT && env.PORT.trim() !== "" ? env.PORT.trim() : "3000"; + const jobs: ScheduledJob[] = [ + { + name: "sync", + path: "/api/sync/scheduled", + body: { issues: true }, + intervalMs: jobIntervalFromEnv(env.DISPATCH_SYNC_INTERVAL_MS, DEFAULT_SYNC_INTERVAL_MS), + }, + { + name: "groomer", + path: "/api/groomer/run", + body: {}, + intervalMs: jobIntervalFromEnv(env.DISPATCH_GROOMER_INTERVAL_MS, DEFAULT_GROOMER_INTERVAL_MS), + }, + { + name: "pr-followup", + path: "/api/pr-followup/sync", + body: {}, + intervalMs: jobIntervalFromEnv(env.DISPATCH_PR_FOLLOWUP_INTERVAL_MS, DEFAULT_PR_FOLLOWUP_INTERVAL_MS), + }, + { + name: "prune-closed", + path: "/api/issues/prune-closed", + body: {}, + intervalMs: jobIntervalFromEnv(env.DISPATCH_PRUNE_CLOSED_INTERVAL_MS, DEFAULT_PRUNE_CLOSED_INTERVAL_MS), + }, + ]; return { enabled: env.DISPATCH_SCHEDULER_ENABLED === "true", baseUrl: `http://127.0.0.1:${port}`, token: env.DISPATCH_AGENT_TOKEN ?? "", startupDelayMs: intFromEnv(env.DISPATCH_SCHEDULER_STARTUP_DELAY_MS, DEFAULT_STARTUP_DELAY_MS), - jobs: [ - { - name: "sync", - path: "/api/sync/scheduled", - body: { issues: true }, - intervalMs: intFromEnv(env.DISPATCH_SYNC_INTERVAL_MS, DEFAULT_SYNC_INTERVAL_MS), - }, - ], + jobs: jobs.filter((job) => job.intervalMs > 0), }; }