From 231b7764d90a18c5f1bc6608fff9bd0534bb603d Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Thu, 13 Aug 2026 08:49:36 +0300 Subject: [PATCH] web: give bd memories their own view --- beads/memory.go | 431 ++++++++++++++++++++++++++++++++++++++ beads/memory_test.go | 404 +++++++++++++++++++++++++++++++++++ web/deps.go | 5 + web/memory.go | 62 ++++++ web/memory_test.go | 292 ++++++++++++++++++++++++++ web/templates.go | 29 +++ web/templates/memory.html | 116 ++++++++++ web/web_test.go | 22 +- 8 files changed, 1360 insertions(+), 1 deletion(-) create mode 100644 beads/memory.go create mode 100644 beads/memory_test.go create mode 100644 web/memory.go create mode 100644 web/memory_test.go create mode 100644 web/templates/memory.html diff --git a/beads/memory.go b/beads/memory.go new file mode 100644 index 0000000000000000000000000000000000000000..2ee35ca661fb74a0fee2e0c560526940c2c8c9d6 --- /dev/null +++ b/beads/memory.go @@ -0,0 +1,431 @@ +package beads + +import ( + "context" + "fmt" + "net/url" + "regexp" + "sort" + "strings" + "time" + + "sourcecraft.dev/bigbes/sr-ht-dolt/browse" +) + +// --- the memories bd remember writes ------------------------------------------ + +// memoryTable is where bd keeps them: the beads config table, as ordinary +// key/value rows. memoryPrefix marks the keys that are memories; the rest of the +// table is the tracker's settings (issue_prefix, compact_tier2_days, …). +const ( + memoryTable = "config" + memoryPrefix = "kv.memory." +) + +// memoryWalkMax bounds the revision walk (see walkMemories): at most this many +// commits back from the head of the ref. The most active tracker on this +// instance had 225 commits three weeks in, so this is roughly two months of +// headroom at that rate. A memory not attributed inside the walk carries no date +// at all rather than one the walk cannot support. +const memoryWalkMax = 500 + +// MemoryStaleAfter is how old a memory has to be before the page questions it. +// It is one constant and not a per-request knob, and what it produces is a +// question ("stale?") rather than a verdict: some memories are meant to be +// permanent, and only the reader knows which. +const MemoryStaleAfter = 60 * 24 * time.Hour + +// MemorySession is the seam the memory projection reads through. It is wider +// than BrowseSession because a memory has no timestamp — the config row is +// (key, value) and nothing else — so the date has to come out of the history: +// Log for the commits and TableHash to skip the ones that did not touch config. +// It is still only what this projection calls, and web's BrowseSession and +// mcpsrv's (and *browse.DB itself) satisfy it structurally. +type MemorySession interface { + BrowseSession + // Log lists commits from the head of refStr, newest first. + Log(ctx context.Context, refStr, fromHash string, limit int) ([]browse.CommitInfo, string, error) + // TableHash is the content hash of a table at a ref, ok=false when the table + // does not exist there. It reads no rows, which is what makes the walk cheap. + TableHash(ctx context.Context, refStr, table string) (string, bool, error) +} + +// MemoryRevision is when a memory's value last changed: the commit that wrote +// it, taken from the history rather than from the row. +type MemoryRevision struct { + Commit string + Date time.Time + Author string +} + +// Memory is one `bd remember` entry. +type Memory struct { + Slug string // the config key with the "kv.memory." prefix stripped + Text string // the value, with both newline spellings normalised + // Paragraphs is Text split on blank lines. Values are typed into shell + // strings, so they arrive with a mix of real newlines and literal "\n" + // escapes; a page that dumped the raw value would render either as one blob. + Paragraphs []string + // Revision is the commit that last changed this key's value, or nil when the + // walk could not reach it (see MemoryView.WalkTruncated). + Revision *MemoryRevision + // Stale reports that the memory is older than MemoryStaleAfter. For a memory + // with no Revision it is set only when the walk's own oldest commit is + // already past the threshold — that much is known even without a date. + Stale bool +} + +// MemoryView is the opaque .Data handed to memory.html. +type MemoryView struct { + Memories []Memory + Total int // memories in the tracker, before ?q= / ?key= narrowing + Search string // ?q=, sticky form state + Key string // ?key=, a single memory + Sort string // MemorySortSlug (default) | MemorySortAge + // Query is the request's query as parsed, carried so the sort toggle can + // rebuild this exact URL with one key replaced (web's withQuery) instead of + // re-listing the parameters it happens to know about. + Query url.Values + // WalkTruncated says the revision walk stopped at WalkMax commits with the + // history still going. It is the only way a Memory can carry no Revision, and + // it is what the page says instead of a date. + WalkTruncated bool + WalkMax int // memoryWalkMax, so the page can name the number it hit +} + +// Memory sort orders. Slug is the default; Age is the review queue. +const ( + MemorySortSlug = "slug" + MemorySortAge = "age" +) + +// AppliesMemories fingerprints a tracker that can carry memories: the beads +// fingerprint plus a config table with key and value columns. Like every +// Applies, it sees table shapes and never rows, so a tracker whose config holds +// no memory at all still gets the tab and renders an empty state. +func AppliesMemories(tables []browse.TableInfo) bool { + if !Applies(tables) { + return false + } + for _, t := range tables { + if t.Name != memoryTable { + continue + } + var haveKey, haveValue bool + for _, c := range t.Columns { + switch c.Name { + case "key": + haveKey = true + case "value": + haveValue = true + } + } + return haveKey && haveValue + } + return false +} + +// BuildMemories reads the memories at ref and dates each one from the history. +// now is the clock staleness is measured against, passed in rather than read +// here: this package renders nothing and reads no hidden clock, and a caller +// that pins its own clock (the web view, its tests) gets a deterministic answer. +// +// A missing config table degrades to no memories — the same treatment the other +// optional tables get — but a history that cannot be read is an error, not an +// empty answer: the date is what this view is for, and a page that silently +// dropped every date would look exactly like a tracker whose memories are all +// older than the walk. +func BuildMemories(ctx context.Context, sess MemorySession, ref string, query url.Values, now time.Time) (*MemoryView, error) { + view := &MemoryView{ + Search: strings.TrimSpace(query.Get("q")), + Key: strings.TrimSpace(query.Get("key")), + Sort: parseMemorySort(query.Get("sort")), + Query: query, + WalkMax: memoryWalkMax, + } + + rows, _, err := readRowsOptional(ctx, sess, ref, memoryTable) + if err != nil { + return nil, err + } + if rows == nil { + return view, nil + } + + // The whole config table, narrowed to the memory keys. raw keeps the stored + // value under its full key: the walk compares values as they are stored, and + // normalising first would make two spellings of the same text look equal. + cols := indexCols(rows.Columns) + raw := map[string]string{} + texts := map[string]string{} + for _, r := range rows.Rows { + key := cell(cols, r, "key") + if !strings.HasPrefix(key, memoryPrefix) { + continue + } + slug := strings.TrimPrefix(key, memoryPrefix) + if slug == "" { + continue + } + raw[key] = cell(cols, r, "value") + texts[key] = normalizeMemoryText(raw[key]) + } + view.Total = len(raw) + + // Narrow before walking: the walk is per-key work, and a ?key= page has no + // business dating the other eight memories. + tracked := map[string]string{} + for key, value := range raw { + slug := strings.TrimPrefix(key, memoryPrefix) + if view.Key != "" && slug != view.Key { + continue + } + if !matchesMemorySearch(view.Search, slug, texts[key]) { + continue + } + tracked[key] = value + } + if len(tracked) == 0 { + return view, nil + } + + walk, err := walkMemories(ctx, sess, ref, tracked) + if err != nil { + return nil, err + } + view.WalkTruncated = walk.truncated + + view.Memories = make([]Memory, 0, len(tracked)) + for key := range tracked { + m := Memory{ + Slug: strings.TrimPrefix(key, memoryPrefix), + Text: texts[key], + Paragraphs: memoryParagraphs(texts[key]), + } + if rev, ok := walk.revisions[key]; ok { + m.Revision = &rev + m.Stale = now.Sub(rev.Date) > MemoryStaleAfter + } else if !walk.oldest.IsZero() { + // No date, but a floor: the memory is at least as old as the oldest + // commit the walk examined. When that alone is past the threshold the + // question is supportable; otherwise it is not asked. + m.Stale = now.Sub(walk.oldest) > MemoryStaleAfter + } + view.Memories = append(view.Memories, m) + } + sortMemories(view.Memories, view.Sort) + + return view, nil +} + +// memoryWalk is what walkMemories learned: the commit each key was last written +// by, whether the walk ran out of budget before the history ran out, and the +// date of the oldest commit it examined. +type memoryWalk struct { + revisions map[string]MemoryRevision + truncated bool + oldest time.Time +} + +// walkMemories attributes each tracked key to the commit that last changed its +// value, walking the history of ref newest to oldest, at most memoryWalkMax +// commits. +// +// The trick that makes it affordable is the table hash. At each commit the +// content hash of config is O(1) and reads no rows; when it equals the hash at +// the newer neighbour, config is byte-identical across that step and the newer +// commit wrote no memory — skip, read nothing. Only a commit that actually +// touched config costs a row read, and then each still-unresolved key whose +// value differs from the newer neighbour's was written *by that newer commit*. +// +// The commit message is not the signal. `bd remember` does write +// "bd: remember (auto-commit) by ", but that is a claim by whoever wrote +// it; the table hash is the fact. +// +// A key still unresolved when the history itself runs out was present with this +// value at the root commit, so the root is what wrote it — that is a date the +// walk supports. A key unresolved because the walk hit its budget gets no date +// at all, and truncated says so. +// +// The walk reads the log's linearization (Log is reverse-topological). Beads +// histories are linear chains of auto-commits, which this is exact for; across a +// merge, attribution is to the nearest commit in that order. +func walkMemories(ctx context.Context, sess MemorySession, ref string, tracked map[string]string) (memoryWalk, error) { + out := memoryWalk{revisions: map[string]MemoryRevision{}} + + commits, next, err := sess.Log(ctx, ref, "", memoryWalkMax) + if err != nil { + return memoryWalk{}, fmt.Errorf("beads: read history of %q: %w", ref, err) + } + if len(commits) == 0 { + return out, nil + } + out.oldest = commits[len(commits)-1].Date + out.truncated = next != "" + + // unresolved carries each key's value at the newer neighbour of the commit + // being examined; it starts as the value at the head, which is where the + // memories themselves were read. + unresolved := make(map[string]string, len(tracked)) + for k, v := range tracked { + unresolved[k] = v + } + + newerHash, err := memoryTableHash(ctx, sess, commits[0].Hash) + if err != nil { + return memoryWalk{}, err + } + + for i := 1; i < len(commits) && len(unresolved) > 0; i++ { + hash, err := memoryTableHash(ctx, sess, commits[i].Hash) + if err != nil { + return memoryWalk{}, err + } + if hash == newerHash { + continue // config unchanged across this step: nothing to read + } + + older, err := memoryValuesAt(ctx, sess, commits[i].Hash) + if err != nil { + return memoryWalk{}, err + } + newer := commits[i-1] + for key, newerValue := range unresolved { + if older[key] == newerValue { + continue + } + out.revisions[key] = MemoryRevision{ + Commit: newer.Hash, + Date: newer.Date, + Author: newer.Author, + } + delete(unresolved, key) + } + for key := range unresolved { + unresolved[key] = older[key] + } + newerHash = hash + } + + if !out.truncated { + // The history ended with these keys never changing: the oldest commit + // walked is the root, and it carries the value we are looking at. + root := commits[len(commits)-1] + for key := range unresolved { + out.revisions[key] = MemoryRevision{Commit: root.Hash, Date: root.Date, Author: root.Author} + } + } + + return out, nil +} + +// memoryTableHash is the config table's content hash at one commit. A table that +// does not exist there is "" — an ordinary answer while walking backwards past +// the commit that created it, and one that compares correctly against another +// commit where it is equally absent. +func memoryTableHash(ctx context.Context, sess MemorySession, at string) (string, error) { + hash, ok, err := sess.TableHash(ctx, at, memoryTable) + if err != nil { + return "", fmt.Errorf("beads: hash of %s at %s: %w", memoryTable, at, err) + } + if !ok { + return "", nil + } + return hash, nil +} + +// memoryValuesAt reads the config table at one commit as key → value. A missing +// table is an empty map, which is what it means here: no key had a value yet. +func memoryValuesAt(ctx context.Context, sess MemorySession, at string) (map[string]string, error) { + rows, _, err := readRowsOptional(ctx, sess, at, memoryTable) + if err != nil { + return nil, err + } + out := map[string]string{} + if rows == nil { + return out, nil + } + cols := indexCols(rows.Columns) + for _, r := range rows.Rows { + if key := cell(cols, r, "key"); key != "" { + out[key] = cell(cols, r, "value") + } + } + return out, nil +} + +// memoryEscapedNewline matches the two-character escapes that reach the value +// because the memory was typed into a shell string: "\r\n" and "\n" written out +// as backslash sequences rather than as newlines. +var memoryEscapedNewline = regexp.MustCompile(`\\r\\n|\\n`) + +// memoryBlankLine splits a memory into paragraphs on runs of blank lines. +var memoryBlankLine = regexp.MustCompile(`\n[ \t]*\n[ \t\n]*`) + +// normalizeMemoryText brings a stored value's two newline spellings together. +// The same memory arrives with real newlines when it was written from a file or +// a heredoc and with literal backslash-n when it was typed into a shell string, +// and both spellings turn up in the same tracker. +func normalizeMemoryText(v string) string { + v = strings.ReplaceAll(v, "\r\n", "\n") + v = strings.ReplaceAll(v, "\r", "\n") + v = memoryEscapedNewline.ReplaceAllString(v, "\n") + return strings.TrimSpace(v) +} + +// memoryParagraphs splits normalised text on blank lines. Line breaks *inside* a +// paragraph are kept — memories are full of bullet lists, and joining those into +// a sentence is not rendering, it is losing the structure the author typed. +func memoryParagraphs(text string) []string { + if text == "" { + return nil + } + parts := memoryBlankLine.Split(text, -1) + out := make([]string, 0, len(parts)) + for _, p := range parts { + if strings.TrimSpace(p) == "" { + continue + } + out = append(out, strings.Trim(p, "\n")) + } + return out +} + +// matchesMemorySearch is the ?q= rule: a case-insensitive substring of the slug +// or of the text. An empty query matches everything. +func matchesMemorySearch(q, slug, text string) bool { + if q == "" { + return true + } + q = strings.ToLower(q) + return strings.Contains(strings.ToLower(slug), q) || strings.Contains(strings.ToLower(text), q) +} + +// parseMemorySort reads ?sort=, defaulting (and falling back from an unknown +// value) to slug order. +func parseMemorySort(v string) string { + if strings.ToLower(strings.TrimSpace(v)) == MemorySortAge { + return MemorySortAge + } + return MemorySortSlug +} + +// sortMemories orders the list: by slug, or oldest first for the review queue. +// In age order a memory with no revision leads — it is older than anything the +// walk could date — and ties fall back to the slug, so the order is total. +func sortMemories(ms []Memory, order string) { + sort.SliceStable(ms, func(i, j int) bool { + a, b := ms[i], ms[j] + if order == MemorySortAge { + switch { + case a.Revision == nil && b.Revision != nil: + return true + case a.Revision != nil && b.Revision == nil: + return false + case a.Revision != nil && b.Revision != nil && !a.Revision.Date.Equal(b.Revision.Date): + return a.Revision.Date.Before(b.Revision.Date) + } + } + return a.Slug < b.Slug + }) +} diff --git a/beads/memory_test.go b/beads/memory_test.go new file mode 100644 index 0000000000000000000000000000000000000000..516ef4b3ac5887b4e10199b9f9cd7e10431fd8e4 --- /dev/null +++ b/beads/memory_test.go @@ -0,0 +1,404 @@ +package beads + +import ( + "context" + "fmt" + "net/url" + "sort" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sourcecraft.dev/bigbes/sr-ht-dolt/browse" +) + +// --- fixture ----------------------------------------------------------------- + +// memoryNow is the instant the memory tests measure staleness against. Nothing +// about it is special beyond being fixed: staleness is a function of the +// fixture's dates and this clock, never of when the suite ran. +var memoryNow = time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC) + +// fakeHistory is a MemorySession over canned per-ref content: the config table +// at each commit, that table's content hash there, and a commit log. It records +// every ref a row read was issued at, which is how the walk's central claim — +// that a commit whose table hash is unchanged is skipped without reading a +// single row — is asserted rather than assumed. +type fakeHistory struct { + commits []browse.CommitInfo // newest first, as Log returns them + config map[string]map[string]string // ref → key → value; a missing ref has no config table + hashes map[string]string // ref → config's content hash; "" = table absent + + reads []string // refs Rows was called at, in order + hashCalls int + logCalls int +} + +func (f *fakeHistory) Rows(_ context.Context, ref, table string, _, _ int) (*browse.RowPage, error) { + f.reads = append(f.reads, ref) + rows, ok := f.config[ref] + if !ok || table != "config" { + return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table) + } + page := &browse.RowPage{Columns: []string{"key", "value"}} + keys := make([]string, 0, len(rows)) + for k := range rows { + keys = append(keys, k) + } + sort.Strings(keys) // a store returns rows in key order; so does this + for _, k := range keys { + page.Rows = append(page.Rows, []string{k, rows[k]}) + } + page.Total = len(page.Rows) + return page, nil +} + +func (f *fakeHistory) Log(_ context.Context, _, _ string, limit int) ([]browse.CommitInfo, string, error) { + f.logCalls++ + if limit < len(f.commits) { + return f.commits[:limit], f.commits[limit].Hash, nil + } + return f.commits, "", nil +} + +func (f *fakeHistory) TableHash(_ context.Context, ref, table string) (string, bool, error) { + f.hashCalls++ + if table != "config" { + return "", false, nil + } + h, ok := f.hashes[ref] + if !ok || h == "" { + return "", false, nil + } + return h, true, nil +} + +// memoryHistory is the fixture the walk is asserted against: five commits, and a +// config table that changes at only two of them. +// +// c4 (3 hours ago) alpha=v2 beta=b gamma=g hash h-c ← head +// c3 (2 days ago) alpha=v2 beta=b gamma=g hash h-c wrote alpha +// c2 (71 days ago) alpha=v1 beta=b gamma=g hash h-b +// c1 (99 days ago) alpha=v1 beta=b gamma=g hash h-b wrote beta +// c0 (200 days ago) gamma=g hash h-a the root +// +// So alpha resolves to c3, beta to c1, and gamma — never changed anywhere in the +// history — to the root commit that introduced it. +func memoryHistory() *fakeHistory { + at := func(d time.Duration) time.Time { return memoryNow.Add(-d) } + head := map[string]string{ + "kv.memory.alpha": "second version", + "kv.memory.beta": "beta text", + "kv.memory.gamma": "gamma text", + "issue_prefix": "demo", // not a memory: the prefix filter drops it + } + older := map[string]string{ + "kv.memory.alpha": "first version", + "kv.memory.beta": "beta text", + "kv.memory.gamma": "gamma text", + "issue_prefix": "demo", + } + root := map[string]string{ + "kv.memory.gamma": "gamma text", + "issue_prefix": "demo", + } + return &fakeHistory{ + commits: []browse.CommitInfo{ + {Hash: "c4", Author: "bigbes", Date: at(3 * time.Hour)}, + {Hash: "c3", Author: "bigbes", Date: at(2 * 24 * time.Hour)}, + {Hash: "c2", Author: "bigbes", Date: at(71 * 24 * time.Hour)}, + {Hash: "c1", Author: "alice", Date: at(99 * 24 * time.Hour)}, + {Hash: "c0", Author: "alice", Date: at(200 * 24 * time.Hour)}, + }, + config: map[string]map[string]string{ + "main": head, "c4": head, "c3": head, + "c2": older, "c1": older, + "c0": root, + }, + hashes: map[string]string{ + "main": "h-c", "c4": "h-c", "c3": "h-c", + "c2": "h-b", "c1": "h-b", + "c0": "h-a", + }, + } +} + +func buildMemories(t *testing.T, sess MemorySession, query string) *MemoryView { + t.Helper() + q, err := url.ParseQuery(query) + require.NoError(t, err) + v, err := BuildMemories(context.Background(), sess, "main", q, memoryNow) + require.NoError(t, err) + return v +} + +func slugsOf(v *MemoryView) []string { + out := make([]string, 0, len(v.Memories)) + for _, m := range v.Memories { + out = append(out, m.Slug) + } + return out +} + +// --- the projection ---------------------------------------------------------- + +// Only the kv.memory.* rows are memories, and the slug is the key without that +// prefix. The rest of config is the tracker's settings and must not appear. +func TestMemoriesPrefixFiltering(t *testing.T) { + v := buildMemories(t, memoryHistory(), "") + + assert.Equal(t, []string{"alpha", "beta", "gamma"}, slugsOf(v)) + assert.Equal(t, 3, v.Total, "issue_prefix is not a memory") + assert.Equal(t, MemorySortSlug, v.Sort) + assert.False(t, v.WalkTruncated) +} + +// A value carries both newline spellings — real ones when it was written from a +// file, literal backslash-n when it was typed into a shell string — and both +// have to become the same paragraphs. +func TestMemoriesNormaliseBothNewlineSpellings(t *testing.T) { + const escaped = `First paragraph.\n\nSecond paragraph, line one.\nLine two.` + real := "First paragraph.\n\nSecond paragraph, line one.\nLine two." + crlf := "First paragraph.\r\n\r\nSecond paragraph, line one.\r\nLine two." + + sess := memoryHistory() + for _, ref := range []string{"main", "c4", "c3"} { + sess.config[ref] = map[string]string{ + "kv.memory.escaped": escaped, + "kv.memory.real": real, + "kv.memory.crlf": crlf, + } + } + v := buildMemories(t, sess, "") + require.Len(t, v.Memories, 3) + + want := []string{"First paragraph.", "Second paragraph, line one.\nLine two."} + for _, m := range v.Memories { + assert.Equal(t, want, m.Paragraphs, "memory %q", m.Slug) + assert.Equal(t, "First paragraph.\n\nSecond paragraph, line one.\nLine two.", m.Text, + "memory %q", m.Slug) + assert.NotContains(t, m.Text, `\n`, "the literal escape must not survive into the text") + } +} + +// ?q= is a substring of the slug or of the text, case-insensitively. +func TestMemoriesSearchFilter(t *testing.T) { + bySlug := buildMemories(t, memoryHistory(), "q=ALPH") + assert.Equal(t, []string{"alpha"}, slugsOf(bySlug)) + assert.Equal(t, 3, bySlug.Total, "Total counts the tracker, not the page") + assert.Equal(t, "ALPH", bySlug.Search) + + byText := buildMemories(t, memoryHistory(), "q=second+version") + assert.Equal(t, []string{"alpha"}, slugsOf(byText)) + + none := buildMemories(t, memoryHistory(), "q=nothing+matches+this") + assert.Empty(t, none.Memories) + assert.Equal(t, 3, none.Total) +} + +// ?key= renders one memory; an unknown slug renders none rather than everything. +func TestMemoriesSingleKey(t *testing.T) { + one := buildMemories(t, memoryHistory(), "key=beta") + require.Len(t, one.Memories, 1) + assert.Equal(t, "beta", one.Memories[0].Slug) + assert.Equal(t, "beta", one.Key) + + missing := buildMemories(t, memoryHistory(), "key=nosuch") + assert.Empty(t, missing.Memories) + assert.Equal(t, 3, missing.Total) +} + +// Slug order is the default; age order is oldest first, which is the review +// queue. An unknown ?sort= value falls back to slug rather than to nothing. +func TestMemoriesSortOrders(t *testing.T) { + assert.Equal(t, []string{"alpha", "beta", "gamma"}, slugsOf(buildMemories(t, memoryHistory(), ""))) + assert.Equal(t, []string{"alpha", "beta", "gamma"}, slugsOf(buildMemories(t, memoryHistory(), "sort=slug"))) + assert.Equal(t, []string{"alpha", "beta", "gamma"}, slugsOf(buildMemories(t, memoryHistory(), "sort=sideways"))) + + // gamma (root, 200 days) → beta (c1, 99 days) → alpha (c3, 2 days). + byAge := buildMemories(t, memoryHistory(), "sort=age") + assert.Equal(t, []string{"gamma", "beta", "alpha"}, slugsOf(byAge)) + assert.Equal(t, MemorySortAge, byAge.Sort) +} + +// A tracker with no memories at all — and one with no config table — is an empty +// view, not an error: the tab exists wherever the beads fingerprint does. +func TestMemoriesEmptyTracker(t *testing.T) { + noMemories := memoryHistory() + for ref := range noMemories.config { + noMemories.config[ref] = map[string]string{"issue_prefix": "demo"} + } + v := buildMemories(t, noMemories, "") + assert.Empty(t, v.Memories) + assert.Equal(t, 0, v.Total) + assert.Empty(t, noMemories.reads[1:], "with nothing to date, the walk must not run") + + noTable := memoryHistory() + noTable.config = map[string]map[string]string{} + empty := buildMemories(t, noTable, "") + assert.Empty(t, empty.Memories) + assert.Equal(t, 0, empty.Total) +} + +// AppliesMemories is the beads fingerprint plus a config table carrying key and +// value. It sees shapes and never rows, so an empty config still gets the tab. +func TestAppliesMemories(t *testing.T) { + configTable := browse.TableInfo{Name: "config", Columns: []browse.ColumnInfo{ + {Name: "key", PrimaryKey: true}, {Name: "value"}, + }} + full := append(beadsTables(), configTable) + + assert.True(t, AppliesMemories(full)) + assert.False(t, AppliesMemories(beadsTables()), "no config table") + assert.False(t, AppliesMemories([]browse.TableInfo{configTable}), "config without the beads fingerprint") + assert.False(t, AppliesMemories(append(beadsTables(), + browse.TableInfo{Name: "config", Columns: []browse.ColumnInfo{{Name: "key"}}})), + "a config table without a value column is somebody else's config") +} + +// --- the revision walk ------------------------------------------------------- + +// The walk attributes a memory to the commit that changed its value, skips the +// commits that did not touch config without reading a row, and — when the +// history itself runs out — attributes what never changed to the root commit. +func TestMemoryRevisionWalk(t *testing.T) { + sess := memoryHistory() + v := buildMemories(t, sess, "") + require.Len(t, v.Memories, 3) + + byslug := map[string]Memory{} + for _, m := range v.Memories { + byslug[m.Slug] = m + } + + // alpha changed between c2 and c3, so c3 wrote it — not c4, which merely has + // the same value, and not c2, which has the older one. + alpha := byslug["alpha"] + require.NotNil(t, alpha.Revision) + assert.Equal(t, "c3", alpha.Revision.Commit) + assert.Equal(t, memoryNow.Add(-2*24*time.Hour), alpha.Revision.Date) + assert.Equal(t, "bigbes", alpha.Revision.Author) + assert.False(t, alpha.Stale, "two days old") + + // beta appeared at c1 (absent at the root) and was left alone since. + beta := byslug["beta"] + require.NotNil(t, beta.Revision) + assert.Equal(t, "c1", beta.Revision.Commit) + assert.Equal(t, "alice", beta.Revision.Author) + assert.True(t, beta.Stale, "99 days old") + + // gamma never changed anywhere in the history: the root is what wrote it. + gamma := byslug["gamma"] + require.NotNil(t, gamma.Revision) + assert.Equal(t, "c0", gamma.Revision.Commit) + assert.True(t, gamma.Stale, "200 days old") + assert.False(t, v.WalkTruncated, "the whole history fits inside the walk") + + // The cost claim: rows were read at the head and at the two commits whose + // config hash differs from their newer neighbour's. c3 and c1 are byte-equal + // to the commit above them and were skipped without a read; c4 is the head + // itself, already read once. + assert.Equal(t, []string{"main", "c2", "c0"}, sess.reads) + assert.Equal(t, 5, sess.hashCalls, "one O(1) table hash per commit") + assert.Equal(t, 1, sess.logCalls, "one log call for the whole walk") +} + +// A key that never changes inside the walk's budget gets no date at all rather +// than a date the walk cannot support — and the view says the walk was cut off, +// which is the only way a memory can carry no revision. +func TestMemoryRevisionUnresolvedWithinWalk(t *testing.T) { + sess := &fakeHistory{ + config: map[string]map[string]string{"main": {"kv.memory.ancient": "unchanged"}}, + hashes: map[string]string{"main": "h"}, + } + // One commit more than the walk examines, none of which touched config. + for i := 0; i <= memoryWalkMax; i++ { + h := fmt.Sprintf("k%03d", i) + sess.commits = append(sess.commits, browse.CommitInfo{ + Hash: h, Author: "bigbes", Date: memoryNow.Add(-time.Duration(i) * time.Hour), + }) + sess.config[h] = map[string]string{"kv.memory.ancient": "unchanged"} + sess.hashes[h] = "h" + } + + v := buildMemories(t, sess, "") + require.Len(t, v.Memories, 1) + assert.Nil(t, v.Memories[0].Revision, "no date the walk cannot support") + assert.True(t, v.WalkTruncated) + assert.Equal(t, memoryWalkMax, v.WalkMax) + // Still older than the threshold? The walk's own oldest commit is 500 hours + // back, which is short of 60 days, so the question is not asked either. + assert.False(t, v.Memories[0].Stale) + + assert.Equal(t, []string{"main"}, sess.reads, + "500 commits that did not touch config must cost no row read at all") + assert.Equal(t, memoryWalkMax, sess.hashCalls) +} + +// The undated arm of staleness: with no revision, the walk's oldest commit is a +// floor on the memory's age, and a floor already past the threshold supports the +// question. +func TestMemoryStaleWithoutARevision(t *testing.T) { + sess := &fakeHistory{ + config: map[string]map[string]string{"main": {"kv.memory.ancient": "unchanged"}}, + hashes: map[string]string{"main": "h"}, + } + for i := 0; i <= memoryWalkMax; i++ { + h := fmt.Sprintf("k%03d", i) + sess.commits = append(sess.commits, browse.CommitInfo{ + Hash: h, Date: memoryNow.Add(-time.Duration(i) * 24 * time.Hour), + }) + sess.config[h] = map[string]string{"kv.memory.ancient": "unchanged"} + sess.hashes[h] = "h" + } + + v := buildMemories(t, sess, "") + require.Len(t, v.Memories, 1) + assert.Nil(t, v.Memories[0].Revision) + assert.True(t, v.Memories[0].Stale, "the oldest commit walked is 499 days back") +} + +// A commit that created the config table is attributed correctly: walking past +// it, the table is simply absent, and absent is an ordinary answer rather than +// an error. +func TestMemoryWalkPastTheTablesCreation(t *testing.T) { + sess := &fakeHistory{ + commits: []browse.CommitInfo{ + {Hash: "b2", Author: "bigbes", Date: memoryNow.Add(-time.Hour)}, + {Hash: "b1", Author: "bigbes", Date: memoryNow.Add(-48 * time.Hour)}, + {Hash: "b0", Author: "bigbes", Date: memoryNow.Add(-72 * time.Hour)}, + }, + config: map[string]map[string]string{ + "main": {"kv.memory.first": "hello"}, + "b2": {"kv.memory.first": "hello"}, + "b1": {"kv.memory.first": "hello"}, + // b0 predates the config table entirely: no entry at all. + }, + hashes: map[string]string{"main": "h1", "b2": "h1", "b1": "h1"}, + } + + v := buildMemories(t, sess, "") + require.Len(t, v.Memories, 1) + require.NotNil(t, v.Memories[0].Revision) + assert.Equal(t, "b1", v.Memories[0].Revision.Commit, + "the commit that created config is the one that wrote the memory") + assert.False(t, v.WalkTruncated) +} + +// A history that cannot be read is an error, not a page of memories with every +// date quietly missing — that page is indistinguishable from a tracker whose +// memories are all older than the walk. +func TestMemoriesFailWhenTheHistoryCannotBeRead(t *testing.T) { + sess := &failingLog{fakeHistory: memoryHistory()} + _, err := BuildMemories(context.Background(), sess, "main", url.Values{}, memoryNow) + require.Error(t, err) + assert.Contains(t, err.Error(), "read history") +} + +type failingLog struct{ *fakeHistory } + +func (*failingLog) Log(context.Context, string, string, int) ([]browse.CommitInfo, string, error) { + return nil, "", fmt.Errorf("browse: walk commits: corrupt chunk") +} diff --git a/web/deps.go b/web/deps.go index 3feba934b72791c5c94fe42e6d5ede9f90e9ab61..b3ff0d7e0f187063286681f53e94aa1d935a2a28 100644 --- a/web/deps.go +++ b/web/deps.go @@ -122,6 +122,11 @@ type BrowseSession interface { Branches(ctx context.Context) ([]browse.Branch, error) Log(ctx context.Context, refStr, fromHash string, limit int) ([]browse.CommitInfo, string, error) Tables(ctx context.Context, refStr string) ([]browse.TableInfo, error) + // TableHash is the content hash of one table at a ref, ok=false when the + // table does not exist there. It reads no rows, and only the Memory view + // asks for it: its revision walk uses the hash to skip every commit that did + // not touch config, so a board never pays for a history walk it does not do. + TableHash(ctx context.Context, refStr, table string) (string, bool, error) Rows(ctx context.Context, refStr, table string, offset, limit int) (*browse.RowPage, error) CommitSummary(ctx context.Context, hashStr string) (*browse.CommitDiff, error) Close() error diff --git a/web/memory.go b/web/memory.go new file mode 100644 index 0000000000000000000000000000000000000000..db55b145967be3dae5d5ce4957894057698645d2 --- /dev/null +++ b/web/memory.go @@ -0,0 +1,62 @@ +package web + +import ( + "context" + "net/url" + + "sourcecraft.dev/bigbes/sr-ht-dolt/beads" + "sourcecraft.dev/bigbes/sr-ht-dolt/browse" + "sourcecraft.dev/bigbes/sr-ht-dolt/core" +) + +// memoryView renders the memories `bd remember` writes: config rows keyed +// kv.memory., each with the revision its value was last written at. The +// row itself is (key, value) and carries no timestamp, so the date comes out of +// the history — see beads.BuildMemories for the walk that finds it. +// +// The reading is not here. It lives in the beads package, which the MCP +// surface's list_memories shares; this type is only the View adapter — slug, +// label, template, and the hand-off of the request's ref and query. +type memoryView struct{} + +// init registers the Memory view, and registers it *after* Milestones, which is +// where the design puts the tab: registration order is tab order (views.go). +// +// Getting that order is not free here, and the extra line is the whole reason +// this comment exists. A package's init functions run in lexical file-name +// order, and "memory.go" sorts before "milestones.go" — so registering only +// memoryView would put this tab between Beads and Milestones. Registering +// milestonesView first claims the slot ahead of it; milestones.go's own init +// then re-registers the same view, which RegisterView resolves in place (same +// Name), leaving the order alone. Both calls name real views, so a rename or a +// removal on either side is a compile error rather than a silently reordered +// tab bar. +func init() { + RegisterView(&milestonesView{}) + RegisterView(&memoryView{}) +} + +func (*memoryView) Name() string { return "memory" } +func (*memoryView) Label() string { return "Memory" } +func (*memoryView) Template() string { return "memory.html" } + +// Applies is the beads fingerprint plus a config table carrying key and value; +// see beads.AppliesMemories. Applies sees table shapes and never rows, so a +// tracker that has never had a memory written into it still gets the tab and +// renders an empty state — the same contract Milestones has. +func (*memoryView) Applies(tables []browse.TableInfo) bool { return beads.AppliesMemories(tables) } + +// Build reads the memories and dates them from the history. The result is a +// *beads.MemoryView, handed to memory.html as its .Data. +// +// The clock is passed in rather than read inside the projection: staleness is +// the one thing on this page that depends on when it was rendered, and timeNow +// is the same swappable clock the freshness line uses, so a test pins both at +// once. +func (*memoryView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo, ref string, query url.Values) (any, error) { + data, err := beads.BuildMemories(ctx, sess, ref, query, timeNow()) + if err != nil { + return nil, err + } + return data, nil +} diff --git a/web/memory_test.go b/web/memory_test.go new file mode 100644 index 0000000000000000000000000000000000000000..94fee595ac87bf1aa3e10e6af96b20fd82e4dac0 --- /dev/null +++ b/web/memory_test.go @@ -0,0 +1,292 @@ +package web + +import ( + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sourcecraft.dev/bigbes/sr-ht-dolt/browse" + "sourcecraft.dev/bigbes/sr-ht-dolt/core" +) + +// The projection — the prefix filter, the newline spellings, ?q=, the sort +// orders and the revision walk — is tested in the beads package. What is left +// here is the tab, the page, and the one thing this view renders that no other +// does: how old a memory is, in days. + +// memoryCommits are the three commits the fixture's history has. Only the head +// and midHash ever touched config; rootHash predates the memories. +const ( + memoryMidHash = "9f8e7d6c5b4a3928374655647382910a" + memoryRootHash = "0a1b2c3d4e5f60718293a4b5c6d7e8f9" +) + +// memoryFixture is a tracker with two memories, written at two different +// commits, over a three-commit history: +// +// head (4 minutes ago) handoff + metered hash h2 wrote handoff +// mid (midAge ago) metered hash h1 wrote metered +// root (200 days ago) no memories hash h0 +// +// midAge is a parameter because the stale? marker is a question about exactly +// that number. +func memoryFixture(midAge time.Duration) *fakeSession { + config := func(pairs ...[2]string) *browse.RowPage { + page := &browse.RowPage{Columns: []string{"key", "value"}} + for _, p := range pairs { + page.Rows = append(page.Rows, []string{p[0], p[1]}) + } + page.Total = len(page.Rows) + return page + } + // The handoff memory carries the literal "\n" escape agents type into shell + // strings, and a fragment that must not reach the page as markup. + handoff := [2]string{"kv.memory.handoff-2026-08-12", + `State after the 2026-08-12 round.\n\nSupersedes the two 2026-08-10 handoff memories, which asserted otherwise.`} + metered := [2]string{"kv.memory.metered-link-calibration", + "Owner is frequently on metered mobile internet.\n\nCalibrate downloads accordingly."} + prefix := [2]string{"issue_prefix", "demo"} + + return &fakeSession{ + branches: []browse.Branch{{Name: "main", Head: headHash}}, + tables: append(beadsTables(), memoryConfigTable()), + commits: []browse.CommitInfo{ + {Hash: headHash, Author: "bigbes", Date: testNow.Add(-4 * time.Minute), Message: "bd: remember (auto-commit) by bigbes"}, + {Hash: memoryMidHash, Author: "bigbes", Date: testNow.Add(-midAge)}, + {Hash: memoryRootHash, Author: "alice", Date: testNow.Add(-200 * 24 * time.Hour)}, + }, + rowsByRef: map[string]map[string]*browse.RowPage{ + "main": {"config": config(handoff, metered, prefix)}, + headHash: {"config": config(handoff, metered, prefix)}, + memoryMidHash: {"config": config(metered, prefix)}, + memoryRootHash: {"config": config(prefix)}, + }, + tableHashes: map[string]string{ + "main/config": "h2", headHash + "/config": "h2", + memoryMidHash + "/config": "h1", memoryRootHash + "/config": "h0", + }, + } +} + +// memoryConfigTable is the shape AppliesMemories looks for on top of the beads +// fingerprint. +func memoryConfigTable() browse.TableInfo { + return browse.TableInfo{Name: "config", Columns: []browse.ColumnInfo{ + {Name: "key", PrimaryKey: true}, {Name: "value"}, + }} +} + +// The tab bar is registration order (views.go), and the design puts Memory after +// Milestones. This asserts the registry the process actually builds — not a +// hand-ordered slice a test injected — because init order is lexical file order +// and "memory.go" sorts before "milestones.go". +func TestMemoryTabFollowsMilestones(t *testing.T) { + pos := map[string]int{} + for i, v := range registeredViews { + pos[v.Name()] = i + } + require.Contains(t, pos, "memory") + require.Contains(t, pos, "milestones") + assert.Less(t, pos["milestones"], pos["memory"], "the Memory tab comes after Milestones") + assert.Less(t, pos["beads"], pos["milestones"], "and both after Beads") + + // And the same order on the rendered page, through the real registry. + pinClock(t, testNow) + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) + h.browse.sess = memoryFixture(71 * 24 * time.Hour) + + rec := h.do("GET", "/~alice/db/view/memory", nil, nil) + require.Equal(t, http.StatusOK, rec.Code, "memory view: %s", rec.Body.String()) + body := rec.Body.String() + iBeads := strings.Index(body, "/view/beads\"") + iMilestones := strings.Index(body, "/view/milestones\"") + iMemory := strings.Index(body, "/view/memory\"") + require.NotEqual(t, -1, iBeads) + require.NotEqual(t, -1, iMilestones) + require.NotEqual(t, -1, iMemory) + assert.Less(t, iBeads, iMilestones) + assert.Less(t, iMilestones, iMemory, "the Memory tab is rendered after Milestones") +} + +func TestMemoryAppliesNeedsAConfigTable(t *testing.T) { + v := &memoryView{} + assert.True(t, v.Applies(append(beadsTables(), memoryConfigTable()))) + assert.False(t, v.Applies(beadsTables()), "no config table, no Memory tab") + assert.False(t, v.Applies([]browse.TableInfo{memoryConfigTable()}), "config alone is not a beads DB") +} + +func TestMemoryRenders(t *testing.T) { + pinClock(t, testNow) + + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) + h.browse.sess = memoryFixture(71 * 24 * time.Hour) + + rec := h.do("GET", "/~alice/db/view/memory", nil, nil) + require.Equal(t, http.StatusOK, rec.Code, "memory view: %s", rec.Body.String()) + body := rec.Body.String() + + // Both memories, by slug, with the prefix stripped and the tracker's own + // settings left where they belong. + assert.Contains(t, body, ">handoff-2026-08-12<") + assert.Contains(t, body, ">metered-link-calibration<") + assert.NotContains(t, body, "kv.memory.") + assert.NotContains(t, body, "issue_prefix") + assert.Contains(t, body, "2 entries") + + // The revision of each, from the history: the head wrote the handoff, and the + // short hash links to the commit page. + assert.Contains(t, body, "written qk9j2n4b") + assert.Contains(t, body, "71 days ago", "the day-resolution spelling, not 2 months ago") + assert.NotContains(t, body, "months ago") + assert.Contains(t, body, `href="/~alice/db/commit/`+memoryMidHash+`"`) + + // Paragraphs, from both newline spellings: the literal escape is a break and + // never reaches the page as backslash-n. + assert.Contains(t, body, "

State after the 2026-08-12 round.

") + assert.Contains(t, body, "

Owner is frequently on metered mobile internet.

") + assert.NotContains(t, body, `\n`) + + // Stored text is text: it is escaped, never rendered as markup. + assert.NotContains(t, body, "otherwise") + assert.Contains(t, body, "<b>otherwise</b>") + + // The freshness line the beads family shares. + assert.Contains(t, body, `class="beads-freshness"`) + assert.Contains(t, body, "main · last commit") +} + +// The stale? marker is a question asked at exactly 60 days, and it is a constant +// rather than a per-request knob: the only way to move it is to edit it. +func TestMemoryStaleMarkerAtTheBoundary(t *testing.T) { + render := func(t *testing.T, midAge time.Duration) string { + t.Helper() + pinClock(t, testNow) + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) + h.browse.sess = memoryFixture(midAge) + rec := h.do("GET", "/~alice/db/view/memory?key=metered-link-calibration", nil, nil) + require.Equal(t, http.StatusOK, rec.Code, "memory view: %s", rec.Body.String()) + return rec.Body.String() + } + + exactly := render(t, 60*24*time.Hour) + assert.Contains(t, exactly, "metered-link-calibration") + assert.NotContains(t, exactly, "stale?", "at the mark itself the question is not asked") + + past := render(t, 60*24*time.Hour+time.Second) + assert.Contains(t, past, "stale?") + assert.Contains(t, past, "60 days ago") + + fresh := render(t, 3*time.Hour) + assert.NotContains(t, fresh, "stale?") + assert.Contains(t, fresh, "3 hours ago") +} + +// ?key= renders one memory and not the rest; an unknown slug says so rather than +// falling back to the whole list. +func TestMemorySingleKey(t *testing.T) { + pinClock(t, testNow) + + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) + h.browse.sess = memoryFixture(71 * 24 * time.Hour) + + one := h.do("GET", "/~alice/db/view/memory?key=handoff-2026-08-12", nil, nil) + require.Equal(t, http.StatusOK, one.Code) + assert.Contains(t, one.Body.String(), "State after the 2026-08-12 round.") + assert.NotContains(t, one.Body.String(), "metered mobile internet") + + missing := h.do("GET", "/~alice/db/view/memory?key=nosuch", nil, nil) + require.Equal(t, http.StatusOK, missing.Code) + assert.Contains(t, missing.Body.String(), "No memory named") + assert.NotContains(t, missing.Body.String(), "State after the 2026-08-12 round.") +} + +// A tracker whose config holds no memory still gets the tab (Applies sees shapes +// and never rows) and renders an empty state that says what writes one. +func TestMemoryEmptyState(t *testing.T) { + pinClock(t, testNow) + + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) + sess := memoryFixture(71 * 24 * time.Hour) + sess.rowsByRef["main"] = map[string]*browse.RowPage{"config": { + Columns: []string{"key", "value"}, + Rows: [][]string{{"issue_prefix", "demo"}}, + Total: 1, + }} + h.browse.sess = sess + + rec := h.do("GET", "/~alice/db/view/memory", nil, nil) + require.Equal(t, http.StatusOK, rec.Code, "memory view: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, "No memories.") + assert.Contains(t, body, "bd remember") + assert.Contains(t, body, "0 entries") + // The tab is there either way, and so is the freshness line. + assert.Contains(t, body, "/view/memory\"") + assert.Contains(t, body, `class="beads-freshness"`) +} + +// The sort toggle is a link that keeps the rest of the query, and the review +// queue is oldest first. +func TestMemorySortToggle(t *testing.T) { + pinClock(t, testNow) + + h := newHarness(t) + h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) + h.browse.sess = memoryFixture(71 * 24 * time.Hour) + + bySlug := h.do("GET", "/~alice/db/view/memory?ref=main", nil, nil) + require.Equal(t, http.StatusOK, bySlug.Code) + assert.Contains(t, bySlug.Body.String(), "/view/memory?ref=main&sort=age", + "the toggle keeps ?ref= rather than re-listing the parameters it knows") + + byAge := h.do("GET", "/~alice/db/view/memory?sort=age", nil, nil) + require.Equal(t, http.StatusOK, byAge.Code) + body := byAge.Body.String() + assert.Less(t, strings.Index(body, "metered-link-calibration"), strings.Index(body, "handoff-2026-08-12"), + "age order is oldest first") +} + +// agoDays is the Memory page's spelling of a duration: the same ladder as ago up +// to a day, and then days all the way — "71 days ago", never "2 months ago", +// because 60 days is the number this page marks memories at. +func TestAgoDaysStopsAtDays(t *testing.T) { + pinClock(t, testNow) + + cases := []struct { + name string + d time.Duration + want string + }{ + {"the instant itself", 0, "just now"}, + {"seconds", 45 * time.Second, "just now"}, + {"exactly a minute", time.Minute, "1 minute ago"}, + {"minutes", 4 * time.Minute, "4 minutes ago"}, + {"exactly an hour", time.Hour, "1 hour ago"}, + {"hours", 3 * time.Hour, "3 hours ago"}, + {"one second short of a day", 24*time.Hour - time.Second, "23 hours ago"}, + {"exactly a day", 24 * time.Hour, "1 day ago"}, + {"the stale mark", 60 * 24 * time.Hour, "60 days ago"}, + {"where ago says two months", 71 * 24 * time.Hour, "71 days ago"}, + {"where ago says a year", 365 * 24 * time.Hour, "365 days ago"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, agoDays(testNow.Add(-c.d))) + }) + } + + // A commit stamped ahead of this host's clock is skew, not a scheduled event. + assert.Equal(t, "just now", agoDays(testNow.Add(48*time.Hour))) +} diff --git a/web/templates.go b/web/templates.go index df4659fbffbfd359306f0ca4b0a867df9b30af09..73521c9e5848b93f99d558e081621d9a19dcc348 100644 --- a/web/templates.go +++ b/web/templates.go @@ -108,6 +108,8 @@ func templateFuncs(icons map[string]template.HTML) template.FuncMap { // ago is the freshness line's relative time: past-facing, coarse, and never // negative. See the func for why it is not chrome's reltime. m["ago"] = ago + // agoDays is ago with the ladder stopped at days, for the Memory view. + m["agoDays"] = agoDays return m } @@ -151,6 +153,33 @@ func ago(t time.Time) string { } } +// agoDays is ago with the unit ladder stopped at days: "just now", "4 minutes +// ago", "3 hours ago", "71 days ago" — never "2 months ago". +// +// It exists because the Memory view asks a different question of the same +// duration. There, the number is what a reader judges a memory by ("is this note +// about a service that has since been rewritten?"), and the informative unit for +// that is days: "2 months ago" and "71 days ago" are the same instant, and only +// the second one can be compared against the 60-day mark the page marks memories +// at. ago itself is left alone — the freshness line shares it, and a line about +// how fresh a page is wants the coarse spelling. +// +// It reads the same swappable clock, so it is testable to the boundary rather +// than by eye, and it is past-facing for the same reason ago is. +func agoDays(t time.Time) string { + d := timeNow().Sub(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return plural(int(d/time.Minute), "minute") + " ago" + case d < 24*time.Hour: + return plural(int(d/time.Hour), "hour") + " ago" + default: + return plural(int(d/(24*time.Hour)), "day") + " ago" + } +} + // plural names a count in a unit, singular at one. func plural(n int, unit string) string { if n == 1 { diff --git a/web/templates/memory.html b/web/templates/memory.html new file mode 100644 index 0000000000000000000000000000000000000000..0e54d1dbc37fc212dbe0a5b46d01d1d6cb5f6e9b --- /dev/null +++ b/web/templates/memory.html @@ -0,0 +1,116 @@ +{{define "content" -}} + + +
+

~{{.Repo.OwnerName}}/{{.Repo.Name}} · memory

+{{template "beadsHead" (dict "Repo" .Repo "Ref" .Ref "Head" .Head)}} +{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "memory" "Ref" .Ref)}} + +
+ {{if .Ref}}{{end}} + {{/* The sort is not a filter, but this form is a GET: without carrying it, + searching from the review queue would answer in slug order. */}} + {{if eq .Data.Sort "age"}}{{end}} + + + {{/* Clear drops the search and the single-memory selection and keeps the ref + and the sort: the sort is how the list is read, not what it is filtered + to. */}} + {{if or .Data.Search .Data.Key}}Clear{{end}} +
+ +
+ {{.Data.Total}} {{if eq .Data.Total 1}}entry{{else}}entries{{end}} + sort: + {{if eq .Data.Sort "age"}} + slug + {{else}}slug{{end}} + · + {{if eq .Data.Sort "age"}}age + {{else}}age{{end}} +
+ +{{if .Data.Memories}} +{{range .Data.Memories}} +
+
+

{{.Slug}}

+ {{if .Stale}}stale?{{end}} + + {{with .Revision}}written {{.Date | agoDays}} + · {{.Commit | shortsha}} + {{- if .Author}} · {{.Author}}{{end}} + {{- else}}written before the last {{$.Data.WalkMax}} commits{{end}} + +
+
+ {{range .Paragraphs}}

{{.}}

{{end}} +
+
+{{end}} +{{else if .Data.Key}} +

No memory named {{.Data.Key}}.

+{{else if .Data.Search}} +

No memory matches “{{.Data.Search}}”.

+{{else}} +

No memories. bd remember writes them into the config table, and they appear here.

+{{end}} +
+{{- end}} diff --git a/web/web_test.go b/web/web_test.go index 9a1ff5f466edbd475c38f67b06916f53f3296b35..32dca2408820a8b6c2e9409d25c16ee12bafd7c0 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -241,6 +241,16 @@ type fakeSession struct { // (as the beads view needs). A named miss falls back to rows. A table absent // from a non-nil map is reported as ErrTableNotFound, mirroring the store. rowsByTable map[string]*browse.RowPage + // rowsByRef is rowsByTable per ref (ref → table → page), for the Memory + // view's revision walk: it reads the same table at several commits and the + // whole point is that the content differs between them. A ref absent here + // falls through to rowsByTable, so every other fixture is unaffected. + rowsByRef map[string]map[string]*browse.RowPage + // tableHashes is the content hash of a table at a ref, keyed "/". + // An absent entry is the store's answer for a table that does not exist + // there — which for the walk means "unchanged from the equally-absent + // neighbour", so a fixture that sets none skips every commit. + tableHashes map[string]string summary *browse.CommitDiff // logErr, when set, makes Log fail — a store whose history cannot be read, // which every page reading the log for decoration has to survive. @@ -258,7 +268,17 @@ func (s *fakeSession) Log(_ context.Context, _, _ string, _ int) ([]browse.Commi func (s *fakeSession) Tables(_ context.Context, _ string) ([]browse.TableInfo, error) { return s.tables, nil } -func (s *fakeSession) Rows(_ context.Context, _, table string, _, _ int) (*browse.RowPage, error) { +func (s *fakeSession) TableHash(_ context.Context, refStr, table string) (string, bool, error) { + h, ok := s.tableHashes[refStr+"/"+table] + return h, ok, nil +} +func (s *fakeSession) Rows(_ context.Context, ref, table string, _, _ int) (*browse.RowPage, error) { + if byTable, ok := s.rowsByRef[ref]; ok { + if p, ok := byTable[table]; ok { + return p, nil + } + return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table) + } if s.rowsByTable != nil { if p, ok := s.rowsByTable[table]; ok { return p, nil