M-14: the manager's conditions, kept in its state and said on the operator channel (issue 1409)

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.<setting>.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
This commit is contained in:
2026-11-12 00:13:05 +01:00
parent 4e10ab7b2e
commit 9e545658a5
7 changed files with 671 additions and 22 deletions
@@ -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)
}
@@ -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))
}
}
@@ -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()
}
@@ -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 --------------------------------------------------------------------------------
@@ -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.<setting>.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 {
@@ -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}
}
+2
View File
@@ -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