feat(web): /heat?by=device — agent coverage breakdown

AgentHeat aggregates agent-kind buckets per device per top-level folder;
the handler joins the device registry (name/OS) and sorts by total. Human
and share buckets are never consulted, so human actor identities cannot
appear in the response — asserted by test, along with registry join, root
folder bucketing, sort order, and invalid-by rejection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
This commit is contained in:
Snow Lee
2026-07-12 07:36:37 -07:00
co-authored by Claude Fable 5
parent 88e361a254
commit 644d738c97
2 changed files with 155 additions and 2 deletions
+91 -2
View File
@@ -7,6 +7,7 @@ import (
"io"
"log"
"net/http"
"sort"
"strconv"
"strings"
"sync"
@@ -218,6 +219,47 @@ func (l *ReadLedger) Heat(project, prefix string, since time.Time) map[string]He
return out
}
// AgentHeat aggregates agent reads per device per top-level folder ("" for
// root files) — the coverage-matrix data. Agent buckets only, by design:
// agent actors are device ids, which history already exposes; human actors
// (emails) must never leave the server, so human/share buckets are not
// consulted at all.
func (l *ReadLedger) AgentHeat(project string, since time.Time) map[string]map[string]int64 {
if l == nil {
return nil
}
sinceDay := ""
if !since.IsZero() {
sinceDay = since.UTC().Format("2006-01-02")
}
out := map[string]map[string]int64{}
l.mu.Lock()
defer l.mu.Unlock()
for key, st := range l.byKey {
if key.Project != project || key.Kind != ReadKindAgent {
continue
}
if key.Day == "" {
if sinceDay != "" {
continue
}
} else if key.Day < sinceDay {
continue
}
folder := ""
if i := strings.IndexByte(key.Path, '/'); i >= 0 {
folder = key.Path[:i]
}
m := out[key.Actor]
if m == nil {
m = map[string]int64{}
out[key.Actor] = m
}
m[folder] += st.Count
}
return out
}
// Close flushes any pending buckets.
func (l *ReadLedger) Close() error {
if l == nil {
@@ -337,8 +379,11 @@ func (s *Server) recordRead(r *http.Request, path string) {
}
// handleHeat serves per-path read aggregates: ?prefix= bounds to a folder,
// ?days= bounds the window (default 30, 0 = all time). Counts only — actor
// identities never leave the server.
// ?days= bounds the window (default 30, 0 = all time). With ?by=device it
// returns the agent-kind breakdown instead: per device (registry-joined),
// reads per top-level folder. In both shapes, counts only — human actor
// identities never leave the server (agent devices are already public via
// history, so naming them here is consistent).
func (s *Server) handleHeat(v *volume, w http.ResponseWriter, r *http.Request) {
if s.Reads == nil {
http.Error(w, "read tracking is not enabled on this server", http.StatusNotFound)
@@ -358,6 +403,15 @@ func (s *Server) handleHeat(v *volume, w http.ResponseWriter, r *http.Request) {
if days > 0 {
since = time.Now().UTC().AddDate(0, 0, -days)
}
switch q.Get("by") {
case "":
case "device":
s.heatByDevice(w, projectID(r), since)
return
default:
http.Error(w, "invalid by (use device)", http.StatusBadRequest)
return
}
entries := s.Reads.Heat(projectID(r), q.Get("prefix"), since)
out := map[string]any{"entries": entries}
if !since.IsZero() {
@@ -366,6 +420,41 @@ func (s *Server) handleHeat(v *volume, w http.ResponseWriter, r *http.Request) {
writeJSON(w, out)
}
// deviceHeat is one row of the ?by=device response.
type deviceHeat struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
OS string `json:"os,omitempty"`
Folders map[string]int64 `json:"folders"`
Total int64 `json:"total"`
}
func (s *Server) heatByDevice(w http.ResponseWriter, project string, since time.Time) {
byDevice := s.Reads.AgentHeat(project, since)
devices := make([]deviceHeat, 0, len(byDevice))
for id, folders := range byDevice {
d := deviceHeat{ID: id, Folders: folders}
if info, ok := s.Devices.Get(id); ok {
d.Name, d.OS = info.Name, info.OS
}
for _, n := range folders {
d.Total += n
}
devices = append(devices, d)
}
sort.Slice(devices, func(i, j int) bool {
if devices[i].Total != devices[j].Total {
return devices[i].Total > devices[j].Total
}
return devices[i].ID < devices[j].ID
})
out := map[string]any{"devices": devices}
if !since.IsZero() {
out["since"] = since.Format("2006-01-02")
}
writeJSON(w, out)
}
// handleReadReport ingests agent reads from a syncing device: the client's
// read spool, drained best-effort at sync time. Requires a device identity —
// the device id is the actor, so reads count as agent traffic.
+64
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
)
@@ -234,3 +235,66 @@ func TestHeatAPI(t *testing.T) {
t.Fatalf("disabled heat: %d, want 404", rec.Code)
}
}
// TestHeatByDevice covers the coverage-matrix breakdown: agent reads per
// device per top-level folder, device-registry joined — and, critically,
// that human actor identities never appear in the response.
func TestHeatByDevice(t *testing.T) {
srv, p, root := newHub(t, false, nil)
f := newFakeRemoteAt(t, filepath.Join(root, p.ID))
f.putAs("dev1", "alice@x.io", "Alice", "wiki/plan.md", "# plan")
var err error
srv.Reads, err = OpenReadLedger(filepath.Join(t.TempDir(), "reads.json"), 0)
if err != nil {
t.Fatal(err)
}
srv.Devices, _ = OpenDeviceRegistry(filepath.Join(t.TempDir(), "devices.json"))
srv.Devices.Observe(DeviceInfo{ID: "dev1", Name: "ci-agent", OS: "linux/amd64"})
// Agent reads from two devices, human reads carrying real emails.
srv.Reads.Record(p.ID, "wiki/plan.md", ReadKindAgent, "dev1")
srv.Reads.Record(p.ID, "wiki/deep.md", ReadKindAgent, "dev1")
srv.Reads.Record(p.ID, "top.md", ReadKindAgent, "dev2")
srv.Reads.Record(p.ID, "wiki/plan.md", ReadKindHuman, "alice@x.io")
srv.Reads.Record(p.ID, "wiki/plan.md", ReadKindShare, "tok123/1.2.3.4")
h := srv.Handler()
base := "/api/p/" + p.ID + "/"
rec := do(t, h, "GET", base+"heat?by=device&days=30", nil)
if rec.Code != 200 {
t.Fatalf("by=device: %d %s", rec.Code, rec.Body)
}
var out struct {
Devices []deviceHeat `json:"devices"`
Since string `json:"since"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if len(out.Devices) != 2 || out.Since == "" {
t.Fatalf("devices = %+v", out)
}
// Sorted by total: dev1 (2 reads, registry-joined) first.
d1 := out.Devices[0]
if d1.ID != "dev1" || d1.Name != "ci-agent" || d1.OS != "linux/amd64" || d1.Total != 2 {
t.Fatalf("dev1 row = %+v", d1)
}
if d1.Folders["wiki"] != 2 {
t.Fatalf("dev1 folders = %+v, want wiki:2", d1.Folders)
}
// Root files land under the "" folder.
if d2 := out.Devices[1]; d2.ID != "dev2" || d2.Folders[""] != 1 {
t.Fatalf("dev2 row = %+v", d2)
}
// The privacy line: human and share actors are invisible here.
body := rec.Body.String()
for _, leak := range []string{"alice@x.io", "tok123", "1.2.3.4", "human", "share"} {
if strings.Contains(body, leak) {
t.Fatalf("by=device leaked %q: %s", leak, body)
}
}
if rec := do(t, h, "GET", base+"heat?by=path", nil); rec.Code != 400 {
t.Fatalf("invalid by: %d, want 400", rec.Code)
}
}