diff --git a/backend/controlplane/src/lib.rs b/backend/controlplane/src/lib.rs index dda7495..40b974a 100644 --- a/backend/controlplane/src/lib.rs +++ b/backend/controlplane/src/lib.rs @@ -19,6 +19,7 @@ pub mod constants; pub mod error; pub mod gateway; pub mod governance; +pub mod marketplace; pub mod mcp; pub mod observability; pub mod registry; diff --git a/backend/controlplane/src/marketplace/mod.rs b/backend/controlplane/src/marketplace/mod.rs new file mode 100644 index 0000000..2c875cb --- /dev/null +++ b/backend/controlplane/src/marketplace/mod.rs @@ -0,0 +1,9 @@ +//! Agent Marketplace — a verified internal catalogue of reusable agent templates. + +pub mod model; +pub mod seed; +pub mod store; + +pub use model::{AgentTemplate, ComplianceBadge, MarketplaceAgent, NewListing, VerificationBadge}; +pub use seed::{sample_listings, seed}; +pub use store::Marketplace; diff --git a/backend/controlplane/src/marketplace/model.rs b/backend/controlplane/src/marketplace/model.rs new file mode 100644 index 0000000..070dd29 --- /dev/null +++ b/backend/controlplane/src/marketplace/model.rs @@ -0,0 +1,147 @@ +//! Agent marketplace domain model. +//! +//! The marketplace is a verified, internal catalogue of reusable agent +//! templates. Publishing puts an agent blueprint on the shelf; installing +//! stamps out a concrete agent into the [`registry`](crate::registry). + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::constants::{DataAccessLevel, RiskLevel}; +use crate::registry::NewAgent; + +/// Verification badge — has the listing been vetted by the platform team? +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum VerificationBadge { + /// Not yet reviewed. + Unverified, + /// Reviewed and verified by the platform team. + Verified, +} + +/// Compliance badge — where the listing sits in the compliance review process. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ComplianceBadge { + /// Compliance review pending. + Pending, + /// Passed compliance review. + Compliant, + /// Formally certified for regulated use. + Certified, +} + +/// A published marketplace listing wrapping a reusable [`AgentTemplate`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MarketplaceAgent { + /// Stable UUID. + pub id: String, + pub name: String, + pub description: String, + /// Functional category (e.g. `licensing`, `it-ops`, `customer-service`). + pub category: String, + /// Owning department. + pub department: String, + /// Average user rating (0.0–5.0). + pub rating: f64, + /// Number of times the listing has been installed. + pub install_count: u64, + /// Risk badge of the template. + pub risk_level: RiskLevel, + /// Verification status. + pub verification: VerificationBadge, + /// Compliance status. + pub compliance: ComplianceBadge, + /// The reusable blueprint. + pub template: AgentTemplate, + pub published_at: i64, +} + +/// Input used to publish a new listing. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NewListing { + pub name: String, + pub description: String, + pub category: String, + pub department: String, + pub template: AgentTemplate, +} + +impl MarketplaceAgent { + /// Materialise a new listing: unverified, pending compliance, zero installs. + pub fn from_new(input: NewListing) -> Self { + MarketplaceAgent { + id: Uuid::new_v4().to_string(), + name: input.name, + description: input.description, + category: input.category, + department: input.department, + rating: 0.0, + install_count: 0, + risk_level: input.template.risk_level, + verification: VerificationBadge::Unverified, + compliance: ComplianceBadge::Pending, + template: input.template, + published_at: Utc::now().timestamp(), + } + } + + /// Whether this listing is safe to surface as a trusted, install-ready + /// option: verified and at least compliance-reviewed. + pub fn is_trusted(&self) -> bool { + self.verification == VerificationBadge::Verified + && matches!(self.compliance, ComplianceBadge::Compliant | ComplianceBadge::Certified) + } +} + +/// The reusable blueprint behind a marketplace listing: everything needed to +/// instantiate a concrete agent. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentTemplate { + /// Framework the instantiated agent runs on. + pub framework: String, + /// Default model provider. + pub model_provider: String, + /// Default model name. + pub model_name: String, + /// Tools the template requires. + #[serde(default)] + pub required_tools: Vec, + /// MCP servers the template requires. + #[serde(default)] + pub required_mcp_servers: Vec, + /// Model providers the template is approved against. + #[serde(default)] + pub required_model_providers: Vec, + /// Data sensitivity the instantiated agent will access. + pub data_access_level: DataAccessLevel, + /// Risk level of the instantiated agent. + pub risk_level: RiskLevel, +} + +impl AgentTemplate { + /// Produce a [`NewAgent`] from this template for the given owner/department. + pub fn to_new_agent( + &self, + name: impl Into, + description: impl Into, + owner: impl Into, + department: impl Into, + ) -> NewAgent { + NewAgent { + name: name.into(), + description: description.into(), + owner: owner.into(), + department: department.into(), + framework: self.framework.clone(), + model_provider: self.model_provider.clone(), + model_name: self.model_name.clone(), + tools_allowed: self.required_tools.clone(), + mcp_servers_allowed: self.required_mcp_servers.clone(), + data_access_level: self.data_access_level, + risk_level: self.risk_level, + } + } +} diff --git a/backend/controlplane/src/marketplace/seed.rs b/backend/controlplane/src/marketplace/seed.rs new file mode 100644 index 0000000..0096ba0 --- /dev/null +++ b/backend/controlplane/src/marketplace/seed.rs @@ -0,0 +1,71 @@ +//! Sample marketplace listings for demos and local development. + +use crate::constants::{DataAccessLevel, RiskLevel}; +use crate::error::Result; + +use super::model::{AgentTemplate, ComplianceBadge, MarketplaceAgent, NewListing, VerificationBadge}; +use super::store::Marketplace; + +/// The built-in catalogue of example listings. +pub fn sample_listings() -> Vec { + vec![ + NewListing { + name: "Permit Intake Assistant".into(), + description: "Triages building-permit applications and routes them.".into(), + category: "licensing".into(), + department: "Licensing".into(), + template: AgentTemplate { + framework: "openclaw".into(), + model_provider: "anthropic".into(), + model_name: "claude-opus-4-8".into(), + required_tools: vec!["search".into(), "document.read".into()], + required_mcp_servers: vec!["records-mcp".into()], + required_model_providers: vec!["anthropic".into()], + data_access_level: DataAccessLevel::Internal, + risk_level: RiskLevel::Medium, + }, + }, + NewListing { + name: "Service Desk Responder".into(), + description: "Answers common citizen service-desk questions.".into(), + category: "customer-service".into(), + department: "Customer Happiness".into(), + template: AgentTemplate { + framework: "openclaw".into(), + model_provider: "anthropic".into(), + model_name: "claude-sonnet-4-6".into(), + required_tools: vec!["search".into()], + required_mcp_servers: vec![], + required_model_providers: vec!["anthropic".into()], + data_access_level: DataAccessLevel::Public, + risk_level: RiskLevel::Low, + }, + }, + ] +} + +/// Publish the sample listings, marking them verified and compliant so the +/// "trusted" catalogue has content out of the box. +pub fn seed(mkt: &Marketplace) -> Result> { + let mut out = Vec::new(); + for input in sample_listings() { + let listing = mkt.publish(input)?; + mkt.set_verification(&listing.id, VerificationBadge::Verified)?; + let listing = mkt.set_compliance(&listing.id, ComplianceBadge::Compliant)?; + out.push(listing); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seed_produces_trusted_listings() { + let mkt = Marketplace::in_memory().unwrap(); + let listings = seed(&mkt).unwrap(); + assert_eq!(listings.len(), 2); + assert!(listings.iter().all(|l| l.is_trusted())); + } +} diff --git a/backend/controlplane/src/marketplace/store.rs b/backend/controlplane/src/marketplace/store.rs new file mode 100644 index 0000000..bbe043a --- /dev/null +++ b/backend/controlplane/src/marketplace/store.rs @@ -0,0 +1,269 @@ +//! SQLite-backed marketplace store. +//! +//! Holds published listings. Installing a listing is delegated to the caller's +//! [`AgentRegistry`](crate::registry::AgentRegistry) so the marketplace stays +//! decoupled from agent persistence. + +use std::sync::Mutex; + +use rusqlite::{params, Connection}; + +use crate::constants::RiskLevel; +use crate::error::{ControlPlaneError, Result}; +use crate::registry::{AgentRecord, AgentRegistry}; + +use super::model::{ComplianceBadge, MarketplaceAgent, NewListing, VerificationBadge}; + +/// Store of published marketplace listings. +pub struct Marketplace { + pub(crate) conn: Mutex, +} + +const SCHEMA: &str = " + CREATE TABLE IF NOT EXISTS marketplace_listings ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + category TEXT NOT NULL, + department TEXT NOT NULL, + rating REAL NOT NULL, + install_count INTEGER NOT NULL, + risk_level TEXT NOT NULL, + verification TEXT NOT NULL, + compliance TEXT NOT NULL, + template TEXT NOT NULL, + published_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_mkt_category ON marketplace_listings(category); +"; + +const COLUMNS: &str = "id, name, description, category, department, rating, install_count, \ + risk_level, verification, compliance, template, published_at"; + +fn row_to_listing(row: &rusqlite::Row) -> rusqlite::Result { + Ok(MarketplaceAgent { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + category: row.get(3)?, + department: row.get(4)?, + rating: row.get(5)?, + install_count: row.get::<_, i64>(6)? as u64, + risk_level: de(&row.get::<_, String>(7)?, 7)?, + verification: de(&row.get::<_, String>(8)?, 8)?, + compliance: de(&row.get::<_, String>(9)?, 9)?, + template: de(&row.get::<_, String>(10)?, 10)?, + published_at: row.get(11)?, + }) +} + +fn de(s: &str, col: usize) -> rusqlite::Result { + serde_json::from_str(s) + .map_err(|e| rusqlite::Error::FromSqlConversionFailure(col, rusqlite::types::Type::Text, Box::new(e))) +} + +impl Marketplace { + /// Open (creating if needed) a marketplace backed by a file. + pub fn open(path: &str) -> Result { + let conn = Connection::open(path)?; + conn.execute_batch(&format!("PRAGMA journal_mode=WAL;{SCHEMA}"))?; + Ok(Self { conn: Mutex::new(conn) }) + } + + /// Open an ephemeral in-memory marketplace (used by tests). + pub fn in_memory() -> Result { + let conn = Connection::open_in_memory()?; + conn.execute_batch(SCHEMA)?; + Ok(Self { conn: Mutex::new(conn) }) + } + + /// Publish a new listing to the marketplace. + pub fn publish(&self, input: NewListing) -> Result { + if input.name.trim().is_empty() { + return Err(ControlPlaneError::validation("listing name must not be empty")); + } + let listing = MarketplaceAgent::from_new(input); + self.upsert(&listing)?; + cp_info!("marketplace.publish", listing_id = %listing.id, name = %listing.name); + Ok(listing) + } + + /// List all listings, most-installed first. + pub fn list(&self) -> Result> { + self.query(&format!("SELECT {COLUMNS} FROM marketplace_listings ORDER BY install_count DESC"), []) + } + + /// List listings in a given category. + pub fn list_by_category(&self, category: &str) -> Result> { + self.query( + &format!("SELECT {COLUMNS} FROM marketplace_listings WHERE category = ?1 ORDER BY install_count DESC"), + params![category], + ) + } + + /// List listings at a given risk level. + pub fn list_by_risk(&self, risk: RiskLevel) -> Result> { + self.query( + &format!("SELECT {COLUMNS} FROM marketplace_listings WHERE risk_level = ?1 ORDER BY install_count DESC"), + params![serde_json::to_string(&risk)?], + ) + } + + /// Run a SELECT returning listings (internal helper). + fn query(&self, sql: &str, params: P) -> Result> { + let conn = self.conn.lock().expect("marketplace mutex poisoned"); + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map(params, row_to_listing)?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) + } + + /// Install a listing into the given agent registry, returning the new + /// agent record and incrementing the listing's install count. + pub fn install( + &self, + listing_id: &str, + registry: &AgentRegistry, + name: &str, + owner: &str, + department: &str, + ) -> Result { + let mut listing = self.get(listing_id)?; + let new_agent = listing + .template + .to_new_agent(name, listing.description.clone(), owner, department); + let agent = registry.create(new_agent)?; + listing.install_count += 1; + self.upsert(&listing)?; + cp_info!("marketplace.install", listing_id = %listing_id, agent_id = %agent.id); + Ok(agent) + } + + /// Set the verification badge on a listing (platform-team action). + pub fn set_verification(&self, listing_id: &str, badge: VerificationBadge) -> Result { + let mut listing = self.get(listing_id)?; + listing.verification = badge; + self.upsert(&listing)?; + cp_info!("marketplace.verify", listing_id = %listing_id, badge = ?badge); + Ok(listing) + } + + /// Set the compliance badge on a listing (compliance-team action). + pub fn set_compliance(&self, listing_id: &str, badge: ComplianceBadge) -> Result { + let mut listing = self.get(listing_id)?; + listing.compliance = badge; + self.upsert(&listing)?; + cp_info!("marketplace.compliance", listing_id = %listing_id, badge = ?badge); + Ok(listing) + } + + /// Fetch a listing by id. + pub fn get(&self, id: &str) -> Result { + let conn = self.conn.lock().expect("marketplace mutex poisoned"); + conn.query_row( + &format!("SELECT {COLUMNS} FROM marketplace_listings WHERE id = ?1"), + params![id], + row_to_listing, + ) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => ControlPlaneError::not_found("listing", id), + other => other.into(), + }) + } + + /// Persist a listing (insert or replace). Internal helper. + pub(crate) fn upsert(&self, l: &MarketplaceAgent) -> Result<()> { + let conn = self.conn.lock().expect("marketplace mutex poisoned"); + conn.execute( + "INSERT OR REPLACE INTO marketplace_listings ( + id, name, description, category, department, rating, install_count, + risk_level, verification, compliance, template, published_at + ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)", + params![ + l.id, + l.name, + l.description, + l.category, + l.department, + l.rating, + l.install_count as i64, + serde_json::to_string(&l.risk_level)?, + serde_json::to_string(&l.verification)?, + serde_json::to_string(&l.compliance)?, + serde_json::to_string(&l.template)?, + l.published_at, + ], + )?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::DataAccessLevel; + use crate::marketplace::model::AgentTemplate; + + pub(super) fn listing() -> NewListing { + NewListing { + name: "Permit Intake".into(), + description: "Triages permit applications".into(), + category: "licensing".into(), + department: "Licensing".into(), + template: AgentTemplate { + framework: "openclaw".into(), + model_provider: "anthropic".into(), + model_name: "claude-opus-4-8".into(), + required_tools: vec!["search".into()], + required_mcp_servers: vec!["records-mcp".into()], + required_model_providers: vec!["anthropic".into()], + data_access_level: DataAccessLevel::Internal, + risk_level: RiskLevel::Medium, + }, + } + } + + #[test] + fn publish_then_get() { + let mkt = Marketplace::in_memory().unwrap(); + let l = mkt.publish(listing()).unwrap(); + assert_eq!(mkt.get(&l.id).unwrap().name, "Permit Intake"); + assert!(!l.is_trusted()); // unverified + pending on publish + } + + #[test] + fn badges_make_listing_trusted() { + let mkt = Marketplace::in_memory().unwrap(); + let l = mkt.publish(listing()).unwrap(); + mkt.set_verification(&l.id, VerificationBadge::Verified).unwrap(); + let l = mkt.set_compliance(&l.id, ComplianceBadge::Certified).unwrap(); + assert!(l.is_trusted()); + } + + #[test] + fn filters_by_category_and_risk() { + let mkt = Marketplace::in_memory().unwrap(); + mkt.publish(listing()).unwrap(); + assert_eq!(mkt.list().unwrap().len(), 1); + assert_eq!(mkt.list_by_category("licensing").unwrap().len(), 1); + assert_eq!(mkt.list_by_category("nope").unwrap().len(), 0); + assert_eq!(mkt.list_by_risk(RiskLevel::Medium).unwrap().len(), 1); + assert_eq!(mkt.list_by_risk(RiskLevel::Critical).unwrap().len(), 0); + } + + #[test] + fn install_creates_agent_and_counts() { + use crate::registry::AgentRegistry; + let mkt = Marketplace::in_memory().unwrap(); + let reg = AgentRegistry::in_memory().unwrap(); + let l = mkt.publish(listing()).unwrap(); + let agent = mkt.install(&l.id, ®, "Permit Bot A", "team-a", "Licensing").unwrap(); + assert_eq!(agent.name, "Permit Bot A"); + assert_eq!(agent.tools_allowed, vec!["search".to_string()]); + assert_eq!(reg.count().unwrap(), 1); + assert_eq!(mkt.get(&l.id).unwrap().install_count, 1); + } +} diff --git a/docs/marketplace.md b/docs/marketplace.md new file mode 100644 index 0000000..e7e9fb6 --- /dev/null +++ b/docs/marketplace.md @@ -0,0 +1,67 @@ +# Agent Marketplace + +The marketplace (`clawforge_controlplane::marketplace`) is a **verified, internal +catalogue of reusable agent templates**. Teams publish proven agent blueprints; +other teams install them as governed, registry-tracked agents — without +re-engineering or re-reviewing from scratch. + +## Listings + +A `MarketplaceAgent` listing carries identity (`name`, `description`, +`category`, `department`), social proof (`rating`, `install_count`), a +`risk_level`, two badges, and an `AgentTemplate` blueprint. + +### Badges + +| Badge | Values | Awarded by | +|-------|--------|-----------| +| `verification` | `unverified` → `verified` | platform team | +| `compliance` | `pending` → `compliant` → `certified` | compliance team | + +`is_trusted()` is true only when a listing is **verified** *and* at least +**compliant** — the bar for surfacing it as install-ready. + +## Templates + +An `AgentTemplate` is everything needed to instantiate a concrete agent: +`framework`, default `model_provider`/`model_name`, `required_tools`, +`required_mcp_servers`, `required_model_providers`, `data_access_level`, and +`risk_level`. `to_new_agent(...)` turns it into a registry `NewAgent`. + +## API + +```rust +use clawforge_controlplane::marketplace::{Marketplace, NewListing, AgentTemplate, VerificationBadge, ComplianceBadge}; +use clawforge_controlplane::registry::AgentRegistry; +use clawforge_controlplane::constants::RiskLevel; + +let mkt = Marketplace::open("clawforge-controlplane.db")?; +let reg = AgentRegistry::open("clawforge-controlplane.db")?; + +// Publish (starts unverified + pending) +let listing = mkt.publish(NewListing { /* … template … */ })?; + +// Vet it +mkt.set_verification(&listing.id, VerificationBadge::Verified)?; +mkt.set_compliance(&listing.id, ComplianceBadge::Compliant)?; + +// Browse +let all = mkt.list()?; // most-installed first +let licensing = mkt.list_by_category("licensing")?; +let low_risk = mkt.list_by_risk(RiskLevel::Low)?; + +// Install into the registry (creates a Draft agent, bumps install_count) +let agent = mkt.install(&listing.id, ®, "Permit Bot A", "team-a", "Licensing")?; +``` + +## How it ties together + +`install` is the bridge to the **Agent Registry**: it stamps out a `Draft` +agent from the template, which then flows through the normal governance and +security lifecycle. The marketplace itself stays decoupled from agent +persistence — it is handed the registry to install into. + +## Seed data + +`marketplace::seed::seed(&mkt)` publishes two verified, compliant sample +listings (permit intake, service-desk responder) for demos.