make store DB relations and queries more logical and efficient

This commit is contained in:
Reinier van der Leer
2026-02-28 13:32:43 +01:00
parent b0f024a78c
commit a26480db31
23 changed files with 262 additions and 241 deletions

View File

@@ -1005,8 +1005,8 @@ class MarketplaceAgentDetails(MarketplaceAgent):
image_urls=agent.agent_image,
video_url=agent.agent_video,
versions=agent.versions,
agent_graph_versions=agent.agentGraphVersions,
agent_graph_id=agent.agentGraphId,
agent_graph_id=agent.graph_id,
agent_graph_versions=agent.graph_versions,
last_updated=agent.last_updated,
)

View File

@@ -128,9 +128,11 @@ async def list_library_agents(
}
where_clause["AgentGraph"] = {
"is": {
"StoreListings": {
"some" if published else "none": active_listing_filter,
}
"StoreListing": (
{"is": active_listing_filter}
if published
else {"is_not": active_listing_filter}
)
}
}
@@ -276,32 +278,12 @@ async def get_library_agent(id: str, user_id: str) -> library_model.LibraryAgent
"userId": user_id,
"isDeleted": False,
},
include=library_agent_include(user_id),
include=library_agent_include(user_id, include_store_listing=True),
)
if not library_agent:
raise NotFoundError(f"Library agent #{id} not found")
# Fetch marketplace listing if the agent has been published
store_listing = None
profile = None
if library_agent.AgentGraph:
store_listing = await prisma.models.StoreListing.prisma().find_first(
where={
"agentGraphId": library_agent.AgentGraph.id,
"isDeleted": False,
"hasApprovedVersion": True,
},
include={
"ActiveVersion": True,
},
)
if store_listing and store_listing.ActiveVersion and store_listing.owningUserId:
# Fetch Profile separately since User doesn't have a direct Profile relation
profile = await prisma.models.Profile.prisma().find_first(
where={"userId": store_listing.owningUserId}
)
return library_model.LibraryAgent.from_db(
library_agent,
sub_graphs=(
@@ -309,8 +291,6 @@ async def get_library_agent(id: str, user_id: str) -> library_model.LibraryAgent
if library_agent.AgentGraph
else None
),
store_listing=store_listing,
profile=profile,
)
@@ -459,7 +439,7 @@ async def create_library_agent(
},
settings=SafeJson(
GraphSettings.from_graph(
graph_entry,
# graph_entry,
hitl_safe_mode=hitl_safe_mode,
sensitive_action_safe_mode=sensitive_action_safe_mode,
).model_dump()
@@ -611,21 +591,22 @@ async def update_library_agent_version_and_settings(
user_id: str, agent_graph: graph_db.GraphModel
) -> library_model.LibraryAgent:
"""Update library agent to point to new graph version and sync settings."""
library = await update_agent_version_in_library(
library_agent = await update_agent_version_in_library(
user_id, agent_graph.id, agent_graph.version
)
updated_settings = GraphSettings.from_graph(
graph=agent_graph,
hitl_safe_mode=library.settings.human_in_the_loop_safe_mode,
sensitive_action_safe_mode=library.settings.sensitive_action_safe_mode,
)
if updated_settings != library.settings:
library = await update_library_agent(
library_agent_id=library.id,
user_id=user_id,
settings=updated_settings,
)
return library
# FIXME: GraphSettings.from_graph(graph) is currently no-op, so this does nothing ⬇️
# updated_settings = GraphSettings.from_graph(
# graph=agent_graph,
# hitl_safe_mode=library_agent.settings.human_in_the_loop_safe_mode,
# sensitive_action_safe_mode=library_agent.settings.sensitive_action_safe_mode,
# )
# if updated_settings != library_agent.settings:
# library_agent = await update_library_agent(
# library_agent_id=library_agent.id,
# user_id=user_id,
# settings=updated_settings,
# )
return library_agent
async def update_library_agent(
@@ -827,7 +808,7 @@ async def add_store_agent_to_library(
Args:
store_listing_version_id: The ID of the store listing version containing the agent.
user_id: The users library to which the agent is being added.
user_id: The user's library to which the agent is being added.
Returns:
The newly created LibraryAgent if successfully added, the existing corresponding one if any.
@@ -843,7 +824,7 @@ async def add_store_agent_to_library(
store_listing_version = (
await prisma.models.StoreListingVersion.prisma().find_unique(
where={"id": store_listing_version_id}, include={"AgentGraph": True}
where={"id": store_listing_version_id}
)
)
if not store_listing_version or not store_listing_version.AgentGraph:
@@ -852,23 +833,12 @@ async def add_store_agent_to_library(
f"Store listing version {store_listing_version_id} not found or invalid"
)
graph = store_listing_version.AgentGraph
# Convert to GraphModel to check for HITL blocks
graph_model = await graph_db.get_graph(
graph_id=graph.id,
version=graph.version,
user_id=user_id,
include_subgraphs=False,
)
if not graph_model:
raise NotFoundError(
f"Graph #{graph.id} v{graph.version} not found or accessible"
)
graph_id = store_listing_version.agentGraphId
graph_version = store_listing_version.agentGraphVersion
# Check if user already has this agent (non-deleted)
if existing := await get_library_agent_by_graph_id(
user_id, graph.id, graph.version
user_id, graph_id, graph_version
):
return existing
@@ -877,8 +847,8 @@ async def add_store_agent_to_library(
where={
"userId_agentGraphId_agentGraphVersion": {
"userId": user_id,
"agentGraphId": graph.id,
"agentGraphVersion": graph.version,
"agentGraphId": graph_id,
"agentGraphVersion": graph_version,
}
},
)
@@ -891,19 +861,19 @@ async def add_store_agent_to_library(
"User": {"connect": {"id": user_id}},
"AgentGraph": {
"connect": {
"graphVersionId": {"id": graph.id, "version": graph.version}
"graphVersionId": {"id": graph_id, "version": graph_version}
}
},
"isCreatedByUser": False,
"useGraphIsActiveVersion": False,
"settings": SafeJson(GraphSettings.from_graph(graph_model).model_dump()),
"settings": SafeJson(GraphSettings.from_graph().model_dump()),
},
include=library_agent_include(
user_id, include_nodes=False, include_executions=False
),
)
logger.debug(
f"Added graph #{graph.id} v{graph.version}"
f"Added graph #{graph_id} v{graph_version}"
f"for store listing version #{store_listing_version.id} "
f"to library for user #{user_id}"
)

View File

@@ -4,7 +4,7 @@ import prisma.enums
import prisma.models
import pytest
import backend.api.features.store.exceptions
import backend.util.exceptions
from backend.data.db import connect
from backend.data.includes import library_agent_include
@@ -218,7 +218,7 @@ async def test_add_agent_to_library_not_found(mocker):
)
# Call function and verify exception
with pytest.raises(backend.api.features.store.exceptions.AgentNotFoundError):
with pytest.raises(backend.util.exceptions.NotFoundError):
await db.add_store_agent_to_library("version123", "test-user")
# Verify mock called correctly

View File

@@ -220,8 +220,6 @@ class LibraryAgent(pydantic.BaseModel):
def from_db(
agent: prisma.models.LibraryAgent,
sub_graphs: Optional[list[prisma.models.AgentGraph]] = None,
store_listing: Optional[prisma.models.StoreListing] = None,
profile: Optional[prisma.models.Profile] = None,
) -> "LibraryAgent":
"""
Factory method that constructs a LibraryAgent from a Prisma LibraryAgent
@@ -306,19 +304,26 @@ class LibraryAgent(pydantic.BaseModel):
can_access_graph = agent.AgentGraph.userId == agent.userId
is_latest_version = True
marketplace_listing_data = None
if store_listing and store_listing.ActiveVersion and profile:
creator_data = MarketplaceListingCreator(
name=profile.name,
id=profile.id,
slug=profile.username,
)
marketplace_listing_data = MarketplaceListing(
store_listing = agent.AgentGraph.StoreListing if agent.AgentGraph else None
active_listing = store_listing.ActiveVersion if store_listing else None
creator_profile = store_listing.CreatorProfile if store_listing else None
marketplace_listing_info = (
MarketplaceListing(
id=store_listing.id,
name=store_listing.ActiveVersion.name,
name=active_listing.name,
slug=store_listing.slug,
creator=creator_data,
creator=MarketplaceListingCreator(
name=creator_profile.name,
id=creator_profile.id,
slug=creator_profile.username,
),
)
if store_listing
and active_listing
and creator_profile
and not store_listing.isDeleted
else None
)
return LibraryAgent(
id=agent.id,
@@ -355,7 +360,7 @@ class LibraryAgent(pydantic.BaseModel):
folder_name=agent.Folder.name if agent.Folder else None,
recommended_schedule_cron=agent.AgentGraph.recommendedScheduleCron,
settings=_parse_settings(agent.settings),
marketplace_listing=marketplace_listing_data,
marketplace_listing=marketplace_listing_info,
)

View File

@@ -24,7 +24,7 @@ from backend.data.notifications import (
NotificationEventModel,
)
from backend.notifications.notifications import queue_notification_async
from backend.util.exceptions import DatabaseError
from backend.util.exceptions import DatabaseError, NotFoundError
from backend.util.settings import Settings
from . import exceptions as store_exceptions
@@ -112,7 +112,7 @@ async def get_store_agents(
description=agent["description"],
runs=agent["runs"],
rating=agent["rating"],
agent_graph_id=agent.get("agentGraphId", ""),
agent_graph_id=agent.get("graph_id", ""),
)
store_agents.append(store_agent)
except Exception as e:
@@ -171,7 +171,7 @@ async def get_store_agents(
description=agent.description,
runs=agent.runs,
rating=agent.rating,
agent_graph_id=agent.agentGraphId,
agent_graph_id=agent.graph_id,
)
# Add to the list only if creation was successful
store_agents.append(store_agent)
@@ -229,66 +229,15 @@ async def get_store_agent_details(
if not agent:
logger.warning(f"Agent not found: {username}/{agent_name}")
raise store_exceptions.AgentNotFoundError(
f"Agent {username}/{agent_name} not found"
)
profile = await prisma.models.Profile.prisma().find_first(
where={"username": username}
)
user_id = profile.userId if profile else None
# Retrieve StoreListing to get active_version_id and has_approved_version
store_listing = await prisma.models.StoreListing.prisma().find_first(
where=prisma.types.StoreListingWhereInput(
slug=agent_name,
owningUserId=user_id or "",
),
include={"ActiveVersion": True},
)
active_version_id = store_listing.activeVersionId if store_listing else None
has_approved_version = (
store_listing.hasApprovedVersion if store_listing else False
)
if active_version_id:
agent_by_active = await prisma.models.StoreAgent.prisma().find_first(
where={"storeListingVersionId": active_version_id}
)
if agent_by_active:
agent = agent_by_active
elif store_listing:
latest_approved = (
await prisma.models.StoreListingVersion.prisma().find_first(
where={
"storeListingId": store_listing.id,
"submissionStatus": prisma.enums.SubmissionStatus.APPROVED,
},
order=[{"version": "desc"}],
)
)
if latest_approved:
agent_latest = await prisma.models.StoreAgent.prisma().find_first(
where={"storeListingVersionId": latest_approved.id}
)
if agent_latest:
agent = agent_latest
if store_listing and store_listing.ActiveVersion:
recommended_schedule_cron = (
store_listing.ActiveVersion.recommendedScheduleCron
)
else:
recommended_schedule_cron = None
raise NotFoundError(f"Agent {username}/{agent_name} not found")
# Fetch changelog data if requested
changelog_data = None
if include_changelog and store_listing:
if include_changelog:
changelog_versions = (
await prisma.models.StoreListingVersion.prisma().find_many(
where={
"storeListingId": store_listing.id,
"storeListingId": agent.listing_id,
"submissionStatus": prisma.enums.SubmissionStatus.APPROVED,
},
order=[{"version": "desc"}],
@@ -305,7 +254,7 @@ async def get_store_agent_details(
logger.debug(f"Found agent details for {username}/{agent_name}")
return store_model.StoreAgentDetails(
store_listing_version_id=agent.storeListingVersionId,
store_listing_version_id=agent.listing_version_id,
slug=agent.slug,
agent_name=agent.agent_name,
agent_video=agent.agent_video or "",
@@ -319,15 +268,15 @@ async def get_store_agent_details(
runs=agent.runs,
rating=agent.rating,
versions=agent.versions,
agentGraphVersions=agent.agentGraphVersions,
agentGraphId=agent.agentGraphId,
active_version_id=agent.listing_version_id, # StoreAgent view is based on active version
graph_id=agent.graph_id,
graph_versions=agent.graph_versions,
last_updated=agent.updated_at,
active_version_id=active_version_id,
has_approved_version=has_approved_version,
recommended_schedule_cron=recommended_schedule_cron,
has_approved_version=True, # already filtered by StoreAgent view
recommended_schedule_cron=agent.recommended_schedule_cron,
changelog=changelog_data,
)
except store_exceptions.AgentNotFoundError:
except NotFoundError:
raise
except Exception as e:
logger.error(f"Error getting store agent details: {e}")
@@ -385,18 +334,16 @@ async def get_store_agent_by_version_id(
try:
agent = await prisma.models.StoreAgent.prisma().find_first(
where={"storeListingVersionId": store_listing_version_id}
where={"listing_version_id": store_listing_version_id}
)
if not agent:
logger.warning(f"Agent not found: {store_listing_version_id}")
raise store_exceptions.AgentNotFoundError(
f"Agent {store_listing_version_id} not found"
)
raise NotFoundError(f"Agent {store_listing_version_id} not found")
logger.debug(f"Found agent details for {store_listing_version_id}")
return store_model.StoreAgentDetails(
store_listing_version_id=agent.storeListingVersionId,
store_listing_version_id=agent.listing_version_id,
slug=agent.slug,
agent_name=agent.agent_name,
agent_video=agent.agent_video or "",
@@ -410,11 +357,11 @@ async def get_store_agent_by_version_id(
runs=agent.runs,
rating=agent.rating,
versions=agent.versions,
agentGraphVersions=agent.agentGraphVersions,
agentGraphId=agent.agentGraphId,
graph_id=agent.graph_id,
graph_versions=agent.graph_versions,
last_updated=agent.updated_at,
)
except store_exceptions.AgentNotFoundError:
except NotFoundError:
raise
except Exception as e:
logger.error(f"Error getting store agent details: {e}")
@@ -752,11 +699,11 @@ async def create_store_submission(
)
# Provide more user-friendly error message when agent_id is empty
if not agent_id or agent_id.strip() == "":
raise store_exceptions.AgentNotFoundError(
raise NotFoundError(
"No agent selected. Please select an agent before submitting to the store."
)
else:
raise store_exceptions.AgentNotFoundError(
raise NotFoundError(
f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {agent_version}"
)
@@ -792,7 +739,6 @@ async def create_store_submission(
data = prisma.types.StoreListingCreateInput(
slug=slug,
agentGraphId=agent_id,
agentGraphVersion=agent_version,
owningUserId=user_id,
createdAt=datetime.now(tz=timezone.utc),
Versions={
@@ -862,7 +808,7 @@ async def create_store_submission(
f"Unique constraint violated (not slug): {error_str}"
) from exc
except (
store_exceptions.AgentNotFoundError,
NotFoundError,
store_exceptions.ListingExistsError,
):
raise
@@ -996,7 +942,7 @@ async def edit_store_submission(
except (
store_exceptions.SubmissionNotFoundError,
store_exceptions.UnauthorizedError,
store_exceptions.AgentNotFoundError,
NotFoundError,
store_exceptions.ListingExistsError,
store_exceptions.InvalidOperationError,
):
@@ -1066,7 +1012,7 @@ async def create_store_version(
)
if not agent:
raise store_exceptions.AgentNotFoundError(
raise NotFoundError(
f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {agent_version}"
)
@@ -1317,8 +1263,8 @@ async def get_my_agents(
"userId": user_id,
"AgentGraph": {
"is": {
"StoreListings": {
"none": {
"StoreListing": {
"is_not": {
"isDeleted": False,
"Versions": {
"some": {
@@ -1424,7 +1370,6 @@ async def _approve_sub_agent(
data=prisma.types.StoreListingCreateInput(
slug=f"sub-agent-{sub_graph.id[:8]}",
agentGraphId=sub_graph.id,
agentGraphVersion=sub_graph.version,
owningUserId=main_agent_user_id,
hasApprovedVersion=True,
Versions={
@@ -1665,7 +1610,7 @@ async def review_store_submission(
if is_approved:
store_agent = (
await prisma.models.StoreAgent.prisma().find_first_or_raise(
where={"storeListingVersionId": submission.id}
where={"listing_version_id": submission.id}
)
)
@@ -1896,7 +1841,6 @@ async def get_admin_listings_with_versions(
listing_id=listing.id,
slug=listing.slug,
agent_id=listing.agentGraphId,
agent_version=listing.agentGraphVersion,
active_version_id=listing.activeVersionId,
has_approved_version=listing.hasApprovedVersion,
creator_email=creator_email,

View File

@@ -26,7 +26,7 @@ async def test_get_store_agents(mocker):
mock_agents = [
prisma.models.StoreAgent(
listing_id="test-id",
storeListingVersionId="version123",
listing_version_id="version123",
slug="test-agent",
agent_name="Test Agent",
agent_video=None,
@@ -40,11 +40,11 @@ async def test_get_store_agents(mocker):
runs=10,
rating=4.5,
versions=["1.0"],
agentGraphVersions=["1"],
agentGraphId="test-graph-id",
graph_id="test-graph-id",
graph_versions=["1"],
updated_at=datetime.now(),
is_available=False,
useForOnboarding=False,
use_for_onboarding=False,
)
]
@@ -71,7 +71,7 @@ async def test_get_store_agent_details(mocker):
# Mock data
mock_agent = prisma.models.StoreAgent(
listing_id="test-id",
storeListingVersionId="version123",
listing_version_id="version123",
slug="test-agent",
agent_name="Test Agent",
agent_video="video.mp4",
@@ -85,17 +85,17 @@ async def test_get_store_agent_details(mocker):
runs=10,
rating=4.5,
versions=["1.0"],
agentGraphVersions=["1"],
agentGraphId="test-graph-id",
graph_id="test-graph-id",
graph_versions=["1"],
updated_at=datetime.now(),
is_available=False,
useForOnboarding=False,
use_for_onboarding=False,
)
# Mock active version agent (what we want to return for active version)
mock_active_agent = prisma.models.StoreAgent(
listing_id="test-id",
storeListingVersionId="active-version-id",
listing_version_id="active-version-id",
slug="test-agent",
agent_name="Test Agent Active",
agent_video="active_video.mp4",
@@ -109,11 +109,11 @@ async def test_get_store_agent_details(mocker):
runs=15,
rating=4.8,
versions=["1.0", "2.0"],
agentGraphVersions=["1", "2"],
agentGraphId="test-graph-id-active",
graph_id="test-graph-id-active",
graph_versions=["1", "2"],
updated_at=datetime.now(),
is_available=True,
useForOnboarding=False,
use_for_onboarding=False,
)
# Create a mock StoreListing result
@@ -129,7 +129,7 @@ async def test_get_store_agent_details(mocker):
# Set up side_effect to return different results for different calls
def mock_find_first_side_effect(*args, **kwargs):
where_clause = kwargs.get("where", {})
if "storeListingVersionId" in where_clause:
if "listing_version_id" in where_clause:
# Second call for active version
return mock_active_agent
else:
@@ -174,7 +174,7 @@ async def test_get_store_agent_details(mocker):
assert calls[0] == mocker.call(
where={"creator_username": "creator", "slug": "test-agent"}
)
assert calls[1] == mocker.call(where={"storeListingVersionId": "active-version-id"})
assert calls[1] == mocker.call(where={"listing_version_id": "active-version-id"})
mock_store_listing_db.return_value.find_first.assert_called_once()
@@ -235,7 +235,6 @@ async def test_create_store_submission(mocker):
hasApprovedVersion=False,
slug="test-agent",
agentGraphId="agent-id",
agentGraphVersion=1,
owningUserId="user-id",
Versions=[
prisma.models.StoreListingVersion(

View File

@@ -57,12 +57,6 @@ class StoreError(ValueError):
pass
class AgentNotFoundError(NotFoundError):
"""Raised when an agent is not found"""
pass
class CreatorNotFoundError(NotFoundError):
"""Raised when a creator is not found"""

View File

@@ -568,7 +568,7 @@ async def hybrid_search(
SELECT uce."contentId" as "storeListingVersionId"
FROM {{schema_prefix}}"UnifiedContentEmbedding" uce
INNER JOIN {{schema_prefix}}"StoreAgent" sa
ON uce."contentId" = sa."storeListingVersionId"
ON uce."contentId" = sa.listing_version_id
WHERE uce."contentType" = 'STORE_AGENT'::{{schema_prefix}}"ContentType"
AND uce."userId" IS NULL
AND uce.search @@ plainto_tsquery('english', {query_param})
@@ -582,7 +582,7 @@ async def hybrid_search(
SELECT uce."contentId", uce.embedding
FROM {{schema_prefix}}"UnifiedContentEmbedding" uce
INNER JOIN {{schema_prefix}}"StoreAgent" sa
ON uce."contentId" = sa."storeListingVersionId"
ON uce."contentId" = sa.listing_version_id
WHERE uce."contentType" = 'STORE_AGENT'::{{schema_prefix}}"ContentType"
AND uce."userId" IS NULL
AND {where_clause}
@@ -605,7 +605,7 @@ async def hybrid_search(
sa.featured,
sa.is_available,
sa.updated_at,
sa."agentGraphId",
sa.graph_id,
-- Searchable text for BM25 reranking
COALESCE(sa.agent_name, '') || ' ' || COALESCE(sa.sub_heading, '') || ' ' || COALESCE(sa.description, '') as searchable_text,
-- Semantic score
@@ -627,9 +627,9 @@ async def hybrid_search(
sa.runs as popularity_raw
FROM candidates c
INNER JOIN {{schema_prefix}}"StoreAgent" sa
ON c."storeListingVersionId" = sa."storeListingVersionId"
ON c."storeListingVersionId" = sa.listing_version_id
INNER JOIN {{schema_prefix}}"UnifiedContentEmbedding" uce
ON sa."storeListingVersionId" = uce."contentId"
ON sa.listing_version_id = uce."contentId"
AND uce."contentType" = 'STORE_AGENT'::{{schema_prefix}}"ContentType"
),
max_vals AS (
@@ -665,7 +665,7 @@ async def hybrid_search(
featured,
is_available,
updated_at,
"agentGraphId",
graph_id,
searchable_text,
semantic_score,
lexical_score,

View File

@@ -62,8 +62,8 @@ class StoreAgentDetails(pydantic.BaseModel):
runs: int
rating: float
versions: list[str]
agentGraphVersions: list[str]
agentGraphId: str
graph_id: str
graph_versions: list[str]
last_updated: datetime.datetime
recommended_schedule_cron: str | None = None
@@ -150,7 +150,6 @@ class StoreListingWithVersions(pydantic.BaseModel):
listing_id: str
slug: str
agent_id: str
agent_version: int
active_version_id: str | None = None
has_approved_version: bool = False
creator_email: str | None = None

View File

@@ -75,8 +75,8 @@ def test_store_agent_details():
runs=50,
rating=4.5,
versions=["1.0", "2.0"],
agentGraphVersions=["1", "2"],
agentGraphId="test-graph-id",
graph_versions=["1", "2"],
graph_id="test-graph-id",
last_updated=datetime.datetime.now(),
)
assert details.slug == "test-agent"

View File

@@ -380,8 +380,8 @@ def test_get_agent_details(
runs=100,
rating=4.5,
versions=["1.0.0", "1.1.0"],
agentGraphVersions=["1", "2"],
agentGraphId="test-graph-id",
graph_versions=["1", "2"],
graph_id="test-graph-id",
last_updated=FIXED_NOW,
)
mock_db_call = mocker.patch("backend.api.features.store.db.get_store_agent_details")

View File

@@ -160,7 +160,6 @@ async def add_test_data(db):
data={
"slug": f"test-agent-{graph.id[:8]}",
"agentGraphId": graph.id,
"agentGraphVersion": graph.version,
"hasApprovedVersion": True,
"owningUserId": graph.userId,
}

View File

@@ -3,9 +3,9 @@
import logging
from typing import Any
from backend.api.features.store.exceptions import AgentNotFoundError
from backend.copilot.model import ChatSession
from backend.data.db_accessors import store_db as get_store_db
from backend.util.exceptions import NotFoundError
from .agent_generator import (
AgentGeneratorNotConfiguredError,
@@ -140,7 +140,7 @@ class CustomizeAgentTool(BaseTool):
agent_details = await store_db.get_store_agent_details(
username=creator_username, agent_name=agent_slug
)
except AgentNotFoundError:
except NotFoundError:
return ErrorResponse(
message=(
f"Could not find marketplace agent '{agent_id}'. "

View File

@@ -66,7 +66,7 @@ class GraphSettings(BaseModel):
@classmethod
def from_graph(
cls,
graph: "GraphModel",
# graph: "GraphModel", # FIXME: wire up this param
hitl_safe_mode: bool | None = None,
sensitive_action_safe_mode: bool = False,
) -> "GraphSettings":

View File

@@ -89,6 +89,7 @@ def library_agent_include(
user_id: str,
include_nodes: bool = True,
include_executions: bool = True,
include_store_listing: bool = False,
execution_limit: int = MAX_LIBRARY_AGENT_EXECUTIONS_FETCH,
) -> prisma.types.LibraryAgentInclude:
"""
@@ -98,6 +99,7 @@ def library_agent_include(
user_id: User ID for filtering user-specific data
include_nodes: Whether to include graph nodes (default: True, needed for get_sub_graphs)
include_executions: Whether to include executions (default: True, safe with execution_limit)
include_store_listing: Whether to include marketplace listing info (default: False, adds extra joins)
execution_limit: Limit on executions to fetch (default: MAX_LIBRARY_AGENT_EXECUTIONS_FETCH)
Defaults maintain backward compatibility and safety - includes everything needed for all functionality.
@@ -116,7 +118,7 @@ def library_agent_include(
# Build AgentGraph include based on requested options
if include_nodes or include_executions:
agent_graph_include = {}
agent_graph_include: prisma.types.AgentGraphIncludeFromAgentGraph = {}
# Add nodes if requested (always full nodes)
if include_nodes:
@@ -130,12 +132,20 @@ def library_agent_include(
"take": execution_limit,
}
result["AgentGraph"] = cast(
prisma.types.AgentGraphArgsFromLibraryAgent,
{"include": agent_graph_include},
)
result["AgentGraph"] = {"include": agent_graph_include}
else:
# Default: Basic metadata only (fast - recommended for most use cases)
result["AgentGraph"] = True # Basic graph metadata (name, description, id)
if include_store_listing:
if result["AgentGraph"] is True:
result["AgentGraph"] = {"include": {}}
result["AgentGraph"]["include"]["StoreListing"] = {
"include": {
"ActiveVersion": True,
"Creator": True,
},
}
return result

View File

@@ -397,7 +397,7 @@ async def get_recommended_agents(user_id: str) -> list[StoreAgentDetails]:
storeAgents = await prisma.models.StoreAgent.prisma().find_many(
where={
"is_available": True,
"useForOnboarding": True,
"use_for_onboarding": True,
},
order=[
{"featured": "desc"},
@@ -407,7 +407,7 @@ async def get_recommended_agents(user_id: str) -> list[StoreAgentDetails]:
take=100,
)
# If not enough agents found, relax the useForOnboarding filter
# If not enough agents found, relax the use_for_onboarding filter
if len(storeAgents) < 2:
storeAgents = await prisma.models.StoreAgent.prisma().find_many(
where=prisma.types.StoreAgentWhereInput(**where_clause),
@@ -446,8 +446,8 @@ async def get_recommended_agents(user_id: str) -> list[StoreAgentDetails]:
runs=agent.runs,
rating=agent.rating,
versions=agent.versions,
agentGraphVersions=agent.agentGraphVersions,
agentGraphId=agent.agentGraphId,
graph_versions=agent.agentGraphVersions,
graph_id=agent.agentGraphId,
last_updated=agent.updated_at,
)
for agent in recommended_agents

View File

@@ -0,0 +1,71 @@
BEGIN;
DROP VIEW IF EXISTS "StoreAgent";
-- Recreate the StoreAgent view with the following changes:
-- 1. Add `recommendedScheduleCron` column from `StoreListingVersion`
-- 2. Narrow to *explicitly active* version rather than *highest* version
CREATE OR REPLACE VIEW "StoreAgent" AS
WITH store_agent_versions AS (
SELECT
"storeListingId",
array_agg(DISTINCT version::text ORDER BY version::text) AS versions
FROM "StoreListingVersion"
WHERE "submissionStatus" = 'APPROVED'
GROUP BY "storeListingId"
),
agent_graph_versions AS (
SELECT
"storeListingId",
array_agg(DISTINCT "agentGraphVersion"::text ORDER BY "agentGraphVersion"::text) AS graph_versions
FROM "StoreListingVersion"
WHERE "submissionStatus" = 'APPROVED'
GROUP BY "storeListingId"
)
SELECT
sl.id AS listing_id,
slv.id AS listing_version_id,
slv."createdAt" AS updated_at,
sl.slug,
COALESCE(slv.name, '') AS agent_name,
slv."videoUrl" AS agent_video,
slv."agentOutputDemoUrl" AS agent_output_demo,
COALESCE(slv."imageUrls", ARRAY[]::text[]) AS agent_image,
slv."isFeatured" AS featured,
cp.username AS creator_username,
cp."avatarUrl" AS creator_avatar,
slv."subHeading" AS sub_heading,
slv.description,
slv.categories,
COALESCE(arc.run_count, 0::bigint) AS runs,
COALESCE(reviews.avg_rating, 0.0)::double precision AS rating,
COALESCE(sav.versions, ARRAY[slv.version::text]) AS versions,
slv."agentGraphId" AS graph_id,
COALESCE(
agv.graph_versions, ARRAY[slv."agentGraphVersion"::text]
) AS graph_versions,
slv."isAvailable" AS is_available,
COALESCE(sl."useForOnboarding", false) AS use_for_onboarding,
slv."recommendedScheduleCron" AS recommended_schedule_cron
FROM "StoreListing" AS sl
JOIN "StoreListingVersion" AS slv
ON slv."storeListingId" = sl.id
AND slv.id = sl."activeVersionId"
AND slv."submissionStatus" = 'APPROVED'
JOIN "AgentGraph" AS ag
ON slv."agentGraphId" = ag.id
AND slv."agentGraphVersion" = ag.version
LEFT JOIN "Profile" AS cp
ON sl."owningUserId" = cp."userId"
LEFT JOIN "mv_review_stats" AS reviews
ON sl.id = reviews."storeListingId"
LEFT JOIN "mv_agent_run_counts" AS arc
ON ag.id = arc."agentGraphId"
LEFT JOIN store_agent_versions AS sav
ON sl.id = sav."storeListingId"
LEFT JOIN agent_graph_versions AS agv
ON sl.id = agv."storeListingId"
WHERE sl."isDeleted" = false
AND sl."hasApprovedVersion" = true;
COMMIT;

View File

@@ -0,0 +1,34 @@
BEGIN;
-- Drop illogical column StoreListing.agentGraphVersion;
-- Update StoreListing:AgentGraph relation to be 1:+ instead of 1:1 (based on agentGraphId)
ALTER TABLE "StoreListing" DROP CONSTRAINT "StoreListing_agentGraphId_agentGraphVersion_fkey";
DROP INDEX "StoreListing_agentGraphId_agentGraphVersion_idx";
ALTER TABLE "StoreListing" DROP COLUMN "agentGraphVersion";
ALTER TABLE "AgentGraph" ADD CONSTRAINT "AgentGraph_id_fkey" FOREIGN KEY ("id") REFERENCES "StoreListing"("agentGraphId") ON DELETE NO ACTION ON UPDATE CASCADE;
-- Add uniqueness constraint to Profile.userId and remove invalid data
--
-- Delete any profiles with null userId (which is invalid and doesn't occur in theory)
DELETE FROM "Profile" WHERE "userId" IS NULL;
--
-- Delete duplicate profiles per userId, keeping the most recently updated one
DELETE FROM "Profile"
WHERE "id" IN (
SELECT "id" FROM (
SELECT "id", ROW_NUMBER() OVER (
PARTITION BY "userId" ORDER BY "updatedAt" DESC, "id" DESC
) AS rn
FROM "Profile"
) ranked
WHERE rn > 1
);
--
-- Add userId uniqueness constraint
ALTER TABLE "Profile" ALTER COLUMN "userId" SET NOT NULL;
CREATE UNIQUE INDEX "Profile_userId_key" ON "Profile"("userId");
-- Add formal relation StoreListing.owningUserId -> Profile.userId
ALTER TABLE "StoreListing" ADD CONSTRAINT "StoreListing_owner_Profile_fkey" FOREIGN KEY ("owningUserId") REFERENCES "Profile"("userId") ON DELETE CASCADE ON UPDATE CASCADE;
COMMIT;

View File

@@ -281,7 +281,7 @@ model AgentGraph {
Presets AgentPreset[]
LibraryAgents LibraryAgent[]
StoreListings StoreListing[]
StoreListing StoreListing? @relation(fields: [id], references: [agentGraphId], onDelete: NoAction)
StoreListingVersions StoreListingVersion[]
@@id(name: "graphVersionId", [id, version])
@@ -814,10 +814,8 @@ model Profile {
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
// Only 1 of user or group can be set.
// The user this profile belongs to, if any.
userId String?
User User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @unique
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
username String @unique
@@ -830,6 +828,7 @@ model Profile {
isFeatured Boolean @default(false)
LibraryAgents LibraryAgent[]
StoreListings StoreListing[]
@@index([userId])
}
@@ -861,7 +860,7 @@ view Creator {
view StoreAgent {
listing_id String @id
storeListingVersionId String
listing_version_id String
updated_at DateTime
slug String
@@ -879,10 +878,12 @@ view StoreAgent {
runs Int
rating Float
versions String[]
agentGraphVersions String[]
agentGraphId String
graph_id String
graph_versions String[]
is_available Boolean @default(true)
useForOnboarding Boolean @default(false)
use_for_onboarding Boolean @default(false)
recommended_schedule_cron String?
// Materialized views used (refreshed every 15 minutes via pg_cron):
// - mv_agent_run_counts - Pre-aggregated agent execution counts by agentGraphId
@@ -979,22 +980,19 @@ model StoreListing {
ActiveVersion StoreListingVersion? @relation("ActiveVersion", fields: [activeVersionId], references: [id])
// The agent link here is only so we can do lookup on agentId
agentGraphId String
agentGraphVersion Int
AgentGraph AgentGraph @relation(fields: [agentGraphId, agentGraphVersion], references: [id, version], onDelete: Cascade)
agentGraphId String @unique
AgentGraph AgentGraph[]
owningUserId String
OwningUser User @relation(fields: [owningUserId], references: [id])
owningUserId String
OwningUser User @relation(fields: [owningUserId], references: [id])
CreatorProfile Profile @relation(fields: [owningUserId], references: [userId], map: "StoreListing_owner_Profile_fkey", onDelete: Cascade)
// Relations
Versions StoreListingVersion[] @relation("ListingVersions")
// Unique index on agentId to ensure only one listing per agent, regardless of number of versions the agent has.
@@unique([agentGraphId])
@@unique([owningUserId, slug])
// Used in the view query
@@index([isDeleted, hasApprovedVersion])
@@index([agentGraphId, agentGraphVersion])
}
model StoreListingVersion {
@@ -1089,16 +1087,16 @@ model UnifiedContentEmbedding {
// Search data
embedding Unsupported("vector(1536)") // pgvector embedding (extension in platform schema)
searchableText String // Combined text for search and fallback
search Unsupported("tsvector")? @default(dbgenerated("''::tsvector")) // Full-text search (auto-populated by trigger)
search Unsupported("tsvector")? @default(dbgenerated("''::tsvector")) // Full-text search (auto-populated by trigger)
metadata Json @default("{}") // Content-specific metadata
// NO @@index for search - GIN index "UnifiedContentEmbedding_search_idx" created via SQL migration
// Prisma may generate DROP INDEX on migrate dev - that's okay, migration recreates it
@@unique([contentType, contentId, userId], map: "UnifiedContentEmbedding_contentType_contentId_userId_key")
@@index([contentType])
@@index([userId])
@@index([contentType, userId])
@@index([embedding], map: "UnifiedContentEmbedding_embedding_idx")
// NO @@index for search - GIN index "UnifiedContentEmbedding_search_idx" created via SQL migration
// Prisma may generate DROP INDEX on migrate dev - that's okay, migration recreates it
}
model StoreListingReview {

View File

@@ -23,14 +23,14 @@
"1.0.0",
"1.1.0"
],
"agentGraphVersions": [
"graph_id": "test-graph-id",
"graph_versions": [
"1",
"2"
],
"agentGraphId": "test-graph-id",
"last_updated": "2023-01-01T00:00:00",
"recommended_schedule_cron": null,
"active_version_id": null,
"has_approved_version": false,
"changelog": null
}
}

View File

@@ -394,7 +394,6 @@ async def main():
listing = await db.storelisting.create(
data={
"agentGraphId": graph.id,
"agentGraphVersion": graph.version,
"owningUserId": user.id,
"hasApprovedVersion": random.choice([True, False]),
"slug": slug,

View File

@@ -42,8 +42,8 @@ export function AgentVersionChangelog({
// Create version info from available graph versions
const storeData = okData(storeAgentData) as StoreAgentDetails | undefined;
const agentVersions: VersionInfo[] = storeData?.agentGraphVersions
? storeData.agentGraphVersions
const agentVersions: VersionInfo[] = storeData?.graph_versions
? storeData.graph_versions
.map((versionStr: string) => parseInt(versionStr, 10))
.sort((a: number, b: number) => b - a) // Sort descending (newest first)
.map((version: number) => ({

View File

@@ -11658,12 +11658,12 @@
"type": "array",
"title": "Versions"
},
"agentGraphVersions": {
"graph_id": { "type": "string", "title": "Graph Id" },
"graph_versions": {
"items": { "type": "string" },
"type": "array",
"title": "Agentgraphversions"
"title": "Graph Versions"
},
"agentGraphId": { "type": "string", "title": "Agentgraphid" },
"last_updated": {
"type": "string",
"format": "date-time",
@@ -11709,8 +11709,8 @@
"runs",
"rating",
"versions",
"agentGraphVersions",
"agentGraphId",
"graph_id",
"graph_versions",
"last_updated"
],
"title": "StoreAgentDetails"
@@ -11733,7 +11733,6 @@
"listing_id": { "type": "string", "title": "Listing Id" },
"slug": { "type": "string", "title": "Slug" },
"agent_id": { "type": "string", "title": "Agent Id" },
"agent_version": { "type": "integer", "title": "Agent Version" },
"active_version_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Active Version Id"
@@ -11761,7 +11760,7 @@
}
},
"type": "object",
"required": ["listing_id", "slug", "agent_id", "agent_version"],
"required": ["listing_id", "slug", "agent_id"],
"title": "StoreListingWithVersions",
"description": "A store listing with its version history"
},