Side A ships production deployment infrastructure (Dockerfile, fly.toml, CI test+deploy workflow), fixes real correctness issues (git auth via GITHUB_TOKEN for private mirrors, requiring OPENROUTER_API_KEY before contested ranking, deduping redundant object-hash checks), and adds a functioning audited dashboard backed by new unit/integration tests. Side B is a solid but narrower fix (private-room URL prefixing across many RPC call sites plus a cookie-based theme switch) with good targeted tests, but it lacks the operational/deployment impact and breadth of correctness fixes found in A.
constitution · epochs · watch · epoch 3
c_7a129e904906 (tommy-mor) vs c_3f420a1f5aa1 (tommy-mor)
download prompt · raw event · cmp_3776e5142e39d3
council reasoning
A ships production deployment (Dockerfile, fly.toml, main-branch CI), multi-repo roots/contributors, GitHub-auth mirrors, epoch retry, and a tested live /watch audit SSE surface that makes the constitutional process operable and observable. B improves theme persistence via cookies and correctly room-prefixes many RPC/web URLs, which is real lasting API fix work, but it is incremental product polish relative to A’s end-to-end deployable constitution runtime.
Side A delivers substantial operational and product functionality: it adds production deployment (Dockerfile, Fly config, GitHub Actions), a live audit dashboard with SSE-backed progress and status APIs, improves emission processing and retry behavior, optimizes Git object verification to avoid redundant work, and adds tests for the new behavior. Side B is a useful but narrower feature set, primarily introducing persistent theme handling across pages and correct room-aware URL generation for RPC/web responses, with corresponding plumbing and helper functions.
sides
A — c_7a129e904906 (tommy-mor)
message
[4e327784] deploy live constitution dashboard Expose auditable progress and event streaming, configure the production roots and runtime, and make tested main-branch commits the deployment authority. Co-authored-by: Cursor <cursoragent@cursor.com>
diff preview
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000000000000000000000000000000000000..c9d63a722beba0a0297fc853089332c460ab78dd
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+.git
+.venv
+.hypothesis
+__pycache__
+tests
+*.json
+*.bsp
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000000000000000000000000000000000000..76dcdf82d53177c1e47d86b23a54523239d232a6
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,49 @@
+name: Test and deploy
+
+on:
+ push:
+ branches: [main]
+
+concurrency:
+ group: production
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: astral-sh/setup-uv@v6
+ with:
+ enable-cache: true
+
+ - name: Run Python tests
+ run: uv run pytest -q
+
+ - name: Install Babashka
+ run: |
+ curl -fsSL https://raw.githubusercontent.com/babashka/babashka/master/install \
+ | sudo bash -s -- --dir /usr/local/bin
+
+ - name: Run process integration tests
+ run: bb TEST.sh
+
+ deploy:
+ needs: test
+ runs-on: ubuntu-latest
+ environment:
+ name: production
+ url: https://token.slug.social
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: superfly/flyctl-actions/setup-flyctl@master
+
+ - name: Deploy to Fly
+ run: flyctl deploy --remote-only
+ env:
+ FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..c9a5c00782371c19ad5ab5c58cf6f5a8ffec0141
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,17 @@
+FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends git ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+COPY pyproject.toml uv.lock ./
+RUN uv sync --frozen --no-install-project
+
+COPY constitution.py ./
+
+ENV PATH="/app/.venv/bin:${PATH}" \
+ PYTHONUNBUFFERED="1"
+
+EXPOSE 8080
+CMD ["python", "constitution.py"]
diff --git a/constitution.py b/constitution.py
index 4bee9f83663ab7fb36db95129b92b64b4ef57258..a58257e1881b21d1d6fa8e68a3faa222e4f661ef 100644
--- a/constitution.py
+++ b/constitution.py
@@ -24,12 +24,12 @@ A daily GitHub Action backs up the JSONL ledger to the same repo.
Run: uv run constitution.py
"""
-from decimal import Decimal, getcontext
+from decimal import Decimal, getcontext, DefaultContext
from datetime import datetime, timezone
from fastapi import FastAPI, Request, Response
from fastapi.responses import PlainTextResponse, HTMLResponse
from starlette.middleware.sessions import SessionMiddleware
-import json, time, os, asyncio, httpx, pathlib, subprocess, hashlib, re, fcntl
+import json, time, os, asyncio, httpx, pathlib, subprocess, hashlib, re, fcntl, base64
import sympy as sp # type: ignore[reportMissingImports]
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
from evaleval import (
@@ -37,6 +37,7 @@ from evaleval import (
exec_event, One, Two, Three, Selector, MORPH, PREPEND,
)
+DefaultContext.prec = 50
getcontext().prec = 50
app = FastAPI()
@@ -129,14 +130,47 @@ OPENROUTER_BASE_URL = os.environ.get("OPENROUTER_BASE_URL", "https://openrouter.
# using the exact same source; their normalized values are committed to every
# discovery event.
DEFAULT_REPOSITORIES = [
+ {
+ "id": "constitution",
+ "url": "https://github.com/sortersocial/constitution.git",
+ "refs": ["refs/heads/**"],
+ },
{
"id": "slug",
- "url": "https://github.com/tommy-mor/slug.git",
+ "url": "https://github.com/sortersocial/slug.git",
+ "refs": ["refs/heads/**"],
+ },
+ {
+ "id": "sorter",
+ "url": "https://github.com/sorterisntonline/sorter.git",
+ "refs": ["refs/heads/**"],
+ },
+ {
+ "id": "sorter2",
+ "url": "https://github.com/sortersocial/sorter2.git",
+ "refs": ["refs/heads/**"],
+ },
+ {
+ "id": "sorter-oldest",
+ "url": "https://github.com/tommy-mor/sorter.git",
"refs": ["refs/heads/**"],
},
]
DEFAULT_CONTRIBUTORS = {
"tommy-mor": ["thmorriss@gmail.com"],
+ "christopher-whitman": [
+ "chris@cwwhitman.com",
+ "7566903+cwwhitman@users.noreply.github.com",
+ ],
+ "jake-chvatal": [
+ "jake+github@uln.industries",
+ "jakechvatal@gmail.com",
+ "jake@isnt.online",
+ ],
+ "lara": ["me@lara.lv"],
+ "nat-reid": ["nathanielreid@gmail.com"],
+ "zod": ["jason.p.mcel@gmail.com", "me@zod.tf"],
+ "jovan": ["jovan@slug.social", "jovan@getcivicai.com"],
}
REPOSITORIES = json.loads(
@@ -147,6 +181,7 @@ CONTRIBUTORS = json.loads(
)
GIT_MIRROR_DIR = pathlib.Path(os.environ.get("GIT_MIRROR_DIR", "/data/git"))
GIT_TIMEOUT_SECONDS = int(os.environ.get("GIT_TIMEOUT_SECONDS", "120"))
+GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
# Council model IDs: slug.social garden rank under this parent (bodies = OpenRouter URLs), then top-up from OpenRouter list.
SLUG_SOCIAL_BASE_URL = os.environ.get("SLUG_SOCIAL_BASE_URL", "https://slug.social").rstrip("/")
@@ -661,20 +696,30 @@ def _git(repo: pathlib.Path | None, *args: str, input_bytes: bytes | None = None
if repo is not None:
command += ["-C", str(repo)]
command += list(args)
+ git_env = {
+ **os.environ,
+ "GIT_CONFIG_NOSYSTEM": "1",
+ "GIT_CONFIG_GLOBAL": os.devnull,
+ "GIT_NO_REPLACE_OBJECTS": "1",
+ "LC_ALL": "C",
+ "TZ": "UTC",
+ }
+ if GITHUB_TOKEN:
+ credential = base64.b64encode(
+ f"x-access-token:{GITHUB_TOKEN}".encode()
+ ).decode()
+ git_env.update({
+ "GIT_CONFIG_COUNT": "1",
+ "GIT_CONFIG_KEY_0": "http.https://github.com/.extraHeader",
+ "GIT_CONFIG_VALUE_0": f"Authorization: Basic {credential}",
+ })
try:
result = subprocess.run(
command,
input=input_bytes,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
- env={
- **os.environ,
- "GIT_CONFIG_NOSYSTEM": "1",
- "GIT_CONFIG_GLOBAL": os.devnull,
- "GIT_NO_REPLACE_OBJECTS": "1",
- "LC_ALL": "C",
- "TZ": "UTC",
- },
+ env=git_env,
timeout=GIT_TIMEOUT_SECONDS,
check=False,
)
@@ -844,9 +889,15 @@ def _build_discovery(epoch_n: int, boundary_ms: int, events: list) -> GitDiscove
canonical_location = min(
locations[qualified_oid], key=lambda x: (x[0], x[1])
)
+ # One commit may be reachable from dozens of refs in the same mirror.
+ # Verify its object once per repository, not once per source ref.
+ object_locations = {
+ (str(m), raw_oid): (m, raw_oid)
+ for _, _, m, raw_oid in locations[qualified_oid]
+ }
object_hashes = {
hashlib.sha256(_git(m, "cat-file", "commit", raw_oid)).hexdigest()
- for _, _, m, raw_oid in locations[qualified_oid]
+ for m, raw_oid in object_locations.values()
}
if len(object_hashes) != 1:
raise RuntimeError(f"conflicting Git objects share OID {qualified_oid}")
@@ -994,11 +1045,55 @@ async def discover_repositories(epoch_n: int, boundary_ms: int) -> GitDiscovery:
SSE_CLIENTS = []
+AUDIT_HISTORY = []
+AUDIT_SEQUENCE = 0
+PROCESS_STATE = {
+ "running": False,
+ "phase": "idle",
+ "progress": 100,
+ "message": "Waiting for the next epoch",
+}
+
+
+def _sse_event(event_name: str, payload: dict) -> str:
+ return (
+ f"event: {event_name}\n"
+ f"data: {json.dumps(payload, separators=(',', ':'))}\n\n"
+ )
+
+
+async def broadcast_audit(
+ kind: str,
+ message: str,
+ *,
+ progress: int | None = None,
+ phase: str | None = None,
+) -> dict:
+ global AUDIT_SEQUENCE
+ AUDIT_SEQUENCE += 1
+ if progress is not None:
+ PROCESS_STATE["progress"] = max(0, min(100, int(progress)))
+ if phase is not None:
+ PROCESS_STATE["phase"] = phase
+ PROCESS_STATE["message"] = message
+ payload = {
+ "id": AUDIT_SEQUENCE,
+ "timestamp_ms": int(time.time() * 1000),
+ "kind": kind,
+ "message": message,
+ **PROCESS_STATE,
+ }
+ AUDIT_HISTORY.append(payload)
+ del AUDIT_HISTORY[:-200]
+ wire = _sse_event("audit", payload)
+ for queue in list(SSE_CLIENTS):
+ await queue.put(wire)
+ return payload
async def broadcast_js(js: str):
"""Send a JS snippet to all connected SSE clients."""
- for queue in SSE_CLIENTS:
+ for queue in list(SSE_CLIENTS):
await queue.put(js)
@@ -1006,10 +1101,29 @@ async def rank_commits(commits: list[dict]):
if not commits:
return {}, []
- models = await fetch_top_models(n=3)
contributors = sorted(set(c["contributor"] for c in commits))
- if len(contributors) > 1 and not models:
+ if len(contributors) == 1:
+ await broadcast_audit(
+ "ranking",
+ f"Only {contributors[0]} is eligible; rank is 1.0",
+ progress=90,
+ phase="finalizing",
+ )
+ return {contributors[0]: Decimal("1")}, []
+ if not (OPENROUTER_API_KEY or "").strip():
+ raise RuntimeError(
+ "OPENROUTER_API_KEY is required when multiple contributors need ranking"
+ )
+
+ models = await fetch_top_models(n=3)
+ if not models:
raise RuntimeError("no council models available for contributor ranking")
+ await broadcast_audit(
+ "council",
+ f"Council selected: {', '.join(models)}",
+ progress=35,
+ phase="ranking",
+ )
await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][
["div.log-council", f"Council: {', '.join(models)} — {len(commits)} commits"]
]))
@@ -1035,6 +1149,11 @@ async def rank_commits(commits: list[dict]):
async def compare_fn(i, j):
a1, a2 = authors[i], authors[j]
+ await broadcast_audit(
+ "comparison",
+ f"Comparing {a1} with {a2}",
+ phase="ranking",
+ )
await broadcast_js(exec_event(Three[Selector("#emission-status")][MORPH][
["div#emission-status", f"Comparing {a1} vs {a2}…"]
]))
@@ -1050,6 +1169,11 @@ async def rank_commits(commits: list[dict]):
if winner_weight <= 0 or loser_weight <= 0:
raise ValueError("ratio weights must be positive")
results.append((w, l, winner_weight, loser_weight))
+ await broadcast_audit(
+ "vote",
+ f"{model}: {authors[w]} over {authors[l]} ({result['ratio']})",
+ phase="ranking",
+ )
await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][
["div.log-vote",
["span.model", model], " — ",
@@ -1059,6 +1183,11 @@ async def rank_commits(commits: list[dict]):
]
]))
except Exception as e:
+ await broadcast_audit(
+ "error",
+ f"{model} failed: {e}",
+ phase="error",
+ )
await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][
["div.log-error", f"⚠ {model}: {e}"]
]))
@@ -1068,8 +1197,13 @@ async def rank_commits(commits: list[dict]):
async def progress_fn(ev):
if ev["phase"] == "spanning_tree":
label = f"Spanning tree: {ev['step']}/{ev['total']}"
+
… preview truncated; 28,089 characters omittedB — c_3f420a1f5aa1 (tommy-mor)
message
[2fe70b0e] themes
diff preview
diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs
index 559db65de14c6157690ffbf18eca0cf65b0a5202..b3631b06153d52f88348fef927e7a024b7b85ad6 100644
--- a/server/src/api/auth.rs
+++ b/server/src/api/auth.rs
@@ -1,7 +1,7 @@
use axum::{
body::Body,
extract::{Path, Query, State},
- http::{header, HeaderMap, HeaderValue, StatusCode},
+ http::{header, HeaderMap, HeaderValue, StatusCode, Uri},
response::{IntoResponse, Redirect, Response},
Form, Json,
};
@@ -17,7 +17,7 @@ use crate::{
events::{Event, GrantAdded, TokenIssued, UserRegistered},
html::{
auth_complete_page, auth_signed_in_fragment, choose_username_error_fragment,
- choose_username_page, JsBuilder,
+ choose_username_page, theme_cookie_header_from_jar, theme_from_jar, theme_next_from_uri, JsBuilder,
},
identity::{parse_agent, parse_username},
reducer::ReducerState,
@@ -48,15 +48,17 @@ fn js_form_error_fragment(session: &str, error: &str) -> Response {
.into_response()
}
-fn js_signed_in_fragment(bearer: &str) -> Response {
+fn js_signed_in_fragment(bearer: &str, jar: &CookieJar) -> Response {
let mut response = JsBuilder::new()
.id("choose-username-form")
.morph_inner(auth_signed_in_fragment())
.redirect("/auth/complete")
.into_response();
- response
- .headers_mut()
- .insert(header::SET_COOKIE, session_cookie_header_value(bearer));
+ let headers = response.headers_mut();
+ headers.append(header::SET_COOKIE, session_cookie_header_value(bearer));
+ if let Some(theme) = theme_cookie_header_from_jar(jar) {
+ headers.append(header::SET_COOKIE, theme);
+ }
response
}
@@ -69,13 +71,18 @@ pub fn optional_principal(headers: &HeaderMap, jar: &CookieJar, reduced: &Reduce
verify_token(reduced, c.value()).ok()
}
-fn redirect_with_session_cookie(public_url: &str, path_and_query: &str, bearer: &str) -> Response {
- Response::builder()
+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}"))
- .header(header::SET_COOKIE, session_cookie_header_value(bearer))
.body(Body::empty())
- .unwrap()
+ .unwrap();
+ let headers = res.headers_mut();
+ headers.append(header::SET_COOKIE, session_cookie_header_value(bearer));
+ if let Some(theme) = theme_cookie_header_from_jar(jar) {
+ headers.append(header::SET_COOKIE, theme);
+ }
+ res
}
async fn apply_invite_redemption(state: &AppState, invite_token: &str, grantee_username: &str) -> Result<(), String> {
@@ -286,7 +293,11 @@ pub struct AuthCallbackQuery {
pub state: String,
}
-pub async fn get_auth_callback(Query(q): Query<AuthCallbackQuery>, State(state): State<AppState>) -> impl IntoResponse {
+pub async fn get_auth_callback(
+ Query(q): Query<AuthCallbackQuery>,
+ State(state): State<AppState>,
+ jar: CookieJar,
+) -> impl IntoResponse {
let sessions = pending_sessions(&state);
{
let sessions_read = sessions.read().await;
@@ -365,7 +376,7 @@ pub async fn get_auth_callback(Query(q): Query<AuthCallbackQuery>, State(state):
}
let cookie_bearer = bearer.clone();
s.complete = Some((username, bearer));
- return redirect_with_session_cookie(&public_url, "/", &cookie_bearer).into_response();
+ return redirect_with_session_cookie(&public_url, "/", &cookie_bearer, &jar).into_response();
}
}
@@ -378,14 +389,20 @@ pub struct ChooseUsernameQuery {
pub error: Option<String>,
}
-pub async fn get_choose_username(Query(q): Query<ChooseUsernameQuery>, State(state): State<AppState>) -> impl IntoResponse {
+pub async fn get_choose_username(
+ Query(q): Query<ChooseUsernameQuery>,
+ State(state): State<AppState>,
+ jar: CookieJar,
+ 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();
}
drop(sessions_read);
- choose_username_page(&q.session, q.error.as_deref()).into_response()
+ let next = theme_next_from_uri(&uri);
+ choose_username_page(&q.session, q.error.as_deref(), theme_from_jar(&jar), &next).into_response()
}
#[derive(Debug, Deserialize)]
@@ -396,6 +413,7 @@ pub struct ChooseUsernameForm {
pub async fn post_choose_username(
State(state): State<AppState>,
+ jar: CookieJar,
Form(form): Form<ChooseUsernameForm>,
) -> impl IntoResponse {
let canon_user = match parse_username(&form.username) {
@@ -477,7 +495,7 @@ pub async fn post_choose_username(
s.complete = Some((canon_user.clone(), bearer.clone()));
}
- js_signed_in_fragment(&bearer).into_response()
+ js_signed_in_fragment(&bearer, &jar).into_response()
}
/// Start a browser-only OAuth flow (no CLI polling). Sets session cookie on success.
@@ -569,8 +587,9 @@ pub async fn get_pending_session(
.into_response()
}
-pub async fn get_auth_complete() -> impl IntoResponse {
- auth_complete_page()
+pub async fn get_auth_complete(jar: CookieJar, uri: Uri) -> impl IntoResponse {
+ let next = theme_next_from_uri(&uri);
+ auth_complete_page(theme_from_jar(&jar), &next).into_response()
}
pub async fn get_whoami(State(state): State<AppState>, headers: HeaderMap) -> impl IntoResponse {
diff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs
index 81e2a55fa3abb8609b4099f91a989480336e11eb..9b71491e9f9efc44a2a4beba09be8f64bd2ff2ee 100644
--- a/server/src/api/helpers.rs
+++ b/server/src/api/helpers.rs
@@ -39,6 +39,55 @@ pub fn item_path_for_api(item: &str) -> String {
}
}
+/// Same as [`item_path_for_api`], but for private rooms ontology items are prefixed with
+/// `/r/{short}/{slug}` so the URL matches the web app (`/r/…/~/…` routes).
+pub fn item_path_for_api_in_room(item: &str, room_wire: &str) -> String {
+ let room = room_wire.trim();
+ if room.is_empty() || room == "public" {
+ return item_path_for_api(item);
+ }
+ let Some((short, slug)) = room.split_once('/') else {
+ return item_path_for_api(item);
+ };
+ if short.is_empty() || slug.is_empty() {
+ return item_path_for_api(item);
+ }
+ let Some(c) = CanonicalItemUrl::parse(item) else {
+ return item_path_for_api(item);
+ };
+ let root = CanonicalItemUrl::ontology_root();
+ let item_norm = c.as_str().trim_end_matches('/');
+ let root_norm = root.as_str().trim_end_matches('/');
+ if let Some(tail) = c.tilde_tail() {
+ return if tail.is_empty() {
+ format!("https://slug.social/r/{short}/{slug}/~")
+ } else {
+ format!("https://slug.social/r/{short}/{slug}/~/{}", tail)
+ };
+ }
+ if item_norm == root_norm {
+ return format!("https://slug.social/r/{short}/{slug}/~");
+ }
+ item_path_for_api(item)
+}
+
+/// Absolute thread URL for forum JSON (`/t/…` vs `/r/…/t/…`).
+pub fn forum_thread_web_url(room_wire: &str, thread_tag: &str) -> String {
+ let room = room_wire.trim();
+ let tag = thread_tag.trim().trim_start_matches('#');
+ if room.is_empty() || room == "public" {
+ format!("https://slug.social/t/{tag}")
+ } else if let Some((short, slug)) = room.split_once('/') {
+ if short.is_empty() || slug.is_empty() {
+ format!("https://slug.social/t/{tag}")
+ } else {
+ format!("https://slug.social/r/{short}/{slug}/t/{tag}")
+ }
+ } else {
+ format!("https://slug.social/t/{tag}")
+ }
+}
+
/// Resolve an item path as a first-class canonical path.
pub fn resolve_item(item: &str) -> Result<String, String> {
let canonical = canonicalize_item(item);
@@ -188,3 +237,52 @@ pub fn vote_touches_path(a: &str, b: &str, parent_canon: &str) -> bool {
let under = |item: &str| item == parent_canon || item.starts_with(&format!("{}/", parent_canon));
under(a) || under(b)
}
+
+#[cfg(test)]
+mod wire_url_tests {
+ use super::{forum_thread_web_url, item_path_for_api_in_room};
+
+ #[test]
+ fn public_room_unchanged() {
+ let u = "https://slug.social/~/a/b";
+ assert_eq!(item_path_for_api_in_room(u, "public"), u);
+ }
+
+ #[test]
+ fn private_room_prefixes_ontology() {
+ assert_eq!(
+ item_path_for_api_in_room("https://slug.social/~/topic/x", "9ab12cd/my-room"),
+ "https://slug.social/r/9ab12cd/my-room/~/topic/x"
+ );
+ }
+
+ #[test]
+ fn private_room_ontology_root() {
+ assert_eq!(
+ item_path_for_api_in_room("https://slug.social/~", "9ab12cd/my-room"),
+ "https://slug.social/r/9ab12cd/my-room/~"
+ );
+ assert_eq!(
+ item_path_for_api_in_room("https://slug.social/~/", "9ab12cd/my-room"),
+ "https://slug.social/r/9ab12cd/my-room/~"
+ );
+ }
+
+ #[test]
+ fn external_url_untouched_in_private_room() {
+ let u = "https://example.com/z";
+ assert_eq!(item_path_for_api_in_room(u, "9ab12cd/my-room"), u);
+ }
+
+ #[test]
+ fn forum_web_public_vs_room() {
+ assert_eq!(
+ forum_thread_web_url("public", "debate"),
+ "https://slug.social/t/debate"
+ );
+ assert_eq!(
+ forum_thread_web_url("9ab12cd/my-room", "#debate"),
+ "https://slug.social/r/9ab12cd/my-room/t/debate"
+ );
+ }
+}
diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs
index 31f5fcfb4eaf4df0a9cbac532dd3dedfe3611810..5b91f5836625eedbb1cd9423168046e3fb576c17 100644
--- a/server/src/api/rpc.rs
+++ b/server/src/api/rpc.rs
@@ -27,8 +27,9 @@ use crate::{
use super::auth::verify_bearer_principal;
use super::helpers::{
- compute_connectivity_stats, is_pair_voted, item_path_for_api, now_ms, paginate_rankings,
- parse_parent_specs, pick_random_distinct, resolve_item, vote_touches_path,
+ compute_connectivity_stats, forum_thread_web_url, is_pair_voted, item_path_for_api,
+ item_path_for_api_in_room, now_ms, paginate_rankings, parse_parent_specs, pick_random_distinct,
+ resolve_item, vote_touches_path,
};
use super::validate::{normalize_room_and_thread, validate_ingest_document};
@@ -148,6 +149,7 @@ fn compute_scope_rank_changes(
parent: &str,
before: &crate::scope_rank::ChildrenRankings,
after: &crate::scope_rank::ChildrenRankings,
+ room_wire: &str,
) -> Option<ScopeRankChanges> {
fn build_positions(rankings: &crate::scope_rank::ChildrenRankings) -> HashMap<String, Option<RankPosition>> {
let mut map = HashMap::new();
@@ -182,7 +184,7 @@ fn compute_scope_rank_changes(
};
if changed {
changes.push(RankChange {
- item: item_path_for_api(&item),
+ item: item_path_for_api_in_room(&item, room_wire),
before: b,
after: a,
});
@@ -204,7 +206,7 @@ fn compute_scope_rank_changes(
parent: if parent.is_empty() {
"/".to_string()
} else {
- item_path_for_api(parent)
+ item_path_for_api_in_room(parent, room_wire)
},
changes,
})
@@ -256,6 +258,7 @@ fn build_rank_response_for_content(
offset: usize,
limit: Option<usize>,
want_percent: bool,
+ room_wire: &str,
) -> Result<RankResponse, RpcErr> {
let parent_owned = parent.map(|s| s.to_string());
let specs = parse_parent_specs(parent_owned.as_ref());
@@ -299,7 +302,7 @@ fn build_rank_response_for_content(
.ranked
.into_iter()
.map(|r| RankRow {
-
… preview truncated; 42,928 characters omittedHardlinks — judgments / attempts / prompt
judgments
attempts
Prompt text is loaded only by the download route.