Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import (
"github.com/VersusControl/versus-incident/pkg/teams"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ssmincidents"
"github.com/go-redis/redis/v8"
"github.com/redis/go-redis/v9"

"github.com/VersusControl/versus-incident/pkg/common"

Expand Down Expand Up @@ -145,13 +145,11 @@ func main() {

// Shared Redis client used by both on-call and the agent worker. We open
// it once here so both subsystems share connections.
var sharedRedis *redis.Client
var sharedRedis redis.UniversalClient

if cfg.OnCall.Enable || cfg.OnCall.InitializedOnly {
redisOptions := handlerRedisOptions(cfg.Redis)

// Initialize Redis client
redisClient := redis.NewClient(redisOptions)
// Initialize Redis client (cluster-aware when redis.cluster is set)
redisClient := newRedisClient(cfg.Redis)

// Test Redis connection
if err := redisClient.Ping(context.Background()).Err(); err != nil {
Expand Down Expand Up @@ -179,7 +177,7 @@ func main() {
// in-memory cursors when Redis isn't reachable).
rdb := sharedRedis
if rdb == nil && cfg.Redis.Host != "" {
rdb = redis.NewClient(handlerRedisOptions(cfg.Redis))
rdb = newRedisClient(cfg.Redis)
if err := rdb.Ping(context.Background()).Err(); err != nil {
log.Printf("agent: Redis unavailable (%v); cursors will be in-memory only", err)
rdb = nil
Expand Down Expand Up @@ -225,7 +223,7 @@ func main() {
// startAgent constructs the worker, starts it in a goroutine, and registers
// admin routes on the fiber app. It returns the catalog so the caller can
// hold a reference (and so future hot-reload code has a handle to it).
func startAgent(ctx context.Context, app *fiber.App, cfg c.AgentConfig, gatewaySecret string, store storage.Provider, rdb *redis.Client) (*agent.Catalog, error) {
func startAgent(ctx context.Context, app *fiber.App, cfg c.AgentConfig, gatewaySecret string, store storage.Provider, rdb redis.UniversalClient) (*agent.Catalog, error) {
// On the Postgres backend, install the typed signal-table
// catalog store so the log catalog reads/writes the explicit
// vs_patterns/vs_logs/vs_services tables (searchable, indexed) instead of
Expand Down Expand Up @@ -408,6 +406,24 @@ func handleQueueMessage(content *map[string]interface{}) error {
return services.CreateIncident("", content, &overwrite) // teamID as empty string
}

// newRedisClient builds the Redis client both subsystems share. It reuses
// handlerRedisOptions for the addr/password/TLS settings, then returns either a
// single-node client (the default) or a cluster-aware client when the operator
// opts in via redis.cluster / REDIS_CLUSTER. Both concrete types satisfy
// redis.UniversalClient, so callers thread the interface and single-key
// Get/Set follow MOVED/ASK redirects transparently in cluster mode.
func newRedisClient(rc c.RedisConfig) redis.UniversalClient {
opts := handlerRedisOptions(rc)
if rc.ClusterEnabled() {
return redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{opts.Addr},
Password: opts.Password,
TLSConfig: opts.TLSConfig,
})
}
return redis.NewClient(opts)
}

func handlerRedisOptions(rc c.RedisConfig) *redis.Options {
redisOptions := &redis.Options{
Addr: rc.Host + ":" + strconv.Itoa(rc.Port),
Expand Down
33 changes: 33 additions & 0 deletions cmd/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"

c "github.com/VersusControl/versus-incident/pkg/config"
"github.com/redis/go-redis/v9"
)

// TestHandlerRedisOptionsTLS covers the plaintext-Redis case: with TLS disabled the Redis
Expand Down Expand Up @@ -41,3 +42,35 @@ func TestHandlerRedisOptionsTLS(t *testing.T) {
}
})
}

// TestNewRedisClientClusterType verifies that enabling cluster mode builds a
// cluster-aware client (*redis.ClusterClient) rather than a single-node one.
// The cluster client is what parses the Redis 7 / Valkey CLUSTER SLOTS reply
// (which carries a 4th per-node metadata element) correctly, so cursor
// persistence keeps working against ElastiCache in cluster mode instead of
// falling back to in-memory. Both concrete clients are threaded as the shared
// redis.UniversalClient interface, which the assertions below also confirm.
func TestNewRedisClientClusterType(t *testing.T) {
tru := true
fls := false

t.Run("cluster enabled returns *redis.ClusterClient", func(t *testing.T) {
client := newRedisClient(c.RedisConfig{Host: "localhost", Port: 6379, TLS: &fls, Cluster: &tru})
defer client.Close()

if _, ok := client.(*redis.ClusterClient); !ok {
t.Fatalf("expected *redis.ClusterClient when redis.cluster=true, got %T", client)
}
var _ redis.UniversalClient = client
})

t.Run("cluster disabled returns single-node *redis.Client", func(t *testing.T) {
client := newRedisClient(c.RedisConfig{Host: "localhost", Port: 6379, TLS: &fls})
defer client.Close()

if _, ok := client.(*redis.Client); !ok {
t.Fatalf("expected *redis.Client when cluster is off, got %T", client)
}
var _ redis.UniversalClient = client
})
}
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ require (
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
github.com/cloudwego/eino-ext/components/model/qwen v0.1.9
github.com/go-git/go-git/v5 v5.19.1
github.com/go-redis/redis/v8 v8.11.5
github.com/gofiber/fiber/v2 v2.52.14
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/redis/go-redis/v9 v9.21.0
github.com/slack-go/slack v0.27.0
github.com/spf13/viper v1.21.0
golang.org/x/image v0.43.0
Expand Down Expand Up @@ -53,6 +53,7 @@ require (
go.opentelemetry.io/otel v1.29.0 // indirect
go.opentelemetry.io/otel/metric v1.29.0 // indirect
go.opentelemetry.io/otel/trace v1.29.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
google.golang.org/api v0.197.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
Expand Down Expand Up @@ -131,7 +132,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 // indirect
github.com/aws/smithy-go v1.27.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
)
Expand Down
19 changes: 10 additions & 9 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
Expand Down Expand Up @@ -115,8 +119,6 @@ github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
Expand Down Expand Up @@ -160,8 +162,6 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
Expand Down Expand Up @@ -260,14 +260,10 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/ollama/ollama v0.20.3 h1:oP7eJZ+U4FzkoGGdzBq9mbq4wyuNyvVkz/BWxOrrAc0=
github.com/ollama/ollama v0.20.3/go.mod h1:tCX4IMV8DHjl3zY0THxuEkpWDZSOchJpzTuLACpMwFw=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
Expand All @@ -281,6 +277,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
Expand Down Expand Up @@ -359,6 +357,8 @@ github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
Expand All @@ -371,6 +371,8 @@ go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
Expand Down Expand Up @@ -475,7 +477,6 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
Expand Down
58 changes: 35 additions & 23 deletions pkg/agent/brain_log.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,13 +267,13 @@ func (b *logBrain) Classify(obs core.Observation, mean, std float64, confident b
prevCount := postCount - tickFreq // recover the pre-fold count
prevVerdict := p.Verdict // Upsert never mutates Verdict, so == pre-fold

// AutoPromoteAfter ≤ 0 disables count-based promotion entirely ("0 disables
// the promotion"): a pattern is never marked "known" by sighting count
// alone, so it keeps flowing to detect-AI however often it is seen. The 100
// default for an UNSET key is supplied by the embedded default_config layer
// (loaded as the base before user overrides), so an omitted key arrives here
// as 100 — only an explicit 0 (or negative) reaches the disabled branch. A
// pattern already promoted to "known" stays known regardless of threshold.
// The effective auto-promotion threshold gates count-based promotion: once
// a pattern's sighting count reaches it, the pattern is marked "known" and
// stops flowing to detect-AI on count alone. A non-positive configured
// value is resolved to the default by isLogKnown (there is no way to turn
// count-based promotion off), and an omitted key already arrives here as the
// default via the config layer. A pattern already promoted to "known" stays
// known regardless of threshold.
threshold := b.cat.AutoPromoteAfter
isKnown := isLogKnown(prevVerdict, postCount, threshold)
if isKnown {
Expand Down Expand Up @@ -340,9 +340,9 @@ func (b *logBrain) confirmSpike(key string, res spikeResult) bool {
// training this is what keeps the Verdict column and the readiness "To known"
// column in agreement (both derive from isLogKnown).
//
// AutoPromoteAfter <= 0 disables count-based promotion entirely: for a not-yet-
// known pattern isLogKnown then returns false, so Promote never marks a pattern
// known by count in any mode; an operator-set "known" is untouched.
// A non-positive AutoPromoteAfter is resolved to the default by isLogKnown, so
// count-based promotion is always in effect; there is no "promotion disabled"
// state.
func (b *logBrain) Promote(key string) {
p := b.catalog.Get(key)
if p == nil {
Expand All @@ -353,16 +353,30 @@ func (b *logBrain) Promote(key string) {
}
}

// effectiveAutoPromote resolves the auto-promotion threshold used everywhere in
// the learning engine. A non-positive value (an operator who omitted, blanked,
// or zeroed the key, or a test that passes 0 directly) is treated as the
// default rather than as a "promotion disabled" state — there is no way to turn
// count-based promotion off. The config loader already normalizes the loaded
// config to a positive value; this is the belt-and-suspenders guard so a 0 that
// reaches the engine by any other path still resolves to a sane gate.
func effectiveAutoPromote(n int) int {
if n <= 0 {
return config.DefaultAutoPromoteAfter
}
return n
}

// isLogKnown is the SINGLE definition of log "known" — the exact predicate
// logBrain.Classify gates promotion on. Both Classify and LogReadiness call it
// so the classifier and the read-side readiness view can never drift; a drift
// test pins them equal. A pattern is known when an operator (or a prior
// auto-promotion) already marked it "known", OR count-based promotion is
// enabled (autoPromoteAfter > 0) and the sighting count has reached it.
// autoPromoteAfter <= 0 disables count-based promotion, so only a pre-set
// "known" verdict counts.
// auto-promotion) already marked it "known", OR the sighting count has reached
// the effective auto-promotion threshold. A non-positive autoPromoteAfter is
// resolved to the default (see effectiveAutoPromote), so count-based promotion
// is always in effect.
func isLogKnown(prevVerdict string, count, autoPromoteAfter int) bool {
return prevVerdict == "known" || (autoPromoteAfter > 0 && count >= autoPromoteAfter)
return prevVerdict == "known" || count >= effectiveAutoPromote(autoPromoteAfter)
}

// LogReadiness computes the readiness of one log pattern as a generic
Expand All @@ -372,9 +386,9 @@ func isLogKnown(prevVerdict string, count, autoPromoteAfter int) bool {
// gate compares) and the catalog's AutoPromoteAfter threshold.
//
// - Seen = the pattern's sighting count (the gate's own counter).
// - Needed = autoPromoteAfter when count-promotion is enabled (>0); 0 is the
// indeterminate sentinel when promotion is disabled (autoPromoteAfter<=0 →
// manual-only).
// - Needed = the effective auto-promotion threshold — always a positive gate
// (a non-positive configured value is resolved to the default), so there is
// no indeterminate/manual-only case.
// - Ready = isLogKnown(...), so an already-"known"/"spike"-marked or
// count-promoted pattern reports Ready.
// - RatePerMin = the pattern's learned per-second sighting rate
Expand All @@ -387,12 +401,10 @@ func LogReadiness(p *Pattern, autoPromoteAfter int, pollInterval time.Duration)
return core.Readiness{}
}
r := core.Readiness{
Seen: p.Count,
Ready: isLogKnown(p.Verdict, p.Count, autoPromoteAfter),
Seen: p.Count,
Needed: effectiveAutoPromote(autoPromoteAfter),
Ready: isLogKnown(p.Verdict, p.Count, autoPromoteAfter),
}
if autoPromoteAfter > 0 {
r.Needed = autoPromoteAfter
} // else Needed=0 → indeterminate (manual-only promotion)
if !r.Ready && pollInterval > 0 && p.BaselineFrequency > 0 {
// BaselineFrequency is now a per-second sighting rate, so sightings/min
// is just rate × 60 — poll-interval-independent. pollInterval is kept as
Expand Down
Loading