# v1 task 06: admin v1 and the wiring **Branch:** `v1` (run `git switch v1`; `git status --short` must be empty, otherwise stop) **Commit subject:** `Admin: leases, pin, release, drain, usage, metrics; wire the store into crossbar` ## Goal Operators see and steer the system: the hosts view gains slots and drain state, the routes view shows leases and pins, `POST` endpoints pin/release a route and drain a host, `/usage` answers the accounting questions, `/metrics` exposes them to Prometheus. `cmd/crossbar` opens the store, builds the lease table and limiter, runs idle expiry and pruning, and shuts down cleanly. `PLAN.md` §7, §7a. ## Files - Copy (**replaces** v0's): `internal/admin/admin_test.go`, `example.toml` - Modify: `internal/admin/admin.go` (split if over 400 lines), `cmd/crossbar/main.go`, `docs/implementer-log.md` ## Interfaces `internal/lease` gains one method — the only change to that package allowed in this task: ```go // Candidates records hosts as seen for route (idempotent), so Pin can accept a host the route // is configured for before any request has used it. cmd/crossbar calls it for every route at // start; the admin handler calls it before Pin. func (t *Table) Candidates(route string, hosts []string) ``` `internal/admin`, package `admin`: ```go type Hosts interface { All() map[string]health.Status } type Drainer interface { Draining(name string) bool; SetDraining(name string, on bool) } // *proxy.Hosts satisfies it type HostView struct { Healthy bool `json:"healthy"` Loaded []string `json:"loaded"` // never null LastOK string `json:"last_ok"` // RFC 3339 UTC or "" LastErr string `json:"last_err"` FreeSlots int `json:"free_slots"` // lim.FreeSlots(host) InFlight int `json:"in_flight"` // sum over the host's configured models Queued int `json:"queued"` // same Draining bool `json:"draining"` } type LeaseView struct { FP, Model, Host, State, Created, LastUsed string } // json tags: fp, model, host, state, created, last_used (RFC 3339 UTC) type RouteView struct { Hosts []string `json:"hosts"` DefaultModel string `json:"default_model"` Pinned string `json:"pinned"` // "" when not pinned Leases []LeaseView `json:"leases"` // never null } func Handler(cfg *config.Config, h Hosts, lt *lease.Table, lim *limiter.Limiter, st *store.Store, d Drainer) http.Handler ``` Endpoints (all JSON unless said; errors `{"error":"…"}`; wrong method → 405 with `Allow`): - `GET /_crossbar/hosts` → `map[string]HostView`. - `GET /_crossbar/routes` → `map[string]RouteView` from config + `lt.Snapshot()` + `lt.Pinned`. - `POST /_crossbar/routes/{route}` body `{"host":"…","pin":true}`: first check `host` is one of `cfg.Routes[route].Hosts` (else **404**), then `lt.Candidates(route, cfg.Routes[route].Hosts)` so the table knows them even if no request has used the route yet, then `lt.Pin(route, host, now)`; `{"release":true}` → `lt.Release(route)` **and** `lt.Unpin(route)`. Unknown route → 404; `lt.Pin` returning `ErrUnknownHost` → 404; not JSON, neither form, or both forms at once, or `pin` without `host` → 400. Answer `{"ok":true}` (plus `"released": n` for a release). `GET` on this path → 405. - `POST /_crossbar/hosts/{host}` body `{"drain":true|false}` → `d.SetDraining`; unknown host (not in config) → 404; bad body → 400. `{"ok":true}`. - `GET /_crossbar/usage?since=…&by=route|model|host` → `[]store.UsageRow` (JSON array; sorted by key). `by` defaults to `route`; `since` is either RFC 3339 or a duration like `24h`/`7d` (meaning `now - d`); absent = all time; anything else → 400. With `Accept: text/plain`, a fixed-width table with a header line containing `key requests errors busy_ms queued_ms prompt cached completion cache_hit` and one line per row (`cache_hit` as `0.80`). - `GET /_crossbar/metrics` → `text/plain; version=0.0.4`, computed on request. Request counters need (route, host, status), which `Usage` (one key) cannot give, so add **one** method to `internal/store` — the only change to that package allowed in this task: ```go type StatusCount struct { Route, Host string; Status int; Count int64 } func (s *Store) StatusCounts(since time.Time) ([]StatusCount, error) // from `requests` only; rolled-up days are not in it, say so in a comment ``` Then emit, in this order: ``` # TYPE crossbar_requests_total counter crossbar_requests_total{route="…",host="…",status="…"} N (one line per StatusCount) # TYPE crossbar_prompt_tokens_total counter crossbar_prompt_tokens_total{route="…"} N (Usage(zero, ByRoute)) # TYPE crossbar_cached_tokens_total counter # TYPE crossbar_completion_tokens_total counter # TYPE crossbar_queue_wait_ms_total counter crossbar_queue_wait_ms_total{route="…"} N # TYPE crossbar_host_healthy gauge crossbar_host_healthy{host="…"} 0|1 # TYPE crossbar_host_free_slots gauge # TYPE crossbar_host_in_flight gauge # TYPE crossbar_host_queued gauge ``` Label values escaped (`"` and `\`), lines sorted, no trailing spaces. `cmd/crossbar/main.go`: - After `config.Load`: `store.Open(cfg.DB)` (error → exit 1 `crossbar: …`); `defer st.Close()`. - `hosts := proxy.HostView(table, cfg)`; `lim := limiter.New()` configured for every host/model from config with `cfg.QueueMax`; `leases, err := lease.New(st, hosts, proxy.Chooser(cfg, table, lim), cfg.LeaseIdle.Duration)`. - `for name, rt := range cfg.Routes { leases.Candidates(name, rt.Hosts) }`. - `proxy.New(cfg, table, leases, lim, st, log)`; `admin.Handler(cfg, table, leases, lim, st, hosts)`. - Background loops until ctx is done: every minute `leases.ExpireIdle(time.Now())`; every hour `st.Prune(time.Now(), cfg.Retention.Duration)`; after every poll round the health table's observations are recorded with `st.RecordHostHealth` — do this from a goroutine that every `poll_interval` reads `table.All()` and writes one row per host. - Shutdown as v0, then `st.Close()`. ## Steps - [ ] **1. Copy (replace).** `git switch v1`; copy `admin_test.go` and `example.toml` from `docs/plans/v1/_files/`. - [ ] **2. See it fail** (compile). **3. Write the code** (admin, the store's `StatusCounts`, main). `gofmt -w .` - [ ] **4. See it pass.** `go test -race -count=1 ./...`. - [ ] **5. Build and run for three seconds.** ```sh make build timeout --preserve-status --signal=TERM 3 bin/crossbar -config example.toml; echo "exit=$?" ls -la crossbar.db* && rm -f crossbar.db crossbar.db-wal crossbar.db-shm ``` Expected: `listening`, `shutting down`, `exit=0`; the SQLite file was created (then removed). - [ ] **6. Run the gate.** `make gate`. **7. Log and commit.** Row `v1/06-admin-main`. ```sh git add internal/admin internal/store internal/lease cmd/crossbar example.toml docs/implementer-log.md git commit ``` ## Done when - All tests pass with `-race`; the three-second run exits 0 and created the db; `make gate` prints `gate: ok`; both copied files byte-identical.