File size: 13,141 Bytes
df84e45 | 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 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 | """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
@dataclass
class Listener:
queue: asyncio.Queue
loop: asyncio.AbstractEventLoop
@dataclass
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)
@property
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()
|