From 644d738c97dc8d791d7dd39a4541004fd0045ab3 Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Sun, 12 Jul 2026 07:36:37 -0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20/heat=3Fby=3Ddevice=20=E2=80=94=20?= =?UTF-8?q?agent=20coverage=20breakdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt --- internal/webapp/reads.go | 93 ++++++++++++++++++++++++++++++++++- internal/webapp/reads_test.go | 64 ++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 2 deletions(-) diff --git a/internal/webapp/reads.go b/internal/webapp/reads.go index d0b5d13..3f424ec 100644 --- a/internal/webapp/reads.go +++ b/internal/webapp/reads.go @@ -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. diff --git a/internal/webapp/reads_test.go b/internal/webapp/reads_test.go index ed4d6b8..2fe60d3 100644 --- a/internal/webapp/reads_test.go +++ b/internal/webapp/reads_test.go @@ -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) + } +}