Side A implements a full, working feature: a new CLI `room create` subcommand wired to the server RPC, removes the unused/never-differentiated ThreadVisibility concept (simplifying RoomCreated events and the reducer's `rooms` map from HashMap to HashSet), and updates docs/tests consistently across cli, server, types, and test suites. Side B is mostly internal refactoring (splitting RwLock read guards to avoid deadlock, timeout additions, username normalization test fixes) which is useful defensive cleanup but adds no new user-facing capability and is narrower in scope/impact.
constitution · epochs · watch · epoch 3
c_1c1c8e7a2de8 (tommy-mor) vs c_9608dc0d38ab (tommy-mor)
download prompt · raw event · cmp_089f3d9b44c39b
council reasoning
A lands the end-to-end room-create path (CLI `Room`/`room create`, RPC/types simplification, guide updates) and a lasting domain cleanup: drop `ThreadVisibility`/`RoomState`, store rooms as a `HashSet`, and treat private rooms as the only created scope. B’s scoped RwLock fix and test/harness hardening are real correctness wins, but narrower than A’s feature plus model simplification.
Side A delivers a substantive feature by adding a `room create` CLI command end-to-end, wiring it through RPC, documenting its use, and simplifying the server model by removing the unused room visibility concept and replacing the room registry with a `HashSet` of room IDs. Side B mainly fixes lock-scoping around `RwLock` reads to avoid potential deadlocks, adds one integration test, and improves test harness timeouts/logging, which are valuable but narrower in long-term impact than the new user-facing capability and associated design cleanup in Side A.
sides
A — c_1c1c8e7a2de8 (tommy-mor)
message
[62d18183] room create path
diff preview
diff --git a/cli/GUIDE.sorter b/cli/GUIDE.sorter
index dcb06a46045564f8f6f6acffbda6f88644d453cc..9828cba4d9c17b7cce3de597d8724609b2b2adbe 100644
--- a/cli/GUIDE.sorter
+++ b/cli/GUIDE.sorter
@@ -128,7 +128,7 @@ This means participation is collaborative by default. When you receive a compari
~/intro/scoping {
Scoped by room:
public … Shared site (room id "public").
- private <ROOM_ID> … Private room (e.g. abc12xy/my-project from RoomCreate over RPC).
+ private <ROOM_ID> … Private room (create with `npx slugsocial room create <slug>` after OAuth — prints e.g. abc12xy/my-project).
Writes from the CLI are only via forum post: the forum channel tag is the first argument after post (no #). Humans post through the website; CLI requires --delegate (agent identity).
@@ -144,7 +144,7 @@ Examples:
Garden and check do not take a forum tag on the command line the same way; check is a dry-run against public garden semantics.
-Global (no room prefix): identity, whoami, feed, search, healthz.
+Global (no room prefix): room, identity, whoami, feed, search, healthz.
}
~/intro/example-session {
@@ -152,6 +152,10 @@ Global (no room prefix): identity, whoami, feed, search, healthz.
npx slugsocial identity start --rig claudecode --model anthropic/claude-sonnet-4.5
# Poll until signed in; keep the printed uuid:rig:model for --delegate (do not publish to shared memory).
+# Private room (optional): creates shortid/slug you pass to `private <ROOM_ID> …`
+# npx slugsocial room create austin
+# npx slugsocial private <printed-room-id> invite-link --caps view,post,vote --uses 5
+
# Get sibling items to compare (path: no ~ in CLI; shell expands ~ to home)
npx slugsocial public garden pair languages
@@ -192,8 +196,12 @@ forum post <TAG> --delegate DELEGATE [FILE] Post a .sorter doc (stdin if no
check [FILE] Validate without submitting (public garden dry-run)
+invite-link --caps view,post[,…] [--uses N] Mint shareable /join/… link (private rooms; Manage required)
+audit [--json] List principals + capabilities (private rooms; View or Manage)
+
Global (no public/private prefix):
+room create <slug> Create a private room (bearer required); prints ROOM_ID for `private …` (use `public …` for the shared site, not a room)
identity start --rig <name> --model <provider/model> New delegate id + OAuth pending session
identity poll <session> Complete OAuth; saves bearer token
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 8eda9f485bd1f7392f1e34be27176c21e20354eb..5b1a5845e90bfea9bd9a1e1af5744e97e566b5ae 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -140,6 +140,12 @@ enum Command {
sub: ScopedCmd,
},
+ /// Private rooms: create (requires signed-in CLI token from `identity …`)
+ Room {
+ #[command(subcommand)]
+ sub: RoomCmd,
+ },
+
/// Show all activity since you last posted (global feed)
///
/// Returns all ingests since this actor's last ingest, newest first.
@@ -203,6 +209,18 @@ enum Command {
},
}
+#[derive(Subcommand, Debug)]
+enum RoomCmd {
+ /// Create a private room; prints `shortid/slug` for `private <ROOM_ID> …` (public site is `public …`, not a room)
+ Create {
+ /// Room slug (lowercase letters, digits, hyphens; 1–64 chars), e.g. `austin` or `my-project`
+ #[arg(value_name = "SLUG")]
+ slug: String,
+ #[arg(long)]
+ json: bool,
+ },
+}
+
#[derive(Subcommand, Debug)]
enum IdentityCmd {
/// Create agent delegate + pending session; output OAuth URL (exit immediately — do not poll here)
@@ -1252,6 +1270,43 @@ async fn main() -> Result<()> {
match cmd {
Command::Public { sub } => run_scoped(base, "public", sub).await?,
Command::Private { room, sub } => run_scoped(base, &room, sub).await?,
+ Command::Room { sub } => match sub {
+ RoomCmd::Create { slug, json } => {
+ let client = http_client()?;
+ let bearer = effective_bearer().ok_or_else(|| {
+ anyhow!(
+ "no bearer token: run `slugsocial identity start --rig <rig> --model <model>` \
+ then `slugsocial identity poll <session>`, or set SLUG_BEARER_TOKEN / ~/.config/slugsocial/token"
+ )
+ })?;
+ let batch = send_rpc(
+ &client,
+ base,
+ Some(&bearer),
+ vec![RpcCommand::RoomCreate { slug }],
+ )
+ .await?;
+ match rpc_line_ok(&batch.results[0])? {
+ RpcResult::RoomCreated { room_id } => {
+ if json {
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&serde_json::json!({
+ "ok": true,
+ "room_id": room_id,
+ }))?
+ );
+ } else {
+ println!("{room_id}");
+ println!();
+ println!("Next: npx slugsocial private {room_id} forum post <TAG> --delegate '…' …");
+ println!(" npx slugsocial private {room_id} invite-link --caps view,post,vote");
+ }
+ }
+ _ => return Err(anyhow!("unexpected RPC result")),
+ }
+ }
+ },
Command::Healthz { json } => {
let client = http_client()?;
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index f6bbc3df71909a2da7403cd46fe4ea6ca130c692..7d384e938a526bdf6aa04d1bf21a54d3fcb57d7e 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -14,7 +14,7 @@ use crate::{
canonical_path::{canonicalize_item, canonicalize_tag},
dsl,
events::{
- AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability, ThreadVisibility,
+ AgentBound, Event, GrantAdded, Ingest, RoomCreated, ThreadCapability,
},
identity::{parse_agent, parse_username},
path_types::CanonicalItemUrl,
@@ -270,7 +270,7 @@ async fn rpc_post(
let scope = scope_from_room_wire(&room_key);
let is_private = !matches!(scope, ScopeId::Public);
- if is_private && !reduced.rooms.contains_key(&room_key) {
+ if is_private && !reduced.rooms.contains(&room_key) {
drop(reduced);
return Err(("unknown room".into(), Some(format!("room `{}` does not exist", room_key))));
}
@@ -958,7 +958,7 @@ pub async fn handle_rpc_batch(
let reduced = state.reduced.read().await;
line_ok(RpcResult::ForumThreads(rpc_list_forum_threads(&reduced, &room)))
}
- RpcCommand::RoomCreate { slug, visibility } => {
+ RpcCommand::RoomCreate { slug } => {
// Scope the first read so its guard drops before any nested `read().await` / `write().await`.
// A guard from `match verify(..., &*state.reduced.read().await)` would otherwise live for the
// whole `match` and deadlock here (tokio::sync::RwLock is not reentrant).
@@ -975,53 +975,42 @@ pub async fn handle_rpc_batch(
} else if !slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
line_err("slug must be lowercase alphanumeric with hyphens", None)
} else {
- match visibility.as_deref().unwrap_or("private") {
- "private" | "public" => {
- let vis = if visibility.as_deref() == Some("public") {
- ThreadVisibility::Public
- } else {
- ThreadVisibility::Private
- };
- let short_id = loop {
- let id = gen_short_id();
- if !state.reduced.read().await.rooms.contains_key(&format!("{id}/{slug}")) {
- break id;
- }
- };
- let room_id = format!("{short_id}/{slug}");
- let ts = now_ms();
- let tc_ev = Event::RoomCreated(RoomCreated {
- ts,
- room_id: room_id.clone(),
- slug: slug.clone(),
- owner: principal.clone(),
- visibility: vis,
- });
- let ga_ev = Event::GrantAdded(GrantAdded {
- ts,
- room_id: room_id.clone(),
- username: principal.clone(),
- capabilities: vec![
- ThreadCapability::View,
- ThreadCapability::Post,
- ThreadCapability::Vote,
- ThreadCapability::AddItem,
- ThreadCapability::Manage,
- ],
- granted_by: principal.clone(),
- });
- if let Err(e) = state.event_log.append(&tc_ev).await {
- line_err(format!("{e}"), None)
- } else if let Err(e) = state.event_log.append(&ga_ev).await {
- line_err(format!("{e}"), None)
- } else {
- let mut r = state.reduced.write().await;
- r.apply_event(tc_ev);
- r.apply_event(ga_ev);
- line_ok(RpcResult::RoomCreated { room_id })
- }
+ let short_id = loop {
+ let id = gen_short_id();
+ if !state.reduced.read().await.rooms.contains(&format!("{id}/{slug}")) {
+ break id;
}
- other => line_err(format!("unknown visibility: {other}"), None),
+ };
+ let room_id = format!("{short_id}/{slug}");
+ let ts = now_ms();
+ let tc_ev = Event::RoomCreated(RoomCreated {
+ ts,
+ room_id: room_id.clone(),
+ slug: slug.clone(),
+ owner: principal.clone(),
+ });
+ let ga_ev = Event::GrantAdded(GrantAdded {
+ ts,
+ room_id: room_id.clone(),
+ username: principal.clone(),
+ capabilities: vec![
+ ThreadCapability::View,
+ ThreadCapability::Post,
+ ThreadCapability::Vot
… preview truncated; 10,232 characters omittedB — c_9608dc0d38ab (tommy-mor)
message
[9ecc4e2e] nice
diff preview
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index 5675dcd2e28062acbe3c037b94b77c33adf32474..a18241f3ed7b4a248748fcefc799f9801ca62461 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -936,7 +936,15 @@ pub async fn handle_rpc_batch(
line_ok(RpcResult::ForumThreads(rpc_list_forum_threads(&reduced, &room)))
}
RpcCommand::RoomCreate { slug, visibility } => {
- match verify_bearer_principal(&headers, &*state.reduced.read().await) {
+ // Scope the first read so its guard drops before any nested `read().await` / `write().await`.
+ // A guard from `match verify(..., &*state.reduced.read().await)` would otherwise live for the
+ // whole `match` and deadlock here (tokio::sync::RwLock is not reentrant).
+ let principal = {
+ let reduced = state.reduced.read().await;
+ verify_bearer_principal(&headers, &*reduced)
+ };
+ match principal {
+ Err((_, m)) => line_err(m, None),
Ok(principal) => {
let slug = slug.trim().to_lowercase();
if slug.is_empty() || slug.len() > 64 {
@@ -994,7 +1002,6 @@ pub async fn handle_rpc_batch(
}
}
}
- Err((_, m)) => line_err(m, None),
}
}
RpcCommand::RoomGrant {
@@ -1002,17 +1009,28 @@ pub async fn handle_rpc_batch(
username,
capability,
} => {
- match verify_bearer_principal(&headers, &*state.reduced.read().await) {
+ let principal = {
+ let reduced = state.reduced.read().await;
+ verify_bearer_principal(&headers, &*reduced)
+ };
+ match principal {
Err((_, m)) => line_err(m, None),
Ok(principal) => {
- let reduced = state.reduced.read().await;
- if !reduced.user_has_cap(&room, &principal, ThreadCapability::Manage) {
+ let can_manage = {
+ let reduced = state.reduced.read().await;
+ reduced.user_has_cap(&room, &principal, ThreadCapability::Manage)
+ };
+ if !can_manage {
line_err("requires Manage capability", None)
} else {
match parse_username(&username) {
Err(msg) => line_err("invalid username", Some(msg)),
Ok(target) => {
- if !reduced.users_by_provider.values().any(|u| u == &target) {
+ let user_exists = {
+ let reduced = state.reduced.read().await;
+ reduced.users_by_provider.values().any(|u| u == &target)
+ };
+ if !user_exists {
line_err(format!("user @{target} not found"), None)
} else {
match parse_capability(&capability) {
diff --git a/server/tests/integration.rs b/server/tests/integration.rs
index 9162bd5bee84035e3908bfb9c8e201b7878ca339..b930120da09fe7d307f0411b84fb639fb8bd0b15 100644
--- a/server/tests/integration.rs
+++ b/server/tests/integration.rs
@@ -95,6 +95,23 @@ async fn test_healthz() {
assert_eq!(response.text().await.unwrap(), "ok");
}
+#[tokio::test]
+async fn test_room_create_private_rpc() {
+ let (addr, _tmp, _log, _handle) = create_test_server().await;
+ let client = reqwest::Client::new();
+ let batch = serde_json::json!([{
+ "RoomCreate": { "slug": "secret-project", "visibility": "private" }
+ }]);
+ let body = rpc_batch(&client, addr, Some(&test_bearer()), batch).await;
+ let line = &body["results"][0];
+ assert_eq!(line["ok"], true, "room create: {:?}", line);
+ let room_id = line["result"]["RoomCreated"]["room_id"].as_str().unwrap();
+ assert!(
+ room_id.contains("/secret-project"),
+ "expected room_id to contain slug, got {room_id}"
+ );
+}
+
#[tokio::test]
async fn test_index_page() {
// HTML routes are offline during the auth-v3 refactor.
diff --git a/test/auth.bb b/test/auth.bb
index 611e04f1806ef81d678a91f499597fe55f691dd7..a67cc1de763434176d2f68e419dd37a8cd328395 100644
--- a/test/auth.bb
+++ b/test/auth.bb
@@ -176,7 +176,7 @@
(assert! (= 200 (:status poll)) "pending-session poll returns 200")
(let [poll-json (json/parse-string (:body poll) true)]
(assert! (:complete poll-json) "pending session complete=true")
- (assert! (= "@bbuser" (:user poll-json)) "poll returns @bbuser")
+ (assert! (= "bbuser" (:user poll-json)) "poll returns stored username bbuser")
(assert! (clojure.string/starts-with? (:token poll-json) "slug_") "poll returns bearer token")
(println "\nwhoami…")
@@ -184,7 +184,7 @@
:headers {"Authorization" (str "Bearer " (:token poll-json))})]
(assert! (= 200 (:status who)) "whoami returns 200")
(let [who-json (json/parse-string (:body who) true)]
- (assert! (= "@bbuser" (:user who-json)) "whoami user is @bbuser"))))))
+ (assert! (= "bbuser" (:user who-json)) "whoami user is bbuser (stored form)"))))))
(println "\nCLI: identity start → OAuth → identity poll → whoami…")
(let [cli-home (str tmp-dir "/cli-home")
@@ -208,7 +208,7 @@
(str "identity poll exits 0 (stderr: " (:err poll-proc) ")"))
(let [poll-cli (json/parse-string (:out poll-proc) true)]
(assert! (= "complete" (:phase poll-cli)) "identity poll --json phase")
- (assert! (= "@cliuser" (:user poll-cli)) "CLI poll user")
+ (assert! (= "cliuser" (:user poll-cli)) "CLI poll user (stored form)")
(assert! (clojure.string/starts-with? (:token poll-cli) "slug_") "CLI poll token")
(let [token-path (str cli-home "/.config/slugsocial/token")]
(assert! (fs/exists? token-path) "token written under isolated HOME")
@@ -219,7 +219,7 @@
(assert! (zero? (:exit who-proc))
(str "whoami exits 0 (stderr: " (:err who-proc) ")"))
(let [who-cli (json/parse-string (:out who-proc) true)]
- (assert! (= "@cliuser" (:user who-cli)) "CLI whoami uses saved token"))))))))
+ (assert! (= "cliuser" (:user who-cli)) "CLI whoami uses saved token"))))))))
(finally
(when-some [s @!server] (common/kill-server s))
diff --git a/test/common.bb b/test/common.bb
index ebb16c615a8b40e8830b5d5765d1e935a45538d0..4ed450377cdbb020f5ff164a812dfbbfc23c0f47 100644
--- a/test/common.bb
+++ b/test/common.bb
@@ -78,9 +78,20 @@
(defn start-server
"Start the slugsocial-server binary with the given env map.
- Returns the babashka.process map."
- [server-bin env-map]
- (p/process [server-bin] {:out :inherit :err :inherit :env env-map}))
+ Returns the babashka.process map.
+
+ When `log-file` (string path) is provided, stdout and stderr are appended there
+ instead of inheriting the parent descriptors. Inheriting shared pipes while the
+ parent blocks on HTTP I/O can fill the pipe buffer and deadlock the server on log writes."
+ ([server-bin env-map]
+ (start-server server-bin env-map nil))
+ ([server-bin env-map log-file]
+ (p/process [server-bin]
+ (if log-file
+ ;; Two string paths (same file): babashka.process can deref the process cleanly.
+ ;; :err :out + ProcessBuilder$Redirect breaks stream copying in deref/kill-server.
+ {:env env-map :out log-file :err log-file}
+ {:out :inherit :err :inherit :env env-map}))))
(defn kill-server
"Forcibly kill a server process (babashka.process map) and wait for it to exit."
diff --git a/test/grants.bb b/test/grants.bb
index 7066c1f2a57f39a362052f8524206dccf37bb7b1..793ba216f2de76a77eb20a76d08403db07ff411b 100644
--- a/test/grants.bb
+++ b/test/grants.bb
@@ -35,13 +35,14 @@
(defn- http-client []
(-> (java.net.http.HttpClient/newBuilder)
(.followRedirects java.net.http.HttpClient$Redirect/ALWAYS)
+ (.connectTimeout (java.time.Duration/ofSeconds 15))
(.build)))
(defn- http-get [url & {:keys [headers]}]
(let [b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]
(doseq [[k v] (or headers {})]
(.header b k v))
- (let [req (-> b (.GET) (.build))
+ (let [req (-> b (.timeout (java.time.Duration/ofSeconds 60)) (.GET) (.build))
resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]
{:status (.statusCode resp) :body (.body resp)})))
@@ -52,6 +53,7 @@
(doseq [[k v] (or headers {})]
(.header b k v))
(let [req (-> b
+ (.timeout (java.time.Duration/ofSeconds 60))
(.POST (java.net.http.HttpRequest$BodyPublishers/ofString body))
(.build))
resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]
@@ -67,6 +69,7 @@
b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]
(.header b "Content-Type" "application/x-www-form-urlencoded")
(let [req (-> b
+ (.timeout (java.time.Duration/ofSeconds 60))
(.POST (java.net.http.HttpRequest$BodyPublishers/ofString pairs))
(.build))
resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]
Hardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.