diff --git a/.changeset/dev-schedule-dispatch-500.md b/.changeset/dev-schedule-dispatch-500.md new file mode 100644 index 000000000..19402b1f0 --- /dev/null +++ b/.changeset/dev-schedule-dispatch-500.md @@ -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 '/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. diff --git a/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts b/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts index ffe216806..d9634f214 100644 --- a/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts +++ b/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts @@ -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( diff --git a/packages/eve/src/internal/nitro/host/dispatch-schedule-in-dev.ts b/packages/eve/src/internal/nitro/host/dispatch-schedule-in-dev.ts index d5a9cc121..ea4b578bf 100644 --- a/packages/eve/src/internal/nitro/host/dispatch-schedule-in-dev.ts +++ b/packages/eve/src/internal/nitro/host/dispatch-schedule-in-dev.ts @@ -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"; @@ -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 { - 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); @@ -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, diff --git a/packages/eve/src/internal/nitro/routes/dev-schedule-dispatch.test.ts b/packages/eve/src/internal/nitro/routes/dev-schedule-dispatch.test.ts index fe98b0433..bf1e9e2d1 100644 --- a/packages/eve/src/internal/nitro/routes/dev-schedule-dispatch.test.ts +++ b/packages/eve/src/internal/nitro/routes/dev-schedule-dispatch.test.ts @@ -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"); @@ -25,7 +30,7 @@ async function postSchedule(scheduleIdInUrl: string): Promise { 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", () => { @@ -44,7 +49,7 @@ describe("handleDevScheduleDispatchRequest", () => { sessionIds: ["sess-1", "sess-2"], }); expect(mocks.dispatchScheduleInDev).toHaveBeenCalledWith({ - appRoot: APP_ROOT, + artifactsConfig: ARTIFACTS_CONFIG, scheduleId: "heartbeat", }); }); @@ -59,7 +64,7 @@ describe("handleDevScheduleDispatchRequest", () => { expect(response.status).toBe(200); expect(mocks.dispatchScheduleInDev).toHaveBeenCalledWith({ - appRoot: APP_ROOT, + artifactsConfig: ARTIFACTS_CONFIG, scheduleId: "weird/name", }); }); diff --git a/packages/eve/src/internal/nitro/routes/dev-schedule-dispatch.ts b/packages/eve/src/internal/nitro/routes/dev-schedule-dispatch.ts index 320d63ba5..d4e0eb90c 100644 --- a/packages/eve/src/internal/nitro/routes/dev-schedule-dispatch.ts +++ b/packages/eve/src/internal/nitro/routes/dev-schedule-dispatch.ts @@ -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"; /** @@ -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 { const url = new URL(request.url); @@ -52,7 +53,7 @@ export async function handleDevScheduleDispatchRequest( try { const result = await dispatchScheduleInDev({ - appRoot: input.appRoot, + artifactsConfig: input, scheduleId, }); return Response.json({ diff --git a/packages/eve/test/scenarios/dev-schedule-dispatch-route.scenario.test.ts b/packages/eve/test/scenarios/dev-schedule-dispatch-route.scenario.test.ts new file mode 100644 index 000000000..8c17df808 --- /dev/null +++ b/packages/eve/test/scenarios/dev-schedule-dispatch-route.scenario.test.ts @@ -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 '/src/internal/authored-module-map-loader.ts' + * imported from '/.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; + readonly getOutput: () => { readonly stderr: string; readonly stdout: string }; +}): Promise { + 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; +} + +async function startEveDev(appRoot: string): Promise { + 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((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, + ); +});