~bigbes/sr-ht-spec

ref: e1b3c64edf59eed1ee27a2a66c9983d23ad22a25 sr-ht-spec/service/read.go -rw-r--r-- 6.6 KiB
e1b3c64e — Eugene Blikh feat: service — wiring, space lifecycle, read paths, push validation, reconciler 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
package service

import (
	"context"
	"errors"
	"fmt"

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

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

// ApprovedRev is the revision string meaning "the space's approved head". It is
// the empty string so that a caller which simply forwards an absent ?rev= gets
// the approved revision by default, which is the read contract: reads default
// to the approved revision, because serving drafts by default would poison
// every downstream agent context with unreviewed text.
const ApprovedRev = ""

// Document is one document as it exists at a revision.
//
// Blob and Rev are hex object names rather than plumbing.Hash so that api/,
// mcpsrv/, graph/ and web/ can carry them without importing gitx — the layering
// rule is that nothing above service/ touches the git layer, and a leaked
// plumbing type would break it on the first struct field.
type Document struct {
	// Path is the document's path in the tree.
	Path string

	// Blob is the sha of the document's blob — the render cache key. It is
	// content-addressed, so a cache entry keyed by it can never go stale.
	Blob string

	// Rev is the commit the read resolved to. For a read at the approved head
	// this is the value to hand back as the pinned ?rev=, and it is the same
	// value an agent sends as If-Match.
	Rev string

	// Data is the whole document: frontmatter and body.
	Data []byte
}

// ReadDocument reads one document by path, at the approved head when rev is
// ApprovedRev and at a pinned revision otherwise.
//
// This is the same code path for both. There is one storage tier and no
// checkout, so "the approved text of SPEC-0007" and "SPEC-0007 at
// 1f0c1d1a" differ only in which revision is resolved.
func (s *Service) ReadDocument(ctx context.Context, sp *Space, rev, path string) (Document, error) {
	commit, resolved, err := s.resolveRev(ctx, sp, rev)
	if err != nil {
		return Document{}, err
	}
	doc, err := sp.Repo.ReadDocument(ctx, resolved, path)
	if err != nil {
		return Document{}, readErr(err, "read %s at %s in %s", path, resolved, sp.Ref)
	}
	return Document{
		Path: doc.Path,
		Blob: doc.Blob.String(),
		Rev:  commit.String(),
		Data: doc.Data,
	}, nil
}

// ListDocuments returns every document in a space at a revision, in tree order.
//
// Bodies are included: they come off the same tree walk, the volume is tens of
// documents a day, and every caller that lists documents (the indexer, the
// review page, the ID map a push validation builds) needs the frontmatter,
// which is not separable from the blob.
func (s *Service) ListDocuments(ctx context.Context, sp *Space, rev string) ([]Document, error) {
	commit, resolved, err := s.resolveRev(ctx, sp, rev)
	if err != nil {
		return nil, err
	}
	docs, err := sp.Repo.ListDocuments(ctx, resolved)
	if err != nil {
		return nil, readErr(err, "list documents at %s in %s", resolved, sp.Ref)
	}
	out := make([]Document, 0, len(docs))
	for _, d := range docs {
		out = append(out, Document{
			Path: d.Path,
			Blob: d.Blob.String(),
			Rev:  commit.String(),
			Data: d.Data,
		})
	}
	return out, nil
}

// Policy reads the space's effective .spec.yml at a revision.
//
// A space with no .spec.yml gets core.DefaultPolicy: the house frontmatter
// contract and nothing auto-merged. That is the fail-closed direction — a space
// that has not said anything about review must not be quietly laundering
// unreviewed agent output onto the approved branch — and it is a defined
// default rather than a fallback, which is why an absent file is not an error
// but an unparseable one is.
//
// Reading it at a revision rather than from configuration is what makes policy
// changes reviewable like any other change, and it is why a push that edits
// .spec.yml is validated against the policy it is installing.
func (s *Service) Policy(ctx context.Context, sp *Space, rev string) (core.Policy, error) {
	_, resolved, err := s.resolveRev(ctx, sp, rev)
	if err != nil {
		return core.Policy{}, err
	}
	data, _, err := sp.Repo.ReadBlob(ctx, resolved, core.PolicyFile)
	if err != nil {
		if errors.Is(err, gitx.ErrNotFound) {
			return core.DefaultPolicy(), nil
		}
		return core.Policy{}, readErr(err, "read %s at %s in %s",
			core.PolicyFile, resolved, sp.Ref)
	}
	pol, err := core.ParsePolicy(data)
	if err != nil {
		return core.Policy{}, fmt.Errorf("service: %s at %s in %s: %w",
			core.PolicyFile, resolved, sp.Ref, err)
	}
	return pol, nil
}

// ResolveRev resolves a revision string against a space, returning the commit
// it names as a hex object name. ApprovedRev resolves to the approved head.
//
// Callers use it to pin: the review UI turns "the approved head right now" into
// an immutable ?rev= before it renders anything, so a merge landing mid-render
// cannot make one page describe two revisions.
func (s *Service) ResolveRev(ctx context.Context, sp *Space, rev string) (string, error) {
	commit, _, err := s.resolveRev(ctx, sp, rev)
	if err != nil {
		return "", err
	}
	return commit.String(), nil
}

// resolveRev turns a caller's revision string into both the commit it names and
// the string to pass back down to gitx.
//
// Both are returned because they are not interchangeable: the hash is what a
// caller pins and compares, while the original string is what the read is
// issued against. Re-issuing reads against the resolved hash instead would be
// one extra object lookup per read for no gain, and would lose the branch name
// from error messages.
func (s *Service) resolveRev(ctx context.Context, sp *Space, rev string) (plumbing.Hash, string, error) {
	if sp == nil || sp.Repo == nil {
		return plumbing.ZeroHash, "", errors.New("service: space has no open repository")
	}
	resolved := rev
	if resolved == ApprovedRev {
		resolved = sp.Repo.ApprovedBranch()
	}
	commit, err := sp.Repo.ResolveRev(ctx, resolved)
	if err != nil {
		return plumbing.ZeroHash, "", readErr(err, "resolve revision %q in %s", resolved, sp.Ref)
	}
	return commit, resolved, nil
}

// readErr maps a gitx failure onto this package's sentinels so callers above
// service/ can branch on it without importing gitx. ErrNotFound and ErrBadRev
// both become ErrNotFound at this boundary — a crafted revision must not be
// able to tell "malformed" from "absent" by probing — while the original class
// stays in the chain for logs and for gitx-aware callers.
func readErr(err error, format string, args ...any) error {
	what := fmt.Sprintf(format, args...)
	switch {
	case errors.Is(err, gitx.ErrNotFound), errors.Is(err, gitx.ErrBadRev):
		return fmt.Errorf("%w: %s: %w", ErrNotFound, what, err)
	default:
		return fmt.Errorf("service: %s: %w", what, err)
	}
}