~bigbes/sr-ht-spec

ref: d8a5164f5f1c6ac49b056eb0cb1b9c3d9db338fd sr-ht-spec/mcpsrv/backend.go -rw-r--r-- 7.0 KiB
d8a5164f — Eugene Blikh feat(webhooks): fire on proposal open/merge/reject (Phase 5a) 25 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
package mcpsrv

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

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/doc"
	"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 three 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 fourth 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)
	// Archive resolves rev and returns the space's addressable document set at
	// it, with every document's bytes, from one tree walk. It replaced a
	// ResolveRev/ListDocuments pair here, and the conversion back into git
	// documents this package used to perform to reach doc.FromDocuments.
	Archive(ctx context.Context, sp *service.Space, rev string) (*doc.Archive, map[string][]byte, 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 returns its addressable document
// set, the bodies keyed by path, and the resolved commit.
//
// service/ does the work: it resolves the revision first and reads 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) {
	arc, bodies, err := b.Docs.Archive(ctx, sp, rev)
	if err != nil {
		return nil, nil, "", err
	}
	return arc, bodies, arc.Rev, 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
}