diff --git a/TESTING.md b/TESTING.md index f8bd50bd..42595c0e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -178,6 +178,102 @@ docker compose down # stop services, keep data --- +## Testing Projects + +Projects group channels and provide shared context to agents. The feature spans +`sprout-core`, `sprout-db`, `sprout-sdk`, `sprout-relay`, and `sprout-mcp`. + +### Automated Tests + +```bash +# SDK builder tests (no infrastructure needed) +cargo test -p sprout-sdk -- project + +# Relay ingest classification + d-tag extraction tests +cargo test -p sprout-relay -- project +cargo test -p sprout-relay -- extract_d_tag_uuid + +# DB CRUD tests (require running Postgres) +cargo test -p sprout-db -- project --ignored +``` + +**SDK tests** (9 tests) verify event construction: +- `create_project_happy_path` / `_all_fields` / `_no_repos` — kind 50001, d-tag, name tag, optional fields +- `update_project_partial` / `_clear_repos` / `_no_fields_rejected` — kind 50002, partial updates, sentinel tag for repo clearing +- `delete_project_happy_path` — kind 50003, d-tag only +- `create_channel_with_project` / `_without_project` — `["project", uuid]` tag presence + +**Relay tests** (6 tests) verify ingest pipeline classification: +- `project_kinds_require_channels_write` — 50001-50003 require `ChannelsWrite` scope +- `project_kinds_are_global_only` — projects are not channel-scoped +- `project_kinds_not_channel_scoped` — no h-tag required +- `extract_d_tag_uuid_valid` / `_missing` / `_invalid` — d-tag UUID extraction + +**DB tests** (6 tests, `#[ignore]` — require Postgres): +- `create_and_get_project` — round-trip create + fetch +- `list_projects_by_creator` — filtering by pubkey +- `update_project_partial` — partial field updates +- `update_project_repos` — set and clear JSONB repo_urls +- `soft_delete_project` — soft delete, verify not found +- `archive_and_unarchive_project` — archive round-trip + +### Manual Testing with MCP Tools + +With the relay running, use the MCP tools via `sprout-mcp-server`: + +```bash +# Create a project +# MCP tool: create_project +# name: "my-project" +# environment: "local" +# description: "Test project" +# repo_urls: ["https://github.com/example/repo"] + +# List all projects +# MCP tool: list_projects + +# Get a specific project +# MCP tool: get_project +# project_id: "" + +# Update a project +# MCP tool: update_project +# project_id: "" +# name: "renamed-project" +# prompt: "You are a helpful coding assistant" + +# Delete a project +# MCP tool: delete_project +# project_id: "" +``` + +### Channel-Project Association + +Create a channel linked to a project: + +```bash +# MCP tool: create_channel +# name: "project-channel" +# channel_type: "stream" +# visibility: "open" +# project_id: "" + +# Verify via REST API: +curl -s -H "X-Pubkey: $USER_PK" \ + http://localhost:3000/api/projects//channels +``` + +### Validation Checks + +- **Environment validation**: only `"local"` and `"blox"` are accepted. Other values + are rejected at ingest time (kind 50001) and update time (kind 50002). +- **Empty update guard**: `update_project` with no fields returns an error from the SDK. +- **Repo clearing**: to clear repos, pass an empty `repo_urls: []`. The SDK emits a + sentinel `["repo", ""]` tag so the relay knows to set the field to `[]` rather than + ignoring it. + +--- + ## Troubleshooting | Symptom | Cause | Fix | diff --git a/VISION.md b/VISION.md index 81ef6b71..e369a685 100644 --- a/VISION.md +++ b/VISION.md @@ -128,6 +128,14 @@ Beyond chat: channels are workspaces. --- +## Projects + +A lightweight grouping that gives channels shared context. A project references repos (by git remote URL), carries shared instructions for agents (the project prompt), and specifies the compute environment (local dev laptop or remote Blox instance). Channels optionally belong to a project. When they do, agents automatically receive the project's context — eliminating manual setup per channel. + +Projects are relay-stored Nostr events (kinds 50001-50003), accessible from any client or agent runtime via REST API and MCP tools. They support all three compute topologies: local agent + local code, local/remote agent + Blox code, and fully remote (mobile-initiated). + +--- + ## Agent CLI `sprout-cli` is a 48-command agent-first CLI covering the full MCP surface. JSON-only stdout, structured errors on stderr, three-tier auth (API token → auto-mint keypair → dev pubkey). Agents can script the entire platform without a GUI. diff --git a/crates/sprout-acp/src/main.rs b/crates/sprout-acp/src/main.rs index aed16cd0..1efdfabb 100644 --- a/crates/sprout-acp/src/main.rs +++ b/crates/sprout-acp/src/main.rs @@ -770,6 +770,7 @@ async fn tokio_main() -> Result<()> { context_message_limit: config.context_message_limit, max_turns_per_session: config.max_turns_per_session, permission_mode: config.permission_mode, + project_cache: tokio::sync::RwLock::new(HashMap::new()), }); // ── Step 6: Heartbeat timer ─────────────────────────────────────────────── diff --git a/crates/sprout-acp/src/pool.rs b/crates/sprout-acp/src/pool.rs index da8e2511..2ef50c2c 100644 --- a/crates/sprout-acp/src/pool.rs +++ b/crates/sprout-acp/src/pool.rs @@ -37,7 +37,7 @@ use crate::queue::{ ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo, PromptProfile, PromptProfileLookup, }; -use crate::relay::{ChannelInfo, RestClient}; +use crate::relay::{ChannelInfo, ProjectInfo, RestClient}; // ── FlushBatch Clone note ───────────────────────────────────────────────────── // FlushBatch and BatchEvent derive Clone (added in queue.rs) so we can store @@ -188,6 +188,8 @@ pub struct PromptContext { pub max_turns_per_session: u32, /// Permission mode to apply after session creation. `Default` = skip. pub permission_mode: PermissionMode, + /// Lazily-populated project metadata cache. Keyed by project UUID. + pub project_cache: tokio::sync::RwLock>, } // ── AgentPool impl ──────────────────────────────────────────────────────────── @@ -812,6 +814,7 @@ pub async fn run_prompt_task( Some(ci) => Some(PromptChannelInfo { name: ci.name.clone(), channel_type: ci.channel_type.clone(), + project_id: ci.project_id, }), None => fetch_channel_info(b.channel_id, &ctx.rest_client).await, }; @@ -822,6 +825,16 @@ pub async fn run_prompt_task( None }; + let project_info = if let Some(ref ci) = channel_info { + if let Some(pid) = ci.project_id { + fetch_or_cache_project(pid, &ctx).await + } else { + None + } + } else { + None + }; + let profile_lookup = fetch_prompt_profile_lookup(b, conversation_context.as_ref(), &ctx.rest_client).await; @@ -831,6 +844,7 @@ pub async fn run_prompt_task( channel_info.as_ref(), conversation_context.as_ref(), profile_lookup.as_ref(), + project_info.as_ref(), ) } else { // Should not happen — batch is None only for heartbeats which have prompt_text. @@ -1140,7 +1154,15 @@ async fn fetch_channel_info(channel_id: Uuid, rest: &RestClient) -> Option().ok()); + Some(PromptChannelInfo { + name, + channel_type, + project_id, + }) } Ok(Err(e)) => { tracing::debug!( @@ -1161,6 +1183,60 @@ async fn fetch_channel_info(channel_id: Uuid, rest: &RestClient) -> Option Option { + // Check cache first (read lock). + { + let cache = ctx.project_cache.read().await; + if let Some(info) = cache.get(&project_id) { + return Some(info.clone()); + } + } + + // Fetch from relay. + let path = format!("/api/projects/{}", project_id); + let json = match timeout(CONTEXT_FETCH_TIMEOUT, ctx.rest_client.get_json(&path)).await { + Ok(Ok(json)) => json, + Ok(Err(e)) => { + tracing::debug!(project_id = %project_id, "project info fetch failed: {e}"); + return None; + } + Err(_) => { + tracing::debug!(project_id = %project_id, "project info fetch timed out"); + return None; + } + }; + + let info = ProjectInfo { + name: json.get("name").and_then(|v| v.as_str())?.to_string(), + prompt: json + .get("prompt") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + description: json + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + repo_urls: json + .get("repo_urls") + .cloned() + .unwrap_or(serde_json::json!([])), + environment: json + .get("environment") + .and_then(|v| v.as_str()) + .unwrap_or("local") + .to_string(), + }; + + // Cache it (write lock). + { + let mut cache = ctx.project_cache.write().await; + cache.insert(project_id, info.clone()); + } + + Some(info) +} + /// Fetch conversation context (thread or DM) for a batch before prompting. /// /// Returns `None` if: diff --git a/crates/sprout-acp/src/queue.rs b/crates/sprout-acp/src/queue.rs index a4077fce..8b66e18e 100644 --- a/crates/sprout-acp/src/queue.rs +++ b/crates/sprout-acp/src/queue.rs @@ -677,6 +677,7 @@ pub struct ContextMessage { pub struct PromptChannelInfo { pub name: String, pub channel_type: String, + pub project_id: Option, } /// Minimal profile fields needed to label users in ACP prompts. @@ -835,6 +836,7 @@ fn format_context_hints( is_dm: bool, has_conversation_context: bool, triggering_event_id: Option<&str>, + project_info: Option<&crate::relay::ProjectInfo>, ) -> String { let channel_display = match channel_info { Some(ci) => format!("{} (#{channel_id})", ci.name), @@ -843,7 +845,7 @@ fn format_context_hints( // DM check comes first — a DM reply has both thread tags AND is_dm=true, // and the scope should be "dm" (not "thread") because the agent is in a DM. - if is_dm { + let mut result = if is_dm { let is_reply = thread_tags.root_event_id.is_some(); // DM replies use get_thread() because /messages excludes thread replies. // DM non-replies use get_channel_history() for recent conversation. @@ -904,7 +906,27 @@ fn format_context_hints( Channel: {channel_display}\n\ Hint: Use get_channel_history() for recent messages if needed." ) + }; + + // Append project context if available. + if let Some(project) = project_info { + result.push_str(&format!("\n\n[Project: {}]", project.name)); + if let Some(desc) = &project.description { + result.push_str(&format!("\nDescription: {}", desc)); + } + result.push_str(&format!("\nEnvironment: {}", project.environment)); + if let Some(repos) = project.repo_urls.as_array() { + let repo_list: Vec<&str> = repos.iter().filter_map(|r| r.as_str()).collect(); + if !repo_list.is_empty() { + result.push_str(&format!("\nRepos: {}", repo_list.join(", "))); + } + } + if let Some(prompt) = &project.prompt { + result.push_str(&format!("\n\n{}", prompt)); + } } + + result } /// Format a conversation context section (thread or DM). @@ -955,6 +977,7 @@ pub fn format_prompt( channel_info: Option<&PromptChannelInfo>, conversation_context: Option<&ConversationContext>, profile_lookup: Option<&PromptProfileLookup>, + project_info: Option<&crate::relay::ProjectInfo>, ) -> String { // Scope is always derived from the LAST event in the batch — that's the // one the agent is responding to. Thread/DM context is supplementary info @@ -992,6 +1015,7 @@ pub fn format_prompt( is_dm, conversation_context.is_some(), triggering_event_id.as_deref(), + project_info, )); // 3. Conversation context (thread or DM). @@ -1298,7 +1322,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); // Should contain [Context] section before the event. assert!(prompt.contains("[Context]")); @@ -1394,7 +1418,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!(prompt.contains("[Context]")); assert!(prompt.contains("[Sprout events — 3 events]")); @@ -1423,7 +1447,14 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, Some("You are a triage bot."), None, None, None); + let prompt = format_prompt( + &batch, + Some("You are a triage bot."), + None, + None, + None, + None, + ); assert!(prompt.starts_with("[System]\nYou are a triage bot.\n\n[Context]")); } @@ -1901,9 +1932,10 @@ mod tests { let ci = PromptChannelInfo { name: "engineering".into(), channel_type: "stream".into(), + project_id: None, }; - let prompt = format_prompt(&batch, None, Some(&ci), None, None); + let prompt = format_prompt(&batch, None, Some(&ci), None, None, None); assert!(prompt.contains("engineering (#")); assert!(prompt.contains("Scope: channel")); } @@ -1924,9 +1956,10 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + project_id: None, }; - let prompt = format_prompt(&batch, None, Some(&ci), None, None); + let prompt = format_prompt(&batch, None, Some(&ci), None, None, None); assert!(prompt.contains("Scope: dm")); } @@ -1952,7 +1985,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!(prompt.contains("Scope: thread")); assert!(prompt.contains("Thread root: root123")); } @@ -1995,7 +2028,7 @@ mod tests { truncated: true, }; - let prompt = format_prompt(&batch, None, None, Some(&ctx), None); + let prompt = format_prompt(&batch, None, None, Some(&ctx), None, None); assert!(prompt.contains("[Thread Context (2 of 5 messages, truncated)]")); assert!(prompt.contains("Let's refactor auth")); assert!(prompt.contains("Thread context included below")); @@ -2017,6 +2050,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + project_id: None, }; let ctx = ConversationContext::Dm { messages: vec![ContextMessage { @@ -2028,7 +2062,7 @@ mod tests { truncated: false, }; - let prompt = format_prompt(&batch, None, Some(&ci), Some(&ctx), None); + let prompt = format_prompt(&batch, None, Some(&ci), Some(&ctx), None, None); assert!(prompt.contains("Scope: dm")); assert!(prompt.contains("[Conversation Context (1 of 1 messages)]")); assert!(prompt.contains("Can you deploy?")); @@ -2080,7 +2114,7 @@ mod tests { ), ]); - let prompt = format_prompt(&batch, None, None, Some(&ctx), Some(&profiles)); + let prompt = format_prompt(&batch, None, None, Some(&ctx), Some(&profiles), None); assert!(prompt.contains("From: Wes (npub:")); assert!(prompt.contains( @@ -2163,6 +2197,7 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + project_id: None, }; // Thread context fetched (as the fetch path does for DM replies). let ctx = ConversationContext::Thread { @@ -2175,7 +2210,7 @@ mod tests { truncated: false, }; - let prompt = format_prompt(&batch, None, Some(&ci), Some(&ctx), None); + let prompt = format_prompt(&batch, None, Some(&ci), Some(&ctx), None, None); // Scope should be "dm", not "thread". assert!( prompt.contains("Scope: dm"), @@ -2211,10 +2246,11 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + project_id: None, }; // No context fetched — hints only. - let prompt = format_prompt(&batch, None, Some(&ci), None, None); + let prompt = format_prompt(&batch, None, Some(&ci), None, None, None); assert!(prompt.contains("Scope: dm")); assert!( prompt.contains("get_channel_history()"), @@ -2241,7 +2277,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!( prompt.contains(&format!("Event ID: {event_id}")), "prompt should contain the event ID" @@ -2264,7 +2300,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!( prompt.contains(&format!("From: {npub} (hex: {hex})")), "prompt should contain both npub and hex" @@ -2286,7 +2322,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!( prompt.contains("Tags:"), "tags should always be included, even for stream messages" @@ -2610,7 +2646,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!( prompt.contains(&format!("parent_event_id=\"{event_id}\"")), "channel thread reply should include reply instruction with triggering event ID" @@ -2642,9 +2678,10 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + project_id: None, }; - let prompt = format_prompt(&batch, None, Some(&ci), None, None); + let prompt = format_prompt(&batch, None, Some(&ci), None, None, None); assert!( prompt.contains(&format!("parent_event_id=\"{event_id}\"")), "DM thread reply should include reply instruction" @@ -2665,7 +2702,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!( !prompt.contains("parent_event_id"), "top-level message should NOT include reply instruction" @@ -2688,9 +2725,10 @@ mod tests { let ci = PromptChannelInfo { name: "DM".into(), channel_type: "dm".into(), + project_id: None, }; - let prompt = format_prompt(&batch, None, Some(&ci), None, None); + let prompt = format_prompt(&batch, None, Some(&ci), None, None, None); assert!( !prompt.contains("parent_event_id"), "DM non-reply should NOT include reply instruction" @@ -2720,7 +2758,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); // The instruction should use the triggering event's own ID — not root or parent. assert!( prompt.contains(&format!("parent_event_id=\"{event_id}\"")), @@ -2763,7 +2801,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!( prompt.contains(&format!("parent_event_id=\"{threaded_id}\"")), "batched prompt should use last (threaded) event's ID" @@ -2796,7 +2834,7 @@ mod tests { cancelled_events: vec![], }; - let prompt = format_prompt(&batch, None, None, None, None); + let prompt = format_prompt(&batch, None, None, None, None, None); assert!( !prompt.contains("parent_event_id"), "batched prompt where last event is top-level should NOT include reply instruction" diff --git a/crates/sprout-acp/src/relay.rs b/crates/sprout-acp/src/relay.rs index fc742ffc..f872b892 100644 --- a/crates/sprout-acp/src/relay.rs +++ b/crates/sprout-acp/src/relay.rs @@ -82,6 +82,17 @@ use crate::config::ChannelFilter; pub struct ChannelInfo { pub name: String, pub channel_type: String, + pub project_id: Option, +} + +/// Project metadata fetched from the relay. +#[derive(Debug, Clone)] +pub struct ProjectInfo { + pub name: String, + pub prompt: Option, + pub description: Option, + pub repo_urls: serde_json::Value, + pub environment: String, } /// Lightweight REST client for pre-prompt context fetches. @@ -465,7 +476,18 @@ impl HarnessRelay { .and_then(|v| v.as_str()) .unwrap_or("stream") .to_string(); - map.insert(uuid, ChannelInfo { name, channel_type }); + let project_id = ch + .get("project_id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()); + map.insert( + uuid, + ChannelInfo { + name, + channel_type, + project_id, + }, + ); } Err(e) => { warn!("skipping channel with unparseable id {id_str:?}: {e}"); diff --git a/crates/sprout-cli/src/commands/channels.rs b/crates/sprout-cli/src/commands/channels.rs index 7660e16c..fa0d9f0a 100644 --- a/crates/sprout-cli/src/commands/channels.rs +++ b/crates/sprout-cli/src/commands/channels.rs @@ -119,9 +119,15 @@ pub async fn cmd_create_channel( "forum" => sprout_sdk::ChannelKind::Forum, _ => unreachable!(), // validated above }; - let builder = - sprout_sdk::build_create_channel(channel_uuid, name, Some(vis), Some(ct), description) - .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; + let builder = sprout_sdk::build_create_channel( + channel_uuid, + name, + Some(vis), + Some(ct), + description, + None, + ) + .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; let event = sign_and_submit_builder(builder, keys)?; let resp = client.submit_event(event).await?; diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs index 27507c47..c78100a2 100644 --- a/crates/sprout-core/src/kind.rs +++ b/crates/sprout-core/src/kind.rs @@ -228,6 +228,14 @@ pub const KIND_HUDDLE_GUIDELINES: u32 = 48106; /// Internal kind for media upload audit entries. Not a relay event kind. pub const KIND_MEDIA_UPLOAD: u32 = 49001; +// Projects (50000–50999) +/// A new project was created. +pub const KIND_PROJECT_CREATED: u32 = 50001; +/// A project was updated. +pub const KIND_PROJECT_UPDATED: u32 = 50002; +/// A project was deleted. +pub const KIND_PROJECT_DELETED: u32 = 50003; + /// All registered kind constants — used for duplicate detection and iteration. pub const ALL_KINDS: &[u32] = &[ KIND_PROFILE, @@ -314,6 +322,9 @@ pub const ALL_KINDS: &[u32] = &[ KIND_HUDDLE_TRACK_PUBLISHED, KIND_HUDDLE_RECORDING_AVAILABLE, KIND_MEDIA_UPLOAD, + KIND_PROJECT_CREATED, + KIND_PROJECT_UPDATED, + KIND_PROJECT_DELETED, ]; /// Returns `true` if `kind` is in the ephemeral range (20000–29999). @@ -357,6 +368,7 @@ pub fn event_kind_i32(event: &nostr::Event) -> i32 { const _: () = assert!(KIND_AUTH <= u16::MAX as u32); const _: () = assert!(KIND_CANVAS <= u16::MAX as u32); const _: () = assert!(KIND_HUDDLE_RECORDING_AVAILABLE <= u16::MAX as u32); +const _: () = assert!(KIND_PROJECT_DELETED <= u16::MAX as u32); const _: () = assert!(EPHEMERAL_KIND_MIN < EPHEMERAL_KIND_MAX); #[cfg(test)] diff --git a/crates/sprout-core/src/lib.rs b/crates/sprout-core/src/lib.rs index 736c0476..7b18e56d 100644 --- a/crates/sprout-core/src/lib.rs +++ b/crates/sprout-core/src/lib.rs @@ -21,6 +21,8 @@ pub mod network; pub mod pairing; /// Presence status types shared across crates. pub mod presence; +/// Project types shared across crates. +pub mod project; /// Schnorr signature and event ID verification. pub mod verification; diff --git a/crates/sprout-core/src/project.rs b/crates/sprout-core/src/project.rs new file mode 100644 index 00000000..b466e5e5 --- /dev/null +++ b/crates/sprout-core/src/project.rs @@ -0,0 +1,45 @@ +//! Project types shared across crates. +//! +//! These live in `sprout-core` (zero I/O deps) so both the SDK (client-side) +//! and the DB layer (server-side) can use the same types without pulling in +//! sqlx/tokio. + +use std::fmt; +use std::str::FromStr; + +/// Environment where project agents execute. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectEnvironment { + /// Local machine (dev laptop). + Local, + /// Remote Blox compute instance. + Blox, +} + +impl ProjectEnvironment { + /// Canonical string representation (matches DB column and Nostr tags). + pub fn as_str(&self) -> &'static str { + match self { + Self::Local => "local", + Self::Blox => "blox", + } + } +} + +impl fmt::Display for ProjectEnvironment { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for ProjectEnvironment { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "local" => Ok(Self::Local), + "blox" => Ok(Self::Blox), + other => Err(format!("unknown environment: {other:?}")), + } + } +} diff --git a/crates/sprout-db/src/channel.rs b/crates/sprout-db/src/channel.rs index dbf8f031..7fe2dbe8 100644 --- a/crates/sprout-db/src/channel.rs +++ b/crates/sprout-db/src/channel.rs @@ -62,6 +62,8 @@ pub struct ChannelRecord { pub ttl_seconds: Option, /// Deadline by which a new message must arrive or the channel is auto-archived. pub ttl_deadline: Option>, + /// Optional project this channel belongs to. + pub project_id: Option, } /// A channel membership row as returned from the database. @@ -143,7 +145,7 @@ pub async fn create_channel( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline + ttl_seconds, ttl_deadline, project_id FROM channels WHERE id = $1 "#, ) @@ -170,6 +172,7 @@ pub async fn create_channel_with_id( description: Option<&str>, created_by: &[u8], ttl_seconds: Option, + project_id: Option, ) -> Result<(ChannelRecord, bool)> { if created_by.len() != 32 { return Err(DbError::InvalidData(format!( @@ -188,9 +191,10 @@ pub async fn create_channel_with_id( let rows_affected = sqlx::query( r#" - INSERT INTO channels (id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline) + INSERT INTO channels (id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline, project_id) VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6, $7, - CASE WHEN $7 IS NOT NULL THEN NOW() + ($7 || ' seconds')::interval ELSE NULL END) + CASE WHEN $7 IS NOT NULL THEN NOW() + ($7 || ' seconds')::interval ELSE NULL END, + $8) ON CONFLICT (id) DO NOTHING "#, ) @@ -201,6 +205,7 @@ pub async fn create_channel_with_id( .bind(description) .bind(created_by) .bind(ttl_seconds) + .bind(project_id) .execute(&mut *tx) .await? .rows_affected(); @@ -234,7 +239,7 @@ pub async fn create_channel_with_id( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, - ttl_seconds, ttl_deadline + ttl_seconds, ttl_deadline, project_id FROM channels WHERE id = $1 "#, ) @@ -257,7 +262,7 @@ pub async fn get_channel(pool: &PgPool, channel_id: Uuid) -> Result) -> Result) -> Result]) -> Result Result { +pub(crate) fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result { let id: Uuid = row.try_get("id")?; let topic_required: bool = row.try_get("topic_required")?; @@ -890,6 +895,7 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result { purpose_set_at, ttl_seconds, ttl_deadline, + project_id: row.try_get("project_id").unwrap_or(None), }) } diff --git a/crates/sprout-db/src/dm.rs b/crates/sprout-db/src/dm.rs index 14fea3dc..8180c2a7 100644 --- a/crates/sprout-db/src/dm.rs +++ b/crates/sprout-db/src/dm.rs @@ -441,6 +441,7 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result { purpose_set_at: row.try_get("purpose_set_at").unwrap_or(None), ttl_seconds: row.try_get("ttl_seconds").unwrap_or(None), ttl_deadline: row.try_get("ttl_deadline").unwrap_or(None), + project_id: row.try_get("project_id").unwrap_or(None), }) } diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index b9f1326a..1c9c30df 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -23,6 +23,8 @@ pub mod event; pub mod feed; /// Monthly table partition management. pub mod partition; +/// Project persistence. +pub mod project; /// Reaction persistence. pub mod reaction; /// Thread metadata persistence. @@ -341,6 +343,7 @@ impl Db { description: Option<&str>, created_by: &[u8], ttl_seconds: Option, + project_id: Option, ) -> Result<(channel::ChannelRecord, bool)> { channel::create_channel_with_id( &self.pool, @@ -351,6 +354,7 @@ impl Db { description, created_by, ttl_seconds, + project_id, ) .await } @@ -504,6 +508,82 @@ impl Db { channel::reap_expired_ephemeral_channels(&self.pool).await } + // ── Projects ───────────────────────────────────────────────────────────── + + /// Creates a project with a client-supplied UUID (idempotent). + #[allow(clippy::too_many_arguments)] + pub async fn create_project_with_id( + &self, + id: Uuid, + name: &str, + environment: &str, + description: Option<&str>, + prompt: Option<&str>, + icon: Option<&str>, + color: Option<&str>, + repo_urls: &serde_json::Value, + created_by: &[u8], + ) -> Result<(project::ProjectRecord, bool)> { + project::create_project_with_id( + &self.pool, + id, + name, + environment, + description, + prompt, + icon, + color, + repo_urls, + created_by, + ) + .await + } + + /// Fetches a project by ID. + pub async fn get_project(&self, id: Uuid) -> Result { + project::get_project(&self.pool, id).await + } + + /// Lists projects, optionally filtered by creator. + pub async fn list_projects( + &self, + created_by: Option<&[u8]>, + ) -> Result> { + project::list_projects(&self.pool, created_by).await + } + + /// Updates a project. + pub async fn update_project( + &self, + id: Uuid, + updates: project::ProjectUpdate, + ) -> Result { + project::update_project(&self.pool, id, updates).await + } + + /// Soft-deletes a project. + pub async fn soft_delete_project(&self, id: Uuid) -> Result { + project::soft_delete_project(&self.pool, id).await + } + + /// Archives a project. + pub async fn archive_project(&self, id: Uuid) -> Result<()> { + project::archive_project(&self.pool, id).await + } + + /// Unarchives a project. + pub async fn unarchive_project(&self, id: Uuid) -> Result<()> { + project::unarchive_project(&self.pool, id).await + } + + /// Lists channels belonging to a project. + pub async fn list_project_channels( + &self, + project_id: Uuid, + ) -> Result> { + project::list_project_channels(&self.pool, project_id).await + } + // ── Users ──────────────────────────────────────────────────────────────── /// Ensure a user record exists (upsert). diff --git a/crates/sprout-db/src/project.rs b/crates/sprout-db/src/project.rs new file mode 100644 index 00000000..ccc23388 --- /dev/null +++ b/crates/sprout-db/src/project.rs @@ -0,0 +1,620 @@ +//! Project CRUD operations. + +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +use crate::error::{DbError, Result}; + +/// A project row as returned from the database. +#[derive(Debug, Clone)] +pub struct ProjectRecord { + /// Unique project identifier. + pub id: Uuid, + /// Human-readable project name. + pub name: String, + /// Optional project description. + pub description: Option, + /// Shared context/instructions injected into agents. + pub prompt: Option, + /// Optional icon identifier. + pub icon: Option, + /// Optional color identifier. + pub color: Option, + /// Execution environment (`"local"` or `"blox"`). + pub environment: String, + /// JSONB array of repository URLs. + pub repo_urls: serde_json::Value, + /// Compressed public key bytes of the project creator. + pub created_by: Vec, + /// When the project was created. + pub created_at: DateTime, + /// When the project was last updated. + pub updated_at: DateTime, + /// When the project was archived, if applicable. + pub archived_at: Option>, + /// When the project was soft-deleted, if applicable. + pub deleted_at: Option>, +} + +/// Partial update for a project. +pub struct ProjectUpdate { + /// New project name, or `None` to leave unchanged. + pub name: Option, + /// New description, or `None` to leave unchanged. + pub description: Option, + /// New prompt, or `None` to leave unchanged. + pub prompt: Option, + /// New icon, or `None` to leave unchanged. + pub icon: Option, + /// New color, or `None` to leave unchanged. + pub color: Option, + /// New environment, or `None` to leave unchanged. + pub environment: Option, + /// New repo_urls, or `None` to leave unchanged. + pub repo_urls: Option, +} + +const SELECT_COLS: &str = r#" + id, name, description, prompt, icon, color, environment, repo_urls, + created_by, created_at, updated_at, archived_at, deleted_at +"#; + +fn row_to_project_record(row: sqlx::postgres::PgRow) -> Result { + Ok(ProjectRecord { + id: row.try_get("id")?, + name: row.try_get("name")?, + description: row.try_get("description").unwrap_or(None), + prompt: row.try_get("prompt").unwrap_or(None), + icon: row.try_get("icon").unwrap_or(None), + color: row.try_get("color").unwrap_or(None), + environment: row.try_get("environment")?, + repo_urls: row.try_get("repo_urls")?, + created_by: row.try_get("created_by")?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + archived_at: row.try_get("archived_at").unwrap_or(None), + deleted_at: row.try_get("deleted_at").unwrap_or(None), + }) +} + +/// Creates a project with a client-supplied UUID (idempotent via ON CONFLICT DO NOTHING). +/// +/// Returns `(record, true)` if newly created, or `(record, false)` if already exists. +#[allow(clippy::too_many_arguments)] +pub async fn create_project_with_id( + pool: &PgPool, + id: Uuid, + name: &str, + environment: &str, + description: Option<&str>, + prompt: Option<&str>, + icon: Option<&str>, + color: Option<&str>, + repo_urls: &serde_json::Value, + created_by: &[u8], +) -> Result<(ProjectRecord, bool)> { + if created_by.len() != 32 { + return Err(DbError::InvalidData(format!( + "pubkey must be 32 bytes, got {}", + created_by.len() + ))); + } + + if id.is_nil() { + return Err(DbError::InvalidData("project id must not be nil".into())); + } + + let rows_affected = sqlx::query( + r#" + INSERT INTO projects (id, name, environment, description, prompt, icon, color, repo_urls, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO NOTHING + "#, + ) + .bind(id) + .bind(name) + .bind(environment) + .bind(description) + .bind(prompt) + .bind(icon) + .bind(color) + .bind(repo_urls) + .bind(created_by) + .execute(pool) + .await? + .rows_affected(); + + let was_created = rows_affected > 0; + + let row = sqlx::query(&format!("SELECT {SELECT_COLS} FROM projects WHERE id = $1")) + .bind(id) + .fetch_one(pool) + .await?; + + Ok((row_to_project_record(row)?, was_created)) +} + +/// Fetches a project by ID. Returns error if not found or deleted. +pub async fn get_project(pool: &PgPool, id: Uuid) -> Result { + let row = sqlx::query(&format!( + "SELECT {SELECT_COLS} FROM projects WHERE id = $1 AND deleted_at IS NULL" + )) + .bind(id) + .fetch_optional(pool) + .await?; + + match row { + Some(r) => row_to_project_record(r), + None => Err(DbError::NotFound(format!("project {id} not found"))), + } +} + +/// Lists projects, optionally filtered by creator pubkey. +pub async fn list_projects(pool: &PgPool, created_by: Option<&[u8]>) -> Result> { + let rows = if let Some(pubkey) = created_by { + sqlx::query(&format!( + "SELECT {SELECT_COLS} FROM projects WHERE created_by = $1 AND deleted_at IS NULL ORDER BY created_at DESC" + )) + .bind(pubkey) + .fetch_all(pool) + .await? + } else { + sqlx::query(&format!( + "SELECT {SELECT_COLS} FROM projects WHERE deleted_at IS NULL ORDER BY created_at DESC" + )) + .fetch_all(pool) + .await? + }; + + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + out.push(row_to_project_record(row)?); + } + Ok(out) +} + +/// Updates a project's fields dynamically. +pub async fn update_project( + pool: &PgPool, + id: Uuid, + updates: ProjectUpdate, +) -> Result { + let has_update = updates.name.is_some() + || updates.description.is_some() + || updates.prompt.is_some() + || updates.icon.is_some() + || updates.color.is_some() + || updates.environment.is_some() + || updates.repo_urls.is_some(); + + if !has_update { + return Err(DbError::InvalidData( + "at least one field must be provided for update".to_string(), + )); + } + + let mut set_parts: Vec = Vec::new(); + let mut param_idx: usize = 1; + + if updates.name.is_some() { + set_parts.push(format!("name = ${param_idx}")); + param_idx += 1; + } + if updates.description.is_some() { + set_parts.push(format!("description = ${param_idx}")); + param_idx += 1; + } + if updates.prompt.is_some() { + set_parts.push(format!("prompt = ${param_idx}")); + param_idx += 1; + } + if updates.icon.is_some() { + set_parts.push(format!("icon = ${param_idx}")); + param_idx += 1; + } + if updates.color.is_some() { + set_parts.push(format!("color = ${param_idx}")); + param_idx += 1; + } + if updates.environment.is_some() { + set_parts.push(format!("environment = ${param_idx}")); + param_idx += 1; + } + if updates.repo_urls.is_some() { + set_parts.push(format!("repo_urls = ${param_idx}")); + param_idx += 1; + } + + let sql = format!( + "UPDATE projects SET {}, updated_at = NOW() WHERE id = ${param_idx} AND deleted_at IS NULL", + set_parts.join(", ") + ); + + let mut q = sqlx::query(&sql); + if let Some(ref v) = updates.name { + q = q.bind(v); + } + if let Some(ref v) = updates.description { + q = q.bind(v); + } + if let Some(ref v) = updates.prompt { + q = q.bind(v); + } + if let Some(ref v) = updates.icon { + q = q.bind(v); + } + if let Some(ref v) = updates.color { + q = q.bind(v); + } + if let Some(ref v) = updates.environment { + q = q.bind(v); + } + if let Some(ref v) = updates.repo_urls { + q = q.bind(v); + } + q = q.bind(id); + + let result = q.execute(pool).await?; + if result.rows_affected() == 0 { + return Err(DbError::NotFound(format!("project {id} not found"))); + } + + get_project(pool, id).await +} + +/// Soft-delete a project. +pub async fn soft_delete_project(pool: &PgPool, id: Uuid) -> Result { + let result = + sqlx::query("UPDATE projects SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL") + .bind(id) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Archive a project. +pub async fn archive_project(pool: &PgPool, id: Uuid) -> Result<()> { + let row = sqlx::query("SELECT archived_at FROM projects WHERE id = $1 AND deleted_at IS NULL") + .bind(id) + .fetch_optional(pool) + .await?; + + match row { + None => return Err(DbError::NotFound(format!("project {id} not found"))), + Some(r) => { + let archived_at: Option> = r.try_get("archived_at")?; + if archived_at.is_some() { + return Err(DbError::AccessDenied( + "project is already archived".to_string(), + )); + } + } + } + + sqlx::query( + "UPDATE projects SET archived_at = NOW() WHERE id = $1 AND deleted_at IS NULL AND archived_at IS NULL", + ) + .bind(id) + .execute(pool) + .await?; + + Ok(()) +} + +/// Unarchive a project. +pub async fn unarchive_project(pool: &PgPool, id: Uuid) -> Result<()> { + let result = sqlx::query( + "UPDATE projects SET archived_at = NULL WHERE id = $1 AND deleted_at IS NULL AND archived_at IS NOT NULL", + ) + .bind(id) + .execute(pool) + .await?; + + if result.rows_affected() == 0 { + return Err(DbError::NotFound(format!( + "project {id} not found or not archived" + ))); + } + + Ok(()) +} + +/// List channels belonging to a project. +pub async fn list_project_channels( + pool: &PgPool, + project_id: Uuid, +) -> Result> { + let rows = sqlx::query( + r#" + SELECT id, name, channel_type::text AS channel_type, visibility::text AS visibility, + description, canvas, + created_by, created_at, updated_at, archived_at, deleted_at, + nip29_group_id, topic_required, max_members, + topic, topic_set_by, topic_set_at, + purpose, purpose_set_by, purpose_set_at, + ttl_seconds, ttl_deadline, project_id + FROM channels + WHERE project_id = $1 AND deleted_at IS NULL + ORDER BY created_at DESC + "#, + ) + .bind(project_id) + .fetch_all(pool) + .await?; + + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + out.push(crate::channel::row_to_channel_record(row)?); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::Keys; + use sqlx::PgPool; + + const TEST_DB_URL: &str = "postgres://sprout:sprout_dev@localhost:5432/sprout"; + + async fn setup_pool() -> PgPool { + PgPool::connect(TEST_DB_URL) + .await + .expect("connect to test DB") + } + + fn random_pubkey() -> Vec { + Keys::generate().public_key().serialize().to_vec() + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn create_and_get_project() { + let pool = setup_pool().await; + let pk = random_pubkey(); + let id = Uuid::new_v4(); + + let (record, created) = create_project_with_id( + &pool, + id, + "test-project", + "local", + Some("A test project"), + Some("Be helpful"), + Some("rocket"), + Some("#00ff00"), + &serde_json::json!(["https://github.com/test/repo"]), + &pk, + ) + .await + .expect("create project"); + + assert!(created); + assert_eq!(record.id, id); + assert_eq!(record.name, "test-project"); + assert_eq!(record.environment, "local"); + assert_eq!(record.description.as_deref(), Some("A test project")); + assert_eq!(record.prompt.as_deref(), Some("Be helpful")); + assert_eq!(record.icon.as_deref(), Some("rocket")); + assert_eq!(record.color.as_deref(), Some("#00ff00")); + + let fetched = get_project(&pool, id).await.expect("get project"); + assert_eq!(fetched.id, id); + assert_eq!(fetched.name, "test-project"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn list_projects_by_creator() { + let pool = setup_pool().await; + let pk1 = random_pubkey(); + let pk2 = random_pubkey(); + + create_project_with_id( + &pool, + Uuid::new_v4(), + "proj-a", + "local", + None, + None, + None, + None, + &serde_json::json!([]), + &pk1, + ) + .await + .expect("create a"); + + create_project_with_id( + &pool, + Uuid::new_v4(), + "proj-b", + "local", + None, + None, + None, + None, + &serde_json::json!([]), + &pk2, + ) + .await + .expect("create b"); + + let all = list_projects(&pool, None).await.expect("list all"); + assert!(all.len() >= 2); + + let by_pk1 = list_projects(&pool, Some(&pk1)).await.expect("list pk1"); + assert!(by_pk1.iter().all(|p| p.created_by == pk1)); + + let by_pk2 = list_projects(&pool, Some(&pk2)).await.expect("list pk2"); + assert!(by_pk2.iter().all(|p| p.created_by == pk2)); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn update_project_partial() { + let pool = setup_pool().await; + let pk = random_pubkey(); + let id = Uuid::new_v4(); + + create_project_with_id( + &pool, + id, + "original", + "local", + Some("desc"), + None, + None, + None, + &serde_json::json!([]), + &pk, + ) + .await + .expect("create"); + + let updated = update_project( + &pool, + id, + ProjectUpdate { + name: Some("renamed".into()), + description: None, + prompt: None, + icon: None, + color: None, + environment: None, + repo_urls: None, + }, + ) + .await + .expect("update"); + + assert_eq!(updated.name, "renamed"); + // Description should be unchanged + assert_eq!(updated.description.as_deref(), Some("desc")); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn update_project_repos() { + let pool = setup_pool().await; + let pk = random_pubkey(); + let id = Uuid::new_v4(); + + create_project_with_id( + &pool, + id, + "repo-test", + "local", + None, + None, + None, + None, + &serde_json::json!(["https://github.com/test/a"]), + &pk, + ) + .await + .expect("create"); + + // Set repos + let updated = update_project( + &pool, + id, + ProjectUpdate { + name: None, + description: None, + prompt: None, + icon: None, + color: None, + environment: None, + repo_urls: Some(serde_json::json!([ + "https://github.com/test/b", + "https://github.com/test/c" + ])), + }, + ) + .await + .expect("update repos"); + let urls: Vec = serde_json::from_value(updated.repo_urls.clone()).unwrap(); + assert_eq!(urls.len(), 2); + + // Clear repos + let cleared = update_project( + &pool, + id, + ProjectUpdate { + name: None, + description: None, + prompt: None, + icon: None, + color: None, + environment: None, + repo_urls: Some(serde_json::json!([])), + }, + ) + .await + .expect("clear repos"); + let urls: Vec = serde_json::from_value(cleared.repo_urls).unwrap(); + assert!(urls.is_empty()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn soft_delete_project_test() { + let pool = setup_pool().await; + let pk = random_pubkey(); + let id = Uuid::new_v4(); + + create_project_with_id( + &pool, + id, + "to-delete", + "local", + None, + None, + None, + None, + &serde_json::json!([]), + &pk, + ) + .await + .expect("create"); + + let deleted = soft_delete_project(&pool, id).await.expect("delete"); + assert!(deleted); + + let result = get_project(&pool, id).await; + assert!(result.is_err()); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn archive_and_unarchive_project() { + let pool = setup_pool().await; + let pk = random_pubkey(); + let id = Uuid::new_v4(); + + create_project_with_id( + &pool, + id, + "to-archive", + "local", + None, + None, + None, + None, + &serde_json::json!([]), + &pk, + ) + .await + .expect("create"); + + archive_project(&pool, id).await.expect("archive"); + let archived = get_project(&pool, id).await.expect("get archived"); + assert!(archived.archived_at.is_some()); + + unarchive_project(&pool, id).await.expect("unarchive"); + let unarchived = get_project(&pool, id).await.expect("get unarchived"); + assert!(unarchived.archived_at.is_none()); + } +} diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 2959d18d..50925f1a 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -228,6 +228,9 @@ pub struct CreateChannelParams { /// Optional human-readable description of the channel's purpose. #[serde(default)] pub description: Option, + /// Optional project UUID to associate this channel with. + #[serde(default)] + pub project_id: Option, } /// Parameters for the `get_canvas` tool. @@ -246,6 +249,79 @@ pub struct SetCanvasParams { pub content: String, } +// ── Project tool parameter structs ─────────────────────────────────────────── + +/// Parameters for the `create_project` tool. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct CreateProjectParams { + /// Display name for the new project. + pub name: String, + /// Optional human-readable description. + #[serde(default)] + pub description: Option, + /// Shared context/instructions injected into agents. + #[serde(default)] + pub prompt: Option, + /// Optional icon identifier. + #[serde(default)] + pub icon: Option, + /// Optional color identifier. + #[serde(default)] + pub color: Option, + /// Execution environment: `"local"` (default) or `"blox"`. + #[serde(default)] + pub environment: Option, + /// Repository URLs associated with this project. + #[serde(default)] + pub repo_urls: Option>, +} + +/// Parameters for the `get_project` tool. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct GetProjectParams { + /// UUID of the project to retrieve. + pub project_id: String, +} + +/// Parameters for the `list_projects` tool. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ListProjectsParams {} + +/// Parameters for the `update_project` tool. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct UpdateProjectParams { + /// UUID of the project to update. + pub project_id: String, + /// New project name. + #[serde(default)] + pub name: Option, + /// New description. + #[serde(default)] + pub description: Option, + /// New shared prompt/instructions. + #[serde(default)] + pub prompt: Option, + /// New icon identifier. + #[serde(default)] + pub icon: Option, + /// New color identifier. + #[serde(default)] + pub color: Option, + /// New execution environment. + #[serde(default)] + pub environment: Option, + /// New repository URLs. + #[serde(default)] + pub repo_urls: Option>, +} + +/// Parameters for the `delete_project` tool. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct DeleteProjectParams { + /// UUID of the project to delete. + pub project_id: String, +} + // ── Workflow tool parameter structs ────────────────────────────────────────── /// Parameters for the `list_workflows` tool. @@ -1339,12 +1415,20 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi ) } }; + let project_uuid = match &p.project_id { + Some(pid) => match uuid::Uuid::parse_str(pid) { + Ok(id) => Some(id), + Err(_) => return format!("Error: invalid project UUID: {pid}"), + }, + None => None, + }; let builder = match sprout_sdk::build_create_channel( channel_uuid, &p.name, Some(visibility), Some(channel_type), p.description.as_deref(), + project_uuid, ) { Ok(b) => b, Err(e) => return format!("Error: {e}"), @@ -2620,6 +2704,152 @@ with kind:45003 comments)." Err(e) => format!("Error: {e}"), } } + + // ── Project tools ──────────────────────────────────────────────────────── + + /// Create a new project. Projects group channels and provide shared context to agents. + #[tool( + name = "create_project", + description = "Create a new project. Projects group channels and provide shared context to agents." + )] + pub async fn create_project(&self, Parameters(p): Parameters) -> String { + let project_uuid = uuid::Uuid::new_v4(); + let env = p.environment.as_deref().unwrap_or("local"); + let repo_refs: Vec<&str> = p + .repo_urls + .as_ref() + .map(|v| v.iter().map(|s| s.as_str()).collect()) + .unwrap_or_default(); + let builder = match sprout_sdk::build_create_project( + project_uuid, + &p.name, + Some(env), + p.description.as_deref(), + p.prompt.as_deref(), + p.icon.as_deref(), + p.color.as_deref(), + if repo_refs.is_empty() { + None + } else { + Some(&repo_refs) + }, + ) { + Ok(b) => b, + Err(e) => return format!("Error: {e}"), + }; + let event = match builder.sign_with_keys(self.client.keys()) { + Ok(e) => e, + Err(e) => return format!("Error: failed to sign: {e}"), + }; + match self.client.send_event(event).await { + Ok(ok) => serde_json::json!({ + "event_id": ok.event_id, + "project_id": project_uuid.to_string(), + "accepted": ok.accepted, + "message": ok.message, + }) + .to_string(), + Err(e) => format!("Error: {e}"), + } + } + + /// Get details for a project by ID. + #[tool(name = "get_project", description = "Get details for a project by ID.")] + pub async fn get_project(&self, Parameters(p): Parameters) -> String { + if let Err(e) = validate_uuid(&p.project_id) { + return format!("Error: {e}"); + } + match self + .client + .get(&format!("/api/projects/{}", p.project_id)) + .await + { + Ok(b) => b, + Err(e) => format!("Error: {e}"), + } + } + + /// List all projects accessible to this agent. + #[tool( + name = "list_projects", + description = "List all projects accessible to this agent." + )] + pub async fn list_projects(&self, Parameters(_p): Parameters) -> String { + match self.client.get("/api/projects").await { + Ok(b) => b, + Err(e) => format!("Error: {e}"), + } + } + + /// Update a project's metadata. + #[tool( + name = "update_project", + description = "Update a project's name, description, prompt, icon, color, environment, or repo_urls." + )] + pub async fn update_project(&self, Parameters(p): Parameters) -> String { + let project_id = match uuid::Uuid::parse_str(&p.project_id) { + Ok(id) => id, + Err(_) => return format!("Error: invalid project UUID: {}", p.project_id), + }; + let repo_refs: Option> = p + .repo_urls + .as_ref() + .map(|v| v.iter().map(|s| s.as_str()).collect()); + let builder = match sprout_sdk::build_update_project( + project_id, + p.name.as_deref(), + p.description.as_deref(), + p.prompt.as_deref(), + p.icon.as_deref(), + p.color.as_deref(), + p.environment.as_deref(), + repo_refs.as_deref(), + ) { + Ok(b) => b, + Err(e) => return format!("Error: {e}"), + }; + let event = match builder.sign_with_keys(self.client.keys()) { + Ok(e) => e, + Err(e) => return format!("Error: failed to sign: {e}"), + }; + match self.client.send_event(event).await { + Ok(ok) => serde_json::json!({ + "event_id": ok.event_id, + "project_id": project_id.to_string(), + "accepted": ok.accepted, + "message": ok.message, + }) + .to_string(), + Err(e) => format!("Error: {e}"), + } + } + + /// Delete a project. + #[tool(name = "delete_project", description = "Delete a project by ID.")] + pub async fn delete_project(&self, Parameters(p): Parameters) -> String { + let project_id = match uuid::Uuid::parse_str(&p.project_id) { + Ok(id) => id, + Err(_) => return format!("Error: invalid project UUID: {}", p.project_id), + }; + let builder = match sprout_sdk::build_delete_project(project_id) { + Ok(b) => b, + Err(e) => return format!("Error: {e}"), + }; + let event = match builder.sign_with_keys(self.client.keys()) { + Ok(e) => e, + Err(e) => return format!("Error: failed to sign: {e}"), + }; + match self.client.send_event(event).await { + Ok(ok) => serde_json::json!({ + "event_id": ok.event_id, + "project_id": project_id.to_string(), + "accepted": ok.accepted, + "message": ok.message, + }) + .to_string(), + Err(e) => format!("Error: {e}"), + } + } } #[tool_handler] diff --git a/crates/sprout-mcp/src/toolsets.rs b/crates/sprout-mcp/src/toolsets.rs index ed3b067d..1662e1f4 100644 --- a/crates/sprout-mcp/src/toolsets.rs +++ b/crates/sprout-mcp/src/toolsets.rs @@ -103,6 +103,12 @@ pub const ALL_TOOLS: &[(&str, &str, bool)] = &[ ("get_event", "social", true), ("get_user_notes", "social", true), ("get_contact_list", "social", true), + // ── project ────────────────────────────────────────────────────────────── + ("create_project", "project", false), + ("get_project", "project", true), + ("list_projects", "project", true), + ("update_project", "project", false), + ("delete_project", "project", false), // Deferred tools (not yet implemented): upload_file, subscribe, unsubscribe ]; @@ -169,6 +175,7 @@ const KNOWN_TOOLSETS: &[&str] = &[ "identity", "forums", "social", + "project", ]; // --------------------------------------------------------------------------- @@ -383,8 +390,8 @@ mod tests { } #[test] - fn all_tools_count_is_48() { - assert_eq!(ALL_TOOLS.len(), 48); + fn all_tools_count_is_53() { + assert_eq!(ALL_TOOLS.len(), 53); } #[test] @@ -411,7 +418,7 @@ mod tests { // ALL_TOOLS covers: default, channel_admin, dms, canvas, workflow_admin, identity, forums, social // (media and realtime have no implemented tools yet) let defs = all_toolsets(); - assert_eq!(defs.len(), 8); + assert_eq!(defs.len(), 9); let names: Vec<_> = defs.iter().map(|d| d.name).collect(); assert!(names.contains(&"default")); assert!(names.contains(&"canvas")); diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 3167d06a..43c86b4e 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -36,6 +36,8 @@ pub mod messages; pub mod nip05; /// Presence status endpoints. pub mod presence; +/// Project endpoints. +pub mod projects; /// Reaction endpoints. pub mod reactions; /// Full-text search endpoint. @@ -61,6 +63,9 @@ pub use feed::feed_handler; pub use members::list_members; pub use messages::{get_thread, list_messages, validate_imeta_tags, verify_imeta_blobs}; pub use presence::{presence_handler, set_presence_handler}; +pub use projects::{ + get_project_handler as get_project, list_project_channels_handler, list_projects_handler, +}; pub use reactions::list_reactions_handler; pub use search::search_handler; pub use users::{ @@ -608,6 +613,7 @@ mod tests { purpose_set_at: None, ttl_seconds: None, ttl_deadline: None, + project_id: None, }, is_member: true, } diff --git a/crates/sprout-relay/src/api/projects.rs b/crates/sprout-relay/src/api/projects.rs new file mode 100644 index 00000000..2e91bf98 --- /dev/null +++ b/crates/sprout-relay/src/api/projects.rs @@ -0,0 +1,128 @@ +//! Project REST API. +//! +//! Endpoints: +//! GET /api/projects — list all projects +//! GET /api/projects/:project_id — get a single project +//! GET /api/projects/:project_id/channels — list channels in a project + +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::{HeaderMap, StatusCode}, + response::Json, +}; + +use crate::state::AppState; + +use super::{api_error, extract_auth_context, internal_error, not_found, scope_error}; + +// ── GET /api/projects ──────────────────────────────────────────────────────── + +/// List all projects accessible to the authenticated user. +pub async fn list_projects_handler( + State(state): State>, + headers: HeaderMap, +) -> Result, (StatusCode, Json)> { + let ctx = extract_auth_context(&headers, &state).await?; + sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead) + .map_err(scope_error)?; + + let records = state + .db + .list_projects(None) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))?; + + let projects: Vec = + records.into_iter().map(|r| project_to_json(&r)).collect(); + + Ok(Json(serde_json::json!({ "projects": projects }))) +} + +// ── GET /api/projects/:project_id ──────────────────────────────────────────── + +/// Get a single project by ID. +pub async fn get_project_handler( + State(state): State>, + headers: HeaderMap, + Path(project_id_str): Path, +) -> Result, (StatusCode, Json)> { + let ctx = extract_auth_context(&headers, &state).await?; + sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead) + .map_err(scope_error)?; + + let project_id = uuid::Uuid::parse_str(&project_id_str) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid project UUID"))?; + + let record = state + .db + .get_project(project_id) + .await + .map_err(|e| match e { + sprout_db::DbError::NotFound(_) => not_found("project not found"), + other => internal_error(&format!("db error: {other}")), + })?; + + Ok(Json(project_to_json(&record))) +} + +// ── GET /api/projects/:project_id/channels ─────────────────────────────────── + +/// List channels belonging to a project. +pub async fn list_project_channels_handler( + State(state): State>, + headers: HeaderMap, + Path(project_id_str): Path, +) -> Result, (StatusCode, Json)> { + let ctx = extract_auth_context(&headers, &state).await?; + sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead) + .map_err(scope_error)?; + + let project_id = uuid::Uuid::parse_str(&project_id_str) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid project UUID"))?; + + let records = state + .db + .list_project_channels(project_id) + .await + .map_err(|e| internal_error(&format!("db error: {e}")))?; + + let channels: Vec = records + .into_iter() + .map(|ch| { + serde_json::json!({ + "id": ch.id, + "name": ch.name, + "channel_type": ch.channel_type, + "visibility": ch.visibility, + "description": ch.description, + "created_by": hex::encode(&ch.created_by), + "created_at": ch.created_at.to_rfc3339(), + "updated_at": ch.updated_at.to_rfc3339(), + "archived_at": ch.archived_at.map(|t| t.to_rfc3339()), + }) + }) + .collect(); + + Ok(Json(serde_json::json!({ "channels": channels }))) +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +fn project_to_json(r: &sprout_db::project::ProjectRecord) -> serde_json::Value { + serde_json::json!({ + "id": r.id, + "name": r.name, + "description": r.description, + "prompt": r.prompt, + "icon": r.icon, + "color": r.color, + "environment": r.environment, + "repo_urls": r.repo_urls, + "created_by": hex::encode(&r.created_by), + "created_at": r.created_at.to_rfc3339(), + "updated_at": r.updated_at.to_rfc3339(), + "archived_at": r.archived_at.map(|t| t.to_rfc3339()), + }) +} diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index 72fbe6c2..0d782464 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -20,15 +20,17 @@ use sprout_core::kind::{ KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_PRESENCE_UPDATE, - KIND_PROFILE, KIND_REACTION, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, - KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, - KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE, + KIND_PROFILE, KIND_PROJECT_CREATED, KIND_PROJECT_DELETED, KIND_PROJECT_UPDATED, KIND_REACTION, + KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, + KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, + KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE, }; use sprout_core::verification::verify_event; use crate::state::AppState; use super::event::dispatch_persistent_event; +use super::side_effects::extract_tag_value; // ── Public types ───────────────────────────────────────────────────────────── @@ -183,6 +185,9 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::ChannelsWrite), + KIND_PROJECT_CREATED | KIND_PROJECT_UPDATED | KIND_PROJECT_DELETED => { + Ok(Scope::ChannelsWrite) + } KIND_NIP29_JOIN_REQUEST | KIND_NIP29_LEAVE_REQUEST => Ok(Scope::ChannelsRead), // Huddle lifecycle events + guidelines KIND_HUDDLE_STARTED @@ -198,7 +203,20 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Option { + for tag in event.tags.iter() { + if tag.kind().to_string() == "d" { + if let Some(val) = tag.content() { + if let Ok(id) = val.parse::() { + return Some(id); + } + } + } + } + None +} + pub(crate) fn extract_channel_id(event: &Event) -> Option { for tag in event.tags.iter() { if tag.kind().to_string() == "h" { @@ -272,7 +290,13 @@ pub(crate) async fn derive_reaction_channel( pub(crate) fn is_global_only_kind(kind: u32) -> bool { matches!( kind, - KIND_PROFILE | KIND_TEXT_NOTE | KIND_CONTACT_LIST | KIND_LONG_FORM + KIND_PROFILE + | KIND_TEXT_NOTE + | KIND_CONTACT_LIST + | KIND_LONG_FORM + | KIND_PROJECT_CREATED + | KIND_PROJECT_UPDATED + | KIND_PROJECT_DELETED ) } @@ -982,17 +1006,12 @@ pub async fn ingest_event( // Track pre-created channel UUID for compensation on insert failure. let mut pre_created_channel: Option = None; + let mut pre_created_project: Option = None; // ── 16. kind:9007 UUID dedup (create channel with client UUID) ─────── if kind_u32 == KIND_NIP29_CREATE_GROUP { // Validate name tag is present and non-empty before any DB work. - let create_name = event.tags.iter().find_map(|t| { - if t.kind().to_string() == "name" { - t.content().map(|s| s.to_string()) - } else { - None - } - }); + let create_name = extract_tag_value(&event, "name"); if create_name .as_ref() .map(|n| n.trim().is_empty()) @@ -1005,28 +1024,10 @@ pub async fn ingest_event( // Validate visibility/channel_type for ALL kind:9007 events (with or without h-tag). // This runs pre-storage so invalid enums are rejected before the event is persisted. - let visibility_str = event - .tags - .iter() - .find_map(|t| { - if t.kind().to_string() == "visibility" { - t.content().map(|s| s.to_string()) - } else { - None - } - }) - .unwrap_or_else(|| "open".to_string()); - let channel_type_str = event - .tags - .iter() - .find_map(|t| { - if t.kind().to_string() == "channel_type" { - t.content().map(|s| s.to_string()) - } else { - None - } - }) - .unwrap_or_else(|| "stream".to_string()); + let visibility_str = + extract_tag_value(&event, "visibility").unwrap_or_else(|| "open".to_string()); + let channel_type_str = + extract_tag_value(&event, "channel_type").unwrap_or_else(|| "stream".to_string()); let visibility: sprout_db::channel::ChannelVisibility = visibility_str .parse() @@ -1039,16 +1040,18 @@ pub async fn ingest_event( if let Some(client_uuid) = channel_id { let name = create_name.unwrap_or_default(); - let description = event.tags.iter().find_map(|t| { - if t.kind().to_string() == "about" { - t.content().map(|s| s.to_string()) + let description = extract_tag_value(&event, "about"); + + let ttl_seconds = super::resolve_ttl(&event, state.config.ephemeral_ttl_override); + + let project_id = event.tags.iter().find_map(|t| { + if t.kind().to_string() == "project" { + t.content().and_then(|s| s.parse::().ok()) } else { None } }); - let ttl_seconds = super::resolve_ttl(&event, state.config.ephemeral_ttl_override); - let actor_bytes = event.pubkey.serialize().to_vec(); let (_, was_created) = state .db @@ -1060,6 +1063,7 @@ pub async fn ingest_event( description.as_deref(), &actor_bytes, ttl_seconds, + project_id, ) .await .map_err(|e| IngestError::Internal(format!("error: {e}")))?; @@ -1075,6 +1079,65 @@ pub async fn ingest_event( } } + // ── 16b. kind:50001 project pre-creation ───────────────────────────── + if kind_u32 == KIND_PROJECT_CREATED { + let project_uuid = extract_d_tag_uuid(&event).ok_or_else(|| { + IngestError::Rejected( + "invalid: project event must include a d tag with valid UUID".into(), + ) + })?; + + let name = extract_tag_value(&event, "name") + .ok_or_else(|| IngestError::Rejected("invalid: project must have a name tag".into()))?; + + let environment = + extract_tag_value(&event, "environment").unwrap_or_else(|| "local".to_string()); + + if !matches!(environment.as_str(), "local" | "blox") { + return Err(IngestError::Rejected(format!( + "invalid: unknown environment {:?}", + environment + ))); + } + + let description = extract_tag_value(&event, "about"); + let prompt = extract_tag_value(&event, "prompt"); + let icon = extract_tag_value(&event, "icon"); + let color = extract_tag_value(&event, "color"); + let repo_urls: Vec = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("repo")) + .filter_map(|t| t.as_slice().get(1).map(|s| s.to_string())) + .collect(); + + let actor_bytes = event.pubkey.serialize().to_vec(); + let (_, was_created) = state + .db + .create_project_with_id( + project_uuid, + &name, + &environment, + description.as_deref(), + prompt.as_deref(), + icon.as_deref(), + color.as_deref(), + &serde_json::json!(repo_urls), + &actor_bytes, + ) + .await + .map_err(|e| IngestError::Internal(format!("error: {e}")))?; + + if !was_created { + return Ok(IngestResult { + event_id: event_id_hex, + accepted: false, + message: "duplicate: project already exists".into(), + }); + } + pre_created_project = Some(project_uuid); + } + // ── 17. kind:9021 open-only check ──────────────────────────────────── if kind_u32 == KIND_NIP29_JOIN_REQUEST { // A join without an h-tag is meaningless — reject early. @@ -1308,6 +1371,11 @@ pub async fn ingest_event( } state.invalidate_channel_deleted(); } + if let Some(proj_id) = pre_created_project { + if let Err(re) = state.db.soft_delete_project(proj_id).await { + warn!(event_id = %event_id_hex, "project compensation failed: {re}"); + } + } return Err(match e { sprout_db::DbError::AuthEventRejected => { IngestError::Rejected("invalid: AUTH events cannot be stored".into()) @@ -1623,6 +1691,71 @@ mod tests { .unwrap() } + // ── Project kind tests ────────────────────────────────────────────── + + #[test] + fn project_kinds_require_channels_write() { + let dummy = make_dummy_event(); + for kind in [ + KIND_PROJECT_CREATED, + KIND_PROJECT_UPDATED, + KIND_PROJECT_DELETED, + ] { + assert_eq!( + required_scope_for_kind(kind, &dummy).unwrap(), + Scope::ChannelsWrite, + "kind {kind} should require ChannelsWrite" + ); + } + } + + #[test] + fn project_kinds_are_global_only() { + for kind in [ + KIND_PROJECT_CREATED, + KIND_PROJECT_UPDATED, + KIND_PROJECT_DELETED, + ] { + assert!( + is_global_only_kind(kind), + "kind {kind} should be global-only" + ); + } + } + + #[test] + fn project_kinds_not_channel_scoped() { + for kind in [ + KIND_PROJECT_CREATED, + KIND_PROJECT_UPDATED, + KIND_PROJECT_DELETED, + ] { + assert!( + !requires_h_channel_scope(kind), + "kind {kind} should NOT require h-tag channel scope" + ); + } + } + + #[test] + fn extract_d_tag_uuid_valid() { + let id = uuid::Uuid::new_v4(); + let event = make_event_with_tags(50001, "", &[&["d", &id.to_string()]]); + assert_eq!(extract_d_tag_uuid(&event), Some(id)); + } + + #[test] + fn extract_d_tag_uuid_missing() { + let event = make_event_with_tags(50001, "", &[&["name", "test"]]); + assert_eq!(extract_d_tag_uuid(&event), None); + } + + #[test] + fn extract_d_tag_uuid_invalid() { + let event = make_event_with_tags(50001, "", &[&["d", "not-a-uuid"]]); + assert_eq!(extract_d_tag_uuid(&event), None); + } + #[test] fn count_e_tags_includes_malformed() { // A deletion event with one valid e-tag and one malformed e-tag diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs index 39a09932..6feda4ab 100644 --- a/crates/sprout-relay/src/handlers/side_effects.rs +++ b/crates/sprout-relay/src/handlers/side_effects.rs @@ -27,7 +27,7 @@ pub fn is_admin_kind(kind: u32) -> bool { /// handled in `ingest_event()` before storage so we can short-circuit on /// duplicates without storing the event at all. pub fn is_side_effect_kind(kind: u32) -> bool { - matches!(kind, 0 | 5 | 9000..=9022 | 41001..=41003 | 40099) + matches!(kind, 0 | 5 | 9000..=9022 | 41001..=41003 | 40099 | 50001..=50003) } async fn evict_live_channel_subscriptions( @@ -85,6 +85,9 @@ pub async fn handle_side_effects( } 9021 => handle_join_request(event, state).await, 9022 => handle_leave_request(event, state).await, + 50001 => handle_create_project(event, state).await, + 50002 => handle_update_project(event, state).await, + 50003 => handle_delete_project(event, state).await, // kind:7 (reaction) handled inline in ingest_event() before storage. _ => Ok(()), } @@ -1417,7 +1420,7 @@ fn extract_target_event_ids(event: &Event) -> Vec> { } /// Extract value of a named tag. -fn extract_tag_value(event: &Event, tag_name: &str) -> Option { +pub(crate) fn extract_tag_value(event: &Event, tag_name: &str) -> Option { for tag in event.tags.iter() { if tag.kind().to_string() == tag_name { return tag.content().map(|s| s.to_string()); @@ -1425,3 +1428,80 @@ fn extract_tag_value(event: &Event, tag_name: &str) -> Option { } None } + +// ── Project side-effect handlers ──────────────────────────────────────────── + +async fn handle_create_project(event: &Event, _state: &Arc) -> anyhow::Result<()> { + // Project was pre-created in ingest — nothing extra needed for Phase 1. + let project_id = + super::ingest::extract_d_tag_uuid(event).ok_or_else(|| anyhow::anyhow!("missing d tag"))?; + info!(project = %project_id, "project created"); + Ok(()) +} + +async fn handle_update_project(event: &Event, state: &Arc) -> anyhow::Result<()> { + let project_id = + super::ingest::extract_d_tag_uuid(event).ok_or_else(|| anyhow::anyhow!("missing d tag"))?; + + let name = extract_tag_value(event, "name"); + let description = extract_tag_value(event, "about"); + let prompt = extract_tag_value(event, "prompt"); + let icon = extract_tag_value(event, "icon"); + let color = extract_tag_value(event, "color"); + let environment = extract_tag_value(event, "environment"); + if let Some(ref env) = environment { + if !matches!(env.as_str(), "local" | "blox") { + return Err(anyhow::anyhow!("invalid environment: {env:?}")); + } + } + let repo_urls: Option = { + let repo_tags: Vec<_> = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("repo")) + .collect(); + if repo_tags.is_empty() { + // No repo tags at all → don't update the field + None + } else { + // Repo tags present → collect non-empty values; if all empty, clear to [] + let urls: Vec = repo_tags + .iter() + .filter_map(|t| { + t.as_slice() + .get(1) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + }) + .collect(); + Some(serde_json::json!(urls)) + } + }; + + state + .db + .update_project( + project_id, + sprout_db::project::ProjectUpdate { + name, + description, + prompt, + icon, + color, + environment, + repo_urls, + }, + ) + .await?; + + info!(project = %project_id, "project updated"); + Ok(()) +} + +async fn handle_delete_project(event: &Event, state: &Arc) -> anyhow::Result<()> { + let project_id = + super::ingest::extract_d_tag_uuid(event).ok_or_else(|| anyhow::anyhow!("missing d tag"))?; + state.db.soft_delete_project(project_id).await?; + info!(project = %project_id, "project deleted"); + Ok(()) +} diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index 169537e3..41f1b9a1 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -110,6 +110,13 @@ pub fn build_router(state: Arc) -> Router { "/huddle/{channel_id}/audio", get(audio::handler::ws_audio_handler), ) + // Project routes + .route("/api/projects", get(api::list_projects_handler)) + .route("/api/projects/{project_id}", get(api::get_project)) + .route( + "/api/projects/{project_id}/channels", + get(api::list_project_channels_handler), + ) // Membership routes .route("/api/channels/{channel_id}/members", get(api::list_members)) // Channel detail + metadata routes diff --git a/crates/sprout-sdk/src/builders.rs b/crates/sprout-sdk/src/builders.rs index a3d5e68e..b7bf03a6 100644 --- a/crates/sprout-sdk/src/builders.rs +++ b/crates/sprout-sdk/src/builders.rs @@ -426,6 +426,7 @@ pub fn build_create_channel( visibility: Option, channel_type: Option, about: Option<&str>, + project_id: Option, ) -> Result { let mut tags = vec![tag(&["h", &channel_id.to_string()])?, tag(&["name", name])?]; if let Some(v) = visibility { @@ -437,6 +438,9 @@ pub fn build_create_channel( if let Some(a) = about { tags.push(tag(&["about", a])?); } + if let Some(pid) = project_id { + tags.push(tag(&["project", &pid.to_string()])?); + } Ok(EventBuilder::new(Kind::Custom(9007), "", tags)) } @@ -673,6 +677,102 @@ pub fn build_huddle_ended( build_huddle_event_sdk(48103, parent_channel_id, ephemeral_channel_id, &[], None) } +// ── Builder 30: build_create_project ───────────────────────────────────────── + +/// Build a project creation event (kind 50001). +#[allow(clippy::too_many_arguments)] +pub fn build_create_project( + project_id: Uuid, + name: &str, + environment: Option<&str>, + description: Option<&str>, + prompt: Option<&str>, + icon: Option<&str>, + color: Option<&str>, + repo_urls: Option<&[&str]>, +) -> Result { + let mut tags = vec![tag(&["d", &project_id.to_string()])?, tag(&["name", name])?]; + if let Some(env) = environment { + tags.push(tag(&["environment", env])?); + } + if let Some(d) = description { + tags.push(tag(&["about", d])?); + } + if let Some(p) = prompt { + tags.push(tag(&["prompt", p])?); + } + if let Some(i) = icon { + tags.push(tag(&["icon", i])?); + } + if let Some(c) = color { + tags.push(tag(&["color", c])?); + } + if let Some(urls) = repo_urls { + for url in urls { + tags.push(tag(&["repo", url])?); + } + } + Ok(EventBuilder::new(Kind::Custom(50001), "", tags)) +} + +// ── Builder 31: build_update_project ───────────────────────────────────────── + +/// Build a project update event (kind 50002). +#[allow(clippy::too_many_arguments)] +pub fn build_update_project( + project_id: Uuid, + name: Option<&str>, + description: Option<&str>, + prompt: Option<&str>, + icon: Option<&str>, + color: Option<&str>, + environment: Option<&str>, + repo_urls: Option<&[&str]>, +) -> Result { + let mut tags = vec![tag(&["d", &project_id.to_string()])?]; + if let Some(n) = name { + tags.push(tag(&["name", n])?); + } + if let Some(d) = description { + tags.push(tag(&["about", d])?); + } + if let Some(p) = prompt { + tags.push(tag(&["prompt", p])?); + } + if let Some(i) = icon { + tags.push(tag(&["icon", i])?); + } + if let Some(c) = color { + tags.push(tag(&["color", c])?); + } + if let Some(env) = environment { + tags.push(tag(&["environment", env])?); + } + if let Some(urls) = repo_urls { + if urls.is_empty() { + tags.push(tag(&["repo", ""])?); + } else { + for url in urls { + tags.push(tag(&["repo", url])?); + } + } + } + if tags.len() == 1 { + return Err(SdkError::InvalidInput( + "update_project requires at least one field to update".into(), + )); + } + Ok(EventBuilder::new(Kind::Custom(50002), "", tags)) +} + +// ── Builder 32: build_delete_project ───────────────────────────────────────── + +/// Build a project deletion event (kind 50003). +pub fn build_delete_project(project_id: Uuid) -> Result { + let tags = vec![tag(&["d", &project_id.to_string()])?]; + Ok(EventBuilder::new(Kind::Custom(50003), "", tags)) +} + // ── Helper: extract_channel_id ─────────────────────────────────────────────── /// Extract the channel UUID from an event's `h` tag. @@ -1243,6 +1343,7 @@ mod tests { Some(Visibility::Open), Some(ChannelKind::Stream), Some("General chat"), + None, ) .unwrap(), ); @@ -1257,8 +1358,15 @@ mod tests { fn create_channel_minimal() { let cid = uuid(); let ev = sign( - build_create_channel(cid, "dev", None::, None::, None) - .unwrap(), + build_create_channel( + cid, + "dev", + None::, + None::, + None, + None, + ) + .unwrap(), ); assert_eq!(ev.kind.as_u16(), 9007); assert!(has_tag(&ev, "name", "dev")); @@ -1598,4 +1706,156 @@ mod tests { assert!(has_tag(&ev, "h", &parent.to_string())); assert!(!has_tag(&ev, "h", &ephemeral.to_string())); } + + // ── build_create_project ──────────────────────────────────────────────── + + #[test] + fn create_project_happy_path() { + let pid = uuid(); + let ev = sign( + build_create_project(pid, "my-project", None, None, None, None, None, None).unwrap(), + ); + assert_eq!(ev.kind.as_u16(), 50001); + assert!(has_tag(&ev, "d", &pid.to_string())); + assert!(has_tag(&ev, "name", "my-project")); + } + + #[test] + fn create_project_all_fields() { + let pid = uuid(); + let ev = sign( + build_create_project( + pid, + "full-project", + Some("blox"), + Some("A description"), + Some("You are a helpful agent"), + Some("rocket"), + Some("#ff0000"), + Some(&[ + "https://github.com/example/repo", + "https://github.com/example/other", + ]), + ) + .unwrap(), + ); + assert_eq!(ev.kind.as_u16(), 50001); + assert!(has_tag(&ev, "d", &pid.to_string())); + assert!(has_tag(&ev, "name", "full-project")); + assert!(has_tag(&ev, "environment", "blox")); + assert!(has_tag(&ev, "about", "A description")); + assert!(has_tag(&ev, "prompt", "You are a helpful agent")); + assert!(has_tag(&ev, "icon", "rocket")); + assert!(has_tag(&ev, "color", "#ff0000")); + let repos = tag_values(&ev, "repo"); + assert_eq!(repos.len(), 2); + assert!(repos.contains(&"https://github.com/example/repo".to_string())); + assert!(repos.contains(&"https://github.com/example/other".to_string())); + } + + #[test] + fn create_project_no_repos() { + let pid = uuid(); + let ev = sign( + build_create_project(pid, "no-repos", None, None, None, None, None, None).unwrap(), + ); + let repos = tag_values(&ev, "repo"); + assert!(repos.is_empty()); + } + + // ── build_update_project ──────────────────────────────────────────────── + + #[test] + fn update_project_partial() { + let pid = uuid(); + let ev = sign( + build_update_project( + pid, + Some("new-name"), + None, + Some("new prompt"), + None, + None, + None, + None, + ) + .unwrap(), + ); + assert_eq!(ev.kind.as_u16(), 50002); + assert!(has_tag(&ev, "d", &pid.to_string())); + assert!(has_tag(&ev, "name", "new-name")); + assert!(has_tag(&ev, "prompt", "new prompt")); + // Fields not set should not be present + assert!(tag_values(&ev, "about").is_empty()); + assert!(tag_values(&ev, "icon").is_empty()); + assert!(tag_values(&ev, "color").is_empty()); + assert!(tag_values(&ev, "environment").is_empty()); + assert!(tag_values(&ev, "repo").is_empty()); + } + + #[test] + fn update_project_clear_repos() { + let pid = uuid(); + let ev = sign( + build_update_project(pid, Some("x"), None, None, None, None, None, Some(&[])).unwrap(), + ); + // Sentinel tag should be emitted + assert!(has_tag(&ev, "repo", "")); + } + + #[test] + fn update_project_no_fields_rejected() { + let pid = uuid(); + let result = build_update_project(pid, None, None, None, None, None, None, None); + assert!(matches!(result, Err(SdkError::InvalidInput(_)))); + } + + // ── build_delete_project ──────────────────────────────────────────────── + + #[test] + fn delete_project_happy_path() { + let pid = uuid(); + let ev = sign(build_delete_project(pid).unwrap()); + assert_eq!(ev.kind.as_u16(), 50003); + assert!(has_tag(&ev, "d", &pid.to_string())); + // Only the d-tag should be present + assert_eq!(ev.tags.len(), 1); + } + + // ── build_create_channel with project_id ──────────────────────────────── + + #[test] + fn create_channel_with_project() { + let cid = uuid(); + let pid = uuid(); + let ev = sign( + build_create_channel( + cid, + "proj-chan", + None::, + None::, + None, + Some(pid), + ) + .unwrap(), + ); + assert!(has_tag(&ev, "project", &pid.to_string())); + } + + #[test] + fn create_channel_without_project() { + let cid = uuid(); + let ev = sign( + build_create_channel( + cid, + "no-proj", + None::, + None::, + None, + None, + ) + .unwrap(), + ); + assert!(tag_values(&ev, "project").is_empty()); + } } diff --git a/crates/sprout-sdk/src/lib.rs b/crates/sprout-sdk/src/lib.rs index 1b94b2be..1243c1e7 100644 --- a/crates/sprout-sdk/src/lib.rs +++ b/crates/sprout-sdk/src/lib.rs @@ -73,6 +73,8 @@ pub use sprout_core::channel::ChannelType as ChannelKind; pub use sprout_core::channel::ChannelVisibility as Visibility; /// Member role. pub use sprout_core::channel::MemberRole; +/// Project environment. +pub use sprout_core::project::ProjectEnvironment; // ── Error ──────────────────────────────────────────────────────────────────── diff --git a/schema/schema.sql b/schema/schema.sql index a7503839..9450f92a 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -326,3 +326,29 @@ CREATE TABLE pubkey_allowlist ( added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), note TEXT ); + +-- ── Projects ──────────────────────────────────────────────────────────────── + +CREATE TABLE projects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + description TEXT, + prompt TEXT, + icon VARCHAR(64), + color VARCHAR(16), + environment VARCHAR(32) NOT NULL DEFAULT 'local', + repo_urls JSONB DEFAULT '[]'::jsonb, + created_by BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + archived_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + CONSTRAINT projects_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid) +); + +CREATE INDEX idx_projects_created_by ON projects(created_by); +CREATE INDEX idx_projects_deleted_at ON projects(deleted_at) WHERE deleted_at IS NULL; + +-- Add project_id FK to channels table +ALTER TABLE channels ADD COLUMN project_id UUID REFERENCES projects(id); +CREATE INDEX idx_channels_project_id ON channels(project_id) WHERE project_id IS NOT NULL;