nomos+web: streaming, provider routing, event gap-fill, embedded UI; fix approval FK & session context
Agent (cmd/nomos): - Stream LLM tokens via NewStreaming; emit text_delta then final text. - OpenRouter provider routing: data_collection=deny (ZDR) + require_parameters; NOMOS_PROVIDER_SORT opt-in; Exacto via model suffix. - Multi-turn: reload session history into context; UI passes session id. - Fix agent_activity logging (agent_id/session_id) and mcpClient data race. Events (live control-room feed): - approval.created (mcp), approval.decided (api), execution.completed/failed (approved-action path), signal.raised/resolved + health.changed (scheduler, transition-gated). Fixes: - createApproval FK violation (reuse execution entity) — the agent's only write path; log the previously-swallowed errors. Web UI: - Embed web/dist via //go:embed (single binary); Dockerfile builds SPA into the Go stage; committed .gitkeep placeholder keeps backend-only builds green. - Caddy: Authentik-gated /agent/* -> nomos so the UI reaches the agent same-origin in production. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/dtoro/oikos/internal/config"
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||
"github.com/dtoro/oikos/internal/observability"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/sys/unix"
|
||||
@@ -98,6 +99,8 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef
|
||||
"entity", cd.EntitySlug, "kind", cd.Kind, "error", checkErr)
|
||||
}
|
||||
|
||||
prevHealth := currentHealth(ctx, pool, cd.EntityID)
|
||||
|
||||
if signalKind == "" || health == "healthy" {
|
||||
// Recovery: resolve any open signal for this check
|
||||
resolveSignal(ctx, pool, cd.EntityID, cd.EntitySlug)
|
||||
@@ -108,6 +111,10 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef
|
||||
LastCheckAt: &[]time.Time{time.Now()}[0],
|
||||
Details: []byte(`{}`),
|
||||
})
|
||||
if prevHealth != "" && prevHealth != "healthy" {
|
||||
emitSchedulerEvent(ctx, pool, "health.changed", cd.EntityID, "info",
|
||||
map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": "healthy"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -140,17 +147,47 @@ func runCheck(ctx context.Context, pool *db.Pool, cd sqlcgen.ListEnabledCheckDef
|
||||
Details: []byte(`{}`),
|
||||
})
|
||||
_ = sig // used for flap detection below
|
||||
|
||||
// Emit only on transition into failure so a persistently-down entity
|
||||
// doesn't flood the stream every tick.
|
||||
if prevHealth == "" || prevHealth == "healthy" {
|
||||
emitSchedulerEvent(ctx, pool, "signal.raised", cd.EntityID, severity,
|
||||
map[string]any{"slug": cd.EntitySlug, "kind": signalKind, "evidence": evidence})
|
||||
}
|
||||
if prevHealth != health {
|
||||
emitSchedulerEvent(ctx, pool, "health.changed", cd.EntityID, severity,
|
||||
map[string]any{"slug": cd.EntitySlug, "from": prevHealth, "to": health})
|
||||
}
|
||||
}
|
||||
|
||||
// currentHealth reads the last recorded health for an entity, or "" if none.
|
||||
func currentHealth(ctx context.Context, pool *db.Pool, entityID uuid.UUID) string {
|
||||
var health string
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT health FROM entity_status WHERE entity_id = $1`, entityID).Scan(&health); err != nil {
|
||||
return ""
|
||||
}
|
||||
return health
|
||||
}
|
||||
|
||||
// emitSchedulerEvent records a scheduler-sourced event for SSE fan-out.
|
||||
func emitSchedulerEvent(ctx context.Context, pool *db.Pool, eventType string, entityID uuid.UUID, severity string, data map[string]any) {
|
||||
_ = observability.Event(ctx, sqlcgen.New(pool), eventType, &entityID, severity, "scheduler", "", data)
|
||||
}
|
||||
|
||||
// resolveSignal resolves any open signal for the given check entity.
|
||||
func resolveSignal(ctx context.Context, pool *db.Pool, entityID uuid.UUID, slug string) {
|
||||
q := sqlcgen.New(pool)
|
||||
// Check if there's an open signal on this entity
|
||||
_, err := pool.Exec(ctx, `UPDATE signals SET state = 'resolved', updated_at = now()
|
||||
tag, err := pool.Exec(ctx, `UPDATE signals SET state = 'resolved', updated_at = now()
|
||||
WHERE entity_id = $1 AND state = 'raised'`, entityID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
emitSchedulerEvent(ctx, pool, "signal.resolved", entityID, "info",
|
||||
map[string]any{"slug": slug})
|
||||
}
|
||||
_ = q.UpsertEntityStatus(ctx, sqlcgen.UpsertEntityStatusParams{
|
||||
EntityID: entityID,
|
||||
Health: "healthy",
|
||||
|
||||
Reference in New Issue
Block a user