constitution · epochs · watch · epoch 3

comparison

c_a337088f57f0 (tommy-mor) vs c_5e9a63e9d276 (tommy-mor)

download prompt · raw event · cmp_197fcce9d0f98c

council reasoning

~anthropic/claude-sonnet-latest · winner B · 8:2 · permalink

Side B performs a substantial, coherent refactor that moves path/URL logic into a shared slug-types crate with type-safe newtypes (GardenItemUrl, ForumThreadUrl, TildeOntologyPath), reducing duplication and making the wire format correct-by-construction across server modules and RPC responses, backed by preserved/expanded tests. Side A is a smaller dev-tooling improvement (fixture persistence via cargo-watch) plus an unrelated, undocumented removal of room page headers, offering less durable architectural value than B's cross-cutting type-safety refactor.

~x-ai/grok-latest · winner B · 1:4 · permalink

B centralizes path canonicalization and wire URL construction into slug-types (GardenItemUrl, ForumThreadUrl, TildeOntologyPath), typing API/RPC fields and removing ad-hoc string helpers—lasting core design. A mainly improves local fixture DX (cargo-watch, persistent fixture-data/, preferred port 8080) plus a small room-page HTML cleanup, valuable for iteration but not structural.

openai/gpt-chat-latest · winner B · 5:2 · permalink

Side B performs a substantial architectural refactor by moving canonical path normalization and URL construction into a shared `slug_types::paths` module, replacing ad hoc string helpers with strongly typed `GardenItemUrl`, `ForumThreadUrl`, and related types across the API and shared data structures. Side A mainly improves the local development workflow by switching the fixture runner to `cargo watch` with persistent fixture data and preferred ports, which is useful but primarily affects developer iteration rather than the project's long-term design.

sides

A — c_a337088f57f0 (tommy-mor)

message

[a45842df] better dev iteration script

diff preview

diff --git a/.gitignore b/.gitignore
index 9cd2eaece834481bee87639cd7cb6be77f33e302..b70cf5b97cd3556e061233d8b0aefd333568a991 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ target/
 repomix-output.xml
 psalms_kjv.txt
 dev-data/
+fixture-data/
 .worktrees/
 .specstory/
 .specstory/
diff --git a/bb.edn b/bb.edn
index 5d1d642d75d03f70879d7673e7ad95360336a8b1..4680e82d4227057d2eea6041dc5647beb759131d 100644
--- a/bb.edn
+++ b/bb.edn
@@ -47,7 +47,7 @@
                                            "RUST_LOG"      "info"})})))}
 
   fixture
-  {:doc "Run local server + mock OAuth + seeded walkthrough data for manual browser demos"
+  {:doc "Run local server via cargo-watch + mock OAuth + seeded walkthrough data (persistent ./fixture-data/, prefers PORT 8080). Requires cargo-watch."
    :requires ([test.walkthrough-fixture :as walkthrough-fixture])
    :task (walkthrough-fixture/run-fixture)}
 
diff --git a/server/src/html/forum.rs b/server/src/html/forum.rs
index e00e7110e2ad778d63b3d00b0cc4ffe20ba2e0d4..815fc6529062b103553644fc15e516334aec2b88 100644
--- a/server/src/html/forum.rs
+++ b/server/src/html/forum.rs
@@ -976,12 +976,6 @@ pub async fn room_page(
         html! {
             (strip)
             nav class="breadcrumb" { (bc_room(&nav, slug_display, None)) }
-            h2 { (slug_display) }
-            p class="muted" { (room_id) }
-            p class="muted room-links" {
-                "room garden · "
-                a href=(nav.garden_root_url()) { "~" }
-            }
             (room_members_section_markup(&reduced, &room_id, false))
             h3 { "threads" }
             (render_thread_feed(Some(&nav), "room-thread-feed", &rows, now))
diff --git a/test/common.clj b/test/common.clj
index 76a3e99fdb67c10bcb5c2f53d8bcd0917398af50..c6deba0d90995b0e3f9c4b8ad66a516ed19b870e 100644
--- a/test/common.clj
+++ b/test/common.clj
@@ -107,6 +107,16 @@
    (.close ss)
    port))
 
+(defn pick-port-prefer
+  "Use `preferred` if it can be bound, otherwise an ephemeral port (same as `pick-port`)."
+  [preferred]
+  (try
+    (let [ss (java.net.ServerSocket. preferred)]
+      (.close ss)
+      preferred)
+    (catch java.io.IOException _
+      (pick-port))))
+
 (defn wait-for-server
   "Poll /healthz until it returns 'ok', up to `timeout-ms`."
   [base-url timeout-ms]
diff --git a/test/walkthrough_fixture.clj b/test/walkthrough_fixture.clj
index 4b814822029c913e38151ca53848d1c64fb58373..a487ff1ef8548580577914fb33f321586859c9af 100644
--- a/test/walkthrough_fixture.clj
+++ b/test/walkthrough_fixture.clj
@@ -1,11 +1,18 @@
 (ns test.walkthrough-fixture
   "Launch a local slug server with mock OAuth and seed browser-friendly demo data."
   (:require [babashka.fs :as fs]
+            [babashka.process :as p]
             [cheshire.core :as json]
             [clojure.string :as str]
             [test.common :as common]
             [test.oauth :as oauth]))
 
+(def ^:private fixture-data-dir "fixture-data")
+;; Prefer the same port as `bb dev` / `bb watch`; fall back if something else is listening.
+(def ^:private preferred-slug-port 8080)
+;; First `cargo watch` compile can exceed a release-binary startup; allow several minutes.
+(def ^:private health-wait-ms 300000)
+
 (defn- assert! [pred msg]
   (when-not pred
     (throw (ex-info msg {}))))
@@ -85,49 +92,87 @@
             :thread_url (str base-url "/r/" room-short "/" room-slug "/t/walkthrough-thread")
             :garden_url (str base-url "/r/" room-short "/" room-slug "/~/secret/item")}}))
 
+(defn- rebase-fixture-summary [saved current-base-url current-google-url current-data-dir]
+  (let [inner (:summary saved)
+        room (:room inner)
+        rs (:short room)
+        lg (:slug room)]
+    (assoc saved
+           :base_url current-base-url
+           :mock_google_url current-google-url
+           :data_dir (str current-data-dir)
+           :summary (assoc inner
+                           :room (assoc room
+                                         :url (str current-base-url "/r/" rs "/" lg)
+                                         :thread_url (str current-base-url "/r/" rs "/" lg "/t/walkthrough-thread")
+                                         :garden_url (str current-base-url "/r/" rs "/" lg "/~/secret/item"))))))
+
+(defn- fixture-log-present? [data-dir]
+  (let [p (fs/path data-dir "events.jsonl")]
+    (and (fs/exists? p) (pos? (fs/size p)))))
+
+(defn- load-or-seed-summary!
+  [base-url google-url data-dir summary-path]
+  (if (and (fixture-log-present? data-dir) (fs/exists? summary-path))
+    (let [saved (json/parse-string (slurp summary-path) true)
+          rebased (rebase-fixture-summary saved base-url google-url data-dir)]
+      (spit summary-path (json/generate-string rebased {:pretty true}))
+      (println "")
+      (println "reusing fixture-data/ (delete the directory for a fresh seed)")
+      rebased)
+    (let [seeded (seed-demo! base-url)
+          s {:base_url base-url
+             :mock_google_url google-url
+             :data_dir (str data-dir)
+             :summary seeded}]
+      (spit summary-path (json/generate-string s {:pretty true}))
+      s)))
+
 (defn run-fixture [& _args]
-  (let [build (common/run-cargo-build-release! ["slugsocial-server"])
-        _ (assert! (zero? (:exit build)) "cargo build --release failed")
-        server-bin "target/release/slugsocial-server"
-        tmp-dir (str (fs/create-temp-dir {:prefix "slug-walkthrough-"}))
-        slug-port (common/pick-port)
+  (let [data-dir (str (fs/absolutize (fs/path (fs/cwd) fixture-data-dir)))
+        slug-port (common/pick-port-prefer preferred-slug-port)
         google-port (common/pick-port)
         base-url (str "http://127.0.0.1:" slug-port)
         google-url (str "http://127.0.0.1:" google-port)
-        stable-dir "/tmp/slug-walkthrough-fixture"
-        summary-path (str stable-dir "/summary.json")
+        summary-path (str (fs/path data-dir "summary.json"))
         !server (atom nil)
         !google (atom nil)
-        server-env (common/slug-server-env tmp-dir base-url google-url slug-port)]
+        server-env (merge (common/slug-server-env data-dir base-url google-url slug-port)
+                          {"RUST_LOG" "info"})
+        watch-cmd [(common/cargo-bin) "watch"
+                   "-x" "run -p slugsocial-server"
+                   "-w" "server/src"
+                   "-w" "server/static"
+                   "-w" "types/src"]]
     (try
-      (fs/create-dirs stable-dir)
+      (fs/create-dirs data-dir)
       (reset! !google
               (oauth/start-mock-google google-port
                                        :google-users ["google-user-alice" "google-user-bob"]))
-      (reset! !server (common/start-server server-bin server-env))
-      (assert! (common/wait-for-server base-url 10000) "server did not become healthy")
-      (let [seeded (seed-demo! base-url)
-            summary {:base_url base-url
-                     :mock_google_url google-url
-                     :data_dir tmp-dir
-                     :summary seeded}]
-        (spit summary-path (json/generate-string summary {:pretty true}))
+      (println "")
+      (println "starting cargo-watch (first compile may take a while)…")
+      (flush)
+      (reset! !server (p/process watch-cmd {:inherit true :env server-env}))
+      (assert! (common/wait-for-server base-url health-wait-ms) "server did not become healthy")
+      (let [summary (load-or-seed-summary! base-url google-url data-dir summary-path)]
         (println "")
         (println "walkthrough fixture ready")
         (println (str "  base url:      " base-url))
         (println (str "  room page:     " (get-in summary [:summary :room :url])))
         (println (str "  thread page:   " (get-in summary [:summary :room :thread_url])))
         (println (str "  garden page:   " (get-in summary [:summary :room :garden_url])))
+        (println (str "  data dir:      " data-dir))
         (println (str "  summary json:  " summary-path))
         (println "")
         (println "seeded users")
         (println "  alice / bob via mock OAuth")
         (println "")
+        (println "editing server/src or server/static reloads the server; data persists in fixture-data/")
+        (println "")
         (println "press Ctrl-C to stop")
         (flush)
         (while true
           (Thread/sleep 1000)))
       (finally
         (when-some [s @!server] (common/kill-server s))
-        (when-some [g @!google] ((:stop-fn g)))
-        (fs/delete-tree tmp-dir)))))
+        (when-some [g @!google] ((:stop-fn g)))))))

download full diff A

B — c_5e9a63e9d276 (tommy-mor)

message

[a888d56c] refactor: centralize path identity in slug-types

Move canonicalization and CanonicalItemUrl into types::paths with
GardenItemUrl, ForumThreadUrl, and TildeOntologyPath for JSON hrefs.
Server canonical_path and path_types re-export slug-types; RPC and
validation build hrefs via those types instead of string helpers.

Made-with: Cursor

diff preview

diff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs
index 9b71491e9f9efc44a2a4beba09be8f64bd2ff2ee..03b3e77911ccd662bec8635345dafe2593cf242e 100644
--- a/server/src/api/helpers.rs
+++ b/server/src/api/helpers.rs
@@ -4,12 +4,12 @@ use axum::{
     Json,
 };
 use sha2::{Digest, Sha256};
+use slug_types::paths::{CanonicalItemUrl, GardenItemUrl};
 use slug_types::*;
 use std::collections::HashMap;
 
 use crate::{
     canonical_path::canonicalize_item,
-    path_types::CanonicalItemUrl,
     ranking::connected_components_from_voted_pairs,
 };
 
@@ -30,64 +30,6 @@ pub fn now_ms() -> i64 {
     t.as_millis() as i64
 }
 
-/// Serialize a canonical item for JSON: absolute URLs stay as-is; bare paths get a `/` prefix.
-pub fn item_path_for_api(item: &str) -> String {
-    if item.starts_with("http://") || item.starts_with("https://") {
-        item.to_string()
-    } else {
-        format!("/{}", item)
-    }
-}
-
-/// Same as [`item_path_for_api`], but for private rooms ontology items are prefixed with
-/// `/r/{short}/{slug}` so the URL matches the web app (`/r/…/~/…` routes).
-pub fn item_path_for_api_in_room(item: &str, room_wire: &str) -> String {
-    let room = room_wire.trim();
-    if room.is_empty() || room == "public" {
-        return item_path_for_api(item);
-    }
-    let Some((short, slug)) = room.split_once('/') else {
-        return item_path_for_api(item);
-    };
-    if short.is_empty() || slug.is_empty() {
-        return item_path_for_api(item);
-    }
-    let Some(c) = CanonicalItemUrl::parse(item) else {
-        return item_path_for_api(item);
-    };
-    let root = CanonicalItemUrl::ontology_root();
-    let item_norm = c.as_str().trim_end_matches('/');
-    let root_norm = root.as_str().trim_end_matches('/');
-    if let Some(tail) = c.tilde_tail() {
-        return if tail.is_empty() {
-            format!("https://slug.social/r/{short}/{slug}/~")
-        } else {
-            format!("https://slug.social/r/{short}/{slug}/~/{}", tail)
-        };
-    }
-    if item_norm == root_norm {
-        return format!("https://slug.social/r/{short}/{slug}/~");
-    }
-    item_path_for_api(item)
-}
-
-/// Absolute thread URL for forum JSON (`/t/…` vs `/r/…/t/…`).
-pub fn forum_thread_web_url(room_wire: &str, thread_tag: &str) -> String {
-    let room = room_wire.trim();
-    let tag = thread_tag.trim().trim_start_matches('#');
-    if room.is_empty() || room == "public" {
-        format!("https://slug.social/t/{tag}")
-    } else if let Some((short, slug)) = room.split_once('/') {
-        if short.is_empty() || slug.is_empty() {
-            format!("https://slug.social/t/{tag}")
-        } else {
-            format!("https://slug.social/r/{short}/{slug}/t/{tag}")
-        }
-    } else {
-        format!("https://slug.social/t/{tag}")
-    }
-}
-
 /// Resolve an item path as a first-class canonical path.
 pub fn resolve_item(item: &str) -> Result<String, String> {
     let canonical = canonicalize_item(item);
@@ -109,14 +51,12 @@ pub fn parse_parent_specs(parent: Option<&String>) -> Vec<String> {
 }
 
 /// Apply offset+limit pagination to the flattened component rankings.
-/// Items are flattened in component order (largest component first), then unranked last.
-/// Returns (components, unranked_items) after the window.
 pub fn paginate_rankings(
     components: Vec<RankComponent>,
-    unranked_items: Vec<String>,
+    unranked_items: Vec<GardenItemUrl>,
     offset: usize,
     limit: Option<usize>,
-) -> (Vec<RankComponent>, Vec<String>) {
+) -> (Vec<RankComponent>, Vec<GardenItemUrl>) {
     let mut remaining_skip = offset;
     let mut remaining_take = limit.unwrap_or(usize::MAX);
     let mut out_components: Vec<RankComponent> = Vec::new();
@@ -141,7 +81,7 @@ pub fn paginate_rankings(
         });
     }
 
-    let out_unranked: Vec<String> = if remaining_take > 0 {
+    let out_unranked: Vec<GardenItemUrl> = if remaining_take > 0 {
         unranked_items
             .into_iter()
             .skip(remaining_skip)
@@ -183,11 +123,9 @@ pub fn is_pair_voted(group: &crate::reducer::GroupState, a: &str, b: &str) -> bo
     group.voted_pairs.contains(&(i, j))
 }
 
-/// Compute graph connectivity stats for a set of items within the ranking group.
 pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[String]) -> ConnectivityStats {
     let n = pool.len();
 
-    // Map pool items to global indices (items not yet in the group get no index)
     let global_idxs: Vec<Option<usize>> = pool
         .iter()
         .map(|it| {
@@ -197,7 +135,6 @@ pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[St
         .collect();
     let present: Vec<usize> = global_idxs.iter().filter_map(|x| *x).collect();
 
-    // Build local index mapping for items that exist in the ranking group
     let global_to_local: HashMap<usize, usize> = present
         .iter()
         .enumerate()
@@ -213,7 +150,6 @@ pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[St
         }),
     );
 
-    // Items not in the ranking group at all are also isolates
     let items_not_in_group = global_idxs.iter().filter(|x| x.is_none()).count();
 
     let num_components = comps.len() + isolates.len() + items_not_in_group;
@@ -237,52 +173,3 @@ pub fn vote_touches_path(a: &str, b: &str, parent_canon: &str) -> bool {
     let under = |item: &str| item == parent_canon || item.starts_with(&format!("{}/", parent_canon));
     under(a) || under(b)
 }
-
-#[cfg(test)]
-mod wire_url_tests {
-    use super::{forum_thread_web_url, item_path_for_api_in_room};
-
-    #[test]
-    fn public_room_unchanged() {
-        let u = "https://slug.social/~/a/b";
-        assert_eq!(item_path_for_api_in_room(u, "public"), u);
-    }
-
-    #[test]
-    fn private_room_prefixes_ontology() {
-        assert_eq!(
-            item_path_for_api_in_room("https://slug.social/~/topic/x", "9ab12cd/my-room"),
-            "https://slug.social/r/9ab12cd/my-room/~/topic/x"
-        );
-    }
-
-    #[test]
-    fn private_room_ontology_root() {
-        assert_eq!(
-            item_path_for_api_in_room("https://slug.social/~", "9ab12cd/my-room"),
-            "https://slug.social/r/9ab12cd/my-room/~"
-        );
-        assert_eq!(
-            item_path_for_api_in_room("https://slug.social/~/", "9ab12cd/my-room"),
-            "https://slug.social/r/9ab12cd/my-room/~"
-        );
-    }
-
-    #[test]
-    fn external_url_untouched_in_private_room() {
-        let u = "https://example.com/z";
-        assert_eq!(item_path_for_api_in_room(u, "9ab12cd/my-room"), u);
-    }
-
-    #[test]
-    fn forum_web_public_vs_room() {
-        assert_eq!(
-            forum_thread_web_url("public", "debate"),
-            "https://slug.social/t/debate"
-        );
-        assert_eq!(
-            forum_thread_web_url("9ab12cd/my-room", "#debate"),
-            "https://slug.social/r/9ab12cd/my-room/t/debate"
-        );
-    }
-}
diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs
index 042aa248305f9362a3be78f9eea2a5abf6ba707a..cf22cb0129366c3aed031bc86f3197a4321cb806 100644
--- a/server/src/api/mod.rs
+++ b/server/src/api/mod.rs
@@ -24,8 +24,7 @@ pub use auth::{
 
 pub use helpers::{
     api_error, compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings,
-    parse_parent_specs, pick_random_distinct, sha256_hex, resolve_item, vote_touches_path,
-    item_path_for_api,
+    parse_parent_specs, pick_random_distinct, resolve_item, sha256_hex, vote_touches_path,
 };
 
 pub use rpc::handle_rpc_batch;
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index 5b91f5836625eedbb1cd9423168046e3fb576c17..5f7d50188f1381267402f2e57e671234ef5db2fd 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -8,6 +8,7 @@ use axum::{
     Json,
 };
 use rand::seq::SliceRandom;
+use slug_types::paths::{ForumThreadUrl, GardenItemUrl, TildeOntologyPath};
 use slug_types::*;
 
 use crate::{
@@ -27,9 +28,8 @@ use crate::{
 
 use super::auth::verify_bearer_principal;
 use super::helpers::{
-    compute_connectivity_stats, forum_thread_web_url, is_pair_voted, item_path_for_api,
-    item_path_for_api_in_room, now_ms, paginate_rankings, parse_parent_specs, pick_random_distinct,
-    resolve_item, vote_touches_path,
+    compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings, parse_parent_specs,
+    pick_random_distinct, resolve_item, vote_touches_path,
 };
 use super::validate::{normalize_room_and_thread, validate_ingest_document};
 
@@ -184,7 +184,7 @@ fn compute_scope_rank_changes(
         };
         if changed {
             changes.push(RankChange {
-                item: item_path_for_api_in_room(&item, room_wire),
+                item: GardenItemUrl::from_storage_str(&item, room_wire),
                 before: b,
                 after: a,
             });
@@ -206,7 +206,7 @@ fn compute_scope_rank_changes(
         parent: if parent.is_empty() {
             "/".to_string()
         } else {
-            item_path_for_api_in_room(parent, room_wire)
+            GardenItemUrl::from_storage_str(parent, room_wire).into_inner()
         },
         changes,
     })
@@ -302,7 +302,7 @@ fn build_rank_response_for_content(
                     .ranked
                     .into_iter()
                     .map(|r| RankRow {
-                        item: item_path_for_api_in_room(r.item.as_str(), room_wire),
+                        item: GardenItemUrl::from_stored(&r.item, room_wire),
                         percent: if want_percent {
                             Some((r.score / max_score) * 100.0)
                         } else {
@@ -315,10 +315,10 @@ fn build_rank_response_for_content(
         })
         .collect();
 
-    let prefixed_unranked: Vec<String> = rankings
+    let prefixed_unranked: Vec<GardenItemUrl> = rankings
         .unranked_items
         .into_iter()
-        .map(|s| item_path_for_api_in_room(s.as_str(), room_wire))
+        .map(|s| GardenItemUrl::from_stored(&s, room_wire))
         .collect();
 
     let (components, unranked_items) = if offset > 0 || limit.is_some() {
@@ -537,13 +537,13 @@ async fn rpc_post(
         (
             "npx slugsocial public garden pair".to_string(),
             "npx slugsocial public garden rank".to_string(),
-            forum_thread_web_url("public", &thread_id),
+            ForumThreadUrl::from_room_tag("public", &thread_id),
         )
     } else {
         (
             format!("npx slugsocial private {room_key} garden pair"),
             format!("npx slugsocial private {room_key} garden rank"),
-            forum_thread_web_url(&room_key, &thread_id),
+            ForumThreadUrl::from_room_tag(&room_key, &thread_id),
         )
     };
 
@@ -664,7 +664,7 @@ async fn rpc_check(
                         .ranked
                         .into_iter()
                         .map(|r| RankRow {
-                            item: item_path_for_api_in_room(r.item.as_str(), &room_key),
+                            item: GardenItemUrl::from_stored(&r.item, &room_key),
                             score: r.score,
                             percent: None,
                         })
@@ -672,12 +672,12 @@ async fn rpc_check(
                 })
                 .collect();
             CheckScopeRanking {
-                parent: item_path_for_api_in_room(parent.as_str(), &room_key),
+                parent: GardenItemUrl::from_stored(parent, &room_key).into_inner(),
                 components,
                 unranked_items: scoped
                     .unranked_items
                     .into_iter()
-                    .map(|it| item_path_for_api_in_room(it.as_str(), &room_key))
+                    .map(|it| GardenItemUrl::from_stored(&it, &room_key))
                     .collect(),
             }
         })
@@ -687,13 +687,13 @@ async fn rpc_check(
         vec![
             "npx slugsocial public forum post <TAG> --delegate <uuid:rig:

… preview truncated; 46,249 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.