Files
bvandeusen f7dfeff256 feat(#392): scan run lifecycle events on the SSE bus
Slice 3c — completes the server-side producer set.

Publishes scan.run_started when InsertScanRun succeeds and
scan.run_finished after FinishScanRun completes (success or with an
error_message payload). Per-file progress events are intentionally NOT
emitted — they'd flood the stream during large library scans without
giving the admin scan card anything actionable. The two-beat signal
gives the UI enough to invalidate scanStatusProvider at the right
moments.

Bus access uses a package-level setter on internal/library because
threading bus through RunScan + TryStartScan + Scheduler + all their
callers would touch ~10 sites without changing behavior at the boundaries
that don't publish. Per-process singleton, matches the log.SetDefault
idiom. cmd/minstrel/main.go calls library.SetEventBus(bus) once at
startup; test contexts that never call it skip publishing safely
(publishScanEvent is a no-op when bus is nil).

This completes the server side of #392. Slice 4 wires the Flutter
consumer: live_events_provider.dart (StreamProvider) +
live_events_dispatcher.dart (event-kind → provider invalidation)
+ AppLifecycleState cold-start invalidation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:52:06 -04:00

82 lines
2.3 KiB
Go

package library
import (
"sync"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
)
// Package-level bus pointer for scan lifecycle events (#392). Set once
// at startup via SetEventBus; reads are nil-safe so test contexts that
// never call SetEventBus simply skip publishing.
//
// A package-global is the right shape here because:
//
// - There is exactly one bus per process (the same instance powers
// the SSE subscriber endpoint and every publisher across the codebase).
// - Threading it through RunScan + TryStartScan + the Scheduler +
// every test caller would touch ~10 call sites without changing
// behavior at the boundaries that don't care.
// - Other Go stdlib packages use the same pattern (log.SetDefault,
// etc.) for process-wide singletons.
var (
busMu sync.RWMutex
bus *eventbus.Bus
)
// SetEventBus wires the live-event bus into the library package so scan
// runs emit scan.run_started / scan.run_finished events. Call once during
// process startup; subsequent calls overwrite the previous bus.
func SetEventBus(b *eventbus.Bus) {
busMu.Lock()
defer busMu.Unlock()
bus = b
}
// publishScanEvent broadcasts a scan lifecycle event. No-op when no bus
// has been set. Scan events are always broadcast (empty UserID) because
// only admins see the scan status card; non-admin clients' invalidation
// dispatcher will receive the event but won't have any provider keyed on
// scan status, so the receive is harmless.
func publishScanEvent(kind string, runID pgtype.UUID, data map[string]any) {
busMu.RLock()
b := bus
busMu.RUnlock()
if b == nil {
return
}
payload := map[string]any{"run_id": uuidToBusString(runID)}
for k, v := range data {
payload[k] = v
}
b.Publish(eventbus.Event{
Kind: kind,
UserID: "", // broadcast
Data: payload,
})
}
// uuidToBusString renders a pgtype.UUID in canonical 8-4-4-4-12 form.
// Inlined to avoid a back-edge dependency on internal/api or another
// helper-bearing package.
func uuidToBusString(u pgtype.UUID) string {
if !u.Valid {
return ""
}
const hex = "0123456789abcdef"
out := make([]byte, 36)
pos := 0
for i, b := range u.Bytes {
if i == 4 || i == 6 || i == 8 || i == 10 {
out[pos] = '-'
pos++
}
out[pos] = hex[b>>4]
out[pos+1] = hex[b&0xf]
pos += 2
}
return string(out)
}