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

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]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:
    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.
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.
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.