mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-14 05:37:40 +02:00
[eric] marketplace: Get on every card and sheet, spinner, then Open; installs remembered by the backend and Open launches the app, workflow or skill
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
2afbdc5b05
commit
bc1e7acac9
@@ -0,0 +1,70 @@
|
||||
"""What this machine installed from the marketplace, by listing id.
|
||||
|
||||
The App Store knows what is installed; without a record every package showed Install again after a
|
||||
restart, and Open had nothing to open. The importer creates a fresh entity per commit and returns its
|
||||
id, so the record is written at commit time and maps a listing to the thing it became. A record whose
|
||||
entity has since been deleted is stale by design: the store re-checks the live lists before showing Open.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from typeguard import typechecked
|
||||
|
||||
from backend.config.paths import DATA_ROOT
|
||||
|
||||
INSTALLS_PATH = os.path.join(DATA_ROOT, "marketplace_installs.json")
|
||||
|
||||
|
||||
class InstallRecord(BaseModel):
|
||||
"""One pointer per importable root, typed, so an id can never point at a kind nobody can resolve."""
|
||||
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
listing_id: str
|
||||
root_type: str
|
||||
output_id: Optional[str] = None
|
||||
skill_id: Optional[str] = None
|
||||
workflow_id: Optional[str] = None
|
||||
dashboard_id: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
version: str = ""
|
||||
installed_at: float = 0.0
|
||||
|
||||
def root_id(self) -> Optional[str]:
|
||||
return self.output_id or self.skill_id or self.workflow_id or self.dashboard_id or self.session_id
|
||||
|
||||
|
||||
@typechecked
|
||||
def load_installs(path: Optional[str] = None) -> Dict[str, InstallRecord]:
|
||||
p = path or INSTALLS_PATH
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
out: Dict[str, InstallRecord] = {}
|
||||
for key, value in (raw or {}).items():
|
||||
try:
|
||||
out[key] = InstallRecord.model_validate(value)
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
@typechecked
|
||||
def record_install(rec: InstallRecord, path: Optional[str] = None) -> Dict[str, InstallRecord]:
|
||||
p = path or INSTALLS_PATH
|
||||
installs = load_installs(p)
|
||||
if rec.installed_at == 0.0:
|
||||
rec = rec.model_copy(update={"installed_at": time.time()})
|
||||
installs[rec.listing_id] = rec
|
||||
os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
|
||||
tmp = p + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump({k: v.model_dump() for k, v in installs.items()}, f, indent=1)
|
||||
os.replace(tmp, p)
|
||||
return installs
|
||||
@@ -14,6 +14,7 @@ from fastapi import HTTPException
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from backend.apps.marketplace import catalog
|
||||
from backend.apps.marketplace.installs import InstallRecord, load_installs, record_install
|
||||
from backend.apps.marketplace.package_download import (
|
||||
DownloadRefused,
|
||||
download_package,
|
||||
@@ -61,3 +62,21 @@ async def install_preflight(body: InstallRequest) -> ImportPreflightResponse:
|
||||
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))
|
||||
|
||||
|
||||
class InstallsResponse(BaseModel):
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
installs: dict[str, InstallRecord]
|
||||
|
||||
|
||||
@marketplace.router.get("/installed")
|
||||
async def get_installed() -> InstallsResponse:
|
||||
return InstallsResponse(installs=load_installs())
|
||||
|
||||
|
||||
@marketplace.router.post("/installed")
|
||||
async def post_installed(body: InstallRecord) -> InstallsResponse:
|
||||
if not body.listing_id or not body.root_type or not body.root_id():
|
||||
raise HTTPException(status_code=400, detail="listing_id, root_type and the id of what was installed are required")
|
||||
return InstallsResponse(installs=record_install(body))
|
||||
|
||||
@@ -32,6 +32,9 @@ class EntityKind(str, Enum):
|
||||
CLOUD_WORKFLOW = "cloud_workflow"
|
||||
# A provider login in 9router's own db, not one of our JSON records.
|
||||
PROVIDER_CONNECTION = "provider_connection"
|
||||
SKILL = "skill"
|
||||
# A row of the published marketplace sheet, resolved from the catalog we last fetched.
|
||||
MARKETPLACE_LISTING = "marketplace_listing"
|
||||
|
||||
|
||||
class EntityStore(BaseModel):
|
||||
@@ -67,9 +70,18 @@ ENTITY_STORES: List[EntityStore] = [
|
||||
# The one referent that does not live on this machine. preflight asks the cloud whether it still has the row; a miss renders as "nothing is running this", never as a silent blank.
|
||||
EntityStore(kind=EntityKind.CLOUD_WORKFLOW, module="backend.apps.workflows.cloud.client", lookup="preflight"),
|
||||
EntityStore(kind=EntityKind.PROVIDER_CONNECTION, module="backend.apps.nine_router.credential_store", lookup="read_credential"),
|
||||
# A skill is a folder under ~/.claude/skills; its only by-id lookup is the path resolver.
|
||||
EntityStore(kind=EntityKind.SKILL, module="backend.apps.skills.skills", lookup="skill_md_path"),
|
||||
EntityStore(kind=EntityKind.MARKETPLACE_LISTING, module="backend.apps.marketplace.catalog", lookup="find_listing"),
|
||||
]
|
||||
|
||||
CROSS_ENTITY_REFERENCES: List[EntityReference] = [
|
||||
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="listing_id", target=EntityKind.MARKETPLACE_LISTING),
|
||||
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="output_id", target=EntityKind.OUTPUT),
|
||||
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="skill_id", target=EntityKind.SKILL),
|
||||
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="workflow_id", target=EntityKind.WORKFLOW),
|
||||
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="dashboard_id", target=EntityKind.DASHBOARD),
|
||||
EntityReference(module="backend.apps.marketplace.installs", model="InstallRecord", field="session_id", target=EntityKind.SESSION),
|
||||
EntityReference(module="backend.apps.agents.core.models", model="AgentConfig", field="dashboard_id", target=EntityKind.DASHBOARD),
|
||||
EntityReference(module="backend.apps.agents.core.models", model="AgentConfig", field="selected_app_output_ids", target=EntityKind.OUTPUT),
|
||||
EntityReference(module="backend.apps.agents.core.models", model="AgentConfig", field="workflow_edit_id", target=EntityKind.WORKFLOW),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""The store must remember what it installed across restarts, and survive a bad file."""
|
||||
|
||||
import json
|
||||
|
||||
from backend.apps.marketplace.installs import InstallRecord, load_installs, record_install
|
||||
|
||||
|
||||
def test_a_recorded_install_round_trips(tmp_path):
|
||||
p = str(tmp_path / "installs.json")
|
||||
record_install(InstallRecord(listing_id="git-graph", root_type="app", output_id="out-1", version="1.0.0"), p)
|
||||
record_install(InstallRecord(listing_id="hello", root_type="skill", skill_id="sk-1", version="1.0.0"), p)
|
||||
got = load_installs(p)
|
||||
assert set(got) == {"git-graph", "hello"}
|
||||
assert got["git-graph"].output_id == "out-1" and got["git-graph"].installed_at > 0
|
||||
|
||||
|
||||
def test_reinstalling_replaces_the_record_for_that_listing(tmp_path):
|
||||
p = str(tmp_path / "installs.json")
|
||||
record_install(InstallRecord(listing_id="git-graph", root_type="app", output_id="out-1", version="1.0.0"), p)
|
||||
record_install(InstallRecord(listing_id="git-graph", root_type="app", output_id="out-2", version="1.1.0"), p)
|
||||
got = load_installs(p)
|
||||
assert len(got) == 1 and got["git-graph"].output_id == "out-2" and got["git-graph"].version == "1.1.0"
|
||||
|
||||
|
||||
def test_a_missing_or_corrupt_file_reads_as_nothing_installed(tmp_path):
|
||||
p = str(tmp_path / "installs.json")
|
||||
assert load_installs(p) == {}
|
||||
(tmp_path / "installs.json").write_text("{not json")
|
||||
assert load_installs(p) == {}
|
||||
(tmp_path / "installs.json").write_text(json.dumps({"x": {"listing_id": "x"}}))
|
||||
assert load_installs(p) == {}
|
||||
Reference in New Issue
Block a user