Merge pull request 'Nothing reads decision records from hq: records_decisions retired, reserve asks the knowledge base, an empty import refuses (hq issue 488)' (#214) from fix/488-nothing-reads-records-from-hq into main

This commit was merged in pull request #214.
This commit is contained in:
2026-10-11 10:55:19 +00:00
18 changed files with 403 additions and 330 deletions
@@ -0,0 +1,62 @@
package main
// The highest number held per kind (hq issue 488): what the issue tracker's reserve starts and raises its decision
// and research counters above (ADR 0275 rule 8, ADR 0290 rule 5), now that the records live here and hq's folders
// empty. Every number held counts — accepted, proposed, reserved or withdrawn — since each is spent.
import (
"context"
"errors"
"fmt"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// HighestTool is the module's tool the issue tracker asks.
const HighestTool = "knowledge_highest"
// Highest is the highest number held of each kind, and how many records are held.
type Highest struct {
Decision int `json:"decision"`
Research int `json:"research"`
Records int `json:"records"`
Says string `json:"says"`
}
// HighestHeld is the highest number held per kind. A knowledge base that holds no record answers none: a counter
// started from it would start at 1.
func (k *KB) HighestHeld() (*Highest, error) {
all, err := k.Store.All(context.Background())
if err != nil {
return nil, fmt.Errorf("the knowledge base cannot be read (%v); it answers no highest number", err)
}
h := &Highest{Records: len(all)}
for _, e := range all {
switch r := e.Current; r.Kind {
case KindDecision:
h.Decision = max(h.Decision, r.Number)
case KindResearch:
h.Research = max(h.Research, r.Number)
}
}
if h.Records == 0 {
return nil, errors.New("the knowledge base holds no record yet (it is imported from hq once): it answers no highest number")
}
h.Says = fmt.Sprintf("The highest numbers held, of %d records, every number spent counted: decision %04d, research %03d.",
h.Records, h.Decision, h.Research)
return h, nil
}
func highestTool(k *KB) stdio.Tool {
return stdio.Tool{Name: HighestTool,
Description: "The highest decision number and the highest research number the knowledge base holds, every number spent " +
"counted: accepted, proposed, reserved or withdrawn. The issue tracker starts and raises its counters above them. " +
"Refused while the knowledge base holds no record, or cannot be read: a number is never guessed.",
Input: schema(nil, map[string]any{}),
Run: func(m map[string]any) (any, error) {
if _, err := readArgs(HighestTool, m); err != nil {
return nil, err
}
return k.HighestHeld()
}}
}
@@ -0,0 +1,88 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// hq issue 488: the issue tracker's reserve takes the highest decision and research number from the knowledge base,
// which holds every record — reserved, withdrawn and proposed numbers included, since each is spent — rather than
// reading hq's folders, which empty when the records leave.
func TestTheHighestToolAnswersTheHighestNumberHeldPerKind(t *testing.T) {
eachStore(t, func(t *testing.T, s Store) {
w := imported(t, s)
tool := highestTool(w.k)
answer := func() Highest {
t.Helper()
out, err := tool.Run(map[string]any{})
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(out)
var h Highest
if err := json.Unmarshal(raw, &h); err != nil {
t.Fatal(err)
}
return h
}
if h := answer(); h.Decision != 290 || h.Research != 45 || h.Records != 11 {
t.Fatalf("%+v", h)
}
// A number reserved, and later withdrawn, is spent: it is the highest.
w.reserved(t, "author@g14") // 300
w.clock = w.clock.Add(DefaultReservedFor + time.Hour)
if _, err := w.k.Withdraw(); err != nil {
t.Fatal(err)
}
if h := answer(); h.Decision != 300 || h.Research != 45 {
t.Fatalf("after a reservation withdrawn: %+v", h)
}
if _, err := tool.Run(map[string]any{"kind": "decision"}); err == nil {
t.Fatal("an argument it does not take was taken")
}
})
// A knowledge base that holds nothing answers nothing: a 0 would start a counter at 1.
empty := newWorld(t, newMemStore(), fixture())
if _, err := highestTool(empty.k).Run(map[string]any{}); err == nil || !strings.Contains(err.Error(), "holds no record") {
t.Fatalf("an empty knowledge base: %v", err)
}
}
// hq issue 488: once the records have left hq, an import at a later commit finds none. It refuses, saying so,
// rather than reporting nothing created as a success.
func TestTheImportRefusesACommitWhereHqHoldsNoRecords(t *testing.T) {
dir := t.TempDir()
write := func(path, text string) {
t.Helper()
if err := writeFileAll(dir+"/"+path, text); err != nil {
t.Fatal(err)
}
}
write("COMMIT", "0123456789abcdef0123456789abcdef01234567\n")
write("02-DECISIONS/README.md", "# Decisions\n\nThey live in the knowledge base.\n")
write("01-RESEARCH/README.md", "# Research\n")
w := newWorld(t, newMemStore(), dirRecords{dir: dir})
for _, dry := range []bool{true, false} {
out, err := w.k.Import("import@test", ImportAsk{DryRun: dry}, dirRecords{dir: dir})
if err == nil || !strings.Contains(err.Error(), "holds no decision record or research effort") ||
!strings.Contains(err.Error(), "0123456789ab") {
t.Fatalf("dry %v: %+v %v", dry, out, err)
}
}
if all, _ := w.k.Store.All(context.Background()); len(all) != 0 {
t.Fatal("written")
}
}
func writeFileAll(path, text string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte(text), 0o644)
}
@@ -127,6 +127,12 @@ func (k *KB) Import(caller string, a ImportAsk, records Records) (*Imported, err
cands = append(cands, &importCandidate{path: ResearchFolder + "/" + f})
}
}
if len(cands) == 0 {
// The records left hq (hq issue 426): what it held is here, and an import from it would say "nothing
// created" as a success (hq issue 488).
return nil, fmt.Errorf("hq holds no decision record or research effort at commit %s: its %s and %s hold none. "+
"The records live in the knowledge base now, and nothing was imported", short(commit), DecisionsFolder, ResearchFolder)
}
// Read and parse, a few at a time, every document at the one commit.
var moved error
var movedMu sync.Mutex
@@ -98,7 +98,7 @@ func serve(k *KB, status string) {
k.kick = make(chan struct{}, 1)
go hear(k)
go background(k)
if err := stdio.Serve("", append(Tools(k, callerOf), healthTool(status, k.now))); err != nil {
if err := stdio.Serve("", append(Tools(k, callerOf), healthTool(status, k.now), highestTool(k))); err != nil {
logf("%v", err)
os.Exit(1)
}
@@ -118,7 +118,7 @@ func TestItsProcessesAreThePreparationTheActAndTheJudge(t *testing.T) {
}
h, _ := watch["health"].(map[string]any)
if watch == nil || watch["run"].([]any)[1] != "watch" || h["kind"] != "tool" || h["tool"] != HealthTool ||
!reflect.DeepEqual(m.Tools, []string{HealthTool}) {
!reflect.DeepEqual(m.Tools, []string{HealthTool, HighestTool}) {
t.Fatalf("watch %v, tools %v", watch, m.Tools)
}
if watch["env"].(map[string]any)["MESH_KNOWLEDGE_STATUS"] != m.Build.Artifacts[0].Env["MESH_KNOWLEDGE_STATUS"] {