diff --git a/modules/claude-code/cmd/claude-code/config_test.go b/modules/claude-code/cmd/claude-code/config_test.go index 5cdbc2238..bb985c3b7 100644 --- a/modules/claude-code/cmd/claude-code/config_test.go +++ b/modules/claude-code/cmd/claude-code/config_test.go @@ -279,7 +279,7 @@ func TestTheManifestListsEveryToolServed(t *testing.T) { // 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", "seat:mesh-catalog.seat-definitions", ModulesTool, ManifestsTool, AssignmentsTool}) || !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.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed"}) { + !slices.Equal(m.Consumes, []string{"mesh-delivery-indexer.views.modules", "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", HoldingsAsked}) { 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"`) || diff --git a/modules/claude-code/cmd/claude-code/holdings.go b/modules/claude-code/cmd/claude-code/holdings.go new file mode 100644 index 000000000..38d036408 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/holdings.go @@ -0,0 +1,37 @@ +package main + +// What this node holds, said (design 39 bus-and-switch §23): kept in this module's own state `holdings` and said as +// the event `holdings-changed`, the holdings report whole and never a token, at start, whenever it changes, and again +// when the licence manager says `holdings.asked`. + +import "encoding/json" + +// HoldingsChanged is the event that says what a node holds. +const HoldingsChanged = "holdings-changed" + +// HoldingsAsked is the licence manager's event asking every node to say what it holds. +const HoldingsAsked = Manager + ".holdings.asked" + +// HoldingsSayer keeps and says what this node holds. +type HoldingsSayer struct { + Put func(Holdings) error + Emit func(Holdings) error + said string +} + +// Say keeps and says now when it differs from what was said last, or always when again: true when it said it. A +// failure says nothing was said, so the next call tries again. +func (s *HoldingsSayer) Say(now Holdings, again bool) (bool, error) { + raw, _ := json.Marshal(now) + if !again && string(raw) == s.said { + return false, nil + } + if err := s.Put(now); err != nil { + return false, err + } + if err := s.Emit(now); err != nil { + return false, err + } + s.said = string(raw) + return true, nil +} diff --git a/modules/claude-code/cmd/claude-code/holdings_test.go b/modules/claude-code/cmd/claude-code/holdings_test.go new file mode 100644 index 000000000..c74b0d682 --- /dev/null +++ b/modules/claude-code/cmd/claude-code/holdings_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +// design 39 bus-and-switch §23: the holdings are said at start, on a change and when asked again, kept in the +// module's own state each time, the report whole and never a token. +func TestHoldingsAreSaidAtStartAndOnChange(t *testing.T) { + var kept, said []string + failing := false + s := &HoldingsSayer{ + Put: func(h Holdings) error { raw, _ := json.Marshal(h); kept = append(kept, string(raw)); return nil }, + Emit: func(h Holdings) error { + if failing { + return errors.New("no responders") + } + raw, _ := json.Marshal(h) + said = append(said, string(raw)) + return nil + }, + } + fp := "sha256:0123456789abcdef" + start := Holdings{Node: "laptop"} + changed := Holdings{Node: "laptop"} + changed.Refresh.Present, changed.Refresh.Fingerprint = true, &fp + for i, step := range []struct { + h Holdings + again, out bool + }{{start, false, true}, {start, false, false}, {changed, false, true}, {changed, true, true}} { + if got, err := s.Say(step.h, step.again); err != nil || got != step.out { + t.Fatalf("step %d: said %v (%v), want %v", i, got, err, step.out) + } + } + if len(said) != 3 || len(kept) != 3 { + t.Fatalf("said %d, kept %d, want 3 each", len(said), len(kept)) + } + if !strings.Contains(said[1], fp) || !strings.Contains(said[1], `"node":"laptop"`) { + t.Fatalf("the report is not said whole: %s", said[1]) + } + failing = true + if got, err := s.Say(start, false); err == nil || got { + t.Fatal("a failed event counts as said") + } + failing = false + if got, _ := s.Say(start, false); !got { + t.Fatal("what failed to be said is not said at the next call") + } +} diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index 53a18a1a0..6c7580ebf 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -20,6 +20,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "time" "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient" @@ -567,15 +568,24 @@ func run(p Paths, view *ServerView, configView *ConfigView) { // every change of the credentials file, polled, because the file is replaced by rename and a watch on // the old inode would go quiet. Fingerprints and expiries only. holdings := stdio.State("holdings") - reported := "" - report := func() { + var sayMu sync.Mutex + sayer := &HoldingsSayer{ + Put: func(h Holdings) error { _, err := holdings.Put(p.Node, h); return err }, + Emit: func(h Holdings) error { return stdio.Emit(HoldingsChanged, h) }, + } + sayHoldings := func(again bool) { + sayMu.Lock() + defer sayMu.Unlock() now := HoldingsOf(p) - raw, _ := json.Marshal(now) - if string(raw) == reported { - return - } - persist("reporting what this node holds", func() error { _, err := holdings.Put(p.Node, now); return err }, func(int) { - reported = string(raw) + said := false + persist("reporting what this node holds", func() error { + var err error + said, err = sayer.Say(now, again) + return err + }, func(int) { + if !said { + return + } account := "no account" if now.Identity != nil && now.Identity.EmailAddress != "" { account = now.Identity.EmailAddress @@ -593,6 +603,15 @@ func run(p Paths, view *ServerView, configView *ConfigView) { say("%s", line) }) } + report := func() { sayHoldings(false) } + // The licence manager asks every node to say what it holds again (§23): after its carry-over, at its start and on + // its resync. + go persist("hearing the licence manager ask what this node holds", func() error { + return stdio.Subscribe(HoldingsAsked, func(stdio.Envelope) error { + go sayHoldings(true) + return nil + }) + }, func(n int) { say("hearing %s%s", HoldingsAsked, refusals(n)) }) // What this node should hold (ADR 0206): the manager's `bindings` key for this node; a newer // generation is fetched with the seat's `current`, sealed to this module's key. diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 7bfbd71fd..c3e46ff70 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -35,7 +35,8 @@ "mesh-delivery-indexer.views.manifests", "mesh-delivery-assigner.views.assignments", "mesh-catalog.seat-definition-changed", - "mesh-catalog.seat-definition-removed" + "mesh-catalog.seat-definition-removed", + "claude-licence-manager.holdings.asked" ], "state": [ "servers", @@ -48,7 +49,8 @@ "findings" ], "emits": [ - "judged" + "judged", + "holdings-changed" ], "reads": [ "claude-licence-manager.bindings"