// Package secutools provides a minimal HTTP client for the centralized SecuAAS // AI-batch platform (https://api.secutools.secuaas.ovh). // // Phase 2 — Chantier E: the dispatcher delegates non-Claude-Code-eligible // tasks to secutools (GPU/Gemini/Claude API) instead of dispatching them to // a local ccl-auto tmux session. This package is the Go side of that // delegation: SubmitJob, GetJob, WaitForResult. // // The Client interface is intentionally narrow so tests can plug a fake // implementation without any network dependency. package secutools import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "time" ) // Client is the abstraction the rest of the daemon uses to talk to secutools. // Real callers use HTTPClient; tests substitute a mock. type Client interface { SubmitJob(ctx context.Context, req *JobRequest) (*JobResponse, error) GetJob(ctx context.Context, id string) (*JobStatus, error) WaitForResult(ctx context.Context, id string, timeout time.Duration) (*JobResult, error) } // JobType mirrors the secutools job-type enum. type JobType string const ( TypeAnalyze JobType = "ai:analyze" TypeBatch JobType = "ai:batch" TypeReport JobType = "ai:report" TypeCorrelate JobType = "ai:correlate" ) // Priority mirrors the secutools priority enum. type Priority string const ( PriorityCritical Priority = "critical" PriorityHigh Priority = "high" PriorityDefault Priority = "default" PriorityLow Priority = "low" ) // JobRequest is the body of POST /api/v1/jobs. type JobRequest struct { Type JobType `json:"type"` Priority Priority `json:"priority,omitempty"` Prompt string `json:"prompt"` Data map[string]any `json:"data,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` PreferredAI string `json:"preferred_ai,omitempty"` Source string `json:"source,omitempty"` } // JobResponse is the immediate reply from POST /api/v1/jobs. type JobResponse struct { JobID string `json:"job_id"` Status string `json:"status"` } // JobStatus is the reply from GET /api/v1/jobs/:id. type JobStatus struct { JobID string `json:"job_id"` Status string `json:"status"` // pending | running | completed | failed | cancelled Provider string `json:"provider,omitempty"` Error string `json:"error,omitempty"` } // JobResult is the reply from GET /api/v1/jobs/:id/result. type JobResult struct { JobID string `json:"job_id"` Response string `json:"response"` Provider string `json:"provider"` Model string `json:"model"` CostCAD float64 `json:"cost_cad"` Tokens int `json:"tokens,omitempty"` } // HTTPClient is the production implementation of Client. type HTTPClient struct { baseURL string apiKey string hc *http.Client maxRetries int baseDelay time.Duration } // NewHTTPClient returns an HTTPClient ready to talk to secutools. // If hc is nil, a default http.Client with a 30s timeout is used. // // The client performs up to 3 retries on transport errors and 5xx // responses, with exponential backoff starting at 500ms (500ms, 1s, 2s). // 4xx responses are returned as errors without retrying. func NewHTTPClient(baseURL, apiKey string, hc *http.Client) *HTTPClient { if hc == nil { hc = &http.Client{Timeout: 30 * time.Second} } return &HTTPClient{ baseURL: baseURL, apiKey: apiKey, hc: hc, maxRetries: 3, baseDelay: 500 * time.Millisecond, } } // SetRetryPolicy overrides the default retry policy. Useful for tests. func (c *HTTPClient) SetRetryPolicy(maxRetries int, baseDelay time.Duration) { c.maxRetries = maxRetries c.baseDelay = baseDelay } // doWithRetry sends req and retries on transport errors or 5xx responses // using exponential backoff. 4xx is returned without retry. Respects ctx. func (c *HTTPClient) doWithRetry(ctx context.Context, build func() (*http.Request, error)) (*http.Response, error) { var lastErr error delay := c.baseDelay for attempt := 0; attempt <= c.maxRetries; attempt++ { if attempt > 0 { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(delay): } delay *= 2 } req, err := build() if err != nil { return nil, err } resp, err := c.hc.Do(req) if err != nil { lastErr = err continue } // Retry 5xx; return success or 4xx immediately. if resp.StatusCode >= 500 && resp.StatusCode <= 599 { raw, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() lastErr = fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(raw)) continue } return resp, nil } if lastErr == nil { lastErr = errors.New("secutools: unknown transport failure") } return nil, fmt.Errorf("after %d attempts: %w", c.maxRetries+1, lastErr) } // SubmitJob POSTs req to /api/v1/jobs with retry on 5xx. func (c *HTTPClient) SubmitJob(ctx context.Context, req *JobRequest) (*JobResponse, error) { body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) } resp, err := c.doWithRetry(ctx, func() (*http.Request, error) { httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/jobs", bytes.NewReader(body)) if err != nil { return nil, err } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("X-API-Key", c.apiKey) return httpReq, nil }) if err != nil { return nil, fmt.Errorf("submit job: %w", err) } defer resp.Body.Close() if resp.StatusCode/100 != 2 { raw, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("submit job: HTTP %d: %s", resp.StatusCode, string(raw)) } var out JobResponse if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, fmt.Errorf("decode submit response: %w", err) } return &out, nil } // GetJob GETs /api/v1/jobs/:id with retry on 5xx. func (c *HTTPClient) GetJob(ctx context.Context, id string) (*JobStatus, error) { resp, err := c.doWithRetry(ctx, func() (*http.Request, error) { httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/jobs/"+id, nil) if err != nil { return nil, err } httpReq.Header.Set("X-API-Key", c.apiKey) return httpReq, nil }) if err != nil { return nil, fmt.Errorf("get job: %w", err) } defer resp.Body.Close() if resp.StatusCode/100 != 2 { raw, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("get job: HTTP %d: %s", resp.StatusCode, string(raw)) } var out JobStatus if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, fmt.Errorf("decode get response: %w", err) } return &out, nil } // getResult fetches the final payload of a completed job with retry on 5xx. func (c *HTTPClient) getResult(ctx context.Context, id string) (*JobResult, error) { resp, err := c.doWithRetry(ctx, func() (*http.Request, error) { httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v1/jobs/"+id+"/result", nil) if err != nil { return nil, err } httpReq.Header.Set("X-API-Key", c.apiKey) return httpReq, nil }) if err != nil { return nil, fmt.Errorf("get result: %w", err) } defer resp.Body.Close() if resp.StatusCode/100 != 2 { raw, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("get result: HTTP %d: %s", resp.StatusCode, string(raw)) } var out JobResult if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, fmt.Errorf("decode result: %w", err) } return &out, nil } // ErrJobFailed is returned by WaitForResult when secutools reports the job // as terminally failed (no result will ever be produced). var ErrJobFailed = errors.New("secutools: job failed") // ErrTimeout is returned by WaitForResult when the polling deadline elapses // before the job reaches a terminal state. var ErrTimeout = errors.New("secutools: wait timeout") // WaitForResult polls /api/v1/jobs/:id every 2s until the job reaches a // terminal state (completed/failed/cancelled) or timeout elapses. // On completed, fetches and returns the result. // // Polling cadence is intentionally fixed (not configurable) to keep the // reaper goroutine simple. If callers need a different cadence they can // implement it themselves on top of GetJob/getResult. func (c *HTTPClient) WaitForResult(ctx context.Context, id string, timeout time.Duration) (*JobResult, error) { deadline := time.Now().Add(timeout) ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for { st, err := c.GetJob(ctx, id) if err != nil { return nil, err } switch st.Status { case "completed": return c.getResult(ctx, id) case "failed", "cancelled": return nil, fmt.Errorf("%w: status=%s err=%s", ErrJobFailed, st.Status, st.Error) } if time.Now().After(deadline) { return nil, ErrTimeout } select { case <-ctx.Done(): return nil, ctx.Err() case <-ticker.C: } } }