~bigbes/sr-ht-spec

ref: cac9fe1fb81fc0c100dab64c30ffe5d5f31a8442 sr-ht-spec/mcpsrv/backend.go -rw-r--r-- 8.4 KiB
cac9fe1f — bigbes docs: three receive hooks, and the read plane's rev guard 27 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
package mcpsrv

import (
	"context"
	"errors"
	"fmt"
	"strings"

	"github.com/go-git/go-git/v5/plumbing"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/doc"
	"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
	"sourcecraft.dev/bigbes/sr-ht-spec/search"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

// Reader is the part of the orchestration layer the read tools call.
// *service.Service satisfies it.
//
// It is deliberately four methods. Every one of them is the same function the
// REST handlers, the GraphQL resolvers and the web UI call, which is what the
// design means by "the MCP tools call the same resolver layer, not a parallel
// implementation" — a fifth method here that service/ does not have would be
// the beginning of a second implementation.
//
// ReadDocument is absent on purpose. Addressing a document by id needs the
// space's whole document set anyway (see resolvePage), and reading the body
// from that set rather than issuing a second, path-only read keeps one code
// path instead of two that must agree about what an id names.
type Reader interface {
	ListSpaces(ctx context.Context) ([]*service.Space, error)
	OpenSpace(ctx context.Context, ref core.SpaceRef) (*service.Space, error)
	ResolveRev(ctx context.Context, sp *service.Space, rev string) (string, error)
	ListDocuments(ctx context.Context, sp *service.Space, rev string) ([]service.Document, error)
}

// Searcher is the query side of the one global index. *search.Index satisfies
// it.
type Searcher interface {
	Search(ctx context.Context, q search.Query) (search.Results, error)
}

// Compile-time assertions that the production types satisfy the interfaces
// this package is written against. They are here rather than in a test so that
// a signature change in service/ or search/ breaks the build of the package
// that depends on them, not of a test somebody may not run.
var (
	_ Reader   = (*service.Service)(nil)
	_ Searcher = (*search.Index)(nil)
)

const (
	// minRevLen and maxRevLen bound a git object name. They are the design's
	// X-Agent-Base grammar — 7-64 lowercase hex — reused here so that "a
	// revision an agent may name" has one spelling across every surface.
	minRevLen = 7
	maxRevLen = 64
)

// parseSpace resolves the tool's space argument, which is written the way the
// design writes a space everywhere else: "~owner/name".
func parseSpace(s string) (core.SpaceRef, error) {
	trimmed := strings.TrimSpace(s)
	if trimmed == "" {
		return core.SpaceRef{}, errors.New("space must not be empty; call spec_list with no arguments to list spaces")
	}
	ref, err := core.ParseSpaceRef(trimmed)
	if err != nil {
		return core.SpaceRef{}, fmt.Errorf("space %q: %w", trimmed, err)
	}
	return ref, nil
}

// parseRev turns the tool's optional rev argument into a revision string for
// service/. An empty argument becomes service.ApprovedRev, which is the read
// contract's default: the approved head.
//
// Anything else must be a git object name. Ref names are refused even though
// service/ would happily resolve them, and that refusal is the whole point:
// "proposals/42" is a legal revision one layer down, so accepting it here
// would make unreviewed proposal content reachable by a plausible-looking
// argument. Reading it requires naming a commit sha, which no read tool ever
// hands back, so it cannot be reached by accident.
func parseRev(s string) (string, error) {
	rev := strings.TrimSpace(s)
	if rev == "" {
		return service.ApprovedRev, nil
	}
	if len(rev) < minRevLen || len(rev) > maxRevLen {
		return "", fmt.Errorf("rev %q must be a git object name of %d-%d hex characters; "+
			"omit rev to read the approved head", rev, minRevLen, maxRevLen)
	}
	for i := 0; i < len(rev); i++ {
		c := rev[i]
		if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
			continue
		}
		return "", fmt.Errorf("rev %q must be a git object name (lowercase hex), not a branch or ref name; "+
			"omit rev to read the approved head", rev)
	}
	return rev, nil
}

// archiveAt reads a space at a revision and builds the addressable document
// set, returning it together with the bodies keyed by path and the resolved
// commit.
//
// The revision is resolved first and everything below is read at the resolved
// sha, never at the caller's string. That is what makes an unpinned read
// pinnable: the rev reported back names the exact bytes returned, so a merge
// landing between the resolve and the read cannot make one answer describe two
// revisions.
//
// The whole space is read for one document. At the confirmed volume — tens of
// documents a day — that is cheap, and the alternative is worse: doc.Archive is
// where the design's addressing rule (id when valid and unique, else path)
// actually lives, and it is built from a revision's whole document set. A
// path-only fast path would be a second addressing implementation.
func archiveAt(ctx context.Context, b Backend, sp *service.Space, rev string) (*doc.Archive, map[string][]byte, string, error) {
	resolved, err := b.Docs.ResolveRev(ctx, sp, rev)
	if err != nil {
		return nil, nil, "", err
	}
	docs, err := b.Docs.ListDocuments(ctx, sp, resolved)
	if err != nil {
		return nil, nil, "", err
	}
	converted, err := toGitDocuments(docs)
	if err != nil {
		return nil, nil, "", err
	}
	arc := doc.FromDocuments(sp.Ref, resolved, converted)
	bodies := make(map[string][]byte, len(docs))
	for _, d := range docs {
		bodies[d.Path] = d.Data
	}
	return arc, bodies, resolved, nil
}

// toGitDocuments converts service/'s hex-object-name documents back into the
// shape doc.FromDocuments takes.
//
// This conversion should not exist, and it is the one place this package
// reaches below service/. service.Document carries Blob and Rev as hex strings
// precisely so that api/, mcpsrv/, graph/ and web/ need not import gitx — but
// doc.FromDocuments, which owns the addressing rule those surfaces have to
// apply, takes []gitx.Document. Until service/ exposes the archive itself,
// something above it has to bridge the two, and doing it here in four lines is
// better than restating the addressing rule in Go. A malformed sha is an
// error, not a zero hash: plumbing.NewHash silently yields the zero value for
// anything it cannot parse, and a document whose render-cache key is zero is a
// cache collision waiting to happen.
func toGitDocuments(docs []service.Document) ([]gitx.Document, error) {
	out := make([]gitx.Document, 0, len(docs))
	for _, d := range docs {
		if !plumbing.IsHash(d.Blob) {
			return nil, fmt.Errorf("document %q carries a malformed blob id %q", d.Path, d.Blob)
		}
		out = append(out, gitx.Document{Path: d.Path, Blob: plumbing.NewHash(d.Blob), Data: d.Data})
	}
	return out, nil
}

// resolvePage applies the design's addressing rule to one caller-supplied
// token, using the archive's own lookups rather than reimplementing them:
//
//   - a document with a well-formed id that no other document in its space
//     claims is addressed by that id;
//   - a document whose id is absent, malformed or duplicated is addressed by
//     its path — with or without the ".md" extension, since the read plane's
//     URL grammar carries no extension and an agent copying a path out of a
//     search hit has one.
//
// A token naming an id that two documents claim resolves to neither, and says
// so. Silently picking one would point an agent at a document its author did
// not mean, and the ambiguity would be invisible.
func resolvePage(arc *doc.Archive, token string) (*doc.Page, error) {
	name := strings.TrimSpace(token)
	if name == "" {
		return nil, errors.New("document must not be empty")
	}
	if p, ok := arc.Page(name); ok {
		return p, nil
	}
	if p, ok := arc.ByPath(name); ok {
		return p, nil
	}
	if p, ok := arc.ByPath(name + ".md"); ok {
		return p, nil
	}
	if dup := duplicateIDPaths(arc, name); len(dup) > 1 {
		return nil, fmt.Errorf("document id %q is claimed by %d documents (%s) and resolves to none of them; "+
			"address one of them by path instead", name, len(dup), strings.Join(dup, ", "))
	}
	return nil, fmt.Errorf("no document %q in %s at %s", name, arc.Space, arc.Rev)
}

// duplicateIDPaths lists the paths of every document claiming id. doc/ excludes
// a duplicated id from the archive's lookup, which is the correct resolution
// but leaves nothing to report; this walk exists only to turn "not found" into
// a message that names the collision.
func duplicateIDPaths(arc *doc.Archive, id string) []string {
	var paths []string
	for _, p := range arc.All() {
		if p.DocID == id {
			paths = append(paths, p.Path)
		}
	}
	return paths
}