[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:
ciregenz
2026-09-03 09:14:31 -07:00
co-authored by Claude Fable 5.1
parent 2afbdc5b05
commit bc1e7acac9
12 changed files with 338 additions and 50 deletions
+70
View File
@@ -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
+19
View File
@@ -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))