From 0ca09272283631dd03b195ebb80fc7a26b46b291 Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 18 Mar 2026 22:12:15 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20GET=20/api/world/state=20=E2=80=94=20Wo?= =?UTF-8?q?rkshop=20bootstrap=20endpoint=20(#373)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dashboard/app.py | 2 + src/dashboard/routes/world.py | 93 +++++++++++++++++ tests/dashboard/test_world_api.py | 168 ++++++++++++++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 src/dashboard/routes/world.py create mode 100644 tests/dashboard/test_world_api.py diff --git a/src/dashboard/app.py b/src/dashboard/app.py index 93f6d07e..4c49eab6 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -47,6 +47,7 @@ from dashboard.routes.thinking import router as thinking_router from dashboard.routes.tools import router as tools_router from dashboard.routes.voice import router as voice_router from dashboard.routes.work_orders import router as work_orders_router +from dashboard.routes.world import router as world_router class _ColorFormatter(logging.Formatter): @@ -501,6 +502,7 @@ app.include_router(loop_qa_router) app.include_router(system_router) app.include_router(experiments_router) app.include_router(db_explorer_router) +app.include_router(world_router) @app.websocket("/ws") diff --git a/src/dashboard/routes/world.py b/src/dashboard/routes/world.py new file mode 100644 index 00000000..61623b42 --- /dev/null +++ b/src/dashboard/routes/world.py @@ -0,0 +1,93 @@ +"""Workshop world state API. + +Serves Timmy's current presence state to the Workshop 3D renderer. +The primary consumer is the browser on first load — before any +WebSocket events arrive, the client needs a full state snapshot. + +Source of truth: ``~/.timmy/presence.json`` written by +:class:`~timmy.workshop_state.WorkshopHeartbeat`. +Falls back to a live ``get_state_dict()`` call if the file is stale +or missing. +""" + +import json +import logging +import time +from datetime import UTC, datetime +from pathlib import Path + +from fastapi import APIRouter +from fastapi.responses import JSONResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/world", tags=["world"]) + +_PRESENCE_FILE = Path.home() / ".timmy" / "presence.json" +_STALE_THRESHOLD = 90 # seconds — file older than this triggers live rebuild + + +def _read_presence_file() -> dict | None: + """Read presence.json if it exists and is fresh enough.""" + try: + if not _PRESENCE_FILE.exists(): + return None + age = time.time() - _PRESENCE_FILE.stat().st_mtime + if age > _STALE_THRESHOLD: + logger.debug("presence.json is stale (%.0fs old)", age) + return None + return json.loads(_PRESENCE_FILE.read_text()) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Failed to read presence.json: %s", exc) + return None + + +def _build_world_state(presence: dict) -> dict: + """Transform presence dict into the world/state API response.""" + return { + "timmyState": { + "mood": presence.get("mood", "focused"), + "activity": presence.get("current_focus", "idle"), + "energy": presence.get("energy", 0.5), + "confidence": presence.get("confidence", 0.7), + }, + "activeThreads": presence.get("active_threads", []), + "recentEvents": presence.get("recent_events", []), + "concerns": presence.get("concerns", []), + "visitorPresent": False, + "updatedAt": presence.get("liveness", datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")), + "version": presence.get("version", 1), + } + + +@router.get("/state") +async def get_world_state() -> JSONResponse: + """Return Timmy's current world state for Workshop bootstrap. + + Reads from ``~/.timmy/presence.json`` if fresh, otherwise + rebuilds live from cognitive state. + """ + presence = _read_presence_file() + + if presence is None: + # Fallback: build live from cognitive tracker + try: + from timmy.workshop_state import get_state_dict + + presence = get_state_dict() + except Exception as exc: + logger.warning("Live state build failed: %s", exc) + presence = { + "version": 1, + "liveness": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + "mood": "idle", + "current_focus": "", + "active_threads": [], + "recent_events": [], + "concerns": [], + } + + return JSONResponse( + content=_build_world_state(presence), + headers={"Cache-Control": "no-cache, no-store"}, + ) diff --git a/tests/dashboard/test_world_api.py b/tests/dashboard/test_world_api.py new file mode 100644 index 00000000..ada37788 --- /dev/null +++ b/tests/dashboard/test_world_api.py @@ -0,0 +1,168 @@ +"""Tests for GET /api/world/state endpoint.""" + +import json +import time +from unittest.mock import patch + +import pytest + +from dashboard.routes.world import ( + _STALE_THRESHOLD, + _build_world_state, + _read_presence_file, +) + +# --------------------------------------------------------------------------- +# _build_world_state +# --------------------------------------------------------------------------- + + +def test_build_world_state_maps_fields(): + presence = { + "version": 1, + "liveness": "2026-03-19T02:00:00Z", + "mood": "exploring", + "current_focus": "reviewing PR", + "energy": 0.8, + "confidence": 0.9, + "active_threads": [{"type": "thinking", "ref": "test", "status": "active"}], + "recent_events": [], + "concerns": [], + } + result = _build_world_state(presence) + + assert result["timmyState"]["mood"] == "exploring" + assert result["timmyState"]["activity"] == "reviewing PR" + assert result["timmyState"]["energy"] == 0.8 + assert result["timmyState"]["confidence"] == 0.9 + assert result["updatedAt"] == "2026-03-19T02:00:00Z" + assert result["version"] == 1 + assert result["visitorPresent"] is False + assert len(result["activeThreads"]) == 1 + + +def test_build_world_state_defaults(): + """Missing fields get safe defaults.""" + result = _build_world_state({}) + assert result["timmyState"]["mood"] == "focused" + assert result["timmyState"]["energy"] == 0.5 + assert result["version"] == 1 + + +# --------------------------------------------------------------------------- +# _read_presence_file +# --------------------------------------------------------------------------- + + +def test_read_presence_file_missing(tmp_path): + with patch("dashboard.routes.world._PRESENCE_FILE", tmp_path / "nope.json"): + assert _read_presence_file() is None + + +def test_read_presence_file_stale(tmp_path): + f = tmp_path / "presence.json" + f.write_text(json.dumps({"version": 1})) + # Backdate the file + stale_time = time.time() - _STALE_THRESHOLD - 10 + import os + + os.utime(f, (stale_time, stale_time)) + with patch("dashboard.routes.world._PRESENCE_FILE", f): + assert _read_presence_file() is None + + +def test_read_presence_file_fresh(tmp_path): + f = tmp_path / "presence.json" + f.write_text(json.dumps({"version": 1, "mood": "focused"})) + with patch("dashboard.routes.world._PRESENCE_FILE", f): + result = _read_presence_file() + assert result is not None + assert result["version"] == 1 + + +def test_read_presence_file_bad_json(tmp_path): + f = tmp_path / "presence.json" + f.write_text("not json {{{") + with patch("dashboard.routes.world._PRESENCE_FILE", f): + assert _read_presence_file() is None + + +# --------------------------------------------------------------------------- +# Full endpoint via TestClient +# --------------------------------------------------------------------------- + + +@pytest.fixture +def client(): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + app = FastAPI() + from dashboard.routes.world import router + + app.include_router(router) + return TestClient(app) + + +def test_world_state_endpoint_with_file(client, tmp_path): + """Endpoint returns data from presence file when fresh.""" + f = tmp_path / "presence.json" + f.write_text( + json.dumps( + { + "version": 1, + "liveness": "2026-03-19T02:00:00Z", + "mood": "exploring", + "current_focus": "testing", + "active_threads": [], + "recent_events": [], + "concerns": [], + } + ) + ) + with patch("dashboard.routes.world._PRESENCE_FILE", f): + resp = client.get("/api/world/state") + + assert resp.status_code == 200 + data = resp.json() + assert data["timmyState"]["mood"] == "exploring" + assert data["timmyState"]["activity"] == "testing" + assert resp.headers["cache-control"] == "no-cache, no-store" + + +def test_world_state_endpoint_fallback(client, tmp_path): + """Endpoint falls back to live state when file missing.""" + with ( + patch("dashboard.routes.world._PRESENCE_FILE", tmp_path / "nope.json"), + patch("timmy.workshop_state.get_state_dict") as mock_get, + ): + mock_get.return_value = { + "version": 1, + "liveness": "2026-03-19T02:00:00Z", + "mood": "idle", + "current_focus": "", + "active_threads": [], + "recent_events": [], + "concerns": [], + } + resp = client.get("/api/world/state") + + assert resp.status_code == 200 + assert resp.json()["timmyState"]["mood"] == "idle" + + +def test_world_state_endpoint_full_fallback(client, tmp_path): + """Endpoint returns safe defaults when everything fails.""" + with ( + patch("dashboard.routes.world._PRESENCE_FILE", tmp_path / "nope.json"), + patch( + "timmy.workshop_state.get_state_dict", + side_effect=RuntimeError("boom"), + ), + ): + resp = client.get("/api/world/state") + + assert resp.status_code == 200 + data = resp.json() + assert data["timmyState"]["mood"] == "idle" + assert data["version"] == 1