feat(agent): add WebSocket transport with HTTP fallback

Add Transport interface abstraction supporting WebSocket (via CF
Durable Objects) and HTTP (direct to origin) with automatic failover.

- Transport interface: Register, SendHeartbeat, SendProgress, Events()
- HTTPTransport: thin adapter over existing Client
- WSTransport: gorilla/websocket with auth handshake, readLoop, reconnect
- HybridTransport: tries WS first, falls back to HTTP, reconnects in bg
- Daemon refactored to always use Transport (no dual-path forks)
- ProgressReporter accepts StatusReporter interface
- deriveWSURL skips localhost/dev (returns "" → HTTP-only)
- API key passed in WS query param for connection auth
- Fixed: reconnectOnce race (mutex+bool), authDone double-close (sync.Once)
- Fixed: forwardWSEvents goroutine leak (select with stop signal)
- 20 transport tests + 2 E2E tests (full lifecycle, hybrid failover)
This commit is contained in:
Deivid Soto 2026-03-28 18:55:29 +01:00
parent 5e80911501
commit 5f337eebd7
10 changed files with 1646 additions and 64 deletions

View file

@ -12,11 +12,17 @@ import (
// ActionFunc is called when the server signals an action on a task.
type ActionFunc func(taskID string)
// StatusReporter is the interface used by ProgressReporter to send progress updates.
// Both *agent.Client and agent.Transport implement this via their ReportStatus/SendProgress methods.
type StatusReporter interface {
ReportStatus(ctx context.Context, update agent.StatusUpdate) (*agent.StatusResponse, error)
}
// ProgressReporter aggregates progress from downloads and reports to the API.
// It batches updates to avoid flooding the server.
type ProgressReporter struct {
agentClient *agent.Client
interval time.Duration
reporter StatusReporter
interval time.Duration
onCancel ActionFunc
onPause ActionFunc
@ -28,14 +34,33 @@ type ProgressReporter struct {
}
// NewProgressReporter creates a reporter that flushes every interval.
// Accepts *agent.Client directly (backwards compatible).
func NewProgressReporter(ac *agent.Client, interval time.Duration) *ProgressReporter {
return &ProgressReporter{
agentClient: ac,
interval: interval,
latest: make(map[string]*Task),
reporter: ac,
interval: interval,
latest: make(map[string]*Task),
}
}
// NewProgressReporterWithTransport creates a reporter using a Transport.
func NewProgressReporterWithTransport(t agent.Transport, interval time.Duration) *ProgressReporter {
return &ProgressReporter{
reporter: &transportStatusAdapter{t: t},
interval: interval,
latest: make(map[string]*Task),
}
}
// transportStatusAdapter adapts agent.Transport to StatusReporter.
type transportStatusAdapter struct {
t agent.Transport
}
func (a *transportStatusAdapter) ReportStatus(ctx context.Context, update agent.StatusUpdate) (*agent.StatusResponse, error) {
return a.t.SendProgress(ctx, update)
}
// SetCancelHandler sets the callback invoked when the server says a task is cancelled.
func (r *ProgressReporter) SetCancelHandler(fn ActionFunc) { r.onCancel = fn }
@ -95,7 +120,7 @@ func (r *ProgressReporter) flush(ctx context.Context) {
}
update := task.ToStatusUpdate()
resp, err := r.agentClient.ReportStatus(ctx, update)
resp, err := r.reporter.ReportStatus(ctx, update)
if err != nil {
log.Printf("[%s] progress report failed: %v", task.ID[:8], err)
continue
@ -130,7 +155,7 @@ func (r *ProgressReporter) flush(ctx context.Context) {
// ReportFinal sends a final status update for a completed/failed task.
func (r *ProgressReporter) ReportFinal(ctx context.Context, task *Task) {
update := task.ToStatusUpdate()
if _, err := r.agentClient.ReportStatus(ctx, update); err != nil {
if _, err := r.reporter.ReportStatus(ctx, update); err != nil {
log.Printf("[%s] final report failed: %v", task.ID[:8], err)
}
r.Untrack(task.ID)