coverage~bigbes/sr-ht-doltede3b0bbmcpsrv/ready.go

Coverage
94.2% 81/86 statements
Δ
Blob
64e0cf6
1 package mcpsrv
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "net/url"
9 "strings"
10 "time"
11
12 "github.com/modelcontextprotocol/go-sdk/mcp"
13 "go.bigb.es/auxilia/scribe"
14
15 "sourcecraft.dev/bigbes/sr-ht-dolt/beads"
16 "sourcecraft.dev/bigbes/sr-ht-dolt/core"
17 )
18
19 // ready_work, the last tool of docs/DESIGN.mcp.md §9.2 and the only one on this
20 // surface that answers about more than one database at a time.
21 //
22 // "What is ready to work" is answerable inside each beads tracker on this
23 // instance and, without this, nowhere across them — which is the question the
24 // split into a global tracker plus per-project trackers was supposed to make
25 // askable. The /ready page (docs/DESIGN.views.md ch. 4) answers it for a human;
26 // this answers it for an agent.
27 //
28 // # One aggregator, two renderings
29 //
30 // The set itself is beads.ReadyAcross's, unchanged and un-re-read: the same
31 // function the page calls, over the same projection cache shape, with the same
32 // three bounds (the head-hash gate, the TTL, the ceiling of
33 // beads.ReadyMaxDatabases). Nothing about the ready rule, the grouping or the
34 // ordering is restated here — two implementations would answer differently the
35 // first time either moved, and the whole point of this tool is that the page and
36 // the agent agree.
37 //
38 // What this file owns is the other half, which is exactly what beads/ refuses to
39 // know: which databases this caller may see at all. That is ListReposForViewer
40 // (the listing rule) followed by core.Allowed/OpBrowse per database (the access
41 // rule), applied before a single store is opened — the same two steps
42 // web/handlers_ready.go takes.
43 //
44 // # Two arms, one answer
45 //
46 // With a database named the tool answers about that one, resolved through the
47 // preamble every beads tool shares (openTrackerFor): the masked not-found of a
48 // database the caller may not read, the "not a beads tracker" refusal, the
49 // default branch. With none named it answers across every tracker the caller may
50 // see. Both arms then run through ReadyAcross with the same filter and the same
51 // cache, so the two cannot disagree about one database: the named arm is the
52 // cross arm restricted to a single candidate.
53
54 // readyWorkInput addresses the tool's two arms and carries the page's own
55 // filters.
56 //
57 // owner and name are optional *together*: naming neither is the cross-database
58 // arm, naming both is one database, and naming one of the two is a call that
59 // means nothing and is refused rather than guessed at.
60 //
61 // There is no ref argument, and that is not an omission. Across databases there
62 // is no ref to name — a branch of one tracker says nothing about another — so
63 // every database is read at its own default branch, exactly as the page reads
64 // it. A tracker's other branches are list_issues' business.
65 type readyWorkInput struct {
66 Owner string `json:"owner,omitempty" jsonschema:"the database owner's SourceHut username, without the \"~\" (a leading one is accepted). Omit it — together with name — to read every tracker you can see."`
67 Name string `json:"name,omitempty" jsonschema:"the database name, as list_databases reports it. Omit it — together with owner — to read every tracker you can see."`
68
69 Query string `json:"q,omitempty" jsonschema:"a case-insensitive substring of an issue's id or title; it does not search bodies"`
70 Assignee string `json:"assignee,omitempty" jsonschema:"an exact assignee"`
71 Priority string `json:"priority,omitempty" jsonschema:"an exact priority as stored: \"0\" (highest) through \"3\""`
72
73 Limit *int `json:"limit,omitempty" jsonschema:"how many ready issues to return across all databases, at most 500; defaults to 200. It is applied after filtering."`
74 }
75
76 // readyCardJSON is one ready issue.
77 //
78 // It is issueCardJSON minus the two fields that would be constants here: every
79 // card in this answer is ready and every one of them is in the open status
80 // category — that is what the ready rule selects — and a field whose value is
81 // fixed by the tool's own name tells a caller nothing. There is no lane either:
82 // the projection this reads produces the ready set, not the board's bucketing.
83 //
84 // No bodies, for the reason §9.2 gives once: a listing carries identity and
85 // metadata, and get_issue carries the prose, one issue at a time.
86 type readyCardJSON struct {
87 ID string `json:"id"`
88 Title string `json:"title"`
89
90 Type string `json:"type"`
91 Priority string `json:"priority"`
92 Assignee string `json:"assignee"`
93 Labels []string `json:"labels"`
94
95 // BlockedBy is how many dependencies this issue has and Blocks how many point
96 // at it. A ready issue can still have dependencies — a closed blocker is a
97 // dependency that does not block — so BlockedBy is not always zero, and that
98 // is worth seeing.
99 BlockedBy int `json:"blocked_by"`
100 Blocks int `json:"blocks"`
101 }
102
103 // readyHeadJSON is the head commit of the branch a database's ready set was read
104 // from.
105 //
106 // It is carried per database and not per answer because it is the one fact that
107 // makes the ready set checkable: a tracker that stopped receiving pushes still
108 // has a ready set, and reporting it without saying how old the data is would be
109 // exactly the claim this tool must not make silently.
110 type readyHeadJSON struct {
111 Hash string `json:"hash"`
112 Time time.Time `json:"time"`
113 }
114
115 // readyDatabaseJSON is one tracker's ready work.
116 type readyDatabaseJSON struct {
117 // Owner and Name are the address every other tool on this surface takes, so
118 // a caller can go straight from a card here to get_issue there.
119 Owner string `json:"owner"`
120 Name string `json:"name"`
121
122 // Ref is the branch this database was read at — its own default branch.
123 Ref string `json:"ref"`
124
125 // Head is that branch's head commit, or null when the log could not be read.
126 // Null is not a claim that the tracker is empty: the ready set beside it was
127 // read at that very branch.
128 Head *readyHeadJSON `json:"head"`
129
130 // Ready is this database's ready issues, ordered by priority (0 first) then
131 // id.
132 Ready []readyCardJSON `json:"ready"`
133
134 // Count is how many ready issues this database has after filtering, which is
135 // not always how many are carried above: the answer's limit is spent across
136 // databases, and this is the honest denominator per database.
137 Count int `json:"count"`
138 }
139
140 // readyUnreadableJSON names a database that could not be read.
141 //
142 // It names it and nothing else: the underlying failure carries on-disk paths and
143 // dolt internals, and it goes to the log (sr-ht-dolt-7ta). The address is not a
144 // disclosure — it is a database this caller was already allowed to list.
145 type readyUnreadableJSON struct {
146 Owner string `json:"owner"`
147 Name string `json:"name"`
148 }
149
150 type readyWorkOutput struct {
151 // Databases carry the ready work, ordered by ready count descending then by
152 // address — the page's own order. A database with nothing ready is absent
153 // rather than present and empty.
154 Databases []readyDatabaseJSON `json:"databases"`
155
156 // Total is every ready issue that matched, across every database, before
157 // Limit clipped the list — the honest denominator of the answer.
158 Total int `json:"total"`
159
160 // Limit is the limit that was applied, which is not always the one asked
161 // for: a request above the cap is answered at the cap.
162 Limit int `json:"limit"`
163
164 // Truncated reports that matches were left behind by Limit. Narrow with q,
165 // assignee or priority, or name one database.
166 Truncated bool `json:"truncated"`
167
168 // Considered is how many databases were actually read (or served from the
169 // cache), and MaxDatabases is the ceiling one call may open.
170 Considered int `json:"considered"`
171 MaxDatabases int `json:"max_databases"`
172
173 // Capped says there were more candidate databases than MaxDatabases and only
174 // the first of them were read. A silent cap reads as "that is everything",
175 // which is the one thing an answer about a *set* must never imply.
176 Capped bool `json:"capped"`
177
178 // Unreadable names the databases whose stores this service could not read.
179 // They are part of the answer rather than a silent omission for Capped's
180 // reason: the ready set below is complete only for the databases that are not
181 // in this list.
182 Unreadable []readyUnreadableJSON `json:"unreadable"`
183 }
184
185 // borrowedSession lends an already-open session to the aggregation without
186 // handing over its lifetime.
187 //
188 // beads.ReadyAcross closes every session its opener produced, which is right
189 // when the opener opened it. In the named arm the session was opened by the
190 // handler — by the same preamble that resolved the database and checked the
191 // fingerprint — and is closed by that handler's own defer, so the borrowed copy
192 // must not close it a second time. A Close is not documented as idempotent
193 // anywhere in this service, and a double close is the kind of thing that works
194 // on a fake and corrupts a handle in production.
195 type borrowedSession struct{ BrowseSession }
196
197 5 func (borrowedSession) Close() error { return nil }
198
199 // --- registration -----------------------------------------------------------
200
201 // registerReadyWork installs ready_work (docs/DESIGN.mcp.md §9.2, the
202 // cross-database row).
203 126 func (s *Server) registerReadyWork() {
204 126 mcp.AddTool(s.mcp, &mcp.Tool{
205 126 Name: "ready_work",
206 126 Annotations: readOnlyTool,
207 126 Description: "Answer \"what can be picked up right now\" — bd's ready set: issues that are open, " +
208 126 "unblocked and not templates or scaffolding.\n\n" +
209 126 "**Omit `owner` and `name` and it answers across every beads tracker you can see**, grouped " +
210 126 "by database, busiest first. That is what this tool is for: an instance holds one tracker per " +
211 126 "project plus a global one, and this is the only way to ask all of them at once. Name both to " +
212 126 "ask one tracker.\n\n" +
213 126 "Each database carries its own `ref` and `head` — the branch the set was read from and that " +
214 126 "branch's head commit with its time. Read them: a tracker that stopped receiving pushes still " +
215 126 "has a ready set, and its age is the only thing that says so.\n\n" +
216 126 "`q`, `assignee` and `priority` narrow the issues; `limit` (default 200, cap 500) is spent " +
217 126 "across all databases after filtering, `total` is how many matched, and `truncated` says some " +
218 126 "were left behind. Each database's `count` is its own match total.\n\n" +
219 126 "Two facts keep the answer honest about being a set. `capped` says there were more trackers " +
220 126 "than `max_databases` and only the first were read. `unreadable` names the databases whose " +
221 126 "stores could not be read at all — the set is complete only for the databases not in it.\n\n" +
222 126 "Cards carry no issue bodies; call get_issue with a card's `owner`, `name` and `id` for the " +
223 126 "description, design and acceptance criteria. A database you name that is not a beads tracker " +
224 126 "says so; one you may not read is reported as not existing.",
225 126 }, func(ctx context.Context, _ *mcp.CallToolRequest, in readyWorkInput) (*mcp.CallToolResult, readyWorkOutput, error) {
226 45 out, err := s.readyWork(ctx, in)
227 45 return nil, out, err
228 45 })
229 }
230
231 // --- the handler ------------------------------------------------------------
232
233 // readyWork answers ready_work: the ready set of one named tracker, or of every
234 // tracker the caller may see.
235 //
236 // The clock is real and is passed into the aggregation rather than read inside
237 // it, exactly as listMemories passes its own: the TTL is the one thing about
238 // this answer that depends on when it was computed, and beads/ reads no hidden
239 // clock.
240 45 func (s *Server) readyWork(ctx context.Context, in readyWorkInput) (readyWorkOutput, error) {
241 45 const tool = "ready_work"
242 45 var out readyWorkOutput
243 45
244 45 limit, err := pageLimit(in.Limit, defaultIssueLimit, maxIssueLimit, "issues")
245 45 if err != nil {
246 2 return out, err
247 2 }
248 // The filter is parsed by beads rather than assembled here, for boardQuery's
249 // reason: the substring rule and the trimming are the page's, not a second
250 // implementation of them reading the same cards.
251 43 filter := beads.ParseReadyFilter(readyQuery(in))
252 43
253 43 ref := databaseRef{Owner: in.Owner, Name: in.Name}
254 43 named := ref.owner() != "" || ref.name() != ""
255 43
256 43 var (
257 43 dbs []beads.ReadyDatabase
258 43 open beads.ReadyOpener
259 43 )
260 43 if named {
261 10 var sess BrowseSession
262 10 dbs, open, sess, err = s.readyOne(ctx, tool, ref)
263 10 if err != nil {
264 5 return out, err
265 5 }
266 5 defer sess.Close()
267 33 } else if dbs, open, err = s.readyAll(ctx, tool); err != nil {
268 0 return out, err
269 0 }
270
271 38 view := beads.ReadyAcross(ctx, dbs, open, s.ready, filter, time.Now())
272 38
273 38 // A store that cannot be read is a fact about this deployment and belongs in
274 38 // the log with its cause; the answer names the database and not the failure.
275 38 //
276 38 // In the named arm it is not a partial answer at all: that database is the
277 38 // whole subject of the call, its ref resolved and its fingerprint matched a
278 38 // moment ago, so what failed afterwards is a table this service could not
279 38 // read. Reporting an empty ready set there would be a false statement about
280 38 // the tracker rather than a true one about the server.
281 38 for _, f := range view.Failed {
282 30 slog.Warn("reading a database for ready_work failed",
283 30 "tool", tool, "database", f.Database.Slug(), scribe.Err(f.Err))
284 30 }
285 38 if named && len(view.Failed) > 0 {
286 0 return out, internalError(view.Failed[0].Err, tool)
287 0 }
288
289 38 out = readyWorkOutput{
290 38 Databases: make([]readyDatabaseJSON, 0, len(view.Groups)),
291 38 Total: view.Total,
292 38 Limit: limit,
293 38 Considered: view.Considered,
294 38 MaxDatabases: view.Max,
295 38 Capped: view.Capped,
296 38 Unreadable: make([]readyUnreadableJSON, 0, len(view.Failed)),
297 38 }
298 38 for _, f := range view.Failed {
299 30 out.Unreadable = append(out.Unreadable, readyUnreadableJSON{
300 30 Owner: f.Database.OwnerName,
301 30 Name: f.Database.Name,
302 30 })
303 30 }
304
305 // The limit is spent across databases in the order the aggregation produced
306 // them, so what a clipped answer carries is the busiest trackers' highest
307 // priorities rather than an arbitrary slice. A database left with nothing is
308 // absent rather than present and empty — the same rule the aggregation
309 // applies to a tracker with no ready work.
310 38 carried := 0
311 126 for _, g := range view.Groups {
312 126 entry := readyDatabaseJSON{
313 126 Owner: g.Database.OwnerName,
314 126 Name: g.Database.Name,
315 126 Ref: g.Ref,
316 126 Count: len(g.Cards),
317 126 Ready: make([]readyCardJSON, 0, len(g.Cards)),
318 126 }
319 126 if g.Head != nil {
320 126 entry.Head = &readyHeadJSON{Hash: g.Head.Hash, Time: g.Head.Date}
321 126 }
322 152 for _, c := range g.Cards {
323 152 if carried >= limit {
324 2 break
325 }
326 150 entry.Ready = append(entry.Ready, readyCardOf(c))
327 150 carried++
328 }
329 126 if len(entry.Ready) == 0 {
330 1 continue
331 }
332 125 out.Databases = append(out.Databases, entry)
333 }
334 38 out.Truncated = carried < out.Total
335 38 return out, nil
336 }
337
338 // readyOne is the named arm's candidate list: one database, resolved and opened
339 // through the preamble every beads tool shares.
340 //
341 // Going through openTrackerFor is what makes this arm answer like the rest of
342 // the surface without restating any of it — the masked not-found, the "not a
343 // beads tracker" refusal, the default branch and the store that will not open
344 // are all decided there. The session it returns is the caller's to close.
345 func (s *Server) readyOne(ctx context.Context, tool string, ref databaseRef) (
346 []beads.ReadyDatabase, beads.ReadyOpener, BrowseSession, error,
347 10 ) {
348 10 if ref.owner() == "" || ref.name() == "" {
349 2 return nil, nil, nil, errors.New(
350 2 "address a database by both its owner and its name, as list_databases reports them — " +
351 2 "or name neither, and this answers across every tracker you can see")
352 2 }
353
354 8 repo, sess, _, err := s.openTrackerFor(ctx, tool, ref, "")
355 8 if err != nil {
356 3 return nil, nil, nil, err
357 3 }
358 5 dbs := []beads.ReadyDatabase{{ID: repo.ID, OwnerName: repo.OwnerName, Name: repo.Name}}
359 5 open := func(context.Context, beads.ReadyDatabase) (beads.ReadySession, error) {
360 5 return borrowedSession{sess}, nil
361 5 }
362 5 return dbs, open, sess, nil
363 }
364
365 // readyAll is the cross-database arm's candidate list: every database this
366 // caller may browse.
367 //
368 // It is two steps and they are not the same rule. ListReposForViewer applies the
369 // *listing* rule (PUBLIC to everyone including anonymity, plus whatever the
370 // caller owns or holds an ACL entry on), and core.Allowed then applies the
371 // *access* rule per database. A database the caller may not browse is simply
372 // absent — not a refusal, not a count, not a named group with hidden contents —
373 // and it is dropped here, before any store is opened, so its very existence
374 // costs nothing observable.
375 //
376 // An ACL lookup that fails takes the whole call with it rather than quietly
377 // narrowing the answer. "I could not check" is not "you may not", and an agent
378 // told that its tracker has no ready work believes it.
379 33 func (s *Server) readyAll(ctx context.Context, tool string) ([]beads.ReadyDatabase, beads.ReadyOpener, error) {
380 33 caller := callerOf(ctx)
381 33
382 33 repos, err := s.repos.ListReposForViewer(ctx, caller)
383 33 if err != nil {
384 0 // No database was addressed, so there is no "that one does not exist" to
385 0 // answer: whatever went wrong enumerating them is this service's.
386 0 return nil, nil, internalError(err, tool)
387 0 }
388
389 // The on-disk path never reaches beads: it is this service's arrangement of
390 // its own storage, and the aggregation addresses a database by the identity
391 // its cache is keyed on. The opener closes over this map, so a database that
392 // was filtered out above has no path to be opened by.
393 33 paths := make(map[int]string, len(repos))
394 33 dbs := make([]beads.ReadyDatabase, 0, len(repos))
395 198 for _, repo := range repos {
396 198 var mode *core.AccessMode
397 198 if caller != nil {
398 68 if mode, err = s.repos.EffectiveAccess(ctx, caller.UserID, repo.ID); err != nil {
399 0 return nil, nil, internalError(err, tool)
400 0 }
401 }
402 198 if !core.Allowed(caller, repo, mode, core.OpBrowse) {
403 1 continue
404 }
405 197 paths[repo.ID] = repo.Path
406 197 dbs = append(dbs, beads.ReadyDatabase{
407 197 ID: repo.ID,
408 197 OwnerName: repo.OwnerName,
409 197 Name: repo.Name,
410 197 })
411 }
412
413 195 open := func(ctx context.Context, d beads.ReadyDatabase) (beads.ReadySession, error) {
414 195 path, ok := paths[d.ID]
415 195 if !ok {
416 0 return nil, fmt.Errorf("mcpsrv: no store path for database %s", d.Slug())
417 0 }
418 195 return s.opener.Open(ctx, path)
419 }
420 33 return dbs, open, nil
421 }
422
423 // readyQuery renders the tool's filters as the query the page's filter parser
424 // reads, so that what narrows this answer is beads.ReadyFilter and not a second
425 // implementation of it applied to the same cards.
426 //
427 // The parser's ?db= is deliberately not offered: naming databases is what owner
428 // and name already do, one at a time, through the resolution every other tool on
429 // this surface uses.
430 43 func readyQuery(in readyWorkInput) url.Values {
431 43 q := url.Values{}
432 129 set := func(key, value string) {
433 129 if value = strings.TrimSpace(value); value != "" {
434 9 q.Set(key, value)
435 9 }
436 }
437 43 set("q", in.Query)
438 43 set("assignee", in.Assignee)
439 43 set("priority", in.Priority)
440 43 return q
441 }
442
443 // readyCardOf projects one ready card. Labels are an array rather than a null,
444 // so an agent can loop without a nil check.
445 150 func readyCardOf(c beads.Card) readyCardJSON {
446 150 labels := c.Labels
447 150 if labels == nil {
448 150 labels = []string{}
449 150 }
450 150 return readyCardJSON{
451 150 ID: c.ID,
452 150 Title: c.Title,
453 150 Type: c.Type,
454 150 Priority: c.Priority,
455 150 Assignee: c.Assignee,
456 150 Labels: labels,
457 150 BlockedBy: c.BlockedBy,
458 150 Blocks: c.Blocks,
459 150 }
460 }