diff options
Diffstat (limited to 'vrsite/model.go')
| -rw-r--r-- | vrsite/model.go | 507 |
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) + } +} |
