From 043b0fd7ade4c7efca7ece081fcc4cabb195534a Mon Sep 17 00:00:00 2001
From: Eugene Blikh
Date: Thu, 13 Aug 2026 09:12:34 +0300
Subject: [PATCH] web: answer what is ready across every tracker
---
beads/build.go | 68 +----
beads/milestones.go | 23 +-
beads/ready.go | 492 +++++++++++++++++++++++++++++++++++++
beads/ready_test.go | 477 +++++++++++++++++++++++++++++++++++
beads/rows.go | 99 ++++++++
web/adapters.go | 4 +
web/deps.go | 7 +
web/handlers_ready.go | 90 +++++++
web/handlers_ready_test.go | 347 ++++++++++++++++++++++++++
web/router.go | 12 +
web/templates/index.html | 7 +-
web/templates/ready.html | 168 +++++++++++++
web/web_test.go | 50 +++-
13 files changed, 1762 insertions(+), 82 deletions(-)
create mode 100644 beads/ready.go
create mode 100644 beads/ready_test.go
create mode 100644 web/handlers_ready.go
create mode 100644 web/handlers_ready_test.go
create mode 100644 web/templates/ready.html
diff --git a/beads/build.go b/beads/build.go
index d4778b76eb4c8a4ea2e653a1ffbfdfdc78f0ce61..e0fd6eed21bfc7fae4caf70812e90e239b39f425 100644
--- a/beads/build.go
+++ b/beads/build.go
@@ -33,64 +33,18 @@ func Build(ctx context.Context, sess BrowseSession, ref string, query url.Values
shownOf := issuesTotal
// status name → category, from custom_statuses (may be empty → heuristics).
- catByStatus := map[string]string{}
- if statuses != nil {
- nameIdx := statuses.Columns
- cols := indexCols(nameIdx)
- for _, r := range statuses.Rows {
- name := cell(cols, r, "name")
- cat := cell(cols, r, "category")
- if name != "" {
- catByStatus[strings.ToLower(name)] = strings.ToLower(cat)
- }
- }
- }
+ catByStatus := indexStatusCategories(statuses)
// issue id → category, needed to decide whether a blocking target is "open".
issueCols := indexCols(issues.Columns)
- catByIssue := make(map[string]string, len(issues.Rows))
- for _, r := range issues.Rows {
- id := cell(issueCols, r, "id")
- catByIssue[id] = statusCategory(cell(issueCols, r, "status"), catByStatus)
- }
+ catByIssue := indexIssueCategories(issues, issueCols, catByStatus)
// Aggregate dependency edges by issue.
depCols := indexCols(deps.Columns)
- blockedByCount := map[string]int{} // issue_id → #deps it has
- blocksCount := map[string]int{} // depends_on_issue_id → #deps aimed at it
- blockedOpen := map[string]bool{} // issue_id → has an open blocking dep
- for _, r := range deps.Rows {
- from := cell(depCols, r, "issue_id")
- to := cell(depCols, r, "depends_on_issue_id")
- typ := strings.ToLower(cell(depCols, r, "type"))
- if from != "" {
- blockedByCount[from]++
- }
- if to != "" {
- blocksCount[to]++
- }
- if from != "" && typ == "blocks" {
- // A "blocks" edge to a still-open target blocks the source. parent-child
- // is hierarchy, not a blocker — a subtask is not blocked by its (open)
- // epic, matching bd's own is_blocked/ready accounting.
- if catByIssue[to] != "closed" {
- blockedOpen[from] = true
- }
- }
- }
+ depIdx := indexDeps(deps, catByIssue)
// labels: issue_id → [label]
- labelsByIssue := map[string][]string{}
- if labels != nil {
- lcols := indexCols(labels.Columns)
- for _, r := range labels.Rows {
- id := cell(lcols, r, "issue_id")
- lb := cell(lcols, r, "label")
- if id != "" && lb != "" {
- labelsByIssue[id] = append(labelsByIssue[id], lb)
- }
- }
- }
+ labelsByIssue := indexLabels(labels)
// Detail mode: a named issue short-circuits the board build.
if want := query.Get("issue"); want != "" {
@@ -118,12 +72,10 @@ func Build(ctx context.Context, sess BrowseSession, ref string, query url.Values
continue
}
cat := catByIssue[id]
- blocked := truthy(cell(issueCols, r, "is_blocked")) || blockedOpen[id]
- // "Ready" mirrors bd's ready set: open (not in-progress/closed), unblocked,
- // and not a template/ephemeral scaffold. Derived in-process (the issues
- // data is already loaded) rather than reading the full ready_issues table.
- ready := cat == "open" && !blocked &&
- !truthy(cell(issueCols, r, "is_template")) && !truthy(cell(issueCols, r, "ephemeral"))
+ blocked := truthy(cell(issueCols, r, "is_blocked")) || depIdx.blockedOpen[id]
+ // "Ready" mirrors bd's ready set; readyRow is the one copy of the rule,
+ // shared with the cross-database ready page.
+ ready := readyRow(cat, blocked, r, issueCols)
if filter.Ready && !ready {
continue
}
@@ -134,8 +86,8 @@ func Build(ctx context.Context, sess BrowseSession, ref string, query url.Values
Priority: cell(issueCols, r, "priority"),
Assignee: cell(issueCols, r, "assignee"),
Labels: labelsByIssue[id],
- BlockedBy: blockedByCount[id],
- Blocks: blocksCount[id],
+ BlockedBy: depIdx.blockedByCount[id],
+ Blocks: depIdx.blocksCount[id],
Ready: ready,
StartedAt: cell(issueCols, r, "started_at"),
ClosedAt: cell(issueCols, r, "closed_at"),
diff --git a/beads/milestones.go b/beads/milestones.go
index fe3710d443593b8c9e5dfc083600cabc253cad16..335106aa0ed87f83f3b027f9a0a64774d458cf6c 100644
--- a/beads/milestones.go
+++ b/beads/milestones.go
@@ -76,27 +76,8 @@ func BuildMilestones(ctx context.Context, sess BrowseSession, ref string) (*Mile
}
}
- catByStatus := map[string]string{}
- if statuses != nil {
- cols := indexCols(statuses.Columns)
- for _, r := range statuses.Rows {
- if name := cell(cols, r, "name"); name != "" {
- catByStatus[strings.ToLower(name)] = strings.ToLower(cell(cols, r, "category"))
- }
- }
- }
-
- labelsByIssue := map[string][]string{}
- if labels != nil {
- cols := indexCols(labels.Columns)
- for _, r := range labels.Rows {
- id := cell(cols, r, "issue_id")
- lb := cell(cols, r, "label")
- if id != "" && lb != "" {
- labelsByIssue[id] = append(labelsByIssue[id], lb)
- }
- }
- }
+ catByStatus := indexStatusCategories(statuses)
+ labelsByIssue := indexLabels(labels)
issueCols := indexCols(issues.Columns)
byLabel := map[string]*MilestoneDetail{}
diff --git a/beads/ready.go b/beads/ready.go
new file mode 100644
index 0000000000000000000000000000000000000000..3d68aaa5055dcca736fb51cf4c960d1c354b146e
--- /dev/null
+++ b/beads/ready.go
@@ -0,0 +1,492 @@
+package beads
+
+import (
+ "context"
+ "net/url"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
+)
+
+// --- the cross-database ready set ---------------------------------------------
+//
+// "What is ready to work" is answerable in each beads database on the instance
+// and, until this, nowhere across them — which is the question the split into a
+// global tracker plus per-project trackers was supposed to make askable.
+//
+// This is one function with two consumers: the /ready page renders it, and the
+// MCP surface's ready_work with no database named calls it. Two implementations
+// would answer differently the first time the ready rule moved.
+//
+// Authorization is not here. By the time a caller reaches this function it has
+// already decided which databases this caller may browse (see the package
+// comment: this package renders nothing and authorizes nothing); handing it a
+// database is the statement that the caller may read it.
+
+const (
+ // ReadyMaxDatabases bounds how many databases one call opens. Opening N
+ // stores per request is exactly what the per-request browse discipline does
+ // not scale to, and a page that hit this ceiling says so: a silent cap reads
+ // as "that is everything".
+ ReadyMaxDatabases = 64
+
+ // ReadyCacheTTL is how long a cached projection stands regardless of the head
+ // hash. The head-hash gate is what makes the cache correct; the TTL is what
+ // makes it impossible for the cache to be the reason a reader sees yesterday's
+ // answer.
+ ReadyCacheTTL = 60 * time.Second
+
+ // readyCacheMaxEntries bounds the cache by entry count. A cache is not a
+ // store: over the ceiling it drops what has expired and, failing that, starts
+ // again, rather than growing with the instance.
+ readyCacheMaxEntries = 256
+)
+
+// ReadyDatabase names one database to consider. ID is the identity the cache is
+// keyed on — the repository row id, which no two databases share and which
+// survives a rename.
+//
+// The field names are OwnerName and Name deliberately: the freshness partial the
+// beads views render is handed a database here rather than a repository, and a
+// partial that reads .OwnerName must find it on both.
+type ReadyDatabase struct {
+ ID int
+ OwnerName string
+ Name string
+}
+
+// Slug is the database's "owner/name" address — what ?db= names and what the
+// group headers show.
+func (d ReadyDatabase) Slug() string { return d.OwnerName + "/" + d.Name }
+
+// ReadySession is the read-only surface one database is read through. It is
+// BrowseSession (the rows) plus the three things this aggregation needs that a
+// projection of a single, already-opened database does not: the branch list (for
+// the head hash the cache gates on), the table list (for the fingerprint), the
+// log (for the group's own freshness line) — and Close, because here the
+// aggregation owns the session's lifetime.
+//
+// web's BrowseSession and *browse.DB satisfy it structurally.
+type ReadySession interface {
+ BrowseSession
+ Branches(ctx context.Context) ([]browse.Branch, error)
+ Tables(ctx context.Context, refStr string) ([]browse.TableInfo, error)
+ Log(ctx context.Context, refStr, fromHash string, limit int) ([]browse.CommitInfo, string, error)
+ Close() error
+}
+
+// ReadyOpener opens one database. The returned session is closed by this
+// package — a caller that kept it would be hoarding a file handle and a memory
+// mapping, which is what per-request opening exists to avoid.
+type ReadyOpener func(ctx context.Context, db ReadyDatabase) (ReadySession, error)
+
+// ReadyGroup is one database's ready work.
+type ReadyGroup struct {
+ Database ReadyDatabase
+ Ref string // the branch the ready set was read from
+ Head *browse.CommitInfo // that branch's head, or nil when it cannot be read
+ Cards []Card // ready issues, priority then id
+}
+
+// ReadyFailure is one database that could not be read. It carries the error for
+// the caller's log and nothing for the reader: an error string on a page is how
+// a store path and a dolt internal end up in a browser (sr-ht-dolt-7ta).
+type ReadyFailure struct {
+ Database ReadyDatabase
+ Err error
+}
+
+// ReadyView is the whole answer: the groups, what was considered, and the two
+// facts a reader needs in order not to over-read it (the ceiling, and that some
+// databases could not be read).
+type ReadyView struct {
+ Groups []ReadyGroup
+ Total int // ready cards across every group, after filtering
+ Considered int // databases actually opened (or served from cache)
+ Capped bool // there were more candidates than Max
+ Max int // ReadyMaxDatabases, so the page can name the number it hit
+ Failed []ReadyFailure
+ Filter ReadyFilter
+ Options ReadyOptions
+ // Query is the request's query as parsed, carried so a link 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
+}
+
+// ReadyOptions lists the distinct values present across the ready set, so the
+// filter dropdowns offer only real choices. Collected before the card filters
+// narrow anything, so the options do not shrink as a filter is applied.
+type ReadyOptions struct {
+ Assignees []string
+ Priorities []string
+}
+
+// ReadyFilter is the page's filter state: ?q=, ?assignee=, ?priority= over the
+// cards, and a repeatable ?db=/ over the databases.
+type ReadyFilter struct {
+ Query string
+ Assignee string
+ Priority string
+ Databases []string // empty means every database the caller was given
+}
+
+// ParseReadyFilter reads the filter out of a request query.
+func ParseReadyFilter(q url.Values) ReadyFilter {
+ f := ReadyFilter{
+ Query: strings.TrimSpace(q.Get("q")),
+ Assignee: strings.TrimSpace(q.Get("assignee")),
+ Priority: strings.TrimSpace(q.Get("priority")),
+ }
+ for _, d := range q["db"] {
+ if d = strings.TrimSpace(d); d != "" {
+ f.Databases = append(f.Databases, d)
+ }
+ }
+ return f
+}
+
+// Active reports whether any filter is set (drives the "Clear" link and the
+// empty-page wording).
+func (f ReadyFilter) Active() bool {
+ return f.Query != "" || f.Assignee != "" || f.Priority != "" || len(f.Databases) > 0
+}
+
+// selects reports whether a database is one of the ones asked for. No ?db= at
+// all means every database the caller was handed.
+func (f ReadyFilter) selects(slug string) bool {
+ if len(f.Databases) == 0 {
+ return true
+ }
+ for _, d := range f.Databases {
+ if d == slug {
+ return true
+ }
+ }
+ return false
+}
+
+// matches reports whether one ready card passes every set card filter.
+func (f ReadyFilter) matches(c Card) bool {
+ if f.Assignee != "" && c.Assignee != f.Assignee {
+ return false
+ }
+ if f.Priority != "" && c.Priority != f.Priority {
+ return false
+ }
+ if f.Query != "" {
+ hay := strings.ToLower(c.ID + " " + c.Title)
+ if !strings.Contains(hay, strings.ToLower(f.Query)) {
+ return false
+ }
+ }
+ return true
+}
+
+// ReadyCache holds one projection per database, keyed by the repository id and
+// gated on the head hash. It is deliberately small: a mutex, a map, and the two
+// bounds above.
+//
+// What it holds is the projection — the ready cards — and never an open
+// session. An open store is a file handle and a memory mapping; caching those is
+// the thing per-request opening exists to prevent.
+//
+// The zero value is not usable; call NewReadyCache. A nil *ReadyCache is a
+// programming error and panics on first use rather than quietly disabling the
+// bound it exists to enforce.
+type ReadyCache struct {
+ mu sync.Mutex
+ entries map[int]readyEntry
+}
+
+// readyEntry is one database's cached projection.
+type readyEntry struct {
+ head string // the branch head the projection was read at; the gate
+ ref string // the branch it was read from
+ at time.Time // when it was stored; the TTL
+ beads bool // the fingerprint held — a false entry is a database to skip
+ // commit is the head commit for the group's freshness line. It cannot go
+ // stale under an unmoved head, so it is cached with the cards and a cache hit
+ // reads no log either.
+ commit *browse.CommitInfo
+ cards []Card
+}
+
+// NewReadyCache returns an empty cache.
+func NewReadyCache() *ReadyCache {
+ return &ReadyCache{entries: map[int]readyEntry{}}
+}
+
+// lookup returns the cached projection for a database when it was read at the
+// same head and has not expired. Both conditions, not either: the head hash is
+// what makes it correct, the TTL is what makes it bounded.
+func (c *ReadyCache) lookup(id int, head string, now time.Time) (readyEntry, bool) {
+ if head == "" {
+ // A database whose head cannot be named cannot be gated on one.
+ return readyEntry{}, false
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ e, ok := c.entries[id]
+ if !ok || e.head != head || now.Sub(e.at) >= ReadyCacheTTL {
+ return readyEntry{}, false
+ }
+ return e, true
+}
+
+// store records a projection, dropping expired entries — and, if that was not
+// enough, everything — when the map is at its ceiling.
+func (c *ReadyCache) store(id int, e readyEntry) {
+ if e.head == "" {
+ return
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if len(c.entries) >= readyCacheMaxEntries {
+ for k, old := range c.entries {
+ if e.at.Sub(old.at) >= ReadyCacheTTL {
+ delete(c.entries, k)
+ }
+ }
+ if len(c.entries) >= readyCacheMaxEntries {
+ c.entries = make(map[int]readyEntry, readyCacheMaxEntries)
+ }
+ }
+ c.entries[id] = e
+}
+
+// ReadyAcross collects the ready set of every database it is handed, grouped by
+// database: groups ordered by ready count desc then name, cards inside a group
+// by priority then id, and a database with no ready work absent rather than
+// shown empty.
+//
+// now is the clock the TTL is measured against, passed in rather than read here
+// for the reason BuildMemories takes one: this package reads no hidden clock,
+// and a caller that pins its own gets a deterministic answer.
+//
+// It returns no error. One database that cannot be opened or read costs itself
+// only — it lands in Failed for the caller's log — because a page that 500s
+// because the seventeenth store is corrupt answers nothing about the other
+// sixteen.
+func ReadyAcross(
+ ctx context.Context,
+ dbs []ReadyDatabase,
+ open ReadyOpener,
+ cache *ReadyCache,
+ filter ReadyFilter,
+ now time.Time,
+) *ReadyView {
+ view := &ReadyView{Filter: filter, Max: ReadyMaxDatabases}
+
+ // ?db= narrows the candidates before the ceiling applies: a database the
+ // caller named is the one thing the cap must not be able to drop.
+ candidates := make([]ReadyDatabase, 0, len(dbs))
+ for _, d := range dbs {
+ if filter.selects(d.Slug()) {
+ candidates = append(candidates, d)
+ }
+ }
+ if len(candidates) > ReadyMaxDatabases {
+ // The first Max in the order the caller listed them, which is the caller's
+ // own ordering (newest first, as the listing produces it) and stable
+ // across requests.
+ candidates = candidates[:ReadyMaxDatabases]
+ view.Capped = true
+ }
+ view.Considered = len(candidates)
+
+ assignees, priorities := map[string]bool{}, map[string]bool{}
+ for _, d := range candidates {
+ entry, err := readyProjection(ctx, d, open, cache, now)
+ if err != nil {
+ view.Failed = append(view.Failed, ReadyFailure{Database: d, Err: err})
+ continue
+ }
+ if !entry.beads {
+ // Not a beads database (or a store with no branches at all): skipped
+ // silently, exactly as the view tabs skip it.
+ continue
+ }
+ cards := make([]Card, 0, len(entry.cards))
+ for _, c := range entry.cards {
+ if c.Assignee != "" {
+ assignees[c.Assignee] = true
+ }
+ if c.Priority != "" {
+ priorities[c.Priority] = true
+ }
+ if filter.matches(c) {
+ cards = append(cards, c)
+ }
+ }
+ if len(cards) == 0 {
+ // Nothing ready here: absent from the page rather than an empty group.
+ continue
+ }
+ view.Groups = append(view.Groups, ReadyGroup{
+ Database: d,
+ Ref: entry.ref,
+ Head: entry.commit,
+ Cards: cards,
+ })
+ view.Total += len(cards)
+ }
+
+ sort.SliceStable(view.Groups, func(i, j int) bool {
+ ni, nj := len(view.Groups[i].Cards), len(view.Groups[j].Cards)
+ if ni != nj {
+ return ni > nj
+ }
+ return view.Groups[i].Database.Slug() < view.Groups[j].Database.Slug()
+ })
+ view.Options = ReadyOptions{
+ Assignees: sortedKeys(assignees),
+ Priorities: sortedKeys(priorities), // single digits sort numerically as strings
+ }
+ return view
+}
+
+// readyProjection returns one database's ready cards, from the cache when the
+// head has not moved and by reading the store otherwise.
+//
+// The order is the whole point of the gate: open, list branches, and only then
+// consult the cache. Opening a session and reading the branch list is cheap;
+// reading and projecting issues + dependencies is not, and on a hit neither the
+// tables, nor the rows, nor the log are touched.
+func readyProjection(
+ ctx context.Context,
+ d ReadyDatabase,
+ open ReadyOpener,
+ cache *ReadyCache,
+ now time.Time,
+) (readyEntry, error) {
+ sess, err := open(ctx, d)
+ if err != nil {
+ return readyEntry{}, err
+ }
+ defer sess.Close()
+
+ branches, err := sess.Branches(ctx)
+ if err != nil {
+ return readyEntry{}, err
+ }
+ ref := browse.DefaultBranch(branches)
+ if ref == "" {
+ // A store with no branches carries no tables either: nothing to skip past
+ // and nothing to cache.
+ return readyEntry{}, nil
+ }
+ head := headHashOf(branches, ref)
+ if e, ok := cache.lookup(d.ID, head, now); ok {
+ return e, nil
+ }
+
+ tables, err := sess.Tables(ctx, ref)
+ if err != nil {
+ return readyEntry{}, err
+ }
+ entry := readyEntry{head: head, ref: ref, at: now, beads: Applies(tables)}
+ if !entry.beads {
+ // Cached too: a database that is not a tracker is not one on the next
+ // request either, and the fingerprint is worth exactly one table listing.
+ cache.store(d.ID, entry)
+ return entry, nil
+ }
+
+ cards, err := readyCards(ctx, sess, ref)
+ if err != nil {
+ return readyEntry{}, err
+ }
+ entry.cards = cards
+ entry.commit = readyHead(ctx, sess, ref)
+ cache.store(d.ID, entry)
+ return entry, nil
+}
+
+// headHashOf returns the head hash of the named branch, or "" when the branch
+// list does not carry it. An empty hash disables the cache for that database
+// rather than letting an entry stand un-gated.
+func headHashOf(branches []browse.Branch, ref string) string {
+ for _, b := range branches {
+ if b.Name == ref {
+ return b.Head
+ }
+ }
+ return ""
+}
+
+// readyHead reads the head commit of ref for the group's freshness line, and
+// returns nil rather than an error on both failure arms. "Ready" read from a
+// store that stopped receiving pushes is exactly the claim this page must not
+// make silently — but a log that cannot be read may not be the reason the ready
+// set is withheld either, so the line is simply absent (the partial renders
+// nothing for nil).
+func readyHead(ctx context.Context, sess ReadySession, ref string) *browse.CommitInfo {
+ commits, _, err := sess.Log(ctx, ref, "", 1)
+ if err != nil || len(commits) == 0 {
+ return nil
+ }
+ return &commits[0]
+}
+
+// readyCards projects one database's ready set: the same tables the board reads,
+// through the same ready rule (readyRow), sorted priority then id.
+func readyCards(ctx context.Context, sess BrowseSession, ref string) ([]Card, error) {
+ issues, _, err := readRows(ctx, sess, ref, "issues")
+ if err != nil {
+ return nil, err
+ }
+ deps, _, err := readRows(ctx, sess, ref, "dependencies")
+ if err != nil {
+ return nil, err
+ }
+ labels, _, _ := readRowsOptional(ctx, sess, ref, "labels")
+ statuses, _, _ := readRowsOptional(ctx, sess, ref, "custom_statuses")
+
+ catByStatus := indexStatusCategories(statuses)
+ issueCols := indexCols(issues.Columns)
+ catByIssue := indexIssueCategories(issues, issueCols, catByStatus)
+ depIdx := indexDeps(deps, catByIssue)
+ labelsByIssue := indexLabels(labels)
+
+ var cards []Card
+ for _, r := range issues.Rows {
+ id := cell(issueCols, r, "id")
+ cat := catByIssue[id]
+ blocked := truthy(cell(issueCols, r, "is_blocked")) || depIdx.blockedOpen[id]
+ if !readyRow(cat, blocked, r, issueCols) {
+ continue
+ }
+ cards = append(cards, Card{
+ ID: id,
+ Title: cell(issueCols, r, "title"),
+ Type: cell(issueCols, r, "issue_type"),
+ Priority: cell(issueCols, r, "priority"),
+ Assignee: cell(issueCols, r, "assignee"),
+ Labels: labelsByIssue[id],
+ BlockedBy: depIdx.blockedByCount[id],
+ Blocks: depIdx.blocksCount[id],
+ Ready: true,
+ Category: cat,
+ })
+ }
+ sortReadyCards(cards)
+ return cards, nil
+}
+
+// sortReadyCards orders a group by priority (0 = highest first), then id. The
+// board sorts by created_at between the two; here the id is the tie-break,
+// because across databases the created_at of one tracker says nothing about the
+// order of another's.
+func sortReadyCards(cards []Card) {
+ sort.SliceStable(cards, func(i, j int) bool {
+ pi, pj := priorityRank(cards[i].Priority), priorityRank(cards[j].Priority)
+ if pi != pj {
+ return pi < pj
+ }
+ return cards[i].ID < cards[j].ID
+ })
+}
diff --git a/beads/ready_test.go b/beads/ready_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..725ee6427f02176b04fc98ef474d32ed029fe6b6
--- /dev/null
+++ b/beads/ready_test.go
@@ -0,0 +1,477 @@
+package beads
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
+)
+
+// --- fixtures ----------------------------------------------------------------
+
+// readyNow is the instant the aggregation's clock is pinned to. Nothing about it
+// is special beyond being fixed: the TTL is a function of the fixture rather
+// than of when the suite ran.
+var readyNow = time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)
+
+// fakeReadyDB is one database behind the ReadyOpener seam, counting what each
+// call actually read. The counters are the point: the head-hash gate is a claim
+// about reads not happening, and only a counter can check it — a timing
+// measurement would pass on a fast machine whatever the code did.
+type fakeReadyDB struct {
+ branches []browse.Branch
+ tables []browse.TableInfo
+ rows map[string]*browse.RowPage
+ commits []browse.CommitInfo
+
+ openErr error
+ branchesErr error
+ logErr error
+
+ opens int
+ closes int
+ rowReads int
+ tableReads int
+ logReads int
+}
+
+func (f *fakeReadyDB) Branches(context.Context) ([]browse.Branch, error) {
+ if f.branchesErr != nil {
+ return nil, f.branchesErr
+ }
+ return f.branches, nil
+}
+
+func (f *fakeReadyDB) Tables(context.Context, string) ([]browse.TableInfo, error) {
+ f.tableReads++
+ return f.tables, nil
+}
+
+func (f *fakeReadyDB) Log(_ context.Context, _, _ string, _ int) ([]browse.CommitInfo, string, error) {
+ f.logReads++
+ if f.logErr != nil {
+ return nil, "", f.logErr
+ }
+ return f.commits, "", nil
+}
+
+func (f *fakeReadyDB) Rows(_ context.Context, _, table string, _, _ int) (*browse.RowPage, error) {
+ f.rowReads++
+ if p, ok := f.rows[table]; ok {
+ return p, nil
+ }
+ return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table)
+}
+
+func (f *fakeReadyDB) Close() error { f.closes++; return nil }
+
+// readyInstance is a set of databases addressed by repository id, plus the
+// opener over them.
+type readyInstance struct {
+ dbs map[int]*fakeReadyDB
+}
+
+func (in *readyInstance) open(_ context.Context, d ReadyDatabase) (ReadySession, error) {
+ f, ok := in.dbs[d.ID]
+ if !ok {
+ return nil, fmt.Errorf("no such database: %s", d.Slug())
+ }
+ if f.openErr != nil {
+ return nil, f.openErr
+ }
+ f.opens++
+ return f, nil
+}
+
+// readyTracker builds a beads database whose issues page carries the given rows.
+// The column order puts is_blocked, is_template and ephemeral last so the
+// projection's name-based (not positional) cell mapping is exercised.
+func readyTracker(head string, issues [][]string, deps [][]string) *fakeReadyDB {
+ return &fakeReadyDB{
+ branches: []browse.Branch{{Name: "main", Head: head}},
+ tables: beadsTables(),
+ commits: []browse.CommitInfo{{
+ Hash: head, Author: "Eugene Blikh", Date: readyNow.Add(-4 * time.Minute),
+ Message: "bd: create (auto-commit)",
+ }},
+ rows: map[string]*browse.RowPage{
+ "issues": {
+ Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "is_blocked", "is_template", "ephemeral"},
+ Rows: issues,
+ Total: len(issues),
+ },
+ "dependencies": {
+ Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
+ Rows: deps,
+ Total: len(deps),
+ },
+ },
+ }
+}
+
+// readyFixture is two trackers: one with three ready issues among six, one with
+// a single ready issue.
+func readyFixture() (*readyInstance, []ReadyDatabase) {
+ alpha := readyTracker("h-alpha", [][]string{
+ {"a-1", "Ready, top priority", "open", "0", "task", "alice", "0", "0", "0"},
+ {"a-2", "Ready, lower", "open", "2", "bug", "bob", "0", "0", "0"},
+ {"a-3", "Ready, same priority as a-2", "open", "2", "task", "alice", "0", "0", "0"},
+ {"a-4", "In progress, not ready", "in_progress", "0", "task", "alice", "0", "0", "0"},
+ {"a-5", "Blocked, not ready", "open", "0", "task", "bob", "1", "0", "0"},
+ {"a-6", "Closed, not ready", "closed", "0", "task", "bob", "0", "0", "0"},
+ }, nil)
+ beta := readyTracker("h-beta", [][]string{
+ {"b-1", "The one ready thing", "open", "1", "task", "carol", "0", "0", "0"},
+ {"b-2", "A template scaffold", "open", "0", "task", "carol", "0", "1", "0"},
+ {"b-3", "An ephemeral scratch", "open", "0", "task", "carol", "0", "0", "1"},
+ }, nil)
+ in := &readyInstance{dbs: map[int]*fakeReadyDB{1: alpha, 2: beta}}
+ return in, []ReadyDatabase{
+ {ID: 1, OwnerName: "alice", Name: "alpha"},
+ {ID: 2, OwnerName: "bob", Name: "beta"},
+ }
+}
+
+func across(in *readyInstance, dbs []ReadyDatabase, cache *ReadyCache, f ReadyFilter, now time.Time) *ReadyView {
+ return ReadyAcross(context.Background(), dbs, in.open, cache, f, now)
+}
+
+// --- tests -------------------------------------------------------------------
+
+// The projection: which issues are ready, how the groups are ordered, how the
+// cards inside one are, and that each group carries its own database's head.
+func TestReadyAcrossGroupsAndOrders(t *testing.T) {
+ in, dbs := readyFixture()
+
+ view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)
+
+ require.Len(t, view.Groups, 2)
+ assert.Equal(t, 4, view.Total)
+ assert.Equal(t, 2, view.Considered)
+ assert.False(t, view.Capped)
+ assert.Empty(t, view.Failed)
+
+ // Groups: ready count desc, then name.
+ assert.Equal(t, "alice/alpha", view.Groups[0].Database.Slug())
+ assert.Equal(t, "bob/beta", view.Groups[1].Database.Slug())
+
+ // Cards: priority, then id. In-progress, blocked, closed, template and
+ // ephemeral issues are not ready.
+ var ids []string
+ for _, c := range view.Groups[0].Cards {
+ ids = append(ids, c.ID)
+ assert.True(t, c.Ready, "%s must be marked ready", c.ID)
+ }
+ assert.Equal(t, []string{"a-1", "a-2", "a-3"}, ids)
+ require.Len(t, view.Groups[1].Cards, 1)
+ assert.Equal(t, "b-1", view.Groups[1].Cards[0].ID)
+
+ // Each group carries its own database's head, so "ready" from a store that
+ // stopped receiving pushes is not claimed silently.
+ require.NotNil(t, view.Groups[0].Head)
+ assert.Equal(t, "h-alpha", view.Groups[0].Head.Hash)
+ assert.Equal(t, "main", view.Groups[0].Ref)
+ require.NotNil(t, view.Groups[1].Head)
+ assert.Equal(t, "h-beta", view.Groups[1].Head.Hash)
+
+ // The filter options come from the whole ready set.
+ assert.Equal(t, []string{"alice", "bob", "carol"}, view.Options.Assignees)
+ assert.Equal(t, []string{"0", "1", "2"}, view.Options.Priorities)
+
+ // Every session opened is closed again: this page opens N stores, and one
+ // leaked handle per request per database is the failure mode it is bounded
+ // against.
+ for id, f := range in.dbs {
+ assert.Equal(t, f.opens, f.closes, "database %d: %d opens, %d closes", id, f.opens, f.closes)
+ }
+}
+
+// A "blocks" edge to a still-open target blocks its source; the same edge to a
+// closed one does not. This is the ready rule the board shares, exercised
+// through the aggregation rather than restated in it.
+func TestReadyAcrossHonoursOpenBlockers(t *testing.T) {
+ tracker := readyTracker("h1", [][]string{
+ {"i-1", "Blocked by an open issue", "open", "1", "task", "alice", "0", "0", "0"},
+ {"i-2", "The open blocker", "open", "1", "task", "alice", "0", "0", "0"},
+ {"i-3", "Blocked by a closed issue", "open", "1", "task", "alice", "0", "0", "0"},
+ {"i-4", "The closed blocker", "closed", "1", "task", "alice", "0", "0", "0"},
+ {"i-5", "A subtask of an open epic", "open", "1", "task", "alice", "0", "0", "0"},
+ {"i-6", "The epic", "open", "1", "epic", "alice", "0", "0", "0"},
+ }, [][]string{
+ {"d1", "i-1", "i-2", "blocks"},
+ {"d2", "i-3", "i-4", "blocks"},
+ {"d3", "i-5", "i-6", "parent-child"},
+ })
+ in := &readyInstance{dbs: map[int]*fakeReadyDB{1: tracker}}
+ dbs := []ReadyDatabase{{ID: 1, OwnerName: "alice", Name: "alpha"}}
+
+ view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)
+
+ require.Len(t, view.Groups, 1)
+ var ids []string
+ for _, c := range view.Groups[0].Cards {
+ ids = append(ids, c.ID)
+ }
+ // i-1 waits on an open issue; everything else is actionable — including the
+ // subtask, because parent-child is hierarchy and not a blocker.
+ assert.Equal(t, []string{"i-2", "i-3", "i-5", "i-6"}, ids)
+}
+
+// The head-hash gate: a second call with unmoved heads reads no rows, no tables
+// and no log. Asserted by counting reads on the fake, never by timing.
+func TestReadyAcrossHeadHashGateSkipsEveryRead(t *testing.T) {
+ in, dbs := readyFixture()
+ cache := NewReadyCache()
+
+ first := across(in, dbs, cache, ReadyFilter{}, readyNow)
+ require.Len(t, first.Groups, 2)
+ alpha := in.dbs[1]
+ firstReads := alpha.rowReads
+ require.Greater(t, firstReads, 0, "the first call must read rows")
+
+ second := across(in, dbs, cache, ReadyFilter{}, readyNow.Add(30*time.Second))
+
+ assert.Equal(t, firstReads, alpha.rowReads, "a second call with an unmoved head must read no rows")
+ assert.Equal(t, 1, alpha.tableReads, "nor list tables again")
+ assert.Equal(t, 1, alpha.logReads, "nor read the log again")
+ // The session is still opened and its branches listed — that is what the gate
+ // is gated on, and it is the cheap half.
+ assert.Equal(t, 2, alpha.opens)
+ assert.Equal(t, 2, alpha.closes)
+ // And the answer is the same one.
+ assert.Equal(t, first.Total, second.Total)
+ require.Len(t, second.Groups, 2)
+ assert.Equal(t, first.Groups[0].Cards, second.Groups[0].Cards)
+ require.NotNil(t, second.Groups[0].Head)
+ assert.Equal(t, "h-alpha", second.Groups[0].Head.Hash)
+}
+
+// A head that moved is a projection that no longer stands: the rows are read
+// again even well inside the TTL.
+func TestReadyAcrossMovedHeadForcesAReread(t *testing.T) {
+ in, dbs := readyFixture()
+ cache := NewReadyCache()
+ alpha := in.dbs[1]
+
+ across(in, dbs, cache, ReadyFilter{}, readyNow)
+ firstReads := alpha.rowReads
+
+ alpha.branches = []browse.Branch{{Name: "main", Head: "h-alpha-2"}}
+ across(in, dbs, cache, ReadyFilter{}, readyNow.Add(time.Second))
+
+ assert.Greater(t, alpha.rowReads, firstReads, "a moved head must be re-read")
+ // The other database's head did not move, so it was not re-read.
+ assert.Equal(t, 1, in.dbs[2].tableReads)
+}
+
+// The TTL: past it the projection is re-read even though the head has not
+// moved, so a cache can never be the reason a reader sees yesterday's answer.
+func TestReadyAcrossTTLExpiryForcesAReread(t *testing.T) {
+ in, dbs := readyFixture()
+ cache := NewReadyCache()
+ alpha := in.dbs[1]
+
+ across(in, dbs, cache, ReadyFilter{}, readyNow)
+ firstReads := alpha.rowReads
+
+ // One tick short of the TTL: still cached.
+ across(in, dbs, cache, ReadyFilter{}, readyNow.Add(ReadyCacheTTL-time.Nanosecond))
+ assert.Equal(t, firstReads, alpha.rowReads, "inside the TTL the projection stands")
+
+ // At the TTL: read again.
+ across(in, dbs, cache, ReadyFilter{}, readyNow.Add(ReadyCacheTTL))
+ assert.Greater(t, alpha.rowReads, firstReads, "at the TTL the projection is re-read")
+}
+
+// The ceiling: more candidates than ReadyMaxDatabases and the view says it was
+// capped, having opened exactly the ceiling's worth of stores.
+func TestReadyAcrossCeiling(t *testing.T) {
+ in := &readyInstance{dbs: map[int]*fakeReadyDB{}}
+ var dbs []ReadyDatabase
+ for i := 1; i <= ReadyMaxDatabases+3; i++ {
+ in.dbs[i] = readyTracker(fmt.Sprintf("h%d", i), [][]string{
+ {fmt.Sprintf("t%d-1", i), "Ready", "open", "1", "task", "alice", "0", "0", "0"},
+ }, nil)
+ dbs = append(dbs, ReadyDatabase{ID: i, OwnerName: "alice", Name: fmt.Sprintf("db%02d", i)})
+ }
+
+ view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)
+
+ assert.True(t, view.Capped, "a page that hit the ceiling must say so")
+ assert.Equal(t, ReadyMaxDatabases, view.Considered)
+ assert.Equal(t, ReadyMaxDatabases, view.Max)
+ assert.Len(t, view.Groups, ReadyMaxDatabases)
+ // The three past the ceiling were never opened.
+ for i := ReadyMaxDatabases + 1; i <= ReadyMaxDatabases+3; i++ {
+ assert.Zero(t, in.dbs[i].opens, "database %d is past the ceiling", i)
+ }
+}
+
+// A database named by ?db= is never dropped by the ceiling: the filter narrows
+// the candidates first.
+func TestReadyAcrossCeilingAppliesAfterTheDatabaseFilter(t *testing.T) {
+ in := &readyInstance{dbs: map[int]*fakeReadyDB{}}
+ var dbs []ReadyDatabase
+ for i := 1; i <= ReadyMaxDatabases+3; i++ {
+ in.dbs[i] = readyTracker(fmt.Sprintf("h%d", i), [][]string{
+ {fmt.Sprintf("t%d-1", i), "Ready", "open", "1", "task", "alice", "0", "0", "0"},
+ }, nil)
+ dbs = append(dbs, ReadyDatabase{ID: i, OwnerName: "alice", Name: fmt.Sprintf("db%02d", i)})
+ }
+ last := dbs[len(dbs)-1]
+
+ view := across(in, dbs, NewReadyCache(), ReadyFilter{Databases: []string{last.Slug()}}, readyNow)
+
+ assert.False(t, view.Capped)
+ assert.Equal(t, 1, view.Considered)
+ require.Len(t, view.Groups, 1)
+ assert.Equal(t, last.Slug(), view.Groups[0].Database.Slug())
+}
+
+// A database that cannot be opened, or whose rows cannot be read, costs itself
+// only. The error is carried out for the caller's log and the rest of the answer
+// stands.
+func TestReadyAcrossFailingDatabaseCostsItselfOnly(t *testing.T) {
+ in, dbs := readyFixture()
+ in.dbs[1].openErr = errors.New("browse: open store: no such file or directory")
+
+ view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)
+
+ require.Len(t, view.Failed, 1)
+ assert.Equal(t, "alice/alpha", view.Failed[0].Database.Slug())
+ assert.ErrorContains(t, view.Failed[0].Err, "no such file")
+ require.Len(t, view.Groups, 1)
+ assert.Equal(t, "bob/beta", view.Groups[0].Database.Slug())
+
+ // A store that opens but cannot list its branches fails the same way.
+ in2, dbs2 := readyFixture()
+ in2.dbs[1].branchesErr = errors.New("browse: list branches: corrupt chunk")
+ view2 := across(in2, dbs2, NewReadyCache(), ReadyFilter{}, readyNow)
+ require.Len(t, view2.Failed, 1)
+ require.Len(t, view2.Groups, 1)
+ assert.Equal(t, 1, in2.dbs[1].closes, "a failing database still closes its session")
+}
+
+// A database whose tables are not a beads schema is skipped silently: no group,
+// no failure, and no second table listing on the next request.
+func TestReadyAcrossSkipsNonBeadsDatabases(t *testing.T) {
+ in, dbs := readyFixture()
+ plain := &fakeReadyDB{
+ branches: []browse.Branch{{Name: "main", Head: "h-plain"}},
+ tables: []browse.TableInfo{{Name: "measurements", Columns: []browse.ColumnInfo{{Name: "id"}}}},
+ }
+ in.dbs[3] = plain
+ dbs = append(dbs, ReadyDatabase{ID: 3, OwnerName: "carol", Name: "sensors"})
+ cache := NewReadyCache()
+
+ view := across(in, dbs, cache, ReadyFilter{}, readyNow)
+ assert.Len(t, view.Groups, 2)
+ assert.Empty(t, view.Failed)
+ assert.Zero(t, plain.rowReads, "a non-beads database is never read for rows")
+
+ across(in, dbs, cache, ReadyFilter{}, readyNow.Add(time.Second))
+ assert.Equal(t, 1, plain.tableReads, "the fingerprint is worth one table listing")
+}
+
+// A store with no branches at all — freshly initialised, never pushed to — is
+// skipped rather than failing the page.
+func TestReadyAcrossSkipsAnEmptyStore(t *testing.T) {
+ in := &readyInstance{dbs: map[int]*fakeReadyDB{1: {}}}
+ dbs := []ReadyDatabase{{ID: 1, OwnerName: "alice", Name: "fresh"}}
+
+ view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)
+
+ assert.Empty(t, view.Groups)
+ assert.Empty(t, view.Failed)
+ assert.Equal(t, 1, view.Considered)
+}
+
+// A log that cannot be read costs the group its freshness line and nothing else.
+func TestReadyAcrossSurvivesAnUnreadableLog(t *testing.T) {
+ in, dbs := readyFixture()
+ in.dbs[1].logErr = errors.New("browse: walk commits: corrupt chunk")
+
+ view := across(in, dbs, NewReadyCache(), ReadyFilter{}, readyNow)
+
+ require.Len(t, view.Groups, 2)
+ assert.Nil(t, view.Groups[0].Head, "no head is renderable")
+ assert.Len(t, view.Groups[0].Cards, 3, "the ready set is still answered")
+}
+
+func TestReadyFilters(t *testing.T) {
+ in, dbs := readyFixture()
+
+ t.Run("q narrows over id and title", func(t *testing.T) {
+ view := across(in, dbs, NewReadyCache(), ReadyFilter{Query: "TOP priority"}, readyNow)
+ require.Len(t, view.Groups, 1)
+ require.Len(t, view.Groups[0].Cards, 1)
+ assert.Equal(t, "a-1", view.Groups[0].Cards[0].ID)
+ assert.Equal(t, 1, view.Total)
+ })
+
+ t.Run("assignee is exact", func(t *testing.T) {
+ view := across(in, dbs, NewReadyCache(), ReadyFilter{Assignee: "alice"}, readyNow)
+ require.Len(t, view.Groups, 1, "only alpha has alice's ready work")
+ assert.Equal(t, "alice/alpha", view.Groups[0].Database.Slug())
+ assert.Equal(t, 2, view.Total)
+ })
+
+ t.Run("priority is exact", func(t *testing.T) {
+ view := across(in, dbs, NewReadyCache(), ReadyFilter{Priority: "2"}, readyNow)
+ require.Len(t, view.Groups, 1)
+ assert.Equal(t, 2, view.Total)
+ })
+
+ t.Run("db selects databases", func(t *testing.T) {
+ // Its own fixture: this one asserts that a database was never opened, and
+ // the shared one has been opened by every subtest above.
+ in, dbs := readyFixture()
+ view := across(in, dbs, NewReadyCache(), ReadyFilter{Databases: []string{"bob/beta"}}, readyNow)
+ require.Len(t, view.Groups, 1)
+ assert.Equal(t, "bob/beta", view.Groups[0].Database.Slug())
+ assert.Equal(t, 1, view.Considered, "the other database is never opened")
+ assert.Zero(t, in.dbs[1].opens)
+ })
+
+ t.Run("a filter that matches nothing empties the page", func(t *testing.T) {
+ view := across(in, dbs, NewReadyCache(), ReadyFilter{Assignee: "nobody"}, readyNow)
+ assert.Empty(t, view.Groups)
+ assert.Zero(t, view.Total)
+ // The options still list the real values, so the reader can pick another.
+ assert.Equal(t, []string{"alice", "bob", "carol"}, view.Options.Assignees)
+ })
+}
+
+func TestParseReadyFilter(t *testing.T) {
+ q, err := url.ParseQuery("q=+cache+&assignee=alice&priority=1&db=alice%2Falpha&db=+&db=bob%2Fbeta")
+ require.NoError(t, err)
+
+ f := ParseReadyFilter(q)
+
+ assert.Equal(t, "cache", f.Query)
+ assert.Equal(t, "alice", f.Assignee)
+ assert.Equal(t, "1", f.Priority)
+ assert.Equal(t, []string{"alice/alpha", "bob/beta"}, f.Databases, "a blank ?db= is not a database")
+ assert.True(t, f.Active())
+ assert.False(t, ParseReadyFilter(url.Values{}).Active())
+}
+
+// The cache is bounded by entry count: it is a cache and not a store, and an
+// instance with more databases than the ceiling must not grow it without end.
+func TestReadyCacheIsBoundedByEntryCount(t *testing.T) {
+ cache := NewReadyCache()
+ for i := 1; i <= readyCacheMaxEntries+10; i++ {
+ cache.store(i, readyEntry{head: fmt.Sprintf("h%d", i), at: readyNow, beads: true})
+ }
+ cache.mu.Lock()
+ n := len(cache.entries)
+ cache.mu.Unlock()
+ assert.LessOrEqual(t, n, readyCacheMaxEntries)
+}
diff --git a/beads/rows.go b/beads/rows.go
index 4d15489fdbf705ae9f68a19b90e366931b1d6dec..54eef8de7ece7e248fae2aa32152a1da03606d64 100644
--- a/beads/rows.go
+++ b/beads/rows.go
@@ -57,6 +57,105 @@ func cell(cols map[string]int, row []string, name string) string {
return v
}
+// indexStatusCategories maps a status name (lowercased) to its category, from
+// the optional custom_statuses table. A nil page — the table is absent — yields
+// an empty map, and statusCategory then falls back to its name heuristics.
+func indexStatusCategories(statuses *browse.RowPage) map[string]string {
+ out := map[string]string{}
+ if statuses == nil {
+ return out
+ }
+ cols := indexCols(statuses.Columns)
+ for _, r := range statuses.Rows {
+ if name := cell(cols, r, "name"); name != "" {
+ out[strings.ToLower(name)] = strings.ToLower(cell(cols, r, "category"))
+ }
+ }
+ return out
+}
+
+// indexLabels maps issue id → its label names, from the optional labels table.
+// A nil page yields an empty map.
+func indexLabels(labels *browse.RowPage) map[string][]string {
+ out := map[string][]string{}
+ if labels == nil {
+ return out
+ }
+ cols := indexCols(labels.Columns)
+ for _, r := range labels.Rows {
+ id := cell(cols, r, "issue_id")
+ lb := cell(cols, r, "label")
+ if id != "" && lb != "" {
+ out[id] = append(out[id], lb)
+ }
+ }
+ return out
+}
+
+// indexIssueCategories maps issue id → status category (open / in_progress /
+// closed), which is what decides whether a blocking target still blocks.
+func indexIssueCategories(issues *browse.RowPage, cols map[string]int, catByStatus map[string]string) map[string]string {
+ out := make(map[string]string, len(issues.Rows))
+ for _, r := range issues.Rows {
+ out[cell(cols, r, "id")] = statusCategory(cell(cols, r, "status"), catByStatus)
+ }
+ return out
+}
+
+// depIndex is the dependency edge set aggregated per issue: how many things an
+// issue waits on, how many wait on it, and whether any of the things it waits on
+// is still open (which is what "blocked" means).
+type depIndex struct {
+ blockedByCount map[string]int // issue_id → #deps it has
+ blocksCount map[string]int // depends_on_issue_id → #deps aimed at it
+ blockedOpen map[string]bool // issue_id → has an open blocking dep
+}
+
+// indexDeps aggregates the dependencies table. A "blocks" edge to a still-open
+// target blocks its source; parent-child is hierarchy, not a blocker — a subtask
+// is not blocked by its (open) epic, matching bd's own is_blocked/ready
+// accounting. A nil page (the table is absent) yields empty maps.
+func indexDeps(deps *browse.RowPage, catByIssue map[string]string) depIndex {
+ idx := depIndex{
+ blockedByCount: map[string]int{},
+ blocksCount: map[string]int{},
+ blockedOpen: map[string]bool{},
+ }
+ if deps == nil {
+ return idx
+ }
+ cols := indexCols(deps.Columns)
+ for _, r := range deps.Rows {
+ from := cell(cols, r, "issue_id")
+ to := cell(cols, r, "depends_on_issue_id")
+ typ := strings.ToLower(cell(cols, r, "type"))
+ if from != "" {
+ idx.blockedByCount[from]++
+ }
+ if to != "" {
+ idx.blocksCount[to]++
+ }
+ if from != "" && typ == "blocks" && catByIssue[to] != "closed" {
+ idx.blockedOpen[from] = true
+ }
+ }
+ return idx
+}
+
+// readyRow is bd's ready rule, and the only copy of it: an issue is ready when
+// it is open (not in-progress, not closed), unblocked, and not a
+// template/ephemeral scaffold. Derived in-process from the issues rows already
+// loaded rather than by reading the ready_issues table.
+//
+// Every surface that says "ready" — the board's ⚡ marker and its ?ready=1
+// filter, the cross-database /ready page, the MCP ready_work tool — comes
+// through here, because two surfaces that each spelled the rule out would
+// disagree the first time it changed.
+func readyRow(cat string, blocked bool, row []string, cols map[string]int) bool {
+ return cat == "open" && !blocked &&
+ !truthy(cell(cols, row, "is_template")) && !truthy(cell(cols, row, "ephemeral"))
+}
+
// truthy reports whether a cell reads as a set boolean/flag.
func truthy(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
diff --git a/web/adapters.go b/web/adapters.go
index 0fd8da904da344c9c00938c260591317a5a459c0..e6f3a697af7794012fbf4ebf845d7e38c83322ef 100644
--- a/web/adapters.go
+++ b/web/adapters.go
@@ -39,6 +39,10 @@ func (DBAdapter) ListReposByOwner(ctx context.Context, owner string, viewer *cor
return db.FromContext(ctx).ListReposByOwner(ctx, owner, viewer)
}
+func (DBAdapter) ListReposForViewer(ctx context.Context, viewer *core.Caller) ([]*core.Repo, error) {
+ return db.FromContext(ctx).ListReposForViewer(ctx, viewer)
+}
+
func (DBAdapter) ListReposForDashboard(ctx context.Context, userID int) ([]*core.Repo, error) {
return db.FromContext(ctx).ListReposForDashboard(ctx, userID)
}
diff --git a/web/deps.go b/web/deps.go
index b3ff0d7e0f187063286681f53e94aa1d935a2a28..5731336fb41513fe559aba4901c02222c769f314 100644
--- a/web/deps.go
+++ b/web/deps.go
@@ -101,6 +101,13 @@ type RepoStore interface {
CreateRepo(ctx context.Context, r *core.Repo) (*core.Repo, error)
GetRepoByOwnerAndName(ctx context.Context, ownerUsername, name string) (*core.Repo, error)
ListReposByOwner(ctx context.Context, ownerUsername string, viewer *core.Caller) ([]*core.Repo, error)
+ // ListReposForViewer lists every database viewer may be shown, across all
+ // owners. It is the enumeration the cross-database ready page is built on:
+ // ListReposByOwner asks the same listing question about one owner, and
+ // ListReposForDashboard omits every PUBLIC database belonging to somebody
+ // else. Listing is not authorization — /ready still asks core.Allowed per
+ // database before it opens anything.
+ ListReposForViewer(ctx context.Context, viewer *core.Caller) ([]*core.Repo, error)
ListReposForDashboard(ctx context.Context, userID int) ([]*core.Repo, error)
UpdateRepo(ctx context.Context, id int, description string, visibility core.Visibility) error
DeleteRepo(ctx context.Context, id int) error
diff --git a/web/handlers_ready.go b/web/handlers_ready.go
new file mode 100644
index 0000000000000000000000000000000000000000..a8eda0e08a60827f70a12320a138f7128f572693
--- /dev/null
+++ b/web/handlers_ready.go
@@ -0,0 +1,90 @@
+package web
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net/http"
+
+ "go.bigb.es/auxilia/scribe"
+
+ "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/beads"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/core"
+)
+
+// handleReady renders the cross-database ready page: for every database this
+// caller may browse, the issues that are open, unblocked and not scaffolding,
+// grouped by database.
+//
+// It is the one page here that reads more than one database, and the three
+// things that keeps affordable live in beads: the head-hash gate over the app's
+// projection cache, the 60-second TTL, and the ceiling on how many databases one
+// request opens. This handler's own job is the other half — deciding which
+// databases the caller may see at all, and doing it before a single store is
+// opened.
+//
+// Visibility is enumerated with ListReposForViewer (the listing rule) and then
+// re-asked per database with core.Allowed/OpBrowse (the access rule). A database
+// the caller may not browse is simply absent: not a 403, not a count, not a
+// group with a hidden name — nothing on the page may hint that it exists.
+func (a *app) handleReady(w http.ResponseWriter, r *http.Request) {
+ _, caller := callerOf(r.Context())
+
+ repos, err := a.cfg.Repos.ListReposForViewer(r.Context(), caller)
+ if err != nil {
+ slog.Error("listing databases for the ready page failed",
+ "component", "web", scribe.Err(err))
+ a.fail(w, r, http.StatusInternalServerError, "")
+ return
+ }
+
+ // The disk path never reaches beads: it is this service's arrangement of its
+ // own storage, and the aggregation addresses a database by the identity the
+ // cache is keyed on. The opener closes over the map, so a database that was
+ // filtered out of the candidate list has no path to be opened by.
+ paths := make(map[int]string, len(repos))
+ dbs := make([]beads.ReadyDatabase, 0, len(repos))
+ for _, repo := range repos {
+ if !core.Allowed(caller, repo, a.effectiveACL(r, caller, repo), core.OpBrowse) {
+ continue
+ }
+ paths[repo.ID] = repo.Path
+ dbs = append(dbs, beads.ReadyDatabase{
+ ID: repo.ID,
+ OwnerName: repo.OwnerName,
+ Name: repo.Name,
+ })
+ }
+
+ open := func(ctx context.Context, d beads.ReadyDatabase) (beads.ReadySession, error) {
+ path, ok := paths[d.ID]
+ if !ok {
+ return nil, fmt.Errorf("web: no store path for database %s", d.Slug())
+ }
+ return a.cfg.Browse.Open(ctx, path)
+ }
+
+ data := beads.ReadyAcross(r.Context(), dbs, open, a.ready,
+ beads.ParseReadyFilter(r.URL.Query()), timeNow())
+ data.Query = r.URL.Query()
+
+ // A store that cannot be read is a fact about this deployment, and it belongs
+ // in the log with its error. The page says only how many databases it could
+ // not read: echoing a browse error into the response is how a store path and
+ // a dolt internal end up in somebody's browser (sr-ht-dolt-7ta).
+ for _, f := range data.Failed {
+ slog.Warn("reading a database for the ready page failed",
+ "component", "web", "database", f.Database.Slug(), scribe.Err(f.Err))
+ }
+
+ view := struct {
+ chrome.Page
+ Data *beads.ReadyView
+ }{
+ Page: a.page(r, "Ready — "+serviceName),
+ Data: data,
+ }
+ a.render(w, http.StatusOK, "ready", view)
+}
diff --git a/web/handlers_ready_test.go b/web/handlers_ready_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..ed3274d96e7e7cbf9b446d755a89b676768afee5
--- /dev/null
+++ b/web/handlers_ready_test.go
@@ -0,0 +1,347 @@
+package web
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/beads"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/core"
+)
+
+// The cross-database ready page. What matters here is not the projection — that
+// is beads' and is tested there — but the two things only this layer can get
+// wrong: which databases a caller is shown, and how many stores one request is
+// allowed to open.
+
+// readyStore builds a fake session for one tracker: a beads schema, a head
+// commit four minutes old, and the given issue rows.
+func readyStore(head string, issues [][]string) *fakeSession {
+ return &fakeSession{
+ branches: []browse.Branch{{Name: "main", Head: head}},
+ tables: beadsTables(),
+ commits: []browse.CommitInfo{{
+ Hash: head, Author: "Eugene Blikh", Date: testNow.Add(-4 * time.Minute),
+ Message: "bd: create (auto-commit)",
+ }},
+ rowsByTable: map[string]*browse.RowPage{
+ "issues": {
+ Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "is_blocked"},
+ Rows: issues,
+ Total: len(issues),
+ },
+ "dependencies": {
+ Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
+ Total: 0,
+ },
+ },
+ }
+}
+
+// addTracker registers a database with the metadata store and wires its store
+// path to a fake session, the way the production pair (repo.Path →
+// BrowseOpener) is wired.
+func addTracker(h *harness, owner string, ownerID int, name string, vis core.Visibility, sess *fakeSession) *core.Repo {
+ repo := h.store.add(&core.Repo{
+ Name: name, OwnerID: ownerID, OwnerName: owner,
+ Path: "/var/lib/dolt/~" + owner + "/" + name, Visibility: vis,
+ })
+ if h.browse.byPath == nil {
+ h.browse.byPath = map[string]*fakeSession{}
+ }
+ h.browse.byPath[repo.Path] = sess
+ return repo
+}
+
+// readyHarness is two public trackers: alpha with two ready issues among four,
+// beta with one.
+func readyHarness(t *testing.T) (*harness, *fakeSession, *fakeSession) {
+ t.Helper()
+ h := newHarness(t)
+ alpha := readyStore("h-alpha", [][]string{
+ {"a-1", "Ready, top priority", "open", "0", "task", "alice", "0"},
+ {"a-2", "Ready, lower", "open", "2", "bug", "bob", "0"},
+ {"a-3", "Under way", "in_progress", "0", "task", "alice", "0"},
+ {"a-4", "Waiting on something", "open", "0", "task", "bob", "1"},
+ })
+ beta := readyStore("h-beta", [][]string{
+ {"b-1", "The one ready thing", "open", "1", "task", "carol", "0"},
+ {"b-2", "Finished", "closed", "0", "task", "carol", "0"},
+ })
+ addTracker(h, "alice", 1, "alpha", core.VisibilityPublic, alpha)
+ addTracker(h, "bob", 2, "beta", core.VisibilityPublic, beta)
+ return h, alpha, beta
+}
+
+func TestReadyPageRendersGroupsInOrder(t *testing.T) {
+ pinClock(t, testNow)
+ h, _, _ := readyHarness(t)
+
+ rec := h.do("GET", "/ready", nil, nil)
+ require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String())
+ body := rec.Body.String()
+
+ // Both databases, the busier one first, each with its own count.
+ assert.Contains(t, body, "~alice/alpha")
+ assert.Contains(t, body, "~bob/beta")
+ assert.Less(t, strings.Index(body, "~alice/alpha"), strings.Index(body, "~bob/beta"),
+ "groups are ordered by ready count desc")
+ assert.Contains(t, body, "2 ready")
+ assert.Contains(t, body, "1 ready")
+ assert.Contains(t, body, "3 ready issues")
+
+ // The ready ones, linked into their own database's board.
+ assert.Contains(t, body, `href="/~alice/alpha/view/beads?issue=a-1"`)
+ assert.Contains(t, body, `href="/~alice/alpha/view/beads?issue=a-2"`)
+ assert.Contains(t, body, `href="/~bob/beta/view/beads?issue=b-1"`)
+ // …and only those: in-progress, blocked and closed issues are not ready.
+ assert.NotContains(t, body, "issue=a-3")
+ assert.NotContains(t, body, "issue=a-4")
+ assert.NotContains(t, body, "issue=b-2")
+
+ // Each group carries its own database's freshness, so "ready" from a store
+ // that stopped receiving pushes is not claimed silently.
+ assert.Contains(t, body, `class="beads-freshness"`)
+ assert.Contains(t, body, "main · last commit")
+ assert.Contains(t, body, "4 minutes ago")
+ assert.Contains(t, body, `href="/~alice/alpha/commit/h-alpha"`)
+ assert.Contains(t, body, `href="/~bob/beta/commit/h-beta"`)
+}
+
+// A PRIVATE tracker the caller may not browse is absent. Not a 403, not a
+// counted-but-unnamed group: nothing on the page may hint that it exists.
+func TestReadyPageHidesADatabaseTheCallerMayNotBrowse(t *testing.T) {
+ pinClock(t, testNow)
+ h, _, _ := readyHarness(t)
+ secret := readyStore("h-secret", [][]string{
+ {"s-1", "The secret thing", "open", "0", "task", "dave", "0"},
+ })
+ addTracker(h, "dave", 9, "secrets", core.VisibilityPrivate, secret)
+
+ rec := h.do("GET", "/ready", nil, nil)
+ require.Equal(t, http.StatusOK, rec.Code, "a hidden database is not a refusal")
+ body := rec.Body.String()
+
+ assert.NotContains(t, body, "secrets")
+ assert.NotContains(t, body, "s-1")
+ assert.NotContains(t, body, "The secret thing")
+ assert.NotContains(t, body, "dave")
+ assert.NotContains(t, body, "h-secret")
+ assert.Zero(t, secret.opens, "a database the caller may not browse is never opened")
+ // The visible ones are unaffected.
+ assert.Contains(t, body, "~alice/alpha")
+ assert.Contains(t, body, "3 ready issues")
+
+ // Its owner sees it, which is what makes the absence above a visibility rule
+ // and not a broken page.
+ owner := h.do("GET", "/ready", testCaller(9, "dave"), nil)
+ require.Equal(t, http.StatusOK, owner.Code)
+ assert.Contains(t, owner.Body.String(), "~dave/secrets")
+ assert.Contains(t, owner.Body.String(), "s-1")
+}
+
+// The head-hash gate: a second request whose heads have not moved reads no rows
+// at all. Counted on the fake, never timed.
+func TestReadyPageHeadHashGateReadsNothingTwice(t *testing.T) {
+ pinClock(t, testNow)
+ h, alpha, beta := readyHarness(t)
+
+ first := h.do("GET", "/ready", nil, nil)
+ require.Equal(t, http.StatusOK, first.Code)
+ reads := alpha.rowReads
+ require.Greater(t, reads, 0, "the first request must read rows")
+
+ second := h.do("GET", "/ready", nil, nil)
+ require.Equal(t, http.StatusOK, second.Code)
+
+ assert.Equal(t, reads, alpha.rowReads, "an unmoved head must cost no row reads")
+ assert.Equal(t, 1, alpha.tableReads, "nor a second table listing")
+ assert.Equal(t, 1, alpha.logReads, "nor a second log read")
+ assert.Equal(t, 1, beta.tableReads, "the sibling database is gated too")
+ // Both stores are still opened and their branches listed — that is what the
+ // gate is gated on, and it is the cheap half.
+ assert.Equal(t, 2, alpha.opens)
+ // The page is the same page, cache or not.
+ assert.Equal(t, first.Body.String(), second.Body.String())
+}
+
+// The TTL: past it the projection is read again even though nothing moved, so a
+// cache can never be the reason a reader sees yesterday's answer.
+func TestReadyPageTTLExpiryForcesAReread(t *testing.T) {
+ pinClock(t, testNow)
+ h, alpha, _ := readyHarness(t)
+
+ require.Equal(t, http.StatusOK, h.do("GET", "/ready", nil, nil).Code)
+ reads := alpha.rowReads
+
+ // One tick short of the TTL: still cached.
+ pinClock(t, testNow.Add(beads.ReadyCacheTTL-time.Second))
+ require.Equal(t, http.StatusOK, h.do("GET", "/ready", nil, nil).Code)
+ assert.Equal(t, reads, alpha.rowReads, "inside the TTL the projection stands")
+
+ // Past it: read again.
+ pinClock(t, testNow.Add(beads.ReadyCacheTTL+time.Second))
+ require.Equal(t, http.StatusOK, h.do("GET", "/ready", nil, nil).Code)
+ assert.Greater(t, alpha.rowReads, reads, "past the TTL the projection is re-read")
+}
+
+// The ceiling: more candidate databases than beads.ReadyMaxDatabases and the
+// page says so. A silent cap reads as "that is everything".
+func TestReadyPageCeilingSaysItWasCapped(t *testing.T) {
+ pinClock(t, testNow)
+ h := newHarness(t)
+ var overflow []*fakeSession
+ for i := 1; i <= beads.ReadyMaxDatabases+2; i++ {
+ sess := readyStore(fmt.Sprintf("h%02d", i), [][]string{
+ {fmt.Sprintf("t%02d-1", i), "Ready", "open", "1", "task", "alice", "0"},
+ })
+ addTracker(h, "alice", 1, fmt.Sprintf("db%02d", i), core.VisibilityPublic, sess)
+ if i > beads.ReadyMaxDatabases {
+ overflow = append(overflow, sess)
+ }
+ }
+
+ rec := h.do("GET", "/ready", nil, nil)
+ require.Equal(t, http.StatusOK, rec.Code)
+ body := rec.Body.String()
+
+ assert.Contains(t, body, fmt.Sprintf("only the first %d were read", beads.ReadyMaxDatabases))
+ assert.Contains(t, body, fmt.Sprintf("%d ready issues", beads.ReadyMaxDatabases))
+ for _, sess := range overflow {
+ assert.Zero(t, sess.opens, "a database past the ceiling is never opened")
+ }
+}
+
+// A database that cannot be opened costs itself only: the rest of the page
+// renders, and the browse error reaches the log and not the reader.
+func TestReadyPageSurvivesADatabaseThatCannotBeOpened(t *testing.T) {
+ pinClock(t, testNow)
+ h, _, _ := readyHarness(t)
+ broken := h.store.add(&core.Repo{
+ Name: "broken", OwnerID: 4, OwnerName: "erin",
+ Path: "/var/lib/dolt/~erin/broken", Visibility: core.VisibilityPublic,
+ })
+ h.browse.errByPath = map[string]error{
+ broken.Path: errors.New("browse: open /var/lib/dolt/~erin/broken: manifest is corrupt"),
+ }
+
+ rec := h.do("GET", "/ready", nil, nil)
+ require.Equal(t, http.StatusOK, rec.Code)
+ body := rec.Body.String()
+
+ // The rest of the page is the page.
+ assert.Contains(t, body, "~alice/alpha")
+ assert.Contains(t, body, "~bob/beta")
+ assert.Contains(t, body, "3 ready issues")
+ // The gap is admitted; the error is not printed.
+ assert.Contains(t, body, "could not be read")
+ assert.NotContains(t, body, "manifest is corrupt")
+ assert.NotContains(t, body, "/var/lib/dolt")
+}
+
+// A database that is not a beads tracker is skipped silently: no group, no
+// note, no mention.
+func TestReadyPageSkipsNonBeadsDatabases(t *testing.T) {
+ pinClock(t, testNow)
+ h, _, _ := readyHarness(t)
+ plain := &fakeSession{
+ branches: []browse.Branch{{Name: "main", Head: "h-plain"}},
+ tables: []browse.TableInfo{{Name: "measurements", Columns: []browse.ColumnInfo{{Name: "id"}}}},
+ }
+ addTracker(h, "frank", 5, "sensors", core.VisibilityPublic, plain)
+
+ rec := h.do("GET", "/ready", nil, nil)
+ require.Equal(t, http.StatusOK, rec.Code)
+ body := rec.Body.String()
+
+ assert.NotContains(t, body, "sensors")
+ assert.NotContains(t, body, "could not be read")
+ assert.Zero(t, plain.rowReads, "a non-beads database is never read for rows")
+ assert.Contains(t, body, "3 ready issues")
+}
+
+func TestReadyPageFilters(t *testing.T) {
+ pinClock(t, testNow)
+
+ get := func(t *testing.T, target string) string {
+ t.Helper()
+ h, _, _ := readyHarness(t)
+ rec := h.do("GET", target, nil, nil)
+ require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String())
+ return rec.Body.String()
+ }
+
+ t.Run("q narrows over id and title", func(t *testing.T) {
+ body := get(t, "/ready?q=one+ready+thing")
+ assert.Contains(t, body, "issue=b-1")
+ assert.NotContains(t, body, "issue=a-1")
+ assert.Contains(t, body, "1 ready issue")
+ })
+
+ t.Run("assignee narrows", func(t *testing.T) {
+ body := get(t, "/ready?assignee=bob")
+ assert.Contains(t, body, "issue=a-2")
+ assert.NotContains(t, body, "issue=a-1")
+ assert.NotContains(t, body, "issue=b-1")
+ })
+
+ t.Run("priority narrows", func(t *testing.T) {
+ body := get(t, "/ready?priority=0")
+ assert.Contains(t, body, "issue=a-1")
+ assert.NotContains(t, body, "issue=a-2")
+ })
+
+ t.Run("db narrows to one database", func(t *testing.T) {
+ body := get(t, "/ready?db=bob%2Fbeta")
+ assert.Contains(t, body, "~bob/beta")
+ assert.NotContains(t, body, "~alice/alpha")
+ assert.Contains(t, body, `href="/ready"`, "and offers a way back to all of them")
+ })
+
+ t.Run("a filter matching nothing says so", func(t *testing.T) {
+ body := get(t, "/ready?assignee=nobody")
+ assert.Contains(t, body, "Nothing ready matches these filters")
+ })
+}
+
+// An instance with no beads databases at all still answers, and says what it
+// found rather than nothing.
+func TestReadyPageEmptyInstance(t *testing.T) {
+ pinClock(t, testNow)
+ h := newHarness(t)
+
+ rec := h.do("GET", "/ready", nil, nil)
+ require.Equal(t, http.StatusOK, rec.Code)
+ assert.Contains(t, rec.Body.String(), "Nothing is ready to work")
+}
+
+// The page is reachable from the dashboard — the only place a reader would look
+// for a question that belongs to no single database.
+func TestDashboardLinksToTheReadyPage(t *testing.T) {
+ h := newHarness(t)
+
+ signedIn := h.do("GET", "/", testCaller(3, "bob"), nil)
+ require.Equal(t, http.StatusOK, signedIn.Code)
+ assert.Contains(t, signedIn.Body.String(), `href="/ready"`)
+
+ anon := h.do("GET", "/", nil, nil)
+ require.Equal(t, http.StatusOK, anon.Code)
+ assert.Contains(t, anon.Body.String(), `href="/ready"`)
+}
+
+// HEAD is registered for this route like every other read route, and answers
+// the same status as the GET.
+func TestReadyPageAnswersHead(t *testing.T) {
+ pinClock(t, testNow)
+ h, _, _ := readyHarness(t)
+
+ rec := h.do("HEAD", "/ready", nil, nil)
+ assert.Equal(t, http.StatusOK, rec.Code)
+}
diff --git a/web/router.go b/web/router.go
index bb98aae467d6914f72457d3296d0d90225e7a8f9..179139ea9dae45c6649a436276797147dcd50798 100644
--- a/web/router.go
+++ b/web/router.go
@@ -18,6 +18,7 @@ import (
"sourcecraft.dev/bigbes/sr-ht-ecore/pages"
"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/beads"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
@@ -58,6 +59,12 @@ type app struct {
// views is a snapshot of the global registeredViews taken at Register time.
// Handlers read this (never the global) so tests can inject their own set.
views []View
+ // ready is the /ready page's projection cache: one ready set per database,
+ // gated on the database's head hash and expiring on beads.ReadyCacheTTL. It
+ // lives on the app because it is the one piece of state that outlives a
+ // request here, and it holds projections rather than open stores — see
+ // beads.ReadyCache.
+ ready *beads.ReadyCache
}
// page builds the chrome for one request: the shared frame plus the per-page
@@ -150,6 +157,7 @@ func newApp(cfg Config) (*app, error) {
// Snapshot the registry so all handlers see a stable set and tests can
// override it per-app without mutating the global.
views: append([]View{}, registeredViews...),
+ ready: beads.NewReadyCache(),
}, nil
}
@@ -211,6 +219,10 @@ func (a *app) mount(r chi.Router) {
// of a form page is registered beside it with r.Post: a HEAD that
// writes is not a HEAD.
chimw.GetHead(r, "/", a.handleIndex)
+ // The cross-database ready page. It is a page of its own and not a View:
+ // a View is a rendering of one repository, and this is the question no
+ // single repository can answer.
+ chimw.GetHead(r, "/ready", a.handleReady)
chimw.GetHead(r, "/create", a.handleCreateForm)
r.Post("/create", a.handleCreate)
diff --git a/web/templates/index.html b/web/templates/index.html
index 359d384364332ad2c0aa15c4a5bfb7b3396124f3..38b7cfb749373a768def96ee607425d77d7f2c9b 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -10,6 +10,10 @@
Create new database {{icon "caret-right"}}
+ {{/* The one question no single database answers: what is ready to work
+ across every tracker this viewer can browse. */}}
+ What is ready to work {{icon "caret-right"}}
Configure dolt credentials {{icon "caret-right"}}
@@ -33,7 +37,8 @@
Log in to create and manage
- databases.
+ databases. The databases carrying a bd issue tracker also answer
+ what is ready to work across all of them.
{{end}}
{{- end}}
diff --git a/web/templates/ready.html b/web/templates/ready.html
new file mode 100644
index 0000000000000000000000000000000000000000..0f396f9a5c10d198ccf47299bf6cb64e37d30ae6
--- /dev/null
+++ b/web/templates/ready.html
@@ -0,0 +1,168 @@
+{{define "content" -}}
+
+
+
+
Ready to work
+
+ {{.Data.Total}} ready {{if eq .Data.Total 1}}issue{{else}}issues{{end}}
+ across {{len .Data.Groups}} {{if eq (len .Data.Groups) 1}}database{{else}}databases{{end}}
+ — open, unblocked, and nobody is on {{if eq .Data.Total 1}}it{{else}}them{{end}} yet.
+
+
+
+
+{{if .Data.Filter.Databases}}
+
+ Narrowed to {{len .Data.Filter.Databases}} of the databases you can browse.
+ Every database
+
+{{end}}
+
+{{if .Data.Capped}}
+{{/* A silent cap reads as "that is everything". */}}
+
+ More than {{.Data.Max}} databases matched; only the first {{.Data.Max}} were read.
+ Narrow with the database links below to see the rest.
+
+{{end}}
+
+{{if .Data.Failed}}
+{{/* What went wrong is in the log, with the error. The reader gets the count,
+ because a page that hid the gap entirely would understate itself. */}}
+
+ {{len .Data.Failed}} {{if eq (len .Data.Failed) 1}}database{{else}}databases{{end}}
+ could not be read and {{if eq (len .Data.Failed) 1}}is{{else}}are{{end}} not counted here.
+
+{{end}}
+
+{{range .Data.Groups}}
+
+
+ {{template "beadsHead" (dict "Repo" .Database "Ref" .Ref "Head" .Head)}}
+
+
+{{else}}
+{{if .Data.Filter.Active}}
+
Nothing ready matches these filters. Clear them.
+{{else}}
+
Nothing is ready to work in the databases you can browse.
+{{end}}
+{{end}}
+
+{{- end}}
diff --git a/web/web_test.go b/web/web_test.go
index 32dca2408820a8b6c2e9409d25c16ee12bafd7c0..c0d7ede5286b0634e9bb62306618362e1225a549 100644
--- a/web/web_test.go
+++ b/web/web_test.go
@@ -11,6 +11,7 @@ import (
"net/url"
"os"
"path/filepath"
+ "sort"
"strings"
"testing"
"time"
@@ -107,6 +108,24 @@ func (f *fakeStore) ListReposByOwner(_ context.Context, owner string, viewer *co
return out, nil
}
+// ListReposForViewer mirrors db.Store's instance-wide listing rule: PUBLIC to
+// everyone, plus whatever the viewer owns or holds an ACL on. Sorted by id so a
+// test that depends on the order it hands to /ready gets the same one twice.
+func (f *fakeStore) ListReposForViewer(_ context.Context, viewer *core.Caller) ([]*core.Repo, error) {
+ var out []*core.Repo
+ for _, r := range f.byID {
+ visible := r.Visibility == core.VisibilityPublic
+ if viewer != nil && (viewer.UserID == r.OwnerID || f.hasACL(r.ID, viewer.UserID)) {
+ visible = true
+ }
+ if visible {
+ out = append(out, r)
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
+ return out, nil
+}
+
func (f *fakeStore) ListReposForDashboard(_ context.Context, userID int) ([]*core.Repo, error) {
var out []*core.Repo
for _, r := range f.byID {
@@ -256,16 +275,26 @@ type fakeSession struct {
// which every page reading the log for decoration has to survive.
logErr error
closed bool
+
+ // Read counters. The /ready page's head-hash gate is a claim about reads not
+ // happening, and the only way to check that is to count them: a timing
+ // measurement would pass on a fast machine whatever the code did.
+ rowReads int
+ tableReads int
+ logReads int
+ opens int
}
func (s *fakeSession) Branches(context.Context) ([]browse.Branch, error) { return s.branches, nil }
func (s *fakeSession) Log(_ context.Context, _, _ string, _ int) ([]browse.CommitInfo, string, error) {
+ s.logReads++
if s.logErr != nil {
return nil, "", s.logErr
}
return s.commits, "", nil
}
func (s *fakeSession) Tables(_ context.Context, _ string) ([]browse.TableInfo, error) {
+ s.tableReads++
return s.tables, nil
}
func (s *fakeSession) TableHash(_ context.Context, refStr, table string) (string, bool, error) {
@@ -273,6 +302,7 @@ func (s *fakeSession) TableHash(_ context.Context, refStr, table string) (string
return h, ok, nil
}
func (s *fakeSession) Rows(_ context.Context, ref, table string, _, _ int) (*browse.RowPage, error) {
+ s.rowReads++
if byTable, ok := s.rowsByRef[ref]; ok {
if p, ok := byTable[table]; ok {
return p, nil
@@ -292,12 +322,28 @@ func (s *fakeSession) CommitSummary(_ context.Context, _ string) (*browse.Commit
}
func (s *fakeSession) Close() error { s.closed = true; return nil }
-type fakeBrowse struct{ sess *fakeSession }
+type fakeBrowse struct {
+ sess *fakeSession
+ // byPath is a session per store path, for the pages that open more than one
+ // database in a request (/ready). A path absent here falls back to sess, so
+ // every single-database test is unaffected.
+ byPath map[string]*fakeSession
+ // errByPath is a store that refuses to open, keyed the same way.
+ errByPath map[string]error
+}
-func (b *fakeBrowse) Open(context.Context, string) (BrowseSession, error) {
+func (b *fakeBrowse) Open(_ context.Context, path string) (BrowseSession, error) {
+ if err, ok := b.errByPath[path]; ok {
+ return nil, err
+ }
+ if s, ok := b.byPath[path]; ok {
+ s.opens++
+ return s, nil
+ }
if b.sess == nil {
return &fakeSession{}, nil
}
+ b.sess.opens++
return b.sess, nil
}