agnes-the-ai-analyst/app/api/my_stack.py
minasarustamyan 9de679c714
System plugins (schema v39) + marketplace UX polish + drop legacy pages (#241)
* System plugin tier with mark/unmark fanout (schema v39)

Adds a mandatory plugin tier so admins can pin a small set of curated
plugins into every user's stack from day one. Marking a plugin via the
new toggle on /admin/marketplaces materializes resource_grants for every
group and user_plugin_optouts subscriptions for every user, so the
existing resolver pulls the plugin into every served set without a new
filter layer. Hooks on user-create (Google OAuth, magic-link, admin
POST, scheduler) and group-create propagate the same materialization to
new principals. UI locks: /admin/access disables the checkbox with a
SYSTEM pill; /marketplace cards swap the "In stack" green pill for an
amber "Required" badge with shield icon; the plugin detail install
button reads "Required by your org"; /my-ai-stack toggle is disabled.
Bypass paths return 409 (DELETE /api/admin/grants for system grants,
PUT /api/my-stack/curated/.../{enabled:false}, DELETE
/api/marketplace/curated/.../install). Unmark only flips the flag —
materialized rows persist so admins curate cleanup at their leisure
through the now-unlocked /admin/access checkboxes.

* Marketplace UX polish + drop legacy /store and /my-ai-stack pages

Two-part cleanup post-v39:

(1) Page deletion. /store and /my-ai-stack were already replaced by
/marketplace?tab=flea and /marketplace?tab=my respectively, but the
standalone routes lingered. Hard delete in dev mode — no redirects,
stale bookmarks 404. The /store/new upload wizard, the flea
detail/edit pages, the admin queue, and all /api/store/* +
/api/my-stack endpoints (CLI consumers) stay. Internal hardcoded
hrefs in the upload wizard's Cancel button and the advanced-setup
page repointed to the marketplace tabs.

(2) Detail-page install button rework. The single button that morphed
between "+ Add to my stack" and "✓ In your stack" did not
communicate uninstall affordance. The installed state now renders an
inline white status label *before* a separate red-bordered
"✕ Remove from stack" button on the same row, both at identical
height to avoid layout shift. System plugins keep their locked amber
"✓ Required by your org" pill (no Remove button — API refuses 409).
The post-action hint panel now fires on remove too with the title
flipped to "✓ Removed from your stack" — Claude Code needs the same
/update-agnes-plugins refresh either way.

Also: /admin/marketplaces Details modal "Mark as system" toggle
redesigned. The button was near-invisible (matched neutral row
metadata). It's now a balanced amber-toned chip with shield icon
and a structured confirm modal replacing the native confirm() dialog
that summarizes fanout consequences before commit.

* Move stack-hint inside hero with glass-on-gradient styling

The post-action hint card ("✓ Added to your stack" with the
/update-agnes-plugins recipe) used to live below the hero in
panel-what (gray card on white page body). Clicking add/remove
inserted/removed it between the hero and content, shifting the
panels below — a noticeable scroll jump.

The hint is now anchored inside the hero's top-right corner alongside
the install/remove buttons, both as flex children of an absolutely
positioned .actions container. The card uses a translucent
white-on-glass treatment that adopts the hero's kind color (blue for
plugin, green for skill, purple for agent) without per-kind branching.
Hero is always tall enough (160px photo) to contain the action+hint
stack without overflow, so toggling the hint visibility doesn't grow
the hero or shift body content.

The hero-head grid reserves a third 300px column for the absolute
actions overlay so meta gets the proper 1fr free space instead of
being squeezed by a padding-right hack. Responsive breakpoint at
1100px reflows the actions stack below hero-head when the viewport
isn't wide enough to keep meta + actions side-by-side comfortably.

* Add optional -DataPath bind mount to run-local-dev.ps1

When the operator wants to inspect DuckDB files (system.duckdb, extracts,
marketplaces, store/, …) directly from Windows Explorer, the named volume
inside the Docker Desktop WSL VM isn't reachable. The new -DataPath param
generates a transient compose override that rebinds /data on app, scheduler,
extract (and Caddy's /srv:ro mirror) to a Windows host folder.

Fully additive — when -DataPath is omitted everything behaves exactly as
before: no override file is generated, $composeFiles array is unchanged,
finally cleanup is a no-op. Existing positional invocations
(.\run-local-dev.ps1 up | down | logs) keep binding to $Action because
$DataPath is a named-only parameter with no Position attribute.

The override is written via [System.IO.File]::WriteAllText so the YAML is
BOM-less across PS 5.1 / 7+ — Compose rejects BOM-prefixed YAML on Windows.
The override file is unique per PID and removed in the script's finally
block so concurrent invocations and crashes don't leak files.

* factor mark_system fanout into UserCuratedSubscriptionsRepository

The endpoint imported UserCuratedSubscriptionsRepository, ignored it
(noqa: F841), then duplicated the user-side fanout SQL inline. Adds
fanout_system_for_plugin() symmetric to the existing
fanout_system_for_user() and routes mark_plugin_system through it —
removes the dead import + 14 lines of inline SQL, returns the same
`affected_users` delta count, no behavior change.

* drop customer-specific path from .ps1 example

Per CLAUDE.md vendor-agnostic OSS rule: replaced
C:\\Business\\Groupon\\Agnes\\agnes-data with the generic
C:\\Users\\<you>\\agnes-data placeholder so the docstring
example reads cleanly on any reviewer's box.

* release: 0.48.0 + parallelize Release-workflow pytest

Cuts the release shipped via #228 #230 #231 #232 #233 #234 #236 #237 #238
#239 #240 plus this PR (#241). Major changes:

- System plugin tier (schema v39) — admins mark a plugin mandatory; fans
  out RBAC grants + subscriptions to every existing user/group plus
  hooks for new principals
- BREAKING: removed standalone /store + /my-ai-stack page routes
  (replaced by /marketplace?tab=flea + /marketplace?tab=my)
- Setup-prompt + bootstrap recovery fixes (#240)
- DuckDB CHECKPOINT-on-shutdown + 60s compose grace (#235)
- Marketplace + flea-market UX polish, agnes-metadata.json enrichment

Bonus: switch release.yml test step to `-n auto` (matches ci.yml).
Single-threaded was 15-20 min and frequently the bottleneck on PR
mergeability — now ~6 min.

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
2026-05-10 19:15:41 +00:00

252 lines
8.8 KiB
Python

"""Per-user composition view of the served Claude Code marketplace.
Provides:
* ``GET /api/my-stack`` — combined view
* ``PUT /api/my-stack/curated/{marketplace_id}/{plugin}`` — toggle opt-out
Used by the ``agnes my-stack`` CLI subcommand. The web page that historically
backed these endpoints (``/my-ai-stack``) was removed in favor of
``/marketplace?tab=my``, but the API stays as the public CLI surface. Both
endpoints touch the same caches as the Store endpoints (ETag invalidation) so
any change here propagates to ``/marketplace.zip`` + ``/marketplace.git/`` on
the next request.
"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from typing import Any, List, Optional
import duckdb
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from app.auth.dependencies import _get_db, get_current_user
from src.marketplace_filter import resolve_allowed_plugins
from src.repositories.audit import AuditRepository
from src.repositories.store_entities import StoreEntitiesRepository
from src.repositories.user_curated_subscriptions import (
UserCuratedSubscriptionsRepository,
)
from src.repositories.user_store_installs import UserStoreInstallsRepository
from src.store_naming import suffixed_name
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/my-stack", tags=["my-stack"])
class CuratedPlugin(BaseModel):
marketplace_id: str
marketplace_slug: str
plugin_name: str
manifest_name: str
description: Optional[str] = None
version: Optional[str] = None
enabled: bool
# v39: when TRUE, the user cannot unsubscribe (UI disables the
# toggle, API guard returns 409). Pre-subscribed by mark_system +
# creation hooks so ``enabled`` is always TRUE here.
is_system: bool = False
class StoreInstallEntry(BaseModel):
entity_id: str
type: str
name: str
description: Optional[str] = None
category: Optional[str] = None
version: str
owner_user_id: str
owner_username: str
invocation_name: str
install_count: int
photo_url: Optional[str] = None
installed_at: Optional[str] = None
# v35: surface visibility so my_ai_stack.html can render an
# "Archived by owner" badge on cards whose owner soft-deleted the
# entity. Bundle still serves to existing installs (per
# UserStoreInstallsRepository.list_for_user filter).
visibility_status: Optional[str] = None
class MyStackResponse(BaseModel):
curated: List[CuratedPlugin]
store: List[StoreInstallEntry]
class ToggleRequest(BaseModel):
enabled: bool
class OkResponse(BaseModel):
ok: bool = True
def _to_iso(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, datetime):
return value.isoformat()
return str(value)
def _audit(
conn: duckdb.DuckDBPyConnection,
actor_id: str,
action: str,
target: str,
params: Optional[dict] = None,
) -> None:
try:
AuditRepository(conn).log(
user_id=actor_id, action=action, resource=target, params=params
)
except Exception:
pass
@router.get("", response_model=MyStackResponse)
async def get_my_stack(
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Combined view of admin-curated plugins (with current opt-out state)
and Store entities the caller has installed.
"""
granted = resolve_allowed_plugins(conn, user)
# Model B (v28+): explicit subscriptions decide what's enabled.
# `enabled` mirrors the legacy "not opted_out" UX so the existing toggle
# remains semantically intuitive in the my-stack view.
subs = UserCuratedSubscriptionsRepository(conn).subscribed_set(user["id"])
# v39: surface is_system flag so the template can lock the toggle.
# One round trip — set membership intersection in Python is cheaper
# than joining marketplace_plugins per-row inside resolve_allowed_plugins
# (which is also called from the marketplace_filter / packager hot path).
sys_rows = conn.execute(
"SELECT marketplace_id, name FROM marketplace_plugins "
"WHERE is_system = TRUE",
).fetchall()
system_plugins: set[tuple[str, str]] = {(r[0], r[1]) for r in sys_rows}
curated: List[CuratedPlugin] = []
for p in granted:
key = (p["marketplace_id"], p["original_name"])
is_subscribed = key in subs
is_system = key in system_plugins
curated.append(
CuratedPlugin(
marketplace_id=p["marketplace_id"],
marketplace_slug=p["marketplace_slug"],
plugin_name=p["original_name"],
manifest_name=p["manifest_name"],
description=p["raw"].get("description"),
version=p.get("version"),
enabled=is_subscribed,
is_system=is_system,
)
)
installs = UserStoreInstallsRepository(conn).list_for_user(user["id"])
store_items: List[StoreInstallEntry] = []
from src.store_naming import strip_archive_suffix
for row in installs:
photo_url = (
f"/api/store/entities/{row['id']}/photo" if row.get("photo_path") else None
)
# Display name strips the archive-rename suffix so the user
# sees their installed plugin's original label even after the
# owner archived (and renamed) it. The served ``invocation_name``
# carries the renamed slug since that's what Claude Code's
# `/plugin` lookup will resolve to after the next sync — this
# is the consumer-side rename described in the rename-on-
# archive plan; the My AI Stack card surfaces it via the
# "Archived by owner" badge already.
raw_name = row["name"]
display_name = strip_archive_suffix(raw_name)
store_items.append(
StoreInstallEntry(
entity_id=row["id"],
type=row["type"],
name=display_name,
description=row.get("description"),
category=row.get("category"),
version=row["version"],
owner_user_id=row["owner_user_id"],
owner_username=row["owner_username"],
invocation_name=suffixed_name(raw_name, row["owner_username"]),
install_count=int(row.get("install_count") or 0),
photo_url=photo_url,
installed_at=_to_iso(row.get("installed_at")),
visibility_status=row.get("visibility_status") or "approved",
)
)
return MyStackResponse(curated=curated, store=store_items)
@router.put(
"/curated/{marketplace_id}/{plugin_name}",
response_model=OkResponse,
)
async def toggle_curated(
marketplace_id: str,
plugin_name: str,
body: ToggleRequest,
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Toggle subscribe/unsubscribe for a single admin-granted plugin.
UI thinks in terms of *enabled* (default off in Model B). v28+ the
repository stores *subscribed* rows (presence = enabled in served set);
``enabled=true`` writes a row, ``enabled=false`` removes it.
"""
# Sanity: caller must actually have the plugin granted (otherwise the
# toggle is meaningless and would just leak rows for ungranted plugins).
granted = resolve_allowed_plugins(conn, user)
has_grant = any(
p["marketplace_id"] == marketplace_id and p["original_name"] == plugin_name
for p in granted
)
if not has_grant:
raise HTTPException(status_code=404, detail="grant_not_found")
# v39: system plugins are pinned in every user's stack — refuse the
# unsubscribe path. Subscribe is still allowed (no-op on the
# already-materialized row).
if not body.enabled:
sys_row = conn.execute(
"SELECT is_system FROM marketplace_plugins "
"WHERE marketplace_id = ? AND name = ?",
[marketplace_id, plugin_name],
).fetchone()
if sys_row and bool(sys_row[0]):
raise HTTPException(
status_code=409,
detail="cannot_unsubscribe_system_plugin",
)
repo = UserCuratedSubscriptionsRepository(conn)
if body.enabled:
repo.subscribe(user["id"], marketplace_id, plugin_name)
else:
repo.unsubscribe(user["id"], marketplace_id, plugin_name)
_audit(
conn,
user["id"],
"my_stack.curated.toggle",
f"plugin:{marketplace_id}/{plugin_name}",
{"enabled": body.enabled},
)
try:
from app.marketplace_server import packager
packager.invalidate_etag_cache()
except Exception:
logger.exception("failed to invalidate marketplace etag cache")
return OkResponse()