constitution · epochs · watch · epoch 3

comparison

c_25ece8f92dc8 (tommy-mor) vs c_c124c217f89c (tommy-mor)

download prompt · raw event · cmp_760d5406834dce

council reasoning

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

Side A fixes a genuine multi-user bug (shared sentinel delegate blocking all-but-first browser voter) with a minimal, well-tested schema/type change (Option<String> agent) plus a substantial new integration test proving the fix; Side B adds a large speculative Reddit-fetch worker feature with no tests exercised against real behavior beyond unit URL-mapping tests, and includes a stray 'todo' file and unrelated .env ignore, making it more exploratory/incomplete than a solid bugfix. Side A's change is smaller, precise, and directly resolves a concrete correctness issue affecting real usage, giving it more lasting value despite Side B's larger code volume.

~x-ai/grok-latest · winner A · 3:2 · permalink

A fixes a real multi-user correctness bug by removing shared browser sentinel delegates (WEB_BROWSER_AGENT/INVITE_BROWSER_AGENT), making PendingSession.agent optional end-to-end, and proving two humans can vote without AgentBound collisions via solid integration tests. B adds substantial Reddit fetch infrastructure (broker, OAuth, rate-limit backoff, JSON mapping), but it is still an incomplete integration surface (plus todo/.gitignore noise) rather than a core multi-user correctness repair.

openai/gpt-chat-latest · winner A · 5:1 · permalink

Side A fixes a concrete correctness bug by removing shared browser sentinel delegates, changing pending sessions to carry an optional delegate only for CLI flows, updating UI posts to use no delegate, and adding integration tests proving two browser users can vote independently without creating erroneous AgentBound entries. Side B adds a large Reddit import framework with background fetching, OAuth, and parsing, but it is a new feature with substantial unproven complexity and no evidence of fixing an existing project-wide correctness issue comparable to A's tested bugfix.

sides

A — c_25ece8f92dc8 (tommy-mor)

message

[b7626603] Remove browser sentinel delegates so multi-user votes work.

Shared WEB_BROWSER_AGENT bound on first vote and blocked every later human; browser posts now use no delegate, matching forum UI.

Co-authored-by: Cursor <cursoragent@cursor.com>

diff preview

diff --git a/cli/src/main.rs b/cli/src/main.rs
index 70435b412188a151c5e89e842a5de57f7480ddf2..a4a22724fc9e27879951f520a9241d5832982a70 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -1645,8 +1645,8 @@ async fn run() -> Result<()> {
                     tokio::time::sleep(std::time::Duration::from_millis(poll_interval_ms)).await;
                     let poll: PendingSessionPollResponse =
                         expect_json(client.get(&poll_url).send().await?).await?;
-                    if !poll.agent.trim().is_empty() {
-                        agent_out = Some(poll.agent.clone());
+                    if let Some(a) = poll.agent.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
+                        agent_out = Some(a.to_string());
                     }
                     if poll.complete {
                         token_out = poll.token;
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index bfddcce4dc1a77571d297648dd840e6c5bc194f8..01c02f50c19bcd62c7f1717f9b927f203c3164d0 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -29,12 +29,6 @@ use crate::{
     write_cmd::WriteCmd,
 };
 
-/// Delegate id for browser users who land via `/join/inv_…` (no CLI agent).
-const INVITE_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000000:invite:web/join";
-
-/// Agent id for `/login` browser OAuth (no CLI); must pass [`parse_agent`].
-pub const WEB_BROWSER_AGENT: &str = "00000000-0000-0000-0000-000000000001:social:web/browser";
-
 /// HttpOnly cookie storing the same `slug_*` bearer string the CLI uses.
 pub const SLUG_SESSION_COOKIE: &str = "slug_session";
 
@@ -282,7 +276,7 @@ pub async fn get_join_invite(
     let session = format!("p_{}", uuid::Uuid::new_v4().simple());
     let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()));
     let s = PendingSession {
-        agent: INVITE_BROWSER_AGENT.to_string(),
+        agent: None,
         created_ts: now_ms(),
         provider: None,
         provider_id: None,
@@ -549,7 +543,7 @@ pub async fn post_choose_username(
     };
 
     let sessions = pending_sessions(&state);
-    let (provider, provider_id, agent) = {
+    let (provider, provider_id) = {
         let sessions_read = sessions.read().await;
         let Some(s) = sessions_read.get(&form.session) else {
             return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response();
@@ -560,14 +554,9 @@ pub async fn post_choose_username(
         let Some(provider_id) = s.provider_id.clone() else {
             return js_form_error_fragment(&form.session, "oauth not completed").into_response();
         };
-        (provider, provider_id, s.agent.clone())
+        (provider, provider_id)
     };
 
-    if let Err(msg) = parse_agent(&agent) {
-        return js_form_error_fragment(&form.session, &format!("invalid agent format — {msg}"))
-            .into_response();
-    }
-
     let redeem_invite = {
         let sessions_read = sessions.read().await;
         sessions_read
@@ -637,7 +626,8 @@ pub async fn get_web_login(
     let redirect_next = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref()))
         .or_else(|| Some("/".to_string()));
     let s = PendingSession {
-        agent: WEB_BROWSER_AGENT.to_string(),
+        // Humans sign in via the website with no AI delegate.
+        agent: None,
         created_ts: now_ms(),
         provider: None,
         provider_id: None,
@@ -698,7 +688,7 @@ pub async fn post_pending_session(
     );
     let poll_url = format!("/api/v0/pending-session/{session}");
     let s = PendingSession {
-        agent: agent_naked,
+        agent: Some(agent_naked),
         created_ts: now_ms(),
         provider: None,
         provider_id: None,
diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs
index 578e5844e86f7c2666c8e37ef9fa90c01d20d134..57a0139928b09034122398f8f3362cbdbcc5862e 100644
--- a/server/src/api/ui_html.rs
+++ b/server/src/api/ui_html.rs
@@ -276,7 +276,7 @@ async fn dispatch_ui_action(
                 &session.bearer,
                 room.clone(),
                 thread_tag.clone(),
-                Some(crate::api::auth::WEB_BROWSER_AGENT.to_string()),
+                None,
                 text,
             )
             .await
diff --git a/server/src/state.rs b/server/src/state.rs
index 648ab5304764a329fcabbbbcd3782b94e3e005a8..8ea84dcf9b1df8f8037e913cdd94e5908e6d5d55 100644
--- a/server/src/state.rs
+++ b/server/src/state.rs
@@ -21,7 +21,8 @@ pub struct InviteState {
 
 #[derive(Debug, Clone)]
 pub struct PendingSession {
-    pub agent: String,
+    /// CLI `identity start` delegate (`uuid:rig:model`). `None` for browser `/login` and `/join`.
+    pub agent: Option<String>,
     pub created_ts: i64,
     pub provider: Option<String>,
     pub provider_id: Option<String>,
diff --git a/server/tests/integration_ui.rs b/server/tests/integration_ui.rs
index d242abff5769cc704b3c8070d7456e5788c31a45..22b16c87d5ecdb5272a017af70b5353d8c39c6e8 100644
--- a/server/tests/integration_ui.rs
+++ b/server/tests/integration_ui.rs
@@ -418,6 +418,99 @@ async fn test_web_login_carries_vote_pair_next_into_pending_session() {
     let sessions = state.pending_sessions.read().await;
     let pending = sessions.get(&session).expect("pending session");
     assert_eq!(pending.redirect_next.as_deref(), Some(next));
+    assert_eq!(
+        pending.agent, None,
+        "browser /login must not invent a sentinel delegate"
+    );
+}
+
+#[tokio::test]
+async fn test_vote_compare_two_users_both_succeed_without_delegate() {
+    let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await;
+    let client = reqwest::Client::new();
+    let alice = test_bearer();
+    let bob = seed_user_token(&state, "bob", "bobtok", "bobsecret").await;
+
+    // Define items first (votes require existing item bodies).
+    let seed = ui_post_ingest_rpc(
+        "public",
+        "multi-vote",
+        "~/multi-a {alpha}\n~/multi-b {beta}\n",
+    );
+    let seed_resp = client
+        .post(format!("http://{addr}/ui"))
+        .header("Authorization", format!("Bearer {alice}"))
+        .form(&[("__rpc__", seed.as_str())])
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(seed_resp.status(), reqwest::StatusCode::OK);
+    let seed_js = seed_resp.text().await.unwrap();
+    assert!(
+        !seed_js.contains("auth-error"),
+        "item seed must succeed, got: {seed_js}"
+    );
+
+    for (bearer, left, right, explanation) in [
+        (&alice, "3", "1", "alice prefers a"),
+        (&bob, "1", "3", "bob prefers b"),
+    ] {
+        let rpc = ui_vote_compare_post_rpc(
+            "public",
+            "multi-vote",
+            "~/multi-a",
+            "~/multi-b",
+            left,
+            right,
+            explanation,
+        );
+        let resp = client
+            .post(format!("http://{addr}/ui"))
+            .header("Authorization", format!("Bearer {bearer}"))
+            .form(&[("__rpc__", rpc.as_str())])
+            .send()
+            .await
+            .unwrap();
+        assert_eq!(resp.status(), reqwest::StatusCode::OK);
+        let js = resp.text().await.unwrap();
+        assert!(
+            !js.contains("delegate already bound"),
+            "human vote must not hit shared-sentinel AgentBound ({explanation}), got: {js}"
+        );
+        assert!(
+            !js.contains("auth-error"),
+            "human vote must succeed ({explanation}), got: {js}"
+        );
+        assert!(
+            js.contains("vote-edge-history-region"),
+            "vote should morph edge history ({explanation}), got: {js}"
+        );
+    }
+
+    let reduced = state.reduced.read().await;
+    let human_votes: Vec<_> = reduced
+        .ingests_ordered
+        .iter()
+        .filter_map(|id| reduced.ingests_by_id.get(id))
+        .filter(|ing| ing.raw.contains("prefers"))
+        .collect();
+    assert_eq!(human_votes.len(), 2, "expected two vote ingests");
+    let mut principals: Vec<&str> = human_votes.iter().map(|i| i.principal.as_str()).collect();
+    principals.sort();
+    assert_eq!(principals, ["bob", "testuser"]);
+    for ing in &human_votes {
+        assert!(
+            ing.delegate.is_none(),
+            "browser votes must have no delegate, principal={} delegate={:?}",
+            ing.principal,
+            ing.delegate
+        );
+    }
+    assert!(
+        reduced.agent_bindings.is_empty(),
+        "human votes must not create AgentBound entries: {:?}",
+        reduced.agent_bindings
+    );
 }
 
 #[tokio::test]
diff --git a/server/tests/support/mod.rs b/server/tests/support/mod.rs
index 4a620eaa875e7a1145f2a4e82cc4ff2be21d5345..a5306fa2c4c9fda47e43fc0de42407b66e23eced 100644
--- a/server/tests/support/mod.rs
+++ b/server/tests/support/mod.rs
@@ -70,20 +70,25 @@ pub async fn rpc_batch(
     response.json().await.unwrap()
 }
 
-pub async fn seed_test_token(state: &AppState) {
+/// Seed a user + bearer into reducer state (not appended to the event log).
+/// Returns the `slug_<token_id>_<secret>` bearer string.
+pub async fn seed_user_token(
+    state: &AppState,
+    username: &str,
+    token_id: &str,
+    secret: &str,
+) -> String {
     let registered = Event::UserRegistered(UserRegistered {
         ts: 0,
-        username: "testuser".to_string(),
+        username: username.to_string(),
         provider: "test".to_string(),
-        provider_id: "testuser".to_string(),
+        provider_id: username.to_string(),
     });
-    let token_id = "testtok";
-    let secret = "secret";
     let salt = "salt";
     let token_hash = sha256_hex(&format!("{salt}:{secret}"));
     let ev = Event::TokenIssued(TokenIssued {
         ts: 0,
-        username: "testuser".to_string(),
+        username: username.to_string(),
         token_id: token_id.to_string(),
         token_hash,
         salt: salt.to_string(),
@@ -92,6 +97,11 @@ pub async fn seed_test_token(state: &AppState) {
     let mut r = state.reduced.write().await;
     r.apply_event(registered);
     r.apply_event(ev);
+    format!("slug_{token_id}_{secret}")
+}
+
+pub async fn seed_test_token(state: &AppState) {
+    let _ = seed_user_token(state, "testuser", "testtok", "secret").await;
 }
 
 pub async fn create_test_server_with_state() -> (
diff --git a/test/oauth.clj b/test/oauth.clj
index 3cbf496005e906ad6c579d44ac8c4055ee60d063..6911ac34a55707acda9f9ce091c079964649a252 100644
--- a/test/oauth.clj
+++ b/test/oauth.clj
@@ -186,8 +186,9 @@
               (:token poll-json))))))))
 
 (defn fetch-bearer-token!
-  "Simulate browser OAuth + username choice; returns `slug_…` bearer token.
-   Ingest `--delegate` must match this agent string for `AgentBound` on first write."
+  "Simulate CLI identity OAuth + username choice; returns `slug_…` bearer token.
+   Pass `:agent` (default local/dev) when the test will CLI-ingest with `--delegate`
+   so first write can `AgentBound`. Browser UI posts use no delegate."
   [base-url & {:keys [username agent] :or {username "intuser" agent default-agent}}]
   (let [token (complete-registration! base-url :username username :agent agent)]
     (when-not (str/starts-with? token "slug_")
diff --git a/types/src/lib.rs b/types/src/lib.rs
index 211493935d19582607f5c87fb492faf47bdc6f53..0c4295b15908ea7a2303dac4c42015ded51e4d73 100644
--- a/types/src/lib.rs
+++ b/types/src/lib.rs
@@ -535,7 +535,9 @@ pub struct PendingSessionStartResponse {
 pub struct PendingSessionPollResponse {
     pub ok: bool,
     pub complete: bool,
-    pub agent: String,
+    /// Present for CLI identity sessions; omitted for browser `/login` / `/join` (no delegate).
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub agent: Option<String>,
     #[serde(skip_serializing_if = "Option::is_none")]
     pub user: Option<String>,
     #[serde(skip_serializing_if = "Option::is_none")]

download full diff A

B — c_c124c217f89c (tommy-mor)

message

[8d8230d1] reddit

diff preview

diff --git a/.gitignore b/.gitignore
index 4c7073f9fac0c30fd2050d79a60ef447af58ebeb..ada462e900d24a3a6d08165d158c80f79c35a5a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@
 data/
 repomix-output.xml
 dev-data/
+.env
diff --git a/server/Cargo.toml b/server/Cargo.toml
index 7906a8547d56b8e6a48ef59c37aa82a8510fdee9..4677fedcb45292eebebe7e9cf6ce2f5738f18ddf 100644
--- a/server/Cargo.toml
+++ b/server/Cargo.toml
@@ -16,6 +16,7 @@ tower = "0.5"
 tower-http = { version = "0.5", features = ["trace"] }
 tracing = "0.1"
 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+reqwest = { version = "0.12", features = ["json"] }
 
 [dev-dependencies]
 reqwest = { version = "0.12", features = ["json"] }
diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs
index 2864407ed6e8ec284a1dc663acf1805534566b62..df6505021d9f446c2b453e20e3eb3cf696a111f9 100644
--- a/server/src/html/mod.rs
+++ b/server/src/html/mod.rs
@@ -272,5 +272,16 @@ pub async fn home(State(state): State<AppState>, uri: Uri) -> impl IntoResponse
 
 pub async fn browse(State(state): State<AppState>, uri: Uri) -> impl IntoResponse {
     let item = ItemId::from_browse_uri(uri.path()).unwrap_or(ItemId::root());
+    if item.as_str().starts_with("reddit.com") {
+        let needs_fetch = {
+            let tree = state.tree.read().await;
+            tree.get(&item)
+                .map(|n| n.data.is_none())
+                .unwrap_or(true)
+        };
+        if needs_fetch {
+            state.reddit.request_fetch(item.clone());
+        }
+    }
     item_page(state, uri, item).await
 }
diff --git a/server/src/reddit.rs b/server/src/reddit.rs
index d203dca09245daf869b3aa942898447700ae69fb..90053ad03b1d7c8e94f325dd4ee64c2b4f7da900 100644
--- a/server/src/reddit.rs
+++ b/server/src/reddit.rs
@@ -1,4 +1,12 @@
-//! Reddit API import (async, decoupled from UI request path).
+//! Reddit API import via a single background worker (rate limits, dedup, backoff).
+
+use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use reqwest::{header, Client, StatusCode};
+use serde::Deserialize;
+use tokio::sync::{mpsc, RwLock};
 
 use crate::{
     path_types::ItemId,
@@ -10,12 +18,401 @@ pub fn ensure_partial_tree(tree: &mut GlobalTree, id: &ItemId) {
     tree.ensure_path(id);
 }
 
-/// Placeholder for Reddit JSON import. Returns entity data when implemented.
-pub async fn fetch_reddit_entity(_id: &ItemId) -> Option<EntityData> {
-    None
+pub struct RedditCommand {
+    pub id: ItemId,
+}
+
+#[derive(Clone)]
+pub struct RedditBroker {
+    tx: mpsc::Sender<RedditCommand>,
+}
+
+#[derive(Clone)]
+struct RedditCredentials {
+    client_id: String,
+    client_secret: String,
+}
+
+struct OAuthToken {
+    access_token: String,
+    expires_at: Instant,
+}
+
+impl RedditBroker {
+    pub fn spawn(tree: Arc<RwLock<GlobalTree>>, user_agent: &str) -> Self {
+        let (tx, rx) = mpsc::channel(100);
+
+        let mut headers = header::HeaderMap::new();
+        headers.insert(
+            header::USER_AGENT,
+            header::HeaderValue::from_str(user_agent).expect("valid user agent"),
+        );
+
+        let client = Client::builder()
+            .default_headers(headers)
+            .timeout(Duration::from_secs(15))
+            .build()
+            .expect("reqwest client");
+
+        let creds = RedditCredentials::from_env();
+        tokio::spawn(reddit_worker(rx, tree, client, creds));
+
+        Self { tx }
+    }
+
+    /// Fire-and-forget: queue a fetch; worker updates the tree when done.
+    pub fn request_fetch(&self, id: ItemId) {
+        let _ = self.tx.try_send(RedditCommand { id });
+    }
+}
+
+impl RedditCredentials {
+    fn from_env() -> Option<Self> {
+        let client_id = std::env::var("REDDIT_CLIENT_ID").ok()?;
+        let client_secret = std::env::var("REDDIT_CLIENT_SECRET").ok()?;
+        if client_id.is_empty() || client_secret.is_empty() {
+            return None;
+        }
+        Some(Self {
+            client_id,
+            client_secret,
+        })
+    }
+}
+
+pub fn default_user_agent() -> String {
+    std::env::var("REDDIT_USER_AGENT").unwrap_or_else(|_| {
+        "web:sorter2.social:v0.0.1 (by /u/sorter2)".to_string()
+    })
 }
 
-/// Apply fetched entity data to a node (called from async worker).
-pub fn apply_entity(tree: &mut GlobalTree, id: &ItemId, data: EntityData) {
-    tree.set_entity_data(id, data);
+async fn reddit_worker(
+    mut rx: mpsc::Receiver<RedditCommand>,
+    tree: Arc<RwLock<GlobalTree>>,
+    client: Client,
+    creds: Option<RedditCredentials>,
+) {
+    let mut in_flight = HashSet::new();
+    let mut recently_fetched: HashMap<ItemId, Instant> = HashMap::new();
+    let mut current_delay = Duration::from_secs(1);
+    let mut oauth: Option<OAuthToken> = None;
+    let cache_ttl = Duration::from_secs(300);
+
+    while let Some(cmd) = rx.recv().await {
+        let now = Instant::now();
+        recently_fetched.retain(|_, t| now.duration_since(*t) < cache_ttl);
+
+        if in_flight.contains(&cmd.id) || recently_fetched.contains_key(&cmd.id) {
+            continue;
+        }
+
+        in_flight.insert(cmd.id.clone());
+        let fetch_id = cmd.id.clone();
+
+        tokio::time::sleep(current_delay).await;
+
+        if let Some(c) = &creds {
+            oauth = ensure_oauth_token(&client, c, oauth.take()).await;
+        }
+
+        let token = oauth.as_ref().map(|t| t.access_token.as_str());
+        let use_oauth = token.is_some();
+
+        match do_fetch(&client, &fetch_id, use_oauth, token).await {
+            Ok(FetchOutcome::Entity(data)) => {
+                let mut w = tree.write().await;
+                w.set_entity_data(&fetch_id, data);
+                recently_fetched.insert(fetch_id.clone(), Instant::now());
+                current_delay = Duration::from_millis(600);
+            }
+            Ok(FetchOutcome::NotFound) => {
+                recently_fetched.insert(fetch_id.clone(), Instant::now());
+            }
+            Ok(FetchOutcome::RateLimited { reset_secs }) => {
+                let wait = Duration::from_secs(reset_secs.max(1));
+                tracing::warn!(
+                    "Reddit rate limit for {}; sleeping {}s",
+                    fetch_id,
+                    wait.as_secs()
+                );
+                tokio::time::sleep(wait).await;
+                current_delay = (current_delay * 2).min(Duration::from_secs(60));
+            }
+            Err(e) => {
+                tracing::warn!("Reddit fetch failed for {}: {}", fetch_id, e);
+                current_delay = (current_delay * 2).min(Duration::from_secs(60));
+            }
+        }
+
+        in_flight.remove(&fetch_id);
+    }
+}
+
+enum FetchOutcome {
+    Entity(EntityData),
+    NotFound,
+    RateLimited { reset_secs: u64 },
+}
+
+async fn ensure_oauth_token(
+    client: &Client,
+    creds: &RedditCredentials,
+    existing: Option<OAuthToken>,
+) -> Option<OAuthToken> {
+    if let Some(t) = existing {
+        if Instant::now() < t.expires_at - Duration::from_secs(60) {
+            return Some(t);
+        }
+    }
+
+    let resp = client
+        .post("https://www.reddit.com/api/v1/access_token")
+        .basic_auth(&creds.client_id, Some(&creds.client_secret))
+        .form(&[("grant_type", "client_credentials")])
+        .send()
+        .await;
+
+    let resp = match resp {
+        Ok(r) => r,
+        Err(e) => {
+            tracing::warn!("Reddit OAuth token request failed: {e}");
+            return None;
+        }
+    };
+
+    if !resp.status().is_success() {
+        tracing::warn!("Reddit OAuth token HTTP {}", resp.status());
+        return None;
+    }
+
+    #[derive(Deserialize)]
+    struct TokenResponse {
+        access_token: String,
+        expires_in: u64,
+    }
+
+    let body: TokenResponse = match resp.json().await {
+        Ok(b) => b,
+        Err(e) => {
+            tracing::warn!("Reddit OAuth token parse failed: {e}");
+            return None;
+        }
+    };
+
+    Some(OAuthToken {
+        access_token: body.access_token,
+        expires_at: Instant::now() + Duration::from_secs(body.expires_in),
+    })
+}
+
+async fn do_fetch(
+    client: &Client,
+    id: &ItemId,
+    use_oauth: bool,
+    bearer: Option<&str>,
+) -> Result<FetchOutcome, String> {
+    let url = map_item_to_reddit_api(id, use_oauth);
+    if url.is_empty() {
+        return Ok(FetchOutcome::NotFound);
+    }
+
+    let mut req = client.get(&url);
+    if let Some(token) = bearer {
+        req = req.bearer_auth(token);
+    }
+
+    let resp = req.send().await.map_err(|e| e.to_string())?;
+
+    if resp.status() == StatusCode::TOO_MANY_REQUESTS {
+        let reset = rate_limit_reset_secs(&resp);
+        return Ok(FetchOutcome::RateLimited { reset_secs: reset });
+    }
+
+    if resp.status() == StatusCode::SERVICE_UNAVAILABLE {
+        return Err("Reddit unavailable (503)".to_string());
+    }
+
+    if !resp.status().is_success() {
+        return Ok(FetchOutcome::NotFound);
+    }
+
+    if rate_limit_remaining(&resp) == Some(0) {
+        let reset = rate_limit_reset_secs(&resp);
+        return Ok(FetchOutcome::RateLimited { reset_secs: reset });
+    }
+
+    let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
+    Ok(parse_reddit_json(id, &bytes)
+        .map(FetchOutcome::Entity)
+        .unwrap_or(FetchOutcome::NotFound))
+}
+
+fn rate_limit_remaining(resp: &reqwest::Response) -> Option<u64> {
+    resp.headers()
+        .get("x-ratelimit-remaining")
+        .and_then(|v| v.to_str().ok())
+        .and_then(|s| s.parse::<f64>().ok())
+        .map(|f| f.floor() as u64)
+}
+
+fn rate_limit_reset_secs(resp: &reqwest::Response) -> u64 {
+    resp.headers()
+        .get("x-ratelimit-reset")
+        .and_then(|v| v.to_str().ok())
+        .and_then(|s| s.parse::<f64>().ok())
+        .map(|f| f.ceil() as u64)
+        .unwrap_or(5)
+}
+
+/// Map canonical item id to Reddit JSON API URL.
+pub fn map_item_to_reddit_api(id: &ItemId, oauth: bool) -> String {
+    let path = id.as_str();
+    if !path.starts_with("reddit.com/") && path != "reddit.com" {
+        return String::new();
+    }
+
+    let base = if oauth {
+        "https://oauth.reddit.com"
+    } else {
+        "https://www.reddit.com"
+    };
+
+    let segments: Vec<&str> = path.split('/').collect();
+
+    if let Some(i) = segments.iter().position(|&p| p == "comments") {
+        if segments.len() > i + 1 {
+            let api_path = segments[1..=i + 1].join("/");
+            return format!("{base}/{api_path}.json?raw_json=1");
+        }
+    }
+
+    if segments.len() == 3 && segments[1] == "r" {
+        return format!("{base}/r/{}/about.json?raw_json=1", segments[2]);
+    }
+
+    String::new()
+}
+
+fn parse_reddit_json(id: &ItemId, bytes: &[u8]) -> Option<EntityData> {
+    let v: serde_json::Value = serde_json::from_slice(bytes).ok()?;
+    let segments: Vec<&str> = id.as_str().split('/').collect();
+
+    if segments.iter().any(|&p| p == "comments") {
+        parse_post_listing(&v)
+    } else {
+        parse_subreddit_about(&v)
+    }
+}
+
+fn parse_subreddit_about(v: &serde_json::Value) -> Option<EntityData> {
+    let data = v.get("data")?;
+    let title = data
+        .get("title")
+        .or_else(|| data.get("display_name"))
+        .and_then(|t| t.as_str())?
+        .to_string();
+    let body_html = data
+        .get("public_description_html")
+        .or_else(|| data.get("public_description"))
+        .and_then(|t| t.as_str())
+        .map(|s| s.to_string());
+    let thumb_url = data
+        .get("icon_img")
+        .or_else(|| data.get("community_icon"))
+        .and_then(|t| t.as_str())
+        .filter(|s| !s.is_empty())
+        .map(|s| s.to_string());
+
+    Some(EntityData {
+        title,
+        author: None,
+        body_html,
+        thumb_url,
+    })
+}
+
+fn parse_post_listing(v: 

… preview truncated; 4,570 characters omitted

download full diff B

Hardlinks — judgments / attempts / prompt

prompt download

judgments

attempts

Prompt text is loaded only by the download route.