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>
6.4 KiB
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:
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]RouteViewfrom config +lt.Snapshot()+lt.Pinned.POST /_crossbar/routes/{route}body{"host":"…","pin":true}→lt.Pin(route, host, now);{"release":true}→lt.Release(route)andlt.Unpin(route). Unknown route → 404;lt.PinreturningErrUnknownHost→ 404; not JSON, neither form, or both forms at once, orpinwithouthost→ 400. Answer{"ok":true}(plus"released": nfor a release).GETon 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).bydefaults toroute;sinceis either RFC 3339 or a duration like24h/7d(meaningnow - d); absent = all time; anything else → 400. WithAccept: text/plain, a fixed-width table with a header line containingkey requests errors busy_ms queued_ms prompt cached completion cache_hitand one line per row (cache_hitas0.80).GET /_crossbar/metrics→text/plain; version=0.0.4, computed on request. Request counters need (route, host, status), whichUsage(one key) cannot give, so add one method tointernal/store— the only change to that package allowed in this task:Then emit, in this order: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 commentLabel values escaped (# 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"and\), lines sorted, no trailing spaces.
cmd/crossbar/main.go:
- After
config.Load:store.Open(cfg.DB)(error → exit 1crossbar: …);defer st.Close(). hosts := proxy.HostView(table, cfg);lim := limiter.New()configured for every host/model from config withcfg.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 hourst.Prune(time.Now(), cfg.Retention.Duration); after every poll round the health table's observations are recorded withst.RecordHostHealth— do this from a goroutine that everypoll_intervalreadstable.All()and writes one row per host. - Shutdown as v0, then
st.Close().
Steps
- 1. Copy (replace).
git switch v1; copyadmin_test.goandexample.tomlfromdocs/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.
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. Rowv1/06-admin-main.
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 gateprintsgate: ok; both copied files byte-identical.