[loop-cycle-155] feat: GET /api/world/state — Workshop bootstrap endpoint (#373) (#378)

This commit is contained in:
2026-03-18 22:13:49 -04:00
parent 864be20dde
commit 3108971bd5
3 changed files with 263 additions and 0 deletions

View File

@@ -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")

View 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"},
)

View 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