"""Server-owned web runtime surviving browser reconnects. The PyBullet world remains the source of truth. This object persists only session/UI metadata, conversation state, task lifecycle, and a bounded event journal so a reloaded page can catch up without resetting the simulation. """ from __future__ import annotations import asyncio from collections import deque import threading import time import uuid from llm_panda.conversation import ConversationManager from llm_panda.execution.cancellation import CancellationToken class WebRuntime: def __init__(self, max_events: int = 750): self.runtime_id = uuid.uuid4().hex self.conversation_manager = ConversationManager( session_id=self.runtime_id ) self._lock = threading.RLock() self._subscribers = set() self._events = deque(maxlen=max_events) self._seq = 0 self._generation = 0 self._active = None self._pending = None self.execution_state = "idle" self.latest_plan = None self.latest_result = None self.latest_state = None self.latest_init = None self.current_step = None async def subscribe(self, websocket): with self._lock: self._subscribers.add(websocket) async def unsubscribe(self, websocket): with self._lock: self._subscribers.discard(websocket) def submit(self, prompt: str, use_mock: bool = False): with self._lock: self._generation += 1 command = { "task_id": uuid.uuid4().hex, "generation": self._generation, "prompt": prompt, "use_mock": bool(use_mock), "created_at": time.time(), } if self._active is not None: self._active["token"].request("superseded_by_new_command") self.execution_state = "interrupt_requested" else: self.execution_state = "planning" # Latest-wins for commands not yet started. self._pending = command return dict(command) def take_pending(self): with self._lock: command = self._pending self._pending = None if command is None: return None token = CancellationToken() command = {**command, "token": token} self._active = command self.execution_state = "planning" self.current_step = None self.latest_result = None return command def request_interrupt(self, reason="user_cancelled"): with self._lock: if self._active is None: return False self._active["token"].request(reason) self.execution_state = "interrupt_requested" return True def cancel_all(self, reason="user_cancelled"): """Cancel active work and discard commands that have not started.""" with self._lock: self._pending = None if self._active is None: self.execution_state = "idle" return False self._active["token"].request(reason) self.execution_state = "interrupt_requested" return True def set_status(self, state, generation=None, current_step=None): with self._lock: if ( generation is not None and self._active is not None and generation != self._active["generation"] ): return False self.execution_state = state self.current_step = current_step return True def complete(self, generation, state="completed"): with self._lock: if ( self._active is None or generation != self._active["generation"] ): return False self._active = None self.execution_state = ( "planning" if self._pending is not None else state ) self.current_step = None return True @property def active_token(self): with self._lock: return self._active["token"] if self._active else None @property def active_generation(self): with self._lock: return self._active["generation"] if self._active else None @property def has_pending(self): with self._lock: return self._pending is not None def _prepare_event(self, payload, journal=True): event = dict(payload) with self._lock: event_generation = event.get("generation") is_current = ( event_generation is None or event_generation >= self._generation ) if event.get("type") == "state": self.latest_state = event elif event.get("type") == "plan" and is_current: self.latest_plan = event elif event.get("type") == "result" and is_current: self.latest_result = event if journal: self._seq += 1 event["seq"] = self._seq self._events.append(event) return event async def publish(self, payload, journal=True): event = self._prepare_event(payload, journal=journal) with self._lock: subscribers = list(self._subscribers) stale = [] for websocket in subscribers: try: await websocket.send_json(event) except Exception: stale.append(websocket) if stale: with self._lock: for websocket in stale: self._subscribers.discard(websocket) return event def publish_threadsafe(self, payload, loop, journal=True): return asyncio.run_coroutine_threadsafe( self.publish(payload, journal=journal), loop, ) def events_after(self, seq): with self._lock: return [ dict(event) for event in self._events if event.get("seq", 0) > int(seq or 0) ] def clear_task_history(self): """Clear UI/task history after an explicit reset, preserving sequence.""" with self._lock: self._events.clear() self.latest_plan = None self.latest_result = None self.current_step = None self.execution_state = "idle" def snapshot(self): with self._lock: pending = self.conversation_manager.pending return { "type": "runtime_snapshot", "runtime_id": self.runtime_id, "execution_state": self.execution_state, "active_task_id": ( self._active["task_id"] if self._active else None ), "active_prompt": ( self._active["prompt"] if self._active else None ), "generation": ( self._active["generation"] if self._active else self._generation ), "current_step": self.current_step, "plan": self.latest_plan, "result": self.latest_result, "state": self.latest_state, "events": [dict(event) for event in self._events], "last_event_seq": self._seq, "conversation_pending": pending is not None, }