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

Self-hosted `eve start` now registers the workflow queue handler for custom (non-Vercel) worlds, so jobs dispatched by a configured world no longer return `Unhandled queue` or leave runs stuck `pending` — and you no longer need `eve dev --no-ui` to run a local world in production. eve also fails fast at boot with an actionable error when a configured workflow world's `@workflow/*` version is incompatible with the line eve bundles, instead of surfacing a cryptic `ZodError` deep in workflow replay.
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe("createCompiledArtifactsBootstrapSource", () => {

expect(source).toContain('import * as workflowWorldModule from "@acme/eve-world";');
expect(source).toContain(
"await installConfiguredWorkflowWorld({ module: workflowWorldModule });",
'await installConfiguredWorkflowWorld({ module: workflowWorldModule, packageName: "@acme/eve-world" });',
);
});
});
5 changes: 4 additions & 1 deletion packages/eve/src/internal/application/compiled-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,10 @@ function createWorkflowWorldBootstrapBody(
return [];
}

return ["", "await installConfiguredWorkflowWorld({ module: workflowWorldModule });"];
return [
"",
`await installConfiguredWorkflowWorld({ module: workflowWorldModule, packageName: ${JSON.stringify(world)} });`,
];
}

function createInstrumentationPluginSource(input: {
Expand Down
13 changes: 12 additions & 1 deletion packages/eve/src/internal/application/package.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";

import { resolveWorkflowModulePath } from "#internal/application/package.js";
import {
resolveExpectedWorkflowVersion,
resolveWorkflowModulePath,
} from "#internal/application/package.js";

describe("resolveWorkflowModulePath", () => {
it("resolves historical workflow specifiers to narrowed runtime modules", () => {
Expand All @@ -19,3 +22,11 @@ describe("resolveWorkflowModulePath", () => {
);
});
});

describe("resolveExpectedWorkflowVersion", () => {
it("reads the @workflow/core line from eve's own package.json", () => {
// Single source of truth: eve declares the workflow line it bundles in its
// own package.json, so this resolves to a concrete prerelease version.
expect(resolveExpectedWorkflowVersion()).toMatch(/^\d+\.\d+\.\d+/);
});
});
57 changes: 57 additions & 0 deletions packages/eve/src/internal/application/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,63 @@ export function resolveInstalledPackageInfo(): InstalledPackageInfo {
return cachedPackageInfo;
}

const EXPECTED_WORKFLOW_VERSION_PACKAGE = "@workflow/core";

function readWorkflowVersionFromManifest(value: unknown): string | undefined {
const manifest = value as {
dependencies?: Record<string, unknown>;
devDependencies?: Record<string, unknown>;
peerDependencies?: Record<string, unknown>;
};

for (const section of [
manifest.devDependencies,
manifest.dependencies,
manifest.peerDependencies,
]) {
const declared = section?.[EXPECTED_WORKFLOW_VERSION_PACKAGE];

if (typeof declared === "string" && declared.trim().length > 0) {
return declared;
}
}

return undefined;
}

/**
* Resolves the `@workflow/core` version this eve release bundles, read from
* eve's own `package.json`.
*
* This is the single source of truth for the `@workflow/*` line eve targets, so
* compatibility checks (see `assertWorkflowWorldCompatibility`) never hardcode a
* version. eve's `package.json` is published with its `devDependencies` intact
* even though those packages are vendored, so the entry is readable from an
* installed eve as well as a source checkout. Returns `undefined` when the
* entry cannot be read so callers can no-op rather than fail.
*/
export function resolveExpectedWorkflowVersion(): string | undefined {
const packageRoot = tryResolvePackageRoot();

if (packageRoot !== undefined) {
try {
return readWorkflowVersionFromManifest(
JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")),
);
} catch {
// Fall through to module-resolution lookup below.
}
}

try {
return readWorkflowVersionFromManifest(
JSON.parse(readFileSync(require.resolve(`${EVE_PACKAGE_NAME}/package.json`), "utf8")),
);
} catch {
return undefined;
}
}

/**
* Resolves a Workflow runtime module from eve's narrowed Workflow dependencies.
*
Expand Down
7 changes: 6 additions & 1 deletion packages/eve/src/internal/application/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ function getWorkflowBuildCacheKey(appRoot: string): string {
return createHash("sha256").update(appRoot).digest("hex").slice(0, 12);
}

function isVercelBuildEnvironment(): boolean {
/**
* Reports whether the current process is running inside a Vercel build or
* deployment. Vercel sets `VERCEL` in both build and runtime environments, so
* this is the canonical signal for "managed by Vercel" versus self-hosted.
*/
export function isVercelBuildEnvironment(): boolean {
return Boolean(process.env.VERCEL);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ interface PreparedApplicationHostStub {
compileResult: {
manifest: {
channels: [];
config: Record<string, never>;
config: {
experimental?: { workflow?: { world?: string } };
};
};
project: {
agentRoot: string;
Expand Down Expand Up @@ -73,6 +75,13 @@ vi.mock("../../workflow-bundle/builder.js", () => ({
},
}));

// Mock paths.js so the unit test avoids its heavyweight workflow-runtime import
// graph while preserving the real, env-driven `isVercelBuildEnvironment`
// semantics that the direct-handler gate depends on.
vi.mock("../../application/paths.js", () => ({
isVercelBuildEnvironment: () => Boolean(process.env.VERCEL),
}));

const { configureNitroRoutes } = await import("./configure-nitro-routes.js");
const { EVE_HEALTH_ROUTE_PATH, EVE_INFO_ROUTE_PATH } = await import("#protocol/routes.js");

Expand All @@ -99,7 +108,7 @@ function createNitroStub(
}

function createPreparedHost(
input: { appRoot?: string; workflowBuildDir?: string } = {},
input: { appRoot?: string; workflowWorld?: string; workflowBuildDir?: string } = {},
): PreparedApplicationHost {
const appRoot = input.appRoot ?? "G:\\projects\\test-eve";

Expand All @@ -108,7 +117,10 @@ function createPreparedHost(
compileResult: {
manifest: {
channels: [],
config: {},
config:
input.workflowWorld === undefined
? {}
: { experimental: { workflow: { world: input.workflowWorld } } },
},
project: {
agentRoot: `${appRoot}\\agent`,
Expand All @@ -132,6 +144,9 @@ describe("configureNitroRoutes", () => {
fsMocks.mkdir.mockClear();
fsMocks.writeFile.mockClear();
workflowBuilderMocks.build.mockClear();
// The direct-handler gate keys off `process.env.VERCEL`; ensure each test
// starts from a clean, self-hosted (non-Vercel) baseline.
vi.unstubAllEnvs();
});

it("registers package-owned route files through file-url virtual handlers", async () => {
Expand Down Expand Up @@ -273,15 +288,25 @@ describe("configureNitroRoutes", () => {
);
});

it("does not register direct workflow queue handlers in production builds", async () => {
const root = "/tmp/eve-nitro-direct-handlers-prod";
it("does not register direct workflow queue handlers for Vercel production builds", async () => {
vi.stubEnv("VERCEL", "1");

const root = "/tmp/eve-nitro-direct-handlers-vercel";
const buildDir = `${root}/nitro`;
const workflowBuildDir = `${root}/workflow-cache`;
const nitro = createNitroStub({ buildDir, dev: false, rootDir: root });

await configureNitroRoutes(nitro, createPreparedHost({ appRoot: root, workflowBuildDir }), {
surface: "all",
});
await configureNitroRoutes(
nitro,
createPreparedHost({
appRoot: root,
workflowBuildDir,
workflowWorld: "@workflow/world-postgres",
}),
{
surface: "all",
},
);

const workflowHandlerSource = readWriteFileSourceMatching("/workflow/workflows-handler.mjs");

Expand All @@ -292,6 +317,54 @@ describe("configureNitroRoutes", () => {
expect(workflowHandlerSource).not.toContain("__eveGetWorkflowWorld");
expect(readWriteFileSourceMatching("/workflow/steps-handler.mjs")).toBeUndefined();
});

it("registers direct workflow queue handlers for self-hosted production builds with a configured world", async () => {
const root = "/tmp/eve-nitro-direct-handlers-self-hosted";
const buildDir = `${root}/nitro`;
const workflowBuildDir = `${root}/workflow-cache`;
const nitro = createNitroStub({ buildDir, dev: false, rootDir: root });

await configureNitroRoutes(
nitro,
createPreparedHost({
appRoot: root,
workflowBuildDir,
workflowWorld: "@workflow/world-postgres",
}),
{
surface: "all",
},
);

const workflowHandlerSource = readWriteFileSourceMatching("/workflow/workflows-handler.mjs");

expect(workflowHandlerSource).toContain(
'import { POST } from "../../workflow-cache/workflows.mjs";',
);
expect(workflowHandlerSource).toContain(
"const __eveWorkflowWorld = await __eveGetWorkflowWorld();",
);
expect(workflowHandlerSource).toContain(
'__eveWorkflowWorld.registerHandler("__eve_wkf_workflow_", POST);',
);
expect(readWriteFileSourceMatching("/workflow/steps-handler.mjs")).toBeUndefined();
});

it("does not register direct workflow queue handlers for self-hosted production builds without a configured world", async () => {
const root = "/tmp/eve-nitro-direct-handlers-self-hosted-no-world";
const buildDir = `${root}/nitro`;
const workflowBuildDir = `${root}/workflow-cache`;
const nitro = createNitroStub({ buildDir, dev: false, rootDir: root });

await configureNitroRoutes(nitro, createPreparedHost({ appRoot: root, workflowBuildDir }), {
surface: "all",
});

const workflowHandlerSource = readWriteFileSourceMatching("/workflow/workflows-handler.mjs");

expect(workflowHandlerSource).not.toContain("registerHandler");
expect(workflowHandlerSource).not.toContain("__eveGetWorkflowWorld");
});
});

function readWriteFileSourceMatching(suffix: string): string | undefined {
Expand Down
21 changes: 17 additions & 4 deletions packages/eve/src/internal/nitro/host/configure-nitro-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
normalizeEsmImportSpecifier,
stringifyEsmImportSpecifier,
} from "#internal/application/import-specifier.js";
import { isVercelBuildEnvironment } from "#internal/application/paths.js";
import {
resolvePackageRoot,
resolvePackageSourceFilePath,
Expand Down Expand Up @@ -397,11 +398,23 @@ export async function configureNitroRoutes(
: join(preparedHost.workflowBuildDir, "workflows.mjs")
: undefined;

// Direct handler registration is dev-only: it only helps when the local
// workflow queue runs inside the same Nitro dev worker. Production
// deployments dispatch through Vercel's queue trigger.
// Register the direct queue→bundle binding whenever the local/configured
// world drives the queue itself, which is true in `eve dev` AND in
// self-hosted (non-Vercel) production. In both cases the world dispatches
// each job to the matching POST handler in-process, bypassing HTTP loopback
// (see `@workflow/world-local`'s queue dispatch: a registered direct handler
// short-circuits the `WORKFLOW_LOCAL_BASE_URL` fetch path). Vercel-managed
// deploys instead dispatch through Vercel's queue trigger, which calls the
// flow route over HTTP, so we never register the binding there — gating on
// "not a Vercel build" preserves that path exactly. We additionally require a
// configured custom world: without one there is no local world to bind to in
// production, so a binding would be dead weight.
const hasConfiguredWorkflowWorld =
preparedHost.compileResult.manifest.config.experimental?.workflow?.world !== undefined;
const localWorldDrivesQueue =
nitro.options.dev || (!isVercelBuildEnvironment() && hasConfiguredWorkflowWorld);
const directHandlerEntries: WorkflowDirectHandlerEntry[] =
nitro.options.dev && workflowBundlePath !== undefined
localWorldDrivesQueue && workflowBundlePath !== undefined
? [{ bundlePath: workflowBundlePath, queuePrefix: EVE_WORKFLOW_QUEUE_PREFIX }]
: [];
// Generated handlers will JSON-stringify this at write-time, so we hand them
Expand Down
48 changes: 48 additions & 0 deletions packages/eve/src/internal/workflow/configure-world.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";

import type { World } from "#compiled/@workflow/world/index.js";
import { resolveExpectedWorkflowVersion } from "#internal/application/package.js";
import { setWorld } from "#internal/workflow/runtime.js";
import {
assertWorkflowWorldCompatibility,
type WorkflowWorldManifest,
} from "#internal/workflow/world-compatibility.js";

export interface ConfiguredWorkflowWorldModule {
readonly [name: string]: unknown;
Expand All @@ -8,6 +16,12 @@ export interface ConfiguredWorkflowWorldModule {

export interface InstallConfiguredWorkflowWorldInput {
readonly module: ConfiguredWorkflowWorldModule | (() => unknown);
/**
* Package name of the configured world (e.g. `@workflow/world-postgres`),
* derived from the agent manifest's `experimental.workflow.world`. Used to
* resolve the world's `package.json` for the boot-time compatibility check.
*/
readonly packageName?: string;
}

/**
Expand All @@ -16,12 +30,46 @@ export interface InstallConfiguredWorkflowWorldInput {
export async function installConfiguredWorkflowWorld(
input: InstallConfiguredWorkflowWorldInput,
): Promise<World> {
assertConfiguredWorldCompatibility(input.packageName);
const world = await createWorkflowWorld(input);
setWorld(world);
await world.start?.();
return world;
}

/**
* Fails fast at boot when the configured world's declared `@workflow/*` line is
* incompatible with the line this eve release bundles. Best-effort: any failure
* to resolve or read the world's `package.json` is swallowed so we never turn a
* readable-but-unverifiable setup into a boot failure.
*/
function assertConfiguredWorldCompatibility(packageName: string | undefined): void {
if (packageName === undefined) {
return;
}

const expectedWorkflowVersion = resolveExpectedWorkflowVersion();

if (expectedWorkflowVersion === undefined) {
return;
}

let worldManifest: WorkflowWorldManifest;
try {
const require = createRequire(import.meta.url);
const manifestPath = require.resolve(`${packageName}/package.json`);
worldManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as WorkflowWorldManifest;
} catch {
return;
}

assertWorkflowWorldCompatibility({
expectedWorkflowVersion,
worldManifest,
worldPackageName: packageName,
});
}

async function createWorkflowWorld(input: InstallConfiguredWorkflowWorldInput): Promise<World> {
const factory = resolveWorkflowWorldFactory(input);
const world = await factory();
Expand Down
Loading
Loading