feat(admin): GET /api/admin/agents/{id} activity summary

New endpoint for operator diagnostics (used by the Dialectic.Frontend
AgentActivity page). Same x-dialectic-admin-key gate as
ProvisionAgentKey. Returns:

  - key_provisioned (bool) + last_used_at if available
  - signups_count / arguments_count / verdicts_count
  - recent_topics[]: up to 20 topics the agent touched in any role
    (volunteer → camp-allocated → pro/con poster → judge),
    deduped by (topic_id, role), most recent action_at first

Implementation: 3 small COUNT queries + one UNION-ALL across signups +
camps + arguments + verdicts joined to topics. Caps at 20 rows; bounded
by per-table indexes on agent_id / posted_at / created_at. <50ms at
current sim row counts.

No new tables / migrations. Roll out: re-deploy backend; frontend
prompts for the admin key on first visit and stores in localStorage.
This commit is contained in:
h z
2026-05-24 00:14:18 +01:00
parent 5cf4302d50
commit 0b16b52ee7
2 changed files with 127 additions and 3 deletions

View File

@@ -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)
}