records: port to Go, and keep the checkout in a directory it owns

Every sync had failed since a container that ran as root left the checkout
root's: git refused it as dubious ownership, and records answered from a
stale copy. The Go bundle clones into repository/ under its directory, clears
the old layout where it can and names what it cannot. Drops the container-
runtime capability the move into the runtime left behind. hq issue 251.
This commit is contained in:
jochen
2026-10-05 17:42:08 +02:00
parent f74e1f0309
commit db9a5bff0c
13 changed files with 718 additions and 500 deletions
+9
View File
@@ -39,3 +39,12 @@ says so in its log.
Search the mesh, through the console, for a phrase that appears only in one design document here, and
get it back. `records_search {"query": "…"}` is that search; its test does the same against a
repository it makes.
## Where the checkout lives
A Go bundle the node's runtime launches as the operator account (`cmd/records`). It clones into
`repository/` inside the directory the mesh gives it — a directory it makes, and so owns. An earlier
layout cloned into the given directory itself, from a container running as root, and left files the
operator account cannot change; git then refused every sync as "dubious ownership" (hq issue 251). What
that layout left is removed where it is the module's, and named in `records_status` (`leftBehind`, with
the one command that deletes it) where it is not.
+133
View File
@@ -0,0 +1,133 @@
// records: the mesh's record — decisions, designs and issues — read where it is written (novox/hq ADR 0025,
// ADR 0153). A Go bundle the node's runtime launches; it keeps a checkout of one repository current on every
// merge the forge announces and on a timer, and answers five questions about it. stdout is the MCP channel;
// what this module says, it says on stderr.
package main
import (
"encoding/json"
"fmt"
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func readJSON(path string, into any) error {
raw, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(raw, into)
}
func str(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
func strArg(a map[string]any, k string) string { s, _ := a[k].(string); return s }
func tools(r *Records) []stdio.Tool {
return []stdio.Tool{
{Name: "records_search",
Description: "Where a phrase appears in the decisions, designs and issues, as written: document, line, nearest heading. " +
"Search the literal words of a symptom or a term before forming a hypothesis; the answer names the commit it was read at.",
Input: map[string]any{
"query": str("the phrase, matched case-insensitively as written"),
"limit": map[string]any{"type": "number", "description": "at most this many places (default 50)"},
},
Run: func(a map[string]any) (any, error) {
limit := 0
if v, ok := a["limit"].(float64); ok {
limit = int(v)
}
return r.Search(strArg(a, "query"), limit)
}},
{Name: "records_read",
Description: "One document, whole, by its path in the repository — a decision record, a design document, an issue report.",
Input: map[string]any{"path": str("the document's path, e.g. 02-DECISIONS/0025-....md")},
Run: func(a map[string]any) (any, error) { return r.Read(strArg(a, "path")) }},
{Name: "records_list",
Description: "What a folder of the repository holds: its sub-folders and its documents. The root when no folder is named.",
Input: map[string]any{"folder": str("a folder inside the repository (optional)")},
Run: func(a map[string]any) (any, error) { return r.List(strArg(a, "folder")) }},
{Name: "records_status",
Description: "Where the checkout stands: the repository, the forge it is read from, the commit and its date, when it was last brought up to date.",
Run: func(map[string]any) (any, error) { return r.Standing(), nil }},
{Name: "records_sync",
Description: "Bring the checkout up to date now, and say where it stands.",
Run: func(map[string]any) (any, error) {
r.Sync()
return r.Standing(), nil
}},
}
}
// keep keeps the checkout current: at start, on every merge the forge announces into this repository, and on
// an unhurried timer for the merges it did not hear about — a restart during a merge, a repository the forge
// does not emit for. The record changes when thinking changes, not by the minute.
func keep(r *Records) {
r.Sync()
s := r.Standing()
commit := s.Commit
if len(commit) > 8 {
commit = commit[:8]
}
line := fmt.Sprintf("[records] %s at %s, %d document(s)", s.Repository, commit, s.Documents)
if s.LastError != "" {
line += " — " + s.LastError
}
fmt.Fprintln(os.Stderr, line)
if s.LeftBehind != "" {
fmt.Fprintln(os.Stderr, "[records] "+s.LeftBehind)
}
go func() {
for range time.Tick(10 * time.Minute) {
r.Sync()
}
}()
// A merge on the forge into the repository this reads: pull now. The event names the repository by
// owner and name (gitea's `pull.merged`); anything else is somebody else's merge.
subscribe := func() error {
return stdio.Subscribe("gitea.pull.merged", func(e stdio.Envelope) error {
var body struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
}
_ = json.Unmarshal(e.Body, &body)
if body.Owner+"/"+body.Repo != r.Repository {
return nil
}
fmt.Fprintf(os.Stderr, "[records] %s merged; syncing\n", r.Repository)
r.Sync()
return nil
})
}
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := subscribe()
if err == nil {
return
}
fmt.Fprintf(os.Stderr, "[records] hearing merges not yet (%v); asking again in %s\n", err, wait)
time.Sleep(wait)
}
}
func main() {
r, err := FromEnv(os.Getenv)
if err != nil {
// Without a repository to read there is nothing to answer: no tools rather than five that fail.
fmt.Fprintf(os.Stderr, "[records] no tools — %v\n", err)
if err := stdio.Serve("", nil); err != nil {
os.Exit(1)
}
return
}
go keep(r)
if err := stdio.Serve("", tools(r)); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+417
View File
@@ -0,0 +1,417 @@
package main
// The record, read where it is written (novox/hq ADR 0025, ADR 0153).
//
// A repository of decisions, designs and issues — markdown, nothing else — cloned from the mesh's own forge
// and kept current. **A checkout, not a copy**: the same bytes the repository holds, at a commit every
// answer names, refreshed on every merge the forge announces and on a timer besides. Nothing is
// transformed, indexed or summarised on the way, so there is nothing that can drift from the source except
// by lagging behind it, and the lag is a number in every answer.
//
// **The clone is in a directory this module made** (novox/hq issue 251). The module is given a directory;
// it clones into `repository/` inside it, a directory it creates and so owns. The TypeScript module cloned
// into the given directory itself, and a sync that once ran as another account left every file there that
// account's: git refused the checkout as "dubious ownership" from then on, and the operator account the
// module runs as could change none of it. What the old layout left is removed where it is this module's,
// and named in the standing where it is not.
import (
"errors"
"fmt"
"io/fs"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"syscall"
"time"
)
// MostHits is the most places a search answers; a phrase found more often is a phrase to narrow.
const MostHits = 50
// MostBytes is how much of a document is answered; a longer one is answered in part, and says so.
const MostBytes = 200_000
// Hit is one place a phrase was found.
type Hit struct {
// Path is the document, relative to the repository's root.
Path string `json:"path"`
// Line is the line it was found on, from 1.
Line int `json:"line"`
// Heading is the nearest heading above it, so a hit reads as where in the document it is.
Heading string `json:"heading"`
// Text is the line itself, trimmed.
Text string `json:"text"`
}
// Standing is where the checkout stands.
type Standing struct {
Repository string `json:"repository"`
Origin string `json:"origin"`
// Commit is the commit the checkout is at, or empty before the first clone.
Commit string `json:"commit"`
// Committed is when that commit was made, as the repository says.
Committed string `json:"committed"`
// Fetched is when this reader last brought the checkout up to date.
Fetched string `json:"fetched"`
// Documents is how many markdown documents the checkout holds.
Documents int `json:"documents"`
// LastError is why the last sync failed, if it did; the checkout stands where it was.
LastError string `json:"lastError,omitempty"`
// LeftBehind names what an earlier layout left in the module's directory that this account cannot
// remove: another account's files, harmless, and the operator's to delete.
LeftBehind string `json:"leftBehind,omitempty"`
}
// Records reads one repository's checkout.
type Records struct {
// Base is the directory the mesh gives the module; the checkout is Base/repository.
Base string
// Origin is the forge's address, `scheme://host:port`, from the git provision's binding.
Origin string
// Repository is the repository's path on it, `owner/name`, from this module's settings.
Repository string
// URL overrides the clone URL; a test points it at a local repository.
URL string
mu sync.Mutex
syncing chan struct{}
fetched string
lastError string
leftBehind string
}
// Dir is where the checkout is.
func (r *Records) Dir() string { return filepath.Join(r.Base, "repository") }
// CloneURL is the forge and the repository. Public repositories only; a credential would be a secret this
// module has not asked for.
func (r *Records) CloneURL() string {
if r.URL != "" {
return r.URL
}
return strings.TrimRight(r.Origin, "/") + "/" + r.Repository + ".git"
}
func git(args ...string) (string, error) {
cmd := exec.Command("git", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), strings.TrimSpace(string(out)))
}
return strings.TrimSpace(string(out)), nil
}
// Sync brings the checkout up to date, cloning it if it does not exist. One at a time: a call while one
// runs waits for that one rather than racing it. It never fails: a failed sync is recorded in the standing
// and the checkout stands where it was, which is still an answer.
func (r *Records) Sync() {
r.mu.Lock()
if r.syncing != nil {
wait := r.syncing
r.mu.Unlock()
<-wait
return
}
done := make(chan struct{})
r.syncing = done
r.mu.Unlock()
err := r.doSync()
r.mu.Lock()
if err != nil {
r.lastError = err.Error()
fmt.Fprintf(os.Stderr, "[records] could not sync %s: %v\n", r.CloneURL(), err)
} else {
r.lastError = ""
r.fetched = time.Now().UTC().Format(time.RFC3339)
}
r.syncing = nil
r.mu.Unlock()
close(done)
}
func (r *Records) doSync() error {
r.clearOldLayout()
dir := r.Dir()
if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil {
_ = os.RemoveAll(dir) // a clone that never finished
if err := os.MkdirAll(r.Base, 0o700); err != nil {
return err
}
_, err := git("clone", "--quiet", "--depth", "50", r.CloneURL(), dir)
return err
}
// The checkout is the mesh's, so a local change is nobody's: reset to what the forge has, rather than
// merging into something a hand may have touched.
if _, err := git("-C", dir, "fetch", "--quiet", "--depth", "50", "origin"); err != nil {
return err
}
_, err := git("-C", dir, "reset", "--quiet", "--hard", "origin/HEAD")
return err
}
// clearOldLayout removes what the TypeScript module's layout left in Base — a checkout in Base itself —
// where it is this account's, and remembers what it is not.
func (r *Records) clearOldLayout() {
entries, err := os.ReadDir(r.Base)
if err != nil {
return
}
foreign := 0
for _, e := range entries {
if e.Name() == "repository" {
continue
}
full := filepath.Join(r.Base, e.Name())
if err := os.RemoveAll(full); err != nil {
foreign++
}
}
r.mu.Lock()
defer r.mu.Unlock()
r.leftBehind = ""
if foreign > 0 {
owner := "another account"
if info, err := os.Stat(filepath.Join(r.Base, ".git")); err == nil {
if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Uid == 0 {
owner = "root"
}
}
r.leftBehind = fmt.Sprintf("%d entr(ies) of an earlier checkout in %s belong to %s and cannot be removed by this module; "+
"they are not read — delete them once: sudo find %s -mindepth 1 -maxdepth 1 ! -name repository -exec rm -rf {} +",
foreign, r.Base, owner, r.Base)
}
}
// Standing says where the checkout stands.
func (r *Records) Standing() Standing {
s := Standing{Repository: r.Repository, Origin: r.Origin}
if _, err := os.Stat(filepath.Join(r.Dir(), ".git")); err == nil {
s.Commit, _ = git("-C", r.Dir(), "rev-parse", "HEAD")
s.Committed, _ = git("-C", r.Dir(), "log", "-1", "--format=%cI")
}
if s.Commit != "" {
s.Documents = len(r.documents())
}
r.mu.Lock()
s.Fetched, s.LastError, s.LeftBehind = r.fetched, r.lastError, r.leftBehind
r.mu.Unlock()
return s
}
// documents is every markdown document, relative to the root, in a stable order.
func (r *Records) documents() []string {
var out []string
root := r.Dir()
_ = filepath.WalkDir(root, func(full string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() && (d.Name() == ".git" || d.Name() == "node_modules") {
return filepath.SkipDir
}
if d.Type().IsRegular() && strings.HasSuffix(d.Name(), ".md") {
rel, _ := filepath.Rel(root, full)
out = append(out, filepath.ToSlash(rel))
}
return nil
})
sort.Strings(out)
return out
}
var (
emphasis = regexp.MustCompile("[*_`]")
spaces = regexp.MustCompile(`\s+`)
heading = regexp.MustCompile(`^#{1,6}\s`)
hashes = regexp.MustCompile(`^#+\s*`)
)
// flat is a line as a phrase is matched against it: no emphasis marks, one space, lower case.
func flat(line string) string {
return strings.TrimSpace(spaces.ReplaceAllString(strings.ToLower(emphasis.ReplaceAllString(line, "")), " "))
}
// SearchAnswer is what a search answers.
type SearchAnswer struct {
Hits []Hit `json:"hits"`
More bool `json:"more"`
Commit string `json:"commit"`
}
// Search finds where a phrase appears, case-insensitively, as written — no stemming, no ranking, because a
// design record is found by its own words. Bounded, and says when it was.
//
// **The record is wrapped prose, and a phrase does not know where the line ends.** A line is matched
// together with the one after it, joined by a space, and emphasis marks are ignored — `**reachable**` is
// the word reachable. A hit names the line it starts on.
func (r *Records) Search(query string, limit int) (SearchAnswer, error) {
needle := flat(query)
if needle == "" {
return SearchAnswer{}, errors.New("search for a phrase; an empty one matches every line of every document")
}
if limit <= 0 || limit > MostHits {
limit = MostHits
}
answer := SearchAnswer{Hits: []Hit{}}
for _, p := range r.documents() {
raw, err := os.ReadFile(filepath.Join(r.Dir(), filepath.FromSlash(p)))
if err != nil {
continue
}
lines := strings.Split(string(raw), "\n")
flats := make([]string, len(lines))
for i, l := range lines {
flats[i] = flat(l)
}
head := ""
for i, line := range lines {
if heading.MatchString(line) {
head = strings.TrimSpace(hashes.ReplaceAllString(line, ""))
}
next := ""
if i+1 < len(flats) {
next = flats[i+1]
}
// On this line, or across the break into the next — but not a phrase that begins on the next
// line alone, which is that line's hit.
onThis := strings.Contains(flats[i], needle)
across := !onThis && next != "" && strings.Contains(flats[i]+" "+next, needle) && !strings.Contains(next, needle)
if !onThis && !across {
continue
}
if len(answer.Hits) >= limit {
answer.More = true
break
}
text := strings.TrimSpace(line)
if across {
text += " " + strings.TrimSpace(lines[i+1])
}
answer.Hits = append(answer.Hits, Hit{Path: p, Line: i + 1, Heading: head, Text: text})
}
if answer.More {
break
}
}
answer.Commit = r.Standing().Commit
return answer, nil
}
// inside cleans a path given relative to the repository, refusing one that leaves it.
func inside(p string) (string, bool) {
clean := path.Clean(strings.ReplaceAll(p, "\\", "/"))
if clean == "." {
return "", true
}
if path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") {
return "", false
}
return clean, true
}
// Document is one document, as read answers it.
type Document struct {
Path string `json:"path"`
Content string `json:"content"`
Truncated bool `json:"truncated"`
Commit string `json:"commit"`
}
// Read is one document, whole, or its first part with a note when it is very long. The path stays inside
// the checkout: `..` and absolute paths are refused, not resolved.
func (r *Records) Read(p string) (Document, error) {
clean, ok := inside(p)
if !ok || clean == "" {
return Document{}, fmt.Errorf("%q is not a path inside the repository", p)
}
raw, err := os.ReadFile(filepath.Join(r.Dir(), filepath.FromSlash(clean)))
if err != nil {
return Document{}, fmt.Errorf("the repository holds no %s — `records_list` says what it holds", clean)
}
d := Document{Path: clean, Content: string(raw), Commit: r.Standing().Commit}
if len(d.Content) > MostBytes {
d.Content = d.Content[:MostBytes] + "\n\n[… truncated; the document is longer than this answer carries]"
d.Truncated = true
}
return d, nil
}
// Folder is what a folder holds, one level.
type Folder struct {
Folder string `json:"folder"`
Folders []string `json:"folders"`
Documents []string `json:"documents"`
}
// List says what a folder holds: its sub-folders and its documents.
func (r *Records) List(folder string) (Folder, error) {
clean, ok := inside(folder)
if !ok {
return Folder{}, fmt.Errorf("%q is not a folder inside the repository", folder)
}
entries, err := os.ReadDir(filepath.Join(r.Dir(), filepath.FromSlash(clean)))
if err != nil {
if clean == "" {
clean = "/"
}
return Folder{}, fmt.Errorf("the repository holds no folder %s", clean)
}
out := Folder{Folder: clean, Folders: []string{}, Documents: []string{}}
for _, e := range entries {
switch {
case e.IsDir() && e.Name() != ".git" && e.Name() != "node_modules":
out.Folders = append(out.Folders, e.Name())
case e.Type().IsRegular() && strings.HasSuffix(e.Name(), ".md"):
out.Documents = append(out.Documents, e.Name())
}
}
return out, nil
}
var repositoryName = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
// FromEnv is the reader as the mesh configures it: the directory it was given, the forge it was bound to,
// and the repository its settings name. It refuses to guess any of the three (novox/hq ADR 0112).
func FromEnv(env func(string) string) (*Records, error) {
dir := env("MESH_RECORDS_DIR")
if dir == "" {
return nil, errors.New("MESH_RECORDS_DIR is unset: the mesh gives this module the directory its checkout lives in")
}
originFile := env("MESH_RECORDS_ORIGIN_FILE")
if originFile == "" {
return nil, errors.New("MESH_RECORDS_ORIGIN_FILE is unset: the forge's address comes from the git provision's binding")
}
raw, err := os.ReadFile(originFile)
if err != nil {
return nil, err
}
origin := strings.TrimSpace(string(raw))
if origin == "" {
return nil, fmt.Errorf("%s is empty: the git provision has not been bound yet", originFile)
}
configFile := env("MESH_RECORDS_CONFIG_FILE")
if configFile == "" {
return nil, errors.New("MESH_RECORDS_CONFIG_FILE is unset")
}
var config struct {
Repository any `json:"repository"`
}
if err := readJSON(configFile, &config); err != nil {
return nil, fmt.Errorf("%s is not JSON: %w", configFile, err)
}
repository, _ := config.Repository.(string)
repository = strings.TrimSpace(repository)
if !repositoryName.MatchString(repository) {
return nil, errors.New("this module reads the repository its settings name, and none is set: " +
"`settings set records <file>` with {\"repository\": \"<owner>/<name>\"} — a module names no mesh (novox/hq ADR 0112)")
}
return &Records{Base: dir, Origin: origin, Repository: repository}, nil
}
+147
View File
@@ -0,0 +1,147 @@
package main
// The reader against a real repository: a checkout, a phrase found where it is written, a document read
// whole, a merge pulled — and the check novox/hq ADR 0025 names: search for a phrase that appears only in
// one design document, and get it back. Ported with the TypeScript module's tests, and the layout's own.
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
func run(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %s", args, out)
}
}
func write(t *testing.T, path, content string) {
t.Helper()
_ = os.MkdirAll(filepath.Dir(path), 0o755)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func aRepository(t *testing.T) string {
dir := t.TempDir()
run(t, dir, "init", "--quiet", "--initial-branch=main")
run(t, dir, "config", "user.email", "t@example.invalid")
run(t, dir, "config", "user.name", "t")
write(t, filepath.Join(dir, "README.md"), "# A repository\n\nWhat this is.\n")
write(t, filepath.Join(dir, "02-DECISIONS/0001-a-decision.md"), "# 1. A decision\n\n## Context\n\nThe context.\n\n## Decision\n\nWe decided the thing.\n")
write(t, filepath.Join(dir, "03-DESIGN/07-knowledge.md"), "# Knowledge\n\n## The stores\n\nSilence and success must never look alike.\n\n"+
"A phrase that is wrapped at the\ncolumn where every document wraps, and **reachable is not the same as\nsurfacing** under emphasis.\n")
run(t, dir, "add", "-A")
run(t, dir, "commit", "--quiet", "-m", "first")
return dir
}
func TestAPhraseInOneDesignDocumentComesBackFromWhereItIsWritten(t *testing.T) {
origin := aRepository(t)
r := &Records{Base: t.TempDir(), Origin: "http://forge.invalid:3000", Repository: "novox/hq", URL: origin}
r.Sync()
found, err := r.Search("never look alike", 0)
if err != nil || len(found.Hits) != 1 || found.Hits[0].Path != "03-DESIGN/07-knowledge.md" || found.Hits[0].Heading != "The stores" {
t.Fatalf("%+v %v", found, err)
}
if !regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(found.Commit) {
t.Fatalf("the answer names no commit: %q", found.Commit)
}
// Wrapped prose: a phrase across the break is found once, at the line it starts on; emphasis is not
// part of the words; a phrase on one line is not also counted from the line before it.
for phrase, line := range map[string]int{"wrapped at the column where": 7, "reachable is not the same as surfacing": 8} {
got, _ := r.Search(phrase, 0)
if len(got.Hits) != 1 || got.Hits[0].Line != line {
t.Errorf("%q: %+v", phrase, got.Hits)
}
}
if got, _ := r.Search("under emphasis", 0); len(got.Hits) != 1 {
t.Errorf("a phrase on one line was counted %d times", len(got.Hits))
}
doc, err := r.Read("02-DECISIONS/0001-a-decision.md")
if err != nil || !strings.Contains(doc.Content, "We decided the thing") || doc.Truncated {
t.Fatalf("%+v %v", doc, err)
}
root, _ := r.List("")
if strings.Join(root.Folders, ",") != "02-DECISIONS,03-DESIGN" || strings.Join(root.Documents, ",") != "README.md" {
t.Fatalf("%+v", root)
}
if s := r.Standing(); s.Documents != 3 || s.LastError != "" {
t.Fatalf("%+v", s)
}
// A merge on the origin, pulled: the checkout follows and names the new commit.
write(t, filepath.Join(origin, "02-DECISIONS/0002-another.md"), "# 2. Another\n\nA phrase nobody wrote before.\n")
run(t, origin, "add", "-A")
run(t, origin, "commit", "--quiet", "-m", "second")
r.Sync()
after, _ := r.Search("nobody wrote before", 0)
if len(after.Hits) != 1 || after.Commit == found.Commit {
t.Fatalf("%+v", after)
}
}
func TestAPathOutsideTheRepositoryAndAnEmptySearchAreRefused(t *testing.T) {
r := &Records{Base: t.TempDir(), Origin: "http://forge.invalid:3000", Repository: "novox/hq"}
for _, p := range []string{"../etc/passwd", "/etc/passwd", "a/../../b"} {
if _, err := r.Read(p); err == nil || !strings.Contains(err.Error(), "not a path inside") {
t.Errorf("%s: %v", p, err)
}
}
if _, err := r.Search(" ", 0); err == nil || !strings.Contains(err.Error(), "empty one") {
t.Errorf("an empty search: %v", err)
}
if r.CloneURL() != "http://forge.invalid:3000/novox/hq.git" {
t.Error(r.CloneURL())
}
}
func TestAFailedSyncLeavesTheCheckoutStandingAndSaysWhy(t *testing.T) {
r := &Records{Base: t.TempDir(), Origin: "http://127.0.0.1:1", Repository: "novox/hq"}
r.Sync()
if s := r.Standing(); s.Commit != "" || s.LastError == "" {
t.Fatalf("%+v", s)
}
}
// The old layout — a checkout in the given directory itself — is cleared where it is this module's, and
// the checkout is made in its own directory (novox/hq issue 251).
func TestTheOldLayoutIsClearedAndTheCheckoutLivesInItsOwnDirectory(t *testing.T) {
origin := aRepository(t)
base := t.TempDir()
write(t, filepath.Join(base, "README.md"), "an old checkout's file")
_ = os.MkdirAll(filepath.Join(base, ".git"), 0o755)
r := &Records{Base: base, Origin: "http://forge.invalid:3000", Repository: "novox/hq", URL: origin}
r.Sync()
if _, err := os.Stat(filepath.Join(base, "README.md")); err == nil {
t.Error("the old layout's file was left")
}
if _, err := os.Stat(filepath.Join(base, "repository", ".git")); err != nil {
t.Fatal("the checkout is not in its own directory")
}
if s := r.Standing(); s.LeftBehind != "" || s.Documents != 3 {
t.Fatalf("%+v", s)
}
}
func TestTheReaderRefusesToGuess(t *testing.T) {
dir := t.TempDir()
write(t, filepath.Join(dir, "origin"), "http://forge.invalid:3000\n")
write(t, filepath.Join(dir, "config.json"), "{}\n")
env := map[string]string{"MESH_RECORDS_DIR": dir, "MESH_RECORDS_ORIGIN_FILE": filepath.Join(dir, "origin"),
"MESH_RECORDS_CONFIG_FILE": filepath.Join(dir, "config.json")}
if _, err := FromEnv(func(k string) string { return env[k] }); err == nil || !strings.Contains(err.Error(), "none is set") {
t.Fatalf("a module with no repository set guessed one: %v", err)
}
write(t, filepath.Join(dir, "config.json"), `{"repository": "novox/hq"}`)
r, err := FromEnv(func(k string) string { return env[k] })
if err != nil || r.Repository != "novox/hq" || r.Origin != "http://forge.invalid:3000" {
t.Fatalf("%+v %v", r, err)
}
}
+5
View File
@@ -0,0 +1,5 @@
module records
go 1.25.0
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
-34
View File
@@ -1,34 +0,0 @@
// records' consumer: keep the checkout current (novox/hq ADR 0153).
//
// Synced when the runtime binds the broker, on every merge the forge announces, and on a timer for
// the merges it did not hear about — a restart during a merge, a repository the forge does not emit
// for. The timer is unhurried: the record changes when thinking changes, not by the minute.
import { on } from "@novox/mesh-sdk/events";
import { recordsFromEnv, type Records } from "./records.js";
let records: Records | null = null;
try {
records = await recordsFromEnv();
} catch (err) {
console.log(`[records] not reading — ${err instanceof Error ? err.message : String(err)}`);
}
if (records) {
const reader = records;
void reader.sync().then(async () => {
const s = await reader.standing();
console.log(`[records] ${s.repository} at ${s.commit.slice(0, 8) || "(no commit)"}, ${s.documents} document(s)${s.lastError ? ` — ${s.lastError}` : ""}`);
});
setInterval(() => void reader.sync(), 10 * 60 * 1000).unref();
// A merge on the forge into the repository this reads: pull now. The event names the repository
// by owner and name (gitea's `pull.merged`); anything else is somebody else's merge.
await on<{ owner?: string; repo?: string; base?: string }>("gitea.pull.merged", async (event) => {
const merged = `${event.body.owner ?? ""}/${event.body.repo ?? ""}`;
if (merged !== reader.repository) return;
console.log(`[records] ${merged} merged; syncing`);
await reader.sync();
});
}
export { records };
+5 -10
View File
@@ -2,9 +2,6 @@
"module": "records",
"version": "1",
"slug": "records",
"capabilities": [
"container-runtime"
],
"requires": [
"git"
],
@@ -60,14 +57,12 @@
{
"name": "code",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js"
],
"language": "go",
"system": "arch",
"from": "cmd/records",
"binary": "records",
"loads": [
"index.js",
"tools/index.js"
"records"
],
"env": {
"MESH_RECORDS_CONFIG_FILE": "${dir:mesh-state}/config.json",
-18
View File
@@ -1,18 +0,0 @@
{
"name": "@novox/module-records",
"version": "0.1.0",
"description": "records — reads a repository of decisions, designs and issues where it is written, and answers questions about it (novox/hq ADR 0025, ADR 0153).",
"type": "module",
"private": true,
"scripts": {
"build": "tsc records.ts index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.1"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
-277
View File
@@ -1,277 +0,0 @@
/**
* The record, read where it is written (novox/hq ADR 0025, ADR 0153).
*
* A repository of decisions, designs and issues — markdown, nothing else — cloned from the mesh's own
* forge and kept current. **A checkout, not a copy**: the same bytes the repository holds, at a commit
* every answer names, refreshed on every merge the forge announces and on a timer besides. Nothing is
* transformed, indexed or summarised on the way, so there is nothing that can drift from the source
* except by lagging behind it, and the lag is a number in every answer.
*
* What it answers: a search for a phrase, a document by path, what a folder holds, and where the
* checkout stands. The reasoning is in the documents; this only finds them.
*/
import { execFile } from "node:child_process";
import { promises as fs } from "node:fs";
import { join, normalize, relative, sep } from "node:path";
import { promisify } from "node:util";
const run = promisify(execFile);
/** One place a phrase was found. */
export interface Hit {
/** The document, relative to the repository's root. */
path: string;
/** The line it was found on, from 1. */
line: number;
/** The nearest heading above it, so a hit reads as where in the document it is. */
heading: string;
/** The line itself, trimmed. */
text: string;
}
export interface Standing {
repository: string;
origin: string;
/** The commit the checkout is at, or empty before the first clone. */
commit: string;
/** When that commit was made, as the repository says. */
committed: string;
/** When this reader last brought the checkout up to date. */
fetched: string;
/** How many markdown documents the checkout holds. */
documents: number;
/** Why the last sync failed, if it did; the checkout stands where it was. */
lastError?: string;
}
/** Search answers at most this many places; a phrase found more often is a phrase to narrow. */
export const MOST_HITS = 50;
/** A document longer than this is answered in part, and says so. */
export const MOST_BYTES = 200_000;
export class Records {
/** Where the checkout lives; the mesh gives the module the directory. */
readonly dir: string;
/** The forge's address, `scheme://host:port`, from the git provision's binding. */
readonly origin: string;
/** The repository's path on it, `owner/name`, from this module's settings. */
readonly repository: string;
private syncing: Promise<void> | undefined;
private fetched = "";
private lastError: string | undefined;
constructor(dir: string, origin: string, repository: string) {
this.dir = dir;
this.origin = origin;
this.repository = repository;
}
/** The clone URL: the forge, the repository. Public repositories only; a credential would be a
* secret this module has not asked for. */
get url(): string {
return `${this.origin.replace(/\/$/, "")}/${this.repository}.git`;
}
/** Bring the checkout up to date, cloning it if it does not exist. One at a time: a second call
* while one runs joins it rather than racing it. Never throws — a failed sync is recorded in
* `standing()` and the checkout stands where it was, which is still an answer. */
sync(): Promise<void> {
if (!this.syncing) {
this.syncing = this.doSync().finally(() => {
this.syncing = undefined;
});
}
return this.syncing;
}
private async doSync(): Promise<void> {
try {
const cloned = await exists(join(this.dir, ".git"));
if (!cloned) {
await fs.mkdir(this.dir, { recursive: true });
await run("git", ["clone", "--quiet", "--depth", "50", this.url, this.dir]);
} else {
// The checkout is the mesh's, so a local change is nobody's: reset to what the forge has,
// rather than merging into something a hand may have touched.
await run("git", ["-C", this.dir, "fetch", "--quiet", "--depth", "50", "origin"]);
await run("git", ["-C", this.dir, "reset", "--quiet", "--hard", "origin/HEAD"]);
}
this.fetched = new Date().toISOString();
this.lastError = undefined;
} catch (e) {
this.lastError = e instanceof Error ? e.message : String(e);
console.error(`[records] could not sync ${this.url}: ${this.lastError}`);
}
}
async standing(): Promise<Standing> {
let commit = "";
let committed = "";
if (await exists(join(this.dir, ".git"))) {
try {
commit = (await run("git", ["-C", this.dir, "rev-parse", "HEAD"])).stdout.trim();
committed = (await run("git", ["-C", this.dir, "log", "-1", "--format=%cI"])).stdout.trim();
} catch {
// A checkout without a commit yet: said as empty rather than thrown.
}
}
const documents = commit ? (await this.documents()).length : 0;
return {
repository: this.repository,
origin: this.origin,
commit,
committed,
fetched: this.fetched,
documents,
...(this.lastError ? { lastError: this.lastError } : {}),
};
}
/** Every markdown document, relative to the root, in a stable order. */
async documents(): Promise<string[]> {
const out: string[] = [];
const walk = async (at: string): Promise<void> => {
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(at, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
if (e.name === ".git" || e.name === "node_modules") continue;
const full = join(at, e.name);
if (e.isDirectory()) await walk(full);
else if (e.isFile() && e.name.endsWith(".md")) out.push(relative(this.dir, full).split(sep).join("/"));
}
};
await walk(this.dir);
return out.sort();
}
/**
* Where a phrase appears, case-insensitively, as written — no stemming, no ranking, because a
* design record is found by its own words and a reader deciding which words matter would be a
* second opinion about somebody else's document. Bounded, and says when it was.
*
* **The record is wrapped prose, and a phrase does not know where the line ends.** Every document
* here wraps at a hundred columns, so a phrase of six words is as likely to straddle a line break
* as not; matched line by line, the first live search for a sentence of ADR 0025 found nothing.
* So a line is matched together with the one after it, joined by a space, and emphasis marks
* are ignored — `**reachable**` is the word reachable. A hit still names the line it starts on.
*/
async search(query: string, limit = MOST_HITS): Promise<{ hits: Hit[]; more: boolean; commit: string }> {
const needle = plain(query).toLowerCase().replace(/\s+/g, " ").trim();
if (!needle) throw new Error("search for a phrase; an empty one matches every line of every document");
const cap = Math.max(1, Math.min(limit, MOST_HITS));
const hits: Hit[] = [];
let more = false;
for (const path of await this.documents()) {
const text = await fs.readFile(join(this.dir, path), "utf8");
let heading = "";
const lines = text.split("\n");
const flat = lines.map((l) => plain(l).toLowerCase().replace(/\s+/g, " ").trim());
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
if (/^#{1,6}\s/.test(line)) heading = line.replace(/^#+\s*/, "").trim();
const here = flat[i]!;
const next = i + 1 < flat.length ? flat[i + 1]! : "";
// On this line, or across the break into the next — but not a phrase that begins on the
// next line alone, which is that line's hit.
const onThis = here.includes(needle);
const acrossTheBreak = !onThis && next !== "" && `${here} ${next}`.includes(needle) && !next.includes(needle);
if (onThis || acrossTheBreak) {
if (hits.length >= cap) {
more = true;
break;
}
hits.push({ path, line: i + 1, heading, text: acrossTheBreak ? `${line.trim()} ${lines[i + 1]!.trim()}` : line.trim() });
}
}
if (more) break;
}
const commit = (await this.standing()).commit;
return { hits, more, commit };
}
/** One document, whole, or its first part with a note when it is very long. The path is kept
* inside the checkout: `..` and absolute paths are refused, not resolved. */
async read(path: string): Promise<{ path: string; content: string; truncated: boolean; commit: string }> {
const clean = normalize(path).split(sep).join("/");
if (!clean || clean.startsWith("..") || clean.startsWith("/") || clean.includes("/../")) {
throw new Error(`"${path}" is not a path inside the repository`);
}
let content: string;
try {
content = await fs.readFile(join(this.dir, clean), "utf8");
} catch {
throw new Error(`the repository holds no ${clean} — \`records_list\` says what it holds`);
}
const truncated = content.length > MOST_BYTES;
return {
path: clean,
content: truncated ? content.slice(0, MOST_BYTES) + "\n\n[… truncated; the document is longer than this answer carries]" : content,
truncated,
commit: (await this.standing()).commit,
};
}
/** What a folder holds: its sub-folders and its documents, one level. */
async list(folder = ""): Promise<{ folder: string; folders: string[]; documents: string[] }> {
const clean = normalize(folder || ".").split(sep).join("/").replace(/^\.\/?/, "");
if (clean.startsWith("..") || clean.startsWith("/")) {
throw new Error(`"${folder}" is not a folder inside the repository`);
}
const at = clean ? join(this.dir, clean) : this.dir;
let entries: import("node:fs").Dirent[];
try {
entries = await fs.readdir(at, { withFileTypes: true });
} catch {
throw new Error(`the repository holds no folder ${clean || "/"}`);
}
const folders = entries.filter((e) => e.isDirectory() && e.name !== ".git" && e.name !== "node_modules").map((e) => e.name).sort();
const documents = entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name).sort();
return { folder: clean, folders, documents };
}
}
/** The reader as the mesh configures it: the directory it was given, the forge it was bound to, and
* the repository its settings name. Refuses to guess any of the three (novox/hq ADR 0112). */
export async function recordsFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<Records> {
const dir = env.MESH_RECORDS_DIR;
if (!dir) throw new Error("MESH_RECORDS_DIR is unset: the mesh gives this module the directory its checkout lives in");
const originFile = env.MESH_RECORDS_ORIGIN_FILE;
if (!originFile) throw new Error("MESH_RECORDS_ORIGIN_FILE is unset: the forge's address comes from the git provision's binding");
const origin = (await fs.readFile(originFile, "utf8")).trim();
if (!origin) throw new Error(`${originFile} is empty: the git provision has not been bound yet`);
const configFile = env.MESH_RECORDS_CONFIG_FILE;
if (!configFile) throw new Error("MESH_RECORDS_CONFIG_FILE is unset");
let config: { repository?: unknown } = {};
try {
config = JSON.parse(await fs.readFile(configFile, "utf8")) as { repository?: unknown };
} catch (e) {
throw new Error(`${configFile} is not JSON: ${e instanceof Error ? e.message : String(e)}`);
}
const repository = typeof config.repository === "string" ? config.repository.trim() : "";
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
throw new Error(
"this module reads the repository its settings name, and none is set: " +
'`settings set records <file>` with {"repository": "<owner>/<name>"} — a module names no mesh (novox/hq ADR 0112)',
);
}
return new Records(dir, origin, repository);
}
/** A line without its markdown emphasis, so a phrase matches the words and not the marks. */
function plain(line: string): string {
return line.replace(/[*_`]/g, "");
}
async function exists(path: string): Promise<boolean> {
try {
await fs.stat(path);
return true;
} catch {
return false;
}
}
-87
View File
@@ -1,87 +0,0 @@
/**
* The reader against a real repository: a checkout, a phrase found where it is written, a document
* read whole, a merge pulled — and the check novox/hq ADR 0025 names: search for a phrase that appears
* only in one design document, and get it back.
*/
import assert from "node:assert/strict";
import { test } from "node:test";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Records } from "../records.ts";
function aRepository(): string {
const dir = mkdtempSync("/tmp/records-origin-");
const git = (...args: string[]) => execFileSync("git", ["-C", dir, ...args], { stdio: "pipe" });
git("init", "--quiet", "--initial-branch=main");
git("config", "user.email", "t@example.invalid");
git("config", "user.name", "t");
mkdirSync(join(dir, "02-DECISIONS"));
mkdirSync(join(dir, "03-DESIGN"));
writeFileSync(join(dir, "README.md"), "# A repository\n\nWhat this is.\n");
writeFileSync(join(dir, "02-DECISIONS/0001-a-decision.md"), "# 1. A decision\n\n## Context\n\nThe context.\n\n## Decision\n\nWe decided the thing.\n");
writeFileSync(join(dir, "03-DESIGN/07-knowledge.md"), "# Knowledge\n\n## The stores\n\nSilence and success must never look alike.\n\nA phrase that is wrapped at the\ncolumn where every document wraps, and **reachable is not the same as\nsurfacing** under emphasis.\n");
git("add", "-A");
git("commit", "--quiet", "-m", "first");
return dir;
}
test("a phrase that appears in one design document comes back from where it is written", async () => {
const origin = aRepository();
const records = new Records(mkdtempSync("/tmp/records-checkout-"), "file://" + origin.replace(/\/[^/]+$/, ""), origin.split("/").pop()!);
// A file:// origin has no `.git` suffix; point the clone at the directory itself.
Object.defineProperty(records, "url", { get: () => origin });
await records.sync();
const found = await records.search("never look alike");
assert.equal(found.hits.length, 1);
assert.equal(found.hits[0]!.path, "03-DESIGN/07-knowledge.md");
assert.equal(found.hits[0]!.heading, "The stores");
assert.match(found.commit, /^[0-9a-f]{40}$/, "the answer names the commit it was read at");
// Wrapped prose: a phrase across the line break is found, once, at the line it starts on; and
// emphasis marks are not part of the words.
const wrapped = await records.search("wrapped at the column where");
assert.deepEqual(wrapped.hits.map((h) => [h.path, h.line]), [["03-DESIGN/07-knowledge.md", 7]]);
const emphasised = await records.search("reachable is not the same as surfacing");
assert.deepEqual(emphasised.hits.map((h) => [h.path, h.line]), [["03-DESIGN/07-knowledge.md", 8]]);
const alsoOnOneLine = await records.search("under emphasis");
assert.equal(alsoOnOneLine.hits.length, 1, "a phrase on one line is not also counted from the line before it");
const doc = await records.read("02-DECISIONS/0001-a-decision.md");
assert.match(doc.content, /We decided the thing/);
assert.equal(doc.truncated, false);
const root = await records.list();
assert.deepEqual(root.folders, ["02-DECISIONS", "03-DESIGN"]);
assert.deepEqual(root.documents, ["README.md"]);
const standing = await records.standing();
assert.equal(standing.documents, 3);
assert.equal(standing.lastError, undefined);
// A merge on the origin, pulled: the checkout follows the source and names the new commit.
writeFileSync(join(origin, "02-DECISIONS/0002-another.md"), "# 2. Another\n\nA phrase nobody wrote before.\n");
execFileSync("git", ["-C", origin, "add", "-A"], { stdio: "pipe" });
execFileSync("git", ["-C", origin, "commit", "--quiet", "-m", "second"], { stdio: "pipe" });
await records.sync();
const after = await records.search("nobody wrote before");
assert.equal(after.hits.length, 1);
assert.notEqual(after.commit, found.commit);
});
test("a path outside the repository is refused, and an empty search is too", async () => {
const records = new Records(mkdtempSync("/tmp/records-checkout-"), "http://forge.invalid:3000", "novox/hq");
await assert.rejects(() => records.read("../etc/passwd"), /not a path inside/);
await assert.rejects(() => records.read("/etc/passwd"), /not a path inside/);
await assert.rejects(() => records.search(" "), /empty one/);
assert.equal(records.url, "http://forge.invalid:3000/novox/hq.git");
});
test("a sync that fails leaves the checkout standing and says why", async () => {
const records = new Records(mkdtempSync("/tmp/records-checkout-"), "http://127.0.0.1:1", "novox/hq");
await records.sync();
const standing = await records.standing();
assert.equal(standing.commit, "");
assert.ok(standing.lastError, "a failed sync is said, not swallowed");
});
-62
View File
@@ -1,62 +0,0 @@
// records' tools — how the record is asked (novox/hq ADR 0025, ADR 0153).
//
// Five questions, each answered from the checkout at the commit it names: where does a phrase
// appear, what does one document say, what does a folder hold, where does the checkout stand, and
// bring it up to date now. The reasoning stays in the documents; the tools only find them.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { recordsFromEnv, type Records } from "../records.js";
export function getRecordsTools(records: Records): ToolDefinition[] {
return [
{
name: "records_search",
description:
"Where a phrase appears in the decisions, designs and issues, as written: document, line, nearest heading. " +
"Search the literal words of a symptom or a term before forming a hypothesis; the answer names the commit it was read at.",
input: {
query: { type: "string", description: "the phrase, matched case-insensitively as written" },
limit: { type: "number", description: "at most this many places (default 50)" },
},
run: async (args) => records.search(String(args.query ?? ""), args.limit ? Number(args.limit) : undefined),
},
{
name: "records_read",
description: "One document, whole, by its path in the repository — a decision record, a design document, an issue report.",
input: { path: { type: "string", description: "the document's path, e.g. 02-DECISIONS/0025-....md" } },
run: async (args) => records.read(String(args.path ?? "")),
},
{
name: "records_list",
description: "What a folder of the repository holds: its sub-folders and its documents. The root when no folder is named.",
input: { folder: { type: "string", description: "a folder inside the repository (optional)" } },
run: async (args) => records.list(args.folder ? String(args.folder) : ""),
},
{
name: "records_status",
description: "Where the checkout stands: the repository, the forge it is read from, the commit and its date, when it was last brought up to date.",
input: {},
run: async () => records.standing(),
},
{
name: "records_sync",
description: "Bring the checkout up to date now, and say where it stands.",
input: {},
run: async () => {
await records.sync();
return records.standing();
},
},
];
}
// The reader is made once, at load, from the environment the runtime resolves; the contributor is
// synchronous and is called at every collection. Without a repository to read there is nothing to
// answer, and the module exposes no tools rather than five that fail — the sdk's contract: a
// contributor returning [] is normal.
let reader: Records | null = null;
try {
reader = await recordsFromEnv();
} catch (err) {
console.log(`[records] no tools — ${err instanceof Error ? err.message : String(err)}`);
}
registerModuleTools("records", () => (reader ? getRecordsTools(reader) : []));
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["records.ts", "index.ts", "tools/index.ts"]
}