Leite Diagnosezustand im Statuspfad 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:49:48 +01:00
parent 852bba6264
commit 4ba3b4ddef
5 changed files with 61 additions and 0 deletions

View file

@ -199,6 +199,7 @@ Ergaenzt seit dem ersten Geruest:
- Backend ergaenzt den Read-Pfad um `received_at` und eine einfache `stale`-Ableitung
- 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
- Backend leitet im Read-Pfad zusaetzlich ein kompaktes `derived_state` fuer Diagnosekonsumenten ab
- 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

@ -85,10 +85,17 @@ 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
- `derived_state` als zusammengefasste Diagnoseeinschaetzung fuer Konsumenten des Read-Pfads
`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.
`derived_state` wird aktuell bewusst einfach abgeleitet:
- `offline` bei `stale = true` oder `server_connectivity = offline`
- `degraded` bei `server_connectivity = degraded|unknown` oder wenn `status` nicht `running` ist
- `online` in den verbleibenden Faellen
## Abgrenzung
Noch nicht Teil dieser Stufe:

View file

@ -120,6 +120,7 @@ func handleGetLatestPlayerStatus(store playerStatusStore) http.HandlerFunc {
}
record.Stale = isStale(record, store.Now())
record.DerivedState = deriveState(record)
writeJSON(w, http.StatusOK, record)
}
@ -133,6 +134,7 @@ func handleListLatestPlayerStatuses(store playerStatusStore) http.HandlerFunc {
filtered := make([]playerStatusRecord, 0, len(records))
for i := range records {
records[i].Stale = isStale(records[i], store.Now())
records[i].DerivedState = deriveState(records[i])
if wantConnectivity != "" && records[i].ServerConnectivity != wantConnectivity {
continue
}
@ -183,3 +185,15 @@ func staleThresholdFor(record playerStatusRecord) time.Duration {
return 2 * time.Minute
}
func deriveState(record playerStatusRecord) string {
if record.Stale || record.ServerConnectivity == "offline" {
return "offline"
}
if record.ServerConnectivity == "degraded" || record.ServerConnectivity == "unknown" || record.Status != "running" {
return "degraded"
}
return "online"
}

View file

@ -13,6 +13,7 @@ type playerStatusRecord struct {
ServerConnectivity string `json:"server_connectivity,omitempty"`
ReceivedAt string `json:"received_at,omitempty"`
Stale bool `json:"stale,omitempty"`
DerivedState string `json:"derived_state,omitempty"`
ServerURL string `json:"server_url,omitempty"`
MQTTBroker string `json:"mqtt_broker,omitempty"`
HeartbeatEverySeconds int `json:"heartbeat_every_seconds,omitempty"`

View file

@ -295,6 +295,10 @@ func TestHandleGetLatestPlayerStatus(t *testing.T) {
if got, want := response.Stale, false; got != want {
t.Fatalf("response.Stale = %v, want %v", got, want)
}
if got, want := response.DerivedState, "degraded"; got != want {
t.Fatalf("response.DerivedState = %q, want %q", got, want)
}
}
func TestHandleGetLatestPlayerStatusMarksStaleRecords(t *testing.T) {
@ -329,6 +333,10 @@ func TestHandleGetLatestPlayerStatusMarksStaleRecords(t *testing.T) {
if got, want := response.Stale, true; got != want {
t.Fatalf("response.Stale = %v, want %v", got, want)
}
if got, want := response.DerivedState, "offline"; got != want {
t.Fatalf("response.DerivedState = %q, want %q", got, want)
}
}
func TestHandleGetLatestPlayerStatusUsesHeartbeatIntervalForFreshness(t *testing.T) {
@ -361,6 +369,36 @@ func TestHandleGetLatestPlayerStatusUsesHeartbeatIntervalForFreshness(t *testing
}
}
func TestHandleGetLatestPlayerStatusDerivesOnlineState(t *testing.T) {
store := newInMemoryPlayerStatusStore()
store.now = func() time.Time {
return time.Date(2026, 3, 22, 16, 0, 30, 0, time.UTC)
}
store.Save(playerStatusRecord{
ScreenID: "online-screen",
Timestamp: "2026-03-22T16:00:00Z",
Status: "running",
ServerConnectivity: "online",
ReceivedAt: "2026-03-22T16:00:00Z",
HeartbeatEverySeconds: 30,
})
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/online-screen/status", nil)
req.SetPathValue("screenId", "online-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.DerivedState, "online"; got != want {
t.Fatalf("response.DerivedState = %q, want %q", got, want)
}
}
func TestHandleGetLatestPlayerStatusNotFound(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/missing/status", nil)
req.SetPathValue("screenId", "missing")