* feat(server): distinguish agent vs human CLI actions via X-Agent-ID/X-Task-ID headers Extract resolveActor helper in handler to centralize agent identity resolution from X-Agent-ID header with X-Task-ID cross-validation. Fix DeleteComment, DeleteIssue, and UpdateComment handlers that previously hardcoded "member" as actor type. Forward MULTICA_TASK_ID as X-Task-ID header from CLI client. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(server): add debug logging and test coverage for resolveActor Add slog.Debug on agent/task validation failures for easier debugging. Add TestResolveActor with 5 cases covering member fallback, valid agent, non-existent agent, valid task, and mismatched task. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
176 lines
4.7 KiB
Go
176 lines
4.7 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// APIClient is a REST client for the Multica server API.
|
|
// Used by ctrl subcommands (agent, runtime, status, etc.).
|
|
//
|
|
// TODO: Add Authorization header support. Agent routes (/api/agents/...)
|
|
// require JWT auth via middleware.Auth, but this client currently sends
|
|
// no auth token. CLI agent commands will fail with 401 until this is added.
|
|
type APIClient struct {
|
|
BaseURL string
|
|
WorkspaceID string
|
|
Token string
|
|
AgentID string // When set, requests are attributed to this agent instead of the user.
|
|
TaskID string // When set, sent as X-Task-ID for agent-task validation.
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// NewAPIClient creates a new API client for ctrl commands.
|
|
func NewAPIClient(baseURL, workspaceID, token string) *APIClient {
|
|
return &APIClient{
|
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
|
WorkspaceID: workspaceID,
|
|
Token: token,
|
|
HTTPClient: &http.Client{Timeout: 15 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (c *APIClient) setHeaders(req *http.Request) {
|
|
if c.Token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
|
}
|
|
if c.WorkspaceID != "" {
|
|
req.Header.Set("X-Workspace-ID", c.WorkspaceID)
|
|
}
|
|
if c.AgentID != "" {
|
|
req.Header.Set("X-Agent-ID", c.AgentID)
|
|
}
|
|
if c.TaskID != "" {
|
|
req.Header.Set("X-Task-ID", c.TaskID)
|
|
}
|
|
}
|
|
|
|
// GetJSON performs a GET request and decodes the JSON response.
|
|
func (c *APIClient) GetJSON(ctx context.Context, path string, out any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return fmt.Errorf("GET %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data)))
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
|
|
// DeleteJSON performs a DELETE request.
|
|
func (c *APIClient) DeleteJSON(ctx context.Context, path string) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, c.BaseURL+path, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return fmt.Errorf("DELETE %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PostJSON performs a POST request with a JSON body.
|
|
func (c *APIClient) PostJSON(ctx context.Context, path string, body any, out any) error {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return fmt.Errorf("POST %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(respData)))
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
|
|
// PutJSON performs a PUT request with a JSON body.
|
|
func (c *APIClient) PutJSON(ctx context.Context, path string, body any, out any) error {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.BaseURL+path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
c.setHeaders(req)
|
|
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
respData, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return fmt.Errorf("PUT %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(respData)))
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
|
|
// HealthCheck hits the /health endpoint and returns the response body.
|
|
func (c *APIClient) HealthCheck(ctx context.Context) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/health", nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
resp, err := c.HTTPClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
if resp.StatusCode >= 400 {
|
|
return "", fmt.Errorf("health check returned %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
|
}
|
|
return strings.TrimSpace(string(data)), nil
|
|
}
|