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

Keep active local Workflow turns on the development generation they selected across reloads, retries, and server restarts. New turns use the latest successful generation, while pruning retains only generations still owned by active work.
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ For bearer tokens or custom schemes, pass explicit headers with `-H`.

Local dev records the last ready URL per resolved app root in `.eve/dev-server-state.v1.json`. A second interactive `eve dev` reconnects only when that URL is loopback and healthy; each terminal UI creates a fresh client session while sharing the server process. A stale or malformed record is replaced when eve starts a new server. Passing `--host`, `--port`, or a `PORT` environment value skips reconnection and reports a healthy recorded server instead.

Local dev keeps immutable runtime source snapshots under `.eve/dev-runtime/snapshots/` so in-flight sessions hold a consistent code revision while new prompts pick up rebuilds. On startup, `eve dev` prunes stale runtime snapshots and old local sandbox templates in the background. For manual cleanup, stop `eve dev` and delete `.eve/dev-runtime/snapshots/` or `.eve/sandbox-cache/local/templates/`.
Local dev keeps immutable runtime generations under `.eve/dev-runtime/snapshots/` so an in-flight turn finishes on the code revision it started with while the next prompt picks up successful rebuilds. eve prunes generations in the background once no request, worker, or active local Workflow run references them, along with old local sandbox templates. For manual cleanup, stop `eve dev` and delete `.eve/dev-runtime/snapshots/` or `.eve/sandbox-cache/local/templates/`; also delete `.workflow-data` if it exists, since a Workflow run that references a deleted generation stops `eve dev` startup with an integrity error.

## `eve link`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";

import { loadDeclaration } from "../_shared.mjs";

const require = createRequire(import.meta.url);

function resolveWorkflowWorldLocalVersion() {
Expand Down Expand Up @@ -53,5 +55,6 @@ export default {
packageName: "@workflow/world-local",
compiledPath: "@workflow/world-local",
chunkGroup: "workflow",
declaration: await loadDeclaration("workflow-world-local.d.ts"),
plugins: [workflowWorldLocalVersionPlugin],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { QueuePrefix, World } from "#compiled/@workflow/world/index.js";

export type LocalWorld = World & {
clear(): Promise<void>;
registerHandler(prefix: QueuePrefix, handler: (request: Request) => Promise<Response>): void;
};

export function createWorld(options?: {
readonly baseUrl?: string;
readonly dataDir?: string;
readonly recoverActiveRuns?: boolean;
readonly streamFlushIntervalMs?: number;
readonly tag?: string;
}): LocalWorld;
9 changes: 6 additions & 3 deletions packages/eve/src/execution/create-session-step.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { RuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js";
import { getCompiledRuntimeAgentBundle } from "#runtime/sessions/compiled-agent-cache.js";
import {
type DurableCompiledArtifactsSource,
resolveDurableCompiledArtifactsSource,
} from "#runtime/durable-compiled-artifacts-source.js";
import {
createDurableSessionState,
type DurableSessionState,
Expand All @@ -26,7 +29,7 @@ export interface CreateSessionStepResult {
* the root agent.
*/
export async function createSessionStep(input: {
readonly compiledArtifactsSource: RuntimeCompiledArtifactsSource;
readonly compiledArtifactsSource: DurableCompiledArtifactsSource;
readonly continuationToken: string;
readonly inheritedLimits?: RunSessionLimits;
readonly outputSchema?: JsonObject;
Expand All @@ -38,7 +41,7 @@ export async function createSessionStep(input: {
"use step";

const bundle = await getCompiledRuntimeAgentBundle({
compiledArtifactsSource: input.compiledArtifactsSource,
compiledArtifactsSource: resolveDurableCompiledArtifactsSource(input.compiledArtifactsSource),
nodeId: input.nodeId,
});

Expand Down
4 changes: 2 additions & 2 deletions packages/eve/src/execution/workflow-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
import { coalesceDeliveries } from "#harness/messages.js";
import { readChannelRequestId, readRootSessionId } from "#execution/eve-workflow-attributes.js";
import type { RunMode } from "#shared/run-mode.js";
import type { RuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js";
import type { DurableCompiledArtifactsSource } from "#runtime/durable-compiled-artifacts-source.js";
import { notifyDelegatedParentStep } from "#execution/delegated-parent-notification.js";
import {
createDelegatedSubagentErrorResult,
Expand Down Expand Up @@ -74,7 +74,7 @@ export async function workflowEntry(input: WorkflowEntryInput): Promise<Workflow
| SessionCapabilities
| undefined;
const serializedBundle = input.serializedContext["eve.bundle"] as {
source: RuntimeCompiledArtifactsSource;
source: DurableCompiledArtifactsSource;
nodeId?: string;
};

Expand Down
12 changes: 5 additions & 7 deletions packages/eve/src/execution/workflow-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
readParentLineage,
} from "#execution/eve-workflow-attributes.js";
import { resolveInstalledPackageInfo } from "#internal/application/package.js";
import { isEveDevEnvironment } from "#internal/application/dev-environment.js";
import { createLogger, logError } from "#internal/logging.js";
import {
getRun,
Expand Down Expand Up @@ -220,15 +221,12 @@ export async function startWorkflowPreferLatest<TArgs extends unknown[], TResult
}

/**
* Latest-deployment routing only applies on Vercel production: the platform
* resolves "latest" through the deployment's git branch reference, which
* only production deployments carry. Preview and CLI deployments have no
* branch and fail with HTTP 400 ("Source deployment has no git branch"), so
* they pin workflow runs to their own immutable deployment — which is also
* the correct isolation semantic for previews.
* Local development resolves "latest" to the active promoted generation.
* Vercel resolves it only for production deployments; previews and CLI
* deployments have no branch reference and remain pinned to themselves.
*/
function shouldRouteToLatestDeployment(): boolean {
return process.env.VERCEL_ENV === "production";
return process.env.VERCEL_ENV === "production" || isEveDevEnvironment();
}

function isLatestDeploymentUnsupportedError(error: unknown): boolean {
Expand Down
14 changes: 14 additions & 0 deletions packages/eve/src/execution/workflow-steps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,20 @@ describe("dispatchTurnStep", () => {
);
});

it("starts turn workflows on the latest promoted generation in local development", async () => {
vi.stubEnv("EVE_DEV", "1");
const input = createTurnInput();
startMock.mockResolvedValue({ runId: "turn-run" });

await expect(dispatchTurnStep(input)).resolves.toEqual({ runId: "turn-run" });

expect(startMock).toHaveBeenCalledWith(
turnWorkflowReference,
[createTurnWorkflowInput(input)],
expect.objectContaining({ deploymentId: "latest" }),
);
});

it("pins turn workflows to the current deployment off production", async () => {
vi.stubEnv("VERCEL_ENV", "preview");
const input = createTurnInput();
Expand Down
29 changes: 28 additions & 1 deletion packages/eve/src/internal/application/compiled-artifacts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";

import { createWorkflowWorldPluginSource } from "#internal/application/compiled-artifacts.js";
import {
createDevelopmentWorkflowWorldPluginSource,
createWorkflowWorldPluginSource,
} from "#internal/application/compiled-artifacts.js";

describe("createWorkflowWorldPluginSource", () => {
it("imports a configured world package and delegates its construction to Workflow", () => {
Expand Down Expand Up @@ -41,3 +44,27 @@ describe("createWorkflowWorldPluginSource", () => {
).toContain("/compiled/@workflow/world-vercel/index.js");
});
});

describe("createDevelopmentWorkflowWorldPluginSource", () => {
it("installs the parent-backed World without starting a local World in the worker", () => {
const source = createDevelopmentWorkflowWorldPluginSource({
compiledArtifactsBootstrapPath: "/app/.eve/host/bootstrap.mjs",
configuredWorld: undefined,
});

expect(source).toContain("createDevelopmentWorkflowWorld");
expect(source).toContain("setWorld(createDevelopmentWorkflowWorld());");
expect(source).not.toContain("@workflow/world-local");
expect(source).not.toContain("workflowWorld.start");
});

it("keeps explicitly configured remote Worlds inside the worker", () => {
const source = createDevelopmentWorkflowWorldPluginSource({
compiledArtifactsBootstrapPath: "/app/.eve/host/bootstrap.mjs",
configuredWorld: "@acme/eve-world",
});

expect(source).toContain('import * as workflowWorldModule from "@acme/eve-world";');
expect(source).toContain("await workflowWorld.start?.();");
});
});
34 changes: 32 additions & 2 deletions packages/eve/src/internal/application/compiled-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,9 @@ export async function writeDevelopmentCompiledArtifactsFiles(input: {
);
await writeFile(
workflowWorldPluginPath,
createWorkflowWorldPluginSource({
createDevelopmentWorkflowWorldPluginSource({
compiledArtifactsBootstrapPath: bootstrapPath,
configuredWorld: input.compileResult.manifest.config.experimental?.workflow?.world,
defaultWorld: "local",
}),
);

Expand Down Expand Up @@ -279,6 +278,37 @@ export function createWorkflowWorldPluginSource(input: {
].join("\n");
}

export function createDevelopmentWorkflowWorldPluginSource(input: {
compiledArtifactsBootstrapPath: string;
configuredWorld: AgentWorkflowWorldDefinition | undefined;
}): string {
const packageName = getWorldImport({ WORKFLOW_TARGET_WORLD: input.configuredWorld ?? "local" });
if (packageName !== "@workflow/world-local") {
return createWorkflowWorldPluginSource({
...input,
defaultWorld: "local",
});
}
const workflowRuntimeImportSpecifier = resolvePackageCompiledFilePath(
"src/compiled/@workflow/core/runtime.js",
);
const developmentWorldImportSpecifier = resolvePackageSourceFilePath(
"src/internal/workflow/development-world-client.ts",
);
return [
"// Generated by eve. Do not edit by hand.",
`import ${stringifyEsmImportSpecifier(input.compiledArtifactsBootstrapPath)};`,
`import { getWorld, setWorld } from ${stringifyEsmImportSpecifier(workflowRuntimeImportSpecifier)};`,
`import { createDevelopmentWorkflowWorld } from ${stringifyEsmImportSpecifier(developmentWorldImportSpecifier)};`,
"",
"setWorld(createDevelopmentWorkflowWorld());",
"await getWorld();",
"",
"export default function installDevelopmentWorkflowWorldPlugin() {}",
"",
].join("\n");
}

function createInstrumentationPluginSource(input: {
agentName: string;
instrumentationPath: string;
Expand Down
7 changes: 7 additions & 0 deletions packages/eve/src/internal/application/dev-environment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** Environment flag set for processes that belong to an `eve dev` session. */
export const EVE_DEV_ENV_FLAG = "EVE_DEV";

/** Reports whether this process belongs to an `eve dev` session. */
export function isEveDevEnvironment(): boolean {
return process.env[EVE_DEV_ENV_FLAG] === "1";
}
14 changes: 2 additions & 12 deletions packages/eve/src/internal/application/optional-package-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,9 @@ import { dirname, join } from "node:path";
import { pathToFileURL } from "node:url";
import { Worker } from "node:worker_threads";

/**
* Environment flag set by `eve dev` so runtime code can distinguish the
* interactive development server from production processes. Optional
* engine packages are auto-installed only when this is set.
*/
export const EVE_DEV_ENV_FLAG = "EVE_DEV";
import { EVE_DEV_ENV_FLAG, isEveDevEnvironment } from "#internal/application/dev-environment.js";

/**
* Reports whether this process belongs to an `eve dev` session.
*/
export function isEveDevEnvironment(): boolean {
return process.env[EVE_DEV_ENV_FLAG] === "1";
}
export { EVE_DEV_ENV_FLAG, isEveDevEnvironment };

export type ProjectPackageManager = "bun" | "npm" | "pnpm" | "yarn";

Expand Down
58 changes: 58 additions & 0 deletions packages/eve/src/internal/compiled-manifest-fingerprint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";

import { createCompiledAgentManifest } from "#compiler/manifest.js";
import { serializeCompiledManifestForFingerprint } from "#internal/compiled-manifest-fingerprint.js";

function manifestWithRoot(runtimeAppRoot: string, agentRoot = join(runtimeAppRoot, "agent")) {
return createCompiledAgentManifest({
agentRoot,
appRoot: runtimeAppRoot,
config: {
model: { id: "openai/gpt-5-mini", routing: { kind: "gateway", target: "openai" } },
name: "fingerprint-test",
},
});
}

describe("serializeCompiledManifestForFingerprint", () => {
it("serializes identical content under different snapshot roots identically", () => {
const firstRoot = "/tmp/snapshots/generation-a/source/app";
const secondRoot = "/tmp/snapshots/generation-b/source/app";
const first = serializeCompiledManifestForFingerprint({
manifest: manifestWithRoot(firstRoot),
runtimeAppRoot: firstRoot,
});
const second = serializeCompiledManifestForFingerprint({
manifest: manifestWithRoot(secondRoot),
runtimeAppRoot: secondRoot,
});

expect(first).toBe(second);
expect(first).toContain("$runtime/agent");
expect(first).not.toContain("generation-a");
});

it("keeps absolute paths outside the runtime root verbatim", () => {
const runtimeAppRoot = "/tmp/snapshots/generation-a/source/app";
const serialized = serializeCompiledManifestForFingerprint({
manifest: manifestWithRoot(runtimeAppRoot, "/somewhere/else/agent"),
runtimeAppRoot,
});

expect(serialized).toContain("/somewhere/else/agent");
});

it("canonicalizes object key order", () => {
const left = serializeCompiledManifestForFingerprint({
manifest: { agentRoot: "/app/agent", config: { model: "m", name: "n" } } as never,
runtimeAppRoot: "/app",
});
const right = serializeCompiledManifestForFingerprint({
manifest: { config: { name: "n", model: "m" }, agentRoot: "/app/agent" } as never,
runtimeAppRoot: "/app",
});

expect(left).toBe(right);
});
});
Loading
Loading