From 85d77ca5521f282b9be023c374c5163b522f07cd Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 2 Jul 2026 01:24:22 -0700 Subject: [PATCH] [eric] apps: multiple independent instances per app (own runtime + ports; alt-click or paste to open) --- backend/apps/dashboard_layout/models.py | 2 + backend/apps/dashboards/models.py | 2 + backend/apps/outputs/outputs.py | 40 +++--- backend/apps/outputs/runtime.py | 127 +++++++++++------- backend/apps/outputs/webapp_template/run.sh | 8 ++ backend/apps/swarm/entities/dashboards.py | 16 ++- backend/main.py | 7 +- backend/tests/test_runtime_multi_instance.py | 72 ++++++++++ .../src/app/pages/AgentChat/ChatInput.tsx | 3 +- .../app/pages/Dashboard/DashboardToolbar.tsx | 9 +- .../Dashboard/canvas/DashboardCanvas.tsx | 2 +- .../Dashboard/canvas/DashboardCardLayer.tsx | 10 +- .../Dashboard/canvas/DashboardOverlays.tsx | 2 +- .../Dashboard/cards/DashboardViewCard.tsx | 67 +++++---- .../Dashboard/controls/CardSearchPalette.tsx | 6 +- .../hooks/interaction/useArrowNav.ts | 4 +- .../interaction/useDashboardClipboard.ts | 12 +- .../lifecycle/useDashboardCardActions.ts | 16 ++- .../hooks/state/useDashboardSelection.ts | 5 +- frontend/src/shared/appTerminal.ts | 21 +-- .../src/shared/hooks/useRuntimePreviewUrl.ts | 12 +- .../src/shared/state/dashboardLayoutSlice.ts | 25 +++- scripts/fetch-webapp-template.sh | 26 ++++ 23 files changed, 345 insertions(+), 149 deletions(-) create mode 100644 backend/tests/test_runtime_multi_instance.py diff --git a/backend/apps/dashboard_layout/models.py b/backend/apps/dashboard_layout/models.py index 65193747..89bbbae8 100644 --- a/backend/apps/dashboard_layout/models.py +++ b/backend/apps/dashboard_layout/models.py @@ -11,6 +11,8 @@ class CardPosition(BaseModel): class ViewCardPosition(BaseModel): output_id: str + # Which instance of the app this card is (1 = primary). Persisted or Pydantic strips it on save and a reloaded second-instance card collapses onto the primary's runtime (same failure shape as the browser-card dashboard_id bleed). + instance: int = 1 x: float = 0 y: float = 0 width: float = 480 diff --git a/backend/apps/dashboards/models.py b/backend/apps/dashboards/models.py index 5a6e7fa7..2027648f 100644 --- a/backend/apps/dashboards/models.py +++ b/backend/apps/dashboards/models.py @@ -14,6 +14,8 @@ class CardPosition(BaseModel): class ViewCardPosition(BaseModel): output_id: str + # Which instance of the app this card is (1 = primary). Persisted or Pydantic strips it on save and a reloaded second-instance card collapses onto the primary's runtime (same failure shape as the browser-card dashboard_id bleed). + instance: int = 1 x: float = 0 y: float = 0 width: float = 480 diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index edc58f0b..2a7cd2a8 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -357,10 +357,10 @@ async def seed_workspace(body: WorkspaceSeedRequest): # --------------------------------------------------------------------------- Persistent app-backend runtime control. backend.py runs as a long-lived subprocess for the lifetime of the App being open; auto-allocated port, log streaming via WebSocket. See runtime.py for the manager. --------------------------------------------------------------------------- -def runtime_status_payload(workspace_id: str) -> dict: +def runtime_status_payload(workspace_id: str, instance: int = 1) -> dict: from backend.apps.outputs.runtime import manager as runtime_manager from backend.apps.outputs.runtime import is_new_mode - rt = runtime_manager.get(workspace_id) + rt = runtime_manager.get(workspace_id, instance) if not rt: # Even without a live runtime, the editor needs is_new_mode to decide whether the preview pane should fall back to the legacy /serve/index.html URL (old-mode flat workspaces) or show the "starting preview…" placeholder (new-mode webapp_template). Compute from disk so a failed runtime/start still gives the client the right hint instead of dumping it onto a 404. folder = os.path.join(WORKSPACE_DIR, workspace_id) @@ -388,44 +388,44 @@ def runtime_status_payload(workspace_id: str) -> dict: @outputs.router.post("/workspace/{workspace_id}/runtime/start") -async def runtime_start(workspace_id: str): +async def runtime_start(workspace_id: str, instance: int = 1): folder = os.path.join(WORKSPACE_DIR, workspace_id) if not os.path.isdir(folder): raise HTTPException(status_code=404, detail="Workspace not found") from backend.apps.outputs.runtime import manager as runtime_manager - await runtime_manager.attach(workspace_id, os.path.abspath(folder)) - return runtime_status_payload(workspace_id) + await runtime_manager.attach(workspace_id, os.path.abspath(folder), instance) + return runtime_status_payload(workspace_id, instance) @outputs.router.post("/workspace/{workspace_id}/runtime/stop") -async def runtime_stop(workspace_id: str): +async def runtime_stop(workspace_id: str, instance: int = 1): from backend.apps.outputs.runtime import manager as runtime_manager - await runtime_manager.detach(workspace_id) - return runtime_status_payload(workspace_id) + await runtime_manager.detach(workspace_id, instance) + return runtime_status_payload(workspace_id, instance) @outputs.router.post("/workspace/{workspace_id}/runtime/restart") -async def runtime_restart(workspace_id: str): +async def runtime_restart(workspace_id: str, instance: int = 1): folder = os.path.join(WORKSPACE_DIR, workspace_id) if not os.path.isdir(folder): raise HTTPException(status_code=404, detail="Workspace not found") from backend.apps.outputs.runtime import manager as runtime_manager # Restart only if something's attached; otherwise this is a no-op silently (a hard-reload click while the runtime was already torn down; we'd rather not silently respawn an orphan). - rt = runtime_manager.get(workspace_id) + rt = runtime_manager.get(workspace_id, instance) if rt: - await runtime_manager.restart(workspace_id, os.path.abspath(folder)) - return runtime_status_payload(workspace_id) + await runtime_manager.restart(workspace_id, os.path.abspath(folder), instance) + return runtime_status_payload(workspace_id, instance) @outputs.router.get("/workspace/{workspace_id}/runtime/status") -async def runtime_get_status(workspace_id: str): - return runtime_status_payload(workspace_id) +async def runtime_get_status(workspace_id: str, instance: int = 1): + return runtime_status_payload(workspace_id, instance) @outputs.router.post("/workspace/{workspace_id}/runtime/report-error") -async def runtime_report_error(workspace_id: str, body: dict): +async def runtime_report_error(workspace_id: str, body: dict, instance: int = 1): from backend.apps.outputs.runtime import manager as runtime_manager - rt = runtime_manager.get(workspace_id) + rt = runtime_manager.get(workspace_id, instance) if rt is None: return {"ok": False, "recorded": 0} message = (body.get("message") or "").strip() @@ -440,10 +440,10 @@ async def runtime_report_error(workspace_id: str, body: dict): @outputs.router.post("/workspace/{workspace_id}/runtime/console-log") -async def runtime_console_log(workspace_id: str, body: dict): +async def runtime_console_log(workspace_id: str, body: dict, instance: int = 1): """Fold webview console lines into the runtime's terminal stream so they reach the Terminal panes AND the agent-readable .openswarm/terminal.log. Renderer batches; body is {lines: [{level, text}, ...]}.""" from backend.apps.outputs.runtime import manager as runtime_manager - rt = runtime_manager.get(workspace_id) + rt = runtime_manager.get(workspace_id, instance) if rt is None: return {"ok": False, "recorded": 0} lines = body.get("lines") or [] @@ -458,9 +458,9 @@ async def runtime_console_log(workspace_id: str, body: dict): @outputs.router.post("/workspace/{workspace_id}/runtime/report-ready") -async def runtime_report_ready(workspace_id: str): +async def runtime_report_ready(workspace_id: str, instance: int = 1): from backend.apps.outputs.runtime import manager as runtime_manager - rt = runtime_manager.get(workspace_id) + rt = runtime_manager.get(workspace_id, instance) if rt is None: return {"ok": False} rt.set_render_ok() diff --git a/backend/apps/outputs/runtime.py b/backend/apps/outputs/runtime.py index 4e5850f1..bc067ade 100644 --- a/backend/apps/outputs/runtime.py +++ b/backend/apps/outputs/runtime.py @@ -73,6 +73,11 @@ TERMINAL_LOG_PREFIXES = { LogSubscriber = Callable[[LogLine], None] +def runtime_key(workspace_id: str, instance: int) -> str: + """Registry key for a workspace instance. Instance 1 keeps the bare workspace_id so every existing get()/attach() caller keeps addressing the primary.""" + return workspace_id if instance <= 1 else f"{workspace_id}#{instance}" + + class AppRuntime: """Manages one workspace's backend.py subprocess. @@ -85,9 +90,11 @@ class AppRuntime: - `log_buffer` is the replay source for new subscribers. """ - def __init__(self, workspace_id: str, workspace_path: str): + def __init__(self, workspace_id: str, workspace_path: str, instance: int = 1): self.workspace_id = workspace_id self.workspace_path = workspace_path + # Instance 1 is the primary (uses the .env-pinned ports); >1 are extra dashboard cards of the same app, fully independent processes on fresh ports that must never rewrite the shared .env. + self.instance = max(1, instance) # 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 @@ -97,8 +104,9 @@ class AppRuntime: self.p_suspended: bool = False self.process: Optional[asyncio.subprocess.Process] = None self.log_buffer: deque[LogLine] = deque(maxlen=LOG_BUFFER_LINES) - # On-disk tee of the ring buffer so the App Builder agent can inspect terminal output itself (Read/grep); reset on every start(). - self.p_terminal_log_path = os.path.join(workspace_path, ".openswarm", "terminal.log") + # On-disk tee of the ring buffer so the App Builder agent can inspect terminal output itself (Read/grep); reset on every start(). Secondary instances get a suffixed file so they don't clobber the primary's. + log_name = "terminal.log" if self.instance <= 1 else f"terminal-{self.instance}.log" + self.p_terminal_log_path = os.path.join(workspace_path, ".openswarm", log_name) self.p_terminal_log_bytes = 0 self.p_subscribers: set[LogSubscriber] = set() # Recent build/runtime errors scraped from stderr; drained by the agent's post-tool hook after Write/Edit so the agent sees vite/babel/uvicorn errors in its next turn and can self-fix instead of leaving the user with a red iframe overlay. @@ -196,39 +204,49 @@ class AppRuntime: env_path = os.path.join(self.workspace_path, ".env") fp_raw = read_env_value(env_path, "FRONTEND_PORT") bp_raw = read_env_value(env_path, "BACKEND_PORT") - # FRONTEND_PORT is allocated by seed_workspace; should always be a number. If missing, fall back to a fresh allocation (rare edge case: workspace seeded by an older OpenSwarm). - try: - self.frontend_port = int(fp_raw) if fp_raw else find_free_port() - except ValueError: + if self.instance > 1: + # Secondary instance: fresh ports always (the primary owns the .env-pinned ones), and NEVER rewrite the shared .env. .env is read only to learn whether the app has a backend at all. self.frontend_port = find_free_port() - # Port-collision safety net: if a ghost subprocess from a prior OpenSwarm run is still bound to the persisted port (force-quit, crash, OS killed the parent before stop_all could reap), Vite would EADDRINUSE silently. Re-probe and reallocate, then rewrite .env so the bash run.sh subprocess reads the new port. - if self.frontend_port and not is_port_free(self.frontend_port): - new_port = find_free_port() - self.p_broadcast(LogLine( - "runtime", - f"[runtime] persisted FRONTEND_PORT {self.frontend_port} is in use; reallocating to {new_port}", - )) - self.frontend_port = new_port - write_env_value(env_path, "FRONTEND_PORT", str(new_port)) - # BACKEND_PORT may be the literal string "NONE" (frontend-only app; the common case) or a number once `backend_init.sh` has run. Only populate self.port when there's a real backend. - if bp_raw and bp_raw != "NONE": + self.port = find_free_port() if (bp_raw and bp_raw != "NONE") else None + else: + # FRONTEND_PORT is allocated by seed_workspace; should always be a number. If missing, fall back to a fresh allocation (rare edge case: workspace seeded by an older OpenSwarm). try: - self.port = int(bp_raw) + self.frontend_port = int(fp_raw) if fp_raw else find_free_port() except ValueError: - self.port = None - # Same collision check for the backend port; a leaked uvicorn from a prior session would otherwise block the new spawn. - if self.port and not is_port_free(self.port): + self.frontend_port = find_free_port() + # Port-collision safety net: if a ghost subprocess from a prior OpenSwarm run is still bound to the persisted port (force-quit, crash, OS killed the parent before stop_all could reap), Vite would EADDRINUSE silently. Re-probe and reallocate, then rewrite .env so the bash run.sh subprocess reads the new port. + if self.frontend_port and not is_port_free(self.frontend_port): new_port = find_free_port() self.p_broadcast(LogLine( "runtime", - f"[runtime] persisted BACKEND_PORT {self.port} is in use; reallocating to {new_port}", + f"[runtime] persisted FRONTEND_PORT {self.frontend_port} is in use; reallocating to {new_port}", )) - self.port = new_port - write_env_value(env_path, "BACKEND_PORT", str(new_port)) - else: - self.port = None + self.frontend_port = new_port + write_env_value(env_path, "FRONTEND_PORT", str(new_port)) + # BACKEND_PORT may be the literal string "NONE" (frontend-only app; the common case) or a number once `backend_init.sh` has run. Only populate self.port when there's a real backend. + if bp_raw and bp_raw != "NONE": + try: + self.port = int(bp_raw) + except ValueError: + self.port = None + # Same collision check for the backend port; a leaked uvicorn from a prior session would otherwise block the new spawn. + if self.port and not is_port_free(self.port): + new_port = find_free_port() + self.p_broadcast(LogLine( + "runtime", + f"[runtime] persisted BACKEND_PORT {self.port} is in use; reallocating to {new_port}", + )) + self.port = new_port + write_env_value(env_path, "BACKEND_PORT", str(new_port)) + else: + self.port = None env = self.p_spawn_env_base() + if self.instance > 1: + # run.sh applies these AFTER sourcing .env, so the secondary boots on its own ports. Older workspaces (pre-override run.sh) ignore them; their secondary instance EADDRINUSEs visibly in the Terminal instead of silently sharing the primary. + env["OPENSWARM_FORCE_FRONTEND_PORT"] = str(self.frontend_port) + if self.port: + env["OPENSWARM_FORCE_BACKEND_PORT"] = str(self.port) # bash run.sh reads .env itself; we don't need to set FRONTEND_PORT / BACKEND_PORT here. We DO export the install paths as env vars (the more reliable read site for subshells). OPENSWARM_DEBUGGER_PATH is legacy-only: workspaces seeded before the PyPI swap have a run.sh that editable-installs the bundled debugger from it; new templates resolve `swarm-debug` from PyPI via pyproject. from backend.apps.outputs.view_builder_templates import ( DEBUGGER_PATH, @@ -539,19 +557,20 @@ class AppRuntimeManager: self.idle_lru: "OrderedDict[str, AppRuntime]" = OrderedDict() self.p_lock = asyncio.Lock() - async def attach(self, workspace_id: str, workspace_path: str) -> AppRuntime: + async def attach(self, workspace_id: str, workspace_path: str, instance: int = 1) -> AppRuntime: + key = runtime_key(workspace_id, instance) revived = False # Defined here so every code path below leaves it bound; the revive-idle branch used to skip the assignment, leaving the post-lock `if dead is not None:` check throwing UnboundLocalError. dead: Optional[AppRuntime] = None async with self.p_lock: - rt = self.runtimes.get(workspace_id) + rt = self.runtimes.get(key) if rt is None: # Maybe the runtime is sitting idle in the LRU; revive it without paying the spawn cost again. - idle_rt = self.idle_lru.pop(workspace_id, None) + idle_rt = self.idle_lru.pop(key, None) if idle_rt is not None and idle_rt.running: rt = idle_rt rt.workspace_path = workspace_path - self.runtimes[workspace_id] = rt + self.runtimes[key] = rt revived = True # SIGCONT the process tree if A2 had it paused while idle. Pair with the SIGSTOP in detach() below. resume_process_tree(rt.process) @@ -560,12 +579,12 @@ class AppRuntimeManager: if idle_rt is not None: # Stale idle entry; process died while idling. Drop and spawn a fresh one below; old one gets stopped outside the lock. dead = idle_rt - rt = AppRuntime(workspace_id, workspace_path) - self.runtimes[workspace_id] = rt + rt = AppRuntime(workspace_id, workspace_path, instance) + self.runtimes[key] = rt else: # 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[workspace_id] = self.p_attached.get(workspace_id, 0) + 1 + self.p_attached[key] = self.p_attached.get(key, 0) + 1 if not revived and not rt.running: await rt.start() # Stop any dead idle runtime outside the lock to avoid blocking. @@ -573,27 +592,28 @@ class AppRuntimeManager: try: await dead.stop() except Exception: - logger.exception("failed to reap dead idle runtime %s", workspace_id) + logger.exception("failed to reap dead idle runtime %s", key) return rt - async def detach(self, workspace_id: str) -> None: + async def detach(self, workspace_id: str, instance: int = 1) -> None: + key = runtime_key(workspace_id, instance) to_idle: Optional[AppRuntime] = None to_reap: list[AppRuntime] = [] async with self.p_lock: - count = self.p_attached.get(workspace_id, 0) - 1 + count = self.p_attached.get(key, 0) - 1 if count > 0: - self.p_attached[workspace_id] = count + self.p_attached[key] = count return - self.p_attached.pop(workspace_id, None) - rt = self.runtimes.pop(workspace_id, None) + self.p_attached.pop(key, None) + rt = self.runtimes.pop(key, None) if rt is None: return # 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) else: - self.idle_lru[workspace_id] = rt - self.idle_lru.move_to_end(workspace_id) + self.idle_lru[key] = rt + self.idle_lru.move_to_end(key) suspend_process_tree(rt.process) rt.p_suspended = True while len(self.idle_lru) > MAX_IDLE_RUNTIMES: @@ -608,16 +628,17 @@ class AppRuntimeManager: try: await old.stop() except Exception: - logger.exception("failed to reap idle runtime %s", workspace_id) + logger.exception("failed to reap idle runtime %s", key) if to_idle is not None: - logger.debug("workspace %s idled (LRU size now %d)", workspace_id, len(self.idle_lru)) + logger.debug("workspace %s idled (LRU size now %d)", key, len(self.idle_lru)) - def get(self, workspace_id: str) -> Optional[AppRuntime]: + def get(self, workspace_id: str, instance: int = 1) -> Optional[AppRuntime]: + key = runtime_key(workspace_id, instance) # Active subscribers see the live runtime; idle-pool members are also accessible so a status probe between detach and the next attach still works. - rt = self.runtimes.get(workspace_id) + rt = self.runtimes.get(key) if rt is not None: return rt - return self.idle_lru.get(workspace_id) + return self.idle_lru.get(key) def drain_errors_for_path(self, file_path: str) -> list[str]: """If `file_path` falls under one of the live workspace @@ -632,15 +653,16 @@ class AppRuntimeManager: abs_path = os.path.abspath(file_path) except Exception: return [] - # Walk both active and idle runtimes; the user might have navigated away from the workspace mid-build, but the agent could still be editing files; the LRU keeps the runtime alive for ~3 idle slots. + # Walk both active and idle runtimes; the user might have navigated away from the workspace mid-build, but the agent could still be editing files; the LRU keeps the runtime alive for ~3 idle slots. Aggregate across instances: with two cards of the same app open, either instance's build errors matter to the agent. + drained: list[str] = [] for rt in (*self.runtimes.values(), *self.idle_lru.values()): try: ws_root = os.path.abspath(rt.workspace_path) except Exception: continue if abs_path == ws_root or abs_path.startswith(ws_root + os.sep): - return rt.drain_errors() - return [] + drained.extend(rt.drain_errors()) + return drained def get_render_state_for_workspace(self, workspace_id: str) -> tuple[Optional[str], str]: rt = self.runtimes.get(workspace_id) or self.idle_lru.get(workspace_id) @@ -653,8 +675,9 @@ class AppRuntimeManager: if rt is not None: rt.reset_render_state() - async def restart(self, workspace_id: str, workspace_path: Optional[str] = None) -> Optional[AppRuntime]: - rt = self.runtimes.get(workspace_id) or self.idle_lru.get(workspace_id) + async def restart(self, workspace_id: str, workspace_path: Optional[str] = None, instance: int = 1) -> Optional[AppRuntime]: + key = runtime_key(workspace_id, instance) + rt = self.runtimes.get(key) or self.idle_lru.get(key) if rt is None: return None if workspace_path: diff --git a/backend/apps/outputs/webapp_template/run.sh b/backend/apps/outputs/webapp_template/run.sh index 40ed3fbd..35376b48 100755 --- a/backend/apps/outputs/webapp_template/run.sh +++ b/backend/apps/outputs/webapp_template/run.sh @@ -8,6 +8,14 @@ if [[ -f "$ROOT_DIR/.env" ]]; then set +a fi +# Per-instance port overrides: OpenSwarm passes these when the user opens a SECOND instance of the app, so it boots on fresh ports instead of colliding with the primary's .env-pinned ones. +if [[ -n "${OPENSWARM_FORCE_FRONTEND_PORT:-}" ]]; then + export FRONTEND_PORT="$OPENSWARM_FORCE_FRONTEND_PORT" +fi +if [[ -n "${OPENSWARM_FORCE_BACKEND_PORT:-}" ]]; then + export BACKEND_PORT="$OPENSWARM_FORCE_BACKEND_PORT" +fi + # Recursively SIGTERM a pid + all of its descendants. We track FRONTEND_PID # and BACKEND_PID below, but each of those is a `bash` wrapper that has its # own grandchildren (vite, uvicorn, npm). A flat `kill $FRONTEND_PID` leaves diff --git a/backend/apps/swarm/entities/dashboards.py b/backend/apps/swarm/entities/dashboards.py index 357970cc..5b9c9642 100644 --- a/backend/apps/swarm/entities/dashboards.py +++ b/backend/apps/swarm/entities/dashboards.py @@ -36,12 +36,15 @@ class DashboardExportable: if bid: cards[bid] = {**card, "session_id": bid} view_cards = {} - for oid, card in (layout.get("view_cards") or {}).items(): + for key, card in (layout.get("view_cards") or {}).items(): + # Keys are output_id for the primary card, `output_id#N` for extra instances of the same app; resolve the bundle id off the bare output id and rebuild the suffix. + oid = str(card.get("output_id") or key).split("#")[0] bid = ctx.bundle_id_for(EntityType.app, oid) if bid: + inst = int(card.get("instance") or 1) # parent_session_id tethers the app card to the agent that built it; it's a session id, so it remaps like spawned_by on browser cards. parent = card.get("parent_session_id") - view_cards[bid] = { + view_cards[bid if inst <= 1 else f"{bid}#{inst}"] = { **card, "output_id": bid, "parent_session_id": ctx.bundle_id_for(EntityType.session, parent) if parent else None, } @@ -64,7 +67,8 @@ class DashboardExportable: def dependencies(self) -> list[DepRef]: layout = self.p_data.get("layout") or {} deps = [DepRef(EntityType.session, sid, "has_agent") for sid in (layout.get("cards") or {})] - deps += [DepRef(EntityType.app, oid, "has_app") for oid in (layout.get("view_cards") or {})] + p_view_oids = {str(card.get("output_id") or key).split("#")[0] for key, card in (layout.get("view_cards") or {}).items()} + deps += [DepRef(EntityType.app, oid, "has_app") for oid in sorted(p_view_oids)] return deps def requirements(self) -> list[Requirement]: @@ -80,11 +84,13 @@ class DashboardExportable: if nsid: cards[nsid] = {**card, "session_id": nsid} view_cards = {} - for bid, card in (layout.get("view_cards") or {}).items(): + for key, card in (layout.get("view_cards") or {}).items(): + bid = str(card.get("output_id") or key).split("#")[0] noid = remap.local(bid) if noid: + inst = int(card.get("instance") or 1) parent = card.get("parent_session_id") - view_cards[noid] = { + view_cards[noid if inst <= 1 else f"{noid}#{inst}"] = { **card, "output_id": noid, "parent_session_id": remap.local(parent) if parent else None, } diff --git a/backend/main.py b/backend/main.py index 7905f39d..ee37df95 100644 --- a/backend/main.py +++ b/backend/main.py @@ -253,16 +253,17 @@ def p_ws_auth_ok(websocket: WebSocket) -> bool: @app.websocket("/ws/outputs/runtime/{workspace_id}/logs") -async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str): +async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str, instance: int = 1): """Stream the persistent app-backend's stdout/stderr to the Terminal pane. On connect we replay the runtime's ring buffer so a Terminal tab opened mid-session sees the context it missed, then we tail - every subsequent line until disconnect.""" + every subsequent line until disconnect. `instance` targets a specific + dashboard card's runtime when the same app is open more than once.""" if not p_ws_auth_ok(websocket): return await websocket.accept() from backend.apps.outputs.runtime import manager as runtime_manager - rt = runtime_manager.get(workspace_id) + rt = runtime_manager.get(workspace_id, instance) if rt is None: # No active runtime, surface that to the client and close. The frontend will call /runtime/start and reconnect. Also emit a status frame with is_new_mode (computed from disk) so the preview pane shows the "starting preview…" placeholder for webapp_template workspaces instead of falling back to the legacy /serve/index.html URL (which 404s in new-mode). try: diff --git a/backend/tests/test_runtime_multi_instance.py b/backend/tests/test_runtime_multi_instance.py new file mode 100644 index 00000000..72498962 --- /dev/null +++ b/backend/tests/test_runtime_multi_instance.py @@ -0,0 +1,72 @@ +"""Multi-instance app runtimes: two dashboard cards of the same app must get fully +independent AppRuntime processes on independent ports. Pins the invariants the +frontend's per-instance attach relies on: instance-suffixed registry keys, no shared +refcount between instances, fresh (non-.env) ports + OPENSWARM_FORCE_* env for +secondaries, and per-instance terminal.log files.""" + +import os + +import pytest + +from backend.apps.outputs.runtime import AppRuntime, AppRuntimeManager, runtime_key + + +def test_runtime_key_primary_is_bare_workspace_id(): + assert runtime_key("ws1", 1) == "ws1" + assert runtime_key("ws1", 2) == "ws1#2" + + +@pytest.mark.asyncio +async def test_attach_two_instances_creates_independent_runtimes(tmp_path, monkeypatch): + # Neither instance should actually spawn a process; pin start() to a no-op. + async def p_fake_start(self): + return True + monkeypatch.setattr(AppRuntime, "start", p_fake_start) + mgr = AppRuntimeManager() + ws = str(tmp_path) + rt1 = await mgr.attach("ws1", ws, 1) + rt2 = await mgr.attach("ws1", ws, 2) + assert rt1 is not rt2 + assert rt1.instance == 1 and rt2.instance == 2 + assert mgr.get("ws1", 1) is rt1 + assert mgr.get("ws1", 2) is rt2 + # Detaching one instance must not tear down or refcount-touch the other. (The stub runtime has no live process, so detach reaps it rather than idling it.) + await mgr.detach("ws1", 2) + assert "ws1" in mgr.runtimes + assert mgr.get("ws1", 2) is None + assert mgr.get("ws1", 1) is rt1 + + +@pytest.mark.asyncio +async def test_secondary_instance_uses_fresh_ports_and_force_env(tmp_path, monkeypatch): + # A new-mode workspace with pinned .env ports; the secondary must NOT reuse or rewrite them. + ws = tmp_path / "ws" + ws.mkdir() + (ws / "run.sh").write_text("#!/bin/bash\n") + (ws / ".env").write_text("FRONTEND_PORT=45001\nBACKEND_PORT=45002\n") + captured_env: dict = {} + + async def p_fake_exec(*cmd, **kwargs): + captured_env.update(kwargs.get("env") or {}) + raise RuntimeError("stop before real spawn") + monkeypatch.setattr("asyncio.create_subprocess_exec", p_fake_exec) + rt = AppRuntime("ws1", str(ws), instance=2) + ok = await rt.start() + # The stubbed spawn raises, so start() reports failure and nulls the ports; the forced env captured at spawn time carries what the secondary would have used. + assert ok is False + forced_fp = int(captured_env["OPENSWARM_FORCE_FRONTEND_PORT"]) + forced_bp = int(captured_env["OPENSWARM_FORCE_BACKEND_PORT"]) + assert forced_fp != 45001 + assert forced_bp != 45002 + # .env untouched: the primary still owns its pinned ports. + assert (ws / ".env").read_text() == "FRONTEND_PORT=45001\nBACKEND_PORT=45002\n" + + +def test_secondary_terminal_log_is_suffixed(tmp_path): + os.makedirs(os.path.join(str(tmp_path), ".openswarm")) + rt1 = AppRuntime("ws1", str(tmp_path), instance=1) + rt2 = AppRuntime("ws1", str(tmp_path), instance=2) + rt1.record_frontend_log("log", "hello from instance 1") + rt2.record_frontend_log("log", "hello from instance 2") + files = sorted(os.listdir(os.path.join(str(tmp_path), ".openswarm"))) + assert files == ["terminal-2.log", "terminal.log"] diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index de9d1833..80f41b12 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -258,7 +258,8 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, .map((el) => el.semanticData!.selectId as string); const appIds = selectedEls .filter((el) => el.semanticType === 'view-card' && el.semanticData?.selectId) - .map((el) => el.semanticData!.selectId as string); + // Strip a multi-instance card-key suffix (`output_id#N`): the backend AppAgent gate wants bare output ids. + .map((el) => (el.semanticData!.selectId as string).split('#')[0]); const settingIds = selectedEls .filter((el) => el.semanticType === 'settings-option' && el.semanticData?.selectId) .map((el) => el.semanticData!.selectId as string); diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index 2fb674e8..1b385459 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -55,7 +55,7 @@ interface Props { selectedBrowserIds?: string[], selectedAppIds?: string[], ) => void; - onAddView: (outputId: string) => void; + onAddView: (outputId: string, opts?: { newInstance?: boolean }) => void; onHistoryResume: (sessionId: string) => void; onAddBrowser: () => void; onAddNote: () => void; @@ -240,8 +240,9 @@ const DashboardToolbar = React.forwardRef( } }, [historyOpen, viewPickerOpen, onCancel, handleCloseHistory]); - const handleSelectView = useCallback((output: Output) => { - onAddView(output.id); + // Alt/Option-click opens ANOTHER independent instance of an already-open app (its own runtime + ports); plain click focuses the existing card. + const handleSelectView = useCallback((output: Output, newInstance?: boolean) => { + onAddView(output.id, { newInstance }); setViewPickerOpen(false); setViewSearch(''); }, [onAddView]); @@ -533,7 +534,7 @@ const DashboardToolbar = React.forwardRef( filteredOutputs.map((output) => ( handleSelectView(output)} + onClick={(e) => handleSelectView(output, e.altKey)} sx={{ display: 'flex', alignItems: 'center', diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index b6f5928c..2bd9e73e 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -81,7 +81,7 @@ interface DashboardCanvasProps { onStarter: (prompt: string, mode?: string) => void; toolbarPrefill?: string; toolbarPrefillMode?: string; - onAddView: (outputId: string) => void; + onAddView: (outputId: string, opts?: { newInstance?: boolean }) => void; onHistoryResume: (sessionId: string) => void; onAddBrowser: () => void; onAddNote: () => void; diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx index 80b15e62..5063b631 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCardLayer.tsx @@ -191,12 +191,14 @@ const DashboardCardLayer: React.FC = ({ ); })} - {Object.values(viewCards).map((vc) => { + {Object.entries(viewCards).map(([cardKey, vc]) => { const output = outputs[vc.output_id]; if (!output) return null; return ( = ({ panX={panX} panY={panY} cmdHeld={cmdHeld} - isSelected={selection.isSelected(vc.output_id)} - isHighlighted={highlightedCardId === vc.output_id} + isSelected={selection.isSelected(cardKey)} + isHighlighted={highlightedCardId === cardKey} multiDragDelta={multiDragDelta} onCardSelect={onCardSelect} onDragStart={onDragStart} diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx index 963d23a7..64473f54 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardOverlays.tsx @@ -39,7 +39,7 @@ interface DashboardOverlaysProps { onNewAgent: () => void; onToolbarCancel: () => void; onToolbarSend: (...args: any[]) => void; - onAddView: (outputId: string) => void; + onAddView: (outputId: string, opts?: { newInstance?: boolean }) => void; onHistoryResume: (sessionId: string) => void; onAddBrowser: () => void; onAddNote: () => void; diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index df541770..39c6f7d3 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -59,6 +59,10 @@ const HANDLE_DEFS: { dir: ResizeDir; sx: Record }[] = [ interface Props { output: Output; + // Record key in dashboardLayout.viewCards (output.id for the primary, `${output.id}#N` for extras); every layout/selection dispatch keys by this. + cardKey?: string; + // Which independent instance of the app this card runs; each instance gets its own runtime + ports. + instance?: number; cardX: number; cardY: number; cardWidth: number; @@ -115,16 +119,17 @@ const BootingBody: React.FC = () => { }; const DashboardViewCard: React.FC = ({ - output, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false, + output, cardKey: cardKeyProp, instance = 1, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false, isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd, cardZOrder = 0, onDoubleClick, onBringToFront, }) => { + const cardKey = cardKeyProp ?? output.id; const c = useClaudeTokens(); const dispatch = useAppDispatch(); const scrollOverlayRef = useOverlayScrollPassthrough(isSelected); const previewRef = useRef(null); const activeViewCardId = useAppSelector((s) => s.dashboardLayout.activeViewCardId); - const interactive = activeViewCardId === output.id; + const interactive = activeViewCardId === cardKey; // Deselecting the card exits interact mode (click anywhere else on canvas). useEffect(() => { @@ -203,8 +208,8 @@ const DashboardViewCard: React.FC = ({ didDrag.current = false; setIsDragging(true); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); - onDragStart?.(output.id, 'view'); - }, [cardX, cardY, onDragStart, output.id]); + onDragStart?.(cardKey, 'view'); + }, [cardX, cardY, onDragStart, cardKey]); const recomputeDragPos = useCallback(() => { const ds = dragState.current; @@ -251,7 +256,7 @@ const DashboardViewCard: React.FC = ({ finalY = Math.round(finalY / 24) * 24; } dispatch(setViewCardPosition({ - outputId: output.id, + outputId: cardKey, x: finalX, y: finalY, })); @@ -264,7 +269,7 @@ const DashboardViewCard: React.FC = ({ setLocalDragPos(null); setIsDragging(false); (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); - }, [dispatch, output.id, onDragEnd]); + }, [dispatch, cardKey, onDragEnd]); const resizeRef = useRef<{ dir: ResizeDir; startX: number; startY: number; @@ -318,19 +323,19 @@ const DashboardViewCard: React.FC = ({ if (!resizeRef.current) return; const result = computeResize(e); if (result) { - dispatch(setViewCardPosition({ outputId: output.id, x: result.x, y: result.y })); - dispatch(setViewCardSize({ outputId: output.id, width: result.w, height: result.h })); + dispatch(setViewCardPosition({ outputId: cardKey, x: result.x, y: result.y })); + dispatch(setViewCardSize({ outputId: cardKey, width: result.w, height: result.h })); } resizeRef.current = null; setLocalResize(null); setIsResizing(false); (e.target as HTMLElement).releasePointerCapture(e.pointerId); - }, [computeResize, dispatch, output.id]); + }, [computeResize, dispatch, cardKey]); const handleRemove = (e: React.MouseEvent) => { e.stopPropagation(); - dispatch(recordClosedCard({ kind: 'view', id: output.id })); - void removeViewCardCleanly(output.id, dispatch); + dispatch(recordClosedCard({ kind: 'view', id: cardKey })); + void removeViewCardCleanly(cardKey, dispatch); }; const [reloadMenuRect, setReloadMenuRect] = useState(null); @@ -343,14 +348,14 @@ const DashboardViewCard: React.FC = ({ const tok = getAuthToken(); const headers: Record = { 'Content-Type': 'application/json' }; if (tok) headers.Authorization = `Bearer ${tok}`; - await fetch(`${API_BASE}/outputs/workspace/${wsId}/runtime/restart`, { + await fetch(`${API_BASE}/outputs/workspace/${wsId}/runtime/restart?instance=${instance}`, { method: 'POST', headers, }); } catch { /* failures surface via the runtime log WS */ } } previewRef.current?.reload(); - }, [output.workspace_id]); + }, [output.workspace_id, instance]); // In Terminal view a soft webview reload is invisible (the terminal is what you're looking at), so the refresh button always hard-reloads there. const handleRefresh = (e: React.MouseEvent) => { @@ -373,16 +378,16 @@ const DashboardViewCard: React.FC = ({ return ( onBringToFront?.(output.id, 'view')} + onPointerDownCapture={() => onBringToFront?.(cardKey, 'view')} onClick={(e: React.MouseEvent) => { if (justDraggedRef.current) return; - onCardSelect?.(output.id, 'view', e.shiftKey); + onCardSelect?.(cardKey, 'view', e.shiftKey); }} onDoubleClick={(e: React.MouseEvent) => { e.stopPropagation(); - onDoubleClick?.(output.id, 'view'); + onDoubleClick?.(cardKey, 'view'); }} sx={{ position: 'absolute', @@ -476,6 +481,11 @@ const DashboardViewCard: React.FC = ({ > {output.name} + {instance > 1 && ( + + #{instance} + + )} {hasWorkspace && ( = ({ dispatch(setActiveViewCardId(output.id))} + onAppClicked={() => dispatch(setActiveViewCardId(cardKey))} onRuntimeLog={handleRuntimeLog} /> {/* Code/Terminal overlay the always-mounted preview instead of replacing it: unmounting the webview kills the app's live state and forces a reload on switch-back. */} @@ -689,12 +701,14 @@ const BuildingOverlay: React.FC<{ show: boolean }> = ({ show }) => { const DashboardOutputPreview: React.FC<{ previewRef: React.Ref; output: Output; + cardKey?: string; + instance?: number; inputData: Record; backendResult: any; interactive: boolean; onAppClicked: () => void; onRuntimeLog?: (line: RuntimeLogLine) => void; -}> = ({ previewRef, output, inputData, backendResult, interactive, onAppClicked, onRuntimeLog }) => { +}> = ({ previewRef, output, cardKey, instance = 1, inputData, backendResult, interactive, onAppClicked, onRuntimeLog }) => { const tokens = useClaudeTokens(); const dispatch = useAppDispatch(); const workspaceId = output.workspace_id ?? null; @@ -702,6 +716,7 @@ const DashboardOutputPreview: React.FC<{ workspaceId, enabled: !!workspaceId, onLog: onRuntimeLog, + instance, }); const { url, isBooting } = pickPreviewUrl({ workspaceId, @@ -717,25 +732,25 @@ const DashboardOutputPreview: React.FC<{ const headers: Record = { 'Content-Type': 'application/json' }; if (tok) headers.Authorization = `Bearer ${tok}`; if (text.includes('[openswarm:app-ready]')) { - fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-ready`, { + fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-ready?instance=${instance}`, { method: 'POST', headers, }).catch(() => {}); return; } // Fold console output into the runtime terminal stream (card Terminal view + agent-readable terminal.log). - postAppConsoleLine(workspaceId, level, text); + postAppConsoleLine(workspaceId, level, text, instance); if (level !== 'error' || !text.includes('[openswarm:app-error]')) return; const idx = text.indexOf('[openswarm:app-error]'); const tail = text.slice(idx + '[openswarm:app-error]'.length).trim(); const firstNewline = tail.indexOf('\n'); const message = firstNewline >= 0 ? tail.slice(0, firstNewline).trim() : tail; const componentStack = firstNewline >= 0 ? tail.slice(firstNewline + 1).trim() : ''; - fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-error`, { + fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/report-error?instance=${instance}`, { method: 'POST', headers, body: JSON.stringify({ message, componentStack }), }).catch(() => {}); - }, [workspaceId]); + }, [workspaceId, instance]); // An orphaned record (files deleted on disk) used to render the raw 404 JSON inside the card, or spin on "Starting preview" forever; probe once instead. const [filesMissing, setFilesMissing] = useState(false); @@ -773,7 +788,7 @@ const DashboardOutputPreview: React.FC<{ This app's files are missing. void removeViewCardCleanly(output.id, dispatch)} + onClick={() => void removeViewCardCleanly(cardKey ?? output.id, dispatch)} sx={{ color: tokens.accent.primary, fontSize: '0.85rem', @@ -800,7 +815,7 @@ const DashboardOutputPreview: React.FC<{ return ( 1 ? `app:${output.id}#${instance}` : `app:${output.id}`} /> ); }; diff --git a/frontend/src/app/pages/Dashboard/controls/CardSearchPalette.tsx b/frontend/src/app/pages/Dashboard/controls/CardSearchPalette.tsx index 331a0ea9..b961f40c 100644 --- a/frontend/src/app/pages/Dashboard/controls/CardSearchPalette.tsx +++ b/frontend/src/app/pages/Dashboard/controls/CardSearchPalette.tsx @@ -44,10 +44,10 @@ const CardSearchPalette: React.FC = ({ rect: { x: card.x, y: card.y, width: card.width, height: card.height }, }); } - for (const vc of Object.values(viewCards)) { + for (const [key, vc] of Object.entries(viewCards)) { result.push({ - id: vc.output_id, - label: `View: ${vc.output_id.slice(0, 12)}`, + id: key, + label: `View: ${vc.output_id.slice(0, 12)}${(vc.instance ?? 1) > 1 ? ` (#${vc.instance})` : ''}`, type: 'view', rect: { x: vc.x, y: vc.y, width: vc.width, height: vc.height }, }); diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts index 44176e24..0b14aa95 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type import { report } from '@/shared/serviceClient'; import { useAppDispatch } from '@/shared/hooks'; import { expandSession } from '@/shared/state/agentsSlice'; -import { bringToFront } from '@/shared/state/dashboardLayoutSlice'; +import { bringToFront, viewCardKey } from '@/shared/state/dashboardLayoutSlice'; import type { CardPosition, ViewCardPosition, BrowserCardPosition, WorkflowCardPosition } from '@/shared/state/dashboardLayoutSlice'; import type { CardType } from '../state/useDashboardSelection'; import type { CanvasActions } from './useCanvasControls'; @@ -45,7 +45,7 @@ export function useArrowNav({ allCardEntries.push({ id: card.session_id, type: 'agent', cx: card.x + card.width / 2, cy: card.y + card.height / 2 }); } for (const vc of Object.values(viewCards)) { - allCardEntries.push({ id: vc.output_id, type: 'view', cx: vc.x + vc.width / 2, cy: vc.y + vc.height / 2 }); + allCardEntries.push({ id: viewCardKey(vc.output_id, vc.instance), type: 'view', cx: vc.x + vc.width / 2, cy: vc.y + vc.height / 2 }); } for (const bc of Object.values(browserCards)) { allCardEntries.push({ id: bc.browser_id, type: 'browser', cx: bc.x + bc.width / 2, cy: bc.y + bc.height / 2 }); diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardClipboard.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardClipboard.ts index b0ede6a1..6ab634b4 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardClipboard.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardClipboard.ts @@ -14,6 +14,7 @@ import { type BrowserCardPosition, } from '@/shared/state/dashboardLayoutSlice'; import type { Output } from '@/shared/state/outputsSlice'; +import { store } from '@/shared/state/store'; import { setClipboardCards, getClipboardCards, type ClipboardCard } from '@/shared/dashboardClipboard'; import type { CardType, useDashboardSelection } from '../state/useDashboardSelection'; @@ -134,8 +135,15 @@ export function useDashboardClipboard({ newSelection.set(newId, 'agent'); } } else if (card.type === 'view') { - dispatch(addViewCard({ outputId: card.id, expandedSessionIds, x: px, y: py, width: card.width, height: card.height })); - newSelection.set(card.id, 'view'); + // Pasting an app whose card is already open creates a NEW independent instance (own runtime + ports) instead of no-op'ing. + const outputId = card.id.split('#')[0]; + dispatch(addViewCard({ outputId, expandedSessionIds, x: px, y: py, width: card.width, height: card.height, newInstance: true })); + const viewCards = store.getState().dashboardLayout.viewCards; + let pastedKey = outputId; + for (const [key, vc] of Object.entries(viewCards)) { + if (vc.output_id === outputId && (vc.instance ?? 1) >= (viewCards[pastedKey]?.instance ?? 1)) pastedKey = key; + } + newSelection.set(pastedKey, 'view'); } else if (card.type === 'browser') { const browserId = `browser-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; dispatch(pasteBrowserCard({ diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts index c6f96a97..7b095152 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts @@ -39,13 +39,21 @@ export function useDashboardCardActions({ }: UseDashboardCardActionsArgs) { const dispatch = useAppDispatch(); - const handleAddView = useCallback((outputId: string) => { - dispatch(addViewCard({ outputId, expandedSessionIds })); + const handleAddView = useCallback((outputId: string, opts?: { newInstance?: boolean }) => { + dispatch(addViewCard({ outputId, expandedSessionIds, newInstance: opts?.newInstance })); setTimeout(() => { - const card = store.getState().dashboardLayout.viewCards[outputId]; + // Focus whichever card the dispatch produced: with newInstance that's the highest-numbered instance of this output, else the primary. + const viewCards = store.getState().dashboardLayout.viewCards; + let focusKey = outputId; + if (opts?.newInstance) { + for (const [key, vc] of Object.entries(viewCards)) { + if (vc.output_id === outputId && (vc.instance ?? 1) >= (viewCards[focusKey]?.instance ?? 1)) focusKey = key; + } + } + const card = viewCards[focusKey]; if (card) { canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true); - handleHighlightCard(outputId); + handleHighlightCard(focusKey); } }, 200); }, [dispatch, expandedSessionIds, canvasActions, handleHighlightCard]); diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts index 1b59427a..64b38841 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelection.ts @@ -1,5 +1,6 @@ import { useState, useCallback, useRef, useEffect, RefObject } from 'react'; import type { CardPosition, ViewCardPosition, BrowserCardPosition, NotePosition, WorkflowCardPosition, WorkflowsHubPosition } from '@/shared/state/dashboardLayoutSlice'; +import { viewCardKey } from '@/shared/state/dashboardLayoutSlice'; export type { CardType } from '@/shared/state/dashboardLayoutSlice'; import type { CardType } from '@/shared/state/dashboardLayoutSlice'; @@ -75,7 +76,7 @@ export function useDashboardSelection( const selectAll = useCallback(() => { const next = new Map(); for (const card of Object.values(cards)) next.set(card.session_id, 'agent'); - for (const vc of Object.values(viewCards)) next.set(vc.output_id, 'view'); + for (const vc of Object.values(viewCards)) next.set(viewCardKey(vc.output_id, vc.instance), 'view'); for (const bc of Object.values(browserCards)) next.set(bc.browser_id, 'browser'); for (const n of Object.values(notes)) next.set(n.note_id, 'note'); for (const wc of Object.values(workflowCards)) next.set(wc.workflow_id, 'workflow'); @@ -134,7 +135,7 @@ export function useDashboardSelection( height: vc.height, }) ) { - intersecting.set(vc.output_id, 'view'); + intersecting.set(viewCardKey(vc.output_id, vc.instance), 'view'); } } diff --git a/frontend/src/shared/appTerminal.ts b/frontend/src/shared/appTerminal.ts index d01d3a72..03f7f5f7 100644 --- a/frontend/src/shared/appTerminal.ts +++ b/frontend/src/shared/appTerminal.ts @@ -16,12 +16,14 @@ const MAX_LINES_PER_FLUSH = 50; interface PendingConsoleLine { level: string; text: string } +// Queues keyed by `${workspaceId}:${instance}` so two cards of the same app don't cross their console streams. const pendingByWorkspace = new Map(); const flushTimers = new Map(); -function flushConsoleLines(workspaceId: string): void { - flushTimers.delete(workspaceId); - const queue = pendingByWorkspace.get(workspaceId); +function flushConsoleLines(workspaceId: string, instance: number): void { + const qKey = `${workspaceId}:${instance}`; + flushTimers.delete(qKey); + const queue = pendingByWorkspace.get(qKey); if (!queue || queue.length === 0) return; const batch = queue.splice(0, MAX_LINES_PER_FLUSH); if (queue.length > 0) { @@ -31,23 +33,24 @@ function flushConsoleLines(workspaceId: string): void { const tok = getAuthToken(); const headers: Record = { 'Content-Type': 'application/json' }; if (tok) headers.Authorization = `Bearer ${tok}`; - fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/console-log`, { + fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/console-log?instance=${instance}`, { method: 'POST', headers, body: JSON.stringify({ lines: batch }), }).catch(() => {}); } -export function postAppConsoleLine(workspaceId: string, level: string, text: string): void { +export function postAppConsoleLine(workspaceId: string, level: string, text: string, instance: number = 1): void { if (!workspaceId || !text) return; - let queue = pendingByWorkspace.get(workspaceId); + const qKey = `${workspaceId}:${instance}`; + let queue = pendingByWorkspace.get(qKey); if (!queue) { queue = []; - pendingByWorkspace.set(workspaceId, queue); + pendingByWorkspace.set(qKey, queue); } queue.push({ level, text }); - if (!flushTimers.has(workspaceId)) { - flushTimers.set(workspaceId, window.setTimeout(() => flushConsoleLines(workspaceId), FLUSH_MS)); + if (!flushTimers.has(qKey)) { + flushTimers.set(qKey, window.setTimeout(() => flushConsoleLines(workspaceId, instance), FLUSH_MS)); } } diff --git a/frontend/src/shared/hooks/useRuntimePreviewUrl.ts b/frontend/src/shared/hooks/useRuntimePreviewUrl.ts index 5c076813..c86543b1 100644 --- a/frontend/src/shared/hooks/useRuntimePreviewUrl.ts +++ b/frontend/src/shared/hooks/useRuntimePreviewUrl.ts @@ -21,10 +21,12 @@ export interface RuntimePreviewOptions { /** Gate the spawn so callers can defer paying runtime cost until preview is wanted. */ enabled?: boolean; onLog?: (line: RuntimeLogLine) => void; + /** Which independent instance of the app to attach (1 = primary). Each instance is its own process on its own ports. */ + instance?: number; } export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePreviewState { - const { workspaceId, enabled = true, onLog } = opts; + const { workspaceId, enabled = true, onLog, instance = 1 } = opts; const [frontendUrl, setFrontendUrl] = useState(null); const [isNewMode, setIsNewMode] = useState(false); const [isHydrating, setIsHydrating] = useState(true); @@ -53,7 +55,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie (async () => { try { - await fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/start`, { + await fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/start?instance=${instance}`, { method: 'POST', headers, }); @@ -63,7 +65,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie if (cancelled) return; try { const wsBase = API_BASE.replace(/^http/, 'ws').replace(/\/api$/, ''); - const url = `${wsBase}/ws/outputs/runtime/${workspaceId}/logs?token=${encodeURIComponent(auth || '')}`; + const url = `${wsBase}/ws/outputs/runtime/${workspaceId}/logs?token=${encodeURIComponent(auth || '')}&instance=${instance}`; ws = new WebSocket(url); ws.onmessage = (ev) => { try { @@ -96,12 +98,12 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie setIsNewMode(false); setIsHydrating(true); // detach is ref-counted on the backend; fire-and-forget. - fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/stop`, { + fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/stop?instance=${instance}`, { method: 'POST', headers, }).catch(() => {}); }; - }, [workspaceId, enabled]); + }, [workspaceId, enabled, instance]); return { frontendUrl, isNewMode, isHydrating }; } diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 3c787733..44aaded6 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -44,6 +44,8 @@ export interface CardPosition { export interface ViewCardPosition { output_id: string; + // Which instance of the app this card is (1 = primary, absent on pre-instance layouts). Each instance is a fully independent runtime on its own ports. + instance?: number; x: number; y: number; width: number; @@ -52,6 +54,11 @@ export interface ViewCardPosition { parent_session_id?: string | null; } +// Record key + card identity for a view card. The primary keeps the bare output_id so persisted layouts and every existing by-output lookup stay valid; secondaries append #N. +export function viewCardKey(outputId: string, instance?: number): string { + return (instance ?? 1) > 1 ? `${outputId}#${instance}` : outputId; +} + export interface BrowserTab { id: string; url: string; @@ -632,7 +639,7 @@ const dashboardLayoutSlice = createSlice({ const allItems = [ ...agentCards.map((c) => ({ kind: 'agent' as const, id: c.session_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })), - ...viewCards.map((c) => ({ kind: 'view' as const, id: c.output_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })), + ...viewCards.map((c) => ({ kind: 'view' as const, id: viewCardKey(c.output_id, c.instance), x: c.x, y: c.y, storedW: c.width, storedH: c.height })), ...bCards.map((c) => ({ kind: 'browser' as const, id: c.browser_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })), ...wCards.map((c) => ({ kind: 'workflow' as const, id: c.workflow_id, x: c.x, y: c.y, storedW: c.width, storedH: c.height })), ...(hub ? [{ kind: 'workflows-hub' as const, id: 'workflows-hub', x: hub.x, y: hub.y, storedW: hub.width, storedH: hub.height }] : []), @@ -679,9 +686,16 @@ const dashboardLayoutSlice = createSlice({ outputId: string; expandedSessionIds?: string[]; parentSessionId?: string | null; x?: number; y?: number; width?: number; height?: number; + // Open ANOTHER independent instance of an already-open app instead of no-op'ing. + newInstance?: boolean; }>) { - const { outputId, expandedSessionIds, parentSessionId, x, y, width, height } = action.payload; - if (state.viewCards[outputId]) return; + const { outputId, expandedSessionIds, parentSessionId, x, y, width, height, newInstance } = action.payload; + let instance = 1; + if (state.viewCards[outputId]) { + if (!newInstance) return; + instance = 2; + while (state.viewCards[viewCardKey(outputId, instance)]) instance++; + } const w = width || DEFAULT_VIEW_CARD_W; const h = height || DEFAULT_VIEW_CARD_H; let posX: number, posY: number; @@ -701,8 +715,9 @@ const dashboardLayoutSlice = createSlice({ posY = pos.y; } } - state.viewCards[outputId] = { + state.viewCards[viewCardKey(outputId, instance)] = { output_id: outputId, + instance, x: posX, y: posY, width: w, @@ -1352,7 +1367,7 @@ const dashboardLayoutSlice = createSlice({ if (entry.kind === 'browser') { state.browserCards[entry.card.browser_id] = { ...entry.card, zOrder, dashboard_id: dashboardId ?? entry.card.dashboard_id }; } else if (entry.kind === 'view') { - state.viewCards[entry.card.output_id] = { ...entry.card, zOrder }; + state.viewCards[viewCardKey(entry.card.output_id, entry.card.instance)] = { ...entry.card, zOrder }; } else if (entry.kind === 'workflow') { state.workflowCards[entry.card.workflow_id] = { ...entry.card, zOrder }; } else if (entry.kind === 'note') { diff --git a/scripts/fetch-webapp-template.sh b/scripts/fetch-webapp-template.sh index ff588dcf..c27c97a7 100755 --- a/scripts/fetch-webapp-template.sh +++ b/scripts/fetch-webapp-template.sh @@ -34,6 +34,32 @@ mkdir -p "$DEST" ( cd "$TMP/clone" && rm -rf .git LICENSE README.md .gitignore ) cp -R "$TMP/clone/." "$DEST/" +# Patch 1a: root run.sh honors per-instance port overrides. OpenSwarm passes +# OPENSWARM_FORCE_FRONTEND_PORT / OPENSWARM_FORCE_BACKEND_PORT when the user +# opens a SECOND instance of an app; without this the `source .env` above +# them pins every instance to the same ports. +ROOT_RUN_SH="$DEST/run.sh" +if ! grep -q "OPENSWARM_FORCE_FRONTEND_PORT" "$ROOT_RUN_SH"; then + awk ' + inserted != 1 && sourced && /^fi$/ { + print + print "" + print "# Per-instance port overrides: OpenSwarm passes these when the user opens a SECOND instance of the app, so it boots on fresh ports instead of colliding with the primary'\''s .env-pinned ones." + print "if [[ -n \"${OPENSWARM_FORCE_FRONTEND_PORT:-}\" ]]; then" + print " export FRONTEND_PORT=\"$OPENSWARM_FORCE_FRONTEND_PORT\"" + print "fi" + print "if [[ -n \"${OPENSWARM_FORCE_BACKEND_PORT:-}\" ]]; then" + print " export BACKEND_PORT=\"$OPENSWARM_FORCE_BACKEND_PORT\"" + print "fi" + inserted = 1 + next + } + /source "\$ROOT_DIR\/.env"/ { sourced = 1 } + { print } + ' "$ROOT_RUN_SH" > "$ROOT_RUN_SH.tmp" && mv "$ROOT_RUN_SH.tmp" "$ROOT_RUN_SH" + chmod +x "$ROOT_RUN_SH" +fi + # Patch 1: backend/run.sh forces all swarm-debug per-file toggles ON at # every boot (they default OFF, including files the agent creates later), # so `debug()` output actually lands in the App Builder Terminal. Runs