Skip to content

Commit 04cfc91

Browse files
authored
feat: service management
feat: service management
2 parents 093521d + f5b0bea commit 04cfc91

44 files changed

Lines changed: 4664 additions & 97 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/main.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,17 @@ func startAgent(ctx context.Context, app *fiber.App, cfg c.AgentConfig, gatewayS
254254
log.Printf("agent: service_patterns warning: %v", e)
255255
}
256256

257+
// Manual-attribution override store (Service-Override seam). It backs the
258+
// service-override admin endpoints AND is installed as the process-wide
259+
// resolver so a stored correction re-labels future log signals (and, when
260+
// the enterprise metric/trace brains are linked, their signals too). OSS
261+
// ships this working store so logs override needs no enterprise module.
262+
overrideStore, err := agent.LoadServiceOverrideStore(store)
263+
if err != nil {
264+
log.Printf("agent: service-override load warning: %v (starting fresh)", err)
265+
}
266+
agent.SetServiceOverride(overrideStore)
267+
257268
sources, sourceErrs := agent.BuildSources(cfg)
258269
for _, e := range sourceErrs {
259270
log.Printf("agent: source warning: %v", e)
@@ -298,7 +309,7 @@ func startAgent(ctx context.Context, app *fiber.App, cfg c.AgentConfig, gatewayS
298309
return nil, fmt.Errorf("agent: gateway_secret is not configured — /api/agent/* admin endpoints require a secret")
299310
}
300311
api := app.Group("/api")
301-
controllers.NewAgentController(catalog, shadowLog, detectLog, aiBundle.Runbooks != nil).Register(api)
312+
controllers.NewAgentController(catalog, miner, shadowLog, detectLog, overrideStore, aiBundle.Runbooks != nil).Register(api)
302313
controllers.NewRunbookAdminController(aiBundle.Runbooks).Register(api)
303314

304315
return catalog, nil

config/config.yaml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ agent:
197197

198198
catalog:
199199
persist_interval: 30s
200-
auto_promote_after: 50 # in detect mode, this many sightings = "known"
200+
auto_promote_after: 100 # in detect mode, this many sightings = "known"
201201
# Spike detection: a known pattern is re-flagged when its tick-level
202202
# frequency suddenly exceeds the EWMA (Exponentially Weighted Moving Average) baseline by `spike_multiplier`.
203203
# Two safety floors keep noise out:
@@ -235,7 +235,7 @@ agent:
235235
enable: false # master switch (env: AGENT_AI_ENABLE)
236236
# provider selects the model backend (openai | deepseek | qwen | ollama |
237237
# claude | gemini). Default openai. The api_key below must match the chosen provider.
238-
provider: openai
238+
provider: openai # model backend (env: AGENT_AI_PROVIDER)
239239
api_key: ${AGENT_AI_API_KEY}
240240
model: "gpt-5.4-mini"
241241
# temperature: 0.0–2.0 (default 0.2). Set a NEGATIVE value (e.g. -1) to
@@ -273,6 +273,20 @@ agent:
273273
# Used by: Node.js (Bunyan, Pino, Winston), .NET Serilog (JSON), Java logback-json,
274274
# Python python-json-logger, AWS Lambda Powertools, nginx JSON access logs
275275
- '(?i)"(?:service|svc|app|component)"\s*:\s*"([A-Za-z0-9._-]+)"'
276+
# Spring Boot / Logback console (custom pattern that prints the app name where
277+
# the level usually goes): "<timestamp> <service> [<thread>] ...".
278+
# 2026-07-01 05:08:14.502 lead-service [consumer-0-C-1] WARN k.c.NetworkClient : ...
279+
# The service is the first token after the timestamp, anchored by the
280+
# bracketed thread that follows it. ANSI colour escapes around the token are
281+
# stripped before matching (see ServiceMatcher.Extract). On the vanilla Spring
282+
# layout the first token is the LEVEL followed by the PID (not a bracket), so
283+
# this rule does not fire and the "---" rule below wins instead.
284+
- '^\s*\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,]\d{1,9})?\s+([A-Za-z][A-Za-z0-9._-]*)\s+\['
285+
# Bracketed MDC context: "[ <service> , requestID=… , traceID=… , spanID=… ]".
286+
# Used by: Java/Logback %X MDC layouts that put the service first in a
287+
# bracketed context block. The trailing request/trace/span id key
288+
# anchors the service so a level token like "[ DEBUG ]" is not caught.
289+
- '\[\s*([A-Za-z][A-Za-z0-9._-]*)\s*,\s*(?i:request[_-]?id|trace[_-]?id|span[_-]?id)\b'
276290
# Two bracketed tokens: [logger] [service] ...
277291
# Used by: Python Django (`[django.request] [orders] ...`),
278292
# some logback layouts that put logger first then MDC service.

pkg/agent/brain_log.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,16 @@ func (b *logBrain) Group(ctx context.Context, batch []core.Signal) ([]core.Obser
9292
if svc == "" {
9393
svc = "_unknown"
9494
}
95+
// Manual-attribution override (Service-Override seam): an operator's
96+
// stored correction WINS over regex detection (and over "_unknown").
97+
// The match key is the mined pattern identity or a message substring.
98+
// A nil resolver (no override wired) returns svc unchanged.
99+
svc = ResolveServiceOverride(ctx, ServiceOverrideInput{
100+
SourceType: OverrideSourceLog,
101+
Service: svc,
102+
Pattern: id,
103+
Message: sig.Message,
104+
})
95105
bk = &bucket{template: template, isNew: isNew, service: svc}
96106
buckets[id] = bk
97107
order = append(order, id)
@@ -164,11 +174,15 @@ func (b *logBrain) Classify(obs core.Observation, mean, std float64, confident b
164174
prevCount := postCount - tickFreq // recover the pre-fold count
165175
prevVerdict := p.Verdict // Upsert never mutates Verdict, so == pre-fold
166176

177+
// AutoPromoteAfter ≤ 0 disables count-based promotion entirely ("0 disables
178+
// the promotion"): a pattern is never marked "known" by sighting count
179+
// alone, so it keeps flowing to detect-AI however often it is seen. The 100
180+
// default for an UNSET key is supplied by the embedded default_config layer
181+
// (loaded as the base before user overrides), so an omitted key arrives here
182+
// as 100 — only an explicit 0 (or negative) reaches the disabled branch. A
183+
// pattern already promoted to "known" stays known regardless of threshold.
167184
threshold := b.cat.AutoPromoteAfter
168-
if threshold <= 0 {
169-
threshold = 100
170-
}
171-
isKnown := prevVerdict == "known" || postCount >= threshold
185+
isKnown := prevVerdict == "known" || (threshold > 0 && postCount >= threshold)
172186
if isKnown {
173187
if prevVerdict != "known" {
174188
b.catalog.MarkKnown(obs.Key)

pkg/agent/brain_log_test.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,135 @@ func TestLogBrain_ClassifyLifecycle(t *testing.T) {
229229
t.Errorf("spike baseline = %v, want the pre-fold baseline > 0", v.Baseline)
230230
}
231231
}
232+
233+
// --- auto_promote_after threshold semantics (QA-028) --------------------------
234+
// Spike is left disabled (SpikeMultiplier == 0) throughout so it can never mask
235+
// the count-based promotion verdict under test.
236+
237+
// (a) The shipped default (100, supplied by the embedded default_config layer
238+
// for an unset key) still promotes exactly at the 100th sighting.
239+
func TestLogBrain_AutoPromoteAfter_DefaultPromotesAt100(t *testing.T) {
240+
b, c := newLogBrainForTest(t, config.AgentCatalogConfig{AutoPromoteAfter: 100})
241+
242+
for i := 0; i < 99; i++ {
243+
classifyOnce(t, b, logObs("p", 1))
244+
}
245+
if got := c.Get("p").Count; got != 99 {
246+
t.Fatalf("count = %d, want 99", got)
247+
}
248+
if c.Get("p").Verdict == "known" {
249+
t.Fatalf("promoted early at count=99 (threshold 100)")
250+
}
251+
252+
v := classifyOnce(t, b, logObs("p", 1)) // 100th sighting crosses the threshold
253+
if got := c.Get("p").Count; got != 100 {
254+
t.Fatalf("count = %d, want 100", got)
255+
}
256+
if v.Class != core.VerdictKnownPattern {
257+
t.Fatalf("at-threshold verdict = %v, want known", v.Class)
258+
}
259+
if c.Get("p").Verdict != "known" {
260+
t.Fatalf("catalog verdict = %q, want known (MarkKnown must fire at 100)", c.Get("p").Verdict)
261+
}
262+
}
263+
264+
// (b) A positive custom threshold promotes exactly at that count, not at 100.
265+
func TestLogBrain_AutoPromoteAfter_CustomThresholdPromotes(t *testing.T) {
266+
b, c := newLogBrainForTest(t, config.AgentCatalogConfig{AutoPromoteAfter: 50})
267+
268+
for i := 0; i < 49; i++ {
269+
classifyOnce(t, b, logObs("p", 1))
270+
}
271+
if c.Get("p").Verdict == "known" {
272+
t.Fatalf("promoted before custom threshold 50 (count=%d)", c.Get("p").Count)
273+
}
274+
275+
v := classifyOnce(t, b, logObs("p", 1)) // 50th sighting
276+
if got := c.Get("p").Count; got != 50 {
277+
t.Fatalf("count = %d, want 50", got)
278+
}
279+
if v.Class != core.VerdictKnownPattern {
280+
t.Fatalf("at-custom-threshold verdict = %v, want known", v.Class)
281+
}
282+
if c.Get("p").Verdict != "known" {
283+
t.Fatalf("catalog verdict = %q, want known at the custom threshold", c.Get("p").Verdict)
284+
}
285+
}
286+
287+
// (c) QA-028: auto_promote_after: 0 DISABLES count-based promotion — a pattern
288+
// is never marked "known" no matter how many times it is seen. Drives well past
289+
// the old 100 fallback to prove the explicit 0 is honoured, not re-mapped.
290+
func TestLogBrain_AutoPromoteAfter_ZeroDisablesPromotion(t *testing.T) {
291+
cat := config.AgentCatalogConfig{
292+
AutoPromoteAfter: 0, // documented: disables promotion
293+
SpikeMultiplier: 0, // disable spike so it can't mask the verdict
294+
}
295+
b, c := newLogBrainForTest(t, cat)
296+
297+
var v core.TypedVerdict
298+
for i := 0; i < 12; i++ {
299+
v = classifyOnce(t, b, logObs("p", 10)) // 120 sightings, well past 100
300+
}
301+
if got := c.Get("p").Count; got != 120 {
302+
t.Fatalf("count = %d, want 120", got)
303+
}
304+
if c.Get("p").Verdict == "known" {
305+
t.Fatalf("auto_promote_after=0 promoted pattern to %q; 0 must disable promotion (QA-028)", c.Get("p").Verdict)
306+
}
307+
if v.Class != core.VerdictUnknown {
308+
t.Fatalf("verdict at count=120 = %v, want unknown (0 disables promotion)", v.Class)
309+
}
310+
}
311+
312+
// (d) A negative threshold (any value ≤ 0) also disables promotion.
313+
func TestLogBrain_AutoPromoteAfter_NegativeDisablesPromotion(t *testing.T) {
314+
cat := config.AgentCatalogConfig{
315+
AutoPromoteAfter: -1,
316+
SpikeMultiplier: 0,
317+
}
318+
b, c := newLogBrainForTest(t, cat)
319+
320+
var v core.TypedVerdict
321+
for i := 0; i < 12; i++ {
322+
v = classifyOnce(t, b, logObs("p", 10)) // 120 sightings
323+
}
324+
if c.Get("p").Verdict == "known" {
325+
t.Fatalf("auto_promote_after=-1 promoted pattern to %q; any value ≤0 must disable promotion", c.Get("p").Verdict)
326+
}
327+
if v.Class != core.VerdictUnknown {
328+
t.Fatalf("verdict = %v, want unknown (negative disables promotion)", v.Class)
329+
}
330+
}
331+
332+
// (e) An operator-labelled "known" pattern STAYS known even when count-based
333+
// promotion is disabled (threshold 0). This bites the `prevVerdict == "known"`
334+
// clause independently of the count clause: were the guard rewritten as
335+
// `threshold > 0 && (prevVerdict == "known" || postCount >= threshold)`, a
336+
// disabled threshold would silently un-suppress a hand-labelled pattern.
337+
func TestLogBrain_AutoPromoteAfter_AlreadyKnownStaysKnown(t *testing.T) {
338+
cat := config.AgentCatalogConfig{
339+
AutoPromoteAfter: 0, // count-based promotion disabled
340+
SpikeMultiplier: 0, // disable spike so it can't mask the verdict
341+
}
342+
b, c := newLogBrainForTest(t, cat)
343+
344+
// Seed the pattern (stays Unknown — 0 disables count-based promotion), then
345+
// label it known by hand as an operator would.
346+
classifyOnce(t, b, logObs("p", 5))
347+
if !c.MarkKnown("p") {
348+
t.Fatalf("MarkKnown(p) did not mark the seeded pattern")
349+
}
350+
if c.Get("p").Verdict != "known" {
351+
t.Fatalf("precondition: catalog verdict = %q, want known", c.Get("p").Verdict)
352+
}
353+
354+
// Further sightings must keep it known — the prior "known" verdict wins even
355+
// though the count threshold is disabled.
356+
v := classifyOnce(t, b, logObs("p", 5))
357+
if v.Class != core.VerdictKnownPattern {
358+
t.Fatalf("already-known verdict = %v, want known (prior known must win with threshold 0)", v.Class)
359+
}
360+
if c.Get("p").Verdict != "known" {
361+
t.Fatalf("catalog verdict = %q, want known (an already-known pattern must stay known)", c.Get("p").Verdict)
362+
}
363+
}

pkg/agent/catalog.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,25 @@ type ServiceInfo struct {
5858
// storage.DefaultOrgID ("default"); see Pattern.OrgID.
5959
OrgID string `json:"org_id,omitempty"`
6060
FirstSeen time.Time `json:"first_seen"`
61+
// Manual is true when an operator created the service by hand via the admin
62+
// API (so it is selectable as an override target BEFORE any signal is
63+
// attributed to it). Auto-discovered services leave it false. A manual
64+
// service coexists with auto-discovery: RegisterService never clobbers an
65+
// existing entry, so a name seen in a real signal keeps its manual flag.
66+
Manual bool `json:"manual,omitempty"`
6167
}
6268

69+
// Sentinel errors for manual-service CRUD, so the admin controller can map them
70+
// to precise HTTP statuses without string matching.
71+
var (
72+
// ErrServiceExists is returned when creating/renaming to a name that is
73+
// already tracked (auto-discovered or manual).
74+
ErrServiceExists = fmt.Errorf("service already exists")
75+
// ErrServiceNotFound is returned when renaming/deleting a name that is not
76+
// tracked.
77+
ErrServiceNotFound = fmt.Errorf("service not found")
78+
)
79+
6380
// Catalog is the in-memory + on-disk pattern store.
6481
//
6582
// All public methods are safe for concurrent use. Disk persistence is
@@ -299,6 +316,43 @@ func (c *Catalog) Delete(patternID string) bool {
299316
return true
300317
}
301318

319+
// Reset wipes the entire catalog — every learned pattern AND every discovered
320+
// service — and persists the empty catalog so training restarts from scratch
321+
// on the next tick. It returns the number of patterns and services that were
322+
// removed (the pre-reset view: fleet-wide when a CatalogStore is installed,
323+
// otherwise this instance's in-memory set).
324+
//
325+
// This is the whole-catalog counterpart to Delete/EndServiceGrace: when a
326+
// CatalogStore is installed the empty state routes through it (so a fleet-wide
327+
// read view is cleared, not just this instance's working set); otherwise the
328+
// inline whole-blob path writes an empty "patterns" blob.
329+
func (c *Catalog) Reset() (patterns int, services int, err error) {
330+
// Snapshot the pre-reset counts through the same read view callers see
331+
// (store-aware when installed) before clearing.
332+
patterns = len(c.All())
333+
services = len(c.AllServices())
334+
335+
c.mu.Lock()
336+
c.patterns = make(map[string]*Pattern)
337+
c.services = make(map[string]*ServiceInfo)
338+
c.dirty = true
339+
c.mu.Unlock()
340+
341+
if s := catalogStore(); s != nil {
342+
if err := s.Curate(CatalogEdit{Kind: CatalogEditReset}); err != nil {
343+
return patterns, services, err
344+
}
345+
c.mu.Lock()
346+
c.dirty = false
347+
c.mu.Unlock()
348+
return patterns, services, nil
349+
}
350+
if err := c.Persist(); err != nil {
351+
return patterns, services, err
352+
}
353+
return patterns, services, nil
354+
}
355+
302356
// Dirty reports whether there are unflushed changes.
303357
func (c *Catalog) Dirty() bool {
304358
c.mu.RLock()
@@ -443,3 +497,84 @@ func (c *Catalog) RestartServiceGrace(name string) bool {
443497
c.dirty = true
444498
return true
445499
}
500+
501+
// ---------------------------------------------------------------------------
502+
// Manual service CRUD — operator-curated services
503+
// ---------------------------------------------------------------------------
504+
505+
// Service returns a copy of one tracked service's info and whether it exists.
506+
// It reads the same unified view AllServices() serves (store-aware when a
507+
// CatalogStore is installed), so a manual service created on any instance is
508+
// visible fleet-wide.
509+
func (c *Catalog) Service(name string) (ServiceInfo, bool) {
510+
info, ok := c.AllServices()[name]
511+
return info, ok
512+
}
513+
514+
// CreateService records an operator-created (manual) service so it is
515+
// selectable as an override target before any signal is attributed to it. The
516+
// caller (admin controller) validates non-existence first — this is the write.
517+
// It routes through the CatalogStore when one is installed so the manual
518+
// service is fleet-visible; otherwise it writes the in-memory + blob path.
519+
func (c *Catalog) CreateService(name string) error {
520+
if s := catalogStore(); s != nil {
521+
return s.Curate(CatalogEdit{Kind: CatalogEditCreateService, Service: name})
522+
}
523+
c.mu.Lock()
524+
if _, ok := c.services[name]; ok {
525+
c.mu.Unlock()
526+
return ErrServiceExists
527+
}
528+
c.services[name] = &ServiceInfo{OrgID: storage.DefaultOrgID, FirstSeen: time.Now().UTC(), Manual: true}
529+
c.dirty = true
530+
c.mu.Unlock()
531+
// Persist immediately: a manual service has no signal to re-create it and
532+
// its override rules persist synchronously, so the two must not diverge.
533+
return c.Persist()
534+
}
535+
536+
// RenameService moves a service entry from oldName to newName, preserving its
537+
// FirstSeen and manual flag. The caller validates that oldName exists and
538+
// newName does not. Pattern attribution is not bulk-rewritten (a pattern's
539+
// Service is a historical label; future signals re-attribute); the admin
540+
// controller repoints override rules that target the old name so none dangle.
541+
func (c *Catalog) RenameService(oldName, newName string) error {
542+
if s := catalogStore(); s != nil {
543+
return s.Curate(CatalogEdit{Kind: CatalogEditRenameService, Service: oldName, NewService: newName})
544+
}
545+
c.mu.Lock()
546+
svc, ok := c.services[oldName]
547+
if !ok {
548+
c.mu.Unlock()
549+
return ErrServiceNotFound
550+
}
551+
if _, exists := c.services[newName]; exists {
552+
c.mu.Unlock()
553+
return ErrServiceExists
554+
}
555+
moved := *svc
556+
delete(c.services, oldName)
557+
c.services[newName] = &moved
558+
c.dirty = true
559+
c.mu.Unlock()
560+
return c.Persist()
561+
}
562+
563+
// DeleteService removes a tracked service entry. Returns false when it does not
564+
// exist. The admin controller gates this on the service being manual and on
565+
// having no override rules that target it, so a delete never orphans an
566+
// override.
567+
func (c *Catalog) DeleteService(name string) bool {
568+
if s := catalogStore(); s != nil {
569+
return s.Curate(CatalogEdit{Kind: CatalogEditDeleteService, Service: name}) == nil
570+
}
571+
c.mu.Lock()
572+
if _, ok := c.services[name]; !ok {
573+
c.mu.Unlock()
574+
return false
575+
}
576+
delete(c.services, name)
577+
c.dirty = true
578+
c.mu.Unlock()
579+
return c.Persist() == nil
580+
}

0 commit comments

Comments
 (0)