Side B adds a complete, self-contained feature (RoomList RPC + CLI subcommand + types) with a thorough new integration test proving per-user isolation, providing clear lasting value and coverage. Side A fixes a real bug in Reddit child import wiring and adjusts a signature threaded through several call sites, which is valuable but narrower in scope and partly consists of test-shuffling rather than new capability.
constitution · epochs · watch · epoch 3
c_cd965c070df3 (tommy-mor) vs c_552f408ae0da (tommy-mor)
download prompt · raw event · cmp_b57200202a63de
council reasoning
A fixes core data-model wiring (replacing link_child + ensure_path with apply_entity_under_parent so listing imports do not pull Reddit comment-path segments) and makes unranked children show real titles via the tree—lasting correctness in import and UI. B adds a useful RoomList RPC/CLI with solid isolation tests, but the server logic is a straightforward grants filter, so the higher-impact design fix is A.
Side A fixes a core correctness issue in Reddit imports by introducing `apply_entity_under_parent` so imported listing children are attached directly without `ensure_path` creating incorrect `/comments/...` hierarchy, and it also improves the ranking UI by showing imported child titles instead of raw IDs. It updates the rendering pipeline and adds integration tests covering SSE morphs and children fetch behavior, whereas Side B primarily adds a useful but incremental `RoomList` RPC/CLI feature with access-control tests.
sides
A — c_cd965c070df3 (tommy-mor)
message
[993d359c] Fix Reddit children import wiring and unranked child labels. Listing imports attach posts directly under the subreddit without ensure_path pulling comment-path segments in, and the ranking panel shows imported titles. Update integration tests for JS SSE morphs and children fetch. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index d1defd28242fd2ca3b886adc91a7070bef75e653..e649a7d192feade465e19ce6187a829f6ec74372 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -58,7 +58,7 @@ pub async fn post_ui_html(
let tree = state.tree.read().await;
let empty = crate::reducer::NodeState::default();
let node = tree.get(&parent).unwrap_or(&empty);
- let panel = ranking_panel(&parent, node);
+ let panel = ranking_panel(&parent, node, &tree);
JsBuilder::new()
.morph_selector("#ranking-panel", panel)
.into_response()
diff --git a/server/src/fetch/mod.rs b/server/src/fetch/mod.rs
index 0177bb161cea1b72a100b52efdfc5e710271c9eb..35f968c21c69ca557bab7951413e3cfbbccfebfd 100644
--- a/server/src/fetch/mod.rs
+++ b/server/src/fetch/mod.rs
@@ -107,7 +107,7 @@ pub fn fetch_entity_stream(
let mut b = JsBuilder::new()
.morph_selector("#entity-section", html::entity_section(&id, node, false));
if kind == FetchKind::Children {
- b = b.morph_selector("#ranking-panel", ranking_panel(&id, node));
+ b = b.morph_selector("#ranking-panel", ranking_panel(&id, node, &tree));
}
yield Ok(js_event(b.build()));
}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 27ce9118c73ec5643e04363ab1a36cf5da6101bf..e88cc43ddc9d8100f7994be6f5960ec4d8f22c55 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -15,7 +15,7 @@ use crate::{
ranking::{
connected_components_from_voted_pairs, ranked_items_subset, RankedItem, MAX_ITERS, TOL,
},
- reducer::NodeState,
+ reducer::{GlobalTree, NodeState},
state::AppState,
ui_action::UI_RPC_FIELD,
};
@@ -182,8 +182,15 @@ fn display_label(id: &ItemId) -> String {
.to_string()
}
+fn child_label(tree: &GlobalTree, id: &ItemId) -> String {
+ tree.get(id)
+ .and_then(|n| n.data.as_ref())
+ .map(|d| d.title.clone())
+ .unwrap_or_else(|| display_label(id))
+}
+
/// Plain (unscored) list of children that have no votes yet.
-fn unranked_list(label: &str, items: &[ItemId]) -> Markup {
+fn unranked_list(label: &str, items: &[ItemId], tree: &GlobalTree) -> Markup {
html! {
@if !items.is_empty() {
h3 class="rank-heading muted small" { (label) }
@@ -191,7 +198,7 @@ fn unranked_list(label: &str, items: &[ItemId]) -> Markup {
@for it in items {
li {
a href=(item_href(it)) {
- strong { (display_label(it)) }
+ strong { (child_label(tree, it)) }
}
}
}
@@ -200,7 +207,7 @@ fn unranked_list(label: &str, items: &[ItemId]) -> Markup {
}
}
-pub fn ranking_panel(item: &ItemId, node: &NodeState) -> Markup {
+pub fn ranking_panel(item: &ItemId, node: &NodeState, tree: &GlobalTree) -> Markup {
let group = &node.local_ranking;
let n = group.idx_to_item.len();
let (comps, _isolates) =
@@ -248,7 +255,7 @@ pub fn ranking_panel(item: &ItemId, node: &NodeState) -> Markup {
@let label = if multi { format!("Ranking group {}", gi + 1) } else { "Ranking".to_string() };
(rank_list(&label, ranked, 1))
}
- (unranked_list("Unranked", &unranked))
+ (unranked_list("Unranked", &unranked, tree))
}
}
}
@@ -297,7 +304,7 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup {
(input_panel("", None))
(breadcrumb_path(&item))
(entity_section(&item, node, false))
- (ranking_panel(&item, node))
+ (ranking_panel(&item, node, &tree))
};
layout("sorter2", body, views)
}
diff --git a/server/src/reddit.rs b/server/src/reddit.rs
index a0eb688709478ee0185b953b41a5d26cc354764d..69d979bc7e4a1cb078f70114dc539bdc1986b574 100644
--- a/server/src/reddit.rs
+++ b/server/src/reddit.rs
@@ -300,9 +300,16 @@ async fn reddit_worker(
}
{
let mut tree = tree.write().await;
- apply_entity_import(&mut tree, &child_id, child_payload);
if kind == FetchKind::Children {
- tree.link_child(&fetch_id, &child_id);
+ let view = entity_view_from_payload(&child_id, &child_payload);
+ tree.apply_entity_under_parent(
+ &fetch_id,
+ &child_id,
+ child_payload,
+ view,
+ );
+ } else {
+ apply_entity_import(&mut tree, &child_id, child_payload);
}
}
written += 1;
diff --git a/server/src/reducer.rs b/server/src/reducer.rs
index a36cd5c9287d61536f9f4a3f6b0df2342388857a..4e42d0369dab50bb2f8ca664aa69b628292f6c07 100644
--- a/server/src/reducer.rs
+++ b/server/src/reducer.rs
@@ -209,14 +209,24 @@ impl GlobalTree {
}
}
- /// Directly attach `child` under `parent`, bypassing path-based nesting.
- /// Used for imported listings (e.g. a subreddit's posts) so they show up
- /// as children of the subreddit rather than a deep `…/comments/<id>` path.
- pub fn link_child(&mut self, parent: &ItemId, child: &ItemId) {
+ /// Import entity data for `id` and attach it as a direct child of `parent`
+ /// without running [`Self::ensure_path`] on `id` (avoids Reddit `/comments/`
+ /// parent rules pulling intermediate path segments into the subreddit).
+ pub fn apply_entity_under_parent(
+ &mut self,
+ parent: &ItemId,
+ id: &ItemId,
+ payload: Value,
+ view: Option<EntityData>,
+ ) {
self.ensure_path(parent);
- self.ensure_path(child);
+ self.ensure_node(id);
+ if let Some(node) = self.nodes.get_mut(id) {
+ node.entity_raw = Some(payload);
+ node.data = view;
+ }
if let Some(p) = self.nodes.get_mut(parent) {
- p.children.insert(child.clone());
+ p.children.insert(id.clone());
}
}
}
diff --git a/test/reddit_import.clj b/test/reddit_import.clj
index 84cbdcf7965e50290313cbce2243a16b583d2097..45a2a19f20799d77e84d8aa64735ab5e7e45f97c 100644
--- a/test/reddit_import.clj
+++ b/test/reddit_import.clj
@@ -67,6 +67,36 @@
(do (Thread/sleep 200) (recur))
false)))))
+(defn- run-reddit-fetch-assertions [app-base data-dir]
+ (let [browse-url (str app-base "/~/https://reddit.com/r/rust")
+ log-path (str data-dir "/events.jsonl")
+ before (:out (process/shell {:out :string :err :string}
+ "curl" "-sf" browse-url))]
+ (is (str/includes? before "Fetch from Reddit"))
+ (is (not (str/includes? before "The Rust Programming Language")))
+ (let [sse (curl-fetch-ui-sse app-base "reddit.com/r/rust" "self")]
+ (is (zero? (:exit sse)) "POST /ui fetch_entity (self) SSE succeeds")
+ (is (str/includes? (:out sse) "Idiomorph.morph"))
+ (is (str/includes? (:out sse) "The Rust Programming Language"))
+ (is (wait-event-log log-path 2000) "event log written"))
+ (let [after (:out (process/shell {:out :string :err :string}
+ "curl" "-sf" browse-url))
+ log (slurp (io/file log-path))]
+ (is (str/includes? after "The Rust Programming Language"))
+ (is (str/includes? log "\"type\":\"entity_imported\""))
+ (is (str/includes? log "\"subscribers\":350000"))
+ (is (str/includes? log "\"display_name\":\"rust\"")))
+ (let [children-sse (curl-fetch-ui-sse app-base "reddit.com/r/rust" "children")]
+ (is (zero? (:exit children-sse)) "POST /ui fetch_entity (children) SSE succeeds")
+ (is (str/includes? (:out children-sse) "Idiomorph.morph"))
+ (is (str/includes? (:out children-sse) "Announcing Rust 1.99")))
+ (let [after-children (:out (process/shell {:out :string :err :string}
+ "curl" "-sf" browse-url))
+ log2 (slurp (io/file log-path))]
+ (is (str/includes? after-children "Announcing Rust 1.99"))
+ (is (str/includes? after-children "Unranked"))
+ (is (str/includes? log2 "announcing_rust_199")))))
+
(deftest reddit-fetch-via-mock-api
(testing "Fetch more queues import; event log stores full payload; page shows title"
(let [root (repo-root)
@@ -102,24 +132,7 @@
bin)]
(try
(is (wait-health app-base 20000) "app healthz")
- (let [browse-url (str app-base "/~/https://reddit.com/r/rust")
- before (:out (process/shell {:out :string :err :string}
- "curl" "-sf" browse-url))]
- (is (str/includes? before "Fetch from Reddit"))
- (is (not (str/includes? before "The Rust Programming Language")))
- (let [log-path (str data-dir "/events.jsonl")
- sse (curl-fetch-ui-sse app-base "reddit.com/r/rust")]
- (is (zero? (:exit sse)) "POST /ui fetch_entity SSE succeeds")
- (is (str/includes? (:out sse) "event: complete"))
- (is (str/includes? (:out sse) "The Rust Programming Language"))
- (is (wait-event-log log-path 2000) "event log written")
- (let [after (:out (process/shell {:out :string :err :string}
- "curl" "-sf" browse-url))
- log (slurp (io/file log-path))]
- (is (str/includes? after "The Rust Programming Language"))
- (is (str/includes? log "\"type\":\"entity_imported\""))
- (is (str/includes? log "\"subscribers\":350000"))
- (is (str/includes? log "\"display_name\":\"rust\"")))))
+ (run-reddit-fetch-assertions app-base data-dir)
(finally
(process/destroy proc))))
(finally
diff --git a/test/smoke.clj b/test/smoke.clj
index 11887c48282088e140d823a88ba616f6325835b3..ce9f958c84b9a89ae55e215ab519f1df6435e24b 100644
--- a/test/smoke.clj
+++ b/test/smoke.clj
@@ -49,7 +49,7 @@
(is (wait-health base 15000) "server responds to /healthz")
(let [home (:out (process/shell {:out :string :err :string}
"curl" "-sf" (str base "/")))]
- (is (str/includes? home "vote-panel"))
+ (is (str/includes? home "entity-section"))
(is (str/includes? home "ranking-panel"))
(is (str/includes? home "parser-panel"))
(is (str/includes? home "__rpc__")))
B — c_552f408ae0da (tommy-mor)
message
[9acdf18a] feat: add RoomList RPC command and CLI room list subcommand Returns all rooms the authenticated principal has a grant in. Includes integration tests proving per-user isolation: users only see rooms they have been explicitly granted, not all rooms in the system. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
diff preview
diff --git a/bb.edn b/bb.edn
index f7c53eb2d5def514a8f2c480ac420416205db14e..8dc6a5be7321de198cbb5939842a33b8c5d52625 100644
--- a/bb.edn
+++ b/bb.edn
@@ -47,16 +47,18 @@
"RUST_LOG" "info"})})))}
test
- {:doc "Full test suite: integration + auth + grants + invites"
+ {:doc "Full test suite: integration + auth + grants + invites + room-list"
:requires ([test.integration :as integration]
[test.auth :as auth]
[test.grants :as grants]
- [test.invites :as invites])
+ [test.invites :as invites]
+ [test.room-list :as room-list])
:task (do
(integration/integration)
(auth/auth-test)
(grants/grants-test)
- (invites/invites-test))}
+ (invites/invites-test)
+ (room-list/room-list-test))}
walkthrough-fixture
{:doc "Run local server + mock OAuth + seeded walkthrough data for manual browser demos"
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 69492cb5417a0a19c38f8cacbeadd103bd905b6f..b008cf377bb360aaae666d2dfd4b5e13dedb204a 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -219,6 +219,12 @@ enum RoomCmd {
#[arg(long)]
json: bool,
},
+ /// List rooms the authenticated user has access to
+ List {
+ /// Output as JSON for agent parsing
+ #[arg(long)]
+ json: bool,
+ },
}
#[derive(Subcommand, Debug)]
@@ -1319,6 +1325,38 @@ async fn main() -> Result<()> {
_ => return Err(anyhow!("unexpected RPC result")),
}
}
+ RoomCmd::List { 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::RoomList],
+ )
+ .await?;
+ match rpc_line_ok(&batch.results[0])? {
+ RpcResult::RoomList(resp) => {
+ if json {
+ println!("{}", serde_json::to_string_pretty(&resp)?);
+ } else {
+ if resp.rooms.is_empty() {
+ println!("no rooms");
+ } else {
+ for room in &resp.rooms {
+ println!("{room}");
+ }
+ }
+ }
+ }
+ _ => return Err(anyhow!("unexpected RPC result")),
+ }
+ }
},
Command::Healthz { json } => {
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index ed4800e7cbdeaf04e72192c191e71354e603c1fe..f1ee6d35b95a1a28490823e907b8f4dc5c091b94 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -1345,6 +1345,25 @@ pub async fn handle_rpc_batch(
}
}
}
+ RpcCommand::RoomList => {
+ 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;
+ let rooms: Vec<String> = reduced
+ .grants
+ .iter()
+ .filter(|(_, members)| members.contains_key(&principal))
+ .map(|(room, _)| room.clone())
+ .collect();
+ line_ok(RpcResult::RoomList(RoomListResponse { rooms }))
+ }
+ }
+ }
RpcCommand::RoomRevoke {
room,
username,
diff --git a/test/room_list.clj b/test/room_list.clj
new file mode 100644
index 0000000000000000000000000000000000000000..a089a722ac8d5f822f47d5511a46f671d61d46cf
--- /dev/null
+++ b/test/room_list.clj
@@ -0,0 +1,158 @@
+(ns test.room-list
+ "Room list integration test: list rooms user has access to via POST /api/v0/rpc.
+
+ Covers:
+ - user with no rooms -> empty list
+ - user with one room -> list contains that room
+ - user with multiple rooms -> list contains all rooms"
+ (:require [babashka.fs :as fs]
+ [cheshire.core :as json]
+ [clojure.set :as set]
+ [test.common :as common]
+ [test.oauth :as oauth]))
+
+(def ^:private counts (atom {:pass 0 :fail 0}))
+
+(defn- assert! [pred msg]
+ (common/test-assert! counts pred msg))
+
+(defn- bearer [token] {"Authorization" (str "Bearer " token)})
+
+(defn- rpc-batch! [base-url token cmds]
+ (let [resp (oauth/http-post-json (str base-url "/api/v0/rpc") cmds :headers (bearer token))]
+ {:status (:status resp)
+ :parsed (json/parse-string (:body resp) false)}))
+
+(defn- rpc-line-ok? [parsed]
+ (true? (get-in parsed ["results" 0 "ok"])))
+
+(defn- register-user! [base-url session-agent username]
+ (oauth/complete-registration! base-url
+ :agent session-agent
+ :username username
+ :assert! (fn [pred msg] (assert! pred msg))))
+
+(defn room-list-test [& _args]
+ (println "\n━━━ room list integration check ━━━\n")
+ (reset! counts {:pass 0 :fail 0})
+
+ (println "building server binary…")
+ (common/letlocals
+ (bind build (common/run-cargo-build-release! ["slugsocial-server"]))
+ (assert! (zero? (:exit build)) "cargo build succeeds")
+ (bind server-bin "target/release/slugsocial-server")
+
+ (bind tmp-dir (str (fs/create-temp-dir {:prefix "slug-room-list-"})))
+ (bind slug-port (common/pick-port))
+ (bind google-port (common/pick-port))
+ (bind base-url (str "http://127.0.0.1:" slug-port))
+ (bind google-url (str "http://127.0.0.1:" google-port))
+
+ (bind !server (atom nil))
+ (bind !google (atom nil))
+
+ (bind server-env (common/slug-server-env tmp-dir base-url google-url slug-port))
+ (try
+ (println (str "starting mock google on :" google-port))
+ (reset! !google (oauth/start-mock-google google-port
+ :google-users ["google-user-alice"
+ "google-user-bob"
+ "google-user-carol"]))
+
+ (println (str "starting server on :" slug-port))
+ (reset! !server (common/start-server server-bin server-env))
+ (assert! (common/wait-for-server base-url 10000) "server responds to /healthz")
+
+ (println "\nregistering alice, bob, carol…")
+ (let [alice-token (register-user! base-url
+ "00000000-0000-0000-0000-000000000001:test:local/dev"
+ "alice")
+ bob-token (register-user! base-url
+ "00000000-0000-0000-0000-000000000002:test:local/dev"
+ "bob")
+ carol-token (register-user! base-url
+ "00000000-0000-0000-0000-000000000003:test:local/dev"
+ "carol")
+
+ ;; Alice creates two private rooms
+ _ (println "\nalice creates two rooms…")
+ room-id-1 (-> (rpc-batch! base-url alice-token [{"RoomCreate" {"slug" "alice-room-one"}}])
+ (get-in [:parsed "results" 0 "result" "RoomCreated" "room_id"]))
+ _ (assert! (some? room-id-1) "alice room-one created")
+ room-id-2 (-> (rpc-batch! base-url alice-token [{"RoomCreate" {"slug" "alice-room-two"}}])
+ (get-in [:parsed "results" 0 "result" "RoomCreated" "room_id"]))
+ _ (assert! (some? room-id-2) "alice room-two created")
+
+ ;; Carol creates her own room
+ _ (println "carol creates her own room…")
+ carol-room (-> (rpc-batch! base-url carol-token [{"RoomCreate" {"slug" "carol-room"}}])
+ (get-in [:parsed "results" 0 "result" "RoomCreated" "room_id"]))
+ _ (assert! (some? carol-room) "carol room created")]
+
+ ;; --- isolation: alice only sees her rooms, not carol's ---
+ (println "\nalice sees her 2 rooms but not carol's…")
+ (let [rooms (-> (rpc-batch! base-url alice-token ["RoomList"])
+ (get-in [:parsed "results" 0 "result" "RoomList" "rooms"])
+ set)]
+ (assert! (= #{room-id-1 room-id-2} rooms)
+ "alice sees exactly her 2 rooms")
+ (assert! (not (contains? rooms carol-room))
+ "alice does NOT see carol's room"))
+
+ ;; --- isolation: carol only sees her room, not alice's ---
+ (println "carol sees only her room…")
+ (let [rooms (-> (rpc-batch! base-url carol-token ["RoomList"])
+ (get-in [:parsed "results" 0 "result" "RoomList" "rooms"])
+ set)]
+ (assert! (= #{carol-room} rooms)
+ "carol sees exactly her own room")
+ (assert! (not (contains? rooms room-id-1))
+ "carol does NOT see alice's room-one")
+ (assert! (not (contains? rooms room-id-2))
+ "carol does NOT see alice's room-two"))
+
+ ;; --- bob sees nothing yet: alice has 3 rooms total but bob is in none ---
+ (println "bob (no grants) sees no rooms despite 3 existing…")
+ (let [rooms (-> (rpc-batch! base-url bob-token ["RoomList"])
+ (get-in [:parsed "results" 0 "result" "RoomList" "rooms"]))]
+ (assert! (zero? (count rooms))
+ "bob sees 0 rooms even though 3 exist in the system"))
+
+ ;; --- partial grant: alice grants bob room-one only ---
+ (println "\nalice grants bob view on room-one only…")
+ (assert! (rpc-line-ok? (:parsed (rpc-batch! base-url alice-token
+ [{"RoomGrant" {"room" room-id-1
+ "username" "bob"
+ "capabilities" ["view"]}}])))
+ "grant ok")
+
+ ;; bob sees room-one but NOT room-two or carol's room
+ (println "bob sees room-one but not room-two or carol's room…")
+ (let [rooms (-> (rpc-batch! base-url bob-token ["RoomList"])
+ (get-in [:parsed "results" 0 "result" "RoomList" "rooms"])
+ set)]
+ (assert! (= #{room-id-1} rooms)
+ "bob sees exactly room-one")
+ (assert! (not (contains? rooms room-id-2))
+ "bob does NOT see alice's room-two (not granted)")
+ (assert! (not (contains? rooms carol-room))
+ "bob does NOT see carol's room (not granted)"))
+
+ ;; alice's view is unchanged
+ (println "alice's view unchanged after granting bob…")
+ (let [rooms (-> (rpc-batch! base-url alice-token ["RoomList"])
+ (get-in [:parsed "results" 0 "result" "RoomList" "rooms"])
+ set)]
+ (assert! (= #{room-id-1 room-id-2} rooms)
+ "alice still sees exactly her 2 rooms after granting bob")))
+
+ (fin
… preview truncated; 2,078 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.