rdev/internal/handlers/builds_ws.go
jordan c59d348040 chore: prepare for composable monorepo template implementation
This commit captures the current state before implementing the composable
monorepo template system. Key changes included:

Infrastructure:
- Add CockroachDB provisioner adapter for database provisioning
- Add Redis provisioner adapter for cache provisioning
- Add build events system with PostgreSQL storage
- Add WebSocket endpoint for real-time build progress

Code agent improvements:
- Fix Claude Code adapter to use default allowed tools instead of dangerously-skip-permissions
- Add context-aware stream closing for cancellation support
- Improve parser tests for edge cases

Build system:
- Add build event constants and metrics
- Remove deprecated git_operations.go (replaced by pod_git_operations.go)
- Add rollback logic for multi-step provisioning operations

Documentation:
- Add composable-monorepo feature documentation
- Add DNS/Cloudflare service documentation
- Update deployment and troubleshooting guides

Cookbooks:
- Add fullstack-app cookbook
- Refactor landing-test with shared library

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 11:39:28 -07:00

155 lines
3.7 KiB
Go

package handlers
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/gorilla/websocket"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/pkg/api"
)
// BuildsWSHandler handles WebSocket connections for build event streaming.
type BuildsWSHandler struct {
streams port.StreamPublisher
upgrader websocket.Upgrader
}
// NewBuildsWSHandler creates a new WebSocket handler for builds.
func NewBuildsWSHandler(streams port.StreamPublisher) *BuildsWSHandler {
return &BuildsWSHandler{
streams: streams,
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins (configure for production)
},
},
}
}
// Mount registers the WebSocket routes.
func (h *BuildsWSHandler) Mount(r api.Router) {
r.With(auth.RequireScope(auth.ScopeBuildRead, auth.ScopeAdmin)).
Get("/builds/{taskId}/ws", h.StreamEvents)
}
// wsMessage is the structure sent over WebSocket.
type wsMessage struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
TaskID string `json:"task_id,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
Data map[string]any `json:"data,omitempty"`
}
// StreamEvents handles WebSocket connections for streaming build events.
// GET /builds/{taskId}/ws
func (h *BuildsWSHandler) StreamEvents(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskId")
if taskID == "" {
api.WriteBadRequest(w, r, "task ID is required")
return
}
// Get optional last event ID from query string
lastEventID := r.URL.Query().Get("last_event_id")
// Upgrade to WebSocket
conn, err := h.upgrader.Upgrade(w, r, nil)
if err != nil {
// Upgrade already wrote error response
return
}
defer func() { _ = conn.Close() }()
// Subscribe to events
var events <-chan port.StreamEvent
var cleanup func()
if lastEventID != "" {
events, cleanup = h.streams.SubscribeFromID(taskID, lastEventID)
} else {
events, cleanup = h.streams.Subscribe(taskID)
}
defer cleanup()
// Send connected message
_ = conn.WriteJSON(wsMessage{
Type: "connected",
TaskID: taskID,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Data: map[string]any{
"reconnecting": lastEventID != "",
},
})
// Set up ping/pong for keepalive
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(60 * time.Second))
})
// Start a goroutine to read from WebSocket (for close detection)
done := make(chan struct{})
go func() {
defer close(done)
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
}
}()
// Stream events
pingTicker := time.NewTicker(30 * time.Second)
defer pingTicker.Stop()
for {
select {
case <-done:
// Client disconnected
return
case event, ok := <-events:
if !ok {
// Stream closed
_ = conn.WriteJSON(wsMessage{
Type: "stream_closed",
TaskID: taskID,
Timestamp: time.Now().UTC().Format(time.RFC3339),
})
return
}
// Convert port.StreamEvent to wsMessage
msg := wsMessage{
ID: event.ID,
Type: event.Type,
TaskID: event.TaskID,
Timestamp: event.Timestamp.Format(time.RFC3339),
Data: event.Data,
}
if err := conn.WriteJSON(msg); err != nil {
return // Write error, close connection
}
// Check for terminal events
if event.Type == "build.completed" || event.Type == "build.failed" {
// Give client time to process final message
time.Sleep(100 * time.Millisecond)
return
}
case <-pingTicker.C:
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}