9aaf3170e0
* fix(lifecycle): drain StreamManager goroutines in tests Goroutines spawned by `agentctl.Client.StreamUpdates` / `StreamWorkspace` and by `lifecycle.StreamManager.connectWorkspaceStream` could outlive the tests that created them on slow CI runners, causing intermittent `goleak.VerifyTestMain` failures in `internal/agent/runtime/lifecycle` even though every individual test passed. The leak required the test to race: `client.Close()` could return while a workspace dial was still in flight, leaving the just-spawned WS read/write loops with nobody to drain them, and `StreamManager.Wait()` only fired the drain when the external `stopCh` had been closed first. Make the close paths absolute drain barriers: - `Client.Close()` now tracks every stream goroutine it spawns (`streamWG` + a per-stream `WorkspaceStream` reference) and blocks until all of them have exited. A `closed` flag flipped under the client mutex makes subsequent `StreamUpdates` / `StreamWorkspace` calls reject the dial, so a Close that races a dial in flight cannot leave behind a stranded WS connection. `manager_interaction.go` switches the restart path to per-stream Close helpers so it can keep reusing the client after teardown. - `StreamManager.Wait()` closes a new internal `waitCh` that the retry backoff (`sleepOrStop`) and the connected `<-ws.Done() / <-stop>` select also observe. This makes `Wait` an absolute drain barrier regardless of whether the caller ever closes the external `stopCh`. `streamContext` carries both stops through `stopChannelContext` so in-flight WebSocket dials cancel on either signal. Local stress (`make test-lifecycle-goleak LIFECYCLE_GOLEAK_COUNT=20`) passes 20/20 with `-race` after the fix. The new `TestClientClose_DrainsStreamGoroutines` covers the drain barrier and the post-Close rejection path. Added a `test-lifecycle-goleak` Makefile target as the repro hook for the CI-only flake. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(lifecycle): track mergeStops goroutine and guard workspace stream races Address PR review feedback: - stopChannelContext now registers the mergeStops goroutine on an optional *sync.WaitGroup so sm.wg.Wait stays a true drain barrier even for the connectUpdatesStream path (outer goroutine returns immediately after dial). - StreamWorkspace re-checks workspaceStreamConn after dial to seal the concurrent-callers race that could orphan a conn. - readWorkspaceStream's defer now guards the workspaceStreamConn reset by identity so an old read goroutine cannot zero out a newer conn pointer. - Clarify comments on streamWG, Client.Close, and the mock.connected wait in the close-barrier test. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(lifecycle): drop agent-stream drain barrier to unblock workflow cascade The previous Close drain barrier for the agent (updates) stream blocked the on_turn_complete cascade flow. completeAndStopSession calls Close synchronously on the current session's client, and Close was waiting on streamWG (which tracked the read goroutine). The wait stalled the cascade long enough that Step2's auto-started session never got its profile name into the tab — surfacing as a flaky "Profile B tab not visible" failure in workflow-agent-switch E2E shard 7. Drain barrier is still in place for the workspace stream (where the original goleak flake lived). Agent stream is closed but not awaited: the cascade legitimately stops + restarts the agent stream on the same client, and the workspace stream's existing barrier is sufficient for the leak coverage. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Kandev Agent <agent@kandev.dev>
721 lines
22 KiB
Go
721 lines
22 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/common/logger"
|
|
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
|
|
pendingMu sync.Mutex
|
|
}
|
|
|
|
// 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),
|
|
}
|
|
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, env map[string]string, approvalPolicy, continueCommand string) error {
|
|
ctx, span := tracing.TraceHTTPRequest(ctx, "POST", "/api/v1/agent/configure", c.executionID)
|
|
defer span.End()
|
|
|
|
payload := struct {
|
|
Command string `json:"command"`
|
|
ContinueCommand string `json:"continue_command,omitempty"`
|
|
Env map[string]string `json:"env,omitempty"`
|
|
ApprovalPolicy string `json:"approval_policy,omitempty"`
|
|
}{
|
|
Command: command,
|
|
ContinueCommand: continueCommand,
|
|
Env: env,
|
|
ApprovalPolicy: approvalPolicy,
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|