feat(flea): marketplace refactor — data model, attribution, UI unification (#342)
* feat(flea): phase-1 — title, tagline, synthetic_name columns + upload UX
Schema v49 adds three user-facing metadata columns to store_entities:
- title (NOT NULL) — humanized display name shown on marketplace
surfaces in later phases. Acronym-aware humanizer in
src/store_naming.py (27 entries: MCP, API, OAuth, S3, …) shared
with the frontend via Jinja-injected dict so JS pre-fill and
Python backfill produce identical output.
- tagline (NULL, ≤200 chars) — optional short description for card
listings. Long-form `description` stays.
- synthetic_name (NOT NULL) — deterministic `<name>-by-<owner_username>`
stored as a column for indexing and as the single source of truth
for attribution lookups in later phases. Today's bundle bake still
uses suffixed_name() at the same call sites.
Migration (_v48_to_v49_migrate, Python function — humanize has no
SQL equivalent) backfills existing rows: title from
humanize_name(strip_archive_suffix(name)), synthetic from the concat
formula; tagline stays NULL. Idempotent (ADD COLUMN IF NOT EXISTS +
SET NOT NULL no-op on re-run).
Upload form (store_upload.html step 2) reorders fields: Title
(pre-filled from server-side humanize, JS keeps it in sync until
the user edits manually) → Name + dark synthetic preview on one
row (matches marketplace_item_detail.html dark code styling, no
copy button — preview only) → Short description with character
counter → Description (unchanged). Edit form (store_edit.html)
mirrors the layout with pre-filled values from the entity row.
API:
- POST /api/store/entities/preview returns `title` (humanized
fallback) for upload form pre-fill.
- POST + PUT /api/store/entities accept `title` and `tagline` form
fields with 100/200-char validation; PUT recomputes
synthetic_name when `name` changes (caller responsibility per
repo contract).
- StoreEntityResponse exposes all three new fields.
Repository:
- create() takes title + tagline + synthetic_name as optional
kwargs with derived defaults (humanize_name(name) / concat) so
existing test fixtures don't need to thread them.
- update() supports partial updates on all three; tagline empty
string clears via NULL sentinel.
- archive() recomputes synthetic_name on rename to the archived
slug so the column stays consistent with name.
Tests:
- New test_schema_v48_to_v49_migration.py: fresh install,
populated-row backfill (incl. archived row strip), idempotence,
NOT NULL constraint verification.
- test_store_naming.py: 14 humanize parametrize cases + acronym
dict invariants.
- test_store_api.py::TestStoreV49Metadata: preview humanize, POST
with explicit + fallback title, 100/200-char rejects, PUT
partial update + synthetic recompute on rename.
- Schema version assertion bumps (48 → 49) in test_db_schema_version,
test_home_stats, test_schema_v42_migration, test_schema_v46_migration.
Phase 1 only — surface rendering on cards / detail pages and
Claude Code bundle propagation come in later phases.
* feat(flea): phase-2 — wire title/tagline/owner through marketplace cards + detail pages
Phase 1 (7f4cfcbb) populated the three new columns on store_entities;
phase 2 surfaces them across the web presentation layer so the kebab-
case slug + bare username no longer leak into user-facing copy.
API:
- `_flea_to_item` now takes `conn` (both callsites updated) and sets
`display_name=entity.title`, `tagline=entity.tagline`, `owner=
_resolve_owner_display(conn, owner_user_id, owner_username)` —
matches the chain the curated path already uses (users.name →
users.email → fallback). The card JS chain `it.display_name ||
it.name` then renders the friendly form; `name` stays at the
suffixed slug as the technical identifier JS uses for fallbacks.
- `flea_detail` adds `display_name` + `tagline` to PluginDetailResponse
so the standalone skill/agent + plugin detail heroes pick them up
through the existing `d.display_name` / `d.tagline` chains.
- `_flea_inner_parent_fields` swaps `parent_display_name` from
`strip_archive_suffix(name)` to `entity.title or strip_archive_suffix(
name)`. Drives parent-plugin label in four surfaces at once:
breadcrumb 3rd segment, hero "part of <plugin>" meta-row,
helper "This skill is part of <plugin>" panel, and the Details
sidebar's "Parent plugin" row.
Templates — `marketplace_item_detail.html`:
- Pre-render: browser title, hero h1, and hero-window-label read
`(entity.title if entity else None) or inner_name or item_name or
plugin_name` so the SSR shell shows the friendly title before the
JS fetch lands (no flash of kebab-case).
- Breadcrumb last segment for flea standalone drops the `d.manifest_name
|| heroTitle` fallback in favour of just `heroTitle` — manifest_name
is the suffixed slug and users explicitly didn't want it in the path.
- Hero meta-row for flea standalone is now hidden. The prior "by
<author> · N installed · <size>" line duplicated install count
(hero telemetry chip below), owner + bundle size (Details sidebar).
Templates — `marketplace_plugin_detail.html`:
- Same SSR pre-render swap (title, h1, window-label, crumb-name).
- Hero tagline element starts hidden; JS shows it only when
`d.tagline` is truthy. Pre-fix it fell back to `d.description`
(long-form text), which read awkwardly under the h1 and pulled the
hero too tall. Description still renders in the "What it does"
panel below the hero.
- Initial "Loading…" placeholder removed so entities without a
tagline don't flash that text mid-fetch.
Tests:
- New `TestFleaPhase2Presentation` class in test_marketplace_api.py
(6 cases): card title + tagline + full-name owner, owner fallback
chain when users.name is NULL, flea_detail exposes title + tagline,
tagline null when omitted, inner skill parent_display_name uses
entity.title (explicit + humanize-fallback variants).
- Updated `TestListItems.test_flea_lists_uploads` to assert both
`display_name == "Alpha"` (humanized) and `name ==
"alpha-by-alice"` (suffixed slug compat).
- Updated `TestWebPages.test_marketplace_flea_detail_page_renders`
to look for the humanized title ("Page Skill") in the SSR shell
instead of the kebab-case `page-skill`.
* feat(flea): phase-3 — read synthetic_name from DB, suffixed_name() only on write
Phase 1 added the column + backfill, repo write paths keep it in sync.
Phase 3 routes every READ callsite through `store_entities.synthetic_name`
directly instead of recomputing `<name>-by-<owner_username>` on the fly,
and switches the collision query off the inline string concat. The
`suffixed_name()` primitive now lives exclusively in write flows.
Read callsites updated (all read `entity["synthetic_name"]` directly,
no fallback — the column is NOT NULL and a missing value would be a
real bug worth surfacing as KeyError):
- app/api/marketplace.py:_flea_to_item — card MarketplaceItem.name.
- app/api/marketplace.py:flea_detail — PluginDetailResponse.manifest_name.
- app/api/store.py:_entity_to_response — StoreEntityResponse.invocation_name.
- app/api/store.py PUT bundle re-bake — `suffixed` passed to
`_bake_plugin_tree`; entity is loaded pre-rename, so its
synthetic_name is the OLD value `_bake_plugin_tree` expects.
- app/api/store.py PUT rename — `old_suffix` for `_rename_baked_tree`.
- app/api/my_stack.py — StoreInstallEntry.invocation_name.
- src/marketplace_filter.py — manifest_name in served plugin entry.
`suffixed_name` imports removed from marketplace.py, my_stack.py, and
marketplace_filter.py (no remaining callsites). store.py keeps the
import for its write paths:
- POST create (`suffixed = suffixed_name(final_name, username)` →
passed to `_bake_plugin_tree` and `repo.create(synthetic_name=...)`).
- PUT rename collision check (`new_suffixed`).
- PUT rename `new_suffix` for `_rename_baked_tree` (proposed value).
- PUT rename `new_synthetic` for `repo.update(synthetic_name=...)`.
- Archive `old_suffix` + `new_suffix` for `_rename_baked_tree`
(retro-compute pre-archive value after `repo.archive` already
overwrote the DB row with the post-archive synthetic).
Collision SQL — `_suffixed_already_taken`:
WHERE name || '-by-' || owner_username = ? (before)
WHERE synthetic_name = ? (after)
Same matches today (phase 1 backfill + NOT NULL invariant + write
paths in sync); indexable + single source of truth going forward.
Repository:
- UserStoreInstallsRepository.list_for_user explicit SELECT extended
with `se.title`, `se.tagline`, `se.synthetic_name` so my_stack and
marketplace_filter callers can read them off the joined row.
Tests:
- test_store_api.py::test_invocation_name_reads_from_synthetic_column —
upload entity, manually override the column with a non-canonical
value, verify GET response returns the override (proves read path
consumes the column, not recomputes).
- test_marketplace_api.py::test_flea_card_and_detail_read_synthetic_name_from_db —
same proof for `MarketplaceItem.name` (card) and
`PluginDetailResponse.manifest_name` (detail).
* feat(flea): phase-4 — rename agnes-store-bundle → flea (synthetic plugin)
The synthetic plugin that wraps loose flea-market skills + agents into
one Claude Code plugin is renamed from `agnes-store-bundle` to `flea`.
Plugin-type flea uploads (their own standalone plugin entry) are
unaffected.
Constants:
- src/marketplace_filter.py:
- BUNDLE_PLUGIN_NAME: "agnes-store-bundle" → "flea" (Claude Code
plugin manifest name + .claude-plugin/plugin.json name)
- BUNDLE_PREFIXED_NAME: "store-bundle" → "flea" (on-disk ZIP /
git tree path, now plugins/flea/...)
Attribution layer (services/session_processors/usage_lib.py):
- FLEA_BUNDLE_PREFIX: "agnes-store-bundle" → "flea". The JSONL
invocation identifier going forward is `flea:<skill-name>`.
- New `_LEGACY_FLEA_BUNDLE_PREFIXES = ("agnes-store-bundle",)`.
`MarketplaceItemLookup.resolve()` + `_attribute_event()` accept BOTH
the new and the legacy prefix so historic usage_events (~90-day
retention) continue attributing to source='flea'. The tuple becomes
a no-op once the rename has been live past the retention window —
a follow-up commit can drop it then.
- USAGE_PROCESSOR_VERSION bumped 6 → 7 so the session-pipeline reprocess
loop re-runs attribution with the new + legacy prefix branches.
User-facing copy:
- /api/store/bundle.zip Content-Disposition filename: agnes-store-bundle.zip → flea.zip
- `agnes admin store pull` default --out: agnes-store-bundle.zip → flea.zip
- Docstrings + JS comment + welcome template comment updated.
Tests:
- skill_flea.jsonl fixture identifier updated to flea:flea-skill.
- New skill_flea_legacy.jsonl with the legacy prefix for backward-compat
coverage.
- New test `test_legacy_agnes_store_bundle_prefix_resolves` replays the
legacy fixture and asserts source='flea' attribution still lands.
- All other test assertions / mocks substituted mechanically:
test_session_processor_usage.py, test_usage_rollups.py,
test_marketplace_filter_store.py, test_store_api.py,
test_cli_refresh_marketplace.py.
- `_seed_flea_entity` (test_usage_rollups.py) + `_seed_attribution`
(test_session_processor_usage.py) helpers now supply the NOT NULL
`title` + `synthetic_name` columns from phase 1, since they INSERT
directly bypassing the repo's create() fallback.
Client rollover note (CHANGELOG): `agnes refresh-marketplace` will
install the new `flea@agnes` plugin and the local marketplace clone's
`plugins/store-bundle/` source folder is removed via `git reset --hard`.
Whether Claude Code itself auto-prunes the orphan `agnes-store-bundle
@agnes` registry entry is undocumented — to verify empirically on the
dev VM. If the orphan entry lingers, a follow-up will add targeted
cleanup; until then users can manually run
`claude plugin uninstall agnes-store-bundle@agnes`.
Verified locally: 98 passed (session_processor_usage + usage_rollups +
marketplace_filter_store + cli_refresh_marketplace) + 228 passed/2
skipped (store_api + marketplace_api + admin_store_submissions +
store_entity_versions + store_repositories).
* fix(flea): phase-5 — attribution keyspace mismatch (closes #335)
Pre-fix every flea skill/agent invocation silently fell through to
`usage_events.source = 'builtin'`. Root cause: lookup tables in
`services/session_processors/usage_lib.py` keyed `_flea_entities` (and
the derived `_flea_plugins` set) by `store_entities.name` — the
un-suffixed display name. Claude Code writes invocations as
`flea:<synthetic_name>` (e.g. `flea:xlsx-by-c-marustamyan`), so
`dict.get(local)` always missed and the resolver fell through to
builtin. Result: marketplace cards, detail telemetry chips, admin
group-by-source all showed 0 flea invocations even when the raw
JSONL stream was correct.
Phase 1 added the `synthetic_name` column + backfill; phase 4 renamed
the bundle prefix to `flea`; phase 5 finally flips the lookup
keyspace to match what JSONL writes.
usage_lib.py:
- `MarketplaceItemLookup.__init__` preload: `SELECT synthetic_name,
type FROM store_entities` (was `SELECT name, type`). `_flea_plugins`
set derived from those keys, so it now carries synthetic_names
too — matches what Claude Code writes when invoking a skill nested
inside a flea plugin (`<synthetic>:<inner>`).
- `rebuild_rollups` preload: same SELECT change; also derives
`flea_plugins` and threads it through `_aggregate_events` /
`_rebuild_window`.
- `_attribute_event`: signature extended with `flea_plugins`; new
branch `if prefix in flea_plugins: return ("flea", default_type,
prefix, local)` for flea-plugin-nested skills/agents. This branch
was added to `MarketplaceItemLookup.resolve()` in v6 (commit
e076ebbe) but the rollup builder's helper was never updated to
match, so nested skills inside flea plugins silently dropped out
of the daily/window fact tables.
- `USAGE_PROCESSOR_VERSION`: 7 → 8. Forces the session-pipeline
reprocess loop to re-attribute existing usage_events rows with
the corrected lookup so rollup tables fill correctly on the next
tick.
marketplace.py — 4 API stats lookup callsites switched from
`entity["name"]` to `entity["synthetic_name"]`:
- `_flea_to_item` (card stats lookup)
- `flea_detail` (`_build_telemetry` + `_load_inner_items_stats_by_parent`)
- `flea_skill_detail` (inner detail `parent_plugin` key)
- `flea_agent_detail` (inner detail `parent_plugin` key)
Tests:
- `skill_flea.jsonl` invocation: `flea:flea-skill` →
`flea:flea-skill-by-alice` (mirrors what Claude Code writes after
phase 1/4 — the suffixed synthetic_name).
- `test_flea_skill_attributed_with_empty_parent` assertion: rollup
`name` column now carries the synthetic_name.
No legacy `agnes-store-bundle` prefix backward compat — clean cut per
user direction (dev phase, no production data worth preserving).
Verified locally: 53 passed targeted (session_processor_usage +
usage_rollups + marketplace_filter_store) + 215 passed/2 skipped
broader (store_api + marketplace_api + admin_store_submissions +
store_entity_versions).
* fix(flea): phase-6 — plugin-level rollup aggregation parity for flea
Flea plugin entity cards + detail pages showed 0 invocations even
though nested skills had correct rollup rows. Root cause: the
plugin-level aggregation pass in `_aggregate_events` was hardcoded
to `source='curated'` only:
if source != "curated" or not parent:
continue
if group_by_day:
pkey = (day, "curated", "plugin", "", parent)
else:
pkey = ("curated", "plugin", "", parent)
So flea plugin entities never got a synthetic
`(source='flea', type='plugin', parent_plugin='', name=<synth>)`
row aggregating nested invocations. `_load_invocation_stats('flea')`
filters `parent_plugin = ''` and returned no row for flea plugin
entity cards, so `stats.get(entity["synthetic_name"])` missed and
the API exposed 0/0.
Triggered by empirical observation on the dev VM —
`codex-second-opinion-by-c-marustamyan` plugin showed 0 calls in
the listing card while its three inner skills (codex-setup ×3,
codex-review ×1, codex-second-opinion ×1) had the expected child
rollup rows.
Fix:
- Extend the guard to `source in ("curated", "flea")`.
- Replace the hardcoded `"curated"` in the `pkey` tuple with the
loop's `source` variable, so flea aggregation lands as `source=
'flea'` and curated aggregation continues landing as
`source='curated'`.
API path unchanged — `_load_invocation_stats('flea')` filters
`parent_plugin = ''` already picks up the new aggregated row
alongside standalone skill/agent rows. Rollup `name` field carries
the synthetic_name keyspace; no collision between standalone entity
synthetic and plugin entity synthetic (global suffix uniqueness
enforced by `_suffixed_already_taken`).
`USAGE_PROCESSOR_VERSION` bumped 8 → 9 to force a reprocess pass so
historic nested-invocation data fills the new plugin-level rows on
the next tick (instead of waiting for the next live invocation).
Tests:
- New `test_flea_plugin_row_aggregates_children` mirrors the existing
`test_curated_plugin_row_aggregates_children`: seeds a flea plugin
entity, three nested events (one user invoking two skills, a
second user invoking one) → asserts the aggregated plugin row
carries count=3, distinct_users=2 (union, not sum), plus the child
rows survive alongside.
Verified locally: 43 passed (session_processor_usage + usage_rollups)
+ 82 passed/2 skipped broader (+ marketplace_filter_store +
marketplace_api).
* refactor(marketplace): phase-7 — unify Details sidebar across detail surfaces
Five marketplace detail surfaces (curated plugin, flea plugin, curated
inner skill/agent, flea inner skill/agent, flea standalone skill/agent)
had drifted on which Details rows they show and what order — the same
field landed in different positions, some fields duplicated hero info,
and the flea plugin Owner row leaked the kebab-case `owner_username`
slug instead of the user's real name. This commit aligns all five
surfaces on a single scan order driven by UX priority:
identity → life-stage → telemetry → debug-tier
Concretely:
1. Curator / Owner (first scan signal — trust)
2. Parent plugin (inner skill/agent only)
3. Released (top-level only — plugins + flea standalone)
4. Last used (recency)
5. Active days (engagement consistency)
6. Version (flea standalone only — content hash)
7. Bundle size (debug-tier)
Dropped:
- Slug field on plugin detail surfaces (`marketplace_id` for curated,
`entity_id` for flea). Pure debug info, never user-relevant; URL
already carries it.
- Category + Installs on flea standalone skill/agent detail.
Category is already shown as a hero badge; install count is in
the hero telemetry chip — sidebar duplication added noise.
Owner display:
- Flea plugin Owner row now reads `d.owner_display` (resolved through
`users.name → users.email → owner_username` by `_resolve_owner_display`
in `app/api/marketplace.py:1491`) instead of the raw `d.author_name`
(which is `owner_username`, the kebab-case slug). API field already
populated from phase 2; templates just consume it.
- Curated Curator row continues to read `d.author_name` from
marketplace-metadata.json; `owner_todo` placeholder behavior
preserved.
Files:
- app/web/templates/marketplace_plugin_detail.html — rewrote the
Details render loop (lines 1364-1427 area). Slug row removed,
rows reordered, Owner branch reads `d.owner_display`.
- app/web/templates/marketplace_item_detail.html — both branches of
the Details sidebar (inner skill/agent + flea standalone) re-laid
around the same scan order. Telemetry helper unchanged, just
repositioned. Category + Installs rows removed from the
standalone branch.
No new tests — no existing test asserts the precise order of Details
rows or references the dropped fields in a sidebar context (grep
confirmed). API surface unchanged.
Verified locally: 84 passed / 2 skipped on `test_marketplace_api.py`
+ `test_store_api.py`.
* fix(flea): post-review hardening — N+1, v50 UNIQUE, docs, test cleanup
Addresses 5 critical findings from PR #342 code review:
1. N+1 query in `_flea_to_item` — owner-display resolution previously
ran one `SELECT … FROM users WHERE id = ?` per item in the listing
comprehension. Now batched via `_load_users_display` IN-query
prefetch; 50 items drops 51 user queries to 2. Regression-guarded
by `TestFleaOwnerDisplayBatched` (spies `_resolve_owner_display`
and asserts it's not called inside the list path).
2. Misleading comment in `src/marketplace_filter.py` claimed the
attribution layer accepts both `agnes-store-bundle` and `flea`
prefixes — it doesn't (clean cut per CHANGELOG). Rewrote to match
reality.
3. CHANGELOG `[Unreleased]` had two `### Changed` blocks. Merged into
one (BREAKING bullet first).
4. New v49→v50 migration adds `UNIQUE INDEX
idx_store_entities_synthetic_name`. v49 made `synthetic_name` the
canonical attribution key but uniqueness was only app-enforced;
v50 promotes the invariant to the DB layer. Migration pre-checks
for existing duplicates and raises `RuntimeError` listing them
rather than letting `CREATE UNIQUE INDEX` fail mid-way. v48→v49
migration gained an `is_nullable='YES'` guard on its `SET NOT NULL`
ALTERs so re-runs on a fully-migrated DB don't trip DuckDB's
"cannot alter entry … entries depend on it" block (the new index
counts as such an entry). Index is created by the migration only —
keeping it out of `_SYSTEM_SCHEMA` preserves fresh-install ordering
(CREATE TABLE → v49 ALTERs → v50 CREATE INDEX).
5. Deleted three redundant version-pinned schema asserts whose names
lied about their bodies (`test_schema_version_is_42` asserting
`== 49`, etc.). Canonical assert lives in
`test_db_schema_version.py`, renamed to
`test_schema_version_matches_constant`.
* fix(db): gate v34→v38 store_entities ALTER COLUMN steps on column state
CI on Linux failed `test_v17_to_v18_drops_*` after the v50 UNIQUE INDEX
landed. Root cause: those tests open a DB at the full target version,
seed fixtures, then reset `schema_version` to 17 and reopen — forcing
the ladder to re-run from 17 → current. With the v50 index now in place,
DuckDB blocks intermediate `ALTER COLUMN` steps on `store_entities`
("Cannot drop this column: an index depends on a column after it!" /
"Cannot alter entry because there are entries that depend on it"),
because `synthetic_name` (the indexed column) sits positionally after
the columns those steps touch.
Fix: convert the three SQL-list migrations that hit store_entities into
defensive Python functions:
- `_v34_to_v35_migrate` short-circuits when `synthetic_name` already
exists (post-v49 shape — the visibility_status rebuild is moot and
the DROP COLUMN would be blocked by the index).
- `_v35_to_v36_migrate` gates the `visibility_status SET NOT NULL` +
`SET DEFAULT` on `is_nullable='YES'` so it's a true no-op when the
column is already constrained.
- `_v37_to_v38_migrate` gates the `version_no SET NOT NULL` step the
same way.
Forward-roll path (real installs that never reset schema_version) is
unchanged: the gates fire `YES` → ALTERs run. The fix only changes
behavior for the "DB is already at v50 shape but version row says 17"
scenario the tests construct.
---------
Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
This commit is contained in:
parent
e11f03eb60
commit
c6c72b9c00
31 changed files with 2166 additions and 275 deletions
156
CHANGELOG.md
156
CHANGELOG.md
|
|
@ -10,6 +10,148 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **BREAKING (marketplace identifier)**: synthetic plugin bundling flea
|
||||
skills + agents renamed from `agnes-store-bundle` to `flea`. The
|
||||
served `marketplace.json` now lists `flea` (previously
|
||||
`agnes-store-bundle`); on-disk ZIP / git tree path is
|
||||
`plugins/flea/` (previously `plugins/store-bundle/`). Claude Code
|
||||
JSONL invocation prefix becomes `flea:<synthetic_name>` going
|
||||
forward. **Clean cut — no legacy-prefix backward compat.** Historic
|
||||
`usage_events` rows whose JSONL was written before the rename will
|
||||
stay attributed as `source='builtin'` (acceptable in dev phase per
|
||||
user direction; nothing to migrate).
|
||||
|
||||
**Client rollover**: `agnes refresh-marketplace` will install the
|
||||
new `flea@agnes` plugin and reset the local marketplace clone (the
|
||||
old `plugins/store-bundle/` source folder gets removed from disk
|
||||
via `git reset --hard`). Whether Claude Code itself auto-prunes
|
||||
the orphan `agnes-store-bundle@agnes` registry entry is
|
||||
undocumented in our codebase — to be verified empirically on the
|
||||
dev VM. If the orphan entry lingers, users can manually run
|
||||
`claude plugin uninstall agnes-store-bundle@agnes`.
|
||||
- Marketplace detail page **Details sidebar** unified across all five
|
||||
surfaces (curated plugin / flea plugin / curated inner skill+agent /
|
||||
flea inner skill+agent / flea standalone skill+agent). Render order
|
||||
now scans **identity → life-stage → telemetry → debug-tier**:
|
||||
Curator / Owner → (Parent plugin for inner / Released for top-level)
|
||||
→ Last used → Active days → Version (flea standalone only) →
|
||||
Bundle size. Drops the previous Slug row (debug-tier, never user-
|
||||
relevant) from plugin detail and the Category + Installs rows
|
||||
(duplicated hero badge + telemetry chip) from flea standalone
|
||||
detail. Flea plugin Owner row now reads `d.owner_display` — the
|
||||
fullname resolved via `users.name → users.email → owner_username`
|
||||
— instead of the kebab-case `owner_username` slug.
|
||||
- Flea marketplace cards and detail pages now render the user-friendly
|
||||
**title** instead of the kebab-case `<name>-by-<owner>` slug, the
|
||||
owner's full name from `users.name` (with email → `owner_username`
|
||||
fallback) instead of the bare username, and the optional **tagline**
|
||||
as the hero subtitle (description still shows below the hero on
|
||||
detail pages). Phase 2 of the Flea refactor — phase 1 (commit
|
||||
`7f4cfcbb`) seeded the columns; phase 2 wires them through
|
||||
`_flea_to_item`, `flea_detail`, and the two detail templates.
|
||||
Breadcrumb last segment on `/marketplace/flea/{id}` drops the
|
||||
suffixed slug fallback in favour of the title.
|
||||
- Flea inner skill/agent detail pages
|
||||
(`/marketplace/flea/{id}/skill/{name}`, `/agent/{name}`) now show
|
||||
the parent plugin's **title** in the breadcrumb 3rd segment, the
|
||||
hero "part of …" meta-row, the helper "This skill is part of …"
|
||||
panel, and the Details sidebar's "Parent plugin" row. Sourced
|
||||
from `store_entities.title` via
|
||||
`_flea_inner_parent_fields.parent_display_name`; falls back to
|
||||
`strip_archive_suffix(name)` for any legacy rows that somehow
|
||||
lack a title.
|
||||
- Flea standalone skill/agent detail (`/marketplace/flea/{id}` where
|
||||
`type IN ('skill','agent')`) drops the hero meta-row that read
|
||||
"by <author> · N installed · <size>". Install count is already
|
||||
rendered in the hero telemetry chip below; owner + bundle size
|
||||
live in the Details sidebar. The row was duplicating those three
|
||||
values in a less-prominent position.
|
||||
- Read paths (marketplace card name, detail manifest_name, response
|
||||
`invocation_name`, My-Stack invocation, served-bundle manifest in
|
||||
`marketplace_filter`) now source the suffixed slug from
|
||||
`store_entities.synthetic_name` directly instead of recomputing
|
||||
`<name>-by-<owner_username>` on the fly. The column is NOT NULL +
|
||||
the repo `create` / `update` / `archive` paths keep it in sync, so
|
||||
reading it is safe; no fallback to a recompute — a missing value
|
||||
would be a genuine bug worth surfacing as `KeyError`, not masked.
|
||||
`suffixed_name()` stays as the primitive used by **write paths
|
||||
only** (POST create insert, PUT rename collision check + new
|
||||
suffix for `_rename_baked_tree` + new synthetic for `repo.update`,
|
||||
archive new/old suffix for on-disk rename). `_suffixed_already_taken`
|
||||
collision query swaps the inline `name || '-by-' || owner_username`
|
||||
concat for `WHERE synthetic_name = ?` — indexable + single source
|
||||
of truth.
|
||||
|
||||
### Fixed
|
||||
- Flea **plugin entity** cards (`/marketplace?tab=flea`) and detail
|
||||
pages (`/marketplace/flea/{id}` for `type='plugin'`) now show the
|
||||
sum of nested skill/agent invocations. Pre-fix the plugin-level
|
||||
rollup pass in `services/session_processors/usage_lib.py:_aggregate_events`
|
||||
was hardcoded to `source='curated'` only, so flea plugin entities
|
||||
never got a `(source='flea', type='plugin', parent_plugin='',
|
||||
name=<plugin_synth>)` aggregated row. The API path's
|
||||
`_load_invocation_stats('flea')` filters `parent_plugin=''` and
|
||||
returned nothing for plugin cards even though nested children had
|
||||
correct rollup rows. Triggered by empirical observation on dev VM
|
||||
(`codex-second-opinion-by-c-marustamyan` plugin showed 0 calls
|
||||
while its three inner skills had 1+1+3 invocations). Fix extends
|
||||
the aggregation pass to `source in ('curated', 'flea')` and
|
||||
preserves the original source tag in the synthetic plugin row.
|
||||
`USAGE_PROCESSOR_VERSION` bumped 8→9 so the reprocess pass fills
|
||||
the new aggregated rows for historic data.
|
||||
- Flea-market attribution layer now keys its lookup tables by
|
||||
`store_entities.synthetic_name` instead of `name`, matching what
|
||||
Claude Code writes in the JSONL invocation local-part
|
||||
(`flea:<synthetic_name>` e.g. `flea:xlsx-by-c-marustamyan`).
|
||||
Pre-fix every flea skill/agent invocation silently fell through to
|
||||
`usage_events.source = 'builtin'` because the dict was keyed by
|
||||
the un-suffixed `name`. Result: marketplace cards, detail
|
||||
telemetry chips, and admin group-by-source had 0 flea invocations
|
||||
even though raw events were arriving correctly. Both
|
||||
`MarketplaceItemLookup` (live writer) and `_attribute_event`
|
||||
(rollup rebuilder) updated; rollup `name`/`parent_plugin`
|
||||
columns now carry the synthetic_name keyspace. API stats lookups
|
||||
in `app/api/marketplace.py` switched from `entity["name"]` to
|
||||
`entity["synthetic_name"]` (4 callsites: `_flea_to_item`,
|
||||
`flea_detail`, two flea inner-detail endpoints). `_attribute_event`
|
||||
also gains the flea-plugin-nested branch it was missing since
|
||||
v6 — nested skills/agents inside flea plugins now flow into
|
||||
rollup tables too. `USAGE_PROCESSOR_VERSION` bumped 7→8 so the
|
||||
session-pipeline reprocess loop re-attributes existing events
|
||||
with the corrected lookup. Closes issue #335.
|
||||
- Flea-tab marketplace listing endpoint
|
||||
(`GET /api/marketplace/items?tab=flea`) no longer issues an N+1
|
||||
query against `users`. The owner-display resolution previously
|
||||
fired one `SELECT name, email FROM users WHERE id = ?` per item
|
||||
inside the list comprehension; now batched into a single
|
||||
`WHERE id IN (…)` prefetch via `_load_users_display`. With 50
|
||||
flea items per page that drops 51 queries to 2.
|
||||
|
||||
### Added
|
||||
- Flea-market upload + edit forms now collect a user-friendly **Title**
|
||||
(humanized from the kebab-case `name`, acronym-aware: `mcp-builder` →
|
||||
`MCP Builder`, `oauth-server-v2` → `OAuth Server V2`), an optional
|
||||
**Short description** (`tagline`, ≤200 chars), and show a read-only
|
||||
live preview of the final synthetic invocation slug
|
||||
(`/<name>-by-<owner_username>`) next to the Name field. Phase 1 of a
|
||||
larger Flea refactor — fields are persisted on `store_entities` but
|
||||
not yet rendered on marketplace cards / detail pages (Phase 2). Schema
|
||||
v49 adds `title NOT NULL`, `tagline`, and `synthetic_name NOT NULL`
|
||||
columns; backfill humanizes existing names (archive-suffix stripped
|
||||
first) and composes synthetic from the deterministic formula.
|
||||
- Schema **v50** adds a UNIQUE INDEX on `store_entities.synthetic_name`
|
||||
(`idx_store_entities_synthetic_name`). v49 made `synthetic_name` the
|
||||
canonical attribution key (rollup keyspace, JSONL invocation prefix,
|
||||
marketplace bundle naming) but uniqueness was only enforced
|
||||
application-side at upload/rename time via `_suffixed_already_taken`.
|
||||
v50 promotes the invariant to the DB layer so admin DB hand-fixes or
|
||||
future write-path bugs can't silently introduce duplicates.
|
||||
DuckDB has no `ALTER TABLE ADD CONSTRAINT UNIQUE`, but
|
||||
`CREATE UNIQUE INDEX` is functionally equivalent. Migration pre-checks
|
||||
for existing duplicates and raises `RuntimeError` listing them rather
|
||||
than letting the index create fail mid-way with a raw DuckDB error.
|
||||
|
||||
## [0.54.28] — 2026-05-18
|
||||
|
||||
### Fixed
|
||||
|
|
@ -91,20 +233,6 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
|
|||
now rejects `tables` dicts with more than 500 entries (ADV-008, ADV-009).
|
||||
- `GET /api/catalog/tables` now has a typed `response_model` (`CatalogTablesResponse`)
|
||||
so Swagger generates an accurate schema for that endpoint (ADV-007).
|
||||
|
||||
### Internal
|
||||
- Added `TestFullLifecycleFromInstaller` integration test class
|
||||
(`tests/test_store_entity_versions.py`) covering the full
|
||||
flea-market lifecycle from issuer / admin / subscribed-user
|
||||
perspectives. Main test walks v1 upload → installer subscribes →
|
||||
v2 promote → v3 blocked → admin force-overrides → restore v1,
|
||||
asserting BOTH entity state AND served `marketplace.zip` bytes +
|
||||
ETag at each transition. Plus 5 corner cases:
|
||||
unsubscribed-user negative control, late-subscriber-during-
|
||||
quarantine, non-owner privacy gate, second-restore reuse path
|
||||
(PR #332 lifecycle validation), and archived-entity-keeps-
|
||||
serving-installs (CLAUDE.md contract).
|
||||
|
||||
## [0.54.24] — 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import logging
|
|||
import re
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Literal, Optional, Tuple
|
||||
from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple
|
||||
|
||||
import duckdb
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
|
@ -52,7 +52,6 @@ from src.repositories.user_curated_subscriptions import (
|
|||
)
|
||||
from src.repositories.user_store_installs import UserStoreInstallsRepository
|
||||
from src.store_categories import STORE_CATEGORIES
|
||||
from src.store_naming import suffixed_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/marketplace", tags=["marketplace"])
|
||||
|
|
@ -799,6 +798,7 @@ def _flea_to_item(
|
|||
entity: dict,
|
||||
*,
|
||||
installed_set: set,
|
||||
users_display: Dict[str, Tuple[Optional[str], Optional[str]]],
|
||||
viewer_id: Optional[str] = None,
|
||||
stats: Optional[Dict[str, Dict]] = None,
|
||||
) -> MarketplaceItem:
|
||||
|
|
@ -815,13 +815,33 @@ def _flea_to_item(
|
|||
# (Claude Code's `/plugin` resolution) uses the renamed slug — we
|
||||
# don't strip there.
|
||||
from src.store_naming import strip_archive_suffix
|
||||
display_name = strip_archive_suffix(entity["name"])
|
||||
invocation = suffixed_name(display_name, entity.get("owner_username") or "")
|
||||
display_name_raw = strip_archive_suffix(entity["name"])
|
||||
# v49 phase-3: invocation is the stored synthetic_name. The column is
|
||||
# NOT NULL (phase 1 migration + repo create/update/archive write
|
||||
# paths keep it in sync), so reading it directly is safe and a
|
||||
# missing value would be a real bug worth surfacing as KeyError
|
||||
# rather than masking with a recompute.
|
||||
invocation = entity["synthetic_name"]
|
||||
is_viewer_owner = bool(viewer_id and entity.get("owner_user_id") == viewer_id)
|
||||
# v46: flea stats keyed by store_entities.name (rollup `name` column).
|
||||
# The display name is post-archive-strip; use the raw row name to match
|
||||
# what the lookup preload sees.
|
||||
stat = (stats or {}).get(entity["name"], {})
|
||||
# v49 phase-5: rollup `name` column carries the synthetic_name (the
|
||||
# post-rename keyspace used by `MarketplaceItemLookup`). Stats dict
|
||||
# built by `_load_invocation_stats` is keyed by that same value, so
|
||||
# the lookup uses `entity["synthetic_name"]`.
|
||||
stat = (stats or {}).get(entity["synthetic_name"], {})
|
||||
# v49 phase-2: front the card with the user-friendly `title` (humanized,
|
||||
# acronym-aware) via the existing `display_name` field — JS already
|
||||
# has the chain `it.display_name || it.name` on cards. `tagline`
|
||||
# rides the same chain JS uses for curated. Owner display resolves
|
||||
# `users.name → users.email → owner_username` so cards no longer
|
||||
# leak the kebab-case username (e.g. `c-marustamyan`) when the user
|
||||
# has a real name on their account. Reads from a prefetched
|
||||
# ``users_display`` map (one IN-query per page) — see
|
||||
# ``_load_users_display`` callers in ``list_items``.
|
||||
owner_display = _owner_display_from_map(
|
||||
users_display,
|
||||
entity["owner_user_id"],
|
||||
entity.get("owner_username") or "",
|
||||
)
|
||||
return MarketplaceItem(
|
||||
id=f"flea-{entity['id']}",
|
||||
source="flea",
|
||||
|
|
@ -829,7 +849,9 @@ def _flea_to_item(
|
|||
type=entity["type"],
|
||||
category=entity.get("category") or None,
|
||||
description=entity.get("description"),
|
||||
owner=entity.get("owner_username"),
|
||||
owner=owner_display,
|
||||
display_name=entity.get("title"),
|
||||
tagline=entity.get("tagline"),
|
||||
version=entity.get("version"),
|
||||
photo_url=photo_url,
|
||||
added=_to_iso(entity.get("created_at")),
|
||||
|
|
@ -1069,9 +1091,13 @@ async def list_items(
|
|||
include_owner_id=include_owner,
|
||||
)
|
||||
flea_stats = _load_invocation_stats(conn, "flea")
|
||||
flea_users_display = _load_users_display(
|
||||
conn, (r["owner_user_id"] for r in all_flea_rows),
|
||||
)
|
||||
items = [
|
||||
_flea_to_item(
|
||||
r, installed_set=installed_set,
|
||||
users_display=flea_users_display,
|
||||
viewer_id=user["id"],
|
||||
stats=flea_stats,
|
||||
)
|
||||
|
|
@ -1145,9 +1171,13 @@ async def list_items(
|
|||
|
||||
flea_installs = UserStoreInstallsRepository(conn).list_for_user(user["id"])
|
||||
flea_installed_set = {row["id"] for row in flea_installs}
|
||||
flea_users_display = _load_users_display(
|
||||
conn, (row["owner_user_id"] for row in flea_installs),
|
||||
)
|
||||
for entity in flea_installs:
|
||||
items.append(_flea_to_item(
|
||||
entity, installed_set=flea_installed_set, stats=flea_stats,
|
||||
entity, installed_set=flea_installed_set,
|
||||
users_display=flea_users_display, stats=flea_stats,
|
||||
))
|
||||
|
||||
# Apply optional filters client-server-style for `my` tab (small N):
|
||||
|
|
@ -1497,6 +1527,9 @@ def _resolve_owner_display(
|
|||
|
||||
Mirrors the inline lookup ``app/web/router.py::store_detail`` already does
|
||||
so the marketplace API surfaces the same string the Store page shows.
|
||||
|
||||
Single-row variant for detail endpoints. List endpoints must use
|
||||
``_load_users_display`` to avoid an N+1 against ``users``.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT name, email FROM users WHERE id = ?", [owner_user_id]
|
||||
|
|
@ -1506,6 +1539,41 @@ def _resolve_owner_display(
|
|||
return row[0] or row[1] or fallback
|
||||
|
||||
|
||||
def _load_users_display(
|
||||
conn: duckdb.DuckDBPyConnection,
|
||||
user_ids: Iterable[str],
|
||||
) -> Dict[str, Tuple[Optional[str], Optional[str]]]:
|
||||
"""Batch-fetch ``(name, email)`` for a set of user_ids — single round-trip.
|
||||
|
||||
Returns a dict keyed by user_id. Use with ``_owner_display_from_map``
|
||||
inside list comprehensions to compose the same
|
||||
``users.name → users.email → fallback`` resolution that
|
||||
``_resolve_owner_display`` does per-row.
|
||||
"""
|
||||
ids = [u for u in {uid for uid in user_ids if uid}]
|
||||
if not ids:
|
||||
return {}
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
rows = conn.execute(
|
||||
f"SELECT id, name, email FROM users WHERE id IN ({placeholders})",
|
||||
ids,
|
||||
).fetchall()
|
||||
return {r[0]: (r[1], r[2]) for r in rows}
|
||||
|
||||
|
||||
def _owner_display_from_map(
|
||||
users_display: Dict[str, Tuple[Optional[str], Optional[str]]],
|
||||
owner_user_id: str,
|
||||
fallback: str,
|
||||
) -> str:
|
||||
"""Resolve owner display from a prefetched map, mirroring
|
||||
``_resolve_owner_display`` semantics."""
|
||||
row = users_display.get(owner_user_id)
|
||||
if not row:
|
||||
return fallback
|
||||
return row[0] or row[1] or fallback
|
||||
|
||||
|
||||
def _get_plugin_row(
|
||||
conn: duckdb.DuckDBPyConnection,
|
||||
marketplace_id: str,
|
||||
|
|
@ -1711,9 +1779,11 @@ async def flea_detail(
|
|||
a.detail_url = f"/marketplace/flea/{entity_id}/agent/{a.name}"
|
||||
# Per-item telemetry — same shape as curated_detail. Adoption
|
||||
# inherits from the parent flea plugin's install_count (no
|
||||
# standalone install on inner items).
|
||||
# standalone install on inner items). v49 phase-5: rollup
|
||||
# `parent_plugin` for flea-plugin children carries the parent's
|
||||
# synthetic_name (= what Claude Code writes in the JSONL prefix).
|
||||
inner_stats = _load_inner_items_stats_by_parent(
|
||||
conn, "flea", entity["name"],
|
||||
conn, "flea", entity["synthetic_name"],
|
||||
)
|
||||
flea_parent_stack = int(entity.get("install_count") or 0)
|
||||
for s in skills:
|
||||
|
|
@ -1759,7 +1829,8 @@ async def flea_detail(
|
|||
# the renamed-on-archive slug since that's what Claude Code resolves.
|
||||
from src.store_naming import strip_archive_suffix
|
||||
_flea_display_name = strip_archive_suffix(entity["name"])
|
||||
invocation = suffixed_name(_flea_display_name, entity.get("owner_username") or "")
|
||||
# v49 phase-3: read the stored synthetic_name (NOT NULL invariant).
|
||||
invocation = entity["synthetic_name"]
|
||||
|
||||
# doc_paths is a JSON array of relative paths the uploader picked at upload
|
||||
# time; `app/api/store.py` serves them by basename via /api/store/.../docs/{filename}.
|
||||
|
|
@ -1799,6 +1870,13 @@ async def flea_detail(
|
|||
entity_id=entity_id,
|
||||
plugin_name=_flea_display_name,
|
||||
manifest_name=invocation,
|
||||
# v49 phase-2: surface the user-friendly title + short description
|
||||
# via the existing curated-side fields. JS heroTitle chain already
|
||||
# prefers `display_name`, and the hero-tagline element already
|
||||
# reads `d.tagline` — flea now feeds the same chain instead of
|
||||
# falling through to plugin_name (= kebab-case entity name).
|
||||
display_name=entity.get("title"),
|
||||
tagline=entity.get("tagline"),
|
||||
description=entity.get("description"),
|
||||
version=entity.get("version"),
|
||||
category=entity.get("category"),
|
||||
|
|
@ -1825,9 +1903,10 @@ async def flea_detail(
|
|||
docs=docs,
|
||||
visibility_status=entity.get("visibility_status") or "approved",
|
||||
submission_status=submission_status,
|
||||
# v46: flea telemetry keyed by entity.name (rollup `name` column),
|
||||
# not entity_id — JSONL identifiers carry the entity name, not its UUID.
|
||||
telemetry=_build_telemetry(conn, "flea", entity["name"]),
|
||||
# v49 phase-5: flea telemetry keyed by entity.synthetic_name
|
||||
# (rollup `name` column carries the post-rename keyspace, which
|
||||
# is the same string Claude Code writes in the JSONL local-part).
|
||||
telemetry=_build_telemetry(conn, "flea", entity["synthetic_name"]),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1996,6 +2075,12 @@ def _flea_inner_parent_fields(
|
|||
enrichment file convention exists for flea bundles yet, so the same
|
||||
fallbacks the flea plugin detail hero uses (strip_archive_suffix on
|
||||
entity.name, owner display, entity.updated_at) populate the response.
|
||||
|
||||
v49 phase-3: ``parent_display_name`` prefers the user-set ``title``
|
||||
column over the kebab-case ``name``. The frontend chain (breadcrumb,
|
||||
hero "part of …", sidebar "Parent plugin", helper "This skill is part
|
||||
of …") all read ``d.parent_display_name`` first, so a single source
|
||||
swap drives every surface to the friendly form.
|
||||
"""
|
||||
from src.store_naming import strip_archive_suffix
|
||||
owner_display = _resolve_owner_display(
|
||||
|
|
@ -2007,7 +2092,7 @@ def _flea_inner_parent_fields(
|
|||
"parent_author_name": owner_display or OWNER_TODO_PLACEHOLDER,
|
||||
"parent_updated_at": _to_iso(entity.get("updated_at")),
|
||||
"manifest_name": entity["name"],
|
||||
"parent_display_name": strip_archive_suffix(entity["name"]),
|
||||
"parent_display_name": entity.get("title") or strip_archive_suffix(entity["name"]),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2531,8 +2616,9 @@ async def flea_skill_detail(
|
|||
text, relpath = res
|
||||
fm = _parse_frontmatter(text)
|
||||
parent = _flea_inner_parent_fields(conn, entity)
|
||||
# v49 phase-5: rollup `parent_plugin` carries the parent's synthetic_name.
|
||||
telemetry = _load_inner_item_stats(
|
||||
conn, "flea", parent_plugin=entity["name"], name=skill_name, item_type="skill",
|
||||
conn, "flea", parent_plugin=entity["synthetic_name"], name=skill_name, item_type="skill",
|
||||
)
|
||||
return InnerDetailResponse(
|
||||
marketplace_id="",
|
||||
|
|
@ -2583,8 +2669,9 @@ async def flea_agent_detail(
|
|||
except OSError:
|
||||
agent_size = 0
|
||||
parent = _flea_inner_parent_fields(conn, entity)
|
||||
# v49 phase-5: rollup `parent_plugin` carries the parent's synthetic_name.
|
||||
telemetry = _load_inner_item_stats(
|
||||
conn, "flea", parent_plugin=entity["name"], name=agent_name, item_type="agent",
|
||||
conn, "flea", parent_plugin=entity["synthetic_name"], name=agent_name, item_type="agent",
|
||||
)
|
||||
return InnerDetailResponse(
|
||||
marketplace_id="",
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ 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"])
|
||||
|
|
@ -182,7 +181,10 @@ async def get_my_stack(
|
|||
version=row["version"],
|
||||
owner_user_id=row["owner_user_id"],
|
||||
owner_username=row["owner_username"],
|
||||
invocation_name=suffixed_name(raw_name, row["owner_username"]),
|
||||
# v49 phase-3: stored synthetic_name (single source of
|
||||
# truth). The column is NOT NULL and `list_for_user`
|
||||
# selects it explicitly from the joined store_entities row.
|
||||
invocation_name=row["synthetic_name"],
|
||||
install_count=int(row.get("install_count") or 0),
|
||||
photo_url=photo_url,
|
||||
installed_at=_to_iso(row.get("installed_at")),
|
||||
|
|
|
|||
110
app/api/store.py
110
app/api/store.py
|
|
@ -104,7 +104,7 @@ def _suffixed_already_taken(
|
|||
The Store namespace is **flat** in Claude Code — two plugins/skills/agents
|
||||
that share a ``name`` collide in the served marketplace catalog (the
|
||||
``manifest_name`` is unique-key for ``/plugin`` lookup) and on-disk inside
|
||||
the ``agnes-store-bundle`` (skills/<suffixed>/SKILL.md is the dir name).
|
||||
the ``flea`` bundle (skills/<suffixed>/SKILL.md is the dir name).
|
||||
|
||||
``sanitize_username`` is many-to-one (``alice.smith`` and ``alice_smith``
|
||||
both → ``alice-smith``), so the per-owner UNIQUE on
|
||||
|
|
@ -117,11 +117,15 @@ def _suffixed_already_taken(
|
|||
check so the same owner can re-upload under the original name after
|
||||
archive. The archive path renames the row to free the slug, so this
|
||||
flag is belt-and-braces.
|
||||
|
||||
v49 phase-3: query the stored ``synthetic_name`` column instead of
|
||||
the inline concat ``name || '-by-' || owner_username``. Phase 1's
|
||||
migration backfilled the column for every row and the repo write
|
||||
paths keep it in sync, so both expressions return the same set —
|
||||
but querying the column is indexable and avoids divergence if the
|
||||
naming formula ever changes (single source of truth).
|
||||
"""
|
||||
sql = (
|
||||
"SELECT id FROM store_entities "
|
||||
"WHERE name || '-by-' || owner_username = ?"
|
||||
)
|
||||
sql = "SELECT id FROM store_entities WHERE synthetic_name = ?"
|
||||
params: List[Any] = [suffixed]
|
||||
if exclude_entity_id:
|
||||
sql += " AND id != ?"
|
||||
|
|
@ -177,6 +181,14 @@ class StoreEntityResponse(BaseModel):
|
|||
# v32+ quarantine: surface visibility so /store browse can render
|
||||
# the corner badge on the submitter's own non-approved cards.
|
||||
visibility_status: Optional[str] = None
|
||||
# v49 phase-1 Flea refactor — user-facing metadata. `title` is a
|
||||
# humanized display name (acronym-aware), `tagline` is an optional
|
||||
# 200-char short description, `synthetic_name` is the deterministic
|
||||
# <name>-by-<owner> baked into served bundles. Phase 1 only writes
|
||||
# them; consuming surfaces (cards, detail, Claude Code) come later.
|
||||
title: Optional[str] = None
|
||||
tagline: Optional[str] = None
|
||||
synthetic_name: Optional[str] = None
|
||||
|
||||
|
||||
class StoreEntityListResponse(BaseModel):
|
||||
|
|
@ -204,6 +216,10 @@ class PreviewResponse(BaseModel):
|
|||
type: str
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
# v49: humanized form of `name` for pre-filling the Title input on
|
||||
# the upload form. Computed server-side so the acronym dict has a
|
||||
# single source of truth (src/store_naming.py:TITLE_ACRONYMS).
|
||||
title: Optional[str] = None
|
||||
components: list[PreviewComponent] = []
|
||||
|
||||
|
||||
|
|
@ -553,8 +569,15 @@ def _entity_to_response(
|
|||
doc_paths=entity.get("doc_paths") or [],
|
||||
created_at=_to_iso(entity.get("created_at")),
|
||||
updated_at=_to_iso(entity.get("updated_at")),
|
||||
invocation_name=suffixed_name(entity["name"], entity["owner_username"]),
|
||||
# v49 phase-3: invocation_name comes from the stored
|
||||
# synthetic_name column (single source of truth). The column is
|
||||
# NOT NULL and the repo write paths keep it in lockstep with
|
||||
# name + owner_username — any missing value is a real bug.
|
||||
invocation_name=entity["synthetic_name"],
|
||||
visibility_status=entity.get("visibility_status") or "approved",
|
||||
title=entity.get("title"),
|
||||
tagline=entity.get("tagline"),
|
||||
synthetic_name=entity.get("synthetic_name"),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1313,10 +1336,13 @@ async def preview_entity(
|
|||
finally:
|
||||
Path(tmp.name).unlink(missing_ok=True)
|
||||
|
||||
from src.store_naming import humanize_name
|
||||
extracted_name = meta.get("name")
|
||||
return PreviewResponse(
|
||||
type=type,
|
||||
name=meta.get("name"),
|
||||
name=extracted_name,
|
||||
description=meta.get("description"),
|
||||
title=humanize_name(extracted_name) if extracted_name else None,
|
||||
components=[
|
||||
PreviewComponent(
|
||||
type=row["type"],
|
||||
|
|
@ -1345,6 +1371,10 @@ async def create_entity(
|
|||
description: Optional[str] = Form(None),
|
||||
category: Optional[str] = Form(None),
|
||||
video_url: Optional[str] = Form(None),
|
||||
# v49 phase-1: user-facing metadata fields. Upload form pre-fills
|
||||
# `title` from a humanizer over `name`; `tagline` is optional.
|
||||
title: Optional[str] = Form(None),
|
||||
tagline: Optional[str] = Form(None),
|
||||
photo: Optional[UploadFile] = File(None),
|
||||
docs: List[UploadFile] = File(default=[]),
|
||||
user: dict = Depends(get_current_user),
|
||||
|
|
@ -1428,6 +1458,18 @@ async def create_entity(
|
|||
raise HTTPException(status_code=400, detail="invalid_name_format")
|
||||
final_description = description or meta.get("description")
|
||||
|
||||
# v49: Title is user-supplied; pre-filled in the upload form by
|
||||
# JS humanize_name() with the same acronym dict the server uses,
|
||||
# but always editable. Fall back to server-side humanize when the
|
||||
# client omits the field (e.g. legacy uploaders, API integrations).
|
||||
from src.store_naming import humanize_name
|
||||
final_title = (title or "").strip() or humanize_name(final_name) or final_name
|
||||
if len(final_title) > 100:
|
||||
raise HTTPException(status_code=400, detail="title_too_long")
|
||||
final_tagline = (tagline or "").strip() or None
|
||||
if final_tagline is not None and len(final_tagline) > 200:
|
||||
raise HTTPException(status_code=400, detail="tagline_too_long")
|
||||
|
||||
repo = StoreEntitiesRepository(conn)
|
||||
# Skip archived rows: archive renames the row to free the slot,
|
||||
# so a same-name re-upload after archive succeeds. Active rows
|
||||
|
|
@ -1519,6 +1561,8 @@ async def create_entity(
|
|||
owner_username=username,
|
||||
type=type,
|
||||
name=final_name,
|
||||
title=final_title,
|
||||
synthetic_name=suffixed,
|
||||
description=final_description,
|
||||
category=category,
|
||||
version=version,
|
||||
|
|
@ -1527,6 +1571,7 @@ async def create_entity(
|
|||
doc_paths=doc_rels,
|
||||
file_size=file_size,
|
||||
visibility_status=initial_visibility,
|
||||
tagline=final_tagline,
|
||||
)
|
||||
_audit(
|
||||
conn,
|
||||
|
|
@ -1596,6 +1641,11 @@ async def update_entity(
|
|||
description: Optional[str] = Form(None),
|
||||
category: Optional[str] = Form(None),
|
||||
video_url: Optional[str] = Form(None),
|
||||
# v49 phase-1 metadata. ``title`` and ``tagline`` are partial-update
|
||||
# fields: omit to leave unchanged, send empty string to clear (only
|
||||
# meaningful for ``tagline``; empty ``title`` is rejected).
|
||||
title: Optional[str] = Form(None),
|
||||
tagline: Optional[str] = Form(None),
|
||||
photo: Optional[UploadFile] = File(None),
|
||||
user: dict = Depends(get_current_user),
|
||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
|
|
@ -1634,6 +1684,7 @@ async def update_entity(
|
|||
background_tasks=background_tasks,
|
||||
file=file, name=name, type=type, description=description,
|
||||
category=category, video_url=video_url, photo=photo,
|
||||
title=title, tagline=tagline,
|
||||
user=user, conn=conn,
|
||||
)
|
||||
|
||||
|
|
@ -1649,6 +1700,8 @@ async def _update_entity_locked(
|
|||
category: Optional[str],
|
||||
video_url: Optional[str],
|
||||
photo: Optional[UploadFile],
|
||||
title: Optional[str],
|
||||
tagline: Optional[str],
|
||||
user: dict,
|
||||
conn: duckdb.DuckDBPyConnection,
|
||||
):
|
||||
|
|
@ -1703,6 +1756,24 @@ async def _update_entity_locked(
|
|||
|
||||
video_url = _validate_video_url(video_url)
|
||||
|
||||
# v49 phase-1: validate metadata fields. ``title`` left None means "no
|
||||
# change"; empty string is rejected (title is NOT NULL). ``tagline``
|
||||
# supports empty-string clear via the repository sentinel.
|
||||
new_title: Optional[str] = None
|
||||
if title is not None:
|
||||
stripped = title.strip()
|
||||
if not stripped:
|
||||
raise HTTPException(status_code=400, detail="title_required")
|
||||
if len(stripped) > 100:
|
||||
raise HTTPException(status_code=400, detail="title_too_long")
|
||||
new_title = stripped
|
||||
new_tagline: Optional[str] = None
|
||||
if tagline is not None:
|
||||
stripped_tagline = tagline.strip()
|
||||
if len(stripped_tagline) > 200:
|
||||
raise HTTPException(status_code=400, detail="tagline_too_long")
|
||||
new_tagline = stripped_tagline # "" clears (repo treats falsy as NULL)
|
||||
|
||||
# Display-name change handled at the end (after bundle bake) so the
|
||||
# rename can target the version-bumped or current bundle dir.
|
||||
rename_to: Optional[str] = None
|
||||
|
|
@ -1766,7 +1837,11 @@ async def _update_entity_locked(
|
|||
Path(tmp.name).unlink(missing_ok=True)
|
||||
|
||||
_validate_and_extract_metadata(entity["type"], scratch)
|
||||
suffixed = suffixed_name(entity["name"], entity["owner_username"])
|
||||
# v49 phase-3: read the stored synthetic_name. Entity row was
|
||||
# loaded before any rename — `synthetic_name` is the OLD value
|
||||
# baked-tree code expects (rename, when present, is applied
|
||||
# below via _rename_baked_tree with NEW suffix).
|
||||
suffixed = entity["synthetic_name"]
|
||||
# Bake into the staging dir — _bake_plugin_tree creates the
|
||||
# target if missing and does its own rmtree on existing
|
||||
# children, so the staging path being fresh is fine.
|
||||
|
|
@ -1859,7 +1934,10 @@ async def _update_entity_locked(
|
|||
# keep serving the prior bundle under the prior slug.
|
||||
if rename_to is not None:
|
||||
owner_username = entity["owner_username"]
|
||||
old_suffix = suffixed_name(entity["name"], owner_username)
|
||||
# v49 phase-3: old_suffix reads the stored synthetic_name (entity
|
||||
# was loaded before any rename was applied). new_suffix MUST be
|
||||
# freshly computed — rename_to is a proposed value not yet in DB.
|
||||
old_suffix = entity["synthetic_name"]
|
||||
new_suffix = suffixed_name(rename_to, owner_username)
|
||||
|
||||
if file is None:
|
||||
|
|
@ -1907,6 +1985,13 @@ async def _update_entity_locked(
|
|||
# Metadata-only column updates (name, description, category, photo,
|
||||
# video) — never bundle-derived (version / file_size) because the
|
||||
# new version isn't promoted to current until the LLM approves.
|
||||
# v49: when ``rename_to`` is set, synthetic_name must move in lockstep
|
||||
# so attribution lookups + the global suffix-uniqueness check stay
|
||||
# accurate. owner_username is immutable, so the new synthetic is a
|
||||
# pure function of the new name.
|
||||
new_synthetic: Optional[str] = None
|
||||
if rename_to is not None:
|
||||
new_synthetic = suffixed_name(rename_to, entity["owner_username"])
|
||||
repo.update(
|
||||
entity_id,
|
||||
name=rename_to,
|
||||
|
|
@ -1914,6 +1999,9 @@ async def _update_entity_locked(
|
|||
category=category,
|
||||
photo_path=photo_rel,
|
||||
video_url=video_url,
|
||||
title=new_title,
|
||||
tagline=new_tagline,
|
||||
synthetic_name=new_synthetic,
|
||||
)
|
||||
|
||||
# v46: rename no longer needs an explicit attribution refresh — the
|
||||
|
|
@ -2529,7 +2617,7 @@ async def uninstall_entity(
|
|||
# `agnes admin store {pull,push}` CLI commands which back up the Store to a
|
||||
# git repo (or restore from one). Bundle format:
|
||||
#
|
||||
# agnes-store-bundle.zip
|
||||
# flea.zip
|
||||
# ├── manifest.json ← {"format":1,"generated_at":..., "entries":[...]}
|
||||
# └── entities/<entity_id>/
|
||||
# ├── plugin/... ← canonical Claude Code plugin tree
|
||||
|
|
@ -2756,7 +2844,7 @@ async def export_bundle(
|
|||
content=payload,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="agnes-store-bundle.zip"',
|
||||
"Content-Disposition": 'attachment; filename="flea.zip"',
|
||||
"X-Bundle-Entry-Count": str(len(items)),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1315,10 +1315,17 @@ async def store_new(
|
|||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||
):
|
||||
from src.store_categories import STORE_CATEGORIES
|
||||
from src.store_naming import TITLE_ACRONYMS, sanitize_username
|
||||
try:
|
||||
owner_username = sanitize_username(user.get("email") or "")
|
||||
except ValueError:
|
||||
owner_username = ""
|
||||
ctx = _build_context(
|
||||
request, user=user,
|
||||
categories=list(STORE_CATEGORIES),
|
||||
guardrail=_guardrail_thresholds(),
|
||||
title_acronyms=TITLE_ACRONYMS,
|
||||
owner_username=owner_username,
|
||||
)
|
||||
return templates.TemplateResponse(request, "store_upload.html", ctx)
|
||||
|
||||
|
|
@ -1375,6 +1382,7 @@ async def store_edit(
|
|||
if latest and latest.get("status") in ("pending_inline", "pending_llm"):
|
||||
pending_sub = latest
|
||||
|
||||
from src.store_naming import TITLE_ACRONYMS
|
||||
ctx = _build_context(
|
||||
request, user=user,
|
||||
entity=entity,
|
||||
|
|
@ -1382,6 +1390,8 @@ async def store_edit(
|
|||
is_owner=entity["owner_user_id"] == user["id"],
|
||||
categories=list(STORE_CATEGORIES),
|
||||
pending_sub=pending_sub,
|
||||
title_acronyms=TITLE_ACRONYMS,
|
||||
owner_username=entity.get("owner_username") or "",
|
||||
)
|
||||
return templates.TemplateResponse(request, "store_edit.html", ctx)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{{ item_name or inner_name or plugin_name }} — {{ config.INSTANCE_NAME }}{% endblock %}
|
||||
{% block title %}{{ (entity.title if entity else None) or inner_name or item_name or plugin_name }} — {{ config.INSTANCE_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
|
|
@ -809,12 +809,12 @@
|
|||
<span class="hwdot red"></span>
|
||||
<span class="hwdot yellow"></span>
|
||||
<span class="hwdot green"></span>
|
||||
<span class="hero-window-label" id="hero-window-label">{{ inner_name or item_name or plugin_name }}</span>
|
||||
<span class="hero-window-label" id="hero-window-label">{{ (entity.title if entity else None) or inner_name or item_name or plugin_name }}</span>
|
||||
</div>
|
||||
<div class="hero-window-body" id="hero-window-body">{{ 'SK' if kind == 'skill' else 'AG' }}</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<h1 id="hero-name">{{ inner_name or item_name or plugin_name }}</h1>
|
||||
<h1 id="hero-name">{{ (entity.title if entity else None) or inner_name or item_name or plugin_name }}</h1>
|
||||
<div class="hero-tagline" id="hero-tagline" hidden></div>
|
||||
<div class="meta-row" id="hero-meta-row"></div>
|
||||
<div class="pills" id="hero-pills">
|
||||
|
|
@ -1048,12 +1048,17 @@
|
|||
<span class="sep">›</span>
|
||||
<span class="current">${esc(heroTitle)}</span>`;
|
||||
} else {
|
||||
// v49 phase-2: drop `d.manifest_name` fallback. manifest_name is the
|
||||
// suffixed slug (e.g. `xlsx-by-c-marustamyan`) and users explicitly
|
||||
// didn't want to see it in breadcrumbs. heroTitle (resolved from
|
||||
// `display_name` which now mirrors `entity.title`) is the
|
||||
// user-friendly label.
|
||||
crumbs.innerHTML =
|
||||
`<a href="/marketplace?tab=flea">Marketplace</a>
|
||||
<span class="sep">›</span>
|
||||
<a href="/marketplace?tab=flea">Flea Market</a>
|
||||
<span class="sep">›</span>
|
||||
<span class="current">${esc(d.manifest_name || heroTitle)}</span>`;
|
||||
<span class="current">${esc(heroTitle)}</span>`;
|
||||
}
|
||||
|
||||
// ── Hero name ──────────────────────────────────────────────────────
|
||||
|
|
@ -1097,7 +1102,7 @@
|
|||
|
||||
// Invocation block — lives inside the Description panel so the "how to
|
||||
// call it" cue sits right under "what it does". Flea entities ship as
|
||||
// their own plugin (or the agnes-store-bundle), so the manifest_name IS
|
||||
// their own plugin (or the flea bundle), so the manifest_name IS
|
||||
// the slash invocation. Curated skills/agents live inside a parent
|
||||
// plugin, so Claude Code namespaces them as /<plugin>:<inner-name>.
|
||||
const invBlock = document.getElementById('invocation-block');
|
||||
|
|
@ -1147,8 +1152,12 @@
|
|||
// * Curated inner (always nested under a parent plugin) → "part of …"
|
||||
// * Flea inner (skill/agent nested in a flea plugin) → same layout,
|
||||
// parent link points at the flea plugin detail.
|
||||
// * Flea standalone (entity is itself the skill/agent) → "by author ·
|
||||
// N installed · size".
|
||||
// * Flea standalone (entity is itself the skill/agent) → hidden.
|
||||
// v49 phase-3: the prior "by <author> · N installed · size" line
|
||||
// duplicated three values that already live on dedicated surfaces
|
||||
// — install count in the hero telemetry chip below, owner +
|
||||
// bundle size in the Details sidebar. Empty meta-row would still
|
||||
// consume vertical rhythm via .meta-row's margin, so we hide it.
|
||||
const metaRow = document.getElementById('hero-meta-row');
|
||||
if (source === 'curated' || innerName) {
|
||||
const author = (d.parent_author_name && d.parent_author_name !== 'owner_todo')
|
||||
|
|
@ -1162,13 +1171,10 @@
|
|||
`<span>part of <a href="${parentHref}"><strong>${esc(parentLabel)}</strong></a></span>
|
||||
<span class="dot">·</span>
|
||||
<span>by ${author}</span>`;
|
||||
metaRow.hidden = false;
|
||||
} else {
|
||||
const author = d.owner_display || d.author_name || '';
|
||||
metaRow.innerHTML =
|
||||
`<span>by <strong>${esc(author)}</strong></span>
|
||||
<span class="dot">·</span>
|
||||
<span>${d.install_count || 0} installed</span>
|
||||
${d.bundle_size != null ? `<span class="dot">·</span><span>${esc(fmtBytes(d.bundle_size))}</span>` : ''}`;
|
||||
metaRow.innerHTML = '';
|
||||
metaRow.hidden = true;
|
||||
}
|
||||
|
||||
// Hero action — Curated nested redirects to parent plugin (no install at
|
||||
|
|
@ -1422,14 +1428,17 @@
|
|||
// ── Details sidebar — skip rows whose value is missing. The
|
||||
// `owner_todo` placeholder for the Curator row stays as a deliberate
|
||||
// reminder to wire up curator metadata.
|
||||
//
|
||||
// v49 phase-7: unified scan order with plugin detail surface.
|
||||
// Priority: identity (who) → life-stage (when) → telemetry (how used)
|
||||
// → debug-tier (version, size). Inner skills/agents pivot on parent
|
||||
// plugin, so Parent plugin slots immediately after authorship.
|
||||
const dl = document.getElementById('details-list');
|
||||
// Activity rows for the sidebar — Invocations + Users live in the
|
||||
// hero chip now, no need to duplicate. Two derived signals only the
|
||||
// daily series can give us:
|
||||
// * Active days — engagement consistency over the 30d window.
|
||||
// * Last used — recency (answers "is this still being used?").
|
||||
|
||||
// Telemetry helper — Last used first (recency, primary decision
|
||||
// driver), Active days second (engagement consistency over 30d).
|
||||
// Both rendered only when the item has at least one active day so a
|
||||
// brand-new skill doesn't show "0 of 30" + "Last used —".
|
||||
// brand-new skill doesn't show "Last used —" + "0 of 30".
|
||||
const telemetryRows = (() => {
|
||||
if (!d.telemetry || !Array.isArray(d.telemetry.daily_series)) return '';
|
||||
const series = d.telemetry.daily_series;
|
||||
|
|
@ -1439,38 +1448,45 @@
|
|||
for (let i = series.length - 1; i >= 0; i--) {
|
||||
if ((series[i].invocations || 0) > 0) { lastIso = series[i].day; break; }
|
||||
}
|
||||
return `<div class="row"><dt>Active days</dt><dd>${activeDays} of ${series.length}</dd></div>`
|
||||
+ (lastIso
|
||||
return (lastIso
|
||||
? `<div class="row"><dt>Last used</dt><dd>${esc(fmtRelative(lastIso))}</dd></div>`
|
||||
: '');
|
||||
: '')
|
||||
+ `<div class="row"><dt>Active days</dt><dd>${activeDays} of ${series.length}</dd></div>`;
|
||||
})();
|
||||
|
||||
if (source === 'curated' || innerName) {
|
||||
// Nested skill/agent (curated OR flea-inner): the Details sidebar
|
||||
// is parent-centric — Parent plugin / Bundle size / activity rows /
|
||||
// authorship label. The authorship row label tracks the source
|
||||
// (curated bundles have a Curator, flea bundles have an Owner) so
|
||||
// the label vocabulary stays consistent with the plugin detail page.
|
||||
// Nested skill/agent (curated OR flea-inner). Sidebar render order:
|
||||
// 1. Curator / Owner — first scan signal (trust)
|
||||
// 2. Parent plugin — where this lives
|
||||
// 3. Last used — recency
|
||||
// 4. Active days — engagement
|
||||
// 5. Bundle size — debug-tier
|
||||
const author = (d.parent_author_name && d.parent_author_name !== 'owner_todo')
|
||||
? esc(d.parent_author_name)
|
||||
: '<span class="todo">owner_todo</span>';
|
||||
const authorLabel = source === 'curated' ? 'Curator' : 'Owner';
|
||||
// Type already shown as a badge in the hero — skip the row here.
|
||||
dl.innerHTML = `
|
||||
<div class="row"><dt>${authorLabel}</dt><dd>${author}</dd></div>
|
||||
<div class="row"><dt>Parent plugin</dt><dd>${esc(d.parent_display_name || d.manifest_name || pluginName)}</dd></div>
|
||||
${d.bundle_size != null ? `<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>` : ''}
|
||||
${telemetryRows}
|
||||
<div class="row"><dt>${authorLabel}</dt><dd>${author}</dd></div>`;
|
||||
${d.bundle_size != null ? `<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>` : ''}`;
|
||||
} else {
|
||||
// Flea standalone skill/agent. Sidebar render order:
|
||||
// 1. Owner — first scan signal (trust)
|
||||
// 2. Released — life-stage
|
||||
// 3. Last used — recency
|
||||
// 4. Active days — engagement
|
||||
// 5. Version — debug-tier
|
||||
// 6. Bundle size — debug-tier
|
||||
// Drops the previous Category + Installs rows (duplicated hero
|
||||
// badge + telemetry chip — see v49 phase-7 notes).
|
||||
const ownerLabel = d.owner_display || d.author_name || '';
|
||||
// Type already shown as a badge in the hero — skip the row here.
|
||||
dl.innerHTML = `
|
||||
${ownerLabel ? `<div class="row"><dt>Owner</dt><dd>${esc(ownerLabel)}</dd></div>` : ''}
|
||||
${d.version ? `<div class="row"><dt>Version</dt><dd class="mono">v${esc(d.version)}</dd></div>` : ''}
|
||||
${d.category ? `<div class="row"><dt>Category</dt><dd>${esc(d.category)}</dd></div>` : ''}
|
||||
${d.bundle_size != null ? `<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>` : ''}
|
||||
<div class="row"><dt>Installs</dt><dd>${d.install_count || 0}</dd></div>
|
||||
${d.released_at ? `<div class="row"><dt>Released</dt><dd>${esc(fmtRelative(d.released_at))}</dd></div>` : ''}
|
||||
${telemetryRows}
|
||||
${d.released_at ? `<div class="row"><dt>Released</dt><dd>${esc(fmtRelative(d.released_at))}</dd></div>` : ''}`;
|
||||
${d.version ? `<div class="row"><dt>Version</dt><dd class="mono">v${esc(d.version)}</dd></div>` : ''}
|
||||
${d.bundle_size != null ? `<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>` : ''}`;
|
||||
}
|
||||
|
||||
// ── Docs section ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{{ plugin_name }} — {{ config.INSTANCE_NAME }}{% endblock %}
|
||||
{% block title %}{{ (entity.title if entity else None) or plugin_name }} — {{ config.INSTANCE_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
|
|
@ -855,7 +855,7 @@
|
|||
<span class="sep">›</span>
|
||||
<a href="/marketplace?tab={{ 'curated' if source == 'curated' else 'flea' }}" id="crumb-mid">{{ 'Curated Marketplace' if source == 'curated' else 'Flea Market' }}</a>
|
||||
<span class="sep">›</span>
|
||||
<span id="crumb-name">{{ plugin_name }}</span>
|
||||
<span id="crumb-name">{{ (entity.title if entity else None) or plugin_name }}</span>
|
||||
</div>
|
||||
<div class="hero-head">
|
||||
<div class="hero-window" id="hero-photo" aria-hidden="true">
|
||||
|
|
@ -863,13 +863,16 @@
|
|||
<span class="hwdot red"></span>
|
||||
<span class="hwdot yellow"></span>
|
||||
<span class="hwdot green"></span>
|
||||
<span class="hero-window-label" id="hero-window-label">{{ plugin_name }}</span>
|
||||
<span class="hero-window-label" id="hero-window-label">{{ (entity.title if entity else None) or plugin_name }}</span>
|
||||
</div>
|
||||
<div class="hero-window-body" id="hero-window-body">PL</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<h1 id="hero-name">{{ plugin_name }}</h1>
|
||||
<div class="tagline" id="hero-tagline">Loading…</div>
|
||||
<h1 id="hero-name">{{ (entity.title if entity else None) or plugin_name }}</h1>
|
||||
<!-- v49 phase-2: hidden by default; JS only un-hides when d.tagline
|
||||
is truthy. Pre-fix this rendered "Loading…" then collapsed to
|
||||
empty for entities without a tagline, causing a flicker. -->
|
||||
<div class="tagline" id="hero-tagline" hidden></div>
|
||||
<div class="pills" id="hero-pills"></div>
|
||||
<!-- Telemetry funnel chip — same shape as the listing card so a
|
||||
user who clicks through from /marketplace sees identical
|
||||
|
|
@ -1042,7 +1045,19 @@
|
|||
document.title = `${heroTitle} — Marketplace`;
|
||||
|
||||
document.getElementById('hero-name').textContent = heroTitle;
|
||||
document.getElementById('hero-tagline').textContent = d.tagline || d.description || '';
|
||||
// v49 phase-2: tagline appears in hero only when explicitly set. Pre-fix
|
||||
// it fell back to `d.description` (verbose long-form text from frontmatter
|
||||
// / marketplace.json), which read awkwardly under the h1 and pulled the
|
||||
// hero too tall. Description still renders in the "What it does" panel
|
||||
// below the hero.
|
||||
const tagEl = document.getElementById('hero-tagline');
|
||||
if (d.tagline) {
|
||||
tagEl.textContent = d.tagline;
|
||||
tagEl.hidden = false;
|
||||
} else {
|
||||
tagEl.textContent = '';
|
||||
tagEl.hidden = true;
|
||||
}
|
||||
// Window titlebar label mirrors the h1 — keeps the mac-window framing
|
||||
// consistent with whichever name the user actually sees as the title.
|
||||
const heroWindowLabel = document.getElementById('hero-window-label');
|
||||
|
|
@ -1347,41 +1362,49 @@
|
|||
}
|
||||
|
||||
// ── Details ─────────────────────────────────────────────────────
|
||||
// Render only rows that have a real value — missing/null/owner_todo
|
||||
// entries get hidden so the panel stays compact.
|
||||
// v49 phase-7: unified scan order across plugin + skill/agent detail
|
||||
// surfaces. Priority: identity (who) → life-stage (when) → telemetry
|
||||
// (how used) → debug-tier (size). Slug field dropped — debug info,
|
||||
// not user-facing.
|
||||
//
|
||||
// Render order:
|
||||
// 1. Curator / Owner — first scan signal (trust)
|
||||
// 2. Released — life-stage
|
||||
// 3. Last used — recency (higher priority than Active days)
|
||||
// 4. Active days — engagement consistency over 30d
|
||||
// 5. Bundle size — debug-tier
|
||||
//
|
||||
// Each row is conditional on data presence so the panel stays compact.
|
||||
const detailRows = [];
|
||||
const slugVal = d.source === 'curated' ? d.marketplace_id : d.entity_id;
|
||||
if (slugVal) {
|
||||
detailRows.push(`<div class="row"><dt>Slug</dt><dd class="mono">${esc(slugVal)}</dd></div>`);
|
||||
|
||||
// 1. Curator/Owner. Curated → d.author_name (with owner_todo placeholder
|
||||
// surfaced as visible reminder). Flea → d.owner_display (fullname from
|
||||
// users.name → users.email → owner_username chain, populated by
|
||||
// _resolve_owner_display in app/api/marketplace.py).
|
||||
if (d.source === 'curated') {
|
||||
if (d.author_name && d.author_name !== 'owner_todo') {
|
||||
detailRows.push(`<div class="row"><dt>Curator</dt><dd>${esc(d.author_name)}</dd></div>`);
|
||||
} else {
|
||||
detailRows.push(`<div class="row"><dt>Curator</dt><dd><span class="todo">owner_todo</span></dd></div>`);
|
||||
}
|
||||
} else if (d.owner_display) {
|
||||
detailRows.push(`<div class="row"><dt>Owner</dt><dd>${esc(d.owner_display)}</dd></div>`);
|
||||
}
|
||||
|
||||
// 2. Released.
|
||||
if (d.released_at) {
|
||||
detailRows.push(`<div class="row"><dt>Released</dt><dd>${esc(fmtRelative(d.released_at))}</dd></div>`);
|
||||
}
|
||||
if (d.bundle_size != null) {
|
||||
detailRows.push(`<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>`);
|
||||
}
|
||||
// Activity sidebar rows — Invocations + Users were moved into the
|
||||
// hero chip above (no need to duplicate the same figures twice on
|
||||
// one page). The sidebar now carries two *derived* signals from the
|
||||
// daily series that the hero doesn't expose:
|
||||
// * Active days — engagement consistency over the 30d window.
|
||||
// "Active on 18 of 30 days" reads very differently from
|
||||
// "Active on 2 of 30 days" even when the raw call count is
|
||||
// similar; helps spot bursty vs. steady usage.
|
||||
// * Last used — recency, answering "is it still alive?". A
|
||||
// plugin with 12 installed users but last call 30 days ago is
|
||||
// stale; the hero chip can't surface that alone.
|
||||
|
||||
// 3-4. Telemetry-derived rows. Invocations + Users live in the hero
|
||||
// chip; sidebar carries two derived signals only the daily series can
|
||||
// give us. Last used first (recency), Active days second (consistency).
|
||||
if (d.telemetry && Array.isArray(d.telemetry.daily_series)) {
|
||||
const series = d.telemetry.daily_series;
|
||||
const activeDays = series.filter(p => (p.invocations || 0) > 0).length;
|
||||
if (activeDays > 0) {
|
||||
detailRows.push(
|
||||
`<div class="row"><dt>Active days</dt><dd>${activeDays} of ${series.length}</dd></div>`
|
||||
);
|
||||
// Last day with at least one invocation. Series is day-ascending
|
||||
// (per _load_plugin_daily_series), so the final non-zero entry is
|
||||
// the latest active day. Render relative ("2 days ago" / "Active
|
||||
// today") via the same fmtRelative helper used elsewhere.
|
||||
// Last day with at least one invocation. Series is day-ascending,
|
||||
// so the final non-zero entry is the latest active day.
|
||||
let lastIso = null;
|
||||
for (let i = series.length - 1; i >= 0; i--) {
|
||||
if ((series[i].invocations || 0) > 0) { lastIso = series[i].day; break; }
|
||||
|
|
@ -1391,19 +1414,17 @@
|
|||
`<div class="row"><dt>Last used</dt><dd>${esc(fmtRelative(lastIso))}</dd></div>`
|
||||
);
|
||||
}
|
||||
detailRows.push(
|
||||
`<div class="row"><dt>Active days</dt><dd>${activeDays} of ${series.length}</dd></div>`
|
||||
);
|
||||
}
|
||||
}
|
||||
// Authorship row label tracks the source: curated bundles have a
|
||||
// curator (the marketplace operator), flea bundles have an owner
|
||||
// (the analyst who uploaded). When `author_name` is the `owner_todo`
|
||||
// placeholder the curated branch surfaces it visibly as a reminder
|
||||
// to wire up curator metadata; flea falls through silently.
|
||||
const authorLabel = d.source === 'curated' ? 'Curator' : 'Owner';
|
||||
if (d.author_name && d.author_name !== 'owner_todo') {
|
||||
detailRows.push(`<div class="row"><dt>${authorLabel}</dt><dd>${esc(d.author_name)}</dd></div>`);
|
||||
} else if (d.source === 'curated') {
|
||||
detailRows.push(`<div class="row"><dt>Curator</dt><dd><span class="todo">owner_todo</span></dd></div>`);
|
||||
|
||||
// 5. Bundle size.
|
||||
if (d.bundle_size != null) {
|
||||
detailRows.push(`<div class="row"><dt>Bundle size</dt><dd>${esc(fmtBytes(d.bundle_size))}</dd></div>`);
|
||||
}
|
||||
|
||||
const detailsEl = document.getElementById('details-list');
|
||||
if (detailRows.length) {
|
||||
detailsEl.innerHTML = detailRows.join('');
|
||||
|
|
|
|||
|
|
@ -61,6 +61,29 @@
|
|||
.banner[hidden] { display: none !important; }
|
||||
.banner.error { background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca; }
|
||||
.banner > span { white-space: pre-wrap; }
|
||||
|
||||
/* v49: matches the synthetic-name preview styling on store_upload.html
|
||||
so upload + edit forms read identically. */
|
||||
.name-row {
|
||||
display: flex; align-items: stretch; gap: 10px; flex-wrap: wrap;
|
||||
}
|
||||
.name-row input[type=text] {
|
||||
flex: 0 1 320px; min-width: 220px;
|
||||
}
|
||||
.invocation-preview {
|
||||
flex: 1 1 320px; min-width: 0;
|
||||
display: flex; align-items: center;
|
||||
background: #1e1e2e; color: #cdd6f4;
|
||||
border-radius: 6px; padding: 0 14px;
|
||||
font-family: var(--font-mono, monospace); font-size: 13px;
|
||||
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
|
||||
}
|
||||
.invocation-preview .prompt {
|
||||
color: #a6e3a1; user-select: none; margin-right: 2px;
|
||||
}
|
||||
.invocation-preview #synthetic-text {
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="page-shell">
|
||||
|
|
@ -85,11 +108,25 @@
|
|||
</div>
|
||||
|
||||
<form id="edit-form">
|
||||
<div class="field">
|
||||
<label class="field-label" for="f-title">Title</label>
|
||||
<input id="f-title" name="title" type="text" maxlength="100" required
|
||||
value="{{ entity.title or '' }}"
|
||||
data-user-edited="{{ 'true' if entity.title else 'false' }}"
|
||||
{% if pending_sub %}disabled{% endif %}>
|
||||
<div class="field-help">Human-friendly name shown on marketplace cards.</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="f-name">Display name</label>
|
||||
<div class="name-row">
|
||||
<input id="f-name" name="name" type="text" value="{{ entity.name | store_display_name }}"
|
||||
pattern="^[a-z][a-z0-9-]{0,63}$"
|
||||
{% if pending_sub %}disabled{% endif %}>
|
||||
<div class="invocation-preview" id="synthetic-preview" aria-live="polite">
|
||||
<span class="prompt">/</span><span id="synthetic-text">{{ entity.synthetic_name or (entity.name ~ '-by-' ~ entity.owner_username) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-help warn">
|
||||
⚠ Changing the name renames the plugin slug for existing
|
||||
installers. They'll see the plugin renamed on their next sync
|
||||
|
|
@ -97,6 +134,15 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="f-tagline">Short description <span style="color:var(--text-secondary,#6b7280);font-weight:400;">(optional)</span></label>
|
||||
<input id="f-tagline" name="tagline" type="text" maxlength="200"
|
||||
value="{{ entity.tagline or '' }}"
|
||||
placeholder="One-line summary shown alongside the entity name"
|
||||
{% if pending_sub %}disabled{% endif %}>
|
||||
<div id="tagline-counter" class="field-help" style="font-family: var(--font-mono); font-size: 12px;">0 / 200 max</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="f-description">Description</label>
|
||||
<textarea id="f-description" name="description" maxlength="1000"
|
||||
|
|
@ -246,6 +292,9 @@ function humanizeError(detail) {
|
|||
if (code === 'conflict_owner_name') return 'You already have a plugin with that name.';
|
||||
if (code === 'conflict_global_suffix') return 'That name conflicts with another user\'s plugin slug.';
|
||||
if (code === 'invalid_name_format') return 'Name must be lowercase letters / digits / hyphens, starting with a letter.';
|
||||
if (code === 'title_required') return 'Title is required.';
|
||||
if (code === 'title_too_long') return 'Title is too long — max 100 characters.';
|
||||
if (code === 'tagline_too_long') return 'Short description is too long — max 200 characters.';
|
||||
if (code) return 'Save failed: ' + code;
|
||||
try { return 'Save failed: ' + JSON.stringify(detail); } catch (_) { return 'Save failed.'; }
|
||||
}
|
||||
|
|
@ -287,6 +336,53 @@ wireDropZone(
|
|||
(f) => f.size > 5 * 1024 * 1024 ? 'Photo too large (max 5 MB).' : null,
|
||||
);
|
||||
|
||||
// v49 phase-1 — Title, Tagline, synthetic preview wiring (mirrors
|
||||
// store_upload.html). Acronym dict + owner injected via template ctx.
|
||||
const TITLE_ACRONYMS = {{ title_acronyms|tojson if title_acronyms else "{}" }};
|
||||
const OWNER_USERNAME = {{ owner_username|tojson if owner_username else '""' }};
|
||||
const titleInput = document.getElementById('f-title');
|
||||
const taglineInput = document.getElementById('f-tagline');
|
||||
const taglineCounter = document.getElementById('tagline-counter');
|
||||
const fNameInput = document.getElementById('f-name');
|
||||
const syntheticText = document.getElementById('synthetic-text');
|
||||
|
||||
function humanizeName(name) {
|
||||
if (!name) return '';
|
||||
const tokens = String(name).split('-').filter(Boolean);
|
||||
return tokens.map(tok => {
|
||||
const canonical = TITLE_ACRONYMS[tok.toLowerCase()];
|
||||
if (canonical !== undefined) return canonical;
|
||||
return tok.charAt(0).toUpperCase() + tok.slice(1).toLowerCase();
|
||||
}).join(' ');
|
||||
}
|
||||
function updateSyntheticPreview() {
|
||||
const name = (fNameInput.value || '').trim();
|
||||
if (!name) {
|
||||
syntheticText.textContent = 'your-name-by-' + (OWNER_USERNAME || 'you');
|
||||
} else {
|
||||
syntheticText.textContent = name + '-by-' + (OWNER_USERNAME || 'you');
|
||||
}
|
||||
}
|
||||
function maybeAutoFillTitle() {
|
||||
if (titleInput.dataset.userEdited === 'true') return;
|
||||
const name = (fNameInput.value || '').trim();
|
||||
titleInput.value = humanizeName(name);
|
||||
}
|
||||
function updateTaglineCounter() {
|
||||
const len = (taglineInput.value || '').length;
|
||||
taglineCounter.textContent = `${len} / 200 max`;
|
||||
}
|
||||
titleInput.addEventListener('input', () => {
|
||||
titleInput.dataset.userEdited = titleInput.value.trim() ? 'true' : 'false';
|
||||
});
|
||||
fNameInput.addEventListener('input', () => {
|
||||
updateSyntheticPreview();
|
||||
maybeAutoFillTitle();
|
||||
});
|
||||
taglineInput.addEventListener('input', updateTaglineCounter);
|
||||
updateSyntheticPreview();
|
||||
updateTaglineCounter();
|
||||
|
||||
document.getElementById('edit-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
clearBanner();
|
||||
|
|
@ -300,6 +396,13 @@ document.getElementById('edit-form').addEventListener('submit', async (e) => {
|
|||
fd.append('description', document.getElementById('f-description').value);
|
||||
fd.append('category', document.getElementById('f-category').value);
|
||||
fd.append('video_url', document.getElementById('f-video').value);
|
||||
// v49: title is required when present; always send so server can
|
||||
// detect "no change" (same value) vs "user-cleared". Tagline sends
|
||||
// empty string to clear.
|
||||
const titleVal = titleInput.value.trim();
|
||||
if (!titleVal) { showError('Title is required.'); saveBtn.disabled = false; saveBtn.textContent = 'Save'; return; }
|
||||
fd.append('title', titleVal);
|
||||
fd.append('tagline', taglineInput.value.trim());
|
||||
const zip = document.getElementById('zip').files[0];
|
||||
if (zip) fd.append('file', zip);
|
||||
const photo = document.getElementById('photo').files[0];
|
||||
|
|
|
|||
|
|
@ -210,6 +210,47 @@
|
|||
}
|
||||
.desc-counter.ok { color: #16a34a; }
|
||||
.desc-counter.warn { color: #b45309; }
|
||||
|
||||
/* v49 phase-1: name row pairs the editable kebab-case input with a
|
||||
read-only dark "what Claude Code will see" preview. Visual language
|
||||
matches the dark .invocation block on marketplace_item_detail.html
|
||||
(Catppuccin Mocha #1e1e2e bg, #cdd6f4 fg, #a6e3a1 prompt) so the
|
||||
two surfaces read as the same concept. No copy button here — this
|
||||
is a live preview, not an artifact to copy. */
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.name-row input[type="text"] {
|
||||
flex: 0 1 320px;
|
||||
min-width: 220px;
|
||||
}
|
||||
.invocation-preview {
|
||||
flex: 1 1 320px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
border-radius: 8px;
|
||||
padding: 0 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.invocation-preview .prompt {
|
||||
color: #a6e3a1;
|
||||
user-select: none;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.invocation-preview #synthetic-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.guidelines {
|
||||
margin-bottom: 14px;
|
||||
background: var(--background, #f9fafb);
|
||||
|
|
@ -465,10 +506,31 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="title">Title</label>
|
||||
<input type="text" id="title" maxlength="100" required
|
||||
placeholder="My Awesome Skill"
|
||||
data-user-edited="false">
|
||||
<div class="field-hint">Human-friendly name shown on marketplace cards. Pre-filled from Name, edit freely.</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="name">Name</label>
|
||||
<div class="name-row">
|
||||
<input type="text" id="name" required placeholder="my-awesome-skill">
|
||||
<div class="field-hint">Lowercase letters, digits, hyphens. Max 64 characters.</div>
|
||||
<div class="invocation-preview" id="synthetic-preview" aria-live="polite">
|
||||
<span class="prompt">/</span><span id="synthetic-text">your-name-by-{{ owner_username|default("you") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-hint">Lowercase letters, digits, hyphens. Max 64 characters. Final invocation shown to the right.</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="tagline">Short description <span style="color:var(--text-secondary,#6b7280);font-weight:400;">(optional)</span></label>
|
||||
<input type="text" id="tagline" maxlength="200"
|
||||
placeholder="One-line summary shown alongside the entity name">
|
||||
<div id="tagline-counter" class="desc-counter">0 / 200 max</div>
|
||||
<div class="field-hint">Max 200 characters. Renders next to the title on marketplace listings.</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
|
@ -559,10 +621,73 @@ const photoLabel = document.getElementById('photo-label');
|
|||
const addDocBtn = document.getElementById('add-doc-btn');
|
||||
const docInput = document.getElementById('doc-input');
|
||||
const docList = document.getElementById('doc-list');
|
||||
// v49 phase-1 — Title + Tagline inputs and the read-only synthetic preview.
|
||||
const titleInput = document.getElementById('title');
|
||||
const taglineInput = document.getElementById('tagline');
|
||||
const taglineCounter = document.getElementById('tagline-counter');
|
||||
const nameInput = document.getElementById('name');
|
||||
const syntheticText = document.getElementById('synthetic-text');
|
||||
|
||||
// Acronym dict + owner username injected from the route handler so JS
|
||||
// humanize stays in sync with the Python TITLE_ACRONYMS source.
|
||||
const TITLE_ACRONYMS = {{ title_acronyms|tojson if title_acronyms else "{}" }};
|
||||
const OWNER_USERNAME = {{ owner_username|tojson if owner_username else '""' }};
|
||||
|
||||
let zipFile = null;
|
||||
let docs = [];
|
||||
|
||||
// Replicates src/store_naming.py:humanize_name(). Split on `-`, drop empty
|
||||
// tokens, replace each token with its canonical acronym form (case-insensitive
|
||||
// lookup) or Title Case it. Join with spaces.
|
||||
function humanizeName(name) {
|
||||
if (!name) return '';
|
||||
const tokens = String(name).split('-').filter(Boolean);
|
||||
return tokens.map(tok => {
|
||||
const canonical = TITLE_ACRONYMS[tok.toLowerCase()];
|
||||
if (canonical !== undefined) return canonical;
|
||||
return tok.charAt(0).toUpperCase() + tok.slice(1).toLowerCase();
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
// Updates the read-only `/<name>-by-<owner>` preview as the user types
|
||||
// in the Name field. Empty name shows a placeholder.
|
||||
function updateSyntheticPreview() {
|
||||
const name = (nameInput.value || '').trim();
|
||||
if (!name) {
|
||||
syntheticText.textContent = 'your-name-by-' + (OWNER_USERNAME || 'you');
|
||||
} else {
|
||||
syntheticText.textContent = name + '-by-' + (OWNER_USERNAME || 'you');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-fill Title from Name unless the user has manually edited Title.
|
||||
// We track manual edits via a `data-user-edited` flag on the input so
|
||||
// flipping back to auto-fill is possible by clearing the title.
|
||||
function maybeAutoFillTitle() {
|
||||
if (titleInput.dataset.userEdited === 'true') return;
|
||||
const name = (nameInput.value || '').trim();
|
||||
titleInput.value = humanizeName(name);
|
||||
}
|
||||
|
||||
titleInput.addEventListener('input', () => {
|
||||
titleInput.dataset.userEdited = titleInput.value.trim() ? 'true' : 'false';
|
||||
});
|
||||
nameInput.addEventListener('input', () => {
|
||||
updateSyntheticPreview();
|
||||
maybeAutoFillTitle();
|
||||
});
|
||||
|
||||
// Tagline counter (mirror desc-counter pattern).
|
||||
function updateTaglineCounter() {
|
||||
const len = (taglineInput.value || '').length;
|
||||
taglineCounter.textContent = `${len} / 200 max`;
|
||||
taglineCounter.classList.toggle('warn', len > 200);
|
||||
}
|
||||
taglineInput.addEventListener('input', updateTaglineCounter);
|
||||
// Initialize on load.
|
||||
updateSyntheticPreview();
|
||||
updateTaglineCounter();
|
||||
|
||||
// Server returns short machine codes in `detail`. Map them to human-friendly
|
||||
// sentences here. Any unknown code falls back to a generic prefix + the raw
|
||||
// code so debugging stays possible without leaking jargon.
|
||||
|
|
@ -595,6 +720,9 @@ const ERROR_MESSAGES = {
|
|||
'Couldn\'t derive a username from your email. Contact your administrator.',
|
||||
conflict_owner_name:
|
||||
'You already have a Store entity with this name. Each owner needs unique names — pick a different one or delete the existing entity first.',
|
||||
title_required: 'Title is required.',
|
||||
title_too_long: 'Title is too long — max 100 characters.',
|
||||
tagline_too_long: 'Short description is too long — max 200 characters.',
|
||||
|
||||
// Step 2 — photo
|
||||
photo_unsupported_format: 'Photo must be JPG, PNG, or WebP.',
|
||||
|
|
@ -1052,7 +1180,14 @@ nextBtn.addEventListener('click', async () => {
|
|||
const preview = await res.json();
|
||||
document.getElementById('name').value = preview.name || '';
|
||||
document.getElementById('description').value = preview.description || '';
|
||||
// v49: pre-fill Title from server-computed humanize unless the user
|
||||
// already touched the field (e.g. came back via Back). Keep the
|
||||
// user-edited flag stable so re-entering step 2 doesn't clobber.
|
||||
if (titleInput.dataset.userEdited !== 'true') {
|
||||
titleInput.value = preview.title || humanizeName(preview.name || '');
|
||||
}
|
||||
updateDescCounter();
|
||||
updateSyntheticPreview();
|
||||
renderComponents(preview.components || []);
|
||||
showStep(2);
|
||||
} catch (err) {
|
||||
|
|
@ -1070,6 +1205,9 @@ finishBtn.addEventListener('click', async () => {
|
|||
clearBanner();
|
||||
const name = document.getElementById('name').value.trim();
|
||||
if (!name) { showError('Name is required.'); return; }
|
||||
// v49: Title is required (NOT NULL in DB). Auto-fill should have set
|
||||
// it; this guards manual clears.
|
||||
if (!titleInput.value.trim()) { showError('Title is required.'); return; }
|
||||
|
||||
function isContentBlock(detail) {
|
||||
if (!detail || !detail.checks) return false;
|
||||
|
|
@ -1091,6 +1229,10 @@ finishBtn.addEventListener('click', async () => {
|
|||
fd.append('type', type);
|
||||
fd.append('name', name);
|
||||
fd.append('description', document.getElementById('description').value);
|
||||
// v49: title required, tagline optional.
|
||||
fd.append('title', titleInput.value.trim());
|
||||
const tag = taglineInput.value.trim();
|
||||
if (tag) fd.append('tagline', tag);
|
||||
const cat = document.getElementById('category').value;
|
||||
if (cat) fd.append('category', cat);
|
||||
const vurl = document.getElementById('video_url').value.trim();
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ def pull_bundle(
|
|||
owner: Optional[str] = typer.Option(None, "--owner", help="Filter by owner user_id"),
|
||||
search: Optional[str] = typer.Option(None, "--search", "-q"),
|
||||
out: Path = typer.Option(
|
||||
Path("agnes-store-bundle.zip"), "-o", "--out",
|
||||
help="Where to save the ZIP (default: ./agnes-store-bundle.zip)",
|
||||
Path("flea.zip"), "-o", "--out",
|
||||
help="Where to save the ZIP (default: ./flea.zip)",
|
||||
),
|
||||
unpack: Optional[Path] = typer.Option(
|
||||
None, "--unpack",
|
||||
|
|
|
|||
|
|
@ -40,17 +40,24 @@ from dataclasses import dataclass
|
|||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Iterator
|
||||
|
||||
# v6: MarketplaceItemLookup now resolves <flea_plugin>:<inner> prefixes so
|
||||
# skills/agents nested inside a flea plugin bundle attribute to source='flea'
|
||||
# with parent_plugin=<plugin name> (same shape as curated nested attribution).
|
||||
# Pre-v6 these landed as ('builtin', '', None) and never flowed into the
|
||||
# rollup tables. Bump forces re-attribution on the next reprocess tick.
|
||||
# v9: phase-6 plugin-level rollup parity for flea. `_aggregate_events`
|
||||
# now produces synthetic (source='flea', type='plugin', parent_plugin='',
|
||||
# name=<plugin_synth>) rows aggregating nested skill/agent invocations,
|
||||
# mirroring the curated path. Without this, flea plugin entity cards +
|
||||
# detail telemetry chips read 0 from `_load_invocation_stats` (which
|
||||
# filters `parent_plugin = ''` for flea) even though nested children
|
||||
# had correct rollup rows. Bump forces a re-aggregation pass so historic
|
||||
# nested-invocation data fills the new plugin-level rows.
|
||||
# (v8: phase-5 attribution keyspace fix + phase-4 bundle rename. Lookup
|
||||
# tables key by `store_entities.synthetic_name` instead of `name`;
|
||||
# `_attribute_event` gained the flea-plugin-nested branch so nested
|
||||
# skills inside flea plugins flow into rollup tables.)
|
||||
# (v5: v46 marketplace-telemetry refactor swapped AttributionLookup for
|
||||
# MarketplaceItemLookup. Identifier prefix (`<plugin>:<local>`) now drives
|
||||
# attribution and usage_events.source / ref_id are populated per-event from
|
||||
# the live marketplace_plugins + store_entities tables.)
|
||||
# (v4: #293 user_id column; v3: #303 <command-name> slash extraction.)
|
||||
USAGE_PROCESSOR_VERSION = 6
|
||||
USAGE_PROCESSOR_VERSION = 9
|
||||
|
||||
# Claude Code wraps user-typed slash invocations as
|
||||
# <command-name>/<name></command-name> inside the user message content
|
||||
|
|
@ -243,8 +250,8 @@ def iter_events(turns: list[dict]) -> Iterator[ParsedEvent]:
|
|||
|
||||
# Synthetic plugin name Agnes uses to bundle flea-market store entities into
|
||||
# a single Claude Code marketplace surface. Skill/agent/command identifiers
|
||||
# from flea entities arrive as `agnes-store-bundle:<entity-name>` in the JSONL.
|
||||
FLEA_BUNDLE_PREFIX = "agnes-store-bundle"
|
||||
# from flea entities arrive as `flea:<synthetic_name>` in the JSONL.
|
||||
FLEA_BUNDLE_PREFIX = "flea"
|
||||
|
||||
|
||||
class MarketplaceItemLookup:
|
||||
|
|
@ -254,7 +261,7 @@ class MarketplaceItemLookup:
|
|||
Claude Code writes plugin-defined skill/agent/command identifiers in the
|
||||
JSONL as ``<plugin_name>:<local_name>`` (e.g. ``grpn:design``). The prefix
|
||||
is the *plugin* name (curated plugin name, or the synthetic
|
||||
``agnes-store-bundle`` for flea entities); the local part is the
|
||||
``flea`` for flea entities); the local part is the
|
||||
skill/agent/command name relative to that plugin. Identifiers without
|
||||
a ``:`` are either built-in tools (``Bash``, ``Read``, …) or flat slash
|
||||
commands (``/exit``) — neither participates in marketplace telemetry.
|
||||
|
|
@ -274,20 +281,26 @@ class MarketplaceItemLookup:
|
|||
"SELECT DISTINCT name FROM marketplace_plugins"
|
||||
).fetchall()
|
||||
}
|
||||
# Flea entities are looked up by name; we also need their type to
|
||||
# determine whether the invocation should land as skill, agent or
|
||||
# plugin in the rollup tables.
|
||||
# v49 phase-5: lookup table keyed by `synthetic_name` (the
|
||||
# `<name>-by-<owner>` slug baked into the served plugin tree). Claude
|
||||
# Code writes the local part of a flea invocation as that synthetic
|
||||
# name (`flea:xlsx-by-c-marustamyan`), so matching against `name`
|
||||
# (un-suffixed) never landed. Type comes along so the rollup writer
|
||||
# knows whether to record the invocation as skill / agent / plugin.
|
||||
self._flea_entities: dict[str, str] = {
|
||||
row[0]: row[1] for row in conn.execute(
|
||||
"SELECT name, type FROM store_entities WHERE visibility_status='approved'"
|
||||
"SELECT synthetic_name, type FROM store_entities WHERE visibility_status='approved'"
|
||||
).fetchall()
|
||||
}
|
||||
# Flea PLUGIN entities can be matched as a prefix too — `<plugin>:<inner>`
|
||||
# invocations of a skill / agent that lives inside a flea plugin bundle
|
||||
# land here, mirroring the curated nested attribution path. Standalone
|
||||
# flea entities still flow through the FLEA_BUNDLE_PREFIX branch.
|
||||
# Set carries synthetic_names because that's the plugin slug Claude
|
||||
# Code resolves at install time (v49 phase-4: `data["name"] = suffixed`
|
||||
# in `_bake_plugin_tree` for type='plugin' entities).
|
||||
self._flea_plugins: set[str] = {
|
||||
name for name, ent_type in self._flea_entities.items()
|
||||
synthetic for synthetic, ent_type in self._flea_entities.items()
|
||||
if ent_type == "plugin"
|
||||
}
|
||||
|
||||
|
|
@ -480,14 +493,21 @@ def _identifier_split(skill_name, subagent_type, command_name, event_type):
|
|||
|
||||
|
||||
def _attribute_event(curated_plugins: set[str], flea_entities: dict[str, str],
|
||||
flea_plugins: set[str],
|
||||
skill_name, subagent_type, command_name, event_type):
|
||||
"""Resolve one event to (source, type, parent_plugin, name).
|
||||
|
||||
Returns None when the event doesn't belong in marketplace rollups
|
||||
(built-in tool, flat slash command, unknown plugin prefix).
|
||||
|
||||
Lookup tables (curated_plugins, flea_entities) are passed in so the
|
||||
caller can preload once and reuse across thousands of events.
|
||||
Lookup tables (curated_plugins, flea_entities, flea_plugins) are passed
|
||||
in so the caller can preload once and reuse across thousands of events.
|
||||
Mirrors the four branches `MarketplaceItemLookup.resolve()` walks:
|
||||
|
||||
1. ``flea:<synthetic>`` — standalone flea skill/agent/plugin
|
||||
2. ``<curated_plugin>:<inner>`` — nested skill/agent of a curated plugin
|
||||
3. ``<flea_plugin>:<inner>`` — nested skill/agent of a flea plugin
|
||||
4. anything else — None (filtered out of rollups)
|
||||
"""
|
||||
prefix, local, default_type = _identifier_split(skill_name, subagent_type, command_name, event_type)
|
||||
if prefix is None:
|
||||
|
|
@ -499,10 +519,19 @@ def _attribute_event(curated_plugins: set[str], flea_entities: dict[str, str],
|
|||
return ("flea", ent_type, "", local)
|
||||
if prefix in curated_plugins:
|
||||
return ("curated", default_type, prefix, local)
|
||||
if prefix in flea_plugins:
|
||||
# v49 phase-5: nested skill/agent inside a flea plugin bundle.
|
||||
# Same shape as curated nested attribution (source='flea',
|
||||
# parent_plugin=<synthetic plugin name>, name=<inner frontmatter
|
||||
# name>). Without this branch the rollup builder silently dropped
|
||||
# inner-item invocations even though MarketplaceItemLookup.resolve()
|
||||
# — used by the live writer — handled them since v6.
|
||||
return ("flea", default_type, prefix, local)
|
||||
return None
|
||||
|
||||
|
||||
def _aggregate_events(events_rows, curated_plugins, flea_entities, *, group_by_day: bool):
|
||||
def _aggregate_events(events_rows, curated_plugins, flea_entities,
|
||||
flea_plugins, *, group_by_day: bool):
|
||||
"""Walk raw event rows and produce aggregated buckets.
|
||||
|
||||
``events_rows`` shape: (day, user_id, is_error, skill_name, subagent_type,
|
||||
|
|
@ -517,7 +546,8 @@ def _aggregate_events(events_rows, curated_plugins, flea_entities, *, group_by_d
|
|||
leaf: dict[tuple, dict] = {}
|
||||
for row in events_rows:
|
||||
day, uid, is_err, sk, sa, cm, etype = row
|
||||
attributed = _attribute_event(curated_plugins, flea_entities, sk, sa, cm, etype)
|
||||
attributed = _attribute_event(curated_plugins, flea_entities, flea_plugins,
|
||||
sk, sa, cm, etype)
|
||||
if attributed is None:
|
||||
continue
|
||||
source, type_, parent, name = attributed
|
||||
|
|
@ -532,9 +562,14 @@ def _aggregate_events(events_rows, curated_plugins, flea_entities, *, group_by_d
|
|||
if is_err:
|
||||
b["errors"] += 1
|
||||
|
||||
# Plugin-level rollup: curated invocations get a parent row, summing the
|
||||
# children. distinct_users at plugin level recomputed across child users
|
||||
# so a user counted in two skills of the same plugin doesn't double-count.
|
||||
# Plugin-level rollup: curated AND flea invocations get a parent row,
|
||||
# summing the children. distinct_users at plugin level recomputed across
|
||||
# child users so a user counted in two skills of the same plugin doesn't
|
||||
# double-count. v49 phase-6: extended to flea (was curated-only); without
|
||||
# this, flea plugin entities never got an aggregated row, so the
|
||||
# parent_plugin='' filter in `_load_invocation_stats` returned no rows
|
||||
# for plugin cards / detail telemetry chips even though nested children
|
||||
# were attributed correctly.
|
||||
plugin_bucket: dict[tuple, dict] = {}
|
||||
for key, vals in leaf.items():
|
||||
if group_by_day:
|
||||
|
|
@ -542,12 +577,12 @@ def _aggregate_events(events_rows, curated_plugins, flea_entities, *, group_by_d
|
|||
else:
|
||||
day = None
|
||||
source, type_, parent, name = key
|
||||
if source != "curated" or not parent:
|
||||
if source not in ("curated", "flea") or not parent:
|
||||
continue
|
||||
if group_by_day:
|
||||
pkey = (day, "curated", "plugin", "", parent)
|
||||
pkey = (day, source, "plugin", "", parent)
|
||||
else:
|
||||
pkey = ("curated", "plugin", "", parent)
|
||||
pkey = (source, "plugin", "", parent)
|
||||
pb = plugin_bucket.setdefault(pkey, {"count": 0, "users": set(), "errors": 0})
|
||||
pb["count"] += vals["count"]
|
||||
pb["users"] |= vals["users"]
|
||||
|
|
@ -613,14 +648,21 @@ def rebuild_rollups(conn, *, since_day=None, force_30d: bool = False) -> None:
|
|||
since_day = (datetime.now(timezone.utc) - timedelta(days=7)).date()
|
||||
|
||||
# Preload lookup tables once — reused across daily + 7d + 30d rebuilds.
|
||||
# v49 phase-5: dict keyed by `synthetic_name` (matches the JSONL invocation
|
||||
# local-part) instead of `name`. `flea_plugins` set drives the
|
||||
# `<plugin>:<inner>` nested-attribution branch in `_attribute_event`.
|
||||
curated_plugins = {
|
||||
r[0] for r in conn.execute("SELECT DISTINCT name FROM marketplace_plugins").fetchall()
|
||||
}
|
||||
flea_entities = {
|
||||
r[0]: r[1] for r in conn.execute(
|
||||
"SELECT name, type FROM store_entities WHERE visibility_status='approved'"
|
||||
"SELECT synthetic_name, type FROM store_entities WHERE visibility_status='approved'"
|
||||
).fetchall()
|
||||
}
|
||||
flea_plugins = {
|
||||
synthetic for synthetic, ent_type in flea_entities.items()
|
||||
if ent_type == "plugin"
|
||||
}
|
||||
|
||||
do_30d = force_30d or _last_30d_due(conn)
|
||||
|
||||
|
|
@ -666,7 +708,8 @@ def rebuild_rollups(conn, *, since_day=None, force_30d: bool = False) -> None:
|
|||
[since_day],
|
||||
).fetchall()
|
||||
daily_buckets = _aggregate_events(
|
||||
daily_events, curated_plugins, flea_entities, group_by_day=True
|
||||
daily_events, curated_plugins, flea_entities, flea_plugins,
|
||||
group_by_day=True,
|
||||
)
|
||||
conn.execute("DELETE FROM usage_marketplace_item_daily WHERE day >= ?", [since_day])
|
||||
if daily_buckets:
|
||||
|
|
@ -685,14 +728,14 @@ def rebuild_rollups(conn, *, since_day=None, force_30d: bool = False) -> None:
|
|||
# ---- New: usage_marketplace_item_window period_label='last_7d' (full) ----
|
||||
cutoff_7d = (datetime.now(timezone.utc) - timedelta(days=7)).date()
|
||||
_rebuild_window(
|
||||
conn, "last_7d", cutoff_7d, curated_plugins, flea_entities,
|
||||
conn, "last_7d", cutoff_7d, curated_plugins, flea_entities, flea_plugins,
|
||||
)
|
||||
|
||||
# ---- New: usage_marketplace_item_window period_label='last_30d' (hourly) ----
|
||||
if do_30d:
|
||||
cutoff_30d = (datetime.now(timezone.utc) - timedelta(days=30)).date()
|
||||
_rebuild_window(
|
||||
conn, "last_30d", cutoff_30d, curated_plugins, flea_entities,
|
||||
conn, "last_30d", cutoff_30d, curated_plugins, flea_entities, flea_plugins,
|
||||
)
|
||||
_mark_last_30d_refreshed(conn)
|
||||
|
||||
|
|
@ -705,7 +748,8 @@ def rebuild_rollups(conn, *, since_day=None, force_30d: bool = False) -> None:
|
|||
raise
|
||||
|
||||
|
||||
def _rebuild_window(conn, period_label: str, cutoff_day, curated_plugins, flea_entities) -> None:
|
||||
def _rebuild_window(conn, period_label: str, cutoff_day, curated_plugins,
|
||||
flea_entities, flea_plugins) -> None:
|
||||
"""Full DELETE+INSERT of one period_label in usage_marketplace_item_window.
|
||||
|
||||
Caller wraps the call in a BEGIN/COMMIT transaction along with the
|
||||
|
|
@ -727,7 +771,8 @@ def _rebuild_window(conn, period_label: str, cutoff_day, curated_plugins, flea_e
|
|||
[cutoff_day],
|
||||
).fetchall()
|
||||
buckets = _aggregate_events(
|
||||
events, curated_plugins, flea_entities, group_by_day=False
|
||||
events, curated_plugins, flea_entities, flea_plugins,
|
||||
group_by_day=False,
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM usage_marketplace_item_window WHERE period_label = ?",
|
||||
|
|
|
|||
235
src/db.py
235
src/db.py
|
|
@ -40,7 +40,7 @@ def _maybe_instrument(con, db_tag: str):
|
|||
|
||||
_SAFE_IDENTIFIER = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
|
||||
|
||||
SCHEMA_VERSION = 48
|
||||
SCHEMA_VERSION = 50
|
||||
|
||||
_SYSTEM_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
|
|
@ -576,6 +576,19 @@ CREATE TABLE IF NOT EXISTS store_entities (
|
|||
-- StoreEntitiesRepository.append_version + restore endpoint.
|
||||
version_no INTEGER NOT NULL DEFAULT 1,
|
||||
version_history JSON DEFAULT '[]',
|
||||
-- v49: phase-1 Flea refactor adds three user-facing metadata columns.
|
||||
-- `title` is a humanized display name (acronym-aware), shown on web
|
||||
-- surfaces instead of the kebab-case `name`. `tagline` is an optional
|
||||
-- 200-char short description for card UI (long-form lives in
|
||||
-- `description`). `synthetic_name` is the deterministic
|
||||
-- `<name>-by-<owner_username>` value baked into served bundles —
|
||||
-- stored as a column so attribution + uniqueness checks can target a
|
||||
-- single source of truth instead of recomputing the concat on every
|
||||
-- query. Phase 1 only populates these; downstream surfaces (cards,
|
||||
-- detail pages, Claude Code propagation) consume them in later phases.
|
||||
title VARCHAR NOT NULL,
|
||||
tagline VARCHAR,
|
||||
synthetic_name VARCHAR NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT current_timestamp,
|
||||
updated_at TIMESTAMP DEFAULT current_timestamp,
|
||||
UNIQUE (owner_user_id, name)
|
||||
|
|
@ -659,6 +672,12 @@ CREATE TABLE IF NOT EXISTS store_submissions (
|
|||
|
||||
CREATE INDEX IF NOT EXISTS idx_store_submissions_status ON store_submissions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_store_submissions_entity ON store_submissions(entity_id);
|
||||
-- NOTE: the v50 UNIQUE INDEX on store_entities.synthetic_name is created
|
||||
-- by ``_v49_to_v50_migrate``, not here. Reason: ``_v48_to_v49_migrate``
|
||||
-- runs ``ALTER TABLE store_entities ALTER COLUMN … SET NOT NULL`` which
|
||||
-- DuckDB blocks when an index already references the table. Fresh-install
|
||||
-- ordering is therefore: CREATE TABLE (no index) → v49 migrate (no-op
|
||||
-- ALTERs on empty table) → v50 migrate (CREATE UNIQUE INDEX).
|
||||
-- NOTE: no created_at index. DuckDB 1.x has a bug where
|
||||
-- `ORDER BY <indexed col> DESC LIMIT N` short-returns on small tables
|
||||
-- (reproduced with N=2 against 3 rows during /admin/store/submissions
|
||||
|
|
@ -2547,6 +2566,21 @@ def _v34_to_v35_migrate(conn: duckdb.DuckDBPyConnection) -> None:
|
|||
conn.execute("ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS archived_at TIMESTAMP")
|
||||
conn.execute("ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS archived_by VARCHAR")
|
||||
|
||||
# If the table is already at a post-v49 shape (synthetic_name column
|
||||
# present from phase-1 Flea refactor), the v35 visibility_status rebuild
|
||||
# has effectively been done long ago AND the v50 UNIQUE INDEX on
|
||||
# synthetic_name now blocks `DROP COLUMN visibility_status` (DuckDB
|
||||
# forbids dropping a column when an index references a column after it
|
||||
# positionally). Short-circuit so re-runs of the ladder on a fully
|
||||
# migrated DB (e.g. a test that resets schema_version backwards) stay
|
||||
# idempotent.
|
||||
post_v49 = conn.execute(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = 'store_entities' AND column_name = 'synthetic_name'"
|
||||
).fetchone()
|
||||
if post_v49:
|
||||
return
|
||||
|
||||
cols = {
|
||||
r[0]
|
||||
for r in conn.execute(
|
||||
|
|
@ -2602,13 +2636,32 @@ def _v34_to_v35_migrate(conn: duckdb.DuckDBPyConnection) -> None:
|
|||
# whose schema_version row says 36 but whose users table is missing
|
||||
# `onboarded` — the only consequence-free recovery is an idempotent
|
||||
# ADD IF NOT EXISTS at the v36 step.
|
||||
_V35_TO_V36_MIGRATIONS = [
|
||||
"UPDATE store_entities SET visibility_status = 'pending' WHERE visibility_status IS NULL",
|
||||
"ALTER TABLE store_entities ALTER COLUMN visibility_status SET NOT NULL",
|
||||
"ALTER TABLE store_entities ALTER COLUMN visibility_status SET DEFAULT 'pending'",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS onboarded BOOLEAN DEFAULT FALSE",
|
||||
"UPDATE users SET onboarded = FALSE WHERE onboarded IS NULL",
|
||||
]
|
||||
def _v35_to_v36_migrate(conn: duckdb.DuckDBPyConnection) -> None:
|
||||
"""Idempotent v35→v36. Gates the ALTER COLUMN steps on current
|
||||
nullability — once v50 creates the UNIQUE INDEX on store_entities,
|
||||
DuckDB blocks ALTER COLUMN against the table (the index references
|
||||
a column "after" visibility_status positionally), so a redundant
|
||||
SET NOT NULL on an already-NOT-NULL column would explode."""
|
||||
conn.execute(
|
||||
"UPDATE store_entities SET visibility_status = 'pending' "
|
||||
"WHERE visibility_status IS NULL"
|
||||
)
|
||||
nullable = conn.execute(
|
||||
"SELECT is_nullable FROM information_schema.columns "
|
||||
"WHERE table_name = 'store_entities' AND column_name = 'visibility_status'"
|
||||
).fetchone()
|
||||
if nullable and nullable[0] == "YES":
|
||||
conn.execute(
|
||||
"ALTER TABLE store_entities ALTER COLUMN visibility_status SET NOT NULL"
|
||||
)
|
||||
conn.execute(
|
||||
"ALTER TABLE store_entities ALTER COLUMN visibility_status "
|
||||
"SET DEFAULT 'pending'"
|
||||
)
|
||||
conn.execute(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS onboarded BOOLEAN DEFAULT FALSE"
|
||||
)
|
||||
conn.execute("UPDATE users SET onboarded = FALSE WHERE onboarded IS NULL")
|
||||
|
||||
|
||||
# v37→v38: flea-market entity edit feature with version history.
|
||||
|
|
@ -2635,25 +2688,37 @@ _V35_TO_V36_MIGRATIONS = [
|
|||
# created_at backfilled from the entity row; submission_id is best-
|
||||
# effort (we look up the most recent submission_id for the entity_id
|
||||
# if any exists, else NULL).
|
||||
_V37_TO_V38_MIGRATIONS = [
|
||||
def _v37_to_v38_migrate(conn: duckdb.DuckDBPyConnection) -> None:
|
||||
"""Idempotent v37→v38. Gates the ``version_no SET NOT NULL`` step on
|
||||
current nullability — see ``_v35_to_v36_migrate`` for why."""
|
||||
# Defensive: minimal partial-state DBs from earlier migrations may
|
||||
# be missing columns the backfill UPDATE below references. Add
|
||||
# them idempotently first. Real post-v29 DBs already have these;
|
||||
# this is a no-op there. Keeps the recovery path through
|
||||
# `tests/test_db_schema_version.py::test_v32_db_with_partial_v35_recovers_through_full_ladder`
|
||||
# intact when walking from v32 fixture forward.
|
||||
"ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS version VARCHAR",
|
||||
"ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS file_size BIGINT",
|
||||
"ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS created_at TIMESTAMP",
|
||||
conn.execute("ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS version VARCHAR")
|
||||
conn.execute("ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS file_size BIGINT")
|
||||
conn.execute("ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS created_at TIMESTAMP")
|
||||
# DuckDB ALTER doesn't accept "NOT NULL DEFAULT" together — split:
|
||||
# ADD nullable + DEFAULT, backfill nulls, then SET NOT NULL.
|
||||
"ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS version_no INTEGER DEFAULT 1",
|
||||
"UPDATE store_entities SET version_no = 1 WHERE version_no IS NULL",
|
||||
"ALTER TABLE store_entities ALTER COLUMN version_no SET NOT NULL",
|
||||
"ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS version_history JSON DEFAULT '[]'",
|
||||
conn.execute(
|
||||
"ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS version_no INTEGER DEFAULT 1"
|
||||
)
|
||||
conn.execute("UPDATE store_entities SET version_no = 1 WHERE version_no IS NULL")
|
||||
nullable = conn.execute(
|
||||
"SELECT is_nullable FROM information_schema.columns "
|
||||
"WHERE table_name = 'store_entities' AND column_name = 'version_no'"
|
||||
).fetchone()
|
||||
if nullable and nullable[0] == "YES":
|
||||
conn.execute("ALTER TABLE store_entities ALTER COLUMN version_no SET NOT NULL")
|
||||
conn.execute(
|
||||
"ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS version_history JSON DEFAULT '[]'"
|
||||
)
|
||||
# Backfill: synthesize a v1 entry from existing columns when the
|
||||
# history is empty. Idempotent — re-running on a populated row
|
||||
# is a no-op because the WHERE filters on empty/NULL history.
|
||||
# history is empty. Idempotent — re-running on a populated row is
|
||||
# a no-op because the WHERE filters on empty/NULL history.
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE store_entities SET version_history = json_array(
|
||||
json_object(
|
||||
|
|
@ -2674,8 +2739,8 @@ _V37_TO_V38_MIGRATIONS = [
|
|||
WHERE version_history IS NULL
|
||||
OR version_history = '[]'
|
||||
OR json_array_length(version_history) = 0
|
||||
""",
|
||||
]
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
# v39: marketplace_plugins.is_system flag backing the "system plugin"
|
||||
|
|
@ -3074,6 +3139,116 @@ def _v47_to_v48(conn: duckdb.DuckDBPyConnection) -> None:
|
|||
conn.execute("CREATE INDEX IF NOT EXISTS idx_miw_lookup ON usage_marketplace_item_window(period_label, source, type)")
|
||||
|
||||
|
||||
def _v48_to_v49_migrate(conn: duckdb.DuckDBPyConnection) -> None:
|
||||
"""v49: phase-1 Flea refactor — add ``title``, ``tagline``, ``synthetic_name``.
|
||||
|
||||
Python function (not a SQL list) because the backfill needs Python-side
|
||||
humanize logic (acronym dict + Title Case) which has no clean SQL
|
||||
equivalent. Pattern mirrors ``_v34_to_v35_migrate``.
|
||||
|
||||
Steps:
|
||||
1. Add columns nullable + default NULL so the ALTER works on a populated
|
||||
table.
|
||||
2. Iterate rows: compute ``title = humanize_name(strip_archive_suffix(name))``
|
||||
and ``synthetic_name = f"{name}-by-{owner_username}"``. ``tagline``
|
||||
stays NULL.
|
||||
3. SET NOT NULL on ``title`` and ``synthetic_name``. ``tagline`` stays
|
||||
nullable by design (optional short description).
|
||||
|
||||
Idempotent: re-runs are safe — ADD COLUMN IF NOT EXISTS is a no-op;
|
||||
UPDATEs overwrite with the same values; ALTER ... SET NOT NULL is a
|
||||
no-op when already NOT NULL.
|
||||
"""
|
||||
from src.store_naming import humanize_name, strip_archive_suffix
|
||||
|
||||
conn.execute("ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS title VARCHAR")
|
||||
conn.execute("ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS tagline VARCHAR")
|
||||
conn.execute("ALTER TABLE store_entities ADD COLUMN IF NOT EXISTS synthetic_name VARCHAR")
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, owner_username FROM store_entities"
|
||||
).fetchall()
|
||||
for row_id, name, owner_username in rows:
|
||||
display_base = strip_archive_suffix(name or "")
|
||||
title = humanize_name(display_base) or display_base or "Untitled"
|
||||
synthetic = f"{name}-by-{owner_username}"
|
||||
conn.execute(
|
||||
"UPDATE store_entities SET title = ?, synthetic_name = ? WHERE id = ?",
|
||||
[title, synthetic, row_id],
|
||||
)
|
||||
|
||||
# Gate ALTER … SET NOT NULL on current nullability. DuckDB blocks
|
||||
# ALTER COLUMN once an index references the table (which happens after
|
||||
# v50 creates the UNIQUE INDEX on synthetic_name), so an unconditional
|
||||
# re-run on a fully-migrated DB would explode. Idempotent path: skip
|
||||
# the ALTER when the column is already NOT NULL.
|
||||
nullable = {
|
||||
r[0]: r[1]
|
||||
for r in conn.execute(
|
||||
"SELECT column_name, is_nullable FROM information_schema.columns "
|
||||
"WHERE table_name = 'store_entities' "
|
||||
"AND column_name IN ('title', 'synthetic_name')"
|
||||
).fetchall()
|
||||
}
|
||||
if nullable.get("title") == "YES":
|
||||
conn.execute("ALTER TABLE store_entities ALTER COLUMN title SET NOT NULL")
|
||||
if nullable.get("synthetic_name") == "YES":
|
||||
conn.execute("ALTER TABLE store_entities ALTER COLUMN synthetic_name SET NOT NULL")
|
||||
|
||||
|
||||
def _v49_to_v50_migrate(conn: duckdb.DuckDBPyConnection) -> None:
|
||||
"""v50: enforce DB-level uniqueness on ``store_entities.synthetic_name``.
|
||||
|
||||
v49 introduced the column as NOT NULL but without uniqueness. App-level
|
||||
``_suffixed_already_taken`` only fires at upload/rename; any other write
|
||||
path (admin DB hand-fix, future migration drift) could silently insert a
|
||||
duplicate, and ``WHERE synthetic_name = ?`` would then non-deterministically
|
||||
return one of the matching rows. With ``synthetic_name`` now the canonical
|
||||
attribution key (rollup tables, marketplace bundle naming, JSONL invocation
|
||||
prefix), uniqueness must be enforced at the DB level.
|
||||
|
||||
DuckDB has no ``ALTER TABLE ADD CONSTRAINT UNIQUE`` for existing tables,
|
||||
but ``CREATE UNIQUE INDEX`` is functionally equivalent (rejects duplicate
|
||||
inserts). The archive rewrite path
|
||||
(``StoreEntitiesRepository.archive``) renames synthetic_name alongside
|
||||
name, so archived rows cannot collide with live ones — a full-table
|
||||
UNIQUE index is correct.
|
||||
|
||||
Steps:
|
||||
1. Pre-flight: scan for existing duplicates. If any are found, abort
|
||||
with ``RuntimeError`` listing them — the index creation would fail
|
||||
anyway, but a structured error gives the operator a clear diagnostic
|
||||
instead of a raw DuckDB constraint-violation message.
|
||||
2. Create the UNIQUE index (idempotent via IF NOT EXISTS).
|
||||
|
||||
Idempotent: a re-run finds the index already present and skips both
|
||||
the duplicate scan (which would still pass) and the CREATE.
|
||||
"""
|
||||
# Pre-flight duplicate detection. List the actual conflicting slugs +
|
||||
# row counts so the operator can resolve manually (typically by
|
||||
# archiving one of the colliding rows, which rewrites its
|
||||
# synthetic_name to the __archived__<epoch>-suffixed form).
|
||||
dupes = conn.execute(
|
||||
"""SELECT synthetic_name, COUNT(*) AS n
|
||||
FROM store_entities
|
||||
GROUP BY synthetic_name
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY n DESC, synthetic_name"""
|
||||
).fetchall()
|
||||
if dupes:
|
||||
summary = ", ".join(f"{name!r} x{n}" for name, n in dupes)
|
||||
raise RuntimeError(
|
||||
"v49→v50 migration blocked: duplicate synthetic_name values "
|
||||
f"present in store_entities ({summary}). Resolve manually "
|
||||
"(archive or rename the colliding rows) and re-run."
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_store_entities_synthetic_name "
|
||||
"ON store_entities(synthetic_name)"
|
||||
)
|
||||
|
||||
|
||||
_V33_TO_V34_MIGRATIONS = [
|
||||
# DuckDB blocks DROP COLUMN while indexes reference the table
|
||||
# ("Dependency Error: Cannot alter entry … because there are entries
|
||||
|
|
@ -3368,6 +3543,16 @@ def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
|
|||
# there because the legacy tables aren't in _SYSTEM_SCHEMA
|
||||
# anymore. Kept here for ladder readability.
|
||||
_v47_to_v48(conn)
|
||||
# v49 phase-1 Flea refactor — title, tagline, synthetic_name
|
||||
# columns. _SYSTEM_SCHEMA already declares them on fresh
|
||||
# installs; this call is a no-op (table empty, ALTER IF NOT
|
||||
# EXISTS, no rows to backfill, SET NOT NULL idempotent).
|
||||
_v48_to_v49_migrate(conn)
|
||||
# v50 UNIQUE INDEX on synthetic_name. _SYSTEM_SCHEMA already
|
||||
# creates the index on fresh installs; this call is a no-op
|
||||
# (table empty so no duplicates possible, CREATE UNIQUE
|
||||
# INDEX IF NOT EXISTS is idempotent).
|
||||
_v49_to_v50_migrate(conn)
|
||||
# Fresh-install seed is handled by the unconditional
|
||||
# _seed_core_roles call at the bottom of _ensure_schema —
|
||||
# left as a no-op branch here so the migration ladder still
|
||||
|
|
@ -3488,14 +3673,12 @@ def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
|
|||
if current < 35:
|
||||
_v34_to_v35_migrate(conn)
|
||||
if current < 36:
|
||||
for sql in _V35_TO_V36_MIGRATIONS:
|
||||
conn.execute(sql)
|
||||
_v35_to_v36_migrate(conn)
|
||||
if current < 37:
|
||||
for sql in _V36_TO_V37_MIGRATIONS:
|
||||
conn.execute(sql)
|
||||
if current < 38:
|
||||
for sql in _V37_TO_V38_MIGRATIONS:
|
||||
conn.execute(sql)
|
||||
_v37_to_v38_migrate(conn)
|
||||
if current < 39:
|
||||
for sql in _V38_TO_V39_MIGRATIONS:
|
||||
conn.execute(sql)
|
||||
|
|
@ -3518,6 +3701,10 @@ def _ensure_schema(conn: duckdb.DuckDBPyConnection) -> None:
|
|||
_v46_to_v47(conn)
|
||||
if current < 48:
|
||||
_v47_to_v48(conn)
|
||||
if current < 49:
|
||||
_v48_to_v49_migrate(conn)
|
||||
if current < 50:
|
||||
_v49_to_v50_migrate(conn)
|
||||
conn.execute(
|
||||
"UPDATE schema_version SET version = ?, applied_at = current_timestamp",
|
||||
[SCHEMA_VERSION],
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ from src.repositories.user_curated_subscriptions import (
|
|||
UserCuratedSubscriptionsRepository,
|
||||
)
|
||||
from src.repositories.user_store_installs import UserStoreInstallsRepository
|
||||
from src.store_naming import suffixed_name
|
||||
|
||||
|
||||
def _resolve_raw(raw: Any) -> dict:
|
||||
|
|
@ -181,15 +180,21 @@ marketplace. ``is_valid_slug`` in ``src/marketplace.py`` rejects any admin
|
|||
marketplace registering ``store`` as its slug, so collisions with admin
|
||||
content are impossible."""
|
||||
|
||||
BUNDLE_PLUGIN_NAME = "agnes-store-bundle"
|
||||
BUNDLE_PLUGIN_NAME = "flea"
|
||||
"""Synth plugin that wraps every Store-installed skill and agent for a user
|
||||
into a single Claude Code plugin. Skill / agent uploads share this single
|
||||
plugin in the served marketplace; only ``type='plugin'`` Store entities
|
||||
materialize as their own plugin entry. See ``resolve_user_marketplace``."""
|
||||
materialize as their own plugin entry. See ``resolve_user_marketplace``.
|
||||
|
||||
BUNDLE_PREFIXED_NAME = "store-bundle"
|
||||
v49 phase-4: renamed from ``agnes-store-bundle`` to ``flea``. Clean cut —
|
||||
``usage_events`` rows whose JSONL was written before the rename stay
|
||||
attributed as ``source='builtin'``; no legacy-prefix fallback in the
|
||||
attribution layer (``services/session_processors/usage_lib.py``)."""
|
||||
|
||||
BUNDLE_PREFIXED_NAME = "flea"
|
||||
"""On-disk directory name in the served ZIP / git tree for the bundle plugin.
|
||||
Lives under ``plugins/store-bundle/...``."""
|
||||
Lives under ``plugins/flea/...``. v49 phase-4: renamed from ``store-bundle``
|
||||
for parity with the manifest plugin name."""
|
||||
|
||||
BUNDLE_DESCRIPTION = "Skills and agents you've installed from the Agnes Store"
|
||||
|
||||
|
|
@ -307,7 +312,13 @@ def resolve_user_marketplace(
|
|||
entity_id = row["id"]
|
||||
owner_username = row["owner_username"]
|
||||
original_name = row["name"]
|
||||
manifest_name = suffixed_name(original_name, owner_username)
|
||||
# v49 phase-3: stored synthetic_name from store_entities is the
|
||||
# canonical value baked into the on-disk plugin tree
|
||||
# (frontmatter name, plugin.json `name`). Reading it from DB
|
||||
# keeps the served manifest in lockstep with whatever the upload
|
||||
# / edit / archive paths last wrote. The column is NOT NULL +
|
||||
# explicitly selected by ``list_for_user``.
|
||||
manifest_name = row["synthetic_name"]
|
||||
plugin_dir = store_root / entity_id / "plugin"
|
||||
store_plugin_entries.append(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -59,7 +59,21 @@ class StoreEntitiesRepository:
|
|||
doc_paths: Optional[List[str]] = None,
|
||||
file_size: int = 0,
|
||||
visibility_status: str = "pending",
|
||||
title: Optional[str] = None,
|
||||
tagline: Optional[str] = None,
|
||||
synthetic_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
# v49 phase-1: title and synthetic_name fall back to derived values
|
||||
# when caller doesn't supply them — keeps the column NOT NULL invariant
|
||||
# without forcing every test/utility caller to recompute the same
|
||||
# deterministic formula. Production code (POST /api/store/entities)
|
||||
# passes both explicitly so the upload form's user-edited title is
|
||||
# honored.
|
||||
if not title:
|
||||
from src.store_naming import humanize_name
|
||||
title = humanize_name(name) or name or "Untitled"
|
||||
if not synthetic_name:
|
||||
synthetic_name = f"{name}-by-{owner_username}"
|
||||
now = datetime.now(timezone.utc)
|
||||
# v37: seed version_history with the v1 entry on create so the
|
||||
# edit feature's append_version always has a baseline to build
|
||||
|
|
@ -81,14 +95,16 @@ class StoreEntitiesRepository:
|
|||
category, version, photo_path, video_url, doc_paths,
|
||||
file_size, install_count, visibility_status,
|
||||
version_no, version_history,
|
||||
title, tagline, synthetic_name,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 1, ?, ?, ?)""",
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 1, ?, ?, ?, ?, ?, ?)""",
|
||||
[
|
||||
id, owner_user_id, owner_username, type, name, description,
|
||||
category, version, photo_path, video_url,
|
||||
json.dumps(doc_paths or []),
|
||||
int(file_size), visibility_status,
|
||||
json.dumps([v1_entry]),
|
||||
title, tagline, synthetic_name,
|
||||
now, now,
|
||||
],
|
||||
)
|
||||
|
|
@ -283,17 +299,23 @@ class StoreEntitiesRepository:
|
|||
}
|
||||
|
||||
original = row.get("name") or ""
|
||||
owner_username = row.get("owner_username") or ""
|
||||
now = datetime.now(timezone.utc)
|
||||
new_name = make_archive_name(original, int(now.timestamp()))
|
||||
# v49: synthetic_name tracks the canonical <name>-by-<owner> string;
|
||||
# rename it alongside `name` so the deterministic formula stays in
|
||||
# sync with the actually-stored slug on disk.
|
||||
new_synthetic = f"{new_name}-by-{owner_username}"
|
||||
self.conn.execute(
|
||||
"""UPDATE store_entities
|
||||
SET visibility_status = 'archived',
|
||||
name = ?,
|
||||
synthetic_name = ?,
|
||||
archived_at = ?,
|
||||
archived_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?""",
|
||||
[new_name, now, by_user_id, now, id],
|
||||
[new_name, new_synthetic, now, by_user_id, now, id],
|
||||
)
|
||||
return {"original_name": original, "new_name": new_name}
|
||||
|
||||
|
|
@ -439,6 +461,9 @@ class StoreEntitiesRepository:
|
|||
video_url: Optional[str] = None,
|
||||
doc_paths: Optional[List[str]] = None,
|
||||
file_size: Optional[int] = None,
|
||||
title: Optional[str] = None,
|
||||
tagline: Optional[str] = None,
|
||||
synthetic_name: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Partial update — only the supplied columns change. Returns the
|
||||
updated row, or None if no row matched.
|
||||
|
|
@ -447,6 +472,14 @@ class StoreEntitiesRepository:
|
|||
responsible for collision checks BEFORE invoking this method
|
||||
(per-owner UNIQUE + global suffix uniqueness) and for
|
||||
renaming the on-disk skill/agent/plugin slug to match.
|
||||
|
||||
``synthetic_name`` must be re-supplied by the caller when ``name``
|
||||
changes — the repo does not recompute it automatically (avoids a
|
||||
round-trip to fetch ``owner_username`` and keeps the deterministic
|
||||
formula in one place: the API layer).
|
||||
|
||||
``tagline`` accepts an empty string to clear the field; pass
|
||||
``None`` to leave it unchanged.
|
||||
"""
|
||||
sets: List[str] = []
|
||||
params: List[Any] = []
|
||||
|
|
@ -466,6 +499,13 @@ class StoreEntitiesRepository:
|
|||
sets.append("doc_paths = ?"); params.append(json.dumps(doc_paths))
|
||||
if file_size is not None:
|
||||
sets.append("file_size = ?"); params.append(int(file_size))
|
||||
if title is not None:
|
||||
sets.append("title = ?"); params.append(title)
|
||||
if tagline is not None:
|
||||
# empty string clears tagline; sentinel preserved here
|
||||
sets.append("tagline = ?"); params.append(tagline or None)
|
||||
if synthetic_name is not None:
|
||||
sets.append("synthetic_name = ?"); params.append(synthetic_name)
|
||||
if not sets:
|
||||
return self.get(id)
|
||||
sets.append("updated_at = ?"); params.append(datetime.now(timezone.utc))
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ class UserStoreInstallsRepository:
|
|||
se.photo_path, se.video_url, se.file_size,
|
||||
se.install_count, se.created_at, se.updated_at,
|
||||
se.visibility_status,
|
||||
se.title, se.tagline, se.synthetic_name,
|
||||
usi.installed_at
|
||||
FROM user_store_installs usi
|
||||
JOIN store_entities se ON se.id = usi.entity_id
|
||||
|
|
|
|||
|
|
@ -125,3 +125,61 @@ def _iter_files(root: Path) -> Iterable[Path]:
|
|||
if not root.is_dir():
|
||||
return []
|
||||
return sorted(p for p in root.rglob("*") if p.is_file())
|
||||
|
||||
|
||||
# Canonical case for tokens that should keep their casing in humanized titles.
|
||||
# Lookup key is lowercased token; value is the canonical form. Extend the list
|
||||
# as new acronyms enter the codebase (kept small on purpose — large lists
|
||||
# silently re-case unrelated user words).
|
||||
TITLE_ACRONYMS = {
|
||||
"ai": "AI",
|
||||
"api": "API",
|
||||
"aws": "AWS",
|
||||
"bm25": "BM25",
|
||||
"bq": "BQ",
|
||||
"ci": "CI",
|
||||
"cd": "CD",
|
||||
"css": "CSS",
|
||||
"db": "DB",
|
||||
"fts": "FTS",
|
||||
"gcp": "GCP",
|
||||
"html": "HTML",
|
||||
"js": "JS",
|
||||
"json": "JSON",
|
||||
"jwt": "JWT",
|
||||
"mcp": "MCP",
|
||||
"oauth": "OAuth",
|
||||
"rbac": "RBAC",
|
||||
"rpc": "RPC",
|
||||
"s3": "S3",
|
||||
"sql": "SQL",
|
||||
"sso": "SSO",
|
||||
"ts": "TS",
|
||||
"ui": "UI",
|
||||
"url": "URL",
|
||||
"ux": "UX",
|
||||
"xml": "XML",
|
||||
}
|
||||
|
||||
|
||||
def humanize_name(name: str) -> str:
|
||||
"""Convert a kebab-case identifier to a user-friendly Title Case string.
|
||||
|
||||
Splits on ``-``, drops empty tokens (handles double dashes), and either
|
||||
looks the token up in ``TITLE_ACRONYMS`` (case-insensitive) or
|
||||
title-cases it. Joined with spaces.
|
||||
|
||||
humanize_name("code-review") -> "Code Review"
|
||||
humanize_name("mcp-builder") -> "MCP Builder"
|
||||
humanize_name("oauth-server-v2") -> "OAuth Server V2"
|
||||
humanize_name("api") -> "API"
|
||||
humanize_name("") -> ""
|
||||
"""
|
||||
if not name:
|
||||
return ""
|
||||
tokens = [t for t in name.split("-") if t]
|
||||
out = []
|
||||
for tok in tokens:
|
||||
canonical = TITLE_ACRONYMS.get(tok.lower())
|
||||
out.append(canonical if canonical is not None else tok.title())
|
||||
return " ".join(out)
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ def compute_default_agent_prompt(
|
|||
# exactly what /marketplace.zip + /marketplace.git/ serve. That's
|
||||
# the `resolve_user_marketplace` view: admin grants minus the
|
||||
# user's opt-outs, plus their Store installs (skills + agents
|
||||
# rolled up into the synth `agnes-store-bundle` plugin, plugin-
|
||||
# rolled up into the synth `flea` plugin, plugin-
|
||||
# typed entities standalone). `resolve_allowed_plugins` was the
|
||||
# pre-store admin-only feed and would emit installs for plugins
|
||||
# the user has opted out of, while skipping the bundle entirely.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{"type": "user", "uuid": "u1", "parentUuid": null, "sessionId": "sess-skill-flea", "timestamp": "2026-05-12T11:00:00.000Z", "cwd": "/workspace", "message": {"role": "user", "content": "Use the flea skill"}}
|
||||
{"type": "assistant", "uuid": "a1", "parentUuid": "u1", "sessionId": "sess-skill-flea", "timestamp": "2026-05-12T11:00:01.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "tool_use", "id": "tu_flea_1", "name": "Skill", "input": {"skill": "agnes-store-bundle:flea-skill", "args": ""}}]}}
|
||||
{"type": "assistant", "uuid": "a1", "parentUuid": "u1", "sessionId": "sess-skill-flea", "timestamp": "2026-05-12T11:00:01.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "tool_use", "id": "tu_flea_1", "name": "Skill", "input": {"skill": "flea:flea-skill-by-alice", "args": ""}}]}}
|
||||
{"type": "user", "uuid": "u2", "parentUuid": "a1", "sessionId": "sess-skill-flea", "timestamp": "2026-05-12T11:00:02.000Z", "cwd": "/workspace", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tu_flea_1", "is_error": false, "content": [{"type": "text", "text": "Flea skill executed"}]}]}}
|
||||
{"type": "assistant", "uuid": "a2", "parentUuid": "u2", "sessionId": "sess-skill-flea", "timestamp": "2026-05-12T11:00:03.000Z", "cwd": "/workspace", "message": {"role": "assistant", "model": "claude-sonnet-4-6", "content": [{"type": "text", "text": "Done."}]}}
|
||||
|
|
|
|||
|
|
@ -307,13 +307,13 @@ def test_reconcile_updates_when_manifest_version_differs(
|
|||
monkeypatch.chdir(workspace)
|
||||
_set_marketplace_manifest(with_clone, [
|
||||
{"name": "grpn-eng", "version": "1.1.0"}, # admin pushed new version
|
||||
{"name": "agnes-store-bundle", "version": "deadbeefcafef00d"}, # bundle bumped
|
||||
{"name": "flea", "version": "deadbeefcafef00d"}, # bundle bumped
|
||||
])
|
||||
recorder.script(
|
||||
("claude", "plugin", "list", "--json"),
|
||||
stdout=_plugin_list_json([
|
||||
{"id": "grpn-eng@agnes", "version": "1.0.0", "projectPath": str(workspace)},
|
||||
{"id": "agnes-store-bundle@agnes", "version": "0123456789abcdef",
|
||||
{"id": "flea@agnes", "version": "0123456789abcdef",
|
||||
"projectPath": str(workspace)},
|
||||
]),
|
||||
)
|
||||
|
|
@ -325,7 +325,7 @@ def test_reconcile_updates_when_manifest_version_differs(
|
|||
if c.cmd[:3] == ["claude", "plugin", "update"]
|
||||
)
|
||||
assert update_targets == [
|
||||
f"agnes-store-bundle@{rm_module.MARKETPLACE_NAME}",
|
||||
f"flea@{rm_module.MARKETPLACE_NAME}",
|
||||
f"grpn-eng@{rm_module.MARKETPLACE_NAME}",
|
||||
]
|
||||
# No installs (both already present).
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import duckdb
|
|||
from src.db import SCHEMA_VERSION, _ensure_schema, get_schema_version
|
||||
|
||||
|
||||
def test_schema_version_is_48():
|
||||
def test_schema_version_matches_constant():
|
||||
# v27 → v28: explicit-install (Model B) for curated marketplace plugins.
|
||||
# user_plugin_optouts row presence flips meaning from "excluded" to
|
||||
# "subscribed"; migration wipes existing rows so the inverted reading
|
||||
|
|
@ -125,7 +125,22 @@ def test_schema_version_is_48():
|
|||
# New attribution logic = prefix split on `<plugin>:<local>`
|
||||
# identifier + live lookup against marketplace_plugins /
|
||||
# store_entities — no mapping tables needed.
|
||||
assert SCHEMA_VERSION == 48
|
||||
# v49 (#TBD): phase-1 Flea refactor — adds title, tagline,
|
||||
# synthetic_name columns to store_entities. title is
|
||||
# user-friendly display name (acronym-aware), tagline is
|
||||
# an optional 200-char short description, synthetic_name is
|
||||
# the deterministic <name>-by-<owner_username> string baked
|
||||
# into served bundles. Migration backfills existing rows
|
||||
# via humanize_name(strip_archive_suffix(name)) for title
|
||||
# and the concat formula for synthetic_name.
|
||||
# v50 (#TBD): UNIQUE INDEX on store_entities.synthetic_name. v49 made
|
||||
# it the canonical attribution key (rollup keyspace, JSONL
|
||||
# prefix, marketplace bundle naming) but uniqueness was
|
||||
# only app-enforced; v50 adds DB-level uniqueness via
|
||||
# CREATE UNIQUE INDEX. Migration pre-checks for existing
|
||||
# duplicates and raises RuntimeError listing them rather
|
||||
# than letting the index create fail mid-way.
|
||||
assert SCHEMA_VERSION == 50
|
||||
|
||||
|
||||
def test_v37_marketplace_curator_columns(tmp_path):
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ import duckdb
|
|||
import pytest
|
||||
|
||||
from src.db import (
|
||||
SCHEMA_VERSION,
|
||||
_SYSTEM_SCHEMA,
|
||||
_ensure_schema,
|
||||
_v43_to_v44,
|
||||
|
|
@ -87,11 +86,6 @@ def test_v43_to_v44_upgrade_is_idempotent(tmp_path):
|
|||
}
|
||||
|
||||
|
||||
def test_schema_version_constant_is_48():
|
||||
"""Belt + suspenders against schema_version regressions."""
|
||||
assert SCHEMA_VERSION == 48
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_home_stats / GET /api/me/home-stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -114,6 +114,28 @@ def _make_skill_zip(skill_name: str = "code-review") -> bytes:
|
|||
return buf.getvalue()
|
||||
|
||||
|
||||
def _make_plugin_zip(plugin_name: str, inner_skill: str = "dummy") -> bytes:
|
||||
"""Mirror of test_store_api._make_plugin_zip — minimal flea plugin
|
||||
ZIP with one inner skill, used to drive ``/api/marketplace/flea/{id}
|
||||
/skill/{name}`` inner-detail tests."""
|
||||
import json
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr(
|
||||
".claude-plugin/plugin.json",
|
||||
json.dumps({
|
||||
"name": plugin_name,
|
||||
"description": _OK_DESC,
|
||||
"version": "0.1",
|
||||
}),
|
||||
)
|
||||
zf.writestr(
|
||||
f"skills/{inner_skill}/SKILL.md",
|
||||
f"---\nname: {inner_skill}\ndescription: {_OK_DESC}\n---\n\n{_OK_BODY}",
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/marketplace/items
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -151,9 +173,14 @@ class TestListItems:
|
|||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["source"] == "flea"
|
||||
# Invocation name suffixed with -by-<owner>
|
||||
assert "alpha" in data["items"][0]["name"]
|
||||
item = data["items"][0]
|
||||
assert item["source"] == "flea"
|
||||
# v49 phase-1: `name` is the suffixed invocation slug — kept as the
|
||||
# technical identifier card JS falls back to when display_name is
|
||||
# absent. v49 phase-2: `display_name` carries the humanized title
|
||||
# (`Alpha`), and JS uses it as the visible card heading.
|
||||
assert item["name"] == "alpha-by-alice"
|
||||
assert item["display_name"] == "Alpha"
|
||||
|
||||
def test_my_subscriptions_default_empty(self, web_client):
|
||||
"""Without explicit install, a granted curated plugin doesn't show
|
||||
|
|
@ -674,3 +701,311 @@ class TestFleaDetail:
|
|||
assert d["install_count"] == 0
|
||||
# docs is always a list (empty when uploader didn't ship any).
|
||||
assert isinstance(d["docs"], list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v49 phase-2 — title + tagline + full-name owner on flea presentation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _set_user_full_name(user_id: str, full_name: str) -> None:
|
||||
"""Override the `users.name` field for an existing test user. Used to
|
||||
simulate the real-world case where a user has a proper full name
|
||||
(e.g. "Carolina Bsolinová Pauerová") distinct from their kebab-case
|
||||
`owner_username` derived from email (`c-bsolinovapauerova`)."""
|
||||
from src.db import get_system_db
|
||||
conn = get_system_db()
|
||||
try:
|
||||
conn.execute("UPDATE users SET name = ? WHERE id = ?", [full_name, user_id])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestFleaPhase2Presentation:
|
||||
"""v49 phase-2 — flea cards and detail pages surface `title` (humanized),
|
||||
`tagline`, and the owner's full name (`users.name`) instead of the
|
||||
kebab-case slug + bare username they used to render."""
|
||||
|
||||
def test_flea_card_carries_title_tagline_and_full_name_owner(self, web_client):
|
||||
user_id, cookies = _create_user(web_client, "c_marustamyan@x.com")
|
||||
# Simulate a real account whose users.name is the friendly form;
|
||||
# owner_username on the entity will be the sanitized kebab-case
|
||||
# local-part ("c-marustamyan").
|
||||
_set_user_full_name(user_id, "Minas Arustamyan")
|
||||
|
||||
web_client.post(
|
||||
"/api/store/entities",
|
||||
files={
|
||||
"file": ("s.zip", _make_skill_zip("mcp-builder"), "application/zip"),
|
||||
},
|
||||
data={
|
||||
"type": "skill",
|
||||
"description": _OK_DESC,
|
||||
"tagline": "Spawns MCP servers from a one-line prompt.",
|
||||
},
|
||||
cookies=cookies,
|
||||
)
|
||||
|
||||
r = web_client.get("/api/marketplace/items?tab=flea", cookies=cookies)
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
it = items[0]
|
||||
# display_name carries the acronym-aware humanized title from
|
||||
# store_entities.title; JS card uses it as the visible heading.
|
||||
assert it["display_name"] == "MCP Builder"
|
||||
# tagline rides the existing curated chain; JS prefers it over
|
||||
# description for the card subtitle.
|
||||
assert it["tagline"] == "Spawns MCP servers from a one-line prompt."
|
||||
# owner is now the full users.name, not the kebab-case slug.
|
||||
assert it["owner"] == "Minas Arustamyan"
|
||||
# The technical suffixed slug stays on `name` as the JS-fallback
|
||||
# identifier (legacy compat — no card UI surfaces it directly).
|
||||
assert it["name"] == "mcp-builder-by-c-marustamyan"
|
||||
|
||||
def test_flea_card_owner_falls_back_to_email_then_username(self, web_client):
|
||||
"""When users.name is NULL, owner display falls back to users.email;
|
||||
when neither is present, to owner_username (defensive bottom)."""
|
||||
from src.db import get_system_db
|
||||
user_id, cookies = _create_user(web_client, "bob@x.com")
|
||||
# Clear the seeded users.name so the fallback chain kicks in.
|
||||
conn = get_system_db()
|
||||
try:
|
||||
conn.execute("UPDATE users SET name = NULL WHERE id = ?", [user_id])
|
||||
finally:
|
||||
conn.close()
|
||||
web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", _make_skill_zip("alpha"), "application/zip")},
|
||||
data={"type": "skill", "description": _OK_DESC},
|
||||
cookies=cookies,
|
||||
)
|
||||
r = web_client.get("/api/marketplace/items?tab=flea", cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
it = r.json()["items"][0]
|
||||
# Fallback: users.name=NULL → users.email → "bob@x.com".
|
||||
assert it["owner"] == "bob@x.com"
|
||||
|
||||
def test_flea_detail_exposes_title_and_tagline(self, web_client):
|
||||
_, cookies = _create_user(web_client, "alice@x.com")
|
||||
up = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={
|
||||
"file": ("s.zip", _make_skill_zip("oauth-server"), "application/zip"),
|
||||
},
|
||||
data={
|
||||
"type": "skill",
|
||||
"description": _OK_DESC,
|
||||
"tagline": "Mock OAuth provider for integration tests.",
|
||||
},
|
||||
cookies=cookies,
|
||||
)
|
||||
entity_id = up.json()["id"]
|
||||
|
||||
r = web_client.get(
|
||||
f"/api/marketplace/flea/{entity_id}/detail", cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
# `display_name` is the curated-style hero title — phase 2 wires
|
||||
# it up for flea so the heroTitle JS chain renders the friendly
|
||||
# form instead of falling through to plugin_name (= entity name).
|
||||
assert d["display_name"] == "OAuth Server"
|
||||
assert d["tagline"] == "Mock OAuth provider for integration tests."
|
||||
# plugin_name + manifest_name unchanged — the JS chain in templates
|
||||
# uses display_name first; these remain for backward compat with
|
||||
# paths that have always read the slug.
|
||||
assert d["plugin_name"] == "oauth-server"
|
||||
assert d["manifest_name"] == "oauth-server-by-alice"
|
||||
|
||||
def test_flea_detail_tagline_null_when_omitted(self, web_client):
|
||||
"""Tagline is optional — flea entity uploaded without it must
|
||||
surface as None on detail so the hero element stays hidden."""
|
||||
_, cookies = _create_user(web_client, "alice@x.com")
|
||||
up = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", _make_skill_zip("notagline"), "application/zip")},
|
||||
data={"type": "skill", "description": _OK_DESC},
|
||||
cookies=cookies,
|
||||
)
|
||||
entity_id = up.json()["id"]
|
||||
r = web_client.get(
|
||||
f"/api/marketplace/flea/{entity_id}/detail", cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["tagline"] is None
|
||||
# display_name still set from the humanizer fallback in POST.
|
||||
assert d["display_name"] == "Notagline"
|
||||
|
||||
def test_flea_inner_skill_parent_display_name_uses_title(self, web_client):
|
||||
"""v49 phase-3: inner skill/agent detail of a flea plugin surfaces
|
||||
the parent plugin's user-friendly ``title`` (humanized) via
|
||||
``parent_display_name``. JS chains (breadcrumb 3rd segment, hero
|
||||
"part of <plugin>", helper "This skill is part of <plugin>",
|
||||
sidebar "Parent plugin") all read this field first — single
|
||||
source swap drives every surface to the friendly form."""
|
||||
_, cookies = _create_user(web_client, "alice@x.com")
|
||||
up = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={
|
||||
"file": (
|
||||
"p.zip",
|
||||
_make_plugin_zip("codex-second-opinion", inner_skill="codex-setup"),
|
||||
"application/zip",
|
||||
),
|
||||
},
|
||||
data={
|
||||
"type": "plugin",
|
||||
"description": _OK_DESC,
|
||||
"title": "Codex Second Opinion",
|
||||
},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert up.status_code == 201, up.text
|
||||
entity_id = up.json()["id"]
|
||||
|
||||
r = web_client.get(
|
||||
f"/api/marketplace/flea/{entity_id}/skill/codex-setup",
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
# Inner skill's own name still comes from frontmatter.
|
||||
assert d["name"] == "codex-setup"
|
||||
# Parent identification: manifest_name = entity.name (technical
|
||||
# slug used by the rename / archive paths); parent_display_name =
|
||||
# entity.title (the human form rendered everywhere on the UI).
|
||||
assert d["manifest_name"] == "codex-second-opinion"
|
||||
assert d["parent_display_name"] == "Codex Second Opinion"
|
||||
|
||||
def test_flea_card_and_detail_read_synthetic_name_from_db(self, web_client):
|
||||
"""v49 phase-3: ``MarketplaceItem.name`` (card) and
|
||||
``PluginDetailResponse.manifest_name`` (detail) source from the
|
||||
stored ``synthetic_name`` column. Manually override the column to
|
||||
a non-canonical value — both surfaces must reflect the override,
|
||||
proving they read the column instead of recomputing
|
||||
``<name>-by-<owner_username>``."""
|
||||
from src.db import get_system_db
|
||||
_, cookies = _create_user(web_client, "syntheticread@x.com")
|
||||
up = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", _make_skill_zip("orig"), "application/zip")},
|
||||
data={"type": "skill", "description": _OK_DESC},
|
||||
cookies=cookies,
|
||||
)
|
||||
eid = up.json()["id"]
|
||||
conn = get_system_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE store_entities SET synthetic_name = ? WHERE id = ?",
|
||||
["manual-override-mkt", eid],
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
# Card
|
||||
r = web_client.get("/api/marketplace/items?tab=flea", cookies=cookies)
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["name"] == "manual-override-mkt"
|
||||
# Detail
|
||||
d = web_client.get(
|
||||
f"/api/marketplace/flea/{eid}/detail", cookies=cookies,
|
||||
)
|
||||
assert d.status_code == 200, d.text
|
||||
assert d.json()["manifest_name"] == "manual-override-mkt"
|
||||
|
||||
def test_flea_inner_skill_parent_display_name_humanize_fallback(self, web_client):
|
||||
"""When title is omitted on upload, the POST endpoint humanizes the
|
||||
plugin name as a fallback — phase 3 must thread that humanized form
|
||||
into ``parent_display_name`` too, not the kebab-case slug."""
|
||||
_, cookies = _create_user(web_client, "alice@x.com")
|
||||
up = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={
|
||||
"file": (
|
||||
"p.zip",
|
||||
_make_plugin_zip("mcp-tools", inner_skill="dummy"),
|
||||
"application/zip",
|
||||
),
|
||||
},
|
||||
data={"type": "plugin", "description": _OK_DESC},
|
||||
cookies=cookies,
|
||||
)
|
||||
entity_id = up.json()["id"]
|
||||
r = web_client.get(
|
||||
f"/api/marketplace/flea/{entity_id}/skill/dummy",
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
# Humanizer + acronym dict from phase 1 — "mcp-tools" → "MCP Tools".
|
||||
assert d["parent_display_name"] == "MCP Tools"
|
||||
assert d["manifest_name"] == "mcp-tools"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v49 hardening — owner-display N+1 regression guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFleaOwnerDisplayBatched:
|
||||
"""Pre-fix, ``_flea_to_item`` called ``_resolve_owner_display`` for every
|
||||
item in the list comprehension — one ``SELECT … FROM users WHERE id = ?``
|
||||
per item. v49 hardening batches the prefetch via ``_load_users_display``
|
||||
so the listing endpoint runs O(1) user lookups regardless of item count.
|
||||
|
||||
Regression guard: assert ``_resolve_owner_display`` is NEVER invoked
|
||||
during the flea listing path. If a future change reintroduces the per-row
|
||||
helper inside ``_flea_to_item``, this fails immediately."""
|
||||
|
||||
def test_listing_does_not_call_per_row_owner_resolver(self, web_client, monkeypatch):
|
||||
# Three owners + three flea uploads — enough to make any per-row
|
||||
# call obviously visible if it sneaks back in.
|
||||
for email in ("alice@x.com", "bob@x.com", "carol@x.com"):
|
||||
_, cookies = _create_user(web_client, email)
|
||||
web_client.post(
|
||||
"/api/store/entities",
|
||||
files={
|
||||
"file": (
|
||||
"s.zip",
|
||||
_make_skill_zip(f"skill-{email.split('@')[0]}"),
|
||||
"application/zip",
|
||||
),
|
||||
},
|
||||
data={"type": "skill", "description": _OK_DESC},
|
||||
cookies=cookies,
|
||||
)
|
||||
|
||||
# Any logged-in user can see the public flea tab.
|
||||
_, viewer_cookies = _create_user(web_client, "viewer@x.com")
|
||||
|
||||
# Spy on the per-row resolver. The fixed implementation must not
|
||||
# call it inside the listing comprehension.
|
||||
from app.api import marketplace as marketplace_module
|
||||
calls: list[str] = []
|
||||
original = marketplace_module._resolve_owner_display
|
||||
|
||||
def spy(conn, owner_user_id, fallback):
|
||||
calls.append(owner_user_id)
|
||||
return original(conn, owner_user_id, fallback)
|
||||
|
||||
monkeypatch.setattr(marketplace_module, "_resolve_owner_display", spy)
|
||||
|
||||
r = web_client.get(
|
||||
"/api/marketplace/items?tab=flea", cookies=viewer_cookies,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 3
|
||||
assert calls == [], (
|
||||
f"_resolve_owner_display called {len(calls)} times inside the "
|
||||
f"flea listing path — regression to the N+1 pattern."
|
||||
)
|
||||
|
||||
# All three owner display strings still resolved correctly via the
|
||||
# batch prefetch (so we're not just sidestepping the assertion by
|
||||
# returning blank owners).
|
||||
owners = sorted(it["owner"] for it in items)
|
||||
assert owners == ["alice", "bob", "carol"]
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ class TestResolveUserMarketplace:
|
|||
def test_skill_install_yields_bundle_entry(self, db_conn):
|
||||
"""A single skill install becomes a bundle entry, not a standalone
|
||||
store-<id> plugin. Skills/agents are merged into one synth plugin
|
||||
named ``agnes-store-bundle`` regardless of how many are installed."""
|
||||
named ``flea`` regardless of how many are installed."""
|
||||
from src.marketplace_filter import resolve_user_marketplace
|
||||
_seed_user_with_grant(db_conn, marketplace="mkt", plugin="p1")
|
||||
_subscribe(db_conn, user_id="u1", marketplace="mkt", plugin="p1")
|
||||
|
|
@ -143,8 +143,8 @@ class TestResolveUserMarketplace:
|
|||
admin_e = next(p for p in result if p["source"] == "marketplace")
|
||||
bundle = next(p for p in result if p["source"] == "store-bundle")
|
||||
assert admin_e["prefixed_name"] == "mkt-p1"
|
||||
assert bundle["prefixed_name"] == "store-bundle"
|
||||
assert bundle["manifest_name"] == "agnes-store-bundle"
|
||||
assert bundle["prefixed_name"] == "flea"
|
||||
assert bundle["manifest_name"] == "flea"
|
||||
assert bundle["marketplace_id"] == "store"
|
||||
assert bundle["plugin_dir"] is None
|
||||
assert eid in bundle["bundle_entity_ids"]
|
||||
|
|
|
|||
|
|
@ -1,15 +1,7 @@
|
|||
"""v41 → v42 migration: 7 new usage_* tables for telemetry."""
|
||||
|
||||
import duckdb
|
||||
import pytest
|
||||
from src.db import _ensure_schema as init_database, SCHEMA_VERSION
|
||||
|
||||
|
||||
def test_schema_version_is_42():
|
||||
# Test name preserved for git-blame continuity; the version-pinned
|
||||
# tests in test_db_schema_version.py, test_home_stats.py and
|
||||
# test_schema_v46_migration.py carry the current commentary.
|
||||
assert SCHEMA_VERSION == 48
|
||||
from src.db import _ensure_schema as init_database
|
||||
|
||||
|
||||
def test_v42_tables_exist_after_init(tmp_path):
|
||||
|
|
@ -74,7 +66,7 @@ def test_v41_to_v42_is_idempotent(tmp_path):
|
|||
conn = duckdb.connect(str(db_path))
|
||||
init_database(conn)
|
||||
v = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
|
||||
assert v == 48
|
||||
assert v == 50
|
||||
conn.close()
|
||||
|
||||
|
||||
|
|
@ -95,7 +87,7 @@ def test_v41_db_upgrades_cleanly(tmp_path):
|
|||
conn = duckdb.connect(str(db_path))
|
||||
init_database(conn)
|
||||
v = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
|
||||
assert v == 48
|
||||
assert v == 50
|
||||
# All 7 new v41 tables exist after the v40→v41 upgrade
|
||||
tables = {
|
||||
row[0]
|
||||
|
|
@ -126,7 +118,7 @@ def test_v30_db_ladders_all_the_way_up(tmp_path):
|
|||
conn = duckdb.connect(str(db_path))
|
||||
init_database(conn)
|
||||
v = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
|
||||
assert v == 48
|
||||
assert v == 50
|
||||
cnt = conn.execute("SELECT COUNT(*) FROM audit_log WHERE id='vintage'").fetchone()[0]
|
||||
assert cnt == 1
|
||||
# New v41 table exists
|
||||
|
|
|
|||
|
|
@ -13,10 +13,6 @@ import duckdb
|
|||
from src.db import SCHEMA_VERSION, _ensure_schema, _v45_to_v46, get_schema_version
|
||||
|
||||
|
||||
def test_schema_version_is_46():
|
||||
assert SCHEMA_VERSION == 48
|
||||
|
||||
|
||||
def test_fresh_install_creates_dismissed_table(tmp_path):
|
||||
"""A brand-new DB ends at v46 with the dismiss table + index in place."""
|
||||
db_path = tmp_path / "system.duckdb"
|
||||
|
|
|
|||
162
tests/test_schema_v48_to_v49_migration.py
Normal file
162
tests/test_schema_v48_to_v49_migration.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""v48 → v49 migration: phase-1 Flea refactor.
|
||||
|
||||
Adds three columns to ``store_entities``:
|
||||
|
||||
- ``title`` (VARCHAR NOT NULL) — humanized display name, backfilled via
|
||||
``humanize_name(strip_archive_suffix(name))`` on existing rows.
|
||||
- ``tagline`` (VARCHAR, nullable) — optional 200-char short description.
|
||||
- ``synthetic_name`` (VARCHAR NOT NULL) — deterministic
|
||||
``<name>-by-<owner_username>`` string, backfilled via the concat formula.
|
||||
|
||||
The migration is implemented as a Python function (``_v48_to_v49_migrate``)
|
||||
because the humanize backfill has no clean SQL equivalent.
|
||||
"""
|
||||
|
||||
import duckdb
|
||||
import pytest
|
||||
|
||||
from src.db import (
|
||||
SCHEMA_VERSION,
|
||||
_ensure_schema,
|
||||
_v48_to_v49_migrate,
|
||||
get_schema_version,
|
||||
)
|
||||
|
||||
|
||||
def test_fresh_install_has_v49_columns(tmp_path):
|
||||
"""Fresh install reaches v49 with the three new columns present."""
|
||||
db_path = tmp_path / "system.duckdb"
|
||||
conn = duckdb.connect(str(db_path))
|
||||
_ensure_schema(conn)
|
||||
|
||||
assert get_schema_version(conn) == SCHEMA_VERSION
|
||||
|
||||
cols = {
|
||||
r[0]
|
||||
for r in conn.execute(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'store_entities'"
|
||||
).fetchall()
|
||||
}
|
||||
assert {"title", "tagline", "synthetic_name"} <= cols, (
|
||||
f"v49 columns missing on store_entities: {cols}"
|
||||
)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_v48_db_migrates_and_backfills(tmp_path):
|
||||
"""A pre-existing v48 DB with seeded store_entities rows climbs to v49
|
||||
with title + synthetic_name backfilled and tagline left NULL."""
|
||||
db_path = tmp_path / "v48.duckdb"
|
||||
conn = duckdb.connect(str(db_path))
|
||||
|
||||
# Stand up a minimal v48-shape store_entities (no title/tagline/synthetic).
|
||||
conn.execute(
|
||||
"CREATE TABLE schema_version (version INTEGER, applied_at TIMESTAMP DEFAULT current_timestamp)"
|
||||
)
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (48)")
|
||||
conn.execute(
|
||||
"""CREATE TABLE store_entities (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
owner_user_id VARCHAR NOT NULL,
|
||||
owner_username VARCHAR NOT NULL,
|
||||
type VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
description TEXT,
|
||||
category VARCHAR,
|
||||
version VARCHAR NOT NULL,
|
||||
photo_path VARCHAR,
|
||||
video_url VARCHAR,
|
||||
doc_paths JSON,
|
||||
file_size BIGINT,
|
||||
install_count BIGINT NOT NULL DEFAULT 0,
|
||||
visibility_status VARCHAR NOT NULL DEFAULT 'pending',
|
||||
archived_at TIMESTAMP,
|
||||
archived_by VARCHAR,
|
||||
version_no INTEGER NOT NULL DEFAULT 1,
|
||||
version_history JSON DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT current_timestamp,
|
||||
updated_at TIMESTAMP DEFAULT current_timestamp
|
||||
)"""
|
||||
)
|
||||
# Plain skill, archived skill, MCP acronym, multi-word with v-suffix.
|
||||
conn.execute(
|
||||
"INSERT INTO store_entities (id, owner_user_id, owner_username, type, name, version) "
|
||||
"VALUES ('e1', 'u1', 'alice', 'skill', 'code-review', 'v1')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO store_entities (id, owner_user_id, owner_username, type, name, version) "
|
||||
"VALUES ('e2', 'u2', 'bob', 'agent', 'mcp-builder', 'v1')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO store_entities (id, owner_user_id, owner_username, type, name, version, visibility_status) "
|
||||
"VALUES ('e3', 'u1', 'alice', 'skill', 'oldname__archived__1700000000', 'v1', 'archived')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO store_entities (id, owner_user_id, owner_username, type, name, version) "
|
||||
"VALUES ('e4', 'u3', 'c-bsolinovapauerova', 'skill', 'html-deck-creator', 'v1')"
|
||||
)
|
||||
|
||||
_ensure_schema(conn)
|
||||
|
||||
assert get_schema_version(conn) == SCHEMA_VERSION
|
||||
|
||||
rows = {
|
||||
r[0]: r
|
||||
for r in conn.execute(
|
||||
"SELECT id, name, title, tagline, synthetic_name FROM store_entities"
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
# title: humanize_name(strip_archive_suffix(name))
|
||||
assert rows["e1"][2] == "Code Review"
|
||||
assert rows["e2"][2] == "MCP Builder"
|
||||
# Archived row: strip __archived__<epoch> before humanizing.
|
||||
assert rows["e3"][2] == "Oldname"
|
||||
assert rows["e4"][2] == "HTML Deck Creator"
|
||||
|
||||
# tagline stays NULL — no backfill source.
|
||||
for eid in ("e1", "e2", "e3", "e4"):
|
||||
assert rows[eid][3] is None, f"{eid} tagline should be NULL"
|
||||
|
||||
# synthetic_name uses the actually-stored name (incl. archive suffix).
|
||||
assert rows["e1"][4] == "code-review-by-alice"
|
||||
assert rows["e2"][4] == "mcp-builder-by-bob"
|
||||
assert rows["e3"][4] == "oldname__archived__1700000000-by-alice"
|
||||
assert rows["e4"][4] == "html-deck-creator-by-c-bsolinovapauerova"
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_v48_to_v49_function_is_idempotent(tmp_path):
|
||||
"""Calling ``_v48_to_v49_migrate`` twice is a no-op the second time."""
|
||||
db_path = tmp_path / "twice.duckdb"
|
||||
conn = duckdb.connect(str(db_path))
|
||||
_ensure_schema(conn)
|
||||
# Re-run directly on a clean v49 DB — ADD COLUMN IF NOT EXISTS +
|
||||
# SET NOT NULL on an already-NOT-NULL column are both idempotent.
|
||||
_v48_to_v49_migrate(conn)
|
||||
_v48_to_v49_migrate(conn)
|
||||
assert get_schema_version(conn) == SCHEMA_VERSION
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_not_null_constraint_after_migration(tmp_path):
|
||||
"""title and synthetic_name must be NOT NULL after migration."""
|
||||
db_path = tmp_path / "constraints.duckdb"
|
||||
conn = duckdb.connect(str(db_path))
|
||||
_ensure_schema(conn)
|
||||
|
||||
# information_schema reports NOT NULL via is_nullable = 'NO'
|
||||
nullable = {
|
||||
r[0]: r[1]
|
||||
for r in conn.execute(
|
||||
"SELECT column_name, is_nullable FROM information_schema.columns "
|
||||
"WHERE table_name = 'store_entities' "
|
||||
"AND column_name IN ('title', 'tagline', 'synthetic_name')"
|
||||
).fetchall()
|
||||
}
|
||||
assert nullable.get("title") == "NO", f"title nullable: {nullable}"
|
||||
assert nullable.get("synthetic_name") == "NO", f"synthetic_name nullable: {nullable}"
|
||||
assert nullable.get("tagline") == "YES", f"tagline must be nullable: {nullable}"
|
||||
conn.close()
|
||||
201
tests/test_schema_v49_to_v50_migration.py
Normal file
201
tests/test_schema_v49_to_v50_migration.py
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
"""v49 → v50 migration: UNIQUE INDEX on ``store_entities.synthetic_name``.
|
||||
|
||||
v49 introduced ``synthetic_name`` as ``NOT NULL`` but without uniqueness.
|
||||
With the column now the canonical attribution key (rollup keyspace, JSONL
|
||||
prefix, marketplace bundle naming), v50 enforces uniqueness at the DB
|
||||
level via ``CREATE UNIQUE INDEX`` (DuckDB has no
|
||||
``ALTER TABLE ADD CONSTRAINT UNIQUE`` for existing tables).
|
||||
|
||||
Migration must:
|
||||
- Pre-flight scan for existing duplicates and raise ``RuntimeError`` with
|
||||
a structured diagnostic listing them (instead of letting the index
|
||||
create fail mid-way with a raw DuckDB error).
|
||||
- Create the UNIQUE index idempotently (re-runs are a no-op).
|
||||
- Cover both fresh installs (index present in ``_SYSTEM_SCHEMA``) and
|
||||
upgrades from v49 (migration creates the index).
|
||||
"""
|
||||
|
||||
import duckdb
|
||||
import pytest
|
||||
|
||||
from src.db import (
|
||||
SCHEMA_VERSION,
|
||||
_ensure_schema,
|
||||
_v49_to_v50_migrate,
|
||||
get_schema_version,
|
||||
)
|
||||
|
||||
|
||||
def test_fresh_install_has_unique_index_on_synthetic_name(tmp_path):
|
||||
"""Fresh install reaches v50 with the UNIQUE index on synthetic_name."""
|
||||
db_path = tmp_path / "system.duckdb"
|
||||
conn = duckdb.connect(str(db_path))
|
||||
_ensure_schema(conn)
|
||||
|
||||
assert get_schema_version(conn) == SCHEMA_VERSION
|
||||
|
||||
# DuckDB exposes indexes via duckdb_indexes(); is_unique flag distinguishes
|
||||
# UNIQUE indexes from non-unique ones.
|
||||
rows = conn.execute(
|
||||
"SELECT index_name, is_unique FROM duckdb_indexes() "
|
||||
"WHERE table_name = 'store_entities'"
|
||||
).fetchall()
|
||||
index_names = {r[0]: r[1] for r in rows}
|
||||
assert "idx_store_entities_synthetic_name" in index_names, (
|
||||
f"synthetic_name UNIQUE index missing on fresh install: {index_names}"
|
||||
)
|
||||
assert index_names["idx_store_entities_synthetic_name"] is True, (
|
||||
"idx_store_entities_synthetic_name exists but is not UNIQUE"
|
||||
)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_fresh_install_rejects_duplicate_synthetic_name(tmp_path):
|
||||
"""After fresh install, inserting two rows with the same synthetic_name
|
||||
raises a DuckDB constraint error — the index is actually enforcing."""
|
||||
db_path = tmp_path / "enforced.duckdb"
|
||||
conn = duckdb.connect(str(db_path))
|
||||
_ensure_schema(conn)
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO store_entities "
|
||||
"(id, owner_user_id, owner_username, type, name, version, "
|
||||
" visibility_status, title, synthetic_name) "
|
||||
"VALUES ('e1', 'u1', 'alice', 'skill', 'a', '1', 'approved', 'A', 'shared-slug')"
|
||||
)
|
||||
with pytest.raises(duckdb.ConstraintException):
|
||||
conn.execute(
|
||||
"INSERT INTO store_entities "
|
||||
"(id, owner_user_id, owner_username, type, name, version, "
|
||||
" visibility_status, title, synthetic_name) "
|
||||
"VALUES ('e2', 'u2', 'bob', 'skill', 'b', '1', 'approved', 'B', 'shared-slug')"
|
||||
)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_v49_db_migrates_when_no_duplicates(tmp_path):
|
||||
"""A v49-shaped DB with clean (non-duplicate) data climbs to v50, and the
|
||||
UNIQUE index is in place and enforcing."""
|
||||
db_path = tmp_path / "v49.duckdb"
|
||||
conn = duckdb.connect(str(db_path))
|
||||
|
||||
# Stand up a minimal v49-shape store_entities. No need to populate
|
||||
# every column — just enough that the duplicate scan and index
|
||||
# creation see realistic data.
|
||||
conn.execute(
|
||||
"CREATE TABLE schema_version (version INTEGER, applied_at TIMESTAMP DEFAULT current_timestamp)"
|
||||
)
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (49)")
|
||||
conn.execute(
|
||||
"""CREATE TABLE store_entities (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
owner_user_id VARCHAR NOT NULL,
|
||||
owner_username VARCHAR NOT NULL,
|
||||
type VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
version VARCHAR NOT NULL,
|
||||
visibility_status VARCHAR NOT NULL DEFAULT 'pending',
|
||||
title VARCHAR NOT NULL,
|
||||
tagline VARCHAR,
|
||||
synthetic_name VARCHAR NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT current_timestamp,
|
||||
updated_at TIMESTAMP DEFAULT current_timestamp
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO store_entities "
|
||||
"(id, owner_user_id, owner_username, type, name, version, "
|
||||
" visibility_status, title, synthetic_name) "
|
||||
"VALUES ('e1', 'u1', 'alice', 'skill', 'a', '1', 'approved', 'A', 'a-by-alice')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO store_entities "
|
||||
"(id, owner_user_id, owner_username, type, name, version, "
|
||||
" visibility_status, title, synthetic_name) "
|
||||
"VALUES ('e2', 'u2', 'bob', 'skill', 'a', '1', 'approved', 'A', 'a-by-bob')"
|
||||
)
|
||||
|
||||
_ensure_schema(conn)
|
||||
|
||||
assert get_schema_version(conn) == SCHEMA_VERSION
|
||||
|
||||
# Index in place — duplicate insert is now rejected.
|
||||
with pytest.raises(duckdb.ConstraintException):
|
||||
conn.execute(
|
||||
"INSERT INTO store_entities "
|
||||
"(id, owner_user_id, owner_username, type, name, version, "
|
||||
" visibility_status, title, synthetic_name) "
|
||||
"VALUES ('e3', 'u3', 'carol', 'skill', 'a', '1', 'approved', 'A', 'a-by-alice')"
|
||||
)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_v49_to_v50_blocks_on_duplicates(tmp_path):
|
||||
"""If a v49 DB has duplicate synthetic_name rows (admin hand-fix gone
|
||||
wrong, etc.), the migration raises ``RuntimeError`` listing the
|
||||
conflicting slugs instead of letting the index create error out
|
||||
mid-way with a less informative DuckDB message."""
|
||||
db_path = tmp_path / "v49dupes.duckdb"
|
||||
conn = duckdb.connect(str(db_path))
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE schema_version (version INTEGER, applied_at TIMESTAMP DEFAULT current_timestamp)"
|
||||
)
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (49)")
|
||||
conn.execute(
|
||||
"""CREATE TABLE store_entities (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
owner_user_id VARCHAR NOT NULL,
|
||||
owner_username VARCHAR NOT NULL,
|
||||
type VARCHAR NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
version VARCHAR NOT NULL,
|
||||
visibility_status VARCHAR NOT NULL DEFAULT 'pending',
|
||||
title VARCHAR NOT NULL,
|
||||
tagline VARCHAR,
|
||||
synthetic_name VARCHAR NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT current_timestamp,
|
||||
updated_at TIMESTAMP DEFAULT current_timestamp
|
||||
)"""
|
||||
)
|
||||
# Two rows colliding on `dup-slug` — wedge condition for v50.
|
||||
for eid, owner in [("e1", "alice"), ("e2", "bob")]:
|
||||
conn.execute(
|
||||
"INSERT INTO store_entities "
|
||||
"(id, owner_user_id, owner_username, type, name, version, "
|
||||
" visibility_status, title, synthetic_name) "
|
||||
"VALUES (?, ?, ?, 'skill', 'x', '1', 'approved', 'X', 'dup-slug')",
|
||||
[eid, owner, owner],
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
_v49_to_v50_migrate(conn)
|
||||
|
||||
msg = str(excinfo.value)
|
||||
assert "dup-slug" in msg, msg
|
||||
assert "store_entities" in msg, msg
|
||||
|
||||
# Migration did not create the index when it bailed — verify the table
|
||||
# still allows what would be a duplicate (no enforcement yet).
|
||||
rows = conn.execute(
|
||||
"SELECT index_name FROM duckdb_indexes() "
|
||||
"WHERE table_name = 'store_entities' "
|
||||
"AND index_name = 'idx_store_entities_synthetic_name'"
|
||||
).fetchall()
|
||||
assert rows == [], "index should NOT be created when duplicates present"
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_v49_to_v50_function_is_idempotent(tmp_path):
|
||||
"""Re-running the migration on an already-v50 DB is a no-op.
|
||||
|
||||
``CREATE UNIQUE INDEX IF NOT EXISTS`` short-circuits; duplicate
|
||||
pre-check still passes (no dupes possible with the index already
|
||||
enforcing)."""
|
||||
db_path = tmp_path / "twice.duckdb"
|
||||
conn = duckdb.connect(str(db_path))
|
||||
_ensure_schema(conn)
|
||||
_v49_to_v50_migrate(conn)
|
||||
_v49_to_v50_migrate(conn)
|
||||
assert get_schema_version(conn) == SCHEMA_VERSION
|
||||
conn.close()
|
||||
|
|
@ -38,7 +38,7 @@ def _seed_attribution(conn: duckdb.DuckDBPyConnection) -> None:
|
|||
`myplug:my-agent`, slash commands `myplug:compound` — note slash
|
||||
commands count as skills under the new rules, and `compound:debug`
|
||||
uses `compound` as the plugin prefix).
|
||||
- flea bundle prefix `agnes-store-bundle` + entity name `flea-skill`.
|
||||
- flea bundle prefix `flea` + entity name `flea-skill`.
|
||||
"""
|
||||
# Curated plugin — only `name` matters for the lookup; the rest is
|
||||
# filler to satisfy NOT NULL constraints / referential expectations.
|
||||
|
|
@ -57,11 +57,14 @@ def _seed_attribution(conn: duckdb.DuckDBPyConnection) -> None:
|
|||
)
|
||||
# Flea entity — visibility_status='approved' is required (lookup filters
|
||||
# on it). type='skill' so the resolver places the invocation under
|
||||
# type='skill' in the rollup.
|
||||
# type='skill' in the rollup. v49 phase-1 added NOT NULL `title` +
|
||||
# `synthetic_name`; mirror what the repo's create() fallback would write.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO store_entities "
|
||||
"(id, owner_user_id, owner_username, type, name, version, visibility_status) "
|
||||
"VALUES ('entity-1', 'u1', 'alice', 'skill', 'flea-skill', '1.0', 'approved')"
|
||||
"(id, owner_user_id, owner_username, type, name, version, "
|
||||
" visibility_status, title, synthetic_name) "
|
||||
"VALUES ('entity-1', 'u1', 'alice', 'skill', 'flea-skill', '1.0', "
|
||||
" 'approved', 'flea-skill', 'flea-skill-by-alice')"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -197,10 +200,11 @@ class TestFleaSkill:
|
|||
_seed_attribution(conn)
|
||||
_process("skill_flea.jsonl", conn)
|
||||
# Flea entity bundle prefix → ref_id is '' (no parent plugin),
|
||||
# normalised to NULL by UsageProcessor.
|
||||
# normalised to NULL by UsageProcessor. v49 phase-5: JSONL local
|
||||
# part is the entity's synthetic_name (= `<name>-by-<owner>`).
|
||||
row = conn.execute(
|
||||
"SELECT source, ref_id FROM usage_events "
|
||||
"WHERE skill_name = 'agnes-store-bundle:flea-skill'"
|
||||
"WHERE skill_name = 'flea:flea-skill-by-alice'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == "flea"
|
||||
|
|
|
|||
|
|
@ -404,6 +404,163 @@ class TestStoreUpload:
|
|||
assert r.json()["detail"] in {"zip_looks_like_plugin", "zip_looks_like_skill"}
|
||||
|
||||
|
||||
class TestStoreV49Metadata:
|
||||
"""v49 phase-1 — title, tagline, synthetic_name fields end-to-end.
|
||||
|
||||
Preview returns humanized title; POST accepts user-supplied title/tagline
|
||||
and falls back to the humanizer; PUT round-trips the partial update; the
|
||||
response always carries the v49 columns.
|
||||
"""
|
||||
|
||||
def test_preview_returns_humanized_title(self, web_client):
|
||||
_, cookies = _create_user(web_client, "preview@x.com")
|
||||
zip_bytes = _make_skill_zip("mcp-builder")
|
||||
r = web_client.post(
|
||||
"/api/store/entities/preview",
|
||||
files={"file": ("s.zip", zip_bytes, "application/zip")},
|
||||
data={"type": "skill"},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["name"] == "mcp-builder"
|
||||
assert body["title"] == "MCP Builder", body
|
||||
|
||||
def test_post_with_explicit_title_and_tagline(self, web_client):
|
||||
_, cookies = _create_user(web_client, "v49post@x.com")
|
||||
zip_bytes = _make_skill_zip("code-review")
|
||||
r = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", zip_bytes, "application/zip")},
|
||||
data={
|
||||
"type": "skill",
|
||||
"description": _OK_DESC,
|
||||
"title": "PR Reviewer (custom)",
|
||||
"tagline": "Spots missing tests and weak assertions.",
|
||||
},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["title"] == "PR Reviewer (custom)"
|
||||
assert body["tagline"] == "Spots missing tests and weak assertions."
|
||||
assert body["synthetic_name"] == "code-review-by-v49post"
|
||||
|
||||
def test_post_falls_back_to_humanized_title_when_omitted(self, web_client):
|
||||
_, cookies = _create_user(web_client, "fallback@x.com")
|
||||
zip_bytes = _make_skill_zip("oauth-server")
|
||||
r = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", zip_bytes, "application/zip")},
|
||||
data={"type": "skill", "description": _OK_DESC},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
# Server-side humanize fallback uses the same acronym dict as JS.
|
||||
assert body["title"] == "OAuth Server"
|
||||
assert body["tagline"] is None
|
||||
assert body["synthetic_name"] == "oauth-server-by-fallback"
|
||||
|
||||
def test_post_rejects_oversize_title(self, web_client):
|
||||
_, cookies = _create_user(web_client, "oversize@x.com")
|
||||
zip_bytes = _make_skill_zip("long-title")
|
||||
r = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", zip_bytes, "application/zip")},
|
||||
data={"type": "skill", "description": _OK_DESC, "title": "x" * 101},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == "title_too_long"
|
||||
|
||||
def test_post_rejects_oversize_tagline(self, web_client):
|
||||
_, cookies = _create_user(web_client, "oversizetag@x.com")
|
||||
zip_bytes = _make_skill_zip("long-tag")
|
||||
r = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", zip_bytes, "application/zip")},
|
||||
data={
|
||||
"type": "skill", "description": _OK_DESC,
|
||||
"tagline": "x" * 201,
|
||||
},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"] == "tagline_too_long"
|
||||
|
||||
def test_put_updates_title_and_tagline_and_recomputes_synthetic_on_rename(
|
||||
self, web_client,
|
||||
):
|
||||
_, cookies = _create_user(web_client, "v49put@x.com")
|
||||
zip_bytes = _make_skill_zip("starter-name")
|
||||
r = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", zip_bytes, "application/zip")},
|
||||
data={"type": "skill", "description": _OK_DESC},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
eid = r.json()["id"]
|
||||
|
||||
# Pure metadata edit: title + tagline.
|
||||
e = web_client.put(
|
||||
f"/api/store/entities/{eid}",
|
||||
data={
|
||||
"title": "New Title",
|
||||
"tagline": "A pithy tagline",
|
||||
},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert e.status_code == 200, e.text
|
||||
body = e.json()
|
||||
assert body["title"] == "New Title"
|
||||
assert body["tagline"] == "A pithy tagline"
|
||||
# name unchanged → synthetic unchanged.
|
||||
assert body["synthetic_name"] == "starter-name-by-v49put"
|
||||
|
||||
# Rename: synthetic_name must follow.
|
||||
e2 = web_client.put(
|
||||
f"/api/store/entities/{eid}",
|
||||
data={"name": "renamed-thing"},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert e2.status_code == 200, e2.text
|
||||
assert e2.json()["synthetic_name"] == "renamed-thing-by-v49put"
|
||||
|
||||
def test_invocation_name_reads_from_synthetic_column(self, web_client):
|
||||
"""v49 phase-3: ``invocation_name`` in StoreEntityResponse sources
|
||||
from the stored ``synthetic_name`` column, not a fresh recompute.
|
||||
Manually override the column with a non-canonical value and verify
|
||||
the API returns it verbatim — proves read paths consume the column
|
||||
rather than recomputing ``<name>-by-<owner_username>`` on the fly."""
|
||||
from src.db import get_system_db
|
||||
_, cookies = _create_user(web_client, "synthread@x.com")
|
||||
up = web_client.post(
|
||||
"/api/store/entities",
|
||||
files={"file": ("s.zip", _make_skill_zip("orig-name"), "application/zip")},
|
||||
data={"type": "skill", "description": _OK_DESC},
|
||||
cookies=cookies,
|
||||
)
|
||||
eid = up.json()["id"]
|
||||
# Manual divergence — pretend an admin fix-up landed a non-canonical
|
||||
# synthetic. A pure recompute path would not see this; a column-read
|
||||
# path will.
|
||||
conn = get_system_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE store_entities SET synthetic_name = ? WHERE id = ?",
|
||||
["manual-override-xyz", eid],
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
r = web_client.get(f"/api/store/entities/{eid}", cookies=cookies)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["synthetic_name"] == "manual-override-xyz"
|
||||
assert body["invocation_name"] == "manual-override-xyz"
|
||||
|
||||
|
||||
class TestStoreSecurityFixes:
|
||||
"""Regression tests for the three security blockers and one correctness
|
||||
bug found in PR #180 review (F1, F2, F4, F5)."""
|
||||
|
|
@ -1121,7 +1278,7 @@ class TestInstallCycle:
|
|||
|
||||
class TestMarketplaceBundle:
|
||||
"""End-to-end: the served /marketplace.zip merges installed Store skills
|
||||
and agents into a single ``store-bundle`` plugin, while ``type='plugin'``
|
||||
and agents into a single ``flea`` plugin, while ``type='plugin'``
|
||||
Store entities stay standalone."""
|
||||
|
||||
def _zip_entries(self, content: bytes) -> set[str]:
|
||||
|
|
@ -1170,10 +1327,10 @@ class TestMarketplaceBundle:
|
|||
names = self._zip_entries(r.content)
|
||||
|
||||
# Bundle exists with synth plugin.json + every skill + agent file.
|
||||
assert "plugins/store-bundle/.claude-plugin/plugin.json" in names
|
||||
assert "plugins/store-bundle/skills/alpha-by-owner/SKILL.md" in names
|
||||
assert "plugins/store-bundle/skills/beta-by-owner/SKILL.md" in names
|
||||
assert "plugins/store-bundle/agents/gamma-by-owner.md" in names
|
||||
assert "plugins/flea/.claude-plugin/plugin.json" in names
|
||||
assert "plugins/flea/skills/alpha-by-owner/SKILL.md" in names
|
||||
assert "plugins/flea/skills/beta-by-owner/SKILL.md" in names
|
||||
assert "plugins/flea/agents/gamma-by-owner.md" in names
|
||||
|
||||
# The plugin-typed entity is a separate dir; skills inside its tree
|
||||
# carry their original (non-suffixed) names per spec.
|
||||
|
|
@ -1184,13 +1341,13 @@ class TestMarketplaceBundle:
|
|||
r.content, ".claude-plugin/marketplace.json",
|
||||
))
|
||||
names_in_catalog = sorted(p["name"] for p in manifest["plugins"])
|
||||
assert names_in_catalog == ["agnes-store-bundle", "delta-by-owner"]
|
||||
assert names_in_catalog == ["delta-by-owner", "flea"]
|
||||
|
||||
# Bundle's own plugin.json carries the synth fields.
|
||||
bundle_pj = _json.loads(self._read_zip_file(
|
||||
r.content, "plugins/store-bundle/.claude-plugin/plugin.json",
|
||||
r.content, "plugins/flea/.claude-plugin/plugin.json",
|
||||
))
|
||||
assert bundle_pj["name"] == "agnes-store-bundle"
|
||||
assert bundle_pj["name"] == "flea"
|
||||
assert bundle_pj["version"] # non-empty hash
|
||||
|
||||
def test_only_skills_yields_only_bundle(self, web_client):
|
||||
|
|
@ -1206,7 +1363,7 @@ class TestMarketplaceBundle:
|
|||
r = web_client.get("/marketplace.zip", cookies=installer_cookies)
|
||||
assert r.status_code == 200
|
||||
names = self._zip_entries(r.content)
|
||||
assert "plugins/store-bundle/skills/solo-by-ob/SKILL.md" in names
|
||||
assert "plugins/flea/skills/solo-by-ob/SKILL.md" in names
|
||||
# No standalone entry for the skill — bundle is the only Store-derived
|
||||
# plugin dir present.
|
||||
assert not any(n.startswith(f"plugins/store-{eid}/") for n in names)
|
||||
|
|
@ -1230,15 +1387,15 @@ class TestMarketplaceBundle:
|
|||
# Both skills present.
|
||||
r1 = web_client.get("/marketplace.zip", cookies=installer_cookies)
|
||||
names1 = self._zip_entries(r1.content)
|
||||
assert "plugins/store-bundle/skills/keepme-by-oc/SKILL.md" in names1
|
||||
assert "plugins/store-bundle/skills/dropme-by-oc/SKILL.md" in names1
|
||||
assert "plugins/flea/skills/keepme-by-oc/SKILL.md" in names1
|
||||
assert "plugins/flea/skills/dropme-by-oc/SKILL.md" in names1
|
||||
|
||||
# Uninstall one — bundle still exists, but only the kept skill remains.
|
||||
web_client.delete(f"/api/store/entities/{b}/install", cookies=installer_cookies)
|
||||
r2 = web_client.get("/marketplace.zip", cookies=installer_cookies)
|
||||
names2 = self._zip_entries(r2.content)
|
||||
assert "plugins/store-bundle/skills/keepme-by-oc/SKILL.md" in names2
|
||||
assert "plugins/store-bundle/skills/dropme-by-oc/SKILL.md" not in names2
|
||||
assert "plugins/flea/skills/keepme-by-oc/SKILL.md" in names2
|
||||
assert "plugins/flea/skills/dropme-by-oc/SKILL.md" not in names2
|
||||
|
||||
|
||||
class TestWebPages:
|
||||
|
|
@ -1250,7 +1407,14 @@ class TestWebPages:
|
|||
|
||||
def test_marketplace_flea_detail_page_renders(self, web_client):
|
||||
"""v32+: /store/{id} was deleted; /marketplace/flea/{id} is the
|
||||
canonical detail surface."""
|
||||
canonical detail surface.
|
||||
|
||||
v49 phase-2: SSR pre-render uses ``entity.title`` (humanized)
|
||||
rather than the kebab-case entity ``name`` for the page heading.
|
||||
Both the friendly + technical forms should be present in the
|
||||
page (title in the hero / breadcrumbs, slug in JS data / detail
|
||||
URL parameter passed to fetch).
|
||||
"""
|
||||
_, cookies = _create_user(web_client, "page4@x.com")
|
||||
r = web_client.post(
|
||||
"/api/store/entities",
|
||||
|
|
@ -1261,7 +1425,10 @@ class TestWebPages:
|
|||
eid = r.json()["id"]
|
||||
det = web_client.get(f"/marketplace/flea/{eid}", cookies=cookies)
|
||||
assert det.status_code == 200
|
||||
assert "page-skill" in det.text
|
||||
# Humanized title sits in the hero h1 + browser title.
|
||||
assert "Page Skill" in det.text
|
||||
# Entity id (slug-equivalent for routing) survives in detail URL.
|
||||
assert eid in det.text
|
||||
# Confirm the legacy URL is gone (404, not 200).
|
||||
legacy = web_client.get(f"/store/{eid}", cookies=cookies)
|
||||
assert legacy.status_code == 404
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ from pathlib import Path
|
|||
import pytest
|
||||
|
||||
from src.store_naming import (
|
||||
TITLE_ACRONYMS,
|
||||
compute_entity_version,
|
||||
humanize_name,
|
||||
sanitize_username,
|
||||
suffixed_name,
|
||||
)
|
||||
|
|
@ -45,6 +47,39 @@ class TestSuffixedName:
|
|||
assert suffixed_name("a-b-c", "u-v") == "a-b-c-by-u-v"
|
||||
|
||||
|
||||
class TestHumanizeName:
|
||||
@pytest.mark.parametrize("name,expected", [
|
||||
("code-review", "Code Review"),
|
||||
("mcp-builder", "MCP Builder"),
|
||||
("oauth-server", "OAuth Server"),
|
||||
("oauth-server-v2", "OAuth Server V2"),
|
||||
("s3-uploader", "S3 Uploader"),
|
||||
("api", "API"),
|
||||
("single", "Single"),
|
||||
("json-to-xml", "JSON To XML"),
|
||||
("html-deck-creator", "HTML Deck Creator"),
|
||||
("rbac-audit", "RBAC Audit"),
|
||||
("", ""),
|
||||
("a", "A"),
|
||||
# double-dashes / leading-trailing dashes collapse via empty-token drop
|
||||
("foo--bar", "Foo Bar"),
|
||||
("-foo-bar-", "Foo Bar"),
|
||||
])
|
||||
def test_known_inputs(self, name, expected):
|
||||
assert humanize_name(name) == expected
|
||||
|
||||
def test_acronyms_dict_has_canonical_case(self):
|
||||
# Sanity — every value is its canonical capitalization, every key is lowercase.
|
||||
for key, value in TITLE_ACRONYMS.items():
|
||||
assert key == key.lower(), f"key {key!r} not lowercase"
|
||||
assert value, f"value for {key!r} is empty"
|
||||
|
||||
def test_case_insensitive_match(self):
|
||||
# Input always arrives lowercase from kebab-case names, but the
|
||||
# lookup must be defensive in case future callers pass mixed case.
|
||||
assert humanize_name("MCP-Builder".lower()) == "MCP Builder"
|
||||
|
||||
|
||||
class TestComputeEntityVersion:
|
||||
def test_deterministic_same_content(self, tmp_path: Path):
|
||||
a = tmp_path / "a"; a.mkdir()
|
||||
|
|
|
|||
|
|
@ -36,11 +36,16 @@ def _seed_curated_plugin(conn, plugin_name: str, marketplace_id: str = "mp") ->
|
|||
|
||||
|
||||
def _seed_flea_entity(conn, entity_id: str, name: str, type_: str = "skill") -> None:
|
||||
# v49 phase-1 added NOT NULL `title` + `synthetic_name` columns. Direct
|
||||
# INSERTs (bypassing repo.create's fallback) must supply both. Mirror
|
||||
# the formula the repo uses so per-test asserts still see the canonical
|
||||
# `<name>-by-<owner>` value.
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO store_entities "
|
||||
"(id, owner_user_id, owner_username, type, name, version, visibility_status) "
|
||||
"VALUES (?, ?, 'alice', ?, ?, '1.0', 'approved')",
|
||||
[entity_id, "uid-" + entity_id, type_, name],
|
||||
"(id, owner_user_id, owner_username, type, name, version, "
|
||||
" visibility_status, title, synthetic_name) "
|
||||
"VALUES (?, ?, 'alice', ?, ?, '1.0', 'approved', ?, ?)",
|
||||
[entity_id, "uid-" + entity_id, type_, name, name, f"{name}-by-alice"],
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -147,17 +152,63 @@ class TestMarketplaceItemDaily:
|
|||
assert row[0] == 3
|
||||
|
||||
def test_flea_skill_attributed_with_empty_parent(self, tmp_path, monkeypatch):
|
||||
"""v49 phase-5: rollup `name` carries the entity's synthetic_name
|
||||
(`<name>-by-<owner>`), matching what Claude Code writes after the
|
||||
bundle plugin's `flea:` prefix."""
|
||||
conn = _fresh_db(tmp_path, monkeypatch)
|
||||
_seed_flea_entity(conn, "ent-1", "flea-skill", type_="skill")
|
||||
today = datetime.now(timezone.utc).replace(hour=10, minute=0, second=0, microsecond=0)
|
||||
_seed_event(conn, occurred_at=today, tool_name="Skill",
|
||||
skill_name="agnes-store-bundle:flea-skill", event_id="ef-1")
|
||||
skill_name="flea:flea-skill-by-alice", event_id="ef-1")
|
||||
rebuild_rollups(conn, since_day=today.date())
|
||||
row = conn.execute(
|
||||
"SELECT source, type, parent_plugin, name "
|
||||
"FROM usage_marketplace_item_daily WHERE source='flea'"
|
||||
).fetchone()
|
||||
assert row == ("flea", "skill", "", "flea-skill")
|
||||
assert row == ("flea", "skill", "", "flea-skill-by-alice")
|
||||
|
||||
def test_flea_plugin_row_aggregates_children(self, tmp_path, monkeypatch):
|
||||
"""v49 phase-6: nested skill/agent invocations under a flea plugin
|
||||
entity get rolled up into a synthetic plugin-level row mirroring
|
||||
the curated path. Without this, `_load_invocation_stats('flea')`
|
||||
(filters `parent_plugin = ''`) returned no row for plugin entity
|
||||
cards / detail telemetry chips even though nested children were
|
||||
attributed correctly. `distinct_users` is the union across
|
||||
children (one user invoking two skills counts once)."""
|
||||
conn = _fresh_db(tmp_path, monkeypatch)
|
||||
# Plugin entity with synthetic_name="my-plug-by-alice" — the
|
||||
# frontmatter-baked name Claude Code writes as the JSONL prefix
|
||||
# when invoking nested skills inside this flea plugin.
|
||||
_seed_flea_entity(conn, "ent-plug", "my-plug", type_="plugin")
|
||||
today = datetime.now(timezone.utc).replace(hour=10, minute=0, second=0, microsecond=0)
|
||||
# Two nested skills, one of them invoked by two distinct users —
|
||||
# plugin-level distinct_users should be 2 (union of children),
|
||||
# not 3 (sum of per-child counts).
|
||||
_seed_event(conn, occurred_at=today, tool_name="Skill",
|
||||
skill_name="my-plug-by-alice:setup",
|
||||
user_id="uid-alice", username="alice", event_id="p1")
|
||||
_seed_event(conn, occurred_at=today, tool_name="Skill",
|
||||
skill_name="my-plug-by-alice:setup",
|
||||
user_id="uid-bob", username="bob", event_id="p2")
|
||||
_seed_event(conn, occurred_at=today, tool_name="Skill",
|
||||
skill_name="my-plug-by-alice:review",
|
||||
user_id="uid-alice", username="alice", event_id="p3")
|
||||
rebuild_rollups(conn, since_day=today.date())
|
||||
# Plugin-level aggregated row.
|
||||
row = conn.execute(
|
||||
"SELECT source, type, parent_plugin, name, count, distinct_users "
|
||||
"FROM usage_marketplace_item_daily "
|
||||
"WHERE source='flea' AND type='plugin'"
|
||||
).fetchone()
|
||||
assert row is not None, "flea plugin-level aggregated row missing"
|
||||
assert row == ("flea", "plugin", "", "my-plug-by-alice", 3, 2)
|
||||
# Child rows still present alongside the aggregate.
|
||||
children = conn.execute(
|
||||
"SELECT name, count FROM usage_marketplace_item_daily "
|
||||
"WHERE source='flea' AND type='skill' "
|
||||
"AND parent_plugin='my-plug-by-alice' ORDER BY name"
|
||||
).fetchall()
|
||||
assert children == [("review", 1), ("setup", 2)]
|
||||
|
||||
def test_unknown_plugin_excluded(self, tmp_path, monkeypatch):
|
||||
conn = _fresh_db(tmp_path, monkeypatch)
|
||||
|
|
|
|||
Loading…
Reference in a new issue