diff --git a/openspec/changes/mapgen-studio-bun-server/design.md b/openspec/changes/mapgen-studio-bun-server/design.md new file mode 100644 index 0000000000..81d483f1f2 --- /dev/null +++ b/openspec/changes/mapgen-studio-bun-server/design.md @@ -0,0 +1,155 @@ +# Design — Standalone Bun daemon for the Studio server surface + +## Current state (verified 2026-06-12) + +- `vite.config.ts` (1,420 lines) carries: ~950 lines of engine code at + module scope (queue, two operation stores, instance identity, autoplay / + run-in-game / save-deploy engines), 16 legacy REST handlers, and three + oRPC mounts (`/rpc` studio-server, `/api/civ7/rpc` control, + `/api/recipe-dag/rpc` recipe-DAG via per-request `ssrLoadModule`). +- The client is already 100% oRPC: zero manual `/api/*` fetches in app + source. The only `/api` strings client-side are the two oRPC path + constants. (P2 client-data slice did the cutover of reads.) +- `@civ7/studio-server`'s handler is already the A4-lite fetch shape + (`Request`/`Response`); the recipe-DAG handler likewise + (`createStudioRecipeDagRpcHandler`). Only the control mount still uses + the node adapter (`@orpc/server/node`). +- Production (`railway.json`/Caddy) serves static `dist` only — no server. + +## Topology decision + +Blueprint: `tools/gt-stack-inspect` on +`codex/gt-stack-inspect-dev-live-topology` (read-only prior art) — a Bun +daemon (`Bun.serve({ fetch })` over a fetch-adapter RPC handler + static +assets + `/healthz`), a `dev-live` runner spawning daemon then Vite, and a +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: + │ /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) + │ static dist (optional, --assets-root; prod story) + └─ spawns: vite --port 5173 (frontend) + server.proxy: /rpc → 5174, /api → 5174 +``` + +- Ports: Vite keeps 5173 (`strictPort`); daemon defaults to 5174. + `STUDIO_DEV_RPC_TARGET` overrides the proxy target so `vite` alone still + works against an already-running daemon. +- The daemon resolves `repoRoot` from its own module location by default + (`--repo-root` overrides), mirroring the engines' previous + `vite.config.ts`-relative resolution. +- `Bun.serve` is typed via a minimal local `declare const Bun` (blueprint + pattern) — no new dependency; the module is executed only under Bun. + +## Engine extraction (slice 2 — no topology change) + +`createStudioEngines({ repoRoot })` in `src/server/studio/engines.ts` owns +all process-singleton state and returns the five engine functions plus +identity. Moved **verbatim** from `vite.config.ts`; the only edits are the +factory closure and `repoRoot` parameterization (previously recomputed per +call via `fileURLToPath(new URL("../..", import.meta.url))`). + +`createStudioServerContext({ engines, hostCommand })` in +`src/server/studio/context.ts` is the moved `createStudioServerContextForApp` +— the `RunInGameHttpError → ORPCError` mapping that preserves the +non-uniform status codes (arch/10 §1 parity invariants). + +Module constraint (load-bearing): `engines.ts` imports +`@civ7/direct-control` + local server modules only — **never +`effect-orpc`** — so the interim static import into `vite.config.ts` stays +node-evaluable. `@civ7/studio-server` is safe (tsup bundles effect-orpc +into its dist); app-src effect-orpc consumers (the recipe-DAG router) stay +out of the config graph until the daemon slice removes the need entirely. + +Slice 2 leaves Vite hosting everything — the config consumes the extracted +module at module scope, identical behavior, all transports still share one +engine instance per process. + +## Control-oRPC fetch adapter (slice 3) + +`civ7ControlOrpc.ts` re-shapes to mirror `recipeDag/orpc.ts`: canonical +`createStudioCiv7ControlRpcHandler` on `RPCHandler` from +`@orpc/server/fetch` (prefix `STUDIO_CIV7_CONTROL_ORPC_PATH`), plus the +Connect shim over `server/http/nodeWebBridge` for the Vite mount this +slice. Context construction (`directControl` facade + `endpointDefaults`) +unchanged. Path contract unchanged. + +## Daemon + cutover (slice 4) + +- `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}`. +- `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 + code — config restarts become cheap and safe (the silent-restart-failure + trap documented in the dag-tab addendum loses its teeth). +- `package.json`: `dev` → dev-live runner; `dev:frontend` → `vite`; + `dev:server` → daemon. `build`/`test`/`check` unchanged. +- `studio.serverInfo.viteCommand` (contract: `z.string()`) is supplied by + the host: the daemon passes `"daemon"`. Verified no client branches on + the value (display/identity only); `serverInstanceId`/`startedAt` restart + 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) + +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. + +| # | Legacy path | oRPC equivalent (`/rpc`) | +| --- | --- | --- | +| 1 | `GET /api/civ7/status` | `civ7.status` | +| 2 | `GET /api/civ7/map-summary` | `civ7.mapSummary` | +| 3 | `GET /api/civ7/gameinfo` | `civ7.gameInfo` | +| 4 | `GET /api/civ7/live/status` | `civ7.live.status` | +| 5 | `GET /api/civ7/live/snapshot` | `civ7.live.snapshot` | +| 6 | `GET /api/civ7/live/entities` | `civ7.live.entities` | +| 7 | `GET /api/civ7/live/gameinfo` | `civ7.live.gameInfo` | +| 8 | `POST /api/civ7/autoplay` | `civ7.autoplay` | +| 9 | `GET /api/studio/server-info` | `studio.serverInfo` | +| 10 | `GET /api/civ7/setup-config` | `civ7.setupConfig` | +| 11 | `GET /api/civ7/saved-configs` | `civ7.savedConfigs` | +| 12 | `GET /api/civ7/setup-catalog` | `civ7.setupCatalog` | +| 13 | `GET /api/civ7/run-in-game/status` | `runInGame.status` (+ parity script) | +| 14 | `POST /api/civ7/run-in-game` | `runInGame.start` | +| 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. +- 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 + (`makeDevLivePlan` pure function: commands, ports, proxy env). +- Live smoke (fresh processes, both run modes): `bun run dev` → + 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`. diff --git a/openspec/changes/mapgen-studio-bun-server/proposal.md b/openspec/changes/mapgen-studio-bun-server/proposal.md new file mode 100644 index 0000000000..795a35187c --- /dev/null +++ b/openspec/changes/mapgen-studio-bun-server/proposal.md @@ -0,0 +1,90 @@ +# Studio server cutover — standalone Bun daemon owns the server surface + +## Why + +Today the Studio has no server process of its own: every server-side surface +(the `@civ7/studio-server` `/rpc` mount, the control-oRPC mount at +`/api/civ7/rpc`, the recipe-DAG mount at `/api/recipe-dag/rpc`, and 16 +hand-rolled legacy `/api/*` REST handlers) runs as Vite dev middleware, with +the stateful engines (serialized operation queue, run-in-game + save/deploy +operation stores, server instance identity) living at module scope inside +`vite.config.ts`. Production is a static SPA with no server at all, so the +dev semantics are the only semantics — and they are welded to the dev +bundler. This was the planned P5a coexistence state; the P5b cutover +(00-GOAL "Remaining (SUPERVISED)", FRAME §4.7 deferral, user go granted +2026-06-11) moves the server surface onto a standalone Bun daemon and makes +Vite frontend-only. + +Bun also dissolves a real defect class: `effect-orpc` ships TypeScript +source as its package entry, which Node cannot load outside Vite's SSR +pipeline — forcing the recipe-DAG mount through per-request `ssrLoadModule` +(see `mapgen-studio-dag-tab` design addendum). Bun loads TS natively; the +daemon imports the same fetch handler statically. + +## Target Authority Refs + +- `docs/projects/mapgen-studio-redesign/architecture/10-target-architecture.md` + (§1 server target + parity invariants, §7 do-not-break registry) +- `docs/projects/mapgen-studio-redesign/00-GOAL.md` (P5b entry — supervised, + go granted) +- `packages/studio-server/src/{index,handler}.ts` (A4-lite host seam: + fetch-adapter `createStudioRpcHandler`) +- `openspec/changes/mapgen-studio-dag-tab/design.md` addendum (mount + constraints + research evidence: fetch handlers are the canonical + artifacts, Vite hosting was a dev-era necessity) +- `tools/gt-stack-inspect` on `codex/gt-stack-inspect-dev-live-topology` + (prior-art daemon + dev-live runner + Vite `/rpc` proxy topology; + read-only reference — `codex/*` branches are never touched) + +## What Changes + +- **Engines move out of `vite.config.ts`** into + `apps/mapgen-studio/src/server/studio/engines.ts`: a + `createStudioEngines({ repoRoot })` factory owning the serialized + operation queue, both operation stores, the server instance identity, and + the five engine functions (autoplay, run-in-game start/status, + save-deploy start/status) — moved verbatim, behavior-parity hard core. + The `StudioServerContext` builder moves alongside as + `createStudioServerContext`. (This slice changes no topology: Vite still + hosts everything, now via the extracted module.) +- **Control-oRPC mount converts to the fetch adapter** + (`@orpc/server/fetch`), mirroring the recipe-DAG mount's shape: canonical + fetch handler + thin Connect shim over the shared + `server/http/nodeWebBridge`. Path contract `/api/civ7/rpc` unchanged. +- **Standalone Bun daemon** (`src/server/daemon/daemon.ts`): `Bun.serve` + hosting a fetch router that owns `/healthz`, `/rpc` (studio-server), + `/api/civ7/rpc` (control-oRPC), `/api/recipe-dag/rpc` (statically + imported — the `ssrLoadModule` constraint evaporates under Bun), the + legacy `/api/*` REST surface as a compat layer over the same engines, and + optional static `dist` serving (the production story's opening, not wired + to deploy in this change). +- **Vite becomes frontend-only**: the `configureServer` plugin and all + server-side imports leave `vite.config.ts`; `server.proxy` forwards + `/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`. + +## 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 + the recipe-DAG router; no client-visible transport changes. +- No re-implementation of engine logic — the engine bodies move verbatim. + +## Impact + +- 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) 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 new file mode 100644 index 0000000000..7013b272ce --- /dev/null +++ b/openspec/changes/mapgen-studio-bun-server/specs/mapgen-studio/spec.md @@ -0,0 +1,75 @@ +## ADDED Requirements + +### 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. + +#### Scenario: One-command dev runs both processes + +- **WHEN** the developer runs the studio dev script +- **THEN** the daemon starts and reports healthy before Vite starts, the + app is served on the Vite port, and every `/rpc` and `/api` request is + answered by the daemon process + +#### Scenario: Recipe-DAG mount loads natively under Bun + +- **WHEN** the daemon serves a recipe-DAG RPC request +- **THEN** the handler is a statically imported module (no SSR loader + indirection) and the response matches the previous Vite-hosted mount + +#### Scenario: Frontend-only Vite config + +- **WHEN** the Vite config is evaluated (dev or build) +- **THEN** it imports no engine, direct-control, or server-handler modules + and registers no server middleware beyond the proxy + +### Requirement: Studio Server State Lives In One Process + +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. + +#### Scenario: Both transports 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 +- **THEN** the request is rejected with the 409 dual-mutex semantics + naming the active operation + +#### Scenario: Restart detection identity + +- **WHEN** the daemon restarts and a client polls a stale run-in-game + request id +- **THEN** the 404 response carries the new process's `serverInstanceId` + and `serverStartedAt` + +### Requirement: Legacy REST Surface Survives The Cutover Until The Retirement Checkpoint + +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. + +#### Scenario: Compat parity + +- **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 + +#### Scenario: Retirement is gated + +- **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 diff --git a/openspec/changes/mapgen-studio-bun-server/tasks.md b/openspec/changes/mapgen-studio-bun-server/tasks.md new file mode 100644 index 0000000000..41c6446dfb --- /dev/null +++ b/openspec/changes/mapgen-studio-bun-server/tasks.md @@ -0,0 +1,59 @@ +## 1. Frame + +- [ ] 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 })` + — 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 `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). + +## 3. Control-oRPC fetch adapter (`design/bun-control-fetch`) + +- [ ] 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). + +## 4. Daemon + cutover (`design/bun-server-daemon`) + +- [ ] 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` → + spawn Vite; signal forwarding; `makeDevLivePlan` pure. +- [ ] 4.4 `vite.config.ts`: delete the server plugin + server imports; add + `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-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`. diff --git a/openspec/changes/mapgen-studio-bun-server/workstream/workstream-record.md b/openspec/changes/mapgen-studio-bun-server/workstream/workstream-record.md new file mode 100644 index 0000000000..d0ea9e4cec --- /dev/null +++ b/openspec/changes/mapgen-studio-bun-server/workstream/workstream-record.md @@ -0,0 +1,50 @@ +# Systematic Workstream Record + +## Frame + +- Objective: Move the Studio's server surface off the Vite dev middleware + onto a standalone Bun daemon (P5b cutover, user go granted 2026-06-11), + making Vite frontend-only and dissolving the effect-orpc TS-source + loading constraint — with zero observable behavior change for the app or + the legacy REST surface. +- Future state: `bun run dev` runs a daemon (owning every `/rpc` + `/api` + 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. +- 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`, + serialized queue + dual mutex, fingerprint dedup→202); path contracts + (`/rpc`, `/api/civ7/rpc`, `/api/recipe-dag/rpc`); single engine instance + per server process. +- Exterior: `codex/*` branches (read-only prior art); placement-stack + resources vertical; the live-control lane's stack. + +## Authority + +- `docs/projects/mapgen-studio-redesign/architecture/10-target-architecture.md` + (§1 server target, §7 do-not-break registry) +- `docs/projects/mapgen-studio-redesign/00-GOAL.md` (P5b — supervised) +- `packages/studio-server/src/{index,handler}.ts` (A4-lite fetch seam) +- `openspec/changes/mapgen-studio-dag-tab/design.md` addendum (mount + research: fetch handlers canonical, Vite hosting dev-era) +- `tools/gt-stack-inspect` @ `codex/gt-stack-inspect-dev-live-topology` + (daemon + dev-live + proxy blueprint) + +## Plan + +- Phase 1 (`design/bun-server-frame`): this record + proposal/design/tasks + + spec deltas, `--strict` valid. +- Phase 2 (`design/bun-server-engines`): engine extraction, no topology + 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`. + +## Evidence + +- (per phase, appended as slices close)