Feature 2.2 — Unified Catalog View: Implementation Plan¶
For agentic workers: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement task-by-task. Each step is 2–5 min. Steps use
- [ ]for tracking.
Goal: Unified catalog table (A2A + MCP side-by-side), protocol filtering, full-text search across all fields, drill-down detail with raw card JSON tab. Backend extracts raw card storage into a CardStorePlugin; removes AgentType.RawDefinition.
Approved spec: docs/superpowers/specs/2026-04-08-unified-catalog-view-design.md
Tech Stack: Go 1.26 · GORM (SQLite + Postgres) · chi · React 18 · Tailwind CSS · shadcn/ui · Vitest · Playwright
Design Decisions (carried from spec)¶
RawDefinitionremoved fromAgentType→ dedicatedplugins/cardstore/plugin.go(CardStorePlugin)GET /api/v1/catalogextended:sortparam + expandedqsearch (capabilities, categories, provider). No new endpoint.- Detail page restructured into shadcn Tabs (Overview + Raw Card)
@tanstack/react-queryadded for data fetching- Catalog UI: clean rewrite under
web/src/routes/catalog/. OldCatalogList.tsx+EntryDetail.tsxdeleted. - Syntax highlighting:
prismjs(JSON only)
File Map¶
Created¶
| File | Responsibility |
|---|---|
internal/model/raw_card.go |
RawCard plain struct |
plugins/cardstore/plugin.go |
CardStorePlugin implementation (SQLite + Postgres) |
plugins/cardstore/plugin_test.go |
CardStorePlugin unit tests |
web/src/lib/catalogApi.ts |
Extended API client functions |
web/src/hooks/useCatalogQuery.ts |
react-query hook with URL sync |
web/src/routes/catalog/CatalogListPage.tsx |
Clean-rewrite list page |
web/src/routes/catalog/CatalogDetailPage.tsx |
Clean-rewrite detail page (tabbed) |
web/src/routes/catalog/components/ProtocolFilter.tsx |
Protocol toggle-group |
web/src/routes/catalog/components/UnifiedSearchBox.tsx |
Debounced search input |
web/src/routes/catalog/components/CatalogRow.tsx |
Table row component |
web/src/routes/catalog/components/RawCardTab.tsx |
Syntax-highlighted raw card |
web/src/routes/catalog/components/SearchHighlight.tsx |
<mark> snippet renderer |
web/src/routes/catalog/components/SpecVersionBadge.tsx |
Spec version badge |
Various *.test.tsx / *.spec.ts |
Tests |
Modified¶
| File | What changes |
|---|---|
internal/kernel/plugin.go |
Add CardStorePlugin interface + PluginTypeCardStore constant |
internal/kernel/kernel.go |
Add cardStore CardStorePlugin field + CardStore() to Core |
internal/kernel/core_registry.go |
Add RegisterCardStore() |
internal/model/agent_type.go |
Remove RawDefinition, RawDefJSON, SyncRawDefForJSON() |
internal/model/agent.go |
Remove SyncRawDefForJSON() call in SyncFromDB() |
internal/model/archetype_test.go |
Remove RawDefinition from required/forbidden field lists |
internal/db/migrations.go |
Add migration006RawCards() |
internal/store/store.go |
Add Sort string to ListFilter |
internal/store/sql_store_query.go |
Expand q search + implement sort |
internal/api/handlers.go |
GetEntryCard reads from card store; ListCatalog adds sort param |
internal/api/catalog_helpers.go |
registerAgentType() stores card via plugin after create |
internal/api/import_handler.go |
ImportCatalogEntry() stores card via plugin after parse |
internal/discovery/manager.go |
upsert() stores card via card store after parse |
plugins/parsers/a2a/a2a.go |
Remove RawDefinition: raw |
plugins/parsers/mcp/mcp.go |
Remove RawDefinition: raw |
cmd/agentlens/main.go |
Register + wire cardstore.New() plugin |
web/src/types.ts |
Add sort to ListFilter; add SearchMatch interface |
web/src/api.ts |
Add sort param to listCatalog(); add getRawCard() |
web/src/App.tsx |
Rewire routes to new page components; remove old imports |
web/src/main.tsx |
Wrap <App> in QueryClientProvider |
web/package.json |
Add @tanstack/react-query, @radix-ui/react-toggle-group, @radix-ui/react-hover-card, prismjs, @types/prismjs |
e2e/tests/catalog.spec.ts |
Add unified-view E2E scenarios |
Deleted¶
| File | Reason |
|---|---|
web/src/components/CatalogList.tsx |
Replaced by routes/catalog/CatalogListPage.tsx |
web/src/components/EntryDetail.tsx |
Replaced by routes/catalog/CatalogDetailPage.tsx |
web/src/components/CatalogList.test.tsx |
Replaced by new test file |
web/src/components/EntryDetail.test.tsx |
Replaced by new test file |
Task 1 — RawCard model struct¶
Files: Create internal/model/raw_card.go
Why this order¶
All later tasks depend on this type. No behavior — just a plain struct.
- [ ] Step 1.1: Create
internal/model/raw_card.go
package model
import "time"
// RawCard holds the verbatim card bytes for an AgentType.
// It is owned by the CardStorePlugin — never embedded in AgentType or CatalogEntry.
type RawCard struct {
AgentTypeID string `json:"agent_type_id"`
Data []byte `json:"data"`
ContentType string `json:"content_type"`
FetchedAt time.Time `json:"fetched_at"`
Truncated bool `json:"truncated"`
}
- [ ] Step 1.2: Verify the file compiles
rtk go build ./internal/model/...
Expected: exits 0 (no compile errors).
- [ ] Step 1.3: Commit
rtk git add internal/model/raw_card.go
rtk git commit -m "feat(model): add RawCard plain struct"
Task 2 — CardStorePlugin kernel interface¶
Files: Modify internal/kernel/plugin.go, internal/kernel/kernel.go, internal/kernel/core_registry.go
Why this order¶
Must exist before the plugin implementation (Task 3) and the ingestion callers (Task 8).
- [ ] Step 2.1: Add
PluginTypeCardStoreconstant andCardStorePlugininterface tointernal/kernel/plugin.go
Add after the PluginTypeStore constant block:
PluginTypeCardStore PluginType = "cardstore"
Add after the SourcePlugin interface block:
// CardStorePlugin persists verbatim raw card bytes keyed by AgentTypeID.
type CardStorePlugin interface {
Plugin
StoreCard(ctx context.Context, agentTypeID string, data []byte, contentType string) error
GetCard(ctx context.Context, agentTypeID string) (*model.RawCard, error)
}
- [ ] Step 2.2: Add
cardStorefield andCardStore()accessor tointernal/kernel/kernel.go
In Core struct, add after middlewares:
cardStore CardStorePlugin
Add CardStore() CardStorePlugin to the Kernel interface block (after RegisterMiddleware):
CardStore() CardStorePlugin
Add the method to Core:
// CardStore returns the registered card store plugin, or nil if not loaded.
func (c *Core) CardStore() CardStorePlugin { return c.cardStore }
- [ ] Step 2.3: Add
RegisterCardStore()tointernal/kernel/core_registry.go
// RegisterCardStore registers the card store plugin.
func (c *Core) RegisterCardStore(p CardStorePlugin) {
c.cardStore = p
}
- [ ] Step 2.4: Compile
rtk go build ./internal/kernel/...
- [ ] Step 2.5: Commit
rtk git add internal/kernel/plugin.go internal/kernel/kernel.go internal/kernel/core_registry.go
rtk git commit -m "feat(kernel): add CardStorePlugin interface + PluginTypeCardStore"
Task 3 — CardStorePlugin implementation¶
Files: Create plugins/cardstore/plugin.go, plugins/cardstore/plugin_test.go
Why this order¶
Plugin exists before migration (Task 4) wires the schema. AutoMigrate in Init() is the safety net.
- [ ] Step 3.1: Write failing tests first — create
plugins/cardstore/plugin_test.go
package cardstore_test
import (
"context"
"testing"
"time"
"github.com/PawelHaracz/agentlens/internal/db"
"github.com/PawelHaracz/agentlens/internal/model"
"github.com/PawelHaracz/agentlens/plugins/cardstore"
)
func newTestPlugin(t *testing.T) *cardstore.Plugin {
t.Helper()
database, err := db.OpenMemory(db.DialectSQLite)
if err != nil {
t.Fatalf("open memory db: %v", err)
}
t.Cleanup(func() {
sqlDB, _ := database.DB.DB()
_ = sqlDB.Close()
})
p := cardstore.New(database)
if err := p.MigrateSchema(context.Background()); err != nil {
t.Fatalf("migrate schema: %v", err)
}
return p
}
func TestCardStore_StoreAndGet(t *testing.T) {
p := newTestPlugin(t)
ctx := context.Background()
data := []byte(`{"name":"test-agent"}`)
if err := p.StoreCard(ctx, "agent-001", data, "application/json"); err != nil {
t.Fatalf("StoreCard: %v", err)
}
card, err := p.GetCard(ctx, "agent-001")
if err != nil {
t.Fatalf("GetCard: %v", err)
}
if string(card.Data) != string(data) {
t.Errorf("Data mismatch: got %s, want %s", card.Data, data)
}
if card.ContentType != "application/json" {
t.Errorf("ContentType = %q, want application/json", card.ContentType)
}
if card.Truncated {
t.Error("Truncated should be false for small payload")
}
}
func TestCardStore_GetNotFound(t *testing.T) {
p := newTestPlugin(t)
_, err := p.GetCard(context.Background(), "nonexistent")
if err == nil {
t.Fatal("expected error for nonexistent card")
}
}
func TestCardStore_Truncation(t *testing.T) {
p := newTestPlugin(t)
ctx := context.Background()
// 257 KiB — exceeds 256 KiB cap
big := make([]byte, 257*1024)
for i := range big {
big[i] = 'x'
}
if err := p.StoreCard(ctx, "agent-big", big, "application/json"); err != nil {
t.Fatalf("StoreCard large: %v", err)
}
card, err := p.GetCard(ctx, "agent-big")
if err != nil {
t.Fatalf("GetCard large: %v", err)
}
if !card.Truncated {
t.Error("Truncated should be true for oversized payload")
}
if len(card.Data) > 256*1024 {
t.Errorf("Data len %d exceeds 256 KiB cap", len(card.Data))
}
}
func TestCardStore_UpsertUpdates(t *testing.T) {
p := newTestPlugin(t)
ctx := context.Background()
v1 := []byte(`{"version":"1"}`)
v2 := []byte(`{"version":"2"}`)
_ = p.StoreCard(ctx, "agent-upsert", v1, "application/json")
time.Sleep(time.Millisecond) // ensure FetchedAt advances
_ = p.StoreCard(ctx, "agent-upsert", v2, "application/json")
card, _ := p.GetCard(ctx, "agent-upsert")
if string(card.Data) != string(v2) {
t.Errorf("expected updated data v2, got %s", card.Data)
}
}
func TestCardPlugin_Name(t *testing.T) {
p := newTestPlugin(t)
if p.Name() != "card-store" {
t.Errorf("Name = %q, want card-store", p.Name())
}
}
func TestCardPlugin_Type(t *testing.T) {
p := newTestPlugin(t)
if p.Type() != "cardstore" {
t.Errorf("Type = %q, want cardstore", p.Type())
}
}
- [ ] Step 3.2: Run tests to verify they fail
rtk go test ./plugins/cardstore/... -v
Expected: compile error (cardstore package does not exist yet).
- [ ] Step 3.3: Create
plugins/cardstore/plugin.go
// Package cardstore provides the CardStorePlugin for persisting raw agent card bytes.
package cardstore
import (
"context"
"fmt"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/PawelHaracz/agentlens/internal/db"
"github.com/PawelHaracz/agentlens/internal/kernel"
"github.com/PawelHaracz/agentlens/internal/model"
)
const maxCardBytes = 256 * 1024 // 256 KiB hard cap
// rawCardRow is the GORM model for the raw_cards table.
// It is internal to this plugin — callers use model.RawCard.
type rawCardRow struct {
AgentTypeID string `gorm:"primaryKey;type:text"`
Data []byte `gorm:"not null;type:blob"`
ContentType string `gorm:"not null;type:text;default:'application/json'"`
FetchedAt time.Time `gorm:"not null"`
Truncated bool `gorm:"not null;default:false"`
}
func (rawCardRow) TableName() string { return "raw_cards" }
// Plugin implements kernel.CardStorePlugin.
type Plugin struct {
database *db.DB
}
// New creates a new Plugin. Call MigrateSchema before first use.
func New(database *db.DB) *Plugin {
return &Plugin{database: database}
}
// Name returns the plugin name.
func (p *Plugin) Name() string { return "card-store" }
// Version returns the plugin version.
func (p *Plugin) Version() string { return "1.0.0" }
// Type returns the plugin type.
func (p *Plugin) Type() kernel.PluginType { return kernel.PluginTypeCardStore }
// Init is called by the PluginManager. It retrieves the DB from the kernel
// and runs AutoMigrate as an idempotent safety net (migration 006 creates the table).
func (p *Plugin) Init(k kernel.Kernel) error {
// The database was injected via New() at composition root.
// AutoMigrate is ONLY a safety net — migration 006 creates the table first.
return p.MigrateSchema(context.Background())
}
// Start is a no-op — card store is synchronous.
func (p *Plugin) Start(_ context.Context) error { return nil }
// Stop is a no-op.
func (p *Plugin) Stop(_ context.Context) error { return nil }
// MigrateSchema ensures the raw_cards table exists. Idempotent.
func (p *Plugin) MigrateSchema(_ context.Context) error {
return p.database.AutoMigrate(&rawCardRow{})
}
// StoreCard persists raw card bytes for an AgentType (upsert by agentTypeID).
// Payloads exceeding maxCardBytes are truncated and flagged.
func (p *Plugin) StoreCard(ctx context.Context, agentTypeID string, data []byte, contentType string) error {
if agentTypeID == "" {
return fmt.Errorf("agentTypeID must not be empty")
}
truncated := false
if len(data) > maxCardBytes {
data = data[:maxCardBytes]
truncated = true
}
row := rawCardRow{
AgentTypeID: agentTypeID,
Data: data,
ContentType: contentType,
FetchedAt: time.Now().UTC(),
Truncated: truncated,
}
result := p.database.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "agent_type_id"}},
DoUpdates: clause.AssignmentColumns([]string{"data", "content_type", "fetched_at", "truncated"}),
}).
Create(&row)
if result.Error != nil {
return fmt.Errorf("storing raw card for %s: %w", agentTypeID, result.Error)
}
return nil
}
// GetCard retrieves the raw card for the given AgentTypeID.
// Returns an error (wrapping gorm.ErrRecordNotFound) if no card is stored.
func (p *Plugin) GetCard(ctx context.Context, agentTypeID string) (*model.RawCard, error) {
var row rawCardRow
result := p.database.WithContext(ctx).
Where("agent_type_id = ?", agentTypeID).
First(&row)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("no raw card stored for agent_type_id %s: %w", agentTypeID, result.Error)
}
return nil, fmt.Errorf("getting raw card for %s: %w", agentTypeID, result.Error)
}
return &model.RawCard{
AgentTypeID: row.AgentTypeID,
Data: row.Data,
ContentType: row.ContentType,
FetchedAt: row.FetchedAt,
Truncated: row.Truncated,
}, nil
}
- [ ] Step 3.4: Wire CardStorePlugin into PluginManager
Open internal/kernel/plugin_manager.go. In InitAll(), add detection for CardStorePlugin after the parser detection block:
if csp, ok := p.(CardStorePlugin); ok {
c.core.RegisterCardStore(csp)
}
- [ ] Step 3.5: Run card store tests
rtk go test ./plugins/cardstore/... -v
Expected: all 6 tests pass.
- [ ] Step 3.6: Run full test suite
rtk go test ./...
Expected: 0 failures.
- [ ] Step 3.7: Commit
rtk git add plugins/cardstore/ internal/kernel/plugin_manager.go
rtk git commit -m "feat(cardstore): CardStorePlugin implementation with 256 KiB cap"
Task 4 — Migration 006: raw_cards table¶
Files: Modify internal/db/migrations.go
Why this order¶
Migration must exist before the column removal from agent_types (Task 5). The migration copies existing RawDefinition data to raw_cards, then drops the column.
- [ ] Step 4.1: Add
migration006RawCards()toAllMigrations()and implement it
In internal/db/migrations.go, add to AllMigrations():
migration006RawCards(),
Add the function body at the end of the file:
func migration006RawCards() Migration {
return Migration{
Version: 6,
Description: "create raw_cards table, copy raw_definition data, drop raw_definition column",
Up: func(tx *gorm.DB) error {
// Step 1: Create raw_cards table if not already present.
if err := tx.Exec(`CREATE TABLE IF NOT EXISTS raw_cards (
agent_type_id TEXT PRIMARY KEY,
data BLOB NOT NULL,
content_type TEXT NOT NULL DEFAULT 'application/json',
fetched_at DATETIME NOT NULL,
truncated BOOLEAN NOT NULL DEFAULT FALSE
)`).Error; err != nil {
return fmt.Errorf("creating raw_cards table: %w", err)
}
// Step 2: Copy existing raw_definition bytes into raw_cards (INSERT OR IGNORE).
// Only copy rows that have non-empty raw_definition.
if err := tx.Exec(`
INSERT OR IGNORE INTO raw_cards (agent_type_id, data, content_type, fetched_at, truncated)
SELECT id, raw_definition, 'application/json', CURRENT_TIMESTAMP, FALSE
FROM agent_types
WHERE raw_definition IS NOT NULL AND length(raw_definition) > 0
`).Error; err != nil {
return fmt.Errorf("copying raw_definition to raw_cards: %w", err)
}
// Step 3: Drop raw_definition column from agent_types.
// SQLite 3.35+ supports DROP COLUMN directly.
// For older SQLite versions (< 3.35), we skip and leave the column.
// In production Postgres, ALTER TABLE DROP COLUMN works unconditionally.
if err := tx.Exec(`ALTER TABLE agent_types DROP COLUMN IF EXISTS raw_definition`).Error; err != nil {
// SQLite older than 3.35 does not support DROP COLUMN.
// The column becomes a harmless dead column.
_ = err
}
return nil
},
}
}
- [ ] Step 4.2: Write a migration test
In internal/db/migrate_test.go (or a new migrations_test.go in internal/db/), add:
func TestMigration006_RawCardsCreated(t *testing.T) {
database, err := OpenMemory(DialectSQLite)
if err != nil {
t.Fatal(err)
}
defer func() {
sqlDB, _ := database.DB.DB()
_ = sqlDB.Close()
}()
migrator := NewMigrator(database, AllMigrations())
if err := migrator.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
// Verify raw_cards table exists.
if !database.Migrator().HasTable("raw_cards") {
t.Error("raw_cards table should exist after migration 006")
}
// Verify agent_types table still exists.
if !database.Migrator().HasTable("agent_types") {
t.Error("agent_types table should still exist")
}
}
- [ ] Step 4.3: Run migration tests
rtk go test ./internal/db/... -v -run TestMigration
Expected: all migration tests pass including the new one.
- [ ] Step 4.4: Commit
rtk git add internal/db/migrations.go internal/db/
rtk git commit -m "feat(db): migration 006 — raw_cards table, copy raw_definition, drop column"
Task 5 — Remove RawDefinition from AgentType¶
Files: Modify internal/model/agent_type.go, internal/model/agent.go, internal/model/archetype_test.go
Why this order¶
The migration has already run (Task 4), so the DB no longer has the column. Now remove the Go struct field.
- [ ] Step 5.1: Run archetype tests first to confirm current state
rtk go test ./internal/model/... -run TestArchetype -v
Note which tests pass/fail before the change.
- [ ] Step 5.2: Update
internal/model/archetype_test.go
In TestArchetype_AgentTypeHasRequiredFields, remove "RawDefinition" from the required slice:
Old:
required := []string{"ID", "AgentKey", "Protocol", "Endpoint", "RawDefinition", "CreatedOn"}
required := []string{"ID", "AgentKey", "Protocol", "Endpoint", "CreatedOn"}
In TestArchetype_CatalogEntryHasNoProductFields, remove "RawDefinition" from productOnlyFields:
Old:
productOnlyFields := []string{
"Protocol", "Endpoint", "RawDefinition",
"Capabilities", "Skills", "TypedMeta", "SpecVersion",
}
productOnlyFields := []string{
"Protocol", "Endpoint",
"Capabilities", "Skills", "TypedMeta", "SpecVersion",
}
- [ ] Step 5.3: Remove
RawDefinition,RawDefJSON, andSyncRawDefForJSON()frominternal/model/agent_type.go
Remove the RawDefinition field line:
RawDefinition []byte `json:"-" gorm:"not null;type:blob"`
Remove the RawDefJSON field line:
RawDefJSON json.RawMessage `json:"raw_definition,omitempty" gorm:"-"`
Remove the entire SyncRawDefForJSON() method:
// SyncRawDefForJSON populates RawDefJSON from RawDefinition for JSON serialization.
func (a *AgentType) SyncRawDefForJSON() {
if len(a.RawDefinition) > 0 && json.Valid(a.RawDefinition) {
a.RawDefJSON = json.RawMessage(a.RawDefinition)
}
}
Remove the "encoding/json" import from agent_type.go if it becomes unused.
- [ ] Step 5.4: Remove
SyncRawDefForJSON()call ininternal/model/agent.go
In SyncFromDB(), remove:
if e.AgentType != nil {
e.AgentType.SyncRawDefForJSON()
}
- [ ] Step 5.5: Compile to find all broken references
rtk go build ./...
Fix any remaining references to RawDefinition or SyncRawDefForJSON found by the compiler.
- [ ] Step 5.6: Run archetype tests
rtk go test ./internal/model/... -run TestArchetype -v
Expected: all archetype tests pass.
- [ ] Step 5.7: Run full test suite
rtk go test ./...
Expected: 0 failures.
- [ ] Step 5.8: Commit
rtk git add internal/model/
rtk git commit -m "feat(model): remove AgentType.RawDefinition — superseded by CardStorePlugin"
Task 6 — Update parsers: stop setting RawDefinition¶
Files: Modify plugins/parsers/a2a/a2a.go, plugins/parsers/mcp/mcp.go
Why this order¶
After Task 5 the field is gone; the compiler already caught these. Confirm structural fix here.
- [ ] Step 6.1: Remove
RawDefinition: rawfromplugins/parsers/a2a/a2a.go
In the Parse() return statement, remove the line:
RawDefinition: raw,
- [ ] Step 6.2: Remove
RawDefinition: rawfromplugins/parsers/mcp/mcp.go
In the Parse() return statement, remove the line:
RawDefinition: raw,
- [ ] Step 6.3: Run parser tests
rtk go test ./plugins/parsers/... -v
Expected: all tests pass.
- [ ] Step 6.4: Commit
rtk git add plugins/parsers/
rtk git commit -m "feat(parsers): stop setting RawDefinition — callers store card via CardStorePlugin"
Task 7 — Update CreateEntry handler (stub card)¶
Files: Modify internal/api/handlers.go
Why this order¶
CreateEntry manually constructs AgentType with RawDefinition: []byte("{}"). After Task 5 that field is gone — fix the compile error now.
- [ ] Step 7.1: Remove
RawDefinition: []byte("{}")fromCreateEntryininternal/api/handlers.go
In the agentType literal inside CreateEntry:
Old:
agentType := &model.AgentType{
ID: uuid.NewString(),
Protocol: model.Protocol(req.Protocol),
Endpoint: req.Endpoint,
Version: req.Version,
RawDefinition: []byte("{}"),
CreatedOn: now,
}
agentType := &model.AgentType{
ID: uuid.NewString(),
Protocol: model.Protocol(req.Protocol),
Endpoint: req.Endpoint,
Version: req.Version,
CreatedOn: now,
}
- [ ] Step 7.2: Compile and test
rtk go build ./... && rtk go test ./...
Expected: 0 failures.
- [ ] Step 7.3: Commit
rtk git add internal/api/handlers.go
rtk git commit -m "fix(api): remove RawDefinition stub from CreateEntry"
Task 8 — Wire card storage into every ingestion path¶
Files: Modify internal/api/catalog_helpers.go, internal/api/import_handler.go, internal/api/register_handler.go, internal/discovery/manager.go
Why this order¶
All callers that previously set RawDefinition on the AgentType must now call CardStore().StoreCard() after creating the catalog entry. The card store may be nil (plugin disabled) — guard with a nil check.
- [ ] Step 8.1: Store card in
internal/api/catalog_helpers.goregisterAgentType()
After h.store.Create(ctx, entry) succeeds, add:
// Store the raw card via the card store plugin (best-effort — not fatal if plugin absent).
if cs := h.parsers.CardStore(); cs != nil && len(rawCard) > 0 {
if err := cs.StoreCard(ctx, agentType.ID, rawCard, "application/json"); err != nil {
slog.WarnContext(ctx, "failed to store raw card", "agent_type_id", agentType.ID, "err", err)
}
}
- [ ] Step 8.2: Store card in
internal/discovery/manager.goupsert()— new entries
The Manager struct does not hold a kernel reference. Add a cardStore field of type kernel.CardStorePlugin. Update NewManager:
// Manager orchestrates periodic discovery from all sources.
type Manager struct {
sources []Source
store store.Store
cardStore kernel.CardStorePlugin // may be nil
pollInterval time.Duration
log *slog.Logger
}
// NewManager creates a new Manager.
func NewManager(sources []Source, s store.Store, pollInterval time.Duration) *Manager {
return &Manager{
sources: sources,
store: s,
pollInterval: pollInterval,
log: slog.With("component", "discovery-manager"),
}
}
// SetCardStore injects the card store plugin (called after plugin init).
func (m *Manager) SetCardStore(cs kernel.CardStorePlugin) {
m.cardStore = cs
}
In upsert(), after the new entry m.store.Create(ctx, entry) succeeds, add:
if m.cardStore != nil && at.rawBytes != nil {
if err := m.cardStore.StoreCard(ctx, at.ID, at.rawBytes, "application/json"); err != nil {
m.log.Warn("failed to store raw card", "endpoint", at.Endpoint, "err", err)
}
}
Note: The discovery sources return *model.AgentType without the raw bytes attached (they set RawDefinition before Task 5). We need to thread the raw bytes through. The cleanest approach without changing the Source.Discover() signature is to extend the AgentType with a transient field:
In internal/model/agent_type.go, add a transient field after Capabilities:
// RawBytes holds the original card bytes during discovery. Not persisted.
// Populated by crawlers/sources; consumed by ingestion path to call StoreCard.
RawBytes []byte `json:"-" gorm:"-"`
Update plugins/parsers/a2a/a2a.go and plugins/parsers/mcp/mcp.go to set RawBytes: raw (not RawDefinition).
Update upsert() in manager.go to use at.RawBytes (not at.rawBytes).
Also store card for updated entries:
// Update existing entry
if existing.AgentType != nil {
existing.AgentType.Protocol = at.Protocol
existing.AgentType.Version = at.Version
existing.AgentType.SpecVersion = at.SpecVersion
existing.AgentType.Provider = at.Provider
existing.AgentType.Capabilities = at.Capabilities
}
existing.Validity.LastSeen = now
existing.UpdatedAt = now
if err := m.store.Update(ctx, existing); err != nil {
m.log.Warn("failed to update entry", "id", existing.ID, "err", err)
}
// Store updated raw card.
if m.cardStore != nil && len(at.RawBytes) > 0 && existing.AgentType != nil {
if err := m.cardStore.StoreCard(ctx, existing.AgentType.ID, at.RawBytes, "application/json"); err != nil {
m.log.Warn("failed to store raw card on update", "id", existing.AgentType.ID, "err", err)
}
}
- [ ] Step 8.3: Wire
SetCardStoreincmd/agentlens/main.go
After plugin init in main.go, after the PluginManager.InitAll() call, add:
if core.CardStore() != nil {
mgr.SetCardStore(core.CardStore())
}
Note: mgr must be moved before this call (currently created after). Move the discovery.NewManager(...) call to before pm.InitAll() is used, or add a deferred SetCardStore after init. The simplest approach: move the discovery manager creation earlier in main.go and call mgr.SetCardStore(core.CardStore()) after pm.InitAll().
- [ ] Step 8.4: Compile and test
rtk go build ./... && rtk go test ./...
Expected: 0 failures.
- [ ] Step 8.5: Commit
rtk git add internal/api/ internal/discovery/ internal/model/agent_type.go cmd/agentlens/
rtk git commit -m "feat(ingestion): store raw card via CardStorePlugin in all ingestion paths"
Task 9 — Update GET /catalog/{id}/card to read from card store¶
Files: Modify internal/api/handlers.go
Why this order¶
GetEntryCard currently reads entry.AgentType.RawDefinition which is gone. Replace with card store lookup.
- [ ] Step 9.1: Write a failing handler test
In internal/api/handlers_test.go, add:
func TestGetEntryCard_FromCardStore(t *testing.T) {
// This test verifies GetEntryCard returns 200 when card is in card store,
// and 404 when no card is stored.
// Set up a handler with a mock/nil card store and verify behavior.
// (Full test implementation uses testServer helper from test_helpers_test.go)
}
(See existing test patterns in handlers_test.go for the full setup.)
- [ ] Step 9.2: Update
GetEntryCardininternal/api/handlers.go
Replace the entire GetEntryCard function:
// GetEntryCard handles GET /api/v1/catalog/{id}/card.
// Returns the raw agent card bytes from the card store plugin.
// Returns 404 if the entry does not exist or no card is stored.
func (h *Handler) GetEntryCard(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
entry, err := h.store.Get(r.Context(), id)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "failed to get catalog entry")
return
}
if entry == nil {
ErrorResponse(w, http.StatusNotFound, "catalog entry not found")
return
}
cs := h.parsers.CardStore()
if cs == nil {
ErrorResponse(w, http.StatusNotFound, "card store plugin not loaded")
return
}
card, err := cs.GetCard(r.Context(), entry.AgentTypeID)
if err != nil {
ErrorResponse(w, http.StatusNotFound, "no raw card stored")
return
}
// Weak ETag from FetchedAt for conditional GET support.
etag := fmt.Sprintf(`W/"%d"`, card.FetchedAt.UnixMilli())
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", card.ContentType)
w.Header().Set("X-Raw-Card-Fetched-At", card.FetchedAt.UTC().Format(time.RFC3339))
w.Header().Set("ETag", etag)
if card.Truncated {
w.Header().Set("X-Raw-Card-Truncated", "true")
}
w.WriteHeader(http.StatusOK)
if _, err := w.Write(card.Data); err != nil {
slog.Error("failed to write card response", "err", err)
}
}
Add "fmt" and "time" to imports if not already present.
- [ ] Step 9.3: Run API tests
rtk go test ./internal/api/... -v
Expected: all existing tests plus new card store test pass.
- [ ] Step 9.4: Commit
rtk git add internal/api/handlers.go internal/api/handlers_test.go
rtk git commit -m "feat(api): GetEntryCard reads from CardStorePlugin, adds ETag + X-Raw-Card-Fetched-At"
Task 10 — Add sort param to ListFilter + store query¶
Files: Modify internal/store/store.go, internal/store/sql_store_query.go, internal/api/handlers.go
Why this order¶
Sort is a pure backend extension with no model changes. Expand search in same step.
- [ ] Step 10.1: Add
Sort stringtoListFilterininternal/store/store.go
// ListFilter holds filtering parameters for listing catalog entries.
type ListFilter struct {
Protocol *model.Protocol
States []model.LifecycleState
Source *model.SourceType
Team string
Query string
Categories []string
Sort string // "lastSuccessAt_desc" (default) | "displayName_asc" | "createdAt_desc"
Limit int
Offset int
}
- [ ] Step 10.2: Write store tests for sort and expanded search
In internal/store/sql_store_query_test.go (create if it does not exist), add:
func TestList_SortLastSuccessAtDesc(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
// Create two entries; give one a recent health success.
entry1 := buildTestEntry("test-a2a-001", model.ProtocolA2A, "http://agent1.test")
entry2 := buildTestEntry("test-a2a-002", model.ProtocolA2A, "http://agent2.test")
_ = s.Create(ctx, entry1)
_ = s.Create(ctx, entry2)
now := time.Now().UTC()
_ = s.UpdateHealth(ctx, entry2.ID, model.Health{
State: model.LifecycleActive,
LastSuccessAt: &now,
})
results, err := s.List(ctx, store.ListFilter{Sort: "lastSuccessAt_desc"})
if err != nil {
t.Fatal(err)
}
if len(results) < 2 {
t.Fatalf("expected at least 2 results, got %d", len(results))
}
// entry2 had a success; should come first.
if results[0].ID != entry2.ID {
t.Errorf("expected entry2 first (has lastSuccessAt), got %s", results[0].ID)
}
}
func TestList_ExpandedSearch_Capabilities(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
entry := buildTestEntry("test-mcp-001", model.ProtocolMCP, "http://mcp-translate.test")
entry.AgentType.Capabilities = []model.Capability{
&model.MCPTool{Name: "translate-text", Description: "Translates between languages"},
}
_ = s.Create(ctx, entry)
results, err := s.List(ctx, store.ListFilter{Query: "translate"})
if err != nil {
t.Fatal(err)
}
if len(results) == 0 {
t.Error("expected results for capability name search 'translate'")
}
}
- [ ] Step 10.3: Run tests to verify they fail
rtk go test ./internal/store/... -run TestList_Sort -v
Expected: test panics or returns wrong order (sort not yet implemented).
- [ ] Step 10.4: Implement sort and expanded search in
internal/store/sql_store_query.go
Replace the filter.Query block and the fixed Order clause:
if filter.Query != "" {
q := "%" + strings.ToLower(filter.Query) + "%"
query = query.
Joins("LEFT JOIN capabilities ON capabilities.agent_type_id = agent_types.id").
Where(
"LOWER(catalog_entries.display_name) LIKE ? OR "+
"LOWER(catalog_entries.description) LIKE ? OR "+
"LOWER(capabilities.name) LIKE ? OR "+
"LOWER(capabilities.description) LIKE ? OR "+
"LOWER(catalog_entries.categories) LIKE ?",
q, q, q, q, q,
).
Distinct("catalog_entries.*").
Joins("LEFT JOIN providers p2 ON p2.id = agent_types.provider_id").
Where("LOWER(p2.organization) LIKE ? OR catalog_entries.description LIKE ?", q, q)
}
Replace the fixed order:
switch filter.Sort {
case "displayName_asc":
query = query.Order("catalog_entries.display_name ASC")
case "createdAt_desc":
query = query.Order("catalog_entries.created_at DESC")
default: // "lastSuccessAt_desc" or empty
query = query.Order("catalog_entries.health_last_success_at DESC NULLS LAST, catalog_entries.display_name ASC")
}
Note on NULLS LAST: SQLite supports NULLS LAST syntax. PostgreSQL also supports it. This is safe for both dialects.
- [ ] Step 10.5: Parse
sortparam inListCataloghandler (internal/api/handlers.go)
After the offset parsing block, add:
if v := q.Get("sort"); v != "" {
validSorts := map[string]bool{
"lastSuccessAt_desc": true,
"displayName_asc": true,
"createdAt_desc": true,
}
if !validSorts[v] {
ErrorResponse(w, http.StatusBadRequest, "invalid sort value: "+v)
return
}
filter.Sort = v
}
- [ ] Step 10.6: Run store and handler tests
rtk go test ./internal/store/... ./internal/api/... -v
Expected: all tests pass.
- [ ] Step 10.7: Commit
rtk git add internal/store/ internal/api/handlers.go
rtk git commit -m "feat(store,api): add sort param + expand q search to capabilities/categories/provider"
Task 11 — Register CardStorePlugin in main.go¶
Files: Modify cmd/agentlens/main.go
Why this order¶
All plugin/kernel changes are in place; wire the composition root.
- [ ] Step 11.1: Add cardstore import and registration to
cmd/agentlens/main.go
Add import:
cardstorePlugin "github.com/PawelHaracz/agentlens/plugins/cardstore"
Before pm.Register(a2aplugin.New()), add:
pm.Register(cardstorePlugin.New(database))
After pm.InitAll() (and before starting discovery manager), add the SetCardStore call:
// Wire card store into discovery manager if plugin loaded.
if core.CardStore() != nil {
mgr.SetCardStore(core.CardStore())
}
- [ ] Step 11.2: Build the binary
rtk make build
Expected: binary compiles successfully.
- [ ] Step 11.3: Commit
rtk git add cmd/agentlens/main.go
rtk git commit -m "feat(main): register CardStorePlugin + wire into discovery manager"
Task 12 — Frontend: install new dependencies¶
Files: Modify web/package.json
- [ ] Step 12.1: Install dependencies
cd web && bun add @tanstack/react-query @radix-ui/react-toggle-group @radix-ui/react-hover-card prismjs && bun add -d @types/prismjs
- [ ] Step 12.2: Verify TypeScript can find types
rtk make web-lint
Expected: no new type errors.
- [ ] Step 12.3: Commit
rtk git add web/package.json web/bun.lockb
rtk git commit -m "feat(web): add react-query, radix toggle-group, hover-card, prismjs"
Task 13 — Frontend: QueryClientProvider + types update¶
Files: Modify web/src/main.tsx, web/src/types.ts, web/src/api.ts
- [ ] Step 13.1: Add
QueryClientProvidertoweb/src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)
- [ ] Step 13.2: Add
sorttoListFilterandSearchMatchtoweb/src/types.ts
In the ListFilter interface, add:
sort?: 'lastSuccessAt_desc' | 'displayName_asc' | 'createdAt_desc'
Add new interface after ListFilter:
export interface SearchMatch {
field: string
snippet: string
}
- [ ] Step 13.3: Add
sortparam andgetRawCard()toweb/src/api.ts
In listCatalog(), add after the categories param:
if (filter.sort) params.set('sort', filter.sort)
Add getRawCard function after getEntry:
export async function getRawCard(id: string): Promise<{ data: string; contentType: string; fetchedAt: string; truncated: boolean }> {
const headers: Record<string, string> = {}
if (authToken) headers['Authorization'] = `Bearer ${authToken}`
const res = await fetch(`${BASE}/catalog/${id}/card`, { headers })
if (res.status === 401 && authToken) handleUnauthorized()
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(body.error ?? res.statusText)
}
const data = await res.text()
return {
data,
contentType: res.headers.get('Content-Type') ?? 'application/json',
fetchedAt: res.headers.get('X-Raw-Card-Fetched-At') ?? '',
truncated: res.headers.get('X-Raw-Card-Truncated') === 'true',
}
}
- [ ] Step 13.4: Run frontend lint
rtk make web-lint
Expected: clean.
- [ ] Step 13.5: Commit
rtk git add web/src/main.tsx web/src/types.ts web/src/api.ts
rtk git commit -m "feat(web): QueryClientProvider, sort param, getRawCard API function"
Task 14 — Frontend: useCatalogQuery hook¶
Files: Create web/src/hooks/useCatalogQuery.ts
- [ ] Step 14.1: Write the hook test first — create
web/src/hooks/useCatalogQuery.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import React from 'react'
import { useCatalogQuery } from './useCatalogQuery'
import * as api from '../api'
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(
QueryClientProvider,
{ client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) },
React.createElement(MemoryRouter, null, children)
)
describe('useCatalogQuery', () => {
beforeEach(() => {
vi.spyOn(api, 'listCatalog').mockResolvedValue([])
})
it('calls listCatalog with default filter', async () => {
renderHook(() => useCatalogQuery(), { wrapper })
await waitFor(() => {
expect(api.listCatalog).toHaveBeenCalledWith(expect.objectContaining({}))
})
})
})
- [ ] Step 14.2: Run test to confirm it fails
rtk make web-test
Expected: compile error (useCatalogQuery module not found).
- [ ] Step 14.3: Create
web/src/hooks/useCatalogQuery.ts
import { useQuery } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom'
import { useCallback } from 'react'
import { listCatalog } from '../api'
import type { ListFilter, Protocol, LifecycleState } from '../types'
export interface CatalogQueryResult {
entries: ReturnType<typeof useQuery>['data']
isLoading: boolean
isError: boolean
error: Error | null
filter: ListFilter
setProtocol: (protocol: Protocol | undefined) => void
setQuery: (q: string) => void
setSort: (sort: ListFilter['sort']) => void
setStates: (states: LifecycleState[]) => void
clearFilters: () => void
refetch: () => void
}
export function useCatalogQuery(): CatalogQueryResult {
const [searchParams, setSearchParams] = useSearchParams()
const protocol = (searchParams.get('protocol') as Protocol) || undefined
const q = searchParams.get('q') || undefined
const sort = (searchParams.get('sort') as ListFilter['sort']) || undefined
const stateParam = searchParams.get('state')
const states = stateParam
? (stateParam.split(',') as LifecycleState[])
: undefined
const filter: ListFilter = {
protocol,
q,
sort,
state: stateParam || undefined,
}
const result = useQuery({
queryKey: ['catalog', { protocol, q, sort, states }],
queryFn: () => listCatalog(filter),
})
const setProtocol = useCallback(
(p: Protocol | undefined) => {
setSearchParams(prev => {
const next = new URLSearchParams(prev)
if (p) next.set('protocol', p)
else next.delete('protocol')
next.delete('offset')
return next
})
},
[setSearchParams]
)
const setQuery = useCallback(
(newQ: string) => {
setSearchParams(prev => {
const next = new URLSearchParams(prev)
if (newQ) next.set('q', newQ)
else next.delete('q')
next.delete('offset')
return next
})
},
[setSearchParams]
)
const setSort = useCallback(
(newSort: ListFilter['sort']) => {
setSearchParams(prev => {
const next = new URLSearchParams(prev)
if (newSort) next.set('sort', newSort)
else next.delete('sort')
return next
})
},
[setSearchParams]
)
const setStates = useCallback(
(newStates: LifecycleState[]) => {
setSearchParams(prev => {
const next = new URLSearchParams(prev)
if (newStates.length > 0) next.set('state', newStates.join(','))
else next.delete('state')
next.delete('offset')
return next
})
},
[setSearchParams]
)
const clearFilters = useCallback(() => {
setSearchParams({})
}, [setSearchParams])
return {
entries: result.data,
isLoading: result.isLoading,
isError: result.isError,
error: result.error as Error | null,
filter,
setProtocol,
setQuery,
setSort,
setStates,
clearFilters,
refetch: result.refetch,
}
}
- [ ] Step 14.4: Run hook test
rtk make web-test
Expected: hook test passes.
- [ ] Step 14.5: Commit
rtk git add web/src/hooks/
rtk git commit -m "feat(web): useCatalogQuery hook with URL sync via react-query"
Task 15 — Frontend: SpecVersionBadge + SearchHighlight components¶
Files: Create web/src/routes/catalog/components/SpecVersionBadge.tsx, web/src/routes/catalog/components/SearchHighlight.tsx
- [ ] Step 15.1: Create
web/src/routes/catalog/components/SpecVersionBadge.tsx
import { Badge } from '../../components/ui/badge'
interface Props {
version?: string
}
export function SpecVersionBadge({ version }: Props) {
if (!version) return null
return (
<Badge variant="outline" className="font-mono text-xs">
{version}
</Badge>
)
}
Note: The ui/badge import path is relative to the new location. Adjust if needed:
import { Badge } from '../../../components/ui/badge'
- [ ] Step 15.2: Create
web/src/routes/catalog/components/SearchHighlight.tsx
interface Props {
snippet?: string
}
export function SearchHighlight({ snippet }: Props) {
if (!snippet) return null
// Snippet may contain HTML <mark> tags from server or be plain text.
// We render as plain text and bold the match portion.
return (
<p className="mt-0.5 text-xs text-muted-foreground line-clamp-1 italic">
…{snippet}…
</p>
)
}
- [ ] Step 15.3: Run lint
rtk make web-lint
- [ ] Step 15.4: Commit
rtk git add web/src/routes/
rtk git commit -m "feat(web): SpecVersionBadge + SearchHighlight components"
Task 16 — Frontend: ProtocolFilter + UnifiedSearchBox¶
Files: Create web/src/routes/catalog/components/ProtocolFilter.tsx, web/src/routes/catalog/components/UnifiedSearchBox.tsx
shadcn ToggleGroup: Install with bunx shadcn@latest add toggle-group first.
- [ ] Step 16.1: Add shadcn ToggleGroup component
cd web && bunx shadcn@latest add toggle-group
Expected: creates web/src/components/ui/toggle-group.tsx.
- [ ] Step 16.2: Create
web/src/routes/catalog/components/ProtocolFilter.tsx
import { Boxes, Bot, Plug } from 'lucide-react'
import { ToggleGroup, ToggleGroupItem } from '../../../components/ui/toggle-group'
import type { Protocol } from '../../../types'
interface Props {
value: Protocol | undefined
onChange: (protocol: Protocol | undefined) => void
}
const OPTIONS = [
{ value: undefined, label: 'All', icon: Boxes },
{ value: 'a2a' as Protocol, label: 'A2A', icon: Bot },
{ value: 'mcp' as Protocol, label: 'MCP', icon: Plug },
]
export function ProtocolFilter({ value, onChange }: Props) {
return (
<ToggleGroup
type="single"
value={value ?? 'all'}
onValueChange={v => onChange(v === 'all' ? undefined : (v as Protocol))}
aria-label="Filter by protocol"
>
{OPTIONS.map(opt => (
<ToggleGroupItem
key={opt.value ?? 'all'}
value={opt.value ?? 'all'}
aria-label={opt.label}
className="flex items-center gap-1.5"
>
<opt.icon className="h-3.5 w-3.5" />
{opt.label}
</ToggleGroupItem>
))}
</ToggleGroup>
)
}
- [ ] Step 16.3: Create
web/src/routes/catalog/components/UnifiedSearchBox.tsx
import { useEffect, useRef, useState } from 'react'
import { Search, X } from 'lucide-react'
import { Input } from '../../../components/ui/input'
interface Props {
value: string | undefined
onChange: (q: string) => void
}
export function UnifiedSearchBox({ value, onChange }: Props) {
const [draft, setDraft] = useState(value ?? '')
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Sync external value changes (e.g., clear filters).
useEffect(() => {
setDraft(value ?? '')
}, [value])
// Focus on '/' keydown anywhere on the page.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === '/' && document.activeElement !== inputRef.current) {
e.preventDefault()
inputRef.current?.focus()
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [])
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const v = e.target.value
setDraft(v)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => onChange(v), 250)
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
if (timerRef.current) clearTimeout(timerRef.current)
onChange(draft)
}
if (e.key === 'Escape') {
inputRef.current?.blur()
}
}
function handleClear() {
setDraft('')
onChange('')
inputRef.current?.focus()
}
return (
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
ref={inputRef}
value={draft}
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder="Search across A2A and MCP — name, description, skills, tags, provider…"
className="pl-9 pr-8"
aria-label="Search catalog"
/>
{draft && (
<button
onClick={handleClear}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
)
}
- [ ] Step 16.4: Run lint
rtk make web-lint
- [ ] Step 16.5: Commit
rtk git add web/src/routes/ web/src/components/ui/toggle-group.tsx
rtk git commit -m "feat(web): ProtocolFilter + UnifiedSearchBox components"
Task 17 — Frontend: CatalogRow component¶
Files: Create web/src/routes/catalog/components/CatalogRow.tsx
Depends on: StatusBadge, ProtocolBadge (existing), SpecVersionBadge, SearchHighlight (Task 15).
- [ ] Step 17.1: Create
web/src/routes/catalog/components/CatalogRow.tsx
import { useNavigate } from 'react-router-dom'
import { ExternalLink } from 'lucide-react'
import { TableRow, TableCell } from '../../../components/ui/table'
import StatusBadge from '../../../components/StatusBadge'
import ProtocolBadge from '../../../components/ProtocolBadge'
import { SpecVersionBadge } from './SpecVersionBadge'
import { SearchHighlight } from './SearchHighlight'
import type { CatalogEntry } from '../../../types'
interface Props {
entry: CatalogEntry
searchSnippet?: string
}
function relativeTime(iso: string | null): string {
if (!iso) return '—'
const ms = Date.now() - new Date(iso).getTime()
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s ago`
const m = Math.floor(s / 60)
if (m < 60) return `${m}m ago`
const h = Math.floor(m / 60)
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}
export function CatalogRow({ entry, searchSnippet }: Props) {
const navigate = useNavigate()
const skillCount = entry.capabilities?.length ?? 0
const provider = entry.provider?.organization
return (
<TableRow
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => navigate(`/catalog/${entry.id}`)}
role="link"
aria-label={`View ${entry.display_name}`}
>
<TableCell>
<ProtocolBadge protocol={entry.protocol} />
</TableCell>
<TableCell>
<div>
<p className="font-medium truncate max-w-[260px]">{entry.display_name}</p>
{entry.description && (
<p className="text-xs text-muted-foreground truncate max-w-[260px]">
{entry.description.slice(0, 80)}
</p>
)}
<SearchHighlight snippet={searchSnippet} />
</div>
</TableCell>
<TableCell>
{provider ? (
<span className="flex items-center gap-1 text-sm">
{provider}
{entry.provider?.url && (
<a
href={entry.provider.url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="text-muted-foreground hover:text-foreground"
aria-label="Provider website"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</span>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>
<span className="text-sm tabular-nums">
{skillCount > 0 ? `${skillCount} skill${skillCount === 1 ? '' : 's'}` : '—'}
</span>
</TableCell>
<TableCell>
<StatusBadge status={entry.status} health={entry.health} />
</TableCell>
<TableCell>
<SpecVersionBadge version={entry.spec_version} />
</TableCell>
<TableCell>
<span
className="text-sm text-muted-foreground"
title={entry.health?.lastSuccessAt ?? undefined}
>
{relativeTime(entry.health?.lastSuccessAt ?? null)}
</span>
</TableCell>
</TableRow>
)
}
- [ ] Step 17.2: Run lint
rtk make web-lint
- [ ] Step 17.3: Commit
rtk git add web/src/routes/catalog/components/CatalogRow.tsx
rtk git commit -m "feat(web): CatalogRow table row component"
Task 18 — Frontend: CatalogListPage (clean rewrite)¶
Files: Create web/src/routes/catalog/CatalogListPage.tsx
- [ ] Step 18.1: Create
web/src/routes/catalog/CatalogListPage.tsx
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Table, TableBody, TableHead, TableHeader, TableRow } from '../../components/ui/table'
import { Skeleton } from '../../components/ui/skeleton'
import { Alert, AlertDescription } from '../../components/ui/alert'
import { Button } from '../../components/ui/button'
import StatsBar from '../../components/StatsBar'
import RegisterAgentDialog from '../../components/RegisterAgentDialog'
import { ProtocolFilter } from './components/ProtocolFilter'
import { UnifiedSearchBox } from './components/UnifiedSearchBox'
import { CatalogRow } from './components/CatalogRow'
import { useCatalogQuery } from '../../hooks/useCatalogQuery'
import type { CatalogEntry } from '../../types'
export default function CatalogListPage() {
const { entries, isLoading, isError, error, filter, setProtocol, setQuery, clearFilters, refetch } =
useCatalogQuery()
const navigate = useNavigate()
const [selectedIdx, setSelectedIdx] = useState(-1)
const rowRefs = useRef<(HTMLTableRowElement | null)[]>([])
const entryList = entries ?? []
const hasFilters = !!(filter.protocol || filter.q)
// Keyboard navigation: Up/Down/Enter.
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (document.activeElement?.tagName === 'INPUT') return
if (e.key === 'ArrowDown') {
setSelectedIdx(i => Math.min(i + 1, entryList.length - 1))
e.preventDefault()
} else if (e.key === 'ArrowUp') {
setSelectedIdx(i => Math.max(i - 1, 0))
e.preventDefault()
} else if (e.key === 'Enter' && selectedIdx >= 0) {
const entry = entryList[selectedIdx]
if (entry) navigate(`/catalog/${entry.id}`)
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [entryList, selectedIdx, navigate])
return (
<div className="p-4 space-y-4">
<StatsBar onRegisterSuccess={refetch} />
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3">
<ProtocolFilter value={filter.protocol} onChange={setProtocol} />
<UnifiedSearchBox value={filter.q} onChange={setQuery} />
<RegisterAgentDialog onSuccess={refetch} />
</div>
{/* Error state */}
{isError && (
<Alert variant="destructive">
<AlertDescription>
{error?.message ?? 'Failed to load catalog.'}{' '}
<Button variant="link" size="sm" onClick={refetch}>
Retry
</Button>
</AlertDescription>
</Alert>
)}
{/* Skeleton loading */}
{isLoading && (
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-md" />
))}
</div>
)}
{/* Empty — no data at all */}
{!isLoading && !isError && entryList.length === 0 && !hasFilters && (
<div className="rounded-xl border bg-card p-8 text-center space-y-3">
<p className="text-muted-foreground">No catalog entries yet.</p>
<p className="text-xs text-muted-foreground font-mono bg-muted rounded px-3 py-2 inline-block">
curl -X POST http://localhost:8080/api/v1/catalog/import \<br />
{' '}-d '{'"'}url{'"'}:{'"'}https://your-agent.example.com/.well-known/agent-card.json{'"'}{'}'}'
</p>
</div>
)}
{/* Empty — filters exclude all results */}
{!isLoading && !isError && entryList.length === 0 && hasFilters && (
<Alert>
<AlertDescription className="flex items-center justify-between">
No entries match your filters.
<Button variant="outline" size="sm" onClick={clearFilters}>
Clear filters
</Button>
</AlertDescription>
</Alert>
)}
{/* Catalog table */}
{!isLoading && entryList.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Protocol</TableHead>
<TableHead>Name</TableHead>
<TableHead>Provider</TableHead>
<TableHead className="w-24">Skills</TableHead>
<TableHead className="w-28">Status</TableHead>
<TableHead className="w-28">Spec</TableHead>
<TableHead className="w-28">Last seen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(entryList as CatalogEntry[]).map((entry, idx) => (
<CatalogRow
key={entry.id}
entry={entry}
/>
))}
</TableBody>
</Table>
)}
</div>
)
}
- [ ] Step 18.2: Run lint
rtk make web-lint
- [ ] Step 18.3: Commit
rtk git add web/src/routes/catalog/CatalogListPage.tsx
rtk git commit -m "feat(web): CatalogListPage — unified table with protocol filter + search"
Task 19 — Frontend: RawCardTab component¶
Files: Create web/src/routes/catalog/components/RawCardTab.tsx
- [ ] Step 19.1: Create
web/src/routes/catalog/components/RawCardTab.tsx
import { useEffect, useState } from 'react'
import Prism from 'prismjs'
import 'prismjs/components/prism-json'
import { Copy, Download, AlertTriangle } from 'lucide-react'
import { Alert, AlertDescription } from '../../../components/ui/alert'
import { Button } from '../../../components/ui/button'
import { Skeleton } from '../../../components/ui/skeleton'
import { getRawCard } from '../../../api'
interface Props {
entryId: string
displayName: string
}
export function RawCardTab({ entryId, displayName }: Props) {
const [state, setState] = useState<{
status: 'idle' | 'loading' | 'success' | 'error'
data?: string
fetchedAt?: string
truncated?: boolean
error?: string
}>({ status: 'idle' })
const [copied, setCopied] = useState(false)
useEffect(() => {
setState({ status: 'loading' })
getRawCard(entryId)
.then(result => {
setState({
status: 'success',
data: result.data,
fetchedAt: result.fetchedAt,
truncated: result.truncated,
})
})
.catch(err => {
setState({ status: 'error', error: (err as Error).message })
})
}, [entryId])
useEffect(() => {
if (state.status === 'success') {
Prism.highlightAll()
}
}, [state])
function handleCopy() {
if (state.data) {
navigator.clipboard.writeText(state.data).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}
}
function handleDownload() {
if (!state.data) return
const blob = new Blob([state.data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${displayName.replace(/\s+/g, '-').toLowerCase()}.json`
a.click()
URL.revokeObjectURL(url)
}
if (state.status === 'loading' || state.status === 'idle') {
return <Skeleton className="h-64 w-full rounded-md" />
}
if (state.status === 'error') {
return (
<Alert variant="destructive">
<AlertDescription>{state.error ?? 'Failed to load raw card.'}</AlertDescription>
</Alert>
)
}
let pretty = state.data ?? ''
try {
pretty = JSON.stringify(JSON.parse(pretty), null, 2)
} catch {
// Not valid JSON — render as-is
}
return (
<div className="space-y-2">
{state.truncated && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Card was truncated at 256 KiB. Download may be incomplete.
</AlertDescription>
</Alert>
)}
<div className="flex items-center justify-between gap-2">
{state.fetchedAt && (
<p className="text-xs text-muted-foreground">
Fetched at: {new Date(state.fetchedAt).toLocaleString()}
</p>
)}
<div className="flex gap-2 ml-auto">
<Button variant="outline" size="sm" onClick={handleCopy}>
<Copy className="h-3.5 w-3.5 mr-1" />
{copied ? 'Copied!' : 'Copy'}
</Button>
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="h-3.5 w-3.5 mr-1" />
Download
</Button>
</div>
</div>
<pre className="rounded-md bg-muted p-4 overflow-auto text-sm max-h-[600px]">
<code className="language-json">{pretty}</code>
</pre>
</div>
)
}
- [ ] Step 19.2: Run lint
rtk make web-lint
- [ ] Step 19.3: Commit
rtk git add web/src/routes/catalog/components/RawCardTab.tsx
rtk git commit -m "feat(web): RawCardTab with prismjs JSON highlighting, copy, download"
Task 20 — Frontend: CatalogDetailPage (tabbed rewrite)¶
Files: Create web/src/routes/catalog/CatalogDetailPage.tsx
- [ ] Step 20.1: Create
web/src/routes/catalog/CatalogDetailPage.tsx
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs'
import { Button } from '../../components/ui/button'
import { Skeleton } from '../../components/ui/skeleton'
import { Alert, AlertDescription } from '../../components/ui/alert'
import StatusBadge from '../../components/StatusBadge'
import ProtocolBadge from '../../components/ProtocolBadge'
import { SpecVersionBadge } from './components/SpecVersionBadge'
import { RawCardTab } from './components/RawCardTab'
import { getEntry, patchLifecycle, postProbe } from '../../api'
import type { CatalogEntry } from '../../types'
export default function CatalogDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [entry, setEntry] = useState<CatalogEntry | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!id) return
setLoading(true)
getEntry(id)
.then(setEntry)
.catch(err => setError((err as Error).message))
.finally(() => setLoading(false))
}, [id])
async function handleProbeNow() {
if (!id) return
try {
await postProbe(id)
const updated = await getEntry(id)
setEntry(updated)
} catch {
// Non-fatal — badge will update on next auto-probe
}
}
async function handleDeprecate() {
if (!id) return
try {
await patchLifecycle(id, 'deprecated')
const updated = await getEntry(id)
setEntry(updated)
} catch {
// Non-fatal
}
}
async function handleUndeprecate() {
if (!id) return
try {
await patchLifecycle(id, 'registered')
const updated = await getEntry(id)
setEntry(updated)
} catch {
// Non-fatal
}
}
if (loading) return <div className="p-4 space-y-4"><Skeleton className="h-40 w-full" /></div>
if (error) return (
<div className="p-4">
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
)
if (!entry) return null
const isDeprecated = entry.status === 'deprecated'
return (
<div className="p-4 space-y-4">
{/* Back + header */}
<div className="flex items-start gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)} aria-label="Back">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-bold">{entry.display_name}</h1>
<ProtocolBadge protocol={entry.protocol} />
<StatusBadge status={entry.status} health={entry.health} />
<SpecVersionBadge version={entry.spec_version} />
</div>
{entry.description && (
<p className="text-muted-foreground">{entry.description}</p>
)}
</div>
<div className="flex gap-2">
{!isDeprecated && (
<Button variant="outline" size="sm" onClick={handleProbeNow}>
Probe Now
</Button>
)}
{!isDeprecated ? (
<Button variant="outline" size="sm" onClick={handleDeprecate}>
Deprecate
</Button>
) : (
<Button variant="outline" size="sm" onClick={handleUndeprecate}>
Undeprecate
</Button>
)}
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="raw-card">Raw Card</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4 pt-4">
{/* Fields grid */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
<div><span className="text-muted-foreground">Endpoint</span><br /><code className="text-xs">{entry.endpoint}</code></div>
<div><span className="text-muted-foreground">Source</span><br />{entry.source}</div>
<div><span className="text-muted-foreground">Version</span><br />{entry.version || '—'}</div>
{entry.provider && (
<div>
<span className="text-muted-foreground">Provider</span><br />
{entry.provider.organization}
{entry.provider.team && ` / ${entry.provider.team}`}
</div>
)}
</div>
{/* Categories */}
{entry.categories && entry.categories.length > 0 && (
<div>
<p className="text-sm font-medium mb-1">Categories</p>
<div className="flex flex-wrap gap-1">
{entry.categories.map(c => (
<span key={c} className="text-xs rounded-full bg-muted px-2 py-0.5">{c}</span>
))}
</div>
</div>
)}
{/* Capabilities */}
{entry.capabilities && entry.capabilities.length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Capabilities ({entry.capabilities.length})</p>
<div className="space-y-1">
{entry.capabilities.map((cap, i) => (
<div key={i} className="rounded-md border px-3 py-2 text-sm">
<span className="font-mono font-medium">{cap.name}</span>
{cap.description && (
<p className="text-xs text-muted-foreground mt-0.5">{cap.description}</p>
)}
</div>
))}
</div>
</div>
)}
{/* Health info */}
{entry.health && (
<div>
<p className="text-sm font-medium mb-1">Health</p>
<div className="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
<div>Latency: {entry.health.latencyMs}ms</div>
<div>Failures: {entry.health.consecutiveFailures}</div>
{entry.health.lastError && (
<div className="col-span-2 text-destructive">Last error: {entry.health.lastError}</div>
)}
</div>
</div>
)}
</TabsContent>
<TabsContent value="raw-card" className="pt-4">
{id && <RawCardTab entryId={id} displayName={entry.display_name} />}
</TabsContent>
</Tabs>
</div>
)
}
- [ ] Step 20.2: Run lint
rtk make web-lint
- [ ] Step 20.3: Commit
rtk git add web/src/routes/catalog/CatalogDetailPage.tsx
rtk git commit -m "feat(web): CatalogDetailPage — tabbed layout with Overview + Raw Card"
Task 21 — Wire new routes in App.tsx, remove old components¶
Files: Modify web/src/App.tsx; delete web/src/components/CatalogList.tsx, web/src/components/EntryDetail.tsx (and their test files)
- [ ] Step 21.1: Update
web/src/App.tsx
Replace the existing CatalogList/EntryDetail imports and routes:
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext'
import { ThemeProvider } from './contexts/ThemeContext'
import Layout from './components/Layout'
import ProtectedRoute from './components/ProtectedRoute'
import CatalogListPage from './routes/catalog/CatalogListPage'
import CatalogDetailPage from './routes/catalog/CatalogDetailPage'
import LoginPage from './pages/LoginPage'
import SettingsPage from './pages/SettingsPage'
export default function App() {
return (
<BrowserRouter>
<ThemeProvider>
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><Layout><CatalogListPage /></Layout></ProtectedRoute>} />
<Route path="/catalog/:id" element={<ProtectedRoute><Layout><CatalogDetailPage /></Layout></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><Layout><SettingsPage /></Layout></ProtectedRoute>} />
</Routes>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
)
}
- [ ] Step 21.2: Delete old component files
rm web/src/components/CatalogList.tsx
rm web/src/components/EntryDetail.tsx
rm web/src/components/CatalogList.test.tsx
rm web/src/components/EntryDetail.test.tsx
- [ ] Step 21.3: Run lint and build
rtk make web-lint && rtk make web-build
Expected: clean build with no references to deleted files.
- [ ] Step 21.4: Commit
rtk git add -A
rtk git commit -m "feat(web): rewire routes to CatalogListPage + CatalogDetailPage, remove old components"
Task 22 — Frontend unit tests¶
Files: Create test files for new components + hook
Why this order¶
Tests written after implementation for the new route components (TDD was used for the hook in Task 14).
- [ ] Step 22.1: Create
web/src/routes/catalog/components/SpecVersionBadge.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { SpecVersionBadge } from './SpecVersionBadge'
describe('SpecVersionBadge', () => {
it('renders spec version text', () => {
render(<SpecVersionBadge version="0.7" />)
expect(screen.getByText('0.7')).toBeInTheDocument()
})
it('renders nothing for empty version', () => {
const { container } = render(<SpecVersionBadge />)
expect(container.firstChild).toBeNull()
})
it('renders nothing for undefined version', () => {
const { container } = render(<SpecVersionBadge version={undefined} />)
expect(container.firstChild).toBeNull()
})
})
- [ ] Step 22.2: Create
web/src/routes/catalog/components/SearchHighlight.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { SearchHighlight } from './SearchHighlight'
describe('SearchHighlight', () => {
it('renders snippet', () => {
render(<SearchHighlight snippet="translate between en and de" />)
expect(screen.getByText(/translate between en and de/)).toBeInTheDocument()
})
it('renders nothing when no snippet', () => {
const { container } = render(<SearchHighlight />)
expect(container.firstChild).toBeNull()
})
})
- [ ] Step 22.3: Create
web/src/routes/catalog/CatalogListPage.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import React from 'react'
import CatalogListPage from './CatalogListPage'
import * as api from '../../api'
const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(
QueryClientProvider,
{ client: new QueryClient({ defaultOptions: { queries: { retry: false } } }) },
React.createElement(MemoryRouter, null, children)
)
describe('CatalogListPage', () => {
beforeEach(() => {
vi.spyOn(api, 'listCatalog').mockResolvedValue([])
vi.spyOn(api, 'getStats').mockResolvedValue({ total: 0, by_status: {}, by_source: {} })
})
it('shows skeleton during loading', () => {
render(<CatalogListPage />, { wrapper })
expect(document.querySelector('.animate-pulse, [data-skeleton]')).toBeTruthy()
})
it('shows empty state when no entries', async () => {
render(<CatalogListPage />, { wrapper })
// Wait for query to resolve.
await screen.findByText(/No catalog entries yet/i)
})
it('renders table when entries present', async () => {
vi.spyOn(api, 'listCatalog').mockResolvedValue([
{
id: 'e1', display_name: 'My Agent', description: '', protocol: 'a2a',
endpoint: 'http://agent.test', version: '1', status: 'active', source: 'push',
agent_type_id: 'at1', validity: { last_seen: new Date().toISOString() },
health: { state: 'active', lastProbedAt: null, lastSuccessAt: null, latencyMs: 0, consecutiveFailures: 0, lastError: '' },
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
},
])
render(<CatalogListPage />, { wrapper })
await screen.findByText('My Agent')
})
})
- [ ] Step 22.4: Run all frontend tests and check coverage
rtk make web-test-coverage
Expected: all tests pass, coverage ≥ 80%.
- [ ] Step 22.5: Commit
rtk git add web/src/routes/
rtk git commit -m "test(web): unit tests for new catalog components + CatalogListPage"
Task 23 — E2E Playwright tests¶
Files: Modify e2e/tests/catalog.spec.ts
- [ ] Step 23.1: Add unified-view E2E scenarios to
e2e/tests/catalog.spec.ts
Append the following test blocks:
import { test, expect } from '@playwright/test'
import { loginViaAPI, authHeader } from './helpers'
test.describe('Unified Catalog View', () => {
test.beforeEach(async ({ page }) => {
await loginViaAPI(page)
})
test('shows all entries by default (All protocol filter)', async ({ page }) => {
await page.goto('/')
// Protocol filter should show "All" selected by default.
await expect(page.getByRole('group', { name: 'Filter by protocol' })).toBeVisible()
// Table should be visible or empty state shown.
const table = page.locator('table')
const emptyState = page.getByText(/No catalog entries yet/i)
await expect(table.or(emptyState)).toBeVisible({ timeout: 10_000 })
})
test('search input focuses on "/" keypress', async ({ page }) => {
await page.goto('/')
await page.keyboard.press('/')
const input = page.getByRole('textbox', { name: 'Search catalog' })
await expect(input).toBeFocused()
})
test('protocol filter updates URL param', async ({ page }) => {
await page.goto('/')
await page.getByRole('radio', { name: 'A2A' }).or(page.getByText('A2A')).first().click()
await expect(page).toHaveURL(/protocol=a2a/)
})
test('URL filter persists on reload', async ({ page }) => {
await page.goto('/?protocol=mcp')
await page.reload()
await expect(page).toHaveURL(/protocol=mcp/)
})
test('Raw Card tab visible on detail page', async ({ page, request }) => {
// Seed an entry via API.
const headers = await authHeader(page)
const res = await request.post('/api/v1/catalog', {
data: { display_name: 'E2E Test Agent', protocol: 'a2a', endpoint: 'http://e2e-test.local' },
headers,
})
expect(res.ok()).toBeTruthy()
const entry = await res.json()
await page.goto(`/catalog/${entry.id}`)
await expect(page.getByRole('tab', { name: 'Raw Card' })).toBeVisible()
await page.getByRole('tab', { name: 'Raw Card' }).click()
// Card store may return 404 for manually created entries without a card — that's acceptable.
await expect(
page.getByText('no raw card stored').or(page.locator('pre code.language-json'))
).toBeVisible({ timeout: 5_000 })
})
})
- [ ] Step 23.2: Run E2E tests (requires built binary + frontend)
rtk make e2e-test
Expected: all E2E tests pass including new unified-view suite.
- [ ] Step 23.3: Commit
rtk git add e2e/tests/catalog.spec.ts
rtk git commit -m "test(e2e): unified catalog view E2E — protocol filter, URL persistence, raw card tab"
Task 24 — Documentation updates¶
Files: Modify docs/api.md, docs/architecture.md
- [ ] Step 24.1: Update
docs/api.md— new sort param + card endpoint changes
Add to the GET /api/v1/catalog endpoint section:
| `sort` | string | `lastSuccessAt_desc` | Sort order. Values: `lastSuccessAt_desc`, `displayName_asc`, `createdAt_desc`. Unknown values return `400`. |
Update the search (q param) description:
| `q` | string | — | Full-text search across `display_name`, `description`, `capabilities.name`, `capabilities.description`, `categories`, `provider.organization`. |
Update GET /api/v1/catalog/{id}/card documentation:
- Response headers: `Content-Type` (from card store), `X-Raw-Card-Fetched-At` (ISO 8601), `ETag` (weak, for conditional GET)
- Request header: `If-None-Match` — if ETag matches, returns `304 Not Modified`
- Returns `404` with `{"error": "no raw card stored"}` if entry exists but no card is in the card store (e.g., manually created entries)
- [ ] Step 24.2: Update
docs/architecture.md— CardStorePlugin + Frontend layers
Add to the Plugin Types section:
| `cardstore` | `CardStorePlugin` | Persists verbatim raw card bytes keyed by `agent_type_id`. Owns the `raw_cards` table. Enforces 256 KiB cap. |
Add note to Frontend section:
Data fetching migrated from plain `fetch`+`useState` to `@tanstack/react-query`. Catalog list page uses `useCatalogQuery` hook for URL-synced state. Detail page uses tabbed layout (Overview + Raw Card).
- [ ] Step 24.3: Commit
rtk git add docs/api.md docs/architecture.md
rtk git commit -m "docs: update API docs for sort param + card endpoint; architecture for CardStorePlugin"
Task 25 — Final validation¶
- [ ] Step 25.1: Run full Go test suite
rtk make test
Expected: all tests pass.
- [ ] Step 25.2: Run architecture validation
rtk make arch-test
Expected: 100% compliance. If plugins/cardstore violates layer rules, update arch-go.yml to allow plugins/cardstore to import internal/kernel and internal/db (consistent with other plugins).
- [ ] Step 25.3: Run frontend tests with coverage
rtk make web-test-coverage
Expected: all tests pass, functions coverage ≥ 80%.
- [ ] Step 25.4: Build frontend and Go binary
rtk make web-build && rtk make build
Expected: both succeed.
- [ ] Step 25.5: Run E2E (optional but recommended)
rtk make e2e-test
- [ ] Step 25.6: Final commit if any cleanup needed
rtk git add -A
rtk git commit -m "chore: final cleanup for feature/2.2-unified-catalog-view"
Known Traps & Gotchas¶
- SQLite DROP COLUMN requires 3.35+. The migration uses
IF EXISTSand silently ignores errors — the column becomes a dead column in old SQLite. This is safe (Go model ignores it). DISTINCT+ search JOINs. The expanded search joinscapabilitieswhich can produce duplicates.Distinct("catalog_entries.*")prevents that.NULLS LASTsyntax. Both SQLite 3.30+ and Postgres support this. Older SQLite needs a workaround (IS NULLfirst). If CI runs old SQLite, replace withCASE WHEN health_last_success_at IS NULL THEN 1 ELSE 0 END ASC, health_last_success_at DESC.- Prism.js in jsdom.
Prism.highlightAll()is a no-op in jsdom (no DOM rendering). Do not try to assert on highlighted output in Vitest tests. getRawCardreturns raw bytes, not JSON. The function readsres.text()notres.json(). Do not change this — the card bytes may not be valid JSON in all future protocols.- Card store nil guard. Every caller of
h.parsers.CardStore()must nil-check. The card store plugin may not be registered in test environments. RawBytesfield onAgentTypeisgorm:"-". It is never persisted. Sources must set it; ingestion path reads and clears it.- Old
CatalogList.test.tsxandEntryDetail.test.tsxmust be deleted. They import the deleted components and will causemake web-testto fail. - arch-go
plugins/cardstorerule. After creating the plugin, runmake arch-testand add a rule if needed:plugins/cardstoremay depend oninternal/kernel,internal/db,internal/model.
Execution Choice¶
The plan is written and ready. Choose how to proceed:
Option A — Subagent-driven (recommended) Run each task as a subagent with the task description + file contents, letting the agent implement one task at a time and commit. Slower but each commit is verified.
Option B — Inline execution
Ask GitHub Copilot to implement this plan task-by-task in the current session, following each - [ ] step in order.
Option C — Manual Follow the plan yourself, checking off each step. All code is complete above — paste and adjust as needed.