package ingest import ( "bufio" "context" "encoding/json" "errors" "fmt" "io" "os" "strings" "sync" "time" "github.com/local/glpi-neural-brain/internal/activity" "github.com/local/glpi-neural-brain/internal/graph" "github.com/local/glpi-neural-brain/internal/model" ) type AgentWatcher struct { Files []string Graph *graph.Store Broker *activity.Broker mu sync.Mutex offsets map[string]int64 } func NewAgentWatcher(files []string, g *graph.Store, b *activity.Broker) *AgentWatcher { return &AgentWatcher{Files: files, Graph: g, Broker: b, offsets: map[string]int64{}} } func (w *AgentWatcher) Start(ctx context.Context) { go func() { ticker := time.NewTicker(time.Second) defer ticker.Stop() for { w.poll() select { case <-ctx.Done(): return case <-ticker.C: } } }() } func (w *AgentWatcher) poll() { for _, path := range w.Files { _ = w.readNew(path) } } func (w *AgentWatcher) readNew(path string) error { f, err := os.Open(path) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } defer f.Close() w.mu.Lock() off := w.offsets[path] w.mu.Unlock() st, err := f.Stat() if err != nil { return err } if st.Size() < off { off = 0 } if _, err = f.Seek(off, io.SeekStart); err != nil { return err } sc := bufio.NewScanner(f) buf := make([]byte, 64*1024) sc.Buffer(buf, 8<<20) for sc.Scan() { line := append([]byte(nil), sc.Bytes()...) off += int64(len(sc.Bytes()) + 1) w.process(line) } if err := sc.Err(); err != nil { return err } w.mu.Lock() w.offsets[path] = off w.mu.Unlock() return nil } func (w *AgentWatcher) process(line []byte) { var run map[string]any if json.Unmarshal(line, &run) != nil { return } runID := str(run["run_id"]) ticket := str(run["ticket_id"]) trigger := str(run["trigger"]) outcome := str(run["outcome"]) analyses, _ := run["analyses"].([]any) nodeSet := map[string]bool{} var phases []string for _, raw := range analyses { a, _ := raw.(map[string]any) if a == nil { continue } phases = append(phases, str(a["analysis_type"])) collectExternalIDs(a, func(id string) { if n, ok := w.Graph.LookupExternal(id); ok { nodeSet[n.ID] = true } }) } var nodeIDs []string for id := range nodeSet { nodeIDs = append(nodeIDs, id) } edgeIDs := w.Graph.ConnectingEdges(nodeIDs) msg := fmt.Sprintf("Agent-Lauf %s · Ticket %s · %s", runID, ticket, outcome) if len(phases) > 0 { msg += " · " + strings.Join(phases, " → ") } w.Broker.Publish(model.Activity{Type: "agent.run", Source: "agent", Phase: trigger, Message: msg, NodeIDs: nodeIDs, EdgeIDs: edgeIDs, Strength: .9, Metadata: map[string]any{"run_id": runID, "ticket_id": ticket, "outcome": outcome}}) } func collectExternalIDs(v any, fn func(string)) { switch x := v.(type) { case map[string]any: for k, v := range x { lk := strings.ToLower(k) if strings.Contains(lk, "knowledge_id") || lk == "id" { s := str(v) if len(s) > 2 { fn(s) } } collectExternalIDs(v, fn) } case []any: for _, v := range x { collectExternalIDs(v, fn) } } } func str(v any) string { if v == nil { return "" } return strings.TrimSpace(fmt.Sprint(v)) }