717 lines
27 KiB
Go
717 lines
27 KiB
Go
// Package agentctl provides a client for communicating with agentctl.
|
|
// This file contains the ControlClient for the agentctl control server API.
|
|
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/agentctl/types/streams"
|
|
"github.com/kandev/kandev/internal/common/acpprovider"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/common/subproc"
|
|
mcpprofile "github.com/kandev/kandev/internal/mcp/profile"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// ControlClient is a client for the agentctl control server API.
|
|
// It manages creation and deletion of agent instances.
|
|
// Used by both Docker and standalone runtimes.
|
|
type ControlClient struct {
|
|
mu sync.Mutex
|
|
baseURL string
|
|
httpClient *http.Client
|
|
logger *logger.Logger
|
|
authToken string
|
|
}
|
|
|
|
// McpServerConfig holds configuration for an MCP server.
|
|
type McpServerConfig struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Command string `json:"command,omitempty"`
|
|
Args []string `json:"args,omitempty"`
|
|
Env map[string]string `json:"env,omitempty"`
|
|
Headers map[string]string `json:"headers,omitempty"`
|
|
}
|
|
|
|
// CreateInstanceRequest contains the parameters for creating a new agent instance.
|
|
type CreateInstanceRequest struct {
|
|
ID string `json:"id,omitempty"`
|
|
WorkspacePath string `json:"workspace_path"`
|
|
AgentCommand string `json:"agent_command,omitempty"`
|
|
Protocol string `json:"protocol,omitempty"` // Protocol adapter to use (currently "acp")
|
|
AgentType string `json:"agent_type,omitempty"` // Agent type ID for debug file naming (e.g., "codex", "auggie")
|
|
WorkspaceFlag string `json:"workspace_flag,omitempty"` // CLI flag for workspace path (e.g., "--workspace-root")
|
|
Env map[string]string `json:"env,omitempty"`
|
|
AutoStart bool `json:"auto_start,omitempty"`
|
|
AutoApprovePermissions *bool `json:"auto_approve_permissions,omitempty"`
|
|
McpServers []McpServerConfig `json:"mcp_servers,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"` // Task session ID for MCP tool calls
|
|
TaskID string `json:"task_id,omitempty"` // Task ID for MCP plan tool calls (server-side injection)
|
|
DisableAskQuestion bool `json:"disable_ask_question,omitempty"` // Disable ask_user_question MCP tool (TUI agents)
|
|
AssumeMcpSse bool `json:"assume_mcp_sse,omitempty"` // Assume agent supports SSE MCP servers
|
|
AssumeMcpHttp bool `json:"assume_mcp_http,omitempty"` // Assume agent supports HTTP MCP servers
|
|
McpMode string `json:"mcp_mode,omitempty"` // MCP tool mode: "task" (default), "task-title-pending", "config", "office", or "automation"
|
|
McpProviders []string `json:"mcp_providers,omitempty"` // Supported review-automation providers
|
|
McpProfile *mcpprofile.Context `json:"mcp_profile,omitempty"` // Backend-owned typed MCP tool profile
|
|
// RequiresProcessKill tells agentctl to skip the graceful stdin-close wait
|
|
// and reap the agent process group immediately. Required for agents whose
|
|
// runtime keeps child processes (e.g. MCP servers) alive when stdin closes
|
|
// — notably opencode acp.
|
|
RequiresProcessKill bool `json:"requires_process_kill,omitempty"`
|
|
|
|
// StripEnv lists environment variables to strip from the agent's child
|
|
// process environment entirely (not just set to empty). Propagated from
|
|
// RuntimeConfig.StripEnv by the lifecycle executors.
|
|
StripEnv []string `json:"strip_env,omitempty"`
|
|
|
|
// ProviderGatewayAuth, when set, makes the ACP adapter authenticate the
|
|
// agent against an OpenAI-compatible gateway (base URL + bearer key) right
|
|
// after initialize. Resolved by the backend from the launching agent
|
|
// profile's OpenAI-compatible provider fields.
|
|
ProviderGatewayAuth *acpprovider.GatewayAuth `json:"provider_gateway_auth,omitempty"`
|
|
|
|
// NamespacesMCPToolsByServer tells the per-instance MCP server to adapt
|
|
// built-in tool names for an agent that appends the server name itself.
|
|
NamespacesMCPToolsByServer bool `json:"namespaces_mcp_tools_by_server,omitempty"`
|
|
|
|
// BaseBranches maps RepositoryName → base branch ref for the task's
|
|
// per-repo diff stats. The empty key "" applies to the root /
|
|
// single-repo tracker. Empty map disables the override and falls back
|
|
// to the hardcoded origin/main → master priority list inside agentctl.
|
|
BaseBranches map[string]string `json:"base_branches,omitempty"`
|
|
// RemoteContributions maps an agentctl workspace repository subpath to the
|
|
// server-authored contribution binding for that checkout. The empty key is
|
|
// the workspace root.
|
|
RemoteContributions map[string]models.RemoteContribution `json:"remote_contributions,omitempty"`
|
|
ContributionDestinations map[string]models.ContributionDestination `json:"contribution_destinations,omitempty"`
|
|
ComparisonTargets map[string]models.ComparisonTarget `json:"comparison_targets,omitempty"`
|
|
// WorkspaceSourceRoots are canonical host roots explicitly attached to the
|
|
// workspace. Agentctl permits file operations through links only beneath
|
|
// these roots.
|
|
WorkspaceSourceRoots []string `json:"workspace_source_roots,omitempty"`
|
|
}
|
|
|
|
// CreateInstanceResponse contains the result of creating a new agent instance.
|
|
type CreateInstanceResponse struct {
|
|
ID string `json:"id"`
|
|
Port int `json:"port"`
|
|
}
|
|
|
|
// InstanceInfo contains information about an agent instance.
|
|
type InstanceInfo struct {
|
|
ID string `json:"id"`
|
|
Port int `json:"port"`
|
|
Status string `json:"status"`
|
|
WorkspacePath string `json:"workspace_path"`
|
|
AgentCommand string `json:"agent_command"`
|
|
Env map[string]string `json:"env,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
// SessionID is the task session ID this instance was created for, if any.
|
|
SessionID string `json:"session_id,omitempty"`
|
|
// TaskID is the task ID this instance was created for, if any.
|
|
TaskID string `json:"task_id,omitempty"`
|
|
// WorkspaceSourceRoots is the instance's live, current source-root
|
|
// allowlist, read back rather than pushed (AC-EXECUTORS-SURVIVAL-002.14's
|
|
// "workspace source roots" reconstruction row).
|
|
WorkspaceSourceRoots []string `json:"workspace_source_roots,omitempty"`
|
|
// ProviderSessionID is the live agent CLI's own session identity, read
|
|
// back from the adopted instance (AC-EXECUTORS-SURVIVAL-002.14's
|
|
// "provider session identity" reconstruction row).
|
|
ProviderSessionID string `json:"provider_session_id,omitempty"`
|
|
}
|
|
|
|
// ControlClientOption configures optional ControlClient settings.
|
|
type ControlClientOption func(*ControlClient)
|
|
|
|
// WithControlAuthToken sets the Bearer token for authenticating control requests.
|
|
func WithControlAuthToken(token string) ControlClientOption {
|
|
return func(c *ControlClient) {
|
|
c.authToken = token
|
|
}
|
|
}
|
|
|
|
// NewControlClient creates a new ControlClient for the agentctl control server.
|
|
func NewControlClient(host string, port int, log *logger.Logger, opts ...ControlClientOption) *ControlClient {
|
|
c := &ControlClient{
|
|
baseURL: fmt.Sprintf("http://%s:%d", host, port),
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
logger: log.WithFields(zap.String("component", "agentctl-control")),
|
|
}
|
|
for _, opt := range opts {
|
|
opt(c)
|
|
}
|
|
if c.authToken != "" {
|
|
c.httpClient.Transport = &authTransport{token: c.authToken}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// AuthToken returns the current auth token. Used to propagate the token
|
|
// from the ControlClient to per-instance Clients after a handshake.
|
|
func (c *ControlClient) AuthToken() string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.authToken
|
|
}
|
|
|
|
// SetAuthToken sets the Bearer token for future requests.
|
|
// Used after a successful Handshake to authenticate subsequent calls.
|
|
func (c *ControlClient) SetAuthToken(token string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.authToken = token
|
|
c.httpClient.Transport = &authTransport{token: token}
|
|
}
|
|
|
|
// Handshake performs the bootstrap handshake with agentctl.
|
|
// It sends the one-time nonce and receives the self-generated auth token.
|
|
// On success, the token is automatically set for future requests.
|
|
func (c *ControlClient) Handshake(ctx context.Context, nonce string) (string, error) {
|
|
body, err := json.Marshal(map[string]string{"nonce": nonce})
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal handshake request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/auth/handshake", 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 "", fmt.Errorf("handshake failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
var errResp struct {
|
|
Error string `json:"error"`
|
|
}
|
|
if decErr := json.NewDecoder(resp.Body).Decode(&errResp); decErr == nil && errResp.Error != "" {
|
|
return "", fmt.Errorf("handshake rejected: %s (status %d)", errResp.Error, resp.StatusCode)
|
|
}
|
|
return "", fmt.Errorf("handshake failed: status %d", resp.StatusCode)
|
|
}
|
|
|
|
var result struct {
|
|
Token string `json:"token"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", fmt.Errorf("failed to decode handshake response: %w", err)
|
|
}
|
|
if result.Token == "" {
|
|
return "", fmt.Errorf("handshake returned empty token")
|
|
}
|
|
|
|
// Automatically set the token for future requests
|
|
c.SetAuthToken(result.Token)
|
|
c.logger.Info("bootstrap handshake completed")
|
|
|
|
return result.Token, nil
|
|
}
|
|
|
|
// Health checks if the agentctl control server is healthy.
|
|
func (c *ControlClient) 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
|
|
}
|
|
|
|
// SubprocessAdmission returns the agentctl process's current Git admission
|
|
// state. It is used by the backend debug export to correlate host and agentctl
|
|
// pressure without exposing control-server internals directly.
|
|
func (c *ControlClient) SubprocessAdmission(ctx context.Context) (subproc.Snapshot, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/debug/subprocess-admission", nil)
|
|
if err != nil {
|
|
return subproc.Snapshot{}, err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return subproc.Snapshot{}, fmt.Errorf("failed to get subprocess admission: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return subproc.Snapshot{}, fmt.Errorf("failed to get subprocess admission: status %d", resp.StatusCode)
|
|
}
|
|
|
|
var snapshot subproc.Snapshot
|
|
if err := json.NewDecoder(resp.Body).Decode(&snapshot); err != nil {
|
|
return subproc.Snapshot{}, fmt.Errorf("failed to decode subprocess admission: %w", err)
|
|
}
|
|
return snapshot, nil
|
|
}
|
|
|
|
// CreateInstance creates a new agent instance.
|
|
func (c *ControlClient) CreateInstance(ctx context.Context, req *CreateInstanceRequest) (*CreateInstanceResponse, error) {
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/instances", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create instance: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
|
var errResp struct {
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
|
|
return nil, fmt.Errorf("failed to decode error response: %w", err)
|
|
}
|
|
return nil, fmt.Errorf("failed to create instance: %s (status %d)", errResp.Error, resp.StatusCode)
|
|
}
|
|
|
|
var result CreateInstanceResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
c.logger.Info("created agent instance",
|
|
zap.String("instance_id", result.ID),
|
|
zap.Int("port", result.Port))
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// DeleteInstance stops and removes an agent instance.
|
|
func (c *ControlClient) DeleteInstance(ctx context.Context, instanceID string) error {
|
|
req, err := http.NewRequestWithContext(ctx, "DELETE", c.baseURL+"/api/v1/instances/"+instanceID, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete instance: %w", err)
|
|
}
|
|
defer func() {
|
|
_ = resp.Body.Close()
|
|
}()
|
|
|
|
// A retry may observe 404 after agentctl completed the first delete but its
|
|
// response was lost. The desired postcondition is already satisfied.
|
|
if resp.StatusCode != http.StatusOK &&
|
|
resp.StatusCode != http.StatusNoContent &&
|
|
resp.StatusCode != http.StatusNotFound {
|
|
var errResp struct {
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil {
|
|
return fmt.Errorf("failed to decode error response: %w", err)
|
|
}
|
|
return fmt.Errorf("failed to delete instance: %s (status %d)", errResp.Error, resp.StatusCode)
|
|
}
|
|
|
|
c.logger.Info("deleted agent instance", zap.String("instance_id", instanceID))
|
|
return nil
|
|
}
|
|
|
|
// ErrInstanceNotFound identifies an absent agentctl instance.
|
|
var ErrInstanceNotFound = errors.New("instance not found")
|
|
|
|
// GetInstance gets information about a specific instance.
|
|
func (c *ControlClient) GetInstance(ctx context.Context, instanceID string) (*InstanceInfo, error) {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/instances/"+instanceID, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get instance: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return nil, fmt.Errorf("%w: %q", ErrInstanceNotFound, instanceID)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("failed to get instance: status %d", resp.StatusCode)
|
|
}
|
|
|
|
var info InstanceInfo
|
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
return &info, nil
|
|
}
|
|
|
|
// IdentityInfo is the response from GET /identity: installation identity and
|
|
// the capability set this control server advertises.
|
|
type IdentityInfo struct {
|
|
ServerIdentity string `json:"server_identity"`
|
|
Capabilities []string `json:"capabilities"`
|
|
// UnownedPeriodMS is the reporting server's own resolved unowned period
|
|
// in milliseconds (AC-EXECUTORS-CONTROL-OWNERSHIP-003.2/.7) -- the value
|
|
// that server itself enforces, which an adopting backend's local config
|
|
// can disagree with across a restart. Use this, not local config, to
|
|
// compute an adopted server's ownership-renewal cadence.
|
|
UnownedPeriodMS int64 `json:"unowned_period_ms"`
|
|
}
|
|
|
|
// ServerDetails carries the control-server values an adopting backend
|
|
// records but that are withheld from the unauthenticated identity endpoint.
|
|
type ServerDetails struct {
|
|
HomeDir string `json:"home_dir"`
|
|
DiagnosticLogPath string `json:"diagnostic_log_path"`
|
|
}
|
|
|
|
// GetIdentity fetches the control server's identity and capability set. It
|
|
// deliberately does not require a valid auth token to succeed server-side
|
|
// (see agentctl's identity handler): identity retrieval decides adoption
|
|
// compatibility, so it cannot itself be gated behind the answer.
|
|
func (c *ControlClient) GetIdentity(ctx context.Context) (*IdentityInfo, error) {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/identity", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get identity: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("failed to get identity: status %d", resp.StatusCode)
|
|
}
|
|
|
|
var info IdentityInfo
|
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
return &info, nil
|
|
}
|
|
|
|
// ErrOwnershipCredentialSuperseded indicates the control server rejected an
|
|
// ownership-claim request because the presented credential is no longer
|
|
// current (HTTP 401) -- distinct from a transient failure (network error,
|
|
// 5xx). This is not the same backend's credential being stale from a
|
|
// renewal it initiated: a rotation this backend performs always leaves it
|
|
// holding the replacement, so a rejection here means some other party
|
|
// rotated the credential out from under it, most likely a second backend
|
|
// that adopted the server. Callers must not retry on this error
|
|
// (AC-EXECUTORS-CONTROL-OWNERSHIP-002.3).
|
|
var ErrOwnershipCredentialSuperseded = errors.New("ownership claim rejected: credential superseded")
|
|
|
|
// ClaimOwnership establishes or renews this backend's ownership of the
|
|
// control server. It carries no instance identity: a server with zero
|
|
// instances is still owned. Refused (non-nil error) once the server's
|
|
// unowned-shutdown one-way door has fired.
|
|
func (c *ControlClient) ClaimOwnership(ctx context.Context) error {
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/ownership/claim", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to claim ownership: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode == http.StatusUnauthorized {
|
|
return ErrOwnershipCredentialSuperseded
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("failed to claim ownership: status %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CredentialRotationResult is the response from the rotate half of the
|
|
// two-phase credential rotation: a rotation identifier from the control
|
|
// server's strictly increasing sequence, and the replacement credential to
|
|
// authenticate with from now on.
|
|
type CredentialRotationResult struct {
|
|
RotationID int64 `json:"rotation_id"`
|
|
Credential string `json:"credential"`
|
|
}
|
|
|
|
// ProveOwnership asks the control server to demonstrate it already holds
|
|
// the credential this backend has stored for it, over a challenge generated
|
|
// for this attempt. It carries no auth token, because it is what
|
|
// establishes that the server is worth sending one to: the answer is a
|
|
// keyed digest the server can only produce from the credential, and it
|
|
// never carries the credential itself.
|
|
func (c *ControlClient) ProveOwnership(ctx context.Context, challenge string) ([]string, error) {
|
|
payload, err := json.Marshal(map[string]string{"challenge": challenge})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/ownership/prove", bytes.NewReader(payload))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to request ownership proof: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("failed to request ownership proof: status %d", resp.StatusCode)
|
|
}
|
|
|
|
var decoded struct {
|
|
Proofs []string `json:"proofs"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
return decoded.Proofs, nil
|
|
}
|
|
|
|
// GetServerDetails reads the control-server values withheld from the
|
|
// unauthenticated identity endpoint. Requires a valid credential.
|
|
func (c *ControlClient) GetServerDetails(ctx context.Context) (*ServerDetails, error) {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/ownership/details", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get server details: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("failed to get server details: status %d", resp.StatusCode)
|
|
}
|
|
|
|
var details ServerDetails
|
|
if err := json.NewDecoder(resp.Body).Decode(&details); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
return &details, nil
|
|
}
|
|
|
|
// RotateCredential presents this client's current auth token (via the
|
|
// normal Authorization header) and asks the control server to replace it.
|
|
// The response's replacement credential authenticates every operation
|
|
// immediately; the presented (now superseded) credential remains valid for
|
|
// a further rotation attempt or the ownership-shutdown operation only,
|
|
// until ConfirmCredentialRotation is called. Idempotent under retry: a
|
|
// retry presenting the same (superseded) credential while unconfirmed
|
|
// returns the same rotation again rather than allocating a new one.
|
|
func (c *ControlClient) RotateCredential(ctx context.Context) (*CredentialRotationResult, error) {
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/ownership/rotate", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to rotate credential: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("failed to rotate credential: status %d", resp.StatusCode)
|
|
}
|
|
|
|
var result CredentialRotationResult
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// ConfirmCredentialRotation names rotationID as durably stored (both the
|
|
// replacement credential and the control-server record's reference to it).
|
|
// Call this only after both writes have durably completed; sending it
|
|
// earlier and then failing can strand the server unadoptable until its
|
|
// unowned period elapses (AC-EXECUTORS-CONTROL-OWNERSHIP-002.7).
|
|
func (c *ControlClient) ConfirmCredentialRotation(ctx context.Context, rotationID int64) error {
|
|
body, err := json.Marshal(struct {
|
|
RotationID int64 `json:"rotation_id"`
|
|
}{RotationID: rotationID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/ownership/confirm", 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 fmt.Errorf("failed to confirm credential rotation: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("failed to confirm credential rotation: status %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ShutdownControlServer invokes the ownership-shutdown operation: the
|
|
// control server stops every instance it supervises, together with their
|
|
// agent subprocesses, and exits. It requires no prior adoption or rotation
|
|
// and is authenticated by any credential in the acceptable set, including a
|
|
// superseded one that has not yet been confirmed away.
|
|
func (c *ControlClient) ShutdownControlServer(ctx context.Context) error {
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/ownership/shutdown", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to shut down control server: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("failed to shut down control server: status %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListInstances lists all running agent instances.
|
|
func (c *ControlClient) ListInstances(ctx context.Context) ([]*InstanceInfo, error) {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/instances", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list instances: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("failed to list instances: status %d", resp.StatusCode)
|
|
}
|
|
|
|
var result struct {
|
|
Instances []*InstanceInfo `json:"instances"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
return result.Instances, nil
|
|
}
|
|
|
|
// TurnOutcome is a retained terminal turn outcome for one instance
|
|
// (AC-EXECUTORS-SURVIVAL-004.1): the turn identifier the control server
|
|
// assigned, and the terminal event itself.
|
|
type TurnOutcome struct {
|
|
TurnID int64 `json:"turn_id"`
|
|
Event streams.AgentEvent `json:"event"`
|
|
}
|
|
|
|
// GetTurnOutcome retrieves the named instance's retained last terminal turn
|
|
// outcome, if any. Returns (nil, nil) when the instance exists but nothing
|
|
// is retained -- the AC-EXECUTORS-SURVIVAL-004.5 case that publishes the
|
|
// session as running. The read is repeatable: it never discards what it
|
|
// returns (AC-EXECUTORS-SURVIVAL-004.6); only AckTurnOutcome does.
|
|
func (c *ControlClient) GetTurnOutcome(ctx context.Context, instanceID string) (*TurnOutcome, error) {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/api/v1/instances/"+instanceID+"/turn-outcome", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get turn outcome: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return nil, fmt.Errorf("instance %q not found", instanceID)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("failed to get turn outcome: status %d", resp.StatusCode)
|
|
}
|
|
|
|
var body struct {
|
|
Retained bool `json:"retained"`
|
|
TurnID int64 `json:"turn_id"`
|
|
Event streams.AgentEvent `json:"event"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
if !body.Retained {
|
|
return nil, nil
|
|
}
|
|
return &TurnOutcome{TurnID: body.TurnID, Event: body.Event}, nil
|
|
}
|
|
|
|
// AckTurnOutcome acknowledges the named instance's retained outcome by turn
|
|
// identifier, discarding it. Naming an identifier the control server no
|
|
// longer holds, or never held, is accepted and changes nothing
|
|
// (AC-EXECUTORS-SURVIVAL-004.6), so a retried acknowledgement is always safe.
|
|
func (c *ControlClient) AckTurnOutcome(ctx context.Context, instanceID string, turnID int64) error {
|
|
body, err := json.Marshal(struct {
|
|
TurnID int64 `json:"turn_id"`
|
|
}{TurnID: turnID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/instances/"+instanceID+"/turn-outcome/ack", 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 fmt.Errorf("failed to ack turn outcome: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("failed to ack turn outcome: status %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|