~bigbes/sr-ht-dolt

043b0fd7ade4c7efca7ece081fcc4cabb195534a — Eugene Blikh 5 days ago e4bc922
web: answer what is ready across every tracker
M beads/build.go => beads/build.go +10 -58
@@ 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"),

M beads/milestones.go => beads/milestones.go +2 -21
@@ 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{}

A beads/ready.go => beads/ready.go +492 -0
@@ 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=<owner>/<name> 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
	})
}

A beads/ready_test.go => beads/ready_test.go +477 -0
@@ 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)
}

M beads/rows.go => beads/rows.go +99 -0
@@ 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)) {

M web/adapters.go => web/adapters.go +4 -0
@@ 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)
}

M web/deps.go => web/deps.go +7 -0
@@ 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

A web/handlers_ready.go => web/handlers_ready.go +90 -0
@@ 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)
}

A web/handlers_ready_test.go => web/handlers_ready_test.go +347 -0
@@ 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 &middot; 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)
}

M web/router.go => web/router.go +12 -0
@@ 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)


M web/templates/index.html => web/templates/index.html +6 -1
@@ 10,6 10,10 @@
    </p>
    <a href="/create" class="btn btn-primary btn-block"
      >Create new database {{icon "caret-right"}}</a>
    {{/* The one question no single database answers: what is ready to work
         across every tracker this viewer can browse. */}}
    <a href="/ready" class="btn btn-default btn-block"
      >What is ready to work {{icon "caret-right"}}</a>
    <a href="/settings/keys" class="btn btn-default btn-block"
      >Configure dolt credentials {{icon "caret-right"}}</a>
  </div>


@@ 33,7 37,8 @@
</p>
<p>
  <a href="{{.LoginURL}}" rel="nofollow">Log in</a> to create and manage
  databases.
  databases. The databases carrying a <code>bd</code> issue tracker also answer
  <a href="/ready">what is ready to work</a> across all of them.
</p>
{{end}}
{{- end}}

A web/templates/ready.html => web/templates/ready.html +168 -0
@@ 0,0 1,168 @@
{{define "content" -}}
<style>
/* Scoped ready styles, inlined in the same flat todo.sr.ht idiom as the beads,
   milestones and memory views (the scss bundle is not rebuilt in dev): square,
   hairline borders, monospace ids, muted small type, no radius and no shadow.
   Colours come from the CSS variables that mirror core.sr.ht's Bootstrap
   palette in the light default and the prefers-color-scheme: dark variant
   sourcehut ships. No JavaScript: the filters are a GET form and every
   narrowing is a link. The card is .bead-row, the board's card, because it is
   the same card read in a different arrangement. */
.ready {
  --accent: #2f9e44;
  --bd-bg: #ffffff; --bd-panel: #f2f3f5; --bd-fg: #212529;
  --bd-muted: #6c757d; --bd-border: #ced4da; --bd-stripe: rgba(0,0,0,.05);
  --bd-danger: #b52a2a;
}
@media (prefers-color-scheme: dark) {
  .ready {
    --bd-bg: #212529; --bd-panel: #343a40; --bd-fg: #dee2e6;
    --bd-muted: #adb5bd; --bd-border: #6c757d; --bd-stripe: rgba(255,255,255,.05);
    --bd-danger: #ff6b6b;
  }
}

.ready { max-width: 52rem; }
.ready .ready-lede { color: var(--bd-muted); font-size: .82rem; margin: -.35rem 0 .8rem; }

/* filter bar: flat hairline controls, as on the board */
.ready-filter { display: flex; flex-wrap: wrap; gap: .4rem; align-items: center; margin-bottom: .4rem; }
.ready-filter input, .ready-filter select, .ready-filter button {
  font: inherit; font-size: .82rem; padding: .25rem .45rem; color: var(--bd-fg);
  background: var(--bd-bg); border: 1px solid var(--bd-border); border-radius: 0;
}
.ready-filter input[type="search"] { min-width: 12rem; flex: 1 1 12rem; }
.ready-filter button { background: var(--bd-panel); cursor: pointer; }
.ready-filter .ready-clear { font-size: .82rem; color: var(--bd-muted); }

/* the two things a reader must not over-read the page by: the ceiling, and the
   databases that could not be read at all */
.ready-note { font-size: .78rem; color: var(--bd-muted); margin: 0 0 .4rem; }
.ready-note.capped { color: var(--bd-danger); }

/* one database's ready work: the lane head's shape, with the freshness line of
   that database's own store under it */
.ready-group { margin-bottom: .9rem; }
.ready-head {
  display: flex; align-items: baseline; gap: .5rem; padding: .3rem .5rem; font-weight: 700;
  background: var(--bd-panel); color: var(--bd-fg);
  border: 1px solid var(--bd-border); border-top: 2px solid var(--accent);
}
.ready-head a { color: var(--bd-fg); }
.ready-head .only { font-weight: 400; font-size: .75rem; color: var(--bd-muted); }
.ready-head .count { margin-left: auto; color: var(--bd-muted); font-weight: 400; font-size: .85rem; }
.ready-group .beads-freshness {
  font-size: .78rem; color: var(--bd-muted); margin: 0;
  padding: .15rem .5rem; border: 1px solid var(--bd-border); border-top: none;
}
.ready-group .beads-freshness a { color: var(--bd-muted); }
.ready-group .beads-freshness code { font-size: .72rem; color: inherit; }
.ready-body { border: 1px solid var(--bd-border); border-top: none; }

/* issue rows: the board's card, verbatim */
.bead-row { display: block; padding: .3rem .5rem; text-decoration: none; color: var(--bd-fg); border-top: 1px solid var(--bd-border); }
.bead-row:first-child { border-top: none; }
.bead-row:hover { background: var(--bd-stripe); text-decoration: none; color: var(--bd-fg); }
.bead-row .r-id { font-family: monospace; font-size: .72rem; color: var(--bd-muted); }
.bead-row .r-title { display: block; margin: .05rem 0 .2rem; color: var(--bd-fg); }
.bead-row .r-meta { display: flex; flex-wrap: wrap; gap: .25rem; align-items: center; }
.ready-dot { margin-right: .25rem; }

/* labels: sourcehut .label — squared, hairline-bordered, transparent */
.blabel {
  display: inline-block; font-size: .7rem; line-height: 1.5; padding: 0 .3rem; white-space: nowrap;
  border: 1px solid var(--bd-border); background: transparent; color: var(--bd-fg);
}
.blabel.prio { border-color: #c9930a; color: var(--bd-fg); font-weight: 700; }
.blabel.assignee { border-color: var(--accent); }
.blabel.muted { color: var(--bd-muted); }

.ready-empty { color: var(--bd-muted); font-style: italic; }
</style>

<div class="ready">
<h2>Ready to work</h2>
<p class="ready-lede">
  {{.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}}
  &mdash; open, unblocked, and nobody is on {{if eq .Data.Total 1}}it{{else}}them{{end}} yet.
</p>

<form class="ready-filter" method="get" action="/ready">
  {{/* The database selection is not a form control (narrowing to one is a link
       on its header), but this form is a GET: without carrying it, searching
       from one database would answer across all of them. */}}
  {{range .Data.Filter.Databases}}<input type="hidden" name="db" value="{{.}}">{{end}}
  <input type="search" name="q" value="{{.Data.Filter.Query}}" placeholder="Search ready work">
  <select name="assignee">
    <option value="">anyone</option>
    {{$sel := .Data.Filter.Assignee}}
    {{range .Data.Options.Assignees}}<option value="{{.}}"{{if eq . $sel}} selected{{end}}>@{{.}}</option>{{end}}
  </select>
  <select name="priority">
    <option value="">any priority</option>
    {{$psel := .Data.Filter.Priority}}
    {{range .Data.Options.Priorities}}<option value="{{.}}"{{if eq . $psel}} selected{{end}}>P{{.}}</option>{{end}}
  </select>
  <button type="submit">Filter</button>
  {{if .Data.Filter.Active}}<a class="ready-clear" href="/ready">Clear</a>{{end}}
</form>

{{if .Data.Filter.Databases}}
<p class="ready-note">
  Narrowed to {{len .Data.Filter.Databases}} of the databases you can browse.
  <a href="/ready{{withQuery .Data.Query "db" ""}}">Every database</a>
</p>
{{end}}

{{if .Data.Capped}}
{{/* A silent cap reads as "that is everything". */}}
<p class="ready-note capped">
  More than {{.Data.Max}} databases matched; only the first {{.Data.Max}} were read.
  Narrow with the database links below to see the rest.
</p>
{{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. */}}
<p class="ready-note">
  {{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.
</p>
{{end}}

{{range .Data.Groups}}
<section class="ready-group">
  <header class="ready-head">
    <a href="/~{{.Database.OwnerName}}/{{.Database.Name}}/view/beads?ready=1">~{{.Database.Slug}}</a>
    {{if ne (len $.Data.Filter.Databases) 1}}<a class="only" href="/ready{{withQuery $.Data.Query "db" .Database.Slug}}">only this one</a>{{end}}
    <span class="count">{{len .Cards}} ready</span>
  </header>
  {{template "beadsHead" (dict "Repo" .Database "Ref" .Ref "Head" .Head)}}
  <div class="ready-body">
    {{$db := .Database}}
    {{range .Cards}}
    <a class="bead-row" href="/~{{$db.OwnerName}}/{{$db.Name}}/view/beads?issue={{.ID | urlquery}}">
      <span class="r-id"><span class="ready-dot" title="Ready to work">&#9889;</span>{{.ID}}</span>
      <span class="r-title">{{.Title}}</span>
      <span class="r-meta">
        {{if .PriorityLabel}}<span class="blabel prio">{{.PriorityLabel}}</span>{{end}}
        {{if .Type}}<span class="blabel">{{.Type}}</span>{{end}}
        {{if .Assignee}}<span class="blabel assignee">@{{.Assignee}}</span>{{end}}
        {{range .Labels}}<span class="blabel muted">{{.}}</span>{{end}}
        {{if .Blocks}}<span class="blabel muted">blocks {{.Blocks}}</span>{{end}}
      </span>
    </a>
    {{end}}
  </div>
</section>
{{else}}
{{if .Data.Filter.Active}}
<p class="ready-empty">Nothing ready matches these filters. <a href="/ready">Clear them</a>.</p>
{{else}}
<p class="ready-empty">Nothing is ready to work in the databases you can browse.</p>
{{end}}
{{end}}
</div>
{{- end}}

M web/web_test.go => web/web_test.go +48 -2
@@ 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
}