diff --git a/backend/apps/marketplace/install_jobs.py b/backend/apps/marketplace/install_jobs.py new file mode 100644 index 00000000..b1f92fb8 --- /dev/null +++ b/backend/apps/marketplace/install_jobs.py @@ -0,0 +1,83 @@ +"""An install as a job the pill can watch: download (with byte progress), stage, then hand back the +same review a dropped .swarm gets. The job lives in memory for a few minutes; a restart forgets it and +the pill simply asks again. +""" + +import threading +import time +import uuid +from dataclasses import dataclass, field +from typing import Literal, Optional + +from typeguard import typechecked + +from backend.apps.marketplace.catalog import Listing +from backend.apps.marketplace.package_download import DownloadRefused, download_package, package_filename +from backend.apps.swarm.models import ImportPreflightResponse + +JobPhase = Literal["downloading", "staging", "ready", "failed"] +JOB_TTL_SECONDS = 600 + + +@dataclass +class InstallJob: + id: str + listing_id: str + phase: JobPhase = "downloading" + received: int = 0 + total: int = 0 + preflight: Optional[ImportPreflightResponse] = None + error: Optional[str] = None + started_at: float = field(default_factory=time.time) + + +P_JOBS: dict[str, InstallJob] = {} +P_LOCK = threading.Lock() + + +@typechecked +def start_job(listing_id: str) -> InstallJob: + sweep_expired() + job = InstallJob(id=uuid.uuid4().hex, listing_id=listing_id) + with P_LOCK: + P_JOBS[job.id] = job + return job + + +@typechecked +def get_job(job_id: str) -> Optional[InstallJob]: + with P_LOCK: + return P_JOBS.get(job_id) + + +@typechecked +def sweep_expired(now: Optional[float] = None) -> int: + cutoff = (now if now is not None else time.time()) - JOB_TTL_SECONDS + with P_LOCK: + stale = [job_id for job_id, job in P_JOBS.items() if job.started_at < cutoff] + for job_id in stale: + del P_JOBS[job_id] + return len(stale) + + +@typechecked +def run_job(job: InstallJob, listing: Listing) -> None: + """Runs on a worker thread. Every exit lands the job in ready or failed; nothing is left spinning.""" + + def on_progress(received: int, total: int) -> None: + job.received = received + job.total = total + + try: + raw = download_package(listing.download_url, on_progress) + job.phase = "staging" + # Staging is local and quick; the ring reads as full while it runs. + from backend.apps.swarm.swarm import stage_bundle_for_import + job.preflight = stage_bundle_for_import(raw, package_filename(listing.id, listing.title)) + job.phase = "ready" + except DownloadRefused as e: + job.error = str(e) + job.phase = "failed" + except Exception as e: + job.error = f"Couldn't install it: {e}" + job.phase = "failed" diff --git a/backend/apps/marketplace/marketplace.py b/backend/apps/marketplace/marketplace.py index 4f53574f..e28b7dbc 100644 --- a/backend/apps/marketplace/marketplace.py +++ b/backend/apps/marketplace/marketplace.py @@ -14,14 +14,9 @@ from fastapi import HTTPException from pydantic import BaseModel, ConfigDict from backend.apps.marketplace import catalog +from backend.apps.marketplace.install_jobs import InstallJob, JobPhase, get_job, run_job, start_job from backend.apps.marketplace.installs import InstallRecord, load_installs, record_install -from backend.apps.marketplace.package_download import ( - DownloadRefused, - download_package, - package_filename, -) from backend.apps.swarm.models import ImportPreflightResponse -from backend.apps.swarm.swarm import stage_bundle_for_import from backend.config.Apps import SubApp logger = logging.getLogger(__name__) @@ -48,20 +43,55 @@ async def get_listings(refresh: bool = False) -> catalog.CatalogResponse: return await asyncio.to_thread(catalog.load_catalog, refresh) -@marketplace.router.post("/install/preflight") -async def install_preflight(body: InstallRequest) -> ImportPreflightResponse: - """Download the listing's bundle and stage it for the ordinary import confirm flow.""" +class InstallStartResponse(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + job_id: str + + +class InstallJobStatus(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + job_id: str + phase: JobPhase + received: int + total: int + preflight: ImportPreflightResponse | None = None + error: str | None = None + + +def job_status(job: InstallJob) -> InstallJobStatus: + return InstallJobStatus(job_id=job.id, phase=job.phase, received=job.received, total=job.total, preflight=job.preflight, error=job.error) + + +# Fire-and-forget tasks need a reference or the loop may drop them mid-download. +P_RUNNING: set[asyncio.Task[None]] = set() + + +@marketplace.router.post("/install/start") +async def install_start(body: InstallRequest) -> InstallStartResponse: + """Start downloading the listing's bundle; the job stages it for the ordinary import confirm + flow and the pill polls /install/{job_id} for byte progress and the review.""" listing = await asyncio.to_thread(catalog.find_listing, body.id) if listing is None: raise HTTPException(status_code=404, detail="that package is not in the catalog any more") if not listing.download_url: raise HTTPException(status_code=400, detail="this listing has no package file yet") - try: - raw = await asyncio.to_thread(download_package, listing.download_url) - except DownloadRefused as e: - logger.warning("marketplace download refused for %s: %s", listing.id, e) - raise HTTPException(status_code=400, detail=str(e)) - return stage_bundle_for_import(raw, package_filename(listing.id, listing.title)) + job = start_job(listing.id) + task = asyncio.create_task(asyncio.to_thread(run_job, job, listing)) + P_RUNNING.add(task) + task.add_done_callback(P_RUNNING.discard) + return InstallStartResponse(job_id=job.id) + + +@marketplace.router.get("/install/{job_id}") +async def install_status(job_id: str) -> InstallJobStatus: + job = get_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail="that install is no longer running; start it again") + if job.phase == "failed": + logger.warning("marketplace install failed for %s: %s", job.listing_id, job.error) + return job_status(job) class InstallsResponse(BaseModel): diff --git a/backend/apps/marketplace/package_download.py b/backend/apps/marketplace/package_download.py index d101378a..7c7e839c 100644 --- a/backend/apps/marketplace/package_download.py +++ b/backend/apps/marketplace/package_download.py @@ -5,8 +5,12 @@ otherwise make OUR backend fetch whatever it names, including localhost and clou hop of the redirect chain is checked, not just the first, because a permitted host may redirect. """ +import logging +import os +import time import urllib.error import urllib.request +from typing import Callable, Optional from urllib.parse import urlparse from typeguard import typechecked @@ -22,6 +26,14 @@ ALLOWED_DOWNLOAD_HOSTS = ( DOWNLOAD_TIMEOUT_SECONDS = 60 MAX_PACKAGE_BYTES = 200 * 1024 * 1024 +CHUNK_BYTES = 64 * 1024 +# Drill seam: cap the download at this many bytes per second so a human can watch the ring fill. Unset in real life. +THROTTLE_ENV = "OSW_MARKETPLACE_THROTTLE_BPS" + +logger = logging.getLogger(__name__) + +# (bytes received so far, total bytes or 0 when the server did not say) +ProgressCallback = Callable[[int, int], None] class DownloadRefused(Exception): @@ -48,15 +60,57 @@ class AllowlistRedirectHandler(urllib.request.HTTPRedirectHandler): @typechecked -def download_package(url: str) -> bytes: - """The bundle's bytes, or DownloadRefused. Never returns a partial or oversized body.""" +def throttle_bytes_per_second() -> int: + raw = (os.environ.get(THROTTLE_ENV) or "").strip() + if not raw: + return 0 + try: + bps = int(raw) + except ValueError: + return 0 + if bps > 0: + logger.warning("marketplace downloads throttled to %d bytes/s by %s (drill seam)", bps, THROTTLE_ENV) + return max(0, bps) + + +@typechecked +def declared_total(response: object) -> int: + """Content-Length when the server sent one, else 0 (the ring then spins instead of filling).""" + try: + return max(0, int(response.headers.get("Content-Length") or 0)) # type: ignore[attr-defined] + except (TypeError, ValueError): + return 0 + + +@typechecked +def download_package(url: str, on_progress: Optional[ProgressCallback] = None) -> bytes: + """The bundle's bytes, or DownloadRefused. Never returns a partial or oversized body. Reads in + chunks and reports (received, total) after each one so an install can show a real ring.""" if not host_allowed(url): raise DownloadRefused("this package is not hosted somewhere OpenSwarm will download from") opener = urllib.request.build_opener(AllowlistRedirectHandler()) request = urllib.request.Request(url, headers={"User-Agent": "OpenSwarm-Marketplace/1.0"}) try: with opener.open(request, timeout=DOWNLOAD_TIMEOUT_SECONDS) as response: - raw = response.read(MAX_PACKAGE_BYTES + 1) + total = declared_total(response) + if total > MAX_PACKAGE_BYTES: + raise DownloadRefused("the package is too large") + throttle = throttle_bytes_per_second() + chunks: list[bytes] = [] + received = 0 + while True: + chunk = response.read(CHUNK_BYTES) + if not chunk: + break + received += len(chunk) + if received > MAX_PACKAGE_BYTES: + raise DownloadRefused("the package is too large") + chunks.append(chunk) + if on_progress is not None: + on_progress(received, total) + if throttle: + time.sleep(len(chunk) / throttle) + raw = b"".join(chunks) except urllib.error.HTTPError as e: raise DownloadRefused(f"the download returned {e.code}") except DownloadRefused: diff --git a/backend/run.sh b/backend/run.sh index cf4c220a..11039d2f 100755 --- a/backend/run.sh +++ b/backend/run.sh @@ -82,7 +82,10 @@ done # single-process uvicorn. if [[ "${OPENSWARM_DEV:-}" == "1" ]]; then echo "OPENSWARM_DEV=1 detected, running uvicorn with --reload." + # A reload SIGTERMs the worker and waits for it; when the graceful shutdown wedges (2026-09-03: worker idle, + # listen queue 128/128, port dead for 13 minutes) the cap turns that wait into a real restart. python3 -m uvicorn backend.main:app --host 0.0.0.0 --port "$BACKEND_PORT" --reload \ + --timeout-graceful-shutdown 10 \ --reload-dir "$BACKEND_DIR_ABSPATH" \ "${UVICORN_EXCLUDE_ARGS[@]}" else diff --git a/backend/tests/test_marketplace_catalog.py b/backend/tests/test_marketplace_catalog.py index 612aa6ac..80888e76 100644 --- a/backend/tests/test_marketplace_catalog.py +++ b/backend/tests/test_marketplace_catalog.py @@ -121,10 +121,10 @@ def test_a_caller_names_a_listing_id_never_a_url(): def test_install_stages_through_the_same_door_as_a_dropped_file(): - from backend.apps.swarm import swarm as swarm_routes + from backend.apps.marketplace import install_jobs - assert marketplace.stage_bundle_for_import is swarm_routes.stage_bundle_for_import - body = inspect.getsource(marketplace.install_preflight) - assert "stage_bundle_for_import" in body + body = inspect.getsource(install_jobs.run_job) + assert "from backend.apps.swarm.swarm import stage_bundle_for_import" in body for forbidden in ("write_folder_skill", "closure.commit", "import_commit"): assert forbidden not in body, "install must never write; it stages and lets the user confirm" + assert forbidden not in inspect.getsource(marketplace), "the routes must never write either" diff --git a/backend/tests/test_marketplace_install_jobs.py b/backend/tests/test_marketplace_install_jobs.py new file mode 100644 index 00000000..97f341e5 --- /dev/null +++ b/backend/tests/test_marketplace_install_jobs.py @@ -0,0 +1,122 @@ +"""An install is a job the pill can watch: the download streams and reports bytes, the job walks +downloading -> staging -> ready (or failed) and nothing is left spinning, and the routes expose it.""" + +import io +import time +from typing import Any + +from backend.apps.marketplace import install_jobs, package_download +from backend.apps.marketplace.catalog import Listing +from backend.apps.marketplace.package_download import DownloadRefused, download_package + + +class P_Response: + def __init__(self, body: bytes, content_length: Any) -> None: + self.headers = {"Content-Length": content_length} + self._buf = io.BytesIO(body) + + def read(self, n: int) -> bytes: + return self._buf.read(n) + + def __enter__(self) -> "P_Response": + return self + + def __exit__(self, *exc: object) -> None: + return None + + +class P_Opener: + def __init__(self, response: P_Response) -> None: + self.response = response + + def open(self, request: object, timeout: float) -> P_Response: + return self.response + + +def p_fake_opener(monkeypatch: Any, body: bytes, content_length: Any) -> None: + monkeypatch.setattr(package_download.urllib.request, "build_opener", lambda *a, **k: P_Opener(P_Response(body, content_length))) + + +def test_the_download_reports_bytes_after_every_chunk_and_ends_on_the_total(monkeypatch: Any) -> None: + body = b"x" * (package_download.CHUNK_BYTES * 2 + 17) + p_fake_opener(monkeypatch, body, str(len(body))) + seen: list[tuple[int, int]] = [] + raw = download_package("https://github.com/o/r/releases/download/v1/p.swarm", lambda r, t: seen.append((r, t))) + assert raw == body + assert [r for r, _ in seen] == [package_download.CHUNK_BYTES, package_download.CHUNK_BYTES * 2, len(body)] + assert {t for _, t in seen} == {len(body)} + + +def test_no_content_length_means_total_zero_and_the_bytes_still_arrive(monkeypatch: Any) -> None: + p_fake_opener(monkeypatch, b"abc", None) + seen: list[tuple[int, int]] = [] + assert download_package("https://github.com/o/r/p.swarm", lambda r, t: seen.append((r, t))) == b"abc" + assert seen == [(3, 0)] + + +def test_an_oversized_declaration_is_refused_before_a_byte_is_read(monkeypatch: Any) -> None: + p_fake_opener(monkeypatch, b"", str(package_download.MAX_PACKAGE_BYTES + 1)) + try: + download_package("https://github.com/o/r/p.swarm") + except DownloadRefused as e: + assert "too large" in str(e) + else: + raise AssertionError("an oversized package was downloaded") + + +def test_a_job_walks_downloading_staging_ready_with_the_review(monkeypatch: Any) -> None: + listing = Listing(id="git-graph", title="Git Graph", download_url="https://github.com/o/r/p.swarm") + monkeypatch.setattr(install_jobs, "download_package", lambda url, cb: (cb(5, 10), cb(10, 10), b"bundle")[-1]) + import backend.apps.swarm.swarm as p_swarm + from backend.apps.swarm.models import BundleSummary, ImportPreflightResponse + monkeypatch.setattr(p_swarm, "stage_bundle_for_import", lambda raw, fn: ImportPreflightResponse.model_construct(summary=BundleSummary.model_construct(), staging_token="tok-" + fn)) + job = install_jobs.start_job(listing.id) + install_jobs.run_job(job, listing) + assert job.phase == "ready" + assert (job.received, job.total) == (10, 10) + assert job.preflight is not None and job.preflight.staging_token == "tok-git-graph.swarm" + assert install_jobs.get_job(job.id) is job + + +def test_a_refused_download_lands_the_job_in_failed_with_the_reason(monkeypatch: Any) -> None: + listing = Listing(id="x", title="X", download_url="https://github.com/o/r/p.swarm") + + def refuse(url: str, cb: Any) -> bytes: + raise DownloadRefused("the download returned 404") + + monkeypatch.setattr(install_jobs, "download_package", refuse) + job = install_jobs.start_job(listing.id) + install_jobs.run_job(job, listing) + assert job.phase == "failed" + assert job.error == "the download returned 404" + + +def test_jobs_expire_so_the_store_cannot_grow_forever() -> None: + job = install_jobs.start_job("old") + job.started_at = time.time() - install_jobs.JOB_TTL_SECONDS - 1 + assert install_jobs.sweep_expired() >= 1 + assert install_jobs.get_job(job.id) is None + + +def test_the_routes_start_a_job_and_report_it(monkeypatch: Any) -> None: + from fastapi.testclient import TestClient + from backend.apps.marketplace import marketplace as p_mp + + listing = Listing(id="git-graph", title="Git Graph", download_url="https://github.com/o/r/p.swarm") + monkeypatch.setattr(p_mp.catalog, "find_listing", lambda listing_id: listing if listing_id == "git-graph" else None) + monkeypatch.setattr(p_mp, "run_job", lambda job, lst: setattr(job, "phase", "failed") or setattr(job, "error", "the download returned 404")) + from fastapi import FastAPI + app = FastAPI() + app.include_router(p_mp.marketplace.router, prefix="/api/marketplace") + client = TestClient(app) + assert client.post("/api/marketplace/install/start", json={"id": "nope"}).status_code == 404 + started = client.post("/api/marketplace/install/start", json={"id": "git-graph"}) + assert started.status_code == 200 + job_id = started.json()["job_id"] + for _ in range(50): + status = client.get(f"/api/marketplace/install/{job_id}").json() + if status["phase"] == "failed": + break + time.sleep(0.02) + assert status["phase"] == "failed" and status["error"] == "the download returned 404" + assert client.get("/api/marketplace/install/does-not-exist").status_code == 404 diff --git a/backend/tests/test_stream_frames_skip_dashboard_socket.py b/backend/tests/test_stream_frames_skip_dashboard_socket.py index 2162100f..b021721c 100644 --- a/backend/tests/test_stream_frames_skip_dashboard_socket.py +++ b/backend/tests/test_stream_frames_skip_dashboard_socket.py @@ -5,7 +5,6 @@ Both directions pinned: deltas stay session-only, and every other event still re dashboard socket, or narrator pills and status chips would go blind.""" import pytest -from unittest.mock import AsyncMock from backend.apps.agents.core.ws_manager import ws_manager diff --git a/frontend/src/app/pages/Directory/DirectoryPackagesTab.tsx b/frontend/src/app/pages/Directory/DirectoryPackagesTab.tsx index 5e01ac17..b12058d8 100644 --- a/frontend/src/app/pages/Directory/DirectoryPackagesTab.tsx +++ b/frontend/src/app/pages/Directory/DirectoryPackagesTab.tsx @@ -21,7 +21,8 @@ import PackageCard from './packages/PackageCard'; import PackageDialog from './packages/detail/PackageDialog'; import PackageBundleCard from './packages/PackageBundleCard'; import PackageBundleDialog from './packages/detail/PackageBundleDialog'; -import { stagePackageInstall } from './packages/installPackage'; +import { stagePackageInstall, type InstallProgress } from './packages/installPackage'; +import { fractionOf } from './packages/installRing'; import { fetchInstalls, installState, recordFor, recordInstall, type InstallRecord, type PillState } from './packages/installs'; import { KIND_LABELS, isBundle, resolveBundleMembers, type Listing } from './packages/catalog'; @@ -43,6 +44,7 @@ const DirectoryPackagesTab: React.FC<{ onOpenSkill?: (skillId: string) => void } const [openListing, setOpenListing] = useState