| 1 |
|
package mcpsrv |
| 2 |
|
|
| 3 |
|
import ( |
| 4 |
|
"context" |
| 5 |
|
"errors" |
| 6 |
|
"fmt" |
| 7 |
|
"strings" |
| 8 |
|
|
| 9 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/core" |
| 10 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/doc" |
| 11 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/search" |
| 12 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/service" |
| 13 |
|
) |
| 14 |
|
|
| 15 |
|
// Reader is the part of the orchestration layer the read tools call. |
| 16 |
|
// *service.Service satisfies it. |
| 17 |
|
// |
| 18 |
|
// It is deliberately three methods. Every one of them is the same function the |
| 19 |
|
// REST handlers, the GraphQL resolvers and the web UI call, which is what the |
| 20 |
|
// design means by "the MCP tools call the same resolver layer, not a parallel |
| 21 |
|
// implementation" — a fourth method here that service/ does not have would be |
| 22 |
|
// the beginning of a second implementation. |
| 23 |
|
// |
| 24 |
|
// ReadDocument is absent on purpose. Addressing a document by id needs the |
| 25 |
|
// space's whole document set anyway (see resolvePage), and reading the body |
| 26 |
|
// from that set rather than issuing a second, path-only read keeps one code |
| 27 |
|
// path instead of two that must agree about what an id names. |
| 28 |
|
type Reader interface { |
| 29 |
|
ListSpaces(ctx context.Context) ([]*service.Space, error) |
| 30 |
|
OpenSpace(ctx context.Context, ref core.SpaceRef) (*service.Space, error) |
| 31 |
|
// Archive resolves rev and returns the space's addressable document set at |
| 32 |
|
// it, with every document's bytes, from one tree walk. It replaced a |
| 33 |
|
// ResolveRev/ListDocuments pair here, and the conversion back into git |
| 34 |
|
// documents this package used to perform to reach doc.FromDocuments. |
| 35 |
|
Archive(ctx context.Context, sp *service.Space, rev string) (*doc.Archive, map[string][]byte, error) |
| 36 |
|
} |
| 37 |
|
|
| 38 |
|
// Searcher is the query side of the one global index. *search.Index satisfies |
| 39 |
|
// it. |
| 40 |
|
type Searcher interface { |
| 41 |
|
Search(ctx context.Context, q search.Query) (search.Results, error) |
| 42 |
|
} |
| 43 |
|
|
| 44 |
|
// Compile-time assertions that the production types satisfy the interfaces |
| 45 |
|
// this package is written against. They are here rather than in a test so that |
| 46 |
|
// a signature change in service/ or search/ breaks the build of the package |
| 47 |
|
// that depends on them, not of a test somebody may not run. |
| 48 |
|
var ( |
| 49 |
|
_ Reader = (*service.Service)(nil) |
| 50 |
|
_ Searcher = (*search.Index)(nil) |
| 51 |
|
_ Writer = (*service.Service)(nil) |
| 52 |
|
) |
| 53 |
|
|
| 54 |
|
const ( |
| 55 |
|
// minRevLen and maxRevLen bound a git object name. They are the design's |
| 56 |
|
// X-Agent-Base grammar — 7-64 lowercase hex — reused here so that "a |
| 57 |
|
// revision an agent may name" has one spelling across every surface. |
| 58 |
|
minRevLen = 7 |
| 59 |
|
maxRevLen = 64 |
| 60 |
|
) |
| 61 |
|
|
| 62 |
|
// parseSpace resolves the tool's space argument, which is written the way the |
| 63 |
|
// design writes a space everywhere else: "~owner/name". |
| 64 |
40 |
func parseSpace(s string) (core.SpaceRef, error) { |
| 65 |
40 |
trimmed := strings.TrimSpace(s) |
| 66 |
40 |
if trimmed == "" { |
| 67 |
1 |
return core.SpaceRef{}, errors.New("space must not be empty; call spec_list with no arguments to list spaces") |
| 68 |
1 |
} |
| 69 |
39 |
ref, err := core.ParseSpaceRef(trimmed) |
| 70 |
39 |
if err != nil { |
| 71 |
1 |
return core.SpaceRef{}, fmt.Errorf("space %q: %w", trimmed, err) |
| 72 |
1 |
} |
| 73 |
38 |
return ref, nil |
| 74 |
|
} |
| 75 |
|
|
| 76 |
|
// parseRev turns the tool's optional rev argument into a revision string for |
| 77 |
|
// service/. An empty argument becomes service.ApprovedRev, which is the read |
| 78 |
|
// contract's default: the approved head. |
| 79 |
|
// |
| 80 |
|
// Anything else must be a git object name. Ref names are refused even though |
| 81 |
|
// service/ would happily resolve them, and that refusal is the whole point: |
| 82 |
|
// "proposals/42" is a legal revision one layer down, so accepting it here |
| 83 |
|
// would make unreviewed proposal content reachable by a plausible-looking |
| 84 |
|
// argument. Reading it requires naming a commit sha, which no read tool ever |
| 85 |
|
// hands back, so it cannot be reached by accident. |
| 86 |
26 |
func parseRev(s string) (string, error) { |
| 87 |
26 |
rev := strings.TrimSpace(s) |
| 88 |
26 |
if rev == "" { |
| 89 |
17 |
return service.ApprovedRev, nil |
| 90 |
17 |
} |
| 91 |
9 |
if len(rev) < minRevLen || len(rev) > maxRevLen { |
| 92 |
3 |
return "", fmt.Errorf("rev %q must be a git object name of %d-%d hex characters; "+ |
| 93 |
3 |
"omit rev to read the approved head", rev, minRevLen, maxRevLen) |
| 94 |
3 |
} |
| 95 |
162 |
for i := 0; i < len(rev); i++ { |
| 96 |
162 |
c := rev[i] |
| 97 |
162 |
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') { |
| 98 |
160 |
continue |
| 99 |
|
} |
| 100 |
2 |
return "", fmt.Errorf("rev %q must be a git object name (lowercase hex), not a branch or ref name; "+ |
| 101 |
2 |
"omit rev to read the approved head", rev) |
| 102 |
|
} |
| 103 |
4 |
return rev, nil |
| 104 |
|
} |
| 105 |
|
|
| 106 |
|
// archiveAt reads a space at a revision and returns its addressable document |
| 107 |
|
// set, the bodies keyed by path, and the resolved commit. |
| 108 |
|
// |
| 109 |
|
// service/ does the work: it resolves the revision first and reads at the |
| 110 |
|
// resolved sha, never at the caller's string. That is what makes an unpinned |
| 111 |
|
// read pinnable — the rev reported back names the exact bytes returned, so a |
| 112 |
|
// merge landing between the resolve and the read cannot make one answer |
| 113 |
|
// describe two revisions. |
| 114 |
|
// |
| 115 |
|
// The whole space is read for one document. At the confirmed volume — tens of |
| 116 |
|
// documents a day — that is cheap, and the alternative is worse: doc.Archive is |
| 117 |
|
// where the design's addressing rule (id when valid and unique, else path) |
| 118 |
|
// actually lives, and it is built from a revision's whole document set. A |
| 119 |
|
// path-only fast path would be a second addressing implementation. |
| 120 |
19 |
func archiveAt(ctx context.Context, b Backend, sp *service.Space, rev string) (*doc.Archive, map[string][]byte, string, error) { |
| 121 |
19 |
arc, bodies, err := b.Docs.Archive(ctx, sp, rev) |
| 122 |
19 |
if err != nil { |
| 123 |
2 |
return nil, nil, "", err |
| 124 |
2 |
} |
| 125 |
17 |
return arc, bodies, arc.Rev, nil |
| 126 |
|
} |
| 127 |
|
|
| 128 |
|
// resolvePage applies the design's addressing rule to one caller-supplied |
| 129 |
|
// token, using the archive's own lookups rather than reimplementing them: |
| 130 |
|
// |
| 131 |
|
// - a document with a well-formed id that no other document in its space |
| 132 |
|
// claims is addressed by that id; |
| 133 |
|
// - a document whose id is absent, malformed or duplicated is addressed by |
| 134 |
|
// its path — with or without the ".md" extension, since the read plane's |
| 135 |
|
// URL grammar carries no extension and an agent copying a path out of a |
| 136 |
|
// search hit has one. |
| 137 |
|
// |
| 138 |
|
// A token naming an id that two documents claim resolves to neither, and says |
| 139 |
|
// so. Silently picking one would point an agent at a document its author did |
| 140 |
|
// not mean, and the ambiguity would be invisible. |
| 141 |
15 |
func resolvePage(arc *doc.Archive, token string) (*doc.Page, error) { |
| 142 |
15 |
name := strings.TrimSpace(token) |
| 143 |
15 |
if name == "" { |
| 144 |
1 |
return nil, errors.New("document must not be empty") |
| 145 |
1 |
} |
| 146 |
14 |
if p, ok := arc.Page(name); ok { |
| 147 |
8 |
return p, nil |
| 148 |
8 |
} |
| 149 |
6 |
if p, ok := arc.ByPath(name); ok { |
| 150 |
3 |
return p, nil |
| 151 |
3 |
} |
| 152 |
3 |
if p, ok := arc.ByPath(name + ".md"); ok { |
| 153 |
1 |
return p, nil |
| 154 |
1 |
} |
| 155 |
2 |
if dup := duplicateIDPaths(arc, name); len(dup) > 1 { |
| 156 |
1 |
return nil, fmt.Errorf("document id %q is claimed by %d documents (%s) and resolves to none of them; "+ |
| 157 |
1 |
"address one of them by path instead", name, len(dup), strings.Join(dup, ", ")) |
| 158 |
1 |
} |
| 159 |
1 |
return nil, fmt.Errorf("no document %q in %s at %s", name, arc.Space, arc.Rev) |
| 160 |
|
} |
| 161 |
|
|
| 162 |
|
// duplicateIDPaths lists the paths of every document claiming id. doc/ excludes |
| 163 |
|
// a duplicated id from the archive's lookup, which is the correct resolution |
| 164 |
|
// but leaves nothing to report; this walk exists only to turn "not found" into |
| 165 |
|
// a message that names the collision. |
| 166 |
2 |
func duplicateIDPaths(arc *doc.Archive, id string) []string { |
| 167 |
2 |
var paths []string |
| 168 |
4 |
for _, p := range arc.All() { |
| 169 |
4 |
if p.DocID == id { |
| 170 |
2 |
paths = append(paths, p.Path) |
| 171 |
2 |
} |
| 172 |
|
} |
| 173 |
2 |
return paths |
| 174 |
|
} |