feat: add Go client package with sync facade over async /jobs
Adds client/ -- a public Go package providing a synchronous facade over
foreman's async POST /jobs API (Level 1 integration per ADR-0011).
Two delivery modes:
- Webhook receiver (preferred): ephemeral HTTP server on random port,
pushes results immediately, verifies HMAC when configured
- Polling fallback: polls GET /jobs/{id} at configurable interval
Also includes Tags() and Embed() helpers, bearer auth support, and
comprehensive integration tests against the real foreman HTTP handlers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user