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 ead306c05..420afb5b3 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -130,10 +130,13 @@ type Settings struct { 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} +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 { @@ -693,7 +696,21 @@ func (m *Manager) ReadUsage(ctx context.Context) error { } raw, err := m.Vendor.Usage(ctx, g.AccessToken) g = FullGrant{} - if err != nil || raw == nil { + if err == nil && raw == nil { + err = errors.New("the usage endpoint answered nothing") + } + if err != nil { + // A failed reading keeps the last percentages and writes the vendor's status and reason (§24). + kept := UsageReading{} + if l.Usage != nil { + kept = *l.Usage + } + kept.ReadError = err.Error() + l.Usage = &kept + if err := m.Store.SaveLicence(ctx, l); err != nil { + return err + } + m.Log("reading the usage of %s failed: %v", l.Name, err) continue } reading := FlattenUsage(raw) @@ -701,12 +718,16 @@ func (m *Manager) ReadUsage(ctx context.Context) error { return err } // The latest reading beside its licence, so licences answers from one key (§2). - l.Usage = &reading + latest := reading + latest.ReadAt = m.Now().UTC().Format(time.RFC3339Nano) + l.Usage = &latest if err := m.Store.SaveLicence(ctx, l); err != nil { return err } _ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct, - "weeklyPct": reading.WeeklyPct, "sonnetPct": reading.SonnetPct}) + "sessionResetsAt": reading.SessionResetsAt, "weeklyPct": reading.WeeklyPct, + "weeklyResetsAt": reading.WeeklyResetsAt, "sonnetPct": reading.SonnetPct, + "sonnetResetsAt": reading.SonnetResetsAt}) } return nil } 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 c0e5da2a6..648f740cc 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 @@ -22,6 +22,9 @@ type stubVendor struct { issued int account string now func() time.Time + // usage is what the usage endpoint answers, and usageErr its failure; nil answers a session window alone. + usage map[string]any + usageErr error } func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed { @@ -44,6 +47,12 @@ func (v *stubVendor) Refresh(_ context.Context, g FullGrant) Refreshed { } func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) { + if v.usageErr != nil { + return nil, v.usageErr + } + if v.usage != nil { + return v.usage, nil + } return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/room.go b/modules/claude-licence-manager/cmd/claude-licence-manager/room.go new file mode 100644 index 000000000..caa24142f --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/room.go @@ -0,0 +1,26 @@ +package main + +// A licence's room (design 39 bus-and-switch §25, §48): 100 less the higher of its session and weekly percentages in +// its latest reading, known only for a subscription read successfully within usage_stale_minutes, whose refresh has not +// failed failures_to_notify times in a row and whose refresh token has not expired. An API key's room is never known, +// so it is never a target. + +import ( + "math" + "time" +) + +// Room is a licence's room at now, and whether it is known. +func Room(l Licence, now time.Time, s Settings) (float64, bool) { + if l.Kind != "subscription" || l.Usage == nil || l.Usage.SessionPct == nil || l.Usage.WeeklyPct == nil { + return 0, false + } + read, err := time.Parse(time.RFC3339Nano, l.Usage.ReadAt) + if err != nil || now.Sub(read) > s.UsageStale { + return 0, false + } + if l.Failures >= s.FailuresToNotify || (l.RefreshExpiresAt != 0 && l.RefreshExpiresAt <= now.UnixMilli()) { + return 0, false + } + return 100 - math.Max(*l.Usage.SessionPct, *l.Usage.WeeklyPct), true +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go new file mode 100644 index 000000000..9f8f5b5b8 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +// vendorUsage is the vendor's usage answer as it reads: three windows, each with its reset time. +var vendorUsage = map[string]any{ + "five_hour": map[string]any{"utilization": 41.0, "resets_at": "2026-11-11T23:00:00Z"}, + "seven_day": map[string]any{"utilization": 97.0, "resets_at": "2026-11-14T09:00:00Z"}, + "seven_day_sonnet": map[string]any{"utilization": 12.0, "resets_at": "2026-11-15T09:00:00Z"}, +} + +// design 39 bus-and-switch §24/AC1: a vendor answer is flattened with its three reset times, kept beside the licence +// with when it was read, and said as usage.read with those fields. +func TestAReadingIsFlattenedWithThreeResets(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + mm.vendor.usage = vendorUsage + mm.events = nil + if err := mm.m.ReadUsage(ctx); err != nil { + t.Fatal(err) + } + l, _ := mm.store.Licence(ctx, licence1) + u := l.Usage + if u == nil || *u.SessionPct != 41 || u.SessionResetsAt != "2026-11-11T23:00:00Z" || *u.WeeklyPct != 97 || + u.WeeklyResetsAt != "2026-11-14T09:00:00Z" || *u.SonnetPct != 12 || u.SonnetResetsAt != "2026-11-15T09:00:00Z" || + u.ReadAt != t0.UTC().Format(time.RFC3339Nano) || u.ReadError != "" { + t.Fatalf("kept %+v", u) + } + said := strings.Join(mm.events, "\n") + for _, want := range []string{`"weeklyResetsAt":"2026-11-14T09:00:00Z"`, `"sonnetResetsAt":"2026-11-15T09:00:00Z"`, + `"sessionResetsAt":"2026-11-11T23:00:00Z"`} { + if !strings.Contains(said, "usage.read ") || !strings.Contains(said, want) { + t.Fatalf("said %s, lacking %s", said, want) + } + } +} + +// design 39 bus-and-switch §24/AC2: a failed answer keeps the last percentages and their time, writes the vendor's +// status and reason, and appends no reading. +func TestAFailedReadingKeepsThePercentages(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + mm.vendor.usage = vendorUsage + _ = mm.m.ReadUsage(ctx) + mm.now = t0.Add(5 * time.Minute) + mm.vendor.usageErr = errors.New(`the usage endpoint answered 429: {"error":"rate_limited"}`) + if err := mm.m.ReadUsage(ctx); err != nil { + t.Fatal(err) + } + l, _ := mm.store.Licence(ctx, licence1) + u := l.Usage + if u == nil || *u.WeeklyPct != 97 || u.ReadAt != t0.UTC().Format(time.RFC3339Nano) || + u.ReadError != `the usage endpoint answered 429: {"error":"rate_limited"}` { + t.Fatalf("kept %+v", u) + } + if n := len(mm.usage.entries); n != 1 { + t.Fatalf("a failed reading appended: %d entries", n) + } +} + +// design 39 bus-and-switch §25/AC1, §48: a table of readings, ages and healths and the room each gives. +func TestTheRoomOfEachLicence(t *testing.T) { + now := t0 + pct := func(v float64) *float64 { return &v } + read := func(session, weekly float64, ago time.Duration) *UsageReading { + return &UsageReading{SessionPct: pct(session), WeeklyPct: pct(weekly), ReadAt: now.Add(-ago).Format(time.RFC3339Nano)} + } + for _, c := range []struct { + name string + l Licence + room float64 + known bool + }{ + {"the higher window counts", Licence{Kind: "subscription", Usage: read(41, 97, time.Minute)}, 3, true}, + {"the session can be the higher", Licence{Kind: "subscription", Usage: read(80, 20, time.Minute)}, 20, true}, + {"read at the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 15*time.Minute)}, 90, true}, + {"read before the stale bound", Licence{Kind: "subscription", Usage: read(10, 10, 16*time.Minute)}, 0, false}, + {"a failure since keeps the room", Licence{Kind: "subscription", Usage: func() *UsageReading { + u := read(10, 30, 5*time.Minute) + u.ReadError = "the usage endpoint answered 500: x" + return u + }()}, 70, true}, + {"never read", Licence{Kind: "subscription"}, 0, false}, + {"no weekly window", Licence{Kind: "subscription", Usage: &UsageReading{SessionPct: pct(10), + ReadAt: now.Format(time.RFC3339Nano)}}, 0, false}, + {"refresh failing", Licence{Kind: "subscription", Failures: 3, Usage: read(10, 10, time.Minute)}, 0, false}, + {"refresh failed twice", Licence{Kind: "subscription", Failures: 2, Usage: read(10, 10, time.Minute)}, 90, true}, + {"refresh token expired", Licence{Kind: "subscription", RefreshExpiresAt: now.UnixMilli(), + Usage: read(10, 10, time.Minute)}, 0, false}, + {"an API key", Licence{Kind: "api-key", Usage: read(0, 0, 0)}, 0, false}, + } { + room, known := Room(c.l, now, Defaults) + if room != c.room || known != c.known { + t.Errorf("%s: room %v known %v, want %v %v", c.name, room, known, c.room, c.known) + } + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/vendor.go b/modules/claude-licence-manager/cmd/claude-licence-manager/vendor.go index 522bd8fe2..1b07c695f 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/vendor.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/vendor.go @@ -140,6 +140,8 @@ func (v liveVendor) Refresh(ctx context.Context, g FullGrant) Refreshed { return NextGrant(g, r, time.Now().UnixMilli()) } +// Usage is the vendor's usage answer whole; a failed answer is an error naming its status and reason, the body, never +// only the status (design 39 bus-and-switch §24). func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]any, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageEndpoint(), nil) if err != nil { @@ -148,25 +150,38 @@ func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]a req.Header.Set("authorization", "Bearer "+accessToken) resp, err := v.client.Do(req) if err != nil { - return nil, err + return nil, fmt.Errorf("the usage endpoint did not answer: %w", err) } defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode/100 != 2 { - return nil, fmt.Errorf("the usage endpoint answered %d", resp.StatusCode) + reason := string(body) + if len(reason) > 400 { + reason = reason[:400] + } + return nil, fmt.Errorf("the usage endpoint answered %d: %s", resp.StatusCode, reason) } var out map[string]any - return out, json.NewDecoder(resp.Body).Decode(&out) + if err := json.Unmarshal(body, &out); err != nil { + return nil, fmt.Errorf("the usage endpoint answered %d with what is not JSON", resp.StatusCode) + } + return out, nil } -// UsageReading is the licence-grain reading (ADR 0054), flattened from the vendor's windows. +// UsageReading is the licence-grain reading (ADR 0054), flattened from the vendor's windows (design 39 bus-and-switch +// §24), with, as the licence keeps it (§2), when it was last read successfully and why the latest reading failed. type UsageReading struct { SessionPct *float64 `json:"sessionPct"` SessionResetsAt string `json:"sessionResetsAt,omitempty"` WeeklyPct *float64 `json:"weeklyPct"` + WeeklyResetsAt string `json:"weeklyResetsAt,omitempty"` SonnetPct *float64 `json:"sonnetPct"` + SonnetResetsAt string `json:"sonnetResetsAt,omitempty"` + ReadAt string `json:"readAt,omitempty"` + ReadError string `json:"readError,omitempty"` } -// FlattenUsage reads the windows the predecessor read. +// FlattenUsage reads the session, weekly and sonnet windows, each with its reset time (§24). func FlattenUsage(u map[string]any) UsageReading { window := func(k string) (*float64, string) { w, ok := u[k].(map[string]any) @@ -180,8 +195,9 @@ func FlattenUsage(u map[string]any) UsageReading { } return &pct, resets } - s, resets := window("five_hour") - w, _ := window("seven_day") - so, _ := window("seven_day_sonnet") - return UsageReading{SessionPct: s, SessionResetsAt: resets, WeeklyPct: w, SonnetPct: so} + s, sResets := window("five_hour") + w, wResets := window("seven_day") + so, soResets := window("seven_day_sonnet") + return UsageReading{SessionPct: s, SessionResetsAt: sResets, WeeklyPct: w, WeeklyResetsAt: wResets, SonnetPct: so, + SonnetResetsAt: soResets} }