From 9e545658a530f9303318787b24b481be65636136 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 00:13:05 +0100 Subject: [PATCH] M-14: the manager's conditions, kept in its state and said on the operator channel (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each condition the manager holds is written in its state conditions with its body and since, then said on the operator channel's condition accept under claude-licence-manager, scope module; it is said again only when its words change, and said cleared, its key deleted, once it no longer holds. At its start, after the carry-over's run, it judges every kind anew: raised again each it holds, cleared each kept key it no longer holds. The kinds: carry-over-failed, settings..invalid for a pair above switch_at_pct and for a setting outside its bound, and per licence no-room when the rule finds no target, near-limit at warn_at_pct with consumers, and usage-unread once its latest success, or its adoption when none, is older than usage_stale_minutes. Headlines name counts and short names, cut at 60. Builds: design 39 bus-and-switch §9, §11, §29, §30, §31, §33, B17, B34, B36, B37, B38, B39 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/conditions.go | 328 ++++++++++++++++++ .../claude-licence-manager/conditions_test.go | 252 ++++++++++++++ .../cmd/claude-licence-manager/main.go | 7 + .../cmd/claude-licence-manager/manager.go | 10 +- .../cmd/claude-licence-manager/settings.go | 29 +- .../cmd/claude-licence-manager/switching.go | 65 ++-- modules/claude-licence-manager/go.mod | 2 + 7 files changed, 671 insertions(+), 22 deletions(-) create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go new file mode 100644 index 000000000..59ddae278 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go @@ -0,0 +1,328 @@ +package main + +// The manager's conditions (design 39 bus-and-switch §9, §11, §29 to §31, §33, B17, B37 to B39): each said on the +// operator channel as any module says one (novox/hq ADR 0437 §9), the router alone deciding whether and where the +// operator is told, and each it said raised kept in its state `conditions`, written before it is said and deleted when +// it is said cleared. A raise or a clear is said again only when what it says changes (ADR 0289 §1); at its start the +// manager judges every kind anew, says raised again each it still holds and cleared each key it no longer holds. + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Publisher is the name the manager says its conditions under, and the id of their subject (B17). +const Publisher = "claude-licence-manager" + +// CarryOverFailed is the condition of a carry-over that failed (§11). +const CarryOverFailed = "carry-over-failed" + +// HeadlineLength is the operator channel's limit on a headline; a longer one is cut, ending in "…" (B39). +const HeadlineLength = 60 + +// KeptCondition is the value of a key of `conditions` (§9): the condition said raised, and since when. +type KeptCondition struct { + Body oc.Condition `json:"body"` + Since string `json:"since"` +} + +// SayCondition publishes a condition on the operator channel's condition accept, under the manager's name, in the form +// its client encodes (ADR 0437 §9). +func SayCondition(c oc.Condition) error { + subject, err := occ.ConditionSubject(Publisher) + if err != nil { + return err + } + raw, err := occ.EncodeCondition(c) + if err != nil { + return err + } + return stdio.SeatPublish(subject, json.RawMessage(raw), "") +} + +// condition is a raised condition of the manager: its key, severity, words and the line said when it is over. An +// empty needs is "Nothing for you to do."; the explanation is opened by its verdict here. +func condition(key, severity, headline, summary, needs, explanation, resolved string) oc.Condition { + verdict := occ.NothingToDo + if needs != "" { + verdict = occ.NeedsYou + needs + "." + } + if explanation != "" { + verdict += " " + explanation + } + return oc.Condition{Key: key, Severity: severity, State: occ.StateRaised, + Subject: oc.ConditionSubject{Scope: "module", ID: Publisher}, + Headline: cut(headline), Summary: summary, Needs: needs, Explanation: verdict, Resolved: resolved} +} + +// cut keeps a headline within the operator channel's limit, ending a longer one in "…" (B39). +func cut(headline string) string { + r := []rune(headline) + if len(r) <= HeadlineLength { + return headline + } + return string(r[:HeadlineLength-1]) + "…" +} + +// short is a licence's short name for a headline: its address before the "@", or its name (B39). +func short(name string) string { + if at := strings.Index(name, "@"); at > 0 { + return name[:at] + } + return name +} + +// machines is a count of consumers in a headline (B39). +func machines(n int) string { + if n == 1 { + return "1 machine" + } + return fmt.Sprintf("%d machines", n) +} + +// Judge is every condition the manager holds now, judged anew from its own state and readings, by key. +func (m *Manager) Judge(ctx context.Context) (map[string]oc.Condition, error) { + held := map[string]oc.Condition{} + if m.Carry != nil && !m.Carry.Ended() { + if why := m.Carry.Failure(); why != "" { + held[CarryOverFailed] = condition(CarryOverFailed, occ.SeverityUrgent, + "The licence manager could not carry over its licences", + "the carry-over of today's database failed: "+why, + "the licence manager could not carry over its licences; no consumer is moved and no token is refreshed until it does", + "It failed: "+why+".", "The licence manager carried over its licences") + } + } + s := m.settings() + for _, o := range s.OutOfBound { + bound := fmt.Sprintf("at least %g", o.Min) + if o.HasMax { + bound = fmt.Sprintf("between %g and %g", o.Min, o.Max) + } + key := "settings." + o.Setting + ".invalid" + held[key] = condition(key, occ.SeverityWarning, o.Setting+" is outside its bound", + fmt.Sprintf("%s is %g, outside its bound; its default %g is used", o.Setting, o.Value, o.Default), + fmt.Sprintf("set %s %s", o.Setting, bound), + fmt.Sprintf("%s is set to %g; the licence manager uses its default %g until it is fixed.", o.Setting, + o.Value, o.Default), o.Setting+" is within its bound again") + } + for _, p := range s.Invalid() { + key := "settings." + p.Setting + ".invalid" + if _, ok := held[key]; ok { + continue + } + held[key] = condition(key, occ.SeverityWarning, p.Setting+" is above switch_at_pct", + fmt.Sprintf("%s is %g, above switch_at_pct %g; no consumer is moved", p.Setting, p.Value, p.SwitchAt), + fmt.Sprintf("set %s at or below switch_at_pct (%g)", p.Setting, p.SwitchAt), + fmt.Sprintf("%s is %g, above switch_at_pct %g; the licence manager moves no consumer until it is fixed.", + p.Setting, p.Value, p.SwitchAt), p.Setting+" is at or below switch_at_pct again") + } + licences, err := m.Store.Licences(ctx) + if err != nil { + return nil, err + } + bindings, err := m.Store.Bindings(ctx) + if err != nil { + return nil, err + } + for _, l := range licences { + for key, c := range m.judgeLicence(l, licences, bindings, s) { + held[key] = c + } + } + return held, nil +} + +// judgeLicence is what licence l holds of no-room (§29), near-limit (§30) and usage-unread (§31). +func (m *Manager) judgeLicence(l Licence, licences []Licence, bindings []Binding, s Settings) map[string]oc.Condition { + held := map[string]oc.Condition{} + key := "licence." + KeyFor(l.Name) + "." + consumers := consumersOf(l.Name, bindings) + list := strings.Join(consumers, ", ") + if r := m.RuleFor(l, licences, bindings, s); r.NoTarget { + o := r.Over + var rooms []string + for _, other := range licences { + if other.Name != l.Name { + rooms = append(rooms, other.Name+" "+m.roomWords(other, s)) + } + } + sort.Strings(rooms) + others := "no other licence is held" + if len(rooms) > 0 { + others = strings.Join(rooms, "; ") + } + held[key+"no-room"] = condition(key+"no-room", occ.SeverityUrgent, + "No licence has room for "+machines(len(consumers)), + fmt.Sprintf("no licence has room for %s, bound to %s at %g %% of its %s window", list, l.Name, o.Pct, o.Window), + fmt.Sprintf("add a licence, or wait for %s's %s window to reset at %s", l.Name, o.Window, o.ResetsAt), + fmt.Sprintf("%s is at %g %% of its %s window, with %s bound to it; %s.", l.Name, o.Pct, o.Window, list, others), + list+" have a licence with room again") + } + if window, pct, resetsAt, ok := windowAt(l.Usage, s.WarnAt); ok && len(consumers) > 0 { + needs, plan := "", "" + if to, _ := m.targetFor(l, licences, s); to != "" { + plan = fmt.Sprintf("%s move to %s at %g %%. ", list, to, s.SwitchAt) + } else { + needs = "no other licence has room" + } + held[key+"near-limit"] = condition(key+"near-limit", occ.SeverityWarning, + fmt.Sprintf("%s at %g%% %s", short(l.Name), pct, window), + fmt.Sprintf("%s at %g %% of its %s window", l.Name, pct, window), needs, + fmt.Sprintf("%s%s is at %g %% of its %s window, which resets at %s; %s bound to it.", plan, l.Name, pct, + window, resetsAt, list), fmt.Sprintf("%s is below %g %% again", l.Name, s.WarnAt)) + } + if since, stale := m.unread(l, s); stale { + needs := "" + if len(consumers) > 0 { + needs = list + " are not moved off it while its usage is unknown" + } + why := l.Usage.readError() + held[key+"usage-unread"] = condition(key+"usage-unread", occ.SeverityWarning, + short(l.Name)+"'s usage cannot be read", + fmt.Sprintf("the usage of %s cannot be read: %s", l.Name, why), needs, + fmt.Sprintf("%s, older than %g minutes; the vendor answers: %s.", since, s.UsageStale.Minutes(), why), + l.Name+"'s usage is read again") + } + return held +} + +// readError is the vendor's reason a reading failed, or that none was given. +func (u *UsageReading) readError() string { + if u == nil || u.ReadError == "" { + return "no reason given" + } + return u.ReadError +} + +// unread is whether subscription l's latest successful reading is older than usage_stale_minutes (§31), or, never +// read successfully, its adoption is (B38); and the words naming since when. +func (m *Manager) unread(l Licence, s Settings) (string, bool) { + if l.Kind != "subscription" { + return "", false + } + if l.Usage != nil && l.Usage.ReadAt != "" { + read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt) + if err != nil { + return "its latest successful reading has no time", true + } + return "its latest successful reading was at " + l.Usage.ReadAt, m.Now().Sub(read) > s.UsageStale + } + if l.AdoptedAt == 0 { + return "", false + } + adopted := time.UnixMilli(l.AdoptedAt) + return "no reading has succeeded since its adoption at " + adopted.UTC().Format(time.RFC3339), + m.Now().Sub(adopted) > s.UsageStale +} + +// roomWords is another licence's room for the condition no-room, or why it is not known or not enough (§29). +func (m *Manager) roomWords(o Licence, s Settings) string { + room, known := Room(o, m.Now(), s) + switch { + case known && (*o.Usage.SessionPct >= s.RoomBelow || *o.Usage.WeeklyPct >= s.RoomBelow): + return fmt.Sprintf("has %g %% room, a window not below %g %%", room, s.RoomBelow) + case known: + return fmt.Sprintf("has %g %% room", room) + case o.Kind != "subscription": + return "is an API key, whose room is never known" + case o.Usage == nil || o.Usage.ReadAt == "": + return "has no reading" + case o.Usage.SessionPct == nil || o.Usage.WeeklyPct == nil: + return "has no session or weekly window in its reading" + case o.Failures >= s.FailuresToNotify || (o.RefreshExpiresAt != 0 && o.RefreshExpiresAt <= m.Now().UnixMilli()): + return "does not refresh" + } + return fmt.Sprintf("was not read within %g minutes", s.UsageStale.Minutes()) +} + +// SayConditions judges every kind anew and says what changed: each condition held whose words differ from those kept +// is written in `conditions`, then said raised; each kept key no longer held is said cleared, then deleted. With +// again (at the start, and by licences_resync), each held is said raised again whether or not it changed. +func (m *Manager) SayConditions(ctx context.Context, again bool) error { + if m.Conditions == nil || m.Say == nil { + return nil + } + m.conditionsMu.Lock() + defer m.conditionsMu.Unlock() + held, err := m.Judge(ctx) + if err != nil { + return err + } + keys, err := m.Conditions.Keys() + if err != nil { + return err + } + var errs []error + for _, key := range keys { + if _, ok := held[key]; ok { + continue + } + var kept KeptCondition + e, err := m.Conditions.Get(key) + if err != nil { + errs = append(errs, err) + continue + } + if e != nil && len(e.Value) > 0 { + _ = json.Unmarshal(e.Value, &kept) + } + cleared := kept.Body + cleared.Key, cleared.State = key, occ.StateCleared + cleared.Subject = oc.ConditionSubject{Scope: "module", ID: Publisher} + if err := m.Say(cleared); err != nil { + errs = append(errs, fmt.Errorf("the clearing of %s could not be said: %w", key, err)) + continue + } + if err := m.Conditions.Delete(key); err != nil { + errs = append(errs, err) + } + } + var order []string + for key := range held { + order = append(order, key) + } + sort.Strings(order) + for _, key := range order { + c := held[key] + kept := KeptCondition{Body: c, Since: m.Now().UTC().Format(time.RFC3339)} + if e, err := m.Conditions.Get(key); err == nil && e != nil && len(e.Value) > 0 { + var was KeptCondition + if json.Unmarshal(e.Value, &was) == nil { + if !again && sameCondition(was.Body, c) { + continue + } + if was.Since != "" { + kept.Since = was.Since + } + } + } + if _, err := m.Conditions.Put(key, kept); err != nil { + errs = append(errs, err) + continue + } + if err := m.Say(c); err != nil { + // Not said: the key goes again, so the next judgement says it. + _ = m.Conditions.Delete(key) + errs = append(errs, fmt.Errorf("%s could not be said: %w", key, err)) + } + } + if len(errs) > 0 { + return fmt.Errorf("conditions: %v", errs) + } + return nil +} + +// sameCondition is whether two raised conditions say the same. +func sameCondition(a, b oc.Condition) bool { + ra, _ := json.Marshal(a) + rb, _ := json.Marshal(b) + return string(ra) == string(rb) +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go new file mode 100644 index 000000000..6fbe66679 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go @@ -0,0 +1,252 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient" + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" +) + +// saying gives mm's manager a state `conditions` and records each condition it says, in the form the operator +// channel's client encodes, refusing what it refuses. +func saying(mm *miniMesh) (*memState, *[]oc.Condition) { + kept := newMemState(func() time.Time { return mm.now }) + said := &[]oc.Condition{} + mm.m.Conditions = kept + mm.m.Say = func(c oc.Condition) error { + if _, err := occ.EncodeCondition(c); err != nil { + return err + } + if _, err := occ.ConditionSubject(Publisher); err != nil { + return err + } + *said = append(*said, c) + return nil + } + return kept, said +} + +// of is what was said of key, as "state" per saying. +func of(said []oc.Condition, key string) []string { + var out []string + for _, c := range said { + if c.Key == key { + out = append(out, c.State) + } + } + return out +} + +// design 39 bus-and-switch §9/AC1, §11, §33, B17, B37: each kind of condition the manager holds of itself is written in +// `conditions` with its body and since, said once with its key, severity, scope and id, its words opened by their +// verdict, and said cleared, its key deleted, once it no longer holds. +func TestAConditionIsKeptThenSaidOnce(t *testing.T) { + ctx := context.Background() + file := filepath.Join(t.TempDir(), "settings.json") + for _, c := range []struct { + name, key, severity string + raise, fix func(mm *miniMesh) + }{ + {"an invalid pair", "settings.room_below_pct.invalid", occ.SeverityWarning, + func(mm *miniMesh) { mm.m.Settings.RoomBelow = 100 }, func(mm *miniMesh) { mm.m.Settings.RoomBelow = 90 }}, + {"a setting outside its bound", "settings.switch_at_pct.invalid", occ.SeverityWarning, + func(mm *miniMesh) { + _ = os.WriteFile(file, []byte(`{"switch_at_pct": 150}`), 0o600) + mm.m.Settings = SettingsFrom(file) + }, + func(mm *miniMesh) { + _ = os.WriteFile(file, []byte(`{"switch_at_pct": 98}`), 0o600) + mm.m.Settings = SettingsFrom(file) + }}, + {"a carry-over that failed", CarryOverFailed, occ.SeverityUrgent, + func(mm *miniMesh) { + mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now }), + Source: func(context.Context) (CarrySource, error) { return nil, errors.New("connection refused") }} + _, _ = mm.m.Carry.Run(ctx, mm.m, mm.store) + }, + func(mm *miniMesh) { + mm.m.Carry.Source = func(context.Context) (CarrySource, error) { return &memSource{}, nil } + _, _ = mm.m.Carry.Run(ctx, mm.m, mm.store) + }}, + } { + mm := newMesh(t) + kept, said := saying(mm) + c.raise(mm) + for range 2 { + if err := mm.m.SayConditions(ctx, false); err != nil { + t.Fatalf("%s: %v", c.name, err) + } + } + if got := of(*said, c.key); strings.Join(got, ",") != "raised" { + t.Fatalf("%s: said %v of %s, all said %+v", c.name, got, c.key, *said) + } + s := (*said)[0] + if s.Severity != c.severity || s.Subject.Scope != "module" || s.Subject.ID != Publisher || s.Headline == "" || + s.Summary == "" || s.Resolved == "" || !strings.HasPrefix(s.Explanation, occ.NeedsYou+s.Needs) || s.Needs == "" { + t.Fatalf("%s: said %+v", c.name, s) + } + var k KeptCondition + if e, _ := kept.Get(c.key); e == nil || json.Unmarshal(e.Value, &k) != nil || k.Body.Key != c.key || k.Since == "" { + t.Fatalf("%s: kept %+v", c.name, k) + } + c.fix(mm) + _ = mm.m.SayConditions(ctx, false) + if got := of(*said, c.key); strings.Join(got, ",") != "raised,cleared" { + t.Fatalf("%s: said %v", c.name, got) + } + if e, _ := kept.Get(c.key); e != nil { + t.Fatalf("%s: the key is kept after its clearing", c.name) + } + } +} + +// design 39 bus-and-switch §9/AC2: at its start the manager says cleared each key of `conditions` it no longer holds, +// and says raised again each it still holds, unchanged or not. +func TestAStartClearsWhatItNoLongerHolds(t *testing.T) { + ctx := context.Background() + mm := newMesh(t) + kept, said := saying(mm) + gone := condition("licence.gone.no-room", occ.SeverityUrgent, "No licence has room for 1 machine", "s", "add one", + "", "x has a licence with room again") + mm.m.Settings.WarnAt = 100 + _ = mm.m.SayConditions(ctx, false) + _, _ = kept.Put(gone.Key, KeptCondition{Body: gone, Since: "2026-11-11T20:00:00Z"}) + *said = nil + if err := mm.m.SayConditions(ctx, true); err != nil { + t.Fatal(err) + } + if strings.Join(of(*said, gone.Key), ",") != "cleared" || strings.Join(of(*said, "settings.warn_at_pct.invalid"), ",") != "raised" { + t.Fatalf("a start said %+v", *said) + } + if (*said)[0].Resolved != gone.Resolved { + t.Fatalf("the clearing carries %+v", (*said)[0]) + } + if e, _ := kept.Get(gone.Key); e != nil { + t.Fatal("a key no longer held is kept") + } +} + +// design 39 bus-and-switch §29/AC1, B39: with every licence at its threshold, no-room is said once, its headline a +// count of machines, its words naming the licence, the window's reset and every other licence's room; a reading with +// room clears it. +func TestNoRoomIsSaidOnceAndClearedByRoom(t *testing.T) { + ctx := context.Background() + mm := fourOnA(t) + _, said := saying(mm) + _ = mm.store.SaveLicence(ctx, sub("b", read(95, 20))) + _ = mm.store.SaveLicence(ctx, sub("c", read(40, 99))) + for range 2 { + _ = mm.m.SayConditions(ctx, false) + } + key := "licence.a.no-room" + if strings.Join(of(*said, key), ",") != "raised" { + t.Fatalf("said %+v", *said) + } + var c oc.Condition + for _, s := range *said { + if s.Key == key { + c = s + } + } + if c.Severity != occ.SeverityUrgent || c.Headline != "No licence has room for 4 machines" || + c.Needs != "add a licence, or wait for a's weekly window to reset at 2026-11-14T09:00:00Z" || + !strings.Contains(c.Explanation, "a is at 99 % of its weekly window, with ace, g14, laptop, server bound to it") || + !strings.Contains(c.Explanation, "b has 5 % room, a window not below 90 %") || + c.Resolved != "ace, g14, laptop, server have a licence with room again" { + t.Fatalf("no-room %+v", c) + } + _ = mm.store.SaveLicence(ctx, sub("b", read(10, 20))) + _ = mm.m.SayConditions(ctx, false) + if strings.Join(of(*said, key), ",") != "raised,cleared" { + t.Fatalf("said %v after a reading with room", of(*said, key)) + } +} + +// design 39 bus-and-switch §30/AC1, B39: a rising series of readings says near-limit once at the crossing and again only +// when its words change; with no other licence having room it needs the operator; a reading after the reset clears it. +func TestNearLimitIsSaidOncePerCrossing(t *testing.T) { + ctx := context.Background() + mm := fourOnA(t) + _, said := saying(mm) + key := "licence.a.near-limit" + var last oc.Condition + for _, w := range []float64{80, 89, 91, 91, 95} { + _ = mm.store.SaveLicence(ctx, sub("a", read(10, w))) + _ = mm.m.SayConditions(ctx, false) + for _, s := range *said { + if s.Key == key { + last = s + } + } + } + if strings.Join(of(*said, key), ",") != "raised,raised" || last.Headline != "a at 95% weekly" || last.Needs != "" || + !strings.HasPrefix(last.Explanation, occ.NothingToDo+" ace, g14, laptop, server move to b at 99 %.") || + !strings.Contains(last.Explanation, "which resets at 2026-11-14T09:00:00Z") { + t.Fatalf("said %v, last %+v", of(*said, key), last) + } + _ = mm.store.SaveLicence(ctx, sub("b", read(95, 0))) + _ = mm.store.SaveLicence(ctx, sub("c", read(95, 0))) + _ = mm.m.SayConditions(ctx, false) + if c := (*said)[len(*said)-1]; c.Key != key || c.Needs != "no other licence has room" { + t.Fatalf("with no target said %+v", c) + } + _ = mm.store.SaveLicence(ctx, sub("a", read(3, 1))) + _ = mm.m.SayConditions(ctx, false) + if got := of(*said, key); got[len(got)-1] != "cleared" || len(got) != 4 { + t.Fatalf("after the reset said %v", got) + } +} + +// design 39 bus-and-switch §31/AC1, B34, B38: three failed readings in a row, the latest success then older than +// usage_stale_minutes, say usage-unread naming the vendor's reason, needing the operator for the consumers bound to it; +// one success clears it. A subscription never read successfully is said once its adoption is that old. +func TestUsageUnreadIsSaidAndCleared(t *testing.T) { + ctx := context.Background() + mm := newMesh(t) + _, said := saying(mm) + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + if err := mm.m.ReadUsage(ctx); err != nil { + t.Fatal(err) + } + key := "licence." + KeyFor(licence1) + ".usage-unread" + mm.vendor.usageErr = errors.New("429 Too Many Requests: rate limited") + for i := 1; i <= 3; i++ { + mm.now = t0.Add(time.Duration(i) * 6 * time.Minute) + if err := mm.m.ReadUsage(ctx); err != nil { + t.Fatal(err) + } + if got := len(of(*said, key)); (i < 3 && got != 0) || (i == 3 && got != 1) { + t.Fatalf("after %d failed readings said %v", i, of(*said, key)) + } + } + c := (*said)[len(*said)-1] + if c.Severity != occ.SeverityWarning || c.Needs != "laptop are not moved off it while its usage is unknown" || + !strings.Contains(c.Explanation, "the vendor answers: 429 Too Many Requests: rate limited") { + t.Fatalf("usage-unread %+v", c) + } + mm.vendor.usageErr = nil + _ = mm.m.ReadUsage(ctx) + if strings.Join(of(*said, key), ",") != "raised,cleared" { + t.Fatalf("after a success said %v", of(*said, key)) + } + // B38: never read successfully since its adoption. + never := Licence{Name: "new@example.org", Kind: "subscription", AdoptedAt: mm.now.UnixMilli()} + _ = mm.store.SaveLicence(ctx, never) + _ = mm.m.SayConditions(ctx, false) + newKey := "licence." + KeyFor(never.Name) + ".usage-unread" + if len(of(*said, newKey)) != 0 { + t.Fatal("said at its adoption") + } + mm.now = mm.now.Add(16 * time.Minute) + _ = mm.m.SayConditions(ctx, false) + if strings.Join(of(*said, newKey), ",") != "raised" { + t.Fatalf("said %v of a licence never read", of(*said, newKey)) + } +} 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 a43e4a38d..798deddc8 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -98,6 +98,8 @@ func manager() (*Manager, error) { Holder: fmt.Sprintf("%s:%d", node, os.Getpid()), Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")), SettingsFile: os.Getenv("MESH_LICENCE_SETTINGS"), + Conditions: stdio.State("conditions"), + Say: SayCondition, } return built, nil } @@ -146,6 +148,11 @@ func carryOverFirst() { bus, _ := m.Store.(*BusStore) _, _ = m.Carry.Run(context.Background(), m, bus) // a failure is said, and the run is asked again by hand (§44) } + // At its start the manager judges every condition anew: raised again each it holds, cleared each it no longer + // holds, carry-over-failed among them (§9, §11). + if err := m.SayConditions(context.Background(), true); err != nil { + say("saying the conditions at the start: %v", err) + } <-m.Carry.Wait() } 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 4b0616b47..2f998c2d4 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -25,6 +25,8 @@ import ( "strings" "sync" "time" + + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" ) // Fingerprint names a token without being one: the agent module's own fingerprint, so the two compare. @@ -159,6 +161,11 @@ type Manager struct { Settings Settings SettingsFile string settingsMu sync.RWMutex + // Conditions is the state `conditions` (design 39 bus-and-switch §9), and Say says one condition on the operator + // channel; either nil says none. + Conditions keptState + Say func(c oc.Condition) error + conditionsMu sync.Mutex } const leaseFor = 2 * time.Minute @@ -700,7 +707,8 @@ func (m *Manager) ReadUsage(ctx context.Context) error { return err } } - return nil + // What the readings and the settings now hold is said, each condition only when what it says changed (§9). + return m.SayConditions(ctx, false) } // ---- the seat's verbs -------------------------------------------------------------------------------- diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go b/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go index 171d2b892..dc2afdb8e 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go @@ -30,6 +30,18 @@ type Settings struct { UsageEvery time.Duration // UsageStale is how old a licence's latest successful reading may be for its room to be known (§25). UsageStale time.Duration + // OutOfBound is every setting the file sets outside the bound the manifest declares, kept at its default and said + // as settings..invalid until it is fixed (B37). + OutOfBound []OutOfBound +} + +// OutOfBound is one setting set outside its bound: the value set, the bound and the default kept in its place. +type OutOfBound struct { + Setting string + Value float64 + Min, Max float64 + Default float64 + HasMax bool } // Defaults are the settings a fresh mesh runs with. @@ -50,9 +62,18 @@ func SettingsFrom(path string) Settings { return s } num := func(k string) (float64, bool) { v, ok := f[k].(float64); return v, ok } + // below is a value under the manifest's minimum of 0 for today's five: kept at its default and said (B37). + below := func(k string, v float64, def float64) { + if v < 0 { + s.OutOfBound = append(s.OutOfBound, OutOfBound{Setting: k, Value: v, Default: def}) + } + } set := func(k string, unit time.Duration, into *time.Duration) { - if v, ok := num(k); ok && v > 0 { + v, ok := num(k) + if ok && v > 0 { *into = time.Duration(v * float64(unit)) + } else if ok { + below(k, v, float64(*into)/float64(unit)) } } set("cadence_minutes", time.Minute, &s.Cadence) @@ -61,12 +82,18 @@ func SettingsFrom(path string) Settings { set("refresh_warn_days", 24*time.Hour, &s.RefreshWarn) if v, ok := num("failures_to_notify"); ok && v > 0 { s.FailuresToNotify = int(v) + } else if ok { + below("failures_to_notify", v, float64(s.FailuresToNotify)) } if v, ok := f["switch_auto"].(bool); ok { s.SwitchAuto = v } bounded := func(k string, lo, hi float64) (float64, bool) { v, ok := num(k) + if ok && (v < lo || v > hi) { + def, _ := Defaults.Values()[k].(float64) + s.OutOfBound = append(s.OutOfBound, OutOfBound{Setting: k, Value: v, Min: lo, Max: hi, Default: def, HasMax: true}) + } return v, ok && v >= lo && v <= hi } for _, p := range []struct { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go b/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go index e321ed7e0..13027e4be 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go @@ -42,38 +42,47 @@ func (s Switch) fields() map[string]any { } // Rule is what the rule finds for one licence at its threshold: the act, or none with NoTarget when no other licence -// has room. +// has room; Over is then the licence's consumers and the window at its threshold, for the condition no-room (§29). type Rule struct { Switch *Switch NoTarget bool + Over *Switch } -// 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 { +// consumersOf are the consumers bound to licence, sorted. +func consumersOf(licence string, bindings []Binding) []string { var consumers []string for _, b := range bindings { - if b.Licence == l.Name { + if b.Licence == licence { consumers = append(consumers, b.Consumer) } } - if len(consumers) == 0 || l.Usage == nil { - return Rule{} + sort.Strings(consumers) + return consumers +} + +// windowAt is l's window at or above pct, the higher of the two, the session window on a tie (B36): its name, +// percentage and reset; ok false when neither is. +func windowAt(u *UsageReading, pct float64) (window string, at float64, resetsAt string, ok bool) { + if u == nil { + return "", 0, "", false } - sw := Switch{From: l.Name, Consumers: consumers} - session, weekly := l.Usage.SessionPct, l.Usage.WeeklyPct + session, weekly := u.SessionPct, u.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{} + case session != nil && *session >= pct && (weekly == nil || *session >= *weekly): + return "session", *session, u.SessionResetsAt, true + case weekly != nil && *weekly >= pct: + return "weekly", *weekly, u.WeeklyResetsAt, true } - sort.Strings(sw.Consumers) + return "", 0, "", false +} + +// targetFor is the licence §26 moves l's consumers to: among the others with known room whose windows are both below +// room_below_pct, the one with the most room, the first by name on a tie; empty when none has room. +func (m *Manager) targetFor(l Licence, licences []Licence, s Settings) (string, float64) { others := append([]Licence(nil), licences...) sort.Slice(others, func(i, j int) bool { return others[i].Name < others[j].Name }) - best := -1.0 + to, best := "", -1.0 for _, o := range others { if o.Name == l.Name { continue @@ -82,10 +91,26 @@ func (m *Manager) RuleFor(l Licence, licences []Licence, bindings []Binding, s 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 + best, to = room, o.Name } - if sw.To == "" { - return Rule{NoTarget: true} + return to, best +} + +// 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 { + consumers := consumersOf(l.Name, bindings) + if len(consumers) == 0 { + return Rule{} + } + sw := Switch{From: l.Name, Consumers: consumers} + var ok bool + if sw.Window, sw.Pct, sw.ResetsAt, ok = windowAt(l.Usage, s.SwitchAt); !ok { + return Rule{} + } + if sw.To, sw.Room = m.targetFor(l, licences, s); sw.To == "" { + sw.Room = 0 + return Rule{NoTarget: true, Over: &sw} } return Rule{Switch: &sw} } diff --git a/modules/claude-licence-manager/go.mod b/modules/claude-licence-manager/go.mod index 4b74e0437..77296baf3 100644 --- a/modules/claude-licence-manager/go.mod +++ b/modules/claude-licence-manager/go.mod @@ -3,6 +3,8 @@ module claude-licence-manager go 1.26.0 require ( + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/jackc/pgx/v5 v5.11.0 golang.org/x/crypto v0.57.0