coverage~bigbes/sr-ht-dolt3523280cbeads/memory.go

Coverage
92.5% 124/134 statements
Δ
+0.0
Blob
98951f1
1 package beads
2
3 import (
4 "context"
5 "fmt"
6 "net/url"
7 "regexp"
8 "sort"
9 "strings"
10 "time"
11
12 "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
13 )
14
15 // --- the memories bd remember writes ------------------------------------------
16
17 // memoryTable is where bd keeps them: the beads config table, as ordinary
18 // key/value rows. memoryPrefix marks the keys that are memories; the rest of the
19 // table is the tracker's settings (issue_prefix, compact_tier2_days, …).
20 const (
21 memoryTable = "config"
22 memoryPrefix = "kv.memory."
23 )
24
25 // memoryWalkMax bounds the revision walk (see walkMemories): at most this many
26 // commits back from the head of the ref. The most active tracker on this
27 // instance had 225 commits three weeks in, so this is roughly two months of
28 // headroom at that rate. A memory not attributed inside the walk carries no date
29 // at all rather than one the walk cannot support.
30 const memoryWalkMax = 500
31
32 // MemoryStaleAfter is how old a memory has to be before the page questions it.
33 // It is one constant and not a per-request knob, and what it produces is a
34 // question ("stale?") rather than a verdict: some memories are meant to be
35 // permanent, and only the reader knows which.
36 const MemoryStaleAfter = 60 * 24 * time.Hour
37
38 // MemorySession is the seam the memory projection reads through. It is wider
39 // than BrowseSession because a memory has no timestamp — the config row is
40 // (key, value) and nothing else — so the date has to come out of the history:
41 // Log for the commits and TableHash to skip the ones that did not touch config.
42 // It is still only what this projection calls, and web's BrowseSession and
43 // mcpsrv's (and *browse.DB itself) satisfy it structurally.
44 type MemorySession interface {
45 BrowseSession
46 // Log lists commits from the head of refStr, newest first.
47 Log(ctx context.Context, refStr, fromHash string, limit int) ([]browse.CommitInfo, string, error)
48 // TableHash is the content hash of a table at a ref, ok=false when the table
49 // does not exist there. It reads no rows, which is what makes the walk cheap.
50 TableHash(ctx context.Context, refStr, table string) (string, bool, error)
51 }
52
53 // MemoryRevision is when a memory's value last changed: the commit that wrote
54 // it, taken from the history rather than from the row.
55 type MemoryRevision struct {
56 Commit string
57 Date time.Time
58 Author string
59 }
60
61 // Memory is one `bd remember` entry.
62 type Memory struct {
63 Slug string // the config key with the "kv.memory." prefix stripped
64 // Text is the value with both newline spellings normalised. It is markdown —
65 // `bd remember` stores whatever was written, and what is written is the same
66 // markdown the memory files carry — and it is left as text here: this package
67 // renders nothing, and the split into paragraphs, lists and code blocks is
68 // the renderer's, not a projection's.
69 Text string
70 // Revision is the commit that last changed this key's value, or nil when the
71 // walk could not reach it (see MemoryView.WalkTruncated).
72 Revision *MemoryRevision
73 // Stale reports that the memory is older than MemoryStaleAfter. For a memory
74 // with no Revision it is set only when the walk's own oldest commit is
75 // already past the threshold — that much is known even without a date.
76 Stale bool
77 }
78
79 // MemoryView is the opaque .Data handed to memory.html.
80 type MemoryView struct {
81 Memories []Memory
82 Total int // memories in the tracker, before ?q= / ?key= narrowing
83 Search string // ?q=, sticky form state
84 Key string // ?key=<slug>, a single memory
85 Sort string // MemorySortSlug (default) | MemorySortAge
86 // Query is the request's query as parsed, carried so the sort toggle can
87 // rebuild this exact URL with one key replaced (web's withQuery) instead of
88 // re-listing the parameters it happens to know about.
89 Query url.Values
90 // WalkTruncated says the revision walk stopped at WalkMax commits with the
91 // history still going. It is the only way a Memory can carry no Revision, and
92 // it is what the page says instead of a date.
93 WalkTruncated bool
94 WalkMax int // memoryWalkMax, so the page can name the number it hit
95
96 // ConfigTruncated says a read of the config table came back clipped at
97 // beads.Max. That is a different clip from WalkTruncated, which bounds the
98 // history in commits; this one bounds a single read in rows, and it covers
99 // both reads this view makes: the one at ref that the memories themselves
100 // come from, and the per-commit ones the walk dates them by. A clipped read
101 // in the walk leaves the dates unsafe rather than the list short — a key
102 // whose row fell past the cap reads as absent there, which is
103 // indistinguishable from a key that had not been written yet.
104 ConfigTruncated bool
105 // ConfigShownOf is the config table's reported total at ref, clipped or not:
106 // rows that exist, against the at most Max that were read. It counts the
107 // whole table, memory keys and tracker settings alike, because that is what
108 // the cap applies to.
109 ConfigShownOf int
110 }
111
112 // ConfigClipped reports that the config read the memories themselves come from
113 // exceeded Max, so Memories and Total cover its first Max rows only and a
114 // memory may be missing from the list entirely. ConfigTruncated is the wider
115 // fact (any config read, here or in the walk, was clipped).
116 3 func (v *MemoryView) ConfigClipped() bool { return v.ConfigShownOf > Max }
117
118 // Memory sort orders. Slug is the default; Age is the review queue.
119 const (
120 MemorySortSlug = "slug"
121 MemorySortAge = "age"
122 )
123
124 // AppliesMemories fingerprints a tracker that can carry memories: the beads
125 // fingerprint plus a config table with key and value columns. Like every
126 // Applies, it sees table shapes and never rows, so a tracker whose config holds
127 // no memory at all still gets the tab and renders an empty state.
128 4 func AppliesMemories(tables []browse.TableInfo) bool {
129 4 if !Applies(tables) {
130 1 return false
131 1 }
132 11 for _, t := range tables {
133 11 if t.Name != memoryTable {
134 9 continue
135 }
136 2 var haveKey, haveValue bool
137 3 for _, c := range t.Columns {
138 3 switch c.Name {
139 2 case "key":
140 2 haveKey = true
141 1 case "value":
142 1 haveValue = true
143 }
144 }
145 2 return haveKey && haveValue
146 }
147 1 return false
148 }
149
150 // BuildMemories reads the memories at ref and dates each one from the history.
151 // now is the clock staleness is measured against, passed in rather than read
152 // here: this package renders nothing and reads no hidden clock, and a caller
153 // that pins its own clock (the web view, its tests) gets a deterministic answer.
154 //
155 // A missing config table degrades to no memories — the same treatment the other
156 // optional tables get — but a history that cannot be read is an error, not an
157 // empty answer: the date is what this view is for, and a page that silently
158 // dropped every date would look exactly like a tracker whose memories are all
159 // older than the walk.
160 21 func BuildMemories(ctx context.Context, sess MemorySession, ref string, query url.Values, now time.Time) (*MemoryView, error) {
161 21 view := &MemoryView{
162 21 Search: strings.TrimSpace(query.Get("q")),
163 21 Key: strings.TrimSpace(query.Get("key")),
164 21 Sort: parseMemorySort(query.Get("sort")),
165 21 Query: query,
166 21 WalkMax: memoryWalkMax,
167 21 }
168 21
169 21 rows, configTotal, err := readRowsOptional(ctx, sess, ref, memoryTable)
170 21 if err != nil {
171 0 return nil, err
172 0 }
173 21 view.ConfigShownOf = configTotal
174 21 view.ConfigTruncated = configTotal > Max
175 21 if rows == nil {
176 1 return view, nil
177 1 }
178
179 // The whole config table, narrowed to the memory keys. raw keeps the stored
180 // value under its full key: the walk compares values as they are stored, and
181 // normalising first would make two spellings of the same text look equal.
182 20 cols := indexCols(rows.Columns)
183 20 raw := map[string]string{}
184 20 texts := map[string]string{}
185 2060 for _, r := range rowsOf(rows) {
186 2060 key := cell(cols, r, "key")
187 2060 if !strings.HasPrefix(key, memoryPrefix) {
188 14 continue
189 }
190 2046 slug := strings.TrimPrefix(key, memoryPrefix)
191 2046 if slug == "" {
192 0 continue
193 }
194 2046 raw[key] = cell(cols, r, "value")
195 2046 texts[key] = normalizeMemoryText(raw[key])
196 }
197 20 view.Total = len(raw)
198 20
199 20 // Narrow before walking: the walk is per-key work, and a ?key= page has no
200 20 // business dating the other eight memories.
201 20 tracked := map[string]string{}
202 2046 for key, value := range raw {
203 2046 slug := strings.TrimPrefix(key, memoryPrefix)
204 2046 if view.Key != "" && slug != view.Key {
205 5 continue
206 }
207 2041 if !matchesMemorySearch(view.Search, slug, texts[key]) {
208 7 continue
209 }
210 2034 tracked[key] = value
211 }
212 20 if len(tracked) == 0 {
213 3 return view, nil
214 3 }
215
216 17 walk, err := walkMemories(ctx, sess, ref, tracked)
217 17 if err != nil {
218 1 return nil, err
219 1 }
220 16 view.WalkTruncated = walk.truncated
221 16 view.ConfigTruncated = view.ConfigTruncated || walk.configClipped
222 16
223 16 view.Memories = make([]Memory, 0, len(tracked))
224 2031 for key := range tracked {
225 2031 m := Memory{
226 2031 Slug: strings.TrimPrefix(key, memoryPrefix),
227 2031 Text: texts[key],
228 2031 }
229 2031 if rev, ok := walk.revisions[key]; ok {
230 2029 m.Revision = &rev
231 2029 m.Stale = now.Sub(rev.Date) > MemoryStaleAfter
232 2029 } else if !walk.oldest.IsZero() {
233 2 // No date, but a floor: the memory is at least as old as the oldest
234 2 // commit the walk examined. When that alone is past the threshold the
235 2 // question is supportable; otherwise it is not asked.
236 2 m.Stale = now.Sub(walk.oldest) > MemoryStaleAfter
237 2 }
238 2031 view.Memories = append(view.Memories, m)
239 }
240 16 sortMemories(view.Memories, view.Sort)
241 16
242 16 return view, nil
243 }
244
245 // memoryWalk is what walkMemories learned: the commit each key was last written
246 // by, whether the walk ran out of budget before the history ran out, and the
247 // date of the oldest commit it examined.
248 type memoryWalk struct {
249 revisions map[string]MemoryRevision
250 truncated bool
251 oldest time.Time
252 // configClipped says one of the per-commit config reads exceeded Max. It is
253 // a different bound from truncated — rows rather than commits — and it costs
254 // the attribution its footing: a key the read never reached looks like a key
255 // that commit had not written yet, which is precisely what the comparison
256 // below treats as a write.
257 configClipped bool
258 }
259
260 // walkMemories attributes each tracked key to the commit that last changed its
261 // value, walking the history of ref newest to oldest, at most memoryWalkMax
262 // commits.
263 //
264 // The trick that makes it affordable is the table hash. At each commit the
265 // content hash of config is O(1) and reads no rows; when it equals the hash at
266 // the newer neighbour, config is byte-identical across that step and the newer
267 // commit wrote no memory — skip, read nothing. Only a commit that actually
268 // touched config costs a row read, and then each still-unresolved key whose
269 // value differs from the newer neighbour's was written *by that newer commit*.
270 //
271 // The commit message is not the signal. `bd remember` does write
272 // "bd: remember (auto-commit) by <author>", but that is a claim by whoever wrote
273 // it; the table hash is the fact.
274 //
275 // A key still unresolved when the history itself runs out was present with this
276 // value at the root commit, so the root is what wrote it — that is a date the
277 // walk supports. A key unresolved because the walk hit its budget gets no date
278 // at all, and truncated says so.
279 //
280 // The walk reads the log's linearization (Log is reverse-topological). Beads
281 // histories are linear chains of auto-commits, which this is exact for; across a
282 // merge, attribution is to the nearest commit in that order.
283 17 func walkMemories(ctx context.Context, sess MemorySession, ref string, tracked map[string]string) (memoryWalk, error) {
284 17 out := memoryWalk{revisions: map[string]MemoryRevision{}}
285 17
286 17 commits, next, err := sess.Log(ctx, ref, "", memoryWalkMax)
287 17 if err != nil {
288 1 return memoryWalk{}, fmt.Errorf("beads: read history of %q: %w", ref, err)
289 1 }
290 16 if len(commits) == 0 {
291 0 return out, nil
292 0 }
293 16 out.oldest = commits[len(commits)-1].Date
294 16 out.truncated = next != ""
295 16
296 16 // unresolved carries each key's value at the newer neighbour of the commit
297 16 // being examined; it starts as the value at the head, which is where the
298 16 // memories themselves were read.
299 16 unresolved := make(map[string]string, len(tracked))
300 2031 for k, v := range tracked {
301 2031 unresolved[k] = v
302 2031 }
303
304 16 newerHash, err := memoryTableHash(ctx, sess, commits[0].Hash)
305 16 if err != nil {
306 0 return memoryWalk{}, err
307 0 }
308
309 1039 for i := 1; i < len(commits) && len(unresolved) > 0; i++ {
310 1039 hash, err := memoryTableHash(ctx, sess, commits[i].Hash)
311 1039 if err != nil {
312 0 return memoryWalk{}, err
313 0 }
314 1039 if hash == newerHash {
315 1018 continue // config unchanged across this step: nothing to read
316 }
317
318 21 older, olderTotal, err := memoryValuesAt(ctx, sess, commits[i].Hash)
319 21 if err != nil {
320 0 return memoryWalk{}, err
321 0 }
322 21 out.configClipped = out.configClipped || olderTotal > Max
323 21 newer := commits[i-1]
324 44 for key, newerValue := range unresolved {
325 44 if older[key] == newerValue {
326 22 continue
327 }
328 22 out.revisions[key] = MemoryRevision{
329 22 Commit: newer.Hash,
330 22 Date: newer.Date,
331 22 Author: newer.Author,
332 22 }
333 22 delete(unresolved, key)
334 }
335 22 for key := range unresolved {
336 22 unresolved[key] = older[key]
337 22 }
338 21 newerHash = hash
339 }
340
341 16 if !out.truncated {
342 14 // The history ended with these keys never changing: the oldest commit
343 14 // walked is the root, and it carries the value we are looking at.
344 14 root := commits[len(commits)-1]
345 2007 for key := range unresolved {
346 2007 out.revisions[key] = MemoryRevision{Commit: root.Hash, Date: root.Date, Author: root.Author}
347 2007 }
348 }
349
350 16 return out, nil
351 }
352
353 // memoryTableHash is the config table's content hash at one commit. A table that
354 // does not exist there is "" — an ordinary answer while walking backwards past
355 // the commit that created it, and one that compares correctly against another
356 // commit where it is equally absent.
357 1055 func memoryTableHash(ctx context.Context, sess MemorySession, at string) (string, error) {
358 1055 hash, ok, err := sess.TableHash(ctx, at, memoryTable)
359 1055 if err != nil {
360 0 return "", fmt.Errorf("beads: hash of %s at %s: %w", memoryTable, at, err)
361 0 }
362 1055 if !ok {
363 1 return "", nil
364 1 }
365 1054 return hash, nil
366 }
367
368 // memoryValuesAt reads the config table at one commit as key → value, and the
369 // total that read reported. A missing table is an empty map, which is what it
370 // means here: no key had a value yet.
371 //
372 // The total is returned rather than dropped because "no key had a value yet"
373 // and "the key sits past Max" arrive at this caller as the same empty slot, and
374 // only the total tells them apart.
375 21 func memoryValuesAt(ctx context.Context, sess MemorySession, at string) (map[string]string, int, error) {
376 21 rows, total, err := readRowsOptional(ctx, sess, at, memoryTable)
377 21 if err != nil {
378 0 return nil, 0, err
379 0 }
380 21 out := map[string]string{}
381 21 if rows == nil {
382 1 return out, total, nil
383 1 }
384 20 cols := indexCols(rows.Columns)
385 2060 for _, r := range rowsOf(rows) {
386 2060 if key := cell(cols, r, "key"); key != "" {
387 2060 out[key] = cell(cols, r, "value")
388 2060 }
389 }
390 20 return out, total, nil
391 }
392
393 // memoryEscapedNewline matches the two-character escapes that reach the value
394 // because the memory was typed into a shell string: "\r\n" and "\n" written out
395 // as backslash sequences rather than as newlines.
396 var memoryEscapedNewline = regexp.MustCompile(`\\r\\n|\\n`)
397
398 // normalizeMemoryText brings a stored value's two newline spellings together.
399 // The same memory arrives with real newlines when it was written from a file or
400 // a heredoc and with literal backslash-n when it was typed into a shell string,
401 // and both spellings turn up in the same tracker.
402 2046 func normalizeMemoryText(v string) string {
403 2046 v = strings.ReplaceAll(v, "\r\n", "\n")
404 2046 v = strings.ReplaceAll(v, "\r", "\n")
405 2046 v = memoryEscapedNewline.ReplaceAllString(v, "\n")
406 2046 return strings.TrimSpace(v)
407 2046 }
408
409 // matchesMemorySearch is the ?q= rule: a case-insensitive substring of the slug
410 // or of the text. An empty query matches everything.
411 2041 func matchesMemorySearch(q, slug, text string) bool {
412 2041 if q == "" {
413 2032 return true
414 2032 }
415 9 q = strings.ToLower(q)
416 9 return strings.Contains(strings.ToLower(slug), q) || strings.Contains(strings.ToLower(text), q)
417 }
418
419 // parseMemorySort reads ?sort=, defaulting (and falling back from an unknown
420 // value) to slug order.
421 21 func parseMemorySort(v string) string {
422 21 if strings.ToLower(strings.TrimSpace(v)) == MemorySortAge {
423 1 return MemorySortAge
424 1 }
425 20 return MemorySortSlug
426 }
427
428 // sortMemories orders the list: by slug, or oldest first for the review queue.
429 // In age order a memory with no revision leads — it is older than anything the
430 // walk could date — and ties fall back to the slug, so the order is total.
431 16 func sortMemories(ms []Memory, order string) {
432 27393 sort.SliceStable(ms, func(i, j int) bool {
433 27393 a, b := ms[i], ms[j]
434 27393 if order == MemorySortAge {
435 3 switch {
436 0 case a.Revision == nil && b.Revision != nil:
437 0 return true
438 0 case a.Revision != nil && b.Revision == nil:
439 0 return false
440 3 case a.Revision != nil && b.Revision != nil && !a.Revision.Date.Equal(b.Revision.Date):
441 3 return a.Revision.Date.Before(b.Revision.Date)
442 }
443 }
444 27390 return a.Slug < b.Slug
445 })
446 }