Side B adds a small, well-contained feature (RoomList RPC + CLI subcommand) with correct server-side per-principal filtering and thorough integration tests proving isolation, providing real lasting value. Side A deletes a large, over-engineered but functional graph-based parser and replaces it with a much simpler paste-and-go implementation—this is a reasonable simplification, but it's largely destructive churn (removing thousands of test lines and features like live suggestions) rather than adding new lasting capability, and the net value is more about reducing complexity than building durable functionality.
constitution · epochs · watch · epoch 3
c_8dc1a8119370 (tommy-mor) vs c_552f408ae0da (tommy-mor)
download prompt · raw event · cmp_b268dfedb20c6c
council reasoning
A replaces an unreliable ~1.8k-line keystroke graph (plus parser_action, race-prone JS, and Playwright harness) with a small correct URL/path parser and paste-and-Go redirect, a lasting design simplification. B adds useful RoomList RPC/CLI with solid isolation tests, but is a smaller additive surface on existing grants rather than fixing a core broken approach.
Side B adds a new, coherent capability: a `RoomList` RPC, matching CLI subcommand, shared request/response types, and integration tests that verify authenticated per-user room visibility and isolation. Side A largely replaces a sophisticated autocomplete/parser system with a much simpler paste-and-go textarea and redirect, deleting substantial functionality and tests while simplifying URL parsing, making it a tradeoff rather than a clear lasting improvement.
sides
A — c_8dc1a8119370 (tommy-mor)
message
[529cc941] Replace autocomplete parser with paste-and-go navigate. The keystroke transition graph was unreliable; a textarea plus Go button now parses pasted Reddit URLs and redirects to the subreddit ranking scope. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/AGENTS.md b/AGENTS.md
index 77f2e31d4e860a92a77255ca5106c8b6c4510ee7..36ee4c0ec700bffbe226ee775ba9cf59cf35c770 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,7 +11,6 @@ Single Rust web app **`sorter2-server`**: pairwise voting, rank-centrality ranki
- **Rust 1.88+** is required (some transitive crates need a recent Cargo). The image may ship older `/usr/local/cargo` (1.83); use **rustup** and `rustup default 1.88.0` before building.
- **System packages** for builds: `pkg-config`, `libssl-dev` (for `reqwest` / OpenSSL in integration tests and release builds).
- **Clojure CLI 1.12.0.1530** (optional but used in CI): install from https://clojure.org/guides/install_clojure — needed for `./scripts/clj-test.sh` / Kaocha tests.
-- **Playwright browser** for the spel browser test (`test/parser_race.clj`): install once with `clojure -M -e "(com.microsoft.playwright.CLI/main (into-array String [\"install\" \"chromium\" \"--with-deps\"]))"`. The browser binary is cached under `~/.cache/ms-playwright`.
### Commands (see also `TEST.sh`)
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 1339048c955c0a3eb5120381aaf031424cbed540..c4ab9d65c7b3cd42a5b4d093ba429993c101e9a8 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -8,7 +8,7 @@ use std::collections::HashMap;
use crate::{
html::{js_string_literal, ranking_panel, JsBuilder},
parser::parse_reddit_url,
- parser_render::parser_panel_morph,
+ parser_render::navigate_panel,
state::AppState,
ui_action::{parse_html_ui_from_form, HtmlUiAction},
};
@@ -57,18 +57,23 @@ pub async fn post_ui_html(
.morph_selector("#ranking-panel", panel)
.into_response()
}
- HtmlUiAction::ParseQuery { query } => {
- let action = parse_reddit_url(&query);
- let panel = parser_panel_morph(&query, &action);
- let mut js = JsBuilder::new().morph_selector("#parser-panel", panel);
- if let Some(comp) = action.primary_completion() {
- js = js.raw(&format!(
- "var __pi=document.getElementById('parser-input'); if(__pi){{__pi.dataset.completion={};}}",
- js_string_literal(comp)
- ));
+ HtmlUiAction::ParseQuery { query } => match parse_reddit_url(&query) {
+ Ok(subreddit) => {
+ let dest = format!("/?sub={subreddit}");
+ JsBuilder::new()
+ .raw(&format!(
+ "window.location.href={};",
+ js_string_literal(&dest)
+ ))
+ .into_response()
}
- js.into_response()
- }
+ Err(message) => {
+ let panel = navigate_panel(&query, Some(&message));
+ JsBuilder::new()
+ .morph_selector("#parser-panel", panel)
+ .into_response()
+ }
+ },
}
}
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 94a5cfb561d1c8464bd2782f7ffd4e0965ccf95d..9650d333d29c4ac94ceb407aee3ee00399c7f40b 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -11,8 +11,7 @@ use serde::Deserialize;
use crate::{
form_template::template_json_compact,
- parser_action::ParserAction,
- parser_render::parser_panel,
+ parser_render::navigate_panel,
ranking::{top_bottom, RankedItem},
reducer::GroupState,
state::{normalize_scope, AppState},
@@ -336,10 +335,9 @@ pub async fn home(
let empty = GroupState::new();
let group = groups.get(&scope).unwrap_or(&empty);
- let empty_action = ParserAction::suggest(String::new(), None);
let body = html! {
h1 { "sorter2" }
- (parser_panel("", &empty_action))
+ (navigate_panel("", None))
(vote_panel(&scope))
(ranking_panel(&scope, group))
};
diff --git a/server/src/lib.rs b/server/src/lib.rs
index fa423640d598f4ba97a5885d228e78d7b97f7a22..de8bca48cbf689cad22337883e7791966e7c4919 100644
--- a/server/src/lib.rs
+++ b/server/src/lib.rs
@@ -4,7 +4,6 @@ pub mod events;
pub mod form_template;
pub mod html;
pub mod parser;
-pub mod parser_action;
pub mod parser_render;
pub mod path_types;
pub mod ranking;
diff --git a/server/src/parser.rs b/server/src/parser.rs
index ea437c4434045b44cba04a2b3416df850db518e7..50571a59f0d3ece1e2538f88e00ef46ec40ea545 100644
--- a/server/src/parser.rs
+++ b/server/src/parser.rs
@@ -1,1811 +1,87 @@
-use std::collections::HashMap;
-use std::sync::OnceLock;
+//! Extract a subreddit name from a pasted Reddit URL or path.
-use crate::parser_action::{GuideOption, ParserAction, ScrollingSuggestion, Suggestion};
-
-// --- Core Abstractions ---
-
-/// Unique identifier for nodes in the graph
-type NodeId = &'static str;
-
-/// Pattern matching for edges
-#[derive(Debug, Clone)]
-pub enum EdgePattern {
- /// Matches exact literal string
- Literal(&'static str),
-
- /// Matches any prefix of a string and suggests the full string
- /// e.g., PrefixOf("reddit.com") matches "r", "re", "red", "reddit", "reddit.com"
- PrefixOf(&'static str),
-
- /// Captures a variable segment (e.g., subreddit name, username)
- Variable(&'static str),
-
- /// Matches any string (wildcard)
- Any,
-}
-
-impl EdgePattern {
- /// Try to match this pattern against input, return (consumed_chars, captured_value)
- fn matches(&self, input: &str) -> Option<(usize, Option<String>)> {
- match self {
- EdgePattern::Literal(lit) => {
- if input.starts_with(lit) {
- Some((lit.len(), None))
- } else {
- None
- }
- }
- EdgePattern::PrefixOf(target) => {
- // Check if input is a prefix of target
- if target.starts_with(input) && !input.is_empty() {
- // It's a valid prefix
- Some((input.len(), None))
- } else if input.starts_with(target) {
- // Full match
- Some((target.len(), None))
- } else {
- None
- }
- }
- EdgePattern::Variable(var_name) => {
- // Consume until next '/' or end of string
- let end = input.find('/').unwrap_or(input.len());
- if end > 0 {
- let captured = input[..end].to_string();
- // Validate based on variable type
- if is_valid_variable(var_name, &captured) {
- Some((end, Some(captured)))
- } else {
- None
- }
- } else {
- None
- }
- }
- EdgePattern::Any => {
- // Match everything until next '/' or end
- let end = input.find('/').unwrap_or(input.len());
- if end > 0 {
- Some((end, Some(input[..end].to_string())))
- } else {
- None
- }
- }
- }
- }
-
- /// Get the completion suggestion for this pattern
- fn completion(&self, partial: &str) -> Option<String> {
- match self {
- EdgePattern::PrefixOf(target) => {
- if target.starts_with(partial) && partial != *target {
- Some(target.to_string())
- } else {
- None
- }
- }
- _ => None,
- }
+pub fn parse_reddit_url(query: &str) -> Result<String, String> {
+ let q = query.trim();
+ if q.is_empty() {
+ return Err("Paste a Reddit URL or r/subreddit path".into());
}
-}
-/// Edge in the graph
-pub struct Edge {
- pattern: EdgePattern,
- target: NodeId,
- /// Optional description for autocomplete
- description: Option<&'static str>,
-}
-
-/// Handler function for generating UI actions (Send + Sync so the graph can live in `OnceLock`).
-type Handler = Box<dyn Fn(&str, &str, &HashMap<String, String>) -> ParserAction + Send + Sync>;
-
-/// Node in the graph
-pub struct Node {
- #[allow(dead_code)]
- id: NodeId,
- edges: Vec<Edge>,
- handler: Option<Handler>,
-}
-
-/// The composable parser graph (immutable after `build`).
-pub struct Graph {
- nodes: HashMap<NodeId, Node>,
- root: NodeId,
-}
-
-// --- Graph Builder (Fluent API) ---
-
-pub struct GraphBuilder {
- nodes: HashMap<NodeId, Node>,
- current_node: Option<NodeId>,
- root: NodeId,
-}
-
-impl GraphBuilder {
- pub fn new() -> Self {
- let mut nodes = HashMap::new();
- nodes.insert(
- "root",
- Node {
- id: "root",
- edges: Vec::new(),
- handler: None,
- },
- );
-
- GraphBuilder {
- nodes,
- current_node: Some("root"),
- root: "root",
- }
+ if let Some(sub) = subreddit_after_prefix(q, "r/") {
+ return Ok(sub);
}
- /// Select a node to add edges to
- pub fn at(mut self, node_id: NodeId) -> Self {
- self.nodes.entry(node_id).or_insert_with(|| Node {
- id: node_id,
- edges: Vec::new(),
- handler: None,
- });
- self.current_node = Some(node_id);
- self
+ if let Some(sub) = subreddit_from_path_segment(q, "/r/") {
+ return Ok(sub);
}
-
- /// Add an edge from the current node
- pub fn edge(self, pattern: EdgePattern, target: NodeId) -> Self {
- self.edge_with_desc(pattern, target, None)
- }
-
- /// Add an edge with description
- pub fn edge_with_desc(
- mut self,
- pattern: EdgePattern,
- target: NodeId,
- desc: Option<&'static str>,
- ) -> Self {
- let current = self.current_node.expect("No current node selected");
-
- self.nodes.entry(target).or_insert_with(|| Node {
- id: target,
- edges: Vec::new(),
- handler: None,
- });
-
- if let Some(node) = self.nodes.get_mut(current) {
- node.edges.push(Edge {
- pattern,
- target,
- description: desc,
- });
- }
- self
- }
-
- /// Set handler for current node
- pub fn handler<F>(mut self, handler: F) -> Self
- where
- F: Fn(&str, &str, &HashMap<String, String>) -> ParserAction + Send + Sync + 'static,
- {
- let current = self.current_node.expect("No current node selected");
- if let Some(node) = self.nodes.get_mut(current) {
- node.handler = Some(Box::new(handler));
- }
- self
- }
-
- /// Build the final graph
- pub fn build(self) -> Graph {
- Graph {
- nodes: self.nodes,
- root: self.root,
- }
- }
+ Err("Could not find a subreddit in that URL".into())
}
-// --- Parser Implementation ---
-
-impl Graph {
- pub fn parse(&self, input: &str) -> ParserAction {
- let normalized = input.trim().to_lowercase();
- let mut state = ParserState {
- input: &normalized,
- cursor: 0,
- current_node_id: self.root,
- context: HashMap::new(),
- original_query: input.to_string(),
- current_prefix: String::new(),
- };
-
- self.parse_recursive(&mut state)
- }
-
- fn parse_recursive(&self, state: &mut ParserState) -> ParserAction {
- let node = self
- .nodes
- .get(state.current_node_id)
- .expect("Node not found in graph");
-
- // If we've consumed all input, check for handler or suggestions
- if state.cursor >= state.input.len() {
- if let Some(handler) = &node.han
… preview truncated; 92,533 characters omittedB — 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.