Description
## Problem
There is no Session object anywhere in the system. 'Who is streaming what' is reconstructed at render time by joining two polling sources. This causes silent contradictions, stale state after crashes, and zero session history.
---
## How the body knows it has a stream (Sunshine)
Sunshine is configured with a prep-cmd hook pointing at hydrabody's local HTTP server. When a Moonlight client successfully connects and starts receiving video, Sunshine executes the prep-cmd: POST /api/v1/stream/started fires on hydrabody's httpserver.go handleStreamStarted. When the client disconnects (cleanly or not), Sunshine executes the prep-cmd Undo: POST /api/v1/stream/ended fires on handleStreamEnded.
hydrabody maintains an in-memory sunshine.SessionRegistry (go-sunshine library) tracking per-session state: app name, state (active/disconnected/expired), PID, and duration computed from start time. This is the earliest and most reliable signal of a real stream — it comes directly from Sunshine before any network reporting happens.
Additionally, hydrabody runs a stale-session watchdog (stale_session_watchdog_windows.go) that polls TCP port 47999 every 30s. If no Moonlight client has been connected for 2 minutes, it synthetically fires OnStreamEnded. This is the recovery path when Sunshine's prep-cmd Undo was never called (e.g. Sunshine crash, ungraceful disconnect).
hydrabody reports StreamStatus to hydracluster via POST /api/v1/body/status on every tick (~30s) and on state transitions. It also reports a richer payload including StreamSessions[] to hydrabodystatus for durable storage.
## How the head knows it has a stream (Moonlight)
hydraheadipad embeds the Moonlight iOS client library (moonlight-ios fork, HydraStreamSession.m). LiStartConnection initiates the GameStream handshake with the body's Sunshine instance (port 47989 for pairing, 47984/47998 for the stream). The Moonlight library fires connection state callbacks into AppState.swift when the stream is established, when it drops, and on error.
While streaming, AppState heartbeats hydracluster every 30s via PUT /api/v1/heads/{id} with status: 'streaming' and liveBodyID set to the body node ID. When the user exits or the stream ends, AppState.stopStream() calls DELETE /api/v1/heads/{id}/stream with the body_id, then notifyStreamStopped clears liveBodyID in the next heartbeat.
hydraheadflatscreen follows the same pattern: heartbeat with HeadStatus, and a stream-stop exec command routed through handleHeadStreamStop in hydracluster.
## The correlation gap
hydracluster receives both signals independently but never joins them into a Session:
- Body reports StreamStatus: streaming in handleBodyStatus — stored in the in-memory bodyStatus map (lost on restart)
- Head reports HeadStatus: streaming + LiveBodyID in handleUpdateHead — stored in nodes.yaml (persisted)
There is no session ID. No start timestamp. No end reason. When a body crashes mid-stream, the body eventually reports idle but the head keeps heartbeating streaming — hydracluster holds a contradiction with no way to resolve it automatically. The monitor shows them joined or not based on a client-side LiveBodyID lookup that may be stale.
## Failure modes today
1. Head crashes without calling stopStream: body stays 'streaming' for up to 2 minutes (stale-session watchdog), then body reports idle. hydracluster's LiveBodyID for the head is never cleared — monitor shows head as streaming against no body.
2. Body crashes or Sunshine restarts: body reports idle on next tick. Head is still heartbeating streaming. hydracluster sees head: streaming, body: idle — contradiction. Requires operator to manually call stop-stream.
3. hydracluster restart: bodyStatus map is lost. All bodies appear idle until their next heartbeat (~30s gap). During this window discoverBody() returns actively-streaming bodies as eligible, risking double-assignment.
4. No session history: no way to answer 'how long did that stream run?' or 'why did it end?' without grepping logs.
## Solution — Phase 1 (hydracluster only, ~200 lines, no changes to hydrabody or heads)
Add SessionRecord to hydracluster server state:
SessionRecord {
ID string
BodyID string
HeadID string // resolved from LiveBodyID map at session-open time
Experience string
StartedAt time.Time
EndedAt *time.Time
EndReason string // 'head_stop', 'body_idle', 'body_offline', 'head_offline'
}
Open a session in handleBodyStatus when StreamStatus transitions from non-streaming to streaming. Close it when the body next reports idle, when the body goes offline (MarkOffline), or when handleHeadStreamStop fires. Store the last 200 closed sessions in an in-memory ring buffer — same pattern as the existing eventLog in event_log.go.
Expose:
GET /api/v1/sessions — active sessions
GET /api/v1/sessions/history — last 200 closed sessions
The streaming monitor replaces its two-call polling join with a single query to /api/v1/sessions. Operator visibility: one place to see all active streams with duration, head, body, and experience.
No hydrabody changes. No hydraheadipad changes. Deployable independently.
## Phase 2 (post-event)
Make sessions explicit on both sides:
- hydrabody handleStreamStarted POSTs to hydracluster POST /api/v1/sessions, receives session_id
- iPad startStream includes session_id from eligible body response
- iPad stopStream sends session_id in DELETE payload
This eliminates the inference gap and makes the Sunshine prep-cmd → Moonlight connect sequence the authoritative session lifecycle trigger.
## Key files
- pkg/api/handlers_body.go — handleBodyStatus (open/close sessions here)
- pkg/api/handlers_head.go — handleUpdateHead, handleHeadStreamStop (close sessions here)
- pkg/api/event_log.go — ring buffer pattern to copy
- pkg/api/server.go — bodyStatus map (add sessions map alongside)
- hydrabody: pkg/provider/httpserver.go handleStreamStarted/handleStreamEnded
- hydrabody: pkg/provider/stale_session_watchdog_windows.go
- hydraheadipad: AppState.swift stopStream() / heartbeat liveBodyID