Files
kandev/backend/internal/agent/streaming/reader.go
T
jcfs ba919305c2 Initial commit: Kandev with SQLite persistence and agent integration
Features:
- Unified Go binary running Task Service, Agent Manager, and Orchestrator
- SQLite persistence for boards, columns, and tasks
- Docker-based agent execution with ACP message streaming
- Augment Agent integration with session resumption support
- RESTful API for all operations (port 8080)
- In-memory event bus for service communication

Architecture:
- cmd/kandev: Single entry point for all services
- internal/task: Task, board, and column management
- internal/agent: Docker container lifecycle and ACP streaming
- internal/orchestrator: Agent coordination and monitoring
- internal/events: In-memory pub/sub event bus

Documentation:
- README.md: Project overview and quick start
- docs/ARCHITECTURE.md: System architecture (current + planned)
- docs/GETTING_STARTED.md: Development setup guide
- docs/IMPLEMENTATION_PLAN.md: Original microservices plan
- backend/NEXT_STEPS.md: Development roadmap
2026-01-10 01:30:24 +00:00

240 lines
5.6 KiB
Go

// Package streaming provides ACP message streaming from agent containers.
package streaming
import (
"bufio"
"context"
"encoding/json"
"io"
"strings"
"sync"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/pkg/acp/protocol"
)
// StreamReader reads ACP messages from a container's output
type StreamReader struct {
instanceID string
taskID string
eventBus bus.EventBus
logger *logger.Logger
cancel context.CancelFunc
wg sync.WaitGroup
running bool
mu sync.Mutex
}
// NewStreamReader creates a new stream reader
func NewStreamReader(
instanceID string,
taskID string,
eventBus bus.EventBus,
log *logger.Logger,
) *StreamReader {
return &StreamReader{
instanceID: instanceID,
taskID: taskID,
eventBus: eventBus,
logger: log.WithFields(
zap.String("component", "stream-reader"),
zap.String("instance_id", instanceID),
zap.String("task_id", taskID),
),
}
}
// Start begins reading from the given reader (container logs)
func (r *StreamReader) Start(ctx context.Context, reader io.ReadCloser) error {
r.mu.Lock()
if r.running {
r.mu.Unlock()
return nil // Already running
}
r.running = true
// Create a cancellable context
ctx, r.cancel = context.WithCancel(ctx)
r.mu.Unlock()
r.logger.Info("starting stream reader")
r.wg.Add(1)
go r.readLoop(ctx, reader)
return nil
}
// Stop stops the stream reader
func (r *StreamReader) Stop() error {
r.mu.Lock()
if !r.running {
r.mu.Unlock()
return nil
}
if r.cancel != nil {
r.cancel()
}
r.mu.Unlock()
r.wg.Wait()
r.mu.Lock()
r.running = false
r.mu.Unlock()
r.logger.Info("stream reader stopped")
return nil
}
// IsRunning returns true if the reader is active
func (r *StreamReader) IsRunning() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.running
}
// readLoop reads lines from the reader and parses ACP messages
func (r *StreamReader) readLoop(ctx context.Context, reader io.ReadCloser) {
defer r.wg.Done()
defer reader.Close()
scanner := bufio.NewScanner(reader)
// Increase buffer size for potentially large JSON messages
buf := make([]byte, 64*1024)
scanner.Buffer(buf, 1024*1024)
for {
select {
case <-ctx.Done():
r.logger.Debug("read loop cancelled")
return
default:
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
r.logger.Error("scanner error", zap.Error(err))
} else {
r.logger.Debug("EOF reached")
}
return
}
line := scanner.Text()
r.processLine(ctx, line)
}
}
}
// processLine processes a single line of output
func (r *StreamReader) processLine(ctx context.Context, line string) {
// Skip empty lines
if len(line) == 0 {
return
}
// Handle Docker multiplexed output prefix (8 bytes header)
// Docker log format: [8-byte header][payload]
// The header contains stream type and size
cleanedLine := r.stripDockerHeader(line)
if len(cleanedLine) == 0 {
return
}
// Try to parse as JSON ACP message
msg, err := protocol.Parse([]byte(cleanedLine))
if err != nil {
// Not valid JSON, treat as regular log line
r.logger.Debug("non-ACP output", zap.String("line", cleanedLine))
return
}
// Check if it's a valid ACP message
if !msg.IsValid() {
r.logger.Debug("invalid ACP message (missing required fields)",
zap.String("line", cleanedLine))
return
}
// Publish the message
if err := r.publishMessage(ctx, msg); err != nil {
r.logger.Error("failed to publish ACP message",
zap.String("message_type", string(msg.Type)),
zap.Error(err))
}
}
// stripDockerHeader removes Docker multiplexed output header and timestamps if present
// Docker log format: [8-byte header][payload] or [timestamp][space][payload]
// Header format: [stream_type(1)][0(3)][size(4)]
// Timestamp format: 2026-01-10T00:42:29.114697835Z {json...}
func (r *StreamReader) stripDockerHeader(line string) string {
// Check if line starts with a Docker stream header
// Stream types: 0=stdin, 1=stdout, 2=stderr
if len(line) < 8 {
return line
}
// Check if first byte is a valid stream type (0, 1, or 2)
// and next 3 bytes are zeros
if (line[0] == 0 || line[0] == 1 || line[0] == 2) &&
line[1] == 0 && line[2] == 0 && line[3] == 0 {
// This looks like a Docker header, skip first 8 bytes
return line[8:]
}
// Handle Docker timestamp format: "2026-01-10T00:42:29.114697835Z {...}"
// The timestamp is typically 30-35 characters followed by a space and JSON
// Look for JSON start anywhere in the line (for timestamp-prefixed logs)
if idx := strings.Index(line, "{"); idx > 0 {
return line[idx:]
}
return line
}
// publishMessage publishes an ACP message to NATS
func (r *StreamReader) publishMessage(ctx context.Context, msg *protocol.Message) error {
if r.eventBus == nil {
return nil
}
// Build the event data from the ACP message
data := map[string]interface{}{
"type": string(msg.Type),
"timestamp": msg.Timestamp,
"agent_id": msg.AgentID,
"task_id": msg.TaskID,
"data": msg.Data,
}
// Create and publish event
event := bus.NewEvent(events.ACPMessage, "agent-manager", data)
subject := events.BuildACPSubject(r.taskID)
if err := r.eventBus.Publish(ctx, subject, event); err != nil {
return err
}
r.logger.Debug("published ACP message",
zap.String("subject", subject),
zap.String("message_type", string(msg.Type)))
return nil
}
// marshalMessage marshals an ACP message for logging/debugging
func (r *StreamReader) marshalMessage(msg *protocol.Message) string {
data, err := json.Marshal(msg)
if err != nil {
return ""
}
return string(data)
}