源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
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
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
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewClientUsesStartupFriendlyTimeout(t *testing.T) {
|
||||
client, err := NewClient(Endpoint{Addr: "127.0.0.1:1", Token: "token-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
|
||||
if client.httpClient.Timeout < 30*time.Second {
|
||||
t.Fatalf("timeout = %s, want at least 30s", client.httpClient.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeWithTimeoutOverridesDefaultClientBudget(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"output":{"kind":"json","value":{"ok":true}}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
token: "token-1",
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
response, err := client.InvokeWithTimeout(context.Background(), "wait.command", InvokeRequest{}, 100*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("InvokeWithTimeout() error = %v", err)
|
||||
}
|
||||
if response.Output == nil || response.Output.Value["ok"] != true {
|
||||
t.Fatalf("response = %#v", response)
|
||||
}
|
||||
if client.httpClient.Timeout != 10*time.Millisecond {
|
||||
t.Fatalf("shared client timeout mutated to %s", client.httpClient.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoJSONReportsTimeoutSeparatelyFromUnreachable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"service":"tuttid","status":"ok"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{
|
||||
baseURL: server.URL,
|
||||
token: "token-1",
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
|
||||
var result HealthStatus
|
||||
err := client.DoJSON(context.Background(), http.MethodGet, healthPath, nil, &result)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "daemon request timed out") {
|
||||
t.Fatalf("error = %q, want timeout message", err.Error())
|
||||
}
|
||||
if strings.Contains(err.Error(), "daemon is not reachable") {
|
||||
t.Fatalf("error = %q, should not report daemon as unreachable", err.Error())
|
||||
}
|
||||
details, ok := RequestErrorDetails(err)
|
||||
if !ok || details.ReasonCode != "daemon_request_timed_out" || !details.Retryable {
|
||||
t.Fatalf("details = %#v ok=%t", details, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoJSONPreservesStructuredDaemonError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"workspace_not_found","reason":"workspace_agent_session_not_found","retryable":true,"developerMessage":"agent session was not found","correlationId":"corr-1"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := &Client{baseURL: server.URL, token: "token-1", httpClient: server.Client()}
|
||||
err := client.DoJSON(context.Background(), http.MethodGet, healthPath, nil, &HealthStatus{})
|
||||
details, ok := RequestErrorDetails(err)
|
||||
if !ok {
|
||||
t.Fatalf("RequestErrorDetails(%v) did not match", err)
|
||||
}
|
||||
if details.ReasonCode != "workspace_agent_session_not_found" || details.Message != "agent session was not found" ||
|
||||
!details.Retryable || details.CorrelationID != "corr-1" || details.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("details = %#v", details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRequestErrorKeepsPlainUnreachableOutsideAgent(t *testing.T) {
|
||||
t.Setenv("TUTTI_AGENT_SESSION_ID", "")
|
||||
t.Setenv("TUTTI_AGENT_ROUTING", "")
|
||||
|
||||
err := daemonRequestError(errors.New("dial tcp failed"))
|
||||
|
||||
if got, want := err.Error(), "daemon is not reachable"; got != want {
|
||||
t.Fatalf("error = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonRequestErrorHintsLocalDaemonAccessInsideAgent(t *testing.T) {
|
||||
t.Setenv("TUTTI_AGENT_SESSION_ID", "session-1")
|
||||
t.Setenv("TUTTI_AGENT_ROUTING", "")
|
||||
|
||||
err := daemonRequestError(errors.New("dial tcp failed"))
|
||||
message := err.Error()
|
||||
|
||||
for _, expected := range []string{
|
||||
"daemon is not reachable from this agent execution environment",
|
||||
"execution environment with localhost/IPC access",
|
||||
"localhost/IPC",
|
||||
} {
|
||||
if !strings.Contains(message, expected) {
|
||||
t.Fatalf("error = %q, want to contain %q", message, expected)
|
||||
}
|
||||
}
|
||||
if strings.Contains(message, "sandbox_permissions=require_escalated") {
|
||||
t.Fatalf("error = %q, should not contain provider-specific Codex escalation syntax", message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/tutti-os/tutti/apps/cli/internal/defaults"
|
||||
)
|
||||
|
||||
type Endpoint struct {
|
||||
Addr string
|
||||
Token string
|
||||
}
|
||||
|
||||
type endpointFile struct {
|
||||
Version int `json:"version"`
|
||||
Addr string `json:"addr"`
|
||||
Auth endpointFileAuth `json:"auth"`
|
||||
}
|
||||
|
||||
type endpointFileAuth struct {
|
||||
Scheme string `json:"scheme"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func DiscoverEndpoint() (Endpoint, error) {
|
||||
return ReadEndpointFile(ListenerInfoPath())
|
||||
}
|
||||
|
||||
func ReadEndpointFile(path string) (Endpoint, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Endpoint{}, fmt.Errorf("daemon endpoint is not available; start the Tutti desktop app")
|
||||
}
|
||||
return Endpoint{}, fmt.Errorf("read daemon endpoint: %w", err)
|
||||
}
|
||||
|
||||
var payload endpointFile
|
||||
if err := json.Unmarshal(content, &payload); err != nil {
|
||||
return Endpoint{}, fmt.Errorf("daemon endpoint file is invalid JSON")
|
||||
}
|
||||
|
||||
addr := strings.TrimSpace(payload.Addr)
|
||||
if addr == "" {
|
||||
return Endpoint{}, fmt.Errorf("daemon endpoint file does not contain addr")
|
||||
}
|
||||
if payload.Version != 0 && payload.Version != 1 {
|
||||
return Endpoint{}, fmt.Errorf("daemon endpoint file version is not supported")
|
||||
}
|
||||
|
||||
scheme := strings.ToLower(strings.TrimSpace(payload.Auth.Scheme))
|
||||
token := strings.TrimSpace(payload.Auth.Token)
|
||||
if scheme != "bearer" || token == "" {
|
||||
return Endpoint{}, fmt.Errorf("daemon endpoint file does not contain bearer auth")
|
||||
}
|
||||
|
||||
return Endpoint{Addr: addr, Token: token}, nil
|
||||
}
|
||||
|
||||
func (endpoint Endpoint) BaseURL() (string, error) {
|
||||
addr := strings.TrimSpace(endpoint.Addr)
|
||||
if addr == "" {
|
||||
return "", fmt.Errorf("daemon address is empty")
|
||||
}
|
||||
if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") {
|
||||
parsed, err := url.Parse(addr)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return "", fmt.Errorf("daemon address is invalid")
|
||||
}
|
||||
return strings.TrimRight(addr, "/"), nil
|
||||
}
|
||||
return "http://" + strings.TrimRight(addr, "/"), nil
|
||||
}
|
||||
|
||||
func ListenerInfoPath() string {
|
||||
return defaults.ResolveDefaultsFromEnv().State.TuttidListenerInfoPath
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadEndpointFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tuttid.listener.json")
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"addr":"127.0.0.1:4545","auth":{"scheme":"bearer","token":"token-1"}}`), 0o600); err != nil {
|
||||
t.Fatalf("write endpoint: %v", err)
|
||||
}
|
||||
|
||||
endpoint, err := ReadEndpointFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEndpointFile: %v", err)
|
||||
}
|
||||
if endpoint.Addr != "127.0.0.1:4545" {
|
||||
t.Fatalf("addr = %q", endpoint.Addr)
|
||||
}
|
||||
if endpoint.Token != "token-1" {
|
||||
t.Fatalf("token = %q", endpoint.Token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadEndpointFileRejectsMissingAuth(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tuttid.listener.json")
|
||||
if err := os.WriteFile(path, []byte(`{"addr":"127.0.0.1:4545"}`), 0o600); err != nil {
|
||||
t.Fatalf("write endpoint: %v", err)
|
||||
}
|
||||
|
||||
if _, err := ReadEndpointFile(path); err == nil {
|
||||
t.Fatal("expected missing auth to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenerInfoPathUsesStateRoot(t *testing.T) {
|
||||
stateDir := t.TempDir()
|
||||
t.Setenv("TUTTI_STATE_DIR", stateDir)
|
||||
t.Setenv("TUTTID_RUN_DIR", "")
|
||||
t.Setenv("TUTTID_LISTENER_INFO_PATH", "")
|
||||
|
||||
want := filepath.Join(stateDir, "run", "tuttid.listener.json")
|
||||
if got := ListenerInfoPath(); got != want {
|
||||
t.Fatalf("ListenerInfoPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ErrorDetails struct {
|
||||
ReasonCode string
|
||||
Message string
|
||||
Retryable bool
|
||||
CorrelationID string
|
||||
StatusCode int
|
||||
}
|
||||
|
||||
type requestError struct {
|
||||
details ErrorDetails
|
||||
}
|
||||
|
||||
func (e *requestError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.details.Message
|
||||
}
|
||||
|
||||
func RequestErrorDetails(err error) (ErrorDetails, bool) {
|
||||
var requestErr *requestError
|
||||
if !errors.As(err, &requestErr) || requestErr == nil {
|
||||
return ErrorDetails{}, false
|
||||
}
|
||||
return requestErr.details, true
|
||||
}
|
||||
|
||||
func newRequestError(details ErrorDetails) error {
|
||||
details.ReasonCode = strings.TrimSpace(details.ReasonCode)
|
||||
details.Message = strings.TrimSpace(details.Message)
|
||||
if details.ReasonCode == "" {
|
||||
details.ReasonCode = "daemon_request_failed"
|
||||
}
|
||||
if details.Message == "" {
|
||||
details.Message = details.ReasonCode
|
||||
}
|
||||
return &requestError{details: details}
|
||||
}
|
||||
Reference in New Issue
Block a user