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:
@@ -93,7 +93,7 @@ func newTestHandler(t *testing.T, cfg config.Config) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
return NewHandler(handlerCtx, pool, cfg)
|
||||
return NewHandler(handlerCtx, pool, cfg, nil)
|
||||
}
|
||||
|
||||
func get(t *testing.T, h http.Handler, path string, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) {
|
||||
|
||||
@@ -122,6 +122,16 @@ func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (stri
|
||||
|
||||
// executeApprovedAction runs a gated action after operator approval.
|
||||
// Runs in a background goroutine to not block the HTTP response.
|
||||
// emitExecutionEvent records an execution lifecycle event for SSE fan-out so
|
||||
// the control room can watch approved actions run to completion live.
|
||||
func emitExecutionEvent(ctx context.Context, pool *db.Pool, execID uuid.UUID, status string, detail map[string]any) {
|
||||
severity := "info"
|
||||
if status == "failed" {
|
||||
severity = "warning"
|
||||
}
|
||||
_ = observability.Event(ctx, sqlcgen.New(pool), "execution."+status, &execID, severity, "actuator", "", detail)
|
||||
}
|
||||
|
||||
func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) {
|
||||
slog.Info("httpapi: executing approved action", "execution_id", execID, "target", targetSlug, "action", actionStr)
|
||||
|
||||
@@ -130,6 +140,7 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
|
||||
slog.Error("httpapi: resolve host for approved execution", "error", err, "target", targetSlug)
|
||||
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
|
||||
execID, fmt.Sprintf(`{"error":"%s"}`, err.Error()))
|
||||
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -186,6 +197,10 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
|
||||
pool.Exec(ctx, `UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4, verified=$5, started_at=$6, completed_at=$7 WHERE entity_id=$1`,
|
||||
execID, status, result, durationMs, verified, startedAt, time.Now())
|
||||
|
||||
emitExecutionEvent(ctx, pool, execID, status, map[string]any{
|
||||
"action": action, "target": targetSlug, "duration_ms": durationMs,
|
||||
})
|
||||
|
||||
slog.Info("httpapi: approved action executed",
|
||||
"execution_id", execID, "action", action, "status", status, "duration_ms", durationMs)
|
||||
}
|
||||
@@ -947,6 +962,12 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque
|
||||
return nil, auditErr
|
||||
}
|
||||
|
||||
// Emit for SSE fan-out (in-tx; NOTIFY fires post-commit).
|
||||
if evErr := observability.Event(ctx, q, "approval.decided", &id, "info", "api", "",
|
||||
map[string]any{"decision": status, "actor": actor}); evErr != nil {
|
||||
return nil, evErr
|
||||
}
|
||||
|
||||
// On approve: execute the linked gated command.
|
||||
if status == "approved" {
|
||||
var execID, targetID uuid.UUID
|
||||
|
||||
@@ -15,6 +15,9 @@ import (
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -66,7 +69,7 @@ type secretsBackend interface {
|
||||
// holds a dedicated pooled connection for LISTEN. Callers MUST cancel ctx
|
||||
// before closing the pool — otherwise the held connection never releases
|
||||
// and pool.Close() deadlocks.
|
||||
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Handler {
|
||||
func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler http.Handler) http.Handler {
|
||||
s := &Server{
|
||||
pool: pool,
|
||||
cfg: cfg,
|
||||
@@ -148,6 +151,24 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config) http.Hand
|
||||
}
|
||||
r.With(combinedAuth(cfg)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID))
|
||||
|
||||
r.Get("/ui/*", func(w http.ResponseWriter, req *http.Request) {
|
||||
if uiHandler != nil {
|
||||
uiHandler.ServeHTTP(w, req)
|
||||
}
|
||||
})
|
||||
r.Get("/ui", func(w http.ResponseWriter, req *http.Request) {
|
||||
http.Redirect(w, req, "/ui/", http.StatusMovedPermanently)
|
||||
})
|
||||
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
|
||||
http.Redirect(w, req, "/ui/", http.StatusMovedPermanently)
|
||||
})
|
||||
|
||||
if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" {
|
||||
target, _ := url.Parse(nomosURL)
|
||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||
r.Mount("/agent", http.StripPrefix("/agent", proxy))
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -484,10 +505,10 @@ func requestLogger(next http.Handler) http.Handler {
|
||||
|
||||
// ListenAndServe runs the API server with graceful shutdown on ctx cancel
|
||||
// (SG4): stop accepting, drain in-flight for up to 30s, then exit.
|
||||
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config) error {
|
||||
func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler http.Handler) error {
|
||||
srv := &http.Server{
|
||||
Addr: cfg.APIListen,
|
||||
Handler: NewHandler(ctx, pool, cfg),
|
||||
Handler: NewHandler(ctx, pool, cfg, uiHandler),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user