feat: GET /api/world/state — Workshop bootstrap endpoint (#373)
This commit is contained in:
@@ -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")
|
||||
|
||||
93
src/dashboard/routes/world.py
Normal file
93
src/dashboard/routes/world.py
Normal file
@@ -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"},
|
||||
)
|
||||
168
tests/dashboard/test_world_api.py
Normal file
168
tests/dashboard/test_world_api.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user