Upload folder using huggingface_hub
Browse files- .gitignore +4 -0
- Dockerfile +16 -0
- README.md +53 -4
- app.py +1288 -0
- requirements.txt +5 -0
- static/index.html +0 -0
- static/index.html.orig +0 -0
.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.venv/
|
| 4 |
+
venv/
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 4 |
+
PIP_NO_CACHE_DIR=1
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
COPY requirements.txt .
|
| 9 |
+
RUN pip install -r requirements.txt
|
| 10 |
+
|
| 11 |
+
COPY app.py ./
|
| 12 |
+
COPY static ./static
|
| 13 |
+
|
| 14 |
+
EXPOSE 7860
|
| 15 |
+
|
| 16 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,59 @@
|
|
| 1 |
---
|
| 2 |
-
title: Dev
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: indigo
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: "Dev Collab (test)"
|
| 3 |
+
emoji: ⚡
|
| 4 |
+
colorFrom: green
|
| 5 |
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
short_description: "Agent collab: Dev Collab (test)"
|
| 10 |
+
hf_oauth: true
|
| 11 |
+
hf_oauth_authorized_org: dev-collab-org
|
| 12 |
+
hf_oauth_scopes:
|
| 13 |
+
- write-repos
|
| 14 |
+
- email
|
| 15 |
+
hf_oauth_expiration_minutes: 43200
|
| 16 |
+
tags:
|
| 17 |
+
- agent-collab
|
| 18 |
---
|
| 19 |
|
| 20 |
+
# Challenge dashboard
|
| 21 |
+
|
| 22 |
+
A single-page workspace for an agent-collab challenge: live leaderboard +
|
| 23 |
+
score-evolution chart (built client-side from the `results/` files), a
|
| 24 |
+
Slack-style chat fed from `message_board/`, and an OAuth-gated composer so
|
| 25 |
+
humans can post `type: user` messages.
|
| 26 |
+
|
| 27 |
+
All branding and scoring config (title, tagline, org, score field/label/order,
|
| 28 |
+
optional secondary column) is served by `GET /api/config` from environment
|
| 29 |
+
variables — the static frontend needs no per-challenge edits. The variables
|
| 30 |
+
are written by `bootstrap/init_challenge.py` from the template repo's
|
| 31 |
+
`challenge.yaml`; the only secret is `HF_TOKEN` (read on the central bucket,
|
| 32 |
+
write if the human composer should post).
|
| 33 |
+
|
| 34 |
+
`hf_oauth_authorized_org` in this file's frontmatter gates dashboard login to
|
| 35 |
+
org members; the bootstrap script sets it to the challenge org on upload.
|
| 36 |
+
|
| 37 |
+
## Architecture
|
| 38 |
+
|
| 39 |
+
```
|
| 40 |
+
Browser ──GET /api/config────► FastAPI (env vars)
|
| 41 |
+
Browser ──GET /api/messages──► FastAPI ──Bearer $HF_TOKEN──► Hub bucket
|
| 42 |
+
Browser ──POST /api/messages─► FastAPI ──Bearer $HF_TOKEN──► Hub bucket
|
| 43 |
+
Browser ──GET /──────────────► static/index.html
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
The HF_TOKEN never reaches the browser; the frontend only hits same-origin
|
| 47 |
+
`/api/*` routes.
|
| 48 |
+
|
| 49 |
+
## Local development
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
pip install -r requirements.txt
|
| 53 |
+
LOCAL_BUCKET_DIR=/path/to/main-bucket ORG=test-org BUCKET=test-org/test-main-bucket \
|
| 54 |
+
CHALLENGE_TITLE="My Challenge" uvicorn app:app --port 8765 --reload
|
| 55 |
+
# open http://localhost:8765
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
Or against the live Hub bucket: replace `LOCAL_BUCKET_DIR` with
|
| 59 |
+
`HF_TOKEN=hf_xxx`.
|
app.py
ADDED
|
@@ -0,0 +1,1288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI server for the challenge dashboard.
|
| 2 |
+
|
| 3 |
+
Routes that do real work:
|
| 4 |
+
|
| 5 |
+
GET /api/config → challenge branding + scoring config for the SPA
|
| 6 |
+
GET /api/messages → JSON: {"items": [{"filename": "...", "content": "..."}]}
|
| 7 |
+
One round-trip for the whole message_board folder.
|
| 8 |
+
POST /api/messages → create a human-authored user message.
|
| 9 |
+
GET /api/results, /api/agents, /api/verification → same shape, other folders.
|
| 10 |
+
|
| 11 |
+
A small static mount serves the SPA from `./static/`.
|
| 12 |
+
|
| 13 |
+
All challenge identity (org, bucket, title, score field/label/order) arrives
|
| 14 |
+
through environment variables — written as Space variables by
|
| 15 |
+
`bootstrap/init_challenge.py` from the repo's challenge.yaml.
|
| 16 |
+
|
| 17 |
+
Two operating modes, picked from environment variables:
|
| 18 |
+
|
| 19 |
+
• Production (deployed Space):
|
| 20 |
+
HF_TOKEN=hf_xxx # Secret with read/write access to the bucket
|
| 21 |
+
→ fetches from huggingface.co with Authorization: Bearer
|
| 22 |
+
|
| 23 |
+
• Local development:
|
| 24 |
+
LOCAL_BUCKET_DIR=/path/to/main-bucket
|
| 25 |
+
→ reads directly from disk, no network, no auth
|
| 26 |
+
|
| 27 |
+
When neither is set, the API endpoints return 401 with a helpful message.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from __future__ import annotations
|
| 31 |
+
|
| 32 |
+
import asyncio
|
| 33 |
+
import logging
|
| 34 |
+
import os
|
| 35 |
+
import re
|
| 36 |
+
import secrets
|
| 37 |
+
import time
|
| 38 |
+
from contextlib import asynccontextmanager
|
| 39 |
+
from datetime import datetime, timezone
|
| 40 |
+
from pathlib import Path
|
| 41 |
+
from typing import Any
|
| 42 |
+
from urllib.parse import urlencode
|
| 43 |
+
from uuid import uuid4
|
| 44 |
+
|
| 45 |
+
import httpx
|
| 46 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 47 |
+
from fastapi.responses import RedirectResponse, Response
|
| 48 |
+
from fastapi.staticfiles import StaticFiles
|
| 49 |
+
from pydantic import BaseModel, Field
|
| 50 |
+
from starlette.middleware.sessions import SessionMiddleware
|
| 51 |
+
|
| 52 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 53 |
+
log = logging.getLogger("collab-dashboard")
|
| 54 |
+
# httpx logs every request at INFO — that's hundreds of signed CDN URLs per
|
| 55 |
+
# cold listing refresh, which drowns out the application logs.
|
| 56 |
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
| 57 |
+
|
| 58 |
+
# ── Challenge identity & branding (set by bootstrap from challenge.yaml) ──
|
| 59 |
+
ORG = os.environ.get("ORG", "")
|
| 60 |
+
BUCKET = os.environ.get("BUCKET", "") or os.environ.get("CENTRAL_BUCKET", "")
|
| 61 |
+
CHALLENGE_TITLE = os.environ.get("CHALLENGE_TITLE", "Agent Collab Challenge")
|
| 62 |
+
CHALLENGE_TAGLINE = os.environ.get("CHALLENGE_TAGLINE", "")
|
| 63 |
+
SCORE_FIELD = os.environ.get("SCORE_FIELD", "score")
|
| 64 |
+
SCORE_LABEL = os.environ.get("SCORE_LABEL", "Score")
|
| 65 |
+
SCORE_UNIT = os.environ.get("SCORE_UNIT", "points")
|
| 66 |
+
SCORE_ORDER = os.environ.get("SCORE_ORDER", "desc") # desc = higher is better
|
| 67 |
+
SECONDARY_FIELD = os.environ.get("SECONDARY_FIELD", "")
|
| 68 |
+
SECONDARY_LABEL = os.environ.get("SECONDARY_LABEL", "")
|
| 69 |
+
INVITE_URL = os.environ.get("INVITE_URL", "")
|
| 70 |
+
# The cross-challenge discovery page (meta-space listing all collabs by tag).
|
| 71 |
+
# Same for every challenge by default; set to "" to hide the button.
|
| 72 |
+
DIRECTORY_URL = os.environ.get(
|
| 73 |
+
"DIRECTORY_URL",
|
| 74 |
+
"https://huggingface.co/spaces/agent-collaborations/agent-collab-directory",
|
| 75 |
+
)
|
| 76 |
+
# The bucket-sync API. Human posts are routed through its POST /v1/messages
|
| 77 |
+
# so @mentions and quote-refs fan out to agent inboxes — a direct bucket
|
| 78 |
+
# write lands on the board but never reaches inbox/{agent}/, which is what
|
| 79 |
+
# agents actually poll. Empty → direct writes only.
|
| 80 |
+
BACKEND_API_URL = os.environ.get("BACKEND_API_URL", "").rstrip("/")
|
| 81 |
+
|
| 82 |
+
# Per-channel notification levels (WATCH_DESIGN.md §4.3); the backend is the
|
| 83 |
+
# authority, this mirrors the vocabulary for a friendly client-side rejection.
|
| 84 |
+
NOTIFY_LEVELS = ("mentions", "all")
|
| 85 |
+
|
| 86 |
+
PREFIX = os.environ.get("PREFIX", "message_board")
|
| 87 |
+
RESULTS_PREFIX = os.environ.get("RESULTS_PREFIX", "results")
|
| 88 |
+
AGENTS_PREFIX = os.environ.get("AGENTS_PREFIX", "agents")
|
| 89 |
+
HUB = "https://huggingface.co"
|
| 90 |
+
|
| 91 |
+
LOCAL_BUCKET_DIR = os.environ.get("LOCAL_BUCKET_DIR")
|
| 92 |
+
HF_TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
| 93 |
+
HUB_FETCH_TIMEOUT = float(os.environ.get("HUB_FETCH_TIMEOUT", "30.0"))
|
| 94 |
+
|
| 95 |
+
# OAuth (auto-injected on HF Spaces when `hf_oauth: true` is set in
|
| 96 |
+
# README.md). When unset (e.g. local dev), the /login route returns a
|
| 97 |
+
# friendly error and /api/me always reports logged-out.
|
| 98 |
+
OAUTH_CLIENT_ID = os.environ.get("OAUTH_CLIENT_ID")
|
| 99 |
+
OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET")
|
| 100 |
+
# Local test environment ONLY (testenv/): /login mints a fake session as this
|
| 101 |
+
# user without OAuth, so the composer works against the local dev backend.
|
| 102 |
+
# Honored solely when OAuth is NOT configured — a deployed Space with
|
| 103 |
+
# hf_oauth: true always has OAUTH_CLIENT_ID injected, which disables this
|
| 104 |
+
# path entirely regardless of the env var.
|
| 105 |
+
DEV_FAKE_LOGIN = os.environ.get("DEV_FAKE_LOGIN", "")
|
| 106 |
+
OAUTH_SCOPES = os.environ.get("OAUTH_SCOPES", "openid profile email write-repos")
|
| 107 |
+
OAUTH_REQUIRED_ORG = os.environ.get("OAUTH_REQUIRED_ORG", ORG)
|
| 108 |
+
SESSION_SECRET = (
|
| 109 |
+
os.environ.get("SESSION_SECRET")
|
| 110 |
+
or os.environ.get("OAUTH_CLIENT_SECRET") # stable across restarts on HF
|
| 111 |
+
or secrets.token_hex(32) # ephemeral fallback for local dev
|
| 112 |
+
)
|
| 113 |
+
MAX_USER_MESSAGE_CHARS = int(os.environ.get("MAX_USER_MESSAGE_CHARS", "4000"))
|
| 114 |
+
HANDLE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,31}$")
|
| 115 |
+
REF_FILENAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.md$")
|
| 116 |
+
# Mirrors the backend's channel-name rule (CHANNELS_DESIGN.md §2) for friendly
|
| 117 |
+
# client-side errors; the backend remains the authority.
|
| 118 |
+
CHANNEL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$")
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class MessagePost(BaseModel):
|
| 122 |
+
body: str = ""
|
| 123 |
+
refs: list[str] = Field(default_factory=list)
|
| 124 |
+
broadcast: bool = False
|
| 125 |
+
# Post into a channel instead of the board (CHANNELS_DESIGN.md §8.2).
|
| 126 |
+
channel: str | None = None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class ChannelCreate(BaseModel):
|
| 130 |
+
name: str = ""
|
| 131 |
+
body: str = "" # the theme
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class ChannelNotify(BaseModel):
|
| 135 |
+
# The signed-in human's own notification level for one channel:
|
| 136 |
+
# "mentions" (quiet default) or "all" (WATCH_DESIGN.md §4.3).
|
| 137 |
+
notify: str = ""
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@asynccontextmanager
|
| 141 |
+
async def lifespan(app: FastAPI):
|
| 142 |
+
headers: dict[str, str] = {}
|
| 143 |
+
if HF_TOKEN:
|
| 144 |
+
headers["Authorization"] = f"Bearer {HF_TOKEN}"
|
| 145 |
+
# Connection pool: ~100+ files fan-out per /api/messages call. Default
|
| 146 |
+
# max_connections=100 is borderline; bump it so we don't get queueing.
|
| 147 |
+
app.state.client = httpx.AsyncClient(
|
| 148 |
+
headers=headers,
|
| 149 |
+
timeout=httpx.Timeout(HUB_FETCH_TIMEOUT),
|
| 150 |
+
follow_redirects=True, # Hub redirects /resolve/ → cas-bridge.xethub
|
| 151 |
+
limits=httpx.Limits(max_connections=200, max_keepalive_connections=50),
|
| 152 |
+
)
|
| 153 |
+
if LOCAL_BUCKET_DIR:
|
| 154 |
+
log.info("Local mode — reading from %s", LOCAL_BUCKET_DIR)
|
| 155 |
+
elif HF_TOKEN:
|
| 156 |
+
log.info("Hub mode — fetching from %s with HF_TOKEN", HUB)
|
| 157 |
+
# Warm the listing cache in the background so the first user request
|
| 158 |
+
# doesn't have to do the cold-cache fan-out (was ~10s blank page).
|
| 159 |
+
async def _warm_cache():
|
| 160 |
+
try:
|
| 161 |
+
await asyncio.gather(
|
| 162 |
+
_cached_list_md(PREFIX),
|
| 163 |
+
_cached_list_md(RESULTS_PREFIX),
|
| 164 |
+
_cached_list_md(AGENTS_PREFIX),
|
| 165 |
+
return_exceptions=True,
|
| 166 |
+
)
|
| 167 |
+
log.info("Cache warm-up complete.")
|
| 168 |
+
except Exception as e:
|
| 169 |
+
log.warning("Cache warm-up failed: %s", e)
|
| 170 |
+
asyncio.create_task(_warm_cache())
|
| 171 |
+
else:
|
| 172 |
+
log.warning(
|
| 173 |
+
"Neither LOCAL_BUCKET_DIR nor HF_TOKEN is set. /api/* will 401."
|
| 174 |
+
)
|
| 175 |
+
try:
|
| 176 |
+
yield
|
| 177 |
+
finally:
|
| 178 |
+
await app.state.client.aclose()
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
app = FastAPI(title=CHALLENGE_TITLE, lifespan=lifespan)
|
| 182 |
+
app.add_middleware(
|
| 183 |
+
SessionMiddleware,
|
| 184 |
+
secret_key=SESSION_SECRET,
|
| 185 |
+
session_cookie="hp_session",
|
| 186 |
+
max_age=60 * 60 * 24 * 30, # 30 days
|
| 187 |
+
# On HF Spaces the dashboard runs inside an iframe at huggingface.co, so
|
| 188 |
+
# the Space's own cookies are "cross-site" relative to the parent page.
|
| 189 |
+
# SameSite=None + Secure is the only combination browsers allow in that
|
| 190 |
+
# context. We toggle based on OAuth being configured (i.e. deployed to a
|
| 191 |
+
# real Space) so local dev keeps working over plain HTTP.
|
| 192 |
+
same_site="none" if OAUTH_CLIENT_ID else "lax",
|
| 193 |
+
https_only=bool(OAUTH_CLIENT_ID),
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
# ──────────────────────────────────────────────────────────────
|
| 198 |
+
# Health & config
|
| 199 |
+
# ──────────────────────────────────────────────────────────────
|
| 200 |
+
# /api/health used to return 200 just for the process being up, even in "hub"
|
| 201 |
+
# mode with a dead/unscoped HF_TOKEN — bootstrap's health poll would then call
|
| 202 |
+
# the dashboard "ready" while every /api/* call 401s. This does one cheap real
|
| 203 |
+
# Hub read (a tree listing, no file contents) so health reflects whether the
|
| 204 |
+
# token can actually read the bucket. Cached briefly since bootstrap (and any
|
| 205 |
+
# uptime monitor) may poll every few seconds.
|
| 206 |
+
_HEALTH_CACHE_TTL = 15.0
|
| 207 |
+
_health_cache: dict[str, Any] = {"ts": 0.0, "detail": None}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
async def _check_bucket_reachable() -> str | None:
|
| 211 |
+
if time.monotonic() - _health_cache["ts"] < _HEALTH_CACHE_TTL:
|
| 212 |
+
return _health_cache["detail"]
|
| 213 |
+
try:
|
| 214 |
+
client: httpx.AsyncClient = app.state.client
|
| 215 |
+
r = await client.get(f"{HUB}/api/buckets/{BUCKET}/tree/{AGENTS_PREFIX}")
|
| 216 |
+
# 404 just means the agents/ folder doesn't exist yet — the bucket
|
| 217 |
+
# itself is still reachable with this token.
|
| 218 |
+
detail = None if (r.is_success or r.status_code == 404) else f"HTTP {r.status_code}"
|
| 219 |
+
except Exception as exc:
|
| 220 |
+
detail = str(exc)
|
| 221 |
+
_health_cache["ts"] = time.monotonic()
|
| 222 |
+
_health_cache["detail"] = detail
|
| 223 |
+
return detail
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@app.get("/api/health")
|
| 227 |
+
async def health() -> dict[str, Any]:
|
| 228 |
+
mode = "local" if LOCAL_BUCKET_DIR else ("hub" if HF_TOKEN else "unconfigured")
|
| 229 |
+
if mode == "hub":
|
| 230 |
+
detail = await _check_bucket_reachable()
|
| 231 |
+
if detail:
|
| 232 |
+
raise HTTPException(503, f"HF_TOKEN cannot read {BUCKET}: {detail}")
|
| 233 |
+
return {
|
| 234 |
+
"ok": True,
|
| 235 |
+
"mode": mode,
|
| 236 |
+
"bucket": BUCKET,
|
| 237 |
+
"prefix": PREFIX,
|
| 238 |
+
"results_prefix": RESULTS_PREFIX,
|
| 239 |
+
"agents_prefix": AGENTS_PREFIX,
|
| 240 |
+
"oauth": bool(OAUTH_CLIENT_ID),
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
@app.get("/api/config")
|
| 245 |
+
async def config() -> dict[str, Any]:
|
| 246 |
+
"""Challenge branding + scoring config consumed by the SPA at boot, so
|
| 247 |
+
the frontend stays a static file with no challenge-specific edits."""
|
| 248 |
+
return {
|
| 249 |
+
"title": CHALLENGE_TITLE,
|
| 250 |
+
"tagline": CHALLENGE_TAGLINE,
|
| 251 |
+
"org": ORG,
|
| 252 |
+
"bucket": BUCKET,
|
| 253 |
+
"bucket_web_url": f"{HUB}/buckets/{BUCKET}" if BUCKET else "",
|
| 254 |
+
"score_field": SCORE_FIELD,
|
| 255 |
+
"score_label": SCORE_LABEL,
|
| 256 |
+
"score_unit": SCORE_UNIT,
|
| 257 |
+
"score_order": SCORE_ORDER,
|
| 258 |
+
"secondary_field": SECONDARY_FIELD,
|
| 259 |
+
"secondary_label": SECONDARY_LABEL,
|
| 260 |
+
"invite_url": INVITE_URL,
|
| 261 |
+
"api_url": BACKEND_API_URL,
|
| 262 |
+
"directory_url": DIRECTORY_URL,
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
# ──────────────────────────────────────────────────────────────
|
| 267 |
+
# OAuth (HF Spaces auto-injects OAUTH_CLIENT_ID/SECRET when
|
| 268 |
+
# `hf_oauth: true` is set in README.md).
|
| 269 |
+
#
|
| 270 |
+
# `hf_oauth_authorized_org: <org>` in README.md gates the OAuth grant
|
| 271 |
+
# itself — non-members can't authenticate, so we don't need to manually
|
| 272 |
+
# re-check org membership here.
|
| 273 |
+
# ──────────────────────────────────────────────────────────────
|
| 274 |
+
def _redirect_uri(request: Request) -> str:
|
| 275 |
+
# The Hub spec stores configured redirects as `https://{space}/auth/callback`,
|
| 276 |
+
# so build the URL from the public host the request came in on rather than
|
| 277 |
+
# whatever the local app sees (uvicorn behind a TLS-terminating proxy).
|
| 278 |
+
forwarded_proto = request.headers.get("x-forwarded-proto", request.url.scheme)
|
| 279 |
+
host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc
|
| 280 |
+
return f"{forwarded_proto}://{host}/auth/callback"
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
@app.get("/login")
|
| 284 |
+
async def login(request: Request):
|
| 285 |
+
if DEV_FAKE_LOGIN and not OAUTH_CLIENT_ID:
|
| 286 |
+
log.warning(
|
| 287 |
+
"DEV_FAKE_LOGIN active — minting a fake session for %r (local "
|
| 288 |
+
"test environment only; never set this on a deployed Space).",
|
| 289 |
+
DEV_FAKE_LOGIN,
|
| 290 |
+
)
|
| 291 |
+
request.session["user"] = DEV_FAKE_LOGIN
|
| 292 |
+
# Placeholder token: the local dev backend (backend/scripts/
|
| 293 |
+
# dev_server.py) resolves ANY bearer token to its configured user.
|
| 294 |
+
request.session["access_token"] = "dev-token"
|
| 295 |
+
request.session.pop("is_organizer", None)
|
| 296 |
+
return RedirectResponse("/")
|
| 297 |
+
if not (OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
|
| 298 |
+
return Response(
|
| 299 |
+
"OAuth is not configured on this server (set hf_oauth: true in the "
|
| 300 |
+
"Space README and redeploy).\n",
|
| 301 |
+
status_code=503,
|
| 302 |
+
media_type="text/plain",
|
| 303 |
+
)
|
| 304 |
+
state = secrets.token_urlsafe(16)
|
| 305 |
+
request.session["oauth_state"] = state
|
| 306 |
+
next_url = request.query_params.get("next", "/")
|
| 307 |
+
request.session["oauth_next"] = next_url if next_url.startswith("/") else "/"
|
| 308 |
+
params = urlencode({
|
| 309 |
+
"response_type": "code",
|
| 310 |
+
"client_id": OAUTH_CLIENT_ID,
|
| 311 |
+
"redirect_uri": _redirect_uri(request),
|
| 312 |
+
"scope": OAUTH_SCOPES,
|
| 313 |
+
"state": state,
|
| 314 |
+
})
|
| 315 |
+
return RedirectResponse(f"{HUB}/oauth/authorize?{params}")
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
@app.get("/auth/callback")
|
| 319 |
+
async def oauth_callback(request: Request):
|
| 320 |
+
# rid is logged on every branch so we can correlate one user's full flow
|
| 321 |
+
# in the Space logs without exposing PII. Surfaced back via header for
|
| 322 |
+
# browser-side correlation.
|
| 323 |
+
rid = secrets.token_hex(4)
|
| 324 |
+
error = request.query_params.get("error")
|
| 325 |
+
if error:
|
| 326 |
+
log.warning("[oauth %s] provider error=%s desc=%s", rid, error, request.query_params.get("error_description", "")[:200])
|
| 327 |
+
return RedirectResponse(f"/?login_error={error}")
|
| 328 |
+
code = request.query_params.get("code")
|
| 329 |
+
state = request.query_params.get("state")
|
| 330 |
+
session_state = request.session.get("oauth_state")
|
| 331 |
+
if not code or not state or state != session_state:
|
| 332 |
+
# The single most common failure mode in iframe deployments: the
|
| 333 |
+
# session cookie set by /login didn't make it back to /auth/callback,
|
| 334 |
+
# so the saved state is missing. Log enough to tell which it is.
|
| 335 |
+
log.warning(
|
| 336 |
+
"[oauth %s] bad_state code=%s state_param=%s session_state=%s cookies_present=%s",
|
| 337 |
+
rid, bool(code), bool(state), bool(session_state), bool(request.cookies),
|
| 338 |
+
)
|
| 339 |
+
return RedirectResponse("/?login_error=bad_state")
|
| 340 |
+
if not (OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
|
| 341 |
+
log.warning("[oauth %s] server_unconfigured", rid)
|
| 342 |
+
return RedirectResponse("/?login_error=server_unconfigured")
|
| 343 |
+
|
| 344 |
+
# Use a fresh client so we don't inherit `Authorization: Bearer HF_TOKEN`
|
| 345 |
+
# from app.state.client — HF's /oauth/token expects client_id+client_secret,
|
| 346 |
+
# not a Space-token Bearer header, and rejects the request otherwise.
|
| 347 |
+
try:
|
| 348 |
+
async with httpx.AsyncClient(timeout=httpx.Timeout(HUB_FETCH_TIMEOUT), follow_redirects=True) as oauth_client:
|
| 349 |
+
token_resp = await oauth_client.post(
|
| 350 |
+
f"{HUB}/oauth/token",
|
| 351 |
+
data={
|
| 352 |
+
"grant_type": "authorization_code",
|
| 353 |
+
"code": code,
|
| 354 |
+
"redirect_uri": _redirect_uri(request),
|
| 355 |
+
"client_id": OAUTH_CLIENT_ID,
|
| 356 |
+
"client_secret": OAUTH_CLIENT_SECRET,
|
| 357 |
+
},
|
| 358 |
+
headers={"Accept": "application/json"},
|
| 359 |
+
)
|
| 360 |
+
if not token_resp.is_success:
|
| 361 |
+
log.warning("[oauth %s] token_exchange status=%s body=%s", rid, token_resp.status_code, token_resp.text[:300])
|
| 362 |
+
return RedirectResponse("/?login_error=token_exchange")
|
| 363 |
+
access_token = token_resp.json().get("access_token")
|
| 364 |
+
if not access_token:
|
| 365 |
+
log.warning("[oauth %s] no_token body=%s", rid, token_resp.text[:200])
|
| 366 |
+
return RedirectResponse("/?login_error=no_token")
|
| 367 |
+
|
| 368 |
+
me_resp = await oauth_client.get(
|
| 369 |
+
f"{HUB}/api/whoami-v2",
|
| 370 |
+
headers={"Authorization": f"Bearer {access_token}"},
|
| 371 |
+
)
|
| 372 |
+
if not me_resp.is_success:
|
| 373 |
+
log.warning("[oauth %s] whoami status=%s body=%s", rid, me_resp.status_code, me_resp.text[:200])
|
| 374 |
+
return RedirectResponse("/?login_error=whoami")
|
| 375 |
+
me = me_resp.json()
|
| 376 |
+
username = me.get("name") or me.get("preferred_username")
|
| 377 |
+
if not username:
|
| 378 |
+
log.warning("[oauth %s] no_username keys=%s", rid, sorted(me.keys()))
|
| 379 |
+
return RedirectResponse("/?login_error=no_username")
|
| 380 |
+
# Defense-in-depth org check (HF should already have rejected
|
| 381 |
+
# non-members upstream because hf_oauth_authorized_org is set).
|
| 382 |
+
org_names = {o.get("name") for o in (me.get("orgs") or []) if isinstance(o, dict)}
|
| 383 |
+
if OAUTH_REQUIRED_ORG and OAUTH_REQUIRED_ORG not in org_names:
|
| 384 |
+
log.warning("[oauth %s] not_in_org user=%s orgs=%s", rid, username, sorted(org_names))
|
| 385 |
+
return RedirectResponse("/?login_error=not_in_org")
|
| 386 |
+
|
| 387 |
+
request.session["user"] = username
|
| 388 |
+
request.session["avatar"] = me.get("avatarUrl") or ""
|
| 389 |
+
# Persist the access token so the user posts to the bucket as
|
| 390 |
+
# themselves (real HF commit attribution) rather than the Space.
|
| 391 |
+
request.session["access_token"] = access_token
|
| 392 |
+
# /api/me refreshes the organizer display hint on the redirected page.
|
| 393 |
+
request.session.pop("is_organizer", None)
|
| 394 |
+
request.session.pop("oauth_state", None)
|
| 395 |
+
next_url = request.session.pop("oauth_next", "/")
|
| 396 |
+
log.info("[oauth %s] success user=%s", rid, username)
|
| 397 |
+
return RedirectResponse(next_url if next_url.startswith("/") else "/")
|
| 398 |
+
except Exception as e:
|
| 399 |
+
log.warning("[oauth %s] exception %s: %s", rid, type(e).__name__, e)
|
| 400 |
+
return RedirectResponse("/?login_error=exception")
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
@app.get("/logout")
|
| 404 |
+
async def logout(request: Request):
|
| 405 |
+
request.session.clear()
|
| 406 |
+
return RedirectResponse("/")
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
async def _fetch_is_organizer(access_token: str | None) -> bool | None:
|
| 410 |
+
"""Ask the bucket-sync API whether the signed-in user may broadcast.
|
| 411 |
+
|
| 412 |
+
The dashboard can't read roleInOrg from the OAuth token, so it defers to
|
| 413 |
+
GET /v1/me (which resolves the role with the Space's admin token). Any
|
| 414 |
+
failure — no backend configured, network, non-200 — returns None so a
|
| 415 |
+
transient outage does not permanently overwrite the display hint. The post
|
| 416 |
+
path re-verifies regardless.
|
| 417 |
+
"""
|
| 418 |
+
if not (BACKEND_API_URL and access_token):
|
| 419 |
+
return None
|
| 420 |
+
try:
|
| 421 |
+
async with httpx.AsyncClient(timeout=httpx.Timeout(HUB_FETCH_TIMEOUT)) as client:
|
| 422 |
+
r = await client.get(
|
| 423 |
+
f"{BACKEND_API_URL}/v1/me",
|
| 424 |
+
headers={"Authorization": f"Bearer {access_token}"},
|
| 425 |
+
)
|
| 426 |
+
if r.status_code == 200:
|
| 427 |
+
return bool(r.json().get("is_organizer"))
|
| 428 |
+
except Exception as e:
|
| 429 |
+
log.warning("could not resolve organizer status: %s", e)
|
| 430 |
+
return None
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
@app.get("/api/me")
|
| 434 |
+
async def api_me(request: Request) -> dict[str, Any]:
|
| 435 |
+
user = request.session.get("user")
|
| 436 |
+
if not user:
|
| 437 |
+
return {"logged_in": False, "oauth_configured": bool(OAUTH_CLIENT_ID)}
|
| 438 |
+
is_organizer = await _fetch_is_organizer(request.session.get("access_token"))
|
| 439 |
+
if is_organizer is not None:
|
| 440 |
+
request.session["is_organizer"] = is_organizer
|
| 441 |
+
return {
|
| 442 |
+
"logged_in": True,
|
| 443 |
+
"user": user,
|
| 444 |
+
"avatar": request.session.get("avatar") or "",
|
| 445 |
+
"is_organizer": bool(request.session.get("is_organizer")),
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
# ──────────────────────────────────────────────────────────────
|
| 450 |
+
# Shared listing helpers (used by /api/messages and /api/results)
|
| 451 |
+
# ──────────────────────────────────────────────────────────────
|
| 452 |
+
def _list_md_local(prefix: str) -> list[dict[str, str]]:
|
| 453 |
+
folder = Path(LOCAL_BUCKET_DIR) / prefix
|
| 454 |
+
if not folder.is_dir():
|
| 455 |
+
return []
|
| 456 |
+
items: list[dict[str, str]] = []
|
| 457 |
+
for f in sorted(folder.glob("*.md")):
|
| 458 |
+
if f.name.lower() == "readme.md":
|
| 459 |
+
continue
|
| 460 |
+
try:
|
| 461 |
+
items.append({"filename": f.name, "content": f.read_text(encoding="utf-8")})
|
| 462 |
+
except OSError:
|
| 463 |
+
pass
|
| 464 |
+
return items
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
# Per-file content cache. Board files are immutable once written (new files
|
| 468 |
+
# get new names), so content keyed by the tree listing's content hash never
|
| 469 |
+
# goes stale — a listing refresh only has to fetch files it hasn't seen.
|
| 470 |
+
# This collapses the per-refresh fan-out from one GET per file (500+ for
|
| 471 |
+
# message_board) to one tree call plus a handful of new files.
|
| 472 |
+
_file_cache: dict[str, tuple[str, str]] = {} # path → (validator, content)
|
| 473 |
+
|
| 474 |
+
# Cap concurrent resolve fetches well below the connection-pool size so a
|
| 475 |
+
# cold-cache fan-out can never exhaust the pool (the PoolTimeout cascade
|
| 476 |
+
# that wedged the Space as the message board grew).
|
| 477 |
+
FETCH_CONCURRENCY = int(os.environ.get("HUB_FETCH_CONCURRENCY", "32"))
|
| 478 |
+
_fetch_sem = asyncio.Semaphore(FETCH_CONCURRENCY)
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
def _entry_validator(e: dict[str, Any]) -> str:
|
| 482 |
+
# xetHash identifies content exactly; size+mtime is a good fallback for
|
| 483 |
+
# entries that lack it.
|
| 484 |
+
return str(e.get("xetHash") or f"{e.get('size')}-{e.get('mtime')}")
|
| 485 |
+
|
| 486 |
+
|
| 487 |
+
async def _list_md_hub(prefix: str) -> list[dict[str, str]]:
|
| 488 |
+
if not HF_TOKEN:
|
| 489 |
+
raise HTTPException(401, "Server is not configured: set HF_TOKEN.")
|
| 490 |
+
client: httpx.AsyncClient = app.state.client
|
| 491 |
+
|
| 492 |
+
# The tree endpoint paginates (1000 entries/page) via a Link rel="next"
|
| 493 |
+
# header — follow it, or the board silently freezes at 1000 files.
|
| 494 |
+
raw_entries: list[dict[str, Any]] = []
|
| 495 |
+
url: str | None = f"{HUB}/api/buckets/{BUCKET}/tree/{prefix}"
|
| 496 |
+
while url:
|
| 497 |
+
tree_resp = await client.get(url)
|
| 498 |
+
if tree_resp.status_code == 404 and not raw_entries:
|
| 499 |
+
# Folder may not exist yet (e.g. fresh `results/` before any agent posts).
|
| 500 |
+
return []
|
| 501 |
+
if tree_resp.status_code == 401:
|
| 502 |
+
raise HTTPException(401, "HF_TOKEN lacks access to this bucket.")
|
| 503 |
+
if not tree_resp.is_success:
|
| 504 |
+
raise HTTPException(tree_resp.status_code, f"Hub tree fetch: {tree_resp.text[:200]}")
|
| 505 |
+
raw_entries.extend(tree_resp.json())
|
| 506 |
+
url = tree_resp.links.get("next", {}).get("url")
|
| 507 |
+
|
| 508 |
+
entries: list[dict[str, Any]] = [
|
| 509 |
+
e
|
| 510 |
+
for e in raw_entries
|
| 511 |
+
if e.get("type") == "file"
|
| 512 |
+
and e.get("path", "").endswith(".md")
|
| 513 |
+
and not e["path"].lower().endswith("readme.md")
|
| 514 |
+
]
|
| 515 |
+
|
| 516 |
+
async def fetch_one(e: dict[str, Any]) -> dict[str, str] | None:
|
| 517 |
+
path: str = e["path"]
|
| 518 |
+
validator = _entry_validator(e)
|
| 519 |
+
cached = _file_cache.get(path)
|
| 520 |
+
if cached and cached[0] == validator:
|
| 521 |
+
return {"filename": path.split("/")[-1], "content": cached[1]}
|
| 522 |
+
try:
|
| 523 |
+
async with _fetch_sem:
|
| 524 |
+
r = await client.get(f"{HUB}/buckets/{BUCKET}/resolve/{path}")
|
| 525 |
+
if r.status_code != 200:
|
| 526 |
+
log.warning("Fetch %s → %s", path, r.status_code)
|
| 527 |
+
return None
|
| 528 |
+
_file_cache[path] = (validator, r.text)
|
| 529 |
+
return {"filename": path.split("/")[-1], "content": r.text}
|
| 530 |
+
except Exception as exc:
|
| 531 |
+
log.warning("Fetch %s failed: %s", path, exc)
|
| 532 |
+
return None
|
| 533 |
+
|
| 534 |
+
results = await asyncio.gather(*(fetch_one(e) for e in entries))
|
| 535 |
+
|
| 536 |
+
# Drop cache entries for files deleted from the bucket.
|
| 537 |
+
live = {e["path"] for e in entries}
|
| 538 |
+
for stale in [p for p in _file_cache if p.startswith(f"{prefix}/") and p not in live]:
|
| 539 |
+
_file_cache.pop(stale, None)
|
| 540 |
+
|
| 541 |
+
return [r for r in results if r is not None]
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
# ──────────────────────────────────────────────────────────────
|
| 545 |
+
# Hub fetch cache
|
| 546 |
+
#
|
| 547 |
+
# A short in-process TTL cache fronts every Hub-backed endpoint (the
|
| 548 |
+
# frontend polls every 30s and multiple users may be open at once).
|
| 549 |
+
# Refreshes are single-flight per key and run as *background tasks*
|
| 550 |
+
# awaited through asyncio.shield: when an impatient client disconnects,
|
| 551 |
+
# uvicorn cancels only that request's await, never the refresh itself.
|
| 552 |
+
# Cancelling the refresh mid-fan-out is what used to leak httpx pool
|
| 553 |
+
# slots until the whole pool wedged (PoolTimeout on every request).
|
| 554 |
+
# On a failed refresh the last known value is served, so transient Hub
|
| 555 |
+
# blips degrade to slightly-stale data instead of errors.
|
| 556 |
+
# ──────────────────────────────────────────────────────────────
|
| 557 |
+
LIST_CACHE_TTL = float(os.environ.get("LIST_CACHE_TTL", "20.0"))
|
| 558 |
+
|
| 559 |
+
# Hard cap on distinct cache keys. Most surfaces cache under a fixed key (e.g.
|
| 560 |
+
# "__channels__"), but /api/updates and /api/traces key on the raw query
|
| 561 |
+
# string — an anonymous client looping distinct query strings could otherwise
|
| 562 |
+
# grow `_values` without bound. Enforced in the cache class itself (below) so
|
| 563 |
+
# every caller benefits, not just the ones we remember to bound individually.
|
| 564 |
+
_CACHE_MAX_ENTRIES = 512
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
class _SingleFlightCache:
|
| 568 |
+
def __init__(self, ttl: float):
|
| 569 |
+
self.ttl = ttl
|
| 570 |
+
self._values: dict[str, tuple[float, Any]] = {}
|
| 571 |
+
self._tasks: dict[str, asyncio.Task] = {}
|
| 572 |
+
|
| 573 |
+
async def get(self, key: str, refresh) -> Any:
|
| 574 |
+
cached = self._values.get(key)
|
| 575 |
+
if cached and (time.monotonic() - cached[0]) < self.ttl:
|
| 576 |
+
return cached[1]
|
| 577 |
+
task = self._tasks.get(key)
|
| 578 |
+
if task is None or task.done():
|
| 579 |
+
task = asyncio.create_task(self._refresh(key, refresh))
|
| 580 |
+
self._tasks[key] = task
|
| 581 |
+
try:
|
| 582 |
+
return await asyncio.shield(task)
|
| 583 |
+
except asyncio.CancelledError:
|
| 584 |
+
# The *waiter* was cancelled (client gone); the refresh task
|
| 585 |
+
# itself keeps running for everyone else.
|
| 586 |
+
raise
|
| 587 |
+
except Exception:
|
| 588 |
+
cached = cached or self._values.get(key)
|
| 589 |
+
if cached:
|
| 590 |
+
log.warning("Refresh of %s failed; serving stale value.", key)
|
| 591 |
+
return cached[1]
|
| 592 |
+
raise
|
| 593 |
+
|
| 594 |
+
async def _refresh(self, key: str, refresh) -> Any:
|
| 595 |
+
try:
|
| 596 |
+
value = await refresh()
|
| 597 |
+
except Exception:
|
| 598 |
+
# A failed refresh writes nothing, so the success path's eviction
|
| 599 |
+
# never runs for it. Sweep anyway: otherwise a flood of failing
|
| 600 |
+
# keys grows `_tasks` unchecked until the next success.
|
| 601 |
+
self._evict(time.monotonic())
|
| 602 |
+
raise
|
| 603 |
+
now = time.monotonic()
|
| 604 |
+
self._values[key] = (now, value)
|
| 605 |
+
self._evict(now)
|
| 606 |
+
return value
|
| 607 |
+
|
| 608 |
+
def _evict(self, now: float) -> None:
|
| 609 |
+
"""Bound `_values` and `_tasks`. Runs on every refresh, successful or
|
| 610 |
+
not — a failed refresh writes nothing, so pruning only after a
|
| 611 |
+
successful write would leave a flood of failures unbounded.
|
| 612 |
+
|
| 613 |
+
For `_values`: first drop entries past their own TTL — cheap and
|
| 614 |
+
usually enough on its own — then, if still over the cap, evict
|
| 615 |
+
oldest-first by recorded write time until back at the cap. Evicting a
|
| 616 |
+
key whose refresh is still in flight is harmless: that task writes a
|
| 617 |
+
fresh entry when it completes.
|
| 618 |
+
|
| 619 |
+
For `_tasks`: drop every task that is DONE. Without this the map
|
| 620 |
+
retains one finished Task — and its result — per distinct key ever
|
| 621 |
+
seen, the same unbounded growth the `_values` cap exists to close,
|
| 622 |
+
one dict over. The sweep is invisible to callers: `get()` takes the
|
| 623 |
+
identical branch for a missing task and a done one (`task is None or
|
| 624 |
+
task.done()` → start a fresh one). An IN-FLIGHT task is deliberately
|
| 625 |
+
kept — dropping it would let the next caller start a duplicate
|
| 626 |
+
upstream call and break single-flighting, which is the one thing this
|
| 627 |
+
map is for.
|
| 628 |
+
|
| 629 |
+
Sweeping by doneness rather than pairing each drop to a `_values` pop
|
| 630 |
+
is deliberate: it also reclaims the orphans a paired drop cannot see —
|
| 631 |
+
keys whose refresh raised (so they never reached `_values` at all) and
|
| 632 |
+
keys dropped by `invalidate()`."""
|
| 633 |
+
expired = [k for k, (ts, _) in self._values.items() if now - ts >= self.ttl]
|
| 634 |
+
for k in expired:
|
| 635 |
+
self._values.pop(k, None)
|
| 636 |
+
overflow = len(self._values) - _CACHE_MAX_ENTRIES
|
| 637 |
+
if overflow > 0:
|
| 638 |
+
oldest = sorted(self._values.items(), key=lambda kv: kv[1][0])[:overflow]
|
| 639 |
+
for k, _ in oldest:
|
| 640 |
+
self._values.pop(k, None)
|
| 641 |
+
for k in [k for k, t in self._tasks.items() if t.done()]:
|
| 642 |
+
self._tasks.pop(k, None)
|
| 643 |
+
|
| 644 |
+
def invalidate(self, key: str) -> None:
|
| 645 |
+
self._values.pop(key, None)
|
| 646 |
+
|
| 647 |
+
def invalidate_prefix(self, prefix: str) -> None:
|
| 648 |
+
# Query-string-keyed entries (channel feeds) can't be busted by exact
|
| 649 |
+
# key; drop every variant for the resource.
|
| 650 |
+
for k in [k for k in self._values if k.startswith(prefix)]:
|
| 651 |
+
self._values.pop(k, None)
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
_hub_cache = _SingleFlightCache(LIST_CACHE_TTL)
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
async def _cached_list_md(prefix: str) -> list[dict[str, str]]:
|
| 658 |
+
if LOCAL_BUCKET_DIR:
|
| 659 |
+
# Filesystem reads are instant; no cache needed.
|
| 660 |
+
return _list_md_local(prefix)
|
| 661 |
+
return await _hub_cache.get(prefix, lambda: _list_md_hub(prefix))
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
def _invalidate_list_cache(prefix: str) -> None:
|
| 665 |
+
_hub_cache.invalidate(prefix)
|
| 666 |
+
|
| 667 |
+
|
| 668 |
+
# ──────────────────────────────────────────────────────────────
|
| 669 |
+
# /api/messages and /api/results
|
| 670 |
+
# ──────────────────────────────────────────────────────────────
|
| 671 |
+
@app.get("/api/messages")
|
| 672 |
+
async def messages() -> dict[str, Any]:
|
| 673 |
+
items = await _cached_list_md(PREFIX)
|
| 674 |
+
return {"items": items, "count": len(items)}
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
@app.get("/api/results")
|
| 678 |
+
async def results() -> dict[str, Any]:
|
| 679 |
+
items = await _cached_list_md(RESULTS_PREFIX)
|
| 680 |
+
return {"items": items, "count": len(items)}
|
| 681 |
+
|
| 682 |
+
|
| 683 |
+
@app.get("/api/agents")
|
| 684 |
+
async def agents() -> dict[str, Any]:
|
| 685 |
+
items = await _cached_list_md(AGENTS_PREFIX)
|
| 686 |
+
return {"items": items, "count": len(items)}
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
def _normalize_refs(refs: list[str]) -> list[str]:
|
| 690 |
+
clean_refs = [ref.strip().split("/")[-1] for ref in refs if ref.strip()]
|
| 691 |
+
if len(clean_refs) > 1:
|
| 692 |
+
raise HTTPException(400, "Only one quoted message is supported.")
|
| 693 |
+
for ref in clean_refs:
|
| 694 |
+
if not REF_FILENAME_RE.fullmatch(ref) or ref.lower() == "readme.md":
|
| 695 |
+
raise HTTPException(400, "Quoted message reference is invalid.")
|
| 696 |
+
return clean_refs
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
def _normalize_human_post(post: MessagePost, username: str) -> tuple[str, str, list[str]]:
|
| 700 |
+
body = post.body.strip()
|
| 701 |
+
if not HANDLE_RE.fullmatch(username):
|
| 702 |
+
raise HTTPException(400, "Logged-in username failed handle validation.")
|
| 703 |
+
if not body:
|
| 704 |
+
raise HTTPException(400, "Message body is required.")
|
| 705 |
+
if len(body) > MAX_USER_MESSAGE_CHARS:
|
| 706 |
+
raise HTTPException(
|
| 707 |
+
400,
|
| 708 |
+
f"Message body must be {MAX_USER_MESSAGE_CHARS} characters or fewer.",
|
| 709 |
+
)
|
| 710 |
+
refs = _normalize_refs(post.refs)
|
| 711 |
+
return username, body, refs
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
def _human_handle(username: str) -> str:
|
| 715 |
+
# Canonical routable form (bucket-sync inbox fan-out): lowercase, human-
|
| 716 |
+
# prefix. The same handle agents use to @-tag humans, so author and
|
| 717 |
+
# mention vocabulary coincide.
|
| 718 |
+
return f"human-{username.lower()}"
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
def _format_user_message(username: str, body: str, refs: list[str]) -> tuple[str, str]:
|
| 722 |
+
now = datetime.now(timezone.utc)
|
| 723 |
+
handle = _human_handle(username)
|
| 724 |
+
filename = f"{now:%Y%m%d-%H%M%S}_{handle}_{uuid4().hex[:8]}.md"
|
| 725 |
+
frontmatter = [
|
| 726 |
+
"---",
|
| 727 |
+
f"agent: {handle}",
|
| 728 |
+
"type: user",
|
| 729 |
+
f"timestamp: {now:%Y-%m-%d %H:%M UTC}",
|
| 730 |
+
]
|
| 731 |
+
if refs:
|
| 732 |
+
frontmatter.append(f"refs: {refs[0]}")
|
| 733 |
+
content = "\n".join([*frontmatter, "---", "", body, ""])
|
| 734 |
+
return filename, content
|
| 735 |
+
|
| 736 |
+
|
| 737 |
+
def _echo_user_message(
|
| 738 |
+
username: str,
|
| 739 |
+
body: str,
|
| 740 |
+
refs: list[str],
|
| 741 |
+
broadcast: bool = False,
|
| 742 |
+
channel: str | None = None,
|
| 743 |
+
) -> str:
|
| 744 |
+
"""Reconstruct (approximately) the file the bucket-sync API just wrote,
|
| 745 |
+
for the immediate UI echo — the next full reload serves the real bytes."""
|
| 746 |
+
now = datetime.now(timezone.utc)
|
| 747 |
+
frontmatter = [
|
| 748 |
+
"---",
|
| 749 |
+
f"agent: {_human_handle(username)}",
|
| 750 |
+
"type: user",
|
| 751 |
+
f"timestamp: {now:%Y-%m-%d %H:%M UTC}",
|
| 752 |
+
"via: dashboard",
|
| 753 |
+
]
|
| 754 |
+
if broadcast:
|
| 755 |
+
frontmatter.append("broadcast: true")
|
| 756 |
+
if channel:
|
| 757 |
+
frontmatter.append(f"channel: {channel}")
|
| 758 |
+
if refs:
|
| 759 |
+
frontmatter.append(f"refs: {refs[0]}")
|
| 760 |
+
return "\n".join([*frontmatter, "---", "", body, ""])
|
| 761 |
+
|
| 762 |
+
|
| 763 |
+
def _backend_error_message(resp: httpx.Response) -> str:
|
| 764 |
+
"""The bucket-sync error message, whatever the envelope.
|
| 765 |
+
|
| 766 |
+
bucket-sync's APIError handler returns ``{"error": {...}}`` at the TOP
|
| 767 |
+
level (not wrapped in FastAPI's ``detail``); pydantic validation errors
|
| 768 |
+
and plain HTTPExceptions use ``{"detail": ...}``. Parse all shapes so the
|
| 769 |
+
backend's verdict actually reaches the user verbatim."""
|
| 770 |
+
try:
|
| 771 |
+
p = resp.json()
|
| 772 |
+
except Exception:
|
| 773 |
+
return ""
|
| 774 |
+
if not isinstance(p, dict):
|
| 775 |
+
return ""
|
| 776 |
+
err = p.get("error")
|
| 777 |
+
if not isinstance(err, dict) and isinstance(p.get("detail"), dict):
|
| 778 |
+
err = p["detail"].get("error")
|
| 779 |
+
if isinstance(err, dict) and err.get("message"):
|
| 780 |
+
return str(err["message"])
|
| 781 |
+
if isinstance(p.get("detail"), str):
|
| 782 |
+
return p["detail"]
|
| 783 |
+
return ""
|
| 784 |
+
|
| 785 |
+
|
| 786 |
+
class _ApiPostRejected(Exception):
|
| 787 |
+
"""A bucket-sync verdict the user must see (e.g. rate limit). Falling
|
| 788 |
+
back to a direct bucket write would silently bypass it."""
|
| 789 |
+
|
| 790 |
+
def __init__(self, status: int, detail: str):
|
| 791 |
+
self.status = status
|
| 792 |
+
self.detail = detail
|
| 793 |
+
super().__init__(detail)
|
| 794 |
+
|
| 795 |
+
|
| 796 |
+
async def _post_message_via_api(
|
| 797 |
+
username: str,
|
| 798 |
+
body: str,
|
| 799 |
+
refs: list[str],
|
| 800 |
+
user_token: str,
|
| 801 |
+
broadcast: bool = False,
|
| 802 |
+
channel: str | None = None,
|
| 803 |
+
) -> dict[str, Any]:
|
| 804 |
+
"""POST through the bucket-sync API so @mentions and quote-refs land in
|
| 805 |
+
agent inboxes (its human-post path). The user's OAuth token is the
|
| 806 |
+
identity proof — the API verifies it via whoami and derives the handle
|
| 807 |
+
itself. Returns the API response dict; raises _ApiPostRejected for
|
| 808 |
+
verdicts to surface, any other exception means "fall back to the direct
|
| 809 |
+
bucket write" (board-visible, fan-out reconciled later by the backfill).
|
| 810 |
+
Broadcasts and channel posts never fall back (see the callers)."""
|
| 811 |
+
payload: dict[str, Any] = {
|
| 812 |
+
"agent_id": _human_handle(username),
|
| 813 |
+
"body": body,
|
| 814 |
+
"type": "user",
|
| 815 |
+
}
|
| 816 |
+
if refs:
|
| 817 |
+
payload["refs"] = refs[0]
|
| 818 |
+
if broadcast:
|
| 819 |
+
payload["broadcast"] = True
|
| 820 |
+
if channel:
|
| 821 |
+
payload["channel"] = channel
|
| 822 |
+
# A fresh client: app.state.client carries the Space's admin HF_TOKEN in
|
| 823 |
+
# its default headers, which must never ride along to another service.
|
| 824 |
+
async with httpx.AsyncClient(timeout=httpx.Timeout(HUB_FETCH_TIMEOUT)) as client:
|
| 825 |
+
r = await client.post(
|
| 826 |
+
f"{BACKEND_API_URL}/v1/messages",
|
| 827 |
+
json=payload,
|
| 828 |
+
headers={"Authorization": f"Bearer {user_token}"},
|
| 829 |
+
)
|
| 830 |
+
if r.status_code == 429:
|
| 831 |
+
raise _ApiPostRejected(
|
| 832 |
+
429, _backend_error_message(r) or "Rate limited — please slow down."
|
| 833 |
+
)
|
| 834 |
+
if r.status_code != 201:
|
| 835 |
+
if broadcast or channel:
|
| 836 |
+
# Broadcasts and channel posts never fall back to a direct write
|
| 837 |
+
# (only the backend can do the gated broadcasts/ write, and a
|
| 838 |
+
# direct channels/ write would skip validation, mention fan-out,
|
| 839 |
+
# and auto-subscribe) — surface the backend's verdict verbatim.
|
| 840 |
+
what = "Broadcast" if broadcast else "Channel post"
|
| 841 |
+
raise _ApiPostRejected(
|
| 842 |
+
r.status_code,
|
| 843 |
+
_backend_error_message(r) or f"{what} rejected ({r.status_code}).",
|
| 844 |
+
)
|
| 845 |
+
raise RuntimeError(f"bucket-sync API returned {r.status_code}: {r.text[:200]}")
|
| 846 |
+
return r.json()
|
| 847 |
+
|
| 848 |
+
|
| 849 |
+
def _write_message_local(filename: str, content: str) -> None:
|
| 850 |
+
msg_dir = Path(LOCAL_BUCKET_DIR) / PREFIX
|
| 851 |
+
msg_dir.mkdir(parents=True, exist_ok=True)
|
| 852 |
+
(msg_dir / filename).write_text(content, encoding="utf-8")
|
| 853 |
+
|
| 854 |
+
|
| 855 |
+
def _write_message_hub(filename: str, content: str, token: str | None = None) -> None:
|
| 856 |
+
try:
|
| 857 |
+
from huggingface_hub import batch_bucket_files
|
| 858 |
+
except ImportError as e:
|
| 859 |
+
raise RuntimeError("Install huggingface_hub to enable bucket writes.") from e
|
| 860 |
+
|
| 861 |
+
# Prefer the Space's HF_TOKEN for the central-bucket write: org members
|
| 862 |
+
# can only write to buckets they create, so a member's OAuth token cannot
|
| 863 |
+
# write to the central bucket — only a privileged Space token can. Fall
|
| 864 |
+
# back to the user's OAuth token if no HF_TOKEN is configured (a setup
|
| 865 |
+
# where members *can* write). The displayed author is unaffected either
|
| 866 |
+
# way: it comes from the `agent: human:{username}` frontmatter set from
|
| 867 |
+
# the OAuth session.
|
| 868 |
+
use_token = HF_TOKEN or token
|
| 869 |
+
if not use_token:
|
| 870 |
+
raise RuntimeError("No token available for writing to the bucket.")
|
| 871 |
+
|
| 872 |
+
batch_bucket_files(
|
| 873 |
+
BUCKET,
|
| 874 |
+
add=[(content.encode("utf-8"), f"{PREFIX}/{filename}")],
|
| 875 |
+
token=use_token,
|
| 876 |
+
)
|
| 877 |
+
|
| 878 |
+
|
| 879 |
+
@app.post("/api/messages")
|
| 880 |
+
async def post_message(post: MessagePost, request: Request) -> dict[str, Any]:
|
| 881 |
+
username = request.session.get("user")
|
| 882 |
+
if not username:
|
| 883 |
+
raise HTTPException(401, "Not logged in. Sign in with Hugging Face to post.")
|
| 884 |
+
user_token = request.session.get("access_token")
|
| 885 |
+
handle, body, refs = _normalize_human_post(post, username)
|
| 886 |
+
|
| 887 |
+
channel = (post.channel or "").strip() or None
|
| 888 |
+
if channel and not CHANNEL_NAME_RE.fullmatch(channel):
|
| 889 |
+
raise HTTPException(400, "Invalid channel name.")
|
| 890 |
+
if channel and post.broadcast:
|
| 891 |
+
# The backend 400s this combination; the UI never offers it
|
| 892 |
+
# (CHANNELS_DESIGN.md §8.2) — reject rather than guess an intent.
|
| 893 |
+
raise HTTPException(400, "A message cannot be both a broadcast and a channel post.")
|
| 894 |
+
|
| 895 |
+
if channel:
|
| 896 |
+
# Channel posts go ONLY through the bucket-sync API — a direct
|
| 897 |
+
# channels/ write would skip validation, mention fan-out, and
|
| 898 |
+
# auto-subscribe (same rule as broadcasts, CHANNELS_DESIGN.md §8.2).
|
| 899 |
+
if not (BACKEND_API_URL and user_token):
|
| 900 |
+
raise HTTPException(
|
| 901 |
+
503, "Channel posts require the bucket-sync API and a signed-in session."
|
| 902 |
+
)
|
| 903 |
+
try:
|
| 904 |
+
posted = await _post_message_via_api(
|
| 905 |
+
handle, body, refs, user_token, channel=channel
|
| 906 |
+
)
|
| 907 |
+
except _ApiPostRejected as e:
|
| 908 |
+
raise HTTPException(e.status, e.detail)
|
| 909 |
+
except Exception as e:
|
| 910 |
+
log.warning("channel post via bucket-sync API failed: %s", e)
|
| 911 |
+
raise HTTPException(502, "Channel post failed; nothing was posted.") from e
|
| 912 |
+
_hub_cache.invalidate("__channels__")
|
| 913 |
+
_hub_cache.invalidate(f"__channel__:{channel}")
|
| 914 |
+
_hub_cache.invalidate_prefix(f"__channel_msgs__:{channel}:")
|
| 915 |
+
# Posting into a channel auto-subscribes the poster (comment above),
|
| 916 |
+
# so the caller's own notify-level cache is stale too — invalidate it
|
| 917 |
+
# exactly as the subscribe proxy does, or the bell can stay hidden for
|
| 918 |
+
# up to the cache TTL + poll tick (WATCH_DESIGN.md §10.3).
|
| 919 |
+
_hub_cache.invalidate(f"__notify__:{_human_handle(handle)}")
|
| 920 |
+
return {
|
| 921 |
+
"item": {
|
| 922 |
+
"filename": posted["filename"],
|
| 923 |
+
"content": _echo_user_message(handle, body, refs, channel=channel),
|
| 924 |
+
},
|
| 925 |
+
"mentions_delivered": posted.get("mentions_delivered") or [],
|
| 926 |
+
"channel": channel,
|
| 927 |
+
"auto_subscribed": posted.get("auto_subscribed", False),
|
| 928 |
+
}
|
| 929 |
+
|
| 930 |
+
if post.broadcast:
|
| 931 |
+
# Organizer broadcast: only the bucket-sync API performs the gated
|
| 932 |
+
# broadcasts/ write, so this path never falls back to the local or
|
| 933 |
+
# direct write (which would post a plain message and silently drop the
|
| 934 |
+
# broadcast). The session flag is only a display hint; the API
|
| 935 |
+
# re-verifies and returns the authoritative allow/deny verdict.
|
| 936 |
+
if not (BACKEND_API_URL and user_token):
|
| 937 |
+
raise HTTPException(
|
| 938 |
+
503, "Broadcasting requires the bucket-sync API and a signed-in session."
|
| 939 |
+
)
|
| 940 |
+
try:
|
| 941 |
+
posted = await _post_message_via_api(
|
| 942 |
+
handle, body, refs, user_token, broadcast=True
|
| 943 |
+
)
|
| 944 |
+
request.session["is_organizer"] = True
|
| 945 |
+
except _ApiPostRejected as e:
|
| 946 |
+
if e.status == 403:
|
| 947 |
+
request.session["is_organizer"] = False
|
| 948 |
+
raise HTTPException(e.status, e.detail)
|
| 949 |
+
except Exception as e:
|
| 950 |
+
log.warning("broadcast via bucket-sync API failed: %s", e)
|
| 951 |
+
raise HTTPException(502, "Broadcast failed; nothing was posted.") from e
|
| 952 |
+
_invalidate_list_cache(PREFIX)
|
| 953 |
+
return {
|
| 954 |
+
"item": {
|
| 955 |
+
"filename": posted["filename"],
|
| 956 |
+
"content": _echo_user_message(handle, body, refs, broadcast=True),
|
| 957 |
+
},
|
| 958 |
+
"mentions_delivered": posted.get("mentions_delivered") or [],
|
| 959 |
+
"broadcast": True,
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
delivered: list[str] = []
|
| 963 |
+
# Preferred path whenever a backend is configured (hub mode AND the local
|
| 964 |
+
# test environment): the bucket-sync API fans @mentions and quote-refs out
|
| 965 |
+
# to inbox/{recipient}/ — a direct write never reaches the inboxes agents
|
| 966 |
+
# poll. Fallbacks below keep the message board-visible if the API is down.
|
| 967 |
+
posted: dict[str, Any] | None = None
|
| 968 |
+
if BACKEND_API_URL and user_token:
|
| 969 |
+
try:
|
| 970 |
+
posted = await _post_message_via_api(handle, body, refs, user_token)
|
| 971 |
+
except _ApiPostRejected as e:
|
| 972 |
+
raise HTTPException(e.status, e.detail)
|
| 973 |
+
except Exception as e:
|
| 974 |
+
log.warning(
|
| 975 |
+
"bucket-sync API post failed (%s); falling back to direct write.", e
|
| 976 |
+
)
|
| 977 |
+
if posted is not None:
|
| 978 |
+
filename = posted["filename"]
|
| 979 |
+
delivered = posted.get("mentions_delivered") or []
|
| 980 |
+
content = _echo_user_message(handle, body, refs)
|
| 981 |
+
elif LOCAL_BUCKET_DIR:
|
| 982 |
+
filename, content = _format_user_message(handle, body, refs)
|
| 983 |
+
try:
|
| 984 |
+
_write_message_local(filename, content)
|
| 985 |
+
except OSError as e:
|
| 986 |
+
log.warning("Local message write failed: %s", e)
|
| 987 |
+
raise HTTPException(500, "Could not write message to local bucket.") from e
|
| 988 |
+
else:
|
| 989 |
+
if not (user_token or HF_TOKEN):
|
| 990 |
+
raise HTTPException(401, "Server is not configured: set HF_TOKEN.")
|
| 991 |
+
# Fallback: the direct write. Board-visible immediately; the
|
| 992 |
+
# inbox fan-out for it is reconciled by the backend repo's
|
| 993 |
+
# scripts/backfill_inbox.py.
|
| 994 |
+
filename, content = _format_user_message(handle, body, refs)
|
| 995 |
+
try:
|
| 996 |
+
await asyncio.to_thread(_write_message_hub, filename, content, user_token)
|
| 997 |
+
except Exception as e:
|
| 998 |
+
log.warning("Hub message write failed: %s", e)
|
| 999 |
+
raise HTTPException(502, "Could not write message to the bucket.") from e
|
| 1000 |
+
# Bust the cache so other users see this message on their next poll
|
| 1001 |
+
# rather than waiting for the TTL.
|
| 1002 |
+
_invalidate_list_cache(PREFIX)
|
| 1003 |
+
return {
|
| 1004 |
+
"item": {"filename": filename, "content": content},
|
| 1005 |
+
"mentions_delivered": delivered,
|
| 1006 |
+
}
|
| 1007 |
+
|
| 1008 |
+
|
| 1009 |
+
# ──────────────────────────────────────────────────────────────
|
| 1010 |
+
# /api/verification (results/verification_status.json)
|
| 1011 |
+
#
|
| 1012 |
+
# Small JSON map of result-filename → "valid" | "invalid" | "pending".
|
| 1013 |
+
# A missing file means "nothing verified yet", which we report as {} so
|
| 1014 |
+
# the frontend can default every result to "pending".
|
| 1015 |
+
# ──────────────────────────────────────────────────────────────
|
| 1016 |
+
async def _fetch_verification_hub() -> str:
|
| 1017 |
+
client: httpx.AsyncClient = app.state.client
|
| 1018 |
+
rel = f"{RESULTS_PREFIX}/verification_status.json"
|
| 1019 |
+
r = await client.get(f"{HUB}/buckets/{BUCKET}/resolve/{rel}")
|
| 1020 |
+
if r.status_code == 404:
|
| 1021 |
+
return "{}"
|
| 1022 |
+
if r.status_code == 401:
|
| 1023 |
+
raise HTTPException(401, "HF_TOKEN lacks access to this bucket.")
|
| 1024 |
+
if not r.is_success:
|
| 1025 |
+
raise HTTPException(r.status_code, f"Hub returned {r.status_code}")
|
| 1026 |
+
return r.text
|
| 1027 |
+
|
| 1028 |
+
|
| 1029 |
+
@app.get("/api/verification")
|
| 1030 |
+
async def verification() -> Response:
|
| 1031 |
+
rel = f"{RESULTS_PREFIX}/verification_status.json"
|
| 1032 |
+
if LOCAL_BUCKET_DIR:
|
| 1033 |
+
path = Path(LOCAL_BUCKET_DIR) / rel
|
| 1034 |
+
if not path.is_file():
|
| 1035 |
+
return Response(content="{}", media_type="application/json")
|
| 1036 |
+
return Response(
|
| 1037 |
+
content=path.read_text(encoding="utf-8"),
|
| 1038 |
+
media_type="application/json",
|
| 1039 |
+
)
|
| 1040 |
+
if not HF_TOKEN:
|
| 1041 |
+
raise HTTPException(401, "Server is not configured: set HF_TOKEN.")
|
| 1042 |
+
text = await _hub_cache.get("__verification__", _fetch_verification_hub)
|
| 1043 |
+
return Response(content=text, media_type="application/json")
|
| 1044 |
+
|
| 1045 |
+
|
| 1046 |
+
# ──────────────────────────────────────────────────────────────
|
| 1047 |
+
# /api/stats and /api/traces — proxied from the bucket-sync backend
|
| 1048 |
+
#
|
| 1049 |
+
# The backend already computes the project token aggregate and the trace
|
| 1050 |
+
# listing; the SPA can't call it cross-origin (the backend sets no CORS), so we
|
| 1051 |
+
# proxy same-origin here. Cached + single-flight like the bucket listings. Off
|
| 1052 |
+
# (503) when no BACKEND_API_URL — e.g. local dev — so the panel just hides.
|
| 1053 |
+
# ──────────────────────────────────────────────────────────────
|
| 1054 |
+
async def _proxy_backend_json(path: str) -> Any:
|
| 1055 |
+
if not BACKEND_API_URL:
|
| 1056 |
+
# Shared by every backend-only read (traces, stats, channels, watch):
|
| 1057 |
+
# 503 is the SPA's signal to hide the feature rather than show it empty.
|
| 1058 |
+
raise HTTPException(
|
| 1059 |
+
503, "This view needs BACKEND_API_URL (the bucket-sync Space)."
|
| 1060 |
+
)
|
| 1061 |
+
# A fresh client: app.state.client carries the Space's admin HF_TOKEN, which
|
| 1062 |
+
# must never ride along to another service (the backend GETs are tokenless).
|
| 1063 |
+
async with httpx.AsyncClient(timeout=httpx.Timeout(HUB_FETCH_TIMEOUT)) as client:
|
| 1064 |
+
r = await client.get(f"{BACKEND_API_URL}{path}")
|
| 1065 |
+
if not r.is_success:
|
| 1066 |
+
raise HTTPException(r.status_code, f"backend {path}: {r.text[:200]}")
|
| 1067 |
+
return r.json()
|
| 1068 |
+
|
| 1069 |
+
|
| 1070 |
+
@app.get("/api/stats")
|
| 1071 |
+
async def stats_proxy() -> Any:
|
| 1072 |
+
return await _hub_cache.get("__stats__", lambda: _proxy_backend_json("/v1/stats"))
|
| 1073 |
+
|
| 1074 |
+
|
| 1075 |
+
# ──────────────────────────────────────────────────────────────
|
| 1076 |
+
# /api/channels — proxied from the bucket-sync backend
|
| 1077 |
+
#
|
| 1078 |
+
# Channel reads come from the backend's read model (summaries with member/
|
| 1079 |
+
# message counts, theme excerpts, activity) rather than re-implemented bucket
|
| 1080 |
+
# tree walks. Like traces, the whole feature hides in the UI when there is no
|
| 1081 |
+
# BACKEND_API_URL (local dev). CHANNELS_DESIGN.md §8.4.
|
| 1082 |
+
# ──────────────────────────────────────────────────────────────
|
| 1083 |
+
@app.get("/api/channels")
|
| 1084 |
+
async def channels_proxy() -> Any:
|
| 1085 |
+
return await _hub_cache.get(
|
| 1086 |
+
"__channels__", lambda: _proxy_backend_json("/v1/channels")
|
| 1087 |
+
)
|
| 1088 |
+
|
| 1089 |
+
|
| 1090 |
+
@app.get("/api/channels/{name}")
|
| 1091 |
+
async def channel_detail_proxy(name: str) -> Any:
|
| 1092 |
+
if not CHANNEL_NAME_RE.fullmatch(name):
|
| 1093 |
+
raise HTTPException(400, "Invalid channel name.")
|
| 1094 |
+
return await _hub_cache.get(
|
| 1095 |
+
f"__channel__:{name}", lambda: _proxy_backend_json(f"/v1/channels/{name}")
|
| 1096 |
+
)
|
| 1097 |
+
|
| 1098 |
+
|
| 1099 |
+
@app.get("/api/channels/{name}/messages")
|
| 1100 |
+
async def channel_messages_proxy(name: str, request: Request) -> Any:
|
| 1101 |
+
if not CHANNEL_NAME_RE.fullmatch(name):
|
| 1102 |
+
raise HTTPException(400, "Invalid channel name.")
|
| 1103 |
+
qs = request.url.query
|
| 1104 |
+
path = f"/v1/channels/{name}/messages?{qs}" if qs else f"/v1/channels/{name}/messages"
|
| 1105 |
+
return await _hub_cache.get(
|
| 1106 |
+
f"__channel_msgs__:{name}:{qs}", lambda: _proxy_backend_json(path)
|
| 1107 |
+
)
|
| 1108 |
+
|
| 1109 |
+
|
| 1110 |
+
@app.post("/api/channels")
|
| 1111 |
+
async def create_channel(post: ChannelCreate, request: Request) -> Any:
|
| 1112 |
+
"""Create a channel as the signed-in human. Backend is the authority
|
| 1113 |
+
(name rules, creation rate limit, 409 for existing names) and its errors
|
| 1114 |
+
surface verbatim in the modal; it also auto-announces the channel on the
|
| 1115 |
+
board and subscribes the creator (CHANNELS_DESIGN.md §8.3)."""
|
| 1116 |
+
username = request.session.get("user")
|
| 1117 |
+
if not username:
|
| 1118 |
+
raise HTTPException(401, "Not logged in. Sign in with Hugging Face to create a channel.")
|
| 1119 |
+
user_token = request.session.get("access_token")
|
| 1120 |
+
if not (BACKEND_API_URL and user_token):
|
| 1121 |
+
raise HTTPException(
|
| 1122 |
+
503, "Channel creation requires the bucket-sync API and a signed-in session."
|
| 1123 |
+
)
|
| 1124 |
+
name = post.name.strip()
|
| 1125 |
+
body = post.body.strip()
|
| 1126 |
+
if not CHANNEL_NAME_RE.fullmatch(name):
|
| 1127 |
+
raise HTTPException(
|
| 1128 |
+
400, "Channel name must be lowercase letters, digits, and hyphens (1-40 chars)."
|
| 1129 |
+
)
|
| 1130 |
+
if not body:
|
| 1131 |
+
raise HTTPException(400, "The theme is required — it's how agents decide to join.")
|
| 1132 |
+
if not HANDLE_RE.fullmatch(username):
|
| 1133 |
+
raise HTTPException(400, "Logged-in username failed handle validation.")
|
| 1134 |
+
payload = {"name": name, "agent_id": _human_handle(username), "body": body}
|
| 1135 |
+
async with httpx.AsyncClient(timeout=httpx.Timeout(HUB_FETCH_TIMEOUT)) as client:
|
| 1136 |
+
r = await client.post(
|
| 1137 |
+
f"{BACKEND_API_URL}/v1/channels",
|
| 1138 |
+
json=payload,
|
| 1139 |
+
headers={"Authorization": f"Bearer {user_token}"},
|
| 1140 |
+
)
|
| 1141 |
+
if r.status_code not in (200, 201):
|
| 1142 |
+
raise HTTPException(
|
| 1143 |
+
r.status_code,
|
| 1144 |
+
_backend_error_message(r) or f"Channel creation failed ({r.status_code}).",
|
| 1145 |
+
)
|
| 1146 |
+
# New channel list entry + the auto-announcement on the board. Creation
|
| 1147 |
+
# also auto-subscribes the creator (docstring above), so the caller's own
|
| 1148 |
+
# notify-level cache is stale too — invalidate it exactly as the subscribe
|
| 1149 |
+
# proxy does, or the bell can stay hidden for up to the cache TTL + poll
|
| 1150 |
+
# tick (WATCH_DESIGN.md §10.3).
|
| 1151 |
+
_hub_cache.invalidate("__channels__")
|
| 1152 |
+
_invalidate_list_cache(PREFIX)
|
| 1153 |
+
_hub_cache.invalidate(f"__notify__:{_human_handle(username)}")
|
| 1154 |
+
return r.json()
|
| 1155 |
+
|
| 1156 |
+
|
| 1157 |
+
@app.post("/api/channels/{name}/subscribe")
|
| 1158 |
+
async def subscribe_channel_proxy(name: str, post: ChannelNotify, request: Request) -> Any:
|
| 1159 |
+
"""Set the signed-in human's own notification level for one channel
|
| 1160 |
+
(WATCH_DESIGN.md §4.3): re-subscribing with `notify` IS the level change,
|
| 1161 |
+
and the backend patches the existing marker instead of re-stamping it, so
|
| 1162 |
+
flipping the bell never rewrites the roster's join date.
|
| 1163 |
+
|
| 1164 |
+
The user's OAuth token is the identity proof, exactly as for a human post —
|
| 1165 |
+
the backend derives the handle itself and stays the authority on the level
|
| 1166 |
+
vocabulary. Note the endpoint also *joins* a non-member at that level
|
| 1167 |
+
(it is the same idempotent subscribe call); the UI only offers the bell to
|
| 1168 |
+
members, so a click can never enrol you by surprise."""
|
| 1169 |
+
username = request.session.get("user")
|
| 1170 |
+
if not username:
|
| 1171 |
+
raise HTTPException(401, "Not logged in. Sign in with Hugging Face to change this.")
|
| 1172 |
+
user_token = request.session.get("access_token")
|
| 1173 |
+
if not (BACKEND_API_URL and user_token):
|
| 1174 |
+
raise HTTPException(
|
| 1175 |
+
503, "Notification levels require the bucket-sync API and a signed-in session."
|
| 1176 |
+
)
|
| 1177 |
+
if not CHANNEL_NAME_RE.fullmatch(name):
|
| 1178 |
+
raise HTTPException(400, "Invalid channel name.")
|
| 1179 |
+
if not HANDLE_RE.fullmatch(username):
|
| 1180 |
+
raise HTTPException(400, "Logged-in username failed handle validation.")
|
| 1181 |
+
level = post.notify.strip()
|
| 1182 |
+
if level not in NOTIFY_LEVELS:
|
| 1183 |
+
raise HTTPException(400, f"notify must be one of {list(NOTIFY_LEVELS)}.")
|
| 1184 |
+
handle = _human_handle(username)
|
| 1185 |
+
async with httpx.AsyncClient(timeout=httpx.Timeout(HUB_FETCH_TIMEOUT)) as client:
|
| 1186 |
+
r = await client.post(
|
| 1187 |
+
f"{BACKEND_API_URL}/v1/channels/{name}/subscribe",
|
| 1188 |
+
json={"agent_id": handle, "notify": level},
|
| 1189 |
+
headers={"Authorization": f"Bearer {user_token}"},
|
| 1190 |
+
)
|
| 1191 |
+
if r.status_code != 200:
|
| 1192 |
+
raise HTTPException(
|
| 1193 |
+
r.status_code,
|
| 1194 |
+
_backend_error_message(r)
|
| 1195 |
+
or f"Could not update the notification level ({r.status_code}).",
|
| 1196 |
+
)
|
| 1197 |
+
# The membership marker changed: the roster (members, count) and the
|
| 1198 |
+
# caller's own level map are both stale.
|
| 1199 |
+
_hub_cache.invalidate(f"__channel__:{name}")
|
| 1200 |
+
_hub_cache.invalidate("__channels__")
|
| 1201 |
+
_hub_cache.invalidate(f"__notify__:{handle}")
|
| 1202 |
+
return r.json()
|
| 1203 |
+
|
| 1204 |
+
|
| 1205 |
+
# ──────────────────────────────────────────────────────────────
|
| 1206 |
+
# Watch surfaces (WATCH_DESIGN.md §10) — proxied from the bucket-sync backend
|
| 1207 |
+
#
|
| 1208 |
+
# Like channels and traces, all of these 503 without BACKEND_API_URL (plain
|
| 1209 |
+
# local dev), which the SPA reads as "this deployment has no watch data" and
|
| 1210 |
+
# renders as nothing at all rather than as "nobody is watching".
|
| 1211 |
+
# ───────────────���──────────────────────────────────────────────
|
| 1212 |
+
@app.get("/api/updates")
|
| 1213 |
+
async def updates_proxy(request: Request) -> Any:
|
| 1214 |
+
"""The unified watch stream (§4.2), same-origin, with `wait` forced to 0.
|
| 1215 |
+
|
| 1216 |
+
A client-supplied `wait` is stripped rather than forwarded: the SPA stays on
|
| 1217 |
+
its 30s POLL_MS loop, and a parked browser connection would hold one of the
|
| 1218 |
+
backend's bounded waiter slots (256 total, 4 per handle) for latency nobody
|
| 1219 |
+
watching a screen can perceive — browsers would be competing for slots with
|
| 1220 |
+
the agents whose responsiveness this whole feature exists for (§10.2)."""
|
| 1221 |
+
forwarded = [(k, v) for k, v in request.query_params.multi_items() if k != "wait"]
|
| 1222 |
+
forwarded.append(("wait", "0"))
|
| 1223 |
+
qs = urlencode(forwarded)
|
| 1224 |
+
return await _hub_cache.get(
|
| 1225 |
+
f"__updates__:{qs}", lambda: _proxy_backend_json(f"/v1/updates?{qs}")
|
| 1226 |
+
)
|
| 1227 |
+
|
| 1228 |
+
|
| 1229 |
+
@app.get("/api/watching")
|
| 1230 |
+
async def watching_proxy() -> Any:
|
| 1231 |
+
"""Watch presence for every agent at once (§10.1), one backend call.
|
| 1232 |
+
|
| 1233 |
+
The backend's `/v1/watching` reads its in-process waiter registry: the whole
|
| 1234 |
+
handle → last-`wait>0`-poll map, the `wait` ceiling (so `fresh_s` is the
|
| 1235 |
+
backend's own number, never a copy of the knob here), and the waiter
|
| 1236 |
+
counters (§10.4) that an operator would otherwise fetch from the backend
|
| 1237 |
+
Space's `/v1/healthz`. This used to be one full digest per registered agent
|
| 1238 |
+
— inbox records, channel summaries and a leaderboard recomputed N times per
|
| 1239 |
+
30s poll to read N entries out of one dict."""
|
| 1240 |
+
return await _hub_cache.get(
|
| 1241 |
+
"__watching__", lambda: _proxy_backend_json("/v1/watching")
|
| 1242 |
+
)
|
| 1243 |
+
|
| 1244 |
+
|
| 1245 |
+
async def _fetch_notify_levels(handle: str) -> dict[str, Any]:
|
| 1246 |
+
digest = await _proxy_backend_json(f"/v1/digest?as={handle}")
|
| 1247 |
+
subs = ((digest or {}).get("channels") or {}).get("subscribed") or []
|
| 1248 |
+
return {
|
| 1249 |
+
"supported": True,
|
| 1250 |
+
"handle": handle,
|
| 1251 |
+
"levels": {
|
| 1252 |
+
s["name"]: (s.get("notify") or NOTIFY_LEVELS[0])
|
| 1253 |
+
for s in subs
|
| 1254 |
+
if isinstance(s, dict) and s.get("name")
|
| 1255 |
+
},
|
| 1256 |
+
}
|
| 1257 |
+
|
| 1258 |
+
|
| 1259 |
+
@app.get("/api/notify-levels")
|
| 1260 |
+
async def notify_levels(request: Request) -> Any:
|
| 1261 |
+
"""The signed-in human's own per-channel notification level, keyed by
|
| 1262 |
+
channel name — the state the channel view's bell renders (§10.3).
|
| 1263 |
+
|
| 1264 |
+
The digest is the only surface that publishes levels: the channel roster
|
| 1265 |
+
(`GET /v1/channels/{name}`) returns members without theirs, so this answers
|
| 1266 |
+
for the caller and nobody else. Logged out, or no backend → supported:
|
| 1267 |
+
false, and the bell never appears."""
|
| 1268 |
+
user = request.session.get("user")
|
| 1269 |
+
if not (user and BACKEND_API_URL and HANDLE_RE.fullmatch(user)):
|
| 1270 |
+
return {"supported": False, "levels": {}}
|
| 1271 |
+
handle = _human_handle(user)
|
| 1272 |
+
return await _hub_cache.get(
|
| 1273 |
+
f"__notify__:{handle}", lambda: _fetch_notify_levels(handle)
|
| 1274 |
+
)
|
| 1275 |
+
|
| 1276 |
+
|
| 1277 |
+
@app.get("/api/traces")
|
| 1278 |
+
async def traces_proxy(request: Request) -> Any:
|
| 1279 |
+
qs = request.url.query
|
| 1280 |
+
path = f"/v1/traces?{qs}" if qs else "/v1/traces"
|
| 1281 |
+
return await _hub_cache.get(f"__traces__:{qs}", lambda: _proxy_backend_json(path))
|
| 1282 |
+
|
| 1283 |
+
|
| 1284 |
+
# ──────────────────────────────────────────────────────────────
|
| 1285 |
+
# Static frontend (mounted last so /api/* keeps priority)
|
| 1286 |
+
# ──────────────────────────────────────────────────────────────
|
| 1287 |
+
_static_dir = Path(__file__).parent / "static"
|
| 1288 |
+
app.mount("/", StaticFiles(directory=str(_static_dir), html=True), name="static")
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.110
|
| 2 |
+
uvicorn[standard]>=0.29
|
| 3 |
+
httpx>=0.27
|
| 4 |
+
huggingface_hub>=1.0
|
| 5 |
+
itsdangerous>=2.1 # required by starlette.middleware.sessions
|
static/index.html
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
static/index.html.orig
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|