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: [51e73d38] voting looks better? Side B — unified diff (full patch): diff --git a/server/static/theme_default.css b/server/static/theme_default.css index 5f60ff7a8cf9045d8d43228baad8ba80ad057f2a..ec0fbe7acee0aa2802f978f14a9b0fc86e78c5b8 100644 --- a/server/static/theme_default.css +++ b/server/static/theme_default.css @@ -844,18 +844,25 @@ a.ont-vote-compare-btn { color: var(--ui); cursor: pointer; font-size: 11px; - padding: 2px 8px; + padding: 3px 10px; text-decoration: none; display: inline-flex; align-items: center; gap: 4px; font-family: inherit; + border-radius: 2px; } button.ont-pin-btn:hover, a.ont-vote-compare-btn:hover { color: var(--signal); } +button.ont-pin-btn:focus-visible, +a.ont-vote-compare-btn:focus-visible { + outline: 2px solid var(--link); + outline-offset: 2px; +} button.ont-pin-btn-active { border-color: var(--link); color: var(--signal); + background: color-mix(in srgb, var(--link) 12%, var(--g4)); } .ont-garden-child-actions { display: inline-flex; @@ -868,12 +875,33 @@ a.ont-garden-vote-ico, span.ont-garden-pinned-here { font-size: 13px; line-height: 1; - padding: 0 2px; - border: none; - background: transparent; + padding: 2px 6px; + min-width: 26px; + min-height: 26px; + box-sizing: border-box; + border: var(--bv) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + border-radius: 2px; + background: var(--g3); cursor: pointer; text-decoration: none; color: inherit; + display: inline-flex; + align-items: center; + justify-content: center; +} +a.ont-garden-vote-ico:hover { + color: var(--signal); + background: var(--g4); +} +span.ont-garden-pinned-here { + border-color: var(--link); + color: var(--signal); + cursor: default; +} +button.ont-garden-pin-ico:focus-visible { + outline: 2px solid var(--link); + outline-offset: 2px; } body.view-ontology-light ol.ont-ranking-list li, body.view-ontology-light ul.ont-group-list li { @@ -889,8 +917,19 @@ body.view-ontology-light ul.ont-group-list li .item-link { /* Vote compare page */ .vote-compare-shell { - margin: 12px 0; + margin: 12px 0 20px; max-width: 720px; + padding: 14px 16px 18px; + background: var(--g2); + border: var(--bv-lg) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); +} +body.view-vote-compare .vote-compare-shell > h2 { + margin-top: 0; + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--meta); } .vote-compare-pair { display: flex; @@ -899,12 +938,20 @@ body.view-ontology-light ul.ont-group-list li .item-link { gap: 10px 16px; margin: 10px 0; } +.vote-compare-item { + text-decoration: none; +} .vote-compare-item code { font-size: 13px; } +.vote-compare-item:hover code { + color: var(--signal); +} .vote-compare-vs { color: var(--meta); - font-size: 12px; + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; } .vote-compare-slider-label { display: flex; @@ -917,7 +964,7 @@ body.view-ontology-light ul.ont-group-list li .item-link { #vote-preference-slider { flex: 1 1 180px; min-width: 120px; - accent-color: var(--ui); + accent-color: var(--link); } .vote-thread-picker { margin: 10px 0; @@ -931,9 +978,17 @@ body.view-ontology-light ul.ont-group-list li .item-link { } #vote-thread-select { min-width: 160px; + background: var(--g3); + border: var(--bv) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + color: var(--ui); + font-size: 12px; + padding: 3px 8px; } #vote-edge-history-region { margin: 14px 0 18px; + padding-bottom: 8px; + border-bottom: 1px dashed var(--lo); } .vote-compare-preview-wrap { margin: 12px 0; @@ -946,8 +1001,37 @@ body.view-ontology-light ul.ont-group-list li .item-link { #vote-compare-preview { min-height: 48px; } +.vote-explain-label { + display: block; + font-size: 12px; + color: var(--meta); + margin: 12px 0 4px; +} +body.view-vote-compare #vote-explain { + width: 100%; + max-width: 100%; + box-sizing: border-box; + background: var(--g3); + border: var(--bv) solid; + border-color: var(--hi) var(--lo) var(--lo) var(--hi); + color: var(--prose); + font: inherit; + line-height: 1.45; + padding: 8px 10px; +} +body.view-vote-compare #vote-explain:focus { + outline: none; + border-color: var(--link); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--link) 22%, transparent); +} +body.view-vote-compare #vote-compare-form button[type="submit"] { + margin-top: 6px; +} .vote-edge-history-title { - font-size: 13px; + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--meta); margin: 16px 0 8px; } ol.vote-edge-history { @@ -968,6 +1052,8 @@ li.vote-edge-history-row { .vote-edge-bar { margin-top: 4px; max-width: 100%; + border-radius: 2px; + overflow: hidden; } .vote-edge-reason { margin-top: 4px; diff --git a/server/static/theme_retro_craft.css b/server/static/theme_retro_craft.css index 3853e9edaf4c77c62dcf350ff28920448dd943a8..092d4b3540b052cc86870179e4c264541c4446cc 100644 --- a/server/static/theme_retro_craft.css +++ b/server/static/theme_retro_craft.css @@ -587,3 +587,337 @@ body.view-ontology #controls { padding-right: 1.25rem; } } + +/* ---------------------------------------------------------------- + Ontology garden: pin HUD, row pins, vote-compare + (Global craft `code` + `button[type=submit]` are tuned for dark + thread pages; these overrides match the cream ontology shell.) + ---------------------------------------------------------------- */ +body.view-ontology #slug-pin-hud.slug-pin-hud { + margin-left: auto; + max-width: min(42vw, 280px); + font-family: var(--font-ui); + font-size: 0.72rem; + color: #5c574e; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +body.view-ontology .slug-pin-hud-link { + color: #1a4a8c; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 0.25rem; +} +body.view-ontology .slug-pin-hud-link:hover { + color: #0d3d82; + text-decoration: underline; +} +body.view-ontology .slug-pin-hud-glyph { + font-size: 0.95rem; + line-height: 1; +} + +body.view-ontology .ont-item-meta { + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; +} +body.view-ontology .ont-item-title { + flex: 1 1 auto; + min-width: 0; +} +body.view-ontology .ont-item-pin-zone { + flex: 0 0 auto; + margin-left: auto; + display: flex; + align-items: center; + gap: 0.4rem; +} + +body.view-ontology button.ont-pin-btn, +body.view-ontology a.ont-vote-compare-btn { + background: #ebe6dc; + border: 1px solid #c8c4bc; + border-radius: 2px; + color: #3d3a34; + cursor: pointer; + font-family: var(--font-ui); + font-size: 0.72rem; + letter-spacing: 0.04em; + padding: 0.25rem 0.55rem; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 0.3rem; +} +body.view-ontology button.ont-pin-btn:hover, +body.view-ontology a.ont-vote-compare-btn:hover { + border-color: #a68e6b; + color: #1a1814; +} +body.view-ontology button.ont-pin-btn-active { + border-color: #1a4a8c; + background: color-mix(in srgb, #1a4a8c 12%, #ebe6dc); + color: #0d2d5c; +} +body.view-ontology button.ont-pin-btn:focus-visible, +body.view-ontology a.ont-vote-compare-btn:focus-visible { + outline: 2px solid #1a4a8c; + outline-offset: 2px; +} + +body.view-ontology .ont-garden-child-actions { + display: inline-flex; + align-items: center; + margin-right: 0.35rem; + vertical-align: middle; + gap: 0.1rem; +} +body.view-ontology button.ont-garden-pin-ico, +body.view-ontology a.ont-garden-vote-ico, +body.view-ontology span.ont-garden-pinned-here { + font-size: 0.95rem; + line-height: 1; + padding: 0.1rem 0.35rem; + min-width: 1.65rem; + min-height: 1.65rem; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid #c8c4bc; + border-radius: 2px; + background: #f7f3eb; + cursor: pointer; + text-decoration: none; + color: #1a1814; +} +body.view-ontology a.ont-garden-vote-ico:hover { + border-color: #a68e6b; + background: #ebe6dc; +} +body.view-ontology span.ont-garden-pinned-here { + border-color: #1a4a8c; + background: color-mix(in srgb, #1a4a8c 10%, #f7f3eb); + cursor: default; +} +body.view-ontology form.ont-garden-pin-form { + display: inline; + margin: 0; + padding: 0; +} +body.view-ontology button.ont-garden-pin-ico:focus-visible { + outline: 2px solid #1a4a8c; + outline-offset: 2px; +} + +body.view-ontology ol.ont-ranking-list li, +body.view-ontology ul.ont-group-list li { + display: flex; + align-items: baseline; + gap: 0.35rem; +} +body.view-ontology ol.ont-ranking-list li .item-link, +body.view-ontology ul.ont-group-list li .item-link { + flex: 1; + min-width: 0; +} + +body.view-ontology code, +body.view-ontology .vote-compare-item code, +body.view-ontology .vote-edge-meta code { + background: #ebe6dc; + color: #1a1814; + border: 1px solid #d4cfc4; + padding: 0.12em 0.35em; + font-size: 0.88em; +} + +body.view-ontology div.ratio-bar { + background: #e3ded4; + border: 1px solid #c8c4bc; +} +body.view-ontology div.ratio-left, +body.view-ontology div.ratio-right { + background: #cec9bf; +} +body.view-ontology div.ratio-left.current, +body.view-ontology div.ratio-right.current { + background: #9a7b4a; +} + +body.view-ontology.view-vote-compare .vote-compare-shell { + margin: 0.75rem 0 1.5rem; + max-width: 36rem; + padding: 0.85rem 1rem 1.1rem; + background: #f7f3eb; + border: 1px solid #c8c4bc; + border-radius: 3px; + box-shadow: 0 1px 0 rgba(26, 24, 20, 0.06); +} +body.view-ontology.view-vote-compare .vote-compare-shell > h2 { + margin-top: 0; + font-size: 0.7rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: #5c574e; +} +body.view-ontology .vote-compare-pair { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.65rem 1rem; + margin: 0.65rem 0 0.85rem; +} +body.view-ontology .vote-compare-vs { + color: #8a857a; + font-family: var(--font-ui); + font-size: 0.72rem; + letter-spacing: 0.1em; + text-transform: uppercase; +} +body.view-ontology .vote-compare-item { + text-decoration: none; +} +body.view-ontology .vote-compare-item:hover code { + border-color: #a68e6b; + background: #f0ebe3; +} + +body.view-ontology .vote-thread-picker { + margin: 0.85rem 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; +} +body.view-ontology .vote-thread-picker-label { + font-family: var(--font-ui); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #5c574e; +} +body.view-ontology #vote-thread-select { + min-width: 10rem; + background: #f7f3eb; + border: 1px solid #c8c4bc; + color: #1a1814; + font-family: var(--font-ui); + font-size: 0.78rem; + padding: 0.3rem 0.45rem; +} + +body.view-ontology .vote-compare-slider-label { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.65rem; + margin: 0.85rem 0; + font-family: var(--font-ui); + font-size: 0.72rem; + color: #5c574e; +} +body.view-ontology #vote-preference-slider { + flex: 1 1 11rem; + min-width: 8rem; + accent-color: #9a7b4a; +} + +body.view-ontology .vote-explain-label { + display: block; + font-family: var(--font-ui); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #5c574e; + margin: 0.65rem 0 0.35rem; +} +body.view-ontology #vote-compare-form textarea, +body.view-ontology.view-vote-compare textarea#vote-explain { + background: #fdfcfa; + border: 1px solid #c8c4bc; + color: #1a1814; + font-family: var(--font-prose); + font-size: 0.9rem; + line-height: 1.45; + width: 100%; + max-width: 100%; + padding: 0.55rem 0.65rem; + margin-top: 0.25rem; +} +body.view-ontology #vote-compare-form textarea:focus { + border-color: #a68e6b; + outline: none; + box-shadow: 0 0 0 2px color-mix(in srgb, #a68e6b 28%, transparent); +} + +body.view-ontology #vote-compare-form button[type="submit"] { + background: #1a1814; + border: 1px solid #1a1814; + color: #f7f3eb; + font-family: var(--font-ui); + font-size: 0.78rem; + letter-spacing: 0.06em; + padding: 0.4rem 1rem; + margin-top: 0.35rem; +} +body.view-ontology #vote-compare-form button[type="submit"]:hover { + background: #2a2620; + border-color: #2a2620; + color: #fff; +} + +body.view-ontology #vote-edge-history-region { + margin: 0.75rem 0 1rem; + padding-bottom: 0.35rem; + border-bottom: 1px dashed #d4cfc4; +} +body.view-ontology .vote-edge-history-title { + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #5c574e; + margin: 0 0 0.5rem; +} +body.view-ontology ol.vote-edge-history { + margin: 0; + padding-left: 1.1rem; +} +body.view-ontology li.vote-edge-history-row { + margin: 0.65rem 0; + padding-left: 0.15rem; +} +body.view-ontology .vote-edge-meta { + font-size: 0.78rem; + line-height: 1.5; +} +body.view-ontology .vote-edge-ratio { + margin: 0 0.35rem; + font-weight: 600; + color: #1a1814; +} +body.view-ontology .vote-edge-bar { + margin-top: 0.25rem; + max-width: 100%; + border-radius: 2px; + overflow: hidden; +} +body.view-ontology .vote-edge-reason { + margin-top: 0.35rem; + font-size: 0.78rem; +} +body.view-ontology .vote-compare-preview-wrap { + margin: 0.85rem 0; +} +body.view-ontology .vote-compare-preview-wrap h3 { + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #5c574e; + margin: 0 0 0.4rem; +} +body.view-ontology #vote-compare-preview { + min-height: 2.5rem; +}