diff --git a/modules/claude-code/cmd/claude-code/config_test.go b/modules/claude-code/cmd/claude-code/config_test.go index 73072d929..f443507ea 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/insteadof_test.go b/modules/claude-code/cmd/claude-code/insteadof_test.go index a157fc7fe..8e5815001 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-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. @@ -192,6 +196,40 @@ 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) { diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index 6dfa03c7f..61e23ad57 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" @@ -24,6 +25,9 @@ import ( "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/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{}, seats.List(), 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) @@ -377,6 +404,7 @@ func tools(p Paths, servers ServerState, view *ServerView, config ConfigState, c 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)...) } @@ -422,6 +450,7 @@ func main() { return } p, launched := PathsFrom(os.Getenv) + meshViews = newMeshViews() if launched { fsys = FilesFor(p) } @@ -451,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 ba1b40600..380cf1366 100644 --- a/modules/claude-code/cmd/claude-code/mesh.go +++ b/modules/claude-code/cmd/claude-code/mesh.go @@ -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{}, seats.List(), 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 000000000..bd0eee143 --- /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 0f890ac6b..3ecf0dfab 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,16 +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", @@ -92,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": [