diff --git a/internal/httpapi/handlers/admin.go b/internal/httpapi/handlers/admin.go index be65050..33cb3ce 100644 --- a/internal/httpapi/handlers/admin.go +++ b/internal/httpapi/handlers/admin.go @@ -3,12 +3,15 @@ package handlers import ( "crypto/rand" "crypto/subtle" + "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" + "time" + "github.com/go-chi/chi/v5" "github.com/go-sql-driver/mysql" "github.com/jmoiron/sqlx" @@ -123,3 +126,122 @@ func randomHexKey(byteLen int) (string, error) { } return hex.EncodeToString(b), nil } + +// GET /api/admin/agents/{id} +// +// Same x-dialectic-admin-key gate as ProvisionAgentKey. Returns a +// rolled-up activity summary for one agent: whether a dialectic key is +// provisioned (and when last used), per-action counts (signups / +// arguments / verdicts), and the 20 most-recent topics the agent +// touched in any role. Used by the frontend AgentActivity page for +// operator diagnostics ("did sherlock get a key?", "how much is mirror +// participating?"). +// +// Joins are wide but capped at 20 recent topics; total query cost is +// bounded by index scans on (agent_id, posted_at/created_at). At +// current row counts (low thousands) returns in <50ms. +type recentTopic struct { + TopicID string `db:"topic_id" json:"topic_id"` + Title string `db:"title" json:"title"` + Status string `db:"status" json:"status"` + Role string `db:"role" json:"role"` + LastActionAt time.Time `db:"last_action_at" json:"last_action_at"` +} + +func (h *AdminHandler) GetAgentSummary(w http.ResponseWriter, r *http.Request) { + if !h.checkAdminAuth(r) { + http.Error(w, "admin auth required (x-dialectic-admin-key)", http.StatusUnauthorized) + return + } + agentID := chi.URLParam(r, "id") + if agentID == "" { + http.Error(w, "agent_id required in path", http.StatusBadRequest) + return + } + + ctx := r.Context() + + // agent_keys row (may be missing — agent never provisioned) + var lastUsedAt sql.NullTime + var keyProvisioned bool + if err := h.db.QueryRowxContext(ctx, + `SELECT last_used_at FROM agent_keys WHERE agent_id = ?`, agentID, + ).Scan(&lastUsedAt); err != nil { + if !errors.Is(err, sql.ErrNoRows) { + http.Error(w, "agent_keys lookup: "+err.Error(), http.StatusInternalServerError) + return + } + keyProvisioned = false + } else { + keyProvisioned = true + } + + // Counts (3 small queries; could be one UNION but readability wins + // at this scale). + count := func(q string) (int, error) { + var n int + err := h.db.GetContext(ctx, &n, q, agentID) + return n, err + } + signups, err := count(`SELECT COUNT(*) FROM signups WHERE agent_id = ?`) + if err != nil { + http.Error(w, "signups count: "+err.Error(), http.StatusInternalServerError) + return + } + args, err := count(`SELECT COUNT(*) FROM arguments WHERE agent_id = ?`) + if err != nil { + http.Error(w, "arguments count: "+err.Error(), http.StatusInternalServerError) + return + } + verdicts, err := count(`SELECT COUNT(*) FROM verdicts WHERE judge_agent_id = ?`) + if err != nil { + http.Error(w, "verdicts count: "+err.Error(), http.StatusInternalServerError) + return + } + + // Recent topics: union 3 sources, dedup by topic_id keeping the + // latest action_at + most-specific role. Allocator camps win over + // raw signups (an allocated agent's role is meaningful; a signup + // without allocation is just "volunteer"). Verdicts row is judge. + var recent []recentTopic + if err := h.db.SelectContext(ctx, &recent, + `SELECT topic_id, title, status, role, MAX(last_action_at) AS last_action_at FROM ( + SELECT s.topic_id, t.title, t.status, 'volunteer' AS role, s.created_at AS last_action_at + FROM signups s JOIN topics t ON t.id = s.topic_id + WHERE s.agent_id = ? + UNION ALL + SELECT c.topic_id, t.title, t.status, c.camp AS role, c.allocated_at AS last_action_at + FROM camps c JOIN topics t ON t.id = c.topic_id + WHERE c.agent_id = ? + UNION ALL + SELECT a.topic_id, t.title, t.status, a.camp AS role, a.posted_at AS last_action_at + FROM arguments a JOIN topics t ON t.id = a.topic_id + WHERE a.agent_id = ? + UNION ALL + SELECT v.topic_id, t.title, t.status, 'judge' AS role, v.produced_at AS last_action_at + FROM verdicts v JOIN topics t ON t.id = v.topic_id + WHERE v.judge_agent_id = ? + ) AS u + GROUP BY topic_id, title, status, role + ORDER BY last_action_at DESC + LIMIT 20`, + agentID, agentID, agentID, agentID, + ); err != nil { + http.Error(w, "recent topics: "+err.Error(), http.StatusInternalServerError) + return + } + + resp := map[string]any{ + "agent_id": agentID, + "key_provisioned": keyProvisioned, + "signups_count": signups, + "arguments_count": args, + "verdicts_count": verdicts, + "recent_topics": recent, + } + if lastUsedAt.Valid { + resp["last_used_at"] = lastUsedAt.Time + } + writeJSON(w, http.StatusOK, resp) +} + diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index d45b0b6..24485e5 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -97,10 +97,12 @@ func Mount(cfg *config.Config, db *sqlx.DB, version string) http.Handler { r.Get("/topics/{id}/signups", signupsH.List) }) - // Admin: provision an agent api key. Auth is its own header - // (x-dialectic-admin-key against env DIALECTIC_ADMIN_API_KEY), - // not bearer — admin lifecycle is separate from agent identity. + // Admin: provision an agent api key + per-agent activity summary. + // Auth is its own header (x-dialectic-admin-key against env + // DIALECTIC_ADMIN_API_KEY), not bearer — admin lifecycle is + // separate from agent identity. r.Post("/admin/agent-keys", adminH.ProvisionAgentKey) + r.Get("/admin/agents/{id}", adminH.GetAgentSummary) }) return r