From 22a12bf079d9c9d090e1072b2c148b4302e07de5 Mon Sep 17 00:00:00 2001 From: Blake McAnally Date: Fri, 30 Oct 2026 11:39:59 -0500 Subject: [PATCH] feat(cli): manage sidebar sections with `buzz sidebar sections` Sidebar sections were desktop-only: they live in the encrypted kind:30078 `channel-sections` preference, and the CLI had no way to read or write it. Agents that mirror a team's channels could create the channels but not group them. Add `buzz sidebar sections list|create|update|delete|reorder|move|unassign`. The commands port desktop's per-field LWW register document (channelSectionsSync.ts / sidebarLwwMap.ts) so CLI writes merge with desktop edits field by field, and they import the flat format mobile writes. Each command is one head read and one publish, so a multi-channel `move` lands atomically. A head the CLI cannot decrypt, verify, or decode is never overwritten, and a dominated write exits 5. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Blake McAnally --- crates/buzz-cli/README.md | 17 + crates/buzz-cli/src/commands/mod.rs | 2 + crates/buzz-cli/src/commands/sidebar.rs | 643 ++++++++++++++++++ crates/buzz-cli/src/commands/sidebar_doc.rs | 556 +++++++++++++++ crates/buzz-cli/src/lib.rs | 70 ++ .../src/managed_agents/nest_skill.md | 1 + 6 files changed, 1289 insertions(+) create mode 100644 crates/buzz-cli/src/commands/sidebar.rs create mode 100644 crates/buzz-cli/src/commands/sidebar_doc.rs diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 5a8e39d617..b68d1e33de 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -140,6 +140,11 @@ buzz messages vote --event --direction up buzz canvas get --channel buzz canvas set --channel --content "# Welcome" +# Sidebar sections (your own sidebar, synced with Buzz desktop) +buzz sidebar sections create --name "Engineering" --icon "🛠" +buzz sidebar sections move --section "Engineering" --channel --channel +buzz sidebar sections list + # Agent Memory (NIP-AE) buzz mem ls buzz mem get @@ -156,6 +161,11 @@ buzz repos protect remove --id my-repo --ref refs/heads/main buzz channels list | jq '.[].name' ``` +`sidebar sections` edits the signing identity's own sidebar. Each command is +one read and one publish, so a multi-channel `move` lands together. `--section` +takes a section id or a unique section name. A repeated command prints +`"message": "unchanged"` and publishes nothing. + `protect set` replaces every existing rule for the exact ref pattern. Any constraint omitted from the command is removed. `protect list` reports malformed stored rules in `validation_error` so an owner can remove and repair them. @@ -196,6 +206,13 @@ The table below mirrors that tree for readers who are not at a terminal. | | `remove-member` | Remove a member | | `canvas` | `get` | Get channel canvas | | | `set` | Set channel canvas | +| `sidebar` | `sections list` | List your sections and their channels | +| | `sections create` | Create a section at the end of your sidebar | +| | `sections update` | Rename a section or change its icon | +| | `sections delete` | Delete a section | +| | `sections reorder` | Put sections in order | +| | `sections move` | Move one or more channels into a section | +| | `sections unassign` | Return channels to the default channel list | | `reactions` | `add` | React to a message | | | `remove` | Remove a reaction | | | `get` | List reactions | diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 3634f12ea9..24e613cc43 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -25,6 +25,8 @@ pub(crate) mod read_state; mod repo_default_branch; pub mod repos; pub(crate) mod sent_from_thread; +pub mod sidebar; +mod sidebar_doc; pub mod social; pub mod upload; pub mod users; diff --git a/crates/buzz-cli/src/commands/sidebar.rs b/crates/buzz-cli/src/commands/sidebar.rs new file mode 100644 index 0000000000..75ef413487 --- /dev/null +++ b/crates/buzz-cli/src/commands/sidebar.rs @@ -0,0 +1,643 @@ +//! `buzz sidebar sections` — your own channel sections, the same synced +//! preference the desktop sidebar edits (kind:30078, d=`channel-sections`, +//! NIP-44 encrypted to yourself). +//! +//! Every write is one read of the current head followed by one publish, so a +//! multi-channel `move` lands atomically. A head this CLI cannot read is never +//! overwritten. + +use std::time::SystemTime; + +use buzz_core::kind::KIND_READ_STATE; +use nostr::nips::nip44; +use nostr::{EventBuilder, Kind, Tag, Timestamp}; +use serde_json::{json, Value}; + +use super::parse_write_response; +use super::sidebar_doc::{self, Edit, Projection, Section, Stamp, Tree}; +use crate::client::BuzzClient; +use crate::error::CliError; +use crate::{SidebarCmd, SidebarSectionsCmd}; + +const D_TAG: &str = "channel-sections"; +/// Relay event size ceiling shared with desktop's sidebar blobs. +const MAX_CIPHERTEXT_BYTES: usize = 65_535; + +struct Head { + created_at: u64, + tree: Tree, +} + +fn now() -> std::time::Duration { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() +} + +/// The relay's current sections event for this identity, decoded. An absent +/// head is an empty tree; an unreadable one is an error, never an empty list. +async fn fetch_head(client: &BuzzClient) -> Result { + let me = client.keys().public_key(); + let filter = json!({ + "kinds": [KIND_READ_STATE], + "authors": [me.to_hex()], + "#d": [D_TAG], + "limit": 1, + }); + let raw = client.query(&filter).await?; + let events: Vec = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("failed to parse sidebar sections query: {e}")))?; + let returned = events.len(); + let head = events + .into_iter() + .filter_map(|event| serde_json::from_value::(event).ok()) + .filter(|event| { + event.pubkey == me && event.tags.identifier() == Some(D_TAG) && event.verify().is_ok() + }) + .min_by(|a, b| { + b.created_at + .cmp(&a.created_at) + .then_with(|| a.id.to_hex().cmp(&b.id.to_hex())) + }); + let Some(event) = head else { + if returned > 0 { + return Err(CliError::Other( + "the relay returned a sidebar sections event that is not yours or fails verification; refusing to overwrite it".into(), + )); + } + return Ok(Head { + created_at: 0, + tree: Tree::new(), + }); + }; + + let unreadable = |detail: String| { + CliError::Other(format!( + "your sidebar sections event {} is unreadable ({detail}); refusing to read or overwrite it", + event.id.to_hex() + )) + }; + let plaintext = nip44::decrypt(client.keys().secret_key(), &me, &event.content) + .map_err(|e| unreadable(e.to_string()))?; + let json: Value = serde_json::from_str(&plaintext).map_err(|e| unreadable(e.to_string()))?; + let created_at = event.created_at.as_secs(); + let legacy_v = i64::try_from(created_at.saturating_mul(1_000)).unwrap_or(i64::MAX); + let tree = sidebar_doc::decode(&json, legacy_v).ok_or_else(|| { + unreadable("unsupported format; a newer client may have written it".into()) + })?; + Ok(Head { created_at, tree }) +} + +/// A live section by exact id, or by exact name when that name is unique. +fn resolve<'a>(view: &'a Projection, key: &str) -> Result<&'a Section, CliError> { + if let Some(section) = view.sections.iter().find(|s| s.id == key) { + return Ok(section); + } + let key = key.trim(); + let mut named = view.sections.iter().filter(|s| s.name == key); + match (named.next(), named.next()) { + (Some(section), None) => Ok(section), + (Some(_), Some(_)) => Err(CliError::Usage(format!( + "more than one section is named \"{key}\"; pass the section id from `buzz sidebar sections list`" + ))), + (None, _) => Err(CliError::NotFound(format!( + "no section has the id or name \"{key}\"" + ))), + } +} + +fn section_name(name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(CliError::Usage("section name must not be empty".into())); + } + Ok(name.to_string()) +} + +fn section_icon(icon: Option<&str>) -> Result, CliError> { + match icon.map(str::trim) { + Some("") => Err(CliError::Usage( + "--icon must not be empty; use --clear-icon to remove an icon".into(), + )), + icon => Ok(icon.map(str::to_string)), + } +} + +/// Channel ids in the lowercase hyphenated form desktop keys assignments by. +fn channel_ids(channels: &[String]) -> Result, CliError> { + channels + .iter() + .map(|channel| { + uuid::Uuid::parse_str(channel.trim()) + .map(|id| id.hyphenated().to_string()) + .map_err(|_| CliError::Usage(format!("invalid channel UUID: {channel}"))) + }) + .collect() +} + +/// Reads the head, applies `apply`, and publishes the result once. Returns +/// the write response, or an `unchanged` response without publishing. +async fn edit( + client: &BuzzClient, + apply: impl FnOnce(&mut Edit, &Projection) -> Result<(), CliError>, +) -> Result { + let head = fetch_head(client).await?; + let view = sidebar_doc::project(&head.tree); + let mut tree = head.tree.clone(); + let stamp = Stamp { + now_ms: i64::try_from(now().as_millis()).unwrap_or(i64::MAX), + dev: format!("{:016x}", rand::random::()), + }; + apply(&mut Edit::new(&mut tree, &stamp), &view)?; + if tree == head.tree { + return Ok(json!({"event_id": null, "accepted": true, "message": "unchanged"})); + } + if !sidebar_doc::project(&tree).within_limits() { + return Err(CliError::Usage(format!( + "the sidebar supports at most {} sections and {} channel assignments", + sidebar_doc::MAX_SECTIONS, + sidebar_doc::MAX_ASSIGNMENTS + ))); + } + + let keys = client.keys(); + let plaintext = serde_json::to_string(&sidebar_doc::encode(&tree)) + .map_err(|e| CliError::Other(format!("failed to encode sidebar sections: {e}")))?; + let too_large = + || CliError::Usage("sidebar sections would exceed the relay's event size limit".into()); + // NIP-44 rejects longer plaintexts outright. + if plaintext.len() > u16::MAX as usize { + return Err(too_large()); + } + let content = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + plaintext, + nip44::Version::V2, + ) + .map_err(|e| CliError::Other(format!("failed to encrypt sidebar sections: {e}")))?; + if content.len() > MAX_CIPHERTEXT_BYTES { + return Err(too_large()); + } + let created_at = now().as_secs().max(head.created_at + 1); + let builder = EventBuilder::new(Kind::Custom(KIND_READ_STATE as u16), content) + .tags([Tag::identifier(D_TAG), Tag::hashtag(D_TAG)]) + .custom_created_at(Timestamp::from(created_at)); + let raw = client.submit_event(client.sign_event(builder)?).await?; + let normalized = parse_write_response( + &raw, + "a newer sidebar sections update won; run the command again", + )?; + serde_json::from_str(&normalized) + .map_err(|e| CliError::Other(format!("relay response is not JSON: {e}"))) +} + +async fn run(cmd: SidebarSectionsCmd, client: &BuzzClient) -> Result { + match cmd { + SidebarSectionsCmd::List => { + let head = fetch_head(client).await?; + serde_json::to_value(sidebar_doc::project(&head.tree)) + .map_err(|e| CliError::Other(format!("failed to encode sections: {e}"))) + } + SidebarSectionsCmd::Create { name, icon } => { + let name = section_name(&name)?; + let icon = section_icon(icon.as_deref())?; + let id = uuid::Uuid::new_v4().to_string(); + let mut response = edit(client, |edit, _| { + edit.create_section(&id, &name, icon.as_deref()); + Ok(()) + }) + .await?; + response["section_id"] = json!(id); + Ok(response) + } + SidebarSectionsCmd::Update { + section, + name, + icon, + clear_icon, + } => { + if name.is_none() && icon.is_none() && !clear_icon { + return Err(CliError::Usage( + "pass --name, --icon, or --clear-icon".into(), + )); + } + let name = name.as_deref().map(section_name).transpose()?; + let icon = section_icon(icon.as_deref())?; + edit(client, |edit, view| { + let id = resolve(view, §ion)?.id.clone(); + if let Some(name) = name { + edit.set(&["s", &id, "name"], json!(name)); + } + if clear_icon || icon.is_some() { + edit.set(&["s", &id, "icon"], json!(icon)); + } + Ok(()) + }) + .await + } + SidebarSectionsCmd::Delete { section } => { + edit(client, |edit, view| { + let id = resolve(view, §ion)?.id.clone(); + edit.set(&["s", &id, "live"], json!(false)); + Ok(()) + }) + .await + } + SidebarSectionsCmd::Reorder { sections } => { + edit(client, |edit, view| { + let ids = sections + .iter() + .map(|key| Ok(resolve(view, key)?.id.clone())) + .collect::, CliError>>()?; + edit.reorder(&ids); + Ok(()) + }) + .await + } + SidebarSectionsCmd::Move { channels, section } => { + let channels = channel_ids(&channels)?; + edit(client, |edit, view| { + let id = resolve(view, §ion)?.id.clone(); + for channel in &channels { + edit.set(&["a", channel], json!(id)); + } + Ok(()) + }) + .await + } + SidebarSectionsCmd::Unassign { channels } => { + let channels = channel_ids(&channels)?; + edit(client, |edit, _| { + for channel in &channels { + edit.unassign(channel); + } + Ok(()) + }) + .await + } + } +} + +pub async fn dispatch(cmd: SidebarCmd, client: &BuzzClient) -> Result<(), CliError> { + let SidebarCmd::Sections(cmd) = cmd; + let output = run(cmd, client).await?; + println!("{output}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Bytes, extract::State, routing::post, Json, Router}; + use nostr::Keys; + use std::sync::{Arc, Mutex}; + use tokio::net::TcpListener; + + #[derive(Default)] + struct Relay { + events: Vec, + duplicate: bool, + } + type Shared = Arc>; + + /// Fake relay: `/query` checks the head filter and returns every stored + /// event matching its author and d-tag (more than the relay's `limit`, so + /// head selection is exercised); `/events` stores one. + async fn fake_relay(relay: Shared) -> String { + let app = Router::new() + .route( + "/query", + post(|State(relay): State, body: Bytes| async move { + let filters: Vec = serde_json::from_slice(&body).unwrap(); + let [filter] = filters.as_slice() else { + panic!("one filter expected: {filters:?}") + }; + assert_eq!(filter["kinds"], json!([KIND_READ_STATE])); + assert_eq!(filter["#d"], json!([D_TAG])); + assert_eq!(filter["limit"], json!(1)); + let author = &filter["authors"][0]; + let relay = relay.lock().unwrap(); + let matching: Vec<_> = relay + .events + .iter() + .filter(|e| &e["pubkey"] == author && e["tags"][0] == json!(["d", D_TAG])) + .cloned() + .collect(); + Json(json!(matching)) + }), + ) + .route( + "/events", + post(|State(relay): State, body: Bytes| async move { + let event: Value = serde_json::from_slice(&body).unwrap(); + let mut relay = relay.lock().unwrap(); + let message = if relay.duplicate { + "duplicate: newer head" + } else { + "" + }; + if !relay.duplicate { + relay.events.push(event.clone()); + } + Json(json!({"event_id": event["id"], "accepted": true, "message": message})) + }), + ) + .with_state(relay); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") + } + + async fn setup() -> (BuzzClient, Shared) { + let relay = Shared::default(); + let url = fake_relay(relay.clone()).await; + let client = BuzzClient::new(url, Keys::generate(), None, None).unwrap(); + (client, relay) + } + + fn seed_at(client: &BuzzClient, relay: &Shared, plaintext: &str, created_at: u64) -> Value { + let keys = client.keys(); + let content = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + plaintext, + nip44::Version::V2, + ) + .unwrap(); + let event = EventBuilder::new(Kind::Custom(KIND_READ_STATE as u16), content) + .tags([Tag::identifier(D_TAG)]) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .unwrap(); + let event = serde_json::to_value(event).unwrap(); + relay.lock().unwrap().events.push(event.clone()); + event + } + + fn seed(client: &BuzzClient, relay: &Shared, plaintext: &str) { + seed_at(client, relay, plaintext, now().as_secs()); + } + + fn doc_with_sections(names: &[&str]) -> String { + let mut tree = Tree::new(); + let stamp = Stamp { + now_ms: 1, + dev: "0123456789abcdef".into(), + }; + let mut edit = Edit::new(&mut tree, &stamp); + for name in names { + edit.create_section(&format!("id-{name}"), name, None); + } + sidebar_doc::encode(&tree).to_string() + } + + /// The decrypted document of the newest published event. + fn published(client: &BuzzClient, relay: &Shared) -> (nostr::Event, Value) { + let raw = relay.lock().unwrap().events.last().cloned().unwrap(); + let event: nostr::Event = serde_json::from_value(raw).unwrap(); + let keys = client.keys(); + let plaintext = + nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content).unwrap(); + (event, serde_json::from_str(&plaintext).unwrap()) + } + + fn published_count(relay: &Shared) -> usize { + relay.lock().unwrap().events.len() + } + + const C1: &str = "11111111-1111-4111-8111-111111111111"; + const C2: &str = "22222222-2222-4222-8222-222222222222"; + const C3: &str = "abcdef01-2345-4678-89ab-cdef01234567"; + + fn mv(channels: &[&str], section: &str) -> SidebarSectionsCmd { + SidebarSectionsCmd::Move { + channels: channels.iter().map(|c| c.to_string()).collect(), + section: section.into(), + } + } + + #[tokio::test] + async fn sets_up_a_sidebar_in_one_pass_and_syncs_desktop_format() { + let (client, relay) = setup().await; + let list = run(SidebarSectionsCmd::List, &client).await.unwrap(); + assert_eq!(list, json!({"sections": [], "assignments": {}})); + + let eng = run( + SidebarSectionsCmd::Create { + name: " Engineering ".into(), + icon: Some("🛠".into()), + }, + &client, + ) + .await + .unwrap(); + let eng_id = eng["section_id"].as_str().unwrap().to_string(); + run( + SidebarSectionsCmd::Create { + name: "Design".into(), + icon: None, + }, + &client, + ) + .await + .unwrap(); + + // Both channels land in one publish, addressed by section name. + let before = published_count(&relay); + run(mv(&[C1, C2], "Engineering"), &client).await.unwrap(); + assert_eq!(published_count(&relay), before + 1); + + let (event, doc) = published(&client, &relay); + assert_eq!(event.kind.as_u16() as u32, KIND_READ_STATE); + assert!(event.tags.iter().any(|t| t.as_slice() == ["d", D_TAG])); + assert!(event.tags.iter().any(|t| t.as_slice() == ["t", D_TAG])); + assert_eq!(doc["meta"]["v"], json!(1)); + assert_eq!(doc["assignments"][C1], json!(eng_id)); + assert_eq!(doc["sections"][0]["icon"], json!("🛠")); + + // A repeated command publishes nothing. + let again = run(mv(&[C1, C2], &eng_id), &client).await.unwrap(); + assert_eq!(again["message"], json!("unchanged")); + assert_eq!(published_count(&relay), before + 1); + + // Invalid input fails before any publish. + let err = run(mv(&[C1, "not-a-uuid"], "Design"), &client) + .await + .unwrap_err(); + assert!(matches!(err, CliError::Usage(_)), "{err:?}"); + let err = run(mv(&[C1], "Nope"), &client).await.unwrap_err(); + assert!(matches!(err, CliError::NotFound(_)), "{err:?}"); + assert_eq!(published_count(&relay), before + 1); + + run( + SidebarSectionsCmd::Reorder { + sections: vec!["Design".into()], + }, + &client, + ) + .await + .unwrap(); + run( + SidebarSectionsCmd::Update { + section: eng_id.clone(), + name: Some("Eng".into()), + icon: None, + clear_icon: true, + }, + &client, + ) + .await + .unwrap(); + run( + SidebarSectionsCmd::Unassign { + channels: vec![C2.into()], + }, + &client, + ) + .await + .unwrap(); + run( + SidebarSectionsCmd::Delete { + section: "Design".into(), + }, + &client, + ) + .await + .unwrap(); + + let list = run(SidebarSectionsCmd::List, &client).await.unwrap(); + assert_eq!( + list, + json!({ + "sections": [{"id": eng_id, "name": "Eng", "order": 0}], + "assignments": {C1: eng_id}, + }) + ); + let (_, doc) = published(&client, &relay); + assert_eq!(doc["sections"], list["sections"]); + } + + #[tokio::test] + async fn keeps_a_mobile_head_and_never_overwrites_an_unreadable_one() { + let (client, relay) = setup().await; + seed( + &client, + &relay, + r#"{"version":1,"sections":[{"id":"m1","name":"Mobile","order":0}],"assignments":{}}"#, + ); + run(mv(&[C1], "Mobile"), &client).await.unwrap(); + let list = run(SidebarSectionsCmd::List, &client).await.unwrap(); + assert_eq!(list["sections"][0]["name"], json!("Mobile")); + assert_eq!(list["assignments"][C1], json!("m1")); + + seed_at( + &client, + &relay, + r#"{"version":1,"meta":{"v":2}}"#, + now().as_secs() + 100, + ); + let count = published_count(&relay); + assert!(run(SidebarSectionsCmd::List, &client).await.is_err()); + assert!(run(mv(&[C2], "Mobile"), &client).await.is_err()); + assert_eq!(published_count(&relay), count); + } + + #[tokio::test] + async fn picks_the_newest_head_and_publishes_after_it() { + let (client, relay) = setup().await; + let future = now().as_secs() + 1_000; + seed_at(&client, &relay, &doc_with_sections(&["Old"]), future - 1); + let a = seed_at(&client, &relay, &doc_with_sections(&["A"]), future); + let b = seed_at(&client, &relay, &doc_with_sections(&["B"]), future); + // Equal `created_at`: the lower id wins, as on the relay. + let winner = if a["id"].as_str() < b["id"].as_str() { + "A" + } else { + "B" + }; + let list = run(SidebarSectionsCmd::List, &client).await.unwrap(); + assert_eq!(list["sections"][0]["name"], json!(winner)); + + // Another author's head is never read as ours. + let other = BuzzClient::new(String::new(), Keys::generate(), None, None).unwrap(); + seed_at(&other, &relay, &doc_with_sections(&["Theirs"]), future + 5); + + // An uppercase channel id is stored in desktop's canonical form. + run(mv(&[&C3.to_uppercase()], winner), &client) + .await + .unwrap(); + let (event, doc) = published(&client, &relay); + assert_eq!(event.created_at.as_secs(), future + 1); + assert_eq!(doc["assignments"][C3], json!(format!("id-{winner}"))); + } + + #[tokio::test] + async fn refuses_a_tampered_head_and_oversized_documents() { + let (client, relay) = setup().await; + let mut head = seed_at(&client, &relay, &doc_with_sections(&["A"]), 10); + head["content"] = json!("tampered"); + relay.lock().unwrap().events = vec![head]; + let err = run(mv(&[C1], "A"), &client).await.unwrap_err(); + assert!(matches!(err, CliError::Other(_)), "{err:?}"); + assert_eq!(published_count(&relay), 1); + + let names: Vec = (0..sidebar_doc::MAX_SECTIONS) + .map(|i| i.to_string()) + .collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + relay.lock().unwrap().events.clear(); + seed(&client, &relay, &doc_with_sections(&names)); + let over_count = SidebarSectionsCmd::Create { + name: "one too many".into(), + icon: None, + }; + let rename = |len: usize| SidebarSectionsCmd::Update { + section: "0".into(), + name: Some("x".repeat(len)), + icon: None, + clear_icon: false, + }; + let err = run(over_count, &client).await.unwrap_err(); + assert!(matches!(err, CliError::Usage(_)), "{err:?}"); + + // Ciphertext over the relay cap, then plaintext over the NIP-44 cap. + relay.lock().unwrap().events.clear(); + seed(&client, &relay, &doc_with_sections(&["0"])); + for cmd in [rename(25_000), rename(70_000)] { + let err = run(cmd, &client).await.unwrap_err(); + assert!(matches!(err, CliError::Usage(_)), "{err:?}"); + } + assert_eq!(published_count(&relay), 1); + + let err = run( + SidebarSectionsCmd::Update { + section: "0".into(), + name: None, + icon: Some(" ".into()), + clear_icon: false, + }, + &client, + ) + .await + .unwrap_err(); + assert!(matches!(err, CliError::Usage(_)), "{err:?}"); + } + + #[tokio::test] + async fn a_dominated_write_is_a_conflict() { + let (client, relay) = setup().await; + relay.lock().unwrap().duplicate = true; + let err = run( + SidebarSectionsCmd::Create { + name: "Late".into(), + icon: None, + }, + &client, + ) + .await + .unwrap_err(); + assert!(matches!(err, CliError::Conflict(_)), "{err:?}"); + } +} diff --git a/crates/buzz-cli/src/commands/sidebar_doc.rs b/crates/buzz-cli/src/commands/sidebar_doc.rs new file mode 100644 index 0000000000..ab02788340 --- /dev/null +++ b/crates/buzz-cli/src/commands/sidebar_doc.rs @@ -0,0 +1,556 @@ +//! The synced channel-sections document (kind:30078, d=`channel-sections`). +//! +//! Wire-compatible with desktop `channelSectionsSync.ts`, `sidebarLaneStore.ts` +//! and `sidebarLwwMap.ts`: a tree of per-field last-writer-wins registers +//! `[v, dev, value]` under `meta`, plus the projected `sections`/`assignments` +//! fields that meta-less clients (mobile) read and write. + +use std::collections::BTreeMap; + +use serde_json::{json, Map, Value}; + +/// Register tree: `{"s": {section_id: {name, icon, order, live}}, "a": {channel_id: section_id|null}}`. +pub(crate) type Tree = Map; + +/// Live-section cap shared with desktop; larger documents are never published. +pub(crate) const MAX_SECTIONS: usize = 100; +/// Live-assignment cap shared with desktop. +pub(crate) const MAX_ASSIGNMENTS: usize = 1_000; + +/// Device id desktop stamps on registers imported from meta-less payloads. +const LEGACY_DEV: &str = "0000000000000000"; +const MAX_SAFE_INTEGER: i64 = (1 << 53) - 1; + +/// One live section, densely renumbered by `(order, id)`. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub(crate) struct Section { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + pub order: usize, +} + +/// The view every client renders: live sections and assignments to them. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub(crate) struct Projection { + pub sections: Vec
, + pub assignments: BTreeMap, +} + +impl Projection { + pub(crate) fn within_limits(&self) -> bool { + self.sections.len() <= MAX_SECTIONS && self.assignments.len() <= MAX_ASSIGNMENTS + } +} + +/// `Number.isSafeInteger`, accepting integral floats such as `1.0`. +fn safe_int(value: &Value) -> Option { + let n = match value.as_i64() { + Some(n) => n, + None => { + let f = value.as_f64()?; + if f.fract() != 0.0 || f.abs() > MAX_SAFE_INTEGER as f64 { + return None; + } + f as i64 + } + }; + (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER) + .contains(&n) + .then_some(n) +} + +fn is_dev(value: &Value) -> bool { + value + .as_str() + .is_some_and(|s| s.len() == 16 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))) +} + +fn valid_reg(node: &Value, leaf: Leaf) -> Option { + let reg = node.as_array().filter(|reg| reg.len() == 3)?; + let v = safe_int(®[0]).filter(|v| *v >= 0)?; + (is_dev(®[1]) && leaf(®[2])).then(|| json!([v, reg[1], reg[2]])) +} + +fn is_string(value: &Value) -> bool { + value.is_string() +} +fn is_string_or_null(value: &Value) -> bool { + value.is_string() || value.is_null() +} +fn is_order(value: &Value) -> bool { + safe_int(value).is_some() +} +fn is_bool(value: &Value) -> bool { + value.is_boolean() +} + +type Leaf = fn(&Value) -> bool; + +const SECTION_FIELDS: [(&str, Leaf); 4] = [ + ("name", is_string), + ("icon", is_string_or_null), + ("order", is_order), + ("live", is_bool), +]; + +/// Own entries of a JSON object, skipping the `__proto__` key desktop drops. +fn entries(node: &Value) -> impl Iterator { + node.as_object() + .into_iter() + .flatten() + .filter(|(key, _)| key.as_str() != "__proto__") +} + +/// Keeps only well-formed registers; an invalid one is dropped while its +/// valid siblings survive (desktop `validTree` with the sections shape). +fn valid_tree(fields: &Value) -> Option { + fields.as_object()?; + let mut tree = Tree::new(); + if let Some(s) = fields.get("s").filter(|s| s.is_object()) { + let mut sections = Map::new(); + for (id, node) in entries(s) { + if !node.is_object() { + continue; + } + let mut out = Map::new(); + for (field, leaf) in SECTION_FIELDS { + if let Some(reg) = node.get(field).and_then(|n| valid_reg(n, leaf)) { + out.insert(field.to_string(), reg); + } + } + sections.insert(id.clone(), Value::Object(out)); + } + tree.insert("s".into(), Value::Object(sections)); + } + if let Some(a) = fields.get("a").filter(|a| a.is_object()) { + let assignments = entries(a) + .filter_map(|(id, node)| Some((id.clone(), valid_reg(node, is_string_or_null)?))) + .collect(); + tree.insert("a".into(), Value::Object(assignments)); + } + Some(tree) +} + +/// JS `Math.round`, as an integer when it is a safe one. +fn js_round(f: f64) -> Value { + let rounded = (f + 0.5).floor(); + if rounded.abs() <= MAX_SAFE_INTEGER as f64 { + json!(rounded as i64) + } else { + json!(rounded) + } +} + +/// Registers for a meta-less (legacy/mobile) payload, every value stamped at `legacy_v`. +fn from_legacy(json: &Value, legacy_v: i64) -> Value { + let stamp = |value: Value| json!([legacy_v, LEGACY_DEV, value]); + let mut s = Map::new(); + for entry in json + .get("sections") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let (Some(id), Some(name), Some(order)) = ( + entry.get("id").and_then(Value::as_str), + entry.get("name").and_then(Value::as_str), + entry.get("order").and_then(Value::as_f64), + ) else { + continue; + }; + let icon = entry + .get("icon") + .and_then(Value::as_str) + .map(str::trim) + .filter(|icon| !icon.is_empty()); + s.insert( + id.to_string(), + json!({ + "name": stamp(json!(name)), + "icon": stamp(json!(icon)), + "order": stamp(js_round(order)), + "live": stamp(json!(true)), + }), + ); + } + let a: Map = entries(json.get("assignments").unwrap_or(&Value::Null)) + .filter(|(_, target)| target.as_str().is_some_and(|t| s.contains_key(t))) + .map(|(id, target)| (id.clone(), stamp(target.clone()))) + .collect(); + json!({ "s": s, "a": a }) +} + +/// Decodes a `version: 1` payload, importing a meta-less one at `legacy_v`. +/// Returns `None` when unreadable, including an unknown `meta.v`. +pub(crate) fn decode(json: &Value, legacy_v: i64) -> Option { + let is_one = |value: Option<&Value>| value.and_then(safe_int) == Some(1); + if !is_one(json.get("version")) { + return None; + } + match json.get("meta") { + None => valid_tree(&from_legacy(json, legacy_v)), + Some(meta) if is_one(meta.get("v")) => { + let mut fields = meta.as_object()?.clone(); + fields.remove("v"); + valid_tree(&Value::Object(fields)) + } + Some(_) => None, + } +} + +fn val<'a>(node: &'a Value, field: &str) -> Option<&'a Value> { + node.get(field)?.as_array()?.get(2) +} + +/// Live sections by `(order, id)` with their stored `order`. +fn ranked(tree: &Tree) -> Vec<(String, String, Option, i64)> { + let mut out: Vec<_> = tree + .get("s") + .map(entries) + .into_iter() + .flatten() + .filter(|(_, node)| val(node, "live") == Some(&json!(true))) + .filter_map(|(id, node)| { + let name = val(node, "name")?.as_str()?.to_string(); + let icon = val(node, "icon") + .and_then(Value::as_str) + .filter(|icon| !icon.is_empty()) + .map(str::to_string); + let order = val(node, "order").and_then(safe_int).unwrap_or(0); + Some((id.clone(), name, icon, order)) + }) + .collect(); + // JS compares strings by UTF-16 code unit. + out.sort_by(|a, b| { + a.3.cmp(&b.3) + .then_with(|| a.0.encode_utf16().cmp(b.0.encode_utf16())) + }); + out +} + +/// Highest stored `order` among live sections, or -1 when there are none. +fn max_live_order(tree: &Tree) -> i64 { + ranked(tree).iter().map(|s| s.3).max().unwrap_or(-1) +} + +pub(crate) fn project(tree: &Tree) -> Projection { + let sections: Vec
= ranked(tree) + .into_iter() + .enumerate() + .map(|(order, (id, name, icon, _))| Section { + id, + name, + icon, + order, + }) + .collect(); + let assignments = tree + .get("a") + .map(entries) + .into_iter() + .flatten() + .filter_map(|(channel, reg)| { + let target = reg.as_array()?.get(2)?.as_str()?; + sections + .iter() + .any(|s| s.id == target) + .then(|| (channel.clone(), target.to_string())) + }) + .collect(); + Projection { + sections, + assignments, + } +} + +/// The plaintext document: projected legacy fields plus the register tree. +pub(crate) fn encode(tree: &Tree) -> Value { + let Projection { + sections, + assignments, + } = project(tree); + let mut meta = tree.clone(); + meta.insert("v".into(), json!(1)); + json!({ + "version": 1, + "sections": sections, + "assignments": assignments, + "meta": meta, + }) +} + +fn max_version(node: &Value) -> i64 { + match node { + Value::Array(reg) => reg.first().and_then(safe_int).unwrap_or(0), + Value::Object(map) => map.values().map(max_version).max().unwrap_or(0), + _ => 0, + } +} + +/// The version and device every register written by one edit shares. +#[derive(Debug, Clone)] +pub(crate) struct Stamp { + pub now_ms: i64, + pub dev: String, +} + +/// One edit against a tree. Each call to [`Edit::set`] that changes a leaf +/// writes a register stamped `max(now, newest + 1)`, like desktop `setRegs`. +pub(crate) struct Edit<'a> { + tree: &'a mut Tree, + reg: (i64, String), +} + +impl<'a> Edit<'a> { + pub(crate) fn new(tree: &'a mut Tree, stamp: &Stamp) -> Self { + let newest = tree.values().map(max_version).max().unwrap_or(0); + let v = stamp.now_ms.max(newest + 1); + Self { + tree, + reg: (v, stamp.dev.clone()), + } + } + + fn current(&self, path: &[&str]) -> Option<&Value> { + let (first, rest) = path.split_first()?; + let mut node = self.tree.get(*first)?; + for key in rest { + node = node.get(key)?; + } + node.as_array()?.get(2) + } + + fn write(&mut self, parents: &[&str], leaf: &str, value: Value) { + let reg = json!([self.reg.0, self.reg.1, value]); + let mut node = &mut *self.tree; + for key in parents { + let child = node.entry(key.to_string()).or_insert_with(|| json!({})); + if !child.is_object() { + *child = json!({}); + } + let Value::Object(map) = child else { + return; + }; + node = map; + } + node.insert(leaf.to_string(), reg); + } + + /// Writes `value` at `path` unless it already holds that value. + pub(crate) fn set(&mut self, path: &[&str], value: Value) { + let Some((leaf, parents)) = path.split_last() else { + return; + }; + if self.current(path) != Some(&value) { + self.write(parents, leaf, value); + } + } + + pub(crate) fn create_section(&mut self, id: &str, name: &str, icon: Option<&str>) { + let order = max_live_order(self.tree) + 1; + self.set(&["s", id, "name"], json!(name)); + self.set(&["s", id, "icon"], json!(icon)); + self.set(&["s", id, "order"], json!(order)); + self.set(&["s", id, "live"], json!(true)); + } + + /// Orders live sections `requested` first, then the rest in their current + /// relative order. Any change rewrites every live `order` register. + pub(crate) fn reorder(&mut self, requested: &[String]) { + let live: Vec = project(self.tree) + .sections + .into_iter() + .map(|s| s.id) + .collect(); + let mut ids: Vec = Vec::with_capacity(live.len()); + for id in requested.iter().chain(&live) { + if live.contains(id) && !ids.contains(id) { + ids.push(id.clone()); + } + } + let unchanged = ids + .iter() + .enumerate() + .all(|(index, id)| self.current(&["s", id, "order"]) == Some(&json!(index))); + if unchanged { + return; + } + for (index, id) in ids.iter().enumerate() { + self.write(&["s", id], "order", json!(index)); + } + } + + /// Clears an assignment that exists (as a value or tombstone). + pub(crate) fn unassign(&mut self, channel_id: &str) { + let exists = self.tree.get("a").and_then(|a| a.get(channel_id)).is_some(); + if exists { + self.set(&["a", channel_id], Value::Null); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DEV: &str = "0123456789abcdef"; + + fn stamp(now_ms: i64) -> Stamp { + Stamp { + now_ms, + dev: DEV.into(), + } + } + + fn reg(v: i64, value: Value) -> Value { + json!([v, DEV, value]) + } + + /// A document in the shape desktop `encodeDoc` publishes. + fn desktop_doc() -> Value { + json!({ + "version": 1, + "sections": [], + "assignments": {}, + "meta": { + "v": 1, + "s": { + "b": {"name": reg(5, json!("Beta")), "icon": reg(5, Value::Null), + "order": reg(5, json!(1)), "live": reg(5, json!(true))}, + "a": {"name": reg(4, json!("Alpha")), "icon": reg(4, json!("🚀")), + "order": reg(4, json!(0)), "live": reg(4, json!(true))}, + "dead": {"name": reg(3, json!("Gone")), "icon": reg(3, Value::Null), + "order": reg(3, json!(2)), "live": reg(6, json!(false))}, + "bad": {"name": [7, "not-a-dev", "Bad"], "order": reg(7, json!(3)), + "live": reg(7, json!(true))}, + }, + "a": { + "c1": reg(8, json!("a")), + "c2": reg(8, json!("dead")), + "c3": reg(9, Value::Null), + }, + "future": reg(1, json!("ignored")), + }, + }) + } + + #[test] + fn decodes_desktop_document_and_projects_live_state() { + let tree = decode(&desktop_doc(), 0).expect("readable"); + let view = project(&tree); + let names: Vec<_> = view + .sections + .iter() + .map(|s| (s.name.as_str(), s.order)) + .collect(); + // `bad` keeps its valid registers but has no name, so it is not live. + assert_eq!(names, [("Alpha", 0), ("Beta", 1)]); + assert_eq!(view.sections[0].icon.as_deref(), Some("🚀")); + assert_eq!( + view.assignments, + BTreeMap::from([("c1".to_string(), "a".to_string())]) + ); + assert!(!tree.contains_key("future")); + } + + #[test] + fn unreadable_payloads_are_rejected() { + for payload in [ + json!({"version": 2, "meta": {"v": 1}}), + json!({"version": 1, "meta": {"v": 2}}), + json!({"meta": {"v": 1}}), + json!("text"), + ] { + assert!(decode(&payload, 0).is_none(), "{payload}"); + } + let bad_version = json!({"version": 1.0, "meta": {"v": 1, "s": {"x": { + "order": [i64::MIN, DEV, 0], "live": [-1, DEV, true], "name": [1, DEV, "X"]}}}}); + let tree = decode(&bad_version, 0).expect("readable"); + assert_eq!(tree["s"]["x"], json!({"name": [1, DEV, "X"]})); + } + + #[test] + fn imports_mobile_flat_payload_at_the_event_version() { + let mobile = json!({ + "version": 1, + "sections": [ + {"id": "x", "name": "Team", "icon": " ", "order": 1.6}, + {"id": "y", "name": "Ops", "order": 0}, + {"name": "no id", "order": 0}, + ], + "assignments": {"c1": "x", "c2": "missing"}, + }); + let tree = decode(&mobile, 42_000).expect("readable"); + assert_eq!(tree["s"]["x"]["order"], json!([42_000, LEGACY_DEV, 2])); + assert_eq!(tree["s"]["x"]["icon"], json!([42_000, LEGACY_DEV, null])); + let view = project(&tree); + let names: Vec<_> = view.sections.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["Ops", "Team"]); + assert_eq!(view.assignments.len(), 1); + } + + #[test] + fn encode_round_trips_and_carries_the_projection() { + let tree = decode(&desktop_doc(), 0).expect("readable"); + let doc = encode(&tree); + assert_eq!(doc["meta"]["v"], json!(1)); + assert_eq!(doc["sections"][0]["name"], json!("Alpha")); + assert!(doc["sections"][1].get("icon").is_none()); + assert_eq!(doc["assignments"], json!({"c1": "a"})); + assert_eq!(decode(&doc, 0).expect("readable"), tree); + } + + #[test] + fn edits_share_one_stamp_newer_than_every_register() { + let mut tree = decode(&desktop_doc(), 0).expect("readable"); + let mut edit = Edit::new(&mut tree, &stamp(1)); + edit.create_section("n", "New", None); + edit.set(&["a", "c9"], json!("n")); + assert_eq!(tree["s"]["n"]["order"], reg(10, json!(2))); + assert_eq!(tree["a"]["c9"], reg(10, json!("n"))); + + let mut edit = Edit::new(&mut tree, &stamp(500)); + edit.set(&["s", "n", "name"], json!("New")); + assert_eq!( + tree["s"]["n"]["name"], + reg(10, json!("New")), + "unchanged leaf" + ); + } + + #[test] + fn reorder_puts_requested_first_and_keeps_the_rest() { + let mut tree = Tree::new(); + let mut edit = Edit::new(&mut tree, &stamp(1)); + for id in ["a", "b", "c"] { + edit.create_section(id, id, None); + } + let mut edit = Edit::new(&mut tree, &stamp(2)); + edit.reorder(&["c".into(), "dead".into(), "c".into()]); + let ids: Vec<_> = project(&tree).sections.into_iter().map(|s| s.id).collect(); + assert_eq!(ids, ["c", "a", "b"]); + assert_eq!(tree["s"]["a"]["order"], json!([2, DEV, 1])); + + let before = tree.clone(); + Edit::new(&mut tree, &stamp(3)).reorder(&["c".into(), "a".into()]); + assert_eq!( + tree, before, + "a reorder to the current order writes nothing" + ); + } + + #[test] + fn unassign_tombstones_only_existing_assignments() { + let mut tree = Tree::new(); + let mut edit = Edit::new(&mut tree, &stamp(1)); + edit.create_section("s1", "S", None); + edit.set(&["a", "c1"], json!("s1")); + let mut edit = Edit::new(&mut tree, &stamp(2)); + edit.unassign("c1"); + edit.unassign("never"); + assert_eq!(tree["a"]["c1"], json!([2, DEV, null])); + assert!(tree["a"].get("never").is_none()); + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 9380e82655..5a1e8ae6d2 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -284,6 +284,9 @@ enum Cmd { /// Get and set channel canvas documents #[command(subcommand)] Canvas(CanvasCmd), + /// Organize your own sidebar into channel sections + #[command(subcommand)] + Sidebar(SidebarCmd), /// Add, remove, and list emoji reactions #[command(subcommand)] Reactions(ReactionsCmd), @@ -1022,6 +1025,71 @@ pub enum ReactionsCmd { }, } +#[derive(Subcommand)] +pub enum SidebarCmd { + /// List, create, and arrange your channel sections (synced with Buzz desktop) + #[command(subcommand)] + Sections(SidebarSectionsCmd), +} + +/// `--section` takes a section id, or a section name that is unique. +#[derive(Subcommand)] +pub enum SidebarSectionsCmd { + /// List your sections and the channels assigned to each one + List, + /// Create a section at the end of your sidebar + Create { + /// Section name + #[arg(long)] + name: String, + /// Emoji shown next to the section name + #[arg(long)] + icon: Option, + }, + /// Rename a section or change its icon + Update { + /// Section id or unique name + #[arg(long)] + section: String, + /// New section name + #[arg(long)] + name: Option, + /// New emoji icon + #[arg(long, conflicts_with = "clear_icon")] + icon: Option, + /// Remove the section icon + #[arg(long, default_value_t = false)] + clear_icon: bool, + }, + /// Delete a section; its channels return to the default channel list + Delete { + /// Section id or unique name + #[arg(long)] + section: String, + }, + /// Put sections in order; unlisted sections keep their order after them + Reorder { + /// Section id or unique name, in the new order (repeatable) + #[arg(long = "section", required = true)] + sections: Vec, + }, + /// Move one or more channels into a section + Move { + /// Channel UUID (repeatable) + #[arg(long = "channel", required = true)] + channels: Vec, + /// Section id or unique name + #[arg(long)] + section: String, + }, + /// Return one or more channels to the default channel list + Unassign { + /// Channel UUID (repeatable) + #[arg(long = "channel", required = true)] + channels: Vec, + }, +} + #[derive(Subcommand)] pub enum EmojiCmd { /// List the workspace custom emoji palette (union of every member's set) @@ -2430,6 +2498,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await, Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await, Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await, + Cmd::Sidebar(sub) => commands::sidebar::dispatch(sub, &client).await, Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await, Cmd::Emoji(sub) => commands::emoji::dispatch(sub, &client).await, Cmd::Gifs(sub) => commands::gifs::dispatch(sub, &client).await, @@ -2662,6 +2731,7 @@ mod tests { "projects", "reactions", "repos", + "sidebar", "social", "upload", "users", diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 891383d0cb..01542b2581 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -67,6 +67,7 @@ Output varies by command group — `--help` shows flags but not response shapes. | `mem hash` | SHA-256 hex string | | `mem set/patch/rm` | nothing to stdout; progress to stderr | | `mem ls` | tab-delimited (`slug\tcreated_at\tevent_id`) by default; `--json` for JSON array | +| `sidebar sections list` | `{sections: [{id, name, icon?, order}], assignments: {channel_id: section_id}}` | | `reactions get` | `{"reactions": [{emoji, count, pubkeys}]}` — aggregated, not raw events | | `pack validate/inspect` | human-readable text, not JSON |