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/declarative-agent-collections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Deploy strict, single-package `agents/<name>/agent/` workspaces as declaratively inferred peer Vercel services, with no generated `vercel.json` step.
22 changes: 22 additions & 0 deletions docs/guides/deployment/vercel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,28 @@ See [Sandbox](../../sandbox) for resource limits, network policy, and lifecycle

## Deploy the agent

A project containing only several independently addressed root agents can use eve's hostless workspace layout:

```json title="package.json"
{
"eve": { "agents": ["agents/*"] }
}
```

```text
my-project/
├── package.json
└── agents/
├── support/
│ └── agent/
└── research/
└── agent/
```

The `eve.agents` declaration marks the project as a workspace. eve then validates and discovers only direct `agents/<name>/agent/` children. Their directory names become their public identities, exposed at `/eve/agents/<name>/eve/v1/*`. Run `eve dev`, `eve info`, and `eve eval` from an individual child directory; run project-level build, link, and deploy commands from the workspace root. The workspace root is the project package; child agent directories do not define packages or build scripts.

With no authored `vercel.json#services`, `eve build` derives the complete Vercel Services graph on every build. If `vercel.json` declares `services`, that authored graph is authoritative instead; use `vercel build` to build and validate the complete project. This supports heterogeneous projects with frontends, private APIs, bindings, and other non-eve services without generated configuration files.

Deploy the linked project to production:

```bash
Expand Down
20 changes: 20 additions & 0 deletions packages/eve/src/cli/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { applicationCommand, type CliApplicationContext } from "#cli/application
import { resolveInternalVercelServiceOutput } from "#cli/vercel-service-output.js";
import { createCliTheme, renderCliTaggedLine } from "#cli/ui/output.js";
import type { ApplicationBuildOptions } from "#internal/nitro/host/types.js";
import { resolveEveProjectContext } from "#internal/project-context.js";
import {
EVE_PUBLIC_ROUTE_PREFIX_ENV,
normalizePublicRoutePrefix,
Expand Down Expand Up @@ -42,6 +43,25 @@ export function registerBuildCommand(input: {

await loadDevelopmentEnvironmentFiles(input.applicationContext.root);

const projectContext = await resolveEveProjectContext(input.applicationContext.root);
if (projectContext.kind === "workspace") {
if (options.profile !== undefined || options.skipSandboxPrewarm === true) {
throw new Error(
"Workspace builds do not support --profile or --skip-sandbox-prewarm. Run those options from an individual agent directory.",
);
}
const { buildAgentWorkspace } = await import("#internal/vercel/build-agent-workspace.js");
const outputDir = await buildAgentWorkspace(projectContext.workspace);
input.logger.log(
renderCliTaggedLine(theme, {
message: `built output at ${outputDir}`,
tag: "build",
tone: "success",
}),
);
return;
}

const buildHost =
input.buildHost ?? (await import("#internal/nitro/host.js")).buildApplication;
const profileOutputPath =
Expand Down
24 changes: 24 additions & 0 deletions packages/eve/src/cli/commands/deploy.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ class TestLogger implements DeployCliLogger {
}
}

async function createWorkspaceProject(): Promise<string> {
const projectRoot = await mkdtemp(join(tmpdir(), "eve-deploy-workspace-"));
await mkdir(join(projectRoot, "agents/support/agent"), { recursive: true });
await writeFile(
join(projectRoot, "package.json"),
JSON.stringify({ eve: { agents: ["agents/*"] }, private: true }),
"utf8",
);
return projectRoot;
}

async function createAgentProject(): Promise<string> {
const projectRoot = await mkdtemp(join(tmpdir(), "eve-deploy-command-"));
await mkdir(join(projectRoot, "agent"), { recursive: true });
Expand Down Expand Up @@ -78,6 +89,19 @@ describe("runDeployCommand", () => {
expect(process.exitCode).toBe(1);
});

test("refuses to deploy one member of a workspace", async () => {
const projectRoot = await createWorkspaceProject();
const logger = new TestLogger();

await runDeployCommand(logger, join(projectRoot, "agents/support"), {
isEveProject,
hasInteractiveTerminal: () => true,
});

expect(logger.errors[0]).toContain("workspace root");
expect(process.exitCode).toBe(1);
});

test("points an unlinked non-interactive run at eve link", async () => {
const projectRoot = await createAgentProject();
const logger = new TestLogger();
Expand Down
12 changes: 10 additions & 2 deletions packages/eve/src/cli/commands/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isEveProject } from "#setup/scaffold/index.js";
import { resolveEveProjectContext } from "#internal/project-context.js";

import { runDeployFlow, type DeployFlowDeps } from "#setup/flows/deploy.js";
import { createPrompter, type Prompter } from "#setup/prompter.js";
Expand Down Expand Up @@ -35,12 +36,19 @@ export async function runDeployCommand(
appRoot: string,
dependencies: DeployCommandDependencies = defaultDependencies,
): Promise<void> {
if (!(await dependencies.isEveProject(appRoot))) {
const projectContext = await resolveEveProjectContext(appRoot);
if (projectContext.kind === "workspace-member") {
logger.error(
`This agent belongs to the workspace at ${projectContext.workspace.root}. Run \`eve deploy\` from the workspace root to deploy every peer agent together.`,
);
process.exitCode = 1;
return;
}
if (!(await dependencies.isEveProject(appRoot)) && projectContext.kind === "standalone") {
logger.error(NOT_AN_AGENT_MESSAGE);
process.exitCode = 1;
return;
}

const prompter = dependencies.createPrompter?.() ?? createPrompter();
prompter.intro("Deploy your eve agent to Vercel");
try {
Expand Down
24 changes: 24 additions & 0 deletions packages/eve/src/cli/commands/link.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ class TestLogger implements LinkCliLogger {
}
}

async function createWorkspaceProject(): Promise<string> {
const projectRoot = await mkdtemp(join(tmpdir(), "eve-link-workspace-"));
await mkdir(join(projectRoot, "agents/support/agent"), { recursive: true });
await writeFile(
join(projectRoot, "package.json"),
JSON.stringify({ eve: { agents: ["agents/*"] }, private: true }),
"utf8",
);
return projectRoot;
}

async function createAgentProject(): Promise<string> {
const projectRoot = await mkdtemp(join(tmpdir(), "eve-link-command-"));
await mkdir(join(projectRoot, "agent"), { recursive: true });
Expand Down Expand Up @@ -123,6 +134,19 @@ describe("runLinkCommand", () => {
expect(process.exitCode).toBe(1);
});

test("refuses to link one member of a workspace", async () => {
const projectRoot = await createWorkspaceProject();
const logger = new TestLogger();

await runLinkCommand(logger, join(projectRoot, "agents/support"), {
isEveProject,
hasInteractiveTerminal: () => true,
});

expect(logger.errors[0]).toContain("workspace root");
expect(process.exitCode).toBe(1);
});

test("refuses without an interactive terminal", async () => {
const projectRoot = await createAgentProject();
const logger = new TestLogger();
Expand Down
11 changes: 10 additions & 1 deletion packages/eve/src/cli/commands/link.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isEveProject } from "#setup/scaffold/index.js";
import { resolveEveProjectContext } from "#internal/project-context.js";

import { runLinkFlow, type LinkFlowDeps } from "#setup/flows/link.js";
import { createPrompter, type Prompter } from "#setup/prompter.js";
Expand Down Expand Up @@ -36,7 +37,15 @@ export async function runLinkCommand(
appRoot: string,
dependencies: LinkCommandDependencies = defaultDependencies,
): Promise<void> {
if (!(await dependencies.isEveProject(appRoot))) {
const projectContext = await resolveEveProjectContext(appRoot);
if (projectContext.kind === "workspace-member") {
logger.error(
`This agent belongs to the workspace at ${projectContext.workspace.root}. Run \`eve link\` from the workspace root.`,
);
process.exitCode = 1;
return;
}
if (!(await dependencies.isEveProject(appRoot)) && projectContext.kind === "standalone") {
logger.error(NOT_AN_AGENT_MESSAGE);
process.exitCode = 1;
return;
Expand Down
9 changes: 9 additions & 0 deletions packages/eve/src/internal/agent-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,12 @@ export async function resolveAgentWorkspace(

return { members, root: workspaceRoot };
}

/** Load a workspace that the caller has already identified as workspace-shaped. */
export async function loadAgentWorkspace(root: string): Promise<AgentWorkspace> {
const workspace = await resolveAgentWorkspace(root);
if (workspace === undefined) {
throw new Error("An eve agent workspace requires an agents/ directory.");
}
return workspace;
}
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ describe("buildApplication", () => {
{
buildDir: expect.stringContaining(join(appRoot, ".eve", "builds")),
outputDir: expect.stringContaining(join(appRoot, ".eve", "builds")),
publicRoutePrefix: "/_eve_internal/eve",
},
);
const nestedFunctionStats = await lstat(
Expand Down
24 changes: 17 additions & 7 deletions packages/eve/src/internal/nitro/host/build-application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import type { ApplicationBuildOptions } from "#internal/nitro/host/types.js";
import { findClosestVercelOutputDirectory } from "#shared/vercel-output-directory.js";
import { toErrorMessage } from "#shared/errors.js";
import { resolveDiscoveryProject } from "#discover/project.js";
import { resolveCoDeployedEveServicePrefix } from "#internal/vercel/vercel-service-config-operations.js";
import { resolveEveServicePrefixByRoot } from "#internal/vercel/vercel-service-config-operations.js";
import { parseVercelServicesConfig } from "#internal/vercel/vercel-services-config.js";
import { createDiskRuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js";
import { isObject } from "#shared/guards.js";
Expand Down Expand Up @@ -100,7 +100,7 @@ async function writeOptionalApplicationBuildProfile(input: {
}
}

async function resolveCoDeployedEveServicePrefixForVercelFunctionOutput(
async function resolveEveServicePrefixForVercelFunctionOutput(
appRoot: string,
agentRoot: string,
): Promise<string | undefined> {
Expand All @@ -114,7 +114,7 @@ async function resolveCoDeployedEveServicePrefixForVercelFunctionOutput(
JSON.parse(await readFile(configPath, "utf8")) as unknown,
configPath,
);
const servicePrefix = resolveCoDeployedEveServicePrefix({
const servicePrefix = resolveEveServicePrefixByRoot({
appRoots,
configRoot: await resolveVercelOutputConfigRoot(outputDirectory),
config,
Expand Down Expand Up @@ -146,7 +146,7 @@ async function resolveCoDeployedEveServicePrefixForVercelFunctionOutput(
? currentDir
: await resolveVercelOutputConfigRoot(dirname(configPath));

const servicePrefix = resolveCoDeployedEveServicePrefix({
const servicePrefix = resolveEveServicePrefixByRoot({
appRoots,
configRoot,
config,
Expand Down Expand Up @@ -288,17 +288,27 @@ async function buildApplicationInWorkspace(

const servicePrefix = isVercelBuild
? await measureBuildPhase(profiler, "vercel.service-prefix.resolve", () =>
resolveCoDeployedEveServicePrefixForVercelFunctionOutput(
resolveEveServicePrefixForVercelFunctionOutput(
preparedHost.appRoot,
preparedHost.compileResult.project.agentRoot,
),
)
: undefined;
if (
options.publicRoutePrefix !== undefined &&
servicePrefix !== undefined &&
options.publicRoutePrefix !== servicePrefix
) {
throw new Error(
`EVE_PUBLIC_ROUTE_PREFIX ${JSON.stringify(options.publicRoutePrefix)} conflicts with the configured Vercel service prefix ${JSON.stringify(servicePrefix)}.`,
);
}
const publicRoutePrefix = options.publicRoutePrefix ?? servicePrefix;
const nitro = await measureBuildPhase(profiler, "nitro.create", () =>
createProductionApplicationNitro(preparedHost, {
buildDir: workspace.nitro.buildDir,
outputDir: workspace.publication.output.stagedDir,
publicRoutePrefix: options.publicRoutePrefix,
publicRoutePrefix,
}),
);

Expand Down Expand Up @@ -341,7 +351,7 @@ async function buildApplicationInWorkspace(
if (vercelServiceOutput !== undefined) {
await measureBuildPhase(profiler, "vercel.service-crons.normalize", () =>
normalizeVercelServiceCrons({
publicRoutePrefix: options.publicRoutePrefix,
publicRoutePrefix,
serviceOutputDirectory: workspace.publication.output.stagedDir,
}),
);
Expand Down
18 changes: 18 additions & 0 deletions packages/eve/src/internal/vercel/agent-workspace-deployment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { join } from "node:path";

import type { AgentWorkspace } from "#internal/agent-workspace.js";
import { readVercelJsonFile } from "#internal/vercel/vercel-services-config.js";

export type AgentWorkspaceDeploymentMode = "authored" | "inferred";

/** Resolve and validate the Vercel deployment policy for an agent workspace. */
export async function resolveAgentWorkspaceDeploymentMode(
workspace: AgentWorkspace,
): Promise<AgentWorkspaceDeploymentMode> {
const config = await readVercelJsonFile(join(workspace.root, "vercel.json"));
return config.services !== undefined ||
config.experimentalServices !== undefined ||
config.experimentalServicesV2 !== undefined
? "authored"
: "inferred";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { access, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

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

vi.mock("#shared/resolve-eve-binary.js", () => ({
resolveEveBinaryPath: (root: string) => join(root, "node_modules", "eve", "bin", "eve.js"),
}));

import { loadAgentWorkspace } from "#internal/agent-workspace.js";
import { buildAgentWorkspace } from "#internal/vercel/build-agent-workspace.js";

async function createWorkspace(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "eve-workspace-build-"));
await writeFile(
join(root, "package.json"),
JSON.stringify({ eve: { agents: ["agents/*"] }, packageManager: "pnpm@10.0.0", private: true }),
);
await Promise.all([
mkdir(join(root, "agents", "support", "agent"), { recursive: true }),
mkdir(join(root, "agents", "research", "agent"), { recursive: true }),
]);
return root;
}

describe("buildAgentWorkspace", () => {
it("emits peer services and canonical public routes", async () => {
const root = await createWorkspace();
const workspace = await loadAgentWorkspace(root);
expect(workspace).toBeDefined();

const output = await buildAgentWorkspace(workspace);
const config = JSON.parse(await readFile(join(output, "config.json"), "utf8"));

expect(config.routes).toEqual([
{
destination: { service: "eve-research", type: "service" },
src: "^/eve/agents/research/eve/v1/(.*)$",
},
{
destination: { service: "eve-support", type: "service" },
src: "^/eve/agents/support/eve/v1/(.*)$",
},
]);
await expect(
access(join(root, ".eve", "vercel-services", "eve-support")),
).resolves.toBeUndefined();

expect(config.services["eve-support"]).toEqual({
buildCommand:
"cd '../../../agents/support' && export EVE_INTERNAL_BUILD_OUTPUT_DIRECTORY='../../.eve/vercel-services/eve-support/.vercel/output' && export EVE_INTERNAL_HOST_BUILD_OUTPUT_DIRECTORY='../../.vercel/output' && export EVE_PUBLIC_ROUTE_PREFIX='/eve/agents/support' && node 'node_modules/eve/bin/eve.js' build",
framework: "eve",
root: ".eve/vercel-services/eve-support",
routePrefix: "/eve/agents/support",
routes: [
{
src: "^/eve/agents/support/eve/v1/(.*)$",
transforms: [{ args: "/eve/v1/$1", op: "set", type: "request.path" }],
},
],
});
});

it("keeps digit-bearing public names while encoding the generated service name", async () => {
const root = await createWorkspace();
await mkdir(join(root, "agents", "support2", "agent"), { recursive: true });
const workspace = await loadAgentWorkspace(root);
const output = await buildAgentWorkspace(workspace);
const config = JSON.parse(await readFile(join(output, "config.json"), "utf8"));
const supportServiceName = Object.keys(config.services).find((name) =>
name.startsWith("eve-support-"),
);

expect(supportServiceName).toMatch(/^eve-support-[a-z]+$/);
expect(config.routes).toContainEqual({
destination: { service: supportServiceName, type: "service" },
src: "^/eve/agents/support2/eve/v1/(.*)$",
});
});

it("refuses to assemble an authored graph", async () => {
const root = await createWorkspace();
await writeFile(join(root, "vercel.json"), JSON.stringify({ services: {} }));
const workspace = await loadAgentWorkspace(root);
await expect(buildAgentWorkspace(workspace)).rejects.toThrow(/Run `vercel build`/);
});
});
Loading
Loading