package daemon import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" ) // requestError is returned by postJSON/getJSON when the server responds with an error status. type requestError struct { Method string Path string StatusCode int Body string } func (e *requestError) Error() string { return fmt.Sprintf("%s %s returned %d: %s", e.Method, e.Path, e.StatusCode, e.Body) } // isWorkspaceNotFoundError returns true if the error is a 404 with "workspace not found" body. func isWorkspaceNotFoundError(err error) bool { var reqErr *requestError if !errors.As(err, &reqErr) { return false } if reqErr.StatusCode != http.StatusNotFound { return false } return strings.Contains(strings.ToLower(reqErr.Body), "workspace not found") } // Client handles HTTP communication with the Multica server daemon API. type Client struct { baseURL string client *http.Client } // NewClient creates a new daemon API client. func NewClient(baseURL string) *Client { return &Client{ baseURL: baseURL, client: &http.Client{Timeout: 30 * time.Second}, } } func (c *Client) ClaimTask(ctx context.Context, runtimeID string) (*Task, error) { var resp struct { Task *Task `json:"task"` } if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/runtimes/%s/tasks/claim", runtimeID), map[string]any{}, &resp); err != nil { return nil, err } return resp.Task, nil } func (c *Client) CreatePairingSession(ctx context.Context, req map[string]string) (PairingSession, error) { var resp PairingSession if err := c.postJSON(ctx, "/api/daemon/pairing-sessions", req, &resp); err != nil { return PairingSession{}, err } return resp, nil } func (c *Client) GetPairingSession(ctx context.Context, token string) (PairingSession, error) { var resp PairingSession if err := c.getJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s", url.PathEscape(token)), &resp); err != nil { return PairingSession{}, err } return resp, nil } func (c *Client) ClaimPairingSession(ctx context.Context, token string) (PairingSession, error) { var resp PairingSession if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s/claim", url.PathEscape(token)), map[string]any{}, &resp); err != nil { return PairingSession{}, err } return resp, nil } func (c *Client) StartTask(ctx context.Context, taskID string) error { return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/start", taskID), map[string]any{}, nil) } func (c *Client) ReportProgress(ctx context.Context, taskID, summary string, step, total int) error { return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/progress", taskID), map[string]any{ "summary": summary, "step": step, "total": total, }, nil) } func (c *Client) CompleteTask(ctx context.Context, taskID, output, branchName string) error { body := map[string]any{"output": output} if branchName != "" { body["branch_name"] = branchName } return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/complete", taskID), body, nil) } func (c *Client) FailTask(ctx context.Context, taskID, errMsg string) error { return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/fail", taskID), map[string]any{ "error": errMsg, }, nil) } func (c *Client) SendHeartbeat(ctx context.Context, runtimeID string) error { return c.postJSON(ctx, "/api/daemon/heartbeat", map[string]string{ "runtime_id": runtimeID, }, nil) } func (c *Client) Register(ctx context.Context, req map[string]any) ([]Runtime, error) { var resp struct { Runtimes []Runtime `json:"runtimes"` } if err := c.postJSON(ctx, "/api/daemon/register", req, &resp); err != nil { return nil, err } return resp.Runtimes, nil } func (c *Client) postJSON(ctx context.Context, path string, reqBody any, respBody any) error { var body io.Reader if reqBody != nil { data, err := json.Marshal(reqBody) if err != nil { return err } body = bytes.NewReader(data) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, body) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := c.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return &requestError{Method: http.MethodPost, Path: path, StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(data))} } if respBody == nil { io.Copy(io.Discard, resp.Body) return nil } return json.NewDecoder(resp.Body).Decode(respBody) } func (c *Client) getJSON(ctx context.Context, path string, respBody any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) if err != nil { return err } resp, err := c.client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 400 { data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) return &requestError{Method: http.MethodGet, Path: path, StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(data))} } if respBody == nil { io.Copy(io.Discard, resp.Body) return nil } return json.NewDecoder(resp.Body).Decode(respBody) }