mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-22 12:42:22 +02:00
161 lines
5.8 KiB
Python
161 lines
5.8 KiB
Python
"""AppExportable: an app is an Output record + its workspace file tree. We carry
|
|
the editable source (frontend/, backend/, run.sh, package.json, .env.example,
|
|
meta) but NOT node_modules/.venv/dist (skip dirs) and NOT the live `.env` (it
|
|
holds the source machine's absolute paths + pinned port). On import we mint a
|
|
fresh output id + workspace id, drop the builder session link, and regenerate a
|
|
local `.env` with a free port. The app stays inert until the user opens it."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import socket
|
|
from uuid import uuid4
|
|
|
|
from backend.apps.outputs.models import Output
|
|
from backend.apps.outputs.workspace_io import WALK_SKIP_DIRS, save, load_output
|
|
from backend.config.paths import OUTPUTS_DIR, OUTPUTS_WORKSPACE_DIR
|
|
|
|
from backend.apps.swarm.exportable import DepRef, ExportContext, RemapTable
|
|
from backend.apps.swarm.models import EntityType, Requirement
|
|
|
|
P_MAX_APP_FILE = 25 * 1024 * 1024 # matches ziputil per-entry cap
|
|
|
|
|
|
class AppExportable:
|
|
type = EntityType.app
|
|
|
|
def __init__(self, output: Output):
|
|
self.output = output
|
|
self.local_id = output.id
|
|
self.name = output.name or "Untitled App"
|
|
|
|
@classmethod
|
|
def load(cls, local_id: str) -> "AppExportable | None":
|
|
o = load_output(local_id)
|
|
return cls(o) if o else None
|
|
|
|
def serialize(self, ctx: ExportContext) -> dict:
|
|
return {
|
|
"name": self.output.name,
|
|
"description": self.output.description,
|
|
"icon": self.output.icon,
|
|
"input_schema": self.output.input_schema,
|
|
"files": self.output.files, # flat-app inline source; webapp apps leave this empty
|
|
}
|
|
|
|
def files(self) -> dict[str, bytes]:
|
|
out: dict[str, bytes] = {}
|
|
wsid = self.output.workspace_id
|
|
if not wsid:
|
|
return out
|
|
folder = os.path.join(OUTPUTS_WORKSPACE_DIR, wsid)
|
|
if not os.path.isdir(folder):
|
|
return out
|
|
for root, dirs, fnames in os.walk(folder):
|
|
dirs[:] = [d for d in dirs if d not in WALK_SKIP_DIRS]
|
|
for fn in fnames:
|
|
# .env is install-specific (absolute paths + port); .env.example travels instead.
|
|
if fn == ".env":
|
|
continue
|
|
full = os.path.join(root, fn)
|
|
if os.path.islink(full):
|
|
continue
|
|
try:
|
|
if os.path.getsize(full) > P_MAX_APP_FILE:
|
|
continue
|
|
with open(full, "rb") as f:
|
|
data = f.read()
|
|
except OSError:
|
|
continue
|
|
rel = os.path.relpath(full, folder).replace(os.sep, "/")
|
|
out[f"workspace/{rel}"] = data
|
|
return out
|
|
|
|
def dependencies(self) -> list[DepRef]:
|
|
return []
|
|
|
|
def requirements(self) -> list[Requirement]:
|
|
return []
|
|
|
|
@classmethod
|
|
def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str:
|
|
new_wsid = uuid4().hex
|
|
folder = os.path.join(OUTPUTS_WORKSPACE_DIR, new_wsid)
|
|
wrote_workspace = False
|
|
for rel, data in files.items():
|
|
if not rel.startswith("workspace/"):
|
|
continue
|
|
dest = p_safe_join(folder, rel[len("workspace/"):])
|
|
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
with open(dest, "wb") as f:
|
|
f.write(data)
|
|
wrote_workspace = True
|
|
if wrote_workspace:
|
|
p_localize_env(folder)
|
|
|
|
o = Output(
|
|
name=payload.get("name") or "Imported App",
|
|
description=payload.get("description", ""),
|
|
icon=payload.get("icon", "view_quilt"),
|
|
input_schema=payload.get("input_schema") or {"type": "object", "properties": {}, "required": []},
|
|
files=payload.get("files") or {},
|
|
workspace_id=new_wsid if wrote_workspace else None,
|
|
session_id=None,
|
|
)
|
|
save(o)
|
|
return o.id
|
|
|
|
@classmethod
|
|
def rollback(cls, local_id: str) -> None:
|
|
o = load_output(local_id)
|
|
if o and o.workspace_id:
|
|
shutil.rmtree(os.path.join(OUTPUTS_WORKSPACE_DIR, o.workspace_id), ignore_errors=True)
|
|
p = os.path.join(OUTPUTS_DIR, f"{local_id}.json")
|
|
if os.path.exists(p):
|
|
os.remove(p)
|
|
|
|
|
|
def p_safe_join(folder: str, rel: str) -> str:
|
|
dest = os.path.realpath(os.path.join(folder, rel))
|
|
root = os.path.realpath(folder)
|
|
if dest != root and not dest.startswith(root + os.sep):
|
|
raise ValueError("app file path escapes the workspace")
|
|
return dest
|
|
|
|
|
|
def p_free_port() -> int:
|
|
s = socket.socket()
|
|
try:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
finally:
|
|
s.close()
|
|
|
|
|
|
def p_localize_env(folder: str) -> None:
|
|
"""Regenerate the workspace .env on the importer's machine: a fresh port plus
|
|
this install's absolute template/debugger paths (the source's were dropped)."""
|
|
env_path = os.path.join(folder, ".env")
|
|
example = os.path.join(folder, ".env.example")
|
|
if not os.path.exists(env_path):
|
|
if os.path.exists(example):
|
|
shutil.copyfile(example, env_path)
|
|
else:
|
|
return # flat app: no run.sh, no env needed
|
|
try:
|
|
from backend.apps.outputs.view_builder_templates import (
|
|
DEBUGGER_PATH,
|
|
TEMPLATE_BACKEND_PATH,
|
|
patch_env_port,
|
|
warm_venv_dir,
|
|
)
|
|
except Exception:
|
|
return
|
|
patch_env_port(env_path, "FRONTEND_PORT", str(p_free_port()))
|
|
patch_env_port(env_path, "OPENSWARM_TEMPLATE_BACKEND_PATH", TEMPLATE_BACKEND_PATH)
|
|
patch_env_port(env_path, "OPENSWARM_DEBUGGER_PATH", DEBUGGER_PATH)
|
|
try:
|
|
patch_env_port(env_path, "OPENSWARM_BACKEND_VENV_CACHE", warm_venv_dir())
|
|
except Exception:
|
|
pass
|