Files
crossbar/docs/plans/v1/06-admin-main.md
T
kyleandClaude Fable 5.1 c457046f8b v1 plan: leases, limiter, chooser, fingerprint, SQLite store, accounting, admin — acceptance tests first, no reference
Every given test compiled against a panic-only interface skeleton (go vet clean); nothing was
implemented. modernc.org/sqlite v1.59.0 vetted in a scratch module (WAL works); go.sum given.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 03:10:24 -07:00

122 lines
6.4 KiB
Markdown

# 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/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}` → `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)`.
- `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 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.