constitution · epochs · watch · epoch 3

comparison

c_8dc1a8119370 (tommy-mor) vs c_509fc5155034 (tommy-mor)

download prompt · raw event · cmp_8fa8e23591fd4b

council reasoning

~anthropic/claude-sonnet-latest · winner B · 6:4 · permalink

B builds substantial lasting architecture (a fractal ItemId/GlobalTree model, breadcrumb navigation, parent-child ranking, journal worker replacing settlement) that generalizes the app beyond flat subreddit scopes, while A merely replaces an over-engineered but self-contained autocomplete graph with a simpler paste-and-go parser. However, B's diff also carries real risk/incompleteness (renamed settlement->journal with duplicated logic, legacy scope shims, less test coverage of new tree semantics), so it's not a clean sweep, but its net design contribution outweighs A's more isolated simplification and deletion of test infrastructure.

~x-ai/grok-latest · winner B · 2:5 · permalink

A’s lasting value is a correct simplification: ripping out the unreliable ~1.8k-line keystroke graph, parser_action, race harness, and live-autocomplete JS in favor of paste-and-go plus a small URL parser. B goes further with durable product architecture—hierarchical ItemId canonicalization, GlobalTree/NodeState with parent-scoped rankings, journaled NodeEnsured events, breadcrumbs/entity panel, and item-based navigation—so the app’s core model, not only one UI entrypoint, becomes hierarchical and extensible.

openai/gpt-chat-latest · winner B · 4:1 · permalink

Side B introduces a durable architectural shift from flat subreddit scopes to canonical `ItemId`-based hierarchical nodes, adding `GlobalTree`, breadcrumb navigation, parent-child rankings, canonical URL parsing, and a journal-backed persistence path. By contrast, Side A mainly simplifies the UI by replacing the fragile autocomplete parser with a paste-and-go flow and a much smaller parser, which is a valuable cleanup but narrower in long-term project impact.

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 omitted

download full diff A

B — c_509fc5155034 (tommy-mor)

message

[88577c56] reconfigure

diff preview

diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index c4ab9d65c7b3cd42a5b4d093ba429993c101e9a8..82b2aa51d21ada1d0d849d3ddfc3a81e4241d861 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -9,7 +9,9 @@ use crate::{
     html::{js_string_literal, ranking_panel, JsBuilder},
     parser::parse_reddit_url,
     parser_render::navigate_panel,
-    state::AppState,
+    path_types::ItemId,
+    reddit::ensure_partial_tree,
+    state::{parse_item_param, AppState},
     ui_action::{parse_html_ui_from_form, HtmlUiAction},
 };
 
@@ -25,6 +27,10 @@ fn ui_js_warn(msg: &str) -> Response {
         .unwrap()
 }
 
+fn parent_from_scope(scope: &str) -> ItemId {
+    parse_item_param(scope)
+}
+
 pub async fn post_ui_html(
     State(state): State<AppState>,
     Form(form): Form<HashMap<String, String>>,
@@ -42,24 +48,36 @@ pub async fn post_ui_html(
             ratio_right,
             scope,
         } => {
+            let parent = parent_from_scope(&scope);
             if let Err(e) = state
-                .record_vote(&scope, &a, &b, ratio_left, ratio_right)
+                .record_vote(&parent, &a, &b, ratio_left, ratio_right)
                 .await
             {
                 return ui_js_warn(&e).into_response();
             }
-            let scope = crate::state::normalize_scope(&scope);
-            let groups = state.groups.read().await;
+            let tree = state.tree.read().await;
             let empty = crate::reducer::GroupState::new();
-            let group = groups.get(&scope).unwrap_or(&empty);
-            let panel = ranking_panel(&scope, group);
+            let group = tree
+                .get(&parent)
+                .map(|n| &n.local_ranking)
+                .unwrap_or(&empty);
+            let panel = ranking_panel(&parent, group);
             JsBuilder::new()
                 .morph_selector("#ranking-panel", panel)
                 .into_response()
         }
         HtmlUiAction::ParseQuery { query } => match parse_reddit_url(&query) {
-            Ok(subreddit) => {
-                let dest = format!("/?sub={subreddit}");
+            Ok(item) => {
+                {
+                    let mut tree = state.tree.write().await;
+                    ensure_partial_tree(&mut tree, &item);
+                }
+                let _ = state.ensure_node(&item).await;
+                let dest = if item.is_root() {
+                    "/".to_string()
+                } else {
+                    format!("/?item={}", item.as_str())
+                };
                 JsBuilder::new()
                     .raw(&format!(
                         "window.location.href={};",
diff --git a/server/src/events.rs b/server/src/events.rs
index a862370fc840ffe02184a11c578e18239cc9474d..ed5be6b13b9d46e838831d6ce0f96f569b401730 100644
--- a/server/src/events.rs
+++ b/server/src/events.rs
@@ -5,8 +5,8 @@ use serde::{Deserialize, Serialize};
 pub enum Event {
     /// Page view recorded (path → counter in views.json).
     ViewRecorded { path: String, ts: i64 },
-    /// Pairwise comparison vote (replayed into the scope's [`crate::reducer::GroupState`] on boot).
-    /// `scope` is the ranking subject (e.g. a subreddit); empty string is the default/global scope.
+    /// Pairwise comparison vote (replayed into the parent node's [`crate::reducer::GroupState`] on boot).
+    /// `scope` is the parent [`crate::path_types::ItemId`] string; empty string is the tree root.
     VoteRecorded {
         ts: i64,
         a: String,
@@ -16,4 +16,6 @@ pub enum Event {
         #[serde(default)]
         scope: String,
     },
+    /// Register a node path in the fractal tree (no external fetch).
+    NodeEnsured { id: String },
 }
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 9650d333d29c4ac94ceb407aee3ee00399c7f40b..c973cb718ac74b95570dabea76e24459417790b9 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -12,9 +12,10 @@ use serde::Deserialize;
 use crate::{
     form_template::template_json_compact,
     parser_render::navigate_panel,
+    path_types::ItemId,
     ranking::{top_bottom, RankedItem},
-    reducer::GroupState,
-    state::{normalize_scope, AppState},
+    reducer::{GroupState, NodeState},
+    state::{parse_item_param, AppState},
     ui_action::UI_RPC_FIELD,
 };
 
@@ -216,6 +217,48 @@ fn layout(title: &str, body: Markup, views: u64, theme: &str, theme_next: &str)
     }
 }
 
+fn item_href(id: &ItemId) -> String {
+    if id.is_root() {
+        "/".to_string()
+    } else {
+        format!("/?item={}", id.as_str())
+    }
+}
+
+fn segment_label(seg: &str) -> &str {
+    seg
+}
+
+/// Generic breadcrumb trail from an [`ItemId`] path.
+pub fn breadcrumb_path(item: &ItemId) -> Markup {
+    html! {
+        nav class="breadcrumbs" aria-label="Breadcrumb" {
+            a href="/" { "Internet" }
+            @for path in item.breadcrumb_paths() {
+                @let seg = path.segments().last().map_or("", |v| *v);
+                span class="separator" { " / " }
+                a href=(item_href(&path)) { (segment_label(seg)) }
+            }
+        }
+    }
+}
+
+fn entity_panel(node: &NodeState) -> Markup {
+    html! {
+        @if let Some(data) = &node.data {
+            section id="entity-panel" class="demo-panel entity-card" {
+                h2 { (data.title) }
+                @if let Some(author) = &data.author {
+                    p class="muted small" { "by " (author) }
+                }
+                @if let Some(body) = &data.body_html {
+                    div class="entity-body" { (maud::PreEscaped(body)) }
+                }
+            }
+        }
+    }
+}
+
 fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {
     html! {
         @if !items.is_empty() {
@@ -224,7 +267,9 @@ fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {
                 @for (i, r) in items.iter().enumerate() {
                     li {
                         span class="rank-num" { (start_rank + i) ". " }
-                        strong { (r.item.as_str()) }
+                        a href=(item_href(&r.item)) {
+                            strong { (display_label(&r.item)) }
+                        }
                         span class="muted" {
                             " — "
                             ({ format!("{:.1}%", r.score * 100.0) })
@@ -236,23 +281,30 @@ fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {
     }
 }
 
-pub fn ranking_panel(scope: &str, group: &GroupState) -> Markup {
+fn display_label(id: &ItemId) -> String {
+    id.segments()
+        .last()
+        .map_or("Internet", |v| *v)
+        .to_string()
+}
+
+pub fn ranking_panel(item: &ItemId, group: &GroupState) -> Markup {
     let total = group.idx_to_item.len();
     let (top, bottom) = top_bottom(group, 8);
     html! {
         section id="ranking-panel" class="demo-panel" {
             h2 {
                 "Ranking"
-                @if !scope.is_empty() {
-                    " — " span class="scope-name" { "r/" (scope) }
+                @if !item.is_root() {
+                    " — " span class="scope-name" { (item.as_str()) }
                 }
             }
             @if total == 0 {
                 p class="muted" {
-                    @if scope.is_empty() {
+                    @if item.is_root() {
                         "No votes yet — compare two items below."
                     } @else {
-                        "No votes yet for r/" (scope) " — compare two items below to start the ranking."
+                        "No votes yet for " (item.as_str()) " — compare two items below to start the ranking."
                     }
                 }
             } @else {
@@ -266,7 +318,8 @@ pub fn ranking_panel(scope: &str, group: &GroupState) -> Markup {
     }
 }
 
-pub fn vote_panel(scope: &str) -> Markup {
+pub fn vote_panel(parent: &ItemId) -> Markup {
+    let parent_str = parent.as_str();
     let rpc = template_json_compact(&serde_json::json!({
         "action": "record_vote",
         "a": {"$form": "item_a"},
@@ -280,16 +333,17 @@ pub fn vote_panel(scope: &str) -> Markup {
         section id="vote-panel" class="demo-panel" {
             h2 { "Compare" }
             p class="muted small" {
-                @if scope.is_empty() {
+                @if parent.is_root() {
                     "Left item wins at 2:1. Votes append to the JSONL log and update rank centrality."
                 } @else {
-                    "Ranking " span class="scope-name" { "r/" (scope) }
+                    "Ranking children of "
+                    span class="scope-name" { (parent_str) }
                     ". Left item wins at 2:1; each vote updates this ranking."
                 }
             }
             form method="post" action="/ui" id="vote-form" {
                 input type="hidden" name=(UI_RPC_FIELD) value=(rpc);
-                input type="hidden" name="scope" value=(scope);
+                input type="hidden" name="scope" value=(parent_str);
                 div class="vote-fields" {
                     label {
                         "Left (wins) "
@@ -329,17 +383,30 @@ pub async fn home(
     let views = state.views.get_views(&path);
     let theme = theme_from_jar(&jar);
     let theme_next = theme_next_from_uri(&uri);
-    let scope = normalize_scope(&query_param(&uri, "sub").unwrap_or_default());
 
-    let groups = state.groups.read().await;
-    let empty = GroupState::new();
-    let group = groups.get(&scope).unwrap_or(&empty);
+    let item_raw = query_param(&uri, "item")
+        .or_else(|| query_param(&uri, "sub").map(|sub| {
+            if sub.is_empty() {
+                String::new()
+            } else {
+                format!("reddit.com/r/{sub}")
+            }
+        }))
+        .unwrap_or_default();
+    let item = parse_item_param(&item_raw);
+
+    let tree = state.tree.read().await;
+    let empty_node = NodeState::default();
+    let node = tree.get(&item).unwrap_or(&empty_node);
+    let group = &node.local_ranking;
 
     let body = html! {
         h1 { "sorter2" }
+        (breadcrumb_path(&item))
         (navigate_panel("", None))
-        (vote_panel(&scope))
-        (ranking_panel(&scope, group))
+        (entity_panel(node))
+        (vote_panel(&item))
+        (ranking_panel(&item, group))
     };
     layout("sorter2", body, views, theme, &theme_next)
 }
diff --git a/server/src/journal.rs b/server/src/journal.rs
new file mode 100644
index 0000000000000000000000000000000000000000..b02ca025683621470ffdf8cd85cf9b85c56d024d
--- /dev/null
+++ b/server/src/journal.rs
@@ -0,0 +1,89 @@
+use std::sync::Arc;
+
+use tokio::sync::{mpsc, oneshot, RwLock};
+
+use crate::{
+    event_log::EventLog,
+    events::Event,
+    path_types::ItemId,
+    reducer::{GlobalTree, VoteData},
+};
+
+pub struct JournalCommand {
+    pub parent: ItemId,
+    pub vote: VoteData,
+    pub event: Event,
+    pub reply: oneshot::Sender<Result<(), String>>,
+}
+
+#[derive(Clone)]
+pub struct JournalClient {
+    tx: mpsc::Sender<JournalCommand>,
+}
+
+impl JournalClient {
+    pub fn spawn(tree: Arc<RwLock<GlobalTree>>, event_log: Arc<EventLog>) -> Self {
+        let (tx, rx) = mpsc::channel(64);
+        tokio::spawn(journal_worker(rx, tree, event_log));
+        Self { tx }
+    }
+
+    pub async fn record_vote(
+        &self,
+        parent: ItemId,
+        vote: VoteData,
+        event: Event,
+    ) -> Result<(), String> {
+        let (reply, rx) = oneshot::channel();
+        self.tx
+            .send(JournalCommand {
+                parent,
+                vote,
+                event,
+                reply,
+            })
+            .await
+            .map_err(|_| "journal worker stopped".to_string())?;
+        rx.await
+            .map_err(|_| "journal worker stopped".to_string())?
+    }
+}
+
+async fn journal_worker(
+

… preview truncated; 29,088 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.