summaryrefslogtreecommitdiff
path: root/vrsite/model.go
diff options
context:
space:
mode:
Diffstat (limited to 'vrsite/model.go')
-rw-r--r--vrsite/model.go507
1 files changed, 507 insertions, 0 deletions
diff --git a/vrsite/model.go b/vrsite/model.go
new file mode 100644
index 0000000..2cafd75
--- /dev/null
+++ b/vrsite/model.go
@@ -0,0 +1,507 @@
+// model.go — the log projected into a browsable model.
+//
+// Everything the site shows is derived here from the vr log alone: agents and
+// their colors, per-file coverage bitmasks, notes, and live session activity.
+// The repo is never written to; the log is the only state.
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "os"
+ "sort"
+ "strings"
+ "time"
+)
+
+// Coverage is a uint8 bitmask, so only the first maxAgents agents get a color;
+// later ones fall back to the muted "no bit" rendering.
+const maxAgents = 8
+
+type Entry struct {
+ Time string `json:"time,omitempty"`
+ Op string `json:"op"`
+ Session string `json:"session,omitempty"`
+ Agent string `json:"agent,omitempty"`
+ Model string `json:"model,omitempty"`
+ Reason string `json:"reasoning,omitempty"`
+ Dir string `json:"dir,omitempty"`
+ Rev string `json:"rev,omitempty"`
+ Change string `json:"change,omitempty"`
+ File string `json:"file,omitempty"`
+ Start int `json:"start,omitempty"`
+ End int `json:"end,omitempty"`
+ Pattern string `json:"pattern,omitempty"`
+ Matches int `json:"matches,omitempty"`
+ NoteID int `json:"note_id,omitempty"`
+ Type string `json:"type,omitempty"`
+ Text string `json:"text,omitempty"`
+}
+
+type Agent struct {
+ Name, Short, Session, Model, Reason string
+ Bit uint8
+ Reads, Greps, Notes int
+}
+
+type Note struct {
+ ID int // position in the log, for anchors
+ NoteID int // the agent's own per-session number
+ Agent *Agent
+ Session, Model, Reason string
+ File, Kind, Text, Change, Rev string
+ Start, End int
+ At time.Time
+ When, Ago string
+}
+
+type File struct {
+ Path, Change string
+ Traced bool
+ Stub string // set when content is not rendered (binary, huge)
+ Cov []uint8 // 1-based line -> agent bitmask
+ NoteAt map[int]int // line -> index into Notes
+ Reads, Greps int
+ Patterns []string
+ Notes []*Note
+ Total, Covered, Pct int
+ Mask uint8
+ Body template.HTML
+}
+
+type Site struct {
+ Title string
+ Repo string
+ Change string // the change most entries were logged at
+ Agents []*Agent
+ Files map[string]*File // traced files
+ Order []string // traced paths, sorted
+ All []*File // every file in the repo at the site change
+ Notes []*Note
+ Entries []Entry
+ Theme *Theme // dark
+ Light *Theme
+}
+
+// buildModel projects log entries onto agents, files and notes. It never
+// touches the repo, so it is cheap enough to redo on every log change.
+func buildModel(title string, entries []Entry) *Site {
+ s := &Site{Title: title, Files: map[string]*File{}, Entries: entries}
+ agents := map[string]*Agent{}
+ changeVotes := map[string]int{}
+ for _, e := range entries {
+ if e.Agent == "" {
+ e.Agent = "unknown"
+ }
+ a := agents[e.Agent]
+ if a == nil {
+ a = &Agent{Name: e.Agent, Short: strings.SplitN(e.Agent, "_", 2)[0],
+ Session: e.Session, Model: e.Model, Reason: e.Reason}
+ if len(s.Agents) < maxAgents {
+ a.Bit = 1 << len(s.Agents)
+ }
+ agents[e.Agent] = a
+ s.Agents = append(s.Agents, a)
+ }
+ if e.Change != "" {
+ changeVotes[e.Change]++
+ }
+ switch e.Op {
+ case "read":
+ a.Reads++
+ f := s.file(e.File, e.Change)
+ f.Reads++
+ f.cover(e.Start, e.End, a.Bit)
+ case "grep":
+ a.Greps++
+ if f, ok := s.Files[e.File]; ok || e.File != "" {
+ if f == nil { // grep on a file we later read
+ f = s.file(e.File, e.Change)
+ }
+ f.Greps++
+ f.Patterns = append(f.Patterns, fmt.Sprintf("%s (%d)", e.Pattern, e.Matches))
+ }
+ case "note":
+ a.Notes++
+ at := parseTime(e.Time)
+ n := &Note{ID: len(s.Notes) + 1, NoteID: e.NoteID, Agent: a, Session: e.Session,
+ Model: e.Model, Reason: e.Reason, File: e.File, Change: e.Change,
+ Rev: e.Rev, Start: e.Start, End: e.End, Kind: e.Type, Text: e.Text,
+ At: at, When: stamp(at), Ago: ago(at)}
+ s.Notes = append(s.Notes, n)
+ if e.File != "" {
+ f := s.file(e.File, e.Change)
+ f.Notes = append(f.Notes, n)
+ }
+ }
+ }
+ for c, n := range changeVotes {
+ if s.Change == "" || n > changeVotes[s.Change] {
+ s.Change = c
+ }
+ }
+ // drop grep-only "files" that are really path filters (dirs, non-files)
+ for p, f := range s.Files {
+ if f.Reads == 0 && len(f.Notes) == 0 {
+ delete(s.Files, p)
+ }
+ }
+ for p := range s.Files {
+ s.Order = append(s.Order, p)
+ }
+ sort.Strings(s.Order)
+ return s
+}
+
+func (s *Site) file(path, change string) *File {
+ f := s.Files[path]
+ if f == nil {
+ f = &File{Path: path, NoteAt: map[int]int{}, Traced: true}
+ s.Files[path] = f
+ }
+ if change != "" {
+ f.Change = change
+ }
+ return f
+}
+
+func (f *File) cover(start, end int, bit uint8) {
+ if start < 1 {
+ return
+ }
+ if end < start {
+ end = start
+ }
+ for len(f.Cov) <= end {
+ f.Cov = append(f.Cov, 0)
+ }
+ for i := start; i <= end; i++ {
+ f.Cov[i] |= bit
+ }
+ f.Mask |= bit
+}
+
+// clone copies the per-render state so one *File from the model can be
+// rendered concurrently at several revisions. Cov is read-only during render.
+func (f *File) clone() *File {
+ c := *f
+ c.NoteAt = map[int]int{}
+ c.Notes = append([]*Note(nil), f.Notes...)
+ return &c
+}
+
+// filesAt joins a revision's path list with the traced files, so untraced
+// files still appear in the tree. Traced *File values are shared read-only.
+func (s *Site) filesAt(paths []string, change string) []*File {
+ var all []*File
+ for _, p := range paths {
+ if p == "" {
+ continue
+ }
+ f := s.Files[p]
+ if f == nil {
+ f = &File{Path: p, Change: change, NoteAt: map[int]int{}}
+ } else {
+ f.Traced = true
+ }
+ all = append(all, f)
+ }
+ sort.Slice(all, func(i, j int) bool { return all[i].Path < all[j].Path })
+ return all
+}
+
+// maskColor mixes the theme's agent colors for a coverage bitmask.
+func (s *Site) maskColor(t *Theme, mask uint8) string {
+ var cols []string
+ for i, a := range s.Agents {
+ if a.Bit != 0 && mask&a.Bit != 0 {
+ cols = append(cols, t.Player(i))
+ }
+ }
+ return mixColors(cols)
+}
+
+// masks lists every bitmask combination the colored agents can produce.
+func (s *Site) masks() []uint8 {
+ n := len(s.Agents)
+ if n > maxAgents {
+ n = maxAgents
+ }
+ var out []uint8
+ for m := 1; m < 1<<n; m++ {
+ out = append(out, uint8(m))
+ }
+ return out
+}
+
+// ── notes: filtering and facets ───────────────────────────────────────────
+
+type NoteFilter struct {
+ Text string // substring of the note body
+ File string // substring of the file path
+ Agent string
+ Model string
+ Session string
+ Kind string
+ Change string
+ From string // YYYY-MM-DD, inclusive
+ To string // YYYY-MM-DD, inclusive
+}
+
+func (f NoteFilter) empty() bool { return f == NoteFilter{} }
+
+func (f NoteFilter) match(n *Note) bool {
+ has := func(hay, needle string) bool {
+ return needle == "" || strings.Contains(strings.ToLower(hay), strings.ToLower(needle))
+ }
+ if !has(n.Text, f.Text) || !has(n.File, f.File) {
+ return false
+ }
+ eq := func(got, want string) bool { return want == "" || got == want }
+ if !eq(n.Agent.Name, f.Agent) || !eq(n.Model, f.Model) || !eq(n.Session, f.Session) ||
+ !eq(n.Kind, f.Kind) || !eq(n.Change, f.Change) {
+ return false
+ }
+ if f.From != "" && (n.At.IsZero() || n.At.Format(dateFmt) < f.From) {
+ return false
+ }
+ if f.To != "" && (n.At.IsZero() || n.At.Format(dateFmt) > f.To) {
+ return false
+ }
+ return true
+}
+
+const dateFmt = "2006-01-02"
+
+// FilterNotes returns matching notes, newest first (undated notes last).
+func (s *Site) FilterNotes(f NoteFilter) []*Note {
+ var out []*Note
+ for _, n := range s.Notes {
+ if f.match(n) {
+ out = append(out, n)
+ }
+ }
+ sort.SliceStable(out, func(i, j int) bool { return out[i].At.After(out[j].At) })
+ return out
+}
+
+// Facets are the distinct values the note filters can select, so the form
+// only ever offers choices that exist in the log.
+type Facets struct {
+ Agents, Models, Sessions, Kinds, Changes, Files []string
+}
+
+func (s *Site) Facets() *Facets {
+ fa := &Facets{}
+ add := func(dst *[]string, seen map[string]bool, v string) {
+ if v != "" && !seen[v] {
+ seen[v] = true
+ *dst = append(*dst, v)
+ }
+ }
+ ag, mo, se, ki, ch, fi := map[string]bool{}, map[string]bool{}, map[string]bool{}, map[string]bool{}, map[string]bool{}, map[string]bool{}
+ for _, n := range s.Notes {
+ add(&fa.Agents, ag, n.Agent.Name)
+ add(&fa.Models, mo, n.Model)
+ add(&fa.Sessions, se, n.Session)
+ add(&fa.Kinds, ki, n.Kind)
+ add(&fa.Changes, ch, n.Change)
+ add(&fa.Files, fi, n.File)
+ }
+ for _, s := range [][]string{fa.Agents, fa.Models, fa.Sessions, fa.Kinds, fa.Changes, fa.Files} {
+ sort.Strings(s)
+ }
+ return fa
+}
+
+// nextNoteID mirrors vr's numbering: ids count up from 1 within a session.
+func nextNoteID(entries []Entry, session string) int {
+ max := 0
+ for _, e := range entries {
+ if e.Op == "note" && e.Session == session && e.NoteID > max {
+ max = e.NoteID
+ }
+ }
+ return max + 1
+}
+
+// ── live activity ─────────────────────────────────────────────────────────
+
+// A Session is one agent run seen in the log: who it is, what it last did,
+// and whether it is still going. This is what "live" shows.
+type Session struct {
+ ID, Agent, Short, Model, Reason, Dir string
+ Bit uint8
+ Last time.Time
+ Ago, LastOp, File string
+ Change string
+ Reads, Greps, Notes int
+ Live bool
+}
+
+// Sessions groups the log by session (falling back to agent+dir for logs
+// without one), newest activity first. Sessions touched within window are
+// reported as live.
+func (s *Site) Sessions(now time.Time, window time.Duration, limit int) []*Session {
+ byKey := map[string]*Session{}
+ var order []*Session
+ byName := map[string]*Agent{}
+ for _, a := range s.Agents {
+ byName[a.Name] = a
+ }
+ for _, e := range s.Entries {
+ name := e.Agent
+ if name == "" {
+ name = "unknown"
+ }
+ key := e.Session
+ if key == "" {
+ key = name + "\x00" + e.Dir
+ }
+ v := byKey[key]
+ if v == nil {
+ a := byName[name]
+ v = &Session{ID: e.Session, Agent: name, Dir: e.Dir}
+ if a != nil {
+ v.Short, v.Bit = a.Short, a.Bit
+ }
+ byKey[key] = v
+ order = append(order, v)
+ }
+ if e.Model != "" {
+ v.Model, v.Reason = e.Model, e.Reason
+ }
+ if e.Dir != "" {
+ v.Dir = e.Dir
+ }
+ if e.Change != "" {
+ v.Change = e.Change
+ }
+ switch e.Op {
+ case "read":
+ v.Reads++
+ case "grep":
+ v.Greps++
+ case "note":
+ v.Notes++
+ }
+ if at := parseTime(e.Time); !at.IsZero() && at.After(v.Last) {
+ v.Last, v.File, v.LastOp = at, e.File, describe(e)
+ }
+ }
+ for _, v := range order {
+ v.Ago = agoFrom(now, v.Last)
+ v.Live = !v.Last.IsZero() && now.Sub(v.Last) < window
+ }
+ sort.SliceStable(order, func(i, j int) bool { return order[i].Last.After(order[j].Last) })
+ if limit > 0 && len(order) > limit {
+ order = order[:limit]
+ }
+ return order
+}
+
+func describe(e Entry) string {
+ switch e.Op {
+ case "read":
+ if e.Start > 0 {
+ if e.End > e.Start {
+ return fmt.Sprintf("read %s:%d-%d", e.File, e.Start, e.End)
+ }
+ return fmt.Sprintf("read %s:%d", e.File, e.Start)
+ }
+ return "read " + e.File
+ case "grep":
+ if e.File != "" {
+ return fmt.Sprintf("grep %q in %s (%d)", e.Pattern, e.File, e.Matches)
+ }
+ return fmt.Sprintf("grep %q (%d)", e.Pattern, e.Matches)
+ case "note":
+ if e.File != "" {
+ return "note on " + e.File
+ }
+ return "note"
+ }
+ return e.Op
+}
+
+// ── log file ──────────────────────────────────────────────────────────────
+
+func parseLog(b []byte) []Entry {
+ var entries []Entry
+ for _, line := range strings.Split(string(b), "\n") {
+ if line == "" {
+ continue
+ }
+ var e Entry
+ if json.Unmarshal([]byte(line), &e) == nil {
+ entries = append(entries, e)
+ }
+ }
+ return entries
+}
+
+func readLog(path string) []Entry {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ fatal("%v", err)
+ }
+ return parseLog(b)
+}
+
+// appendEntry adds one line to the log. O_APPEND plus a single write keeps it
+// interleaving safely with vr processes writing the same file.
+func appendEntry(path string, e Entry) error {
+ b, err := json.Marshal(e)
+ if err != nil {
+ return err
+ }
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ _, err = f.Write(append(b, '\n'))
+ return err
+}
+
+// ── time ──────────────────────────────────────────────────────────────────
+
+func parseTime(s string) time.Time {
+ if s == "" {
+ return time.Time{}
+ }
+ t, err := time.Parse(time.RFC3339, s)
+ if err != nil {
+ return time.Time{}
+ }
+ return t
+}
+
+func stamp(t time.Time) string {
+ if t.IsZero() {
+ return ""
+ }
+ return t.Local().Format("2006-01-02 15:04")
+}
+
+func ago(t time.Time) string { return agoFrom(time.Now(), t) }
+
+func agoFrom(now, t time.Time) string {
+ if t.IsZero() {
+ return ""
+ }
+ d := now.Sub(t)
+ switch {
+ case d < 0:
+ return "just now"
+ case d < time.Minute:
+ return fmt.Sprintf("%ds ago", int(d.Seconds()))
+ case d < time.Hour:
+ return fmt.Sprintf("%dm ago", int(d.Minutes()))
+ case d < 24*time.Hour:
+ return fmt.Sprintf("%dh ago", int(d.Hours()))
+ default:
+ return fmt.Sprintf("%dd ago", int(d.Hours())/24)
+ }
+}