{"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[4cd0d15d] more seed\n\nSide A — unified diff (full patch):\ndiff --git a/Dockerfile b/Dockerfile\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..9cb07c60cb0da063f747cfbf1b3b876ecb8ba03e\n--- /dev/null\n+++ b/Dockerfile\n@@ -0,0 +1,34 @@\n+# time 0.3.47+ requires Rust 1.88 (edition 2024)\n+FROM rust:1.88-slim as builder\n+\n+WORKDIR /build\n+\n+RUN apt-get update && \\\n+ apt-get install -y pkg-config libssl-dev && \\\n+ rm -rf /var/lib/apt/lists/*\n+\n+# Copy source and build. (Keep it simple to avoid remote build cache oddities.)\n+COPY . .\n+RUN cargo build --release --package slugsocial-server\n+\n+FROM debian:bookworm-slim\n+\n+RUN apt-get update && \\\n+ apt-get install -y ca-certificates && \\\n+ rm -rf /var/lib/apt/lists/*\n+\n+WORKDIR /app\n+\n+COPY --from=builder /build/target/release/slugsocial-server /app/slugsocial-server\n+\n+# Create data directory for persistent volume\n+RUN mkdir -p /data\n+\n+ENV SLUG_DATA_DIR=/data\n+ENV SLUG_EVENT_LOG=/data/events.jsonl\n+ENV PORT=8080\n+\n+EXPOSE 8080\n+\n+CMD [\"/app/slugsocial-server\"]\n+\ndiff --git a/deps.edn b/deps.edn\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..0bf892d44f491cb2313e01ae8a942c3097c52948\n--- /dev/null\n+++ b/deps.edn\n@@ -0,0 +1,10 @@\n+{:paths [\".\" \"test\"]\n+ :deps {cheshire/cheshire {:mvn/version \"5.13.0\"}\n+ http-kit/http-kit {:mvn/version \"2.8.0\"}\n+ babashka/fs {:mvn/version \"0.5.32\"}\n+ babashka/process {:mvn/version \"0.6.25\"}\n+ com.blockether/spel {:mvn/version \"0.7.11\"}}\n+ :aliases\n+ {:kaocha {:extra-deps {lambdaisland/kaocha {:mvn/version \"1.91.1392\"}\n+ lambdaisland/kaocha-junit-xml {:mvn/version \"1.17.101\"}}\n+ :main-opts [\"-m\" \"kaocha.runner\"]}}}\ndiff --git a/event_log.rs b/event_log.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..eaae0d495e43a45d6590603892265a62cc92906e\n--- /dev/null\n+++ b/event_log.rs\n@@ -0,0 +1,83 @@\n+use std::path::{Path, PathBuf};\n+\n+use tokio::{\n+ fs::{self, OpenOptions},\n+ io::{AsyncBufReadExt, AsyncWriteExt, BufReader},\n+};\n+\n+use crate::events::Event;\n+\n+#[derive(Debug, thiserror::Error)]\n+pub enum EventLogError {\n+ #[error(\"io error: {0}\")]\n+ Io(#[from] std::io::Error),\n+ #[error(\"json error: {0}\")]\n+ Json(#[from] serde_json::Error),\n+}\n+\n+#[derive(Debug, Clone)]\n+pub struct EventLog {\n+ path: PathBuf,\n+}\n+\n+impl EventLog {\n+ pub fn new(path: impl Into) -> Self {\n+ Self { path: path.into() }\n+ }\n+\n+ pub fn path(&self) -> &Path {\n+ &self.path\n+ }\n+\n+ pub async fn ensure_parent_dir(&self) -> Result<(), EventLogError> {\n+ if let Some(parent) = self.path.parent() {\n+ fs::create_dir_all(parent).await?;\n+ }\n+ Ok(())\n+ }\n+\n+ pub async fn append(&self, event: &Event) -> Result<(), EventLogError> {\n+ self.ensure_parent_dir().await?;\n+ let mut f: tokio::fs::File = OpenOptions::new()\n+ .create(true)\n+ .append(true)\n+ .open(&self.path)\n+ .await?;\n+\n+ let mut line = serde_json::to_string(event)?;\n+ line.push('\\n');\n+ f.write_all(line.as_bytes()).await?;\n+ f.flush().await?;\n+ Ok(())\n+ }\n+\n+ /// Load events from JSONL. Corrupt lines are skipped and returned as `(line_no, line)`.\n+ pub async fn load_all(&self) -> Result<(Vec, Vec<(usize, String)>), EventLogError> {\n+ if !fs::try_exists(&self.path).await? {\n+ return Ok((vec![], vec![]));\n+ }\n+\n+ let f = fs::File::open(&self.path).await?;\n+ let mut reader = BufReader::new(f).lines();\n+\n+ let mut events = Vec::new();\n+ let mut bad_lines = Vec::new();\n+\n+ let mut line_no: usize = 0;\n+ while let Some(line) = reader.next_line().await? {\n+ line_no += 1;\n+ let trimmed = line.trim();\n+ if trimmed.is_empty() {\n+ continue;\n+ }\n+ match serde_json::from_str::(trimmed) {\n+ Ok(ev) => events.push(ev),\n+ Err(_) => bad_lines.push((line_no, line)),\n+ }\n+ }\n+\n+ Ok((events, bad_lines))\n+ }\n+}\n+\n+\ndiff --git a/fly.toml b/fly.toml\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..bbb9345e527452db1d87a549213645c195eae5fc\n--- /dev/null\n+++ b/fly.toml\n@@ -0,0 +1,42 @@\n+app = \"slugsocial\"\n+primary_region = \"iad\"\n+\n+[build]\n+ dockerfile = \"Dockerfile\"\n+\n+[env]\n+ SLUG_DATA_DIR = \"/data\"\n+ SLUG_EVENT_LOG = \"/data/events.jsonl\"\n+ PORT = \"8080\"\n+\n+[[services]]\n+ internal_port = 8080\n+ protocol = \"tcp\"\n+\n+ [[services.ports]]\n+ port = 80\n+ handlers = [\"http\"]\n+ force_https = true\n+\n+ [[services.ports]]\n+ port = 443\n+ handlers = [\"tls\", \"http\"]\n+\n+ [services.concurrency]\n+ type = \"connections\"\n+ hard_limit = 1000\n+ soft_limit = 500\n+\n+ [[services.http_checks]]\n+ interval = \"10s\"\n+ timeout = \"2s\"\n+ grace_period = \"5s\"\n+ method = \"GET\"\n+ path = \"/healthz\"\n+ protocol = \"http\"\n+ tls_skip_verify = false\n+\n+[[mounts]]\n+ source = \"slugsocial_data\"\n+ destination = \"/data\"\n+\ndiff --git a/views.rs b/views.rs\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..d4f0ffc49475f014698b4da0de6f476884430813\n--- /dev/null\n+++ b/views.rs\n@@ -0,0 +1,63 @@\n+use std::{\n+ collections::HashMap,\n+ sync::{Arc, Mutex},\n+};\n+use tokio::sync::mpsc;\n+\n+type CountMap = Arc>>;\n+\n+#[derive(Clone)]\n+pub struct ViewStore {\n+ counts: CountMap,\n+ flush_tx: mpsc::Sender<()>,\n+}\n+\n+impl ViewStore {\n+ pub fn new(json_path: &str) -> Self {\n+ // Load existing counts from disk on startup (best-effort)\n+ let initial: HashMap = std::fs::read_to_string(json_path)\n+ .ok()\n+ .and_then(|s| serde_json::from_str(&s).ok())\n+ .unwrap_or_default();\n+\n+ let counts: CountMap = Arc::new(Mutex::new(initial));\n+ let (flush_tx, mut flush_rx) = mpsc::channel::<()>(64);\n+ let path = json_path.to_string();\n+\n+ let counts_for_writer = counts.clone();\n+ tokio::spawn(async move {\n+ while flush_rx.recv().await.is_some() {\n+ while flush_rx.try_recv().is_ok() {}\n+\n+ let snapshot: HashMap = {\n+ counts_for_writer.lock().unwrap().clone()\n+ };\n+\n+ let path = path.clone();\n+ let _ = tokio::task::spawn_blocking(move || {\n+ if let Ok(json) = serde_json::to_string(&snapshot) {\n+ let tmp = format!(\"{path}.tmp\");\n+ if std::fs::write(&tmp, &json).is_ok() {\n+ let _ = std::fs::rename(&tmp, &path);\n+ }\n+ }\n+ })\n+ .await;\n+ }\n+ });\n+\n+ Self { counts, flush_tx }\n+ }\n+\n+ pub fn increment(&self, path: String) {\n+ {\n+ let mut map = self.counts.lock().unwrap();\n+ *map.entry(path).or_insert(0) += 1;\n+ }\n+ let _ = self.flush_tx.try_send(());\n+ }\n+\n+ pub fn get_views(&self, path: &str) -> u64 {\n+ self.counts.lock().unwrap().get(path).copied().unwrap_or(0)\n+ }\n+}\n\n\nSide B — contributor: tommy-mor\nSide B — commit message:\n[8f69c309] Require Reddit OAuth when credentials are set and refresh on 401/403.\n\nAvoid falling back to the public www.reddit.com API from cloud IPs, which\nreturns Reddit's network-security block page. Also pin SORTER2_BASE_URL in fly.toml.\n\nCo-authored-by: Cursor \n\nSide B — unified diff (full patch):\ndiff --git a/fly.toml b/fly.toml\nindex f0c6a39f643c204987debc177234d95a8ef44b65..ca7e0088a7efd7d58d29d808f8d89080b2bff233 100644\n--- a/fly.toml\n+++ b/fly.toml\n@@ -5,6 +5,7 @@ primary_region = \"iad\"\n dockerfile = \"Dockerfile\"\n \n [env]\n+ SORTER2_BASE_URL = \"https://reddit.sorter.social\"\n SORTER2_DATA_DIR = \"/data\"\n SORTER2_EVENT_LOG = \"/data/events.jsonl\"\n PORT = \"8080\"\ndiff --git a/server/src/reddit.rs b/server/src/reddit.rs\nindex a874814f8927192ee62cab2d0db1efd27dcd57b7..f409764c1e1f36216f1b08107043c2eab905694c 100644\n--- a/server/src/reddit.rs\n+++ b/server/src/reddit.rs\n@@ -283,26 +283,35 @@ async fn reddit_worker(\n );\n tokio::time::sleep(current_delay).await;\n \n- if let Some(c) = &creds {\n- oauth = ensure_oauth_token(&client, &oauth_token_base, c, oauth.take()).await;\n- }\n-\n- let token = oauth.as_ref().map(|t| t.access_token.as_str());\n- let fetch_base = if token.is_some() {\n- tracing::debug!(\n- item = %fetch_id,\n- base = %oauth_api_base,\n- \"reddit fetch using OAuth bearer\"\n- );\n- &oauth_api_base\n- } else {\n- &api_base\n- };\n- let url = match kind {\n- FetchKind::SelfEntity => map_item_to_reddit_api(&fetch_id, fetch_base),\n- FetchKind::Children => map_children_url(&fetch_id, fetch_base),\n+ let outcome = match &creds {\n+ Some(c) => {\n+ // OAuth is required when credentials are configured — never fall\n+ // back to the public www.reddit.com JSON endpoints (cloud IPs\n+ // get blocked with a 403 HTML interstitial).\n+ fetch_with_oauth(\n+ &client,\n+ &oauth_token_base,\n+ &oauth_api_base,\n+ c,\n+ &mut oauth,\n+ &fetch_id,\n+ kind,\n+ )\n+ .await\n+ }\n+ None => {\n+ let url = match kind {\n+ FetchKind::SelfEntity => map_item_to_reddit_api(&fetch_id, &api_base),\n+ FetchKind::Children => map_children_url(&fetch_id, &api_base),\n+ };\n+ match do_fetch(&client, &url, &fetch_id, None).await {\n+ Ok(FetchOutcome::AuthRejected { status, detail }) => {\n+ Err(format!(\"Reddit API {status}: {detail}\"))\n+ }\n+ other => other,\n+ }\n+ }\n };\n- let outcome = do_fetch(&client, &url, &fetch_id, token).await;\n \n match outcome {\n Ok(FetchOutcome::Payload(payload)) => {\n@@ -342,6 +351,12 @@ async fn reddit_worker(\n current_delay = (current_delay * 2).min(Duration::from_secs(60));\n notify(done, FetchJobResult::RateLimited { reset_secs });\n }\n+ Ok(FetchOutcome::AuthRejected { status, detail }) => {\n+ let e = format!(\"Reddit API {status}: {detail}\");\n+ tracing::warn!(item = %fetch_id, err = %e, \"reddit fetch auth rejected\");\n+ current_delay = (current_delay * 2).min(Duration::from_secs(60));\n+ notify(done, FetchJobResult::Failed(e));\n+ }\n Err(e) => {\n tracing::warn!(item = %fetch_id, err = %e, \"reddit fetch failed\");\n current_delay = (current_delay * 2).min(Duration::from_secs(60));\n@@ -357,6 +372,60 @@ enum FetchOutcome {\n Payload(Value),\n NotFound,\n RateLimited { reset_secs: u64 },\n+ /// Bearer rejected — caller should drop the cached token and retry once.\n+ AuthRejected { status: StatusCode, detail: String },\n+}\n+\n+async fn fetch_with_oauth(\n+ client: &Client,\n+ oauth_token_base: &str,\n+ oauth_api_base: &str,\n+ creds: &RedditCredentials,\n+ oauth: &mut Option,\n+ fetch_id: &ItemId,\n+ kind: FetchKind,\n+) -> Result {\n+ for attempt in 0..2 {\n+ let force_refresh = attempt > 0;\n+ *oauth = Some(\n+ ensure_oauth_token(client, oauth_token_base, creds, oauth.take(), force_refresh)\n+ .await?,\n+ );\n+ let token = oauth\n+ .as_ref()\n+ .expect(\"token set above\")\n+ .access_token\n+ .clone();\n+\n+ tracing::debug!(\n+ item = %fetch_id,\n+ base = %oauth_api_base,\n+ attempt,\n+ \"reddit fetch using OAuth bearer\"\n+ );\n+\n+ let url = match kind {\n+ FetchKind::SelfEntity => map_item_to_reddit_api(fetch_id, oauth_api_base),\n+ FetchKind::Children => map_children_url(fetch_id, oauth_api_base),\n+ };\n+ match do_fetch(client, &url, fetch_id, Some(&token)).await? {\n+ FetchOutcome::AuthRejected { status, detail } if attempt == 0 => {\n+ tracing::warn!(\n+ item = %fetch_id,\n+ %status,\n+ %detail,\n+ \"reddit OAuth rejected; refreshing token and retrying\"\n+ );\n+ *oauth = None;\n+ continue;\n+ }\n+ FetchOutcome::AuthRejected { status, detail } => {\n+ return Err(format!(\"Reddit API {status}: {detail}\"));\n+ }\n+ other => return Ok(other),\n+ }\n+ }\n+ unreachable!(\"loop always returns\")\n }\n \n async fn ensure_oauth_token(\n@@ -364,35 +433,35 @@ async fn ensure_oauth_token(\n oauth_base: &str,\n creds: &RedditCredentials,\n existing: Option,\n-) -> Option {\n- if let Some(t) = existing {\n- if Instant::now() < t.expires_at - Duration::from_secs(60) {\n- tracing::debug!(\"reddit OAuth token still valid\");\n- return Some(t);\n+ force_refresh: bool,\n+) -> Result {\n+ if !force_refresh {\n+ if let Some(t) = existing {\n+ if Instant::now() < t.expires_at - Duration::from_secs(60) {\n+ tracing::debug!(\"reddit OAuth token still valid\");\n+ return Ok(t);\n+ }\n }\n }\n \n let url = format!(\"{}/api/v1/access_token\", oauth_base.trim_end_matches('/'));\n- tracing::debug!(%url, \"reddit OAuth token request\");\n+ tracing::debug!(%url, force_refresh, \"reddit OAuth token request\");\n \n let resp = client\n .post(&url)\n .basic_auth(&creds.client_id, Some(&creds.client_secret))\n .form(&[(\"grant_type\", \"client_credentials\")])\n .send()\n- .await;\n-\n- let resp = match resp {\n- Ok(r) => r,\n- Err(e) => {\n- tracing::warn!(\"reddit OAuth token request failed: {e}\");\n- return None;\n- }\n- };\n+ .await\n+ .map_err(|e| format!(\"Reddit OAuth token request failed: {e}\"))?;\n \n if !resp.status().is_success() {\n- tracing::warn!(\"reddit OAuth token HTTP {}\", resp.status());\n- return None;\n+ let status = resp.status();\n+ let body = resp.text().await.unwrap_or_default();\n+ return Err(format!(\n+ \"Reddit OAuth token HTTP {status}: {}\",\n+ truncate_for_error(&body)\n+ ));\n }\n \n #[derive(Deserialize)]\n@@ -401,21 +470,40 @@ async fn ensure_oauth_token(\n expires_in: u64,\n }\n \n- let body: TokenResponse = match resp.json().await {\n- Ok(b) => b,\n- Err(e) => {\n- tracing::warn!(\"reddit OAuth token parse failed: {e}\");\n- return None;\n- }\n- };\n+ let body: TokenResponse = resp\n+ .json()\n+ .await\n+ .map_err(|e| format!(\"Reddit OAuth token parse failed: {e}\"))?;\n \n- tracing::debug!(expires_in = body.expires_in, \"reddit OAuth token acquired\");\n- Some(OAuthToken {\n+ tracing::info!(expires_in = body.expires_in, \"reddit OAuth token acquired\");\n+ Ok(OAuthToken {\n access_token: body.access_token,\n expires_at: Instant::now() + Duration::from_secs(body.expires_in),\n })\n }\n \n+fn truncate_for_error(body: &str) -> String {\n+ let compact: String = body.split_whitespace().collect::>().join(\" \");\n+ if compact.is_empty() {\n+ return \"(empty body)\".into();\n+ }\n+ // Prefer the human-readable block message over dumping Reddit's CSS.\n+ if let Some(idx) = compact.find(\"You've been blocked\") {\n+ let slice: String = compact.chars().skip(idx).take(160).collect();\n+ return if compact.chars().count() > idx + 160 {\n+ format!(\"{slice}…\")\n+ } else {\n+ slice\n+ };\n+ }\n+ let chars: String = compact.chars().take(200).collect();\n+ if compact.chars().count() > 200 {\n+ format!(\"{chars}…\")\n+ } else {\n+ chars\n+ }\n+}\n+\n async fn do_fetch(\n client: &Client,\n url: &str,\n@@ -460,15 +548,16 @@ async fn do_fetch(\n \n if !status.is_success() {\n let body = resp.text().await.unwrap_or_default();\n+ let detail = truncate_for_error(&body);\n tracing::debug!(\n item = %id,\n %status,\n body_len = body.len(),\n- body_prefix = %body.chars().take(240).collect::(),\n+ %detail,\n \"reddit non-success body\"\n );\n if status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED {\n- return Err(format!(\"Reddit API {status}: {body}\"));\n+ return Ok(FetchOutcome::AuthRejected { status, detail });\n }\n return Ok(FetchOutcome::NotFound);\n }\n@@ -716,6 +805,15 @@ fn reddit_direct_image_url(url: &str) -> bool {\n mod tests {\n use super::*;\n \n+ #[test]\n+ fn truncate_error_prefers_block_message() {\n+ let html = r#\"
You've been blocked by network security. To continue, log in
\"#;\n+ let msg = truncate_for_error(html);\n+ assert!(msg.starts_with(\"You've been blocked\"));\n+ assert!(msg.len() < 200);\n+ assert!(!msg.contains(\".x{color\"));\n+ }\n+\n #[test]\n fn map_subreddit_about_url() {\n let id = ItemId::from_url(\"https://reddit.com/r/rust\").unwrap();\n","role":"user"}],"model":"openai/gpt-chat-latest"}