// Package backendrpc is the Go client for the local Rust Resource Backend. // // The package talks directly to bat.sock over newline-delimited JSON-RPC 2.0. // It is the default Go integration path for bat-api; do not shell out to the // bat binary for normal service calls. package backendrpc import ( "bufio" "context" "encoding/json" "errors" "fmt" "net" "sync/atomic" "time" ) const jsonRPCVersion = "2.0" // Client calls the local Rust daemon through a Unix domain socket. type Client struct { SocketPath string Timeout time.Duration // DialContext exists for tests and alternative local transports. DialContext func(ctx context.Context, network string, address string) (net.Conn, error) seq atomic.Uint64 } // New returns a client for the given bat.sock path. func New(socketPath string) *Client { return &Client{SocketPath: socketPath, Timeout: 30 * time.Second} } // APIError mirrors the Rust ApiError envelope payload. type APIError struct { Code string `json:"code"` Kind string `json:"kind"` Domain string `json:"domain"` Location string `json:"location"` Message string `json:"message"` Retryable bool `json:"retryable"` } func (e *APIError) Error() string { if e == nil { return "" } if e.Code == "" { return e.Message } return fmt.Sprintf("%s %s", e.Code, e.Message) } // JSONRPCError is a transport-level JSON-RPC error. type JSONRPCError struct { Code int `json:"code"` Message string `json:"message"` } func (e *JSONRPCError) Error() string { if e == nil { return "" } return fmt.Sprintf("json-rpc error %d: %s", e.Code, e.Message) } // Envelope is the application-level payload carried in JSON-RPC result. type Envelope struct { OK bool `json:"ok"` Status string `json:"status"` Data json.RawMessage `json:"data,omitempty"` Error *APIError `json:"error,omitempty"` RequestID string `json:"request_id"` } type rpcRequest struct { JSONRPC string `json:"jsonrpc"` ID uint64 `json:"id"` Method string `json:"method"` Params any `json:"params,omitempty"` } type rpcResponse struct { JSONRPC string `json:"jsonrpc"` ID uint64 `json:"id"` Result json.RawMessage `json:"result,omitempty"` Error *JSONRPCError `json:"error,omitempty"` } // Call invokes method and decodes the envelope data into out when out is non-nil. func (c *Client) Call(ctx context.Context, method string, params any, out any) (*Envelope, error) { envelope, err := c.callEnvelope(ctx, method, params) if err != nil { return nil, err } if !envelope.OK { if envelope.Error != nil { return envelope, envelope.Error } return envelope, errors.New("backend rpc returned ok=false without error payload") } if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" { if err := json.Unmarshal(envelope.Data, out); err != nil { return envelope, fmt.Errorf("decode %s response data: %w", method, err) } } return envelope, nil } func (c *Client) callEnvelope(ctx context.Context, method string, params any) (*Envelope, error) { if c.SocketPath == "" { return nil, errors.New("backend rpc socket path is empty") } conn, err := c.dial(ctx) if err != nil { return nil, err } defer conn.Close() if deadline, ok := c.deadline(ctx); ok { _ = conn.SetDeadline(deadline) } req := rpcRequest{ JSONRPC: jsonRPCVersion, ID: c.seq.Add(1), Method: method, Params: params, } if err := json.NewEncoder(conn).Encode(&req); err != nil { return nil, fmt.Errorf("write backend rpc request %s: %w", method, err) } line, err := bufio.NewReader(conn).ReadBytes('\n') if err != nil { return nil, fmt.Errorf("read backend rpc response %s: %w", method, err) } var resp rpcResponse if err := json.Unmarshal(line, &resp); err != nil { return nil, fmt.Errorf("decode backend rpc response %s: %w", method, err) } if resp.Error != nil { return nil, resp.Error } if len(resp.Result) == 0 { return nil, fmt.Errorf("backend rpc %s response missing result", method) } var envelope Envelope if err := json.Unmarshal(resp.Result, &envelope); err != nil { return nil, fmt.Errorf("decode backend rpc envelope %s: %w", method, err) } return &envelope, nil } func (c *Client) dial(ctx context.Context) (net.Conn, error) { if c.DialContext != nil { return c.DialContext(ctx, "unix", c.SocketPath) } var dialer net.Dialer return dialer.DialContext(ctx, "unix", c.SocketPath) } func (c *Client) deadline(ctx context.Context) (time.Time, bool) { if deadline, ok := ctx.Deadline(); ok { return deadline, true } if c.Timeout > 0 { return time.Now().Add(c.Timeout), true } return time.Time{}, false } type boolParam struct { Force bool `json:"force"` } type pageParam struct { Offset int `json:"offset"` Limit int `json:"limit"` } type tailParam struct { Tail int `json:"tail"` } type taskIDParam struct { TaskID string `json:"task_id"` } // Ack is returned by accepted daemon control methods. type Ack struct { Command string `json:"command"` Status string `json:"status"` Message string `json:"message"` StateDir string `json:"state_dir"` SocketPath string `json:"socket_path"` Force *bool `json:"force,omitempty"` } // TaskAccepted is returned when an async backend task is queued. type TaskAccepted struct { TaskID string `json:"task_id"` Kind string `json:"kind"` } // TaskRecord is the pollable task state. type TaskRecord struct { ID string `json:"id"` Kind string `json:"kind"` Status string `json:"status"` Stage *string `json:"stage,omitempty"` Message *string `json:"message,omitempty"` CreatedAt uint64 `json:"created_at"` UpdatedAt uint64 `json:"updated_at"` StartedAt *uint64 `json:"started_at,omitempty"` FinishedAt *uint64 `json:"finished_at,omitempty"` Error *APIError `json:"error,omitempty"` Result json.RawMessage `json:"result,omitempty"` } type TaskList struct { Tasks []TaskRecord `json:"tasks"` } type TaskCancelResult struct { TaskID string `json:"task_id"` CancelRequested bool `json:"cancel_requested"` Note string `json:"note,omitempty"` } type TaskLogs struct { TaskID string `json:"task_id"` Lines []string `json:"lines"` } type DoctorCheck struct { Name string `json:"name"` OK bool `json:"ok"` Message string `json:"message"` } type DoctorReport struct { Command string `json:"command"` Status string `json:"status"` Message string `json:"message"` Healthy bool `json:"healthy"` Checks []DoctorCheck `json:"checks"` } type LogsReport struct { Command string `json:"command"` Status string `json:"status"` Message string `json:"message"` LogPath string `json:"log_path"` Exists bool `json:"exists"` Empty bool `json:"empty"` Bytes int `json:"bytes"` TotalLines int `json:"total_lines"` ReturnedLines int `json:"returned_lines"` Content string `json:"content"` } type DaemonStatusReport struct { Status string `json:"status"` StatusCode *string `json:"status_code,omitempty"` Message string `json:"message"` Running bool `json:"running"` PID *int `json:"pid,omitempty"` ResourceOutputRoot *string `json:"resource_output_root,omitempty"` StateDir string `json:"state_dir"` SocketPath string `json:"socket_path"` RPCAvailable bool `json:"rpc_available"` CurrentStage *string `json:"current_stage,omitempty"` CurrentMessage *string `json:"current_message,omitempty"` } type ResourceState struct { Status string `json:"status,omitempty"` StatusCode string `json:"status_code,omitempty"` StatusPhase string `json:"status_phase,omitempty"` StatusTerminal bool `json:"status_terminal,omitempty"` StatusRetryable bool `json:"status_retryable,omitempty"` ResourceOutputRoot *string `json:"resource_output_root,omitempty"` VersionState json.RawMessage `json:"version_state,omitempty"` LastUpdateStatus *string `json:"last_update_status,omitempty"` LastSuccessUnixSeconds *uint64 `json:"last_success_unix_seconds,omitempty"` } type ResourceManifestEntry struct { URL string `json:"url"` Destination string `json:"destination"` Bytes *uint64 `json:"bytes,omitempty"` BLAKE3 string `json:"blake3,omitempty"` } type ResourceManifestPage struct { Available bool `json:"available"` ResourceRoot string `json:"resource_root,omitempty"` ManifestVersion int `json:"manifest_version,omitempty"` TotalEntries int `json:"total_entries,omitempty"` Offset int `json:"offset,omitempty"` Limit int `json:"limit,omitempty"` Entries []ResourceManifestEntry `json:"entries,omitempty"` } func (c *Client) DaemonStatus(ctx context.Context) (*DaemonStatusReport, error) { var out DaemonStatusReport _, err := c.Call(ctx, "daemon.status", nil, &out) return &out, err } func (c *Client) DaemonLogs(ctx context.Context, tail int) (*LogsReport, error) { var out LogsReport _, err := c.Call(ctx, "daemon.logs", tailParam{Tail: tail}, &out) return &out, err } func (c *Client) DaemonStop(ctx context.Context) (*Ack, error) { var out Ack _, err := c.Call(ctx, "daemon.stop", nil, &out) return &out, err } func (c *Client) DaemonReload(ctx context.Context) (*Ack, error) { var out Ack _, err := c.Call(ctx, "daemon.reload", nil, &out) return &out, err } func (c *Client) DaemonRefresh(ctx context.Context, force bool) (*Ack, error) { var out Ack _, err := c.Call(ctx, "daemon.refresh", boolParam{Force: force}, &out) return &out, err } func (c *Client) DaemonDoctor(ctx context.Context) (*DoctorReport, error) { var out DoctorReport _, err := c.Call(ctx, "daemon.doctor", nil, &out) return &out, err } func (c *Client) ResourceState(ctx context.Context) (*ResourceState, error) { var out ResourceState _, err := c.Call(ctx, "resource.state", nil, &out) return &out, err } func (c *Client) ResourceSync(ctx context.Context, force bool) (*TaskAccepted, error) { var out TaskAccepted _, err := c.Call(ctx, "resource.sync", boolParam{Force: force}, &out) return &out, err } func (c *Client) ResourceVerify(ctx context.Context) (*TaskAccepted, error) { var out TaskAccepted _, err := c.Call(ctx, "resource.verify", nil, &out) return &out, err } func (c *Client) ResourceRepair(ctx context.Context) (*TaskAccepted, error) { var out TaskAccepted _, err := c.Call(ctx, "resource.repair", nil, &out) return &out, err } func (c *Client) ResourceManifest(ctx context.Context, offset int, limit int) (*ResourceManifestPage, error) { var out ResourceManifestPage _, err := c.Call(ctx, "resource.manifest", pageParam{Offset: offset, Limit: limit}, &out) return &out, err } func (c *Client) ResourceList(ctx context.Context, offset int, limit int) (*ResourceManifestPage, error) { var out ResourceManifestPage _, err := c.Call(ctx, "resource.list", pageParam{Offset: offset, Limit: limit}, &out) return &out, err } func (c *Client) CatalogStatus(ctx context.Context) (json.RawMessage, error) { return c.rawData(ctx, "catalog.status", nil) } func (c *Client) CatalogVersions(ctx context.Context) (json.RawMessage, error) { return c.rawData(ctx, "catalog.versions", nil) } func (c *Client) CatalogDiff(ctx context.Context) (json.RawMessage, error) { return c.rawData(ctx, "catalog.diff", nil) } func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted, error) { var out TaskAccepted _, err := c.Call(ctx, "catalog.refresh", boolParam{Force: force}, &out) return &out, err } func (c *Client) TaskStatus(ctx context.Context, taskID string) (*TaskRecord, error) { var out TaskRecord _, err := c.Call(ctx, "task.status", taskIDParam{TaskID: taskID}, &out) return &out, err } func (c *Client) TaskList(ctx context.Context) (*TaskList, error) { var out TaskList _, err := c.Call(ctx, "task.list", nil, &out) return &out, err } func (c *Client) TaskCancel(ctx context.Context, taskID string) (*TaskCancelResult, error) { var out TaskCancelResult _, err := c.Call(ctx, "task.cancel", taskIDParam{TaskID: taskID}, &out) return &out, err } func (c *Client) TaskLogs(ctx context.Context, taskID string) (*TaskLogs, error) { var out TaskLogs _, err := c.Call(ctx, "task.logs", taskIDParam{TaskID: taskID}, &out) return &out, err } func (c *Client) rawData(ctx context.Context, method string, params any) (json.RawMessage, error) { envelope, err := c.Call(ctx, method, params, nil) if err != nil { return nil, err } return envelope.Data, nil }