Files
openswarm/backend/apps/swarm/swarm.py
T

131 lines
4.0 KiB
Python

"""SubApp for .swarm sharing. Three endpoints: export (returns the bundle bytes
as a download), import/preflight (parse + stage in a sandbox, no writes), and
import/commit (write the staged entities with fresh ids). Staging is in-process
with a TTL; a lost token just means re-open the file."""
import logging
import shutil
import time
import uuid
from contextlib import asynccontextmanager
from fastapi import File, HTTPException, Response, UploadFile
from backend.config.Apps import SubApp
from . import closure
from .models import (
ExportPreflightResponse,
ExportRequest,
ImportCommitRequest,
ImportCommitResponse,
ImportPreflightResponse,
RequirementView,
)
from .ziputil import MAX_TOTAL_BYTES, BundleError
logger = logging.getLogger(__name__)
_STAGING: dict[str, dict] = {}
_STAGING_TTL = 30 * 60 # 30 minutes
def _gc_staging() -> None:
now = time.time()
for token in list(_STAGING):
if now - _STAGING[token]["created_at"] > _STAGING_TTL:
_discard(token)
def _discard(token: str) -> None:
entry = _STAGING.pop(token, None)
if entry:
shutil.rmtree(entry["sandbox"], ignore_errors=True)
@asynccontextmanager
async def swarm_lifespan():
_gc_staging()
try:
yield
finally:
for token in list(_STAGING):
_discard(token)
swarm = SubApp("swarm", swarm_lifespan)
@swarm.router.post("/export/preflight")
async def export_preflight(body: ExportRequest) -> ExportPreflightResponse:
try:
manifest = closure.build_manifest(body.type, body.id)
except BundleError as e:
raise HTTPException(status_code=400, detail=str(e))
return ExportPreflightResponse(
summary=closure.summarize(manifest),
filename=closure.swarm_filename(manifest.root.name),
link_supported=False,
)
@swarm.router.post("/export")
async def export_bundle(body: ExportRequest) -> Response:
try:
raw, name = closure.build_bundle(body.type, body.id)
except BundleError as e:
raise HTTPException(status_code=400, detail=str(e))
fname = closure.swarm_filename(name)
return Response(
content=raw,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
@swarm.router.post("/import/preflight")
async def import_preflight(file: UploadFile = File(...)) -> ImportPreflightResponse:
raw = await file.read()
if len(raw) > MAX_TOTAL_BYTES:
raise HTTPException(status_code=400, detail="file is too large")
try:
sandbox, manifest, warnings = closure.stage_upload(raw, file.filename or "")
conflicts = closure.detect_conflicts(sandbox, manifest)
review = closure.review_bundle(sandbox, manifest)
except BundleError as e:
raise HTTPException(status_code=400, detail=str(e))
_gc_staging()
token = uuid.uuid4().hex
_STAGING[token] = {"sandbox": sandbox, "manifest": manifest, "created_at": time.time()}
return ImportPreflightResponse(
summary=closure.summarize(manifest),
staging_token=token,
conflicts=conflicts,
review=review,
warnings=warnings,
)
@swarm.router.post("/import/commit")
async def import_commit(body: ImportCommitRequest) -> ImportCommitResponse:
entry = _STAGING.get(body.staging_token)
if not entry:
raise HTTPException(status_code=404, detail="import session expired; please re-open the file")
try:
root_type, root_id, created, unresolved = closure.commit(
entry["sandbox"], entry["manifest"], body.accept_requirements
)
except BundleError as e:
raise HTTPException(status_code=400, detail=str(e))
finally:
_discard(body.staging_token)
if root_id is None:
raise HTTPException(status_code=400, detail="bundle has no root entity")
return ImportCommitResponse(
root_type=root_type,
root_id=root_id,
created=created,
unresolved_requirements=[
RequirementView(kind=r.kind, key=r.key, label=r.label, detail=r.detail) for r in unresolved
],
)