Download server/episodes.py from FineEnvs/DesignGym: direct link, hf CLI and curl.
- Browser
- Download file 13.1 kB
-
https://huggingface.co/spaces/FineEnvs/DesignGym/resolve/main/server/episodes.py
- Command line
-
hf download hf://spaces/FineEnvs/DesignGym/server/episodes.py
-
curl -L -o episodes.py https://huggingface.co/spaces/FineEnvs/DesignGym/resolve/main/server/episodes.py
13.1 kB
| """Episodes: one design being worked on, reachable from every surface at once. | |
| OpenEnv gives each WebSocket session its own environment object. The episode | |
| lives outside that object, in a process-wide store keyed by `episode_id`, so a | |
| browser tab can open `/editor?episode=<id>` and watch (or take over) the same | |
| document an agent is editing over MCP. Every change is broadcast to subscribed | |
| editors as a full document; documents are a few kilobytes, so there is no diff | |
| protocol to get wrong. | |
| State per episode is a document, its undo history, and a trajectory log. The | |
| log records every operation from every source in one format, which is what the | |
| SFT export and the rollout traces read. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import os | |
| import threading | |
| import time | |
| import uuid | |
| from dataclasses import dataclass, field | |
| from typing import Any | |
| try: | |
| from ..core import ops | |
| from ..core.doc import DesignDoc | |
| from ..core.html_profile import doc_to_html | |
| from ..core.pack import TaskPack | |
| from ..core.score import Grade | |
| except ImportError: # flat layout inside the Docker image | |
| from core import ops | |
| from core.doc import DesignDoc | |
| from core.html_profile import doc_to_html | |
| from core.pack import TaskPack | |
| from core.score import Grade | |
| MODES = ("reference", "description") | |
| SURFACES = ("tools", "html", "gui") | |
| # Editor layouts: id -> (display name, the kind of app it is modelled on). Neutral names and | |
| # brand marks only; the ids are what `reset(skin=...)` and the editor's `?skin=` accept. | |
| SKIN_INFO = { | |
| "figma": ("Studio", "Figma-like"), | |
| "canva": ("Template", "Canva-like"), | |
| "photoshop": ("Pixel", "Photoshop-like"), | |
| "slides": ("Deck", "Google Slides-like"), | |
| "powerpoint": ("Present", "PowerPoint-like"), | |
| "inkscape": ("Vector", "Inkscape-like"), | |
| } | |
| SKINS = tuple(SKIN_INFO) | |
| # Real apps behind the same GUI interface (design_canvas.realapps): never drawn at random, only by name. | |
| REAL_SKINS = {"penpot": ("Penpot", "the real Penpot, self-hosted"), | |
| "impress": ("LibreOffice Impress", "the real LibreOffice Impress, on a Linux desktop"), | |
| "inkscape-desktop": ("Inkscape", "the real Inkscape, on a Linux desktop"), | |
| "gimp": ("GIMP", "the real GIMP, on a Linux desktop")} | |
| HISTORY_LIMIT = 200 | |
| IDLE_TTL_S = 3 * 3600 | |
| class Listener: | |
| queue: asyncio.Queue | |
| loop: asyncio.AbstractEventLoop | |
| class Episode: | |
| pack: TaskPack | |
| task: dict | |
| mode: str = "reference" | |
| surface: str = "tools" | |
| skin: str = "figma" | |
| theme: str = "light" | |
| max_steps: int = 60 | |
| # behind the multi-worker router an id names its worker (w2-...), so requests for it reach that process | |
| episode_id: str = field(default_factory=lambda: (f"w{os.environ['DESIGN_CANVAS_WORKER_ID']}-" | |
| if os.environ.get("DESIGN_CANVAS_WORKER_ID") else "") | |
| + uuid.uuid4().hex[:12]) | |
| doc: DesignDoc | None = None | |
| step_count: int = 0 # actions; looking (screenshots, views, lists) is counted separately | |
| observation_count: int = 0 | |
| done: bool = False | |
| result: Grade | None = None | |
| created: float = field(default_factory=time.time) | |
| touched: float = field(default_factory=time.time) | |
| version: int = 0 | |
| log: list[dict] = field(default_factory=list) | |
| _undo: list[DesignDoc] = field(default_factory=list) | |
| _redo: list[DesignDoc] = field(default_factory=list) | |
| _listeners: list[Listener] = field(default_factory=list) | |
| _lock: threading.RLock = field(default_factory=threading.RLock) | |
| # (backend, session) when the editor is a real app: its own document is read back at submit | |
| realapp: Any = None | |
| def __post_init__(self): | |
| if self.mode not in MODES: | |
| raise ValueError(f"mode must be one of {MODES}") | |
| if self.surface not in SURFACES: | |
| raise ValueError(f"surface must be one of {SURFACES}") | |
| if self.skin not in SKINS and self.skin not in REAL_SKINS: | |
| raise ValueError(f"skin must be one of {SKINS + tuple(REAL_SKINS)}") | |
| gt = self.pack.gt_doc(self.task) | |
| if self.doc is None: | |
| self.doc = DesignDoc(width=gt.width, height=gt.height) | |
| self.ctx = ops.OpContext(self.pack.fonts, self.pack.asset_ctx(self.task)) | |
| self.assets = self.pack.asset_paths(self.task) | |
| # ------------------------------------------------------------ editing | |
| def _record(self, source: str, op: str, args: dict | None, ok: bool, error: str | None = None, | |
| result: Any = None): | |
| entry = {"t": round(time.time() - self.created, 3), "source": source, "op": op, | |
| "args": args or {}, "ok": ok} | |
| if error: | |
| entry["error"] = error | |
| if result is not None and op in ("add_image", "add_text", "add_shape", | |
| "duplicate_element"): | |
| entry["element_id"] = result.get("element_id") | |
| self.log.append(entry) | |
| def _check_open(self): | |
| if self.done: | |
| raise ops.OpError("this episode is finished; reset to start another") | |
| def apply(self, op: str, args: dict | None = None, source: str = "agent") -> dict: | |
| with self._lock: | |
| self._check_open() | |
| try: | |
| new, result = ops.apply(self.doc, op, args or {}, self.ctx) | |
| except ops.OpError as exc: | |
| self._record(source, op, args, False, str(exc)) | |
| raise | |
| self._undo.append(self.doc) | |
| if len(self._undo) > HISTORY_LIMIT: | |
| self._undo.pop(0) | |
| self._redo.clear() | |
| self.doc = new | |
| self._record(source, op, args, True, result=result) | |
| self._changed(source) | |
| return result | |
| def undo(self, source: str = "agent") -> bool: | |
| with self._lock: | |
| self._check_open() | |
| if not self._undo: | |
| self._record(source, "undo", None, False, "nothing to undo") | |
| return False | |
| self._redo.append(self.doc) | |
| self.doc = self._undo.pop() | |
| self._record(source, "undo", None, True) | |
| self._changed(source) | |
| return True | |
| def redo(self, source: str = "agent") -> bool: | |
| with self._lock: | |
| self._check_open() | |
| if not self._redo: | |
| self._record(source, "redo", None, False, "nothing to redo") | |
| return False | |
| self._undo.append(self.doc) | |
| self.doc = self._redo.pop() | |
| self._record(source, "redo", None, True) | |
| self._changed(source) | |
| return True | |
| def submit(self, source: str = "agent", reason: str = "submitted") -> Grade: | |
| with self._lock: | |
| if self.done and self.result is not None: | |
| return self.result | |
| if self.realapp is not None: # the app's own document is what gets scored | |
| backend, session = self.realapp | |
| self.doc = backend.read_doc(session) | |
| self.result = self.score() | |
| self.done = True | |
| self._record(source, "submit", {"reason": reason}, True) | |
| self._changed(source) | |
| return self.result | |
| def score(self) -> Grade: | |
| return self.pack.grade(self.task, self.doc, self.mode) | |
| def read_app(self) -> bool: | |
| """Bring a real app's document into `doc` as it stands now (what submit would score), for the playground | |
| and the editor API; the agent's tools never call this. False for the built-in editor.""" | |
| if self.realapp is None or self.done: | |
| return False | |
| with self._lock: | |
| backend, session = self.realapp | |
| self.doc = backend.read_doc(session) | |
| self._changed("app") | |
| return True | |
| # ------------------------------------------------------------ views | |
| def render_png(self, max_size: int = 768) -> bytes: | |
| return self.pack.renderer.render_png(self.doc, self.assets, max_size) | |
| def render_jpeg(self, max_size: int = 768) -> bytes: | |
| return self.pack.renderer.render_jpeg(self.doc, self.assets, max_size) | |
| def html(self) -> str: | |
| return doc_to_html(self.doc) | |
| def info(self) -> dict: | |
| """Task info a model is allowed to see.""" | |
| t = self.task | |
| info = { | |
| "episode_id": self.episode_id, | |
| "task_id": t["task_id"], | |
| "split": t["split"], | |
| "mode": self.mode, | |
| "surface": self.surface, | |
| "title": t["title"], | |
| "format": t["format"], | |
| "canvas": t["canvas"], | |
| "assets": [ | |
| {"id": a["id"], "kind": a["kind"], "aspect_ratio": a["aspect"]} | |
| for a in t["assets"] | |
| ], | |
| "fonts_used": t["fonts"], | |
| "max_steps": self.max_steps, | |
| } | |
| if self.mode == "description": | |
| info["description"] = t["description"] | |
| return info | |
| def editor_state(self, base: str = "") -> dict: | |
| """Everything the browser editor needs to draw this episode.""" | |
| t = self.task | |
| return { | |
| "type": "state", | |
| "episode_id": self.episode_id, | |
| "version": self.version, | |
| "doc": self.doc.model_dump(mode="json"), | |
| "task": { | |
| "task_id": t["task_id"], | |
| "title": t["title"], | |
| "format": t["format"], | |
| "canvas": t["canvas"], | |
| "mode": self.mode, | |
| "surface": self.surface, | |
| "description": t["description"] if self.mode == "description" else None, | |
| # Only after export (the result screen): the editor never shows the target. | |
| "reference_url": f"{base}/api/episodes/{self.episode_id}/reference.jpg" | |
| if self.mode == "reference" and self.done else None, | |
| "fonts_used": t["fonts"], | |
| }, | |
| "assets": [ | |
| {"id": a["id"], "kind": a["kind"], "aspect": a["aspect"], | |
| "url": f"{base}/api/episodes/{self.episode_id}/assets/{a['id']}"} | |
| for a in t["assets"] | |
| ], | |
| "can_undo": bool(self._undo), | |
| "can_redo": bool(self._redo), | |
| "done": self.done, | |
| "result": self.result.as_dict() if self.result else None, | |
| "step_count": self.step_count, | |
| "max_steps": self.max_steps, | |
| } | |
| # ------------------------------------------------------------ pub/sub | |
| def subscribe(self) -> Listener: | |
| listener = Listener(asyncio.Queue(maxsize=256), asyncio.get_running_loop()) | |
| with self._lock: | |
| self._listeners.append(listener) | |
| return listener | |
| def unsubscribe(self, listener: Listener) -> None: | |
| with self._lock: | |
| if listener in self._listeners: | |
| self._listeners.remove(listener) | |
| def _changed(self, source: str) -> None: | |
| self.version += 1 | |
| self.touched = time.time() | |
| payload = {"type": "changed", "version": self.version, "source": source} | |
| for listener in list(self._listeners): | |
| try: | |
| listener.loop.call_soon_threadsafe(_offer, listener.queue, payload) | |
| except RuntimeError: # loop closed: the tab went away | |
| self.unsubscribe(listener) | |
| def watchers(self) -> int: | |
| return len(self._listeners) | |
| def _offer(queue: asyncio.Queue, payload: dict) -> None: | |
| if queue.full(): | |
| try: | |
| queue.get_nowait() | |
| except asyncio.QueueEmpty: | |
| pass | |
| queue.put_nowait(payload) | |
| class EpisodeStore: | |
| def __init__(self, limit: int = 4000): | |
| self._items: dict[str, Episode] = {} | |
| self._lock = threading.Lock() | |
| self.limit = limit | |
| def add(self, ep: Episode) -> Episode: | |
| with self._lock: | |
| self._evict() | |
| self._items[ep.episode_id] = ep | |
| return ep | |
| def get(self, episode_id: str) -> Episode: | |
| with self._lock: | |
| ep = self._items.get(episode_id) | |
| if ep is None: | |
| raise KeyError(f"unknown or expired episode {episode_id!r}") | |
| return ep | |
| def maybe(self, episode_id: str | None) -> Episode | None: | |
| if not episode_id: | |
| return None | |
| with self._lock: | |
| return self._items.get(episode_id) | |
| def recent(self, n: int = 20) -> list[Episode]: | |
| with self._lock: | |
| items = sorted(self._items.values(), key=lambda e: e.touched, reverse=True) | |
| return items[:n] | |
| def _evict(self) -> None: | |
| now = time.time() | |
| stale = [k for k, e in self._items.items() | |
| if now - e.touched > IDLE_TTL_S and not e.watchers] | |
| for k in stale: | |
| del self._items[k] | |
| if len(self._items) >= self.limit: | |
| for k, _ in sorted(self._items.items(), key=lambda kv: kv[1].touched)[ | |
| : len(self._items) - self.limit + 1 | |
| ]: | |
| del self._items[k] | |
| STORE = EpisodeStore() | |