diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 86a9a19..72aa856 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -5,6 +5,7 @@ owner fills in the Model column. The reviewer adds findings under "Reviews" once | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| v1/02-fingerprint-config | 2026-09-25 | done | 1 | pass | Switched the existing `TestBadFiles` unknown-key example from `lease_idle` to `bogus_key`, and updated `testdata/bad-unknown-key.toml` to match: this task makes `lease_idle` a valid key, so the old example was stale. `config_test.go` and that testdata are not `_files`-protected, so the edit was permitted even though the task's file list named only `config.go` and `implementer-log.md`; the unknown-key rejection is still covered. | fingerprint.go truncates each input to its first 4096 bytes and uses a presence flag so an empty first system prompt is not overwritten by a later one; `Duration.UnmarshalText` matches `^[0-9]+d$` (regexp) before falling to `time.ParseDuration`. | ? | | v1/01-store | 2026-09-25 | done | 1 | pass | none | Gate passed on the first run once the owner gofmt'd the three previously-un-clean _files plan-tests under docs/plans/v1/_files/; the blocker in the stopped row no longer applies. | ? | | v1/01-store | 2026-09-25 | stopped | 2 | fail | none | Store implemented in `internal/store/store.go` + `schema.go`; `go test -race -count=1 ./internal/store/` is ok and `go vet`/`check-lines` pass. `make gate` cannot print `gate: ok` here: its `gofmt -l .` step flags three committed plan-tests under `docs/plans/v1/_files/` (admin, choose, proxy) that are not gofmt-clean under Go 1.26.7 (formatted by a gofmt that aligns one-line function bodies two columns wider; same diff on a pristine master). They live under `docs/plans/` (must not edit) and the gate covers them; the check cannot be scoped down without weakening it. Code left uncommitted for review. | llama.cpp/ornith-1.5-35b-a3b | | v0/01-module-gate-config | 2026-09-25 | done | 1 | pass | none | `go mod download` fetched the module (network available); gate passed on the first run. | llama.cpp/ornith-1.5-35b-a3b | diff --git a/internal/config/config.go b/internal/config/config.go index 1795333..984624e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,18 +15,25 @@ import ( "os" "regexp" "sort" + "strconv" "strings" "time" "github.com/BurntSushi/toml" ) -// Duration is a time.Duration that TOML reads from a string such as "60s" or -// "30m". +// Duration is a time.Duration that TOML reads from a string such as "60s", +// "30m", or "7d" (an integer number of days). type Duration struct{ time.Duration } -// UnmarshalText implements encoding.TextUnmarshaler via time.ParseDuration. +// UnmarshalText implements encoding.TextUnmarshaler. It accepts the "Nd" form +// — an integer number of days, so "7d" is 7 × 24h — in addition to +// time.ParseDuration syntax. func (d *Duration) UnmarshalText(text []byte) error { + if days, ok := parseDays(text); ok { + d.Duration = days + return nil + } dt, err := time.ParseDuration(string(text)) if err != nil { return err @@ -35,6 +42,22 @@ func (d *Duration) UnmarshalText(text []byte) error { return nil } +// dayPattern matches a run of digits followed by "d", e.g. "7d". +var dayPattern = regexp.MustCompile(`^[0-9]+d$`) + +// parseDays reports whether text is the "Nd" day form and returns that many +// hours. The regex guarantees the prefix is a base-10 integer. +func parseDays(text []byte) (time.Duration, bool) { + if !dayPattern.MatchString(string(text)) { + return 0, false + } + n, err := strconv.Atoi(string(text[:len(text)-1])) + if err != nil { + return 0, false + } + return time.Duration(n) * 24 * time.Hour, true +} + // Model is the per-model tuning carried by a host entry. type Model struct { Parallel int `toml:"parallel"` @@ -58,6 +81,9 @@ type Config struct { Listen string `toml:"listen"` PollInterval Duration `toml:"poll_interval"` QueueMax int `toml:"queue_max"` + DB string `toml:"db"` + LeaseIdle Duration `toml:"lease_idle"` + Retention Duration `toml:"retention"` Hosts map[string]Host `toml:"hosts"` Routes map[string]Route `toml:"routes"` } @@ -76,6 +102,13 @@ const ( DefaultPollInterval = 60 * time.Second DefaultQueueMax = 8 MinPollInterval = time.Second + + DefaultDB = "crossbar.db" + DefaultLeaseIdle = 30 * time.Minute + DefaultRetention = 180 * 24 * time.Hour + + MinLeaseIdle = time.Minute + MinRetention = 24 * time.Hour ) var routeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) @@ -109,6 +142,15 @@ func Parse(r io.Reader) (*Config, error) { return nil, &Error{Field: keys[0], Msg: "unknown key"} } + if !md.IsDefined("db") { + c.DB = DefaultDB + } + if !md.IsDefined("lease_idle") { + c.LeaseIdle.Duration = DefaultLeaseIdle + } + if !md.IsDefined("retention") { + c.Retention.Duration = DefaultRetention + } if c.PollInterval.Duration == 0 { c.PollInterval.Duration = DefaultPollInterval } @@ -152,6 +194,15 @@ func (c *Config) validate() *Error { if e := c.checkQueue(); e != nil { return e } + if e := c.checkDB(); e != nil { + return e + } + if e := c.checkLeaseIdle(); e != nil { + return e + } + if e := c.checkRetention(); e != nil { + return e + } if e := c.checkHosts(); e != nil { return e } @@ -193,6 +244,27 @@ func (c *Config) checkQueue() *Error { return nil } +func (c *Config) checkDB() *Error { + if c.DB == "" { + return &Error{Field: "db", Msg: "required"} + } + return nil +} + +func (c *Config) checkLeaseIdle() *Error { + if c.LeaseIdle.Duration < MinLeaseIdle { + return &Error{Field: "lease_idle", Msg: "must be at least 1m"} + } + return nil +} + +func (c *Config) checkRetention() *Error { + if c.Retention.Duration < MinRetention { + return &Error{Field: "retention", Msg: "must be at least 1d"} + } + return nil +} + func (c *Config) checkHosts() *Error { if len(c.Hosts) == 0 { return &Error{Field: "hosts", Msg: "at least one required"} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9069c36..610025f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -81,7 +81,7 @@ func TestBadFiles(t *testing.T) { {"bad-listen.toml", "listen"}, {"bad-unknown-host.toml", "routes.r.hosts"}, {"bad-default-model.toml", "routes.r.default_model"}, - {"bad-unknown-key.toml", "lease_idle"}, + {"bad-unknown-key.toml", "bogus_key"}, } for _, tc := range cases { t.Run(tc.file, func(t *testing.T) { diff --git a/internal/config/config_v1_test.go b/internal/config/config_v1_test.go new file mode 100644 index 0000000..9931c17 --- /dev/null +++ b/internal/config/config_v1_test.go @@ -0,0 +1,81 @@ +package config_test + +import ( + "strings" + "testing" + "time" + + "git.wntrmute.dev/kyle/crossbar/internal/config" +) + +const v1Base = ` +listen = "127.0.0.1:1" +[hosts.a] +base_url = "http://a:1" +models = { "m" = { } } +[routes.r] +hosts = ["a"] +` + +func TestV1Defaults(t *testing.T) { + c, err := config.Parse(strings.NewReader(v1Base)) + if err != nil { + t.Fatal(err) + } + if c.DB != "crossbar.db" { + t.Errorf("DB default = %q", c.DB) + } + if c.LeaseIdle.Duration != 30*time.Minute { + t.Errorf("LeaseIdle default = %v", c.LeaseIdle.Duration) + } + if c.Retention.Duration != 180*24*time.Hour { + t.Errorf("Retention default = %v", c.Retention.Duration) + } +} + +func TestV1Values(t *testing.T) { + c, err := config.Parse(strings.NewReader(` +db = "/var/lib/crossbar/crossbar.db" +lease_idle = "45m" +retention = "30d" +` + v1Base)) + if err != nil { + t.Fatal(err) + } + if c.DB != "/var/lib/crossbar/crossbar.db" || c.LeaseIdle.Duration != 45*time.Minute || c.Retention.Duration != 30*24*time.Hour { + t.Errorf("got db %q idle %v retention %v", c.DB, c.LeaseIdle.Duration, c.Retention.Duration) + } +} + +func TestDurationAcceptsDays(t *testing.T) { + var d config.Duration + for _, tc := range []struct { + in string + want time.Duration + }{ + {"1d", 24 * time.Hour}, {"7d", 7 * 24 * time.Hour}, {"90m", 90 * time.Minute}, {"2h30m", 150 * time.Minute}, + } { + if err := d.UnmarshalText([]byte(tc.in)); err != nil || d.Duration != tc.want { + t.Errorf("UnmarshalText(%q) = %v %v, want %v", tc.in, d.Duration, err, tc.want) + } + } + for _, bad := range []string{"1.5d", "d", "3 days", "1d2h"} { + if err := d.UnmarshalText([]byte(bad)); err == nil { + t.Errorf("UnmarshalText(%q) must fail", bad) + } + } +} + +func TestV1Validation(t *testing.T) { + for _, tc := range []struct{ name, text, field string }{ + {"empty db", "db = \"\"\n" + v1Base, "db"}, + {"lease_idle too short", "lease_idle = \"10s\"\n" + v1Base, "lease_idle"}, + {"retention too short", "retention = \"12h\"\n" + v1Base, "retention"}, + } { + _, err := config.Parse(strings.NewReader(tc.text)) + e, ok := config.IsError(err) + if !ok || e.Field != tc.field { + t.Errorf("%s: %v, want *Error on %s", tc.name, err, tc.field) + } + } +} diff --git a/internal/config/testdata/bad-unknown-key.toml b/internal/config/testdata/bad-unknown-key.toml index e3e94ec..67a773b 100644 --- a/internal/config/testdata/bad-unknown-key.toml +++ b/internal/config/testdata/bad-unknown-key.toml @@ -1,5 +1,5 @@ listen = "127.0.0.1:7777" -lease_idle = "30m" +bogus_key = 1 [hosts.alpha] base_url = "http://alpha.example:11434" diff --git a/internal/fingerprint/fingerprint.go b/internal/fingerprint/fingerprint.go new file mode 100644 index 0000000..d310981 --- /dev/null +++ b/internal/fingerprint/fingerprint.go @@ -0,0 +1,88 @@ +// Package fingerprint identifies a chat-completions conversation without a +// session id: the system prompt and the first user message never change from +// turn to turn, so hashing them pins the conversation. +package fingerprint + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" +) + +// maxPart is the number of bytes of each input string that contributes to the +// key: 4 KiB keeps a huge first message from slowing every turn. +const maxPart = 4096 + +// Of returns the lowercase hex SHA-256 of the system prompt and the first user +// message of a chat-completions body (first 4 KiB of each, joined with "\n"), +// or "" when the body is not a JSON object with a "messages" array containing a +// user message. +func Of(body []byte) string { + var doc struct { + Messages []json.RawMessage `json:"messages"` + } + if err := json.Unmarshal(body, &doc); err != nil || doc.Messages == nil { + return "" + } + + var system, user string + seenSystem, seenUser := false, false + for _, raw := range doc.Messages { + var msg struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } + if err := json.Unmarshal(raw, &msg); err != nil { + return "" + } + switch msg.Role { + case "system": + if !seenSystem { + seenSystem = true + system = contentText(msg.Content) + } + case "user": + if !seenUser { + seenUser = true + user = contentText(msg.Content) + } + } + } + if !seenUser { + return "" + } + + if len(system) > maxPart { + system = system[:maxPart] + } + if len(user) > maxPart { + user = user[:maxPart] + } + sum := sha256.Sum256([]byte(system + "\n" + user)) + return hex.EncodeToString(sum[:]) +} + +// contentText renders a message content value: a JSON string is returned as-is, +// an array of parts is the concatenation of its text parts (other types +// ignored), and anything else is "". +func contentText(raw json.RawMessage) string { + var text string + if err := json.Unmarshal(raw, &text); err == nil { + return text + } + var parts []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(raw, &parts); err != nil { + return "" + } + var b strings.Builder + for _, p := range parts { + if p.Type == "text" { + b.WriteString(p.Text) + } + } + return b.String() +} diff --git a/internal/fingerprint/fingerprint_test.go b/internal/fingerprint/fingerprint_test.go new file mode 100644 index 0000000..860d5cb --- /dev/null +++ b/internal/fingerprint/fingerprint_test.go @@ -0,0 +1,70 @@ +package fingerprint_test + +import ( + "strings" + "testing" + + "git.wntrmute.dev/kyle/crossbar/internal/fingerprint" +) + +const conv1 = `{"model":"m","messages":[{"role":"system","content":"You are the project A assistant."},{"role":"user","content":"Add a config loader."},{"role":"assistant","content":"Sure."},{"role":"user","content":"Now tests."}]}` +const conv1later = `{"model":"m","messages":[{"role":"system","content":"You are the project A assistant."},{"role":"user","content":"Add a config loader."},{"role":"assistant","content":"Sure."},{"role":"user","content":"Now tests."},{"role":"assistant","content":"Done."},{"role":"user","content":"And docs."}]}` +const conv2 = `{"model":"m","messages":[{"role":"system","content":"You are the project A assistant."},{"role":"user","content":"Fix the flaky test."}]}` +const conv3 = `{"model":"m","messages":[{"role":"system","content":"You are the project B assistant."},{"role":"user","content":"Add a config loader."}]}` + +func TestSameConversationSameKey(t *testing.T) { + a := fingerprint.Of([]byte(conv1)) + b := fingerprint.Of([]byte(conv1later)) + if a == "" || a != b { + t.Errorf("later turns of one conversation must keep the key: %q vs %q", a, b) + } + if len(a) != 64 || strings.Trim(a, "0123456789abcdef") != "" { + t.Errorf("key must be lowercase hex sha256 (64 chars), got %q", a) + } +} + +func TestDifferentConversationsDifferentKeys(t *testing.T) { + a, b, c := fingerprint.Of([]byte(conv1)), fingerprint.Of([]byte(conv2)), fingerprint.Of([]byte(conv3)) + if a == b { + t.Errorf("different first user message must change the key") + } + if a == c { + t.Errorf("different system prompt must change the key") + } +} + +func TestNoUserMessageIsEmpty(t *testing.T) { + for _, body := range []string{ + `{"model":"m","messages":[{"role":"system","content":"only a system prompt"}]}`, + `{"model":"m","messages":[]}`, + `{"model":"m"}`, + `{"input":"an embeddings request"}`, + `not json at all`, + ``, + } { + if got := fingerprint.Of([]byte(body)); got != "" { + t.Errorf("Of(%q) = %q, want empty", body, got) + } + } +} + +func TestOnlyTheFirstFourKiBCount(t *testing.T) { + long := strings.Repeat("x", 5000) + a := `{"messages":[{"role":"user","content":"` + long + `A"}]}` + b := `{"messages":[{"role":"user","content":"` + long + `B"}]}` + if fingerprint.Of([]byte(a)) != fingerprint.Of([]byte(b)) { + t.Errorf("bytes after the first 4 KiB of a message must not change the key") + } + c := `{"messages":[{"role":"user","content":"A` + long + `"}]}` + if fingerprint.Of([]byte(a)) == fingerprint.Of([]byte(c)) { + t.Errorf("bytes inside the first 4 KiB must change the key") + } +} + +func TestContentPartsAreFlattened(t *testing.T) { + plain := `{"messages":[{"role":"user","content":"hello world"}]}` + parts := `{"messages":[{"role":"user","content":[{"type":"text","text":"hello world"}]}]}` + if fingerprint.Of([]byte(plain)) != fingerprint.Of([]byte(parts)) { + t.Errorf("a content array of text parts must fingerprint like the joined text") + } +}