chore: Remove dead code and consolidate agentctl modes (#17)
This commit is contained in:
@@ -128,7 +128,8 @@ func containsHelper(s, substr string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// runDockerMode runs agentctl in Docker mode (single instance, same as legacy behavior)
|
||||
// runDockerMode runs agentctl in Docker mode (single instance)
|
||||
// Uses the unified ControlServer with embedded process manager
|
||||
func runDockerMode(cfg *config.MultiConfig, log *logger.Logger) {
|
||||
// Load full config from environment (including AutoApprovePermissions)
|
||||
singleCfg := config.Load()
|
||||
@@ -158,14 +159,14 @@ func runDockerMode(cfg *config.MultiConfig, log *logger.Logger) {
|
||||
// Create process manager
|
||||
procMgr := process.NewManager(singleCfg, log)
|
||||
|
||||
// Create HTTP API server
|
||||
server := api.NewServer(singleCfg, procMgr, log)
|
||||
// Create unified ControlServer with embedded single-instance support
|
||||
controlServer := api.NewSingleInstanceServer(cfg, singleCfg, procMgr, log)
|
||||
|
||||
// Start HTTP server
|
||||
addr := fmt.Sprintf(":%d", singleCfg.Port)
|
||||
httpServer := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: server.Router(),
|
||||
Handler: controlServer.Router(),
|
||||
}
|
||||
|
||||
go func() {
|
||||
|
||||
@@ -359,7 +359,7 @@ func main() {
|
||||
}
|
||||
|
||||
serviceCfg := orchestrator.DefaultServiceConfig()
|
||||
orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, nil, agentManagerClient, taskRepoAdapter, taskRepo, log)
|
||||
orchestratorSvc := orchestrator.NewService(serviceCfg, eventBus, agentManagerClient, taskRepoAdapter, taskRepo, log)
|
||||
|
||||
// Set up comment creator for saving agent responses as comments
|
||||
orchestratorSvc.SetCommentCreator(&commentCreatorAdapter{svc: taskSvc})
|
||||
@@ -1040,71 +1040,4 @@ func (a *commentCreatorAdapter) UpdateToolCallComment(ctx context.Context, taskI
|
||||
return a.svc.UpdateToolCallComment(ctx, taskID, toolCallID, status, result)
|
||||
}
|
||||
|
||||
// mapACPTypeToCommentType maps ACP message types to comment types
|
||||
func mapACPTypeToCommentType(msgType protocol.MessageType) string {
|
||||
switch msgType {
|
||||
case protocol.MessageTypeProgress:
|
||||
return "progress"
|
||||
case protocol.MessageTypeLog:
|
||||
return "content" // Log messages are treated as content
|
||||
case protocol.MessageTypeResult:
|
||||
return "status"
|
||||
case protocol.MessageTypeError:
|
||||
return "error"
|
||||
case protocol.MessageTypeStatus:
|
||||
return "status"
|
||||
case protocol.MessageTypeInputRequired:
|
||||
return "content"
|
||||
case protocol.MessageTypeSessionInfo:
|
||||
return "status"
|
||||
default:
|
||||
return "content"
|
||||
}
|
||||
}
|
||||
|
||||
// extractACPContent extracts a human-readable message from ACP data
|
||||
func extractACPContent(msg *protocol.Message) string {
|
||||
if msg.Data == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check for nested content.text structure (used by agent_message_chunk)
|
||||
if content, ok := msg.Data["content"].(map[string]interface{}); ok {
|
||||
if text, ok := content["text"].(string); ok && text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
// Try common message fields
|
||||
if message, ok := msg.Data["message"].(string); ok && message != "" {
|
||||
return message
|
||||
}
|
||||
if text, ok := msg.Data["text"].(string); ok && text != "" {
|
||||
return text
|
||||
}
|
||||
if errMsg, ok := msg.Data["error"].(string); ok && errMsg != "" {
|
||||
return errMsg
|
||||
}
|
||||
if summary, ok := msg.Data["summary"].(string); ok && summary != "" {
|
||||
return summary
|
||||
}
|
||||
if prompt, ok := msg.Data["prompt"].(string); ok && prompt != "" {
|
||||
return prompt
|
||||
}
|
||||
|
||||
// For progress updates, generate a message
|
||||
if msg.Type == protocol.MessageTypeProgress {
|
||||
if progress, ok := msg.Data["progress"].(float64); ok {
|
||||
return fmt.Sprintf("Progress: %d%%", int(progress))
|
||||
}
|
||||
}
|
||||
|
||||
// For status updates
|
||||
if msg.Type == protocol.MessageTypeStatus {
|
||||
if status, ok := msg.Data["status"].(string); ok && status != "" {
|
||||
return fmt.Sprintf("Status: %s", status)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,599 +0,0 @@
|
||||
// Package acp provides ACP (Agent Client Protocol) session management
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/pkg/acp/jsonrpc"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Session represents an active ACP session with an agent
|
||||
type Session struct {
|
||||
InstanceID string
|
||||
TaskID string
|
||||
SessionID string // ACP session ID (from agent)
|
||||
Client *jsonrpc.Client
|
||||
Stdin io.WriteCloser
|
||||
Stdout io.Reader
|
||||
CreatedAt time.Time
|
||||
Status string // initializing, ready, prompting, complete, error
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// PendingPermission represents a permission request waiting for user response
|
||||
type PendingPermission struct {
|
||||
ID string // Unique ID for this pending request
|
||||
RPCID interface{} // JSON-RPC request ID from agent
|
||||
InstanceID string // Agent instance
|
||||
TaskID string // Task being worked on
|
||||
SessionID string // ACP session ID
|
||||
ToolCallID string // Tool call requesting permission
|
||||
Title string // Human-readable title
|
||||
Options []jsonrpc.PermissionOption // Available choices
|
||||
ResponseCh chan *PermissionResponseMsg // Channel to receive user response
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// PermissionResponseMsg is the message sent by the user in response to a permission request
|
||||
type PermissionResponseMsg struct {
|
||||
OptionID string
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// PermissionRequestHandler is called when an agent requests permission from the user
|
||||
type PermissionRequestHandler func(req *PendingPermission)
|
||||
|
||||
// UpdateHandler is called when the agent sends session updates
|
||||
type UpdateHandler func(instanceID, taskID string, updateType string, data json.RawMessage)
|
||||
|
||||
// SessionManager manages ACP sessions for multiple agents
|
||||
type SessionManager struct {
|
||||
sessions map[string]*Session // by instance ID
|
||||
mu sync.RWMutex
|
||||
eventBus bus.EventBus
|
||||
logger *logger.Logger
|
||||
|
||||
// Handler for session updates
|
||||
updateHandler UpdateHandler
|
||||
|
||||
// Handler for permission requests
|
||||
permissionHandler PermissionRequestHandler
|
||||
|
||||
// Pending permission requests waiting for user response
|
||||
pendingPermissions map[string]*PendingPermission
|
||||
permissionMu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewSessionManager creates a new session manager
|
||||
func NewSessionManager(eventBus bus.EventBus, log *logger.Logger) *SessionManager {
|
||||
return &SessionManager{
|
||||
sessions: make(map[string]*Session),
|
||||
pendingPermissions: make(map[string]*PendingPermission),
|
||||
eventBus: eventBus,
|
||||
logger: log.WithFields(zap.String("component", "acp-session-manager")),
|
||||
}
|
||||
}
|
||||
|
||||
// SetUpdateHandler sets the handler for session updates
|
||||
func (m *SessionManager) SetUpdateHandler(handler UpdateHandler) {
|
||||
m.updateHandler = handler
|
||||
}
|
||||
|
||||
// SetPermissionHandler sets the handler for permission requests from agents
|
||||
func (m *SessionManager) SetPermissionHandler(handler PermissionRequestHandler) {
|
||||
m.permissionHandler = handler
|
||||
}
|
||||
|
||||
// CreateSession creates a new ACP session for an agent instance
|
||||
func (m *SessionManager) CreateSession(ctx context.Context, instanceID, taskID string, stdin io.WriteCloser, stdout io.Reader) error {
|
||||
m.logger.Info("Creating ACP session",
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.String("task_id", taskID))
|
||||
|
||||
// Create JSON-RPC client
|
||||
client := jsonrpc.NewClient(stdin, stdout, m.logger)
|
||||
|
||||
session := &Session{
|
||||
InstanceID: instanceID,
|
||||
TaskID: taskID,
|
||||
Client: client,
|
||||
Stdin: stdin,
|
||||
Stdout: stdout,
|
||||
CreatedAt: time.Now(),
|
||||
Status: "initializing",
|
||||
}
|
||||
|
||||
// Set notification handler
|
||||
client.SetNotificationHandler(func(method string, params json.RawMessage) {
|
||||
m.handleNotification(session, method, params)
|
||||
})
|
||||
|
||||
// Set request handler for agent-to-client requests (like session/request_permission)
|
||||
client.SetRequestHandler(func(id interface{}, method string, params json.RawMessage) {
|
||||
m.handleRequest(session, id, method, params)
|
||||
})
|
||||
|
||||
// Start the client read loop
|
||||
client.Start(ctx)
|
||||
|
||||
// Store session
|
||||
m.mu.Lock()
|
||||
m.sessions[instanceID] = session
|
||||
m.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize performs the ACP initialize handshake
|
||||
func (m *SessionManager) Initialize(ctx context.Context, instanceID string) error {
|
||||
session, err := m.getSession(instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.logger.Info("Initializing ACP session", zap.String("instance_id", instanceID))
|
||||
|
||||
params := jsonrpc.InitializeParams{
|
||||
ProtocolVersion: 1,
|
||||
ClientInfo: jsonrpc.ClientInfo{
|
||||
Name: "kandev",
|
||||
Version: "0.1.0",
|
||||
},
|
||||
Capabilities: jsonrpc.ClientCapabilities{
|
||||
Streaming: true,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := session.Client.Call(ctx, jsonrpc.MethodInitialize, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("initialize failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.Error != nil {
|
||||
return fmt.Errorf("initialize error: %s (code %d)", resp.Error.Message, resp.Error.Code)
|
||||
}
|
||||
|
||||
session.mu.Lock()
|
||||
session.Status = "ready"
|
||||
session.mu.Unlock()
|
||||
|
||||
m.logger.Info("ACP session initialized", zap.String("instance_id", instanceID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSession creates a new ACP session (session/new)
|
||||
func (m *SessionManager) NewSession(ctx context.Context, instanceID, cwd string) (string, error) {
|
||||
session, err := m.getSession(instanceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
m.logger.Info("Creating new ACP session",
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.String("cwd", cwd))
|
||||
|
||||
params := jsonrpc.SessionNewParams{
|
||||
Cwd: cwd,
|
||||
McpServers: []jsonrpc.McpServer{}, // Empty array, no MCP servers for now
|
||||
}
|
||||
|
||||
resp, err := session.Client.Call(ctx, jsonrpc.MethodSessionNew, params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("session/new failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.Error != nil {
|
||||
return "", fmt.Errorf("session/new error: %s", resp.Error.Message)
|
||||
}
|
||||
|
||||
var result jsonrpc.SessionNewResult
|
||||
if err := json.Unmarshal(resp.Result, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse session/new result: %w", err)
|
||||
}
|
||||
|
||||
session.mu.Lock()
|
||||
session.SessionID = result.SessionID
|
||||
session.mu.Unlock()
|
||||
|
||||
m.logger.Info("ACP session created",
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.String("session_id", result.SessionID))
|
||||
|
||||
return result.SessionID, nil
|
||||
}
|
||||
|
||||
// LoadSession resumes an existing ACP session (session/load)
|
||||
func (m *SessionManager) LoadSession(ctx context.Context, instanceID, sessionID string) error {
|
||||
session, err := m.getSession(instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.logger.Info("Loading ACP session",
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.String("session_id", sessionID))
|
||||
|
||||
params := jsonrpc.SessionLoadParams{
|
||||
SessionID: sessionID,
|
||||
}
|
||||
|
||||
resp, err := session.Client.Call(ctx, jsonrpc.MethodSessionLoad, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("session/load failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.Error != nil {
|
||||
return fmt.Errorf("session/load error: %s", resp.Error.Message)
|
||||
}
|
||||
|
||||
session.mu.Lock()
|
||||
session.SessionID = sessionID
|
||||
session.mu.Unlock()
|
||||
|
||||
m.logger.Info("ACP session loaded", zap.String("instance_id", instanceID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prompt sends a prompt to the agent (session/prompt)
|
||||
func (m *SessionManager) Prompt(ctx context.Context, instanceID, message string) error {
|
||||
session, err := m.getSession(instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.mu.RLock()
|
||||
sessionID := session.SessionID
|
||||
session.mu.RUnlock()
|
||||
|
||||
if sessionID == "" {
|
||||
return fmt.Errorf("no session ID available, call NewSession first")
|
||||
}
|
||||
|
||||
m.logger.Info("Sending prompt to agent",
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Int("message_length", len(message)))
|
||||
|
||||
session.mu.Lock()
|
||||
session.Status = "prompting"
|
||||
session.mu.Unlock()
|
||||
|
||||
// ACP protocol requires prompt to be an array of ContentBlock
|
||||
params := jsonrpc.SessionPromptParams{
|
||||
SessionID: sessionID,
|
||||
Prompt: []jsonrpc.ContentBlock{
|
||||
{
|
||||
Type: "text",
|
||||
Text: message,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := session.Client.Call(ctx, jsonrpc.MethodSessionPrompt, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("session/prompt failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.Error != nil {
|
||||
return fmt.Errorf("session/prompt error: %s", resp.Error.Message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel cancels the current operation (session/cancel)
|
||||
func (m *SessionManager) Cancel(ctx context.Context, instanceID, reason string) error {
|
||||
session, err := m.getSession(instanceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.logger.Info("Cancelling agent operation",
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.String("reason", reason))
|
||||
|
||||
params := jsonrpc.SessionCancelParams{
|
||||
Reason: reason,
|
||||
}
|
||||
|
||||
// Cancel is a notification, not a request
|
||||
return session.Client.Notify(jsonrpc.MethodSessionCancel, params)
|
||||
}
|
||||
|
||||
// CloseSession closes an ACP session
|
||||
func (m *SessionManager) CloseSession(instanceID string) error {
|
||||
m.mu.Lock()
|
||||
session, exists := m.sessions[instanceID]
|
||||
if exists {
|
||||
delete(m.sessions, instanceID)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("session not found: %s", instanceID)
|
||||
}
|
||||
|
||||
m.logger.Info("Closing ACP session", zap.String("instance_id", instanceID))
|
||||
|
||||
session.Client.Stop()
|
||||
if session.Stdin != nil {
|
||||
session.Stdin.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSession returns a session by instance ID
|
||||
func (m *SessionManager) GetSession(instanceID string) (*Session, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
session, exists := m.sessions[instanceID]
|
||||
return session, exists
|
||||
}
|
||||
|
||||
// getSession returns a session or error if not found
|
||||
func (m *SessionManager) getSession(instanceID string) (*Session, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
session, exists := m.sessions[instanceID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("session not found: %s", instanceID)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// handleNotification handles incoming notifications from the agent
|
||||
func (m *SessionManager) handleNotification(session *Session, method string, params json.RawMessage) {
|
||||
m.logger.Debug("Received notification",
|
||||
zap.String("instance_id", session.InstanceID),
|
||||
zap.String("method", method))
|
||||
|
||||
switch method {
|
||||
case jsonrpc.NotificationSessionUpdate:
|
||||
var update jsonrpc.SessionUpdate
|
||||
if err := json.Unmarshal(params, &update); err != nil {
|
||||
m.logger.Error("Failed to parse session update", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Handle completion
|
||||
if update.Type == "complete" {
|
||||
session.mu.Lock()
|
||||
session.Status = "complete"
|
||||
session.mu.Unlock()
|
||||
}
|
||||
|
||||
// Forward to update handler
|
||||
if m.updateHandler != nil {
|
||||
m.updateHandler(session.InstanceID, session.TaskID, update.Type, update.Data)
|
||||
}
|
||||
|
||||
default:
|
||||
m.logger.Warn("Unknown notification method", zap.String("method", method))
|
||||
}
|
||||
}
|
||||
|
||||
// GetSessionID returns the ACP session ID for an instance
|
||||
func (m *SessionManager) GetSessionID(instanceID string) (string, bool) {
|
||||
session, exists := m.GetSession(instanceID)
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
session.mu.RLock()
|
||||
defer session.mu.RUnlock()
|
||||
return session.SessionID, session.SessionID != ""
|
||||
}
|
||||
|
||||
// handleRequest handles incoming requests from the agent (e.g., session/request_permission)
|
||||
func (m *SessionManager) handleRequest(session *Session, id interface{}, method string, params json.RawMessage) {
|
||||
m.logger.Info("Received request from agent",
|
||||
zap.String("instance_id", session.InstanceID),
|
||||
zap.String("method", method),
|
||||
zap.Any("id", id))
|
||||
|
||||
switch method {
|
||||
case jsonrpc.MethodRequestPermission:
|
||||
m.handleRequestPermission(session, id, params)
|
||||
default:
|
||||
m.logger.Warn("Unknown request method from agent", zap.String("method", method))
|
||||
// Send error response for unknown methods
|
||||
session.Client.SendResponse(id, nil, &jsonrpc.Error{
|
||||
Code: jsonrpc.MethodNotFound,
|
||||
Message: fmt.Sprintf("Unknown method: %s", method),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleRequestPermission handles session/request_permission requests from the agent
|
||||
func (m *SessionManager) handleRequestPermission(session *Session, id interface{}, params json.RawMessage) {
|
||||
var reqParams jsonrpc.RequestPermissionParams
|
||||
if err := json.Unmarshal(params, &reqParams); err != nil {
|
||||
m.logger.Error("Failed to parse request_permission params", zap.Error(err))
|
||||
session.Client.SendResponse(id, nil, &jsonrpc.Error{
|
||||
Code: jsonrpc.InvalidParams,
|
||||
Message: "Invalid params",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
m.logger.Info("Agent requesting permission",
|
||||
zap.String("instance_id", session.InstanceID),
|
||||
zap.String("session_id", reqParams.SessionID),
|
||||
zap.String("tool_call_id", reqParams.ToolCall.ToolCallID),
|
||||
zap.String("title", reqParams.ToolCall.Title),
|
||||
zap.Int("num_options", len(reqParams.Options)))
|
||||
|
||||
// If no permission handler is set, fall back to auto-approve
|
||||
if m.permissionHandler == nil {
|
||||
m.autoApprovePermission(session, id, &reqParams)
|
||||
return
|
||||
}
|
||||
|
||||
// Create a pending permission request
|
||||
pendingID := fmt.Sprintf("%s-%d", session.InstanceID, time.Now().UnixNano())
|
||||
pending := &PendingPermission{
|
||||
ID: pendingID,
|
||||
RPCID: id,
|
||||
InstanceID: session.InstanceID,
|
||||
TaskID: session.TaskID,
|
||||
SessionID: reqParams.SessionID,
|
||||
ToolCallID: reqParams.ToolCall.ToolCallID,
|
||||
Title: reqParams.ToolCall.Title,
|
||||
Options: reqParams.Options,
|
||||
ResponseCh: make(chan *PermissionResponseMsg, 1),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Store the pending request
|
||||
m.permissionMu.Lock()
|
||||
m.pendingPermissions[pendingID] = pending
|
||||
m.permissionMu.Unlock()
|
||||
|
||||
// Notify the permission handler (which will send WebSocket notification to user)
|
||||
m.permissionHandler(pending)
|
||||
|
||||
// Wait for user response in a goroutine to not block the read loop
|
||||
go m.waitForPermissionResponse(session, pending)
|
||||
}
|
||||
|
||||
// waitForPermissionResponse waits for user response and sends it to the agent
|
||||
func (m *SessionManager) waitForPermissionResponse(session *Session, pending *PendingPermission) {
|
||||
// Wait for response with a 5-minute timeout
|
||||
timeout := time.After(5 * time.Minute)
|
||||
|
||||
select {
|
||||
case resp := <-pending.ResponseCh:
|
||||
m.logger.Info("Received permission response from user",
|
||||
zap.String("pending_id", pending.ID),
|
||||
zap.String("option_id", resp.OptionID),
|
||||
zap.Bool("cancelled", resp.Cancelled))
|
||||
|
||||
var result jsonrpc.RequestPermissionResult
|
||||
if resp.Cancelled {
|
||||
result = jsonrpc.RequestPermissionResult{
|
||||
Outcome: jsonrpc.PermissionOutcome{
|
||||
Outcome: "cancelled",
|
||||
},
|
||||
}
|
||||
} else {
|
||||
result = jsonrpc.RequestPermissionResult{
|
||||
Outcome: jsonrpc.PermissionOutcome{
|
||||
Outcome: "selected",
|
||||
OptionID: resp.OptionID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if err := session.Client.SendResponse(pending.RPCID, result, nil); err != nil {
|
||||
m.logger.Error("Failed to send permission response to agent", zap.Error(err))
|
||||
}
|
||||
|
||||
case <-timeout:
|
||||
m.logger.Warn("Permission request timed out, cancelling",
|
||||
zap.String("pending_id", pending.ID))
|
||||
|
||||
result := jsonrpc.RequestPermissionResult{
|
||||
Outcome: jsonrpc.PermissionOutcome{
|
||||
Outcome: "cancelled",
|
||||
},
|
||||
}
|
||||
session.Client.SendResponse(pending.RPCID, result, nil)
|
||||
}
|
||||
|
||||
// Clean up
|
||||
m.permissionMu.Lock()
|
||||
delete(m.pendingPermissions, pending.ID)
|
||||
m.permissionMu.Unlock()
|
||||
}
|
||||
|
||||
// autoApprovePermission auto-approves a permission request (fallback behavior)
|
||||
func (m *SessionManager) autoApprovePermission(session *Session, id interface{}, reqParams *jsonrpc.RequestPermissionParams) {
|
||||
var selectedOptionID string
|
||||
for _, opt := range reqParams.Options {
|
||||
if opt.Kind == "allow_once" || opt.Kind == "allow_always" {
|
||||
selectedOptionID = opt.OptionID
|
||||
m.logger.Info("Auto-approving permission request",
|
||||
zap.String("option_id", selectedOptionID),
|
||||
zap.String("option_name", opt.Name),
|
||||
zap.String("kind", opt.Kind))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no allow option found, use the first option
|
||||
if selectedOptionID == "" && len(reqParams.Options) > 0 {
|
||||
selectedOptionID = reqParams.Options[0].OptionID
|
||||
m.logger.Info("No allow option found, using first option",
|
||||
zap.String("option_id", selectedOptionID))
|
||||
}
|
||||
|
||||
result := jsonrpc.RequestPermissionResult{
|
||||
Outcome: jsonrpc.PermissionOutcome{
|
||||
Outcome: "selected",
|
||||
OptionID: selectedOptionID,
|
||||
},
|
||||
}
|
||||
|
||||
if err := session.Client.SendResponse(id, result, nil); err != nil {
|
||||
m.logger.Error("Failed to send permission response", zap.Error(err))
|
||||
} else {
|
||||
m.logger.Info("Sent auto-approved permission response",
|
||||
zap.String("instance_id", session.InstanceID),
|
||||
zap.String("selected_option", selectedOptionID))
|
||||
}
|
||||
}
|
||||
|
||||
// RespondToPermission sends a user's response to a pending permission request
|
||||
func (m *SessionManager) RespondToPermission(pendingID string, optionID string, cancelled bool) error {
|
||||
m.permissionMu.RLock()
|
||||
pending, exists := m.pendingPermissions[pendingID]
|
||||
m.permissionMu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("pending permission request not found: %s", pendingID)
|
||||
}
|
||||
|
||||
// Send response through channel (non-blocking)
|
||||
select {
|
||||
case pending.ResponseCh <- &PermissionResponseMsg{
|
||||
OptionID: optionID,
|
||||
Cancelled: cancelled,
|
||||
}:
|
||||
m.logger.Info("Sent permission response to waiting goroutine",
|
||||
zap.String("pending_id", pendingID),
|
||||
zap.String("option_id", optionID))
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("permission request already responded to or timed out")
|
||||
}
|
||||
}
|
||||
|
||||
// GetPendingPermission returns a pending permission request by ID
|
||||
func (m *SessionManager) GetPendingPermission(pendingID string) (*PendingPermission, bool) {
|
||||
m.permissionMu.RLock()
|
||||
defer m.permissionMu.RUnlock()
|
||||
pending, exists := m.pendingPermissions[pendingID]
|
||||
return pending, exists
|
||||
}
|
||||
|
||||
// GetPendingPermissionsForTask returns all pending permissions for a task
|
||||
func (m *SessionManager) GetPendingPermissionsForTask(taskID string) []*PendingPermission {
|
||||
m.permissionMu.RLock()
|
||||
defer m.permissionMu.RUnlock()
|
||||
|
||||
var result []*PendingPermission
|
||||
for _, pending := range m.pendingPermissions {
|
||||
if pending.TaskID == taskID {
|
||||
result = append(result, pending)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -221,32 +221,7 @@ func (l *Launcher) Stop(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Wait blocks until the agentctl process exits.
|
||||
// Returns the exit error if any.
|
||||
func (l *Launcher) Wait() error {
|
||||
<-l.exited
|
||||
if l.cmd != nil && l.cmd.ProcessState != nil {
|
||||
if !l.cmd.ProcessState.Success() {
|
||||
return fmt.Errorf("agentctl exited with code %d", l.cmd.ProcessState.ExitCode())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Running returns true if agentctl is currently running.
|
||||
func (l *Launcher) Running() bool {
|
||||
select {
|
||||
case <-l.exited:
|
||||
return false
|
||||
default:
|
||||
return l.cmd != nil && l.cmd.Process != nil
|
||||
}
|
||||
}
|
||||
|
||||
// Port returns the control port agentctl is listening on.
|
||||
func (l *Launcher) Port() int {
|
||||
return l.port
|
||||
}
|
||||
|
||||
// checkPortAvailable verifies the port is not in use.
|
||||
func (l *Launcher) checkPortAvailable() error {
|
||||
|
||||
@@ -1491,21 +1491,7 @@ func (m *Manager) updateInstanceProgress(instanceID string, progress int) {
|
||||
instance.Progress = progress
|
||||
}
|
||||
|
||||
// updateInstanceComplete marks an instance as completed
|
||||
func (m *Manager) updateInstanceComplete(instanceID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
instance, exists := m.instances[instanceID]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
instance.Status = v1.AgentStatusCompleted
|
||||
instance.Progress = 100
|
||||
now := time.Now()
|
||||
instance.FinishedAt = &now
|
||||
}
|
||||
|
||||
// updateInstanceError updates an instance with an error
|
||||
func (m *Manager) updateInstanceError(instanceID, errorMsg string) {
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
package streaming
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
)
|
||||
|
||||
// Manager manages ACP stream readers for multiple agent instances
|
||||
type Manager struct {
|
||||
eventBus bus.EventBus
|
||||
logger *logger.Logger
|
||||
|
||||
readers map[string]*StreamReader // by instanceID
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewManager creates a new streaming manager
|
||||
func NewManager(eventBus bus.EventBus, log *logger.Logger) *Manager {
|
||||
return &Manager{
|
||||
eventBus: eventBus,
|
||||
logger: log.WithFields(zap.String("component", "streaming-manager")),
|
||||
readers: make(map[string]*StreamReader),
|
||||
}
|
||||
}
|
||||
|
||||
// StartStreaming starts streaming for an agent instance
|
||||
func (m *Manager) StartStreaming(ctx context.Context, instanceID, taskID string, reader io.ReadCloser) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Check if already streaming for this instance
|
||||
if existing, exists := m.readers[instanceID]; exists {
|
||||
if existing.IsRunning() {
|
||||
return fmt.Errorf("streaming already active for instance %s", instanceID)
|
||||
}
|
||||
// Previous reader exists but not running, replace it
|
||||
}
|
||||
|
||||
// Create new stream reader
|
||||
streamReader := NewStreamReader(instanceID, taskID, m.eventBus, m.logger)
|
||||
m.readers[instanceID] = streamReader
|
||||
|
||||
// Start reading
|
||||
if err := streamReader.Start(ctx, reader); err != nil {
|
||||
delete(m.readers, instanceID)
|
||||
return fmt.Errorf("failed to start streaming: %w", err)
|
||||
}
|
||||
|
||||
m.logger.Info("started streaming for instance",
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.String("task_id", taskID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopStreaming stops streaming for an agent instance
|
||||
func (m *Manager) StopStreaming(instanceID string) error {
|
||||
m.mu.Lock()
|
||||
reader, exists := m.readers[instanceID]
|
||||
if !exists {
|
||||
m.mu.Unlock()
|
||||
return nil // Not streaming, no error
|
||||
}
|
||||
delete(m.readers, instanceID)
|
||||
m.mu.Unlock()
|
||||
|
||||
if err := reader.Stop(); err != nil {
|
||||
m.logger.Warn("error stopping stream reader",
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
m.logger.Info("stopped streaming for instance",
|
||||
zap.String("instance_id", instanceID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopAll stops all stream readers
|
||||
func (m *Manager) StopAll() {
|
||||
m.mu.Lock()
|
||||
readers := make([]*StreamReader, 0, len(m.readers))
|
||||
instanceIDs := make([]string, 0, len(m.readers))
|
||||
for id, reader := range m.readers {
|
||||
readers = append(readers, reader)
|
||||
instanceIDs = append(instanceIDs, id)
|
||||
}
|
||||
m.readers = make(map[string]*StreamReader)
|
||||
m.mu.Unlock()
|
||||
|
||||
// Stop all readers outside the lock
|
||||
for i, reader := range readers {
|
||||
if err := reader.Stop(); err != nil {
|
||||
m.logger.Warn("error stopping stream reader during shutdown",
|
||||
zap.String("instance_id", instanceIDs[i]),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
m.logger.Info("stopped all stream readers", zap.Int("count", len(readers)))
|
||||
}
|
||||
|
||||
// IsStreaming returns true if streaming is active for an instance
|
||||
func (m *Manager) IsStreaming(instanceID string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
reader, exists := m.readers[instanceID]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
return reader.IsRunning()
|
||||
}
|
||||
|
||||
// GetActiveCount returns the number of active stream readers
|
||||
func (m *Manager) GetActiveCount() int {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
count := 0
|
||||
for _, reader := range m.readers {
|
||||
if reader.IsRunning() {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// GetReader returns the stream reader for an instance, if it exists
|
||||
func (m *Manager) GetReader(instanceID string) (*StreamReader, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
reader, exists := m.readers[instanceID]
|
||||
return reader, exists
|
||||
}
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -27,15 +27,7 @@ type Config struct {
|
||||
BranchPrefix string `mapstructure:"branch_prefix"`
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default worktree configuration.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Enabled: true,
|
||||
BasePath: "~/.kandev/worktrees",
|
||||
MaxPerRepo: 10,
|
||||
BranchPrefix: "kandev/",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Validate validates the configuration and returns an error if invalid.
|
||||
func (c *Config) Validate() error {
|
||||
|
||||
@@ -2,22 +2,33 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/kandev/kandev/internal/agentctl/config"
|
||||
"github.com/kandev/kandev/internal/agentctl/instance"
|
||||
"github.com/kandev/kandev/internal/agentctl/process"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ControlServer provides instance CRUD endpoints on the control port (9999).
|
||||
// In single-instance mode, it also embeds the agent API routes directly.
|
||||
type ControlServer struct {
|
||||
cfg *config.MultiConfig
|
||||
instMgr *instance.Manager
|
||||
logger *logger.Logger
|
||||
router *gin.Engine
|
||||
|
||||
// Single-instance mode: embedded process manager and config
|
||||
singleMode bool
|
||||
singleCfg *config.Config
|
||||
singleProcMgr *process.Manager
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
// NewControlServer creates a new ControlServer for multi-instance management.
|
||||
@@ -35,6 +46,29 @@ func NewControlServer(cfg *config.MultiConfig, instMgr *instance.Manager, log *l
|
||||
return cs
|
||||
}
|
||||
|
||||
// NewSingleInstanceServer creates a ControlServer for single-instance (Docker) mode.
|
||||
// It embeds the agent API routes directly on the control server.
|
||||
func NewSingleInstanceServer(cfg *config.MultiConfig, singleCfg *config.Config, procMgr *process.Manager, log *logger.Logger) *ControlServer {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
cs := &ControlServer{
|
||||
cfg: cfg,
|
||||
logger: log.WithFields(zap.String("component", "control-server")),
|
||||
router: gin.New(),
|
||||
singleMode: true,
|
||||
singleCfg: singleCfg,
|
||||
singleProcMgr: procMgr,
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Allow all origins for container-local communication
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cs.setupRoutes()
|
||||
return cs
|
||||
}
|
||||
|
||||
// Router returns the HTTP handler for the control server.
|
||||
func (m *ControlServer) Router() http.Handler {
|
||||
return m.router
|
||||
@@ -44,9 +78,13 @@ func (m *ControlServer) setupRoutes() {
|
||||
// Health check
|
||||
m.router.GET("/health", m.handleHealth)
|
||||
|
||||
// Instance management API
|
||||
api := m.router.Group("/api/v1")
|
||||
{
|
||||
|
||||
if m.singleMode {
|
||||
// Single-instance mode: mount agent API routes directly
|
||||
m.setupSingleInstanceRoutes(api)
|
||||
} else {
|
||||
// Multi-instance mode: instance management API
|
||||
api.POST("/instances", m.handleCreateInstance)
|
||||
api.GET("/instances", m.handleListInstances)
|
||||
api.GET("/instances/:id", m.handleGetInstance)
|
||||
@@ -54,6 +92,38 @@ func (m *ControlServer) setupRoutes() {
|
||||
}
|
||||
}
|
||||
|
||||
// setupSingleInstanceRoutes adds the agent API routes for single-instance mode.
|
||||
// These are the same routes that api.Server provides.
|
||||
func (m *ControlServer) setupSingleInstanceRoutes(api *gin.RouterGroup) {
|
||||
// Status and info
|
||||
api.GET("/status", m.handleStatus)
|
||||
api.GET("/info", m.handleInfo)
|
||||
|
||||
// Process control
|
||||
api.POST("/start", m.handleStart)
|
||||
api.POST("/stop", m.handleStop)
|
||||
|
||||
// ACP high-level methods
|
||||
api.POST("/acp/initialize", m.handleACPInitialize)
|
||||
api.POST("/acp/session/new", m.handleACPNewSession)
|
||||
api.POST("/acp/prompt", m.handleACPPrompt)
|
||||
api.GET("/acp/stream", m.handleACPStreamWS)
|
||||
|
||||
// Permission request handling
|
||||
api.GET("/acp/permissions", m.handleGetPendingPermissions)
|
||||
api.GET("/acp/permissions/stream", m.handlePermissionStreamWS)
|
||||
api.POST("/acp/permissions/respond", m.handlePermissionRespond)
|
||||
|
||||
// Workspace monitoring (git status, files)
|
||||
api.GET("/workspace/git-status/stream", m.handleGitStatusStreamWS)
|
||||
api.GET("/workspace/files/stream", m.handleFilesStreamWS)
|
||||
api.GET("/workspace/file-changes/stream", m.handleFileChangesStreamWS)
|
||||
|
||||
// Workspace file operations (simple HTTP)
|
||||
api.GET("/workspace/tree", m.handleFileTree)
|
||||
api.GET("/workspace/file/content", m.handleFileContent)
|
||||
}
|
||||
|
||||
func (m *ControlServer) handleHealth(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
@@ -136,3 +206,473 @@ func (m *ControlServer) handleDeleteInstance(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Single-instance mode handlers
|
||||
// These mirror the handlers in server.go but use singleProcMgr
|
||||
// =============================================================================
|
||||
|
||||
func (m *ControlServer) handleStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, StatusResponse{
|
||||
AgentStatus: string(m.singleProcMgr.Status()),
|
||||
ProcessInfo: m.singleProcMgr.GetProcessInfo(),
|
||||
})
|
||||
}
|
||||
|
||||
func (m *ControlServer) handleInfo(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, InfoResponse{
|
||||
Version: "0.1.0",
|
||||
AgentCommand: m.singleCfg.AgentCommand,
|
||||
WorkDir: m.singleCfg.WorkDir,
|
||||
Port: m.singleCfg.Port,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *ControlServer) handleStart(c *gin.Context) {
|
||||
if err := m.singleProcMgr.Start(c.Request.Context()); err != nil {
|
||||
m.logger.Error("failed to start agent", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, StartResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, StartResponse{
|
||||
Success: true,
|
||||
Message: "agent started",
|
||||
})
|
||||
}
|
||||
|
||||
func (m *ControlServer) handleStop(c *gin.Context) {
|
||||
if err := m.singleProcMgr.Stop(c.Request.Context()); err != nil {
|
||||
m.logger.Error("failed to stop agent", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, StopResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, StopResponse{
|
||||
Success: true,
|
||||
Message: "agent stopped",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// ACP handlers for single-instance mode
|
||||
|
||||
func (m *ControlServer) handleACPInitialize(c *gin.Context) {
|
||||
var req InitializeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, InitializeResponse{
|
||||
Success: false,
|
||||
Error: "invalid request: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
adapter := m.singleProcMgr.GetAdapter()
|
||||
if adapter == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, InitializeResponse{
|
||||
Success: false,
|
||||
Error: "agent not running",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err := adapter.Initialize(ctx)
|
||||
if err != nil {
|
||||
m.logger.Error("initialize failed", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, InitializeResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var agentInfoResp *AgentInfoResponse
|
||||
if info := adapter.GetAgentInfo(); info != nil {
|
||||
agentInfoResp = &AgentInfoResponse{
|
||||
Name: info.Name,
|
||||
Version: info.Version,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, InitializeResponse{
|
||||
Success: true,
|
||||
AgentInfo: agentInfoResp,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *ControlServer) handleACPNewSession(c *gin.Context) {
|
||||
var req NewSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, NewSessionResponse{
|
||||
Success: false,
|
||||
Error: "invalid request: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
adapter := m.singleProcMgr.GetAdapter()
|
||||
if adapter == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, NewSessionResponse{
|
||||
Success: false,
|
||||
Error: "agent not running",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID, err := adapter.NewSession(ctx)
|
||||
if err != nil {
|
||||
m.logger.Error("new session failed", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, NewSessionResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, NewSessionResponse{
|
||||
Success: true,
|
||||
SessionID: sessionID,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *ControlServer) handleACPPrompt(c *gin.Context) {
|
||||
var req PromptRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, PromptResponse{
|
||||
Success: false,
|
||||
Error: "invalid request: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
adapter := m.singleProcMgr.GetAdapter()
|
||||
if adapter == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, PromptResponse{
|
||||
Success: false,
|
||||
Error: "agent not running",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := m.singleProcMgr.GetSessionID()
|
||||
if sessionID == "" {
|
||||
c.JSON(http.StatusBadRequest, PromptResponse{
|
||||
Success: false,
|
||||
Error: "no active session - call new_session first",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
err := adapter.Prompt(ctx, req.Text)
|
||||
if err != nil {
|
||||
m.logger.Error("prompt failed", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, PromptResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
m.logger.Info("prompt completed")
|
||||
c.JSON(http.StatusOK, PromptResponse{
|
||||
Success: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func (m *ControlServer) handleACPStreamWS(c *gin.Context) {
|
||||
conn, err := m.upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
m.logger.Error("WebSocket upgrade failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
m.logger.Info("ACP stream WebSocket connected")
|
||||
|
||||
updatesCh := m.singleProcMgr.GetUpdates()
|
||||
|
||||
for {
|
||||
select {
|
||||
case notification, ok := <-updatesCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(notification)
|
||||
if err != nil {
|
||||
m.logger.Error("failed to marshal notification", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ControlServer) handleGetPendingPermissions(c *gin.Context) {
|
||||
pending := m.singleProcMgr.GetPendingPermissions()
|
||||
|
||||
result := make([]PendingPermissionResponse, 0, len(pending))
|
||||
for _, p := range pending {
|
||||
options := make([]PermissionOptionJSON, len(p.Request.Options))
|
||||
for i, opt := range p.Request.Options {
|
||||
options[i] = PermissionOptionJSON{
|
||||
OptionID: opt.OptionID,
|
||||
Name: opt.Name,
|
||||
Kind: opt.Kind,
|
||||
}
|
||||
}
|
||||
result = append(result, PendingPermissionResponse{
|
||||
PendingID: p.ID,
|
||||
SessionID: p.Request.SessionID,
|
||||
ToolCallID: p.Request.ToolCallID,
|
||||
Title: p.Request.Title,
|
||||
Options: options,
|
||||
CreatedAt: p.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (m *ControlServer) handlePermissionStreamWS(c *gin.Context) {
|
||||
conn, err := m.upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
m.logger.Error("WebSocket upgrade failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
m.logger.Info("Permission stream WebSocket connected")
|
||||
|
||||
permissionCh := m.singleProcMgr.GetPermissionRequests()
|
||||
|
||||
for {
|
||||
select {
|
||||
case notification, ok := <-permissionCh:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(notification)
|
||||
if err != nil {
|
||||
m.logger.Error("failed to marshal permission notification", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
m.logger.Debug("WebSocket write error", zap.Error(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ControlServer) handlePermissionRespond(c *gin.Context) {
|
||||
var req PermissionRespondRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, PermissionRespondResponse{
|
||||
Success: false,
|
||||
Error: "invalid request: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
m.logger.Info("received permission response",
|
||||
zap.String("pending_id", req.PendingID),
|
||||
zap.String("option_id", req.OptionID),
|
||||
zap.Bool("cancelled", req.Cancelled))
|
||||
|
||||
if err := m.singleProcMgr.RespondToPermission(req.PendingID, req.OptionID, req.Cancelled); err != nil {
|
||||
m.logger.Error("failed to respond to permission", zap.Error(err))
|
||||
c.JSON(http.StatusNotFound, PermissionRespondResponse{
|
||||
Success: false,
|
||||
Error: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, PermissionRespondResponse{
|
||||
Success: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// Workspace handlers for single-instance mode
|
||||
|
||||
func (m *ControlServer) handleGitStatusStreamWS(c *gin.Context) {
|
||||
conn, err := m.upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
m.logger.Error("WebSocket upgrade failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
sub := m.singleProcMgr.GetWorkspaceTracker().SubscribeGitStatus()
|
||||
defer m.singleProcMgr.GetWorkspaceTracker().UnsubscribeGitStatus(sub)
|
||||
|
||||
closeCh := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
close(closeCh)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case update, ok := <-sub:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
m.logger.Error("failed to marshal git status update", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
return
|
||||
}
|
||||
case <-closeCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ControlServer) handleFilesStreamWS(c *gin.Context) {
|
||||
conn, err := m.upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
m.logger.Error("WebSocket upgrade failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
sub := m.singleProcMgr.GetWorkspaceTracker().SubscribeFiles()
|
||||
defer m.singleProcMgr.GetWorkspaceTracker().UnsubscribeFiles(sub)
|
||||
|
||||
closeCh := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
close(closeCh)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case update, ok := <-sub:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
m.logger.Error("failed to marshal file update", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
return
|
||||
}
|
||||
case <-closeCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ControlServer) handleFileChangesStreamWS(c *gin.Context) {
|
||||
conn, err := m.upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
m.logger.Error("WebSocket upgrade failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
sub := m.singleProcMgr.GetWorkspaceTracker().SubscribeFileChanges()
|
||||
defer m.singleProcMgr.GetWorkspaceTracker().UnsubscribeFileChanges(sub)
|
||||
|
||||
closeCh := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
_, _, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
close(closeCh)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case notification, ok := <-sub:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(notification)
|
||||
if err != nil {
|
||||
m.logger.Error("failed to marshal file change notification", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
return
|
||||
}
|
||||
case <-closeCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ControlServer) handleFileTree(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
depth := 1
|
||||
if d := c.Query("depth"); d != "" {
|
||||
if _, err := json.Number(d).Int64(); err == nil {
|
||||
depth = int(mustParseInt(d))
|
||||
}
|
||||
}
|
||||
|
||||
tree, err := m.singleProcMgr.GetWorkspaceTracker().GetFileTree(path, depth)
|
||||
if err != nil {
|
||||
c.JSON(400, process.FileTreeResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, process.FileTreeResponse{Root: tree})
|
||||
}
|
||||
|
||||
func (m *ControlServer) handleFileContent(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
c.JSON(400, process.FileContentResponse{Error: "path is required"})
|
||||
return
|
||||
}
|
||||
|
||||
content, size, err := m.singleProcMgr.GetWorkspaceTracker().GetFileContent(path)
|
||||
if err != nil {
|
||||
c.JSON(400, process.FileContentResponse{Path: path, Error: err.Error(), Size: size})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, process.FileContentResponse{Path: path, Content: content, Size: size})
|
||||
}
|
||||
@@ -53,12 +53,3 @@ func (p *PortAllocator) Release(port int) {
|
||||
delete(p.allocated, port)
|
||||
}
|
||||
|
||||
// IsAvailable checks if a specific port is available for allocation.
|
||||
func (p *PortAllocator) IsAvailable(port int) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
_, exists := p.allocated[port]
|
||||
return !exists
|
||||
}
|
||||
|
||||
|
||||
@@ -563,10 +563,4 @@ func (m *Manager) GetPendingPermissions() []*PendingPermission {
|
||||
return result
|
||||
}
|
||||
|
||||
// GetPendingPermission returns a specific pending permission request
|
||||
func (m *Manager) GetPendingPermission(pendingID string) (*PendingPermission, bool) {
|
||||
m.permissionMu.RLock()
|
||||
defer m.permissionMu.RUnlock()
|
||||
p, ok := m.pendingPermissions[pendingID]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
|
||||
@@ -558,19 +558,7 @@ func (wt *WorkspaceTracker) notifyFilesSubscribers(update FileListUpdate) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentGitStatus returns the current git status
|
||||
func (wt *WorkspaceTracker) GetCurrentGitStatus() GitStatusUpdate {
|
||||
wt.mu.RLock()
|
||||
defer wt.mu.RUnlock()
|
||||
return wt.currentStatus
|
||||
}
|
||||
|
||||
// GetCurrentFiles returns the current file list
|
||||
func (wt *WorkspaceTracker) GetCurrentFiles() FileListUpdate {
|
||||
wt.mu.RLock()
|
||||
defer wt.mu.RUnlock()
|
||||
return wt.currentFiles
|
||||
}
|
||||
|
||||
// SubscribeFileChanges creates a new file change subscriber
|
||||
func (wt *WorkspaceTracker) SubscribeFileChanges() FileChangeSubscriber {
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
// Package database provides PostgreSQL connection pooling and database operations.
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/config"
|
||||
)
|
||||
|
||||
// DB wraps a pgxpool.Pool and provides helper methods for database operations.
|
||||
type DB struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewDB creates a new database connection pool using the provided configuration.
|
||||
// It builds the connection string from config, configures pool settings,
|
||||
// establishes the connection, and verifies it with a ping.
|
||||
func NewDB(ctx context.Context, cfg config.DatabaseConfig) (*DB, error) {
|
||||
connString := cfg.DSN()
|
||||
|
||||
poolConfig, err := pgxpool.ParseConfig(connString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse database config: %w", err)
|
||||
}
|
||||
|
||||
// Configure pool settings
|
||||
poolConfig.MaxConns = int32(cfg.MaxConns)
|
||||
poolConfig.MinConns = int32(cfg.MinConns)
|
||||
|
||||
// Set reasonable connection timeouts
|
||||
poolConfig.ConnConfig.ConnectTimeout = 10 * time.Second
|
||||
|
||||
// Create the connection pool
|
||||
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create connection pool: %w", err)
|
||||
}
|
||||
|
||||
// Verify the connection
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
return &DB{pool: pool}, nil
|
||||
}
|
||||
|
||||
// Pool returns the underlying pgxpool.Pool.
|
||||
func (db *DB) Pool() *pgxpool.Pool {
|
||||
return db.pool
|
||||
}
|
||||
|
||||
// Close closes the connection pool.
|
||||
func (db *DB) Close() {
|
||||
if db.pool != nil {
|
||||
db.pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Ping verifies the database connection is still alive.
|
||||
func (db *DB) Ping(ctx context.Context) error {
|
||||
return db.pool.Ping(ctx)
|
||||
}
|
||||
|
||||
// Exec executes a query that doesn't return rows.
|
||||
func (db *DB) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
|
||||
return db.pool.Exec(ctx, sql, args...)
|
||||
}
|
||||
|
||||
// Query executes a query that returns rows.
|
||||
func (db *DB) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
|
||||
return db.pool.Query(ctx, sql, args...)
|
||||
}
|
||||
|
||||
// QueryRow executes a query that returns at most one row.
|
||||
func (db *DB) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
|
||||
return db.pool.QueryRow(ctx, sql, args...)
|
||||
}
|
||||
|
||||
// BeginTx starts a transaction with the given options.
|
||||
func (db *DB) BeginTx(ctx context.Context, opts pgx.TxOptions) (pgx.Tx, error) {
|
||||
return db.pool.BeginTx(ctx, opts)
|
||||
}
|
||||
|
||||
// WithTx executes the given function within a transaction.
|
||||
// If the function returns an error, the transaction is rolled back.
|
||||
// If the function succeeds, the transaction is committed.
|
||||
func (db *DB) WithTx(ctx context.Context, fn func(tx pgx.Tx) error) error {
|
||||
tx, err := db.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
// Rollback on panic
|
||||
_ = tx.Rollback(ctx)
|
||||
panic(p)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := fn(tx); err != nil {
|
||||
if rbErr := tx.Rollback(ctx); rbErr != nil {
|
||||
return fmt.Errorf("tx failed: %w, rollback failed: %v", err, rbErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithTxOptions executes the given function within a transaction with custom options.
|
||||
// If the function returns an error, the transaction is rolled back.
|
||||
// If the function succeeds, the transaction is committed.
|
||||
func (db *DB) WithTxOptions(ctx context.Context, opts pgx.TxOptions, fn func(tx pgx.Tx) error) error {
|
||||
tx, err := db.pool.BeginTx(ctx, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
// Rollback on panic
|
||||
_ = tx.Rollback(ctx)
|
||||
panic(p)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := fn(tx); err != nil {
|
||||
if rbErr := tx.Rollback(ctx); rbErr != nil {
|
||||
return fmt.Errorf("tx failed: %w, rollback failed: %v", err, rbErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
// Package errors provides custom error types for the Kandev application.
|
||||
package errors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Error codes as constants
|
||||
const (
|
||||
ErrCodeNotFound = "NOT_FOUND"
|
||||
ErrCodeBadRequest = "BAD_REQUEST"
|
||||
ErrCodeUnauthorized = "UNAUTHORIZED"
|
||||
ErrCodeForbidden = "FORBIDDEN"
|
||||
ErrCodeInternalError = "INTERNAL_ERROR"
|
||||
ErrCodeConflict = "CONFLICT"
|
||||
ErrCodeValidationError = "VALIDATION_ERROR"
|
||||
ErrCodeServiceUnavailable = "SERVICE_UNAVAILABLE"
|
||||
)
|
||||
|
||||
// AppError represents an application-specific error with additional context.
|
||||
type AppError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
HTTPStatus int `json:"http_status"`
|
||||
Err error `json:"-"`
|
||||
}
|
||||
|
||||
// Error implements the error interface.
|
||||
func (e *AppError) Error() string {
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap returns the wrapped error for use with errors.Is and errors.As.
|
||||
func (e *AppError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// NotFound creates a new not found error for a resource.
|
||||
func NotFound(resource string, id string) *AppError {
|
||||
return &AppError{
|
||||
Code: ErrCodeNotFound,
|
||||
Message: fmt.Sprintf("%s with id '%s' not found", resource, id),
|
||||
HTTPStatus: http.StatusNotFound,
|
||||
}
|
||||
}
|
||||
|
||||
// BadRequest creates a new bad request error.
|
||||
func BadRequest(message string) *AppError {
|
||||
return &AppError{
|
||||
Code: ErrCodeBadRequest,
|
||||
Message: message,
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
// Unauthorized creates a new unauthorized error.
|
||||
func Unauthorized(message string) *AppError {
|
||||
return &AppError{
|
||||
Code: ErrCodeUnauthorized,
|
||||
Message: message,
|
||||
HTTPStatus: http.StatusUnauthorized,
|
||||
}
|
||||
}
|
||||
|
||||
// Forbidden creates a new forbidden error.
|
||||
func Forbidden(message string) *AppError {
|
||||
return &AppError{
|
||||
Code: ErrCodeForbidden,
|
||||
Message: message,
|
||||
HTTPStatus: http.StatusForbidden,
|
||||
}
|
||||
}
|
||||
|
||||
// InternalError creates a new internal server error with a wrapped underlying error.
|
||||
func InternalError(message string, err error) *AppError {
|
||||
return &AppError{
|
||||
Code: ErrCodeInternalError,
|
||||
Message: message,
|
||||
HTTPStatus: http.StatusInternalServerError,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Conflict creates a new conflict error.
|
||||
func Conflict(message string) *AppError {
|
||||
return &AppError{
|
||||
Code: ErrCodeConflict,
|
||||
Message: message,
|
||||
HTTPStatus: http.StatusConflict,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidationError creates a new validation error for a specific field.
|
||||
func ValidationError(field string, message string) *AppError {
|
||||
return &AppError{
|
||||
Code: ErrCodeValidationError,
|
||||
Message: fmt.Sprintf("validation failed for field '%s': %s", field, message),
|
||||
HTTPStatus: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceUnavailable creates a new service unavailable error.
|
||||
func ServiceUnavailable(service string) *AppError {
|
||||
return &AppError{
|
||||
Code: ErrCodeServiceUnavailable,
|
||||
Message: fmt.Sprintf("service '%s' is currently unavailable", service),
|
||||
HTTPStatus: http.StatusServiceUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap wraps an existing error with additional context, returning an AppError.
|
||||
func Wrap(err error, message string) *AppError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If the error is already an AppError, preserve its code and status
|
||||
var appErr *AppError
|
||||
if errors.As(err, &appErr) {
|
||||
return &AppError{
|
||||
Code: appErr.Code,
|
||||
Message: fmt.Sprintf("%s: %s", message, appErr.Message),
|
||||
HTTPStatus: appErr.HTTPStatus,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, wrap as an internal error
|
||||
return &AppError{
|
||||
Code: ErrCodeInternalError,
|
||||
Message: message,
|
||||
HTTPStatus: http.StatusInternalServerError,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// IsNotFound checks if the error is a not found error.
|
||||
func IsNotFound(err error) bool {
|
||||
var appErr *AppError
|
||||
if errors.As(err, &appErr) {
|
||||
return appErr.Code == ErrCodeNotFound
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsBadRequest checks if the error is a bad request error.
|
||||
func IsBadRequest(err error) bool {
|
||||
var appErr *AppError
|
||||
if errors.As(err, &appErr) {
|
||||
return appErr.Code == ErrCodeBadRequest || appErr.Code == ErrCodeValidationError
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetHTTPStatus returns the HTTP status code for an error.
|
||||
// Returns 500 Internal Server Error if the error is not an AppError.
|
||||
func GetHTTPStatus(err error) int {
|
||||
var appErr *AppError
|
||||
if errors.As(err, &appErr) {
|
||||
return appErr.HTTPStatus
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// Package stringutil provides common string utility functions.
|
||||
package stringutil
|
||||
|
||||
// TruncateString truncates a string to a maximum length.
|
||||
// If the string is shorter than maxLen, it returns the original string.
|
||||
// If the string is longer, it returns the first maxLen characters.
|
||||
func TruncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen]
|
||||
}
|
||||
|
||||
// TruncateStringWithEllipsis truncates a string to a maximum length and adds "..." suffix.
|
||||
// If the string is shorter than maxLen, it returns the original string.
|
||||
// If the string is longer, it returns the first (maxLen-3) characters followed by "...".
|
||||
func TruncateStringWithEllipsis(s string, maxLen int) string {
|
||||
if maxLen < 4 {
|
||||
return TruncateString(s, maxLen)
|
||||
}
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
@@ -72,13 +72,7 @@ func (h *Handler) HandleConnection(c *gin.Context) {
|
||||
client.ReadPump(c.Request.Context())
|
||||
}
|
||||
|
||||
// SetupRoutes adds the WebSocket route to the router
|
||||
func SetupRoutes(router *gin.Engine, hub *Hub, log *logger.Logger) {
|
||||
handler := NewHandler(hub, log)
|
||||
|
||||
// Main WebSocket endpoint
|
||||
router.GET("/ws", handler.HandleConnection)
|
||||
}
|
||||
|
||||
// RegisterHealthHandler registers the health check handler
|
||||
func RegisterHealthHandler(d *ws.Dispatcher) {
|
||||
|
||||
@@ -55,8 +55,13 @@ func NewTestServer(t *testing.T) *TestServer {
|
||||
// Initialize event bus
|
||||
eventBus := bus.NewMemoryEventBus(log)
|
||||
|
||||
// Initialize task repository (in-memory for tests)
|
||||
taskRepo := repository.NewMemoryRepository()
|
||||
// Initialize task repository (SQLite for tests)
|
||||
tmpDir := t.TempDir()
|
||||
taskRepo, err := repository.NewSQLiteRepository(tmpDir + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test repository: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { taskRepo.Close() })
|
||||
|
||||
// Initialize task service
|
||||
taskSvc := taskservice.NewService(taskRepo, eventBus, log, taskservice.RepositoryDiscoveryConfig{})
|
||||
@@ -728,8 +733,10 @@ func TestMultipleClients(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
boardID := boardPayload["id"].(string)
|
||||
|
||||
// Client 2 can see the board
|
||||
listResp, err := client2.SendRequest("c2-list-1", ws.ActionBoardList, map[string]interface{}{})
|
||||
// Client 2 can see the board (filter by workspace to avoid default board)
|
||||
listResp, err := client2.SendRequest("c2-list-1", ws.ActionBoardList, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var listPayload map[string]interface{}
|
||||
|
||||
@@ -468,8 +468,13 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
|
||||
// Initialize event bus
|
||||
eventBus := bus.NewMemoryEventBus(log)
|
||||
|
||||
// Initialize task repository (in-memory for tests)
|
||||
taskRepo := repository.NewMemoryRepository()
|
||||
// Initialize task repository (SQLite for tests)
|
||||
tmpDir := t.TempDir()
|
||||
taskRepo, err := repository.NewSQLiteRepository(tmpDir + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test repository: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { taskRepo.Close() })
|
||||
|
||||
// Initialize task service
|
||||
taskSvc := taskservice.NewService(taskRepo, eventBus, log, taskservice.RepositoryDiscoveryConfig{})
|
||||
@@ -483,7 +488,7 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
|
||||
// Create orchestrator service
|
||||
cfg := orchestrator.DefaultServiceConfig()
|
||||
cfg.Scheduler.ProcessInterval = 50 * time.Millisecond // Faster for tests
|
||||
orchestratorSvc := orchestrator.NewService(cfg, eventBus, nil, agentManager, taskRepoAdapter, taskRepo, log)
|
||||
orchestratorSvc := orchestrator.NewService(cfg, eventBus, agentManager, taskRepoAdapter, taskRepo, log)
|
||||
|
||||
// Create WebSocket gateway
|
||||
gateway := gateways.NewGateway(log)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// Package dto provides Data Transfer Objects for the orchestrator module.
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
// GetStatusRequest is the request for orchestrator.status
|
||||
type GetStatusRequest struct{}
|
||||
|
||||
@@ -109,14 +107,7 @@ type PermissionRespondResponse struct {
|
||||
PendingID string `json:"pending_id"`
|
||||
}
|
||||
|
||||
// FromQueuedTask converts a queue.QueuedTask to QueuedTaskDTO
|
||||
func FromQueuedTask(qt interface{ GetTaskID() string; GetPriority() int; GetQueuedAt() time.Time }) QueuedTaskDTO {
|
||||
return QueuedTaskDTO{
|
||||
TaskID: qt.GetTaskID(),
|
||||
Priority: qt.GetPriority(),
|
||||
QueuedAt: qt.GetQueuedAt().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// GetTaskExecutionRequest is the payload for task.execution
|
||||
type GetTaskExecutionRequest struct {
|
||||
|
||||
@@ -327,61 +327,3 @@ func (s *Scheduler) processTasks(ctx context.Context) {
|
||||
}
|
||||
|
||||
// MockTaskRepository is a placeholder implementation
|
||||
type MockTaskRepository struct {
|
||||
tasks map[string]*v1.Task
|
||||
mu sync.RWMutex
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewMockTaskRepository creates a new mock task repository
|
||||
func NewMockTaskRepository(log *logger.Logger) *MockTaskRepository {
|
||||
return &MockTaskRepository{
|
||||
tasks: make(map[string]*v1.Task),
|
||||
logger: log.WithFields(zap.String("component", "mock_task_repo")),
|
||||
}
|
||||
}
|
||||
|
||||
// GetTask retrieves a task by ID
|
||||
func (r *MockTaskRepository) GetTask(ctx context.Context, taskID string) (*v1.Task, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
task, exists := r.tasks[taskID]
|
||||
if !exists {
|
||||
r.logger.Debug("task not found", zap.String("task_id", taskID))
|
||||
return nil, ErrTaskNotFound
|
||||
}
|
||||
|
||||
// Return a copy
|
||||
taskCopy := *task
|
||||
return &taskCopy, nil
|
||||
}
|
||||
|
||||
// UpdateTaskState updates the state of a task
|
||||
func (r *MockTaskRepository) UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
task, exists := r.tasks[taskID]
|
||||
if !exists {
|
||||
r.logger.Debug("task not found for state update", zap.String("task_id", taskID))
|
||||
return ErrTaskNotFound
|
||||
}
|
||||
|
||||
task.State = state
|
||||
task.UpdatedAt = time.Now()
|
||||
r.logger.Info("task state updated",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("state", string(state)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddTask adds a task to the mock repository
|
||||
func (r *MockTaskRepository) AddTask(task *v1.Task) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.tasks[task.ID] = task
|
||||
r.logger.Debug("task added to mock repository", zap.String("task_id", task.ID))
|
||||
}
|
||||
|
||||
|
||||
@@ -120,8 +120,13 @@ func createTestLogger() *logger.Logger {
|
||||
return log
|
||||
}
|
||||
|
||||
func createTestExecutor(agentMgr *mockAgentManager, log *logger.Logger, maxConcurrent int) *executor.Executor {
|
||||
repo := repository.NewMemoryRepository()
|
||||
func createTestExecutor(t *testing.T, agentMgr *mockAgentManager, log *logger.Logger, maxConcurrent int) *executor.Executor {
|
||||
tmpDir := t.TempDir()
|
||||
repo, err := repository.NewSQLiteRepository(tmpDir + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test repository: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { repo.Close() })
|
||||
cfg := executor.ExecutorConfig{
|
||||
MaxConcurrent: maxConcurrent,
|
||||
WorktreeEnabled: true,
|
||||
@@ -147,7 +152,7 @@ func TestNewScheduler(t *testing.T) {
|
||||
q := queue.NewTaskQueue(100)
|
||||
agentMgr := newMockAgentManager()
|
||||
log := createTestLogger()
|
||||
exec := createTestExecutor(agentMgr, log, 5)
|
||||
exec := createTestExecutor(t, agentMgr, log, 5)
|
||||
taskRepo := newTestTaskRepository()
|
||||
|
||||
s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig())
|
||||
@@ -180,7 +185,7 @@ func TestStartStop(t *testing.T) {
|
||||
q := queue.NewTaskQueue(100)
|
||||
agentMgr := newMockAgentManager()
|
||||
log := createTestLogger()
|
||||
exec := createTestExecutor(agentMgr, log, 5)
|
||||
exec := createTestExecutor(t, agentMgr, log, 5)
|
||||
taskRepo := newTestTaskRepository()
|
||||
|
||||
s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig())
|
||||
@@ -206,7 +211,7 @@ func TestStartAlreadyRunning(t *testing.T) {
|
||||
q := queue.NewTaskQueue(100)
|
||||
agentMgr := newMockAgentManager()
|
||||
log := createTestLogger()
|
||||
exec := createTestExecutor(agentMgr, log, 5)
|
||||
exec := createTestExecutor(t, agentMgr, log, 5)
|
||||
taskRepo := newTestTaskRepository()
|
||||
|
||||
s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig())
|
||||
@@ -224,7 +229,7 @@ func TestStopNotRunning(t *testing.T) {
|
||||
q := queue.NewTaskQueue(100)
|
||||
agentMgr := newMockAgentManager()
|
||||
log := createTestLogger()
|
||||
exec := createTestExecutor(agentMgr, log, 5)
|
||||
exec := createTestExecutor(t, agentMgr, log, 5)
|
||||
taskRepo := newTestTaskRepository()
|
||||
|
||||
s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig())
|
||||
@@ -239,7 +244,7 @@ func TestEnqueueTask(t *testing.T) {
|
||||
q := queue.NewTaskQueue(100)
|
||||
agentMgr := newMockAgentManager()
|
||||
log := createTestLogger()
|
||||
exec := createTestExecutor(agentMgr, log, 5)
|
||||
exec := createTestExecutor(t, agentMgr, log, 5)
|
||||
taskRepo := newTestTaskRepository()
|
||||
|
||||
s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig())
|
||||
@@ -259,7 +264,7 @@ func TestRemoveTask(t *testing.T) {
|
||||
q := queue.NewTaskQueue(100)
|
||||
agentMgr := newMockAgentManager()
|
||||
log := createTestLogger()
|
||||
exec := createTestExecutor(agentMgr, log, 5)
|
||||
exec := createTestExecutor(t, agentMgr, log, 5)
|
||||
taskRepo := newTestTaskRepository()
|
||||
|
||||
s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig())
|
||||
@@ -281,7 +286,7 @@ func TestRemoveNonExistentTask(t *testing.T) {
|
||||
q := queue.NewTaskQueue(100)
|
||||
agentMgr := newMockAgentManager()
|
||||
log := createTestLogger()
|
||||
exec := createTestExecutor(agentMgr, log, 5)
|
||||
exec := createTestExecutor(t, agentMgr, log, 5)
|
||||
taskRepo := newTestTaskRepository()
|
||||
|
||||
s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig())
|
||||
@@ -296,7 +301,7 @@ func TestGetQueueStatus(t *testing.T) {
|
||||
q := queue.NewTaskQueue(100)
|
||||
agentMgr := newMockAgentManager()
|
||||
log := createTestLogger()
|
||||
exec := createTestExecutor(agentMgr, log, 5)
|
||||
exec := createTestExecutor(t, agentMgr, log, 5)
|
||||
taskRepo := newTestTaskRepository()
|
||||
|
||||
cfg := DefaultSchedulerConfig()
|
||||
@@ -322,7 +327,7 @@ func TestHandleTaskCompleted(t *testing.T) {
|
||||
q := queue.NewTaskQueue(100)
|
||||
agentMgr := newMockAgentManager()
|
||||
log := createTestLogger()
|
||||
exec := createTestExecutor(agentMgr, log, 5)
|
||||
exec := createTestExecutor(t, agentMgr, log, 5)
|
||||
taskRepo := newTestTaskRepository()
|
||||
|
||||
s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig())
|
||||
@@ -351,7 +356,7 @@ func TestPriorityOrdering(t *testing.T) {
|
||||
q := queue.NewTaskQueue(100)
|
||||
agentMgr := newMockAgentManager()
|
||||
log := createTestLogger()
|
||||
exec := createTestExecutor(agentMgr, log, 5)
|
||||
exec := createTestExecutor(t, agentMgr, log, 5)
|
||||
taskRepo := newTestTaskRepository()
|
||||
|
||||
s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig())
|
||||
@@ -372,7 +377,7 @@ func TestIsRunning(t *testing.T) {
|
||||
q := queue.NewTaskQueue(100)
|
||||
agentMgr := newMockAgentManager()
|
||||
log := createTestLogger()
|
||||
exec := createTestExecutor(agentMgr, log, 5)
|
||||
exec := createTestExecutor(t, agentMgr, log, 5)
|
||||
taskRepo := newTestTaskRepository()
|
||||
|
||||
s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig())
|
||||
@@ -396,7 +401,7 @@ func TestSchedulerWithContextCancellation(t *testing.T) {
|
||||
q := queue.NewTaskQueue(100)
|
||||
agentMgr := newMockAgentManager()
|
||||
log := createTestLogger()
|
||||
exec := createTestExecutor(agentMgr, log, 5)
|
||||
exec := createTestExecutor(t, agentMgr, log, 5)
|
||||
taskRepo := newTestTaskRepository()
|
||||
|
||||
cfg := DefaultSchedulerConfig()
|
||||
@@ -425,7 +430,7 @@ func TestEnqueueDuplicateTask(t *testing.T) {
|
||||
q := queue.NewTaskQueue(100)
|
||||
agentMgr := newMockAgentManager()
|
||||
log := createTestLogger()
|
||||
exec := createTestExecutor(agentMgr, log, 5)
|
||||
exec := createTestExecutor(t, agentMgr, log, 5)
|
||||
taskRepo := newTestTaskRepository()
|
||||
|
||||
s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig())
|
||||
@@ -443,7 +448,7 @@ func TestRetryTaskExceedsLimit(t *testing.T) {
|
||||
q := queue.NewTaskQueue(100)
|
||||
agentMgr := newMockAgentManager()
|
||||
log := createTestLogger()
|
||||
exec := createTestExecutor(agentMgr, log, 5)
|
||||
exec := createTestExecutor(t, agentMgr, log, 5)
|
||||
taskRepo := newTestTaskRepository()
|
||||
|
||||
cfg := DefaultSchedulerConfig()
|
||||
@@ -477,7 +482,7 @@ func TestRetryTaskNotFound(t *testing.T) {
|
||||
q := queue.NewTaskQueue(100)
|
||||
agentMgr := newMockAgentManager()
|
||||
log := createTestLogger()
|
||||
exec := createTestExecutor(agentMgr, log, 5)
|
||||
exec := createTestExecutor(t, agentMgr, log, 5)
|
||||
taskRepo := newTestTaskRepository()
|
||||
|
||||
cfg := DefaultSchedulerConfig()
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/database"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/orchestrator/executor"
|
||||
@@ -59,7 +58,6 @@ type Service struct {
|
||||
config ServiceConfig
|
||||
logger *logger.Logger
|
||||
eventBus bus.EventBus
|
||||
db *database.DB
|
||||
taskRepo scheduler.TaskRepository
|
||||
repo repository.Repository // Full repository for agent sessions
|
||||
agentManager executor.AgentManagerClient
|
||||
@@ -101,7 +99,6 @@ type Status struct {
|
||||
func NewService(
|
||||
cfg ServiceConfig,
|
||||
eventBus bus.EventBus,
|
||||
db *database.DB,
|
||||
agentManager executor.AgentManagerClient,
|
||||
taskRepo scheduler.TaskRepository,
|
||||
repo repository.Repository,
|
||||
@@ -127,7 +124,6 @@ func NewService(
|
||||
config: cfg,
|
||||
logger: svcLogger,
|
||||
eventBus: eventBus,
|
||||
db: db,
|
||||
taskRepo: taskRepo,
|
||||
repo: repo,
|
||||
agentManager: agentManager,
|
||||
|
||||
@@ -1,987 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
// MemoryRepository provides in-memory task storage operations
|
||||
type MemoryRepository struct {
|
||||
workspaces map[string]*models.Workspace
|
||||
tasks map[string]*models.Task
|
||||
boards map[string]*models.Board
|
||||
columns map[string]*models.Column
|
||||
comments map[string]*models.Comment
|
||||
repositories map[string]*models.Repository
|
||||
repoScripts map[string]*models.RepositoryScript
|
||||
agentSessions map[string]*models.AgentSession
|
||||
executors map[string]*models.Executor
|
||||
environments map[string]*models.Environment
|
||||
taskBoards map[string]map[string]struct{}
|
||||
taskPlacements map[string]map[string]*taskPlacement
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// Ensure MemoryRepository implements Repository interface
|
||||
var _ Repository = (*MemoryRepository)(nil)
|
||||
|
||||
// NewMemoryRepository creates a new in-memory task repository
|
||||
func NewMemoryRepository() *MemoryRepository {
|
||||
repo := &MemoryRepository{
|
||||
workspaces: make(map[string]*models.Workspace),
|
||||
tasks: make(map[string]*models.Task),
|
||||
boards: make(map[string]*models.Board),
|
||||
columns: make(map[string]*models.Column),
|
||||
comments: make(map[string]*models.Comment),
|
||||
repositories: make(map[string]*models.Repository),
|
||||
repoScripts: make(map[string]*models.RepositoryScript),
|
||||
agentSessions: make(map[string]*models.AgentSession),
|
||||
executors: make(map[string]*models.Executor),
|
||||
environments: make(map[string]*models.Environment),
|
||||
taskBoards: make(map[string]map[string]struct{}),
|
||||
taskPlacements: make(map[string]map[string]*taskPlacement),
|
||||
}
|
||||
repo.seedDefaults()
|
||||
return repo
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) seedDefaults() {
|
||||
now := time.Now().UTC()
|
||||
r.executors[models.ExecutorIDLocalPC] = &models.Executor{
|
||||
ID: models.ExecutorIDLocalPC,
|
||||
Name: "Local PC",
|
||||
Type: models.ExecutorTypeLocalPC,
|
||||
Status: models.ExecutorStatusActive,
|
||||
IsSystem: true,
|
||||
Config: map[string]string{},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
r.executors[models.ExecutorIDLocalDocker] = &models.Executor{
|
||||
ID: models.ExecutorIDLocalDocker,
|
||||
Name: "Local Docker",
|
||||
Type: models.ExecutorTypeLocalDocker,
|
||||
Status: models.ExecutorStatusActive,
|
||||
IsSystem: false,
|
||||
Config: map[string]string{"docker_host": "unix:///var/run/docker.sock"},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
r.executors[models.ExecutorIDRemoteDocker] = &models.Executor{
|
||||
ID: models.ExecutorIDRemoteDocker,
|
||||
Name: "Remote Docker",
|
||||
Type: models.ExecutorTypeRemoteDocker,
|
||||
Status: models.ExecutorStatusDisabled,
|
||||
IsSystem: false,
|
||||
Config: map[string]string{},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
r.environments[models.EnvironmentIDLocal] = &models.Environment{
|
||||
ID: models.EnvironmentIDLocal,
|
||||
Name: "Local",
|
||||
Kind: models.EnvironmentKindLocalPC,
|
||||
WorktreeRoot: "~/kandev",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
type taskPlacement struct {
|
||||
boardID string
|
||||
columnID string
|
||||
position int
|
||||
}
|
||||
|
||||
// Close is a no-op for in-memory repository
|
||||
func (r *MemoryRepository) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Workspace operations
|
||||
|
||||
// CreateWorkspace creates a new workspace
|
||||
func (r *MemoryRepository) CreateWorkspace(ctx context.Context, workspace *models.Workspace) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if workspace.ID == "" {
|
||||
workspace.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
workspace.CreatedAt = now
|
||||
workspace.UpdatedAt = now
|
||||
|
||||
r.workspaces[workspace.ID] = workspace
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetWorkspace retrieves a workspace by ID
|
||||
func (r *MemoryRepository) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
workspace, ok := r.workspaces[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("workspace not found: %s", id)
|
||||
}
|
||||
return workspace, nil
|
||||
}
|
||||
|
||||
// UpdateWorkspace updates an existing workspace
|
||||
func (r *MemoryRepository) UpdateWorkspace(ctx context.Context, workspace *models.Workspace) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.workspaces[workspace.ID]; !ok {
|
||||
return fmt.Errorf("workspace not found: %s", workspace.ID)
|
||||
}
|
||||
workspace.UpdatedAt = time.Now().UTC()
|
||||
r.workspaces[workspace.ID] = workspace
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteWorkspace deletes a workspace by ID
|
||||
func (r *MemoryRepository) DeleteWorkspace(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.workspaces[id]; !ok {
|
||||
return fmt.Errorf("workspace not found: %s", id)
|
||||
}
|
||||
delete(r.workspaces, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListWorkspaces returns all workspaces
|
||||
func (r *MemoryRepository) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
result := make([]*models.Workspace, 0, len(r.workspaces))
|
||||
for _, workspace := range r.workspaces {
|
||||
result = append(result, workspace)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Task operations
|
||||
|
||||
// CreateTask creates a new task
|
||||
func (r *MemoryRepository) CreateTask(ctx context.Context, task *models.Task) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if task.ID == "" {
|
||||
task.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
task.CreatedAt = now
|
||||
task.UpdatedAt = now
|
||||
|
||||
r.tasks[task.ID] = task
|
||||
if task.BoardID != "" {
|
||||
r.addTaskPlacement(task.ID, task.BoardID, task.ColumnID, task.Position)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTask retrieves a task by ID
|
||||
func (r *MemoryRepository) GetTask(ctx context.Context, id string) (*models.Task, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
task, ok := r.tasks[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("task not found: %s", id)
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// UpdateTask updates an existing task
|
||||
func (r *MemoryRepository) UpdateTask(ctx context.Context, task *models.Task) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.tasks[task.ID]; !ok {
|
||||
return fmt.Errorf("task not found: %s", task.ID)
|
||||
}
|
||||
task.UpdatedAt = time.Now().UTC()
|
||||
r.tasks[task.ID] = task
|
||||
if task.BoardID != "" {
|
||||
r.addTaskPlacement(task.ID, task.BoardID, task.ColumnID, task.Position)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTask deletes a task by ID
|
||||
func (r *MemoryRepository) DeleteTask(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.tasks[id]; !ok {
|
||||
return fmt.Errorf("task not found: %s", id)
|
||||
}
|
||||
delete(r.tasks, id)
|
||||
delete(r.taskBoards, id)
|
||||
delete(r.taskPlacements, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTasks returns all tasks for a board
|
||||
func (r *MemoryRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
var result []*models.Task
|
||||
for _, task := range r.tasks {
|
||||
placement, ok := r.getTaskPlacement(task.ID, boardID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
copy := *task
|
||||
copy.BoardID = placement.boardID
|
||||
copy.ColumnID = placement.columnID
|
||||
copy.Position = placement.position
|
||||
result = append(result, ©)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListTasksByColumn returns all tasks in a column
|
||||
func (r *MemoryRepository) ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
var result []*models.Task
|
||||
for _, task := range r.tasks {
|
||||
placements, ok := r.taskPlacements[task.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, placement := range placements {
|
||||
if placement.columnID != columnID {
|
||||
continue
|
||||
}
|
||||
copy := *task
|
||||
copy.BoardID = placement.boardID
|
||||
copy.ColumnID = placement.columnID
|
||||
copy.Position = placement.position
|
||||
result = append(result, ©)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateTaskState updates the state of a task
|
||||
func (r *MemoryRepository) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
task, ok := r.tasks[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("task not found: %s", id)
|
||||
}
|
||||
task.State = state
|
||||
task.UpdatedAt = time.Now().UTC()
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddTaskToBoard adds a task to a board with placement
|
||||
func (r *MemoryRepository) AddTaskToBoard(ctx context.Context, taskID, boardID, columnID string, position int) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.tasks[taskID]; !ok {
|
||||
return fmt.Errorf("task not found: %s", taskID)
|
||||
}
|
||||
r.addTaskPlacement(taskID, boardID, columnID, position)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveTaskFromBoard removes a task from a board
|
||||
func (r *MemoryRepository) RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.tasks[taskID]; !ok {
|
||||
return fmt.Errorf("task not found: %s", taskID)
|
||||
}
|
||||
if placements, ok := r.taskPlacements[taskID]; ok {
|
||||
delete(placements, boardID)
|
||||
}
|
||||
if boards, ok := r.taskBoards[taskID]; ok {
|
||||
delete(boards, boardID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Board operations
|
||||
|
||||
// CreateBoard creates a new board
|
||||
func (r *MemoryRepository) CreateBoard(ctx context.Context, board *models.Board) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if board.ID == "" {
|
||||
board.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
board.CreatedAt = now
|
||||
board.UpdatedAt = now
|
||||
|
||||
r.boards[board.ID] = board
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBoard retrieves a board by ID
|
||||
func (r *MemoryRepository) GetBoard(ctx context.Context, id string) (*models.Board, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
board, ok := r.boards[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("board not found: %s", id)
|
||||
}
|
||||
return board, nil
|
||||
}
|
||||
|
||||
// UpdateBoard updates an existing board
|
||||
func (r *MemoryRepository) UpdateBoard(ctx context.Context, board *models.Board) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.boards[board.ID]; !ok {
|
||||
return fmt.Errorf("board not found: %s", board.ID)
|
||||
}
|
||||
board.UpdatedAt = time.Now().UTC()
|
||||
r.boards[board.ID] = board
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteBoard deletes a board by ID
|
||||
func (r *MemoryRepository) DeleteBoard(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.boards[id]; !ok {
|
||||
return fmt.Errorf("board not found: %s", id)
|
||||
}
|
||||
delete(r.boards, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBoards returns all boards
|
||||
func (r *MemoryRepository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
result := make([]*models.Board, 0, len(r.boards))
|
||||
for _, board := range r.boards {
|
||||
if workspaceID != "" && board.WorkspaceID != workspaceID {
|
||||
continue
|
||||
}
|
||||
result = append(result, board)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Column operations
|
||||
|
||||
// CreateColumn creates a new column
|
||||
func (r *MemoryRepository) CreateColumn(ctx context.Context, column *models.Column) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if column.ID == "" {
|
||||
column.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
column.CreatedAt = now
|
||||
column.UpdatedAt = now
|
||||
|
||||
r.columns[column.ID] = column
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetColumn retrieves a column by ID
|
||||
func (r *MemoryRepository) GetColumn(ctx context.Context, id string) (*models.Column, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
column, ok := r.columns[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("column not found: %s", id)
|
||||
}
|
||||
return column, nil
|
||||
}
|
||||
|
||||
// GetColumnByState retrieves a column by board ID and state
|
||||
func (r *MemoryRepository) GetColumnByState(ctx context.Context, boardID string, state v1.TaskState) (*models.Column, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, column := range r.columns {
|
||||
if column.BoardID == boardID && column.State == state {
|
||||
return column, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("column not found for board %s with state %s", boardID, state)
|
||||
}
|
||||
|
||||
// UpdateColumn updates an existing column
|
||||
func (r *MemoryRepository) UpdateColumn(ctx context.Context, column *models.Column) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.columns[column.ID]; !ok {
|
||||
return fmt.Errorf("column not found: %s", column.ID)
|
||||
}
|
||||
column.UpdatedAt = time.Now().UTC()
|
||||
r.columns[column.ID] = column
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteColumn deletes a column by ID
|
||||
func (r *MemoryRepository) DeleteColumn(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.columns[id]; !ok {
|
||||
return fmt.Errorf("column not found: %s", id)
|
||||
}
|
||||
delete(r.columns, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListColumns returns all columns for a board
|
||||
func (r *MemoryRepository) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
var result []*models.Column
|
||||
for _, column := range r.columns {
|
||||
if column.BoardID == boardID {
|
||||
result = append(result, column)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Repository operations
|
||||
|
||||
func (r *MemoryRepository) CreateRepository(ctx context.Context, repository *models.Repository) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if repository.ID == "" {
|
||||
repository.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
repository.CreatedAt = now
|
||||
repository.UpdatedAt = now
|
||||
r.repositories[repository.ID] = repository
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) GetRepository(ctx context.Context, id string) (*models.Repository, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
repository, ok := r.repositories[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("repository not found: %s", id)
|
||||
}
|
||||
return repository, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) UpdateRepository(ctx context.Context, repository *models.Repository) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.repositories[repository.ID]; !ok {
|
||||
return fmt.Errorf("repository not found: %s", repository.ID)
|
||||
}
|
||||
repository.UpdatedAt = time.Now().UTC()
|
||||
r.repositories[repository.ID] = repository
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) DeleteRepository(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.repositories[id]; !ok {
|
||||
return fmt.Errorf("repository not found: %s", id)
|
||||
}
|
||||
delete(r.repositories, id)
|
||||
for scriptID, script := range r.repoScripts {
|
||||
if script.RepositoryID == id {
|
||||
delete(r.repoScripts, scriptID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
result := make([]*models.Repository, 0)
|
||||
for _, repository := range r.repositories {
|
||||
if repository.WorkspaceID == workspaceID {
|
||||
result = append(result, repository)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Repository script operations
|
||||
|
||||
func (r *MemoryRepository) CreateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if script.ID == "" {
|
||||
script.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
script.CreatedAt = now
|
||||
script.UpdatedAt = now
|
||||
r.repoScripts[script.ID] = script
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
script, ok := r.repoScripts[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("repository script not found: %s", id)
|
||||
}
|
||||
return script, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) UpdateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.repoScripts[script.ID]; !ok {
|
||||
return fmt.Errorf("repository script not found: %s", script.ID)
|
||||
}
|
||||
script.UpdatedAt = time.Now().UTC()
|
||||
r.repoScripts[script.ID] = script
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) DeleteRepositoryScript(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.repoScripts[id]; !ok {
|
||||
return fmt.Errorf("repository script not found: %s", id)
|
||||
}
|
||||
delete(r.repoScripts, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
result := make([]*models.RepositoryScript, 0)
|
||||
for _, script := range r.repoScripts {
|
||||
if script.RepositoryID == repositoryID {
|
||||
result = append(result, script)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) addTaskPlacement(taskID, boardID, columnID string, position int) {
|
||||
if _, ok := r.taskBoards[taskID]; !ok {
|
||||
r.taskBoards[taskID] = make(map[string]struct{})
|
||||
}
|
||||
r.taskBoards[taskID][boardID] = struct{}{}
|
||||
|
||||
if _, ok := r.taskPlacements[taskID]; !ok {
|
||||
r.taskPlacements[taskID] = make(map[string]*taskPlacement)
|
||||
}
|
||||
r.taskPlacements[taskID][boardID] = &taskPlacement{
|
||||
boardID: boardID,
|
||||
columnID: columnID,
|
||||
position: position,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) getTaskPlacement(taskID, boardID string) (*taskPlacement, bool) {
|
||||
placements, ok := r.taskPlacements[taskID]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
placement, ok := placements[boardID]
|
||||
return placement, ok
|
||||
}
|
||||
|
||||
// Comment operations
|
||||
|
||||
// CreateComment creates a new comment
|
||||
func (r *MemoryRepository) CreateComment(ctx context.Context, comment *models.Comment) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if comment.ID == "" {
|
||||
comment.ID = uuid.New().String()
|
||||
}
|
||||
if comment.CreatedAt.IsZero() {
|
||||
comment.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if comment.AuthorType == "" {
|
||||
comment.AuthorType = models.CommentAuthorUser
|
||||
}
|
||||
if comment.Type == "" {
|
||||
comment.Type = models.CommentTypeMessage
|
||||
}
|
||||
|
||||
r.comments[comment.ID] = comment
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetComment retrieves a comment by ID
|
||||
func (r *MemoryRepository) GetComment(ctx context.Context, id string) (*models.Comment, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
comment, ok := r.comments[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("comment not found: %s", id)
|
||||
}
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
// ListComments returns all comments for a task ordered by creation time
|
||||
func (r *MemoryRepository) ListComments(ctx context.Context, taskID string) ([]*models.Comment, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
var result []*models.Comment
|
||||
for _, comment := range r.comments {
|
||||
if comment.TaskID == taskID {
|
||||
result = append(result, comment)
|
||||
}
|
||||
}
|
||||
// Sort by CreatedAt
|
||||
for i := 0; i < len(result); i++ {
|
||||
for j := i + 1; j < len(result); j++ {
|
||||
if result[i].CreatedAt.After(result[j].CreatedAt) {
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteComment deletes a comment by ID
|
||||
func (r *MemoryRepository) DeleteComment(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.comments[id]; !ok {
|
||||
return fmt.Errorf("comment not found: %s", id)
|
||||
}
|
||||
delete(r.comments, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCommentByToolCallID retrieves a comment by task ID and tool_call_id in metadata
|
||||
func (r *MemoryRepository) GetCommentByToolCallID(ctx context.Context, taskID, toolCallID string) (*models.Comment, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, comment := range r.comments {
|
||||
if comment.TaskID == taskID && comment.Metadata != nil {
|
||||
if tcID, ok := comment.Metadata["tool_call_id"].(string); ok && tcID == toolCallID {
|
||||
return comment, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("comment not found for tool_call_id: %s", toolCallID)
|
||||
}
|
||||
|
||||
// UpdateComment updates an existing comment
|
||||
func (r *MemoryRepository) UpdateComment(ctx context.Context, comment *models.Comment) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.comments[comment.ID]; !ok {
|
||||
return fmt.Errorf("comment not found: %s", comment.ID)
|
||||
}
|
||||
r.comments[comment.ID] = comment
|
||||
return nil
|
||||
}
|
||||
|
||||
// AgentSession operations
|
||||
|
||||
// CreateAgentSession creates a new agent session
|
||||
func (r *MemoryRepository) CreateAgentSession(ctx context.Context, session *models.AgentSession) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if session.ID == "" {
|
||||
session.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if session.StartedAt.IsZero() {
|
||||
session.StartedAt = now
|
||||
}
|
||||
session.UpdatedAt = now
|
||||
|
||||
r.agentSessions[session.ID] = session
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAgentSession retrieves an agent session by ID
|
||||
func (r *MemoryRepository) GetAgentSession(ctx context.Context, id string) (*models.AgentSession, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
session, ok := r.agentSessions[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("agent session not found: %s", id)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// GetAgentSessionByTaskID retrieves the most recent agent session for a task
|
||||
func (r *MemoryRepository) GetAgentSessionByTaskID(ctx context.Context, taskID string) (*models.AgentSession, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
var latest *models.AgentSession
|
||||
for _, session := range r.agentSessions {
|
||||
if session.TaskID == taskID {
|
||||
if latest == nil || session.StartedAt.After(latest.StartedAt) {
|
||||
latest = session
|
||||
}
|
||||
}
|
||||
}
|
||||
if latest == nil {
|
||||
return nil, fmt.Errorf("no agent session found for task: %s", taskID)
|
||||
}
|
||||
return latest, nil
|
||||
}
|
||||
|
||||
// GetActiveAgentSessionByTaskID retrieves the active agent session for a task
|
||||
func (r *MemoryRepository) GetActiveAgentSessionByTaskID(ctx context.Context, taskID string) (*models.AgentSession, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, session := range r.agentSessions {
|
||||
if session.TaskID == taskID && (session.Status == models.AgentSessionStatusRunning || session.Status == models.AgentSessionStatusWaiting || session.Status == models.AgentSessionStatusPending) {
|
||||
return session, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no active agent session found for task: %s", taskID)
|
||||
}
|
||||
|
||||
// UpdateAgentSession updates an existing agent session
|
||||
func (r *MemoryRepository) UpdateAgentSession(ctx context.Context, session *models.AgentSession) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.agentSessions[session.ID]; !ok {
|
||||
return fmt.Errorf("agent session not found: %s", session.ID)
|
||||
}
|
||||
session.UpdatedAt = time.Now().UTC()
|
||||
r.agentSessions[session.ID] = session
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateAgentSessionStatus updates the status of an agent session
|
||||
func (r *MemoryRepository) UpdateAgentSessionStatus(ctx context.Context, id string, status models.AgentSessionStatus, errorMessage string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
session, ok := r.agentSessions[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("agent session not found: %s", id)
|
||||
}
|
||||
session.Status = status
|
||||
session.ErrorMessage = errorMessage
|
||||
session.UpdatedAt = time.Now().UTC()
|
||||
if status == models.AgentSessionStatusCompleted || status == models.AgentSessionStatusFailed || status == models.AgentSessionStatusStopped {
|
||||
now := time.Now().UTC()
|
||||
session.CompletedAt = &now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAgentSessions returns all agent sessions for a task
|
||||
func (r *MemoryRepository) ListAgentSessions(ctx context.Context, taskID string) ([]*models.AgentSession, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
var result []*models.AgentSession
|
||||
for _, session := range r.agentSessions {
|
||||
if session.TaskID == taskID {
|
||||
result = append(result, session)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListActiveAgentSessions returns all active agent sessions
|
||||
func (r *MemoryRepository) ListActiveAgentSessions(ctx context.Context) ([]*models.AgentSession, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
var result []*models.AgentSession
|
||||
for _, session := range r.agentSessions {
|
||||
if session.Status == models.AgentSessionStatusRunning || session.Status == models.AgentSessionStatusWaiting || session.Status == models.AgentSessionStatusPending {
|
||||
result = append(result, session)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteAgentSession deletes an agent session by ID
|
||||
func (r *MemoryRepository) DeleteAgentSession(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.agentSessions[id]; !ok {
|
||||
return fmt.Errorf("agent session not found: %s", id)
|
||||
}
|
||||
delete(r.agentSessions, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Executor operations
|
||||
|
||||
func (r *MemoryRepository) CreateExecutor(ctx context.Context, executor *models.Executor) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if executor.ID == "" {
|
||||
executor.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
executor.CreatedAt = now
|
||||
executor.UpdatedAt = now
|
||||
|
||||
r.executors[executor.ID] = executor
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) GetExecutor(ctx context.Context, id string) (*models.Executor, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
executor, ok := r.executors[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("executor not found: %s", id)
|
||||
}
|
||||
return executor, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) UpdateExecutor(ctx context.Context, executor *models.Executor) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.executors[executor.ID]; !ok {
|
||||
return fmt.Errorf("executor not found: %s", executor.ID)
|
||||
}
|
||||
executor.UpdatedAt = time.Now().UTC()
|
||||
r.executors[executor.ID] = executor
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) DeleteExecutor(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.executors[id]; !ok {
|
||||
return fmt.Errorf("executor not found: %s", id)
|
||||
}
|
||||
delete(r.executors, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) ListExecutors(ctx context.Context) ([]*models.Executor, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
result := make([]*models.Executor, 0, len(r.executors))
|
||||
for _, executor := range r.executors {
|
||||
result = append(result, executor)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Environment operations
|
||||
|
||||
func (r *MemoryRepository) CreateEnvironment(ctx context.Context, environment *models.Environment) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if environment.ID == "" {
|
||||
environment.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
environment.CreatedAt = now
|
||||
environment.UpdatedAt = now
|
||||
r.environments[environment.ID] = environment
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) GetEnvironment(ctx context.Context, id string) (*models.Environment, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
environment, ok := r.environments[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("environment not found: %s", id)
|
||||
}
|
||||
return environment, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) UpdateEnvironment(ctx context.Context, environment *models.Environment) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.environments[environment.ID]; !ok {
|
||||
return fmt.Errorf("environment not found: %s", environment.ID)
|
||||
}
|
||||
environment.UpdatedAt = time.Now().UTC()
|
||||
r.environments[environment.ID] = environment
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) DeleteEnvironment(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.environments[id]; !ok {
|
||||
return fmt.Errorf("environment not found: %s", id)
|
||||
}
|
||||
delete(r.environments, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) ListEnvironments(ctx context.Context) ([]*models.Environment, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
result := make([]*models.Environment, 0, len(r.environments))
|
||||
for _, environment := range r.environments {
|
||||
result = append(result, environment)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,465 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
func TestNewMemoryRepository(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
if repo == nil {
|
||||
t.Fatal("expected non-nil repository")
|
||||
}
|
||||
if repo.tasks == nil {
|
||||
t.Error("expected tasks map to be initialized")
|
||||
}
|
||||
if repo.workspaces == nil {
|
||||
t.Error("expected workspaces map to be initialized")
|
||||
}
|
||||
if repo.boards == nil {
|
||||
t.Error("expected boards map to be initialized")
|
||||
}
|
||||
if repo.columns == nil {
|
||||
t.Error("expected columns map to be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRepository_Close(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
err := repo.Close()
|
||||
if err != nil {
|
||||
t.Errorf("expected no error on close, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Board CRUD tests
|
||||
|
||||
func TestMemoryRepository_BoardCRUD(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
workspace := &models.Workspace{ID: "ws-1", Name: "Workspace"}
|
||||
if err := repo.CreateWorkspace(ctx, workspace); err != nil {
|
||||
t.Fatalf("failed to create workspace: %v", err)
|
||||
}
|
||||
|
||||
// Create
|
||||
board := &models.Board{WorkspaceID: workspace.ID, Name: "Test Board", Description: "A test board"}
|
||||
if err := repo.CreateBoard(ctx, board); err != nil {
|
||||
t.Fatalf("failed to create board: %v", err)
|
||||
}
|
||||
if board.ID == "" {
|
||||
t.Error("expected board ID to be set")
|
||||
}
|
||||
if board.CreatedAt.IsZero() {
|
||||
t.Error("expected CreatedAt to be set")
|
||||
}
|
||||
|
||||
// Get
|
||||
retrieved, err := repo.GetBoard(ctx, board.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get board: %v", err)
|
||||
}
|
||||
if retrieved.Name != "Test Board" {
|
||||
t.Errorf("expected name 'Test Board', got %s", retrieved.Name)
|
||||
}
|
||||
|
||||
// Update
|
||||
board.Name = "Updated Name"
|
||||
if err := repo.UpdateBoard(ctx, board); err != nil {
|
||||
t.Fatalf("failed to update board: %v", err)
|
||||
}
|
||||
retrieved, _ = repo.GetBoard(ctx, board.ID)
|
||||
if retrieved.Name != "Updated Name" {
|
||||
t.Errorf("expected name 'Updated Name', got %s", retrieved.Name)
|
||||
}
|
||||
|
||||
// Delete
|
||||
if err := repo.DeleteBoard(ctx, board.ID); err != nil {
|
||||
t.Fatalf("failed to delete board: %v", err)
|
||||
}
|
||||
_, err = repo.GetBoard(ctx, board.ID)
|
||||
if err == nil {
|
||||
t.Error("expected board to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRepository_BoardNotFound(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := repo.GetBoard(ctx, "nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent board")
|
||||
}
|
||||
|
||||
err = repo.UpdateBoard(ctx, &models.Board{ID: "nonexistent", Name: "Test"})
|
||||
if err == nil {
|
||||
t.Error("expected error for updating nonexistent board")
|
||||
}
|
||||
|
||||
err = repo.DeleteBoard(ctx, "nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for deleting nonexistent board")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRepository_ListBoards(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"})
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"})
|
||||
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-1", WorkspaceID: "ws-1", Name: "Board 1"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-2", WorkspaceID: "ws-1", Name: "Board 2"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-3", WorkspaceID: "ws-2", Name: "Board 3"})
|
||||
|
||||
boards, err := repo.ListBoards(ctx, "ws-1")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list boards: %v", err)
|
||||
}
|
||||
if len(boards) != 2 {
|
||||
t.Errorf("expected 2 boards, got %d", len(boards))
|
||||
}
|
||||
}
|
||||
|
||||
// Column CRUD tests
|
||||
|
||||
func TestMemoryRepository_ColumnCRUD(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create
|
||||
column := &models.Column{BoardID: "board-123", Name: "To Do", Position: 0, State: v1.TaskStateTODO}
|
||||
if err := repo.CreateColumn(ctx, column); err != nil {
|
||||
t.Fatalf("failed to create column: %v", err)
|
||||
}
|
||||
if column.ID == "" {
|
||||
t.Error("expected column ID to be set")
|
||||
}
|
||||
|
||||
// Get
|
||||
retrieved, err := repo.GetColumn(ctx, column.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get column: %v", err)
|
||||
}
|
||||
if retrieved.Name != "To Do" {
|
||||
t.Errorf("expected name 'To Do', got %s", retrieved.Name)
|
||||
}
|
||||
|
||||
// Update
|
||||
column.Name = "Done"
|
||||
column.State = v1.TaskStateCompleted
|
||||
if err := repo.UpdateColumn(ctx, column); err != nil {
|
||||
t.Fatalf("failed to update column: %v", err)
|
||||
}
|
||||
retrieved, _ = repo.GetColumn(ctx, column.ID)
|
||||
if retrieved.Name != "Done" {
|
||||
t.Errorf("expected name 'Done', got %s", retrieved.Name)
|
||||
}
|
||||
|
||||
// Delete
|
||||
if err := repo.DeleteColumn(ctx, column.ID); err != nil {
|
||||
t.Fatalf("failed to delete column: %v", err)
|
||||
}
|
||||
_, err = repo.GetColumn(ctx, column.ID)
|
||||
if err == nil {
|
||||
t.Error("expected column to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRepository_ColumnNotFound(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := repo.GetColumn(ctx, "nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent column")
|
||||
}
|
||||
|
||||
err = repo.UpdateColumn(ctx, &models.Column{ID: "nonexistent", Name: "Test"})
|
||||
if err == nil {
|
||||
t.Error("expected error for updating nonexistent column")
|
||||
}
|
||||
|
||||
err = repo.DeleteColumn(ctx, "nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for deleting nonexistent column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRepository_ListColumns(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-1", BoardID: "board-123", Name: "To Do"})
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-2", BoardID: "board-123", Name: "In Progress"})
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-3", BoardID: "board-456", Name: "Done"})
|
||||
|
||||
columns, err := repo.ListColumns(ctx, "board-123")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list columns: %v", err)
|
||||
}
|
||||
if len(columns) != 2 {
|
||||
t.Errorf("expected 2 columns for board-123, got %d", len(columns))
|
||||
}
|
||||
}
|
||||
|
||||
// Task CRUD tests
|
||||
|
||||
func TestMemoryRepository_TaskCRUD(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create
|
||||
task := &models.Task{
|
||||
WorkspaceID: "ws-1",
|
||||
BoardID: "board-123",
|
||||
ColumnID: "col-123",
|
||||
Title: "Test Task",
|
||||
Description: "A test task",
|
||||
State: v1.TaskStateTODO,
|
||||
Priority: 5,
|
||||
}
|
||||
if err := repo.CreateTask(ctx, task); err != nil {
|
||||
t.Fatalf("failed to create task: %v", err)
|
||||
}
|
||||
if task.ID == "" {
|
||||
t.Error("expected task ID to be set")
|
||||
}
|
||||
if task.CreatedAt.IsZero() {
|
||||
t.Error("expected CreatedAt to be set")
|
||||
}
|
||||
|
||||
// Get
|
||||
retrieved, err := repo.GetTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get task: %v", err)
|
||||
}
|
||||
if retrieved.Title != "Test Task" {
|
||||
t.Errorf("expected title 'Test Task', got %s", retrieved.Title)
|
||||
}
|
||||
|
||||
// Update
|
||||
task.Title = "Updated Task"
|
||||
task.Description = "Updated description"
|
||||
if err := repo.UpdateTask(ctx, task); err != nil {
|
||||
t.Fatalf("failed to update task: %v", err)
|
||||
}
|
||||
retrieved, _ = repo.GetTask(ctx, task.ID)
|
||||
if retrieved.Title != "Updated Task" {
|
||||
t.Errorf("expected title 'Updated Task', got %s", retrieved.Title)
|
||||
}
|
||||
|
||||
// Delete
|
||||
if err := repo.DeleteTask(ctx, task.ID); err != nil {
|
||||
t.Fatalf("failed to delete task: %v", err)
|
||||
}
|
||||
_, err = repo.GetTask(ctx, task.ID)
|
||||
if err == nil {
|
||||
t.Error("expected task to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRepository_TaskNotFound(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := repo.GetTask(ctx, "nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent task")
|
||||
}
|
||||
|
||||
err = repo.UpdateTask(ctx, &models.Task{ID: "nonexistent", Title: "Test"})
|
||||
if err == nil {
|
||||
t.Error("expected error for updating nonexistent task")
|
||||
}
|
||||
|
||||
err = repo.DeleteTask(ctx, "nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for deleting nonexistent task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRepository_UpdateTaskState(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
|
||||
err := repo.UpdateTaskState(ctx, "task-123", v1.TaskStateInProgress)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update task state: %v", err)
|
||||
}
|
||||
|
||||
retrieved, _ := repo.GetTask(ctx, "task-123")
|
||||
if retrieved.State != v1.TaskStateInProgress {
|
||||
t.Errorf("expected state IN_PROGRESS, got %s", retrieved.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRepository_UpdateTaskStateNotFound(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
err := repo.UpdateTaskState(ctx, "nonexistent", v1.TaskStateInProgress)
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRepository_ListTasks(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 1"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 2"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-456", ColumnID: "col-2", Title: "Task 3"})
|
||||
|
||||
tasks, err := repo.ListTasks(ctx, "board-123")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list tasks: %v", err)
|
||||
}
|
||||
if len(tasks) != 2 {
|
||||
t.Errorf("expected 2 tasks for board-123, got %d", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRepository_ListTasksByColumn(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 1"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 2"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-2", Title: "Task 3"})
|
||||
|
||||
tasks, err := repo.ListTasksByColumn(ctx, "col-1")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list tasks by column: %v", err)
|
||||
}
|
||||
if len(tasks) != 2 {
|
||||
t.Errorf("expected 2 tasks for col-1, got %d", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
// Comment CRUD tests
|
||||
|
||||
func TestMemoryRepository_CommentCRUD(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a task first
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
|
||||
// Create comment
|
||||
comment := &models.Comment{
|
||||
TaskID: "task-123",
|
||||
AuthorType: models.CommentAuthorUser,
|
||||
AuthorID: "user-123",
|
||||
Content: "This is a test comment",
|
||||
RequestsInput: false,
|
||||
}
|
||||
if err := repo.CreateComment(ctx, comment); err != nil {
|
||||
t.Fatalf("failed to create comment: %v", err)
|
||||
}
|
||||
if comment.ID == "" {
|
||||
t.Error("expected comment ID to be set")
|
||||
}
|
||||
if comment.CreatedAt.IsZero() {
|
||||
t.Error("expected CreatedAt to be set")
|
||||
}
|
||||
|
||||
// Get comment
|
||||
retrieved, err := repo.GetComment(ctx, comment.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get comment: %v", err)
|
||||
}
|
||||
if retrieved.Content != "This is a test comment" {
|
||||
t.Errorf("expected content 'This is a test comment', got %s", retrieved.Content)
|
||||
}
|
||||
if retrieved.AuthorType != models.CommentAuthorUser {
|
||||
t.Errorf("expected author type 'user', got %s", retrieved.AuthorType)
|
||||
}
|
||||
|
||||
// Delete comment
|
||||
if err := repo.DeleteComment(ctx, comment.ID); err != nil {
|
||||
t.Fatalf("failed to delete comment: %v", err)
|
||||
}
|
||||
_, err = repo.GetComment(ctx, comment.ID)
|
||||
if err == nil {
|
||||
t.Error("expected comment to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRepository_CommentNotFound(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := repo.GetComment(ctx, "nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent comment")
|
||||
}
|
||||
|
||||
err = repo.DeleteComment(ctx, "nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for deleting nonexistent comment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRepository_ListComments(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a task
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
|
||||
// Create multiple comments
|
||||
_ = repo.CreateComment(ctx, &models.Comment{ID: "comment-1", TaskID: "task-123", AuthorType: models.CommentAuthorUser, Content: "Comment 1"})
|
||||
_ = repo.CreateComment(ctx, &models.Comment{ID: "comment-2", TaskID: "task-123", AuthorType: models.CommentAuthorAgent, Content: "Comment 2"})
|
||||
_ = repo.CreateComment(ctx, &models.Comment{ID: "comment-3", TaskID: "task-456", AuthorType: models.CommentAuthorUser, Content: "Comment 3"})
|
||||
|
||||
comments, err := repo.ListComments(ctx, "task-123")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list comments: %v", err)
|
||||
}
|
||||
if len(comments) != 2 {
|
||||
t.Errorf("expected 2 comments for task-123, got %d", len(comments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRepository_CommentWithRequestsInput(t *testing.T) {
|
||||
repo := NewMemoryRepository()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a task
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
|
||||
// Create agent comment requesting input
|
||||
comment := &models.Comment{
|
||||
TaskID: "task-123",
|
||||
AuthorType: models.CommentAuthorAgent,
|
||||
AuthorID: "agent-123",
|
||||
Content: "What should I do next?",
|
||||
RequestsInput: true,
|
||||
ACPSessionID: "session-abc",
|
||||
}
|
||||
if err := repo.CreateComment(ctx, comment); err != nil {
|
||||
t.Fatalf("failed to create comment: %v", err)
|
||||
}
|
||||
|
||||
retrieved, _ := repo.GetComment(ctx, comment.ID)
|
||||
if !retrieved.RequestsInput {
|
||||
t.Error("expected RequestsInput to be true")
|
||||
}
|
||||
if retrieved.ACPSessionID != "session-abc" {
|
||||
t.Errorf("expected ACPSessionID 'session-abc', got %s", retrieved.ACPSessionID)
|
||||
}
|
||||
}
|
||||
@@ -916,26 +916,7 @@ func (r *SQLiteRepository) ListTasksByColumn(ctx context.Context, columnID strin
|
||||
return r.scanTasksWithPlacement(rows)
|
||||
}
|
||||
|
||||
// scanTasks scans multiple task rows
|
||||
func (r *SQLiteRepository) scanTasks(rows *sql.Rows) ([]*models.Task, error) {
|
||||
var result []*models.Task
|
||||
for rows.Next() {
|
||||
task := &models.Task{}
|
||||
var metadata string
|
||||
var agentProfileID, repositoryURL, branch, assignedTo sql.NullString
|
||||
err := rows.Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &agentProfileID, &repositoryURL, &branch, &assignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task.AgentProfileID = agentProfileID.String
|
||||
task.RepositoryURL = repositoryURL.String
|
||||
task.Branch = branch.String
|
||||
task.AssignedTo = assignedTo.String
|
||||
_ = json.Unmarshal([]byte(metadata), &task.Metadata)
|
||||
result = append(result, task)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
|
||||
func (r *SQLiteRepository) scanTasksWithPlacement(rows *sql.Rows) ([]*models.Task, error) {
|
||||
var result []*models.Task
|
||||
|
||||
@@ -272,7 +272,12 @@ func writeRef(t *testing.T, path string) {
|
||||
|
||||
func newDiscoveryService(t *testing.T, root string) *Service {
|
||||
t.Helper()
|
||||
repo := repository.NewMemoryRepository()
|
||||
tmpDir := t.TempDir()
|
||||
repo, err := repository.NewSQLiteRepository(tmpDir + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test repository: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { repo.Close() })
|
||||
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"})
|
||||
eventBus := bus.NewMemoryEventBus(log)
|
||||
return NewService(repo, eventBus, log, RepositoryDiscoveryConfig{
|
||||
|
||||
@@ -67,9 +67,14 @@ func (m *MockEventBus) ClearEvents() {
|
||||
m.publishedEvents = make([]*bus.Event, 0)
|
||||
}
|
||||
|
||||
func createTestService(t *testing.T) (*Service, *MockEventBus, *repository.MemoryRepository) {
|
||||
func createTestService(t *testing.T) (*Service, *MockEventBus, repository.Repository) {
|
||||
t.Helper()
|
||||
repo := repository.NewMemoryRepository()
|
||||
tmpDir := t.TempDir()
|
||||
repo, err := repository.NewSQLiteRepository(tmpDir + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test repository: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { repo.Close() })
|
||||
eventBus := NewMockEventBus()
|
||||
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"})
|
||||
svc := NewService(repo, eventBus, log, RepositoryDiscoveryConfig{})
|
||||
@@ -128,9 +133,8 @@ func TestService_GetTask(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a task directly in repo
|
||||
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
// Create required entities
|
||||
setupTestTask(t, repo)
|
||||
|
||||
retrieved, err := svc.GetTask(ctx, "task-123")
|
||||
if err != nil {
|
||||
@@ -155,8 +159,10 @@ func TestService_UpdateTask(t *testing.T) {
|
||||
svc, eventBus, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Original"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Column", State: v1.TaskStateTODO})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Original"})
|
||||
eventBus.ClearEvents()
|
||||
|
||||
newTitle := "Updated Title"
|
||||
@@ -184,8 +190,10 @@ func TestService_DeleteTask(t *testing.T) {
|
||||
svc, eventBus, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Column", State: v1.TaskStateTODO})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test"})
|
||||
eventBus.ClearEvents()
|
||||
|
||||
err := svc.DeleteTask(ctx, "task-123")
|
||||
@@ -210,6 +218,9 @@ func TestService_ListTasks(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Column", State: v1.TaskStateTODO})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 1"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 2"})
|
||||
|
||||
@@ -227,6 +238,8 @@ func TestService_UpdateTaskState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Column", State: v1.TaskStateTODO})
|
||||
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
eventBus.ClearEvents()
|
||||
@@ -256,9 +269,10 @@ func TestService_MoveTask(t *testing.T) {
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
|
||||
|
||||
// Create column with different state
|
||||
column := &models.Column{ID: "col-done", BoardID: "board-123", Name: "Done", State: v1.TaskStateCompleted}
|
||||
_ = repo.CreateColumn(ctx, column)
|
||||
// Create source column
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Todo", State: v1.TaskStateTODO})
|
||||
// Create destination column with different state
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-done", BoardID: "board-123", Name: "Done", State: v1.TaskStateCompleted})
|
||||
|
||||
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
@@ -440,13 +454,21 @@ func TestService_ListColumns(t *testing.T) {
|
||||
|
||||
// Comment tests
|
||||
|
||||
func setupTestTask(t *testing.T, repo repository.Repository) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Column", State: v1.TaskStateTODO})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"})
|
||||
}
|
||||
|
||||
func TestService_CreateComment(t *testing.T) {
|
||||
svc, eventBus, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a task first
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
setupTestTask(t, repo)
|
||||
eventBus.ClearEvents()
|
||||
|
||||
req := &CreateCommentRequest{
|
||||
@@ -485,8 +507,7 @@ func TestService_CreateAgentComment(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
setupTestTask(t, repo)
|
||||
|
||||
req := &CreateCommentRequest{
|
||||
TaskID: "task-123",
|
||||
@@ -532,8 +553,7 @@ func TestService_GetComment(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
setupTestTask(t, repo)
|
||||
|
||||
comment := &models.Comment{ID: "comment-123", TaskID: "task-123", AuthorType: models.CommentAuthorUser, Content: "Test"}
|
||||
_ = repo.CreateComment(ctx, comment)
|
||||
@@ -551,8 +571,7 @@ func TestService_ListComments(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
setupTestTask(t, repo)
|
||||
|
||||
_ = repo.CreateComment(ctx, &models.Comment{ID: "comment-1", TaskID: "task-123", AuthorType: models.CommentAuthorUser, Content: "Comment 1"})
|
||||
_ = repo.CreateComment(ctx, &models.Comment{ID: "comment-2", TaskID: "task-123", AuthorType: models.CommentAuthorAgent, Content: "Comment 2"})
|
||||
@@ -570,8 +589,7 @@ func TestService_DeleteComment(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
setupTestTask(t, repo)
|
||||
|
||||
comment := &models.Comment{ID: "comment-123", TaskID: "task-123", AuthorType: models.CommentAuthorUser, Content: "Test"}
|
||||
_ = repo.CreateComment(ctx, comment)
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
package jsonrpc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Client handles JSON-RPC 2.0 communication over stdin/stdout streams
|
||||
type Client struct {
|
||||
stdin io.Writer
|
||||
stdout io.Reader
|
||||
|
||||
requestID atomic.Int64
|
||||
pending map[interface{}]chan *Response
|
||||
mu sync.Mutex
|
||||
|
||||
// Notification handler
|
||||
onNotification func(method string, params json.RawMessage)
|
||||
|
||||
// Request handler for agent-to-client requests (like session/request_permission)
|
||||
onRequest func(id interface{}, method string, params json.RawMessage)
|
||||
|
||||
logger *logger.Logger
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewClient creates a new JSON-RPC client
|
||||
func NewClient(stdin io.Writer, stdout io.Reader, log *logger.Logger) *Client {
|
||||
return &Client{
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
pending: make(map[interface{}]chan *Response),
|
||||
logger: log.WithFields(zap.String("component", "jsonrpc-client")),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetNotificationHandler sets the handler for incoming notifications
|
||||
func (c *Client) SetNotificationHandler(handler func(method string, params json.RawMessage)) {
|
||||
c.onNotification = handler
|
||||
}
|
||||
|
||||
// SetRequestHandler sets the handler for incoming requests from the agent
|
||||
// (e.g., session/request_permission). The handler should call SendResponse to reply.
|
||||
func (c *Client) SetRequestHandler(handler func(id interface{}, method string, params json.RawMessage)) {
|
||||
c.onRequest = handler
|
||||
}
|
||||
|
||||
// SendResponse sends a response to an agent request
|
||||
func (c *Client) SendResponse(id interface{}, result interface{}, err *Error) error {
|
||||
var resultJSON json.RawMessage
|
||||
if result != nil && err == nil {
|
||||
var marshalErr error
|
||||
resultJSON, marshalErr = json.Marshal(result)
|
||||
if marshalErr != nil {
|
||||
return fmt.Errorf("failed to marshal result: %w", marshalErr)
|
||||
}
|
||||
}
|
||||
|
||||
resp := &Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Result: resultJSON,
|
||||
Error: err,
|
||||
}
|
||||
|
||||
return c.send(resp)
|
||||
}
|
||||
|
||||
// Start begins reading responses from stdout
|
||||
func (c *Client) Start(ctx context.Context) {
|
||||
go c.readLoop(ctx)
|
||||
}
|
||||
|
||||
// Stop stops the client
|
||||
func (c *Client) Stop() {
|
||||
close(c.done)
|
||||
}
|
||||
|
||||
// Call sends a request and waits for a response
|
||||
func (c *Client) Call(ctx context.Context, method string, params interface{}) (*Response, error) {
|
||||
id := c.requestID.Add(1)
|
||||
|
||||
var paramsJSON json.RawMessage
|
||||
if params != nil {
|
||||
var err error
|
||||
paramsJSON, err = json.Marshal(params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal params: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := &Request{
|
||||
JSONRPC: "2.0",
|
||||
ID: id,
|
||||
Method: method,
|
||||
Params: paramsJSON,
|
||||
}
|
||||
|
||||
// Create response channel
|
||||
respCh := make(chan *Response, 1)
|
||||
c.mu.Lock()
|
||||
c.pending[id] = respCh
|
||||
c.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, id)
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
// Send request
|
||||
if err := c.send(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Wait for response
|
||||
select {
|
||||
case resp := <-respCh:
|
||||
return resp, nil
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-c.done:
|
||||
return nil, fmt.Errorf("client closed")
|
||||
}
|
||||
}
|
||||
|
||||
// Notify sends a notification (no response expected)
|
||||
func (c *Client) Notify(method string, params interface{}) error {
|
||||
var paramsJSON json.RawMessage
|
||||
if params != nil {
|
||||
var err error
|
||||
paramsJSON, err = json.Marshal(params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal params: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
notif := &Notification{
|
||||
JSONRPC: "2.0",
|
||||
Method: method,
|
||||
Params: paramsJSON,
|
||||
}
|
||||
|
||||
return c.send(notif)
|
||||
}
|
||||
|
||||
func (c *Client) send(msg interface{}) error {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
data = append(data, '\n')
|
||||
_, err = c.stdin.Write(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write message: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Debug("sent message", zap.String("data", string(data)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) readLoop(ctx context.Context) {
|
||||
scanner := bufio.NewScanner(c.stdout)
|
||||
// Increase buffer size for large messages
|
||||
buf := make([]byte, 0, 64*1024)
|
||||
scanner.Buffer(buf, 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-c.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
c.logger.Debug("received message", zap.String("data", string(line)))
|
||||
|
||||
// Parse the message to determine its type
|
||||
// JSON-RPC 2.0 message types:
|
||||
// - Response: has "id" + ("result" OR "error"), no "method"
|
||||
// - Request: has "id" + "method"
|
||||
// - Notification: has "method", no "id"
|
||||
var msg struct {
|
||||
ID interface{} `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *Error `json:"error"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &msg); err != nil {
|
||||
c.logger.Warn("failed to parse message", zap.Error(err), zap.String("data", string(line)))
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine message type based on fields present
|
||||
hasID := msg.ID != nil
|
||||
hasMethod := msg.Method != ""
|
||||
hasResult := msg.Result != nil
|
||||
hasError := msg.Error != nil
|
||||
|
||||
if hasID && !hasMethod && (hasResult || hasError) {
|
||||
// This is a response to our request
|
||||
resp := &Response{
|
||||
JSONRPC: "2.0",
|
||||
ID: msg.ID,
|
||||
Result: msg.Result,
|
||||
Error: msg.Error,
|
||||
}
|
||||
c.handleResponse(resp)
|
||||
} else if hasID && hasMethod {
|
||||
// This is a request FROM the agent (e.g., session/request_permission)
|
||||
c.handleRequest(msg.ID, msg.Method, msg.Params)
|
||||
} else if hasMethod && !hasID {
|
||||
// This is a notification
|
||||
notif := &Notification{
|
||||
JSONRPC: "2.0",
|
||||
Method: msg.Method,
|
||||
Params: msg.Params,
|
||||
}
|
||||
c.handleNotification(notif)
|
||||
} else {
|
||||
c.logger.Warn("received unknown message format", zap.String("data", string(line)))
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
c.logger.Error("read loop error", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleResponse(resp *Response) {
|
||||
// Normalize the ID - JSON unmarshals numbers as float64, but we store as int64
|
||||
id := normalizeID(resp.ID)
|
||||
|
||||
c.mu.Lock()
|
||||
ch, ok := c.pending[id]
|
||||
c.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
ch <- resp
|
||||
} else {
|
||||
c.logger.Warn("received response for unknown request", zap.Any("id", resp.ID))
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeID converts JSON unmarshaled IDs to a consistent type for map lookup.
|
||||
// JSON numbers are unmarshaled as float64, but we store request IDs as int64.
|
||||
func normalizeID(id interface{}) interface{} {
|
||||
switch v := id.(type) {
|
||||
case float64:
|
||||
return int64(v)
|
||||
case json.Number:
|
||||
if i, err := v.Int64(); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (c *Client) handleNotification(notif *Notification) {
|
||||
if c.onNotification != nil {
|
||||
c.onNotification(notif.Method, notif.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleRequest(id interface{}, method string, params json.RawMessage) {
|
||||
if c.onRequest != nil {
|
||||
c.onRequest(id, method, params)
|
||||
} else {
|
||||
// No handler registered, send error response
|
||||
c.logger.Warn("received request but no handler registered",
|
||||
zap.Any("id", id),
|
||||
zap.String("method", method))
|
||||
c.SendResponse(id, nil, &Error{
|
||||
Code: MethodNotFound,
|
||||
Message: "Method not found",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package protocol
|
||||
|
||||
// NewProgressMessage creates a new progress message
|
||||
func NewProgressMessage(agentID, taskID string, data ProgressData) *Message {
|
||||
return NewMessage(MessageTypeProgress, agentID, taskID, map[string]interface{}{
|
||||
"progress": data.Progress,
|
||||
"message": data.Message,
|
||||
"current_file": data.CurrentFile,
|
||||
"files_processed": data.FilesProcessed,
|
||||
"total_files": data.TotalFiles,
|
||||
})
|
||||
}
|
||||
|
||||
// NewLogMessage creates a new log message
|
||||
func NewLogMessage(agentID, taskID string, data LogData) *Message {
|
||||
return NewMessage(MessageTypeLog, agentID, taskID, map[string]interface{}{
|
||||
"level": data.Level,
|
||||
"message": data.Message,
|
||||
"metadata": data.Metadata,
|
||||
})
|
||||
}
|
||||
|
||||
// NewResultMessage creates a new result message
|
||||
func NewResultMessage(agentID, taskID string, data ResultData) *Message {
|
||||
// Convert artifacts to interface slice
|
||||
artifacts := make([]interface{}, len(data.Artifacts))
|
||||
for i, a := range data.Artifacts {
|
||||
artifacts[i] = map[string]interface{}{
|
||||
"type": a.Type,
|
||||
"path": a.Path,
|
||||
"url": a.URL,
|
||||
}
|
||||
}
|
||||
|
||||
return NewMessage(MessageTypeResult, agentID, taskID, map[string]interface{}{
|
||||
"status": data.Status,
|
||||
"summary": data.Summary,
|
||||
"artifacts": artifacts,
|
||||
})
|
||||
}
|
||||
|
||||
// NewErrorMessage creates a new error message
|
||||
func NewErrorMessage(agentID, taskID string, data ErrorData) *Message {
|
||||
return NewMessage(MessageTypeError, agentID, taskID, map[string]interface{}{
|
||||
"error": data.Error,
|
||||
"file": data.File,
|
||||
"details": data.Details,
|
||||
})
|
||||
}
|
||||
|
||||
// NewStatusMessage creates a new status message
|
||||
func NewStatusMessage(agentID, taskID string, data StatusData) *Message {
|
||||
return NewMessage(MessageTypeStatus, agentID, taskID, map[string]interface{}{
|
||||
"status": data.Status,
|
||||
"message": data.Message,
|
||||
})
|
||||
}
|
||||
|
||||
// NewHeartbeatMessage creates a new heartbeat message
|
||||
func NewHeartbeatMessage(agentID, taskID string) *Message {
|
||||
return NewMessage(MessageTypeHeartbeat, agentID, taskID, map[string]interface{}{})
|
||||
}
|
||||
|
||||
// NewControlMessage creates a new control message
|
||||
func NewControlMessage(agentID, taskID string, data ControlData) *Message {
|
||||
return NewMessage(MessageTypeControl, agentID, taskID, map[string]interface{}{
|
||||
"action": data.Action,
|
||||
"reason": data.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewProgressMessage(t *testing.T) {
|
||||
data := ProgressData{
|
||||
Progress: 50,
|
||||
Message: "Processing files",
|
||||
CurrentFile: "main.go",
|
||||
FilesProcessed: 5,
|
||||
TotalFiles: 10,
|
||||
}
|
||||
|
||||
msg := NewProgressMessage("agent-1", "task-1", data)
|
||||
|
||||
if msg.Type != MessageTypeProgress {
|
||||
t.Errorf("Expected type %s, got %s", MessageTypeProgress, msg.Type)
|
||||
}
|
||||
if msg.AgentID != "agent-1" {
|
||||
t.Errorf("Expected agentID 'agent-1', got %s", msg.AgentID)
|
||||
}
|
||||
if msg.TaskID != "task-1" {
|
||||
t.Errorf("Expected taskID 'task-1', got %s", msg.TaskID)
|
||||
}
|
||||
if msg.Data["progress"] != 50 {
|
||||
t.Errorf("Expected progress 50, got %v", msg.Data["progress"])
|
||||
}
|
||||
if msg.Data["message"] != "Processing files" {
|
||||
t.Errorf("Expected message 'Processing files', got %v", msg.Data["message"])
|
||||
}
|
||||
if msg.Data["current_file"] != "main.go" {
|
||||
t.Errorf("Expected current_file 'main.go', got %v", msg.Data["current_file"])
|
||||
}
|
||||
if msg.Data["files_processed"] != 5 {
|
||||
t.Errorf("Expected files_processed 5, got %v", msg.Data["files_processed"])
|
||||
}
|
||||
if msg.Data["total_files"] != 10 {
|
||||
t.Errorf("Expected total_files 10, got %v", msg.Data["total_files"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLogMessage(t *testing.T) {
|
||||
data := LogData{
|
||||
Level: "info",
|
||||
Message: "Task started",
|
||||
Metadata: map[string]interface{}{
|
||||
"component": "scanner",
|
||||
},
|
||||
}
|
||||
|
||||
msg := NewLogMessage("agent-2", "task-2", data)
|
||||
|
||||
if msg.Type != MessageTypeLog {
|
||||
t.Errorf("Expected type %s, got %s", MessageTypeLog, msg.Type)
|
||||
}
|
||||
if msg.AgentID != "agent-2" {
|
||||
t.Errorf("Expected agentID 'agent-2', got %s", msg.AgentID)
|
||||
}
|
||||
if msg.TaskID != "task-2" {
|
||||
t.Errorf("Expected taskID 'task-2', got %s", msg.TaskID)
|
||||
}
|
||||
if msg.Data["level"] != "info" {
|
||||
t.Errorf("Expected level 'info', got %v", msg.Data["level"])
|
||||
}
|
||||
if msg.Data["message"] != "Task started" {
|
||||
t.Errorf("Expected message 'Task started', got %v", msg.Data["message"])
|
||||
}
|
||||
metadata, ok := msg.Data["metadata"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected metadata to be a map")
|
||||
}
|
||||
if metadata["component"] != "scanner" {
|
||||
t.Errorf("Expected component 'scanner', got %v", metadata["component"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewResultMessage(t *testing.T) {
|
||||
data := ResultData{
|
||||
Status: "completed",
|
||||
Summary: "All checks passed",
|
||||
Artifacts: []Artifact{
|
||||
{Type: "report", Path: "/reports/scan.json", URL: "https://example.com/report"},
|
||||
{Type: "log", Path: "/logs/scan.log", URL: ""},
|
||||
},
|
||||
}
|
||||
|
||||
msg := NewResultMessage("agent-3", "task-3", data)
|
||||
|
||||
if msg.Type != MessageTypeResult {
|
||||
t.Errorf("Expected type %s, got %s", MessageTypeResult, msg.Type)
|
||||
}
|
||||
if msg.AgentID != "agent-3" {
|
||||
t.Errorf("Expected agentID 'agent-3', got %s", msg.AgentID)
|
||||
}
|
||||
if msg.TaskID != "task-3" {
|
||||
t.Errorf("Expected taskID 'task-3', got %s", msg.TaskID)
|
||||
}
|
||||
if msg.Data["status"] != "completed" {
|
||||
t.Errorf("Expected status 'completed', got %v", msg.Data["status"])
|
||||
}
|
||||
if msg.Data["summary"] != "All checks passed" {
|
||||
t.Errorf("Expected summary 'All checks passed', got %v", msg.Data["summary"])
|
||||
}
|
||||
|
||||
artifacts, ok := msg.Data["artifacts"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected artifacts to be a slice")
|
||||
}
|
||||
if len(artifacts) != 2 {
|
||||
t.Errorf("Expected 2 artifacts, got %d", len(artifacts))
|
||||
}
|
||||
|
||||
artifact1, ok := artifacts[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected first artifact to be a map")
|
||||
}
|
||||
if artifact1["type"] != "report" {
|
||||
t.Errorf("Expected first artifact type 'report', got %v", artifact1["type"])
|
||||
}
|
||||
if artifact1["path"] != "/reports/scan.json" {
|
||||
t.Errorf("Expected first artifact path '/reports/scan.json', got %v", artifact1["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewErrorMessage(t *testing.T) {
|
||||
data := ErrorData{
|
||||
Error: "Failed to parse file",
|
||||
File: "config.yaml",
|
||||
Details: "Invalid YAML syntax on line 42",
|
||||
}
|
||||
|
||||
msg := NewErrorMessage("agent-4", "task-4", data)
|
||||
|
||||
if msg.Type != MessageTypeError {
|
||||
t.Errorf("Expected type %s, got %s", MessageTypeError, msg.Type)
|
||||
}
|
||||
if msg.AgentID != "agent-4" {
|
||||
t.Errorf("Expected agentID 'agent-4', got %s", msg.AgentID)
|
||||
}
|
||||
if msg.TaskID != "task-4" {
|
||||
t.Errorf("Expected taskID 'task-4', got %s", msg.TaskID)
|
||||
}
|
||||
if msg.Data["error"] != "Failed to parse file" {
|
||||
t.Errorf("Expected error 'Failed to parse file', got %v", msg.Data["error"])
|
||||
}
|
||||
if msg.Data["file"] != "config.yaml" {
|
||||
t.Errorf("Expected file 'config.yaml', got %v", msg.Data["file"])
|
||||
}
|
||||
if msg.Data["details"] != "Invalid YAML syntax on line 42" {
|
||||
t.Errorf("Expected details, got %v", msg.Data["details"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewStatusMessage(t *testing.T) {
|
||||
data := StatusData{
|
||||
Status: "running",
|
||||
Message: "Processing task",
|
||||
}
|
||||
|
||||
msg := NewStatusMessage("agent-5", "task-5", data)
|
||||
|
||||
if msg.Type != MessageTypeStatus {
|
||||
t.Errorf("Expected type %s, got %s", MessageTypeStatus, msg.Type)
|
||||
}
|
||||
if msg.Data["status"] != "running" {
|
||||
t.Errorf("Expected status 'running', got %v", msg.Data["status"])
|
||||
}
|
||||
if msg.Data["message"] != "Processing task" {
|
||||
t.Errorf("Expected message 'Processing task', got %v", msg.Data["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewHeartbeatMessage(t *testing.T) {
|
||||
msg := NewHeartbeatMessage("agent-6", "task-6")
|
||||
|
||||
if msg.Type != MessageTypeHeartbeat {
|
||||
t.Errorf("Expected type %s, got %s", MessageTypeHeartbeat, msg.Type)
|
||||
}
|
||||
if msg.AgentID != "agent-6" {
|
||||
t.Errorf("Expected agentID 'agent-6', got %s", msg.AgentID)
|
||||
}
|
||||
if msg.TaskID != "task-6" {
|
||||
t.Errorf("Expected taskID 'task-6', got %s", msg.TaskID)
|
||||
}
|
||||
if len(msg.Data) != 0 {
|
||||
t.Errorf("Expected empty data map, got %v", msg.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewControlMessage(t *testing.T) {
|
||||
data := ControlData{
|
||||
Action: "pause",
|
||||
Reason: "User requested pause",
|
||||
}
|
||||
|
||||
msg := NewControlMessage("agent-7", "task-7", data)
|
||||
|
||||
if msg.Type != MessageTypeControl {
|
||||
t.Errorf("Expected type %s, got %s", MessageTypeControl, msg.Type)
|
||||
}
|
||||
if msg.AgentID != "agent-7" {
|
||||
t.Errorf("Expected agentID 'agent-7', got %s", msg.AgentID)
|
||||
}
|
||||
if msg.TaskID != "task-7" {
|
||||
t.Errorf("Expected taskID 'task-7', got %s", msg.TaskID)
|
||||
}
|
||||
if msg.Data["action"] != "pause" {
|
||||
t.Errorf("Expected action 'pause', got %v", msg.Data["action"])
|
||||
}
|
||||
if msg.Data["reason"] != "User requested pause" {
|
||||
t.Errorf("Expected reason 'User requested pause', got %v", msg.Data["reason"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewProgressMessage_ZeroValues(t *testing.T) {
|
||||
data := ProgressData{
|
||||
Progress: 0,
|
||||
Message: "",
|
||||
}
|
||||
|
||||
msg := NewProgressMessage("agent-1", "task-1", data)
|
||||
|
||||
if msg.Type != MessageTypeProgress {
|
||||
t.Errorf("Expected type %s, got %s", MessageTypeProgress, msg.Type)
|
||||
}
|
||||
if msg.Data["progress"] != 0 {
|
||||
t.Errorf("Expected progress 0, got %v", msg.Data["progress"])
|
||||
}
|
||||
if msg.Data["message"] != "" {
|
||||
t.Errorf("Expected empty message, got %v", msg.Data["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewResultMessage_NoArtifacts(t *testing.T) {
|
||||
data := ResultData{
|
||||
Status: "completed",
|
||||
Summary: "No files generated",
|
||||
Artifacts: []Artifact{},
|
||||
}
|
||||
|
||||
msg := NewResultMessage("agent-1", "task-1", data)
|
||||
|
||||
if msg.Type != MessageTypeResult {
|
||||
t.Errorf("Expected type %s, got %s", MessageTypeResult, msg.Type)
|
||||
}
|
||||
artifacts, ok := msg.Data["artifacts"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected artifacts to be a slice")
|
||||
}
|
||||
if len(artifacts) != 0 {
|
||||
t.Errorf("Expected 0 artifacts, got %d", len(artifacts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLogMessage_AllLevels(t *testing.T) {
|
||||
levels := []string{"debug", "info", "warn", "error"}
|
||||
|
||||
for _, level := range levels {
|
||||
t.Run(level, func(t *testing.T) {
|
||||
data := LogData{
|
||||
Level: level,
|
||||
Message: "Test message",
|
||||
}
|
||||
|
||||
msg := NewLogMessage("agent-1", "task-1", data)
|
||||
|
||||
if msg.Data["level"] != level {
|
||||
t.Errorf("Expected level '%s', got %v", level, msg.Data["level"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,17 +33,6 @@ type Message struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// NewMessage creates a new ACP message with the current timestamp
|
||||
func NewMessage(msgType MessageType, agentID, taskID string, data map[string]interface{}) *Message {
|
||||
return &Message{
|
||||
Type: msgType,
|
||||
Timestamp: time.Now().UTC(),
|
||||
AgentID: agentID,
|
||||
TaskID: taskID,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON implements custom JSON marshaling
|
||||
func (m *Message) MarshalJSON() ([]byte, error) {
|
||||
type Alias Message
|
||||
@@ -56,15 +45,6 @@ func (m *Message) MarshalJSON() ([]byte, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// Parse parses a JSON string into an ACP message
|
||||
func Parse(data []byte) (*Message, error) {
|
||||
var msg Message
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
// IsValid checks if the message has required fields
|
||||
func (m *Message) IsValid() bool {
|
||||
if m.Type == "" {
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMessageType_Constants(t *testing.T) {
|
||||
tests := []struct {
|
||||
msgType MessageType
|
||||
expected string
|
||||
}{
|
||||
{MessageTypeProgress, "progress"},
|
||||
{MessageTypeLog, "log"},
|
||||
{MessageTypeResult, "result"},
|
||||
{MessageTypeError, "error"},
|
||||
{MessageTypeStatus, "status"},
|
||||
{MessageTypeHeartbeat, "heartbeat"},
|
||||
{MessageTypeControl, "control"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if string(tt.msgType) != tt.expected {
|
||||
t.Errorf("Expected MessageType %s, got %s", tt.expected, tt.msgType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMessage(t *testing.T) {
|
||||
agentID := "agent-123"
|
||||
taskID := "task-456"
|
||||
data := map[string]interface{}{"key": "value"}
|
||||
|
||||
before := time.Now().UTC()
|
||||
msg := NewMessage(MessageTypeProgress, agentID, taskID, data)
|
||||
after := time.Now().UTC()
|
||||
|
||||
if msg.Type != MessageTypeProgress {
|
||||
t.Errorf("Expected type %s, got %s", MessageTypeProgress, msg.Type)
|
||||
}
|
||||
if msg.AgentID != agentID {
|
||||
t.Errorf("Expected agentID %s, got %s", agentID, msg.AgentID)
|
||||
}
|
||||
if msg.TaskID != taskID {
|
||||
t.Errorf("Expected taskID %s, got %s", taskID, msg.TaskID)
|
||||
}
|
||||
if msg.Data["key"] != "value" {
|
||||
t.Error("Expected data to contain key=value")
|
||||
}
|
||||
if msg.Timestamp.Before(before) || msg.Timestamp.After(after) {
|
||||
t.Error("Expected timestamp to be set to current time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessage_MarshalJSON(t *testing.T) {
|
||||
fixedTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)
|
||||
msg := &Message{
|
||||
Type: MessageTypeLog,
|
||||
Timestamp: fixedTime,
|
||||
AgentID: "agent-001",
|
||||
TaskID: "task-001",
|
||||
Data: map[string]interface{}{"level": "info"},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("MarshalJSON failed: %v", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
t.Fatalf("Failed to unmarshal result: %v", err)
|
||||
}
|
||||
|
||||
if result["type"] != "log" {
|
||||
t.Errorf("Expected type 'log', got %v", result["type"])
|
||||
}
|
||||
if result["agent_id"] != "agent-001" {
|
||||
t.Errorf("Expected agent_id 'agent-001', got %v", result["agent_id"])
|
||||
}
|
||||
if result["task_id"] != "task-001" {
|
||||
t.Errorf("Expected task_id 'task-001', got %v", result["task_id"])
|
||||
}
|
||||
|
||||
// Check timestamp is in RFC3339Nano format
|
||||
ts, ok := result["timestamp"].(string)
|
||||
if !ok {
|
||||
t.Fatal("Expected timestamp to be a string")
|
||||
}
|
||||
_, err = time.Parse(time.RFC3339Nano, ts)
|
||||
if err != nil {
|
||||
t.Errorf("Timestamp not in RFC3339Nano format: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
jsonData := []byte(`{
|
||||
"type": "progress",
|
||||
"timestamp": "2024-01-15T10:30:00Z",
|
||||
"agent_id": "agent-test",
|
||||
"task_id": "task-test",
|
||||
"data": {"progress": 50, "message": "Processing..."}
|
||||
}`)
|
||||
|
||||
msg, err := Parse(jsonData)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
|
||||
if msg.Type != MessageTypeProgress {
|
||||
t.Errorf("Expected type %s, got %s", MessageTypeProgress, msg.Type)
|
||||
}
|
||||
if msg.AgentID != "agent-test" {
|
||||
t.Errorf("Expected agentID 'agent-test', got %s", msg.AgentID)
|
||||
}
|
||||
if msg.TaskID != "task-test" {
|
||||
t.Errorf("Expected taskID 'task-test', got %s", msg.TaskID)
|
||||
}
|
||||
if msg.Data["progress"] != float64(50) {
|
||||
t.Errorf("Expected progress 50, got %v", msg.Data["progress"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_InvalidJSON(t *testing.T) {
|
||||
invalidJSON := []byte(`{invalid json}`)
|
||||
|
||||
_, err := Parse(invalidJSON)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessage_IsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg *Message
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "valid message",
|
||||
msg: &Message{
|
||||
Type: MessageTypeProgress,
|
||||
AgentID: "agent-1",
|
||||
TaskID: "task-1",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "missing type",
|
||||
msg: &Message{
|
||||
Type: "",
|
||||
AgentID: "agent-1",
|
||||
TaskID: "task-1",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "missing agent_id",
|
||||
msg: &Message{
|
||||
Type: MessageTypeProgress,
|
||||
AgentID: "",
|
||||
TaskID: "task-1",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "missing task_id",
|
||||
msg: &Message{
|
||||
Type: MessageTypeProgress,
|
||||
AgentID: "agent-1",
|
||||
TaskID: "",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.msg.IsValid(); got != tt.expected {
|
||||
t.Errorf("IsValid() = %v, expected %v", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,18 +232,3 @@ func (c *Client) handleRequest(id interface{}, method string, params json.RawMes
|
||||
}
|
||||
}
|
||||
|
||||
// extractMethod extracts the method field from a JSON message for logging
|
||||
func extractMethod(data []byte) string {
|
||||
var msg struct {
|
||||
Method string `json:"method"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
return "parse-error"
|
||||
}
|
||||
if msg.Method != "" {
|
||||
return msg.Method
|
||||
}
|
||||
return "response"
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ type ErrorPayload struct {
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
|
||||
// NewRequest creates a new request message
|
||||
func NewRequest(id, action string, payload interface{}) (*Message, error) {
|
||||
data, err := json.Marshal(payload)
|
||||
|
||||
Reference in New Issue
Block a user