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",