Spaces:
Running
Running
File size: 5,347 Bytes
47482e3 e45a917 47482e3 e45a917 47482e3 1c5f41d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | """
Webhook receiver: turns a push to psyche-private into a build Job.
WHY A SPACE IN FRONT OF A JOB
-----------------------------
A webhook can trigger a Job directly, and the Hub does re-run the Job — but it
clones only PART of the spec. Verified empirically against a throwaway repo:
field direct Job webhook-triggered
namespace psyche-org the token owner's PERSONAL namespace
secrets present DROPPED
volumes present DROPPED
timeout present DROPPED
A webhook-triggered Job therefore starts with no HF_TOKEN, cannot read the
private builder repo, and cannot write the output. That is not fixable by
configuration.
A Space, on the other hand, keeps its own secrets. So the Space is a thin
trigger that receives the webhook and creates the Job *properly* — with the
token, the flavor, the timeout and the right namespace. The Job does all the
work; this process only starts it.
SPACE SETUP
-----------
SDK gradio. Secrets:
HF_TOKEN write access to psyche-org
WEBHOOK_SECRET any random string; the same value goes in the webhook config
`config.toml` is uploaded next to this file and is the single source of truth
for the repo ids and the Job's hardware.
"""
import os
import threading
import tomllib
from huggingface_hub import HfApi, WebhookPayload, WebhooksServer
with open("config.toml", "rb") as file:
CONFIG = tomllib.load(file)
SOURCE_REPO = CONFIG["source"]["repo_id"]
BUILDER_REPO = CONFIG["builder"]["repo_id"]
JOB = CONFIG["job"]
TOKEN = os.environ["HF_TOKEN"]
api = HfApi(token=TOKEN)
# The Job's script: fetch the builder code, then build both artefacts. Kept
# identical to `buildDB/deploy.sh --print-job-command update`.
JOB_SCRIPT = f"""set -eu
export UV_PROJECT_ENVIRONMENT=/tmp/psyche-venv
uv run --no-project --with huggingface_hub python -c \
"from huggingface_hub import snapshot_download; \
snapshot_download('{BUILDER_REPO}', repo_type='dataset', local_dir='/tmp/builder')"
cd /tmp/builder
uv run --frozen buildDB/build.py update
uv run --frozen buildPaperIndex/build.py --sync-hf --upload
"""
ACTIVE_STAGES = ("RUNNING", "SCHEDULING")
_wake = threading.Event()
_last = {"job": None, "note": "nothing yet"}
def _running_job():
"""The build Job currently in flight, if any."""
for job in api.list_jobs(
namespace=JOB["namespace"], labels={"name": JOB["name"]}
):
if job.status and job.status.stage in ACTIVE_STAGES:
return job
return None
def _launch():
info = api.run_job(
image=JOB["image"],
command=["bash", "-c", JOB_SCRIPT],
env={
"PSYCHE_SOURCE_DIR": "/tmp/psyche-build/psyche-private",
"PSYCHE_OUTPUT_DIR": "/tmp/psyche-build/psyche",
"PYTHONUNBUFFERED": "1",
},
secrets={"HF_TOKEN": TOKEN},
flavor=JOB["flavor"],
timeout=JOB["timeout"],
labels={"name": JOB["name"], "mode": "update", "trigger": "webhook"},
namespace=JOB["namespace"],
)
return info
def _worker() -> None:
"""
One build at a time, bursts coalesced.
Several commits in a row wake this once more, not N times — and `update`
is idempotent, so a run that finds nothing to do exits in seconds.
"""
while True:
_wake.wait()
_wake.clear()
try:
existing = _running_job()
if existing is not None:
# Its own revision check will pick up whatever arrived while
# it was starting, so a second Job would only duplicate work.
_last["note"] = f"already running: {existing.id}"
print(f"[worker] {_last['note']}", flush=True)
continue
info = _launch()
_last["job"] = info.id
_last["note"] = f"launched {info.id}"
print(f"[worker] launched job {info.id}", flush=True)
except Exception as error: # noqa: BLE001 — must not kill the worker
_last["note"] = f"failed to launch: {error}"
print(f"[worker] {_last['note']}", flush=True)
threading.Thread(target=_worker, daemon=True).start()
app = WebhooksServer()
@app.add_webhook("/psyche_update")
async def on_source_change(payload: WebhookPayload):
repo = getattr(payload.repo, "name", None)
# Log every delivery, including the ones we ignore: silence here is
# otherwise indistinguishable from the webhook never arriving.
print(f"[webhook] received event for {repo}", flush=True)
if repo != SOURCE_REPO:
return {"skipped": f"not {SOURCE_REPO}: {repo}"}
scope = getattr(payload.event, "scope", "")
if not str(scope).startswith("repo"):
return {"skipped": f"scope {scope}"}
_wake.set()
print(f"[webhook] queued a build for {repo} ({scope})", flush=True)
return {"queued": True, "repo": repo, "scope": str(scope)}
if __name__ == "__main__":
print(
f"watching {SOURCE_REPO} -> job {JOB['name']} in {JOB['namespace']}",
flush=True,
)
# ssr_mode=False is required. Gradio 6 otherwise serves through a Node
# SSR proxy that forwards only the UI routes to Python, so the FastAPI
# webhook route is announced at startup but answers 405 to a real POST.
app.launch(ssr_mode=False)
|