{"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[b9476669] Remove browser sentinel delegates so multi-user votes work.\n\nShared WEB_BROWSER_AGENT bound on first vote and blocked every later human; browser posts now use no delegate, matching forum UI.\n\nCo-authored-by: Cursor \n\nSide A — unified diff (full patch):\ndiff --git a/cli/src/main.rs b/cli/src/main.rs\nindex c4f1494df3aedbd8b883aea6249579aa8336abfe..af3e4fee876910a44f6f872b24821bc541a23e20 100644\n--- a/cli/src/main.rs\n+++ b/cli/src/main.rs\n@@ -1741,8 +1741,8 @@ async fn run() -> Result<()> {\n tokio::time::sleep(std::time::Duration::from_millis(poll_interval_ms)).await;\n let poll: PendingSessionPollResponse =\n expect_json(client.get(&poll_url).send().await?).await?;\n- if !poll.agent.trim().is_empty() {\n- agent_out = Some(poll.agent.clone());\n+ if let Some(a) = poll.agent.as_deref().map(str::trim).filter(|s| !s.is_empty()) {\n+ agent_out = Some(a.to_string());\n }\n if poll.complete {\n token_out = poll.token;\ndiff --git a/server/src/api/auth.rs b/server/src/api/auth.rs\nindex 50dc6af908412b16343829eae25154ebf4e19fca..01c02f50c19bcd62c7f1717f9b927f203c3164d0 100644\n--- a/server/src/api/auth.rs\n+++ b/server/src/api/auth.rs\n@@ -29,18 +29,6 @@ use crate::{\n write_cmd::WriteCmd,\n };\n \n-/// Delegate id for browser users who land via `/join/inv_…` (no CLI agent).\n-const INVITE_BROWSER_AGENT: &str = \"00000000-0000-0000-0000-000000000000:invite:web/join\";\n-\n-/// Agent id for `/login` browser OAuth (no CLI); must pass [`parse_agent`].\n-pub const WEB_BROWSER_AGENT: &str = \"00000000-0000-0000-0000-000000000001:social:web/browser\";\n-\n-/// True for well-known browser / human-form sentinel delegates (not real AI agents).\n-/// HTML attribution should show the human username for these, not `@@uuid:rig:…`.\n-pub fn is_browser_sentinel_delegate(agent: &str) -> bool {\n- agent == WEB_BROWSER_AGENT || agent == INVITE_BROWSER_AGENT\n-}\n-\n /// HttpOnly cookie storing the same `slug_*` bearer string the CLI uses.\n pub const SLUG_SESSION_COOKIE: &str = \"slug_session\";\n \n@@ -288,7 +276,7 @@ pub async fn get_join_invite(\n let session = format!(\"p_{}\", uuid::Uuid::new_v4().simple());\n let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()));\n let s = PendingSession {\n- agent: INVITE_BROWSER_AGENT.to_string(),\n+ agent: None,\n created_ts: now_ms(),\n provider: None,\n provider_id: None,\n@@ -555,7 +543,7 @@ pub async fn post_choose_username(\n };\n \n let sessions = pending_sessions(&state);\n- let (provider, provider_id, agent) = {\n+ let (provider, provider_id) = {\n let sessions_read = sessions.read().await;\n let Some(s) = sessions_read.get(&form.session) else {\n return api_error(StatusCode::NOT_FOUND, \"unknown session\", None).into_response();\n@@ -566,14 +554,9 @@ pub async fn post_choose_username(\n let Some(provider_id) = s.provider_id.clone() else {\n return js_form_error_fragment(&form.session, \"oauth not completed\").into_response();\n };\n- (provider, provider_id, s.agent.clone())\n+ (provider, provider_id)\n };\n \n- if let Err(msg) = parse_agent(&agent) {\n- return js_form_error_fragment(&form.session, &format!(\"invalid agent format — {msg}\"))\n- .into_response();\n- }\n-\n let redeem_invite = {\n let sessions_read = sessions.read().await;\n sessions_read\n@@ -643,7 +626,8 @@ pub async fn get_web_login(\n let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()))\n .or_else(|| Some(\"/\".to_string()));\n let s = PendingSession {\n- agent: WEB_BROWSER_AGENT.to_string(),\n+ // Humans sign in via the website with no AI delegate.\n+ agent: None,\n created_ts: now_ms(),\n provider: None,\n provider_id: None,\n@@ -704,7 +688,7 @@ pub async fn post_pending_session(\n );\n let poll_url = format!(\"/api/v0/pending-session/{session}\");\n let s = PendingSession {\n- agent: agent_naked,\n+ agent: Some(agent_naked),\n created_ts: now_ms(),\n provider: None,\n provider_id: None,\ndiff --git a/server/src/api/mod.rs b/server/src/api/mod.rs\nindex fdff6db0472b36cd7870a4be68574023e0daf120..920e967b47852ea82fa61b84c457ae3582dd9800 100644\n--- a/server/src/api/mod.rs\n+++ b/server/src/api/mod.rs\n@@ -18,13 +18,11 @@ pub use auth::{\n get_choose_username,\n get_web_login,\n get_logout,\n- is_browser_sentinel_delegate,\n optional_principal,\n resolve_web_session,\n session_cookie_header_value,\n WebSession,\n SLUG_SESSION_COOKIE,\n- WEB_BROWSER_AGENT,\n };\n \n pub use helpers::{\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex 611956d0a46062b49ce350be176e4be139312ff0..6e56c039a184953cd1d0593c34cf4d5abe41f2a3 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -277,7 +277,7 @@ async fn dispatch_ui_action(\n &session.bearer,\n room.clone(),\n thread_tag.clone(),\n- Some(crate::api::auth::WEB_BROWSER_AGENT.to_string()),\n+ None,\n text,\n )\n .await\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex 645d54f2e117ba901e02ed958ada6ff69a78ae34..03ad762e88709c77b9d5dd130c48bc96226616a1 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -560,27 +560,28 @@ fn identity_color_css(seed: &str) -> String {\n format!(\"hsl({hue}, 62%, 66%)\")\n }\n \n-/// Seed for author color: real AI → delegate uuid; human/sentinel → principal username.\n+/// Seed for author color: AI → delegate uuid; human (no delegate) → principal username.\n fn authorship_color_seed<'a>(principal: &'a str, delegate: &'a Option) -> &'a str {\n match delegate {\n- Some(d) if !crate::api::is_browser_sentinel_delegate(d) => {\n- d.split(':').next().filter(|s| !s.is_empty()).unwrap_or(d.as_str())\n- }\n- _ => principal,\n+ Some(d) => d\n+ .split(':')\n+ .next()\n+ .filter(|s| !s.is_empty())\n+ .unwrap_or(d.as_str()),\n+ None => principal,\n }\n }\n \n-/// Prefer the AI delegate in attribution; fall back to the human username when there is no\n-/// delegate or the delegate is a browser/human-form sentinel.\n+/// Prefer the AI delegate in attribution; humans post with no delegate and show `@username`.\n pub(crate) fn authorship_attr(principal: &str, delegate: &Option) -> AuthorshipAttr {\n let color = identity_color_css(authorship_color_seed(principal, delegate));\n match delegate {\n- Some(d) if !crate::api::is_browser_sentinel_delegate(d) => AuthorshipAttr {\n+ Some(d) => AuthorshipAttr {\n label: format!(\"@@{}\", actor_label(d)),\n author_title: Some(format!(\"@{principal}\")),\n color,\n },\n- _ => AuthorshipAttr {\n+ None => AuthorshipAttr {\n label: format!(\"@{principal}\"),\n author_title: None,\n color,\n@@ -933,7 +934,6 @@ pub(super) fn recency_class(now_ms: i64, ts_ms: i64) -> &'static str {\n #[cfg(test)]\n mod authorship_tests {\n use super::*;\n- use crate::api::WEB_BROWSER_AGENT;\n \n #[test]\n fn human_or_missing_delegate_shows_username() {\n@@ -943,15 +943,6 @@ mod authorship_tests {\n assert!(a.color.starts_with(\"hsl(\"));\n }\n \n- #[test]\n- fn browser_sentinel_delegate_shows_username() {\n- let d = Some(WEB_BROWSER_AGENT.to_string());\n- let a = authorship_attr(\"alice\", &d);\n- assert_eq!(a.label, \"@alice\");\n- assert_eq!(a.author_title, None);\n- assert_eq!(authorship_address(\"alice\", &d), \"@alice\");\n- }\n-\n #[test]\n fn real_ai_delegate_shows_short_agent_with_username_hover() {\n let d = Some(\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex 648ab5304764a329fcabbbbcd3782b94e3e005a8..8ea84dcf9b1df8f8037e913cdd94e5908e6d5d55 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -21,7 +21,8 @@ pub struct InviteState {\n \n #[derive(Debug, Clone)]\n pub struct PendingSession {\n- pub agent: String,\n+ /// CLI `identity start` delegate (`uuid:rig:model`). `None` for browser `/login` and `/join`.\n+ pub agent: Option,\n pub created_ts: i64,\n pub provider: Option,\n pub provider_id: Option,\ndiff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs\nindex 6b1475b773106a2dd3f326475c9fb4cc727f6b4b..714563a8b330e3917d95a54ae29b4de143e3b8a4 100644\n--- a/server/tests/integration_ui.rs\n+++ b/server/tests/integration_ui.rs\n@@ -418,6 +418,99 @@ async fn test_web_login_carries_vote_pair_next_into_pending_session() {\n let sessions = state.pending_sessions.read().await;\n let pending = sessions.get(&session).expect(\"pending session\");\n assert_eq!(pending.redirect_next.as_deref(), Some(next));\n+ assert_eq!(\n+ pending.agent, None,\n+ \"browser /login must not invent a sentinel delegate\"\n+ );\n+}\n+\n+#[tokio::test]\n+async fn test_vote_compare_two_users_both_succeed_without_delegate() {\n+ let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;\n+ let client = reqwest::Client::new();\n+ let alice = test_bearer();\n+ let bob = seed_test_identity(&state, \"bob\", \"bobtok\", \"bobsecret\").await;\n+\n+ // Define items first (votes require existing item bodies).\n+ let seed = ui_post_ingest_rpc(\n+ \"public\",\n+ \"multi-vote\",\n+ \"~/multi-a {alpha}\\n~/multi-b {beta}\\n\",\n+ );\n+ let seed_resp = client\n+ .post(format!(\"http://{addr}/ui\"))\n+ .header(\"Authorization\", format!(\"Bearer {alice}\"))\n+ .form(&[(\"__rpc__\", seed.as_str())])\n+ .send()\n+ .await\n+ .unwrap();\n+ assert_eq!(seed_resp.status(), reqwest::StatusCode::OK);\n+ let seed_js = seed_resp.text().await.unwrap();\n+ assert!(\n+ !seed_js.contains(\"auth-error\"),\n+ \"item seed must succeed, got: {seed_js}\"\n+ );\n+\n+ for (bearer, left, right, explanation) in [\n+ (&alice, \"3\", \"1\", \"alice prefers a\"),\n+ (&bob, \"1\", \"3\", \"bob prefers b\"),\n+ ] {\n+ let rpc = ui_vote_compare_post_rpc(\n+ \"public\",\n+ \"multi-vote\",\n+ \"~/multi-a\",\n+ \"~/multi-b\",\n+ left,\n+ right,\n+ explanation,\n+ );\n+ let resp = client\n+ .post(format!(\"http://{addr}/ui\"))\n+ .header(\"Authorization\", format!(\"Bearer {bearer}\"))\n+ .form(&[(\"__rpc__\", rpc.as_str())])\n+ .send()\n+ .await\n+ .unwrap();\n+ assert_eq!(resp.status(), reqwest::StatusCode::OK);\n+ let js = resp.text().await.unwrap();\n+ assert!(\n+ !js.contains(\"delegate already bound\"),\n+ \"human vote must not hit shared-sentinel AgentBound ({explanation}), got: {js}\"\n+ );\n+ assert!(\n+ !js.contains(\"auth-error\"),\n+ \"human vote must succeed ({explanation}), got: {js}\"\n+ );\n+ assert!(\n+ js.contains(\"vote-edge-history-region\"),\n+ \"vote should morph edge history ({explanation}), got: {js}\"\n+ );\n+ }\n+\n+ let reduced = state.reduced.read().await;\n+ let human_votes: Vec<_> = reduced\n+ .ingests_ordered\n+ .iter()\n+ .filter_map(|id| reduced.ingests_by_id.get(id))\n+ .filter(|ing| ing.raw.contains(\"prefers\"))\n+ .collect();\n+ assert_eq!(human_votes.len(), 2, \"expected two vote ingests\");\n+ let mut principals: Vec<&str> = human_votes.iter().map(|i| i.principal.as_str()).collect();\n+ principals.sort();\n+ assert_eq!(principals, [\"bob\", \"testuser\"]);\n+ for ing in &human_votes {\n+ assert!(\n+ ing.delegate.is_none(),\n+ \"browser votes must have no delegate, principal={} delegate={:?}\",\n+ ing.principal,\n+ ing.delegate\n+ );\n+ }\n+ assert!(\n+ reduced.agent_bindings.is_empty(),\n+ \"human votes must not create AgentBound entries: {:?}\",\n+ reduced.agent_bindings\n+ );\n }\n \n #[tokio::test]\ndiff --git a/server/tests/support/mod.rs b/server/tests/support/mod.rs\nindex a4320e991763617c1a760efad4b621977e2b74d0..67650520ea8f9f144bfe904347c40329d5db37e8 100644\n--- a/server/tests/support/mod.rs\n+++ b/server/tests/support/mod.rs\n@@ -72,17 +72,14 @@ pub async fn rpc_batch(\n response.json().await.unwrap()\n }\n \n-pub async fn seed_test_token(state: &AppState) {\n- seed_test_identity(state, \"testuser\", \"testtok\", \"secret\").await;\n-}\n-\n-/// Add a distinct principal and bearer to a running integration-test server.\n+/// Seed a user + bearer into reducer state (not appended to the event log).\n+/// Returns the `slug__` bearer string.\n pub async fn seed_test_identity(\n state: &AppState,\n username: &str,\n token_id: &str,\n secret: &str,\n-) {\n+) -> String {\n let registered = Event::UserRegistered(UserRegistered {\n ts: 0,\n username: username.to_string(),\n@@ -102,6 +99,11 @@ pub async fn seed_test_identity(\n let mut r = state.reduced.write().await;\n r.apply_event(registered);\n r.apply_event(ev);\n+ format!(\"slug_{token_id}_{secret}\")\n+}\n+\n+pub async fn seed_test_token(state: &AppState) {\n+ let _ = seed_test_identity(state, \"testuser\", \"testtok\", \"secret\").await;\n }\n \n pub async fn create_test_server_with_state() -> (\ndiff --git a/test/oauth.clj b/test/oauth.clj\nindex 3cbf496005e906ad6c579d44ac8c4055ee60d063..6911ac34a55707acda9f9ce091c079964649a252 100644\n--- a/test/oauth.clj\n+++ b/test/oauth.clj\n@@ -186,8 +186,9 @@\n (:token poll-json))))))))\n \n (defn fetch-bearer-token!\n- \"Simulate browser OAuth + username choice; returns `slug_…` bearer token.\n- Ingest `--delegate` must match this agent string for `AgentBound` on first write.\"\n+ \"Simulate CLI identity OAuth + username choice; returns `slug_…` bearer token.\n+ Pass `:agent` (default local/dev) when the test will CLI-ingest with `--delegate`\n+ so first write can `AgentBound`. Browser UI posts use no delegate.\"\n [base-url & {:keys [username agent] :or {username \"intuser\" agent default-agent}}]\n (let [token (complete-registration! base-url :username username :agent agent)]\n (when-not (str/starts-with? token \"slug_\")\ndiff --git a/types/src/lib.rs b/types/src/lib.rs\nindex cd6f94c60a4e1c3bfbce13bc0404b803f5f57c6d..4d05466c19a2f15f7d600e1d4a206b39984fabc4 100644\n--- a/types/src/lib.rs\n+++ b/types/src/lib.rs\n@@ -538,7 +538,9 @@ pub struct PendingSessionStartResponse {\n pub struct PendingSessionPollResponse {\n pub ok: bool,\n pub complete: bool,\n- pub agent: String,\n+ /// Present for CLI identity sessions; omitted for browser `/login` / `/join` (no delegate).\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub agent: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub user: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[40b975bf] nice\n\nSide B — unified diff (full patch):\ndiff --git a/Cargo.lock b/Cargo.lock\nindex 266e876bb7ccbe788beb1d5bd53ad5b45ee5825b..2cea973082716e761ef6f5dd5886acc08ff9aac0 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -222,6 +222,12 @@ dependencies = [\n \"syn\",\n ]\n \n+[[package]]\n+name = \"dotenvy\"\n+version = \"0.15.7\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b\"\n+\n [[package]]\n name = \"encoding_rs\"\n version = \"0.8.35\"\n@@ -1238,6 +1244,7 @@ version = \"0.0.1\"\n dependencies = [\n \"axum\",\n \"axum-extra\",\n+ \"dotenvy\",\n \"maud\",\n \"reqwest\",\n \"serde\",\ndiff --git a/server/Cargo.toml b/server/Cargo.toml\nindex 4677fedcb45292eebebe7e9cf6ce2f5738f18ddf..bd600138b613bd0f546bdec217a5334cdcb20aa5 100644\n--- a/server/Cargo.toml\n+++ b/server/Cargo.toml\n@@ -17,6 +17,7 @@ tower-http = { version = \"0.5\", features = [\"trace\"] }\n tracing = \"0.1\"\n tracing-subscriber = { version = \"0.3\", features = [\"env-filter\"] }\n reqwest = { version = \"0.12\", features = [\"json\"] }\n+dotenvy = \"0.15\"\n \n [dev-dependencies]\n reqwest = { version = \"0.12\", features = [\"json\"] }\ndiff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs\nindex d2024bd4582bcc8482b461b2ba4fedbd8bff7c66..b33a84e8bb5e817b26592868d88090e6d664d950 100644\n--- a/server/src/api/ui_html.rs\n+++ b/server/src/api/ui_html.rs\n@@ -6,7 +6,7 @@ use axum::{\n use std::collections::HashMap;\n \n use crate::{\n- html::{input_panel, js_string_literal, ranking_panel, JsBuilder},\n+ html::{entity_section, input_panel, js_string_literal, ranking_panel, JsBuilder},\n parser::parse_reddit_url,\n path_types::ItemId,\n reddit::ensure_partial_tree,\n@@ -87,6 +87,20 @@ pub async fn post_ui_html(\n .into_response()\n }\n },\n+ HtmlUiAction::FetchEntity { item } => {\n+ let id = parse_item_param(&item);\n+ if id.is_root() {\n+ return ui_js_warn(\"nothing to fetch for the root\").into_response();\n+ }\n+ state.queue_entity_fetch(id.clone());\n+ let tree = state.tree.read().await;\n+ let empty = crate::reducer::NodeState::default();\n+ let node = tree.get(&id).unwrap_or(&empty);\n+ let panel = entity_section(&id, node, true);\n+ JsBuilder::new()\n+ .morph_selector(\"#entity-section\", panel)\n+ .into_response()\n+ },\n }\n }\n \ndiff --git a/server/src/events.rs b/server/src/events.rs\nindex ed5be6b13b9d46e838831d6ce0f96f569b401730..07ce24b5e56cf72b0b442c3c3241efbf6c3b006a 100644\n--- a/server/src/events.rs\n+++ b/server/src/events.rs\n@@ -1,4 +1,5 @@\n use serde::{Deserialize, Serialize};\n+use serde_json::Value;\n \n #[derive(Debug, Clone, Serialize, Deserialize)]\n #[serde(tag = \"type\", rename_all = \"snake_case\")]\n@@ -18,4 +19,10 @@ pub enum Event {\n },\n /// Register a node path in the fractal tree (no external fetch).\n NodeEnsured { id: String },\n+ /// Full upstream API payload for a node (domain-specific view derived at replay/render time).\n+ EntityImported {\n+ id: String,\n+ ts: i64,\n+ payload: Value,\n+ },\n }\ndiff --git a/server/src/html/mod.rs b/server/src/html/mod.rs\nindex df6505021d9f446c2b453e20e3eb3cf696a111f9..caf1309c8d93b47104499c57f9cc35ee7631fbb9 100644\n--- a/server/src/html/mod.rs\n+++ b/server/src/html/mod.rs\n@@ -10,6 +10,7 @@ use crate::{\n form_template::template_json_compact,\n path_types::ItemId,\n ranking::{top_bottom, RankedItem},\n+ reddit::is_fetchable,\n reducer::{GroupState, NodeState},\n state::AppState,\n ui_action::UI_RPC_FIELD,\n@@ -151,7 +152,7 @@ pub fn breadcrumb_path(item: &ItemId) -> Markup {\n fn entity_panel(node: &NodeState) -> Markup {\n html! {\n @if let Some(data) = &node.data {\n- section id=\"entity-panel\" class=\"demo-panel entity-card\" {\n+ div id=\"entity-panel\" class=\"entity-card\" {\n h2 { (data.title) }\n @if let Some(author) = &data.author {\n p class=\"muted small\" { \"by \" (author) }\n@@ -164,6 +165,42 @@ fn entity_panel(node: &NodeState) -> Markup {\n }\n }\n \n+/// Reddit/API import control — only shown on fetchable pages; never auto-fires.\n+pub fn fetch_entity_panel(item: &ItemId, has_data: bool, fetching: bool) -> Markup {\n+ if !is_fetchable(item) {\n+ return html! {};\n+ }\n+ let label = if fetching {\n+ \"Fetching…\"\n+ } else if has_data {\n+ \"Fetch more\"\n+ } else {\n+ \"Fetch from Reddit\"\n+ };\n+ let rpc = template_json_compact(&serde_json::json!({\n+ \"action\": \"fetch_entity\",\n+ \"item\": item.as_str(),\n+ }))\n+ .expect(\"fetch_entity rpc template\");\n+ html! {\n+ form method=\"post\" action=\"/ui\" id=\"fetch-entity-form\" class=\"fetch-entity-form\" {\n+ input type=\"hidden\" name=(UI_RPC_FIELD) value=(rpc);\n+ button type=\"submit\" class=\"btn-secondary\" disabled=(fetching) { (label) }\n+ }\n+ }\n+}\n+\n+/// Entity card + explicit fetch control (morphed as `#entity-section`).\n+pub fn entity_section(item: &ItemId, node: &NodeState, fetching: bool) -> Markup {\n+ let has_data = node.data.is_some();\n+ html! {\n+ section id=\"entity-section\" class=\"demo-panel\" {\n+ (entity_panel(node))\n+ (fetch_entity_panel(item, has_data, fetching))\n+ }\n+ }\n+}\n+\n fn rank_list(label: &str, items: &[RankedItem], start_rank: usize) -> Markup {\n html! {\n @if !items.is_empty() {\n@@ -260,7 +297,7 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId) -> Markup {\n h1 { \"sorter\" }\n (input_panel(\"\", None))\n (breadcrumb_path(&item))\n- (entity_panel(node))\n+ (entity_section(&item, node, false))\n (ranking_panel(&item, group))\n };\n layout(\"sorter2\", body, views)\n@@ -272,16 +309,5 @@ pub async fn home(State(state): State, uri: Uri) -> impl IntoResponse\n \n pub async fn browse(State(state): State, uri: Uri) -> impl IntoResponse {\n let item = ItemId::from_browse_uri(uri.path()).unwrap_or(ItemId::root());\n- if item.as_str().starts_with(\"reddit.com\") {\n- let needs_fetch = {\n- let tree = state.tree.read().await;\n- tree.get(&item)\n- .map(|n| n.data.is_none())\n- .unwrap_or(true)\n- };\n- if needs_fetch {\n- state.reddit.request_fetch(item.clone());\n- }\n- }\n item_page(state, uri, item).await\n }\ndiff --git a/server/src/main.rs b/server/src/main.rs\nindex c22ec6c9f5358e5ec99fb83210dc351938505a93..1f0cddc39302b35b0cd6a6219f44c9d59202facf 100644\n--- a/server/src/main.rs\n+++ b/server/src/main.rs\n@@ -2,6 +2,10 @@ use sorter2_server::state::AppConfig;\n \n #[tokio::main]\n async fn main() -> Result<(), Box> {\n+ if std::env::var(\"SORTER2_SKIP_DOTENV\").is_err() {\n+ let _ = dotenvy::dotenv();\n+ }\n+\n tracing_subscriber::fmt()\n .with_env_filter(\n tracing_subscriber::EnvFilter::try_from_default_env()\ndiff --git a/server/src/reddit.rs b/server/src/reddit.rs\nindex 90053ad03b1d7c8e94f325dd4ee64c2b4f7da900..ff0f01e57b18af878eb5be3efc47204a7673589d 100644\n--- a/server/src/reddit.rs\n+++ b/server/src/reddit.rs\n@@ -6,11 +6,15 @@ use std::time::{Duration, Instant};\n \n use reqwest::{header, Client, StatusCode};\n use serde::Deserialize;\n+use serde_json::Value;\n use tokio::sync::{mpsc, RwLock};\n \n use crate::{\n+ event_log::EventLog,\n+ events::Event,\n+ html::now_ms,\n path_types::ItemId,\n- reducer::{EntityData, GlobalTree},\n+ reducer::GlobalTree,\n };\n \n /// Bootstrap blank nodes along a URL path so breadcrumbs and voting work before fetch.\n@@ -20,6 +24,8 @@ pub fn ensure_partial_tree(tree: &mut GlobalTree, id: &ItemId) {\n \n pub struct RedditCommand {\n pub id: ItemId,\n+ /// User-initiated fetch bypasses the in-memory \"recently fetched\" cache.\n+ pub force: bool,\n }\n \n #[derive(Clone)]\n@@ -33,19 +39,31 @@ struct RedditCredentials {\n client_secret: String,\n }\n \n+#[derive(Clone)]\n+pub struct RedditApiConfig {\n+ pub api_base: String,\n+ pub oauth_base: String,\n+ pub user_agent: String,\n+ creds: Option,\n+}\n+\n struct OAuthToken {\n access_token: String,\n expires_at: Instant,\n }\n \n impl RedditBroker {\n- pub fn spawn(tree: Arc>, user_agent: &str) -> Self {\n+ pub fn spawn(\n+ tree: Arc>,\n+ event_log: Arc,\n+ config: RedditApiConfig,\n+ ) -> Self {\n let (tx, rx) = mpsc::channel(100);\n \n let mut headers = header::HeaderMap::new();\n headers.insert(\n header::USER_AGENT,\n- header::HeaderValue::from_str(user_agent).expect(\"valid user agent\"),\n+ header::HeaderValue::from_str(&config.user_agent).expect(\"valid user agent\"),\n );\n \n let client = Client::builder()\n@@ -54,22 +72,38 @@ impl RedditBroker {\n .build()\n .expect(\"reqwest client\");\n \n- let creds = RedditCredentials::from_env();\n- tokio::spawn(reddit_worker(rx, tree, client, creds));\n+ tokio::spawn(reddit_worker(rx, tree, event_log, client, config));\n \n Self { tx }\n }\n \n- /// Fire-and-forget: queue a fetch; worker updates the tree when done.\n- pub fn request_fetch(&self, id: ItemId) {\n- let _ = self.tx.try_send(RedditCommand { id });\n+ /// Queue a fetch; drops when the channel is full (backpressure).\n+ pub fn request_fetch(&self, id: ItemId, force: bool) {\n+ let _ = self.tx.try_send(RedditCommand { id, force });\n+ }\n+}\n+\n+impl RedditApiConfig {\n+ pub fn from_env() -> Self {\n+ Self {\n+ api_base: reddit_api_base(),\n+ oauth_base: reddit_oauth_base(),\n+ user_agent: default_user_agent(),\n+ creds: RedditCredentials::from_env(),\n+ }\n }\n }\n \n impl RedditCredentials {\n+ /// Reddit's OAuth docs call these \"client id\" and \"client secret\"; the app\n+ /// registration UI often labels them \"app id\" / \"app secret\" — same values.\n fn from_env() -> Option {\n- let client_id = std::env::var(\"REDDIT_CLIENT_ID\").ok()?;\n- let client_secret = std::env::var(\"REDDIT_CLIENT_SECRET\").ok()?;\n+ let client_id = std::env::var(\"REDDIT_CLIENT_ID\")\n+ .or_else(|_| std::env::var(\"REDDIT_APP_ID\"))\n+ .ok()?;\n+ let client_secret = std::env::var(\"REDDIT_CLIENT_SECRET\")\n+ .or_else(|_| std::env::var(\"REDDIT_APP_SECRET\"))\n+ .ok()?;\n if client_id.is_empty() || client_secret.is_empty() {\n return None;\n }\n@@ -80,29 +114,63 @@ impl RedditCredentials {\n }\n }\n \n+pub fn reddit_api_base() -> String {\n+ std::env::var(\"REDDIT_API_BASE\").unwrap_or_else(|_| \"https://www.reddit.com\".into())\n+}\n+\n+pub fn reddit_oauth_base() -> String {\n+ std::env::var(\"REDDIT_OAUTH_BASE\").unwrap_or_else(|_| \"https://www.reddit.com\".into())\n+}\n+\n pub fn default_user_agent() -> String {\n std::env::var(\"REDDIT_USER_AGENT\").unwrap_or_else(|_| {\n \"web:sorter2.social:v0.0.1 (by /u/sorter2)\".to_string()\n })\n }\n \n+/// True when this node can be loaded from the Reddit JSON API.\n+pub fn is_fetchable(id: &ItemId) -> bool {\n+ !map_item_to_reddit_api(id, \"https://example.com\").is_empty()\n+}\n+\n+/// Derive UI-facing fields from a stored payload (Reddit-specific when under reddit.com).\n+pub fn entity_view_from_payload(id: &ItemId, payload: &Value) -> Option {\n+ if id.as_str().starts_with(\"reddit.com\") {\n+ return parse_reddit_view(id, payload);\n+ }\n+ None\n+}\n+\n+/// Apply a full API payload to the in-memory tree (view derived for known domains).\n+pub fn apply_entity_import(tree: &mut GlobalTree, id: &ItemId, payload: Value) {\n+ let view = entity_view_from_payload(id, &payload);\n+ tree.apply_entity_raw(id, payload, view);\n+}\n+\n async fn reddit_worker(\n mut rx: mpsc::Receiver,\n tree: Arc>,\n+ event_log: Arc,\n client: Client,\n- creds: Option,\n+ config: RedditApiConfig,\n ) {\n let mut in_flight = HashSet::new();\n let mut recently_fetched: HashMap = HashMap::new();\n let mut current_delay = Duration::from_secs(1);\n let mut oauth: Option = None;\n let cache_ttl = Duration::from_secs(300);\n+ let creds = config.creds.clone();\n+ let api_base = config.api_base.clone();\n+ let oauth_base = config.oauth_base.clone();\n \n while let Some(cmd) = rx.recv().await {\n let now = Instant::now();\n recently_fetched.retain(|_, t| now.duration_since(*t) < cache_ttl);\n \n- if in_flight.contains(&cmd.id) || recently_fetched.contains_key(&cmd.id) {\n+ if in_flight.contains(&cmd.id) {\n+ continue;\n+ }\n+ if !cmd.force && recently_fetched.contains_key(&cmd.id) {\n continue;\n }\n \n@@ -112,18 +180,26 @@ async fn reddit_worker(\n tokio::time::sleep(current_delay).await;\n \n if let Some(c) = &creds {\n- oauth = ensure_oauth_token(&client, c, oauth.take()).await;\n+ oauth = ensure_oauth_token(&client, &oauth_base, c, oauth.take()).await;\n }\n \n let token = oauth.as_ref().map(|t| t.access_token.as_str());\n- let use_oauth = token.is_some();\n \n- match do_fetch(&client, &fetch_id, use_oauth, token).await {\n- Ok(FetchOutcome::Entity(data)) => {\n- let mut w = tree.write().await;\n- w.set_entity_data(&fetch_id, data);\n- recently_fetched.insert(fetch_id.clone(), Instant::now());\n- current_delay = Duration::from_millis(600);\n+ match do_fetch(&client, &api_base, &fetch_id, token).await {\n+ Ok(FetchOutcome::Payload(payload)) => {\n+ let ts = now_ms();\n+ let event = Event::EntityImported {\n+ id: fetch_id.as_str().to_string(),\n+ ts,\n+ payload: payload.clone(),\n+ };\n+ if let Err(e) = event_log.append(&event).await {\n+ tracing::warn!(\"event log append failed for {}: {}\", fetch_id, e);\n+ } else {\n+ apply_entity_import(&mut *tree.write().await, &fetch_id, payload);\n+ recently_fetched.insert(fetch_id.clone(), Instant::now());\n+ current_delay = Duration::from_millis(600);\n+ }\n }\n Ok(FetchOutcome::NotFound) => {\n recently_fetched.insert(fetch_id.clone(), Instant::now());\n@@ -149,13 +225,14 @@ async fn reddit_worker(\n }\n \n enum FetchOutcome {\n- Entity(EntityData),\n+ Payload(Value),\n NotFound,\n RateLimited { reset_secs: u64 },\n }\n \n async fn ensure_oauth_token(\n client: &Client,\n+ oauth_base: &str,\n creds: &RedditCredentials,\n existing: Option,\n ) -> Option {\n@@ -165,8 +242,13 @@ async fn ensure_oauth_token(\n }\n }\n \n+ let url = format!(\n+ \"{}/api/v1/access_token\",\n+ oauth_base.trim_end_matches('/')\n+ );\n+\n let resp = client\n- .post(\"https://www.reddit.com/api/v1/access_token\")\n+ .post(&url)\n .basic_auth(&creds.client_id, Some(&creds.client_secret))\n .form(&[(\"grant_type\", \"client_credentials\")])\n .send()\n@@ -207,11 +289,11 @@ async fn ensure_oauth_token(\n \n async fn do_fetch(\n client: &Client,\n+ api_base: &str,\n id: &ItemId,\n- use_oauth: bool,\n bearer: Option<&str>,\n ) -> Result {\n- let url = map_item_to_reddit_api(id, use_oauth);\n+ let url = map_item_to_reddit_api(id, api_base);\n if url.is_empty() {\n return Ok(FetchOutcome::NotFound);\n }\n@@ -241,10 +323,8 @@ async fn do_fetch(\n return Ok(FetchOutcome::RateLimited { reset_secs: reset });\n }\n \n- let bytes = resp.bytes().await.map_err(|e| e.to_string())?;\n- Ok(parse_reddit_json(id, &bytes)\n- .map(FetchOutcome::Entity)\n- .unwrap_or(FetchOutcome::NotFound))\n+ let payload: Value = resp.json().await.map_err(|e| e.to_string())?;\n+ Ok(FetchOutcome::Payload(payload))\n }\n \n fn rate_limit_remaining(resp: &reqwest::Response) -> Option {\n@@ -264,18 +344,14 @@ fn rate_limit_reset_secs(resp: &reqwest::Response) -> u64 {\n .unwrap_or(5)\n }\n \n-/// Map canonical item id to Reddit JSON API URL.\n-pub fn map_item_to_reddit_api(id: &ItemId, oauth: bool) -> String {\n+/// Map canonical item id to a Reddit JSON API URL under `api_base`.\n+pub fn map_item_to_reddit_api(id: &ItemId, api_base: &str) -> String {\n let path = id.as_str();\n if !path.starts_with(\"reddit.com/\") && path != \"reddit.com\" {\n return String::new();\n }\n \n- let base = if oauth {\n- \"https://oauth.reddit.com\"\n- } else {\n- \"https://www.reddit.com\"\n- };\n+ let base = api_base.trim_end_matches('/');\n \n let segments: Vec<&str> = path.split('/').collect();\n \n@@ -293,18 +369,17 @@ pub fn map_item_to_reddit_api(id: &ItemId, oauth: bool) -> String {\n String::new()\n }\n \n-fn parse_reddit_json(id: &ItemId, bytes: &[u8]) -> Option {\n- let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;\n+fn parse_reddit_view(id: &ItemId, v: &Value) -> Option {\n let segments: Vec<&str> = id.as_str().split('/').collect();\n \n if segments.iter().any(|&p| p == \"comments\") {\n- parse_post_listing(&v)\n+ parse_post_listing(v)\n } else {\n- parse_subreddit_about(&v)\n+ parse_subreddit_about(v)\n }\n }\n \n-fn parse_subreddit_about(v: &serde_json::Value) -> Option {\n+fn parse_subreddit_about(v: &Value) -> Option {\n let data = v.get(\"data\")?;\n let title = data\n .get(\"title\")\n@@ -323,7 +398,7 @@ fn parse_subreddit_about(v: &serde_json::Value) -> Option {\n .filter(|s| !s.is_empty())\n .map(|s| s.to_string());\n \n- Some(EntityData {\n+ Some(crate::reducer::EntityData {\n title,\n author: None,\n body_html,\n@@ -331,10 +406,9 @@ fn parse_subreddit_about(v: &serde_json::Value) -> Option {\n })\n }\n \n-fn parse_post_listing(v: &serde_json::Value) -> Option {\n+fn parse_post_listing(v: &Value) -> Option {\n let listing = v.as_array()?.first()?;\n- let child = listing\n- .pointer(\"/data/children/0/data\")?;\n+ let child = listing.pointer(\"/data/children/0/data\")?;\n let title = child.get(\"title\")?.as_str()?.to_string();\n let author = child\n .get(\"author\")\n@@ -352,7 +426,7 @@ fn parse_post_listing(v: &serde_json::Value) -> Option {\n .filter(|s| s.starts_with(\"http\"))\n .map(|s| s.to_string());\n \n- Some(EntityData {\n+ Some(crate::reducer::EntityData {\n title,\n author,\n body_html,\n@@ -368,48 +442,51 @@ mod tests {\n fn map_subreddit_about_url() {\n let id = ItemId::parse(\"reddit.com/r/rust\").unwrap();\n assert_eq!(\n- map_item_to_reddit_api(&id, false),\n+ map_item_to_reddit_api(&id, \"https://www.reddit.com\"),\n \"https://www.reddit.com/r/rust/about.json?raw_json=1\"\n );\n assert_eq!(\n- map_item_to_reddit_api(&id, true),\n- \"https://oauth.reddit.com/r/rust/about.json?raw_json=1\"\n+ map_item_to_reddit_api(&id, \"http://127.0.0.1:9999\"),\n+ \"http://127.0.0.1:9999/r/rust/about.json?raw_json=1\"\n );\n }\n \n #[test]\n fn map_post_url() {\n- let id =\n- ItemId::parse(\"reddit.com/r/amitheasshole/comments/1trnvdl\").unwrap();\n+ let id = ItemId::parse(\"reddit.com/r/amitheasshole/comments/1trnvdl\").unwrap();\n assert_eq!(\n- map_item_to_reddit_api(&id, false),\n+ map_item_to_reddit_api(&id, \"https://www.reddit.com\"),\n \"https://www.reddit.com/r/amitheasshole/comments/1trnvdl.json?raw_json=1\"\n );\n }\n \n #[test]\n- fn map_non_reddit_empty() {\n- let id = ItemId::opaque(\"example.com/foo\");\n- assert!(map_item_to_reddit_api(&id, false).is_empty());\n+ fn is_fetchable_reddit_sub() {\n+ let id = ItemId::parse(\"reddit.com/r/rust\").unwrap();\n+ assert!(is_fetchable(&id));\n+ assert!(!is_fetchable(&ItemId::opaque(\"example.com/x\")));\n }\n \n #[test]\n fn parse_subreddit_fixture() {\n- let json = r#\"{\"kind\":\"t5\",\"data\":{\"title\":\"Rust\",\"display_name\":\"rust\",\"public_description\":\"systems\"}}\"#;\n- let entity = parse_reddit_json(\n+ let json = include_str!(\"../../test/fixtures/reddit/r_rust_about.json\");\n+ let v: Value = serde_json::from_str(json).unwrap();\n+ let entity = entity_view_from_payload(\n &ItemId::parse(\"reddit.com/r/rust\").unwrap(),\n- json.as_bytes(),\n+ &v,\n )\n .unwrap();\n- assert_eq!(entity.title, \"Rust\");\n+ assert_eq!(entity.title, \"The Rust Programming Language\");\n+ assert!(entity.body_html.as_ref().is_some_and(|b| b.contains(\"Rust\")));\n }\n \n #[test]\n fn parse_post_fixture() {\n let json = r#\"[{\"kind\":\"Listing\",\"data\":{\"children\":[{\"kind\":\"t3\",\"data\":{\"title\":\"AITA\",\"author\":\"op\",\"selftext_html\":\"<p>hi</p>\",\"thumbnail\":\"https://b.thumbs.redditmedia.com/x.jpg\"}}]}}]\"#;\n- let entity = parse_reddit_json(\n+ let v: Value = serde_json::from_str(json).unwrap();\n+ let entity = entity_view_from_payload(\n &ItemId::parse(\"reddit.com/r/x/comments/abc\").unwrap(),\n- json.as_bytes(),\n+ &v,\n )\n .unwrap();\n assert_eq!(entity.title, \"AITA\");\ndiff --git a/server/src/reducer.rs b/server/src/reducer.rs\nindex 077f700bf00ddefe18ffd004bb5288bdc7c4adaf..60db81a562e3590775012573225a76ba82a14563 100644\n--- a/server/src/reducer.rs\n+++ b/server/src/reducer.rs\n@@ -1,6 +1,7 @@\n use std::collections::{HashMap, HashSet, VecDeque};\n \n use serde::{Deserialize, Serialize};\n+use serde_json::Value;\n \n use crate::path_types::ItemId;\n \n@@ -128,6 +129,9 @@ pub struct EntityData {\n #[derive(Debug, Clone, Default)]\n pub struct NodeState {\n pub id: ItemId,\n+ /// Full imported API JSON (persisted in the event log).\n+ pub entity_raw: Option,\n+ /// Domain-specific view derived from `entity_raw` (e.g. Reddit title/author).\n pub data: Option,\n pub children: HashSet,\n pub local_ranking: GroupState,\n@@ -197,10 +201,11 @@ impl GlobalTree {\n }\n }\n \n- pub fn set_entity_data(&mut self, id: &ItemId, data: EntityData) {\n+ pub fn apply_entity_raw(&mut self, id: &ItemId, payload: Value, view: Option) {\n self.ensure_path(id);\n if let Some(node) = self.nodes.get_mut(id) {\n- node.data = Some(data);\n+ node.entity_raw = Some(payload);\n+ node.data = view;\n }\n }\n }\ndiff --git a/server/src/state.rs b/server/src/state.rs\nindex 8c03aa60c15aee803a534439400b69935b1a3d84..d71d1079a8f486cdab15795384aef0b81b32544d 100644\n--- a/server/src/state.rs\n+++ b/server/src/state.rs\n@@ -7,7 +7,7 @@ use crate::{\n events::Event,\n journal::JournalClient,\n path_types::ItemId,\n- reddit::{default_user_agent, RedditBroker},\n+ reddit::{apply_entity_import, RedditApiConfig, RedditBroker},\n reducer::{GlobalTree, VoteData},\n views::ViewStore,\n };\n@@ -108,13 +108,18 @@ impl AppState {\n tree.ensure_path(&parsed);\n }\n }\n+ Event::EntityImported { id, payload, .. } => {\n+ if let Some(parsed) = ItemId::parse(&id).or_else(|| ItemId::from_url(&id)) {\n+ apply_entity_import(&mut tree, &parsed, payload);\n+ }\n+ }\n }\n }\n }\n \n let tree = Arc::new(RwLock::new(tree));\n let journal = JournalClient::spawn(tree.clone(), event_log.clone());\n- let reddit = RedditBroker::spawn(tree.clone(), &default_user_agent());\n+ let reddit = RedditBroker::spawn(tree.clone(), event_log.clone(), RedditApiConfig::from_env());\n \n Self {\n cfg: Arc::new(cfg),\n@@ -135,10 +140,14 @@ impl AppState {\n let mut w = self.tree.write().await;\n w.ensure_path(id);\n }\n- self.reddit.request_fetch(id.clone());\n Ok(())\n }\n \n+ /// User-initiated Reddit/API import (via \"Fetch more\" — never on paste or navigate).\n+ pub fn queue_entity_fetch(&self, id: ItemId) {\n+ self.reddit.request_fetch(id, true);\n+ }\n+\n pub async fn record_vote(\n &self,\n parent: &ItemId,\n@@ -169,6 +178,44 @@ impl AppState {\n #[cfg(test)]\n mod tests {\n use super::{normalize_scope, parse_item_param};\n+ use crate::{\n+ event_log::EventLog,\n+ events::Event,\n+ path_types::ItemId,\n+ reddit::apply_entity_import,\n+ reducer::GlobalTree,\n+ };\n+ use serde_json::json;\n+\n+ #[tokio::test]\n+ async fn replay_entity_imported_restores_view() {\n+ let tmp = tempfile::tempdir().unwrap();\n+ let log_path = tmp.path().join(\"events.jsonl\");\n+ let log = EventLog::new(log_path.to_string_lossy().into_owned());\n+ let payload = json!({\"kind\":\"t5\",\"data\":{\"title\":\"Rust\",\"display_name\":\"rust\"}});\n+ log.append(&Event::EntityImported {\n+ id: \"reddit.com/r/rust\".into(),\n+ ts: 1,\n+ payload: payload.clone(),\n+ })\n+ .await\n+ .unwrap();\n+\n+ let mut tree = GlobalTree::new();\n+ let (events, _) = log.load_all().await.unwrap();\n+ for ev in events {\n+ if let Event::EntityImported { id, payload, .. } = ev {\n+ let parsed = ItemId::parse(&id).unwrap();\n+ apply_entity_import(&mut tree, &parsed, payload);\n+ }\n+ }\n+ let node = tree.get(&ItemId::parse(\"reddit.com/r/rust\").unwrap()).unwrap();\n+ assert_eq!(node.data.as_ref().unwrap().title, \"Rust\");\n+ assert_eq!(\n+ node.entity_raw.as_ref().unwrap()[\"data\"][\"display_name\"],\n+ \"rust\"\n+ );\n+ }\n \n #[test]\n fn normalize_scope_strips_prefix_and_lowercases() {\ndiff --git a/server/src/ui_action.rs b/server/src/ui_action.rs\nindex d798874d1d94c0dfee59ec1ff703f9ee6ef432c0..3d6a49a2a3fb950752827efe1f5a049308f09baf 100644\n--- a/server/src/ui_action.rs\n+++ b/server/src/ui_action.rs\n@@ -26,6 +26,10 @@ pub enum HtmlUiAction {\n ParseQuery {\n query: String,\n },\n+ /// Fetch upstream entity data for the current page (explicit user action only).\n+ FetchEntity {\n+ item: String,\n+ },\n }\n \n #[derive(Debug, Error)]\ndiff --git a/server/static/sorter.css b/server/static/sorter.css\nindex 36f68d415cea33c8562d5d02c0c9d4c5b225a782..1f9f5158414902d43a380dd899232c70d8cf5d63 100644\n--- a/server/static/sorter.css\n+++ b/server/static/sorter.css\n@@ -52,6 +52,25 @@ body {\n filter: brightness(1.1);\n }\n \n+.btn-secondary {\n+ background: transparent;\n+ color: var(--accent);\n+ border: 1px solid var(--border);\n+ padding: 0.4rem 0.85rem;\n+ font-size: 0.9rem;\n+ cursor: pointer;\n+ margin-top: 0.75rem;\n+}\n+\n+.btn-secondary:disabled {\n+ opacity: 0.6;\n+ cursor: wait;\n+}\n+\n+.fetch-entity-form {\n+ margin-top: 0.5rem;\n+}\n+\n code {\n font-size: 0.85em;\n background: var(--bg);\ndiff --git a/test/fixtures/reddit/r_rust_about.json b/test/fixtures/reddit/r_rust_about.json\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..bb7fd74ab00d63356d9b672a775996ed155b984d\n--- /dev/null\n+++ b/test/fixtures/reddit/r_rust_about.json\n@@ -0,0 +1,21 @@\n+{\n+ \"kind\": \"t5\",\n+ \"data\": {\n+ \"user_flair_background_color\": null,\n+ \"submit_text_html\": null,\n+ \"banner_img\": \"\",\n+ \"user_is_banned\": null,\n+ \"wiki_enabled\": true,\n+ \"show_media\": true,\n+ \"id\": \"2qh0i\",\n+ \"display_name\": \"rust\",\n+ \"title\": \"The Rust Programming Language\",\n+ \"public_description\": \"A place for all things related to the Rust programming language.\",\n+ \"public_description_html\": \"<!-- SC_OFF --><div class=\\\"md\\\"><p>A place for all things related to the Rust programming language.</p>\\n</div><!-- SC_ON -->\",\n+ \"icon_img\": \"https://styles.redditmedia.com/t5_2qh0i/styles/communityIcon_kfqpknb2j6j51.png\",\n+ \"community_icon\": \"https://styles.redditmedia.com/t5_2qh0i/styles/communityIcon_kfqpknb2j6j51.png\",\n+ \"subscribers\": 350000,\n+ \"active_user_count\": 1200,\n+ \"over18\": false\n+ }\n+}\ndiff --git a/test/reddit_import.clj b/test/reddit_import.clj\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..54edaedeef08718c8f184d6aa468d8e6415068c7\n--- /dev/null\n+++ b/test/reddit_import.clj\n@@ -0,0 +1,116 @@\n+(ns test.reddit-import\n+ (:require [babashka.process :as process]\n+ [clojure.java.io :as io]\n+ [clojure.string :as str]\n+ [clojure.test :refer [deftest is testing]])\n+ (:import [com.sun.net.httpserver HttpServer HttpHandler HttpExchange]\n+ [java.net InetSocketAddress]))\n+\n+(defn- repo-root []\n+ (.getCanonicalPath (io/file (System/getProperty \"user.dir\"))))\n+\n+(defn- pick-port []\n+ (with-open [s (java.net.ServerSocket. 0)]\n+ (.getLocalPort s)))\n+\n+(defn- start-mock-reddit [port fixtures-dir]\n+ (let [fixture (io/file fixtures-dir \"r_rust_about.json\")\n+ body (.getBytes (slurp fixture) \"UTF-8\")\n+ server (HttpServer/create (InetSocketAddress. \"127.0.0.1\" port) 0)\n+ handler\n+ (proxy [HttpHandler] []\n+ (handle [^HttpExchange exchange]\n+ (.sendResponseHeaders exchange 200 (alength body))\n+ (let [out (.getResponseBody exchange)]\n+ (.write out body)\n+ (.close out))))]\n+ (.createContext server \"/\" handler)\n+ (.setExecutor server nil)\n+ (.start server)\n+ (fn stop []\n+ (.stop server 0))))\n+\n+(defn- wait-health [base-url ms]\n+ (let [deadline (+ (System/currentTimeMillis) ms)\n+ url (str base-url \"/healthz\")]\n+ (loop []\n+ (let [resp (try\n+ (process/shell {:out :string :err :string}\n+ \"curl\" \"-sf\" url)\n+ (catch Exception _ nil))]\n+ (if (and resp (zero? (:exit resp)) (= \"ok\" (str/trim (:out resp \"\"))))\n+ true\n+ (if (< (System/currentTimeMillis) deadline)\n+ (do (Thread/sleep 200) (recur))\n+ false))))))\n+\n+(defn- curl-post-ui [base rpc-json]\n+ (process/shell {:out :string :err :string}\n+ \"curl\" \"-sf\" \"-X\" \"POST\" (str base \"/ui\")\n+ \"--data-urlencode\" (str \"__rpc__=\" rpc-json)))\n+\n+(defn- wait-event-log [path ms]\n+ (let [deadline (+ (System/currentTimeMillis) ms)]\n+ (loop []\n+ (if (.exists (io/file path))\n+ true\n+ (if (< (System/currentTimeMillis) deadline)\n+ (do (Thread/sleep 200) (recur))\n+ false)))))\n+\n+(deftest reddit-fetch-via-mock-api\n+ (testing \"Fetch more queues import; event log stores full payload; page shows title\"\n+ (let [root (repo-root)\n+ fixtures (str root \"/test/fixtures/reddit\")\n+ data-dir (.getAbsolutePath\n+ (doto (io/file (System/getProperty \"java.io.tmpdir\")\n+ (str \"sorter2-reddit-\" (System/currentTimeMillis)))\n+ (.mkdirs)))\n+ reddit-port (pick-port)\n+ app-port (pick-port)\n+ reddit-base (str \"http://127.0.0.1:\" reddit-port)\n+ app-base (str \"http://127.0.0.1:\" app-port)\n+ bin (str root \"/target/release/sorter2-server\")\n+ stop-mock (start-mock-reddit reddit-port fixtures)]\n+ (try\n+ (is (zero? (:exit (process/shell {:dir root}\n+ \"cargo\" \"build\" \"--release\" \"--package\" \"sorter2-server\")))\n+ \"release build succeeds\")\n+ (let [proc (process/process {:dir root\n+ :env (into (into {} (System/getenv))\n+ {\"SORTER2_SKIP_DOTENV\" \"1\"\n+ \"SORTER2_DATA_DIR\" data-dir\n+ \"SORTER2_EVENT_LOG\" (str data-dir \"/events.jsonl\")\n+ \"PORT\" (str app-port)\n+ \"REDDIT_API_BASE\" reddit-base\n+ \"REDDIT_OAUTH_BASE\" reddit-base\n+ \"REDDIT_CLIENT_ID\" \"\"\n+ \"REDDIT_CLIENT_SECRET\" \"\"\n+ \"REDDIT_APP_ID\" \"\"\n+ \"REDDIT_APP_SECRET\" \"\"})\n+ :out :string\n+ :err :string}\n+ bin)]\n+ (try\n+ (is (wait-health app-base 20000) \"app healthz\")\n+ (let [browse-url (str app-base \"/~/https://reddit.com/r/rust\")\n+ before (:out (process/shell {:out :string :err :string}\n+ \"curl\" \"-sf\" browse-url))]\n+ (is (str/includes? before \"Fetch from Reddit\"))\n+ (is (not (str/includes? before \"The Rust Programming Language\")))\n+ (let [rpc \"{\\\"action\\\":\\\"fetch_entity\\\",\\\"item\\\":\\\"reddit.com/r/rust\\\"}\"\n+ post (curl-post-ui app-base rpc)\n+ log-path (str data-dir \"/events.jsonl\")]\n+ (is (zero? (:exit post)) \"fetch_entity POST succeeds\")\n+ (is (wait-event-log log-path 10000) \"event log written\")\n+ (let [after (:out (process/shell {:out :string :err :string}\n+ \"curl\" \"-sf\" browse-url))\n+ log (slurp (io/file log-path))]\n+ (is (str/includes? after \"The Rust Programming Language\"))\n+ (is (str/includes? log \"\\\"type\\\":\\\"entity_imported\\\"\"))\n+ (is (str/includes? log \"\\\"subscribers\\\":350000\"))\n+ (is (str/includes? log \"\\\"display_name\\\":\\\"rust\\\"\")))))\n+ (finally\n+ (process/destroy proc))))\n+ (finally\n+ (stop-mock))))))\n","role":"user"}],"model":"~x-ai/grok-latest"}