diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index f48ffa1bb..a43e4a38d 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -86,8 +86,13 @@ func manager() (*Manager, error) { } return a, json.Unmarshal(raw, &a) }, - Reported: heard.Of, - Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) }, + Reported: heard.Of, + Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) }, + Tell: func(_ context.Context, headline string, lines []string) error { + _, err := stdio.Ask("seat:operator-channel.tell", map[string]any{"subject": Seat, "headline": headline, + "lines": lines}) + return err + }, Now: time.Now, Log: say, Holder: fmt.Sprintf("%s:%d", node, os.Getpid()), diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index 040a9ecc9..4b0616b47 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -148,8 +148,10 @@ type Manager struct { // Reported is the binding generation a node last reported applying (B11), 0 when none is known. Reported func(node string) int64 Emit func(event string, body map[string]any) error - Now func() time.Time - Log func(format string, args ...any) + // Tell tells the operator one notice that needs no answer, through the seat operator-channel's tell (§28). + Tell func(ctx context.Context, headline string, lines []string) error + Now func() time.Time + Log func(format string, args ...any) // Holder names this process in a lease, so a second run is told apart. Holder string // Settings are those in force, read again from SettingsFile at every reading (settings.go); an empty SettingsFile @@ -693,6 +695,10 @@ func (m *Manager) ReadUsage(ctx context.Context) error { "sessionResetsAt": reading.SessionResetsAt, "weeklyPct": reading.WeeklyPct, "weeklyResetsAt": reading.WeeklyResetsAt, "sonnetPct": reading.SonnetPct, "sonnetResetsAt": reading.SonnetResetsAt}) + // On each reading, a licence at its threshold moves every consumer bound to it (§26). + if _, err := m.ApplyRule(ctx, l.Name, false, false); err != nil { + return err + } } return nil } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go b/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go new file mode 100644 index 000000000..e321ed7e0 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go @@ -0,0 +1,163 @@ +package main + +// The switch rule (design 39 bus-and-switch §26, §27, §28, §32): after a reading of licence L, when switch_auto is on and +// L, with one or more consumers bound to it, is at or above switch_at_pct in its session or weekly window, every +// consumer bound to L moves, in one act, to the other licence with known room whose windows are both below +// room_below_pct and that has the most room, the first by name on a tie. The act is said as one licence.switched, a +// binding.changed per consumer, one audit entry switched, and told to the operator. A consumer is only ever moved off +// a licence at its threshold, so none flaps between two. + +import ( + "context" + "fmt" + "sort" + "strings" +) + +// ByUsage is who moves a consumer by the rule (§26). +const ByUsage = "usage" + +// Switch is one act of the rule: from L to the target, the consumers it moves, the window of L that reached its +// threshold, its percentage and reset, and the target's room. +type Switch struct { + From string `json:"from"` + To string `json:"to"` + Consumers []string `json:"consumers"` + Window string `json:"window"` + Pct float64 `json:"pct"` + ResetsAt string `json:"resetsAt"` + Room float64 `json:"room"` +} + +// why is the cause a moved binding carries: L, the window, its percentage and its reset (§26). +func (s Switch) why() string { + return fmt.Sprintf("%s reached %g %% of its %s window, which resets at %s", s.From, s.Pct, s.Window, s.ResetsAt) +} + +// fields are the act's fields, the same in licence.switched and the audit entry switched (§27). +func (s Switch) fields() map[string]any { + f := switchedFields(s.From, s.To, s.Consumers, Cause{Why: s.why(), By: ByUsage}) + f["window"], f["pct"], f["resetsAt"], f["room"] = s.Window, s.Pct, s.ResetsAt, s.Room + return f +} + +// Rule is what the rule finds for one licence at its threshold: the act, or none with NoTarget when no other licence +// has room. +type Rule struct { + Switch *Switch + NoTarget bool +} + +// RuleFor is what §26 gives for licence l among every licence and binding, under the settings s. A licence below its +// threshold, or with no consumer, gives nothing. +func (m *Manager) RuleFor(l Licence, licences []Licence, bindings []Binding, s Settings) Rule { + var consumers []string + for _, b := range bindings { + if b.Licence == l.Name { + consumers = append(consumers, b.Consumer) + } + } + if len(consumers) == 0 || l.Usage == nil { + return Rule{} + } + sw := Switch{From: l.Name, Consumers: consumers} + session, weekly := l.Usage.SessionPct, l.Usage.WeeklyPct + switch { + case session != nil && *session >= s.SwitchAt && (weekly == nil || *session >= *weekly): + sw.Window, sw.Pct, sw.ResetsAt = "session", *session, l.Usage.SessionResetsAt + case weekly != nil && *weekly >= s.SwitchAt: + sw.Window, sw.Pct, sw.ResetsAt = "weekly", *weekly, l.Usage.WeeklyResetsAt + default: + return Rule{} + } + sort.Strings(sw.Consumers) + others := append([]Licence(nil), licences...) + sort.Slice(others, func(i, j int) bool { return others[i].Name < others[j].Name }) + best := -1.0 + for _, o := range others { + if o.Name == l.Name { + continue + } + room, known := Room(o, m.Now(), s) + if !known || *o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow || room <= best { + continue + } + best, sw.To, sw.Room = room, o.Name, room + } + if sw.To == "" { + return Rule{NoTarget: true} + } + return Rule{Switch: &sw} +} + +// ApplyRule applies §26 to one licence, or to every one when licence is empty: after a reading, by switch_auto, or by +// hand, even with switch_auto off (§32, §39). With dry it answers the moves and moves nothing. An invalid pair of +// settings moves no consumer (§33). +func (m *Manager) ApplyRule(ctx context.Context, licence string, byHand, dry bool) ([]Switch, error) { + s := m.settings() + if (!s.SwitchAuto && !byHand) || len(s.Invalid()) > 0 { + return nil, nil + } + licences, err := m.Store.Licences(ctx) + if err != nil { + return nil, err + } + bindings, err := m.Store.Bindings(ctx) + if err != nil { + return nil, err + } + var acts []Switch + for _, l := range licences { + if licence != "" && l.Name != licence { + continue + } + r := m.RuleFor(l, licences, bindings, s) + if r.Switch == nil { + continue + } + acts = append(acts, *r.Switch) + if dry { + continue + } + if err := m.act(ctx, *r.Switch, licences); err != nil { + return acts, err + } + // What was moved is bound to the target now, for the next licence's rule. + if bindings, err = m.Store.Bindings(ctx); err != nil { + return acts, err + } + } + return acts, nil +} + +// act moves every consumer of one act as the switch verb does, then says the act once and tells the operator (§26 to +// §28). +func (m *Manager) act(ctx context.Context, sw Switch, licences []Licence) error { + kind := "" + for _, l := range licences { + if l.Name == sw.To { + kind = l.Kind + } + } + floor := m.floorIn(nil) + for _, consumer := range sw.Consumers { + b, err := m.Store.Bind(ctx, consumer, sw.To, Move{Kind: kind, Why: sw.why(), By: ByUsage, Floor: floor}) + if err != nil { + return fmt.Errorf("moving %s to %s: %w", consumer, sw.To, err) + } + m.changed(b, sw.From) + } + f := sw.fields() + if err := m.record(ctx, "switched", fmt.Sprintf("switched %s from %s to %s: %s; by %s", + strings.Join(sw.Consumers, ", "), sw.From, sw.To, sw.why(), ByUsage), f); err != nil { + return err + } + _ = m.Emit("licence.switched", f) + if m.Tell != nil { + line := fmt.Sprintf("%s; %s has %g %% room", sw.why(), sw.To, sw.Room) + if err := m.Tell(ctx, strings.Join(sw.Consumers, ", ")+" moved to "+sw.To, []string{line}); err != nil { + m.Log("the switch of %s to %s could not be told: %v", strings.Join(sw.Consumers, ", "), sw.To, err) + } + } + return nil +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/switching_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/switching_test.go new file mode 100644 index 000000000..3dfa8e8da --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/switching_test.go @@ -0,0 +1,181 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" +) + +// read is a licence's latest reading, read at t0. +func read(session, weekly float64) *UsageReading { + return &UsageReading{SessionPct: &session, SessionResetsAt: "2026-11-11T23:00:00Z", WeeklyPct: &weekly, + WeeklyResetsAt: "2026-11-14T09:00:00Z", ReadAt: t0.UTC().Format(time.RFC3339Nano)} +} + +func sub(name string, u *UsageReading) Licence { + return Licence{Name: name, Kind: "subscription", Generation: 1, Usage: u} +} + +// design 39 bus-and-switch §26/AC1, §25, §48: a table of licences, readings and bindings and the moves each gives. +func TestTheRuleMovesEachTable(t *testing.T) { + mm := newMesh(t) + on := func(consumers ...string) []Binding { + var out []Binding + for _, c := range consumers { + out = append(out, Binding{Consumer: c, Licence: "a"}) + } + return out + } + for _, c := range []struct { + name string + licences []Licence + bindings []Binding + to string + window string + noTarget bool + consumers int + }{ + {"below its threshold", []Licence{sub("a", read(50, 98)), sub("b", read(0, 0))}, on("x"), "", "", false, 0}, + {"no consumer", []Licence{sub("a", read(99, 99)), sub("b", read(0, 0))}, nil, "", "", false, 0}, + {"the weekly window at 99", []Licence{sub("a", read(40, 99)), sub("b", read(10, 30)), sub("c", read(5, 20))}, + on("x", "y", "z", "w"), "c", "weekly", false, 4}, + {"the session window at 100", []Licence{sub("a", read(100, 50)), sub("b", read(10, 30))}, on("x"), "b", "session", false, 1}, + {"a tie goes to the first by name", []Licence{sub("a", read(99, 0)), sub("c", read(10, 10)), sub("b", read(10, 10))}, + on("x"), "b", "session", false, 1}, + {"a target's windows both below room_below_pct", []Licence{sub("a", read(99, 0)), sub("b", read(0, 90)), + sub("c", read(89, 89))}, on("x"), "c", "session", false, 1}, + {"no other licence has room", []Licence{sub("a", read(99, 0)), sub("b", read(95, 0)), + {Name: "k", Kind: "api-key", Generation: 1}}, on("x"), "", "", true, 0}, + {"an unread licence is no target", []Licence{sub("a", read(0, 99)), sub("b", nil)}, on("x"), "", "", true, 0}, + } { + r := mm.m.RuleFor(c.licences[0], c.licences, c.bindings, Defaults) + to, window, n := "", "", 0 + if r.Switch != nil { + to, window, n = r.Switch.To, r.Switch.Window, len(r.Switch.Consumers) + } + if to != c.to || window != c.window || n != c.consumers || r.NoTarget != c.noTarget { + t.Errorf("%s: to %q window %q consumers %d no target %v", c.name, to, window, n, r.NoTarget) + } + } +} + +// fourOnA is four consumers bound to a, at 99 % of its weekly window, b with 70 % room and c with 60 %. +func fourOnA(t *testing.T) *miniMesh { + mm := newMesh(t) + ctx := context.Background() + for _, l := range []Licence{sub("a", read(40, 99)), sub("b", read(30, 20)), sub("c", read(40, 10))} { + _ = mm.store.SaveLicence(ctx, l) + } + for _, c := range []string{"ace", "g14", "laptop", "server"} { + _, _ = mm.store.Bind(ctx, c, "a", Move{Kind: "subscription", Why: "set", By: "test"}) + } + mm.events = nil + return mm +} + +// design 39 bus-and-switch §27/AC1, §26, §21: one act says one licence.switched with the consumers moved, the window, +// its percentage, reset and the target's room; one binding.changed per consumer; one audit entry switched with the same +// fields; each binding moved by usage, its why naming the licence, the window, the percentage and the reset. +func TestOneSwitchedPerAct(t *testing.T) { + mm := fourOnA(t) + acts, err := mm.m.ApplyRule(context.Background(), "", false, false) + if err != nil || len(acts) != 1 || acts[0].To != "b" || acts[0].Room != 70 { + t.Fatalf("acts %+v %v", acts, err) + } + var switched []map[string]any + changed := 0 + for _, e := range mm.events { + name, body, _ := strings.Cut(e, " ") + var f map[string]any + _ = json.Unmarshal([]byte(body), &f) + switch name { + case "licence.switched": + switched = append(switched, f) + case "binding.changed": + changed++ + } + } + if len(switched) != 1 || changed != 4 { + t.Fatalf("said %v", mm.events) + } + s := switched[0] + if s["from"] != "a" || s["to"] != "b" || len(s["consumers"].([]any)) != 4 || s["by"] != ByUsage || + s["window"] != "weekly" || s["pct"] != 99.0 || s["resetsAt"] != "2026-11-14T09:00:00Z" || s["room"] != 70.0 { + t.Fatalf("licence.switched %v", s) + } + audit := mm.audit.under("switched") + if len(audit) != 1 || audit[0]["window"] != "weekly" || len(audit[0]["consumers"].([]any)) != 4 { + t.Fatalf("audit %v", audit) + } + b := mm.bound("laptop") + if b.Licence != "b" || b.By != ByUsage || b.Why != "a reached 99 % of its weekly window, which resets at 2026-11-14T09:00:00Z" { + t.Fatalf("laptop %+v", b) + } +} + +// design 39 bus-and-switch §28/AC1: the rule tells the operator once per act, and the switch verb never. +func TestTheRuleTellsOnceAndTheVerbNever(t *testing.T) { + mm := fourOnA(t) + var told []string + mm.m.Tell = func(_ context.Context, headline string, lines []string) error { + told = append(told, headline+" | "+strings.Join(lines, " ")) + return nil + } + _, _ = mm.m.ApplyRule(context.Background(), "", false, false) + if len(told) != 1 || told[0] != "ace, g14, laptop, server moved to b | a reached 99 % of its weekly window, "+ + "which resets at 2026-11-14T09:00:00Z; b has 70 % room" { + t.Fatalf("told %v", told) + } + _, _ = mm.m.Bind(WithCause(context.Background(), Cause{Why: "the switch verb", By: "a person"}), "ace", "c") + if len(told) != 1 { + t.Fatalf("the switch verb told %v", told) + } +} + +// design 39 bus-and-switch §32/AC1, §33/AC2: dry answers the same moves as the act and moves nothing; an invalid pair +// of settings, or switch_auto off, moves nothing after a reading, and the rule by hand runs with switch_auto off. +func TestDryAnswersTheSameMovesAndMovesNothing(t *testing.T) { + ctx := context.Background() + mm := fourOnA(t) + dry, err := mm.m.ApplyRule(ctx, "a", true, true) + if err != nil || len(dry) != 1 || mm.bound("ace").Licence != "a" || len(mm.events) != 0 { + t.Fatalf("dry %+v %v, said %v", dry, err, mm.events) + } + acts, _ := mm.m.ApplyRule(ctx, "a", true, false) + if len(acts) != 1 || acts[0].To != dry[0].To || strings.Join(acts[0].Consumers, ",") != strings.Join(dry[0].Consumers, ",") { + t.Fatalf("dry %+v, act %+v", dry, acts) + } + for name, change := range map[string]func(*Settings){ + "an invalid pair": func(s *Settings) { s.RoomBelow = 100 }, + "switch_auto off": func(s *Settings) { s.SwitchAuto = false }, + } { + mm := fourOnA(t) + change(&mm.m.Settings) + if acts, _ := mm.m.ApplyRule(ctx, "", false, false); len(acts) != 0 || mm.bound("ace").Licence != "a" { + t.Errorf("%s moved %+v", name, acts) + } + } + mm = fourOnA(t) + mm.m.Settings.SwitchAuto = false + if acts, _ := mm.m.ApplyRule(ctx, "", true, false); len(acts) != 1 || mm.bound("ace").Licence != "b" { + t.Fatalf("by hand with switch_auto off moved %+v", acts) + } +} + +// design 39 bus-and-switch §26: a reading at the threshold applies the rule at once. +func TestAReadingAtTheThresholdApplies(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + _ = mm.store.SaveLicence(ctx, Licence{Name: "b@example.org", Kind: "subscription", Usage: read(10, 10)}) + mm.vendor.usage = map[string]any{"five_hour": map[string]any{"utilization": 30.0}, + "seven_day": map[string]any{"utilization": 99.0, "resets_at": "2026-11-14T09:00:00Z"}} + if err := mm.m.ReadUsage(ctx); err != nil { + t.Fatal(err) + } + if b := mm.bound("laptop"); b.Licence != "b@example.org" || b.By != ByUsage { + t.Fatalf("laptop %+v", b) + } +}