Leite Frische des letzten Player-Status ab

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jesko Anschütz 2026-03-22 18:30:49 +01:00
parent 8f0f06ae25
commit cc06b5a728
5 changed files with 91 additions and 2 deletions

View file

@ -196,6 +196,7 @@ Ergaenzt seit dem ersten Geruest:
- Basisendpunkte und `message_wall`-Validierung im Backend testseitig breiter abgedeckt
- erster `POST /api/v1/player/status`-Endpunkt im Backend
- letzter bekannter Player-Status wird im Backend pro Screen in-memory vorgehalten und lesbar gemacht
- Backend ergaenzt den Read-Pfad um `received_at` und eine einfache `stale`-Ableitung
- dateibasierte Agent-Konfiguration zusaetzlich zu Env-Overrides
- strukturierte Agent-Logs mit internem Health-Snapshot und signalgesteuertem Shutdown
- erster periodischer HTTP-Status-Reporter im Agent

View file

@ -72,6 +72,13 @@ Wenn fuer den Screen noch kein Status vorliegt, liefert das Backend `404` mit de
Der aktuell zurueckgelieferte Datensatz enthaelt damit sowohl den Lifecycle-Status (`status`) als auch den vom Agenten lokal abgeleiteten Reachability-Zustand (`server_connectivity`).
Zusaetzlich fuegt das Backend im Read-Pfad derzeit hinzu:
- `received_at` als serverseitigen Annahmezeitpunkt des letzten gueltigen Reports
- `stale` als einfache serverseitige Einordnung, ob der letzte Report bereits veraltet wirkt
`stale` ist aktuell bewusst nur eine kleine Diagnosehilfe fuer die Entwicklungsstufe und noch kein vollstaendiges Online-/Offline-Modell fuer spaetere Admin-Oberflaechen.
## Abgrenzung
Noch nicht Teil dieser Stufe:

View file

@ -6,6 +6,8 @@ import (
"time"
)
const staleThreshold = 2 * time.Minute
type playerStatusRequest struct {
ScreenID string `json:"screen_id"`
Timestamp string `json:"ts"`
@ -88,6 +90,8 @@ func handleGetLatestPlayerStatus(store playerStatusStore) http.HandlerFunc {
return
}
record.Stale = isStale(record, store.Now())
writeJSON(w, http.StatusOK, record)
}
}
@ -100,3 +104,16 @@ func validateOptionalRFC3339(value string) error {
_, err := time.Parse(time.RFC3339, value)
return err
}
func isStale(record playerStatusRecord, now time.Time) bool {
if strings.TrimSpace(record.ReceivedAt) == "" {
return false
}
receivedAt, err := time.Parse(time.RFC3339, record.ReceivedAt)
if err != nil {
return false
}
return now.Sub(receivedAt) > staleThreshold
}

View file

@ -1,12 +1,17 @@
package httpapi
import "sync"
import (
"sync"
"time"
)
type playerStatusRecord struct {
ScreenID string `json:"screen_id"`
Timestamp string `json:"ts"`
Status string `json:"status"`
ServerConnectivity string `json:"server_connectivity,omitempty"`
ReceivedAt string `json:"received_at,omitempty"`
Stale bool `json:"stale,omitempty"`
ServerURL string `json:"server_url,omitempty"`
MQTTBroker string `json:"mqtt_broker,omitempty"`
HeartbeatEverySeconds int `json:"heartbeat_every_seconds,omitempty"`
@ -17,15 +22,17 @@ type playerStatusRecord struct {
type playerStatusStore interface {
Save(record playerStatusRecord)
Get(screenID string) (playerStatusRecord, bool)
Now() time.Time
}
type inMemoryPlayerStatusStore struct {
mu sync.RWMutex
records map[string]playerStatusRecord
now func() time.Time
}
func newInMemoryPlayerStatusStore() *inMemoryPlayerStatusStore {
return &inMemoryPlayerStatusStore{records: make(map[string]playerStatusRecord)}
return &inMemoryPlayerStatusStore{records: make(map[string]playerStatusRecord), now: time.Now}
}
func NewPlayerStatusStore() playerStatusStore {
@ -35,6 +42,9 @@ func NewPlayerStatusStore() playerStatusStore {
func (s *inMemoryPlayerStatusStore) Save(record playerStatusRecord) {
s.mu.Lock()
defer s.mu.Unlock()
if s.now != nil && record.ReceivedAt == "" {
record.ReceivedAt = s.now().Format(time.RFC3339)
}
s.records[record.ScreenID] = record
}
@ -44,3 +54,10 @@ func (s *inMemoryPlayerStatusStore) Get(screenID string) (playerStatusRecord, bo
record, ok := s.records[screenID]
return record, ok
}
func (s *inMemoryPlayerStatusStore) Now() time.Time {
if s.now == nil {
return time.Now()
}
return s.now()
}

View file

@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestHandlePlayerStatusAccepted(t *testing.T) {
@ -55,6 +56,10 @@ func TestHandlePlayerStatusAccepted(t *testing.T) {
if got, want := stored.ServerConnectivity, "online"; got != want {
t.Fatalf("stored.ServerConnectivity = %q, want %q", got, want)
}
if stored.ReceivedAt == "" {
t.Fatal("stored.ReceivedAt = empty, want server receive timestamp")
}
}
func TestHandlePlayerStatusRejectsInvalidJSON(t *testing.T) {
@ -172,11 +177,15 @@ func TestHandlePlayerStatusRejectsMalformedLastHeartbeatAt(t *testing.T) {
func TestHandleGetLatestPlayerStatus(t *testing.T) {
store := newInMemoryPlayerStatusStore()
store.now = func() time.Time {
return time.Date(2026, 3, 22, 16, 1, 0, 0, time.UTC)
}
store.Save(playerStatusRecord{
ScreenID: "info01-dev",
Timestamp: "2026-03-22T16:00:00Z",
Status: "running",
ServerConnectivity: "degraded",
ReceivedAt: "2026-03-22T16:00:05Z",
ServerURL: "http://127.0.0.1:8080",
MQTTBroker: "tcp://127.0.0.1:1883",
HeartbeatEverySeconds: 30,
@ -206,6 +215,44 @@ func TestHandleGetLatestPlayerStatus(t *testing.T) {
if got, want := response.ServerConnectivity, "degraded"; got != want {
t.Fatalf("response.ServerConnectivity = %q, want %q", got, want)
}
if got, want := response.Stale, false; got != want {
t.Fatalf("response.Stale = %v, want %v", got, want)
}
}
func TestHandleGetLatestPlayerStatusMarksStaleRecords(t *testing.T) {
store := newInMemoryPlayerStatusStore()
store.now = func() time.Time {
return time.Date(2026, 3, 22, 16, 10, 0, 0, time.UTC)
}
store.Save(playerStatusRecord{
ScreenID: "stale-screen",
Timestamp: "2026-03-22T16:00:00Z",
Status: "running",
ServerConnectivity: "offline",
ReceivedAt: "2026-03-22T16:00:00Z",
HeartbeatEverySeconds: 30,
})
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/stale-screen/status", nil)
req.SetPathValue("screenId", "stale-screen")
w := httptest.NewRecorder()
handleGetLatestPlayerStatus(store)(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
var response playerStatusRecord
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if got, want := response.Stale, true; got != want {
t.Fatalf("response.Stale = %v, want %v", got, want)
}
}
func TestHandleGetLatestPlayerStatusNotFound(t *testing.T) {