package main import ( "encoding/json" "fmt" "strings" "testing" "time" "github.com/nats-io/nats.go" ) // A module's code asks at runtime (novox/hq ADR 0349 clause 14, ADR 0370, design 57 S1, issue 685), against a real // bus whose accounts are the client's and its machine's tool runner's as mesh-controller composes them: the tool // runner asks for a carried module's config value and is answered; a change is said, so a watch asks again; a key // the module does not declare, a module not placed on the machine, a secret and a malformed ask are refused, each // with its reason. // toolRunner is g14's tool runner on the bus, asking as its account may. type toolRunner struct { t *testing.T nc *nats.Conn } func aToolRunner(t *testing.T, b *aBus) *toolRunner { t.Helper() nc, err := nats.Connect(b.url, nats.UserInfo(node+".node-tools", "x"), nats.CustomInboxPrefix("_INBOX."+node+".node-tools"), nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { t.Errorf("the bus refused the tool runner: %v", err) })) if err != nil { t.Fatal(err) } t.Cleanup(nc.Close) return &toolRunner{t: t, nc: nc} } func (r *toolRunner) ask(body string) Answered { r.t.Helper() var a Answered deadline := time.Now().Add(10 * time.Second) for { msg, err := r.nc.Request(AskSubject(node), []byte(body), 2*time.Second) if err == nil { if err := json.Unmarshal(msg.Data, &a); err != nil { r.t.Fatalf("the client answered %s", msg.Data) } return a } if time.Now().After(deadline) { r.t.Fatalf("the client did not answer %s: %v", body, err) } time.Sleep(50 * time.Millisecond) // not listening yet } } func (r *toolRunner) askConfig(module, key string) Answered { r.t.Helper() return r.ask(fmt.Sprintf(`{"module":%q,"kind":"config","key":%q}`, module, key)) } // hear collects what the client says moved on this machine. func (r *toolRunner) hear() chan string { r.t.Helper() heard := make(chan string, 64) if _, err := r.nc.Subscribe("prov.changed."+node+".>", func(m *nats.Msg) { heard <- strings.TrimPrefix(m.Subject, "prov.changed."+node+".") + " " + string(m.Data) }); err != nil { r.t.Fatal(err) } _ = r.nc.Flush() return heard } // placed writes an assignment of a module the controller has not handed to the provisioner: placed, no plan. func (b *aBus) placed(t *testing.T, module string) { t.Helper() b.put(t, AssignmentsBucket, node+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T20:00:00Z"}`, node, module)) } func until(t *testing.T, heard chan string, want string) { t.Helper() deadline := time.After(10 * time.Second) for { select { case got := <-heard: if got == want { return } case <-deadline: t.Fatalf("the client did not say %s", want) } } } func TestNatsAModulesCodeReadsItsConfigAndIsToldOfAChange(t *testing.T) { b := startBus(t) e := startEngine(t, nil) b.placed(t, "claude-code") b.config(t, "claude-code", "managed_settings", `{"spinnerTipsEnabled":false}`) runner := aToolRunner(t, b) heard := runner.hear() aClient(t, b, e, t.TempDir()) // The value as mesh-config resolved it. if a := runner.askConfig("claude-code", "managed_settings"); a.Error != "" || string(a.Value) != `{"spinnerTipsEnabled":false}` { t.Fatalf("answered %+v", a) } // Coming up, it says every module's config moved, so a watch held across its absence asks again. until(t, heard, `claude-code {"kind":"config"}`) // A change is said, which value and never the value; asked again, the new value. b.config(t, "claude-code", "managed_settings", `{"spinnerTipsEnabled":true}`) until(t, heard, `claude-code {"kind":"config","key":"managed_settings"}`) if a := runner.askConfig("claude-code", "managed_settings"); string(a.Value) != `{"spinnerTipsEnabled":true}` { t.Fatalf("after the change, answered %+v", a) } // Taken off the machine: said, and no longer answered. b.del(t, AssignmentsBucket, node+".claude-code") until(t, heard, `claude-code {"kind":"config"}`) if a := runner.askConfig("claude-code", "managed_settings"); !strings.Contains(a.Error, "not placed on g14") { t.Fatalf("a module taken off the machine was answered %+v", a) } } func TestNatsWhatAModulesCodeMayNotReadIsRefusedWithTheReason(t *testing.T) { b := startBus(t) e := startEngine(t, nil) b.placed(t, "claude-code") b.config(t, "claude-code", "managed_settings", `{}`) // Resolved for a module the composition does not place on this machine. b.config(t, "dunst", "font-size", "13") runner := aToolRunner(t, b) aClient(t, b, e, t.TempDir()) for _, c := range []struct{ ask, refused string }{ {`{"module":"claude-code","kind":"config","key":"no_such_key"}`, "declares no config value no_such_key"}, {`{"module":"dunst","kind":"config","key":"font-size"}`, "dunst is not placed on g14"}, {`{"module":"claude-code","kind":"secret","key":"gitea-read"}`, "not answered on the tool runner's word"}, {`{"module":"claude-code","kind":"bound","key":"mcp-endpoint:url"}`, "not answered yet"}, {`{"module":"claude-code","kind":"setting","key":"role"}`, "not a kind the client answers"}, {`{"module":"claude-code","kind":"config"}`, "names its module, its kind and its key"}, {`{"module":"claude-code","kind":"config","key":"managed_settings","as":"dunst"}`, "names its module, its kind and its key"}, {`not json`, "names its module, its kind and its key"}, } { a := runner.ask(c.ask) if len(a.Value) != 0 || !strings.Contains(a.Error, c.refused) { t.Errorf("%s: answered %+v, want refused with %q", c.ask, a, c.refused) } } }