mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
[4/4] buzz teams: group published personas into rosters
Completes the definition surface: `buzz teams create|list|get|delete` writes kind:30176 over the personas from [3/4], from flags or a Desktop `.team.json` export. Membership resolution is the load-bearing part. A team event stores persona d-tags, and Desktop publishes personas under their record id — usually a UUID — while its team export names members only by display name. Matching on the slugified name would therefore resolve nothing for every Desktop-exported roster. `--persona` accepts a d-tag, a slugified d-tag, or a unique display name, in that order; an ambiguous display name is an error rather than a guess, and a member with no published persona is refused rather than published as a seat that silently stays empty. A team id is used verbatim, never normalized. The relay enforces the slug grammar on persona d-tags but only a length bound on team ids, and Desktop writes raw UUIDs and ids like `builtin-team:welcome` — normalizing would address a different coordinate than the one Desktop published, making its teams unreachable. Since kind:30176 has no envelope validator on the relay, the CLI is the only guard against a blank or oversized id. `instructions` and `persona_ids` are always published. On the wire an absent field means "publisher predates always-publish, membership unknown, preserve local" — distinct from an explicit empty. A new client must never claim the former, or a write meant to clear a roster reads as "leave it alone" and one meant to leave it alone wipes it. The e2e suite gains the relay rule the delete path depends on: a tombstone older than its target head is accepted, deletes nothing, and still reports OK. Signed-off-by: Max Lampert <maxwell@squareup.com>
This commit is contained in:
@@ -482,9 +482,9 @@ buzz notes get --name dco-check # exits non-zero: not found
|
||||
buzz notes rm --name does-not-exist # exits non-zero
|
||||
```
|
||||
|
||||
### 6.13 Personas (NIP-AP, kind:30175)
|
||||
### 6.13 Personas & Teams (NIP-AP, kind:30175 / kind:30176)
|
||||
|
||||
Owner-authored agent definitions. These are the same coordinates
|
||||
Owner-authored agent definitions and rosters. These are the same coordinates
|
||||
Buzz Desktop reads, so run them with the **same key as the Desktop you expect
|
||||
the definitions to appear in** — a different key writes to a different
|
||||
coordinate space and Desktop shows nothing.
|
||||
@@ -521,8 +521,15 @@ buzz personas list
|
||||
buzz personas get herring
|
||||
buzz personas get herring --json | jq . # sig-stripped array of one
|
||||
|
||||
# create a team; members resolve by d-tag OR persona display name
|
||||
buzz teams create --name "Red team" --persona Herring --persona quinby | jq .
|
||||
# → {event_id, accepted, message, team_id}
|
||||
buzz teams create --from ~/Downloads/Red\ team.team.json --replace | jq .
|
||||
buzz teams get red-team
|
||||
|
||||
# delete (NIP-09 a-tag tombstone; the CLI re-reads to confirm the coordinate
|
||||
# is gone, because the relay accepts a tombstone that deleted nothing)
|
||||
buzz teams delete red-team
|
||||
buzz personas delete herring
|
||||
buzz personas get herring # exits non-zero: not found
|
||||
```
|
||||
@@ -530,6 +537,9 @@ buzz personas get herring # exits non-zero: not found
|
||||
Checks worth making by hand:
|
||||
|
||||
```bash
|
||||
# A member with no published persona is refused rather than seated empty
|
||||
buzz teams create --name "Ghost team" --persona nobody-published; echo "exit: $?" # 1
|
||||
|
||||
# Desktop rejects invisible characters in definition text; so must the CLI
|
||||
buzz personas create --display-name $'Reviewer' --prompt x; echo "exit: $?" # 1
|
||||
|
||||
@@ -541,6 +551,15 @@ buzz personas create --display-name "Herring" --prompt x --shared --replace
|
||||
buzz personas create --display-name "Herring" --prompt y --replace
|
||||
buzz personas get herring | grep shared # → shared: true
|
||||
|
||||
# A team created from --name is addressed by its slug; the miss says so
|
||||
buzz teams create --name "Red team" --persona herring
|
||||
buzz teams get "Red team"; echo "exit: $?" # 1, error names 'red-team'
|
||||
buzz teams get red-team # found
|
||||
|
||||
# A team past the relay's content cap fails locally, before members resolve
|
||||
buzz teams create --name "Red team" --instructions "$(head -c 300000 /dev/zero | tr '\0' a)"
|
||||
echo "exit: $?" # 1, "too large to publish"
|
||||
|
||||
# A non-image --avatar is refused locally rather than after a round trip
|
||||
buzz personas create --display-name "Herring" --prompt x --avatar ./notes.txt; echo "exit: $?" # 1
|
||||
|
||||
@@ -578,10 +597,15 @@ buzz personas create --from /tmp/bad.agent.json; echo "exit: $?"
|
||||
# Past the 5 MiB Desktop itself refuses to import
|
||||
head -c 6000000 /dev/zero | tr '\0' a > /tmp/big.agent.json
|
||||
buzz personas create --from /tmp/big.agent.json; echo "exit: $?" # 1, "snapshot limit"
|
||||
|
||||
# Same for a team snapshot, at Desktop's 25 MiB team limit
|
||||
jq '.team.instructions = ["be terse"]' ~/Downloads/Red\ team.team.json > /tmp/bad.team.json
|
||||
buzz teams create --from /tmp/bad.team.json; echo "exit: $?"
|
||||
# → "not a valid v1 team snapshot", exit 1
|
||||
```
|
||||
|
||||
Cross-check in Desktop: after `personas create`, the definition appears in the
|
||||
agent picker.
|
||||
agent picker; after `teams create`, the team appears with every member seated.
|
||||
|
||||
---
|
||||
|
||||
@@ -726,3 +750,6 @@ buzz channels delete --channel "$FORUM_ID" | jq .
|
||||
| 63a | `personas create --avatar` | ☐ | Small image inlines with its metadata stripped; large flat art downscales back to inline; EXIF-bearing photo succeeds and lands upright; non-image → exit 1; `--avatar-url` conflict or non-http(s) URL → exit 1 |
|
||||
| 64 | `personas list` / `get` | ☐ | `--json` is a sig-stripped array |
|
||||
| 65 | `personas delete` | ☐ | Delete→get 404; warns when a published team still lists it |
|
||||
| 66 | `teams create` | ☐ | Flags, `--from` snapshot, members by d-tag and by display name, unpublished member → exit 1 |
|
||||
| 67 | `teams list` / `get` | ☐ | Members absent vs. empty render differently |
|
||||
| 68 | `teams delete` | ☐ | Delete→get 404; verify in Desktop the team is gone |
|
||||
|
||||
@@ -211,6 +211,11 @@ pub const SNAPSHOT_VERSION: u64 = 1;
|
||||
/// in Buzz Desktop's persona import.
|
||||
pub const MAX_AGENT_SNAPSHOT_BYTES: usize = 5 * 1024 * 1024;
|
||||
|
||||
/// Largest team snapshot this CLI reads, mirroring `MAX_TEAM_SNAPSHOT_JSON_BYTES`
|
||||
/// in Buzz Desktop's team import. Larger than the agent cap because a team
|
||||
/// carries a snapshot per member.
|
||||
pub const MAX_TEAM_SNAPSHOT_BYTES: usize = 25 * 1024 * 1024;
|
||||
|
||||
/// Load and parse a Buzz Desktop snapshot export (`.agent.json` /
|
||||
/// `.team.json`), checking the `format` and `version` envelope before the caller
|
||||
/// reads its payload. Refuses a file past `max_bytes`.
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod projects;
|
||||
pub mod reactions;
|
||||
pub mod repos;
|
||||
pub mod social;
|
||||
pub mod teams;
|
||||
pub mod upload;
|
||||
pub mod users;
|
||||
pub mod workflows;
|
||||
|
||||
@@ -0,0 +1,868 @@
|
||||
//! `buzz teams` — create, list, get, and delete kind:30176 team definitions.
|
||||
//!
|
||||
//! A team is a named set of persona definitions plus optional layered
|
||||
//! instructions. Membership is by persona d-tag, and `create` resolves every
|
||||
//! `--persona` against the personas this identity has actually published: a team
|
||||
//! naming a persona that does not exist yet is rejected rather than published as
|
||||
//! a dangling reference.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use buzz_core::kind::{KIND_PERSONA, KIND_TEAM};
|
||||
use buzz_sdk::agent_definitions::{
|
||||
build_team_delete, build_team_event, event_d_tag, normalize_d_tag, persona_content_from_event,
|
||||
team_content_from_event, TeamEventContent, MAX_D_TAG_LEN,
|
||||
};
|
||||
|
||||
use super::definitions::{
|
||||
fetch_head, list_owned, print_event_json, print_write_response, publish_definition,
|
||||
read_body_file, read_snapshot, MAX_EVENT_CONTENT_LEN, MAX_TEAM_SNAPSHOT_BYTES,
|
||||
};
|
||||
use crate::client::BuzzClient;
|
||||
use crate::error::CliError;
|
||||
use crate::validate::sdk_err;
|
||||
use crate::{TeamCreateArgs, TeamsCmd};
|
||||
|
||||
/// Snapshot envelope written by Buzz Desktop's team export.
|
||||
const TEAM_SNAPSHOT_FORMAT: &str = "buzz-team-snapshot";
|
||||
|
||||
pub async fn dispatch(cmd: TeamsCmd, client: &BuzzClient) -> Result<(), CliError> {
|
||||
match cmd {
|
||||
TeamsCmd::Create(args) => cmd_create(client, args).await,
|
||||
TeamsCmd::List { json } => cmd_list(client, json).await,
|
||||
TeamsCmd::Get { id, json } => cmd_get(client, &id, json).await,
|
||||
TeamsCmd::Delete { id } => cmd_delete(client, &id).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// The v1 `team` header fields a team event publishes.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SnapshotTeam {
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
instructions: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve `--from` and the individual flags into a team id plus content body.
|
||||
///
|
||||
/// `instructions` and `persona_ids` are always `Some`: the outer `None` on the
|
||||
/// wire means "publisher predates always-publish, preserve local", which a new
|
||||
/// client must never claim. An empty instruction set is published as `null` and
|
||||
/// an empty roster as `[]`, so a reader can tell "cleared" from "unknown".
|
||||
fn resolve_create(args: &TeamCreateArgs) -> Result<(String, TeamEventContent), CliError> {
|
||||
let mut name = None;
|
||||
let mut description = None;
|
||||
let mut instructions = None;
|
||||
let mut personas: Vec<String> = Vec::new();
|
||||
let mut id_from_file = None;
|
||||
|
||||
if let Some(path) = &args.from {
|
||||
let snapshot = read_snapshot(path, TEAM_SNAPSHOT_FORMAT, MAX_TEAM_SNAPSHOT_BYTES)?;
|
||||
let raw_team = snapshot
|
||||
.get("team")
|
||||
.ok_or_else(|| CliError::Usage(format!("'{path}' has no `team` object to publish")))?;
|
||||
// Typed, so a wrong-typed field is an error: probing with `as_str` would
|
||||
// drop a non-string `instructions` and publish a team missing the text
|
||||
// that drives it, reporting success.
|
||||
let team: SnapshotTeam = serde_json::from_value(raw_team.clone()).map_err(|e| {
|
||||
CliError::Usage(format!(
|
||||
"'{path}' team is not a valid v1 team snapshot: {e}"
|
||||
))
|
||||
})?;
|
||||
name = team.name;
|
||||
description = team.description;
|
||||
instructions = team.instructions;
|
||||
id_from_file = name.clone();
|
||||
|
||||
// Members are embedded agent snapshots, which identify a persona only by
|
||||
// display name — there is no id in the export. Keep the name verbatim;
|
||||
// cmd_create resolves it against what is actually published, because
|
||||
// Desktop publishes personas under their record id (usually a UUID) and
|
||||
// slugifying the name here would never match one.
|
||||
// Dropping an unreadable member would publish a smaller roster and still
|
||||
// report success — silent membership loss is the exact failure the
|
||||
// persona_ids tri-state exists to prevent, so refuse instead.
|
||||
personas = match snapshot.get("members") {
|
||||
None => Vec::new(),
|
||||
Some(serde_json::Value::Array(members)) => {
|
||||
let mut out = Vec::with_capacity(members.len());
|
||||
for (i, m) in members.iter().enumerate() {
|
||||
let name = m
|
||||
.get("definition")
|
||||
.and_then(|d| d.get("name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| {
|
||||
CliError::Usage(format!(
|
||||
"'{path}' member {i} has no string `definition.name`"
|
||||
))
|
||||
})?;
|
||||
out.push(name.to_owned());
|
||||
}
|
||||
out
|
||||
}
|
||||
Some(_) => {
|
||||
return Err(CliError::Usage(format!(
|
||||
"'{path}' has a non-array `members` field"
|
||||
)))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(v) = &args.name {
|
||||
name = Some(v.clone());
|
||||
}
|
||||
if let Some(v) = &args.description {
|
||||
description = Some(v.clone());
|
||||
}
|
||||
match (&args.instructions, &args.instructions_file) {
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(CliError::Usage(
|
||||
"--instructions and --instructions-file are mutually exclusive".into(),
|
||||
))
|
||||
}
|
||||
(Some(v), None) => instructions = Some(v.clone()),
|
||||
(None, Some(path)) => instructions = Some(read_body_file(path, "instructions")?),
|
||||
(None, None) => {}
|
||||
}
|
||||
if !args.persona.is_empty() {
|
||||
// Verbatim, like the snapshot path: cmd_create resolves a handle to a
|
||||
// published d-tag, accepting either a slug or a persona display name.
|
||||
personas = args.persona.clone();
|
||||
}
|
||||
|
||||
let name = name.filter(|n| !n.trim().is_empty()).ok_or_else(|| {
|
||||
CliError::Usage("--name is required (or supply --from with a team name)".into())
|
||||
})?;
|
||||
|
||||
// Duplicate members would publish the same persona twice in one roster.
|
||||
let mut seen = BTreeSet::new();
|
||||
personas.retain(|p| seen.insert(p.clone()));
|
||||
|
||||
// An explicit --id is used verbatim; only a derived one is slugified. The
|
||||
// relay enforces the slug grammar on persona d-tags but only a length bound
|
||||
// on team ids, and Buzz Desktop team ids are raw UUIDs or identifiers like
|
||||
// `builtin-team:welcome` — normalizing here would address a different
|
||||
// coordinate than the one Desktop wrote, making its teams unreachable.
|
||||
let id = match &args.id {
|
||||
// The relay has no envelope validator for kind:30176 — only a generic
|
||||
// length cap — so a blank id would publish at a junk coordinate that
|
||||
// later writers collide with, which is last-write-wins data loss. Only
|
||||
// an explicit id needs the guard: normalize_d_tag always yields a
|
||||
// non-empty slug within MAX_D_TAG_LEN.
|
||||
Some(id) => {
|
||||
if id.trim().is_empty() {
|
||||
return Err(CliError::Usage("--id must not be empty or blank".into()));
|
||||
}
|
||||
if id.len() > MAX_D_TAG_LEN {
|
||||
return Err(CliError::Usage(format!(
|
||||
"--id must not exceed {MAX_D_TAG_LEN} bytes (got {})",
|
||||
id.len()
|
||||
)));
|
||||
}
|
||||
id.clone()
|
||||
}
|
||||
None => normalize_d_tag(&id_from_file.unwrap_or_else(|| name.clone())),
|
||||
};
|
||||
let content = TeamEventContent {
|
||||
name,
|
||||
description,
|
||||
instructions: Some(instructions),
|
||||
persona_ids: Some(personas),
|
||||
};
|
||||
|
||||
// Report the relay's own cap here rather than letting the write fail with an
|
||||
// opaque rejection after every member has already been resolved. Only the
|
||||
// cap — Desktop applies no text rules to a team, so a CLI-only one would
|
||||
// refuse a roster Desktop exported legitimately.
|
||||
let len = serde_json::to_string(&content)
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(0);
|
||||
if len > MAX_EVENT_CONTENT_LEN {
|
||||
return Err(CliError::Usage(format!(
|
||||
"team is too large to publish ({len} bytes, max {MAX_EVENT_CONTENT_LEN}); \
|
||||
shorten --instructions"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok((id, content))
|
||||
}
|
||||
|
||||
/// Resolve each requested member to a published persona d-tag, rejecting any
|
||||
/// that has no published kind:30175 definition.
|
||||
///
|
||||
/// A team event stores d-tags, not definitions, so a missing persona is a
|
||||
/// dangling reference that surfaces later as an empty seat rather than an error.
|
||||
///
|
||||
/// A handle may be a d-tag or a persona's display name: a Desktop team snapshot
|
||||
/// names its members only by display name, and Desktop publishes personas under
|
||||
/// their record id — usually a UUID — so matching on the slugified name alone
|
||||
/// would reject every Desktop-published roster.
|
||||
async fn resolve_members(
|
||||
client: &BuzzClient,
|
||||
requested: &[String],
|
||||
) -> Result<Vec<String>, CliError> {
|
||||
if requested.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let events = list_owned(client, KIND_PERSONA).await?;
|
||||
let mut d_tags: BTreeSet<&str> = BTreeSet::new();
|
||||
let mut by_display: BTreeMap<String, Vec<&str>> = BTreeMap::new();
|
||||
for event in &events {
|
||||
let Some(d_tag) = event_d_tag(event) else {
|
||||
continue;
|
||||
};
|
||||
d_tags.insert(d_tag);
|
||||
if let Ok(content) = persona_content_from_event(event) {
|
||||
by_display
|
||||
.entry(content.display_name.to_lowercase())
|
||||
.or_default()
|
||||
.push(d_tag);
|
||||
}
|
||||
}
|
||||
|
||||
match_members(requested, &d_tags, &by_display)
|
||||
}
|
||||
|
||||
/// Match member handles against an index of published personas.
|
||||
///
|
||||
/// Tried in order: exact d-tag, slugified d-tag, then unique display name
|
||||
/// (case-insensitive). An ambiguous display name is an error rather than a
|
||||
/// guess, since picking the wrong persona seats the wrong agent.
|
||||
fn match_members(
|
||||
requested: &[String],
|
||||
d_tags: &BTreeSet<&str>,
|
||||
by_display: &BTreeMap<String, Vec<&str>>,
|
||||
) -> Result<Vec<String>, CliError> {
|
||||
let mut resolved = Vec::with_capacity(requested.len());
|
||||
let mut missing = Vec::new();
|
||||
for handle in requested {
|
||||
if let Some(hit) = d_tags.get(handle.as_str()) {
|
||||
resolved.push((*hit).to_owned());
|
||||
continue;
|
||||
}
|
||||
let slug = normalize_d_tag(handle);
|
||||
if let Some(hit) = d_tags.get(slug.as_str()) {
|
||||
resolved.push((*hit).to_owned());
|
||||
continue;
|
||||
}
|
||||
match by_display.get(&handle.to_lowercase()).map(Vec::as_slice) {
|
||||
Some([only]) => resolved.push((*only).to_owned()),
|
||||
Some(many) => {
|
||||
return Err(CliError::Usage(format!(
|
||||
"'{handle}' matches {} published personas ({}); \
|
||||
pass the slug you want with --persona",
|
||||
many.len(),
|
||||
many.join(", ")
|
||||
)));
|
||||
}
|
||||
_ => missing.push(handle.as_str()),
|
||||
}
|
||||
}
|
||||
if !missing.is_empty() {
|
||||
return Err(CliError::NotFound(format!(
|
||||
"no published persona for: {}. Create each one first with \
|
||||
`buzz personas create` (see `buzz personas list`).",
|
||||
missing.join(", ")
|
||||
)));
|
||||
}
|
||||
|
||||
// Two handles can resolve to the same persona (a slug and its display name),
|
||||
// which would seat it twice.
|
||||
let mut seen = BTreeSet::new();
|
||||
resolved.retain(|p| seen.insert(p.clone()));
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// Find the published team whose name matches `name`, if exactly one does.
|
||||
///
|
||||
/// Ambiguity returns `None` rather than guessing: writing to the wrong
|
||||
/// coordinate would overwrite an unrelated team.
|
||||
async fn find_team_by_name(client: &BuzzClient, name: &str) -> Result<Option<String>, CliError> {
|
||||
let events = list_owned(client, KIND_TEAM).await?;
|
||||
let mut hits: Vec<&str> = Vec::new();
|
||||
for event in &events {
|
||||
let Some(d_tag) = event_d_tag(event) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(content) = team_content_from_event(event) {
|
||||
if content.name.eq_ignore_ascii_case(name) {
|
||||
hits.push(d_tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
match hits.as_slice() {
|
||||
[only] => Ok(Some((*only).to_owned())),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cmd_create(client: &BuzzClient, args: TeamCreateArgs) -> Result<(), CliError> {
|
||||
let (mut id, mut content) = resolve_create(&args)?;
|
||||
|
||||
// Membership is resolved before the existence check so a typo'd roster fails
|
||||
// the same way whether or not the team already exists. resolve_create leaves
|
||||
// handles verbatim; the event must carry d-tags.
|
||||
let members = resolve_members(client, &content.persona_ids.clone().unwrap_or_default()).await?;
|
||||
content.persona_ids = Some(members.clone());
|
||||
|
||||
// A team snapshot carries no id, so a derived one is a slugified name — but
|
||||
// Desktop publishes teams under a UUID or an id like `builtin-team:welcome`.
|
||||
// Writing the derived id would mint a second coordinate for a team that
|
||||
// already exists, leaving Desktop with two records of the same name. Adopt
|
||||
// the published team's own id when the name matches, the same way member
|
||||
// handles resolve.
|
||||
if args.id.is_none() {
|
||||
if let Some(existing) = find_team_by_name(client, &content.name).await? {
|
||||
if existing != id {
|
||||
eprintln!(
|
||||
"note: adopting published team id '{existing}' for '{}'",
|
||||
content.name
|
||||
);
|
||||
id = existing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let head = fetch_head(client, KIND_TEAM, &id).await?;
|
||||
if head.is_some() && !args.replace {
|
||||
return Err(CliError::Conflict(format!(
|
||||
"team '{id}' already exists — pass --replace to overwrite it"
|
||||
)));
|
||||
}
|
||||
|
||||
let builder = build_team_event(&id, &content).map_err(sdk_err)?;
|
||||
let (event, response) = publish_definition(client, builder, head.as_ref()).await?;
|
||||
|
||||
let verb = if head.is_some() {
|
||||
"replaced"
|
||||
} else {
|
||||
"created"
|
||||
};
|
||||
eprintln!(
|
||||
"{verb} team {id} ({}) with {} member(s) — event {}",
|
||||
content.name,
|
||||
members.len(),
|
||||
event.id.to_hex()
|
||||
);
|
||||
print_write_response(response, "team_id", &id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_list(client: &BuzzClient, json: bool) -> Result<(), CliError> {
|
||||
let events = list_owned(client, KIND_TEAM).await?;
|
||||
if json {
|
||||
let items: Vec<serde_json::Value> = events
|
||||
.iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"id": event_d_tag(e),
|
||||
"event_id": e.id.to_hex(),
|
||||
"created_at": e.created_at.as_secs(),
|
||||
"content": team_content_from_event(e).ok(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&items)
|
||||
.map_err(|e| CliError::Other(format!("failed to render JSON: {e}")))?
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if events.is_empty() {
|
||||
eprintln!("no teams published by this identity");
|
||||
return Ok(());
|
||||
}
|
||||
for event in &events {
|
||||
let id = event_d_tag(event).unwrap_or("<no d-tag>");
|
||||
match team_content_from_event(event) {
|
||||
Ok(c) => {
|
||||
let members = c.persona_ids.unwrap_or_default();
|
||||
println!("{id:<24} {:<32} {}", c.name, members.join(", "));
|
||||
}
|
||||
Err(e) => println!("{id:<24} <unparseable content: {e}>"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Report a missing team, naming the slug when the id would have been one.
|
||||
///
|
||||
/// Lookups are verbatim because Desktop writes raw UUIDs and ids like
|
||||
/// `builtin-team:welcome`, which normalizing would point at a different
|
||||
/// coordinate. But `create` slugifies an id it derives from `--name`, so
|
||||
/// `teams get "Red team"` misses the `red-team` it just published.
|
||||
fn team_not_found(id: &str) -> CliError {
|
||||
let slug = normalize_d_tag(id);
|
||||
if slug == id {
|
||||
return CliError::NotFound(format!("no team '{id}' for this identity"));
|
||||
}
|
||||
CliError::NotFound(format!(
|
||||
"no team '{id}' for this identity — a team created from --name is addressed by its \
|
||||
slug, so try '{slug}'"
|
||||
))
|
||||
}
|
||||
|
||||
async fn cmd_get(client: &BuzzClient, id: &str, json: bool) -> Result<(), CliError> {
|
||||
// Verbatim: an explicit --id publishes verbatim, so reads must match it.
|
||||
let event = fetch_head(client, KIND_TEAM, id)
|
||||
.await?
|
||||
.ok_or_else(|| team_not_found(id))?;
|
||||
if json {
|
||||
return print_event_json(&event);
|
||||
}
|
||||
|
||||
let c = team_content_from_event(&event).map_err(sdk_err)?;
|
||||
println!("id: {id}");
|
||||
println!("name: {}", c.name);
|
||||
println!(
|
||||
"description: {}",
|
||||
c.description.unwrap_or_else(|| "-".into())
|
||||
);
|
||||
match c.persona_ids {
|
||||
Some(ids) if ids.is_empty() => println!("members: (none)"),
|
||||
Some(ids) => println!("members: {}", ids.join(", ")),
|
||||
// Absent, not empty: this publisher predates always-publish, so its
|
||||
// real membership is unknown rather than empty.
|
||||
None => println!("members: (not published — unknown)"),
|
||||
}
|
||||
println!("event: {}", event.id.to_hex());
|
||||
if let Some(Some(instructions)) = c.instructions {
|
||||
println!("\n{instructions}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_delete(client: &BuzzClient, id: &str) -> Result<(), CliError> {
|
||||
// Verbatim: an explicit --id publishes verbatim, so reads must match it.
|
||||
let head = fetch_head(client, KIND_TEAM, id).await?;
|
||||
if head.is_none() {
|
||||
return Err(team_not_found(id));
|
||||
}
|
||||
let builder = build_team_delete(id, &client.keys().public_key().to_hex()).map_err(sdk_err)?;
|
||||
// NIP-09 scopes an `a`-tag delete to versions at or before the tombstone's
|
||||
// own created_at, so the tombstone must outrank the head it targets. A team
|
||||
// replaced within this same second carries a bumped created_at, and a
|
||||
// tombstone stamped with a bare `now` would be silently ignored.
|
||||
let (event, response) = publish_definition(client, builder, head.as_ref()).await?;
|
||||
|
||||
// The relay accepts a tombstone that deletes nothing and only debug-logs the
|
||||
// miss, so acceptance is not evidence. Re-read the coordinate to confirm.
|
||||
//
|
||||
// A stale read is not a race: buzz-db documents that "stale deletions can
|
||||
// briefly inflate the result set", so a lagging replica can still return the
|
||||
// head we just deleted. That head is always STRICTLY older than its own
|
||||
// tombstone, which is stamped past it — so a survivor at or past the
|
||||
// tombstone's stamp is a different event, one the delete did not cover.
|
||||
if let Some(survivor) = fetch_head(client, KIND_TEAM, id).await? {
|
||||
if survivor.created_at >= event.created_at {
|
||||
return Err(CliError::Conflict(format!(
|
||||
"team '{id}' still exists (head at {}); a concurrent write raced the delete",
|
||||
survivor.created_at.as_secs()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("deleted team {id} — tombstone {}", event.id.to_hex());
|
||||
print_write_response(response, "team_id", id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The relay would reject this write anyway, but only after every member has
|
||||
/// been resolved and with nothing naming the cause.
|
||||
#[test]
|
||||
fn a_team_past_the_relay_content_cap_is_refused_locally() {
|
||||
let err = resolve_create(&TeamCreateArgs {
|
||||
name: Some("Red team".into()),
|
||||
instructions: Some("a".repeat(MAX_EVENT_CONTENT_LEN + 1)),
|
||||
..args()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("too large to publish"), "{err}");
|
||||
}
|
||||
|
||||
/// Desktop applies no text rules to a team, so a CLI-only one would refuse a
|
||||
/// roster Desktop exported legitimately. Long-but-publishable text passes.
|
||||
#[test]
|
||||
fn team_text_is_not_validated_beyond_the_cap() {
|
||||
assert!(resolve_create(&TeamCreateArgs {
|
||||
name: Some("Red team".into()),
|
||||
instructions: Some(format!("Ship\u{200B}it{}", "a".repeat(64 * 1024))),
|
||||
..args()
|
||||
})
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
/// `create` slugifies an id derived from `--name`, so a read with the name
|
||||
/// misses. Lookups stay verbatim — Desktop writes ids like
|
||||
/// `builtin-team:welcome` — so the error has to name the slug instead.
|
||||
#[test]
|
||||
fn a_missing_team_names_the_slug_it_would_have() {
|
||||
let err = team_not_found("Red team").to_string();
|
||||
assert!(err.contains("red-team"), "{err}");
|
||||
// An id that is already a slug has nothing to suggest.
|
||||
let plain = team_not_found("red-team").to_string();
|
||||
assert!(!plain.contains("try"), "{plain}");
|
||||
}
|
||||
|
||||
fn args() -> TeamCreateArgs {
|
||||
TeamCreateArgs {
|
||||
id: None,
|
||||
name: None,
|
||||
description: None,
|
||||
instructions: None,
|
||||
instructions_file: None,
|
||||
persona: Vec::new(),
|
||||
from: None,
|
||||
replace: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_file(body: &str) -> (std::path::PathBuf, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"buzz-teams-{}-{}",
|
||||
super::super::definitions::now_secs(),
|
||||
body.len()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("team.json");
|
||||
std::fs::write(&path, body).unwrap();
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_is_required() {
|
||||
let err = resolve_create(&args()).unwrap_err();
|
||||
assert!(err.to_string().contains("--name is required"), "{err}");
|
||||
}
|
||||
|
||||
/// A new client must always publish both fields: the outer `None` means
|
||||
/// "unknown, preserve local", which would let this write wipe a roster.
|
||||
#[test]
|
||||
fn always_publishes_instructions_and_members() {
|
||||
let (_, content) = resolve_create(&TeamCreateArgs {
|
||||
name: Some("Red team".into()),
|
||||
..args()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(content.instructions, Some(None), "cleared, not unknown");
|
||||
assert_eq!(content.persona_ids, Some(vec![]), "emptied, not unknown");
|
||||
|
||||
let json = serde_json::to_string(&content).unwrap();
|
||||
assert!(json.contains(r#""instructions":null"#), "{json}");
|
||||
assert!(json.contains(r#""persona_ids":[]"#), "{json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_defaults_to_the_normalized_name() {
|
||||
let (id, _) = resolve_create(&TeamCreateArgs {
|
||||
name: Some("Red Team".into()),
|
||||
..args()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(id, "red-team");
|
||||
}
|
||||
|
||||
/// Buzz Desktop team ids are raw UUIDs or identifiers like
|
||||
/// `builtin-team:welcome`. Slugifying an explicit --id would address a
|
||||
/// different coordinate than Desktop wrote, so its teams would be
|
||||
/// unreachable from the CLI.
|
||||
#[test]
|
||||
fn explicit_id_is_used_verbatim() {
|
||||
for raw in [
|
||||
"builtin-team:welcome",
|
||||
"9A1657AC-F7AA-5DB0-B632-D8BBEB6DFB50",
|
||||
] {
|
||||
let (id, _) = resolve_create(&TeamCreateArgs {
|
||||
id: Some(raw.into()),
|
||||
name: Some("Whatever".into()),
|
||||
..args()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(id, raw);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles stay verbatim here — `resolve_members` maps them to published
|
||||
/// d-tags, because a display name is not necessarily its own slug. Only
|
||||
/// exact duplicates collapse at this stage.
|
||||
#[test]
|
||||
fn members_are_kept_verbatim_and_deduped() {
|
||||
let (_, content) = resolve_create(&TeamCreateArgs {
|
||||
name: Some("T".into()),
|
||||
persona: vec!["Herring".into(), "Herring".into(), "quinby".into()],
|
||||
..args()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
content.persona_ids,
|
||||
Some(vec!["Herring".into(), "quinby".into()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instructions_flags_conflict() {
|
||||
let err = resolve_create(&TeamCreateArgs {
|
||||
name: Some("T".into()),
|
||||
instructions: Some("a".into()),
|
||||
instructions_file: Some("b".into()),
|
||||
..args()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("mutually exclusive"), "{err}");
|
||||
}
|
||||
|
||||
/// A team snapshot embeds full agent definitions; the team event references
|
||||
/// them by d-tag, so members are reduced to normalized slugs.
|
||||
#[test]
|
||||
fn from_reads_a_desktop_team_snapshot() {
|
||||
let (dir, path) = snapshot_file(
|
||||
r#"{
|
||||
"format": "buzz-team-snapshot",
|
||||
"version": 1,
|
||||
"team": {"name": "Red team", "description": "Three skeptics."},
|
||||
"members": [
|
||||
{"definition": {"name": "Monocle"}},
|
||||
{"definition": {"name": "Quinby"}},
|
||||
{"definition": {"name": "Herring"}}
|
||||
]
|
||||
}"#,
|
||||
);
|
||||
let (id, content) = resolve_create(&TeamCreateArgs {
|
||||
from: Some(path.to_str().unwrap().into()),
|
||||
..args()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(id, "red-team");
|
||||
assert_eq!(content.name, "Red team");
|
||||
assert_eq!(content.description.as_deref(), Some("Three skeptics."));
|
||||
// Display names verbatim: Desktop publishes personas under a UUID d-tag,
|
||||
// so these are resolved against the relay, not slugified here.
|
||||
assert_eq!(
|
||||
content.persona_ids,
|
||||
Some(vec!["Monocle".into(), "Quinby".into(), "Herring".into()])
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_flags_replace_the_snapshot_roster() {
|
||||
let (dir, path) = snapshot_file(
|
||||
r#"{"format":"buzz-team-snapshot","version":1,
|
||||
"team":{"name":"Red team"},
|
||||
"members":[{"definition":{"name":"Monocle"}}]}"#,
|
||||
);
|
||||
let (_, content) = resolve_create(&TeamCreateArgs {
|
||||
from: Some(path.to_str().unwrap().into()),
|
||||
persona: vec!["herring".into()],
|
||||
..args()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(content.persona_ids, Some(vec!["herring".into()]));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Silently dropping a member would publish a smaller roster and still
|
||||
/// report success — the exact loss the `persona_ids` tri-state guards.
|
||||
#[test]
|
||||
fn from_rejects_a_member_without_a_definition_name() {
|
||||
let (dir, path) = snapshot_file(
|
||||
r#"{"format":"buzz-team-snapshot","version":1,
|
||||
"team":{"name":"Red team"},
|
||||
"members":[{"definition":{"name":"Monocle"}},{"definition":{}}]}"#,
|
||||
);
|
||||
let err = resolve_create(&TeamCreateArgs {
|
||||
from: Some(path.to_str().unwrap().into()),
|
||||
..args()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("member 1 has no string"), "{err}");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_rejects_a_non_array_members_field() {
|
||||
let (dir, path) = snapshot_file(
|
||||
r#"{"format":"buzz-team-snapshot","version":1,
|
||||
"team":{"name":"Red team"},"members":{"a":1}}"#,
|
||||
);
|
||||
let err = resolve_create(&TeamCreateArgs {
|
||||
from: Some(path.to_str().unwrap().into()),
|
||||
..args()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("non-array `members`"), "{err}");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// Probing with `as_str` would drop a wrong-typed field and publish a team
|
||||
/// missing the instructions that drive it, reporting success.
|
||||
#[test]
|
||||
fn from_rejects_a_wrong_typed_team_field() {
|
||||
let (dir, path) = snapshot_file(
|
||||
r#"{"format":"buzz-team-snapshot","version":1,
|
||||
"team":{"name":"Red team","instructions":["be terse"]}}"#,
|
||||
);
|
||||
let err = resolve_create(&TeamCreateArgs {
|
||||
from: Some(path.to_str().unwrap().into()),
|
||||
..args()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("not a valid v1 team snapshot"),
|
||||
"{err}"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The relay only length-bounds a team d-tag, so the CLI is the sole guard
|
||||
/// against publishing at a junk coordinate.
|
||||
#[test]
|
||||
fn blank_explicit_id_is_refused() {
|
||||
let err = resolve_create(&TeamCreateArgs {
|
||||
id: Some(" ".into()),
|
||||
name: Some("Red team".into()),
|
||||
..args()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("must not be empty"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_explicit_id_is_refused() {
|
||||
let err = resolve_create(&TeamCreateArgs {
|
||||
id: Some("t".repeat(MAX_D_TAG_LEN + 1)),
|
||||
name: Some("Red team".into()),
|
||||
..args()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("must not exceed"), "{err}");
|
||||
}
|
||||
|
||||
/// The derived path carries no id guard because normalization can't
|
||||
/// produce a blank or oversized d-tag — pin that so the omission stays true.
|
||||
#[test]
|
||||
fn derived_id_is_always_within_the_relay_bound() {
|
||||
// A blank name never gets this far — --name rejects it first.
|
||||
for name in ["!!!", "-", &"z".repeat(MAX_D_TAG_LEN * 2)] {
|
||||
let (id, _) = resolve_create(&TeamCreateArgs {
|
||||
name: Some(name.into()),
|
||||
..args()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!id.trim().is_empty(), "blank id from {name:?}");
|
||||
assert!(id.len() <= MAX_D_TAG_LEN, "oversized id from {name:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_rejects_an_agent_snapshot() {
|
||||
let (dir, path) = snapshot_file(r#"{"format":"buzz-agent-snapshot","version":1}"#);
|
||||
let err = resolve_create(&TeamCreateArgs {
|
||||
from: Some(path.to_str().unwrap().into()),
|
||||
..args()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("expected 'buzz-team-snapshot'"),
|
||||
"{err}"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ── member resolution ────────────────────────────────────────────────────
|
||||
|
||||
fn index<'a>(
|
||||
published: &[(&'a str, &'a str)],
|
||||
) -> (BTreeSet<&'a str>, BTreeMap<String, Vec<&'a str>>) {
|
||||
let mut d_tags = BTreeSet::new();
|
||||
let mut by_display: BTreeMap<String, Vec<&str>> = BTreeMap::new();
|
||||
for (d_tag, display) in published {
|
||||
d_tags.insert(*d_tag);
|
||||
by_display
|
||||
.entry(display.to_lowercase())
|
||||
.or_default()
|
||||
.push(*d_tag);
|
||||
}
|
||||
(d_tags, by_display)
|
||||
}
|
||||
|
||||
/// The Desktop→CLI case: personas published under UUID d-tags, a team
|
||||
/// snapshot naming them only by display name.
|
||||
#[test]
|
||||
fn display_names_resolve_to_uuid_d_tags() {
|
||||
let (d, n) = index(&[
|
||||
("6f1e2c9a-0b3d-4e5f-8a7b-1c2d3e4f5a6b", "Monocle"),
|
||||
("7a2f3d0b-1c4e-5f6a-9b8c-2d3e4f5a6b7c", "Herring"),
|
||||
]);
|
||||
let got = match_members(&["Monocle".into(), "Herring".into()], &d, &n).unwrap();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
"6f1e2c9a-0b3d-4e5f-8a7b-1c2d3e4f5a6b".to_string(),
|
||||
"7a2f3d0b-1c4e-5f6a-9b8c-2d3e4f5a6b7c".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The CLI-only case still works: the slug is the d-tag.
|
||||
#[test]
|
||||
fn slugs_resolve_directly() {
|
||||
let (d, n) = index(&[("herring", "Herring")]);
|
||||
assert_eq!(
|
||||
match_members(&["herring".into()], &d, &n).unwrap(),
|
||||
vec!["herring".to_string()]
|
||||
);
|
||||
// A display name that slugifies onto its own d-tag resolves too.
|
||||
assert_eq!(
|
||||
match_members(&["Herring".into()], &d, &n).unwrap(),
|
||||
vec!["herring".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
/// A d-tag hit must win over a display-name hit pointing elsewhere.
|
||||
#[test]
|
||||
fn exact_d_tag_wins_over_display_name() {
|
||||
let (d, n) = index(&[("herring", "Quinby"), ("quinby-2", "Herring")]);
|
||||
assert_eq!(
|
||||
match_members(&["herring".into()], &d, &n).unwrap(),
|
||||
vec!["herring".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_display_name_is_refused() {
|
||||
let (d, n) = index(&[("uuid-a", "Herring"), ("uuid-b", "Herring")]);
|
||||
let err = match_members(&["Herring".into()], &d, &n).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("matches 2 published personas"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpublished_member_is_refused() {
|
||||
let (d, n) = index(&[("herring", "Herring")]);
|
||||
let err = match_members(&["ghost".into()], &d, &n).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("no published persona for: ghost"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A slug and its display name are one persona, not two seats.
|
||||
#[test]
|
||||
fn handles_converging_on_one_persona_dedupe() {
|
||||
let (d, n) = index(&[("herring", "Herring")]);
|
||||
assert_eq!(
|
||||
match_members(&["herring".into(), "Herring".into()], &d, &n).unwrap(),
|
||||
vec!["herring".to_string()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -240,6 +240,9 @@ enum Cmd {
|
||||
/// Publish and manage agent definitions (personas) on the relay
|
||||
#[command(subcommand)]
|
||||
Personas(PersonasCmd),
|
||||
/// Group published personas into teams
|
||||
#[command(subcommand)]
|
||||
Teams(TeamsCmd),
|
||||
/// Community moderation — reports queue, bans, timeouts, audit trail
|
||||
#[command(subcommand)]
|
||||
Moderation(ModerationCmd),
|
||||
@@ -1954,6 +1957,80 @@ pub enum PersonasCmd {
|
||||
},
|
||||
}
|
||||
|
||||
/// Flags for `buzz teams create`.
|
||||
///
|
||||
/// A `clap::Args` struct rather than inline variant fields so the CLI surface
|
||||
/// and the resolver share one definition of the create inputs.
|
||||
#[derive(clap::Args)]
|
||||
pub struct TeamCreateArgs {
|
||||
/// Team id (the event d-tag), used verbatim. Defaults to the name,
|
||||
/// slugified. Buzz Desktop ids are raw UUIDs or identifiers like
|
||||
/// `builtin-team:welcome` — pass one exactly as Desktop shows it
|
||||
#[arg(long)]
|
||||
pub id: Option<String>,
|
||||
/// Team name
|
||||
#[arg(long)]
|
||||
pub name: Option<String>,
|
||||
/// Short description
|
||||
#[arg(long)]
|
||||
pub description: Option<String>,
|
||||
/// Instructions layered over each member's own prompt
|
||||
#[arg(long, conflicts_with = "instructions_file")]
|
||||
pub instructions: Option<String>,
|
||||
/// Read the instructions from a file
|
||||
#[arg(long)]
|
||||
pub instructions_file: Option<String>,
|
||||
/// Member persona slug; repeat for each member. Replaces the roster from
|
||||
/// --from when both are given
|
||||
#[arg(long)]
|
||||
pub persona: Vec<String>,
|
||||
/// Read fields from a Buzz Desktop `.team.json` export; members are taken
|
||||
/// as persona slugs and must already be published
|
||||
#[arg(long)]
|
||||
pub from: Option<String>,
|
||||
/// Overwrite an existing team at this id
|
||||
#[arg(long)]
|
||||
pub replace: bool,
|
||||
}
|
||||
|
||||
/// Subcommands for `buzz teams` — kind:30176 team definitions.
|
||||
///
|
||||
/// A team references personas by slug, so every member must already be
|
||||
/// published; `create` rejects a roster naming a persona that does not exist.
|
||||
// clap cannot derive `Args` through a `Box`, and a subcommand enum is built
|
||||
// once per process — boxing to even out variant sizes would trade an allocation
|
||||
// for nothing.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Subcommand)]
|
||||
pub enum TeamsCmd {
|
||||
/// Publish a team over already-published personas
|
||||
#[command(after_help = "Examples:\n \
|
||||
buzz teams create --name 'Red team' --persona monocle --persona quinby --persona herring\n \
|
||||
buzz teams create --from ./red-team.team.json\n \
|
||||
buzz teams create --id red-team --name 'Red team' --instructions-file ./charter.md \\\n \
|
||||
--persona herring --replace")]
|
||||
Create(TeamCreateArgs),
|
||||
/// List teams published by this identity
|
||||
List {
|
||||
/// Emit JSON instead of a table
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Show one team
|
||||
Get {
|
||||
/// Team id
|
||||
id: String,
|
||||
/// Emit the relay event as a sig-stripped JSON array
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Delete a team (NIP-09 coordinate tombstone)
|
||||
Delete {
|
||||
/// Team id
|
||||
id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Community moderation commands.
|
||||
///
|
||||
/// The community (tenant) is selected by the relay host in `--relay` /
|
||||
@@ -2155,6 +2232,7 @@ async fn run(cli: Cli) -> Result<(), CliError> {
|
||||
Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await,
|
||||
Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await,
|
||||
Cmd::Personas(sub) => commands::personas::dispatch(sub, &client).await,
|
||||
Cmd::Teams(sub) => commands::teams::dispatch(sub, &client).await,
|
||||
Cmd::Moderation(sub) => commands::moderation::dispatch(sub, &client, &cli.format).await,
|
||||
Cmd::Pack(_) => unreachable!("handled above"),
|
||||
}
|
||||
@@ -2281,6 +2359,7 @@ mod tests {
|
||||
"reactions",
|
||||
"repos",
|
||||
"social",
|
||||
"teams",
|
||||
"upload",
|
||||
"users",
|
||||
"workflows",
|
||||
@@ -2373,6 +2452,10 @@ mod tests {
|
||||
names(&cmd, "personas"),
|
||||
vec!["create", "delete", "get", "list"]
|
||||
);
|
||||
assert_eq!(
|
||||
names(&cmd, "teams"),
|
||||
vec!["create", "delete", "get", "list"]
|
||||
);
|
||||
assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]);
|
||||
assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]);
|
||||
assert_eq!(
|
||||
|
||||
@@ -61,6 +61,38 @@ fn team_delete_event(keys: &Keys, d_tag: &str) -> nostr::Event {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn team_delete_event_at(keys: &Keys, d_tag: &str, created_at: u64) -> nostr::Event {
|
||||
let coord = format!("{TEAM_KIND}:{}:{d_tag}", keys.public_key().to_hex());
|
||||
EventBuilder::new(Kind::Custom(5), "")
|
||||
.tags(vec![Tag::parse(["a", coord.as_str()]).unwrap()])
|
||||
.custom_created_at(Timestamp::from(created_at))
|
||||
.sign_with_keys(keys)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Count the live events at a team's NIP-33 coordinate.
|
||||
async fn live_team_count(
|
||||
client: &mut BuzzTestClient,
|
||||
keys: &Keys,
|
||||
d_tag: &str,
|
||||
name: &str,
|
||||
) -> usize {
|
||||
let sid = sub_id(name);
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::Custom(TEAM_KIND))
|
||||
.author(keys.public_key())
|
||||
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag]);
|
||||
client
|
||||
.subscribe(&sid, vec![filter])
|
||||
.await
|
||||
.expect("subscribe");
|
||||
client
|
||||
.collect_until_eose(&sid, Duration::from_secs(5))
|
||||
.await
|
||||
.expect("collect")
|
||||
.len()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_team_publish_and_query() {
|
||||
@@ -210,3 +242,70 @@ async fn test_team_tombstone_deletes_coordinate() {
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
/// A tombstone older than the head it targets is accepted but does nothing —
|
||||
/// `soft_delete_by_coordinate` matches on `created_at <= tombstone.created_at`,
|
||||
/// and its return value only feeds a debug log, so the relay reports OK either
|
||||
/// way. Any writer that bumps a head to `max(now, prior + 1)` (NIP-33 tie-break)
|
||||
/// can therefore land a head in the future and then fail to delete it with a
|
||||
/// tombstone stamped at a bare `now`. Publishers must stamp the tombstone at or
|
||||
/// after the head; this pins the rule they depend on.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_tombstone_older_than_head_does_not_delete() {
|
||||
let url = relay_url();
|
||||
let keys = Keys::generate();
|
||||
let d_tag = format!("team-stale-tomb-{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
|
||||
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
|
||||
|
||||
// Head lands ahead of the wall clock, as a same-second replacement would.
|
||||
let now = Timestamp::now().as_secs();
|
||||
let head_at = now + 5;
|
||||
let ok = client
|
||||
.send_event(team_event_at(
|
||||
&keys,
|
||||
&d_tag,
|
||||
r#"{"name":"Bumped Team","persona_ids":["p1"]}"#,
|
||||
head_at,
|
||||
))
|
||||
.await
|
||||
.expect("send head");
|
||||
assert!(ok.accepted, "relay rejected head: {}", ok.message);
|
||||
|
||||
assert_eq!(
|
||||
live_team_count(&mut client, &keys, &d_tag, "stale-pre").await,
|
||||
1,
|
||||
"head should be live"
|
||||
);
|
||||
|
||||
// Stale tombstone: accepted by the relay, but must not erase a newer head.
|
||||
let ok = client
|
||||
.send_event(team_delete_event_at(&keys, &d_tag, now))
|
||||
.await
|
||||
.expect("send stale tombstone");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"relay should still accept the stale tombstone: {}",
|
||||
ok.message
|
||||
);
|
||||
assert_eq!(
|
||||
live_team_count(&mut client, &keys, &d_tag, "stale-post").await,
|
||||
1,
|
||||
"a tombstone older than the head must not delete it"
|
||||
);
|
||||
|
||||
// Same instant as the head is enough — the predicate is `<=`.
|
||||
let ok = client
|
||||
.send_event(team_delete_event_at(&keys, &d_tag, head_at))
|
||||
.await
|
||||
.expect("send matching tombstone");
|
||||
assert!(ok.accepted, "relay rejected tombstone: {}", ok.message);
|
||||
assert_eq!(
|
||||
live_team_count(&mut client, &keys, &d_tag, "stale-final").await,
|
||||
0,
|
||||
"a tombstone at the head's instant must delete it"
|
||||
);
|
||||
|
||||
client.disconnect().await.expect("disconnect");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user