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: [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 Side A — unified diff (full patch): 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']}" + percent = 35 + round(35 * ev["step"] / max(ev["total"], 1)) else: label = f"Zip pass {ev['pass']}: {ev['step']}/{ev['total']}" + percent = 70 + round(20 * ev["step"] / max(ev["total"], 1)) + await broadcast_audit( + "progress", label, progress=percent, phase="ranking" + ) await broadcast_js(exec_event(Three[Selector("#emission-status")][MORPH][ ["div#emission-status", label] ])) @@ -1083,6 +1217,12 @@ async def rank_commits(commits: list[dict]): scores = rank_centrality(pairs) ranking = {authors[i]: Decimal(str(scores[i])) for i in range(len(authors))} ranking_rows = sorted(ranking.items(), key=lambda x: x[1], reverse=True) + await broadcast_audit( + "ranking", + "Ranking: " + ", ".join(f"{a} {s:.4f}" for a, s in ranking_rows), + progress=90, + phase="finalizing", + ) await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][ ["div.log-ranking", ["b", "Ranking: "], @@ -1104,11 +1244,33 @@ def pool_remaining(events: list) -> Decimal: async def run_emission(epoch_n, boundary_ms): + PROCESS_STATE["running"] = True + await broadcast_audit( + "start", + f"Epoch {epoch_n} emission started", + progress=2, + phase="starting", + ) await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][ ["div.log-start", f"⚡ Epoch {epoch_n} emission started"] ])) + await broadcast_audit( + "discovery", + "Fetching configured repositories and snapshotting refs", + progress=8, + phase="discovery", + ) discovery = await discover_repositories(epoch_n, boundary_ms) + await broadcast_audit( + "discovery", + ( + f"Discovered {len(discovery.observations)} new commits; " + f"{len(discovery.commits)} are eligible" + ), + progress=30, + phase="discovery", + ) ranking, models = await rank_commits(discovery.commits) def make_emission(events): @@ -1146,6 +1308,13 @@ async def run_emission(epoch_n, boundary_ms): entry = await store.atomic(make_emission) if entry: + PROCESS_STATE["running"] = False + await broadcast_audit( + "complete", + f"Epoch {entry.epoch} complete; emitted {entry.total_emitted} SLG", + progress=100, + phase="idle", + ) await broadcast_js(exec_event(Three[Selector("#emission-log")][PREPEND][ ["div.log-amount", f"Pool {entry.pool_before} → emit {entry.total_emitted} → {entry.pool_after}"] @@ -1189,13 +1358,24 @@ async def distribute_usdc(holdings, treasury_balance): async def epoch_loop(): while True: epoch_n, current_start, next_boundary = current_epoch() + processed = {e.epoch for e in store.read() if isinstance(e, Emission)} + if epoch_n >= 0 and epoch_n not in processed: + try: + await run_emission(epoch_n, current_start) + except Exception as exc: + PROCESS_STATE["running"] = False + await broadcast_audit( + "error", + f"Epoch {epoch_n} failed: {exc}; retrying in 60 seconds", + phase="error", + ) + print(f"epoch {epoch_n} emission failed: {exc}", flush=True) + await asyncio.sleep(60) + continue + now = int(time.time() * 1000) wait_ms = next_boundary - now - if wait_ms <= 0: - processed = {e.epoch for e in store.read() if isinstance(e, Emission)} - if epoch_n not in processed and epoch_n >= 0: - await run_emission(epoch_n, current_start) await asyncio.sleep(60) elif wait_ms < 86_400_000: await broadcast_js(exec_event(Three[Selector("#emission-status")][MORPH][ @@ -1243,6 +1423,36 @@ async def get_ranking(): return {"ranking": latest.ranking, "epoch": latest.epoch} +@app.get("/api/status") +async def get_status(): + events = store.read() + discoveries = [e for e in events if isinstance(e, GitDiscovery)] + emissions = [e for e in events if isinstance(e, Emission)] + return { + **PROCESS_STATE, + "epoch": current_epoch()[0], + "openrouter_configured": bool((OPENROUTER_API_KEY or "").strip()), + "sse_clients": len(SSE_CLIENTS), + "latest_discovery": ( + { + "epoch": discoveries[-1].epoch, + "snapshot_id": discoveries[-1].snapshot_id, + "observations": len(discoveries[-1].observations), + "eligible_commits": len(discoveries[-1].commits), + } + if discoveries else None + ), + "latest_emission": ( + { + "epoch": emissions[-1].epoch, + "total_emitted": emissions[-1].total_emitted, + "ranking": emissions[-1].ranking, + } + if emissions else None + ), + } + + @app.get("/api/contributor/{github_username}") async def get_contributor(github_username: str): history = [ @@ -1306,13 +1516,6 @@ async def test_emit(): # =========================================================================== # §9. SSE — live audit stream of the pairwise voting process -# -# TODO: the /sse emission audit page needs a real SSE-driven UI. votes arrive -# incrementally during rank_commits(), and the client should show a live -# progress bar and per-vote results as they stream in. this requires a -# dedicated page that connects to /sse and updates the DOM on each event -# (council, comparing, vote, ranking, emission_complete). defer until we -# have playwright tests to cover it — the incremental rendering is fiddly. # =========================================================================== @app.get("/sse") @@ -1322,6 +1525,13 @@ async def sse_stream(request: Request): async def generate(): try: + yield _sse_event("audit", { + "id": AUDIT_SEQUENCE, + "timestamp_ms": int(time.time() * 1000), + "kind": "connection", + "message": f"Connected to epoch {current_epoch()[0]}", + **PROCESS_STATE, + }) yield exec_event(Three[Selector("#emission-status")][MORPH][ ["div#emission-status", f"Connected — epoch {current_epoch()[0]}"] ]) @@ -1334,10 +1544,15 @@ async def sse_stream(request: Request): except asyncio.TimeoutError: yield ": keepalive\n\n" finally: - SSE_CLIENTS.remove(queue) + if queue in SSE_CLIENTS: + SSE_CLIENTS.remove(queue) from starlette.responses import StreamingResponse - return StreamingResponse(generate(), media_type="text/event-stream") + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) # =========================================================================== @@ -1359,6 +1574,7 @@ def _page(title: str, body: list) -> HTMLResponse: ["meta", {"charset": "utf-8"}], ["meta", {"name": "viewport", "content": "width=device-width, initial-scale=1"}], ["title", title], + ["style", RawContent(_WATCH_CSS)], ], ["body", body, @@ -1367,6 +1583,387 @@ def _page(title: str, body: list) -> HTMLResponse: ])) +_WATCH_CSS = """ +/* ================================================================ + ZIGGURAT — bevel-first dark theme + --spread (0→1) controls bevel depth. 0 = flat. 1 = full relief. + Light source: top-left. Shadow: bottom-right. + Platforms nest. Each level is raised. Nothing is rounded. + ================================================================ */ + +:root { + color-scheme: dark; + --spread: 1; + + --g0: #080808; + --g1: #131313; + --g2: #1c1c1c; + --g3: #252525; + --g4: #2e2e2e; + --g5: #383838; + + --hi: #5e5e5e; + --lo: #050505; + --bv: calc(var(--spread) * 4px + 1px); + --bv-lg: calc(var(--spread) * 6px + 2px); + + --signal: #f0f0f0; + --prose: #c2c2c2; + --ui: #888; + --meta: #4a4a4a; + --link: #8899ee; + --code-fg: #c8dda0; + + --font-prose: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif; + --font-ui: system-ui, -apple-system, sans-serif; + --font-code: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace; +} + +*, *::before, *::after { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } + +body { + background: var(--g0); + color: var(--prose); + font-family: var(--font-ui); + font-size: 14px; + line-height: 1.6; + margin: 0 auto; + max-width: 560px; + min-height: 100vh; + padding: 0 16px 48px; +} +main { width: 100%; padding: 18px 0 48px; } + +h1, h2, h3 { + color: var(--signal); + font-size: 11px; + font-weight: bold; + letter-spacing: 0.12em; + margin: 14px 0 6px; + text-transform: uppercase; +} +a { color: var(--link); text-decoration: none; } +a:hover { color: var(--signal); } +.eyebrow { + background: var(--g2); + border: var(--bv) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + color: var(--ui); + font-size: 11px; + letter-spacing: 0.12em; + padding: 4px 10px; + text-transform: uppercase; + width: fit-content; +} + +/* Every dashboard section is a raised platform. */ +.panel { + background: var(--g2); + border: var(--bv-lg) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + margin: 8px 0; + padding: 10px; + width: 100%; +} +.status-row { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: space-between; +} +#process-status { color: var(--signal); font-family: var(--font-code); font-weight: bold; } +.badge { + align-items: center; + background: var(--g3); + border: var(--bv) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + color: var(--ui); + display: inline-flex; + font-size: 11px; + gap: 7px; + padding: 3px 8px; +} +.dot { background: var(--meta); height: 8px; width: 8px; } +.live .dot { background: #7acc7a; } +.warn .dot { background: #cc9955; } + +/* The progress track is inset; its signal is raised inside it. */ +.progress-shell { + background: var(--g1); + border: var(--bv-lg) solid; + border-color: var(--lo) var(--hi) var(--hi) var(--lo); + height: 58px; + margin: 14px 0 10px; + overflow: hidden; + position: relative; +} +#progress-fill { + background: var(--link); + border: var(--bv) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + height: 100%; + transition: width .35s steps(8, end); + width: 0; +} +#progress-label { + color: var(--signal); + display: grid; + font-family: var(--font-code); + font-size: 18px; + font-weight: bold; + inset: 0; + place-items: center; + position: absolute; + text-shadow: 1px 1px var(--lo); +} + +.controls { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; } +button { + background: var(--g5); + border: var(--bv) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + color: var(--signal); + cursor: pointer; + font: inherit; + font-size: 12px; + padding: 4px 10px; +} +button:hover { background: #404040; } +button:active { + background: var(--g4); + border-color: var(--lo) var(--hi) var(--hi) var(--lo); + transform: translate(1px, 1px); +} +button:disabled { cursor: default; opacity: .4; } +.note { color: var(--meta); font-size: 11px; margin: 4px 0; } + +.feed-head { align-items: baseline; display: flex; justify-content: space-between; } +#audit-feed { + background: var(--g1); + border: var(--bv) solid; + border-color: var(--lo) var(--hi) var(--hi) var(--lo); + display: flex; + flex-direction: column; + gap: 5px; + margin-top: 8px; + padding: 6px; +} +.event { + background: var(--g3); + border: var(--bv) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + display: grid; + gap: 6px; + grid-template-columns: 82px 88px 1fr; + padding: 5px 8px; +} +.event[data-kind="error"] { border-left-color: #cc5555; } +.event[data-kind="complete"], .event[data-kind="ranking"] { border-left-color: #7acc7a; } +.event[data-kind="vote"] { border-left-color: var(--link); } +.event time, .event-kind { color: var(--meta); font-family: var(--font-code); font-size: 10px; } +.event-kind { text-transform: uppercase; } +.event-message { color: var(--prose); font-family: var(--font-prose); } + +code { + background: var(--g1); + border: 2px solid; + border-color: var(--lo) var(--hi) var(--hi) var(--lo); + color: var(--code-fg); + font-family: var(--font-code); + font-size: 12px; + padding: 1px 4px; +} + +@media (max-width: 520px) { + .event { grid-template-columns: 72px 1fr; } + .event-message { grid-column: 1 / -1; } +} +""" + + +def _watch_initial_state() -> dict: + events = store.read() + feed = [] + for event_ in events[-40:]: + if isinstance(event_, GitDiscovery): + feed.append({ + "id": f"discovery-{event_.snapshot_id}", + "timestamp_ms": event_.timestamp_ms, + "kind": "discovery", + "message": ( + f"Epoch {event_.epoch}: observed {len(event_.observations)} commits; " + f"{len(event_.commits)} eligible" + ), + }) + elif isinstance(event_, Emission): + feed.append({ + "id": f"emission-{event_.epoch}", + "timestamp_ms": event_.timestamp_ms, + "kind": "complete", + "message": ( + f"Epoch {event_.epoch}: emitted {event_.total_emitted} SLG; " + f"ranking {event_.ranking}" + ), + }) + feed.extend(AUDIT_HISTORY) + return { + "process": dict(PROCESS_STATE), + "openrouter_configured": bool((OPENROUTER_API_KEY or "").strip()), + "epoch": current_epoch()[0], + "feed": feed[-200:], + } + + +_WATCH_JS = """ +const initial = __INITIAL__; +const feed = document.querySelector('#audit-feed'); +const processStatus = document.querySelector('#process-status'); +const connection = document.querySelector('#connection-status'); +const fill = document.querySelector('#progress-fill'); +const progressLabel = document.querySelector('#progress-label'); +const play = document.querySelector('#play'); +const pause = document.querySelector('#pause'); +const seen = new Set(); +let source = null; + +function setProgress(value) { + const n = Math.max(0, Math.min(100, Number(value ?? 0))); + fill.style.width = `${n}%`; + progressLabel.textContent = `${Math.round(n)}%`; + document.querySelector('.progress-shell').setAttribute('aria-valuenow', String(n)); +} + +function addEvent(event) { + const id = String(event.id); + if (seen.has(id)) return; + seen.add(id); + const row = document.createElement('div'); + row.className = 'event'; + row.dataset.kind = event.kind || 'event'; + const when = document.createElement('time'); + when.dateTime = new Date(event.timestamp_ms).toISOString(); + when.textContent = new Date(event.timestamp_ms).toLocaleTimeString(); + const kind = document.createElement('span'); + kind.className = 'event-kind'; + kind.textContent = event.kind || 'event'; + const message = document.createElement('span'); + message.className = 'event-message'; + message.textContent = event.message; + row.append(when, kind, message); + feed.prepend(row); + while (feed.children.length > 200) feed.lastElementChild.remove(); +} + +function applyState(event) { + processStatus.textContent = event.message || 'Waiting for the next epoch'; + setProgress(event.progress); + if (event.kind !== 'connection') addEvent(event); +} + +function connect() { + if (source) return; + source = new EventSource('/sse'); + connection.classList.remove('warn'); + connection.classList.add('live'); + connection.querySelector('span:last-child').textContent = 'connecting'; + play.disabled = true; + pause.disabled = false; + source.onopen = () => { + connection.querySelector('span:last-child').textContent = 'live'; + }; + source.addEventListener('audit', event => applyState(JSON.parse(event.data))); + source.onerror = () => { + connection.classList.remove('live'); + connection.classList.add('warn'); + connection.querySelector('span:last-child').textContent = 'reconnecting'; + }; +} + +function disconnect() { + if (source) source.close(); + source = null; + connection.classList.remove('live'); + connection.classList.add('warn'); + connection.querySelector('span:last-child').textContent = 'paused locally'; + play.disabled = false; + pause.disabled = true; +} + +play.addEventListener('click', connect); +pause.addEventListener('click', disconnect); +initial.feed.forEach(addEvent); +processStatus.textContent = initial.process.message; +setProgress(initial.process.progress); +connect(); +""" + + +@app.get("/watch") +async def watch(): + initial = json.dumps( + _watch_initial_state(), separators=(",", ":") + ).replace(" 0") - ;; 9. SSE connects and sends initial event + ;; 9. watch UI exposes progress, controls, readiness, and live SSE + (println "\nchecking /watch UI…") + (bind watch-html (slurp (str base-url "/watch"))) + (assert! (str/includes? watch-html "role=\"progressbar\"") + "watch page has progress bar") + (assert! (str/includes? watch-html "id=\"play\"") + "watch page has play control") + (assert! (str/includes? watch-html "id=\"pause\"") + "watch page has pause control") + (assert! (str/includes? watch-html "OpenRouter configured") + "watch page reports council readiness") + (bind status-resp (get-json base-url "/api/status")) + (assert! (true? (:openrouter_configured status-resp)) + "status API reports OpenRouter configuration") + + ;; 10. SSE connects and sends initial event (println "\nchecking /sse initial event…") (bind sse-events (read-sse-events (str base-url "/sse") 1 5000)) (assert! (= 1 (count sse-events)) "received 1 SSE event") (assert! (not (str/blank? (first sse-events))) "initial SSE event contains executable audit data") - ;; 10. POST /test/emit — full ranking pipeline hits mocks + ;; 11. POST /test/emit — full ranking pipeline hits mocks (println "\ntriggering /test/emit (epoch 1)…") (bind emit-resp (post-json! base-url "/test/emit")) (assert! (= "emission" (:type emit-resp)) "emit response type is emission") @@ -503,7 +518,7 @@ (bind rank-after (get-json base-url "/api/ranking")) (assert! (= 1 (:epoch rank-after)) "latest ranking is epoch 1") - ;; 11. kill and restart — prove replay determinism + ;; 12. kill and restart — prove replay determinism (println "\nkilling server for replay test…") (.destroyForcibly (:proc server)) (deref server) diff --git a/tests/test_git_discovery.py b/tests/test_git_discovery.py index 0dd31bc42a19bc8c59842dc61f193c595c474659..5a9e167e16ad2ffb25988af85820f6f19e8910cd 100644 --- a/tests/test_git_discovery.py +++ b/tests/test_git_discovery.py @@ -393,7 +393,9 @@ def test_empty_epoch_records_zero_emission_without_burning_pool( monkeypatch.setattr(c, "store", c.JsonlStore(discovery_config / "ledger.jsonl")) async def discover(_epoch, _boundary): - return SimpleNamespace(commits=[], snapshot_id="empty-snapshot") + return SimpleNamespace( + observations=[], commits=[], snapshot_id="empty-snapshot" + ) async def rank(_commits): return {}, [] @@ -412,7 +414,11 @@ def test_emission_distribution_sums_exactly_to_total( monkeypatch.setattr(c, "store", c.JsonlStore(discovery_config / "ledger.jsonl")) async def discover(_epoch, _boundary): - return SimpleNamespace(commits=[{"x": 1}], snapshot_id="ranked-snapshot") + return SimpleNamespace( + observations=[{"x": 1}], + commits=[{"x": 1}], + snapshot_id="ranked-snapshot", + ) async def rank(_commits): return { @@ -454,6 +460,7 @@ def test_any_council_failure_aborts_ranking(monkeypatch): monkeypatch.setattr(c, "fetch_top_models", models) monkeypatch.setattr(c, "llm_pairwise_compare", compare) + monkeypatch.setattr(c, "OPENROUTER_API_KEY", "test-key") commits = [ { "contributor": contributor, @@ -465,3 +472,54 @@ def test_any_council_failure_aborts_ranking(monkeypatch): ] with pytest.raises(RuntimeError, match="council model failed"): asyncio.run(c.rank_commits(commits)) + + +def test_contested_ranking_requires_openrouter_key(monkeypatch): + monkeypatch.setattr(c, "OPENROUTER_API_KEY", "") + commits = [ + { + "contributor": contributor, + "oid": "sha1:" + char * 40, + "message": contributor, + "patch": "patch", + } + for contributor, char in [("alice", "a"), ("bob", "b")] + ] + with pytest.raises(RuntimeError, match="OPENROUTER_API_KEY"): + asyncio.run(c.rank_commits(commits)) + + +def test_watch_page_has_live_controls_progress_and_key_warning( + discovery_config, monkeypatch +): + monkeypatch.setattr(c, "store", c.JsonlStore(discovery_config / "ledger.jsonl")) + monkeypatch.setattr(c, "OPENROUTER_API_KEY", "") + monkeypatch.setattr(c, "current_epoch", lambda: (3, 0, 1)) + response = asyncio.run(c.watch()) + html = response.body.decode() + assert 'role="progressbar"' in html + assert 'id="play"' in html + assert 'id="pause"' in html + assert "new EventSource('/sse')" in html + assert "OpenRouter key missing" in html + + +def test_audit_events_are_json_sse_and_update_process_state(monkeypatch): + clients = [] + history = [] + monkeypatch.setattr(c, "SSE_CLIENTS", clients) + monkeypatch.setattr(c, "AUDIT_HISTORY", history) + queue = asyncio.Queue() + clients.append(queue) + + async def emit(): + event = await c.broadcast_audit( + "progress", "halfway", progress=50, phase="ranking" + ) + return event, await queue.get() + + event, wire = asyncio.run(emit()) + assert event["progress"] == 50 + assert event["phase"] == "ranking" + assert wire.startswith("event: audit\ndata: {") + assert '"message":"halfway"' in wire Side B — contributor: tommy-mor Side B — commit message: [c94456ff] Make feed catch-up stable and permission-aware Anchor implicit feeds to durable ingest order and cover multi-user private-room visibility so concurrent posts are not missed or leaked. Co-authored-by: Cursor Side B — unified diff (full patch): diff --git a/cli/src/main.rs b/cli/src/main.rs index abb5a55b49f60fe28fbfd4ec02715cb94ea0b4ec..c4f1494df3aedbd8b883aea6249579aa8336abfe 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -878,6 +878,30 @@ mod tests { "graph: 4 items, 3/6 pairs (50.0% density), 1 component, connected" ); } + + #[test] + fn feed_without_since_uses_logged_in_delegate_from_env() { + let key = "SLUG_DELEGATE"; + let previous = std::env::var_os(key); + let expected = "00000000-0000-0000-0000-0000000000ee:test:local/model"; + std::env::set_var(key, expected); + + let cli = Cli::try_parse_from(["slugsocial", "feed"]).expect("parse feed"); + + match previous { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + match cli.cmd { + Some(Command::Feed { + delegate, since, .. + }) => { + assert_eq!(delegate.as_deref(), Some(expected)); + assert!(since.is_none()); + } + _ => panic!("expected feed command"), + } + } } async fn run_scoped(base: &str, room: &str, sub: ScopedCmd) -> Result<()> { @@ -1626,7 +1650,15 @@ async fn run() -> Result<()> { } else { for p in &resp.posts { let ago = slug_types::timeago::timeago(now_ms, p.ts); - println!("", p.id, ago); + let thread_attr = p + .thread + .as_deref() + .map(|thread| format!(" thread=\"{thread}\"")) + .unwrap_or_default(); + println!( + "", + p.id, ago, p.room, thread_attr + ); print!("{}", p.body); if !p.body.ends_with('\n') { println!(); } println!(""); diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs index afc4f95bef160c1e38ecff2c096d6440cd94e2b3..46d748f918d9b225acc4ecedfe5a1793089407b5 100644 --- a/server/src/api/rpc.rs +++ b/server/src/api/rpc.rs @@ -72,6 +72,80 @@ fn can_view_scope(reduced: &ReducerState, scope: &ScopeId, principal: Option<&st } } +/// Build a feed in durable ingest order. +/// +/// An implicit feed boundary is an ingest position, not only its millisecond timestamp. Two users +/// can post in the same millisecond, and wall-clock timestamps can move backwards during replay. +/// Explicit `since` remains a timestamp query for API compatibility, but scans the whole ordered +/// ledger rather than assuming timestamps are monotonic. +fn rpc_feed( + reduced: &ReducerState, + viewer: &str, + delegate: Option, + requested_since: Option, + implicit_anchor: Option<(usize, i64)>, + limit: usize, +) -> FeedResponse { + let since = requested_since.or_else(|| implicit_anchor.map(|(_, ts)| ts)); + let implicit_anchor_index = requested_since + .is_none() + .then(|| implicit_anchor.map(|(index, _)| index)) + .flatten(); + + let matching: Vec<&str> = reduced + .ingests_ordered + .iter() + .enumerate() + .rev() + .filter(|(index, id)| { + reduced.ingests_by_id.get(id.as_str()).is_some_and(|ing| { + match requested_since { + Some(cutoff) => ing.ts > cutoff, + None => implicit_anchor_index.is_none_or(|anchor| *index > anchor), + } + }) + }) + .map(|(_, id)| id.as_str()) + .filter(|id| { + reduced.ingests_by_id.get(*id).is_some_and(|ing| { + let scope = scope_from_room_wire(&ing.room_id); + can_view_scope(reduced, &scope, Some(viewer)) + }) + }) + .filter(|id| !reduced.redacted_posts.contains(*id)) + .collect(); + + let total = matching.len(); + let posts = matching + .into_iter() + .take(limit) + .filter_map(|id| reduced.ingests_by_id.get(id)) + .map(|ing| { + let scope = scope_from_room_wire(&ing.room_id); + let thread_post_index = reduced.try_thread_post_index_chronological( + &scope, + &ing.thread_tag, + &ing.id, + ); + FeedPost { + ts: ing.ts, + id: ing.id.clone(), + room: ing.room_id.clone(), + thread: Some(ing.thread_tag.clone()), + thread_post_index, + body: ing.raw.clone(), + } + }) + .collect(); + + FeedResponse { + delegate, + since, + posts, + total, + } +} + fn principal_from_optional_bearer(headers: &HeaderMap, reduced: &ReducerState) -> Result, RpcErr> { if headers.contains_key(axum::http::header::AUTHORIZATION) { verify_bearer_principal(headers, reduced) @@ -1506,60 +1580,27 @@ pub async fn handle_rpc_batch( Some("this delegate is not bound to your signed-in account".into()), ) } else { - let since_default = reduced + let implicit_anchor = reduced .ingests_ordered .iter() + .enumerate() .rev() - .filter_map(|id| reduced.ingests_by_id.get(id)) - .find(|ing| { - if ing.delegate.as_deref() != Some(delegate_stored.as_str()) { - return false; - } - let scope = scope_from_room_wire(&ing.room_id); - can_view_scope(&reduced, &scope, Some(viewer.as_str())) - }) - .map(|ing| ing.ts); - let since = since.or(since_default); - let cutoff = since.unwrap_or(0); - let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); - let matching: Vec<&str> = reduced.ingests_ordered.iter().rev() - .map(|id| id.as_str()) - .take_while(|id| reduced.ingests_by_id.get(*id).is_some_and(|ing| ing.ts > cutoff)) - .filter(|id| { - reduced.ingests_by_id.get(*id).is_some_and(|ing| { - let scope = scope_from_room_wire(&ing.room_id); - can_view_scope(&reduced, &scope, Some(viewer.as_str())) + .find_map(|(index, id)| { + reduced.ingests_by_id.get(id).and_then(|ing| { + (ing.delegate.as_deref() + == Some(delegate_stored.as_str())) + .then_some((index, ing.ts)) }) - }) - .filter(|id| !reduced.redacted_posts.contains(*id)) - .collect(); - let total = matching.len(); - let posts: Vec = matching.into_iter() - .take(limit) - .filter_map(|id| reduced.ingests_by_id.get(id)) - .map(|ing| { - let scope = scope_from_room_wire(&ing.room_id); - let thread_post_index = reduced - .try_thread_post_index_chronological( - &scope, - &ing.thread_tag, - &ing.id, - ); - FeedPost { - ts: ing.ts, - id: ing.id.clone(), - thread: Some(ing.thread_tag.clone()), - thread_post_index, - body: ing.raw.clone(), - } - }) - .collect(); - line_ok(RpcResult::Feed(FeedResponse { - delegate: Some(delegate_stored), + }); + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + line_ok(RpcResult::Feed(rpc_feed( + &reduced, + &viewer, + Some(delegate_stored), since, - posts, - total, - })) + implicit_anchor, + limit, + ))) }; drop(reduced); line @@ -1567,60 +1608,25 @@ pub async fn handle_rpc_batch( None => { // Session catch-up: last time *you* posted anything (delegate or not), so revisiting // an old chat with only a token still gets a sane cutoff. - let since_default = reduced + let implicit_anchor = reduced .ingests_ordered .iter() + .enumerate() .rev() - .filter_map(|id| reduced.ingests_by_id.get(id)) - .find(|ing| { - if ing.principal != viewer { - return false; - } - let scope = scope_from_room_wire(&ing.room_id); - can_view_scope(&reduced, &scope, Some(viewer.as_str())) - }) - .map(|ing| ing.ts); - let since = since.or(since_default); - let cutoff = since.unwrap_or(0); - let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); - let matching: Vec<&str> = reduced.ingests_ordered.iter().rev() - .map(|id| id.as_str()) - .take_while(|id| reduced.ingests_by_id.get(*id).is_some_and(|ing| ing.ts > cutoff)) - .filter(|id| { - reduced.ingests_by_id.get(*id).is_some_and(|ing| { - let scope = scope_from_room_wire(&ing.room_id); - can_view_scope(&reduced, &scope, Some(viewer.as_str())) + .find_map(|(index, id)| { + reduced.ingests_by_id.get(id).and_then(|ing| { + (ing.principal == viewer).then_some((index, ing.ts)) }) - }) - .filter(|id| !reduced.redacted_posts.contains(*id)) - .collect(); - let total = matching.len(); - let posts: Vec = matching.into_iter() - .take(limit) - .filter_map(|id| reduced.ingests_by_id.get(id)) - .map(|ing| { - let scope = scope_from_room_wire(&ing.room_id); - let thread_post_index = reduced - .try_thread_post_index_chronological( - &scope, - &ing.thread_tag, - &ing.id, - ); - FeedPost { - ts: ing.ts, - id: ing.id.clone(), - thread: Some(ing.thread_tag.clone()), - thread_post_index, - body: ing.raw.clone(), - } - }) - .collect(); - let line = line_ok(RpcResult::Feed(FeedResponse { - delegate: None, + }); + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + let line = line_ok(RpcResult::Feed(rpc_feed( + &reduced, + &viewer, + None, since, - posts, - total, - })); + implicit_anchor, + limit, + ))); drop(reduced); line } diff --git a/server/tests/integration_rooms.rs b/server/tests/integration_rooms.rs index a3b378267e090a016a7e663f103a0dd9f482690b..e623523ba352ce456674a399542c689505760b78 100644 --- a/server/tests/integration_rooms.rs +++ b/server/tests/integration_rooms.rs @@ -1,6 +1,9 @@ mod support; use slug_types::room_route_segment; +use slugsocial_server::events::{ + AgentBound, Event, GrantAdded, GrantRevoked, Ingest, RoomCreated, ThreadCapability, +}; use support::*; #[tokio::test] @@ -473,6 +476,247 @@ async fn test_feed_without_delegate_uses_principal_last_post_including_delegate( ); } +#[tokio::test] +async fn test_feed_uses_delegate_ingest_position_when_multi_user_timestamps_collide() { + let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await; + seed_test_identity(&state, "bob", "bobtok", "bobsecret").await; + let client = reqwest::Client::new(); + let alice_delegate = + "00000000-0000-0000-0000-0000000000c1:feedtest:local/alice-model"; + let bob_delegate = + "00000000-0000-0000-0000-0000000000c2:feedtest:local/bob-model"; + + { + let mut reduced = state.reduced.write().await; + for (agent, username) in [ + (alice_delegate, "testuser"), + (bob_delegate, "bob"), + ] { + reduced.apply_event(Event::AgentBound(AgentBound { + ts: 1, + agent: agent.to_string(), + username: username.to_string(), + })); + } + reduced.apply_event(Event::Ingest(Ingest { + ts: 100, + id: "alice-anchor".into(), + raw: "alice anchor".into(), + principal: "testuser".into(), + delegate: Some(alice_delegate.into()), + room_id: "public".into(), + thread_tag: "feed-order".into(), + })); + reduced.apply_event(Event::Ingest(Ingest { + ts: 100, + id: "bob-same-millisecond".into(), + raw: "bob same-millisecond change".into(), + principal: "bob".into(), + delegate: Some(bob_delegate.into()), + room_id: "public".into(), + thread_tag: "feed-order".into(), + })); + // Event-log order remains authoritative even if the wall clock moves backwards. + reduced.apply_event(Event::Ingest(Ingest { + ts: 99, + id: "bob-clock-rollback".into(), + raw: "bob change after clock rollback".into(), + principal: "bob".into(), + delegate: Some(bob_delegate.into()), + room_id: "public".into(), + thread_tag: "feed-order".into(), + })); + } + + let feed = rpc_batch( + &client, + addr, + Some(&test_bearer()), + serde_json::json!([{ + "GetFeed": { "delegate": alice_delegate, "limit": 20 } + }]), + ) + .await; + let posts = feed["results"][0]["result"]["Feed"]["posts"] + .as_array() + .unwrap(); + let ids: Vec<&str> = posts.iter().filter_map(|p| p["id"].as_str()).collect(); + assert_eq!( + ids, + ["bob-clock-rollback", "bob-same-millisecond"], + "implicit feed cutoff must use append position, not timestamp" + ); +} + +#[tokio::test] +async fn test_feed_multi_user_private_room_visibility_and_revoked_anchor_access() { + let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await; + seed_test_identity(&state, "bob", "bobtok", "bobsecret").await; + seed_test_identity(&state, "carol", "caroltok", "carolsecret").await; + let client = reqwest::Client::new(); + let alice_delegate = + "00000000-0000-0000-0000-0000000000d1:feedtest:local/alice-model"; + let bob_delegate = + "00000000-0000-0000-0000-0000000000d2:feedtest:local/bob-model"; + let room_alice = "alice01/alice-room"; + let room_bob = "bob0001/bob-room"; + + { + let mut reduced = state.reduced.write().await; + for (agent, username) in [ + (alice_delegate, "testuser"), + (bob_delegate, "bob"), + ] { + reduced.apply_event(Event::AgentBound(AgentBound { + ts: 1, + agent: agent.to_string(), + username: username.to_string(), + })); + } + for (room_id, slug, owner) in [ + (room_alice, "alice-room", "testuser"), + (room_bob, "bob-room", "bob"), + ] { + reduced.apply_event(Event::RoomCreated(RoomCreated { + ts: 2, + room_id: room_id.to_string(), + slug: slug.to_string(), + owner: owner.to_string(), + })); + reduced.apply_event(Event::GrantAdded(GrantAdded { + ts: 2, + room_id: room_id.to_string(), + username: owner.to_string(), + capabilities: vec![ThreadCapability::View], + granted_by: owner.to_string(), + })); + } + + let mut add_ingest = + |ts, id: &str, raw: &str, principal: &str, delegate: Option<&str>, room: &str| { + reduced.apply_event(Event::Ingest(Ingest { + ts, + id: id.into(), + raw: raw.into(), + principal: principal.into(), + delegate: delegate.map(str::to_string), + room_id: room.into(), + thread_tag: "feed-permissions".into(), + })); + }; + add_ingest(10, "prehistory", "must remain before alice anchor", "carol", None, "public"); + add_ingest( + 11, + "alice-private-anchor", + "alice last posted here", + "testuser", + Some(alice_delegate), + room_alice, + ); + add_ingest( + 12, + "bob-public-anchor", + "bob last posted here", + "bob", + Some(bob_delegate), + "public", + ); + add_ingest(13, "alice-room-change", "visible only to alice", "carol", None, room_alice); + add_ingest(14, "bob-room-change", "visible only to bob", "carol", None, room_bob); + add_ingest(15, "public-change", "visible to everyone", "carol", None, "public"); + + reduced.apply_event(Event::GrantRevoked(GrantRevoked { + ts: 16, + room_id: room_alice.into(), + username: "testuser".into(), + capabilities: vec![ThreadCapability::View], + revoked_by: "testuser".into(), + })); + } + + let alice_feed = rpc_batch( + &client, + addr, + Some(&test_bearer()), + serde_json::json!([{ + "GetFeed": { "delegate": alice_delegate, "limit": 20 } + }]), + ) + .await; + let alice_posts = alice_feed["results"][0]["result"]["Feed"]["posts"] + .as_array() + .unwrap(); + let alice_ids: Vec<&str> = alice_posts + .iter() + .filter_map(|p| p["id"].as_str()) + .collect(); + assert_eq!( + alice_ids, + ["public-change", "bob-public-anchor"], + "revoked private content must be hidden without moving the delegate anchor backwards" + ); + assert!(!alice_ids.contains(&"prehistory")); + assert!( + alice_posts + .iter() + .all(|post| post["room"].as_str() == Some("public")), + "private posts must not leak and every feed post must identify its room" + ); + + let bob_bearer = test_bearer_for("bobtok", "bobsecret"); + let bob_feed = rpc_batch( + &client, + addr, + Some(&bob_bearer), + serde_json::json!([{ + "GetFeed": { "delegate": bob_delegate, "limit": 20 } + }]), + ) + .await; + let bob_ids: Vec<&str> = bob_feed["results"][0]["result"]["Feed"]["posts"] + .as_array() + .unwrap() + .iter() + .filter_map(|p| p["id"].as_str()) + .collect(); + assert_eq!(bob_ids, ["public-change", "bob-room-change"]); + assert_eq!( + bob_feed["results"][0]["result"]["Feed"]["posts"][1]["room"], + room_bob + ); + + // Restoring View exposes only changes after the same stable delegate anchor. + { + let mut reduced = state.reduced.write().await; + reduced.apply_event(Event::GrantAdded(GrantAdded { + ts: 17, + room_id: room_alice.into(), + username: "testuser".into(), + capabilities: vec![ThreadCapability::View], + granted_by: "testuser".into(), + })); + } + let restored = rpc_batch( + &client, + addr, + Some(&test_bearer()), + serde_json::json!([{ + "GetFeed": { "delegate": alice_delegate, "limit": 20 } + }]), + ) + .await; + let restored_ids: Vec<&str> = restored["results"][0]["result"]["Feed"]["posts"] + .as_array() + .unwrap() + .iter() + .filter_map(|p| p["id"].as_str()) + .collect(); + assert_eq!( + restored_ids, + ["public-change", "alice-room-change", "bob-public-anchor"] + ); +} + #[tokio::test] async fn test_private_room_thread_urls_use_t_segment() { let (addr, _tmp, _log, _handle) = create_test_server().await; diff --git a/server/tests/support/mod.rs b/server/tests/support/mod.rs index 4a620eaa875e7a1145f2a4e82cc4ff2be21d5345..a4320e991763617c1a760efad4b621977e2b74d0 100644 --- a/server/tests/support/mod.rs +++ b/server/tests/support/mod.rs @@ -20,8 +20,10 @@ pub fn sha256_hex(s: &str) -> String { /// Fixed bearer for integration tests (`TokenIssued` seeded into reducer in `create_test_server`). pub fn test_bearer() -> String { - let token_id = "testtok"; - let secret = "secret"; + test_bearer_for("testtok", "secret") +} + +pub fn test_bearer_for(token_id: &str, secret: &str) -> String { format!("slug_{token_id}_{secret}") } @@ -71,19 +73,27 @@ pub async fn rpc_batch( } pub async fn seed_test_token(state: &AppState) { + seed_test_identity(state, "testuser", "testtok", "secret").await; +} + +/// Add a distinct principal and bearer to a running integration-test server. +pub async fn seed_test_identity( + state: &AppState, + username: &str, + token_id: &str, + secret: &str, +) { let registered = Event::UserRegistered(UserRegistered { ts: 0, - username: "testuser".to_string(), + username: username.to_string(), provider: "test".to_string(), - provider_id: "testuser".to_string(), + provider_id: username.to_string(), }); - let token_id = "testtok"; - let secret = "secret"; let salt = "salt"; let token_hash = sha256_hex(&format!("{salt}:{secret}")); let ev = Event::TokenIssued(TokenIssued { ts: 0, - username: "testuser".to_string(), + username: username.to_string(), token_id: token_id.to_string(), token_hash, salt: salt.to_string(), diff --git a/types/src/lib.rs b/types/src/lib.rs index 211493935d19582607f5c87fb492faf47bdc6f53..cd6f94c60a4e1c3bfbce13bc0404b803f5f57c6d 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -264,6 +264,9 @@ pub struct FeedResponse { pub struct FeedPost { pub ts: i64, pub id: String, + /// Permission scope containing the post: `"public"` or a private room id. + #[serde(default)] + pub room: String, /// Primary thread tag (without #), if the ingest declared one. #[serde(skip_serializing_if = "Option::is_none")] pub thread: Option,