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..6efa715b7 100644 --- a/packages/eve/src/compiler/artifacts.ts +++ b/packages/eve/src/compiler/artifacts.ts @@ -86,11 +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; + artifactLocations: CompilerArtifactLocations; diagnostics: readonly DiscoverDiagnostic[]; manifest: AgentSourceManifest; } @@ -106,13 +112,19 @@ interface WriteCompilerArtifactsResult { paths: CompilerArtifactPaths; } -/** - * Resolves the compiler-owned artifact paths for one application root. - */ +/** 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, +): 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, @@ -186,13 +198,15 @@ export function createCompileMetadata(input: { }; } -/** - * Writes the compiler-owned discovery artifacts under `.eve/`. - */ +/** Writes compiler-owned artifacts and records their stable published locations. */ export async function writeCompilerArtifacts( input: WriteCompilerArtifactsInput, ): Promise { - const paths = resolveCompilerArtifactPaths(input.appRoot); + 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, @@ -203,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, @@ -211,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 2d197dae1..41d97c8e7 100644 --- a/packages/eve/src/compiler/compile-agent.ts +++ b/packages/eve/src/compiler/compile-agent.ts @@ -1,11 +1,15 @@ +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 CompilerArtifactLocations, type CompilerArtifactPaths, writeCompilerArtifacts, } from "#compiler/artifacts.js"; @@ -43,16 +47,26 @@ export interface CompileAgentResult { export class CompileAgentError extends Error { readonly result: CompileAgentResult; - constructor(result: CompileAgentResult) { - super( - formatCompileAgentErrorMessage({ - diagnostics: result.diagnostics, - diagnosticsPath: 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"), + ); + } } /** @@ -60,27 +74,82 @@ export class CompileAgentError extends Error { * produced errors. */ export async function compileAgent(input: CompileAgentInput = {}): Promise { + const discovered = await discoverAgentForCompilation(input); + const artifactsRoot = join(discovered.project.appRoot, ".eve"); + const result = await writeAgentCompilation(discovered, { + publishedRoot: artifactsRoot, + writeRoot: artifactsRoot, + }); + + return finishAgentCompilation(result, CompileAgentError.fromDurableArtifacts); +} + +/** + * Compiles an agent for a production build. Artifacts are written to the + * invocation-owned `writeRoot` (a throwaway build workspace), while the + * metadata and module map record paths under the stable `publishedRoot` + * where publication later installs them — so the recorded paths stay + * relocatable and identical across builds of the same source. + */ +export async function compileAgentInBuildWorkspace(input: { + readonly artifactLocations: CompilerArtifactLocations; + readonly startPath: string; +}): Promise { + const discovered = await discoverAgentForCompilation({ startPath: input.startPath }); + const result = await writeAgentCompilation(discovered, input.artifactLocations); + + 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, + + return { diagnostics: discoveryResult.diagnostics, manifest: discoveryResult.manifest, + project, + }; +} + +async function writeAgentCompilation( + discovered: DiscoveredAgentCompilation, + artifactLocations: CompilerArtifactLocations, +): Promise { + const writtenArtifacts = await writeCompilerArtifacts({ + appRoot: discovered.project.appRoot, + artifactLocations, + 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); +function finishAgentCompilation( + result: CompileAgentResult, + createError: (result: CompileAgentResult) => CompileAgentError, +): CompileAgentResult { + if (hasDiscoverErrors(result.diagnostics)) { + throw createError(result); } - reportDiscoverWarnings(discoveryResult.diagnostics); + reportDiscoverWarnings(result.diagnostics); return result; } @@ -97,31 +166,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 6903fc439..d1667deca 100644 --- a/packages/eve/src/execution/sandbox/prewarm.scenario.test.ts +++ b/packages/eve/src/execution/sandbox/prewarm.scenario.test.ts @@ -3,8 +3,9 @@ import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { compileAgent } from "#compiler/compile-agent.js"; -import { createNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.js"; +import { compileAgent, compileAgentInBuildWorkspace } from "#compiler/compile-agent.js"; +import { resolvePackageSourceFilePath } from "#internal/application/package.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"; @@ -13,6 +14,7 @@ import type { SandboxBackendPrewarmResult, } from "#public/definitions/sandbox-backend.js"; import { prewarmAppSandboxes } from "#execution/sandbox/prewarm.js"; +import { createDiskRuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js"; const createScratchDirectory = useTemporaryDirectories(); @@ -22,6 +24,39 @@ describe("prewarmAppSandboxes", () => { 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"); + await compileAgentInBuildWorkspace({ + artifactLocations: { + publishedRoot: join(compilerAppRoot, ".eve"), + writeRoot: join(compilerAppRoot, ".eve"), + }, + startPath: appRoot, + }); + const events = createPrewarmEvents(); + + await prewarmAppSandboxes({ + appRoot, + 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 @@ -69,7 +104,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 db16a5c29..0a08204d4 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; @@ -168,6 +173,8 @@ export async function prewarmAppSandboxes(input: { await prewarmSandboxes({ appRoot: getRuntimeCompiledArtifactsSandboxAppRoot(compiledArtifactsSource) ?? input.appRoot, + compileDirectoryPath: resolveRuntimeCompilerArtifactPaths(compiledArtifactsSource.appRoot) + .compileDirectoryPath, compiledArtifactsSource, dispatch: input.dispatch, graph, @@ -186,16 +193,20 @@ export async function prewarmBuiltAppSandboxes(input: { readonly log?: (message: string) => void; readonly dispatch?: SandboxBackendPrewarmDispatch; }): Promise { - const authoredSource = createAuthoredSourceRuntimeCompiledArtifactsSource(input.appRoot); + const builtArtifactsRoot = join(input.appRoot, ".output"); + 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, }), ]); @@ -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,10 @@ 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 runtimeContext = { appRoot: input.appRoot }; const targets: PrewarmTarget[] = []; @@ -260,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/application/build-workspace.ts b/packages/eve/src/internal/application/build-workspace.ts new file mode 100644 index 000000000..62e8efa90 --- /dev/null +++ b/packages/eve/src/internal/application/build-workspace.ts @@ -0,0 +1,87 @@ +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 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 workflow: { + readonly buildDir: string; + }; +} + +/** + * Creates the invocation-owned directory tree under `.eve/builds/` 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 { + const resolvedAppRoot = resolve(appRoot); + const buildId = `${Date.now().toString(36)}-${randomUUID()}`; + const rootDir = join(resolvedAppRoot, ".eve", "builds", buildId); + const compilerRootDir = join(rootDir, "compiler"); + const workspace: ApplicationBuildWorkspace = { + appRoot: resolvedAppRoot, + 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, + workflow: { + buildDir: 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/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..f1994b76d --- /dev/null +++ b/packages/eve/src/internal/application/output-publication-artifacts.ts @@ -0,0 +1,99 @@ +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, +): 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); + } +} 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..6422acc4d --- /dev/null +++ b/packages/eve/src/internal/application/output-publication-journal.ts @@ -0,0 +1,110 @@ +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 { + readonly finalOutputDir: string; + readonly finalSummaryPath: string; + hadOutput: boolean; + hadSummary: boolean; + liveness: "active" | "recoverable"; + readonly outputBackupPath: string; + phase: OutputPublicationPhase; + readonly pid: number; + readonly scratchDir: string; + 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); +} + +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); +} + +export async function readRecoveryLeaseJournal( + leasePath: string, +): Promise { + const value = await readJournal(leasePath); + return isRecoveryLeaseJournal(value) ? value : undefined; +} + +/** + * 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(resolveJournalFilePath(path), "utf8")) as unknown; + } catch (error) { + if (isErrnoCode(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.scratchDir === "string" && + 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"; +} 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..f703266f4 --- /dev/null +++ b/packages/eve/src/internal/application/output-publication-lock.ts @@ -0,0 +1,413 @@ +import { watch } from "node:fs"; +import { mkdir, readdir, rename, rm, stat, utimes } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; + +import { + removeOutputPublicationBackups, + rollbackOutputPublication, +} from "#internal/application/output-publication-artifacts.js"; +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; + release(): Promise; +} + +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, + 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 (isErrnoCode(error, "ENOENT")) { + return; + } + throw error; + } + await rm(releasedPath, { force: true, recursive: true }); + }; + } catch (error) { + if (!isErrnoCode(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; + } + + const stopLeaseHeartbeat = startPublicationJournalHeartbeat(join(recoveryPath, "lease")); + let preserveRecovery = false; + try { + const existingJournal = await readOutputPublicationJournal(lockPath); + if ( + existingJournal !== undefined && + existingJournal.liveness === "active" && + isProcessAlive(existingJournal.pid) && + !(await isJournalStale(lockPath)) + ) { + 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 (!isErrnoCode(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)); + } + preserveRecovery = false; + return true; + } finally { + stopLeaseHeartbeat(); + 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 (isErrnoCode(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 (isErrnoCode(error, "ENOENT")) { + return; + } + throw error; + } + await rm(releasedPath, { force: true, recursive: true }); + }, + }; + } catch (error) { + // 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) && + !(await isJournalStale(leasePath)) + ) { + return undefined; + } + if (currentJournal === undefined && !(await isPathStale(leasePath))) { + return undefined; + } + + const staleLeasePath = `${leasePath}.stale-${token}`; + try { + await rename(leasePath, staleLeasePath); + } catch (error) { + if (isErrnoCode(error, "ENOENT")) { + continue; + } + throw error; + } + await rm(staleLeasePath, { force: true, recursive: true }); + } +} + +/** + * 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 || !hasTokenDerivedBackupPaths(staleJournal)) { + await rm(journalPath, { force: true, recursive: true }); + return; + } + if (staleJournal.phase === "committed") { + await removeOutputPublicationBackups(staleJournal); + } else { + await rollbackOutputPublication(staleJournal); + } + await removePublicationScratchDirectory(staleJournal); + await rm(journalPath, { force: true, recursive: true }); +} + +/** + * 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 ( + relativeStagedPath === "" || + relativeStagedPath.startsWith("..") || + isAbsolute(relativeStagedPath) + ) { + return; + } + await rm(journal.scratchDir, { force: true, recursive: true }); +} + +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 (isErrnoCode(error, "ENOENT")) { + return true; + } + throw error; + } +} + +async function isJournalStale(journalDirectoryPath: string): Promise { + try { + const journalStats = await stat(resolveJournalFilePath(journalDirectoryPath)); + return Date.now() - journalStats.mtimeMs >= ACTIVE_JOURNAL_STALE_MS; + } catch (error) { + if (isErrnoCode(error, "ENOENT")) { + return true; + } + throw error; + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !isErrnoCode(error, "ESRCH"); + } +} diff --git a/packages/eve/src/internal/application/output-publication.integration.test.ts b/packages/eve/src/internal/application/output-publication.integration.test.ts new file mode 100644 index 000000000..bde53edfc --- /dev/null +++ b/packages/eve/src/internal/application/output-publication.integration.test.ts @@ -0,0 +1,649 @@ +import { mkdir, readFile, rename, stat, utimes, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + publishApplicationBuildArtifacts, + publishApplicationBuildArtifactsWithObserver, + resolveOutputPublicationLockPath, +} from "#internal/application/output-publication.js"; +import { useTemporaryDirectories } from "#internal/testing/use-temporary-app-roots.js"; + +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; + 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; + 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`); +} + +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 interrupted: StagedBuild; + readonly pid?: number; +}): 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.interrupted.stagedOutputDir, + outputMarker: "interrupted", + summaryMarker: "interrupted", + summaryPath: input.interrupted.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: input.pid ?? 2_147_483_647, + scratchDir: input.interrupted.scratchDir, + stagedOutputDir: input.interrupted.stagedOutputDir, + stagedSummaryPath: input.interrupted.stagedSummaryPath, + summaryBackupPath, + token, + })}\n`, + ); +} + +describe("build output publication", () => { + 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"); + const next = stagedBuild(appRoot, "next"); + await writePublication({ + outputDir: finalOutputDir, + outputMarker: "previous", + summaryMarker: "previous", + summaryPath: finalSummaryPath, + }); + 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("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 failed = stagedBuild(appRoot, "failed"); + await writePublication({ + outputDir: finalOutputDir, + outputMarker: "last-good", + summaryMarker: "last-good", + summaryPath: finalSummaryPath, + }); + await writePublication({ + outputDir: failed.stagedOutputDir, + outputMarker: "failed", + summaryMarker: "failed", + summaryPath: failed.stagedSummaryPath, + }); + + await expect( + publishApplicationBuildArtifactsWithObserver( + { + appRoot, + finalOutputDir, + finalSummaryPath, + ...failed, + }, + { + async afterBackup() {}, + async afterOutputInstall() { + throw new Error("injected publication failure"); + }, + async onContention() {}, + }, + ), + ).rejects.toThrow("injected publication failure"); + + await expectPublication({ + outputDir: finalOutputDir, + outputMarker: "last-good", + summaryMarker: "last-good", + summaryPath: finalSummaryPath, + }); + await expectPublication({ + outputDir: failed.stagedOutputDir, + outputMarker: "failed", + summaryMarker: "failed", + 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); + + 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"); + }; + }, + async onContention() {}, + }, + ), + ).rejects.toThrow("injected committed journal failure"); + + await expectPublication({ + outputDir: finalOutputDir, + outputMarker: "last-good", + summaryMarker: "last-good", + summaryPath: finalSummaryPath, + }); + await expectPublication({ + outputDir: next.stagedOutputDir, + outputMarker: "next", + summaryMarker: "next", + summaryPath: next.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 first = stagedBuild(appRoot, "first"); + const second = stagedBuild(appRoot, "second"); + await writePublication({ + outputDir: first.stagedOutputDir, + outputMarker: "first", + summaryMarker: "first", + summaryPath: first.stagedSummaryPath, + }); + await writePublication({ + outputDir: second.stagedOutputDir, + outputMarker: "second", + summaryMarker: "second", + summaryPath: second.stagedSummaryPath, + }); + const firstEntered = Promise.withResolvers(); + const releaseFirst = Promise.withResolvers(); + const secondObservedContention = Promise.withResolvers(); + const entered: string[] = []; + + const firstPublish = publishApplicationBuildArtifactsWithObserver( + { + appRoot, + finalOutputDir, + finalSummaryPath, + ...first, + }, + { + async afterBackup() { + entered.push("first"); + firstEntered.resolve(); + await releaseFirst.promise; + }, + async afterOutputInstall() {}, + async onContention() {}, + }, + ); + await firstEntered.promise; + const secondPublish = publishApplicationBuildArtifactsWithObserver( + { + appRoot, + finalOutputDir, + finalSummaryPath, + ...second, + }, + { + async afterBackup() { + entered.push("second"); + }, + async afterOutputInstall() {}, + async onContention() { + secondObservedContention.resolve(); + }, + }, + ); + + await secondObservedContention.promise; + expect(entered).toEqual(["first"]); + releaseFirst.resolve(); + await Promise.all([firstPublish, secondPublish]); + + 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 interrupted = stagedBuild(appRoot, "interrupted"); + const next = stagedBuild(appRoot, "next"); + await interruptPublicationAfterBackup({ + appRoot, + finalOutputDir, + finalSummaryPath, + interrupted, + }); + await writePublication({ + outputDir: next.stagedOutputDir, + outputMarker: "next", + summaryMarker: "next", + summaryPath: next.stagedSummaryPath, + }); + await expect( + publishApplicationBuildArtifactsWithObserver( + { + appRoot, + finalOutputDir, + finalSummaryPath, + ...next, + }, + { + 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, + }); + 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 interrupted = stagedBuild(appRoot, "interrupted"); + const first = stagedBuild(appRoot, "first"); + const retry = stagedBuild(appRoot, "retry"); + await interruptPublicationAfterBackup({ + appRoot, + finalOutputDir, + finalSummaryPath, + interrupted, + }); + await writePublication({ + outputDir: first.stagedOutputDir, + outputMarker: "first", + summaryMarker: "first", + summaryPath: first.stagedSummaryPath, + }); + await writePublication({ + outputDir: retry.stagedOutputDir, + outputMarker: "retry", + summaryMarker: "retry", + summaryPath: retry.stagedSummaryPath, + }); + 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( + { + appRoot, + finalOutputDir, + finalSummaryPath, + ...retry, + }, + { + async afterBackup() { + throw new Error("stop after recovery retry"); + }, + async afterOutputInstall() {}, + async onContention() {}, + }, + ), + ).rejects.toThrow("stop after recovery retry"); + + await expectPublication({ + outputDir: finalOutputDir, + outputMarker: "last-good", + summaryMarker: "last-good", + 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 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, + }); + + 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, + outputMarker: "next", + summaryMarker: "next", + summaryPath: finalSummaryPath, + }); + await expect( + readFile(join(resolveOutputPublicationLockPath(appRoot), "owner.json"), "utf8"), + ).resolves.toContain('"phase": "committed"'); + + const recovered = stagedBuild(appRoot, "recovered"); + await writePublication({ + outputDir: recovered.stagedOutputDir, + outputMarker: "recovered", + summaryMarker: "recovered", + summaryPath: recovered.stagedSummaryPath, + }); + await publishApplicationBuildArtifacts({ + appRoot, + finalOutputDir, + finalSummaryPath, + ...recovered, + }); + + 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 new file mode 100644 index 000000000..d963743b2 --- /dev/null +++ b/packages/eve/src/internal/application/output-publication.ts @@ -0,0 +1,164 @@ +import { randomUUID } from "node:crypto"; +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, + startPublicationJournalHeartbeat, +} from "#internal/application/output-publication-lock.js"; + +export { resolveOutputPublicationLockPath }; + +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; + onContention(): Promise; +} + +const DEFAULT_OBSERVER: OutputPublicationObserver = { + async afterBackup() {}, + async afterOutputInstall() {}, + 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 { + await publishApplicationBuildArtifactsWithObserver(input, DEFAULT_OBSERVER); +} + +export async function publishApplicationBuildArtifactsWithObserver( + input: OutputPublicationInput, + observer: OutputPublicationObserver, +): Promise { + const journal = createOutputPublicationJournal(input); + await assertStagedPublicationExists(journal); + + 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"; + 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); + committedJournalWritten = true; + await removeOutputPublicationBackups(journal); + } catch (error) { + // 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, + lockPath, + message: "Build output was committed but backup cleanup failed.", + }); + } + try { + 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.", + }); + } + await release(); + throw error; + } finally { + stopHeartbeat(); + } + + 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, + scratchDir: resolve(input.scratchDir), + 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; +}): Promise { + input.journal.liveness = "recoverable"; + try { + await writeOutputPublicationJournal(input.lockPath, input.journal); + } catch (journalWriteError) { + throw new RecoverablePublicationError([...input.errors, journalWriteError], input.message, { + cause: input.errors[0], + }); + } + throw new RecoverablePublicationError(input.errors, input.message, { cause: input.errors[0] }); +} 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/nitro/dev-runtime-artifacts.integration.test.ts b/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts index 5f8615a0f..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 @@ -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, @@ -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({ @@ -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..9cc0b1f21 100644 --- a/packages/eve/src/internal/nitro/host/artifacts-config.ts +++ b/packages/eve/src/internal/nitro/host/artifacts-config.ts @@ -1,34 +1,32 @@ 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"; +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. + * 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 interface NitroArtifactsConfigInput extends NitroArtifactsConfig { +export function createDevelopmentNitroArtifactsConfig(input: { readonly appRoot: string; - readonly dev: boolean; +}): DevelopmentNitroArtifactsConfig { + return { + appRoot: input.appRoot, + devRuntimeArtifactsPointerPath: resolveDevelopmentRuntimeArtifactsPointerPath(input.appRoot), + kind: "development", + moduleMapLoaderPath: resolvePackageSourceFilePath("src/internal/authored-module-map-loader.ts"), + }; } /** - * Creates the artifacts config baked into Nitro virtual handlers. + * Runtime-artifacts wiring for built output: routes require the artifacts + * bundled into the server at build time and never touch the filesystem. */ -export function createNitroArtifactsConfig(input: { - readonly appRoot: string; - readonly dev: boolean; -}): NitroArtifactsConfigInput { - if (!input.dev) { - return { - appRoot: input.appRoot, - dev: input.dev, - }; - } - +export function createProductionNitroArtifactsConfig(): ProductionNitroArtifactsConfig { return { - appRoot: input.appRoot, - devRuntimeArtifactsPointerPath: resolveDevelopmentRuntimeArtifactsPointerPath(input.appRoot), - dev: input.dev, - moduleMapLoaderPath: resolvePackageSourceFilePath("src/internal/authored-module-map-loader.ts"), + 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 9af1c8adc..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 @@ -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,10 +54,15 @@ const buildNitroMock = vi.fn(async (nitro: Nitro) => { ); }); const copyPublicAssetsMock = vi.fn(async () => undefined); -const createApplicationNitroMock = vi.fn(); -const prepareApplicationHostMock = vi.fn(); +const createProductionApplicationNitroMock = 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) => ({ + 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[] = []; @@ -69,11 +75,15 @@ vi.mock("nitro/builder", () => ({ })); vi.mock("./create-application-nitro.js", () => ({ - createApplicationNitro: createApplicationNitroMock, + createProductionApplicationNitro: createProductionApplicationNitroMock, })); vi.mock("./prepare-application-host.js", () => ({ - prepareApplicationHost: prepareApplicationHostMock, + prepareProductionApplicationHost: prepareProductionApplicationHostMock, +})); + +vi.mock("#discover/project.js", () => ({ + resolveDiscoveryProject: resolveDiscoveryProjectMock, })); vi.mock("./vercel-build-prewarm.js", () => ({ @@ -109,6 +119,17 @@ function createPreparedHost(appRoot: string): PreparedApplicationHost { appRoot, compileResult: { manifest, + paths: { + compileDirectoryPath: join( + appRoot, + ".eve", + "builds", + "test", + "compiler", + ".eve", + "compile", + ), + }, project: { agentRoot, appRoot, @@ -141,6 +162,13 @@ function createNitroStub(outputDir: string): Nitro { } as unknown as Nitro; } +async function prepareHostBuildWorkspace( + workspace: ApplicationBuildWorkspace, +): Promise { + await mkdir(join(workspace.compiler.artifactsDir, "compile"), { recursive: true }); + return createPreparedHost(workspace.appRoot); +} + describe("buildApplication", () => { beforeEach(() => { vi.resetModules(); @@ -152,14 +180,17 @@ 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"); - prepareApplicationHostMock.mockResolvedValueOnce(createPreparedHost(appRoot)); - createApplicationNitroMock.mockResolvedValueOnce(createNitroStub(outputDir)); + prepareProductionApplicationHostMock.mockImplementationOnce(prepareHostBuildWorkspace); + createProductionApplicationNitroMock.mockImplementationOnce( + async (_preparedHost: PreparedApplicationHost, options: { outputDir: string }) => + createNitroStub(options.outputDir), + ); await mkdir(outputDir, { recursive: true }); await Promise.all([ writeFile(join(outputDir, "eve-cache.json"), `${JSON.stringify({ eveVersion: "old" })}\n`), @@ -170,10 +201,13 @@ 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")), + }), ); await expect(readFile(staleOutputPath, "utf8")).rejects.toThrow(); await expect(readFile(join(outputDir, "eve-cache.json"), "utf8")).resolves.toBe( @@ -187,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"), @@ -196,30 +233,60 @@ 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); + 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 }); + 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, DEPLOYABLE_BUILD_OPTIONS)).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"); - - 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")); - }, + const stableFlowOutputDir = join(appRoot, ".eve", "nitro-output", "flow"); + const staleFlowOutputPath = join(stableFlowOutputDir, "stale-flow.txt"); + + prepareProductionApplicationHostMock.mockImplementationOnce(prepareHostBuildWorkspace); + createProductionApplicationNitroMock.mockImplementation( + async (_preparedHost: PreparedApplicationHost, options: { outputDir: string }) => + createNitroStub(options.outputDir), ); - 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"), @@ -254,15 +321,18 @@ 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", - ]); + 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({ flowNitroOutputDir: flowOutputDir, - outputDir: join(appRoot, ".vercel", "output"), + outputDir: expect.stringContaining(join(appRoot, ".eve", "builds")), runtime: "nodejs24.x", }); const nestedFunctionStats = await lstat( @@ -305,11 +375,17 @@ 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, + 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"), @@ -323,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"); @@ -353,19 +420,10 @@ describe("buildApplication", () => { const appRoot = await createScratchDirectory("eve-build-application-vercel-nuxt-"); const flowOutputDir = join(appRoot, ".eve", "nitro-output", "flow"); - 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), ); await mkdir(flowOutputDir, { recursive: true }); await writeFile( @@ -420,13 +478,10 @@ describe("buildApplication", () => { vi.stubEnv("VERCEL", "1"); const appRoot = await createScratchDirectory("eve-build-application-vercel-service-array-"); - prepareApplicationHostMock.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( @@ -474,13 +529,10 @@ describe("buildApplication", () => { const projectRoot = await createScratchDirectory("eve-build-application-vercel-root-dir-"); const appRoot = join(projectRoot, "apps", "web", "agents", "support"); - prepareApplicationHostMock.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( @@ -537,19 +589,10 @@ describe("buildApplication", () => { const appRoot = await createScratchDirectory("eve-build-application-vercel-root-config-"); const flowOutputDir = join(appRoot, ".eve", "nitro-output", "flow"); - 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), ); await Promise.all([ mkdir(flowOutputDir, { recursive: true }), @@ -595,19 +638,10 @@ describe("buildApplication", () => { vi.stubEnv("VERCEL", "1"); const appRoot = await createScratchDirectory("eve-build-application-vercel-standalone-"); - 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 e08118582..38cd7a711 100644 --- a/packages/eve/src/internal/nitro/host/build-application.ts +++ b/packages/eve/src/internal/nitro/host/build-application.ts @@ -4,18 +4,27 @@ 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, + 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"; -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 { 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, @@ -23,6 +32,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 +289,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), + const nitro = await createProductionApplicationNitro(preparedHost, { + buildDir: join(workspace.nitro.buildDir, surface), + outputDir: join(workspace.nitro.surfaceOutputDir, surface), surface, }); @@ -308,65 +321,123 @@ export async function buildApplication( ); } - const preparedHost = await prepareApplicationHost(rootDir); + 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 { + if (!preserveWorkspaceForRecovery) { + await removeApplicationBuildWorkspace(workspace); + } + } +} + +async function buildApplicationInWorkspace( + workspace: ApplicationBuildWorkspace, + options: ApplicationBuildOptions, +): Promise { + const preparedHost = await prepareProductionApplicationHost(workspace); if (!process.env.VERCEL) { - const nitro = await createApplicationNitro(preparedHost, false); + const nitro = await createProductionApplicationNitro(preparedHost, { + buildDir: workspace.nitro.buildDir, + outputDir: workspace.publication.output.stagedDir, + surface: "all", + }); try { - const outputDirectory = await buildNitroOutput(nitro); + await buildNitroOutput(nitro); await emitVercelAgentSummary({ manifest: preparedHost.compileResult.manifest, - appRoot: preparedHost.appRoot, + outputPath: workspace.publication.summary.stagedPath, + }); + await stageProductionCompilerArtifacts({ + compilerArtifactsRoot: workspace.compiler.artifactsDir, + outputDir: workspace.publication.output.stagedDir, }); - return outputDirectory; } finally { await nitro.close(); } + + await publishCompletedApplicationBuild(workspace); + return workspace.publication.output.finalDir; } const servicePrefix = await resolveCoDeployedEveServicePrefixForVercelFunctionOutput( preparedHost.appRoot, preparedHost.compileResult.project.agentRoot, ); - const nitro = await createApplicationNitro(preparedHost, false, { + const nitro = await createProductionApplicationNitro(preparedHost, { + buildDir: join(workspace.nitro.buildDir, "app"), + outputDir: workspace.publication.output.stagedDir, 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, + compiledArtifactsSource: createDiskRuntimeCompiledArtifactsSource( + workspace.compiler.rootDir, + { + 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, - workflowBuildDir: preparedHost.workflowBuildDir, + outputDir: workspace.publication.output.stagedDir, + workflowBuildDir: workspace.workflow.buildDir, }); if (servicePrefix !== undefined) { - await normalizeEveVercelFunctionOutput(outputDirectory, { + await normalizeEveVercelFunctionOutput(workspace.publication.output.stagedDir, { servicePrefix, }); } await emitVercelAgentSummary({ manifest: preparedHost.compileResult.manifest, - appRoot: preparedHost.appRoot, + outputPath: workspace.publication.summary.stagedPath, }); - - return outputDirectory; } finally { await nitro.close(); } + + await publishCompletedApplicationBuild(workspace); + return workspace.publication.output.finalDir; +} + +async function publishCompletedApplicationBuild( + workspace: ApplicationBuildWorkspace, +): Promise { + await publishApplicationBuildArtifacts({ + 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/build-vercel-agent-summary.ts b/packages/eve/src/internal/nitro/host/build-vercel-agent-summary.ts index 94bfdb348..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,19 +87,17 @@ export function buildVercelAgentSummary(input: { */ 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); - - 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/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 f4c84fb57..6a3d5db1a 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; @@ -84,16 +85,21 @@ 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_DEV_DISPATCH_SCHEDULE_ROUTE_PATTERN, EVE_HEALTH_ROUTE_PATH, EVE_INFO_ROUTE_PATH } = await import("#protocol/routes.js"); 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 +113,7 @@ function createNitroStub( }, }; - return nitro as never as Nitro; + return nitro as never as Nitro & Pick; } function createPreparedHost( @@ -152,7 +158,7 @@ function createPreparedHost( return preparedHost as never as PreparedApplicationHost; } -describe("configureNitroRoutes", () => { +describe("Nitro route configuration", () => { beforeEach(() => { fsMocks.mkdir.mockClear(); fsMocks.writeFile.mockClear(); @@ -165,9 +171,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", @@ -184,9 +188,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", @@ -201,9 +207,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) @@ -228,15 +232,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( @@ -256,14 +257,30 @@ describe("configureNitroRoutes", () => { 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`; 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"); @@ -283,16 +300,22 @@ describe("configureNitroRoutes", () => { expect(readWriteFileSourceMatching("/workflow/steps-handler.mjs")).toBeUndefined(); }); + it("bakes the module map loader into the dev schedule handler", async () => { + const nitro = createNitroStub({ dev: true }); + + await configureDevelopmentNitroRoutes(nitro, createPreparedHost()); + + const source = nitro.options.virtual[`#eve-route${EVE_DEV_DISPATCH_SCHEDULE_ROUTE_PATTERN}`]; + expect(source).toContain('"moduleMapLoaderPath"'); + expect(source).toContain("authored-module-map-loader.js"); + }); + it("registers the dev runtime artifact revision route only in dev mode", async () => { 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", @@ -311,28 +334,12 @@ describe("configureNitroRoutes", () => { ); }); - it("bakes the module map loader into the dev schedule handler", async () => { - const nitro = createNitroStub({ dev: true }); - - await configureNitroRoutes(nitro, createPreparedHost(), { - surface: "app", - }); - - const source = nitro.options.virtual[`#eve-route${EVE_DEV_DISPATCH_SCHEDULE_ROUTE_PATTERN}`]; - expect(source).toContain('"moduleMapLoaderPath"'); - expect(source).toContain("authored-module-map-loader.js"); - }); - it("registers the agent info route for dev and production app builds", async () => { 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}`, @@ -346,10 +353,10 @@ describe("configureNitroRoutes", () => { }); 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"); @@ -368,16 +375,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"); @@ -396,16 +401,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"); @@ -428,9 +431,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 d9634f214..089ca80c6 100644 --- a/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts +++ b/packages/eve/src/internal/nitro/host/configure-nitro-routes.ts @@ -19,9 +19,13 @@ import { } from "#internal/application/package.js"; import { WorkflowBundleBuilder } from "#internal/workflow-bundle/builder.js"; import { - createNitroArtifactsConfig, - type NitroArtifactsConfigInput, + createDevelopmentNitroArtifactsConfig, + createProductionNitroArtifactsConfig, } 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, @@ -264,176 +268,187 @@ 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( +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: NitroArtifactsConfig, +): 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: DevelopmentNitroArtifactsConfig, +): 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, { + // The complete config is resolved here, in the unbundled host process, + // and baked into the handler: resolving the module-map loader path from + // inside the bundled dev server can land on the authored app instead of + // the installed eve package (vercel/eve#311). + args: JSON.stringify(artifactsConfig), + 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 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, +): 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, + }); + // 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"), }); + }; - if (nitro.options.dev) { - nitro.hooks.hook("dev:reload", async () => { - await syncWorkflowArtifacts(); - }); - } - } + await registerWorkflowArtifactBuildHook(nitro, syncWorkflowArtifacts); + nitro.hooks.hook("dev:reload", syncWorkflowArtifacts); - const artifactsConfig: NitroArtifactsConfigInput = createNitroArtifactsConfig({ + const artifactsConfig = createDevelopmentNitroArtifactsConfig({ appRoot: preparedHost.appRoot, - dev: nitro.options.dev, }); + 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 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, + 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(artifactsConfig), - handlerExport: "handleDevScheduleDispatchRequest", - method: "POST", - modulePath: resolvePackageSourceFilePath( - "src/internal/nitro/routes/dev-schedule-dispatch.ts", - ), - route: EVE_DEV_DISPATCH_SCHEDULE_ROUTE_PATTERN, + const syncWorkflowArtifacts = 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()); + } - 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 22bcbbb24..423653854 100644 --- a/packages/eve/src/internal/nitro/host/create-application-nitro.ts +++ b/packages/eve/src/internal/nitro/host/create-application-nitro.ts @@ -16,13 +16,13 @@ import { writeEveVersionedCacheMetadata, } from "#internal/application/cache-metadata.js"; import { resolveNitroBuildDirectory } from "#internal/application/paths.js"; -import { - createNitroArtifactsConfig, - 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 { 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 +80,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 +108,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 +166,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 +398,7 @@ function addNitroStepModuleSideEffectsPlugin( input: { stepEntrypointPath: string; }, -): void { +): () => void { let cachedStepTransformTargets: Set | null = null; const getStepTransformTargets = async (): Promise> => { @@ -430,15 +413,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 +444,8 @@ function addNitroStepModuleSideEffectsPlugin( }, }); }); + + return clearCachedStepTransformTargets; } /** @@ -478,7 +458,7 @@ function addNitroStepTransformPlugin( input: { stepEntrypointPath: string; }, -): void { +): () => void { let cachedStepTransformTargets: Set | null = null; const getStepTransformTargets = async (): Promise> => { @@ -493,15 +473,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 +509,8 @@ function addNitroStepTransformPlugin( name: "eve:workflow-step-transform", }); }); + + return clearCachedStepTransformTargets; } /** @@ -644,31 +621,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. - * `outputDir` lets callers stage those isolated builds into separate Nitro - * output roots before assembling the final hosted deployment. - */ -export async function createApplicationNitro( +function createApplicationNitroBundlerConfiguration( preparedHost: PreparedApplicationHost, - dev: boolean, - options: { - 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, ); @@ -705,21 +661,19 @@ export async function createApplicationNitro( preparedHost, configuredOptionalEnginePackages, ); - const nitroBuildDir = 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 +682,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 +700,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,43 +709,153 @@ 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-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 { + 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 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, +): 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 = createNitroArtifactsConfig({ - appRoot: preparedHost.appRoot, - dev: nitro.options.dev, - }); + const artifactsConfig = createProductionNitroArtifactsConfig(); registerScheduleTaskHandlers(nitro, { artifactsConfig, dispatchModulePath: resolvePackageSourceFilePath( @@ -849,12 +864,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/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 0bc67c8f3..09326e5fb 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 type { NitroArtifactsConfigInput } from "#internal/nitro/host/artifacts-config.js"; +import type { DevelopmentNitroArtifactsConfig } from "#internal/nitro/routes/runtime-artifacts.js"; import { createDiskRuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js"; import { createScheduleRegistrations } from "#runtime/schedules/register.js"; import { loadResolvedCompiledSchedules } from "#runtime/schedules/resolve-schedule.js"; @@ -57,11 +57,11 @@ export class UnknownDevScheduleError extends Error { * resolve relative to the app instead of the installed eve package. */ export async function dispatchScheduleInDev(input: { - readonly artifactsConfig: NitroArtifactsConfigInput; + readonly artifactsConfig: DevelopmentNitroArtifactsConfig; readonly scheduleId: string; }): Promise { const { appRoot, moduleMapLoaderPath } = input.artifactsConfig; - if (appRoot === undefined || moduleMapLoaderPath === undefined) { + if (!appRoot || !moduleMapLoaderPath) { throw new Error( 'Dev schedule dispatch requires "appRoot" and "moduleMapLoaderPath" in the artifacts config.', ); 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..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 @@ -5,12 +5,19 @@ 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, } 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(); @@ -25,11 +32,40 @@ async function readDevelopmentRuntimePointer(appRoot: string): Promise { +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: { + "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 prepareProductionApplicationHost(workspace); + + expect(preparedHost.compileResult.paths.compileDirectoryPath).toBe( + join(workspace.compiler.artifactsDir, "compile"), + ); + expect(preparedHost.compiledArtifacts.bootstrapPath).toBe( + join(workspace.host.artifactsDir, "compiled-artifacts-bootstrap.mjs"), + ); + expect(preparedHost.workflowBuildDir).toBe(workspace.workflow.buildDir); + 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("selects the Vercel Workflow world for a prebuilt production host", async () => { vi.stubEnv("VERCEL", "1"); vi.stubEnv("VERCEL_DEPLOYMENT_ID", ""); @@ -40,15 +76,20 @@ describe("prepareApplicationHost", () => { 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"); + 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 () => { @@ -61,7 +102,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"); @@ -86,7 +127,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 f29e3fff2..51d3842e0 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,16 @@ -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 } from "#runtime/schedules/resolve-schedule.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 { join } from "node:path"; import { type BuiltInWorkflowWorldTarget, writeCompiledArtifactsFiles, @@ -17,14 +27,12 @@ import { import type { PreparedApplicationHost } from "#internal/nitro/host/types.js"; /** - * Compiles one authored app and stages the package-owned artifacts needed by - * the Nitro host. + * 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 prepareApplicationHost( +export async function prepareDevelopmentApplicationHost( startPath: string, - options: { - readonly dev?: boolean; - } = {}, ): Promise { const compileResult = await compileAgent({ startPath, @@ -34,40 +42,77 @@ export async function prepareApplicationHost( compileResult.project.appRoot, ), }); - const scheduleRegistrations = createScheduleRegistrations(schedules); - const workflowBuildDir = resolveWorkflowBuildDirectory(compileResult.project.appRoot); - const runtimeArtifactsSnapshot = - options.dev === true - ? await stageDevelopmentRuntimeArtifactsSnapshot(compileResult) - : undefined; - const compiledArtifacts = await writeCompiledArtifactsFiles({ + const runtimeArtifactsSnapshot = await stageDevelopmentRuntimeArtifactsSnapshot(compileResult); + const preparedHost = await materializeApplicationHost({ compileResult, - defaultWorkflowWorld: resolveDefaultWorkflowWorld(options), - outDir: 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; +} + +/** + * 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 { + const compileResult = await compileAgentInBuildWorkspace({ + artifactLocations: { + publishedRoot: join(workspace.publication.output.finalDir, ".eve"), + writeRoot: workspace.compiler.artifactsDir, + }, + startPath: workspace.appRoot, + }); + const schedules = await resolveSchedules({ manifest: compileResult.manifest }); + + return await materializeApplicationHost({ compileResult, - compiledArtifacts, - scheduleRegistrations, + defaultWorkflowWorld: resolveProductionWorkflowWorldTarget(), + hostArtifactsDir: workspace.host.artifactsDir, schedules, - workflowBuildDir, + workflowBuildDir: workspace.workflow.buildDir, + }); +} + +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 resolveDefaultWorkflowWorld(options: { - readonly dev?: boolean; -}): BuiltInWorkflowWorldTarget { - if (options.dev === true) { - return "local"; +function resolveProductionWorkflowWorldTarget(): BuiltInWorkflowWorldTarget { + if (process.env.VERCEL) { + return "vercel"; } - return process.env.VERCEL ? "vercel" : "local"; + 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/host/start-development-server.test.ts b/packages/eve/src/internal/nitro/host/start-development-server.test.ts index dfa329e8b..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 })), @@ -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 ( @@ -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", () => ({ @@ -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", ); @@ -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 6442bb492..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,10 +6,10 @@ 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 { createNitroArtifactsConfig } from "#internal/nitro/host/artifacts-config.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 { 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({ @@ -475,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; 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/dev-schedule-dispatch.test.ts b/packages/eve/src/internal/nitro/routes/dev-schedule-dispatch.test.ts index bf1e9e2d1..f7ed24707 100644 --- a/packages/eve/src/internal/nitro/routes/dev-schedule-dispatch.test.ts +++ b/packages/eve/src/internal/nitro/routes/dev-schedule-dispatch.test.ts @@ -17,9 +17,10 @@ vi.mock("#internal/nitro/host/dispatch-schedule-in-dev.js", async () => { const APP_ROOT = "/tmp/eve-test"; const ARTIFACTS_CONFIG = { appRoot: APP_ROOT, - dev: true, + devRuntimeArtifactsPointerPath: "/tmp/eve-test/.eve/dev-runtime/latest.json", + kind: "development", moduleMapLoaderPath: "/tmp/eve-test/module-map-loader.js", -}; +} as const; async function importHandler() { return await import("#internal/nitro/routes/dev-schedule-dispatch.js"); diff --git a/packages/eve/src/internal/nitro/routes/dev-schedule-dispatch.ts b/packages/eve/src/internal/nitro/routes/dev-schedule-dispatch.ts index d4e0eb90c..3919eceef 100644 --- a/packages/eve/src/internal/nitro/routes/dev-schedule-dispatch.ts +++ b/packages/eve/src/internal/nitro/routes/dev-schedule-dispatch.ts @@ -2,7 +2,7 @@ import { dispatchScheduleInDev, UnknownDevScheduleError, } from "#internal/nitro/host/dispatch-schedule-in-dev.js"; -import type { NitroArtifactsConfigInput } from "#internal/nitro/host/artifacts-config.js"; +import type { DevelopmentNitroArtifactsConfig } from "#internal/nitro/routes/runtime-artifacts.js"; import { EVE_ROUTE_PREFIX } from "#protocol/routes.js"; /** @@ -30,7 +30,7 @@ const DEV_DISPATCH_SCHEDULE_PATH_PATTERN = new RegExp( * Auth: none. The dev server is local-only and the route is dev-only. */ export async function handleDevScheduleDispatchRequest( - input: NitroArtifactsConfigInput, + input: DevelopmentNitroArtifactsConfig, request: Request, ): Promise { const url = new URL(request.url); 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-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.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..3aac1c5e9 100644 --- a/packages/eve/src/internal/workflow-bundle/builder.ts +++ b/packages/eve/src/internal/workflow-bundle/builder.ts @@ -11,8 +11,9 @@ 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 } from "#shared/atomic-write-file.js"; import { - atomicWriteFile, bundleFinalWorkflowOutput, collectWorkflowInputFiles, convertClassesManifest, @@ -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/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.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 new file mode 100644 index 000000000..87b8f0844 --- /dev/null +++ b/packages/eve/src/shared/atomic-write-file.ts @@ -0,0 +1,48 @@ +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, + contents: string | Buffer | Uint8Array, +): Promise { + const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now().toString(36)}`; + await writeFile(tmpPath, contents); + 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") + ); +} 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/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 { 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/compile-agent.scenario.test.ts b/packages/eve/test/scenarios/compile-agent.scenario.test.ts index 88793e647..b9f76d88f 100644 --- a/packages/eve/test/scenarios/compile-agent.scenario.test.ts +++ b/packages/eve/test/scenarios/compile-agent.scenario.test.ts @@ -129,6 +129,10 @@ describe("compiler artifacts", () => { const writtenArtifacts = await writeCompilerArtifacts({ appRoot, + artifactLocations: { + publishedRoot: join(appRoot, ".eve"), + writeRoot: join(appRoot, ".eve"), + }, diagnostics: [ createDiscoverWarningDiagnostic({ code: "discover/unsupported-directory", @@ -391,6 +395,10 @@ describe("compiler artifacts", () => { const writtenArtifacts = await writeCompilerArtifacts({ appRoot, + 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 new file mode 100644 index 000000000..4587148c3 --- /dev/null +++ b/packages/eve/test/scenarios/production-build-isolation.scenario.test.ts @@ -0,0 +1,632 @@ +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, + rename, + utimes, + 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"; + +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; + } +} + +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", + 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, + ); + + 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 () => { + 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, + ); +}); diff --git a/research/dev-revision-isolation.md b/research/dev-revision-isolation.md new file mode 100644 index 000000000..75ff48a71 --- /dev/null +++ b/research/dev-revision-isolation.md @@ -0,0 +1,179 @@ +--- +issue: TBD +status: in-review +last_updated: "2026-07-13" +--- + +# Dev generation isolation + +## Summary + +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. + +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 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 + +### 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 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 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 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 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 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 or Workflow queue endpoint +unavailable. It also makes cancellation and shutdown ownership unclear. + +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. + +### Parked sessions and active turns need different lifetimes + +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. + +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 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, 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 + +```text +authored edit + │ + ▼ +immutable generation ── runtime-only ─────────► publish latest pointer + │ + └── structural ─► ready Nitro candidate ─► atomically swap pointer, routes, and worker + +stable parent server + ├── 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 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. + +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 + +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 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, + 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. **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. + +## User-visible result + +- Concurrent production builds cannot interfere with each other or a running dev server. +- 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 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 and Workflow queue endpoints remain available during reload. +- Channel changes preserve the route Nitro selected, including overlapping routes. +- 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. +- 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.