From bc1e7acac9932d2f8dc4e38b6cd0bfe6d2f0d3a7 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 3 Sep 2026 09:14:31 -0700 Subject: [PATCH] [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 Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT --- backend/apps/marketplace/installs.py | 70 +++++++++++++++++++ backend/apps/marketplace/marketplace.py | 19 +++++ backend/config/entity_references.py | 12 ++++ backend/tests/test_marketplace_installs.py | 31 ++++++++ .../pages/Directory/DirectoryPackagesTab.tsx | 53 +++++++++++--- .../app/pages/Directory/MarketplaceBody.tsx | 2 +- .../pages/Directory/packages/InstallPill.tsx | 57 +++++++++++++++ .../pages/Directory/packages/PackageCard.tsx | 8 ++- .../packages/detail/PackageBundleDialog.tsx | 30 +++----- .../packages/detail/PackageDialog.tsx | 22 ++---- .../Directory/packages/installState.test.ts | 24 +++++++ .../app/pages/Directory/packages/installs.ts | 60 ++++++++++++++++ 12 files changed, 338 insertions(+), 50 deletions(-) create mode 100644 backend/apps/marketplace/installs.py create mode 100644 backend/tests/test_marketplace_installs.py create mode 100644 frontend/src/app/pages/Directory/packages/InstallPill.tsx create mode 100644 frontend/src/app/pages/Directory/packages/installState.test.ts create mode 100644 frontend/src/app/pages/Directory/packages/installs.ts diff --git a/backend/apps/marketplace/installs.py b/backend/apps/marketplace/installs.py new file mode 100644 index 00000000..f5cf7af3 --- /dev/null +++ b/backend/apps/marketplace/installs.py @@ -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 diff --git a/backend/apps/marketplace/marketplace.py b/backend/apps/marketplace/marketplace.py index 2987d6ea..4f53574f 100644 --- a/backend/apps/marketplace/marketplace.py +++ b/backend/apps/marketplace/marketplace.py @@ -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)) diff --git a/backend/config/entity_references.py b/backend/config/entity_references.py index f48a752c..4d560363 100644 --- a/backend/config/entity_references.py +++ b/backend/config/entity_references.py @@ -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), diff --git a/backend/tests/test_marketplace_installs.py b/backend/tests/test_marketplace_installs.py new file mode 100644 index 00000000..e9ba41de --- /dev/null +++ b/backend/tests/test_marketplace_installs.py @@ -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) == {} diff --git a/frontend/src/app/pages/Directory/DirectoryPackagesTab.tsx b/frontend/src/app/pages/Directory/DirectoryPackagesTab.tsx index c9fff06e..aa54bd54 100644 --- a/frontend/src/app/pages/Directory/DirectoryPackagesTab.tsx +++ b/frontend/src/app/pages/Directory/DirectoryPackagesTab.tsx @@ -11,6 +11,7 @@ import { fetchMarketplaceListings } from '@/shared/state/marketplaceCatalogSlice import { fetchSkills } from '@/shared/state/skillsSlice'; import { fetchOutputs } from '@/shared/state/outputsSlice'; import { fetchWorkflows } from '@/shared/state/workflowsSlice'; +import { addViewCard, openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice'; import ImportModal from '@/app/components/share/ImportModal'; import { importNeedsConfirm } from '@/app/components/share/importNeedsConfirm'; import { importCommit } from '@/app/components/share/shareApi'; @@ -21,6 +22,7 @@ import PackageDialog from './packages/detail/PackageDialog'; import PackageBundleCard from './packages/PackageBundleCard'; import PackageBundleDialog from './packages/detail/PackageBundleDialog'; import { stagePackageInstall } from './packages/installPackage'; +import { fetchInstalls, installState, recordFor, recordInstall, type InstallRecord, type PillState } from './packages/installs'; import { KIND_LABELS, isBundle, resolveBundleMembers, type Listing } from './packages/catalog'; type Toast = { message: string; severity: 'success' | 'error' } | null; @@ -28,25 +30,47 @@ type Toast = { message: string; severity: 'success' | 'error' } | null; // The store tab: packages published to the marketplace sheet. Install downloads the .swarm and hands // it to the ordinary bundle import, so what a package can do to this machine is reviewed the same way // a dropped file is. -const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void }> = ({ onInstalled }) => { +const DirectoryPackagesTab: React.FC<{ onOpenSkill?: (skillId: string) => void }> = ({ onOpenSkill }) => { const c = useClaudeTokens(); const dispatch = useAppDispatch(); const { listings, loading, loaded, source, error } = useAppSelector((s) => s.marketplaceCatalog); + const outputs = useAppSelector((s) => s.outputs.items); + const skills = useAppSelector((s) => s.skills.items); + const workflows = useAppSelector((s) => s.workflows.items); const [query, setQuery] = useState(''); const [kinds, setKinds] = useState([]); const [sort, setSort] = useState('newest'); const [openListing, setOpenListing] = useState(null); const [openBundle, setOpenBundle] = useState(null); const [installingId, setInstallingId] = useState(null); - const [installedIds, setInstalledIds] = useState([]); + const [installs, setInstalls] = useState>({}); const [confirm, setConfirm] = useState<{ preflight: ImportPreflight; listingId: string } | null>(null); const [committing, setCommitting] = useState(false); const [toast, setToast] = useState(null); useEffect(() => { dispatch(fetchMarketplaceListings(false)); + // The live lists decide whether Open still has something to open; the record alone only says Get is done. + dispatch(fetchOutputs()); + dispatch(fetchSkills()); + dispatch(fetchWorkflows(undefined)); + let alive = true; + fetchInstalls().then((m) => { if (alive) setInstalls(m); }).catch(() => {}); + return () => { alive = false; }; }, [dispatch]); + const stateFor = (listing: Listing): PillState => ( + installingId === listing.id ? 'installing' : installState(listing, installs[listing.id], { outputs, skills, workflows }) + ); + + const openInstalled = (listing: Listing) => { + const rec = installs[listing.id]; + if (!rec) return; + if (rec.output_id) dispatch(addViewCard({ outputId: rec.output_id })); + else if (rec.workflow_id) dispatch(openWorkflowsApp({ workflowId: rec.workflow_id })); + else if (rec.skill_id) onOpenSkill?.(rec.skill_id); + }; + const packages = useMemo(() => listings.filter((l) => !isBundle(l)), [listings]); const bundles = useMemo(() => listings.filter(isBundle), [listings]); const kindOptions = useMemo( @@ -75,21 +99,26 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void return bundles.filter((b) => matchesQuery(b, q)); }, [bundles, query, kinds]); - const finish = (preflight: ImportPreflight, listingId: string, rootType: string) => { - setInstalledIds((prev) => (prev.includes(listingId) ? prev : [...prev, listingId])); - setToast({ message: `Added ${preflight.summary.root.name}`, severity: 'success' }); + // The pill turning into Open is the feedback, the way the App Store does it: no toast, no tab jump. + const finish = async (listingId: string, rootType: string, rootId: string) => { + const version = listings.find((l) => l.id === listingId)?.version ?? ''; // Nothing else refetches these on import, so an installed package would otherwise stay invisible. if (rootType === 'skill') dispatch(fetchSkills()); if (rootType === 'app') dispatch(fetchOutputs()); if (rootType === 'workflow') dispatch(fetchWorkflows(undefined)); - onInstalled?.(rootType); + try { + setInstalls(await recordInstall(recordFor(listingId, rootType, rootId, version))); + } catch (e: unknown) { + setInstalls((prev) => ({ ...prev, [listingId]: { ...recordFor(listingId, rootType, rootId, version), installed_at: Date.now() / 1000 } })); + setToast({ message: e instanceof Error ? e.message : "Installed, but the store couldn't remember it.", severity: 'error' }); + } }; const commit = async (preflight: ImportPreflight, listingId: string) => { setCommitting(true); try { const res = await importCommit(preflight.staging_token); - finish(preflight, listingId, res.root_type); + await finish(listingId, res.root_type, res.root_id); setConfirm(null); } catch (e: unknown) { setToast({ message: e instanceof Error ? e.message : "We couldn't finish the install.", severity: 'error' }); @@ -183,7 +212,10 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void setOpenListing(listing)} + onGet={() => { void install(listing.id); }} + onOpenInstalled={() => openInstalled(listing)} onTag={(tag) => setQuery(tag)} /> ))} @@ -224,15 +256,16 @@ const DirectoryPackagesTab: React.FC<{ onInstalled?: (rootType: string) => void {body()} { if (openListing) void install(openListing.id); }} + onOpen={() => { if (openListing) openInstalled(openListing); }} onClose={() => setOpenListing(null)} /> { const l = listings.find((x) => x.id === id); return l ? stateFor(l) : 'get'; }} + onOpenInstalled={(member) => openInstalled(member)} installing={installingId !== null} onInstallAll={() => { if (openBundle) void installBundle(openBundle); }} onInstallMember={(id) => { void install(id); }} diff --git a/frontend/src/app/pages/Directory/MarketplaceBody.tsx b/frontend/src/app/pages/Directory/MarketplaceBody.tsx index 522153b1..f7fc67a9 100644 --- a/frontend/src/app/pages/Directory/MarketplaceBody.tsx +++ b/frontend/src/app/pages/Directory/MarketplaceBody.tsx @@ -66,7 +66,7 @@ const MarketplaceBody: React.FC = () => { const content = (): React.ReactElement => { switch (view) { case 'packages': - return { if (rootType === 'skill') { setFocusSkillId(null); setView('my-skills'); } }} />; + return { setFocusSkillId(id); setView('my-skills'); }} />; case 'connectors': return { setFocusToolId(id); setView('my-connectors'); }} />; case 'my-skills': diff --git a/frontend/src/app/pages/Directory/packages/InstallPill.tsx b/frontend/src/app/pages/Directory/packages/InstallPill.tsx new file mode 100644 index 00000000..5a52693a --- /dev/null +++ b/frontend/src/app/pages/Directory/packages/InstallPill.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import type { PillState } from './installs'; + +interface Props { + state: PillState; + disabled?: boolean; + onGet: () => void; + onOpen: () => void; + size?: 'sm' | 'md'; +} + +// The one action a package has, the way the App Store draws it: Get, a spinner while it lands, then Open. It swallows the click so a card underneath never opens its sheet. +export default function InstallPill({ state, disabled, onGet, onOpen, size = 'md' }: Props) { + const c = useClaudeTokens(); + const sm = size === 'sm'; + const base = { + borderRadius: `${c.radius.full}px`, + textTransform: 'none' as const, + fontWeight: 650, + fontSize: sm ? '0.75rem' : '0.8125rem', + px: sm ? 1.75 : 2.25, + py: sm ? 0.35 : 0.6, + minWidth: sm ? 58 : 72, + lineHeight: 1.5, + flexShrink: 0, + }; + const stop = (e: React.MouseEvent) => { e.stopPropagation(); }; + if (state === 'installed') { + return ( + + ); + } + if (state === 'open') { + return ( + + ); + } + return ( + + ); +} diff --git a/frontend/src/app/pages/Directory/packages/PackageCard.tsx b/frontend/src/app/pages/Directory/packages/PackageCard.tsx index 1b5e0e66..3f7697c4 100644 --- a/frontend/src/app/pages/Directory/packages/PackageCard.tsx +++ b/frontend/src/app/pages/Directory/packages/PackageCard.tsx @@ -6,14 +6,19 @@ import ExtensionIcon from '@mui/icons-material/Extension'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { parseTags, KIND_LABELS, type Listing } from './catalog'; import PackageTagRow from './detail/PackageTagRow'; +import InstallPill from './InstallPill'; +import type { PillState } from './installs'; interface Props { listing: Listing; + state: PillState; onOpen: () => void; + onGet: () => void; + onOpenInstalled: () => void; onTag: (tag: string) => void; } -export default function PackageCard({ listing, onOpen, onTag }: Props) { +export default function PackageCard({ listing, state, onOpen, onGet, onOpenInstalled, onTag }: Props) { const c = useClaudeTokens(); const tags = parseTags(listing.tags).slice(0, 3); @@ -73,6 +78,7 @@ export default function PackageCard({ listing, onOpen, onTag }: Props) { {listing.author ? ` · ${listing.author}` : ''} + PillState; + onOpenInstalled: (listing: Listing) => void; onClose: () => void; onOpenMember: (member: Listing) => void; onInstallAll: () => void; @@ -28,11 +30,11 @@ interface Props { // A bundle has no package of its own: the sheet lists it, the dialog installs its members. There is no // file to download here on purpose; a store page installs, it does not hand out archives. -export default function PackageBundleDialog({ bundle, members, installedIds, onClose, onOpenMember, onInstallAll, onInstallMember, installing }: Props) { +export default function PackageBundleDialog({ bundle, members, stateOf, onOpenInstalled, onClose, onOpenMember, onInstallAll, onInstallMember, installing }: Props) { const c = useClaudeTokens(); if (!bundle) return null; const installable = members.filter((m) => m.download_url); - const allInstalled = installable.length > 0 && installable.every((m) => installedIds.includes(m.id)); + const allInstalled = installable.length > 0 && installable.every((m) => stateOf(m.id) !== 'get'); const pill = { borderRadius: `${c.radius.full}px`, textTransform: 'none' as const, fontWeight: 600, fontSize: '0.8125rem', px: 2, py: 0.6, minWidth: 0, whiteSpace: 'nowrap' as const }; return ( @@ -75,7 +77,7 @@ export default function PackageBundleDialog({ bundle, members, installedIds, onC disableElevation sx={{ ...pill, bgcolor: c.accent.primary, color: c.text.inverse, '&:hover': { bgcolor: c.accent.hover }, '&.Mui-disabled': { bgcolor: c.bg.secondary, color: c.text.muted } }} > - {installing ? : allInstalled ? 'Installed' : 'Install all'} + {installing ? : allInstalled ? 'Installed' : 'Get all'} @@ -94,7 +96,6 @@ export default function PackageBundleDialog({ bundle, members, installedIds, onC ) : ( {members.map((m) => { - const done = installedIds.includes(m.id); return ( - {done ? ( - - - Installed - - ) : ( - - )} + onInstallMember(m.id)} onOpen={() => onOpenInstalled(m)} size="sm" /> ); })} diff --git a/frontend/src/app/pages/Directory/packages/detail/PackageDialog.tsx b/frontend/src/app/pages/Directory/packages/detail/PackageDialog.tsx index e8e60339..721785bb 100644 --- a/frontend/src/app/pages/Directory/packages/detail/PackageDialog.tsx +++ b/frontend/src/app/pages/Directory/packages/detail/PackageDialog.tsx @@ -1,13 +1,10 @@ import React from 'react'; import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import CircularProgress from '@mui/material/CircularProgress'; import Dialog from '@mui/material/Dialog'; import DialogContent from '@mui/material/DialogContent'; import IconButton from '@mui/material/IconButton'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; -import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; import CloseIcon from '@mui/icons-material/Close'; import ExtensionIcon from '@mui/icons-material/Extension'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; @@ -15,19 +12,21 @@ import { KIND_LABELS, parseTags, type Listing } from '../catalog'; import { detailsForListing } from '../notionDetails'; import PackageDetails from './PackageDetails'; import PackageTagRow from './PackageTagRow'; +import InstallPill from '../InstallPill'; +import type { PillState } from '../installs'; import PackageVideoSection from './PackageVideoSection'; interface Props { listing: Listing | null; onClose: () => void; + state: PillState; onInstall: () => void; - installing: boolean; - installed?: boolean; + onOpen: () => void; } // The package sheet: one Install action, no file to download. The bundle is fetched and reviewed by the // same import path a dropped .swarm takes, so the raw archive has no job on a store page. -export default function PackageDialog({ listing, onClose, onInstall, installing, installed }: Props) { +export default function PackageDialog({ listing, onClose, state, onInstall, onOpen }: Props) { const c = useClaudeTokens(); if (!listing) return null; const details = detailsForListing(listing); @@ -70,16 +69,7 @@ export default function PackageDialog({ listing, onClose, onInstall, installing, {meta} - + {listing.description && ( diff --git a/frontend/src/app/pages/Directory/packages/installState.test.ts b/frontend/src/app/pages/Directory/packages/installState.test.ts new file mode 100644 index 00000000..c88e7f5a --- /dev/null +++ b/frontend/src/app/pages/Directory/packages/installState.test.ts @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { installState } from './installs'; +import type { Listing } from './catalog'; + +// The store's one button: Get until the machine remembers an install, Open while the thing it became still exists. +const listing = { id: 'git-graph', kind: 'app' } as Listing; +const rec = { listing_id: 'git-graph', root_type: 'app', output_id: 'out-1', version: '1.0.0', installed_at: 1 }; +const none = { outputs: {}, skills: {}, workflows: {} }; + +test('no record means Get', () => { + assert.equal(installState(listing, undefined, none), 'get'); +}); + +test('a recorded app is Open while its output exists, and Get again once it is gone', () => { + assert.equal(installState(listing, rec, { ...none, outputs: { 'out-1': {} } }), 'open'); + assert.equal(installState(listing, rec, none), 'get'); +}); + +test('skills and workflows open through their own lists; other kinds just read Installed', () => { + assert.equal(installState(listing, { listing_id: 'x', root_type: 'skill', skill_id: 'sk', version: '', installed_at: 1 }, { ...none, skills: { sk: {} } }), 'open'); + assert.equal(installState(listing, { listing_id: 'x', root_type: 'workflow', workflow_id: 'wf', version: '', installed_at: 1 }, { ...none, workflows: { wf: {} } }), 'open'); + assert.equal(installState(listing, { listing_id: 'x', root_type: 'mode', session_id: 'm', version: '', installed_at: 1 }, none), 'installed'); +}); diff --git a/frontend/src/app/pages/Directory/packages/installs.ts b/frontend/src/app/pages/Directory/packages/installs.ts new file mode 100644 index 00000000..89fd0324 --- /dev/null +++ b/frontend/src/app/pages/Directory/packages/installs.ts @@ -0,0 +1,60 @@ +import { API_BASE } from '@/shared/config'; +import type { Listing } from './catalog'; + +// One typed pointer per importable root, mirroring the backend record. +export interface InstallRecord { + listing_id: string; + root_type: string; + output_id?: string | null; + skill_id?: string | null; + workflow_id?: string | null; + dashboard_id?: string | null; + session_id?: string | null; + version: string; + installed_at: number; +} + +export function recordFor(listingId: string, rootType: string, rootId: string, version: string): Omit { + const rec: Omit = { listing_id: listingId, root_type: rootType, version }; + if (rootType === 'app') rec.output_id = rootId; + else if (rootType === 'skill') rec.skill_id = rootId; + else if (rootType === 'workflow') rec.workflow_id = rootId; + else if (rootType === 'dashboard') rec.dashboard_id = rootId; + else if (rootType === 'session' || rootType === 'mode') rec.session_id = rootId; + return rec; +} + +export type PillState = 'get' | 'installing' | 'open' | 'installed'; + +interface LiveEntities { + outputs: Record; + skills: Record; + workflows: Record; +} + +// Get until the record says otherwise; Open only while the thing it became still exists, so a deleted app offers Get again instead of an Open that goes nowhere. +export function installState(listing: Listing, record: InstallRecord | undefined, live: LiveEntities): PillState { + if (!record) return 'get'; + if (record.output_id) return record.output_id in live.outputs ? 'open' : 'get'; + if (record.skill_id) return record.skill_id in live.skills ? 'open' : 'get'; + if (record.workflow_id) return record.workflow_id in live.workflows ? 'open' : 'get'; + return 'installed'; +} + +export async function fetchInstalls(): Promise> { + const res = await fetch(`${API_BASE}/marketplace/installed`); + if (!res.ok) return {}; + const body = (await res.json()) as { installs: Record }; + return body.installs || {}; +} + +export async function recordInstall(rec: Omit): Promise> { + const res = await fetch(`${API_BASE}/marketplace/installed`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...rec, installed_at: 0 }), + }); + if (!res.ok) throw new Error("Installed, but the store couldn't remember it."); + const body = (await res.json()) as { installs: Record }; + return body.installs || {}; +}