feat: global activity feed + session digest
Ops "Executions" tab showed raw target UUIDs, alphabetical (not
recency) order, and a stale status vocabulary from an earlier schema
iteration — never actually usable as a live "what's happening" view.
Replaced with a new recency-ordered /api/v1/activity/recent endpoint
and matching table (human-readable action summaries, risk/status
badges, duration, inline error preview).
Also added /api/v1/activity/session/{id} + a collapsible SessionDigest
panel in the chat rail, answering "what did this session actually do"
(executions by status, entities touched, knowledge written) — the
missing piece for proactive outcome reporting to be visible in the UI,
not just in the chat transcript.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
215
internal/httpapi/activity.go
Normal file
215
internal/httpapi/activity.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// activityItem is one row in the global activity feed — a human-readable
|
||||
// projection of an execution, independent of the paginated/alphabetically-
|
||||
// sorted ListExecutions (which orders by target slug for entity-scoped
|
||||
// browsing, not recency — wrong shape for "what just happened").
|
||||
type activityItem struct {
|
||||
ID string `json:"id"`
|
||||
Target string `json:"target"`
|
||||
Verb string `json:"verb"` // e.g. "run", "pct_create", "systemctl"
|
||||
Summary string `json:"summary"` // human-readable: the command, or purpose, or action detail
|
||||
RiskClass string `json:"risk_class"`
|
||||
Status string `json:"status"`
|
||||
DurationMs *int `json:"duration_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CompletedAt *string `json:"completed_at"`
|
||||
}
|
||||
|
||||
// splitAction parses the "verb:params" encoding used throughout executions.action
|
||||
// (see internal/mcp/server.go) into a verb and a human-readable summary. For
|
||||
// `run`, params is JSON {command, purpose} — show the purpose if present
|
||||
// (it's written for a human), falling back to the raw command. For other
|
||||
// actions (pct_create, systemctl, apt_upgrade, pct_exec), params is either a
|
||||
// JSON blob or a short flag string — truncate either as a fallback summary.
|
||||
func splitAction(action string) (verb, summary string) {
|
||||
idx := strings.IndexByte(action, ':')
|
||||
if idx < 0 {
|
||||
return action, ""
|
||||
}
|
||||
verb, params := action[:idx], action[idx+1:]
|
||||
if verb == "run" {
|
||||
var p struct {
|
||||
Command string `json:"command"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
if json.Unmarshal([]byte(params), &p) == nil {
|
||||
if p.Purpose != "" {
|
||||
return verb, p.Purpose
|
||||
}
|
||||
return verb, p.Command
|
||||
}
|
||||
}
|
||||
if verb == "pct_create" {
|
||||
var p struct {
|
||||
Hostname string `json:"hostname"`
|
||||
}
|
||||
if json.Unmarshal([]byte(params), &p) == nil && p.Hostname != "" {
|
||||
return verb, "provision " + p.Hostname
|
||||
}
|
||||
}
|
||||
if len(params) > 140 {
|
||||
params = params[:140] + "…"
|
||||
}
|
||||
return verb, params
|
||||
}
|
||||
|
||||
// serveRecentActivity backs the Operations page's live activity feed — the
|
||||
// global "what is the system doing / what did it just do" view, recency-
|
||||
// ordered (unlike ListExecutions, which sorts by target for pagination).
|
||||
// Custom route, same shape/rationale as serveRecentKnowledge.
|
||||
func (s *Server) serveRecentActivity(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
limit := 50
|
||||
if l := req.URL.Query().Get("limit"); l != "" {
|
||||
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 200 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT e.entity_id, te.slug, e.action, e.risk_class, e.status,
|
||||
e.duration_ms, e.result, e.created_at::text, e.completed_at::text
|
||||
FROM executions e
|
||||
JOIN entities te ON te.id = e.target_entity_id
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []activityItem{}
|
||||
for rows.Next() {
|
||||
var it activityItem
|
||||
var action string
|
||||
var resultBytes []byte
|
||||
var completedAt *string
|
||||
if err := rows.Scan(&it.ID, &it.Target, &action, &it.RiskClass, &it.Status,
|
||||
&it.DurationMs, &resultBytes, &it.CreatedAt, &completedAt); err != nil {
|
||||
slog.Error("httpapi: activity/recent row scan failed", "error", err)
|
||||
continue
|
||||
}
|
||||
it.Verb, it.Summary = splitAction(action)
|
||||
it.CompletedAt = completedAt
|
||||
if len(resultBytes) > 0 {
|
||||
var result map[string]any
|
||||
if json.Unmarshal(resultBytes, &result) == nil {
|
||||
if e, ok := result["error"].(string); ok && e != "" {
|
||||
if len(e) > 200 {
|
||||
e = e[:200] + "…"
|
||||
}
|
||||
it.Error = e
|
||||
}
|
||||
}
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{"items": items})
|
||||
}
|
||||
|
||||
// sessionDigestItem summarizes one execution for the session digest.
|
||||
type sessionDigestItem struct {
|
||||
Target string `json:"target"`
|
||||
Verb string `json:"verb"`
|
||||
Summary string `json:"summary"`
|
||||
RiskClass string `json:"risk_class"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// serveSessionDigest answers "what did THIS chat session actually do" —
|
||||
// commands run (grouped by outcome), distinct entities touched, and knowledge
|
||||
// written during the session's time window. Uses nomos_plan_executions (the
|
||||
// session<->execution link added for auto-continuation) as the source of
|
||||
// truth for which executions belong to this session; knowledge correlation is
|
||||
// a best-effort time-window match since knowledge_entities has no session_id.
|
||||
func (s *Server) serveSessionDigest(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := req.Context()
|
||||
sessionID := chi.URLParam(req, "id")
|
||||
if sessionID == "" {
|
||||
writeProblem(w, req, http.StatusBadRequest, "missing session id", "")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT te.slug, e.action, e.risk_class, e.status
|
||||
FROM nomos_plan_executions l
|
||||
JOIN executions e ON e.entity_id = l.execution_id
|
||||
JOIN entities te ON te.id = e.target_entity_id
|
||||
WHERE l.session_id = $1
|
||||
ORDER BY e.created_at`, sessionID)
|
||||
if err != nil {
|
||||
writeProblem(w, req, http.StatusInternalServerError, "query failed", err.Error())
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []sessionDigestItem{}
|
||||
byStatus := map[string]int{}
|
||||
targets := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var it sessionDigestItem
|
||||
var action string
|
||||
if err := rows.Scan(&it.Target, &action, &it.RiskClass, &it.Status); err != nil {
|
||||
continue
|
||||
}
|
||||
it.Verb, it.Summary = splitAction(action)
|
||||
items = append(items, it)
|
||||
byStatus[it.Status]++
|
||||
targets[it.Target] = true
|
||||
}
|
||||
|
||||
entityList := make([]string, 0, len(targets))
|
||||
for t := range targets {
|
||||
entityList = append(entityList, t)
|
||||
}
|
||||
|
||||
// Best-effort knowledge correlation: notes the agent wrote during this
|
||||
// session's active window. Not exact (no session_id on knowledge_entities)
|
||||
// but close enough to show "you learned N things in this session".
|
||||
var knowledgeTitles []string
|
||||
krows, err := s.pool.Query(ctx, `
|
||||
SELECT ke.title FROM knowledge_entities ke
|
||||
WHERE ke.source = 'nomos-agent'
|
||||
AND ke.updated_at BETWEEN
|
||||
(SELECT COALESCE(MIN(created_at), now()) FROM agent_messages WHERE session_id = $1)
|
||||
AND
|
||||
(SELECT COALESCE(MAX(created_at), now()) + interval '2 minutes' FROM agent_messages WHERE session_id = $1)
|
||||
ORDER BY ke.updated_at`, sessionID)
|
||||
if err == nil {
|
||||
defer krows.Close()
|
||||
for krows.Next() {
|
||||
var t string
|
||||
if krows.Scan(&t) == nil {
|
||||
knowledgeTitles = append(knowledgeTitles, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
if knowledgeTitles == nil {
|
||||
knowledgeTitles = []string{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"session_id": sessionID,
|
||||
"total_executions": len(items),
|
||||
"by_status": byStatus,
|
||||
"entities_touched": entityList,
|
||||
"executions": items,
|
||||
"knowledge_created": knowledgeTitles,
|
||||
})
|
||||
}
|
||||
@@ -144,6 +144,12 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
|
||||
// HandlerWithOptions so it wins over any generated catch-all.
|
||||
r.With(combinedAuth(cfg)).Get("/api/v1/knowledge/recent", s.serveRecentKnowledge)
|
||||
|
||||
// Custom (non-OpenAPI) routes: the global activity feed (recency-ordered,
|
||||
// unlike ListExecutions which sorts by target for pagination) and the
|
||||
// per-session "what did this session do" digest.
|
||||
r.With(combinedAuth(cfg)).Get("/api/v1/activity/recent", s.serveRecentActivity)
|
||||
r.With(combinedAuth(cfg)).Get("/api/v1/activity/session/{id}", s.serveSessionDigest)
|
||||
|
||||
// Mount MCP at /mcp (plan R3-10)
|
||||
nomosAgentID := uuid.Nil
|
||||
if cfg.NomosAgentID != "" {
|
||||
|
||||
Reference in New Issue
Block a user