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
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
27 changes: 25 additions & 2 deletions src/lib/scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined>, 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", () => {
Expand Down
54 changes: 43 additions & 11 deletions src/lib/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<string, string | undefined>): 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),
};
}

Expand Down