Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
307 lines
9.9 KiB
Go
307 lines
9.9 KiB
Go
package daemon
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Client struct {
|
|
baseURL string
|
|
token string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
const (
|
|
// Built-in commands use this broad fallback budget. App commands publish
|
|
// their handler timeout through capability metadata and override it per
|
|
// invocation, including the transport margin owned by the terminal CLI.
|
|
defaultClientTimeout = 360 * time.Second
|
|
healthPath = "/v1/health"
|
|
cliCapabilitiesPath = "/v1/cli/capabilities"
|
|
cliCommandInvokePattern = "/v1/cli/commands/{commandID}/invoke"
|
|
)
|
|
|
|
type HealthStatus struct {
|
|
Service string `json:"service"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
type CapabilityList struct {
|
|
Commands []Capability `json:"commands"`
|
|
}
|
|
|
|
type Capability struct {
|
|
ID string `json:"id"`
|
|
Path []string `json:"path"`
|
|
Summary string `json:"summary"`
|
|
Description string `json:"description,omitempty"`
|
|
Visibility string `json:"visibility,omitempty"`
|
|
InputSchema map[string]any `json:"inputSchema,omitempty"`
|
|
Output CapabilityOutput `json:"output"`
|
|
Execution *CommandExecution `json:"execution,omitempty"`
|
|
HandlerTimeoutMs int `json:"handlerTimeoutMs,omitempty"`
|
|
Source CapabilitySource `json:"source"`
|
|
}
|
|
|
|
type CommandExecution struct {
|
|
Mode string `json:"mode"`
|
|
}
|
|
|
|
type CapabilityListOptions struct {
|
|
IncludeHidden bool
|
|
IncludeIntegration bool
|
|
AgentSessionID string
|
|
}
|
|
|
|
type CapabilitySource struct {
|
|
Kind string `json:"kind"`
|
|
AppID string `json:"appId,omitempty"`
|
|
AppName string `json:"appName,omitempty"`
|
|
CLIDescription string `json:"cliDescription,omitempty"`
|
|
AppDescription string `json:"appDescription,omitempty"`
|
|
DocumentationFile string `json:"documentationFile,omitempty"`
|
|
DocumentationPath string `json:"documentationPath,omitempty"`
|
|
}
|
|
|
|
type CapabilityOutput struct {
|
|
DefaultMode string `json:"defaultMode"`
|
|
JSON bool `json:"json"`
|
|
Table *struct {
|
|
Columns []TableColumn `json:"columns"`
|
|
} `json:"table"`
|
|
}
|
|
|
|
type TableColumn struct {
|
|
Key string `json:"key"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
type InvokeRequest struct {
|
|
Input map[string]any `json:"input,omitempty"`
|
|
OutputMode string `json:"outputMode,omitempty"`
|
|
Context InvokeContext `json:"context"`
|
|
}
|
|
|
|
type InvokeContext struct {
|
|
AppID string `json:"appId,omitempty"`
|
|
Source string `json:"source"`
|
|
WorkspaceID string `json:"workspaceID,omitempty"`
|
|
ParentCommandID string `json:"parentCommandId,omitempty"`
|
|
AgentSessionID string `json:"agentSessionId,omitempty"`
|
|
AgentCWD string `json:"agentCwd,omitempty"`
|
|
AgentRailPlacementJSON string `json:"agentRailPlacementJSON,omitempty"`
|
|
}
|
|
|
|
type InvokeResponse struct {
|
|
OK bool `json:"ok"`
|
|
Output *CommandOutput `json:"output,omitempty"`
|
|
}
|
|
|
|
type CommandOutput struct {
|
|
Kind string `json:"kind"`
|
|
Columns []TableColumn `json:"columns,omitempty"`
|
|
Rows []map[string]any `json:"rows,omitempty"`
|
|
Value map[string]any `json:"value,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
Warnings []CommandWarning `json:"warnings,omitempty"`
|
|
Continuation *CommandContinuation `json:"continuation,omitempty"`
|
|
}
|
|
|
|
type CommandContinuation struct {
|
|
State string `json:"state"`
|
|
RetryAfterMs int `json:"retryAfterMs"`
|
|
}
|
|
|
|
type CommandWarning struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type apiErrorEnvelope struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
Reason string `json:"reason"`
|
|
Retryable bool `json:"retryable"`
|
|
DeveloperMessage string `json:"developerMessage"`
|
|
CorrelationID string `json:"correlationId"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
func NewClient(endpoint Endpoint) (*Client, error) {
|
|
baseURL, err := endpoint.BaseURL()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Client{
|
|
baseURL: baseURL,
|
|
token: endpoint.Token,
|
|
httpClient: &http.Client{
|
|
Timeout: defaultClientTimeout,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (client *Client) GetHealth(ctx context.Context) (HealthStatus, error) {
|
|
var result HealthStatus
|
|
if err := client.DoJSON(ctx, http.MethodGet, healthPath, nil, &result); err != nil {
|
|
return HealthStatus{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (client *Client) ListCapabilitiesForWorkspaceWithOptions(ctx context.Context, workspaceID string, options CapabilityListOptions) (CapabilityList, error) {
|
|
var result CapabilityList
|
|
path := cliCapabilitiesPath
|
|
query := url.Values{}
|
|
if strings.TrimSpace(workspaceID) != "" {
|
|
query.Set("workspaceID", strings.TrimSpace(workspaceID))
|
|
}
|
|
if strings.TrimSpace(options.AgentSessionID) != "" {
|
|
query.Set("agentSessionID", strings.TrimSpace(options.AgentSessionID))
|
|
}
|
|
if options.IncludeHidden {
|
|
query.Set("includeHidden", "true")
|
|
}
|
|
if options.IncludeIntegration {
|
|
query.Set("includeIntegration", "true")
|
|
}
|
|
if len(query) > 0 {
|
|
path += "?" + query.Encode()
|
|
}
|
|
if err := client.DoJSON(ctx, http.MethodGet, path, nil, &result); err != nil {
|
|
return CapabilityList{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (client *Client) Invoke(ctx context.Context, commandID string, request InvokeRequest) (InvokeResponse, error) {
|
|
return client.InvokeWithTimeout(ctx, commandID, request, defaultClientTimeout)
|
|
}
|
|
|
|
func (client *Client) InvokeWithTimeout(ctx context.Context, commandID string, request InvokeRequest, timeout time.Duration) (InvokeResponse, error) {
|
|
var result InvokeResponse
|
|
path := strings.Replace(cliCommandInvokePattern, "{commandID}", urlPathEscape(commandID), 1)
|
|
httpClient := client.httpClient
|
|
if timeout > 0 {
|
|
cloned := *client.httpClient
|
|
cloned.Timeout = timeout
|
|
httpClient = &cloned
|
|
}
|
|
if err := client.doJSON(ctx, httpClient, http.MethodPost, path, request, &result); err != nil {
|
|
return InvokeResponse{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (client *Client) DoJSON(ctx context.Context, method string, path string, body any, result any) error {
|
|
return client.doJSON(ctx, client.httpClient, method, path, body, result)
|
|
}
|
|
|
|
func (client *Client) doJSON(ctx context.Context, httpClient *http.Client, method string, path string, body any, result any) error {
|
|
var requestBody io.Reader
|
|
if body != nil {
|
|
encoded, err := json.Marshal(body)
|
|
if err != nil {
|
|
return newRequestError(ErrorDetails{ReasonCode: "daemon_request_invalid", Message: fmt.Sprintf("encode request body: %v", err)})
|
|
}
|
|
requestBody = bytes.NewReader(encoded)
|
|
}
|
|
|
|
url := client.baseURL + path
|
|
request, err := http.NewRequestWithContext(ctx, method, url, requestBody)
|
|
if err != nil {
|
|
return newRequestError(ErrorDetails{ReasonCode: "daemon_request_invalid", Message: fmt.Sprintf("create daemon request: %v", err)})
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
request.Header.Set("Authorization", "Bearer "+client.token)
|
|
if body != nil {
|
|
request.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
response, err := httpClient.Do(request)
|
|
if err != nil {
|
|
return daemonRequestError(err)
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
content, err := io.ReadAll(response.Body)
|
|
if err != nil {
|
|
return newRequestError(ErrorDetails{ReasonCode: "daemon_response_read_failed", Message: fmt.Sprintf("read daemon response: %v", err)})
|
|
}
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return daemonResponseError(response.StatusCode, response.Status, content)
|
|
}
|
|
if result == nil {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(content, result); err != nil {
|
|
return newRequestError(ErrorDetails{ReasonCode: "daemon_response_invalid", Message: fmt.Sprintf("decode daemon response: %v", err)})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func daemonResponseError(statusCode int, status string, content []byte) error {
|
|
var envelope apiErrorEnvelope
|
|
if err := json.Unmarshal(content, &envelope); err == nil {
|
|
reasonCode := strings.TrimSpace(envelope.Error.Reason)
|
|
if reasonCode == "" {
|
|
reasonCode = strings.TrimSpace(envelope.Error.Code)
|
|
}
|
|
if reasonCode != "" {
|
|
message := strings.TrimSpace(envelope.Error.DeveloperMessage)
|
|
if message == "" {
|
|
message = reasonCode
|
|
}
|
|
return newRequestError(ErrorDetails{
|
|
ReasonCode: reasonCode, Message: message, Retryable: envelope.Error.Retryable,
|
|
CorrelationID: strings.TrimSpace(envelope.Error.CorrelationID), StatusCode: statusCode,
|
|
})
|
|
}
|
|
}
|
|
if statusCode == http.StatusUnauthorized {
|
|
return newRequestError(ErrorDetails{ReasonCode: "unauthorized", Message: "daemon authentication failed", StatusCode: statusCode})
|
|
}
|
|
message := strings.TrimSpace(string(content))
|
|
if message == "" {
|
|
message = strings.TrimSpace(status)
|
|
}
|
|
return newRequestError(ErrorDetails{
|
|
ReasonCode: "daemon_request_failed", Message: "daemon request failed: " + message, StatusCode: statusCode,
|
|
})
|
|
}
|
|
|
|
func daemonRequestError(err error) error {
|
|
if errors.Is(err, context.Canceled) {
|
|
return newRequestError(ErrorDetails{ReasonCode: "daemon_request_canceled", Message: "daemon request canceled"})
|
|
}
|
|
if errors.Is(err, context.DeadlineExceeded) || os.IsTimeout(err) {
|
|
return newRequestError(ErrorDetails{ReasonCode: "daemon_request_timed_out", Message: "daemon request timed out", Retryable: true})
|
|
}
|
|
if runningInAgentEnvironment() {
|
|
return newRequestError(ErrorDetails{
|
|
ReasonCode: "daemon_unavailable", Retryable: true,
|
|
Message: "daemon is not reachable from this agent execution environment; rerun the command in an execution environment with localhost/IPC access",
|
|
})
|
|
}
|
|
return newRequestError(ErrorDetails{ReasonCode: "daemon_unavailable", Message: "daemon is not reachable", Retryable: true})
|
|
}
|
|
|
|
func runningInAgentEnvironment() bool {
|
|
return strings.TrimSpace(os.Getenv("TUTTI_AGENT_SESSION_ID")) != "" ||
|
|
strings.TrimSpace(os.Getenv("TUTTI_AGENT_ROUTING")) != ""
|
|
}
|
|
|
|
func urlPathEscape(value string) string {
|
|
replacer := strings.NewReplacer("%", "%25", "/", "%2F", "?", "%3F", "#", "%23")
|
|
return replacer.Replace(value)
|
|
}
|