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

131 lines
4.1 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 backend.apps.swarm import closure
from backend.apps.swarm.models import (
ExportPreflightResponse,
ExportRequest,
ImportCommitRequest,
ImportCommitResponse,
ImportPreflightResponse,
RequirementView,
)
from backend.apps.swarm.ziputil import MAX_TOTAL_BYTES, BundleError
logger = logging.getLogger(__name__)
P_STAGING: dict[str, dict] = {}
P_STAGING_TTL = 30 * 60 # 30 minutes
def p_gc_staging() -> None:
now = time.time()
for token in list(P_STAGING):
if now - P_STAGING[token]["created_at"] > P_STAGING_TTL:
p_discard(token)
def p_discard(token: str) -> None:
entry = P_STAGING.pop(token, None)
if entry:
shutil.rmtree(entry["sandbox"], ignore_errors=True)
@asynccontextmanager
async def swarm_lifespan():
p_gc_staging()
try:
yield
finally:
for token in list(P_STAGING):
p_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))
p_gc_staging()
token = uuid.uuid4().hex
P_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 = P_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:
p_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
],
)