mirror of
https://github.com/openai/codex.git
synced 2026-10-24 18:18:41 +00:00
Add channel upserts to message boards (#56494)
## What changed Add an `upsert_channel` API and selectable tool to create a channel or replace only its description. Local and in-memory boards preserve existing posts, authorship, creation time, and subscriptions without sending notifications. Updating an existing channel does not require the host clock. Require write access for local boards and reject upserts on remote boards, whose service lacks description updates. ## Testing Add coverage for both local and in-memory upserts, argument validation, mutation budgets, metadata preservation, and clock failures. Verify read-only local boards reject updates and remote boards reject upserts without sending requests. GitOrigin-RevId: e667375a62784f2fbeeff72359b5b0fa476e061e
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,6 +45,15 @@ pub trait AgentMessageBoard: Send + Sync {
|
||||
request: CreateChannelRequest,
|
||||
) -> BoxFuture<'_, Result<ChannelSummary>>;
|
||||
|
||||
/// 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")]
|
||||
|
||||
@@ -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<Utc>,
|
||||
) -> 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<PostMetadata> {
|
||||
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<String>) -> CodexErr {
|
||||
CodexErr::InvalidRequest(message.into())
|
||||
}
|
||||
|
||||
@@ -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<PostMetadata>> {
|
||||
Box::pin(self.post(caller, request))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."})
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ use pretty_assertions::assert_eq;
|
||||
|
||||
struct Host {
|
||||
clock: AtomicI64,
|
||||
fail_clock: AtomicBool,
|
||||
agent_path_calls: AtomicUsize,
|
||||
members: HashMap<ThreadId, AgentPath>,
|
||||
active: AtomicBool,
|
||||
@@ -57,6 +58,9 @@ impl MessageBoardHost for Host {
|
||||
|
||||
fn current_time(&self, _caller: ThreadId) -> BoxFuture<'_, Result<DateTime<Utc>>> {
|
||||
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::<Page<ChannelSummary>>(&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<ChannelSummary> = 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::<Page<ChannelSummary>>(&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::<Page<ChannelSummary>>(&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),
|
||||
|
||||
Reference in New Issue
Block a user