From db9a5bff0c08a90078fbe20b40d48e0fc751a57e Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 5 Oct 2026 17:42:08 +0200 Subject: [PATCH] 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. --- modules/records/README.md | 9 + modules/records/cmd/records/main.go | 133 +++++++ modules/records/cmd/records/records.go | 417 ++++++++++++++++++++ modules/records/cmd/records/records_test.go | 147 +++++++ modules/records/go.mod | 5 + modules/records/go.sum | 2 + modules/records/index.ts | 34 -- modules/records/module.json | 15 +- modules/records/package.json | 18 - modules/records/records.ts | 277 ------------- modules/records/test/records.test.ts | 87 ---- modules/records/tools/index.ts | 62 --- modules/records/tsconfig.json | 12 - 13 files changed, 718 insertions(+), 500 deletions(-) create mode 100644 modules/records/cmd/records/main.go create mode 100644 modules/records/cmd/records/records.go create mode 100644 modules/records/cmd/records/records_test.go create mode 100644 modules/records/go.mod create mode 100644 modules/records/go.sum delete mode 100644 modules/records/index.ts delete mode 100644 modules/records/package.json delete mode 100644 modules/records/records.ts delete mode 100644 modules/records/test/records.test.ts delete mode 100644 modules/records/tools/index.ts delete mode 100644 modules/records/tsconfig.json diff --git a/modules/records/README.md b/modules/records/README.md index eb468c3..68c3167 100644 --- a/modules/records/README.md +++ b/modules/records/README.md @@ -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. diff --git a/modules/records/cmd/records/main.go b/modules/records/cmd/records/main.go new file mode 100644 index 0000000..1c423ae --- /dev/null +++ b/modules/records/cmd/records/main.go @@ -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) + } +} diff --git a/modules/records/cmd/records/records.go b/modules/records/cmd/records/records.go new file mode 100644 index 0000000..99b9d95 --- /dev/null +++ b/modules/records/cmd/records/records.go @@ -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 ` with {\"repository\": \"/\"} — a module names no mesh (novox/hq ADR 0112)") + } + return &Records{Base: dir, Origin: origin, Repository: repository}, nil +} diff --git a/modules/records/cmd/records/records_test.go b/modules/records/cmd/records/records_test.go new file mode 100644 index 0000000..b1ac821 --- /dev/null +++ b/modules/records/cmd/records/records_test.go @@ -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) + } +} diff --git a/modules/records/go.mod b/modules/records/go.mod new file mode 100644 index 0000000..81bbc8e --- /dev/null +++ b/modules/records/go.mod @@ -0,0 +1,5 @@ +module records + +go 1.25.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/records/go.sum b/modules/records/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/records/go.sum @@ -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= diff --git a/modules/records/index.ts b/modules/records/index.ts deleted file mode 100644 index b1aaa9c..0000000 --- a/modules/records/index.ts +++ /dev/null @@ -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 }; diff --git a/modules/records/module.json b/modules/records/module.json index f1f3c4e..02feb5b 100644 --- a/modules/records/module.json +++ b/modules/records/module.json @@ -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", diff --git a/modules/records/package.json b/modules/records/package.json deleted file mode 100644 index fb56341..0000000 --- a/modules/records/package.json +++ /dev/null @@ -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" - } -} diff --git a/modules/records/records.ts b/modules/records/records.ts deleted file mode 100644 index 6b7735d..0000000 --- a/modules/records/records.ts +++ /dev/null @@ -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 | 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 { - if (!this.syncing) { - this.syncing = this.doSync().finally(() => { - this.syncing = undefined; - }); - } - return this.syncing; - } - - private async doSync(): Promise { - 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 { - 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 { - const out: string[] = []; - const walk = async (at: string): Promise => { - 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 { - 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 ` with {"repository": "/"} — 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 { - try { - await fs.stat(path); - return true; - } catch { - return false; - } -} diff --git a/modules/records/test/records.test.ts b/modules/records/test/records.test.ts deleted file mode 100644 index 104ddef..0000000 --- a/modules/records/test/records.test.ts +++ /dev/null @@ -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"); -}); diff --git a/modules/records/tools/index.ts b/modules/records/tools/index.ts deleted file mode 100644 index 85d3651..0000000 --- a/modules/records/tools/index.ts +++ /dev/null @@ -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) : [])); diff --git a/modules/records/tsconfig.json b/modules/records/tsconfig.json deleted file mode 100644 index 1ccd946..0000000 --- a/modules/records/tsconfig.json +++ /dev/null @@ -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"] -}