diff --git a/apps/mapgen-studio/package.json b/apps/mapgen-studio/package.json index b3d6fd022b..bc3f00744a 100644 --- a/apps/mapgen-studio/package.json +++ b/apps/mapgen-studio/package.json @@ -5,7 +5,10 @@ "private": true, "packageManager": "bun@1.3.7", "scripts": { - "dev": "vite", + "dev": "bun src/server/daemon/devLive.ts", + "dev:frontend": "vite", + "dev:server": "bun src/server/daemon/daemon.ts", + "serve": "bun src/server/daemon/daemon.ts --assets-root dist", "gen:civ7-tables": "bun ../../scripts/mapgen-studio/generate-civ7-browser-tables.ts", "check:worker-bundle": "node scripts/check-worker-bundle.mjs", "check": "tsc --noEmit", diff --git a/apps/mapgen-studio/src/server/daemon/daemon.ts b/apps/mapgen-studio/src/server/daemon/daemon.ts new file mode 100644 index 0000000000..07c8268b13 --- /dev/null +++ b/apps/mapgen-studio/src/server/daemon/daemon.ts @@ -0,0 +1,220 @@ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { extname, join, normalize, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { createStudioRpcHandler } from "@civ7/studio-server"; + +import { STUDIO_CIV7_CONTROL_ORPC_PATH } from "../../shared/civ7ControlOrpc"; +import { STUDIO_RECIPE_DAG_ORPC_PATH } from "../../shared/recipeDagOrpc"; +import { createStudioCiv7ControlRpcHandler } from "../civ7ControlOrpc"; +import { createStudioRecipeDagRpcHandler } from "../recipeDag/orpc"; +import { createStudioServerContext } from "../studio/context"; +import { createStudioEngines } from "../studio/engines"; + +// ============================================================================ +// Studio daemon — the standalone Bun server owning the studio server surface +// (bun-server workstream, daemon slice; topology per the gt-stack-inspect +// blueprint: Bun.serve fetch router + dev-live runner + Vite proxy). +// ---------------------------------------------------------------------------- +// This process owns EVERY `/rpc` + `/api` byte and ALL server state (the one +// `createStudioEngines` instance — queue, operation stores, instance +// identity). Vite is frontend-only and proxies here. Running under Bun also +// dissolves the effect-orpc TS-source constraint: the recipe-DAG handler is a +// plain static import (no `ssrLoadModule`). +// +// The server surface is oRPC ONLY (user directive 2026-06-12: no legacy, no +// fallbacks): `/rpc` (studio-server), `/api/civ7/rpc` (control), and +// `/api/recipe-dag/rpc` (recipe DAG). The 16 hand-rolled legacy `/api/*` REST +// handlers from the Vite middleware era are RETIRED — any other `/api` path +// is a 404. +// +// Executed with `bun src/server/daemon/daemon.ts` (never under node). The +// fetch composition (`createStudioDaemonFetch`) is pure and unit-tested under +// vitest; only `main()` touches `Bun.serve`. +// ============================================================================ + +declare const Bun: { + serve(options: { + hostname: string; + port: number; + idleTimeout?: number; + fetch(request: Request): Response | Promise; + }): { hostname: string; port: number; stop(force?: boolean): void }; +}; + +export type StudioDaemonArgs = Readonly<{ + host: string; + port: number; + repoRoot: string; + assetsRoot?: string; +}>; + +export const STUDIO_DAEMON_DEFAULT_PORT = 5174; + +const mimeTypes: Record = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".woff2": "font/woff2", +}; + +export function parseStudioDaemonArgs( + argv: readonly string[], + defaults: Readonly<{ repoRoot: string }>, +): StudioDaemonArgs { + const options = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg || !arg.startsWith("--")) throw new Error(`Unexpected positional argument: ${arg}`); + const key = arg.slice(2); + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for --${key}`); + options.set(key, value); + index += 1; + } + const assetsRoot = options.get("assets-root"); + return { + host: options.get("host") ?? "127.0.0.1", + port: Number(options.get("port") ?? STUDIO_DAEMON_DEFAULT_PORT), + repoRoot: resolve(options.get("repo-root") ?? defaults.repoRoot), + ...(assetsRoot ? { assetsRoot: resolve(assetsRoot) } : {}), + }; +} + +/** + * Static SPA serving (the production story's opening): only active when + * `--assets-root` points at a built `dist`. Path-jailed to the assets root + * with an index.html fallback for client routes (blueprint pattern). + */ +function staticResponse(assetsRoot: string, pathname: string): Response { + if (pathname === "/favicon.ico") return new Response(null, { status: 204 }); + const requested = pathname === "/" ? "/index.html" : pathname; + const normalized = normalize(decodeURIComponent(requested)).replace(/^(\.\.(\/|\\|$))+/, ""); + const candidate = resolve(join(assetsRoot, normalized)); + const rootWithSep = assetsRoot.endsWith(sep) ? assetsRoot : `${assetsRoot}${sep}`; + const filePath = candidate.startsWith(rootWithSep) ? candidate : join(assetsRoot, "index.html"); + const fallbackPath = join(assetsRoot, "index.html"); + const requestedExt = extname(filePath); + if ((!existsSync(filePath) || !statSync(filePath).isFile()) && requestedExt) { + return new Response(`Asset not found: ${pathname}`, { status: 404 }); + } + const finalPath = existsSync(filePath) && statSync(filePath).isFile() ? filePath : fallbackPath; + + if (!existsSync(finalPath)) { + return new Response("Studio assets not found. Run `bun run build` first.", { status: 503 }); + } + return new Response(readFileSync(finalPath), { + headers: { + "content-type": mimeTypes[extname(finalPath)] ?? "application/octet-stream", + }, + }); +} + +export interface StudioDaemonDeps { + studioRpc: { + handle( + request: Request, + options?: { prefix?: `/${string}` }, + ): Promise<{ matched: boolean; response?: Response }>; + }; + controlRpc: { handle(request: Request): Promise<{ matched: boolean; response?: Response }> }; + recipeDagRpc: { handle(request: Request): Promise<{ matched: boolean; response?: Response }> }; + health(): { ok: boolean } & Record; + assetsRoot?: string; +} + +/** Pure fetch router over injected handlers — unit-testable without Bun.serve. */ +export function createStudioDaemonFetch( + deps: StudioDaemonDeps, +): (request: Request) => Promise { + return async (request) => { + const url = new URL(request.url); + const pathname = url.pathname; + + if (pathname === "/healthz") { + const health = deps.health(); + return Response.json(health, { status: health.ok ? 200 : 503 }); + } + + if (pathname.startsWith("/rpc")) { + const { matched, response } = await deps.studioRpc.handle(request, { prefix: "/rpc" }); + if (matched && response) return response; + return new Response("Not Found", { status: 404 }); + } + + if (pathname.startsWith(STUDIO_CIV7_CONTROL_ORPC_PATH)) { + const { matched, response } = await deps.controlRpc.handle(request); + if (matched && response) return response; + return new Response("Not Found", { status: 404 }); + } + + if (pathname.startsWith(STUDIO_RECIPE_DAG_ORPC_PATH)) { + const { matched, response } = await deps.recipeDagRpc.handle(request); + if (matched && response) return response; + return new Response("Not Found", { status: 404 }); + } + + // Retired legacy REST surface (no fallbacks): any other /api path is 404. + if (pathname.startsWith("/api/")) { + return new Response("Not Found", { status: 404 }); + } + + if (deps.assetsRoot) { + return staticResponse(deps.assetsRoot, pathname); + } + + return new Response("Not Found", { status: 404 }); + }; +} + +export function createStudioDaemon(args: StudioDaemonArgs) { + const engines = createStudioEngines({ repoRoot: args.repoRoot }); + const context = createStudioServerContext({ engines, hostCommand: "daemon" }); + const deps: StudioDaemonDeps = { + studioRpc: createStudioRpcHandler(context), + controlRpc: createStudioCiv7ControlRpcHandler(), + recipeDagRpc: createStudioRecipeDagRpcHandler(), + health: () => ({ + ok: true, + serverInstanceId: engines.serverInstanceId, + startedAt: engines.serverStartedAt, + repoRoot: args.repoRoot, + assetsRoot: args.assetsRoot ?? null, + runtimeMode: "studio-daemon-effect-orpc", + }), + ...(args.assetsRoot ? { assetsRoot: args.assetsRoot } : {}), + }; + const fetch = createStudioDaemonFetch(deps); + + return { + engines, + start() { + const server = Bun.serve({ + hostname: args.host, + port: args.port, + // Run-in-game launches block their HTTP request for long stretches; + // never idle-close them. + idleTimeout: 0, + fetch, + }); + return server; + }, + }; +} + +function defaultRepoRoot(): string { + // src/server/daemon/daemon.ts → apps/mapgen-studio → repo root. + return fileURLToPath(new URL("../../../../..", import.meta.url)); +} + +if ((import.meta as { main?: boolean }).main) { + const args = parseStudioDaemonArgs(process.argv.slice(2), { repoRoot: defaultRepoRoot() }); + const daemon = createStudioDaemon(args); + const server = daemon.start(); + process.stdout.write( + `mapgen-studio daemon listening on http://${server.hostname}:${server.port} (repoRoot ${args.repoRoot})\n`, + ); +} diff --git a/apps/mapgen-studio/src/server/daemon/devLive.ts b/apps/mapgen-studio/src/server/daemon/devLive.ts new file mode 100644 index 0000000000..514809af50 --- /dev/null +++ b/apps/mapgen-studio/src/server/daemon/devLive.ts @@ -0,0 +1,181 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { STUDIO_DAEMON_DEFAULT_PORT } from "./daemon"; + +// ============================================================================ +// Dev-live runner — `bun run dev` (bun-server workstream, daemon slice) +// ---------------------------------------------------------------------------- +// Spawns the studio daemon (backend), waits for `/healthz`, then spawns the +// Vite frontend, whose `server.proxy` forwards `/rpc` + `/api` to the daemon +// (target via STUDIO_DEV_RPC_TARGET). Either child exiting stops the other — +// one Ctrl-C tears the whole topology down (gt-stack-inspect blueprint). +// ============================================================================ + +export type DevLiveArgs = Readonly<{ + host: string; + frontendPort: number; + backendPort: number; + readinessTimeoutMs: number; + printPlan: boolean; +}>; + +export type DevLiveCommand = Readonly<{ + command: string; + args: readonly string[]; + cwd: string; + env?: Readonly>; +}>; + +export type DevLivePlan = Readonly<{ + backendUrl: string; + frontendUrl: string; + rpcProxyTarget: string; + daemon: DevLiveCommand; + vite: DevLiveCommand; +}>; + +type RunningChild = { name: string; process: ChildProcess }; + +const appRoot = fileURLToPath(new URL("../../..", import.meta.url)); +const bunExecutable = process.env.BUN_EXECUTABLE ?? process.execPath ?? "bun"; + +export function parseDevLiveArgs(argv: readonly string[]): DevLiveArgs { + const options = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (!arg || !arg.startsWith("--")) throw new Error(`Unexpected positional argument: ${arg}`); + const key = arg.slice(2); + if (key === "print-plan") { + options.set(key, true); + continue; + } + const value = argv[index + 1]; + if (!value || typeof value !== "string" || value.startsWith("--")) { + throw new Error(`Missing value for --${key}`); + } + options.set(key, value); + index += 1; + } + const readNumber = (key: string): number | undefined => { + const value = options.get(key); + return typeof value === "string" ? Number(value) : undefined; + }; + const host = options.get("host"); + return { + host: typeof host === "string" ? host : "127.0.0.1", + frontendPort: readNumber("port") ?? 5173, + backendPort: readNumber("backend-port") ?? STUDIO_DAEMON_DEFAULT_PORT, + readinessTimeoutMs: readNumber("readiness-timeout-ms") ?? 30_000, + printPlan: options.get("print-plan") === true, + }; +} + +export function makeDevLivePlan(args: DevLiveArgs): DevLivePlan { + const backendUrl = `http://${args.host}:${args.backendPort}`; + return { + backendUrl, + frontendUrl: `http://${args.host}:${args.frontendPort}/`, + rpcProxyTarget: backendUrl, + daemon: { + command: bunExecutable, + args: [ + "src/server/daemon/daemon.ts", + "--host", + args.host, + "--port", + String(args.backendPort), + ], + cwd: appRoot, + }, + vite: { + command: bunExecutable, + args: ["run", "dev:frontend"], + cwd: appRoot, + env: { STUDIO_DEV_RPC_TARGET: backendUrl }, + }, + }; +} + +function startChild(name: string, command: DevLiveCommand): RunningChild { + const child = spawn(command.command, [...command.args], { + cwd: command.cwd, + stdio: "inherit", + env: { ...process.env, ...(command.env ?? {}) }, + }); + return { name, process: child }; +} + +async function waitForDaemonReadiness( + backendUrl: string, + timeoutMs: number, + daemon: RunningChild, +): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt <= timeoutMs) { + if (daemon.process.exitCode !== null) { + throw new Error(`daemon exited before readiness (code ${daemon.process.exitCode})`); + } + try { + const res = await fetch(`${backendUrl}/healthz`); + if (res.ok) return; + } catch { + // Daemon is still booting. + } + await new Promise((resolveSleep) => setTimeout(resolveSleep, 250)); + } + throw new Error(`daemon did not become ready within ${timeoutMs}ms (${backendUrl}/healthz)`); +} + +function waitForFirstExit(children: readonly RunningChild[]): Promise { + return new Promise((resolveExit) => { + for (const child of children) { + child.process.once("exit", () => resolveExit(child)); + } + }); +} + +export async function runDevLive(args: DevLiveArgs): Promise { + const plan = makeDevLivePlan(args); + if (args.printPlan) { + process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`); + return; + } + + const running: RunningChild[] = []; + let shuttingDown = false; + const stopAll = (signal: NodeJS.Signals = "SIGTERM") => { + if (shuttingDown) return; + shuttingDown = true; + for (const child of [...running].reverse()) { + if (child.process.exitCode === null) child.process.kill(signal); + } + }; + process.once("SIGINT", () => stopAll("SIGINT")); + process.once("SIGTERM", () => stopAll("SIGTERM")); + + try { + const daemon = startChild("daemon", plan.daemon); + running.push(daemon); + await waitForDaemonReadiness(plan.backendUrl, args.readinessTimeoutMs, daemon); + process.stdout.write(`mapgen-studio daemon ready at ${plan.backendUrl}\n`); + + const vite = startChild("vite", plan.vite); + running.push(vite); + process.stdout.write( + `mapgen-studio frontend at ${plan.frontendUrl} (proxying /rpc + /api to ${plan.rpcProxyTarget})\n`, + ); + + const exited = await waitForFirstExit(running); + process.stdout.write(`${exited.name} exited; stopping dev-live.\n`); + } finally { + stopAll(); + } +} + +if ((import.meta as { main?: boolean }).main) { + runDevLive(parseDevLiveArgs(process.argv.slice(2))).catch((err: unknown) => { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + process.exit(1); + }); +} diff --git a/apps/mapgen-studio/src/server/studio/context.ts b/apps/mapgen-studio/src/server/studio/context.ts index c15aa8a34d..d627f26834 100644 --- a/apps/mapgen-studio/src/server/studio/context.ts +++ b/apps/mapgen-studio/src/server/studio/context.ts @@ -8,16 +8,13 @@ import { RunInGameHttpError } from "../runInGame/operationState"; import type { StudioEngines } from "./engines"; /** - * Build the `StudioServerContext` the oRPC router consumes — the SAME engines + - * stores the legacy `/api/*` handlers use (shared state, no divergence). Engine - * `RunInGameHttpError`s are converted to `ORPCError` with the legacy status + - * `details`/`observedAt` payload so the non-uniform codes survive the oRPC - * boundary. Moved verbatim from `vite.config.ts` (`createStudioServerContextForApp`) - * in the bun-server engine-extraction slice; the host now injects the engines, - * the repo root, and its command label (`vite` dev: "serve"; Bun daemon: "daemon"). - * - * Import constraint: `@civ7/studio-server` is safe to import from node-evaluated - * config code (tsup bundles effect-orpc into its dist) — see ./engines.ts header. + * Build the `StudioServerContext` the oRPC router consumes over the process's + * one engines instance. Engine `RunInGameHttpError`s are converted to + * `ORPCError` with the historical status + `details` payload so the + * non-uniform codes survive the oRPC boundary (architecture/10 §1). Moved + * verbatim from `vite.config.ts` (`createStudioServerContextForApp`) in the + * bun-server engine-extraction slice; the host (the Bun daemon) injects the + * engines and its command label. */ export function createStudioServerContext(options: Readonly<{ engines: StudioEngines; diff --git a/apps/mapgen-studio/src/server/studio/engines.ts b/apps/mapgen-studio/src/server/studio/engines.ts index 827778672b..ba0e6954a1 100644 --- a/apps/mapgen-studio/src/server/studio/engines.ts +++ b/apps/mapgen-studio/src/server/studio/engines.ts @@ -50,22 +50,16 @@ import { buildLiveRuntimeStatusState } from "../../features/liveRuntime/model"; // queue, both operation stores (the dual-store 409 mutex), the server instance // identity, and the five engine functions (autoplay, run-in-game start/status, // save-deploy start/status). `createStudioEngines` owns ALL process-singleton -// server state — exactly one instance may exist per server process, shared by -// every transport (the `/rpc` oRPC mount and the legacy `/api/*` handlers), or -// the queue/mutex semantics diverge (architecture/10 §7). +// server state — exactly one instance may exist per server process (the Bun +// daemon), shared by every oRPC mount, or the queue/mutex semantics diverge +// (architecture/10 §7). // -// Each engine returns the SAME success body the legacy `/api` handler wrote, or -// THROWS: -// - `RunInGameHttpError` (carries statusCode + details) — used as-is, also -// for the autoplay/save-deploy 409 mutex + run-in-game/save-deploy 404. +// Each engine returns its success body, or THROWS: +// - `RunInGameHttpError` (carries statusCode + details) — preserves the +// non-uniform legacy status codes (409 mutex, run-in-game/save-deploy 404). // - a plain `Error` — validation/save failures (mapped to 400 by the caller). -// The `/api` middleware adapts return/throw → `res`; the oRPC context adapts -// return/throw → value/ORPCError (./context.ts, `orpcError` mapping). -// -// IMPORT CONSTRAINT (load-bearing): this module is statically imported by -// `vite.config.ts`, so its import graph must stay node-evaluable — never -// import `effect-orpc` (TS-source package entry) or anything that does outside -// a bundled dist. `@civ7/direct-control` + local server modules only. +// The oRPC context adapts return/throw → value/ORPCError (./context.ts, +// `orpcError` mapping), keeping those statuses across the oRPC boundary. // ============================================================================ const execFileAsync = promisify(execFile); diff --git a/apps/mapgen-studio/test/server/daemonFetch.test.ts b/apps/mapgen-studio/test/server/daemonFetch.test.ts new file mode 100644 index 0000000000..5193b2ea91 --- /dev/null +++ b/apps/mapgen-studio/test/server/daemonFetch.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "vitest"; + +import { createStudioDaemonFetch, type StudioDaemonDeps } from "../../src/server/daemon/daemon"; +import { STUDIO_CIV7_CONTROL_ORPC_PATH } from "../../src/shared/civ7ControlOrpc"; +import { STUDIO_RECIPE_DAG_ORPC_PATH } from "../../src/shared/recipeDagOrpc"; + +function makeDeps(overrides: Partial = {}): { + deps: StudioDaemonDeps; + calls: string[]; +} { + const calls: string[] = []; + const handler = (name: string, matched = true) => ({ + handle: async () => { + calls.push(name); + return matched + ? { matched: true, response: new Response(name, { status: 200 }) } + : { matched: false as const }; + }, + }); + const deps: StudioDaemonDeps = { + studioRpc: handler("studio"), + controlRpc: handler("control"), + recipeDagRpc: handler("recipeDag"), + health: () => ({ ok: true, probe: "test" }), + ...overrides, + }; + return { deps, calls }; +} + +describe("studio daemon fetch router", () => { + test("serves /healthz from the health probe", async () => { + const { deps } = makeDeps(); + const fetchHandler = createStudioDaemonFetch(deps); + const res = await fetchHandler(new Request("http://daemon.test/healthz")); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ ok: true, probe: "test" }); + }); + + test("healthz reports 503 when the probe is unhealthy", async () => { + const { deps } = makeDeps({ health: () => ({ ok: false }) }); + const res = await createStudioDaemonFetch(deps)(new Request("http://daemon.test/healthz")); + expect(res.status).toBe(503); + }); + + test("routes the three oRPC prefixes to their handlers", async () => { + const { deps, calls } = makeDeps(); + const fetchHandler = createStudioDaemonFetch(deps); + + const studio = await fetchHandler( + new Request("http://daemon.test/rpc/civ7/status", { method: "POST" }), + ); + const control = await fetchHandler( + new Request(`http://daemon.test${STUDIO_CIV7_CONTROL_ORPC_PATH}/readiness/current`, { + method: "POST", + }), + ); + const recipeDag = await fetchHandler( + new Request(`http://daemon.test${STUDIO_RECIPE_DAG_ORPC_PATH}/recipeDag/get`, { + method: "POST", + }), + ); + + await expect(studio.text()).resolves.toBe("studio"); + await expect(control.text()).resolves.toBe("control"); + await expect(recipeDag.text()).resolves.toBe("recipeDag"); + expect(calls).toEqual(["studio", "control", "recipeDag"]); + }); + + test("unmatched RPC paths under a mounted prefix are 404", async () => { + const { deps } = makeDeps({ + studioRpc: { handle: async () => ({ matched: false }) }, + }); + const res = await createStudioDaemonFetch(deps)( + new Request("http://daemon.test/rpc/nope", { method: "POST" }), + ); + expect(res.status).toBe(404); + }); + + test("retired legacy /api paths are 404 (no fallbacks)", async () => { + const { deps, calls } = makeDeps(); + const fetchHandler = createStudioDaemonFetch(deps); + for (const path of [ + "/api/civ7/status", + "/api/civ7/run-in-game/status?requestId=x", + "/api/map-configs", + "/api/studio/server-info", + ]) { + const res = await fetchHandler(new Request(`http://daemon.test${path}`)); + expect(res.status, path).toBe(404); + } + expect(calls).toEqual([]); + }); + + test("non-API paths without an assets root are 404", async () => { + const { deps } = makeDeps(); + const res = await createStudioDaemonFetch(deps)(new Request("http://daemon.test/")); + expect(res.status).toBe(404); + }); +}); diff --git a/apps/mapgen-studio/test/server/devLivePlan.test.ts b/apps/mapgen-studio/test/server/devLivePlan.test.ts new file mode 100644 index 0000000000..f9b7c54696 --- /dev/null +++ b/apps/mapgen-studio/test/server/devLivePlan.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "vitest"; + +import { makeDevLivePlan, parseDevLiveArgs } from "../../src/server/daemon/devLive"; + +describe("dev-live plan", () => { + test("defaults: daemon on 5174, vite frontend proxying to it", () => { + const plan = makeDevLivePlan(parseDevLiveArgs([])); + expect(plan.backendUrl).toBe("http://127.0.0.1:5174"); + expect(plan.rpcProxyTarget).toBe(plan.backendUrl); + expect(plan.frontendUrl).toBe("http://127.0.0.1:5173/"); + expect(plan.daemon.args).toEqual([ + "src/server/daemon/daemon.ts", + "--host", + "127.0.0.1", + "--port", + "5174", + ]); + expect(plan.vite.args).toEqual(["run", "dev:frontend"]); + expect(plan.vite.env).toEqual({ STUDIO_DEV_RPC_TARGET: "http://127.0.0.1:5174" }); + }); + + test("ports and host are overridable", () => { + const plan = makeDevLivePlan( + parseDevLiveArgs(["--host", "0.0.0.0", "--port", "6000", "--backend-port", "6001"]), + ); + expect(plan.backendUrl).toBe("http://0.0.0.0:6001"); + expect(plan.frontendUrl).toBe("http://0.0.0.0:6000/"); + expect(plan.vite.env).toEqual({ STUDIO_DEV_RPC_TARGET: "http://0.0.0.0:6001" }); + }); +}); diff --git a/apps/mapgen-studio/vite.config.ts b/apps/mapgen-studio/vite.config.ts index 1f6096c444..81ba3a00a5 100644 --- a/apps/mapgen-studio/vite.config.ts +++ b/apps/mapgen-studio/vite.config.ts @@ -2,398 +2,27 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { fileURLToPath } from "node:url"; -import { - DEFAULT_CIV7_TUNER_TIMEOUT_MS, - getCiv7AppUiSnapshot, - getCiv7AutoplayStatus, - getCiv7CitySummary, - getCiv7GameInfoRows, - getCiv7MapGrid, - getCiv7MapSummary, - getCiv7PlayableStatus, - getCiv7SetupSnapshot, - getCiv7PlayerSummary, - getCiv7UnitSummary, - listCiv7SavedGameConfigurations, -} from "@civ7/direct-control"; -import { loadCiv7SetupCatalog } from "./src/server/civ7Resources/catalog"; -import { RunInGameHttpError } from "./src/server/runInGame/operationState"; -import { createStudioEngines, type RunInGameStartEngineBody } from "./src/server/studio/engines"; -import { createStudioServerContext } from "./src/server/studio/context"; -import { handleStudioCiv7ControlOrpcRequest } from "./src/server/civ7ControlOrpc"; -import { STUDIO_RECIPE_DAG_ORPC_PATH } from "./src/shared/recipeDagOrpc"; -import { nodeRequestToWebRequest, writeWebResponse } from "./src/server/http/nodeWebBridge"; -import { createStudioRpcHandler } from "@civ7/studio-server"; // =========================================================================== -// Studio server surface (P5a coexistence state) +// Frontend-only Vite config (bun-server workstream, daemon cutover). // --------------------------------------------------------------------------- -// The stateful engines (serialized queue, run-in-game + save/deploy operation -// stores, instance identity) live in ./src/server/studio/engines.ts — -// `createStudioEngines` below is the ONE instance for this process, shared by -// the legacy `/api/*` handlers AND the `/rpc` oRPC mount (no state divergence, -// architecture/10 §7). The bun-server workstream's daemon slice moves this -// whole surface into a standalone Bun process and reduces this config to -// frontend-only proxying. +// The studio server surface — `/rpc` (studio-server), `/api/civ7/rpc` +// (control-oRPC), `/api/recipe-dag/rpc` (recipe DAG), and ALL server state — +// lives in the standalone Bun daemon (src/server/daemon/daemon.ts). Dev runs +// both via `bun run dev` (src/server/daemon/devLive.ts); this config only +// proxies `/rpc` + `/api` to the daemon. The legacy hand-rolled `/api/*` REST +// handlers are RETIRED (user directive 2026-06-12: no legacy, no fallbacks) — +// unknown `/api` paths 404 at the daemon. // -// Import constraint: everything imported here is evaluated by NODE at config -// load — no effect-orpc in the static graph (see engines.ts header). The -// recipe-DAG mount stays on per-request `ssrLoadModule` for exactly that -// reason. +// No server modules are imported here anymore: config evaluation is cheap, +// restarts are safe, and the effect-orpc TS-source constraint is gone (the +// daemon runs under Bun, which loads TS natively). // =========================================================================== -const STUDIO_REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); -const studioEngines = createStudioEngines({ repoRoot: STUDIO_REPO_ROOT }); - -async function readJsonBody(req: AsyncIterable): Promise { - const chunks: Buffer[] = []; - for await (const chunk of req) chunks.push(Buffer.from(chunk)); - return JSON.parse(Buffer.concat(chunks).toString("utf-8")) as T; -} - -function writeJson(res: { statusCode: number; setHeader(name: string, value: string): void; end(body?: string): void }, statusCode: number, body: unknown): void { - res.statusCode = statusCode; - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify(body)); -} +const STUDIO_DEV_RPC_TARGET = process.env.STUDIO_DEV_RPC_TARGET ?? "http://127.0.0.1:5174"; export default defineConfig(({ command }) => ({ - plugins: [ - react(), - tailwindcss(), - { - name: "repo-backed-map-configs", - configureServer(server) { - server.middlewares.use(handleStudioCiv7ControlOrpcRequest); - // Recipe-DAG oRPC mount. The handler module MUST load through Vite's - // SSR pipeline: its effect-orpc router layer imports `effect-orpc`, - // whose package entry is TypeScript SOURCE — Node cannot type-strip - // under node_modules, so a static import here breaks config evaluation - // (@civ7/studio-server avoids this only by bundling effect-orpc into - // its dist). `ssrLoadModule` is called PER REQUEST — Vite's documented - // SSR pattern: unchanged graphs are cheap cache hits, and edits to the - // server module are picked up on the next request (the previous - // forever-memoized promise served the first load until restart). The - // path pre-check keeps the SSR loader off every other request. - server.middlewares.use((req, res, next) => { - const path = (req as { originalUrl?: string }).originalUrl ?? req.url ?? "/"; - if (!path.startsWith(STUDIO_RECIPE_DAG_ORPC_PATH)) return next(); - server.ssrLoadModule("/src/server/recipeDag/orpc.ts").then( - (module) => - void (module as typeof import("./src/server/recipeDag/orpc")) - .handleStudioRecipeDagOrpcRequest(req, res, next), - next, - ); - }); - server.middlewares.use("/api/civ7/status", async (req, res, next) => { - if (req.method !== "GET") return next(); - try { - const status = await getCiv7PlayableStatus({ - timeoutMs: DEFAULT_CIV7_TUNER_TIMEOUT_MS, - }); - writeJson(res, 200, { ok: status.playable, status }); - } catch (err) { - const error = err instanceof Error ? err.message : "Civ7 status request failed"; - writeJson(res, 500, { ok: false, error }); - } - }); - server.middlewares.use("/api/civ7/map-summary", async (req, res, next) => { - if (req.method !== "GET") return next(); - try { - const summary = await getCiv7MapSummary({ - timeoutMs: DEFAULT_CIV7_TUNER_TIMEOUT_MS, - includeAreaRegionCounts: true, - }); - writeJson(res, 200, { ok: true, summary }); - } catch (err) { - const error = err instanceof Error ? err.message : "Civ7 map summary request failed"; - writeJson(res, 500, { ok: false, error }); - } - }); - server.middlewares.use("/api/civ7/gameinfo", async (req, res, next) => { - if (req.method !== "GET") return next(); - try { - const url = new URL(req.url ?? "", "http://localhost"); - const table = url.searchParams.get("table"); - if (!table) throw new Error("Missing table query parameter"); - const limit = Number(url.searchParams.get("limit") ?? "100"); - const rows = await getCiv7GameInfoRows({ - table, - limit, - }, { - timeoutMs: DEFAULT_CIV7_TUNER_TIMEOUT_MS, - }); - writeJson(res, 200, { ok: true, rows }); - } catch (err) { - const error = err instanceof Error ? err.message : "Civ7 GameInfo request failed"; - writeJson(res, 400, { ok: false, error }); - } - }); - server.middlewares.use("/api/civ7/live/status", async (req, res, next) => { - if (req.method !== "GET") return next(); - try { - const [status, appUi, mapSummary, autoplay] = await Promise.allSettled([ - getCiv7PlayableStatus({ timeoutMs: DEFAULT_CIV7_TUNER_TIMEOUT_MS }), - getCiv7AppUiSnapshot({ timeoutMs: DEFAULT_CIV7_TUNER_TIMEOUT_MS }), - getCiv7MapSummary({ timeoutMs: DEFAULT_CIV7_TUNER_TIMEOUT_MS, includeAreaRegionCounts: false }), - getCiv7AutoplayStatus({ timeoutMs: DEFAULT_CIV7_TUNER_TIMEOUT_MS }), - ]); - const playableStatus = status.status === "fulfilled" ? status.value : undefined; - writeJson(res, 200, { - ok: Boolean(playableStatus && playableStatus.readiness !== "unavailable"), - playable: playableStatus?.playable ?? false, - observedAt: new Date().toISOString(), - status: playableStatus ?? { error: String(status.reason) }, - appUi: appUi.status === "fulfilled" ? appUi.value : { error: String(appUi.reason) }, - mapSummary: mapSummary.status === "fulfilled" ? mapSummary.value : { error: String(mapSummary.reason) }, - autoplay: autoplay.status === "fulfilled" ? autoplay.value : { error: String(autoplay.reason) }, - }); - } catch (err) { - const error = err instanceof Error ? err.message : "Civ7 live status request failed"; - writeJson(res, 500, { ok: false, error }); - } - }); - server.middlewares.use("/api/civ7/live/snapshot", async (req, res, next) => { - if (req.method !== "GET") return next(); - try { - const url = new URL(req.url ?? "", "http://localhost"); - const bounds = { - x: Number(url.searchParams.get("x") ?? "0"), - y: Number(url.searchParams.get("y") ?? "0"), - width: Number(url.searchParams.get("width") ?? "24"), - height: Number(url.searchParams.get("height") ?? "18"), - }; - const fields = (url.searchParams.get("fields") ?? "terrain,biome,feature,resource,visibility,owner") - .split(",") - .map((field) => field.trim()) - .filter(Boolean) as Parameters[0]["fields"]; - const playerIdParam = url.searchParams.get("playerId"); - const maxPlots = Math.min(512, Math.max(1, Number(url.searchParams.get("maxPlots") ?? "512"))); - const grid = await getCiv7MapGrid({ - bounds, - fields, - maxPlots, - ...(playerIdParam === null ? {} : { playerId: Number(playerIdParam) }), - }, { - timeoutMs: DEFAULT_CIV7_TUNER_TIMEOUT_MS, - }); - writeJson(res, 200, { ok: true, observedAt: new Date().toISOString(), grid }); - } catch (err) { - const error = err instanceof Error ? err.message : "Civ7 live snapshot request failed"; - writeJson(res, 400, { ok: false, error }); - } - }); - server.middlewares.use("/api/civ7/live/entities", async (req, res, next) => { - if (req.method !== "GET") return next(); - try { - const url = new URL(req.url ?? "", "http://localhost"); - const playerIdParam = url.searchParams.get("playerId"); - const maxItems = Math.min(128, Math.max(1, Number(url.searchParams.get("maxItems") ?? "128"))); - const playerId = playerIdParam === null ? undefined : Number(playerIdParam); - const [players, units, cities] = await Promise.all([ - getCiv7PlayerSummary({ ...(playerId === undefined ? {} : { playerIds: [playerId] }), maxItems }, { timeoutMs: DEFAULT_CIV7_TUNER_TIMEOUT_MS }), - getCiv7UnitSummary({ ...(playerId === undefined ? {} : { playerId }), maxItems }, { timeoutMs: DEFAULT_CIV7_TUNER_TIMEOUT_MS }), - getCiv7CitySummary({ ...(playerId === undefined ? {} : { playerId }), maxItems }, { timeoutMs: DEFAULT_CIV7_TUNER_TIMEOUT_MS }), - ]); - writeJson(res, 200, { ok: true, observedAt: new Date().toISOString(), players, units, cities }); - } catch (err) { - const error = err instanceof Error ? err.message : "Civ7 live entities request failed"; - writeJson(res, 400, { ok: false, error }); - } - }); - server.middlewares.use("/api/civ7/live/gameinfo", async (req, res, next) => { - if (req.method !== "GET") return next(); - try { - const url = new URL(req.url ?? "", "http://localhost"); - const tables = (url.searchParams.get("tables") ?? "Terrains,Biomes,Features,Resources,Maps,MapSizes") - .split(",") - .map((table) => table.trim()) - .filter(Boolean) - .slice(0, 8); - const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? "100"))); - const rows = await Promise.all( - tables.map(async (table) => [table, await getCiv7GameInfoRows({ table, limit }, { timeoutMs: DEFAULT_CIV7_TUNER_TIMEOUT_MS })] as const), - ); - writeJson(res, 200, { ok: true, observedAt: new Date().toISOString(), tables: Object.fromEntries(rows) }); - } catch (err) { - const error = err instanceof Error ? err.message : "Civ7 live GameInfo request failed"; - writeJson(res, 400, { ok: false, error }); - } - }); - server.middlewares.use("/api/civ7/autoplay", async (req, res, next) => { - if (req.method !== "POST") return next(); - try { - const body = await readJsonBody<{ action?: unknown }>(req); - if (body.action !== "start" && body.action !== "stop") { - writeJson(res, 400, { ok: false, error: 'Autoplay action must be "start" or "stop"' }); - return; - } - // Shared engine (also drives the `/rpc` mount) — single source of truth. - const result = await studioEngines.runAutoplayEngine(body.action); - writeJson(res, 200, result); - } catch (err) { - if (err instanceof RunInGameHttpError) { - writeJson(res, err.statusCode, { - ok: false, - error: err.message, - ...(err.details === undefined ? {} : { details: err.details }), - }); - return; - } - const error = err instanceof Error ? err.message : "Civ7 autoplay request failed"; - writeJson(res, 500, { ok: false, error }); - } - }); - server.middlewares.use("/api/studio/server-info", async (req, res, next) => { - if (req.method !== "GET") return next(); - writeJson(res, 200, { - ok: true, - serverInstanceId: studioEngines.serverInstanceId, - startedAt: studioEngines.serverStartedAt, - runInGameApiVersion: 2, - viteCommand: command, - }); - }); - server.middlewares.use("/api/civ7/setup-config", async (req, res, next) => { - if (req.method !== "GET") return next(); - try { - const snapshot = await getCiv7SetupSnapshot({ timeoutMs: DEFAULT_CIV7_TUNER_TIMEOUT_MS }); - writeJson(res, 200, { - ok: true, - observedAt: new Date().toISOString(), - setup: snapshot.snapshot, - state: snapshot.state, - host: snapshot.host, - port: snapshot.port, - }); - } catch (err) { - const error = err instanceof Error ? err.message : "Civ7 setup config unavailable"; - writeJson(res, 503, { ok: false, error, observedAt: new Date().toISOString() }); - } - }); - server.middlewares.use("/api/civ7/saved-configs", async (req, res, next) => { - if (req.method !== "GET") return next(); - try { - const result = await listCiv7SavedGameConfigurations(); - writeJson(res, 200, { - ok: true, - observedAt: new Date().toISOString(), - ...result, - }); - } catch (err) { - const error = err instanceof Error ? err.message : "Civ7 saved configurations unavailable"; - writeJson(res, 500, { ok: false, error, observedAt: new Date().toISOString() }); - } - }); - server.middlewares.use("/api/civ7/setup-catalog", async (req, res, next) => { - if (req.method !== "GET") return next(); - try { - const catalog = await loadCiv7SetupCatalog({ repoRoot: STUDIO_REPO_ROOT }); - writeJson(res, 200, { ok: true, catalog }); - } catch (err) { - const error = err instanceof Error ? err.message : "Civ7 setup catalog unavailable"; - writeJson(res, 500, { ok: false, error, observedAt: new Date().toISOString() }); - } - }); - server.middlewares.use("/api/civ7/run-in-game/status", async (req, res, next) => { - if (req.method !== "GET") return next(); - const url = new URL(req.url ?? "", "http://localhost"); - const requestId = url.searchParams.get("requestId"); - if (!requestId) { - writeJson(res, 400, { ok: false, error: "Missing requestId" }); - return; - } - try { - writeJson(res, 200, studioEngines.runRunInGameStatusEngine(requestId)); - } catch { - // Parity: 404 echoes serverInstanceId/serverStartedAt for restart detection. - writeJson(res, 404, { - ok: false, - error: `Run in Game request not found: ${requestId}`, - serverInstanceId: studioEngines.serverInstanceId, - serverStartedAt: studioEngines.serverStartedAt, - }); - } - }); - server.middlewares.use("/api/civ7/run-in-game", async (req, res, next) => { - if (req.method !== "POST") return next(); - try { - const body = await readJsonBody(req); - // Shared engine (also drives the `/rpc` mount). Both the accepted and - // duplicate-request results return 202 with the operation snapshot. - const result = await studioEngines.runRunInGameStartEngine(body); - writeJson(res, 202, result.operation); - } catch (err) { - const error = err instanceof Error ? err.message : "Run in Game failed"; - const statusCode = err instanceof RunInGameHttpError ? err.statusCode : 500; - const details = err instanceof RunInGameHttpError ? err.details : undefined; - writeJson(res, statusCode, { - ok: false, - error, - ...(details === undefined ? {} : { details }), - }); - } - }); - server.middlewares.use("/api/map-configs/status", async (req, res, next) => { - if (req.method !== "GET") return next(); - const url = new URL(req.url ?? "", "http://localhost"); - const requestId = url.searchParams.get("requestId"); - if (!requestId) { - writeJson(res, 400, { ok: false, error: "Missing requestId" }); - return; - } - try { - // Shared engine. Parity: 404 here does NOT echo serverInstanceId. - writeJson(res, 200, studioEngines.runSaveDeployStatusEngine(requestId)); - } catch { - writeJson(res, 404, { ok: false, error: `Save/Deploy request not found: ${requestId}` }); - } - }); - server.middlewares.use("/api/map-configs", async (req, res, next) => { - if (req.method !== "POST") return next(); - try { - const body = await readJsonBody(req); - // Shared engine (also drives the `/rpc` mount). Idempotent-active reuse + - // 409 dual mutex + write-then-deploy + rollback live in the engine. - const operation = await studioEngines.runSaveDeployEngine(body); - writeJson(res, 202, operation); - } catch (err) { - if (err instanceof RunInGameHttpError) { - writeJson(res, err.statusCode, { - ok: false, - error: err.message, - ...(err.details === undefined ? {} : { details: err.details }), - }); - return; - } - const error = err instanceof Error ? err.message : "Save failed"; - writeJson(res, 400, { ok: false, error }); - } - }); - - // ------------------------------------------------------------------- - // oRPC RPCHandler mount (slice ServerOrpc). EVERYTHING talks oRPC: this - // mounts `@civ7/studio-server`'s effect-orpc router at `/rpc` INSIDE the - // existing Vite dev middleware — alongside the legacy `/api/*` handlers, - // which stay alive this run (coexistence; cutover is the bun-server - // workstream's daemon slice). Both transports share the SAME engines + - // stores (no state divergence). - // ------------------------------------------------------------------- - const studioRpc = createStudioRpcHandler( - createStudioServerContext({ engines: studioEngines, hostCommand: command }), - ); - server.middlewares.use("/rpc", async (req, res, next) => { - const request = await nodeRequestToWebRequest(req); - const { matched, response } = await studioRpc.handle(request, { prefix: "/rpc" }); - if (!matched || !response) { - next(); - return; - } - await writeWebResponse(res, response); - }); - }, - }, - ], + plugins: [react(), tailwindcss()], resolve: { alias: { // deck.gl -> loaders.gl includes a Node-only helper that imports `child_process`. @@ -418,6 +47,10 @@ export default defineConfig(({ command }) => ({ server: { port: 5173, strictPort: true, + proxy: { + "/rpc": { target: STUDIO_DEV_RPC_TARGET }, + "/api": { target: STUDIO_DEV_RPC_TARGET }, + }, watch: { ignored: [ "**/mods/mod-swooper-maps/dist/**", diff --git a/docs/projects/mapgen-studio-redesign/00-GOAL.md b/docs/projects/mapgen-studio-redesign/00-GOAL.md index a31aa6b77e..f07cf4813b 100644 --- a/docs/projects/mapgen-studio-redesign/00-GOAL.md +++ b/docs/projects/mapgen-studio-redesign/00-GOAL.md @@ -210,8 +210,21 @@ live screenshots, zero console errors. Each slice = one OpenSpec change. - [x] **P7 Review** — multi-lens reviewer waves each run; no open P1/P2 at tip. **Remaining (SUPERVISED — not auto-run):** -- [ ] **P5b Server cutover** — standalone **Bun** server, production `/api` parity fix - (today `/api` is Vite-dev-only), then **remove** the legacy `/api` middleware. Awaiting user go-ahead. +- [x] **P5b Server cutover** — DONE (2026-06-12, user go granted; OpenSpec + `mapgen-studio-bun-server`, slices `design/bun-server-frame` → + `design/bun-server-engines` → `design/bun-control-fetch` → + `design/bun-server-daemon`): standalone **Bun** daemon + (`src/server/daemon/daemon.ts`, port 5174) owns all server state + (extracted `createStudioEngines`) + the three oRPC mounts (`/rpc`, + `/api/civ7/rpc` and `/api/recipe-dag/rpc` both now fetch-adapter, + statically imported under Bun — `ssrLoadModule` gone); `bun run dev` = + dev-live runner (daemon → healthz → Vite); `vite.config.ts` is + frontend-only (proxy `/rpc` + `/api`); optional `--assets-root` static + serving opens the prod story. Legacy `/api/*` REST handlers **RETIRED + outright** per user directive 2026-06-12 ("no legacy... forward only") — + no compat layer, unknown `/api` → 404; parity script moved to oRPC. + Gates: studio 201, mod 471, build + worker bundle, strict OpenSpec, + live-verified (run loop + pipeline view through the proxy). - [ ] *(optional)* deeper `StudioShell` container/presentational panel split. Live-control: NOT waiting for merge. Seam designed toward stack-top (FRAME §6). diff --git a/openspec/changes/mapgen-studio-bun-server/design.md b/openspec/changes/mapgen-studio-bun-server/design.md index 81d483f1f2..f255d19c59 100644 --- a/openspec/changes/mapgen-studio-bun-server/design.md +++ b/openspec/changes/mapgen-studio-bun-server/design.md @@ -28,12 +28,12 @@ Vite proxy for the RPC prefix. The studio adopts the same shape: bun run dev └─ src/server/daemon/devLive.ts (runner) ├─ spawns: bun src/server/daemon/daemon.ts --port 5174 (backend) - │ Bun.serve fetch router: + │ Bun.serve fetch router (oRPC ONLY — no legacy, no fallbacks): │ /healthz → runtime health (json) │ /rpc/* → createStudioRpcHandler (studio-server) │ /api/civ7/rpc/* → control-oRPC fetch handler │ /api/recipe-dag/rpc/* → recipe-DAG fetch handler (static import) - │ /api/* → legacy REST compat (same engines) + │ /api/* (anything else) → 404 (legacy REST surface retired) │ static dist (optional, --assets-root; prod story) └─ spawns: vite --port 5173 (frontend) server.proxy: /rpc → 5174, /api → 5174 @@ -85,16 +85,12 @@ unchanged. Path contract unchanged. - `src/server/daemon/daemon.ts`: arg parsing (`--host`, `--port`, `--repo-root`, `--assets-root`), one `createStudioEngines` instance, the - three fetch handlers, the legacy compat router, `/healthz`, optional - static serving with SPA fallback. The fetch composition is exported as a - pure `createStudioDaemonFetch(deps)` so route dispatch is unit-testable - under vitest without `Bun.serve`. -- `src/server/studio/legacyHttp.ts`: the 16 REST handlers re-expressed as - fetch handlers over the same engines — **byte-equal response bodies and - status codes**, including the parity pins: run-in-game status 404 echoes - `serverInstanceId`/`serverStartedAt`; save-deploy status 404 does not; - autoplay/save-deploy/run-in-game non-uniform codes; `live/status` returns - 200 with per-field embedded `{error}`. + three fetch handlers, `/healthz`, optional static serving with SPA + fallback. The fetch composition is exported as a pure + `createStudioDaemonFetch(deps)` so route dispatch is unit-testable under + vitest without `Bun.serve`. The oRPC parity invariants (non-uniform + status codes, run-in-game 404 echo) live in the engines + context, which + move unchanged. - `vite.config.ts`: the `configureServer` plugin and every server-side import are deleted; `server.proxy` forwards `/rpc` and `/api` to the daemon. The config no longer drags `@civ7/direct-control` or any engine @@ -108,14 +104,18 @@ unchanged. Path contract unchanged. detection is unaffected (identity now lives in the daemon process — correct: it owns the operation stores the client reconciles against). -## Legacy retirement list (checkpoint artifact — NOT auto-run) +## Retired legacy surface (user directive 2026-06-12) -The compat surface below is pending an explicit user checkpoint before -deletion. Consumer evidence (repo-wide sweep 2026-06-12): the studio client -calls none of these (oRPC-only since P2); the single live consumer is -`scripts/civ7-direct-control/verify-final-surface-parity.ts` (hits -`GET /api/civ7/run-in-game/status`); `packages/civ7-direct-control/README.md` -documents three of them as examples. +The checkpoint resolved mid-build: **"no legacy allowed. remove all +fallbacks and legacy paths. no support. forward only."** The 16 paths below +are deleted with the Vite server plugin — the daemon never serves them (any +non-oRPC `/api` path is a 404). Consumer evidence (repo-wide sweep +2026-06-12): the studio client calls none of these (oRPC-only since P2); +the single live consumer was +`scripts/civ7-direct-control/verify-final-surface-parity.ts` (status poll), +moved to the oRPC `runInGame.status` endpoint in this slice; +`packages/civ7-direct-control/README.md` documents three of them as +examples (stale docs, not consumers). | # | Legacy path | oRPC equivalent (`/rpc`) | | --- | --- | --- | @@ -136,20 +136,17 @@ documents three of them as examples. | 15 | `GET /api/map-configs/status` | `mapConfigs.status` | | 16 | `POST /api/map-configs` | `mapConfigs.saveDeploy` | -Retirement slice (post-checkpoint): delete `legacyHttp.ts` + its daemon -mount + tests; update or retire the parity script's status poll. - ## Testing -- Existing pins unchanged: `test/recipeDag/orpc.test.ts` (node transport - via the Connect shim), operation-state suites, watch-ignore pins. +- Existing pins unchanged: `test/recipeDag/orpc.test.ts` and + `test/runInGame/civ7ControlOrpcClient.test.ts` (node transports via the + Connect shims over the fetch handlers), operation-state suites, + watch-ignore pins. - New: daemon route-dispatch tests over `createStudioDaemonFetch` with - injected handlers (prefix routing, healthz, 404 fall-through, static - fallback); control fetch-adapter transport test (mirror of the recipe-DAG - one); legacy compat parity pins (404-echo asymmetry, non-uniform status - codes, `live/status` embedded-error 200); dev-live plan test + injected handlers (prefix routing, healthz 200/503, RPC-unmatched 404, + retired-legacy-path 404, no-assets 404); dev-live plan test (`makeDevLivePlan` pure function: commands, ports, proxy env). -- Live smoke (fresh processes, both run modes): `bun run dev` → +- Live smoke (fresh processes): `bun run dev` boots daemon + vite; generation run completes; pipeline view loads the DAG through the proxy; - control readiness poll works; curl pins on `/healthz`, a legacy path, and - `studio.serverInfo`. + control readiness poll works; curl pins on `/healthz` and a retired + legacy path (404). diff --git a/openspec/changes/mapgen-studio-bun-server/proposal.md b/openspec/changes/mapgen-studio-bun-server/proposal.md index 795a35187c..bd9a43488c 100644 --- a/openspec/changes/mapgen-studio-bun-server/proposal.md +++ b/openspec/changes/mapgen-studio-bun-server/proposal.md @@ -63,16 +63,14 @@ daemon imports the same fetch handler statically. `/rpc` and `/api` to the daemon. Dev topology: `bun run dev` runs a dev-live runner that spawns the daemon (port 5174), waits for `/healthz`, then spawns Vite (port 5173). -- **Legacy `/api/*` REST handlers**: ported onto the daemon as a thin compat - module (same engines, same response bodies and status codes) so the - cutover lands with zero observable surface change. Their **retirement is - gated on an explicit user checkpoint** (supervision condition) and is NOT - part of this change's auto-run scope; the retirement list and consumer - evidence live in `design.md`. +- **Legacy `/api/*` REST handlers: RETIRED** (user directive 2026-06-12: "no + legacy allowed... no support, forward only"). The daemon serves the three + oRPC mounts only; any other `/api` path is a 404. The single external + consumer (`scripts/civ7-direct-control/verify-final-surface-parity.ts`) + moves to the oRPC `runInGame.status` endpoint in the same slice. The + retired-path inventory lives in `design.md`. ## Non-Goals - -- No retirement of the legacy REST surface without the user checkpoint. - No Railway/Caddy production deploy changes (the daemon's static serving makes them possible later; wiring them is out of scope). - No contract changes to `@civ7/studio-server`, `@civ7/control-orpc`, or @@ -84,7 +82,9 @@ daemon imports the same fetch handler statically. - Affected specs: `mapgen-studio` - Affected code: `apps/mapgen-studio/vite.config.ts` (shrinks to frontend-only), `apps/mapgen-studio/src/server/studio/*` (new: engines, - context, legacy compat), `apps/mapgen-studio/src/server/daemon/*` (new: - daemon, dev-live runner), `apps/mapgen-studio/src/server/civ7ControlOrpc.ts` - (fetch adapter), `apps/mapgen-studio/package.json` (dev scripts), - `apps/mapgen-studio/test/devServer/*` + `test/server/*` (new pins) + context), `apps/mapgen-studio/src/server/daemon/*` (new: daemon, dev-live + runner), `apps/mapgen-studio/src/server/civ7ControlOrpc.ts` (fetch + adapter), `apps/mapgen-studio/package.json` (dev scripts), + `apps/mapgen-studio/test/server/*` (new pins), + `scripts/civ7-direct-control/verify-final-surface-parity.ts` (oRPC + transport) diff --git a/openspec/changes/mapgen-studio-bun-server/specs/mapgen-studio/spec.md b/openspec/changes/mapgen-studio-bun-server/specs/mapgen-studio/spec.md index 7013b272ce..2d5c0428ba 100644 --- a/openspec/changes/mapgen-studio-bun-server/specs/mapgen-studio/spec.md +++ b/openspec/changes/mapgen-studio-bun-server/specs/mapgen-studio/spec.md @@ -3,11 +3,11 @@ ### Requirement: The Studio Server Surface Runs As A Standalone Bun Daemon The Studio's server surface SHALL be owned by a standalone Bun daemon -process hosting the studio-server `/rpc` mount, the control-oRPC mount at -`/api/civ7/rpc`, the recipe-DAG mount at `/api/recipe-dag/rpc`, and the -legacy `/api/*` REST compat surface, with the Vite dev server reduced to a -frontend-only role that proxies `/rpc` and `/api` to the daemon. All path -contracts and client transports are unchanged by the cutover. +process hosting exactly three mounts — the studio-server `/rpc` mount, the +control-oRPC mount at `/api/civ7/rpc`, and the recipe-DAG mount at +`/api/recipe-dag/rpc` — with the Vite dev server reduced to a frontend-only +role that proxies `/rpc` and `/api` to the daemon. The oRPC path contracts +and client transports are unchanged by the cutover. #### Scenario: One-command dev runs both processes @@ -32,17 +32,17 @@ contracts and client transports are unchanged by the cutover. The Studio SHALL keep the serialized operation queue, the run-in-game and save/deploy operation stores, and the server instance identity in exactly -one place — the daemon process — shared by every transport (oRPC mounts -and legacy compat); no studio server state may live in the Vite process. The engine -behaviors move verbatim: non-uniform error status codes are preserved -per-procedure, run-in-game status 404 echoes the server instance identity -while save/deploy status 404 does not, and `live/status` returns 200 with -per-field embedded errors. +one place — the daemon process — shared by every oRPC mount; no studio +server state may live in the Vite process. The engine behaviors move +verbatim: non-uniform error status codes are preserved per-procedure, +run-in-game status 404 echoes the server instance identity while +save/deploy status 404 does not, and `civ7.live.status` returns success +with per-field embedded errors. -#### Scenario: Both transports observe one queue +#### Scenario: All mounts observe one queue - **WHEN** a run-in-game operation is active and a save/deploy request - arrives on either the oRPC mount or the legacy compat surface + arrives on the `/rpc` mount - **THEN** the request is rejected with the 409 dual-mutex semantics naming the active operation @@ -53,23 +53,23 @@ per-field embedded errors. - **THEN** the 404 response carries the new process's `serverInstanceId` and `serverStartedAt` -### Requirement: Legacy REST Surface Survives The Cutover Until The Retirement Checkpoint +### Requirement: The Legacy REST Surface Is Retired -The legacy `/api/*` REST handlers SHALL remain available through the -cutover as a daemon-hosted compat layer with response bodies and status -codes identical to the Vite-hosted handlers, and SHALL NOT be retired -without an explicit user-approved retirement decision recorded in this -workstream. +The Studio server SHALL NOT serve the hand-rolled legacy `/api/*` REST +endpoints (user retirement directive, 2026-06-12: no legacy paths, no +fallbacks, forward only): any `/api` path other than the control-oRPC and +recipe-DAG mounts is a 404, and every consumer — the studio client and +repo scripts — talks to the oRPC surface. -#### Scenario: Compat parity +#### Scenario: Retired paths are gone -- **WHEN** a legacy endpoint is requested after the cutover -- **THEN** the response status and body shape match the pre-cutover - Vite-hosted handler for the same engine state +- **WHEN** a retired legacy endpoint such as `/api/civ7/status` or + `/api/map-configs` is requested +- **THEN** the daemon responds 404 and no handler-specific behavior runs -#### Scenario: Retirement is gated +#### Scenario: Consumers ride oRPC -- **WHEN** the cutover slices land without a recorded user checkpoint - decision -- **THEN** the compat surface is still mounted and the retirement remains - an open follow-up in the workstream record +- **WHEN** the run-in-game proof verification script polls an operation's + status +- **THEN** it calls the `runInGame.status` oRPC endpoint, not a legacy + REST path diff --git a/openspec/changes/mapgen-studio-bun-server/tasks.md b/openspec/changes/mapgen-studio-bun-server/tasks.md index 41c6446dfb..c4648b6335 100644 --- a/openspec/changes/mapgen-studio-bun-server/tasks.md +++ b/openspec/changes/mapgen-studio-bun-server/tasks.md @@ -1,59 +1,55 @@ ## 1. Frame -- [ ] 1.1 Workstream record + proposal/design/tasks/spec deltas committed +- [x] 1.1 Workstream record + proposal/design/tasks/spec deltas committed (`design/bun-server-frame`), `--strict` valid. ## 2. Engine extraction (`design/bun-server-engines`) — no topology change -- [ ] 2.1 `src/server/studio/engines.ts`: `createStudioEngines({ repoRoot })` +- [x] 2.1 `src/server/studio/engines.ts`: `createStudioEngines({ repoRoot })` — queue, both operation stores, instance identity, five engine fns - moved VERBATIM from `vite.config.ts`. No effect-orpc in its import - graph (config must stay node-evaluable). -- [ ] 2.2 `src/server/studio/context.ts`: `createStudioServerContext` + moved VERBATIM from `vite.config.ts`. +- [x] 2.2 `src/server/studio/context.ts`: `createStudioServerContext` (moved `createStudioServerContextForApp`; non-uniform error mapping intact). -- [ ] 2.3 `vite.config.ts` consumes the extracted modules at module scope; - legacy handlers and all three mounts behave identically. -- [ ] 2.4 Gates: tsc, studio tests, mod tests, build + worker bundle, - fresh-process live smoke (run loop + pipeline view + readiness poll). +- [x] 2.3 `vite.config.ts` consumes the extracted modules at module scope; + all mounts behave identically. +- [x] 2.4 Gates: tsc, studio tests (193), mod tests (471), build + worker + bundle, fresh-process live smoke (server-info identity, recipe-dag + 200, run-in-game 404 echo observed live). ## 3. Control-oRPC fetch adapter (`design/bun-control-fetch`) -- [ ] 3.1 `civ7ControlOrpc.ts` → canonical fetch handler +- [x] 3.1 `civ7ControlOrpc.ts` → canonical fetch handler (`@orpc/server/fetch`) + Connect shim over `nodeWebBridge`, mirroring `recipeDag/orpc.ts`; path contract unchanged. -- [ ] 3.2 Transport test (node:http + shim → fetch handler), mirroring the - recipe-DAG transport pins. -- [ ] 3.3 Gates: tsc, studio tests, live smoke (readiness/control poll). +- [x] 3.2 Existing node transport pins (`civ7ControlOrpcClient.test.ts`) + green unchanged over the re-shaped adapter. +- [x] 3.3 Gates: tsc, studio tests, live smoke (readiness.current 200 on a + fresh process). ## 4. Daemon + cutover (`design/bun-server-daemon`) -- [ ] 4.1 `src/server/daemon/daemon.ts`: arg parsing, one engines +- [x] 4.1 `src/server/daemon/daemon.ts`: arg parsing, one engines instance, `createStudioDaemonFetch(deps)` (pure, testable) routing `/healthz`, `/rpc`, `/api/civ7/rpc`, `/api/recipe-dag/rpc` (static - import), legacy compat, optional static assets; `Bun.serve` entry. -- [ ] 4.2 `src/server/studio/legacyHttp.ts`: 16 REST handlers as fetch - adapters over the shared engines — identical bodies/status codes - (404-echo asymmetry, non-uniform codes, live/status embedded-error - 200). -- [ ] 4.3 `src/server/daemon/devLive.ts`: spawn daemon → wait `/healthz` → + import), optional static assets; `Bun.serve` entry. Legacy `/api/*` + RETIRED — 404, no fallbacks (user directive 2026-06-12). +- [x] 4.2 `src/server/daemon/devLive.ts`: spawn daemon → wait `/healthz` → spawn Vite; signal forwarding; `makeDevLivePlan` pure. -- [ ] 4.4 `vite.config.ts`: delete the server plugin + server imports; add +- [x] 4.3 `vite.config.ts`: delete the server plugin + all server imports; `server.proxy` for `/rpc` + `/api` (`STUDIO_DEV_RPC_TARGET` override). `package.json`: `dev` → runner, `dev:frontend`, - `dev:server`. -- [ ] 4.5 Tests: daemon route dispatch; legacy compat parity pins; + `dev:server`, `serve` (daemon + static dist). +- [x] 4.4 `scripts/civ7-direct-control/verify-final-surface-parity.ts`: + status poll → oRPC `runInGame.status` (the legacy endpoint is gone). +- [x] 4.5 Tests: daemon route dispatch (incl. retired-path 404 pins); dev-live plan; existing transport/operation pins green unchanged. -- [ ] 4.6 Gates: tsc, studio tests, mod tests, build + worker bundle. -- [ ] 4.7 Live verification on FRESH processes: `bun run dev` boots both; - generation run completes; pipeline view loads DAG via proxy; control - readiness poll works; curl pins (`/healthz`, one legacy path, - `studio.serverInfo`); dark theme visual unchanged. - -## 5. Retirement checkpoint (SUPERVISED — not auto-run) - -- [ ] 5.1 Present the retirement list (design.md table + consumer - evidence) to the user; record the decision in the workstream record. -- [ ] 5.2 (post-approval) `design/bun-legacy-retire`: delete - `legacyHttp.ts` + mount + compat tests; update - `scripts/civ7-direct-control/verify-final-surface-parity.ts`. +- [x] 4.6 Gates: tsc, studio tests, mod tests, build + worker bundle. +- [x] 4.7 Live verification on FRESH processes: `bun run dev` boots + daemon + vite; generation run completes; pipeline view loads DAG via + proxy; control mount routes and returns the router's typed error + envelope (the live tuner was unresponsive at test time — verified + runtime-independent: the same direct-control read times out under + node); curl pins (`/healthz` 200, retired legacy path 404, + `studio.serverInfo` via proxy); zero console errors, dark theme. +- [x] 4.8 Workstream/phase record closed with evidence; goal-ledger entry. diff --git a/openspec/changes/mapgen-studio-bun-server/workstream/workstream-record.md b/openspec/changes/mapgen-studio-bun-server/workstream/workstream-record.md index d0ea9e4cec..0d7160940b 100644 --- a/openspec/changes/mapgen-studio-bun-server/workstream/workstream-record.md +++ b/openspec/changes/mapgen-studio-bun-server/workstream/workstream-record.md @@ -11,8 +11,13 @@ byte and all server state) plus a Vite frontend that proxies to it; the daemon can also serve the built SPA, opening a real production server story. -- Non-goals: legacy retirement without the user checkpoint; deploy/Caddy - changes; any contract or client transport change; engine rewrites. +- Non-goals: deploy/Caddy changes; any contract or client transport + change; engine rewrites. +- Decision (2026-06-12, user directive, supersedes the planned retirement + checkpoint): **"no legacy allowed. remove all fallbacks and legacy + paths. no support. forward only."** The daemon serves the three oRPC + mounts only; the 16 legacy REST handlers die with the Vite server + plugin; the parity script moves to oRPC. - Hard core: behavior parity (run/poll/localStorage/transport semantics); arch/10 §1 parity invariants (non-uniform status codes, 404-echo asymmetry, live/status embedded-error 200, `assertNoRawControlFields`, @@ -41,10 +46,46 @@ change. - Phase 3 (`design/bun-control-fetch`): control mount → fetch adapter. - Phase 4 (`design/bun-server-daemon`): daemon + dev-live + Vite - frontend-only cutover + legacy compat port. -- Phase 5 (checkpoint, SUPERVISED): retirement decision; then - `design/bun-legacy-retire`. + frontend-only cutover + legacy retirement (per the 2026-06-12 user + directive) + parity-script oRPC migration. ## Evidence -- (per phase, appended as slices close) +- Phase 1 (`design/bun-server-frame`): docs committed; `openspec validate + mapgen-studio-bun-server --strict` valid. +- Phase 2 (`design/bun-server-engines`): tsc clean; studio 193; mod 471; + build + worker bundle (the build evaluates the config under node, + proving the extracted graph stays node-safe); fresh-process live smoke — + `/api/studio/server-info` carries the factory identity, recipe-dag POST + 200, run-in-game stale-id 404 echoed `serverInstanceId`/`serverStartedAt` + live (restart-detection parity pin observed end-to-end). +- Phase 3 (`design/bun-control-fetch`): tsc clean; studio 193 (existing + `civ7ControlOrpcClient.test.ts` node-transport pins unchanged over the + fetch adapter); live `readiness.current` 200 on a fresh process. +- Phase 4 (`design/bun-server-daemon`): tsc clean; studio 201 (8 new + daemon/dev-live pins); mod 471; build + worker bundle; `--strict` valid. + Live on fresh processes: dev-live boots daemon (5174, Bun loads + effect-orpc TS natively — stack traces show `src/server/studio/*.ts` + executing from source) then Vite (5173); all three oRPC mounts answer + through the proxy; retired legacy paths 404 (`/api/civ7/status` et al); + `studio.serverInfo` → `viteCommand: "daemon"`; full generation run + completes; Pipeline stage view loads the DAG via the proxy; zero browser + console errors. Control facade timeout at test time verified + runtime-independent (same read times out under node). +- Legacy retirement: executed per the 2026-06-12 user directive (decision + recorded under Frame); parity script moved to oRPC `runInGame.status`. +- Phase 4 addendum — live-control connect VERIFIED after a tuner-wedge + incident: the initial cutover smoke could not reach the tuner and the + failure was misattributed to transient game state. Root cause of the + outage: Civ7 itself had accumulated **187 leaked tuner connections** + (`lsof` CLOSED-state fds inside the game process) over the long dev + session, wedging the tuner for ALL clients (node and Bun alike — the + same read timed out under both). After restarting Civ7: node and Bun + reads both succeed and leak ZERO fds across sequential (10×), + concurrent (8×), and forced-timeout (5×) matrices — Bun is NOT the + trigger; the leak class predates the cutover (polling during busy-game + windows, e.g. in-game mapgen/loading, is the suspected accumulator). + End-to-end through the daemon on a healthy tuner: `readiness.current` → + `"shell"`, `civ7.status` returns the full App UI snapshot, and the + studio Game bar shows the live `shell` readiness chip. Durable + follow-up (tuner wedge mitigation) tracked outside this change. diff --git a/openspec/changes/mapgen-studio-tuner-session/design.md b/openspec/changes/mapgen-studio-tuner-session/design.md new file mode 100644 index 0000000000..46d4f744fd --- /dev/null +++ b/openspec/changes/mapgen-studio-tuner-session/design.md @@ -0,0 +1,160 @@ +# Design — Effect-scoped shared tuner session + +## The resource model (proportionality decision) + +Research verdict (effect@3.21.3 source + effect-orpc source + repo prior +art): the canonical Effect shape for "one shared long-lived connection, +released on shutdown" is a `Context.Tag` service built with +`Layer.scoped`/`Effect.Service({ scoped: ... })` whose constructor uses +`Effect.acquireRelease`; `ManagedRuntime.make(layer)` extends the layer +into a closeable scope and `dispose()` runs the finalizers +(`internal/managedRuntime.ts`: `disposeEffect = Scope.close(scope)`). +effect-orpc executes every `.effect` handler via +`runtime.runPromiseExit(effect, { signal })` — services come from the +runtime layer; there is no per-request scope, so the service encapsulates +any scoping internally. + +`RcRef`/`Pool` were evaluated and rejected: `RcRef.invalidate` exists +(3.19.6, `@experimental`) for replace-on-failure resources, and a size-1 +`Pool` would serialize use — but `Civ7DirectControlSession` multiplexes +concurrent requests over one socket (listenerId routing) and `connect()` +is reuse-idempotent (a dead socket reconnects on the next `request()`). +The instance never needs replacement, so the proportional model is **one +instance, `acquireRelease`, self-healing inside**. No semaphore: only +acquisition would need serializing, and the session class already guards +that. (Effect core has no circuit breaker — issue #2843 — so the gate is +a `Ref` cooldown, the documented minimal idiom.) + +## Ownership topology (one owner, three consumers) + +``` +ManagedRuntime (makeStudioRuntime, daemon-lifetime, dispose() on SIGINT/SIGTERM) + └─ Civ7TunerSession (Layer.scoped: acquireRelease ONE Civ7DirectControlSession) + ├─ Civ7TunerClient (Effect dep): every /rpc read → use(run, {session}) + ├─ control-oRPC facade: daemon injects `session` into endpointDefaults + │ (Civ7ControlOrpcContext.endpointDefaults is ALREADY typed as + │ Civ7DirectControlOptions — the new options field flows through + │ the entire router with zero control-orpc changes) + └─ healthz: tuner.health() → { consecutiveResponseTimeouts, gate } +Run-in-game engines: untouched (per-flow sessions, serialized queue) — they +gain graceful close from the package fix but do NOT share the socket. +``` + +Layer memoization guarantees one instance: `Civ7TunerClient.Default` +declares `dependencies: [Civ7TunerSession.Default]`, and the runtime layer +also merges `Civ7TunerSession.Default` to expose it — the same layer +reference is memoized within one `ManagedRuntime`, so the client and the +host port see the same session. + +## The seam in `@civ7/direct-control` (no Effect dependency) + +```ts +// session/types.ts +interface Civ7DirectControlOptions { + // ...existing host/port/timeoutMs/state + /** Caller-owned session: reused, NOT closed by the callee. */ + session?: Civ7DirectControlSession; +} + +// session/session.ts +export async function withCiv7DirectControlSession(options, run) { + if (options.session) return await run(options.session); // caller owns lifecycle + const session = new Civ7DirectControlSession(options); + try { return await run(session); } finally { await session.close(); } +} +``` + +All ~60 procedures funnel through this wrapper (via `execute.ts`), so the +single field converges every read path. Long-flow procedures +(`startPreparedCiv7SinglePlayerGame`, `restartCiv7Game`, +`runCiv7SinglePlayerFromSetup`) hold their own sessions via a separate +`withSession` dependency pattern and are deliberately not converged. + +**Graceful close** (leak mitigation at the root, benefits all paths): +`close()` rejects pending, then `socket.end()` (FIN) and awaits the +`close` event with a 1s timeout falling back to `destroy()`; idempotent; +release-path errors are swallowed (finalizers must not fail). + +**Session stats** (the only point that observes ALL shared-socket +traffic, control reads included): `consecutiveResponseTimeouts` +incremented on each `response-timeout` rejection, reset to zero on every +successfully resolved frame; exposed read-only as `session.stats`. + +## `Civ7TunerSession` service (studio-server) + +```ts +class Civ7TunerSession extends Effect.Service()( + "@civ7/studio-server/Civ7TunerSession", + { + scoped: Effect.gen(function* () { + const session = yield* Effect.acquireRelease( + Effect.sync(() => new Civ7DirectControlSession()), // lazy: connects on first request + (s) => Effect.promise(() => s.close()), // graceful FIN on dispose() + ); + const gate = yield* Ref.make({ openUntil: 0 }); + const use = (run: (o: { session: Civ7DirectControlSession }) => Promise) => + gateCheck.pipe(Effect.zipRight(Effect.tryPromise({ + try: () => run({ session }), + catch: classify, // typed error union + }))); + return { use, session, health }; + }), + }, +) {} +``` + +- **Gate policy**: opens when `session.stats.consecutiveResponseTimeouts + >= 4` (set `openUntil = now + 15s`); while open, `use` fails fast with + `Civ7TunerBackoffError` (Data.TaggedError carrying + `consecutiveResponseTimeouts`, `retryAt`) without touching the socket; + after expiry the next request flows (natural half-open: a timeout + re-opens, a success resets the counter). Trigger is `response-timeout` + ONLY — `connection-failed`/refused (game not running) is fast, leak-free + and must keep flowing for readiness UX. +- **Error channel**: one strategy at the boundary — `use` surfaces + `Civ7TunerBackoffError | UnknownException`; the existing router error + mapping is unchanged in shape (live.status keeps 200 + per-field + embedded `{error}`; non-uniform status codes preserved — only failure + message TEXT differs when the gate fails fast, which was never pinned). +- `Civ7TunerClient` accessors change mechanically: + `Effect.tryPromise(() => fn(args, { timeoutMs }))` → + `tuner.use((o) => fn(args, { timeoutMs, ...o }))`. +- `StudioRpcHandle` gains `tuner: { session(): Promise; + health(): Promise }` and `dispose(): Promise` + (delegating to `runtime.dispose()`). **Today nothing disposes the + runtime** — finalizers never ran; the daemon now does on + SIGINT/SIGTERM. + +## Daemon wiring + +- `createStudioCiv7ControlOrpcContext/RpcHandler/Middleware` options gain + `session?: Civ7DirectControlSession` → merged into `endpointDefaults`. +- `daemon.ts`: builds the handler, resolves `await studioRpc.tuner.session()` + once at startup, passes it to the control handler; `/healthz` gains a + `tuner` block (`consecutiveResponseTimeouts`, `gateOpenUntil`, + `wedgeSuspected: consecutive >= 4`); SIGINT/SIGTERM → `dispose()` → + `server.stop()`. + +## Migration order (strangler fig) + +1. Package seam + graceful close + stats (independently valuable; no + consumer change). +2. Effect service + Civ7TunerClient convergence (the /rpc poll surface). +3. Daemon wiring (control facade shares the socket; health; dispose). +4. Run-in-game convergence: explicitly deferred — out of scope. + +## Verification + +- direct-control tests (package has a fake-tuner-server fixture): + injected session reused and NOT closed by the wrapper; graceful close + delivers FIN (server observes `end`, not abrupt RST); stats counter + increments on response-timeout and resets on success. +- studio-server: gate behavior unit-tested against a stub session + (fail-fast while open, half-open after cooldown); dispose runs the + release (session.close called once). +- daemon route tests unchanged; live: `bun run dev` → app polls readiness + and live status → **exactly ONE established connection on :4318 from + the daemon, reused across polls** (`lsof -nP -iTCP:4318 | grep + ESTABLISHED`), CLOSED-fd count stays flat during a soak; healthz tuner + block present; Ctrl-C/daemon stop sends FIN (game-side fd count does + not grow). diff --git a/openspec/changes/mapgen-studio-tuner-session/proposal.md b/openspec/changes/mapgen-studio-tuner-session/proposal.md new file mode 100644 index 0000000000..23a6f8439a --- /dev/null +++ b/openspec/changes/mapgen-studio-tuner-session/proposal.md @@ -0,0 +1,103 @@ +# Shared Civ7 tuner session — Effect-scoped acquisition/release in the daemon + +## Why + +The Civ7 FireTuner endpoint (port 4318) wedges after long Studio sessions: +the game process accumulates leaked TCP descriptors (observed: 187 +CLOSED-state fds held by CivilizationVII), after which ALL tuner reads time +out until the game is restarted. The client side drives this: every +direct-control procedure opens a fresh TCP connection and `destroy()`s it +(`withCiv7DirectControlSession` connect-per-request), and the Studio polls +several read endpoints every few seconds through the daemon — thousands of +connect/RST cycles per session, with the leak concentrating when polls +time out against a busy game (in-game mapgen, loading screens). Clean +reads leak zero fds (verified under node and Bun: sequential, concurrent, +forced-timeout matrices), so the fix is to stop the churn, not to chase a +runtime bug. + +The session protocol already supports the fix: `Civ7DirectControlSession` +multiplexes concurrent requests over one socket via listenerIds and +self-heals (`connect()` is reuse-idempotent). What is missing is an owner: +a scoped, shared session whose lifetime is managed — acquired lazily, +released gracefully — and a polite failure mode when the game stops +answering. The daemon (P5 cutover) is the natural owner, and the +studio-server's Effect `ManagedRuntime` is the natural lifecycle host +(user direction: solve this with Effect — scoped release/acquisition as a +layer). + +## Target Authority Refs + +- `openspec/changes/mapgen-studio-bun-server/workstream/workstream-record.md` + (Phase 4 addendum — incident evidence + isolation matrix) +- `packages/studio-server/src/{runtime,services/Civ7TunerClient}.ts` + (Effect service idiom; `ManagedRuntime` host seam) +- effect@3.21.3 source (verified): `ManagedRuntime.make` builds the layer + into a closeable scope; `dispose()` runs `Layer.scoped` finalizers; + `Effect.Service` supports `scoped:` constructors and `dependencies`, + with layer memoization deduplicating a shared service +- `packages/civ7-direct-control/src/session/*` (session class: listenerId + multiplexing, idempotent `connect()`, `withCiv7DirectControlSession` + funnel — the single low-churn seam all ~60 procedures pass through) +- TypeScript skill (`/typescript`): ports/adapters seam, strangler-fig + migration (polling reads first, run-in-game untouched), functional + gate state, proportional complexity (no pool/RcRef — the session + instance self-heals and never needs replacement) + +## What Changes + +- **`@civ7/direct-control` — injection seam + graceful close + stats** + (no Effect dependency added): + - `Civ7DirectControlOptions.session?: Civ7DirectControlSession` — + `withCiv7DirectControlSession` uses a caller-owned session without + closing it; absent, behavior is unchanged. One field converges all + ~60 procedures (they all funnel through `execute.ts`). + - `Civ7DirectControlSession.close()` becomes graceful: reject pending, + then `socket.end()` (FIN) and wait for `close` with a short timeout + falling back to `destroy()`. Benefits every path, including + run-in-game per-flow sessions, without semantic change. + - Session-level health counters (`session.stats`): consecutive + response-timeouts (reset on any successful frame) — the one place + that observes ALL traffic on the shared socket. +- **`@civ7/studio-server` — `Civ7TunerSession` Effect service** (new): + `scoped:` constructor acquiring ONE `Civ7DirectControlSession` via + `Effect.acquireRelease` (lazy connect on first request; release = + graceful close on `runtime.dispose()`), exposing `use(run)` (gated + execution with typed fail-fast `Civ7TunerBackoffError` while the + cooldown gate is open), `session` (for host injection into the + control-oRPC context), and `health()`. Gate policy: opens after N + consecutive response-timeouts (read from `session.stats`, so control + traffic counts too), fixed cooldown, naturally half-open after expiry. + `Civ7TunerClient` gains `dependencies: [Civ7TunerSession.Default]` and + routes every accessor through `use` with the shared session injected. + `StudioRpcHandle` gains `tuner.{session,health}` promise ports and + `dispose()` (today nothing closes the runtime scope — finalizers never + ran). +- **Daemon wiring** (`apps/mapgen-studio`): the control-oRPC context + builders accept `session?` and merge it into `endpointDefaults` + (`Civ7DirectControlOptions` already typed there — zero control-orpc + package changes); the daemon injects the shared session from the + studio runtime, reports tuner health (consecutive timeouts, gate + state, wedge suspicion) on `/healthz`, and disposes the runtime on + SIGINT/SIGTERM so the socket gets a clean FIN. +- **Run-in-game flows: UNTOUCHED** (strangler order; behavior-parity hard + core). Engines keep their per-flow sessions — bounded, serialized by + the operation queue, and now graceful-closing via the package fix. + +## Non-Goals + +- No connection pooling / RcRef / request serialization — the session + multiplexes and self-heals; one instance for the runtime's life. +- No change to run-in-game semantics (queue, proofs, reconnect flows). +- No "restart Civ7" UI affordance this change (healthz + typed errors + expose the wedge signal; UI surfacing is a follow-up). +- No control-orpc package changes beyond what the existing + `endpointDefaults` type already permits. + +## Impact + +- Affected specs: `mapgen-studio` +- Affected code: `packages/civ7-direct-control/src/session/{types,session}.ts` + (+ tests), `packages/studio-server/src/{services/Civ7TunerSession.ts + (new), services/Civ7TunerClient.ts, runtime.ts, handler.ts, index.ts}` + (+ tests), `apps/mapgen-studio/src/server/civ7ControlOrpc.ts`, + `apps/mapgen-studio/src/server/daemon/daemon.ts` (+ tests) diff --git a/openspec/changes/mapgen-studio-tuner-session/specs/mapgen-studio/spec.md b/openspec/changes/mapgen-studio-tuner-session/specs/mapgen-studio/spec.md new file mode 100644 index 0000000000..4471d402f4 --- /dev/null +++ b/openspec/changes/mapgen-studio-tuner-session/specs/mapgen-studio/spec.md @@ -0,0 +1,68 @@ +## ADDED Requirements + +### Requirement: Studio Polling Reads Share One Managed Tuner Session + +The Studio daemon SHALL serve all polling tuner reads (the studio-server +`/rpc` read surface and the control-oRPC mount) over a single shared +`Civ7DirectControlSession` owned by an Effect scoped service in the +studio runtime — acquired lazily, multiplexed across concurrent requests, +self-healing on reconnect, and released with a graceful FIN when the +daemon disposes its runtime on shutdown. Run-in-game flows keep their +per-flow sessions (serialized by the operation queue) and are unchanged. + +#### Scenario: One connection across polls + +- **WHEN** the Studio polls readiness and live status repeatedly through + the daemon +- **THEN** at most one established client connection to the tuner port + exists for those reads, reused across polls, and the game-side + descriptor count does not grow with poll count + +#### Scenario: Graceful release on shutdown + +- **WHEN** the daemon receives SIGINT or SIGTERM +- **THEN** the runtime scope closes, the shared session ends with a FIN + handshake (destroy only as a timeout fallback), and the process exits + +#### Scenario: Self-healing after a dropped socket + +- **WHEN** the tuner socket drops (game restart) and a later read arrives +- **THEN** the shared session reconnects on that request without daemon + restart and the read succeeds once the tuner answers + +### Requirement: Tuner Reads Back Off When The Game Stops Answering + +The shared session service SHALL track consecutive response-timeouts +observed on the shared socket and, past a threshold, fail polling reads +fast with a typed backoff error for a fixed cooldown instead of issuing +new tuner requests — recovering automatically (half-open) after the +cooldown, with the counter reset by any successful response. Connection +failures (game not running) are not gated. Response shapes and status +codes of the read surface are unchanged; only failure message text may +differ while the gate is open. + +#### Scenario: Gate opens under sustained timeouts + +- **WHEN** consecutive tuner reads exceed the response-timeout threshold +- **THEN** subsequent reads fail fast with the typed backoff error during + the cooldown and no new requests are written to the tuner socket + +#### Scenario: Gate recovers + +- **WHEN** the cooldown elapses and the next read receives a successful + response +- **THEN** the counter resets and reads flow normally again + +### Requirement: The Daemon Reports Tuner Session Health + +The daemon's health endpoint SHALL report the shared tuner session state — +consecutive response-timeouts, gate status, and a wedge-suspicion flag — +so a wedged game (process alive, tuner silent) is observable without log +archaeology. + +#### Scenario: Wedge suspicion is visible + +- **WHEN** the tuner stops answering while the game process is running + and the timeout threshold is crossed +- **THEN** the health endpoint reports the elevated consecutive-timeout + count and wedge suspicion diff --git a/openspec/changes/mapgen-studio-tuner-session/tasks.md b/openspec/changes/mapgen-studio-tuner-session/tasks.md new file mode 100644 index 0000000000..f9c3a67b04 --- /dev/null +++ b/openspec/changes/mapgen-studio-tuner-session/tasks.md @@ -0,0 +1,56 @@ +## 1. Frame + +- [ ] 1.1 Workstream record + proposal/design/tasks/spec deltas committed + (`design/tuner-session-frame`), `--strict` valid. + +## 2. direct-control seam (`design/tuner-session-seam`) + +- [ ] 2.1 `Civ7DirectControlOptions.session?` — caller-owned session; + `withCiv7DirectControlSession` reuses without closing; absent → + unchanged behavior. +- [ ] 2.2 Graceful `close()`: reject pending → `end()` (FIN) → await + `close` with 1s fallback `destroy()`; idempotent. +- [ ] 2.3 `session.stats`: consecutive response-timeouts (increment on + response-timeout, reset on any resolved frame). +- [ ] 2.4 Package tests (fake tuner server): injected session reused + + not closed; FIN observed on graceful close; stats counter behavior. +- [ ] 2.5 Gates: direct-control tests, tsc, dependents build. + +## 3. Effect service (`design/tuner-effect-session`) + +- [ ] 3.1 `Civ7TunerSession` (studio-server): `scoped:` acquireRelease of + one session; `use(run)` with cooldown gate (threshold 4, 15s, + response-timeout only) + typed `Civ7TunerBackoffError`; `session` + + `health()` ports. +- [ ] 3.2 `Civ7TunerClient`: `dependencies: [Civ7TunerSession.Default]`; + accessors route through `use` with the shared session injected. +- [ ] 3.3 `runtime.ts` merges the session layer (memoized single + instance); `handler.ts` exposes `tuner.{session,health}` + + `dispose()`. +- [ ] 3.4 Tests: gate fail-fast/half-open/reset against a stub session; + dispose closes the session exactly once. +- [ ] 3.5 Gates: studio-server build + tests, studio app tsc/tests. + +## 4. Daemon wiring (`design/tuner-daemon-wiring`) + +- [ ] 4.1 Control mount options gain `session?` → merged into + `endpointDefaults` (no control-orpc package changes). +- [ ] 4.2 `daemon.ts`: inject the shared session into the control + handler; `/healthz` tuner block (consecutive timeouts, gate, + `wedgeSuspected`); SIGINT/SIGTERM → `dispose()` → stop. +- [ ] 4.3 Tests: daemon health/dispatch updates; existing pins green. +- [ ] 4.4 Gates: tsc, studio tests, mod tests, build + worker bundle, + `--strict` valid. +- [ ] 4.5 Live verification (fresh processes, Civ7 in shell): readiness + + live polls work; `lsof -nP -iTCP:4318` shows ONE established + connection reused across polls and a flat CLOSED count during a + soak; healthz tuner block live; daemon stop sends FIN. +- [ ] 4.6 Workstream record closed with evidence; goal-ledger entry. + +## 5. Deferred (explicit) + +- [ ] 5.1 Run-in-game flow convergence onto the shared session — OUT OF + SCOPE (behavior parity); revisit only with a dedicated parity + harness. +- [ ] 5.2 "Restart Civ7" recovery affordance in the Studio UI driven by + `wedgeSuspected` — follow-up surface work. diff --git a/openspec/changes/mapgen-studio-tuner-session/workstream/workstream-record.md b/openspec/changes/mapgen-studio-tuner-session/workstream/workstream-record.md new file mode 100644 index 0000000000..a4b446e080 --- /dev/null +++ b/openspec/changes/mapgen-studio-tuner-session/workstream/workstream-record.md @@ -0,0 +1,56 @@ +# Systematic Workstream Record + +## Frame + +- Objective: Stop the Civ7 tuner connection churn that wedges the game + (leaked game-side fds → all reads time out) by making the daemon's + polling read surface share ONE Effect-managed `Civ7DirectControlSession` + — scoped acquisition/release in the studio `ManagedRuntime`, graceful + FIN on shutdown, typed backoff when the game stops answering, wedge + signal on healthz. User direction: solve with Effect; follow the + /typescript skill's design disciplines. +- Future state: a long Studio session keeps at most one established tuner + connection for polls; the daemon disposes its runtime on shutdown (FIN + to the game); when the game goes silent the Studio backs off with a + typed error instead of hammering; the wedge state is observable. +- Non-goals: pooling/RcRef; run-in-game flow changes; "restart Civ7" UI; + control-orpc package changes. +- Hard core: behavior parity for run-in-game (serialized queue, proof + markers, per-flow sessions untouched); read-surface response shapes + + non-uniform status codes unchanged (failure message text may differ + while the gate is open); `Civ7DirectControlOptions.session` absent → + byte-identical legacy behavior; no Effect dependency enters + `@civ7/direct-control`. +- Exterior: `codex/*` branches; CLI consumers of direct-control (the new + options field is additive). + +## Authority + +- `openspec/changes/mapgen-studio-bun-server/workstream/workstream-record.md` + Phase 4 addendum (incident: 187 leaked fds; isolation matrix: clean + reads leak zero under node AND Bun) +- effect@3.21.3 source (ManagedRuntime scope/dispose semantics; + Effect.Service `scoped:`/`dependencies`; layer memoization; no core + circuit breaker — Effect-TS/effect#2843) +- effect-orpc@0.2.2 source (`runtime.runPromiseExit` per handler; no + per-request scope — app-scope services live in the runtime layer) +- `packages/civ7-direct-control/src/session/*` (listenerId multiplexing, + idempotent connect, the `withCiv7DirectControlSession` funnel) +- TypeScript skill references (ports/adapters, strangler fig, typed error + contracts, proportional complexity) + +## Plan + +- Phase 1 (`design/tuner-session-frame`): this record + docs, `--strict`. +- Phase 2 (`design/tuner-session-seam`): direct-control injection seam + + graceful close + stats (+ package tests). +- Phase 3 (`design/tuner-effect-session`): `Civ7TunerSession` scoped + service + gate; `Civ7TunerClient` convergence; handler `tuner` ports + + `dispose()`. +- Phase 4 (`design/tuner-daemon-wiring`): control mount session + injection; healthz tuner block; signal-driven dispose; live soak + verification (one established connection; flat CLOSED count). + +## Evidence + +- (per phase, appended as slices close) diff --git a/scripts/civ7-direct-control/verify-final-surface-parity.ts b/scripts/civ7-direct-control/verify-final-surface-parity.ts index 3c513736cd..0e324e0c57 100644 --- a/scripts/civ7-direct-control/verify-final-surface-parity.ts +++ b/scripts/civ7-direct-control/verify-final-surface-parity.ts @@ -119,11 +119,20 @@ async function loadExactAuthorshipProof(args: Args): Promise