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 ec5e2015e..f48ffa1bb 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -86,12 +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) }, - Now: time.Now, - Log: say, - Holder: fmt.Sprintf("%s:%d", node, os.Getpid()), - Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")), + Reported: heard.Of, + Emit: func(event string, body map[string]any) error { return stdio.Emit(event, body) }, + Now: time.Now, + Log: say, + Holder: fmt.Sprintf("%s:%d", node, os.Getpid()), + Settings: SettingsFrom(os.Getenv("MESH_LICENCE_SETTINGS")), + SettingsFile: os.Getenv("MESH_LICENCE_SETTINGS"), } return built, nil } @@ -196,7 +197,12 @@ func daemon() { }() refresh := time.NewTicker(time.Minute) - usage := time.NewTicker(5 * time.Minute) + // Every usage_minutes, read again at every reading (§24, §33): the manager's one timer of its own readings. + every := Defaults.UsageEvery + if m, err := manager(); err == nil { + every = m.settings().UsageEvery + } + usage := time.NewTimer(every) for { select { case <-refresh.C: @@ -216,7 +222,9 @@ func daemon() { if err := m.ReadUsage(context.Background()); err != nil { say("reading usage failed: %v", err) } + every = m.settings().UsageEvery } + usage.Reset(every) } } } 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 420afb5b3..040a9ecc9 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -23,6 +23,7 @@ import ( "regexp" "sort" "strings" + "sync" "time" ) @@ -123,49 +124,6 @@ func (m *Manager) moved(ctx context.Context, b Binding, from string, c Cause) er return err } -// Settings are the manager's own, declared with defaults (design 39 §8). -type Settings struct { - Cadence time.Duration // rotate a grant once older than this - Floor time.Duration // refresh in any case with less than this left - FailuresToNotify int - Cooldown time.Duration // at most one notification per licence in this window - RefreshWarn time.Duration // warn this long before a refresh token itself expires - // UsageStale is how old a licence's latest successful reading may be for its room to be known (§25, §33). - UsageStale time.Duration -} - -// Defaults are the settings a fresh mesh runs with. -var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, - RefreshWarn: 72 * time.Hour, UsageStale: 15 * time.Minute} - -// SettingsFrom reads the settings file, keeping a default for anything absent or not positive. -func SettingsFrom(path string) Settings { - s := Defaults - raw, err := os.ReadFile(path) - if err != nil { - return s - } - var f map[string]float64 - if json.Unmarshal(raw, &f) != nil { - return s - } - set := func(k string, unit time.Duration, into *time.Duration) { - if v := f[k]; v > 0 { - *into = time.Duration(v * float64(unit)) - } - } - set("cadence_minutes", time.Minute, &s.Cadence) - set("floor_minutes", time.Minute, &s.Floor) - set("cooldown_hours", time.Hour, &s.Cooldown) - if v := f["refresh_warn_days"]; v > 0 { - s.RefreshWarn = time.Duration(v * float64(24*time.Hour)) - } - if v := f["failures_to_notify"]; v > 0 { - s.FailuresToNotify = int(v) - } - return s -} - // GrantAnswer is what a node's `claude_code_grant` answers: a login sealed to the key given, or nothing. type GrantAnswer struct { Sealed *SealedBox `json:"sealed"` @@ -193,8 +151,12 @@ type Manager struct { 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 Settings + Holder string + // Settings are those in force, read again from SettingsFile at every reading (settings.go); an empty SettingsFile + // keeps them. + Settings Settings + SettingsFile string + settingsMu sync.RWMutex } const leaseFor = 2 * time.Minute @@ -595,7 +557,7 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil } now := m.Now() - if !force && !Due(*l, now, m.Settings) { + if !force && !Due(*l, now, m.settings()) { return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil } g, err := m.grantOf(ctx, *l) @@ -610,7 +572,7 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri if err := m.Store.Audit(ctx, "failed", map[string]any{"licence": name, "status": r.Status, "reason": r.Reason, "failures": l.Failures}); err != nil { return nil, err } - m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.Settings.FailuresToNotify) + m.notify(l, fmt.Sprintf("refresh failed %d time(s) in a row: %d", l.Failures, r.Status), l.Failures >= m.settings().FailuresToNotify) if err := m.Store.SaveLicence(ctx, *l); err != nil { return nil, err } @@ -622,7 +584,7 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri if l.RefreshExpiresAt != 0 { left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24), - left < m.Settings.RefreshWarn) + left < m.settings().RefreshWarn) } if err := m.Store.SaveLicence(ctx, *l); err != nil { return nil, err @@ -654,7 +616,7 @@ func (m *Manager) notify(l *Licence, why string, when bool) { return } now := m.Now().UnixMilli() - if l.NotifiedAt != 0 && now-l.NotifiedAt < m.Settings.Cooldown.Milliseconds() { + if l.NotifiedAt != 0 && now-l.NotifiedAt < m.settings().Cooldown.Milliseconds() { return } l.NotifiedAt = now @@ -669,7 +631,7 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) { } var out []map[string]any for _, l := range all { - if Due(l, m.Now(), m.Settings) { + if Due(l, m.Now(), m.settings()) { r, err := m.Rotate(ctx, l.Name, false) if err != nil { return out, err @@ -682,6 +644,9 @@ func (m *Manager) RotateDue(ctx context.Context) ([]map[string]any, error) { // ReadUsage reads and records each subscription's usage (ADR 0054); the event names the licence and numbers. func (m *Manager) ReadUsage(ctx context.Context) error { + if err := m.ReadSettings(ctx); err != nil { + return err + } all, err := m.Store.Licences(ctx) if err != nil { return err @@ -748,7 +713,7 @@ func (m *Manager) Current(ctx context.Context, consumer string) (map[string]any, return nil, err } out := map[string]any{"licence": l.Name, "key": KeyFor(l.Name), "kind": l.Kind, "generation": b.Generation, - "health": Health(*l, m.Now(), m.Settings)} + "health": Health(*l, m.Now(), m.settings())} if l.AccessExpiresAt != 0 { out["accessExpiresAt"] = time.UnixMilli(l.AccessExpiresAt).UTC().Format(time.RFC3339) } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index 648f740cc..c0d645616 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -263,11 +263,12 @@ func TestTwoRefreshRunsStartedTogetherRotateAGrantOnce(t *testing.T) { ctx := context.Background() _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) mm.now = t0.Add(5 * time.Hour) - second := *mm.m - second.Holder = "another run" + second := &Manager{Store: mm.m.Store, Vendor: mm.m.Vendor, Crypt: mm.m.Crypt, Grants: mm.m.Grants, Keys: mm.m.Keys, + AskGrant: mm.m.AskGrant, Emit: mm.m.Emit, Now: mm.m.Now, Log: mm.m.Log, Holder: "another run", + Settings: mm.m.Settings} var wg sync.WaitGroup results := make([]map[string]any, 2) - for i, m := range []*Manager{mm.m, &second} { + for i, m := range []*Manager{mm.m, second} { wg.Add(1) go func() { defer wg.Done(); results[i], _ = m.Rotate(ctx, licence1, false) }() } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go b/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go new file mode 100644 index 000000000..171d2b892 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go @@ -0,0 +1,147 @@ +package main + +// The manager's settings (design 39 §8, design 39 bus-and-switch §33): today's five and the six of the switch, each +// declared in the manifest's `config` with its default and bound, set through mesh-config and written into the +// settings file. They are read again at every reading, so a change takes effect at the next one, and each change of a +// value read is written in the audit under `settings`. + +import ( + "context" + "encoding/json" + "fmt" + "os" + "reflect" + "time" +) + +// Settings are the manager's own, declared with defaults. +type Settings struct { + Cadence time.Duration // rotate a grant once older than this + Floor time.Duration // refresh in any case with less than this left + FailuresToNotify int + Cooldown time.Duration // at most one notification per licence in this window + RefreshWarn time.Duration // warn this long before a refresh token itself expires + // SwitchAuto is whether a reading at its threshold moves the licence's consumers (§26). + SwitchAuto bool + // SwitchAt, RoomBelow and WarnAt are percentages: where a licence's consumers move, what a target's windows must + // both be below, and where a licence with consumers is warned about. + SwitchAt, RoomBelow, WarnAt float64 + // UsageEvery is how often each subscription's usage is read. + 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 +} + +// Defaults are the settings a fresh mesh runs with. +var Defaults = Settings{Cadence: 4 * time.Hour, Floor: time.Hour, FailuresToNotify: 3, Cooldown: 24 * time.Hour, + RefreshWarn: 72 * time.Hour, SwitchAuto: true, SwitchAt: 99, RoomBelow: 90, WarnAt: 90, UsageEvery: 5 * time.Minute, + UsageStale: 15 * time.Minute} + +// SettingsFrom reads the settings file. Today's five keep a default for anything absent or not positive; the six of +// the switch keep a default for anything absent or outside the bound the manifest declares (§33). +func SettingsFrom(path string) Settings { + s := Defaults + raw, err := os.ReadFile(path) + if err != nil { + return s + } + var f map[string]any + if json.Unmarshal(raw, &f) != nil { + return s + } + num := func(k string) (float64, bool) { v, ok := f[k].(float64); return v, ok } + set := func(k string, unit time.Duration, into *time.Duration) { + if v, ok := num(k); ok && v > 0 { + *into = time.Duration(v * float64(unit)) + } + } + set("cadence_minutes", time.Minute, &s.Cadence) + set("floor_minutes", time.Minute, &s.Floor) + set("cooldown_hours", time.Hour, &s.Cooldown) + set("refresh_warn_days", 24*time.Hour, &s.RefreshWarn) + if v, ok := num("failures_to_notify"); ok && v > 0 { + s.FailuresToNotify = int(v) + } + if v, ok := f["switch_auto"].(bool); ok { + s.SwitchAuto = v + } + bounded := func(k string, lo, hi float64) (float64, bool) { + v, ok := num(k) + return v, ok && v >= lo && v <= hi + } + for _, p := range []struct { + key string + into *float64 + }{{"switch_at_pct", &s.SwitchAt}, {"room_below_pct", &s.RoomBelow}, {"warn_at_pct", &s.WarnAt}} { + if v, ok := bounded(p.key, 1, 100); ok { + *p.into = v + } + } + if v, ok := bounded("usage_minutes", 1, 60); ok { + s.UsageEvery = time.Duration(v * float64(time.Minute)) + } + if v, ok := bounded("usage_stale_minutes", 5, 180); ok { + s.UsageStale = time.Duration(v * float64(time.Minute)) + } + return s +} + +// Values are the settings by their names in the manifest's `config`, in its units. +func (s Settings) Values() map[string]any { + return map[string]any{"cadence_minutes": s.Cadence.Minutes(), "floor_minutes": s.Floor.Minutes(), + "failures_to_notify": float64(s.FailuresToNotify), "cooldown_hours": s.Cooldown.Hours(), + "refresh_warn_days": s.RefreshWarn.Hours() / 24, "switch_auto": s.SwitchAuto, "switch_at_pct": s.SwitchAt, + "room_below_pct": s.RoomBelow, "warn_at_pct": s.WarnAt, "usage_minutes": s.UsageEvery.Minutes(), + "usage_stale_minutes": s.UsageStale.Minutes()} +} + +// Invalid is a setting above switch_at_pct, which moves no consumer until it is fixed (§33): the setting and both +// values. +type Invalid struct { + Setting string + Value float64 + SwitchAt float64 +} + +// Invalid is every setting of the pairs §33 names that is above switch_at_pct: room_below_pct, then warn_at_pct. +func (s Settings) Invalid() []Invalid { + var out []Invalid + if s.RoomBelow > s.SwitchAt { + out = append(out, Invalid{Setting: "room_below_pct", Value: s.RoomBelow, SwitchAt: s.SwitchAt}) + } + if s.WarnAt > s.SwitchAt { + out = append(out, Invalid{Setting: "warn_at_pct", Value: s.WarnAt, SwitchAt: s.SwitchAt}) + } + return out +} + +// settings are those in force. +func (m *Manager) settings() Settings { + m.settingsMu.RLock() + defer m.settingsMu.RUnlock() + return m.Settings +} + +// ReadSettings reads the settings file again, at every reading (§33): each value that changed is written in the audit +// under `settings`, its value before and now, and the settings read are those in force from then on. +func (m *Manager) ReadSettings(ctx context.Context) error { + if m.SettingsFile == "" { + return nil + } + next := SettingsFrom(m.SettingsFile) + m.settingsMu.Lock() + before := m.Settings + m.Settings = next + m.settingsMu.Unlock() + was, now := before.Values(), next.Values() + changed := map[string]any{} + for k, v := range now { + if !reflect.DeepEqual(was[k], v) { + changed[k] = map[string]any{"from": was[k], "to": v} + } + } + if len(changed) == 0 { + return nil + } + return m.record(ctx, "settings", fmt.Sprintf("%d setting(s) changed: %v", len(changed), changed), changed) +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/settings_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/settings_test.go new file mode 100644 index 000000000..d642e76eb --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/settings_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// design 39 bus-and-switch §33/AC1: each setting's default is the manifest's, a value at its bound is taken, and one +// outside its bound or absent keeps the default. +func TestEachSettingsDefaultAndBound(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Config map[string]struct { + Default any `json:"default"` + Minimum *float64 `json:"minimum"` + Maximum *float64 `json:"maximum"` + Trusted bool `json:"trusted"` + } `json:"config"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + defaults := Defaults.Values() + if len(m.Config) != len(defaults) { + t.Fatalf("the manifest declares %d settings, the code %d", len(m.Config), len(defaults)) + } + file := filepath.Join(t.TempDir(), "settings.json") + read := func(k string, v any) any { + b, _ := json.Marshal(map[string]any{k: v}) + _ = os.WriteFile(file, b, 0o600) + return SettingsFrom(file).Values()[k] + } + for k, c := range m.Config { + if !c.Trusted || c.Default != defaults[k] { + t.Errorf("%s: the manifest's default %v (trusted %v), the code's %v", k, c.Default, c.Trusted, defaults[k]) + } + if c.Maximum == nil { + continue + } + lo, hi := *c.Minimum, *c.Maximum + for v, want := range map[float64]any{lo: lo, hi: hi, lo - 1: defaults[k], hi + 1: defaults[k]} { + if got := read(k, v); got != want { + t.Errorf("%s set to %v reads %v, want %v", k, v, got, want) + } + } + } + if read("switch_auto", false) != false || read("switch_at_pct", "high") != 99.0 { + t.Error("switch_auto off, or a value of the wrong type, is not read as written") + } +} + +// design 39 bus-and-switch §33: room_below_pct or warn_at_pct above switch_at_pct is an invalid pair naming both +// values; equal is valid. +func TestAnInvalidPairIsFound(t *testing.T) { + s := Defaults + if len(s.Invalid()) != 0 { + t.Fatal("the defaults are invalid") + } + s.SwitchAt, s.RoomBelow, s.WarnAt = 80, 85, 80 + got := s.Invalid() + if len(got) != 1 || got[0] != (Invalid{Setting: "room_below_pct", Value: 85, SwitchAt: 80}) { + t.Fatalf("invalid %+v", got) + } + s.WarnAt = 95 + if got := s.Invalid(); len(got) != 2 || got[1].Setting != "warn_at_pct" { + t.Fatalf("invalid %+v", got) + } +} + +// design 39 bus-and-switch §33: the settings are read again at every reading, and each change of a value read is +// written once in the audit under settings. +func TestASettingChangedIsReadAtTheNextReadingAndAudited(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + mm.m.SettingsFile = filepath.Join(t.TempDir(), "settings.json") + _ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 99}`), 0o600) + if err := mm.m.ReadUsage(ctx); err != nil || len(mm.audit.under("settings")) != 0 { + t.Fatalf("an unchanged file audited %v (%v)", mm.audit.under("settings"), err) + } + _ = os.WriteFile(mm.m.SettingsFile, []byte(`{"switch_at_pct": 95, "usage_minutes": 10}`), 0o600) + _ = mm.m.ReadUsage(ctx) + _ = mm.m.ReadUsage(ctx) + if s := mm.m.settings(); s.SwitchAt != 95 || s.UsageEvery.Minutes() != 10 { + t.Fatalf("in force %+v", s) + } + got := mm.audit.under("settings") + if len(got) != 1 { + t.Fatalf("audited %v", got) + } + if c, _ := got[0]["switch_at_pct"].(map[string]any); c["from"] != 99.0 || c["to"] != 95.0 { + t.Fatalf("audited %v", got[0]) + } +}