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:
Will Pfleger
2026-07-20 12:55:40 -04:00
committed by GitHub
parent 9ab52d4f84
commit 1a94146181
6 changed files with 705 additions and 17 deletions

2
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View 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");
}
}

View File

@@ -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");
}
}

View File

@@ -1,4 +1,5 @@
pub mod agents;
pub mod channel_templates;
pub mod channels;
pub mod dms;
pub mod emoji;

View File

@@ -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 {