Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ jobs:
# ── Services (start, probe, stop) ──
- name: just registry-local
run: |
# Pre-build the binary so the 120s startup timer is not eaten by compilation
# (cold-cache CI runs re-compile pap-registry after Cargo.lock changes)
cargo build -p pap-registry --features ssr
just registry-local &
pid=$!
for i in $(seq 1 120); do
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions apps/registry/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,36 @@ async fn main() -> anyhow::Result<()> {
}
}
info!("Seeded registry with {} standard agents", persisted.len());

// Seed TOML catalog agents when PAP_CATALOG_PATH is configured.
if let Ok(catalog_env) = std::env::var("PAP_CATALOG_PATH") {
let catalog_path = std::path::PathBuf::from(&catalog_env);
if catalog_path.exists() {
let catalog_defs = pap_agents::load_catalog(&catalog_path);
let mut catalog_seeded = 0usize;
for def in &catalog_defs {
match def.to_signed_advertisement() {
Ok(ad) => {
let hash = ad.hash();
if store.insert_agent(&hash, &ad).await.is_ok() {
persisted.push(ad);
catalog_seeded += 1;
}
}
Err(e) => tracing::warn!("Startup catalog sign error: {e}"),
}
}
info!(
"Seeded {} TOML catalog agents from {}",
catalog_seeded, catalog_env
);
} else {
tracing::warn!(
"PAP_CATALOG_PATH={catalog_env} does not exist; skipping TOML catalog seeding"
);
}
}

persisted
} else {
vec![]
Expand Down
98 changes: 98 additions & 0 deletions apps/registry/src/ui/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -492,3 +492,101 @@ pub async fn get_peer_sync_log(did: String) -> Result<Vec<SyncEvent>, ServerFnEr
let json = serde_json::to_string(&events).map_err(|e| ServerFnError::new(e.to_string()))?;
serde_json::from_str(&json).map_err(|e| ServerFnError::new(e.to_string()))
}

/// Result of a catalog install operation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CatalogInstallResult {
pub installed: usize,
pub skipped: usize,
pub errors: usize,
pub catalog_path: String,
}

/// Install PAP catalog agents (from the shared pap-agents package) into this registry.
///
/// Each catalog agent receives a deterministic Ed25519 operator keypair derived from
/// its name via SHA-256, so reinstalls produce the same DIDs and content hashes —
/// making the operation fully idempotent.
///
/// Catalog path is resolved in order:
/// 1. `$PAP_CATALOG_PATH` environment variable
/// 2. `crates/pap-agents/catalog` relative to the current working directory
/// (works when running `cargo run -p pap-registry` from the workspace root)
#[server]
pub async fn install_catalog_agents() -> Result<CatalogInstallResult, ServerFnError> {
use crate::routes::admin::extract_bearer;
use crate::state::AppState;
use axum::http::HeaderMap;
use std::path::PathBuf;

let headers: HeaderMap = leptos_axum::extract()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let state = use_context::<AppState>().ok_or_else(|| ServerFnError::new("no state"))?;
if !state.is_authorized(extract_bearer(&headers)) {
return Err(ServerFnError::new("unauthorized"));
}

// Resolve catalog path
let catalog_path: PathBuf = std::env::var("PAP_CATALOG_PATH")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("crates/pap-agents/catalog"));

if !catalog_path.exists() {
return Err(ServerFnError::new(format!(
"Catalog directory not found: {}. Set $PAP_CATALOG_PATH or run from the workspace root.",
catalog_path.display()
)));
}

let catalog_path_str = catalog_path.display().to_string();
let entries = pap_agents::load_catalog(&catalog_path);

let mut installed = 0usize;
let mut skipped = 0usize;
let mut errors = 0usize;

for entry in entries {
let ad = match entry.to_signed_advertisement() {
Ok(ad) => ad,
Err(e) => {
tracing::warn!("Failed to build catalog agent '{}': {e}", entry.name);
errors += 1;
continue;
}
};

let hash = ad.hash();

match state.store.insert_agent(&hash, &ad).await {
Ok(()) => {
let mut registry = state.registry.lock().unwrap();
let _ = registry.register_local(ad); // duplicate silently ignored
installed += 1;
}
Err(e) => {
let msg = e.to_string();
if msg.contains("UNIQUE")
|| msg.contains("duplicate")
|| msg.contains("already exists")
{
skipped += 1;
} else {
tracing::warn!("Failed to insert catalog agent '{}': {e}", entry.name);
errors += 1;
}
}
}
}

tracing::info!(
"Catalog install complete: {installed} installed, {skipped} skipped, {errors} errors"
);

Ok(CatalogInstallResult {
installed,
skipped,
errors,
catalog_path: catalog_path_str,
})
}
1 change: 1 addition & 0 deletions apps/registry/src/ui/components/nav.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub fn Sidebar() -> impl IntoView {
<nav class="nav-section" style="margin-top: var(--sp-md)">
<div class="nav-section-label">"Registry"</div>
{nav_item("/agents", "⬡", "Agents")}
{nav_item("/agents/design", "✦", "Design Agent")}
{nav_item("/peers", "◎", "Peers")}
</nav>
<nav class="nav-section" style="margin-top: var(--sp-md)">
Expand Down
70 changes: 46 additions & 24 deletions apps/registry/src/ui/pages/agent_designer.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
use leptos::prelude::*;
use leptos::task::spawn_local;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

use crate::ui::api::AgentAdvertisement;
// Phase 4: These will be used when implementing full async signing flow
#[allow(unused_imports)]
use crate::ui::api::{register_agent_json, sign_advertisement};

/// Form state for the agent designer
Expand Down Expand Up @@ -54,7 +53,7 @@ impl AgentFormState {
requires_disclosure: self.requires_disclosure.clone(),
returns: self.returns.clone(),
ttl_min: self.ttl_min,
signed_by: String::new(),
signed_by: self.provider_did.clone(),
signature: None,
}
}
Expand Down Expand Up @@ -187,7 +186,7 @@ pub fn AgentDesignerPage() -> impl IntoView {
}
}

/// Form component with all sections (Phases 2-5 integrated)
/// Form component with all sections
#[component]
fn DesignerForm(
form_state: RwSignal<AgentFormState>,
Expand All @@ -210,9 +209,7 @@ fn DesignerForm(
submit_status.set(Some("⚠️ Signing key required. Paste your Ed25519 private key (base64)".to_string()));
return;
}
// Phase 4: Prepare for signing and registration
// Full async handling requires using Action component for proper state management
submit_status.set(Some("📋 Ready to sign. Use Sign & Register button to proceed.".to_string()));
submit_status.set(Some("📋 Form valid. Use Sign & Register to publish.".to_string()));
} else {
form_state.set(state);
submit_status.set(None);
Expand All @@ -238,26 +235,51 @@ fn DesignerForm(
<button
class="btn btn-primary"
type="button"
disabled=move || {
let state = form_state.get();
state.errors.is_empty() && signing_key.get().trim().is_empty() || is_submitting.get()
}
disabled=move || signing_key.get().trim().is_empty() || is_submitting.get()
on:click=move |_| {
is_submitting.set(true);
let _state = form_state.get();
let _ad = _state.to_advertisement();
let _key = signing_key.get();

// Phase 4: TODO - Implement actual signing and registration
// This will:
// 1. Call sign_advertisement(json, key) server function
// 2. On success, call register_agent_json(signed_json)
// 3. Update status with hash on success or error message on failure
submit_status.set(Some("⏳ Signing & registering... (Phase 4 in progress)".to_string()));
is_submitting.set(false);
let mut state = form_state.get();
validation_attempted.set(true);
if !state.validate() {
form_state.set(state);
return;
}
form_state.set(state.clone());
let key = signing_key.get();
if key.trim().is_empty() {
submit_status.set(Some("⚠️ Signing key required.".to_string()));
return;
}
let ad = state.to_advertisement();
match serde_json::to_string(&ad) {
Ok(json) => {
is_submitting.set(true);
submit_status.set(Some("⏳ Signing & registering…".to_string()));
spawn_local(async move {
let result = match sign_advertisement(json, key).await {
Ok(signed_json) => match register_agent_json(signed_json).await {
Ok(hash) => Ok(hash),
Err(e) => Err(e.to_string()),
},
Err(e) => Err(e.to_string()),
};
match result {
Ok(hash) => {
submit_status.set(Some(format!("✓ Registered! Hash: {hash}")));
}
Err(e) => {
submit_status.set(Some(format!("✗ {e}")));
}
}
is_submitting.set(false);
});
}
Err(e) => {
submit_status.set(Some(format!("✗ Serialization error: {e}")));
}
}
}
>
{move || if is_submitting.get() { "⏳ Signing..." } else { "🔐 Sign & Register" }}
{move || if is_submitting.get() { "⏳ Signing" } else { "🔐 Sign & Register" }}
</button>
<a href="/agents" class="btn btn-secondary">
"Cancel"
Expand Down
9 changes: 6 additions & 3 deletions apps/registry/src/ui/pages/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ pub fn AgentsPage() -> impl IntoView {
<h1 class="page-title">"Agents"</h1>
<p class="page-subtitle">"All registered agent advertisements in this node's registry."</p>
</div>
<button class="btn btn-primary" on:click=move |_| show_register.set(true)>
"+ Register Agent"
</button>
<div style="display:flex; gap:8px;">
<a href="/agents/design" class="btn btn-secondary">"+ Design Agent"</a>
<button class="btn btn-primary" on:click=move |_| show_register.set(true)>
"+ Register Agent"
</button>
</div>
</div>
</div>

Expand Down
79 changes: 78 additions & 1 deletion apps/registry/src/ui/pages/settings.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use leptos::prelude::*;
use leptos::task::spawn_local;

use crate::ui::api;

Expand Down Expand Up @@ -71,7 +72,7 @@ pub fn SettingsPage() -> impl IntoView {
</div>
</div>

<div class="card">
<div class="card" style="margin-bottom: var(--sp-xl)">
<div class="card-header">
<span class="card-title">"Deployment"</span>
</div>
Expand All @@ -87,6 +88,82 @@ pub fn SettingsPage() -> impl IntoView {
</ol>
</div>
</div>

<CatalogInstallCard />
</div>
}
}

#[component]
fn CatalogInstallCard() -> impl IntoView {
let installing = RwSignal::new(false);
let install_status: RwSignal<Option<Result<String, String>>> = RwSignal::new(None);

let on_install = move |_| {
installing.set(true);
install_status.set(None);
spawn_local(async move {
match api::install_catalog_agents().await {
Ok(result) => {
install_status.set(Some(Ok(format!(
"✓ Installed {} agents ({} already present{})",
result.installed,
result.skipped,
if result.errors > 0 {
format!(", {} errors", result.errors)
} else {
String::new()
}
))));
}
Err(e) => {
install_status.set(Some(Err(e.to_string())));
}
}
installing.set(false);
});
};

view! {
<div class="card">
<div class="card-header">
<span class="card-title">"PAP Catalog Agents"</span>
</div>
<div class="card-body">
<p style="font-size: 13px; color: var(--text-2); margin-bottom: var(--sp-md)">
"Install the shared PAP agent catalog (200+ agents covering search, travel, finance, science, and more) into this registry. "
"Each agent receives a deterministic operator keypair — reinstalling is safe and idempotent."
</p>
<div style="display: flex; align-items: center; gap: var(--sp-sm); margin-bottom: var(--sp-md); font-size: 12px; color: var(--text-3)">
<span style="font-family: var(--font-mono);">"Catalog path:"</span>
<code style="font-family: var(--font-mono); color: var(--purple); background: var(--purple-muted); padding: 2px 6px; border-radius: 4px">
"$PAP_CATALOG_PATH"
</code>
<span>"or"</span>
<code style="font-family: var(--font-mono); color: var(--text-2); background: var(--bg-2); padding: 2px 6px; border-radius: 4px">
"crates/pap-agents/catalog"
</code>
</div>

<div style="display: flex; align-items: center; gap: var(--sp-md); flex-wrap: wrap">
<button
class="btn btn-primary"
disabled=move || installing.get()
on:click=on_install
>
{move || if installing.get() { "⏳ Installing…" } else { "Install Catalog Agents" }}
</button>

{move || install_status.get().map(|result| match result {
Ok(msg) => view! {
<span style="font-size: 13px; color: var(--teal)">{msg}</span>
}.into_any(),
Err(e) => view! {
<span style="font-size: 13px; color: var(--red)">"✗ " {e}</span>
}.into_any(),
})}
</div>
</div>
</div>
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/pap-agents/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ url = "2"
uuid = { workspace = true }
chrono = { workspace = true }
ed25519-dalek = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }

# On-device inference (feature-gated so WASM builds can opt out)
Expand Down
Loading
Loading