[eric] apps: an app nobody is editing serves its built bundle with no dev-server process (~270MB -> ~0), vite reboots the moment an agent binds or the dist goes stale

This commit is contained in:
ciregenz
2026-08-09 16:53:14 -07:00
parent 2476d4c03d
commit 97d0c7a8ef
6 changed files with 277 additions and 6 deletions
@@ -61,6 +61,13 @@ class AgentLaunch(AgentManagerProtocol):
):
from backend.apps.outputs.workspace_io import app_workspace_dir
bound = app_workspace_dir(config.selected_app_output_ids[0])
if bound:
# The user is about to EDIT this app: a serve-static runtime must boot vite now or the agent's changes render nowhere (ENG-209).
try:
from backend.apps.outputs.runtime import manager as p_rt_manager
await p_rt_manager.ensure_editing(bound)
except Exception:
pass
if bound:
config.target_directory = bound
+12 -5
View File
@@ -120,18 +120,23 @@ async def serve_workspace_file(workspace_id: str, filepath: str, p_d: str = ""):
if not os.path.isfile(full_path):
raise HTTPException(status_code=404, detail="File not found")
with open(full_path) as f:
content = f.read()
if filepath == "index.html":
# endswith, not equality: serve-mode delivers frontend/dist/index.html through this same route and needs the identical injection + token rewrite (ENG-209).
if filepath.endswith("index.html"):
with open(full_path) as f:
content = f.read()
input_json, result_json = decode_data_param(p_d) if p_d else ("{}", "null")
backend_url_json = backend_url_for_workspace(workspace_id)
content = inject_data_into_html(content, input_json, result_json, backend_url_json, with_runtime=True)
# Iframe sub-resource fetches (<link>, <script src>, <img>) drop the parent's ?token= query string, so rewrite the HTML to put the token back on every relative URL; otherwise sub-resources 401.
content = inject_token_into_relative_urls(content, get_auth_token())
mime, _ = mimetypes.guess_type(filepath)
return Response(content=content, media_type=mime or "text/plain")
# Binary-safe for everything else: a built bundle carries fonts and images that a text read would corrupt.
with open(full_path, "rb") as f:
raw = f.read()
mime, _ = mimetypes.guess_type(filepath)
return Response(content=content, media_type=mime or "text/plain")
return Response(content=raw, media_type=mime or "application/octet-stream")
@outputs.router.get("/{output_id}/serve/{filepath:path}")
@@ -460,6 +465,8 @@ def runtime_status_payload(workspace_id: str, instance: int = 1) -> dict:
"running": rt.running,
# 'spawned' vs 'serving': ready flips only once the primary port answered the bind poll (and un-flips when the process dies or is frozen).
"ready": rt.ready,
# True when the app is served as a built bundle with no dev-server process (ENG-209).
"serve_static": rt.serve_static,
"port": rt.port,
"serving_url": serving_url,
"has_backend_file": rt.has_backend_file,
+38 -1
View File
@@ -108,6 +108,8 @@ class AppRuntime:
# Old-mode: `port` is the backend.py port. New-mode: `port` is the workspace's optional FastAPI backend (only set if BACKEND_PORT!=NONE) and `frontend_port` is the Vite dev server port. Both Nones until start() decides what's there.
self.port: Optional[int] = None
self.frontend_port: Optional[int] = None
# Serve-mode (ENG-209): a fresh built bundle + no agent editing means NO process at all; the workspace serve route delivers frontend/dist and this flag makes ready/frontend_url say so.
self.serve_static: bool = False
# New-mode only: flips True once something is actually listening on frontend_port (we kick off a background poll task in p_start_new_mode). frontend_url returns null until this flips, so the preview pane doesn't try to navigate to an unbound port and show a "Site can't be reached" error mid-npm-install.
self.p_frontend_ready: bool = False
# True while the process tree is SIGSTOP'd in the idle pool. A frozen vite still holds its port but can't answer it, so frontend_url must stay null while suspended (else the webview loads a dead port = the ERR_FAILED on fast app-switching).
@@ -174,6 +176,8 @@ class AppRuntime:
"""True only when the runtime is actually SERVING (process alive, not frozen, and its
primary port answered the bind poll), so callers can tell 'spawned' from 'serving'.
Old-mode workspaces have no bind poll; a live process is their best readiness signal."""
if self.serve_static:
return True
if not self.running or self.p_suspended:
return False
if self.is_new_mode:
@@ -183,6 +187,11 @@ class AppRuntime:
@property
def frontend_url(self) -> Optional[str]:
# Gated on `_frontend_ready` (set by the background bind-poll task in p_start_new_mode) so the preview pane only switches over once Vite is actually accepting connections. Without this, the editor flashes a "Site can't be reached" error while `npm install` is running. Also gated on `running`: a vite that crashed or got orphaned still has _frontend_ready=True, and handing the webview that dead port is the ERR_FAILED you see on reopen. No live process, no URL. And gated on `not _suspended`: a SIGSTOP'd idle runtime is "running" (returncode is None) but frozen, so its port won't answer.
if self.serve_static:
# The existing workspace serve route: index injection + token rewrite apply, and the dist's relative assets resolve under the same path.
from backend.auth import init_auth_token
p_port = os.environ.get("OPENSWARM_PORT", "8324")
return f"http://127.0.0.1:{p_port}/api/outputs/workspace/{self.workspace_id}/serve/frontend/dist/index.html?token={init_auth_token()}"
if self.frontend_port and self.p_frontend_ready and self.running and not self.p_suspended:
return f"http://127.0.0.1:{self.frontend_port}/"
return None
@@ -230,6 +239,14 @@ class AppRuntime:
return await self.p_start_old_mode()
async def p_start_new_mode(self) -> bool:
# Serve-mode (ENG-209): a fresh built bundle + nobody editing = no process at all. Primary
# instance only (secondaries are explicitly "another independent window", keep them live).
if self.instance == 1:
from backend.apps.outputs.static_serve import static_fresh, workspace_being_edited
if static_fresh(self.workspace_path) and not workspace_being_edited(self.workspace_path):
self.serve_static = True
self.p_broadcast(LogLine("runtime", "[runtime] serving the built bundle (no dev server); editing an app boots vite automatically"))
return True
# Legacy workspaces (scaffolded pre-multi-instance) ignore the forced ports; self-heal their run.sh so a second instance stops colliding on the primary's ports.
ensure_force_port_shim(self.workspace_path)
env_path = os.path.join(self.workspace_path, ".env")
@@ -672,8 +689,15 @@ class AppRuntimeManager:
# Workspace paths shouldn't change for a given id, but if somehow they did (e.g. the user moved the workspace folder), trust the latest caller; they have the current truth.
rt.workspace_path = workspace_path
self.p_attached[key] = self.p_attached.get(key, 0) + 1
if not revived and not rt.running:
if not revived and not rt.running and not rt.serve_static:
await rt.start()
# A serve-static runtime re-checks its world on every attach: an agent may have bound to the
# workspace since, or the dist may have gone stale; either flips it back to a real vite boot.
if rt.serve_static:
from backend.apps.outputs.static_serve import static_fresh, workspace_being_edited
if workspace_being_edited(rt.workspace_path) or not static_fresh(rt.workspace_path):
rt.serve_static = False
await rt.start()
# Stop any dead idle runtime outside the lock to avoid blocking.
if dead is not None:
try:
@@ -682,6 +706,15 @@ class AppRuntimeManager:
logger.exception("failed to reap dead idle runtime %s", key)
return rt
async def ensure_editing(self, workspace_path: str) -> None:
"""An agent just bound to this workspace: any serve-static runtime for it must become a real
vite dev server, or the agent edits files the user never sees."""
norm = os.path.realpath(workspace_path)
for rt in list(self.runtimes.values()) + list(self.idle_lru.values()):
if os.path.realpath(rt.workspace_path) == norm and rt.serve_static:
rt.serve_static = False
await rt.start()
async def detach(self, workspace_id: str, instance: int = 1) -> None:
key = runtime_key(workspace_id, instance)
to_idle: Optional[AppRuntime] = None
@@ -695,6 +728,10 @@ class AppRuntimeManager:
rt = self.runtimes.pop(key, None)
if rt is None:
return
# Park-time dist build (ENG-209): a vite runtime going idle is the moment to bake the static bundle, so the NEXT open serves it processlessly. Fire-and-forget; failure just means vite again.
if rt.running and not rt.serve_static and rt.instance == 1 and is_new_mode(rt.workspace_path):
from backend.apps.outputs.static_serve import build_dist_in_background
asyncio.create_task(build_dist_in_background(rt.workspace_path, rt.workspace_id))
# If the process is already dead, no point keeping it around; just clean up. Otherwise move to the LRU AND SIGSTOP the process tree so it consumes 0% CPU while idle. The matching SIGCONT lives in attach() above.
if not rt.running:
to_reap.append(rt)
+112
View File
@@ -0,0 +1,112 @@
"""Serve-mode support (ENG-209): an open app that nobody is editing costs a whole vite dev server
(~270MB). When a FRESH built bundle exists and no agent is bound to the workspace, the runtime
serves `frontend/dist` statically through the existing workspace serve route instead of spawning
anything; vite comes back the moment an agent starts editing."""
import asyncio
import logging
import os
import subprocess
from typing import Optional
from typeguard import typechecked
logger = logging.getLogger(__name__)
@typechecked
def dist_index(workspace_path: str) -> str:
return os.path.join(workspace_path, "frontend", "dist", "index.html")
@typechecked
def p_newest_source_mtime(workspace_path: str) -> float:
newest = 0.0
fe = os.path.join(workspace_path, "frontend")
for probe in ("package.json", "vite.config.ts", "index.html"):
p = os.path.join(fe, probe)
if os.path.isfile(p):
newest = max(newest, os.path.getmtime(p))
src = os.path.join(fe, "src")
for dirpath, dirnames, filenames in os.walk(src):
dirnames[:] = [d for d in dirnames if d != "node_modules"]
for f in filenames:
try:
newest = max(newest, os.path.getmtime(os.path.join(dirpath, f)))
except OSError:
pass
return newest
@typechecked
def static_fresh(workspace_path: str) -> bool:
"""A dist we can honestly serve: exists, newer than every source file, and built with RELATIVE
asset paths (vite `base: './'`). Absolute /assets/ URLs would resolve against the backend host
root and 404, so a dist built by an older template is refused rather than served broken."""
idx = dist_index(workspace_path)
if not os.path.isfile(idx):
return False
try:
with open(idx, encoding="utf-8", errors="ignore") as f:
html = f.read()
except OSError:
return False
if 'src="/' in html or 'href="/' in html:
return False
return os.path.getmtime(idx) >= p_newest_source_mtime(workspace_path)
@typechecked
def workspace_being_edited(workspace_path: str) -> bool:
"""True when a live agent session is bound to this workspace (launch sets the chat's cwd to the
app it edits), so serve-mode never hides an agent's in-flight changes behind a stale bundle."""
try:
from backend.apps.agents.agents import agent_manager
norm = os.path.realpath(workspace_path)
for session in agent_manager.sessions.values():
if session.status not in ("running", "waiting_approval"):
continue
cwd = getattr(session, "cwd", None)
if cwd and os.path.realpath(cwd) == norm:
return True
except Exception:
logger.debug("being-edited probe failed; assuming edited (vite)", exc_info=True)
return True
return False
@typechecked
async def build_dist_in_background(workspace_path: str, workspace_id: str) -> Optional[int]:
"""Fire `npm run build` for a parked app so the NEXT open can serve static. Best-effort: any
missing precondition (no node_modules, no build script) just means vite next time. Returns the
exit code, or None when the build was skipped."""
fe = os.path.join(workspace_path, "frontend")
if not os.path.isdir(os.path.join(fe, "node_modules")):
return None
if static_fresh(workspace_path):
return None
node_bin = os.path.dirname(os.environ.get("OPENSWARM_NODE_PATH", "") or "")
env = {**os.environ}
if node_bin:
env["PATH"] = f"{node_bin}:{env.get('PATH', '')}"
log_path = os.path.join(workspace_path, ".openswarm", "build.log")
os.makedirs(os.path.dirname(log_path), exist_ok=True)
try:
with open(log_path, "w", encoding="utf-8") as log:
proc = await asyncio.create_subprocess_exec(
"npm", "run", "build",
cwd=fe, env=env, stdout=log, stderr=subprocess.STDOUT,
)
code = await asyncio.wait_for(proc.wait(), timeout=180)
logger.info("background dist build for %s exited %s", workspace_id, code)
return code
except asyncio.TimeoutError:
try:
proc.kill()
except Exception:
pass
logger.warning("background dist build for %s timed out", workspace_id)
return -1
except Exception:
logger.debug("background dist build for %s failed to spawn", workspace_id, exc_info=True)
return None
@@ -49,6 +49,8 @@ export default defineConfig(({ mode }) => {
const backendEnabled = backendPort && backendPort !== 'NONE';
return {
// Relative asset URLs: the built bundle is served under /api/outputs/workspace/<id>/serve/frontend/dist/, where absolute /assets/ paths would 404 (ENG-209 serve-mode).
base: './',
cacheDir: sharedViteCacheDir(),
plugins: [
react(),
+106
View File
@@ -0,0 +1,106 @@
"""ENG-209: an open app nobody is editing serves its built bundle with NO process; vite comes back
the moment an agent binds or the dist goes stale. Every transition is covered, since a wrong switch
would break preview for every app."""
import asyncio
import os
import time
from backend.apps.outputs import static_serve
from backend.apps.outputs.runtime import AppRuntime, AppRuntimeManager
def p_seed(tmp_path, relative_assets=True, with_dist=True):
ws = tmp_path / "ws"
fe = ws / "frontend"
(fe / "src").mkdir(parents=True)
(fe / "src" / "App.tsx").write_text("export {}")
(fe / "package.json").write_text("{}")
(ws / "run.sh").write_text("#!/bin/bash\n")
if with_dist:
dist = fe / "dist"
dist.mkdir()
src = "./assets/index-abc.js" if relative_assets else "/assets/index-abc.js"
(dist / "index.html").write_text(f'<html><script src="{src}"></script></html>')
return str(ws)
def test_static_fresh_requires_dist_newer_than_source_and_relative_assets(tmp_path):
ws = p_seed(tmp_path)
assert static_serve.static_fresh(ws) is True
time.sleep(0.02)
os.utime(os.path.join(ws, "frontend", "src", "App.tsx"))
assert static_serve.static_fresh(ws) is False
assert static_serve.static_fresh(p_seed(tmp_path / "abs", relative_assets=False)) is False
assert static_serve.static_fresh(p_seed(tmp_path / "nodist", with_dist=False)) is False
def test_start_serves_static_when_not_edited(tmp_path, monkeypatch):
ws = p_seed(tmp_path)
monkeypatch.setattr(static_serve, "workspace_being_edited", lambda p: False)
rt = AppRuntime("ws-t", ws)
assert asyncio.run(rt.start()) is True
assert rt.serve_static is True and rt.process is None and rt.ready is True
assert "/serve/frontend/dist/index.html" in (rt.frontend_url or "")
def test_start_skips_serve_when_edited(tmp_path, monkeypatch):
ws = p_seed(tmp_path)
monkeypatch.setattr(static_serve, "workspace_being_edited", lambda p: True)
rt = AppRuntime("ws-t2", ws)
spawned = {"n": 0}
async def p_no_spawn(env):
spawned["n"] += 1
return None, ws, "stub"
monkeypatch.setattr(rt, "p_resolve_launch", p_no_spawn)
try:
asyncio.run(rt.start())
except Exception:
pass
assert rt.serve_static is False and spawned["n"] == 1
def p_manager_no_watcher(monkeypatch):
# The restart-sentinel watcher is a forever loop; tests must not arm it or asyncio.run leaks a task.
monkeypatch.setattr(AppRuntimeManager, "p_ensure_restart_watcher", lambda self: None)
return AppRuntimeManager()
def test_ensure_editing_flips_serve_runtime_to_vite(tmp_path, monkeypatch):
ws = p_seed(tmp_path)
mgr = p_manager_no_watcher(monkeypatch)
rt = AppRuntime("ws-t", ws)
rt.serve_static = True
restarted = {"n": 0}
async def p_fake_start():
restarted["n"] += 1
return True
monkeypatch.setattr(rt, "start", p_fake_start)
mgr.runtimes["ws-t"] = rt
asyncio.run(mgr.ensure_editing(ws))
assert rt.serve_static is False and restarted["n"] == 1
def test_attach_recheck_reboots_when_dist_goes_stale(tmp_path, monkeypatch):
ws = p_seed(tmp_path)
mgr = p_manager_no_watcher(monkeypatch)
rt = AppRuntime("ws-t", ws)
rt.serve_static = True
restarted = {"n": 0}
async def p_fake_start():
restarted["n"] += 1
return True
monkeypatch.setattr(rt, "start", p_fake_start)
mgr.runtimes["ws-t"] = rt
monkeypatch.setattr(static_serve, "workspace_being_edited", lambda p: False)
asyncio.run(mgr.attach("ws-t", ws))
assert rt.serve_static is True and restarted["n"] == 0
time.sleep(0.02)
os.utime(os.path.join(ws, "frontend", "src", "App.tsx"))
asyncio.run(mgr.attach("ws-t", ws))
assert rt.serve_static is False and restarted["n"] == 1