Files
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

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)
}