{"messages":[{"content":"You are a constitutional council ranking individual git commits for ownership allocation.\n\nCompare these two commits. Decide which contributed more lasting value to the project.\n\nJudge substance, not spectacle:\n- Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise.\n- Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one.\n- Do not favor a side merely because its patch is longer or noisier.\n- Weight what the change does for the project, not the contributor's name.\n\nReturn ONLY a JSON object: {\"winner\": \"A\" or \"B\", \"ratio\": \"N:M\", \"explanation\": \"...\"}\nThe explanation must cite concrete differences in the patches (1-3 sentences).\n\nSide A — contributor: tommy-mor\nSide A — commit message:\n[a45842df] better dev iteration script\n\nSide A — unified diff (full patch):\ndiff --git a/.gitignore b/.gitignore\nindex 9cd2eaece834481bee87639cd7cb6be77f33e302..b70cf5b97cd3556e061233d8b0aefd333568a991 100644\n--- a/.gitignore\n+++ b/.gitignore\n@@ -3,6 +3,7 @@ target/\n repomix-output.xml\n psalms_kjv.txt\n dev-data/\n+fixture-data/\n .worktrees/\n .specstory/\n .specstory/\ndiff --git a/bb.edn b/bb.edn\nindex 5d1d642d75d03f70879d7673e7ad95360336a8b1..4680e82d4227057d2eea6041dc5647beb759131d 100644\n--- a/bb.edn\n+++ b/bb.edn\n@@ -47,7 +47,7 @@\n \"RUST_LOG\" \"info\"})})))}\n \n fixture\n- {:doc \"Run local server + mock OAuth + seeded walkthrough data for manual browser demos\"\n+ {:doc \"Run local server via cargo-watch + mock OAuth + seeded walkthrough data (persistent ./fixture-data/, prefers PORT 8080). Requires cargo-watch.\"\n :requires ([test.walkthrough-fixture :as walkthrough-fixture])\n :task (walkthrough-fixture/run-fixture)}\n \ndiff --git a/server/src/html/forum.rs b/server/src/html/forum.rs\nindex e00e7110e2ad778d63b3d00b0cc4ffe20ba2e0d4..815fc6529062b103553644fc15e516334aec2b88 100644\n--- a/server/src/html/forum.rs\n+++ b/server/src/html/forum.rs\n@@ -976,12 +976,6 @@ pub async fn room_page(\n html! {\n (strip)\n nav class=\"breadcrumb\" { (bc_room(&nav, slug_display, None)) }\n- h2 { (slug_display) }\n- p class=\"muted\" { (room_id) }\n- p class=\"muted room-links\" {\n- \"room garden · \"\n- a href=(nav.garden_root_url()) { \"~\" }\n- }\n (room_members_section_markup(&reduced, &room_id, false))\n h3 { \"threads\" }\n (render_thread_feed(Some(&nav), \"room-thread-feed\", &rows, now))\ndiff --git a/test/common.clj b/test/common.clj\nindex 76a3e99fdb67c10bcb5c2f53d8bcd0917398af50..c6deba0d90995b0e3f9c4b8ad66a516ed19b870e 100644\n--- a/test/common.clj\n+++ b/test/common.clj\n@@ -107,6 +107,16 @@\n (.close ss)\n port))\n \n+(defn pick-port-prefer\n+ \"Use `preferred` if it can be bound, otherwise an ephemeral port (same as `pick-port`).\"\n+ [preferred]\n+ (try\n+ (let [ss (java.net.ServerSocket. preferred)]\n+ (.close ss)\n+ preferred)\n+ (catch java.io.IOException _\n+ (pick-port))))\n+\n (defn wait-for-server\n \"Poll /healthz until it returns 'ok', up to `timeout-ms`.\"\n [base-url timeout-ms]\ndiff --git a/test/walkthrough_fixture.clj b/test/walkthrough_fixture.clj\nindex 4b814822029c913e38151ca53848d1c64fb58373..a487ff1ef8548580577914fb33f321586859c9af 100644\n--- a/test/walkthrough_fixture.clj\n+++ b/test/walkthrough_fixture.clj\n@@ -1,11 +1,18 @@\n (ns test.walkthrough-fixture\n \"Launch a local slug server with mock OAuth and seed browser-friendly demo data.\"\n (:require [babashka.fs :as fs]\n+ [babashka.process :as p]\n [cheshire.core :as json]\n [clojure.string :as str]\n [test.common :as common]\n [test.oauth :as oauth]))\n \n+(def ^:private fixture-data-dir \"fixture-data\")\n+;; Prefer the same port as `bb dev` / `bb watch`; fall back if something else is listening.\n+(def ^:private preferred-slug-port 8080)\n+;; First `cargo watch` compile can exceed a release-binary startup; allow several minutes.\n+(def ^:private health-wait-ms 300000)\n+\n (defn- assert! [pred msg]\n (when-not pred\n (throw (ex-info msg {}))))\n@@ -85,49 +92,87 @@\n :thread_url (str base-url \"/r/\" room-short \"/\" room-slug \"/t/walkthrough-thread\")\n :garden_url (str base-url \"/r/\" room-short \"/\" room-slug \"/~/secret/item\")}}))\n \n+(defn- rebase-fixture-summary [saved current-base-url current-google-url current-data-dir]\n+ (let [inner (:summary saved)\n+ room (:room inner)\n+ rs (:short room)\n+ lg (:slug room)]\n+ (assoc saved\n+ :base_url current-base-url\n+ :mock_google_url current-google-url\n+ :data_dir (str current-data-dir)\n+ :summary (assoc inner\n+ :room (assoc room\n+ :url (str current-base-url \"/r/\" rs \"/\" lg)\n+ :thread_url (str current-base-url \"/r/\" rs \"/\" lg \"/t/walkthrough-thread\")\n+ :garden_url (str current-base-url \"/r/\" rs \"/\" lg \"/~/secret/item\"))))))\n+\n+(defn- fixture-log-present? [data-dir]\n+ (let [p (fs/path data-dir \"events.jsonl\")]\n+ (and (fs/exists? p) (pos? (fs/size p)))))\n+\n+(defn- load-or-seed-summary!\n+ [base-url google-url data-dir summary-path]\n+ (if (and (fixture-log-present? data-dir) (fs/exists? summary-path))\n+ (let [saved (json/parse-string (slurp summary-path) true)\n+ rebased (rebase-fixture-summary saved base-url google-url data-dir)]\n+ (spit summary-path (json/generate-string rebased {:pretty true}))\n+ (println \"\")\n+ (println \"reusing fixture-data/ (delete the directory for a fresh seed)\")\n+ rebased)\n+ (let [seeded (seed-demo! base-url)\n+ s {:base_url base-url\n+ :mock_google_url google-url\n+ :data_dir (str data-dir)\n+ :summary seeded}]\n+ (spit summary-path (json/generate-string s {:pretty true}))\n+ s)))\n+\n (defn run-fixture [& _args]\n- (let [build (common/run-cargo-build-release! [\"slugsocial-server\"])\n- _ (assert! (zero? (:exit build)) \"cargo build --release failed\")\n- server-bin \"target/release/slugsocial-server\"\n- tmp-dir (str (fs/create-temp-dir {:prefix \"slug-walkthrough-\"}))\n- slug-port (common/pick-port)\n+ (let [data-dir (str (fs/absolutize (fs/path (fs/cwd) fixture-data-dir)))\n+ slug-port (common/pick-port-prefer preferred-slug-port)\n google-port (common/pick-port)\n base-url (str \"http://127.0.0.1:\" slug-port)\n google-url (str \"http://127.0.0.1:\" google-port)\n- stable-dir \"/tmp/slug-walkthrough-fixture\"\n- summary-path (str stable-dir \"/summary.json\")\n+ summary-path (str (fs/path data-dir \"summary.json\"))\n !server (atom nil)\n !google (atom nil)\n- server-env (common/slug-server-env tmp-dir base-url google-url slug-port)]\n+ server-env (merge (common/slug-server-env data-dir base-url google-url slug-port)\n+ {\"RUST_LOG\" \"info\"})\n+ watch-cmd [(common/cargo-bin) \"watch\"\n+ \"-x\" \"run -p slugsocial-server\"\n+ \"-w\" \"server/src\"\n+ \"-w\" \"server/static\"\n+ \"-w\" \"types/src\"]]\n (try\n- (fs/create-dirs stable-dir)\n+ (fs/create-dirs data-dir)\n (reset! !google\n (oauth/start-mock-google google-port\n :google-users [\"google-user-alice\" \"google-user-bob\"]))\n- (reset! !server (common/start-server server-bin server-env))\n- (assert! (common/wait-for-server base-url 10000) \"server did not become healthy\")\n- (let [seeded (seed-demo! base-url)\n- summary {:base_url base-url\n- :mock_google_url google-url\n- :data_dir tmp-dir\n- :summary seeded}]\n- (spit summary-path (json/generate-string summary {:pretty true}))\n+ (println \"\")\n+ (println \"starting cargo-watch (first compile may take a while)…\")\n+ (flush)\n+ (reset! !server (p/process watch-cmd {:inherit true :env server-env}))\n+ (assert! (common/wait-for-server base-url health-wait-ms) \"server did not become healthy\")\n+ (let [summary (load-or-seed-summary! base-url google-url data-dir summary-path)]\n (println \"\")\n (println \"walkthrough fixture ready\")\n (println (str \" base url: \" base-url))\n (println (str \" room page: \" (get-in summary [:summary :room :url])))\n (println (str \" thread page: \" (get-in summary [:summary :room :thread_url])))\n (println (str \" garden page: \" (get-in summary [:summary :room :garden_url])))\n+ (println (str \" data dir: \" data-dir))\n (println (str \" summary json: \" summary-path))\n (println \"\")\n (println \"seeded users\")\n (println \" alice / bob via mock OAuth\")\n (println \"\")\n+ (println \"editing server/src or server/static reloads the server; data persists in fixture-data/\")\n+ (println \"\")\n (println \"press Ctrl-C to stop\")\n (flush)\n (while true\n (Thread/sleep 1000)))\n (finally\n (when-some [s @!server] (common/kill-server s))\n- (when-some [g @!google] ((:stop-fn g)))\n- (fs/delete-tree tmp-dir)))))\n+ (when-some [g @!google] ((:stop-fn g)))))))\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[9ecc4e2e] nice\n\nSide B — unified diff (full patch):\ndiff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs\nindex 5675dcd2e28062acbe3c037b94b77c33adf32474..a18241f3ed7b4a248748fcefc799f9801ca62461 100644\n--- a/server/src/api/rpc.rs\n+++ b/server/src/api/rpc.rs\n@@ -936,7 +936,15 @@ pub async fn handle_rpc_batch(\n line_ok(RpcResult::ForumThreads(rpc_list_forum_threads(&reduced, &room)))\n }\n RpcCommand::RoomCreate { slug, visibility } => {\n- match verify_bearer_principal(&headers, &*state.reduced.read().await) {\n+ // Scope the first read so its guard drops before any nested `read().await` / `write().await`.\n+ // A guard from `match verify(..., &*state.reduced.read().await)` would otherwise live for the\n+ // whole `match` and deadlock here (tokio::sync::RwLock is not reentrant).\n+ let principal = {\n+ let reduced = state.reduced.read().await;\n+ verify_bearer_principal(&headers, &*reduced)\n+ };\n+ match principal {\n+ Err((_, m)) => line_err(m, None),\n Ok(principal) => {\n let slug = slug.trim().to_lowercase();\n if slug.is_empty() || slug.len() > 64 {\n@@ -994,7 +1002,6 @@ pub async fn handle_rpc_batch(\n }\n }\n }\n- Err((_, m)) => line_err(m, None),\n }\n }\n RpcCommand::RoomGrant {\n@@ -1002,17 +1009,28 @@ pub async fn handle_rpc_batch(\n username,\n capability,\n } => {\n- match verify_bearer_principal(&headers, &*state.reduced.read().await) {\n+ let principal = {\n+ let reduced = state.reduced.read().await;\n+ verify_bearer_principal(&headers, &*reduced)\n+ };\n+ match principal {\n Err((_, m)) => line_err(m, None),\n Ok(principal) => {\n- let reduced = state.reduced.read().await;\n- if !reduced.user_has_cap(&room, &principal, ThreadCapability::Manage) {\n+ let can_manage = {\n+ let reduced = state.reduced.read().await;\n+ reduced.user_has_cap(&room, &principal, ThreadCapability::Manage)\n+ };\n+ if !can_manage {\n line_err(\"requires Manage capability\", None)\n } else {\n match parse_username(&username) {\n Err(msg) => line_err(\"invalid username\", Some(msg)),\n Ok(target) => {\n- if !reduced.users_by_provider.values().any(|u| u == &target) {\n+ let user_exists = {\n+ let reduced = state.reduced.read().await;\n+ reduced.users_by_provider.values().any(|u| u == &target)\n+ };\n+ if !user_exists {\n line_err(format!(\"user @{target} not found\"), None)\n } else {\n match parse_capability(&capability) {\ndiff --git a/server/tests/integration.rs b/server/tests/integration.rs\nindex 9162bd5bee84035e3908bfb9c8e201b7878ca339..b930120da09fe7d307f0411b84fb639fb8bd0b15 100644\n--- a/server/tests/integration.rs\n+++ b/server/tests/integration.rs\n@@ -95,6 +95,23 @@ async fn test_healthz() {\n assert_eq!(response.text().await.unwrap(), \"ok\");\n }\n \n+#[tokio::test]\n+async fn test_room_create_private_rpc() {\n+ let (addr, _tmp, _log, _handle) = create_test_server().await;\n+ let client = reqwest::Client::new();\n+ let batch = serde_json::json!([{\n+ \"RoomCreate\": { \"slug\": \"secret-project\", \"visibility\": \"private\" }\n+ }]);\n+ let body = rpc_batch(&client, addr, Some(&test_bearer()), batch).await;\n+ let line = &body[\"results\"][0];\n+ assert_eq!(line[\"ok\"], true, \"room create: {:?}\", line);\n+ let room_id = line[\"result\"][\"RoomCreated\"][\"room_id\"].as_str().unwrap();\n+ assert!(\n+ room_id.contains(\"/secret-project\"),\n+ \"expected room_id to contain slug, got {room_id}\"\n+ );\n+}\n+\n #[tokio::test]\n async fn test_index_page() {\n // HTML routes are offline during the auth-v3 refactor.\ndiff --git a/test/auth.bb b/test/auth.bb\nindex 611e04f1806ef81d678a91f499597fe55f691dd7..a67cc1de763434176d2f68e419dd37a8cd328395 100644\n--- a/test/auth.bb\n+++ b/test/auth.bb\n@@ -176,7 +176,7 @@\n (assert! (= 200 (:status poll)) \"pending-session poll returns 200\")\n (let [poll-json (json/parse-string (:body poll) true)]\n (assert! (:complete poll-json) \"pending session complete=true\")\n- (assert! (= \"@bbuser\" (:user poll-json)) \"poll returns @bbuser\")\n+ (assert! (= \"bbuser\" (:user poll-json)) \"poll returns stored username bbuser\")\n (assert! (clojure.string/starts-with? (:token poll-json) \"slug_\") \"poll returns bearer token\")\n \n (println \"\\nwhoami…\")\n@@ -184,7 +184,7 @@\n :headers {\"Authorization\" (str \"Bearer \" (:token poll-json))})]\n (assert! (= 200 (:status who)) \"whoami returns 200\")\n (let [who-json (json/parse-string (:body who) true)]\n- (assert! (= \"@bbuser\" (:user who-json)) \"whoami user is @bbuser\"))))))\n+ (assert! (= \"bbuser\" (:user who-json)) \"whoami user is bbuser (stored form)\"))))))\n \n (println \"\\nCLI: identity start → OAuth → identity poll → whoami…\")\n (let [cli-home (str tmp-dir \"/cli-home\")\n@@ -208,7 +208,7 @@\n (str \"identity poll exits 0 (stderr: \" (:err poll-proc) \")\"))\n (let [poll-cli (json/parse-string (:out poll-proc) true)]\n (assert! (= \"complete\" (:phase poll-cli)) \"identity poll --json phase\")\n- (assert! (= \"@cliuser\" (:user poll-cli)) \"CLI poll user\")\n+ (assert! (= \"cliuser\" (:user poll-cli)) \"CLI poll user (stored form)\")\n (assert! (clojure.string/starts-with? (:token poll-cli) \"slug_\") \"CLI poll token\")\n (let [token-path (str cli-home \"/.config/slugsocial/token\")]\n (assert! (fs/exists? token-path) \"token written under isolated HOME\")\n@@ -219,7 +219,7 @@\n (assert! (zero? (:exit who-proc))\n (str \"whoami exits 0 (stderr: \" (:err who-proc) \")\"))\n (let [who-cli (json/parse-string (:out who-proc) true)]\n- (assert! (= \"@cliuser\" (:user who-cli)) \"CLI whoami uses saved token\"))))))))\n+ (assert! (= \"cliuser\" (:user who-cli)) \"CLI whoami uses saved token\"))))))))\n \n (finally\n (when-some [s @!server] (common/kill-server s))\ndiff --git a/test/common.bb b/test/common.bb\nindex ebb16c615a8b40e8830b5d5765d1e935a45538d0..4ed450377cdbb020f5ff164a812dfbbfc23c0f47 100644\n--- a/test/common.bb\n+++ b/test/common.bb\n@@ -78,9 +78,20 @@\n \n (defn start-server\n \"Start the slugsocial-server binary with the given env map.\n- Returns the babashka.process map.\"\n- [server-bin env-map]\n- (p/process [server-bin] {:out :inherit :err :inherit :env env-map}))\n+ Returns the babashka.process map.\n+\n+ When `log-file` (string path) is provided, stdout and stderr are appended there\n+ instead of inheriting the parent descriptors. Inheriting shared pipes while the\n+ parent blocks on HTTP I/O can fill the pipe buffer and deadlock the server on log writes.\"\n+ ([server-bin env-map]\n+ (start-server server-bin env-map nil))\n+ ([server-bin env-map log-file]\n+ (p/process [server-bin]\n+ (if log-file\n+ ;; Two string paths (same file): babashka.process can deref the process cleanly.\n+ ;; :err :out + ProcessBuilder$Redirect breaks stream copying in deref/kill-server.\n+ {:env env-map :out log-file :err log-file}\n+ {:out :inherit :err :inherit :env env-map}))))\n \n (defn kill-server\n \"Forcibly kill a server process (babashka.process map) and wait for it to exit.\"\ndiff --git a/test/grants.bb b/test/grants.bb\nindex 7066c1f2a57f39a362052f8524206dccf37bb7b1..793ba216f2de76a77eb20a76d08403db07ff411b 100644\n--- a/test/grants.bb\n+++ b/test/grants.bb\n@@ -35,13 +35,14 @@\n (defn- http-client []\n (-> (java.net.http.HttpClient/newBuilder)\n (.followRedirects java.net.http.HttpClient$Redirect/ALWAYS)\n+ (.connectTimeout (java.time.Duration/ofSeconds 15))\n (.build)))\n \n (defn- http-get [url & {:keys [headers]}]\n (let [b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]\n (doseq [[k v] (or headers {})]\n (.header b k v))\n- (let [req (-> b (.GET) (.build))\n+ (let [req (-> b (.timeout (java.time.Duration/ofSeconds 60)) (.GET) (.build))\n resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]\n {:status (.statusCode resp) :body (.body resp)})))\n \n@@ -52,6 +53,7 @@\n (doseq [[k v] (or headers {})]\n (.header b k v))\n (let [req (-> b\n+ (.timeout (java.time.Duration/ofSeconds 60))\n (.POST (java.net.http.HttpRequest$BodyPublishers/ofString body))\n (.build))\n resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]\n@@ -67,6 +69,7 @@\n b (java.net.http.HttpRequest/newBuilder (java.net.URI/create url))]\n (.header b \"Content-Type\" \"application/x-www-form-urlencoded\")\n (let [req (-> b\n+ (.timeout (java.time.Duration/ofSeconds 60))\n (.POST (java.net.http.HttpRequest$BodyPublishers/ofString pairs))\n (.build))\n resp (.send (http-client) req (java.net.http.HttpResponse$BodyHandlers/ofString))]\n","role":"user"}],"model":"openai/gpt-chat-latest"}