ba919305c2
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
240 lines
5.6 KiB
Go
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)
|
|
}
|
|
|