diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index f18654de..fa38e207 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -445,18 +445,25 @@ def runtime_status_payload(workspace_id: str, instance: int = 1) -> dict: is_new = is_new_mode(folder) if os.path.isdir(folder) else False return { "running": False, + "ready": False, "port": None, + "serving_url": None, "has_backend_file": False, "backend_url": None, "frontend_port": None, "frontend_url": None, "is_new_mode": is_new, } + # The one address a client can actually open (ENG-190: `port` is the OPTIONAL API backend, which legitimately 404s at /, and readers kept treating it as the app). + serving_url = rt.frontend_url if rt.is_new_mode else (f"http://127.0.0.1:{rt.port}" if rt.ready and rt.port else None) return { "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, "port": rt.port, + "serving_url": serving_url, "has_backend_file": rt.has_backend_file, - # For old-mode: backend.py serves; backend_url is its port. For new-mode: backend.py is optional (gated by BACKEND_PORT!=NONE); only populated if the agent ran bash backend_init.sh. + # For old-mode: backend.py serves; backend_url is its port. For new-mode: backend.py is optional (gated by BACKEND_PORT!=NONE); only populated if the agent ran bash backend_init.sh. 404 at / is NORMAL here: it serves /api routes, not the app. "backend_url": f"http://127.0.0.1:{rt.port}" if rt.running and rt.port else None, # New-mode only: where the Vite dev server is reachable. Old-mode workspaces report null and the editor falls back to the legacy /api/outputs/workspace/{ws}/serve/... path. "frontend_port": rt.frontend_port, diff --git a/backend/apps/outputs/runtime.py b/backend/apps/outputs/runtime.py index c43bf432..201bb23e 100644 --- a/backend/apps/outputs/runtime.py +++ b/backend/apps/outputs/runtime.py @@ -169,6 +169,17 @@ class AppRuntime: def is_new_mode(self) -> bool: return is_new_mode(self.workspace_path) + @property + def ready(self) -> bool: + """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 not self.running or self.p_suspended: + return False + if self.is_new_mode: + return self.p_frontend_ready + return self.port is not None + @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. diff --git a/backend/tests/test_runtime_status_ready.py b/backend/tests/test_runtime_status_ready.py new file mode 100644 index 00000000..f0582f3b --- /dev/null +++ b/backend/tests/test_runtime_status_ready.py @@ -0,0 +1,60 @@ +"""ENG-190: the status payload distinguishes 'spawned' from 'serving' and names the one address a +client can open, instead of running=True with an unactionable port.""" + +from backend.apps.outputs.runtime import AppRuntime + + +def p_rt(tmp_path, new_mode: bool) -> AppRuntime: + ws = tmp_path / "ws" + ws.mkdir(exist_ok=True) + if new_mode: + (ws / "run.sh").write_text("#!/bin/bash\n") + rt = AppRuntime(workspace_id="ws-test", workspace_path=str(ws)) + return rt + + +def test_ready_false_while_spawned_but_not_serving(tmp_path, monkeypatch): + rt = p_rt(tmp_path, new_mode=True) + monkeypatch.setattr(type(rt), "running", property(lambda self: True)) + rt.frontend_port = 5173 + rt.p_frontend_ready = False + assert rt.ready is False + assert rt.frontend_url is None + + +def test_ready_true_once_port_answers_and_falls_with_process(tmp_path, monkeypatch): + rt = p_rt(tmp_path, new_mode=True) + monkeypatch.setattr(type(rt), "running", property(lambda self: True)) + rt.frontend_port = 5173 + rt.p_frontend_ready = True + assert rt.ready is True + assert rt.frontend_url == "http://127.0.0.1:5173/" + monkeypatch.setattr(type(rt), "running", property(lambda self: False)) + assert rt.ready is False + assert rt.frontend_url is None + + +def test_frozen_runtime_is_not_ready(tmp_path, monkeypatch): + rt = p_rt(tmp_path, new_mode=True) + monkeypatch.setattr(type(rt), "running", property(lambda self: True)) + rt.frontend_port = 5173 + rt.p_frontend_ready = True + rt.p_suspended = True + assert rt.ready is False + + +def test_status_payload_carries_ready_and_serving_url(tmp_path, monkeypatch): + from backend.apps.outputs import outputs as outputs_mod + from backend.apps.outputs.runtime import manager + + rt = p_rt(tmp_path, new_mode=True) + monkeypatch.setattr(type(rt), "running", property(lambda self: True)) + rt.frontend_port = 5173 + rt.p_frontend_ready = True + monkeypatch.setitem(manager.runtimes, "ws-test", rt) + payload = outputs_mod.runtime_status_payload("ws-test") + assert payload["ready"] is True + assert payload["serving_url"] == "http://127.0.0.1:5173/" + rt.p_frontend_ready = False + payload = outputs_mod.runtime_status_payload("ws-test") + assert payload["running"] is True and payload["ready"] is False and payload["serving_url"] is None