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
5 changes: 5 additions & 0 deletions .changeset/dev-schedule-dispatch-500.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Fix `eve dev`'s one-shot schedule dispatch route (`POST /eve/v1/dev/schedules/:id`) returning a 500 with `Cannot find module '<projectRoot>/src/internal/authored-module-map-loader.ts'`. The route now reuses the module map loader path resolved once at server start instead of re-deriving it from inside the bundled dev server, where that resolution silently broke.
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ export async function configureNitroRoutes(
route: EVE_DEV_RUNTIME_ARTIFACTS_ROUTE_PATH,
});
addFrameworkVirtualHandler(nitro, {
args: JSON.stringify({ appRoot: artifactsConfig.appRoot }),
args: JSON.stringify(artifactsConfig),
handlerExport: "handleDevScheduleDispatchRequest",
method: "POST",
modulePath: resolvePackageSourceFilePath(
Expand Down
32 changes: 23 additions & 9 deletions packages/eve/src/internal/nitro/host/dispatch-schedule-in-dev.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js";
import { createAuthoredSourceRuntimeCompiledArtifactsSource } from "#internal/application/runtime-compiled-artifacts-source.js";
import type { NitroArtifactsConfigInput } from "#internal/nitro/host/artifacts-config.js";
import { createDiskRuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js";
import { createScheduleRegistrations } from "#runtime/schedules/register.js";
import { loadResolvedCompiledSchedules } from "#runtime/schedules/resolve-schedule.js";

Expand Down Expand Up @@ -51,12 +51,30 @@ export class UnknownDevScheduleError extends Error {
* Re-resolves authored schedule registrations from disk on every call so
* the route picks up edits made by the authored-source watcher without a
* dev-server restart.
*
* `artifactsConfig` is baked in once by `configure-nitro-routes.ts` at
* server-build time — inside the unbundled host CLI process — and passed
* through as-is rather than re-derived here. Recomputing it at request time
* would call `resolvePackageSourceFilePath` from code running inside the
* bundled Nitro dev output, where its "find an ancestor directory literally
* named `dist`" heuristic fails and silently resolves to a broken path (see
* vercel/eve#311).
*/
export async function dispatchScheduleInDev(input: {
readonly appRoot: string;
readonly artifactsConfig: NitroArtifactsConfigInput;
readonly scheduleId: string;
}): Promise<DispatchScheduleInDevResult> {
const compiledArtifactsSource = createAuthoredSourceRuntimeCompiledArtifactsSource(input.appRoot);
const { appRoot, moduleMapLoaderPath } = input.artifactsConfig;

if (appRoot === undefined || moduleMapLoaderPath === undefined) {
throw new Error(
'Dev schedule dispatch requires "appRoot" and "moduleMapLoaderPath" in the artifacts config.',
);
}

const compiledArtifactsSource = createDiskRuntimeCompiledArtifactsSource(appRoot, {
moduleMapLoaderPath,
});
const schedules = await loadResolvedCompiledSchedules({ compiledArtifactsSource });
const registrations = createScheduleRegistrations(schedules);
const registration = registrations.find((candidate) => candidate.scheduleId === input.scheduleId);
Expand All @@ -69,11 +87,7 @@ export async function dispatchScheduleInDev(input: {
}

const { dispatchScheduleTask } = await import("#internal/nitro/routes/schedule-task.js");
const artifactsConfig = createNitroArtifactsConfig({
appRoot: input.appRoot,
dev: true,
});
const result = await dispatchScheduleTask(registration.taskName, artifactsConfig);
const result = await dispatchScheduleTask(registration.taskName, input.artifactsConfig);

return {
scheduleId: result.scheduleId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ vi.mock("#internal/nitro/host/dispatch-schedule-in-dev.js", async () => {
});

const APP_ROOT = "/tmp/eve-test";
const ARTIFACTS_CONFIG = {
appRoot: APP_ROOT,
dev: true,
moduleMapLoaderPath: "/tmp/eve-test/module-map-loader.js",
};

async function importHandler() {
return await import("#internal/nitro/routes/dev-schedule-dispatch.js");
Expand All @@ -25,7 +30,7 @@ async function postSchedule(scheduleIdInUrl: string): Promise<Response> {
const request = new Request(`http://localhost:3000/eve/v1/dev/schedules/${scheduleIdInUrl}`, {
method: "POST",
});
return await handleDevScheduleDispatchRequest({ appRoot: APP_ROOT }, request);
return await handleDevScheduleDispatchRequest(ARTIFACTS_CONFIG, request);
}

describe("handleDevScheduleDispatchRequest", () => {
Expand All @@ -44,7 +49,7 @@ describe("handleDevScheduleDispatchRequest", () => {
sessionIds: ["sess-1", "sess-2"],
});
expect(mocks.dispatchScheduleInDev).toHaveBeenCalledWith({
appRoot: APP_ROOT,
artifactsConfig: ARTIFACTS_CONFIG,
scheduleId: "heartbeat",
});
});
Expand All @@ -59,7 +64,7 @@ describe("handleDevScheduleDispatchRequest", () => {

expect(response.status).toBe(200);
expect(mocks.dispatchScheduleInDev).toHaveBeenCalledWith({
appRoot: APP_ROOT,
artifactsConfig: ARTIFACTS_CONFIG,
scheduleId: "weird/name",
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
dispatchScheduleInDev,
UnknownDevScheduleError,
} from "#internal/nitro/host/dispatch-schedule-in-dev.js";
import type { NitroArtifactsConfigInput } from "#internal/nitro/host/artifacts-config.js";
import { EVE_ROUTE_PREFIX } from "#protocol/routes.js";

/**
Expand Down Expand Up @@ -29,7 +30,7 @@ const DEV_DISPATCH_SCHEDULE_PATH_PATTERN = new RegExp(
* Auth: none. The dev server is local-only and the route is dev-only.
*/
export async function handleDevScheduleDispatchRequest(
input: { appRoot: string },
input: NitroArtifactsConfigInput,
request: Request,
): Promise<Response> {
const url = new URL(request.url);
Expand All @@ -52,7 +53,7 @@ export async function handleDevScheduleDispatchRequest(

try {
const result = await dispatchScheduleInDev({
appRoot: input.appRoot,
artifactsConfig: input,
scheduleId,
});
return Response.json({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import { spawn, type ChildProcessByStdio } from "node:child_process";
import { join } from "node:path";
import type { Readable } from "node:stream";

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

import { createEveDevDispatchSchedulePath } from "../../src/protocol/routes.js";
import {
type ScenarioAppDescriptor,
useScenarioApp,
} from "../../src/internal/testing/scenario-app.js";

/**
* Pins vercel/eve#311: `POST /eve/v1/dev/schedules/:id` 500ing with
*
* Cannot find module '<projectRoot>/src/internal/authored-module-map-loader.ts'
* imported from '<projectRoot>/.eve/nitro/dev/index.mjs'
*
* Pre-fix, `dispatchScheduleInDev` re-derived its compiled-artifacts source
* (and artifacts config) via `resolvePackageSourceFilePath` at request time,
* from code Nitro had already bundled into `.eve/nitro/dev/index.mjs`. That
* function locates eve's own package root by walking up from the *currently
* executing module's* directory looking for an ancestor literally named
* `dist` — a walk that only succeeds when the code runs unbundled straight
* out of `node_modules/eve/dist`. Every other Nitro route sidesteps this by
* resolving the path once in the unbundled host CLI process and baking the
* literal into the virtual handler; this route was the one place that
* didn't, so it silently resolved against the *app's* package root instead
* of eve's the moment Nitro bundled it. A unit test cannot reproduce this —
* it only manifests once Nitro has actually bundled the route — so this
* spawns a real `eve dev` against an installed (tarball) `eve`, the same
* shape a real user's `node_modules/eve` takes.
*/

const SCHEDULE_AGENT_SOURCE = `import { defineAgent } from "eve";

export default defineAgent({
model: "openai/gpt-5.4-mini",
});
`;

const SCHEDULE_TOOL_SOURCE = `import { defineTool } from "eve/tools";
import { z } from "zod";

export default defineTool({
description: "Record that the heartbeat schedule ran.",
inputSchema: z.object({
note: z.string(),
}),
async execute(input) {
return \`heartbeat-ok:\${input.note}\`;
},
});
`;

const SCHEDULE_SOURCE = `import { defineSchedule } from "eve/schedules";

export default defineSchedule({
cron: "0 0 * * *",
markdown: "Call the \`record-heartbeat\` tool exactly once with note 'cron-tick'.",
});
`;

const SCHEDULE_DISPATCH_AGENT_DESCRIPTOR: ScenarioAppDescriptor = {
dependencies: {
zod: "^4.3.6",
},
files: {
"agent/agent.ts": SCHEDULE_AGENT_SOURCE,
"agent/instructions.md": "You are a precise assistant.\n",
"agent/schedules/heartbeat.ts": SCHEDULE_SOURCE,
"agent/tools/record-heartbeat.ts": SCHEDULE_TOOL_SOURCE,
},
installDependencies: true,
name: "dev-schedule-dispatch",
};

const DEV_SERVER_SCENARIO_TIMEOUT_MS = 180_000;
const scenarioApp = useScenarioApp();

function stripAnsi(text: string): string {
return text
.split("[")
.map((segment, index) => (index === 0 ? segment : segment.replace(/^[0-9;]*m/, "")))
.join("");
}

function parseServerUrl(stdout: string): string | undefined {
const match = /server listening at (https?:\/\/\S+)/.exec(stripAnsi(stdout));
return match?.[1];
}

async function waitForServerUrl(input: {
readonly child: ChildProcessByStdio<null, Readable, Readable>;
readonly getOutput: () => { readonly stderr: string; readonly stdout: string };
}): Promise<string> {
return await new Promise((resolve, reject) => {
let settled = false;
const timeout = setTimeout(() => {
settleReject(
new Error(
[
"Timed out waiting for eve dev to print its server URL.",
`stdout:\n${input.getOutput().stdout}`,
`stderr:\n${input.getOutput().stderr}`,
].join("\n\n"),
),
);
}, 120_000);

const cleanup = () => {
clearTimeout(timeout);
input.child.stdout.off("data", handleOutput);
input.child.stderr.off("data", handleOutput);
input.child.off("error", settleReject);
input.child.off("exit", handleExit);
};

const settleResolve = (url: string) => {
if (settled) return;
settled = true;
cleanup();
resolve(url);
};

function settleReject(error: unknown) {
if (settled) return;
settled = true;
cleanup();
reject(error);
}

function handleOutput() {
const url = parseServerUrl(input.getOutput().stdout);
if (url !== undefined) {
settleResolve(url);
}
}

function handleExit(code: number | null, signal: NodeJS.Signals | null) {
settleReject(
new Error(
`eve dev exited before printing its server URL (code ${String(code)}, signal ${String(signal)}).`,
),
);
}

input.child.stdout.on("data", handleOutput);
input.child.stderr.on("data", handleOutput);
input.child.once("error", settleReject);
input.child.once("exit", handleExit);
handleOutput();
});
}

interface RunningEveDev {
readonly stderr: () => string;
readonly stdout: () => string;
readonly url: string;
stop(): Promise<void>;
}

async function startEveDev(appRoot: string): Promise<RunningEveDev> {
const eveBinPath = join(appRoot, "node_modules", "eve", "bin", "eve.js");
const child = spawn(
process.execPath,
[eveBinPath, "dev", "--no-ui", "--host", "127.0.0.1", "--port", "0"],
{
cwd: appRoot,
env: {
...process.env,
// Activates the deterministic mock-model adapter so a dispatched
// schedule's turn completes without real model credentials.
NODE_ENV: "test",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
let stderr = "";
let stdout = "";

child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});

const url = await waitForServerUrl({
child,
getOutput: () => ({ stderr, stdout }),
});

return {
stderr: () => stderr,
stdout: () => stdout,
async stop() {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
resolve();
}, 10_000);
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
child.kill("SIGTERM");
});
},
url,
};
}

describe("eve dev schedule dispatch route", () => {
it(
"dispatches a compiled schedule instead of 500ing on the authored module map loader path",
async () => {
const app = await scenarioApp(SCHEDULE_DISPATCH_AGENT_DESCRIPTOR);
const server = await startEveDev(app.appRoot);

try {
const response = await fetch(
new URL(createEveDevDispatchSchedulePath("heartbeat"), server.url),
{ method: "POST" },
);
const responseText = await response.text();

expect(
response.status,
[
"Expected the dev schedule dispatch route to return 200, not 500.",
`response body:\n${responseText}`,
`stdout:\n${server.stdout()}`,
`stderr:\n${server.stderr()}`,
].join("\n\n"),
).toBe(200);

// Regression pin for the exact vercel/eve#311 failure mode.
expect(responseText).not.toContain("authored-module-map-loader");
expect(responseText).not.toContain("Cannot find module");

const body = JSON.parse(responseText) as {
scheduleId: string;
sessionIds: readonly string[];
};
expect(body.scheduleId).toBe("heartbeat");
expect(body.sessionIds.length).toBeGreaterThan(0);
} finally {
await server.stop();
}
},
DEV_SERVER_SCENARIO_TIMEOUT_MS,
);
});