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:
parent
e219eac5d7
commit
85949937cc
5 changed files with 133 additions and 4 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue