Skip to content
Open
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
72 changes: 71 additions & 1 deletion desktop/src-tauri/src/commands/channel_templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ use crate::{
app_state::AppState,
templates::{
load_channel_templates, save_channel_templates, validate_channel_template_deletion,
ChannelTemplateRecord, CreateChannelTemplateRequest, UpdateChannelTemplateRequest,
ChannelTemplateRecord, CreateChannelTemplateRequest, TemplateWorktreeConfig,
UpdateChannelTemplateRequest,
},
util::now_iso,
};
Expand All @@ -25,6 +26,33 @@ fn trim_optional(value: Option<String>) -> Option<String> {
})
}

fn normalize_project_folders(
project_folders: Vec<String>,
project_folder: Option<String>,
) -> Vec<String> {
let mut normalized = Vec::new();
for candidate in project_folders.into_iter().chain(project_folder) {
let trimmed = candidate.trim();
if !trimmed.is_empty() && !normalized.iter().any(|folder| folder == trimmed) {
normalized.push(trimmed.to_string());
}
}
normalized
}

fn normalize_worktree(
worktree: Option<TemplateWorktreeConfig>,
) -> Result<Option<TemplateWorktreeConfig>, String> {
worktree
.map(|worktree| {
Ok(TemplateWorktreeConfig {
location: trim_required(&worktree.location, "Worktree location")?,
base_branch: trim_required(&worktree.base_branch, "Base branch")?,
})
})
.transpose()
}

fn validate_channel_type(value: &str) -> Result<(), String> {
match value {
"stream" | "forum" => Ok(()),
Expand All @@ -43,6 +71,34 @@ fn validate_visibility(value: &str) -> Result<(), String> {
}
}

#[tauri::command]
/// Open a native multi-folder picker for channel-template project folders.
///
/// Returns an empty list when the user cancels. Returns an error if the dialog
/// closes unexpectedly or any selected folder cannot be represented as a local
/// filesystem path.
pub async fn pick_channel_template_project_folder(app: AppHandle) -> Result<Vec<String>, String> {
Comment thread
klopez4212 marked this conversation as resolved.
use tauri_plugin_dialog::DialogExt;

let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog().file().pick_folders(move |paths| {
let _ = tx.send(paths);
});

let Some(folder_paths) = rx.await.map_err(|_| "dialog cancelled".to_string())? else {
return Ok(Vec::new());
};
folder_paths
.into_iter()
.map(|folder_path| {
folder_path
.as_path()
.map(|path| path.to_string_lossy().into_owned())
.ok_or_else(|| "Folder picker returned an invalid path".to_string())
})
.collect()
}

#[tauri::command]
pub async fn list_channel_templates(app: AppHandle) -> Result<Vec<ChannelTemplateRecord>, String> {
tokio::task::spawn_blocking(move || {
Expand All @@ -66,6 +122,10 @@ pub async fn create_channel_template(
let name = trim_required(&input.name, "Template name")?;
let description = trim_optional(input.description);
let canvas_template = trim_optional(input.canvas_template);
let project_folders =
normalize_project_folders(input.project_folders, input.project_folder);
let project_folder = project_folders.first().cloned();
let worktree = normalize_worktree(input.worktree)?;
let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string());
let visibility = input.visibility.unwrap_or_else(|| "open".to_string());
validate_channel_type(&channel_type)?;
Expand All @@ -86,6 +146,9 @@ pub async fn create_channel_template(
channel_type,
visibility,
canvas_template,
project_folders,
project_folder,
worktree,
agents: input.agents,
is_builtin: false,
created_at: now.clone(),
Expand All @@ -109,6 +172,10 @@ pub async fn update_channel_template(
let name = trim_required(&input.name, "Template name")?;
let description = trim_optional(input.description);
let canvas_template = trim_optional(input.canvas_template);
let project_folders =
normalize_project_folders(input.project_folders, input.project_folder);
let project_folder = project_folders.first().cloned();
let worktree = normalize_worktree(input.worktree)?;
let channel_type = input.channel_type.unwrap_or_else(|| "stream".to_string());
let visibility = input.visibility.unwrap_or_else(|| "open".to_string());
validate_channel_type(&channel_type)?;
Expand All @@ -130,6 +197,9 @@ pub async fn update_channel_template(
template.channel_type = channel_type;
template.visibility = visibility;
template.canvas_template = canvas_template;
template.project_folders = project_folders;
template.project_folder = project_folder;
template.worktree = worktree;
template.agents = input.agents;
template.updated_at = now_iso();

Expand Down
14 changes: 10 additions & 4 deletions desktop/src-tauri/src/commands/team_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,8 @@ pub(crate) fn build_import_team(
persona_ids: Vec<String>,
now: &str,
) -> Result<TeamRecord, String> {
let name = snapshot.team.name.trim();
if name.is_empty() {
return Err("Team snapshot name is empty.".to_string());
}
let name = crate::managed_agents::validate_team_name(&snapshot.team.name)
.map_err(|error| format!("Invalid team snapshot: {error}"))?;

Ok(TeamRecord {
id: Uuid::new_v4().to_string(),
Expand All @@ -184,8 +182,12 @@ pub(crate) fn build_import_team(
fn member_preview(member: &AgentSnapshot) -> TeamSnapshotMemberPreview {
TeamSnapshotMemberPreview {
display_name: member.profile.display_name.clone(),
summary: member.profile.about.clone(),
system_prompt: member.definition.system_prompt.clone(),
avatar_url: effective_avatar(member),
is_built_in: member.definition.source_is_builtin,
model: member.definition.model.clone(),
runtime: member.definition.runtime.clone(),
has_source_allowlist: !member.definition.respond_to_allowlist.is_empty(),
source_allowlist_count: member.definition.respond_to_allowlist.len(),
}
Expand All @@ -196,8 +198,12 @@ fn member_preview(member: &AgentSnapshot) -> TeamSnapshotMemberPreview {
#[serde(rename_all = "camelCase")]
pub struct TeamSnapshotMemberPreview {
pub display_name: String,
pub summary: Option<String>,
pub system_prompt: Option<String>,
pub avatar_url: Option<String>,
pub is_built_in: bool,
pub model: Option<String>,
pub runtime: Option<String>,
pub has_source_allowlist: bool,
pub source_allowlist_count: usize,
}
Expand Down
11 changes: 11 additions & 0 deletions desktop/src-tauri/src/commands/team_snapshot/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,17 @@ fn team_import_definitions_are_built_for_all_members() {
assert_eq!(definitions[0].system_prompt, "Alice prompt");
}

#[test]
fn team_import_rejects_names_that_cannot_be_mentioned() {
for invalid_name in ["x".repeat(201), "Review\nTeam".to_string()] {
let mut source = snapshot(vec![member("Alice")]);
source.team.name = invalid_name;

let error = build_import_team(&source, vec!["alice".to_string()], "now").unwrap_err();
assert!(error.starts_with("Invalid team snapshot: Team name"));
}
}

#[test]
fn team_import_keeps_or_clears_every_member_allowlist_with_one_toggle() {
let source = snapshot(vec![member("Alice"), member("Bob")]);
Expand Down
15 changes: 4 additions & 11 deletions desktop/src-tauri/src/commands/teams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,12 @@ use crate::{
app_state::AppState,
managed_agents::{
delete_team_with_cascade, ensure_persona_ids_are_active, load_personas, load_teams,
save_teams, try_regenerate_nest, CreateTeamRequest, TeamRecord, UpdateTeamRequest,
save_teams, try_regenerate_nest, validate_team_name, CreateTeamRequest, TeamRecord,
UpdateTeamRequest,
},
util::now_iso,
};

fn trim_required(value: &str, label: &str) -> Result<String, String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(format!("{label} is required"));
}
Ok(trimmed.to_string())
}

fn trim_optional(value: Option<String>) -> Option<String> {
value.and_then(|candidate| {
let trimmed = candidate.trim();
Expand Down Expand Up @@ -145,7 +138,7 @@ pub async fn create_team(input: CreateTeamRequest, app: AppHandle) -> Result<Tea
use tauri::Manager;
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
let name = trim_required(&input.name, "Team name")?;
let name = validate_team_name(&input.name)?;
let description = trim_optional(input.description);
let instructions = trim_optional(input.instructions);
let now = now_iso();
Expand Down Expand Up @@ -186,7 +179,7 @@ pub async fn update_team(input: UpdateTeamRequest, app: AppHandle) -> Result<Tea
use tauri::Manager;
tokio::task::spawn_blocking(move || {
let state = app.state::<AppState>();
let name = trim_required(&input.name, "Team name")?;
let name = validate_team_name(&input.name)?;
let description = trim_optional(input.description);
let instructions = trim_optional(input.instructions);

Expand Down
16 changes: 5 additions & 11 deletions desktop/src-tauri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
//!
//! Each function validates inputs and returns a nostr::EventBuilder.
//! Signing and submission happen in relay::submit_event.
mod mention_reference_tags;

use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST};
use nostr::{EventBuilder, EventId, Kind, Tag};
use uuid::Uuid;
Expand Down Expand Up @@ -76,17 +78,9 @@ fn mention_tags(mentions: &[&str]) -> Result<Vec<Tag>, String> {

fn mention_reference_tags(mentions: &[Vec<String>], tags: &mut Vec<Tag>) -> Result<(), String> {
for mention in mentions {
if mention.first().map(String::as_str) != Some("mention") {
return Err(format!(
"mention reference tags must use 'mention' prefix (got {:?})",
mention.first()
));
}
let Some(pubkey) = mention.get(1) else {
return Err("mention reference tag missing pubkey".into());
};
check_pubkey(pubkey)?;
tags.push(tag(vec!["mention", &pubkey.to_ascii_lowercase()])?);
tags.push(mention_reference_tags::parse_mention_reference_tag(
mention,
)?);
}
Ok(())
}
Expand Down
84 changes: 84 additions & 0 deletions desktop/src-tauri/src/events/mention_reference_tags.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
use crate::managed_agents::MAX_TEAM_NAME_CHARS;
use nostr::Tag;

const MAX_TEAM_MENTION_ID_CHARS: usize = 200;

pub(super) fn parse_mention_reference_tag(mention: &[String]) -> Result<Tag, String> {
match mention.first().map(String::as_str) {
Some("mention") => parse_person_mention(mention),
Some("team_mention") => parse_team_mention(mention),
prefix => Err(format!(
"mention reference tags must use 'mention' or 'team_mention' prefix (got {prefix:?})"
)),
}
}

fn parse_person_mention(mention: &[String]) -> Result<Tag, String> {
if mention.len() != 2 {
return Err("mention reference tag must contain exactly one pubkey".into());
}
let pubkey = &mention[1];
if pubkey.len() != 64
|| !pubkey
.chars()
.all(|character| character.is_ascii_hexdigit())
{
return Err(format!(
"pubkey must be a 64-character hex string (got {} chars)",
pubkey.len()
));
}
Tag::parse(["mention", &pubkey.to_ascii_lowercase()])
.map_err(|error| format!("invalid tag: {error}"))
}

fn parse_team_mention(mention: &[String]) -> Result<Tag, String> {
if mention.len() != 3 {
return Err("team mention tag must contain exactly a team id and display name".into());
}
let team_id = mention[1].trim();
let display_name = mention[2].trim();
if team_id.is_empty() || display_name.is_empty() {
return Err("team mention id and display name must not be blank".into());
}
if team_id.chars().count() > MAX_TEAM_MENTION_ID_CHARS
|| display_name.chars().count() > MAX_TEAM_NAME_CHARS
{
return Err("team mention id and display name must be at most 200 characters".into());
}
if team_id.chars().any(char::is_control) || display_name.chars().any(char::is_control) {
return Err("team mention id and display name must not contain controls".into());
}
Tag::parse(["team_mention", team_id, display_name])
.map_err(|error| format!("invalid tag: {error}"))
}

#[cfg(test)]
mod tests {
use super::parse_mention_reference_tag;

#[test]
fn preserves_visible_team_chip_metadata() {
let tag = parse_mention_reference_tag(&[
"team_mention".into(),
"team-launch".into(),
"Launch Team".into(),
])
.unwrap();
assert_eq!(
tag.as_slice(),
&["team_mention", "team-launch", "Launch Team"]
);
}

#[test]
fn rejects_extra_team_tag_fields() {
assert!(parse_mention_reference_tag(&[
"team_mention".into(),
"team-launch".into(),
"Launch Team".into(),
"forged".into(),
])
.is_err());
}
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,7 @@ pub fn run() {
set_persona_shared,
reconcile_inbound_persona_event,
list_channel_templates,
pick_channel_template_project_folder,
create_channel_template,
update_channel_template,
delete_channel_template,
Expand Down
18 changes: 14 additions & 4 deletions desktop/src-tauri/src/managed_agents/team_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,8 @@ pub(crate) fn validate_team_snapshot(snapshot: &TeamSnapshot) -> Result<(), Stri
snapshot.version
));
}
if snapshot.team.name.trim().is_empty() {
return Err("Team snapshot team.name is empty".to_string());
}
crate::managed_agents::validate_team_name(&snapshot.team.name)
.map_err(|error| format!("Invalid team snapshot: {error}"))?;
if snapshot.members.is_empty() {
return Err("Team snapshot must have at least one member".to_string());
}
Expand Down Expand Up @@ -423,7 +422,18 @@ mod tests {
let bytes = serde_json::to_vec(&snapshot).unwrap();
let result = decode_team_snapshot_json(&bytes);
assert!(result.is_err());
assert!(result.unwrap_err().contains("team.name is empty"));
assert!(result.unwrap_err().contains("Team name is required"));
}

#[test]
fn validate_rejects_team_name_that_cannot_be_mentioned() {
for invalid_name in ["x".repeat(201), "Review\nTeam".to_string()] {
let mut snapshot = two_member_team();
snapshot.team.name = invalid_name;
let bytes = serde_json::to_vec(&snapshot).unwrap();
let error = decode_team_snapshot_json(&bytes).unwrap_err();
assert!(error.starts_with("Invalid team snapshot: Team name"));
}
}

#[test]
Expand Down
Loading
Loading