import asyncio import json import os import logging import mimetypes import shutil from datetime import datetime from typing import Optional from contextlib import asynccontextmanager from fastapi import HTTPException, Query from fastapi.responses import Response from backend.auth import get_auth_token from backend.config.Apps import SubApp from backend.apps.outputs.models import ( Output, OutputCreate, OutputUpdate, OutputExecute, OutputExecuteResult, VibeCodeRequest, WorkspaceSeedRequest, AgentCreateAppRequest, PublishPreflightRequest, PublishRequest, PublishPreflightResponse, PublishResult, PublishReview, ) from backend.apps.outputs.code_safety import get_code_warnings from backend.apps.outputs.executor import execute_backend_code from backend.apps.outputs.app_icon import glyph_icon from backend.apps.outputs.publish_capability import check_publish_capability from backend.apps.outputs.publish_common import slugify, PublishError from backend.apps.outputs.publish_scan import scan_for_publish, quick_ast_gate from backend.apps.outputs.publish_build import build_static, collect_bundle from backend.apps.outputs.publish_cloud import upload_to_cloud from backend.apps.outputs.release_publication import release_publication from backend.apps.outputs.view_builder_templates import ( VIEW_TEMPLATE_FILES, load_app_builder_skill, seed_webapp_template_workspace, ) from backend.apps.settings.settings import load_settings from backend.config.paths import OUTPUTS_DIR as DATA_DIR, OUTPUTS_WORKSPACE_DIR as WORKSPACE_DIR from backend.apps.outputs.html_inject import ( get_anthropic_client, validate_against_schema, build_data_injection, inject_data_into_html, backend_url_for_workspace, inject_token_into_relative_urls, decode_data_param, ) from backend.apps.outputs.workspace_io import ( load_all, save, load, load_output, resolve_in_workspace, walk_directory, workspace_root, would_shrink_oversize_file, ) from backend.apps.outputs.prompts import VIBE_CODE_SYSTEM_PROMPT logger = logging.getLogger(__name__) @asynccontextmanager async def outputs_lifespan(): os.makedirs(DATA_DIR, exist_ok=True) os.makedirs(WORKSPACE_DIR, exist_ok=True) # One-time sweep: an app whose record vanished still has its work on disk, and no way for the user to know it is there. try: from backend.apps.outputs.recover_orphaned_apps import recover_orphaned_apps recover_orphaned_apps() except Exception: logger.exception("orphaned-app recovery failed; apps stay hidden but nothing else breaks") # Ghosts from a session that died badly keep running forever: stop_all only fires on a clean # shutdown, and the port-collision path routes AROUND a squatter instead of killing it. Measured # on a dev box: runtimes still alive after 2 days 19 hours. Boot is the one safe moment, since we # have not spawned any of our own yet. try: from backend.apps.outputs.reap_ghost_runtimes import reap_ghost_runtimes ghosts = reap_ghost_runtimes() if ghosts: logger.warning("outputs lifespan: reaped %d ghost runtime(s) from a previous session", ghosts) except Exception: logger.exception("ghost-runtime reap failed; stale processes stay but boot continues") # The boot reap catches ghosts from a PREVIOUS session, but a session can live for days: this # sweep keeps catching them while we run (another backend dying leaves orphans mid-session) and # retires idle runtimes past their TTL, so "quit but still around" has a bounded lifetime. async def p_periodic_sweep() -> None: from backend.apps.outputs.reap_ghost_runtimes import reap_ghost_runtimes from backend.apps.outputs.runtime import manager as p_sweep_manager while True: await asyncio.sleep(600) try: ghosts = await asyncio.to_thread(reap_ghost_runtimes) stale = await p_sweep_manager.reap_stale_idle() if ghosts or stale: logger.info("periodic sweep: %d ghost(s) reaped, %d stale idle runtime(s) stopped", ghosts, stale) except Exception: logger.exception("periodic sweep failed; will retry next interval") p_sweep_task = asyncio.create_task(p_periodic_sweep()) try: yield finally: p_sweep_task.cancel() # Reap every per-app subprocess. Without this each `bash run.sh` (and its vite/uvicorn descendants) reparents to PID 1 when the main backend dies, leaving ghost listeners on the .env-pinned ports that block the next OpenSwarm launch's reload preview. try: from backend.apps.outputs.runtime import manager as runtime_manager killed = await runtime_manager.stop_all() if killed: logger.info("outputs lifespan: reaped %d workspace runtimes on shutdown", killed) except Exception: logger.exception("outputs lifespan: stop_all failed") outputs = SubApp("outputs", outputs_lifespan) # --------------------------------------------------------------------------- File-serving endpoints (for iframe preview with multi-file support) --------------------------------------------------------------------------- @outputs.router.get("/workspace/{workspace_id}/serve/{filepath:path}") async def serve_workspace_file(workspace_id: str, filepath: str, p_d: str = ""): """Serve a file from a workspace folder. For index.html, inject OUTPUT data.""" folder = os.path.join(WORKSPACE_DIR, workspace_id) full_path = resolve_in_workspace(folder, filepath) if full_path is None: raise HTTPException(status_code=403, detail="Path traversal not allowed") if not os.path.isfile(full_path): raise HTTPException(status_code=404, detail="File not found") # 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 (,