// Package logs owns per-run flat-file logs and their live SSE fan-out. // A single Writer serialises writes for one run; a Hub keeps a cache // per run so handlers can open/close freely without stepping on each // other. Lines go to disk for persistence (reload + replay) and onto // the events.Hub so the UI tile can tail live. package logs import ( "fmt" "html" "log" "os" "path/filepath" "strings" "sync" "time" "vetting/internal/events" ) type Line struct { TS time.Time Level string // info|warn|error|debug Text string } type Writer struct { runID int64 mu sync.Mutex f *os.File hub *events.Hub } // Hub owns the per-run Writers. The orchestrator creates one Hub at // startup and hands it to the api package. type Hub struct { dir string events *events.Hub mu sync.Mutex writers map[int64]*Writer } func NewHub(dir string, ev *events.Hub) (*Hub, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("mkdir log dir: %w", err) } return &Hub{dir: dir, events: ev, writers: map[int64]*Writer{}}, nil } // WriterFor returns a cached Writer, opening the file lazily. The file // is append-only; if an existing run's log is reopened (e.g. after a // restart) we append rather than truncate so nothing is lost. func (h *Hub) WriterFor(runID int64) (*Writer, error) { h.mu.Lock() defer h.mu.Unlock() if w, ok := h.writers[runID]; ok { return w, nil } path := filepath.Join(h.dir, fmt.Sprintf("run-%d.log", runID)) f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return nil, fmt.Errorf("open %s: %w", path, err) } w := &Writer{runID: runID, f: f, hub: h.events} h.writers[runID] = w return w, nil } // Close flushes and closes all open run files. Called from main on // shutdown so the logs aren't left with buffered data. func (h *Hub) Close() { h.mu.Lock() defer h.mu.Unlock() for id, w := range h.writers { if err := w.Close(); err != nil { log.Printf("logs: close run-%d: %v", id, err) } } h.writers = nil } // PathFor returns the on-disk path for a run's log; used by replay // handlers and the report generator. func (h *Hub) PathFor(runID int64) string { return filepath.Join(h.dir, fmt.Sprintf("run-%d.log", runID)) } // Append writes a line to disk and publishes an SSE event. Failures // on disk log but don't block the SSE fan-out — the operator can still // see the live tail even if disk IO is degraded. func (w *Writer) Append(line Line) { w.mu.Lock() defer w.mu.Unlock() if line.TS.IsZero() { line.TS = time.Now().UTC() } if line.Level == "" { line.Level = "info" } stamped := fmt.Sprintf("%s %5s %s\n", line.TS.Format(time.RFC3339Nano), strings.ToUpper(line.Level), line.Text) if _, err := w.f.WriteString(stamped); err != nil { log.Printf("logs: write run-%d: %v", w.runID, err) } if w.hub != nil { w.hub.Publish(events.Event{ Name: fmt.Sprintf("log-%d", w.runID), Payload: renderLogSSE(line), }) } } func (w *Writer) Close() error { w.mu.Lock() defer w.mu.Unlock() if w.f == nil { return nil } err := w.f.Close() w.f = nil return err } // renderLogSSE returns an HTMX-compatible fragment. The tile contains // a
: each event appends one //
to it. func renderLogSSE(l Line) string { level := strings.ToLower(l.Level) return fmt.Sprintf( `
%s %s
`, html.EscapeString(level), html.EscapeString(l.TS.Format("15:04:05")), html.EscapeString(l.Text), ) }