// Package client provides a synchronous facade over foreman's async /jobs API. // // Why: orchestration callers need a simple blocking call to submit chat work // to a foreman daemon without managing webhooks or polling themselves. This is // the Level 1 integration described in ADR-0011. // What: submits a job via POST /jobs, waits for completion using either a local // webhook receiver (preferred) or polling fallback, and returns the result. // Test: spin up a real foreman server via internal packages, submit jobs through // the client, verify results for happy path, failure, timeout, and auth. package client import ( "bytes" "context" "encoding/json" "fmt" "net/http" "time" ) // Client communicates with a foreman daemon's async /jobs API. type Client struct { baseURL string token string webhookSecret string httpClient *http.Client pollInterval time.Duration forcePolling bool } // Option configures a Client. type Option func(*Client) // New creates a new foreman client targeting the given base URL. // // Why: callers need a single entry point with sensible defaults and optional overrides. // What: returns a Client configured with the base URL and any provided options. // Test: create with New, verify baseURL is trimmed and defaults are applied. func New(baseURL string, opts ...Option) *Client { c := &Client{ baseURL: baseURL, httpClient: &http.Client{Timeout: 0}, // no global timeout; per-request via context pollInterval: 2 * time.Second, } for _, opt := range opts { opt(c) } return c } // WithToken sets the bearer token for authenticating with foreman. // // Why: foreman supports optional static bearer auth (ADR-0010). // What: configures the Authorization header on all outbound requests. // Test: create with WithToken, submit a job, verify the header is present. func WithToken(token string) Option { return func(c *Client) { c.token = token } } // WithWebhookSecret sets the HMAC secret for verifying inbound webhook payloads. // // Why: callers receiving webhooks need to verify authenticity (ADR-0005). // What: stores the secret for HMAC-SHA256 verification on received events. // Test: create with a secret, receive a webhook, verify signature check passes. func WithWebhookSecret(secret string) Option { return func(c *Client) { c.webhookSecret = secret } } // WithHTTPClient sets a custom HTTP client for outbound requests. // // Why: callers may need custom TLS, timeouts, or transport settings. // What: replaces the default http.Client. // Test: create with a custom client, verify it is used for requests. func WithHTTPClient(hc *http.Client) Option { return func(c *Client) { c.httpClient = hc } } // WithPollInterval sets the interval for polling GET /jobs/{id} in fallback mode. // // Why: the default 2s may be too frequent or too slow for some callers. // What: overrides the default poll interval. // Test: create with a custom interval, verify polling uses it. func WithPollInterval(d time.Duration) Option { return func(c *Client) { c.pollInterval = d } } // WithPollingMode forces the client to use polling instead of attempting a // webhook receiver. Useful when the caller knows it is behind NAT or a firewall. // // Why: webhook receivers require the foreman daemon to reach back to the caller, // which is not always possible (NAT, firewalls, containers). // What: disables the webhook receiver attempt and uses polling exclusively. // Test: create with WithPollingMode, verify no listener is started. func WithPollingMode() Option { return func(c *Client) { c.forcePolling = true } } // SubmitRequest is the payload for submitting a job to foreman. type SubmitRequest struct { Model string `json:"model"` Messages []json.RawMessage `json:"messages"` Stream *bool `json:"stream,omitempty"` Tools json.RawMessage `json:"tools,omitempty"` Options json.RawMessage `json:"options,omitempty"` Think json.RawMessage `json:"think,omitempty"` } // Result is the final outcome of a submitted job. type Result struct { JobID string `json:"job_id"` State string `json:"state"` Model string `json:"model"` Attempt int `json:"attempt"` Result json.RawMessage `json:"result,omitempty"` Error string `json:"error,omitempty"` Artifacts []Artifact `json:"artifacts,omitempty"` CreatedAt time.Time `json:"created_at"` CompletedAt *time.Time `json:"completed_at,omitempty"` } // Artifact is metadata and optional inline data for a job artifact. type Artifact struct { Name string `json:"name"` ContentType string `json:"content_type"` Size int64 `json:"size"` Data string `json:"data,omitempty"` URL string `json:"url,omitempty"` } // ModelInfo describes an installed model, as returned by GET /api/tags. type ModelInfo struct { Name string `json:"name"` Model string `json:"model"` ModifiedAt time.Time `json:"modified_at"` Size int64 `json:"size"` Digest string `json:"digest"` Details json.RawMessage `json:"details,omitempty"` } // EmbedRequest is the payload for the embedding endpoint. type EmbedRequest struct { Model string `json:"model"` Input json.RawMessage `json:"input"` KeepAlive json.RawMessage `json:"keep_alive,omitempty"` Options json.RawMessage `json:"options,omitempty"` } // EmbedResponse is the response from the embedding endpoint. type EmbedResponse struct { Model string `json:"model,omitempty"` Embeddings [][]float64 `json:"embeddings,omitempty"` } // jobSubmitRequest is the wire format for POST /jobs on the foreman server. type jobSubmitRequest struct { Model string `json:"model"` Messages []json.RawMessage `json:"messages"` Stream *bool `json:"stream,omitempty"` Tools json.RawMessage `json:"tools,omitempty"` Options json.RawMessage `json:"options,omitempty"` Think json.RawMessage `json:"think,omitempty"` StateWebhookURL string `json:"state_webhook_url,omitempty"` } // jobSubmitResponse is the wire format for the POST /jobs response. type jobSubmitResponse struct { JobID string `json:"job_id"` } // jobStatusResponse mirrors the GET /jobs/{id} response from the foreman server. type jobStatusResponse struct { JobID string `json:"job_id"` State string `json:"state"` Model string `json:"model"` CreatedAt time.Time `json:"created_at"` StartedAt *time.Time `json:"started_at"` CompletedAt *time.Time `json:"completed_at"` Attempt int `json:"attempt"` Result json.RawMessage `json:"result"` Error *string `json:"error"` Artifacts []Artifact `json:"artifacts"` } // tagsResponse mirrors the GET /api/tags response. type tagsResponse struct { Models []ModelInfo `json:"models"` } // Submit sends a chat job to foreman and blocks until it completes or the context // is cancelled. It attempts to use a local webhook receiver for push notification; // if that fails (or forcePolling is set), it falls back to polling GET /jobs/{id}. // // Why: orchestration callers want a synchronous call signature over the async API // without managing their own webhook infrastructure. // What: POSTs to /jobs, waits for terminal state via webhook or polling, returns // the final result or error. // Test: submit a job to a test server, verify the returned Result contains the // completion, artifacts, and correct state. func (c *Client) Submit(ctx context.Context, req SubmitRequest) (*Result, error) { if req.Model == "" { return nil, fmt.Errorf("model is required") } // Attempt webhook receiver mode first, fall back to polling. if !c.forcePolling { result, err := c.submitWithWebhook(ctx, req) if err == nil { return result, nil } // If the webhook listener failed to bind, fall back to polling. // Any other error (context cancelled, server error) is returned directly. if !isBindError(err) { return nil, err } } return c.submitWithPolling(ctx, req) } // submitWithPolling submits a job and polls GET /jobs/{id} until a terminal state. func (c *Client) submitWithPolling(ctx context.Context, req SubmitRequest) (*Result, error) { wireReq := jobSubmitRequest{ Model: req.Model, Messages: req.Messages, Stream: req.Stream, Tools: req.Tools, Options: req.Options, Think: req.Think, } jobID, err := c.postJob(ctx, wireReq) if err != nil { return nil, fmt.Errorf("submit job: %w", err) } return c.pollUntilDone(ctx, jobID) } // postJob POSTs a job to the foreman server and returns the job ID. func (c *Client) postJob(ctx context.Context, req jobSubmitRequest) (string, error) { body, err := json.Marshal(req) if err != nil { return "", fmt.Errorf("marshal job request: %w", err) } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/jobs", bytes.NewReader(body)) if err != nil { return "", fmt.Errorf("create POST /jobs request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") c.setAuth(httpReq) resp, err := c.httpClient.Do(httpReq) if err != nil { return "", fmt.Errorf("POST /jobs: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusAccepted { var errResp struct { Error string `json:"error"` } json.NewDecoder(resp.Body).Decode(&errResp) return "", fmt.Errorf("POST /jobs returned %d: %s", resp.StatusCode, errResp.Error) } var submitResp jobSubmitResponse if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil { return "", fmt.Errorf("decode POST /jobs response: %w", err) } return submitResp.JobID, nil } // pollUntilDone polls GET /jobs/{id} at pollInterval until the job reaches a // terminal state (done or failed) or the context is cancelled. func (c *Client) pollUntilDone(ctx context.Context, jobID string) (*Result, error) { ticker := time.NewTicker(c.pollInterval) defer ticker.Stop() for { status, err := c.getJobStatus(ctx, jobID) if err != nil { return nil, fmt.Errorf("poll job %s: %w", jobID, err) } if status.State == "done" || status.State == "failed" { return statusToResult(status), nil } select { case <-ctx.Done(): return nil, fmt.Errorf("context cancelled while polling job %s: %w", jobID, ctx.Err()) case <-ticker.C: } } } // getJobStatus fetches the current state of a job via GET /jobs/{id}. func (c *Client) getJobStatus(ctx context.Context, jobID string) (*jobStatusResponse, error) { httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/jobs/"+jobID, nil) if err != nil { return nil, fmt.Errorf("create GET /jobs/%s request: %w", jobID, err) } c.setAuth(httpReq) resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("GET /jobs/%s: %w", jobID, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { var errResp struct { Error string `json:"error"` } json.NewDecoder(resp.Body).Decode(&errResp) return nil, fmt.Errorf("GET /jobs/%s returned %d: %s", jobID, resp.StatusCode, errResp.Error) } var status jobStatusResponse if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { return nil, fmt.Errorf("decode GET /jobs/%s response: %w", jobID, err) } return &status, nil } // Tags returns the list of models installed on the foreman target. // // Why: callers need to discover available models before submitting work. // What: GETs /api/tags and returns parsed model info. // Test: call Tags on a test server with known models, verify the list matches. func (c *Client) Tags(ctx context.Context) ([]ModelInfo, error) { httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/tags", nil) if err != nil { return nil, fmt.Errorf("create GET /api/tags request: %w", err) } c.setAuth(httpReq) resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("GET /api/tags: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GET /api/tags returned %d", resp.StatusCode) } var tags tagsResponse if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil { return nil, fmt.Errorf("decode /api/tags response: %w", err) } return tags.Models, nil } // Embed sends an embedding request to the foreman target. Embeddings bypass the // queue and are proxied directly to the always-resident embedder (ADR-0013). // // Why: callers need embeddings without waiting behind chat jobs in the queue. // What: POSTs to /api/embed and returns the parsed response. // Test: call Embed on a test server with a stub embedder, verify embeddings returned. func (c *Client) Embed(ctx context.Context, req EmbedRequest) (*EmbedResponse, error) { body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("marshal embed request: %w", err) } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/embed", bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("create POST /api/embed request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") c.setAuth(httpReq) resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("POST /api/embed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("POST /api/embed returned %d", resp.StatusCode) } var embedResp EmbedResponse if err := json.NewDecoder(resp.Body).Decode(&embedResp); err != nil { return nil, fmt.Errorf("decode /api/embed response: %w", err) } return &embedResp, nil } // setAuth adds the bearer token to the request if configured. func (c *Client) setAuth(req *http.Request) { if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) } } // statusToResult converts a jobStatusResponse to a Result. func statusToResult(s *jobStatusResponse) *Result { r := &Result{ JobID: s.JobID, State: s.State, Model: s.Model, Attempt: s.Attempt, Result: s.Result, Artifacts: s.Artifacts, CreatedAt: s.CreatedAt, CompletedAt: s.CompletedAt, } if s.Error != nil { r.Error = *s.Error } if r.Artifacts == nil { r.Artifacts = []Artifact{} } return r }