Files
kandev/apps/backend/internal/agentctl/client/client_stream.go
T
2026-02-20 17:30:14 +00:00

120 lines
3.2 KiB
Go

package client
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/kandev/kandev/internal/agentctl/tracing"
ws "github.com/kandev/kandev/pkg/websocket"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
)
// sendStreamRequest sends a request over the agent WebSocket stream and waits for a response.
// It creates a ws.Message with a UUID, registers a pending response channel,
// writes the message to the stream, and blocks until a response arrives or context is cancelled.
func (c *Client) sendStreamRequest(ctx context.Context, action string, payload interface{}) (*ws.Message, error) {
c.mu.RLock()
conn := c.agentStreamConn
c.mu.RUnlock()
if conn == nil {
return nil, fmt.Errorf("agent stream not connected")
}
reqID := uuid.New().String()
// Start tracing span for the request/response round-trip
ctx, span := tracing.TraceWSRequest(ctx, action, reqID, c.executionID, c.sessionID)
defer span.End()
msg, err := ws.NewRequest(reqID, action, payload)
if err != nil {
tracing.TraceWSResponse(span, "", err)
return nil, fmt.Errorf("failed to create request message: %w", err)
}
// Inject trace context (traceparent) into WS message metadata for cross-process propagation
otel.GetTextMapPropagator().Inject(ctx, propagation.MapCarrier(msg.EnsureMetadata()))
// Register pending request
respCh := make(chan *ws.Message, 1)
c.pendingMu.Lock()
c.pendingRequests[reqID] = respCh
c.pendingMu.Unlock()
// Clean up on exit
defer func() {
c.pendingMu.Lock()
delete(c.pendingRequests, reqID)
c.pendingMu.Unlock()
}()
// Serialize write to stream
data, err := json.Marshal(msg)
if err != nil {
tracing.TraceWSResponse(span, "", err)
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
c.streamWriteMu.Lock()
writeErr := conn.WriteMessage(websocket.TextMessage, data)
c.streamWriteMu.Unlock()
if writeErr != nil {
tracing.TraceWSResponse(span, "", writeErr)
return nil, fmt.Errorf("failed to write request to stream: %w", writeErr)
}
// Wait for response or context cancellation
select {
case resp := <-respCh:
if resp == nil {
disconnErr := fmt.Errorf("agent stream disconnected while waiting for response")
tracing.TraceWSResponse(span, "", disconnErr)
return nil, disconnErr
}
tracing.TraceWSResponse(span, string(resp.Type), nil)
return resp, nil
case <-ctx.Done():
tracing.TraceWSResponse(span, "", ctx.Err())
return nil, ctx.Err()
}
}
// resolvePendingRequest matches a response message to a pending request by ID.
// Returns true if the message was matched to a pending request.
func (c *Client) resolvePendingRequest(msg *ws.Message) bool {
if msg.ID == "" {
return false
}
c.pendingMu.Lock()
ch, ok := c.pendingRequests[msg.ID]
c.pendingMu.Unlock()
if !ok {
return false
}
// Send response to waiting caller (non-blocking since channel is buffered)
select {
case ch <- msg:
default:
}
return true
}
// cleanupPendingRequests unblocks all pending requests with nil (signaling disconnect).
func (c *Client) cleanupPendingRequests() {
c.pendingMu.Lock()
defer c.pendingMu.Unlock()
for id, ch := range c.pendingRequests {
close(ch)
delete(c.pendingRequests, id)
}
}