diff --git a/codex-rs/agent-message-board-client/src/client.rs b/codex-rs/agent-message-board-client/src/client.rs index 692822d2f8..d09804dc8b 100644 --- a/codex-rs/agent-message-board-client/src/client.rs +++ b/codex-rs/agent-message-board-client/src/client.rs @@ -37,6 +37,7 @@ use codex_message_board::SubscriptionState; use codex_message_board::ThreadPage; use codex_message_board::ThreadQuery; use codex_message_board::ThreadSummary; +use codex_message_board::UpsertChannelRequest; use codex_protocol::AgentPath; use codex_protocol::SessionId; use codex_protocol::ThreadId; @@ -325,6 +326,18 @@ impl AgentMessageBoard for RemoteAgentMessageBoard { ChannelSummary ); operation!(list_channels, ListChannels, ChannelQuery, ChannelPage); + fn upsert_channel( + &self, + _caller: ThreadId, + _request: UpsertChannelRequest, + ) -> BoxFuture<'_, Result<()>> { + // The service has no description-update operation; creation has different semantics. + Box::pin(async { + Err(CodexErr::InvalidRequest( + "remote message boards do not support channel upsert".into(), + )) + }) + } operation!(post, Post, PostRequest, PostMetadata); fn append( &self, diff --git a/codex-rs/agent-message-board-client/tests/remote_board.rs b/codex-rs/agent-message-board-client/tests/remote_board.rs index e9d8ba6516..614b33d4e2 100644 --- a/codex-rs/agent-message-board-client/tests/remote_board.rs +++ b/codex-rs/agent-message-board-client/tests/remote_board.rs @@ -14,6 +14,7 @@ use codex_message_board::PostMetadata; use codex_message_board::PostPreview; use codex_message_board::PostRequest; use codex_message_board::ReadPostRequest; +use codex_message_board::UpsertChannelRequest; use codex_protocol::AgentPath; use codex_protocol::SessionId; use codex_protocol::ThreadId; @@ -146,6 +147,20 @@ async fn remote_board_preserves_the_host_contract() -> anyhow::Result<()> { unsupported.details(), CodexErrorDetails::InvalidRequest(_) )); + let unsupported = board_handle + .upsert_channel( + caller, + UpsertChannelRequest { + channel_name: "results".into(), + description: "Updated purpose".to_owned().try_into()?, + }, + ) + .await + .unwrap_err(); + assert!(matches!( + unsupported.details(), + CodexErrorDetails::InvalidRequest(_) + )); assert_eq!( server.received_requests().await.unwrap().len(), requests_before diff --git a/codex-rs/message-board/src/api.rs b/codex-rs/message-board/src/api.rs index 817d28f472..1b8aa9229d 100644 --- a/codex-rs/message-board/src/api.rs +++ b/codex-rs/message-board/src/api.rs @@ -45,6 +45,15 @@ pub trait AgentMessageBoard: Send + Sync { request: CreateChannelRequest, ) -> BoxFuture<'_, Result>; + /// Creates a channel or replaces only its description. Preserves existing + /// posts, authorship, creation time and subscriptions; never sends notices. + /// Backends without upsert support must reject this rather than substitute creation. + fn upsert_channel( + &self, + caller: ThreadId, + request: UpsertChannelRequest, + ) -> BoxFuture<'_, Result<()>>; + fn list_channels( &self, caller: ThreadId, @@ -137,6 +146,14 @@ pub struct CreateChannelRequest { pub subscription: SubscriptionChange, } +/// Creates or updates navigation metadata without changing stored evidence or delivery. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpsertChannelRequest { + pub channel_name: String, + pub description: ChannelDescription, +} + /// A channel purpose containing at most 512 UTF-8 bytes. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(try_from = "String")] diff --git a/codex-rs/message-board/src/in_memory.rs b/codex-rs/message-board/src/in_memory.rs index 04be204dc4..ff0ca7d757 100644 --- a/codex-rs/message-board/src/in_memory.rs +++ b/codex-rs/message-board/src/in_memory.rs @@ -1,6 +1,7 @@ //! Tree-owned message boards for training. Agent handles share state; nothing is written to disk. //! Mutations and recipient selection are atomic. Host callbacks run outside the state lock. //! Silent appends share the write path without changing subscriptions or delivering notices. +//! Channel upserts only change descriptions of existing channels. //! Opening a board prunes registry entries whose state has been released. use crate::AppendRequest; @@ -15,6 +16,7 @@ use crate::SubscriptionChange; use crate::SubscriptionRequest; use crate::SubscriptionState; use crate::SubscriptionTarget; +use crate::UpsertChannelRequest; use crate::api::PostBehavior; use caseless::default_case_fold_str; use chrono::DateTime; @@ -161,15 +163,7 @@ impl State { author: AgentPath, now: DateTime, ) -> Result<()> { - if name.is_empty() - || name.len() > 128 - || name.trim() != name - || name.chars().any(char::is_control) - { - return Err(invalid( - "channel names must contain 1–128 bytes without edge whitespace or control characters", - )); - } + validate_channel(name)?; if self.channels.contains_key(name) { return Err(invalid("channel already exists")); } @@ -234,6 +228,33 @@ impl InMemoryAgentMessageBoard { Ok(state.channels[&request.channel_name].summary.clone()) } + async fn upsert_channel(&self, caller: ThreadId, request: UpsertChannelRequest) -> Result<()> { + validate_channel(&request.channel_name)?; + let author = self.host.agent_path(caller).await?; + { + let mut state = self.state.lock().await; + if let Some(channel) = state.channels.get_mut(&request.channel_name) { + channel.summary.description = Some(request.description.into()); + return Ok(()); + } + } + // Fetch insertion metadata without holding the state lock, then recheck + // in case another caller created the channel while the clock was pending. + let now = self.host.current_time(caller).await?; + let mut state = self.state.lock().await; + if let Some(channel) = state.channels.get_mut(&request.channel_name) { + channel.summary.description = Some(request.description.into()); + Ok(()) + } else { + state.insert_channel( + &request.channel_name, + Some(request.description.into()), + author, + now, + ) + } + } + async fn post(&self, caller: ThreadId, request: PostRequest) -> Result { self.write(caller, request, PostBehavior::Discussion).await } @@ -446,6 +467,19 @@ impl InMemoryAgentMessageBoard { } } +fn validate_channel(name: &str) -> Result<()> { + if name.is_empty() + || name.len() > 128 + || name.trim() != name + || name.chars().any(char::is_control) + { + return Err(invalid( + "channel names must contain 1–128 bytes without edge whitespace or control characters", + )); + } + Ok(()) +} + fn invalid(message: impl Into) -> CodexErr { CodexErr::InvalidRequest(message.into()) } diff --git a/codex-rs/message-board/src/in_memory/queries.rs b/codex-rs/message-board/src/in_memory/queries.rs index b1fc785290..f41ae2c19f 100644 --- a/codex-rs/message-board/src/in_memory/queries.rs +++ b/codex-rs/message-board/src/in_memory/queries.rs @@ -24,6 +24,7 @@ use crate::ThreadPage; use crate::ThreadQuery; use crate::ThreadSort; use crate::ThreadSummary; +use crate::UpsertChannelRequest; use base64::Engine; use base64::prelude::BASE64_URL_SAFE_NO_PAD; use caseless::default_case_fold_str; @@ -46,6 +47,14 @@ impl AgentMessageBoard for InMemoryAgentMessageBoard { Box::pin(self.create_channel(caller, request)) } + fn upsert_channel( + &self, + caller: ThreadId, + request: UpsertChannelRequest, + ) -> BoxFuture<'_, Result<()>> { + Box::pin(self.upsert_channel(caller, request)) + } + fn post(&self, caller: ThreadId, request: PostRequest) -> BoxFuture<'_, Result> { Box::pin(self.post(caller, request)) } diff --git a/codex-rs/message-board/src/lib.rs b/codex-rs/message-board/src/lib.rs index 5b9355f5f7..770b5ca500 100644 --- a/codex-rs/message-board/src/lib.rs +++ b/codex-rs/message-board/src/lib.rs @@ -28,6 +28,7 @@ pub use api::SubscriptionRequest; pub use api::SubscriptionTarget; pub use api::ThreadQuery; pub use api::ThreadSort; +pub use api::UpsertChannelRequest; pub use host::MessageBoardHost; pub use host::NotificationDelivery; pub use in_memory::InMemoryAgentMessageBoard; diff --git a/codex-rs/message-board/src/local/queries.rs b/codex-rs/message-board/src/local/queries.rs index a8f0f71084..882a609dcb 100644 --- a/codex-rs/message-board/src/local/queries.rs +++ b/codex-rs/message-board/src/local/queries.rs @@ -1,4 +1,4 @@ -//! Implements the board contract using bounded SQLite queries with ordinary offset pagination. +//! Bounded board queries and channel-description updates that preserve existing evidence. use super::LocalAgentMessageBoard; use super::MAX_READ_CHARS; @@ -10,6 +10,7 @@ use super::paging::decode_posts; use super::paging::direction; use super::paging::preview; use super::storage_error; +use super::validate_channel; use crate::AgentMessageBoard; use crate::AppendRequest; use crate::BoardPermissions; @@ -31,6 +32,7 @@ use crate::ThreadPage; use crate::ThreadQuery; use crate::ThreadSort; use crate::ThreadSummary; +use crate::UpsertChannelRequest; use caseless::default_case_fold_str; use codex_protocol::SessionId; use codex_protocol::ThreadId; @@ -38,11 +40,63 @@ use codex_protocol::error::Result; use futures::future::BoxFuture; use sqlx::QueryBuilder; +impl LocalAgentMessageBoard { + /// Creates a channel or updates its navigation description without replacing posts, + /// its original author, or its creation time. Only writable connections may call this. + pub async fn upsert_channel( + &self, + caller: ThreadId, + request: UpsertChannelRequest, + ) -> Result<()> { + if let Some(access) = self.access { + access.require_write(self.identity, "channel.edit_metadata")?; + } + let UpsertChannelRequest { + channel_name, + description, + } = request; + validate_channel(&channel_name)?; + let author = self.host.agent_path(caller).await?; + let mut tx = self.begin_write().await?; + let updated = sqlx::query("UPDATE channels SET description=? WHERE board=? AND name=?") + .bind(description.as_str()) + .bind(self.identity.to_string()) + .bind(&channel_name) + .execute(&mut *tx) + .await + .map_err(storage_error)? + .rows_affected(); + if updated != 0 { + return tx.commit().await.map_err(storage_error); + } + tx.rollback().await.map_err(storage_error)?; + // Only creation needs a timestamp. Do not hold a write transaction across + // the host clock call; a concurrent creator is handled by the upsert below. + let now = self.host.current_time(caller).await?; + let mut tx = self.begin_write().await?; + sqlx::query("INSERT INTO channels(board,name,name_search,created_at,timestamp,author,description) VALUES(?,?,?,?,?,?,?) ON CONFLICT(board,name) DO UPDATE SET description=excluded.description") + .bind(self.identity.to_string()).bind(&channel_name).bind(default_case_fold_str(&channel_name)) + .bind(now.to_rfc3339()).bind(now.timestamp_micros()).bind(author.to_string()).bind(description.as_str()) + .execute(&mut *tx).await.map_err(storage_error)?; + tx.commit().await.map_err(storage_error) + } +} + impl AgentMessageBoard for LocalAgentMessageBoard { fn identity(&self) -> SessionId { self.identity } + fn upsert_channel( + &self, + caller: ThreadId, + request: UpsertChannelRequest, + ) -> BoxFuture<'_, Result<()>> { + Box::pin(LocalAgentMessageBoard::upsert_channel( + self, caller, request, + )) + } + fn create_channel( &self, caller: ThreadId, diff --git a/codex-rs/message-board/src/tools.rs b/codex-rs/message-board/src/tools.rs index b3b854b41e..9102ac9cc6 100644 --- a/codex-rs/message-board/src/tools.rs +++ b/codex-rs/message-board/src/tools.rs @@ -169,6 +169,11 @@ impl BoardTool { } }; match self.tool { + MessageBoardTool::UpsertChannel => { + let request = serde_json::from_str(raw).map_err(model_error)?; + self.check_mutation_budget(budget, /*target_path_bytes*/ 0)?; + encode(board.upsert_channel(caller, request).await) + } MessageBoardTool::CreateChannel => { let arguments::CreateChannel { channel_name, diff --git a/codex-rs/message-board/src/tools/selection.rs b/codex-rs/message-board/src/tools/selection.rs index 86755e5600..cbce5dc7d9 100644 --- a/codex-rs/message-board/src/tools/selection.rs +++ b/codex-rs/message-board/src/tools/selection.rs @@ -5,6 +5,8 @@ #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MessageBoardTool { CreateChannel, + /// Creates a channel or updates its description, without subscriptions or notifications. + UpsertChannel, GetChannels, ListThreads, SearchPosts, @@ -35,6 +37,7 @@ impl MessageBoardTool { pub(super) fn name(self) -> &'static str { match self { Self::CreateChannel => "create_channel", + Self::UpsertChannel => "upsert_channel", Self::GetChannels => "get_channels", Self::ListThreads => "list_threads", Self::SearchPosts => "search_posts", diff --git a/codex-rs/message-board/src/tools/spec.rs b/codex-rs/message-board/src/tools/spec.rs index 79a0a0691e..88ebb1a9ac 100644 --- a/codex-rs/message-board/src/tools/spec.rs +++ b/codex-rs/message-board/src/tools/spec.rs @@ -20,6 +20,11 @@ pub(super) fn tool( &["channel_name", "description", "subscribe"], &["channel_name"], ), + MessageBoardTool::UpsertChannel => ( + "Create a channel or update only its description. Existing posts, author, creation time and subscriptions are preserved. Does not subscribe anyone or send notifications. Returns null on success.", + &["channel_name", "description"], + &["channel_name", "description"], + ), MessageBoardTool::GetChannels => ( "List channels, most recently active first, or search by a case-insensitive part of the name. Creating a channel or posting in it counts as activity.", &["query", "recent_first", "limit", "cursor"], @@ -90,6 +95,9 @@ pub(super) fn tool( let mut properties = serde_json::Map::new(); for field in fields { let schema = match *field { + "description" if tool == MessageBoardTool::UpsertChannel => { + json!({"type":"string","description":"Channel purpose, at most 512 UTF-8 bytes."}) + } "description" => { json!({"type":"string","description":"Optional channel purpose, at most 512 UTF-8 bytes."}) } diff --git a/codex-rs/message-board/tests/local_board.rs b/codex-rs/message-board/tests/local_board.rs index 9486bcf9dd..bc38e02b70 100644 --- a/codex-rs/message-board/tests/local_board.rs +++ b/codex-rs/message-board/tests/local_board.rs @@ -28,6 +28,7 @@ use pretty_assertions::assert_eq; struct Host { clock: AtomicI64, + fail_clock: AtomicBool, agent_path_calls: AtomicUsize, members: HashMap, active: AtomicBool, @@ -57,6 +58,9 @@ impl MessageBoardHost for Host { fn current_time(&self, _caller: ThreadId) -> BoxFuture<'_, Result>> { Box::pin(async move { + if self.fail_clock.load(Ordering::SeqCst) { + return Err(CodexErr::Io(std::io::Error::other("clock unavailable"))); + } Ok(DateTime::parse_from_rfc3339("2026-09-18T12:00:00Z") .map_err(|error| CodexErr::Io(std::io::Error::other(error)))? .with_timezone(&Utc) @@ -95,6 +99,7 @@ async fn opening_board_preserves_posts_when_corruption_has_no_startup_recovery() let root = ThreadId::new(); let host = Arc::new(Host { clock: AtomicI64::default(), + fail_clock: AtomicBool::new(false), agent_path_calls: AtomicUsize::default(), members: [(root, AgentPath::root())].into(), fail_notifications: AtomicBool::new(false), @@ -159,6 +164,7 @@ async fn shared_handles_resume_posts_and_preserve_subscription_rules() { let child_path = AgentPath::root().join("worker").unwrap(); let host = Arc::new(Host { clock: AtomicI64::default(), + fail_clock: AtomicBool::new(false), agent_path_calls: AtomicUsize::default(), members: [(root, AgentPath::root()), (child, child_path.clone())].into(), fail_notifications: AtomicBool::new(false), @@ -458,6 +464,7 @@ async fn in_memory_handles_share_posts_deduplicate_calls_and_release_state() { let child_path = AgentPath::root().join("worker").unwrap(); let host = Arc::new(Host { clock: AtomicI64::default(), + fail_clock: AtomicBool::new(false), agent_path_calls: AtomicUsize::default(), members: [(root, AgentPath::root()), (child, child_path.clone())].into(), active: AtomicBool::new(true), @@ -544,6 +551,7 @@ async fn check_failed_requests_do_not_create_channels_or_notify_inactive_agents( let child_path = AgentPath::root().join("worker").unwrap(); let host = Arc::new(Host { clock: AtomicI64::default(), + fail_clock: AtomicBool::new(false), agent_path_calls: AtomicUsize::default(), members: [(root, AgentPath::root()), (child, child_path.clone())].into(), fail_notifications: AtomicBool::new(false), @@ -615,6 +623,7 @@ async fn check_queries_enforce_page_and_preview_caps(backend: Backend) { let host = Arc::new(Host { fail_notifications: AtomicBool::new(false), clock: AtomicI64::default(), + fail_clock: AtomicBool::new(false), agent_path_calls: AtomicUsize::default(), members: [(root, AgentPath::root())].into(), active: AtomicBool::new(false), @@ -711,6 +720,7 @@ async fn check_queries_page_discussions_and_search_unicode(backend: Backend) { let host = Arc::new(Host { fail_notifications: AtomicBool::new(false), clock: AtomicI64::default(), + fail_clock: AtomicBool::new(false), agent_path_calls: AtomicUsize::default(), members: [(root, AgentPath::root())].into(), active: AtomicBool::new(true), @@ -981,6 +991,7 @@ async fn check_tools_cover_channel_discussions_subscriptions_and_escaped_preview fail_notifications: AtomicBool::new(false), members: [(root, AgentPath::root()), (child, child_path.clone())].into(), clock: AtomicI64::default(), + fail_clock: AtomicBool::new(false), agent_path_calls: AtomicUsize::default(), active: AtomicBool::new(true), notifications: Mutex::default(), @@ -1213,15 +1224,16 @@ async fn check_tools_validate_arguments_deduplicate_calls_and_bound_unicode_resu let child_path = AgentPath::root().join("worker").unwrap(); let host = Arc::new(Host { fail_notifications: AtomicBool::new(false), - members: [(root, AgentPath::root()), (child, child_path)].into(), + members: [(root, AgentPath::root()), (child, child_path.clone())].into(), clock: AtomicI64::default(), + fail_clock: AtomicBool::new(false), agent_path_calls: AtomicUsize::default(), active: AtomicBool::new(true), notifications: Mutex::default(), }); let board = backend.open(&sqlite, root.into(), host.clone()).await; let mut selected = MessageBoardTool::DISCUSSION.to_vec(); - selected.push(MessageBoardTool::Append); + selected.extend([MessageBoardTool::Append, MessageBoardTool::UpsertChannel]); let tools = message_board_tools( &selected, board.clone(), @@ -1248,6 +1260,10 @@ async fn check_tools_validate_arguments_deduplicate_calls_and_bound_unicode_resu json!({"new_channel_name":"low-budget-append","text":"must not be stored"}), ), ("create_channel", json!({"channel_name":"low-budget"})), + ( + "upsert_channel", + json!({"channel_name":"low-budget-upsert","description":"must not be stored"}), + ), ] { let mut limited = call(name, args); limited.truncation_policy = TruncationPolicy::Bytes(1); @@ -1286,6 +1302,20 @@ async fn check_tools_validate_arguments_deduplicate_calls_and_bound_unicode_resu ] { assert!(tool("append").handle(call("append", args)).await.is_err()); } + for args in [ + json!({"channel_name":"policy"}), + json!({"description":"missing channel"}), + json!({"channel_name":"policy","description":"é".repeat(257)}), + json!({"channel_name":"policy","description":"invalid option","subscribe":true}), + json!({"channel_name":"policy","description":"invalid option","permissions":{}}), + ] { + assert!( + tool("upsert_channel") + .handle(call("upsert_channel", args)) + .await + .is_err() + ); + } assert!( tool("get_channels") .handle(call("get_channels", json!({"limit":0}))) @@ -1515,6 +1545,107 @@ async fn check_tools_validate_arguments_deduplicate_calls_and_bound_unicode_resu .unwrap(); } assert_eq!(*host.notifications.lock().unwrap(), Vec::new()); + + // Upsert creates metadata without subscribing its author. Updating from another + // caller changes only the description, preserving the channel and its evidence. + host.clock.store(100, Ordering::SeqCst); + let upsert = call( + "upsert_channel", + json!({"channel_name":"policy","description":"Original policy"}), + ); + assert_eq!( + tool("upsert_channel") + .handle(upsert) + .await + .unwrap() + .log_output(), + "null" + ); + let created = tool("get_channels") + .handle(call("get_channels", json!({"query":"policy"}))) + .await + .unwrap(); + assert_eq!( + serde_json::from_str::>(&created.log_output()).unwrap(), + Page { + results: vec![ChannelSummary { + channel_name: "policy".into(), + description: Some("Original policy".into()), + permissions: None, + created_at: "2026-09-18T12:01:40Z".parse().unwrap(), + created_by: AgentPath::root(), + message_count: 0, + last_message_id: None, + }], + next_cursor: None, + } + ); + board + .post( + child, + PostRequest { + request_id: "policy-evidence".into(), + destination: PostDestination::Channel("policy".into()), + text: "Deployment requires approval.".into(), + agents_to_notify: Vec::new(), + }, + ) + .await + .unwrap(); + let channels = call("get_channels", json!({"query":"policy"})); + let before = tool("get_channels").handle(channels.clone()).await.unwrap(); + let mut expected: Page = serde_json::from_str(&before.log_output()).unwrap(); + let child_tools = message_board_tools( + &[MessageBoardTool::UpsertChannel], + board, + child, + child_path, + Some("collaboration"), + "Shared tools", + /*tool_messages*/ None, + ); + host.fail_clock.store(true, Ordering::SeqCst); + child_tools[0] + .handle(call( + "upsert_channel", + json!({"channel_name":"policy","description":"Updated policy"}), + )) + .await + .unwrap(); + expected.results[0].description = Some("Updated policy".into()); + let after = tool("get_channels").handle(channels).await.unwrap(); + assert_eq!( + serde_json::from_str::>(&after.log_output()).unwrap(), + expected + ); + assert!( + child_tools[0] + .handle(call( + "upsert_channel", + json!({"channel_name":"clock-missing","description":"Must not be created"}), + )) + .await + .is_err() + ); + let missing = tool("get_channels") + .handle(call("get_channels", json!({"query":"clock-missing"}))) + .await + .unwrap(); + assert_eq!( + serde_json::from_str::>(&missing.log_output()).unwrap(), + Page { + results: Vec::new(), + next_cursor: None + } + ); + host.fail_clock.store(false, Ordering::SeqCst); + let mut posted = call( + "post", + json!({"channel_name":"policy","text":"More policy evidence."}), + ); + posted.call_id = "after-upsert".into(); + tool("post").handle(posted).await.unwrap(); + assert_eq!(*host.notifications.lock().unwrap(), Vec::new()); } #[test] @@ -1554,6 +1685,7 @@ async fn channel_descriptions_migrate_legacy_storage_and_survive_reopen() { let root = ThreadId::new(); let host = Arc::new(Host { clock: AtomicI64::default(), + fail_clock: AtomicBool::new(false), agent_path_calls: AtomicUsize::default(), members: [(root, AgentPath::root())].into(), active: AtomicBool::new(true), @@ -1634,6 +1766,7 @@ async fn silent_appends_preserve_evidence_and_explicit_subscriptions() let child = ThreadId::new(); let host = Arc::new(Host { clock: AtomicI64::default(), + fail_clock: AtomicBool::new(false), agent_path_calls: AtomicUsize::default(), members: [ (root, AgentPath::root()), @@ -1684,6 +1817,51 @@ async fn silent_appends_preserve_evidence_and_explicit_subscriptions() .await .is_err() ); + let query = ChannelQuery { + query: None, + direction: SortDirection::OldestFirst, + page: PageRequest::default(), + }; + let before = writer + .list_channels(root, query.clone()) + .await? + .channels + .results + .remove(0); + writer + .upsert_channel( + child, + UpsertChannelRequest { + channel_name: channel.into(), + description: "Release decisions".to_owned().try_into()?, + }, + ) + .await?; + for (board, can_write) in [(&writer, true), (&reader, false)] { + assert_eq!( + board.list_channels(root, query.clone()).await?, + ChannelPage { + channels: Page { + results: vec![ChannelSummary { + description: Some("Release decisions".into()), + permissions: Some(ChannelPermissions { + read: true, + post: can_write, + reply: can_write, + edit_metadata: false, + }), + ..before.clone() + }], + next_cursor: None, + }, + board_permissions: Some(BoardPermissions { + create_channel: can_write, + edit_metadata: false, + manage_permissions: false, + }), + } + ); + } assert_eq!( reader .read_post( @@ -1702,6 +1880,18 @@ async fn silent_appends_preserve_evidence_and_explicit_subscriptions() next_offset_chars: request.text.chars().count(), } ); + assert!( + reader + .upsert_channel( + child, + UpsertChannelRequest { + channel_name: channel.into(), + description: "Changed".to_owned().try_into()?, + }, + ) + .await + .is_err() + ); writer .create_channel( root, @@ -1796,6 +1986,7 @@ async fn local_board_setup_survives_reopen() -> Result<()> { let root = ThreadId::new(); let host = Arc::new(Host { clock: AtomicI64::default(), + fail_clock: AtomicBool::new(false), agent_path_calls: AtomicUsize::default(), members: [(root, AgentPath::root())].into(), active: AtomicBool::new(true), @@ -1894,6 +2085,7 @@ async fn storage_namespaces_preserve_isolation_and_reopen() -> Result<()> { let child_path = AgentPath::root().join("worker").unwrap(); let host = Arc::new(Host { clock: AtomicI64::default(), + fail_clock: AtomicBool::new(false), agent_path_calls: AtomicUsize::default(), members: [(root, AgentPath::root()), (child, child_path)].into(), active: AtomicBool::new(true),