From ce9f984363d7fe04b41e715bf25a70be87fa4582 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 11:55:54 +0100 Subject: [PATCH 1/2] P1: the carry-over leaves the licence manager (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The carry-over from the store database ran live once and carried every row, so its code, the gate that answered "the carry-over has not ended", the condition carry-over-failed, the tool licences_carried, the opening of grants with grant.key and the postgres driver leave the module, with its requirement of the database and of the secret grant-key. The state carried stays as the carry-over's record; at its start the manager waits for nothing and clears a carry-over-failed it finds kept. Builds: design 39 parts §6 (the template files/database.url), §9, §10, §11 --- modules/claude-licence-manager/README.md | 26 +- .../cmd/claude-licence-manager/carry.go | 486 ------------------ .../cmd/claude-licence-manager/carry_test.go | 314 ----------- .../cmd/claude-licence-manager/conditions.go | 12 - .../claude-licence-manager/conditions_test.go | 29 +- .../cmd/claude-licence-manager/crypt.go | 55 -- .../cmd/claude-licence-manager/holdings.go | 2 +- .../cmd/claude-licence-manager/main.go | 58 +-- .../cmd/claude-licence-manager/manager.go | 6 +- .../claude-licence-manager/manager_test.go | 5 +- .../claude-licence-manager/manifest_test.go | 78 +++ .../cmd/claude-licence-manager/pgstore.go | 161 ------ .../cmd/claude-licence-manager/store_test.go | 39 +- .../cmd/claude-licence-manager/tools.go | 70 +-- .../cmd/claude-licence-manager/tools_test.go | 44 +- .../whole_integration_test.go | 353 ++----------- .../claude-licence-manager/files/database.url | 1 - modules/claude-licence-manager/go.mod | 6 - modules/claude-licence-manager/go.sum | 12 - modules/claude-licence-manager/module.json | 33 +- 20 files changed, 180 insertions(+), 1610 deletions(-) delete mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/carry.go delete mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go delete mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go delete mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go delete mode 100644 modules/claude-licence-manager/files/database.url diff --git a/modules/claude-licence-manager/README.md b/modules/claude-licence-manager/README.md index 6f7a2a6cb..9217ac3c9 100644 --- a/modules/claude-licence-manager/README.md +++ b/modules/claude-licence-manager/README.md @@ -6,8 +6,8 @@ ADR 0206, design 39 and its milestone bus-and-switch). Every grant and access to keeps none. It runs as an account of its own (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), in a runtime of its -own, never in the machine's tool runner: its service part (the carry-over at start, the usage timer, the switch rule, -the conditions) and its tools beside it, so every state, log and secret below is issued to its own account. Its +own, never in the machine's tool runner: its service part (the conditions judged at its start, the usage timer, the switch +rule) and its tools beside it, so every state, log and secret below is issued to its own account. Its account is issued what its manifest names: the asks under `invokes` (the vault, the operator channel, mesh-config and a node's `claude_code_grant`), the events under `emits` and `consumes`. @@ -15,7 +15,7 @@ a node's `claude_code_grant`), the events under `emits` and `consumes`. Nothing is configured. Every node running `claude-code` says what it holds as the event `claude-code.holdings-changed` (the account, fingerprints and expiries, never a token), at its start, on every change, -and again when this module says `holdings.asked` (at every start, after the carry-over, and on `licences_resync`): +and again when this module says `holdings.asked` (at every start, after its conditions are judged, and on `licences_resync`): 1. A report with a refresh token it does not hold is a **candidate**. 2. It asks that node's `claude_code_grant`, giving its public key, and receives the grant sealed to it. @@ -43,14 +43,12 @@ form the runtime issues today (design 39 bus-and-switch C3); each read by `licen | `offered` | state, a lifetime per key | each grant a node offered and its outcome, 90 days | | `usage` | log, 35 days, 256 MiB | every reading, one subject per licence key, with the vendor's answer whole | | `audit` | log, 64 MiB | every act, one subject per kind (`switched`, `carried`, `settings` among them) | -| `carried` | state | `postgres`: when the carry-over ran, and per table the rows read and entries written | +| `carried` | state | `postgres`: the record of the carry-over from the store database, which ran once on 2026-11-12; nothing writes it again | | `conditions` | state | each condition said raised, and since when | -Its database (`postgres-database`, read with the module's own credential) and `grant.key` are kept only as the -carry-over's source: at its first start, before it hears an event, refreshes or reads usage, the manager reads every -row of today's six tables and writes them here and to the vault, then `carried.postgres`. Until then it serves its -reads alone and answers every act with "the carry-over has not ended"; a failed read or write raises -`carry-over-failed`. With `carried.postgres` present it never reads the database again. The database is never written. +The carry-over from the store database is done, and its code has left the module with its requirement of the +database and of the secret `grant-key` (design 39 parts §9, §10); at its start the manager waits for nothing and +clears a `carry-over-failed` it finds kept. ## Switching on usage @@ -63,7 +61,7 @@ with the most room whose weekly window is below `room_below_pct`: one `binding.c Its conditions are said on the operator channel's condition subject, the router deciding where they go: `licence..no-room` (urgent; no licence has room), `licence..near-limit` (at `warn_at_pct`, once per -crossing), `licence..usage-unread`, `carry-over-failed` and `settings..invalid`. +crossing), `licence..usage-unread` and `settings..invalid`. ## The seat's verbs @@ -76,8 +74,7 @@ health, never the token) — through the console as `anthropic-licence-manager.< `licences_switches` (every switch and why), `licences_read_usage` (read now, `dry`), `licences_switch_now` (apply the rule now, even with `switch_auto` off), `licences_settings` (each value, its default and where it was set), `licences_kept` (every entry it keeps, how full against its cap), `licences_resync` (say every binding, holdings.asked -and each condition again, and hand each access token to the vault again), `licences_conditions`, `licences_carried` -(the carry-over's record; `run` while it has not ended). +and each condition again, and hand each access token to the vault again), and `licences_conditions`. ## Settings @@ -98,12 +95,11 @@ sealed box is `claude-code`'s own format, byte for byte — the two modules carr one sealed by the TypeScript agent module the Go one replaced, so the format is the one already on the machines. go test ./... - # the whole module on a real bus, and today's rows on a real postgres: + # the whole module on a real bus: go test -tags integration ./... The whole tests (`whole_integration_test.go`) start the built binary as its runtime does, its kept data in a real -nats-server, and today's database on `MESH_TEST_POSTGRES` or a private postgres started from the machine's `initdb` -on a socket of its own; with neither, the tests that need one skip and say so. The vault, the agent modules, the +nats-server. The vault, the agent modules, the operator channel and the vendor are played beside it; a condition is read through the operator channel's client. ## Files it does not know diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go b/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go deleted file mode 100644 index cf3199641..000000000 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/carry.go +++ /dev/null @@ -1,486 +0,0 @@ -package main - -// The carry-over (design 39 bus-and-switch §8, §11 to §13, A3, B14): at its first start the manager reads every row of -// today's database, the module's own and read only, and writes it into its kept data on the bus and its grants into -// the vault, then the key `carried.postgres`. Until that key exists it serves its read verbs alone and does nothing -// else. With the key present it never reads the database again. - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "sync" - "time" -) - -// CarriedKey is the key of the state `carried` the carry-over writes at its end (§8). -const CarriedKey = "postgres" - -// NotCarried is the answer of every acting verb and tool until the carry-over has ended (§11). -const NotCarried = "the carry-over has not ended" - -// usageKeptFor is how far back the carry-over reads usage: the log `usage` keeps no older reading (§6, §12). -const usageKeptFor = 35 * 24 * time.Hour - -// The tables of today's database, in the order the carry-over reads them (§11, §12). -var CarriedTables = []string{"licence", "binding", "lease", "offered", "usage", "audit"} - -// LicenceRow is a row of the table `licence`: the licence and its grant at rest, sealed with `grant.key`. -type LicenceRow struct { - Licence - Sealed string -} - -// OfferedRow is a row of the table `offered`. -type OfferedRow struct { - Fingerprint, Node, Account string - Outcome Outcome - Why string - At time.Time -} - -// UsageTableRow is a row of the table `usage`: the reading and the vendor's answer whole. -type UsageTableRow struct { - Licence string - At int64 // milliseconds - Reading UsageReading - Raw map[string]any -} - -// AuditRow is a row of the table `audit`. -type AuditRow struct { - At time.Time - What string - Detail map[string]any -} - -// CarrySource reads the six tables of today's database, each oldest first; a table that does not exist is no rows. -type CarrySource interface { - Licences(ctx context.Context) ([]LicenceRow, error) - Bindings(ctx context.Context) ([]Binding, error) - Leases(ctx context.Context) ([]LeaseRow, error) - Offered(ctx context.Context) ([]OfferedRow, error) - Usage(ctx context.Context, since time.Time) ([]UsageTableRow, error) - Audit(ctx context.Context) ([]AuditRow, error) -} - -// LeaseRow is one row of today's table `lease`: its key (`licence:` or `account:`), holder and until when. -type LeaseRow struct { - Key string - Holder string - Until time.Time -} - -// CarriedTable is one table in `carried.postgres`: the rows read and the entries written for them. -type CarriedTable struct { - Read int `json:"read"` - Written int `json:"written"` -} - -// CarriedRecord is the value of `carried.postgres` (§8): when, and for each table the rows read and entries written. -type CarriedRecord struct { - At string `json:"at"` - Tables map[string]CarriedTable `json:"tables"` -} - -// Carry is the carry-over's gate: closed until `carried.postgres` exists, and the failure of its last run. -type Carry struct { - State keptState // the state `carried` - // Source opens today's database; called only while the key is absent. - Source func(ctx context.Context) (CarrySource, error) - - mu sync.Mutex - running bool - failure string - done chan struct{} - once sync.Once -} - -func (c *Carry) init() { - c.mu.Lock() - if c.done == nil { - c.done = make(chan struct{}) - } - c.mu.Unlock() -} - -// Ended is whether the carry-over has ended: the key `carried.postgres` exists. -func (c *Carry) Ended() bool { - if c == nil { - return true // a manager built with no carry-over to wait on: the tests' own - } - c.init() - select { - case <-c.done: - return true - default: - } - if e, err := c.State.Get(CarriedKey); err == nil && e != nil && len(e.Value) > 0 { - c.end() - return true - } - return false -} - -func (c *Carry) end() { c.once.Do(func() { close(c.done) }) } - -// Wait is closed once the carry-over has ended. -func (c *Carry) Wait() <-chan struct{} { - c.init() - return c.done -} - -// Acts answers an acting verb or tool: nothing until the carry-over has ended (§11). -func (c *Carry) Acts() error { - if c.Ended() { - return nil - } - return errors.New(NotCarried) -} - -// Failure is what the last run that failed names, empty when none failed. -func (c *Carry) Failure() string { - c.mu.Lock() - defer c.mu.Unlock() - return c.failure -} - -// Run carries over every row when `carried.postgres` is absent, and says nothing else happened when it is present -// (§13). A run that fails names its table and error, and leaves the key absent, so the next is done again whole. -func (c *Carry) Run(ctx context.Context, m *Manager, s *BusStore) (*CarriedRecord, error) { - if c.Ended() { - return nil, nil - } - c.mu.Lock() - if c.running { - c.mu.Unlock() - return nil, errors.New("a carry-over is running") - } - c.running = true - c.mu.Unlock() - rec, err := c.run(ctx, m, s) - c.mu.Lock() - c.running = false - if err != nil { - c.failure = err.Error() - } else { - c.failure = "" - } - c.mu.Unlock() - if err != nil { - m.Log("the carry-over failed: %v", err) - return nil, err - } - c.end() - m.Log("carried over today's database: %v", rec.Tables) - return rec, nil -} - -func (c *Carry) run(ctx context.Context, m *Manager, s *BusStore) (*CarriedRecord, error) { - src, err := c.Source(ctx) - if err != nil { - return nil, fmt.Errorf("the database cannot be opened: %w", err) - } - if closer, ok := src.(interface{ Close() }); ok { - defer closer.Close() - } - co := carryOver{m: m, s: s, src: src, now: m.Now(), tables: map[string]CarriedTable{}} - if err := co.all(ctx); err != nil { - return nil, err - } - rec := CarriedRecord{At: co.now.UTC().Format(time.RFC3339), Tables: co.tables} - detail := map[string]any{"why": "the first start of the manager on the bus", "by": Itself, "tables": rec.Tables} - if err := s.Audit(ctx, "carried", detail); err != nil { - return nil, fmt.Errorf("carried: %w", err) - } - if _, err := c.State.Put(CarriedKey, rec); err != nil { - return nil, fmt.Errorf("carried: the key %s was not written: %w", CarriedKey, err) - } - return &rec, nil -} - -// carryOver is one run of the carry-over. -type carryOver struct { - m *Manager - s *BusStore - src CarrySource - now time.Time - tables map[string]CarriedTable - audit []AuditRow -} - -func (co *carryOver) all(ctx context.Context) error { - // The audit is read first: a licence's adoptedFrom comes from it (B14). - audit, err := co.src.Audit(ctx) - if err != nil { - return fmt.Errorf("audit: %w", err) - } - co.audit = audit - for _, table := range CarriedTables { - var t CarriedTable - var err error - switch table { - case "licence": - t, err = co.licences(ctx) - case "binding": - t, err = co.bindings(ctx) - case "lease": - t, err = co.leases(ctx) - case "offered": - t, err = co.offered(ctx) - case "usage": - t, err = co.usage(ctx) - case "audit": - t, err = co.audits() - } - if err != nil { - return fmt.Errorf("%s: %w", table, err) - } - co.tables[table] = t - } - return nil -} - -// adoptedFrom is the node the newest adoption of a licence names in the audit (B14). -func (co *carryOver) adoptedFrom(name string) string { - from := "" - for _, r := range co.audit { - if r.What != "adopted" || r.Detail["licence"] != name { - continue - } - if n, _ := r.Detail["node"].(string); n != "" { - from = n - } else if f, _ := r.Detail["from"].(string); f != "" { - from = f - } - } - return from -} - -// licences writes a key of `licences` per row, its grant opened with `grant.key` and handed to the vault at -// generation 1, the grant first (§12, §15). -func (co *carryOver) licences(ctx context.Context) (CarriedTable, error) { - rows, err := co.src.Licences(ctx) - if err != nil { - return CarriedTable{}, err - } - t := CarriedTable{Read: len(rows)} - for _, r := range rows { - l := r.Licence - l.Generation, l.Usage = 0, nil - l.AdoptedFrom = co.adoptedFrom(l.Name) - if r.Sealed != "" { - if co.m.Crypt == nil { - return t, fmt.Errorf("the grant of %s cannot be opened: no grant.key", l.Name) - } - plain, err := co.m.Crypt.Open(r.Sealed) - if err != nil { - return t, fmt.Errorf("the grant of %s: %w", l.Name, err) - } - grant, access := plain, plain - if l.Kind == "subscription" { - var g FullGrant - if err := json.Unmarshal([]byte(plain), &g); err != nil { - return t, fmt.Errorf("the grant of %s is not a grant", l.Name) - } - access = accessOf(g) - } - if err := co.m.handOver(ctx, &l, grant, access); err != nil { - return t, err - } - } - if err := co.s.SaveLicence(ctx, l); err != nil { - return t, err - } - t.Written++ - } - return t, nil -} - -// bindings writes a key of `bindings` per row, its generation kept, why "carried over", by the manager, since the -// carry-over's time (§12, B14); a key a run before this one wrote for the same row keeps its since, so it is put again -// with the same value (§13). -func (co *carryOver) bindings(ctx context.Context) (CarriedTable, error) { - rows, err := co.src.Bindings(ctx) - if err != nil { - return CarriedTable{}, err - } - kinds := map[string]string{} - if all, err := co.s.Licences(ctx); err == nil { - for _, l := range all { - kinds[l.Name] = l.Kind - } - } else { - return CarriedTable{}, err - } - t := CarriedTable{Read: len(rows)} - for _, r := range rows { - v := BindingState{Licence: r.Licence, Kind: kinds[r.Licence], Generation: r.Generation, - Since: co.now.UTC().Format(time.RFC3339), Why: "carried over", By: Itself} - var held BindingState - ok, err := co.s.get(co.s.BindingsState, r.Consumer, &held) - if err != nil { - return t, err - } - if ok && held.Licence == v.Licence && held.Generation == v.Generation && held.Why == v.Why { - v.Since = held.Since - } - if _, err := co.s.BindingsState.Put(r.Consumer, v); err != nil { - return t, err - } - // Said after its key is written, as every binding's change is (§21), so a reader's first view taken during the - // carry-over is brought up to date by the event. - from := "" - if ok && held.Licence != v.Licence { - from = held.Licence - } - co.m.changed(Binding{Consumer: r.Consumer, Licence: v.Licence, Kind: v.Kind, Generation: v.Generation, Why: v.Why, By: v.By}, from) - t.Written++ - } - return t, nil -} - -// leases writes a key of `leases` per lease still live at the run, with its remaining lifetime, so the manager never -// refreshes a licence the bundle before it holds a lease on, whatever the delivery's order; an expired one is counted -// read and not written (B44). Its key takes the bus's form: `licence.` or `account.` (§4). -func (co *carryOver) leases(ctx context.Context) (CarriedTable, error) { - rows, err := co.src.Leases(ctx) - if err != nil { - return CarriedTable{}, err - } - t := CarriedTable{Read: len(rows)} - for _, r := range rows { - left := r.Until.Sub(co.now) - if left <= 0 { - continue - } - key := "" - if name, ok := strings.CutPrefix(r.Key, "licence:"); ok { - key = "licence." + KeyFor(name) - } else if account, ok := strings.CutPrefix(r.Key, "account:"); ok { - key = "account." + account - } else { - co.m.Log("the lease %s names neither a licence nor an account; it is not carried", r.Key) - continue - } - v := leaseValue{Holder: r.Holder, Until: r.Until.UTC().Format(time.RFC3339)} - if _, err := co.s.LeasesState.PutTTL(key, v, left); err != nil { - return t, err - } - t.Written++ - } - return t, nil -} - -// offered writes a key of `offered` per row, its lifetime counted from the row's `at`; a row past its lifetime is -// gone already and written nowhere (§5, §12). -func (co *carryOver) offered(ctx context.Context) (CarriedTable, error) { - rows, err := co.src.Offered(ctx) - if err != nil { - return CarriedTable{}, err - } - t := CarriedTable{Read: len(rows)} - for _, r := range rows { - left := offeredFor - co.now.Sub(r.At) - if left <= 0 { - continue - } - v := offeredValue{Node: r.Node, Account: r.Account, Outcome: r.Outcome, Why: r.Why, - At: r.At.UTC().Format(time.RFC3339)} - if err := co.s.putOffered(r.Fingerprint, v, left); err != nil { - return t, err - } - t.Written++ - } - return t, nil -} - -// lastAt is the `at` of a log's last entry for a subject, zero when it has none: rows at or before it are in the log -// already (§13). -func lastAt(l keptLog, key string) (time.Time, error) { - e, err := l.Last(key) - if err != nil || e == nil { - return time.Time{}, err - } - var v struct { - At string `json:"at"` - } - if json.Unmarshal(e.Value, &v) != nil { - return time.Time{}, nil - } - at, _ := time.Parse(time.RFC3339Nano, v.At) - return at, nil -} - -// usage appends one entry of the log `usage` per row of the last 35 days, oldest first, its `at` kept (§12). -func (co *carryOver) usage(ctx context.Context) (CarriedTable, error) { - rows, err := co.src.Usage(ctx, co.now.Add(-usageKeptFor)) - if err != nil { - return CarriedTable{}, err - } - t := CarriedTable{Read: len(rows)} - last := map[string]time.Time{} - for _, r := range rows { - key := KeyFor(r.Licence) - before, seen := last[key] - if !seen { - if before, err = lastAt(co.s.UsageLog, key); err != nil { - return t, err - } - last[key] = before - } - at := time.UnixMilli(r.At).UTC() - if !at.After(before) { - t.Written++ // in the log already, from a run before this one - continue - } - if _, err := co.s.UsageLog.Append(key, UsageEntry{At: at.Format(time.RFC3339Nano), Reading: r.Reading, - Raw: r.Raw}); err != nil { - return t, err - } - t.Written++ - } - return t, nil -} - -// audits appends one entry of the log `audit` per row, oldest first, its `at` kept, under the subject its `what` -// names, `drift` for any other (§7, §12). -func (co *carryOver) audits() (CarriedTable, error) { - t := CarriedTable{Read: len(co.audit)} - last := map[string]time.Time{} - for _, r := range co.audit { - kind := "drift" - for _, k := range AuditKinds { - if k == r.What { - kind = k - } - } - before, seen := last[kind] - if !seen { - var err error - if before, err = lastAt(co.s.AuditLog, kind); err != nil { - return t, err - } - last[kind] = before - } - if !r.At.After(before) { - t.Written++ // in the log already, from a run before this one - continue - } - entry := map[string]any{} - for k, v := range r.Detail { - entry[k] = v - } - entry["at"] = r.At.UTC().Format(time.RFC3339Nano) - if kind != r.What { - entry["what"] = r.What - } - if _, err := co.s.AuditLog.Append(kind, entry); err != nil { - return t, fmt.Errorf("the audit did not take the carried %s entry: %w", r.What, err) - } - t.Written++ - } - return t, nil -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go deleted file mode 100644 index 9c7782424..000000000 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/carry_test.go +++ /dev/null @@ -1,314 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "regexp" - "strings" - "testing" - "time" - - "github.com/jackc/pgx/v5/pgconn" -) - -// memSource is today's database as the carry-over reads it, from rows given; failAt makes one table's read fail. -type memSource struct { - licences []LicenceRow - bindings []Binding - leases []LeaseRow - offered []OfferedRow - usage []UsageTableRow - audit []AuditRow - failAt string -} - -func (s *memSource) fail(table string) error { - if s.failAt == table { - return errors.New("connection reset by peer") - } - return nil -} -func (s *memSource) Licences(context.Context) ([]LicenceRow, error) { - return s.licences, s.fail("licence") -} -func (s *memSource) Bindings(context.Context) ([]Binding, error) { - return s.bindings, s.fail("binding") -} -func (s *memSource) Leases(context.Context) ([]LeaseRow, error) { return s.leases, s.fail("lease") } -func (s *memSource) Offered(context.Context) ([]OfferedRow, error) { - return s.offered, s.fail("offered") -} -func (s *memSource) Audit(context.Context) ([]AuditRow, error) { return s.audit, s.fail("audit") } -func (s *memSource) Usage(_ context.Context, since time.Time) ([]UsageTableRow, error) { - var out []UsageTableRow - for _, u := range s.usage { - if u.At >= since.UnixMilli() { - out = append(out, u) - } - } - return out, s.fail("usage") -} - -// todaysRows is a copy of the shape of today's rows: a subscription and an API key, two bindings, a lease, two offers, -// three readings (one older than 35 days) and four audit rows (one of a kind the log does not name). -func todaysRows(t *testing.T, mm *miniMesh) *memSource { - t.Helper() - grant, _ := json.Marshal(FullGrant{AccessToken: "at-old", RefreshToken: "rt-old", ExpiresAt: 7}) - pct := 42.0 - return &memSource{ - licences: []LicenceRow{ - {Licence: Licence{Name: licence1, Kind: "subscription", AccountUUID: "acct-1", Email: licence1, - RefreshFingerprint: Fingerprint("rt-old"), AccessExpiresAt: 7, AdoptedAt: 1}, Sealed: sealAtRest(t, mm.m.Crypt, string(grant))}, - {Licence: Licence{Name: "api", Kind: "api-key", AdoptedAt: 2}, Sealed: sealAtRest(t, mm.m.Crypt, "sk-ant-api03-x")}, - }, - bindings: []Binding{{Consumer: "laptop", Licence: licence1, Generation: 1013}, {Consumer: "server", Licence: "api", Generation: 1016}}, - leases: []LeaseRow{{Key: "licence:x", Holder: "novox:1", Until: t0.Add(-time.Minute)}}, - offered: []OfferedRow{{Fingerprint: "sha256:aa", Node: "laptop", Account: "acct-1", Outcome: Adopted, Why: "refreshed", At: t0.Add(-24 * time.Hour)}, - {Fingerprint: "sha256:bb", Node: "server", Outcome: Dead, Why: "400", At: t0.Add(-48 * time.Hour)}}, - usage: []UsageTableRow{ - {Licence: licence1, At: t0.Add(-40 * 24 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}}, - {Licence: licence1, At: t0.Add(-2 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}, Raw: map[string]any{"five_hour": 1.0}}, - {Licence: licence1, At: t0.Add(-1 * time.Hour).UnixMilli(), Reading: UsageReading{SessionPct: &pct}}, - }, - audit: []AuditRow{ - {At: t0.Add(-72 * time.Hour), What: "adopted", Detail: map[string]any{"licence": licence1, "node": "laptop"}}, - {At: t0.Add(-71 * time.Hour), What: "bound", Detail: map[string]any{"consumer": "laptop"}}, - {At: t0.Add(-70 * time.Hour), What: "switched", Detail: map[string]any{"consumer": "server"}}, - {At: t0.Add(-69 * time.Hour), What: "grant-moved", Detail: map[string]any{"licence": "api"}}, - }, - } -} - -func carryFor(mm *miniMesh, carried *memState, src CarrySource) *Carry { - return &Carry{State: carried, Source: func(context.Context) (CarrySource, error) { return src, nil }} -} - -func readCarried(t *testing.T, carried *memState) CarriedRecord { - t.Helper() - var rec CarriedRecord - e, _ := carried.Get(CarriedKey) - if e == nil { - t.Fatal("carried.postgres is not written") - } - _ = json.Unmarshal(e.Value, &rec) - return rec -} - -// design 39 bus-and-switch §11/AC1, §12/AC1: every row is carried over and the key written; the counts in -// carried.postgres equal the rows read for every table but lease, and the vault answers each licence's grant. -func TestEveryRowIsCarriedOver(t *testing.T) { - mm := newMesh(t) - carried := newMemState(func() time.Time { return mm.now }) - src := todaysRows(t, mm) - c := carryFor(mm, carried, src) - if _, err := c.Run(context.Background(), mm.m, mm.store); err != nil { - t.Fatal(err) - } - rec := readCarried(t, carried) - want := map[string]int{"licence": 2, "binding": 2, "lease": 1, "offered": 2, "usage": 2, "audit": 4} - for table, n := range want { - got := rec.Tables[table] - if got.Read != n || (table != "lease" && got.Written != got.Read) { - t.Errorf("%s: read %d written %d, want %d read and as many written", table, got.Read, got.Written, n) - } - } - if g, err := mm.grants.Get(context.Background(), KeyFor(licence1), PartGrant); err != nil || !strings.Contains(g, "rt-old") { - t.Fatalf("the vault answers the grant %q, %v", g, err) - } - if a, _ := mm.grants.Get(context.Background(), KeyFor(licence1), PartAccess); strings.Contains(a, "rt-old") || !strings.Contains(a, "at-old") { - t.Fatalf("the access secret is %q", a) - } - if a, _ := mm.grants.Get(context.Background(), KeyFor("api"), PartAccess); a != "sk-ant-api03-x" { - t.Fatalf("the API key's access is %q", a) - } - l, _ := mm.store.Licence(context.Background(), licence1) - if l == nil || l.Generation != 1 || l.AdoptedFrom != "laptop" || l.AccountUUID != "acct-1" || l.AccessExpiresAt != 7 { - t.Fatalf("the licence is %+v", l) - } - if b := mm.bound("laptop"); b.Generation != 1013 || b.Why != "carried over" || b.By != Itself || b.Kind != "subscription" || b.Since == "" { - t.Fatalf("laptop's binding %+v", b) - } - if u := mm.offered.until[OfferedKey("sha256:aa")]; !u.Equal(t0.Add(-24 * time.Hour).Add(offeredFor)) { - t.Fatalf("the offer lives until %v", u) - } - if got := mm.audit.under("drift"); len(got) != 1 || got[0]["licence"] != "api" { - t.Fatalf("drift holds %v", got) - } - if got := mm.audit.under("adopted"); len(got) != 1 || got[0]["at"] != t0.Add(-72*time.Hour).Format(time.RFC3339Nano) { - t.Fatalf("adopted holds %v", got) - } - if got := mm.usage.under(KeyFor(licence1)); len(got) != 2 || got[0]["raw"] == nil { - t.Fatalf("usage holds %v", got) - } - if len(mm.audit.under("carried")) != 1 || !c.Ended() { - t.Fatal("the carry-over did not say it carried, or did not end") - } - // §21: each carried binding is said as binding.changed after its key is written. - said := map[string]bool{} - for _, e := range mm.changedSince(0) { - if e["why"] == "carried over" && e["key"] == KeyFor(fmt.Sprint(e["licence"])) { - said[fmt.Sprint(e["consumer"])] = true - } - } - if !said["laptop"] || !said["server"] || len(mm.changedSince(0)) != 2 { - t.Fatalf("the carry-over said %v", mm.changedSince(0)) - } -} - -// design 39 bus-and-switch §13/AC1: a second start, with carried.postgres present, never opens the database and -// writes nothing. -func TestASecondStartCarriesNothing(t *testing.T) { - mm := newMesh(t) - carried := newMemState(func() time.Time { return mm.now }) - if _, err := carryFor(mm, carried, todaysRows(t, mm)).Run(context.Background(), mm.m, mm.store); err != nil { - t.Fatal(err) - } - writes := carried.writes + mm.licences.writes + mm.bindings.writes + mm.offered.writes - entries := len(mm.audit.entries) + len(mm.usage.entries) - puts := len(mm.grants.puts) - opened := false - again := &Carry{State: carried, Source: func(context.Context) (CarrySource, error) { opened = true; return nil, errors.New("no") }} - if rec, err := again.Run(context.Background(), mm.m, mm.store); rec != nil || err != nil || opened { - t.Fatalf("a second start answered %v %v, opened the database: %v", rec, err, opened) - } - if carried.writes+mm.licences.writes+mm.bindings.writes+mm.offered.writes != writes || - len(mm.audit.entries)+len(mm.usage.entries) != entries || len(mm.grants.puts) != puts { - t.Fatal("a second start wrote") - } -} - -// design 39 bus-and-switch §13/AC2: a carry-over stopped after bindings leaves no key, names its table, and a start -// again carries over whole: every count equals the rows read, and no log entry is appended twice. -func TestAStoppedCarryOverIsDoneAgainWhole(t *testing.T) { - mm := newMesh(t) - carried := newMemState(func() time.Time { return mm.now }) - src := todaysRows(t, mm) - src.failAt = "offered" - c := carryFor(mm, carried, src) - if _, err := c.Run(context.Background(), mm.m, mm.store); err == nil || !strings.Contains(c.Failure(), "offered") { - t.Fatalf("a stopped carry-over answered %v, its failure %q", err, c.Failure()) - } - if e, _ := carried.Get(CarriedKey); e != nil || c.Ended() { - t.Fatal("a stopped carry-over wrote its key") - } - src.failAt = "" - since := mm.bound("laptop").Since - mm.now = mm.now.Add(time.Minute) - if _, err := c.Run(context.Background(), mm.m, mm.store); err != nil { - t.Fatal(err) - } - for table, got := range readCarried(t, carried).Tables { - if table != "lease" && got.Written != got.Read { - t.Errorf("%s: read %d written %d", table, got.Read, got.Written) - } - } - if b := mm.bound("laptop"); b.Since != since { - t.Fatalf("the binding put again changed its since from %s to %s", since, b.Since) - } - if n := len(mm.audit.under("")); n != 5 { - t.Fatalf("the audit holds %d entries, want the four rows and one carried", n) - } - // the run stopped before the logs; a second pass over a log already written appends nothing. - co := carryOver{m: mm.m, s: mm.store, src: src, now: mm.now, tables: map[string]CarriedTable{}, audit: src.audit} - if _, err := co.usage(context.Background()); err != nil { - t.Fatal(err) - } - if got, err := co.audits(); err != nil || got.Written != 4 || len(mm.audit.under("")) != 5 || len(mm.usage.under("")) != 2 { - t.Fatalf("a log written again appended: %+v %v, %d usage entries", got, err, len(mm.usage.under(""))) - } -} - -// design 39 bus-and-switch §11/AC2: with the database unreachable the carry-over fails naming it, every acting verb -// answers "the carry-over has not ended", a read verb is served, and no vendor call is made. -func TestAnUnreachableDatabaseActsNotAndCallsNoVendor(t *testing.T) { - mm := newMesh(t) - carried := newMemState(func() time.Time { return mm.now }) - mm.m.Carry = &Carry{State: carried, Source: func(context.Context) (CarrySource, error) { - return nil, errors.New("dial tcp 192.0.2.1:5432: connect: connection refused") - }} - if _, err := mm.m.Carry.Run(context.Background(), mm.m, mm.store); err == nil || !strings.Contains(mm.m.Carry.Failure(), "connection refused") { - t.Fatalf("the run answered %v, its failure %q", err, mm.m.Carry.Failure()) - } - built = mm.m - t.Cleanup(func() { built = nil }) - served := map[string]stdTool{} - for _, tool := range tools() { - served[strings.TrimPrefix(tool.Name, Seat+".")] = stdTool(tool.Run) - } - for _, name := range []string{"bind", "switch", "release", "refresh", "adopt"} { - if _, err := served[name](map[string]any{"consumer": "laptop", "licence": licence1, "name": "api", "file": "/none"}); err == nil || err.Error() != NotCarried { - t.Errorf("%s answered %v", name, err) - } - } - for _, name := range []string{"licences", "bindings", "usage", "public-key"} { - if _, err := served[name](map[string]any{}); err != nil { - t.Errorf("the read verb %s answered %v", name, err) - } - } - if len(mm.vendor.exchanged) != 0 { - t.Fatalf("the vendor was called: %v", mm.vendor.exchanged) - } -} - -type stdTool func(map[string]any) (any, error) - -// design 39 bus-and-switch §11/AC3: a table that does not exist (a fresh install) is read as no rows; any other -// failure of the database is one. -func TestAMissingTableIsNoRows(t *testing.T) { - if !noTable(&pgconn.PgError{Code: "42P01", Message: `relation "offered" does not exist`}) { - t.Fatal("a missing table is a failure") - } - for _, err := range []error{&pgconn.PgError{Code: "28P01"}, errors.New("connection refused"), nil} { - if noTable(err) { - t.Fatalf("%v is read as a missing table", err) - } - } -} - -// design 39 bus-and-switch §1/AC3: no code of the manager writes to its database. -func TestNoWriteToTheDatabase(t *testing.T) { - write := regexp.MustCompile(`(?i)\b(insert\s+into|update\s+\w+\s+set|delete\s+from|create\s+(table|sequence|index)|drop\s+(table|sequence|index)|alter\s+table|truncate\s|setval\s*\(|nextval\s*\()`) - files, _ := os.ReadDir(".") - for _, f := range files { - if !strings.HasSuffix(f.Name(), ".go") || strings.HasSuffix(f.Name(), "_test.go") { - continue - } - raw, err := os.ReadFile(f.Name()) - if err != nil { - t.Fatal(err) - } - if w := write.Find(raw); w != nil { - t.Errorf("%s writes to the database: %q", f.Name(), w) - } - } -} - -// design 39 bus-and-switch §12, B44: a lease still live at the run is carried with its remaining lifetime, and a -// refresh waits for it to end; an expired one is counted read and not written. -func TestALiveLeaseIsCarriedAndBlocksARefreshUntilItEnds(t *testing.T) { - mm := newMesh(t) - carried := newMemState(func() time.Time { return mm.now }) - src := todaysRows(t, mm) - src.leases = []LeaseRow{{Key: "licence:" + licence1, Holder: "the old bundle", Until: mm.now.Add(90 * time.Second)}, - {Key: "account:acct-1", Holder: "the old bundle", Until: mm.now.Add(-time.Second)}} - if _, err := carryFor(mm, carried, src).Run(context.Background(), mm.m, mm.store); err != nil { - t.Fatal(err) - } - if got := readCarried(t, carried).Tables["lease"]; got.Read != 2 || got.Written != 1 { - t.Fatalf("lease: read %d written %d, want 2 read and 1 written", got.Read, got.Written) - } - if e, _ := mm.leases.Get("account.acct-1"); e != nil { - t.Fatalf("an expired lease was carried: %s", e.Value) - } - r, err := mm.m.Rotate(context.Background(), licence1, true) - if err != nil || r["refreshed"] != false || r["reason"] != "another run holds its lease" { - t.Fatalf("a refresh under the carried lease answered %v, %v", r, err) - } - mm.now = mm.now.Add(91 * time.Second) - if r, err := mm.m.Rotate(context.Background(), licence1, true); err != nil || r["reason"] == "another run holds its lease" { - t.Fatalf("once the carried lease ended the refresh answered %v, %v", r, err) - } -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go index 4e53daaa0..c8d3d3796 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go @@ -22,9 +22,6 @@ import ( // 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 @@ -91,15 +88,6 @@ func machines(n int) string { // 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) 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 index 1fab35649..2ef22da9b 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go @@ -65,16 +65,6 @@ func TestAConditionIsKeptThenSaidOnce(t *testing.T) { _ = 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) @@ -107,7 +97,7 @@ func TestAConditionIsKeptThenSaidOnce(t *testing.T) { } } -// design 39 bus-and-switch §9/AC2: at its start the manager says cleared each key of `conditions` it no longer holds, +// design 39 bus-and-switch §9/AC2, design 39 parts §11: 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() @@ -118,18 +108,25 @@ func TestAStartClearsWhatItNoLongerHolds(t *testing.T) { mm.m.Settings.WarnAt = 100 _ = mm.m.SayConditions(ctx, false) _, _ = kept.Put(gone.Key, KeptCondition{Body: gone, Since: "2026-11-11T20:00:00Z"}) + // design 39 parts §11: a carry-over-failed key kept from before the carry-over left is cleared like any other. + carry := condition("carry-over-failed", occ.SeverityUrgent, "The licence manager could not carry over its licences", + "s", "", "", "The licence manager carried over its licences") + _, _ = kept.Put(carry.Key, KeptCondition{Body: carry, Since: "2026-11-12T09: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" { + if strings.Join(of(*said, gone.Key), ",") != "cleared" || strings.Join(of(*said, carry.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 (*said)[0].Resolved != carry.Resolved || (*said)[1].Resolved != gone.Resolved { + t.Fatalf("the clearings carry %+v", *said) } - if e, _ := kept.Get(gone.Key); e != nil { - t.Fatal("a key no longer held is kept") + for _, key := range []string{gone.Key, carry.Key} { + if e, _ := kept.Get(key); e != nil { + t.Fatalf("%s, no longer held, is kept", key) + } } } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go b/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go deleted file mode 100644 index 2857e5f97..000000000 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go +++ /dev/null @@ -1,55 +0,0 @@ -package main - -// The grants at rest (novox/hq ADR 0183): encrypted with a key the vault made for this module, so the -// store holds ciphertext and only this module, reading its own secret, can open a row. AES-256-GCM, the -// key derived from the vault's secret by SHA-256 so a secret of any length serves. - -import ( - "crypto/sha256" - "encoding/base64" - "errors" - "os" - "strings" -) - -// Crypt opens what today's database keeps at rest. -type Crypt struct{ key []byte } - -// NewCrypt is the store's cipher from the vault's secret. -func NewCrypt(secret string) (*Crypt, error) { - secret = strings.TrimSpace(secret) - if secret == "" { - return nil, errors.New("the key the grants are encrypted with is empty: the vault has not delivered it yet") - } - sum := sha256.Sum256([]byte(secret)) - return &Crypt{key: sum[:]}, nil -} - -// CryptFromFile reads the vault's secret from the file the mesh delivered it to. -func CryptFromFile(path string) (*Crypt, error) { - raw, err := os.ReadFile(path) - if err != nil { - return nil, err - } - return NewCrypt(string(raw)) -} - -// Open decrypts a grant today's database keeps at rest, `v1..`: the carry-over's alone, since -// nothing seals a grant at rest any more (design 39 bus-and-switch §20, B15). -func (c *Crypt) Open(sealed string) (string, error) { - parts := strings.Split(sealed, ".") - if len(parts) != 3 || parts[0] != "v1" { - return "", errors.New("not a grant this module sealed") - } - iv, err1 := base64.StdEncoding.DecodeString(parts[1]) - ct, err2 := base64.StdEncoding.DecodeString(parts[2]) - if err := errors.Join(err1, err2); err != nil { - return "", err - } - gcm, _ := newGCM(c.key) - plain, err := gcm.Open(nil, iv, ct, nil) - if err != nil { - return "", errors.New("the grant does not open with this module's key") - } - return string(plain), nil -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/holdings.go b/modules/claude-licence-manager/cmd/claude-licence-manager/holdings.go index 1925b9613..27793e5b6 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/holdings.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/holdings.go @@ -2,7 +2,7 @@ package main // What every node holds, heard as events (design 39 bus-and-switch §23, §46, B12): the agent module says // `holdings-changed` with its holdings report whole, at its start, on every change and when asked; the manager says -// `holdings.asked` at every start, after its carry-over, and on its resync, and takes each report it hears as that +// `holdings.asked` at every start, after its conditions are judged, and on its resync, and takes each report it hears as that // node's latest. import ( 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 1d34a8da9..48d5d0bd2 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -1,7 +1,7 @@ // claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by a runtime of its own under // its own account (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), never by the machine's tool runner, // and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). Its service part runs long (the -// carry-over at start, the usage timer, the switch rule, the conditions): it watches what every node reports holding +// conditions judged at its start, the usage timer, the switch rule): it watches what every node reports holding // and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage. Its tools part, the // seat's verbs and the module's tools, is served beside it and acts only through that one running manager. // @@ -45,20 +45,16 @@ var ( ) // manager builds the rules' dependencies once, from the module's words (ADR 0192): file paths, never -// values. A failure is said and tried again on the next call, so a database that arrives late is not fatal. +// values. A failure is said and tried again on the next call, so a key pair that cannot be made yet is not fatal. func manager() (*Manager, error) { buildMu.Lock() defer buildMu.Unlock() if built != nil { return built, nil } - dir, keyFile := os.Getenv("MESH_LICENCE_STATE"), os.Getenv("MESH_LICENCE_KEY_FILE") - if dir == "" || keyFile == "" { - return nil, errors.New("MESH_LICENCE_STATE and MESH_LICENCE_KEY_FILE are not set: the mesh renders them for this module") - } - crypt, err := CryptFromFile(keyFile) - if err != nil { - return nil, err + dir := os.Getenv("MESH_LICENCE_STATE") + if dir == "" { + return nil, errors.New("MESH_LICENCE_STATE is not set: the mesh renders it for this module") } keys, err := keypair(dir, say) if err != nil { @@ -72,14 +68,10 @@ func manager() (*Manager, error) { Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"), LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), UsageLog: stdio.Log("usage"), AuditLog: stdio.Log("audit"), Now: time.Now}, - Vendor: LiveVendor(), - Crypt: crypt, - // Today's database is the carry-over's source alone, read once and never written (§11, A3). - Carry: &Carry{State: stdio.State("carried"), Source: func(ctx context.Context) (CarrySource, error) { - return PgSourceFromEnv(ctx) - }}, - Grants: VaultGrants{Keys: keys}, - Keys: keys, + Vendor: LiveVendor(), + Carried: stdio.State("carried"), + Grants: VaultGrants{Keys: keys}, + Keys: keys, AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) { var a GrantAnswer raw, err := stdio.Ask("claude-code.claude_code_grant@"+node, map[string]any{"public_key": publicKey}) @@ -192,30 +184,23 @@ func writeKeyPair(dir, file string, replace bool) error { // heard is what every node last said it holds (ADR 0206, design 39 bus-and-switch §23), by node. var heard = &Heard{} -// carryOverFirst runs the carry-over at the start, before the manager hears an event, refreshes or reads usage (§11), and -// answers once it has ended: at once when `carried.postgres` exists, else when a run writes it, this one or one asked -// by hand later. -func carryOverFirst() { +// judgedAtStart judges every condition anew at the start, before the manager asks what the nodes hold and starts its +// timers: raised again each it holds, cleared each key of `conditions` it no longer holds, carry-over-failed among +// them when present (design 39 parts §11, bus-and-switch §9). It waits for nothing to be carried over. +func judgedAtStart() { <-stdio.Served() m, err := manager() if err != nil { - say("the carry-over cannot start: %v", err) + say("the conditions cannot be judged at the start: %v", err) return } - if !m.Carry.Ended() { - 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() } func daemon() { - carryOverFirst() + judgedAtStart() var passing sync.Mutex pass := func() { passing.Lock() // one pass at a time: each account is leased, and a pass is cheap @@ -236,7 +221,7 @@ func daemon() { // What every node holds (ADR 0206, design 39 bus-and-switch §23): each `holdings-changed` heard is that node's // latest report and starts a pass; once heard, every node is asked to say it again (B12: at every start, after the - // carry-over). Asked again until it answers — the channel to the runtime opens as the bundle starts. + // conditions are judged). Asked again until it answers — the channel to the runtime opens as the bundle starts. go func() { waits := []time.Duration{2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second} for attempt := 0; ; attempt++ { @@ -310,8 +295,7 @@ func text(a map[string]any, k string) (string, error) { } // verb is one of the seat's verbs: listed as `.`, so the runtime serves it on the seat's subject. -// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625). A verb -// that acts answers nothing but "the carry-over has not ended" until it has (§11). +// What it changes is recorded as asked by its caller, as the runtime proved it (novox/hq issues 365 and 625). func verb(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool { return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: func(a map[string]any) (any, error) { @@ -319,19 +303,11 @@ func verb(name, description string, input map[string]any, run func(ctx context.C if err != nil { return nil, err } - if !reads[name] { - if err := m.Carry.Acts(); err != nil { - return nil, err - } - } ctx := WithCause(context.Background(), Cause{Why: "the " + name + " verb", By: stdio.CallerOf(a)}) return run(ctx, m, a) }} } -// reads are the seat's verbs that only read, served while the carry-over has not ended (§11). -var reads = map[string]bool{"licences": true, "bindings": true, "usage": true, "public-key": true, "current": true} - // sinceOf is the argument since, RFC 3339; absent is the zero time. func sinceOf(a map[string]any) (time.Time, error) { v, _ := a["since"].(string) 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 81828085f..49ac2f3f4 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -137,10 +137,8 @@ type GrantAnswer struct { type Manager struct { Store Store Vendor Vendor - // Crypt opens the grants today's database keeps at rest, for the carry-over alone (design 39 bus-and-switch §12, A3). - Crypt *Crypt - // Carry is the carry-over's gate (§11): no act until it has ended. Nil is a manager with nothing to carry over. - Carry *Carry + // Carried is the state `carried`, the carry-over's record, which nothing writes again (design 39 parts §10). + Carried keptState // Grants is the vault, which holds every licence's grant and access token (§15, §16). Grants Grants Keys KeyPair 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 c94dbc3f1..bba2a463b 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 @@ -127,10 +127,9 @@ func newMesh(t *testing.T) *miniMesh { mm.store = &BusStore{LicencesState: mm.licences, BindingsState: mm.bindings, LeasesState: mm.leases, OfferedState: mm.offered, UsageLog: mm.usage, AuditLog: mm.audit, Now: now} mm.vendor = &stubVendor{live: map[string]bool{}, now: now} - crypt, _ := NewCrypt("a key the vault made") mm.keys, _ = GenerateKeyPair() mm.m = &Manager{ - Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Grants: mm.grants, Keys: mm.keys, + Store: mm.store, Vendor: mm.vendor, Grants: mm.grants, Keys: mm.keys, AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) { if mm.askDown { return GrantAnswer{}, fmt.Errorf("503 no responders") @@ -278,7 +277,7 @@ 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 := &Manager{Store: mm.m.Store, Vendor: mm.m.Vendor, Crypt: mm.m.Crypt, Grants: mm.m.Grants, Keys: mm.m.Keys, + second := &Manager{Store: mm.m.Store, Vendor: mm.m.Vendor, 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 diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go index 3a63d0a65..4e05cfeb1 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" "testing" ) @@ -190,3 +191,80 @@ func TestTheManagerRunsAsItsOwnAccount(t *testing.T) { } } } + +// design 39 parts §9, §10: the carry-over has left the module whole. Its manifest requires neither the store database +// nor the secret grant-key, contributes and binds nothing and has no secrets section; no word of its code names the +// database's address or grant.key; its go.mod requires no postgres driver; the tool licences_carried is gone, and the +// state carried stays as the carry-over's record. +func TestTheCarryOverHasLeft(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m map[string]json.RawMessage + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + for _, gone := range []string{"contributes", "binds", "secrets"} { + if _, ok := m[gone]; ok { + t.Errorf("the manifest keeps %q", gone) + } + } + var requires []json.RawMessage + _ = json.Unmarshal(m["requires"], &requires) + carried := false + for _, r := range requires { + var bare string + if json.Unmarshal(r, &bare) == nil { + t.Errorf("the manifest requires %q", bare) + } + var e struct{ Name string } + if json.Unmarshal(r, &e) == nil && e.Name == "carried" { + carried = true + } + } + if !carried { + t.Error("the state carried, the carry-over's record, is gone") + } + var tools []string + _ = json.Unmarshal(m["tools"], &tools) + if slices.Contains(tools, "licences_carried") { + t.Error("the manifest keeps the tool licences_carried") + } + var resources []struct{ ID string } + _ = json.Unmarshal(m["resources"], &resources) + for _, r := range resources { + if r.ID == "database-url" { + t.Error("the manifest keeps the resource database-url") + } + } + words := []string{"DATABASE_URL" + "_FILE", "MESH_LICENCE_KEY" + "_FILE"} + err = filepath.WalkDir(moduleDir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + switch filepath.Ext(path) { + case ".go", ".json", ".mod", ".sum": + default: + if filepath.Base(filepath.Dir(path)) != "files" { + return nil // a built binary is no code of the module + } + } + src, err := os.ReadFile(path) + if err != nil { + return err + } + for _, w := range words { + if strings.Contains(string(src), w) { + t.Errorf("%s names %s", path, w) + } + } + if filepath.Base(path) == "go.mod" && strings.Contains(string(src), "github.com/jackc/pgx") { + t.Errorf("%s requires the postgres driver", path) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go b/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go deleted file mode 100644 index 892986886..000000000 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go +++ /dev/null @@ -1,161 +0,0 @@ -package main - -// Today's database, read once by the carry-over and never written (design 39 bus-and-switch §1, §11, A3): the -// module's own, opened with the credential the mesh still provisions for it alone. Every query is a select; a table -// that does not exist (a fresh install) is read as no rows. - -import ( - "context" - "encoding/json" - "errors" - "os" - "strings" - "time" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgxpool" -) - -// PgSource reads today's database. -type PgSource struct{ pool *pgxpool.Pool } - -// PgSourceFromEnv opens the database the mesh provisioned, its URL in the file DATABASE_URL_FILE names. -func PgSourceFromEnv(ctx context.Context) (*PgSource, error) { - file := os.Getenv("DATABASE_URL_FILE") - if file == "" { - return nil, errors.New("DATABASE_URL_FILE is not set: the manager's database is a requirement the mesh resolves") - } - raw, err := os.ReadFile(file) - if err != nil { - return nil, err - } - return OpenPgSource(ctx, strings.TrimSpace(string(raw))) -} - -// OpenPgSource opens a database at a URL, and reaches it once, so an unreachable one fails here. -func OpenPgSource(ctx context.Context, url string) (*PgSource, error) { - pool, err := pgxpool.New(ctx, url) - if err != nil { - return nil, err - } - if err := pool.Ping(ctx); err != nil { - pool.Close() - return nil, err - } - return &PgSource{pool: pool}, nil -} - -// Close lets the database go. -func (s *PgSource) Close() { s.pool.Close() } - -// noTable is whether err is postgres saying a table does not exist (SQLSTATE 42P01). -func noTable(err error) bool { - var pg *pgconn.PgError - return errors.As(err, &pg) && pg.Code == "42P01" -} - -// each runs a select and scans each row; a table that does not exist is no rows. -func (s *PgSource) each(ctx context.Context, q string, scan func(pgx.Rows) error, args ...any) error { - rows, err := s.pool.Query(ctx, q, args...) - if err != nil { - if noTable(err) { - return nil - } - return err - } - defer rows.Close() - for rows.Next() { - if err := scan(rows); err != nil { - return err - } - } - if err := rows.Err(); err != nil && !noTable(err) { - return err - } - return nil -} - -func (s *PgSource) Licences(ctx context.Context) ([]LicenceRow, error) { - out := []LicenceRow{} - err := s.each(ctx, `select name, kind, coalesce(account_uuid,''), coalesce(email,''), coalesce(organization_uuid,''), - coalesce(sealed,''), coalesce(refresh_fingerprint,''), coalesce(access_expires_at,0), - coalesce(refresh_expires_at,0), failures, coalesce(notified_at,0), adopted_at, coalesce(rotated_at,0) - from licence order by adopted_at, name`, func(r pgx.Rows) error { - var l LicenceRow - err := r.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint, - &l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt) - out = append(out, l) - return err - }) - return out, err -} - -func (s *PgSource) Bindings(ctx context.Context) ([]Binding, error) { - out := []Binding{} - err := s.each(ctx, `select consumer, licence, generation from binding order by generation, consumer`, - func(r pgx.Rows) error { - var b Binding - err := r.Scan(&b.Consumer, &b.Licence, &b.Generation) - out = append(out, b) - return err - }) - return out, err -} - -func (s *PgSource) Leases(ctx context.Context) ([]LeaseRow, error) { - out := []LeaseRow{} - err := s.each(ctx, `select key, holder, until from lease order by key`, func(r pgx.Rows) error { - var l LeaseRow - err := r.Scan(&l.Key, &l.Holder, &l.Until) - out = append(out, l) - return err - }) - return out, err -} - -func (s *PgSource) Offered(ctx context.Context) ([]OfferedRow, error) { - out := []OfferedRow{} - err := s.each(ctx, `select fingerprint, node, coalesce(account_uuid,''), outcome, why, at from offered - order by at, fingerprint`, func(r pgx.Rows) error { - var o OfferedRow - var outcome string - err := r.Scan(&o.Fingerprint, &o.Node, &o.Account, &outcome, &o.Why, &o.At) - o.Outcome = Outcome(outcome) - out = append(out, o) - return err - }) - return out, err -} - -func (s *PgSource) Usage(ctx context.Context, since time.Time) ([]UsageTableRow, error) { - out := []UsageTableRow{} - err := s.each(ctx, `select licence, at, reading, raw from usage where at >= $1 order by at, licence`, - func(r pgx.Rows) error { - var u UsageTableRow - var reading, raw []byte - if err := r.Scan(&u.Licence, &u.At, &reading, &raw); err != nil { - return err - } - _ = json.Unmarshal(reading, &u.Reading) - _ = json.Unmarshal(raw, &u.Raw) - out = append(out, u) - return nil - }, since.UnixMilli()) - return out, err -} - -func (s *PgSource) Audit(ctx context.Context) ([]AuditRow, error) { - out := []AuditRow{} - err := s.each(ctx, `select at, what, detail from audit order by at`, func(r pgx.Rows) error { - var a AuditRow - var detail []byte - if err := r.Scan(&a.At, &a.What, &detail); err != nil { - return err - } - _ = json.Unmarshal(detail, &a.Detail) - out = append(out, a) - return nil - }) - return out, err -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go index 35f99deab..b26ff3628 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go @@ -1,11 +1,6 @@ package main -import ( - "crypto/rand" - "encoding/base64" - "strings" - "testing" -) +import "testing" func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) { prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1} @@ -22,35 +17,3 @@ func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) { t.Fatal("an answer with no access token was a grant") } } - -func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) { - a, _ := NewCrypt("one key") - b, _ := NewCrypt("another key") - sealed := sealAtRest(t, a, `{"refreshToken":"rt"}`) - // The plaintext's own words, not two letters a random ciphertext holds one time in about seventy. - if strings.Contains(sealed, "refreshToken") { - t.Fatal("stored in the clear") - } - if got, err := a.Open(sealed); err != nil || got != `{"refreshToken":"rt"}` { - t.Fatalf("%q %v", got, err) - } - if _, err := b.Open(sealed); err == nil { - t.Fatal("opened with another key") - } - if _, err := NewCrypt(" "); err == nil { - t.Fatal("an empty key was accepted") - } -} - -// sealAtRest seals a grant as today's database keeps it, `v1..`: the rows the carry-over reads. -func sealAtRest(t *testing.T, c *Crypt, plaintext string) string { - t.Helper() - gcm, err := newGCM(c.key) - if err != nil { - t.Fatal(err) - } - iv := make([]byte, 12) - _, _ = rand.Read(iv) - b64 := base64.StdEncoding.EncodeToString - return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil)) -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go b/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go index f7bbd4dea..adb198bab 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go @@ -1,8 +1,7 @@ package main // The module's own tools (design 39 bus-and-switch §37 to §44, B40): beside the seat's verbs, they read everything the -// manager keeps and start by hand everything it does by itself. A tool that acts answers "the carry-over has not ended" -// until it has (§11); licences_carried's run is the carry-over itself. +// manager keeps and start by hand everything it does by itself. import ( "context" @@ -17,19 +16,14 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" ) -// tool is one of the module's own tools; acts says whether it acts, so the carry-over's gate holds it (§11). -func tool(name, description string, acts bool, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool { +// tool is one of the module's own tools. +func tool(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool { return stdio.Tool{Name: name, Description: description, Input: input, Run: func(a map[string]any) (any, error) { m, err := manager() if err != nil { return nil, err } - if acts { - if err := m.Carry.Acts(); err != nil { - return nil, err - } - } ctx := WithCause(context.Background(), Cause{Why: "the " + name + " tool", By: stdio.CallerOf(a)}) return run(ctx, m, a) }} @@ -48,7 +42,7 @@ func moduleTools() []stdio.Tool { dry := boolean("answer what the rule would do, and do nothing") return []stdio.Tool{ tool("licences_switches", "Every switch of a consumer and why, newest first, from the audit: when, from and to which licence, the consumers, by whom and why, and for a switch by usage the reading that moved them.", - false, map[string]any{"licence": licence, "consumer": str("a consumer; absent for all"), + map[string]any{"licence": licence, "consumer": str("a consumer; absent for all"), "since": str("a time, RFC 3339: no switch before it"), "limit": number("how many (default 20)")}, func(ctx context.Context, m *Manager, a map[string]any) (any, error) { l, _ := a["licence"].(string) @@ -60,14 +54,14 @@ func moduleTools() []stdio.Tool { return m.Switches(strings.TrimSpace(l), strings.TrimSpace(c), since, limitOf(a, 20, 2000)) }), tool("licences_read_usage", "Read usage now, for one subscription or every one, as the manager does on its cadence; then apply the switch rule unless dry. Answers each reading and what the rule did or would do.", - true, map[string]any{"licence": licence, "dry": dry}, + map[string]any{"licence": licence, "dry": dry}, func(ctx context.Context, m *Manager, a map[string]any) (any, error) { l, _ := a["licence"].(string) d, _ := a["dry"].(bool) return m.ReadUsageOf(ctx, strings.TrimSpace(l), d) }), tool("licences_switch_now", "Apply the switch rule now from the latest readings, for one licence or all, even with switch_auto off since a person asked; with dry, answer the moves and make none.", - true, map[string]any{"licence": licence, "dry": dry}, + map[string]any{"licence": licence, "dry": dry}, func(ctx context.Context, m *Manager, a map[string]any) (any, error) { l, _ := a["licence"].(string) d, _ := a["dry"].(bool) @@ -78,11 +72,11 @@ func moduleTools() []stdio.Tool { return acts, err }), tool("licences_settings", "The settings in force: each one's value, its default, where it was set (mesh, node or the default, as mesh-config answers it) and why it is invalid when it is.", - false, nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { + nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.SettingsInForce(ctx) }), tool("licences_kept", "Every entry the manager keeps on the bus: a state's keys, or a log's subjects with their last entry; with key, that key's value or that subject's last entry; and how full each is against its cap.", - false, map[string]any{"entry": str("one of licences, bindings, leases, offered, usage, audit, carried, conditions; absent for all"), + map[string]any{"entry": str("one of licences, bindings, leases, offered, usage, audit, carried, conditions; absent for all"), "key": str("a key of a state, or a subject of a log")}, func(ctx context.Context, m *Manager, a map[string]any) (any, error) { e, _ := a["entry"].(string) @@ -90,19 +84,13 @@ func moduleTools() []stdio.Tool { return m.Kept(strings.TrimSpace(e), strings.TrimSpace(k)) }), tool("licences_resync", "Recover: say binding.changed again for every binding, ask every node what it holds, say again each condition held, and hand each licence's access token to the vault again. Answers what it said.", - true, nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { + nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { return m.Resync(ctx) }), tool("licences_conditions", "The conditions the manager said and holds: each key of its state conditions with its body and since.", - false, nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) { + nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) { return m.KeptConditions() }), - tool("licences_carried", "The carry-over's record (when, and per table the rows read and entries written), or, while it has not ended, the failure it names; with run and no record, carry over again. Refused once carried.", - false, map[string]any{"run": boolean("carry over again: only while it has not ended")}, - func(ctx context.Context, m *Manager, a map[string]any) (any, error) { - run, _ := a["run"].(bool) - return m.Carried(ctx, run) - }), } } @@ -203,8 +191,8 @@ func (m *Manager) kept() (map[string]keptState, map[string]keptLog) { states["leases"], states["offered"] = bus.LeasesState, bus.OfferedState logs["usage"], logs["audit"] = bus.UsageLog, bus.AuditLog } - if m.Carry != nil { - states["carried"] = m.Carry.State + if m.Carried != nil { + states["carried"] = m.Carried } if m.Conditions != nil { states["conditions"] = m.Conditions @@ -385,37 +373,3 @@ func (m *Manager) KeptConditions() ([]KeptCondition, error) { } return out, nil } - -// Carried is the carry-over's record, or the failure its last run names while it has not ended; with run and no -// record it carries over again, and is refused once carried (§44). -func (m *Manager) Carried(ctx context.Context, run bool) (map[string]any, error) { - if m.Carry == nil { - return nil, errors.New("this manager has nothing to carry over") - } - if m.Carry.Ended() { - if run { - return nil, errors.New("the carry-over has ended: carried.postgres is written, and it is never done again") - } - e, err := m.Carry.State.Get(CarriedKey) - if err != nil || e == nil { - return nil, err - } - var rec CarriedRecord - if err := json.Unmarshal(e.Value, &rec); err != nil { - return nil, err - } - return map[string]any{"carried": rec}, nil - } - if !run { - return map[string]any{"carried": nil, "failure": m.Carry.Failure()}, nil - } - bus, _ := m.Store.(*BusStore) - rec, err := m.Carry.Run(ctx, m, bus) - if serr := m.SayConditions(ctx, false); serr != nil { - m.Log("saying the conditions after the carry-over: %v", serr) - } - if err != nil { - return map[string]any{"carried": nil, "failure": m.Carry.Failure()}, err - } - return map[string]any{"carried": rec}, nil -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go index 01af59786..6232280bd 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go @@ -10,6 +10,9 @@ import ( "time" ) +// stdTool is one served verb or tool's run. +type stdTool func(map[string]any) (any, error) + // served is every verb and tool the binary serves, by name, run on mm's manager. func served(t *testing.T, mm *miniMesh) map[string]stdTool { t.Helper() @@ -149,7 +152,7 @@ func TestReadUsageNowAppliesTheRuleUnlessDry(t *testing.T) { } // design 39 bus-and-switch §39/AC1, §32: switching now runs the rule with switch_auto off, and dry moves nothing; an -// acting tool waits for the carry-over (§11). +// acting tool waits for nothing to be carried over (design 39 parts §11). func TestSwitchNowRunsWithSwitchAutoOff(t *testing.T) { mm := fourOnA(t) mm.m.Settings.SwitchAuto = false @@ -160,10 +163,6 @@ func TestSwitchNowRunsWithSwitchAutoOff(t *testing.T) { if got := answer(t, run, map[string]any{"licence": "a"}).([]any); len(got) != 1 || mm.bound("ace").Licence != "b" { t.Fatalf("switched %v, ace on %s", got, mm.bound("ace").Licence) } - mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now })} - if _, err := run(nil); err == nil || err.Error() != NotCarried { - t.Fatalf("before the carry-over answered %v", err) - } } // design 39 bus-and-switch §40/AC1, B18: a value set at node level is answered with its level, the rest with the @@ -214,7 +213,7 @@ func TestKeptReadsEveryEntryOfTheManifest(t *testing.T) { mm := newMesh(t) ctx := context.Background() _, _ = saying(mm) - mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now })} + mm.m.Carried = newMemState(func() time.Time { return mm.now }) _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-secret-1", true, t0)}) _ = mm.m.ReadUsage(ctx) run := served(t, mm)["licences_kept"] @@ -299,36 +298,3 @@ func TestConditionsAnswersEachKey(t *testing.T) { t.Fatalf("keys %v", keys) } } - -// design 39 bus-and-switch §44/AC1: licences_carried answers the failure while not carried, carries over with run, then -// answers the record and refuses run. -func TestCarriedRunIsRefusedOnceCarried(t *testing.T) { - mm := newMesh(t) - _, said := saying(mm) - down := true - mm.m.Carry = &Carry{State: newMemState(func() time.Time { return mm.now }), - Source: func(context.Context) (CarrySource, error) { - if down { - return nil, errors.New("connection refused") - } - return &memSource{}, nil - }} - run := served(t, mm)["licences_carried"] - if _, err := run(map[string]any{"run": true}); err == nil || len(of(*said, CarryOverFailed)) != 1 { - t.Fatalf("a failed run answered %v, said %v", err, *said) - } - if got := answer(t, run, nil).(map[string]any); !strings.Contains(fmtAny(got["failure"]), "connection refused") { - t.Fatalf("not carried %v", got) - } - down = false - if got := answer(t, run, map[string]any{"run": true}).(map[string]any); got["carried"] == nil || - strings.Join(of(*said, CarryOverFailed), ",") != "raised,cleared" { - t.Fatalf("carried %v, said %v", got, *said) - } - if got := answer(t, run, nil).(map[string]any); got["carried"].(map[string]any)["tables"] == nil { - t.Fatalf("the record %v", got) - } - if _, err := run(map[string]any{"run": true}); err == nil { - t.Fatal("run is answered once carried") - } -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go index a9606cd64..7f35fdce2 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go @@ -2,11 +2,10 @@ package main -// The manager whole against a real bus (design 39 bus-and-switch §9, §11 to §13, §23, §26, §28, §29, §46, B41): the +// The manager whole against a real bus (design 39 bus-and-switch §9, §23, §26, §28, §29, §46, B41): the // built binary, played over its real stdin and stdout as the node's tool runtime plays it (tools/call with the caller in // _meta; mesh/state.* from a real nats-server's key-value buckets, mesh/log.* from the logs' real streams, mesh/publish -// and mesh/seat.publish onto their subjects), and today's database on a real postgres (MESH_TEST_POSTGRES, or a private -// server started here from the machine's initdb on a socket of its own, listening on no port). What the manager asks of +// and mesh/seat.publish onto their subjects). What the manager asks of // other parts is played beside it: the vault's licence-put and get, a node's claude_code_grant and its holdings-changed, // the operator channel's tell, and the vendor's token and usage endpoints. A condition is taken off the operator // channel's subject and decoded by its client, the contract the router reads (B41); the router itself is not run. @@ -22,7 +21,6 @@ import ( "io" "net/http" "net/http/httptest" - "net/url" "os" "os/exec" "path/filepath" @@ -32,7 +30,6 @@ import ( "testing" "time" - "github.com/jackc/pgx/v5" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" "golang.org/x/crypto/nacl/box" @@ -42,165 +39,45 @@ import ( "git.novox.be/novox/mesh-sdk/go/testbus" ) -func TestMain(m *testing.M) { - code := m.Run() - stopPostgres() - os.Exit(code) -} - -// ---- today's database on a real postgres --------------------------------------------------------------------- - -var ( - pgOnce sync.Once - pgBase string - pgDir string - pgWhy string - pgCount int - pgMu sync.Mutex -) - -func startPostgres() { - if u := os.Getenv("MESH_TEST_POSTGRES"); u != "" { - pgBase = u - return - } - initdb, err := exec.LookPath("initdb") - if err != nil { - pgWhy = "MESH_TEST_POSTGRES is unset and the machine has no initdb" - return - } - dir, err := os.MkdirTemp("", "licpg") - if err != nil { - pgWhy = err.Error() - return - } - pgDir = dir - if out, err := exec.Command(initdb, "-D", dir+"/data", "-U", "postgres", "-A", "trust", "-E", "UTF8", "--no-locale").CombinedOutput(); err != nil { - pgWhy = fmt.Sprintf("initdb: %v: %s", err, out) - return - } - // A socket in its own directory and no port: nothing on this machine reaches it but these tests. - if out, err := exec.Command(filepath.Join(filepath.Dir(initdb), "pg_ctl"), "-D", dir+"/data", "-l", dir+"/log", "-w", - "-o", "-k "+dir+" -c listen_addresses='' -F", "start").CombinedOutput(); err != nil { - pgWhy = fmt.Sprintf("pg_ctl start: %v: %s", err, out) - return - } - pgBase = "postgres://postgres@/postgres?host=" + dir -} - -func stopPostgres() { - if pgDir == "" { - return - } - if initdb, err := exec.LookPath("initdb"); err == nil { - _ = exec.Command(filepath.Join(filepath.Dir(initdb), "pg_ctl"), "-D", pgDir+"/data", "-m", "immediate", "stop").Run() - } - _ = os.RemoveAll(pgDir) -} - -// freshDatabase is an empty database of its own for one test, or a skip saying why there is none. -func freshDatabase(t *testing.T) (string, *pgx.Conn) { - t.Helper() - pgOnce.Do(startPostgres) - if pgBase == "" { - t.Skipf("NOT TESTED ON POSTGRES: %s", pgWhy) - } - pgMu.Lock() - pgCount++ - name := fmt.Sprintf("licmgr_%d_%d", os.Getpid(), pgCount) - pgMu.Unlock() - ctx := context.Background() - admin, err := pgx.Connect(ctx, pgBase) - if err != nil { - t.Fatal(err) - } - defer admin.Close(ctx) - if _, err := admin.Exec(ctx, "create database "+name); err != nil { - t.Fatal(err) - } - u, err := url.Parse(pgBase) - if err != nil { - t.Fatal(err) - } - u.Path = "/" + name - conn, err := pgx.Connect(ctx, u.String()) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = conn.Close(ctx) }) - return u.String(), conn -} - -// todaysSchema is the schema trunk's manager made (its PgStore's Schema): what the carry-over reads. -var todaysSchema = []string{ - `create table licence (name text primary key, kind text not null check (kind in ('subscription', 'api-key')), - account_uuid text unique, email text, organization_uuid text, sealed text, refresh_fingerprint text, - access_expires_at bigint, refresh_expires_at bigint, failures integer not null default 0, notified_at bigint, - adopted_at bigint not null, rotated_at bigint)`, - `create sequence binding_generation`, - `create table binding (consumer text primary key, licence text not null references licence(name), generation bigint not null)`, - `create table lease (key text primary key, holder text not null, until timestamptz not null)`, - `create table offered (fingerprint text primary key, node text not null, account_uuid text, outcome text not null, - why text not null, at timestamptz not null default now())`, - `create table usage (licence text not null, at bigint not null, reading jsonb not null, raw jsonb not null)`, - `create table audit (at timestamptz not null default now(), what text not null, detail jsonb not null)`, -} - const ( licenceA = "first@example.org" licenceB = "second@example.org" - grantKey = "a key the vault made" ) -// consumers are the four nodes bound to licenceA in today's rows. +// consumers are the four nodes bound to licenceA. var consumers = []string{"anchor", "home-server", "laptop", "workstation"} -// fillTodaysRows writes a copy of the shape of today's rows: two subscriptions, four consumers bound to the first, a -// lease, two offers (one older than the offered state's 90 days), three readings (one older than 35 days) and four -// audit rows (one of a kind the log does not name). -func fillTodaysRows(t *testing.T, conn *pgx.Conn) { - t.Helper() - ctx := context.Background() - for _, s := range todaysSchema { - if _, err := conn.Exec(ctx, s); err != nil { - t.Fatal(err) - } - } - crypt, _ := NewCrypt(grantKey) +// seed keeps what a running manager kept before this start: two subscriptions, their grants and access tokens in the +// vault, and four consumers bound to the first. +func (m *mesh) seed() { + m.t.Helper() now := time.Now() far := now.Add(30 * 24 * time.Hour).UnixMilli() + licences, err := m.js.KeyValue(m.ctx, Publisher+"_licences") + if err != nil { + m.t.Fatal(err) + } + bindings, err := m.js.KeyValue(m.ctx, Publisher+"_bindings") + if err != nil { + m.t.Fatal(err) + } for i, l := range []struct{ name, account, refresh, access string }{ {licenceA, "acct-a", "rt-a", "at-a"}, {licenceB, "acct-b", "rt-b", "at-b"}} { grant, _ := json.Marshal(FullGrant{AccessToken: l.access, RefreshToken: l.refresh, ExpiresAt: far}) - if _, err := conn.Exec(ctx, `insert into licence (name, kind, account_uuid, email, sealed, refresh_fingerprint, - access_expires_at, refresh_expires_at, adopted_at, rotated_at) values ($1,'subscription',$2,$1,$3,$4,$5,$5,$6,$7)`, - l.name, l.account, sealAtRest(t, crypt, string(grant)), Fingerprint(l.refresh), far, int64(i+1), now.UnixMilli()); err != nil { - t.Fatal(err) + m.secrets[SecretName(KeyFor(l.name), PartGrant)] = string(grant) + m.secrets[SecretName(KeyFor(l.name), PartAccess)] = l.access + v, _ := json.Marshal(Licence{Name: l.name, Kind: "subscription", AccountUUID: l.account, Email: l.name, + RefreshFingerprint: Fingerprint(l.refresh), AccessExpiresAt: far, RefreshExpiresAt: far, + AdoptedAt: int64(i + 1), RotatedAt: now.UnixMilli(), Generation: 1}) + if _, err := licences.Put(m.ctx, KeyFor(l.name), v); err != nil { + m.t.Fatal(err) } } for i, c := range consumers { - if _, err := conn.Exec(ctx, `insert into binding values ($1, $2, $3)`, c, licenceA, 1013+i); err != nil { - t.Fatal(err) - } - } - reading := `{"sessionPct": 42, "weeklyPct": 10}` - for _, q := range []struct { - sql string - args []any - }{ - {`insert into lease values ('licence:x', 'novox:1', now())`, nil}, - {`insert into offered values ('sha256:aa', 'laptop', 'acct-a', 'adopted', 'refreshed', $1)`, []any{now.Add(-24 * time.Hour)}}, - {`insert into offered values ('sha256:bb', 'anchor', null, 'dead', '400', $1)`, []any{now.Add(-100 * 24 * time.Hour)}}, - {`insert into usage values ($1, $2, $3, '{}')`, []any{licenceA, now.Add(-40 * 24 * time.Hour).UnixMilli(), reading}}, - {`insert into usage values ($1, $2, $3, '{"five_hour": {"utilization": 42}}')`, []any{licenceA, now.Add(-2 * time.Hour).UnixMilli(), reading}}, - {`insert into usage values ($1, $2, $3, '{}')`, []any{licenceB, now.Add(-time.Hour).UnixMilli(), reading}}, - {`insert into audit values ($1, 'adopted', '{"licence": "first@example.org", "node": "laptop"}')`, []any{now.Add(-72 * time.Hour)}}, - {`insert into audit values ($1, 'bound', '{"consumer": "laptop"}')`, []any{now.Add(-71 * time.Hour)}}, - {`insert into audit values ($1, 'switched', '{"consumer": "anchor"}')`, []any{now.Add(-70 * time.Hour)}}, - {`insert into audit values ($1, 'grant-moved', '{"licence": "second@example.org"}')`, []any{now.Add(-69 * time.Hour)}}, - } { - if _, err := conn.Exec(ctx, q.sql, q.args...); err != nil { - t.Fatal(err) + v, _ := json.Marshal(BindingState{Licence: licenceA, Kind: "subscription", Generation: int64(1013 + i), + Since: now.UTC().Format(time.RFC3339), Why: "bound", By: Itself}) + if _, err := bindings.Put(m.ctx, c, v); err != nil { + m.t.Fatal(err) } } } @@ -278,7 +155,7 @@ type said struct { body json.RawMessage } -// mesh is the bus, the state directory, the database and the parts beside the manager, kept across its starts. +// mesh is the bus, the state directory and the parts beside the manager, kept across its starts. type mesh struct { t *testing.T ctx context.Context @@ -286,7 +163,6 @@ type mesh struct { js jetstream.JetStream bin string dir string - dbURL string vendor *vendor own issued // what the manager's own runtime is issued from its manifest (C1) @@ -301,7 +177,7 @@ type mesh struct { conditions []oc.Condition } -func newWholeMesh(t *testing.T, dbURL string) *mesh { +func newWholeMesh(t *testing.T) *mesh { t.Helper() // The manager runs as its own account, never in the machine's tool runner, whose account is issued none of its // logs or states (C1): this player plays that runtime of its own, issuing what the manifest names and nothing else. @@ -340,12 +216,10 @@ func newWholeMesh(t *testing.T, dbURL string) *mesh { } } _ = os.MkdirAll(filepath.Join(dir, "state"), 0o700) - for f, v := range map[string]string{"grant.key": grantKey, "database.url": dbURL, "settings.json": "{}"} { - if err := os.WriteFile(filepath.Join(dir, f), []byte(v), 0o600); err != nil { - t.Fatal(err) - } + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte("{}"), 0o600); err != nil { + t.Fatal(err) } - m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, dir: dir, dbURL: dbURL, vendor: newVendor(t), own: own, + m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, dir: dir, vendor: newVendor(t), own: own, news: make(chan struct{}, 1), secrets: map[string]string{}, calls: map[string][32]byte{}, callArgs: map[string]map[string]any{}, logins: map[string]FullGrant{}} sub, err := nc.Subscribe("mesh.seat.operator-channel.accept.condition.>", func(msg *nats.Msg) { @@ -446,7 +320,6 @@ func (m *mesh) start() *started { m.t.Helper() cmd := exec.CommandContext(m.ctx, m.bin) cmd.Env = append(os.Environ(), "MESH_LICENCE_STATE="+filepath.Join(m.dir, "state"), - "MESH_LICENCE_KEY_FILE="+filepath.Join(m.dir, "grant.key"), "DATABASE_URL_FILE="+filepath.Join(m.dir, "database.url"), "MESH_LICENCE_SETTINGS="+filepath.Join(m.dir, "settings.json"), "MESH_NODE=novox", "MESH_MODULE="+Publisher, "MESH_ANTHROPIC_TOKEN_ENDPOINT="+m.vendor.server.URL+"/token", "MESH_ANTHROPIC_USAGE_ENDPOINT="+m.vendor.server.URL+"/usage") in, _ := cmd.StdinPipe() @@ -890,19 +763,6 @@ func (p *started) must(name string, args map[string]any) json.RawMessage { return raw } -// carried is the manager's carry-over record, as licences_carried answers it. -func (p *started) carried() CarriedRecord { - p.t.Helper() - var a struct { - Carried *CarriedRecord `json:"carried"` - Failure string `json:"failure"` - } - if err := json.Unmarshal(p.must("licences_carried", nil), &a); err != nil || a.Carried == nil { - p.t.Fatalf("licences_carried answers no record (%v): %s", err, a.Failure) - } - return *a.Carried -} - // bindings are the consumers' bindings, as the seat's verb answers them. func (p *started) bindings() map[string]Binding { p.t.Helper() @@ -917,152 +777,17 @@ func (p *started) bindings() map[string]Binding { return out } -// written is every revision of the states and every entry of the logs the carry-over writes: what a second start -// must leave as it is (§13). -func (m *mesh) written() map[string]uint64 { - m.t.Helper() - out := map[string]uint64{} - for _, s := range []string{"licences", "bindings", "offered", "carried"} { - info, err := m.js.Stream(m.ctx, "KV_"+Publisher+"_"+s) - if err != nil { - m.t.Fatal(err) - } - i, _ := info.Info(m.ctx) - out[s] = i.State.LastSeq - } - for l := range m.own.Logs { - info, err := m.js.Stream(m.ctx, "LOG_"+Publisher+"_"+l) - if err != nil { - m.t.Fatal(err) - } - i, _ := info.Info(m.ctx) - out[l] = i.State.LastSeq - } - return out -} - // ---- the tests ------------------------------------------------------------------------------------------------- -// design 39 bus-and-switch §11/AC1, §12/AC1, §13/AC1, §13/AC2: a start stopped at a table after bindings says -// carry-over-failed naming it and acts on nothing; started again it carries every row whole, the counts in -// carried.postgres equal to the rows read, the vault answering each licence's grant, and clears the condition; a -// further start writes nothing. -func TestTodaysRowsAreCarriedOverOnceAndWhole(t *testing.T) { - dbURL, conn := freshDatabase(t) - fillTodaysRows(t, conn) - ctx := context.Background() - // The table after bindings cannot be read: the start stops there. - if _, err := conn.Exec(ctx, `alter table offered rename column why to reason`); err != nil { - t.Fatal(err) - } - m := newWholeMesh(t, dbURL) - first := m.start() - m.until("carry-over-failed is said", func() bool { return len(m.conditionsOf(CarryOverFailed)) > 0 }) - m.mu.Lock() - raised := m.conditionsOf(CarryOverFailed)[0] - m.mu.Unlock() - if raised.State != "raised" || raised.Severity != "urgent" || !strings.Contains(raised.Explanation+raised.Summary, "offered") { - t.Errorf("carry-over-failed said %+v", raised) - } - if _, err := first.call(Seat+".bind", map[string]any{"consumer": "laptop", "licence": licenceB}); err == nil || - !strings.Contains(err.Error(), "the carry-over has not ended") { - t.Errorf("bind before the carry-over ended answered %v", err) - } - if n := m.vendor.called(); n != 0 { - t.Errorf("the vendor was called %d times before the carry-over ended", n) - } - first.stop() - - if _, err := conn.Exec(ctx, `alter table offered rename column reason to why`); err != nil { - t.Fatal(err) - } - m.mu.Lock() - beforeSecond := len(m.of("binding.changed")) - m.mu.Unlock() - second := m.start() - m.until("holdings.asked after the carry-over", func() bool { return m.count(HoldingsAsked) > 0 }) - rec := second.carried() - // §21: every carried binding is said by the start that carries it whole (the stopped one said those it wrote), so a reader's first view taken during the carry-over is brought up to date. - m.mu.Lock() - carriedSaid := 0 - for _, c := range m.of("binding.changed")[beforeSecond:] { - if c["why"] == "carried over" && c["licence"] == licenceA { - carriedSaid++ - } - } - m.mu.Unlock() - if carriedSaid != 4 { - t.Errorf("the carry-over said %d binding.changed, not one per binding", carriedSaid) - } - want := map[string][2]int{"licence": {2, 2}, "binding": {4, 4}, "lease": {1, 0}, "offered": {2, 1}, "usage": {2, 2}, "audit": {4, 4}} - for table, n := range want { - if got := rec.Tables[table]; got.Read != n[0] || got.Written != n[1] { - t.Errorf("%s: read %d written %d, want read %d written %d", table, got.Read, got.Written, n[0], n[1]) - } - } - m.mu.Lock() - for _, l := range []struct{ name, refresh string }{{licenceA, "rt-a"}, {licenceB, "rt-b"}} { - if g := m.secrets[SecretName(KeyFor(l.name), PartGrant)]; !strings.Contains(g, l.refresh) { - t.Errorf("the vault holds %q as the grant of %s", g, l.name) - } - } - m.mu.Unlock() - if b := second.bindings(); len(b) != 4 || b["anchor"].Licence != licenceA || b["anchor"].Generation != 1013 || b["anchor"].Why != "carried over" { - t.Errorf("bindings answers %+v", b) - } - m.until("carry-over-failed is cleared", func() bool { - c := m.conditionsOf(CarryOverFailed) - return len(c) > 1 && c[len(c)-1].State == "cleared" - }) - second.stop() - - before := m.written() - asked := m.count(HoldingsAsked) - third := m.start() - m.until("holdings.asked at the third start", func() bool { return m.count(HoldingsAsked) > asked }) - if again := third.carried(); again.At != rec.At { - t.Errorf("the third start carried over again at %s (first at %s)", again.At, rec.At) - } - if after := m.written(); fmt.Sprint(after) != fmt.Sprint(before) { - t.Errorf("a further start wrote %v, before it %v", after, before) - } -} - -// design 39 bus-and-switch §11/AC2: with the database unreachable the condition is raised, no act is taken and the -// vendor is never called. -func TestAnUnreachableDatabaseRaisesAndCallsNoVendor(t *testing.T) { - m := newWholeMesh(t, "postgres://postgres@127.0.0.1:1/claude_licences?connect_timeout=2") - p := m.start() - m.until("carry-over-failed is said", func() bool { return len(m.conditionsOf(CarryOverFailed)) > 0 }) - m.mu.Lock() - c := m.conditionsOf(CarryOverFailed)[0] - m.mu.Unlock() - if c.State != "raised" || c.Severity != "urgent" || c.Subject.ID != Publisher { - t.Errorf("carry-over-failed said %+v", c) - } - if _, err := p.call("licences_read_usage", nil); err == nil || !strings.Contains(err.Error(), "the carry-over has not ended") { - t.Errorf("licences_read_usage before the carry-over answered %v", err) - } - if n := m.vendor.called(); n != 0 { - t.Errorf("the vendor was called %d times", n) - } - m.mu.Lock() - defer m.mu.Unlock() - if n := m.count(HoldingsAsked); n != 0 { - t.Errorf("holdings.asked was said %d times before the carry-over", n) - } -} - // design 39 bus-and-switch §26/AC2, §28, §29/AC2, §9/AC3, B41: a reading of 99 % moves the four consumers bound to a // licence to the one with the most room in one act, saying one binding.changed each, one licence.switched and one tell; // with every licence at its threshold no-room is raised on the operator channel's subject, and a reading with room // clears it. func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) { - dbURL, conn := freshDatabase(t) - fillTodaysRows(t, conn) - m := newWholeMesh(t, dbURL) + m := newWholeMesh(t) + m.seed() p := m.start() - m.until("holdings.asked after the carry-over", func() bool { return m.count(HoldingsAsked) > 0 }) + m.until("holdings.asked at the start", func() bool { return m.count(HoldingsAsked) > 0 }) m.mu.Lock() carried := m.count("binding.changed") m.mu.Unlock() @@ -1128,13 +853,12 @@ func TestAReadingAtTheThresholdMovesEveryConsumer(t *testing.T) { } } -// design 39 bus-and-switch §23/AC1, §46/AC1, §11/AC3: on a fresh install (no table) the carry-over carries nothing and -// the manager says holdings.asked; each node's agent module answers with one holdings-changed; a report carrying an +// design 39 bus-and-switch §23/AC1, §46/AC1, design 39 parts §11: on a fresh install the manager waits for no carry-over +// and says holdings.asked; each node's agent module answers with one holdings-changed; a report carrying an // unknown refresh token's fingerprint makes a candidate, which is adopted, listed by licences with its organisation, // and chosen as a target at its first reading with room. func TestANewLoginIsAdoptedListedAndATarget(t *testing.T) { - dbURL, _ := freshDatabase(t) - m := newWholeMesh(t, dbURL) + m := newWholeMesh(t) changed := time.Now().UTC().Format(time.RFC3339Nano) report := func(node, account, email, org, refresh string) Holdings { h := Holdings{Node: node, Identity: &Identity{AccountUUID: account, EmailAddress: email, OrganizationUUID: org}, @@ -1155,9 +879,6 @@ func TestANewLoginIsAdoptedListedAndATarget(t *testing.T) { if asked != 2 { t.Errorf("the nodes were asked for their logins %d times, not once each", asked) } - if rec := p.carried(); rec.Tables["licence"].Read != 0 || rec.Tables["audit"].Read != 0 { - t.Errorf("a fresh install carried %+v", rec.Tables) - } // The full licence is read first: nothing has room yet, so its consumer stays. m.vendor.set("at-old", 99) diff --git a/modules/claude-licence-manager/files/database.url b/modules/claude-licence-manager/files/database.url deleted file mode 100644 index dc59da9a2..000000000 --- a/modules/claude-licence-manager/files/database.url +++ /dev/null @@ -1 +0,0 @@ -postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as} diff --git a/modules/claude-licence-manager/go.mod b/modules/claude-licence-manager/go.mod index dc60fbeab..fc42a8641 100644 --- a/modules/claude-licence-manager/go.mod +++ b/modules/claude-licence-manager/go.mod @@ -6,7 +6,6 @@ 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 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 ) @@ -14,17 +13,12 @@ require ( require ( github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.20.1 // indirect github.com/minio/highwayhash v1.0.4 // indirect github.com/nats-io/jwt/v2 v2.8.2 // indirect github.com/nats-io/nats-server/v2 v2.11.17 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect - golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.16.0 // indirect ) diff --git a/modules/claude-licence-manager/go.sum b/modules/claude-licence-manager/go.sum index 96a5b2dcb..4bc701c15 100644 --- a/modules/claude-licence-manager/go.sum +++ b/modules/claude-licence-manager/go.sum @@ -5,14 +5,6 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= -github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= @@ -36,12 +28,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/modules/claude-licence-manager/module.json b/modules/claude-licence-manager/module.json index 5d6fe9d52..162379dc2 100644 --- a/modules/claude-licence-manager/module.json +++ b/modules/claude-licence-manager/module.json @@ -4,8 +4,6 @@ "slug": "licmgr", "runs-as": "claude-licence-manager", "requires": [ - "postgres-database", - "secret", { "name": "licences", "storage": "key-value", @@ -68,24 +66,6 @@ "max-mib": 64 } ], - "contributes": { - "postgres-database": { - "name": "claude_licences", - "readable": [ - "audit", - "binding" - ] - } - }, - "binds": { - "postgres-database": "${dir:state}/database.json" - }, - "secrets": { - "postgres-database": "${dir:state}/database.secret", - "secret": { - "grant-key": "${dir:state}/grant.key" - } - }, "own-secrets": { "broker": "${dir:state}/broker" }, @@ -247,14 +227,6 @@ "place": ".", "unknown": "program" }, - { - "id": "database-url", - "type": "file", - "path": "${dir:state}/database.url", - "mode": "0600", - "owner": "claude-licence-manager", - "template": "files/database.url" - }, { "id": "settings", "type": "file", @@ -285,8 +257,7 @@ "licences_settings", "licences_kept", "licences_resync", - "licences_conditions", - "licences_carried" + "licences_conditions" ], "build": { "artifacts": [ @@ -301,9 +272,7 @@ "claude-licence-manager" ], "env": { - "DATABASE_URL_FILE": "${dir:state}/database.url", "MESH_LICENCE_STATE": "${dir:state}", - "MESH_LICENCE_KEY_FILE": "${dir:state}/grant.key", "MESH_LICENCE_SETTINGS": "${dir:state}/settings.json" } }, From cff1005422c1822954886c292f0434ee87d5a196 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 12:06:56 +0100 Subject: [PATCH 2/2] P2: the licence manager is four parts of one manifest (issue 1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one bundle that did everything becomes the manager bundle, which runs long and answers the seat's ten verbs and the three acting tools, and a tools bundle of its own that answers the four reading tools and writes nothing, so they answer while the manager fails to load or stops. Both import the module's one package for the layouts of the kept data and the log entries, the key form, the entries' names and the settings with their validity; the settings file and the account with its directory stay as they are. Builds: design 39 parts §1, §2, §3, §4, §5, §6, §7, §8 --- .gitignore | 5 +- modules/claude-licence-manager/README.md | 24 +- .../cmd/claude-licence-manager/tools.go | 375 ------------------ .../busstore.go | 88 +--- .../busstore_test.go | 4 +- .../conditions.go | 26 +- .../conditions_test.go | 2 + .../holdings.go | 0 .../cmd/manager/keyform.go | 24 ++ .../keyform_test.go | 18 +- .../main.go | 46 +-- .../manager.go | 11 +- .../manager_test.go | 2 + .../manifest_test.go | 109 ++++- .../room.go | 6 +- .../room_test.go | 2 + .../seal.go | 0 .../seal_test.go | 0 .../cmd/manager/settings.go | 45 +++ .../cmd/manager/settings_test.go | 33 ++ .../store.go | 35 +- .../store_test.go | 4 +- .../switching.go | 2 + .../switching_test.go | 2 + .../templates_test.go | 0 .../testdata/holdings-changed.json | 0 .../testdata/sealed-by-typescript.json | 0 .../cmd/manager/tools.go | 129 ++++++ .../tools_test.go | 141 +------ .../vaultgrants.go | 0 .../vaultgrants_test.go | 2 + .../vendor.go | 15 +- .../whole_integration_test.go | 125 +++++- .../claude-licence-manager/cmd/tools/main.go | 70 ++++ .../claude-licence-manager/cmd/tools/tools.go | 260 ++++++++++++ .../cmd/tools/tools_test.go | 340 ++++++++++++++++ .../internal/licences/kept.go | 179 +++++++++ .../licences}/keyform.go | 24 +- .../licences/licences_test.go} | 44 +- .../licences}/settings.go | 48 +-- modules/claude-licence-manager/module.json | 15 +- 41 files changed, 1433 insertions(+), 822 deletions(-) delete mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/tools.go rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/busstore.go (76%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/busstore_test.go (99%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/conditions.go (93%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/conditions_test.go (99%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/holdings.go (100%) create mode 100644 modules/claude-licence-manager/cmd/manager/keyform.go rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/keyform_test.go (74%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/main.go (90%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/manager.go (98%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/manager_test.go (99%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/manifest_test.go (64%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/room.go (93%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/room_test.go (99%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/seal.go (100%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/seal_test.go (100%) create mode 100644 modules/claude-licence-manager/cmd/manager/settings.go create mode 100644 modules/claude-licence-manager/cmd/manager/settings_test.go rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/store.go (69%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/store_test.go (96%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/switching.go (99%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/switching_test.go (99%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/templates_test.go (100%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/testdata/holdings-changed.json (100%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/testdata/sealed-by-typescript.json (100%) create mode 100644 modules/claude-licence-manager/cmd/manager/tools.go rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/tools_test.go (53%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/vaultgrants.go (100%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/vaultgrants_test.go (97%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/vendor.go (90%) rename modules/claude-licence-manager/cmd/{claude-licence-manager => manager}/whole_integration_test.go (88%) create mode 100644 modules/claude-licence-manager/cmd/tools/main.go create mode 100644 modules/claude-licence-manager/cmd/tools/tools.go create mode 100644 modules/claude-licence-manager/cmd/tools/tools_test.go create mode 100644 modules/claude-licence-manager/internal/licences/kept.go rename modules/claude-licence-manager/{cmd/claude-licence-manager => internal/licences}/keyform.go (53%) rename modules/claude-licence-manager/{cmd/claude-licence-manager/settings_test.go => internal/licences/licences_test.go} (67%) rename modules/claude-licence-manager/{cmd/claude-licence-manager => internal/licences}/settings.go (79%) diff --git a/.gitignore b/.gitignore index 3c486ed48..cdef30ce8 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,10 @@ dist/ /modules/claude-code/claude-code /modules/claude-code/cmd/claude-code/claude-code /modules/claude-licence-manager/claude-licence-manager -/modules/claude-licence-manager/cmd/claude-licence-manager/claude-licence-manager +/modules/claude-licence-manager/cmd/manager/manager +/modules/claude-licence-manager/cmd/tools/tools +/modules/claude-licence-manager/manager +/modules/claude-licence-manager/tools /modules/clipmenu/clipmenu-tools /modules/clipmenu/cmd/clipmenu-tools/clipmenu-tools /modules/cups/cmd/cups-tools/cups-tools diff --git a/modules/claude-licence-manager/README.md b/modules/claude-licence-manager/README.md index 9217ac3c9..92c64f920 100644 --- a/modules/claude-licence-manager/README.md +++ b/modules/claude-licence-manager/README.md @@ -5,9 +5,12 @@ rotation source, its consumers moved off a licence near its limit by the manager ADR 0206, design 39 and its milestone bus-and-switch). Every grant and access token lives in the vault; this module keeps none. -It runs as an account of its own (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), in a runtime of its -own, never in the machine's tool runner: its service part (the conditions judged at its start, the usage timer, the switch -rule) and its tools beside it, so every state, log and secret below is issued to its own account. Its +It is one manifest of four parts (design 39 parts §1): the manager bundle (`manager`, `cmd/manager`), which runs long +(the conditions judged at its start, the usage timer, the switch rule) and answers the seat's verbs and every act; the +tools bundle (`tools`, `cmd/tools`), which answers the reading tools and writes nothing, so they answer while the +manager fails; the settings file; and the module's account with its directory. Both bundles run as an account of its +own (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), in a runtime of its own, never in the machine's +tool runner, so every state, log and secret below is issued to its own account. Its account is issued what its manifest names: the asks under `invokes` (the vault, the operator channel, mesh-config and a node's `claude_code_grant`), the events under `emits` and `consumes`. @@ -71,10 +74,11 @@ health, never the token) — through the console as `anthropic-licence-manager.< ## Its tools -`licences_switches` (every switch and why), `licences_read_usage` (read now, `dry`), `licences_switch_now` (apply the -rule now, even with `switch_auto` off), `licences_settings` (each value, its default and where it was set), -`licences_kept` (every entry it keeps, how full against its cap), `licences_resync` (say every binding, holdings.asked -and each condition again, and hand each access token to the vault again), and `licences_conditions`. +Read by the tools bundle: `licences_switches` (every switch and why), `licences_settings` (each value, its default +and where it was set), `licences_kept` (every entry it keeps, how full against its cap) and `licences_conditions`. +Acting, by the manager bundle: `licences_read_usage` (read now, `dry`), `licences_switch_now` (apply the rule now, even +with `switch_auto` off) and `licences_resync` (say every binding, holdings.asked and each condition again, and hand +each access token to the vault again). ## Settings @@ -90,7 +94,9 @@ Through mesh-config, each `trusted`, read again at every reading: `cadence_minut ## Code and tests -Go, one binary (`cmd/claude-licence-manager`): the seat's verbs, the tools and the daemon in one launched bundle. The +Go, one Go module of two binaries: `cmd/manager` (`claude-licence-manager`: the seat's verbs, the acting tools and the +daemon) and `cmd/tools` (`claude-licence-manager-tools`: the reading tools), sharing the package `internal/licences` +(the layouts of the kept data and the log entries, the key form, the entries' names and the settings). The sealed box is `claude-code`'s own format, byte for byte — the two modules carry the same `seal.go` — and a test opens one sealed by the TypeScript agent module the Go one replaced, so the format is the one already on the machines. @@ -98,7 +104,7 @@ one sealed by the TypeScript agent module the Go one replaced, so the format is # the whole module on a real bus: go test -tags integration ./... -The whole tests (`whole_integration_test.go`) start the built binary as its runtime does, its kept data in a real +The whole tests (`cmd/manager/whole_integration_test.go`) start the built binaries as their runtime does, its kept data in a real nats-server. The vault, the agent modules, the operator channel and the vendor are played beside it; a condition is read through the operator channel's client. diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go b/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go deleted file mode 100644 index adb198bab..000000000 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/tools.go +++ /dev/null @@ -1,375 +0,0 @@ -package main - -// The module's own tools (design 39 bus-and-switch §37 to §44, B40): beside the seat's verbs, they read everything the -// manager keeps and start by hand everything it does by itself. - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "slices" - "sort" - "strings" - "time" - - stdio "git.novox.be/novox/mesh-sdk/go" -) - -// tool is one of the module's own tools. -func tool(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool { - return stdio.Tool{Name: name, Description: description, Input: input, - Run: func(a map[string]any) (any, error) { - m, err := manager() - if err != nil { - return nil, err - } - ctx := WithCause(context.Background(), Cause{Why: "the " + name + " tool", By: stdio.CallerOf(a)}) - return run(ctx, m, a) - }} -} - -func boolean(description string) map[string]any { - return map[string]any{"type": "boolean", "description": description} -} - -func number(description string) map[string]any { - return map[string]any{"type": "number", "description": description} -} - -func moduleTools() []stdio.Tool { - licence := str("a licence, as `licences` names it; absent for all") - dry := boolean("answer what the rule would do, and do nothing") - return []stdio.Tool{ - tool("licences_switches", "Every switch of a consumer and why, newest first, from the audit: when, from and to which licence, the consumers, by whom and why, and for a switch by usage the reading that moved them.", - map[string]any{"licence": licence, "consumer": str("a consumer; absent for all"), - "since": str("a time, RFC 3339: no switch before it"), "limit": number("how many (default 20)")}, - func(ctx context.Context, m *Manager, a map[string]any) (any, error) { - l, _ := a["licence"].(string) - c, _ := a["consumer"].(string) - since, err := sinceOf(a) - if err != nil { - return nil, err - } - return m.Switches(strings.TrimSpace(l), strings.TrimSpace(c), since, limitOf(a, 20, 2000)) - }), - tool("licences_read_usage", "Read usage now, for one subscription or every one, as the manager does on its cadence; then apply the switch rule unless dry. Answers each reading and what the rule did or would do.", - map[string]any{"licence": licence, "dry": dry}, - func(ctx context.Context, m *Manager, a map[string]any) (any, error) { - l, _ := a["licence"].(string) - d, _ := a["dry"].(bool) - return m.ReadUsageOf(ctx, strings.TrimSpace(l), d) - }), - tool("licences_switch_now", "Apply the switch rule now from the latest readings, for one licence or all, even with switch_auto off since a person asked; with dry, answer the moves and make none.", - map[string]any{"licence": licence, "dry": dry}, - func(ctx context.Context, m *Manager, a map[string]any) (any, error) { - l, _ := a["licence"].(string) - d, _ := a["dry"].(bool) - acts, err := m.ApplyRule(ctx, strings.TrimSpace(l), true, d) - if acts == nil { - acts = []Switch{} - } - return acts, err - }), - tool("licences_settings", "The settings in force: each one's value, its default, where it was set (mesh, node or the default, as mesh-config answers it) and why it is invalid when it is.", - nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { - return m.SettingsInForce(ctx) - }), - tool("licences_kept", "Every entry the manager keeps on the bus: a state's keys, or a log's subjects with their last entry; with key, that key's value or that subject's last entry; and how full each is against its cap.", - map[string]any{"entry": str("one of licences, bindings, leases, offered, usage, audit, carried, conditions; absent for all"), - "key": str("a key of a state, or a subject of a log")}, - func(ctx context.Context, m *Manager, a map[string]any) (any, error) { - e, _ := a["entry"].(string) - k, _ := a["key"].(string) - return m.Kept(strings.TrimSpace(e), strings.TrimSpace(k)) - }), - tool("licences_resync", "Recover: say binding.changed again for every binding, ask every node what it holds, say again each condition held, and hand each licence's access token to the vault again. Answers what it said.", - nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { - return m.Resync(ctx) - }), - tool("licences_conditions", "The conditions the manager said and holds: each key of its state conditions with its body and since.", - nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) { - return m.KeptConditions() - }), - } -} - -// Switches are the audit's switches, newest first: of a licence (from or to it) and a consumer when named, none before -// since, at most limit (§37). -func (m *Manager) Switches(licence, consumer string, since time.Time, limit int) ([]map[string]any, error) { - bus, ok := m.Store.(*BusStore) - if !ok { - return nil, errors.New("the audit is not kept on the bus") - } - info, err := bus.AuditLog.Info() - if err != nil || info.Last == 0 { - return []map[string]any{}, err - } - var all []stdio.LogEntry - for next := uint64(1); next != 0; { - page, err := bus.AuditLog.Read(next, "switched", 0) - if err != nil { - return nil, err - } - all = append(all, page.Entries...) - next = page.Next - } - out := []map[string]any{} - for i := len(all) - 1; i >= 0 && len(out) < limit; i-- { - var e map[string]any - if json.Unmarshal(all[i].Value, &e) != nil { - continue - } - at, _ := time.Parse(time.RFC3339Nano, fmt.Sprint(e["at"])) - if at.Before(since) { - continue - } - if licence != "" && e["from"] != licence && e["to"] != licence { - continue - } - consumers, _ := e["consumers"].([]any) - if consumer != "" && !slices.Contains(consumers, any(consumer)) { - continue - } - s := map[string]any{"at": e["at"], "from": e["from"], "to": e["to"], "consumers": e["consumers"], - "by": e["by"], "why": e["why"]} - if _, ok := e["window"]; ok { - s["reading"] = map[string]any{"window": e["window"], "pct": e["pct"], "resetsAt": e["resetsAt"], - "room": e["room"]} - } - out = append(out, s) - } - return out, nil -} - -// SettingAnswer is one setting as licences_settings answers it (§40). -type SettingAnswer struct { - Setting string `json:"setting"` - Value any `json:"value"` - Default any `json:"default"` - Level string `json:"level"` - Invalid string `json:"invalid,omitempty"` -} - -// SettingsInForce is each setting in force, its default, where mesh-config says it was set, and why it is invalid -// when it is (§40, B18). -func (m *Manager) SettingsInForce(ctx context.Context) ([]SettingAnswer, error) { - levels := map[string]string{} - if m.ConfigLevels != nil { - got, err := m.ConfigLevels(ctx) - if err != nil { - return nil, fmt.Errorf("mesh-config did not say where each setting was set: %w", err) - } - levels = got - } - s := m.settings() - held, err := m.Judge(ctx) - if err != nil { - return nil, err - } - values, defaults := s.Values(), Defaults.Values() - var out []SettingAnswer - for k, v := range values { - a := SettingAnswer{Setting: k, Value: v, Default: defaults[k], Level: levels[k]} - if a.Level == "" { - a.Level = "default" - } - if c, ok := held["settings."+k+".invalid"]; ok { - a.Invalid = c.Summary - } - out = append(out, a) - } - sort.Slice(out, func(i, j int) bool { return out[i].Setting < out[j].Setting }) - return out, nil -} - -// kept are the entries the manager keeps on the bus, by their local names in the manifest (§2 to §9). -func (m *Manager) kept() (map[string]keptState, map[string]keptLog) { - states, logs := map[string]keptState{}, map[string]keptLog{} - if bus, ok := m.Store.(*BusStore); ok { - states["licences"], states["bindings"] = bus.LicencesState, bus.BindingsState - states["leases"], states["offered"] = bus.LeasesState, bus.OfferedState - logs["usage"], logs["audit"] = bus.UsageLog, bus.AuditLog - } - if m.Carried != nil { - states["carried"] = m.Carried - } - if m.Conditions != nil { - states["conditions"] = m.Conditions - } - return states, logs -} - -// Kept answers one entry the manager keeps, or every one (§41): a state's keys and their count, a log's subjects with -// their last entry and its fill against its cap; with key, one key's value or one subject's last entry. -func (m *Manager) Kept(entry, key string) (map[string]any, error) { - states, logs := m.kept() - out := map[string]any{} - names := []string{} - for n := range states { - names = append(names, n) - } - for n := range logs { - names = append(names, n) - } - sort.Strings(names) - if entry != "" && !slices.Contains(names, entry) { - return nil, fmt.Errorf("the manager keeps no entry %q: it keeps %s", entry, strings.Join(names, ", ")) - } - for _, n := range names { - if entry != "" && n != entry { - continue - } - if st, ok := states[n]; ok { - a, err := keptStateAnswer(st, key) - if err != nil { - return nil, fmt.Errorf("%s: %w", n, err) - } - out[n] = a - continue - } - a, err := keptLogAnswer(logs[n], key) - if err != nil { - return nil, fmt.Errorf("%s: %w", n, err) - } - out[n] = a - } - return out, nil -} - -func keptStateAnswer(st keptState, key string) (map[string]any, error) { - keys, err := st.Keys() - if err != nil { - return nil, err - } - sort.Strings(keys) - a := map[string]any{"kind": "state", "keys": keys, "fill": map[string]any{"keys": len(keys), "cap": "none declared"}} - if key != "" { - e, err := st.Get(key) - if err != nil { - return nil, err - } - if e == nil { - a["value"] = nil - } else { - a["value"] = e.Value - } - } - return a, nil -} - -func keptLogAnswer(l keptLog, key string) (map[string]any, error) { - info, err := l.Info() - if err != nil { - return nil, err - } - a := map[string]any{"kind": "log", "fill": map[string]any{"entries": info.Entries, "bytes": info.Bytes, - "maxBytes": info.MaxBytes}} - if key != "" { - e, err := l.Last(key) - if err != nil { - return nil, err - } - a["last"] = e - return a, nil - } - newest, err := l.Newest("") - if err != nil { - return nil, err - } - subjects := map[string]any{} - for _, e := range newest { - subjects[e.Key] = e - } - a["subjects"] = subjects - return a, nil -} - -// Resync says again what a consumer, the vault or a node may have missed (§42): binding.changed for every binding, -// holdings.asked, each condition held, and licence-put of each licence's access token. Answers what it said. -func (m *Manager) Resync(ctx context.Context) (map[string]any, error) { - bindings, err := m.Store.Bindings(ctx) - if err != nil { - return nil, err - } - said := map[string]any{} - consumers := []string{} - for _, b := range bindings { - m.changed(b, b.Licence) - consumers = append(consumers, b.Consumer) - } - said["binding.changed"] = consumers - if err := m.AskHoldings(CauseOf(ctx).By); err != nil { - return said, fmt.Errorf("holdings.asked could not be said: %w", err) - } - said["holdings.asked"] = true - if err := m.SayConditions(ctx, true); err != nil { - return said, err - } - conditions, err := m.KeptConditions() - if err != nil { - return said, err - } - keys := []string{} - for _, c := range conditions { - keys = append(keys, c.Body.Key) - } - said["conditions"] = keys - licences, err := m.Store.Licences(ctx) - if err != nil { - return said, err - } - put := []string{} - var errs []error - for _, l := range licences { - if l.Generation == 0 { - continue - } - key := KeyFor(l.Name) - // The access token is built from the grant, which the vault hands the manager; it hands a licence's access - // token only to a machine a binding names (§17), and the manager's machine may have none. - access, err := m.Grants.Get(ctx, key, PartGrant) - if err == nil && l.Kind == "subscription" { - var g FullGrant - if err = json.Unmarshal([]byte(access), &g); err == nil { - access = accessOf(g) - } - } - if err == nil { - err = m.Grants.Put(ctx, key, PartAccess, l.Generation, access) - } - access = "" - if err != nil { - errs = append(errs, fmt.Errorf("the access token of %s: %w", l.Name, err)) - continue - } - put = append(put, l.Name) - } - said["licence-put"] = put - return said, errors.Join(errs...) -} - -// KeptConditions is each key of the state conditions with its body and since (§43), by key. -func (m *Manager) KeptConditions() ([]KeptCondition, error) { - out := []KeptCondition{} - if m.Conditions == nil { - return out, nil - } - keys, err := m.Conditions.Keys() - if err != nil { - return nil, err - } - sort.Strings(keys) - for _, k := range keys { - e, err := m.Conditions.Get(k) - if err != nil { - return nil, err - } - var c KeptCondition - if e == nil || json.Unmarshal(e.Value, &c) != nil { - continue - } - out = append(out, c) - } - return out, nil -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go b/modules/claude-licence-manager/cmd/manager/busstore.go similarity index 76% rename from modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go rename to modules/claude-licence-manager/cmd/manager/busstore.go index 2ffaa3598..46c9eb94e 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore.go +++ b/modules/claude-licence-manager/cmd/manager/busstore.go @@ -13,43 +13,22 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + + . "claude-licence-manager/internal/licences" ) -// keptState is a state this module keeps, as mesh-sdk's KeptState answers it. -type keptState interface { - Get(key string) (*stdio.StateEntry, error) - Put(key string, value any) (uint64, error) - PutTTL(key string, value any, ttl time.Duration) (uint64, error) - Create(key string, value any) (uint64, error) - CreateTTL(key string, value any, ttl time.Duration) (uint64, error) - Delete(key string) error - Keys() ([]string, error) - History(key string) ([]stdio.StateRevision, error) -} - -// BindingState is the value of a consumer's key in `bindings` (§3, B22: the form 0 its readers decode): no secret, -// only what it should hold, which generation, since when, why and by whom. -type BindingState struct { - Licence string `json:"licence"` - Kind string `json:"kind"` - Generation int64 `json:"generation"` - Since string `json:"since,omitempty"` - Why string `json:"why,omitempty"` - By string `json:"by,omitempty"` -} - // BusStore keeps the manager's data in the bus's own storage. type BusStore struct { - LicencesState keptState - BindingsState keptState - LeasesState keptState - OfferedState keptState - UsageLog keptLog - AuditLog keptLog + LicencesState State + BindingsState State + LeasesState State + OfferedState State + UsageLog Log + AuditLog Log Now func() time.Time } -func (s *BusStore) get(st keptState, key string, into any) (bool, error) { +func (s *BusStore) get(st State, key string, into any) (bool, error) { e, err := st.Get(key) if err != nil || e == nil || len(e.Value) == 0 || string(e.Value) == "null" { return false, err @@ -185,16 +164,10 @@ func (s *BusStore) Advance(ctx context.Context, licence string, m Move) ([]Bindi // ---- leases and offers (§4, §5) --------------------------------------------------------------------------------- -// leaseValue is a lease's key in `leases`: its holder and until when. -type leaseValue struct { - Holder string `json:"holder"` - Until string `json:"until"` -} - // Lease takes a lease by creating its key with a lifetime of d (§4): a create fails while another holds it, the bus's // own compare-and-set. A key its own holder holds is taken again. func (s *BusStore) Lease(_ context.Context, key, holder string, d time.Duration) (bool, error) { - v := leaseValue{Holder: holder, Until: s.Now().Add(d).UTC().Format(time.RFC3339)} + v := LeaseValue{Holder: holder, Until: s.Now().Add(d).UTC().Format(time.RFC3339)} _, err := s.LeasesState.CreateTTL(key, v, d) if err == nil { return true, nil @@ -202,7 +175,7 @@ func (s *BusStore) Lease(_ context.Context, key, holder string, d time.Duration) if !errors.Is(err, stdio.ErrChanged) { return false, err } - var held leaseValue + var held LeaseValue if ok, gerr := s.get(s.LeasesState, key, &held); gerr != nil || !ok || held.Holder != holder { return false, gerr } @@ -212,7 +185,7 @@ func (s *BusStore) Lease(_ context.Context, key, holder string, d time.Duration) // Unlease deletes a lease its holder holds; another's is left to it or its lifetime. func (s *BusStore) Unlease(_ context.Context, key, holder string) error { - var held leaseValue + var held LeaseValue if ok, err := s.get(s.LeasesState, key, &held); err != nil || !ok || held.Holder != holder { return err } @@ -222,20 +195,11 @@ func (s *BusStore) Unlease(_ context.Context, key, holder string) error { // offeredFor is how long the outcome of an offered grant is kept (§5): longer than a refresh token lives. const offeredFor = 90 * 24 * time.Hour -// offeredValue is a grant's key in `offered`: what became of it, where and when (§5, B13: every outcome). -type offeredValue struct { - Node string `json:"node"` - Account string `json:"account"` - Outcome Outcome `json:"outcome"` - Why string `json:"why"` - At string `json:"at"` -} - // OfferedKey is a fingerprint's key in `offered`: through the licence key form (B13). func OfferedKey(fingerprint string) string { return KeyFor(fingerprint) } func (s *BusStore) Outcome(_ context.Context, fingerprint string) (Outcome, error) { - var v offeredValue + var v OfferedValue if ok, err := s.get(s.OfferedState, OfferedKey(fingerprint), &v); err != nil || !ok { return "", err } @@ -243,37 +207,17 @@ func (s *BusStore) Outcome(_ context.Context, fingerprint string) (Outcome, erro } func (s *BusStore) RecordOutcome(_ context.Context, fingerprint, node, account string, o Outcome, why string) error { - return s.putOffered(fingerprint, offeredValue{Node: node, Account: account, Outcome: o, Why: why, + return s.putOffered(fingerprint, OfferedValue{Node: node, Account: account, Outcome: o, Why: why, At: s.Now().UTC().Format(time.RFC3339)}, offeredFor) } -func (s *BusStore) putOffered(fingerprint string, v offeredValue, ttl time.Duration) error { +func (s *BusStore) putOffered(fingerprint string, v OfferedValue, ttl time.Duration) error { _, err := s.OfferedState.PutTTL(OfferedKey(fingerprint), v, ttl) return err } // ---- the logs (§6, §7) ------------------------------------------------------------------------------------------ -// keptLog is a log this module keeps, as mesh-sdk's KeptLog answers it. -type keptLog interface { - Append(key string, value any) (uint64, error) - Read(from uint64, keys string, limit int) (*stdio.LogPage, error) - Last(key string) (*stdio.LogEntry, error) - Info() (*stdio.LogInfo, error) - Newest(keys string) ([]stdio.LogEntry, error) -} - -// UsageEntry is one entry of the log `usage` (§6): when, the flattened reading and the vendor's answer whole (ADR 0054). -type UsageEntry struct { - At string `json:"at"` - Reading UsageReading `json:"reading"` - Raw map[string]any `json:"raw,omitempty"` -} - -// AuditKinds are the subjects of the log `audit` (§7); an act of another kind is kept under `drift`. -var AuditKinds = []string{"adopted", "refused", "failed", "rotated", "bound", "switched", "released", "drift", "carried", - "settings"} - func (s *BusStore) RecordUsage(_ context.Context, licence string, at int64, r UsageReading, raw map[string]any) error { _, err := s.UsageLog.Append(KeyFor(licence), UsageEntry{At: time.UnixMilli(at).UTC().Format(time.RFC3339Nano), Reading: r, Raw: raw}) @@ -324,7 +268,7 @@ func (s *BusStore) Usage(ctx context.Context, licence string, since time.Time, l // newest is up to limit entries of keys, newest first: read back from the log's last sequence, a window that doubles // until it holds limit entries or reaches the first. -func (s *BusStore) newest(l keptLog, keys string, limit int) ([]stdio.LogEntry, error) { +func (s *BusStore) newest(l Log, keys string, limit int) ([]stdio.LogEntry, error) { info, err := l.Info() if err != nil || info.Last == 0 { return nil, err diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go b/modules/claude-licence-manager/cmd/manager/busstore_test.go similarity index 99% rename from modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go rename to modules/claude-licence-manager/cmd/manager/busstore_test.go index c6e92ded2..8b2c66d15 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/busstore_test.go +++ b/modules/claude-licence-manager/cmd/manager/busstore_test.go @@ -11,6 +11,8 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + + . "claude-licence-manager/internal/licences" ) // memState is a state of the bus as the runtime answers it, in memory: values, their past values, keys' lifetimes. @@ -205,7 +207,7 @@ func TestALeaseAndAnOfferAreKeptByTheirKeysAndLifetimes(t *testing.T) { if strings.ContainsAny(fp, ":") || !strings.HasPrefix(fp, "sha256_") { t.Fatalf("the offered key %q is not in the key form", fp) } - var v offeredValue + var v OfferedValue _ = json.Unmarshal(mm.offered.values[fp], &v) if v.Outcome != Dead || v.Node != "laptop" || v.Account != "acct-1" || v.Why == "" || v.At == "" { t.Fatalf("offered %+v", v) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go b/modules/claude-licence-manager/cmd/manager/conditions.go similarity index 93% rename from modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go rename to modules/claude-licence-manager/cmd/manager/conditions.go index c8d3d3796..5c6dcbb58 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions.go +++ b/modules/claude-licence-manager/cmd/manager/conditions.go @@ -17,20 +17,13 @@ import ( 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" + . "claude-licence-manager/internal/licences" +) // 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 { @@ -89,6 +82,7 @@ func machines(n int) string { func (m *Manager) Judge(ctx context.Context) (map[string]oc.Condition, error) { held := map[string]oc.Condition{} s := m.settings() + reasons := s.Reasons() for _, o := range s.OutOfBound { bound := fmt.Sprintf("at least %g", o.Min) if o.HasMax { @@ -96,7 +90,7 @@ func (m *Manager) Judge(ctx context.Context) (map[string]oc.Condition, error) { } 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), + reasons[o.Setting], 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") @@ -107,7 +101,7 @@ func (m *Manager) Judge(ctx context.Context) (map[string]oc.Condition, error) { 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), + reasons[p.Setting], 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") @@ -172,7 +166,7 @@ func (m *Manager) judgeLicence(l Licence, licences []Licence, bindings []Binding if len(consumers) > 0 { needs = list + " are not moved off it while its usage is unknown" } - why := l.Usage.readError() + why := l.Usage.ReadErrorWords() 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, @@ -182,14 +176,6 @@ func (m *Manager) judgeLicence(l Licence, licences []Licence, bindings []Binding 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) { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go b/modules/claude-licence-manager/cmd/manager/conditions_test.go similarity index 99% rename from modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go rename to modules/claude-licence-manager/cmd/manager/conditions_test.go index 2ef22da9b..ea81dbd88 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/conditions_test.go +++ b/modules/claude-licence-manager/cmd/manager/conditions_test.go @@ -12,6 +12,8 @@ import ( 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" + + . "claude-licence-manager/internal/licences" ) // saying gives mm's manager a state `conditions` and records each condition it says, in the form the operator diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/holdings.go b/modules/claude-licence-manager/cmd/manager/holdings.go similarity index 100% rename from modules/claude-licence-manager/cmd/claude-licence-manager/holdings.go rename to modules/claude-licence-manager/cmd/manager/holdings.go diff --git a/modules/claude-licence-manager/cmd/manager/keyform.go b/modules/claude-licence-manager/cmd/manager/keyform.go new file mode 100644 index 000000000..d706cef5e --- /dev/null +++ b/modules/claude-licence-manager/cmd/manager/keyform.go @@ -0,0 +1,24 @@ +package main + +// A licence's key taken by another (design 39 bus-and-switch §10, B6); the key form itself is the shared package's. + +import ( + "context" + + . "claude-licence-manager/internal/licences" +) + +// keyTaken names the licence other than name whose key is name's, or "" when none is. +func (m *Manager) keyTaken(ctx context.Context, name string) (string, error) { + all, err := m.Store.Licences(ctx) + if err != nil { + return "", err + } + key := KeyFor(name) + for _, l := range all { + if l.Name != name && KeyFor(l.Name) == key { + return l.Name, nil + } + } + return "", nil +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go b/modules/claude-licence-manager/cmd/manager/keyform_test.go similarity index 74% rename from modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go rename to modules/claude-licence-manager/cmd/manager/keyform_test.go index 205a8f35f..b7ed4a2b6 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/keyform_test.go +++ b/modules/claude-licence-manager/cmd/manager/keyform_test.go @@ -5,23 +5,9 @@ import ( "strings" "testing" "time" -) -// design 39 bus-and-switch §10, B6. -func TestALicencesKeyForm(t *testing.T) { - for name, key := range map[string]string{ - "jane@example.org": "jane_at_example.org", - "Jane.Doe@Example.ORG": "jane.doe_at_example.org", - "0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5": "0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5", - "team key_1": "team_key_1", - "a+b/c:d": "a_b_c_d", - "ops-key": "ops-key", - } { - if got := KeyFor(name); got != key { - t.Errorf("KeyFor(%q) = %q, want %q", name, got, key) - } - } -} + . "claude-licence-manager/internal/licences" +) // design 39 bus-and-switch §10: an adoption whose key another licence has is refused, in the audit and as an event. func TestAnAdoptionWhoseKeyIsTakenIsRefused(t *testing.T) { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/manager/main.go similarity index 90% rename from modules/claude-licence-manager/cmd/claude-licence-manager/main.go rename to modules/claude-licence-manager/cmd/manager/main.go index 48d5d0bd2..52e888111 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/manager/main.go @@ -1,9 +1,10 @@ -// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): one binary, launched by a runtime of its own under -// its own account (`runs-as claude-licence-manager`, design 39 bus-and-switch C1), never by the machine's tool runner, -// and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198). Its service part runs long (the -// conditions judged at its start, the usage timer, the switch rule): it watches what every node reports holding -// and adopts a login it does not hold, keeps every grant alive under a lease, and reads usage. Its tools part, the -// seat's verbs and the module's tools, is served beside it and acts only through that one running manager. +// claude-licence-manager (novox/hq ADR 0183, ADR 0206, design 39): the licence manager's manager bundle (design 39 +// parts §2), loaded by the module's runtime of its own under its own account (`runs-as claude-licence-manager`, design +// 39 bus-and-switch C1), never by the machine's tool runner, and speaking MCP to it over stdio through the Go SDK (ADR +// 0193, ADR 0198). It runs long (the conditions judged at its start, the usage timer, the switch rule): it watches what +// every node reports holding and adopts a login it does not hold, keeps every grant alive under a lease, and reads +// usage. It answers the seat's ten verbs and the module's three acting tools, and is the only part that writes the +// module's kept data and logs; the reading tools are the tools bundle's (cmd/tools, parts §3, §4). // // stdout is the MCP channel; everything this module says, it says on stderr. package main @@ -20,6 +21,8 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + + . "claude-licence-manager/internal/licences" ) // Seat is the role this module holds. @@ -68,10 +71,9 @@ func manager() (*Manager, error) { Store: &BusStore{LicencesState: stdio.State("licences"), BindingsState: stdio.State("bindings"), LeasesState: stdio.State("leases"), OfferedState: stdio.State("offered"), UsageLog: stdio.Log("usage"), AuditLog: stdio.Log("audit"), Now: time.Now}, - Vendor: LiveVendor(), - Carried: stdio.State("carried"), - Grants: VaultGrants{Keys: keys}, - Keys: keys, + Vendor: LiveVendor(), + Grants: VaultGrants{Keys: keys}, + Keys: keys, AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) { var a GrantAnswer raw, err := stdio.Ask("claude-code.claude_code_grant@"+node, map[string]any{"public_key": publicKey}) @@ -94,34 +96,10 @@ func manager() (*Manager, error) { SettingsFile: os.Getenv("MESH_LICENCE_SETTINGS"), Conditions: stdio.State("conditions"), Say: SayCondition, - ConfigLevels: func(context.Context) (map[string]string, error) { return configLevels(node) }, } return built, nil } -// configLevels asks mesh-config where each of the manager's own settings was set on its node (§40, B18): the source -// of each resolved value, by its key. -func configLevels(node string) (map[string]string, error) { - raw, err := stdio.Ask("seat:mesh-config.config", map[string]any{"action": "list", "module": Publisher, "node": node}) - if err != nil { - return nil, err - } - var answer struct { - Resolved []struct { - Key string `json:"key"` - Source string `json:"source"` - } `json:"resolved"` - } - if err := json.Unmarshal(raw, &answer); err != nil { - return nil, fmt.Errorf("mesh-config's answer: %w", err) - } - levels := map[string]string{} - for _, r := range answer.Resolved { - levels[r.Key] = r.Source - } - return levels, nil -} - // keypair is this module's own, made once in its state directory; the private half never leaves it. // Written whole, then linked into place, so a second process reads the first's key and never half of it. A pair it // finds and cannot read (another account's, left before the module ran as its own) is made anew in its place and said diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/manager/manager.go similarity index 98% rename from modules/claude-licence-manager/cmd/claude-licence-manager/manager.go rename to modules/claude-licence-manager/cmd/manager/manager.go index 49ac2f3f4..caf89e33a 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/manager/manager.go @@ -27,6 +27,8 @@ import ( "time" oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + + . "claude-licence-manager/internal/licences" ) // Fingerprint names a token without being one: the agent module's own fingerprint, so the two compare. @@ -137,8 +139,6 @@ type GrantAnswer struct { type Manager struct { Store Store Vendor Vendor - // Carried is the state `carried`, the carry-over's record, which nothing writes again (design 39 parts §10). - Carried keptState // Grants is the vault, which holds every licence's grant and access token (§15, §16). Grants Grants Keys KeyPair @@ -161,12 +161,9 @@ type Manager struct { 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 + Conditions State Say func(c oc.Condition) error conditionsMu sync.Mutex - // ConfigLevels is where mesh-config says each setting was set on the manager's node (mesh, node or default), by - // setting (§40, B18); nil answers every one as the default. - ConfigLevels func(ctx context.Context) (map[string]string, error) } const leaseFor = 2 * time.Minute @@ -952,7 +949,7 @@ func (m *Manager) Licences(ctx context.Context) ([]map[string]any, error) { if err != nil { return nil, err } - open, err := m.KeptConditions() + open, err := KeptConditions(m.Conditions) if err != nil { return nil, err } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/manager/manager_test.go similarity index 99% rename from modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go rename to modules/claude-licence-manager/cmd/manager/manager_test.go index bba2a463b..a5c1104f4 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/manager/manager_test.go @@ -10,6 +10,8 @@ import ( "sync" "testing" "time" + + . "claude-licence-manager/internal/licences" ) var t0 = time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go b/modules/claude-licence-manager/cmd/manager/manifest_test.go similarity index 64% rename from modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go rename to modules/claude-licence-manager/cmd/manager/manifest_test.go index 4e05cfeb1..72e758fe9 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manifest_test.go +++ b/modules/claude-licence-manager/cmd/manager/manifest_test.go @@ -2,12 +2,16 @@ package main import ( "encoding/json" + "fmt" "os" "path/filepath" "regexp" "slices" + "sort" "strings" "testing" + + . "claude-licence-manager/internal/licences" ) // design 39 bus-and-switch §1: the module moves to the new form whole — no `reads`, no old `state`, no `prepare` @@ -174,7 +178,9 @@ func TestTheManagerRunsAsItsOwnAccount(t *testing.T) { if !made { t.Errorf("the manager does not make its account: no user resource named %s", Publisher) } - files, _ := filepath.Glob("*.go") + files, _ := filepath.Glob("../*/*.go") + shared, _ := filepath.Glob("../../internal/*/*.go") + files = append(files, shared...) asked := regexp.MustCompile(`stdio\.Ask\("([^"]+)"`) for _, f := range files { if strings.HasSuffix(f, "_test.go") { @@ -268,3 +274,104 @@ func TestTheCarryOverHasLeft(t *testing.T) { t.Fatal(err) } } + +// design 39 parts §1 to §4, §6 to §8: the module is four parts in one manifest. Its artifacts are exactly the manager +// bundle, the tools bundle and the templates archive, with their words; its resources exactly the account, its +// directory and the settings file, in that order; no process or container resource, no run-once step, no bus account +// of its own and no runtime-health; its tools are the four reading ones and the three acting ones; files/ holds only +// the settings template. +func TestTheModuleIsFourParts(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + same := func(what string, got, want any) { + t.Helper() + g, _ := json.Marshal(got) + w, _ := json.Marshal(want) + if string(g) != string(w) { + t.Errorf("%s is %s, want %s", what, g, w) + } + } + build, _ := m["build"].(map[string]any) + same("build.artifacts", build["artifacts"], []map[string]any{ + {"name": "manager", "kind": "bundle", "language": "go", "system": "arch", "from": "cmd/manager", + "binary": "claude-licence-manager", "loads": []string{"claude-licence-manager"}, + "env": map[string]string{"MESH_LICENCE_STATE": "${dir:state}", "MESH_LICENCE_SETTINGS": "${dir:state}/settings.json"}}, + {"name": "tools", "kind": "bundle", "language": "go", "system": "arch", "from": "cmd/tools", + "binary": "claude-licence-manager-tools", "loads": []string{"claude-licence-manager-tools"}}, + {"name": "files", "kind": "archive", "from": "files"}, + }) + same("resources", m["resources"], []map[string]any{ + {"id": "account", "type": "user", "name": Publisher, "shell": "/usr/bin/nologin", "home": "/var/lib/" + Publisher}, + {"id": "state", "type": "directory", "mode": "0700", "owner": Publisher, "place": ".", "unknown": "program"}, + {"id": "settings", "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", "owner": Publisher, + "takes-config": []string{"cadence_minutes", "floor_minutes", "failures_to_notify", "cooldown_hours", + "refresh_warn_days", "switch_auto", "switch_at_pct", "room_below_pct", "warn_at_pct", "usage_minutes", + "usage_stale_minutes"}, "merge": "json", "template": "files/settings.json"}, + }) + tools, _ := m["tools"].([]any) + var names []string + for _, n := range tools { + names = append(names, fmt.Sprint(n)) + } + sort.Strings(names) + same("tools", names, []string{"licences_conditions", "licences_kept", "licences_read_usage", "licences_resync", + "licences_settings", "licences_switch_now", "licences_switches"}) + same("runs-as", m["runs-as"], Publisher) + same("secrets-owner", m["secrets-owner"], Publisher) + same("own-secrets", m["own-secrets"], map[string]string{"broker": "${dir:state}/broker"}) + if _, ok := m["runtime-health"]; ok { + t.Error("the manifest adds runtime-health: the node-engine's judgement that the runtime is alive is its health") + } + var walk func(path string, v any) + walk = func(path string, v any) { + switch v := v.(type) { + case map[string]any: + for k, e := range v { + if k == "bus" || k == "run-once" { + t.Errorf("the manifest says %s at %s", k, path) + } + if k == "type" && (e == "process" || e == "container") { + t.Errorf("the manifest has a %v resource at %s", e, path) + } + walk(path+"."+k, e) + } + case []any: + for i, e := range v { + walk(fmt.Sprintf("%s[%d]", path, i), e) + } + } + } + walk("", m) + files, _ := os.ReadDir(filepath.Join(moduleDir, "files")) + var templates []string + for _, f := range files { + templates = append(templates, f.Name()) + } + same("files/", templates, []string{"settings.json"}) +} + +// design 39 parts §2, §4: the manager bundle registers the seat's ten verbs under the seat and the three acting tools +// under the module, and nothing more. +func TestTheManagerRegistersTheVerbsAndTheActs(t *testing.T) { + var names []string + for _, tool := range tools() { + names = append(names, tool.Name) + } + sort.Strings(names) + var want []string + for _, v := range []string{"licences", "bindings", "bind", "switch", "release", "refresh", "usage", "adopt", + "current", "public-key"} { + want = append(want, Seat+"."+v) + } + want = append(want, "licences_read_usage", "licences_resync", "licences_switch_now") + sort.Strings(want) + if !slices.Equal(names, want) { + t.Fatalf("the manager bundle registers %v, not %v", names, want) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/room.go b/modules/claude-licence-manager/cmd/manager/room.go similarity index 93% rename from modules/claude-licence-manager/cmd/claude-licence-manager/room.go rename to modules/claude-licence-manager/cmd/manager/room.go index 587448141..3d9045cae 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/room.go +++ b/modules/claude-licence-manager/cmd/manager/room.go @@ -5,7 +5,11 @@ package main // 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 "time" +import ( + "time" + + . "claude-licence-manager/internal/licences" +) // Room is a licence's room at now, and whether it is known. func Room(l Licence, now time.Time, s Settings) (float64, bool) { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go b/modules/claude-licence-manager/cmd/manager/room_test.go similarity index 99% rename from modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go rename to modules/claude-licence-manager/cmd/manager/room_test.go index de14c71da..4ce80c01a 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/room_test.go +++ b/modules/claude-licence-manager/cmd/manager/room_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" "time" + + . "claude-licence-manager/internal/licences" ) // vendorUsage is the vendor's usage answer as it reads: three windows, each with its reset time. diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/seal.go b/modules/claude-licence-manager/cmd/manager/seal.go similarity index 100% rename from modules/claude-licence-manager/cmd/claude-licence-manager/seal.go rename to modules/claude-licence-manager/cmd/manager/seal.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go b/modules/claude-licence-manager/cmd/manager/seal_test.go similarity index 100% rename from modules/claude-licence-manager/cmd/claude-licence-manager/seal_test.go rename to modules/claude-licence-manager/cmd/manager/seal_test.go diff --git a/modules/claude-licence-manager/cmd/manager/settings.go b/modules/claude-licence-manager/cmd/manager/settings.go new file mode 100644 index 000000000..07e9ffe14 --- /dev/null +++ b/modules/claude-licence-manager/cmd/manager/settings.go @@ -0,0 +1,45 @@ +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" + "fmt" + "reflect" + + . "claude-licence-manager/internal/licences" +) + +// 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/manager/settings_test.go b/modules/claude-licence-manager/cmd/manager/settings_test.go new file mode 100644 index 000000000..710d8124f --- /dev/null +++ b/modules/claude-licence-manager/cmd/manager/settings_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +// 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]) + } +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go b/modules/claude-licence-manager/cmd/manager/store.go similarity index 69% rename from modules/claude-licence-manager/cmd/claude-licence-manager/store.go rename to modules/claude-licence-manager/cmd/manager/store.go index 878b4d877..bc2a0fbe7 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go +++ b/modules/claude-licence-manager/cmd/manager/store.go @@ -7,29 +7,9 @@ package main import ( "context" "time" -) -// Licence is one licence: an account, or an API key. Its form is the value of the state `licences` (design 39 -// bus-and-switch §2): never a secret, which the vault holds (§15). -type Licence struct { - Name string `json:"name"` - Kind string `json:"kind"` // subscription | api-key - AccountUUID string `json:"account,omitempty"` - Email string `json:"address,omitempty"` - OrganizationUUID string `json:"organisation,omitempty"` - RefreshFingerprint string `json:"refreshFingerprint,omitempty"` - AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"` - RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"` - Failures int `json:"failures"` - NotifiedAt int64 `json:"notifiedAt,omitempty"` - AdoptedAt int64 `json:"adoptedAt"` - AdoptedFrom string `json:"adoptedFrom,omitempty"` - RotatedAt int64 `json:"rotatedAt,omitempty"` - // Generation is the generation of the licence's secrets in the vault (§2, §15). - Generation int64 `json:"generation,omitempty"` - // Usage is the latest reading of the licence's usage (§2, §24). - Usage *UsageReading `json:"usage,omitempty"` -} + . "claude-licence-manager/internal/licences" +) // Binding is one consumer's binding (ADR 0206, §3): the licence, kind and generation it should hold, since when, and // why and by whom it was moved last. @@ -60,17 +40,6 @@ func (m Move) floor(consumer string) int64 { return m.Floor(consumer) } -// Outcome is what became of a login the manager was offered, by its refresh token's fingerprint. -type Outcome string - -const ( - Adopted Outcome = "adopted" - Dead Outcome = "dead" - Skipped Outcome = "skipped" - Refused Outcome = "refused" - Gone Outcome = "gone" -) - // UsageRow is one usage reading. type UsageRow struct { Licence string `json:"licence"` diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go b/modules/claude-licence-manager/cmd/manager/store_test.go similarity index 96% rename from modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go rename to modules/claude-licence-manager/cmd/manager/store_test.go index b26ff3628..c84d75ec2 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go +++ b/modules/claude-licence-manager/cmd/manager/store_test.go @@ -1,6 +1,8 @@ package main -import "testing" +import ( + "testing" +) func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) { prev := FullGrant{AccessToken: "a", RefreshToken: "r", ExpiresAt: 1} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go b/modules/claude-licence-manager/cmd/manager/switching.go similarity index 99% rename from modules/claude-licence-manager/cmd/claude-licence-manager/switching.go rename to modules/claude-licence-manager/cmd/manager/switching.go index 45bbc8ae6..fd51f639b 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/switching.go +++ b/modules/claude-licence-manager/cmd/manager/switching.go @@ -12,6 +12,8 @@ import ( "fmt" "sort" "strings" + + . "claude-licence-manager/internal/licences" ) // ByUsage is who moves a consumer by the rule (§26). diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/switching_test.go b/modules/claude-licence-manager/cmd/manager/switching_test.go similarity index 99% rename from modules/claude-licence-manager/cmd/claude-licence-manager/switching_test.go rename to modules/claude-licence-manager/cmd/manager/switching_test.go index 40757860a..fe9928acf 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/switching_test.go +++ b/modules/claude-licence-manager/cmd/manager/switching_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" "time" + + . "claude-licence-manager/internal/licences" ) // read is a licence's latest reading, read at t0. diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/templates_test.go b/modules/claude-licence-manager/cmd/manager/templates_test.go similarity index 100% rename from modules/claude-licence-manager/cmd/claude-licence-manager/templates_test.go rename to modules/claude-licence-manager/cmd/manager/templates_test.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/testdata/holdings-changed.json b/modules/claude-licence-manager/cmd/manager/testdata/holdings-changed.json similarity index 100% rename from modules/claude-licence-manager/cmd/claude-licence-manager/testdata/holdings-changed.json rename to modules/claude-licence-manager/cmd/manager/testdata/holdings-changed.json diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/testdata/sealed-by-typescript.json b/modules/claude-licence-manager/cmd/manager/testdata/sealed-by-typescript.json similarity index 100% rename from modules/claude-licence-manager/cmd/claude-licence-manager/testdata/sealed-by-typescript.json rename to modules/claude-licence-manager/cmd/manager/testdata/sealed-by-typescript.json diff --git a/modules/claude-licence-manager/cmd/manager/tools.go b/modules/claude-licence-manager/cmd/manager/tools.go new file mode 100644 index 000000000..1c5b284de --- /dev/null +++ b/modules/claude-licence-manager/cmd/manager/tools.go @@ -0,0 +1,129 @@ +package main + +// The module's acting tools (design 39 bus-and-switch §38, §39, §42; design 39 parts §4): registered by the manager +// bundle beside the seat's verbs, they start by hand what the manager does by itself. The tools that only read are the +// tools bundle's (cmd/tools). + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" + + . "claude-licence-manager/internal/licences" +) + +// tool is one of the module's own tools. +func tool(name, description string, input map[string]any, run func(ctx context.Context, m *Manager, a map[string]any) (any, error)) stdio.Tool { + return stdio.Tool{Name: name, Description: description, Input: input, + Run: func(a map[string]any) (any, error) { + m, err := manager() + if err != nil { + return nil, err + } + ctx := WithCause(context.Background(), Cause{Why: "the " + name + " tool", By: stdio.CallerOf(a)}) + return run(ctx, m, a) + }} +} + +func boolean(description string) map[string]any { + return map[string]any{"type": "boolean", "description": description} +} + +func number(description string) map[string]any { + return map[string]any{"type": "number", "description": description} +} + +func moduleTools() []stdio.Tool { + licence := str("a licence, as `licences` names it; absent for all") + dry := boolean("answer what the rule would do, and do nothing") + return []stdio.Tool{ + tool("licences_read_usage", "Read usage now, for one subscription or every one, as the manager does on its cadence; then apply the switch rule unless dry. Answers each reading and what the rule did or would do.", + map[string]any{"licence": licence, "dry": dry}, + func(ctx context.Context, m *Manager, a map[string]any) (any, error) { + l, _ := a["licence"].(string) + d, _ := a["dry"].(bool) + return m.ReadUsageOf(ctx, strings.TrimSpace(l), d) + }), + tool("licences_switch_now", "Apply the switch rule now from the latest readings, for one licence or all, even with switch_auto off since a person asked; with dry, answer the moves and make none.", + map[string]any{"licence": licence, "dry": dry}, + func(ctx context.Context, m *Manager, a map[string]any) (any, error) { + l, _ := a["licence"].(string) + d, _ := a["dry"].(bool) + acts, err := m.ApplyRule(ctx, strings.TrimSpace(l), true, d) + if acts == nil { + acts = []Switch{} + } + return acts, err + }), + tool("licences_resync", "Recover: say binding.changed again for every binding, ask every node what it holds, say again each condition held, and hand each licence's access token to the vault again. Answers what it said.", + nil, func(ctx context.Context, m *Manager, _ map[string]any) (any, error) { + return m.Resync(ctx) + }), + } +} + +// Resync says again what a consumer, the vault or a node may have missed (§42): binding.changed for every binding, +// holdings.asked, each condition held, and licence-put of each licence's access token. Answers what it said. +func (m *Manager) Resync(ctx context.Context) (map[string]any, error) { + bindings, err := m.Store.Bindings(ctx) + if err != nil { + return nil, err + } + said := map[string]any{} + consumers := []string{} + for _, b := range bindings { + m.changed(b, b.Licence) + consumers = append(consumers, b.Consumer) + } + said["binding.changed"] = consumers + if err := m.AskHoldings(CauseOf(ctx).By); err != nil { + return said, fmt.Errorf("holdings.asked could not be said: %w", err) + } + said["holdings.asked"] = true + if err := m.SayConditions(ctx, true); err != nil { + return said, err + } + keys, err := m.Conditions.Keys() + if err != nil { + return said, err + } + sort.Strings(keys) + said["conditions"] = keys + licences, err := m.Store.Licences(ctx) + if err != nil { + return said, err + } + put := []string{} + var errs []error + for _, l := range licences { + if l.Generation == 0 { + continue + } + key := KeyFor(l.Name) + // The access token is built from the grant, which the vault hands the manager; it hands a licence's access + // token only to a machine a binding names (§17), and the manager's machine may have none. + access, err := m.Grants.Get(ctx, key, PartGrant) + if err == nil && l.Kind == "subscription" { + var g FullGrant + if err = json.Unmarshal([]byte(access), &g); err == nil { + access = accessOf(g) + } + } + if err == nil { + err = m.Grants.Put(ctx, key, PartAccess, l.Generation, access) + } + access = "" + if err != nil { + errs = append(errs, fmt.Errorf("the access token of %s: %w", l.Name, err)) + continue + } + put = append(put, l.Name) + } + said["licence-put"] = put + return said, errors.Join(errs...) +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go b/modules/claude-licence-manager/cmd/manager/tools_test.go similarity index 53% rename from modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go rename to modules/claude-licence-manager/cmd/manager/tools_test.go index 6232280bd..ce528e61a 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/tools_test.go +++ b/modules/claude-licence-manager/cmd/manager/tools_test.go @@ -3,11 +3,11 @@ package main import ( "context" "encoding/json" - "errors" - "os" "strings" "testing" "time" + + . "claude-licence-manager/internal/licences" ) // stdTool is one served verb or tool's run. @@ -105,32 +105,6 @@ func TestUsageIsBoundedBySinceAndLimit(t *testing.T) { } } -// design 39 bus-and-switch §37/AC1: a switch by usage and one by a person are answered, newest first, with their -// causes, a switch by usage with the reading that moved them; licence and consumer narrow the answer. -func TestSwitchesAnswersBothCauses(t *testing.T) { - mm := fourOnA(t) - ctx := context.Background() - _, _ = mm.m.ApplyRule(ctx, "", false, false) - mm.now = t0.Add(time.Minute) - _, _ = mm.m.Bind(WithCause(ctx, Cause{Why: "the switch verb", By: "a person"}), "ace", "c") - run := served(t, mm)["licences_switches"] - got := answer(t, run, nil).([]any) - if len(got) != 2 { - t.Fatalf("switches %v", got) - } - person, usage := got[0].(map[string]any), got[1].(map[string]any) - if person["by"] != "a person" || person["why"] != "the switch verb" || person["to"] != "c" || person["reading"] != nil || - usage["by"] != ByUsage || usage["reading"].(map[string]any)["window"] != "weekly" || usage["from"] != "a" { - t.Fatalf("switches %v", got) - } - if got := answer(t, run, map[string]any{"licence": "c"}).([]any); len(got) != 1 { - t.Fatalf("of c %v", got) - } - if got := answer(t, run, map[string]any{"consumer": "g14"}).([]any); len(got) != 1 || got[0].(map[string]any)["by"] != ByUsage { - t.Fatalf("of g14 %v", got) - } -} - // design 39 bus-and-switch §38/AC1, §24, §32: reading usage now writes the reading and applies the rule, and with dry // reads, answers the moves and moves nothing. func TestReadUsageNowAppliesTheRuleUnlessDry(t *testing.T) { @@ -165,95 +139,6 @@ func TestSwitchNowRunsWithSwitchAutoOff(t *testing.T) { } } -// design 39 bus-and-switch §40/AC1, B18: a value set at node level is answered with its level, the rest with the -// default, each with its value and default, and an invalid one saying why. -func TestSettingsAnswersTheLevel(t *testing.T) { - mm := newMesh(t) - mm.m.Settings.SwitchAt, mm.m.Settings.WarnAt = 80, 85 - mm.m.ConfigLevels = func(context.Context) (map[string]string, error) { - return map[string]string{"switch_at_pct": "node", "warn_at_pct": "mesh", "cadence_minutes": "default"}, nil - } - got := answer(t, served(t, mm)["licences_settings"], nil).([]any) - by := map[string]map[string]any{} - for _, s := range got { - f := s.(map[string]any) - by[f["setting"].(string)] = f - } - if len(by) != len(Defaults.Values()) || by["switch_at_pct"]["level"] != "node" || by["switch_at_pct"]["value"] != 80.0 || - by["switch_at_pct"]["default"] != 99.0 || by["usage_minutes"]["level"] != "default" || - !strings.Contains(fmtAny(by["room_below_pct"]["invalid"]), "above switch_at_pct 80") || - !strings.Contains(fmtAny(by["warn_at_pct"]["invalid"]), "85") || by["switch_auto"]["invalid"] != nil { - t.Fatalf("settings %v", by) - } - mm.m.ConfigLevels = func(context.Context) (map[string]string, error) { return nil, errors.New("no responders") } - if _, err := served(t, mm)["licences_settings"](nil); err == nil { - t.Fatal("mesh-config not answering is answered as the default") - } -} - -func fmtAny(v any) string { s, _ := v.(string); return s } - -// design 39 bus-and-switch §41/AC1, §20: every bus-state entry the manifest asks for is read by licences_kept, with its -// keys or subjects, one key's value and its fill; and no entry holds a token. -func TestKeptReadsEveryEntryOfTheManifest(t *testing.T) { - raw, err := os.ReadFile("../../module.json") - if err != nil { - t.Fatal(err) - } - var m struct { - Requires []json.RawMessage `json:"requires"` - Logs []json.RawMessage `json:"logs"` - } - _ = json.Unmarshal(raw, &m) - // The logs are declared in the logs section (C3), each read as a log. - for name := range logsSection(m.Logs) { - r, _ := json.Marshal(map[string]string{"name": name, "template": "log"}) - m.Requires = append(m.Requires, r) - } - mm := newMesh(t) - ctx := context.Background() - _, _ = saying(mm) - mm.m.Carried = newMemState(func() time.Time { return mm.now }) - _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-secret-1", true, t0)}) - _ = mm.m.ReadUsage(ctx) - run := served(t, mm)["licences_kept"] - all := answer(t, run, nil).(map[string]any) - entries := 0 - for _, r := range m.Requires { - var e struct { - Name string `json:"name"` - Template string `json:"template"` - } - if json.Unmarshal(r, &e) != nil || e.Template == "" { - continue - } - entries++ - got, ok := all[e.Name].(map[string]any) - if !ok || got["fill"] == nil || got["kind"] != map[string]string{"state": "state", "log": "log"}[e.Template] { - t.Errorf("licences_kept does not read %s as a %s: %v", e.Name, e.Template, all[e.Name]) - } - } - if entries != 8 || len(all) != entries { - t.Fatalf("the manifest asks for %d entries, licences_kept reads %d", entries, len(all)) - } - one := answer(t, run, map[string]any{"entry": "licences", "key": KeyFor(licence1)}).(map[string]any) - if one["licences"].(map[string]any)["value"] == nil { - t.Fatalf("one key %v", one) - } - if subjects := all["usage"].(map[string]any)["subjects"].(map[string]any); subjects[KeyFor(licence1)] == nil { - t.Fatalf("usage subjects %v", subjects) - } - if _, err := run(map[string]any{"entry": "tables"}); err == nil { - t.Fatal("an entry not kept is answered") - } - everything, _ := json.Marshal(all) - for _, token := range []string{"rt-secret", "rt-1", "at-1", "local-"} { - if strings.Contains(string(everything), token) { - t.Fatalf("a kept entry holds %s", token) - } - } -} - // design 39 bus-and-switch §42/AC1: resync says binding.changed once per binding, holdings.asked once, each condition // once, and licence-put of each licence's access once. func TestResyncSaysEachOnce(t *testing.T) { @@ -276,25 +161,3 @@ func TestResyncSaysEachOnce(t *testing.T) { t.Fatalf("said %v, conditions %d, answered %v", mm.events, len(*said), got) } } - -// design 39 bus-and-switch §43/AC1: each key of conditions is answered with its body and since. -func TestConditionsAnswersEachKey(t *testing.T) { - mm := fourOnA(t) - ctx := context.Background() - _, _ = saying(mm) - _ = mm.store.SaveLicence(ctx, sub("a", read(40, 95))) - mm.m.Settings.RoomBelow = 100 - _ = mm.m.SayConditions(ctx, false) - got := answer(t, served(t, mm)["licences_conditions"], nil).([]any) - var keys []string - for _, c := range got { - f := c.(map[string]any) - if f["since"] == "" || f["body"].(map[string]any)["headline"] == "" { - t.Fatalf("condition %v", f) - } - keys = append(keys, f["body"].(map[string]any)["key"].(string)) - } - if strings.Join(keys, ",") != "licence.a.near-limit,settings.room_below_pct.invalid" { - t.Fatalf("keys %v", keys) - } -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants.go b/modules/claude-licence-manager/cmd/manager/vaultgrants.go similarity index 100% rename from modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants.go rename to modules/claude-licence-manager/cmd/manager/vaultgrants.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants_test.go b/modules/claude-licence-manager/cmd/manager/vaultgrants_test.go similarity index 97% rename from modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants_test.go rename to modules/claude-licence-manager/cmd/manager/vaultgrants_test.go index 2205ba5a2..d5dc9405e 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants_test.go +++ b/modules/claude-licence-manager/cmd/manager/vaultgrants_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "strings" "testing" + + . "claude-licence-manager/internal/licences" ) // design 39 bus-and-switch §15, §16, §20: a rotation reads the grant from the vault and hands it back, the grant first diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/vendor.go b/modules/claude-licence-manager/cmd/manager/vendor.go similarity index 90% rename from modules/claude-licence-manager/cmd/claude-licence-manager/vendor.go rename to modules/claude-licence-manager/cmd/manager/vendor.go index 1b07c695f..51e2ef704 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/vendor.go +++ b/modules/claude-licence-manager/cmd/manager/vendor.go @@ -14,6 +14,8 @@ import ( "os" "strings" "time" + + . "claude-licence-manager/internal/licences" ) // The public Claude Code client's id: not a secret, and a hard-won constant — a metadata URL in its place @@ -168,19 +170,6 @@ func (v liveVendor) Usage(ctx context.Context, accessToken string) (map[string]a return out, nil } -// 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 session, weekly and sonnet windows, each with its reset time (§24). func FlattenUsage(u map[string]any) UsageReading { window := func(k string) (*float64, string) { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go b/modules/claude-licence-manager/cmd/manager/whole_integration_test.go similarity index 88% rename from modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go rename to modules/claude-licence-manager/cmd/manager/whole_integration_test.go index 7f35fdce2..fa1afe7df 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/whole_integration_test.go +++ b/modules/claude-licence-manager/cmd/manager/whole_integration_test.go @@ -37,6 +37,8 @@ import ( 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" "git.novox.be/novox/mesh-sdk/go/testbus" + + . "claude-licence-manager/internal/licences" ) const ( @@ -157,14 +159,16 @@ type said struct { // mesh is the bus, the state directory and the parts beside the manager, kept across its starts. type mesh struct { - t *testing.T - ctx context.Context - nc *nats.Conn - js jetstream.JetStream - bin string - dir string - vendor *vendor - own issued // what the manager's own runtime is issued from its manifest (C1) + t *testing.T + ctx context.Context + nc *nats.Conn + js jetstream.JetStream + bin string + // toolsBin is the tools bundle's binary. + toolsBin string + dir string + vendor *vendor + own issued // what the manager's own runtime is issued from its manifest (C1) mu sync.Mutex said []said @@ -188,11 +192,14 @@ func newWholeMesh(t *testing.T) *mesh { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) dir := t.TempDir() - bin := filepath.Join(dir, "claude-licence-manager") - build := exec.Command("go", "build", "-o", bin, ".") - build.Stderr = os.Stderr - if err := build.Run(); err != nil { - t.Fatalf("the manager does not build: %v", err) + // The module's two bundles, each loaded by the same runtime (design 39 parts §2, §3). + bin, toolsBin := filepath.Join(dir, "claude-licence-manager"), filepath.Join(dir, "claude-licence-manager-tools") + for out, from := range map[string]string{bin: ".", toolsBin: "../tools"} { + build := exec.Command("go", "build", "-o", out, from) + build.Stderr = os.Stderr + if err := build.Run(); err != nil { + t.Fatalf("%s does not build: %v", from, err) + } } nc, err := nats.Connect(testbus.URL(t)) if err != nil { @@ -209,9 +216,20 @@ func newWholeMesh(t *testing.T) *mesh { t.Fatal(err) } } + // Each log with the cap its logs section names (C3), as the bus module creates it. + raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json")) + if err != nil { + t.Fatal(err) + } + var manifest struct { + Logs []json.RawMessage `json:"logs"` + } + _ = json.Unmarshal(raw, &manifest) + bounds := logsSection(manifest.Logs) for l := range own.Logs { + mib, _ := bounds[l]["max-mib"].(float64) if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "LOG_" + Publisher + "_" + l, - Subjects: []string{"mesh.log." + Publisher + "." + l + ".>"}}); err != nil { + Subjects: []string{"mesh.log." + Publisher + "." + l + ".>"}, MaxBytes: int64(mib) << 20}); err != nil { t.Fatal(err) } } @@ -219,7 +237,7 @@ func newWholeMesh(t *testing.T) *mesh { if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte("{}"), 0o600); err != nil { t.Fatal(err) } - m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, dir: dir, vendor: newVendor(t), own: own, + m := &mesh{t: t, ctx: ctx, nc: nc, js: js, bin: bin, toolsBin: toolsBin, dir: dir, vendor: newVendor(t), own: own, news: make(chan struct{}, 1), secrets: map[string]string{}, calls: map[string][32]byte{}, callArgs: map[string]map[string]any{}, logins: map[string]FullGrant{}} sub, err := nc.Subscribe("mesh.seat.operator-channel.accept.condition.>", func(msg *nats.Msg) { @@ -318,7 +336,14 @@ type started struct { // start starts the manager as its runtime launches it, with the module's words (ADR 0192). func (m *mesh) start() *started { m.t.Helper() - cmd := exec.CommandContext(m.ctx, m.bin) + return m.load(m.bin) +} + +// load starts one bundle's binary as the module's runtime loads it, with the module's words, which the runtime hands +// to every bundle of the module (ADR 0192). +func (m *mesh) load(bin string) *started { + m.t.Helper() + cmd := exec.CommandContext(m.ctx, bin) cmd.Env = append(os.Environ(), "MESH_LICENCE_STATE="+filepath.Join(m.dir, "state"), "MESH_LICENCE_SETTINGS="+filepath.Join(m.dir, "settings.json"), "MESH_NODE=novox", "MESH_MODULE="+Publisher, "MESH_ANTHROPIC_TOKEN_ENDPOINT="+m.vendor.server.URL+"/token", "MESH_ANTHROPIC_USAGE_ENDPOINT="+m.vendor.server.URL+"/usage") @@ -911,3 +936,71 @@ func TestANewLoginIsAdoptedListedAndATarget(t *testing.T) { t.Errorf("laptop is bound %+v", b) } } + +// design 39 parts §3, §10, §18: with the manager bundle not loaded (as when it refuses to start), the tools bundle, +// loaded by the same runtime, answers licences_kept with every entry the module keeps and licences_conditions with +// each condition kept, and says, writes and asks nothing while it does. +func TestTheReadingToolsAnswerWithoutTheManager(t *testing.T) { + m := newWholeMesh(t) + m.seed() + carried, err := m.js.KeyValue(m.ctx, Publisher+"_carried") + if err != nil { + t.Fatal(err) + } + if _, err := carried.Put(m.ctx, "postgres", []byte(`{"at":"2026-11-12T09:44:59Z"}`)); err != nil { + t.Fatal(err) + } + seqs := func() map[string]uint64 { + out := map[string]uint64{} + for s := range m.own.States { + st, err := m.js.Stream(m.ctx, "KV_"+Publisher+"_"+s) + if err != nil { + t.Fatal(err) + } + i, _ := st.Info(m.ctx) + out[s] = i.State.LastSeq + } + for l := range m.own.Logs { + st, err := m.js.Stream(m.ctx, "LOG_"+Publisher+"_"+l) + if err != nil { + t.Fatal(err) + } + i, _ := st.Info(m.ctx) + out["log "+l] = i.State.LastSeq + } + return out + } + before := seqs() + tools := m.load(m.toolsBin) + var all map[string]map[string]any + if err := json.Unmarshal(tools.must("licences_kept", nil), &all); err != nil { + t.Fatal(err) + } + if len(all) != len(m.own.States)+len(m.own.Logs) || len(all) != 8 { + t.Fatalf("licences_kept answers %d entries without the manager, not 8: %v", len(all), all) + } + for name, e := range all { + if e["fill"] == nil { + t.Errorf("licences_kept answers %s without its fill: %v", name, e) + } + } + var one map[string]map[string]any + if err := json.Unmarshal(tools.must("licences_kept", map[string]any{"entry": "carried", "key": "postgres"}), &one); err != nil { + t.Fatal(err) + } + if v, _ := one["carried"]["value"].(map[string]any); v["at"] != "2026-11-12T09:44:59Z" { + t.Errorf("the carry-over's record is answered as %v", one) + } + if keys, _ := all["bindings"]["keys"].([]any); len(keys) != len(consumers) { + t.Errorf("bindings answers %v", all["bindings"]) + } + tools.must("licences_conditions", nil) + if after := seqs(); fmt.Sprint(after) != fmt.Sprint(before) { + t.Errorf("the tools bundle wrote: before %v, after %v", before, after) + } + m.mu.Lock() + defer m.mu.Unlock() + if len(m.said) != 0 || len(m.conditions) != 0 { + t.Errorf("the tools bundle said or asked %v and %v", m.said, m.conditions) + } +} diff --git a/modules/claude-licence-manager/cmd/tools/main.go b/modules/claude-licence-manager/cmd/tools/main.go new file mode 100644 index 000000000..a4554edfa --- /dev/null +++ b/modules/claude-licence-manager/cmd/tools/main.go @@ -0,0 +1,70 @@ +// claude-licence-manager-tools (design 39 parts §3): the licence manager's tools bundle, loaded by the module's runtime +// of its own beside the manager bundle. It answers the module's four reading tools and writes nothing: it reads the +// module's kept data with the runtime's bus-state calls and its two logs with the runtime's log calls, each answered +// as the module whose bundle asks, and asks mesh-config where each setting was set. The runtime serves the tools of +// every bundle of a module that loaded, so these answer while the manager bundle fails to load or stops. +// +// stdout is the MCP channel; everything this bundle says, it says on stderr. +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" + + "claude-licence-manager/internal/licences" +) + +func main() { + if err := stdio.Serve("", fromTheRuntime().Tools()); err != nil { + fmt.Fprintf(os.Stderr, "[claude-licence-manager-tools] %v\n", err) + os.Exit(1) + } +} + +// fromTheRuntime is the reader of the module's kept data through its runtime, and of its settings file by the word +// the manager bundle gives the module (MESH_LICENCE_SETTINGS; ADR 0192: the runtime hands a module's words to all +// its bundles). +func fromTheRuntime() *Reader { + node, _ := os.Hostname() + if n := os.Getenv("MESH_NODE"); n != "" { + node = n + } + r := &Reader{States: map[string]licences.State{}, Logs: map[string]licences.Log{}, + SettingsFile: os.Getenv("MESH_LICENCE_SETTINGS"), + ConfigLevels: func(context.Context) (map[string]string, error) { return configLevels(node) }} + for _, name := range licences.States { + r.States[name] = stdio.State(name) + } + for _, name := range licences.Logs { + r.Logs[name] = stdio.Log(name) + } + return r +} + +// configLevels asks mesh-config where each of the module's own settings was set on its node (bus-and-switch §40, +// B18): the source of each resolved value, by its key. +func configLevels(node string) (map[string]string, error) { + raw, err := stdio.Ask("seat:mesh-config.config", map[string]any{"action": "list", "module": licences.Publisher, + "node": node}) + if err != nil { + return nil, err + } + var answer struct { + Resolved []struct { + Key string `json:"key"` + Source string `json:"source"` + } `json:"resolved"` + } + if err := json.Unmarshal(raw, &answer); err != nil { + return nil, fmt.Errorf("mesh-config's answer: %w", err) + } + levels := map[string]string{} + for _, r := range answer.Resolved { + levels[r.Key] = r.Source + } + return levels, nil +} diff --git a/modules/claude-licence-manager/cmd/tools/tools.go b/modules/claude-licence-manager/cmd/tools/tools.go new file mode 100644 index 000000000..6f4e647fb --- /dev/null +++ b/modules/claude-licence-manager/cmd/tools/tools.go @@ -0,0 +1,260 @@ +package main + +// The module's four reading tools (design 39 parts §3; bus-and-switch §37, §40, §41, §43): they read everything the +// manager keeps and answer it, and write nothing. Every act is the manager bundle's (parts §4). + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "sort" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + + "claude-licence-manager/internal/licences" +) + +// Reader reads the module's kept data and its settings, as the module whose bundle asks. +type Reader struct { + // States and Logs are the module's kept states and logs, by their names in the manifest. + States map[string]licences.State + Logs map[string]licences.Log + // SettingsFile is the settings file the node-engine writes, read again at every call (bus-and-switch §33). + SettingsFile string + // ConfigLevels is where mesh-config says each setting was set on the manager's node (mesh, node or default), by + // setting (§40, B18). + ConfigLevels func(ctx context.Context) (map[string]string, error) +} + +func str(description string) map[string]any { + return map[string]any{"type": "string", "description": description} +} + +func number(description string) map[string]any { + return map[string]any{"type": "number", "description": description} +} + +// sinceOf is the argument since, RFC 3339; absent is the zero time. +func sinceOf(a map[string]any) (time.Time, error) { + v, _ := a["since"].(string) + if strings.TrimSpace(v) == "" { + return time.Time{}, nil + } + t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(v)) + if err != nil { + return time.Time{}, fmt.Errorf("since is not a time in RFC 3339: %w", err) + } + return t, nil +} + +// limitOf is the argument limit: def when absent or not positive, at most most. +func limitOf(a map[string]any, def, most int) int { + limit := def + if v, ok := a["limit"].(float64); ok && v > 0 { + limit = int(v) + } + return min(limit, most) +} + +// Tools are the four reading tools, registered under the module (parts §3). +func (r *Reader) Tools() []stdio.Tool { + licence := str("a licence, as `licences` names it; absent for all") + return []stdio.Tool{ + {Name: "licences_switches", Description: "Every switch of a consumer and why, newest first, from the audit: when, from and to which licence, the consumers, by whom and why, and for a switch by usage the reading that moved them.", + Input: map[string]any{"licence": licence, "consumer": str("a consumer; absent for all"), + "since": str("a time, RFC 3339: no switch before it"), "limit": number("how many (default 20)")}, + Run: func(a map[string]any) (any, error) { + l, _ := a["licence"].(string) + c, _ := a["consumer"].(string) + since, err := sinceOf(a) + if err != nil { + return nil, err + } + return r.Switches(strings.TrimSpace(l), strings.TrimSpace(c), since, limitOf(a, 20, 2000)) + }}, + {Name: "licences_settings", Description: "The settings in force: each one's value, its default, where it was set (mesh, node or the default, as mesh-config answers it) and why it is invalid when it is.", + Run: func(map[string]any) (any, error) { return r.SettingsInForce(context.Background()) }}, + {Name: "licences_kept", Description: "Every entry the manager keeps on the bus: a state's keys, or a log's subjects with their last entry; with key, that key's value or that subject's last entry; and how full each is against its cap.", + Input: map[string]any{"entry": str("one of licences, bindings, leases, offered, usage, audit, carried, conditions; absent for all"), + "key": str("a key of a state, or a subject of a log")}, + Run: func(a map[string]any) (any, error) { + e, _ := a["entry"].(string) + k, _ := a["key"].(string) + return r.Kept(strings.TrimSpace(e), strings.TrimSpace(k)) + }}, + {Name: "licences_conditions", Description: "The conditions the manager said and holds: each key of its state conditions with its body and since.", + Run: func(map[string]any) (any, error) { + return licences.KeptConditions(r.States[licences.EntryConditions]) + }}, + } +} + +// Switches are the audit's switches, newest first: of a licence (from or to it) and a consumer when named, none before +// since, at most limit (§37). +func (r *Reader) Switches(licence, consumer string, since time.Time, limit int) ([]map[string]any, error) { + audit, ok := r.Logs[licences.EntryAudit] + if !ok { + return nil, fmt.Errorf("the module keeps no log %s", licences.EntryAudit) + } + info, err := audit.Info() + if err != nil || info.Last == 0 { + return []map[string]any{}, err + } + var all []stdio.LogEntry + for next := uint64(1); next != 0; { + page, err := audit.Read(next, "switched", 0) + if err != nil { + return nil, err + } + all = append(all, page.Entries...) + next = page.Next + } + out := []map[string]any{} + for i := len(all) - 1; i >= 0 && len(out) < limit; i-- { + var e map[string]any + if json.Unmarshal(all[i].Value, &e) != nil { + continue + } + at, _ := time.Parse(time.RFC3339Nano, fmt.Sprint(e["at"])) + if at.Before(since) { + continue + } + if licence != "" && e["from"] != licence && e["to"] != licence { + continue + } + consumers, _ := e["consumers"].([]any) + if consumer != "" && !slices.Contains(consumers, any(consumer)) { + continue + } + s := map[string]any{"at": e["at"], "from": e["from"], "to": e["to"], "consumers": e["consumers"], + "by": e["by"], "why": e["why"]} + if _, ok := e["window"]; ok { + s["reading"] = map[string]any{"window": e["window"], "pct": e["pct"], "resetsAt": e["resetsAt"], + "room": e["room"]} + } + out = append(out, s) + } + return out, nil +} + +// SettingAnswer is one setting as licences_settings answers it (§40). +type SettingAnswer struct { + Setting string `json:"setting"` + Value any `json:"value"` + Default any `json:"default"` + Level string `json:"level"` + Invalid string `json:"invalid,omitempty"` +} + +// SettingsInForce is each setting in force, read from the settings file, its default, where mesh-config says it was +// set, and why it is invalid when it is (§40, B18). +func (r *Reader) SettingsInForce(ctx context.Context) ([]SettingAnswer, error) { + levels := map[string]string{} + if r.ConfigLevels != nil { + got, err := r.ConfigLevels(ctx) + if err != nil { + return nil, fmt.Errorf("mesh-config did not say where each setting was set: %w", err) + } + levels = got + } + s := licences.SettingsFrom(r.SettingsFile) + reasons := s.Reasons() + values, defaults := s.Values(), licences.Defaults.Values() + var out []SettingAnswer + for k, v := range values { + a := SettingAnswer{Setting: k, Value: v, Default: defaults[k], Level: levels[k], Invalid: reasons[k]} + if a.Level == "" { + a.Level = "default" + } + out = append(out, a) + } + sort.Slice(out, func(i, j int) bool { return out[i].Setting < out[j].Setting }) + return out, nil +} + +// Kept answers one entry the module keeps, or every one (§41): a state's keys and their count, a log's subjects with +// their last entry and its fill against its cap; with key, one key's value or one subject's last entry. +func (r *Reader) Kept(entry, key string) (map[string]any, error) { + out := map[string]any{} + names := []string{} + for n := range r.States { + names = append(names, n) + } + for n := range r.Logs { + names = append(names, n) + } + sort.Strings(names) + if entry != "" && !slices.Contains(names, entry) { + return nil, fmt.Errorf("the manager keeps no entry %q: it keeps %s", entry, strings.Join(names, ", ")) + } + for _, n := range names { + if entry != "" && n != entry { + continue + } + if st, ok := r.States[n]; ok { + a, err := stateAnswer(st, key) + if err != nil { + return nil, fmt.Errorf("%s: %w", n, err) + } + out[n] = a + continue + } + a, err := logAnswer(r.Logs[n], key) + if err != nil { + return nil, fmt.Errorf("%s: %w", n, err) + } + out[n] = a + } + return out, nil +} + +func stateAnswer(st licences.State, key string) (map[string]any, error) { + keys, err := st.Keys() + if err != nil { + return nil, err + } + sort.Strings(keys) + a := map[string]any{"kind": "state", "keys": keys, "fill": map[string]any{"keys": len(keys), "cap": "none declared"}} + if key != "" { + e, err := st.Get(key) + if err != nil { + return nil, err + } + if e == nil { + a["value"] = nil + } else { + a["value"] = e.Value + } + } + return a, nil +} + +func logAnswer(l licences.Log, key string) (map[string]any, error) { + info, err := l.Info() + if err != nil { + return nil, err + } + a := map[string]any{"kind": "log", "fill": map[string]any{"entries": info.Entries, "bytes": info.Bytes, + "maxBytes": info.MaxBytes}} + if key != "" { + e, err := l.Last(key) + if err != nil { + return nil, err + } + a["last"] = e + return a, nil + } + newest, err := l.Newest("") + if err != nil { + return nil, err + } + subjects := map[string]any{} + for _, e := range newest { + subjects[e.Key] = e + } + a["subjects"] = subjects + return a, nil +} diff --git a/modules/claude-licence-manager/cmd/tools/tools_test.go b/modules/claude-licence-manager/cmd/tools/tools_test.go new file mode 100644 index 000000000..6df0bf48b --- /dev/null +++ b/modules/claude-licence-manager/cmd/tools/tools_test.go @@ -0,0 +1,340 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" + "testing" + "time" + + oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel" + stdio "git.novox.be/novox/mesh-sdk/go" + + "claude-licence-manager/internal/licences" +) + +// runtime is a fixture of the runtime's bus-state and log calls (design 39 parts §3): it answers every read from what +// a test seeds, and fails the test on any write or append the tools bundle makes. +type runtime struct { + t *testing.T + states map[string]map[string]json.RawMessage + logs map[string][]stdio.LogEntry +} + +func newRuntime(t *testing.T) *runtime { + rt := &runtime{t: t, states: map[string]map[string]json.RawMessage{}, logs: map[string][]stdio.LogEntry{}} + for _, s := range licences.States { + rt.states[s] = map[string]json.RawMessage{} + } + for _, l := range licences.Logs { + rt.logs[l] = nil + } + return rt +} + +// seedState and seedLog keep what the manager wrote before the tools were asked. +func (rt *runtime) seedState(state, key string, v any) { + raw, _ := json.Marshal(v) + rt.states[state][key] = raw +} + +func (rt *runtime) seedLog(log, key string, v any) { + raw, _ := json.Marshal(v) + rt.logs[log] = append(rt.logs[log], stdio.LogEntry{Sequence: uint64(len(rt.logs[log]) + 1), Key: key, Value: raw}) +} + +func (rt *runtime) reader() *Reader { + r := &Reader{States: map[string]licences.State{}, Logs: map[string]licences.Log{}} + for s := range rt.states { + r.States[s] = keptState{rt, s} + } + for l := range rt.logs { + r.Logs[l] = keptLog{rt, l} + } + return r +} + +type keptState struct { + rt *runtime + name string +} + +func (s keptState) wrote(how, key string) error { + s.rt.t.Errorf("the tools bundle %s %s.%s: it writes nothing", how, s.name, key) + return errors.New("refused: the tools bundle writes nothing") +} + +func (s keptState) Get(key string) (*stdio.StateEntry, error) { + v, ok := s.rt.states[s.name][key] + if !ok { + return nil, nil + } + return &stdio.StateEntry{Key: key, Value: v, Revision: 1}, nil +} +func (s keptState) Put(key string, _ any) (uint64, error) { return 0, s.wrote("put", key) } +func (s keptState) PutTTL(key string, _ any, _ time.Duration) (uint64, error) { + return 0, s.wrote("put", key) +} +func (s keptState) Create(key string, _ any) (uint64, error) { return 0, s.wrote("created", key) } +func (s keptState) CreateTTL(key string, _ any, _ time.Duration) (uint64, error) { + return 0, s.wrote("created", key) +} +func (s keptState) Delete(key string) error { return s.wrote("deleted", key) } +func (s keptState) Keys() ([]string, error) { + keys := []string{} + for k := range s.rt.states[s.name] { + keys = append(keys, k) + } + sort.Strings(keys) + return keys, nil +} +func (s keptState) History(key string) ([]stdio.StateRevision, error) { return nil, nil } + +type keptLog struct { + rt *runtime + name string +} + +func (l keptLog) Append(key string, _ any) (uint64, error) { + l.rt.t.Errorf("the tools bundle appended to %s.%s: it writes nothing", l.name, key) + return 0, errors.New("refused: the tools bundle writes nothing") +} + +func (l keptLog) Read(from uint64, keys string, _ int) (*stdio.LogPage, error) { + page := &stdio.LogPage{Last: uint64(len(l.rt.logs[l.name]))} + for _, e := range l.rt.logs[l.name] { + if e.Sequence >= from && (keys == "" || e.Key == keys) { + page.Entries = append(page.Entries, e) + } + } + return page, nil +} + +func (l keptLog) Last(key string) (*stdio.LogEntry, error) { + var last *stdio.LogEntry + for _, e := range l.rt.logs[l.name] { + if e.Key == key { + last = &e + } + } + return last, nil +} + +func (l keptLog) Info() (*stdio.LogInfo, error) { + n := uint64(len(l.rt.logs[l.name])) + return &stdio.LogInfo{Entries: n, Last: n, First: min(n, 1), MaxBytes: 64 << 20}, nil +} + +func (l keptLog) Newest(string) ([]stdio.LogEntry, error) { + by := map[string]stdio.LogEntry{} + for _, e := range l.rt.logs[l.name] { + by[e.Key] = e + } + out := []stdio.LogEntry{} + for _, e := range by { + out = append(out, e) + } + return out, nil +} + +// run is one tool's answer as JSON, decoded into a generic form. +func run(t *testing.T, r *Reader, name string, args map[string]any) any { + t.Helper() + for _, tool := range r.Tools() { + if tool.Name != name { + continue + } + got, err := tool.Run(args) + if err != nil { + t.Fatalf("%s %v answered %v", name, args, err) + } + raw, _ := json.Marshal(got) + var v any + _ = json.Unmarshal(raw, &v) + return v + } + t.Fatalf("the tools bundle registers no %s", name) + return nil +} + +// design 39 parts §3, §4: the tools bundle registers exactly the four reading tools; its code emits nothing, +// subscribes to no event, asks no heartbeat, calls neither the vendor nor the vault, and asks nothing but mesh-config. +func TestTheToolsBundleRegistersTheFourReadingToolsAndActsOnNothing(t *testing.T) { + var names []string + for _, tool := range newRuntime(t).reader().Tools() { + names = append(names, tool.Name) + } + sort.Strings(names) + if want := []string{"licences_conditions", "licences_kept", "licences_settings", "licences_switches"}; !slices.Equal(names, want) { + t.Fatalf("the tools bundle registers %v, not %v", names, want) + } + files, _ := filepath.Glob("*.go") + acts := regexp.MustCompile(`stdio\.(Emit|Subscribe|SeatPublish|Publish|Heartbeat|Watch)\b|\.(Put|PutTTL|Create|CreateTTL|Delete|Append)\(|os\.(WriteFile|Create|OpenFile)|http\.`) + asked := regexp.MustCompile(`stdio\.Ask\("([^"]+)"`) + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + src, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for _, m := range acts.FindAllString(string(src), -1) { + t.Errorf("%s calls %s: the tools bundle acts on nothing", f, m) + } + for _, m := range asked.FindAllStringSubmatch(string(src), -1) { + if m[1] != "seat:mesh-config.config" { + t.Errorf("%s asks %s: the tools bundle asks only mesh-config", f, m[1]) + } + } + } +} + +// design 39 bus-and-switch §37/AC1, design 39 parts §3: a switch by usage and one by a person are answered, newest +// first, with their causes, a switch by usage with the reading that moved them; licence and consumer narrow the +// answer; nothing is written. +func TestSwitchesAnswersBothCauses(t *testing.T) { + rt := newRuntime(t) + rt.seedLog(licences.EntryAudit, "switched", map[string]any{"at": "2026-11-12T10:00:00Z", "from": "a", "to": "b", + "consumers": []string{"ace", "g14"}, "by": "usage", "why": "a at 99 %", "window": "weekly", "pct": 99, + "resetsAt": "2026-11-14T09:00:00Z", "room": 60}) + rt.seedLog(licences.EntryAudit, "bound", map[string]any{"at": "2026-11-12T10:00:30Z", "consumer": "ace"}) + rt.seedLog(licences.EntryAudit, "switched", map[string]any{"at": "2026-11-12T10:01:00Z", "from": "b", "to": "c", + "consumers": []string{"ace"}, "by": "a person", "why": "the switch verb"}) + r := rt.reader() + got := run(t, r, "licences_switches", nil).([]any) + if len(got) != 2 { + t.Fatalf("switches %v", got) + } + person, usage := got[0].(map[string]any), got[1].(map[string]any) + if person["by"] != "a person" || person["why"] != "the switch verb" || person["to"] != "c" || person["reading"] != nil || + usage["by"] != "usage" || usage["reading"].(map[string]any)["window"] != "weekly" || usage["from"] != "a" { + t.Fatalf("switches %v", got) + } + if got := run(t, r, "licences_switches", map[string]any{"licence": "c"}).([]any); len(got) != 1 { + t.Fatalf("of c %v", got) + } + if got := run(t, r, "licences_switches", map[string]any{"consumer": "g14"}).([]any); len(got) != 1 || + got[0].(map[string]any)["by"] != "usage" { + t.Fatalf("of g14 %v", got) + } + if got := run(t, r, "licences_switches", map[string]any{"since": "2026-11-12T10:00:30Z"}).([]any); len(got) != 1 { + t.Fatalf("since %v", got) + } +} + +// design 39 bus-and-switch §40/AC1, B18, design 39 parts §3: a value set at node level is answered with its level, the +// rest with the default, each with its value and default, and an invalid one saying why, read from the settings file; +// mesh-config not answering is an error, never the default. +func TestSettingsAnswersTheLevel(t *testing.T) { + r := newRuntime(t).reader() + r.SettingsFile = filepath.Join(t.TempDir(), "settings.json") + if err := os.WriteFile(r.SettingsFile, []byte(`{"switch_at_pct": 80, "warn_at_pct": 85, "usage_minutes": 900}`), 0o600); err != nil { + t.Fatal(err) + } + r.ConfigLevels = func(context.Context) (map[string]string, error) { + return map[string]string{"switch_at_pct": "node", "warn_at_pct": "mesh", "cadence_minutes": "default"}, nil + } + by := map[string]map[string]any{} + for _, s := range run(t, r, "licences_settings", nil).([]any) { + f := s.(map[string]any) + by[f["setting"].(string)] = f + } + str := func(v any) string { s, _ := v.(string); return s } + if len(by) != len(licences.Defaults.Values()) || by["switch_at_pct"]["level"] != "node" || + by["switch_at_pct"]["value"] != 80.0 || by["switch_at_pct"]["default"] != 99.0 || + by["usage_minutes"]["level"] != "default" || by["usage_minutes"]["value"] != 5.0 || + !strings.Contains(str(by["usage_minutes"]["invalid"]), "outside its bound") || + !strings.Contains(str(by["room_below_pct"]["invalid"]), "above switch_at_pct 80") || + !strings.Contains(str(by["warn_at_pct"]["invalid"]), "85") || by["switch_auto"]["invalid"] != nil { + t.Fatalf("settings %v", by) + } + r.ConfigLevels = func(context.Context) (map[string]string, error) { return nil, errors.New("no responders") } + for _, tool := range r.Tools() { + if tool.Name == "licences_settings" { + if _, err := tool.Run(nil); err == nil { + t.Fatal("mesh-config not answering is answered as the default") + } + } + } +} + +// design 39 bus-and-switch §41/AC1, design 39 parts §3, §10: every bus-state entry the manifest asks for is read by +// licences_kept, with its keys or subjects, one key's value and its fill, the carry-over's record among them; an +// entry not kept is refused; nothing is written. +func TestKeptReadsEveryEntryOfTheManifest(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Requires []struct { + Name string `json:"name"` + Template string `json:"template"` + } `json:"requires"` + Logs []struct { + Name string `json:"name"` + } `json:"logs"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + rt := newRuntime(t) + rt.seedState(licences.EntryLicences, "a_at_example.org", licences.Licence{Name: "a@example.org", Kind: "subscription"}) + rt.seedState(licences.EntryCarried, "postgres", map[string]any{"at": "2026-11-12T09:44:59Z"}) + rt.seedLog(licences.EntryUsage, "a_at_example.org", licences.UsageEntry{At: "2026-11-12T10:00:00Z"}) + r := rt.reader() + all := run(t, r, "licences_kept", nil).(map[string]any) + kinds := map[string]string{} + for _, e := range m.Requires { + kinds[e.Name] = e.Template + } + for _, l := range m.Logs { + kinds[l.Name] = "log" + } + for name, kind := range kinds { + got, ok := all[name].(map[string]any) + if !ok || got["fill"] == nil || got["kind"] != kind { + t.Errorf("licences_kept does not read %s as a %s: %v", name, kind, all[name]) + } + } + if len(kinds) != 8 || len(all) != len(kinds) { + t.Fatalf("the manifest asks for %d entries, licences_kept reads %d", len(kinds), len(all)) + } + one := run(t, r, "licences_kept", map[string]any{"entry": "carried", "key": "postgres"}).(map[string]any) + if v, _ := one["carried"].(map[string]any)["value"].(map[string]any); v["at"] != "2026-11-12T09:44:59Z" { + t.Fatalf("the carry-over's record %v", one) + } + if subjects := all["usage"].(map[string]any)["subjects"].(map[string]any); subjects["a_at_example.org"] == nil { + t.Fatalf("usage subjects %v", subjects) + } + if _, err := r.Kept("tables", ""); err == nil { + t.Fatal("an entry not kept is answered") + } +} + +// design 39 bus-and-switch §43/AC1, design 39 parts §3: each key of conditions is answered with its body and since. +func TestConditionsAnswersEachKey(t *testing.T) { + rt := newRuntime(t) + for _, k := range []string{"settings.room_below_pct.invalid", "licence.a.near-limit"} { + rt.seedState(licences.EntryConditions, k, licences.KeptCondition{Body: oc.Condition{Key: k, Headline: "h"}, + Since: "2026-11-12T10:00:00Z"}) + } + var keys []string + for _, c := range run(t, rt.reader(), "licences_conditions", nil).([]any) { + f := c.(map[string]any) + if f["since"] == "" || f["body"].(map[string]any)["headline"] == "" { + t.Fatalf("condition %v", f) + } + keys = append(keys, f["body"].(map[string]any)["key"].(string)) + } + if strings.Join(keys, ",") != "licence.a.near-limit,settings.room_below_pct.invalid" { + t.Fatalf("keys %v", keys) + } +} diff --git a/modules/claude-licence-manager/internal/licences/kept.go b/modules/claude-licence-manager/internal/licences/kept.go new file mode 100644 index 000000000..ab807e852 --- /dev/null +++ b/modules/claude-licence-manager/internal/licences/kept.go @@ -0,0 +1,179 @@ +package licences + +// The layouts of the module's kept data and of its log entries (design 39 bus-and-switch §2 to §9; design 39 parts +// §5): what the manager bundle writes and the tools bundle reads, declared once here, so the reading tools never read +// a form the manager no longer writes. + +import ( + "encoding/json" + "sort" + "time" + + 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" + +// The names of the entries the module keeps on the bus, as its manifest asks for them (§2 to §9). +const ( + EntryLicences = "licences" + EntryBindings = "bindings" + EntryLeases = "leases" + EntryOffered = "offered" + EntryUsage = "usage" + EntryAudit = "audit" + EntryCarried = "carried" + EntryConditions = "conditions" +) + +// States are the module's kept states, and Logs its two logs, by their names in the manifest. +var ( + States = []string{EntryLicences, EntryBindings, EntryLeases, EntryOffered, EntryCarried, EntryConditions} + Logs = []string{EntryUsage, EntryAudit} +) + +// State is a state the module keeps, as mesh-sdk's KeptState answers it. +type State interface { + Get(key string) (*stdio.StateEntry, error) + Put(key string, value any) (uint64, error) + PutTTL(key string, value any, ttl time.Duration) (uint64, error) + Create(key string, value any) (uint64, error) + CreateTTL(key string, value any, ttl time.Duration) (uint64, error) + Delete(key string) error + Keys() ([]string, error) + History(key string) ([]stdio.StateRevision, error) +} + +// Log is a log the module keeps, as mesh-sdk's KeptLog answers it. +type Log interface { + Append(key string, value any) (uint64, error) + Read(from uint64, keys string, limit int) (*stdio.LogPage, error) + Last(key string) (*stdio.LogEntry, error) + Info() (*stdio.LogInfo, error) + Newest(keys string) ([]stdio.LogEntry, error) +} + +// Licence is one licence: an account, or an API key. Its form is the value of the state `licences` (design 39 +// bus-and-switch §2): never a secret, which the vault holds (§15). +type Licence struct { + Name string `json:"name"` + Kind string `json:"kind"` // subscription | api-key + AccountUUID string `json:"account,omitempty"` + Email string `json:"address,omitempty"` + OrganizationUUID string `json:"organisation,omitempty"` + RefreshFingerprint string `json:"refreshFingerprint,omitempty"` + AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"` + RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"` + Failures int `json:"failures"` + NotifiedAt int64 `json:"notifiedAt,omitempty"` + AdoptedAt int64 `json:"adoptedAt"` + AdoptedFrom string `json:"adoptedFrom,omitempty"` + RotatedAt int64 `json:"rotatedAt,omitempty"` + // Generation is the generation of the licence's secrets in the vault (§2, §15). + Generation int64 `json:"generation,omitempty"` + // Usage is the latest reading of the licence's usage (§2, §24). + Usage *UsageReading `json:"usage,omitempty"` +} + +// 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"` +} + +// ReadErrorWords is the vendor's reason a reading failed, or that none was given. +func (u *UsageReading) ReadErrorWords() string { + if u == nil || u.ReadError == "" { + return "no reason given" + } + return u.ReadError +} + +// BindingState is the value of a consumer's key in `bindings` (§3, B22: the form 0 its readers decode): no secret, +// only what it should hold, which generation, since when, why and by whom. +type BindingState struct { + Licence string `json:"licence"` + Kind string `json:"kind"` + Generation int64 `json:"generation"` + Since string `json:"since,omitempty"` + Why string `json:"why,omitempty"` + By string `json:"by,omitempty"` +} + +// LeaseValue is a lease's key in `leases`: its holder and until when. +type LeaseValue struct { + Holder string `json:"holder"` + Until string `json:"until"` +} + +// OfferedValue is a grant's key in `offered`: what became of it, where and when (§5, B13: every outcome). +type OfferedValue struct { + Node string `json:"node"` + Account string `json:"account"` + Outcome Outcome `json:"outcome"` + Why string `json:"why"` + At string `json:"at"` +} + +// Outcome is what became of a login the manager was offered, by its refresh token's fingerprint. +type Outcome string + +const ( + Adopted Outcome = "adopted" + Dead Outcome = "dead" + Skipped Outcome = "skipped" + Refused Outcome = "refused" + Gone Outcome = "gone" +) + +// UsageEntry is one entry of the log `usage` (§6): when, the flattened reading and the vendor's answer whole (ADR 0054). +type UsageEntry struct { + At string `json:"at"` + Reading UsageReading `json:"reading"` + Raw map[string]any `json:"raw,omitempty"` +} + +// AuditKinds are the subjects of the log `audit` (§7); an act of another kind is kept under `drift`. +var AuditKinds = []string{"adopted", "refused", "failed", "rotated", "bound", "switched", "released", "drift", "carried", + "settings"} + +// 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"` +} + +// KeptConditions is each key of the state conditions with its body and since (bus-and-switch §9, §43), by key; a +// nil state holds none. +func KeptConditions(conditions State) ([]KeptCondition, error) { + out := []KeptCondition{} + if conditions == nil { + return out, nil + } + keys, err := conditions.Keys() + if err != nil { + return nil, err + } + sort.Strings(keys) + for _, k := range keys { + e, err := conditions.Get(k) + if err != nil { + return nil, err + } + var c KeptCondition + if e == nil || json.Unmarshal(e.Value, &c) != nil { + continue + } + out = append(out, c) + } + return out, nil +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/keyform.go b/modules/claude-licence-manager/internal/licences/keyform.go similarity index 53% rename from modules/claude-licence-manager/cmd/claude-licence-manager/keyform.go rename to modules/claude-licence-manager/internal/licences/keyform.go index 46732bd3b..b86dc328d 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/keyform.go +++ b/modules/claude-licence-manager/internal/licences/keyform.go @@ -1,12 +1,9 @@ -package main +package licences // A licence's key form (design 39 bus-and-switch §10, B6): the name every verb and tool takes and answers, written -// in the characters a key of the bus takes. The key is the store's alone. +// in the characters a key of the bus takes. -import ( - "context" - "strings" -) +import "strings" // KeyFor is a licence's key: its name in lower case, each `@` written as `_at_`, and every other character // outside letters, digits, `-` and `.` written as `_`. @@ -24,18 +21,3 @@ func KeyFor(name string) string { } return b.String() } - -// keyTaken names the licence other than name whose key is name's, or "" when none is. -func (m *Manager) keyTaken(ctx context.Context, name string) (string, error) { - all, err := m.Store.Licences(ctx) - if err != nil { - return "", err - } - key := KeyFor(name) - for _, l := range all { - if l.Name != name && KeyFor(l.Name) == key { - return l.Name, nil - } - } - return "", nil -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/settings_test.go b/modules/claude-licence-manager/internal/licences/licences_test.go similarity index 67% rename from modules/claude-licence-manager/cmd/claude-licence-manager/settings_test.go rename to modules/claude-licence-manager/internal/licences/licences_test.go index d642e76eb..326a93a02 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/settings_test.go +++ b/modules/claude-licence-manager/internal/licences/licences_test.go @@ -1,13 +1,28 @@ -package main +package licences import ( - "context" "encoding/json" "os" "path/filepath" "testing" ) +// design 39 bus-and-switch §10, B6. +func TestALicencesKeyForm(t *testing.T) { + for name, key := range map[string]string{ + "jane@example.org": "jane_at_example.org", + "Jane.Doe@Example.ORG": "jane.doe_at_example.org", + "0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5": "0c1d2e3f-4a5b-6c7d-8e9f-a0b1c2d3e4f5", + "team key_1": "team_key_1", + "a+b/c:d": "a_b_c_d", + "ops-key": "ops-key", + } { + if got := KeyFor(name); got != key { + t.Errorf("KeyFor(%q) = %q, want %q", name, got, key) + } + } +} + // 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) { @@ -72,28 +87,3 @@ func TestAnInvalidPairIsFound(t *testing.T) { 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]) - } -} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go b/modules/claude-licence-manager/internal/licences/settings.go similarity index 79% rename from modules/claude-licence-manager/cmd/claude-licence-manager/settings.go rename to modules/claude-licence-manager/internal/licences/settings.go index dc2afdb8e..007a14de1 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/settings.go +++ b/modules/claude-licence-manager/internal/licences/settings.go @@ -1,16 +1,13 @@ -package main +package licences -// 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`. +// The module's settings (design 39 §8, design 39 bus-and-switch §33, B37; design 39 parts §5): 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; with their defaults, bounds and validity, read by both bundles. import ( - "context" "encoding/json" "fmt" "os" - "reflect" "time" ) @@ -142,33 +139,18 @@ func (s Settings) Invalid() []Invalid { 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 +// Reasons are why each invalid setting is invalid, by setting, in the words its condition's summary says (§33, B37): a +// setting outside its bound first, then a setting of §33's pairs above switch_at_pct. +func (s Settings) Reasons() map[string]string { + out := map[string]string{} + for _, o := range s.OutOfBound { + out[o.Setting] = fmt.Sprintf("%s is %g, outside its bound; its default %g is used", o.Setting, o.Value, o.Default) } - 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} + for _, p := range s.Invalid() { + if _, ok := out[p.Setting]; !ok { + out[p.Setting] = fmt.Sprintf("%s is %g, above switch_at_pct %g; no consumer is moved", p.Setting, p.Value, + p.SwitchAt) } } - if len(changed) == 0 { - return nil - } - return m.record(ctx, "settings", fmt.Sprintf("%d setting(s) changed: %v", len(changed), changed), changed) + return out } diff --git a/modules/claude-licence-manager/module.json b/modules/claude-licence-manager/module.json index 162379dc2..2fec797ef 100644 --- a/modules/claude-licence-manager/module.json +++ b/modules/claude-licence-manager/module.json @@ -262,11 +262,11 @@ "build": { "artifacts": [ { - "name": "code", + "name": "manager", "kind": "bundle", "language": "go", "system": "arch", - "from": "cmd/claude-licence-manager", + "from": "cmd/manager", "binary": "claude-licence-manager", "loads": [ "claude-licence-manager" @@ -276,6 +276,17 @@ "MESH_LICENCE_SETTINGS": "${dir:state}/settings.json" } }, + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/tools", + "binary": "claude-licence-manager-tools", + "loads": [ + "claude-licence-manager-tools" + ] + }, { "name": "files", "kind": "archive",