mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
fix(eve): workflow builds - resolve scoped application imports (#3088)
Signed-off-by: Andrew Barba <barba@hey.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"eve": patch
|
||||
---
|
||||
|
||||
Resolve workflow path aliases from the application config and bundle only workflows reachable from the agent. Unresolved workflow imports now fail the build instead of producing a bundle that crashes every durable session.
|
||||
@@ -93,6 +93,12 @@ tool object, a channel handler, or a schedule handler fails the build. To start
|
||||
channel or schedule, use the [channel operations](/docs/channels/custom#channel-operations-and-session-handles)
|
||||
or [schedule handler](/docs/schedules#handler-form-run) APIs.
|
||||
|
||||
Workflow imports resolve `paths` aliases from your application's `tsconfig.json` or
|
||||
`jsconfig.json`, including when the application is a workspace package. eve includes only
|
||||
workflow and step modules reachable from the agent's runtime modules. Unrelated workflows
|
||||
in the host application stay outside the agent bundle. An unresolved workflow import fails
|
||||
the build with the missing import in the error.
|
||||
|
||||
### Migrate an existing workflow tool
|
||||
|
||||
Replace `defineTool` with `defineWorkflowTool`, keep the executor's `"use workflow"` directive,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { WorkflowToolContext } from "eve/tools";
|
||||
import { sleep } from "workflow";
|
||||
|
||||
import { describePlan, hashPlan } from "./plan.ts";
|
||||
import { describePlan, hashPlan } from "@/agent/lib/plan.ts";
|
||||
|
||||
export async function deployService({ service }: { service: string }, ctx: WorkflowToolContext) {
|
||||
"use workflow";
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
"declaration": false,
|
||||
"noEmit": true,
|
||||
"types": ["node", "eve/workflow-modules"],
|
||||
"allowImportingTsExtensions": true
|
||||
"allowImportingTsExtensions": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["agent/**/*.ts", "evals/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { copyFile, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { AuthoredWorkflowModules } from "#internal/workflow-bundle/builder-support.js";
|
||||
import type { CompileMetadata } from "#compiler/artifacts.js";
|
||||
import type { CompileAgentResult } from "#compiler/compile-agent.js";
|
||||
import { stringifyEsmImportSpecifier } from "#internal/application/import-specifier.js";
|
||||
@@ -41,6 +42,7 @@ type InstrumentationPluginLayout =
|
||||
* vendored workflow bundles for one application.
|
||||
*/
|
||||
export interface GeneratedCompiledArtifactsFiles {
|
||||
readonly authoredWorkflowModules?: AuthoredWorkflowModules;
|
||||
/**
|
||||
* Shared bundled-artifacts bootstrap installed by Nitro and vendored
|
||||
* workflow handlers.
|
||||
@@ -105,6 +107,7 @@ export async function writeCompiledArtifactsFiles(input: {
|
||||
);
|
||||
|
||||
const generatedArtifacts: GeneratedCompiledArtifactsFiles = {
|
||||
authoredWorkflowModules: prepared.authoredWorkflowModules,
|
||||
bootstrapPath,
|
||||
workflowWorldPluginPath,
|
||||
};
|
||||
@@ -167,6 +170,7 @@ function instrumentationSourcePathsOf(layout: InstrumentationLayout): readonly s
|
||||
// bootstrap references no authored module, and the instrumentation bundle is
|
||||
// copied out of the generation into the stable host directory.
|
||||
export async function writeDevelopmentCompiledArtifactsFiles(input: {
|
||||
readonly authoredWorkflowModules?: AuthoredWorkflowModules;
|
||||
readonly compileResult: CompileAgentResult;
|
||||
readonly outDir: string;
|
||||
readonly runtimeAppRoot: string;
|
||||
@@ -194,6 +198,7 @@ export async function writeDevelopmentCompiledArtifactsFiles(input: {
|
||||
);
|
||||
|
||||
const generatedArtifacts: GeneratedCompiledArtifactsFiles = {
|
||||
authoredWorkflowModules: input.authoredWorkflowModules,
|
||||
bootstrapPath,
|
||||
workflowWorldPluginPath,
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
buildWithNitroRolldown,
|
||||
} from "#internal/bundler/nitro-rolldown.js";
|
||||
import { createNodeEsmCompatBannerPlugin } from "#internal/node-esm-compat-banner.js";
|
||||
import type { AuthoredWorkflowModules } from "#internal/workflow-bundle/builder-support.js";
|
||||
import { prepareAuthoredWorkflowDirectives } from "#internal/workflow-bundle/authored-workflow-directives.js";
|
||||
import { createDynamicCapabilityTransformPlugin } from "#internal/workflow-bundle/dynamic-capability-transform-plugin.js";
|
||||
import {
|
||||
@@ -263,6 +264,7 @@ export async function bundleExtensionDistributionGraph(input: {
|
||||
* entry.
|
||||
*/
|
||||
export interface AuthoredModuleMapBundle {
|
||||
readonly authoredWorkflowModules: AuthoredWorkflowModules;
|
||||
readonly code: string;
|
||||
/** Fingerprint of the sources that also feed the driver and step registry; a change rebuilds the host. */
|
||||
readonly workflowSourceFingerprint: string | undefined;
|
||||
@@ -340,6 +342,7 @@ export async function bundleAuthoredModuleMapForGeneration(input: {
|
||||
},
|
||||
});
|
||||
return {
|
||||
authoredWorkflowModules: workflowSources.modules(),
|
||||
code: removeRolldownModuleRegionComments(chunk.code),
|
||||
workflowSourceFingerprint: workflowSources.fingerprint(),
|
||||
};
|
||||
@@ -399,6 +402,13 @@ class AuthoredWorkflowSourceRecorder {
|
||||
};
|
||||
}
|
||||
|
||||
modules(): AuthoredWorkflowModules {
|
||||
return {
|
||||
directiveModules: [...this.#directiveModules].sort(),
|
||||
workflowModules: [...this.#workflowFunctions.keys()].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
fingerprint(): string | undefined {
|
||||
if (this.#directiveModules.size === 0) return undefined;
|
||||
|
||||
@@ -637,7 +647,7 @@ function createInFlightModuleLoadKey(
|
||||
return `${modulePath}\0${externalDependencies.join("\0")}\0${options.extensionScopeNamespace ?? ""}`;
|
||||
}
|
||||
|
||||
function resolveAuthoredTsConfigPath(packageRoot: string): string | false {
|
||||
export function resolveAuthoredTsConfigPath(packageRoot: string): string | false {
|
||||
for (const fileName of ["tsconfig.json", "jsconfig.json"]) {
|
||||
const path = join(packageRoot, fileName);
|
||||
if (existsSync(path)) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AuthoredWorkflowModules } from "#internal/workflow-bundle/builder-support.js";
|
||||
import type { CompiledAgentManifest } from "#compiler/manifest.js";
|
||||
import {
|
||||
bundleAuthoredModuleForGeneration,
|
||||
@@ -11,6 +12,7 @@ interface PreparedAuthoredRuntimeInstrumentation {
|
||||
}
|
||||
|
||||
export interface PreparedAuthoredRuntimeModules {
|
||||
readonly authoredWorkflowModules: AuthoredWorkflowModules;
|
||||
readonly instrumentation?: PreparedAuthoredRuntimeInstrumentation;
|
||||
readonly moduleMapCode: string;
|
||||
/** Identity of authored sources shared by the workflow driver and step registrations. */
|
||||
@@ -22,8 +24,11 @@ export async function prepareAuthoredRuntimeModules(input: {
|
||||
readonly manifest: CompiledAgentManifest;
|
||||
readonly moduleMapPath: string;
|
||||
}): Promise<PreparedAuthoredRuntimeModules> {
|
||||
const { code: moduleMapCode, workflowSourceFingerprint } =
|
||||
await bundleAuthoredModuleMapForGeneration(input);
|
||||
const {
|
||||
authoredWorkflowModules,
|
||||
code: moduleMapCode,
|
||||
workflowSourceFingerprint,
|
||||
} = await bundleAuthoredModuleMapForGeneration(input);
|
||||
const providersEnabled = input.manifest.config.experimental?.instrumentationProviders ?? false;
|
||||
const layout = providersEnabled
|
||||
? resolveInstrumentationLayout({ agentRoot: input.manifest.agentRoot, providersEnabled: true })
|
||||
@@ -42,6 +47,6 @@ export async function prepareAuthoredRuntimeModules(input: {
|
||||
}
|
||||
|
||||
return instrumentation === undefined
|
||||
? { moduleMapCode, workflowSourceFingerprint }
|
||||
: { instrumentation, moduleMapCode, workflowSourceFingerprint };
|
||||
? { authoredWorkflowModules, moduleMapCode, workflowSourceFingerprint }
|
||||
: { authoredWorkflowModules, instrumentation, moduleMapCode, workflowSourceFingerprint };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
|
||||
import type { AuthoredWorkflowModules } from "#internal/workflow-bundle/builder-support.js";
|
||||
import type { CompileAgentResult } from "#compiler/compile-agent.js";
|
||||
import { prepareAuthoredRuntimeModules } from "#internal/authored-runtime-modules.js";
|
||||
import { writeMaterializedAuthoredModules } from "#internal/materialized-authored-modules.js";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "#internal/nitro/dev-runtime-artifacts.js";
|
||||
|
||||
export interface DevelopmentGeneration extends DevelopmentRuntimeArtifactsSnapshot {
|
||||
readonly authoredWorkflowModules?: AuthoredWorkflowModules;
|
||||
readonly fingerprint: string;
|
||||
/** Identity of the authored sources the workflow driver and step registrations are built from. */
|
||||
readonly workflowSourceFingerprint?: string;
|
||||
@@ -40,9 +42,14 @@ export async function stageDevelopmentGeneration(
|
||||
});
|
||||
|
||||
return prepared.workflowSourceFingerprint === undefined
|
||||
? { ...snapshot, fingerprint: materialized.fingerprint }
|
||||
? {
|
||||
...snapshot,
|
||||
authoredWorkflowModules: prepared.authoredWorkflowModules,
|
||||
fingerprint: materialized.fingerprint,
|
||||
}
|
||||
: {
|
||||
...snapshot,
|
||||
authoredWorkflowModules: prepared.authoredWorkflowModules,
|
||||
fingerprint: materialized.fingerprint,
|
||||
workflowSourceFingerprint: prepared.workflowSourceFingerprint,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { readFile, realpath } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createApplicationBuildWorkspace,
|
||||
removeApplicationBuildWorkspace,
|
||||
} from "#internal/application/build-workspace.js";
|
||||
import { resolvePackageRoot } from "#internal/application/package.js";
|
||||
import { useScenarioApp } from "#internal/testing/scenario-app.js";
|
||||
import { WorkflowBundleBuilder } from "#internal/workflow-bundle/builder.js";
|
||||
import { buildApplication } from "./build-application.js";
|
||||
import { startProductionServer } from "./start-production-server.js";
|
||||
import { prepareProductionApplicationHost } from "./prepare-application-host.js";
|
||||
|
||||
const ALIAS_MARKER = "workflow-alias-resolved";
|
||||
|
||||
function workflowCode(source: string): string {
|
||||
const match = source.match(
|
||||
/Buffer\.from\((\[[\s\S]*?\])\.join\(""\), "base64"\)\.toString\("utf8"\)/,
|
||||
);
|
||||
return Buffer.from((JSON.parse(match?.[1] ?? "[]") as string[]).join(""), "base64").toString(
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
describe("authored workflow scope", () => {
|
||||
const scenarioApp = useScenarioApp();
|
||||
|
||||
it.each(["tsconfig.json", "jsconfig.json"])(
|
||||
"resolves workflow aliases from the application's %s",
|
||||
async (configFile) => {
|
||||
const app = await scenarioApp({
|
||||
name: "workflow-app-alias",
|
||||
installDependencies: true,
|
||||
files: {
|
||||
[configFile]: JSON.stringify({
|
||||
compilerOptions: { baseUrl: ".", paths: { "@/*": ["./*"] } },
|
||||
}),
|
||||
"agent/agent.ts": 'export default { model: "openai/gpt-5.4" };',
|
||||
"agent/instructions.md": "Call the probe tool.",
|
||||
"agent/tools/probe.ts":
|
||||
'import { defineWorkflowTool } from "eve/tools"; import { describe } from "@/lib/describe"; export default defineWorkflowTool({ description: "Probe", inputSchema: {}, async execute() { "use workflow"; return describe(); } });',
|
||||
"lib/describe.ts": `export function describe() { return ${JSON.stringify(ALIAS_MARKER)}; }`,
|
||||
},
|
||||
});
|
||||
const appRoot = await realpath(app.appRoot);
|
||||
const workspace = await createApplicationBuildWorkspace(appRoot);
|
||||
try {
|
||||
const host = await prepareProductionApplicationHost(workspace);
|
||||
const builder = new WorkflowBundleBuilder({
|
||||
agentName: host.compileResult.manifest.config.name,
|
||||
appRoot,
|
||||
compiledArtifactsBootstrapPath: host.compiledArtifacts.bootstrapPath,
|
||||
outDir: workspace.workflow.buildDir,
|
||||
rootDir: resolvePackageRoot(),
|
||||
watch: false,
|
||||
authoredWorkflowModules: host.compiledArtifacts.authoredWorkflowModules,
|
||||
});
|
||||
await builder.build();
|
||||
const code = workflowCode(
|
||||
await readFile(join(workspace.workflow.buildDir, "workflows.mjs"), "utf8"),
|
||||
);
|
||||
expect(code).toContain(ALIAS_MARKER);
|
||||
expect(code).not.toContain('require("@/lib/describe")');
|
||||
await buildApplication(appRoot, { skipVercelSandboxPrewarm: false });
|
||||
const server = await startProductionServer(appRoot, { port: 0, host: "127.0.0.1" });
|
||||
try {
|
||||
expect((await fetch(new URL("/eve/v1/health", server.url))).status).toBe(200);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
} finally {
|
||||
await removeApplicationBuildWorkspace(workspace);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("excludes host workflows while retaining reachable workflow step helpers", async () => {
|
||||
const app = await scenarioApp({
|
||||
name: "workflow-agent-scope",
|
||||
installDependencies: true,
|
||||
files: {
|
||||
"agent/agent.ts": 'export default { model: "openai/gpt-5.4" };',
|
||||
"agent/instructions.md": "Call the probe tool.",
|
||||
"agent/tools/probe.ts":
|
||||
'import { defineWorkflowTool } from "eve/tools"; import { run } from "../../lib/run"; export default defineWorkflowTool({ description: "Probe", inputSchema: {}, execute: run });',
|
||||
"lib/run.ts":
|
||||
'import { readMarker } from "./step"; export async function run() { "use workflow"; return readMarker(); }',
|
||||
"lib/step.ts":
|
||||
'import { hostname } from "node:os"; export async function readMarker() { "use step"; return hostname(); }',
|
||||
"components/layout.js":
|
||||
"export default function Layout() { return <div>Host application</div>; }",
|
||||
"workflows/host.ts":
|
||||
'import { readFileSync } from "node:fs"; export async function unrelatedHostWorkflow() { "use workflow"; return readFileSync("host.txt", "utf8"); }',
|
||||
},
|
||||
});
|
||||
const appRoot = await realpath(app.appRoot);
|
||||
const workspace = await createApplicationBuildWorkspace(appRoot);
|
||||
try {
|
||||
const host = await prepareProductionApplicationHost(workspace);
|
||||
const builder = new WorkflowBundleBuilder({
|
||||
agentName: host.compileResult.manifest.config.name,
|
||||
appRoot,
|
||||
compiledArtifactsBootstrapPath: host.compiledArtifacts.bootstrapPath,
|
||||
outDir: workspace.workflow.buildDir,
|
||||
rootDir: resolvePackageRoot(),
|
||||
watch: false,
|
||||
authoredWorkflowModules: host.compiledArtifacts.authoredWorkflowModules,
|
||||
});
|
||||
await builder.build();
|
||||
const steps = await readFile(join(workspace.workflow.buildDir, "steps.mjs"), "utf8");
|
||||
const code = workflowCode(
|
||||
await readFile(join(workspace.workflow.buildDir, "workflows.mjs"), "utf8"),
|
||||
);
|
||||
expect(steps).toContain("lib/step.ts");
|
||||
expect(code).toContain("workflow//./lib/run//run");
|
||||
expect(code).toContain("step//./lib/step//readMarker");
|
||||
expect(code).not.toContain("unrelatedHostWorkflow");
|
||||
expect(steps).not.toContain("workflows/host.ts");
|
||||
} finally {
|
||||
await removeApplicationBuildWorkspace(workspace);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -344,6 +344,7 @@ export async function configureDevelopmentNitroRoutes(
|
||||
): Promise<void> {
|
||||
const workflowBuildDirectory = resolveNitroWorkflowBuildDirectory(nitro);
|
||||
const builder = new WorkflowBundleBuilder({
|
||||
authoredWorkflowModules: preparedHost.compiledArtifacts.authoredWorkflowModules,
|
||||
agentName: preparedHost.compileResult.manifest.config.name,
|
||||
appRoot: preparedHost.appRoot,
|
||||
compiledArtifactsBootstrapPath: preparedHost.compiledArtifacts.bootstrapPath,
|
||||
@@ -398,6 +399,7 @@ export async function configureProductionNitroRoutes(
|
||||
preparedHost: PreparedApplicationHost,
|
||||
): Promise<void> {
|
||||
const builder = new WorkflowBundleBuilder({
|
||||
authoredWorkflowModules: preparedHost.compiledArtifacts.authoredWorkflowModules,
|
||||
agentName: preparedHost.compileResult.manifest.config.name,
|
||||
appRoot: preparedHost.appRoot,
|
||||
compiledArtifactsBootstrapPath: preparedHost.compiledArtifacts.bootstrapPath,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { resolveAuthoredTsConfigPath } from "#internal/authored-module-loader.js";
|
||||
import { createNitro } from "nitro/builder";
|
||||
import type { Nitro } from "nitro/types";
|
||||
import { EVE_PACKAGE_NAME } from "#internal/package-name.js";
|
||||
@@ -637,7 +638,10 @@ function createApplicationNitroBundlerConfiguration(
|
||||
createExtensionExternalDependencyPlugin(extensionMounts),
|
||||
extensionScopePlugin,
|
||||
].filter((plugin) => plugin !== null);
|
||||
const nitroRolldownConfig = createNitroBundlerConfig(nitroBundlerPlugins);
|
||||
const nitroRolldownConfig = {
|
||||
...createNitroBundlerConfig(nitroBundlerPlugins),
|
||||
tsconfig: resolveAuthoredTsConfigPath(preparedHost.appRoot),
|
||||
};
|
||||
const nitroRollupConfig = createNitroBundlerConfig(nitroBundlerPlugins);
|
||||
const tracedAppDependencies = collectHostedTraceDependencies(
|
||||
preparedHost,
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function prepareDevelopmentApplicationHost(
|
||||
const schedules = await resolveSchedules({ manifest: compileResult.manifest });
|
||||
generation = await stageDevelopmentGeneration(compileResult);
|
||||
const compiledArtifacts = await writeDevelopmentCompiledArtifactsFiles({
|
||||
authoredWorkflowModules: generation.authoredWorkflowModules,
|
||||
compileResult,
|
||||
outDir: workspace.artifactsDir,
|
||||
runtimeAppRoot: generation.runtimeAppRoot,
|
||||
|
||||
-149
@@ -1,149 +0,0 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { discoverAuthoredWorkflowModules } from "./authored-workflow-modules.js";
|
||||
|
||||
let appRoot: string;
|
||||
|
||||
async function write(relativePath: string, source: string): Promise<string> {
|
||||
const filePath = join(appRoot, relativePath);
|
||||
await mkdir(join(filePath, ".."), { recursive: true });
|
||||
await writeFile(filePath, source);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
appRoot = await mkdtemp(join(tmpdir(), "eve-authored-workflows-"));
|
||||
await write("package.json", JSON.stringify({ name: "fixture-app", type: "module" }));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(appRoot, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
describe("discoverAuthoredWorkflowModules", () => {
|
||||
it("separates workflow modules from step-only modules and skips the rest", async () => {
|
||||
const tool = await write(
|
||||
"agent/tools/deploy.ts",
|
||||
`import { defineWorkflowTool } from "eve/tools";
|
||||
import { plan } from "../lib/plan.ts";
|
||||
export default defineWorkflowTool({
|
||||
description: "d",
|
||||
inputSchema: {},
|
||||
async execute(input) {
|
||||
"use workflow";
|
||||
return plan(input);
|
||||
},
|
||||
});`,
|
||||
);
|
||||
const helper = await write(
|
||||
"agent/lib/plan.ts",
|
||||
`export async function plan(input) {
|
||||
"use step";
|
||||
return input;
|
||||
}`,
|
||||
);
|
||||
await write(
|
||||
"agent/tools/plain.ts",
|
||||
`export default { description: "p", async execute() { return 1; } };`,
|
||||
);
|
||||
await write(
|
||||
"agent/lib/quoted.ts",
|
||||
`export const text = '"use workflow" is only a directive as a statement';`,
|
||||
);
|
||||
await write("node_modules/dep/index.js", `export async function f() { "use workflow"; }`);
|
||||
await write(
|
||||
".output.eve-backup-crashed/server/_libs/eve+zod.mjs",
|
||||
`const step = async function () { "use step"; };\nexport { step };`,
|
||||
);
|
||||
await write(
|
||||
".eve/builds/x/output/server/index.mjs",
|
||||
`export async function f() { "use step"; }`,
|
||||
);
|
||||
await write(
|
||||
"src/components/banner.js",
|
||||
`// "use step" helpers live in ../lib\nexport function Banner() { return <b>hi</b>; }`,
|
||||
);
|
||||
await write(
|
||||
".well-known/workflow/v1/flow.js",
|
||||
`export async function generated() {\n "use workflow";\n}`,
|
||||
);
|
||||
await write("dist/out.js", `export async function f() { "use step"; }`);
|
||||
await write("agent/README.md", `"use workflow"`);
|
||||
|
||||
await expect(discoverAuthoredWorkflowModules(appRoot)).resolves.toEqual({
|
||||
directiveModules: [helper, tool],
|
||||
workflowModules: [tool],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves modules without a directive unparsed, so JSX in plain .js never fails the scan", async () => {
|
||||
await write(
|
||||
"src/app/layout.js",
|
||||
`export default function RootLayout({ children }) {
|
||||
return <html><body><code>defineWorkflowTool is only text here</code>{children}</body></html>;
|
||||
}`,
|
||||
);
|
||||
const tool = await write(
|
||||
"agent/tools/deploy.ts",
|
||||
`import { defineWorkflowTool } from "eve/tools";
|
||||
export default defineWorkflowTool({
|
||||
description: "d",
|
||||
async execute() {
|
||||
"use workflow";
|
||||
return 1;
|
||||
},
|
||||
});`,
|
||||
);
|
||||
|
||||
await expect(discoverAuthoredWorkflowModules(appRoot)).resolves.toEqual({
|
||||
directiveModules: [tool],
|
||||
workflowModules: [tool],
|
||||
});
|
||||
});
|
||||
|
||||
it("finds nothing without an application package.json", async () => {
|
||||
await rm(join(appRoot, "package.json"));
|
||||
await write("agent/tools/deploy.ts", `export async function run() { "use workflow"; }`);
|
||||
|
||||
await expect(discoverAuthoredWorkflowModules(appRoot)).resolves.toEqual({
|
||||
directiveModules: [],
|
||||
workflowModules: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("discovers directives on the same line as their function declaration", async () => {
|
||||
const tool = await write(
|
||||
"agent/tools/inline.ts",
|
||||
`import { defineWorkflowTool } from "eve/tools";
|
||||
export default defineWorkflowTool({ async execute() { "use workflow"; return plan(); } });`,
|
||||
);
|
||||
const helper = await write(
|
||||
"agent/lib/plan.ts",
|
||||
`export async function plan() { "use step"; return 1; }`,
|
||||
);
|
||||
await expect(discoverAuthoredWorkflowModules(appRoot)).resolves.toEqual({
|
||||
directiveModules: [helper, tool],
|
||||
workflowModules: [tool],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an invalid directive placement as a build error", async () => {
|
||||
await write(
|
||||
"agent/tools/bad.ts",
|
||||
`export default {
|
||||
execute() {
|
||||
const inner = async () => {
|
||||
"use workflow";
|
||||
};
|
||||
return inner();
|
||||
},
|
||||
};`,
|
||||
);
|
||||
|
||||
await expect(discoverAuthoredWorkflowModules(appRoot)).rejects.toThrow(/use workflow/);
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { isGeneratedWorkflowFile } from "#compiled/@workflow/builders/index.js";
|
||||
|
||||
import { prepareAuthoredWorkflowDirectives } from "./authored-workflow-directives.js";
|
||||
import { isWorkflowSourceFile } from "./builder-support.js";
|
||||
import { isAuthoredApplicationModule, isAuthoredApplicationRoot } from "./workflow-builders.js";
|
||||
|
||||
// The SDK's own ignore list (`BaseBuilder.getInputFiles`) plus eve's generated locations.
|
||||
const IGNORED_DIRECTORIES = new Set([
|
||||
".cache",
|
||||
".eve",
|
||||
".git",
|
||||
".next",
|
||||
".nitro",
|
||||
".nuxt",
|
||||
".output",
|
||||
".pnpm-store",
|
||||
".svelte-kit",
|
||||
".swc",
|
||||
".turbo",
|
||||
".vercel",
|
||||
".workflow-data",
|
||||
".workflow-vitest",
|
||||
".yarn",
|
||||
"coverage",
|
||||
"dist",
|
||||
"node_modules",
|
||||
]);
|
||||
|
||||
function isIgnoredDirectory(name: string): boolean {
|
||||
return IGNORED_DIRECTORIES.has(name) || name.startsWith(".output.");
|
||||
}
|
||||
|
||||
export interface AuthoredWorkflowModules {
|
||||
readonly directiveModules: readonly string[];
|
||||
readonly workflowModules: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the whole application root, as the SDK's bundler integrations do, so a
|
||||
* step helper can live wherever the tool imports it from.
|
||||
*/
|
||||
export async function discoverAuthoredWorkflowModules(
|
||||
appRoot: string,
|
||||
): Promise<AuthoredWorkflowModules> {
|
||||
const directiveModules: string[] = [];
|
||||
const workflowModules: string[] = [];
|
||||
if (!isAuthoredApplicationRoot(appRoot)) return { directiveModules, workflowModules };
|
||||
|
||||
const files = await collectSourceFiles(appRoot);
|
||||
for (const filePath of files.sort()) {
|
||||
if (!isAuthoredApplicationModule(filePath, appRoot) || isGeneratedWorkflowFile(filePath))
|
||||
continue;
|
||||
const source = await readFile(filePath, "utf8");
|
||||
if (!source.includes("use workflow") && !source.includes("use step")) continue;
|
||||
const prepared = await prepareAuthoredWorkflowDirectives({ filePath, source });
|
||||
if (!prepared.hasDirectives) continue;
|
||||
directiveModules.push(filePath);
|
||||
if (prepared.hasWorkflowDirective) workflowModules.push(filePath);
|
||||
}
|
||||
|
||||
return { directiveModules, workflowModules };
|
||||
}
|
||||
|
||||
async function collectSourceFiles(root: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
|
||||
async function visit(directory: string): Promise<void> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!isIgnoredDirectory(entry.name)) await visit(entryPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
if (isWorkflowSourceFile(entry.name)) files.push(entryPath);
|
||||
}
|
||||
}
|
||||
|
||||
await visit(root);
|
||||
return files;
|
||||
}
|
||||
@@ -18,7 +18,13 @@ import { WORKFLOW_STEP_EXTERNAL_PACKAGES } from "#internal/workflow-bundle/verce
|
||||
|
||||
export const WORKFLOW_VIRTUAL_ENTRY_ID = "\0eve-workflow-entry";
|
||||
|
||||
export interface AuthoredWorkflowModules {
|
||||
readonly directiveModules: readonly string[];
|
||||
readonly workflowModules: readonly string[];
|
||||
}
|
||||
|
||||
export interface WorkflowBundleBuilderOptions {
|
||||
readonly authoredWorkflowModules?: AuthoredWorkflowModules;
|
||||
agentName: string;
|
||||
appRoot: string;
|
||||
compiledArtifactsBootstrapPath: string;
|
||||
|
||||
@@ -834,6 +834,10 @@ describe("WorkflowBundleBuilder", () => {
|
||||
|
||||
const builder = new FixtureWorkflowBundleBuilder(
|
||||
{
|
||||
authoredWorkflowModules: {
|
||||
directiveModules: [toolPath, stepsPath, join(appRoot, "agent", "lib", "run.ts")],
|
||||
workflowModules: [toolPath, join(appRoot, "agent", "lib", "run.ts")],
|
||||
},
|
||||
agentName: "test-agent",
|
||||
appRoot,
|
||||
compiledArtifactsBootstrapPath,
|
||||
@@ -868,4 +872,28 @@ describe("WorkflowBundleBuilder", () => {
|
||||
await rm(tempRoot, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
it("rejects unresolved workflow imports before emitting a VM bundle", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "eve-workflow-missing-import-"));
|
||||
const flow = join(root, "flow.ts");
|
||||
try {
|
||||
await writeFile(
|
||||
flow,
|
||||
'import { value } from "missing-workflow-package"; export async function flow() { "use workflow"; return value; }',
|
||||
);
|
||||
const builder = new FixtureWorkflowBundleBuilder(
|
||||
{
|
||||
agentName: "missing-import",
|
||||
appRoot: root,
|
||||
rootDir: root,
|
||||
outDir: join(root, "out"),
|
||||
compiledArtifactsBootstrapPath: join(root, "bootstrap.mjs"),
|
||||
watch: false,
|
||||
},
|
||||
[flow],
|
||||
);
|
||||
await expect(builder.build()).rejects.toThrow("missing-workflow-package");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,9 +14,6 @@ import { createAuthoredPackageTsConfigPathsPlugin } from "#internal/authored-pac
|
||||
import { createAuthoredRelativeExtensionResolverPlugin } from "#internal/authored-relative-extension-resolver.js";
|
||||
import {
|
||||
type AuthoredWorkflowModules,
|
||||
discoverAuthoredWorkflowModules,
|
||||
} from "#internal/workflow-bundle/authored-workflow-modules.js";
|
||||
import {
|
||||
bundleFinalWorkflowOutput,
|
||||
collectWorkflowInputFiles,
|
||||
composeWorkflowDriverCode,
|
||||
@@ -54,6 +51,7 @@ import {
|
||||
import { deriveEveWorkflowQueueNamespace } from "#internal/workflow/queue-namespace.js";
|
||||
|
||||
export class WorkflowBundleBuilder {
|
||||
readonly #authoredWorkflowModules: AuthoredWorkflowModules;
|
||||
readonly #compiledArtifactsBootstrapPath: string;
|
||||
readonly #outDir: string;
|
||||
readonly #queueNamespace: string;
|
||||
@@ -78,6 +76,10 @@ export class WorkflowBundleBuilder {
|
||||
workingDir: options.rootDir,
|
||||
};
|
||||
|
||||
this.#authoredWorkflowModules = options.authoredWorkflowModules ?? {
|
||||
directiveModules: [],
|
||||
workflowModules: [],
|
||||
};
|
||||
this.#compiledArtifactsBootstrapPath = options.compiledArtifactsBootstrapPath;
|
||||
this.#outDir = options.outDir;
|
||||
this.#queueNamespace = deriveEveWorkflowQueueNamespace(options.agentName);
|
||||
@@ -107,7 +109,7 @@ export class WorkflowBundleBuilder {
|
||||
|
||||
await mkdir(this.#outDir, { recursive: true });
|
||||
const frameworkEntries = await this.discoverEntries(frameworkInputFiles);
|
||||
const appEntries = await discoverAuthoredWorkflowModules(this.transformProjectRoot);
|
||||
const appEntries = this.#authoredWorkflowModules;
|
||||
const stepEntries = mergeStepEntries(frameworkEntries, appEntries);
|
||||
|
||||
const stepsOutfile = join(this.#outDir, "steps.mjs");
|
||||
@@ -172,7 +174,7 @@ export class WorkflowBundleBuilder {
|
||||
}
|
||||
|
||||
protected async findTsConfigPath(): Promise<string | undefined> {
|
||||
let current = this.config.workingDir;
|
||||
let current = this.transformProjectRoot;
|
||||
|
||||
while (true) {
|
||||
for (const filename of ["tsconfig.json", "jsconfig.json"]) {
|
||||
@@ -290,6 +292,12 @@ export class WorkflowBundleBuilder {
|
||||
].join("\n");
|
||||
const interimBundle = await buildSingleRolldownChunk(`${options.label} workflow driver chunk`, {
|
||||
cwd: this.config.workingDir,
|
||||
onwarn(warning: { code: string; message: string }, warn: (warning: unknown) => void) {
|
||||
if (warning.code === "UNRESOLVED_IMPORT") {
|
||||
throw new Error(`Cannot build workflow bundle: ${warning.message}`);
|
||||
}
|
||||
warn(warning);
|
||||
},
|
||||
input: WORKFLOW_VIRTUAL_ENTRY_ID,
|
||||
platform: "neutral",
|
||||
plugins: [
|
||||
|
||||
Reference in New Issue
Block a user