~bigbes/sr-ht-dolt

ref: f91a2e80c6848098c00ef56d75f205e8eb88f744 sr-ht-dolt/mcpsrv/ready.go -rw-r--r-- 18.9 KiB
f91a2e80 — Eugene Blikh docs: read_rows answers strings or null 5 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
package mcpsrv

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/url"
	"strings"
	"time"

	"github.com/modelcontextprotocol/go-sdk/mcp"
	"go.bigb.es/auxilia/scribe"

	"sourcecraft.dev/bigbes/sr-ht-dolt/beads"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)

// ready_work, the last tool of docs/DESIGN.mcp.md §9.2 and the only one on this
// surface that answers about more than one database at a time.
//
// "What is ready to work" is answerable inside each beads tracker on this
// instance and, without this, nowhere across them — which is the question the
// split into a global tracker plus per-project trackers was supposed to make
// askable. The /ready page (docs/DESIGN.views.md ch. 4) answers it for a human;
// this answers it for an agent.
//
// # One aggregator, two renderings
//
// The set itself is beads.ReadyAcross's, unchanged and un-re-read: the same
// function the page calls, over the same projection cache shape, with the same
// three bounds (the head-hash gate, the TTL, the ceiling of
// beads.ReadyMaxDatabases). Nothing about the ready rule, the grouping or the
// ordering is restated here — two implementations would answer differently the
// first time either moved, and the whole point of this tool is that the page and
// the agent agree.
//
// What this file owns is the other half, which is exactly what beads/ refuses to
// know: which databases this caller may see at all. That is ListReposForViewer
// (the listing rule) followed by core.Allowed/OpBrowse per database (the access
// rule), applied before a single store is opened — the same two steps
// web/handlers_ready.go takes.
//
// # Two arms, one answer
//
// With a database named the tool answers about that one, resolved through the
// preamble every beads tool shares (openTrackerFor): the masked not-found of a
// database the caller may not read, the "not a beads tracker" refusal, the
// default branch. With none named it answers across every tracker the caller may
// see. Both arms then run through ReadyAcross with the same filter and the same
// cache, so the two cannot disagree about one database: the named arm is the
// cross arm restricted to a single candidate.

// readyWorkInput addresses the tool's two arms and carries the page's own
// filters.
//
// owner and name are optional *together*: naming neither is the cross-database
// arm, naming both is one database, and naming one of the two is a call that
// means nothing and is refused rather than guessed at.
//
// There is no ref argument, and that is not an omission. Across databases there
// is no ref to name — a branch of one tracker says nothing about another — so
// every database is read at its own default branch, exactly as the page reads
// it. A tracker's other branches are list_issues' business.
type readyWorkInput struct {
	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."`
	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."`

	Query    string `json:"q,omitempty" jsonschema:"a case-insensitive substring of an issue's id or title; it does not search bodies"`
	Assignee string `json:"assignee,omitempty" jsonschema:"an exact assignee"`
	Priority string `json:"priority,omitempty" jsonschema:"an exact priority as stored: \"0\" (highest) through \"3\""`

	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."`
}

// readyCardJSON is one ready issue.
//
// It is issueCardJSON minus the two fields that would be constants here: every
// card in this answer is ready and every one of them is in the open status
// category — that is what the ready rule selects — and a field whose value is
// fixed by the tool's own name tells a caller nothing. There is no lane either:
// the projection this reads produces the ready set, not the board's bucketing.
//
// No bodies, for the reason §9.2 gives once: a listing carries identity and
// metadata, and get_issue carries the prose, one issue at a time.
type readyCardJSON struct {
	ID    string `json:"id"`
	Title string `json:"title"`

	Type     string   `json:"type"`
	Priority string   `json:"priority"`
	Assignee string   `json:"assignee"`
	Labels   []string `json:"labels"`

	// BlockedBy is how many dependencies this issue has and Blocks how many point
	// at it. A ready issue can still have dependencies — a closed blocker is a
	// dependency that does not block — so BlockedBy is not always zero, and that
	// is worth seeing.
	BlockedBy int `json:"blocked_by"`
	Blocks    int `json:"blocks"`
}

// readyHeadJSON is the head commit of the branch a database's ready set was read
// from.
//
// It is carried per database and not per answer because it is the one fact that
// makes the ready set checkable: a tracker that stopped receiving pushes still
// has a ready set, and reporting it without saying how old the data is would be
// exactly the claim this tool must not make silently.
type readyHeadJSON struct {
	Hash string    `json:"hash"`
	Time time.Time `json:"time"`
}

// readyDatabaseJSON is one tracker's ready work.
type readyDatabaseJSON struct {
	// Owner and Name are the address every other tool on this surface takes, so
	// a caller can go straight from a card here to get_issue there.
	Owner string `json:"owner"`
	Name  string `json:"name"`

	// Ref is the branch this database was read at — its own default branch.
	Ref string `json:"ref"`

	// Head is that branch's head commit, or null when the log could not be read.
	// Null is not a claim that the tracker is empty: the ready set beside it was
	// read at that very branch.
	Head *readyHeadJSON `json:"head"`

	// Ready is this database's ready issues, ordered by priority (0 first) then
	// id.
	Ready []readyCardJSON `json:"ready"`

	// Count is how many ready issues this database has after filtering, which is
	// not always how many are carried above: the answer's limit is spent across
	// databases, and this is the honest denominator per database.
	Count int `json:"count"`
}

// readyUnreadableJSON names a database that could not be read.
//
// It names it and nothing else: the underlying failure carries on-disk paths and
// dolt internals, and it goes to the log (sr-ht-dolt-7ta). The address is not a
// disclosure — it is a database this caller was already allowed to list.
type readyUnreadableJSON struct {
	Owner string `json:"owner"`
	Name  string `json:"name"`
}

type readyWorkOutput struct {
	// Databases carry the ready work, ordered by ready count descending then by
	// address — the page's own order. A database with nothing ready is absent
	// rather than present and empty.
	Databases []readyDatabaseJSON `json:"databases"`

	// Total is every ready issue that matched, across every database, before
	// Limit clipped the list — the honest denominator of the answer.
	Total int `json:"total"`

	// Limit is the limit that was applied, which is not always the one asked
	// for: a request above the cap is answered at the cap.
	Limit int `json:"limit"`

	// Truncated reports that matches were left behind by Limit. Narrow with q,
	// assignee or priority, or name one database.
	Truncated bool `json:"truncated"`

	// Considered is how many databases were actually read (or served from the
	// cache), and MaxDatabases is the ceiling one call may open.
	Considered   int `json:"considered"`
	MaxDatabases int `json:"max_databases"`

	// Capped says there were more candidate databases than MaxDatabases and only
	// the first of them were read. A silent cap reads as "that is everything",
	// which is the one thing an answer about a *set* must never imply.
	Capped bool `json:"capped"`

	// Unreadable names the databases whose stores this service could not read.
	// They are part of the answer rather than a silent omission for Capped's
	// reason: the ready set below is complete only for the databases that are not
	// in this list.
	Unreadable []readyUnreadableJSON `json:"unreadable"`
}

// borrowedSession lends an already-open session to the aggregation without
// handing over its lifetime.
//
// beads.ReadyAcross closes every session its opener produced, which is right
// when the opener opened it. In the named arm the session was opened by the
// handler — by the same preamble that resolved the database and checked the
// fingerprint — and is closed by that handler's own defer, so the borrowed copy
// must not close it a second time. A Close is not documented as idempotent
// anywhere in this service, and a double close is the kind of thing that works
// on a fake and corrupts a handle in production.
type borrowedSession struct{ BrowseSession }

func (borrowedSession) Close() error { return nil }

// --- registration -----------------------------------------------------------

// registerReadyWork installs ready_work (docs/DESIGN.mcp.md §9.2, the
// cross-database row).
func (s *Server) registerReadyWork() {
	mcp.AddTool(s.mcp, &mcp.Tool{
		Name:        "ready_work",
		Annotations: readOnlyTool,
		Description: "Answer \"what can be picked up right now\" — bd's ready set: issues that are open, " +
			"unblocked and not templates or scaffolding.\n\n" +
			"**Omit `owner` and `name` and it answers across every beads tracker you can see**, grouped " +
			"by database, busiest first. That is what this tool is for: an instance holds one tracker per " +
			"project plus a global one, and this is the only way to ask all of them at once. Name both to " +
			"ask one tracker.\n\n" +
			"Each database carries its own `ref` and `head` — the branch the set was read from and that " +
			"branch's head commit with its time. Read them: a tracker that stopped receiving pushes still " +
			"has a ready set, and its age is the only thing that says so.\n\n" +
			"`q`, `assignee` and `priority` narrow the issues; `limit` (default 200, cap 500) is spent " +
			"across all databases after filtering, `total` is how many matched, and `truncated` says some " +
			"were left behind. Each database's `count` is its own match total.\n\n" +
			"Two facts keep the answer honest about being a set. `capped` says there were more trackers " +
			"than `max_databases` and only the first were read. `unreadable` names the databases whose " +
			"stores could not be read at all — the set is complete only for the databases not in it.\n\n" +
			"Cards carry no issue bodies; call get_issue with a card's `owner`, `name` and `id` for the " +
			"description, design and acceptance criteria. A database you name that is not a beads tracker " +
			"says so; one you may not read is reported as not existing.",
	}, func(ctx context.Context, _ *mcp.CallToolRequest, in readyWorkInput) (*mcp.CallToolResult, readyWorkOutput, error) {
		out, err := s.readyWork(ctx, in)
		return nil, out, err
	})
}

// --- the handler ------------------------------------------------------------

// readyWork answers ready_work: the ready set of one named tracker, or of every
// tracker the caller may see.
//
// The clock is real and is passed into the aggregation rather than read inside
// it, exactly as listMemories passes its own: the TTL is the one thing about
// this answer that depends on when it was computed, and beads/ reads no hidden
// clock.
func (s *Server) readyWork(ctx context.Context, in readyWorkInput) (readyWorkOutput, error) {
	const tool = "ready_work"
	var out readyWorkOutput

	limit, err := pageLimit(in.Limit, defaultIssueLimit, maxIssueLimit, "issues")
	if err != nil {
		return out, err
	}
	// The filter is parsed by beads rather than assembled here, for boardQuery's
	// reason: the substring rule and the trimming are the page's, not a second
	// implementation of them reading the same cards.
	filter := beads.ParseReadyFilter(readyQuery(in))

	ref := databaseRef{Owner: in.Owner, Name: in.Name}
	named := ref.owner() != "" || ref.name() != ""

	var (
		dbs  []beads.ReadyDatabase
		open beads.ReadyOpener
	)
	if named {
		var sess BrowseSession
		dbs, open, sess, err = s.readyOne(ctx, tool, ref)
		if err != nil {
			return out, err
		}
		defer sess.Close()
	} else if dbs, open, err = s.readyAll(ctx, tool); err != nil {
		return out, err
	}

	view := beads.ReadyAcross(ctx, dbs, open, s.ready, filter, time.Now())

	// A store that cannot be read is a fact about this deployment and belongs in
	// the log with its cause; the answer names the database and not the failure.
	//
	// In the named arm it is not a partial answer at all: that database is the
	// whole subject of the call, its ref resolved and its fingerprint matched a
	// moment ago, so what failed afterwards is a table this service could not
	// read. Reporting an empty ready set there would be a false statement about
	// the tracker rather than a true one about the server.
	for _, f := range view.Failed {
		slog.Warn("reading a database for ready_work failed",
			"tool", tool, "database", f.Database.Slug(), scribe.Err(f.Err))
	}
	if named && len(view.Failed) > 0 {
		return out, internalError(view.Failed[0].Err, tool)
	}

	out = readyWorkOutput{
		Databases:    make([]readyDatabaseJSON, 0, len(view.Groups)),
		Total:        view.Total,
		Limit:        limit,
		Considered:   view.Considered,
		MaxDatabases: view.Max,
		Capped:       view.Capped,
		Unreadable:   make([]readyUnreadableJSON, 0, len(view.Failed)),
	}
	for _, f := range view.Failed {
		out.Unreadable = append(out.Unreadable, readyUnreadableJSON{
			Owner: f.Database.OwnerName,
			Name:  f.Database.Name,
		})
	}

	// The limit is spent across databases in the order the aggregation produced
	// them, so what a clipped answer carries is the busiest trackers' highest
	// priorities rather than an arbitrary slice. A database left with nothing is
	// absent rather than present and empty — the same rule the aggregation
	// applies to a tracker with no ready work.
	carried := 0
	for _, g := range view.Groups {
		entry := readyDatabaseJSON{
			Owner: g.Database.OwnerName,
			Name:  g.Database.Name,
			Ref:   g.Ref,
			Count: len(g.Cards),
			Ready: make([]readyCardJSON, 0, len(g.Cards)),
		}
		if g.Head != nil {
			entry.Head = &readyHeadJSON{Hash: g.Head.Hash, Time: g.Head.Date}
		}
		for _, c := range g.Cards {
			if carried >= limit {
				break
			}
			entry.Ready = append(entry.Ready, readyCardOf(c))
			carried++
		}
		if len(entry.Ready) == 0 {
			continue
		}
		out.Databases = append(out.Databases, entry)
	}
	out.Truncated = carried < out.Total
	return out, nil
}

// readyOne is the named arm's candidate list: one database, resolved and opened
// through the preamble every beads tool shares.
//
// Going through openTrackerFor is what makes this arm answer like the rest of
// the surface without restating any of it — the masked not-found, the "not a
// beads tracker" refusal, the default branch and the store that will not open
// are all decided there. The session it returns is the caller's to close.
func (s *Server) readyOne(ctx context.Context, tool string, ref databaseRef) (
	[]beads.ReadyDatabase, beads.ReadyOpener, BrowseSession, error,
) {
	if ref.owner() == "" || ref.name() == "" {
		return nil, nil, nil, errors.New(
			"address a database by both its owner and its name, as list_databases reports them — " +
				"or name neither, and this answers across every tracker you can see")
	}

	repo, sess, _, err := s.openTrackerFor(ctx, tool, ref, "")
	if err != nil {
		return nil, nil, nil, err
	}
	dbs := []beads.ReadyDatabase{{ID: repo.ID, OwnerName: repo.OwnerName, Name: repo.Name}}
	open := func(context.Context, beads.ReadyDatabase) (beads.ReadySession, error) {
		return borrowedSession{sess}, nil
	}
	return dbs, open, sess, nil
}

// readyAll is the cross-database arm's candidate list: every database this
// caller may browse.
//
// It is two steps and they are not the same rule. ListReposForViewer applies the
// *listing* rule (PUBLIC to everyone including anonymity, plus whatever the
// caller owns or holds an ACL entry on), and core.Allowed then applies the
// *access* rule per database. A database the caller may not browse is simply
// absent — not a refusal, not a count, not a named group with hidden contents —
// and it is dropped here, before any store is opened, so its very existence
// costs nothing observable.
//
// An ACL lookup that fails takes the whole call with it rather than quietly
// narrowing the answer. "I could not check" is not "you may not", and an agent
// told that its tracker has no ready work believes it.
func (s *Server) readyAll(ctx context.Context, tool string) ([]beads.ReadyDatabase, beads.ReadyOpener, error) {
	caller := callerOf(ctx)

	repos, err := s.repos.ListReposForViewer(ctx, caller)
	if err != nil {
		// No database was addressed, so there is no "that one does not exist" to
		// answer: whatever went wrong enumerating them is this service's.
		return nil, nil, internalError(err, tool)
	}

	// The on-disk path never reaches beads: it is this service's arrangement of
	// its own storage, and the aggregation addresses a database by the identity
	// its cache is keyed on. The opener closes over this map, so a database that
	// was filtered out above 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 {
		var mode *core.AccessMode
		if caller != nil {
			if mode, err = s.repos.EffectiveAccess(ctx, caller.UserID, repo.ID); err != nil {
				return nil, nil, internalError(err, tool)
			}
		}
		if !core.Allowed(caller, repo, mode, 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("mcpsrv: no store path for database %s", d.Slug())
		}
		return s.opener.Open(ctx, path)
	}
	return dbs, open, nil
}

// readyQuery renders the tool's filters as the query the page's filter parser
// reads, so that what narrows this answer is beads.ReadyFilter and not a second
// implementation of it applied to the same cards.
//
// The parser's ?db= is deliberately not offered: naming databases is what owner
// and name already do, one at a time, through the resolution every other tool on
// this surface uses.
func readyQuery(in readyWorkInput) url.Values {
	q := url.Values{}
	set := func(key, value string) {
		if value = strings.TrimSpace(value); value != "" {
			q.Set(key, value)
		}
	}
	set("q", in.Query)
	set("assignee", in.Assignee)
	set("priority", in.Priority)
	return q
}

// readyCardOf projects one ready card. Labels are an array rather than a null,
// so an agent can loop without a nil check.
func readyCardOf(c beads.Card) readyCardJSON {
	labels := c.Labels
	if labels == nil {
		labels = []string{}
	}
	return readyCardJSON{
		ID:        c.ID,
		Title:     c.Title,
		Type:      c.Type,
		Priority:  c.Priority,
		Assignee:  c.Assignee,
		Labels:    labels,
		BlockedBy: c.BlockedBy,
		Blocks:    c.Blocks,
	}
}