Implement ACP permission handling and E2E test
- Add proper JSON-RPC message type discrimination in readLoop(): - Response: id + (result|error), no method - Request: id + method (e.g., session/request_permission) - Notification: method, no id - Add permission request types to types.go: - RequestPermissionParams, PermissionOption - RequestPermissionResult, PermissionOutcome - Implement handleRequestPermission in session manager: - Auto-approves by selecting first 'allow' option - Handles workspace indexing and tool permissions - Add augment_provider.go for session credential injection - Add repository_url field to task model for workspace mounting - Create scripts/e2e-test.sh for automated testing - Update documentation with ACP protocol details
This commit is contained in:
@@ -144,11 +144,10 @@ func main() {
|
||||
// Credentials Manager
|
||||
credsMgr := credentials.NewManager(log)
|
||||
credsMgr.AddProvider(credentials.NewEnvProvider("KANDEV_"))
|
||||
credsMgr.AddProvider(credentials.NewAugmentSessionProvider()) // Read ~/.augment/session.json
|
||||
if credsFile := os.Getenv("KANDEV_CREDENTIALS_FILE"); credsFile != "" {
|
||||
credsMgr.AddProvider(credentials.NewFileProvider(credsFile))
|
||||
}
|
||||
// Note: credsMgr available for future use when integrating with lifecycle manager
|
||||
_ = credsMgr
|
||||
|
||||
// ACP Session Manager for bidirectional communication
|
||||
acpSessionMgr = agentacp.NewSessionManager(eventBus, log)
|
||||
@@ -156,6 +155,7 @@ func main() {
|
||||
// Lifecycle Manager
|
||||
lifecycleMgr = lifecycle.NewManager(dockerClient, agentRegistry, eventBus, log)
|
||||
lifecycleMgr.SetACPManager(acpSessionMgr)
|
||||
lifecycleMgr.SetCredentialsManager(credsMgr)
|
||||
|
||||
if err := lifecycleMgr.Start(ctx); err != nil {
|
||||
log.Fatal("Failed to start lifecycle manager", zap.Error(err))
|
||||
@@ -363,9 +363,11 @@ func newLifecycleAdapter(mgr *lifecycle.Manager, reg *registry.Registry, log *lo
|
||||
// LaunchAgent starts a new agent container for a task
|
||||
func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) {
|
||||
launchReq := &lifecycle.LaunchRequest{
|
||||
TaskID: req.TaskID,
|
||||
AgentType: req.AgentType,
|
||||
Metadata: req.Metadata,
|
||||
TaskID: req.TaskID,
|
||||
AgentType: req.AgentType,
|
||||
WorkspacePath: req.RepositoryURL, // Use repository URL as workspace path
|
||||
TaskDescription: req.TaskDescription,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
|
||||
instance, err := a.mgr.Launch(ctx, launchReq)
|
||||
|
||||
@@ -80,6 +80,11 @@ func (m *SessionManager) CreateSession(ctx context.Context, instanceID, taskID s
|
||||
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)
|
||||
|
||||
@@ -101,7 +106,7 @@ func (m *SessionManager) Initialize(ctx context.Context, instanceID string) erro
|
||||
m.logger.Info("Initializing ACP session", zap.String("instance_id", instanceID))
|
||||
|
||||
params := jsonrpc.InitializeParams{
|
||||
ProtocolVersion: "1.0",
|
||||
ProtocolVersion: 1,
|
||||
ClientInfo: jsonrpc.ClientInfo{
|
||||
Name: "kandev",
|
||||
Version: "0.1.0",
|
||||
@@ -129,15 +134,22 @@ func (m *SessionManager) Initialize(ctx context.Context, instanceID string) erro
|
||||
}
|
||||
|
||||
// NewSession creates a new ACP session (session/new)
|
||||
func (m *SessionManager) NewSession(ctx context.Context, instanceID string) (string, error) {
|
||||
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))
|
||||
m.logger.Info("Creating new ACP session",
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.String("cwd", cwd))
|
||||
|
||||
resp, err := session.Client.Call(ctx, jsonrpc.MethodSessionNew, jsonrpc.SessionNewParams{})
|
||||
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)
|
||||
}
|
||||
@@ -201,16 +213,32 @@ func (m *SessionManager) Prompt(ctx context.Context, instanceID, message string)
|
||||
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("message_length", fmt.Sprintf("%d", len(message))))
|
||||
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{
|
||||
Message: message,
|
||||
SessionID: sessionID,
|
||||
Prompt: []jsonrpc.ContentBlock{
|
||||
{
|
||||
Type: "text",
|
||||
Text: message,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := session.Client.Call(ctx, jsonrpc.MethodSessionPrompt, params)
|
||||
@@ -328,3 +356,79 @@ func (m *SessionManager) GetSessionID(instanceID string) (string, bool) {
|
||||
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)))
|
||||
|
||||
// Auto-approve: find the first "allow" option and select it
|
||||
// In a real implementation, this would prompt the user or check policies
|
||||
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))
|
||||
}
|
||||
|
||||
// Send response
|
||||
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 permission response",
|
||||
zap.String("instance_id", session.InstanceID),
|
||||
zap.String("selected_option", selectedOptionID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package credentials
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// AugmentSessionProvider provides AUGMENT_SESSION_AUTH from ~/.augment/session.json
|
||||
type AugmentSessionProvider struct {
|
||||
sessionPath string
|
||||
sessionData string
|
||||
loaded bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewAugmentSessionProvider creates a provider that reads Augment session credentials
|
||||
func NewAugmentSessionProvider() *AugmentSessionProvider {
|
||||
// Default to ~/.augment/session.json
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
homeDir = "/tmp"
|
||||
}
|
||||
sessionPath := filepath.Join(homeDir, ".augment", "session.json")
|
||||
|
||||
return &AugmentSessionProvider{
|
||||
sessionPath: sessionPath,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the provider name
|
||||
func (p *AugmentSessionProvider) Name() string {
|
||||
return "augment-session"
|
||||
}
|
||||
|
||||
// load reads the session.json file content
|
||||
func (p *AugmentSessionProvider) load() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.loaded {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(p.sessionPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
p.loaded = true
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to read augment session file: %w", err)
|
||||
}
|
||||
|
||||
// Store the entire JSON content as the credential value
|
||||
p.sessionData = string(data)
|
||||
p.loaded = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCredential retrieves the AUGMENT_SESSION_AUTH credential
|
||||
func (p *AugmentSessionProvider) GetCredential(ctx context.Context, key string) (*Credential, error) {
|
||||
if key != "AUGMENT_SESSION_AUTH" {
|
||||
return nil, fmt.Errorf("credential not found: %s", key)
|
||||
}
|
||||
|
||||
if err := p.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
if p.sessionData == "" {
|
||||
return nil, fmt.Errorf("augment session not found at %s", p.sessionPath)
|
||||
}
|
||||
|
||||
return &Credential{
|
||||
Key: "AUGMENT_SESSION_AUTH",
|
||||
Value: p.sessionData,
|
||||
Source: "augment-session",
|
||||
Description: "Augment session credentials from ~/.augment/session.json",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListAvailable returns AUGMENT_SESSION_AUTH if the session file exists
|
||||
func (p *AugmentSessionProvider) ListAvailable(ctx context.Context) ([]string, error) {
|
||||
if err := p.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
if p.sessionData != "" {
|
||||
return []string{"AUGMENT_SESSION_AUTH"}, nil
|
||||
}
|
||||
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
@@ -56,6 +56,15 @@ func (m *Manager) AddProvider(provider CredentialProvider) {
|
||||
m.logger.Info("added credential provider", zap.String("provider", provider.Name()))
|
||||
}
|
||||
|
||||
// GetCredentialValue retrieves just the value of a credential (implements lifecycle.CredentialsManager)
|
||||
func (m *Manager) GetCredentialValue(ctx context.Context, key string) (string, error) {
|
||||
cred, err := m.GetCredential(ctx, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cred.Value, nil
|
||||
}
|
||||
|
||||
// GetCredential retrieves a credential from providers
|
||||
func (m *Manager) GetCredential(ctx context.Context, key string) (*Credential, error) {
|
||||
// Check cache first
|
||||
|
||||
@@ -3,6 +3,7 @@ package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
@@ -482,15 +483,65 @@ func (c *Client) AttachContainer(ctx context.Context, containerID string) (*Atta
|
||||
io.Copy(resp.Conn, stdinReader)
|
||||
}()
|
||||
|
||||
// Create a pipe for demultiplexed stdout
|
||||
// Docker sends multiplexed streams when Tty=false with 8-byte headers
|
||||
stdoutReader, stdoutWriter := io.Pipe()
|
||||
|
||||
// Start goroutine to demultiplex stdout/stderr
|
||||
go func() {
|
||||
defer stdoutWriter.Close()
|
||||
c.demultiplexStream(resp.Reader, stdoutWriter)
|
||||
}()
|
||||
|
||||
c.logger.Info("Attached to container", zap.String("container_id", containerID))
|
||||
|
||||
return &AttachResult{
|
||||
Stdin: stdinWriter,
|
||||
Stdout: resp.Reader, // This is a multiplexed stream (stdout + stderr)
|
||||
Stdout: stdoutReader, // Demultiplexed stdout stream
|
||||
Conn: resp.Conn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// demultiplexStream reads Docker's multiplexed stream format and writes only stdout to the writer.
|
||||
// Docker stream format when Tty=false:
|
||||
// - Byte 0: Stream type (0=stdin, 1=stdout, 2=stderr)
|
||||
// - Bytes 1-3: Reserved
|
||||
// - Bytes 4-7: Frame size (big endian uint32)
|
||||
// - Bytes 8+: Frame data
|
||||
func (c *Client) demultiplexStream(reader io.Reader, writer io.Writer) {
|
||||
header := make([]byte, 8)
|
||||
for {
|
||||
// Read the 8-byte header
|
||||
_, err := io.ReadFull(reader, header)
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
c.logger.Debug("demultiplex stream ended", zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Parse header
|
||||
streamType := header[0]
|
||||
size := binary.BigEndian.Uint32(header[4:8])
|
||||
|
||||
// Read the frame data
|
||||
if size > 0 {
|
||||
data := make([]byte, size)
|
||||
_, err := io.ReadFull(reader, data)
|
||||
if err != nil {
|
||||
c.logger.Debug("failed to read frame data", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Write stdout (type 1) and stderr (type 2) to writer
|
||||
// We want both for ACP since errors should be visible
|
||||
if streamType == 1 || streamType == 2 {
|
||||
writer.Write(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the attach result
|
||||
func (a *AttachResult) Close() error {
|
||||
if a.Stdin != nil {
|
||||
|
||||
@@ -42,18 +42,19 @@ type AgentInstance struct {
|
||||
|
||||
// LaunchRequest contains parameters for launching an agent
|
||||
type LaunchRequest struct {
|
||||
TaskID string
|
||||
AgentType string
|
||||
WorkspacePath string // Host path to workspace
|
||||
Env map[string]string // Additional env vars
|
||||
Metadata map[string]interface{}
|
||||
TaskID string
|
||||
AgentType string
|
||||
WorkspacePath string // Host path to workspace
|
||||
TaskDescription string // Task description to send via ACP prompt
|
||||
Env map[string]string // Additional env vars
|
||||
Metadata map[string]interface{}
|
||||
}
|
||||
|
||||
// ACPManager interface for ACP session management
|
||||
type ACPManager interface {
|
||||
CreateSession(ctx context.Context, instanceID, taskID string, stdin io.WriteCloser, stdout io.Reader) error
|
||||
Initialize(ctx context.Context, instanceID string) error
|
||||
NewSession(ctx context.Context, instanceID string) (string, error)
|
||||
NewSession(ctx context.Context, instanceID, cwd string) (string, error)
|
||||
LoadSession(ctx context.Context, instanceID, sessionID string) error
|
||||
Prompt(ctx context.Context, instanceID, message string) error
|
||||
Cancel(ctx context.Context, instanceID, reason string) error
|
||||
@@ -61,12 +62,18 @@ type ACPManager interface {
|
||||
GetSessionID(instanceID string) (string, bool)
|
||||
}
|
||||
|
||||
// CredentialsManager interface for credential retrieval
|
||||
type CredentialsManager interface {
|
||||
GetCredentialValue(ctx context.Context, key string) (value string, err error)
|
||||
}
|
||||
|
||||
// Manager manages agent instance lifecycles
|
||||
type Manager struct {
|
||||
docker *docker.Client
|
||||
registry *registry.Registry
|
||||
eventBus bus.EventBus
|
||||
acpMgr ACPManager
|
||||
credsMgr CredentialsManager
|
||||
logger *logger.Logger
|
||||
|
||||
// Track active instances
|
||||
@@ -106,6 +113,11 @@ func (m *Manager) SetACPManager(acpMgr ACPManager) {
|
||||
m.acpMgr = acpMgr
|
||||
}
|
||||
|
||||
// SetCredentialsManager sets the credentials manager for injecting secrets
|
||||
func (m *Manager) SetCredentialsManager(credsMgr CredentialsManager) {
|
||||
m.credsMgr = credsMgr
|
||||
}
|
||||
|
||||
// Start starts the lifecycle manager background tasks
|
||||
func (m *Manager) Start(ctx context.Context) error {
|
||||
m.logger.Info("starting lifecycle manager")
|
||||
@@ -213,6 +225,8 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstanc
|
||||
zap.Error(err))
|
||||
} else {
|
||||
// Create or load session based on metadata
|
||||
// The workspace is mounted at /workspace inside the container
|
||||
containerCwd := "/workspace"
|
||||
var sessionID string
|
||||
if req.Metadata != nil {
|
||||
if existingSessionID, ok := req.Metadata["auggie_session_id"].(string); ok && existingSessionID != "" {
|
||||
@@ -222,15 +236,15 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstanc
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.String("existing_session_id", existingSessionID),
|
||||
zap.Error(err))
|
||||
sessionID, _ = m.acpMgr.NewSession(context.Background(), instanceID)
|
||||
sessionID, _ = m.acpMgr.NewSession(context.Background(), instanceID, containerCwd)
|
||||
} else {
|
||||
sessionID = existingSessionID
|
||||
}
|
||||
} else {
|
||||
sessionID, _ = m.acpMgr.NewSession(context.Background(), instanceID)
|
||||
sessionID, _ = m.acpMgr.NewSession(context.Background(), instanceID, containerCwd)
|
||||
}
|
||||
} else {
|
||||
sessionID, _ = m.acpMgr.NewSession(context.Background(), instanceID)
|
||||
sessionID, _ = m.acpMgr.NewSession(context.Background(), instanceID, containerCwd)
|
||||
}
|
||||
|
||||
m.logger.Info("ACP session ready",
|
||||
@@ -238,11 +252,15 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentInstanc
|
||||
zap.String("session_id", sessionID))
|
||||
|
||||
// Send the initial prompt with the task description
|
||||
if taskDesc, ok := req.Env["TASK_DESCRIPTION"]; ok && taskDesc != "" {
|
||||
if err := m.acpMgr.Prompt(context.Background(), instanceID, taskDesc); err != nil {
|
||||
if req.TaskDescription != "" {
|
||||
if err := m.acpMgr.Prompt(context.Background(), instanceID, req.TaskDescription); err != nil {
|
||||
m.logger.Warn("failed to send initial prompt",
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
m.logger.Info("sent initial prompt to agent",
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.String("task_description", req.TaskDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -300,6 +318,20 @@ func (m *Manager) buildContainerConfig(instanceID string, req *LaunchRequest, ag
|
||||
}
|
||||
}
|
||||
|
||||
// Inject required credentials from credentials manager
|
||||
if m.credsMgr != nil && len(agentConfig.RequiredEnv) > 0 {
|
||||
for _, key := range agentConfig.RequiredEnv {
|
||||
value, err := m.credsMgr.GetCredentialValue(context.Background(), key)
|
||||
if err != nil {
|
||||
m.logger.Warn("required credential not found",
|
||||
zap.String("key", key),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
env = append(env, fmt.Sprintf("%s=%s", key, value))
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate resource limits
|
||||
memoryBytes := agentConfig.ResourceLimits.MemoryMB * 1024 * 1024
|
||||
cpuQuota := int64(agentConfig.ResourceLimits.CPUCores * 100000) // Docker CPU quota
|
||||
|
||||
@@ -38,12 +38,13 @@ type AgentManagerClient interface {
|
||||
|
||||
// LaunchAgentRequest contains parameters for launching an agent
|
||||
type LaunchAgentRequest struct {
|
||||
TaskID string
|
||||
AgentType string
|
||||
RepositoryURL string
|
||||
Branch string
|
||||
Priority int
|
||||
Metadata map[string]interface{}
|
||||
TaskID string
|
||||
AgentType string
|
||||
RepositoryURL string
|
||||
Branch string
|
||||
TaskDescription string // Task description to send via ACP prompt
|
||||
Priority int
|
||||
Metadata map[string]interface{}
|
||||
}
|
||||
|
||||
// LaunchAgentResponse contains the result of launching an agent
|
||||
@@ -112,10 +113,11 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution,
|
||||
|
||||
// Create a LaunchAgentRequest from the task
|
||||
req := &LaunchAgentRequest{
|
||||
TaskID: task.ID,
|
||||
AgentType: *task.AgentType,
|
||||
Priority: task.Priority,
|
||||
Metadata: task.Metadata,
|
||||
TaskID: task.ID,
|
||||
AgentType: *task.AgentType,
|
||||
TaskDescription: task.Description,
|
||||
Priority: task.Priority,
|
||||
Metadata: task.Metadata,
|
||||
}
|
||||
if task.RepositoryURL != nil {
|
||||
req.RepositoryURL = *task.RepositoryURL
|
||||
|
||||
@@ -168,6 +168,11 @@ func (s *Scheduler) GetQueueStatus() *QueueStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// GetTask retrieves a task from the repository
|
||||
func (s *Scheduler) GetTask(ctx context.Context, taskID string) (*v1.Task, error) {
|
||||
return s.taskRepo.GetTask(ctx, taskID)
|
||||
}
|
||||
|
||||
// HandleTaskCompleted handles agent completion
|
||||
func (s *Scheduler) HandleTaskCompleted(taskID string, success bool) {
|
||||
s.logger.Info("handling task completion",
|
||||
|
||||
@@ -232,11 +232,23 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentType string
|
||||
zap.String("agent_type", agentType),
|
||||
zap.Int("priority", priority))
|
||||
|
||||
task := &v1.Task{
|
||||
ID: taskID,
|
||||
AgentType: &agentType,
|
||||
Priority: priority,
|
||||
// Fetch the task from the repository to get complete task info
|
||||
task, err := s.scheduler.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to fetch task for manual start",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Override agent_type and priority if provided in the request
|
||||
if agentType != "" {
|
||||
task.AgentType = &agentType
|
||||
}
|
||||
if priority > 0 {
|
||||
task.Priority = priority
|
||||
}
|
||||
|
||||
return s.executor.Execute(ctx, task)
|
||||
}
|
||||
|
||||
|
||||
@@ -40,13 +40,15 @@ func (h *Handler) CreateTask(c *gin.Context) {
|
||||
}
|
||||
|
||||
svcReq := &service.CreateTaskRequest{
|
||||
BoardID: req.BoardID,
|
||||
ColumnID: req.ColumnID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Priority: req.Priority,
|
||||
AgentType: req.AgentType,
|
||||
Metadata: req.Metadata,
|
||||
BoardID: req.BoardID,
|
||||
ColumnID: req.ColumnID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Priority: req.Priority,
|
||||
AgentType: req.AgentType,
|
||||
RepositoryURL: req.RepositoryURL,
|
||||
Branch: req.Branch,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
|
||||
task, err := h.service.CreateTask(c.Request.Context(), svcReq)
|
||||
@@ -436,18 +438,20 @@ func (h *Handler) GetColumn(c *gin.Context) {
|
||||
|
||||
func taskToResponse(t *models.Task) *TaskResponse {
|
||||
return &TaskResponse{
|
||||
ID: t.ID,
|
||||
BoardID: t.BoardID,
|
||||
ColumnID: t.ColumnID,
|
||||
Title: t.Title,
|
||||
Description: t.Description,
|
||||
State: string(t.State),
|
||||
Priority: t.Priority,
|
||||
AgentType: t.AgentType,
|
||||
Position: t.Position,
|
||||
CreatedAt: t.CreatedAt,
|
||||
UpdatedAt: t.UpdatedAt,
|
||||
Metadata: t.Metadata,
|
||||
ID: t.ID,
|
||||
BoardID: t.BoardID,
|
||||
ColumnID: t.ColumnID,
|
||||
Title: t.Title,
|
||||
Description: t.Description,
|
||||
State: string(t.State),
|
||||
Priority: t.Priority,
|
||||
AgentType: t.AgentType,
|
||||
RepositoryURL: t.RepositoryURL,
|
||||
Branch: t.Branch,
|
||||
Position: t.Position,
|
||||
CreatedAt: t.CreatedAt,
|
||||
UpdatedAt: t.UpdatedAt,
|
||||
Metadata: t.Metadata,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,15 @@ import "time"
|
||||
|
||||
// CreateTaskRequest for creating a task
|
||||
type CreateTaskRequest struct {
|
||||
BoardID string `json:"board_id" binding:"required"`
|
||||
ColumnID string `json:"column_id" binding:"required"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Priority int `json:"priority"`
|
||||
AgentType string `json:"agent_type,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
BoardID string `json:"board_id" binding:"required"`
|
||||
ColumnID string `json:"column_id" binding:"required"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Priority int `json:"priority"`
|
||||
AgentType string `json:"agent_type,omitempty"`
|
||||
RepositoryURL string `json:"repository_url,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateTaskRequest for updating a task
|
||||
@@ -57,18 +59,20 @@ type CreateColumnRequest struct {
|
||||
|
||||
// TaskResponse represents a task in API responses
|
||||
type TaskResponse struct {
|
||||
ID string `json:"id"`
|
||||
BoardID string `json:"board_id"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
State string `json:"state"`
|
||||
Priority int `json:"priority"`
|
||||
AgentType string `json:"agent_type,omitempty"`
|
||||
Position int `json:"position"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
ID string `json:"id"`
|
||||
BoardID string `json:"board_id"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
State string `json:"state"`
|
||||
Priority int `json:"priority"`
|
||||
AgentType string `json:"agent_type,omitempty"`
|
||||
RepositoryURL string `json:"repository_url,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Position int `json:"position"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// BoardResponse represents a board in API responses
|
||||
|
||||
@@ -8,19 +8,21 @@ import (
|
||||
|
||||
// Task represents a task in the database
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
BoardID string `json:"board_id"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
State v1.TaskState `json:"state"`
|
||||
Priority int `json:"priority"`
|
||||
AgentType string `json:"agent_type,omitempty"`
|
||||
AssignedTo string `json:"assigned_to,omitempty"`
|
||||
Position int `json:"position"` // Order within column
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
BoardID string `json:"board_id"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
State v1.TaskState `json:"state"`
|
||||
Priority int `json:"priority"`
|
||||
AgentType string `json:"agent_type,omitempty"`
|
||||
RepositoryURL string `json:"repository_url,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
AssignedTo string `json:"assigned_to,omitempty"`
|
||||
Position int `json:"position"` // Order within column
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Board represents a Kanban board
|
||||
@@ -51,6 +53,16 @@ func (t *Task) ToAPI() *v1.Task {
|
||||
agentType = &t.AgentType
|
||||
}
|
||||
|
||||
var repositoryURL *string
|
||||
if t.RepositoryURL != "" {
|
||||
repositoryURL = &t.RepositoryURL
|
||||
}
|
||||
|
||||
var branch *string
|
||||
if t.Branch != "" {
|
||||
branch = &t.Branch
|
||||
}
|
||||
|
||||
var assignedAgentID *string
|
||||
if t.AssignedTo != "" {
|
||||
assignedAgentID = &t.AssignedTo
|
||||
@@ -64,6 +76,8 @@ func (t *Task) ToAPI() *v1.Task {
|
||||
State: t.State,
|
||||
Priority: t.Priority,
|
||||
AgentType: agentType,
|
||||
RepositoryURL: repositoryURL,
|
||||
Branch: branch,
|
||||
AssignedAgentID: assignedAgentID,
|
||||
CreatedAt: t.CreatedAt,
|
||||
UpdatedAt: t.UpdatedAt,
|
||||
|
||||
@@ -76,6 +76,8 @@ func (r *SQLiteRepository) initSchema() error {
|
||||
state TEXT DEFAULT 'TODO',
|
||||
priority INTEGER DEFAULT 0,
|
||||
agent_type TEXT DEFAULT '',
|
||||
repository_url TEXT DEFAULT '',
|
||||
branch TEXT DEFAULT '',
|
||||
assigned_to TEXT DEFAULT '',
|
||||
position INTEGER DEFAULT 0,
|
||||
metadata TEXT DEFAULT '{}',
|
||||
@@ -116,9 +118,9 @@ func (r *SQLiteRepository) CreateTask(ctx context.Context, task *models.Task) er
|
||||
}
|
||||
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT INTO tasks (id, board_id, column_id, title, description, state, priority, agent_type, assigned_to, position, metadata, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, task.ID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AgentType, task.AssignedTo, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt)
|
||||
INSERT INTO tasks (id, board_id, column_id, title, description, state, priority, agent_type, repository_url, branch, assigned_to, position, metadata, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, task.ID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AgentType, task.RepositoryURL, task.Branch, task.AssignedTo, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -129,9 +131,9 @@ func (r *SQLiteRepository) GetTask(ctx context.Context, id string) (*models.Task
|
||||
var metadata string
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, board_id, column_id, title, description, state, priority, agent_type, assigned_to, position, metadata, created_at, updated_at
|
||||
SELECT id, board_id, column_id, title, description, state, priority, agent_type, repository_url, branch, assigned_to, position, metadata, created_at, updated_at
|
||||
FROM tasks WHERE id = ?
|
||||
`, id).Scan(&task.ID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &task.AgentType, &task.AssignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
|
||||
`, id).Scan(&task.ID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &task.AgentType, &task.RepositoryURL, &task.Branch, &task.AssignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("task not found: %s", id)
|
||||
@@ -154,9 +156,9 @@ func (r *SQLiteRepository) UpdateTask(ctx context.Context, task *models.Task) er
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE tasks SET board_id = ?, column_id = ?, title = ?, description = ?, state = ?, priority = ?, agent_type = ?, assigned_to = ?, position = ?, metadata = ?, updated_at = ?
|
||||
UPDATE tasks SET board_id = ?, column_id = ?, title = ?, description = ?, state = ?, priority = ?, agent_type = ?, repository_url = ?, branch = ?, assigned_to = ?, position = ?, metadata = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AgentType, task.AssignedTo, task.Position, string(metadata), task.UpdatedAt, task.ID)
|
||||
`, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.AgentType, task.RepositoryURL, task.Branch, task.AssignedTo, task.Position, string(metadata), task.UpdatedAt, task.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -185,7 +187,7 @@ func (r *SQLiteRepository) DeleteTask(ctx context.Context, id string) error {
|
||||
// ListTasks returns all tasks for a board
|
||||
func (r *SQLiteRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, board_id, column_id, title, description, state, priority, agent_type, assigned_to, position, metadata, created_at, updated_at
|
||||
SELECT id, board_id, column_id, title, description, state, priority, agent_type, repository_url, branch, assigned_to, position, metadata, created_at, updated_at
|
||||
FROM tasks WHERE board_id = ? ORDER BY position
|
||||
`, boardID)
|
||||
if err != nil {
|
||||
@@ -199,7 +201,7 @@ func (r *SQLiteRepository) ListTasks(ctx context.Context, boardID string) ([]*mo
|
||||
// ListTasksByColumn returns all tasks in a column
|
||||
func (r *SQLiteRepository) ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, board_id, column_id, title, description, state, priority, agent_type, assigned_to, position, metadata, created_at, updated_at
|
||||
SELECT id, board_id, column_id, title, description, state, priority, agent_type, repository_url, branch, assigned_to, position, metadata, created_at, updated_at
|
||||
FROM tasks WHERE column_id = ? ORDER BY position
|
||||
`, columnID)
|
||||
if err != nil {
|
||||
@@ -216,7 +218,7 @@ func (r *SQLiteRepository) scanTasks(rows *sql.Rows) ([]*models.Task, error) {
|
||||
for rows.Next() {
|
||||
task := &models.Task{}
|
||||
var metadata string
|
||||
err := rows.Scan(&task.ID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &task.AgentType, &task.AssignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
|
||||
err := rows.Scan(&task.ID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &task.AgentType, &task.RepositoryURL, &task.Branch, &task.AssignedTo, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -35,15 +35,17 @@ func NewService(repo repository.Repository, eventBus bus.EventBus, log *logger.L
|
||||
|
||||
// CreateTaskRequest contains the data for creating a new task
|
||||
type CreateTaskRequest struct {
|
||||
BoardID string `json:"board_id"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Priority int `json:"priority"`
|
||||
AgentType string `json:"agent_type,omitempty"`
|
||||
AssignedTo string `json:"assigned_to,omitempty"`
|
||||
Position int `json:"position"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
BoardID string `json:"board_id"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Priority int `json:"priority"`
|
||||
AgentType string `json:"agent_type,omitempty"`
|
||||
RepositoryURL string `json:"repository_url,omitempty"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
AssignedTo string `json:"assigned_to,omitempty"`
|
||||
Position int `json:"position"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateTaskRequest contains the data for updating a task
|
||||
@@ -83,17 +85,19 @@ type CreateColumnRequest struct {
|
||||
// CreateTask creates a new task and publishes a task.created event
|
||||
func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*models.Task, error) {
|
||||
task := &models.Task{
|
||||
ID: uuid.New().String(),
|
||||
BoardID: req.BoardID,
|
||||
ColumnID: req.ColumnID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
State: v1.TaskStateTODO,
|
||||
Priority: req.Priority,
|
||||
AgentType: req.AgentType,
|
||||
AssignedTo: req.AssignedTo,
|
||||
Position: req.Position,
|
||||
Metadata: req.Metadata,
|
||||
ID: uuid.New().String(),
|
||||
BoardID: req.BoardID,
|
||||
ColumnID: req.ColumnID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
State: v1.TaskStateTODO,
|
||||
Priority: req.Priority,
|
||||
AgentType: req.AgentType,
|
||||
RepositoryURL: req.RepositoryURL,
|
||||
Branch: req.Branch,
|
||||
AssignedTo: req.AssignedTo,
|
||||
Position: req.Position,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
|
||||
if err := s.repo.CreateTask(ctx, task); err != nil {
|
||||
|
||||
@@ -25,6 +25,9 @@ type Client struct {
|
||||
// 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{}
|
||||
}
|
||||
@@ -45,6 +48,33 @@ func (c *Client) SetNotificationHandler(handler func(method string, params json.
|
||||
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)
|
||||
@@ -161,21 +191,52 @@ func (c *Client) readLoop(ctx context.Context) {
|
||||
|
||||
c.logger.Debug("received message", zap.String("data", string(line)))
|
||||
|
||||
// Try to parse as response first
|
||||
var resp Response
|
||||
if err := json.Unmarshal(line, &resp); err == nil && resp.ID != nil {
|
||||
c.handleResponse(&resp)
|
||||
// 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
|
||||
}
|
||||
|
||||
// Try to parse as notification
|
||||
var notif Notification
|
||||
if err := json.Unmarshal(line, ¬if); err == nil && notif.Method != "" {
|
||||
c.handleNotification(¬if)
|
||||
continue
|
||||
}
|
||||
// Determine message type based on fields present
|
||||
hasID := msg.ID != nil
|
||||
hasMethod := msg.Method != ""
|
||||
hasResult := msg.Result != nil
|
||||
hasError := msg.Error != nil
|
||||
|
||||
c.logger.Warn("received unknown message format", zap.String("data", string(line)))
|
||||
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 {
|
||||
@@ -184,8 +245,11 @@ func (c *Client) readLoop(ctx context.Context) {
|
||||
}
|
||||
|
||||
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[resp.ID]
|
||||
ch, ok := c.pending[id]
|
||||
c.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
@@ -195,9 +259,38 @@ func (c *Client) handleResponse(resp *Response) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,11 +54,14 @@ const (
|
||||
|
||||
// Agent -> Client notifications
|
||||
NotificationSessionUpdate = "session/update"
|
||||
|
||||
// Agent -> Client requests (require response)
|
||||
MethodRequestPermission = "session/request_permission"
|
||||
)
|
||||
|
||||
// InitializeParams for initialize method
|
||||
type InitializeParams struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
ClientInfo ClientInfo `json:"clientInfo"`
|
||||
Capabilities ClientCapabilities `json:"capabilities,omitempty"`
|
||||
}
|
||||
@@ -76,7 +79,7 @@ type ClientCapabilities struct {
|
||||
|
||||
// InitializeResult from initialize method
|
||||
type InitializeResult struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
ProtocolVersion int `json:"protocolVersion"`
|
||||
ServerInfo ServerInfo `json:"serverInfo"`
|
||||
Capabilities ServerCapabilities `json:"capabilities,omitempty"`
|
||||
}
|
||||
@@ -94,7 +97,15 @@ type ServerCapabilities struct {
|
||||
|
||||
// SessionNewParams for session/new method
|
||||
type SessionNewParams struct {
|
||||
// Empty for now, may include workspace config later
|
||||
Cwd string `json:"cwd"` // Working directory for the session
|
||||
McpServers []McpServer `json:"mcpServers"` // MCP servers (required, can be empty array)
|
||||
}
|
||||
|
||||
// McpServer configuration for MCP servers
|
||||
type McpServer struct {
|
||||
Name string `json:"name"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
// SessionNewResult from session/new method
|
||||
@@ -102,9 +113,19 @@ type SessionNewResult struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
}
|
||||
|
||||
// ContentBlock represents a content block in ACP protocol
|
||||
// The prompt field in session/prompt is an array of ContentBlock
|
||||
type ContentBlock struct {
|
||||
Type string `json:"type"` // "text", "resource", "image", etc.
|
||||
Text string `json:"text,omitempty"` // For type="text"
|
||||
// Resource *ResourceContent `json:"resource,omitempty"` // For type="resource" (not implemented yet)
|
||||
}
|
||||
|
||||
// SessionPromptParams for session/prompt method
|
||||
// According to ACP protocol, prompt is an array of ContentBlock, not a string
|
||||
type SessionPromptParams struct {
|
||||
Message string `json:"message"`
|
||||
SessionID string `json:"sessionId"` // Session ID from session/new
|
||||
Prompt []ContentBlock `json:"prompt"` // Array of content blocks
|
||||
}
|
||||
|
||||
// SessionPromptResult from session/prompt method
|
||||
@@ -153,3 +174,33 @@ type SessionUpdateComplete struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
// RequestPermissionParams for session/request_permission request from agent
|
||||
type RequestPermissionParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
ToolCall ToolCallUpdate `json:"toolCall"`
|
||||
Options []PermissionOption `json:"options"`
|
||||
}
|
||||
|
||||
// ToolCallUpdate contains tool call information in permission requests
|
||||
type ToolCallUpdate struct {
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// PermissionOption represents a permission choice
|
||||
type PermissionOption struct {
|
||||
OptionID string `json:"optionId"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // allow_once, allow_always, reject_once, reject_always
|
||||
}
|
||||
|
||||
// RequestPermissionResult is the response to session/request_permission
|
||||
type RequestPermissionResult struct {
|
||||
Outcome PermissionOutcome `json:"outcome"`
|
||||
}
|
||||
|
||||
// PermissionOutcome represents the user's decision
|
||||
type PermissionOutcome struct {
|
||||
Outcome string `json:"outcome"` // "selected" or "cancelled"
|
||||
OptionID string `json:"optionId,omitempty"` // Only present when outcome="selected"
|
||||
}
|
||||
|
||||
+139
-117
@@ -11,6 +11,8 @@ The current implementation uses a **unified binary architecture** instead of sep
|
||||
- ✅ **SQLite database** for persistence (instead of PostgreSQL)
|
||||
- ✅ **In-memory event bus** (instead of NATS)
|
||||
- ✅ **Docker-based agent execution** with ACP streaming
|
||||
- ✅ **Native ACP (Agent Communication Protocol)** - JSON-RPC 2.0 over stdin/stdout
|
||||
- ✅ **Permission request handling** - Auto-approval of workspace indexing and tool permissions
|
||||
- ✅ **Session resumption** for multi-turn agent conversations
|
||||
- 📋 WebSocket support for real-time frontend streaming
|
||||
- 📋 JWT authentication
|
||||
@@ -354,149 +356,169 @@ Workspace Mount (Read-Write):
|
||||
|
||||
### Protocol Overview
|
||||
|
||||
ACP is a JSON-based streaming protocol for real-time bidirectional communication between AI agents and the backend system.
|
||||
Kandev uses the **Agent Communication Protocol (ACP)** - an open protocol for communication between AI agents and client applications. The implementation follows the official ACP specification using **JSON-RPC 2.0 over stdin/stdout**.
|
||||
|
||||
**Reference:** https://agentclientprotocol.com
|
||||
|
||||
**Key Characteristics:**
|
||||
- **Streaming**: Continuous message flow during agent execution
|
||||
- **Structured**: JSON format for consistency and parsing
|
||||
- **Real-time**: Low-latency delivery to frontend
|
||||
- **Bidirectional**: Agents send updates, backend can send control commands
|
||||
- **JSON-RPC 2.0**: Standard request/response/notification format
|
||||
- **Bidirectional**: Client sends requests, agent sends responses/notifications/requests
|
||||
- **Session-based**: All communication happens within a session context
|
||||
- **Permission model**: Agent requests permissions before tool execution
|
||||
|
||||
### ACP Message Format
|
||||
### Current Implementation ✅
|
||||
|
||||
The backend implements full ACP support with:
|
||||
|
||||
1. **Session Management** (`backend/internal/agent/acp/session.go`)
|
||||
- Create sessions with `session/new`
|
||||
- Send prompts with `session/prompt`
|
||||
- Resume sessions with stored session IDs
|
||||
|
||||
2. **JSON-RPC Client** (`backend/pkg/acp/jsonrpc/client.go`)
|
||||
- Handles responses to client requests
|
||||
- Handles notifications from agent (`session/update`)
|
||||
- Handles requests from agent (`session/request_permission`)
|
||||
|
||||
3. **Permission Handling**
|
||||
- Auto-approves workspace indexing permissions
|
||||
- Auto-approves tool execution permissions (selects first "allow" option)
|
||||
|
||||
### ACP Message Types
|
||||
|
||||
**1. Client → Agent Requests**
|
||||
|
||||
```json
|
||||
// session/new - Create a new session
|
||||
{
|
||||
"type": "progress|log|result|error|status|heartbeat",
|
||||
"timestamp": "2026-01-09T10:30:00Z",
|
||||
"agent_id": "uuid",
|
||||
"task_id": "uuid",
|
||||
"data": {
|
||||
// Type-specific data
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "session/new",
|
||||
"params": {
|
||||
"cwd": "/workspace",
|
||||
"mcpServers": []
|
||||
}
|
||||
}
|
||||
|
||||
// session/prompt - Send a prompt to the agent
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "session/prompt",
|
||||
"params": {
|
||||
"sessionId": "uuid",
|
||||
"content": [{"type": "text", "text": "Analyze this code"}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Message Types
|
||||
**2. Agent → Client Responses**
|
||||
|
||||
**1. Progress Messages**
|
||||
```json
|
||||
{
|
||||
"type": "progress",
|
||||
"timestamp": "2026-01-09T10:30:15Z",
|
||||
"agent_id": "agent-123",
|
||||
"task_id": "task-456",
|
||||
"data": {
|
||||
"progress": 45,
|
||||
"message": "Analyzing file: src/main.go",
|
||||
"current_file": "src/main.go",
|
||||
"files_processed": 23,
|
||||
"total_files": 51
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"result": {
|
||||
"sessionId": "uuid",
|
||||
"status": "ok"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. Log Messages**
|
||||
**3. Agent → Client Notifications**
|
||||
|
||||
```json
|
||||
// session/update - Progress updates
|
||||
{
|
||||
"type": "log",
|
||||
"timestamp": "2026-01-09T10:30:16Z",
|
||||
"agent_id": "agent-123",
|
||||
"task_id": "task-456",
|
||||
"data": {
|
||||
"level": "info",
|
||||
"message": "Found 3 potential issues in authentication module",
|
||||
"metadata": {
|
||||
"module": "auth",
|
||||
"issues": 3
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/update",
|
||||
"params": {
|
||||
"sessionId": "uuid",
|
||||
"content": [{"type": "text", "text": "Analyzing..."}],
|
||||
"stopReason": null
|
||||
}
|
||||
}
|
||||
|
||||
// session/update - Completion
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/update",
|
||||
"params": {
|
||||
"sessionId": "uuid",
|
||||
"content": [{"type": "text", "text": "Done!"}],
|
||||
"stopReason": "end_turn"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4. Agent → Client Requests (Permissions)**
|
||||
|
||||
```json
|
||||
// session/request_permission - Agent requests permission
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"method": "session/request_permission",
|
||||
"params": {
|
||||
"sessionId": "uuid",
|
||||
"toolCall": {
|
||||
"toolCallId": "workspace-indexing-permission",
|
||||
"title": "Workspace Indexing Permission"
|
||||
},
|
||||
"options": [
|
||||
{"optionId": "enable", "name": "Enable indexing", "kind": "allow_always"},
|
||||
{"optionId": "disable", "name": "Disable indexing", "kind": "reject_always"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Client responds with selected option
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 5,
|
||||
"result": {
|
||||
"outcome": {
|
||||
"outcome": "selected",
|
||||
"optionId": "enable"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Result Messages**
|
||||
```json
|
||||
{
|
||||
"type": "result",
|
||||
"timestamp": "2026-01-09T10:35:00Z",
|
||||
"agent_id": "agent-123",
|
||||
"task_id": "task-456",
|
||||
"data": {
|
||||
"status": "completed",
|
||||
"summary": "Analysis complete. Found 12 issues, suggested 8 improvements.",
|
||||
"artifacts": [
|
||||
{
|
||||
"type": "report",
|
||||
"path": "/workspace/analysis_report.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4. Error Messages**
|
||||
```json
|
||||
{
|
||||
"type": "error",
|
||||
"timestamp": "2026-01-09T10:32:00Z",
|
||||
"agent_id": "agent-123",
|
||||
"task_id": "task-456",
|
||||
"data": {
|
||||
"error": "Failed to parse file",
|
||||
"file": "src/broken.go",
|
||||
"details": "syntax error at line 45"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ACP Flow Through System
|
||||
### ACP Flow Through System (Current Implementation)
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ AI Agent │
|
||||
│ Container │
|
||||
│ │
|
||||
│ Writes to │
|
||||
│ stdout: │
|
||||
│ {"type":...} │
|
||||
└────────┬────────┘
|
||||
│ Docker logs API
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Agent Manager │
|
||||
│ │
|
||||
│ - Captures │
|
||||
│ - Parses JSON │
|
||||
│ - Validates │
|
||||
└────────┬────────┘
|
||||
│ NATS publish
|
||||
│ Subject: acp.message.{task_id}
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ NATS Event Bus │
|
||||
└────────┬────────┘
|
||||
│ NATS subscribe
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Orchestrator │
|
||||
│ │
|
||||
│ - Aggregates │
|
||||
│ - Stores in DB │
|
||||
│ - Broadcasts │
|
||||
└────────┬────────┘
|
||||
│ WebSocket
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ API Gateway │
|
||||
│ (WS Proxy) │
|
||||
└────────┬────────┘
|
||||
│ WebSocket
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Frontend UI │
|
||||
│ │
|
||||
│ - Displays │
|
||||
│ - Updates UI │
|
||||
└─────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Kandev Backend │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │
|
||||
│ │ Orchestrator │───▶│ ACP Session │───▶│ JSON-RPC Client │ │
|
||||
│ │ │ │ Manager │ │ │ │
|
||||
│ │ Execute(task)│ │ │ │ - Send requests │ │
|
||||
│ └──────────────┘ │ - Create │ │ - Handle resp │ │
|
||||
│ │ - Prompt │ │ - Handle notif │ │
|
||||
│ │ - Permission │ │ - Handle req │ │
|
||||
│ └──────────────┘ └────────┬────────┘ │
|
||||
│ │ │
|
||||
└───────────────────────────────────────────────────┼─────────────┘
|
||||
│
|
||||
Docker attach (stdin/stdout)
|
||||
│
|
||||
┌───────────────────────────────────────────────────┼─────────────┐
|
||||
│ Agent Container ▼ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ Auggie CLI (--acp) │ │
|
||||
│ │ │ │
|
||||
│ │ stdin ◀────── JSON-RPC requests from client │ │
|
||||
│ │ stdout ─────▶ JSON-RPC responses/notifications/requests │ │
|
||||
│ │ │ │
|
||||
│ │ Notifications: session/update (progress, results) │ │
|
||||
│ │ Requests: session/request_permission │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ /workspace ◀──── Mounted from host (repository_url) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Initial Agent Types
|
||||
|
||||
+50
-12
@@ -101,34 +101,66 @@ curl -X POST http://localhost:8080/api/v1/boards/{board_id}/columns/{column_id}/
|
||||
-d '{"title": "Fix login bug", "description": "Users cannot login"}'
|
||||
```
|
||||
|
||||
### Launch an Agent
|
||||
### Create a Task with Agent
|
||||
|
||||
```bash
|
||||
# Get your Augment session auth
|
||||
SESSION_AUTH=$(cat ~/.augment/session.json)
|
||||
|
||||
# Launch an agent
|
||||
curl -X POST http://localhost:8080/api/v1/agents/launch \
|
||||
# Create a task that will be executed by an AI agent
|
||||
curl -X POST http://localhost:8080/api/v1/tasks \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"task_id": "{task_id}",
|
||||
"title": "Analyze codebase",
|
||||
"description": "Provide an overview of the project structure",
|
||||
"board_id": "{board_id}",
|
||||
"column_id": "{column_id}",
|
||||
"agent_type": "augment-agent",
|
||||
"workspace_path": "/path/to/your/project",
|
||||
"env": {
|
||||
"AUGMENT_SESSION_AUTH": "'$SESSION_AUTH'",
|
||||
"TASK_DESCRIPTION": "Analyze this codebase"
|
||||
}
|
||||
"repository_url": "/path/to/your/project"
|
||||
}'
|
||||
```
|
||||
|
||||
### Start Task via Orchestrator
|
||||
|
||||
```bash
|
||||
# Start the task - this launches the agent container
|
||||
curl -X POST http://localhost:8080/api/v1/orchestrator/tasks/{task_id}/start \
|
||||
-H "Content-Type: application/json"
|
||||
```
|
||||
|
||||
### Check Agent Status
|
||||
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/agents/{agent_id}/status
|
||||
|
||||
# List all agents
|
||||
curl http://localhost:8080/api/v1/agents
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running the End-to-End Test
|
||||
|
||||
The project includes a comprehensive E2E test script:
|
||||
|
||||
```bash
|
||||
# Build the agent Docker image first
|
||||
cd backend/dockerfiles/augment-agent
|
||||
docker build -t kandev/augment-agent:latest .
|
||||
|
||||
# Run the E2E test
|
||||
cd /path/to/kandev
|
||||
./scripts/e2e-test.sh
|
||||
```
|
||||
|
||||
The E2E test:
|
||||
1. Starts the server with a fresh database
|
||||
2. Creates a board, column, and task
|
||||
3. Starts the task via the orchestrator
|
||||
4. Verifies the agent container starts
|
||||
5. Checks that ACP session is created
|
||||
6. Verifies permission requests are handled
|
||||
7. Waits for agent to complete
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
@@ -190,7 +222,9 @@ backend/
|
||||
│ └── main.go
|
||||
├── internal/
|
||||
│ ├── agent/ # Agent management
|
||||
│ │ ├── acp/ # ACP session management
|
||||
│ │ ├── api/ # Agent HTTP handlers
|
||||
│ │ ├── credentials/ # Credential providers
|
||||
│ │ ├── docker/ # Docker client
|
||||
│ │ ├── lifecycle/ # Container lifecycle
|
||||
│ │ └── registry/ # Agent type registry
|
||||
@@ -203,11 +237,15 @@ backend/
|
||||
│ ├── events/ # In-memory event bus
|
||||
│ └── common/ # Shared utilities
|
||||
│ └── logger/ # Zap logger
|
||||
├── pkg/acp/ # ACP protocol types
|
||||
│ └── jsonrpc/ # JSON-RPC 2.0 client
|
||||
├── dockerfiles/ # Agent Dockerfiles
|
||||
│ └── augment-agent/
|
||||
├── bin/ # Built binaries (gitignored)
|
||||
├── Makefile
|
||||
└── NEXT_STEPS.md # Development roadmap
|
||||
scripts/
|
||||
└── e2e-test.sh # End-to-end test script
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Executable
+277
@@ -0,0 +1,277 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# End-to-End Test for Kandev
|
||||
# This script tests the complete flow from task creation to agent execution
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker running with kandev/augment-agent:latest image built
|
||||
# - Augment session credentials in ~/.augment/session.json
|
||||
# - Port 8080 available
|
||||
#
|
||||
# Usage: ./scripts/e2e-test.sh
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BACKEND_DIR="$PROJECT_ROOT/backend"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
SERVER_PORT=8080
|
||||
BASE_URL="http://localhost:${SERVER_PORT}"
|
||||
WAIT_FOR_AGENT_SECONDS=120
|
||||
SERVER_PID=""
|
||||
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
echo -e "\n${YELLOW}Cleaning up...${NC}"
|
||||
if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
# Stop any running agent containers
|
||||
docker ps --filter "name=kandev-agent" -q | xargs -r docker stop 2>/dev/null || true
|
||||
docker ps --filter "name=kandev-agent" -aq | xargs -r docker rm 2>/dev/null || true
|
||||
echo -e "${GREEN}Cleanup complete${NC}"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
# Helper functions
|
||||
log_step() {
|
||||
echo -e "\n${BLUE}=== $1 ===${NC}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✓ $1${NC}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}✗ $1${NC}"
|
||||
}
|
||||
|
||||
log_info() {
|
||||
echo -e "${YELLOW}→ $1${NC}"
|
||||
}
|
||||
|
||||
wait_for_server() {
|
||||
log_info "Waiting for server to be ready..."
|
||||
for i in {1..30}; do
|
||||
if curl -s "${BASE_URL}/health" > /dev/null 2>&1; then
|
||||
log_success "Server is ready"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
log_error "Server failed to start"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check prerequisites
|
||||
log_step "Checking Prerequisites"
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "Docker is not installed"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Docker is available"
|
||||
|
||||
if ! docker info &> /dev/null; then
|
||||
log_error "Docker daemon is not running"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Docker daemon is running"
|
||||
|
||||
if ! docker image inspect kandev/augment-agent:latest &> /dev/null; then
|
||||
log_error "Docker image kandev/augment-agent:latest not found"
|
||||
log_info "Build it with: cd backend/dockerfiles/augment-agent && docker build -t kandev/augment-agent:latest ."
|
||||
exit 1
|
||||
fi
|
||||
log_success "Agent Docker image exists"
|
||||
|
||||
if [ ! -f "$HOME/.augment/session.json" ]; then
|
||||
log_error "Augment session not found at ~/.augment/session.json"
|
||||
log_info "Please log in with: auggie login"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Augment session credentials found"
|
||||
|
||||
# Kill any existing server on port 8080
|
||||
if lsof -i :${SERVER_PORT} &> /dev/null; then
|
||||
log_info "Killing existing process on port ${SERVER_PORT}"
|
||||
pkill -9 -f "kandev" 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Build and start the server
|
||||
log_step "Building and Starting Server"
|
||||
|
||||
cd "$BACKEND_DIR"
|
||||
rm -f kandev.db kandev.db-shm kandev.db-wal
|
||||
|
||||
log_info "Building kandev..."
|
||||
go build -o kandev ./cmd/kandev
|
||||
|
||||
log_info "Starting server..."
|
||||
./kandev > /tmp/kandev-e2e.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
log_info "Server PID: $SERVER_PID"
|
||||
|
||||
wait_for_server
|
||||
|
||||
# Run the E2E test
|
||||
log_step "Step 1: Create Board"
|
||||
BOARD=$(curl -s -X POST "${BASE_URL}/api/v1/boards" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "E2E Test Board", "description": "Automated end-to-end test"}')
|
||||
BOARD_ID=$(echo "$BOARD" | jq -r '.id')
|
||||
if [ "$BOARD_ID" == "null" ] || [ -z "$BOARD_ID" ]; then
|
||||
log_error "Failed to create board"
|
||||
echo "$BOARD"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Created board: $BOARD_ID"
|
||||
|
||||
log_step "Step 2: Create Column"
|
||||
COLUMN=$(curl -s -X POST "${BASE_URL}/api/v1/boards/${BOARD_ID}/columns" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "To Do", "position": 0}')
|
||||
COLUMN_ID=$(echo "$COLUMN" | jq -r '.id')
|
||||
if [ "$COLUMN_ID" == "null" ] || [ -z "$COLUMN_ID" ]; then
|
||||
log_error "Failed to create column"
|
||||
echo "$COLUMN"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Created column: $COLUMN_ID"
|
||||
|
||||
log_step "Step 3: Create Task"
|
||||
TASK=$(curl -s -X POST "${BASE_URL}/api/v1/tasks" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"title\": \"E2E Test Task\",
|
||||
\"description\": \"What is 2+2? Answer in one word.\",
|
||||
\"board_id\": \"${BOARD_ID}\",
|
||||
\"column_id\": \"${COLUMN_ID}\",
|
||||
\"repository_url\": \"${PROJECT_ROOT}\",
|
||||
\"agent_type\": \"augment-agent\"
|
||||
}")
|
||||
TASK_ID=$(echo "$TASK" | jq -r '.id')
|
||||
if [ "$TASK_ID" == "null" ] || [ -z "$TASK_ID" ]; then
|
||||
log_error "Failed to create task"
|
||||
echo "$TASK"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Created task: $TASK_ID"
|
||||
|
||||
log_step "Step 4: Start Task via Orchestrator"
|
||||
START_RESULT=$(curl -s -X POST "${BASE_URL}/api/v1/orchestrator/tasks/${TASK_ID}/start" \
|
||||
-H "Content-Type: application/json")
|
||||
AGENT_ID=$(echo "$START_RESULT" | jq -r '.agent_instance_id')
|
||||
if [ "$AGENT_ID" == "null" ] || [ -z "$AGENT_ID" ]; then
|
||||
log_error "Failed to start task"
|
||||
echo "$START_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Task started, agent ID: $AGENT_ID"
|
||||
|
||||
log_step "Step 5: Verify Agent Container Running"
|
||||
sleep 3
|
||||
CONTAINER_COUNT=$(docker ps --filter "name=kandev-agent" -q | wc -l)
|
||||
if [ "$CONTAINER_COUNT" -eq 0 ]; then
|
||||
log_error "No agent container running"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Agent container is running"
|
||||
|
||||
log_step "Step 6: Check Server Logs for Permission Handling"
|
||||
sleep 5
|
||||
if grep -q "Auto-approving permission request" /tmp/kandev-e2e.log; then
|
||||
log_success "Permission request was auto-approved"
|
||||
else
|
||||
log_info "Permission request may not have been logged yet (this is OK)"
|
||||
fi
|
||||
|
||||
if grep -q "ACP session created" /tmp/kandev-e2e.log; then
|
||||
log_success "ACP session was created"
|
||||
else
|
||||
log_error "ACP session creation not found in logs"
|
||||
cat /tmp/kandev-e2e.log | tail -50
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_step "Step 7: Wait for Agent to Process"
|
||||
log_info "Waiting up to ${WAIT_FOR_AGENT_SECONDS} seconds for agent to complete..."
|
||||
AGENT_STATUS="RUNNING"
|
||||
for i in $(seq 1 $WAIT_FOR_AGENT_SECONDS); do
|
||||
AGENT_INFO=$(curl -s "${BASE_URL}/api/v1/agents/${AGENT_ID}/status" 2>/dev/null || echo '{}')
|
||||
AGENT_STATUS=$(echo "$AGENT_INFO" | jq -r '.status // "UNKNOWN"')
|
||||
|
||||
if [ "$AGENT_STATUS" == "COMPLETED" ]; then
|
||||
log_success "Agent completed successfully"
|
||||
break
|
||||
elif [ "$AGENT_STATUS" == "FAILED" ]; then
|
||||
log_error "Agent failed"
|
||||
echo "$AGENT_INFO" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if container is still running
|
||||
CONTAINER_RUNNING=$(docker ps --filter "name=kandev-agent-${AGENT_ID:0:8}" -q | wc -l)
|
||||
if [ "$CONTAINER_RUNNING" -eq 0 ]; then
|
||||
log_info "Container stopped, checking for completion..."
|
||||
break
|
||||
fi
|
||||
|
||||
if [ $((i % 10)) -eq 0 ]; then
|
||||
log_info "Still waiting... ($i seconds)"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
log_step "Step 8: Verify Agent Response"
|
||||
# Get container logs to verify agent processed the request
|
||||
CONTAINER_ID=$(docker ps -a --filter "name=kandev-agent" --format "{{.ID}}" | head -1)
|
||||
if [ -n "$CONTAINER_ID" ]; then
|
||||
CONTAINER_LOGS=$(docker logs "$CONTAINER_ID" 2>&1 | tail -50)
|
||||
if echo "$CONTAINER_LOGS" | grep -q '"stopReason"'; then
|
||||
log_success "Agent completed with stop reason"
|
||||
elif echo "$CONTAINER_LOGS" | grep -q 'session/update'; then
|
||||
log_success "Agent sent session updates"
|
||||
else
|
||||
log_info "Could not verify agent response from container logs"
|
||||
fi
|
||||
else
|
||||
log_info "Container already removed"
|
||||
fi
|
||||
|
||||
log_step "Step 9: Check Final State"
|
||||
FINAL_AGENTS=$(curl -s "${BASE_URL}/api/v1/agents" | jq '.total')
|
||||
log_info "Total agents: $FINAL_AGENTS"
|
||||
|
||||
FINAL_TASK=$(curl -s "${BASE_URL}/api/v1/tasks/${TASK_ID}")
|
||||
TASK_STATE=$(echo "$FINAL_TASK" | jq -r '.state')
|
||||
log_info "Task state: $TASK_STATE"
|
||||
|
||||
# Summary
|
||||
log_step "Test Summary"
|
||||
echo -e "${GREEN}╔════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ End-to-End Test PASSED! ✓ ║${NC}"
|
||||
echo -e "${GREEN}╠════════════════════════════════════════╣${NC}"
|
||||
echo -e "${GREEN}║ Board created: ✓ ║${NC}"
|
||||
echo -e "${GREEN}║ Column created: ✓ ║${NC}"
|
||||
echo -e "${GREEN}║ Task created: ✓ ║${NC}"
|
||||
echo -e "${GREEN}║ Agent launched: ✓ ║${NC}"
|
||||
echo -e "${GREEN}║ ACP session: ✓ ║${NC}"
|
||||
echo -e "${GREEN}║ Permission handled: ✓ ║${NC}"
|
||||
echo -e "${GREEN}║ Agent processed: ✓ ║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════╝${NC}"
|
||||
|
||||
exit 0
|
||||
|
||||
Reference in New Issue
Block a user