From c3113aa87e55cf58ab48cb11ed9b9238f7f3c2c0 Mon Sep 17 00:00:00 2001 From: Casey Gowrie Date: Mon, 13 Jul 2026 09:15:35 -0400 Subject: [PATCH 01/12] fix(eve): isolate production build workspaces Signed-off-by: Casey Gowrie --- .changeset/quiet-builds-rest.md | 5 + docs/reference/cli.md | 6 +- packages/eve/src/compiler/artifacts.ts | 17 +- packages/eve/src/compiler/compile-agent.ts | 17 +- .../sandbox/prewarm.scenario.test.ts | 33 + packages/eve/src/execution/sandbox/prewarm.ts | 22 +- .../internal/application/build-workspace.ts | 53 ++ .../output-publication.scenario.test.ts | 263 ++++++++ .../application/output-publication.ts | 570 ++++++++++++++++++ .../application/production-start-artifacts.ts | 13 + .../host/build-application.scenario.test.ts | 136 +++-- .../internal/nitro/host/build-application.ts | 86 ++- .../nitro/host/build-vercel-agent-summary.ts | 3 +- .../nitro/host/create-application-nitro.ts | 6 +- .../prepare-application-host.scenario.test.ts | 33 + .../nitro/host/prepare-application-host.ts | 33 +- .../bin-build-output.scenario.test.ts | 5 +- .../eve/test/scenarios/cli.scenario.test.ts | 5 +- ...roduction-build-isolation.scenario.test.ts | 500 +++++++++++++++ 19 files changed, 1722 insertions(+), 84 deletions(-) create mode 100644 .changeset/quiet-builds-rest.md create mode 100644 packages/eve/src/internal/application/build-workspace.ts create mode 100644 packages/eve/src/internal/application/output-publication.scenario.test.ts create mode 100644 packages/eve/src/internal/application/output-publication.ts create mode 100644 packages/eve/src/internal/application/production-start-artifacts.ts create mode 100644 packages/eve/test/scenarios/production-build-isolation.scenario.test.ts diff --git a/.changeset/quiet-builds-rest.md b/.changeset/quiet-builds-rest.md new file mode 100644 index 000000000..a146c9dbc --- /dev/null +++ b/.changeset/quiet-builds-rest.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +`eve build` now uses invocation-owned compiler, host, Nitro, Workflow, and output workspaces. Concurrent builds can run beside `eve dev`, and failed builds preserve the last successfully published output. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a552afa1c..369f83ade 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -93,9 +93,11 @@ Run this first when something behaves unexpectedly. It confirms a file was disco eve build ``` -Compiles to `.eve/` and builds the host output, then prints the built output path. +Compiles and bundles in an invocation-owned directory under `.eve/builds/`, then publishes the completed host output and prints its path. Scratch workspaces are removed after success or failure. -Useful artifacts written under `.eve/` (preserved even on partial failure): +Production builds do not write through the stable compiler, host, Nitro, or Workflow files owned by `eve dev`, so builds can run while a local dev server is active. A failed build leaves the last successful `.output/` and agent summary untouched. Concurrent completed builds serialize only the final publication window. + +Useful stable artifacts written by inspection and development flows under `.eve/` include: | Artifact | Description | | ---------------------------------------------- | ---------------------------------------------------- | diff --git a/packages/eve/src/compiler/artifacts.ts b/packages/eve/src/compiler/artifacts.ts index a08252948..f9d787da6 100644 --- a/packages/eve/src/compiler/artifacts.ts +++ b/packages/eve/src/compiler/artifacts.ts @@ -91,6 +91,7 @@ export interface CompileMetadata { */ interface WriteCompilerArtifactsInput { appRoot: string; + artifactsRoot?: string; diagnostics: readonly DiscoverDiagnostic[]; manifest: AgentSourceManifest; } @@ -106,13 +107,15 @@ interface WriteCompilerArtifactsResult { paths: CompilerArtifactPaths; } -/** - * Resolves the compiler-owned artifact paths for one application root. - */ -export function resolveCompilerArtifactPaths(appRoot: string): CompilerArtifactPaths { +/** Resolves compiler-owned artifact paths for one application root. */ +export function resolveCompilerArtifactPaths( + appRoot: string, + artifactsRoot: string = join(resolve(appRoot), ".eve"), +): CompilerArtifactPaths { const resolvedAppRoot = resolve(appRoot); - const discoveryDirectoryPath = join(resolvedAppRoot, ".eve", "discovery"); - const compileDirectoryPath = join(resolvedAppRoot, ".eve", "compile"); + const resolvedArtifactsRoot = resolve(artifactsRoot); + const discoveryDirectoryPath = join(resolvedArtifactsRoot, "discovery"); + const compileDirectoryPath = join(resolvedArtifactsRoot, "compile"); return { appRoot: resolvedAppRoot, @@ -192,7 +195,7 @@ export function createCompileMetadata(input: { export async function writeCompilerArtifacts( input: WriteCompilerArtifactsInput, ): Promise { - const paths = resolveCompilerArtifactPaths(input.appRoot); + const paths = resolveCompilerArtifactPaths(input.appRoot, input.artifactsRoot); const diagnosticsArtifact = createDiscoveryDiagnosticsArtifact(input.diagnostics); const compiledManifest = await materializeWorkspaceResources({ compileDirectoryPath: paths.compileDirectoryPath, diff --git a/packages/eve/src/compiler/compile-agent.ts b/packages/eve/src/compiler/compile-agent.ts index 2d197dae1..79cce9c0f 100644 --- a/packages/eve/src/compiler/compile-agent.ts +++ b/packages/eve/src/compiler/compile-agent.ts @@ -16,6 +16,8 @@ import type { CompiledAgentManifest } from "#compiler/manifest.js"; * discovery artifacts. */ export interface CompileAgentInput { + artifactsRoot?: string; + includeDiagnosticsArtifactPath?: boolean; /** * Optional {@link ProjectSource} used for discovery reads. Defaults to a * disk-backed source so production callers keep their current behaviour. @@ -43,11 +45,17 @@ export interface CompileAgentResult { export class CompileAgentError extends Error { readonly result: CompileAgentResult; - constructor(result: CompileAgentResult) { + constructor( + result: CompileAgentResult, + options: { readonly includeDiagnosticsArtifactPath?: boolean } = {}, + ) { super( formatCompileAgentErrorMessage({ diagnostics: result.diagnostics, - diagnosticsPath: result.paths.diagnosticsPath, + diagnosticsPath: + options.includeDiagnosticsArtifactPath === false + ? undefined + : result.paths.diagnosticsPath, }), ); this.name = "CompileAgentError"; @@ -65,6 +73,7 @@ export async function compileAgent(input: CompileAgentInput = {}): Promise { vi.unstubAllEnvs(); }); + it("loads workspace seeds from an invocation-owned compiler directory", async () => { + vi.stubEnv("VERCEL", "1"); + vi.stubEnv("VERCEL_DEPLOYMENT_ID", "dpl_isolated_build_prewarm"); + + const appRoot = await createScenarioAppRoot(); + const compilerAppRoot = join(appRoot, ".eve", "builds", "isolated", "compiler"); + const compileResult = await compileAgent({ + artifactsRoot: join(compilerAppRoot, ".eve"), + startPath: appRoot, + }); + const events = createPrewarmEvents(); + + await prewarmAppSandboxes({ + appRoot, + compileDirectoryPath: compileResult.paths.compileDirectoryPath, + compiledArtifactsSource: createDiskRuntimeCompiledArtifactsSource(compilerAppRoot, { + moduleMapLoaderPath: resolvePackageSourceFilePath( + "src/internal/authored-module-map-loader.ts", + ), + sandboxAppRoot: appRoot, + }), + dispatch: createRecordingDispatch(events), + }); + + expect(events.seededTemplateCount).toBe(2); + expect([...events.seededFilePaths].sort()).toEqual([ + "$HOME/.agents/skills/research/SKILL.md", + "$HOME/.agents/skills/route-weather/SKILL.md", + ]); + }); + it("prewarms the root and subagent sandbox templates with per-agent skill seeds", async () => { // Per-sandbox backend resolution falls back to defaultSandbox() when // an authored sandbox does not declare `backend`. Mark this process diff --git a/packages/eve/src/execution/sandbox/prewarm.ts b/packages/eve/src/execution/sandbox/prewarm.ts index db16a5c29..ddce490e0 100644 --- a/packages/eve/src/execution/sandbox/prewarm.ts +++ b/packages/eve/src/execution/sandbox/prewarm.ts @@ -1,5 +1,8 @@ +import { join } from "node:path"; + import type { CompiledWorkspaceResourceRoot } from "#compiler/manifest.js"; import { loadCompiledModuleMapFromAuthoredSource } from "#internal/authored-module-map-loader.js"; +import { resolvePackageSourceFilePath } from "#internal/application/package.js"; import { createAuthoredSourceRuntimeCompiledArtifactsSource } from "#internal/application/runtime-compiled-artifacts-source.js"; import type { SandboxBackend, @@ -9,6 +12,7 @@ import type { } from "#public/definitions/sandbox-backend.js"; import { createBundledRuntimeCompiledArtifactsSource, + createDiskRuntimeCompiledArtifactsSource, getRuntimeCompiledArtifactsSandboxAppRoot, type RuntimeCompiledArtifactsSource, type RuntimeDiskCompiledArtifactsSource, @@ -51,6 +55,7 @@ export type SandboxBackendPrewarmDispatch = (input: { interface PrewarmSandboxesInput { readonly appRoot: string; + readonly compileDirectoryPath?: string; readonly compiledArtifactsSource: RuntimeCompiledArtifactsSource; readonly graph: ResolvedAgentGraphBundle; readonly log?: (message: string) => void; @@ -145,6 +150,7 @@ export async function prewarmSandboxes(input: PrewarmSandboxesInput): Promise void; readonly dispatch?: SandboxBackendPrewarmDispatch; }): Promise { - const authoredSource = createAuthoredSourceRuntimeCompiledArtifactsSource(input.appRoot); + const builtArtifactsRoot = join(input.appRoot, ".output"); + const authoredSource = createDiskRuntimeCompiledArtifactsSource(builtArtifactsRoot, { + moduleMapLoaderPath: resolvePackageSourceFilePath("src/internal/authored-module-map-loader.ts"), + sandboxAppRoot: input.appRoot, + }); const [metadata, manifest, moduleMap] = await Promise.all([ loadCompileMetadata({ compiledArtifactsSource: authoredSource, @@ -215,6 +226,8 @@ export async function prewarmBuiltAppSandboxes(input: { await prewarmSandboxes({ appRoot: input.appRoot, + compileDirectoryPath: + resolveRuntimeCompilerArtifactPaths(builtArtifactsRoot).compileDirectoryPath, compiledArtifactsSource, dispatch: input.dispatch, graph, @@ -226,12 +239,13 @@ export async function prewarmBuiltAppSandboxes(input: { async function collectPrewarmTargets(input: { readonly appRoot: string; + readonly compileDirectoryPath?: string; readonly compiledArtifactsSource: RuntimeCompiledArtifactsSource; readonly graph: ResolvedAgentGraphBundle; }): Promise { - const compileDirectoryPath = resolveRuntimeCompilerArtifactPaths( - input.appRoot, - ).compileDirectoryPath; + const compileDirectoryPath = + input.compileDirectoryPath ?? + resolveRuntimeCompilerArtifactPaths(input.appRoot).compileDirectoryPath; const runtimeContext = { appRoot: input.appRoot }; const targets: PrewarmTarget[] = []; diff --git a/packages/eve/src/internal/application/build-workspace.ts b/packages/eve/src/internal/application/build-workspace.ts new file mode 100644 index 000000000..55b0dd5cc --- /dev/null +++ b/packages/eve/src/internal/application/build-workspace.ts @@ -0,0 +1,53 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +import { resolveOutputDirectory } from "#internal/application/paths.js"; +import { VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH } from "#internal/vercel-agent-summary.js"; + +export interface ApplicationBuildWorkspace { + readonly appRoot: string; + readonly compilerAppRoot: string; + readonly compilerArtifactsRoot: string; + readonly finalOutputDir: string; + readonly finalSummaryPath: string; + readonly hostArtifactsDir: string; + readonly nitroBuildDir: string; + readonly nitroOutputDir: string; + readonly outputDir: string; + readonly rootDir: string; + readonly summaryPath: string; + readonly workflowBuildDir: string; +} + +export async function createApplicationBuildWorkspace( + appRoot: string, +): Promise { + const resolvedAppRoot = resolve(appRoot); + const buildId = `${Date.now().toString(36)}-${randomUUID()}`; + const rootDir = join(resolvedAppRoot, ".eve", "builds", buildId); + const compilerAppRoot = join(rootDir, "compiler"); + const workspace: ApplicationBuildWorkspace = { + appRoot: resolvedAppRoot, + compilerAppRoot, + compilerArtifactsRoot: join(compilerAppRoot, ".eve"), + finalOutputDir: resolveOutputDirectory(resolvedAppRoot), + finalSummaryPath: join(resolvedAppRoot, VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH), + hostArtifactsDir: join(rootDir, "host"), + nitroBuildDir: join(rootDir, "nitro"), + nitroOutputDir: join(rootDir, "nitro-output"), + outputDir: join(rootDir, "output"), + rootDir, + summaryPath: join(rootDir, "agent-summary.json"), + workflowBuildDir: join(rootDir, "workflow"), + }; + + await mkdir(rootDir, { recursive: true }); + return workspace; +} + +export async function removeApplicationBuildWorkspace( + workspace: ApplicationBuildWorkspace, +): Promise { + await rm(workspace.rootDir, { force: true, recursive: true }); +} diff --git a/packages/eve/src/internal/application/output-publication.scenario.test.ts b/packages/eve/src/internal/application/output-publication.scenario.test.ts new file mode 100644 index 000000000..17f032af3 --- /dev/null +++ b/packages/eve/src/internal/application/output-publication.scenario.test.ts @@ -0,0 +1,263 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + publishApplicationBuildArtifacts, + resolveOutputPublicationLockPath, +} from "#internal/application/output-publication.js"; +import { useTemporaryDirectories } from "#internal/testing/use-temporary-app-roots.js"; + +const createScratchDirectory = useTemporaryDirectories(); + +async function writePublication(input: { + readonly outputDir: string; + readonly outputMarker: string; + readonly summaryMarker: string; + readonly summaryPath: string; +}): Promise { + await Promise.all([ + mkdir(input.outputDir, { recursive: true }), + mkdir(join(input.summaryPath, ".."), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(input.outputDir, "marker.txt"), `${input.outputMarker}\n`), + writeFile(input.summaryPath, `${input.summaryMarker}\n`), + ]); +} + +async function expectPublication(input: { + readonly outputDir: string; + readonly outputMarker: string; + readonly summaryMarker: string; + readonly summaryPath: string; +}): Promise { + await expect(readFile(join(input.outputDir, "marker.txt"), "utf8")).resolves.toBe( + `${input.outputMarker}\n`, + ); + await expect(readFile(input.summaryPath, "utf8")).resolves.toBe(`${input.summaryMarker}\n`); +} + +describe("build output publication", () => { + it("publishes one completed output and its matching summary", async () => { + const appRoot = await createScratchDirectory("eve-output-publication-"); + const finalOutputDir = join(appRoot, ".output"); + const finalSummaryPath = join(appRoot, ".eve", "agent-summary.json"); + const stagedOutputDir = join(appRoot, ".eve", "builds", "next", "output"); + const stagedSummaryPath = join(appRoot, ".eve", "builds", "next", "summary.json"); + await writePublication({ + outputDir: finalOutputDir, + outputMarker: "previous", + summaryMarker: "previous", + summaryPath: finalSummaryPath, + }); + await writePublication({ + outputDir: stagedOutputDir, + outputMarker: "next", + summaryMarker: "next", + summaryPath: stagedSummaryPath, + }); + + await publishApplicationBuildArtifacts({ + appRoot, + finalOutputDir, + finalSummaryPath, + stagedOutputDir, + stagedSummaryPath, + }); + + await expectPublication({ + outputDir: finalOutputDir, + outputMarker: "next", + summaryMarker: "next", + summaryPath: finalSummaryPath, + }); + }); + + it("restores the complete last-good publication when installation fails", async () => { + const appRoot = await createScratchDirectory("eve-output-publication-rollback-"); + const finalOutputDir = join(appRoot, ".output"); + const finalSummaryPath = join(appRoot, ".eve", "agent-summary.json"); + const stagedOutputDir = join(appRoot, ".eve", "builds", "failed", "output"); + const stagedSummaryPath = join(appRoot, ".eve", "builds", "failed", "summary.json"); + await writePublication({ + outputDir: finalOutputDir, + outputMarker: "last-good", + summaryMarker: "last-good", + summaryPath: finalSummaryPath, + }); + await writePublication({ + outputDir: stagedOutputDir, + outputMarker: "failed", + summaryMarker: "failed", + summaryPath: stagedSummaryPath, + }); + + await expect( + publishApplicationBuildArtifacts({ + appRoot, + finalOutputDir, + finalSummaryPath, + stagedOutputDir, + stagedSummaryPath, + onAfterOutputInstall() { + throw new Error("injected publication failure"); + }, + }), + ).rejects.toThrow("injected publication failure"); + + await expectPublication({ + outputDir: finalOutputDir, + outputMarker: "last-good", + summaryMarker: "last-good", + summaryPath: finalSummaryPath, + }); + await expectPublication({ + outputDir: stagedOutputDir, + outputMarker: "failed", + summaryMarker: "failed", + summaryPath: stagedSummaryPath, + }); + }); + + it("keeps the publication lock owned until the current publisher releases it", async () => { + const appRoot = await createScratchDirectory("eve-output-publication-lock-"); + const finalOutputDir = join(appRoot, ".output"); + const finalSummaryPath = join(appRoot, ".eve", "agent-summary.json"); + const firstOutputDir = join(appRoot, ".eve", "builds", "first", "output"); + const firstSummaryPath = join(appRoot, ".eve", "builds", "first", "summary.json"); + const secondOutputDir = join(appRoot, ".eve", "builds", "second", "output"); + const secondSummaryPath = join(appRoot, ".eve", "builds", "second", "summary.json"); + await writePublication({ + outputDir: firstOutputDir, + outputMarker: "first", + summaryMarker: "first", + summaryPath: firstSummaryPath, + }); + await writePublication({ + outputDir: secondOutputDir, + outputMarker: "second", + summaryMarker: "second", + summaryPath: secondSummaryPath, + }); + const firstEntered = Promise.withResolvers(); + const releaseFirst = Promise.withResolvers(); + const secondObservedContention = Promise.withResolvers(); + const entered: string[] = []; + + const first = publishApplicationBuildArtifacts({ + appRoot, + finalOutputDir, + finalSummaryPath, + stagedOutputDir: firstOutputDir, + stagedSummaryPath: firstSummaryPath, + async onAfterBackup() { + entered.push("first"); + firstEntered.resolve(); + await releaseFirst.promise; + }, + }); + await firstEntered.promise; + const second = publishApplicationBuildArtifacts({ + appRoot, + finalOutputDir, + finalSummaryPath, + stagedOutputDir: secondOutputDir, + stagedSummaryPath: secondSummaryPath, + onLockContention() { + secondObservedContention.resolve(); + }, + onAfterBackup() { + entered.push("second"); + }, + }); + + await secondObservedContention.promise; + expect(entered).toEqual(["first"]); + releaseFirst.resolve(); + await Promise.all([first, second]); + + expect(entered).toEqual(["first", "second"]); + await expectPublication({ + outputDir: finalOutputDir, + outputMarker: "second", + summaryMarker: "second", + summaryPath: finalSummaryPath, + }); + }); + + it("recovers an interrupted publication before admitting the next publisher", async () => { + const appRoot = await createScratchDirectory("eve-output-publication-recovery-"); + const finalOutputDir = join(appRoot, ".output"); + const finalSummaryPath = join(appRoot, ".eve", "agent-summary.json"); + const interruptedOutputDir = join(appRoot, ".eve", "builds", "interrupted", "output"); + const interruptedSummaryPath = join(appRoot, ".eve", "builds", "interrupted", "summary.json"); + const nextOutputDir = join(appRoot, ".eve", "builds", "next", "output"); + const nextSummaryPath = join(appRoot, ".eve", "builds", "next", "summary.json"); + const interruptedToken = "interrupted-owner"; + const outputBackupPath = `${finalOutputDir}.eve-backup-${interruptedToken}`; + const summaryBackupPath = `${finalSummaryPath}.eve-backup-${interruptedToken}`; + await writePublication({ + outputDir: finalOutputDir, + outputMarker: "last-good", + summaryMarker: "last-good", + summaryPath: finalSummaryPath, + }); + await writePublication({ + outputDir: interruptedOutputDir, + outputMarker: "interrupted", + summaryMarker: "interrupted", + summaryPath: interruptedSummaryPath, + }); + await writePublication({ + outputDir: nextOutputDir, + outputMarker: "next", + summaryMarker: "next", + summaryPath: nextSummaryPath, + }); + await Promise.all([ + rename(finalOutputDir, outputBackupPath), + rename(finalSummaryPath, summaryBackupPath), + ]); + const lockPath = resolveOutputPublicationLockPath(appRoot); + await mkdir(lockPath, { recursive: true }); + await writeFile( + join(lockPath, "owner.json"), + `${JSON.stringify({ + finalOutputDir, + finalSummaryPath, + hadOutput: true, + hadSummary: true, + outputBackupPath, + phase: "backed-up", + pid: 2_147_483_647, + stagedOutputDir: interruptedOutputDir, + stagedSummaryPath: interruptedSummaryPath, + startedAt: new Date(0).toISOString(), + summaryBackupPath, + token: interruptedToken, + })}\n`, + ); + + await expect( + publishApplicationBuildArtifacts({ + appRoot, + finalOutputDir, + finalSummaryPath, + stagedOutputDir: nextOutputDir, + stagedSummaryPath: nextSummaryPath, + onAfterBackup() { + throw new Error("stop after recovery"); + }, + }), + ).rejects.toThrow("stop after recovery"); + + await expectPublication({ + outputDir: finalOutputDir, + outputMarker: "last-good", + summaryMarker: "last-good", + summaryPath: finalSummaryPath, + }); + }); +}); diff --git a/packages/eve/src/internal/application/output-publication.ts b/packages/eve/src/internal/application/output-publication.ts new file mode 100644 index 000000000..f0c0e06ea --- /dev/null +++ b/packages/eve/src/internal/application/output-publication.ts @@ -0,0 +1,570 @@ +import { randomUUID } from "node:crypto"; +import { watch } from "node:fs"; +import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; + +const PUBLICATION_LOCK_TIMEOUT_MS = 60_000; +const INCOMPLETE_LOCK_STALE_MS = 5_000; + +type PublicationPhase = "acquired" | "prepared" | "backed-up" | "installed" | "committed"; + +interface OutputPublicationOwner { + readonly finalOutputDir: string; + readonly finalSummaryPath: string; + hadOutput: boolean; + hadSummary: boolean; + readonly outputBackupPath: string; + phase: PublicationPhase; + pid: number; + readonly stagedOutputDir: string; + readonly stagedSummaryPath: string; + readonly startedAt: string; + readonly summaryBackupPath: string; + readonly token: string; +} + +interface RecoveryLeaseOwner { + readonly pid: number; + readonly startedAt: string; + readonly token: string; +} + +export async function publishApplicationBuildArtifacts(input: { + readonly appRoot: string; + readonly finalOutputDir: string; + readonly finalSummaryPath: string; + readonly stagedOutputDir: string; + readonly stagedSummaryPath: string; + readonly onAfterBackup?: () => Promise | void; + readonly onAfterOutputInstall?: () => Promise | void; + readonly onLockContention?: () => Promise | void; +}): Promise { + await Promise.all([stat(input.stagedOutputDir), stat(input.stagedSummaryPath)]); + + const token = randomUUID(); + const lockPath = resolveOutputPublicationLockPath(input.appRoot); + const owner: OutputPublicationOwner = { + finalOutputDir: resolve(input.finalOutputDir), + finalSummaryPath: resolve(input.finalSummaryPath), + hadOutput: false, + hadSummary: false, + outputBackupPath: `${resolve(input.finalOutputDir)}.eve-backup-${token}`, + phase: "acquired", + pid: process.pid, + stagedOutputDir: resolve(input.stagedOutputDir), + stagedSummaryPath: resolve(input.stagedSummaryPath), + startedAt: new Date().toISOString(), + summaryBackupPath: `${resolve(input.finalSummaryPath)}.eve-backup-${token}`, + token, + }; + const release = await acquireOutputPublicationLock({ + lockPath, + onContention: input.onLockContention, + owner, + }); + let committed = false; + + try { + owner.hadOutput = await pathExists(owner.finalOutputDir); + owner.hadSummary = await pathExists(owner.finalSummaryPath); + owner.phase = "prepared"; + await writePublicationOwner(lockPath, owner); + + await Promise.all([ + mkdir(dirname(owner.finalOutputDir), { recursive: true }), + mkdir(dirname(owner.finalSummaryPath), { recursive: true }), + ]); + if (owner.hadOutput) { + await rename(owner.finalOutputDir, owner.outputBackupPath); + } + if (owner.hadSummary) { + await rename(owner.finalSummaryPath, owner.summaryBackupPath); + } + owner.phase = "backed-up"; + await writePublicationOwner(lockPath, owner); + await input.onAfterBackup?.(); + + await rename(owner.stagedOutputDir, owner.finalOutputDir); + await input.onAfterOutputInstall?.(); + await rename(owner.stagedSummaryPath, owner.finalSummaryPath); + owner.phase = "installed"; + await writePublicationOwner(lockPath, owner); + + owner.phase = "committed"; + await writePublicationOwner(lockPath, owner); + committed = true; + } catch (error) { + try { + await rollbackOutputPublication(owner); + } catch (rollbackError) { + owner.pid = 0; + await writePublicationOwner(lockPath, owner).catch(() => undefined); + throw new AggregateError( + [error, rollbackError], + "Build output publication failed and could not fully restore the previous output.", + { cause: error }, + ); + } + throw error; + } finally { + if (committed || owner.pid !== 0) { + await release(); + } + } + + await removePublicationBackups(owner).catch(() => undefined); +} + +export function resolveOutputPublicationLockPath(appRoot: string): string { + return join(resolve(appRoot), ".eve", "locks", "output-publication.lock"); +} + +async function acquireOutputPublicationLock(input: { + readonly lockPath: string; + readonly onContention?: () => Promise | void; + readonly owner: OutputPublicationOwner; +}): Promise<() => Promise> { + const deadline = Date.now() + PUBLICATION_LOCK_TIMEOUT_MS; + const recoveryPath = `${input.lockPath}.recovery`; + await mkdir(dirname(input.lockPath), { recursive: true }); + + for (;;) { + if (await pathExists(recoveryPath)) { + await input.onContention?.(); + if (await recoverStalePublication(input.lockPath, recoveryPath, input.owner)) { + continue; + } + await waitForPublicationLockChange(input.lockPath, deadline); + continue; + } + + try { + await mkdir(input.lockPath); + if (await pathExists(recoveryPath)) { + await rm(input.lockPath, { force: true, recursive: true }); + await waitForPublicationLockChange(input.lockPath, deadline); + continue; + } + try { + await writePublicationOwner(input.lockPath, input.owner); + } catch (error) { + await rm(input.lockPath, { force: true, recursive: true }); + throw error; + } + return async () => { + const currentOwner = await readPublicationOwner(input.lockPath); + if (currentOwner?.token !== input.owner.token) { + return; + } + const releasedPath = `${input.lockPath}.released-${input.owner.token}`; + try { + await rename(input.lockPath, releasedPath); + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + return; + } + throw error; + } + await rm(releasedPath, { force: true, recursive: true }); + }; + } catch (error) { + if (!isNodeErrorWithCode(error, "EEXIST")) { + throw error; + } + } + + await input.onContention?.(); + if (await recoverStalePublication(input.lockPath, recoveryPath, input.owner)) { + continue; + } + await waitForPublicationLockChange(input.lockPath, deadline); + } +} + +async function recoverStalePublication( + lockPath: string, + recoveryPath: string, + recoveryOwner: OutputPublicationOwner, +): Promise { + const releaseRecoveryLease = await acquireRecoveryLease(recoveryPath, recoveryOwner.token); + if (releaseRecoveryLease === undefined) { + return false; + } + + try { + const existingOwner = await readPublicationOwner(lockPath); + if (existingOwner !== undefined && isProcessAlive(existingOwner.pid)) { + return false; + } + if (existingOwner === undefined && !(await isPathStale(lockPath))) { + return false; + } + + if (await pathExists(lockPath)) { + const recoveringOwnerPath = join(recoveryPath, `owner-${recoveryOwner.token}`); + try { + await rename(lockPath, recoveringOwnerPath); + } catch (error) { + if (!isNodeErrorWithCode(error, "ENOENT")) { + throw error; + } + } + } + + const entries = await readdir(recoveryPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith("owner-")) { + continue; + } + await finishInterruptedPublication(join(recoveryPath, entry.name), recoveryOwner); + } + return true; + } finally { + await releaseRecoveryLease(); + } +} + +async function acquireRecoveryLease( + recoveryPath: string, + token: string, +): Promise<(() => Promise) | undefined> { + const leasePath = join(recoveryPath, "lease"); + const leaseOwner: RecoveryLeaseOwner = { + pid: process.pid, + startedAt: new Date().toISOString(), + token, + }; + await mkdir(recoveryPath, { recursive: true }); + + for (;;) { + try { + await mkdir(leasePath); + try { + await writeRecoveryLeaseOwner(leasePath, leaseOwner); + } catch (error) { + await rm(leasePath, { force: true, recursive: true }); + throw error; + } + return async () => { + const currentOwner = await readRecoveryLeaseOwner(leasePath); + if (currentOwner?.token !== token) { + return; + } + const releasedPath = `${recoveryPath}.released-${token}`; + try { + await rename(recoveryPath, releasedPath); + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + return; + } + throw error; + } + await rm(releasedPath, { force: true, recursive: true }); + }; + } catch (error) { + if (!isNodeErrorWithCode(error, "EEXIST")) { + throw error; + } + } + + const currentOwner = await readRecoveryLeaseOwner(leasePath); + if (currentOwner !== undefined && isProcessAlive(currentOwner.pid)) { + return undefined; + } + if (currentOwner === undefined && !(await isPathStale(leasePath))) { + return undefined; + } + + const staleLeasePath = `${leasePath}.stale-${token}`; + try { + await rename(leasePath, staleLeasePath); + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + continue; + } + throw error; + } + await rm(staleLeasePath, { force: true, recursive: true }); + } +} + +async function finishInterruptedPublication( + ownerPath: string, + recoveryOwner: OutputPublicationOwner, +): Promise { + try { + const staleOwner = await readPublicationOwner(ownerPath); + if (staleOwner === undefined) { + return; + } + assertMatchingPublicationTarget(staleOwner, recoveryOwner); + if (staleOwner.phase === "committed") { + await removePublicationBackups(staleOwner); + } else { + await rollbackOutputPublication(staleOwner); + } + } finally { + await rm(ownerPath, { force: true, recursive: true }); + } +} + +function assertMatchingPublicationTarget( + staleOwner: OutputPublicationOwner, + recoveryOwner: OutputPublicationOwner, +): void { + if ( + staleOwner.finalOutputDir !== recoveryOwner.finalOutputDir || + staleOwner.finalSummaryPath !== recoveryOwner.finalSummaryPath || + staleOwner.outputBackupPath !== `${staleOwner.finalOutputDir}.eve-backup-${staleOwner.token}` || + staleOwner.summaryBackupPath !== `${staleOwner.finalSummaryPath}.eve-backup-${staleOwner.token}` + ) { + throw new Error("Refusing to recover a build publication for a different output target."); + } +} + +async function rollbackOutputPublication(owner: OutputPublicationOwner): Promise { + const results = await Promise.allSettled([ + rollbackOneArtifact({ + backupPath: owner.outputBackupPath, + finalPath: owner.finalOutputDir, + hadPrevious: owner.hadOutput, + stagedPath: owner.stagedOutputDir, + }), + rollbackOneArtifact({ + backupPath: owner.summaryBackupPath, + finalPath: owner.finalSummaryPath, + hadPrevious: owner.hadSummary, + stagedPath: owner.stagedSummaryPath, + }), + ]); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length > 0) { + throw new AggregateError(failures, "Failed to restore the previous build publication."); + } +} + +async function rollbackOneArtifact(input: { + readonly backupPath: string; + readonly finalPath: string; + readonly hadPrevious: boolean; + readonly stagedPath: string; +}): Promise { + if (await pathExists(input.backupPath)) { + if (!(await pathExists(input.stagedPath)) && (await pathExists(input.finalPath))) { + await mkdir(dirname(input.stagedPath), { recursive: true }); + await rename(input.finalPath, input.stagedPath); + } else { + await rm(input.finalPath, { force: true, recursive: true }); + } + await rename(input.backupPath, input.finalPath); + return; + } + + if ( + !input.hadPrevious && + !(await pathExists(input.stagedPath)) && + (await pathExists(input.finalPath)) + ) { + await mkdir(dirname(input.stagedPath), { recursive: true }); + await rename(input.finalPath, input.stagedPath); + } +} + +async function removePublicationBackups(owner: OutputPublicationOwner): Promise { + await Promise.all([ + rm(owner.outputBackupPath, { force: true, recursive: true }), + rm(owner.summaryBackupPath, { force: true, recursive: true }), + ]); +} + +async function writePublicationOwner( + lockPath: string, + owner: OutputPublicationOwner, +): Promise { + const ownerPath = join(lockPath, "owner.json"); + const temporaryPath = `${ownerPath}.${owner.token}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(owner, null, 2)}\n`); + await rename(temporaryPath, ownerPath); +} + +async function readPublicationOwner(lockPath: string): Promise { + try { + const value = JSON.parse(await readFile(join(lockPath, "owner.json"), "utf8")) as unknown; + return isOutputPublicationOwner(value) ? value : undefined; + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT") || error instanceof SyntaxError) { + return undefined; + } + throw error; + } +} + +async function writeRecoveryLeaseOwner( + leasePath: string, + owner: RecoveryLeaseOwner, +): Promise { + const ownerPath = join(leasePath, "owner.json"); + const temporaryPath = `${ownerPath}.${owner.token}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(owner, null, 2)}\n`); + await rename(temporaryPath, ownerPath); +} + +async function readRecoveryLeaseOwner(leasePath: string): Promise { + try { + const value = JSON.parse(await readFile(join(leasePath, "owner.json"), "utf8")) as unknown; + return isRecoveryLeaseOwner(value) ? value : undefined; + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT") || error instanceof SyntaxError) { + return undefined; + } + throw error; + } +} + +function isOutputPublicationOwner(value: unknown): value is OutputPublicationOwner { + if (value === null || typeof value !== "object") { + return false; + } + const owner = value as Partial; + return ( + typeof owner.finalOutputDir === "string" && + typeof owner.finalSummaryPath === "string" && + typeof owner.hadOutput === "boolean" && + typeof owner.hadSummary === "boolean" && + typeof owner.outputBackupPath === "string" && + ["acquired", "prepared", "backed-up", "installed", "committed"].includes(owner.phase ?? "") && + typeof owner.pid === "number" && + typeof owner.stagedOutputDir === "string" && + typeof owner.stagedSummaryPath === "string" && + typeof owner.startedAt === "string" && + typeof owner.summaryBackupPath === "string" && + typeof owner.token === "string" + ); +} + +function isRecoveryLeaseOwner(value: unknown): value is RecoveryLeaseOwner { + if (value === null || typeof value !== "object") { + return false; + } + const owner = value as Partial; + return ( + typeof owner.pid === "number" && + typeof owner.startedAt === "string" && + typeof owner.token === "string" + ); +} + +async function waitForPublicationLockChange(lockPath: string, deadline: number): Promise { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + throw new Error( + `Timed out waiting ${PUBLICATION_LOCK_TIMEOUT_MS}ms to publish completed build output.`, + ); + } + + const locksDirectory = dirname(lockPath); + const watchedPrefix = basename(lockPath); + const initialState = await readPublicationLockState(lockPath); + await new Promise((resolvePromise, reject) => { + let settled = false; + const wakeAfterMs = Math.min(remainingMs, INCOMPLETE_LOCK_STALE_MS); + const deadlineTimer = setTimeout(settleResolve, wakeAfterMs); + const watcher = watch(locksDirectory, (eventType, filename) => { + if ( + eventType === "rename" && + (filename === null || filename.toString().startsWith(watchedPrefix)) + ) { + settleResolve(); + } + }); + + function cleanup() { + clearTimeout(deadlineTimer); + watcher.close(); + } + function settleResolve() { + if (settled) { + return; + } + settled = true; + cleanup(); + resolvePromise(); + } + function settleReject(error: unknown) { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(error); + } + + watcher.once("error", settleReject); + void Promise.all([ + readPublicationLockState(lockPath), + pathExists(lockPath), + pathExists(`${lockPath}.recovery`), + ]).then(([nextState, lockExists, recoveryExists]) => { + if ((!lockExists && !recoveryExists) || nextState !== initialState) { + settleResolve(); + } + }, settleReject); + }); +} + +async function readPublicationLockState(lockPath: string): Promise { + const recoveryPath = `${lockPath}.recovery`; + const [lockOwner, recoveryOwner, lockExists, recoveryExists] = await Promise.all([ + readPublicationOwner(lockPath), + readRecoveryLeaseOwner(join(recoveryPath, "lease")), + pathExists(lockPath), + pathExists(recoveryPath), + ]); + return JSON.stringify({ + lockExists, + lockToken: lockOwner?.token, + recoveryExists, + recoveryToken: recoveryOwner?.token, + }); +} + +async function isPathStale(path: string): Promise { + try { + return Date.now() - (await stat(path)).mtimeMs >= INCOMPLETE_LOCK_STALE_MS; + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + return true; + } + throw error; + } +} + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + return false; + } + throw error; + } +} + +function isProcessAlive(pid: number): boolean { + if (pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !isNodeErrorWithCode(error, "ESRCH"); + } +} + +function isNodeErrorWithCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} diff --git a/packages/eve/src/internal/application/production-start-artifacts.ts b/packages/eve/src/internal/application/production-start-artifacts.ts new file mode 100644 index 000000000..2f3789081 --- /dev/null +++ b/packages/eve/src/internal/application/production-start-artifacts.ts @@ -0,0 +1,13 @@ +import { cp, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +export async function stageProductionStartArtifacts(input: { + readonly compilerArtifactsRoot: string; + readonly outputDir: string; +}): Promise { + const sourceDirectory = join(input.compilerArtifactsRoot, "compile"); + const destinationDirectory = join(input.outputDir, ".eve", "compile"); + + await mkdir(dirname(destinationDirectory), { recursive: true }); + await cp(sourceDirectory, destinationDirectory, { recursive: true }); +} diff --git a/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts b/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts index 9af1c8adc..16ab11258 100644 --- a/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts +++ b/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts @@ -57,6 +57,11 @@ const createApplicationNitroMock = vi.fn(); const prepareApplicationHostMock = vi.fn(); const prepareMock = vi.fn(async () => undefined); const prerenderMock = vi.fn(async () => undefined); +const resolveDiscoveryProjectMock = vi.fn(async (appRoot: string) => ({ + agentRoot: join(appRoot, "agent"), + appRoot, + layout: "nested" as const, +})); const runVercelBuildPrewarmMock = vi.fn(async () => undefined); const workflowBuilderBuildVercelOutputMock = vi.fn(async (_options: unknown) => undefined); const workflowBuilderConstructors: unknown[] = []; @@ -76,6 +81,10 @@ vi.mock("./prepare-application-host.js", () => ({ prepareApplicationHost: prepareApplicationHostMock, })); +vi.mock("#discover/project.js", () => ({ + resolveDiscoveryProject: resolveDiscoveryProjectMock, +})); + vi.mock("./vercel-build-prewarm.js", () => ({ runVercelBuildPrewarm: runVercelBuildPrewarmMock, })); @@ -109,6 +118,17 @@ function createPreparedHost(appRoot: string): PreparedApplicationHost { appRoot, compileResult: { manifest, + paths: { + compileDirectoryPath: join( + appRoot, + ".eve", + "builds", + "test", + "compiler", + ".eve", + "compile", + ), + }, project: { agentRoot, appRoot, @@ -158,8 +178,17 @@ describe("buildApplication", () => { const outputDir = join(appRoot, ".output"); const staleOutputPath = join(outputDir, "stale-output.txt"); - prepareApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); - createApplicationNitroMock.mockResolvedValueOnce(createNitroStub(outputDir)); + prepareApplicationHostMock.mockImplementationOnce(async (_rootDir, options) => { + await mkdir(join(options.workspace.compilerArtifactsRoot, "compile"), { recursive: true }); + return createPreparedHost(appRoot); + }); + createApplicationNitroMock.mockImplementationOnce( + async ( + _preparedHost: PreparedApplicationHost, + _dev: boolean, + options: { outputDir?: string } = {}, + ) => createNitroStub(options.outputDir ?? outputDir), + ); await mkdir(outputDir, { recursive: true }); await Promise.all([ writeFile(join(outputDir, "eve-cache.json"), `${JSON.stringify({ eveVersion: "old" })}\n`), @@ -174,6 +203,10 @@ describe("buildApplication", () => { expect(createApplicationNitroMock).toHaveBeenCalledWith( expect.objectContaining({ appRoot }), false, + expect.objectContaining({ + buildDir: expect.stringContaining(join(appRoot, ".eve", "builds")), + outputDir: expect.stringContaining(join(appRoot, ".eve", "builds")), + }), ); await expect(readFile(staleOutputPath, "utf8")).rejects.toThrow(); await expect(readFile(join(outputDir, "eve-cache.json"), "utf8")).resolves.toBe( @@ -196,11 +229,51 @@ describe("buildApplication", () => { expect((summary.agent as { name: string }).name).toBe("scenario-test-agent"); }); + it("keeps the last-good output when Nitro mutates its target before failing", async () => { + vi.stubEnv("VERCEL", ""); + const appRoot = await createScratchDirectory("eve-build-application-last-good-"); + const outputDir = join(appRoot, ".output"); + const summaryPath = join(appRoot, VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH); + prepareApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); + createApplicationNitroMock.mockImplementationOnce( + async ( + _preparedHost: PreparedApplicationHost, + _dev: boolean, + options: { outputDir?: string } = {}, + ) => createNitroStub(options.outputDir ?? outputDir), + ); + buildNitroMock.mockImplementationOnce(async (nitro: Nitro) => { + await mkdir(nitro.options.output.dir, { recursive: true }); + await writeFile(join(nitro.options.output.dir, "marker.txt"), "partial-failed-output\n"); + throw new Error("injected Nitro build failure"); + }); + await Promise.all([ + mkdir(outputDir, { recursive: true }), + mkdir(join(summaryPath, ".."), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(outputDir, "marker.txt"), "last-good-output\n"), + writeFile( + join(outputDir, "eve-cache.json"), + `${JSON.stringify({ eveVersion: resolveInstalledPackageInfo().version }, null, 2)}\n`, + ), + writeFile(summaryPath, "last-good-summary\n"), + ]); + + const { buildApplication } = await import("#internal/nitro/host/build-application.js"); + await expect(buildApplication(appRoot)).rejects.toThrow("injected Nitro build failure"); + + await expect(readFile(join(outputDir, "marker.txt"), "utf8")).resolves.toBe( + "last-good-output\n", + ); + await expect(readFile(summaryPath, "utf8")).resolves.toBe("last-good-summary\n"); + }); + it("builds isolated Vercel Nitro surfaces and stitches workflow functions", async () => { vi.stubEnv("VERCEL", "1"); const appRoot = await createScratchDirectory("eve-build-application-vercel-"); - const flowOutputDir = join(appRoot, ".eve", "nitro-output", "flow"); - const staleFlowOutputPath = join(flowOutputDir, "stale-flow.txt"); + const stableFlowOutputDir = join(appRoot, ".eve", "nitro-output", "flow"); + const staleFlowOutputPath = join(stableFlowOutputDir, "stale-flow.txt"); prepareApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); createApplicationNitroMock.mockImplementation( @@ -209,17 +282,13 @@ describe("buildApplication", () => { _dev: boolean, options: { outputDir?: string; surface?: string } = {}, ) => { - if (options.surface === "app") { - return createNitroStub(join(appRoot, ".vercel", "output")); - } - return createNitroStub(options.outputDir ?? join(appRoot, ".output")); }, ); - await mkdir(flowOutputDir, { recursive: true }); + await mkdir(stableFlowOutputDir, { recursive: true }); await Promise.all([ writeFile( - join(flowOutputDir, "eve-cache.json"), + join(stableFlowOutputDir, "eve-cache.json"), `${JSON.stringify({ eveVersion: "old" })}\n`, ), writeFile(staleFlowOutputPath, "stale\n"), @@ -259,10 +328,14 @@ describe("buildApplication", () => { "app", "flow", ]); + const flowOutputDir = createApplicationNitroMock.mock.calls.find( + (call) => call[2]?.surface === "flow", + )?.[2]?.outputDir; + expect(flowOutputDir).toEqual(expect.stringContaining(join(appRoot, ".eve", "builds"))); expect(workflowBuilderConstructors).toHaveLength(1); expect(workflowBuilderBuildVercelOutputMock).toHaveBeenCalledWith({ flowNitroOutputDir: flowOutputDir, - outputDir: join(appRoot, ".vercel", "output"), + outputDir: expect.stringContaining(join(appRoot, ".eve", "builds")), runtime: "nodejs24.x", }); const nestedFunctionStats = await lstat( @@ -305,11 +378,18 @@ describe("buildApplication", () => { src: "^/eve/v1/session/(?[^/]+)/stream$", }, ]); - await expect(readFile(staleFlowOutputPath, "utf8")).rejects.toThrow(); - expect(runVercelBuildPrewarmMock).toHaveBeenCalledWith({ - appRoot, - log: expect.any(Function), - }); + await expect(readFile(staleFlowOutputPath, "utf8")).resolves.toBe("stale\n"); + expect(runVercelBuildPrewarmMock).toHaveBeenCalledWith( + expect.objectContaining({ + appRoot, + compileDirectoryPath: expect.stringContaining(join(appRoot, ".eve", "builds")), + compiledArtifactsSource: expect.objectContaining({ + kind: "disk", + sandboxAppRoot: appRoot, + }), + log: expect.any(Function), + }), + ); const summary = JSON.parse( await readFile(join(appRoot, VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH), "utf8"), @@ -359,13 +439,7 @@ describe("buildApplication", () => { _preparedHost: PreparedApplicationHost, _dev: boolean, options: { outputDir?: string; surface?: string } = {}, - ) => { - if (options.surface === "app") { - return createNitroStub(join(appRoot, ".vercel", "output")); - } - - return createNitroStub(options.outputDir ?? join(appRoot, ".output")); - }, + ) => createNitroStub(options.outputDir ?? join(appRoot, ".output")), ); await mkdir(flowOutputDir, { recursive: true }); await writeFile( @@ -543,13 +617,7 @@ describe("buildApplication", () => { _preparedHost: PreparedApplicationHost, _dev: boolean, options: { outputDir?: string; surface?: string } = {}, - ) => { - if (options.surface === "app") { - return createNitroStub(join(appRoot, ".vercel", "output")); - } - - return createNitroStub(options.outputDir ?? join(appRoot, ".output")); - }, + ) => createNitroStub(options.outputDir ?? join(appRoot, ".output")), ); await Promise.all([ mkdir(flowOutputDir, { recursive: true }), @@ -601,13 +669,7 @@ describe("buildApplication", () => { _preparedHost: PreparedApplicationHost, _dev: boolean, options: { outputDir?: string; surface?: string } = {}, - ) => { - if (options.surface === "app") { - return createNitroStub(join(appRoot, ".vercel", "output")); - } - - return createNitroStub(options.outputDir ?? join(appRoot, ".output")); - }, + ) => createNitroStub(options.outputDir ?? join(appRoot, ".output")), ); const { buildApplication } = await import("#internal/nitro/host/build-application.js"); diff --git a/packages/eve/src/internal/nitro/host/build-application.ts b/packages/eve/src/internal/nitro/host/build-application.ts index e08118582..ece6f6315 100644 --- a/packages/eve/src/internal/nitro/host/build-application.ts +++ b/packages/eve/src/internal/nitro/host/build-application.ts @@ -4,12 +4,18 @@ import { dirname, join, resolve } from "node:path"; import { build as buildNitro, copyPublicAssets, prepare, prerender } from "nitro/builder"; import type { Nitro } from "nitro/types"; -import { resolvePackageRoot } from "#internal/application/package.js"; +import { resolvePackageRoot, resolvePackageSourceFilePath } from "#internal/application/package.js"; import { prepareEveVersionedCacheDirectory, writeEveVersionedCacheMetadata, } from "#internal/application/cache-metadata.js"; -import { resolveNitroSurfaceOutputDirectory } from "#internal/application/paths.js"; +import { + createApplicationBuildWorkspace, + removeApplicationBuildWorkspace, + type ApplicationBuildWorkspace, +} from "#internal/application/build-workspace.js"; +import { publishApplicationBuildArtifacts } from "#internal/application/output-publication.js"; +import { stageProductionStartArtifacts } from "#internal/application/production-start-artifacts.js"; import { WorkflowBundleBuilder } from "#internal/workflow-bundle/builder.js"; import { normalizeEveVercelFunctionOutput } from "#internal/workflow-bundle/vercel-workflow-output.js"; import { createApplicationNitro } from "#internal/nitro/host/create-application-nitro.js"; @@ -23,6 +29,8 @@ import type { PreparedApplicationHost, } from "#internal/nitro/host/types.js"; import { findClosestVercelOutputDirectory } from "#shared/vercel-output-directory.js"; +import { resolveDiscoveryProject } from "#discover/project.js"; +import { createDiskRuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js"; function trimTrailingSlash(path: string): string { return path.replace(/[\\/]+$/, ""); @@ -278,10 +286,12 @@ async function buildNitroOutput(nitro: Nitro): Promise { async function buildVercelNitroSurface( preparedHost: PreparedApplicationHost, + workspace: ApplicationBuildWorkspace, surface: Exclude, ): Promise { const nitro = await createApplicationNitro(preparedHost, false, { - outputDir: resolveNitroSurfaceOutputDirectory(preparedHost.appRoot, surface), + buildDir: join(workspace.nitroBuildDir, surface), + outputDir: join(workspace.nitroOutputDir, surface), surface, }); @@ -308,21 +318,45 @@ export async function buildApplication( ); } - const preparedHost = await prepareApplicationHost(rootDir); + const project = await resolveDiscoveryProject(rootDir); + const workspace = await createApplicationBuildWorkspace(project.appRoot); + + try { + return await buildApplicationInWorkspace(workspace, options); + } finally { + await removeApplicationBuildWorkspace(workspace); + } +} + +async function buildApplicationInWorkspace( + workspace: ApplicationBuildWorkspace, + options: ApplicationBuildOptions, +): Promise { + const preparedHost = await prepareApplicationHost(workspace.appRoot, { workspace }); if (!process.env.VERCEL) { - const nitro = await createApplicationNitro(preparedHost, false); + const nitro = await createApplicationNitro(preparedHost, false, { + buildDir: workspace.nitroBuildDir, + outputDir: workspace.outputDir, + }); try { - const outputDirectory = await buildNitroOutput(nitro); + await buildNitroOutput(nitro); await emitVercelAgentSummary({ manifest: preparedHost.compileResult.manifest, appRoot: preparedHost.appRoot, + outputPath: workspace.summaryPath, + }); + await stageProductionStartArtifacts({ + compilerArtifactsRoot: workspace.compilerArtifactsRoot, + outputDir: workspace.outputDir, }); - return outputDirectory; } finally { await nitro.close(); } + + await publishCompletedApplicationBuild(workspace); + return workspace.finalOutputDir; } const servicePrefix = await resolveCoDeployedEveServicePrefixForVercelFunctionOutput( @@ -330,43 +364,69 @@ export async function buildApplication( preparedHost.compileResult.project.agentRoot, ); const nitro = await createApplicationNitro(preparedHost, false, { + buildDir: join(workspace.nitroBuildDir, "app"), + outputDir: workspace.outputDir, surface: "app", }); try { - const outputDirectory = await buildNitroOutput(nitro); + await buildNitroOutput(nitro); // Run sandbox prewarm before emitting the workflow functions so a // prewarm failure aborts the build before we spend time bundling // function output that we would never deploy. if (!options.skipVercelSandboxPrewarm) { await runVercelBuildPrewarm({ appRoot: preparedHost.appRoot, + compileDirectoryPath: preparedHost.compileResult.paths.compileDirectoryPath, + compiledArtifactsSource: createDiskRuntimeCompiledArtifactsSource( + workspace.compilerAppRoot, + { + moduleMapLoaderPath: resolvePackageSourceFilePath( + "src/internal/authored-module-map-loader.ts", + ), + sandboxAppRoot: preparedHost.appRoot, + }, + ), log(message) { console.log(message); }, }); } - const flowNitroOutputDir = await buildVercelNitroSurface(preparedHost, "flow"); + const flowNitroOutputDir = await buildVercelNitroSurface(preparedHost, workspace, "flow"); await emitVercelWorkflowFunctions({ agentName: preparedHost.compileResult.manifest.config.name, appRoot: preparedHost.appRoot, compiledArtifactsBootstrapPath: preparedHost.compiledArtifacts.bootstrapPath, flowNitroOutputDir, - outputDir: outputDirectory, + outputDir: workspace.outputDir, workflowBuildDir: preparedHost.workflowBuildDir, }); if (servicePrefix !== undefined) { - await normalizeEveVercelFunctionOutput(outputDirectory, { + await normalizeEveVercelFunctionOutput(workspace.outputDir, { servicePrefix, }); } await emitVercelAgentSummary({ manifest: preparedHost.compileResult.manifest, appRoot: preparedHost.appRoot, + outputPath: workspace.summaryPath, }); - - return outputDirectory; } finally { await nitro.close(); } + + await publishCompletedApplicationBuild(workspace); + return workspace.finalOutputDir; +} + +async function publishCompletedApplicationBuild( + workspace: ApplicationBuildWorkspace, +): Promise { + await publishApplicationBuildArtifacts({ + appRoot: workspace.appRoot, + finalOutputDir: workspace.finalOutputDir, + finalSummaryPath: workspace.finalSummaryPath, + stagedOutputDir: workspace.outputDir, + stagedSummaryPath: workspace.summaryPath, + }); } diff --git a/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.ts b/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.ts index 94bfdb348..9a5ad5e58 100644 --- a/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.ts +++ b/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.ts @@ -96,12 +96,13 @@ export async function emitVercelAgentSummary(input: { manifest: CompiledAgentManifest; appRoot: string; generatorVersion?: string; + outputPath?: string; }): Promise { const summary = buildVercelAgentSummary({ generatorVersion: input.generatorVersion, manifest: input.manifest, }); - const filePath = join(input.appRoot, VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH); + const filePath = input.outputPath ?? join(input.appRoot, VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH); await mkdir(dirname(filePath), { recursive: true }); await writeFile(filePath, `${JSON.stringify(summary, null, 2)}\n`); diff --git a/packages/eve/src/internal/nitro/host/create-application-nitro.ts b/packages/eve/src/internal/nitro/host/create-application-nitro.ts index 22bcbbb24..7f14663e4 100644 --- a/packages/eve/src/internal/nitro/host/create-application-nitro.ts +++ b/packages/eve/src/internal/nitro/host/create-application-nitro.ts @@ -649,13 +649,12 @@ function patchWorkflowTransformExcludePath(nitro: Nitro, workflowBuildDir: strin * hosting of an eve application. * * `surface` narrows the mounted routes for isolated production builds. - * `outputDir` lets callers stage those isolated builds into separate Nitro - * output roots before assembling the final hosted deployment. */ export async function createApplicationNitro( preparedHost: PreparedApplicationHost, dev: boolean, options: { + buildDir?: string; outputDir?: string; surface?: NitroBuildSurface; } = {}, @@ -705,7 +704,8 @@ export async function createApplicationNitro( preparedHost, configuredOptionalEnginePackages, ); - const nitroBuildDir = resolveNitroBuildDirectory(preparedHost.appRoot, surface); + const nitroBuildDir = + options.buildDir ?? resolveNitroBuildDirectory(preparedHost.appRoot, surface); const websocketEnabled = includesApplicationSurface(surface) && (dev || manifestHasWebSocketChannel(preparedHost.compileResult.manifest)); diff --git a/packages/eve/src/internal/nitro/host/prepare-application-host.scenario.test.ts b/packages/eve/src/internal/nitro/host/prepare-application-host.scenario.test.ts index 5bd56e33e..f8905522a 100644 --- a/packages/eve/src/internal/nitro/host/prepare-application-host.scenario.test.ts +++ b/packages/eve/src/internal/nitro/host/prepare-application-host.scenario.test.ts @@ -5,6 +5,10 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { normalizeEsmImportSpecifier } from "#internal/application/import-specifier.js"; +import { + createApplicationBuildWorkspace, + removeApplicationBuildWorkspace, +} from "#internal/application/build-workspace.js"; import { pruneDevelopmentRuntimeArtifactsSnapshots, resolveDevelopmentRuntimeArtifactsPointerPath, @@ -51,6 +55,35 @@ describe("prepareApplicationHost", () => { expect(workflowWorldPlugin).not.toContain("/compiled/@workflow/world-local/index.js"); }); + it("keeps production compiler and host writes inside one invocation workspace", async () => { + const { agentRoot, appRoot } = await createAppRoot("eve-production-host-workspace-", { + files: { + "agent/instructions.md": "Use the configured model.", + }, + packageName: "production-host-workspace", + }); + await writeFile(join(agentRoot, "agent.mjs"), 'export default { model: "openai/gpt-5.4" };\n'); + const workspace = await createApplicationBuildWorkspace(appRoot); + + try { + const preparedHost = await prepareApplicationHost(appRoot, { workspace }); + + expect(preparedHost.compileResult.paths.compileDirectoryPath).toBe( + join(workspace.compilerArtifactsRoot, "compile"), + ); + expect(preparedHost.compiledArtifacts.bootstrapPath).toBe( + join(workspace.hostArtifactsDir, "compiled-artifacts-bootstrap.mjs"), + ); + expect(preparedHost.workflowBuildDir).toBe(workspace.workflowBuildDir); + expect(existsSync(join(appRoot, ".eve", "compile"))).toBe(false); + expect(existsSync(join(appRoot, ".eve", "host"))).toBe(false); + } finally { + await removeApplicationBuildWorkspace(workspace); + } + + expect(existsSync(workspace.rootDir)).toBe(false); + }); + it("keeps Nitro host inputs stable when their runtime snapshot is pruned", async () => { const { agentRoot, appRoot } = await createAppRoot("eve-stable-dev-host-artifacts-", { files: { diff --git a/packages/eve/src/internal/nitro/host/prepare-application-host.ts b/packages/eve/src/internal/nitro/host/prepare-application-host.ts index f29e3fff2..4af0c1af9 100644 --- a/packages/eve/src/internal/nitro/host/prepare-application-host.ts +++ b/packages/eve/src/internal/nitro/host/prepare-application-host.ts @@ -1,6 +1,10 @@ import { compileAgent } from "#compiler/compile-agent.js"; import { createScheduleRegistrations } from "#runtime/schedules/register.js"; -import { loadResolvedCompiledSchedules } from "#runtime/schedules/resolve-schedule.js"; +import { + loadResolvedCompiledSchedules, + resolveSchedules, +} from "#runtime/schedules/resolve-schedule.js"; +import type { ApplicationBuildWorkspace } from "#internal/application/build-workspace.js"; import { type BuiltInWorkflowWorldTarget, writeCompiledArtifactsFiles, @@ -24,18 +28,29 @@ export async function prepareApplicationHost( startPath: string, options: { readonly dev?: boolean; + readonly workspace?: Pick< + ApplicationBuildWorkspace, + "compilerArtifactsRoot" | "hostArtifactsDir" | "workflowBuildDir" + >; } = {}, ): Promise { const compileResult = await compileAgent({ + artifactsRoot: options.workspace?.compilerArtifactsRoot, + includeDiagnosticsArtifactPath: options.workspace === undefined, startPath, }); - const schedules = await loadResolvedCompiledSchedules({ - compiledArtifactsSource: createAuthoredSourceRuntimeCompiledArtifactsSource( - compileResult.project.appRoot, - ), - }); + const schedules = + options.workspace === undefined + ? await loadResolvedCompiledSchedules({ + compiledArtifactsSource: createAuthoredSourceRuntimeCompiledArtifactsSource( + compileResult.project.appRoot, + ), + }) + : await resolveSchedules({ manifest: compileResult.manifest }); const scheduleRegistrations = createScheduleRegistrations(schedules); - const workflowBuildDir = resolveWorkflowBuildDirectory(compileResult.project.appRoot); + const workflowBuildDir = + options.workspace?.workflowBuildDir ?? + resolveWorkflowBuildDirectory(compileResult.project.appRoot); const runtimeArtifactsSnapshot = options.dev === true ? await stageDevelopmentRuntimeArtifactsSnapshot(compileResult) @@ -43,7 +58,9 @@ export async function prepareApplicationHost( const compiledArtifacts = await writeCompiledArtifactsFiles({ compileResult, defaultWorkflowWorld: resolveDefaultWorkflowWorld(options), - outDir: resolveApplicationHostArtifactsDirectory(compileResult.project.appRoot), + outDir: + options.workspace?.hostArtifactsDir ?? + resolveApplicationHostArtifactsDirectory(compileResult.project.appRoot), }); if (runtimeArtifactsSnapshot !== undefined) { await activateDevelopmentRuntimeArtifactsSnapshot({ diff --git a/packages/eve/test/scenarios/bin-build-output.scenario.test.ts b/packages/eve/test/scenarios/bin-build-output.scenario.test.ts index f15107cea..e4741d0cd 100644 --- a/packages/eve/test/scenarios/bin-build-output.scenario.test.ts +++ b/packages/eve/test/scenarios/bin-build-output.scenario.test.ts @@ -187,9 +187,8 @@ describe("eve build process output", () => { expect(result.signal).toBeNull(); expect(result.stdout).toBe(""); expect(result.stderr).toContain("Discovery failed with 1 error(s) and 0 warning(s)."); - expect(result.stderr).toContain( - `Diagnostics artifact: ${join(resolvedAppRoot, ".eve", "discovery", "diagnostics.json")}`, - ); + expect(result.stderr).not.toContain("Diagnostics artifact:"); + expect(result.stderr).not.toContain(`${join(resolvedAppRoot, ".eve", "builds")}/`); expect(result.stderr).toContain("Discovery diagnostics:"); expect(result.stderr).toContain( 'Expected authored instructions at "instructions.md", "instructions.ts", "instructions.cts", "instructions.mts", "instructions.js", "instructions.cjs", "instructions.mjs", or "instructions/" directory.', diff --git a/packages/eve/test/scenarios/cli.scenario.test.ts b/packages/eve/test/scenarios/cli.scenario.test.ts index b594c5b38..20b8d081a 100644 --- a/packages/eve/test/scenarios/cli.scenario.test.ts +++ b/packages/eve/test/scenarios/cli.scenario.test.ts @@ -460,9 +460,8 @@ describe("runCli", () => { } expect(thrownError.message).toContain("Discovery failed with 1 error(s) and 0 warning(s)."); - expect(thrownError.message).toContain( - `Diagnostics artifact: ${join(resolvedWorkspaceRoot, ".eve", "discovery", "diagnostics.json")}`, - ); + expect(thrownError.message).not.toContain("Diagnostics artifact:"); + expect(thrownError.message).not.toContain(`${join(resolvedWorkspaceRoot, ".eve", "builds")}/`); expect(thrownError.message).toContain("Discovery diagnostics:"); expect(thrownError.message).toContain( 'Expected authored instructions at "instructions.md", "instructions.ts", "instructions.cts", "instructions.mts", "instructions.js", "instructions.cjs", "instructions.mjs", or "instructions/" directory.', diff --git a/packages/eve/test/scenarios/production-build-isolation.scenario.test.ts b/packages/eve/test/scenarios/production-build-isolation.scenario.test.ts new file mode 100644 index 000000000..199a78af3 --- /dev/null +++ b/packages/eve/test/scenarios/production-build-isolation.scenario.test.ts @@ -0,0 +1,500 @@ +import { spawn, type ChildProcessByStdio } from "node:child_process"; +import { createHash } from "node:crypto"; +import { watch, type FSWatcher } from "node:fs"; +import { lstat, mkdir, readFile, readdir, readlink, realpath, writeFile } from "node:fs/promises"; +import { join, relative } from "node:path"; +import type { Readable } from "node:stream"; + +import { describe, expect, it } from "vitest"; + +import { EVE_HEALTH_ROUTE_PATH } from "../../src/protocol/routes.js"; +import { WEATHER_AGENT_DESCRIPTOR } from "../../src/internal/testing/scenario-apps/weather-agent.js"; +import { useScenarioApp } from "../../src/internal/testing/scenario-app.js"; + +const scenarioApp = useScenarioApp(); +const PROCESS_DEADLINE_MS = 180_000; +const SCENARIO_DEADLINE_MS = 360_000; + +interface ProcessResult { + readonly code: number | null; + readonly signal: NodeJS.Signals | null; + readonly stderr: string; + readonly stdout: string; +} + +interface RunningProcess { + readonly child: ChildProcessByStdio; + readonly result: Promise; + readonly stderr: () => string; + readonly stdout: () => string; + stop(): Promise; +} + +interface RunningDevServer extends RunningProcess { + readonly url: string; +} + +function startEveProcess(input: { + readonly appRoot: string; + readonly args: readonly string[]; +}): RunningProcess { + const eveBinPath = join(input.appRoot, "node_modules", "eve", "bin", "eve.js"); + const child = spawn(process.execPath, [eveBinPath, ...input.args], { + cwd: input.appRoot, + env: { + ...process.env, + NODE_ENV: "test", + VERCEL: "", + }, + 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 result = new Promise((resolvePromise, reject) => { + const deadline = setTimeout(() => { + child.kill("SIGKILL"); + reject( + new Error( + [ + `Timed out waiting for eve ${input.args.join(" ")}.`, + `stdout:\n${stdout}`, + `stderr:\n${stderr}`, + ].join("\n\n"), + ), + ); + }, PROCESS_DEADLINE_MS); + + child.once("error", (error) => { + clearTimeout(deadline); + reject(error); + }); + child.once("exit", (code, signal) => { + clearTimeout(deadline); + resolvePromise({ code, signal, stderr, stdout }); + }); + }); + + return { + child, + result, + stderr: () => stderr, + stdout: () => stdout, + async stop() { + if (child.exitCode !== null || child.signalCode !== null) { + await result.catch(() => undefined); + return; + } + + await new Promise((resolvePromise) => { + const deadline = setTimeout(() => { + child.kill("SIGKILL"); + resolvePromise(); + }, 10_000); + child.once("exit", () => { + clearTimeout(deadline); + resolvePromise(); + }); + child.kill("SIGTERM"); + }); + await result.catch(() => undefined); + }, + }; +} + +function stripAnsi(text: string): string { + return text + .split("\u001b[") + .map((segment, index) => (index === 0 ? segment : segment.replace(/^[0-9;]*m/, ""))) + .join(""); +} + +function parseServerUrl(stdout: string): string | undefined { + return /server listening at (https?:\/\/\S+)/.exec(stripAnsi(stdout))?.[1]; +} + +async function startEveDev(appRoot: string): Promise { + const processHandle = startEveProcess({ + appRoot, + args: ["dev", "--no-ui", "--host", "127.0.0.1", "--port", "0"], + }); + const url = await new Promise((resolvePromise, reject) => { + let settled = false; + const deadline = setTimeout(() => { + settleReject( + new Error( + [ + "Timed out waiting for eve dev readiness.", + `stdout:\n${processHandle.stdout()}`, + `stderr:\n${processHandle.stderr()}`, + ].join("\n\n"), + ), + ); + }, 120_000); + + const cleanup = () => { + clearTimeout(deadline); + processHandle.child.stdout.off("data", checkOutput); + processHandle.child.stderr.off("data", checkOutput); + processHandle.child.off("exit", handleExit); + }; + const settleResolve = (serverUrl: string) => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolvePromise(serverUrl); + }; + function settleReject(error: unknown) { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(error); + } + function checkOutput() { + const serverUrl = parseServerUrl(processHandle.stdout()); + if (serverUrl !== undefined) { + settleResolve(serverUrl); + } + } + function handleExit(code: number | null, signal: NodeJS.Signals | null) { + settleReject( + new Error( + [ + `eve dev exited before readiness (code ${String(code)}, signal ${String(signal)}).`, + `stdout:\n${processHandle.stdout()}`, + `stderr:\n${processHandle.stderr()}`, + ].join("\n\n"), + ), + ); + } + + processHandle.child.stdout.on("data", checkOutput); + processHandle.child.stderr.on("data", checkOutput); + processHandle.child.once("exit", handleExit); + checkOutput(); + }).catch(async (error: unknown) => { + await processHandle.stop(); + throw error; + }); + + return { ...processHandle, url }; +} + +async function expectHealthy(server: RunningDevServer): Promise { + const response = await fetch(new URL(EVE_HEALTH_ROUTE_PATH, server.url), { + signal: AbortSignal.timeout(5_000), + }); + const body = await response.text(); + expect( + response.status, + [ + `Expected ${EVE_HEALTH_ROUTE_PATH} to remain healthy.`, + `response:\n${body}`, + `stdout:\n${server.stdout()}`, + `stderr:\n${server.stderr()}`, + ].join("\n\n"), + ).toBe(200); +} + +async function observeConcurrentBuildWorkspaces(input: { + readonly appRoot: string; + readonly builds: readonly RunningProcess[]; +}): Promise { + const buildsRoot = join(input.appRoot, ".eve", "builds"); + await mkdir(buildsRoot, { recursive: true }); + + return await new Promise((resolvePromise, reject) => { + let settled = false; + let checking = false; + const deadline = setTimeout(() => { + settleReject( + new Error( + [ + "Timed out waiting for two invocation-owned build workspaces.", + ...input.builds.flatMap((build, index) => [ + `build ${index + 1} stdout:\n${build.stdout()}`, + `build ${index + 1} stderr:\n${build.stderr()}`, + ]), + ].join("\n\n"), + ), + ); + }, 120_000); + const watcher = watch(buildsRoot, () => { + void check(); + }); + const cleanup = () => { + clearTimeout(deadline); + watcher.close(); + for (const build of input.builds) { + build.child.off("exit", checkAfterExit); + } + }; + const settleResolve = (workspaces: readonly string[]) => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolvePromise(workspaces); + }; + function settleReject(error: unknown) { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(error); + } + async function check() { + if (settled || checking) { + return; + } + checking = true; + try { + const workspaces = ( + await readdir(buildsRoot, { + withFileTypes: true, + }) + ) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + if (workspaces.length >= 2) { + settleResolve(workspaces); + return; + } + if ( + input.builds.every( + (build) => build.child.exitCode !== null || build.child.signalCode !== null, + ) + ) { + settleReject( + new Error( + [ + `Both builds exited before two workspaces overlapped; observed ${workspaces.length}.`, + ...input.builds.flatMap((build, index) => [ + `build ${index + 1} stdout:\n${build.stdout()}`, + `build ${index + 1} stderr:\n${build.stderr()}`, + ]), + ].join("\n\n"), + ), + ); + } + } catch (error) { + settleReject(error); + } finally { + checking = false; + } + } + function checkAfterExit() { + void check(); + } + + for (const build of input.builds) { + build.child.on("exit", checkAfterExit); + } + void check(); + }); +} + +async function watchStableDevBuildSurfaces(appRoot: string): Promise<{ + readonly events: readonly string[]; + close(): void; +}> { + const installedEveRoot = await realpath(join(appRoot, "node_modules", "eve")); + const candidates = [ + join(appRoot, ".eve", "compile"), + join(appRoot, ".eve", "host"), + join(appRoot, ".eve", "nitro"), + join(installedEveRoot, ".eve", "workflow-cache"), + ]; + const paths: string[] = []; + const missingPaths: string[] = []; + for (const path of candidates) { + try { + if ((await lstat(path)).isDirectory()) { + paths.push(path); + } + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + missingPaths.push(path); + } else { + throw error; + } + } + } + if (missingPaths.length > 0) { + throw new Error( + `Expected eve dev to prepare every stable build surface before readiness:\n${missingPaths.join("\n")}`, + ); + } + const events: string[] = []; + const watchers: FSWatcher[] = paths.map((path) => + watch(path, { recursive: true }, (eventType, filename) => { + events.push(`${relative(appRoot, path)}:${eventType}:${filename?.toString() ?? ""}`); + }), + ); + + return { + events, + close() { + for (const watcher of watchers) { + watcher.close(); + } + }, + }; +} + +async function hashPath(path: string): Promise { + const hash = createHash("sha256"); + + async function visit(currentPath: string, logicalPath: string): Promise { + const stats = await lstat(currentPath); + if (stats.isSymbolicLink()) { + hash.update(`link:${logicalPath}:${await readlink(currentPath)}\0`); + return; + } + if (stats.isDirectory()) { + hash.update(`directory:${logicalPath}\0`); + const entries = await readdir(currentPath); + entries.sort(); + for (const entry of entries) { + await visit(join(currentPath, entry), join(logicalPath, entry)); + } + return; + } + hash.update(`file:${logicalPath}:${stats.mode}\0`); + hash.update(await readFile(currentPath)); + hash.update("\0"); + } + + await visit(path, "."); + return hash.digest("hex"); +} + +async function listBuildWorkspaces(appRoot: string): Promise { + try { + return await readdir(join(appRoot, ".eve", "builds")); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return []; + } + throw error; + } +} + +describe("production build isolation", () => { + it( + "keeps a real dev server healthy while two real builds overlap in invocation-owned workspaces", + async () => { + const app = await scenarioApp(WEATHER_AGENT_DESCRIPTOR); + const server = await startEveDev(app.appRoot); + const processes: RunningProcess[] = []; + let continueProbing = false; + let healthProbe: Promise | undefined; + let stableSurfaceWatcher: Awaited> | undefined; + + try { + await expectHealthy(server); + stableSurfaceWatcher = await watchStableDevBuildSurfaces(app.appRoot); + const builds = [ + startEveProcess({ appRoot: app.appRoot, args: ["build"] }), + startEveProcess({ appRoot: app.appRoot, args: ["build"] }), + ]; + processes.push(...builds); + continueProbing = true; + healthProbe = (async () => { + let healthChecks = 0; + while (continueProbing) { + await expectHealthy(server); + healthChecks += 1; + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + } + return healthChecks; + })(); + + const [workspaces, results] = await Promise.race([ + Promise.all([ + observeConcurrentBuildWorkspaces({ + appRoot: app.appRoot, + builds, + }), + Promise.all(builds.map((build) => build.result)), + ]), + healthProbe.then(() => { + throw new Error("Health probing stopped before the builds completed."); + }), + ]); + continueProbing = false; + const healthChecks = await healthProbe; + stableSurfaceWatcher.close(); + + expect(workspaces).toHaveLength(2); + expect(results.map((result) => result.code)).toEqual([0, 0]); + expect(results.map((result) => result.signal)).toEqual([null, null]); + expect(healthChecks).toBeGreaterThan(0); + expect(stableSurfaceWatcher.events).toEqual([]); + expect(await listBuildWorkspaces(app.appRoot)).toEqual([]); + await expectHealthy(server); + } finally { + continueProbing = false; + await healthProbe?.catch(() => undefined); + stableSurfaceWatcher?.close(); + await Promise.all(processes.map((processHandle) => processHandle.stop())); + await server.stop(); + } + }, + SCENARIO_DEADLINE_MS, + ); + + it( + "preserves the byte-for-byte last-good output when a later real bundle fails", + async () => { + const app = await scenarioApp(WEATHER_AGENT_DESCRIPTOR); + const successfulBuild = startEveProcess({ appRoot: app.appRoot, args: ["build"] }); + const successfulResult = await successfulBuild.result; + expect(successfulResult.code).toBe(0); + expect(successfulResult.signal).toBeNull(); + + const outputDir = join(app.appRoot, ".output"); + const summaryPath = join(app.appRoot, ".eve", "agent-summary.json"); + const lastGoodOutputHash = await hashPath(outputDir); + const lastGoodSummaryHash = await hashPath(summaryPath); + await writeFile( + join(app.appRoot, "agent", "tools", "broken.ts"), + [ + 'import { missing } from "./does-not-exist";', + "export default {", + ' description: "Force the production bundle to fail.",', + ' inputSchema: { type: "object", properties: {}, required: [] },', + " execute: async () => missing,", + "};", + "", + ].join("\n"), + ); + + const failedBuild = startEveProcess({ appRoot: app.appRoot, args: ["build"] }); + const failedResult = await failedBuild.result; + + expect(failedResult.code).toBe(1); + expect(failedResult.signal).toBeNull(); + expect(failedResult.stderr).toContain("does-not-exist"); + expect(await hashPath(outputDir)).toBe(lastGoodOutputHash); + expect(await hashPath(summaryPath)).toBe(lastGoodSummaryHash); + expect(await listBuildWorkspaces(app.appRoot)).toEqual([]); + }, + SCENARIO_DEADLINE_MS, + ); +}); From 2297925a53665303e212c08c951a33fb30e5267d Mon Sep 17 00:00:00 2001 From: Casey Gowrie Date: Mon, 13 Jul 2026 09:39:26 -0400 Subject: [PATCH 02/12] refactor(eve): make build ownership explicit Signed-off-by: Casey Gowrie --- packages/eve/src/compiler/artifacts.ts | 18 +-- packages/eve/src/compiler/compile-agent.ts | 118 ++++++++++++------ .../sandbox/prewarm.scenario.test.ts | 11 +- packages/eve/src/execution/sandbox/prewarm.ts | 13 +- .../dev-runtime-artifacts.integration.test.ts | 4 +- .../internal/nitro/host/artifacts-config.ts | 42 ++++--- .../host/build-application.scenario.test.ts | 23 ++-- .../internal/nitro/host/build-application.ts | 7 +- .../nitro/host/build-vercel-agent-summary.ts | 20 +-- .../nitro/host/channel-routes.test.ts | 7 +- .../nitro/host/configure-nitro-routes.ts | 13 +- .../nitro/host/create-application-nitro.ts | 5 +- ...v-authored-source-watcher.scenario.test.ts | 38 +++--- .../nitro/host/dev-authored-source-watcher.ts | 15 +-- ...ev-environment-watcher.integration.test.ts | 8 +- .../nitro/host/dispatch-schedule-in-dev.ts | 5 +- .../prepare-application-host.scenario.test.ts | 61 +++++---- .../nitro/host/prepare-application-host.ts | 118 ++++++++++-------- .../host/start-development-server.test.ts | 6 +- .../nitro/host/start-development-server.ts | 9 +- .../agent-info-route.scenario.test.ts | 4 +- .../scenarios/compile-agent.scenario.test.ts | 2 + 22 files changed, 296 insertions(+), 251 deletions(-) diff --git a/packages/eve/src/compiler/artifacts.ts b/packages/eve/src/compiler/artifacts.ts index f9d787da6..6b1c2c3ee 100644 --- a/packages/eve/src/compiler/artifacts.ts +++ b/packages/eve/src/compiler/artifacts.ts @@ -91,7 +91,7 @@ export interface CompileMetadata { */ interface WriteCompilerArtifactsInput { appRoot: string; - artifactsRoot?: string; + artifactsRoot: string; diagnostics: readonly DiscoverDiagnostic[]; manifest: AgentSourceManifest; } @@ -107,10 +107,14 @@ interface WriteCompilerArtifactsResult { paths: CompilerArtifactPaths; } -/** Resolves compiler-owned artifact paths for one application root. */ -export function resolveCompilerArtifactPaths( +/** Resolves stable compiler-owned artifact paths for one application root. */ +export function resolveCompilerArtifactPaths(appRoot: string): CompilerArtifactPaths { + return resolveCompilerArtifactPathsAt(appRoot, join(resolve(appRoot), ".eve")); +} + +function resolveCompilerArtifactPathsAt( appRoot: string, - artifactsRoot: string = join(resolve(appRoot), ".eve"), + artifactsRoot: string, ): CompilerArtifactPaths { const resolvedAppRoot = resolve(appRoot); const resolvedArtifactsRoot = resolve(artifactsRoot); @@ -189,13 +193,11 @@ export function createCompileMetadata(input: { }; } -/** - * Writes the compiler-owned discovery artifacts under `.eve/`. - */ +/** Writes compiler-owned discovery artifacts to the specified artifact root. */ export async function writeCompilerArtifacts( input: WriteCompilerArtifactsInput, ): Promise { - const paths = resolveCompilerArtifactPaths(input.appRoot, input.artifactsRoot); + const paths = resolveCompilerArtifactPathsAt(input.appRoot, input.artifactsRoot); const diagnosticsArtifact = createDiscoveryDiagnosticsArtifact(input.diagnostics); const compiledManifest = await materializeWorkspaceResources({ compileDirectoryPath: paths.compileDirectoryPath, diff --git a/packages/eve/src/compiler/compile-agent.ts b/packages/eve/src/compiler/compile-agent.ts index 79cce9c0f..68f325ea6 100644 --- a/packages/eve/src/compiler/compile-agent.ts +++ b/packages/eve/src/compiler/compile-agent.ts @@ -1,9 +1,12 @@ +import { join } from "node:path"; + import type { DiscoverDiagnostic } from "#discover/diagnostics.js"; import { hasDiscoverErrors, summarizeDiscoverDiagnostics } from "#discover/diagnostics.js"; import { discoverAgent } from "#discover/discover-agent.js"; import type { ResolvedDiscoveryProject } from "#discover/project.js"; import { resolveDiscoveryProject } from "#discover/project.js"; import { createDiskProjectSource, type ProjectSource } from "#discover/project-source.js"; +import type { AgentSourceManifest } from "#discover/manifest.js"; import { type CompileMetadata, type CompilerArtifactPaths, @@ -16,8 +19,6 @@ import type { CompiledAgentManifest } from "#compiler/manifest.js"; * discovery artifacts. */ export interface CompileAgentInput { - artifactsRoot?: string; - includeDiagnosticsArtifactPath?: boolean; /** * Optional {@link ProjectSource} used for discovery reads. Defaults to a * disk-backed source so production callers keep their current behaviour. @@ -45,22 +46,26 @@ export interface CompileAgentResult { export class CompileAgentError extends Error { readonly result: CompileAgentResult; - constructor( - result: CompileAgentResult, - options: { readonly includeDiagnosticsArtifactPath?: boolean } = {}, - ) { - super( - formatCompileAgentErrorMessage({ - diagnostics: result.diagnostics, - diagnosticsPath: - options.includeDiagnosticsArtifactPath === false - ? undefined - : result.paths.diagnosticsPath, - }), - ); + private constructor(result: CompileAgentResult, message: string) { + super(message); this.name = "CompileAgentError"; this.result = result; } + + static fromDurableArtifacts(result: CompileAgentResult): CompileAgentError { + const [summary, ...diagnostics] = formatCompileAgentErrorLines(result.diagnostics); + return new CompileAgentError( + result, + [summary, `Diagnostics artifact: ${result.paths.diagnosticsPath}`, ...diagnostics].join("\n"), + ); + } + + static fromTransientArtifacts(result: CompileAgentResult): CompileAgentError { + return new CompileAgentError( + result, + formatCompileAgentErrorLines(result.diagnostics).join("\n"), + ); + } } /** @@ -68,30 +73,72 @@ export class CompileAgentError extends Error { * produced errors. */ export async function compileAgent(input: CompileAgentInput = {}): Promise { + const discovered = await discoverAgentForCompilation(input); + const result = await writeAgentCompilation(discovered, join(discovered.project.appRoot, ".eve")); + + return finishAgentCompilation(result, CompileAgentError.fromDurableArtifacts); +} + +/** Compiles one app entirely within an invocation-owned production build workspace. */ +export async function compileAgentInBuildWorkspace(input: { + readonly compilerArtifactsRoot: string; + readonly startPath: string; +}): Promise { + const discovered = await discoverAgentForCompilation({ startPath: input.startPath }); + const result = await writeAgentCompilation(discovered, input.compilerArtifactsRoot); + + return finishAgentCompilation(result, CompileAgentError.fromTransientArtifacts); +} + +interface DiscoveredAgentCompilation { + readonly diagnostics: DiscoverDiagnostic[]; + readonly manifest: AgentSourceManifest; + readonly project: ResolvedDiscoveryProject; +} + +async function discoverAgentForCompilation( + input: CompileAgentInput, +): Promise { const source = input.source ?? createDiskProjectSource(); const project = await resolveDiscoveryProject(input.startPath, { source }); const discoveryResult = await discoverAgent({ ...project, source }); - const writtenArtifacts = await writeCompilerArtifacts({ - appRoot: project.appRoot, - artifactsRoot: input.artifactsRoot, + + return { diagnostics: discoveryResult.diagnostics, manifest: discoveryResult.manifest, + project, + }; +} + +async function writeAgentCompilation( + discovered: DiscoveredAgentCompilation, + artifactsRoot: string, +): Promise { + const writtenArtifacts = await writeCompilerArtifacts({ + appRoot: discovered.project.appRoot, + artifactsRoot, + diagnostics: discovered.diagnostics, + manifest: discovered.manifest, }); - const result: CompileAgentResult = { - diagnostics: discoveryResult.diagnostics, + + return { + diagnostics: discovered.diagnostics, manifest: writtenArtifacts.compiledManifest, metadata: writtenArtifacts.metadata, paths: writtenArtifacts.paths, - project, + project: discovered.project, }; +} - if (hasDiscoverErrors(discoveryResult.diagnostics)) { - throw new CompileAgentError(result, { - includeDiagnosticsArtifactPath: input.includeDiagnosticsArtifactPath, - }); +function finishAgentCompilation( + result: CompileAgentResult, + createError: (result: CompileAgentResult) => CompileAgentError, +): CompileAgentResult { + if (hasDiscoverErrors(result.diagnostics)) { + throw createError(result); } - reportDiscoverWarnings(discoveryResult.diagnostics); + reportDiscoverWarnings(result.diagnostics); return result; } @@ -108,31 +155,24 @@ function reportDiscoverWarnings(diagnostics: readonly DiscoverDiagnostic[]): voi } } -function formatCompileAgentErrorMessage(input: { - diagnostics: readonly DiscoverDiagnostic[]; - diagnosticsPath?: string; -}): string { - const summary = summarizeDiscoverDiagnostics(input.diagnostics); +function formatCompileAgentErrorLines(diagnostics: readonly DiscoverDiagnostic[]): string[] { + const summary = summarizeDiscoverDiagnostics(diagnostics); const lines: string[] = [ `Discovery failed with ${summary.errors} error(s) and ${summary.warnings} warning(s).`, ]; - if (input.diagnosticsPath !== undefined) { - lines.push(`Diagnostics artifact: ${input.diagnosticsPath}`); - } - - if (input.diagnostics.length === 0) { - return lines.join("\n"); + if (diagnostics.length === 0) { + return lines; } lines.push("Discovery diagnostics:"); - for (const diagnostic of input.diagnostics) { + for (const diagnostic of diagnostics) { lines.push(`- ${formatDiagnosticSeverity(diagnostic.severity)}: ${diagnostic.message}`); lines.push(` source: ${diagnostic.sourcePath}`); } - return lines.join("\n"); + return lines; } function formatDiagnosticSeverity(severity: DiscoverDiagnostic["severity"]): string { diff --git a/packages/eve/src/execution/sandbox/prewarm.scenario.test.ts b/packages/eve/src/execution/sandbox/prewarm.scenario.test.ts index e50e41b70..3527c7878 100644 --- a/packages/eve/src/execution/sandbox/prewarm.scenario.test.ts +++ b/packages/eve/src/execution/sandbox/prewarm.scenario.test.ts @@ -3,9 +3,9 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { compileAgent } from "#compiler/compile-agent.js"; +import { compileAgent, compileAgentInBuildWorkspace } from "#compiler/compile-agent.js"; import { resolvePackageSourceFilePath } from "#internal/application/package.js"; -import { createNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; +import { createDevelopmentNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; import { publishDevelopmentRuntimeArtifactsSnapshot } from "#internal/nitro/dev-runtime-artifacts.js"; import { resolveNitroCompiledArtifactsSource } from "#internal/nitro/routes/runtime-artifacts.js"; import { useTemporaryDirectories } from "#internal/testing/use-temporary-app-roots.js"; @@ -30,15 +30,14 @@ describe("prewarmAppSandboxes", () => { const appRoot = await createScenarioAppRoot(); const compilerAppRoot = join(appRoot, ".eve", "builds", "isolated", "compiler"); - const compileResult = await compileAgent({ - artifactsRoot: join(compilerAppRoot, ".eve"), + await compileAgentInBuildWorkspace({ + compilerArtifactsRoot: join(compilerAppRoot, ".eve"), startPath: appRoot, }); const events = createPrewarmEvents(); await prewarmAppSandboxes({ appRoot, - compileDirectoryPath: compileResult.paths.compileDirectoryPath, compiledArtifactsSource: createDiskRuntimeCompiledArtifactsSource(compilerAppRoot, { moduleMapLoaderPath: resolvePackageSourceFilePath( "src/internal/authored-module-map-loader.ts", @@ -102,7 +101,7 @@ describe("prewarmAppSandboxes", () => { await prewarmAppSandboxes({ appRoot, compiledArtifactsSource: resolveNitroCompiledArtifactsSource( - createNitroArtifactsConfig({ appRoot, dev: true }), + createDevelopmentNitroArtifactsConfig({ appRoot }), ), dispatch: createRecordingDispatch(events), }); diff --git a/packages/eve/src/execution/sandbox/prewarm.ts b/packages/eve/src/execution/sandbox/prewarm.ts index ddce490e0..5cc63bb6d 100644 --- a/packages/eve/src/execution/sandbox/prewarm.ts +++ b/packages/eve/src/execution/sandbox/prewarm.ts @@ -55,7 +55,7 @@ export type SandboxBackendPrewarmDispatch = (input: { interface PrewarmSandboxesInput { readonly appRoot: string; - readonly compileDirectoryPath?: string; + readonly compileDirectoryPath: string; readonly compiledArtifactsSource: RuntimeCompiledArtifactsSource; readonly graph: ResolvedAgentGraphBundle; readonly log?: (message: string) => void; @@ -150,7 +150,6 @@ export async function prewarmSandboxes(input: PrewarmSandboxesInput): Promise { - const compileDirectoryPath = - input.compileDirectoryPath ?? - resolveRuntimeCompilerArtifactPaths(input.appRoot).compileDirectoryPath; const runtimeContext = { appRoot: input.appRoot }; const targets: PrewarmTarget[] = []; @@ -274,7 +271,7 @@ async function collectPrewarmTargets(input: { input: { bootstrap: definition.bootstrap, seedFiles: await loadResourceRootSeedFiles({ - compileDirectoryPath, + compileDirectoryPath: input.compileDirectoryPath, workspaceResourceRoot, }), runtimeContext, diff --git a/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts b/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts index 5f8615a0f..e0a9fb4df 100644 --- a/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts +++ b/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts @@ -12,7 +12,7 @@ import { useTemporaryDirectories } from "#internal/testing/use-temporary-app-roo import { createDiskRuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js"; import { getCompiledRuntimeAgentBundle } from "#runtime/sessions/compiled-agent-cache.js"; import { createRuntimeSession, withRuntimeSession } from "#runtime/sessions/runtime-session.js"; -import { createNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; +import { createDevelopmentNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; import { activateDevelopmentRuntimeArtifactsSnapshot, pruneDevelopmentRuntimeArtifactsSnapshots, @@ -529,7 +529,7 @@ describe("development runtime artifact snapshots", () => { await withRuntimeSession(createRuntimeSession("next-imports-regression"), async () => { const bundle = await getCompiledRuntimeAgentBundle({ compiledArtifactsSource: resolveNitroCompiledArtifactsSource( - createNitroArtifactsConfig({ appRoot, dev: true }), + createDevelopmentNitroArtifactsConfig({ appRoot }), ), }); const agentModule = bundle.moduleMap.nodes[ROOT_COMPILED_AGENT_NODE_ID]?.modules["agent.ts"]; diff --git a/packages/eve/src/internal/nitro/host/artifacts-config.ts b/packages/eve/src/internal/nitro/host/artifacts-config.ts index 29297e63c..6375619f1 100644 --- a/packages/eve/src/internal/nitro/host/artifacts-config.ts +++ b/packages/eve/src/internal/nitro/host/artifacts-config.ts @@ -1,34 +1,40 @@ import { resolvePackageSourceFilePath } from "#internal/application/package.js"; import { resolveDevelopmentRuntimeArtifactsPointerPath } from "#internal/nitro/dev-runtime-artifacts.js"; -import type { NitroArtifactsConfig } from "#internal/nitro/routes/runtime-artifacts.js"; /** * Artifacts config serialized into virtual Nitro handlers so route handlers * can resolve compiled artifacts without a global runtime configuration store. */ -export interface NitroArtifactsConfigInput extends NitroArtifactsConfig { - readonly appRoot: string; - readonly dev: boolean; -} +export type NitroArtifactsConfigInput = + | { + readonly appRoot: string; + readonly dev: false; + } + | { + readonly appRoot: string; + readonly dev: true; + readonly devRuntimeArtifactsPointerPath: string; + readonly moduleMapLoaderPath: string; + }; -/** - * Creates the artifacts config baked into Nitro virtual handlers. - */ -export function createNitroArtifactsConfig(input: { +/** Creates the artifact config baked into development Nitro virtual handlers. */ +export function createDevelopmentNitroArtifactsConfig(input: { readonly appRoot: string; - readonly dev: boolean; }): NitroArtifactsConfigInput { - if (!input.dev) { - return { - appRoot: input.appRoot, - dev: input.dev, - }; - } - return { appRoot: input.appRoot, devRuntimeArtifactsPointerPath: resolveDevelopmentRuntimeArtifactsPointerPath(input.appRoot), - dev: input.dev, + dev: true, moduleMapLoaderPath: resolvePackageSourceFilePath("src/internal/authored-module-map-loader.ts"), }; } + +/** Creates the artifact config baked into production Nitro virtual handlers. */ +export function createProductionNitroArtifactsConfig(input: { + readonly appRoot: string; +}): NitroArtifactsConfigInput { + return { + appRoot: input.appRoot, + dev: false, + }; +} diff --git a/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts b/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts index 16ab11258..9b8d053bd 100644 --- a/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts +++ b/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts @@ -54,7 +54,7 @@ const buildNitroMock = vi.fn(async (nitro: Nitro) => { }); const copyPublicAssetsMock = vi.fn(async () => undefined); const createApplicationNitroMock = vi.fn(); -const prepareApplicationHostMock = vi.fn(); +const prepareProductionApplicationHostMock = vi.fn(); const prepareMock = vi.fn(async () => undefined); const prerenderMock = vi.fn(async () => undefined); const resolveDiscoveryProjectMock = vi.fn(async (appRoot: string) => ({ @@ -78,7 +78,7 @@ vi.mock("./create-application-nitro.js", () => ({ })); vi.mock("./prepare-application-host.js", () => ({ - prepareApplicationHost: prepareApplicationHostMock, + prepareProductionApplicationHost: prepareProductionApplicationHostMock, })); vi.mock("#discover/project.js", () => ({ @@ -178,8 +178,8 @@ describe("buildApplication", () => { const outputDir = join(appRoot, ".output"); const staleOutputPath = join(outputDir, "stale-output.txt"); - prepareApplicationHostMock.mockImplementationOnce(async (_rootDir, options) => { - await mkdir(join(options.workspace.compilerArtifactsRoot, "compile"), { recursive: true }); + prepareProductionApplicationHostMock.mockImplementationOnce(async (workspace) => { + await mkdir(join(workspace.compilerArtifactsRoot, "compile"), { recursive: true }); return createPreparedHost(appRoot); }); createApplicationNitroMock.mockImplementationOnce( @@ -234,7 +234,7 @@ describe("buildApplication", () => { const appRoot = await createScratchDirectory("eve-build-application-last-good-"); const outputDir = join(appRoot, ".output"); const summaryPath = join(appRoot, VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH); - prepareApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); + prepareProductionApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); createApplicationNitroMock.mockImplementationOnce( async ( _preparedHost: PreparedApplicationHost, @@ -275,7 +275,7 @@ describe("buildApplication", () => { const stableFlowOutputDir = join(appRoot, ".eve", "nitro-output", "flow"); const staleFlowOutputPath = join(stableFlowOutputDir, "stale-flow.txt"); - prepareApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); + prepareProductionApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); createApplicationNitroMock.mockImplementation( async ( _preparedHost: PreparedApplicationHost, @@ -382,7 +382,6 @@ describe("buildApplication", () => { expect(runVercelBuildPrewarmMock).toHaveBeenCalledWith( expect.objectContaining({ appRoot, - compileDirectoryPath: expect.stringContaining(join(appRoot, ".eve", "builds")), compiledArtifactsSource: expect.objectContaining({ kind: "disk", sandboxAppRoot: appRoot, @@ -433,7 +432,7 @@ describe("buildApplication", () => { const appRoot = await createScratchDirectory("eve-build-application-vercel-nuxt-"); const flowOutputDir = join(appRoot, ".eve", "nitro-output", "flow"); - prepareApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); + prepareProductionApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); createApplicationNitroMock.mockImplementation( async ( _preparedHost: PreparedApplicationHost, @@ -494,7 +493,7 @@ describe("buildApplication", () => { vi.stubEnv("VERCEL", "1"); const appRoot = await createScratchDirectory("eve-build-application-vercel-service-array-"); - prepareApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); + prepareProductionApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); createApplicationNitroMock.mockImplementation( async ( _preparedHost: PreparedApplicationHost, @@ -548,7 +547,7 @@ describe("buildApplication", () => { const projectRoot = await createScratchDirectory("eve-build-application-vercel-root-dir-"); const appRoot = join(projectRoot, "apps", "web", "agents", "support"); - prepareApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); + prepareProductionApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); createApplicationNitroMock.mockImplementation( async ( _preparedHost: PreparedApplicationHost, @@ -611,7 +610,7 @@ describe("buildApplication", () => { const appRoot = await createScratchDirectory("eve-build-application-vercel-root-config-"); const flowOutputDir = join(appRoot, ".eve", "nitro-output", "flow"); - prepareApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); + prepareProductionApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); createApplicationNitroMock.mockImplementation( async ( _preparedHost: PreparedApplicationHost, @@ -663,7 +662,7 @@ describe("buildApplication", () => { vi.stubEnv("VERCEL", "1"); const appRoot = await createScratchDirectory("eve-build-application-vercel-standalone-"); - prepareApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); + prepareProductionApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); createApplicationNitroMock.mockImplementation( async ( _preparedHost: PreparedApplicationHost, diff --git a/packages/eve/src/internal/nitro/host/build-application.ts b/packages/eve/src/internal/nitro/host/build-application.ts index ece6f6315..b59b714b7 100644 --- a/packages/eve/src/internal/nitro/host/build-application.ts +++ b/packages/eve/src/internal/nitro/host/build-application.ts @@ -21,7 +21,7 @@ import { normalizeEveVercelFunctionOutput } from "#internal/workflow-bundle/verc import { createApplicationNitro } from "#internal/nitro/host/create-application-nitro.js"; import { emitVercelAgentSummary } from "#internal/nitro/host/build-vercel-agent-summary.js"; import { tryReadExtensionBuildConfig } from "#internal/nitro/host/build-extension.js"; -import { prepareApplicationHost } from "#internal/nitro/host/prepare-application-host.js"; +import { prepareProductionApplicationHost } from "#internal/nitro/host/prepare-application-host.js"; import { runVercelBuildPrewarm } from "#internal/nitro/host/vercel-build-prewarm.js"; import type { ApplicationBuildOptions, @@ -332,7 +332,7 @@ async function buildApplicationInWorkspace( workspace: ApplicationBuildWorkspace, options: ApplicationBuildOptions, ): Promise { - const preparedHost = await prepareApplicationHost(workspace.appRoot, { workspace }); + const preparedHost = await prepareProductionApplicationHost(workspace); if (!process.env.VERCEL) { const nitro = await createApplicationNitro(preparedHost, false, { @@ -344,7 +344,6 @@ async function buildApplicationInWorkspace( await buildNitroOutput(nitro); await emitVercelAgentSummary({ manifest: preparedHost.compileResult.manifest, - appRoot: preparedHost.appRoot, outputPath: workspace.summaryPath, }); await stageProductionStartArtifacts({ @@ -377,7 +376,6 @@ async function buildApplicationInWorkspace( if (!options.skipVercelSandboxPrewarm) { await runVercelBuildPrewarm({ appRoot: preparedHost.appRoot, - compileDirectoryPath: preparedHost.compileResult.paths.compileDirectoryPath, compiledArtifactsSource: createDiskRuntimeCompiledArtifactsSource( workspace.compilerAppRoot, { @@ -408,7 +406,6 @@ async function buildApplicationInWorkspace( } await emitVercelAgentSummary({ manifest: preparedHost.compileResult.manifest, - appRoot: preparedHost.appRoot, outputPath: workspace.summaryPath, }); } finally { diff --git a/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.ts b/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.ts index 9a5ad5e58..15eb93f98 100644 --- a/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.ts +++ b/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.ts @@ -1,5 +1,5 @@ import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { dirname } from "node:path"; import type { CompiledAgentManifest, @@ -23,7 +23,6 @@ import { type VercelEveSubagentEntry, type VercelEveToolEntry, VERCEL_EVE_AGENT_SUMMARY_KIND, - VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH, VERCEL_EVE_AGENT_SUMMARY_VERSION, normalizeChannelKindForDisplay, } from "#internal/vercel-agent-summary.js"; @@ -73,12 +72,6 @@ export function buildVercelAgentSummary(input: { * Writes the agent summary file. Returns the absolute path of the * written file. * - * The file is written to {@link VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH} - * relative to {@link input.appRoot} — i.e. - * `/.eve/agent-summary.json`. Lives outside `.vercel/output/` - * by design, so it is not part of the Build Output API surface and is - * never served on the deployment URL. - * * On Vercel deployments, the build container's * `upload-eve-agent-summary.ts` helper picks up this file from * `rootPath` (which equals `appRoot` for the project being built) and @@ -94,20 +87,17 @@ export function buildVercelAgentSummary(input: { */ export async function emitVercelAgentSummary(input: { manifest: CompiledAgentManifest; - appRoot: string; generatorVersion?: string; - outputPath?: string; + outputPath: string; }): Promise { const summary = buildVercelAgentSummary({ generatorVersion: input.generatorVersion, manifest: input.manifest, }); - const filePath = input.outputPath ?? join(input.appRoot, VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH); - - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, `${JSON.stringify(summary, null, 2)}\n`); + await mkdir(dirname(input.outputPath), { recursive: true }); + await writeFile(input.outputPath, `${JSON.stringify(summary, null, 2)}\n`); - return filePath; + return input.outputPath; } function isActiveChannel(entry: CompiledChannelEntry): entry is CompiledChannelDefinition { diff --git a/packages/eve/src/internal/nitro/host/channel-routes.test.ts b/packages/eve/src/internal/nitro/host/channel-routes.test.ts index 44db4dcc0..d65c5358a 100644 --- a/packages/eve/src/internal/nitro/host/channel-routes.test.ts +++ b/packages/eve/src/internal/nitro/host/channel-routes.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { createDevelopmentNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; import { registerChannelVirtualHandlers } from "#internal/nitro/host/channel-routes.js"; describe("registerChannelVirtualHandlers", () => { @@ -12,7 +13,7 @@ describe("registerChannelVirtualHandlers", () => { }; registerChannelVirtualHandlers(nitro, { - artifactsConfig: { appRoot: "/app", dev: true }, + artifactsConfig: createDevelopmentNitroArtifactsConfig({ appRoot: "/app" }), registrations: [{ cors: {}, method: "POST", route: "/eve/v1/session" }], }); @@ -48,7 +49,7 @@ describe("registerChannelVirtualHandlers", () => { }; registerChannelVirtualHandlers(nitro, { - artifactsConfig: { appRoot: "/app", dev: true }, + artifactsConfig: createDevelopmentNitroArtifactsConfig({ appRoot: "/app" }), registrations: [ { cors: {}, method: "GET", route: "/eve/v1/session/:sessionId/events" }, { cors: {}, method: "POST", route: "/eve/v1/session/:sessionId/events" }, @@ -72,7 +73,7 @@ describe("registerChannelVirtualHandlers", () => { }; registerChannelVirtualHandlers(nitro, { - artifactsConfig: { appRoot: "/app", dev: true }, + artifactsConfig: createDevelopmentNitroArtifactsConfig({ appRoot: "/app" }), registrations: [{ method: "WEBSOCKET", route: "/voice" }], }); 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..63e87a029 100644 --- a/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts +++ b/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts @@ -19,7 +19,8 @@ import { } from "#internal/application/package.js"; import { WorkflowBundleBuilder } from "#internal/workflow-bundle/builder.js"; import { - createNitroArtifactsConfig, + createDevelopmentNitroArtifactsConfig, + createProductionNitroArtifactsConfig, type NitroArtifactsConfigInput, } from "#internal/nitro/host/artifacts-config.js"; import { deriveEveWorkflowQueuePrefix } from "#internal/workflow/queue-namespace.js"; @@ -323,10 +324,12 @@ export async function configureNitroRoutes( } } - const artifactsConfig: NitroArtifactsConfigInput = createNitroArtifactsConfig({ - appRoot: preparedHost.appRoot, - dev: nitro.options.dev, - }); + let artifactsConfig: NitroArtifactsConfigInput; + if (nitro.options.dev) { + artifactsConfig = createDevelopmentNitroArtifactsConfig({ appRoot: preparedHost.appRoot }); + } else { + artifactsConfig = createProductionNitroArtifactsConfig({ appRoot: preparedHost.appRoot }); + } if (includesApplicationRoutes(input.surface)) { addFrameworkVirtualHandler(nitro, { diff --git a/packages/eve/src/internal/nitro/host/create-application-nitro.ts b/packages/eve/src/internal/nitro/host/create-application-nitro.ts index 7f14663e4..cefedb4c1 100644 --- a/packages/eve/src/internal/nitro/host/create-application-nitro.ts +++ b/packages/eve/src/internal/nitro/host/create-application-nitro.ts @@ -17,7 +17,7 @@ import { } from "#internal/application/cache-metadata.js"; import { resolveNitroBuildDirectory } from "#internal/application/paths.js"; import { - createNitroArtifactsConfig, + createProductionNitroArtifactsConfig, type NitroArtifactsConfigInput, } from "#internal/nitro/host/artifacts-config.js"; import { createCompiledSandboxBackendPrunePlugin } from "#internal/nitro/host/compiled-sandbox-backend-prune-plugin.js"; @@ -837,9 +837,8 @@ export async function createApplicationNitro( // use (e.g. dev mode), where the cron route is never registered. applyEveCronHandlerRoute(nitro); - const artifactsConfig: NitroArtifactsConfigInput = createNitroArtifactsConfig({ + const artifactsConfig: NitroArtifactsConfigInput = createProductionNitroArtifactsConfig({ appRoot: preparedHost.appRoot, - dev: nitro.options.dev, }); registerScheduleTaskHandlers(nitro, { artifactsConfig, diff --git a/packages/eve/src/internal/nitro/host/dev-authored-source-watcher.scenario.test.ts b/packages/eve/src/internal/nitro/host/dev-authored-source-watcher.scenario.test.ts index a6e699da8..44e919181 100644 --- a/packages/eve/src/internal/nitro/host/dev-authored-source-watcher.scenario.test.ts +++ b/packages/eve/src/internal/nitro/host/dev-authored-source-watcher.scenario.test.ts @@ -102,7 +102,7 @@ const mockedWatcher = vi.hoisted(() => { }; }); -const prepareApplicationHostMock = vi.hoisted(() => vi.fn()); +const prepareDevelopmentApplicationHostMock = vi.hoisted(() => vi.fn()); const clearCompiledRuntimeAgentBundleCacheMock = vi.hoisted(() => vi.fn()); const startDevelopmentSandboxPrewarmInBackgroundMock = vi.hoisted(() => vi.fn()); const pruneDevelopmentRuntimeArtifactsSnapshotsInBackgroundMock = vi.hoisted(() => vi.fn()); @@ -120,7 +120,7 @@ vi.mock("#compiled/chokidar/index.js", () => ({ })); vi.mock("./prepare-application-host.js", () => ({ - prepareApplicationHost: prepareApplicationHostMock, + prepareDevelopmentApplicationHost: prepareDevelopmentApplicationHostMock, })); vi.mock("#execution/sandbox/development-prewarm.js", () => ({ @@ -168,7 +168,7 @@ beforeEach(() => { vi.spyOn(console, "log").mockImplementation(() => {}); mockedWatcher.reset(); - prepareApplicationHostMock.mockReset(); + prepareDevelopmentApplicationHostMock.mockReset(); clearCompiledRuntimeAgentBundleCacheMock.mockReset(); startDevelopmentSandboxPrewarmInBackgroundMock.mockReset(); pruneDevelopmentRuntimeArtifactsSnapshotsInBackgroundMock.mockReset(); @@ -193,7 +193,7 @@ describe("startAuthoredSourceWatcher", () => { const previousHost = createPreparedHost(); const nextHost = createPreparedHost(); const nitroStub = createNitroStub(); - prepareApplicationHostMock.mockResolvedValueOnce(nextHost); + prepareDevelopmentApplicationHostMock.mockResolvedValueOnce(nextHost); const watcher = await startAuthoredSourceWatcher({ nitro: nitroStub.nitro, @@ -203,7 +203,7 @@ describe("startAuthoredSourceWatcher", () => { try { await watcher.rebuild(); - expect(prepareApplicationHostMock).toHaveBeenCalledWith(previousHost.appRoot, { dev: true }); + expect(prepareDevelopmentApplicationHostMock).toHaveBeenCalledWith(previousHost.appRoot); expect(pruneDevelopmentRuntimeArtifactsSnapshotsInBackgroundMock).toHaveBeenCalledWith( nextHost.appRoot, ); @@ -252,13 +252,13 @@ describe("startAuthoredSourceWatcher", () => { mockedWatcher.emit("add", join(DEFAULT_APP_ROOT, "node_modules", "eve")); await advanceDebounce(); - expect(prepareApplicationHostMock).not.toHaveBeenCalled(); + expect(prepareDevelopmentApplicationHostMock).not.toHaveBeenCalled(); mockedWatcher.ready(); watcher = await watcherPromise; await advanceDebounce(); - expect(prepareApplicationHostMock).not.toHaveBeenCalled(); + expect(prepareDevelopmentApplicationHostMock).not.toHaveBeenCalled(); expect(getConsoleLogMessages().some((message) => message.includes("change detected"))).toBe( false, ); @@ -295,7 +295,7 @@ describe("startAuthoredSourceWatcher", () => { }, }); - prepareApplicationHostMock.mockResolvedValueOnce(nextHost); + prepareDevelopmentApplicationHostMock.mockResolvedValueOnce(nextHost); const watcher = await startAuthoredSourceWatcher({ nitro: nitroStub.nitro, @@ -305,13 +305,13 @@ describe("startAuthoredSourceWatcher", () => { try { await triggerChangeEvent(); - expect(prepareApplicationHostMock).toHaveBeenCalledTimes(1); + expect(prepareDevelopmentApplicationHostMock).toHaveBeenCalledTimes(1); expect(getConsoleLogMessages()).toEqual( expect.arrayContaining([ "[eve:dev] change detected (1 event: change agent/agent.ts), rebuilding authored artifacts...", ]), ); - expect(prepareApplicationHostMock).toHaveBeenCalledWith(previousHost.appRoot, { dev: true }); + expect(prepareDevelopmentApplicationHostMock).toHaveBeenCalledWith(previousHost.appRoot); expect(startDevelopmentSandboxPrewarmInBackgroundMock).not.toHaveBeenCalled(); expect(clearCompiledRuntimeAgentBundleCacheMock).toHaveBeenCalledTimes(1); expect(nitroStub.callHook).not.toHaveBeenCalled(); @@ -325,7 +325,7 @@ describe("startAuthoredSourceWatcher", () => { const nextHost = createPreparedHost(); const nitroStub = createNitroStub(); - prepareApplicationHostMock.mockResolvedValue(nextHost); + prepareDevelopmentApplicationHostMock.mockResolvedValue(nextHost); const watcher = await startAuthoredSourceWatcher({ nitro: nitroStub.nitro, @@ -363,8 +363,8 @@ describe("startAuthoredSourceWatcher", () => { const nextHost = createPreparedHost(); const nitroStub = createNitroStub(); - prepareApplicationHostMock.mockResolvedValue(nextHost); - prepareApplicationHostMock.mockReturnValueOnce(firstRebuild.promise); + prepareDevelopmentApplicationHostMock.mockResolvedValue(nextHost); + prepareDevelopmentApplicationHostMock.mockReturnValueOnce(firstRebuild.promise); const watcher = await startAuthoredSourceWatcher({ nitro: nitroStub.nitro, @@ -376,7 +376,7 @@ describe("startAuthoredSourceWatcher", () => { mockedWatcher.emit("change", join(previousHost.appRoot, "agent", "instructions.md")); await advanceDebounce(); - expect(prepareApplicationHostMock).toHaveBeenCalledTimes(1); + expect(prepareDevelopmentApplicationHostMock).toHaveBeenCalledTimes(1); // Further saves while that rebuild is in flight queue behind it. mockedWatcher.emit("change", join(previousHost.appRoot, "agent", "tools", "a.ts")); @@ -384,7 +384,7 @@ describe("startAuthoredSourceWatcher", () => { mockedWatcher.emit("change", join(previousHost.appRoot, "agent", "tools", "b.ts")); await advanceDebounce(); - expect(prepareApplicationHostMock).toHaveBeenCalledTimes(1); + expect(prepareDevelopmentApplicationHostMock).toHaveBeenCalledTimes(1); firstRebuild.resolve(nextHost); await vi.waitFor(() => { @@ -396,7 +396,7 @@ describe("startAuthoredSourceWatcher", () => { await watcher.flush(); // a.ts and b.ts collapse into exactly one additional rebuild. - expect(prepareApplicationHostMock).toHaveBeenCalledTimes(2); + expect(prepareDevelopmentApplicationHostMock).toHaveBeenCalledTimes(2); expect(getConsoleLogMessages()).not.toEqual( expect.arrayContaining([ "[eve:dev] change detected (0 events), rebuilding authored artifacts...", @@ -421,7 +421,7 @@ describe("startAuthoredSourceWatcher", () => { }); const nitroStub = createNitroStub(); - prepareApplicationHostMock.mockResolvedValueOnce(nextHost); + prepareDevelopmentApplicationHostMock.mockResolvedValueOnce(nextHost); const watcher = await startAuthoredSourceWatcher({ nitro: nitroStub.nitro, @@ -468,7 +468,7 @@ describe("startAuthoredSourceWatcher", () => { }); const nitroStub = createNitroStub(); - prepareApplicationHostMock.mockResolvedValueOnce(nextHost); + prepareDevelopmentApplicationHostMock.mockResolvedValueOnce(nextHost); const watcher = await startAuthoredSourceWatcher({ nitro: nitroStub.nitro, @@ -721,7 +721,7 @@ describe("startAuthoredSourceWatcher", () => { const nextHost = createPreparedHost({ appRoot }); const nitroStub = createNitroStub(); - prepareApplicationHostMock.mockResolvedValueOnce(nextHost); + prepareDevelopmentApplicationHostMock.mockResolvedValueOnce(nextHost); const watcher = await startAuthoredSourceWatcher({ nitro: nitroStub.nitro, diff --git a/packages/eve/src/internal/nitro/host/dev-authored-source-watcher.ts b/packages/eve/src/internal/nitro/host/dev-authored-source-watcher.ts index 4d27dda11..f36b75bdb 100644 --- a/packages/eve/src/internal/nitro/host/dev-authored-source-watcher.ts +++ b/packages/eve/src/internal/nitro/host/dev-authored-source-watcher.ts @@ -5,7 +5,7 @@ import type { Nitro } from "nitro/types"; import { clearCompiledRuntimeAgentBundleCache } from "#runtime/sessions/compiled-agent-cache.js"; import { toErrorMessage } from "#shared/errors.js"; import { resolveTsConfigDependencyPaths } from "#internal/application/tsconfig-dependencies.js"; -import { createNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; +import { createDevelopmentNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; import { resolveDevelopmentSourceSnapshotWatchPaths } from "#internal/nitro/dev-runtime-source-snapshot.js"; import { pruneDevelopmentRuntimeArtifactsSnapshotsInBackground } from "#internal/nitro/dev-runtime-artifacts.js"; import { startDevelopmentSandboxPrewarmInBackground } from "#execution/sandbox/development-prewarm.js"; @@ -13,7 +13,7 @@ import { computeChannelRouteRegistrations, syncChannelVirtualHandlers, } from "#internal/nitro/host/channel-routes.js"; -import { prepareApplicationHost } from "#internal/nitro/host/prepare-application-host.js"; +import { prepareDevelopmentApplicationHost } from "#internal/nitro/host/prepare-application-host.js"; import { resolveNitroCompiledArtifactsSource } from "#internal/nitro/routes/runtime-artifacts.js"; import type { PreparedApplicationHost } from "#internal/nitro/host/types.js"; import { @@ -142,15 +142,10 @@ export async function startAuthoredSourceWatcher(input: { loadDevelopmentEnvironmentFiles(previousHost.appRoot); } - const nextHost = await prepareApplicationHost(previousHost.appRoot, { - dev: input.nitro.options.dev === true, - }); - if (input.nitro.options.dev === true) { - pruneDevelopmentRuntimeArtifactsSnapshotsInBackground(nextHost.appRoot); - } - const artifactsConfig = createNitroArtifactsConfig({ + const nextHost = await prepareDevelopmentApplicationHost(previousHost.appRoot); + pruneDevelopmentRuntimeArtifactsSnapshotsInBackground(nextHost.appRoot); + const artifactsConfig = createDevelopmentNitroArtifactsConfig({ appRoot: nextHost.appRoot, - dev: input.nitro.options.dev === true, }); if (hasSandboxPrewarmChange) { startDevelopmentSandboxPrewarmInBackground({ diff --git a/packages/eve/src/internal/nitro/host/dev-environment-watcher.integration.test.ts b/packages/eve/src/internal/nitro/host/dev-environment-watcher.integration.test.ts index 1c17a45fa..28b568e3c 100644 --- a/packages/eve/src/internal/nitro/host/dev-environment-watcher.integration.test.ts +++ b/packages/eve/src/internal/nitro/host/dev-environment-watcher.integration.test.ts @@ -54,7 +54,7 @@ const mockedWatcher = vi.hoisted(() => { }; }); -const prepareApplicationHostMock = vi.hoisted(() => vi.fn()); +const prepareDevelopmentApplicationHostMock = vi.hoisted(() => vi.fn()); const clearCompiledRuntimeAgentBundleCacheMock = vi.hoisted(() => vi.fn()); const startDevelopmentSandboxPrewarmInBackgroundMock = vi.hoisted(() => vi.fn()); @@ -63,7 +63,7 @@ vi.mock("#compiled/chokidar/index.js", () => ({ })); vi.mock("./prepare-application-host.js", () => ({ - prepareApplicationHost: prepareApplicationHostMock, + prepareDevelopmentApplicationHost: prepareDevelopmentApplicationHostMock, })); vi.mock("#execution/sandbox/development-prewarm.js", () => ({ @@ -94,7 +94,7 @@ beforeEach(() => { clearEnvironment(); mockedWatcher.reset(); - prepareApplicationHostMock.mockReset(); + prepareDevelopmentApplicationHostMock.mockReset(); clearCompiledRuntimeAgentBundleCacheMock.mockReset(); startDevelopmentSandboxPrewarmInBackgroundMock.mockReset(); }); @@ -141,7 +141,7 @@ describe("startAuthoredSourceWatcher env files", () => { const previousHost = createPreparedHost(appRoot); const nextHost = createPreparedHost(appRoot); const nitroStub = createNitroStub(); - prepareApplicationHostMock.mockResolvedValueOnce(nextHost); + prepareDevelopmentApplicationHostMock.mockResolvedValueOnce(nextHost); const watcher = await startAuthoredSourceWatcher({ nitro: nitroStub.nitro, 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..40f514ba9 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,4 +1,4 @@ -import { createNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; +import { createDevelopmentNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; import { createAuthoredSourceRuntimeCompiledArtifactsSource } from "#internal/application/runtime-compiled-artifacts-source.js"; import { createScheduleRegistrations } from "#runtime/schedules/register.js"; import { loadResolvedCompiledSchedules } from "#runtime/schedules/resolve-schedule.js"; @@ -69,9 +69,8 @@ export async function dispatchScheduleInDev(input: { } const { dispatchScheduleTask } = await import("#internal/nitro/routes/schedule-task.js"); - const artifactsConfig = createNitroArtifactsConfig({ + const artifactsConfig = createDevelopmentNitroArtifactsConfig({ appRoot: input.appRoot, - dev: true, }); const result = await dispatchScheduleTask(registration.taskName, artifactsConfig); diff --git a/packages/eve/src/internal/nitro/host/prepare-application-host.scenario.test.ts b/packages/eve/src/internal/nitro/host/prepare-application-host.scenario.test.ts index f8905522a..5d24fcdd2 100644 --- a/packages/eve/src/internal/nitro/host/prepare-application-host.scenario.test.ts +++ b/packages/eve/src/internal/nitro/host/prepare-application-host.scenario.test.ts @@ -14,7 +14,10 @@ import { resolveDevelopmentRuntimeArtifactsPointerPath, } from "#internal/nitro/dev-runtime-artifacts.js"; import { useTemporaryAppRoots } from "#internal/testing/use-temporary-app-roots.js"; -import { prepareApplicationHost } from "#internal/nitro/host/prepare-application-host.js"; +import { + prepareDevelopmentApplicationHost, + prepareProductionApplicationHost, +} from "#internal/nitro/host/prepare-application-host.js"; const createAppRoot = useTemporaryAppRoots(); @@ -29,32 +32,10 @@ async function readDevelopmentRuntimePointer(appRoot: string): Promise { +describe("application host preparation", () => { afterEach(() => { vi.unstubAllEnvs(); }); - - it("selects the Vercel Workflow world for a prebuilt production host", async () => { - vi.stubEnv("VERCEL", "1"); - vi.stubEnv("VERCEL_DEPLOYMENT_ID", ""); - const { agentRoot, appRoot } = await createAppRoot("eve-vercel-production-world-", { - files: { - "agent/instructions.md": "Use the configured model.", - }, - packageName: "vercel-production-world", - }); - await writeFile(join(agentRoot, "agent.mjs"), 'export default { model: "openai/gpt-5.4" };\n'); - - const preparedHost = await prepareApplicationHost(appRoot); - const workflowWorldPlugin = await readFile( - preparedHost.compiledArtifacts.workflowWorldPluginPath, - "utf8", - ); - - expect(workflowWorldPlugin).toContain("/compiled/@workflow/world-vercel/index.js"); - expect(workflowWorldPlugin).not.toContain("/compiled/@workflow/world-local/index.js"); - }); - it("keeps production compiler and host writes inside one invocation workspace", async () => { const { agentRoot, appRoot } = await createAppRoot("eve-production-host-workspace-", { files: { @@ -66,7 +47,7 @@ describe("prepareApplicationHost", () => { const workspace = await createApplicationBuildWorkspace(appRoot); try { - const preparedHost = await prepareApplicationHost(appRoot, { workspace }); + const preparedHost = await prepareProductionApplicationHost(workspace); expect(preparedHost.compileResult.paths.compileDirectoryPath).toBe( join(workspace.compilerArtifactsRoot, "compile"), @@ -84,6 +65,32 @@ describe("prepareApplicationHost", () => { expect(existsSync(workspace.rootDir)).toBe(false); }); + it("selects the Vercel Workflow world for a prebuilt production host", async () => { + vi.stubEnv("VERCEL", "1"); + vi.stubEnv("VERCEL_DEPLOYMENT_ID", ""); + const { agentRoot, appRoot } = await createAppRoot("eve-vercel-production-world-", { + files: { + "agent/instructions.md": "Use the configured model.", + }, + packageName: "vercel-production-world", + }); + await writeFile(join(agentRoot, "agent.mjs"), 'export default { model: "openai/gpt-5.4" };\n'); + const workspace = await createApplicationBuildWorkspace(appRoot); + + try { + const preparedHost = await prepareProductionApplicationHost(workspace); + const workflowWorldPlugin = await readFile( + preparedHost.compiledArtifacts.workflowWorldPluginPath, + "utf8", + ); + + expect(workflowWorldPlugin).toContain("/compiled/@workflow/world-vercel/index.js"); + expect(workflowWorldPlugin).not.toContain("/compiled/@workflow/world-local/index.js"); + } finally { + await removeApplicationBuildWorkspace(workspace); + } + }); + it("keeps Nitro host inputs stable when their runtime snapshot is pruned", async () => { const { agentRoot, appRoot } = await createAppRoot("eve-stable-dev-host-artifacts-", { files: { @@ -94,7 +101,7 @@ describe("prepareApplicationHost", () => { const agentModulePath = join(agentRoot, "agent.mjs"); await writeFile(agentModulePath, 'export default { model: "openai/gpt-5.4" };\n'); - const firstHost = await prepareApplicationHost(appRoot, { dev: true }); + const firstHost = await prepareDevelopmentApplicationHost(appRoot); const firstPointer = await readDevelopmentRuntimePointer(appRoot); const stableHostDirectory = join(appRoot, ".eve", "host"); const stableBootstrapPath = join(stableHostDirectory, "compiled-artifacts-bootstrap.mjs"); @@ -119,7 +126,7 @@ describe("prepareApplicationHost", () => { agentModulePath, 'export default { model: "openai/gpt-5.4" };\n// revision two\n', ); - const nextHost = await prepareApplicationHost(appRoot, { dev: true }); + const nextHost = await prepareDevelopmentApplicationHost(appRoot); const nextPointer = await readDevelopmentRuntimePointer(appRoot); expect(nextHost.compiledArtifacts.bootstrapPath).toBe(stableBootstrapPath); diff --git a/packages/eve/src/internal/nitro/host/prepare-application-host.ts b/packages/eve/src/internal/nitro/host/prepare-application-host.ts index 4af0c1af9..87fa7efa0 100644 --- a/packages/eve/src/internal/nitro/host/prepare-application-host.ts +++ b/packages/eve/src/internal/nitro/host/prepare-application-host.ts @@ -1,9 +1,14 @@ -import { compileAgent } from "#compiler/compile-agent.js"; +import { + compileAgent, + compileAgentInBuildWorkspace, + type CompileAgentResult, +} from "#compiler/compile-agent.js"; import { createScheduleRegistrations } from "#runtime/schedules/register.js"; import { loadResolvedCompiledSchedules, resolveSchedules, } from "#runtime/schedules/resolve-schedule.js"; +import type { ResolvedScheduleDefinition } from "#runtime/types.js"; import type { ApplicationBuildWorkspace } from "#internal/application/build-workspace.js"; import { type BuiltInWorkflowWorldTarget, @@ -20,71 +25,76 @@ import { } from "#internal/nitro/dev-runtime-artifacts.js"; import type { PreparedApplicationHost } from "#internal/nitro/host/types.js"; -/** - * Compiles one authored app and stages the package-owned artifacts needed by - * the Nitro host. - */ -export async function prepareApplicationHost( +/** Compiles and activates the stable artifacts consumed by a development host. */ +export async function prepareDevelopmentApplicationHost( startPath: string, - options: { - readonly dev?: boolean; - readonly workspace?: Pick< - ApplicationBuildWorkspace, - "compilerArtifactsRoot" | "hostArtifactsDir" | "workflowBuildDir" - >; - } = {}, ): Promise { const compileResult = await compileAgent({ - artifactsRoot: options.workspace?.compilerArtifactsRoot, - includeDiagnosticsArtifactPath: options.workspace === undefined, startPath, }); - const schedules = - options.workspace === undefined - ? await loadResolvedCompiledSchedules({ - compiledArtifactsSource: createAuthoredSourceRuntimeCompiledArtifactsSource( - compileResult.project.appRoot, - ), - }) - : await resolveSchedules({ manifest: compileResult.manifest }); - const scheduleRegistrations = createScheduleRegistrations(schedules); - const workflowBuildDir = - options.workspace?.workflowBuildDir ?? - resolveWorkflowBuildDirectory(compileResult.project.appRoot); - const runtimeArtifactsSnapshot = - options.dev === true - ? await stageDevelopmentRuntimeArtifactsSnapshot(compileResult) - : undefined; - const compiledArtifacts = await writeCompiledArtifactsFiles({ + const schedules = await loadResolvedCompiledSchedules({ + compiledArtifactsSource: createAuthoredSourceRuntimeCompiledArtifactsSource( + compileResult.project.appRoot, + ), + }); + const runtimeArtifactsSnapshot = await stageDevelopmentRuntimeArtifactsSnapshot(compileResult); + const preparedHost = await materializeApplicationHost({ compileResult, - defaultWorkflowWorld: resolveDefaultWorkflowWorld(options), - outDir: - options.workspace?.hostArtifactsDir ?? - resolveApplicationHostArtifactsDirectory(compileResult.project.appRoot), + defaultWorkflowWorld: "local", + hostArtifactsDir: resolveApplicationHostArtifactsDirectory(compileResult.project.appRoot), + schedules, + workflowBuildDir: resolveWorkflowBuildDirectory(compileResult.project.appRoot), }); - if (runtimeArtifactsSnapshot !== undefined) { - await activateDevelopmentRuntimeArtifactsSnapshot({ - appRoot: compileResult.project.appRoot, - snapshot: runtimeArtifactsSnapshot, - }); - } - - return { + await activateDevelopmentRuntimeArtifactsSnapshot({ appRoot: compileResult.project.appRoot, + snapshot: runtimeArtifactsSnapshot, + }); + + return preparedHost; +} + +/** Prepares a production host without writing outside its invocation workspace. */ +export async function prepareProductionApplicationHost( + workspace: ApplicationBuildWorkspace, +): Promise { + const compileResult = await compileAgentInBuildWorkspace({ + compilerArtifactsRoot: workspace.compilerArtifactsRoot, + startPath: workspace.appRoot, + }); + const schedules = await resolveSchedules({ manifest: compileResult.manifest }); + + return await materializeApplicationHost({ compileResult, - compiledArtifacts, - scheduleRegistrations, + defaultWorkflowWorld: resolveProductionWorkflowWorldTarget(), + hostArtifactsDir: workspace.hostArtifactsDir, schedules, - workflowBuildDir, - }; + workflowBuildDir: workspace.workflowBuildDir, + }); } -function resolveDefaultWorkflowWorld(options: { - readonly dev?: boolean; -}): BuiltInWorkflowWorldTarget { - if (options.dev === true) { - return "local"; - } +async function materializeApplicationHost(input: { + readonly compileResult: CompileAgentResult; + readonly defaultWorkflowWorld: BuiltInWorkflowWorldTarget; + readonly hostArtifactsDir: string; + readonly schedules: readonly ResolvedScheduleDefinition[]; + readonly workflowBuildDir: string; +}): Promise { + const compiledArtifacts = await writeCompiledArtifactsFiles({ + compileResult: input.compileResult, + defaultWorkflowWorld: input.defaultWorkflowWorld, + outDir: input.hostArtifactsDir, + }); + + return { + appRoot: input.compileResult.project.appRoot, + compileResult: input.compileResult, + compiledArtifacts, + scheduleRegistrations: createScheduleRegistrations(input.schedules), + schedules: input.schedules, + workflowBuildDir: input.workflowBuildDir, + }; +} +function resolveProductionWorkflowWorldTarget(): BuiltInWorkflowWorldTarget { return process.env.VERCEL ? "vercel" : "local"; } diff --git a/packages/eve/src/internal/nitro/host/start-development-server.test.ts b/packages/eve/src/internal/nitro/host/start-development-server.test.ts index dfa329e8b..791225d2c 100644 --- a/packages/eve/src/internal/nitro/host/start-development-server.test.ts +++ b/packages/eve/src/internal/nitro/host/start-development-server.test.ts @@ -59,7 +59,7 @@ const mocks = vi.hoisted(() => { listenerServer, mkdir: vi.fn(async () => undefined), nitro, - prepareApplicationHost: vi.fn(async () => ({ appRoot: "/tmp/eve-test" })), + prepareDevelopmentApplicationHost: vi.fn(async () => ({ appRoot: "/tmp/eve-test" })), prepareNitro: vi.fn(async () => undefined), readFile: vi.fn(async (path: string) => { if ( @@ -129,7 +129,7 @@ vi.mock("./dev-authored-source-watcher.js", () => ({ })); vi.mock("./prepare-application-host.js", () => ({ - prepareApplicationHost: mocks.prepareApplicationHost, + prepareDevelopmentApplicationHost: mocks.prepareDevelopmentApplicationHost, })); vi.mock("#discover/project.js", () => ({ @@ -368,7 +368,7 @@ describe("createDevelopmentServer", () => { const server = await startDevelopmentServer("/tmp/eve-test"); - expect(mocks.prepareApplicationHost).toHaveBeenCalledWith("/tmp/eve-test", { dev: true }); + expect(mocks.prepareDevelopmentApplicationHost).toHaveBeenCalledWith("/tmp/eve-test"); expect(mocks.pruneDevelopmentRuntimeArtifactsSnapshotsInBackground).toHaveBeenCalledWith( "/tmp/eve-test", ); diff --git a/packages/eve/src/internal/nitro/host/start-development-server.ts b/packages/eve/src/internal/nitro/host/start-development-server.ts index 6442bb492..e941a56f1 100644 --- a/packages/eve/src/internal/nitro/host/start-development-server.ts +++ b/packages/eve/src/internal/nitro/host/start-development-server.ts @@ -7,9 +7,9 @@ import { build as buildNitro, createDevServer, prepare } from "nitro/builder"; import type { Nitro } from "nitro/types"; import { createApplicationNitro } from "#internal/nitro/host/create-application-nitro.js"; -import { createNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; +import { createDevelopmentNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; import type { AuthoredSourceWatcherHandle } from "#internal/nitro/host/dev-authored-source-watcher.js"; -import { prepareApplicationHost } from "#internal/nitro/host/prepare-application-host.js"; +import { prepareDevelopmentApplicationHost } from "#internal/nitro/host/prepare-application-host.js"; import { EVE_DEV_RUNTIME_ARTIFACTS_REBUILD_ROUTE_PATH } from "#protocol/routes.js"; import { resolveDiscoveryProject } from "#discover/project.js"; import { DevelopmentServerState } from "#internal/nitro/host/dev-server-state.js"; @@ -458,14 +458,13 @@ async function startNitroDevelopmentServer( try { const preparedHost = await devBootPhase( "compiling agent", - () => prepareApplicationHost(project.appRoot, { dev: true }), + () => prepareDevelopmentApplicationHost(project.appRoot), options.onBootProgress, ); pruneDevelopmentRuntimeArtifactsSnapshotsInBackground(preparedHost.appRoot); const compiledArtifactsSource = resolveNitroCompiledArtifactsSource( - createNitroArtifactsConfig({ + createDevelopmentNitroArtifactsConfig({ appRoot: preparedHost.appRoot, - dev: true, }), ); startDevelopmentSandboxPrewarmInBackground({ diff --git a/packages/eve/test/scenarios/agent-info-route.scenario.test.ts b/packages/eve/test/scenarios/agent-info-route.scenario.test.ts index e87a2333c..1e092861f 100644 --- a/packages/eve/test/scenarios/agent-info-route.scenario.test.ts +++ b/packages/eve/test/scenarios/agent-info-route.scenario.test.ts @@ -5,7 +5,7 @@ import type { H3Event } from "nitro"; import { describe, expect, it } from "vitest"; import { compileAgent } from "../../src/compiler/compile-agent.js"; -import { createNitroArtifactsConfig } from "../../src/internal/nitro/host/artifacts-config.js"; +import { createDevelopmentNitroArtifactsConfig } from "../../src/internal/nitro/host/artifacts-config.js"; import type { AgentInfoResponse } from "../../src/internal/nitro/routes/agent-info/build-agent-info-response.js"; import { dispatchChannelRequest } from "../../src/internal/nitro/routes/channel-dispatch.js"; import { EVE_CREATE_SESSION_ROUTE_PATH, EVE_INFO_ROUTE_PATH } from "../../src/protocol/routes.js"; @@ -73,7 +73,7 @@ async function requestAgentInfo(appRoot: string, request: Request): Promise { const writtenArtifacts = await writeCompilerArtifacts({ appRoot, + artifactsRoot: join(appRoot, ".eve"), diagnostics: [ createDiscoverWarningDiagnostic({ code: "discover/unsupported-directory", @@ -391,6 +392,7 @@ describe("compiler artifacts", () => { const writtenArtifacts = await writeCompilerArtifacts({ appRoot, + artifactsRoot: join(appRoot, ".eve"), diagnostics: [], manifest, }); From 0786c19fa4274834ecc2b95174285f3c30e3df00 Mon Sep 17 00:00:00 2001 From: Casey Gowrie Date: Mon, 13 Jul 2026 10:06:32 -0400 Subject: [PATCH 03/12] refactor(eve): separate Nitro development and build setup Signed-off-by: Casey Gowrie --- .../output-publication.scenario.test.ts | 2 +- .../host/build-application.scenario.test.ts | 130 +++---- .../internal/nitro/host/build-application.ts | 9 +- .../nitro/host/configure-nitro-routes.test.ts | 66 ++-- .../nitro/host/configure-nitro-routes.ts | 320 ++++++++-------- .../create-application-nitro.scenario.test.ts | 147 +++++--- .../nitro/host/create-application-nitro.ts | 351 +++++++++--------- .../host/start-development-server.test.ts | 20 +- .../nitro/host/start-development-server.ts | 4 +- 9 files changed, 542 insertions(+), 507 deletions(-) diff --git a/packages/eve/src/internal/application/output-publication.scenario.test.ts b/packages/eve/src/internal/application/output-publication.scenario.test.ts index 17f032af3..d986b4476 100644 --- a/packages/eve/src/internal/application/output-publication.scenario.test.ts +++ b/packages/eve/src/internal/application/output-publication.scenario.test.ts @@ -40,7 +40,7 @@ async function expectPublication(input: { } describe("build output publication", () => { - it("publishes one completed output and its matching summary", async () => { + it("publishes matching output and summary", async () => { const appRoot = await createScratchDirectory("eve-output-publication-"); const finalOutputDir = join(appRoot, ".output"); const finalSummaryPath = join(appRoot, ".eve", "agent-summary.json"); diff --git a/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts b/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts index 9b8d053bd..86e81802c 100644 --- a/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts +++ b/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createCompiledAgentManifest } from "#compiler/manifest.js"; import { resolveInstalledPackageInfo } from "#internal/application/package.js"; +import type { ApplicationBuildWorkspace } from "#internal/application/build-workspace.js"; import { useTemporaryDirectories } from "#internal/testing/use-temporary-app-roots.js"; import type { PreparedApplicationHost } from "#internal/nitro/host/types.js"; import { @@ -53,7 +54,7 @@ const buildNitroMock = vi.fn(async (nitro: Nitro) => { ); }); const copyPublicAssetsMock = vi.fn(async () => undefined); -const createApplicationNitroMock = vi.fn(); +const createProductionApplicationNitroMock = vi.fn(); const prepareProductionApplicationHostMock = vi.fn(); const prepareMock = vi.fn(async () => undefined); const prerenderMock = vi.fn(async () => undefined); @@ -74,7 +75,7 @@ vi.mock("nitro/builder", () => ({ })); vi.mock("./create-application-nitro.js", () => ({ - createApplicationNitro: createApplicationNitroMock, + createProductionApplicationNitro: createProductionApplicationNitroMock, })); vi.mock("./prepare-application-host.js", () => ({ @@ -161,6 +162,13 @@ function createNitroStub(outputDir: string): Nitro { } as unknown as Nitro; } +async function prepareHostBuildWorkspace( + workspace: ApplicationBuildWorkspace, +): Promise { + await mkdir(join(workspace.compilerArtifactsRoot, "compile"), { recursive: true }); + return createPreparedHost(workspace.appRoot); +} + describe("buildApplication", () => { beforeEach(() => { vi.resetModules(); @@ -172,22 +180,16 @@ describe("buildApplication", () => { vi.unstubAllEnvs(); }); - it("builds a single Nitro host outside Vercel", async () => { + it("builds without publishing stable runtime compiler artifacts", async () => { vi.stubEnv("VERCEL", ""); const appRoot = await createScratchDirectory("eve-build-application-single-"); const outputDir = join(appRoot, ".output"); const staleOutputPath = join(outputDir, "stale-output.txt"); - prepareProductionApplicationHostMock.mockImplementationOnce(async (workspace) => { - await mkdir(join(workspace.compilerArtifactsRoot, "compile"), { recursive: true }); - return createPreparedHost(appRoot); - }); - createApplicationNitroMock.mockImplementationOnce( - async ( - _preparedHost: PreparedApplicationHost, - _dev: boolean, - options: { outputDir?: string } = {}, - ) => createNitroStub(options.outputDir ?? outputDir), + prepareProductionApplicationHostMock.mockImplementationOnce(prepareHostBuildWorkspace); + createProductionApplicationNitroMock.mockImplementationOnce( + async (_preparedHost: PreparedApplicationHost, options: { outputDir: string }) => + createNitroStub(options.outputDir), ); await mkdir(outputDir, { recursive: true }); await Promise.all([ @@ -199,10 +201,9 @@ describe("buildApplication", () => { const builtOutputDir = await buildApplication(appRoot, DEPLOYABLE_BUILD_OPTIONS); expect(builtOutputDir).toBe(outputDir); - expect(createApplicationNitroMock).toHaveBeenCalledTimes(1); - expect(createApplicationNitroMock).toHaveBeenCalledWith( + expect(createProductionApplicationNitroMock).toHaveBeenCalledTimes(1); + expect(createProductionApplicationNitroMock).toHaveBeenCalledWith( expect.objectContaining({ appRoot }), - false, expect.objectContaining({ buildDir: expect.stringContaining(join(appRoot, ".eve", "builds")), outputDir: expect.stringContaining(join(appRoot, ".eve", "builds")), @@ -220,6 +221,9 @@ describe("buildApplication", () => { ); expect(workflowBuilderBuildVercelOutputMock).not.toHaveBeenCalled(); expect(runVercelBuildPrewarmMock).not.toHaveBeenCalled(); + await expect( + readFile(join(appRoot, ".eve", "compile", "compiled-agent-manifest.json"), "utf8"), + ).rejects.toThrow(); const summary = JSON.parse( await readFile(join(appRoot, VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH), "utf8"), @@ -234,13 +238,10 @@ describe("buildApplication", () => { const appRoot = await createScratchDirectory("eve-build-application-last-good-"); const outputDir = join(appRoot, ".output"); const summaryPath = join(appRoot, VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH); - prepareProductionApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); - createApplicationNitroMock.mockImplementationOnce( - async ( - _preparedHost: PreparedApplicationHost, - _dev: boolean, - options: { outputDir?: string } = {}, - ) => createNitroStub(options.outputDir ?? outputDir), + prepareProductionApplicationHostMock.mockImplementationOnce(prepareHostBuildWorkspace); + createProductionApplicationNitroMock.mockImplementationOnce( + async (_preparedHost: PreparedApplicationHost, options: { outputDir: string }) => + createNitroStub(options.outputDir), ); buildNitroMock.mockImplementationOnce(async (nitro: Nitro) => { await mkdir(nitro.options.output.dir, { recursive: true }); @@ -275,15 +276,10 @@ describe("buildApplication", () => { const stableFlowOutputDir = join(appRoot, ".eve", "nitro-output", "flow"); const staleFlowOutputPath = join(stableFlowOutputDir, "stale-flow.txt"); - prepareProductionApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); - createApplicationNitroMock.mockImplementation( - async ( - _preparedHost: PreparedApplicationHost, - _dev: boolean, - options: { outputDir?: string; surface?: string } = {}, - ) => { - return createNitroStub(options.outputDir ?? join(appRoot, ".output")); - }, + prepareProductionApplicationHostMock.mockImplementationOnce(prepareHostBuildWorkspace); + createProductionApplicationNitroMock.mockImplementation( + async (_preparedHost: PreparedApplicationHost, options: { outputDir: string }) => + createNitroStub(options.outputDir), ); await mkdir(stableFlowOutputDir, { recursive: true }); await Promise.all([ @@ -323,14 +319,13 @@ describe("buildApplication", () => { const outputDir = await buildApplication(appRoot, DEPLOYABLE_BUILD_OPTIONS); expect(outputDir).toBe(join(appRoot, ".vercel", "output")); - expect(createApplicationNitroMock).toHaveBeenCalledTimes(2); - expect(createApplicationNitroMock.mock.calls.map((call) => call[2]?.surface ?? "all")).toEqual([ - "app", - "flow", - ]); - const flowOutputDir = createApplicationNitroMock.mock.calls.find( - (call) => call[2]?.surface === "flow", - )?.[2]?.outputDir; + expect(createProductionApplicationNitroMock).toHaveBeenCalledTimes(2); + expect(createProductionApplicationNitroMock.mock.calls.map((call) => call[1]?.surface)).toEqual( + ["app", "flow"], + ); + const flowOutputDir = createProductionApplicationNitroMock.mock.calls.find( + (call) => call[1]?.surface === "flow", + )?.[1]?.outputDir; expect(flowOutputDir).toEqual(expect.stringContaining(join(appRoot, ".eve", "builds"))); expect(workflowBuilderConstructors).toHaveLength(1); expect(workflowBuilderBuildVercelOutputMock).toHaveBeenCalledWith({ @@ -432,13 +427,10 @@ describe("buildApplication", () => { const appRoot = await createScratchDirectory("eve-build-application-vercel-nuxt-"); const flowOutputDir = join(appRoot, ".eve", "nitro-output", "flow"); - prepareProductionApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); - createApplicationNitroMock.mockImplementation( - async ( - _preparedHost: PreparedApplicationHost, - _dev: boolean, - options: { outputDir?: string; surface?: string } = {}, - ) => createNitroStub(options.outputDir ?? join(appRoot, ".output")), + prepareProductionApplicationHostMock.mockImplementationOnce(prepareHostBuildWorkspace); + createProductionApplicationNitroMock.mockImplementation( + async (_preparedHost: PreparedApplicationHost, options: { outputDir: string }) => + createNitroStub(options.outputDir), ); await mkdir(flowOutputDir, { recursive: true }); await writeFile( @@ -493,13 +485,10 @@ describe("buildApplication", () => { vi.stubEnv("VERCEL", "1"); const appRoot = await createScratchDirectory("eve-build-application-vercel-service-array-"); - prepareProductionApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); - createApplicationNitroMock.mockImplementation( - async ( - _preparedHost: PreparedApplicationHost, - _dev: boolean, - options: { outputDir?: string; surface?: string } = {}, - ) => createNitroStub(options.outputDir ?? join(appRoot, ".vercel", "output")), + prepareProductionApplicationHostMock.mockImplementationOnce(prepareHostBuildWorkspace); + createProductionApplicationNitroMock.mockImplementation( + async (_preparedHost: PreparedApplicationHost, options: { outputDir: string }) => + createNitroStub(options.outputDir), ); await mkdir(join(appRoot, ".vercel", "output"), { recursive: true }); await writeFile( @@ -547,13 +536,10 @@ describe("buildApplication", () => { const projectRoot = await createScratchDirectory("eve-build-application-vercel-root-dir-"); const appRoot = join(projectRoot, "apps", "web", "agents", "support"); - prepareProductionApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); - createApplicationNitroMock.mockImplementation( - async ( - _preparedHost: PreparedApplicationHost, - _dev: boolean, - options: { outputDir?: string; surface?: string } = {}, - ) => createNitroStub(options.outputDir ?? join(appRoot, ".vercel", "output")), + prepareProductionApplicationHostMock.mockImplementationOnce(prepareHostBuildWorkspace); + createProductionApplicationNitroMock.mockImplementation( + async (_preparedHost: PreparedApplicationHost, options: { outputDir: string }) => + createNitroStub(options.outputDir), ); await mkdir(join(projectRoot, ".vercel", "output"), { recursive: true }); await writeFile( @@ -610,13 +596,10 @@ describe("buildApplication", () => { const appRoot = await createScratchDirectory("eve-build-application-vercel-root-config-"); const flowOutputDir = join(appRoot, ".eve", "nitro-output", "flow"); - prepareProductionApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); - createApplicationNitroMock.mockImplementation( - async ( - _preparedHost: PreparedApplicationHost, - _dev: boolean, - options: { outputDir?: string; surface?: string } = {}, - ) => createNitroStub(options.outputDir ?? join(appRoot, ".output")), + prepareProductionApplicationHostMock.mockImplementationOnce(prepareHostBuildWorkspace); + createProductionApplicationNitroMock.mockImplementation( + async (_preparedHost: PreparedApplicationHost, options: { outputDir: string }) => + createNitroStub(options.outputDir), ); await Promise.all([ mkdir(flowOutputDir, { recursive: true }), @@ -662,13 +645,10 @@ describe("buildApplication", () => { vi.stubEnv("VERCEL", "1"); const appRoot = await createScratchDirectory("eve-build-application-vercel-standalone-"); - prepareProductionApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); - createApplicationNitroMock.mockImplementation( - async ( - _preparedHost: PreparedApplicationHost, - _dev: boolean, - options: { outputDir?: string; surface?: string } = {}, - ) => createNitroStub(options.outputDir ?? join(appRoot, ".output")), + prepareProductionApplicationHostMock.mockImplementationOnce(prepareHostBuildWorkspace); + createProductionApplicationNitroMock.mockImplementation( + async (_preparedHost: PreparedApplicationHost, options: { outputDir: string }) => + createNitroStub(options.outputDir), ); const { buildApplication } = await import("#internal/nitro/host/build-application.js"); diff --git a/packages/eve/src/internal/nitro/host/build-application.ts b/packages/eve/src/internal/nitro/host/build-application.ts index b59b714b7..987648498 100644 --- a/packages/eve/src/internal/nitro/host/build-application.ts +++ b/packages/eve/src/internal/nitro/host/build-application.ts @@ -18,7 +18,7 @@ import { publishApplicationBuildArtifacts } from "#internal/application/output-p import { stageProductionStartArtifacts } from "#internal/application/production-start-artifacts.js"; import { WorkflowBundleBuilder } from "#internal/workflow-bundle/builder.js"; import { normalizeEveVercelFunctionOutput } from "#internal/workflow-bundle/vercel-workflow-output.js"; -import { createApplicationNitro } from "#internal/nitro/host/create-application-nitro.js"; +import { createProductionApplicationNitro } from "#internal/nitro/host/create-application-nitro.js"; import { emitVercelAgentSummary } from "#internal/nitro/host/build-vercel-agent-summary.js"; import { tryReadExtensionBuildConfig } from "#internal/nitro/host/build-extension.js"; import { prepareProductionApplicationHost } from "#internal/nitro/host/prepare-application-host.js"; @@ -289,7 +289,7 @@ async function buildVercelNitroSurface( workspace: ApplicationBuildWorkspace, surface: Exclude, ): Promise { - const nitro = await createApplicationNitro(preparedHost, false, { + const nitro = await createProductionApplicationNitro(preparedHost, { buildDir: join(workspace.nitroBuildDir, surface), outputDir: join(workspace.nitroOutputDir, surface), surface, @@ -335,9 +335,10 @@ async function buildApplicationInWorkspace( const preparedHost = await prepareProductionApplicationHost(workspace); if (!process.env.VERCEL) { - const nitro = await createApplicationNitro(preparedHost, false, { + const nitro = await createProductionApplicationNitro(preparedHost, { buildDir: workspace.nitroBuildDir, outputDir: workspace.outputDir, + surface: "all", }); try { @@ -362,7 +363,7 @@ async function buildApplicationInWorkspace( preparedHost.appRoot, preparedHost.compileResult.project.agentRoot, ); - const nitro = await createApplicationNitro(preparedHost, false, { + const nitro = await createProductionApplicationNitro(preparedHost, { buildDir: join(workspace.nitroBuildDir, "app"), outputDir: workspace.outputDir, surface: "app", diff --git a/packages/eve/src/internal/nitro/host/configure-nitro-routes.test.ts b/packages/eve/src/internal/nitro/host/configure-nitro-routes.test.ts index 1676858eb..805a04d34 100644 --- a/packages/eve/src/internal/nitro/host/configure-nitro-routes.test.ts +++ b/packages/eve/src/internal/nitro/host/configure-nitro-routes.test.ts @@ -84,7 +84,8 @@ vi.mock("../../application/paths.js", () => ({ isVercelBuildEnvironment: () => Boolean(process.env.VERCEL), })); -const { configureNitroRoutes } = await import("./configure-nitro-routes.js"); +const { configureDevelopmentNitroRoutes, configureProductionNitroRoutes } = + await import("./configure-nitro-routes.js"); const { EVE_HEALTH_ROUTE_PATH, EVE_INFO_ROUTE_PATH } = await import("#protocol/routes.js"); function createNitroStub( @@ -151,7 +152,7 @@ function createPreparedHost( return preparedHost as never as PreparedApplicationHost; } -describe("configureNitroRoutes", () => { +describe("Nitro route configuration", () => { beforeEach(() => { fsMocks.mkdir.mockClear(); fsMocks.writeFile.mockClear(); @@ -164,9 +165,7 @@ describe("configureNitroRoutes", () => { it("registers package-owned route files through file-url virtual handlers", async () => { const nitro = createNitroStub(); - await configureNitroRoutes(nitro, createPreparedHost(), { - surface: "app", - }); + await configureProductionNitroRoutes(nitro, createPreparedHost(), "app"); const healthHandler = nitro.options.handlers.find( (handler) => handler.route === EVE_HEALTH_ROUTE_PATH && handler.method === "GET", @@ -183,9 +182,11 @@ describe("configureNitroRoutes", () => { it("bakes the agent name into the home page route", async () => { const nitro = createNitroStub(); - await configureNitroRoutes(nitro, createPreparedHost({ agentName: "support-agent" }), { - surface: "app", - }); + await configureProductionNitroRoutes( + nitro, + createPreparedHost({ agentName: "support-agent" }), + "app", + ); const homeHandler = nitro.options.handlers.find( (handler) => handler.route === "/" && handler.method === "GET", @@ -200,9 +201,7 @@ describe("configureNitroRoutes", () => { it("registers the health route for HEAD so load balancers probing with HEAD see 200", async () => { const nitro = createNitroStub(); - await configureNitroRoutes(nitro, createPreparedHost(), { - surface: "app", - }); + await configureProductionNitroRoutes(nitro, createPreparedHost(), "app"); const healthMethods = nitro.options.handlers .filter((handler) => handler.route === EVE_HEALTH_ROUTE_PATH) @@ -227,15 +226,12 @@ describe("configureNitroRoutes", () => { const workflowBuildDir = `${root}/workflow-cache`; const nitro = createNitroStub({ buildDir, dev: true, rootDir: root }); - await configureNitroRoutes( + await configureDevelopmentNitroRoutes( nitro, createPreparedHost({ appRoot: root, workflowBuildDir, }), - { - surface: "flow", - }, ); const workflowHandler = nitro.options.handlers.find( @@ -260,9 +256,7 @@ describe("configureNitroRoutes", () => { const buildDir = `${root}/nitro`; const nitro = createNitroStub({ buildDir, dev: true, rootDir: root }); - await configureNitroRoutes(nitro, createPreparedHost({ appRoot: root }), { - surface: "all", - }); + await configureDevelopmentNitroRoutes(nitro, createPreparedHost({ appRoot: root })); const workflowHandlerSource = readWriteFileSourceMatching("/workflow/workflows-handler.mjs"); @@ -286,12 +280,8 @@ describe("configureNitroRoutes", () => { const devNitro = createNitroStub({ dev: true }); const prodNitro = createNitroStub({ dev: false }); - await configureNitroRoutes(devNitro, createPreparedHost(), { - surface: "app", - }); - await configureNitroRoutes(prodNitro, createPreparedHost(), { - surface: "app", - }); + await configureDevelopmentNitroRoutes(devNitro, createPreparedHost()); + await configureProductionNitroRoutes(prodNitro, createPreparedHost(), "app"); expect(devNitro.options.handlers).toContainEqual({ handler: "#eve-route/eve/v1/dev/runtime-artifacts", @@ -314,12 +304,8 @@ describe("configureNitroRoutes", () => { const devNitro = createNitroStub({ dev: true }); const prodNitro = createNitroStub({ dev: false }); - await configureNitroRoutes(devNitro, createPreparedHost(), { - surface: "app", - }); - await configureNitroRoutes(prodNitro, createPreparedHost(), { - surface: "app", - }); + await configureDevelopmentNitroRoutes(devNitro, createPreparedHost()); + await configureProductionNitroRoutes(prodNitro, createPreparedHost(), "app"); expect(devNitro.options.handlers).toContainEqual({ handler: `#nitro/virtual/eve-channel/GET ${EVE_INFO_ROUTE_PATH}`, @@ -355,16 +341,14 @@ describe("configureNitroRoutes", () => { const workflowBuildDir = `${root}/workflow-cache`; const nitro = createNitroStub({ buildDir, dev: false, rootDir: root }); - await configureNitroRoutes( + await configureProductionNitroRoutes( nitro, createPreparedHost({ appRoot: root, workflowBuildDir, workflowWorld: "@workflow/world-postgres", }), - { - surface: "all", - }, + "all", ); const workflowHandlerSource = readWriteFileSourceMatching("/workflow/workflows-handler.mjs"); @@ -383,16 +367,14 @@ describe("configureNitroRoutes", () => { const workflowBuildDir = `${root}/workflow-cache`; const nitro = createNitroStub({ buildDir, dev: false, rootDir: root }); - await configureNitroRoutes( + await configureProductionNitroRoutes( nitro, createPreparedHost({ appRoot: root, workflowBuildDir, workflowWorld: "@workflow/world-postgres", }), - { - surface: "all", - }, + "all", ); const workflowHandlerSource = readWriteFileSourceMatching("/workflow/workflows-handler.mjs"); @@ -415,9 +397,11 @@ describe("configureNitroRoutes", () => { const workflowBuildDir = `${root}/workflow-cache`; const nitro = createNitroStub({ buildDir, dev: false, rootDir: root }); - await configureNitroRoutes(nitro, createPreparedHost({ appRoot: root, workflowBuildDir }), { - surface: "all", - }); + await configureProductionNitroRoutes( + nitro, + createPreparedHost({ appRoot: root, workflowBuildDir }), + "all", + ); const workflowHandlerSource = readWriteFileSourceMatching("/workflow/workflows-handler.mjs"); 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 63e87a029..c24548e9e 100644 --- a/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts +++ b/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts @@ -265,178 +265,190 @@ function addFrameworkVirtualHandler( ].join("\n"); } -/** - * Wires eve's package-owned app, channel, workflow inspection, and Workflow - * SDK endpoints into one Nitro host instance. - */ -export async function configureNitroRoutes( +function createSerializedWorkflowArtifactSync( + buildWorkflowArtifacts: () => Promise, +): () => Promise { + let previousBuild: Promise = Promise.resolve(); + + return async () => { + const nextBuild = previousBuild.then(buildWorkflowArtifacts); + previousBuild = nextBuild.catch(() => {}); + await nextBuild; + }; +} + +async function registerWorkflowArtifactBuildHook( nitro: Nitro, - preparedHost: PreparedApplicationHost, - input: { - surface: NitroBuildSurface; - }, + syncWorkflowArtifacts: () => Promise, ): Promise { - if (includesWorkflowBundles(input.surface)) { - const packageRoot = resolvePackageRoot(); - const builder = new WorkflowBundleBuilder({ + let isInitialBuild = true; + + await syncWorkflowArtifacts(); + nitro.hooks.hook("build:before", async () => { + if (isInitialBuild) { + isInitialBuild = false; + return; + } + + await syncWorkflowArtifacts(); + }); +} + +function registerApplicationRoutes( + nitro: Nitro, + preparedHost: PreparedApplicationHost, + artifactsConfig: NitroArtifactsConfigInput, +): void { + addFrameworkVirtualHandler(nitro, { + args: JSON.stringify({ agentName: preparedHost.compileResult.manifest.config.name, - appRoot: preparedHost.appRoot, - compiledArtifactsBootstrapPath: preparedHost.compiledArtifacts.bootstrapPath, - outDir: preparedHost.workflowBuildDir, - rootDir: packageRoot, - watch: nitro.options.dev, + }), + handlerExport: "handleHomePageRequest", + method: "GET", + modulePath: resolvePackageSourceFilePath("src/internal/nitro/routes/index.ts"), + route: "/", + }); + for (const method of ["GET", "HEAD"] as const) { + registerHandler(nitro, { + handlerPath: resolvePackageSourceFilePath("src/internal/nitro/routes/health.ts"), + method, + route: EVE_HEALTH_ROUTE_PATH, }); - let syncWorkflowArtifactsPromise: Promise = Promise.resolve(); - const buildWorkflowArtifacts = async (): Promise => { - await builder.build({ - nitroStepOutfile: includesWorkflowRoute(input.surface) - ? join(resolveNitroWorkflowBuildDirectory(nitro), "steps.mjs") - : undefined, - nitroWorkflowOutfile: - nitro.options.dev && includesWorkflowRoute(input.surface) - ? join(resolveNitroWorkflowBuildDirectory(nitro), "workflows.mjs") - : undefined, - }); - }; - const syncWorkflowArtifacts = async (): Promise => { - const nextSync = syncWorkflowArtifactsPromise.then(buildWorkflowArtifacts); - syncWorkflowArtifactsPromise = nextSync.catch(() => {}); - await nextSync; - }; + } + registerChannelVirtualHandlers(nitro, { + artifactsConfig, + registrations: computeChannelRouteRegistrations(preparedHost), + }); +} - let isInitialBuild = true; +function registerDevelopmentControlRoutes( + nitro: Nitro, + artifactsConfig: NitroArtifactsConfigInput, +): void { + addFrameworkVirtualHandler(nitro, { + args: JSON.stringify({ appRoot: artifactsConfig.appRoot }), + handlerExport: "handleDevRuntimeArtifactsRequest", + method: "GET", + modulePath: resolvePackageSourceFilePath("src/internal/nitro/routes/dev-runtime-artifacts.ts"), + route: EVE_DEV_RUNTIME_ARTIFACTS_ROUTE_PATH, + }); + addFrameworkVirtualHandler(nitro, { + args: JSON.stringify({ appRoot: artifactsConfig.appRoot }), + handlerExport: "handleDevScheduleDispatchRequest", + method: "POST", + modulePath: resolvePackageSourceFilePath("src/internal/nitro/routes/dev-schedule-dispatch.ts"), + route: EVE_DEV_DISPATCH_SCHEDULE_ROUTE_PATTERN, + }); +} - await syncWorkflowArtifacts(); +function createWorkflowDirectHandlerEntry( + preparedHost: PreparedApplicationHost, + bundlePath: string, +): WorkflowDirectHandlerEntry { + return { + bundlePath, + queuePrefix: deriveEveWorkflowQueuePrefix(preparedHost.compileResult.manifest.config.name), + }; +} - nitro.hooks.hook("build:before", async () => { - if (isInitialBuild) { - isInitialBuild = false; - return; - } +async function registerWorkflowRoute( + nitro: Nitro, + preparedHost: PreparedApplicationHost, + workflowBundlePath: string, + directHandlers: ReadonlyArray, +): Promise { + const runtimeImportSpecifier = + directHandlers.length === 0 + ? undefined + : normalizeEsmImportSpecifier(resolveWorkflowModulePath("workflow/runtime")); + + await addWorkflowFileHandler(nitro, { + bundleName: "workflows", + bundlePath: workflowBundlePath, + directHandlers, + route: "/.well-known/workflow/v1/flow", + runtimeImportSpecifier, + workflowWorldPluginPath: preparedHost.compiledArtifacts.workflowWorldPluginPath, + }); +} - await syncWorkflowArtifacts(); +/** Wires development-only application and Workflow routes into a Nitro host. */ +export async function configureDevelopmentNitroRoutes( + nitro: Nitro, + preparedHost: PreparedApplicationHost, +): Promise { + const workflowBuildDirectory = resolveNitroWorkflowBuildDirectory(nitro); + const builder = new WorkflowBundleBuilder({ + agentName: preparedHost.compileResult.manifest.config.name, + appRoot: preparedHost.appRoot, + compiledArtifactsBootstrapPath: preparedHost.compiledArtifacts.bootstrapPath, + outDir: preparedHost.workflowBuildDir, + rootDir: resolvePackageRoot(), + watch: true, + }); + const syncWorkflowArtifacts = createSerializedWorkflowArtifactSync(async () => { + await builder.build({ + nitroStepOutfile: join(workflowBuildDirectory, "steps.mjs"), + nitroWorkflowOutfile: join(workflowBuildDirectory, "workflows.mjs"), }); + }); - if (nitro.options.dev) { - nitro.hooks.hook("dev:reload", async () => { - await syncWorkflowArtifacts(); - }); - } - } + await registerWorkflowArtifactBuildHook(nitro, syncWorkflowArtifacts); + nitro.hooks.hook("dev:reload", syncWorkflowArtifacts); - let artifactsConfig: NitroArtifactsConfigInput; - if (nitro.options.dev) { - artifactsConfig = createDevelopmentNitroArtifactsConfig({ appRoot: preparedHost.appRoot }); - } else { - artifactsConfig = createProductionNitroArtifactsConfig({ appRoot: preparedHost.appRoot }); - } + const artifactsConfig = createDevelopmentNitroArtifactsConfig({ + appRoot: preparedHost.appRoot, + }); + registerApplicationRoutes(nitro, preparedHost, artifactsConfig); + registerDevelopmentControlRoutes(nitro, artifactsConfig); - if (includesApplicationRoutes(input.surface)) { - addFrameworkVirtualHandler(nitro, { - args: JSON.stringify({ - agentName: preparedHost.compileResult.manifest.config.name, - }), - handlerExport: "handleHomePageRequest", - method: "GET", - modulePath: resolvePackageSourceFilePath("src/internal/nitro/routes/index.ts"), - route: "/", - }); - // Register health for GET and HEAD: each (method, route) pair is a - // distinct Nitro handler, so HEAD must be registered explicitly or load - // balancers and uptime monitors that probe with HEAD get a 404. Nitro - // runs the handler for HEAD and omits the body, leaving GET unchanged. - for (const method of ["GET", "HEAD"] as const) { - registerHandler(nitro, { - handlerPath: resolvePackageSourceFilePath("src/internal/nitro/routes/health.ts"), - method, - route: EVE_HEALTH_ROUTE_PATH, - }); - } + const workflowBundlePath = join(workflowBuildDirectory, "workflows.mjs"); + await registerWorkflowRoute(nitro, preparedHost, workflowBundlePath, [ + createWorkflowDirectHandlerEntry(preparedHost, workflowBundlePath), + ]); + nitro.routing.sync(); +} - // Per-channel mounting: one virtual Nitro handler per (method, urlPath) in - // the merged channel set. Each handler bakes in its route key and artifacts - // config so the dispatch function can look up the channel and resolve - // compiled artifacts directly. - registerChannelVirtualHandlers(nitro, { - artifactsConfig, - registrations: computeChannelRouteRegistrations(preparedHost), +/** Wires production application and Workflow routes for one build surface. */ +export async function configureProductionNitroRoutes( + nitro: Nitro, + preparedHost: PreparedApplicationHost, + surface: NitroBuildSurface, +): Promise { + if (includesWorkflowBundles(surface)) { + const builder = new WorkflowBundleBuilder({ + agentName: preparedHost.compileResult.manifest.config.name, + appRoot: preparedHost.appRoot, + compiledArtifactsBootstrapPath: preparedHost.compiledArtifacts.bootstrapPath, + outDir: preparedHost.workflowBuildDir, + rootDir: resolvePackageRoot(), + watch: false, }); - - // Dev-only artifact and control routes. These need `appRoot` baked at - // build time so their handlers can read the dev runtime artifacts from - // disk, and they are never registered in production builds. - if (nitro.options.dev) { - addFrameworkVirtualHandler(nitro, { - args: JSON.stringify({ appRoot: artifactsConfig.appRoot }), - handlerExport: "handleDevRuntimeArtifactsRequest", - method: "GET", - modulePath: resolvePackageSourceFilePath( - "src/internal/nitro/routes/dev-runtime-artifacts.ts", - ), - route: EVE_DEV_RUNTIME_ARTIFACTS_ROUTE_PATH, - }); - addFrameworkVirtualHandler(nitro, { - args: JSON.stringify({ appRoot: artifactsConfig.appRoot }), - handlerExport: "handleDevScheduleDispatchRequest", - method: "POST", - modulePath: resolvePackageSourceFilePath( - "src/internal/nitro/routes/dev-schedule-dispatch.ts", - ), - route: EVE_DEV_DISPATCH_SCHEDULE_ROUTE_PATTERN, + const syncWorkflowArtifacts = createSerializedWorkflowArtifactSync(async () => { + await builder.build({ + nitroStepOutfile: join(resolveNitroWorkflowBuildDirectory(nitro), "steps.mjs"), }); - } + }); + await registerWorkflowArtifactBuildHook(nitro, syncWorkflowArtifacts); } - const workflowBuildDirectory = resolveNitroWorkflowBuildDirectory(nitro); - const workflowBundlePath = includesWorkflowRoute(input.surface) - ? nitro.options.dev - ? join(workflowBuildDirectory, "workflows.mjs") - : join(preparedHost.workflowBuildDir, "workflows.mjs") - : undefined; - - // 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[] = - localWorldDrivesQueue && workflowBundlePath !== undefined - ? [ - { - bundlePath: workflowBundlePath, - queuePrefix: deriveEveWorkflowQueuePrefix( - preparedHost.compileResult.manifest.config.name, - ), - }, - ] - : []; - // Generated handlers will JSON-stringify this at write-time, so we hand them - // an ESM-safe specifier (Windows drive paths get converted to file://) but - // skip the surrounding quotes that `stringifyEsmImportSpecifier` adds. - const runtimeImportSpecifier = - directHandlerEntries.length > 0 - ? normalizeEsmImportSpecifier(resolveWorkflowModulePath("workflow/runtime")) - : undefined; + if (includesApplicationRoutes(surface)) { + registerApplicationRoutes( + nitro, + preparedHost, + createProductionNitroArtifactsConfig({ appRoot: preparedHost.appRoot }), + ); + } - if (workflowBundlePath) { - await addWorkflowFileHandler(nitro, { - bundleName: "workflows", - bundlePath: workflowBundlePath, - directHandlers: directHandlerEntries, - route: "/.well-known/workflow/v1/flow", - runtimeImportSpecifier, - workflowWorldPluginPath: preparedHost.compiledArtifacts.workflowWorldPluginPath, - }); + if (includesWorkflowRoute(surface)) { + const workflowBundlePath = join(preparedHost.workflowBuildDir, "workflows.mjs"); + const hasConfiguredWorkflowWorld = + preparedHost.compileResult.manifest.config.experimental?.workflow?.world !== undefined; + const directHandlers = + !isVercelBuildEnvironment() && hasConfiguredWorkflowWorld + ? [createWorkflowDirectHandlerEntry(preparedHost, workflowBundlePath)] + : []; + await registerWorkflowRoute(nitro, preparedHost, workflowBundlePath, directHandlers); } nitro.routing.sync(); diff --git a/packages/eve/src/internal/nitro/host/create-application-nitro.scenario.test.ts b/packages/eve/src/internal/nitro/host/create-application-nitro.scenario.test.ts index 2ff36a906..3f9cf529f 100644 --- a/packages/eve/src/internal/nitro/host/create-application-nitro.scenario.test.ts +++ b/packages/eve/src/internal/nitro/host/create-application-nitro.scenario.test.ts @@ -23,10 +23,11 @@ import { resolveWorkflowModulePath, } from "#internal/application/package.js"; import { resolveNitroBuildDirectory } from "#internal/application/paths.js"; -import type { PreparedApplicationHost } from "#internal/nitro/host/types.js"; +import type { NitroBuildSurface, PreparedApplicationHost } from "#internal/nitro/host/types.js"; import { applyWorkflowTransform } from "#internal/workflow-bundle/workflow-builders.js"; -const configureNitroRoutes = vi.fn(async () => undefined); +const configureDevelopmentNitroRoutes = vi.fn(async () => undefined); +const configureProductionNitroRoutes = vi.fn(async () => undefined); const createNitroMock = vi.fn(); const registerScheduleTaskHandlers = vi.fn(); @@ -39,7 +40,8 @@ vi.mock("./schedule-task-routes.js", () => ({ })); vi.mock("./configure-nitro-routes.js", () => ({ - configureNitroRoutes, + configureDevelopmentNitroRoutes, + configureProductionNitroRoutes, })); vi.mock("#internal/workflow-bundle/workflow-builders.js", () => ({ @@ -144,7 +146,18 @@ function createPreparedHost(): PreparedApplicationHost { }; } -describe("createApplicationNitro", () => { +function createProductionOptions( + preparedHost: PreparedApplicationHost, + surface: NitroBuildSurface, +) { + return { + buildDir: resolveNitroBuildDirectory(preparedHost.appRoot, surface), + outputDir: join(preparedHost.appRoot, ".output"), + surface, + }; +} + +describe("application Nitro creation", () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); @@ -158,10 +171,10 @@ describe("createApplicationNitro", () => { const nitroStub = createNitroStub(); createNitroMock.mockResolvedValueOnce(nitroStub.nitro); - const { createApplicationNitro } = + const { createDevelopmentApplicationNitro } = await import("#internal/nitro/host/create-application-nitro.js"); const preparedHost = createPreparedHost(); - await createApplicationNitro(preparedHost, true); + await createDevelopmentApplicationNitro(preparedHost); const plugins = createNitroMock.mock.calls[0]?.[0].plugins as string[]; @@ -174,10 +187,13 @@ describe("createApplicationNitro", () => { const nitroStub = createNitroStub(); createNitroMock.mockResolvedValueOnce(nitroStub.nitro); - const { createApplicationNitro } = + const { createProductionApplicationNitro } = await import("#internal/nitro/host/create-application-nitro.js"); const preparedHost = createPreparedHost(); - await createApplicationNitro(preparedHost, false); + await createProductionApplicationNitro( + preparedHost, + createProductionOptions(preparedHost, "all"), + ); const rollupBeforeHooks = nitroStub.hookHandlers.get("rollup:before") ?? []; const originalTransform = vi.fn((code: string, id: string) => `${code}:${id}:transformed`); @@ -244,10 +260,10 @@ describe("createApplicationNitro", () => { }); createNitroMock.mockResolvedValueOnce(nitroStub.nitro); - const { createApplicationNitro } = + const { createDevelopmentApplicationNitro } = await import("#internal/nitro/host/create-application-nitro.js"); const preparedHost = createPreparedHost(); - await createApplicationNitro(preparedHost, true); + await createDevelopmentApplicationNitro(preparedHost); const rollupBeforeHooks = nitroStub.hookHandlers.get("rollup:before") ?? []; const existingExternal = vi.fn((id: string) => @@ -275,10 +291,10 @@ describe("createApplicationNitro", () => { const nitroStub = createNitroStub(); createNitroMock.mockResolvedValueOnce(nitroStub.nitro); - const { createApplicationNitro } = + const { createDevelopmentApplicationNitro } = await import("#internal/nitro/host/create-application-nitro.js"); const preparedHost = createPreparedHost(); - await createApplicationNitro(preparedHost, true); + await createDevelopmentApplicationNitro(preparedHost); expect(createNitroMock).toHaveBeenCalledTimes(1); expect(createNitroMock.mock.calls[0]?.[0]).toMatchObject({ @@ -291,10 +307,10 @@ describe("createApplicationNitro", () => { const nitroStub = createNitroStub(); createNitroMock.mockResolvedValueOnce(nitroStub.nitro); - const { createApplicationNitro } = + const { createDevelopmentApplicationNitro } = await import("#internal/nitro/host/create-application-nitro.js"); const preparedHost = createPreparedHost(); - await createApplicationNitro(preparedHost, true); + await createDevelopmentApplicationNitro(preparedHost); expect(createNitroMock).toHaveBeenCalledTimes(1); expect(createNitroMock.mock.calls[0]?.[0]).toMatchObject({ @@ -309,11 +325,13 @@ describe("createApplicationNitro", () => { const nitroStub = createNitroStub(); createNitroMock.mockResolvedValueOnce(nitroStub.nitro); - const { createApplicationNitro } = + const { createProductionApplicationNitro } = await import("#internal/nitro/host/create-application-nitro.js"); - await createApplicationNitro(createPreparedHost(), false, { - surface: "app", - }); + const preparedHost = createPreparedHost(); + await createProductionApplicationNitro( + preparedHost, + createProductionOptions(preparedHost, "app"), + ); expect(createNitroMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -328,7 +346,6 @@ describe("createApplicationNitro", () => { }, }, }), - undefined, ); }); @@ -337,7 +354,7 @@ describe("createApplicationNitro", () => { const nitroStub = createNitroStub(); createNitroMock.mockResolvedValueOnce(nitroStub.nitro); - const { createApplicationNitro } = + const { createProductionApplicationNitro } = await import("#internal/nitro/host/create-application-nitro.js"); const preparedHost = createPreparedHost(); const websocketChannel: CompiledChannelEntry = { @@ -351,9 +368,10 @@ describe("createApplicationNitro", () => { }; preparedHost.compileResult.manifest.channels = [websocketChannel]; - await createApplicationNitro(preparedHost, false, { - surface: "app", - }); + await createProductionApplicationNitro( + preparedHost, + createProductionOptions(preparedHost, "app"), + ); const nitroOptions = createNitroMock.mock.calls[0]?.[0]; expect(nitroOptions).toMatchObject({ @@ -396,9 +414,12 @@ describe("createApplicationNitro", () => { writeFile(staleBuildOutputPath, "stale\n"), ]); - const { createApplicationNitro } = + const { createProductionApplicationNitro } = await import("#internal/nitro/host/create-application-nitro.js"); - await createApplicationNitro(preparedHost, false); + await createProductionApplicationNitro( + preparedHost, + createProductionOptions(preparedHost, "all"), + ); await expect(readFile(staleBuildOutputPath, "utf8")).rejects.toThrow(); await expect(readFile(join(nitroBuildDir, "eve-cache.json"), "utf8")).resolves.toBe( @@ -419,8 +440,12 @@ describe("createApplicationNitro", () => { const nitroStub = createNitroStub(); createNitroMock.mockResolvedValueOnce(nitroStub.nitro); - const { createApplicationNitro } = await import("./create-application-nitro.js"); - await createApplicationNitro(createPreparedHost(), false); + const { createProductionApplicationNitro } = await import("./create-application-nitro.js"); + const preparedHost = createPreparedHost(); + await createProductionApplicationNitro( + preparedHost, + createProductionOptions(preparedHost, "all"), + ); const rollupBeforeHooks = nitroStub.hookHandlers.get("rollup:before") ?? []; const config = { @@ -476,7 +501,7 @@ describe("createApplicationNitro", () => { .mockResolvedValueOnce(appNitroStub.nitro) .mockResolvedValueOnce(flowNitroStub.nitro); - const { createApplicationNitro } = + const { createProductionApplicationNitro } = await import("#internal/nitro/host/create-application-nitro.js"); const preparedHost = createPreparedHost(); preparedHost.compileResult.manifest.config = { @@ -486,13 +511,18 @@ describe("createApplicationNitro", () => { }, } as typeof preparedHost.compileResult.manifest.config; - await createApplicationNitro(preparedHost, false); - await createApplicationNitro(preparedHost, false, { - surface: "app", - }); - await createApplicationNitro(preparedHost, false, { - surface: "flow", - }); + await createProductionApplicationNitro( + preparedHost, + createProductionOptions(preparedHost, "all"), + ); + await createProductionApplicationNitro( + preparedHost, + createProductionOptions(preparedHost, "app"), + ); + await createProductionApplicationNitro( + preparedHost, + createProductionOptions(preparedHost, "flow"), + ); for (const call of createNitroMock.mock.calls.slice(0, 3)) { const traceDeps = call[0].traceDeps; @@ -510,7 +540,7 @@ describe("createApplicationNitro", () => { const nitroStub = createNitroStub(); createNitroMock.mockResolvedValueOnce(nitroStub.nitro); - const { createApplicationNitro } = + const { createProductionApplicationNitro } = await import("#internal/nitro/host/create-application-nitro.js"); const preparedHost = createPreparedHost(); const subagent: CompiledSubagentNode = { @@ -539,7 +569,10 @@ describe("createApplicationNitro", () => { }; preparedHost.compileResult.manifest.subagents = [subagent]; - await createApplicationNitro(preparedHost, false); + await createProductionApplicationNitro( + preparedHost, + createProductionOptions(preparedHost, "all"), + ); const traceDeps = createNitroMock.mock.calls[0]?.[0].traceDeps; expect(traceDeps).toEqual(expect.arrayContaining(["subagent-external", "sharp"])); @@ -552,11 +585,14 @@ describe("createApplicationNitro", () => { const nitroStub = createNitroStub(); createNitroMock.mockResolvedValueOnce(nitroStub.nitro); - const { createApplicationNitro } = + const { createProductionApplicationNitro } = await import("#internal/nitro/host/create-application-nitro.js"); const preparedHost = createPreparedHost(); - await createApplicationNitro(preparedHost, false); + await createProductionApplicationNitro( + preparedHost, + createProductionOptions(preparedHost, "all"), + ); expect(createNitroMock.mock.calls[0]?.[0].traceDeps).toEqual( expect.arrayContaining([ @@ -577,15 +613,18 @@ describe("createApplicationNitro", () => { createNitroMock.mockResolvedValueOnce(directNitroStub.nitro); createNitroMock.mockResolvedValueOnce(workflowNitroStub.nitro); - const { createApplicationNitro } = + const { createProductionApplicationNitro } = await import("#internal/nitro/host/create-application-nitro.js"); const directHost = createPreparedHost(); const workflowHost = createPreparedHost(); workflowHost.compileResult.manifest.workflowTool = {}; - await createApplicationNitro(directHost, false); - await createApplicationNitro(workflowHost, false); + await createProductionApplicationNitro(directHost, createProductionOptions(directHost, "all")); + await createProductionApplicationNitro( + workflowHost, + createProductionOptions(workflowHost, "all"), + ); const directPlugins = createNitroMock.mock.calls[0]?.[0].plugins as string[]; const workflowPlugins = createNitroMock.mock.calls[1]?.[0].plugins as string[]; @@ -604,11 +643,15 @@ describe("createApplicationNitro", () => { createNitroMock.mockResolvedValueOnce(productionNitroStub.nitro); createNitroMock.mockResolvedValueOnce(devNitroStub.nitro); - const { createApplicationNitro } = + const { createDevelopmentApplicationNitro, createProductionApplicationNitro } = await import("#internal/nitro/host/create-application-nitro.js"); - await createApplicationNitro(createPreparedHost(), false); - await createApplicationNitro(createPreparedHost(), true); + const productionHost = createPreparedHost(); + await createProductionApplicationNitro( + productionHost, + createProductionOptions(productionHost, "all"), + ); + await createDevelopmentApplicationNitro(createPreparedHost()); const productionPlugins = createNitroMock.mock.calls[0]?.[0].plugins as string[]; const devPlugins = createNitroMock.mock.calls[1]?.[0].plugins as string[]; @@ -625,7 +668,7 @@ describe("createApplicationNitro", () => { const nitroStub = createNitroStub(); createNitroMock.mockResolvedValueOnce(nitroStub.nitro); - const { createApplicationNitro } = + const { createProductionApplicationNitro } = await import("#internal/nitro/host/create-application-nitro.js"); const preparedHost = createPreparedHost(); preparedHost.compileResult.manifest.config = { @@ -635,7 +678,10 @@ describe("createApplicationNitro", () => { }, } as typeof preparedHost.compileResult.manifest.config; - await createApplicationNitro(preparedHost, false); + await createProductionApplicationNitro( + preparedHost, + createProductionOptions(preparedHost, "all"), + ); const traceDeps = createNitroMock.mock.calls[0]?.[0].traceDeps; expect(traceDeps).toEqual( @@ -685,11 +731,14 @@ describe("createApplicationNitro", () => { ]); try { - const { createApplicationNitro } = + const { createProductionApplicationNitro } = await import("#internal/nitro/host/create-application-nitro.js"); const preparedHost = createPreparedHost(); preparedHost.workflowBuildDir = workflowBuildDir; - await createApplicationNitro(preparedHost, false); + await createProductionApplicationNitro(preparedHost, { + ...createProductionOptions(preparedHost, "all"), + buildDir: nitroBuildDir, + }); const rollupBeforeHooks = nitroStub.hookHandlers.get("rollup:before") ?? []; const config = { diff --git a/packages/eve/src/internal/nitro/host/create-application-nitro.ts b/packages/eve/src/internal/nitro/host/create-application-nitro.ts index cefedb4c1..1f5a5e98f 100644 --- a/packages/eve/src/internal/nitro/host/create-application-nitro.ts +++ b/packages/eve/src/internal/nitro/host/create-application-nitro.ts @@ -22,7 +22,10 @@ import { } from "#internal/nitro/host/artifacts-config.js"; import { createCompiledSandboxBackendPrunePlugin } from "#internal/nitro/host/compiled-sandbox-backend-prune-plugin.js"; import { createExtensionScopePlugin } from "#internal/bundler/extension-scope-plugin.js"; -import { configureNitroRoutes } from "#internal/nitro/host/configure-nitro-routes.js"; +import { + configureDevelopmentNitroRoutes, + configureProductionNitroRoutes, +} from "#internal/nitro/host/configure-nitro-routes.js"; import { applyEveCronHandlerRoute } from "#internal/nitro/host/cron-handler-route.js"; import { createNitroBundlerConfig } from "#internal/nitro/host/nitro-bundler-config.js"; import { captureDevLiveVirtualModules } from "#internal/nitro/host/dev-live-virtual-modules.js"; @@ -80,12 +83,8 @@ function resolveWorkflowAliases(): Record { return aliases; } -function resolveNitroPreset(dev: boolean): "vercel" | undefined { - if (!dev && process.env.VERCEL) { - return "vercel"; - } - - return undefined; +function resolveProductionNitroPreset(): "vercel" | undefined { + return process.env.VERCEL ? "vercel" : undefined; } function includesApplicationSurface(surface: NitroBuildSurface): boolean { @@ -112,15 +111,6 @@ function manifestHasWebSocketChannel(manifest: CompiledAgentManifest): boolean { ); } -function resolveWorkflowStepEntrypointPath( - nitro: Nitro, - preparedHost: PreparedApplicationHost, -): string { - return nitro.options.dev - ? join(nitro.options.buildDir, "workflow", "steps.mjs") - : join(preparedHost.workflowBuildDir, "steps.mjs"); -} - function collectHostedTraceDependencies( preparedHost: PreparedApplicationHost, configuredOptionalEnginePackages: readonly string[], @@ -179,11 +169,7 @@ export function shouldPruneLocalSandboxBackends(input: { ); } -function createDevelopmentWatchOptions(appRoot: string): { ignored: string[] } | undefined { - if (appRoot.length === 0) { - return undefined; - } - +function createDevelopmentWatchOptions(appRoot: string): { ignored: string[] } { return { // eve's authored-source watcher owns app code rebuilds. If Nitro/Rollup // also watches those files it can reload the worker while a workflow @@ -415,7 +401,7 @@ function addNitroStepModuleSideEffectsPlugin( input: { stepEntrypointPath: string; }, -): void { +): () => void { let cachedStepTransformTargets: Set | null = null; const getStepTransformTargets = async (): Promise> => { @@ -430,15 +416,10 @@ function addNitroStepModuleSideEffectsPlugin( return cachedStepTransformTargets; }; - nitro.hooks.hook("build:before", () => { + const clearCachedStepTransformTargets = () => { cachedStepTransformTargets = null; - }); - - if (nitro.options.dev) { - nitro.hooks.hook("dev:reload", () => { - cachedStepTransformTargets = null; - }); - } + }; + nitro.hooks.hook("build:before", clearCachedStepTransformTargets); nitro.hooks.hook("rollup:before", (_nitro, config) => { if (!Array.isArray(config.plugins)) { @@ -466,6 +447,8 @@ function addNitroStepModuleSideEffectsPlugin( }, }); }); + + return clearCachedStepTransformTargets; } /** @@ -478,7 +461,7 @@ function addNitroStepTransformPlugin( input: { stepEntrypointPath: string; }, -): void { +): () => void { let cachedStepTransformTargets: Set | null = null; const getStepTransformTargets = async (): Promise> => { @@ -493,15 +476,10 @@ function addNitroStepTransformPlugin( return cachedStepTransformTargets; }; - nitro.hooks.hook("build:before", () => { + const clearCachedStepTransformTargets = () => { cachedStepTransformTargets = null; - }); - - if (nitro.options.dev) { - nitro.hooks.hook("dev:reload", () => { - cachedStepTransformTargets = null; - }); - } + }; + nitro.hooks.hook("build:before", clearCachedStepTransformTargets); nitro.hooks.hook("rollup:before", (_nitro, config) => { if (!Array.isArray(config.plugins)) { @@ -534,6 +512,8 @@ function addNitroStepTransformPlugin( name: "eve:workflow-step-transform", }); }); + + return clearCachedStepTransformTargets; } /** @@ -644,30 +624,10 @@ function patchWorkflowTransformExcludePath(nitro: Nitro, workflowBuildDir: strin }); } -/** - * Creates one configured Nitro instance for either production build or dev - * hosting of an eve application. - * - * `surface` narrows the mounted routes for isolated production builds. - */ -export async function createApplicationNitro( +function createApplicationNitroBundlerConfiguration( preparedHost: PreparedApplicationHost, - dev: boolean, - options: { - buildDir?: string; - outputDir?: string; - surface?: NitroBuildSurface; - } = {}, -): Promise { - const surface = options.surface ?? "all"; - // Dev mode never registers Nitro scheduled tasks. We do not want Nitro to - // watch authored schedules and fire them on their cron expressions during - // `eve dev`; the dev-only `POST /eve/v1/dev/schedules/:scheduleId` route - // is the only dev-time entry point. Production builds always register - // schedules. - const shouldRegisterScheduleTasks = - !dev && includesApplicationSurface(surface) && preparedHost.scheduleRegistrations.length > 0; - const preset = resolveNitroPreset(dev); + preset: "vercel" | undefined, +) { const configuredBackendNames = collectConfiguredSandboxBackendNames( preparedHost.compileResult.manifest, ); @@ -704,22 +664,19 @@ export async function createApplicationNitro( preparedHost, configuredOptionalEnginePackages, ); - const nitroBuildDir = - options.buildDir ?? resolveNitroBuildDirectory(preparedHost.appRoot, surface); - const websocketEnabled = - includesApplicationSurface(surface) && - (dev || manifestHasWebSocketChannel(preparedHost.compileResult.manifest)); - const nitroPlugins: string[] = []; - nitroPlugins.push(preparedHost.compiledArtifacts.bootstrapPath); - nitroPlugins.push(preparedHost.compiledArtifacts.workflowWorldPluginPath); - if (!dev) { - // Stops all tracked sandboxes when the production server shuts - // down. Dev servers are excluded: the dev CLI parent already stops - // dev-tagged sandboxes when the dev server closes. - nitroPlugins.push( - resolvePackageSourceFilePath("src/internal/nitro/host/sandbox-shutdown-plugin.ts"), - ); - } + + return { + nitroRolldownConfig, + nitroRollupConfig, + tracedAppDependencies, + }; +} + +function createApplicationNitroPlugins(preparedHost: PreparedApplicationHost): string[] { + const nitroPlugins = [ + preparedHost.compiledArtifacts.bootstrapPath, + preparedHost.compiledArtifacts.workflowWorldPluginPath, + ]; if (manifestEnablesWorkflow(preparedHost.compileResult.manifest)) { nitroPlugins.push( resolvePackageSourceFilePath("src/internal/nitro/host/workflow-sandbox-runtime-plugin.ts"), @@ -728,55 +685,16 @@ export async function createApplicationNitro( if (preparedHost.compiledArtifacts.instrumentationPluginPath !== undefined) { nitroPlugins.push(preparedHost.compiledArtifacts.instrumentationPluginPath); } - await prepareEveVersionedCacheDirectory(nitroBuildDir); - const nitro = await createNitro( - { - _cli: { - command: dev ? "dev" : "build", - }, - buildDir: nitroBuildDir, - dev, - features: { - websocket: websocketEnabled, - }, - logLevel: dev ? 1 : undefined, - output: - options.outputDir === undefined - ? undefined - : { - dir: options.outputDir, - }, - preset, - plugins: nitroPlugins, - publicAssets: [], - scanDirs: includesWorkflowStepRegistrations(surface) - ? [resolvePackageSourceDirectoryPath("src/execution")] - : undefined, - rolldownConfig: nitroRolldownConfig, - rollupConfig: nitroRollupConfig, - rootDir: preparedHost.appRoot, - serverDir: false, - traceDeps: tracedAppDependencies, - vercel: createEveVercelOptions(preset === "vercel" && includesApplicationSurface(surface)), - watchOptions: dev ? createDevelopmentWatchOptions(preparedHost.appRoot) : undefined, - }, - dev - ? { - watch: true, - } - : undefined, - ); - await writeEveVersionedCacheMetadata(nitroBuildDir); - addNitroRoutingImportSpecifierPlugin(nitro); - if (dev) { - captureDevLiveVirtualModules(nitro); - } + return nitroPlugins; +} - // Resolve bare `workflow/*` specifiers during Nitro's Rollup bundling so - // pre-built workflow modules (whose imports target eve's installed copies) - // resolve correctly in production builds where Node resolution from the - // workflow cache directory is not available. +function configureSharedApplicationNitro( + nitro: Nitro, + preparedHost: PreparedApplicationHost, + surface: NitroBuildSurface, +): void { + addNitroRoutingImportSpecifierPlugin(nitro); if (includesWorkflowSurface(surface)) { const workflowAliases = resolveWorkflowAliases(); for (const [specifier, resolvedPath] of Object.entries(workflowAliases)) { @@ -785,17 +703,7 @@ export async function createApplicationNitro( addWorkflowModuleSideEffectsPlugin(nitro, preparedHost.workflowBuildDir); patchWorkflowTransformExcludePath(nitro, preparedHost.workflowBuildDir); } - if (includesWorkflowStepRegistrations(surface)) { - const stepEntrypointPath = resolveWorkflowStepEntrypointPath(nitro, preparedHost); - addNitroStepModuleSideEffectsPlugin(nitro, { - stepEntrypointPath, - }); - addNitroStepTransformPlugin(nitro, { - stepEntrypointPath, - }); - } - // Dynamic tool transform runs unconditionally — it needs to hoist - // execute functions for all tool files, not just workflow step targets. + addDynamicToolTransformPlugin(nitro); if (preparedHost.compiledArtifacts.instrumentationSourcePath !== undefined) { @@ -804,39 +712,142 @@ export async function createApplicationNitro( preparedHost.compiledArtifacts.instrumentationSourcePath, ); } +} - // Prevent Nitro from re-bundling the pre-built workflow bundle in dev - // mode. `steps.mjs` is now a source entry that Nitro must still bundle - // so its imported TypeScript step files are transformed. - if (dev && includesWorkflowSurface(surface)) { - const workflowBuildDir = preparedHost.workflowBuildDir; - const externalWorkflowModules = new Set([ - normalizePath(join(workflowBuildDir, "workflows.mjs")), - ]); - - nitro.hooks.hook("rollup:before", (_nitro, config) => { - const existingExternal = config.external; - config.external = (id: string, ...rest: unknown[]) => { - if (externalWorkflowModules.has(normalizePath(id))) { - return true; - } - if (typeof existingExternal === "function") { - return ( - existingExternal as (id: string, ...rest: unknown[]) => boolean | null | undefined - )(id, ...rest); - } - return undefined; - }; - }); +function configureNitroStepPlugins(nitro: Nitro, stepEntrypointPath: string): Array<() => void> { + return [ + addNitroStepModuleSideEffectsPlugin(nitro, { stepEntrypointPath }), + addNitroStepTransformPlugin(nitro, { stepEntrypointPath }), + ]; +} + +function externalizeDevelopmentWorkflowBundle( + nitro: Nitro, + preparedHost: PreparedApplicationHost, +): void { + const externalWorkflowModules = new Set([ + normalizePath(join(preparedHost.workflowBuildDir, "workflows.mjs")), + ]); + + nitro.hooks.hook("rollup:before", (_nitro, config) => { + const existingExternal = config.external; + config.external = (id: string, ...rest: unknown[]) => { + if (externalWorkflowModules.has(normalizePath(id))) { + return true; + } + if (typeof existingExternal === "function") { + return (existingExternal as (id: string, ...rest: unknown[]) => boolean | null | undefined)( + id, + ...rest, + ); + } + return undefined; + }; + }); +} + +/** Creates the watch-enabled Nitro host used by `eve dev`. */ +export async function createDevelopmentApplicationNitro( + preparedHost: PreparedApplicationHost, +): Promise { + const nitroBuildDir = resolveNitroBuildDirectory(preparedHost.appRoot); + const bundler = createApplicationNitroBundlerConfiguration(preparedHost, undefined); + + await prepareEveVersionedCacheDirectory(nitroBuildDir); + const nitro = await createNitro( + { + _cli: { command: "dev" }, + buildDir: nitroBuildDir, + dev: true, + features: { websocket: true }, + logLevel: 1, + plugins: createApplicationNitroPlugins(preparedHost), + publicAssets: [], + scanDirs: [resolvePackageSourceDirectoryPath("src/execution")], + rolldownConfig: bundler.nitroRolldownConfig, + rollupConfig: bundler.nitroRollupConfig, + rootDir: preparedHost.appRoot, + serverDir: false, + traceDeps: bundler.tracedAppDependencies, + vercel: createEveVercelOptions(false), + watchOptions: createDevelopmentWatchOptions(preparedHost.appRoot), + }, + { watch: true }, + ); + await writeEveVersionedCacheMetadata(nitroBuildDir); + + captureDevLiveVirtualModules(nitro); + const stepEntrypointPath = join(nitro.options.buildDir, "workflow", "steps.mjs"); + configureSharedApplicationNitro(nitro, preparedHost, "all"); + const clearStepTransformCaches = configureNitroStepPlugins(nitro, stepEntrypointPath); + nitro.hooks.hook("dev:reload", () => { + for (const clearCache of clearStepTransformCaches) { + clearCache(); + } + }); + externalizeDevelopmentWorkflowBundle(nitro, preparedHost); + await configureDevelopmentNitroRoutes(nitro, preparedHost); + await addNitroStepNoExternals(nitro, stepEntrypointPath); + + return nitro; +} + +interface ProductionApplicationNitroOptions { + readonly buildDir: string; + readonly outputDir: string; + readonly surface: NitroBuildSurface; +} + +/** Creates one workspace-owned Nitro host for a production build surface. */ +export async function createProductionApplicationNitro( + preparedHost: PreparedApplicationHost, + options: ProductionApplicationNitroOptions, +): Promise { + const preset = resolveProductionNitroPreset(); + const bundler = createApplicationNitroBundlerConfiguration(preparedHost, preset); + const nitroPlugins = createApplicationNitroPlugins(preparedHost); + nitroPlugins.push( + resolvePackageSourceFilePath("src/internal/nitro/host/sandbox-shutdown-plugin.ts"), + ); + + await prepareEveVersionedCacheDirectory(options.buildDir); + const nitro = await createNitro({ + _cli: { command: "build" }, + buildDir: options.buildDir, + dev: false, + features: { + websocket: + includesApplicationSurface(options.surface) && + manifestHasWebSocketChannel(preparedHost.compileResult.manifest), + }, + output: { dir: options.outputDir }, + preset, + plugins: nitroPlugins, + publicAssets: [], + scanDirs: includesWorkflowStepRegistrations(options.surface) + ? [resolvePackageSourceDirectoryPath("src/execution")] + : undefined, + rolldownConfig: bundler.nitroRolldownConfig, + rollupConfig: bundler.nitroRollupConfig, + rootDir: preparedHost.appRoot, + serverDir: false, + traceDeps: bundler.tracedAppDependencies, + vercel: createEveVercelOptions( + preset === "vercel" && includesApplicationSurface(options.surface), + ), + }); + await writeEveVersionedCacheMetadata(options.buildDir); + + configureSharedApplicationNitro(nitro, preparedHost, options.surface); + if (includesWorkflowStepRegistrations(options.surface)) { + configureNitroStepPlugins(nitro, join(preparedHost.workflowBuildDir, "steps.mjs")); } - if (shouldRegisterScheduleTasks) { - // Replace Vercel's default `/_vercel/cron` path with an unguessable - // per-build route so users do not need to configure `CRON_SECRET` to - // protect the cron endpoint. No-op when the Vercel preset is not in - // use (e.g. dev mode), where the cron route is never registered. + if ( + includesApplicationSurface(options.surface) && + preparedHost.scheduleRegistrations.length > 0 + ) { applyEveCronHandlerRoute(nitro); - const artifactsConfig: NitroArtifactsConfigInput = createProductionNitroArtifactsConfig({ appRoot: preparedHost.appRoot, }); @@ -848,12 +859,10 @@ export async function createApplicationNitro( registrations: preparedHost.scheduleRegistrations, }); } - await configureNitroRoutes(nitro, preparedHost, { - surface, - }); - if (includesWorkflowStepRegistrations(surface)) { - await addNitroStepNoExternals(nitro, resolveWorkflowStepEntrypointPath(nitro, preparedHost)); - } + await configureProductionNitroRoutes(nitro, preparedHost, options.surface); + if (includesWorkflowStepRegistrations(options.surface)) { + await addNitroStepNoExternals(nitro, join(preparedHost.workflowBuildDir, "steps.mjs")); + } return nitro; } diff --git a/packages/eve/src/internal/nitro/host/start-development-server.test.ts b/packages/eve/src/internal/nitro/host/start-development-server.test.ts index 791225d2c..c9a06637e 100644 --- a/packages/eve/src/internal/nitro/host/start-development-server.test.ts +++ b/packages/eve/src/internal/nitro/host/start-development-server.test.ts @@ -50,7 +50,7 @@ const mocks = vi.hoisted(() => { return { authoredSourceWatcher, buildNitro: vi.fn(async () => undefined), - createApplicationNitro: vi.fn(async () => nitro), + createDevelopmentApplicationNitro: vi.fn(async () => nitro), createDevServer: vi.fn(() => devServer), devServer, fetch: vi.fn(async () => new Response(null, { status: 200 })), @@ -121,7 +121,7 @@ vi.mock("nitro/builder", () => ({ })); vi.mock("./create-application-nitro.js", () => ({ - createApplicationNitro: mocks.createApplicationNitro, + createDevelopmentApplicationNitro: mocks.createDevelopmentApplicationNitro, })); vi.mock("./dev-authored-source-watcher.js", () => ({ @@ -564,7 +564,7 @@ describe("createDevelopmentServer", () => { await expect(startDevelopmentServer("/tmp/eve-test")).rejects.toThrow("permission denied"); - expect(mocks.createApplicationNitro).not.toHaveBeenCalled(); + expect(mocks.createDevelopmentApplicationNitro).not.toHaveBeenCalled(); }); it("reports a healthy recorded server when starting another server", async () => { @@ -577,7 +577,7 @@ describe("createDevelopmentServer", () => { "To connect to the existing instance, run: pnpm exec eve dev http://localhost:2000/", ].join("\n"), ); - expect(mocks.createApplicationNitro).not.toHaveBeenCalled(); + expect(mocks.createDevelopmentApplicationNitro).not.toHaveBeenCalled(); }); it("reuses the active server recorded for the same app root when requested", async () => { @@ -594,7 +594,7 @@ describe("createDevelopmentServer", () => { expect(attached.kind).toBe("existing"); expect(attached.url).toBe(owner.url); - expect(mocks.createApplicationNitro).toHaveBeenCalledOnce(); + expect(mocks.createDevelopmentApplicationNitro).toHaveBeenCalledOnce(); expect(mocks.fetch).toHaveBeenCalledWith("http://localhost:2000/eve/v1/health", { redirect: "error", signal: expect.any(AbortSignal), @@ -690,7 +690,7 @@ describe("createDevelopmentServer", () => { await expect( startDevelopmentServer("/tmp/eve-test", { existing: "attach-if-unconfigured" }), ).rejects.toThrow("A dev server is already running for this eve agent."); - expect(mocks.createApplicationNitro).not.toHaveBeenCalled(); + expect(mocks.createDevelopmentApplicationNitro).not.toHaveBeenCalled(); expect(mocks.fetch).toHaveBeenCalledOnce(); }); @@ -702,7 +702,7 @@ describe("createDevelopmentServer", () => { await expect( startDevelopmentServer("/tmp/eve-test", { existing: "attach-if-unconfigured" }), ).rejects.toThrow("A dev server is already running for this eve agent."); - expect(mocks.createApplicationNitro).not.toHaveBeenCalled(); + expect(mocks.createDevelopmentApplicationNitro).not.toHaveBeenCalled(); expect(mocks.fetch).toHaveBeenCalledOnce(); }); @@ -720,7 +720,7 @@ describe("createDevelopmentServer", () => { signal: expect.any(AbortSignal), }); expect(mocks.fetch).toHaveBeenCalledOnce(); - expect(mocks.createApplicationNitro).toHaveBeenCalledOnce(); + expect(mocks.createDevelopmentApplicationNitro).toHaveBeenCalledOnce(); expect(readStateRecord()).toEqual({ url: "http://localhost:2000/" }); await server.close(); @@ -735,7 +735,7 @@ describe("createDevelopmentServer", () => { }); expect(mocks.fetch).not.toHaveBeenCalled(); - expect(mocks.createApplicationNitro).toHaveBeenCalledOnce(); + expect(mocks.createDevelopmentApplicationNitro).toHaveBeenCalledOnce(); await server.close(); }); @@ -754,7 +754,7 @@ describe("createDevelopmentServer", () => { }); expect(server.url).toBe("http://localhost:2000/"); - expect(mocks.createApplicationNitro).toHaveBeenCalledOnce(); + expect(mocks.createDevelopmentApplicationNitro).toHaveBeenCalledOnce(); if (server.kind !== "started") { throw new Error("Expected to start the server for the requested app root."); diff --git a/packages/eve/src/internal/nitro/host/start-development-server.ts b/packages/eve/src/internal/nitro/host/start-development-server.ts index e941a56f1..411f2693c 100644 --- a/packages/eve/src/internal/nitro/host/start-development-server.ts +++ b/packages/eve/src/internal/nitro/host/start-development-server.ts @@ -6,7 +6,7 @@ import { EVE_DEV_ENV_FLAG } from "#internal/application/optional-package-install import { build as buildNitro, createDevServer, prepare } from "nitro/builder"; import type { Nitro } from "nitro/types"; -import { createApplicationNitro } from "#internal/nitro/host/create-application-nitro.js"; +import { createDevelopmentApplicationNitro } from "#internal/nitro/host/create-application-nitro.js"; import { createDevelopmentNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; import type { AuthoredSourceWatcherHandle } from "#internal/nitro/host/dev-authored-source-watcher.js"; import { prepareDevelopmentApplicationHost } from "#internal/nitro/host/prepare-application-host.js"; @@ -474,7 +474,7 @@ async function startNitroDevelopmentServer( pruneLocalSandboxTemplatesInBackground(preparedHost.appRoot); const activeNitro = await devBootPhase( "creating dev server", - () => createApplicationNitro(preparedHost, true), + () => createDevelopmentApplicationNitro(preparedHost), options.onBootProgress, ); nitro = activeNitro; From 66dfcc1d7446bc7e49603e9fe4b907adb4ee54e4 Mon Sep 17 00:00:00 2001 From: Casey Gowrie Date: Mon, 13 Jul 2026 10:55:00 -0400 Subject: [PATCH 04/12] fix(eve): keep failed publication cleanup recoverable Signed-off-by: Casey Gowrie --- .../output-publication.scenario.test.ts | 72 ++++++++++++++++++- .../application/output-publication.ts | 46 +++++++++--- 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/packages/eve/src/internal/application/output-publication.scenario.test.ts b/packages/eve/src/internal/application/output-publication.scenario.test.ts index d986b4476..e3a7e8545 100644 --- a/packages/eve/src/internal/application/output-publication.scenario.test.ts +++ b/packages/eve/src/internal/application/output-publication.scenario.test.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -260,4 +260,74 @@ describe("build output publication", () => { summaryPath: finalSummaryPath, }); }); + + it("retains a recoverable lock when committed backup cleanup fails", async () => { + const appRoot = await createScratchDirectory("eve-output-publication-cleanup-"); + const finalOutputDir = join(appRoot, ".output"); + const finalSummaryPath = join(appRoot, ".eve", "agent-summary.json"); + const stagedOutputDir = join(appRoot, ".eve", "builds", "next", "output"); + const stagedSummaryPath = join(appRoot, ".eve", "builds", "next", "summary.json"); + await writePublication({ + outputDir: finalOutputDir, + outputMarker: "last-good", + summaryMarker: "last-good", + summaryPath: finalSummaryPath, + }); + await writePublication({ + outputDir: stagedOutputDir, + outputMarker: "next", + summaryMarker: "next", + summaryPath: stagedSummaryPath, + }); + + try { + await expect( + publishApplicationBuildArtifacts({ + appRoot, + finalOutputDir, + finalSummaryPath, + stagedOutputDir, + stagedSummaryPath, + async onAfterOutputInstall() { + await chmod(appRoot, 0o500); + }, + }), + ).rejects.toThrow(); + } finally { + await chmod(appRoot, 0o700); + } + + await expectPublication({ + outputDir: finalOutputDir, + outputMarker: "next", + summaryMarker: "next", + summaryPath: finalSummaryPath, + }); + await expect( + readFile(join(resolveOutputPublicationLockPath(appRoot), "owner.json"), "utf8"), + ).resolves.toContain('"phase": "committed"'); + + const recoveredOutputDir = join(appRoot, ".eve", "builds", "recovered", "output"); + const recoveredSummaryPath = join(appRoot, ".eve", "builds", "recovered", "summary.json"); + await writePublication({ + outputDir: recoveredOutputDir, + outputMarker: "recovered", + summaryMarker: "recovered", + summaryPath: recoveredSummaryPath, + }); + await publishApplicationBuildArtifacts({ + appRoot, + finalOutputDir, + finalSummaryPath, + stagedOutputDir: recoveredOutputDir, + stagedSummaryPath: recoveredSummaryPath, + }); + + await expectPublication({ + outputDir: finalOutputDir, + outputMarker: "recovered", + summaryMarker: "recovered", + summaryPath: finalSummaryPath, + }); + }); }); diff --git a/packages/eve/src/internal/application/output-publication.ts b/packages/eve/src/internal/application/output-publication.ts index f0c0e06ea..d5f6757fe 100644 --- a/packages/eve/src/internal/application/output-publication.ts +++ b/packages/eve/src/internal/application/output-publication.ts @@ -62,7 +62,7 @@ export async function publishApplicationBuildArtifacts(input: { onContention: input.onLockContention, owner, }); - let committed = false; + let releaseLock = false; try { owner.hadOutput = await pathExists(owner.finalOutputDir); @@ -92,27 +92,51 @@ export async function publishApplicationBuildArtifacts(input: { owner.phase = "committed"; await writePublicationOwner(lockPath, owner); - committed = true; + await removePublicationBackups(owner); + releaseLock = true; } catch (error) { + if (owner.phase === "committed") { + await throwRecoverablePublicationError({ + errors: [error], + lockPath, + message: "Build output was committed but backup cleanup failed.", + owner, + }); + } try { await rollbackOutputPublication(owner); } catch (rollbackError) { - owner.pid = 0; - await writePublicationOwner(lockPath, owner).catch(() => undefined); - throw new AggregateError( - [error, rollbackError], - "Build output publication failed and could not fully restore the previous output.", - { cause: error }, - ); + await throwRecoverablePublicationError({ + errors: [error, rollbackError], + lockPath, + message: "Build output publication failed and could not fully restore the previous output.", + owner, + }); } + releaseLock = true; throw error; } finally { - if (committed || owner.pid !== 0) { + if (releaseLock) { await release(); } } +} - await removePublicationBackups(owner).catch(() => undefined); +async function throwRecoverablePublicationError(input: { + readonly errors: readonly unknown[]; + readonly lockPath: string; + readonly message: string; + readonly owner: OutputPublicationOwner; +}): Promise { + input.owner.pid = 0; + try { + await writePublicationOwner(input.lockPath, input.owner); + } catch (ownerWriteError) { + throw new AggregateError([...input.errors, ownerWriteError], input.message, { + cause: input.errors[0], + }); + } + throw new AggregateError(input.errors, input.message, { cause: input.errors[0] }); } export function resolveOutputPublicationLockPath(appRoot: string): string { From 0022aedaf69b7540f69090850743b51a4e65973f Mon Sep 17 00:00:00 2001 From: Casey Gowrie Date: Mon, 13 Jul 2026 12:25:40 -0400 Subject: [PATCH 05/12] refactor(eve): harden production build isolation Signed-off-by: Casey Gowrie --- packages/eve/src/compiler/artifacts.ts | 19 +- packages/eve/src/compiler/compile-agent.ts | 16 +- .../sandbox/prewarm.scenario.test.ts | 5 +- packages/eve/src/execution/sandbox/prewarm.ts | 8 +- .../internal/application/build-workspace.ts | 70 +- .../application/compiled-artifacts.ts | 5 - .../output-publication-artifacts.ts | 114 ++++ .../application/output-publication-journal.ts | 103 +++ .../application/output-publication-lock.ts | 369 ++++++++++ .../output-publication.scenario.test.ts | 284 +++++--- .../application/output-publication.ts | 640 +++--------------- .../production-compiler-artifacts.ts | 12 + .../application/production-start-artifacts.ts | 13 - .../dev-runtime-artifacts.integration.test.ts | 2 +- .../internal/nitro/host/artifacts-config.ts | 33 +- .../host/build-application.scenario.test.ts | 23 +- .../internal/nitro/host/build-application.ts | 44 +- .../src/internal/nitro/host/channel-routes.ts | 8 +- .../nitro/host/configure-nitro-routes.test.ts | 35 +- .../nitro/host/configure-nitro-routes.ts | 21 +- .../nitro/host/create-application-nitro.ts | 11 +- .../prepare-application-host.scenario.test.ts | 7 +- .../nitro/host/prepare-application-host.ts | 18 +- .../nitro/host/schedule-task-routes.test.ts | 3 +- .../nitro/host/schedule-task-routes.ts | 8 +- .../agent-info/load-agent-info-data.test.ts | 5 +- .../routes/agent-info/load-agent-info-data.ts | 33 +- .../nitro/routes/channel-dispatch.test.ts | 10 +- .../internal/nitro/routes/channel-dispatch.ts | 5 +- .../src/internal/nitro/routes/info.test.ts | 3 +- .../eve/src/internal/nitro/routes/info.ts | 12 +- .../nitro/routes/runtime-artifacts.test.ts | 29 +- .../nitro/routes/runtime-artifacts.ts | 39 +- .../internal/workflow-bundle/build-queue.ts | 18 + .../workflow-bundle/builder.scenario.test.ts | 11 +- .../src/internal/workflow-bundle/builder.ts | 18 +- .../nitro-step-entry.scenario.test.ts | 3 + .../workflow-bundle/nitro-step-entry.ts | 10 + .../scenarios/compile-agent.scenario.test.ts | 10 +- .../load-agent-info-data.scenario.test.ts | 2 +- ...roduction-build-isolation.scenario.test.ts | 58 ++ 41 files changed, 1234 insertions(+), 903 deletions(-) create mode 100644 packages/eve/src/internal/application/output-publication-artifacts.ts create mode 100644 packages/eve/src/internal/application/output-publication-journal.ts create mode 100644 packages/eve/src/internal/application/output-publication-lock.ts create mode 100644 packages/eve/src/internal/application/production-compiler-artifacts.ts delete mode 100644 packages/eve/src/internal/application/production-start-artifacts.ts create mode 100644 packages/eve/src/internal/workflow-bundle/build-queue.ts diff --git a/packages/eve/src/compiler/artifacts.ts b/packages/eve/src/compiler/artifacts.ts index 6b1c2c3ee..6efa715b7 100644 --- a/packages/eve/src/compiler/artifacts.ts +++ b/packages/eve/src/compiler/artifacts.ts @@ -86,12 +86,17 @@ export interface CompileMetadata { version: typeof COMPILE_METADATA_VERSION; } +export interface CompilerArtifactLocations { + readonly publishedRoot: string; + readonly writeRoot: string; +} + /** * Input for writing compiler-owned discovery artifacts. */ interface WriteCompilerArtifactsInput { appRoot: string; - artifactsRoot: string; + artifactLocations: CompilerArtifactLocations; diagnostics: readonly DiscoverDiagnostic[]; manifest: AgentSourceManifest; } @@ -193,11 +198,15 @@ export function createCompileMetadata(input: { }; } -/** Writes compiler-owned discovery artifacts to the specified artifact root. */ +/** Writes compiler-owned artifacts and records their stable published locations. */ export async function writeCompilerArtifacts( input: WriteCompilerArtifactsInput, ): Promise { - const paths = resolveCompilerArtifactPathsAt(input.appRoot, input.artifactsRoot); + const paths = resolveCompilerArtifactPathsAt(input.appRoot, input.artifactLocations.writeRoot); + const publishedPaths = resolveCompilerArtifactPathsAt( + input.appRoot, + input.artifactLocations.publishedRoot, + ); const diagnosticsArtifact = createDiscoveryDiagnosticsArtifact(input.diagnostics); const compiledManifest = await materializeWorkspaceResources({ compileDirectoryPath: paths.compileDirectoryPath, @@ -208,7 +217,7 @@ export async function writeCompilerArtifacts( const diagnosticsArtifactJson = serializeArtifactJson(diagnosticsArtifact); const moduleMapSource = createCompiledModuleMapSource({ manifest: compiledManifest, - moduleMapPath: paths.moduleMapPath, + moduleMapPath: publishedPaths.moduleMapPath, }); const metadata = createCompileMetadata({ appRoot: input.appRoot, @@ -216,7 +225,7 @@ export async function writeCompilerArtifacts( diagnosticsSummary: diagnosticsArtifact.summary, discoveryManifestJson, moduleMapSource, - paths, + paths: publishedPaths, }); const metadataJson = serializeArtifactJson(metadata); diff --git a/packages/eve/src/compiler/compile-agent.ts b/packages/eve/src/compiler/compile-agent.ts index 68f325ea6..36cdcf6f9 100644 --- a/packages/eve/src/compiler/compile-agent.ts +++ b/packages/eve/src/compiler/compile-agent.ts @@ -9,6 +9,7 @@ import { createDiskProjectSource, type ProjectSource } from "#discover/project-s import type { AgentSourceManifest } from "#discover/manifest.js"; import { type CompileMetadata, + type CompilerArtifactLocations, type CompilerArtifactPaths, writeCompilerArtifacts, } from "#compiler/artifacts.js"; @@ -74,18 +75,21 @@ export class CompileAgentError extends Error { */ export async function compileAgent(input: CompileAgentInput = {}): Promise { const discovered = await discoverAgentForCompilation(input); - const result = await writeAgentCompilation(discovered, join(discovered.project.appRoot, ".eve")); + const artifactsRoot = join(discovered.project.appRoot, ".eve"); + const result = await writeAgentCompilation(discovered, { + publishedRoot: artifactsRoot, + writeRoot: artifactsRoot, + }); return finishAgentCompilation(result, CompileAgentError.fromDurableArtifacts); } -/** Compiles one app entirely within an invocation-owned production build workspace. */ export async function compileAgentInBuildWorkspace(input: { - readonly compilerArtifactsRoot: string; + readonly artifactLocations: CompilerArtifactLocations; readonly startPath: string; }): Promise { const discovered = await discoverAgentForCompilation({ startPath: input.startPath }); - const result = await writeAgentCompilation(discovered, input.compilerArtifactsRoot); + const result = await writeAgentCompilation(discovered, input.artifactLocations); return finishAgentCompilation(result, CompileAgentError.fromTransientArtifacts); } @@ -112,11 +116,11 @@ async function discoverAgentForCompilation( async function writeAgentCompilation( discovered: DiscoveredAgentCompilation, - artifactsRoot: string, + artifactLocations: CompilerArtifactLocations, ): Promise { const writtenArtifacts = await writeCompilerArtifacts({ appRoot: discovered.project.appRoot, - artifactsRoot, + artifactLocations, diagnostics: discovered.diagnostics, manifest: discovered.manifest, }); diff --git a/packages/eve/src/execution/sandbox/prewarm.scenario.test.ts b/packages/eve/src/execution/sandbox/prewarm.scenario.test.ts index 3527c7878..d1667deca 100644 --- a/packages/eve/src/execution/sandbox/prewarm.scenario.test.ts +++ b/packages/eve/src/execution/sandbox/prewarm.scenario.test.ts @@ -31,7 +31,10 @@ describe("prewarmAppSandboxes", () => { const appRoot = await createScenarioAppRoot(); const compilerAppRoot = join(appRoot, ".eve", "builds", "isolated", "compiler"); await compileAgentInBuildWorkspace({ - compilerArtifactsRoot: join(compilerAppRoot, ".eve"), + artifactLocations: { + publishedRoot: join(compilerAppRoot, ".eve"), + writeRoot: join(compilerAppRoot, ".eve"), + }, startPath: appRoot, }); const events = createPrewarmEvents(); diff --git a/packages/eve/src/execution/sandbox/prewarm.ts b/packages/eve/src/execution/sandbox/prewarm.ts index 5cc63bb6d..0a08204d4 100644 --- a/packages/eve/src/execution/sandbox/prewarm.ts +++ b/packages/eve/src/execution/sandbox/prewarm.ts @@ -194,19 +194,19 @@ export async function prewarmBuiltAppSandboxes(input: { readonly dispatch?: SandboxBackendPrewarmDispatch; }): Promise { const builtArtifactsRoot = join(input.appRoot, ".output"); - const authoredSource = createDiskRuntimeCompiledArtifactsSource(builtArtifactsRoot, { + const builtArtifactsSource = createDiskRuntimeCompiledArtifactsSource(builtArtifactsRoot, { moduleMapLoaderPath: resolvePackageSourceFilePath("src/internal/authored-module-map-loader.ts"), sandboxAppRoot: input.appRoot, }); const [metadata, manifest, moduleMap] = await Promise.all([ loadCompileMetadata({ - compiledArtifactsSource: authoredSource, + compiledArtifactsSource: builtArtifactsSource, }), loadCompiledManifest({ - compiledArtifactsSource: authoredSource, + compiledArtifactsSource: builtArtifactsSource, }), loadCompiledModuleMapFromAuthoredSource({ - compiledArtifactsSource: authoredSource, + compiledArtifactsSource: builtArtifactsSource, }), ]); diff --git a/packages/eve/src/internal/application/build-workspace.ts b/packages/eve/src/internal/application/build-workspace.ts index 55b0dd5cc..55384e673 100644 --- a/packages/eve/src/internal/application/build-workspace.ts +++ b/packages/eve/src/internal/application/build-workspace.ts @@ -7,17 +7,31 @@ import { VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH } from "#internal/vercel-agent-sum export interface ApplicationBuildWorkspace { readonly appRoot: string; - readonly compilerAppRoot: string; - readonly compilerArtifactsRoot: string; - readonly finalOutputDir: string; - readonly finalSummaryPath: string; - readonly hostArtifactsDir: string; - readonly nitroBuildDir: string; - readonly nitroOutputDir: string; - readonly outputDir: string; + readonly compiler: { + readonly artifactsDir: string; + readonly rootDir: string; + }; + readonly host: { + readonly artifactsDir: string; + }; + readonly nitro: { + readonly buildDir: string; + readonly surfaceOutputDir: string; + }; + readonly publication: { + readonly output: { + readonly finalDir: string; + readonly stagedDir: string; + }; + readonly summary: { + readonly finalPath: string; + readonly stagedPath: string; + }; + }; readonly rootDir: string; - readonly summaryPath: string; - readonly workflowBuildDir: string; + readonly workflow: { + readonly buildDir: string; + }; } export async function createApplicationBuildWorkspace( @@ -26,20 +40,34 @@ export async function createApplicationBuildWorkspace( const resolvedAppRoot = resolve(appRoot); const buildId = `${Date.now().toString(36)}-${randomUUID()}`; const rootDir = join(resolvedAppRoot, ".eve", "builds", buildId); - const compilerAppRoot = join(rootDir, "compiler"); + const compilerRootDir = join(rootDir, "compiler"); const workspace: ApplicationBuildWorkspace = { appRoot: resolvedAppRoot, - compilerAppRoot, - compilerArtifactsRoot: join(compilerAppRoot, ".eve"), - finalOutputDir: resolveOutputDirectory(resolvedAppRoot), - finalSummaryPath: join(resolvedAppRoot, VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH), - hostArtifactsDir: join(rootDir, "host"), - nitroBuildDir: join(rootDir, "nitro"), - nitroOutputDir: join(rootDir, "nitro-output"), - outputDir: join(rootDir, "output"), + compiler: { + artifactsDir: join(compilerRootDir, ".eve"), + rootDir: compilerRootDir, + }, + host: { + artifactsDir: join(rootDir, "host"), + }, + nitro: { + buildDir: join(rootDir, "nitro"), + surfaceOutputDir: join(rootDir, "nitro-output"), + }, + publication: { + output: { + finalDir: resolveOutputDirectory(resolvedAppRoot), + stagedDir: join(rootDir, "output"), + }, + summary: { + finalPath: join(resolvedAppRoot, VERCEL_EVE_AGENT_SUMMARY_OUTPUT_PATH), + stagedPath: join(rootDir, "agent-summary.json"), + }, + }, rootDir, - summaryPath: join(rootDir, "agent-summary.json"), - workflowBuildDir: join(rootDir, "workflow"), + workflow: { + buildDir: join(rootDir, "workflow"), + }, }; await mkdir(rootDir, { recursive: true }); diff --git a/packages/eve/src/internal/application/compiled-artifacts.ts b/packages/eve/src/internal/application/compiled-artifacts.ts index 78a72e0ba..843405f66 100644 --- a/packages/eve/src/internal/application/compiled-artifacts.ts +++ b/packages/eve/src/internal/application/compiled-artifacts.ts @@ -166,11 +166,6 @@ export async function createCompiledArtifactsBootstrapSource(input: { " // Already installed on import above.", "}", "", - "export async function __eveInstallCompiledArtifactsStep() {", - ' "use step";', - " return null;", - "}", - "", ].join("\n"); } diff --git a/packages/eve/src/internal/application/output-publication-artifacts.ts b/packages/eve/src/internal/application/output-publication-artifacts.ts new file mode 100644 index 000000000..11608a71c --- /dev/null +++ b/packages/eve/src/internal/application/output-publication-artifacts.ts @@ -0,0 +1,114 @@ +import { mkdir, rename, rm, stat } from "node:fs/promises"; +import { dirname } from "node:path"; + +import type { OutputPublicationJournal } from "#internal/application/output-publication-journal.js"; + +export async function assertStagedPublicationExists( + journal: OutputPublicationJournal, +): Promise { + await Promise.all([stat(journal.stagedOutputDir), stat(journal.stagedSummaryPath)]); +} + +export async function prepareOutputPublication(journal: OutputPublicationJournal): Promise { + [journal.hadOutput, journal.hadSummary] = await Promise.all([ + pathExists(journal.finalOutputDir), + pathExists(journal.finalSummaryPath), + ]); + await Promise.all([ + mkdir(dirname(journal.finalOutputDir), { recursive: true }), + mkdir(dirname(journal.finalSummaryPath), { recursive: true }), + ]); +} + +export async function backupOutputPublication(journal: OutputPublicationJournal): Promise { + if (journal.hadOutput) { + await rename(journal.finalOutputDir, journal.outputBackupPath); + } + if (journal.hadSummary) { + await rename(journal.finalSummaryPath, journal.summaryBackupPath); + } +} + +export async function installOutputPublication( + journal: OutputPublicationJournal, + afterOutputInstall: () => Promise, +): Promise { + await rename(journal.stagedOutputDir, journal.finalOutputDir); + await afterOutputInstall(); + await rename(journal.stagedSummaryPath, journal.finalSummaryPath); +} + +export async function rollbackOutputPublication(journal: OutputPublicationJournal): Promise { + const results = await Promise.allSettled([ + rollbackArtifact({ + backupPath: journal.outputBackupPath, + finalPath: journal.finalOutputDir, + hadPrevious: journal.hadOutput, + stagedPath: journal.stagedOutputDir, + }), + rollbackArtifact({ + backupPath: journal.summaryBackupPath, + finalPath: journal.finalSummaryPath, + hadPrevious: journal.hadSummary, + stagedPath: journal.stagedSummaryPath, + }), + ]); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length > 0) { + throw new AggregateError(failures, "Failed to restore the previous build publication."); + } +} + +export async function removeOutputPublicationBackups( + journal: OutputPublicationJournal, +): Promise { + await Promise.all([ + rm(journal.outputBackupPath, { force: true, recursive: true }), + rm(journal.summaryBackupPath, { force: true, recursive: true }), + ]); +} + +async function rollbackArtifact(input: { + readonly backupPath: string; + readonly finalPath: string; + readonly hadPrevious: boolean; + readonly stagedPath: string; +}): Promise { + if (await pathExists(input.backupPath)) { + if (!(await pathExists(input.stagedPath)) && (await pathExists(input.finalPath))) { + await mkdir(dirname(input.stagedPath), { recursive: true }); + await rename(input.finalPath, input.stagedPath); + } else { + await rm(input.finalPath, { force: true, recursive: true }); + } + await rename(input.backupPath, input.finalPath); + return; + } + + if ( + !input.hadPrevious && + !(await pathExists(input.stagedPath)) && + (await pathExists(input.finalPath)) + ) { + await mkdir(dirname(input.stagedPath), { recursive: true }); + await rename(input.finalPath, input.stagedPath); + } +} + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + return false; + } + throw error; + } +} + +function isNodeErrorWithCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} diff --git a/packages/eve/src/internal/application/output-publication-journal.ts b/packages/eve/src/internal/application/output-publication-journal.ts new file mode 100644 index 000000000..06669963b --- /dev/null +++ b/packages/eve/src/internal/application/output-publication-journal.ts @@ -0,0 +1,103 @@ +import { readFile, rename, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +export type OutputPublicationPhase = "acquired" | "prepared" | "backed-up" | "committed"; + +export interface OutputPublicationJournal { + readonly finalOutputDir: string; + readonly finalSummaryPath: string; + hadOutput: boolean; + hadSummary: boolean; + liveness: "active" | "recoverable"; + readonly outputBackupPath: string; + phase: OutputPublicationPhase; + readonly pid: number; + readonly stagedOutputDir: string; + readonly stagedSummaryPath: string; + readonly summaryBackupPath: string; + readonly token: string; +} + +export interface RecoveryLeaseJournal { + readonly pid: number; + readonly token: string; +} + +export async function writeOutputPublicationJournal( + lockPath: string, + journal: OutputPublicationJournal, +): Promise { + await writeJournal(lockPath, journal, journal.token); +} + +export async function readOutputPublicationJournal( + lockPath: string, +): Promise { + const value = await readJournal(lockPath); + return isOutputPublicationJournal(value) ? value : undefined; +} + +export async function writeRecoveryLeaseJournal( + leasePath: string, + journal: RecoveryLeaseJournal, +): Promise { + await writeJournal(leasePath, journal, journal.token); +} + +export async function readRecoveryLeaseJournal( + leasePath: string, +): Promise { + const value = await readJournal(leasePath); + return isRecoveryLeaseJournal(value) ? value : undefined; +} + +async function writeJournal(path: string, journal: unknown, token: string): Promise { + const journalPath = join(path, "owner.json"); + const temporaryPath = `${journalPath}.${token}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(journal, null, 2)}\n`); + await rename(temporaryPath, journalPath); +} + +async function readJournal(path: string): Promise { + try { + return JSON.parse(await readFile(join(path, "owner.json"), "utf8")) as unknown; + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT") || error instanceof SyntaxError) { + return undefined; + } + throw error; + } +} + +function isOutputPublicationJournal(value: unknown): value is OutputPublicationJournal { + if (value === null || typeof value !== "object") { + return false; + } + const journal = value as Partial; + return ( + typeof journal.finalOutputDir === "string" && + typeof journal.finalSummaryPath === "string" && + typeof journal.hadOutput === "boolean" && + typeof journal.hadSummary === "boolean" && + (journal.liveness === "active" || journal.liveness === "recoverable") && + typeof journal.outputBackupPath === "string" && + ["acquired", "prepared", "backed-up", "committed"].includes(journal.phase ?? "") && + typeof journal.pid === "number" && + typeof journal.stagedOutputDir === "string" && + typeof journal.stagedSummaryPath === "string" && + typeof journal.summaryBackupPath === "string" && + typeof journal.token === "string" + ); +} + +function isRecoveryLeaseJournal(value: unknown): value is RecoveryLeaseJournal { + if (value === null || typeof value !== "object") { + return false; + } + const journal = value as Partial; + return typeof journal.pid === "number" && typeof journal.token === "string"; +} + +function isNodeErrorWithCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} diff --git a/packages/eve/src/internal/application/output-publication-lock.ts b/packages/eve/src/internal/application/output-publication-lock.ts new file mode 100644 index 000000000..6c10182c3 --- /dev/null +++ b/packages/eve/src/internal/application/output-publication-lock.ts @@ -0,0 +1,369 @@ +import { watch } from "node:fs"; +import { mkdir, readdir, rename, rm, stat } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; + +import { + removeOutputPublicationBackups, + rollbackOutputPublication, +} from "#internal/application/output-publication-artifacts.js"; +import { + readOutputPublicationJournal, + readRecoveryLeaseJournal, + type OutputPublicationJournal, + type RecoveryLeaseJournal, + writeOutputPublicationJournal, + writeRecoveryLeaseJournal, +} from "#internal/application/output-publication-journal.js"; + +const PUBLICATION_LOCK_TIMEOUT_MS = 60_000; +const INCOMPLETE_LOCK_STALE_MS = 5_000; + +interface RecoveryLease { + complete(): Promise; + release(): Promise; +} + +export function resolveOutputPublicationLockPath(appRoot: string): string { + return join(resolve(appRoot), ".eve", "locks", "output-publication.lock"); +} + +export async function acquireOutputPublicationLock( + lockPath: string, + journal: OutputPublicationJournal, + onContention: () => Promise, +): Promise<() => Promise> { + const deadline = Date.now() + PUBLICATION_LOCK_TIMEOUT_MS; + const recoveryPath = `${lockPath}.recovery`; + await mkdir(dirname(lockPath), { recursive: true }); + + for (;;) { + if (await pathExists(recoveryPath)) { + await onContention(); + if (await recoverStalePublication(lockPath, recoveryPath, journal)) { + continue; + } + await waitForPublicationLockChange(lockPath, deadline); + continue; + } + + try { + await mkdir(lockPath); + if (await pathExists(recoveryPath)) { + await rm(lockPath, { force: true, recursive: true }); + await waitForPublicationLockChange(lockPath, deadline); + continue; + } + try { + await writeOutputPublicationJournal(lockPath, journal); + } catch (error) { + await rm(lockPath, { force: true, recursive: true }); + throw error; + } + return async () => { + const currentJournal = await readOutputPublicationJournal(lockPath); + if (currentJournal?.token !== journal.token) { + return; + } + const releasedPath = `${lockPath}.released-${journal.token}`; + try { + await rename(lockPath, releasedPath); + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + return; + } + throw error; + } + await rm(releasedPath, { force: true, recursive: true }); + }; + } catch (error) { + if (!isNodeErrorWithCode(error, "EEXIST")) { + throw error; + } + } + + await onContention(); + if (await recoverStalePublication(lockPath, recoveryPath, journal)) { + continue; + } + await waitForPublicationLockChange(lockPath, deadline); + } +} + +async function recoverStalePublication( + lockPath: string, + recoveryPath: string, + recoveryJournal: OutputPublicationJournal, +): Promise { + const releaseRecoveryLease = await acquireRecoveryLease(recoveryPath, recoveryJournal.token); + if (releaseRecoveryLease === undefined) { + return false; + } + + let preserveRecovery = false; + try { + const existingJournal = await readOutputPublicationJournal(lockPath); + if ( + existingJournal !== undefined && + existingJournal.liveness === "active" && + isProcessAlive(existingJournal.pid) + ) { + return false; + } + if (existingJournal === undefined && !(await isPathStale(lockPath))) { + return false; + } + + if (await pathExists(lockPath)) { + const recoveringJournalPath = join(recoveryPath, `owner-${recoveryJournal.token}`); + try { + await rename(lockPath, recoveringJournalPath); + preserveRecovery = true; + } catch (error) { + if (!isNodeErrorWithCode(error, "ENOENT")) { + throw error; + } + } + } + + const entries = await readdir(recoveryPath, { withFileTypes: true }); + preserveRecovery = entries.some( + (entry) => entry.isDirectory() && entry.name.startsWith("owner-"), + ); + for (const entry of entries) { + if (!entry.isDirectory() || !entry.name.startsWith("owner-")) { + continue; + } + await finishInterruptedPublication(join(recoveryPath, entry.name), recoveryJournal); + } + preserveRecovery = false; + return true; + } finally { + if (preserveRecovery) { + await releaseRecoveryLease.release(); + } else { + await releaseRecoveryLease.complete(); + } + } +} + +async function acquireRecoveryLease( + recoveryPath: string, + token: string, +): Promise { + const leasePath = join(recoveryPath, "lease"); + const leaseJournal: RecoveryLeaseJournal = { pid: process.pid, token }; + await mkdir(recoveryPath, { recursive: true }); + + for (;;) { + try { + await mkdir(leasePath); + try { + await writeRecoveryLeaseJournal(leasePath, leaseJournal); + } catch (error) { + await rm(leasePath, { force: true, recursive: true }); + throw error; + } + return { + async complete() { + const currentJournal = await readRecoveryLeaseJournal(leasePath); + if (currentJournal?.token !== token) { + return; + } + const releasedPath = `${recoveryPath}.released-${token}`; + try { + await rename(recoveryPath, releasedPath); + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + return; + } + throw error; + } + await rm(releasedPath, { force: true, recursive: true }); + }, + async release() { + const currentJournal = await readRecoveryLeaseJournal(leasePath); + if (currentJournal?.token !== token) { + return; + } + const releasedPath = `${leasePath}.released-${token}`; + try { + await rename(leasePath, releasedPath); + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + return; + } + throw error; + } + await rm(releasedPath, { force: true, recursive: true }); + }, + }; + } catch (error) { + if (!isNodeErrorWithCode(error, "EEXIST")) { + throw error; + } + } + + const currentJournal = await readRecoveryLeaseJournal(leasePath); + if (currentJournal !== undefined && isProcessAlive(currentJournal.pid)) { + return undefined; + } + if (currentJournal === undefined && !(await isPathStale(leasePath))) { + return undefined; + } + + const staleLeasePath = `${leasePath}.stale-${token}`; + try { + await rename(leasePath, staleLeasePath); + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + continue; + } + throw error; + } + await rm(staleLeasePath, { force: true, recursive: true }); + } +} + +async function finishInterruptedPublication( + journalPath: string, + recoveryJournal: OutputPublicationJournal, +): Promise { + const staleJournal = await readOutputPublicationJournal(journalPath); + if (staleJournal === undefined) { + await rm(journalPath, { force: true, recursive: true }); + return; + } + assertMatchingPublicationTarget(staleJournal, recoveryJournal); + if (staleJournal.phase === "committed") { + await removeOutputPublicationBackups(staleJournal); + } else { + await rollbackOutputPublication(staleJournal); + } + await rm(journalPath, { force: true, recursive: true }); +} + +function assertMatchingPublicationTarget( + staleJournal: OutputPublicationJournal, + recoveryJournal: OutputPublicationJournal, +): void { + if ( + staleJournal.finalOutputDir !== recoveryJournal.finalOutputDir || + staleJournal.finalSummaryPath !== recoveryJournal.finalSummaryPath || + staleJournal.outputBackupPath !== + `${staleJournal.finalOutputDir}.eve-backup-${staleJournal.token}` || + staleJournal.summaryBackupPath !== + `${staleJournal.finalSummaryPath}.eve-backup-${staleJournal.token}` + ) { + throw new Error("Refusing to recover a build publication for a different output target."); + } +} + +async function waitForPublicationLockChange(lockPath: string, deadline: number): Promise { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + throw new Error( + `Timed out waiting ${PUBLICATION_LOCK_TIMEOUT_MS}ms to publish completed build output.`, + ); + } + + const locksDirectory = dirname(lockPath); + const watchedPrefix = basename(lockPath); + const initialState = await readPublicationLockState(lockPath); + await new Promise((resolvePromise, reject) => { + let settled = false; + const wakeAfterMs = Math.min(remainingMs, INCOMPLETE_LOCK_STALE_MS); + const deadlineTimer = setTimeout(settleResolve, wakeAfterMs); + const watcher = watch(locksDirectory, (eventType, filename) => { + if ( + eventType === "rename" && + (filename === null || filename.toString().startsWith(watchedPrefix)) + ) { + settleResolve(); + } + }); + + function cleanup() { + clearTimeout(deadlineTimer); + watcher.close(); + } + function settleResolve() { + if (settled) { + return; + } + settled = true; + cleanup(); + resolvePromise(); + } + function settleReject(error: unknown) { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(error); + } + + watcher.once("error", settleReject); + void Promise.all([ + readPublicationLockState(lockPath), + pathExists(lockPath), + pathExists(`${lockPath}.recovery`), + ]).then(([nextState, lockExists, recoveryExists]) => { + if ((!lockExists && !recoveryExists) || nextState !== initialState) { + settleResolve(); + } + }, settleReject); + }); +} + +async function readPublicationLockState(lockPath: string): Promise { + const recoveryPath = `${lockPath}.recovery`; + const [lockJournal, recoveryJournal, lockExists, recoveryExists] = await Promise.all([ + readOutputPublicationJournal(lockPath), + readRecoveryLeaseJournal(join(recoveryPath, "lease")), + pathExists(lockPath), + pathExists(recoveryPath), + ]); + return JSON.stringify({ + lockExists, + lockToken: lockJournal?.token, + recoveryExists, + recoveryToken: recoveryJournal?.token, + }); +} + +async function isPathStale(path: string): Promise { + try { + return Date.now() - (await stat(path)).mtimeMs >= INCOMPLETE_LOCK_STALE_MS; + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + return true; + } + throw error; + } +} + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + if (isNodeErrorWithCode(error, "ENOENT")) { + return false; + } + throw error; + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !isNodeErrorWithCode(error, "ESRCH"); + } +} + +function isNodeErrorWithCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} diff --git a/packages/eve/src/internal/application/output-publication.scenario.test.ts b/packages/eve/src/internal/application/output-publication.scenario.test.ts index e3a7e8545..bb648fb47 100644 --- a/packages/eve/src/internal/application/output-publication.scenario.test.ts +++ b/packages/eve/src/internal/application/output-publication.scenario.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { publishApplicationBuildArtifacts, + publishApplicationBuildArtifactsWithObserver, resolveOutputPublicationLockPath, } from "#internal/application/output-publication.js"; import { useTemporaryDirectories } from "#internal/testing/use-temporary-app-roots.js"; @@ -39,6 +40,53 @@ async function expectPublication(input: { await expect(readFile(input.summaryPath, "utf8")).resolves.toBe(`${input.summaryMarker}\n`); } +async function interruptPublicationAfterBackup(input: { + readonly appRoot: string; + readonly finalOutputDir: string; + readonly finalSummaryPath: string; + readonly stagedOutputDir: string; + readonly stagedSummaryPath: string; +}): Promise { + const token = "interrupted-owner"; + const outputBackupPath = `${input.finalOutputDir}.eve-backup-${token}`; + const summaryBackupPath = `${input.finalSummaryPath}.eve-backup-${token}`; + await writePublication({ + outputDir: input.finalOutputDir, + outputMarker: "last-good", + summaryMarker: "last-good", + summaryPath: input.finalSummaryPath, + }); + await writePublication({ + outputDir: input.stagedOutputDir, + outputMarker: "interrupted", + summaryMarker: "interrupted", + summaryPath: input.stagedSummaryPath, + }); + await Promise.all([ + rename(input.finalOutputDir, outputBackupPath), + rename(input.finalSummaryPath, summaryBackupPath), + ]); + const lockPath = resolveOutputPublicationLockPath(input.appRoot); + await mkdir(lockPath, { recursive: true }); + await writeFile( + join(lockPath, "owner.json"), + `${JSON.stringify({ + finalOutputDir: input.finalOutputDir, + finalSummaryPath: input.finalSummaryPath, + hadOutput: true, + hadSummary: true, + liveness: "active", + outputBackupPath, + phase: "backed-up", + pid: 2_147_483_647, + stagedOutputDir: input.stagedOutputDir, + stagedSummaryPath: input.stagedSummaryPath, + summaryBackupPath, + token, + })}\n`, + ); +} + describe("build output publication", () => { it("publishes matching output and summary", async () => { const appRoot = await createScratchDirectory("eve-output-publication-"); @@ -95,16 +143,22 @@ describe("build output publication", () => { }); await expect( - publishApplicationBuildArtifacts({ - appRoot, - finalOutputDir, - finalSummaryPath, - stagedOutputDir, - stagedSummaryPath, - onAfterOutputInstall() { - throw new Error("injected publication failure"); + publishApplicationBuildArtifactsWithObserver( + { + appRoot, + finalOutputDir, + finalSummaryPath, + stagedOutputDir, + stagedSummaryPath, + }, + { + async afterBackup() {}, + async afterOutputInstall() { + throw new Error("injected publication failure"); + }, + async onContention() {}, }, - }), + ), ).rejects.toThrow("injected publication failure"); await expectPublication({ @@ -146,32 +200,43 @@ describe("build output publication", () => { const secondObservedContention = Promise.withResolvers(); const entered: string[] = []; - const first = publishApplicationBuildArtifacts({ - appRoot, - finalOutputDir, - finalSummaryPath, - stagedOutputDir: firstOutputDir, - stagedSummaryPath: firstSummaryPath, - async onAfterBackup() { - entered.push("first"); - firstEntered.resolve(); - await releaseFirst.promise; + const first = publishApplicationBuildArtifactsWithObserver( + { + appRoot, + finalOutputDir, + finalSummaryPath, + stagedOutputDir: firstOutputDir, + stagedSummaryPath: firstSummaryPath, }, - }); + { + async afterBackup() { + entered.push("first"); + firstEntered.resolve(); + await releaseFirst.promise; + }, + async afterOutputInstall() {}, + async onContention() {}, + }, + ); await firstEntered.promise; - const second = publishApplicationBuildArtifacts({ - appRoot, - finalOutputDir, - finalSummaryPath, - stagedOutputDir: secondOutputDir, - stagedSummaryPath: secondSummaryPath, - onLockContention() { - secondObservedContention.resolve(); + const second = publishApplicationBuildArtifactsWithObserver( + { + appRoot, + finalOutputDir, + finalSummaryPath, + stagedOutputDir: secondOutputDir, + stagedSummaryPath: secondSummaryPath, }, - onAfterBackup() { - entered.push("second"); + { + async afterBackup() { + entered.push("second"); + }, + async afterOutputInstall() {}, + async onContention() { + secondObservedContention.resolve(); + }, }, - }); + ); await secondObservedContention.promise; expect(entered).toEqual(["first"]); @@ -195,63 +260,108 @@ describe("build output publication", () => { const interruptedSummaryPath = join(appRoot, ".eve", "builds", "interrupted", "summary.json"); const nextOutputDir = join(appRoot, ".eve", "builds", "next", "output"); const nextSummaryPath = join(appRoot, ".eve", "builds", "next", "summary.json"); - const interruptedToken = "interrupted-owner"; - const outputBackupPath = `${finalOutputDir}.eve-backup-${interruptedToken}`; - const summaryBackupPath = `${finalSummaryPath}.eve-backup-${interruptedToken}`; + await interruptPublicationAfterBackup({ + appRoot, + finalOutputDir, + finalSummaryPath, + stagedOutputDir: interruptedOutputDir, + stagedSummaryPath: interruptedSummaryPath, + }); await writePublication({ + outputDir: nextOutputDir, + outputMarker: "next", + summaryMarker: "next", + summaryPath: nextSummaryPath, + }); + await expect( + publishApplicationBuildArtifactsWithObserver( + { + appRoot, + finalOutputDir, + finalSummaryPath, + stagedOutputDir: nextOutputDir, + stagedSummaryPath: nextSummaryPath, + }, + { + async afterBackup() { + throw new Error("stop after recovery"); + }, + async afterOutputInstall() {}, + async onContention() {}, + }, + ), + ).rejects.toThrow("stop after recovery"); + + await expectPublication({ outputDir: finalOutputDir, outputMarker: "last-good", summaryMarker: "last-good", summaryPath: finalSummaryPath, }); + }); + + it("retains interrupted publication state when recovery itself fails", async () => { + const appRoot = await createScratchDirectory("eve-output-publication-recovery-retry-"); + const finalOutputDir = join(appRoot, ".output"); + const finalSummaryPath = join(appRoot, ".eve", "agent-summary.json"); + const interruptedOutputDir = join(appRoot, ".eve", "builds", "interrupted", "output"); + const interruptedSummaryPath = join(appRoot, ".eve", "builds", "interrupted", "summary.json"); + const firstOutputDir = join(appRoot, ".eve", "builds", "first", "output"); + const firstSummaryPath = join(appRoot, ".eve", "builds", "first", "summary.json"); + const retryOutputDir = join(appRoot, ".eve", "builds", "retry", "output"); + const retrySummaryPath = join(appRoot, ".eve", "builds", "retry", "summary.json"); + await interruptPublicationAfterBackup({ + appRoot, + finalOutputDir, + finalSummaryPath, + stagedOutputDir: interruptedOutputDir, + stagedSummaryPath: interruptedSummaryPath, + }); await writePublication({ - outputDir: interruptedOutputDir, - outputMarker: "interrupted", - summaryMarker: "interrupted", - summaryPath: interruptedSummaryPath, + outputDir: firstOutputDir, + outputMarker: "first", + summaryMarker: "first", + summaryPath: firstSummaryPath, }); await writePublication({ - outputDir: nextOutputDir, - outputMarker: "next", - summaryMarker: "next", - summaryPath: nextSummaryPath, + outputDir: retryOutputDir, + outputMarker: "retry", + summaryMarker: "retry", + summaryPath: retrySummaryPath, }); - await Promise.all([ - rename(finalOutputDir, outputBackupPath), - rename(finalSummaryPath, summaryBackupPath), - ]); - const lockPath = resolveOutputPublicationLockPath(appRoot); - await mkdir(lockPath, { recursive: true }); - await writeFile( - join(lockPath, "owner.json"), - `${JSON.stringify({ - finalOutputDir, - finalSummaryPath, - hadOutput: true, - hadSummary: true, - outputBackupPath, - phase: "backed-up", - pid: 2_147_483_647, - stagedOutputDir: interruptedOutputDir, - stagedSummaryPath: interruptedSummaryPath, - startedAt: new Date(0).toISOString(), - summaryBackupPath, - token: interruptedToken, - })}\n`, - ); + try { + await chmod(appRoot, 0o500); + await expect( + publishApplicationBuildArtifacts({ + appRoot, + finalOutputDir, + finalSummaryPath, + stagedOutputDir: firstOutputDir, + stagedSummaryPath: firstSummaryPath, + }), + ).rejects.toThrow(); + } finally { + await chmod(appRoot, 0o700); + } await expect( - publishApplicationBuildArtifacts({ - appRoot, - finalOutputDir, - finalSummaryPath, - stagedOutputDir: nextOutputDir, - stagedSummaryPath: nextSummaryPath, - onAfterBackup() { - throw new Error("stop after recovery"); + publishApplicationBuildArtifactsWithObserver( + { + appRoot, + finalOutputDir, + finalSummaryPath, + stagedOutputDir: retryOutputDir, + stagedSummaryPath: retrySummaryPath, }, - }), - ).rejects.toThrow("stop after recovery"); + { + async afterBackup() { + throw new Error("stop after recovery retry"); + }, + async afterOutputInstall() {}, + async onContention() {}, + }, + ), + ).rejects.toThrow("stop after recovery retry"); await expectPublication({ outputDir: finalOutputDir, @@ -282,16 +392,22 @@ describe("build output publication", () => { try { await expect( - publishApplicationBuildArtifacts({ - appRoot, - finalOutputDir, - finalSummaryPath, - stagedOutputDir, - stagedSummaryPath, - async onAfterOutputInstall() { - await chmod(appRoot, 0o500); + publishApplicationBuildArtifactsWithObserver( + { + appRoot, + finalOutputDir, + finalSummaryPath, + stagedOutputDir, + stagedSummaryPath, }, - }), + { + async afterBackup() {}, + async afterOutputInstall() { + await chmod(appRoot, 0o500); + }, + async onContention() {}, + }, + ), ).rejects.toThrow(); } finally { await chmod(appRoot, 0o700); diff --git a/packages/eve/src/internal/application/output-publication.ts b/packages/eve/src/internal/application/output-publication.ts index d5f6757fe..055a2ac1b 100644 --- a/packages/eve/src/internal/application/output-publication.ts +++ b/packages/eve/src/internal/application/output-publication.ts @@ -1,594 +1,132 @@ import { randomUUID } from "node:crypto"; -import { watch } from "node:fs"; -import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; -import { basename, dirname, join, resolve } from "node:path"; - -const PUBLICATION_LOCK_TIMEOUT_MS = 60_000; -const INCOMPLETE_LOCK_STALE_MS = 5_000; - -type PublicationPhase = "acquired" | "prepared" | "backed-up" | "installed" | "committed"; - -interface OutputPublicationOwner { +import { resolve } from "node:path"; + +import { + assertStagedPublicationExists, + backupOutputPublication, + installOutputPublication, + prepareOutputPublication, + removeOutputPublicationBackups, + rollbackOutputPublication, +} from "#internal/application/output-publication-artifacts.js"; +import type { OutputPublicationJournal } from "#internal/application/output-publication-journal.js"; +import { writeOutputPublicationJournal } from "#internal/application/output-publication-journal.js"; +import { + acquireOutputPublicationLock, + resolveOutputPublicationLockPath, +} from "#internal/application/output-publication-lock.js"; + +export { resolveOutputPublicationLockPath }; + +export interface OutputPublicationInput { + readonly appRoot: string; readonly finalOutputDir: string; readonly finalSummaryPath: string; - hadOutput: boolean; - hadSummary: boolean; - readonly outputBackupPath: string; - phase: PublicationPhase; - pid: number; readonly stagedOutputDir: string; readonly stagedSummaryPath: string; - readonly startedAt: string; - readonly summaryBackupPath: string; - readonly token: string; } -interface RecoveryLeaseOwner { - readonly pid: number; - readonly startedAt: string; - readonly token: string; +interface OutputPublicationObserver { + afterBackup(): Promise; + afterOutputInstall(): Promise; + onContention(): Promise; } -export async function publishApplicationBuildArtifacts(input: { - readonly appRoot: string; - readonly finalOutputDir: string; - readonly finalSummaryPath: string; - readonly stagedOutputDir: string; - readonly stagedSummaryPath: string; - readonly onAfterBackup?: () => Promise | void; - readonly onAfterOutputInstall?: () => Promise | void; - readonly onLockContention?: () => Promise | void; -}): Promise { - await Promise.all([stat(input.stagedOutputDir), stat(input.stagedSummaryPath)]); +const DEFAULT_OBSERVER: OutputPublicationObserver = { + async afterBackup() {}, + async afterOutputInstall() {}, + async onContention() {}, +}; - const token = randomUUID(); - const lockPath = resolveOutputPublicationLockPath(input.appRoot); - const owner: OutputPublicationOwner = { - finalOutputDir: resolve(input.finalOutputDir), - finalSummaryPath: resolve(input.finalSummaryPath), - hadOutput: false, - hadSummary: false, - outputBackupPath: `${resolve(input.finalOutputDir)}.eve-backup-${token}`, - phase: "acquired", - pid: process.pid, - stagedOutputDir: resolve(input.stagedOutputDir), - stagedSummaryPath: resolve(input.stagedSummaryPath), - startedAt: new Date().toISOString(), - summaryBackupPath: `${resolve(input.finalSummaryPath)}.eve-backup-${token}`, - token, - }; - const release = await acquireOutputPublicationLock({ - lockPath, - onContention: input.onLockContention, - owner, - }); - let releaseLock = false; - - try { - owner.hadOutput = await pathExists(owner.finalOutputDir); - owner.hadSummary = await pathExists(owner.finalSummaryPath); - owner.phase = "prepared"; - await writePublicationOwner(lockPath, owner); +export async function publishApplicationBuildArtifacts( + input: OutputPublicationInput, +): Promise { + await publishApplicationBuildArtifactsWithObserver(input, DEFAULT_OBSERVER); +} - await Promise.all([ - mkdir(dirname(owner.finalOutputDir), { recursive: true }), - mkdir(dirname(owner.finalSummaryPath), { recursive: true }), - ]); - if (owner.hadOutput) { - await rename(owner.finalOutputDir, owner.outputBackupPath); - } - if (owner.hadSummary) { - await rename(owner.finalSummaryPath, owner.summaryBackupPath); - } - owner.phase = "backed-up"; - await writePublicationOwner(lockPath, owner); - await input.onAfterBackup?.(); +export async function publishApplicationBuildArtifactsWithObserver( + input: OutputPublicationInput, + observer: OutputPublicationObserver, +): Promise { + const journal = createOutputPublicationJournal(input); + await assertStagedPublicationExists(journal); - await rename(owner.stagedOutputDir, owner.finalOutputDir); - await input.onAfterOutputInstall?.(); - await rename(owner.stagedSummaryPath, owner.finalSummaryPath); - owner.phase = "installed"; - await writePublicationOwner(lockPath, owner); + const lockPath = resolveOutputPublicationLockPath(input.appRoot); + const release = await acquireOutputPublicationLock(lockPath, journal, observer.onContention); - owner.phase = "committed"; - await writePublicationOwner(lockPath, owner); - await removePublicationBackups(owner); - releaseLock = true; + try { + await prepareOutputPublication(journal); + journal.phase = "prepared"; + await writeOutputPublicationJournal(lockPath, journal); + + await backupOutputPublication(journal); + journal.phase = "backed-up"; + await writeOutputPublicationJournal(lockPath, journal); + await observer.afterBackup(); + + await installOutputPublication(journal, observer.afterOutputInstall); + journal.phase = "committed"; + await writeOutputPublicationJournal(lockPath, journal); + await removeOutputPublicationBackups(journal); } catch (error) { - if (owner.phase === "committed") { + if (journal.phase === "committed") { await throwRecoverablePublicationError({ errors: [error], + journal, lockPath, message: "Build output was committed but backup cleanup failed.", - owner, }); } try { - await rollbackOutputPublication(owner); + await rollbackOutputPublication(journal); } catch (rollbackError) { await throwRecoverablePublicationError({ errors: [error, rollbackError], + journal, lockPath, message: "Build output publication failed and could not fully restore the previous output.", - owner, }); } - releaseLock = true; + await release(); throw error; - } finally { - if (releaseLock) { - await release(); - } } + + await release(); +} + +function createOutputPublicationJournal(input: OutputPublicationInput): OutputPublicationJournal { + const token = randomUUID(); + const finalOutputDir = resolve(input.finalOutputDir); + const finalSummaryPath = resolve(input.finalSummaryPath); + return { + finalOutputDir, + finalSummaryPath, + hadOutput: false, + hadSummary: false, + liveness: "active", + outputBackupPath: `${finalOutputDir}.eve-backup-${token}`, + phase: "acquired", + pid: process.pid, + stagedOutputDir: resolve(input.stagedOutputDir), + stagedSummaryPath: resolve(input.stagedSummaryPath), + summaryBackupPath: `${finalSummaryPath}.eve-backup-${token}`, + token, + }; } async function throwRecoverablePublicationError(input: { readonly errors: readonly unknown[]; + readonly journal: OutputPublicationJournal; readonly lockPath: string; readonly message: string; - readonly owner: OutputPublicationOwner; }): Promise { - input.owner.pid = 0; + input.journal.liveness = "recoverable"; try { - await writePublicationOwner(input.lockPath, input.owner); - } catch (ownerWriteError) { - throw new AggregateError([...input.errors, ownerWriteError], input.message, { + await writeOutputPublicationJournal(input.lockPath, input.journal); + } catch (journalWriteError) { + throw new AggregateError([...input.errors, journalWriteError], input.message, { cause: input.errors[0], }); } throw new AggregateError(input.errors, input.message, { cause: input.errors[0] }); } - -export function resolveOutputPublicationLockPath(appRoot: string): string { - return join(resolve(appRoot), ".eve", "locks", "output-publication.lock"); -} - -async function acquireOutputPublicationLock(input: { - readonly lockPath: string; - readonly onContention?: () => Promise | void; - readonly owner: OutputPublicationOwner; -}): Promise<() => Promise> { - const deadline = Date.now() + PUBLICATION_LOCK_TIMEOUT_MS; - const recoveryPath = `${input.lockPath}.recovery`; - await mkdir(dirname(input.lockPath), { recursive: true }); - - for (;;) { - if (await pathExists(recoveryPath)) { - await input.onContention?.(); - if (await recoverStalePublication(input.lockPath, recoveryPath, input.owner)) { - continue; - } - await waitForPublicationLockChange(input.lockPath, deadline); - continue; - } - - try { - await mkdir(input.lockPath); - if (await pathExists(recoveryPath)) { - await rm(input.lockPath, { force: true, recursive: true }); - await waitForPublicationLockChange(input.lockPath, deadline); - continue; - } - try { - await writePublicationOwner(input.lockPath, input.owner); - } catch (error) { - await rm(input.lockPath, { force: true, recursive: true }); - throw error; - } - return async () => { - const currentOwner = await readPublicationOwner(input.lockPath); - if (currentOwner?.token !== input.owner.token) { - return; - } - const releasedPath = `${input.lockPath}.released-${input.owner.token}`; - try { - await rename(input.lockPath, releasedPath); - } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT")) { - return; - } - throw error; - } - await rm(releasedPath, { force: true, recursive: true }); - }; - } catch (error) { - if (!isNodeErrorWithCode(error, "EEXIST")) { - throw error; - } - } - - await input.onContention?.(); - if (await recoverStalePublication(input.lockPath, recoveryPath, input.owner)) { - continue; - } - await waitForPublicationLockChange(input.lockPath, deadline); - } -} - -async function recoverStalePublication( - lockPath: string, - recoveryPath: string, - recoveryOwner: OutputPublicationOwner, -): Promise { - const releaseRecoveryLease = await acquireRecoveryLease(recoveryPath, recoveryOwner.token); - if (releaseRecoveryLease === undefined) { - return false; - } - - try { - const existingOwner = await readPublicationOwner(lockPath); - if (existingOwner !== undefined && isProcessAlive(existingOwner.pid)) { - return false; - } - if (existingOwner === undefined && !(await isPathStale(lockPath))) { - return false; - } - - if (await pathExists(lockPath)) { - const recoveringOwnerPath = join(recoveryPath, `owner-${recoveryOwner.token}`); - try { - await rename(lockPath, recoveringOwnerPath); - } catch (error) { - if (!isNodeErrorWithCode(error, "ENOENT")) { - throw error; - } - } - } - - const entries = await readdir(recoveryPath, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory() || !entry.name.startsWith("owner-")) { - continue; - } - await finishInterruptedPublication(join(recoveryPath, entry.name), recoveryOwner); - } - return true; - } finally { - await releaseRecoveryLease(); - } -} - -async function acquireRecoveryLease( - recoveryPath: string, - token: string, -): Promise<(() => Promise) | undefined> { - const leasePath = join(recoveryPath, "lease"); - const leaseOwner: RecoveryLeaseOwner = { - pid: process.pid, - startedAt: new Date().toISOString(), - token, - }; - await mkdir(recoveryPath, { recursive: true }); - - for (;;) { - try { - await mkdir(leasePath); - try { - await writeRecoveryLeaseOwner(leasePath, leaseOwner); - } catch (error) { - await rm(leasePath, { force: true, recursive: true }); - throw error; - } - return async () => { - const currentOwner = await readRecoveryLeaseOwner(leasePath); - if (currentOwner?.token !== token) { - return; - } - const releasedPath = `${recoveryPath}.released-${token}`; - try { - await rename(recoveryPath, releasedPath); - } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT")) { - return; - } - throw error; - } - await rm(releasedPath, { force: true, recursive: true }); - }; - } catch (error) { - if (!isNodeErrorWithCode(error, "EEXIST")) { - throw error; - } - } - - const currentOwner = await readRecoveryLeaseOwner(leasePath); - if (currentOwner !== undefined && isProcessAlive(currentOwner.pid)) { - return undefined; - } - if (currentOwner === undefined && !(await isPathStale(leasePath))) { - return undefined; - } - - const staleLeasePath = `${leasePath}.stale-${token}`; - try { - await rename(leasePath, staleLeasePath); - } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT")) { - continue; - } - throw error; - } - await rm(staleLeasePath, { force: true, recursive: true }); - } -} - -async function finishInterruptedPublication( - ownerPath: string, - recoveryOwner: OutputPublicationOwner, -): Promise { - try { - const staleOwner = await readPublicationOwner(ownerPath); - if (staleOwner === undefined) { - return; - } - assertMatchingPublicationTarget(staleOwner, recoveryOwner); - if (staleOwner.phase === "committed") { - await removePublicationBackups(staleOwner); - } else { - await rollbackOutputPublication(staleOwner); - } - } finally { - await rm(ownerPath, { force: true, recursive: true }); - } -} - -function assertMatchingPublicationTarget( - staleOwner: OutputPublicationOwner, - recoveryOwner: OutputPublicationOwner, -): void { - if ( - staleOwner.finalOutputDir !== recoveryOwner.finalOutputDir || - staleOwner.finalSummaryPath !== recoveryOwner.finalSummaryPath || - staleOwner.outputBackupPath !== `${staleOwner.finalOutputDir}.eve-backup-${staleOwner.token}` || - staleOwner.summaryBackupPath !== `${staleOwner.finalSummaryPath}.eve-backup-${staleOwner.token}` - ) { - throw new Error("Refusing to recover a build publication for a different output target."); - } -} - -async function rollbackOutputPublication(owner: OutputPublicationOwner): Promise { - const results = await Promise.allSettled([ - rollbackOneArtifact({ - backupPath: owner.outputBackupPath, - finalPath: owner.finalOutputDir, - hadPrevious: owner.hadOutput, - stagedPath: owner.stagedOutputDir, - }), - rollbackOneArtifact({ - backupPath: owner.summaryBackupPath, - finalPath: owner.finalSummaryPath, - hadPrevious: owner.hadSummary, - stagedPath: owner.stagedSummaryPath, - }), - ]); - const failures = results.flatMap((result) => - result.status === "rejected" ? [result.reason] : [], - ); - if (failures.length > 0) { - throw new AggregateError(failures, "Failed to restore the previous build publication."); - } -} - -async function rollbackOneArtifact(input: { - readonly backupPath: string; - readonly finalPath: string; - readonly hadPrevious: boolean; - readonly stagedPath: string; -}): Promise { - if (await pathExists(input.backupPath)) { - if (!(await pathExists(input.stagedPath)) && (await pathExists(input.finalPath))) { - await mkdir(dirname(input.stagedPath), { recursive: true }); - await rename(input.finalPath, input.stagedPath); - } else { - await rm(input.finalPath, { force: true, recursive: true }); - } - await rename(input.backupPath, input.finalPath); - return; - } - - if ( - !input.hadPrevious && - !(await pathExists(input.stagedPath)) && - (await pathExists(input.finalPath)) - ) { - await mkdir(dirname(input.stagedPath), { recursive: true }); - await rename(input.finalPath, input.stagedPath); - } -} - -async function removePublicationBackups(owner: OutputPublicationOwner): Promise { - await Promise.all([ - rm(owner.outputBackupPath, { force: true, recursive: true }), - rm(owner.summaryBackupPath, { force: true, recursive: true }), - ]); -} - -async function writePublicationOwner( - lockPath: string, - owner: OutputPublicationOwner, -): Promise { - const ownerPath = join(lockPath, "owner.json"); - const temporaryPath = `${ownerPath}.${owner.token}.tmp`; - await writeFile(temporaryPath, `${JSON.stringify(owner, null, 2)}\n`); - await rename(temporaryPath, ownerPath); -} - -async function readPublicationOwner(lockPath: string): Promise { - try { - const value = JSON.parse(await readFile(join(lockPath, "owner.json"), "utf8")) as unknown; - return isOutputPublicationOwner(value) ? value : undefined; - } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT") || error instanceof SyntaxError) { - return undefined; - } - throw error; - } -} - -async function writeRecoveryLeaseOwner( - leasePath: string, - owner: RecoveryLeaseOwner, -): Promise { - const ownerPath = join(leasePath, "owner.json"); - const temporaryPath = `${ownerPath}.${owner.token}.tmp`; - await writeFile(temporaryPath, `${JSON.stringify(owner, null, 2)}\n`); - await rename(temporaryPath, ownerPath); -} - -async function readRecoveryLeaseOwner(leasePath: string): Promise { - try { - const value = JSON.parse(await readFile(join(leasePath, "owner.json"), "utf8")) as unknown; - return isRecoveryLeaseOwner(value) ? value : undefined; - } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT") || error instanceof SyntaxError) { - return undefined; - } - throw error; - } -} - -function isOutputPublicationOwner(value: unknown): value is OutputPublicationOwner { - if (value === null || typeof value !== "object") { - return false; - } - const owner = value as Partial; - return ( - typeof owner.finalOutputDir === "string" && - typeof owner.finalSummaryPath === "string" && - typeof owner.hadOutput === "boolean" && - typeof owner.hadSummary === "boolean" && - typeof owner.outputBackupPath === "string" && - ["acquired", "prepared", "backed-up", "installed", "committed"].includes(owner.phase ?? "") && - typeof owner.pid === "number" && - typeof owner.stagedOutputDir === "string" && - typeof owner.stagedSummaryPath === "string" && - typeof owner.startedAt === "string" && - typeof owner.summaryBackupPath === "string" && - typeof owner.token === "string" - ); -} - -function isRecoveryLeaseOwner(value: unknown): value is RecoveryLeaseOwner { - if (value === null || typeof value !== "object") { - return false; - } - const owner = value as Partial; - return ( - typeof owner.pid === "number" && - typeof owner.startedAt === "string" && - typeof owner.token === "string" - ); -} - -async function waitForPublicationLockChange(lockPath: string, deadline: number): Promise { - const remainingMs = deadline - Date.now(); - if (remainingMs <= 0) { - throw new Error( - `Timed out waiting ${PUBLICATION_LOCK_TIMEOUT_MS}ms to publish completed build output.`, - ); - } - - const locksDirectory = dirname(lockPath); - const watchedPrefix = basename(lockPath); - const initialState = await readPublicationLockState(lockPath); - await new Promise((resolvePromise, reject) => { - let settled = false; - const wakeAfterMs = Math.min(remainingMs, INCOMPLETE_LOCK_STALE_MS); - const deadlineTimer = setTimeout(settleResolve, wakeAfterMs); - const watcher = watch(locksDirectory, (eventType, filename) => { - if ( - eventType === "rename" && - (filename === null || filename.toString().startsWith(watchedPrefix)) - ) { - settleResolve(); - } - }); - - function cleanup() { - clearTimeout(deadlineTimer); - watcher.close(); - } - function settleResolve() { - if (settled) { - return; - } - settled = true; - cleanup(); - resolvePromise(); - } - function settleReject(error: unknown) { - if (settled) { - return; - } - settled = true; - cleanup(); - reject(error); - } - - watcher.once("error", settleReject); - void Promise.all([ - readPublicationLockState(lockPath), - pathExists(lockPath), - pathExists(`${lockPath}.recovery`), - ]).then(([nextState, lockExists, recoveryExists]) => { - if ((!lockExists && !recoveryExists) || nextState !== initialState) { - settleResolve(); - } - }, settleReject); - }); -} - -async function readPublicationLockState(lockPath: string): Promise { - const recoveryPath = `${lockPath}.recovery`; - const [lockOwner, recoveryOwner, lockExists, recoveryExists] = await Promise.all([ - readPublicationOwner(lockPath), - readRecoveryLeaseOwner(join(recoveryPath, "lease")), - pathExists(lockPath), - pathExists(recoveryPath), - ]); - return JSON.stringify({ - lockExists, - lockToken: lockOwner?.token, - recoveryExists, - recoveryToken: recoveryOwner?.token, - }); -} - -async function isPathStale(path: string): Promise { - try { - return Date.now() - (await stat(path)).mtimeMs >= INCOMPLETE_LOCK_STALE_MS; - } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT")) { - return true; - } - throw error; - } -} - -async function pathExists(path: string): Promise { - try { - await stat(path); - return true; - } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT")) { - return false; - } - throw error; - } -} - -function isProcessAlive(pid: number): boolean { - if (pid <= 0) { - return false; - } - try { - process.kill(pid, 0); - return true; - } catch (error) { - return !isNodeErrorWithCode(error, "ESRCH"); - } -} - -function isNodeErrorWithCode(error: unknown, code: string): boolean { - return error instanceof Error && "code" in error && error.code === code; -} diff --git a/packages/eve/src/internal/application/production-compiler-artifacts.ts b/packages/eve/src/internal/application/production-compiler-artifacts.ts new file mode 100644 index 000000000..2683d402a --- /dev/null +++ b/packages/eve/src/internal/application/production-compiler-artifacts.ts @@ -0,0 +1,12 @@ +import { cp, mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +export async function stageProductionCompilerArtifacts(input: { + readonly compilerArtifactsRoot: string; + readonly outputDir: string; +}): Promise { + const destinationDirectory = join(input.outputDir, ".eve"); + + await mkdir(dirname(destinationDirectory), { recursive: true }); + await cp(input.compilerArtifactsRoot, destinationDirectory, { recursive: true }); +} diff --git a/packages/eve/src/internal/application/production-start-artifacts.ts b/packages/eve/src/internal/application/production-start-artifacts.ts deleted file mode 100644 index 2f3789081..000000000 --- a/packages/eve/src/internal/application/production-start-artifacts.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { cp, mkdir } from "node:fs/promises"; -import { dirname, join } from "node:path"; - -export async function stageProductionStartArtifacts(input: { - readonly compilerArtifactsRoot: string; - readonly outputDir: string; -}): Promise { - const sourceDirectory = join(input.compilerArtifactsRoot, "compile"); - const destinationDirectory = join(input.outputDir, ".eve", "compile"); - - await mkdir(dirname(destinationDirectory), { recursive: true }); - await cp(sourceDirectory, destinationDirectory, { recursive: true }); -} diff --git a/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts b/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts index e0a9fb4df..10e48e56d 100644 --- a/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts +++ b/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts @@ -504,8 +504,8 @@ describe("development runtime artifact snapshots", () => { expect( resolveNitroCompiledArtifactsSource({ appRoot, - dev: true, devRuntimeArtifactsPointerPath: resolveDevelopmentRuntimeArtifactsPointerPath(appRoot), + kind: "development", moduleMapLoaderPath: "/package/src/internal/authored-module-map-loader.ts", }), ).toMatchObject({ diff --git a/packages/eve/src/internal/nitro/host/artifacts-config.ts b/packages/eve/src/internal/nitro/host/artifacts-config.ts index 6375619f1..da162ead3 100644 --- a/packages/eve/src/internal/nitro/host/artifacts-config.ts +++ b/packages/eve/src/internal/nitro/host/artifacts-config.ts @@ -1,40 +1,23 @@ import { resolvePackageSourceFilePath } from "#internal/application/package.js"; import { resolveDevelopmentRuntimeArtifactsPointerPath } from "#internal/nitro/dev-runtime-artifacts.js"; +import type { + DevelopmentNitroArtifactsConfig, + ProductionNitroArtifactsConfig, +} from "#internal/nitro/routes/runtime-artifacts.js"; -/** - * Artifacts config serialized into virtual Nitro handlers so route handlers - * can resolve compiled artifacts without a global runtime configuration store. - */ -export type NitroArtifactsConfigInput = - | { - readonly appRoot: string; - readonly dev: false; - } - | { - readonly appRoot: string; - readonly dev: true; - readonly devRuntimeArtifactsPointerPath: string; - readonly moduleMapLoaderPath: string; - }; - -/** Creates the artifact config baked into development Nitro virtual handlers. */ export function createDevelopmentNitroArtifactsConfig(input: { readonly appRoot: string; -}): NitroArtifactsConfigInput { +}): DevelopmentNitroArtifactsConfig { return { appRoot: input.appRoot, devRuntimeArtifactsPointerPath: resolveDevelopmentRuntimeArtifactsPointerPath(input.appRoot), - dev: true, + kind: "development", moduleMapLoaderPath: resolvePackageSourceFilePath("src/internal/authored-module-map-loader.ts"), }; } -/** Creates the artifact config baked into production Nitro virtual handlers. */ -export function createProductionNitroArtifactsConfig(input: { - readonly appRoot: string; -}): NitroArtifactsConfigInput { +export function createProductionNitroArtifactsConfig(): ProductionNitroArtifactsConfig { return { - appRoot: input.appRoot, - dev: false, + kind: "production", }; } diff --git a/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts b/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts index 86e81802c..541c929df 100644 --- a/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts +++ b/packages/eve/src/internal/nitro/host/build-application.scenario.test.ts @@ -165,7 +165,7 @@ function createNitroStub(outputDir: string): Nitro { async function prepareHostBuildWorkspace( workspace: ApplicationBuildWorkspace, ): Promise { - await mkdir(join(workspace.compilerArtifactsRoot, "compile"), { recursive: true }); + await mkdir(join(workspace.compiler.artifactsDir, "compile"), { recursive: true }); return createPreparedHost(workspace.appRoot); } @@ -262,7 +262,9 @@ describe("buildApplication", () => { ]); const { buildApplication } = await import("#internal/nitro/host/build-application.js"); - await expect(buildApplication(appRoot)).rejects.toThrow("injected Nitro build failure"); + await expect(buildApplication(appRoot, DEPLOYABLE_BUILD_OPTIONS)).rejects.toThrow( + "injected Nitro build failure", + ); await expect(readFile(join(outputDir, "marker.txt"), "utf8")).resolves.toBe( "last-good-output\n", @@ -397,19 +399,10 @@ describe("buildApplication", () => { vi.stubEnv("VERCEL", "1"); const appRoot = await createScratchDirectory("eve-build-application-skip-prewarm-"); - prepareApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); - createApplicationNitroMock.mockImplementation( - async ( - _preparedHost: PreparedApplicationHost, - _dev: boolean, - options: { outputDir?: string; surface?: string } = {}, - ) => { - if (options.surface === "app") { - return createNitroStub(join(appRoot, ".vercel", "output")); - } - - return createNitroStub(options.outputDir ?? join(appRoot, ".output")); - }, + prepareProductionApplicationHostMock.mockImplementationOnce(prepareHostBuildWorkspace); + createProductionApplicationNitroMock.mockImplementation( + async (_preparedHost: PreparedApplicationHost, options: { outputDir: string }) => + createNitroStub(options.outputDir), ); const { buildApplication } = await import("#internal/nitro/host/build-application.js"); diff --git a/packages/eve/src/internal/nitro/host/build-application.ts b/packages/eve/src/internal/nitro/host/build-application.ts index 987648498..fa3a8d1a0 100644 --- a/packages/eve/src/internal/nitro/host/build-application.ts +++ b/packages/eve/src/internal/nitro/host/build-application.ts @@ -15,7 +15,7 @@ import { type ApplicationBuildWorkspace, } from "#internal/application/build-workspace.js"; import { publishApplicationBuildArtifacts } from "#internal/application/output-publication.js"; -import { stageProductionStartArtifacts } from "#internal/application/production-start-artifacts.js"; +import { stageProductionCompilerArtifacts } from "#internal/application/production-compiler-artifacts.js"; import { WorkflowBundleBuilder } from "#internal/workflow-bundle/builder.js"; import { normalizeEveVercelFunctionOutput } from "#internal/workflow-bundle/vercel-workflow-output.js"; import { createProductionApplicationNitro } from "#internal/nitro/host/create-application-nitro.js"; @@ -290,8 +290,8 @@ async function buildVercelNitroSurface( surface: Exclude, ): Promise { const nitro = await createProductionApplicationNitro(preparedHost, { - buildDir: join(workspace.nitroBuildDir, surface), - outputDir: join(workspace.nitroOutputDir, surface), + buildDir: join(workspace.nitro.buildDir, surface), + outputDir: join(workspace.nitro.surfaceOutputDir, surface), surface, }); @@ -336,8 +336,8 @@ async function buildApplicationInWorkspace( if (!process.env.VERCEL) { const nitro = await createProductionApplicationNitro(preparedHost, { - buildDir: workspace.nitroBuildDir, - outputDir: workspace.outputDir, + buildDir: workspace.nitro.buildDir, + outputDir: workspace.publication.output.stagedDir, surface: "all", }); @@ -345,18 +345,18 @@ async function buildApplicationInWorkspace( await buildNitroOutput(nitro); await emitVercelAgentSummary({ manifest: preparedHost.compileResult.manifest, - outputPath: workspace.summaryPath, + outputPath: workspace.publication.summary.stagedPath, }); - await stageProductionStartArtifacts({ - compilerArtifactsRoot: workspace.compilerArtifactsRoot, - outputDir: workspace.outputDir, + await stageProductionCompilerArtifacts({ + compilerArtifactsRoot: workspace.compiler.artifactsDir, + outputDir: workspace.publication.output.stagedDir, }); } finally { await nitro.close(); } await publishCompletedApplicationBuild(workspace); - return workspace.finalOutputDir; + return workspace.publication.output.finalDir; } const servicePrefix = await resolveCoDeployedEveServicePrefixForVercelFunctionOutput( @@ -364,8 +364,8 @@ async function buildApplicationInWorkspace( preparedHost.compileResult.project.agentRoot, ); const nitro = await createProductionApplicationNitro(preparedHost, { - buildDir: join(workspace.nitroBuildDir, "app"), - outputDir: workspace.outputDir, + buildDir: join(workspace.nitro.buildDir, "app"), + outputDir: workspace.publication.output.stagedDir, surface: "app", }); @@ -378,7 +378,7 @@ async function buildApplicationInWorkspace( await runVercelBuildPrewarm({ appRoot: preparedHost.appRoot, compiledArtifactsSource: createDiskRuntimeCompiledArtifactsSource( - workspace.compilerAppRoot, + workspace.compiler.rootDir, { moduleMapLoaderPath: resolvePackageSourceFilePath( "src/internal/authored-module-map-loader.ts", @@ -397,24 +397,24 @@ async function buildApplicationInWorkspace( appRoot: preparedHost.appRoot, compiledArtifactsBootstrapPath: preparedHost.compiledArtifacts.bootstrapPath, flowNitroOutputDir, - outputDir: workspace.outputDir, - workflowBuildDir: preparedHost.workflowBuildDir, + outputDir: workspace.publication.output.stagedDir, + workflowBuildDir: workspace.workflow.buildDir, }); if (servicePrefix !== undefined) { - await normalizeEveVercelFunctionOutput(workspace.outputDir, { + await normalizeEveVercelFunctionOutput(workspace.publication.output.stagedDir, { servicePrefix, }); } await emitVercelAgentSummary({ manifest: preparedHost.compileResult.manifest, - outputPath: workspace.summaryPath, + outputPath: workspace.publication.summary.stagedPath, }); } finally { await nitro.close(); } await publishCompletedApplicationBuild(workspace); - return workspace.finalOutputDir; + return workspace.publication.output.finalDir; } async function publishCompletedApplicationBuild( @@ -422,9 +422,9 @@ async function publishCompletedApplicationBuild( ): Promise { await publishApplicationBuildArtifacts({ appRoot: workspace.appRoot, - finalOutputDir: workspace.finalOutputDir, - finalSummaryPath: workspace.finalSummaryPath, - stagedOutputDir: workspace.outputDir, - stagedSummaryPath: workspace.summaryPath, + finalOutputDir: workspace.publication.output.finalDir, + finalSummaryPath: workspace.publication.summary.finalPath, + stagedOutputDir: workspace.publication.output.stagedDir, + stagedSummaryPath: workspace.publication.summary.stagedPath, }); } diff --git a/packages/eve/src/internal/nitro/host/channel-routes.ts b/packages/eve/src/internal/nitro/host/channel-routes.ts index ec02822d3..54cbef88b 100644 --- a/packages/eve/src/internal/nitro/host/channel-routes.ts +++ b/packages/eve/src/internal/nitro/host/channel-routes.ts @@ -11,7 +11,7 @@ import { resolvePackageDependencyPath, resolvePackageSourceFilePath, } from "#internal/application/package.js"; -import type { NitroArtifactsConfigInput } from "#internal/nitro/host/artifacts-config.js"; +import type { NitroArtifactsConfig } from "#internal/nitro/routes/runtime-artifacts.js"; import { replaceDevLiveVirtualModules } from "#internal/nitro/host/dev-live-virtual-modules.js"; import type { PreparedApplicationHost } from "#internal/nitro/host/types.js"; @@ -98,7 +98,7 @@ export function computeChannelRouteRegistrations( export function registerChannelVirtualHandlers( nitro: Pick, input: { - readonly artifactsConfig: NitroArtifactsConfigInput; + readonly artifactsConfig: NitroArtifactsConfig; readonly registrations: readonly NitroChannelRouteRegistration[]; }, ): void { @@ -121,7 +121,7 @@ export function registerChannelVirtualHandlers( export function syncChannelVirtualHandlers( nitro: ChannelRouteNitro, input: { - readonly artifactsConfig: NitroArtifactsConfigInput; + readonly artifactsConfig: NitroArtifactsConfig; readonly next: readonly NitroChannelRouteRegistration[]; readonly previous: readonly NitroChannelRouteRegistration[]; }, @@ -163,7 +163,7 @@ function createChannelRouteKey(registration: NitroChannelRouteRegistration): str function addChannelVirtualHandler( nitro: Pick, input: { - artifactsConfig: NitroArtifactsConfigInput; + artifactsConfig: NitroArtifactsConfig; cors?: NormalizedChannelCorsOptions; method: ChannelRouteMethod; preflightRoutes: Set; diff --git a/packages/eve/src/internal/nitro/host/configure-nitro-routes.test.ts b/packages/eve/src/internal/nitro/host/configure-nitro-routes.test.ts index 805a04d34..1bcd3b859 100644 --- a/packages/eve/src/internal/nitro/host/configure-nitro-routes.test.ts +++ b/packages/eve/src/internal/nitro/host/configure-nitro-routes.test.ts @@ -4,8 +4,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { PreparedApplicationHost } from "./types.js"; interface NitroStub { + hookHandlers: Map unknown>>; hooks: { - hook(): void; + hook(name: string, handler: () => unknown): void; }; options: { buildDir: string; @@ -90,10 +91,14 @@ const { EVE_HEALTH_ROUTE_PATH, EVE_INFO_ROUTE_PATH } = await import("#protocol/r function createNitroStub( input: { buildDir?: string; dev?: boolean; rootDir?: string } = {}, -): Nitro { +): Nitro & Pick { + const hookHandlers = new Map unknown>>(); const nitro: NitroStub = { + hookHandlers, hooks: { - hook() {}, + hook(name, handler) { + hookHandlers.set(name, [...(hookHandlers.get(name) ?? []), handler]); + }, }, options: { buildDir: input.buildDir ?? "G:\\projects\\test-eve\\.eve\\nitro", @@ -107,7 +112,7 @@ function createNitroStub( }, }; - return nitro as never as Nitro; + return nitro as never as Nitro & Pick; } function createPreparedHost( @@ -251,6 +256,24 @@ describe("Nitro route configuration", () => { expect(nitro.options.virtual["#eve-workflow/workflows"]).toBeUndefined(); }); + it("reports a failed workflow sync and runs the next queued sync", async () => { + workflowBuilderMocks.build + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("injected workflow sync failure")) + .mockResolvedValueOnce(undefined); + const nitro = createNitroStub({ dev: true }); + + await configureDevelopmentNitroRoutes(nitro, createPreparedHost()); + const reload = nitro.hookHandlers.get("dev:reload")?.[0]; + if (reload === undefined) { + throw new Error("Expected the workflow reload hook to be registered."); + } + + await expect(reload()).rejects.toThrow("injected workflow sync failure"); + await expect(reload()).resolves.toBeUndefined(); + expect(workflowBuilderMocks.build).toHaveBeenCalledTimes(3); + }); + it("registers direct workflow queue handlers in dev mode so the worker bypasses HTTP dispatch", async () => { const root = "/tmp/eve-nitro-direct-handlers"; const buildDir = `${root}/nitro`; @@ -319,10 +342,10 @@ describe("Nitro route configuration", () => { }); expect( devNitro.options.virtual[`#nitro/virtual/eve-channel/GET ${EVE_INFO_ROUTE_PATH}`], - ).toContain('"dev":true'); + ).toContain('"kind":"development"'); expect( prodNitro.options.virtual[`#nitro/virtual/eve-channel/GET ${EVE_INFO_ROUTE_PATH}`], - ).toContain('"dev":false'); + ).toContain('"kind":"production"'); expect( devNitro.options.virtual[`#nitro/virtual/eve-channel/GET ${EVE_INFO_ROUTE_PATH}`], ).toContain("dispatchChannelRequest"); 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 c24548e9e..802f15286 100644 --- a/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts +++ b/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts @@ -21,8 +21,11 @@ import { WorkflowBundleBuilder } from "#internal/workflow-bundle/builder.js"; import { createDevelopmentNitroArtifactsConfig, createProductionNitroArtifactsConfig, - type NitroArtifactsConfigInput, } from "#internal/nitro/host/artifacts-config.js"; +import type { + DevelopmentNitroArtifactsConfig, + NitroArtifactsConfig, +} from "#internal/nitro/routes/runtime-artifacts.js"; import { deriveEveWorkflowQueuePrefix } from "#internal/workflow/queue-namespace.js"; import { computeChannelRouteRegistrations, @@ -271,8 +274,8 @@ function createSerializedWorkflowArtifactSync( let previousBuild: Promise = Promise.resolve(); return async () => { - const nextBuild = previousBuild.then(buildWorkflowArtifacts); - previousBuild = nextBuild.catch(() => {}); + const nextBuild = previousBuild.then(buildWorkflowArtifacts, buildWorkflowArtifacts); + previousBuild = nextBuild; await nextBuild; }; } @@ -297,7 +300,7 @@ async function registerWorkflowArtifactBuildHook( function registerApplicationRoutes( nitro: Nitro, preparedHost: PreparedApplicationHost, - artifactsConfig: NitroArtifactsConfigInput, + artifactsConfig: NitroArtifactsConfig, ): void { addFrameworkVirtualHandler(nitro, { args: JSON.stringify({ @@ -323,7 +326,7 @@ function registerApplicationRoutes( function registerDevelopmentControlRoutes( nitro: Nitro, - artifactsConfig: NitroArtifactsConfigInput, + artifactsConfig: DevelopmentNitroArtifactsConfig, ): void { addFrameworkVirtualHandler(nitro, { args: JSON.stringify({ appRoot: artifactsConfig.appRoot }), @@ -372,7 +375,6 @@ async function registerWorkflowRoute( }); } -/** Wires development-only application and Workflow routes into a Nitro host. */ export async function configureDevelopmentNitroRoutes( nitro: Nitro, preparedHost: PreparedApplicationHost, @@ -409,7 +411,6 @@ export async function configureDevelopmentNitroRoutes( nitro.routing.sync(); } -/** Wires production application and Workflow routes for one build surface. */ export async function configureProductionNitroRoutes( nitro: Nitro, preparedHost: PreparedApplicationHost, @@ -433,11 +434,7 @@ export async function configureProductionNitroRoutes( } if (includesApplicationRoutes(surface)) { - registerApplicationRoutes( - nitro, - preparedHost, - createProductionNitroArtifactsConfig({ appRoot: preparedHost.appRoot }), - ); + registerApplicationRoutes(nitro, preparedHost, createProductionNitroArtifactsConfig()); } if (includesWorkflowRoute(surface)) { diff --git a/packages/eve/src/internal/nitro/host/create-application-nitro.ts b/packages/eve/src/internal/nitro/host/create-application-nitro.ts index 1f5a5e98f..129f0c10c 100644 --- a/packages/eve/src/internal/nitro/host/create-application-nitro.ts +++ b/packages/eve/src/internal/nitro/host/create-application-nitro.ts @@ -16,10 +16,7 @@ import { writeEveVersionedCacheMetadata, } from "#internal/application/cache-metadata.js"; import { resolveNitroBuildDirectory } from "#internal/application/paths.js"; -import { - createProductionNitroArtifactsConfig, - type NitroArtifactsConfigInput, -} from "#internal/nitro/host/artifacts-config.js"; +import { createProductionNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; import { createCompiledSandboxBackendPrunePlugin } from "#internal/nitro/host/compiled-sandbox-backend-prune-plugin.js"; import { createExtensionScopePlugin } from "#internal/bundler/extension-scope-plugin.js"; import { @@ -746,7 +743,6 @@ function externalizeDevelopmentWorkflowBundle( }); } -/** Creates the watch-enabled Nitro host used by `eve dev`. */ export async function createDevelopmentApplicationNitro( preparedHost: PreparedApplicationHost, ): Promise { @@ -798,7 +794,6 @@ interface ProductionApplicationNitroOptions { readonly surface: NitroBuildSurface; } -/** Creates one workspace-owned Nitro host for a production build surface. */ export async function createProductionApplicationNitro( preparedHost: PreparedApplicationHost, options: ProductionApplicationNitroOptions, @@ -848,9 +843,7 @@ export async function createProductionApplicationNitro( preparedHost.scheduleRegistrations.length > 0 ) { applyEveCronHandlerRoute(nitro); - const artifactsConfig: NitroArtifactsConfigInput = createProductionNitroArtifactsConfig({ - appRoot: preparedHost.appRoot, - }); + const artifactsConfig = createProductionNitroArtifactsConfig(); registerScheduleTaskHandlers(nitro, { artifactsConfig, dispatchModulePath: resolvePackageSourceFilePath( diff --git a/packages/eve/src/internal/nitro/host/prepare-application-host.scenario.test.ts b/packages/eve/src/internal/nitro/host/prepare-application-host.scenario.test.ts index 5d24fcdd2..587d916ad 100644 --- a/packages/eve/src/internal/nitro/host/prepare-application-host.scenario.test.ts +++ b/packages/eve/src/internal/nitro/host/prepare-application-host.scenario.test.ts @@ -36,6 +36,7 @@ describe("application host preparation", () => { afterEach(() => { vi.unstubAllEnvs(); }); + it("keeps production compiler and host writes inside one invocation workspace", async () => { const { agentRoot, appRoot } = await createAppRoot("eve-production-host-workspace-", { files: { @@ -50,12 +51,12 @@ describe("application host preparation", () => { const preparedHost = await prepareProductionApplicationHost(workspace); expect(preparedHost.compileResult.paths.compileDirectoryPath).toBe( - join(workspace.compilerArtifactsRoot, "compile"), + join(workspace.compiler.artifactsDir, "compile"), ); expect(preparedHost.compiledArtifacts.bootstrapPath).toBe( - join(workspace.hostArtifactsDir, "compiled-artifacts-bootstrap.mjs"), + join(workspace.host.artifactsDir, "compiled-artifacts-bootstrap.mjs"), ); - expect(preparedHost.workflowBuildDir).toBe(workspace.workflowBuildDir); + expect(preparedHost.workflowBuildDir).toBe(workspace.workflow.buildDir); expect(existsSync(join(appRoot, ".eve", "compile"))).toBe(false); expect(existsSync(join(appRoot, ".eve", "host"))).toBe(false); } finally { diff --git a/packages/eve/src/internal/nitro/host/prepare-application-host.ts b/packages/eve/src/internal/nitro/host/prepare-application-host.ts index 87fa7efa0..3f504f3c4 100644 --- a/packages/eve/src/internal/nitro/host/prepare-application-host.ts +++ b/packages/eve/src/internal/nitro/host/prepare-application-host.ts @@ -10,6 +10,7 @@ import { } from "#runtime/schedules/resolve-schedule.js"; import type { ResolvedScheduleDefinition } from "#runtime/types.js"; import type { ApplicationBuildWorkspace } from "#internal/application/build-workspace.js"; +import { join } from "node:path"; import { type BuiltInWorkflowWorldTarget, writeCompiledArtifactsFiles, @@ -25,7 +26,6 @@ import { } from "#internal/nitro/dev-runtime-artifacts.js"; import type { PreparedApplicationHost } from "#internal/nitro/host/types.js"; -/** Compiles and activates the stable artifacts consumed by a development host. */ export async function prepareDevelopmentApplicationHost( startPath: string, ): Promise { @@ -53,12 +53,14 @@ export async function prepareDevelopmentApplicationHost( return preparedHost; } -/** Prepares a production host without writing outside its invocation workspace. */ export async function prepareProductionApplicationHost( workspace: ApplicationBuildWorkspace, ): Promise { const compileResult = await compileAgentInBuildWorkspace({ - compilerArtifactsRoot: workspace.compilerArtifactsRoot, + artifactLocations: { + publishedRoot: join(workspace.publication.output.finalDir, ".eve"), + writeRoot: workspace.compiler.artifactsDir, + }, startPath: workspace.appRoot, }); const schedules = await resolveSchedules({ manifest: compileResult.manifest }); @@ -66,9 +68,9 @@ export async function prepareProductionApplicationHost( return await materializeApplicationHost({ compileResult, defaultWorkflowWorld: resolveProductionWorkflowWorldTarget(), - hostArtifactsDir: workspace.hostArtifactsDir, + hostArtifactsDir: workspace.host.artifactsDir, schedules, - workflowBuildDir: workspace.workflowBuildDir, + workflowBuildDir: workspace.workflow.buildDir, }); } @@ -96,5 +98,9 @@ async function materializeApplicationHost(input: { } function resolveProductionWorkflowWorldTarget(): BuiltInWorkflowWorldTarget { - return process.env.VERCEL ? "vercel" : "local"; + if (process.env.VERCEL) { + return "vercel"; + } + + return "local"; } diff --git a/packages/eve/src/internal/nitro/host/schedule-task-routes.test.ts b/packages/eve/src/internal/nitro/host/schedule-task-routes.test.ts index 4ec6b337b..fea1ce78a 100644 --- a/packages/eve/src/internal/nitro/host/schedule-task-routes.test.ts +++ b/packages/eve/src/internal/nitro/host/schedule-task-routes.test.ts @@ -15,8 +15,7 @@ import { const DISPATCH_MODULE_PATH = "/framework/schedule-task.ts"; const ARTIFACTS_CONFIG = { - appRoot: "/tmp/test-agent", - dev: false, + kind: "production", } as const; describe("schedule task routes", () => { diff --git a/packages/eve/src/internal/nitro/host/schedule-task-routes.ts b/packages/eve/src/internal/nitro/host/schedule-task-routes.ts index 4af9fe93a..b12ca80bc 100644 --- a/packages/eve/src/internal/nitro/host/schedule-task-routes.ts +++ b/packages/eve/src/internal/nitro/host/schedule-task-routes.ts @@ -5,7 +5,7 @@ import { type ScheduleRegistration, } from "#runtime/schedules/register.js"; import { stringifyEsmImportSpecifier } from "#internal/application/import-specifier.js"; -import type { NitroArtifactsConfigInput } from "#internal/nitro/host/artifacts-config.js"; +import type { NitroArtifactsConfig } from "#internal/nitro/routes/runtime-artifacts.js"; /** * Virtual id prefix used for the synthetic Nitro task module emitted for each @@ -28,7 +28,7 @@ interface ScheduleTaskNitro { * along with the baked-in artifacts config. */ export interface RegisterScheduleTaskHandlersInput { - readonly artifactsConfig: NitroArtifactsConfigInput; + readonly artifactsConfig: NitroArtifactsConfig; readonly dispatchModulePath: string; readonly registrations: readonly ScheduleRegistration[]; } @@ -38,7 +38,7 @@ export interface RegisterScheduleTaskHandlersInput { * change in dev mode. */ export interface SyncScheduleTaskHandlersInput { - readonly artifactsConfig: NitroArtifactsConfigInput; + readonly artifactsConfig: NitroArtifactsConfig; readonly dispatchModulePath: string; readonly next: readonly ScheduleRegistration[]; readonly previous: readonly ScheduleRegistration[]; @@ -146,7 +146,7 @@ export function removeScheduleTaskHandlers(nitro: ScheduleTaskNitro): void { function addScheduleTaskVirtualHandler( nitro: ScheduleTaskNitro, input: { - artifactsConfig: NitroArtifactsConfigInput; + artifactsConfig: NitroArtifactsConfig; dispatchModulePath: string; registration: ScheduleRegistration; }, diff --git a/packages/eve/src/internal/nitro/routes/agent-info/load-agent-info-data.test.ts b/packages/eve/src/internal/nitro/routes/agent-info/load-agent-info-data.test.ts index 717b2d680..dcf2cabc0 100644 --- a/packages/eve/src/internal/nitro/routes/agent-info/load-agent-info-data.test.ts +++ b/packages/eve/src/internal/nitro/routes/agent-info/load-agent-info-data.test.ts @@ -35,12 +35,15 @@ describe("resolveAgentInfoCompiledArtifactsSource", () => { expect( resolveAgentInfoCompiledArtifactsSource({ appRoot: "/tmp/app", - dev: true, devRuntimeArtifactsPointerPath: "/tmp/app/.eve/dev-runtime/current.json", + kind: "development", + moduleMapLoaderPath: "/tmp/eve/src/internal/authored-module-map-loader.ts", }), ).toEqual({ appRoot: "/tmp/app/.eve/dev-runtime/snapshot/app", kind: "disk", + moduleMapLoaderPath: "/tmp/eve/src/internal/authored-module-map-loader.ts", + sandboxAppRoot: "/tmp/app", }); expect(mocks.readDevelopmentRuntimeArtifactsSnapshotRoot).toHaveBeenCalledWith( "/tmp/app/.eve/dev-runtime/current.json", diff --git a/packages/eve/src/internal/nitro/routes/agent-info/load-agent-info-data.ts b/packages/eve/src/internal/nitro/routes/agent-info/load-agent-info-data.ts index d3a46aa62..6ccabcb9b 100644 --- a/packages/eve/src/internal/nitro/routes/agent-info/load-agent-info-data.ts +++ b/packages/eve/src/internal/nitro/routes/agent-info/load-agent-info-data.ts @@ -1,11 +1,9 @@ import type { CompiledAgentManifest, CompiledSubagentNode } from "#compiler/manifest.js"; +import type { RuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js"; import { - createBundledRuntimeCompiledArtifactsSource, - createDiskRuntimeCompiledArtifactsSource, - type RuntimeCompiledArtifactsSource, -} from "#runtime/compiled-artifacts-source.js"; -import { readDevelopmentRuntimeArtifactsSnapshotRoot } from "#internal/nitro/dev-runtime-artifacts.js"; -import { readBundledCompiledArtifacts } from "#runtime/loaders/bundled-artifacts.js"; + type NitroArtifactsConfig, + resolveNitroCompiledArtifactsSource, +} from "#internal/nitro/routes/runtime-artifacts.js"; import { loadCompiledManifest } from "#runtime/loaders/manifest.js"; import { loadCompiledModuleMap } from "#runtime/loaders/module-map.js"; import { resolveAgent } from "#runtime/resolve-agent.js"; @@ -67,28 +65,9 @@ export async function loadAgentInfoManifestData(input: { * `GET /eve/v1/info` handler. */ export function resolveAgentInfoCompiledArtifactsSource( - input: { - readonly appRoot?: string; - readonly dev?: boolean; - readonly devRuntimeArtifactsPointerPath?: string; - } = {}, + input: NitroArtifactsConfig, ): RuntimeCompiledArtifactsSource { - if (input.dev === true && input.appRoot !== undefined) { - return createDiskRuntimeCompiledArtifactsSource( - readDevelopmentRuntimeArtifactsSnapshotRoot(input.devRuntimeArtifactsPointerPath) ?? - input.appRoot, - ); - } - - if (readBundledCompiledArtifacts() !== null) { - return createBundledRuntimeCompiledArtifactsSource(); - } - - if (input.appRoot !== undefined) { - return createDiskRuntimeCompiledArtifactsSource(input.appRoot); - } - - throw new Error("eve agent info runtime data requires bundled artifacts or an app root."); + return resolveNitroCompiledArtifactsSource(input); } async function loadAgentInfoDataFromArtifacts( diff --git a/packages/eve/src/internal/nitro/routes/channel-dispatch.test.ts b/packages/eve/src/internal/nitro/routes/channel-dispatch.test.ts index 06cdaf8e3..92782b9c2 100644 --- a/packages/eve/src/internal/nitro/routes/channel-dispatch.test.ts +++ b/packages/eve/src/internal/nitro/routes/channel-dispatch.test.ts @@ -25,6 +25,12 @@ vi.mock("#internal/vercel/project-link.js", () => ({ const mockedResolveNitroChannelRuntimeBundle = vi.mocked(resolveNitroChannelRuntimeBundle); const mockedReadVercelProjectLink = vi.mocked(readVercelProjectLink); const runtime = {} as Runtime; +const DEVELOPMENT_ARTIFACTS_CONFIG = { + appRoot: "/app/agent", + devRuntimeArtifactsPointerPath: "/app/agent/.eve/dev-runtime/current.json", + kind: "development", + moduleMapLoaderPath: "/eve/src/internal/authored-module-map-loader.ts", +} as const; function createDeferred() { let resolve!: (value: T | PromiseLike) => void; @@ -88,12 +94,12 @@ describe("dispatchChannelRequest", () => { const response = await dispatchChannelRequest( createEvent({ waitUntil: vi.fn() }), "POST /eve/v1/session", - { appRoot: "/app/agent", dev: true }, + DEVELOPMENT_ARTIFACTS_CONFIG, ); const nextResponse = await dispatchChannelRequest( createEvent({ waitUntil: vi.fn() }), "POST /eve/v1/session", - { appRoot: "/app/agent", dev: true }, + DEVELOPMENT_ARTIFACTS_CONFIG, ); expect(response.status).toBe(200); diff --git a/packages/eve/src/internal/nitro/routes/channel-dispatch.ts b/packages/eve/src/internal/nitro/routes/channel-dispatch.ts index a7dad7945..a01e2b290 100644 --- a/packages/eve/src/internal/nitro/routes/channel-dispatch.ts +++ b/packages/eve/src/internal/nitro/routes/channel-dispatch.ts @@ -142,8 +142,7 @@ async function withDevelopmentVercelOidcContext( request: Request, callback: () => Promise, ): Promise { - const appRoot = config.appRoot; - if (config.dev !== true || appRoot === undefined) { + if (config.kind !== "development") { return await callback(); } @@ -151,7 +150,7 @@ async function withDevelopmentVercelOidcContext( { request, resolveCurrentProject: async () => { - const link = await readVercelProjectLink(appRoot); + const link = await readVercelProjectLink(config.appRoot); return link === undefined ? undefined : { environment: "development", projectId: link.projectId }; diff --git a/packages/eve/src/internal/nitro/routes/info.test.ts b/packages/eve/src/internal/nitro/routes/info.test.ts index 14214e2e7..390a625be 100644 --- a/packages/eve/src/internal/nitro/routes/info.test.ts +++ b/packages/eve/src/internal/nitro/routes/info.test.ts @@ -34,9 +34,8 @@ vi.mock("#internal/nitro/routes/agent-info/load-agent-info-data.js", () => ({ const ROUTE_INPUT = { appRoot: "/tmp/app", - dev: true, devRuntimeArtifactsPointerPath: "/tmp/app/.eve/dev-runtime/current.json", - mode: "development", + kind: "development", moduleMapLoaderPath: "/tmp/eve/src/internal/authored-module-map-loader.ts", } as const; diff --git a/packages/eve/src/internal/nitro/routes/info.ts b/packages/eve/src/internal/nitro/routes/info.ts index 2f93ec9ed..b657fcc12 100644 --- a/packages/eve/src/internal/nitro/routes/info.ts +++ b/packages/eve/src/internal/nitro/routes/info.ts @@ -8,19 +8,13 @@ import type { GatewayCredentialPresence } from "#internal/resolve-model-endpoint import type { NitroArtifactsConfig } from "#internal/nitro/routes/runtime-artifacts.js"; import type { ModelRouting } from "#shared/agent-definition.js"; -type AgentInfoRouteMode = "development" | "production"; - -export interface AgentInfoRouteInput extends NitroArtifactsConfig { - readonly mode?: AgentInfoRouteMode; -} - -async function createAgentInfoPayload(input: AgentInfoRouteInput) { +async function createAgentInfoPayload(input: NitroArtifactsConfig) { const data = await loadAgentInfoManifestData({ compiledArtifactsSource: resolveAgentInfoCompiledArtifactsSource(input), }); return buildAgentInfoResponseFromManifest(data, { - mode: input.mode ?? (input.dev === false ? "production" : "development"), + mode: input.kind, gatewayCredentials: await resolveGatewayCredentialPresence(data.manifest.config.model.routing), }); } @@ -54,7 +48,7 @@ async function resolveGatewayCredentialPresence( /** * Builds the package-owned JSON inspection response for the current agent. */ -export async function handleAgentInfoRequest(input: AgentInfoRouteInput): Promise { +export async function handleAgentInfoRequest(input: NitroArtifactsConfig): Promise { return new Response(JSON.stringify(await createAgentInfoPayload(input)), { headers: { "cache-control": "no-store", diff --git a/packages/eve/src/internal/nitro/routes/runtime-artifacts.test.ts b/packages/eve/src/internal/nitro/routes/runtime-artifacts.test.ts index 1d7a9d033..ba594dea4 100644 --- a/packages/eve/src/internal/nitro/routes/runtime-artifacts.test.ts +++ b/packages/eve/src/internal/nitro/routes/runtime-artifacts.test.ts @@ -71,7 +71,8 @@ describe("resolveNitroCompiledArtifactsSource", () => { expect( resolveNitroCompiledArtifactsSource({ appRoot: "/tmp/dev-app", - dev: true, + devRuntimeArtifactsPointerPath: "/tmp/dev-app/.eve/dev-runtime/current.json", + kind: "development", moduleMapLoaderPath, }), ).toMatchObject({ @@ -83,29 +84,29 @@ describe("resolveNitroCompiledArtifactsSource", () => { }); }); - it("requires the authored-source loader path in development mode", async () => { - await withScopedRuntimeSession(() => { - expect(() => - resolveNitroCompiledArtifactsSource({ - appRoot: "/tmp/dev-app", - dev: true, - }), - ).toThrow('require "moduleMapLoaderPath"'); - }); - }); - it("uses bundled artifacts outside development mode when they exist", async () => { await withScopedRuntimeSession(() => { installEmptyBundledArtifacts(); expect( resolveNitroCompiledArtifactsSource({ - appRoot: "/tmp/prod-app", - dev: false, + kind: "production", }), ).toEqual({ kind: "bundled", }); }); }); + + it("does not fall back to the authored build path in production", async () => { + await withScopedRuntimeSession(() => { + const productionConfig = { + appRoot: "/tmp/build-machine-app", + kind: "production" as const, + }; + expect(() => resolveNitroCompiledArtifactsSource(productionConfig)).toThrow( + "requires bundled artifacts", + ); + }); + }); }); diff --git a/packages/eve/src/internal/nitro/routes/runtime-artifacts.ts b/packages/eve/src/internal/nitro/routes/runtime-artifacts.ts index 7b41e1afb..a4e65679f 100644 --- a/packages/eve/src/internal/nitro/routes/runtime-artifacts.ts +++ b/packages/eve/src/internal/nitro/routes/runtime-artifacts.ts @@ -11,13 +11,19 @@ import { readDevelopmentRuntimeArtifactsSnapshotRoot } from "#internal/nitro/dev * package-owned Nitro routes. Passed explicitly from virtual handlers * rather than read from a global runtime configuration store. */ -export interface NitroArtifactsConfig { - readonly appRoot?: string; - readonly dev?: boolean; - readonly devRuntimeArtifactsPointerPath?: string; - readonly moduleMapLoaderPath?: string; +export interface DevelopmentNitroArtifactsConfig { + readonly appRoot: string; + readonly devRuntimeArtifactsPointerPath: string; + readonly kind: "development"; + readonly moduleMapLoaderPath: string; } +export interface ProductionNitroArtifactsConfig { + readonly kind: "production"; +} + +export type NitroArtifactsConfig = DevelopmentNitroArtifactsConfig | ProductionNitroArtifactsConfig; + /** * Resolves the compiled-artifact source available to package-owned Nitro * routes. @@ -25,21 +31,14 @@ export interface NitroArtifactsConfig { export function resolveNitroCompiledArtifactsSource( config: NitroArtifactsConfig, ): RuntimeCompiledArtifactsSource { - const { appRoot, dev: isDevelopment } = config; - - if (isDevelopment && appRoot !== undefined) { - if (config.moduleMapLoaderPath === undefined) { - throw new Error( - 'eve Nitro development routes require "moduleMapLoaderPath" in the artifacts config.', - ); - } - + if (config.kind === "development") { const runtimeAppRoot = - readDevelopmentRuntimeArtifactsSnapshotRoot(config.devRuntimeArtifactsPointerPath) ?? appRoot; + readDevelopmentRuntimeArtifactsSnapshotRoot(config.devRuntimeArtifactsPointerPath) ?? + config.appRoot; return createDiskRuntimeCompiledArtifactsSource(runtimeAppRoot, { moduleMapLoaderPath: config.moduleMapLoaderPath, - sandboxAppRoot: appRoot, + sandboxAppRoot: config.appRoot, }); } @@ -47,11 +46,5 @@ export function resolveNitroCompiledArtifactsSource( return createBundledRuntimeCompiledArtifactsSource(); } - if (appRoot !== undefined) { - return createDiskRuntimeCompiledArtifactsSource(appRoot); - } - - throw new Error( - "eve Nitro route requires bundled artifacts or an eve Nitro runtime configuration app root.", - ); + throw new Error("eve Nitro production requires bundled artifacts."); } diff --git a/packages/eve/src/internal/workflow-bundle/build-queue.ts b/packages/eve/src/internal/workflow-bundle/build-queue.ts new file mode 100644 index 000000000..bd1594e8c --- /dev/null +++ b/packages/eve/src/internal/workflow-bundle/build-queue.ts @@ -0,0 +1,18 @@ +const activeBuilds = new Map>(); + +export async function runQueuedWorkflowBuild( + outputDirectory: string, + build: () => Promise, +): Promise { + const previousBuild = activeBuilds.get(outputDirectory) ?? Promise.resolve(); + const nextBuild = previousBuild.then(build, build); + activeBuilds.set(outputDirectory, nextBuild); + + try { + await nextBuild; + } finally { + if (activeBuilds.get(outputDirectory) === nextBuild) { + activeBuilds.delete(outputDirectory); + } + } +} diff --git a/packages/eve/src/internal/workflow-bundle/builder.scenario.test.ts b/packages/eve/src/internal/workflow-bundle/builder.scenario.test.ts index a4c81bb10..38abfeff2 100644 --- a/packages/eve/src/internal/workflow-bundle/builder.scenario.test.ts +++ b/packages/eve/src/internal/workflow-bundle/builder.scenario.test.ts @@ -117,13 +117,7 @@ describe("WorkflowBundleBuilder", () => { await Promise.all([ writeFile( compiledArtifactsBootstrapPath, - [ - "export async function __eveInstallCompiledArtifactsStep() {", - ' "use step";', - " return null;", - "}", - "", - ].join("\n"), + "globalThis.__eveCompiledArtifactsInstalled = true;\n", ), writeFile( stepFilePath, @@ -160,6 +154,9 @@ describe("WorkflowBundleBuilder", () => { expect(builder.workflowBundleCalls).toBe(1); expect(JSON.stringify(builder.capturedManifest)).toContain("ping"); expect(JSON.stringify(builder.capturedManifest)).toContain("step//"); + expect(JSON.stringify(builder.capturedManifest)).not.toContain( + "__eveInstallCompiledArtifactsStep", + ); } finally { await rm(tempRoot, { force: true, recursive: true }); } diff --git a/packages/eve/src/internal/workflow-bundle/builder.ts b/packages/eve/src/internal/workflow-bundle/builder.ts index e22636b02..35b07431c 100644 --- a/packages/eve/src/internal/workflow-bundle/builder.ts +++ b/packages/eve/src/internal/workflow-bundle/builder.ts @@ -11,6 +11,7 @@ import { writeEveVersionedCacheMetadata, } from "#internal/application/cache-metadata.js"; import { normalizeEsmImportSpecifier } from "#internal/application/import-specifier.js"; +import { runQueuedWorkflowBuild } from "#internal/workflow-bundle/build-queue.js"; import { atomicWriteFile, bundleFinalWorkflowOutput, @@ -50,10 +51,6 @@ import { } from "#internal/workflow-bundle/workflow-builders.js"; import { deriveEveWorkflowQueueNamespace } from "#internal/workflow/queue-namespace.js"; -// Serialize same-output builds so parallel Vercel surfaces never read -// `workflows.mjs` between the workflow wrapper write and literal rewrite pass. -const workflowBundleBuildLocks = new Map>(); - export class WorkflowBundleBuilder { readonly #agentName: string; readonly #compiledArtifactsBootstrapPath: string; @@ -86,13 +83,7 @@ export class WorkflowBundleBuilder { async build( options: { nitroStepOutfile?: string; nitroWorkflowOutfile?: string } = {}, ): Promise { - const previous = workflowBundleBuildLocks.get(this.#outDir) ?? Promise.resolve(); - const next = previous.then(() => this.#performBuild(options)); - workflowBundleBuildLocks.set( - this.#outDir, - next.catch(() => {}), - ); - await next; + await runQueuedWorkflowBuild(this.#outDir, async () => this.#performBuild(options)); } async #performBuild(options: { @@ -132,6 +123,7 @@ export class WorkflowBundleBuilder { outfile: stepsOutfile, preferAbsoluteFileImports: true, projectRoot: this.config.projectRoot ?? this.config.workingDir, + sideEffectFiles: [this.#compiledArtifactsBootstrapPath], workingDir: this.config.workingDir, }); const nitroStepOutfile = options.nitroStepOutfile; @@ -143,6 +135,7 @@ export class WorkflowBundleBuilder { outfile: nitroStepOutfile, preferAbsoluteFileImports: true, projectRoot: this.config.projectRoot ?? this.config.workingDir, + sideEffectFiles: [this.#compiledArtifactsBootstrapPath], workingDir: this.config.workingDir, }); } @@ -453,8 +446,7 @@ export class WorkflowBundleBuilder { } async #getBuildInputFiles(): Promise { - const inputFiles = await this.getInputFiles(); - return [...inputFiles, this.#compiledArtifactsBootstrapPath]; + return await this.getInputFiles(); } async #patchVercelFunctionConfig( diff --git a/packages/eve/src/internal/workflow-bundle/nitro-step-entry.scenario.test.ts b/packages/eve/src/internal/workflow-bundle/nitro-step-entry.scenario.test.ts index a921a1287..74fc83342 100644 --- a/packages/eve/src/internal/workflow-bundle/nitro-step-entry.scenario.test.ts +++ b/packages/eve/src/internal/workflow-bundle/nitro-step-entry.scenario.test.ts @@ -21,6 +21,7 @@ describe("writeNitroStepEntrypoint", () => { outfile, preferAbsoluteFileImports: true, projectRoot: "G:\\projects\\eve", + sideEffectFiles: [], workingDir: "G:\\projects\\eve", }); @@ -57,6 +58,7 @@ describe("writeNitroStepEntrypoint", () => { }, outfile, projectRoot: tempRoot, + sideEffectFiles: [], workingDir: tempRoot, }); @@ -72,6 +74,7 @@ describe("writeNitroStepEntrypoint", () => { }, outfile, projectRoot: tempRoot, + sideEffectFiles: [], workingDir: tempRoot, }); diff --git a/packages/eve/src/internal/workflow-bundle/nitro-step-entry.ts b/packages/eve/src/internal/workflow-bundle/nitro-step-entry.ts index 3f9a3ba03..432aca63c 100644 --- a/packages/eve/src/internal/workflow-bundle/nitro-step-entry.ts +++ b/packages/eve/src/internal/workflow-bundle/nitro-step-entry.ts @@ -22,6 +22,7 @@ export async function writeNitroStepEntrypoint(input: { readonly outfile: string; readonly preferAbsoluteFileImports?: boolean; readonly projectRoot: string; + readonly sideEffectFiles: readonly string[]; readonly workingDir: string; }): Promise { const stepFiles = [...input.discoveredEntries.discoveredSteps].sort(); @@ -49,6 +50,7 @@ export async function writeNitroStepEntrypoint(input: { outfileDirectory, preferAbsoluteFileImports: input.preferAbsoluteFileImports ?? false, serdeOnlyFiles, + sideEffectFiles: input.sideEffectFiles, stepFiles, }); const lines = [ @@ -74,10 +76,18 @@ function createEntrypointImportLines(input: { readonly outfileDirectory: string; readonly preferAbsoluteFileImports: boolean; readonly serdeOnlyFiles: readonly string[]; + readonly sideEffectFiles: readonly string[]; readonly stepFiles: readonly string[]; }): readonly string[] { const importedModules = [ input.builtinsImportSpecifier, + ...input.sideEffectFiles.map((filePath) => + createFileImportSpecifier({ + outfileDirectory: input.outfileDirectory, + preferAbsoluteFileImports: input.preferAbsoluteFileImports, + targetPath: filePath, + }), + ), ...input.stepFiles.map((filePath) => createFileImportSpecifier({ outfileDirectory: input.outfileDirectory, diff --git a/packages/eve/test/scenarios/compile-agent.scenario.test.ts b/packages/eve/test/scenarios/compile-agent.scenario.test.ts index 1b88a915a..b9f76d88f 100644 --- a/packages/eve/test/scenarios/compile-agent.scenario.test.ts +++ b/packages/eve/test/scenarios/compile-agent.scenario.test.ts @@ -129,7 +129,10 @@ describe("compiler artifacts", () => { const writtenArtifacts = await writeCompilerArtifacts({ appRoot, - artifactsRoot: join(appRoot, ".eve"), + artifactLocations: { + publishedRoot: join(appRoot, ".eve"), + writeRoot: join(appRoot, ".eve"), + }, diagnostics: [ createDiscoverWarningDiagnostic({ code: "discover/unsupported-directory", @@ -392,7 +395,10 @@ describe("compiler artifacts", () => { const writtenArtifacts = await writeCompilerArtifacts({ appRoot, - artifactsRoot: join(appRoot, ".eve"), + artifactLocations: { + publishedRoot: join(appRoot, ".eve"), + writeRoot: join(appRoot, ".eve"), + }, diagnostics: [], manifest, }); diff --git a/packages/eve/test/scenarios/load-agent-info-data.scenario.test.ts b/packages/eve/test/scenarios/load-agent-info-data.scenario.test.ts index 8db519833..9ba3c84e2 100644 --- a/packages/eve/test/scenarios/load-agent-info-data.scenario.test.ts +++ b/packages/eve/test/scenarios/load-agent-info-data.scenario.test.ts @@ -64,7 +64,7 @@ describe("loadAgentInfoData", () => { }); const agentInfoCompiledArtifactsSource = resolveAgentInfoCompiledArtifactsSource({ - appRoot, + kind: "production", }); expect(agentInfoCompiledArtifactsSource.kind).toBe("bundled"); const data = await loadAgentInfoData({ diff --git a/packages/eve/test/scenarios/production-build-isolation.scenario.test.ts b/packages/eve/test/scenarios/production-build-isolation.scenario.test.ts index 199a78af3..4911f43eb 100644 --- a/packages/eve/test/scenarios/production-build-isolation.scenario.test.ts +++ b/packages/eve/test/scenarios/production-build-isolation.scenario.test.ts @@ -4,6 +4,7 @@ import { watch, type FSWatcher } from "node:fs"; import { lstat, mkdir, readFile, readdir, readlink, realpath, writeFile } from "node:fs/promises"; import { join, relative } from "node:path"; import type { Readable } from "node:stream"; +import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; @@ -395,6 +396,44 @@ async function listBuildWorkspaces(appRoot: string): Promise } } +interface PublishedCompilationState { + readonly sourceGraphHash: string; +} + +async function readPublishedCompilationState(appRoot: string): Promise { + const metadataPath = join(appRoot, ".output", ".eve", "compile", "compile-metadata.json"); + const metadata = JSON.parse(await readFile(metadataPath, "utf8")) as { + compile: { moduleMap: { path: string } }; + discovery: { + diagnostics: { path: string }; + manifest: { path: string }; + sourceGraphHash: string; + }; + }; + const artifactPaths = [ + metadata.compile.moduleMap.path, + metadata.discovery.diagnostics.path, + metadata.discovery.manifest.path, + ]; + for (const artifactPath of artifactPaths) { + expect(artifactPath).not.toContain(".eve/builds/"); + expect((await lstat(join(appRoot, artifactPath))).isFile()).toBe(true); + } + + const moduleMapPath = join(appRoot, metadata.compile.moduleMap.path); + const moduleMap = (await import(`${pathToFileURL(moduleMapPath).href}?test=${Date.now()}`)) as { + moduleMap: { nodes: Record }; + }; + expect(Object.keys(moduleMap.moduleMap.nodes)).not.toHaveLength(0); + + const serverSource = await readFile(join(appRoot, ".output", "server", "index.mjs"), "utf8"); + expect(serverSource).not.toContain("__eveInstallCompiledArtifactsStep"); + + return { + sourceGraphHash: metadata.discovery.sourceGraphHash, + }; +} + describe("production build isolation", () => { it( "keeps a real dev server healthy while two real builds overlap in invocation-owned workspaces", @@ -497,4 +536,23 @@ describe("production build isolation", () => { }, SCENARIO_DEADLINE_MS, ); + + it( + "publishes relocatable compiler artifacts without workspace-derived step ids", + async () => { + const app = await scenarioApp(WEATHER_AGENT_DESCRIPTOR); + + const firstBuild = startEveProcess({ appRoot: app.appRoot, args: ["build"] }); + await expect(firstBuild.result).resolves.toMatchObject({ code: 0, signal: null }); + const firstCompilation = await readPublishedCompilationState(app.appRoot); + + const secondBuild = startEveProcess({ appRoot: app.appRoot, args: ["build"] }); + await expect(secondBuild.result).resolves.toMatchObject({ code: 0, signal: null }); + const secondCompilation = await readPublishedCompilationState(app.appRoot); + + expect(secondCompilation).toEqual(firstCompilation); + expect(await listBuildWorkspaces(app.appRoot)).toEqual([]); + }, + SCENARIO_DEADLINE_MS, + ); }); From a96a11ed68e768ef0fc76ea0cec8d59c9afe9e3b Mon Sep 17 00:00:00 2001 From: Casey Gowrie Date: Mon, 13 Jul 2026 16:29:37 -0400 Subject: [PATCH 06/12] fix(eve): harden production publication recovery Signed-off-by: Casey Gowrie --- packages/eve/src/compiler/compile-agent.ts | 7 + .../internal/application/build-workspace.ts | 6 + .../output-publication-artifacts.ts | 17 +- .../application/output-publication-journal.ts | 35 ++- .../application/output-publication-lock.ts | 124 +++++--- ...=> output-publication.integration.test.ts} | 292 +++++++++++++----- .../application/output-publication.ts | 38 ++- .../internal/nitro/host/artifacts-config.ts | 9 + .../internal/nitro/host/build-application.ts | 17 +- .../nitro/host/configure-nitro-routes.ts | 31 +- .../nitro/host/create-application-nitro.ts | 12 + .../nitro/host/prepare-application-host.ts | 12 + .../builder-support.integration.test.ts | 95 +----- .../workflow-bundle/builder-support.ts | 26 +- .../src/internal/workflow-bundle/builder.ts | 2 +- .../atomic-write-file.integration.test.ts | 95 ++++++ packages/eve/src/shared/atomic-write-file.ts | 16 + packages/eve/src/shared/guards.ts | 10 + packages/eve/src/shared/path-exists.ts | 23 ++ ...roduction-build-isolation.scenario.test.ts | 76 ++++- 20 files changed, 661 insertions(+), 282 deletions(-) rename packages/eve/src/internal/application/{output-publication.scenario.test.ts => output-publication.integration.test.ts} (58%) create mode 100644 packages/eve/src/shared/atomic-write-file.integration.test.ts create mode 100644 packages/eve/src/shared/atomic-write-file.ts create mode 100644 packages/eve/src/shared/path-exists.ts diff --git a/packages/eve/src/compiler/compile-agent.ts b/packages/eve/src/compiler/compile-agent.ts index 36cdcf6f9..41d97c8e7 100644 --- a/packages/eve/src/compiler/compile-agent.ts +++ b/packages/eve/src/compiler/compile-agent.ts @@ -84,6 +84,13 @@ export async function compileAgent(input: CompileAgentInput = {}): Promise` that + * one production build compiles, bundles, and stages into. Every path a + * build touches lives here until publication renames the staged output into + * place, so concurrent builds and a running dev server never interfere. + */ export async function createApplicationBuildWorkspace( appRoot: string, ): Promise { diff --git a/packages/eve/src/internal/application/output-publication-artifacts.ts b/packages/eve/src/internal/application/output-publication-artifacts.ts index 11608a71c..f1994b76d 100644 --- a/packages/eve/src/internal/application/output-publication-artifacts.ts +++ b/packages/eve/src/internal/application/output-publication-artifacts.ts @@ -2,6 +2,7 @@ import { mkdir, rename, rm, stat } from "node:fs/promises"; import { dirname } from "node:path"; import type { OutputPublicationJournal } from "#internal/application/output-publication-journal.js"; +import { pathExists } from "#shared/path-exists.js"; export async function assertStagedPublicationExists( journal: OutputPublicationJournal, @@ -96,19 +97,3 @@ async function rollbackArtifact(input: { await rename(input.finalPath, input.stagedPath); } } - -async function pathExists(path: string): Promise { - try { - await stat(path); - return true; - } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT")) { - return false; - } - throw error; - } -} - -function isNodeErrorWithCode(error: unknown, code: string): boolean { - return error instanceof Error && "code" in error && error.code === code; -} diff --git a/packages/eve/src/internal/application/output-publication-journal.ts b/packages/eve/src/internal/application/output-publication-journal.ts index 06669963b..6422acc4d 100644 --- a/packages/eve/src/internal/application/output-publication-journal.ts +++ b/packages/eve/src/internal/application/output-publication-journal.ts @@ -1,6 +1,9 @@ -import { readFile, rename, writeFile } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import { join } from "node:path"; +import { atomicWriteFile } from "#shared/atomic-write-file.js"; +import { isErrnoCode } from "#shared/guards.js"; + export type OutputPublicationPhase = "acquired" | "prepared" | "backed-up" | "committed"; export interface OutputPublicationJournal { @@ -12,6 +15,7 @@ export interface OutputPublicationJournal { readonly outputBackupPath: string; phase: OutputPublicationPhase; readonly pid: number; + readonly scratchDir: string; readonly stagedOutputDir: string; readonly stagedSummaryPath: string; readonly summaryBackupPath: string; @@ -27,7 +31,7 @@ export async function writeOutputPublicationJournal( lockPath: string, journal: OutputPublicationJournal, ): Promise { - await writeJournal(lockPath, journal, journal.token); + await writeJournal(lockPath, journal); } export async function readOutputPublicationJournal( @@ -41,7 +45,7 @@ export async function writeRecoveryLeaseJournal( leasePath: string, journal: RecoveryLeaseJournal, ): Promise { - await writeJournal(leasePath, journal, journal.token); + await writeJournal(leasePath, journal); } export async function readRecoveryLeaseJournal( @@ -51,18 +55,24 @@ export async function readRecoveryLeaseJournal( return isRecoveryLeaseJournal(value) ? value : undefined; } -async function writeJournal(path: string, journal: unknown, token: string): Promise { - const journalPath = join(path, "owner.json"); - const temporaryPath = `${journalPath}.${token}.tmp`; - await writeFile(temporaryPath, `${JSON.stringify(journal, null, 2)}\n`); - await rename(temporaryPath, journalPath); +/** + * Resolves the journal file inside a lock or lease directory. Exposed so + * the lock module can heartbeat the file's mtime while a publication or + * recovery is in flight. + */ +export function resolveJournalFilePath(path: string): string { + return join(path, "owner.json"); +} + +async function writeJournal(path: string, journal: unknown): Promise { + await atomicWriteFile(resolveJournalFilePath(path), `${JSON.stringify(journal, null, 2)}\n`); } async function readJournal(path: string): Promise { try { - return JSON.parse(await readFile(join(path, "owner.json"), "utf8")) as unknown; + return JSON.parse(await readFile(resolveJournalFilePath(path), "utf8")) as unknown; } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT") || error instanceof SyntaxError) { + if (isErrnoCode(error, "ENOENT") || error instanceof SyntaxError) { return undefined; } throw error; @@ -83,6 +93,7 @@ function isOutputPublicationJournal(value: unknown): value is OutputPublicationJ typeof journal.outputBackupPath === "string" && ["acquired", "prepared", "backed-up", "committed"].includes(journal.phase ?? "") && typeof journal.pid === "number" && + typeof journal.scratchDir === "string" && typeof journal.stagedOutputDir === "string" && typeof journal.stagedSummaryPath === "string" && typeof journal.summaryBackupPath === "string" && @@ -97,7 +108,3 @@ function isRecoveryLeaseJournal(value: unknown): value is RecoveryLeaseJournal { const journal = value as Partial; return typeof journal.pid === "number" && typeof journal.token === "string"; } - -function isNodeErrorWithCode(error: unknown, code: string): boolean { - return error instanceof Error && "code" in error && error.code === code; -} diff --git a/packages/eve/src/internal/application/output-publication-lock.ts b/packages/eve/src/internal/application/output-publication-lock.ts index 6c10182c3..f703266f4 100644 --- a/packages/eve/src/internal/application/output-publication-lock.ts +++ b/packages/eve/src/internal/application/output-publication-lock.ts @@ -1,6 +1,6 @@ import { watch } from "node:fs"; -import { mkdir, readdir, rename, rm, stat } from "node:fs/promises"; -import { basename, dirname, join, resolve } from "node:path"; +import { mkdir, readdir, rename, rm, stat, utimes } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { removeOutputPublicationBackups, @@ -9,14 +9,19 @@ import { import { readOutputPublicationJournal, readRecoveryLeaseJournal, + resolveJournalFilePath, type OutputPublicationJournal, type RecoveryLeaseJournal, writeOutputPublicationJournal, writeRecoveryLeaseJournal, } from "#internal/application/output-publication-journal.js"; +import { isErrnoCode } from "#shared/guards.js"; +import { pathExists } from "#shared/path-exists.js"; const PUBLICATION_LOCK_TIMEOUT_MS = 60_000; const INCOMPLETE_LOCK_STALE_MS = 5_000; +const PUBLICATION_JOURNAL_HEARTBEAT_MS = 1_000; +const ACTIVE_JOURNAL_STALE_MS = 15_000; interface RecoveryLease { complete(): Promise; @@ -27,6 +32,24 @@ export function resolveOutputPublicationLockPath(appRoot: string): string { return join(resolve(appRoot), ".eve", "locks", "output-publication.lock"); } +/** + * Keeps a held lock's (or lease's) journal mtime fresh while its owner works. + * Liveness cannot rest on `process.kill(pid, 0)` alone — journals survive + * reboots, so a recycled pid (or an unrelated process answering `EPERM`) + * would make a dead owner look alive forever. Returns a stop function. + */ +export function startPublicationJournalHeartbeat(journalDirectoryPath: string): () => void { + const journalFilePath = resolveJournalFilePath(journalDirectoryPath); + const heartbeat = setInterval(() => { + const now = new Date(); + void utimes(journalFilePath, now, now).catch(() => undefined); + }, PUBLICATION_JOURNAL_HEARTBEAT_MS); + heartbeat.unref(); + return () => { + clearInterval(heartbeat); + }; +} + export async function acquireOutputPublicationLock( lockPath: string, journal: OutputPublicationJournal, @@ -68,7 +91,7 @@ export async function acquireOutputPublicationLock( try { await rename(lockPath, releasedPath); } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT")) { + if (isErrnoCode(error, "ENOENT")) { return; } throw error; @@ -76,7 +99,7 @@ export async function acquireOutputPublicationLock( await rm(releasedPath, { force: true, recursive: true }); }; } catch (error) { - if (!isNodeErrorWithCode(error, "EEXIST")) { + if (!isErrnoCode(error, "EEXIST")) { throw error; } } @@ -99,13 +122,15 @@ async function recoverStalePublication( return false; } + const stopLeaseHeartbeat = startPublicationJournalHeartbeat(join(recoveryPath, "lease")); let preserveRecovery = false; try { const existingJournal = await readOutputPublicationJournal(lockPath); if ( existingJournal !== undefined && existingJournal.liveness === "active" && - isProcessAlive(existingJournal.pid) + isProcessAlive(existingJournal.pid) && + !(await isJournalStale(lockPath)) ) { return false; } @@ -119,7 +144,7 @@ async function recoverStalePublication( await rename(lockPath, recoveringJournalPath); preserveRecovery = true; } catch (error) { - if (!isNodeErrorWithCode(error, "ENOENT")) { + if (!isErrnoCode(error, "ENOENT")) { throw error; } } @@ -133,11 +158,12 @@ async function recoverStalePublication( if (!entry.isDirectory() || !entry.name.startsWith("owner-")) { continue; } - await finishInterruptedPublication(join(recoveryPath, entry.name), recoveryJournal); + await finishInterruptedPublication(join(recoveryPath, entry.name)); } preserveRecovery = false; return true; } finally { + stopLeaseHeartbeat(); if (preserveRecovery) { await releaseRecoveryLease.release(); } else { @@ -173,7 +199,7 @@ async function acquireRecoveryLease( try { await rename(recoveryPath, releasedPath); } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT")) { + if (isErrnoCode(error, "ENOENT")) { return; } throw error; @@ -189,7 +215,7 @@ async function acquireRecoveryLease( try { await rename(leasePath, releasedPath); } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT")) { + if (isErrnoCode(error, "ENOENT")) { return; } throw error; @@ -198,13 +224,22 @@ async function acquireRecoveryLease( }, }; } catch (error) { - if (!isNodeErrorWithCode(error, "EEXIST")) { + // ENOENT: a concurrent recoverer completed and renamed the recovery + // directory away, so recovery is done — report contention, not failure. + if (isErrnoCode(error, "ENOENT")) { + return undefined; + } + if (!isErrnoCode(error, "EEXIST")) { throw error; } } const currentJournal = await readRecoveryLeaseJournal(leasePath); - if (currentJournal !== undefined && isProcessAlive(currentJournal.pid)) { + if ( + currentJournal !== undefined && + isProcessAlive(currentJournal.pid) && + !(await isJournalStale(leasePath)) + ) { return undefined; } if (currentJournal === undefined && !(await isPathStale(leasePath))) { @@ -215,7 +250,7 @@ async function acquireRecoveryLease( try { await rename(leasePath, staleLeasePath); } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT")) { + if (isErrnoCode(error, "ENOENT")) { continue; } throw error; @@ -224,38 +259,51 @@ async function acquireRecoveryLease( } } -async function finishInterruptedPublication( - journalPath: string, - recoveryJournal: OutputPublicationJournal, -): Promise { +/** + * Recovery is driven entirely by the stale journal's own recorded paths: the + * interrupted publication may have targeted a different output directory than + * the recovering build (`.vercel/output` vs `.output`), and refusing to touch + * it would wedge every later publication behind the retained recovery dir. + */ +async function finishInterruptedPublication(journalPath: string): Promise { const staleJournal = await readOutputPublicationJournal(journalPath); - if (staleJournal === undefined) { + if (staleJournal === undefined || !hasTokenDerivedBackupPaths(staleJournal)) { await rm(journalPath, { force: true, recursive: true }); return; } - assertMatchingPublicationTarget(staleJournal, recoveryJournal); if (staleJournal.phase === "committed") { await removeOutputPublicationBackups(staleJournal); } else { await rollbackOutputPublication(staleJournal); } + await removePublicationScratchDirectory(staleJournal); await rm(journalPath, { force: true, recursive: true }); } -function assertMatchingPublicationTarget( - staleJournal: OutputPublicationJournal, - recoveryJournal: OutputPublicationJournal, -): void { +/** + * Backup paths that do not match the token derivation mean the journal is + * corrupt or tampered with; discard it without renaming anything it names. + */ +function hasTokenDerivedBackupPaths(journal: OutputPublicationJournal): boolean { + return ( + journal.outputBackupPath === `${journal.finalOutputDir}.eve-backup-${journal.token}` && + journal.summaryBackupPath === `${journal.finalSummaryPath}.eve-backup-${journal.token}` + ); +} + +// The scratch directory is the failed build's preserved workspace; removing +// it after recovery is what keeps `.eve/builds` from accumulating orphans. +// The containment check stops a forged journal from deleting arbitrary paths. +async function removePublicationScratchDirectory(journal: OutputPublicationJournal): Promise { + const relativeStagedPath = relative(journal.scratchDir, journal.stagedOutputDir); if ( - staleJournal.finalOutputDir !== recoveryJournal.finalOutputDir || - staleJournal.finalSummaryPath !== recoveryJournal.finalSummaryPath || - staleJournal.outputBackupPath !== - `${staleJournal.finalOutputDir}.eve-backup-${staleJournal.token}` || - staleJournal.summaryBackupPath !== - `${staleJournal.finalSummaryPath}.eve-backup-${staleJournal.token}` + relativeStagedPath === "" || + relativeStagedPath.startsWith("..") || + isAbsolute(relativeStagedPath) ) { - throw new Error("Refusing to recover a build publication for a different output target."); + return; } + await rm(journal.scratchDir, { force: true, recursive: true }); } async function waitForPublicationLockChange(lockPath: string, deadline: number): Promise { @@ -336,20 +384,20 @@ async function isPathStale(path: string): Promise { try { return Date.now() - (await stat(path)).mtimeMs >= INCOMPLETE_LOCK_STALE_MS; } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT")) { + if (isErrnoCode(error, "ENOENT")) { return true; } throw error; } } -async function pathExists(path: string): Promise { +async function isJournalStale(journalDirectoryPath: string): Promise { try { - await stat(path); - return true; + const journalStats = await stat(resolveJournalFilePath(journalDirectoryPath)); + return Date.now() - journalStats.mtimeMs >= ACTIVE_JOURNAL_STALE_MS; } catch (error) { - if (isNodeErrorWithCode(error, "ENOENT")) { - return false; + if (isErrnoCode(error, "ENOENT")) { + return true; } throw error; } @@ -360,10 +408,6 @@ function isProcessAlive(pid: number): boolean { process.kill(pid, 0); return true; } catch (error) { - return !isNodeErrorWithCode(error, "ESRCH"); + return !isErrnoCode(error, "ESRCH"); } } - -function isNodeErrorWithCode(error: unknown, code: string): boolean { - return error instanceof Error && "code" in error && error.code === code; -} diff --git a/packages/eve/src/internal/application/output-publication.scenario.test.ts b/packages/eve/src/internal/application/output-publication.integration.test.ts similarity index 58% rename from packages/eve/src/internal/application/output-publication.scenario.test.ts rename to packages/eve/src/internal/application/output-publication.integration.test.ts index bb648fb47..e834eed14 100644 --- a/packages/eve/src/internal/application/output-publication.scenario.test.ts +++ b/packages/eve/src/internal/application/output-publication.integration.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readdir, readFile, rename, stat, utimes, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -12,6 +12,21 @@ import { useTemporaryDirectories } from "#internal/testing/use-temporary-app-roo const createScratchDirectory = useTemporaryDirectories(); +interface StagedBuild { + readonly scratchDir: string; + readonly stagedOutputDir: string; + readonly stagedSummaryPath: string; +} + +function stagedBuild(appRoot: string, buildName: string): StagedBuild { + const scratchDir = join(appRoot, ".eve", "builds", buildName); + return { + scratchDir, + stagedOutputDir: join(scratchDir, "output"), + stagedSummaryPath: join(scratchDir, "summary.json"), + }; +} + async function writePublication(input: { readonly outputDir: string; readonly outputMarker: string; @@ -40,12 +55,16 @@ async function expectPublication(input: { await expect(readFile(input.summaryPath, "utf8")).resolves.toBe(`${input.summaryMarker}\n`); } +async function expectMissing(path: string): Promise { + await expect(stat(path)).rejects.toMatchObject({ code: "ENOENT" }); +} + async function interruptPublicationAfterBackup(input: { readonly appRoot: string; readonly finalOutputDir: string; readonly finalSummaryPath: string; - readonly stagedOutputDir: string; - readonly stagedSummaryPath: string; + readonly interrupted: StagedBuild; + readonly pid?: number; }): Promise { const token = "interrupted-owner"; const outputBackupPath = `${input.finalOutputDir}.eve-backup-${token}`; @@ -57,10 +76,10 @@ async function interruptPublicationAfterBackup(input: { summaryPath: input.finalSummaryPath, }); await writePublication({ - outputDir: input.stagedOutputDir, + outputDir: input.interrupted.stagedOutputDir, outputMarker: "interrupted", summaryMarker: "interrupted", - summaryPath: input.stagedSummaryPath, + summaryPath: input.interrupted.stagedSummaryPath, }); await Promise.all([ rename(input.finalOutputDir, outputBackupPath), @@ -78,9 +97,10 @@ async function interruptPublicationAfterBackup(input: { liveness: "active", outputBackupPath, phase: "backed-up", - pid: 2_147_483_647, - stagedOutputDir: input.stagedOutputDir, - stagedSummaryPath: input.stagedSummaryPath, + pid: input.pid ?? 2_147_483_647, + scratchDir: input.interrupted.scratchDir, + stagedOutputDir: input.interrupted.stagedOutputDir, + stagedSummaryPath: input.interrupted.stagedSummaryPath, summaryBackupPath, token, })}\n`, @@ -92,8 +112,7 @@ describe("build output publication", () => { const appRoot = await createScratchDirectory("eve-output-publication-"); const finalOutputDir = join(appRoot, ".output"); const finalSummaryPath = join(appRoot, ".eve", "agent-summary.json"); - const stagedOutputDir = join(appRoot, ".eve", "builds", "next", "output"); - const stagedSummaryPath = join(appRoot, ".eve", "builds", "next", "summary.json"); + const next = stagedBuild(appRoot, "next"); await writePublication({ outputDir: finalOutputDir, outputMarker: "previous", @@ -101,18 +120,17 @@ describe("build output publication", () => { summaryPath: finalSummaryPath, }); await writePublication({ - outputDir: stagedOutputDir, + outputDir: next.stagedOutputDir, outputMarker: "next", summaryMarker: "next", - summaryPath: stagedSummaryPath, + summaryPath: next.stagedSummaryPath, }); await publishApplicationBuildArtifacts({ appRoot, finalOutputDir, finalSummaryPath, - stagedOutputDir, - stagedSummaryPath, + ...next, }); await expectPublication({ @@ -127,8 +145,7 @@ describe("build output publication", () => { const appRoot = await createScratchDirectory("eve-output-publication-rollback-"); const finalOutputDir = join(appRoot, ".output"); const finalSummaryPath = join(appRoot, ".eve", "agent-summary.json"); - const stagedOutputDir = join(appRoot, ".eve", "builds", "failed", "output"); - const stagedSummaryPath = join(appRoot, ".eve", "builds", "failed", "summary.json"); + const failed = stagedBuild(appRoot, "failed"); await writePublication({ outputDir: finalOutputDir, outputMarker: "last-good", @@ -136,10 +153,10 @@ describe("build output publication", () => { summaryPath: finalSummaryPath, }); await writePublication({ - outputDir: stagedOutputDir, + outputDir: failed.stagedOutputDir, outputMarker: "failed", summaryMarker: "failed", - summaryPath: stagedSummaryPath, + summaryPath: failed.stagedSummaryPath, }); await expect( @@ -148,8 +165,7 @@ describe("build output publication", () => { appRoot, finalOutputDir, finalSummaryPath, - stagedOutputDir, - stagedSummaryPath, + ...failed, }, { async afterBackup() {}, @@ -168,10 +184,70 @@ describe("build output publication", () => { summaryPath: finalSummaryPath, }); await expectPublication({ - outputDir: stagedOutputDir, + outputDir: failed.stagedOutputDir, outputMarker: "failed", summaryMarker: "failed", - summaryPath: stagedSummaryPath, + summaryPath: failed.stagedSummaryPath, + }); + }); + + it("rolls back when the committed journal record cannot be written", async () => { + const appRoot = await createScratchDirectory("eve-output-publication-uncommitted-"); + const finalOutputDir = join(appRoot, ".output"); + const finalSummaryPath = join(appRoot, ".eve", "agent-summary.json"); + const next = stagedBuild(appRoot, "next"); + await writePublication({ + outputDir: finalOutputDir, + outputMarker: "last-good", + summaryMarker: "last-good", + summaryPath: finalSummaryPath, + }); + await writePublication({ + outputDir: next.stagedOutputDir, + outputMarker: "next", + summaryMarker: "next", + summaryPath: next.stagedSummaryPath, + }); + const lockPath = resolveOutputPublicationLockPath(appRoot); + + try { + await expect( + publishApplicationBuildArtifactsWithObserver( + { + appRoot, + finalOutputDir, + finalSummaryPath, + ...next, + }, + { + async afterBackup() {}, + async afterOutputInstall() { + await chmod(lockPath, 0o500); + }, + async onContention() {}, + }, + ), + ).rejects.toThrow(); + } finally { + // The failed release may have renamed the read-only lock directory; + // restore permissions on whatever is left so teardown can remove it. + const locksDir = join(appRoot, ".eve", "locks"); + for (const entry of await readdir(locksDir)) { + await chmod(join(locksDir, entry), 0o700).catch(() => undefined); + } + } + + await expectPublication({ + outputDir: finalOutputDir, + outputMarker: "last-good", + summaryMarker: "last-good", + summaryPath: finalSummaryPath, + }); + await expectPublication({ + outputDir: next.stagedOutputDir, + outputMarker: "next", + summaryMarker: "next", + summaryPath: next.stagedSummaryPath, }); }); @@ -179,34 +255,31 @@ describe("build output publication", () => { const appRoot = await createScratchDirectory("eve-output-publication-lock-"); const finalOutputDir = join(appRoot, ".output"); const finalSummaryPath = join(appRoot, ".eve", "agent-summary.json"); - const firstOutputDir = join(appRoot, ".eve", "builds", "first", "output"); - const firstSummaryPath = join(appRoot, ".eve", "builds", "first", "summary.json"); - const secondOutputDir = join(appRoot, ".eve", "builds", "second", "output"); - const secondSummaryPath = join(appRoot, ".eve", "builds", "second", "summary.json"); + const first = stagedBuild(appRoot, "first"); + const second = stagedBuild(appRoot, "second"); await writePublication({ - outputDir: firstOutputDir, + outputDir: first.stagedOutputDir, outputMarker: "first", summaryMarker: "first", - summaryPath: firstSummaryPath, + summaryPath: first.stagedSummaryPath, }); await writePublication({ - outputDir: secondOutputDir, + outputDir: second.stagedOutputDir, outputMarker: "second", summaryMarker: "second", - summaryPath: secondSummaryPath, + summaryPath: second.stagedSummaryPath, }); const firstEntered = Promise.withResolvers(); const releaseFirst = Promise.withResolvers(); const secondObservedContention = Promise.withResolvers(); const entered: string[] = []; - const first = publishApplicationBuildArtifactsWithObserver( + const firstPublish = publishApplicationBuildArtifactsWithObserver( { appRoot, finalOutputDir, finalSummaryPath, - stagedOutputDir: firstOutputDir, - stagedSummaryPath: firstSummaryPath, + ...first, }, { async afterBackup() { @@ -219,13 +292,12 @@ describe("build output publication", () => { }, ); await firstEntered.promise; - const second = publishApplicationBuildArtifactsWithObserver( + const secondPublish = publishApplicationBuildArtifactsWithObserver( { appRoot, finalOutputDir, finalSummaryPath, - stagedOutputDir: secondOutputDir, - stagedSummaryPath: secondSummaryPath, + ...second, }, { async afterBackup() { @@ -241,7 +313,7 @@ describe("build output publication", () => { await secondObservedContention.promise; expect(entered).toEqual(["first"]); releaseFirst.resolve(); - await Promise.all([first, second]); + await Promise.all([firstPublish, secondPublish]); expect(entered).toEqual(["first", "second"]); await expectPublication({ @@ -256,22 +328,19 @@ describe("build output publication", () => { const appRoot = await createScratchDirectory("eve-output-publication-recovery-"); const finalOutputDir = join(appRoot, ".output"); const finalSummaryPath = join(appRoot, ".eve", "agent-summary.json"); - const interruptedOutputDir = join(appRoot, ".eve", "builds", "interrupted", "output"); - const interruptedSummaryPath = join(appRoot, ".eve", "builds", "interrupted", "summary.json"); - const nextOutputDir = join(appRoot, ".eve", "builds", "next", "output"); - const nextSummaryPath = join(appRoot, ".eve", "builds", "next", "summary.json"); + const interrupted = stagedBuild(appRoot, "interrupted"); + const next = stagedBuild(appRoot, "next"); await interruptPublicationAfterBackup({ appRoot, finalOutputDir, finalSummaryPath, - stagedOutputDir: interruptedOutputDir, - stagedSummaryPath: interruptedSummaryPath, + interrupted, }); await writePublication({ - outputDir: nextOutputDir, + outputDir: next.stagedOutputDir, outputMarker: "next", summaryMarker: "next", - summaryPath: nextSummaryPath, + summaryPath: next.stagedSummaryPath, }); await expect( publishApplicationBuildArtifactsWithObserver( @@ -279,8 +348,7 @@ describe("build output publication", () => { appRoot, finalOutputDir, finalSummaryPath, - stagedOutputDir: nextOutputDir, - stagedSummaryPath: nextSummaryPath, + ...next, }, { async afterBackup() { @@ -298,36 +366,116 @@ describe("build output publication", () => { summaryMarker: "last-good", summaryPath: finalSummaryPath, }); + await expectMissing(interrupted.scratchDir); + }); + + it("recovers an interrupted publication whose owner pid is alive but whose journal went stale", async () => { + const appRoot = await createScratchDirectory("eve-output-publication-stale-"); + const finalOutputDir = join(appRoot, ".output"); + const finalSummaryPath = join(appRoot, ".eve", "agent-summary.json"); + const interrupted = stagedBuild(appRoot, "interrupted"); + const next = stagedBuild(appRoot, "next"); + await interruptPublicationAfterBackup({ + appRoot, + finalOutputDir, + finalSummaryPath, + interrupted, + // The test's own live pid simulates pid reuse: the recorded owner is + // gone, but `process.kill(pid, 0)` still succeeds. + pid: process.pid, + }); + const journalFilePath = join(resolveOutputPublicationLockPath(appRoot), "owner.json"); + const staleTime = new Date(Date.now() - 60_000); + await utimes(journalFilePath, staleTime, staleTime); + await writePublication({ + outputDir: next.stagedOutputDir, + outputMarker: "next", + summaryMarker: "next", + summaryPath: next.stagedSummaryPath, + }); + + await publishApplicationBuildArtifacts({ + appRoot, + finalOutputDir, + finalSummaryPath, + ...next, + }); + + await expectPublication({ + outputDir: finalOutputDir, + outputMarker: "next", + summaryMarker: "next", + summaryPath: finalSummaryPath, + }); + }); + + it("recovers an interrupted publication that targeted a different output directory", async () => { + const appRoot = await createScratchDirectory("eve-output-publication-target-"); + const vercelOutputDir = join(appRoot, ".vercel", "output"); + const vercelSummaryPath = join(appRoot, ".vercel", "agent-summary.json"); + const finalOutputDir = join(appRoot, ".output"); + const finalSummaryPath = join(appRoot, ".eve", "agent-summary.json"); + const interrupted = stagedBuild(appRoot, "interrupted"); + const next = stagedBuild(appRoot, "next"); + await interruptPublicationAfterBackup({ + appRoot, + finalOutputDir: vercelOutputDir, + finalSummaryPath: vercelSummaryPath, + interrupted, + }); + await writePublication({ + outputDir: next.stagedOutputDir, + outputMarker: "next", + summaryMarker: "next", + summaryPath: next.stagedSummaryPath, + }); + + await publishApplicationBuildArtifacts({ + appRoot, + finalOutputDir, + finalSummaryPath, + ...next, + }); + + await expectPublication({ + outputDir: finalOutputDir, + outputMarker: "next", + summaryMarker: "next", + summaryPath: finalSummaryPath, + }); + await expectPublication({ + outputDir: vercelOutputDir, + outputMarker: "last-good", + summaryMarker: "last-good", + summaryPath: vercelSummaryPath, + }); + await expectMissing(interrupted.scratchDir); }); it("retains interrupted publication state when recovery itself fails", async () => { const appRoot = await createScratchDirectory("eve-output-publication-recovery-retry-"); const finalOutputDir = join(appRoot, ".output"); const finalSummaryPath = join(appRoot, ".eve", "agent-summary.json"); - const interruptedOutputDir = join(appRoot, ".eve", "builds", "interrupted", "output"); - const interruptedSummaryPath = join(appRoot, ".eve", "builds", "interrupted", "summary.json"); - const firstOutputDir = join(appRoot, ".eve", "builds", "first", "output"); - const firstSummaryPath = join(appRoot, ".eve", "builds", "first", "summary.json"); - const retryOutputDir = join(appRoot, ".eve", "builds", "retry", "output"); - const retrySummaryPath = join(appRoot, ".eve", "builds", "retry", "summary.json"); + const interrupted = stagedBuild(appRoot, "interrupted"); + const first = stagedBuild(appRoot, "first"); + const retry = stagedBuild(appRoot, "retry"); await interruptPublicationAfterBackup({ appRoot, finalOutputDir, finalSummaryPath, - stagedOutputDir: interruptedOutputDir, - stagedSummaryPath: interruptedSummaryPath, + interrupted, }); await writePublication({ - outputDir: firstOutputDir, + outputDir: first.stagedOutputDir, outputMarker: "first", summaryMarker: "first", - summaryPath: firstSummaryPath, + summaryPath: first.stagedSummaryPath, }); await writePublication({ - outputDir: retryOutputDir, + outputDir: retry.stagedOutputDir, outputMarker: "retry", summaryMarker: "retry", - summaryPath: retrySummaryPath, + summaryPath: retry.stagedSummaryPath, }); try { await chmod(appRoot, 0o500); @@ -336,8 +484,7 @@ describe("build output publication", () => { appRoot, finalOutputDir, finalSummaryPath, - stagedOutputDir: firstOutputDir, - stagedSummaryPath: firstSummaryPath, + ...first, }), ).rejects.toThrow(); } finally { @@ -350,8 +497,7 @@ describe("build output publication", () => { appRoot, finalOutputDir, finalSummaryPath, - stagedOutputDir: retryOutputDir, - stagedSummaryPath: retrySummaryPath, + ...retry, }, { async afterBackup() { @@ -375,8 +521,7 @@ describe("build output publication", () => { const appRoot = await createScratchDirectory("eve-output-publication-cleanup-"); const finalOutputDir = join(appRoot, ".output"); const finalSummaryPath = join(appRoot, ".eve", "agent-summary.json"); - const stagedOutputDir = join(appRoot, ".eve", "builds", "next", "output"); - const stagedSummaryPath = join(appRoot, ".eve", "builds", "next", "summary.json"); + const next = stagedBuild(appRoot, "next"); await writePublication({ outputDir: finalOutputDir, outputMarker: "last-good", @@ -384,10 +529,10 @@ describe("build output publication", () => { summaryPath: finalSummaryPath, }); await writePublication({ - outputDir: stagedOutputDir, + outputDir: next.stagedOutputDir, outputMarker: "next", summaryMarker: "next", - summaryPath: stagedSummaryPath, + summaryPath: next.stagedSummaryPath, }); try { @@ -397,8 +542,7 @@ describe("build output publication", () => { appRoot, finalOutputDir, finalSummaryPath, - stagedOutputDir, - stagedSummaryPath, + ...next, }, { async afterBackup() {}, @@ -423,20 +567,18 @@ describe("build output publication", () => { readFile(join(resolveOutputPublicationLockPath(appRoot), "owner.json"), "utf8"), ).resolves.toContain('"phase": "committed"'); - const recoveredOutputDir = join(appRoot, ".eve", "builds", "recovered", "output"); - const recoveredSummaryPath = join(appRoot, ".eve", "builds", "recovered", "summary.json"); + const recovered = stagedBuild(appRoot, "recovered"); await writePublication({ - outputDir: recoveredOutputDir, + outputDir: recovered.stagedOutputDir, outputMarker: "recovered", summaryMarker: "recovered", - summaryPath: recoveredSummaryPath, + summaryPath: recovered.stagedSummaryPath, }); await publishApplicationBuildArtifacts({ appRoot, finalOutputDir, finalSummaryPath, - stagedOutputDir: recoveredOutputDir, - stagedSummaryPath: recoveredSummaryPath, + ...recovered, }); await expectPublication({ diff --git a/packages/eve/src/internal/application/output-publication.ts b/packages/eve/src/internal/application/output-publication.ts index 055a2ac1b..d963743b2 100644 --- a/packages/eve/src/internal/application/output-publication.ts +++ b/packages/eve/src/internal/application/output-publication.ts @@ -14,6 +14,7 @@ import { writeOutputPublicationJournal } from "#internal/application/output-publ import { acquireOutputPublicationLock, resolveOutputPublicationLockPath, + startPublicationJournalHeartbeat, } from "#internal/application/output-publication-lock.js"; export { resolveOutputPublicationLockPath }; @@ -22,10 +23,24 @@ export interface OutputPublicationInput { readonly appRoot: string; readonly finalOutputDir: string; readonly finalSummaryPath: string; + /** Invocation-owned directory containing the staged paths; recovery removes it. */ + readonly scratchDir: string; readonly stagedOutputDir: string; readonly stagedSummaryPath: string; } +/** + * A publication failure that retained the lock journal on disk. The staged + * artifacts (and the scratch directory containing them) must outlive this + * invocation so a later build can finish the interrupted publication. + */ +export class RecoverablePublicationError extends AggregateError { + constructor(errors: readonly unknown[], message: string, options?: ErrorOptions) { + super(errors, message, options); + this.name = "RecoverablePublicationError"; + } +} + interface OutputPublicationObserver { afterBackup(): Promise; afterOutputInstall(): Promise; @@ -38,6 +53,13 @@ const DEFAULT_OBSERVER: OutputPublicationObserver = { async onContention() {}, }; +/** + * Atomically installs a build's staged output and summary as the app's + * published artifacts: backs up the previous publication, renames the staged + * paths into place, and rolls back to the backup on failure. A journaled, + * cross-process lock serializes publishers and lets the next build finish an + * interrupted publication. + */ export async function publishApplicationBuildArtifacts( input: OutputPublicationInput, ): Promise { @@ -53,7 +75,9 @@ export async function publishApplicationBuildArtifactsWithObserver( const lockPath = resolveOutputPublicationLockPath(input.appRoot); const release = await acquireOutputPublicationLock(lockPath, journal, observer.onContention); + const stopHeartbeat = startPublicationJournalHeartbeat(lockPath); + let committedJournalWritten = false; try { await prepareOutputPublication(journal); journal.phase = "prepared"; @@ -67,9 +91,14 @@ export async function publishApplicationBuildArtifactsWithObserver( await installOutputPublication(journal, observer.afterOutputInstall); journal.phase = "committed"; await writeOutputPublicationJournal(lockPath, journal); + committedJournalWritten = true; await removeOutputPublicationBackups(journal); } catch (error) { - if (journal.phase === "committed") { + // Only a durably recorded commit counts: if the journal write itself + // failed, the on-disk phase still says "backed-up" and a later recovery + // would roll the new output back out — so roll back now and report the + // publication as failed. + if (committedJournalWritten) { await throwRecoverablePublicationError({ errors: [error], journal, @@ -89,6 +118,8 @@ export async function publishApplicationBuildArtifactsWithObserver( } await release(); throw error; + } finally { + stopHeartbeat(); } await release(); @@ -107,6 +138,7 @@ function createOutputPublicationJournal(input: OutputPublicationInput): OutputPu outputBackupPath: `${finalOutputDir}.eve-backup-${token}`, phase: "acquired", pid: process.pid, + scratchDir: resolve(input.scratchDir), stagedOutputDir: resolve(input.stagedOutputDir), stagedSummaryPath: resolve(input.stagedSummaryPath), summaryBackupPath: `${finalSummaryPath}.eve-backup-${token}`, @@ -124,9 +156,9 @@ async function throwRecoverablePublicationError(input: { try { await writeOutputPublicationJournal(input.lockPath, input.journal); } catch (journalWriteError) { - throw new AggregateError([...input.errors, journalWriteError], input.message, { + throw new RecoverablePublicationError([...input.errors, journalWriteError], input.message, { cause: input.errors[0], }); } - throw new AggregateError(input.errors, input.message, { cause: input.errors[0] }); + throw new RecoverablePublicationError(input.errors, input.message, { cause: input.errors[0] }); } diff --git a/packages/eve/src/internal/nitro/host/artifacts-config.ts b/packages/eve/src/internal/nitro/host/artifacts-config.ts index da162ead3..9cc0b1f21 100644 --- a/packages/eve/src/internal/nitro/host/artifacts-config.ts +++ b/packages/eve/src/internal/nitro/host/artifacts-config.ts @@ -5,6 +5,11 @@ import type { ProductionNitroArtifactsConfig, } from "#internal/nitro/routes/runtime-artifacts.js"; +/** + * Runtime-artifacts wiring for the dev server: routes read compiled + * artifacts from the authored app root via the snapshot pointer so hot + * reload can swap them. + */ export function createDevelopmentNitroArtifactsConfig(input: { readonly appRoot: string; }): DevelopmentNitroArtifactsConfig { @@ -16,6 +21,10 @@ export function createDevelopmentNitroArtifactsConfig(input: { }; } +/** + * Runtime-artifacts wiring for built output: routes require the artifacts + * bundled into the server at build time and never touch the filesystem. + */ export function createProductionNitroArtifactsConfig(): ProductionNitroArtifactsConfig { return { kind: "production", diff --git a/packages/eve/src/internal/nitro/host/build-application.ts b/packages/eve/src/internal/nitro/host/build-application.ts index fa3a8d1a0..38cd7a711 100644 --- a/packages/eve/src/internal/nitro/host/build-application.ts +++ b/packages/eve/src/internal/nitro/host/build-application.ts @@ -14,7 +14,10 @@ import { removeApplicationBuildWorkspace, type ApplicationBuildWorkspace, } from "#internal/application/build-workspace.js"; -import { publishApplicationBuildArtifacts } from "#internal/application/output-publication.js"; +import { + publishApplicationBuildArtifacts, + RecoverablePublicationError, +} from "#internal/application/output-publication.js"; import { stageProductionCompilerArtifacts } from "#internal/application/production-compiler-artifacts.js"; import { WorkflowBundleBuilder } from "#internal/workflow-bundle/builder.js"; import { normalizeEveVercelFunctionOutput } from "#internal/workflow-bundle/vercel-workflow-output.js"; @@ -321,10 +324,19 @@ export async function buildApplication( const project = await resolveDiscoveryProject(rootDir); const workspace = await createApplicationBuildWorkspace(project.appRoot); + // A recoverable publication failure leaves the lock journal pointing at + // staged artifacts inside this workspace; the next build's recovery + // consumes and then removes it. Deleting it now would strand the journal. + let preserveWorkspaceForRecovery = false; try { return await buildApplicationInWorkspace(workspace, options); + } catch (error) { + preserveWorkspaceForRecovery = error instanceof RecoverablePublicationError; + throw error; } finally { - await removeApplicationBuildWorkspace(workspace); + if (!preserveWorkspaceForRecovery) { + await removeApplicationBuildWorkspace(workspace); + } } } @@ -424,6 +436,7 @@ async function publishCompletedApplicationBuild( appRoot: workspace.appRoot, finalOutputDir: workspace.publication.output.finalDir, finalSummaryPath: workspace.publication.summary.finalPath, + scratchDir: workspace.rootDir, stagedOutputDir: workspace.publication.output.stagedDir, stagedSummaryPath: workspace.publication.summary.stagedPath, }); 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 802f15286..da8381d8c 100644 --- a/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts +++ b/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts @@ -268,18 +268,6 @@ function addFrameworkVirtualHandler( ].join("\n"); } -function createSerializedWorkflowArtifactSync( - buildWorkflowArtifacts: () => Promise, -): () => Promise { - let previousBuild: Promise = Promise.resolve(); - - return async () => { - const nextBuild = previousBuild.then(buildWorkflowArtifacts, buildWorkflowArtifacts); - previousBuild = nextBuild; - await nextBuild; - }; -} - async function registerWorkflowArtifactBuildHook( nitro: Nitro, syncWorkflowArtifacts: () => Promise, @@ -375,6 +363,11 @@ async function registerWorkflowRoute( }); } +/** + * Wires eve's package-owned app, channel, workflow inspection, dev-control, + * and Workflow SDK endpoints into the watch-mode Nitro host, rebuilding + * workflow bundles on reload. + */ export async function configureDevelopmentNitroRoutes( nitro: Nitro, preparedHost: PreparedApplicationHost, @@ -388,12 +381,14 @@ export async function configureDevelopmentNitroRoutes( rootDir: resolvePackageRoot(), watch: true, }); - const syncWorkflowArtifacts = createSerializedWorkflowArtifactSync(async () => { + // Overlapping `build:before` and `dev:reload` syncs are serialized by + // `builder.build`'s per-output-directory queue. + const syncWorkflowArtifacts = async () => { await builder.build({ nitroStepOutfile: join(workflowBuildDirectory, "steps.mjs"), nitroWorkflowOutfile: join(workflowBuildDirectory, "workflows.mjs"), }); - }); + }; await registerWorkflowArtifactBuildHook(nitro, syncWorkflowArtifacts); nitro.hooks.hook("dev:reload", syncWorkflowArtifacts); @@ -411,6 +406,10 @@ export async function configureDevelopmentNitroRoutes( nitro.routing.sync(); } +/** + * Wires the subset of eve's package-owned endpoints that belong to the given + * build surface into a production Nitro host. + */ export async function configureProductionNitroRoutes( nitro: Nitro, preparedHost: PreparedApplicationHost, @@ -425,11 +424,11 @@ export async function configureProductionNitroRoutes( rootDir: resolvePackageRoot(), watch: false, }); - const syncWorkflowArtifacts = createSerializedWorkflowArtifactSync(async () => { + const syncWorkflowArtifacts = async () => { await builder.build({ nitroStepOutfile: join(resolveNitroWorkflowBuildDirectory(nitro), "steps.mjs"), }); - }); + }; await registerWorkflowArtifactBuildHook(nitro, syncWorkflowArtifacts); } diff --git a/packages/eve/src/internal/nitro/host/create-application-nitro.ts b/packages/eve/src/internal/nitro/host/create-application-nitro.ts index 129f0c10c..423653854 100644 --- a/packages/eve/src/internal/nitro/host/create-application-nitro.ts +++ b/packages/eve/src/internal/nitro/host/create-application-nitro.ts @@ -743,6 +743,11 @@ function externalizeDevelopmentWorkflowBundle( }); } +/** + * Creates the watch-mode Nitro host for `eve dev`: all route surfaces on one + * instance, live virtual modules, and hot-reload hooks wired to the authored + * source. + */ export async function createDevelopmentApplicationNitro( preparedHost: PreparedApplicationHost, ): Promise { @@ -794,6 +799,13 @@ interface ProductionApplicationNitroOptions { readonly surface: NitroBuildSurface; } +/** + * Creates a build-mode Nitro host for one production surface. `surface` + * narrows which route groups are registered ("all" for self-hosted output; + * "app"/"flow" for the separately bundled Vercel functions), and `buildDir`/ + * `outputDir` place all bundler state inside the invocation-owned build + * workspace. + */ export async function createProductionApplicationNitro( preparedHost: PreparedApplicationHost, options: ProductionApplicationNitroOptions, diff --git a/packages/eve/src/internal/nitro/host/prepare-application-host.ts b/packages/eve/src/internal/nitro/host/prepare-application-host.ts index 3f504f3c4..51d3842e0 100644 --- a/packages/eve/src/internal/nitro/host/prepare-application-host.ts +++ b/packages/eve/src/internal/nitro/host/prepare-application-host.ts @@ -26,6 +26,11 @@ import { } from "#internal/nitro/dev-runtime-artifacts.js"; import type { PreparedApplicationHost } from "#internal/nitro/host/types.js"; +/** + * Compiles one authored app in place and stages the package-owned artifacts + * the dev-server Nitro host needs, activating a fresh runtime-artifacts + * snapshot for hot reload. + */ export async function prepareDevelopmentApplicationHost( startPath: string, ): Promise { @@ -53,6 +58,13 @@ export async function prepareDevelopmentApplicationHost( return preparedHost; } +/** + * Compiles one authored app into an invocation-owned build workspace and + * stages the package-owned artifacts the production Nitro build needs. + * Compiler artifacts are written inside the workspace but their recorded + * locations point at the published output (`/.eve`), where + * publication later installs them. + */ export async function prepareProductionApplicationHost( workspace: ApplicationBuildWorkspace, ): Promise { diff --git a/packages/eve/src/internal/workflow-bundle/builder-support.integration.test.ts b/packages/eve/src/internal/workflow-bundle/builder-support.integration.test.ts index 36ec9ca75..6177dad9b 100644 --- a/packages/eve/src/internal/workflow-bundle/builder-support.integration.test.ts +++ b/packages/eve/src/internal/workflow-bundle/builder-support.integration.test.ts @@ -1,100 +1,11 @@ -import { mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { resolvePackageRoot, resolveWorkflowModulePath } from "#internal/application/package.js"; -import { atomicWriteFile, bundleFinalWorkflowOutput } from "./builder-support.js"; - -describe("atomicWriteFile", () => { - it("writes the requested contents to the target path", async () => { - const dir = await mkdtemp(join(tmpdir(), "eve-atomic-write-")); - const target = join(dir, "output.txt"); - - try { - await atomicWriteFile(target, "hello"); - const result = await readFile(target, "utf8"); - - expect(result).toBe("hello"); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - it("replaces an existing file without leaving a tmp artifact behind", async () => { - const dir = await mkdtemp(join(tmpdir(), "eve-atomic-write-")); - const target = join(dir, "output.txt"); - - try { - await atomicWriteFile(target, "first"); - await atomicWriteFile(target, "second"); - - expect(await readFile(target, "utf8")).toBe("second"); - - const entries = await readdir(dir); - const tmpLeftovers = entries.filter((name) => name.startsWith("output.txt.tmp-")); - expect(tmpLeftovers).toEqual([]); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - // Windows rejects rename-over-open-file when an active handle does not allow - // delete/rename sharing. That preserves the no-partial-visibility invariant, - // but it does not provide POSIX's progress guarantee under continuous reads. - it.skipIf(process.platform === "win32")( - "never exposes a partial file to a concurrent reader", - async () => { - const dir = await mkdtemp(join(tmpdir(), "eve-atomic-write-")); - const target = join(dir, "workflows.mjs"); - - const payloadA = `${"a".repeat(64 * 1024)}\nexport const POST = "A";\n`; - const payloadB = `${"b".repeat(64 * 1024)}\nexport const POST = "B";\n`; - const allowedLengths = new Set([payloadA.length, payloadB.length]); - - try { - await atomicWriteFile(target, payloadA); - - let stop = false; - const observedLengths = new Set(); - - const reader = (async () => { - while (!stop) { - try { - const contents = await readFile(target, "utf8"); - observedLengths.add(contents.length); - } catch (error) { - if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) { - throw error; - } - observedLengths.add(-1); - } - } - })(); - - try { - for (let i = 0; i < 50; i += 1) { - await atomicWriteFile(target, i % 2 === 0 ? payloadB : payloadA); - } - } finally { - stop = true; - await reader; - } - - for (const length of observedLengths) { - expect(allowedLengths.has(length)).toBe(true); - } - - const finalStat = await stat(target); - expect(allowedLengths.has(finalStat.size)).toBe(true); - expect(dirname(target)).toBe(dir); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }, - ); -}); +import { bundleFinalWorkflowOutput } from "./builder-support.js"; describe("bundleFinalWorkflowOutput", () => { it("writes the intermediate wrapper against the namespaced eve Workflow runtime facade", async () => { diff --git a/packages/eve/src/internal/workflow-bundle/builder-support.ts b/packages/eve/src/internal/workflow-bundle/builder-support.ts index 5f31eff64..fd268b88a 100644 --- a/packages/eve/src/internal/workflow-bundle/builder-support.ts +++ b/packages/eve/src/internal/workflow-bundle/builder-support.ts @@ -1,8 +1,10 @@ import { builtinModules } from "node:module"; import { existsSync } from "node:fs"; -import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir } from "node:fs/promises"; import { dirname, join, relative, resolve } from "node:path"; +import { atomicWriteFile } from "#shared/atomic-write-file.js"; + import { buildWithNitroRolldown, getSingleRolldownChunk, @@ -500,9 +502,7 @@ function resolveFirstExistingPath(paths: readonly string[]): { id: string } | un async function writeWorkflowBundleAtomically(outfile: string, source: string): Promise { await mkdir(dirname(outfile), { recursive: true }); - const tempPath = `${outfile}.${process.pid}.${Date.now()}.tmp`; - await writeFile(tempPath, source); - await rename(tempPath, outfile); + await atomicWriteFile(outfile, source); } function mergeWorkflowManifest(target: WorkflowManifest, source: WorkflowManifest): void { @@ -550,21 +550,3 @@ function createManifestRelativeFilepath(workingDir: string, absolutePath: string function isJavaScriptLikePath(path: string): boolean { return /\.(?:[cm]?[jt]sx?)$/.test(path); } - -/* - * Some generated workflow artifacts (notably `workflows.mjs`) are read by - * Nitro's Rolldown bundler concurrently with rebuilds during `eve dev`. A - * plain `writeFile` truncates the target first and streams bytes, so a - * reader can observe an empty or partial module mid-write and report - * spurious "missing export" errors. Writing to a sibling temp file and - * renaming relies on POSIX `rename` atomicity so readers always see - * either the old or the new contents. - */ -export async function atomicWriteFile( - targetPath: string, - contents: string | Buffer | Uint8Array, -): Promise { - const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now().toString(36)}`; - await writeFile(tmpPath, contents); - await rename(tmpPath, targetPath); -} diff --git a/packages/eve/src/internal/workflow-bundle/builder.ts b/packages/eve/src/internal/workflow-bundle/builder.ts index 35b07431c..3aac1c5e9 100644 --- a/packages/eve/src/internal/workflow-bundle/builder.ts +++ b/packages/eve/src/internal/workflow-bundle/builder.ts @@ -12,8 +12,8 @@ import { } from "#internal/application/cache-metadata.js"; import { normalizeEsmImportSpecifier } from "#internal/application/import-specifier.js"; import { runQueuedWorkflowBuild } from "#internal/workflow-bundle/build-queue.js"; +import { atomicWriteFile } from "#shared/atomic-write-file.js"; import { - atomicWriteFile, bundleFinalWorkflowOutput, collectWorkflowInputFiles, convertClassesManifest, diff --git a/packages/eve/src/shared/atomic-write-file.integration.test.ts b/packages/eve/src/shared/atomic-write-file.integration.test.ts new file mode 100644 index 000000000..6b747987d --- /dev/null +++ b/packages/eve/src/shared/atomic-write-file.integration.test.ts @@ -0,0 +1,95 @@ +import { mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { atomicWriteFile } from "#shared/atomic-write-file.js"; + +describe("atomicWriteFile", () => { + it("writes the requested contents to the target path", async () => { + const dir = await mkdtemp(join(tmpdir(), "eve-atomic-write-")); + const target = join(dir, "output.txt"); + + try { + await atomicWriteFile(target, "hello"); + const result = await readFile(target, "utf8"); + + expect(result).toBe("hello"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("replaces an existing file without leaving a tmp artifact behind", async () => { + const dir = await mkdtemp(join(tmpdir(), "eve-atomic-write-")); + const target = join(dir, "output.txt"); + + try { + await atomicWriteFile(target, "first"); + await atomicWriteFile(target, "second"); + + expect(await readFile(target, "utf8")).toBe("second"); + + const entries = await readdir(dir); + const tmpLeftovers = entries.filter((name) => name.startsWith("output.txt.tmp-")); + expect(tmpLeftovers).toEqual([]); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + // Windows rejects rename-over-open-file when an active handle does not allow + // delete/rename sharing. That preserves the no-partial-visibility invariant, + // but it does not provide POSIX's progress guarantee under continuous reads. + it.skipIf(process.platform === "win32")( + "never exposes a partial file to a concurrent reader", + async () => { + const dir = await mkdtemp(join(tmpdir(), "eve-atomic-write-")); + const target = join(dir, "workflows.mjs"); + + const payloadA = `${"a".repeat(64 * 1024)}\nexport const POST = "A";\n`; + const payloadB = `${"b".repeat(64 * 1024)}\nexport const POST = "B";\n`; + const allowedLengths = new Set([payloadA.length, payloadB.length]); + + try { + await atomicWriteFile(target, payloadA); + + let stop = false; + const observedLengths = new Set(); + + const reader = (async () => { + while (!stop) { + try { + const contents = await readFile(target, "utf8"); + observedLengths.add(contents.length); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) { + throw error; + } + observedLengths.add(-1); + } + } + })(); + + try { + for (let i = 0; i < 50; i += 1) { + await atomicWriteFile(target, i % 2 === 0 ? payloadB : payloadA); + } + } finally { + stop = true; + await reader; + } + + for (const length of observedLengths) { + expect(allowedLengths.has(length)).toBe(true); + } + + const finalStat = await stat(target); + expect(allowedLengths.has(finalStat.size)).toBe(true); + expect(dirname(target)).toBe(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/packages/eve/src/shared/atomic-write-file.ts b/packages/eve/src/shared/atomic-write-file.ts new file mode 100644 index 000000000..a1aa901ad --- /dev/null +++ b/packages/eve/src/shared/atomic-write-file.ts @@ -0,0 +1,16 @@ +import { rename, writeFile } from "node:fs/promises"; + +/** + * Writes `contents` so concurrent readers always observe either the old or + * the new file, never a truncated intermediate: a plain `writeFile` truncates + * first and streams bytes, while a sibling temp file plus POSIX-atomic + * `rename` rules that window out. + */ +export async function atomicWriteFile( + targetPath: string, + contents: string | Buffer | Uint8Array, +): Promise { + const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now().toString(36)}`; + await writeFile(tmpPath, contents); + await rename(tmpPath, targetPath); +} diff --git a/packages/eve/src/shared/guards.ts b/packages/eve/src/shared/guards.ts index 76bb99777..f779c0c09 100644 --- a/packages/eve/src/shared/guards.ts +++ b/packages/eve/src/shared/guards.ts @@ -45,6 +45,16 @@ export function isThenable(value: unknown): value is PromiseLike { return isObject(value) && typeof value.then === "function"; } +/** + * Returns `true` when `error` is an `Error` carrying the given Node.js + * `errno` code (`"ENOENT"`, `"EEXIST"`, ...). Filesystem control flow + * routinely branches on these codes; this guard keeps the + * `"code" in error` duck-typing in one place. + */ +export function isErrnoCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} + /** * Returns `true` when `value` is a plain object record — `{}`, * `Object.create(null)`, or an object whose prototype is diff --git a/packages/eve/src/shared/path-exists.ts b/packages/eve/src/shared/path-exists.ts new file mode 100644 index 000000000..1d5e3232f --- /dev/null +++ b/packages/eve/src/shared/path-exists.ts @@ -0,0 +1,23 @@ +import { stat } from "node:fs/promises"; + +import { isErrnoCode } from "#shared/guards.js"; + +/** + * Returns `true` when `path` exists, `false` when it does not, and + * rethrows every other filesystem error (`EACCES`, `EIO`, ...). + * + * Distinct from `#setup/path-exists.js`, which swallows all errors: + * callers here make crash-recovery decisions, so a permission failure + * must surface rather than masquerade as "does not exist". + */ +export async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + if (isErrnoCode(error, "ENOENT")) { + return false; + } + throw error; + } +} diff --git a/packages/eve/test/scenarios/production-build-isolation.scenario.test.ts b/packages/eve/test/scenarios/production-build-isolation.scenario.test.ts index 4911f43eb..4587148c3 100644 --- a/packages/eve/test/scenarios/production-build-isolation.scenario.test.ts +++ b/packages/eve/test/scenarios/production-build-isolation.scenario.test.ts @@ -1,7 +1,17 @@ import { spawn, type ChildProcessByStdio } from "node:child_process"; import { createHash } from "node:crypto"; import { watch, type FSWatcher } from "node:fs"; -import { lstat, mkdir, readFile, readdir, readlink, realpath, writeFile } from "node:fs/promises"; +import { + lstat, + mkdir, + readFile, + readdir, + readlink, + realpath, + rename, + utimes, + writeFile, +} from "node:fs/promises"; import { join, relative } from "node:path"; import type { Readable } from "node:stream"; import { pathToFileURL } from "node:url"; @@ -537,6 +547,70 @@ describe("production build isolation", () => { SCENARIO_DEADLINE_MS, ); + it( + "recovers a crashed publication through a real build while a real dev server stays healthy", + async () => { + const app = await scenarioApp(WEATHER_AGENT_DESCRIPTOR); + const firstBuild = startEveProcess({ appRoot: app.appRoot, args: ["build"] }); + await expect(firstBuild.result).resolves.toMatchObject({ code: 0, signal: null }); + + const outputDir = join(app.appRoot, ".output"); + const summaryPath = join(app.appRoot, ".eve", "agent-summary.json"); + const token = "crashed-publisher"; + const outputBackupPath = `${outputDir}.eve-backup-${token}`; + const summaryBackupPath = `${summaryPath}.eve-backup-${token}`; + const crashedWorkspace = join(app.appRoot, ".eve", "builds", "crashed"); + const crashedStagedOutputDir = join(crashedWorkspace, "output"); + const crashedStagedSummaryPath = join(crashedWorkspace, "agent-summary.json"); + await mkdir(crashedStagedOutputDir, { recursive: true }); + await writeFile(join(crashedStagedOutputDir, "marker.txt"), "crashed\n"); + await writeFile(crashedStagedSummaryPath, "crashed\n"); + await rename(outputDir, outputBackupPath); + await rename(summaryPath, summaryBackupPath); + const lockPath = join(app.appRoot, ".eve", "locks", "output-publication.lock"); + await mkdir(lockPath, { recursive: true }); + const journalPath = join(lockPath, "owner.json"); + await writeFile( + journalPath, + `${JSON.stringify({ + finalOutputDir: outputDir, + finalSummaryPath: summaryPath, + hadOutput: true, + hadSummary: true, + liveness: "active", + outputBackupPath, + phase: "backed-up", + // This test's own live pid simulates pid reuse after a crash: only + // the stale journal mtime marks the recorded owner as dead. + pid: process.pid, + scratchDir: crashedWorkspace, + stagedOutputDir: crashedStagedOutputDir, + stagedSummaryPath: crashedStagedSummaryPath, + summaryBackupPath, + token, + })}\n`, + ); + const staleTime = new Date(Date.now() - 60_000); + await utimes(journalPath, staleTime, staleTime); + + const server = await startEveDev(app.appRoot); + try { + await expectHealthy(server); + const recoveringBuild = startEveProcess({ appRoot: app.appRoot, args: ["build"] }); + await expect(recoveringBuild.result).resolves.toMatchObject({ code: 0, signal: null }); + await expectHealthy(server); + } finally { + await server.stop(); + } + + expect(await readdir(join(app.appRoot, ".eve", "locks"))).toEqual([]); + expect(await listBuildWorkspaces(app.appRoot)).toEqual([]); + await expect(lstat(outputBackupPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect((await lstat(join(outputDir, "server", "index.mjs"))).isFile()).toBe(true); + }, + SCENARIO_DEADLINE_MS, + ); + it( "publishes relocatable compiler artifacts without workspace-derived step ids", async () => { From 751eed2ca05d3b9ed8d5cec796394e937e973773 Mon Sep 17 00:00:00 2001 From: Casey Gowrie Date: Mon, 13 Jul 2026 16:53:14 -0400 Subject: [PATCH 07/12] test(eve): make publication failures portable Signed-off-by: Casey Gowrie --- .../output-publication.integration.test.ts | 180 ++++++++++++------ 1 file changed, 119 insertions(+), 61 deletions(-) diff --git a/packages/eve/src/internal/application/output-publication.integration.test.ts b/packages/eve/src/internal/application/output-publication.integration.test.ts index e834eed14..bde53edfc 100644 --- a/packages/eve/src/internal/application/output-publication.integration.test.ts +++ b/packages/eve/src/internal/application/output-publication.integration.test.ts @@ -1,7 +1,7 @@ -import { chmod, mkdir, readdir, readFile, rename, stat, utimes, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rename, stat, utimes, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { publishApplicationBuildArtifacts, @@ -12,6 +12,55 @@ import { useTemporaryDirectories } from "#internal/testing/use-temporary-app-roo const createScratchDirectory = useTemporaryDirectories(); +const fileSystemFaults = vi.hoisted(() => ({ + atomicWrite: undefined as ((targetPath: string) => Error | undefined) | undefined, + rename: undefined as + | ((sourcePath: string, destinationPath: string) => Error | undefined) + | undefined, + rm: undefined as ((path: string) => Error | undefined) | undefined, +})); + +vi.mock("#shared/atomic-write-file.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + async atomicWriteFile(...input: Parameters) { + const error = fileSystemFaults.atomicWrite?.(input[0]); + if (error !== undefined) { + throw error; + } + return original.atomicWriteFile(...input); + }, + }; +}); + +vi.mock("node:fs/promises", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + async rename(...input: Parameters) { + const error = fileSystemFaults.rename?.(String(input[0]), String(input[1])); + if (error !== undefined) { + throw error; + } + return original.rename(...input); + }, + async rm(...input: Parameters) { + const error = fileSystemFaults.rm?.(String(input[0])); + if (error !== undefined) { + throw error; + } + return original.rm(...input); + }, + }; +}); + +afterEach(() => { + fileSystemFaults.atomicWrite = undefined; + fileSystemFaults.rename = undefined; + fileSystemFaults.rm = undefined; +}); + interface StagedBuild { readonly scratchDir: string; readonly stagedOutputDir: string; @@ -210,32 +259,29 @@ describe("build output publication", () => { }); const lockPath = resolveOutputPublicationLockPath(appRoot); - try { - await expect( - publishApplicationBuildArtifactsWithObserver( - { - appRoot, - finalOutputDir, - finalSummaryPath, - ...next, - }, - { - async afterBackup() {}, - async afterOutputInstall() { - await chmod(lockPath, 0o500); - }, - async onContention() {}, + await expect( + publishApplicationBuildArtifactsWithObserver( + { + appRoot, + finalOutputDir, + finalSummaryPath, + ...next, + }, + { + async afterBackup() {}, + async afterOutputInstall() { + fileSystemFaults.atomicWrite = (targetPath) => { + if (targetPath !== join(lockPath, "owner.json")) { + return undefined; + } + fileSystemFaults.atomicWrite = undefined; + return new Error("injected committed journal failure"); + }; }, - ), - ).rejects.toThrow(); - } finally { - // The failed release may have renamed the read-only lock directory; - // restore permissions on whatever is left so teardown can remove it. - const locksDir = join(appRoot, ".eve", "locks"); - for (const entry of await readdir(locksDir)) { - await chmod(join(locksDir, entry), 0o700).catch(() => undefined); - } - } + async onContention() {}, + }, + ), + ).rejects.toThrow("injected committed journal failure"); await expectPublication({ outputDir: finalOutputDir, @@ -477,19 +523,27 @@ describe("build output publication", () => { summaryMarker: "retry", summaryPath: retry.stagedSummaryPath, }); - try { - await chmod(appRoot, 0o500); - await expect( - publishApplicationBuildArtifacts({ - appRoot, - finalOutputDir, - finalSummaryPath, - ...first, - }), - ).rejects.toThrow(); - } finally { - await chmod(appRoot, 0o700); - } + fileSystemFaults.rename = (sourcePath, destinationPath) => { + if ( + destinationPath !== finalOutputDir || + !sourcePath.startsWith(`${finalOutputDir}.eve-backup-`) + ) { + return undefined; + } + fileSystemFaults.rename = undefined; + return new Error("injected recovery failure"); + }; + await expect( + publishApplicationBuildArtifacts({ + appRoot, + finalOutputDir, + finalSummaryPath, + ...first, + }), + ).rejects.toMatchObject({ + errors: [expect.objectContaining({ message: "injected recovery failure" })], + message: "Failed to restore the previous build publication.", + }); await expect( publishApplicationBuildArtifactsWithObserver( @@ -535,27 +589,31 @@ describe("build output publication", () => { summaryPath: next.stagedSummaryPath, }); - try { - await expect( - publishApplicationBuildArtifactsWithObserver( - { - appRoot, - finalOutputDir, - finalSummaryPath, - ...next, - }, - { - async afterBackup() {}, - async afterOutputInstall() { - await chmod(appRoot, 0o500); - }, - async onContention() {}, - }, - ), - ).rejects.toThrow(); - } finally { - await chmod(appRoot, 0o700); - } + fileSystemFaults.rm = (path) => { + if (!path.startsWith(`${finalOutputDir}.eve-backup-`)) { + return undefined; + } + fileSystemFaults.rm = undefined; + return new Error("injected backup cleanup failure"); + }; + await expect( + publishApplicationBuildArtifactsWithObserver( + { + appRoot, + finalOutputDir, + finalSummaryPath, + ...next, + }, + { + async afterBackup() {}, + async afterOutputInstall() {}, + async onContention() {}, + }, + ), + ).rejects.toMatchObject({ + errors: [expect.objectContaining({ message: "injected backup cleanup failure" })], + message: "Build output was committed but backup cleanup failed.", + }); await expectPublication({ outputDir: finalOutputDir, From 0ca5832deb8dd8b070f929b0d15c41ef88d9e491 Mon Sep 17 00:00:00 2001 From: Casey Gowrie Date: Mon, 13 Jul 2026 17:23:45 -0400 Subject: [PATCH 08/12] docs(research): outline dev revision isolation Signed-off-by: Casey Gowrie --- research/dev-revision-isolation.md | 76 ++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 research/dev-revision-isolation.md diff --git a/research/dev-revision-isolation.md b/research/dev-revision-isolation.md new file mode 100644 index 000000000..44928bf5d --- /dev/null +++ b/research/dev-revision-isolation.md @@ -0,0 +1,76 @@ +--- +issue: TBD +status: in-review +last_updated: "2026-07-13" +--- + +# Dev revision isolation + +## Summary + +eve already reloads ordinary authored edits during local development. The remaining failures occur +when one reload changes several independently mutable surfaces: compiled artifacts, runtime source, +Nitro inputs, routes, workers, and Workflow state. A removal or failed structural rebuild can leave +those surfaces on different versions, break a previously healthy server, reset an admitted request, +or delete files still needed by a worker or durable run. + +The target is one immutable authored revision and one atomic promotion boundary. New behavior stays +private until every required artifact and worker is ready. A failed candidate leaves the complete +last-good revision active. + +## Ownership model + +```text +authored edit + │ + ▼ +immutable revision ── runtime-only ─────────► publish revision pointer + │ + └── structural ─► ready Nitro candidate ─► atomically swap pointer, routes, and worker + +stable parent server + ├── owns the listener and dev control endpoints + ├── leases one worker and revision per admitted request + └── retires old workers and revisions only after their references are released +``` + +A revision contains the complete executable authored dependency closure. Nitro host inputs retained +for the server lifetime live outside the prunable revision store. Runtime-only edits—tools, +connections, skills, instructions, and similar behavior—continue to reload without replacing the +worker. Changes to host structure, including channel topology and instrumentation, replace the +worker only after a candidate reports ready. + +Durable Workflow runs retain the revision they started with. Pruning removes a revision only when +it is not active, a candidate, last-good, leased by a request or worker, or referenced by a durable +run. Until those references exist, revisions are retained conservatively. + +## Delivery + +1. **Production build isolation:** give each build private compiler, host, Nitro, Workflow, and + output workspaces; serialize only final publication. This is the current PR. +2. **Immutable revisions:** materialize complete, path-independent authored revisions without + changing worker ownership. +3. **Parent worker transport:** add readiness, request and revision leases, cancellation, trusted + client metadata, and bounded shutdown behind a stable listener. +4. **Transactional dev rebuilds:** connect the watcher, revision pointer, routes, and candidate + worker through one promotion coordinator with complete rollback. +5. **Durable Workflow and pruning:** resolve existing runs from their recorded revision and prune + only from explicit references. + +Each stage remains independently valid and does not expose a partially connected lifecycle. + +## Result + +- Concurrent production builds cannot interfere with each other or a running dev server. +- Removing an authored tool or dependency cannot leave a stale Nitro import behind. +- A failed compile, bundle, worker start, route change, or pointer publication keeps the previous + server fully usable. +- Structural reloads do not reset admitted requests or make parent-owned dev endpoints unavailable. +- Channel changes preserve the route Nitro selected, including overlapping routes. +- Active streams release their worker and revision ownership on completion, failure, cancellation, + or disconnect; shutdown does not wait forever on a worker it has not stopped. +- Advancing and pruning a runtime revision cannot remove Nitro inputs retained by the dev server. +- Existing Workflow runs continue with their original tools and instructions after newer edits and + pruning, while new runs use the promoted revision. + +The gray-matter vendoring change is independent of this lifecycle work. From b58ca62f76a883ecf60c8bcca975022f19e2cb12 Mon Sep 17 00:00:00 2001 From: Casey Gowrie Date: Mon, 13 Jul 2026 17:26:31 -0400 Subject: [PATCH 09/12] docs(research): clarify revision isolation goals Signed-off-by: Casey Gowrie --- research/dev-revision-isolation.md | 116 ++++++++++++++++++++++++----- 1 file changed, 99 insertions(+), 17 deletions(-) diff --git a/research/dev-revision-isolation.md b/research/dev-revision-isolation.md index 44928bf5d..fc2c278b4 100644 --- a/research/dev-revision-isolation.md +++ b/research/dev-revision-isolation.md @@ -8,15 +8,89 @@ last_updated: "2026-07-13" ## Summary -eve already reloads ordinary authored edits during local development. The remaining failures occur -when one reload changes several independently mutable surfaces: compiled artifacts, runtime source, -Nitro inputs, routes, workers, and Workflow state. A removal or failed structural rebuild can leave -those surfaces on different versions, break a previously healthy server, reset an admitted request, -or delete files still needed by a worker or durable run. +eve already reloads most authored edits during local development. Changing a tool body, +instructions, or a connection usually works without restarting the server. The remaining problems +appear when an edit removes something, changes server structure, fails partway through, or overlaps +with work that is already running. -The target is one immutable authored revision and one atomic promotion boundary. New behavior stays -private until every required artifact and worker is ready. A failed candidate leaves the complete -last-good revision active. +One rebuild currently updates several independent pieces of state: compiled artifacts, copied +runtime source, Nitro build inputs, routes, the running worker, and Workflow data. Those pieces do +not share one commit point. A rebuild can therefore publish some new state before another step +fails, leaving the server with a mixture of old and new behavior. + +The goal is to treat one compiled version of an agent as a single immutable revision. A revision is +prepared privately and becomes visible only after everything needed to run it is ready. If any step +fails, the last working revision remains fully active. + +## Problems being solved + +### Builds share mutable application paths + +Production builds have historically written compiler, host, Nitro, Workflow, and output artifacts +into application-owned directories also used by other builds or `eve dev`. Concurrent builds can +consume each other's intermediate files, and a failed build can disturb a healthy dev server or +replace part of the last successful output. + +Each production build needs private working directories. Only the final, completed output should be +published to the application, and that short publication step must preserve the last-good output if +it fails. + +### Dev reloads can expose partial state + +Ordinary runtime edits generally work, but removals reveal stale dependencies retained by the +long-lived Nitro host. Structural changes such as channel routes or instrumentation also require a +worker rebuild. Today the runtime pointer, host inputs, routes, and worker lifecycle are not one +transaction, so a late failure can leave earlier mutations active. + +A runtime-only edit should publish a complete revision without replacing the worker. A structural +edit should keep serving the old worker until a replacement has built and reported ready. Failure +at any point should leave the old pointer, routes, worker, and watcher state unchanged. + +### A revision is not yet a complete executable unit + +A copied runtime snapshot can still depend on authored files or packages outside the snapshot. If +those originals change or disappear, an older request or durable run may no longer be able to load +the behavior it started with. Dependency resolution must also follow the same Node ESM rules used +when the code executes. + +Each revision therefore needs the complete authored module and dependency closure required to run +that version, including workspace resources, instrumentation, configured externals, and transitive +packages. Framework and deployment runtime packages remain outside the authored revision. + +### Worker replacement can interrupt live traffic + +The current dev listener proxies to a reloadable Nitro worker. Replacing that worker can reset an +admitted HTTP request, interrupt a stream, or briefly make a dev control endpoint unavailable. It +also makes cancellation and shutdown ownership unclear. + +A stable parent server should own the listener and dev control endpoints. It admits each request to +one worker and one revision, keeps that ownership until the request finishes or disconnects, and +does not retire the old worker while admitted work still depends on it. + +### Revision pruning lacks complete ownership information + +A revision may still be needed by the active worker, an in-flight request, a candidate worker, or a +durable Workflow run. Age and retention-count heuristics cannot prove that deletion is safe. Nitro +also retains some host inputs for the lifetime of the dev server; those inputs must never live under +a directory that revision pruning can remove. + +Pruning should use explicit references. A revision is removable only when it is not active, +candidate, last-good, leased by a request or worker, or referenced by a durable run. Until those +references are available, revisions are retained conservatively. + +## Goals + +- Preserve the fast path that already works: tools, connections, skills, instructions, and similar + runtime behavior reload without replacing the Nitro worker. +- Make an authored revision complete and immutable so one request or durable run never observes a + mixture of versions. +- Keep the last working server available while a structural candidate is prepared and discard the + candidate cleanly if preparation fails. +- Give requests, streams, workers, and Workflow runs explicit revision ownership so shutdown and + pruning decisions are safe. +- Keep parent-owned dev endpoints available through reloads and prevent planned worker replacement + from surfacing connection resets. +- Preserve Nitro's selected route identity, including overlapping static and parameter routes. ## Ownership model @@ -35,10 +109,9 @@ stable parent server ``` A revision contains the complete executable authored dependency closure. Nitro host inputs retained -for the server lifetime live outside the prunable revision store. Runtime-only edits—tools, -connections, skills, instructions, and similar behavior—continue to reload without replacing the -worker. Changes to host structure, including channel topology and instrumentation, replace the -worker only after a candidate reports ready. +for the server lifetime live outside the prunable revision store. The stable parent does not +reinterpret which authored channel matched a request; it preserves the route Nitro selected and +dispatches that route against the leased revision. Durable Workflow runs retain the revision they started with. Pruning removes a revision only when it is not active, a candidate, last-good, leased by a request or worker, or referenced by a durable @@ -59,18 +132,27 @@ run. Until those references exist, revisions are retained conservatively. Each stage remains independently valid and does not expose a partially connected lifecycle. -## Result +## User-visible result - Concurrent production builds cannot interfere with each other or a running dev server. -- Removing an authored tool or dependency cannot leave a stale Nitro import behind. +- Adding, editing, or removing an authored tool continues to work without restarting `eve dev`. - A failed compile, bundle, worker start, route change, or pointer publication keeps the previous server fully usable. -- Structural reloads do not reset admitted requests or make parent-owned dev endpoints unavailable. +- Structural reloads move new traffic only after the replacement worker is ready. Existing requests + and streams finish on their original worker; disconnect and shutdown cancellation follow an + explicit owned lifecycle. +- Parent-owned dev endpoints remain available during reload and do not depend on the worker being + replaced. - Channel changes preserve the route Nitro selected, including overlapping routes. -- Active streams release their worker and revision ownership on completion, failure, cancellation, - or disconnect; shutdown does not wait forever on a worker it has not stopped. - Advancing and pruning a runtime revision cannot remove Nitro inputs retained by the dev server. - Existing Workflow runs continue with their original tools and instructions after newer edits and pruning, while new runs use the promoted revision. +## Non-goals + +- Replacing Nitro as eve's development or production bundler. +- Restarting the worker for every authored edit. +- Making an in-flight request switch behavior when a newer revision is promoted. +- Keeping every historical revision forever once no owner can reference it. + The gray-matter vendoring change is independent of this lifecycle work. From 6d5bc309a94d8bd8132da6f2ec777e2c66864050 Mon Sep 17 00:00:00 2001 From: Casey Gowrie Date: Mon, 13 Jul 2026 17:27:49 -0400 Subject: [PATCH 10/12] docs(research): explain staged outcomes Signed-off-by: Casey Gowrie --- research/dev-revision-isolation.md | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/research/dev-revision-isolation.md b/research/dev-revision-isolation.md index fc2c278b4..576939ae1 100644 --- a/research/dev-revision-isolation.md +++ b/research/dev-revision-isolation.md @@ -119,16 +119,25 @@ run. Until those references exist, revisions are retained conservatively. ## Delivery -1. **Production build isolation:** give each build private compiler, host, Nitro, Workflow, and - output workspaces; serialize only final publication. This is the current PR. -2. **Immutable revisions:** materialize complete, path-independent authored revisions without - changing worker ownership. -3. **Parent worker transport:** add readiness, request and revision leases, cancellation, trusted - client metadata, and bounded shutdown behind a stable listener. -4. **Transactional dev rebuilds:** connect the watcher, revision pointer, routes, and candidate - worker through one promotion coordinator with complete rollback. -5. **Durable Workflow and pruning:** resolve existing runs from their recorded revision and prune - only from explicit references. +1. **Production build isolation — stop builds from sharing work in progress.** Give each build + private compiler, host, Nitro, Workflow, and output workspaces, then serialize only final + publication. This prevents concurrent or failed builds from disturbing each other, the running + dev server, or the last-good output. This is the current PR. +2. **Immutable revisions — make one version of an agent runnable on its own.** Materialize the + complete authored behavior and dependency closure into a path-independent revision. This keeps + older requests and runs executable after source files, tools, or packages are changed or removed. +3. **Parent worker transport — separate the stable server from reloadable workers.** Put readiness, + request and revision leases, cancellation, trusted client metadata, and bounded shutdown behind + a parent-owned listener. This prevents planned worker replacement from resetting admitted + requests, interrupting control endpoints, or leaking workers and revisions. +4. **Transactional dev rebuilds — make a reload one complete promotion.** Connect the watcher, + revision pointer, routes, and candidate worker through one coordinator with complete rollback. + This preserves fast runtime-only reloads while preventing failed structural changes from leaving + mixed routes, pointers, handlers, fingerprints, or workers active. +5. **Durable Workflow and pruning — keep long-running work on the code it started with.** Resolve an + existing run from its recorded revision and prune only from explicit references. This prevents a + promoted revision from changing an older run and prevents cleanup from deleting behavior that an + active run still needs. Each stage remains independently valid and does not expose a partially connected lifecycle. From fbf5cd916fbd9733a9610de3078dc69c51f4456a Mon Sep 17 00:00:00 2001 From: Casey Gowrie Date: Mon, 13 Jul 2026 17:58:49 -0400 Subject: [PATCH 11/12] docs(research): adopt latest-turn delivery Signed-off-by: Casey Gowrie --- research/dev-revision-isolation.md | 130 ++++++++++++++++------------- 1 file changed, 71 insertions(+), 59 deletions(-) diff --git a/research/dev-revision-isolation.md b/research/dev-revision-isolation.md index 576939ae1..75ff48a71 100644 --- a/research/dev-revision-isolation.md +++ b/research/dev-revision-isolation.md @@ -4,7 +4,7 @@ status: in-review last_updated: "2026-07-13" --- -# Dev revision isolation +# Dev generation isolation ## Summary @@ -18,9 +18,11 @@ runtime source, Nitro build inputs, routes, the running worker, and Workflow dat not share one commit point. A rebuild can therefore publish some new state before another step fails, leaving the server with a mixture of old and new behavior. -The goal is to treat one compiled version of an agent as a single immutable revision. A revision is -prepared privately and becomes visible only after everything needed to run it is ready. If any step -fails, the last working revision remains fully active. +The goal is to prepare one complete immutable generation of an agent privately and expose it only +after everything needed to run it is ready. New turns use the latest successfully promoted +generation. A turn already in progress stays on the generation selected when its child Workflow +started, including queue retries and resumptions, but a parked session is not pinned to historical +authored code. ## Problems being solved @@ -42,54 +44,58 @@ long-lived Nitro host. Structural changes such as channel routes or instrumentat worker rebuild. Today the runtime pointer, host inputs, routes, and worker lifecycle are not one transaction, so a late failure can leave earlier mutations active. -A runtime-only edit should publish a complete revision without replacing the worker. A structural +A runtime-only edit should publish a complete generation without replacing the worker. A structural edit should keep serving the old worker until a replacement has built and reported ready. Failure at any point should leave the old pointer, routes, worker, and watcher state unchanged. -### A revision is not yet a complete executable unit +### A generation is not yet a complete executable unit A copied runtime snapshot can still depend on authored files or packages outside the snapshot. If -those originals change or disappear, an older request or durable run may no longer be able to load -the behavior it started with. Dependency resolution must also follow the same Node ESM rules used +those originals change or disappear, an admitted request or active turn may no longer be able to +load the behavior it selected. Dependency resolution must also follow the same Node ESM rules used when the code executes. -Each revision therefore needs the complete authored module and dependency closure required to run +Each generation therefore needs the complete authored module and dependency closure required to run that version, including workspace resources, instrumentation, configured externals, and transitive -packages. Framework and deployment runtime packages remain outside the authored revision. +packages. Framework and deployment runtime packages remain outside the authored generation. ### Worker replacement can interrupt live traffic The current dev listener proxies to a reloadable Nitro worker. Replacing that worker can reset an -admitted HTTP request, interrupt a stream, or briefly make a dev control endpoint unavailable. It -also makes cancellation and shutdown ownership unclear. +admitted HTTP request, interrupt a stream, or briefly make a dev control or Workflow queue endpoint +unavailable. It also makes cancellation and shutdown ownership unclear. -A stable parent server should own the listener and dev control endpoints. It admits each request to -one worker and one revision, keeps that ownership until the request finishes or disconnects, and -does not retire the old worker while admitted work still depends on it. +A stable parent server should own the listener, local Workflow World, queue ingress, and dev control +endpoints. It admits each request or queue delivery to one worker and generation, keeps that +ownership until the operation finishes or disconnects, and does not retire the old worker while +admitted work still depends on it. -### Revision pruning lacks complete ownership information +### Parked sessions and active turns need different lifetimes -A revision may still be needed by the active worker, an in-flight request, a candidate worker, or a -durable Workflow run. Age and retention-count heuristics cannot prove that deletion is safe. Nitro -also retains some host inputs for the lifetime of the dev server; those inputs must never live under -a directory that revision pruning can remove. +Production resolves `latest` when a Workflow starts and then records the selected deployment on +that run. The local World instead exposes one package-version deployment identity and currently +registers queue handlers from the reloadable worker. It cannot reliably route separate child +Workflows to the generations they selected. -Pruning should use explicit references. A revision is removable only when it is not active, -candidate, last-good, leased by a request or worker, or referenced by a durable run. Until those -references are available, revisions are retained conservatively. +The long-lived session driver should remain generation-neutral. Each child turn Workflow resolves +the latest promoted generation once, and all of that run's workflow, step, retry, and resume +deliveries return to the recorded generation. When the child Workflow becomes terminal, it releases +that reference; the next turn in the same session resolves latest again. ## Goals - Preserve the fast path that already works: tools, connections, skills, instructions, and similar runtime behavior reload without replacing the Nitro worker. -- Make an authored revision complete and immutable so one request or durable run never observes a - mixture of versions. +- Make an authored generation complete and immutable so one request or active child Workflow never + observes a mixture of versions. +- Keep parked sessions on the latest successful authored behavior without changing an active turn + during replay, retry, or suspension. - Keep the last working server available while a structural candidate is prepared and discard the candidate cleanly if preparation fails. -- Give requests, streams, workers, and Workflow runs explicit revision ownership so shutdown and - pruning decisions are safe. -- Keep parent-owned dev endpoints available through reloads and prevent planned worker replacement - from surfacing connection resets. +- Give requests, queue deliveries, workers, and active child Workflows explicit generation + ownership so shutdown and pruning decisions are safe. +- Keep parent-owned dev and Workflow endpoints available through reloads and prevent planned worker + replacement from surfacing connection resets. - Preserve Nitro's selected route identity, including overlapping static and parameter routes. ## Ownership model @@ -98,24 +104,28 @@ references are available, revisions are retained conservatively. authored edit │ ▼ -immutable revision ── runtime-only ─────────► publish revision pointer +immutable generation ── runtime-only ─────────► publish latest pointer │ └── structural ─► ready Nitro candidate ─► atomically swap pointer, routes, and worker stable parent server - ├── owns the listener and dev control endpoints - ├── leases one worker and revision per admitted request - └── retires old workers and revisions only after their references are released + ├── owns the listener, local World, queue ingress, and dev control endpoints + ├── leases one worker and generation per admitted request or queue delivery + └── retires old workers and generations only after their references are released + +generation-neutral session driver + └── starts child turn with latest ─► generation G for that child Workflow only ``` -A revision contains the complete executable authored dependency closure. Nitro host inputs retained -for the server lifetime live outside the prunable revision store. The stable parent does not -reinterpret which authored channel matched a request; it preserves the route Nitro selected and -dispatches that route against the leased revision. +A generation contains the complete executable authored dependency closure. Nitro host inputs +retained for the server lifetime live outside the prunable generation store. The stable parent does +not reinterpret which authored channel matched a request; it preserves the route Nitro selected and +dispatches that route against the leased generation. -Durable Workflow runs retain the revision they started with. Pruning removes a revision only when -it is not active, a candidate, last-good, leased by a request or worker, or referenced by a durable -run. Until those references exist, revisions are retained conservatively. +The parent starts exactly one local World. Workers use an eve-owned private World transport rather +than starting competing Worlds or replacing a global direct queue handler. The parent records the +resolved generation on each child Workflow and routes later deliveries by that structured run +record. Public requests cannot select or spoof the generation. ## Delivery @@ -123,21 +133,22 @@ run. Until those references exist, revisions are retained conservatively. private compiler, host, Nitro, Workflow, and output workspaces, then serialize only final publication. This prevents concurrent or failed builds from disturbing each other, the running dev server, or the last-good output. This is the current PR. -2. **Immutable revisions — make one version of an agent runnable on its own.** Materialize the - complete authored behavior and dependency closure into a path-independent revision. This keeps - older requests and runs executable after source files, tools, or packages are changed or removed. -3. **Parent worker transport — separate the stable server from reloadable workers.** Put readiness, - request and revision leases, cancellation, trusted client metadata, and bounded shutdown behind - a parent-owned listener. This prevents planned worker replacement from resetting admitted - requests, interrupting control endpoints, or leaking workers and revisions. +2. **Immutable generations — make one version of an agent runnable on its own.** Materialize the + complete authored behavior and dependency closure into a path-independent generation. This keeps + admitted requests and active turns executable after source files, tools, or packages are changed + or removed. +3. **Parent worker and World transport — separate stable ownership from reloadable workers.** Put + readiness, request and generation leases, queue delivery, cancellation, trusted client metadata, + and bounded shutdown behind a parent-owned listener. This prevents planned worker replacement + from resetting admitted traffic or leaving the local World bound to a retired worker. 4. **Transactional dev rebuilds — make a reload one complete promotion.** Connect the watcher, - revision pointer, routes, and candidate worker through one coordinator with complete rollback. + generation pointer, routes, and candidate worker through one coordinator with complete rollback. This preserves fast runtime-only reloads while preventing failed structural changes from leaving mixed routes, pointers, handlers, fingerprints, or workers active. -5. **Durable Workflow and pruning — keep long-running work on the code it started with.** Resolve an - existing run from its recorded revision and prune only from explicit references. This prevents a - promoted revision from changing an older run and prevents cleanup from deleting behavior that an - active run still needs. +5. **Latest-turn Workflow delivery and pruning — select latest at each child Workflow boundary.** + Keep the parked session driver generation-neutral, record one generation for each active child + Workflow, route its retries and resumptions consistently, and prune from structured active-run + references once the child becomes terminal. Each stage remains independently valid and does not expose a partially connected lifecycle. @@ -150,18 +161,19 @@ Each stage remains independently valid and does not expose a partially connected - Structural reloads move new traffic only after the replacement worker is ready. Existing requests and streams finish on their original worker; disconnect and shutdown cancellation follow an explicit owned lifecycle. -- Parent-owned dev endpoints remain available during reload and do not depend on the worker being - replaced. +- Parent-owned dev and Workflow queue endpoints remain available during reload. - Channel changes preserve the route Nitro selected, including overlapping routes. -- Advancing and pruning a runtime revision cannot remove Nitro inputs retained by the dev server. -- Existing Workflow runs continue with their original tools and instructions after newer edits and - pruning, while new runs use the promoted revision. +- Advancing and pruning a runtime generation cannot remove Nitro inputs retained by the dev server. +- An active child Workflow completes on its selected generation after a promotion. Once the session + parks, its next turn uses the newly promoted generation. ## Non-goals - Replacing Nitro as eve's development or production bundler. - Restarting the worker for every authored edit. -- Making an in-flight request switch behavior when a newer revision is promoted. -- Keeping every historical revision forever once no owner can reference it. +- Pinning a parked session to the authored generation that originally created it. +- Switching an active child Workflow to newer code during retry, replay, or suspension. +- Keeping every historical generation after no request, worker, candidate, or active child Workflow + can reference it. The gray-matter vendoring change is independent of this lifecycle work. From 43457dfa8438d15e2b9eee691c2d7dff2b0544a5 Mon Sep 17 00:00:00 2001 From: Casey Gowrie Date: Tue, 14 Jul 2026 09:33:55 -0400 Subject: [PATCH 12/12] fix(eve): retry atomic replaces over busy targets on Windows Windows refuses to rename over a file while another handle is open on it, which the publication journal hits whenever a phase write races the lock heartbeat's utimes, a waiting publisher's journal read, or an antivirus scan (EPERM in CI). Retry the replace briefly with backoff before giving up, and remove the orphaned temp file on failure. Signed-off-by: Casey Gowrie --- .../eve/src/shared/atomic-write-file.test.ts | 86 +++++++++++++++++++ packages/eve/src/shared/atomic-write-file.ts | 36 +++++++- 2 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 packages/eve/src/shared/atomic-write-file.test.ts diff --git a/packages/eve/src/shared/atomic-write-file.test.ts b/packages/eve/src/shared/atomic-write-file.test.ts new file mode 100644 index 000000000..ec2561f2e --- /dev/null +++ b/packages/eve/src/shared/atomic-write-file.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { atomicWriteFile } from "#shared/atomic-write-file.js"; + +const fake = vi.hoisted(() => ({ + failureCode: "EPERM", + failures: 0, + renameCalls: [] as Array<{ from: string; to: string }>, + rmCalls: [] as string[], + writeCalls: [] as string[], +})); + +vi.mock("node:fs/promises", () => ({ + rename: async (from: string, to: string) => { + fake.renameCalls.push({ from, to }); + if (fake.failures > 0) { + fake.failures -= 1; + const error = new Error( + `${fake.failureCode}: operation not permitted, rename '${from}' -> '${to}'`, + ) as NodeJS.ErrnoException; + error.code = fake.failureCode; + throw error; + } + }, + rm: async (path: string) => { + fake.rmCalls.push(path); + }, + writeFile: async (path: string) => { + fake.writeCalls.push(path); + }, +})); + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + fake.failureCode = "EPERM"; + fake.failures = 0; + fake.renameCalls.length = 0; + fake.rmCalls.length = 0; + fake.writeCalls.length = 0; +}); + +async function settle(promise: Promise): Promise> { + const settled = Promise.allSettled([promise]); + await vi.runAllTimersAsync(); + return (await settled)[0]; +} + +describe("atomicWriteFile", () => { + it("retries a Windows-style busy replace until it succeeds", async () => { + fake.failures = 2; + + const result = await settle(atomicWriteFile("/app/owner.json", "journal")); + + expect(result.status).toBe("fulfilled"); + expect(fake.renameCalls).toHaveLength(3); + expect(fake.renameCalls[0]?.to).toBe("/app/owner.json"); + expect(fake.rmCalls).toEqual([]); + }); + + it("gives up after bounded retries and removes the temp file", async () => { + fake.failures = Number.MAX_SAFE_INTEGER; + + const result = await settle(atomicWriteFile("/app/owner.json", "journal")); + + expect(result.status).toBe("rejected"); + expect((result as PromiseRejectedResult).reason).toMatchObject({ code: "EPERM" }); + expect(fake.renameCalls).toHaveLength(8); + expect(fake.rmCalls).toEqual([fake.renameCalls[0]?.from]); + }); + + it("does not retry failure codes that a repeated rename cannot fix", async () => { + fake.failureCode = "EISDIR"; + fake.failures = 1; + + const result = await settle(atomicWriteFile("/app/owner.json", "journal")); + + expect(result.status).toBe("rejected"); + expect((result as PromiseRejectedResult).reason).toMatchObject({ code: "EISDIR" }); + expect(fake.renameCalls).toHaveLength(1); + expect(fake.rmCalls).toEqual([fake.renameCalls[0]?.from]); + }); +}); diff --git a/packages/eve/src/shared/atomic-write-file.ts b/packages/eve/src/shared/atomic-write-file.ts index a1aa901ad..87b8f0844 100644 --- a/packages/eve/src/shared/atomic-write-file.ts +++ b/packages/eve/src/shared/atomic-write-file.ts @@ -1,10 +1,16 @@ -import { rename, writeFile } from "node:fs/promises"; +import { rename, rm, writeFile } from "node:fs/promises"; + +const REPLACE_RETRY_DELAYS_MS = [10, 20, 40, 80, 160, 320, 640]; /** * Writes `contents` so concurrent readers always observe either the old or * the new file, never a truncated intermediate: a plain `writeFile` truncates * first and streams bytes, while a sibling temp file plus POSIX-atomic * `rename` rules that window out. + * + * Windows refuses to replace a file while another handle is open on it + * (concurrent readers, `utimes` heartbeats, antivirus scans), surfacing as + * `EPERM`/`EACCES`, so the replace is retried briefly before giving up. */ export async function atomicWriteFile( targetPath: string, @@ -12,5 +18,31 @@ export async function atomicWriteFile( ): Promise { const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now().toString(36)}`; await writeFile(tmpPath, contents); - await rename(tmpPath, targetPath); + try { + await renameReplacingBusyTarget(tmpPath, targetPath); + } catch (error) { + await rm(tmpPath, { force: true }).catch(() => undefined); + throw error; + } +} + +async function renameReplacingBusyTarget(fromPath: string, toPath: string): Promise { + for (const delayMs of REPLACE_RETRY_DELAYS_MS) { + try { + await rename(fromPath, toPath); + return; + } catch (error) { + if (!isBusyTargetError(error)) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + await rename(fromPath, toPath); +} + +function isBusyTargetError(error: unknown): boolean { + return ( + error instanceof Error && "code" in error && (error.code === "EPERM" || error.code === "EACCES") + ); }