Validiere Statuswerte und verfeinere Frischelogik

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:45:37 +01:00
parent e219eac5d7
commit 85949937cc
5 changed files with 133 additions and 4 deletions

View file

@ -198,6 +198,7 @@ Ergaenzt seit dem ersten Geruest:
- letzter bekannter Player-Status wird im Backend pro Screen in-memory vorgehalten und lesbar gemacht - 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 - Backend ergaenzt den Read-Pfad um `received_at` und eine einfache `stale`-Ableitung
- Backend bietet zusaetzlich eine kleine Uebersicht aller zuletzt meldenden Screens - Backend bietet zusaetzlich eine kleine Uebersicht aller zuletzt meldenden Screens
- Backend validiert den Statuspfad jetzt enger auf erlaubte Lifecycle-/Connectivity-Werte und leitet `stale` aus dem gemeldeten Intervall ab
- dateibasierte Agent-Konfiguration zusaetzlich zu Env-Overrides - dateibasierte Agent-Konfiguration zusaetzlich zu Env-Overrides
- strukturierte Agent-Logs mit internem Health-Snapshot und signalgesteuertem Shutdown - strukturierte Agent-Logs mit internem Health-Snapshot und signalgesteuertem Shutdown
- erster periodischer HTTP-Status-Reporter im Agent - erster periodischer HTTP-Status-Reporter im Agent

View file

@ -24,6 +24,12 @@ Mindestens enthalten:
- `ts` - `ts`
- `status` - `status`
Fuer die aktuelle Entwicklungsstufe sind zulaessig:
- `status`: `starting`, `running`, `stopped`
- `server_connectivity`: `unknown`, `online`, `degraded`, `offline`
- `heartbeat_every_seconds`: positive Ganzzahl
Aktuell zusaetzlich enthalten: Aktuell zusaetzlich enthalten:
- `server_connectivity` - `server_connectivity`
@ -81,6 +87,7 @@ Zusaetzlich fuegt das Backend im Read-Pfad derzeit hinzu:
- `stale` als einfache serverseitige Einordnung, ob der letzte Report bereits veraltet wirkt - `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. `stale` ist aktuell bewusst nur eine kleine Diagnosehilfe fuer die Entwicklungsstufe und noch kein vollstaendiges Online-/Offline-Modell fuer spaetere Admin-Oberflaechen.
Die Schwelle wird derzeit einfach aus dem gemeldeten `heartbeat_every_seconds` abgeleitet: mehr als zwei Intervalle ohne neuen Report gelten als veraltet.
## Abgrenzung ## Abgrenzung

View file

@ -6,7 +6,19 @@ import (
"time" "time"
) )
const staleThreshold = 2 * time.Minute var allowedStatuses = map[string]struct{}{
"starting": {},
"running": {},
"stopped": {},
}
var allowedServerConnectivity = map[string]struct{}{
"": {},
"unknown": {},
"online": {},
"degraded": {},
"offline": {},
}
type playerStatusRequest struct { type playerStatusRequest struct {
ScreenID string `json:"screen_id"` ScreenID string `json:"screen_id"`
@ -43,6 +55,22 @@ func handlePlayerStatus(store playerStatusStore) http.HandlerFunc {
writeError(w, http.StatusBadRequest, "status_required", "status ist erforderlich", nil) writeError(w, http.StatusBadRequest, "status_required", "status ist erforderlich", nil)
return return
} }
request.Status = strings.TrimSpace(request.Status)
if _, ok := allowedStatuses[request.Status]; !ok {
writeError(w, http.StatusBadRequest, "invalid_status", "status ist ungueltig", nil)
return
}
request.ServerConnectivity = strings.TrimSpace(request.ServerConnectivity)
if _, ok := allowedServerConnectivity[request.ServerConnectivity]; !ok {
writeError(w, http.StatusBadRequest, "invalid_server_connectivity", "server_connectivity ist ungueltig", nil)
return
}
if request.HeartbeatEverySeconds <= 0 {
writeError(w, http.StatusBadRequest, "invalid_heartbeat_interval", "heartbeat_every_seconds muss positiv sein", nil)
return
}
if err := validateOptionalRFC3339(request.Timestamp); err != nil { if err := validateOptionalRFC3339(request.Timestamp); err != nil {
writeError(w, http.StatusBadRequest, "invalid_timestamp", "ts ist kein gueltiger RFC3339-Zeitstempel", nil) writeError(w, http.StatusBadRequest, "invalid_timestamp", "ts ist kein gueltiger RFC3339-Zeitstempel", nil)
@ -129,5 +157,14 @@ func isStale(record playerStatusRecord, now time.Time) bool {
return false return false
} }
return now.Sub(receivedAt) > staleThreshold threshold := staleThresholdFor(record)
return now.Sub(receivedAt) > threshold
}
func staleThresholdFor(record playerStatusRecord) time.Duration {
if record.HeartbeatEverySeconds > 0 {
return time.Duration(record.HeartbeatEverySeconds*2) * time.Second
}
return 2 * time.Minute
} }

View file

@ -95,7 +95,8 @@ func TestHandlePlayerStatusStoresNormalizedScreenID(t *testing.T) {
body := []byte(`{ body := []byte(`{
"screen_id": " info01-dev ", "screen_id": " info01-dev ",
"ts": "2026-03-22T16:00:00Z", "ts": "2026-03-22T16:00:00Z",
"status": "running" "status": "running",
"heartbeat_every_seconds": 30
}`) }`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
@ -144,6 +145,59 @@ func TestHandlePlayerStatusRejectsMissingStatus(t *testing.T) {
} }
} }
func TestHandlePlayerStatusRejectsUnknownStatus(t *testing.T) {
body := []byte(`{
"screen_id": "info01-dev",
"ts": "2026-03-22T16:00:00Z",
"status": "broken"
}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
w := httptest.NewRecorder()
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
func TestHandlePlayerStatusRejectsUnknownServerConnectivity(t *testing.T) {
body := []byte(`{
"screen_id": "info01-dev",
"ts": "2026-03-22T16:00:00Z",
"status": "running",
"server_connectivity": "maybe"
}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
w := httptest.NewRecorder()
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
func TestHandlePlayerStatusRejectsNonPositiveHeartbeatInterval(t *testing.T) {
body := []byte(`{
"screen_id": "info01-dev",
"ts": "2026-03-22T16:00:00Z",
"status": "running",
"heartbeat_every_seconds": 0
}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body))
w := httptest.NewRecorder()
handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
}
}
func TestHandlePlayerStatusRejectsMalformedTimestamps(t *testing.T) { func TestHandlePlayerStatusRejectsMalformedTimestamps(t *testing.T) {
body := []byte(`{ body := []byte(`{
"screen_id": "info01-dev", "screen_id": "info01-dev",
@ -277,6 +331,36 @@ func TestHandleGetLatestPlayerStatusMarksStaleRecords(t *testing.T) {
} }
} }
func TestHandleGetLatestPlayerStatusUsesHeartbeatIntervalForFreshness(t *testing.T) {
store := newInMemoryPlayerStatusStore()
store.now = func() time.Time {
return time.Date(2026, 3, 22, 16, 3, 30, 0, time.UTC)
}
store.Save(playerStatusRecord{
ScreenID: "slow-screen",
Timestamp: "2026-03-22T16:00:00Z",
Status: "running",
ServerConnectivity: "online",
ReceivedAt: "2026-03-22T16:00:00Z",
HeartbeatEverySeconds: 120,
})
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/slow-screen/status", nil)
req.SetPathValue("screenId", "slow-screen")
w := httptest.NewRecorder()
handleGetLatestPlayerStatus(store)(w, req)
var response playerStatusRecord
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if got, want := response.Stale, false; got != want {
t.Fatalf("response.Stale = %v, want %v", got, want)
}
}
func TestHandleGetLatestPlayerStatusNotFound(t *testing.T) { func TestHandleGetLatestPlayerStatusNotFound(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/missing/status", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/missing/status", nil)
req.SetPathValue("screenId", "missing") req.SetPathValue("screenId", "missing")

View file

@ -139,7 +139,7 @@ func TestRouterMeta(t *testing.T) {
} }
func TestRouterPlayerStatusRoute(t *testing.T) { func TestRouterPlayerStatusRoute(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString(`{"screen_id":"demo","ts":"2026-03-22T16:00:00Z","status":"running"}`)) req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString(`{"screen_id":"demo","ts":"2026-03-22T16:00:00Z","status":"running","heartbeat_every_seconds":30}`))
w := httptest.NewRecorder() w := httptest.NewRecorder()
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req) NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)