diff --git a/.changeset/local-tui-status-address.md b/.changeset/local-tui-status-address.md new file mode 100644 index 000000000..65e5b298e --- /dev/null +++ b/.changeset/local-tui-status-address.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +The local `eve dev` status bar now shows a gray `:port` badge and retains it as terminal width narrows. Status segments now use tighter spacing. diff --git a/.changeset/reconnect-dev-tui.md b/.changeset/reconnect-dev-tui.md new file mode 100644 index 000000000..cc91926ed --- /dev/null +++ b/.changeset/reconnect-dev-tui.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Running `eve dev` interactively now reconnects to the healthy loopback dev server recorded for the same app root, with a fresh session for each attached terminal UI. Eve replaces stale or malformed state when it starts a new server. `--host`, `--port`, or `PORT` skips reconnection and reports a healthy recorded server instead. diff --git a/docs/guides/dev-tui.md b/docs/guides/dev-tui.md index e07663dd0..702fa38cb 100644 --- a/docs/guides/dev-tui.md +++ b/docs/guides/dev-tui.md @@ -24,7 +24,7 @@ The conversation streams straight into your terminal's normal scrollback, so you Each turn renders without boxes. A colored gutter glyph marks who is speaking, tool calls collapse to a one-line summary (`✓ get_weather city="SF" → 73°F`), and a subagent's work is indented beneath its `◆` header. When input is ready, the prompt stays bare until you type. A green circle-dot pulses while the agent is waiting to answer and disappears when reasoning or answer content begins. -A persistent line beneath the prompt or status shows the model, the session's token flow (`↑ 394.4K ↓ 4.3K`), the linked Vercel project, and a yellow `/deploy pending` marker once a channel added this session still needs `/deploy`. The Vercel segment stays hidden until the directory is linked. Remote sessions lead with a padded `↗ project (environment)` badge, or the host when Vercel cannot resolve the deployment. The badge is gray while checking or unavailable, yellow while authentication is required or failed, and blue when connected. Remote status lines omit AI Gateway endpoint state. +A persistent line beneath the prompt or status shows the model, the session's token flow (`↑ 394.4K ↓ 4.3K`), the linked Vercel project, and a yellow `/deploy pending` marker once a channel added this session still needs `/deploy`. The Vercel segment stays hidden until the directory is linked. Local sessions lead with a gray `:port` badge. Remote sessions lead with a padded `↗ project (environment)` badge, or the host when Vercel cannot resolve the deployment. The badge is gray while checking or unavailable, yellow while authentication is required or failed, and blue when connected. Other status segments use `·` separators. Remote status lines omit AI Gateway endpoint state. Errors render compactly with docs links highlighted. A code bug escaping your agent's own code shows its stack trace dim beneath the error headline. Dev-server rebuilds condense into one status row that updates in place (`tui/setup-panel.ts changed · rebuilding…`, then `· rebuilt`); only the latest rebuild shows, and paths shrink to their last two components. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 853491d4e..c6e5c36ff 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -109,7 +109,7 @@ Pass a bare URL as the only argument and the UI connects to that server instead | `--context-size ` | number | none | Model context window size, shown as a usage percentage | | `--logs ` | enum | `stderr` | Server/agent logs to show: `all` \| `stderr` \| `sandbox` \| `none` | -Local dev writes the active server process ID to `.eve/dev-process.pid`. If another `eve dev` starts for the same agent while that process is still running, eve exits with a message that includes the command to stop the existing server. +Local dev records the last ready URL per resolved app root in `.eve/dev-server-state.v1.json`. A second interactive `eve dev` reconnects only when that URL is loopback and healthy; each terminal UI creates a fresh client session while sharing the server process. A stale or malformed record is replaced when eve starts a new server. Passing `--host`, `--port`, or a `PORT` environment value skips reconnection and reports a healthy recorded server instead. Local dev keeps immutable runtime source snapshots under `.eve/dev-runtime/snapshots/` so in-flight sessions hold a consistent code revision while new prompts pick up rebuilds. On startup, `eve dev` prunes stale runtime snapshots and old local sandbox templates in the background. For manual cleanup, stop `eve dev` and delete `.eve/dev-runtime/snapshots/` or `.eve/sandbox-cache/local/templates/`. diff --git a/packages/eve/src/cli/dev/tui/runner.test.ts b/packages/eve/src/cli/dev/tui/runner.test.ts index 74b621658..8cd47c4df 100644 --- a/packages/eve/src/cli/dev/tui/runner.test.ts +++ b/packages/eve/src/cli/dev/tui/runner.test.ts @@ -23,6 +23,7 @@ import { } from "./runner.js"; import { createPromptCommandHandler } from "./prompt-command-handler.js"; import { promptCommandsFor } from "./prompt-commands.js"; +import { interruptedError } from "./errors.js"; import type { RemoteAuthFlow } from "./remote-auth.js"; import type { RemoteAuthCompletedMutation } from "./remote-auth-result.js"; import type { RemoteConnectionControllerOptions } from "./remote-connection.js"; @@ -1504,6 +1505,66 @@ describe("EveTUIRunner renderer teardown", () => { expect(shutdown).toHaveBeenCalledTimes(1); }); + + it("aborts child-session streams when Ctrl-C exits the runner", async () => { + const client = stubClient(); + const childSession = client.session({ sessionId: "child-session", streamIndex: 0 }); + let childSignal: AbortSignal | undefined; + vi.spyOn(client, "session").mockReturnValue(childSession); + vi.spyOn(childSession, "stream").mockImplementation((options) => { + const signal = options?.signal; + if (signal === undefined) { + throw new Error("Expected the child stream to receive an abort signal."); + } + childSignal = signal; + return { + async *[Symbol.asyncIterator]() { + await new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + yield { + type: "session.waiting", + data: { wait: "next-user-message" }, + } as HandleMessageStreamEvent; + }, + }; + }); + + const runner = new EveTUIRunner({ + client, + name: "Weather Agent", + renderer: fakeRenderer({ + readPrompt: vi + .fn() + .mockResolvedValueOnce("delegate") + .mockRejectedValueOnce(interruptedError()), + renderStream: vi.fn(async (result) => { + for await (const event of result.events as AsyncIterable) void event; + }), + }), + session: sessionYielding([ + { + type: "subagent.called", + data: { + callId: "call-child", + childSessionId: "child-session", + name: "weather-child", + sequence: 0, + sessionId: "parent-session", + toolName: "delegate_weather", + turnId: "turn-parent", + workflowId: "workflow-parent", + }, + }, + { type: "turn.completed", data: { sequence: 0, turnId: "turn-parent" } }, + { type: "session.waiting", data: { wait: "next-user-message" } }, + ]), + }); + + await runner.run(); + + expect(childSignal?.aborted).toBe(true); + }); }); describe("EveTUIRunner Vercel status line", () => { @@ -1536,7 +1597,9 @@ describe("EveTUIRunner Vercel status line", () => { await runner.run(); expect(pushes).toEqual([{ identity, pendingDeploy: false }]); - expect(detectIdentity).toHaveBeenCalledWith("/tmp/weather-agent"); + expect(detectIdentity).toHaveBeenCalledWith("/tmp/weather-agent", { + signal: expect.any(AbortSignal), + }); }); it("applies command effects: channels mark pending, deploy clears and re-probes", async () => { diff --git a/packages/eve/src/cli/dev/tui/runner.ts b/packages/eve/src/cli/dev/tui/runner.ts index 69b2d4bd1..2a14e9190 100644 --- a/packages/eve/src/cli/dev/tui/runner.ts +++ b/packages/eve/src/cli/dev/tui/runner.ts @@ -444,8 +444,8 @@ export class EveTUIRunner { readonly #subagentRuns = new Map(); /** * callId → AbortController for the parallel child-session stream pump - * launched on `subagent.called`. Cancelled on `subagent.completed` or - * when the runner shuts down. + * launched on `subagent.called`. Cancelled on `subagent.completed`, when + * the session resets, or when the runner shuts down. */ readonly #subagentChildPumps = new Map(); /** @@ -587,6 +587,7 @@ export class EveTUIRunner { } finally { this.#disposed = true; this.#authProbeAbort.abort(); + this.#abortSubagentChildPumps(); // Restore captured stdout/stderr before a fatal error reaches the CLI. this.#unsubscribeDevelopmentSandboxLogs?.(); this.#unsubscribeDevelopmentSandboxLogs = undefined; @@ -819,10 +820,7 @@ export class EveTUIRunner { * In-flight subagent child-session streams are aborted. */ #startNewSession(): void { - for (const controller of this.#subagentChildPumps.values()) { - controller.abort(); - } - this.#subagentChildPumps.clear(); + this.#abortSubagentChildPumps(); this.#subagentRuns.clear(); this.#pendingInputRequests.clear(); this.#connectionAuthRuns.clear(); @@ -834,6 +832,13 @@ export class EveTUIRunner { this.#runtimeArtifacts?.clear(); } + #abortSubagentChildPumps(): void { + for (const controller of this.#subagentChildPumps.values()) { + controller.abort(); + } + this.#subagentChildPumps.clear(); + } + async #readPromptWithIdleRefresh(options: AgentTUISessionOptions): Promise { if (!this.#renderer.readPrompt) { return undefined; diff --git a/packages/eve/src/cli/dev/tui/status-line.test.ts b/packages/eve/src/cli/dev/tui/status-line.test.ts index 8282ef1aa..ffa7b433c 100644 --- a/packages/eve/src/cli/dev/tui/status-line.test.ts +++ b/packages/eve/src/cli/dev/tui/status-line.test.ts @@ -37,6 +37,19 @@ function deployedRemote( } describe("buildStatusLine", () => { + it("leads local sessions with a gray colon-prefixed port badge", () => { + const input = { + serverPort: "3000", + model: "openai/gpt-5.5", + } as const; + + const line = buildStatusLine({ ...input, theme, width: 120 })!; + expect(stripAnsi(line)).toBe(" :3000 openai/gpt-5.5"); + expect(buildStatusLine({ ...input, theme: plain, width: " :3000 ".length })).toBe(" :3000 "); + expect(line).toContain("\x1b[7m\x1b[90m :3000 \x1b[39m\x1b[27m"); + expect(line).not.toContain("\x1b[7m\x1b[34m :3000 "); + }); + it("renders all segments in order with dot separators", () => { const line = buildStatusLine({ model: "anthropic/claude-sonnet-4-6", @@ -48,7 +61,7 @@ describe("buildStatusLine", () => { }); expect(line).toBe( - "anthropic/claude-sonnet-4-6 · 12,300 tokens 6% · AI Gateway (my-agent) · /deploy pending", + "anthropic/claude-sonnet-4-6 · 12,300 tokens 6% · AI Gateway (my-agent) · /deploy pending", ); }); @@ -60,7 +73,7 @@ describe("buildStatusLine", () => { theme: plain, width: 120, }), - ).toBe(" ↗ vpoke.playground-vercel.tools · openai/gpt-5"); + ).toBe(" ↗ vpoke.playground-vercel.tools openai/gpt-5"); }); it("dims every segment except the yellow pending-deploy marker", () => { @@ -85,7 +98,7 @@ describe("buildStatusLine", () => { theme: plain, width: 120, }); - expect(withProject).toBe("m · AI Gateway (my-agent)"); + expect(withProject).toBe("m · AI Gateway (my-agent)"); // Connected without a linked project (a raw key): bare "AI Gateway". const noProject = buildStatusLine({ @@ -94,7 +107,7 @@ describe("buildStatusLine", () => { theme: plain, width: 120, }); - expect(noProject).toBe("m · AI Gateway"); + expect(noProject).toBe("m · AI Gateway"); }); it("renders the pending marker even when no segment else resolved", () => { @@ -117,7 +130,7 @@ describe("buildStatusLine", () => { } as const; const full = buildStatusLine({ ...input, width: 120 })!; - expect(full.startsWith("logs: sandbox · ")).toBe(true); + expect(full.startsWith("logs: sandbox · ")).toBe(true); // Narrow enough that only the leading hint survives. expect(buildStatusLine({ ...input, width: 13 })).toBe("logs: sandbox"); @@ -150,7 +163,7 @@ describe("buildStatusLine", () => { expect(noEndpoint).toContain("anthropic/claude-sonnet-4-6"); const noModel = buildStatusLine({ ...input, width: visibleLength(noEndpoint) - 1 })!; - expect(noModel).toBe("12,300 tokens · /deploy pending"); + expect(noModel).toBe("12,300 tokens · /deploy pending"); }); it("renders the three model-endpoint states", () => { @@ -160,7 +173,7 @@ describe("buildStatusLine", () => { theme: plain, width: 120, }); - expect(external).toBe("anthropic/claude-sonnet-4-6 · External endpoint"); + expect(external).toBe("anthropic/claude-sonnet-4-6 · External endpoint"); const linked = buildStatusLine({ model: "m", @@ -169,7 +182,7 @@ describe("buildStatusLine", () => { theme: plain, width: 120, }); - expect(linked).toBe("m · AI Gateway (my-agent)"); + expect(linked).toBe("m · AI Gateway (my-agent)"); const notConnected = buildStatusLine({ model: "m", @@ -177,7 +190,7 @@ describe("buildStatusLine", () => { theme: plain, width: 120, }); - expect(notConnected).toBe("m · ⚠ AI Gateway"); + expect(notConnected).toBe("m · ⚠ AI Gateway"); }); it("paints only the not-connected endpoint yellow", () => { @@ -203,7 +216,7 @@ describe("buildStatusLine", () => { theme: ascii, width: 120, }); - expect(stripAnsi(line!)).toBe("m - ! AI Gateway"); + expect(stripAnsi(line!)).toBe("m - ! AI Gateway"); }); it("renders the remote badge first and projects each authentication state", () => { @@ -213,7 +226,7 @@ describe("buildStatusLine", () => { theme: plain, width: 120, }), - ).toBe(" ↗ vpoke.playground-vercel.tools · Checking access…"); + ).toBe(" ↗ vpoke.playground-vercel.tools · Checking access…"); expect( buildStatusLine({ remote: remote({ @@ -223,7 +236,7 @@ describe("buildStatusLine", () => { theme: plain, width: 120, }), - ).toBe(" ↗ vpoke.playground-vercel.tools · Authenticate via OIDC"); + ).toBe(" ↗ vpoke.playground-vercel.tools · Authenticate via OIDC"); expect( buildStatusLine({ remote: remote({ @@ -233,7 +246,7 @@ describe("buildStatusLine", () => { theme: plain, width: 120, }), - ).toBe(" ↗ vpoke.playground-vercel.tools · Authenticating via OIDC…"); + ).toBe(" ↗ vpoke.playground-vercel.tools · Authenticating via OIDC…"); expect( buildStatusLine({ remote: remote({ @@ -243,7 +256,7 @@ describe("buildStatusLine", () => { theme: plain, width: 120, }), - ).toBe(" ↗ vpoke.playground-vercel.tools · Authentication failed"); + ).toBe(" ↗ vpoke.playground-vercel.tools · Authentication failed"); expect( buildStatusLine({ remote: remote({ @@ -253,7 +266,7 @@ describe("buildStatusLine", () => { theme: plain, width: 120, }), - ).toBe(" ↗ vpoke.playground-vercel.tools · Remote unavailable"); + ).toBe(" ↗ vpoke.playground-vercel.tools · Remote unavailable"); expect( buildStatusLine({ remote: deployedRemote({ state: "ready", info: {} as never }), @@ -343,6 +356,6 @@ describe("buildStatusLine", () => { theme: ascii, width: 120, }); - expect(line).toBe(" -> vpoke.playground-vercel.tools - Checking access…"); + expect(line).toBe(" -> vpoke.playground-vercel.tools - Checking access…"); }); }); diff --git a/packages/eve/src/cli/dev/tui/status-line.ts b/packages/eve/src/cli/dev/tui/status-line.ts index b49c70c60..46d274e3a 100644 --- a/packages/eve/src/cli/dev/tui/status-line.ts +++ b/packages/eve/src/cli/dev/tui/status-line.ts @@ -7,6 +7,8 @@ import type { VercelStatusSnapshot } from "./vercel-status.js"; import type { ModelEndpointStatus } from "#shared/model-endpoint-status.js"; export interface StatusLineInput { + /** Port of the connected local development server; omitted for remote sessions. */ + serverPort?: string; /** Resolved model slug, e.g. "anthropic/claude-sonnet-4-6"; absent when `/eve/v1/info` failed. */ model?: string; /** Preformatted token-flow segment (formatTokenFlow output), e.g. `↑ 394.4K ↓ 4.3K`. */ @@ -37,6 +39,14 @@ function renderModel( return input.theme.colors.dim(model); } +function renderServerPort( + input: Pick, +): string | undefined { + if (input.remote !== undefined || input.serverPort === undefined) return undefined; + const c = input.theme.colors; + return c.inverse(c.gray(` :${input.serverPort} `)); +} + function renderEndpoint( input: Pick, ): string | undefined { @@ -51,35 +61,45 @@ function renderEndpoint( } /** - * Builds `↗ project (environment) · model · tokens · /deploy pending`. - * Remote sessions omit endpoint state and keep their badge as the final - * narrow-width fallback. Returns undefined when every segment is empty. + * Builds a leading local `:port` or remote badge followed by model, token, and + * deploy status segments. Both badges are the final narrow-width fallback. + * Remote sessions omit endpoint state. Returns undefined when every segment is + * empty. */ export function buildStatusLine(input: StatusLineInput): string | undefined { const { theme, width } = input; const c = theme.colors; const logLevel = input.logLevel === undefined ? undefined : c.cyan(`logs: ${input.logLevel}`); + const serverPort = renderServerPort(input); const model = renderModel(input); const tokens = input.tokens === undefined ? undefined : c.dim(input.tokens); const pending = input.vercel?.pendingDeploy ? c.yellow("/deploy pending") : undefined; const remote = input.remote === undefined ? undefined : formatRemoteStatus(input.remote, theme); const endpoint = renderEndpoint(input); + const leading = remote?.full ?? serverPort; + const badge = remote?.badge ?? serverPort; - const separator = ` ${c.dim(theme.glyph.dot)} `; - const compose = (segments: ReadonlyArray): string => - segments.filter((segment) => segment !== undefined).join(separator); + const separator = ` ${c.dim(theme.glyph.dot)} `; + const compose = ( + target: string | undefined, + segments: ReadonlyArray, + ): string => { + const body = segments.filter((segment) => segment !== undefined).join(separator); + if (target === undefined || body.length === 0) return target ?? body; + return `${target} ${body}`; + }; - // Descending fidelity; the first variant that fits wins. The remote target - // leads every variant and gets the final stand-alone fallback. Without a - // remote, the logs hint retains its previous priority. + // Descending fidelity; the first variant that fits wins. The server badge + // leads every variant and gets the final stand-alone fallback. Without one, + // the logs hint retains its previous priority. const variants = [ - compose([remote?.full, logLevel, model, tokens, endpoint, pending]), - compose([remote?.full, logLevel, model, tokens, pending]), - compose([remote?.full, logLevel, tokens, pending]), - compose([remote?.full, logLevel]), - compose([remote?.badge, logLevel]), - compose([remote?.badge]), + compose(leading, [logLevel, model, tokens, endpoint, pending]), + compose(leading, [logLevel, model, tokens, pending]), + compose(leading, [logLevel, tokens, pending]), + compose(leading, [logLevel]), + compose(badge, [logLevel]), + compose(badge, []), ]; if (variants[0]!.length === 0) return undefined; @@ -102,7 +122,7 @@ function formatRemoteStatus( : `${snapshot.deployment.projectName} (${snapshot.deployment.environment})`; const arrow = theme.unicode ? "↗" : "->"; const badge = formatRemoteBadge(` ${arrow} ${label} `, snapshot.connection.state, theme); - const separator = ` ${c.dim(theme.glyph.dot)} `; + const separator = `${c.dim(theme.glyph.dot)} `; let suffix: string | undefined; switch (snapshot.connection.state) { diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts index b9dcae635..6ec11b6de 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -2706,7 +2706,7 @@ describe("TerminalRenderer status line", () => { pendingDeploy: false, }; - it("renders model and Vercel link under the prompt row", async () => { + it("renders the local server, model, and Vercel link under the prompt row", async () => { const { screen, input, renderer } = makeRenderer(); renderer.renderAgentHeader({ name: "Weather Agent", @@ -2736,7 +2736,11 @@ describe("TerminalRenderer status line", () => { const promptRow = lines.findIndex((line) => line.includes("❯")); expect(promptRow).toBeGreaterThan(-1); const statusRow = lines.slice(promptRow + 1).join("\n"); + expect(statusRow).toContain(":3000"); expect(statusRow).toContain("anthropic/claude-sonnet-4-6"); + expect(statusRow.indexOf(":3000")).toBeLessThan( + statusRow.indexOf("anthropic/claude-sonnet-4-6"), + ); // The linked project folds into the connected gateway label. expect(statusRow).toContain("AI Gateway (my-agent)"); expect(statusRow).not.toContain("⚠ AI Gateway"); @@ -2815,7 +2819,7 @@ describe("TerminalRenderer status line", () => { "", " Select your team", ]); - const status = lines.indexOf(" ↗ vpoke.playground-vercel.tools · Authenticating via OIDC…"); + const status = lines.indexOf(" ↗ vpoke.playground-vercel.tools · Authenticating via OIDC…"); expect(status).toBeGreaterThan(title); expect(lines[status - 1]).toBe(""); diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.ts index 4b8edc955..8f206d1a3 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.ts @@ -2809,10 +2809,7 @@ export class TerminalRenderer implements AgentTUIRenderer { return rows; } - /** - * Appends the persistent bottom status line (model · tokens · Vercel link · - * pending deploy) when any segment has content. - */ + /** Appends the persistent bottom status line below the prompt when it has content. */ #pushStatusLine(rows: string[], width: number): void { const padding = this.#remoteConnection === undefined ? "" : STATUS_LINE_LEFT_PADDING; const contentWidth = Math.max(1, width - padding.length); @@ -2821,6 +2818,11 @@ export class TerminalRenderer implements AgentTUIRenderer { width: contentWidth, }; if (this.#logLevelHintActive) input.logLevel = this.#logs; + const serverUrl = this.#agentHeader?.serverUrl; + if (serverUrl !== undefined && this.#remoteConnection === undefined) { + const serverPort = new URL(serverUrl).port; + if (serverPort.length > 0) input.serverPort = serverPort; + } const model = this.#agentHeader?.info?.agent.model.id; if (model !== undefined) input.model = model; // The runner resolves model-provider state with `/info` before caching this diff --git a/packages/eve/src/cli/dev/tui/tui.test.ts b/packages/eve/src/cli/dev/tui/tui.test.ts new file mode 100644 index 000000000..6a13f87f7 --- /dev/null +++ b/packages/eve/src/cli/dev/tui/tui.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { EveTUIRunnerOptions } from "./runner.js"; + +const mocks = vi.hoisted<{ runnerOptions: EveTUIRunnerOptions[] }>(() => ({ + runnerOptions: [], +})); + +vi.mock("./runner.js", () => ({ + EveTUIRunner: class { + constructor(options: EveTUIRunnerOptions) { + mocks.runnerOptions.push(options); + } + + async run(): Promise {} + }, +})); + +import { runDevelopmentTui, type DevelopmentTuiTarget } from "./tui.js"; + +describe("runDevelopmentTui", () => { + beforeEach(() => { + mocks.runnerOptions.length = 0; + }); + + it("creates a fresh client session for every TUI attached to the same server", async () => { + const target = { + kind: "local", + serverUrl: "http://127.0.0.1:4321/", + workspaceRoot: "/tmp/app", + } satisfies DevelopmentTuiTarget; + await runDevelopmentTui({ target }); + await runDevelopmentTui({ target }); + + expect(mocks.runnerOptions).toHaveLength(2); + const [first, second] = mocks.runnerOptions; + if (first === undefined || second === undefined) { + throw new Error("Expected two TUI runner invocations."); + } + expect(first.client).not.toBe(second.client); + expect(first.session).not.toBe(second.session); + }); +}); diff --git a/packages/eve/src/cli/dev/tui/vercel-status.test.ts b/packages/eve/src/cli/dev/tui/vercel-status.test.ts index a23c770c9..57eab4b5b 100644 --- a/packages/eve/src/cli/dev/tui/vercel-status.test.ts +++ b/packages/eve/src/cli/dev/tui/vercel-status.test.ts @@ -149,4 +149,22 @@ describe("createVercelStatusTracker", () => { expect(snapshots).toEqual([]); }); + + it("aborts an in-flight identity probe when disposed", () => { + let probeSignal: AbortSignal | undefined; + const tracker = createVercelStatusTracker({ + appRoot: "/app", + onChange: () => {}, + detectIdentity: (_appRoot, options) => { + probeSignal = options?.signal; + return new Promise(() => {}); + }, + }); + + tracker.refreshIdentity(); + + expect(probeSignal).toBeDefined(); + tracker.dispose(); + expect(probeSignal?.aborted).toBe(true); + }); }); diff --git a/packages/eve/src/cli/dev/tui/vercel-status.ts b/packages/eve/src/cli/dev/tui/vercel-status.ts index dec1aa80d..994c089bf 100644 --- a/packages/eve/src/cli/dev/tui/vercel-status.ts +++ b/packages/eve/src/cli/dev/tui/vercel-status.ts @@ -37,11 +37,11 @@ export interface VercelStatusTrackerOptions { * which hides the segment. */ export interface VercelStatusTracker { - /** Fire-and-forget identity re-probe; stale resolutions are discarded. */ + /** Fire-and-forget identity re-probe; superseded probes are aborted. */ refreshIdentity(): void; applyEffect(effect: VercelStatusEffect): void; current(): VercelStatusSnapshot; - /** Stops future onChange emissions; in-flight probe results are dropped. */ + /** Stops future changes and aborts the in-flight identity probe. */ dispose(): void; } @@ -57,6 +57,7 @@ export function createVercelStatusTracker( // overwrite the newer result. let epoch = 0; let disposed = false; + let identityProbeAbort: AbortController | undefined; const snapshot = (): VercelStatusSnapshot => { const current: VercelStatusSnapshot = { pendingDeploy }; @@ -70,16 +71,24 @@ export function createVercelStatusTracker( }; const refreshIdentity = (): void => { + if (disposed) return; + identityProbeAbort?.abort(); + const probeAbort = new AbortController(); + identityProbeAbort = probeAbort; epoch += 1; const probeEpoch = epoch; void (async () => { let resolved: ProjectIdentity | undefined; try { - resolved = await detectIdentity(options.appRoot); + resolved = await detectIdentity(options.appRoot, { signal: probeAbort.signal }); } catch { // detectProjectIdentity never throws today; if a future change does, // keep the last known identity rather than killing the prompt loop. return; + } finally { + if (identityProbeAbort === probeAbort) { + identityProbeAbort = undefined; + } } if (disposed || probeEpoch !== epoch) return; identity = resolved; @@ -90,6 +99,7 @@ export function createVercelStatusTracker( return { refreshIdentity, applyEffect(effect) { + if (disposed) return; switch (effect.kind) { case "channels-added": pendingDeploy = true; @@ -109,8 +119,11 @@ export function createVercelStatusTracker( }, current: snapshot, dispose() { + if (disposed) return; disposed = true; epoch += 1; + identityProbeAbort?.abort(); + identityProbeAbort = undefined; }, }; } diff --git a/packages/eve/src/cli/run.test.ts b/packages/eve/src/cli/run.test.ts index 218a88ef4..2a3c4f104 100644 --- a/packages/eve/src/cli/run.test.ts +++ b/packages/eve/src/cli/run.test.ts @@ -163,12 +163,19 @@ describe("eve dev boot progress", () => { const close = vi.fn(async () => {}); let hostReporter: DevelopmentServerOptions["onBootProgress"] = undefined; let tuiReporter: RunDevelopmentTuiInput["onBootProgress"] = undefined; - const startHost = vi.fn(async (_appRoot: string, options?: DevelopmentServerOptions) => { - hostReporter = options?.onBootProgress; - hostReporter?.({ phase: "compiling agent", type: "phase-started" }); - hostReporter?.({ elapsedMs: 1, phase: "compiling agent", type: "phase-finished" }); - return { close, url: "http://127.0.0.1:2000" }; - }); + const startHost = vi.fn((_appRoot: string, options?: DevelopmentServerOptions) => ({ + start: async () => { + hostReporter = options?.onBootProgress; + hostReporter?.({ phase: "compiling agent", type: "phase-started" }); + hostReporter?.({ elapsedMs: 1, phase: "compiling agent", type: "phase-finished" }); + return { + kind: "started" as const, + appRoot: "/canonical/app", + url: "http://127.0.0.1:2000", + }; + }, + close, + })); const runDevelopmentTui = vi.fn(async (input: RunDevelopmentTuiInput) => { tuiReporter = input.onBootProgress; throw new Error("TUI startup failed"); @@ -195,6 +202,62 @@ describe("eve dev boot progress", () => { }); }); +describe("eve dev local server ownership", () => { + it("uses the host's canonical root and leaves an attached server running", async () => { + const startHost = vi.fn(() => ({ + start: async () => ({ + kind: "existing" as const, + appRoot: "/canonical/app", + url: "http://127.0.0.1:4321/", + }), + close: async () => {}, + })); + const runDevelopmentTui = vi.fn(async () => {}); + + await withInteractiveTerminal(() => + runCli(["dev"], { error: () => {}, log: () => {} }, { runDevelopmentTui, startHost }), + ); + + expect(startHost).toHaveBeenCalledWith(expect.any(String), { + existing: "attach-if-unconfigured", + host: undefined, + onBootProgress: expect.any(Function), + port: undefined, + }); + expect(runDevelopmentTui).toHaveBeenCalledWith( + expect.objectContaining({ + name: "App", + target: { + kind: "local", + serverUrl: "http://127.0.0.1:4321/", + workspaceRoot: "/canonical/app", + }, + }), + ); + }); + + it("closes a server started for the interactive TUI", async () => { + const close = vi.fn(async () => {}); + const startHost = vi.fn(() => ({ + start: async () => ({ + kind: "started" as const, + appRoot: "/canonical/app", + url: "http://127.0.0.1:4321/", + }), + close, + })); + + await withInteractiveTerminal(() => + runCli( + ["dev"], + { error: () => {}, log: () => {} }, + { runDevelopmentTui: vi.fn(async () => {}), startHost }, + ), + ); + expect(close).toHaveBeenCalledOnce(); + }); +}); + describe("resolveDevUiMode", () => { it("defaults to the terminal UI in an interactive terminal", () => { expect(resolveDevUiMode({ options: {}, interactive: true })).toBe("tui"); diff --git a/packages/eve/src/cli/run.ts b/packages/eve/src/cli/run.ts index 5cf53e378..61a00f1e7 100644 --- a/packages/eve/src/cli/run.ts +++ b/packages/eve/src/cli/run.ts @@ -13,7 +13,7 @@ import { startCliLiveRow } from "#cli/ui/live-row.js"; import { createCliTheme, renderCliTaggedLine } from "#cli/ui/output.js"; import { createLogger } from "#internal/logging.js"; import type { - DevelopmentServerHandle, + DevelopmentServer, DevelopmentServerOptions, ProductionServerHandle, } from "#internal/nitro/host/types.js"; @@ -64,7 +64,7 @@ interface CliRuntimeDependencies { options: EvalCliOptions, logger: CliLogger, ): Promise; - startHost(appRoot: string, options?: DevelopmentServerOptions): Promise; + startHost(appRoot: string, options?: DevelopmentServerOptions): DevelopmentServer; startProductionHost( appRoot: string, options?: { @@ -131,7 +131,7 @@ async function loadRunEvalCommand(): Promise { - return (await import("#internal/nitro/host.js")).startDevelopmentServer; + return (await import("#internal/nitro/host.js")).createDevelopmentServer; } async function loadStartProductionHost(): Promise { @@ -487,11 +487,13 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm if (options.input !== undefined && mode === "headless") { throw new InvalidArgumentError("--input requires the interactive UI."); } - const { loadDevelopmentEnvironmentFiles } = await import("#cli/dev/environment.js"); - - loadDevelopmentEnvironmentFiles(appRoot); - - const runInteractiveUi = async (serverUrl: string, report?: DevBootProgressReporter) => { + const runInteractiveUi = async ( + input: { + readonly appRoot?: string; + readonly serverUrl: string; + }, + report?: DevBootProgressReporter, + ): Promise => { const runDevelopmentTui = await devBootPhase( "loading interactive UI", async () => runtime.runDevelopmentTui ?? (await loadRunDevelopmentTui()), @@ -500,8 +502,12 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm const display = resolveTuiDisplayOptions(options); const target: DevelopmentTuiTarget = remoteServerUrl === undefined - ? { kind: "local", serverUrl, workspaceRoot: appRoot } - : { kind: "remote", serverUrl, workspaceRoot: appRoot }; + ? { + kind: "local", + serverUrl: input.serverUrl, + workspaceRoot: input.appRoot ?? appRoot, + } + : { kind: "remote", serverUrl: input.serverUrl, workspaceRoot: appRoot }; const title = resolveTuiTitle({ name: options.name, target }); if (title !== undefined) display.name = title; const tuiInput: RunDevelopmentTuiInput = { @@ -514,6 +520,8 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm }; if (remoteServerUrl) { + const { loadDevelopmentEnvironmentFiles } = await import("#cli/dev/environment.js"); + loadDevelopmentEnvironmentFiles(appRoot); logger.log(`↗ remote mode targeting ${theme.info(new URL(remoteServerUrl).host)}`); if (mode === "headless") { @@ -528,7 +536,7 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm } logger.log(""); - await runInteractiveUi(remoteServerUrl); + await runInteractiveUi({ serverUrl: remoteServerUrl }); return; } @@ -539,23 +547,26 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm buildProgress?.update("Building your agent"); let closed = false; - let server: DevelopmentServerHandle | undefined; + let server: DevelopmentServer | undefined; const closeServer = async () => { if (closed || server === undefined) { return; } closed = true; + // No-op when this instance attached to a server another process owns. await server.close(); }; try { const startHost = runtime.startHost ?? (await loadStartHost()); - server = await startHost(appRoot, { + server = startHost(appRoot, { + existing: mode === "tui" ? "attach-if-unconfigured" : "reject", host: options.host, onBootProgress, port: options.port, }); + const handle = await server.start(); // The terminal UI's header already shows the server URL, and startup // no longer clears the screen, so the line would linger as noise. @@ -563,7 +574,7 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm if (mode !== "tui") { logger.log( renderCliTaggedLine(theme, { - message: `server listening at ${server.url}`, + message: `server listening at ${handle.url}`, tag: "dev", tone: "success", }), @@ -589,7 +600,7 @@ function createCliProgram(logger: CliLogger, runtime: CliRuntimeOverrides): Comm }); } - await runInteractiveUi(server.url, onBootProgress); + await runInteractiveUi({ appRoot: handle.appRoot, serverUrl: handle.url }, onBootProgress); } finally { buildProgress?.stop(); await closeServer(); diff --git a/packages/eve/src/evals/cli/eval.ts b/packages/eve/src/evals/cli/eval.ts index e84a13045..4bc1016e5 100644 --- a/packages/eve/src/evals/cli/eval.ts +++ b/packages/eve/src/evals/cli/eval.ts @@ -3,7 +3,7 @@ import { basename, join } from "node:path"; import { loadDevelopmentEnvironmentFiles } from "#cli/dev/environment.js"; import { resolveApplicationRoot } from "#internal/application/paths.js"; -import { type DevelopmentServerHandle, startDevelopmentServer } from "#internal/nitro/host.js"; +import { createDevelopmentServer, type DevelopmentServer } from "#internal/nitro/host.js"; import { createEvalClient } from "#evals/cli/eval-client.js"; import { discoverAndImportEvals, discoverEvalConfig } from "#evals/runner/discover.js"; import { runEvals } from "#evals/runner/run-evals.js"; @@ -90,7 +90,7 @@ export async function runEvalCommand( } // Resolve target - let server: DevelopmentServerHandle | undefined; + let devServer: DevelopmentServer | undefined; let target: EveEvalTargetHandle; let client: Awaited>; @@ -107,13 +107,14 @@ export async function runEvalCommand( url: options.url, }); } else { - server = await startDevelopmentServer(appRoot, { host: "127.0.0.1", port: 0 }); - client = await createEvalClient({ kind: "local", url: server.url }); + devServer = createDevelopmentServer(appRoot, { host: "127.0.0.1", port: 0 }); + const started = await devServer.start(); + client = await createEvalClient({ kind: "local", url: started.url }); target = await resolveEvalTargetHandle({ client, expectedAgentName: await readExpectedAgentName(appRoot), kind: "local", - url: server.url, + url: started.url, }); } @@ -151,8 +152,8 @@ export async function runEvalCommand( process.exitCode = 1; } } finally { - if (server) { - await server.close(); + if (devServer) { + await devServer.close(); } } diff --git a/packages/eve/src/internal/nitro/host.ts b/packages/eve/src/internal/nitro/host.ts index c3d270e54..79dd257ae 100644 --- a/packages/eve/src/internal/nitro/host.ts +++ b/packages/eve/src/internal/nitro/host.ts @@ -1,8 +1,11 @@ export { buildApplication } from "#internal/nitro/host/build-application.js"; -export { startDevelopmentServer } from "#internal/nitro/host/start-development-server.js"; +export { createDevelopmentServer } from "#internal/nitro/host/start-development-server.js"; export { startProductionServer } from "#internal/nitro/host/start-production-server.js"; export type { + DevelopmentServer, DevelopmentServerHandle, DevelopmentServerOptions, + ExistingDevelopmentServer, ProductionServerHandle, + StartedDevelopmentServer, } from "#internal/nitro/host/types.js"; diff --git a/packages/eve/src/internal/nitro/host/dev-server-state.integration.test.ts b/packages/eve/src/internal/nitro/host/dev-server-state.integration.test.ts new file mode 100644 index 000000000..dcb137e6f --- /dev/null +++ b/packages/eve/src/internal/nitro/host/dev-server-state.integration.test.ts @@ -0,0 +1,58 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { DevelopmentServerState } from "#internal/nitro/host/dev-server-state.js"; + +const STATE_FILE_NAME = "dev-server-state.v1.json"; +const temporaryRoots: string[] = []; + +async function createState(): Promise { + const appRoot = await mkdtemp(join(tmpdir(), "eve-dev-server-state-")); + temporaryRoots.push(appRoot); + return new DevelopmentServerState({ appRoot }); +} + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { force: true, recursive: true })), + ); +}); + +describe("DevelopmentServerState", () => { + it("returns no URL when no state file exists", async () => { + const state = await createState(); + + await expect(state.read()).resolves.toBeUndefined(); + }); + + it("writes and reads the ready server URL", async () => { + const state = await createState(); + + await state.write("http://127.0.0.1:2000/"); + + await expect(state.read()).resolves.toBe("http://127.0.0.1:2000/"); + await expect(readFile(join(state.appRoot, ".eve", STATE_FILE_NAME), "utf8")).resolves.toBe( + '{"url":"http://127.0.0.1:2000/"}\n', + ); + }); + + it("treats malformed state as stale", async () => { + const state = await createState(); + await mkdir(join(state.appRoot, ".eve"), { recursive: true }); + await writeFile(join(state.appRoot, ".eve", STATE_FILE_NAME), "{ not json", "utf8"); + + await expect(state.read()).resolves.toBeUndefined(); + }); + + it("removes the state record", async () => { + const state = await createState(); + await state.write("http://127.0.0.1:2000/"); + + await state.remove(); + + await expect(state.read()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/eve/src/internal/nitro/host/dev-server-state.ts b/packages/eve/src/internal/nitro/host/dev-server-state.ts new file mode 100644 index 000000000..bbf09b724 --- /dev/null +++ b/packages/eve/src/internal/nitro/host/dev-server-state.ts @@ -0,0 +1,69 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { z } from "#compiled/zod/index.js"; +import { httpServerUrlSchema } from "#shared/network-address.js"; + +const STATE_FILE_NAME = "dev-server-state.v1.json"; + +const developmentServerStateSchema = z + .object({ + url: httpServerUrlSchema, + }) + .strict(); + +/** + * The last ready development-server URL for one app root. + * + * The record lets a second interactive `eve dev` attach to the same server. + * It is not a lock: a stale or malformed record simply causes the caller to + * start a new server and overwrite it once that server is ready. + */ +export class DevelopmentServerState { + readonly appRoot: string; + readonly #stateDir: string; + readonly #statePath: string; + + constructor(project: { readonly appRoot: string }) { + this.appRoot = project.appRoot; + this.#stateDir = join(this.appRoot, ".eve"); + this.#statePath = join(this.#stateDir, STATE_FILE_NAME); + } + + /** Returns the recorded URL, if the record exists and is valid. */ + async read(): Promise { + let raw: string; + + try { + raw = await readFile(this.#statePath, "utf8"); + } catch (error) { + if (isErrnoException(error, "ENOENT")) { + return undefined; + } + throw error; + } + + try { + const parsed = developmentServerStateSchema.safeParse(JSON.parse(raw)); + return parsed.success ? parsed.data.url : undefined; + } catch { + return undefined; + } + } + + /** Records a server after it is ready to accept clients. */ + async write(url: string): Promise { + const state = developmentServerStateSchema.parse({ url }); + await mkdir(this.#stateDir, { recursive: true }); + await writeFile(this.#statePath, `${JSON.stringify(state)}\n`, "utf8"); + } + + /** Clears the record after the listening server has stopped. */ + async remove(): Promise { + await rm(this.#statePath, { force: true }); + } +} + +function isErrnoException(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && error.code === code; +} 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 409a8a7f7..e0f4dddb4 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 @@ -5,7 +5,16 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + DevelopmentServerHandle, + DevelopmentServerOptions, +} from "#internal/nitro/host/types.js"; + const mocks = vi.hoisted(() => { + const fsControl: { + stateReadError?: Error; + stateWriteError?: Error; + } = {}; const authoredSourceWatcher = { close: vi.fn(async () => undefined), flush: vi.fn(async () => undefined), @@ -40,13 +49,22 @@ const mocks = vi.hoisted(() => { createApplicationNitro: vi.fn(async () => nitro), createDevServer: vi.fn(() => devServer), devServer, + fetch: vi.fn(async () => new Response(null, { status: 200 })), files, + fsControl, listenerServer, mkdir: vi.fn(async () => undefined), nitro, prepareApplicationHost: vi.fn(async () => ({ appRoot: "/tmp/eve-test" })), prepareNitro: vi.fn(async () => undefined), readFile: vi.fn(async (path: string) => { + if ( + path.endsWith("/.eve/dev-server-state.v1.json") && + fsControl.stateReadError !== undefined + ) { + throw fsControl.stateReadError; + } + const value = files.get(path); if (value === undefined) { @@ -58,15 +76,15 @@ const mocks = vi.hoisted(() => { rm: vi.fn(async (path: string) => { files.delete(path); }), - stat: vi.fn(async (path: string) => { - if (!files.has(path)) { - throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); - } - }), startDevelopmentSandboxPrewarmInBackground: vi.fn(() => undefined), pruneLocalSandboxTemplatesInBackground: vi.fn(() => undefined), stopDevelopmentSandboxResources: vi.fn(async () => undefined), pruneDevelopmentRuntimeArtifactsSnapshotsInBackground: vi.fn(() => undefined), + resolveDiscoveryProject: vi.fn(async () => ({ + agentRoot: "/tmp/eve-test/agent", + appRoot: "/tmp/eve-test", + layout: "nested" as const, + })), resolveNitroCompiledArtifactsSource: vi.fn(() => ({ appRoot: "/tmp/eve-test/.eve/dev-runtime-test", kind: "disk" as const, @@ -74,6 +92,12 @@ const mocks = vi.hoisted(() => { })), startAuthoredSourceWatcher: vi.fn(async () => authoredSourceWatcher), writeFile: vi.fn(async (path: string, value: string) => { + if ( + path.endsWith("/.eve/dev-server-state.v1.json") && + fsControl.stateWriteError !== undefined + ) { + throw fsControl.stateWriteError; + } files.set(path, value); }), }; @@ -83,7 +107,6 @@ vi.mock("node:fs/promises", () => ({ mkdir: mocks.mkdir, readFile: mocks.readFile, rm: mocks.rm, - stat: mocks.stat, writeFile: mocks.writeFile, })); @@ -105,6 +128,10 @@ vi.mock("./prepare-application-host.js", () => ({ prepareApplicationHost: mocks.prepareApplicationHost, })); +vi.mock("#discover/project.js", () => ({ + resolveDiscoveryProject: mocks.resolveDiscoveryProject, +})); + vi.mock("#internal/nitro/routes/runtime-artifacts.js", () => ({ resolveNitroCompiledArtifactsSource: mocks.resolveNitroCompiledArtifactsSource, })); @@ -158,15 +185,77 @@ function createSocket(): Socket { return socket; } -const developmentProcessIdPath = join("/tmp/eve-test", ".eve", "dev-process.pid"); -const developmentServerMetadataPath = join("/tmp/eve-test", ".eve", "dev-server.json"); +const developmentServerStatePath = join("/tmp/eve-test", ".eve", "dev-server-state.v1.json"); + +function readStateRecord( + path: string = developmentServerStatePath, +): Record | undefined { + const raw = mocks.files.get(path); + return raw === undefined ? undefined : (JSON.parse(raw) as Record); +} + +function seedStateRecord( + record: Record, + path: string = developmentServerStatePath, +): void { + mocks.files.set(path, `${JSON.stringify(record)}\n`); +} + +function createDeferred(): { + readonly promise: Promise; + reject(error: unknown): void; + resolve(value: T): void; +} { + let resolvePromise: ((value: T) => void) | undefined; + let rejectPromise: ((reason?: unknown) => void) | undefined; + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve; + rejectPromise = reject; + }); + + return { + promise, + reject(error) { + if (rejectPromise === undefined) { + throw new Error("Deferred promise was not initialized."); + } + rejectPromise(error); + }, + resolve(value) { + if (resolvePromise === undefined) { + throw new Error("Deferred promise was not initialized."); + } + resolvePromise(value); + }, + }; +} + +/** The owned-server shape the suite asserted against before `close()` moved onto `DevelopmentServer`. */ +type StartedTestServer = DevelopmentServerHandle & { close(): Promise }; + +/** + * Adapts `createDevelopmentServer().start()` back into the handle-plus-`close()` + * shape these tests assert against, so each call exercises the real factory, + * `start()`, and `close()` while keeping the call sites terse. + */ +async function loadStartDevelopmentServer(): Promise< + (rootDir: string, options?: DevelopmentServerOptions) => Promise +> { + const { createDevelopmentServer } = + await import("#internal/nitro/host/start-development-server.js"); + + return async (rootDir, options) => { + const server = createDevelopmentServer(rootDir, options); + const handle = await server.start(); + return Object.assign({ ...handle }, { close: () => server.close() }); + }; +} async function startServer(): Promise<{ close(): Promise; url: string; }> { - const { startDevelopmentServer } = - await import("#internal/nitro/host/start-development-server.js"); + const startDevelopmentServer = await loadStartDevelopmentServer(); return await startDevelopmentServer("/tmp/eve-test"); } @@ -198,9 +287,17 @@ describe("normalizeDevelopmentServerClientUrl", () => { }); }); -describe("startDevelopmentServer", () => { +describe("createDevelopmentServer", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.fetch.mockResolvedValue(new Response(null, { status: 200 })); + mocks.fsControl.stateReadError = undefined; + mocks.fsControl.stateWriteError = undefined; + mocks.authoredSourceWatcher.close.mockResolvedValue(undefined); + mocks.devServer.close.mockResolvedValue(undefined); + mocks.nitro.close.mockResolvedValue(undefined); + mocks.stopDevelopmentSandboxResources.mockResolvedValue(undefined); + vi.stubGlobal("fetch", mocks.fetch); delete process.env.WORKFLOW_LOCAL_BASE_URL; delete process.env.PORT; delete process.env.EVE_DEVELOPMENT_SANDBOX_RUN_ID; @@ -226,10 +323,11 @@ describe("startDevelopmentServer", () => { delete process.env.PORT; delete process.env.EVE_DEVELOPMENT_SANDBOX_RUN_ID; mocks.files.clear(); + vi.unstubAllGlobals(); }); it("pins local workflow queue callbacks to the active dev server URL", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + const startDevelopmentServer = await loadStartDevelopmentServer(); Object.assign(mocks.listenerServer, { url: "http://127.0.0.1:42123/", }); @@ -264,8 +362,8 @@ describe("startDevelopmentServer", () => { expect(process.env.EVE_DEVELOPMENT_SANDBOX_RUN_ID).toBeUndefined(); }); - it("uses eve's default port when no port is requested", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + it("uses Eve's default port when no port is requested", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); Object.assign(mocks.nitro.options.devServer, { port: 3000, }); @@ -282,7 +380,7 @@ describe("startDevelopmentServer", () => { }); it("normalizes wildcard IPv6 listener URLs before exposing them to the REPL or workflow", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + const startDevelopmentServer = await loadStartDevelopmentServer(); Object.assign(mocks.listenerServer, { url: "http://[::]:2000/", }); @@ -297,7 +395,7 @@ describe("startDevelopmentServer", () => { }); it("retries the next port on IPv4 loopback when the default port is occupied", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + const startDevelopmentServer = await loadStartDevelopmentServer(); const addressInUseError = Object.assign(new Error("Address already in use"), { code: "EADDRINUSE", }); @@ -328,80 +426,275 @@ describe("startDevelopmentServer", () => { await server.close(); }); - it("writes the active dev process id and removes it on close", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + it("records the active dev server URL and removes the state on close", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); const server = await startDevelopmentServer("/tmp/eve-test"); - expect(mocks.files.get(developmentProcessIdPath)).toBe(`${process.pid}\n`); - expect(JSON.parse(mocks.files.get(developmentServerMetadataPath) ?? "{}")).toMatchObject({ - pid: process.pid, - url: "http://localhost:2000/", - }); + const record = readStateRecord(); + expect(record).toEqual({ url: "http://localhost:2000/" }); await server.close(); - expect(mocks.files.has(developmentProcessIdPath)).toBe(false); - expect(mocks.files.has(developmentServerMetadataPath)).toBe(false); + expect(mocks.files.has(developmentServerStatePath)).toBe(false); }); - it("refuses to start when the agent already has a running dev process", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); - mocks.files.set(developmentProcessIdPath, `${process.pid}\n`); + it("attempts every cleanup step when the authored-source watcher fails to close", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + const server = await startDevelopmentServer("/tmp/eve-test"); + mocks.authoredSourceWatcher.close.mockRejectedValueOnce(new Error("watcher close failed")); + + await expect(server.close()).rejects.toThrow("watcher close failed"); + + expect(mocks.devServer.close).toHaveBeenCalledOnce(); + expect(mocks.nitro.close).toHaveBeenCalledOnce(); + expect(mocks.stopDevelopmentSandboxResources).toHaveBeenCalledOnce(); + expect(readStateRecord()).toBeUndefined(); + }); + + it("keeps the state record when the listener fails to close", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + const server = await startDevelopmentServer("/tmp/eve-test"); + mocks.devServer.close.mockRejectedValueOnce(new Error("listener close failed")); + + await expect(server.close()).rejects.toThrow("listener close failed"); + + expect(mocks.nitro.close).toHaveBeenCalledOnce(); + expect(mocks.stopDevelopmentSandboxResources).toHaveBeenCalledOnce(); + expect(readStateRecord()).toEqual({ url: "http://localhost:2000/" }); + }); + + it("closes the server when its state record cannot be written", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + mocks.fsControl.stateWriteError = Object.assign(new Error("disk full"), { code: "ENOSPC" }); + + await expect(startDevelopmentServer("/tmp/eve-test")).rejects.toThrow("disk full"); + + expect(mocks.devServer.close).toHaveBeenCalledOnce(); + expect(mocks.nitro.close).toHaveBeenCalledOnce(); + expect(readStateRecord()).toBeUndefined(); + }); + + it("does not start when the state record cannot be read", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + mocks.fsControl.stateReadError = Object.assign(new Error("permission denied"), { + code: "EACCES", + }); + + await expect(startDevelopmentServer("/tmp/eve-test")).rejects.toThrow("permission denied"); - await expect(startDevelopmentServer("/tmp/eve-test")).rejects.toThrow( - [ - `A dev server is already running for this eve agent (pid ${process.pid}).`, - "To connect to the existing instance, run: pnpm exec eve dev http://localhost:PORT", - `To stop it, run: ${ - process.platform === "win32" ? "taskkill /PID" : "kill" - } ${process.pid}`, - ].join("\n"), - ); expect(mocks.createApplicationNitro).not.toHaveBeenCalled(); }); - it("prints a copyable connect command with the detected package manager and server URL", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); - mocks.files.set( - join("/tmp/eve-test", "package.json"), - JSON.stringify({ packageManager: "npm@10.0.0" }), - ); - mocks.files.set(developmentProcessIdPath, `${process.pid}\n`); - mocks.files.set( - developmentServerMetadataPath, - JSON.stringify({ - pid: process.pid, - updatedAt: "2026-06-17T00:00:00.000Z", - url: "http://127.0.0.1:4321/", - }), - ); + it("reports a healthy recorded server when starting another server", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + seedStateRecord({ url: "http://localhost:2000/" }); await expect(startDevelopmentServer("/tmp/eve-test")).rejects.toThrow( [ - `A dev server is already running for this eve agent (pid ${process.pid}).`, - "To connect to the existing instance, run: npm exec -- eve dev http://127.0.0.1:4321/", - `To stop it, run: ${ - process.platform === "win32" ? "taskkill /PID" : "kill" - } ${process.pid}`, + "A dev server is already running for this eve agent.", + "To connect to the existing instance, run: pnpm exec eve dev http://localhost:2000/", ].join("\n"), ); expect(mocks.createApplicationNitro).not.toHaveBeenCalled(); }); - it("overwrites a stale dev process id", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); - mocks.files.set(developmentProcessIdPath, "999999999\n"); + it("reuses the active server recorded for the same app root when requested", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + + const owner = await startDevelopmentServer("/tmp/eve-test"); + const ownerSandboxRunId = process.env.EVE_DEVELOPMENT_SANDBOX_RUN_ID; + // A real attaching TUI is a separate process and does not inherit the + // owner's internally installed listener port. + delete process.env.PORT; + const attached = await startDevelopmentServer("/tmp/eve-test", { + existing: "attach-if-unconfigured", + }); + + expect(attached.kind).toBe("existing"); + expect(attached.url).toBe(owner.url); + expect(mocks.createApplicationNitro).toHaveBeenCalledOnce(); + expect(mocks.fetch).toHaveBeenCalledWith("http://localhost:2000/eve/v1/health", { + redirect: "error", + signal: expect.any(AbortSignal), + }); + expect(process.env.EVE_DEVELOPMENT_SANDBOX_RUN_ID).toBe(ownerSandboxRunId); + + expect(mocks.devServer.close).not.toHaveBeenCalled(); + expect(process.env.EVE_DEVELOPMENT_SANDBOX_RUN_ID).toBe(ownerSandboxRunId); + expect(readStateRecord()).toEqual({ url: "http://localhost:2000/" }); + + await owner.close(); + expect(process.env.EVE_DEVELOPMENT_SANDBOX_RUN_ID).toBeUndefined(); + }); + + it("close() tears nothing down when the instance attached to an existing owner", async () => { + const { createDevelopmentServer } = await import("./start-development-server.js"); + + const owner = createDevelopmentServer("/tmp/eve-test"); + await owner.start(); + // A real attaching TUI is a separate process and does not inherit the + // owner's internally installed listener port. + delete process.env.PORT; + + const attaching = createDevelopmentServer("/tmp/eve-test", { + existing: "attach-if-unconfigured", + }); + const attached = await attaching.start(); + expect(attached.kind).toBe("existing"); + + mocks.devServer.close.mockClear(); + await attaching.close(); + // The attaching instance owns nothing, so close() is a no-op: it neither + // closes the listener nor disturbs the owner's published state. + expect(mocks.devServer.close).not.toHaveBeenCalled(); + expect(readStateRecord()).toEqual({ url: "http://localhost:2000/" }); + + await owner.close(); + }); + + it("waits for a pending start before closing an owned server", async () => { + const { createDevelopmentServer } = await import("./start-development-server.js"); + const project = createDeferred<{ + readonly agentRoot: string; + readonly appRoot: string; + readonly layout: "nested"; + }>(); + mocks.resolveDiscoveryProject.mockReturnValueOnce(project.promise); + + const server = createDevelopmentServer("/tmp/eve-test"); + const starting = server.start(); + await vi.waitFor(() => expect(mocks.resolveDiscoveryProject).toHaveBeenCalledOnce()); + const closing = server.close(); + + project.resolve({ + agentRoot: "/tmp/eve-test/agent", + appRoot: "/tmp/eve-test", + layout: "nested", + }); + + await starting; + await closing; + + expect(mocks.devServer.close).toHaveBeenCalledOnce(); + expect(readStateRecord()).toBeUndefined(); + }); + + it("waits for a failed start without rethrowing from close", async () => { + const { createDevelopmentServer } = await import("./start-development-server.js"); + const project = createDeferred<{ + readonly agentRoot: string; + readonly appRoot: string; + readonly layout: "nested"; + }>(); + mocks.resolveDiscoveryProject.mockReturnValueOnce(project.promise); + + const server = createDevelopmentServer("/tmp/eve-test"); + const starting = server.start(); + await vi.waitFor(() => expect(mocks.resolveDiscoveryProject).toHaveBeenCalledOnce()); + const closing = server.close(); + + project.reject(new Error("discovery failed")); + + await expect(starting).rejects.toThrow("discovery failed"); + await expect(closing).resolves.toBeUndefined(); + expect(mocks.devServer.close).not.toHaveBeenCalled(); + }); + + it("does not attach when PORT explicitly configures the endpoint", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + process.env.PORT = "2000"; + seedStateRecord({ url: "http://localhost:2000/" }); + + 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.fetch).toHaveBeenCalledOnce(); + }); + + it("rejects reuse when the requested environment port conflicts", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + process.env.PORT = "2001"; + seedStateRecord({ url: "http://localhost:2000/" }); + + 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.fetch).toHaveBeenCalledOnce(); + }); + + it("overwrites an unhealthy state record", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + mocks.fetch.mockResolvedValue(new Response(null, { status: 503 })); + seedStateRecord({ url: "http://localhost:2000/" }); + + const server = await startDevelopmentServer("/tmp/eve-test", { + existing: "attach-if-unconfigured", + }); + + expect(mocks.fetch).toHaveBeenCalledWith("http://localhost:2000/eve/v1/health", { + redirect: "error", + signal: expect.any(AbortSignal), + }); + expect(mocks.fetch).toHaveBeenCalledOnce(); + expect(mocks.createApplicationNitro).toHaveBeenCalledOnce(); + expect(readStateRecord()).toEqual({ url: "http://localhost:2000/" }); + + await server.close(); + }); + + it("does not probe a non-loopback URL from persisted state", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + seedStateRecord({ url: "http://192.168.1.20:2000/" }); + + const server = await startDevelopmentServer("/tmp/eve-test", { + existing: "attach-if-unconfigured", + }); + + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.createApplicationNitro).toHaveBeenCalledOnce(); + + await server.close(); + }); + + it("does not reuse a server recorded under another app root", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + const otherAppRoot = "/tmp/other-eve-test"; + + seedStateRecord( + { url: "http://127.0.0.1:2999/" }, + join(otherAppRoot, ".eve", "dev-server-state.v1.json"), + ); + + const server = await startDevelopmentServer("/tmp/eve-test", { + existing: "attach-if-unconfigured", + }); + + expect(server.url).toBe("http://localhost:2000/"); + expect(mocks.createApplicationNitro).toHaveBeenCalledOnce(); + + if (server.kind !== "started") { + throw new Error("Expected to start the server for the requested app root."); + } + await server.close(); + }); + + it("overwrites a stale dev server record", async () => { + const startDevelopmentServer = await loadStartDevelopmentServer(); + mocks.fetch.mockResolvedValue(new Response(null, { status: 503 })); + seedStateRecord({ url: "http://localhost:2000/" }); const server = await startDevelopmentServer("/tmp/eve-test"); - expect(mocks.files.get(developmentProcessIdPath)).toBe(`${process.pid}\n`); + expect(readStateRecord()).toEqual({ url: "http://localhost:2000/" }); await server.close(); }); it("normalizes wildcard IPv4 listener URLs before exposing them to the REPL or workflow", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + const startDevelopmentServer = await loadStartDevelopmentServer(); Object.assign(mocks.listenerServer, { url: "http://0.0.0.0:2000/", }); @@ -415,7 +708,7 @@ describe("startDevelopmentServer", () => { }); it("honors the PORT environment variable when no port option is provided", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + const startDevelopmentServer = await loadStartDevelopmentServer(); process.env.PORT = "4321"; Object.assign(mocks.listenerServer, { url: "http://127.0.0.1:4321/", @@ -429,7 +722,7 @@ describe("startDevelopmentServer", () => { }); it("prefers the explicit port option over the PORT environment variable", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + const startDevelopmentServer = await loadStartDevelopmentServer(); process.env.PORT = "4321"; const server = await startDevelopmentServer("/tmp/eve-test", { port: 5000 }); @@ -440,7 +733,7 @@ describe("startDevelopmentServer", () => { }); it("rejects when the PORT environment variable is not a valid port", async () => { - const { startDevelopmentServer } = await import("./start-development-server.js"); + const startDevelopmentServer = await loadStartDevelopmentServer(); process.env.PORT = "not-a-port"; await expect(startDevelopmentServer("/tmp/eve-test")).rejects.toThrow( 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 06c0ee49a..29f6ae41d 100644 --- a/packages/eve/src/internal/nitro/host/start-development-server.ts +++ b/packages/eve/src/internal/nitro/host/start-development-server.ts @@ -1,7 +1,5 @@ -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import type { IncomingMessage } from "node:http"; import type { Socket } from "node:net"; -import { join } from "node:path"; import { EVE_DEV_ENV_FLAG } from "#internal/application/optional-package-install.js"; @@ -12,6 +10,11 @@ import { createApplicationNitro } from "#internal/nitro/host/create-application- import { createNitroArtifactsConfig } 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 { resolveDiscoveryProject } from "#discover/project.js"; +import { DevelopmentServerState } from "#internal/nitro/host/dev-server-state.js"; +import { toErrorMessage } from "#shared/errors.js"; +import { isEveServerHealthy } from "#shared/eve-server-health.js"; +import { isLoopbackServerUrl } from "#shared/network-address.js"; import { resolveNitroCompiledArtifactsSource } from "#internal/nitro/routes/runtime-artifacts.js"; import { pruneLocalSandboxTemplatesInBackground, @@ -25,8 +28,10 @@ import { getInitializedDevelopmentSandboxBackendNames, } from "#execution/sandbox/development-run.js"; import type { + DevelopmentServer, DevelopmentServerHandle, DevelopmentServerOptions, + StartedDevelopmentServer, } from "#internal/nitro/host/types.js"; import { loadDevelopmentEnvironmentFiles } from "#cli/dev/environment.js"; import { pruneDevelopmentRuntimeArtifactsSnapshotsInBackground } from "#internal/nitro/dev-runtime-artifacts.js"; @@ -41,21 +46,8 @@ import { devBootPhase } from "#internal/dev-boot-progress.js"; const MAX_ALLOWED_DEVELOPMENT_SERVER_PORT = 65_535; const WORKFLOW_LOCAL_BASE_URL_ENV = "WORKFLOW_LOCAL_BASE_URL"; const PORT_ENV = "PORT"; -const DEVELOPMENT_PROCESS_ID_FILE = "dev-process.pid"; -const DEVELOPMENT_SERVER_METADATA_FILE = "dev-server.json"; const DEFAULT_DEVELOPMENT_SERVER_HOST = "127.0.0.1"; const IPV6_LOOPBACK_HOSTNAME = "[::1]"; -const DEVELOPMENT_SERVER_URL_PLACEHOLDER = "http://localhost:PORT"; - -interface DevelopmentServerMetadata { - readonly processId: number; - readonly url: string; -} - -interface ActiveDevelopmentProcess { - readonly processId: number; - readonly url?: string; -} /** * Hostnames Nitro/srvx surface when listening on an IPv6 wildcard interface. @@ -125,118 +117,6 @@ function readEnvironmentPort(): number | undefined { return parsed; } -function resolveDevelopmentProcessIdPath(appRoot: string): string { - return join(appRoot, ".eve", DEVELOPMENT_PROCESS_ID_FILE); -} - -function resolveDevelopmentServerMetadataPath(appRoot: string): string { - return join(appRoot, ".eve", DEVELOPMENT_SERVER_METADATA_FILE); -} - -function parseProcessId(value: string): number | undefined { - const trimmed = value.trim(); - - if (!/^\d+$/.test(trimmed)) { - return undefined; - } - - const processId = Number(trimmed); - return Number.isSafeInteger(processId) && processId > 0 ? processId : undefined; -} - -function isProcessRunning(processId: number): boolean { - try { - process.kill(processId, 0); - return true; - } catch (error) { - return ( - error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "EPERM" - ); - } -} - -function formatKillCommand(processId: number): string { - if (process.platform === "win32") { - return `taskkill /PID ${processId}`; - } - - return `kill ${processId}`; -} - -function parseDevelopmentServerMetadata(value: string): DevelopmentServerMetadata | undefined { - let parsed: unknown; - - try { - parsed = JSON.parse(value); - } catch { - return undefined; - } - - if ( - typeof parsed !== "object" || - parsed === null || - !("pid" in parsed) || - typeof parsed.pid !== "number" || - !Number.isSafeInteger(parsed.pid) || - parsed.pid <= 0 || - !("url" in parsed) || - typeof parsed.url !== "string" - ) { - return undefined; - } - - const url = normalizeDevelopmentServerClientUrl(parsed.url); - - try { - const parsedUrl = new URL(url); - if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { - return undefined; - } - } catch { - return undefined; - } - - return { - processId: parsed.pid, - url, - }; -} - -async function readDevelopmentServerMetadata( - appRoot: string, -): Promise { - try { - return parseDevelopmentServerMetadata( - await readFile(resolveDevelopmentServerMetadataPath(appRoot), "utf8"), - ); - } catch { - return undefined; - } -} - -async function readActiveDevelopmentProcess( - appRoot: string, -): Promise { - let processId: number | undefined; - - try { - processId = parseProcessId(await readFile(resolveDevelopmentProcessIdPath(appRoot), "utf8")); - } catch { - return undefined; - } - - if (processId === undefined || !isProcessRunning(processId)) { - return undefined; - } - - const metadata = await readDevelopmentServerMetadata(appRoot); - - return { - processId, - url: metadata?.processId === processId ? metadata.url : undefined, - }; -} - async function detectDevelopmentCommandPackageManager( appRoot: string, ): Promise { @@ -255,64 +135,19 @@ async function formatDevelopmentServerConnectCommand( return [packageManager, ...eveDevArguments(packageManager), serverUrl].join(" "); } -async function writeDevelopmentServerMetadata(appRoot: string, serverUrl: string): Promise { - await writeFile( - resolveDevelopmentServerMetadataPath(appRoot), - `${JSON.stringify( - { - pid: process.pid, - updatedAt: new Date().toISOString(), - url: normalizeDevelopmentServerClientUrl(serverUrl), - }, - null, - 2, - )}\n`, - "utf8", +async function createDevelopmentServerAlreadyRunningError( + appRoot: string, + serverUrl: string, +): Promise { + const connectCommand = await formatDevelopmentServerConnectCommand(appRoot, serverUrl); + return new Error( + [ + "A dev server is already running for this eve agent.", + `To connect to the existing instance, run: ${connectCommand}`, + ].join("\n"), ); } -async function writeDevelopmentProcessId(appRoot: string): Promise<() => Promise> { - const processIdPath = resolveDevelopmentProcessIdPath(appRoot); - const metadataPath = resolveDevelopmentServerMetadataPath(appRoot); - const activeProcess = await readActiveDevelopmentProcess(appRoot); - - if (activeProcess !== undefined) { - const connectUrl = activeProcess.url ?? DEVELOPMENT_SERVER_URL_PLACEHOLDER; - const connectCommand = await formatDevelopmentServerConnectCommand(appRoot, connectUrl); - throw new Error( - [ - `A dev server is already running for this eve agent (pid ${activeProcess.processId}).`, - `To connect to the existing instance, run: ${connectCommand}`, - `To stop it, run: ${formatKillCommand(activeProcess.processId)}`, - ].join("\n"), - ); - } - - await mkdir(join(appRoot, ".eve"), { recursive: true }); - await writeFile(processIdPath, `${process.pid}\n`, "utf8"); - - return async () => { - let currentProcessId: number | undefined; - - try { - currentProcessId = parseProcessId(await readFile(processIdPath, "utf8")); - } catch { - return; - } - - if (currentProcessId === process.pid) { - await rm(processIdPath, { force: true }); - await rm(metadataPath, { force: true }); - return; - } - - const metadata = await readDevelopmentServerMetadata(appRoot); - if (metadata?.processId === process.pid) { - await rm(metadataPath, { force: true }); - } - }; -} - function resolveDevelopmentServerPorts(input: { readonly port: number | string | undefined; readonly retryOnAddressInUse: boolean; @@ -413,6 +248,72 @@ function guardDevelopmentServerWebSocketUpgrades( devServer.upgrade = guardedUpgrade; } +async function closeDevelopmentServerResources(input: { + readonly authoredSourceWatcher: AuthoredSourceWatcherHandle | undefined; + readonly devServer: NitroDevelopmentServer | undefined; + readonly developmentSandboxRunId: string; + readonly nitro: Nitro | undefined; +}): Promise<{ readonly errors: readonly unknown[]; readonly listenerClosed: boolean }> { + const errors: unknown[] = []; + const attempt = async (operation: () => Promise): Promise => { + try { + await operation(); + return true; + } catch (error) { + errors.push(error); + return false; + } + }; + + const authoredSourceWatcher = input.authoredSourceWatcher; + if (authoredSourceWatcher !== undefined) { + await attempt(() => authoredSourceWatcher.close()); + } + const devServer = input.devServer; + const listenerClosed = devServer === undefined ? true : await attempt(() => devServer.close()); + const nitro = input.nitro; + if (nitro !== undefined) { + await attempt(() => nitro.close()); + } + await attempt(() => + stopDevelopmentSandboxResources({ + backendNames: getInitializedDevelopmentSandboxBackendNames(input.developmentSandboxRunId), + devRunId: input.developmentSandboxRunId, + log: (message) => console.warn(`[eve:dev] ${message}`), + }), + ); + + return { errors, listenerClosed }; +} + +function createDevelopmentServerCleanupError(errors: readonly unknown[]): Error | undefined { + if (errors.length === 0) { + return undefined; + } + + if (errors.length === 1) { + const error = errors[0]; + return error instanceof Error + ? error + : new Error(`Failed to close the development server: ${toErrorMessage(error)}`, { + cause: error, + }); + } + + return new AggregateError(errors, "Multiple development-server resources failed to close."); +} + +function createDevelopmentServerStartupCleanupError( + startupError: unknown, + cleanupErrors: readonly unknown[], +): AggregateError { + return new AggregateError( + [startupError, ...cleanupErrors], + `${toErrorMessage(startupError)} Cleanup also failed.`, + { cause: startupError }, + ); +} + async function listenForDevelopmentServer(input: { readonly devServer: NitroDevelopmentServer; readonly host?: string; @@ -457,24 +358,45 @@ async function listenForDevelopmentServer(input: { ); } -/** - * Starts the development Nitro server for an eve application. - * - * Authored schedules are never registered with Nitro's cron scheduler in - * dev mode. To fire one authored schedule on demand, `POST` the dev-only - * `/eve/v1/dev/schedules/:scheduleId` route — the handler returns - * `{ scheduleId, sessionIds }` so callers can subscribe to the existing - * per-session stream route. - */ -export async function startDevelopmentServer( +interface DevelopmentServerStartResult { + readonly handle: DevelopmentServerHandle; + /** Teardown for a server this process owns; undefined when attached to an existing owner. */ + readonly close: (() => Promise) | undefined; +} + +async function startNitroDevelopmentServer( rootDir: string, - options: DevelopmentServerOptions = {}, -): Promise { + options: DevelopmentServerOptions, +): Promise { // Marks this process tree as an `eve dev` session so runtime features // that must never run in production (for example auto-installing // optional sandbox engine packages) can gate on it. process.env[EVE_DEV_ENV_FLAG] ??= "1"; - loadDevelopmentEnvironmentFiles(rootDir); + + const project = await resolveDiscoveryProject(rootDir); + loadDevelopmentEnvironmentFiles(project.appRoot); + + const environmentPort = readEnvironmentPort(); + const requestedPort = options.port ?? environmentPort; + const hasExplicitEndpoint = + options.host !== undefined || options.port !== undefined || environmentPort !== undefined; + const state = new DevelopmentServerState(project); + const existingServerUrl = await state.read(); + + if ( + existingServerUrl !== undefined && + isLoopbackServerUrl(existingServerUrl) && + (await isEveServerHealthy(existingServerUrl)) + ) { + if (options.existing === "attach-if-unconfigured" && !hasExplicitEndpoint) { + return { + handle: { kind: "existing", appRoot: project.appRoot, url: existingServerUrl }, + close: undefined, + }; + } + throw await createDevelopmentServerAlreadyRunningError(project.appRoot, existingServerUrl); + } + const previousDevelopmentSandboxRunId = process.env[EVE_DEVELOPMENT_SANDBOX_RUN_ID_ENV]; const developmentSandboxRunId = createDevelopmentSandboxRunId(); process.env[EVE_DEVELOPMENT_SANDBOX_RUN_ID_ENV] = developmentSandboxRunId; @@ -482,15 +404,13 @@ export async function startDevelopmentServer( let devServer: NitroDevelopmentServer | undefined; let restoreWorkflowLocalQueueEnvironment: (() => void) | undefined; let authoredSourceWatcher: AuthoredSourceWatcherHandle | undefined; - let removeDevelopmentProcessId: (() => Promise) | undefined; try { const preparedHost = await devBootPhase( "compiling agent", - () => prepareApplicationHost(rootDir, { dev: true }), + () => prepareApplicationHost(project.appRoot, { dev: true }), options.onBootProgress, ); - removeDevelopmentProcessId = await writeDevelopmentProcessId(preparedHost.appRoot); pruneDevelopmentRuntimeArtifactsSnapshotsInBackground(preparedHost.appRoot); const compiledArtifactsSource = resolveNitroCompiledArtifactsSource( createNitroArtifactsConfig({ @@ -514,7 +434,6 @@ export async function startDevelopmentServer( guardDevelopmentServerWebSocketUpgrades(activeNitro, devServer); const hostname = options.host ?? activeNitro.options.devServer.hostname ?? DEFAULT_DEVELOPMENT_SERVER_HOST; - const requestedPort = options.port ?? readEnvironmentPort(); const retryOnAddressInUse = requestedPort === undefined; const server = await devBootPhase( "binding port", @@ -533,7 +452,6 @@ export async function startDevelopmentServer( } const serverUrl = normalizeDevelopmentServerClientUrl(server.url); - await writeDevelopmentServerMetadata(preparedHost.appRoot, serverUrl); restoreWorkflowLocalQueueEnvironment = installWorkflowLocalQueueEnvironment(serverUrl); await devBootPhase( "building dev bundle", @@ -549,10 +467,14 @@ export async function startDevelopmentServer( async () => { const { startAuthoredSourceWatcher } = await import("#internal/nitro/host/dev-authored-source-watcher.js"); - return startAuthoredSourceWatcher({ nitro: activeNitro, preparedHost }); + return startAuthoredSourceWatcher({ + nitro: activeNitro, + preparedHost, + }); }, options.onBootProgress, ); + await state.write(serverUrl); const restoreWorkflowLocalQueueEnvironmentOnClose = restoreWorkflowLocalQueueEnvironment; if (restoreWorkflowLocalQueueEnvironmentOnClose === undefined) { throw new Error("Workflow local queue environment was not initialized."); @@ -561,43 +483,108 @@ export async function startDevelopmentServer( const authoredSourceWatcherOnClose = authoredSourceWatcher; const devServerOnClose = devServer; const nitroOnClose = activeNitro; - return { - async close() { + let closePromise: Promise | undefined; + const close = (): Promise => { + closePromise ??= (async () => { + const cleanup = await closeDevelopmentServerResources({ + authoredSourceWatcher: authoredSourceWatcherOnClose, + devServer: devServerOnClose, + developmentSandboxRunId, + nitro: nitroOnClose, + }); + if (cleanup.listenerClosed) { + await state.remove().catch(() => {}); + } + try { - await authoredSourceWatcherOnClose.close(); - await devServerOnClose.close(); - await nitroOnClose.close(); - await stopDevelopmentSandboxResources({ - backendNames: getInitializedDevelopmentSandboxBackendNames(developmentSandboxRunId), - devRunId: developmentSandboxRunId, - log: (message) => console.warn(`[eve:dev] ${message}`), - }); + const cleanupError = createDevelopmentServerCleanupError(cleanup.errors); + if (cleanupError !== undefined) { + throw cleanupError; + } } finally { clearInitializedDevelopmentSandboxBackendNames(developmentSandboxRunId); - await removeDevelopmentProcessId?.(); restoreWorkflowLocalQueueEnvironmentOnClose(); restoreDevelopmentSandboxRunId(previousDevelopmentSandboxRunId); } - }, - url: serverUrl, + })(); + return closePromise; + }; + return { + handle: { kind: "started", appRoot: project.appRoot, url: serverUrl }, + close, }; } catch (error) { - await authoredSourceWatcher?.close().catch(() => {}); + const cleanup = await closeDevelopmentServerResources({ + authoredSourceWatcher, + devServer, + developmentSandboxRunId, + nitro, + }); + const cleanupErrors = [...cleanup.errors]; restoreWorkflowLocalQueueEnvironment?.(); - await devServer?.close().catch(() => {}); - await nitro?.close().catch(() => {}); - await stopDevelopmentSandboxResources({ - backendNames: getInitializedDevelopmentSandboxBackendNames(developmentSandboxRunId), - devRunId: developmentSandboxRunId, - log: (message) => console.warn(`[eve:dev] ${message}`), - }).catch(() => {}); clearInitializedDevelopmentSandboxBackendNames(developmentSandboxRunId); - await removeDevelopmentProcessId?.().catch(() => {}); + if (cleanup.listenerClosed) { + await state.remove().catch(() => {}); + } restoreDevelopmentSandboxRunId(previousDevelopmentSandboxRunId); + if (cleanupErrors.length > 0) { + throw createDevelopmentServerStartupCleanupError(error, cleanupErrors); + } throw error; } } +/** + * Creates a development server for an eve application. Call `start()` to boot an + * owned Nitro server or attach to a running owner, and `close()` to tear down a + * server this instance started. `close()` waits for an in-progress `start()`, + * resolves after failed-start cleanup, and is a no-op when it attached to an + * existing owner or was never started. + * + * Authored schedules are never registered with Nitro's cron scheduler in dev + * mode. To fire one authored schedule on demand, `POST` the dev-only + * `/eve/v1/dev/schedules/:scheduleId` route — the handler returns + * `{ scheduleId, sessionIds }` so callers can subscribe to the existing + * per-session stream route. + */ +export function createDevelopmentServer( + rootDir: string, + options?: DevelopmentServerOptions & { existing?: "reject" }, +): DevelopmentServer; +export function createDevelopmentServer( + rootDir: string, + options?: DevelopmentServerOptions, +): DevelopmentServer; +export function createDevelopmentServer( + rootDir: string, + options: DevelopmentServerOptions = {}, +): DevelopmentServer { + let startPromise: Promise | undefined; + let closeStartedServer: (() => Promise) | undefined; + + return { + start(): Promise { + if (startPromise !== undefined) { + throw new Error("DevelopmentServer.start() was already called."); + } + + startPromise = startNitroDevelopmentServer(rootDir, options).then(({ handle, close }) => { + closeStartedServer = close; + return handle; + }); + return startPromise; + }, + async close(): Promise { + if (startPromise === undefined) { + return; + } + + await startPromise.catch(() => undefined); + await closeStartedServer?.(); + }, + }; +} + function restoreDevelopmentSandboxRunId(previous: string | undefined): void { if (previous === undefined) { delete process.env[EVE_DEVELOPMENT_SANDBOX_RUN_ID_ENV]; diff --git a/packages/eve/src/internal/nitro/host/types.ts b/packages/eve/src/internal/nitro/host/types.ts index 96522fefb..238694636 100644 --- a/packages/eve/src/internal/nitro/host/types.ts +++ b/packages/eve/src/internal/nitro/host/types.ts @@ -9,15 +9,40 @@ import type { DevBootProgressReporter } from "#internal/dev-boot-progress.js"; */ export type NitroBuildSurface = "all" | "app" | "flow"; +/** Outcome of starting a Nitro development server the current process owns. */ +export interface StartedDevelopmentServer { + readonly kind: "started"; + readonly appRoot: string; + readonly url: string; +} + +/** A live development server owned by another process. */ +export interface ExistingDevelopmentServer { + readonly kind: "existing"; + readonly appRoot: string; + readonly url: string; +} + +/** Result of starting a development server for an app root. */ +export type DevelopmentServerHandle = StartedDevelopmentServer | ExistingDevelopmentServer; + /** - * Handle returned after starting one Nitro development server. + * Lifecycle for one in-process Nitro development server. + * + * `start()` either boots a server this process owns or attaches to a running + * owner; the {@link DevelopmentServerHandle} result discriminates which. + * `close()` waits for an in-progress `start()`, then tears down only a server + * this instance started. It resolves after startup cleanup when `start()` + * fails, and is a no-op when the instance attached to an existing owner or was + * never started. */ -export interface DevelopmentServerHandle { +export interface DevelopmentServer { + start(): Promise; close(): Promise; - url: string; } export interface DevelopmentServerOptions { + readonly existing?: "attach-if-unconfigured" | "reject"; readonly host?: string; readonly onBootProgress?: DevBootProgressReporter; readonly port?: number; diff --git a/packages/eve/src/shared/eve-server-health.test.ts b/packages/eve/src/shared/eve-server-health.test.ts new file mode 100644 index 000000000..02b144f69 --- /dev/null +++ b/packages/eve/src/shared/eve-server-health.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { isEveServerHealthy } from "./eve-server-health.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("isEveServerHealthy", () => { + it("refuses health redirects", async () => { + const fetchMock = vi.fn(async (_url: string, options: RequestInit) => { + expect(options.redirect).toBe("error"); + return new Response(null, { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(isEveServerHealthy("http://127.0.0.1:2000")).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:2000/eve/v1/health", + expect.objectContaining({ redirect: "error", signal: expect.any(AbortSignal) }), + ); + }); +}); diff --git a/packages/eve/src/shared/eve-server-health.ts b/packages/eve/src/shared/eve-server-health.ts new file mode 100644 index 000000000..771ec350b --- /dev/null +++ b/packages/eve/src/shared/eve-server-health.ts @@ -0,0 +1,26 @@ +import { EVE_HEALTH_ROUTE_PATH } from "#protocol/routes.js"; + +const DEFAULT_EVE_SERVER_HEALTH_TIMEOUT_MS = 1_000; + +/** Returns whether an Eve server answers its health route successfully. */ +export async function isEveServerHealthy( + serverUrl: string, + options: { + readonly signal?: AbortSignal; + readonly timeoutMs?: number; + } = {}, +): Promise { + const timeoutSignal = AbortSignal.timeout( + options.timeoutMs ?? DEFAULT_EVE_SERVER_HEALTH_TIMEOUT_MS, + ); + const signal = + options.signal === undefined ? timeoutSignal : AbortSignal.any([options.signal, timeoutSignal]); + + try { + const healthUrl = new URL(EVE_HEALTH_ROUTE_PATH, serverUrl).toString(); + const response = await fetch(healthUrl, { redirect: "error", signal }); + return response.ok; + } catch { + return false; + } +} diff --git a/packages/eve/src/shared/network-address.test.ts b/packages/eve/src/shared/network-address.test.ts index 963ff8ab4..5ad147eba 100644 --- a/packages/eve/src/shared/network-address.test.ts +++ b/packages/eve/src/shared/network-address.test.ts @@ -1,6 +1,44 @@ import { describe, expect, it } from "vitest"; -import { isReservedIpAddress } from "#shared/network-address.js"; +import { + isLoopbackHostname, + isLoopbackServerUrl, + isReservedIpAddress, +} from "#shared/network-address.js"; + +describe("isLoopbackHostname", () => { + it("accepts the IPv4 loopback block, IPv6 loopback, and the localhost namespace", () => { + for (const host of ["localhost", "app.localhost", "127.0.0.1", "127.1.2.3", "::1", "[::1]"]) { + expect(isLoopbackHostname(host), host).toBe(true); + } + }); + + it("rejects wildcard binds, public hosts, and non-loopback IPs", () => { + for (const host of ["0.0.0.0", "::", "8.8.8.8", "example.com", "10.0.0.1"]) { + expect(isLoopbackHostname(host), host).toBe(false); + } + }); +}); + +describe("isLoopbackServerUrl", () => { + it("accepts http(s) URLs on loopback hosts", () => { + for (const url of ["http://127.0.0.1:2000/", "http://localhost:3000", "https://[::1]:8080/x"]) { + expect(isLoopbackServerUrl(url), url).toBe(true); + } + }); + + it("rejects non-loopback hosts, non-http schemes, and junk", () => { + for (const url of [ + "ws://localhost:2000/", + "http://evil.example/", + "http://0.0.0.0:2000/", + "ftp://127.0.0.1/", + "nope", + ]) { + expect(isLoopbackServerUrl(url), url).toBe(false); + } + }); +}); describe("isReservedIpAddress", () => { it("blocks link-local (cloud metadata), private, CGNAT, ULA, and unspecified addresses", () => { diff --git a/packages/eve/src/shared/network-address.ts b/packages/eve/src/shared/network-address.ts index 29f52caaa..59c79ac5c 100644 --- a/packages/eve/src/shared/network-address.ts +++ b/packages/eve/src/shared/network-address.ts @@ -1,5 +1,10 @@ import { BlockList, isIP } from "node:net"; +import { z } from "#compiled/zod/index.js"; + +/** HTTP(S) URL accepted as a development-server endpoint. */ +export const httpServerUrlSchema = z.url({ protocol: /^https?$/ }); + /** * Private, link-local, and otherwise reserved IP ranges that a framework-issued * outbound request to a caller-supplied URL must not target. This is the SSRF @@ -38,6 +43,33 @@ function normalizeAddress(host: string): string { return withoutZone; } +/** + * Returns whether `hostname` names the current machine's loopback interface. + * Accepts the full IPv4 loopback block, IPv6 loopback, and the RFC 6761 + * `localhost` namespace. Wildcard bind addresses such as `0.0.0.0` are not + * loopback connect targets. + */ +export function isLoopbackHostname(hostname: string): boolean { + const normalized = normalizeAddress(hostname).toLowerCase(); + + if (normalized === "localhost" || normalized.endsWith(".localhost")) { + return true; + } + + const family = isIP(normalized); + if (family === 4) { + return normalized.startsWith("127."); + } + + return family === 6 && normalized === "::1"; +} + +/** Returns whether `urlText` is an HTTP(S) URL with a loopback hostname. */ +export function isLoopbackServerUrl(urlText: string): boolean { + const parsed = httpServerUrlSchema.safeParse(urlText); + return parsed.success && isLoopbackHostname(new URL(parsed.data).hostname); +} + /** * Whether `host` is an IP literal in a private, link-local, or otherwise * reserved range that an outbound framework request must not target — an SSRF diff --git a/packages/eve/src/shared/result.test.ts b/packages/eve/src/shared/result.test.ts new file mode 100644 index 000000000..1e3be34e5 --- /dev/null +++ b/packages/eve/src/shared/result.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { err, ok, type Result } from "#shared/result.js"; + +describe("Result", () => { + it("constructs a success", () => { + expect(ok(42)).toEqual({ ok: true, value: 42 }); + }); + + it("constructs a failure", () => { + expect(err("nope")).toEqual({ ok: false, error: "nope" }); + }); + + it("narrows by the ok discriminant", () => { + function parseNumber(value: number): Result { + return Number.isNaN(value) ? err("nan") : ok(value); + } + + const result = parseNumber(1); + expect(result.ok ? result.value : result.error).toBe(1); + }); +}); diff --git a/packages/eve/src/shared/result.ts b/packages/eve/src/shared/result.ts new file mode 100644 index 000000000..36e8a14bb --- /dev/null +++ b/packages/eve/src/shared/result.ts @@ -0,0 +1,14 @@ +/** A typed success-or-failure value for expected, recoverable outcomes. */ +export type Result = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: E }; + +/** Wraps a value as a successful {@link Result}. */ +export function ok(value: T): Result { + return { ok: true, value }; +} + +/** Wraps an error as a failed {@link Result}. */ +export function err(error: E): Result { + return { ok: false, error }; +} diff --git a/packages/eve/test/scenarios/cli.scenario.test.ts b/packages/eve/test/scenarios/cli.scenario.test.ts index 541070a1e..4da5912aa 100644 --- a/packages/eve/test/scenarios/cli.scenario.test.ts +++ b/packages/eve/test/scenarios/cli.scenario.test.ts @@ -282,9 +282,12 @@ describe("runCli", () => { error: vi.fn(), log: vi.fn(), }; - const startHost = vi.fn(async () => { - throw new Error("dev started"); - }); + const startHost = vi.fn(() => ({ + start: async () => { + throw new Error("dev started"); + }, + close: async () => {}, + })); await expect( runCli([], logger, { diff --git a/packages/eve/test/tui-client/tui-status-line.ts b/packages/eve/test/tui-client/tui-status-line.ts index 4b1fb13df..d7b64319b 100644 --- a/packages/eve/test/tui-client/tui-status-line.ts +++ b/packages/eve/test/tui-client/tui-status-line.ts @@ -50,6 +50,7 @@ async function runPendingDeployCycle(): Promise { userInput: input, name: "TUI status line", appRoot, + serverUrl: UNREACHABLE_HOST, promptCommandHandler: createPromptCommandHandler({ target: { kind: "local", serverUrl: UNREACHABLE_HOST, workspaceRoot: appRoot }, flows: { @@ -62,6 +63,8 @@ async function runPendingDeployCycle(): Promise { try { await screen.waitForText("❯", 5_000); + await screen.waitForText(` :${new URL(UNREACHABLE_HOST).port} `, 5_000); + console.log(theme.muted("[tui-status-line] local loopback badge rendered")); input.type("/channels"); input.enter(); @@ -109,6 +112,7 @@ async function runUnlinkedShowsNoVercelSegment(): Promise { userInput: input, name: "TUI status line unlinked", appRoot, + serverUrl: UNREACHABLE_HOST, promptCommandHandler: createPromptCommandHandler({ target: { kind: "local", serverUrl: UNREACHABLE_HOST, workspaceRoot: appRoot }, }),