From 41099d62502b19b1541971e23f44727faba6a1dd Mon Sep 17 00:00:00 2001 From: Pierre Rouanet Date: Mon, 14 Sep 2026 14:29:08 +0200 Subject: [PATCH 1/4] policy-shop: a box for the policy the search does not reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gallery is `?search=microduck`, which is a convention rather than a rule. A policy on a branch, in a repo named something else, or published into a search index that has not caught up is invisible in the list and perfectly fetchable — so there is a box above the rows, and its button is the same four calls the row buttons make. What it takes is `robotctl policy add`'s own spelling — `org/name`, optionally `@revision`, optionally `:file.onnx` — because a second syntax for the same three fields is a second syntax to get wrong, and a Hub URL besides, because what is in somebody's clipboard is the page they were just reading. `blob/`, `resolve/`, `tree/` and the api JSON all resolve to the same three fields. `parse_spec` repeats `updater/src/policy.rs`'s rule about what an `org/name` is for one reason: the daemon's refusal is correct and arrives after a round trip to a robot, and a typo deserves an answer while the cursor is still in the box. A Space URL is named as one rather than left to 404, since it is the same shape as a model's and it is the mistake to expect. Nothing is read here first, which is the difference from a row: a row has had its manifest read and can be refused before anything downloads, a typed repo has not, and fetching one to pre-judge it would be this page second-guessing `policy.fetch` — which reads the same file on the robot and refuses on shape before downloading. The encoding refusal still lands, one step later, off the fetch answer. The four calls move into `put_on_the_duck`, taking a repo, a revision and a file rather than a catalogue row, and the line a run finishes on is now the spec — `org/name@v2:walk.onnx`, what was actually fetched — which pastes back into the box or into `robotctl policy add`. `uv run catalogue.py ` is the check: what each argument parses to and what the Hub has there, needing no robot, no token and no Space. Assisted-by: Claude:claude-opus-5 --- spaces/policy-shop/README.md | 34 +++++- spaces/policy-shop/app.py | 105 ++++++++++++++---- spaces/policy-shop/catalogue.py | 191 +++++++++++++++++++++++++++++++- 3 files changed, 307 insertions(+), 23 deletions(-) diff --git a/spaces/policy-shop/README.md b/spaces/policy-shop/README.md index e1899e7a..f41c6d6a 100644 --- a/spaces/policy-shop/README.md +++ b/spaces/policy-shop/README.md @@ -13,7 +13,7 @@ short_description: Pick a policy off the Hub, put it on your duck, run it. # microduck policy shop Everything published to the Hub as `microduck-…`, read the way the robot reads it, with a button -that downloads one onto your duck and runs it. +that downloads one onto your duck and runs it — and a box to name one the search did not reach. **Do not edit this Space directly.** The source is `spaces/policy-shop/` in `pollen-robotics/microduck`, and `scripts/publish-space.sh policy-shop` is what puts it here. @@ -37,6 +37,32 @@ pipe to the API the robot already serves. can say what a policy claims to be before anybody clicks it, but the skill that gets written comes from `policy.fetch`'s answer, which is about the bytes that were actually downloaded. +## Naming one yourself + +The gallery is `?search=microduck`, which is a convention rather than a rule. A policy on a branch, +in a repo named something else, or published into a search index that has not caught up is +invisible in the list and perfectly fetchable — so there is a box above the rows, and what it takes +is `robotctl policy add`'s own spelling: `org/name`, optionally `@branch-tag-or-commit`, optionally +`:file.onnx` for a repo carrying several. A pasted Hub address is the same three fields and is +accepted as it comes, `blob/` and `resolve/` URLs included, because what is in somebody's clipboard +is the page they were just reading. + +`catalogue.parse_spec` is the whole of that, and it repeats `updater/src/policy.rs`'s rule about +what an `org/name` is for one reason: the daemon's refusal is correct and arrives after a round +trip to a robot, and a typo deserves an answer while the cursor is still in the box. A Space URL +gets named as one rather than 404ing, since it is the same shape as a model's and it is the +mistake to expect. + +**Nothing is read here first, and that is the difference from a row.** A row has had its manifest +read, so the page can refuse a ground pick before anything is downloaded; a typed repo has not, and +fetching one to pre-judge it would be this page second-guessing `policy.fetch` — which reads the +same file on the robot, refuses on shape before downloading, and is the reading that the skill gets +written from either way. The encoding refusal still lands, one step later, off the fetch answer. + +The line a run finishes on is the spec rather than the row's key — `org/name@v2:walk.onnx`, what +was actually fetched, revision and all. It pastes back into the box, and into `robotctl policy add` +on the robot. + ## The picture The session carries the camera as well as the channel, so the newest frame is shown beside the @@ -162,6 +188,12 @@ uv run catalogue.py ``` prints the whole Hub catalogue with what each policy claims and which are refused. Needs no token. +Given arguments, it answers for the box instead — what each one parses to, and what the Hub has +there: + +```bash +uv run catalogue.py https://huggingface.co/RemiFabre/microduck-flamingo-cycle +``` ```bash uv run lan.py diff --git a/spaces/policy-shop/app.py b/spaces/policy-shop/app.py index 12e218b4..6a64595e 100644 --- a/spaces/policy-shop/app.py +++ b/spaces/policy-shop/app.py @@ -542,12 +542,11 @@ def already_note(result: Any) -> str: def install_and_run(index: int, hold: float) -> str: - """`policy.fetch`, `robot.setSkill`, `robot.policies`, `robot.do` — and stop at the first no. + """A row's button: the policy the gallery read, put on the duck. - Every refusal is worth showing verbatim. `policy.fetch` is the one that checks the claims that - matter — `obs_len`, `action_len`, `model_api`, `robot.model` — and it makes them *before* the - download, so "this policy is 51-D and this robot is 61-D" arrives in a second rather than - after 800 KB and a load failure. + The refusal is made here rather than left to the four calls because the gallery has already + read this one's manifest — a policy the page knows is a ground pick should not cost a + download to find that out. """ if index >= len(CATALOGUE): return "that row is stale — reload the catalogue." @@ -557,25 +556,59 @@ def install_and_run(index: int, hold: float) -> str: blocked = catalogue.refusal(policy) if blocked: return f"**not installed.** {blocked}" + return put_on_the_duck(policy.repo, None, policy.file, hold) + + +def install_typed(spec: str, hold: float) -> str: + """The box's button: whatever somebody named, put on the duck. + + **Nothing is read here first, and that is the difference from a row.** The gallery is + `?search=microduck` and this box exists for what that does not reach — a branch, a repo named + something else, a policy published ten minutes ago — so there is no manifest in hand and + fetching one to pre-judge it would be this page second-guessing `policy.fetch`, which reads + the same file on the robot and refuses on it before downloading anything. + """ + try: + repo, revision, file = catalogue.parse_spec(spec) + except ValueError as why: + return f"**that is not a policy to fetch.** {why}" + logger.info( + "typed: %r → repo=%s revision=%s file=%s (hold %s)", spec, repo, revision, file, hold + ) + return put_on_the_duck(repo, revision, file, hold) + + +def put_on_the_duck(repo: str, revision: str | None, file: str | None, hold: float) -> str: + """`policy.fetch`, `robot.setSkill`, `robot.policies`, `robot.do` — and stop at the first no. - params: dict[str, Any] = {"repo": policy.repo} - if policy.file: - params["file"] = policy.file + Every refusal is worth showing verbatim. `policy.fetch` is the one that checks the claims that + matter — `obs_len`, `action_len`, `model_api`, `robot.model` — and it makes them *before* the + download, so "this policy is 51-D and this robot is 61-D" arrives in a second rather than + after 800 KB and a load failure. + """ + params: dict[str, Any] = {"repo": repo} + if revision: + params["revision"] = revision + if file: + params["file"] = file try: fetched = LINK.call("policy.fetch", params, timeout=FETCH_TIMEOUT) or {} except RpcError as e: return f"**`policy.fetch` refused it.** {e.message}" - # The robot's own reading of the manifest wins over this Space's: it downloaded the file and - # parsed the manifest beside it, so its answer is about the bytes that are going to run. + # Everything below is the robot's own reading of the manifest rather than this Space's: it + # downloaded the file and parsed the manifest beside it, so its answer is about the bytes that + # are going to run. For a typed policy it is the *only* reading there has been. + spec = catalogue.spec_of(fetched) + name = fetched.get("name") or spec if not fetched.get("duration_s") and not hold: return ( - f"**{policy.name} holds until it is told otherwise**, so it has no length of its own. " + f"**{name} holds until it is told otherwise**, so it has no length of its own. " "Say how many seconds to hold it, beside the button." ) late_refusal = catalogue.refusal( - Policy(repo=policy.repo, name=policy.name, encoding=fetched.get("encoding")) + Policy(repo=repo, name=name, encoding=fetched.get("encoding")) ) if late_refusal: return f"**downloaded, and not installed.** {late_refusal}" @@ -603,23 +636,25 @@ def install_and_run(index: int, hold: float) -> str: if after.get("change_error"): return f"**added, and the robot could not re-read it:** {after['change_error']}" - name = skill["name"] + skill_name = skill["name"] try: - ran = LINK.call("robot.do", {"skill": name}) + ran = LINK.call("robot.do", {"skill": skill_name}) except RpcError as e: return ( - f"**`{name}` is installed** ({skill['duration']:g}s) **and it would not run:** " + f"**`{skill_name}` is installed** ({skill['duration']:g}s) **and it would not run:** " f"{e.message}" ) said_no = not_accepted(ran) if said_no: return ( - f"**`{name}` is installed** ({skill['duration']:g}s) **and the robot would not run " - f"it:** {said_no}" + f"**`{skill_name}` is installed** ({skill['duration']:g}s) **and the robot would not " + f"run it:** {said_no}" ) + # The spec rather than the row's key: it is what was actually fetched, revision and all, and + # it is a line somebody can paste back into the box or into `robotctl policy add`. return ( - f"**`{name}` installed and running** — {skill['duration']:g}s from " - f"`{policy.key}`{already_note(ran)}{unconfirmed}" + f"**`{skill_name}` installed and running** — {skill['duration']:g}s from " + f"`{spec}`{already_note(ran)}{unconfirmed}" ) @@ -867,6 +902,32 @@ def open_session(peer_id: str | None, oauth: gr.OAuthToken | None) -> str: scale=2, ) reload_catalogue = gr.Button("reload the catalogue") + + # **The box, above the list rather than under it.** The gallery is `?search=microduck`, which + # is a convention rather than a rule, and the policy somebody actually wants to try is often + # the one that convention has not reached yet — a branch, a fork, a repo named something else, + # a file published ten minutes ago. Forty rows below this would be forty rows to scroll past + # to find it. + gr.Markdown( + "**Somewhere else on the Hub?** Name it and it is the same four calls. " + "`robotctl policy add`'s own spelling — `org/name`, optionally `@branch-tag-or-commit`, " + "optionally `:file.onnx` for a repo carrying several — or paste the address of the " + "repo's page, a `blob/` or a `resolve/` URL included.\n\n" + "**Its manifest is read on the robot and nowhere else.** Unlike a row, nothing here " + "knows what this policy claims to be, so the answer comes back from `policy.fetch` — " + "which reads the manifest beside the file and refuses on shape before downloading " + "anything. A public repo: the robot fetches these signed in as nobody, exactly as this " + "page reads the Hub." + ) + with gr.Row(): + typed = gr.Textbox( + value="", + placeholder="RemiFabre/microduck-flamingo-cycle", + label="a policy repo, or the URL of its page on the Hub", + scale=3, + ) + put_typed = gr.Button("put it on the duck and run it", scale=0) + catalogue_note = gr.Markdown("Loading…") rows: list[Any] = [] @@ -901,6 +962,12 @@ def open_session(peer_id: str | None, oauth: gr.OAuthToken | None) -> str: stop.click(lambda: plain("robot.stop"), outputs=status) relax.click(lambda: plain("robot.relax"), outputs=status) run.click(run_installed, inputs=installed, outputs=status) + # The button and the return key both, because a box you typed a repo into is a box you press + # return in. + for press in (put_typed.click, typed.submit): + press(install_typed, inputs=[typed, hold], outputs=status).then( + lambda: robot_state()[0], outputs=robot_panel + ) reload_catalogue.click( lambda: load_catalogue(force=True), outputs=[catalogue_note, *rows] ) diff --git a/spaces/policy-shop/catalogue.py b/spaces/policy-shop/catalogue.py index 2f0f604d..c46d55ee 100644 --- a/spaces/policy-shop/catalogue.py +++ b/spaces/policy-shop/catalogue.py @@ -18,7 +18,8 @@ `policies`. `policy.fetch` takes a `file`, so an entry out of the set is one click like any other. Runnable on its own — `uv run catalogue.py` prints what a duck would be offered — which is how -this is checked without a robot, a token or a Space. +this is checked without a robot, a token or a Space. Given arguments it answers for the box +instead: `uv run catalogue.py ` says what that parses to and what the Hub has there. """ from __future__ import annotations @@ -95,6 +96,145 @@ def headline(self) -> str: return " · ".join(bits) +# ── naming one the list does not have ──────────────────────────────────────── +# +# The gallery is `?search=microduck`, which is a convention and not a rule: a policy on a branch, +# in a repo named something else, or published an hour ago into a search index that has not caught +# up is invisible here and perfectly fetchable. So there is a box to type into, and this is what it +# accepts. + +# Hosts a Hub URL can arrive under. What is in somebody's clipboard is the address bar of the page +# they were just reading, and asking them to retype it as `org/name` is asking them to do a +# machine's job. +HUB_HOSTS = ("huggingface.co", "www.huggingface.co", "hf.co") +# The path shapes a repo's own pages use: `/org/name/blob/main/policy.onnx` is what the file +# viewer's address bar says, `resolve` is the download link behind it, `tree` is a directory. +REVISIONED = ("blob", "resolve", "tree", "raw") + + +def parse_spec(text: str) -> tuple[str, str | None, str | None]: + """A repo, a revision and a file out of whatever somebody typed or pasted. + + **`robotctl policy add`'s spelling, exactly**: `org/name`, optionally `@revision`, optionally + `:file.onnx`. Somebody who has one of those in their notes or in a README should not have to + translate it to use this page, and a second syntax for the same three fields is a second + syntax to get wrong. + + **And a Hub URL besides**, because that is what a person actually has to hand. Every shape the + Hub's own pages produce resolves to the same three fields — the repo page, a `tree`, and the + `blob`/`resolve` of a file, which carries the revision and the file both. + + Raises `ValueError` carrying the sentence to show. The repo rule is `updater/src/policy.rs`'s + own, repeated here rather than relied on: the daemon's refusal is correct and arrives after a + round trip to a robot, and a typo deserves an answer while the cursor is still in the box. + """ + typed = (text or "").strip() + if not typed: + raise ValueError("nothing typed.") + + # A scheme or a hostname means a URL, whatever host it names — routing `example.com/org/name` + # through the `org/name` parser instead would answer a pasted address with a complaint about + # the word `https`, which is the least useful true thing that could be said about it. + if "://" in typed or typed.split("/")[0].lower() in HUB_HOSTS: + repo, revision, file = _from_url(typed) + else: + repo, revision, file = _from_spec(typed) + + org, _, name = repo.partition("/") + if not org or not name or "/" in name: + raise ValueError( + f"`{repo}` is not an `org/name` repo — that is the whole of what the Hub calls one." + ) + for part in (org, name): + # `updater/src/policy.rs`'s rule, character for character: neither half may hold a dot or + # a backslash, because both halves become a path under the policy library. It costs the + # Hub's dotted model names — `org/Phi-3.5-mini` is unfetchable by a duck — and a page that + # accepted one would be a page that sends a call guaranteed to come back refused. + if any(character in part for character in "./\\"): + raise ValueError( + f"`{repo}` is not an `org/name` repo — a dot or a slash in either half is what " + "the robot refuses, since both halves become a directory on it." + ) + # The daemon's rule again: a revision becomes the last directory under `//`. + if revision is not None and (revision.startswith(".") or any(c in revision for c in "/\\")): + raise ValueError( + f"`{revision}` is not a branch, a tag or a commit — it becomes a directory on the " + "robot, so it carries no slash." + ) + if file is not None: + if "/" in file: + raise ValueError( + f"`{file}` is not at the top of the repo, and the robot installs only files that " + "are — `docs/policy-manifest.md` is why." + ) + if not file.endswith(".onnx"): + raise ValueError( + f"`{file}` is not a policy. The file is the `.onnx` — leave it off entirely and " + "the robot takes the only one in the repo, which is what these repos have." + ) + return repo, revision, file + + +def _from_spec(typed: str) -> tuple[str, str | None, str | None]: + """`org/name[@revision][:file]`, split the way `robotctl` splits it.""" + repo, _, file = typed.partition(":") + repo, _, revision = repo.partition("@") + return repo.strip("/"), revision or None, file or None + + +def _from_url(typed: str) -> tuple[str, str | None, str | None]: + """The three fields out of a Hub address, whichever of its pages it came from.""" + address = typed.split("//")[-1].split("?")[0].split("#")[0] + host, _, path = address.partition("/") + if host.lower() not in HUB_HOSTS: + raise ValueError(f"`{host}` is not the Hub. A policy lives at `huggingface.co/org/name`.") + + parts = [part for part in path.split("/") if part] + # `/api/models/org/name` is the JSON behind the page, and somebody debugging has it open. + if parts[:2] == ["api", "models"]: + parts = parts[2:] + if parts[:1] == ["models"]: + parts = parts[1:] + # A Space and a dataset are not policies, and their URLs are the same shape as a model's — so + # this is the one mistake worth naming rather than letting the Hub answer it with a 404. + if parts and parts[0] in ("spaces", "datasets"): + what = parts[0].rstrip("s") + raise ValueError( + f"that is a {what}, not a model repo. A policy is the `.onnx` and the " + "`manifest.json` beside it, published as a model — `huggingface.co/org/name`." + ) + if len(parts) < 2: + raise ValueError(f"`{typed}` names no repo. A policy lives at `huggingface.co/org/name`.") + + repo = f"{parts[0]}/{parts[1]}" + rest = parts[2:] + if rest and rest[0] in REVISIONED: + revision = rest[1] if len(rest) > 1 else None + return repo, revision, "/".join(rest[2:]) or None + if rest: + raise ValueError( + f"`{'/'.join(rest)}` is not part of a repo's address — the repo itself is " + f"`huggingface.co/{repo}`, and a file in it is under `blob/` or `resolve/`." + ) + return repo, None, None + + +def spec_of(fetched: dict[str, Any]) -> str: + """What the robot fetched, written the way this page would take it back. + + The answer to "what did that button actually install" has to be something a person can act on + — paste into the box to run it again, or into `robotctl policy add` on the robot. `main` is + left off because it is what a bare repo means anyway. + """ + spec = fetched.get("repo") or "?" + revision = fetched.get("revision") + if revision and revision != "main": + spec += f"@{revision}" + if fetched.get("file"): + spec += f":{fetched['file']}" + return spec + + def refusal(policy: Policy) -> str | None: """Why this cannot be a one-shot skill, or `None`. @@ -214,13 +354,13 @@ def _integer(value: Any) -> int | None: return int(value) if isinstance(value, int) and not isinstance(value, bool) else None -def _manifest(repo: str) -> dict[str, Any] | None: +def _manifest(repo: str, revision: str = "main") -> dict[str, Any] | None: """A repo's `manifest.json`, or `None` if it has none. No token: these are public repos, and a Space that reads them signed in as whoever is looking would show a different catalogue to each visitor for no reason. """ - url = f"https://huggingface.co/{repo}/resolve/main/manifest.json" + url = f"https://huggingface.co/{repo}/resolve/{revision}/manifest.json" try: answer = requests.get(url, timeout=TIMEOUT) except requests.RequestException: @@ -339,7 +479,52 @@ def skill_for(fetched: dict[str, Any], hold: float | None) -> dict[str, Any]: return params +def _check(typed: str) -> None: + """What the box would make of one line, and what the Hub says about the result.""" + try: + repo, revision, file = parse_spec(typed) + except ValueError as why: + print(f"{typed}\n refused: {why}") + return + print( + f"{typed}\n repo={repo} revision={revision or 'main'} " + f"file={file or '(the only .onnx in it)'}" + ) + manifest = _manifest(repo, revision or "main") + if manifest is None: + print(" no manifest.json there — the robot will fetch it anyway and the shape gate at") + print(" load is what decides, but nothing can be said about it first.") + return + + # A set is a repo with several `.onnx` in it, and `policy.fetch` refuses one without a `file` + # rather than guessing which network to run. Saying so here beats learning it from the robot. + entries = manifest.get("policies") + if isinstance(entries, list) and entries: + entry = next((e for e in entries if e.get("file") == file), None) if file else None + if entry is None: + names = ", ".join(str(e.get("file")) for e in entries if e.get("file")) + print(f" a set of {len(entries)} — name one with `:file.onnx`: {names}") + return + manifest = _merge(manifest, entry) + one = _policy_from(manifest, repo, file) + print(f" {one.name} — {one.headline()}") + if one.description: + print(f" {one.description}") + if refusal(one): + print(f" REFUSED: {refusal(one)}") + + if __name__ == "__main__": + import sys + + # `uv run catalogue.py ` answers, without a robot, a token + # or a Space, the two questions a typed policy raises: does this parse into a repo, and does + # the Hub have anything there. Asked nothing, it prints the whole catalogue as before. + if len(sys.argv) > 1: + for argument in sys.argv[1:]: + _check(argument) + raise SystemExit(0) + found, why = read_hub() if why: print(why) From 6468f6244ab2b736c978585255e1eab0402d839b Mon Sep 17 00:00:00 2001 From: Pierre Rouanet Date: Mon, 14 Sep 2026 14:45:05 +0200 Subject: [PATCH 2/4] spaces: the policy Space is a playground, not a shop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Space it publishes to is `pollen-robotics/microduck-policy-playground`, and nothing here is sold: the page is where you put a stranger's policy on your duck to find out whether it is any good, which is what `policy-channel-design.md` §1 asked the channel for in the first place. `publish-space.sh` derives the Space id from the directory name, so a directory called `policy-shop` would need `--space` passed by hand at every publish, and the README's own "this is what puts it here" line would be wrong. The name is in five other places besides the directory — the Space card's title, the page title, the consumer label the rendezvous sees, `vision-demo`'s note about sharing the log handler, and `remote-access-design.md` §5.2 — and all of them move together. The three symlinks into `spaces/shared/` are relative and at the same depth, so they survive the rename untouched. Assisted-by: Claude:claude-opus-5 --- docs/design/remote-access-design.md | 2 +- spaces/{policy-shop => policy-playground}/README.md | 8 ++++---- spaces/{policy-shop => policy-playground}/app.py | 6 +++--- spaces/{policy-shop => policy-playground}/catalogue.py | 0 spaces/{policy-shop => policy-playground}/control.py | 0 spaces/{policy-shop => policy-playground}/lan.py | 0 spaces/{policy-shop => policy-playground}/rendezvous.py | 0 .../{policy-shop => policy-playground}/requirements.txt | 0 spaces/{policy-shop => policy-playground}/wire.py | 0 spaces/shared/wire.py | 6 ++++-- spaces/vision-demo/app.py | 2 +- 11 files changed, 13 insertions(+), 11 deletions(-) rename spaces/{policy-shop => policy-playground}/README.md (99%) rename spaces/{policy-shop => policy-playground}/app.py (99%) rename spaces/{policy-shop => policy-playground}/catalogue.py (100%) rename spaces/{policy-shop => policy-playground}/control.py (100%) rename spaces/{policy-shop => policy-playground}/lan.py (100%) rename spaces/{policy-shop => policy-playground}/rendezvous.py (100%) rename spaces/{policy-shop => policy-playground}/requirements.txt (100%) rename spaces/{policy-shop => policy-playground}/wire.py (100%) diff --git a/docs/design/remote-access-design.md b/docs/design/remote-access-design.md index ab78dfed..87c9d5fb 100644 --- a/docs/design/remote-access-design.md +++ b/docs/design/remote-access-design.md @@ -696,7 +696,7 @@ and it is a two-line change on their side — `kind` is already on the wire. ### 5.2 A consumer that drives a duck, and the one line of theirs it has to get past -`spaces/policy-shop` is the second consumer in this repository and the first that *sends* +`spaces/policy-playground` is the second consumer in this repository and the first that *sends* anything: sign in, list the account's ducks, and put a policy from the Hub onto one in a click — `policy.fetch`, `robot.setSkill`, `robot.policies`, `robot.do`, which is `robotctl policy add`'s own order over a datachannel instead of over a unix socket. diff --git a/spaces/policy-shop/README.md b/spaces/policy-playground/README.md similarity index 99% rename from spaces/policy-shop/README.md rename to spaces/policy-playground/README.md index f41c6d6a..fe0d9383 100644 --- a/spaces/policy-shop/README.md +++ b/spaces/policy-playground/README.md @@ -1,5 +1,5 @@ --- -title: microduck policy shop +title: microduck policy playground emoji: 🦆 colorFrom: yellow colorTo: pink @@ -10,13 +10,13 @@ hf_oauth: true short_description: Pick a policy off the Hub, put it on your duck, run it. --- -# microduck policy shop +# microduck policy playground Everything published to the Hub as `microduck-…`, read the way the robot reads it, with a button that downloads one onto your duck and runs it — and a box to name one the search did not reach. -**Do not edit this Space directly.** The source is `spaces/policy-shop/` in -`pollen-robotics/microduck`, and `scripts/publish-space.sh policy-shop` is what puts it here. +**Do not edit this Space directly.** The source is `spaces/policy-playground/` in +`pollen-robotics/microduck`, and `scripts/publish-space.sh policy-playground` is what puts it here. ## The one click diff --git a/spaces/policy-shop/app.py b/spaces/policy-playground/app.py similarity index 99% rename from spaces/policy-shop/app.py rename to spaces/policy-playground/app.py index 6a64595e..6eea348e 100644 --- a/spaces/policy-shop/app.py +++ b/spaces/policy-playground/app.py @@ -98,7 +98,7 @@ def emit(self, record: logging.LogRecord) -> None: for chatty in ("aioice", "aiortc", "aiohttp", "httpx", "urllib3"): logging.getLogger(chatty).setLevel(logging.DEBUG if LEVEL == "DEBUG" else logging.WARNING) -logger = logging.getLogger("shop") +logger = logging.getLogger("playground") # How long to hold a policy that declares no length of its own. Perpetual means "until told # otherwise", so something has to choose, and `robotctl policy add` refuses rather than guessing — @@ -208,7 +208,7 @@ def through_rendezvous(self, token: str, peer_id: str, label: str) -> str: # match would be the place a mini could be handed to a duck's client. peer_id, rpc, - label=f"microduck-policy-shop/{os.environ.get('SPACE_ID', 'local')}", + label=f"microduck-policy-playground/{os.environ.get('SPACE_ID', 'local')}", ), ) @@ -805,7 +805,7 @@ def open_session(peer_id: str | None, oauth: gr.OAuthToken | None) -> str: return LINK.through_rendezvous(token, peer_id, NAMES.get(peer_id, peer_id)) -with gr.Blocks(title="microduck policy shop") as demo: +with gr.Blocks(title="microduck policy playground") as demo: gr.Markdown( """ # Put a policy on your duck diff --git a/spaces/policy-shop/catalogue.py b/spaces/policy-playground/catalogue.py similarity index 100% rename from spaces/policy-shop/catalogue.py rename to spaces/policy-playground/catalogue.py diff --git a/spaces/policy-shop/control.py b/spaces/policy-playground/control.py similarity index 100% rename from spaces/policy-shop/control.py rename to spaces/policy-playground/control.py diff --git a/spaces/policy-shop/lan.py b/spaces/policy-playground/lan.py similarity index 100% rename from spaces/policy-shop/lan.py rename to spaces/policy-playground/lan.py diff --git a/spaces/policy-shop/rendezvous.py b/spaces/policy-playground/rendezvous.py similarity index 100% rename from spaces/policy-shop/rendezvous.py rename to spaces/policy-playground/rendezvous.py diff --git a/spaces/policy-shop/requirements.txt b/spaces/policy-playground/requirements.txt similarity index 100% rename from spaces/policy-shop/requirements.txt rename to spaces/policy-playground/requirements.txt diff --git a/spaces/policy-shop/wire.py b/spaces/policy-playground/wire.py similarity index 100% rename from spaces/policy-shop/wire.py rename to spaces/policy-playground/wire.py diff --git a/spaces/shared/wire.py b/spaces/shared/wire.py index 398e9898..92440470 100644 --- a/spaces/shared/wire.py +++ b/spaces/shared/wire.py @@ -72,7 +72,7 @@ class WsConsumer: """ def __init__(self, token: str, peer_id: str, rpc: Any, base: str = DEFAULT_CENTRAL_URL, - label: str = "microduck-policy-shop"): + label: str = "microduck-policy-playground"): self._token = token self._peer_id = peer_id self._rpc = rpc @@ -378,7 +378,9 @@ def connect(token: str, peer_id: str, rpc: Any, label: str) -> WsConsumer: # Shorter than the page's, because a person is watching this one. rpc = Rpc(timeout=15) - consumer = WsConsumer(credential, duck.peer_id, rpc, label="microduck-policy-shop/wire-check") + consumer = WsConsumer( + credential, duck.peer_id, rpc, label="microduck-policy-playground/wire-check" + ) try: consumer.start() except WireError as e: diff --git a/spaces/vision-demo/app.py b/spaces/vision-demo/app.py index dc4f65b8..b3a2e21a 100644 --- a/spaces/vision-demo/app.py +++ b/spaces/vision-demo/app.py @@ -83,7 +83,7 @@ class Ring(logging.Handler): **A Space's logs are on a page only its owner can open**, and reading them needs write access to the Space — so "click the button and tell me what it said" was a round trip through somebody's screenshot. The panel shows what the container's stderr shows, to whoever is - already looking at the thing that failed. `policy-shop` has the same handler for the same + already looking at the thing that failed. `policy-playground` has the same handler for the same reason. """ From 2e8f1cc417eeb9e1459b4ba6997e98a1c027d782 Mon Sep 17 00:00:00 2001 From: Pierre Rouanet Date: Mon, 14 Sep 2026 15:09:38 +0200 Subject: [PATCH 3/4] policy-playground: drop the robot runtime, keep the cipher patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Space would not build. The error was a page of `reachy-mini X does not provide the extra 'central-consumer'` for every version from 1.8.4 down, which is pip backtracking after the newest one — the one that does provide it — failed to install, and names the wrong thing entirely. On Linux `reachy_mini` pulls `PyGObject`, which compiles against `gobject-introspection` headers a Gradio Space's builder does not carry. On a laptop the same line resolves to a `gstreamer-bundle` wheel and installs in seconds, which is why this was only ever going to be found in their builder. Installing the build deps would have worked and would still have been wrong. The whole contribution of `reachy_mini[central-consumer]` here was `aiortc`, `av` and one DTLS cipher patch; taking it meant a motor controller, `rustypot`, `libusb`, ONNX Runtime and zeroconf inside a web page that drives a robot over HTTP. So the libraries the page actually uses are named — including `aiohttp`, `numpy` and `huggingface_hub`, which were arriving transitively, which is not the same as being dependencies — and `lan.py` carries the cipher patch itself. That patch is theirs, copied, with the upstream aiortc PR named and the flag it sets kept at their attribute name: whichever copy runs first, the other sees it and does nothing. Without it aiortc and `webrtcsink` share no cipher, DTLS never completes, and the peer connection hangs in `connecting` — indistinguishable from the NAT failure the other tab exists to work around, which is the worst shape a bug here can take. `uv run --isolated lan.py` drives the whole session on loopback with no `reachy_mini` installed: DTLS, SCTP, the control channel, a call matched to its reply, a refusal, and frames decoded to RGB. Assisted-by: Claude:claude-opus-5 --- spaces/policy-playground/lan.py | 53 ++++++++++++++++++++--- spaces/policy-playground/requirements.txt | 40 ++++++++++++----- 2 files changed, 74 insertions(+), 19 deletions(-) diff --git a/spaces/policy-playground/lan.py b/spaces/policy-playground/lan.py index b207ce6c..4306fe6d 100644 --- a/spaces/policy-playground/lan.py +++ b/spaces/policy-playground/lan.py @@ -39,17 +39,56 @@ from aiortc.mediastreams import MediaStreamError from aiortc.sdp import candidate_from_sdp -# **Their DTLS shim, called out loud.** aiortc's default cipher list shares no cipher with the -# GStreamer `webrtcsink` a duck runs, so DTLS never completes and the peer connection never -# reaches `connected` — a failure that looks exactly like a NAT problem and is not one. Their -# module applies it at import; calling it here means this file does not depend on somebody else's -# import having happened first, and it is idempotent by design. -from reachy_mini.media.central_consumer import _patch_aiortc_dtls_ciphers - from control import CONTROL_LABEL, Rpc logger = logging.getLogger(__name__) + +def _patch_aiortc_dtls_ciphers() -> None: + """Let aiortc agree a cipher with the GStreamer `webrtcsink` a duck runs. + + **Without this, DTLS never completes and the peer connection never reaches `connected`** — a + failure that looks exactly like a NAT problem and is not one, which is the worst kind of bug + this page can have, since a NAT problem is what the *from anywhere* tab exists to work around. + aiortc's default cipher list and `webrtcsink`'s share nothing; adding + `ECDHE-RSA-AES128-GCM-SHA256` is the whole fix. Upstream is aiortc PR #1392, and the day a + released aiortc negotiates one of these by default this function is a deletion. + + **This is `reachy_mini.media.central_consumer._patch_aiortc_dtls_ciphers`, copied.** It was + imported until the Space would not build: on Linux `reachy_mini` pulls `PyGObject`, which + compiles against headers a Gradio builder has none of, and installing them would have put a + motor controller and ONNX Runtime inside a web page to get thirty lines of cipher list. A copy + of a shim that is itself a monkeypatch of somebody's private method, with the upstream PR + named, is the smaller debt — and `requirements.txt` says so at the point of the decision. + + The flag it sets is *their* attribute name, deliberately: whichever of the two runs first, the + other sees it and does nothing, so a process that has both installed patches once. + """ + from aiortc.rtcdtlstransport import RTCCertificate + + if getattr(RTCCertificate, "_reachy_cipher_patched", False): + return + original = RTCCertificate._create_ssl_context + ciphers = ( + b"ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:" + b"ECDHE-ECDSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA:" + b"ECDHE-RSA-AES128-GCM-SHA256" + ) + + def patched(self: Any, srtp_profiles: Any) -> Any: + context = original(self, srtp_profiles) + try: + context.set_cipher_list(ciphers) + except Exception as e: # noqa: BLE001 - a cipher list that will not set is a DTLS that + # fails later with a better message than this one would be + logger.warning("dtls: set_cipher_list refused the list: %r", e) + return context + + RTCCertificate._create_ssl_context = patched + RTCCertificate._reachy_cipher_patched = True + logger.info("dtls: cipher list extended (+ECDHE-RSA-AES128-GCM-SHA256)") + + _patch_aiortc_dtls_ciphers() # What `mediad --port` defaults to, which is `webrtcsink`'s own signaller's default. diff --git a/spaces/policy-playground/requirements.txt b/spaces/policy-playground/requirements.txt index 589dae94..28d14891 100644 --- a/spaces/policy-playground/requirements.txt +++ b/spaces/policy-playground/requirements.txt @@ -1,15 +1,24 @@ -# The consumer, and the WebRTC stack under it. The `central-consumer` extra is what pulls -# `aiortc` and `av`: a robot never runs the consumer, so the base install leaves them out. +# **`reachy_mini` is deliberately not here, and it used to be.** The page asked for +# `reachy_mini[central-consumer]`, whose whole contribution was `aiortc`, `av` and one DTLS cipher +# patch — and on Linux the package pulls `PyGObject`, which builds from source against +# `gobject-introspection` headers a Gradio Space's builder does not carry. The Space failed to +# build with a page of `reachy-mini X does not provide the extra 'central-consumer'`, which is pip +# backtracking through every older version after the newest one would not install, and names the +# wrong thing entirely. # -# **Unpinned deliberately, for now**, for the reason `vision-demo` gives: the drift that matters -# here is between the consumer and a duck, and pinning a version whose DTLS shim we depend on -# before knowing which versions work would pin the wrong thing. The first time this breaks, pin -# it here with the version that worked. -# -# `control.py` subclasses `ReachyCentralConsumer` and overrides one private method, so this is -# the dependency most likely to break silently across a version — the shim's docstring says what -# the failure looks like and what makes it stop being needed. -reachy_mini[central-consumer] +# Installing the build deps would have worked and would still have been wrong: it would put a +# robot runtime — a motor controller, `rustypot`, `libusb`, ONNX Runtime, zeroconf — inside a web +# page that drives a robot over HTTP. So the two libraries that page actually uses are named here, +# and `lan.py` carries the cipher patch itself. + +# WebRTC, for the *on this network* tab. `av` comes with `aiortc` and is named anyway, because +# `lan.py`'s own check builds frames with it. +aiortc>=1.9 +av>=12 + +# The signalling client for that tab. It was arriving transitively before, which is not the same +# as being a dependency. +aiohttp # **`[oauth]`, not bare `gradio`.** `gr.LoginButton` needs `itsdangerous` and `authlib`, which # only the extra pulls in, and without them a Space with `hf_oauth: true` fails at *import* — @@ -17,7 +26,14 @@ reachy_mini[central-consumer] # about the page says which one is missing, so it is worth the four characters. gradio[oauth]>=5 +# Frames arrive as arrays and the page rotates them. +numpy + # Reading the Hub: `huggingface.co/api/models?search=microduck`, and a `manifest.json` per hit. # The same two requests `updater/src/policy.rs` makes, so the gallery and `policy.search` cannot -# disagree about what exists. +# disagree about what exists. `requests` is also the whole of the rendezvous transport. requests + +# `get_token`, for a local run: the sign-in button is mocked off a Space, so the token comes from +# `HF_TOKEN` or from whatever `hf auth login` stored. +huggingface_hub From f1bbd8da6ecafda8761bd34204af807020e3a224 Mon Sep 17 00:00:00 2001 From: Pierre Rouanet Date: Mon, 14 Sep 2026 15:13:03 +0200 Subject: [PATCH 4/4] policy-playground: no server-side rendering, so the Space stays up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build passed and the app did not. Ten log lines: gradio bound 7860 "with SSR (Node proxy -> Python :7861)", then `Stopping Node.js server...` ten seconds later, then nothing — no traceback, no port, `RUNTIME_ERROR`. SSR is a second process in front of the first, and what it buys is SEO for a page that sits behind a Hugging Face sign-in. Ruling it out at `launch` costs nothing and removes the whole of that surface. `vision-demo` rules it out too, with `GRADIO_SSR_MODE=false` in its Dockerfile, for the different reason that a `-slim` image has no Node for gradio to find. Assisted-by: Claude:claude-opus-5 --- spaces/policy-playground/app.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/spaces/policy-playground/app.py b/spaces/policy-playground/app.py index 6eea348e..0bc1a07d 100644 --- a/spaces/policy-playground/app.py +++ b/spaces/policy-playground/app.py @@ -994,4 +994,14 @@ def open_session(peer_id: str | None, oauth: gr.OAuthToken | None) -> str: if __name__ == "__main__": - demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", 7860))) + # **`ssr_mode=False`, and the Space would not stay up without it.** Gradio's server-side + # rendering puts a Node proxy on 7860 in front of Python on 7861, and on this Space it + # started, served nothing, and stopped ten seconds later — `Stopping Node.js server...` and no + # traceback, which is a failure with nowhere to look. SSR buys SEO for a page that sits behind + # a sign-in, so the second process is all cost; `vision-demo`'s Dockerfile rules it out with + # `GRADIO_SSR_MODE=false` for the different reason that its image has no Node at all. + demo.launch( + server_name="0.0.0.0", + server_port=int(os.environ.get("PORT", 7860)), + ssr_mode=False, + )