Skip to content
Open
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: 7 additions & 0 deletions .changeset/external-cron-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"eve": minor
---

feat(eve): external cron mode for self-hosted builds (`EVE_EXTERNAL_CRON=1`)

Building with `EVE_EXTERNAL_CRON=1` on the node preset registers no in-process cron: the deployment instead mounts the same unguessable token cron route the Vercel preset uses (`POST /eve/v1/cron/<token>`, `x-vercel-cron-schedule` header, optional `CRON_SECRET` bearer check) and writes `.output/eve/cron-manifest.json` with the route path and one entry per distinct cron expression listing the schedules it dispatches. Hosting platforms can own the clock — replica deduplication, catch-up policies, manual triggering — by driving that route from their own scheduler, exactly like Vercel Cron does.
24 changes: 23 additions & 1 deletion docs/guides/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,33 @@ Self-deployed agents should make the Vercel-specific choices explicit:
- Use `AI_GATEWAY_API_KEY` if you still want Gateway routing from a non-Vercel host.
- Replace `vercelOidc()` with auth that your host can verify.
- Use `defaultBackend()`, a pinned non-Vercel sandbox backend such as Docker or microsandbox, or your own `SandboxBackend` adapter.
- If the agent defines schedules, the default `eve build && eve start` path starts Nitro's schedule runner, and Vercel wires schedules to Vercel Cron automatically. If you adapt the output to a custom HTTP-only host or preset, make sure it also runs Nitro scheduled tasks, or trigger the same work from your own scheduler.
- If the agent defines schedules, the default `eve build && eve start` path starts Nitro's schedule runner, and Vercel wires schedules to Vercel Cron automatically. If you adapt the output to a custom HTTP-only host or preset, make sure it also runs Nitro scheduled tasks, trigger the same work from your own scheduler, or build with `EVE_EXTERNAL_CRON=1` (below) to hand the clock to an external scheduler.
- Treat Vercel Cron, Vercel Sandbox prewarm, Vercel Deployment Protection bypass, and the Agent Runs dashboard as Vercel-only conveniences.

The HTTP contract is unchanged: health, session creation, streaming, channels, tools, and subagents use the same routes under `/eve/`, and the workflow dispatch route lives under `/.well-known/workflow/`. A reverse proxy must preserve both prefixes. Any client that can reach and authenticate to those routes can talk to the agent.

### External schedule dispatch

By default a self-hosted build fires schedules from an in-process runner: every replica keeps its own clock, ticks that land while the process is down are lost, and production exposes no endpoint to pause or trigger a schedule. A hosting platform that wants to own the clock — deduplication across replicas, catch-up policies, manual "run now" — can build in external cron mode:

```bash
EVE_EXTERNAL_CRON=1 eve build
```

The build then registers no in-process cron. Instead it:

- mounts the same unguessable token cron route the Vercel preset uses (`POST /eve/v1/cron/<token>`); the path is the credential, and a configured `CRON_SECRET` is additionally enforced as a bearer token, exactly as on Vercel;
- writes `.output/eve/cron-manifest.json` with the route path and one entry per distinct cron expression, each listing the schedules it dispatches — the self-hosted equivalent of the `config.crons[]` Vercel reads from build output. Create one scheduler job per entry: a POST fires every schedule registered for that expression, so one job per schedule would double-dispatch shared expressions. The manifest contains the secret path, so it lives only in build output and is never served.

Your scheduler drives the deployment the same way Vercel Cron does: on each due tick, POST to the route with the cron expression in the `x-vercel-cron-schedule` header:

```bash
curl -X POST "https://<your-app>$(jq -r .cronHandlerRoute .output/eve/cron-manifest.json)" \
-H "x-vercel-cron-schedule: 0 8 * * *"
```

Every schedule registered for that expression dispatches, and the response lists the sessions each dispatch started. The flag has no effect on Vercel builds, where the platform already owns the clock, or in `eve dev`, where schedules are dispatched through the dev route.

## 9. Verify the deployment

Smoke-test the live routes. Health first:
Expand Down
14 changes: 14 additions & 0 deletions packages/eve/src/internal/nitro/host/create-application-nitro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import {
import { createCompiledSandboxBackendPrunePlugin } from "#internal/nitro/host/compiled-sandbox-backend-prune-plugin.js";
import { configureNitroRoutes } from "#internal/nitro/host/configure-nitro-routes.js";
import { applyEveCronHandlerRoute } from "#internal/nitro/host/cron-handler-route.js";
import {
applyExternalCronHandlerRoute,
isExternalCronEnabled,
} from "#internal/nitro/host/external-cron.js";
import { createNitroBundlerConfig } from "#internal/nitro/host/nitro-bundler-config.js";
import { captureDevLiveVirtualModules } from "#internal/nitro/host/dev-live-virtual-modules.js";
import {
Expand Down Expand Up @@ -801,6 +805,9 @@ export async function createApplicationNitro(
// use (e.g. dev mode), where the cron route is never registered.
applyEveCronHandlerRoute(nitro);

// External cron mode only applies to self-hosted (node preset) builds:
// on Vercel the platform already owns the clock through the route above.
const externalCron = preset === undefined && isExternalCronEnabled();
const artifactsConfig: NitroArtifactsConfigInput = createNitroArtifactsConfig({
appRoot: preparedHost.appRoot,
dev: nitro.options.dev,
Expand All @@ -810,8 +817,15 @@ export async function createApplicationNitro(
dispatchModulePath: resolvePackageSourceFilePath(
"src/internal/nitro/routes/schedule-task.ts",
),
registerCronSchedules: !externalCron,
registrations: preparedHost.scheduleRegistrations,
});
if (externalCron) {
applyExternalCronHandlerRoute(nitro, {
artifactsConfig,
registrations: preparedHost.scheduleRegistrations,
});
}
}
await configureNitroRoutes(nitro, preparedHost, {
surface,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { afterEach, beforeEach, describe, expect, it } from "vitest";

import type { ScheduleRegistration } from "#runtime/schedules/register.js";
import {
applyExternalCronHandlerRoute,
EVE_CRON_MANIFEST_OUTPUT_PATH,
type ExternalCronNitro,
} from "#internal/nitro/host/external-cron.js";

const ARTIFACTS_CONFIG = {
appRoot: "/tmp/test-agent",
dev: false,
} as const;

const REGISTRATIONS: ScheduleRegistration[] = [
{
cron: "0 8 * * *",
description: 'Run eve schedule "daily".',
logicalPath: "schedules/daily.md",
scheduleId: "daily",
sourceId: "schedules/daily.md",
taskName: "eve.schedule.daily",
},
{
cron: "*/5 * * * *",
description: 'Run eve schedule "heartbeat".',
logicalPath: "schedules/heartbeat.md",
scheduleId: "heartbeat",
sourceId: "schedules/heartbeat.md",
taskName: "eve.schedule.heartbeat",
},
{
cron: "*/5 * * * *",
description: 'Run eve schedule "sync".',
logicalPath: "schedules/sync.md",
scheduleId: "sync",
sourceId: "schedules/sync.md",
taskName: "eve.schedule.sync",
},
];

describe("applyExternalCronHandlerRoute manifest emission", () => {
let outputDir: string;

beforeEach(async () => {
outputDir = await mkdtemp(join(tmpdir(), "eve-external-cron-"));
});

afterEach(async () => {
await rm(outputDir, { force: true, recursive: true });
});

it("writes the cron manifest grouped by expression on compiled", async () => {
const compiledHooks: Array<() => Promise<void> | void> = [];
const nitro: ExternalCronNitro = {
hooks: {
hook(name, fn) {
if (name === "compiled") {
compiledHooks.push(fn);
}
},
},
options: {
handlers: [],
output: { dir: outputDir },
virtual: {},
},
};

const route = applyExternalCronHandlerRoute(nitro, {
artifactsConfig: ARTIFACTS_CONFIG,
registrations: REGISTRATIONS,
});

expect(compiledHooks).toHaveLength(1);
await compiledHooks[0]!();

const manifest = JSON.parse(
await readFile(join(outputDir, EVE_CRON_MANIFEST_OUTPUT_PATH), "utf8"),
) as unknown;
expect(manifest).toEqual({
version: 1,
cronHandlerRoute: route,
crons: [
{ cron: "0 8 * * *", schedules: ["daily"] },
{ cron: "*/5 * * * *", schedules: ["heartbeat", "sync"] },
],
});
});
});
117 changes: 117 additions & 0 deletions packages/eve/src/internal/nitro/host/external-cron.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";

import { EVE_ROUTE_PREFIX } from "#protocol/routes.js";
import type { ScheduleRegistration } from "#runtime/schedules/register.js";
import {
applyExternalCronHandlerRoute,
isExternalCronEnabled,
type ExternalCronNitro,
} from "#internal/nitro/host/external-cron.js";

const ARTIFACTS_CONFIG = {
appRoot: "/tmp/test-agent",
dev: false,
} as const;

const REGISTRATIONS: ScheduleRegistration[] = [
{
cron: "0 8 * * *",
description: 'Run eve schedule "daily".',
logicalPath: "schedules/daily.md",
scheduleId: "daily",
sourceId: "schedules/daily.md",
taskName: "eve.schedule.daily",
},
{
cron: "*/5 * * * *",
description: 'Run eve schedule "heartbeat".',
logicalPath: "schedules/heartbeat.md",
scheduleId: "heartbeat",
sourceId: "schedules/heartbeat.md",
taskName: "eve.schedule.heartbeat",
},
];

describe("isExternalCronEnabled", () => {
it("accepts 1 and true, case- and whitespace-insensitively", () => {
expect(isExternalCronEnabled({ EVE_EXTERNAL_CRON: "1" })).toBe(true);
expect(isExternalCronEnabled({ EVE_EXTERNAL_CRON: "true" })).toBe(true);
expect(isExternalCronEnabled({ EVE_EXTERNAL_CRON: " TRUE " })).toBe(true);
});

it("rejects everything else", () => {
expect(isExternalCronEnabled({})).toBe(false);
expect(isExternalCronEnabled({ EVE_EXTERNAL_CRON: "" })).toBe(false);
expect(isExternalCronEnabled({ EVE_EXTERNAL_CRON: "0" })).toBe(false);
expect(isExternalCronEnabled({ EVE_EXTERNAL_CRON: "false" })).toBe(false);
expect(isExternalCronEnabled({ EVE_EXTERNAL_CRON: "yes" })).toBe(false);
});
});

describe("applyExternalCronHandlerRoute", () => {
it("registers a POST handler at an unguessable route with the schedules baked in", () => {
const nitro = createNitroStub();

const route = applyExternalCronHandlerRoute(nitro, {
artifactsConfig: ARTIFACTS_CONFIG,
registrations: REGISTRATIONS,
});

expect(route.startsWith(`${EVE_ROUTE_PREFIX}/cron/`)).toBe(true);
expect(nitro.options.handlers).toEqual([
{
handler: "#eve-route-external-cron",
method: "POST",
route,
},
]);

const virtualSource = nitro.options.virtual["#eve-route-external-cron"];
expect(virtualSource).toContain("handleExternalCronRequest");
expect(virtualSource).toContain(
JSON.stringify({
artifactsConfig: ARTIFACTS_CONFIG,
schedules: [
{ cron: "0 8 * * *", name: "daily", taskName: "eve.schedule.daily" },
{ cron: "*/5 * * * *", name: "heartbeat", taskName: "eve.schedule.heartbeat" },
],
}),
);
});

it("registers exactly one manifest write on the compiled hook", () => {
const nitro = createNitroStub();

applyExternalCronHandlerRoute(nitro, {
artifactsConfig: ARTIFACTS_CONFIG,
registrations: REGISTRATIONS,
});

// The write-out itself (and its content) is covered by the
// integration test — unit tests stay hermetic.
expect(nitro.compiledHooks).toHaveLength(1);
});
});

type NitroStub = ExternalCronNitro & {
compiledHooks: Array<() => Promise<void> | void>;
};

function createNitroStub(): NitroStub {
const compiledHooks: Array<() => Promise<void> | void> = [];
return {
compiledHooks,
hooks: {
hook(name, fn) {
if (name === "compiled") {
compiledHooks.push(fn);
}
},
},
options: {
handlers: [],
output: { dir: "/tmp/fake-output" },
virtual: {},
},
};
}
Loading