You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit message: [e52a5895] Replace vestigial vote identity with pseudonym-based uuid dedup. Store votes in uuid_votes (not blind edge merges), derive rankings on load, and resolve actors via a pseudonyms map seeded at projection open. Co-authored-by: Cursor Side A — unified diff (full patch): diff --git a/server/src/bin/storage_bench.rs b/server/src/bin/storage_bench.rs index 3d988416ad36b27a7d3dc84280cfdbdcafa43e69..cc5cd78ba1e02a1e6f92aa503677f2d329bb5993 100644 --- a/server/src/bin/storage_bench.rs +++ b/server/src/bin/storage_bench.rs @@ -29,14 +29,7 @@ async fn main() -> Result<(), Box> { for chunk_start in (0..opts.events).step_by(opts.batch_size) { let chunk_end = (chunk_start + opts.batch_size).min(opts.events); let events = (chunk_start..chunk_end) - .map(|i| Event::VoteRecorded { - ts: i as i64, - a: format!("item-{i}"), - b: format!("item-{}", i + 1), - ratio_left: 2, - ratio_right: 1, - scope: String::new(), - }) + .map(|i| Event::vote_recorded(i as i64, format!("item-{i}"), format!("item-{}", i + 1), 2, 1, "")) .collect(); journal.append_many(events).await?; } diff --git a/server/src/event_log.rs b/server/src/event_log.rs index 2c9290b6fdbf2c2ad1c0f1ffd7374b2d9cc97f36..8e5684cb1378e80c45bcf743d2fdc48402dd4dc8 100644 --- a/server/src/event_log.rs +++ b/server/src/event_log.rs @@ -206,14 +206,7 @@ mod tests { .unwrap(); log.append(&sample_record( 2, - Event::VoteRecorded { - ts: 1, - a: "a".into(), - b: "b".into(), - ratio_left: 2, - ratio_right: 1, - scope: String::new(), - }, + Event::vote_recorded(1, "a", "b", 2, 1, ""), )) .await .unwrap(); diff --git a/server/src/events.rs b/server/src/events.rs index d76c3bb4277216b0d39c9422ba7a50db10a95e05..8a166d49b4f26835fbc2b58cb1f4bdbf002763b8 100644 --- a/server/src/events.rs +++ b/server/src/events.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; /// Schema version for JSONL log records. Bump when event semantics change. -pub const CURRENT_LOG_SCHEMA: u32 = 1; +pub const CURRENT_LOG_SCHEMA: u32 = 2; /// One JSONL line: schema envelope around a payload event. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -51,9 +51,33 @@ pub enum Event { b: String, ratio_left: i32, ratio_right: i32, - #[serde(default)] scope: String, + pseudonym: String, + trust_weight: f64, }, /// Register a node path in the fractal tree (no external fetch). NodeEnsured { id: String }, } + +impl Event { + /// Construct a vote event with the default dev pseudonym (tests and benches). + pub fn vote_recorded( + ts: i64, + a: impl Into, + b: impl Into, + ratio_left: i32, + ratio_right: i32, + scope: impl Into, + ) -> Self { + Self::VoteRecorded { + ts, + a: a.into(), + b: b.into(), + ratio_left, + ratio_right, + scope: scope.into(), + pseudonym: crate::identity::DEFAULT_PSEUDONYM.to_string(), + trust_weight: 1.0, + } + } +} diff --git a/server/src/html/vote.rs b/server/src/html/vote.rs index 8cf2b0d4d8bd58cc51cc27c9046fa7f7728a7017..bf82aef3ad9c5f4e9e877c47dab27beb29a80b8f 100644 --- a/server/src/html/vote.rs +++ b/server/src/html/vote.rs @@ -336,6 +336,7 @@ pub async fn vote_page( #[cfg(test)] mod polarity_tests { use super::*; + use crate::identity::{DEFAULT_PSEUDONYM, TEST_ACTOR_UUID}; use crate::ranking::ranked_items; use crate::reducer::GlobalTree; @@ -351,11 +352,11 @@ mod polarity_tests { let right = id("right_item"); // Stored a == page left: keep order. - let v1 = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap(); + let v1 = VoteData::from_event(1, left.as_str(), right.as_str(), 9, 1, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap(); assert_eq!(ratios_for_page(&v1, &left, &right), (9, 1)); // Stored a == page right: swap so left stays left. - let v2 = VoteData::from_recorded(2, right.as_str(), left.as_str(), 9, 1).unwrap(); + let v2 = VoteData::from_event(2, right.as_str(), left.as_str(), 9, 1, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap(); assert_eq!(ratios_for_page(&v2, &left, &right), (1, 9)); } @@ -383,9 +384,9 @@ mod polarity_tests { let right = id("right_item"); // Slider dragged left yields e.g. 9:1 with a = left item. - let vote = VoteData::from_recorded(1, left.as_str(), right.as_str(), 9, 1).unwrap(); + let vote = VoteData::from_event(1, left.as_str(), right.as_str(), 9, 1, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap(); let mut tree = GlobalTree::new(); - tree.apply_vote(&parent, vote); + tree.apply_vote(&parent, vote, TEST_ACTOR_UUID); let group = &tree.get(&parent).unwrap().local_ranking; let ranked = ranked_items(group); diff --git a/server/src/identity.rs b/server/src/identity.rs new file mode 100644 index 0000000000000000000000000000000000000000..621d505436912246ec0830ff20c3473f0c4665ce --- /dev/null +++ b/server/src/identity.rs @@ -0,0 +1,35 @@ +//! Actor identity: pseudonym display names mapped to stable UUIDs for vote dedup. + +use durable::Db; + +use crate::storage_schema::{Store, StoreFields}; + +/// Default pseudonym until session/auth UI exists. +pub const DEFAULT_PSEUDONYM: &str = "anon"; + +/// UUID for the default single-user dev principal. +pub const DEFAULT_ACTOR_UUID: &str = "00000000-0000-0000-0000-000000000001"; + +/// UUID for in-memory unit tests. +pub const TEST_ACTOR_UUID: &str = "00000000-0000-0000-0000-000000000099"; + +/// Resolve the trust anchor for a pseudonym (must exist in the pseudonyms map). +pub fn resolve_actor_uuid(db: &Db, pseudonym: &str) -> Result { + Store::root() + .pseudonyms() + .key(&pseudonym.to_string()) + .get(db) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("unknown pseudonym: {pseudonym}")) +} + +/// Ensure the default pseudonym → UUID mapping exists (operational seed, not event-logged). +pub fn seed_default_pseudonym(db: &Db) -> Result<(), durable::Error> { + let path = Store::root() + .pseudonyms() + .key(&DEFAULT_PSEUDONYM.to_string()); + if path.get(db)?.is_none() { + db.run(path.set(&DEFAULT_ACTOR_UUID.to_string()), durable::Durability::SyncWal)?; + } + Ok(()) +} diff --git a/server/src/lib.rs b/server/src/lib.rs index 3dfc7c8acb8ed61bb73ade63e72768e402042cc5..da6e33e3dd6d79967bbee7a2708a7d982c93b88c 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -4,6 +4,7 @@ pub mod events; pub mod fetch; pub mod form_template; pub mod html; +pub mod identity; pub mod journal; pub mod pair; pub mod parser; diff --git a/server/src/pair.rs b/server/src/pair.rs index 2277e32edf6e0024687d6fe2b9d1a9b84c0b34c8..42a1b1eb2adf16730d34d0fe23c13d5a75d7ba27 100644 --- a/server/src/pair.rs +++ b/server/src/pair.rs @@ -360,8 +360,17 @@ impl PairError { #[cfg(test)] mod tests { use super::*; + use crate::identity::{DEFAULT_PSEUDONYM, TEST_ACTOR_UUID}; use crate::reducer::{GlobalTree, VoteData}; + fn test_vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData { + VoteData::from_event(ts, a, b, l, r, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap() + } + + fn apply(tree: &mut GlobalTree, parent: &ItemId, vote: VoteData) { + tree.apply_vote(parent, vote, TEST_ACTOR_UUID); + } + fn seed_children(parent: &ItemId, ids: &[&str]) -> GlobalTree { let mut tree = GlobalTree::new(); tree.ensure_path(parent); @@ -382,22 +391,13 @@ mod tests { #[test] fn zero_weight_vote_leaves_pair_available_for_suggestion() { let parent = ItemId::parse("https://reddit.com/r/rust").unwrap(); - let mut tree = seed_children( + let tree = seed_children( &parent, &[ "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", ], ); - let noop = VoteData::from_recorded( - 1, - "https://reddit.com/r/rust/a", - "https://reddit.com/r/rust/b", - 0, - 0, - ) - .unwrap(); - tree.apply_vote(&parent, noop); let group = tree.get(&parent).unwrap().local_ranking.clone(); let pool = children_of(&tree, &parent); assert!(!pair_is_voted(&group, &pool[0], &pool[1])); @@ -415,9 +415,8 @@ mod tests { "https://reddit.com/r/rust/c", ], ); - let vote = - VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap(); - tree.apply_vote(&parent, vote); + let vote = test_vote(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1); + apply(&mut tree, &parent, vote); let group = tree.get(&parent).unwrap().local_ranking.clone(); let pool = children_of(&tree, &parent); let (l, r) = suggest_next_pair_in_pool(&group, &pool, None).unwrap(); @@ -438,12 +437,10 @@ mod tests { "https://reddit.com/r/rust/d", ], ); - let ab = - VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap(); - let cd = - VoteData::from_recorded(2, "https://reddit.com/r/rust/c", "https://reddit.com/r/rust/d", 2, 1).unwrap(); - tree.apply_vote(&parent, ab); - tree.apply_vote(&parent, cd); + let ab = test_vote(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1); + let cd = test_vote(2, "https://reddit.com/r/rust/c", "https://reddit.com/r/rust/d", 2, 1); + apply(&mut tree, &parent, ab); + apply(&mut tree, &parent, cd); let group = tree.get(&parent).unwrap().local_ranking.clone(); let pool = children_of(&tree, &parent); let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap(); @@ -468,9 +465,8 @@ mod tests { "https://reddit.com/r/rust/e", ], ); - let ab = - VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap(); - tree.apply_vote(&parent, ab); + let ab = test_vote(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1); + apply(&mut tree, &parent, ab); let group = tree.get(&parent).unwrap().local_ranking.clone(); let pool = children_of(&tree, &parent); let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap(); @@ -498,9 +494,8 @@ mod tests { "https://reddit.com/r/rust/c", ], ); - let ab = - VoteData::from_recorded(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1).unwrap(); - tree.apply_vote(&parent, ab); + let ab = test_vote(1, "https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 2, 1); + apply(&mut tree, &parent, ab); let group = tree.get(&parent).unwrap().local_ranking.clone(); let pool = children_of(&tree, &parent); let pair = suggest_next_pair_in_pool(&group, &pool, None).unwrap(); @@ -524,8 +519,8 @@ mod tests { ("https://reddit.com/r/rust/a", "https://reddit.com/r/rust/b", 3, 1), ("https://reddit.com/r/rust/a", "https://reddit.com/r/rust/c", 2, 1), ] { - let v = VoteData::from_recorded(1, a, b, l, r).unwrap(); - tree.apply_vote(&parent, v); + let v = test_vote(1, a, b, l, r); + apply(&mut tree, &parent, v); } let group = tree.get(&parent).unwrap().local_ranking.clone(); let pool = children_of(&tree, &parent); @@ -552,8 +547,8 @@ mod tests { ("https://reddit.com/r/rust/b", "https://reddit.com/r/rust/c", 2, 1), ("https://reddit.com/r/rust/a", "https://reddit.com/r/rust/c", 2, 1), ] { - let v = VoteData::from_recorded(1, a, b, l, r).unwrap(); - tree.apply_vote(&parent, v); + let v = test_vote(1, a, b, l, r); + apply(&mut tree, &parent, v); } let group = tree.get(&parent).unwrap().local_ranking.clone(); let pool = children_of(&tree, &parent); diff --git a/server/src/projection_apply.rs b/server/src/projection_apply.rs index ad404bacb8bcdd5ae0e682cff97f974fd44528ea..f2859c7e0e718c5bd3d332224eb05d7a8c65a816 100644 --- a/server/src/projection_apply.rs +++ b/server/src/projection_apply.rs @@ -1,14 +1,13 @@ //! Apply event-log records to the durable projection as precise point updates. //! -//! Each batch of records lowers to reified durable writes (edge merges, child -//! links, voted-pair flags, recent-vote pushes) plus a cursor advance, all -//! committed in one atomic `DisableWal` batch. The cursor moving in the same -//! batch as the (non-idempotent) edge merges guarantees exactly-once application -//! across replay. +//! Each batch of records lowers to reified durable writes (uuid vote upserts, +//! child links, recent-vote appends) plus a cursor advance, all committed in +//! one atomic `DisableWal` batch. use crate::{ event_log::EventLogError, events::{Event, EventRecord}, + identity::resolve_actor_uuid, path_types::ItemId, projection_store::ProjectionStore, reducer::VoteData, @@ -53,20 +52,31 @@ pub fn apply_records( ratio_left, ratio_right, scope, + pseudonym, + trust_weight, } => { - let vote = VoteData::from_recorded(*ts, a, b, *ratio_left, *ratio_right) - .ok_or_else(|| EventLogError::Apply(format!("invalid vote event: {a} vs {b}")))?; - let parent = parent_from_event_scope(scope); - vote_writes( - &mut batch, - &parent, - vote.a.as_str(), - vote.b.as_str(), - *ratio_left, - *ratio_right, + let left = (*ratio_left).max(0); + let right = (*ratio_right).max(0); + if left == 0 && right == 0 { + return Err(EventLogError::Apply(format!( + "invalid vote event: zero weights ({a} vs {b})" + ))); + } + let vote = VoteData::from_event( *ts, + a, + b, + left, + right, + pseudonym.clone(), + *trust_weight, ) - .map_err(|e| EventLogError::Apply(e.to_string()))?; + .ok_or_else(|| EventLogError::Apply(format!("invalid vote event: {a} vs {b}")))?; + let actor_uuid = resolve_actor_uuid(db, pseudonym) + .map_err(|e| EventLogError::Apply(e))?; + let parent = parent_from_event_scope(scope); + vote_writes(&mut batch, &parent, &vote, &actor_uuid) + .map_err(|e| EventLogError::Apply(e.to_string()))?; } Event::NodeEnsured { id } => { let parsed = parse_event_id(id)?; diff --git a/server/src/projection_store.rs b/server/src/projection_store.rs index 9a8953d010029d3639dc3987687554bab8b7663e..27fc0bb0519d9f035dceec1fc8b0fa74b00b6b40 100644 --- a/server/src/projection_store.rs +++ b/server/src/projection_store.rs @@ -18,7 +18,7 @@ use crate::{ const PROJECTION_CURSOR_KEY: &str = "cursor"; const PROJECTION_SCHEMA_KEY: &str = "schema_version"; -const PROJECTION_SCHEMA_VERSION: u64 = 4; +const PROJECTION_SCHEMA_VERSION: u64 = 5; #[derive(Debug, thiserror::Error)] pub enum ProjectionStoreError { @@ -51,6 +51,8 @@ impl ProjectionStore { if version != Some(PROJECTION_SCHEMA_VERSION) { store.reset()?; } + crate::identity::seed_default_pseudonym(db) + .map_err(ProjectionStoreError::Durable)?; Ok(store) } @@ -206,14 +208,7 @@ mod tests { let db = Db::open(tmp.path()).unwrap(); let store = ProjectionStore::from_db(&db).unwrap(); - let event = Event::VoteRecorded { - ts: 1, - a: "alpha".into(), - b: "beta".into(), - ratio_left: 2, - ratio_right: 1, - scope: String::new(), - }; + let event = Event::vote_recorded(1, "alpha", "beta", 2, 1, ""); projection_apply::apply_records(&store, &[record(1, event)]).unwrap(); assert_eq!(store.last_applied_event_count().unwrap(), 1); @@ -229,14 +224,7 @@ mod tests { let db = Db::open(tmp.path()).unwrap(); let store = ProjectionStore::from_db(&db).unwrap(); - let event = Event::VoteRecorded { - ts: 1, - a: "alpha".into(), - b: "beta".into(), - ratio_left: 2, - ratio_right: 1, - scope: String::new(), - }; + let event = Event::vote_recorded(1, "alpha", "beta", 2, 1, ""); projection_apply::apply_records(&store, &[record(1, event)]).unwrap(); let scoped = store.scope_tree(&ItemId::root()).unwrap(); diff --git a/server/src/ranking.rs b/server/src/ranking.rs index a83f7be120d9420db042479222ca9efc230b6da9..1fc3298d2b2864e9711cd262a034677e45c7090c 100644 --- a/server/src/ranking.rs +++ b/server/src/ranking.rs @@ -271,6 +271,7 @@ pub fn group_summary_scores(group: &GroupState) -> HashMap { #[cfg(test)] mod tests { use super::*; + use crate::identity::{DEFAULT_PSEUDONYM, TEST_ACTOR_UUID}; use crate::reducer::VoteData; fn mk_group() -> GroupState { @@ -278,18 +279,11 @@ mod tests { } fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData { - use crate::path_types::ItemId; - VoteData { - ts, - a: ItemId::parse(a).unwrap(), - b: ItemId::parse(b).unwrap(), - ratio_left: l, - ratio_right: r, - body: "because".to_string(), - principal: "test".to_string(), - delegate: Some("00000000-0000-0000-0000-000000000000:test:local/test".to_string()), - thread_tag: "untagged".to_string(), - } + VoteData::from_event(ts, a, b, l, r, DEFAULT_PSEUDONYM.to_string(), 1.0).unwrap() + } + + fn apply(g: &mut GroupState, v: VoteData) { + g.apply_vote(v, TEST_ACTOR_UUID); } /// Regression for issue #146: pure forward star at default `>` ratio (2:1). @@ -301,8 +295,8 @@ mod tests { #[test] fn star_topology_winner_at_top_via_subset() { let mut g = mk_group(); - g.apply_vote(vote(1, "zebra", "alpha", 2, 1)); - g.apply_vote(vote(2, "zebra", "beta", 2, 1)); + g.apply_vote(vote(1, "zebra", "alpha", 2, 1), TEST_ACTOR_UUID); + g.apply_vote(vote(2, "zebra", "beta", 2, 1), TEST_ACTOR_UUID); let mut items: Vec<(usize, String)> = g .idx_to_item @@ -329,7 +323,7 @@ mod tests { let mut g = mk_group(); // Chain a > b > c > d > e > f so ranks are well separated. for (hi, lo) in [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e"), ("e", "f")] { - g.apply_vote(vote(1, hi, lo, 2, 1)); + apply(&mut g, vote(1, hi, lo, 2, 1)); } let (top, bottom) = top_bottom(&g, 2); assert_eq!(top.len(), 2); @@ -345,7 +339,7 @@ mod tests { #[test] fn top_bottom_small_group_has_empty_bottom() { let mut g = mk_group(); - g.apply_vote(vote(1, "a", "b", 2, 1)); + apply(&mut g, vote(1, "a", "b", 2, 1)); let (top, bottom) = top_bottom(&g, 5); assert_eq!(top.len(), 2); assert!(bottom.is_empty()); @@ -355,8 +349,8 @@ mod tests { fn connected_components_split_disconnected_pairs() { let mut g = mk_group(); // Two disconnected edges: (a,b) and (c,d) - g.apply_vote(vote(1, "a", "b", 3, 1)); - g.apply_vote(vote(2, "c", "d", 3, 1)); + apply(&mut g, vote(1, "a", "b", 3, 1)); + apply(&mut g, vote(2, "c", "d", 3, 1)); let n = g.idx_to_item.len(); let (mut comps, isolates) = @@ -401,13 +395,16 @@ mod tests { let i = *perm[..k].choose(&mut rng).unwrap(); let j = perm[k]; let (a, b) = (letters[i], letters[j]); - g.apply_vote(vote( - k as i64, - &a.to_string(), - &b.to_string(), - (i + 1) as i32, - (j + 1) as i32, - )); + apply( + &mut g, + vote( + k as i64, + &a.to_string(), + &b.to_string(), + (i + 1) as i32, + (j + 1) as i32, + ), + ); } let ranked = ranked_items(&g); @@ -426,8 +423,8 @@ mod tests { #[test] fn subset_ranking_ranks_within_component_only() { let mut g = mk_group(); - g.apply_vote(vote(1, "a", "b", 3, 1)); // a > b - g.apply_vote(vote(2, "c", "d", 1, 4)); // d > c + apply(&mut g, vote(1, "a", "b", 3, 1)); // a > b + apply(&mut g, vote(2, "c", "d", 1, 4)); // d > c let (comps, _) = connected_components_from_voted_pairs( g.idx_to_item.len(), diff --git a/server/src/reducer.rs b/server/src/reducer.rs index 759918b8c0eb8f8bf1ed0911d8877adaa55c8ea6..8c4c9f83635cbcbb037d680dbae2aa99cb2dc785 100644 --- a/server/src/reducer.rs +++ b/server/src/reducer.rs @@ -5,27 +5,27 @@ use serde::{Deserialize, Serialize}; use crate::path_types::ItemId; /// Parsed pairwise vote (internal representation). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct VoteData { pub ts: i64, pub a: ItemId, pub b: ItemId, pub ratio_left: i32, pub ratio_right: i32, - pub body: String, - pub principal: String, - pub delegate: Option, - pub thread_tag: String, + pub pseudonym: String, + pub trust_weight: f64, } impl VoteData { - /// Build a vote from persisted event fields (web UI / replay). - pub fn from_recorded( + /// Build a vote from validated event fields (replay / tests). + pub fn from_event( ts: i64, a: &str, b: &str, ratio_left: i32, ratio_right: i32, + pseudonym: String, + trust_weight: f64, ) -> Option { let a = ItemId::from_storage(a)?; let b = ItemId::from_storage(b)?; @@ -38,10 +38,8 @@ impl VoteData { b, ratio_left, ratio_right, - body: String::new(), - principal: "web".to_string(), - delegate: None, - thread_tag: "default".to_string(), + pseudonym, + trust_weight, }) } } @@ -52,6 +50,8 @@ pub struct GroupState { pub idx_to_item: Vec, pub edges: HashMap<(usize, usize), f64>, pub voted_pairs: HashSet<(usize, usize)>, + /// Latest vote per `(actor_uuid, min_idx, max_idx)` — Sybil dedup anchor. + pub uuid_votes: HashMap<(String, usize, usize), VoteData>, pub recent_votes: Vec, } @@ -62,6 +62,7 @@ impl GroupState { idx_to_item: Vec::new(), edges: HashMap::new(), voted_pairs: HashSet::new(), + uuid_votes: HashMap::new(), recent_votes: Vec::new(), } } @@ -83,36 +84,78 @@ impl GroupState { *self.edges.entry((src, dst)).or_insert(0.0) += w; } - pub fn apply_vote(&mut self, mut vote: VoteData) { - vote.a = ItemId::from_storage(vote.a.as_str()).unwrap_or(vote.a.clone()); - vote.b = ItemId::from_storage(vote.b.as_str()).unwrap_or(vote.b.clone()); - if vote.ratio_left < 0 { - vote.ratio_left = 0; - } - if vote.ratio_right < 0 { - vote.ratio_right = 0; - } - if vote.ratio_left == 0 && vote.ratio_right == 0 { + fn subtract_edge_weight(&mut self, src: usize, dst: usize, w: f64) { + if w <= 0.0 { return; } - let a_idx = self.ensure_item(&vote.a); - let b_idx = self.ensure_item(&vote.b); + if let Some(entry) = self.edges.get_mut(&(src, dst)) { + *entry -= w; + if *entry <= 0.0 { + self.edges.remove(&(src, dst)); + } + } + } + fn apply_weights(&mut self, vote: &VoteData, a_idx: usize, b_idx: usize) { + let w_a = vote.ratio_left as f64 * vote.trust_weight; + let w_b = vote.ratio_right as f64 * vote.trust_weight; let (i, j) = if a_idx < b_idx { (a_idx, b_idx) } else { (b_idx, a_idx) }; - - let w_a = vote.ratio_left as f64; - let w_b = vote.ratio_right as f64; - self.voted_pairs.insert((i, j)); self.add_edge_weight(b_idx, a_idx, w_a); self.add_edge_weight(a_idx, b_idx, w_b); + } + fn rollback_weights(&mut self, vote: &VoteData) { + let a_idx = match self.item_to_idx.get(&vote.a) { + Some(&i) => i, + None => return, + }; + let b_idx = match self.item_to_idx.get(&vote.b) { + Some(&i) => i, + None => return, + }; + let w_a = vote.ratio_left as f64 * vote.trust_weight; + let w_b = vote.ratio_right as f64 * vote.trust_weight; + self.subtract_edge_weight(b_idx, a_idx, w_a); + self.subtract_edge_weight(a_idx, b_idx, w_b); + } + + /// Apply a validated vote, deduplicating by `actor_uuid` per unordered pair. + pub fn apply_vote(&mut self, vote: VoteData, actor_uuid: &str) { + let a_idx = self.ensure_item(&vote.a); + let b_idx = self.ensure_item(&vote.b); + let (i, j) = if a_idx < b_idx { + (a_idx, b_idx) + } else { + (b_idx, a_idx) + }; + + let dedupe_key = (actor_uuid.to_string(), i, j); + if let Some(old) = self.uuid_votes.get(&dedupe_key).cloned() { + self.rollback_weights(&old); + } + + self.apply_weights(&vote, a_idx, b_idx); + self.uuid_votes.insert(dedupe_key, vote.clone()); self.recent_votes.push(vote); } + + /// Rebuild edge weights from deduped uuid votes (load path — no rollback). + pub fn ingest_uuid_vote(&mut self, vote: VoteData, actor_uuid: &str) { + let a_idx = self.ensure_item(&vote.a); + let b_idx = self.ensure_item(&vote.b); + let (i, j) = if a_idx < b_idx { + (a_idx, b_idx) + } else { + (b_idx, a_idx) + }; + self.apply_weights(&vote, a_idx, b_idx); + self.uuid_votes.insert((actor_uuid.to_string(), i, j), vote); + } } /// Structured data imported from Reddit or elsewhere. @@ -192,14 +235,14 @@ impl GlobalTree { self.nodes.get(id) } - pub fn apply_vote(&mut self, parent: &ItemId, vote: VoteData) { + pub fn apply_vote(&mut self, parent: &ItemId, vote: VoteData, actor_uuid: &str) { self.ensure_path(parent); self.ensure_path(&vote.a); self.ensure_path(&vote.b); if let Some(node) = self.nodes.get_mut(parent) { node.children.insert(vote.a.clone()); node.children.insert(vote.b.clone()); - node.local_ranking.apply_vote(vote); + node.local_ranking.apply_vote(vote, actor_uuid); } } @@ -231,36 +274,62 @@ impl GlobalTree { } #[cfg(test)] -mod from_recorded_tests { +mod tests { use super::*; + fn vote(ts: i64, a: &str, b: &str, l: i32, r: i32, pseudonym: &str) -> VoteData { + VoteData { + ts, + a: ItemId::opaque(a), + b: ItemId::opaque(b), + ratio_left: l, + ratio_right: r, + pseudonym: pseudonym.to_string(), + trust_weight: 1.0, + } + } + #[test] - fn rejects_same_item() { - assert!(VoteData::from_recorded(1, "a", "a", 2, 1).is_none()); + fn from_event_rejects_same_item() { + assert!(VoteData::from_event( + 1, + "a", + "a", + 2, + 1, + "anon".into(), + 1.0 + ) + .is_none()); } #[test] - fn rejects_empty_pair() { - assert!(VoteData::from_recorded(1, "", "b", 2, 1).is_none()); + fn from_event_rejects_empty_pair() { + assert!(VoteData::from_event(1, "", "b", 2, 1, "anon".into(), 1.0).is_none()); + } + + #[test] + fn same_uuid_replaces_prior_vote_on_pair() { + let mut g = GroupState::new(); + let uuid = "u1"; + g.apply_vote(vote(1, "a", "b", 2, 1, "alice"), uuid); + let first_total: f64 = g.edges.values().sum(); + assert_eq!(first_total, 3.0); + + g.apply_vote(vote(2, "a", "b", 0, 1, "bob"), uuid); + let second_total: f64 = g.edges.values().sum(); + assert_eq!(second_total, 1.0); + assert_eq!(g.uuid_votes.len(), 1); } #[test] - fn zero_weight_vote_does_not_mark_pair_or_edges() { + fn different_uuids_both_count() { let mut g = GroupState::new(); - g.apply_vote(VoteData { - ts: 1, - a: ItemId::opaque("a"), - b: ItemId::opaque("b"), - ratio_left: 0, - ratio_right: 0, - body: String::new(), - principal: "test".to_string(), - delegate: None, - thread_tag: "untagged".to_string(), - }); - assert!(g.voted_pairs.is_empty()); - assert!(g.edges.is_empty()); - assert!(g.recent_votes.is_empty()); + g.apply_vote(vote(1, "a", "b", 2, 1, "alice"), "u1"); + g.apply_vote(vote(2, "a", "b", 0, 1, "bob"), "u2"); + let total: f64 = g.edges.values().sum(); + assert_eq!(total, 4.0); + assert_eq!(g.uuid_votes.len(), 2); } #[test] diff --git a/server/src/state.rs b/server/src/state.rs index 247b9047a57956f76c4b6bef691662101e62a8f9..8dabc93e95c39cbe18d69596dee79683b384ef86 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -9,7 +9,7 @@ use crate::{ projection_apply, projection_store::ProjectionStore, reddit::{RedditApiConfig, RedditBroker, REDDIT_CONTENT_TTL}, - reducer::{GlobalTree, VoteData}, + reducer::GlobalTree, view_log::ViewLog, views::ViewStore, }; @@ -234,23 +234,31 @@ impl AppState { if a_raw.is_empty() || b_raw.is_empty() || a_raw == b_raw { return Err("invalid vote: need two distinct non-empty items".to_string()); } - if ratio_left.max(0) == 0 && ratio_right.max(0) == 0 { + let left = ratio_left.max(0); + let right = ratio_right.max(0); + if left == 0 && right == 0 { return Err( "invalid vote: need a positive preference on at least one side".to_string(), ); } - // Validate items canonicalize (or are opaque keys) before append. - let _ = VoteData::from_recorded(ts, a_raw, b_raw, ratio_left, ratio_right) - .ok_or_else(|| "invalid vote: need two distinct parseable items".to_string())?; + let a_id = ItemId::from_storage(a_raw) + .or_else(|| ItemId::parse(a_raw)) + .ok_or_else(|| "invalid vote: unparseable item a".to_string())?; + let b_id = ItemId::from_storage(b_raw) + .or_else(|| ItemId::parse(b_raw)) + .ok_or_else(|| "invalid vote: unparseable item b".to_string())?; + if a_id == b_id { + return Err("invalid vote: need two distinct items".to_string()); + } - let event = Event::VoteRecorded { + let event = Event::vote_recorded( ts, - a: a_raw.to_string(), - b: b_raw.to_string(), - ratio_left, - ratio_right, - scope: parent.as_str().to_string(), - }; + a_id.as_str(), + b_id.as_str(), + left, + right, + parent.as_str(), + ); self.journal.append(event).await } @@ -333,17 +341,7 @@ mod tests { id: "https://reddit.com/r/rust".into(), }, ), - event_record( - 2, - Event::VoteRecorded { - ts: 2, - a: "alpha".into(), - b: "beta".into(), - ratio_left: 2, - ratio_right: 1, - scope: String::new(), - }, - ), + event_record(2, Event::vote_recorded(2, "alpha", "beta", 2, 1, "")), ]) .await .unwrap(); @@ -430,14 +428,7 @@ mod tests { let log = EventLog::new(log_path.to_string_lossy().into_owned()); log.append(&event_record( 1, - Event::VoteRecorded { - ts: 1, - a: "alpha".into(), - b: "beta".into(), - ratio_left: 2, - ratio_right: 1, - scope: String::new(), - }, + Event::vote_recorded(1, "alpha", "beta", 2, 1, ""), )) .await .unwrap(); @@ -601,14 +592,7 @@ mod tests { let log = EventLog::new(format!("{data_dir}/events.jsonl")); log.append(&event_record( 1, - Event::VoteRecorded { - ts: 1, - a: "alpha".into(), - b: "beta".into(), - ratio_left: 2, - ratio_right: 1, - scope: String::new(), - }, + Event::vote_recorded(1, "alpha", "beta", 2, 1, ""), )) .await .unwrap(); diff --git a/server/src/storage_dto.rs b/server/src/storage_dto.rs index 3fd6db5cb909ac4896bd8a3ecace796de5f08781..22f5ac498ae3a5db4347f3fe94830539c7c01e3e 100644 --- a/server/src/storage_dto.rs +++ b/server/src/storage_dto.rs @@ -1,9 +1,8 @@ //! Versioned leaf value DTOs persisted in durable collections. //! -//! Node structure (children, edges, voted pairs, recent votes) is no longer a -//! single blob — it lives as point-addressable durable collections (see -//! [`crate::storage_schema`]). This module only defines the small leaf values: -//! ephemeral entity views and individual votes. +//! Node structure (children, uuid votes, recent votes) lives as point-addressable +//! durable collections (see [`crate::storage_schema`]). This module defines the +//! small leaf values: ephemeral entity views and individual votes. use serde::{Deserialize, Serialize}; @@ -12,7 +11,7 @@ use crate::{ reducer::{EntityData, VoteData}, }; -pub const VOTE_RECORD_VERSION: u32 = 1; +pub const VOTE_RECORD_VERSION: u32 = 2; pub const ENTITY_DATA_VERSION: u32 = 1; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -39,7 +38,7 @@ pub struct StoredEntityDataV1 { pub link_url: Option, } -/// One vote stored in a node's `recent_votes` list. +/// One vote stored in a node's `recent_votes` list or `uuid_votes` map. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoredVoteV1 { pub version: u32, @@ -48,10 +47,8 @@ pub struct StoredVoteV1 { pub b: String, pub ratio_left: i32, pub ratio_right: i32, - pub body: String, - pub principal: String, - pub delegate: Option, - pub thread_tag: String, + pub pseudonym: String, + pub trust_weight: f64, } pub fn encode_entity_data(data: &EntityData) -> StoredEntityDataV1 { @@ -85,10 +82,8 @@ pub fn encode_vote(vote: &VoteData) -> StoredVoteV1 { b: vote.b.as_str().to_string(), ratio_left: vote.ratio_left, ratio_right: vote.ratio_right, - body: vote.body.clone(), - principal: vote.principal.clone(), - delegate: vote.delegate.clone(), - thread_tag: vote.thread_tag.clone(), + pseudonym: vote.pseudonym.clone(), + trust_weight: vote.trust_weight, } } @@ -99,10 +94,8 @@ pub fn decode_vote(vote: StoredVoteV1) -> Result { b: parse_stored_id(&vote.b)?, ratio_left: vote.ratio_left, ratio_right: vote.ratio_right, - body: vote.body, - principal: vote.principal, - delegate: vote.delegate, - thread_tag: vote.thread_tag, + pseudonym: vote.pseudonym, + trust_weight: vote.trust_weight, }) } diff --git a/server/src/storage_schema.rs b/server/src/storage_schema.rs index 5d2bb1d56927fb61c7c6d2d8602bd6882327f862..67c9f4ab2e1865f8da81b6735dd2a05b87e5e366 100644 --- a/server/src/storage_schema.rs +++ b/server/src/storage_schema.rs @@ -1,14 +1,13 @@ //! Durable schema for sorter2 — the projection laid out as point-addressable //! durable collections instead of one blob per node. //! -//! A vote updates a handful of keys: a few edge-weight merges, a voted-pair flag, -//! a recent-vote list append, and child-link set entries. The in-memory -//! [`crate::reducer::GroupState`] is reconstructed from these keys on read for -//! rank-centrality. +//! Votes are stored as deduped `uuid_votes` entries plus an append-only +//! `recent_votes` audit list. Edge weights for rank centrality are derived +//! from `uuid_votes` on read, not incrementally merged in RocksDB. -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{HashSet}; -use durable::{Batch, Db, Durable, Leaf, List, Map, Sum}; +use durable::{Batch, Db, Durable, Leaf, List, Map}; use crate::{ path_types::ItemId, @@ -19,10 +18,8 @@ use crate::{ }, }; -/// Directed edge key `(from_id, to_id)`. -pub type EdgeKey = (String, String); -/// Unordered voted-pair key, stored canonically as `(min, max)` by string. -pub type PairKey = (String, String); +/// `(actor_uuid, min_item_id, max_item_id)` — one vote slot per human per pair. +pub type UuidVoteKey = (String, String, String); /// One node in the fractal tree, exploded into precisely-updatable collections. #[derive(Durable)] @@ -34,22 +31,21 @@ pub struct NodeSchema { pub data: Leaf, /// Child ids (a set; value is always `true`). pub children: Map>, - /// Directed edge weights `(from, to) -> weight`, updated by blind merges. - pub edges: Map>, - /// Voted pairs `(min, max) -> true`. - pub voted_pairs: Map>, + /// Latest vote per actor per unordered pair; edges are derived from this on read. + pub uuid_votes: Map>, /// Recent votes, append-only oldest-first (cap applied on read). pub recent_votes: List>, /// When ephemeral Reddit display content was last fetched (ms); absent after eviction. pub fetched_at: Leaf, } -/// The single database root: nodes, view counts, and per-concern metadata maps -/// (cursors and schema versions). +/// The single database root: nodes, identity maps, view counts, and metadata. #[derive(Durable)] #[allow(dead_code)] pub struct Store { pub nodes: Map, + /// Global pseudonym → actor UUID (Sybil dedup anchor). + pub pseudonyms: Map>, pub proj_meta: Map>, pub view_counts: Map>, pub view_meta: Map>, @@ -62,6 +58,21 @@ fn id_key(id: &ItemId) -> String { id.as_str().to_string() } +fn pair_keys(a: &ItemId, b: &ItemId) -> (String, String) { + let ak = id_key(a); + let bk = id_key(b); + if ak <= bk { + (ak, bk) + } else { + (bk, ak) + } +} + +pub fn uuid_vote_key(actor_uuid: &str, a: &ItemId, b: &ItemId) -> UuidVoteKey { + let (lo, hi) = pair_keys(a, b); + (actor_uuid.to_string(), lo, hi) +} + /// Path to a node by id. pub fn node(id: &ItemId) -> durable::Path { Store::root().nodes().key(&id_key(id)) @@ -77,16 +88,10 @@ pub fn load_node_state(db: &Db, id: &ItemId) -> durable::Result durable::Result durable::Result, - voted: Vec, - edges_raw: Vec<(EdgeKey, f64)>, -) -> durable::Result { - // Item universe = every endpoint that appears in a voted pair or an edge. - let mut item_strs: BTreeSet = BTreeSet::new(); - for (a, b) in &voted { - item_strs.insert(a.clone()); - item_strs.insert(b.clone()); - } - for ((a, b), _) in &edges_raw { - item_strs.insert(a.clone()); - item_strs.insert(b.clone()); - } - - let mut idx_to_item: Vec = Vec::with_capacity(item_strs.len()); - let mut item_to_idx: HashMap = HashMap::with_capacity(item_strs.len()); - let mut str_to_idx: HashMap = HashMap::with_capacity(item_strs.len()); - for s in item_strs { - let id = parse_storage_id(&s)?; - let idx = idx_to_item.len(); - str_to_idx.insert(s, idx); - item_to_idx.insert(id.clone(), idx); - idx_to_item.push(id); - } +fn build_group_state(db: &Db, np: &durable::Path) -> durable::Result { + let mut group = GroupState::new(); - let mut edges: HashMap<(usize, usize), f64> = HashMap::with_capacity(edges_raw.len()); - for ((a, b), w) in edges_raw { - if let (Some(&ai), Some(&bi)) = (str_to_idx.get(&a), str_to_idx.get(&b)) { - edges.insert((ai, bi), w); - } - } - - let mut voted_pairs: HashSet<(usize, usize)> = HashSet::with_capacity(voted.len()); - for (a, b) in voted { - if let (Some(&ai), Some(&bi)) = (str_to_idx.get(&a), str_to_idx.get(&b)) { - let (i, j) = if ai < bi { (ai, bi) } else { (bi, ai) }; - voted_pairs.insert((i, j)); - } + for (key, stored) in np.uuid_votes().iter(db)? { + let (actor_uuid, _lo, _hi) = key; + let vote = decode_vote(stored).map_err(durable::Error::Deserialize)?; + group.ingest_uuid_vote(vote, &actor_uuid); } - // List is index order (oldest first); keep the newest RECENT_VOTES_CAP entries. let stored = np.recent_votes().iter(db)?; let cap = RECENT_VOTES_CAP as usize; let start = stored.len().saturating_sub(cap); - let recent_votes = stored[start..] + group.recent_votes = stored[start..] .iter() .map(|s| decode_vote(s.clone()).map_err(durable::Error::Deserialize)) .collect::, _>>()?; - Ok(GroupState { - item_to_idx, - idx_to_item, - edges, - voted_pairs, - recent_votes, - }) + Ok(group) } fn parse_storage_id(s: &str) -> durable::Result { @@ -197,73 +161,24 @@ pub fn ensure_path_writes(batch: &mut Batch, id: &ItemId) { } } -/// Reified writes for a recorded vote under `parent`. Mirrors -/// [`crate::reducer::GroupState::apply_vote`] as point updates. +/// Reified writes for a validated vote under `parent`. pub fn vote_writes( batch: &mut Batch, parent: &ItemId, - a: &str, - b: &str, - ratio_left: i32, - ratio_right: i32, - ts: i64, + vote: &VoteData, + actor_uuid: &str, ) -> durable::Result<()> { - let left = ratio_left.max(0); - let right = ratio_right.max(0); - if left == 0 && right == 0 { - return Ok(()); - } - - let a_id = ItemId::from_storage(a).unwrap_or_else(|| ItemId::opaque(a)); - let b_id = ItemId::from_storage(b).unwrap_or_else(|| ItemId::opaque(b)); - ensure_path_writes(batch, parent); - ensure_path_writes(batch, &a_id); - ensure_path_writes(batch, &b_id); + ensure_path_writes(batch, &vote.a); + ensure_path_writes(batch, &vote.b); let pnode = node(parent); - batch.write(pnode.children().key(&id_key(&a_id)).set(&true)); - batch.write(pnode.children().key(&id_key(&b_id)).set(&true)); - - // Edge weights: edge (b,a) += left, edge (a,b) += right (positive only). - if left > 0 { - batch.write( - pnode - .edges() - .key(&(id_key(&b_id), id_key(&a_id))) - .add(left as f64), - ); - } - if right > 0 { - batch.write( - pnode - .edges() - .key(&(id_key(&a_id), id_key(&b_id))) - .add(right as f64), - ); - } + batch.write(pnode.children().key(&id_key(&vote.a)).set(&true)); + batch.write(pnode.children().key(&id_key(&vote.b)).set(&true)); - // Voted pair, canonicalized. - let (lo, hi) = if id_key(&a_id) <= id_key(&b_id) { - (id_key(&a_id), id_key(&b_id)) - } else { - (id_key(&b_id), id_key(&a_id)) - }; - batch.write(pnode.voted_pairs().key(&(lo, hi)).set(&true)); - - // Recent votes (append-only; cap on read). - let stored = encode_vote(&VoteData { - ts, - a: a_id, - b: b_id, - ratio_left: left, - ratio_right: right, - body: String::new(), - principal: "web".to_string(), - delegate: None, - thread_tag: "default".to_string(), - }); - batch.push(&pnode.recent_votes(), &stored)?; + let key = uuid_vote_key(actor_uuid, &vote.a, &vote.b); + batch.write(pnode.uuid_votes().key(&key).set(&encode_vote(vote))); + batch.push(&pnode.recent_votes(), &encode_vote(vote))?; Ok(()) } @@ -283,15 +198,30 @@ pub fn entity_content_clear_writes(batch: &mut Batch, id: &ItemId) { #[cfg(test)] mod tests { use super::*; + use crate::identity::{seed_default_pseudonym, DEFAULT_ACTOR_UUID, DEFAULT_PSEUDONYM}; + + fn sample_vote(ts: i64, a: &str, b: &str, l: i32, r: i32) -> VoteData { + VoteData { + ts, + a: ItemId::opaque(a), + b: ItemId::opaque(b), + ratio_left: l, + ratio_right: r, + pseudonym: DEFAULT_PSEUDONYM.to_string(), + trust_weight: 1.0, + } + } #[test] fn vote_roundtrip_reconstructs_group_state() { let dir = tempfile::tempdir().unwrap(); let db = Db::open(dir.path()).unwrap(); + seed_default_pseudonym(&db).unwrap(); let parent = ItemId::root(); + let vote = sample_vote(1, "alpha", "beta", 2, 1); let mut batch = db.batch(); - vote_writes(&mut batch, &parent, "alpha", "beta", 2, 1, 1).unwrap(); + vote_writes(&mut batch, &parent, &vote, DEFAULT_ACTOR_UUID).unwrap(); batch.commit().unwrap(); let node_state = load_node_state(&db, &parent).unwrap().unwrap(); @@ -305,16 +235,38 @@ mod tests { } #[test] - fn zero_weight_vote_writes_nothing() { + fn uuid_vote_replace_updates_edges() { let dir = tempfile::tempdir().unwrap(); let db = Db::open(dir.path()).unwrap(); let parent = ItemId::root(); + let uuid = "u1"; let mut batch = db.batch(); - vote_writes(&mut batch, &parent, "alpha", "beta", 0, 0, 1).unwrap(); + vote_writes( + &mut batch, + &parent, + &sample_vote(1, "alpha", "beta", 2, 1), + uuid, + ) + .unwrap(); + vote_writes( + &mut batch, + &parent, + &VoteData { + pseudonym: "alias2".into(), + ratio_left: 0, + ratio_right: 1, + ..sample_vote(2, "alpha", "beta", 0, 1) + }, + uuid, + ) + .unwrap(); batch.commit().unwrap(); - assert!(load_node_state(&db, &parent).unwrap().is_none()); + let g = &load_node_state(&db, &parent).unwrap().unwrap().local_ranking; + let edge_total: f64 = g.edges.values().sum(); + assert_eq!(edge_total, 1.0); + assert_eq!(g.uuid_votes.len(), 1); } #[test] @@ -325,7 +277,13 @@ mod tests { let mut batch = db.batch(); for i in 0..RECENT_VOTES_CAP + 10 { - vote_writes(&mut batch, &parent, "alpha", "beta", 1, 0, i as i64).unwrap(); + vote_writes( + &mut batch, + &parent, + &sample_vote(i as i64, "alpha", "beta", 1, 0), + "u1", + ) + .unwrap(); } batch.commit().unwrap(); @@ -337,13 +295,13 @@ mod tests { let node_state = load_node_state(&db, &parent).unwrap().unwrap(); assert_eq!(node_state.local_ranking.recent_votes.len(), RECENT_VOTES_CAP as usize); assert_eq!( - node_state.local_ranking.recent_votes.first().map(|v| v.ts), + node_state + .local_ranking + .recent_votes + .first() + .map(|v| v.ts), Some(10) ); - assert_eq!( - node_state.local_ranking.recent_votes.last().map(|v| v.ts), - Some(RECENT_VOTES_CAP as i64 + 9) - ); } #[test] Side B — contributor: tommy-mor Side B — commit message: [e8c85249] Fix GitHub resolver and vote compare flow (#149) * Fix GitHub resolver and vote compare flow Co-authored-by: tommy * Fix vote compare next pair helper Co-authored-by: tommy * Extend GitHub resolver and vote pair updates Co-authored-by: tommy * Fix resolver browser refresh coverage Co-authored-by: tommy * Stabilize GitHub resolver browser test Co-authored-by: tommy --------- Co-authored-by: Cursor Agent Side B — unified diff (full patch): diff --git a/agents.md b/agents.md index c66f33789441eea193b4354fce4c03b7fffdd639..7508234d9b04223d0e64cfe69fedbebd06a256b5 100644 --- a/agents.md +++ b/agents.md @@ -36,12 +36,18 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma - **`HtmlUiAction` / `POST /ui`** (`server/src/html/ui_action.rs`, `server/src/api/ui_html.rs`): **Browser session** (cookie) UI commands. Payload is `__rpc__` + form fields. Most responses are **JS morphs**; some actions return **HTTP redirects** (see below). +- **Do not add one-off POST routes** for browser mutations. New browser actions belong in **`HtmlUiAction`** behind **`POST /ui`**; new programmatic verbs belong in **`RpcCommand`** behind **`POST /api/v0/rpc`**. Ordinary shareable pages remain normal **`GET`** routes. + - **Non-morph `POST /ui` responses:** **`SetGardenPin`** returns **`303 See Other`** and **`Set-Cookie`** (same as **`POST /theme`**). Garden pin/unpin is a normal **`
`** — browser navigation applies cookies reliably (see **`test/browser_garden_pin.clj`**). Each **`__rpc__`** payload includes **`form_action: "/ui"`**; **`post_ui_html`** rejects mismatches to bind tokens to the UI endpoint. -- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-compare-preview`** (new ingest card), **`#vote-edge-history-region`** (recomputed **`
    `** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer). The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: "/ui"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes. +- **`VoteComparePost`:** On success returns **`text/javascript`** that **morphs** **`#vote-edge-history-region`** (recomputed **`
      `** — ratios match **`left`/`right`** query order, bullets, sorted by strength toward **`left`** then newer) and **`.vote-compare-nav`** (fresh next-pair link). The compare **`GET`** page uses **`layout_full_bleed_chromeless`** (no breadcrumbs, no **`#controls`**, no **`slug-pin-hud`**; **`view-vote-compare-fullscreen`** full-width **`body`**). **`__rpc__`** carries **`form_action: "/ui"`**; **`thread_tag`** and ratio fields come from the same form as **`$form`** holes. + +- **`ResolveExternal`:** GitHub resolver buttons are browser actions through **`POST /ui`**. Success responses morph **`#external-resolver-status`** then redirect to the sanitized shareable **`GET`** page so imported children render through the normal page path; errors morph the same status region. Resolver results are durable system ingests, while cooldown state is RAM-only. - **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`** (fullscreen **`GET`** page: no HUD; other garden pages). HUD (**`#slug-pin-hud`**): only when **`layout`** passes garden metadata on **`body`**; the label is **`POST /ui`** **`set_garden_pin`** **`clear:true`** (**`slug_ui.js`**), not a permalink to the item. +- **Browser auth redirects:** `/login`, `/join/:token`, `/auth/login`, and `/auth/choose-username` may carry **`next`** (or legacy **`redirect`**) as a **safe local path only**. The value is stored on the RAM-only pending session and applied after OAuth / username selection. + **Rule of thumb:** New **CLI or API** verbs → `RpcCommand`. New **in-page morph or form-driven** behavior that only makes sense in the browser → `HtmlUiAction`. If both need the same operation, implement the real work once (e.g. call shared RPC helpers from `post_ui_html`) and keep the wire shapes separate. --- diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs index c9c4082f251838b5ac46de7ce48392c136883d55..88bb3335e4873643530351266d213f258da5893e 100644 --- a/server/src/api/auth.rs +++ b/server/src/api/auth.rs @@ -8,7 +8,10 @@ use axum::{ use axum_extra::extract::cookie::CookieJar; use base64::Engine; use serde::Deserialize; -use slug_types::{PendingSessionPollResponse, PendingSessionStartRequest, PendingSessionStartResponse, WhoamiResponse}; +use slug_types::{ + PendingSessionPollResponse, PendingSessionStartRequest, PendingSessionStartResponse, + WhoamiResponse, +}; use std::{collections::HashMap, sync::Arc}; use tokio::sync::{oneshot, RwLock}; @@ -17,7 +20,8 @@ use crate::{ events::{Event, TokenIssued}, html::{ auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment, - choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri, JsBuilder, + choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri, + JsBuilder, }, identity::{parse_agent, parse_username}, reducer::ReducerState, @@ -36,12 +40,26 @@ pub const SLUG_SESSION_COOKIE: &str = "slug_session"; /// `Set-Cookie` header value (full attribute string). pub fn session_cookie_header_value(bearer: &str) -> HeaderValue { - let s = format!( - "{SLUG_SESSION_COOKIE}={bearer}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000" - ); + let s = + format!("{SLUG_SESSION_COOKIE}={bearer}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"); HeaderValue::from_str(&s).expect("session cookie value must be ASCII") } +fn safe_local_redirect(raw: Option<&str>) -> Option { + let s = raw?.trim(); + if s.starts_with('/') && !s.starts_with("//") && s.len() < 8192 { + Some(s.to_string()) + } else { + None + } +} + +fn redirect_query(next: Option<&str>) -> String { + safe_local_redirect(next) + .map(|n| format!("&next={}", urlencoding::encode(&n))) + .unwrap_or_default() +} + fn js_form_error_fragment(session: &str, error: &str) -> Response { JsBuilder::new() .id("choose-username-form") @@ -49,11 +67,11 @@ fn js_form_error_fragment(session: &str, error: &str) -> Response { .into_response() } -fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response { +fn js_signed_in_fragment(bearer: &str, jar: &CookieJar, redirect_to: &str) -> Response { let mut response = JsBuilder::new() .id("choose-username-form") .morph_inner(auth_signed_in_fragment()) - .redirect("/auth/complete") + .redirect(redirect_to) .into_response(); let headers = response.headers_mut(); headers.append(header::SET_COOKIE, session_cookie_header_value(bearer)); @@ -64,7 +82,11 @@ fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response { } /// Resolve the signed-in username from `Authorization: Bearer` or `slug_session` cookie. -pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option { +pub fn optional_principal( + headers: &HeaderMap, + jar: &CookieJar, + reduced: &ReducerState, +) -> Option { if let Ok(u) = verify_bearer_principal(headers, reduced) { return Some(u); } @@ -80,7 +102,11 @@ pub struct WebSession { } /// Resolve username and bearer together for `POST /ui` dispatch (one read of headers + jar). -pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &ReducerState) -> Option { +pub fn resolve_web_session( + headers: &HeaderMap, + jar: &CookieJar, + reduced: &ReducerState, +) -> Option { let username = optional_principal(headers, jar, reduced)?; let bearer = headers .get(header::AUTHORIZATION) @@ -90,7 +116,12 @@ pub fn resolve_web_session(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduc Some(WebSession { username, bearer }) } -fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str, jar: &CookieJar) -> Response { +fn redirect_with_session_cookie( + public_url: &str, + path_and_query: &str, + bearer: &str, + jar: &CookieJar, +) -> Response { let mut res = Response::builder() .status(StatusCode::TEMPORARY_REDIRECT) .header(header::LOCATION, format!("{public_url}{path_and_query}")) @@ -112,21 +143,32 @@ fn pending_sessions(state: &AppState) -> Arc Option { let payload_b64 = jwt.split('.').nth(1)?; - let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64).ok()?; + let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .ok()?; let v: serde_json::Value = serde_json::from_slice(&decoded).ok()?; v.get("sub")?.as_str().map(|s| s.to_string()) } pub(crate) fn parse_bearer(headers: &HeaderMap) -> Result { let Some(value) = headers.get(axum::http::header::AUTHORIZATION) else { - return Err((StatusCode::UNAUTHORIZED, "missing Authorization header".to_string())); + return Err(( + StatusCode::UNAUTHORIZED, + "missing Authorization header".to_string(), + )); }; let Ok(s) = value.to_str() else { - return Err((StatusCode::UNAUTHORIZED, "invalid Authorization header".to_string())); + return Err(( + StatusCode::UNAUTHORIZED, + "invalid Authorization header".to_string(), + )); }; let s = s.trim(); let Some(rest) = s.strip_prefix("Bearer ") else { - return Err((StatusCode::UNAUTHORIZED, "Authorization must be Bearer".to_string())); + return Err(( + StatusCode::UNAUTHORIZED, + "Authorization must be Bearer".to_string(), + )); }; Ok(rest.trim().to_string()) } @@ -140,7 +182,10 @@ pub fn verify_bearer_principal( verify_token(reduced, &bearer) } -pub(crate) fn verify_token(reduced: &crate::reducer::ReducerState, bearer: &str) -> Result { +pub(crate) fn verify_token( + reduced: &crate::reducer::ReducerState, + bearer: &str, +) -> Result { // slug__ let Some(rest) = bearer.strip_prefix("slug_") else { return Err((StatusCode::UNAUTHORIZED, "invalid token format".to_string())); @@ -199,9 +244,25 @@ pub(crate) fn issue_token_for_user(stored_username: &str) -> (String, TokenIssue #[derive(Debug, Deserialize)] pub struct AuthLoginQuery { pub session: String, + #[serde(default)] + pub next: Option, + #[serde(default)] + pub redirect: Option, } -pub async fn get_join_invite(Path(token): Path, State(state): State) -> impl IntoResponse { +#[derive(Debug, Deserialize)] +pub struct JoinInviteQuery { + #[serde(default)] + pub next: Option, + #[serde(default)] + pub redirect: Option, +} + +pub async fn get_join_invite( + Path(token): Path, + Query(q): Query, + State(state): State, +) -> impl IntoResponse { let token = token.trim().to_string(); if token.is_empty() { return api_error(StatusCode::NOT_FOUND, "invite invalid or expired", None).into_response(); @@ -219,37 +280,57 @@ pub async fn get_join_invite(Path(token): Path, State(state): State, State(state): State) -> impl IntoResponse { +pub async fn get_auth_login( + Query(q): Query, + State(state): State, +) -> impl IntoResponse { // Redirect to Google auth endpoint. let sessions = pending_sessions(&state); - let sessions_read = sessions.read().await; - let Some(_s) = sessions_read.get(&q.session) else { - return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response(); - }; - drop(sessions_read); + { + let mut sessions_write = sessions.write().await; + let Some(s) = sessions_write.get_mut(&q.session) else { + return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response(); + }; + if let Some(next) = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref())) { + s.redirect_next = Some(next); + } + } let auth_url_base = std::env::var("SLUG_GOOGLE_AUTH_URL") .unwrap_or_else(|_| "https://accounts.google.com/o/oauth2/v2/auth".to_string()); let client_id = std::env::var("SLUG_GOOGLE_CLIENT_ID").unwrap_or_else(|_| "dev".to_string()); - let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); + let public_url = + std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); let redirect_uri = format!("{public_url}/auth/callback"); let auth_url = format!( "{auth_url_base}?client_id={}&redirect_uri={}&response_type=code&scope=openid%20email&state={}", @@ -282,8 +363,10 @@ pub async fn get_auth_callback( let token_url = std::env::var("SLUG_GOOGLE_TOKEN_URL") .unwrap_or_else(|_| "https://oauth2.googleapis.com/token".to_string()); let client_id = std::env::var("SLUG_GOOGLE_CLIENT_ID").unwrap_or_else(|_| "dev".to_string()); - let client_secret = std::env::var("SLUG_GOOGLE_CLIENT_SECRET").unwrap_or_else(|_| "dev".to_string()); - let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); + let client_secret = + std::env::var("SLUG_GOOGLE_CLIENT_SECRET").unwrap_or_else(|_| "dev".to_string()); + let public_url = + std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); let redirect_uri = format!("{public_url}/auth/callback"); // Exchange code for id_token + access_token. @@ -306,16 +389,37 @@ pub async fn get_auth_callback( { Ok(resp) => match resp.json().await { Ok(v) => v, - Err(err) => return api_error(StatusCode::BAD_GATEWAY, "oauth token exchange failed", Some(format!("{err}"))).into_response(), + Err(err) => { + return api_error( + StatusCode::BAD_GATEWAY, + "oauth token exchange failed", + Some(format!("{err}")), + ) + .into_response() + } }, - Err(err) => return api_error(StatusCode::BAD_GATEWAY, "oauth token exchange failed", Some(format!("{err}"))).into_response(), + Err(err) => { + return api_error( + StatusCode::BAD_GATEWAY, + "oauth token exchange failed", + Some(format!("{err}")), + ) + .into_response() + } }; // Extract sub from the id_token JWT payload (base64-decode middle segment). // The token arrived directly from Google over TLS — no need for an extra userinfo roundtrip. let sub = match extract_jwt_sub(&tr.id_token) { Some(s) => s, - None => return api_error(StatusCode::BAD_GATEWAY, "oauth: could not extract sub from id_token", None).into_response(), + None => { + return api_error( + StatusCode::BAD_GATEWAY, + "oauth: could not extract sub from id_token", + None, + ) + .into_response() + } }; // If user exists, issue token and complete session. Otherwise redirect to choose-username. @@ -327,7 +431,9 @@ pub async fn get_auth_callback( { let mut sessions_write = sessions.write().await; - let s = sessions_write.get_mut(&q.state).expect("session checked above"); + let s = sessions_write + .get_mut(&q.state) + .expect("session checked above"); s.provider = Some("google".to_string()); s.provider_id = Some(sub.clone()); if let Some(username) = existing { @@ -345,31 +451,61 @@ pub async fn get_auth_callback( .await .is_err() { - return api_error(StatusCode::INTERNAL_SERVER_ERROR, "writer unavailable", None).into_response(); + return api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "writer unavailable", + None, + ) + .into_response(); } match rx.await { Err(_) => { - return api_error(StatusCode::INTERNAL_SERVER_ERROR, "writer dropped", None).into_response(); + return api_error(StatusCode::INTERNAL_SERVER_ERROR, "writer dropped", None) + .into_response(); } Ok(Err(err)) => { - return api_error(StatusCode::INTERNAL_SERVER_ERROR, "failed to persist token", Some(err)) - .into_response(); + return api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "failed to persist token", + Some(err), + ) + .into_response(); } Ok(Ok(())) => {} } + let redirect_to = + safe_local_redirect(s.redirect_next.as_deref()).unwrap_or_else(|| "/".to_string()); let cookie_bearer = bearer.clone(); s.complete = Some((username, bearer)); - return redirect_with_session_cookie(&public_url, "/", &cookie_bearer, &jar).into_response(); + return redirect_with_session_cookie(&public_url, &redirect_to, &cookie_bearer, &jar) + .into_response(); } } - Redirect::temporary(&format!("{public_url}/auth/choose-username?session={}", q.state)).into_response() + let next_q = { + let sessions_read = sessions.read().await; + sessions_read + .get(&q.state) + .and_then(|s| s.redirect_next.as_deref()) + .map(|n| redirect_query(Some(n))) + .unwrap_or_default() + }; + Redirect::temporary(&format!( + "{public_url}/auth/choose-username?session={}{}", + urlencoding::encode(&q.state), + next_q + )) + .into_response() } #[derive(Debug, Deserialize)] pub struct ChooseUsernameQuery { pub session: String, pub error: Option, + #[serde(default)] + pub next: Option, + #[serde(default)] + pub redirect: Option, } pub async fn get_choose_username( @@ -379,13 +515,18 @@ pub async fn get_choose_username( uri: Uri, ) -> impl IntoResponse { let sessions = pending_sessions(&state); - let sessions_read = sessions.read().await; - if !sessions_read.contains_key(&q.session) { - return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response(); + { + let mut sessions_write = sessions.write().await; + let Some(s) = sessions_write.get_mut(&q.session) else { + return api_error(StatusCode::NOT_FOUND, "unknown session", None).into_response(); + }; + if let Some(next) = safe_local_redirect(q.next.as_deref().or(q.redirect.as_deref())) { + s.redirect_next = Some(next); + } } - drop(sessions_read); let next = theme_next_from_uri(&uri); - choose_username_page(&q.session, q.error.as_deref(), theme_from_jar(&jar), &next).into_response() + choose_username_page(&q.session, q.error.as_deref(), theme_from_jar(&jar), &next) + .into_response() } #[derive(Debug, Deserialize)] @@ -401,7 +542,10 @@ pub async fn post_choose_username( ) -> impl IntoResponse { let canon_user = match parse_username(&form.username) { Ok(u) => u, - Err(msg) => return js_form_error_fragment(&form.session, &format!("invalid username — {msg}")).into_response(), + Err(msg) => { + return js_form_error_fragment(&form.session, &format!("invalid username — {msg}")) + .into_response() + } }; let sessions = pending_sessions(&state); @@ -420,12 +564,15 @@ pub async fn post_choose_username( }; if let Err(msg) = parse_agent(&agent) { - return js_form_error_fragment(&form.session, &format!("invalid agent format — {msg}")).into_response(); + 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.get(&form.session).and_then(|s| s.redeem_invite.clone()) + sessions_read + .get(&form.session) + .and_then(|s| s.redeem_invite.clone()) }; let (tx, rx) = oneshot::channel(); @@ -441,12 +588,18 @@ pub async fn post_choose_username( .await .is_err() { - return api_error(StatusCode::INTERNAL_SERVER_ERROR, "writer unavailable", None).into_response(); + return api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "writer unavailable", + None, + ) + .into_response(); } let bearer = match rx.await { Err(_) => { - return api_error(StatusCode::INTERNAL_SERVER_ERROR, "writer dropped", None).into_response(); + return api_error(StatusCode::INTERNAL_SERVER_ERROR, "writer dropped", None) + .into_response(); } Ok(Err(msg)) => { return js_form_error_fragment(&form.session, &msg).into_response(); @@ -454,31 +607,59 @@ pub async fn post_choose_username( Ok(Ok(b)) => b, }; - { + let redirect_to = { let mut sessions_write = sessions.write().await; - let s = sessions_write.get_mut(&form.session).expect("session checked above"); + let s = sessions_write + .get_mut(&form.session) + .expect("session checked above"); s.complete = Some((canon_user.clone(), bearer.clone())); - } + safe_local_redirect(s.redirect_next.as_deref()) + .unwrap_or_else(|| "/auth/complete".to_string()) + }; - js_signed_in_fragment(&bearer, &jar).into_response() + js_signed_in_fragment(&bearer, &jar, &redirect_to).into_response() } /// Start a browser-only OAuth flow (no CLI polling). Sets session cookie on success. -pub async fn get_web_login(State(state): State) -> impl IntoResponse { +#[derive(Debug, Deserialize)] +pub struct WebLoginQuery { + #[serde(default)] + pub next: Option, + #[serde(default)] + pub redirect: Option, +} + +pub async fn get_web_login( + Query(q): Query, + State(state): State, +) -> impl IntoResponse { let session = format!("p_{}", uuid::Uuid::new_v4().simple()); + 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(), created_ts: now_ms(), provider: None, provider_id: None, redeem_invite: None, + redirect_next: redirect_next.clone(), complete: None, }; - state.pending_sessions.write().await.insert(session.clone(), s); - let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); + state + .pending_sessions + .write() + .await + .insert(session.clone(), s); + let public_url = + std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); + let next_q = redirect_next + .as_deref() + .map(|n| redirect_query(Some(n))) + .unwrap_or_default(); Redirect::temporary(&format!( - "{public_url}/auth/login?session={}", - urlencoding::encode(&session) + "{public_url}/auth/login?session={}{}", + urlencoding::encode(&session), + next_q )) .into_response() } @@ -504,12 +685,17 @@ pub async fn post_pending_session( let agent_naked = match parse_agent(&req.agent) { Ok(a) => a, Err(msg) => { - return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)).into_response(); + return api_error(StatusCode::BAD_REQUEST, "invalid agent format", Some(msg)) + .into_response(); } }; let session = format!("p_{}", uuid::Uuid::new_v4().simple()); - let public_url = std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); - let login_url = format!("{public_url}/auth/login?session={}", urlencoding::encode(&session)); + let public_url = + std::env::var("SLUG_PUBLIC_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); + let login_url = format!( + "{public_url}/auth/login?session={}", + urlencoding::encode(&session) + ); let poll_url = format!("/api/v0/pending-session/{}", session); let s = PendingSession { agent: agent_naked, @@ -517,6 +703,7 @@ pub async fn post_pending_session( provider: None, provider_id: None, redeem_invite: None, + redirect_next: None, complete: None, }; let sessions = pending_sessions(&state); @@ -567,11 +754,14 @@ pub async fn get_whoami(State(state): State, headers: HeaderMap) -> im Ok(u) => u, Err((st, msg)) => return api_error(st, msg, None).into_response(), }; - let agents_bound = reduced.agent_bindings.values().filter(|u| *u == &username).count(); + let agents_bound = reduced + .agent_bindings + .values() + .filter(|u| *u == &username) + .count(); Json(WhoamiResponse { user: username, agents_bound, }) .into_response() } - diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs index 336d54bfad5037e9d87437400747260d160bee2a..146792b9ec33d539ce8c6b106768162805dbb3f9 100644 --- a/server/src/api/rpc.rs +++ b/server/src/api/rpc.rs @@ -20,6 +20,7 @@ use crate::{ path_types::ItemId, ranking::{connected_components_from_voted_pairs, ranked_items_subset}, reducer::{scope_from_room_wire, ReducerState, ScopeId}, + scope_rank::suggest_next_pair_in_pool, state::{AppState, InviteState}, write_cmd::WriteCmd, }; @@ -759,7 +760,8 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re } } } - pick.or_else(|| pick_random_distinct_item_pair(&pool)) + pick.or_else(|| suggest_next_pair_in_pool(group, &pool, None)) + .or_else(|| pick_random_distinct_item_pair(&pool)) } }; let Some((left, right)) = selected else { diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs index cd501ba6d4d656eabad03afed4583efdf885695d..4b0214d18b173cd506d09176104f461dc4c4f208 100644 --- a/server/src/api/ui_html.rs +++ b/server/src/api/ui_html.rs @@ -21,11 +21,11 @@ use crate::{ external_resolver::resolve_github_children, html::vote_compare_post_success_js, html::{ - fragment_new_thread_slot, login_to_post_hint_markup, parse_html_ui_from_form, - room_members_section_markup, thread_feed_html, thread_feed_html_for_room, - thread_feed_region_markup, thread_ui_collapse_redacted_post, thread_ui_expand_post_full, - thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room, user_can_view_room, - HtmlUiAction, JsBuilder, ThreadNav, + external_resolver_status_markup, fragment_new_thread_slot, login_to_post_hint_markup, + parse_html_ui_from_form, room_members_section_markup, thread_feed_html, + thread_feed_html_for_room, thread_feed_region_markup, thread_ui_collapse_redacted_post, + thread_ui_expand_post_full, thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room, + user_can_view_room, HtmlUiAction, JsBuilder, ThreadNav, }, reducer::{scope_from_room_wire, ScopeId}, state::AppState, @@ -68,6 +68,13 @@ fn sanitize_garden_pin_next(next: &str) -> String { } } +fn login_redirect_for(next: &str) -> String { + format!( + "/login?next={}", + urlencoding::encode(&sanitize_garden_pin_next(next)) + ) +} + fn redirect_with_pin_cookie(cookie_header_value: &str, location: &str) -> Response { let loc = HeaderValue::try_from(location).unwrap_or_else(|_| HeaderValue::from_static("/")); let hv = HeaderValue::try_from(cookie_header_value).expect("set-cookie header"); @@ -194,7 +201,7 @@ async fn dispatch_ui_action( .into_response(); } let Some(session) = session else { - return js_redirect("/login").into_response(); + return js_redirect(&login_redirect_for(&next)).into_response(); }; let err_tgt = Some("vote-compare-errors".to_string()); let room = room.trim().to_string(); @@ -363,7 +370,7 @@ async fn dispatch_ui_action( .into_response(); } let Some(session) = session else { - return js_redirect("/login").into_response(); + return js_redirect(&login_redirect_for(&next)).into_response(); }; let room = room_wire.trim(); if room.is_empty() { @@ -390,8 +397,19 @@ async fn dispatch_ui_action( item.normalized_storage() }; match resolve_github_children(state, room, &target).await { - Ok(_) => js_redirect(&sanitize_garden_pin_next(&next)).into_response(), - Err(msg) => ui_js_warn(&msg).into_response(), + Ok(n) => JsBuilder::new() + .morph_inner_selector( + "#external-resolver-status", + external_resolver_status_markup(Ok(n), &sanitize_garden_pin_next(&next)), + ) + .redirect(&sanitize_garden_pin_next(&next)) + .into_response(), + Err(msg) => JsBuilder::new() + .morph_inner_selector( + "#external-resolver-status", + external_resolver_status_markup(Err(msg.as_str()), &next), + ) + .into_response(), } } HtmlUiAction::RedactPost { post_id } => { diff --git a/server/src/external_resolver.rs b/server/src/external_resolver.rs index 04bc3de32b6df1aefe197d942db67de2cbdbde4f..a5812250fed7613950b5417f396f886a55fafccf 100644 --- a/server/src/external_resolver.rs +++ b/server/src/external_resolver.rs @@ -6,6 +6,7 @@ use crate::{path_types::ItemId, state::AppState, write_cmd::WriteCmd}; const GITHUB_SYSTEM_PRINCIPAL: &str = "system:github-resolver"; const GITHUB_RESOLVER_COOLDOWN_MS: i64 = 15_000; +const GITHUB_MAX_PAGES: usize = 3; fn now_ms() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; @@ -69,6 +70,10 @@ impl GitHubResolver { [owner, repo] => Ok(github_repo_sections(owner, repo)), [owner, repo, section] if section == "issues" => self.list_issues(owner, repo).await, [owner, repo, section] if section == "pulls" => self.list_pulls(owner, repo).await, + [owner, repo, section] if section == "commits" => self.list_commits(owner, repo).await, + [owner, repo, section] if section == "releases" => { + self.list_releases(owner, repo).await + } _ => Ok(vec![]), } } @@ -95,17 +100,31 @@ impl GitHubResolver { .map_err(|e| format!("GitHub response JSON failed: {e}")) } + async fn get_json_array_pages(&self, path: &str) -> Result, String> { + let sep = if path.contains('?') { '&' } else { '?' }; + let mut out = Vec::new(); + for page in 1..=GITHUB_MAX_PAGES { + let value = self.get_json(&format!("{path}{sep}page={page}")).await?; + let arr = value + .as_array() + .ok_or_else(|| "GitHub paged response was not an array".to_string())?; + let n = arr.len(); + out.extend(arr.iter().cloned()); + if n < 100 { + break; + } + } + Ok(out) + } + async fn list_repos(&self, owner: &str) -> Result, String> { - let value = self - .get_json(&format!( + let arr = self + .get_json_array_pages(&format!( "/users/{owner}/repos?per_page=100&sort=updated&type=owner" )) .await?; - let arr = value - .as_array() - .ok_or_else(|| "GitHub repos response was not an array".to_string())?; let mut out = Vec::new(); - for repo in arr { + for repo in &arr { let name = repo .get("name") .and_then(|v| v.as_str()) @@ -121,7 +140,7 @@ impl GitHubResolver { out.push(ResolvedChild { url: format!("https://github.com/{full_name}"), title: full_name.clone(), - body: Some(github_json_body(repo)), + body: Some(github_repo_body(repo)), }); } out.sort_by(|a, b| a.url.cmp(&b.url)); @@ -129,16 +148,13 @@ impl GitHubResolver { } async fn list_issues(&self, owner: &str, repo: &str) -> Result, String> { - let value = self - .get_json(&format!( + let arr = self + .get_json_array_pages(&format!( "/repos/{owner}/{repo}/issues?state=open&per_page=100" )) .await?; - let arr = value - .as_array() - .ok_or_else(|| "GitHub issues response was not an array".to_string())?; let mut out = Vec::new(); - for issue in arr { + for issue in &arr { if issue.get("pull_request").is_some() { continue; } @@ -152,7 +168,7 @@ impl GitHubResolver { out.push(ResolvedChild { url: format!("https://github.com/{owner}/{repo}/issues/{number}"), title: format!("#{number} {title}"), - body: Some(github_json_body(issue)), + body: Some(github_issue_body(issue, "issue")), }); } out.sort_by(|a, b| a.url.cmp(&b.url)); @@ -160,16 +176,13 @@ impl GitHubResolver { } async fn list_pulls(&self, owner: &str, repo: &str) -> Result, String> { - let value = self - .get_json(&format!( + let arr = self + .get_json_array_pages(&format!( "/repos/{owner}/{repo}/pulls?state=open&per_page=100" )) .await?; - let arr = value - .as_array() - .ok_or_else(|| "GitHub pulls response was not an array".to_string())?; let mut out = Vec::new(); - for pull in arr { + for pull in &arr { let Some(number) = pull.get("number").and_then(|v| v.as_i64()) else { continue; }; @@ -180,7 +193,60 @@ impl GitHubResolver { out.push(ResolvedChild { url: format!("https://github.com/{owner}/{repo}/pulls/{number}"), title: format!("#{number} {title}"), - body: Some(github_json_body(pull)), + body: Some(github_issue_body(pull, "pull request")), + }); + } + out.sort_by(|a, b| a.url.cmp(&b.url)); + Ok(out) + } + + async fn list_commits(&self, owner: &str, repo: &str) -> Result, String> { + let arr = self + .get_json_array_pages(&format!("/repos/{owner}/{repo}/commits?per_page=100")) + .await?; + let mut out = Vec::new(); + for commit in &arr { + let Some(sha) = github_string(commit, "sha") else { + continue; + }; + let short = sha.chars().take(7).collect::(); + let title = commit + .get("commit") + .and_then(|c| c.get("message")) + .and_then(|v| v.as_str()) + .and_then(|m| m.lines().next()) + .filter(|s| !s.trim().is_empty()) + .unwrap_or("commit"); + let url = github_string(commit, "html_url") + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("https://github.com/{owner}/{repo}/commit/{sha}")); + out.push(ResolvedChild { + url, + title: format!("{short} {title}"), + body: Some(github_commit_body(commit)), + }); + } + out.sort_by(|a, b| a.url.cmp(&b.url)); + Ok(out) + } + + async fn list_releases(&self, owner: &str, repo: &str) -> Result, String> { + let arr = self + .get_json_array_pages(&format!("/repos/{owner}/{repo}/releases?per_page=100")) + .await?; + let mut out = Vec::new(); + for release in &arr { + let Some(tag) = github_string(release, "tag_name") else { + continue; + }; + let title = github_string(release, "name").unwrap_or(tag); + let url = github_string(release, "html_url") + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("https://github.com/{owner}/{repo}/releases/tag/{tag}")); + out.push(ResolvedChild { + url, + title: title.to_string(), + body: Some(github_release_body(release)), }); } out.sort_by(|a, b| a.url.cmp(&b.url)); @@ -240,11 +306,147 @@ fn sanitize_body(s: &str) -> String { .collect() } -fn github_json_body(value: &Value) -> String { - let json = serde_json::to_string_pretty(value) - .unwrap_or_else(|_| value.to_string()) - .replace("```", "` ` `"); - format!("```json\n{json}\n```") +fn github_string<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + value + .get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) +} + +fn github_user_login(value: &Value) -> Option<&str> { + value + .get("user") + .and_then(|u| u.get("login")) + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) +} + +fn github_labels(value: &Value) -> Vec { + value + .get("labels") + .and_then(|v| v.as_array()) + .into_iter() + .flat_map(|labels| labels.iter()) + .filter_map(|label| label.get("name").and_then(|v| v.as_str())) + .filter(|name| !name.trim().is_empty()) + .map(|name| name.to_string()) + .collect() +} + +fn github_repo_body(repo: &Value) -> String { + let full_name = github_string(repo, "full_name") + .or_else(|| github_string(repo, "name")) + .unwrap_or("GitHub repository"); + let mut lines = vec![full_name.to_string()]; + if let Some(desc) = github_string(repo, "description") { + lines.push(String::new()); + lines.push(desc.to_string()); + } + if let Some(url) = github_string(repo, "html_url") { + lines.push(String::new()); + lines.push(format!("Source: {url}")); + } + if let Some(lang) = github_string(repo, "language") { + lines.push(format!("Language: {lang}")); + } + lines.join("\n") +} + +fn github_issue_body(issue: &Value, kind: &str) -> String { + let number = issue + .get("number") + .and_then(|v| v.as_i64()) + .map(|n| format!("#{n} ")) + .unwrap_or_default(); + let title = github_string(issue, "title").unwrap_or("Untitled"); + let state = github_string(issue, "state").unwrap_or("unknown"); + let mut lines = vec![format!("{kind} {number}{title}")]; + lines.push(format!("State: {state}")); + if let Some(author) = github_user_login(issue) { + lines.push(format!("Author: @{author}")); + } + let labels = github_labels(issue); + if !labels.is_empty() { + lines.push(format!("Labels: {}", labels.join(", "))); + } + if let Some(url) = github_string(issue, "html_url") { + lines.push(format!("Source: {url}")); + } + if let Some(body) = github_string(issue, "body") { + lines.push(String::new()); + lines.push(body.to_string()); + } + lines.join("\n") +} + +fn github_commit_body(commit: &Value) -> String { + let sha = github_string(commit, "sha").unwrap_or("unknown"); + let short = sha.chars().take(7).collect::(); + let commit_obj = commit.get("commit"); + let message = commit_obj + .and_then(|c| c.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or("commit"); + let mut lines = vec![format!("commit {short}")]; + if let Some(author) = commit_obj + .and_then(|c| c.get("author")) + .and_then(|a| a.get("name")) + .and_then(|v| v.as_str()) + .filter(|s| !s.trim().is_empty()) + { + lines.push(format!("Author: {author}")); + } + if let Some(login) = github_user_login(commit) { + lines.push(format!("GitHub user: @{login}")); + } + if let Some(date) = commit_obj + .and_then(|c| c.get("author")) + .and_then(|a| a.get("date")) + .and_then(|v| v.as_str()) + { + lines.push(format!("Date: {date}")); + } + if let Some(url) = github_string(commit, "html_url") { + lines.push(format!("Source: {url}")); + } + lines.push(String::new()); + lines.push(message.to_string()); + lines.join("\n") +} + +fn github_release_body(release: &Value) -> String { + let tag = github_string(release, "tag_name").unwrap_or("untagged"); + let title = github_string(release, "name").unwrap_or(tag); + let mut lines = vec![format!("release {title}")]; + lines.push(format!("Tag: {tag}")); + if release + .get("draft") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + lines.push("Draft: yes".to_string()); + } + if release + .get("prerelease") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + lines.push("Prerelease: yes".to_string()); + } + if let Some(author) = github_user_login(release) { + lines.push(format!("Author: @{author}")); + } + if let Some(published) = github_string(release, "published_at") { + lines.push(format!("Published: {published}")); + } + if let Some(url) = github_string(release, "html_url") { + lines.push(format!("Source: {url}")); + } + if let Some(body) = github_string(release, "body") { + lines.push(String::new()); + lines.push(body.to_string()); + } + lines.join("\n") } fn children_to_dsl(children: &[ResolvedChild]) -> String { @@ -382,4 +584,47 @@ mod tests { assert!(dsl.contains("{\"test\": true}")); assert!(dsl.contains("```\n}\n")); } + + #[test] + fn github_issue_body_is_readable_text_not_json_dump() { + let issue = serde_json::json!({ + "number": 12, + "title": "Render children", + "state": "open", + "html_url": "https://github.com/o/r/issues/12", + "user": {"login": "octo"}, + "labels": [{"name": "bug"}], + "body": "The issue body." + }); + let body = github_issue_body(&issue, "issue"); + assert!(body.contains("issue #12 Render children")); + assert!(body.contains("Author: @octo")); + assert!(body.contains("The issue body.")); + assert!(!body.trim_start().starts_with("```json")); + } + + #[test] + fn github_commit_and_release_bodies_are_readable() { + let commit = serde_json::json!({ + "sha": "abcdef123456", + "html_url": "https://github.com/o/r/commit/abcdef123456", + "author": {"login": "octo"}, + "commit": { + "message": "Fix vote page\n\nDetails here.", + "author": {"name": "Octo Dev", "date": "2026-05-17T00:00:00Z"} + } + }); + let release = serde_json::json!({ + "tag_name": "v1.2.3", + "name": "Release 1.2.3", + "html_url": "https://github.com/o/r/releases/tag/v1.2.3", + "author": {"login": "octo"}, + "prerelease": true, + "body": "Release notes." + }); + assert!(github_commit_body(&commit).contains("commit abcdef1")); + assert!(github_commit_body(&commit).contains("Fix vote page")); + assert!(github_release_body(&release).contains("release Release 1.2.3")); + assert!(github_release_body(&release).contains("Prerelease: yes")); + } } diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs index b28271e0316738eae245855b24aeb76486860554..9ca66e7c5860d428e95abf5df518fc1e7b4f6332 100644 --- a/server/src/html/garden.rs +++ b/server/src/html/garden.rs @@ -22,7 +22,7 @@ use crate::{ reducer::{ContentState, ReducerState, ScopeId}, scope_rank::{ build_children_rankings, build_rankings_for_item_set, resolve_scope_recursive, - ChildrenRankings, + suggest_next_pair_in_pool, ChildrenRankings, }, state::AppState, timeago, @@ -237,7 +237,7 @@ pub(crate) async fn vote_compare_post_success_js( state: &AppState, nav: &ThreadNav, _room_wire: &str, - _thread_tag: &str, + thread_tag: &str, left: &ItemId, right: &ItemId, _post_id: &str, @@ -246,9 +246,13 @@ pub(crate) async fn vote_compare_post_success_js( let reduced = state.reduced.read().await; let content = content_for_garden_view(&reduced, &nav.scope()); let edge_history = vote_edge_history_markup(content, left, right); + let next_pair = suggest_next_vote_pair(content, left, right); + let nav_markup = + vote_compare_nav_markup(nav, next_pair.as_ref(), left, right, Some(thread_tag)); drop(reduced); JsBuilder::new() .morph_inner_selector("#vote-edge-history-region", edge_history) + .morph_selector(".vote-compare-nav", nav_markup) .build() } @@ -288,6 +292,93 @@ fn vote_compare_href( } } +fn login_href_with_next(next: &str) -> String { + let next = if next.trim().starts_with('/') && !next.trim().starts_with("//") { + next.trim() + } else { + "/" + }; + format!("/login?next={}", urlencoding::encode(next)) +} + +fn vote_compare_nav_markup( + nav: &ThreadNav, + next_pair: Option<&(ItemId, ItemId)>, + left: &ItemId, + right: &ItemId, + thread_override: Option<&str>, +) -> maud::Markup { + let next_pair_href = next_pair.map(|(nl, nr)| vote_compare_href(nav, nl, nr, None)); + let swap_pair_href = vote_compare_href(nav, right, left, thread_override); + html! { + div class="vote-compare-nav" { + @if let Some(href) = &next_pair_href { + a class="vote-compare-next" data-testid="vote-next-pair" href=(href) { "next pair" } + } @else { + span class="vote-compare-next is-disabled" { "no next pair" } + } + a class="vote-compare-next" href=(swap_pair_href) { "swap sides" } + } + } +} + +fn suggest_next_vote_pair( + content: &ContentState, + current_left: &ItemId, + current_right: &ItemId, +) -> Option<(ItemId, ItemId)> { + let mut pool: Vec = if current_left.parent().as_ref().map(|p| p.as_str()) + == current_right.parent().as_ref().map(|p| p.as_str()) + { + current_left + .parent() + .and_then(|parent| { + content + .item_children + .get(&parent.normalized_storage()) + .cloned() + }) + .map(|children| children.into_iter().collect()) + .unwrap_or_default() + } else { + Vec::new() + }; + if pool.len() < 2 { + pool = content.items.iter().cloned().collect(); + } + suggest_next_pair_in_pool( + &content.ranking_group, + &pool, + Some((current_left, current_right)), + ) +} + +fn vote_compare_item_card( + nav: &ThreadNav, + item: &ItemId, + body: Option<&String>, + side_class: &str, +) -> maud::Markup { + html! { + div class=(format!("vote-compare-side {side_class}")) { + a class=(format!("vote-compare-item {side_class}")) href=(nav.garden_item_href(item)) { + code { (item_display_path(item.as_str())) } + } + @if let Some(body) = body.filter(|b| !b.trim().is_empty()) { + div class="vote-compare-item-body" { + (render_linkified_with_embeds_in_scope( + body, + nav.garden_root_url(), + None, + )) + } + } @else { + p class="muted vote-compare-item-body-empty" { "no body yet" } + } + } + } +} + fn ont_pin_vote_controls( nav: &ThreadNav, current_storage: &str, @@ -1153,7 +1244,7 @@ fn github_resolver_controls(item: &str, nav: &ThreadNav, next: &str) -> Option Option, + next: &str, +) -> maud::Markup { + let next = if next.trim().starts_with('/') && !next.trim().starts_with("//") { + next.trim() + } else { + "/" + }; + html! { + @match imported { + Ok(n) => { + p class="resolver-status-ok" { + @if n == 0 { + "No GitHub children found. " + } @else { + (format!("Imported {n} GitHub item{}.", if n == 1 { "" } else { "s" })) " " + } + a href=(next) { "Refresh page" } + " to render the updated ontology." + } + } + Err(msg) => { + p class="resolver-status-error" { (msg) } + } + } + } +} + async fn render_scope_view( state: AppState, browse: GardenBrowsePath, @@ -1468,6 +1590,9 @@ async fn vote_compare_inner( .unwrap_or_else(|| pick_autothread_for_vote_pair(content, &left, &right)); let thread_tags = vote_thread_tags_for_pair(content, &left, &right); let edge_history = vote_edge_history_markup(content, &left, &right); + let left_body = content.item_bodies.get(&left).cloned(); + let right_body = content.item_bodies.get(&right).cloned(); + let next_pair = suggest_next_vote_pair(content, &left, &right); drop(reduced); let title = format!( @@ -1495,52 +1620,51 @@ async fn vote_compare_inner( .expect("vote compare rpc json"); let body = html! { - h2 { "compare" } - div class="vote-compare-pair" { - a class="vote-compare-item" href=(nav.garden_item_href(&left)) { - code { (item_display_path(left.as_str())) } + section class="vote-compare-shell" { + h2 { "compare" } + div class="vote-compare-pair" { + (vote_compare_item_card(&nav, &left, left_body.as_ref(), "vote-compare-left")) + span class="vote-compare-vs" { "vs" } + (vote_compare_item_card(&nav, &right, right_body.as_ref(), "vote-compare-right")) } - span class="vote-compare-vs" { "vs" } - a class="vote-compare-item" href=(nav.garden_item_href(&right)) { - code { (item_display_path(right.as_str())) } + (vote_compare_nav_markup(&nav, next_pair.as_ref(), &left, &right, q.thread.as_deref())) + div id="vote-edge-history-region" { + (edge_history) } - } - div id="vote-edge-history-region" { - (edge_history) - } - @if can_post { - form id="vote-compare-form" method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(rpc_json); - div class="vote-thread-picker" { - label class="vote-thread-picker-label" { "thread" } - select id="vote-thread-select" name="thread_tag" aria-label="Thread to post vote into" { - @if thread_tags.is_empty() { - option value="vote" selected { "#vote" } - } - @for t in &thread_tags { - @if *t == auto_thread { - option value=(t) selected { "#" (t) } - } @else { - option value=(t) { "#" (t) } + @if can_post { + form id="vote-compare-form" method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(rpc_json); + div class="vote-thread-picker" { + label class="vote-thread-picker-label" { "thread" } + select id="vote-thread-select" name="thread_tag" aria-label="Thread to post vote into" { + @if thread_tags.is_empty() { + option value="vote" selected { "#vote" } + } + @for t in &thread_tags { + @if *t == auto_thread { + option value=(t) selected { "#" (t) } + } @else { + option value=(t) { "#" (t) } + } } } } + input type="hidden" name="ratio_left" id="vote-ratio-left" value="50"; + input type="hidden" name="ratio_right" id="vote-ratio-right" value="50"; + label class="vote-compare-slider-label" { + span id="vote-slider-left-label" { (item_display_path(left.as_str())) } + input type="range" id="vote-preference-slider" min="0" max="100" value="50" + aria-valuemin="0" aria-valuemax="100"; + span id="vote-slider-right-label" { (item_display_path(right.as_str())) } + } + label class="vote-explain-label" { "reason (required)" } + textarea name="explanation" id="vote-explain" rows="5" placeholder="why this split?" required {} + div id="vote-compare-errors" {} + p { button type="submit" { "post vote" } } } - input type="hidden" name="ratio_left" id="vote-ratio-left" value="50"; - input type="hidden" name="ratio_right" id="vote-ratio-right" value="50"; - label class="vote-compare-slider-label" { - span id="vote-slider-left-label" { (item_display_path(left.as_str())) } - input type="range" id="vote-preference-slider" min="0" max="100" value="50" - aria-valuemin="0" aria-valuemax="100"; - span id="vote-slider-right-label" { (item_display_path(right.as_str())) } - } - label class="vote-explain-label" { "reason (required)" } - textarea name="explanation" id="vote-explain" rows="5" placeholder="why this split?" required {} - div id="vote-compare-errors" {} - p { button type="submit" { "post vote" } } + } @else { + p class="muted" { a href=(login_href_with_next(&next_path)) { "log in" } " to post this vote." } } - } @else { - p class="muted" { a href="/login" { "log in" } " to post this vote." } } }; @@ -1649,6 +1773,42 @@ mod tests { assert_eq!(edge_vote_entries_for_pair(content, &a, &b).len(), 2); } + #[test] + fn suggest_next_vote_pair_prefers_unvoted_sibling_pair() { + use super::{content_for_garden_view, suggest_next_vote_pair}; + use crate::path_types::ItemId; + let mut reduced = ReducerState::default(); + apply_ingest( + &mut reduced, + 1, + "@00000000-0000-0000-0000-000000000000:test:local/test\n\ + ~/topic {root}\n\ + ~/topic/a {alpha}\n\ + ~/topic/b {beta}\n\ + ~/topic/c {gamma}\n\ + {a beats b}\n ~/topic/a 2:1 ~/topic/b\n", + ); + let content = content_for_garden_view(&reduced, &ScopeId::Public); + let a = ItemId::parse("~/topic/a").unwrap().normalized_storage(); + let b = ItemId::parse("~/topic/b").unwrap().normalized_storage(); + let next = suggest_next_vote_pair(content, &a, &b).expect("next sibling pair"); + assert_ne!( + super::canonical_edge_items(&next.0, &next.1), + super::canonical_edge_items(&a, &b) + ); + assert!( + next.0.as_str().ends_with("/c") || next.1.as_str().ends_with("/c"), + "next pair should include the unvoted sibling: {next:?}" + ); + } + + #[test] + fn external_resolver_status_markup_reports_success_and_refresh() { + let html = super::external_resolver_status_markup(Ok(2), "/-/github.com/o/r").into_string(); + assert!(html.contains("Imported 2 GitHub items.")); + assert!(html.contains("href=\"/-/github.com/o/r\"")); + } + #[test] fn item_page_model_includes_body_and_unranked_without_votes() { let mut reduced = ReducerState::default(); diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index d5877eeb3632ed5d770f23a04cb4d1881209bc74..a1b929625acbd5298c0cf62f3ca0892079edcb2a 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -36,7 +36,10 @@ pub(crate) use forum::{ thread_ui_collapse_redacted_post, thread_ui_expand_post_full, thread_ui_expand_redacted_post, user_can_post_room, user_can_view_room, }; -pub(crate) use garden::{encode_pin_cookie_value, vote_compare_post_success_js, GARDEN_PIN_COOKIE}; +pub(crate) use garden::{ + encode_pin_cookie_value, external_resolver_status_markup, vote_compare_post_success_js, + GARDEN_PIN_COOKIE, +}; pub use garden::{ external_garden_index, external_ontology_path, garden_index, ontology_path, room_external_garden_index, room_external_ontology_path, room_garden_index, room_ontology_path, diff --git a/server/src/scope_rank.rs b/server/src/scope_rank.rs index 4fdaae150c74ec5162c2accc06d4cc778ad79914..06c560b8eff09b34896d3935d3917fb28f602bc6 100644 --- a/server/src/scope_rank.rs +++ b/server/src/scope_rank.rs @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet}; use crate::path_types::ItemId; use crate::ranking::{connected_components_from_voted_pairs, ranked_items_subset, RankedItem}; -use crate::reducer::ContentState; +use crate::reducer::{ContentState, GroupState}; #[derive(Debug, Clone)] pub struct ScopedComponent { @@ -49,15 +49,16 @@ pub fn resolve_scope(content: &ContentState, specs: &[String]) -> Vec { /// Resolve scope specs recursively up to `depth` levels deep. /// depth=1 is equivalent to resolve_scope (direct children only). /// depth=2 includes grandchildren, etc. -pub fn resolve_scope_recursive(content: &ContentState, specs: &[String], depth: usize) -> Vec { +pub fn resolve_scope_recursive( + content: &ContentState, + specs: &[String], + depth: usize, +) -> Vec { if depth == 0 { return vec![]; } let mut visited: HashSet = HashSet::new(); - let mut frontier: Vec = specs - .iter() - .filter_map(|s| ItemId::parse(s)) - .collect(); + let mut frontier: Vec = specs.iter().filter_map(|s| ItemId::parse(s)).collect(); for _level in 0..depth { let mut next_frontier: Vec = Vec::new(); @@ -83,7 +84,10 @@ pub fn resolve_scope_recursive(content: &ContentState, specs: &[String], depth: /// Build connected-component rankings for an explicit set of item paths. /// Use this when scope comes from multiple parents (resolve_scope). -pub fn build_rankings_for_item_set(content: &ContentState, items_in_scope: &[ItemId]) -> ChildrenRankings { +pub fn build_rankings_for_item_set( + content: &ContentState, + items_in_scope: &[ItemId], +) -> ChildrenRankings { let group = &content.ranking_group; let mut items_in_scope: Vec = items_in_scope.to_vec(); items_in_scope.sort(); @@ -158,6 +162,67 @@ pub fn build_children_rankings(content: &ContentState, parent: &ItemId) -> Child build_rankings_for_item_set(content, &items) } +pub fn is_pair_voted_in_group(group: &GroupState, a: &ItemId, b: &ItemId) -> bool { + let Some(&a_idx) = group.item_to_idx.get(a) else { + return false; + }; + let Some(&b_idx) = group.item_to_idx.get(b) else { + return false; + }; + let (i, j) = if a_idx < b_idx { + (a_idx, b_idx) + } else { + (b_idx, a_idx) + }; + group.voted_pairs.contains(&(i, j)) +} + +fn canonical_pair(a: &ItemId, b: &ItemId) -> (ItemId, ItemId) { + let ac = a.clone().normalized_storage(); + let bc = b.clone().normalized_storage(); + if ac.as_str() <= bc.as_str() { + (ac, bc) + } else { + (bc, ac) + } +} + +pub fn suggest_next_pair_in_pool( + group: &GroupState, + pool: &[ItemId], + current_pair: Option<(&ItemId, &ItemId)>, +) -> Option<(ItemId, ItemId)> { + let current = current_pair.map(|(a, b)| canonical_pair(a, b)); + let mut pool = pool.to_vec(); + pool.sort(); + pool.dedup(); + if pool.len() < 2 { + return None; + } + + for i in 0..pool.len() { + for j in (i + 1)..pool.len() { + let pair = canonical_pair(&pool[i], &pool[j]); + if current.as_ref() == Some(&pair) { + continue; + } + if !is_pair_voted_in_group(group, &pool[i], &pool[j]) { + return Some((pool[i].clone(), pool[j].clone())); + } + } + } + + for i in 0..pool.len() { + for j in (i + 1)..pool.len() { + let pair = canonical_pair(&pool[i], &pool[j]); + if current.as_ref() != Some(&pair) { + return Some((pool[i].clone(), pool[j].clone())); + } + } + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -167,10 +232,7 @@ mod tests { let mut item_children: HashMap> = HashMap::new(); for (parent, children) in edges { let parent = ItemId::parse(parent).unwrap(); - let set: HashSet = children - .iter() - .map(|s| ItemId::parse(s).unwrap()) - .collect(); + let set: HashSet = children.iter().map(|s| ItemId::parse(s).unwrap()).collect(); item_children.insert(parent, set); } ContentState { @@ -187,9 +249,13 @@ mod tests { #[test] fn resolve_one_scope_literal() { - let content = content_with_children(&[ - ("https://slug.social/models", &["https://slug.social/models/x", "https://slug.social/models/y"]), - ]); + let content = content_with_children(&[( + "https://slug.social/models", + &[ + "https://slug.social/models/x", + "https://slug.social/models/y", + ], + )]); let out = resolve_one_scope(&content, "models"); assert_eq!(out.len(), 2); assert!(out.contains(&ItemId::parse("https://slug.social/models/x").unwrap())); @@ -199,8 +265,14 @@ mod tests { #[test] fn resolve_scope_multiple_parents_merges() { let content = content_with_children(&[ - ("https://slug.social/a", &["https://slug.social/a/1", "https://slug.social/a/2"]), - ("https://slug.social/b", &["https://slug.social/b/1", "https://slug.social/b/2"]), + ( + "https://slug.social/a", + &["https://slug.social/a/1", "https://slug.social/a/2"], + ), + ( + "https://slug.social/b", + &["https://slug.social/b/1", "https://slug.social/b/2"], + ), ]); let out = resolve_scope(&content, &["a".into(), "b".into()]); assert_eq!(out.len(), 4); @@ -209,4 +281,28 @@ mod tests { assert!(out.contains(&ItemId::parse("https://slug.social/b/1").unwrap())); assert!(out.contains(&ItemId::parse("https://slug.social/b/2").unwrap())); } + + #[test] + fn suggest_next_pair_skips_current_and_voted_pairs() { + let mut group = crate::reducer::GroupState::new(); + let a = ItemId::parse("~/a").unwrap().normalized_storage(); + let b = ItemId::parse("~/b").unwrap().normalized_storage(); + let c = ItemId::parse("~/c").unwrap().normalized_storage(); + group.apply_vote(crate::reducer::VoteData { + ts: 1, + a: a.clone(), + b: b.clone(), + ratio_left: 2, + ratio_right: 1, + body: "a beats b".to_string(), + principal: "tester".to_string(), + delegate: None, + thread_tag: "vote".to_string(), + }); + let next = + suggest_next_pair_in_pool(&group, &[a.clone(), b.clone(), c.clone()], Some((&a, &b))) + .expect("next pair"); + assert!(next.0 == c || next.1 == c); + assert_ne!(canonical_pair(&next.0, &next.1), canonical_pair(&a, &b)); + } } diff --git a/server/src/state.rs b/server/src/state.rs index 8a80fed0a6bc7ae3516c76ab9d1e8592d7038ad1..48298e2e66456268d23a6462536eb32bfeb5f29b 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -27,6 +27,8 @@ pub struct PendingSession { pub provider_id: Option, /// When set, successful OAuth completion redeems this invite token and appends [`crate::events::GrantAdded`]. pub redeem_invite: Option, + /// Local path to navigate to after browser onboarding completes. + pub redirect_next: Option, pub complete: Option<(String /*username*/, String /*bearer*/)>, } diff --git a/server/static/theme_default.css b/server/static/theme_default.css index fdcde86c718eb53cce8273e45a9844c2d8041f88..184e11a590e7019773f7f0abfa41e79161556c71 100644 --- a/server/static/theme_default.css +++ b/server/static/theme_default.css @@ -930,6 +930,34 @@ button.ont-garden-pin-ico:focus-visible { outline: 2px solid var(--link); outline-offset: 2px; } +.resolver-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +.resolver-refresh-link, +.vote-compare-next { + border: var(--bv) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + color: var(--ui); + font-size: 12px; + padding: 4px 10px; + text-decoration: none; +} +.resolver-refresh-link:hover, +.vote-compare-next:hover { + color: var(--signal); +} +.resolver-status { + margin-top: 8px; +} +.resolver-status p { + margin: 0; +} +.resolver-status-error { + color: var(--danger, #b00020); +} body.view-ontology-light ol.ont-ranking-list li, body.view-ontology-light ul.ont-group-list li { display: flex; @@ -959,15 +987,19 @@ body.view-vote-compare .vote-compare-shell > h2 { color: var(--meta); } .vote-compare-pair { - display: flex; - flex-wrap: wrap; - align-items: center; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: start; gap: 10px 16px; - margin: 10px 0; + margin: 0 0 10px; } .vote-compare-item { + display: inline-block; text-decoration: none; } +.vote-compare-right { + text-align: right; +} .vote-compare-item code { font-size: 13px; } @@ -980,6 +1012,33 @@ body.view-vote-compare .vote-compare-shell > h2 { letter-spacing: 0.1em; text-transform: uppercase; } +.vote-compare-item-body { + margin-top: 8px; + max-height: 42dvh; + overflow: auto; +} +.vote-compare-item-body pre { + background: var(--g1); + font-size: 13px; + line-height: 1.35; + padding: 8px 10px; +} +.vote-compare-item-body-empty { + font-size: 12px; + margin: 8px 0 0; +} +.vote-compare-nav { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + margin: 12px 0; +} +.vote-compare-next.is-disabled { + color: var(--meta); + cursor: default; + opacity: 0.65; +} .vote-compare-slider-label { display: flex; flex-wrap: wrap; @@ -1060,18 +1119,27 @@ body.view-vote-compare-fullscreen { width: 100%; margin-left: 0; margin-right: 0; - padding: clamp(8px, 2.5vw, 28px); - padding-bottom: max(20px, env(safe-area-inset-bottom, 0px)); - padding-top: max(8px, env(safe-area-inset-top, 0px)); + padding: 0; min-height: 100vh; min-height: 100dvh; box-sizing: border-box; } +body.view-vote-compare-fullscreen > .view-meta { + bottom: 4px; + position: fixed; + right: 6px; + z-index: 20; +} body.view-vote-compare-fullscreen .vote-compare-shell { max-width: none; width: 100%; box-sizing: border-box; margin: 0; + min-height: 100dvh; + padding: 0; +} +body.view-vote-compare-fullscreen .vote-compare-shell > h2 { + display: none; } .vote-edge-history-title { diff --git a/server/static/theme_retro.css b/server/static/theme_retro.css index 61e1448b2f66a075c0e33325d6980448712fc927..6747f59eb1ec5c335029fe92d4e5c55b3125a210 100644 --- a/server/static/theme_retro.css +++ b/server/static/theme_retro.css @@ -135,6 +135,34 @@ body.view-ontology nav.breadcrumb a:hover { body.view-ontology nav.breadcrumb a.bc-current { color: #111; font-weight: 600; } body.view-ontology nav.breadcrumb .bc-sep { color: #888; padding: 0 2px; } +body.view-ontology .resolver-actions, +body.view-ontology .vote-compare-nav { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} +body.view-ontology .resolver-refresh-link, +body.view-ontology .vote-compare-next { + border: 1px solid #bbb; + color: #23a; + padding: 0.2rem 0.55rem; + text-decoration: none; +} +body.view-ontology .vote-compare-next.is-disabled { + color: #666; + opacity: 0.65; +} +body.view-ontology .resolver-status { + margin-top: 0.5rem; +} +body.view-ontology .resolver-status p { + margin: 0; +} +body.view-ontology .resolver-status-error { + color: #9b1c1c; +} + body.view-ontology ol.ont-ranking-list { counter-reset: ont-rank; list-style: none; @@ -205,16 +233,48 @@ body.view-vote-compare-fullscreen { max-width: none; width: 100%; margin: 0; - padding: clamp(8px, 2.5vw, 20px); - padding-bottom: max(24px, env(safe-area-inset-bottom, 0px)); - padding-top: max(8px, env(safe-area-inset-top, 0px)); + padding: 0; min-height: 100vh; min-height: 100dvh; box-sizing: border-box; } +body.view-vote-compare-fullscreen > .view-meta { + bottom: 4px; + position: fixed; + right: 6px; + z-index: 20; +} body.view-vote-compare-fullscreen .vote-compare-shell { max-width: none; width: 100%; box-sizing: border-box; margin: 0; + min-height: 100dvh; + padding: 0; +} +body.view-vote-compare-fullscreen .vote-compare-shell > h2 { + display: none; +} +body.view-ontology .vote-compare-pair { + align-items: start; + display: grid; + gap: 0.65rem 1rem; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + margin: 0 0 0.75rem; +} +body.view-ontology .vote-compare-item { + display: inline-block; +} +body.view-ontology .vote-compare-right { + text-align: right; +} +body.view-ontology .vote-compare-item-body { + margin-top: 0.45rem; + max-height: 42dvh; + overflow: auto; +} +body.view-ontology .vote-compare-item-body pre { + background: #faf8f3; + border: 1px solid #ccc; + padding: 0.5rem 0.65rem; } diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css index 7da102040484c887833158a37c307d078205c701..55d984a6dd70ebeadeca7baef86b844955f1d78c 100644 --- a/server/static/theme_retro_craft.css +++ b/server/static/theme_retro_craft.css @@ -742,6 +742,34 @@ body.view-ontology button.ont-garden-pin-ico:focus-visible { outline-offset: 2px; } +body.view-ontology .resolver-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} +body.view-ontology .resolver-refresh-link { + border: 1px solid #c8c4bc; + color: #3d3a34; + font-family: var(--font-ui); + font-size: 0.72rem; + padding: 0.25rem 0.55rem; + text-decoration: none; +} +body.view-ontology .resolver-refresh-link:hover { + border-color: #a68e6b; + color: #1a1814; +} +body.view-ontology .resolver-status { + margin-top: 0.5rem; +} +body.view-ontology .resolver-status p { + margin: 0; +} +body.view-ontology .resolver-status-error { + color: #8b1f1f; +} + body.view-ontology ol.ont-ranking-list { counter-reset: ont-rank; list-style: none; @@ -809,21 +837,26 @@ body.view-ontology.view-vote-compare .vote-compare-shell { body.view-ontology.view-vote-compare.view-vote-compare-fullscreen .vote-compare-shell { max-width: none; width: 100%; - min-height: calc(100dvh - clamp(20px, 5vw, 56px)); + min-height: 100dvh; margin: 0; + padding: 0; box-sizing: border-box; } body.view-vote-compare-fullscreen { max-width: none !important; width: 100%; margin: 0 !important; - padding: clamp(6px, 2vw, 1.75rem); - padding-bottom: max(1.25rem, env(safe-area-inset-bottom, 0px)); - padding-top: max(6px, env(safe-area-inset-top, 0px)); + padding: 0; min-height: 100vh; min-height: 100dvh; box-sizing: border-box; } +body.view-vote-compare-fullscreen > .view-meta { + bottom: 0.25rem; + position: fixed; + right: 0.35rem; + z-index: 20; +} body.view-ontology.view-vote-compare .vote-compare-shell > h2 { margin-top: 0; font-size: 0.7rem; @@ -831,12 +864,15 @@ body.view-ontology.view-vote-compare .vote-compare-shell > h2 { text-transform: uppercase; color: #5c574e; } +body.view-ontology.view-vote-compare-fullscreen .vote-compare-shell > h2 { + display: none; +} body.view-ontology .vote-compare-pair { - display: flex; - flex-wrap: wrap; - align-items: center; + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: start; gap: 0.65rem 1rem; - margin: 0.65rem 0 0.85rem; + margin: 0 0 0.85rem; } body.view-ontology .vote-compare-vs { color: #8a857a; @@ -844,14 +880,62 @@ body.view-ontology .vote-compare-vs { font-size: 0.72rem; letter-spacing: 0.1em; text-transform: uppercase; + padding-top: 0.18rem; } body.view-ontology .vote-compare-item { + display: inline-block; text-decoration: none; } +body.view-ontology .vote-compare-right { + text-align: right; +} body.view-ontology .vote-compare-item:hover code { border-color: #a68e6b; background: #f0ebe3; } +body.view-ontology .vote-compare-item-body { + margin-top: 0.45rem; + max-height: 42dvh; + overflow: auto; +} +body.view-ontology .vote-compare-item-body pre { + background: #fdfcfa; + border: 1px solid #d4cfc4; + border-left: 3px solid #a68e6b; + color: #1a1814; + font-size: 0.82rem; + line-height: 1.35; + padding: 0.55rem 0.65rem; +} +body.view-ontology .vote-compare-item-body-empty { + font-size: 0.78rem; + margin: 0.45rem 0 0; +} +body.view-ontology .vote-compare-nav { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + justify-content: center; + margin: 0.75rem 0; +} +body.view-ontology .vote-compare-next { + border: 1px solid #c8c4bc; + color: #3d3a34; + font-family: var(--font-ui); + font-size: 0.72rem; + letter-spacing: 0.04em; + padding: 0.25rem 0.65rem; + text-decoration: none; +} +body.view-ontology .vote-compare-next:hover { + border-color: #a68e6b; + color: #1a1814; +} +body.view-ontology .vote-compare-next.is-disabled { + color: #8a857a; + cursor: default; + opacity: 0.65; +} body.view-ontology .vote-thread-picker { margin: 0.85rem 0; diff --git a/server/tests/integration.rs b/server/tests/integration.rs index 3c9f0476c58abdc947fd50424bfea66a784fb83b..fb0b9335440181d4d50104d37926b0b2eeeb602a 100644 --- a/server/tests/integration.rs +++ b/server/tests/integration.rs @@ -8,9 +8,9 @@ use slugsocial_server::{ spawn_writer_actor_for_test, state::{AppConfig, AppState}, }; +use std::net::SocketAddr; use tempfile::TempDir; use tokio::net::TcpListener; -use std::net::SocketAddr; fn sha256_hex(s: &str) -> String { let mut hasher = Sha256::new(); @@ -94,7 +94,13 @@ async fn seed_test_token(state: &AppState) { r.apply_event(ev); } -async fn create_test_server_with_state() -> (SocketAddr, TempDir, EventLog, AppState, tokio::task::JoinHandle<()>) { +async fn create_test_server_with_state() -> ( + SocketAddr, + TempDir, + EventLog, + AppState, + tokio::task::JoinHandle<()>, +) { let tmp = TempDir::new().unwrap(); let log_path = tmp.path().join("events.jsonl"); let log = EventLog::new(&log_path); @@ -131,7 +137,11 @@ async fn create_test_server() -> (SocketAddr, TempDir, EventLog, tokio::task::Jo async fn test_healthz() { let (addr, _tmp, _log, _handle) = create_test_server().await; let client = reqwest::Client::new(); - let response = client.get(&format!("http://{}/healthz", addr)).send().await.unwrap(); + let response = client + .get(&format!("http://{}/healthz", addr)) + .send() + .await + .unwrap(); assert!(response.status().is_success()); assert_eq!(response.text().await.unwrap(), "ok"); } @@ -189,7 +199,13 @@ async fn test_room_delete_rpc() { del["results"][0] ); - let list = rpc_batch(&client, addr, Some(&bearer), serde_json::json!(["RoomList"])).await; + let list = rpc_batch( + &client, + addr, + Some(&bearer), + serde_json::json!(["RoomList"]), + ) + .await; let rooms = list["results"][0]["result"]["RoomList"]["rooms"] .as_array() .unwrap(); @@ -278,7 +294,11 @@ async fn test_private_room_forum_read_requires_bearer() { ) .await; let line_na = &no_auth["results"][0]; - assert_eq!(line_na["ok"], false, "expected failure without bearer: {:?}", line_na); + assert_eq!( + line_na["ok"], false, + "expected failure without bearer: {:?}", + line_na + ); assert_eq!(line_na["error"], "room not found"); let with_auth = rpc_batch( @@ -300,7 +320,11 @@ async fn test_private_room_forum_read_requires_bearer() { ) .await; let line_ok = &with_auth["results"][0]; - assert_eq!(line_ok["ok"], true, "expected success with bearer: {:?}", line_ok); + assert_eq!( + line_ok["ok"], true, + "expected success with bearer: {:?}", + line_ok + ); let total = line_ok["result"]["ForumThread"]["total"].as_u64().unwrap(); assert!(total >= 1); } @@ -499,7 +523,11 @@ async fn test_feed_since_last_post_is_scoped_to_delegate() { ) .await; let steal_line = &steal["results"][0]; - assert_eq!(steal_line["ok"], false, "expected rejection for unbound delegate: {:?}", steal_line); + assert_eq!( + steal_line["ok"], false, + "expected rejection for unbound delegate: {:?}", + steal_line + ); assert_eq!(steal_line["error"], "not your delegate"); let no_auth = rpc_batch( @@ -852,24 +880,24 @@ async fn test_choose_username_returns_evalable_js() { { let mut sessions = state.pending_sessions.write().await; - let pending = sessions.get_mut(session).expect("pending session must exist"); + let pending = sessions + .get_mut(session) + .expect("pending session must exist"); pending.provider = Some("google".to_string()); pending.provider_id = Some("google-user-123".to_string()); } let choose = client .post(format!("http://{addr}/auth/choose-username")) - .form(&[ - ("session", session), - ("username", "webuser"), - ]) + .form(&[("session", session), ("username", "webuser")]) .send() .await .unwrap(); assert_eq!(choose.status(), reqwest::StatusCode::OK); assert_eq!( - choose.headers() + choose + .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()), Some("text/javascript; charset=utf-8") @@ -880,6 +908,48 @@ async fn test_choose_username_returns_evalable_js() { assert!(body.contains("window.location = \"/auth/complete\"")); } +#[tokio::test] +async fn test_choose_username_carries_redirect_next() { + let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + let start = client + .post(format!("http://{addr}/api/v0/pending-session")) + .json(&serde_json::json!({ + "agent": "00000000-0000-0000-0000-000000000123:test:web/form" + })) + .send() + .await + .unwrap(); + assert!(start.status().is_success()); + let start_json: serde_json::Value = start.json().await.unwrap(); + let session = start_json["session"].as_str().unwrap(); + + { + let mut sessions = state.pending_sessions.write().await; + let pending = sessions + .get_mut(session) + .expect("pending session must exist"); + pending.provider = Some("google".to_string()); + pending.provider_id = Some("google-user-redirect".to_string()); + pending.redirect_next = Some("/vote/compare?left=%7E%2Fa&right=%7E%2Fb".to_string()); + } + + let choose = client + .post(format!("http://{addr}/auth/choose-username")) + .form(&[("session", session), ("username", "redirectuser")]) + .send() + .await + .unwrap(); + + assert_eq!(choose.status(), reqwest::StatusCode::OK); + let body = choose.text().await.unwrap(); + assert!(body.contains("window.location = \"/vote/compare?left=%7E%2Fa&right=%7E%2Fb\"")); +} + #[tokio::test] async fn test_sse_stream_emits_evalable_js_after_post() { let (addr, _tmp, _log, _state, _handle) = create_test_server_with_state().await; @@ -903,7 +973,10 @@ async fn test_sse_stream_emits_evalable_js_after_post() { let room_path = format!("/r/{room_seg}"); let sse_resp = client - .get(format!("http://{addr}/sse?path={}", urlencoding::encode(&room_path))) + .get(format!( + "http://{addr}/sse?path={}", + urlencoding::encode(&room_path) + )) .send() .await .unwrap(); @@ -1153,7 +1226,9 @@ async fn test_post_redact_removes_garden_and_marks_thread() { .await; let ra_line = &rank_after["results"][0]; assert_eq!(ra_line["ok"], true); - let comps = ra_line["result"]["GardenRank"]["components"].as_array().unwrap(); + let comps = ra_line["result"]["GardenRank"]["components"] + .as_array() + .unwrap(); assert!( comps.is_empty() || comps[0]["ranking"].as_array().unwrap().is_empty(), "votes from redacted post should be removed: {:?}", @@ -1267,7 +1342,9 @@ async fn test_check_endpoint_does_not_commit() { let threads_body = rpc_batch(&client, addr, None, list_batch).await; let tline = &threads_body["results"][0]; assert_eq!(tline["ok"], true); - let threads = tline["result"]["ForumThreads"]["threads"].as_array().unwrap(); + let threads = tline["result"]["ForumThreads"]["threads"] + .as_array() + .unwrap(); assert!(threads.is_empty()); } @@ -1316,7 +1393,11 @@ async fn test_garden_item_pair_matchup_include_threads() { .iter() .filter_map(|t| t.as_str()) .collect(); - assert!(threads.contains(&"sorting-hat"), "item threads should contain sorting-hat: {:?}", threads); + assert!( + threads.contains(&"sorting-hat"), + "item threads should contain sorting-hat: {:?}", + threads + ); let pair_body = rpc_batch( &client, @@ -1337,7 +1418,11 @@ async fn test_garden_item_pair_matchup_include_threads() { .iter() .filter_map(|t| t.as_str()) .collect(); - assert!(pair_threads.contains(&"sorting-hat"), "pair threads should contain sorting-hat: {:?}", pair_threads); + assert!( + pair_threads.contains(&"sorting-hat"), + "pair threads should contain sorting-hat: {:?}", + pair_threads + ); let matchup_body = rpc_batch( &client, @@ -1429,7 +1514,11 @@ async fn test_garden_pair_and_rank_path_not_found_consistent() { ) .await; let pair2_line = &pair2["results"][0]; - assert_eq!(pair2_line["ok"], true, "GetPair after parent materialized: {:?}", pair2_line); + assert_eq!( + pair2_line["ok"], true, + "GetPair after parent materialized: {:?}", + pair2_line + ); assert!(pair2_line["result"]["Pair"].is_object()); } @@ -1490,7 +1579,11 @@ async fn test_view_counts_increment_and_display() { .send() .await .unwrap(); - assert!(v1.status().is_success(), "vote compare GET 1: {}", v1.status()); + assert!( + v1.status().is_success(), + "vote compare GET 1: {}", + v1.status() + ); let v1_body = v1.text().await.unwrap(); assert!( v1_body.contains("1 views"), @@ -1502,7 +1595,11 @@ async fn test_view_counts_increment_and_display() { .send() .await .unwrap(); - assert!(v2.status().is_success(), "vote compare GET 2: {}", v2.status()); + assert!( + v2.status().is_success(), + "vote compare GET 2: {}", + v2.status() + ); let v2_body = v2.text().await.unwrap(); assert!( v2_body.contains("2 views"), @@ -1576,13 +1673,18 @@ async fn test_rank_history() { let history = resp["history"].as_array().unwrap(); assert_eq!(history.len(), 1, "one ingest → one history entry"); let entry = &history[0]; - assert_eq!(entry["scope_rank"], 1, "rust should be #1 in scope after first ingest"); - assert_eq!(entry["scope_rank_delta"], 0, "delta is 0 on first appearance"); + assert_eq!( + entry["scope_rank"], 1, + "rust should be #1 in scope after first ingest" + ); + assert_eq!( + entry["scope_rank_delta"], 0, + "delta is 0 on first appearance" + ); let caused_by = entry["caused_by"].as_array().unwrap(); assert_eq!(caused_by.len(), 2, "both votes in the ingest touched rust"); assert_eq!( - entry["thread_post_index"], - 0, + entry["thread_post_index"], 0, "rank history links use same 0-based index as /t/hist-test/0" ); @@ -1610,16 +1712,16 @@ async fn test_rank_history() { let caused_by2 = hist2[1]["caused_by"].as_array().unwrap(); assert_eq!(caused_by2.len(), 1); - assert!(caused_by2[0]["a"].as_str().unwrap().ends_with("python") || - caused_by2[0]["b"].as_str().unwrap().ends_with("python")); + assert!( + caused_by2[0]["a"].as_str().unwrap().ends_with("python") + || caused_by2[0]["b"].as_str().unwrap().ends_with("python") + ); assert_eq!( - hist2[0]["thread_post_index"], - 0, + hist2[0]["thread_post_index"], 0, "first hist-test post is chronological index 0" ); assert_eq!( - hist2[1]["thread_post_index"], - 1, + hist2[1]["thread_post_index"], 1, "second ingest is chronological index 1" ); @@ -1675,12 +1777,27 @@ async fn pair_returns_connectivity_stats() { let pair = &pair_body["results"][0]["result"]["Pair"]; let conn = &pair["connectivity"]; - assert!(!conn.is_null(), "pair response should include connectivity stats"); + assert!( + !conn.is_null(), + "pair response should include connectivity stats" + ); assert_eq!(conn["items"].as_u64().unwrap(), 4, "4 items in scope"); - assert_eq!(conn["components"].as_u64().unwrap(), 3, "3 components (1 connected + 2 isolates)"); - assert_eq!(conn["comparisons_until_connected"].as_u64().unwrap(), 2, "need 2 more comparisons"); + assert_eq!( + conn["components"].as_u64().unwrap(), + 3, + "3 components (1 connected + 2 isolates)" + ); + assert_eq!( + conn["comparisons_until_connected"].as_u64().unwrap(), + 2, + "need 2 more comparisons" + ); assert_eq!(conn["pairs_voted"].as_u64().unwrap(), 1, "1 pair voted"); - assert_eq!(conn["pairs_possible"].as_u64().unwrap(), 6, "4*3/2 = 6 possible pairs"); + assert_eq!( + conn["pairs_possible"].as_u64().unwrap(), + 6, + "4*3/2 = 6 possible pairs" + ); let doc2 = serde_json::json!([{ "Post": { @@ -1693,8 +1810,7 @@ async fn pair_returns_connectivity_stats() { }]); let resp2 = rpc_batch(&client, addr, Some(&test_bearer()), doc2).await; assert_eq!( - resp2["results"][0]["ok"], - true, + resp2["results"][0]["ok"], true, "second ingest failed: {:?}", resp2 ); @@ -1713,8 +1829,19 @@ async fn pair_returns_connectivity_stats() { .await; let pair2 = &pair_body2["results"][0]["result"]["Pair"]; let conn2 = &pair2["connectivity"]; - assert_eq!(conn2["components"].as_u64().unwrap(), 2, "2 components after connecting c"); - assert_eq!(conn2["comparisons_until_connected"].as_u64().unwrap(), 1, "1 more comparison to connect"); - assert_eq!(conn2["pairs_voted"].as_u64().unwrap(), 2, "2 pairs voted now"); + assert_eq!( + conn2["components"].as_u64().unwrap(), + 2, + "2 components after connecting c" + ); + assert_eq!( + conn2["comparisons_until_connected"].as_u64().unwrap(), + 1, + "1 more comparison to connect" + ); + assert_eq!( + conn2["pairs_voted"].as_u64().unwrap(), + 2, + "2 pairs voted now" + ); } - diff --git a/test/browser_github_resolver.clj b/test/browser_github_resolver.clj index 1d5b6f1c4575e67ec430e4ce7f02c1bca5a35775..fee272099457ab1f4478fff9aed0f5c08ea2e85e 100644 --- a/test/browser_github_resolver.clj +++ b/test/browser_github_resolver.clj @@ -21,6 +21,18 @@ (do (Thread/sleep 200) (recur)) false)))))) +(defn- wait-for-http-text [url expected timeout-ms] + (let [deadline (+ (System/currentTimeMillis) timeout-ms)] + (loop [] + (let [text (try + (:body (oauth/http-get url)) + (catch Exception _ nil))] + (if (and (string? text) (str/includes? text expected)) + true + (if (< (System/currentTimeMillis) deadline) + (do (Thread/sleep 200) (recur)) + false)))))) + (defn- start-mock-github [port] (let [!paths (atom []) handler (fn [req] @@ -112,12 +124,21 @@ (locator/click (page/locator pg "[data-testid=\"github-resolve-children\"]")) (is (wait-for-text pg "body" "-/github.com/octo/hello/pulls" 15000) "repo resolver imports structural children") + (is (wait-for-text pg "body" "-/github.com/octo/hello/commits" 15000) + "repo resolver imports commits section") + (is (wait-for-text pg "body" "-/github.com/octo/hello/releases" 15000) + "repo resolver imports releases section") (page/navigate pg (str base-url "/-/github.com/octo/hello/issues/42")) (locator/click (page/locator pg "[data-testid=\"github-resolve-siblings\"]")) - (page/navigate pg (str base-url "/-/github.com/octo/hello/issues")) - (is (wait-for-text pg "body" "-/github.com/octo/hello/issues/43" 15000) - "issue sibling resolver imports issue siblings"))))) + (is (wait-for-http-text (str base-url "/-/github.com/octo/hello/issues") + "-/github.com/octo/hello/issues/43" + 15000) + "issue sibling resolver persisted issue siblings") + (core/with-page [pg2 (core/new-page-from-context ctx)] + (page/navigate pg2 (str base-url "/-/github.com/octo/hello/issues")) + (is (wait-for-text pg2 "body" "-/github.com/octo/hello/issues/43" 15000) + "issue sibling resolver imports issue siblings")))))) (is (some #{"/users/octo/repos"} @(:paths @!github)) "mock GitHub saw user repos request") diff --git a/test/browser_vote_compare.clj b/test/browser_vote_compare.clj index f97a4a45e89c388f093694a1bf296f674bcb377a..7cd1e467fdd8a4480df1be2afd78adb45b9cdc6d 100644 --- a/test/browser_vote_compare.clj +++ b/test/browser_vote_compare.clj @@ -50,9 +50,11 @@ thread-tag "browser-vote-compare" left-url "https://slug.social/~/gp-vote-a" right-url "https://slug.social/~/gp-vote-b" + third-url "https://slug.social/~/gp-vote-c" raw (str "# " thread-tag "\n\n" "~/gp-vote-a {one}\n" "~/gp-vote-b {two}\n" + "~/gp-vote-c {three}\n" "{seed edge vote}\n" "~/gp-vote-a 1:1 ~/gp-vote-b\n") post-resp (oauth/http-post-json @@ -64,7 +66,7 @@ :headers {"Authorization" (str "Bearer " alice-token)}) post-json (json/parse-string (:body post-resp) false) _ (is (true? (get-in post-json ["results" 0 "ok"])) "seed items + edge vote via rpc") - cmp-url (str base-url "/vote/compare?left=" (enc left-url) "&right=" (enc right-url))] + cmp-url (str base-url "/vote/compare?left=" (enc left-url) "&right=" (enc third-url))] (core/with-playwright [pw] (core/with-browser [browser (core/launch-chromium pw {:headless true :channel "chrome"})] (core/with-context [ctx (core/new-context browser)] @@ -74,12 +76,17 @@ (page/navigate pg cmp-url) ;; No .vote-compare-shell wrapper — wait on stable vote-compare UI instead. (is (wait-for-text pg "body.view-vote-compare" "compare" 15000) "vote compare page") - (is (wait-for-text pg "ul.vote-edge-history" "seed edge vote" 15000) - "edge history lists canonical-order vote") + (is (wait-for-text pg "#vote-edge-history-region" "no votes on this pair" 15000) + "current pair starts without edge history") (locator/fill (page/locator pg "#vote-explain") "because playwright says so") (locator/click (page/locator pg "#vote-compare-form button[type=submit]")) (is (wait-for-text pg "ul.vote-edge-history" "because playwright" 20000) - "new vote appears in edge history after morph")))))) + "new vote appears in edge history after morph") + (locator/click (page/locator pg "[data-testid=\"vote-next-pair\"]")) + (is (wait-for-text pg ".vote-compare-pair" "~/gp-vote-b" 15000) + "post-success next-pair nav points to a different pair") + (is (wait-for-text pg ".vote-compare-pair" "~/gp-vote-c" 15000) + "next pair keeps the unvoted third item")))))) (finally (when-some [s @!server] (common/kill-server s))