v0 plan: AGENTS.md, gate, five task files with given tests, run-plan driver
Tests were run against a private reference implementation: gate ok after every task in order, smoke ok (stream spread ~1000 ms). The reference is not in the repository. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
# v0 task 01: module, gate and the config package
|
||||
|
||||
**Branch:** `v0` (create it: `git switch -c v0`; `git status --short` must be empty first, otherwise stop)
|
||||
**Commit subject:** `Add the Go module, the gate and the config package`
|
||||
|
||||
## Goal
|
||||
|
||||
Create the Go module and the gate every later task must pass, and write `internal/config`: the
|
||||
TOML file crossbar starts from, with defaults and validation. At the end `make gate` prints
|
||||
`gate: ok`.
|
||||
|
||||
## Context
|
||||
|
||||
crossbar is an HTTP reverse proxy in front of several `llama-server` routers ("hosts"). A client's
|
||||
identity is the first segment of its request path (its "route"); each route has an ordered list
|
||||
of hosts to try. The config file names the hosts, which models each serves, and the routes. It
|
||||
must be strict: a misspelt key or a route naming a host that does not exist is an error at start,
|
||||
not a surprise at 3 a.m. The one dependency is `github.com/BurntSushi/toml` v1.6.0; `go.sum` for it
|
||||
is given.
|
||||
|
||||
## Files
|
||||
|
||||
- Copy (never edit afterwards): `Makefile`, `scripts/check-lines.sh`, `go.sum`,
|
||||
`internal/config/config_test.go`, `internal/config/testdata/` (5 files)
|
||||
- Create: `go.mod`, `internal/config/config.go`
|
||||
- Modify: `docs/implementer-log.md`
|
||||
|
||||
## Interfaces
|
||||
|
||||
Produces, in `internal/config/config.go`, package `config`:
|
||||
|
||||
```go
|
||||
// Duration is a time.Duration that TOML reads from a string such as "60s" or "30m".
|
||||
type Duration struct{ time.Duration }
|
||||
func (d *Duration) UnmarshalText(text []byte) error // time.ParseDuration
|
||||
|
||||
type Model struct { Parallel int `toml:"parallel"` }
|
||||
type Host struct {
|
||||
BaseURL string `toml:"base_url"`
|
||||
Weight float64 `toml:"weight"`
|
||||
Models map[string]Model `toml:"models"`
|
||||
}
|
||||
type Route struct {
|
||||
Hosts []string `toml:"hosts"`
|
||||
DefaultModel string `toml:"default_model"`
|
||||
}
|
||||
type Config struct {
|
||||
Listen string `toml:"listen"`
|
||||
PollInterval Duration `toml:"poll_interval"`
|
||||
QueueMax int `toml:"queue_max"`
|
||||
Hosts map[string]Host `toml:"hosts"`
|
||||
Routes map[string]Route `toml:"routes"`
|
||||
}
|
||||
|
||||
// Error is a validation error naming the field it is about.
|
||||
type Error struct { Field, Msg string }
|
||||
func (e *Error) Error() string // "config: " + Field + ": " + Msg
|
||||
|
||||
const (
|
||||
DefaultPollInterval = 60 * time.Second
|
||||
DefaultQueueMax = 8
|
||||
MinPollInterval = time.Second
|
||||
)
|
||||
|
||||
func Load(path string) (*Config, error) // open, then Parse
|
||||
func Parse(r io.Reader) (*Config, error) // decode, defaults, validate
|
||||
func (c *Config) Serves(host, model string) bool // host exists and lists model
|
||||
func IsError(err error) (*Error, bool) // errors.As on *Error
|
||||
```
|
||||
|
||||
Rules the tests check:
|
||||
|
||||
1. **Decoding.** `toml.NewDecoder(r).Decode(&c)`. A TOML syntax error is returned as
|
||||
`fmt.Errorf("config: %w", err)` — it is *not* an `*Error`. If `md.Undecoded()` is non-empty,
|
||||
the error is `&Error{Field: <first undecoded key, keys sorted>, Msg: "unknown key"}`. `Load`
|
||||
wraps an open failure the same way (`config: …`).
|
||||
2. **Validation, in this order, first problem wins.** Every problem is an `*Error` with exactly
|
||||
this `Field`:
|
||||
- `listen`: required; must be `host:port` (`net.SplitHostPort`); the host part must not be
|
||||
empty and must not be an unspecified address (`0.0.0.0`, `::`).
|
||||
- `poll_interval`: `0` (absent) becomes `DefaultPollInterval`; less than `MinPollInterval` is
|
||||
an error.
|
||||
- `queue_max`: `0` becomes `DefaultQueueMax`; negative is an error.
|
||||
- `hosts`: at least one. Then for each host, **in sorted name order**:
|
||||
- `hosts.<name>.base_url`: `url.Parse` must succeed, scheme `http` or `https`, non-empty
|
||||
host, no query, no fragment. A trailing `/` is trimmed (`strings.TrimRight(url, "/")`)
|
||||
and the trimmed value stored.
|
||||
- `hosts.<name>.weight`: `0` becomes `1`; negative is an error.
|
||||
- `hosts.<name>.models`: at least one. For each model in sorted order,
|
||||
`hosts.<name>.models.<model>.parallel`: `0` becomes `1`; negative is an error.
|
||||
- `routes`: at least one. For each route in sorted name order:
|
||||
- `routes.<name>`: the name must match `^[a-z0-9][a-z0-9-]*$`.
|
||||
- `routes.<name>.hosts`: at least one; every entry must be a configured host; no host twice.
|
||||
- `routes.<name>.default_model`: if set, at least one of the route's hosts must list it.
|
||||
3. Defaults are written back into the returned `Config` (the tests read `Weight == 1`,
|
||||
`Parallel == 1`, `PollInterval == DefaultPollInterval` after parsing files that omit them).
|
||||
4. Nothing here panics on a bad file: every map lookup and slice access is on data you checked.
|
||||
|
||||
## Steps
|
||||
|
||||
- [ ] **1. Branch and copy.**
|
||||
|
||||
```sh
|
||||
git switch -c v0
|
||||
mkdir -p scripts internal/config/testdata
|
||||
cp docs/plans/v0/files/Makefile docs/plans/v0/files/go.sum .
|
||||
cp docs/plans/v0/files/scripts/check-lines.sh scripts/
|
||||
cp docs/plans/v0/files/internal/config/config_test.go internal/config/
|
||||
cp docs/plans/v0/files/internal/config/testdata/*.toml internal/config/testdata/
|
||||
```
|
||||
|
||||
Read `Makefile` and `internal/config/config_test.go`. The test names every rule above.
|
||||
|
||||
- [ ] **2. Write `go.mod`** with exactly this content:
|
||||
|
||||
```
|
||||
module git.wntrmute.dev/kyle/crossbar
|
||||
|
||||
go 1.26
|
||||
|
||||
require github.com/BurntSushi/toml v1.6.0
|
||||
```
|
||||
|
||||
Then `go mod download github.com/BurntSushi/toml` (this needs the network once) and
|
||||
`go mod verify`. Expected: `all modules verified`.
|
||||
|
||||
- [ ] **3. See the test fail.** `go test ./internal/config/`. Expected: it does not compile
|
||||
(`no Go files` or undefined names).
|
||||
- [ ] **4. Write `internal/config/config.go`.** Run `gofmt -w internal/config/`.
|
||||
- [ ] **5. See the test pass.** `go test ./internal/config/`. Expected: `ok`.
|
||||
- [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`.
|
||||
- [ ] **7. Log and commit.** Add your row to `docs/implementer-log.md` (Task `v0/01-module-gate-config`).
|
||||
|
||||
```sh
|
||||
git add go.mod go.sum Makefile scripts/check-lines.sh internal/config docs/implementer-log.md
|
||||
git commit
|
||||
```
|
||||
|
||||
## Done when
|
||||
|
||||
- `go test ./internal/config/` is `ok`; `make gate` prints `gate: ok`.
|
||||
- `cmp internal/config/config_test.go docs/plans/v0/files/internal/config/config_test.go` prints nothing.
|
||||
|
||||
## Stop and report if
|
||||
|
||||
- `go mod download` cannot fetch the module (no network): stop, log `stopped`.
|
||||
- A test expects a `Field` you cannot produce under the rules above: stop; do not edit the test.
|
||||
Reference in New Issue
Block a user