diff --git a/src/dashboard/app.py b/src/dashboard/app.py index 3176455..93f6d07 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -373,6 +373,12 @@ async def lifespan(app: FastAPI): except Exception as exc: logger.debug("Vault size check skipped: %s", exc) + # Start Workshop presence heartbeat + from timmy.workshop_state import WorkshopHeartbeat + + workshop_heartbeat = WorkshopHeartbeat() + await workshop_heartbeat.start() + # Start chat integrations in background chat_task = asyncio.create_task(_start_chat_integrations_background()) @@ -404,6 +410,8 @@ async def lifespan(app: FastAPI): except Exception as exc: logger.debug("MCP shutdown: %s", exc) + await workshop_heartbeat.stop() + for task in [briefing_task, thinking_task, chat_task, loop_qa_task]: if task: task.cancel() diff --git a/src/timmy/workshop_state.py b/src/timmy/workshop_state.py new file mode 100644 index 0000000..f81a0ef --- /dev/null +++ b/src/timmy/workshop_state.py @@ -0,0 +1,155 @@ +"""Workshop presence heartbeat — periodic writer for ``~/.timmy/presence.json``. + +Maintains Timmy's observable presence state for the Workshop 3D renderer. +Writes the presence file every 30 seconds (or on cognitive state change), +skipping writes when state is unchanged. + +See ADR-023 for the schema contract. +""" + +import asyncio +import hashlib +import json +import logging +from datetime import UTC, datetime +from pathlib import Path + +logger = logging.getLogger(__name__) + +PRESENCE_FILE = Path.home() / ".timmy" / "presence.json" +HEARTBEAT_INTERVAL = 30 # seconds + +# Cognitive mood → presence mood mapping +_MOOD_MAP = { + "curious": "exploring", + "settled": "focused", + "hesitant": "uncertain", + "energized": "excited", +} + + +def get_state_dict() -> dict: + """Build presence state dict from current cognitive state. + + Returns a v1 presence schema dict suitable for JSON serialisation. + """ + from timmy.cognitive_state import cognitive_tracker + + state = cognitive_tracker.get_state() + + # Map cognitive engagement to presence mood fallback + mood = _MOOD_MAP.get(state.mood, "focused") + if state.engagement == "idle" and state.mood == "settled": + mood = "idle" + + # Build active threads from commitments + threads = [] + for commitment in state.active_commitments[:10]: + threads.append({"type": "thinking", "ref": commitment[:80], "status": "active"}) + + return { + "version": 1, + "liveness": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), + "current_focus": state.focus_topic or "", + "active_threads": threads, + "recent_events": [], + "concerns": [], + "mood": mood, + } + + +def write_state(state_dict: dict | None = None, path: Path | None = None) -> None: + """Write presence state to ``~/.timmy/presence.json``. + + Gracefully degrades if the file cannot be written. + """ + if state_dict is None: + state_dict = get_state_dict() + target = path or PRESENCE_FILE + try: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(state_dict, indent=2) + "\n") + except OSError as exc: + logger.warning("Failed to write presence state: %s", exc) + + +def _state_hash(state_dict: dict) -> str: + """Compute hash of state dict, ignoring liveness timestamp.""" + stable = {k: v for k, v in state_dict.items() if k != "liveness"} + return hashlib.md5(json.dumps(stable, sort_keys=True).encode()).hexdigest() + + +class WorkshopHeartbeat: + """Async background task that keeps ``presence.json`` fresh. + + - Writes every ``interval`` seconds (default 30). + - Reacts to cognitive state changes via sensory bus. + - Skips write if state hasn't changed (hash comparison). + """ + + def __init__( + self, + interval: int = HEARTBEAT_INTERVAL, + path: Path | None = None, + ) -> None: + self._interval = interval + self._path = path or PRESENCE_FILE + self._last_hash: str | None = None + self._task: asyncio.Task | None = None + self._trigger = asyncio.Event() + + async def start(self) -> None: + """Start the heartbeat background loop.""" + self._subscribe_to_events() + self._task = asyncio.create_task(self._run()) + + async def stop(self) -> None: + """Cancel the heartbeat task gracefully.""" + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + def notify(self) -> None: + """Signal an immediate state write (e.g. on cognitive state change).""" + self._trigger.set() + + async def _run(self) -> None: + """Main loop: write state on interval or trigger.""" + await asyncio.sleep(1) # Initial stagger + while True: + try: + # Wait for interval OR early trigger + try: + await asyncio.wait_for(self._trigger.wait(), timeout=self._interval) + self._trigger.clear() + except TimeoutError: + pass # Normal periodic tick + + self._write_if_changed() + except asyncio.CancelledError: + raise + except Exception as exc: + logger.error("Workshop heartbeat error: %s", exc) + + def _write_if_changed(self) -> None: + """Build state, compare hash, write only if changed.""" + state_dict = get_state_dict() + current_hash = _state_hash(state_dict) + if current_hash == self._last_hash: + return + self._last_hash = current_hash + write_state(state_dict, self._path) + + def _subscribe_to_events(self) -> None: + """Subscribe to cognitive state change events on the sensory bus.""" + try: + from timmy.event_bus import get_sensory_bus + + bus = get_sensory_bus() + bus.subscribe("cognitive_state_changed", lambda _: self.notify()) + except Exception as exc: + logger.debug("Heartbeat event subscription skipped: %s", exc) diff --git a/tests/timmy/test_workshop_state.py b/tests/timmy/test_workshop_state.py new file mode 100644 index 0000000..f55c07e --- /dev/null +++ b/tests/timmy/test_workshop_state.py @@ -0,0 +1,179 @@ +"""Tests for Workshop presence heartbeat.""" + +import json +from unittest.mock import patch + +import pytest + +from timmy.workshop_state import ( + WorkshopHeartbeat, + _state_hash, + get_state_dict, + write_state, +) + +# --------------------------------------------------------------------------- +# get_state_dict +# --------------------------------------------------------------------------- + + +def test_get_state_dict_returns_v1_schema(): + state = get_state_dict() + assert state["version"] == 1 + assert "liveness" in state + assert "current_focus" in state + assert "mood" in state + assert isinstance(state["active_threads"], list) + assert isinstance(state["recent_events"], list) + assert isinstance(state["concerns"], list) + + +def test_get_state_dict_idle_mood(): + """Idle engagement + settled mood → 'idle' presence mood.""" + from timmy.cognitive_state import CognitiveState, CognitiveTracker + + tracker = CognitiveTracker.__new__(CognitiveTracker) + tracker.state = CognitiveState(engagement="idle", mood="settled") + with patch("timmy.cognitive_state.cognitive_tracker", tracker): + state = get_state_dict() + assert state["mood"] == "idle" + + +def test_get_state_dict_maps_mood(): + """Cognitive moods map to presence moods.""" + from timmy.cognitive_state import CognitiveState, CognitiveTracker + + for cog_mood, expected in [ + ("curious", "exploring"), + ("hesitant", "uncertain"), + ("energized", "excited"), + ]: + tracker = CognitiveTracker.__new__(CognitiveTracker) + tracker.state = CognitiveState(engagement="deep", mood=cog_mood) + with patch("timmy.cognitive_state.cognitive_tracker", tracker): + state = get_state_dict() + assert state["mood"] == expected, f"Expected {expected} for {cog_mood}" + + +# --------------------------------------------------------------------------- +# write_state +# --------------------------------------------------------------------------- + + +def test_write_state_creates_file(tmp_path): + target = tmp_path / "presence.json" + state = {"version": 1, "liveness": "2026-01-01T00:00:00Z", "current_focus": ""} + write_state(state, path=target) + + assert target.exists() + data = json.loads(target.read_text()) + assert data["version"] == 1 + + +def test_write_state_creates_parent_dirs(tmp_path): + target = tmp_path / "deep" / "nested" / "presence.json" + write_state({"version": 1}, path=target) + assert target.exists() + + +# --------------------------------------------------------------------------- +# _state_hash +# --------------------------------------------------------------------------- + + +def test_state_hash_ignores_liveness(): + a = {"version": 1, "mood": "focused", "liveness": "2026-01-01T00:00:00Z"} + b = {"version": 1, "mood": "focused", "liveness": "2026-12-31T23:59:59Z"} + assert _state_hash(a) == _state_hash(b) + + +def test_state_hash_detects_mood_change(): + a = {"version": 1, "mood": "focused", "liveness": "t1"} + b = {"version": 1, "mood": "idle", "liveness": "t1"} + assert _state_hash(a) != _state_hash(b) + + +# --------------------------------------------------------------------------- +# WorkshopHeartbeat — _write_if_changed +# --------------------------------------------------------------------------- + + +def test_write_if_changed_writes_on_first_call(tmp_path): + target = tmp_path / "presence.json" + hb = WorkshopHeartbeat(interval=60, path=target) + + with patch("timmy.workshop_state.get_state_dict") as mock_state: + mock_state.return_value = { + "version": 1, + "liveness": "t1", + "current_focus": "testing", + "mood": "focused", + } + hb._write_if_changed() + + assert target.exists() + data = json.loads(target.read_text()) + assert data["version"] == 1 + assert data["current_focus"] == "testing" + + +def test_write_if_changed_skips_when_unchanged(tmp_path): + target = tmp_path / "presence.json" + hb = WorkshopHeartbeat(interval=60, path=target) + + fixed_state = {"version": 1, "liveness": "t1", "mood": "idle"} + + with patch("timmy.workshop_state.get_state_dict", return_value=fixed_state): + hb._write_if_changed() # First write + target.write_text("") # Clear to detect if second write happens + hb._write_if_changed() # Should skip — state unchanged + + # File should still be empty (second write was skipped) + assert target.read_text() == "" + + +def test_write_if_changed_writes_on_state_change(tmp_path): + target = tmp_path / "presence.json" + hb = WorkshopHeartbeat(interval=60, path=target) + + state_a = {"version": 1, "liveness": "t1", "mood": "idle"} + state_b = {"version": 1, "liveness": "t2", "mood": "focused"} + + with patch("timmy.workshop_state.get_state_dict", return_value=state_a): + hb._write_if_changed() + + with patch("timmy.workshop_state.get_state_dict", return_value=state_b): + hb._write_if_changed() + + data = json.loads(target.read_text()) + assert data["mood"] == "focused" + + +# --------------------------------------------------------------------------- +# WorkshopHeartbeat — notify +# --------------------------------------------------------------------------- + + +def test_notify_sets_trigger(): + hb = WorkshopHeartbeat(interval=60) + assert not hb._trigger.is_set() + hb.notify() + assert hb._trigger.is_set() + + +# --------------------------------------------------------------------------- +# WorkshopHeartbeat — start/stop lifecycle +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_heartbeat_start_stop_lifecycle(tmp_path): + target = tmp_path / "presence.json" + hb = WorkshopHeartbeat(interval=60, path=target) + + with patch("timmy.workshop_state.get_state_dict", return_value={"version": 1}): + await hb.start() + assert hb._task is not None + assert not hb._task.done() + await hb.stop() + assert hb._task is None