diff --git a/modules/claude-code/cmd/claude-code/config_test.go b/modules/claude-code/cmd/claude-code/config_test.go index 73072d92..f443507e 100644 --- a/modules/claude-code/cmd/claude-code/config_test.go +++ b/modules/claude-code/cmd/claude-code/config_test.go @@ -270,11 +270,17 @@ func TestTheManifestListsEveryToolServed(t *testing.T) { } var m struct { Tools []string `json:"tools"` - Invokes []string `json:"invokes"` + Invokes []string `json:"invokes"` + Reads []string `json:"reads"` + Consumes []string `json:"consumes"` } _ = json.Unmarshal(raw, &m) - if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted"}) { - t.Errorf("invokes = %v, want the hand-act seat's warranted alone", m.Invokes) + // The hand-act seat's warranted and hand-act, and the owners' read tools of the three followed states; no verb of + // the controller, and no read of the indexer's or the assigner's state (ADR 0615 §1): their views are consumed. + if !slices.Equal(m.Invokes, []string{"seat:mesh-hand-acts.warranted", "seat:mesh-hand-acts.hand-act", ModulesTool, ManifestsTool, PlacementTool}) || + !slices.Equal(m.Reads, []string{"claude-licence-manager.bindings"}) || + !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.placement"}) { + t.Errorf("invokes = %v, reads = %v, consumes = %v", m.Invokes, m.Reads, m.Consumes) } if src, _ := os.ReadFile("proposals.go"); !strings.Contains(string(src), `"seat:mesh-hand-acts.warranted"`) || strings.Contains(string(src), "seat:mesh-controller.warranted") { @@ -285,6 +291,7 @@ func TestTheManifestListsEveryToolServed(t *testing.T) { listed[n] = true } p, _ := node(t, "laptop") + meshViews = newMeshViews() served := tools(p, nil, NewServerView(p), memConfig{}, NewConfigView(p)) for _, tool := range served { if !listed[tool.Name] { diff --git a/modules/claude-code/cmd/claude-code/guard_test.go b/modules/claude-code/cmd/claude-code/guard_test.go index 5ea512ea..7acf464d 100644 --- a/modules/claude-code/cmd/claude-code/guard_test.go +++ b/modules/claude-code/cmd/claude-code/guard_test.go @@ -9,25 +9,25 @@ import ( "strings" "testing" - "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-contracts/go/seats" ) // meshForTheGuard is a mesh of two machines and the verbs that replace what an agent runs over ssh, as the // seats' contract and the modules' manifests say them. func meshForTheGuard() GuardData { - seats := []catalogue.Seat{ - {Name: "mesh-placement", Scope: catalogue.ScopeMesh, Serves: []catalogue.Verb{ + defined := []seats.Seat{ + {Name: "mesh-placement", Scope: "mesh", Serves: []seats.Verb{ {Name: "placement", Description: "Read placement: one node's key.", Replaces: []string{"hostnamectl", "uptime"}}}}, - {Name: "node-service-manager", Scope: catalogue.ScopeNode, Serves: []catalogue.Verb{ + {Name: "node-service-manager", Scope: "node", Serves: []seats.Verb{ {Name: "status", Description: "One unit as the service manager sees it now: its states.", Replaces: []string{"systemctl status", "systemctl is-active"}}, {Name: "restart", Description: "Restart one unit.", Replaces: []string{"systemctl restart"}}, {Name: "journal", Description: "The last lines of one unit's journal.", Replaces: []string{"journalctl"}}}}, - {Name: "node-hostname", Scope: catalogue.ScopeNode, Serves: []catalogue.Verb{ + {Name: "node-hostname", Scope: "node", Serves: []seats.Verb{ {Name: "add", Description: "Add one address and its names to the operator's lines of /etc/hosts.", Replaces: []string{"edit /etc/hosts", "HOSTALIASES"}}}}, } modules := []ModuleTools{{Module: "docker", On: []string{"anchor"}, Replaces: map[string][]string{"docker_logs": {"docker logs"}, "docker_list": {"docker ps"}}}, {Module: "systemd", On: []string{"anchor", "laptop"}}} - return GuardData{Node: "laptop", Replacements: ReplacementsOf(seats, modules), Machines: []Machine{ + return GuardData{Node: "laptop", Replacements: ReplacementsOf(defined, modules), Machines: []Machine{ {Name: "anchor", Domains: []string{"anchor" + InternalSuffix, "anchor.example"}, Addresses: []string{"192.0.2.10", "10.10.0.1"}}, {Name: "laptop", Domains: []string{"laptop" + InternalSuffix}, Addresses: []string{"10.10.0.4"}}, }} diff --git a/modules/claude-code/cmd/claude-code/insteadof_test.go b/modules/claude-code/cmd/claude-code/insteadof_test.go index 0be167b2..8e581500 100644 --- a/modules/claude-code/cmd/claude-code/insteadof_test.go +++ b/modules/claude-code/cmd/claude-code/insteadof_test.go @@ -1,16 +1,20 @@ package main import ( + "context" "crypto/sha256" "encoding/json" "errors" "fmt" "os" + "reflect" "strings" "testing" + "time" - "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-contracts/go/seats" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/views" ) // aBus is the states claude-code reads, as a test holds them: by read, key and value. @@ -33,12 +37,12 @@ func (b aBus) Get(read, key string) ([]byte, bool, error) { } // someSeats are two seats as the seats' contract says them: one held once for the mesh, one on every machine. -func someSeats() []catalogue.Seat { - return []catalogue.Seat{ - {Name: "mesh-placement", Scope: catalogue.ScopeMesh, Serves: []catalogue.Verb{ +func someSeats() []seats.Seat { + return []seats.Seat{ + {Name: "mesh-placement", Scope: "mesh", Serves: []seats.Verb{ {Name: "placement", Description: "Read placement: one node's key.", Replaces: []string{"hostnamectl", "uptime"}}, {Name: "propose", Description: "Propose a change."}}}, - {Name: "node-service-manager", Scope: catalogue.ScopeNode, Serves: []catalogue.Verb{ + {Name: "node-service-manager", Scope: "node", Serves: []seats.Verb{ {Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot.", Replaces: []string{"systemctl status", "systemctl is-active"}}, {Name: "journal", Description: "The last lines of one unit's journal.", Replaces: []string{"journalctl"}}}}, } @@ -192,10 +196,44 @@ func TestTheMachinesRunningAModuleAreReadFromPlacement(t *testing.T) { } } +// ADR 0615 §1: the tools and the placement come from the owners' first views and their views events, never their state. +// A reader whose first views are answered from the fixture reads what the state read gave, and a views event changes +// what it reads (CAT-CC3). +func TestTheMeshIsReadFromTheOwnersFirstViews(t *testing.T) { + bus := aMesh(t) + r := newMeshViews() + for _, name := range []string{ModulesRead, ManifestsRead, PlacementRead} { + state := strings.TrimPrefix(name, strings.Split(name, ".")[0]+".") + values := map[string]json.RawMessage{} + for k, v := range bus[name] { + values[k] = v + } + f := r.Follow(strings.Split(name, ".")[0], state, "x") + f.Ask = func(context.Context, string) (views.Answer, error) { + return views.Answer{V: views.Version, Owner: f.Owner, State: state, Revision: 5, Created: time.Unix(1, 0), Values: values, At: time.Now()}, nil + } + if err := f.Start(context.Background()); err != nil { + t.Fatal(err) + } + } + want, err := ReadMeshToolsFrom(bus, seats.List(), resolving) + if err != nil { + t.Fatal(err) + } + got, err := ReadMeshToolsFrom(viewStates{r}, seats.List(), resolving) + if err != nil || !reflect.DeepEqual(got, want) || len(got.Replacements) == 0 { + t.Fatalf("from the views: %+v, %v\nfrom the state: %+v", got, err, want) + } + // A reader not yet answered says so, and reads nothing. + if _, err := ReadMeshToolsFrom(viewStates{newMeshViews()}, seats.List(), resolving); err == nil || !strings.Contains(err.Error(), "no first view") { + t.Fatalf("an unanswered first view: %v", err) + } +} + // Issue 1126 B13 AC1 (ADR 0590): over the seats the mesh defines, the rendered instructions, their table among them, // name no address of the controller, which is not placed: each job is named by the seat that owns it now. func TestTheRenderedInstructionsNameNoControllerAddress(t *testing.T) { - m, err := ReadMeshToolsFrom(aMesh(t), catalogue.Seats(), resolving) + m, err := ReadMeshToolsFrom(aMesh(t), seats.List(), resolving) if err != nil { t.Fatal(err) } diff --git a/modules/claude-code/cmd/claude-code/judge.go b/modules/claude-code/cmd/claude-code/judge.go index 724cfbd0..6491c44f 100644 --- a/modules/claude-code/cmd/claude-code/judge.go +++ b/modules/claude-code/cmd/claude-code/judge.go @@ -71,6 +71,17 @@ type Judged struct { Pending bool `json:"pending,omitempty"` } +// JudgedEvent is the body of `judged`: one per judgement asked with a bound past JudgeBound, said when it ends, with +// its answer or why there is none. The question and the schema are never in it. +type JudgedEvent struct { + Ticket string `json:"ticket"` + Node string `json:"node"` + Model string `json:"model,omitempty"` + Seconds float64 `json:"seconds,omitempty"` + Answer json.RawMessage `json:"answer,omitempty"` + Error string `json:"error,omitempty"` +} + // running is one judgement asked with a bound past JudgeBound, until it is collected. type running struct { done chan struct{} @@ -92,6 +103,9 @@ type Judge struct { // Wait is how long a call waits for a long judgement before it answers its ticket; JudgeWait unless a test // says less. Wait time.Duration + // Say tells the bus a ticketed judgement ended: the event `judged`, once, under the message id it is given so a + // repeat is dropped. Nil says nothing (a test's judge); the ticket is collected by claude_code_judged all the same. + Say func(body JudgedEvent, id string) error turn chan struct{} mu sync.Mutex @@ -208,10 +222,28 @@ func (j *Judge) start(schema, model, question string, bound time.Duration) (*Jud go func() { r.out, r.err = j.run(schema, model, question, bound) close(r.done) + j.said(ticket, r) }() return j.Collect(ticket) } +// said tells the bus that the judgement under a ticket ended (the event `judged`), once. A failure to say is logged: the +// ticket stays collectible by claude_code_judged, and the asker's own bound ends its wait. +func (j *Judge) said(ticket string, r *running) { + if j.Say == nil { + return + } + e := JudgedEvent{Ticket: ticket, Node: j.P.Node} + if r.err != nil { + e.Error = r.err.Error() + } else { + e.Model, e.Seconds, e.Answer = r.out.Model, r.out.Seconds, r.out.Answer + } + if err := j.Say(e, "judged-"+j.P.Node+"-"+ticket); err != nil { + say("the judgement under ticket %s ended and could not be said on the bus: %v", ticket, err) + } +} + // Collect waits as long as a call may for the judgement under a ticket: its answer, why it failed, or that it is // still pending. A ticket is answered once; an answer nobody collects is kept JudgedKept past its bound. func (j *Judge) Collect(ticket string) (*Judged, error) { diff --git a/modules/claude-code/cmd/claude-code/judge_test.go b/modules/claude-code/cmd/claude-code/judge_test.go index 4fd3da1f..8f48e003 100644 --- a/modules/claude-code/cmd/claude-code/judge_test.go +++ b/modules/claude-code/cmd/claude-code/judge_test.go @@ -182,6 +182,9 @@ func TestALongJudgementIsCollectedByItsTicket(t *testing.T) { } }) j.Wait = 50 * time.Millisecond + said := make(chan JudgedEvent, 4) + ids := make(chan string, 4) + j.Say = func(e JudgedEvent, id string) error { said <- e; ids <- id; return nil } start := time.Now() out, err := j.Judge(JudgeAsk{Question: "a long record", Schema: verdictSchema, Within: 90 * time.Second}) if err != nil { @@ -203,6 +206,21 @@ func TestALongJudgementIsCollectedByItsTicket(t *testing.T) { if got.Pending || string(got.Answer) != `{"verdict":"none"}` || got.Ticket != out.Ticket { t.Fatalf("%+v", got) } + // One `judged` per ticket, with the answer, under an id of the ticket's own; none for a judgement with no ticket. + select { + case e := <-said: + if e.Ticket != out.Ticket || e.Node != "control" || string(e.Answer) != `{"verdict":"none"}` || e.Error != "" || e.Model != "sonnet" { + t.Fatalf("judged: %+v", e) + } + if id := <-ids; id != "judged-control-"+out.Ticket { + t.Fatalf("judged under %q", id) + } + case <-time.After(5 * time.Second): + t.Fatal("the ended judgement was not said") + } + if len(said) != 0 { + t.Fatal("judged said twice") + } // Read once the judgement is collected: its end is what orders the agent's write before this read. if left := deadline.Sub(start); left < 80*time.Second || left > 91*time.Second { t.Fatalf("the judgement's bound is %s, not the 90s asked", left) @@ -224,10 +242,23 @@ func TestALongJudgementIsBoundedAndSaysWhyItFailed(t *testing.T) { } j.Wait = 20 * time.Millisecond j.Bound = time.Second // the test's stand-in for JudgeBound: within is past it, so a ticket; far past Wait on a loaded machine + failed := make(chan JudgedEvent, 1) + j.Say = func(e JudgedEvent, _ string) error { failed <- e; return nil } out, err := j.Judge(JudgeAsk{Question: "q", Schema: verdictSchema, Within: 2 * time.Second}) if err != nil || !out.Pending { t.Fatalf("%+v %v", out, err) } + defer func() { + // A failed judgement is said too, with why and no answer. + select { + case e := <-failed: + if e.Ticket != out.Ticket || e.Answer != nil || !strings.Contains(e.Error, "did not answer within") { + t.Errorf("judged: %+v", e) + } + case <-time.After(5 * time.Second): + t.Error("the failed judgement was not said") + } + }() for i := 0; i < 3000; i++ { got, err := j.Collect(out.Ticket) if err != nil { diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index 3c53201b..61e23ad5 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -12,6 +12,7 @@ package main import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -21,9 +22,12 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-contracts/go/seats" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asks" - "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/handacts" + "git.novox.be/novox/mesh-sdk/go/views" ) func say(format string, args ...any) { @@ -205,23 +209,46 @@ func (s stateOf) Get(key string) (json.RawMessage, bool, error) { return e.Value, true, nil } -// nodesRunningMe is the nodes claude-code runs on, from the seat mesh-placement's placement — for the register -// tool's question. -func nodesRunningMe() ([]string, error) { return NodesRunning(busStates{}, "claude-code") } +// nodesRunningMe is the nodes claude-code runs on, from the assigner's placement — for the register tool's question. +func nodesRunningMe() ([]string, error) { return NodesRunning(viewStates{meshViews}, "claude-code") } -// busStates reads the states the manifest declares in its reads through the runtime. -type busStates struct{} +// The owners' read tools of the states this module follows, which the manifest invokes (novox/hq ADR 0615 §1). +const ( + ModulesTool = delivery.IndexerModule + ".indexer_read_modules" + ManifestsTool = delivery.IndexerModule + ".indexer_read_manifests" + PlacementTool = delivery.AssignerModule + ".assigner_read_placement" +) -func (busStates) Keys(read string) ([]string, error) { return stdio.State(read).Keys() } +// meshViews is this module's picture of the indexer's modules and manifests and the assigner's placement: each asked +// once of the owner's read tool, then followed on the owner's views family. It reads none of their state. +var meshViews *views.Reader -func (busStates) Get(read, key string) ([]byte, bool, error) { - e, err := stdio.State(read).Get(key) - if err != nil || e == nil { - return nil, false, err - } - return e.Value, true, nil +// meshChanged is told, without waiting, that a followed state changed: the instructions' table is read again. +var meshChanged = make(chan struct{}, 1) + +// newMeshViews builds the reader of those three states; Start asks them. +func newMeshViews() *views.Reader { + v := &views.Reader{Module: Module, Logf: func(f string, a ...any) { say(f, a...) }, Findings: views.OwnFindings{}, + Acts: handacts.Recorder{Ask: stdio.Ask}, + Changed: func(string, string, json.RawMessage, bool) { + select { + case meshChanged <- struct{}{}: + default: + } + }} + v.Follow(delivery.IndexerModule, "modules", ModulesTool) + v.Follow(delivery.IndexerModule, "manifests", ManifestsTool) + v.Follow(delivery.AssignerModule, "placement", PlacementTool) + return v } +// viewStates reads the followed states from the reader's pictures, never from the owners' state. +type viewStates struct{ v *views.Reader } + +func (s viewStates) Keys(read string) ([]string, error) { return s.v.Keys(read) } + +func (s viewStates) Get(read, key string) ([]byte, bool, error) { return s.v.Get(read, key) } + func fingerprintOfFile(path string) any { raw, err := os.ReadFile(path) if err != nil { @@ -291,7 +318,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c Description: "Write Claude Code's managed directory now, from the mesh's facts, this module's settings and the servers registered here — after reading the bus again for what each tool replaces and which machines the mesh has (the instructions' table and the shell's guard).", Run: func(map[string]any) (any, error) { answer := map[string]any{} - if m, err := ReadMeshToolsFrom(busStates{}, catalogue.Seats(), lookup); err != nil { + if m, err := ReadMeshToolsFrom(viewStates{meshViews}, seats.List(), lookup); err != nil { answer["asked"] = "the bus could not be read in full (" + err.Error() + "); what was kept is rendered" } else { KeepMeshTools(p, m) @@ -372,7 +399,12 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c out = append(out, proposalTools(p)...) out = append(out, waitTools(p)...) judge := NewJudge(p) + // The event `judged`, one per ticket when its judgement ends: the asker hears it instead of asking again. + judge.Say = func(e JudgedEvent, id string) error { + return stdio.Emit("judged", e, stdio.EmitOptions{MessageID: id}) + } out = append(out, judgeTool(judge), judgedTool(judge)) + out = append(out, meshViews.Tools("claude_code")...) return append(out, configTools(p, config, configView)...) } @@ -418,6 +450,7 @@ func main() { return } p, launched := PathsFrom(os.Getenv) + meshViews = newMeshViews() if launched { fsys = FilesFor(p) } @@ -447,6 +480,7 @@ func main() { } } } + meshViews.Start(context.Background()) go refreshMeshTools(p) servers := stateOf{stdio.State("servers")} view := NewServerView(p) diff --git a/modules/claude-code/cmd/claude-code/mesh.go b/modules/claude-code/cmd/claude-code/mesh.go index d27b234b..380cf136 100644 --- a/modules/claude-code/cmd/claude-code/mesh.go +++ b/modules/claude-code/cmd/claude-code/mesh.go @@ -5,7 +5,7 @@ package main // replaces, and the mesh's machines by every name and address a session could reach one by. // // Read from the bus at start and every few minutes (issue 1126 B13; the controller that answered this before is -// not placed, novox/hq ADR 0590): the seats' verbs from the seats' contract (mesh-sdk catalogue), the modules' +// not placed, novox/hq ADR 0590): the seats' verbs from the seats' contract (mesh-contracts), the modules' // own tools from the manifests the indexer keeps (`mesh-delivery-indexer.modules` and `.manifests`), and the // machines and where each module runs from the seat mesh-placement's `placement`. Kept in the module's state so // a render never waits for the bus, and rendered whenever it changes. Nothing here is written by hand: a verb @@ -23,7 +23,7 @@ import ( "strings" "time" - "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-contracts/go/seats" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -217,14 +217,14 @@ func firstSentence(s string) string { // ReplacementsOf is every seat verb and module tool that says it replaces a command: the seats' verbs first, in the // seats' order, then the modules' own tools by module and tool. -func ReplacementsOf(seats []catalogue.Seat, modules []ModuleTools) []Replacement { +func ReplacementsOf(defined []seats.Seat, modules []ModuleTools) []Replacement { var out []Replacement - for _, s := range seats { + for _, s := range defined { for _, v := range s.Serves { if len(v.Replaces) == 0 { continue } - out = append(out, Replacement{Address: s.Name + "." + v.Name, NodeScoped: s.Scope == catalogue.ScopeNode, Seat: true, + out = append(out, Replacement{Address: s.Name + "." + v.Name, NodeScoped: s.Scope == "node", Seat: true, Does: firstSentence(v.Description), Replaces: v.Replaces}) } } @@ -257,15 +257,15 @@ func lookup(name string) []string { // ReadMeshToolsFrom reads the bus now: the seats' verbs, the placed modules' own tools, and every node placement names // with its domains and addresses. A part that cannot be read is left out and said in the error; what was read is kept. -func ReadMeshToolsFrom(st States, seats []catalogue.Seat, resolve func(string) []string) (MeshTools, error) { +func ReadMeshToolsFrom(st States, defined []seats.Seat, resolve func(string) []string) (MeshTools, error) { var m MeshTools placed, err := placements(st) if err != nil { - m.Replacements = ReplacementsOf(seats, nil) + m.Replacements = ReplacementsOf(defined, nil) return m, err } modules, merr := modulesTools(st, runsOn(placed)) - m.Replacements = ReplacementsOf(seats, modules) + m.Replacements = ReplacementsOf(defined, modules) for node, p := range placed { machine := Machine{Name: node, Domains: []string{node + InternalSuffix}} if d := strings.ToLower(strings.TrimSpace(p.Decided.PublicDomain)); d != "" { @@ -334,13 +334,23 @@ func guardFiles(p Paths, m *MeshTools) *GuardFiles { return &GuardFiles{Exe: exe, Data: GuardDataOf(p, m), WaitsDir: WaitsDirPath(p)} } -// MeshToolsEvery is how often the bus is read again. +// MeshToolsEvery is how often the machines' addresses are resolved again; the tools and the placement are read again +// when their followed states change (a views event), not on this timer. const MeshToolsEvery = 10 * time.Minute -// refreshMeshTools reads the bus at start and every few minutes, and renders when what it said changed. +// MeshToolsFirst is how long the first read waits for the first views to be answered. +const MeshToolsFirst = 2 * time.Minute + +// refreshMeshTools reads the pictures once the first views are answered, and again whenever one changes (and the +// machines' addresses every few minutes), and renders when what it said changed. func refreshMeshTools(p Paths) { + select { + case <-meshViews.Ready(): + case <-time.After(MeshToolsFirst): + say("the first views of the tools and the placement were not answered in %s; reading what is held", MeshToolsFirst) + } for { - m, err := ReadMeshToolsFrom(busStates{}, catalogue.Seats(), lookup) + m, err := ReadMeshToolsFrom(viewStates{meshViews}, seats.List(), lookup) if err != nil { say("reading what each tool replaces and which machines the mesh has: %v", err) } @@ -351,7 +361,15 @@ func refreshMeshTools(p Paths) { say("the mesh's tools changed: %d replace a command, %d machines; rendered", len(m.Replacements), len(m.Machines)) } } - time.Sleep(MeshToolsEvery) + select { + case <-meshChanged: + time.Sleep(2 * time.Second) // a burst of events is one read + select { + case <-meshChanged: + default: + } + case <-time.After(MeshToolsEvery): + } } } diff --git a/modules/claude-code/go.work.sum b/modules/claude-code/go.work.sum new file mode 100644 index 00000000..bd0eee14 --- /dev/null +++ b/modules/claude-code/go.work.sum @@ -0,0 +1,2 @@ +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index b1b0da23..3ecf0dfa 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -23,7 +23,16 @@ "operator-channel" ], "invokes": [ - "seat:mesh-hand-acts.warranted" + "seat:mesh-hand-acts.warranted", + "seat:mesh-hand-acts.hand-act", + "mesh-delivery-indexer.indexer_read_modules", + "mesh-delivery-indexer.indexer_read_manifests", + "mesh-delivery-assigner.assigner_read_placement" + ], + "consumes": [ + "mesh-delivery-indexer.views.modules", + "mesh-delivery-indexer.views.manifests", + "mesh-delivery-assigner.views.placement" ], "state": [ "servers", @@ -32,13 +41,14 @@ "proposals", "questions", "memory", - "waits" + "waits", + "findings" + ], + "emits": [ + "judged" ], "reads": [ - "claude-licence-manager.bindings", - "mesh-delivery-assigner.placement", - "mesh-delivery-indexer.modules", - "mesh-delivery-indexer.manifests" + "claude-licence-manager.bindings" ], "tools": [ "claude_code_status", @@ -89,7 +99,9 @@ "claude_code_home_removed", "claude_code_home_restore", "claude_code_judge", - "claude_code_judged" + "claude_code_judged", + "claude_code_first_views", + "claude_code_first_view_again" ], "data": { "own": [ diff --git a/modules/mesh-knowledge/README.md b/modules/mesh-knowledge/README.md index 0dde291c..fc89568b 100644 --- a/modules/mesh-knowledge/README.md +++ b/modules/mesh-knowledge/README.md @@ -23,7 +23,7 @@ operator, it is accepted only on the operator's Approve, given on a channel that | `links` | `number`, `kind` | the record's typed links, out and in | | `history` | `number`, `kind`, `from`, `to`, `bound` | every version; with `from` and `to`, the difference between the two, line by line | | `reserve` | `kind`, `for` | the next decision or research number, asked of the issue tracker (`issue-tracker.reserve`); spent whether or not its record is accepted | -| `propose` | the record whole | proposed, or refused naming every rule it breaks; an accepted record changes only by a progressive insight (`insight`), but an active research effort takes new versions freely, each reviewed as a proposal. Each version proposed is then reviewed by the knowledge base itself (below) | +| `propose` | the record whole | proposed, or refused naming every rule it breaks; an accepted record changes only by a progressive insight (`insight`), but an active research effort takes new versions freely, each reviewed as a proposal; such a version may leave out its sections (and title, date, deciders), keeping the served version's, as when the effort is closed with its status and became links. Each version proposed is then reviewed by the knowledge base itself (below) | | `import` | `dry-run`, `commit` | hq's decision records and research efforts at the commit the records module stands at, each whole at its own number | | `return` | `number`, `reason` | drafted clauses sent back to be drafted again; refused for a record whose clauses another record changed or links by number | | `withdraw` | `number`, `kind`, `why`, `retraction` | a reserved or proposed record withdrawn, as a new version naming why; its number stays spent. A pending review is cleared and a waiting operator's ask is ended. Refused for a record already accepted, superseded or withdrawn. With `retraction` true, the retraction waiting on the record is withdrawn instead, and the record holds as it did | @@ -77,7 +77,7 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel ## Writing a record 1. `reserve` a number for its kind. -2. `propose` the record whole. It is refused, naming every reason at once, when it breaks one of the **30 rules +2. `propose` the record whole. It is refused, naming every reason at once, when it breaks one of the **31 rules that judge one record or its prose**. They are hq's checks moved to the write; `PerRecordRules` in `rules.go` lists each one with the check it comes from: - 25 on the structure: kind, reserved number, title, topic, date, deciders, research status, at least one @@ -109,8 +109,8 @@ as text. `get` serves each, and any other field a record of hq wrote under `fiel shows its state in the knowledge base apart from a research effort's own status (hq issue 492). The background work asks it at once, before the next step of any drafting, one judgement at a time. Every judgement asks a bound sized to its question: 25 seconds and 2 more per KiB, at most 120 (hq issue 508: an eleven-clause record was never - reviewed in 25). A judgement not done within one bus call is answered pending with a ticket, and collected from - the same node with `claude-code.claude_code_judged`. + reviewed in 25). A judgement not done within one bus call is answered pending with a ticket, and the review wakes + on the agent module's event `claude-code.judged` for that ticket (nothing asks again). - On `changes` the record stays proposed, the findings kept with the version they judged (`get` shows them). Its author revises it by proposing it again, and that version is reviewed again. - A judgement that cannot be had (the judge unreachable, or an answer that is not `approve`, or `changes` naming diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/active_effort_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/active_effort_test.go index 3302dec0..aa3b0bcc 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/active_effort_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/active_effort_test.go @@ -1,6 +1,7 @@ package main import ( + "reflect" "strings" "testing" "time" @@ -13,44 +14,95 @@ func effortAsk(n int, status string, docs ...Section) ProposeAsk { Links: []LinkAsk{{Kind: LinkTouches, To: "0290"}}} } -// novox/hq issue 844: an active research effort takes new versions freely, each reviewed as a proposal. +// novox/hq issue 844: an active research effort takes new versions freely, each reviewed as a proposal. Issue 1966: +// a new version that leaves out its sections keeps the served version's text, title, date and deciders, and takes +// the status, links and why given; the rules still judge the version so composed. func TestAnActiveEffortTakesANewVersionWithANewDocument(t *testing.T) { - w := imported(t, newMemStore()) - r, err := w.k.ReserveNumber("author@g14", KindResearch, "an effort") - if err != nil { - t.Fatal(err) - } overview := Section{Name: "00-overview.md", Text: "What an inference layer would be."} - w.accepted(t, "author@g14", effortAsk(r.Number, StatusActive, overview)) - key := Key{KindResearch, r.Number} - reviews := len(w.judge.reviews) - next := effortAsk(r.Number, StatusActive, overview, Section{Name: "01-findings.md", Text: "A new finding."}) - next.Why = "findings added" - if _, err := w.k.Propose("author@g14", next); err != nil { - t.Fatalf("an active effort refused a new version: %v", err) - } - e := mustGet(t, w.k.Store, key) - if e.Pending == nil || e.Current.Section("01-findings.md") != "" { - t.Fatalf("the new version was served before its review") - } - w.reviewAll(t) - q := w.judge.reviews[reviews] - if strings.Contains(q, "a correction of fact (a progressive insight)") { - t.Fatalf("the review judged a new version as an insight:\n%.400s", q) - } - e = mustGet(t, w.k.Store, key) - if e.Pending != nil || e.Current.Status != StatusActive || e.Current.Section("01-findings.md") != "A new finding." || - len(e.Current.Insights) != 0 { - t.Fatalf("after the review: %s, pending %v, insights %v", e.Current.Status, e.Pending != nil, e.Current.Insights) - } - h, err := w.k.History(key, 0, 0, 0) - if err != nil { - t.Fatal(err) - } - for _, v := range h.Versions { - if v.Insight { - t.Errorf("version %d is marked an insight", v.N) - } + findings := Section{Name: "01-findings.md", Text: "A new finding."} + became := []LinkAsk{{Kind: LinkTouches, To: "0290"}, {Kind: LinkBecame, To: "0290"}} + for _, c := range []struct { + name string + next func(n int) ProposeAsk + refused string // the rule a refusal names; empty when the version is taken + status string + docs []Section + became bool + }{ + {"a new document", func(n int) ProposeAsk { return effortAsk(n, StatusActive, overview, findings) }, + "", StatusActive, []Section{overview, findings}, false}, + {"closed with its sections left out", func(n int) ProposeAsk { + return ProposeAsk{Kind: KindResearch, Number: n, Status: StatusGraduated, Links: became} + }, "", StatusGraduated, []Section{overview}, true}, + {"abandoned with its sections left out", func(n int) ProposeAsk { + return ProposeAsk{Kind: KindResearch, Number: n, Status: StatusAbandoned, Links: effortAsk(n, "").Links} + }, "", StatusAbandoned, []Section{overview}, false}, + {"closed with its sections left out and no became link", func(n int) ProposeAsk { + return ProposeAsk{Kind: KindResearch, Number: n, Status: StatusGraduated, Links: effortAsk(n, "").Links} + }, "became", "", nil, false}, + } { + t.Run(c.name, func(t *testing.T) { + eachStore(t, func(t *testing.T, s Store) { + w := imported(t, s) + r, err := w.k.ReserveNumber("author@g14", KindResearch, "an effort") + if err != nil { + t.Fatal(err) + } + first := effortAsk(r.Number, StatusActive, overview) + w.accepted(t, "author@g14", first) + key := Key{KindResearch, r.Number} + reviews, versions := len(w.judge.reviews), mustGet(t, w.k.Store, key).Versions + next := c.next(r.Number) + next.Why = c.name + _, err = w.k.Propose("author@g14", next) + if c.refused != "" { + if err == nil || !strings.Contains(err.Error(), c.refused) { + t.Fatalf("want a refusal naming %s, got %v", c.refused, err) + } + if e := mustGet(t, w.k.Store, key); e.Pending != nil || e.Versions != versions { + t.Fatalf("a refused version was written: pending %v, %d versions", e.Pending != nil, e.Versions) + } + return + } + if err != nil { + t.Fatalf("an active effort refused a new version: %v", err) + } + e := mustGet(t, w.k.Store, key) + if e.Pending == nil || e.Current.Status != StatusActive || e.Current.Section("01-findings.md") != "" { + t.Fatalf("the new version was served before its review") + } + w.reviewAll(t) + asked := strings.Join(w.judge.reviews[reviews:], "\n") + if strings.Contains(asked, "a correction of fact (a progressive insight)") { + t.Fatalf("the review judged a new version as an insight:\n%.400s", asked) + } + // The overview was judged with the first version and is unchanged: its verdict is kept, and only the head + // and what is new are judged (to-be 55 §5). + if strings.Contains(asked, overview.Text) || len(w.judge.reviews)-reviews < 1 || len(w.judge.reviews)-reviews > 2 { + t.Fatalf("the kept section was judged again, or nothing was:\n%.400s", asked) + } + e = mustGet(t, w.k.Store, key) + cur := e.Current + if e.Pending != nil || cur.Status != c.status || len(cur.Insights) != 0 || !reflect.DeepEqual(cur.Sections, c.docs) { + t.Fatalf("after the review: %s, pending %v, insights %v, sections %+v", cur.Status, e.Pending != nil, cur.Insights, cur.Sections) + } + if cur.Title != first.Title || cur.Date != first.Date || !reflect.DeepEqual(cur.Deciders, first.Deciders) { + t.Fatalf("title, date or deciders not kept: %q %s %v", cur.Title, cur.Date, cur.Deciders) + } + if got := hasLink(cur, Link{Kind: LinkBecame, To: Ref{Kind: KindDecision, Number: 290}}); got != c.became { + t.Fatalf("became link %v, want %v: %+v", got, c.became, cur.Links) + } + h, err := w.k.History(key, 0, 0, 0) + if err != nil { + t.Fatal(err) + } + for _, v := range h.Versions { + if v.Insight { + t.Errorf("version %d is marked an insight", v.N) + } + } + }) + }) } } @@ -76,6 +128,25 @@ func TestAGraduatedEffortAndADecisionStillTakeOnlyAnInsight(t *testing.T) { if _, err := w.k.Propose("author@g14", p); err == nil || !strings.Contains(err.Error(), "progressive insight") { t.Fatalf("a decision took a plain version: %v", err) } + // Issue 1966: only a research effort's new version keeps the served sections when it leaves them out. A + // decision's insight, and an effort with no served version, still need their text. + m, err := w.k.ReserveNumber("author@g14", KindResearch, "another effort") + if err != nil { + t.Fatal(err) + } + for _, c := range []struct { + name, rule string + ask ProposeAsk + }{ + {"a decision's insight with no sections", "context", ProposeAsk{Kind: KindDecision, Number: n, Title: p.Title, Topic: p.Topic, + Deciders: p.Deciders, Clauses: p.Clauses, Links: p.Links, Homes: p.Homes, Insight: "the count was 3"}}, + {"an effort with no served version and no sections", "overview", ProposeAsk{Kind: KindResearch, Number: m.Number, + Title: "An effort", Deciders: []string{"jochen"}, Status: StatusActive}}, + } { + if _, err := w.k.Propose("author@g14", c.ask); err == nil || !strings.Contains(err.Error(), c.rule) { + t.Errorf("%s: want a refusal naming %s, got %v", c.name, c.rule, err) + } + } } // An old active effort's new text is held to the rules as of the day it is proposed, not the day the effort began. diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/bus_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/bus_test.go index 03c87498..48ce0a35 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/bus_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/bus_test.go @@ -155,7 +155,7 @@ func (w *busWorld) start(t *testing.T) (*bundle, string) { _ = os.WriteFile(settings, []byte(`{"judge-model": "sonnet", "drafting": "off"}`), 0o600) status := filepath.Join(dir, "health.json") cmd := exec.Command(os.Args[0], "-test.run=^$") - cmd.Env = append(os.Environ(), bundleVar+"=1", "MESH_KNOWLEDGE_SETTINGS="+settings, "MESH_KNOWLEDGE_STATUS="+status, + cmd.Env = append(os.Environ(), bundleVar+"=1", "MESH_KNOWLEDGE_PAGE=off", "MESH_KNOWLEDGE_SETTINGS="+settings, "MESH_KNOWLEDGE_STATUS="+status, "MESH_SERVED_MODULE="+Module, "MESH_MODULE="+Module, "MESH_NODE=test") in, _ := cmd.StdinPipe() out, _ := cmd.StdoutPipe() diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/form.go b/modules/mesh-knowledge/cmd/mesh-knowledge/form.go new file mode 100644 index 00000000..cd8be6f8 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/form.go @@ -0,0 +1,262 @@ +package main + +// A form-only version (novox/hq to-be 55 §2.2, ADR 0592 §31): the records imported with file-named sections get plain titles +// in one version per record, written after the move by the module's tool alone. It changes only the sections' names and the +// targets of links to files, each becoming a reference of the one form. It is marked `form`, lists the names it changed, +// and is accepted without a judgement because a check proves only form changed: each section's text with every link replaced +// by its label is the same before and after, and each new link target names the record the old one named. `get` takes the +// old section names as aliases, recorded in the version. A part's review carries over to the part renamed. + +import ( + "context" + "fmt" + "net/url" + "regexp" + "sort" + "strings" + "unicode" +) + +// FormTool is the module's tool that lists, dry-runs and runs the form-only versions. +const FormTool = "knowledge_form_only" + +var ( + fileLink = regexp.MustCompile(`\[([^\]]*)\]\(([^)\s]+)\)`) + issueTarget = regexp.MustCompile(`04-ISSUES/(\d{3,5})-`) +) + +// refTarget is the target a reference is written as in a link: the scheme ref: and the reference of the one form. +func refTarget(ref string) string { return "ref:" + url.PathEscape(ref) } + +// targetKey is the record a link's target names, in the old form (a file) or the new (a ref:). +func targetRef(target string) (string, bool) { + if rest, ok := strings.CutPrefix(target, "ref:"); ok { + s, err := url.PathUnescape(rest) + return s, err == nil + } + switch { + case researchFolder.MatchString(target): + return fmt.Sprintf("research %03d", atoi(researchFolder.FindStringSubmatch(target)[1])), true + case issueTarget.MatchString(target): + return fmt.Sprintf("issue %d", atoi(issueTarget.FindStringSubmatch(target)[1])), true + case designPath.MatchString(target): + return "design " + designPath.FindString(target), true + case adrFile.MatchString(target): + return fmt.Sprintf("ADR %04d", atoi(adrFile.FindStringSubmatch(target)[1])), true + } + return "", false +} + +// titleOf is a plain title for a file-named section: "00-overview.md" is "Overview", "01-open-questions.md" "Open questions". +func titleOf(name string) string { + t := name + if i := strings.LastIndexByte(t, '/'); i >= 0 { + t = t[i+1:] + } + t = fileExtension.ReplaceAllString(t, "") + t = numberPrefix.ReplaceAllString(t, "") + t = strings.NewReplacer("-", " ", "_", " ").Replace(strings.TrimSpace(t)) + if t == "" { + return name + } + r := []rune(t) + r[0] = unicode.ToUpper(r[0]) + return string(r) +} + +// FormChange is what a form-only version changes. +type FormChange struct { + Renamed map[string]string `json:"renamed,omitempty"` + Relinked int `json:"relinked"` +} + +// formOf is the record with plain titles and links of the one form, and what it changed; none when it is already so. +func formOf(r *Record) (*Record, *FormChange) { + n := r.Clone() + ch := &FormChange{Renamed: map[string]string{}} + taken := map[string]bool{} + for _, s := range n.Sections { + if len(sectionNameProblems(s.Name)) == 0 { + taken[s.Name] = true + } + } + for i, s := range n.Sections { + if len(sectionNameProblems(s.Name)) > 0 { + t := titleOf(s.Name) + for base, k := t, 2; taken[t]; k++ { + t = fmt.Sprintf("%s %d", base, k) + } + taken[t] = true + ch.Renamed[s.Name] = t + n.Sections[i].Name = t + } + n.Sections[i].Text = fileLink.ReplaceAllStringFunc(n.Sections[i].Text, func(link string) string { + m := fileLink.FindStringSubmatch(link) + if strings.HasPrefix(m[2], "ref:") || !linkToFile(m[2]) { + return link + } + ref, ok := targetRef(m[2]) + if !ok { + return link + } + ch.Relinked++ + return "[" + m[1] + "](" + refTarget(ref) + ")" + }) + } + if len(ch.Renamed) == 0 && ch.Relinked == 0 { + return r, nil + } + if len(ch.Renamed) > 0 { + if n.Aliases == nil { + n.Aliases = map[string]string{} + } + for old, t := range ch.Renamed { + n.Aliases[old] = t + } + } + return n, ch +} + +// checkForm proves that only form changed between two versions: the fields and clauses and links are the same, each +// section's text with every link replaced by its label is the same, and each link's new target names what the old one did. +func checkForm(old, nw *Record, ch *FormChange) error { + if len(old.Sections) != len(nw.Sections) { + return fmt.Errorf("the number of sections changed") + } + strip := func(r *Record) string { + c := r.Clone() + c.Sections, c.Aliases, c.Parts, c.FormOnly = nil, nil, nil, false + return Render(c) + } + if strip(old) != strip(nw) { + return fmt.Errorf("a field, a clause or a link changed") + } + for i := range old.Sections { + label := func(s string) string { return fileLink.ReplaceAllString(s, "$1") } + if label(old.Sections[i].Text) != label(nw.Sections[i].Text) { + return fmt.Errorf("the text of %q changed beyond its links", old.Sections[i].Name) + } + o, n := fileLink.FindAllStringSubmatch(old.Sections[i].Text, -1), fileLink.FindAllStringSubmatch(nw.Sections[i].Text, -1) + for j := range o { + ro, ok1 := targetRef(o[j][2]) + rn, ok2 := targetRef(n[j][2]) + if ok1 && o[j][2] != n[j][2] && (!ok2 || ro != rn) { + return fmt.Errorf("the link %q of %q names %s, and its new target %s", o[j][1], old.Sections[i].Name, ro, rn) + } + } + } + if len(ch.Renamed) == 0 && ch.Relinked == 0 { + return fmt.Errorf("nothing changed") + } + return nil +} + +// FormedRecord is one record a form-only version was written (or would be) for. +type FormedRecord struct { + Record string `json:"record"` + Renamed map[string]string `json:"renamed,omitempty"` + Relinked int `json:"relinked,omitempty"` + Skipped string `json:"skipped,omitempty"` +} + +// FormOnly lists (dry), or writes (commit), the form-only version of one record or of every record that needs one. A +// version is written only when the check proves form alone changed; a record with a version waiting for its review is +// skipped, saying so. It answers what it did, record by record. +func (k *KB) FormOnly(caller string, only *Key, commit bool) ([]FormedRecord, error) { + if _, err := k.ledger(); err != nil { + return nil, err + } + if commit && !proven(caller) { + return nil, fmt.Errorf("a form-only version needs a caller the runtime proved; this call is by %s", caller) + } + k.mu.Lock() + defer k.mu.Unlock() + ctx := k.ctxFor(caller) + all, err := k.Store.All(ctx) + if err != nil { + return nil, err + } + var out []FormedRecord + for _, e := range all { + key := e.Current.Key() + if only != nil && *only != key { + continue + } + next, ch := formOf(e.Current) + if ch == nil { + continue + } + fr := FormedRecord{Record: key.String(), Renamed: ch.Renamed, Relinked: ch.Relinked} + switch { + case e.Pending != nil: + fr.Skipped = "a version waits for its review: it takes the form-only version once that is decided" + case e.Current.Status == StatusReserved || e.Current.Status == StatusWithdrawn: + fr.Skipped = "it is " + e.Current.Status + default: + if err := checkForm(e.Current, next, ch); err != nil { + fr.Skipped = "the check refused it: " + err.Error() + } else if commit { + k.carryParts(next, ch) + next.FormOnly = true + names := make([]string, 0, len(ch.Renamed)) + for old, t := range ch.Renamed { + names = append(names, old+" → "+t) + } + sort.Strings(names) + why := fmt.Sprintf("form only: %d section name(s), %d link(s) written in the one form", len(ch.Renamed), ch.Relinked) + if len(names) > 0 { + why += " (" + strings.Join(names, "; ") + ")" + } + if err := k.Store.Save(ctx, Version{By: caller, At: k.now(), Why: why, Record: next}, true, false, e.Revision); err != nil { + fr.Skipped = "it could not be written: " + err.Error() + } else { + next.FormOnly = false + k.emit(EventFormed, next, caller, why, e.Versions+1) + } + } + } + out = append(out, fr) + } + return out, nil +} + +// carryParts moves a record's part verdicts to the parts renamed, with the digest of the new part, so the review carries +// over to a version whose meaning is unchanged. +func (k *KB) carryParts(r *Record, ch *FormChange) { + if len(r.Parts) == 0 { + return + } + linked := k.linkedOf(context.Background(), r) + fresh := map[string]string{} + for _, p := range partsOf(r, linked) { + fresh[p.name] = p.digest + } + var out []PartReview + for _, p := range r.Parts { + if t, ok := ch.Renamed[p.Part]; ok { + p.Part = t + } + if d, ok := fresh[p.Part]; ok { + p.Digest = d + out = append(out, p) + } + } + r.Parts = out +} + +// linkedOf are the records r links, as the review reads them. +func (k *KB) linkedOf(ctx context.Context, r *Record) []*Record { + var linked []*Record + seen := map[Key]bool{r.Key(): true} + for _, l := range r.Links { + to, ok := l.To.Record() + if !ok || seen[to] { + continue + } + seen[to] = true + if t, err := k.Store.Get(ctx, to); err == nil && t != nil { + linked = append(linked, t.Current) + } + } + return linked +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/form_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/form_test.go new file mode 100644 index 00000000..b92ac2d5 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/form_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "strings" + "testing" +) + +// A form-only version (to-be 55 §2.2, AC55): file-named sections get plain titles and links to files become references of +// the one form, in one version a check proves changes only form; the old names stay as aliases; a part's review carries over; +// nothing is written without commit, and a record waiting for its review is skipped. +func TestAFormOnlyVersionChangesTheNamesAndTheLinksAndNothingElse(t *testing.T) { + w, _ := ledgerWorld(t) + r, _ := w.k.ReserveNumber("author@g14", KindResearch, "an effort") + old := effortAsk(r.Number, StatusActive, + Section{Name: "00-overview.md", Text: "What it is. See [the window](0276-merges-are-assembled-in-a-rolling-window-and-each-batch-is-delivered-by-one-walk-with-one-commit-per-repository.md) and [the plan](https://example.org/plan.md)."}, + Section{Name: "01-open-questions.md", Text: "Is it [ready](../../01-RESEARCH/045-a-knowledge-base-the-mesh-writes/01-findings.md)?"}) + w.accepted(t, "author@g14", old) + key := Key{KindResearch, r.Number} + before := mustGet(t, w.k.Store, key) + w.k.Store.(*ledgerStore).mu.Lock() + versions := before.Versions + w.k.Store.(*ledgerStore).mu.Unlock() + + dry, err := w.k.FormOnly("lead@g14", &key, false) + if err != nil || len(dry) != 1 || dry[0].Renamed["00-overview.md"] != "Overview" || dry[0].Renamed["01-open-questions.md"] != "Open questions" || dry[0].Relinked != 2 { + t.Fatalf("%v %+v", err, dry) + } + if e := mustGet(t, w.k.Store, key); e.Versions != versions { + t.Fatal("a dry run wrote a version") + } + if _, err := w.k.FormOnly("", &key, true); err == nil { + t.Fatal("an unproven caller wrote a form-only version") + } + done, err := w.k.FormOnly("lead@g14", &key, true) + if err != nil || len(done) != 1 || done[0].Skipped != "" { + t.Fatalf("%v %+v", err, done) + } + e := mustGet(t, w.k.Store, key) + if e.Versions != versions+1 || e.Current.Status != StatusActive || e.Pending != nil || e.Current.Judging != nil { + t.Fatalf("%d versions, %s", e.Versions, e.Current.Status) + } + got := e.Current + if got.Sections[0].Name != "Overview" || got.Sections[1].Name != "Open questions" || + !strings.Contains(got.Sections[0].Text, "[the window](ref:ADR%200276)") || !strings.Contains(got.Sections[0].Text, "(https://example.org/plan.md)") || + !strings.Contains(got.Sections[1].Text, "[ready](ref:research%20045)") { + t.Fatalf("%+v", got.Sections) + } + // get takes the old name as an alias, and the review of the parts carries over to the renamed parts. + if got.Section("00-overview.md") != got.Sections[0].Text || got.Aliases["01-open-questions.md"] != "Open questions" { + t.Fatalf("aliases %v", got.Aliases) + } + if len(got.Parts) != 3 || got.Parts[1].Part != "Overview" { + t.Fatalf("parts %+v", got.Parts) + } + for _, p := range partsOf(got, w.k.linkedOf(t.Context(), got)) { + if keptPart(got, p) == nil { + t.Errorf("the part %q is judged again after a change of form", p.name) + } + } + h, _ := w.k.History(key, 0, 0, 0) + if last := h.Versions[len(h.Versions)-1]; !strings.HasPrefix(last.Why, "form only:") { + t.Fatalf("%+v", last) + } + if len(w.said.of(EventFormed)) != 1 { + t.Fatalf("events %v", w.said.events) + } + // Done: another run has nothing left to do; and the check refuses a change that is more than form. + if again, _ := w.k.FormOnly("lead@g14", &key, true); len(again) != 0 { + t.Fatalf("%+v", again) + } + more := e.Current.Clone() + more.Sections[0].Text += " And more." + if err := checkForm(e.Current, more, &FormChange{Relinked: 1}); err == nil { + t.Fatal("a change of text passed as form") + } + moreLinks := old + _ = moreLinks + wrong, ch := formOf(&Record{Kind: KindResearch, Number: 1, Sections: []Section{{Name: "a.md", Text: "[x](0276-a.md)"}}}) + wrong.Sections[0].Text = "[x](ref:ADR%200131)" + if err := checkForm(&Record{Kind: KindResearch, Number: 1, Sections: []Section{{Name: "a.md", Text: "[x](0276-a.md)"}}}, wrong, ch); err == nil { + t.Fatal("a link to another record passed as form") + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/health.go b/modules/mesh-knowledge/cmd/mesh-knowledge/health.go new file mode 100644 index 00000000..7cb0c44a --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/health.go @@ -0,0 +1,235 @@ +package main + +// The module's health once its records are on the ledger (novox/hq to-be 55 §1.1, ADR 0592 §33 changed by ADR 0597 §12): +// unhealthy, naming the cause, when a kept thing does not answer, when the search index is behind the ledger, when a head lags +// behind its history, and when the stream of the verdicts is at its cap. It names where the records are served from and the +// result of the move's last check, and counts the drafts idle more than a week, the records the form-only check refused and the +// parts waiting more than 30 minutes; such a part is asked of the operator once, and the ask clears when it is judged. A record +// waiting for its review is not the module failing, so those counts leave it healthy. + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/buscalls" +) + +// PartWaits is how long a part may wait on the judge before the operator is asked once. +const PartWaits = 30 * time.Minute + +// ledgerHealth adds what the ledger form owes to the verdict of the watch. +func (k *KB) ledgerHealth(base HealthAnswer) HealthAnswer { + ms, _ := k.Store.(*movableStore) + l, err := k.ledger() + if err != nil { + if ms != nil { + base.Why += "; the records are served from the " + SourceDatabase + } + return base + } + var broken, notes []string + notes = append(notes, "the records are served from the ledger") + // A kept thing that does not answer, and a head behind its history. + var lag buscalls.Lagged + if err := l.pipe.Call(buscalls.LedgerLag, buscalls.LedgerRequest{Ledger: LedgerName}, &lag); err != nil { + broken = append(broken, "the ledger does not answer: "+err.Error()) + } else if len(lag.Instances) > 0 { + names := make([]string, 0, 3) + for i, in := range lag.Instances { + if i == 3 { + break + } + names = append(names, fmt.Sprintf("%s (state at %d of %d)", in.Instance, in.State, in.History)) + } + broken = append(broken, fmt.Sprintf("%d head(s) are behind their history: %s", len(lag.Instances), strings.Join(names, ", "))) + } + // The index is behind the ledger when it holds fewer records than the ledger does. + st := l.IndexState() + l.mu.Lock() + heads, indexed := len(l.heads), 0 + if l.index != nil { + indexed = len(l.index.docsOf) + } + l.mu.Unlock() + switch { + case !st.Built: + notes = append(notes, "the search index is being built") + case heads > 0 && indexed < heads-reservedCount(l): + broken = append(broken, fmt.Sprintf("the search index holds %d of %d records: it is behind the ledger", indexed, heads)) + default: + notes = append(notes, fmt.Sprintf("the search index holds %d documents", st.Docs)) + } + // The stream of the verdicts at its cap. + var fill buscalls.StreamFilled + if err := l.pipe.Call(buscalls.StreamFill, buscalls.StreamRequest{Stream: "reviews"}, &fill); err != nil { + broken = append(broken, "the stream of the verdicts does not answer: "+err.Error()) + } else if fill.MaxBytes > 0 && fill.Bytes >= fill.MaxBytes { + broken = append(broken, fmt.Sprintf("the stream of the verdicts is at its cap of %d bytes", fill.MaxBytes)) + } + // The move's last check. + if kv := k.States; kv != nil { + if e, err := kv.State(findingsState).Get(checkKey); err == nil && e != nil { + var c struct { + Differences []string `json:"differences"` + At time.Time `json:"at"` + } + if json.Unmarshal(e.Value, &c) == nil { + if len(c.Differences) == 0 { + notes = append(notes, "the last check of the database against the ledger was clean ("+c.At.Local().Format("2006-01-02 15:04")+")") + } else { + notes = append(notes, fmt.Sprintf("the last check of the database against the ledger found %d difference(s)", len(c.Differences))) + } + } + } + } + // What waits on a person, counted and left healthy. + idle, waiting := k.waitingCounts() + if idle > 0 { + notes = append(notes, fmt.Sprintf("%d draft(s) untouched for more than a week", idle)) + } + if waiting > 0 { + notes = append(notes, fmt.Sprintf("%d part(s) waiting on the judge for more than 30 minutes", waiting)) + } + if refused := k.formRefused(); refused > 0 { + notes = append(notes, fmt.Sprintf("%d record(s) the form-only check refused", refused)) + } + if len(broken) > 0 { + base.State, base.Healthy = "unhealthy", false + base.Why = strings.Join(broken, "; ") + "; " + strings.Join(notes, "; ") + return base + } + base.Why += "; " + strings.Join(notes, "; ") + return base +} + +func reservedCount(l *ledgerStore) int { + n := 0 + for _, h := range l.heads { + if st := h.Entry.Current.Status; st == StatusReserved || st == StatusWithdrawn || st == StatusRetracted { + n++ + } + } + return n +} + +// waitingCounts are the drafts idle more than DraftIdle and the parts waiting more than PartWaits; a part that waits is +// asked of the operator once, and the ask is cleared when it is judged (the mark leaves `asked`). +func (k *KB) waitingCounts() (idle, waiting int) { + if k.States == nil { + return 0, 0 + } + now := k.now() + if keys, err := k.States.State(draftsState).Keys(); err == nil { + for _, key := range keys { + if e, err := k.States.State(draftsState).Get(key); err == nil && e != nil { + var d Draft + if json.Unmarshal(e.Value, &d) == nil && now.Sub(d.Touched) > DraftIdle { + idle++ + } + } + } + } + live := map[string]bool{} + if keys, err := k.States.State(askedState).Keys(); err == nil { + for _, key := range keys { + e, err := k.States.State(askedState).Get(key) + if err != nil || e == nil { + continue + } + var a struct { + Part string `json:"part"` + Record string `json:"record"` + At time.Time `json:"at"` + } + if json.Unmarshal(e.Value, &a) != nil || now.Sub(a.At) <= PartWaits { + continue + } + waiting++ + live[key] = true + k.askPartWaits(key, a.Record, a.Part) + } + } + k.clearPartWaits(live) + return idle, waiting +} + +const partWaitPrefix = "part-wait." + +// askPartWaits asks the operator once, through the operator channel, about a part waiting more than 30 minutes. +func (k *KB) askPartWaits(key, record, part string) { + if k.Notify == nil || k.Store == nil { + return + } + ctx := context.Background() + if first, err := k.Store.Told(ctx, partWaitPrefix+key); err != nil || !first { + return + } + if err := k.Notify("knowledge."+partWaitPrefix+key, fmt.Sprintf("The review of %s waits on the judge: its part %q has waited more than 30 minutes.", record, part), + "A review is waiting on the judge: "+record, "The knowledge base asked the judge for a part of this record and no answer has come. It asks again by itself; the tool knowledge_asked lists what is in flight.", + record+" is judged again"); err != nil { + _ = k.Store.Untold(ctx, partWaitPrefix+key) + } +} + +// clearPartWaits clears the ask of every part that is no longer waiting. +func (k *KB) clearPartWaits(live map[string]bool) { + if k.Clear == nil || k.Store == nil { + return + } + ctx := context.Background() + told, err := k.Store.ToldOf(ctx, partWaitPrefix) + if err != nil { + return + } + for _, t := range told { + if live[strings.TrimPrefix(t, partWaitPrefix)] { + continue + } + if err := k.Clear("knowledge."+t, "its part was judged"); err == nil { + _ = k.Store.Untold(ctx, t) + } + } +} + +// formRefused counts the records a form-only version would be refused for. +func (k *KB) formRefused() int { + done, err := k.FormOnly("", nil, false) + if err != nil { + return 0 + } + n := 0 + for _, d := range done { + if strings.HasPrefix(d.Skipped, "the check refused") { + n++ + } + } + return n +} + +// pageHealth adds the overview page to a verdict: the last rebuild and each source's last answer, and the module is said degraded +// while any source is unanswered. The module stays healthy: a source that does not answer is that module's fault, not this one's. +func (k *KB) pageHealth(base HealthAnswer) HealthAnswer { + if k.Overview == nil { + return base + } + k.Overview.mu.Lock() + rebuilt := k.Overview.rebuilt + k.Overview.mu.Unlock() + if bad := k.Overview.Unanswered(); len(bad) > 0 { + var says []string + for _, s := range bad { + why := "no answer yet" + if s.Mark != nil { + why = s.Mark.Cause + ": " + s.Mark.Why + } + says = append(says, fmt.Sprintf("%s (%s; last answer %s)", s.Name, why, s.Last.Local().Format("15:04"))) + } + base.Why += "; the overview page is degraded, it does not answer: " + strings.Join(says, ", ") + return base + } + base.Why += "; the overview page was last rebuilt " + rebuilt.Local().Format("2006-01-02 15:04") + return base +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/health_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/health_test.go new file mode 100644 index 00000000..546e4388 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/health_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/buscalls" +) + +// The health on the ledger (to-be 55 §1.1, AC56): it names where the records are served from and the move's last check, is +// unhealthy for a ledger or a stream that does not answer, a head behind its history, an index behind the ledger and a stream +// at its cap, and counts what waits on a person (an idle draft, a part waiting on the judge, a form-only check refused) without +// failing; a part waiting more than 30 minutes is asked of the operator once, and the ask clears when it is judged. +func TestTheHealthOnTheLedgerNamesTheSourceAndFailsForWhatTheLedgerOwes(t *testing.T) { + w, f := ledgerWorld(t) + ls := w.k.Store.(*ledgerStore) + if err := ls.BuildIndex(t.Context()); err != nil { + t.Fatal(err) + } + f.fill = buscalls.StreamFilled{Fill: buscalls.Fill{Count: 3, Bytes: 100, MaxBytes: 512 << 20}} + base := HealthAnswer{State: "healthy", Healthy: true, Why: "the database serves"} + ans := w.k.ledgerHealth(base) + if !ans.Healthy || !strings.Contains(ans.Why, "served from the ledger") || !strings.Contains(ans.Why, "search index holds") { + t.Fatalf("%+v", ans) + } + // Each thing the ledger owes, one at a time. + for name, c := range map[string]struct { + set func() + want string + }{ + "a head behind": {func() { + f.lag = buscalls.Lagged{Instances: []buscalls.InstanceLag{{Instance: "decision/0290", History: 9, State: 7}}} + }, "behind their history"}, + "a stream at a cap": {func() { f.fill.Bytes = f.fill.MaxBytes }, "at its cap"}, + "a stream that does not answer": {func() { f.noFill = true }, "does not answer"}, + } { + f.lag, f.noFill = buscalls.Lagged{}, false + f.fill = buscalls.StreamFilled{Fill: buscalls.Fill{Bytes: 100, MaxBytes: 512 << 20}} + c.set() + got := w.k.ledgerHealth(base) + if got.Healthy || !strings.Contains(got.Why, c.want) { + t.Errorf("%s: %+v", name, got) + } + } + f.lag, f.noFill, f.fill = buscalls.Lagged{}, false, buscalls.StreamFilled{} + // An index behind the ledger: a record the ledger holds and the index does not. + ls.index.mu.Lock() + ls.index.docsOf = map[Key][]string{} + ls.index.mu.Unlock() + if got := w.k.ledgerHealth(base); got.Healthy || !strings.Contains(got.Why, "behind the ledger") { + t.Errorf("an index behind: %+v", got) + } + _ = ls.BuildIndex(t.Context()) + // What waits on a person is counted and leaves it healthy; a part waiting more than 30 minutes is asked once. + w.k.States.State(askedState).Put("decision.0300.abc", map[string]any{"part": "Context", "record": "ADR 0300", "at": w.clock.Add(-time.Hour)}) + w.k.States.State(draftsState).Put("decision.0301", Draft{Kind: KindDecision, Number: 301, Author: "a@n", Touched: w.clock.AddDate(0, 0, -8)}) + got := w.k.ledgerHealth(base) + if !got.Healthy || !strings.Contains(got.Why, "1 part(s) waiting") || !strings.Contains(got.Why, "1 draft(s) untouched") { + t.Fatalf("%+v", got) + } + w.k.ledgerHealth(base) + if len(w.told) != 1 { + t.Fatalf("the operator was asked %d times", len(w.told)) + } + w.k.States.State(askedState).Delete("decision.0300.abc") + w.k.ledgerHealth(base) + if len(w.cleared) != 1 { + t.Fatalf("the ask was not cleared: %v", w.cleared) + } +} + +// The records served from the database say so, and fail nothing. +func TestTheHealthOnTheDatabaseSaysSo(t *testing.T) { + db := newMemStore() + states := newFakeStates() + m := newMovableStore(db, newLedgerStore(newFakeLedger(t), states), states) + k := &KB{Store: m, States: states} + ans := k.ledgerHealth(HealthAnswer{State: "healthy", Healthy: true, Why: "the database serves"}) + if !ans.Healthy || !strings.Contains(ans.Why, "served from the database") { + t.Fatalf("%+v", ans) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/index.go b/modules/mesh-knowledge/cmd/mesh-knowledge/index.go new file mode 100644 index 00000000..a679712f --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/index.go @@ -0,0 +1,221 @@ +package main + +// The search index (novox/hq to-be 55 §1, ADR 0597 §12): a full-text index in the module's own memory, written with the +// pure Go library bleve. At start the module reads every head from the ledger and the bodies of every current version into +// it, then keeps it current as it writes; `ask` and `holds` answer from it, and refuse with "index not built" while it is +// built. Nothing but the module writes it and nothing outside it reads it. + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/blevesearch/bleve/v2" + "github.com/blevesearch/bleve/v2/analysis/lang/en" + "github.com/blevesearch/bleve/v2/mapping" + "github.com/blevesearch/bleve/v2/search/query" +) + +// ErrIndexNotBuilt is the answer of a question asked while the index is built. +var ErrIndexNotBuilt = errors.New("index not built: the search index is being built from the ledger; ask again in a moment (get, list and propose answer at once)") + +// searchIndex is the index and what it holds. +type searchIndex struct { + mu sync.Mutex + idx bleve.Index + built bool + builtAt time.Time + docs int + // docsOf are the documents each record has in the index, so a new version replaces them. + docsOf map[Key][]string +} + +func newSearchIndex() (*searchIndex, error) { + m := bleve.NewIndexMapping() + text := mapping.NewTextFieldMapping() + text.Analyzer = en.AnalyzerName + doc := bleve.NewDocumentMapping() + for _, f := range []string{"title", "topic", "text", "clause"} { + doc.AddFieldMappingsAt(f, text) + } + m.DefaultMapping = doc + idx, err := bleve.NewMemOnly(m) + if err != nil { + return nil, err + } + return &searchIndex{idx: idx, docsOf: map[Key][]string{}}, nil +} + +type indexDoc struct { + Title string `json:"title,omitempty"` + Topic string `json:"topic,omitempty"` + Text string `json:"text,omitempty"` + Clause string `json:"clause,omitempty"` +} + +// put replaces the documents of a record by those of its served version: its title, topic and sections as one document, +// and each clause in force or proposed as one of its own. A reserved, withdrawn or retracted record has none. +func (s *searchIndex) put(r *Record) error { + s.mu.Lock() + defer s.mu.Unlock() + key := r.Key() + b := s.idx.NewBatch() + for _, id := range s.docsOf[key] { + b.Delete(id) + } + delete(s.docsOf, key) + if r.Status != StatusReserved && r.Status != StatusWithdrawn && r.Status != StatusRetracted { + var body strings.Builder + for _, sec := range r.Sections { + body.WriteString(sec.Text + "\n") + } + id := fmt.Sprintf("%s/%d", key.Kind, key.Number) + if err := b.Index(id, indexDoc{Title: r.Title, Topic: r.Topic, Text: body.String()}); err != nil { + return err + } + ids := []string{id} + for _, c := range r.Clauses { + if c.Status == ClauseRetracted { + continue + } + cid := fmt.Sprintf("%s#%d", id, c.N) + if err := b.Index(cid, indexDoc{Clause: c.Text}); err != nil { + return err + } + ids = append(ids, cid) + } + s.docsOf[key] = ids + } + s.docs = 0 + for _, ids := range s.docsOf { + s.docs += len(ids) + } + return s.idx.Batch(b) +} + +// search ranks the documents against the question's words: a title weighs twice, as the in-memory ranking did. +func (s *searchIndex) search(question string, limit int) ([]Hit, error) { + s.mu.Lock() + built := s.built + s.mu.Unlock() + if !built { + return nil, ErrIndexNotBuilt + } + var words []string + for _, w := range wordsOf(question) { + if !askingWords[w] && len(w) >= 3 { + words = append(words, w) + } + } + if len(words) == 0 { + return nil, nil + } + var should []query.Query + for _, w := range words { + for field, boost := range map[string]float64{"title": 2, "topic": 1, "text": 0.05, "clause": 1} { + q := bleve.NewMatchQuery(w) + q.SetField(field) + q.SetBoost(boost) + should = append(should, q) + } + } + req := bleve.NewSearchRequestOptions(bleve.NewDisjunctionQuery(should...), 5000, 0, false) + res, err := s.idx.Search(req) + if err != nil { + return nil, err + } + var hits []Hit + for _, h := range res.Hits { + var kind string + var number, clause int + rest := h.ID + if i := strings.IndexByte(rest, '#'); i >= 0 { + if _, err := fmt.Sscanf(rest[i+1:], "%d", &clause); err != nil { + continue + } + rest = rest[:i] + } + if i := strings.IndexByte(rest, '/'); i >= 0 { + kind = rest[:i] + if _, err := fmt.Sscanf(rest[i+1:], "%d", &number); err != nil { + continue + } + } + hits = append(hits, Hit{Key: Key{kind, number}, Clause: clause, Rank: h.Score}) + } + sort.SliceStable(hits, func(i, j int) bool { + if hits[i].Rank != hits[j].Rank { + return hits[i].Rank > hits[j].Rank + } + if hits[i].Key != hits[j].Key { + return hits[i].Key.Number > hits[j].Key.Number + } + return hits[i].Clause < hits[j].Clause + }) + if limit > 0 && len(hits) > limit { + hits = hits[:limit] + } + return hits, nil +} + +// IndexState says how the index stands: built or not, when, and how many documents it holds. +type IndexState struct { + Built bool `json:"built"` + BuiltAt time.Time `json:"built-at,omitempty"` + Docs int `json:"documents"` +} + +func (s *searchIndex) state() IndexState { + s.mu.Lock() + defer s.mu.Unlock() + return IndexState{Built: s.built, BuiltAt: s.builtAt, Docs: s.docs} +} + +// BuildIndex reads every record from the ledger into the index and marks it built; asked again it builds it anew. +func (s *ledgerStore) BuildIndex(ctx context.Context) error { + idx, err := newSearchIndex() + if err != nil { + return err + } + all, err := s.All(ctx) + if err != nil { + return err + } + for _, e := range all { + if err := idx.put(e.Current); err != nil { + return err + } + } + idx.mu.Lock() + idx.built, idx.builtAt = true, time.Now().UTC() + idx.mu.Unlock() + s.mu.Lock() + s.index = idx + s.mu.Unlock() + return nil +} + +// IndexState is how the module's index stands. +func (s *ledgerStore) IndexState() IndexState { + s.mu.Lock() + idx := s.index + s.mu.Unlock() + if idx == nil { + return IndexState{} + } + return idx.state() +} + +// indexed keeps the index current with a version the store wrote. +func (s *ledgerStore) indexed(r *Record, current bool) { + if s.index == nil || !current { + return + } + if err := s.index.put(r); err != nil && s.logf != nil { + s.logf("[mesh-knowledge] the search index could not take %s: %v", r.Key(), err) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/index_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/index_test.go new file mode 100644 index 00000000..48220eba --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/index_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "errors" + "testing" +) + +// The search index (to-be 55 §1, AC52): built from the ledger at start, refusing "index not built" meanwhile, kept current +// as the module writes, and answering the questions the knowledge base is asked as the in-memory ranking does: the records +// the questions are about come first. +func TestTheSearchIndexAnswersTheQuestionsAsTheStoreDoes(t *testing.T) { + f := newFakeLedger(t) + ls := newLedgerStore(f, newFakeStates()) + w := newWorld(t, ls, fixture()) + w.k.States = newFakeStates() + ls.indexAsync = true + if _, err := ls.Search(t.Context(), "merge window", 5); !errors.Is(err, ErrIndexNotBuilt) { + t.Fatalf("a question before the index was built: %v", err) + } + if _, err := w.k.Import("import@test", ImportAsk{}, fixture()); err != nil { + t.Fatal(err) + } + if err := ls.BuildIndex(t.Context()); err != nil { + t.Fatal(err) + } + if st := ls.IndexState(); !st.Built || st.Docs < 20 { + t.Fatalf("%+v", st) + } + mem := imported(t, newMemStore()) + for _, q := range []string{"merge window", "how is the tool runtime chosen", "what holds about resolved names", "a knowledge base the mesh writes"} { + want, _ := mem.k.Store.Search(t.Context(), q, 3) + got, err := ls.Search(t.Context(), q, 8) + if err != nil { + t.Fatal(err) + } + // The ranking differs (the index weighs by how rare a word is), so the record the store puts first is among the + // first few of the index's. + found := false + for _, h := range got { + found = found || (len(want) > 0 && h.Key == want[0].Key) + } + if len(want) > 0 && !found { + t.Errorf("%q: the store puts %v first, the index %v", q, want[0], got) + } + } + // A new version is in the index at once; a withdrawn record is out of it. + r, _ := w.k.ReserveNumber("author@g14", KindDecision, "a test record") + if hits, _ := ls.Search(t.Context(), "zebra quantum", 5); len(hits) != 0 { + t.Fatalf("%v", hits) + } + p := valid(r.Number) + p.Title = "Zebra quantum processors" + p.Sections[2].Text = "A zebra quantum processor." + if _, err := w.k.Propose("author@g14", p); err != nil { + t.Fatal(err) + } + if hits, _ := ls.Search(t.Context(), "zebra quantum", 5); len(hits) == 0 || hits[0].Key.Number != r.Number { + t.Fatalf("a proposed record is not in the index: %v", hits) + } + if _, err := w.k.WithdrawRecord("author@g14", Key{KindDecision, r.Number}, "not needed"); err != nil { + t.Fatal(err) + } + if hits, _ := ls.Search(t.Context(), "zebra quantum", 5); len(hits) != 0 { + t.Fatalf("a withdrawn record is in the index: %v", hits) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/judge_bound_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/judge_bound_test.go index e614a568..b18da80a 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/judge_bound_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/judge_bound_test.go @@ -68,7 +68,7 @@ func withinOf(t *testing.T, b map[string]any) time.Duration { func TestALongRecordsReviewGetsTheLargerBound(t *testing.T) { short, long := ReviewQuestion(aRecord(2, 200), nil), ReviewQuestion(aRecord(12, 2000), nil) a := &asked{} - j := judgementOver(a.ask, "novox") + j := judgementOver(a.ask, "novox", newJudgedHub()) for _, q := range []string{short, long} { if _, _, err := j(q, reviewSchema, "sonnet"); err != nil { t.Fatal(err) @@ -84,19 +84,38 @@ func TestALongRecordsReviewGetsTheLargerBound(t *testing.T) { } } -// A judgement answered pending is collected by its ticket from the node that holds it, and its answer is the -// judgement's. -func TestAPendingJudgementIsCollectedByItsTicket(t *testing.T) { - a := &asked{pending: 3} - answer, by, err := judgementOver(a.ask, "novox")(ReviewQuestion(aRecord(12, 2000), nil), reviewSchema, "sonnet") - if err != nil { - t.Fatal(err) +// A judgement answered pending waits for the agent module's word `judged` under its ticket, whether the word comes +// after the answer or before it, and asks nothing again; its error is the judgement's, and a word that never comes +// ends the wait in words. +func TestAPendingJudgementWaitsForTheWordOfItsTicket(t *testing.T) { + word := `{"ticket":"t1","node":"novox","model":"sonnet","answer":{"verdict":"approve","findings":[]}}` + for name, c := range map[string]struct{ before bool }{"after": {false}, "before": {true}} { + a := &asked{pending: 1} + hub := newJudgedHub() + if c.before { + hub.Heard(json.RawMessage(word)) + } else { + time.AfterFunc(50*time.Millisecond, func() { hub.Heard(json.RawMessage(word)) }) + } + answer, by, err := judgementOver(a.ask, "novox", hub)(ReviewQuestion(aRecord(12, 2000), nil), reviewSchema, "sonnet") + if err != nil { + t.Fatalf("%s: %v", name, err) + } + if string(answer) != `{"verdict":"approve","findings":[]}` || by != "the agent on novox (sonnet)" { + t.Fatalf("%s: %s by %s", name, answer, by) + } + if strings.Join(a.keys, " ") != "claude-code.claude_code_judge@novox" { + t.Fatalf("%s: asked %q", name, a.keys) + } } - if string(answer) != `{"verdict":"approve","findings":[]}` || by != "the agent on novox (sonnet)" { - t.Fatalf("%s by %s", answer, by) + hub := newJudgedHub() + hub.Heard(json.RawMessage(`{"ticket":"t1","node":"novox","error":"the agent on novox did not answer within 90s"}`)) + if _, _, err := judgementOver((&asked{pending: 1}).ask, "novox", hub)(ReviewQuestion(aRecord(12, 2000), nil), reviewSchema, "sonnet"); err == nil || + !strings.Contains(err.Error(), "did not answer within") { + t.Fatalf("the judgement's own failure: %v", err) } - if strings.Join(a.keys, " ") != "claude-code.claude_code_judge@novox claude-code.claude_code_judged@novox claude-code.claude_code_judged@novox claude-code.claude_code_judged@novox" { - t.Fatalf("asked %q", a.keys) + if _, err := newJudgedHub().Wait("novox", "t2", 20*time.Millisecond); err == nil || !strings.Contains(err.Error(), "t2") { + t.Fatalf("a word never said: %v", err) } } @@ -104,7 +123,7 @@ func TestAPendingJudgementIsCollectedByItsTicket(t *testing.T) { // it fits the old bound, and fails as before when it does not. func TestAnAgentModuleThatTakesNoWithinIsAskedWithoutIt(t *testing.T) { a := &asked{refuse: true} - if _, _, err := judgementOver(a.ask, "novox")(ReviewQuestion(aRecord(12, 2000), nil), reviewSchema, "sonnet"); err != nil { + if _, _, err := judgementOver(a.ask, "novox", newJudgedHub())(ReviewQuestion(aRecord(12, 2000), nil), reviewSchema, "sonnet"); err != nil { t.Fatal(err) } if len(a.bodies) != 2 || withinOf(t, a.bodies[1]) != 0 { diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/judged.go b/modules/mesh-knowledge/cmd/mesh-knowledge/judged.go new file mode 100644 index 00000000..891156a9 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/judged.go @@ -0,0 +1,94 @@ +package main + +// The agent module's word that a judgement ended (novox/hq ADR 0616 §5, issue 1956 note 8): a judgement asked with +// a bound past one bus call is answered pending with a ticket, and the module says `claude-code.judged` when the +// ticket's judgement ends. The knowledge base hears it here and the waiting review wakes; nothing asks the agent +// module again and again. + +import ( + "encoding/json" + "fmt" + "sync" + "time" +) + +// JudgedKept is how long a heard word is kept for a review that has not yet read its ticket (the word can arrive +// before the answer that names the ticket does). +const JudgedKept = 10 * time.Minute + +// judgedWord is the body of `claude-code.judged`. +type judgedWord struct { + Ticket string `json:"ticket"` + Node string `json:"node"` + Model string `json:"model"` + Answer json.RawMessage `json:"answer"` + Error string `json:"error"` +} + +type heardWord struct { + word judgedWord + at time.Time +} + +// judgedHub keeps the words heard, by node and ticket, and wakes whoever waits on one. +type judgedHub struct { + mu sync.Mutex + now func() time.Time + heard map[string]heardWord + waiting map[string][]chan struct{} +} + +func newJudgedHub() *judgedHub { + return &judgedHub{now: time.Now, heard: map[string]heardWord{}, waiting: map[string][]chan struct{}{}} +} + +func ticketKey(node, ticket string) string { return node + "/" + ticket } + +// Heard takes the body of one `judged` event. +func (h *judgedHub) Heard(body json.RawMessage) { + var w judgedWord + if json.Unmarshal(body, &w) != nil || w.Ticket == "" { + return + } + k := ticketKey(w.Node, w.Ticket) + h.mu.Lock() + now := h.now() + for key, o := range h.heard { + if now.Sub(o.at) > JudgedKept { + delete(h.heard, key) + } + } + h.heard[k] = heardWord{w, now} + waiters := h.waiting[k] + delete(h.waiting, k) + h.mu.Unlock() + for _, c := range waiters { + close(c) + } +} + +// Wait is the word for a ticket, waited for up to d: it is read once, and a ticket nobody said is an error that +// names the ticket. +func (h *judgedHub) Wait(node, ticket string, d time.Duration) (judgedWord, error) { + k := ticketKey(node, ticket) + h.mu.Lock() + if o, ok := h.heard[k]; ok { + delete(h.heard, k) + h.mu.Unlock() + return o.word, nil + } + c := make(chan struct{}) + h.waiting[k] = append(h.waiting[k], c) + h.mu.Unlock() + select { + case <-c: + h.mu.Lock() + o := h.heard[k] + delete(h.heard, k) + h.mu.Unlock() + return o.word, nil + case <-time.After(d): + return judgedWord{}, fmt.Errorf("the agent on %s did not say that ticket %s ended within %s; the review is asked again "+ + "(claude_code_judged on that node collects it by hand)", node, ticket, d) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go index 149f9d4a..dc2c8211 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kb.go @@ -39,11 +39,25 @@ const ( EventDrafted = "drafted" EventConfirmed = "confirmed" EventReturned = "returned" + // The events of a record written in a draft, section by section, and of the move to the ledger (novox/hq + // to-be 55 §1.1, ADR 0592 §34). + EventOpened = "opened" + EventWritten = "written" + EventRemoved = "removed" + EventOrdered = "ordered" + EventSubmitted = "submitted" + EventDropped = "dropped" + EventTakenOver = "taken-over" + EventPartReviewed = "part-reviewed" + EventMoved = "moved" + EventChecked = "checked" + EventFormed = "formed" ) // Events are the events, in the order the manifest declares them. var Events = []string{EventReserved, EventProposed, EventReviewed, EventAccepted, EventChanged, EventImported, - EventDrafted, EventConfirmed, EventReturned} + EventDrafted, EventConfirmed, EventReturned, EventOpened, EventWritten, EventRemoved, EventOrdered, EventSubmitted, + EventDropped, EventTakenOver, EventPartReviewed, EventMoved, EventChecked, EventFormed} // Settings are the operator's values for this module, merged by the mesh into one JSON file. type Settings struct { @@ -81,18 +95,29 @@ type Clearer func(key, resolved string) error // KB is the knowledge base. type KB struct { - Store Store - Records Records - Reserve Reserver - Judge Judgement - Asks AskBus - Notify Notifier - Clear Clearer - Emit func(event string, body any) error + Store Store + Records Records + Reserve Reserver + Judge Judgement + Asks AskBus + Notify Notifier + Clear Clearer + Emit func(event string, body any) error + // EmitID says an event under a message id of its own, which the bus drops a repeat of. + EmitID func(event string, body any, id string) error Settings func() (Settings, error) Now func() time.Time Logf func(format string, a ...any) + // Overview is the overview page's model, when the page runs. + Overview *Overview + + // calls are the tool calls in flight, by the proven caller of each (noteCall). + calls sync.Map + + // States are the module's states by local name (the drafts, the parts asked of the judge, the marks of what was sent). + States States + // mu serialises the writes: one instance holds the seat, and a write reads, judges and writes as one. mu sync.Mutex @@ -147,11 +172,28 @@ func (k *KB) emit(event string, r *Record, by, what string, version int) { } body := map[string]any{"kind": r.Kind, "number": r.Number, "record": r.Key().String(), "by": by, "at": k.now(), "what": what, "version": version, "status": r.Status} - if err := k.Emit(event, body); err != nil { + if err := k.send(event, r.Key(), version, body); err != nil { k.logf("[mesh-knowledge] the event %s for %s could not be said: %v", event, r.Key(), err) } } +// send says an event once: its message id is the record, the version (the operation it follows) and the event, so the +// bus drops a second send within its duplicate window (to-be 55 §1); the last version sent per record is kept in the state +// `sent`, and the events of later operations are sent at start (ResendEvents), so a module stopped between an operation +// and its event loses none. An event of no version (a section written) has no id of its own. +func (k *KB) send(event string, key Key, version int, body map[string]any) error { + var err error + if k.EmitID != nil && version > 0 { + err = k.EmitID(event, body, fmt.Sprintf("%s.%04d.v%d.%s", key.Kind, key.Number, version, event)) + } else { + err = k.Emit(event, body) + } + if err == nil && version > 0 { + k.markSent(key, version) + } + return err +} + // reference is the reading order and the glossary, read again after ten minutes. func (k *KB) reference() (*Reference, error) { k.refMu.Lock() @@ -214,7 +256,7 @@ func (k *KB) ReserveNumber(caller, kind, title string) (*Reserved, error) { } k.mu.Lock() defer k.mu.Unlock() - ctx := context.Background() + ctx := k.ctxFor(caller) at := k.now() r := &Record{Kind: kind, Number: n, Title: title, Status: StatusReserved, ReservedBy: caller, ReservedAt: at, ReservedFor: title} err = k.Store.Create(ctx, Version{By: caller, At: at, Why: "number reserved from the issue tracker", Record: r}) @@ -280,7 +322,7 @@ const MostRecordBytes = 512 << 10 // revised as new versions by its author until it is accepted; an accepted record is changed only by a progressive // insight (Insight), which is reviewed like a proposal while the accepted version stays the one served. func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) { - ctx := context.Background() + ctx := k.ctxFor(caller) k.mu.Lock() defer k.mu.Unlock() if !contains(Kinds, a.Kind) { @@ -338,6 +380,7 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) { "Nothing was proposed", key, w.Author, caller) } r.Author, r.Reviews, r.ReservedBy, r.ReservedAt, r.ReservedFor = w.Author, w.Reviews, w.ReservedBy, w.ReservedAt, w.ReservedFor + r.Parts = w.Parts r.Writers = w.Writers if a.Insight != "" { refusals = append(refusals, Refusal{Rule: "reserved", Says: "a proposal not yet accepted is revised, not corrected by an insight"}) @@ -367,6 +410,7 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) { r.Writers = append(r.Writers, held.Current.Author) } r.AcceptedAt = held.Current.AcceptedAt + r.Parts = held.Current.Parts r.Pending = &PendingState{Insight: Insight{Date: k.now().Format("2006-01-02"), Before: strings.TrimSpace(a.Insight), By: caller}} r.Date = held.Current.Date r.Effort = a.Status @@ -382,6 +426,21 @@ func (k *KB) Propose(caller string, a ProposeAsk) (*Proposed, error) { default: return nil, fmt.Errorf("%s is %s and takes no proposal; nothing was proposed", key, held.Working().Status) } + // A research effort's new version that gives no sections keeps the served version's, and its title, date and + // deciders where none are given: closing a long effort (its status and its became links) needs no retyping of + // its text (novox/hq issue 1966). The version so composed is judged and reviewed as any other. + if s := held.Current; a.Kind == KindResearch && len(a.Sections) == 0 && s.Status != StatusReserved { + r.Sections = append([]Section(nil), s.Sections...) + if r.Title == "" { + r.Title = s.Title + } + if strings.TrimSpace(a.Date) == "" { + r.Date = s.Date + } + if len(a.Deciders) == 0 { + r.Deciders = append([]string(nil), s.Deciders...) + } + } } if current && held != nil && !held.Current.ReservedAt.IsZero() && r.Date < held.Current.ReservedAt.Format("2006-01-02") { // A new record is of its own day: a date before its reservation would put it under rules older records keep. @@ -729,7 +788,7 @@ func (k *KB) WithdrawRecord(caller string, key Key, why string) (*Withdrawn, err if why == "" { return nil, errors.New("withdraw needs why: one line saying why the record is withdrawn; nothing was withdrawn") } - ctx := context.Background() + ctx := k.ctxFor(caller) k.mu.Lock() defer k.mu.Unlock() e, err := k.Store.Get(ctx, key) diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go index ca15ee58..eed0e18c 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/kit_test.go @@ -146,9 +146,11 @@ func pgStore(t *testing.T) *PgStore { return s } -// eachStore runs a test over the store in memory and over postgres. +// eachStore runs a test over the store in memory, over the ledger (played here, with the manifest's own types) and over +// postgres. func eachStore(t *testing.T, run func(t *testing.T, s Store)) { t.Run("memory", func(t *testing.T) { run(t, newMemStore()) }) + t.Run("ledger", func(t *testing.T) { run(t, newLedgerStore(newFakeLedger(t), newFakeStates())) }) t.Run("postgres", func(t *testing.T) { run(t, pgStore(t)) }) } diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/ledger.go b/modules/mesh-knowledge/cmd/mesh-knowledge/ledger.go new file mode 100644 index 00000000..956fcbe7 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/ledger.go @@ -0,0 +1,32 @@ +package main + +// The ledger the records are kept in (novox/hq ADR 0597 §11, to-be 55 §2.1): one instance per record, a type for each +// kind, every change an operation the module hands to the tool runner's apply. + +// LedgerName is the local name the module asks its ledger for in `requires`. +const LedgerName = "records" + +// The operations the ledger's types allow (ADR 0597 §11: reserve, propose, write-section, submit, take-over, review, ask, +// warrant, accept and withdraw; and those the module's other changes of a record are, so each leaves its own mark in +// the history). +const ( + OpReserve = "reserve" + OpPropose = "propose" + OpWriteSection = "write-section" + OpSubmit = "submit" + OpTakeOver = "take-over" + OpReview = "review" + OpAsk = "ask" + OpWarrant = "warrant" + OpAccept = "accept" + OpWithdraw = "withdraw" + OpRetract = "retract" + OpDraft = "draft" + OpDrop = "drop" + OpImport = "import" + OpForm = "form" +) + +// LedgerOperations are the operations, in the order the manifest's types allow them. +var LedgerOperations = []string{OpReserve, OpPropose, OpWriteSection, OpSubmit, OpDrop, OpImport, OpForm, OpTakeOver, + OpReview, OpAsk, OpWarrant, OpAccept, OpWithdraw, OpRetract, OpDraft} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/ledger_bus_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/ledger_bus_test.go new file mode 100644 index 00000000..335a2ed0 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/ledger_bus_test.go @@ -0,0 +1,196 @@ +//go:build integration + +package main + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// jsKV is one of the module's states on a real JetStream bucket. +func jsonMarshal(v any) ([]byte, error) { return json.Marshal(v) } + +type jsKV struct { + kv jetstream.KeyValue +} + +func changed(err error) error { + if errors.Is(err, jetstream.ErrKeyExists) || (err != nil && strings.Contains(err.Error(), "wrong last sequence")) { + return stdio.ErrChanged + } + return err +} + +func (k jsKV) Get(key string) (*stdio.StateEntry, error) { + e, err := k.kv.Get(context.Background(), key) + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &stdio.StateEntry{Key: key, Value: e.Value(), Revision: e.Revision()}, nil +} +func (k jsKV) Put(key string, v any) (uint64, error) { + b, _ := jsonMarshal(v) + return k.kv.Put(context.Background(), key, b) +} +func (k jsKV) Create(key string, v any) (uint64, error) { + b, _ := jsonMarshal(v) + r, err := k.kv.Create(context.Background(), key, b) + return r, changed(err) +} +func (k jsKV) Update(key string, v any, rev uint64) (uint64, error) { + b, _ := jsonMarshal(v) + r, err := k.kv.Update(context.Background(), key, b, rev) + return r, changed(err) +} +func (k jsKV) Delete(key string) error { return k.kv.Delete(context.Background(), key) } +func (k jsKV) Keys() ([]string, error) { + keys, err := k.kv.Keys(context.Background()) + if errors.Is(err, jetstream.ErrNoKeysFound) { + return nil, nil + } + return keys, err +} + +type jsStates struct { + mu sync.Mutex + js jetstream.JetStream + by map[string]jsKV + t *testing.T +} + +func (s *jsStates) State(name string) KV { + s.mu.Lock() + defer s.mu.Unlock() + if k, ok := s.by[name]; ok { + return k + } + kv, err := s.js.CreateOrUpdateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: "mesh_knowledge_" + name, History: 1}) + if err != nil { + s.t.Fatal(err) + } + k := jsKV{kv} + s.by[name] = k + return k +} + +// The knowledge base running whole on the ledger against a real bus (to-be 55 §10, AC57): the states are JetStream buckets, +// its events are published under their message ids to a stream that drops a repeat, the ledger is played with the module's +// own types. It moves its records, proposes, is reviewed and accepts a record, loses an event between an operation and its send, +// restarts, sends what the stop lost exactly once, builds its index and checks the database within 40 seconds, and is healthy. +func TestTheKnowledgeBaseRunsWholeOnTheLedgerOverARealBus(t *testing.T) { + s, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true}) + if err != nil { + t.Fatal(err) + } + go s.Start() + if !s.ReadyForConnections(30 * time.Second) { + t.Fatal("no bus within 30s") + } + t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() }) + nc, err := nats.Connect(s.ClientURL()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + js, _ := jetstream.New(nc) + ctx := context.Background() + events, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "KB_EVENTS", Subjects: []string{"kb.event.>"}, Duplicates: 24 * time.Hour}) + if err != nil { + t.Fatal(err) + } + states := &jsStates{js: js, by: map[string]jsKV{}, t: t} + publish := func(event string, body any, id string) error { + b, _ := jsonMarshal(body) + _, err := js.Publish(ctx, "kb.event."+event, b, jetstream.WithMsgID(id)) + return err + } + fakeBus := newFakeLedger(t) // the ledger of the tool runner, played with the module's own types + database := newMemStore() + seed := imported(t, database) + seed.accepted(t, "author@g14", valid(seed.reserved(t, "author@g14"))) + + start := func() (*world, *movableStore) { + led := newLedgerStore(fakeBus, states) + m := newMovableStore(database, led, states) + w := newWorld(t, m, fixture()) + w.k.States = states + w.k.EmitID = publish + w.tracker.next[KindDecision] = 400 + return w, m + } + w, m := start() + moved, err := m.Move(ctx, MoveAsk{Commit: true}, nil) + if err != nil || !moved.Switched { + t.Fatalf("%v %+v", err, moved) + } + // A record is proposed, reviewed in parts and accepted, writing through the ledger and saying its events on the bus. + n := w.reserved(t, "author@g14") + w.accepted(t, "author@g14", valid(n)) + if info, _ := events.Info(ctx); info.State.Msgs == 0 { + t.Fatal("no event reached the bus") + } + // An event lost between the operation and its send: the bus is cut, the operation is in the history. + cut := w.k.EmitID + w.k.EmitID = func(string, any, string) error { return errors.New("the bus is cut") } + lost := w.reserved(t, "author@g14") + if w.k.sentUpTo(Key{KindDecision, lost}) != 0 { + t.Fatal("an event never sent was marked sent") + } + w.k.EmitID = cut + before, _ := events.Info(ctx) + + // Restart: new objects over the same ledger and buckets. The start builds the index, checks, and sends what the stop lost. + began := time.Now() + w2, m2 := start() + if m2.Source() != SourceLedger { + t.Fatalf("a restart serves from %s: the switch key was not kept", m2.Source()) + } + if err := m2.led.BuildIndex(ctx); err != nil { + t.Fatal(err) + } + if diffs, err := m2.Check(ctx); err != nil || len(diffs) != 0 { + t.Fatalf("%v %v", err, diffs) + } + sent, err := w2.k.ResendEvents(ctx) + if err != nil || sent != 1 { + t.Fatalf("a restart sent %d events: %v", sent, err) + } + if took := time.Since(began); took > 40*time.Second { + t.Fatalf("the start took %s; at most 40 s", took) + } + after, _ := events.Info(ctx) + if after.State.Msgs != before.State.Msgs+1 { + t.Fatalf("the bus holds %d messages, was %d: the lost event is sent once", after.State.Msgs, before.State.Msgs) + } + // Sent again under the same id (the mark lost too), the bus drops the repeat. + _ = states.State(sentState).Delete(sentKey(Key{KindDecision, lost})) + _, _ = w2.k.ResendEvents(ctx) + again, _ := events.Info(ctx) + if again.State.Msgs != after.State.Msgs { + t.Fatalf("a repeat was kept: %d messages, was %d", again.State.Msgs, after.State.Msgs) + } + // The records are served, searched and healthy. + if e, err := w2.k.Store.Get(ctx, Key{KindDecision, n}); err != nil || e == nil || !e.Current.Accepted() { + t.Fatalf("%v %v", err, e) + } + if hits, err := m2.Search(ctx, "merge window", 3); err != nil || len(hits) == 0 { + t.Fatalf("%v %v", err, hits) + } + fakeBus.fill.MaxBytes = 512 << 20 + if h := w2.k.ledgerHealth(HealthAnswer{State: "healthy", Healthy: true}); !h.Healthy || !strings.Contains(h.Why, "served from the ledger") { + t.Fatalf("%+v", h) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/ledger_fake_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/ledger_fake_test.go new file mode 100644 index 00000000..377eb602 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/ledger_fake_test.go @@ -0,0 +1,268 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/buscalls" + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// fakeLedger plays the tool runner's side of the ledger for the module's own types (read from its manifest), as design +// 25 §12.4 says: apply checks an operation against the type, appends it with the sequence expected and derives the +// state by the operation's effect. It is a pipe, so a test of the store runs the same rules the manifest declares. +type fakeLedger struct { + t *testing.T + types map[string]catalogue.LedgerType + mu sync.Mutex + hist map[string][]buscalls.Operation + state map[string]map[string]any + // calls are the methods called, in order; applied are the call ids each apply named. + calls []string + applied []buscalls.ApplyRequest + // failApply, when set, refuses the apply of that operation. + failApply string + account string + // lag and fill are what lag and a stream's fill answer; noFill makes the stream not answer. + lag buscalls.Lagged + fill buscalls.StreamFilled + noFill bool +} + +func newFakeLedger(t *testing.T) *fakeLedger { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + m, err := catalogue.Decode(raw) + if err != nil { + t.Fatal(err) + } + f := &fakeLedger{t: t, types: map[string]catalogue.LedgerType{}, hist: map[string][]buscalls.Operation{}, state: map[string]map[string]any{}, account: "mesh-knowledge"} + for _, e := range m.BusState { + if e.Name == LedgerName { + for _, ty := range e.Types { + f.types[ty.Name] = ty + } + } + } + if len(f.types) != 2 { + t.Fatalf("the manifest's ledger has types %v", f.types) + } + return f +} + +func (f *fakeLedger) Call(method string, req, answer any) error { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, method) + raw, _ := json.Marshal(req) + var out any + switch method { + case buscalls.LedgerApply: + var r buscalls.ApplyRequest + _ = json.Unmarshal(raw, &r) + a, err := f.apply(r) + if err != nil { + return err + } + out = a + case buscalls.LedgerGet: + var r buscalls.InstanceRequest + _ = json.Unmarshal(raw, &r) + st := f.state[r.Instance] + is := buscalls.InstanceState{Instance: r.Instance, State: json.RawMessage("null")} + if st != nil { + b, _ := json.Marshal(st) + is.State, is.Sequence = b, uint64(len(f.hist[r.Instance])) + } + out = is + case buscalls.LedgerHistory: + var r buscalls.HistoryRequest + _ = json.Unmarshal(raw, &r) + ops := f.hist[r.Instance] + page := buscalls.HistoryPage{Last: uint64(len(ops))} + for _, o := range ops { + if o.Sequence < r.From { + continue + } + if r.Limit > 0 && len(page.Operations) == r.Limit { + page.Next = o.Sequence + break + } + page.Operations = append(page.Operations, o) + } + out = page + case buscalls.LedgerLag: + out = f.lag + case buscalls.StreamFill: + if f.noFill { + return fmt.Errorf("the stream does not answer") + } + out = f.fill + default: + return fmt.Errorf("the fake ledger answers no %s", method) + } + b, _ := json.Marshal(out) + return json.Unmarshal(b, answer) +} + +func (f *fakeLedger) apply(r buscalls.ApplyRequest) (buscalls.Applied, error) { + if r.Ledger != LedgerName { + return buscalls.Applied{}, fmt.Errorf("no ledger %q", r.Ledger) + } + if f.failApply == r.Operation { + return buscalls.Applied{}, fmt.Errorf("refused: %s", r.Operation) + } + typeName, _, _ := strings.Cut(r.Instance, "/") + ty, ok := f.types[typeName] + if !ok { + return buscalls.Applied{}, fmt.Errorf("no type %q", typeName) + } + var op *catalogue.LedgerOperation + for i := range ty.Operations { + if ty.Operations[i].Name == r.Operation { + op = &ty.Operations[i] + } + } + if op == nil { + return buscalls.Applied{}, fmt.Errorf("the type %s allows no operation %q", typeName, r.Operation) + } + body := map[string]any{} + if len(r.Body) > 0 { + if err := json.Unmarshal(r.Body, &body); err != nil { + return buscalls.Applied{}, err + } + } + for name, field := range ty.Schema { + v, has := body[name] + if !has { + continue + } + okType := map[string]func(any) bool{ + "string": func(v any) bool { _, ok := v.(string); return ok }, "integer": func(v any) bool { _, ok := v.(float64); return ok }, + "object": func(v any) bool { _, ok := v.(map[string]any); return ok }, + }[field.Type] + if okType != nil && !okType(v) { + return buscalls.Applied{}, fmt.Errorf("the field %s is not a %s", name, field.Type) + } + if field.DigestOf != "" { + if txt, _ := body[field.DigestOf].(string); v != SectionDigest(txt) { + return buscalls.Applied{}, fmt.Errorf("the %s is not the digest of its %s", name, field.DigestOf) + } + } + } + seq := uint64(len(f.hist[r.Instance]) + 1) + author := buscalls.Author{Account: f.account} + if r.Call != "" { + author.Caller = "caller-of-" + r.Call + } + f.hist[r.Instance] = append(f.hist[r.Instance], buscalls.Operation{Sequence: seq, Instance: r.Instance, Operation: r.Operation, Body: r.Body, Author: author, At: time.Unix(int64(seq), 0).UTC()}) + f.applied = append(f.applied, r) + st := f.state[r.Instance] + if st == nil { + st = map[string]any{} + } + copied := map[string]any{} + for _, name := range op.Fields { + if v, ok := body[name]; ok { + copied[name] = v + } + } + if op.Sequence { + copied["sequence"] = seq + } + if op.Author { + copied["author"] = author + } + switch op.Effect { + case catalogue.EffectSetFields: + for k, v := range copied { + st[k] = v + } + case catalogue.EffectSetEntry: + m, _ := st[op.Into].(map[string]any) + if m == nil { + m = map[string]any{} + } + m[body[op.Key].(string)] = copied + st[op.Into] = m + default: + f.t.Fatalf("the fake ledger plays no effect %s", op.Effect) + } + f.state[r.Instance] = st + return buscalls.Applied{Taken: true, Sequence: seq}, nil +} + +// fakeStates plays the module's states. +type fakeStates struct { + mu sync.Mutex + kv map[string]map[string][]byte +} + +func newFakeStates() *fakeStates { return &fakeStates{kv: map[string]map[string][]byte{}} } + +func (f *fakeStates) State(name string) KV { return fakeKV{f, name} } + +type fakeKV struct { + f *fakeStates + name string +} + +func (k fakeKV) bucket() map[string][]byte { + if k.f.kv[k.name] == nil { + k.f.kv[k.name] = map[string][]byte{} + } + return k.f.kv[k.name] +} + +func (k fakeKV) Get(key string) (*stdio.StateEntry, error) { + k.f.mu.Lock() + defer k.f.mu.Unlock() + v, ok := k.bucket()[key] + if !ok { + return nil, nil + } + return &stdio.StateEntry{Key: key, Value: v, Revision: 1}, nil +} +func (k fakeKV) Put(key string, value any) (uint64, error) { + k.f.mu.Lock() + defer k.f.mu.Unlock() + b, _ := json.Marshal(value) + k.bucket()[key] = b + return 1, nil +} +func (k fakeKV) Create(key string, value any) (uint64, error) { + k.f.mu.Lock() + defer k.f.mu.Unlock() + if _, ok := k.bucket()[key]; ok { + return 0, stdio.ErrChanged + } + b, _ := json.Marshal(value) + k.bucket()[key] = b + return 1, nil +} +func (k fakeKV) Update(key string, value any, _ uint64) (uint64, error) { return k.Put(key, value) } +func (k fakeKV) Delete(key string) error { + k.f.mu.Lock() + defer k.f.mu.Unlock() + delete(k.bucket(), key) + return nil +} +func (k fakeKV) Keys() ([]string, error) { + k.f.mu.Lock() + defer k.f.mu.Unlock() + var out []string + for key := range k.bucket() { + out = append(out, key) + } + return out, nil +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/ledgerstore.go b/modules/mesh-knowledge/cmd/mesh-knowledge/ledgerstore.go new file mode 100644 index 00000000..ea3b3f3f --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/ledgerstore.go @@ -0,0 +1,659 @@ +package main + +// The records kept in the ledger (novox/hq ADR 0597 §11, to-be 55 §2.1): the Store the rules of the knowledge base are +// written against, on the tool runner's ledger calls. A record is one instance, `decision/0290` or `research/081`; every +// change to it is one operation appended to its history; its head, the entry, is the state apply derives from the +// operations and is never written by the module; a section's text is in the `write-section` operation that wrote it and +// nowhere else, the head naming it by its title, its digest and that operation's sequence. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/buscalls" +) + +// MostSectionBytes is the most a section holds (to-be 55 §4), within a history entry's 256 KiB. +const MostSectionBytes = 40 << 10 + +// sectionRef is how a head names a section: its title, the digest of its text and the sequence of the write-section that +// wrote it. +type sectionRef struct { + Name string `json:"name"` + Digest string `json:"digest"` + Sequence uint64 `json:"sequence"` +} + +// storedRecord is a record as the head and a version keep it: the record whole but for its sections' text. +type storedRecord struct { + Record + Sections []sectionRef `json:"sections,omitempty"` +} + +// entryHead is the head of an instance: the version served, the one proposed over it, and how many versions there are. +type entryHead struct { + Current *storedRecord `json:"current"` + CurrentVersion int `json:"current-version"` + Pending *storedRecord `json:"pending,omitempty"` + PendingVersion int `json:"pending-version,omitempty"` + Versions int `json:"versions"` +} + +// versionHead is a version as its operation keeps it. +type versionHead struct { + N int `json:"n"` + By string `json:"by"` + // ImportedBy is who wrote the version in the database it was copied from: the ledger's author is the module's own + // account, and the old author is kept apart, marked imported (ADR 0597 §7). + ImportedBy string `json:"imported-by,omitempty"` + At time.Time `json:"at"` + Why string `json:"why"` + Insight bool `json:"insight,omitempty"` + Record storedRecord `json:"record"` +} + +// headState is the state of an instance, as apply derives it from the operations. +type headState struct { + Kind string `json:"kind"` + Number int `json:"number"` + Entry *entryHead `json:"entry"` + Sequence uint64 `json:"sequence"` +} + +// SectionDigest is the digest a section's text is kept under; the ledger refuses a write-section whose digest is another. +func SectionDigest(text string) string { + sum := sha256.Sum256([]byte(text)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// InstanceOf is the instance a record is: `decision/0290`, `research/081`. +func InstanceOf(k Key) string { + if k.Kind == KindResearch { + return fmt.Sprintf("research/%03d", k.Number) + } + return fmt.Sprintf("decision/%04d", k.Number) +} + +// ledgerStore is the Store on the ledger. +type ledgerStore struct { + pipe Pipe + states States + + mu sync.Mutex + // heads are the instances read, by key, each with the sequence it was read at; texts are the sections' text by digest. + heads map[Key]*headState + texts map[string]string + scanned bool + // drafted are the sections written for a draft and named by no version yet: a version that carries one reuses its + // write-section and writes none again. + drafted map[Key][]sectionRef + // index is the search index of the module's memory, nil until built. + index *searchIndex + indexAsync bool + logf func(format string, a ...any) +} + +// newLedgerStore is the store over a pipe and the module's states. +func newLedgerStore(p Pipe, s States) *ledgerStore { + return &ledgerStore{pipe: p, states: s, heads: map[Key]*headState{}, texts: map[string]string{}, drafted: map[Key][]sectionRef{}} +} + +// stripped is r as a stored record naming its sections by ref: the refs of the sections already written are kept, the +// new ones are filled by the caller. +func stripped(r *Record, refs []sectionRef) storedRecord { + c := r.Clone() + c.Sections = nil + return storedRecord{Record: *c, Sections: refs} +} + +// read is one instance's head from the ledger, or nil when it has none. +func (s *ledgerStore) read(k Key) (*headState, error) { + var got buscalls.InstanceState + if err := s.pipe.Call(buscalls.LedgerGet, buscalls.InstanceRequest{Ledger: LedgerName, Instance: InstanceOf(k)}, &got); err != nil { + return nil, err + } + if len(got.State) == 0 || string(got.State) == "null" { + return nil, nil + } + var h headState + if err := json.Unmarshal(got.State, &h); err != nil { + return nil, fmt.Errorf("the head of %s is not a record's: %w", k, err) + } + h.Sequence = got.Sequence + if h.Entry == nil { + // Sections were written for it and no version: a draft of a number not held yet is no record. + return nil, nil + } + return &h, nil +} + +// fresh is the head, read again unless this process wrote it or read it and nothing else writes: one instance of the +// module holds the seat, so what it holds is what the ledger has. +func (s *ledgerStore) fresh(k Key) (*headState, error) { + if h := s.heads[k]; h != nil { + return h, nil + } + h, err := s.read(k) + if err != nil || h == nil { + return nil, err + } + s.heads[k] = h + return h, nil +} + +// heldPrefix starts the key of the state `findings` that says a record is held: the ledger answers an instance by its path and +// lists none, so the store keeps one small key for each record it creates, and a restart reads them to know what to ask for. +const heldPrefix = "held." + +func heldKey(k Key) string { return fmt.Sprintf("%s%s.%04d", heldPrefix, k.Kind, k.Number) } + +func (s *ledgerStore) hold(k Key) { + if _, err := s.states.State(findingsState).Put(heldKey(k), 1); err != nil && s.logf != nil { + s.logf("[mesh-knowledge] that %s is held could not be kept: %v", k, err) + } +} + +// scan reads every instance the ledger holds, once: the keys that say a record is held name them; a ledger written before +// those keys existed is probed number by number, a long run of none ending it, and the keys are then written. +func (s *ledgerStore) scan() error { + if s.scanned { + return nil + } + keys, err := s.states.State(findingsState).Keys() + if err != nil { + return err + } + found := 0 + for _, key := range keys { + rest, ok := strings.CutPrefix(key, heldPrefix) + if !ok { + continue + } + var k Key + var n int + kind, num, ok := strings.Cut(rest, ".") + if _, err := fmt.Sscanf(num, "%d", &n); !ok || err != nil { + continue + } + k = Key{kind, n} + if _, err := s.fresh(k); err != nil { + return err + } + found++ + } + if found == 0 { + const gap = 1000 + for _, kind := range Kinds { + misses := 0 + for n := 1; misses < gap; n++ { + k := Key{kind, n} + h, err := s.fresh(k) + if err != nil { + return err + } + if h == nil { + misses++ + continue + } + misses = 0 + s.hold(k) + } + } + } + s.scanned = true + return nil +} + +// text is the text of a section by its ref, read from the write-section that wrote it when it is not held, and held to its +// digest. +func (s *ledgerStore) text(ref sectionRef, k Key) (string, error) { + if t, ok := s.texts[ref.Digest]; ok { + return t, nil + } + var page buscalls.HistoryPage + if err := s.pipe.Call(buscalls.LedgerHistory, buscalls.HistoryRequest{Ledger: LedgerName, Instance: InstanceOf(k), From: ref.Sequence, Limit: 1}, &page); err != nil { + return "", err + } + if len(page.Operations) == 0 || page.Operations[0].Sequence != ref.Sequence || page.Operations[0].Operation != OpWriteSection { + return "", fmt.Errorf("the section %q of %s names operation %d, which is no write-section of it", ref.Name, k, ref.Sequence) + } + var body struct { + Text string `json:"text"` + } + if err := json.Unmarshal(page.Operations[0].Body, &body); err != nil { + return "", err + } + if SectionDigest(body.Text) != ref.Digest { + return "", fmt.Errorf("the section %q of %s has a text whose digest is not %s: the history is damaged", ref.Name, k, ref.Digest) + } + s.texts[ref.Digest] = body.Text + return body.Text, nil +} + +// whole is a stored record with its sections' text. +func (s *ledgerStore) whole(sr *storedRecord, k Key) (*Record, error) { + if sr == nil { + return nil, nil + } + r := sr.Record.Clone() + r.Sections = nil + for _, ref := range sr.Sections { + t, err := s.text(ref, k) + if err != nil { + return nil, err + } + r.Sections = append(r.Sections, Section{Name: ref.Name, Text: t}) + } + return r, nil +} + +// entryOf is the entry a head is. +func (s *ledgerStore) entryOf(k Key, h *headState) (*Entry, error) { + cur, err := s.whole(h.Entry.Current, k) + if err != nil { + return nil, err + } + pend, err := s.whole(h.Entry.Pending, k) + if err != nil { + return nil, err + } + return &Entry{Current: cur, CurrentVersion: h.Entry.CurrentVersion, Pending: pend, PendingVersion: h.Entry.PendingVersion, + Versions: h.Entry.Versions, Revision: int64(h.Sequence)}, nil +} + +func (s *ledgerStore) Get(_ context.Context, k Key) (*Entry, error) { + s.mu.Lock() + defer s.mu.Unlock() + h, err := s.fresh(k) + if err != nil || h == nil { + return nil, err + } + return s.entryOf(k, h) +} + +func (s *ledgerStore) All(context.Context) ([]*Entry, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.scan(); err != nil { + return nil, err + } + keys := make([]Key, 0, len(s.heads)) + for k := range s.heads { + keys = append(keys, k) + } + sortKeys(keys) + out := make([]*Entry, 0, len(keys)) + for _, k := range keys { + e, err := s.entryOf(k, s.heads[k]) + if err != nil { + return nil, err + } + out = append(out, e) + } + return out, nil +} + +// write appends a write-section for each section of r that is new, and answers the refs of all its sections. known are +// the refs already written, by name and digest. +func (s *ledgerStore) write(ctx context.Context, k Key, r *Record, known []sectionRef) ([]sectionRef, error) { + var refs []sectionRef + for _, sec := range r.Sections { + d := SectionDigest(sec.Text) + found := false + for _, kr := range known { + if kr.Name == sec.Name && kr.Digest == d { + refs, found = append(refs, kr), true + break + } + } + if found { + continue + } + if len(sec.Text) > MostSectionBytes { + return nil, fmt.Errorf("the section %q of %s is %d bytes; a section holds at most %d: split it", sec.Name, k, len(sec.Text), MostSectionBytes) + } + seq, err := s.apply(ctx, k, OpWriteSection, map[string]any{"title": sec.Name, "text": sec.Text, "digest": d}) + if err != nil { + return nil, err + } + s.texts[d] = sec.Text + refs = append(refs, sectionRef{Name: sec.Name, Digest: d, Sequence: seq}) + } + return refs, nil +} + +// apply hands one operation to the ledger and answers its sequence. +func (s *ledgerStore) apply(ctx context.Context, k Key, op string, body map[string]any) (uint64, error) { + raw, err := json.Marshal(body) + if err != nil { + return 0, err + } + var got buscalls.Applied + if err := s.pipe.Call(buscalls.LedgerApply, buscalls.ApplyRequest{Ledger: LedgerName, Instance: InstanceOf(k), Operation: op, Body: raw, Call: callOf(ctx)}, &got); err != nil { + return 0, err + } + if !got.Taken { + return 0, fmt.Errorf("the ledger did not take %s on %s", op, k) + } + return got.Sequence, nil +} + +// operationFor is the operation a change of an entry is, named by what changed, so the history says it. +func operationFor(old *Entry, v Version, current bool) string { + if old == nil { + switch { + case v.Record.Status == StatusReserved: + return OpReserve + case v.Record.Source != "" && v.Record.Author == "" && len(v.Record.Writers) == 0: + return OpImport + } + return OpPropose + } + prev, next := old.Working(), v.Record + switch { + case prev.Status == StatusReserved && next.Status == StatusProposed: + return OpPropose + case next.Status == StatusWithdrawn && prev.Status != StatusWithdrawn: + return OpWithdraw + case next.Accepted() && !prev.Accepted(), next.Status == StatusSuperseded && prev.Status != StatusSuperseded: + return OpAccept + case (next.Retracting != nil) != (prev.Retracting != nil), len(next.Retractions) != len(prev.Retractions), next.Status == StatusRetracted && prev.Status != StatusRetracted: + return OpRetract + case next.Ask != nil && (prev.Ask == nil || prev.Ask.Said != next.Ask.Said) && next.Ask.Warrant != "": + return OpWarrant + case next.Ask != nil && (prev.Ask == nil || prev.Ask.ID != next.Ask.ID): + return OpAsk + case len(next.Reviews) != len(prev.Reviews): + return OpReview + case (next.Drafting != nil) != (prev.Drafting != nil) || (next.Drafting != nil && prev.Drafting != nil && *next.Drafting != *prev.Drafting): + return OpDraft + case next.Author != prev.Author: + return OpTakeOver + } + return OpSubmit +} + +func (s *ledgerStore) Create(ctx context.Context, v Version) error { + s.mu.Lock() + defer s.mu.Unlock() + k := v.Record.Key() + h, err := s.fresh(k) + if err != nil { + return err + } + if h != nil { + return ErrExists + } + v.N = 1 + refs, err := s.write(ctx, k, v.Record, s.drafted[k]) + if err != nil { + return err + } + return s.commit(ctx, k, operationFor(nil, v, true), &entryHead{CurrentVersion: 1, Versions: 1, Current: ptr(stripped(v.Record, refs))}, v, refs, true) +} + +func ptr[T any](v T) *T { return &v } + +// commit appends the operation that carries the new head and the version, and holds the head it derived. +func (s *ledgerStore) commit(ctx context.Context, k Key, op string, e *entryHead, v Version, refs []sectionRef, current bool) error { + body := map[string]any{"kind": k.Kind, "number": k.Number, "entry": e, + "version": versionHead{N: v.N, By: v.By, At: v.At, Why: v.Why, Insight: v.Insight, Record: stripped(v.Record, refs)}, + "pending-sections": map[string]any{}} + seq, err := s.apply(ctx, k, op, body) + if err != nil { + return err + } + s.heads[k] = &headState{Kind: k.Kind, Number: k.Number, Entry: e, Sequence: seq} + delete(s.drafted, k) + if e.Versions == 1 { + s.hold(k) + } + s.indexed(v.Record, current) + return nil +} + +// WriteSection appends a write-section of the record (SectionWriter): a draft's text is in the history at once. +func (s *ledgerStore) WriteSection(ctx context.Context, k Key, title, text string) (sectionRef, error) { + s.mu.Lock() + defer s.mu.Unlock() + if len(text) > MostSectionBytes { + return sectionRef{}, fmt.Errorf("the section %q is %d bytes; a section holds at most %d: split it", title, len(text), MostSectionBytes) + } + d := SectionDigest(text) + seq, err := s.apply(ctx, k, OpWriteSection, map[string]any{"title": title, "text": text, "digest": d}) + if err != nil { + return sectionRef{}, err + } + s.texts[d] = text + ref := sectionRef{Name: title, Digest: d, Sequence: seq} + s.drafted[k] = append(s.drafted[k], ref) + return ref, nil +} + +// SectionText is the text a ref names (SectionWriter). +func (s *ledgerStore) SectionText(_ context.Context, k Key, ref sectionRef) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.text(ref, k) +} + +// Working is the refs of the version a writer works on (SectionWriter). +func (s *ledgerStore) Working(_ context.Context, k Key) ([]sectionRef, error) { + s.mu.Lock() + defer s.mu.Unlock() + h, err := s.fresh(k) + if err != nil || h == nil { + return nil, err + } + if h.Entry.Pending != nil { + return append([]sectionRef(nil), h.Entry.Pending.Sections...), nil + } + return append([]sectionRef(nil), h.Entry.Current.Sections...), nil +} + +// Known tells the store sections already written for a draft, after a restart (SectionWriter). +func (s *ledgerStore) Known(k Key, refs []sectionRef) { + s.mu.Lock() + defer s.mu.Unlock() + s.drafted[k] = append([]sectionRef(nil), refs...) +} + +func (s *ledgerStore) Save(ctx context.Context, v Version, current, clearPending bool, revision int64) error { + s.mu.Lock() + defer s.mu.Unlock() + k := v.Record.Key() + h, err := s.fresh(k) + if err != nil { + return err + } + if h == nil { + return fmt.Errorf("%s is not held", k) + } + if int64(h.Sequence) != revision { + return ErrChanged + } + old, err := s.entryOf(k, h) + if err != nil { + return err + } + known := append([]sectionRef(nil), s.drafted[k]...) + for _, sr := range []*storedRecord{h.Entry.Current, h.Entry.Pending} { + if sr != nil { + known = append(known, sr.Sections...) + } + } + refs, err := s.write(ctx, k, v.Record, known) + if err != nil { + return err + } + e := *h.Entry + v.N = e.Versions + 1 + e.Versions = v.N + sr := ptr(stripped(v.Record, refs)) + if current { + e.Current, e.CurrentVersion = sr, v.N + } else { + e.Pending, e.PendingVersion = sr, v.N + } + if clearPending { + e.Pending, e.PendingVersion = nil, 0 + } + return s.commit(ctx, k, operationFor(old, v, current), &e, v, refs, current) +} + +func (s *ledgerStore) Versions(_ context.Context, k Key) ([]Version, error) { + s.mu.Lock() + defer s.mu.Unlock() + h, err := s.fresh(k) + if err != nil || h == nil { + return nil, err + } + var out []Version + for from := uint64(0); ; { + var page buscalls.HistoryPage + if err := s.pipe.Call(buscalls.LedgerHistory, buscalls.HistoryRequest{Ledger: LedgerName, Instance: InstanceOf(k), From: from}, &page); err != nil { + return nil, err + } + for _, op := range page.Operations { + var body struct { + Version *versionHead `json:"version"` + } + if op.Operation == OpWriteSection || json.Unmarshal(op.Body, &body) != nil || body.Version == nil { + continue + } + r, err := s.whole(&body.Version.Record, k) + if err != nil { + return nil, err + } + out = append(out, Version{N: body.Version.N, By: body.Version.By, At: body.Version.At, Why: body.Version.Why, Record: r, Insight: body.Version.Insight}) + } + if page.Next == 0 { + break + } + from = page.Next + } + sort.SliceStable(out, func(i, j int) bool { return out[i].N < out[j].N }) + return out, nil +} + +// Search ranks the served versions from the index of the module's memory; it is built on the first question when no start +// built it, and refuses with "index not built" while a start builds it. +func (s *ledgerStore) Search(ctx context.Context, question string, limit int) ([]Hit, error) { + s.mu.Lock() + idx, async := s.index, s.indexAsync + s.mu.Unlock() + if idx == nil { + if async { + return nil, ErrIndexNotBuilt + } + if err := s.BuildIndex(ctx); err != nil { + return nil, err + } + s.mu.Lock() + idx = s.index + s.mu.Unlock() + } + return idx.search(question, limit) +} + +func (s *ledgerStore) Inbound(_ context.Context, k Key) ([]Inbound, error) { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.scan(); err != nil { + return nil, err + } + // The links are in the heads, which hold no text: nothing is read of a section. + var served []*Record + for _, h := range s.heads { + served = append(served, &h.Entry.Current.Record) + } + return inboundOf(served, k), nil +} + +// The whole-set findings and what the operator was told are keys of the state `findings`. +const ( + findingsState = "findings" + findingsKey = "set" + toldPrefix = "told." +) + +func (s *ledgerStore) SetFindings(_ context.Context, f []Finding) error { + _, err := s.states.State(findingsState).Put(findingsKey, f) + return err +} + +func (s *ledgerStore) Findings(context.Context) ([]Finding, error) { + e, err := s.states.State(findingsState).Get(findingsKey) + if err != nil || e == nil { + return nil, err + } + var out []Finding + return out, json.Unmarshal(e.Value, &out) +} + +func (s *ledgerStore) Told(_ context.Context, topic string) (bool, error) { + _, err := s.states.State(findingsState).Create(toldPrefix+topic, time.Now().UTC()) + switch { + case err == nil: + return true, nil + case isChanged(err): + return false, nil + } + return false, err +} + +func (s *ledgerStore) ToldOf(_ context.Context, prefix string) ([]string, error) { + keys, err := s.states.State(findingsState).Keys() + if err != nil { + return nil, err + } + var out []string + for _, k := range keys { + if t, ok := strings.CutPrefix(k, toldPrefix); ok && strings.HasPrefix(t, prefix) { + out = append(out, t) + } + } + sort.Strings(out) + return out, nil +} + +func (s *ledgerStore) Untold(_ context.Context, key string) error { + return s.states.State(findingsState).Delete(toldPrefix + key) +} + +func isChanged(err error) bool { return errors.Is(err, stdio.ErrChanged) } + +// Operations are the operations of a record that made a version (OperationReader). +func (s *ledgerStore) Operations(_ context.Context, k Key) ([]OperationInfo, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []OperationInfo + for from := uint64(0); ; { + var page buscalls.HistoryPage + if err := s.pipe.Call(buscalls.LedgerHistory, buscalls.HistoryRequest{Ledger: LedgerName, Instance: InstanceOf(k), From: from}, &page); err != nil { + return nil, err + } + for _, op := range page.Operations { + var body struct { + Version *versionHead `json:"version"` + } + if op.Operation == OpWriteSection || json.Unmarshal(op.Body, &body) != nil || body.Version == nil { + continue + } + out = append(out, OperationInfo{Sequence: op.Sequence, Operation: op.Operation, Version: body.Version.N, By: body.Version.By, At: body.Version.At, Why: body.Version.Why}) + } + if page.Next == 0 { + return out, nil + } + from = page.Next + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/ledgerstore_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/ledgerstore_test.go new file mode 100644 index 00000000..72ac00f9 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/ledgerstore_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/buscalls" +) + +func ledgerRecord(n int, status string, secs ...Section) *Record { + return &Record{Kind: KindResearch, Number: n, Title: "A record", Status: status, Date: "2026-11-04", Sections: secs} +} + +// A record is one instance; a section's text is written once, in its own operation, and a new version names it by its +// digest and sequence; the head holds no text (to-be 55 §2.1, AC46). +func TestTheLedgerStoreKeepsEachSectionOnceAndTheHeadWithoutText(t *testing.T) { + ctx := context.Background() + f := newFakeLedger(t) + s := newLedgerStore(f, newFakeStates()) + at := time.Date(2026, 11, 4, 9, 0, 0, 0, time.UTC) + if err := s.Create(ctx, Version{By: "a@n", At: at, Why: "proposed", Record: ledgerRecord(81, StatusProposed, Section{"Overview", "one"}, Section{"Direction", "two"})}); err != nil { + t.Fatal(err) + } + e, _ := s.Get(ctx, Key{KindResearch, 81}) + r2 := e.Current.Clone() + r2.Sections[1].Text = "two, revised" + if err := s.Save(ctx, Version{By: "a@n", At: at, Why: "revised", Record: r2}, true, false, e.Revision); err != nil { + t.Fatal(err) + } + // Three texts written (one, two, two revised), never "one" again; the operations are named by what they did. + var names []string + for _, a := range f.applied { + names = append(names, a.Operation) + } + if got := strings.Join(names, " "); got != "write-section write-section propose write-section submit" { + t.Fatalf("operations %q", got) + } + head := string(mustJSON(t, f.state["research/081"]["entry"])) + if strings.Contains(head, "two, revised") || strings.Contains(head, `"text"`) { + t.Fatalf("the head holds a section's text: %s", head) + } + got, _ := s.Get(ctx, Key{KindResearch, 81}) + if got.Current.Sections[1].Text != "two, revised" || got.Current.Sections[0].Text != "one" || got.Versions != 2 || got.CurrentVersion != 2 { + t.Fatalf("%+v", got.Current.Sections) + } + // A reader that holds nothing (a restart) hydrates from the history, every version. + fresh := newLedgerStore(f, newFakeStates()) + vs, err := fresh.Versions(ctx, Key{KindResearch, 81}) + if err != nil || len(vs) != 2 || vs[0].Record.Sections[1].Text != "two" || vs[1].Record.Sections[1].Text != "two, revised" || vs[1].Why != "revised" { + t.Fatalf("%v %+v", err, vs) + } +} + +func TestTheLedgerStoreRefusesWhatTheLedgerWouldHaveToTakeBack(t *testing.T) { + ctx := context.Background() + f := newFakeLedger(t) + s := newLedgerStore(f, newFakeStates()) + v := Version{By: "a@n", At: time.Now(), Why: "x", Record: ledgerRecord(5, StatusProposed, Section{"Overview", "one"})} + if err := s.Create(withCall(ctx, "call-9"), v); err != nil { + t.Fatal(err) + } + // The tool call in flight is named to the ledger, so the author stamped is the caller it proved; a background act names none. + if last := f.applied[len(f.applied)-1]; last.Call != "call-9" || f.hist["research/005"][1].Author.Caller != "caller-of-call-9" { + t.Fatalf("%+v", last) + } + if err := s.Create(ctx, v); !errors.Is(err, ErrExists) { + t.Fatalf("a record held is created again: %v", err) + } + e, _ := s.Get(ctx, v.Record.Key()) + if err := s.Save(ctx, v, true, false, e.Revision-1); !errors.Is(err, ErrChanged) { + t.Fatalf("a write on a head already moved: %v", err) + } + big := ledgerRecord(6, StatusProposed, Section{"Overview", strings.Repeat("x", MostSectionBytes+1)}) + if err := s.Create(ctx, Version{Record: big}); err == nil || !strings.Contains(err.Error(), "split it") { + t.Fatalf("a section over its bound: %v", err) + } + if h, _ := s.Get(ctx, big.Key()); h != nil { + t.Fatal("a refused record is held") + } + // A text altered in the history is caught by its digest. + op := &f.hist["research/005"][0] + op.Body = mustJSON(t, map[string]any{"title": "Overview", "text": "altered", "digest": SectionDigest("one")}) + if _, err := newLedgerStore(f, newFakeStates()).Get(ctx, v.Record.Key()); err == nil || !strings.Contains(err.Error(), "digest") { + t.Fatalf("a damaged history was read: %v", err) + } + // The ledger's refusal is the call's refusal. + f.failApply = OpPropose + if err := s.Create(ctx, Version{Record: ledgerRecord(7, StatusProposed)}); err == nil { + t.Fatal("a refused apply was taken") + } +} + +func TestTheOperationAChangeIsIsNamedByWhatChanged(t *testing.T) { + base := ledgerRecord(1, StatusProposed) + base.Author = "a@n" + with := func(f func(*Record)) *Record { c := base.Clone(); f(c); return c } + held := &Entry{Current: base, CurrentVersion: 1, Versions: 1} + for _, c := range []struct { + name string + old *Entry + next *Record + want string + }{ + {"a reserved number", nil, ledgerRecord(1, StatusReserved), OpReserve}, + {"a proposal", nil, base, OpPropose}, + {"an import", nil, with(func(r *Record) { r.Author, r.Source = "", "hq@abc:x.md" }), OpImport}, + {"a revision", held, with(func(r *Record) { r.Title = "B" }), OpSubmit}, + {"a review", held, with(func(r *Record) { r.Reviews = []Review{{By: "j"}} }), OpReview}, + {"an acceptance", held, with(func(r *Record) { r.Status = StatusAccepted }), OpAccept}, + {"a withdrawal", held, with(func(r *Record) { r.Status = StatusWithdrawn }), OpWithdraw}, + {"an ask", held, with(func(r *Record) { r.Ask = &OperatorAsk{ID: "x", Said: "waiting"} }), OpAsk}, + {"a warrant", &Entry{Current: with(func(r *Record) { r.Ask = &OperatorAsk{ID: "x", Said: "waiting"} })}, with(func(r *Record) { r.Ask = &OperatorAsk{ID: "x", Said: "approved", Warrant: "w"} }), OpWarrant}, + {"a take-over", held, with(func(r *Record) { r.Author = "b@n" }), OpTakeOver}, + {"a drafting step", held, with(func(r *Record) { r.Drafting = &Drafting{State: DraftWaiting} }), OpDraft}, + {"a retraction asked", held, with(func(r *Record) { r.Retracting = &Retraction{Why: "x"} }), OpRetract}, + } { + if got := operationFor(c.old, Version{Record: c.next}, true); got != c.want { + t.Errorf("%s: %s, want %s", c.name, got, c.want) + } + } +} + +func TestTheFindingsAndTheToldMarksAreKeysOfTheState(t *testing.T) { + ctx := context.Background() + st := newFakeStates() + s := newLedgerStore(newFakeLedger(t), st) + if err := s.SetFindings(ctx, []Finding{{Rule: "cited", Says: "x"}}); err != nil { + t.Fatal(err) + } + if f, _ := s.Findings(ctx); len(f) != 1 || f[0].Rule != "cited" { + t.Fatalf("%v", f) + } + if first, _ := s.Told(ctx, "clauses.0290"); !first { + t.Fatal("told once") + } + if again, _ := s.Told(ctx, "clauses.0290"); again { + t.Fatal("told twice") + } + _, _ = s.Told(ctx, "review.0291") + if got, _ := s.ToldOf(ctx, "clauses."); len(got) != 1 || got[0] != "clauses.0290" { + t.Fatalf("%v", got) + } + _ = s.Untold(ctx, "clauses.0290") + if first, _ := s.Told(ctx, "clauses.0290"); !first { + t.Fatal("told again after it was over") + } +} + +func mustJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return b +} + +var _ = buscalls.LedgerApply + +// A restart finds every record the ledger holds, however far apart their numbers are: the ledger lists none, so the store +// keeps a key for each record it creates. +func TestARestartFindsEveryRecordHoweverSparseTheirNumbers(t *testing.T) { + f := newFakeLedger(t) + st := newFakeStates() + s := newLedgerStore(f, st) + for _, n := range []int{11, 127, 900} { + if err := s.Create(context.Background(), Version{By: "a@n", Record: ledgerRecord(n, StatusProposed, Section{"Overview", "x"})}); err != nil { + t.Fatal(err) + } + } + all, err := newLedgerStore(f, st).All(context.Background()) + if err != nil || len(all) != 3 || all[2].Current.Number != 900 { + t.Fatalf("%v %d", err, len(all)) + } + // A ledger written before the keys existed is probed, and the keys are written. + bare := newFakeStates() + if all, err := newLedgerStore(f, bare).All(context.Background()); err != nil || len(all) != 3 { + t.Fatalf("%v %d", err, len(all)) + } + if keys, _ := bare.State(findingsState).Keys(); len(keys) != 3 { + t.Fatalf("%v", keys) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/ledgertype_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/ledgertype_test.go new file mode 100644 index 00000000..0449bc1e --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/ledgertype_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/buscalls" +) + +// Each operation of the ledger's types has the effect on a head the design says (ADR 0597 §2, to-be 55 §2.1): a +// write-section puts the section's title and digest, never its text, among the pending sections; an operation that +// makes a version sets the head and empties them; the others set the head and leave them. +func TestEachOperationHasItsEffectOnAHead(t *testing.T) { + text := "the section's text" + for _, c := range []struct { + op string + body map[string]any + pendingKept bool // the pending sections stand after it + clears bool // the pending sections are emptied by it + }{ + {OpReserve, map[string]any{"entry": map[string]any{"v": 1}}, false, false}, + {OpPropose, map[string]any{"entry": map[string]any{"v": 1}, "pending-sections": map[string]any{}}, false, true}, + {OpSubmit, map[string]any{"entry": map[string]any{"v": 1}, "pending-sections": map[string]any{}}, false, true}, + {OpDrop, map[string]any{"entry": map[string]any{"v": 1}, "pending-sections": map[string]any{}}, false, true}, + {OpImport, map[string]any{"entry": map[string]any{"v": 1}, "pending-sections": map[string]any{}}, false, true}, + {OpForm, map[string]any{"entry": map[string]any{"v": 1}, "pending-sections": map[string]any{}}, false, true}, + {OpTakeOver, map[string]any{"entry": map[string]any{"v": 1}}, true, false}, + {OpReview, map[string]any{"entry": map[string]any{"v": 1}}, true, false}, + {OpAsk, map[string]any{"entry": map[string]any{"v": 1}}, true, false}, + {OpWarrant, map[string]any{"entry": map[string]any{"v": 1}}, true, false}, + {OpAccept, map[string]any{"entry": map[string]any{"v": 1}}, true, false}, + {OpWithdraw, map[string]any{"entry": map[string]any{"v": 1}}, true, false}, + {OpRetract, map[string]any{"entry": map[string]any{"v": 1}}, true, false}, + {OpDraft, map[string]any{"entry": map[string]any{"v": 1}}, true, false}, + } { + f := newFakeLedger(t) + apply := func(op string, body map[string]any) { + t.Helper() + raw, _ := json.Marshal(body) + var got buscalls.Applied + if err := f.Call(buscalls.LedgerApply, buscalls.ApplyRequest{Ledger: LedgerName, Instance: "decision/0001", Operation: op, Body: raw}, &got); err != nil { + t.Fatalf("%s: %v", op, err) + } + } + apply(OpWriteSection, map[string]any{"title": "Context", "text": text, "digest": SectionDigest(text)}) + body := map[string]any{"kind": "decision", "number": 1} + for k, v := range c.body { + body[k] = v + } + apply(c.op, body) + var st map[string]any + _ = json.Unmarshal(mustJSON(t, f.state["decision/0001"]), &st) + pending, _ := st["pending-sections"].(map[string]any) + switch { + case c.clears && len(pending) != 0: + t.Errorf("%s leaves pending sections %v", c.op, pending) + case c.pendingKept && len(pending) != 1: + t.Errorf("%s does not keep the pending sections: %v", c.op, pending) + } + if c.op == OpReserve && len(pending) != 1 { + t.Errorf("reserve touched the pending sections: %v", pending) + } + if st["entry"] == nil || st["sequence"] != float64(2) { + t.Errorf("%s: head %v", c.op, st) + } + if a, _ := st["author"].(map[string]any); a["account"] != "mesh-knowledge" { + t.Errorf("%s: author %v", c.op, st["author"]) + } + if raw, _ := json.Marshal(st); json.Valid(raw) && strings.Contains(string(raw), text) { + t.Errorf("%s: the head holds the section's text", c.op) + } + } +} + +// A write-section whose digest is not its text's is refused by apply, and an operation the type does not allow is too. +func TestTheLedgerRefusesADigestThatIsNotTheTextsAndAnOperationTheTypeLacks(t *testing.T) { + f := newFakeLedger(t) + var got buscalls.Applied + bad, _ := json.Marshal(map[string]any{"title": "Context", "text": "a", "digest": SectionDigest("b")}) + if err := f.Call(buscalls.LedgerApply, buscalls.ApplyRequest{Ledger: LedgerName, Instance: "decision/0001", Operation: OpWriteSection, Body: bad}, &got); err == nil { + t.Error("a digest of another text was taken") + } + if err := f.Call(buscalls.LedgerApply, buscalls.ApplyRequest{Ledger: LedgerName, Instance: "decision/0001", Operation: "delete"}, &got); err == nil { + t.Error("an operation the type lacks was taken") + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go index f0b70606..80572703 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/main.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/main.go @@ -37,7 +37,7 @@ import ( const Seat = "knowledge-base" // Verbs are the seat's verbs, as the manifest declares and claims them (ADR 0290 rule 6). -var Verbs = []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return", "withdraw", "retract", "progress"} +var Verbs = []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "open", "write", "remove", "order", "submit", "drop", "take-over", "import", "return", "withdraw", "retract", "progress"} // HealthTool is the module's own tool the node-engine asks how it is. const HealthTool = "knowledge_health" @@ -73,7 +73,9 @@ func main() { } } st := &lazyStore{open: func(ctx context.Context) (*PgStore, error) { return PgStoreFromEnv(ctx) }} - k := newKB(st) + // The records are served from the database until the move sets the switch key, then from the ledger, with the + // database written beside it until it is dropped (move.go). + k := newKB(newMovableStore(st, newLedgerStore(sdkPipe{}, sdkStates{}), sdkStates{})) serve(k, os.Getenv("MESH_KNOWLEDGE_STATUS")) } @@ -81,17 +83,21 @@ func main() { func newKB(store Store) *KB { file := os.Getenv("MESH_KNOWLEDGE_SETTINGS") return &KB{ - Store: store, - Records: busRecords{}, - Seats: stdio.Ask, - Reserve: busReserver, - Judge: busJudgement(os.Getenv("MESH_NODE")), - Asks: sdkAskBus{}, - Notify: busNotifier, - Clear: busClearer, - Emit: func(event string, body any) error { return stdio.Emit(event, body) }, + Store: store, + Records: busRecords{}, + Seats: stdio.Ask, + Reserve: busReserver, + Judge: busJudgement(os.Getenv("MESH_NODE")), + Asks: sdkAskBus{}, + Notify: busNotifier, + Clear: busClearer, + Emit: func(event string, body any) error { return stdio.Emit(event, body) }, + EmitID: func(event string, body any, id string) error { + return stdio.Emit(event, body, stdio.EmitOptions{MessageID: id}) + }, Settings: func() (Settings, error) { return readSettings(file) }, Logf: logf, + States: sdkStates{}, } } @@ -99,8 +105,10 @@ func newKB(store Store) *KB { func serve(k *KB, status string) { k.kick = make(chan struct{}, 1) go hear(k) + go hearJudged(heardJudged) go background(k) - if err := stdio.Serve("", append(Tools(k, callerOf), healthTool(status, k.now), highestTool(k), marksTool(k))); err != nil { + go ServePage(k) + if err := stdio.Serve("", append(append(Tools(k, callerOf), healthTool(status, k.now, k), highestTool(k), marksTool(k)), moduleTools(k, callerOf)...)); err != nil { logf("%v", err) os.Exit(1) } @@ -130,6 +138,22 @@ func hear(k *KB) { } } +// hearJudged subscribes to the agent modules' word that a judgement ended, asked again until the runtime takes it. +func hearJudged(h *judgedHub) { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + err := stdio.Subscribe("claude-code.judged", func(e stdio.Envelope) error { + h.Heard(e.Body) + return nil + }) + if err == nil { + logf("[mesh-knowledge] hears when a judgement ends") + return + } + logf("[mesh-knowledge] does not hear when a judgement ends yet (%v); asking again in %s", err, wait) + time.Sleep(wait) + } +} + // background reads the router's record of every waiting ask once a minute, withdraws reservations never // proposed, reviews every proposal waiting for its review — at once when one is proposed — and drafts and // confirms clauses one step at a time, a review always before the next drafting step: the agent judges one @@ -138,6 +162,13 @@ func background(k *KB) { tick := time.NewTicker(time.Minute) defer tick.Stop() time.Sleep(5 * time.Second) + // The events of the operations a stop lost are sent first (to-be 55 §1). + if n, err := k.ResendEvents(context.Background()); err != nil { + logf("[mesh-knowledge] sending the events a stop lost: %v", err) + } else if n > 0 { + logf("[mesh-knowledge] sent %d event(s) a stop lost", n) + } + k.atStart() review := func() { for { said, err := k.ReviewStep() @@ -205,7 +236,10 @@ func busReserver(kind, title string) (int, error) { } // busJudgement asks the agent module on this node for one judgement (ADR 0278). -func busJudgement(node string) Judgement { return judgementOver(stdio.Ask, node) } +func busJudgement(node string) Judgement { return judgementOver(stdio.Ask, node, heardJudged) } + +// heardJudged is what the agent modules have said ended (the event `claude-code.judged`, heard in hear). +var heardJudged = newJudgedHub() // The bound a judgement asks scales with its question (novox/hq issue 508): ADR 0297, eleven clauses and eleven // links, was never reviewed within the agent module's 25 seconds, while short records took 14 to 25. Each KiB of @@ -225,9 +259,9 @@ func JudgeWithin(question string) time.Duration { } // judgementOver asks the agent module through ask. A bound past one bus call's 30 seconds is answered pending with -// a ticket when the judgement is not done in the call, and collected from the node that holds it with -// claude_code_judged (novox/hq issue 508). -func judgementOver(ask func(key string, body any) (json.RawMessage, error), node string) Judgement { +// a ticket when the judgement is not done in the call, and its end is heard as the agent module's event `judged` +// (novox/hq issue 508, ADR 0616 §5); nothing asks for it again. +func judgementOver(ask func(key string, body any) (json.RawMessage, error), node string, hub *judgedHub) Judgement { return func(question string, schema map[string]any, model string) (json.RawMessage, string, error) { key := "claude-code.claude_code_judge" if node != "" { @@ -244,9 +278,6 @@ func judgementOver(ask func(key string, body any) (json.RawMessage, error), node delete(body, "within") raw, err = ask(key, body) } - // Past the judgement's bound the agent module ends it and says so; the deadline only guards against a - // module that answers pending for ever. - deadline := time.Now().Add(within + time.Minute) for { if err != nil { return nil, "", err @@ -270,10 +301,19 @@ func judgementOver(ask func(key string, body any) (json.RawMessage, error), node if out.Ticket == "" || out.Node == "" { return nil, "", fmt.Errorf("the agent module answered pending with no ticket or node: %.200s", raw) } - if time.Now().After(deadline) { - return nil, "", fmt.Errorf("the agent on %s still had not answered %s after its bound of %s", out.Node, out.Ticket, within) + // The agent module ends the judgement at its bound and says so in its word; this wait only guards + // against a word that never comes. + w, err := hub.Wait(out.Node, out.Ticket, within+time.Minute) + if err != nil { + return nil, "", err } - raw, err = ask("claude-code.claude_code_judged@"+out.Node, map[string]any{"ticket": out.Ticket}) + if w.Error != "" { + return nil, "", errors.New(w.Error) + } + if len(w.Answer) == 0 { + return nil, "", fmt.Errorf("the agent on %s said ticket %s ended with no answer", out.Node, out.Ticket) + } + return w.Answer, fmt.Sprintf("the agent on %s (%s)", out.Node, w.Model), nil } } } @@ -604,14 +644,20 @@ func (k *KB) Health(ready Healther) HealthAnswer { return HealthAnswer{State: "healthy", Healthy: true, Why: why} } -func healthTool(status string, now func() time.Time) stdio.Tool { +func healthTool(status string, now func() time.Time, k *KB) stdio.Tool { return stdio.Tool{Name: HealthTool, Description: "How the knowledge base is, as its watch last saw it: healthy when its database serves this version, unhealthy " + "otherwise. A rule that judges the whole record set and fails — a decision accepted that nothing cites, a supersession " + "or a change said on one end only, a record extending a superseded one — is named but leaves it healthy: it is told " + "to the operator as a warning for the records' authors (novox/hq issue 504). So is a proposed record that ran out of " + "review tries: it waits on its author (novox/hq issue 508).", - Run: func(map[string]any) (any, error) { return ReadStatus(status, now()), nil }} + Run: func(map[string]any) (any, error) { + ans := ReadStatus(status, now()) + if k != nil { + ans = k.pageHealth(k.ledgerHealth(ans)) // what the ledger form owes once the records are on it, and the page (health.go) + } + return ans, nil + }} } // ---- the verbs -------------------------------------------------------------------------------------------------- @@ -755,8 +801,20 @@ func schema(required []string, props map[string]any) map[string]any { } // Tools are the seat's verbs, each said in plain words: what it does and what it refuses. caller is who called, -// as the runtime proves it. +// as the runtime proves it. A verb that writes names the tool call it serves to the ledger while it runs (noteCall). func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool { + tools := verbTools(k, caller) + for i := range tools { + run := tools[i].Run + tools[i].Run = func(m map[string]any) (any, error) { + defer k.noteCall(caller(m), m)() + return run(m) + } + } + return tools +} + +func verbTools(k *KB, caller func(map[string]any) string) []stdio.Tool { return []stdio.Tool{ {Name: Seat + ".ask", Description: "Ask what holds about something, in plain words — \"what holds about the merge window, and why\" — and get the " + @@ -972,41 +1030,47 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool { "clauses, each opening with a bold lead (\"**What is decided.** …\"), and its reasoning in sections (Context, " + "Considered Options, Consequences, How it is checked, References); for a research effort its status and documents; its " + "links (supersedes, changes \"0276 §4\", extends, became, cites), and whether it needs the operator, with the brief its ask shows. Refused, naming " + - "every reason at once and writing nothing, when it breaks one of the 30 rules that judge one record or its prose — a " + + "every reason at once and writing nothing, when it breaks one of the 31 rules that judge one record or its prose — a " + "link to nothing, a clause without a bold lead, a superseded record cited alone, an unknown topic, a retired word. A " + "proposal is revised by proposing it again; an accepted record (an active research effort or an idea excepted, which take new versions; at most as many research efforts are active as the setting research-active-at-most allows, three by default) changes only by a progressive insight (insight: what " + - "stood before). The knowledge base then reviews each version itself, in a judgement that sees only the record, the " + + "stood before). A research effort's new version may leave out its sections, and its title, date and deciders: it then " + + "keeps the served version's and takes the status, links and why given, as when an effort is closed (graduated or " + + "abandoned, with its became links). The knowledge base then reviews each version itself, in a judgement that sees only the record, the " + "records it links and the rules. A decision that names no home and that no accepted record links is not judged: " + "its review asks changes for the whole-set rule cited, which its acceptance would break. On approve it is accepted (a record that needs the operator waits for the operator's " + "Approve), on changes it stays proposed with the findings for its author to revise, and when no judgement can be had " + "it stays proposed and says so. Nothing proposed is in force.", - Input: schema([]string{"number", "title"}, map[string]any{ - "kind": kindArg(), "number": integer("the number reserve gave"), "title": text("one line"), - "topic": text("for a decision: a topic of the reading order, such as the mesh"), - "date": text("the day, YYYY-MM-DD (today when absent)"), - "deciders": texts("who decided"), - "clauses": texts("a decision's clauses in order, each \"**Lead.** the statement\""), - "sections": map[string]any{"type": "array", "description": "the reasoning, in order: each a name and its markdown", - "items": map[string]any{"type": "object", "properties": map[string]any{"name": text("such as Context"), "text": text("markdown")}, - "required": []string{"name", "text"}}}, - "links": map[string]any{"type": "array", "description": "its links: kind, to (\"0276\", \"0276 §4\", \"research 045\", \"issue 365\", a design's path), from (the clause it goes out from)", - "items": map[string]any{"type": "object", "properties": map[string]any{"kind": map[string]any{"type": "string", "enum": LinkKinds}, - "to": text("the target"), "from": integer("the clause of this record it goes out from")}, "required": []string{"kind", "to"}}}, - "homes": texts("hq documents that cite it, such as a design's path; a decision needs one unless an accepted record links it"), - "needs-operator": flag("it holds only once the operator approves it"), - "brief": map[string]any{"type": "object", "description": "required with needs-operator: what the operator's phone shows " + - "in place of the clauses (issue 639) — a headline of at most 60 characters saying what it changes for the operator, " + - "not the title; a summary of two or three short sentences, at most 300 characters, on what changes, why and what it " + - "costs or risks; at most three points of at most 160 characters. No path, address, dotted name or long word", - "properties": map[string]any{"headline": text("what it changes for the operator"), "summary": text("two or three short sentences"), - "points": texts("at most three, only where they add something")}, "required": []string{"headline", "summary"}}, - "status": map[string]any{"type": "string", "enum": ResearchStatuses, "description": "a research effort's status"}, - "insight": text("for an accepted record: a progressive insight, saying what stood before"), - "why": text("one line: why this version"), - }), + Input: schema([]string{"number"}, proposeProps()), Run: func(m map[string]any) (any, error) { - a, err := readArgs("propose", m, "kind", "number", "title", "topic", "date", "deciders", "clauses", "sections", - "links", "homes", "needs-operator", "brief", "status", "insight", "why") + p, err := proposalOf("propose", m, true) + if err != nil { + return nil, err + } + return k.Propose(caller(m), p) + }}, + {Name: Seat + ".open", + Description: "Open a draft of a reserved or held record, to write it one section at a time: its title, topic, date and " + + "deciders, for a decision its clauses, its links and homes, whether it needs the operator and the brief its ask shows. " + + "Called again by its author, it changes those. A held record's draft starts from the sections of the version being " + + "worked on, so a section left alone costs nothing. Then write each section, and submit. Refused: a caller the runtime " + + "could not prove, a number held of none, a draft by another author (take-over makes you its author), a number reserved " + + "by another caller.", + Input: schema([]string{"number"}, openProps()), + Run: func(m map[string]any) (any, error) { + p, err := proposalOf("open", m, false) + if err != nil { + return nil, err + } + return k.Open(caller(m), p) + }}, + {Name: Seat + ".write", + Description: "Write one section of your draft: its plain title and its markdown text, one call per section; a title already in " + + "the draft is replaced. The text is kept at once, so the draft survives this session and another can take it over. " + + "Refused: a section over 40 KiB (split it), a draft over 64 sections or 1 MiB, a draft by another author, no draft open.", + Input: schema([]string{"number", "title", "text"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"), + "title": text("the section's title, such as Context"), "text": text("the section's markdown")}), + Run: func(m map[string]any) (any, error) { + a, err := readArgs("write", m, "kind", "number", "title", "text") if err != nil { return nil, err } @@ -1014,39 +1078,114 @@ func Tools(k *KB, caller func(map[string]any) string) []stdio.Tool { if err != nil { return nil, err } - p := ProposeAsk{Kind: key.Kind, Number: key.Number} - for _, f := range []struct { - k string - p *string - }{{"title", &p.Title}, {"topic", &p.Topic}, {"date", &p.Date}, {"status", &p.Status}, {"insight", &p.Insight}, {"why", &p.Why}} { - if *f.p, err = a.str(f.k); err != nil { - return nil, err - } - } - for _, f := range []struct { - k string - p *[]string - }{{"deciders", &p.Deciders}, {"clauses", &p.Clauses}, {"homes", &p.Homes}} { - if *f.p, err = a.strs(f.k); err != nil { - return nil, err - } - } - if p.NeedsOperator, err = a.flag("needs-operator"); err != nil { + title, err := a.str("title") + if err != nil { return nil, err } - if err := decode(m["sections"], &p.Sections, "sections is a list of {name, text}"); err != nil { + body, err := a.str("text") + if err != nil { return nil, err } - if err := decode(m["links"], &p.Links, "links is a list of {kind, to, from}"); err != nil { + return k.Write(caller(m), key, title, body) + }}, + {Name: Seat + ".remove", + Description: "Remove one section, by its title, from your draft of a record. The text it held stays in the record's history, " + + "named by no version. Refused: a title the draft does not hold, a draft by another author, no draft open.", + Input: schema([]string{"number", "title"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"), "title": text("the section's title")}), + Run: func(m map[string]any) (any, error) { + a, err := readArgs("remove", m, "kind", "number", "title") + if err != nil { return nil, err } - if m["brief"] != nil { - p.Brief = &Brief{} - if err := decode(m["brief"], p.Brief, "brief is {headline, summary, points}"); err != nil { - return nil, err - } + key, err := a.key() + if err != nil { + return nil, err } - return k.Propose(caller(m), p) + title, err := a.str("title") + if err != nil { + return nil, err + } + return k.Remove(caller(m), key, title) + }}, + {Name: Seat + ".order", + Description: "Set the order of the sections of your draft: every title the draft holds, once, in the order the record reads. " + + "Refused: a list that leaves out or adds a title, a draft by another author, no draft open.", + Input: schema([]string{"number", "titles"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"), "titles": texts("every section's title, in order")}), + Run: func(m map[string]any) (any, error) { + a, err := readArgs("order", m, "kind", "number", "titles") + if err != nil { + return nil, err + } + key, err := a.key() + if err != nil { + return nil, err + } + titles, err := a.strs("titles") + if err != nil { + return nil, err + } + return k.Order(caller(m), key, titles) + }}, + {Name: Seat + ".submit", + Description: "Make your draft a version of the record: every rule that judges one record or its prose is checked and every " + + "reason of a refusal named at once, the draft kept as it was; on success the knowledge base reviews the version, a part " + + "at a time. Takes why, one line. Refused: no draft open, a draft by another author, a caller the runtime could not prove.", + Input: schema([]string{"number"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"), "why": text("one line: why this version")}), + Run: func(m map[string]any) (any, error) { + a, err := readArgs("submit", m, "kind", "number", "why") + if err != nil { + return nil, err + } + key, err := a.key() + if err != nil { + return nil, err + } + why, err := a.str("why") + if err != nil { + return nil, err + } + return k.SubmitDraft(caller(m), key, why) + }}, + {Name: Seat + ".drop", + Description: "Discard your draft of a record, with a reason in one line if you have one. The sections written stay in the " + + "record's history, named by no version. A draft untouched for 14 days is dropped, and its author told. Refused: a draft " + + "by another author, no draft open.", + Input: schema([]string{"number"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"), "reason": text("one line")}), + Run: func(m map[string]any) (any, error) { + a, err := readArgs("drop", m, "kind", "number", "reason") + if err != nil { + return nil, err + } + key, err := a.key() + if err != nil { + return nil, err + } + reason, err := a.str("reason") + if err != nil { + return nil, err + } + return k.DropDraft(caller(m), key, reason) + }}, + {Name: Seat + ".take-over", + Description: "Make yourself the author of a draft or a proposal another session wrote, with the reason in one line: only its " + + "author writes a draft or revises a proposal. The previous author is told, and the reason goes in the next version's " + + "message. Refused: no reason, a caller the runtime could not prove, a proposal waiting for the operator's Approve (its " + + "author withdraws it first), a record with neither a draft nor a proposal.", + Input: schema([]string{"number", "reason"}, map[string]any{"kind": kindArg(), "number": integer("the record's number"), "reason": text("one line: why you take it over")}), + Run: func(m map[string]any) (any, error) { + a, err := readArgs("take-over", m, "kind", "number", "reason") + if err != nil { + return nil, err + } + key, err := a.key() + if err != nil { + return nil, err + } + reason, err := a.str("reason") + if err != nil { + return nil, err + } + return k.TakeOver(caller(m), key, reason) }}, {Name: Seat + ".import", Description: "Import hq's decision records and research efforts, read through the records module at the commit its checkout " + @@ -1220,3 +1359,110 @@ func decode(v any, into any, says string) error { } return nil } + +// proposeProps are the arguments of propose; open takes the same but the sections, which are written one call each. +func proposeProps() map[string]any { + return map[string]any{ + "kind": kindArg(), "number": integer("the number reserve gave"), "title": text("one line; a research effort's new version keeps the served title when absent"), + "topic": text("for a decision: a topic of the reading order, such as the mesh"), + "date": text("the day, YYYY-MM-DD (today when absent)"), + "deciders": texts("who decided"), + "clauses": texts("a decision's clauses in order, each \"**Lead.** the statement\""), + "sections": map[string]any{"type": "array", "description": "the reasoning, in order: each a name and its markdown; a research effort's new version keeps the served version's when absent", + "items": map[string]any{"type": "object", "properties": map[string]any{"name": text("such as Context"), "text": text("markdown")}, + "required": []string{"name", "text"}}}, + "links": map[string]any{"type": "array", "description": "its links: kind, to (\"0276\", \"0276 §4\", \"research 045\", \"issue 365\", a design's path), from (the clause it goes out from)", + "items": map[string]any{"type": "object", "properties": map[string]any{"kind": map[string]any{"type": "string", "enum": LinkKinds}, + "to": text("the target"), "from": integer("the clause of this record it goes out from")}, "required": []string{"kind", "to"}}}, + "homes": texts("hq documents that cite it, such as a design's path; a decision needs one unless an accepted record links it"), + "needs-operator": flag("it holds only once the operator approves it"), + "brief": map[string]any{"type": "object", "description": "required with needs-operator: what the operator's phone shows " + + "in place of the clauses (issue 639) — a headline of at most 60 characters saying what it changes for the operator, " + + "not the title; a summary of two or three short sentences, at most 300 characters, on what changes, why and what it " + + "costs or risks; at most three points of at most 160 characters. No path, address, dotted name or long word", + "properties": map[string]any{"headline": text("what it changes for the operator"), "summary": text("two or three short sentences"), + "points": texts("at most three, only where they add something")}, "required": []string{"headline", "summary"}}, + "status": map[string]any{"type": "string", "enum": ResearchStatuses, "description": "a research effort's status"}, + "insight": text("for an accepted record: a progressive insight, saying what stood before"), + "why": text("one line: why this version"), + } +} + +func openProps() map[string]any { + p := proposeProps() + delete(p, "sections") + return p +} + +// proposalOf reads the arguments of propose (with its sections) or of open (without them). +func proposalOf(verb string, m map[string]any, withSections bool) (ProposeAsk, error) { + takes := []string{"kind", "number", "title", "topic", "date", "deciders", "clauses", "links", "homes", "needs-operator", "brief", "status", "insight", "why"} + if withSections { + takes = append(takes, "sections") + } + a, err := readArgs(verb, m, takes...) + if err != nil { + return ProposeAsk{}, err + } + key, err := a.key() + if err != nil { + return ProposeAsk{}, err + } + p := ProposeAsk{Kind: key.Kind, Number: key.Number} + for _, f := range []struct { + k string + p *string + }{{"title", &p.Title}, {"topic", &p.Topic}, {"date", &p.Date}, {"status", &p.Status}, {"insight", &p.Insight}, {"why", &p.Why}} { + if *f.p, err = a.str(f.k); err != nil { + return ProposeAsk{}, err + } + } + for _, f := range []struct { + k string + p *[]string + }{{"deciders", &p.Deciders}, {"clauses", &p.Clauses}, {"homes", &p.Homes}} { + if *f.p, err = a.strs(f.k); err != nil { + return ProposeAsk{}, err + } + } + if p.NeedsOperator, err = a.flag("needs-operator"); err != nil { + return ProposeAsk{}, err + } + if err := decode(m["sections"], &p.Sections, "sections is a list of {name, text}"); err != nil { + return ProposeAsk{}, err + } + if err := decode(m["links"], &p.Links, "links is a list of {kind, to, from}"); err != nil { + return ProposeAsk{}, err + } + if m["brief"] != nil { + p.Brief = &Brief{} + if err := decode(m["brief"], p.Brief, "brief is {headline, summary, points}"); err != nil { + return ProposeAsk{}, err + } + } + return p, nil +} + +// atStart does what a start owes once the records are on the ledger: the search index is built, and the check of the +// database against the ledger runs, every start until the database is dropped (to-be 55 §8.1). +func (k *KB) atStart() { + ms, ok := k.Store.(*movableStore) + if !ok || ms.Source() != SourceLedger { + return + } + ms.led.mu.Lock() + ms.led.indexAsync = true + ms.led.mu.Unlock() + go func() { + if err := ms.led.BuildIndex(context.Background()); err != nil { + logf("[mesh-knowledge] the search index could not be built: %v", err) + } + }() + diffs, err := ms.Check(context.Background()) + switch { + case err != nil: + logf("[mesh-knowledge] the check of the database against the ledger could not run: %v", err) + case len(diffs) > 0: + logf("[mesh-knowledge] the database differs from the ledger in %d place(s): %s", len(diffs), strings.Join(diffs, "; ")) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go index 76f77737..b65885b5 100644 --- a/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/manifest_test.go @@ -5,9 +5,12 @@ import ( "os" "path/filepath" "reflect" + "slices" "strings" "testing" "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" ) // The manifest says what the code does (novox/hq to-be 55 §1): the seat it declares and holds once for the mesh @@ -16,7 +19,7 @@ import ( type manifest struct { Module string `json:"module"` - Requires []string `json:"requires"` + Requires []json.RawMessage `json:"requires"` Contributes map[string]map[string]any `json:"contributes"` Seats []seat `json:"seats"` Claims []seat `json:"claims"` @@ -64,27 +67,28 @@ func TestItDeclaresAndHoldsTheKnowledgeBaseSeatOnceForTheMesh(t *testing.T) { } // ADR 0290 rule 6's verbs and those added after it; progress says a design's state, which ADR 0424 §1 makes the // knowledge base's to work out (novox/hq issue 1947). - if !reflect.DeepEqual(Verbs, []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "import", "return", "withdraw", "retract", "progress"}) { + if !reflect.DeepEqual(Verbs, []string{"ask", "get", "list", "findings", "holds", "links", "history", "reserve", "propose", "open", "write", "remove", "order", "submit", "drop", "take-over", "import", "return", "withdraw", "retract", "progress"}) { t.Fatalf("the verbs are not ADR 0290 rule 6's and those added after it: %v", Verbs) } if !reflect.DeepEqual(m.Emits, Events) { t.Fatalf("emits %v, the code says %v", m.Emits, Events) } - // Its records are in a database in the store (ADR 0290 rule 7), not in buckets. - if !reflect.DeepEqual(m.Requires, []string{"postgres-database"}) || m.Contributes["postgres-database"]["name"] != "knowledge" || len(m.State) != 0 { - t.Fatalf("requires %v, contributes %v, state %v", m.Requires, m.Contributes, m.State) + // Until the database is dropped on the operator's yes, the database stays asked for (novox/hq ADR 0592 §14, §15); the + // records and the module's other kept data are asked for beside it (TestItAsksForItsBusStateInTheTypedForm). + if string(m.Requires[0]) != `"postgres-database"` || m.Contributes["postgres-database"]["name"] != "knowledge" || len(m.State) != 0 { + t.Fatalf("requires %s, contributes %v, state %v", m.Requires, m.Contributes, m.State) } - // It asks the operator on the router, hears the router's word, and calls exactly what it calls. - if !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) || len(m.Consumes) != 0 { + // It asks the operator on the router, hears the router's word, and hears the judge's answers. + if !reflect.DeepEqual(m.Uses, []string{"operator-channel"}) || !reflect.DeepEqual(m.Consumes, append([]string{"claude-code.judged"}, overviewConsumes()...)) { t.Fatalf("uses %v, consumes %v", m.Uses, m.Consumes) } - if !reflect.DeepEqual(m.Invokes, []string{"seat:issue-tracker.reserve", "claude-code.claude_code_judge", "claude-code.claude_code_judged", "records.records_status", + if !reflect.DeepEqual(m.Invokes, []string{"seat:issue-tracker.reserve", "claude-code.claude_code_judge", "records.records_status", "records.records_list", "records.records_read", "seat:operator-channel.notify", "seat:issue-tracker.list", "seat:issue-tracker.get", - "seat:issue-tracker.milestones", "seat:issue-tracker.changes", "seat:mesh-delivery.deliveries", "seat:mesh-delivery.show", "seat:mesh-delivery.groups"}) { + "seat:issue-tracker.milestones", "seat:issue-tracker.changes", "seat:mesh-delivery.deliveries", "seat:mesh-delivery.show", "seat:mesh-delivery.groups", "seat:operator-channel.asks", "seat:mesh-hand-acts.hand-act"}) { t.Fatalf("invokes %v", m.Invokes) } src := "" - for _, f := range []string{"main.go", "hq.go", "progress.go", "progress_since.go", "milestones.go"} { + for _, f := range []string{"main.go", "hq.go", "progress.go", "progress_since.go", "milestones.go", "overview.go", "tools.go"} { src += readFile(t, f) } for _, inv := range m.Invokes { @@ -122,7 +126,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, HighestTool, MarksTool}) { + !reflect.DeepEqual(m.Tools, ModuleTools) { t.Fatalf("watch %v, tools %v", watch, m.Tools) } // The watch's verdict is whether the database serves, so its check names the provision it exercises: while the @@ -212,3 +216,74 @@ func TestTheHealthToolReadsTheWatchsVerdictAndSaysWhenItIsStale(t *testing.T) { t.Fatalf("a stale verdict: %+v", a) } } + +// Its kept data is asked for in `requires` in the typed long form, and the manifest reads whole as the mesh reads it +// (design 55 §1, §2.1; design 32 §4): the ledger of the records with its two types, the module's other states, and the +// stream of the parts' verdicts, kept for good, refusing when full, granted by a trusted config value. +func TestItAsksForItsBusStateInTheTypedForm(t *testing.T) { + _, raw := readManifest(t) + m, err := catalogue.Decode([]byte(raw)) + if err != nil { + t.Fatal(err) + } + var names []string + byName := map[string]catalogue.BusStateEntry{} + for _, e := range m.BusState { + names = append(names, e.Name) + byName[e.Name] = e + } + if !reflect.DeepEqual(names, []string{"records", "drafts", "asked", "findings", "sent", "reviews"}) { + t.Fatalf("bus state %v", names) + } + rec := byName["records"] + if rec.Storage != catalogue.StorageLedger || rec.Template != catalogue.TemplateLedger || len(rec.Types) != 2 { + t.Fatalf("records %+v", rec) + } + if !reflect.DeepEqual(rec.Types[0].Schema, rec.Types[1].Schema) || !reflect.DeepEqual(rec.Types[0].Operations, rec.Types[1].Operations) || + rec.Types[0].Name != KindDecision || rec.Types[1].Name != KindResearch { + t.Fatalf("the two types differ in more than their names") + } + have := map[string]bool{} + for _, op := range rec.Types[0].Operations { + have[op.Name] = true + } + for _, op := range LedgerOperations { + if !have[op] { + t.Errorf("the code applies the operation %s, which the ledger's types do not allow", op) + } + } + if len(have) != len(LedgerOperations) { + t.Errorf("the types allow %d operations, the code names %d", len(have), len(LedgerOperations)) + } + for _, n := range []string{"drafts", "asked", "findings", "sent"} { + if byName[n].Storage != catalogue.StorageKeyValue || byName[n].Template != catalogue.TemplateState { + t.Errorf("%s is %+v, a state", n, byName[n]) + } + } + rv := byName["reviews"] + var grant string + _ = json.Unmarshal(rv.Configuration[catalogue.OptionGrant], &grant) + if rv.Storage != catalogue.StorageStream || rv.Template != "" || grant != "readers" || string(rv.Configuration[catalogue.ConfigWhenFull]) != `"refuse"` { + t.Fatalf("reviews %+v", rv) + } + if !slices.Contains(catalogue.TerminalKeys(m), "readers") { + t.Fatalf("readers is not a terminal key (%v): the grant names a value only the controller's terminal or the operator's Approve sets", catalogue.TerminalKeys(m)) + } + for _, c := range m.Consumes { + if strings.Contains(c, "decided") { + t.Errorf("it consumes %s: an asker's decided family is read by its asker only (ADR 0616 §2)", c) + } + } + if len(m.Reads) != 0 { + t.Fatalf("it reads %v, and no module reads another's bus state (ADR 0596 §2)", m.Reads) + } +} + +// overviewConsumes are the events the overview page hears, in the order the manifest lists them. +func overviewConsumes() []string { + var out []string + for _, s := range []string{SourceTracker, SourceDelivery, SourceAsks} { + out = append(out, OverviewEvents[s]...) + } + return out +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/move.go b/modules/mesh-knowledge/cmd/mesh-knowledge/move.go new file mode 100644 index 00000000..f0be9b69 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/move.go @@ -0,0 +1,428 @@ +package main + +// The move from the database to the ledger (novox/hq to-be 55 §8.1, ADR 0592 §13 to §15, ADR 0597 §13): a dry run says what +// would be copied; the copy writes every version of every record as an operation (the old author kept apart, marked +// imported), with writes refused for its length (the window); a check compares the two record by record; the switch key is +// set only on a clean check; from then on the ledger is the source and the database is written too until it is dropped +// on the operator's yes, behind or not, and the check runs at every start before the drop. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" +) + +// The two places the records are served from. +const ( + SourceDatabase = "database" + SourceLedger = "ledger" +) + +const ( + switchKey = "switch" + sequenceKey = "database.sequence" + behindKey = "database.behind" + checkKey = "move.check" +) + +// ErrMoving is the answer of a write made while the records are copied. +var ErrMoving = errors.New("the knowledge base is moving its records to the ledger: writes wait until the copy is done and checked, a few minutes at most") + +// movableStore is the Store of a module that is moving: the database until the switch key says the ledger, then the ledger +// with the database written beside it. +type movableStore struct { + db Store + led *ledgerStore + st States + + mu sync.Mutex + moving bool + active string +} + +// newMovableStore is the store over both places; the switch key is read when it is first used. +func newMovableStore(db Store, led *ledgerStore, st States) *movableStore { + return &movableStore{db: db, led: led, st: st} +} + +func (m *movableStore) kv() KV { return m.st.State(findingsState) } + +// Source says where the records are served from, reading the switch key once. +func (m *movableStore) Source() string { + m.mu.Lock() + defer m.mu.Unlock() + if m.active == "" { + m.active = SourceDatabase + if e, err := m.kv().Get(switchKey); err == nil && e != nil { + var s string + if json.Unmarshal(e.Value, &s) == nil && s == SourceLedger { + m.active = SourceLedger + } + } + } + return m.active +} + +func (m *movableStore) cur() Store { + if m.Source() == SourceLedger { + return m.led + } + return m.db +} + +// OnLedger says the records are served from the ledger: a draft is written section by section only then. +func (m *movableStore) OnLedger() bool { return m.Source() == SourceLedger } + +func (m *movableStore) writable() error { + m.mu.Lock() + defer m.mu.Unlock() + if m.moving { + return ErrMoving + } + return nil +} + +func (m *movableStore) Get(ctx context.Context, k Key) (*Entry, error) { return m.cur().Get(ctx, k) } +func (m *movableStore) All(ctx context.Context) ([]*Entry, error) { return m.cur().All(ctx) } +func (m *movableStore) Versions(ctx context.Context, k Key) ([]Version, error) { + return m.cur().Versions(ctx, k) +} +func (m *movableStore) Search(ctx context.Context, q string, n int) ([]Hit, error) { + return m.cur().Search(ctx, q, n) +} +func (m *movableStore) Inbound(ctx context.Context, k Key) ([]Inbound, error) { + return m.cur().Inbound(ctx, k) +} +func (m *movableStore) SetFindings(ctx context.Context, f []Finding) error { + return m.cur().SetFindings(ctx, f) +} +func (m *movableStore) Findings(ctx context.Context) ([]Finding, error) { return m.cur().Findings(ctx) } +func (m *movableStore) Told(ctx context.Context, t string) (bool, error) { return m.cur().Told(ctx, t) } +func (m *movableStore) ToldOf(ctx context.Context, p string) ([]string, error) { + return m.cur().ToldOf(ctx, p) +} +func (m *movableStore) Untold(ctx context.Context, k string) error { return m.cur().Untold(ctx, k) } + +// Create writes to the place served from; once the ledger is, the database is written too (behind or not, ADR 0592 §14). +func (m *movableStore) Create(ctx context.Context, v Version) error { + if err := m.writable(); err != nil { + return err + } + if m.Source() != SourceLedger { + return m.db.Create(ctx, v) + } + if err := m.led.Create(ctx, v); err != nil { + return err + } + m.mirror(ctx, v.Record.Key(), func() error { return m.db.Create(ctx, v) }) + return nil +} + +func (m *movableStore) Save(ctx context.Context, v Version, current, clearPending bool, revision int64) error { + if err := m.writable(); err != nil { + return err + } + if m.Source() != SourceLedger { + return m.db.Save(ctx, v, current, clearPending, revision) + } + if err := m.led.Save(ctx, v, current, clearPending, revision); err != nil { + return err + } + m.mirror(ctx, v.Record.Key(), func() error { + e, err := m.db.Get(ctx, v.Record.Key()) + if err != nil || e == nil { + return fmt.Errorf("the database holds no %s: %v", v.Record.Key(), err) + } + return m.db.Save(ctx, v, current, clearPending, e.Revision) + }) + return nil +} + +// mirror writes the database beside the ledger. A write the database refuses is not the ledger's refusal: the ledger took +// it, and the database is behind; `database.behind` says which record, and the next check names it. +func (m *movableStore) mirror(ctx context.Context, k Key, write func() error) { + if err := write(); err != nil { + _, _ = m.kv().Put(behindKey, map[string]any{"record": k.String(), "error": err.Error(), "at": time.Now().UTC()}) + return + } + if e, err := m.kv().Get(sequenceKey); err == nil { + var n int + rev := uint64(0) + if e != nil { + _ = json.Unmarshal(e.Value, &n) + rev = e.Revision + } + if rev == 0 { + _, _ = m.kv().Create(sequenceKey, n+1) + } else { + _, _ = m.kv().Update(sequenceKey, n+1, rev) + } + } +} + +// The ledger's own capabilities, offered once the records are served from it. +func (m *movableStore) WriteSection(ctx context.Context, k Key, title, text string) (sectionRef, error) { + if err := m.writable(); err != nil { + return sectionRef{}, err + } + return m.led.WriteSection(ctx, k, title, text) +} +func (m *movableStore) SectionText(ctx context.Context, k Key, r sectionRef) (string, error) { + return m.led.SectionText(ctx, k, r) +} +func (m *movableStore) Working(ctx context.Context, k Key) ([]sectionRef, error) { + return m.led.Working(ctx, k) +} +func (m *movableStore) Known(k Key, refs []sectionRef) { m.led.Known(k, refs) } +func (m *movableStore) Operations(ctx context.Context, k Key) ([]OperationInfo, error) { + return m.led.Operations(ctx, k) +} + +// ---- the copy and its check ------------------------------------------------------------------------------------ + +// MoveAsk asks the move: a dry run says what would be copied and writes nothing; commit copies, checks and switches. +type MoveAsk struct { + DryRun bool `json:"dry-run,omitempty"` + Commit bool `json:"commit,omitempty"` +} + +// Moved is the answer to the move. +type Moved struct { + Records int `json:"records"` + Versions int `json:"versions"` + Sections int `json:"sections"` + Bytes int `json:"bytes"` + Dry bool `json:"dry-run,omitempty"` + Switched bool `json:"switched"` + Source string `json:"source"` + Problems []string `json:"differences,omitempty"` + Says string `json:"says"` +} + +// Move copies every record of the database as operations into the ledger. A dry run counts what it would copy. A commit +// refuses writes for its length, copies what the ledger does not hold yet (run again it changes nothing), checks the +// copy and sets the switch key only when the check is clean. +func (m *movableStore) Move(ctx context.Context, a MoveAsk, say func(string)) (*Moved, error) { + if a.DryRun == a.Commit { + return nil, errors.New("the move takes dry-run or commit, one of them: a dry run writes nothing and says what it would copy") + } + if m.Source() == SourceLedger { + return nil, errors.New("the records are already served from the ledger; the check says whether the database still matches") + } + all, err := m.db.All(ctx) + if err != nil { + return nil, fmt.Errorf("the database cannot be read: %v", err) + } + out := &Moved{Dry: a.DryRun, Source: SourceDatabase} + type plan struct { + e *Entry + versions []Version + } + var plans []plan + for _, e := range all { + vs, err := m.db.Versions(ctx, e.Current.Key()) + if err != nil { + return nil, err + } + plans = append(plans, plan{e, vs}) + out.Records++ + out.Versions += len(vs) + for _, v := range vs { + out.Sections += len(v.Record.Sections) + for _, s := range v.Record.Sections { + out.Bytes += len(s.Text) + } + } + } + if a.DryRun { + out.Says = fmt.Sprintf("A commit would copy %d records, %d versions, %d sections (%d bytes of text) into the ledger, and set the records to be served from it only if the check is clean. Nothing was written.", out.Records, out.Versions, out.Sections, out.Bytes) + return out, nil + } + m.mu.Lock() + m.moving = true + m.mu.Unlock() + defer func() { m.mu.Lock(); m.moving = false; m.mu.Unlock() }() + copied := 0 + for _, p := range plans { + k := p.e.Current.Key() + if held, err := m.led.Get(ctx, k); err != nil { + return nil, err + } else if held != nil && held.Versions == len(p.versions) { + continue // copied by an earlier run + } else if held != nil { + return nil, fmt.Errorf("the ledger holds %s with %d versions, the database %d: it is not a copy; nothing more was copied", k, held.Versions, len(p.versions)) + } + if err := m.led.Import(ctx, p.e, p.versions); err != nil { + return nil, fmt.Errorf("%s could not be copied (%v); %d records copied, the rest not, and nothing is switched", k, err, copied) + } + // The events of these versions were said when they were written in the database: none is owed. + _, _ = m.st.State(sentState).Put(sentKey(k), len(p.versions)) + copied++ + if say != nil && copied%100 == 0 { + say(fmt.Sprintf("copied %d of %d records", copied, len(plans))) + } + } + if f, err := m.db.Findings(ctx); err == nil { + _ = m.led.SetFindings(ctx, f) + } + if told, err := m.db.ToldOf(ctx, ""); err == nil { + for _, t := range told { + _, _ = m.led.Told(ctx, t) + } + } + problems, err := m.Check(ctx) + if err != nil { + return nil, err + } + out.Problems = problems + if len(problems) > 0 { + out.Says = fmt.Sprintf("The copy of %d records is not clean (%d difference(s)); nothing is switched and the database stays the source.", out.Records, len(problems)) + return out, nil + } + if _, err := m.kv().Put(switchKey, SourceLedger); err != nil { + return nil, fmt.Errorf("the check is clean and the switch could not be kept (%v): the database stays the source", err) + } + m.mu.Lock() + m.active = SourceLedger + m.mu.Unlock() + out.Switched, out.Source = true, SourceLedger + out.Says = fmt.Sprintf("%d records (%d versions) are copied and the check is clean: they are served from the ledger now, and the database is written beside it until it is dropped on the operator's yes.", out.Records, out.Versions) + return out, nil +} + +// MostDifferences is how many differences a check names. +const MostDifferences = 50 + +// Check compares the database with the ledger, record by record: the versions, the served and the proposed version whole, +// every section's text. It answers every difference, at most MostDifferences, and keeps the result in the state; a clean +// check is an empty answer. +func (m *movableStore) Check(ctx context.Context) ([]string, error) { + dbAll, err := m.db.All(ctx) + if err != nil { + return nil, fmt.Errorf("the database cannot be read: %v", err) + } + var diffs []string + add := func(format string, a ...any) { + if len(diffs) < MostDifferences { + diffs = append(diffs, fmt.Sprintf(format, a...)) + } + } + same := func(a, b *Record) bool { + if a == nil || b == nil { + return a == b + } + x, _ := json.Marshal(a) + y, _ := json.Marshal(b) + return string(x) == string(y) + } + seen := map[Key]bool{} + for _, d := range dbAll { + k := d.Current.Key() + seen[k] = true + l, err := m.led.Get(ctx, k) + if err != nil { + return nil, err + } + switch { + case l == nil: + add("%s is in the database and not in the ledger", k) + continue + case l.Versions != d.Versions || l.CurrentVersion != d.CurrentVersion || l.PendingVersion != d.PendingVersion: + add("%s: the ledger has %d versions (served %d, proposed %d), the database %d (served %d, proposed %d)", k, l.Versions, l.CurrentVersion, l.PendingVersion, d.Versions, d.CurrentVersion, d.PendingVersion) + case !same(l.Current, d.Current): + add("%s: the served version differs", k) + case !same(l.Pending, d.Pending): + add("%s: the proposed version differs", k) + } + dv, err := m.db.Versions(ctx, k) + if err != nil { + return nil, err + } + lv, err := m.led.Versions(ctx, k) + if err != nil { + return nil, err + } + if len(dv) == len(lv) { + for i := range dv { + if dv[i].N != lv[i].N || dv[i].Why != lv[i].Why || !same(dv[i].Record, lv[i].Record) { + add("%s: version %d differs", k, dv[i].N) + break + } + } + } + } + if all, err := m.led.All(ctx); err == nil { + for _, l := range all { + if k := l.Current.Key(); !seen[k] { + add("%s is in the ledger and not in the database", k) + } + } + } + if e, err := m.kv().Get(behindKey); err == nil && e != nil { + add("the database is behind: %s", strings.TrimSpace(string(e.Value))) + } + sort.Strings(diffs) + _, _ = m.kv().Put(checkKey, map[string]any{"differences": diffs, "at": time.Now().UTC(), "records": len(dbAll)}) + return diffs, nil +} + +// Import writes a record's versions into the ledger as `import` operations, the old author kept apart as imported and the +// ledger's author the module's own account (ADR 0597 §7), each section's text once; the last operation carries the head. +func (s *ledgerStore) Import(ctx context.Context, e *Entry, versions []Version) error { + s.mu.Lock() + defer s.mu.Unlock() + k := e.Current.Key() + if len(versions) == 0 { + return fmt.Errorf("%s has no version to copy", k) + } + var known []sectionRef + var head *entryHead + for i, v := range versions { + refs, err := s.write(ctx, k, v.Record, known) + if err != nil { + return err + } + known = append(known, refs...) + h := &entryHead{Versions: v.N} + // Each operation's head is the one the database held at that version; the last is the entry as it stands. + if i == len(versions)-1 { + h.CurrentVersion, h.PendingVersion = e.CurrentVersion, e.PendingVersion + } else { + h.CurrentVersion = v.N + } + for _, side := range []struct { + n int + to **storedRecord + }{{h.CurrentVersion, &h.Current}, {h.PendingVersion, &h.Pending}} { + if side.n == 0 { + continue + } + for _, w := range versions { + if w.N == side.n { + r, err := s.write(ctx, k, w.Record, known) + if err != nil { + return err + } + *side.to = ptr(stripped(w.Record, r)) + } + } + } + head = h + body := map[string]any{"kind": k.Kind, "number": k.Number, "entry": head, + "version": versionHead{N: v.N, By: v.By, ImportedBy: v.By, At: v.At, Why: v.Why, Insight: v.Insight, Record: stripped(v.Record, refs)}, + "pending-sections": map[string]any{}} + seq, err := s.apply(ctx, k, OpImport, body) + if err != nil { + return err + } + s.heads[k] = &headState{Kind: k.Kind, Number: k.Number, Entry: head, Sequence: seq} + } + s.hold(k) + s.indexed(e.Current, true) + return nil +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/move_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/move_test.go new file mode 100644 index 00000000..2092defb --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/move_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" +) + +// The move (to-be 55 §8.1, AC53): a dry run writes nothing; the copy writes every version as an operation with the old +// author kept apart; writes wait while it runs; the check names every difference; the switch is set only on a clean check; +// from then on the ledger is served and the database written beside it, behind or not; run again nothing is copied twice. +func TestTheMoveCopiesChecksAndSwitchesOnlyOnACleanCheck(t *testing.T) { + ctx := context.Background() + db := newMemStore() + seed := imported(t, db) // the database holds the fixture's records, one version each + n := seed.reserved(t, "author@g14") + seed.accepted(t, "author@g14", valid(n)) + f := newFakeLedger(t) + states := newFakeStates() + led := newLedgerStore(f, states) + m := newMovableStore(db, led, states) + w := newWorld(t, m, fixture()) + w.k.States = states + w.tracker.next[KindDecision] = n + 1 // the numbers seed took are spent + + if _, err := m.Move(ctx, MoveAsk{}, nil); err == nil { + t.Fatal("a move that is neither a dry run nor a commit") + } + dry, err := m.Move(ctx, MoveAsk{DryRun: true}, nil) + if err != nil || dry.Records < 10 || dry.Versions < dry.Records || len(f.applied) != 0 || m.Source() != SourceDatabase { + t.Fatalf("%v %+v, %d operations", err, dry, len(f.applied)) + } + // Writes wait while the copy runs, and the copy says how far it is. + refused := false + m2 := newMovableStore(db, newLedgerStore(newFakeLedger(t), newFakeStates()), states) + m2.moving = true + if err := m2.Create(ctx, Version{Record: ledgerRecord(900, StatusReserved)}); errors.Is(err, ErrMoving) { + refused = true + } + if !refused { + t.Fatal("a write was taken while the records move") + } + // A difference stops the switch: a record changed in the database after the copy is named by the check. + moved, err := m.Move(ctx, MoveAsk{Commit: true}, nil) + if err != nil || !moved.Switched || len(moved.Problems) != 0 || m.Source() != SourceLedger || moved.Records != dry.Records { + t.Fatalf("%v %+v", err, moved) + } + // The old author is kept apart, the module's account is the ledger's. + var imports int + for _, a := range f.applied { + if a.Operation == OpImport { + imports++ + } + } + if imports < moved.Versions { + t.Fatalf("%d import operations for %d versions", imports, moved.Versions) + } + last := f.hist[InstanceOf(Key{KindDecision, n})] + if op := last[len(last)-1]; op.Operation != OpImport || op.Author.Account != "mesh-knowledge" || op.Author.Caller != "" || !strings.Contains(string(op.Body), `"imported-by":"the agent on test`) { + t.Fatalf("the old author is not kept apart from the ledger's: %+v", op.Author) + } + // Run again: nothing is copied twice, and it is refused as the switch is set. + if _, err := m.Move(ctx, MoveAsk{Commit: true}, nil); err == nil { + t.Fatal("a second move was taken") + } + // Served from the ledger, and a write goes to both: the database is written beside it. + before := len(f.applied) + m2n := w.reserved(t, "author@g14") + if len(f.applied) == before { + t.Fatal("a reservation after the switch did not reach the ledger") + } + if e, _ := db.Get(ctx, Key{KindDecision, m2n}); e == nil { + t.Fatal("the database is not written beside the ledger") + } + if diffs, err := m.Check(ctx); err != nil || len(diffs) != 0 { + t.Fatalf("a check after the writes: %v %v", err, diffs) + } + // The database changed behind the ledger's back, and a write it refuses: the check names both. + e, _ := db.Get(ctx, Key{KindDecision, n}) + r := e.Current.Clone() + r.Title = "Changed in the database only" + if err := db.Save(ctx, Version{By: "x", At: w.clock, Why: "x", Record: r}, true, false, e.Revision); err != nil { + t.Fatal(err) + } + diffs, _ := m.Check(ctx) + if len(diffs) == 0 || !strings.Contains(strings.Join(diffs, "\n"), "ADR 0300") { + t.Fatalf("the check missed a difference: %v", diffs) + } +} + +// A copy that is not clean leaves the database the source. +func TestACopyThatIsNotCleanLeavesTheDatabaseTheSource(t *testing.T) { + ctx := context.Background() + db := newMemStore() + imported(t, db) + f := newFakeLedger(t) + states := newFakeStates() + m := newMovableStore(db, newLedgerStore(f, states), states) + f.failApply = OpImport + if _, err := m.Move(ctx, MoveAsk{Commit: true}, nil); err == nil || m.Source() != SourceDatabase { + t.Fatalf("a copy the ledger refused switched: %v", err) + } + f.failApply = "" + // A mirror the database refuses is written down: the ledger took the write, and the database is behind. + if moved, err := m.Move(ctx, MoveAsk{Commit: true}, nil); err != nil || !moved.Switched { + t.Fatalf("%v %+v", err, moved) + } + m.db = &failingSaves{memStore: db, key: Key{KindDecision, 11}, fail: true} + e, _ := m.Get(ctx, Key{KindDecision, 11}) + r := e.Current.Clone() + r.Title = "Changed" + if err := m.Save(ctx, Version{By: "x", Record: r, At: e.Current.AcceptedAt}, true, false, e.Revision); err != nil { + t.Fatalf("the ledger's write is refused for the database's: %v", err) + } + if kv, _ := states.State(findingsState).Get(behindKey); kv == nil { + t.Fatal("a database behind is not written down") + } + if diffs, _ := m.Check(ctx); !strings.Contains(strings.Join(diffs, "\n"), "database is behind") { + t.Fatalf("%v", diffs) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/overview.go b/modules/mesh-knowledge/cmd/mesh-knowledge/overview.go new file mode 100644 index 00000000..5b53fbee --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/overview.go @@ -0,0 +1,521 @@ +package main + +// The overview page (novox/hq to-be 55 §11, ADR 0616): one read-only page for the operator with three blocks: every design in +// progress with its milestones and their issues; the decisions and research efforts accepted in the last seven days and those +// waiting; and what waits on the operator (records waiting for an Approve, the operator channel's open asks, the delivery +// groups waiting). Built from mesh paths alone: the module's own ledger, and three other modules' verbs for the first view and +// their events after it. It never polls: a source is asked again only when it is marked unanswered, on a bounded backoff. + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "sync" + "time" +) + +// The sources of the page, besides the module's own ledger. +const ( + SourceTracker = "issue-tracker" + SourceDelivery = "mesh-delivery" + SourceAsks = "operator-channel" +) + +// The verbs of the first view, and the events that keep each source current. +const ( + TrackerMilestonesVerb = "seat:issue-tracker.milestones" + TrackerListVerb = "seat:issue-tracker.list" + DeliveryGroupsVerb = "seat:mesh-delivery.groups" + AsksVerb = "seat:operator-channel.asks" +) + +// OverviewEvents are the events the page consumes, by source, as the manifest declares them. +var OverviewEvents = map[string][]string{ + SourceTracker: {"mesh-issues.opened", "mesh-issues.moved", "mesh-issues.milestone-set", "mesh-issues.milestone-moved"}, + SourceDelivery: {"mesh-delivery-gate.group", "mesh-delivery-gate.transition", "mesh-delivery-gate.merged"}, + SourceAsks: {"messenger.offered", "messenger.closed"}, +} + +// The unanswered marks: a verb that did not answer, or a subscription that missed its heartbeat. +const ( + MarkVerb = "verb" + MarkSubscription = "subscription" +) + +// Backoff for a source marked from its verb: from 10 seconds, doubling, at most every 5 minutes (ADR 0616 §6). +const ( + BackoffFrom = 10 * time.Second + BackoffMost = 5 * time.Minute + // HeartbeatWithin is how long a subscription may be silent before its source is marked. + HeartbeatWithin = 90 * time.Second + // RecentFor is the window of the accepted records block, counted from the ledger's dates when the page is answered. + RecentFor = 7 * 24 * time.Hour +) + +// askRow is an open ask of the operator channel. +type askRow struct { + Key string `json:"key"` + Asker string `json:"asker"` + Headline string `json:"headline"` + State string `json:"state,omitempty"` + Opened string `json:"opened,omitempty"` + Offered []string `json:"offered_on,omitempty"` +} + +// sourceMark says a source did not answer, and why. +type sourceMark struct { + Cause string `json:"cause"` + Why string `json:"why"` + Since time.Time `json:"since"` + // Next is when the verb is asked again (a mark from the verb only). + Next time.Time `json:"next,omitempty"` + Backoff time.Duration `json:"-"` +} + +// SourceState is one source on the page: answered, or marked, with the time of its last answer. +type SourceState struct { + Name string `json:"name"` + Answered bool `json:"answered"` + Mark *sourceMark `json:"mark,omitempty"` + Last time.Time `json:"last-answer,omitempty"` +} + +// RecordRow is a record on the page. +type RecordRow struct { + Record string `json:"record"` + Title string `json:"title"` + Status string `json:"status"` + Date string `json:"date,omitempty"` + Waits string `json:"waits,omitempty"` +} + +// MilestoneRow is a milestone with its issues on the page. +type MilestoneRow struct { + Design int `json:"design"` + ID string `json:"id"` + Outcome string `json:"outcome"` + Status string `json:"status"` + Issues []IssueRow `json:"issues"` +} + +// IssueRow is an issue of a milestone. +type IssueRow struct { + Number int `json:"number"` + Title string `json:"title,omitempty"` + Status string `json:"status,omitempty"` +} + +// OverviewModel is the page's model, as the page renders it and the tool answers it: the same data. +type OverviewModel struct { + At time.Time `json:"at"` + Designs []MilestoneRow `json:"milestones"` + Accepted []RecordRow `json:"accepted-in-seven-days"` + Waiting []RecordRow `json:"records-waiting"` + Operator OperatorBlock `json:"waiting-on-the-operator"` + Sources []SourceState `json:"sources"` + LastRebuilt time.Time `json:"last-rebuilt"` +} + +// OperatorBlock is what waits on the operator. +type OperatorBlock struct { + Records []RecordRow `json:"records"` + Asks []askRow `json:"asks"` + Groups []groupRow `json:"delivery-groups"` +} + +// Overview is the page's model in memory, kept by events. +type Overview struct { + // Ask reaches a verb on a seat; Records reads the module's own ledger; Now is the clock. + Ask SeatAsk + Records func() ([]*Entry, error) + Now func() time.Time + + mu sync.Mutex + milestones map[string]trackerMilestone + issues map[int]trackerRow + groups map[string]groupRow + asks map[string]askRow + state map[string]*SourceState + rebuilt time.Time + // verbCalls counts the verbs asked, so a test sees that an event asked none. + verbCalls map[string]int +} + +// NewOverview is a model with no source answered: Start asks each source's verb once. +func NewOverview(ask SeatAsk, records func() ([]*Entry, error), now func() time.Time) *Overview { + o := &Overview{Ask: ask, Records: records, Now: now, milestones: map[string]trackerMilestone{}, issues: map[int]trackerRow{}, + groups: map[string]groupRow{}, asks: map[string]askRow{}, state: map[string]*SourceState{}, verbCalls: map[string]int{}} + for _, s := range []string{SourceTracker, SourceDelivery, SourceAsks} { + o.state[s] = &SourceState{Name: s} + } + return o +} + +func (o *Overview) now() time.Time { + if o.Now != nil { + return o.Now().UTC() + } + return time.Now().UTC() +} + +// rowsOf reads the rows a verb answers: a bare list, or an object holding one list. +func rowsOf(raw json.RawMessage, into any, field string) error { + if err := json.Unmarshal(raw, into); err == nil { + return nil + } + var wrapped map[string]json.RawMessage + if err := json.Unmarshal(raw, &wrapped); err != nil { + return err + } + if v, ok := wrapped[field]; ok { + return json.Unmarshal(v, into) + } + return fmt.Errorf("no %s in the answer", field) +} + +func milestoneKey(design int, id string) string { return fmt.Sprintf("%d/%s", design, id) } + +// Rebuild asks a source's verb and rebuilds its block from the answer, clearing its mark; a verb that does not answer marks +// the source from the verb, with the next try on the backoff. It is the first view at start, the answer to a mark, and the +// hand act of the re-read tool. +func (o *Overview) Rebuild(source string) error { + o.mu.Lock() + defer o.mu.Unlock() + return o.rebuildLocked(source) +} + +func (o *Overview) rebuildLocked(source string) error { + st := o.state[source] + if st == nil { + return fmt.Errorf("%q is no source of the page (%s)", source, strings.Join(o.sourceNames(), ", ")) + } + call := func(verb string, body any) (json.RawMessage, error) { + o.verbCalls[verb]++ + return o.Ask(verb, body) + } + fail := func(why string) error { + prev := st.Mark + back := BackoffFrom + if prev != nil && prev.Cause == MarkVerb { + back = min(prev.Backoff*2, BackoffMost) + } + since := o.now() + if prev != nil { + since = prev.Since + } + st.Answered, st.Mark = false, &sourceMark{Cause: MarkVerb, Why: why, Since: since, Next: o.now().Add(back), Backoff: back} + return fmt.Errorf("%s does not answer: %s", source, why) + } + switch source { + case SourceTracker: + raw, err := call(TrackerMilestonesVerb, map[string]any{}) + if err != nil { + return fail(err.Error()) + } + var ms []trackerMilestone + if err := rowsOf(raw, &ms, "milestones"); err != nil { + return fail("milestones answered something else: " + err.Error()) + } + raw, err = call(TrackerListVerb, map[string]any{}) + if err != nil { + return fail(err.Error()) + } + var rows []trackerRow + if err := rowsOf(raw, &rows, "issues"); err != nil { + return fail("list answered something else: " + err.Error()) + } + o.milestones, o.issues = map[string]trackerMilestone{}, map[int]trackerRow{} + for _, m := range ms { + o.milestones[milestoneKey(m.Design, m.ID)] = m + } + for _, r := range rows { + o.issues[r.Number] = r + } + case SourceDelivery: + raw, err := call(DeliveryGroupsVerb, map[string]any{}) + if err != nil { + return fail(err.Error()) + } + var gs []groupRow + if err := rowsOf(raw, &gs, "groups"); err != nil { + return fail("groups answered something else: " + err.Error()) + } + o.groups = map[string]groupRow{} + for _, g := range gs { + o.groups[g.ID] = g + } + case SourceAsks: + raw, err := call(AsksVerb, map[string]any{}) + if err != nil { + return fail(err.Error()) + } + var as []askRow + if err := rowsOf(raw, &as, "asks"); err != nil { + return fail("asks answered something else: " + err.Error()) + } + o.asks = map[string]askRow{} + for _, a := range as { + if a.State == "" || a.State == "open" || a.State == "held" { + o.asks[a.Key] = a + } + } + } + st.Answered, st.Mark, st.Last = true, nil, o.now() + o.rebuilt = o.now() + return nil +} + +func (o *Overview) sourceNames() []string { + out := make([]string, 0, len(o.state)) + for n := range o.state { + out = append(out, n) + } + sort.Strings(out) + return out +} + +// Start is the first view: every source's verb asked once. A verb that does not answer marks its source. +func (o *Overview) Start() { + for _, s := range []string{SourceTracker, SourceDelivery, SourceAsks} { + _ = o.Rebuild(s) + } +} + +// Due asks again, once, every source marked from its verb whose backoff has run out: the only asks the page makes after start +// besides the one a subscription's return makes. It answers the sources asked. +func (o *Overview) Due() []string { + o.mu.Lock() + defer o.mu.Unlock() + var asked []string + for _, s := range o.sourceNames() { + if m := o.state[s].Mark; m != nil && m.Cause == MarkVerb && !o.now().Before(m.Next) { + _ = o.rebuildLocked(s) + asked = append(asked, s) + } + } + return asked +} + +// Silent marks the sources whose subscription has been silent past its bound: nothing is asked while it is silent. +func (o *Overview) Silent() []string { + o.mu.Lock() + defer o.mu.Unlock() + var marked []string + for _, s := range o.sourceNames() { + st := o.state[s] + if st.Answered && st.Mark == nil && !st.Last.IsZero() && o.now().Sub(st.Last) > HeartbeatWithin { + st.Answered, st.Mark = false, &sourceMark{Cause: MarkSubscription, Why: fmt.Sprintf("its subscription missed the bus's heartbeat for %s", HeartbeatWithin), Since: o.now()} + marked = append(marked, s) + } + } + return marked +} + +// Heard takes an event, or a heartbeat when event is empty, of a source into the model. A source marked from its subscription +// is asked once, on its return, to rebuild what it missed; that answer clears the mark. +func (o *Overview) Heard(source, event string, body json.RawMessage) { + o.mu.Lock() + defer o.mu.Unlock() + st := o.state[source] + if st == nil { + return + } + if st.Mark != nil && st.Mark.Cause == MarkSubscription { + _ = o.rebuildLocked(source) + if !st.Answered { + return + } + } + if st.Mark == nil { + st.Last = o.now() + } + if event == "" { + return + } + var f struct { + Number int `json:"number"` + Status string `json:"status"` + To string `json:"to"` + State string `json:"state"` + Title string `json:"title"` + Design int `json:"design"` + ID string `json:"id"` + Group string `json:"group"` + Outcome string `json:"outcome"` + Issues []int `json:"issues"` + Members []string `json:"members"` + Why string `json:"why"` + Key string `json:"key"` + Asker string `json:"asker"` + Headline string `json:"headline"` + Offered []string `json:"offered_on"` + Channels []string `json:"channels"` + } + if json.Unmarshal(body, &f) != nil { + return + } + name := event[strings.LastIndexByte(event, '.')+1:] + switch source { + case SourceTracker: + switch name { + case "opened": + if f.Number > 0 { + o.issues[f.Number] = trackerRow{Number: f.Number, Title: f.Title, Status: firstNonEmpty(f.Status, "open")} + } + case "moved": + if r, ok := o.issues[f.Number]; ok { + r.Status = firstNonEmpty(f.To, f.Status, r.Status) + o.issues[f.Number] = r + } else if f.Number > 0 { + o.issues[f.Number] = trackerRow{Number: f.Number, Title: f.Title, Status: firstNonEmpty(f.To, f.Status)} + } + case "milestone-set", "milestone-moved": + k := milestoneKey(f.Design, f.ID) + m := o.milestones[k] + m.Design, m.ID = f.Design, f.ID + m.Outcome, m.Issues = firstNonEmpty(f.Outcome, m.Outcome), append([]int(nil), firstInts(f.Issues, m.Issues)...) + m.Status = firstNonEmpty(f.To, f.Status, m.Status) + o.milestones[k] = m + } + case SourceDelivery: + id := firstNonEmpty(f.Group, f.ID) + switch name { + case "group": + g := o.groups[id] + g.ID, g.State, g.Why = id, firstNonEmpty(f.State, f.To, g.State), firstNonEmpty(f.Why, g.Why) + g.Members = firstStrings(f.Members, g.Members) + o.groups[id] = g + case "transition": + if g, ok := o.groups[id]; ok { + g.State = firstNonEmpty(f.To, f.State, g.State) + g.Why = firstNonEmpty(f.Why, g.Why) + o.groups[id] = g + } + case "merged": + delete(o.groups, id) + } + case SourceAsks: + switch name { + case "offered": + k := firstNonEmpty(f.Key, f.Asker+"."+f.ID) + o.asks[k] = askRow{Key: k, Asker: f.Asker, Headline: f.Headline, Offered: firstStrings(f.Offered, f.Channels)} + case "closed": + delete(o.asks, firstNonEmpty(f.Key, f.Asker+"."+f.ID)) + } + } + o.rebuilt = o.now() +} + +func firstInts(a, b []int) []int { + if len(a) > 0 { + return a + } + return b +} + +func firstStrings(a, b []string) []string { + if len(a) > 0 { + return a + } + return b +} + +// Model is the page's model at the time asked: the ledger's dates are counted now, the other blocks are what the events kept. +func (o *Overview) Model() (*OverviewModel, error) { + var entries []*Entry + if o.Records != nil { + var err error + if entries, err = o.Records(); err != nil { + return nil, fmt.Errorf("the records cannot be read: %v", err) + } + } + o.mu.Lock() + defer o.mu.Unlock() + now := o.now() + m := &OverviewModel{At: now, LastRebuilt: o.rebuilt} + var ms []trackerMilestone + for _, t := range o.milestones { + ms = append(ms, t) + } + sort.Slice(ms, func(i, j int) bool { + if ms[i].Design != ms[j].Design { + return ms[i].Design < ms[j].Design + } + return ms[i].ID < ms[j].ID + }) + for _, t := range ms { + if t.Status == "done" || t.Status == "dropped" { + continue + } + row := MilestoneRow{Design: t.Design, ID: t.ID, Outcome: t.Outcome, Status: t.Status, Issues: []IssueRow{}} + for _, n := range t.Issues { + i := o.issues[n] + row.Issues = append(row.Issues, IssueRow{Number: n, Title: i.Title, Status: i.Status}) + } + m.Designs = append(m.Designs, row) + } + cutoff := now.Add(-RecentFor) + for _, e := range entries { + r := e.Working() + row := RecordRow{Record: r.Key().String(), Title: r.Title, Status: r.Status, Date: r.Date} + switch { + case e.Current.Accepted() && acceptedAt(e.Current).After(cutoff): + m.Accepted = append(m.Accepted, row) + case r.Status == StatusProposed: + if r.NeedsOperator && r.Ask != nil && r.Ask.Said == "waiting" { + row.Waits = "the operator's Approve" + m.Operator.Records = append(m.Operator.Records, row) + } else if r.Judging != nil && r.Judging.State == JudgingFailed { + row.Waits = "its review, which could not be had" + } else { + row.Waits = "its review" + } + m.Waiting = append(m.Waiting, row) + } + } + sort.Slice(m.Accepted, func(i, j int) bool { return m.Accepted[i].Record > m.Accepted[j].Record }) + for _, a := range o.asks { + m.Operator.Asks = append(m.Operator.Asks, a) + } + sort.Slice(m.Operator.Asks, func(i, j int) bool { return m.Operator.Asks[i].Key < m.Operator.Asks[j].Key }) + for _, g := range o.groups { + if g.State != "merged" { + m.Operator.Groups = append(m.Operator.Groups, g) + } + } + sort.Slice(m.Operator.Groups, func(i, j int) bool { return m.Operator.Groups[i].ID < m.Operator.Groups[j].ID }) + for _, n := range o.sourceNames() { + m.Sources = append(m.Sources, *o.state[n]) + } + return m, nil +} + +// acceptedAt is when a record was accepted here, or its day for one imported accepted. +func acceptedAt(r *Record) time.Time { + if !r.AcceptedAt.IsZero() { + return r.AcceptedAt + } + t, _ := time.Parse("2006-01-02", r.Date) + return t +} + +// Unanswered are the sources marked, each with its cause, for the module's health: degraded while any is. +func (o *Overview) Unanswered() []SourceState { + o.mu.Lock() + defer o.mu.Unlock() + var out []SourceState + for _, n := range o.sourceNames() { + if !o.state[n].Answered { + out = append(out, *o.state[n]) + } + } + return out +} + +// Calls is how many times a verb was asked. +func (o *Overview) Calls(verb string) int { + o.mu.Lock() + defer o.mu.Unlock() + return o.verbCalls[verb] +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/overview_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/overview_test.go new file mode 100644 index 00000000..379cb4bf --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/overview_test.go @@ -0,0 +1,217 @@ +package main + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" +) + +// overviewWorld is a page model over the sources played here: what each verb answers, and what fails. +type overviewWorld struct { + o *Overview + now time.Time + answers map[string]string + fail map[string]bool + entries []*Entry +} + +func newOverviewWorld(t *testing.T) *overviewWorld { + t.Helper() + w := &overviewWorld{now: time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC), fail: map[string]bool{}, answers: map[string]string{ + TrackerMilestonesVerb: `{"milestones":[{"design":47,"id":"1","outcome":"A merge is never dropped","status":"open","issues":[973,525]},{"design":47,"id":"2","outcome":"Done thing","status":"done","issues":[1]}]}`, + TrackerListVerb: `[{"number":973,"title":"The window","status":"fixing"},{"number":525,"title":"The queue","status":"open"}]`, + DeliveryGroupsVerb: `[{"id":"fix/a","state":"held","why":"a member is held","members":["x@1"]}]`, + AsksVerb: `{"asks":[{"key":"mesh-knowledge.1","asker":"mesh-knowledge","headline":"Approve ADR 0301","state":"open","offered_on":["telegram"]},{"key":"ask-0","asker":"x","headline":"old","state":"answered"}]}`, + }} + w.o = NewOverview(func(key string, body any) (json.RawMessage, error) { + if w.fail[key] { + return nil, errors.New("no answer in time") + } + return json.RawMessage(w.answers[key]), nil + }, func() ([]*Entry, error) { return w.entries, nil }, func() time.Time { return w.now }) + return w +} + +func recordEntry(n int, status, date string, accepted time.Time, mut func(*Record)) *Entry { + r := &Record{Kind: KindDecision, Number: n, Title: "Record " + itoa(n), Status: status, Date: date, AcceptedAt: accepted} + if mut != nil { + mut(r) + } + return &Entry{Current: r, CurrentVersion: 1, Versions: 1} +} + +// The page's model (ADR 0616 §1-3, AC P1): three blocks built from the ledger, the tracker's milestones and issues, the delivery +// groups and the operator channel's asks; events change the blocks and ask no verb; a record accepted eight days before the +// answer is not in the seven-day block, with no event between. +func TestThePageModelHasThreeBlocksKeptByEventsAndAsksNoVerbAgain(t *testing.T) { + w := newOverviewWorld(t) + w.entries = []*Entry{ + recordEntry(301, StatusAccepted, "2026-11-02", w.now.Add(-2*24*time.Hour), nil), + recordEntry(302, StatusAccepted, "2026-10-20", w.now.Add(-6*24*time.Hour-23*time.Hour), nil), + recordEntry(303, StatusProposed, "2026-11-04", time.Time{}, func(r *Record) { + r.NeedsOperator = true + r.Ask = &OperatorAsk{ID: "a", Said: "waiting"} + }), + recordEntry(304, StatusProposed, "2026-11-04", time.Time{}, nil), + } + w.o.Start() + m, err := w.o.Model() + if err != nil { + t.Fatal(err) + } + if len(m.Designs) != 1 || m.Designs[0].ID != "1" || len(m.Designs[0].Issues) != 2 || m.Designs[0].Issues[0].Title != "The window" { + t.Fatalf("the design block: %+v", m.Designs) + } + if len(m.Accepted) != 2 || len(m.Waiting) != 2 || len(m.Operator.Records) != 1 || m.Operator.Records[0].Record != "ADR 0303" || + len(m.Operator.Asks) != 1 || m.Operator.Asks[0].Key != "mesh-knowledge.1" || len(m.Operator.Groups) != 1 { + t.Fatalf("%+v", m) + } + asked := map[string]int{} + for _, v := range []string{TrackerMilestonesVerb, TrackerListVerb, DeliveryGroupsVerb, AsksVerb} { + asked[v] = w.o.Calls(v) + } + // Events change the blocks. + w.o.Heard(SourceDelivery, "mesh-delivery-gate.group", json.RawMessage(`{"group":"fix/b","state":"checking","members":["y@2"]}`)) + w.o.Heard(SourceDelivery, "mesh-delivery-gate.transition", json.RawMessage(`{"group":"fix/a","to":"approved"}`)) + w.o.Heard(SourceAsks, "messenger.offered", json.RawMessage(`{"id":"2","asker":"mesh-delivery","headline":"Approve the group","channels":["telegram"]}`)) + w.o.Heard(SourceTracker, "mesh-issues.opened", json.RawMessage(`{"number":1999,"title":"New","status":"open"}`)) + w.o.Heard(SourceTracker, "mesh-issues.milestone-moved", json.RawMessage(`{"design":47,"id":"1","to":"done","issues":[973,525,1999]}`)) + m, _ = w.o.Model() + if len(m.Operator.Groups) != 2 || m.Operator.Groups[0].State != "approved" || len(m.Operator.Asks) != 2 || len(m.Designs) != 0 { + t.Fatalf("after the events: %+v", m) + } + w.o.Heard(SourceDelivery, "mesh-delivery-gate.merged", json.RawMessage(`{"group":"fix/a"}`)) + w.o.Heard(SourceAsks, "messenger.closed", json.RawMessage(`{"id":"1","asker":"mesh-knowledge","how":"answered"}`)) + m, _ = w.o.Model() + if len(m.Operator.Groups) != 1 || m.Operator.Groups[0].ID != "fix/b" || len(m.Operator.Asks) != 1 || m.Operator.Asks[0].Key != "mesh-delivery.2" { + t.Fatalf("after merged and closed: %+v", m.Operator) + } + for v, n := range asked { + if w.o.Calls(v) != n { + t.Errorf("%s was asked again by an event", v) + } + } + // The seven days are counted when the answer is given: no event between, the record falls out of the block. + w.now = w.now.Add(2 * 24 * time.Hour) + m, _ = w.o.Model() + if len(m.Accepted) != 1 || m.Accepted[0].Record != "ADR 0301" { + t.Fatalf("the seven-day block: %+v", m.Accepted) + } +} + +// A source that does not answer says so, never as an empty list (ADR 0616 §5, §6; AC P3): from its verb, on a bounded backoff +// asked again alone, its first answer clearing the mark; from its subscription, asking nothing while it is silent and the verb once on its +// next event; the model as a tool equals the page's model; the re-read tool asks one source and records the hand act. +func TestASourceThatDoesNotAnswerIsSaidAndAskedAgainOnABackoff(t *testing.T) { + w := newOverviewWorld(t) + w.fail[DeliveryGroupsVerb] = true + w.o.Start() + m, _ := w.o.Model() + var delivery SourceState + for _, s := range m.Sources { + if s.Name == SourceDelivery { + delivery = s + } + } + if delivery.Answered || delivery.Mark == nil || delivery.Mark.Cause != MarkVerb || len(m.Operator.Groups) != 0 || !strings.Contains(delivery.Mark.Why, "no answer in time") { + t.Fatalf("%+v", delivery) + } + if len(m.Designs) != 1 { + t.Fatalf("another source's block was lost: %+v", m.Designs) + } + // Asked again on the backoff alone: 10 s, then doubling, at most 5 minutes; no other source is asked. + calls := w.o.Calls(DeliveryGroupsVerb) + if got := w.o.Due(); len(got) != 0 { + t.Fatalf("asked before the backoff ran out: %v", got) + } + var waits []time.Duration + for i := 0; i < 8; i++ { + before := w.now + w.now = w.o.state[SourceDelivery].Mark.Next + waits = append(waits, w.now.Sub(before)) + if got := w.o.Due(); len(got) != 1 || got[0] != SourceDelivery { + t.Fatalf("%v", got) + } + } + if waits[0] != BackoffFrom || waits[1] != 2*BackoffFrom || waits[7] != BackoffMost || w.o.Calls(DeliveryGroupsVerb) != calls+8 || w.o.Calls(AsksVerb) != 1 { + t.Fatalf("waits %v, delivery asked %d, asks asked %d", waits, w.o.Calls(DeliveryGroupsVerb), w.o.Calls(AsksVerb)) + } + // Its first answer rebuilds the block and clears the mark. + w.fail[DeliveryGroupsVerb] = false + w.now = w.o.state[SourceDelivery].Mark.Next + w.o.Due() + m, _ = w.o.Model() + if len(w.o.Unanswered()) != 0 || len(m.Operator.Groups) != 1 { + t.Fatalf("%+v", m) + } + // A subscription that goes silent marks the source from the subscription and asks nothing while it is silent. + asked := w.o.Calls(AsksVerb) + w.now = w.now.Add(HeartbeatWithin + time.Second) + if got := w.o.Silent(); len(got) != 3 { + t.Fatalf("%v", got) + } + if got := w.o.Due(); len(got) != 0 || w.o.Calls(AsksVerb) != asked { + t.Fatalf("a silent subscription was asked: %v", got) + } + if u := w.o.Unanswered(); len(u) != 3 || u[0].Mark.Cause != MarkSubscription { + t.Fatalf("%+v", u) + } + // Its next event (or heartbeat) makes the module ask the verb once, and that answer clears the mark. + w.o.Heard(SourceAsks, "messenger.offered", json.RawMessage(`{"id":"9","asker":"x","headline":"x"}`)) + if w.o.Calls(AsksVerb) != asked+1 || w.o.state[SourceAsks].Mark != nil { + t.Fatalf("asked %d, mark %+v", w.o.Calls(AsksVerb), w.o.state[SourceAsks].Mark) + } + // An unanswered verb then turns the mark into one from the verb. + w.fail[DeliveryGroupsVerb] = true + w.o.Heard(SourceDelivery, "", nil) + if mk := w.o.state[SourceDelivery].Mark; mk == nil || mk.Cause != MarkVerb { + t.Fatalf("%+v", mk) + } +} + +func TestThePagesModelIsAToolAndItsRereadIsAHandAct(t *testing.T) { + w := newOverviewWorld(t) + w.entries = []*Entry{recordEntry(301, StatusAccepted, "2026-11-02", w.now.Add(-24*time.Hour), nil)} + w.o.Start() + var acts []string + k := &KB{Overview: w.o, Seats: func(key string, body any) (json.RawMessage, error) { + acts = append(acts, key) + return json.RawMessage(`{}`), nil + }, Now: func() time.Time { return w.now }} + var tools []toolRun + for _, tool := range moduleTools(k, callerOf) { + tools = append(tools, toolRun{tool.Name, tool.Run}) + } + got, err := callTool(t, tools, OverviewTool, map[string]any{}) + want, _ := w.o.Model() + raw, _ := json.Marshal(want) + var wantMap map[string]any + _ = json.Unmarshal(raw, &wantMap) + if err != nil || !reflect.DeepEqual(got, wantMap) { + t.Fatalf("the tool's answer is not the page's model: %v", err) + } + // The re-read asks the one source and no other, and records itself. + before := map[string]int{} + for _, v := range []string{TrackerListVerb, DeliveryGroupsVerb, AsksVerb} { + before[v] = w.o.Calls(v) + } + w.fail[AsksVerb] = true + res, err := callTool(t, tools, RereadTool, map[string]any{"source": SourceAsks, "why": "the page showed it stale"}) + if err != nil || res["answered"] != false || w.o.Calls(AsksVerb) != before[AsksVerb]+1 || w.o.Calls(DeliveryGroupsVerb) != before[DeliveryGroupsVerb] || w.o.Calls(TrackerListVerb) != before[TrackerListVerb] { + t.Fatalf("%v %v", err, res) + } + if len(acts) != 1 || acts[0] != "seat:mesh-hand-acts.hand-act" { + t.Fatalf("the hand act: %v", acts) + } + if _, err := callTool(t, tools, RereadTool, map[string]any{"source": "nobody"}); err == nil { + t.Fatal("a source the page lacks was re-read") + } + // The health carries the page: degraded while a source is unanswered, healthy. + h := k.pageHealth(HealthAnswer{State: "healthy", Healthy: true, Why: "ok"}) + if !h.Healthy || !strings.Contains(h.Why, "overview page is degraded") || !strings.Contains(h.Why, SourceAsks) { + t.Fatalf("%+v", h) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/page.go b/modules/mesh-knowledge/cmd/mesh-knowledge/page.go new file mode 100644 index 00000000..37e52883 --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/page.go @@ -0,0 +1,139 @@ +package main + +// The overview page's handler and the process that keeps it (novox/hq to-be 55 §11, ADR 0616 §4): GET only, no form and no +// verb behind it, served by the module's bundle (the one process with the bus: the ledger and the sources' verbs are reached +// through it) on a port the manifest declares for this machine only, reached through the route proxy by a route the module requires. + +import ( + "context" + "fmt" + "html/template" + "net/http" + "os" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// PageAddress is where the page listens unless MESH_KNOWLEDGE_PAGE says otherwise: the port the manifest declares under +// `listens`, which the node's packet filter opens for this machine only. +const PageAddress = ":8480" + +// PageHandler renders the model as a page; any method but GET is refused, nothing is written. +func PageHandler(o *Overview) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "this page is read-only: GET only", http.StatusMethodNotAllowed) + return + } + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + m, err := o.Model() + if err != nil { + http.Error(w, err.Error(), http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'") + if err := pageTemplate.Execute(w, m); err != nil { + fmt.Fprintf(os.Stderr, "[mesh-knowledge] the page could not be rendered: %v\n", err) + } + }) +} + +var pageFuncs = template.FuncMap{ + "clock": func(t time.Time) string { + if t.IsZero() { + return "never" + } + return t.Local().Format("2006-01-02 15:04") + }, + "join": strings.Join, +} + +var pageTemplate = template.Must(template.New("page").Funcs(pageFuncs).Parse(` +
+Read at {{clock .At}}. Sources: +{{range .Sources}}{{.Name}}: {{if .Answered}}answered {{clock .Last}}{{else}}does not answer{{if .Mark}} ({{.Mark.Cause}}: {{.Mark.Why}}; last answer {{clock .Last}}){{end}}{{end}}{{end}}
+ +The issue tracker does not answer: this block is not empty, it is unknown (last answer {{clock .Last}}).
{{end}}{{end}} +{{if .Designs}}No design has a milestone open.
{{end}} + +None.
{{end}} +None.
{{end}} + +The operator channel does not answer: the asks below are not complete (last answer {{clock .Last}}).
{{end}}{{if and (eq .Name "mesh-delivery") (not .Answered)}}Delivery does not answer: the groups below are not complete (last answer {{clock .Last}}).
{{end}}{{end}} +None.
{{end}} +None.
{{end}} +{{.ID}} {{.State}}{{if .Why}}: {{.Why}}{{end}}None.
{{end}} +`)) + +// ServePage starts the overview: the model's first view, the events kept, the unanswered sources asked again on their backoff, +// and the page on its address. It runs for the life of the process. +func ServePage(k *KB) { + o := NewOverview(k.Seats, func() ([]*Entry, error) { return k.Store.All(context.Background()) }, nil) + k.Overview = o + <-stdio.Served() + for source, events := range OverviewEvents { + for _, pattern := range events { + source, pattern := source, pattern + go func() { + for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { + err := stdio.Subscribe(pattern, func(e stdio.Envelope) error { + o.Heard(source, pattern, e.Body) + return nil + }) + if err == nil { + return + } + logf("[mesh-knowledge] the page does not hear %s yet (%v); asking again in %s", pattern, err, wait) + time.Sleep(wait) + } + }() + } + } + o.Start() + go func() { + for range time.Tick(5 * time.Second) { + o.Silent() + o.Due() + } + }() + addr := os.Getenv("MESH_KNOWLEDGE_PAGE") + if addr == "" { + addr = PageAddress + } + if addr == "off" { + return // a test's bundle serves no page + } + srv := &http.Server{Addr: addr, Handler: PageHandler(o), ReadHeaderTimeout: 10 * time.Second} + logf("[mesh-knowledge] the overview page listens on %s", addr) + if err := srv.ListenAndServe(); err != nil { + logf("[mesh-knowledge] the overview page stopped: %v", err) + } +} diff --git a/modules/mesh-knowledge/cmd/mesh-knowledge/page_test.go b/modules/mesh-knowledge/cmd/mesh-knowledge/page_test.go new file mode 100644 index 00000000..1cb57e8e --- /dev/null +++ b/modules/mesh-knowledge/cmd/mesh-knowledge/page_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "strings" + "testing" + "time" +) + +// The page's handler answers GET and nothing else; its manifest declares the port for this machine only and requires a route +// (ADR 0616 §4, AC P2). +func TestThePageAnswersGetOnlyAndTheManifestKeepsItToThisMachine(t *testing.T) { + w := newOverviewWorld(t) + w.entries = []*Entry{recordEntry(301, StatusAccepted, "2026-11-02", w.now.Add(-24*time.Hour), nil)} + w.o.Start() + h := PageHandler(w.o) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + body := rec.Body.String() + if rec.Code != 200 || !strings.Contains(body, "Design 47, milestone 1") || !strings.Contains(body, "ADR 0301") || !strings.Contains(body, "Approve ADR 0301") || + !strings.Contains(body, "fix/a") { + t.Fatalf("%d\n%.600s", rec.Code, body) + } + if strings.Contains(body, "