840 lines
26 KiB
Go
840 lines
26 KiB
Go
// Package agentctl provides a client for communicating with agentctl running inside containers
|
|
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/kandev/kandev/internal/agentctl/tracing"
|
|
"github.com/kandev/kandev/internal/agentctl/types"
|
|
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/mcp/plugintools"
|
|
ws "github.com/kandev/kandev/pkg/websocket"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Client communicates with agentctl via HTTP and WebSocket
|
|
type Client struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
// longRunningHTTPClient is used for long-running operations like inference prompts.
|
|
// It has a much longer timeout (5 minutes) to accommodate LLM API calls.
|
|
longRunningHTTPClient *http.Client
|
|
logger *logger.Logger
|
|
executionID string
|
|
sessionID string
|
|
authToken string // shared secret for Bearer auth
|
|
|
|
// Optional trace context for session-scoped spans in background goroutines.
|
|
// When set, stream read loops use this as parent context for tracing instead of context.Background().
|
|
traceCtx context.Context
|
|
|
|
// WebSocket connections for streaming
|
|
agentStreamConn *websocket.Conn
|
|
workspaceStreamConn *websocket.Conn
|
|
// workspaceStream is the most-recent workspace stream returned by
|
|
// StreamWorkspace, retained so Client.Close can wait for its read/write
|
|
// goroutines to drain. Cleared by readWorkspaceStream's defer once the
|
|
// stream tears down.
|
|
workspaceStream *WorkspaceStream
|
|
// closed flips to true on Client.Close and prevents new StreamWorkspace
|
|
// dials from leaking goroutines past the close barrier. Agent (updates)
|
|
// stream is not gated on this flag because the cascade flow legitimately
|
|
// stops + restarts the agent stream on the same client; gating it would
|
|
// strand workflow step transitions on a closed client.
|
|
closed bool
|
|
mu sync.RWMutex
|
|
|
|
// Shared write mutex for agent stream (used by StreamUpdates and sendStreamRequest)
|
|
streamWriteMu sync.Mutex
|
|
|
|
// Pending request/response tracking for agent stream
|
|
pendingRequests map[string]chan *ws.Message
|
|
pendingRequestConns map[string]*websocket.Conn
|
|
pendingMu sync.Mutex
|
|
|
|
// lastSessionModelState is populated synchronously by session/new,
|
|
// session/reset, or session/load responses. Lifecycle policy evaluation can
|
|
// use it before the corresponding session_models event reaches its handler.
|
|
lastSessionModelState *streams.SessionModelState
|
|
}
|
|
|
|
func (c *Client) setLastSessionModelState(state *streams.SessionModelState) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.lastSessionModelState = cloneSessionModelState(state)
|
|
}
|
|
|
|
// GetLastSessionModelState returns the model catalog included in the most
|
|
// recent session creation or load response.
|
|
func (c *Client) GetLastSessionModelState() *streams.SessionModelState {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return cloneSessionModelState(c.lastSessionModelState)
|
|
}
|
|
|
|
func cloneSessionModelState(state *streams.SessionModelState) *streams.SessionModelState {
|
|
if state == nil {
|
|
return nil
|
|
}
|
|
cloned := &streams.SessionModelState{
|
|
CurrentModelID: state.CurrentModelID,
|
|
Models: append([]streams.SessionModelInfo(nil), state.Models...),
|
|
ConfigOptions: append([]streams.ConfigOption(nil), state.ConfigOptions...),
|
|
}
|
|
for i, model := range cloned.Models {
|
|
if model.Meta == nil {
|
|
continue
|
|
}
|
|
cloned.Models[i].Meta = make(map[string]any, len(model.Meta))
|
|
for key, value := range model.Meta {
|
|
cloned.Models[i].Meta[key] = value
|
|
}
|
|
}
|
|
for i, option := range cloned.ConfigOptions {
|
|
cloned.ConfigOptions[i].Options = append([]streams.ConfigOptionValue(nil), option.Options...)
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
// ClientOption configures optional Client settings.
|
|
type ClientOption func(*Client)
|
|
|
|
// WithExecutionID sets the execution ID used for tracing spans.
|
|
func WithExecutionID(id string) ClientOption {
|
|
return func(c *Client) {
|
|
c.executionID = id
|
|
}
|
|
}
|
|
|
|
// WithSessionID sets the session ID used for tracing spans.
|
|
func WithSessionID(id string) ClientOption {
|
|
return func(c *Client) {
|
|
c.sessionID = id
|
|
}
|
|
}
|
|
|
|
// WithAuthToken sets the Bearer token for authenticating requests to agentctl.
|
|
func WithAuthToken(token string) ClientOption {
|
|
return func(c *Client) {
|
|
c.authToken = token
|
|
}
|
|
}
|
|
|
|
// SetTraceContext sets the trace context used as parent for spans created in
|
|
// background goroutines (stream read loops). Thread-safe: can be called after construction.
|
|
func (c *Client) SetTraceContext(ctx context.Context) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.traceCtx = ctx
|
|
}
|
|
|
|
// getTraceCtx returns the trace context for background operations.
|
|
// Returns context.Background() when no trace context is set.
|
|
func (c *Client) getTraceCtx() context.Context {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if c.traceCtx != nil {
|
|
return c.traceCtx
|
|
}
|
|
return context.Background()
|
|
}
|
|
|
|
// StatusResponse from agentctl
|
|
type StatusResponse struct {
|
|
AgentStatus string `json:"agent_status"`
|
|
ProcessInfo map[string]interface{} `json:"process_info"`
|
|
}
|
|
|
|
// IsAgentRunning returns true if the agent process is running or starting
|
|
// (i.e., the agent is active and should not be considered stale)
|
|
func (s *StatusResponse) IsAgentRunning() bool {
|
|
return s.AgentStatus == "running" || s.AgentStatus == "starting"
|
|
}
|
|
|
|
// NewClient creates a new agentctl client
|
|
func NewClient(host string, port int, log *logger.Logger, opts ...ClientOption) *Client {
|
|
c := &Client{
|
|
baseURL: fmt.Sprintf("http://%s:%d", host, port),
|
|
httpClient: &http.Client{
|
|
Timeout: 60 * time.Second,
|
|
},
|
|
// Long-running HTTP client for inference prompts and other operations that may take minutes.
|
|
// LLM inference calls can take 1-5 minutes depending on model, prompt complexity, and API load.
|
|
longRunningHTTPClient: &http.Client{
|
|
Timeout: 5 * time.Minute,
|
|
},
|
|
logger: log.WithFields(zap.String("component", "agentctl-client")),
|
|
pendingRequests: make(map[string]chan *ws.Message),
|
|
pendingRequestConns: make(map[string]*websocket.Conn),
|
|
}
|
|
for _, opt := range opts {
|
|
opt(c)
|
|
}
|
|
// Install auth transport after options are applied so WithAuthToken takes effect.
|
|
if c.authToken != "" {
|
|
c.httpClient.Transport = &authTransport{token: c.authToken, base: c.httpClient.Transport}
|
|
c.longRunningHTTPClient.Transport = &authTransport{token: c.authToken, base: c.longRunningHTTPClient.Transport}
|
|
}
|
|
// Stamp every outgoing request with the execution/instance ID so the
|
|
// agentctl-server's middleware can reject stale clients whose port
|
|
// was recycled to a new instance. The header is informational —
|
|
// see instanceIDGuard in apps/backend/internal/agentctl/server/api.
|
|
if c.executionID != "" {
|
|
c.httpClient.Transport = &instanceIDTransport{instanceID: c.executionID, base: c.httpClient.Transport}
|
|
c.longRunningHTTPClient.Transport = &instanceIDTransport{instanceID: c.executionID, base: c.longRunningHTTPClient.Transport}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// authTransport is an http.RoundTripper that injects an Authorization header.
|
|
type authTransport struct {
|
|
token string
|
|
base http.RoundTripper
|
|
}
|
|
|
|
func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
r := req.Clone(req.Context())
|
|
r.Header.Set("Authorization", "Bearer "+t.token)
|
|
base := t.base
|
|
if base == nil {
|
|
base = http.DefaultTransport
|
|
}
|
|
return base.RoundTrip(r)
|
|
}
|
|
|
|
// instanceIDTransport is an http.RoundTripper that injects the
|
|
// X-Instance-ID header so the agentctl-server can reject requests
|
|
// whose port has been recycled to a different instance.
|
|
type instanceIDTransport struct {
|
|
instanceID string
|
|
base http.RoundTripper
|
|
}
|
|
|
|
func (t *instanceIDTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
r := req.Clone(req.Context())
|
|
r.Header.Set("X-Instance-ID", t.instanceID)
|
|
base := t.base
|
|
if base == nil {
|
|
base = http.DefaultTransport
|
|
}
|
|
return base.RoundTrip(r)
|
|
}
|
|
|
|
// wsAuthHeaders returns HTTP headers for WebSocket dial calls.
|
|
func (c *Client) wsAuthHeaders() http.Header {
|
|
if c.authToken == "" && c.executionID == "" {
|
|
return nil
|
|
}
|
|
h := http.Header{}
|
|
if c.authToken != "" {
|
|
h.Set("Authorization", "Bearer "+c.authToken)
|
|
}
|
|
if c.executionID != "" {
|
|
h.Set("X-Instance-ID", c.executionID)
|
|
}
|
|
return h
|
|
}
|
|
|
|
// Health checks if agentctl is healthy
|
|
func (c *Client) Health(ctx context.Context) error {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/health", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("health check failed: %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetStatus returns the agent status
|
|
func (c *Client) GetStatus(ctx context.Context) (*StatusResponse, error) {
|
|
ctx, span := tracing.TraceHTTPRequest(ctx, "GET", "/api/v1/status", c.executionID)
|
|
defer span.End()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/status", nil)
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, 0, err)
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, 0, err)
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
respBody, err := readResponseBody(resp)
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, err)
|
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
httpErr := fmt.Errorf("status request failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, httpErr)
|
|
return nil, httpErr
|
|
}
|
|
|
|
var status StatusResponse
|
|
if err := json.Unmarshal(respBody, &status); err != nil {
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, err)
|
|
return nil, fmt.Errorf("failed to parse status response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err)
|
|
}
|
|
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, nil)
|
|
return &status, nil
|
|
}
|
|
|
|
// ConfigureAgent configures the agent command and optional approval policy. Must be called before Start().
|
|
// continueCommand is optional — when set, the adapter uses it for one-shot follow-up prompts.
|
|
func (c *Client) ConfigureAgent(ctx context.Context, command string, agentArgs []string, env map[string]string, approvalPolicy, continueCommand string, continueArgs []string) error {
|
|
ctx, span := tracing.TraceHTTPRequest(ctx, "POST", "/api/v1/agent/configure", c.executionID)
|
|
defer span.End()
|
|
|
|
payload := struct {
|
|
Command string `json:"command"`
|
|
AgentArgs *[]string `json:"agent_args,omitempty"`
|
|
ContinueCommand string `json:"continue_command,omitempty"`
|
|
ContinueArgs *[]string `json:"continue_args,omitempty"`
|
|
Env map[string]string `json:"env,omitempty"`
|
|
ApprovalPolicy string `json:"approval_policy,omitempty"`
|
|
}{
|
|
Command: command,
|
|
ContinueCommand: continueCommand,
|
|
Env: env,
|
|
ApprovalPolicy: approvalPolicy,
|
|
}
|
|
if agentArgs != nil {
|
|
payload.AgentArgs = &agentArgs
|
|
}
|
|
if continueArgs != nil {
|
|
payload.ContinueArgs = &continueArgs
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, 0, err)
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/agent/configure", bytes.NewReader(body))
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, 0, err)
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, 0, err)
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
respBody, err := readResponseBody(resp)
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, err)
|
|
return fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
httpErr := fmt.Errorf("configure request failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, httpErr)
|
|
return httpErr
|
|
}
|
|
|
|
var result struct {
|
|
Success bool `json:"success"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, err)
|
|
return fmt.Errorf("failed to parse configure response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err)
|
|
}
|
|
if !result.Success {
|
|
cfgErr := fmt.Errorf("configure failed: %s", result.Error)
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, cfgErr)
|
|
return cfgErr
|
|
}
|
|
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, nil)
|
|
return nil
|
|
}
|
|
|
|
// SetMcpMode changes the MCP tool mode on the agentctl instance.
|
|
// This reconfigures which MCP tools are available to the agent.
|
|
func (c *Client) SetMcpMode(ctx context.Context, mode string) error {
|
|
ctx, span := tracing.TraceHTTPRequest(ctx, "PUT", "/api/v1/mcp/mode", c.executionID)
|
|
defer span.End()
|
|
|
|
body, err := json.Marshal(struct {
|
|
Mode string `json:"mode"`
|
|
}{Mode: mode})
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, 0, err)
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "PUT", c.baseURL+"/api/v1/mcp/mode", bytes.NewReader(body))
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, 0, err)
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, 0, err)
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
respBody, _ := readResponseBody(resp)
|
|
httpErr := fmt.Errorf("set MCP mode failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, httpErr)
|
|
return httpErr
|
|
}
|
|
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, nil)
|
|
return nil
|
|
}
|
|
|
|
// SetMcpProviders replaces the provider capabilities advertised by task-mode
|
|
// MCP tools on the agentctl instance.
|
|
func (c *Client) SetMcpProviders(ctx context.Context, providers []string) error {
|
|
ctx, span := tracing.TraceHTTPRequest(ctx, "PUT", "/api/v1/mcp/providers", c.executionID)
|
|
defer span.End()
|
|
|
|
body, err := json.Marshal(struct {
|
|
Providers []string `json:"mcp_providers"`
|
|
}{Providers: providers})
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, 0, err)
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "PUT", c.baseURL+"/api/v1/mcp/providers", bytes.NewReader(body))
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, 0, err)
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, 0, err)
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
respBody, _ := readResponseBody(resp)
|
|
httpErr := fmt.Errorf("set MCP providers failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, httpErr)
|
|
return httpErr
|
|
}
|
|
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, nil)
|
|
return nil
|
|
}
|
|
|
|
// SetPluginTools replaces the plugin-contributed MCP catalog on agentctl.
|
|
func (c *Client) SetPluginTools(ctx context.Context, snapshot plugintools.Snapshot) error {
|
|
ctx, span := tracing.TraceHTTPRequest(ctx, "PUT", "/api/v1/mcp/plugin-tools", c.executionID)
|
|
defer span.End()
|
|
body, err := json.Marshal(snapshot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, "PUT", c.baseURL+"/api/v1/mcp/plugin-tools", bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
respBody, _ := readResponseBody(resp)
|
|
return fmt.Errorf("set plugin tools failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Start starts the agent process and returns the full command that was executed.
|
|
func (c *Client) Start(ctx context.Context) (string, error) {
|
|
ctx, span := tracing.TraceHTTPRequest(ctx, "POST", "/api/v1/start", c.executionID)
|
|
defer span.End()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/start", nil)
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, 0, err)
|
|
return "", err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, 0, err)
|
|
return "", err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
respBody, err := readResponseBody(resp)
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, err)
|
|
return "", fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
httpErr := fmt.Errorf("start request failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, httpErr)
|
|
return "", httpErr
|
|
}
|
|
|
|
var result struct {
|
|
Success bool `json:"success"`
|
|
Command string `json:"command,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, err)
|
|
return "", fmt.Errorf("failed to parse start response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err)
|
|
}
|
|
if !result.Success {
|
|
startErr := fmt.Errorf("start failed: %s", result.Error)
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, startErr)
|
|
return "", startErr
|
|
}
|
|
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, nil)
|
|
return result.Command, nil
|
|
}
|
|
|
|
// Stop stops the agent process
|
|
func (c *Client) Stop(ctx context.Context) error {
|
|
ctx, span := tracing.TraceHTTPRequest(ctx, "POST", "/api/v1/stop", c.executionID)
|
|
defer span.End()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/stop", nil)
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, 0, err)
|
|
return err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, 0, err)
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
respBody, err := readResponseBody(resp)
|
|
if err != nil {
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, err)
|
|
return fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
httpErr := fmt.Errorf("stop request failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, httpErr)
|
|
return httpErr
|
|
}
|
|
|
|
var result struct {
|
|
Success bool `json:"success"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, err)
|
|
return fmt.Errorf("failed to parse stop response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err)
|
|
}
|
|
if !result.Success {
|
|
stopErr := fmt.Errorf("stop failed: %s", result.Error)
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, stopErr)
|
|
return stopErr
|
|
}
|
|
|
|
tracing.TraceHTTPResponse(span, resp.StatusCode, nil)
|
|
return nil
|
|
}
|
|
|
|
// WaitForReady waits until agentctl is ready to accept requests
|
|
func (c *Client) WaitForReady(ctx context.Context, timeout time.Duration) error {
|
|
deadline := time.Now().Add(timeout)
|
|
ticker := time.NewTicker(500 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-ticker.C:
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("timeout waiting for agentctl to be ready")
|
|
}
|
|
|
|
if err := c.Health(ctx); err == nil {
|
|
c.logger.Info("agentctl is ready")
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-export VS Code types from shared types package.
|
|
type (
|
|
VscodeStartResponse = types.VscodeStartResponse
|
|
VscodeStatusResponse = types.VscodeStatusResponse
|
|
VscodeStopResponse = types.VscodeStopResponse
|
|
)
|
|
|
|
// StartVscode starts the code-server with the given theme.
|
|
// The port is allocated by agentctl using an OS-assigned random port.
|
|
func (c *Client) StartVscode(ctx context.Context, theme string) (*VscodeStartResponse, error) {
|
|
payload := struct {
|
|
Theme string `json:"theme,omitempty"`
|
|
}{Theme: theme}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/vscode/start", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
respBody, err := readResponseBody(resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("vscode start failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result VscodeStartResponse
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return nil, fmt.Errorf("failed to parse vscode start response: %w", err)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// StopVscode stops the code-server.
|
|
func (c *Client) StopVscode(ctx context.Context) error {
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/vscode/stop", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
respBody, _ := readResponseBody(resp)
|
|
return fmt.Errorf("vscode stop failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// VscodeOpenFile opens a file in the running VS Code instance via agentctl.
|
|
func (c *Client) VscodeOpenFile(ctx context.Context, path string, line, col int) error {
|
|
payload := types.VscodeOpenFileRequest{
|
|
Path: path,
|
|
Line: line,
|
|
Col: col,
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/vscode/open-file", bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
respBody, err := readResponseBody(resp)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("vscode open-file failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result types.VscodeOpenFileResponse
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return fmt.Errorf("failed to parse vscode open-file response: %w", err)
|
|
}
|
|
if !result.Success {
|
|
return fmt.Errorf("vscode open-file failed: %s", result.Error)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// VscodeStatus returns the current code-server state.
|
|
func (c *Client) VscodeStatus(ctx context.Context) (*VscodeStatusResponse, error) {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/vscode/status", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
respBody, err := readResponseBody(resp)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("vscode status failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result VscodeStatusResponse
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return nil, fmt.Errorf("failed to parse vscode status response: %w", err)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// BaseURL returns the base URL of the agentctl client
|
|
func (c *Client) BaseURL() string {
|
|
return c.baseURL
|
|
}
|
|
|
|
// AuthToken returns the Bearer token used for authenticating requests to agentctl.
|
|
// Returns empty string if no token is configured.
|
|
func (c *Client) AuthToken() string {
|
|
return c.authToken
|
|
}
|
|
|
|
// Host returns the host portion (without port) of the agentctl client URL.
|
|
func (c *Client) Host() string {
|
|
parsed, err := url.Parse(c.baseURL)
|
|
if err != nil {
|
|
return c.baseURL
|
|
}
|
|
return parsed.Hostname()
|
|
}
|
|
|
|
// Re-export types from types package for convenience.
|
|
// These types are defined in the streams subpackage and re-exported through types.
|
|
type (
|
|
GitStatusUpdate = types.GitStatusUpdate
|
|
GitCommitNotification = types.GitCommitNotification
|
|
GitResetNotification = types.GitResetNotification
|
|
GitBranchSwitchNotification = types.GitBranchSwitchNotification
|
|
FileInfo = types.FileInfo
|
|
FileEntry = types.FileEntry
|
|
FileTreeNode = types.FileTreeNode
|
|
FileTreeRequest = types.FileTreeRequest
|
|
FileTreeResponse = types.FileTreeResponse
|
|
FileContentRequest = types.FileContentRequest
|
|
FileContentResponse = types.FileContentResponse
|
|
FileChangeNotification = types.FileChangeNotification
|
|
ShellMessage = types.ShellMessage
|
|
ShellStatusResponse = types.ShellStatusResponse
|
|
ShellBufferResponse = types.ShellBufferResponse
|
|
ProcessKind = types.ProcessKind
|
|
ProcessStatus = types.ProcessStatus
|
|
ProcessOutput = types.ProcessOutput
|
|
ProcessStatusUpdate = types.ProcessStatusUpdate
|
|
)
|
|
|
|
// Close closes all connections and releases resources. It is a drain
|
|
// barrier for workspace stream goroutines: when Close returns, the workspace
|
|
// read/write loops have fully exited and future StreamWorkspace calls return
|
|
// immediately with an error. The agent (updates) stream is closed but not
|
|
// drained synchronously — the cascade flow legitimately calls Close on a
|
|
// client whose updates stream is still mid-event, and blocking would stall
|
|
// workflow step transitions.
|
|
func (c *Client) Close() {
|
|
c.mu.Lock()
|
|
c.closed = true
|
|
ws := c.workspaceStream
|
|
c.mu.Unlock()
|
|
|
|
c.CloseUpdatesStream()
|
|
// CloseWorkspaceStream closes the raw conn to wake the blocked read loop.
|
|
// ws.Close (below) is needed to close the writeLoop's closeCh; closeOnce
|
|
// makes ws.Close idempotent so the duplicate conn.Close it issues just
|
|
// logs at Debug. Both calls together wake both goroutines deterministically.
|
|
c.CloseWorkspaceStream()
|
|
|
|
// Wait for the workspace stream's read/write goroutines to fully unwind.
|
|
if ws != nil {
|
|
ws.Close()
|
|
ws.Wait()
|
|
}
|
|
|
|
if c.httpClient != nil {
|
|
c.httpClient.CloseIdleConnections()
|
|
}
|
|
if c.longRunningHTTPClient != nil {
|
|
c.longRunningHTTPClient.CloseIdleConnections()
|
|
}
|
|
}
|
|
|
|
// readResponseBody reads and returns the response body
|
|
func readResponseBody(resp *http.Response) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
if _, err := buf.ReadFrom(resp.Body); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// truncateBody truncates body for error messages to avoid huge logs
|
|
func truncateBody(body []byte) string {
|
|
const maxLen = 200
|
|
if len(body) > maxLen {
|
|
return string(body[:maxLen]) + "..."
|
|
}
|
|
return string(body)
|
|
}
|