mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(cli): add channels create --template for desktop channel templates (#1990)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -892,6 +892,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"diffy",
|
||||
"dirs",
|
||||
"hex",
|
||||
"infer",
|
||||
"nostr",
|
||||
@@ -899,6 +900,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"url",
|
||||
|
||||
@@ -69,5 +69,13 @@ url = { workspace = true }
|
||||
# Persona pack parsing, validation, and resolution
|
||||
buzz-persona = { path = "../buzz-persona" }
|
||||
|
||||
# Platform app-data dir resolution — locates the desktop app's
|
||||
# channel-templates.json store for `channels create --template`
|
||||
dirs = "6"
|
||||
|
||||
# WebSocket client — ephemeral event publish (kind:20001 is WS-only on the relay)
|
||||
buzz-ws-client = { path = "../buzz-ws-client" }
|
||||
|
||||
[dev-dependencies]
|
||||
# Scratch files for channel-templates.json fixtures in tests
|
||||
tempfile = "3"
|
||||
|
||||
195
crates/buzz-cli/src/commands/channel_templates.rs
Normal file
195
crates/buzz-cli/src/commands/channel_templates.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
//! Desktop-local channel template loading for `buzz channels create --template`.
|
||||
//!
|
||||
//! Templates live in a JSON file the desktop app owns
|
||||
//! (`<app-data>/templates/channel-templates.json`); this module duplicates the
|
||||
//! wire shape (`desktop/src-tauri/src/templates/types.rs`) rather than sharing
|
||||
//! a crate, since buzz-cli and desktop-tauri are independent crates and the
|
||||
//! shape is small and stable. Only the fields the CLI needs to read are kept.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::CliError;
|
||||
|
||||
/// Tauri bundle identifier for the production desktop app. `dirs::data_dir()`
|
||||
/// joined with this segment matches `app.path().app_data_dir()` exactly
|
||||
/// (Tauri resolves app-data as the platform data dir plus the identifier).
|
||||
const PROD_BUNDLE_IDENTIFIER: &str = "xyz.block.buzz.app";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ChannelTemplateRecord {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
#[serde(default = "default_channel_type")]
|
||||
pub channel_type: String,
|
||||
#[serde(default = "default_visibility")]
|
||||
pub visibility: String,
|
||||
#[serde(default)]
|
||||
pub canvas_template: Option<String>,
|
||||
#[serde(default)]
|
||||
pub agents: TemplateAgentRoster,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TemplateAgentRoster {
|
||||
#[serde(default)]
|
||||
pub personas: Vec<TemplateAgentEntry>,
|
||||
#[serde(default)]
|
||||
pub teams: Vec<TemplateTeamEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TemplateAgentEntry {
|
||||
pub persona_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TemplateTeamEntry {
|
||||
pub team_id: String,
|
||||
}
|
||||
|
||||
fn default_channel_type() -> String {
|
||||
"stream".to_string()
|
||||
}
|
||||
|
||||
fn default_visibility() -> String {
|
||||
"open".to_string()
|
||||
}
|
||||
|
||||
/// Resolve the desktop app's `channel-templates.json` path.
|
||||
///
|
||||
/// `override_path` (from `--templates-file`) always wins — useful for the dev
|
||||
/// store or tests. Otherwise defaults to the prod bundle's app-data dir:
|
||||
/// `<platform-data-dir>/xyz.block.buzz.app/templates/channel-templates.json`.
|
||||
pub fn resolve_templates_path(override_path: Option<&str>) -> Result<PathBuf, CliError> {
|
||||
if let Some(p) = override_path {
|
||||
return Ok(PathBuf::from(p));
|
||||
}
|
||||
let data_dir = dirs::data_dir().ok_or_else(|| {
|
||||
CliError::Other("could not resolve platform app-data directory".to_string())
|
||||
})?;
|
||||
Ok(data_dir
|
||||
.join(PROD_BUNDLE_IDENTIFIER)
|
||||
.join("templates")
|
||||
.join("channel-templates.json"))
|
||||
}
|
||||
|
||||
/// Load and parse the channel-templates store from `path`.
|
||||
fn load_templates(path: &Path) -> Result<Vec<ChannelTemplateRecord>, CliError> {
|
||||
if !path.exists() {
|
||||
return Err(CliError::NotFound(format!(
|
||||
"no channel templates store found at {} (create a template in Buzz Desktop first, \
|
||||
or pass --templates-file)",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| CliError::Other(format!("failed to read {}: {e}", path.display())))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| CliError::Other(format!("failed to parse {}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// Load the templates store and find the template matching `name`
|
||||
/// (case-insensitive, exact match). Errors list available names if not found.
|
||||
pub fn find_template(path: &Path, name: &str) -> Result<ChannelTemplateRecord, CliError> {
|
||||
let templates = load_templates(path)?;
|
||||
let needle = name.to_ascii_lowercase();
|
||||
if let Some(t) = templates
|
||||
.into_iter()
|
||||
.find(|t| t.name.to_ascii_lowercase() == needle)
|
||||
{
|
||||
return Ok(t);
|
||||
}
|
||||
Err(CliError::NotFound(format!(
|
||||
"no channel template named '{name}' (available: {})",
|
||||
available_names(path)?
|
||||
)))
|
||||
}
|
||||
|
||||
fn available_names(path: &Path) -> Result<String, CliError> {
|
||||
let templates = load_templates(path)?;
|
||||
if templates.is_empty() {
|
||||
return Ok("<none>".to_string());
|
||||
}
|
||||
Ok(templates
|
||||
.iter()
|
||||
.map(|t| t.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn write_store(json: &str) -> tempfile::NamedTempFile {
|
||||
let mut f = tempfile::NamedTempFile::new().expect("tempfile");
|
||||
f.write_all(json.as_bytes()).expect("write");
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_templates_path_honors_override() {
|
||||
let path = resolve_templates_path(Some("/tmp/custom.json")).unwrap();
|
||||
assert_eq!(path, PathBuf::from("/tmp/custom.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_templates_path_defaults_to_prod_bundle() {
|
||||
let path = resolve_templates_path(None).unwrap();
|
||||
assert!(path.ends_with("xyz.block.buzz.app/templates/channel-templates.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_template_matches_case_insensitive() {
|
||||
let f = write_store(r#"[{"id":"t1","name":"Buzz Team","createdAt":"x","updatedAt":"x"}]"#);
|
||||
let t = find_template(f.path(), "buzz team").expect("found");
|
||||
assert_eq!(t.name, "Buzz Team");
|
||||
assert_eq!(t.channel_type, "stream");
|
||||
assert_eq!(t.visibility, "open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_template_not_found_lists_available_names() {
|
||||
let f = write_store(
|
||||
r#"[{"id":"t1","name":"Buzz Team","createdAt":"x","updatedAt":"x"},
|
||||
{"id":"t2","name":"Standup","createdAt":"x","updatedAt":"x"}]"#,
|
||||
);
|
||||
let err = find_template(f.path(), "nope").unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("Buzz Team"));
|
||||
assert!(msg.contains("Standup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_template_missing_store_is_not_found() {
|
||||
let err = find_template(Path::new("/nonexistent/channel-templates.json"), "x").unwrap_err();
|
||||
assert!(matches!(err, CliError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_templates_parses_full_roster() {
|
||||
let f = write_store(
|
||||
r##"[{
|
||||
"id":"t1","name":"Buzz Team","channel_type":"forum","visibility":"private",
|
||||
"canvas_template":"# {channel.name}",
|
||||
"agents":{"personas":[{"personaId":"builtin:fizz"}],"teams":[{"teamId":"team-1"}]},
|
||||
"created_at":"x","updated_at":"x"
|
||||
}]"##,
|
||||
);
|
||||
let t = find_template(f.path(), "Buzz Team").expect("found");
|
||||
assert_eq!(t.channel_type, "forum");
|
||||
assert_eq!(t.visibility, "private");
|
||||
assert_eq!(t.canvas_template.as_deref(), Some("# {channel.name}"));
|
||||
assert_eq!(t.agents.personas.len(), 1);
|
||||
assert_eq!(t.agents.personas[0].persona_id, "builtin:fizz");
|
||||
assert_eq!(t.agents.teams.len(), 1);
|
||||
assert_eq!(t.agents.teams[0].team_id, "team-1");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use buzz_core::kind::{KIND_MANAGED_AGENT, KIND_TEAM};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::client::{
|
||||
extract_d_tag, extract_p_tags, extract_tag_value, normalize_write_response,
|
||||
print_create_response, BuzzClient,
|
||||
};
|
||||
use crate::commands::channel_templates::{self, ChannelTemplateRecord, TemplateAgentRoster};
|
||||
use crate::error::CliError;
|
||||
use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid};
|
||||
|
||||
@@ -332,6 +337,366 @@ pub async fn cmd_create_channel(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Relay-side cap on a single historical query (`buzz-relay/src/handlers/req.rs`).
|
||||
/// Never request above this — a clamped response compared against a larger
|
||||
/// requested size would falsely look like "no more pages."
|
||||
const RELAY_MAX_HISTORICAL_LIMIT: u32 = 2000;
|
||||
|
||||
/// One page of a keyset-paginated kind:30177 scan.
|
||||
const MANAGED_AGENT_PAGE_SIZE: u32 = RELAY_MAX_HISTORICAL_LIMIT;
|
||||
|
||||
/// A resolved live managed-agent instance backing a template persona slug.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ResolvedAgent {
|
||||
persona_id: String,
|
||||
pubkey: String,
|
||||
}
|
||||
|
||||
/// Minimal projection of a kind:30177 event's content needed for roster
|
||||
/// resolution. Other fields (system_prompt, model, ...) are irrelevant here.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ManagedAgentContent {
|
||||
#[serde(default)]
|
||||
persona_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Outcome of resolving a template's roster against the relay, before any
|
||||
/// channel-creation side effects. `skipped` (zero live instances) and
|
||||
/// cardinality errors are both known at this point — resolution happens
|
||||
/// entirely before channel creation so ambiguity aborts with zero side effects.
|
||||
#[derive(Debug)]
|
||||
struct ResolvedRoster {
|
||||
/// Exactly one live instance per persona slug — safe to add.
|
||||
agents: Vec<ResolvedAgent>,
|
||||
/// Persona slugs with no live kind:30177 instance for the effective
|
||||
/// owner (cold-start provisioning is desktop-only, out of scope here).
|
||||
skipped: Vec<String>,
|
||||
}
|
||||
|
||||
/// Fetch kind:30176 (team) events authored by `owner` with `#d = [team_id]`
|
||||
/// and return the team's persona slugs. Absent `persona_ids` (publisher
|
||||
/// predates always-publish, or no matching event) resolves to an empty slug
|
||||
/// set — the CLI reads a single relay snapshot, not a local reconciled
|
||||
/// merge, so "unknown" here is indistinguishable from "empty."
|
||||
async fn fetch_team_persona_slugs(
|
||||
client: &BuzzClient,
|
||||
owner: &str,
|
||||
team_id: &str,
|
||||
) -> Result<Vec<String>, CliError> {
|
||||
let filter = serde_json::json!({
|
||||
"kinds": [KIND_TEAM],
|
||||
"authors": [owner],
|
||||
"#d": [team_id],
|
||||
"limit": 1,
|
||||
});
|
||||
let raw = client.query(&filter).await?;
|
||||
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
|
||||
.map_err(|e| CliError::Other(format!("failed to parse team query response: {e}")))?;
|
||||
let Some(event) = events.first() else {
|
||||
return Err(CliError::NotFound(format!(
|
||||
"team '{team_id}' not found for effective owner {owner}"
|
||||
)));
|
||||
};
|
||||
let content: serde_json::Value = event
|
||||
.get("content")
|
||||
.and_then(|c| c.as_str())
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
let slugs = content
|
||||
.get("persona_ids")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(slugs)
|
||||
}
|
||||
|
||||
/// Scan all kind:30177 (managed-agent) events authored by `owner`, keyset-
|
||||
/// paginated (`until` + `before_id`, never `page`/offset — 30177 is
|
||||
/// parameterized-replaceable and offset drift can silently skip a live
|
||||
/// instance across requests). Returns every event whose `content.persona_id`
|
||||
/// is in `slugs`, keyed by the event's `d` tag (the agent pubkey).
|
||||
async fn scan_managed_agents_by_owner(
|
||||
client: &BuzzClient,
|
||||
owner: &str,
|
||||
slugs: &HashSet<&str>,
|
||||
) -> Result<Vec<ResolvedAgent>, CliError> {
|
||||
let mut found: Vec<ResolvedAgent> = Vec::new();
|
||||
let mut seen_event_ids: HashSet<String> = HashSet::new();
|
||||
let mut cursor: Option<(i64, String)> = None;
|
||||
|
||||
loop {
|
||||
let mut filter = serde_json::json!({
|
||||
"kinds": [KIND_MANAGED_AGENT],
|
||||
"authors": [owner],
|
||||
"limit": MANAGED_AGENT_PAGE_SIZE,
|
||||
});
|
||||
if let Some((until, ref before_id)) = cursor {
|
||||
filter["until"] = serde_json::json!(until);
|
||||
filter["before_id"] = serde_json::json!(before_id);
|
||||
}
|
||||
|
||||
let raw = client.query(&filter).await?;
|
||||
let events: Vec<serde_json::Value> = serde_json::from_str(&raw).map_err(|e| {
|
||||
CliError::Other(format!("failed to parse managed-agent query response: {e}"))
|
||||
})?;
|
||||
|
||||
let page_len = events.len();
|
||||
for event in &events {
|
||||
let Some(event_id) = event.get("id").and_then(|v| v.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !seen_event_ids.insert(event_id.to_string()) {
|
||||
continue;
|
||||
}
|
||||
let pubkey = extract_d_tag(event);
|
||||
if pubkey.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let content: ManagedAgentContent = event
|
||||
.get("content")
|
||||
.and_then(|c| c.as_str())
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or(ManagedAgentContent { persona_id: None });
|
||||
let Some(persona_id) = content.persona_id else {
|
||||
continue;
|
||||
};
|
||||
if slugs.contains(persona_id.as_str()) {
|
||||
found.push(ResolvedAgent { persona_id, pubkey });
|
||||
}
|
||||
}
|
||||
|
||||
if (page_len as u32) < MANAGED_AGENT_PAGE_SIZE {
|
||||
break;
|
||||
}
|
||||
let Some(last) = events.last() else { break };
|
||||
let Some(created_at) = last.get("created_at").and_then(|v| v.as_i64()) else {
|
||||
break;
|
||||
};
|
||||
let Some(last_id) = last.get("id").and_then(|v| v.as_str()) else {
|
||||
break;
|
||||
};
|
||||
cursor = Some((created_at, last_id.to_string()));
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Apply the F4 cardinality rule per persona slug: zero live instances is a
|
||||
/// known skip (cold-start provisioning is desktop-only, out of scope), one is
|
||||
/// added, more than one is a hard error listing candidate pubkeys — matching
|
||||
/// all instances silently would risk adding a stale or wrong instance. Pure
|
||||
/// and independent of the relay so it's directly unit-testable.
|
||||
fn apply_cardinality_rule(
|
||||
slugs: &[String],
|
||||
found: &[ResolvedAgent],
|
||||
) -> Result<ResolvedRoster, CliError> {
|
||||
let mut agents = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for slug in slugs {
|
||||
let matches: Vec<&ResolvedAgent> = found.iter().filter(|a| &a.persona_id == slug).collect();
|
||||
match matches.as_slice() {
|
||||
[] => skipped.push(slug.clone()),
|
||||
[one] => agents.push((*one).clone()),
|
||||
many => {
|
||||
let candidates: Vec<&str> = many.iter().map(|a| a.pubkey.as_str()).collect();
|
||||
return Err(CliError::Usage(format!(
|
||||
"persona '{slug}' has {} live instances for this owner ({}); \
|
||||
pass a template with a single instance per persona, or resolve \
|
||||
the duplicate in Buzz Desktop before creating the channel",
|
||||
many.len(),
|
||||
candidates.join(", ")
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ResolvedRoster { agents, skipped })
|
||||
}
|
||||
|
||||
/// Resolve a template's roster against the relay: expand team entries into
|
||||
/// persona slugs (via kind:30176), scan for live kind:30177 instances scoped
|
||||
/// to the effective owner, and apply the cardinality rule per slug. Runs
|
||||
/// entirely before any channel-creation side effect — a cardinality error
|
||||
/// aborts with nothing created.
|
||||
async fn resolve_template_roster(
|
||||
client: &BuzzClient,
|
||||
owner: &str,
|
||||
roster: &TemplateAgentRoster,
|
||||
) -> Result<ResolvedRoster, CliError> {
|
||||
let mut slugs: Vec<String> = Vec::new();
|
||||
for entry in &roster.personas {
|
||||
if !slugs.contains(&entry.persona_id) {
|
||||
slugs.push(entry.persona_id.clone());
|
||||
}
|
||||
}
|
||||
for team in &roster.teams {
|
||||
let team_slugs = fetch_team_persona_slugs(client, owner, &team.team_id).await?;
|
||||
for slug in team_slugs {
|
||||
if !slugs.contains(&slug) {
|
||||
slugs.push(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if slugs.is_empty() {
|
||||
return Ok(ResolvedRoster {
|
||||
agents: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let slug_set: HashSet<&str> = slugs.iter().map(String::as_str).collect();
|
||||
let found = scan_managed_agents_by_owner(client, owner, &slug_set).await?;
|
||||
apply_cardinality_rule(&slugs, &found)
|
||||
}
|
||||
|
||||
/// `buzz channels create --template <name>`: load a desktop-local channel
|
||||
/// template, resolve its agent roster against the relay, create the
|
||||
/// channel, apply the canvas template, and add resolved agents as members.
|
||||
///
|
||||
/// Roster resolution happens entirely before channel creation (see
|
||||
/// `resolve_template_roster`) so an ambiguous roster aborts with zero side
|
||||
/// effects. Channel creation, canvas, and member-add are best-effort from
|
||||
/// that point: canvas failures and per-member add failures are reported,
|
||||
/// not fatal.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn cmd_create_channel_from_template(
|
||||
client: &BuzzClient,
|
||||
name: &str,
|
||||
template_name: &str,
|
||||
templates_file: Option<&str>,
|
||||
channel_type_override: Option<&str>,
|
||||
visibility_override: Option<&str>,
|
||||
description: Option<&str>,
|
||||
ttl: Option<i64>,
|
||||
) -> Result<(), CliError> {
|
||||
let templates_path = channel_templates::resolve_templates_path(templates_file)?;
|
||||
let template: ChannelTemplateRecord =
|
||||
channel_templates::find_template(&templates_path, template_name)?;
|
||||
|
||||
let channel_type = channel_type_override.unwrap_or(&template.channel_type);
|
||||
let visibility = visibility_override.unwrap_or(&template.visibility);
|
||||
match channel_type {
|
||||
"stream" | "forum" => {}
|
||||
_ => {
|
||||
return Err(CliError::Usage(format!(
|
||||
"template channel_type must be 'stream' or 'forum' (got: {channel_type})"
|
||||
)))
|
||||
}
|
||||
}
|
||||
match visibility {
|
||||
"open" | "private" => {}
|
||||
_ => {
|
||||
return Err(CliError::Usage(format!(
|
||||
"template visibility must be 'open' or 'private' (got: {visibility})"
|
||||
)))
|
||||
}
|
||||
}
|
||||
let ttl = ttl.map(validate_ttl_seconds).transpose()?;
|
||||
|
||||
// Owner invariant (F1): the auth-tag owner (already verified against the
|
||||
// signer at startup) if present, else the signing pubkey. No sole-author
|
||||
// fallback — a same-slug 30176/30177 from another principal must never
|
||||
// be selected.
|
||||
let owner = client
|
||||
.auth_tag_owner_hex()
|
||||
.unwrap_or_else(|| client.keys().public_key().to_hex());
|
||||
|
||||
let resolved = resolve_template_roster(client, &owner, &template.agents).await?;
|
||||
|
||||
let channel_uuid = Uuid::new_v4();
|
||||
let vis = match visibility {
|
||||
"open" => buzz_sdk::Visibility::Open,
|
||||
"private" => buzz_sdk::Visibility::Private,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let ct = match channel_type {
|
||||
"stream" => buzz_sdk::ChannelKind::Stream,
|
||||
"forum" => buzz_sdk::ChannelKind::Forum,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let effective_description = description.or(template.description.as_deref());
|
||||
let builder = buzz_sdk::build_create_channel(
|
||||
channel_uuid,
|
||||
name,
|
||||
Some(vis),
|
||||
Some(ct),
|
||||
effective_description,
|
||||
ttl,
|
||||
)
|
||||
.map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?;
|
||||
let event = client.sign_event(builder)?;
|
||||
client.submit_event(event).await?;
|
||||
|
||||
let mut canvas_applied = false;
|
||||
if let Some(canvas_template) = template.canvas_template.as_deref() {
|
||||
let content = canvas_template
|
||||
.replace("{channel.name}", name)
|
||||
.replace("{template.name}", &template.name);
|
||||
let canvas_result: Result<(), CliError> = async {
|
||||
let builder = buzz_sdk::build_set_canvas(channel_uuid, &content)
|
||||
.map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?;
|
||||
let event = client.sign_event(builder)?;
|
||||
client.submit_event(event).await?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
// Canvas is best-effort — matches desktop's useApplyTemplate.ts behavior.
|
||||
canvas_applied = canvas_result.is_ok();
|
||||
}
|
||||
|
||||
// Members are added sequentially: concurrent kind:9000 writes are
|
||||
// last-write-wins on the relay (see channelAgents.ts), so parallel adds
|
||||
// here would race each other for no benefit.
|
||||
let mut members_added: Vec<serde_json::Value> = Vec::new();
|
||||
let mut member_failures: Vec<serde_json::Value> = Vec::new();
|
||||
for agent in &resolved.agents {
|
||||
let outcome: Result<(), CliError> = async {
|
||||
let builder = buzz_sdk::build_add_member(
|
||||
channel_uuid,
|
||||
&agent.pubkey,
|
||||
Some(buzz_sdk::MemberRole::Bot),
|
||||
)
|
||||
.map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?;
|
||||
let event = client.sign_event(builder)?;
|
||||
client.submit_event(event).await?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
match outcome {
|
||||
Ok(()) => members_added.push(serde_json::json!({
|
||||
"persona_id": agent.persona_id,
|
||||
"pubkey": agent.pubkey,
|
||||
})),
|
||||
Err(e) => member_failures.push(serde_json::json!({
|
||||
"persona_id": agent.persona_id,
|
||||
"pubkey": agent.pubkey,
|
||||
"error": e.to_string(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
let status = if member_failures.is_empty() {
|
||||
"ok"
|
||||
} else {
|
||||
"partial"
|
||||
};
|
||||
let report = serde_json::json!({
|
||||
"status": status,
|
||||
"channel_id": channel_uuid.to_string(),
|
||||
"template": template.name,
|
||||
"canvas_applied": canvas_applied,
|
||||
"members_added": members_added,
|
||||
"skipped": resolved.skipped,
|
||||
"member_failures": member_failures,
|
||||
});
|
||||
println!("{report}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a user-supplied TTL (in seconds): must be a positive value that
|
||||
/// fits in the relay's `i32` column.
|
||||
fn validate_ttl_seconds(secs: i64) -> Result<i32, CliError> {
|
||||
@@ -606,16 +971,38 @@ pub async fn dispatch(
|
||||
visibility,
|
||||
description,
|
||||
ttl,
|
||||
template,
|
||||
templates_file,
|
||||
} => {
|
||||
cmd_create_channel(
|
||||
client,
|
||||
&name,
|
||||
&channel_type.to_string(),
|
||||
&visibility.to_string(),
|
||||
description.as_deref(),
|
||||
ttl,
|
||||
)
|
||||
.await
|
||||
if let Some(template_name) = template {
|
||||
cmd_create_channel_from_template(
|
||||
client,
|
||||
&name,
|
||||
&template_name,
|
||||
templates_file.as_deref(),
|
||||
channel_type.as_ref().map(|t| t.to_string()).as_deref(),
|
||||
visibility.as_ref().map(|v| v.to_string()).as_deref(),
|
||||
description.as_deref(),
|
||||
ttl,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
// required_unless_present = "template" guarantees these are
|
||||
// Some when template is None.
|
||||
let channel_type =
|
||||
channel_type.ok_or_else(|| CliError::Usage("--type is required".into()))?;
|
||||
let visibility =
|
||||
visibility.ok_or_else(|| CliError::Usage("--visibility is required".into()))?;
|
||||
cmd_create_channel(
|
||||
client,
|
||||
&name,
|
||||
&channel_type.to_string(),
|
||||
&visibility.to_string(),
|
||||
description.as_deref(),
|
||||
ttl,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
ChannelsCmd::Update {
|
||||
channel,
|
||||
@@ -668,7 +1055,10 @@ pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &BuzzClient) -> Resu
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{cmd_set_add_policy, name_matches, validate_ttl_seconds, ChannelSummary};
|
||||
use super::{
|
||||
apply_cardinality_rule, cmd_set_add_policy, name_matches, validate_ttl_seconds,
|
||||
ChannelSummary, ResolvedAgent,
|
||||
};
|
||||
use crate::client::BuzzClient;
|
||||
use crate::CliError;
|
||||
use serde_json::json;
|
||||
@@ -867,4 +1257,87 @@ mod tests {
|
||||
other => panic!("expected CliError::Usage, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Template roster cardinality (F4) ---
|
||||
|
||||
fn agent(persona_id: &str, pubkey: &str) -> ResolvedAgent {
|
||||
ResolvedAgent {
|
||||
persona_id: persona_id.to_string(),
|
||||
pubkey: pubkey.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cardinality_zero_instances_is_skipped_not_error() {
|
||||
let slugs = vec!["builtin:fizz".to_string()];
|
||||
let resolved = apply_cardinality_rule(&slugs, &[]).expect("zero instances is not fatal");
|
||||
assert!(resolved.agents.is_empty());
|
||||
assert_eq!(resolved.skipped, vec!["builtin:fizz".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cardinality_one_instance_is_added() {
|
||||
let slugs = vec!["builtin:fizz".to_string()];
|
||||
let found = vec![agent("builtin:fizz", "a".repeat(64).as_str())];
|
||||
let resolved = apply_cardinality_rule(&slugs, &found).expect("single instance resolves");
|
||||
assert_eq!(resolved.agents.len(), 1);
|
||||
assert_eq!(resolved.agents[0].persona_id, "builtin:fizz");
|
||||
assert!(resolved.skipped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cardinality_multiple_instances_is_hard_error_listing_candidates() {
|
||||
let slugs = vec!["builtin:fizz".to_string()];
|
||||
let found = vec![
|
||||
agent("builtin:fizz", &"a".repeat(64)),
|
||||
agent("builtin:fizz", &"b".repeat(64)),
|
||||
];
|
||||
let err = apply_cardinality_rule(&slugs, &found).unwrap_err();
|
||||
assert!(matches!(err, CliError::Usage(_)));
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("builtin:fizz"));
|
||||
assert!(msg.contains(&"a".repeat(64)));
|
||||
assert!(msg.contains(&"b".repeat(64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cardinality_mixed_slugs_zero_one_many_reports_first_ambiguity() {
|
||||
// Zero and one resolve fine on their own, but a hard error on any
|
||||
// slug must abort the whole roster (no partial channel-creation
|
||||
// side effects from this stage) — the error must name the
|
||||
// ambiguous slug, not a co-resolved one.
|
||||
let slugs = vec![
|
||||
"builtin:no-instance".to_string(),
|
||||
"builtin:fizz".to_string(),
|
||||
"builtin:duplicated".to_string(),
|
||||
];
|
||||
let found = vec![
|
||||
agent("builtin:fizz", &"a".repeat(64)),
|
||||
agent("builtin:duplicated", &"b".repeat(64)),
|
||||
agent("builtin:duplicated", &"c".repeat(64)),
|
||||
];
|
||||
let err = apply_cardinality_rule(&slugs, &found).unwrap_err();
|
||||
assert!(err.to_string().contains("builtin:duplicated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cardinality_empty_roster_resolves_to_empty_lists() {
|
||||
let resolved = apply_cardinality_rule(&[], &[]).expect("empty roster is not fatal");
|
||||
assert!(resolved.agents.is_empty());
|
||||
assert!(resolved.skipped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cardinality_ignores_instances_for_unrelated_slugs() {
|
||||
// A found agent for a slug that isn't in this roster must not leak
|
||||
// into the resolved set or affect another slug's cardinality.
|
||||
let slugs = vec!["builtin:fizz".to_string()];
|
||||
let found = vec![
|
||||
agent("builtin:fizz", &"a".repeat(64)),
|
||||
agent("builtin:unrelated", &"z".repeat(64)),
|
||||
];
|
||||
let resolved = apply_cardinality_rule(&slugs, &found).expect("resolves");
|
||||
assert_eq!(resolved.agents.len(), 1);
|
||||
assert_eq!(resolved.agents[0].persona_id, "builtin:fizz");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod agents;
|
||||
pub mod channel_templates;
|
||||
pub mod channels;
|
||||
pub mod dms;
|
||||
pub mod emoji;
|
||||
|
||||
@@ -479,18 +479,18 @@ pub enum ChannelsCmd {
|
||||
},
|
||||
/// Create a new channel
|
||||
#[command(
|
||||
after_help = "Examples:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name design --type forum --visibility open --description \"Design discussions\"\n buzz channels create --name standup --type stream --visibility open --ttl 3600 # ephemeral, archived after 1h idle"
|
||||
after_help = "Examples:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name design --type forum --visibility open --description \"Design discussions\"\n buzz channels create --name standup --type stream --visibility open --ttl 3600 # ephemeral, archived after 1h idle\n buzz channels create --name project-x --template \"Buzz Team\" # type/visibility/canvas/roster from the template; explicit flags override"
|
||||
)]
|
||||
Create {
|
||||
/// Channel name
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
/// Channel type
|
||||
#[arg(long = "type", value_enum)]
|
||||
channel_type: ChannelType,
|
||||
/// Channel visibility
|
||||
#[arg(long, value_enum)]
|
||||
visibility: ChannelVisibility,
|
||||
/// Channel type. Required unless --template supplies one.
|
||||
#[arg(long = "type", value_enum, required_unless_present = "template")]
|
||||
channel_type: Option<ChannelType>,
|
||||
/// Channel visibility. Required unless --template supplies one.
|
||||
#[arg(long, value_enum, required_unless_present = "template")]
|
||||
visibility: Option<ChannelVisibility>,
|
||||
/// Channel description
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
@@ -498,6 +498,15 @@ pub enum ChannelsCmd {
|
||||
/// it once this many seconds pass without a new message.
|
||||
#[arg(long, value_name = "SECONDS")]
|
||||
ttl: Option<i64>,
|
||||
/// Apply a desktop-local channel template by name (case-insensitive):
|
||||
/// supplies default type/visibility/description/canvas, and resolves
|
||||
/// its agent roster against the relay to add as members.
|
||||
#[arg(long)]
|
||||
template: Option<String>,
|
||||
/// Override the channel-templates.json path (default: the desktop
|
||||
/// app's prod app-data dir). Mainly for the dev store or testing.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
templates_file: Option<String>,
|
||||
},
|
||||
/// Update channel name, description, or ephemeral TTL
|
||||
Update {
|
||||
|
||||
Reference in New Issue
Block a user