profit 8c6e7831e9 Add Phase 10-12 implementation: multi-tenant, marketplace, observability
Major additions:
- marketplace/: Agent template registry with FTS5 search, ratings, versioning
- observability/: Prometheus metrics, distributed tracing, structured logging
- ledger/migrations/: Database migration scripts for multi-tenant support
- tests/governance/: 15 new test files for phases 6-12 (295 total tests)
- bin/validate-phases: Full 12-phase validation script

New features:
- Multi-tenant support with tenant isolation and quota enforcement
- Agent marketplace with semantic versioning and search
- Observability with metrics, tracing, and log correlation
- Tier-1 agent bootstrap scripts

Updated components:
- ledger/api.py: Extended API for tenants, marketplace, observability
- ledger/schema.sql: Added tenant, project, marketplace tables
- testing/framework.ts: Enhanced test framework
- checkpoint/checkpoint.py: Improved checkpoint management

Archived:
- External integrations (Slack/GitHub/PagerDuty) moved to .archive/
- Old checkpoint files cleaned up

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 18:39:47 -05:00

721 lines
25 KiB
Python

#!/usr/bin/env python3
"""
Agent Marketplace API
=====================
REST API for the agent template marketplace.
Endpoints:
- /marketplace/templates - Browse and search templates
- /marketplace/templates/{id} - Get template details
- /marketplace/publish - Publish new template
- /marketplace/install - Install template to project
- /marketplace/ratings - Rate and review templates
"""
import hashlib
import json
import os
import re
import secrets
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, List
from contextlib import contextmanager
from fastapi import APIRouter, HTTPException, Query, Depends
from pydantic import BaseModel, Field
# =============================================================================
# Configuration
# =============================================================================
DB_PATH = Path("/opt/agent-governance/ledger/governance.db")
TEMPLATE_STORAGE = Path("/opt/agent-governance/marketplace/templates")
TEMPLATE_STORAGE.mkdir(parents=True, exist_ok=True)
# =============================================================================
# Database
# =============================================================================
@contextmanager
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
def row_to_dict(row: sqlite3.Row) -> dict:
return dict(row)
def rows_to_list(rows: list) -> list:
return [row_to_dict(row) for row in rows]
# =============================================================================
# Models
# =============================================================================
class TenantContext(BaseModel):
"""Simplified tenant context for marketplace"""
tenant_id: str = "default"
project_id: str = "default"
user_id: Optional[str] = None
role: str = "viewer"
class TemplateCreate(BaseModel):
name: str
slug: str
description: Optional[str] = None
category: str = "general"
tier_required: int = 0
is_public: bool = False
license: str = "MIT"
repository_url: Optional[str] = None
documentation_url: Optional[str] = None
tags: List[str] = []
capabilities: List[str] = []
requirements: Optional[dict] = None
class TemplateVersionCreate(BaseModel):
version: str # Semantic version: 1.0.0
release_notes: Optional[str] = None
config_schema: Optional[dict] = None
template_content: dict # Full template definition
is_stable: bool = True
class TemplateInstall(BaseModel):
template_id: str
version: Optional[str] = None # None = latest
config_overrides: Optional[dict] = None
class TemplateRating(BaseModel):
rating: int = Field(ge=1, le=5)
review_title: Optional[str] = None
review_text: Optional[str] = None
class TemplateUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
category: Optional[str] = None
is_public: Optional[bool] = None
tags: Optional[List[str]] = None
capabilities: Optional[List[str]] = None
# =============================================================================
# Helper Functions
# =============================================================================
def parse_version(version: str) -> int:
"""Convert semver to sortable integer: 1.2.3 -> 10203"""
parts = version.split(".")
if len(parts) != 3:
return 0
try:
major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
return major * 10000 + minor * 100 + patch
except ValueError:
return 0
def validate_slug(slug: str) -> bool:
"""Validate slug format: lowercase, alphanumeric, hyphens only"""
return bool(re.match(r'^[a-z0-9][a-z0-9-]*[a-z0-9]$', slug)) or len(slug) == 1
def update_template_stats(conn, template_id: str, stat: str, increment: int = 1):
"""Update template statistics"""
conn.execute(f"""
INSERT INTO template_stats (template_id, {stat}, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(template_id) DO UPDATE SET
{stat} = {stat} + ?,
updated_at = datetime('now')
""", (template_id, increment, increment))
def calculate_avg_rating(conn, template_id: str):
"""Recalculate average rating for template"""
cursor = conn.execute("""
SELECT AVG(rating) as avg, COUNT(*) as count
FROM template_ratings WHERE template_id = ?
""", (template_id,))
row = cursor.fetchone()
conn.execute("""
UPDATE template_stats
SET avg_rating = ?, rating_count = ?, updated_at = datetime('now')
WHERE template_id = ?
""", (row["avg"] or 0, row["count"], template_id))
# =============================================================================
# Router
# =============================================================================
router = APIRouter(prefix="/marketplace", tags=["Marketplace"])
# =============================================================================
# Browse & Search Endpoints
# =============================================================================
@router.get("/templates")
async def list_templates(
category: Optional[str] = None,
tag: Optional[str] = None,
search: Optional[str] = None,
author: Optional[str] = None,
verified_only: bool = False,
sort: str = Query("popular", pattern="^(popular|recent|rating|downloads)$"),
limit: int = Query(20, le=100),
offset: int = Query(0, ge=0)
):
"""List and search marketplace templates"""
with get_db() as conn:
# Use FTS for search, regular query otherwise
if search:
cursor = conn.execute("""
SELECT t.*, ts.total_downloads, ts.avg_rating, ts.rating_count,
ts.trending_score
FROM agent_templates t
LEFT JOIN template_stats ts ON t.template_id = ts.template_id
WHERE t.template_id IN (
SELECT template_id FROM template_search WHERE template_search MATCH ?
)
AND t.is_public = 1
ORDER BY ts.trending_score DESC
LIMIT ? OFFSET ?
""", (search, limit, offset))
else:
query = """
SELECT t.*, ts.total_downloads, ts.avg_rating, ts.rating_count,
ts.trending_score
FROM agent_templates t
LEFT JOIN template_stats ts ON t.template_id = ts.template_id
WHERE t.is_public = 1
"""
params = []
if category:
query += " AND t.category = ?"
params.append(category)
if tag:
query += " AND t.tags LIKE ?"
params.append(f'%"{tag}"%')
if author:
query += " AND t.author_id = ?"
params.append(author)
if verified_only:
query += " AND t.is_verified = 1"
# Sorting
sort_map = {
"popular": "ts.trending_score DESC",
"recent": "t.created_at DESC",
"rating": "ts.avg_rating DESC",
"downloads": "ts.total_downloads DESC"
}
query += f" ORDER BY {sort_map.get(sort, 'ts.trending_score DESC')}"
query += " LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor = conn.execute(query, params)
templates = rows_to_list(cursor.fetchall())
# Parse JSON fields
for t in templates:
t["tags"] = json.loads(t["tags"]) if t.get("tags") else []
t["capabilities"] = json.loads(t["capabilities"]) if t.get("capabilities") else []
return {"templates": templates, "count": len(templates)}
@router.get("/templates/{template_id}")
async def get_template(template_id: str):
"""Get template details with versions"""
with get_db() as conn:
# Get template
cursor = conn.execute("""
SELECT t.*, ts.total_downloads, ts.avg_rating, ts.rating_count,
ts.active_installs, ts.trending_score
FROM agent_templates t
LEFT JOIN template_stats ts ON t.template_id = ts.template_id
WHERE t.template_id = ?
""", (template_id,))
template = cursor.fetchone()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
template = row_to_dict(template)
template["tags"] = json.loads(template["tags"]) if template.get("tags") else []
template["capabilities"] = json.loads(template["capabilities"]) if template.get("capabilities") else []
template["requirements"] = json.loads(template["requirements"]) if template.get("requirements") else {}
# Get versions
cursor = conn.execute("""
SELECT version_id, version, version_number, release_notes,
is_stable, is_deprecated, published_at
FROM template_versions
WHERE template_id = ?
ORDER BY version_number DESC
""", (template_id,))
template["versions"] = rows_to_list(cursor.fetchall())
# Get recent ratings
cursor = conn.execute("""
SELECT rating, review_title, review_text, created_at
FROM template_ratings
WHERE template_id = ?
ORDER BY created_at DESC
LIMIT 5
""", (template_id,))
template["recent_reviews"] = rows_to_list(cursor.fetchall())
return template
@router.get("/templates/{template_id}/versions/{version}")
async def get_template_version(template_id: str, version: str):
"""Get specific version of a template with full content"""
with get_db() as conn:
cursor = conn.execute("""
SELECT * FROM template_versions
WHERE template_id = ? AND version = ?
""", (template_id, version))
ver = cursor.fetchone()
if not ver:
raise HTTPException(status_code=404, detail="Version not found")
ver = row_to_dict(ver)
ver["config_schema"] = json.loads(ver["config_schema"]) if ver.get("config_schema") else {}
ver["template_content"] = json.loads(ver["template_content"]) if ver.get("template_content") else {}
return ver
@router.get("/categories")
async def list_categories():
"""List marketplace categories"""
with get_db() as conn:
cursor = conn.execute("""
SELECT c.*, COUNT(t.template_id) as template_count
FROM marketplace_categories c
LEFT JOIN agent_templates t ON c.category_id = t.category AND t.is_public = 1
WHERE c.is_active = 1
GROUP BY c.category_id
ORDER BY c.sort_order
""")
categories = rows_to_list(cursor.fetchall())
return {"categories": categories}
# =============================================================================
# Publishing Endpoints
# =============================================================================
@router.post("/templates")
async def create_template(
template: TemplateCreate,
ctx: TenantContext = None
):
"""Create a new template (publish to marketplace)"""
if ctx is None:
ctx = TenantContext()
if not validate_slug(template.slug):
raise HTTPException(status_code=400, detail="Invalid slug format")
template_id = f"tpl-{secrets.token_hex(8)}"
with get_db() as conn:
try:
conn.execute("""
INSERT INTO agent_templates
(template_id, name, slug, description, author_id, author_name,
category, tier_required, is_public, license, repository_url,
documentation_url, tags, capabilities, requirements)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
template_id, template.name, template.slug, template.description,
ctx.tenant_id, ctx.user_id or ctx.tenant_id, template.category,
template.tier_required, 1 if template.is_public else 0, template.license,
template.repository_url, template.documentation_url,
json.dumps(template.tags), json.dumps(template.capabilities),
json.dumps(template.requirements) if template.requirements else None
))
# Initialize stats
conn.execute("""
INSERT INTO template_stats (template_id) VALUES (?)
""", (template_id,))
conn.commit()
except sqlite3.IntegrityError as e:
raise HTTPException(status_code=409, detail=f"Template slug already exists: {e}")
return {"status": "created", "template_id": template_id}
@router.post("/templates/{template_id}/versions")
async def create_version(
template_id: str,
version: TemplateVersionCreate,
ctx: TenantContext = None
):
"""Publish a new version of a template"""
if ctx is None:
ctx = TenantContext()
# Verify ownership
with get_db() as conn:
cursor = conn.execute("""
SELECT author_id FROM agent_templates WHERE template_id = ?
""", (template_id,))
template = cursor.fetchone()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
if template["author_id"] != ctx.tenant_id:
raise HTTPException(status_code=403, detail="Not template owner")
version_id = f"ver-{secrets.token_hex(8)}"
version_number = parse_version(version.version)
content_json = json.dumps(version.template_content)
file_hash = hashlib.sha256(content_json.encode()).hexdigest()
try:
conn.execute("""
INSERT INTO template_versions
(version_id, template_id, version, version_number, release_notes,
config_schema, template_content, file_hash, is_stable)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
version_id, template_id, version.version, version_number,
version.release_notes,
json.dumps(version.config_schema) if version.config_schema else None,
content_json, file_hash, 1 if version.is_stable else 0
))
# Update template timestamp
conn.execute("""
UPDATE agent_templates SET updated_at = datetime('now')
WHERE template_id = ?
""", (template_id,))
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail="Version already exists")
return {"status": "created", "version_id": version_id, "version": version.version}
@router.patch("/templates/{template_id}")
async def update_template(
template_id: str,
update: TemplateUpdate,
ctx: TenantContext = None
):
"""Update template metadata"""
if ctx is None:
ctx = TenantContext()
with get_db() as conn:
# Verify ownership
cursor = conn.execute("""
SELECT author_id FROM agent_templates WHERE template_id = ?
""", (template_id,))
template = cursor.fetchone()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
if template["author_id"] != ctx.tenant_id:
raise HTTPException(status_code=403, detail="Not template owner")
updates = []
params = []
if update.name is not None:
updates.append("name = ?")
params.append(update.name)
if update.description is not None:
updates.append("description = ?")
params.append(update.description)
if update.category is not None:
updates.append("category = ?")
params.append(update.category)
if update.is_public is not None:
updates.append("is_public = ?")
params.append(1 if update.is_public else 0)
if update.tags is not None:
updates.append("tags = ?")
params.append(json.dumps(update.tags))
if update.capabilities is not None:
updates.append("capabilities = ?")
params.append(json.dumps(update.capabilities))
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
updates.append("updated_at = datetime('now')")
params.append(template_id)
conn.execute(
f"UPDATE agent_templates SET {', '.join(updates)} WHERE template_id = ?",
params
)
conn.commit()
return {"status": "updated", "template_id": template_id}
# =============================================================================
# Installation Endpoints
# =============================================================================
@router.post("/install")
async def install_template(
install: TemplateInstall,
ctx: TenantContext = None
):
"""Install a template to current project"""
if ctx is None:
ctx = TenantContext()
with get_db() as conn:
# Get template
cursor = conn.execute("""
SELECT template_id, is_public, author_id FROM agent_templates
WHERE template_id = ?
""", (install.template_id,))
template = cursor.fetchone()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
# Check access (public or owner)
if not template["is_public"] and template["author_id"] != ctx.tenant_id:
raise HTTPException(status_code=403, detail="Template not accessible")
# Get version (latest stable if not specified)
if install.version:
cursor = conn.execute("""
SELECT version_id FROM template_versions
WHERE template_id = ? AND version = ?
""", (install.template_id, install.version))
else:
cursor = conn.execute("""
SELECT version_id FROM template_versions
WHERE template_id = ? AND is_stable = 1
ORDER BY version_number DESC LIMIT 1
""", (install.template_id,))
version = cursor.fetchone()
if not version:
raise HTTPException(status_code=404, detail="Version not found")
# Install
conn.execute("""
INSERT INTO template_installations
(template_id, version_id, tenant_id, project_id, config_overrides)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(template_id, tenant_id, project_id) DO UPDATE SET
version_id = ?, config_overrides = ?, updated_at = datetime('now')
""", (
install.template_id, version["version_id"], ctx.tenant_id, ctx.project_id,
json.dumps(install.config_overrides) if install.config_overrides else None,
version["version_id"],
json.dumps(install.config_overrides) if install.config_overrides else None
))
# Update stats
update_template_stats(conn, install.template_id, "total_installs")
update_template_stats(conn, install.template_id, "active_installs")
# Record download
conn.execute("""
INSERT INTO template_downloads (template_id, version_id, tenant_id)
VALUES (?, ?, ?)
""", (install.template_id, version["version_id"], ctx.tenant_id))
update_template_stats(conn, install.template_id, "total_downloads")
conn.commit()
return {"status": "installed", "template_id": install.template_id}
@router.get("/installed")
async def list_installed(
ctx: TenantContext = None
):
"""List templates installed in current project"""
if ctx is None:
ctx = TenantContext()
with get_db() as conn:
cursor = conn.execute("""
SELECT i.*, t.name, t.description, t.category, v.version
FROM template_installations i
JOIN agent_templates t ON i.template_id = t.template_id
LEFT JOIN template_versions v ON i.version_id = v.version_id
WHERE i.tenant_id = ? AND i.project_id = ?
ORDER BY i.installed_at DESC
""", (ctx.tenant_id, ctx.project_id))
installed = rows_to_list(cursor.fetchall())
return {"installed": installed, "count": len(installed)}
@router.delete("/installed/{template_id}")
async def uninstall_template(
template_id: str,
ctx: TenantContext = None
):
"""Uninstall a template from current project"""
if ctx is None:
ctx = TenantContext()
with get_db() as conn:
cursor = conn.execute("""
DELETE FROM template_installations
WHERE template_id = ? AND tenant_id = ? AND project_id = ?
""", (template_id, ctx.tenant_id, ctx.project_id))
conn.commit()
if cursor.rowcount == 0:
raise HTTPException(status_code=404, detail="Installation not found")
# Update active installs
conn.execute("""
UPDATE template_stats
SET active_installs = active_installs - 1, updated_at = datetime('now')
WHERE template_id = ? AND active_installs > 0
""", (template_id,))
conn.commit()
return {"status": "uninstalled", "template_id": template_id}
# =============================================================================
# Rating Endpoints
# =============================================================================
@router.post("/templates/{template_id}/rate")
async def rate_template(
template_id: str,
rating: TemplateRating,
ctx: TenantContext = None
):
"""Rate and review a template"""
if ctx is None:
ctx = TenantContext()
with get_db() as conn:
# Check if template exists
cursor = conn.execute("""
SELECT 1 FROM agent_templates WHERE template_id = ?
""", (template_id,))
if not cursor.fetchone():
raise HTTPException(status_code=404, detail="Template not found")
# Check if user has installed (verified reviewer)
cursor = conn.execute("""
SELECT 1 FROM template_installations
WHERE template_id = ? AND tenant_id = ?
""", (template_id, ctx.tenant_id))
is_verified = 1 if cursor.fetchone() else 0
conn.execute("""
INSERT INTO template_ratings
(template_id, tenant_id, user_id, rating, review_title, review_text, is_verified_user)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(template_id, tenant_id) DO UPDATE SET
rating = ?, review_title = ?, review_text = ?, updated_at = datetime('now')
""", (
template_id, ctx.tenant_id, ctx.user_id, rating.rating,
rating.review_title, rating.review_text, is_verified,
rating.rating, rating.review_title, rating.review_text
))
# Recalculate average
calculate_avg_rating(conn, template_id)
conn.commit()
return {"status": "rated", "template_id": template_id, "rating": rating.rating}
@router.get("/templates/{template_id}/ratings")
async def get_ratings(
template_id: str,
limit: int = Query(20, le=100),
offset: int = Query(0, ge=0)
):
"""Get ratings for a template"""
with get_db() as conn:
cursor = conn.execute("""
SELECT rating, review_title, review_text, is_verified_user, created_at
FROM template_ratings
WHERE template_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""", (template_id, limit, offset))
ratings = rows_to_list(cursor.fetchall())
# Get summary
cursor = conn.execute("""
SELECT avg_rating, rating_count FROM template_stats
WHERE template_id = ?
""", (template_id,))
stats = cursor.fetchone()
return {
"ratings": ratings,
"count": len(ratings),
"avg_rating": stats["avg_rating"] if stats else 0,
"total_ratings": stats["rating_count"] if stats else 0
}
# =============================================================================
# My Templates (Author View)
# =============================================================================
@router.get("/my-templates")
async def list_my_templates(
ctx: TenantContext = None
):
"""List templates created by current tenant"""
if ctx is None:
ctx = TenantContext()
with get_db() as conn:
cursor = conn.execute("""
SELECT t.*, ts.total_downloads, ts.avg_rating, ts.rating_count,
ts.active_installs
FROM agent_templates t
LEFT JOIN template_stats ts ON t.template_id = ts.template_id
WHERE t.author_id = ?
ORDER BY t.updated_at DESC
""", (ctx.tenant_id,))
templates = rows_to_list(cursor.fetchall())
for t in templates:
t["tags"] = json.loads(t["tags"]) if t.get("tags") else []
return {"templates": templates, "count": len(templates)}