M-2: the manager's manifest asks for its kept data by template
Eight bus-state entries in the typed long form; the old state and the reads of claude-code.holdings go, and so does the run-once prepare, since nothing writes the tables again. The database and grant-key stay as the carry-over's read-only source. The manifest declares its new events, its consumption of holdings-changed, the operator channel it uses, the verbs it invokes, six settings and its eight tools. Builds: design 39 bus-and-switch §1, §9, §21, §23, §27, §33, §37-§44, A3, B9, B16 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ
This commit is contained in:
@@ -3,9 +3,6 @@
|
||||
// `anthropic-licence-manager` seat's verbs and, beside them, runs long: 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.
|
||||
//
|
||||
// `claude-licence-manager prepare` is the preparation step (ADR 0135): the host runs it once before the
|
||||
// version that needs it, with the module's words and no bus, and it brings the store's schema to shape.
|
||||
//
|
||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
@@ -32,14 +29,6 @@ func say(format string, args ...any) {
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == "prepare" {
|
||||
if err := prepare(); err != nil {
|
||||
say("preparing the store failed: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
say("the store's schema is what this version needs")
|
||||
return
|
||||
}
|
||||
go daemon()
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
say("%v", err)
|
||||
@@ -47,17 +36,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func prepare() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
store, err := PgStoreFromEnv(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
return store.Migrate(ctx)
|
||||
}
|
||||
|
||||
// ---- what the binary is handed -----------------------------------------------------------------------
|
||||
|
||||
var (
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// design 39 bus-and-switch §1: the module moves to the new form whole — no `reads`, no old `state`, no `prepare`
|
||||
// step — and asks for each piece of its kept data by template.
|
||||
func TestTheManifestKeepsNoOldForm(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, old := range []string{"reads", "state"} {
|
||||
if _, ok := m[old]; ok {
|
||||
t.Errorf("the manifest keeps the old section %q", old)
|
||||
}
|
||||
}
|
||||
var resources []struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.Unmarshal(m["resources"], &resources)
|
||||
for _, r := range resources {
|
||||
if r.ID == "prepare" {
|
||||
t.Error("the manifest keeps the run-once step prepare")
|
||||
}
|
||||
}
|
||||
var requires []json.RawMessage
|
||||
_ = json.Unmarshal(m["requires"], &requires)
|
||||
kept := map[string]string{}
|
||||
for _, r := range requires {
|
||||
var e struct{ Name, Template string }
|
||||
if json.Unmarshal(r, &e) == nil {
|
||||
kept[e.Name] = e.Template
|
||||
}
|
||||
}
|
||||
for name, template := range map[string]string{"licences": "state", "bindings": "state", "leases": "state",
|
||||
"offered": "state", "usage": "log", "audit": "log", "carried": "state", "conditions": "state"} {
|
||||
if kept[name] != template {
|
||||
t.Errorf("bus-state entry %s is %q, want template %s", name, kept[name], template)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,11 @@
|
||||
"floor_minutes": 60,
|
||||
"failures_to_notify": 3,
|
||||
"cooldown_hours": 24,
|
||||
"refresh_warn_days": 3
|
||||
"refresh_warn_days": 3,
|
||||
"switch_auto": true,
|
||||
"switch_at_pct": 99,
|
||||
"room_below_pct": 90,
|
||||
"warn_at_pct": 90,
|
||||
"usage_minutes": 5,
|
||||
"usage_stale_minutes": 15
|
||||
}
|
||||
|
||||
@@ -4,7 +4,74 @@
|
||||
"slug": "licmgr",
|
||||
"requires": [
|
||||
"postgres-database",
|
||||
"secret"
|
||||
"secret",
|
||||
{
|
||||
"name": "licences",
|
||||
"storage": "key-value",
|
||||
"template": "state",
|
||||
"options": {
|
||||
"history": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bindings",
|
||||
"storage": "key-value",
|
||||
"template": "state",
|
||||
"options": {
|
||||
"history": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "leases",
|
||||
"storage": "key-value",
|
||||
"template": "state",
|
||||
"options": {
|
||||
"history": 1,
|
||||
"key-ttl": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "offered",
|
||||
"storage": "key-value",
|
||||
"template": "state",
|
||||
"options": {
|
||||
"history": 1,
|
||||
"key-ttl": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "usage",
|
||||
"storage": "stream",
|
||||
"template": "log",
|
||||
"options": {
|
||||
"max-age-days": 35,
|
||||
"max-mib": 256
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "audit",
|
||||
"storage": "stream",
|
||||
"template": "log",
|
||||
"options": {
|
||||
"max-mib": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "carried",
|
||||
"storage": "key-value",
|
||||
"template": "state",
|
||||
"options": {
|
||||
"history": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "conditions",
|
||||
"storage": "key-value",
|
||||
"template": "state",
|
||||
"options": {
|
||||
"history": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"contributes": {
|
||||
"postgres-database": {
|
||||
@@ -24,6 +91,14 @@
|
||||
"grant-key": "${dir:state}/grant.key"
|
||||
}
|
||||
},
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:operator-channel.tell",
|
||||
"seat:mesh-vault.licence-put",
|
||||
"seat:mesh-vault.get"
|
||||
],
|
||||
"seats": [
|
||||
{
|
||||
"name": "anthropic-licence-manager",
|
||||
@@ -63,18 +138,14 @@
|
||||
"emits": [
|
||||
"licence.adopted",
|
||||
"licence.refused",
|
||||
"licence.bound",
|
||||
"licence.switched",
|
||||
"licence.released",
|
||||
"licence.rotated",
|
||||
"licence.failing",
|
||||
"usage.read"
|
||||
"usage.read",
|
||||
"binding.changed",
|
||||
"holdings.asked"
|
||||
],
|
||||
"state": [
|
||||
"bindings"
|
||||
],
|
||||
"reads": [
|
||||
"claude-code.holdings"
|
||||
"consumes": [
|
||||
"claude-code.holdings-changed"
|
||||
],
|
||||
"config": {
|
||||
"cadence_minutes": {
|
||||
@@ -111,6 +182,52 @@
|
||||
"minimum": 0,
|
||||
"trusted": true,
|
||||
"default": 3
|
||||
},
|
||||
"switch_auto": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the manager moves every consumer off a licence whose session or weekly usage reaches switch_at_pct, to the licence with the most room; off, it still raises its no-room and near-limit conditions.",
|
||||
"trusted": true,
|
||||
"default": true
|
||||
},
|
||||
"switch_at_pct": {
|
||||
"type": "number",
|
||||
"description": "The session or weekly usage of a licence, in percent, at which its consumers are moved to the licence with the most room.",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"trusted": true,
|
||||
"default": 99
|
||||
},
|
||||
"room_below_pct": {
|
||||
"type": "number",
|
||||
"description": "What both the session and the weekly usage of a licence must be below, in percent, for it to be a target of a switch.",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"trusted": true,
|
||||
"default": 90
|
||||
},
|
||||
"warn_at_pct": {
|
||||
"type": "number",
|
||||
"description": "The session or weekly usage of a licence with consumers, in percent, at which it is warned about once.",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"trusted": true,
|
||||
"default": 90
|
||||
},
|
||||
"usage_minutes": {
|
||||
"type": "number",
|
||||
"description": "How often each subscription's usage is read, in minutes.",
|
||||
"minimum": 1,
|
||||
"maximum": 60,
|
||||
"trusted": true,
|
||||
"default": 5
|
||||
},
|
||||
"usage_stale_minutes": {
|
||||
"type": "number",
|
||||
"description": "How old a subscription's last successful usage reading may grow before its room is not known and it is said, in minutes.",
|
||||
"minimum": 5,
|
||||
"maximum": 180,
|
||||
"trusted": true,
|
||||
"default": 15
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
@@ -133,28 +250,33 @@
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"takes-config": ["cadence_minutes", "floor_minutes", "failures_to_notify", "cooldown_hours", "refresh_warn_days"],
|
||||
"takes-config": [
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"id": "prepare",
|
||||
"type": "process",
|
||||
"name": "claude-licence-manager-prepare",
|
||||
"artifact": "code",
|
||||
"run": [
|
||||
"./claude-licence-manager",
|
||||
"prepare"
|
||||
],
|
||||
"run-once": true,
|
||||
"env": {
|
||||
"DATABASE_URL_FILE": "${dir:state}/database.url"
|
||||
},
|
||||
"restart-on": [
|
||||
"database-url"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"licences_switches",
|
||||
"licences_read_usage",
|
||||
"licences_switch_now",
|
||||
"licences_settings",
|
||||
"licences_kept",
|
||||
"licences_resync",
|
||||
"licences_conditions",
|
||||
"licences_carried"
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user