[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))
+12
View File
@@ -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) == {}