M-11: each reading keeps its three reset times, and a licence's room (issue 1409)

The usage reading flattens the session, weekly and sonnet windows each with
its reset time, keeps it beside the licence with when it was read, and says
it as usage.read; a failed answer keeps the last percentages and writes the
vendor's status and reason. A licence's room is 100 less the higher of its
session and weekly windows, known only for a subscription read within
usage_stale_minutes whose refresh is healthy; an API key's is never known.

Builds: design 39 bus-and-switch §24, §25, §48, §2

Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
This commit is contained in:
2026-11-11 23:55:22 +01:00
parent bd65b023bd
commit 6cf98386e9
5 changed files with 190 additions and 13 deletions
@@ -130,10 +130,13 @@ type Settings struct {
FailuresToNotify int FailuresToNotify int
Cooldown time.Duration // at most one notification per licence in this window Cooldown time.Duration // at most one notification per licence in this window
RefreshWarn time.Duration // warn this long before a refresh token itself expires 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. // 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. // SettingsFrom reads the settings file, keeping a default for anything absent or not positive.
func SettingsFrom(path string) Settings { 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) raw, err := m.Vendor.Usage(ctx, g.AccessToken)
g = FullGrant{} 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 continue
} }
reading := FlattenUsage(raw) reading := FlattenUsage(raw)
@@ -701,12 +718,16 @@ func (m *Manager) ReadUsage(ctx context.Context) error {
return err return err
} }
// The latest reading beside its licence, so licences answers from one key (§2). // 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 { if err := m.Store.SaveLicence(ctx, l); err != nil {
return err return err
} }
_ = m.Emit("usage.read", map[string]any{"licence": l.Name, "sessionPct": reading.SessionPct, _ = 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 return nil
} }
@@ -22,6 +22,9 @@ type stubVendor struct {
issued int issued int
account string account string
now func() time.Time 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 { 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) { 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 return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil
} }
@@ -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
}
@@ -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)
}
}
}
@@ -140,6 +140,8 @@ func (v liveVendor) Refresh(ctx context.Context, g FullGrant) Refreshed {
return NextGrant(g, r, time.Now().UnixMilli()) 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) { func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageEndpoint(), nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, usageEndpoint(), nil)
if err != 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) req.Header.Set("authorization", "Bearer "+accessToken)
resp, err := v.client.Do(req) resp, err := v.client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("the usage endpoint did not answer: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 { 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 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 { type UsageReading struct {
SessionPct *float64 `json:"sessionPct"` SessionPct *float64 `json:"sessionPct"`
SessionResetsAt string `json:"sessionResetsAt,omitempty"` SessionResetsAt string `json:"sessionResetsAt,omitempty"`
WeeklyPct *float64 `json:"weeklyPct"` WeeklyPct *float64 `json:"weeklyPct"`
WeeklyResetsAt string `json:"weeklyResetsAt,omitempty"`
SonnetPct *float64 `json:"sonnetPct"` 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 { func FlattenUsage(u map[string]any) UsageReading {
window := func(k string) (*float64, string) { window := func(k string) (*float64, string) {
w, ok := u[k].(map[string]any) w, ok := u[k].(map[string]any)
@@ -180,8 +195,9 @@ func FlattenUsage(u map[string]any) UsageReading {
} }
return &pct, resets return &pct, resets
} }
s, resets := window("five_hour") s, sResets := window("five_hour")
w, _ := window("seven_day") w, wResets := window("seven_day")
so, _ := window("seven_day_sonnet") so, soResets := window("seven_day_sonnet")
return UsageReading{SessionPct: s, SessionResetsAt: resets, WeeklyPct: w, SonnetPct: so} return UsageReading{SessionPct: s, SessionResetsAt: sResets, WeeklyPct: w, WeeklyResetsAt: wResets, SonnetPct: so,
SonnetResetsAt: soResets}
} }