molecule-core/workspace-server/internal/events/broadcaster.go
Hongming Wang 7d48f24fef test(handlers): introduce events.EventEmitter interface (#1814 partial)
The 3 skipped tests in workspace_provision_test.go (#1206 regression
tests) were blocked because captureBroadcaster's struct-embed wouldn't
type-check against WorkspaceHandler.broadcaster's concrete
*events.Broadcaster field. This PR fixes the interface blocker for
the 2 broadcaster-related tests; the 3rd (plugins.Registry resolver)
is a separate blocker tracked elsewhere.

Changes:

- internal/events/broadcaster.go: define `EventEmitter` interface with
  RecordAndBroadcast + BroadcastOnly. *Broadcaster satisfies it via
  its existing methods (compile-time assertion guards future drift).
  SubscribeSSE / Subscribe stay off the interface because only sse.go
  + cmd/server/main.go call them, and both still hold the concrete
  *Broadcaster.

- internal/handlers/workspace.go: WorkspaceHandler.broadcaster type
  changes from *events.Broadcaster to events.EventEmitter.
  NewWorkspaceHandler signature updated to match. Production callers
  unchanged — they pass *events.Broadcaster, which the interface
  accepts.

- internal/handlers/activity.go: LogActivity takes events.EventEmitter
  for the same reason — tests passing a stub no longer need to
  construct the full broadcaster.

- internal/handlers/workspace_provision_test.go: captureBroadcaster
  drops the struct embed (no more zero-value Broadcaster underlying
  the SSE+hub fields), implements RecordAndBroadcast directly, and
  adds a no-op BroadcastOnly to satisfy the interface. Skip messages
  on the 2 empty broadcaster-blocked tests updated to reflect the
  new "interface unblocked, test body still needed" state.

Verified `go build ./...`, `go test ./internal/handlers/`, and
`go vet ./...` all clean.

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

187 lines
5.5 KiB
Go

package events
import (
"context"
"encoding/json"
"log"
"sync"
"time"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
"github.com/Molecule-AI/molecule-monorepo/platform/internal/ws"
"github.com/redis/go-redis/v9"
)
const broadcastChannel = "events:broadcast"
// EventEmitter is the contract handler code needs from a broadcaster.
// Defining it here lets tests substitute a capture-only stub instead
// of standing up the full Redis + WebSocket hub topology that the
// concrete *Broadcaster builds (and that previously blocked
// TestProvisionWorkspace_* regression tests on issue #1814).
//
// Includes BroadcastOnly because the activity-log + A2A-response paths
// inside the handler package fan out via that method — narrowing
// further would force production callers back to the concrete type.
//
// *Broadcaster satisfies this interface trivially. Production code that
// needs the wider surface (SubscribeSSE, Subscribe) keeps using the
// concrete *Broadcaster type — sse.go + cmd/server/main.go are the
// only such call sites today.
type EventEmitter interface {
RecordAndBroadcast(ctx context.Context, eventType string, workspaceID string, payload interface{}) error
BroadcastOnly(workspaceID string, eventType string, payload interface{})
}
// Compile-time assertion: a renamed/reshaped Broadcaster method that
// silently broke this interface would fail to build here.
var _ EventEmitter = (*Broadcaster)(nil)
// sseSubscription is a single in-process SSE subscriber.
// deliverToSSE writes to ch; StreamEvents reads from it.
type sseSubscription struct {
workspaceID string
ch chan models.WSMessage
}
type Broadcaster struct {
hub *ws.Hub
ssesMu sync.RWMutex
sses []*sseSubscription
}
func NewBroadcaster(hub *ws.Hub) *Broadcaster {
return &Broadcaster{hub: hub}
}
// RecordAndBroadcast inserts a structure event into Postgres and publishes to Redis pub/sub.
func (b *Broadcaster) RecordAndBroadcast(ctx context.Context, eventType string, workspaceID string, payload interface{}) error {
payloadJSON, err := json.Marshal(payload)
if err != nil {
return err
}
// Insert into structure_events — cast to jsonb explicitly
_, err = db.DB.ExecContext(ctx, `
INSERT INTO structure_events (event_type, workspace_id, payload)
VALUES ($1, $2, $3::jsonb)
`, eventType, workspaceID, string(payloadJSON))
if err != nil {
log.Printf("RecordAndBroadcast: insert event error: %v", err)
return err
}
// Build WebSocket message
msg := models.WSMessage{
Event: eventType,
WorkspaceID: workspaceID,
Timestamp: time.Now().UTC(),
Payload: payloadJSON,
}
// Publish to Redis pub/sub for multi-instance support
msgJSON, err := json.Marshal(msg)
if err != nil {
return err
}
if err := db.RDB.Publish(ctx, broadcastChannel, msgJSON).Err(); err != nil {
log.Printf("Warning: Redis publish failed: %v", err)
}
// Broadcast to local WebSocket clients
b.hub.Broadcast(msg)
// Fan out to in-process SSE subscribers (e.g. GET /events/stream).
b.deliverToSSE(msg)
return nil
}
// BroadcastOnly sends a WebSocket event without recording in structure_events.
// Used for high-frequency events like activity logs that have their own table.
func (b *Broadcaster) BroadcastOnly(workspaceID string, eventType string, payload interface{}) {
payloadJSON, err := json.Marshal(payload)
if err != nil {
log.Printf("BroadcastOnly: marshal error: %v", err)
return
}
msg := models.WSMessage{
Event: eventType,
WorkspaceID: workspaceID,
Timestamp: time.Now().UTC(),
Payload: payloadJSON,
}
b.hub.Broadcast(msg)
// Fan out to in-process SSE subscribers.
b.deliverToSSE(msg)
}
// SubscribeSSE registers a per-workspace in-process channel for SSE streaming.
// The caller MUST invoke the returned cancel func when it disconnects so the
// subscription is removed and the channel is not leaked.
func (b *Broadcaster) SubscribeSSE(workspaceID string) (<-chan models.WSMessage, func()) {
sub := &sseSubscription{
workspaceID: workspaceID,
ch: make(chan models.WSMessage, 64),
}
b.ssesMu.Lock()
b.sses = append(b.sses, sub)
b.ssesMu.Unlock()
cancel := func() {
b.ssesMu.Lock()
defer b.ssesMu.Unlock()
for i, s := range b.sses {
if s == sub {
b.sses = append(b.sses[:i], b.sses[i+1:]...)
break
}
}
}
return sub.ch, cancel
}
// deliverToSSE fans msg out to every in-process SSE subscriber watching the
// same workspace. Non-blocking: if a subscriber's buffer is full the event is
// dropped with a log line (the WebSocket path still delivers it).
func (b *Broadcaster) deliverToSSE(msg models.WSMessage) {
b.ssesMu.RLock()
defer b.ssesMu.RUnlock()
for _, s := range b.sses {
if s.workspaceID != msg.WorkspaceID {
continue
}
select {
case s.ch <- msg:
default:
log.Printf("SSE: subscriber buffer full for workspace %s, dropping event %s", msg.WorkspaceID, msg.Event)
}
}
}
// Subscribe listens to Redis pub/sub and relays events to the WebSocket hub.
func (b *Broadcaster) Subscribe(ctx context.Context) {
sub := db.RDB.Subscribe(ctx, broadcastChannel)
ch := sub.Channel(redis.WithChannelHealthCheckInterval(30 * time.Second))
log.Println("Subscribed to Redis broadcast channel")
for {
select {
case <-ctx.Done():
sub.Close()
return
case redisMsg := <-ch:
if redisMsg == nil {
continue
}
// In single-instance mode, RecordAndBroadcast already calls hub.Broadcast().
// This subscriber becomes relevant in multi-instance deployments.
_ = redisMsg
}
}
}