1 Commits

Author SHA1 Message Date
etalon 33c9c39676 feat(atomize): serve Subdivide via Anypreta Issue Atomizer
Add an optional remote atomization backend (the Anypreta "Issue Atomizer"
API) for the Subdivide flow, selected by ATOMIZER_REMOTE_BASE_URL /
ATOMIZER_REMOTE_API_KEY. When set, POST /v1/atomize is served by the remote
/v1/atomize-issue endpoint; its two-level issue→features→tasks model is mapped
onto our one-level task→children model by treating each Feature as a child and
its `estimation` as the effort coefficient (renormalized to sum to 1, even
split as a reported fallback when estimations are absent).

Extend and Summarize have no counterpart in the remote API and always stay on
ATOMIZER_BASE_URL, so the §5.1 service must keep running; readiness now probes
both backends. The mandatory `system` object is derived from the customer,
with a generic component tree (an empty tree makes the service return zero
features).

extsvc also learns the remote's flat error envelope ({error_code,message} and
FastAPI {detail}) and never swallows an unrecognized 4xx body.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 17:49:41 +02:00
9 changed files with 613 additions and 10 deletions
+13
View File
@@ -0,0 +1,13 @@
# Container-owned data: unreadable by the build user, and stat'ing it fails the
# context walk outright.
.volumes/
backups/
.git/
.env
.env.*
!.env.example
# Local working backups.
.bak-*/
**/.bak-*/
+6
View File
@@ -24,6 +24,12 @@ ADMIN_INITIAL_PASSWORD=change-me-now
ATOMIZER_BASE_URL=http://atomizer-mock:8090 ATOMIZER_BASE_URL=http://atomizer-mock:8090
ATOMIZER_TOKEN=change-me-atomizer ATOMIZER_TOKEN=change-me-atomizer
ATOMIZER_TIMEOUT_SEC=120 ATOMIZER_TIMEOUT_SEC=120
# Optional: serve Subdivide from the Anypreta Issue Atomizer instead of the
# §5.1 service above. Both vars are required to enable it. Extend and Summarize
# have no counterpart in that API and always stay on ATOMIZER_BASE_URL, so the
# service above must keep running either way.
ATOMIZER_REMOTE_BASE_URL=
ATOMIZER_REMOTE_API_KEY=
WORK_PERFORMER_BASE_URL=http://work-performer:8091 WORK_PERFORMER_BASE_URL=http://work-performer:8091
WORK_PERFORMER_TOKEN=change-me-performer WORK_PERFORMER_TOKEN=change-me-performer
# Job submission/polling only; jobs themselves are async. # Job submission/polling only; jobs themselves are async.
+7
View File
@@ -98,6 +98,13 @@ func run() error {
} }
return cfg.AtomizerBaseURL return cfg.AtomizerBaseURL
}, cfg.AtomizerToken, cfg.AtomizerTimeout) }, cfg.AtomizerToken, cfg.AtomizerTimeout)
if cfg.AtomizerRemoteBaseURL != "" {
// Subdivide is served by the Anypreta Issue Atomizer; Extend and
// Summarize have no counterpart there and stay on ATOMIZER_BASE_URL.
atomClient.UseRemote(cfg.AtomizerRemoteBaseURL, cfg.AtomizerRemoteAPIKey, cfg.AtomizerTimeout)
log.Info("atomizer: subdivide served by remote backend",
"url", cfg.AtomizerRemoteBaseURL, "extend_summarize", cfg.AtomizerBaseURL)
}
srv.SetAtomizerInfo(atomClient) srv.SetAtomizerInfo(atomClient)
srv.AddReadinessCheck(httpx.ReadinessCheck{Name: "atomizer", Probe: atomClient.Healthy}) srv.AddReadinessCheck(httpx.ReadinessCheck{Name: "atomizer", Probe: atomClient.Healthy})
+287
View File
@@ -0,0 +1,287 @@
package atomize
import (
"context"
"fmt"
"math"
"net/http"
"strings"
"time"
"unicode/utf8"
"bountyboard/internal/extsvc"
)
// Backend for the Anypreta "Issue Atomizer" API (OpenAPI 3.1, v0.1.0). Its
// model is two-level — issue → features → tasks — where ours is one-level
// (task → children), and only *features* carry an effort weight
// (`estimation`); its tasks carry none at all. A board child therefore maps to
// a Feature and Subdivide calls POST /v1/atomize-issue.
//
// Extend and Summarize have no counterpart here (/v1/extend-feature-tasks adds
// tasks *inside* one feature and returns the whole set), so those stay on the
// §5.1 service. The API declares no security scheme; it authenticates with
// Authorization: Bearer <key>.
// Field caps from the spec — exceeded values are 422s, so trim rather than fail.
const (
maxIssueLen = 50000
maxGuidanceLen = 10000
maxSystemIDLen = 256
maxSystemDesc = 2000
)
type apComponent struct {
ID string `json:"id"`
Description string `json:"description"`
SubComponents []apComponent `json:"sub_components,omitempty"`
}
type apSystem struct {
ID string `json:"id"`
Description string `json:"description"`
Components []apComponent `json:"components,omitempty"`
}
type apAtomizeIssueRequest struct {
System apSystem `json:"system"`
Issue string `json:"issue"`
GuidanceNote string `json:"guidance_note,omitempty"`
}
type apCriterion struct {
Description string `json:"description"`
}
type apFeature struct {
ID string `json:"id"`
Title string `json:"title"`
StakeholderSummary string `json:"stakeholder_summary"`
Description string `json:"description"`
AcceptanceCriteria []apCriterion `json:"acceptance_criteria"`
Priority int `json:"priority"`
RelatedComponents []string `json:"related_components"`
DependsOn []string `json:"depends_on"`
// Estimation is optional and nullable in the spec; observed to sum to 1
// across a feature set, but that is not promised.
Estimation *float64 `json:"estimation"`
}
type apAtomizeIssueResponse struct {
FeatureSet struct {
Features []apFeature `json:"features"`
} `json:"feature_set"`
}
// remote is the Anypreta-backed Atomize path. It reuses extsvc for the bearer
// auth, retry and circuit breaker; the base URL carries the /v1 prefix so
// extsvc's /healthz probe lands on the API's /v1/healthz.
type remote struct {
c *extsvc.Client
}
func newRemote(baseURL, apiKey string, timeout time.Duration) *remote {
base := strings.TrimRight(baseURL, "/") + "/v1"
return &remote{c: extsvc.NewClient("atomizer-remote",
func() string { return base }, apiKey, timeout)}
}
// Atomize maps a subdivide request onto POST /v1/atomize-issue and maps the
// returned features back onto board children.
func (r *remote) Atomize(ctx context.Context, req AtomizeRequest) (*AtomizeResponse, error) {
body := apAtomizeIssueRequest{
System: systemFor(req),
Issue: truncate(issueText(req), maxIssueLen),
GuidanceNote: truncate(guidanceNote(req), maxGuidanceLen),
}
var resp apAtomizeIssueResponse
if err := r.c.Call(ctx, http.MethodPost, "/atomize-issue", body, &resp); err != nil {
return nil, err
}
features := resp.FeatureSet.Features
if len(features) == 0 {
return nil, fmt.Errorf("%w: empty feature set", ErrBadCoefficients)
}
coeffs, estimated, err := featureCoefficients(features)
if err != nil {
return nil, err
}
tasks := make([]SubTask, len(features))
for i, f := range features {
criteria := make([]string, 0, len(f.AcceptanceCriteria))
for _, c := range f.AcceptanceCriteria {
if d := strings.TrimSpace(c.Description); d != "" {
criteria = append(criteria, d)
}
}
tasks[i] = SubTask{
Title: strings.TrimSpace(f.Title),
Description: strings.TrimSpace(f.Description),
AcceptanceCriteria: criteria,
EffortCoefficient: coeffs[i],
}
}
out := &AtomizeResponse{Tasks: tasks, Model: "anypreta/issue-atomizer"}
if !estimated {
out.Notes = "The atomizer returned no effort estimation, so the budget was split evenly across the tasks."
}
return out, nil
}
// featureCoefficients turns feature estimations into effort coefficients
// summing to exactly 1. Estimations are optional in the spec, so an incomplete
// set falls back to an even split (reported via the second return value)
// rather than failing the job — bounties still need *some* weight.
func featureCoefficients(features []apFeature) ([]float64, bool, error) {
raw := make([]float64, len(features))
estimated := true
for i, f := range features {
if f.Estimation == nil || *f.Estimation <= 0 || math.IsNaN(*f.Estimation) {
estimated = false
break
}
raw[i] = *f.Estimation
}
if !estimated {
even := 1.0 / float64(len(features))
for i := range raw {
raw[i] = even
}
}
norm, err := normalizeToOne(raw)
if err != nil {
return nil, false, err
}
return norm, estimated, nil
}
// normalizeToOne rescales positive weights proportionally so they sum to
// exactly 1. Unlike NormalizeCoefficients (§5.1) it accepts any positive sum:
// this API never promises a normalized feature set, so a deviation is expected
// rather than a service fault.
func normalizeToOne(weights []float64) ([]float64, error) {
if len(weights) == 0 {
return nil, fmt.Errorf("%w: empty", ErrBadCoefficients)
}
sum := 0.0
for _, w := range weights {
if math.IsNaN(w) || math.IsInf(w, 0) || w <= 0 {
return nil, fmt.Errorf("%w: weight %v is not positive", ErrBadCoefficients, w)
}
sum += w
}
out := make([]float64, len(weights))
largest, total := 0, 0.0
for i, w := range weights {
out[i] = math.Round(w/sum*10000) / 10000
total += out[i]
if out[i] > out[largest] {
largest = i
}
}
out[largest] = math.Round((out[largest]+1-total)*10000) / 10000
return out, nil
}
// defaultComponents is the component tree we send for every customer.
//
// The spec marks `components` optional, but the service only emits features for
// components it considers *in scope*: an empty tree yields zero features and a
// 422 ("no in-scope components produced features"). The board has no component
// model of its own, so we send a generic tree covering the usual surfaces of a
// software change. Components irrelevant to a ticket simply produce nothing, so
// a broad tree costs nothing and a narrow one under-splits — a single catch-all
// component collapses every ticket into one feature.
var defaultComponents = []apComponent{
{ID: "frontend", Description: "User interface: pages, views, client-side behaviour and styling."},
{ID: "backend", Description: "Server-side application logic, APIs and background jobs."},
{ID: "data", Description: "Data storage: schema, migrations, queries and data integrity."},
{ID: "integrations", Description: "Integrations with external systems, third-party APIs and notifications."},
}
// systemFor derives the required system object from the customer, which is the
// only project-level context the board has.
func systemFor(req AtomizeRequest) apSystem {
id := strings.TrimSpace(req.CustomerID)
if id == "" {
id = "bountyboard"
}
name := strings.TrimSpace(req.CustomerName)
if name == "" {
name = "this project"
}
return apSystem{
ID: truncate(id, maxSystemIDLen),
Description: truncate(fmt.Sprintf("Software project %q. Work is delivered as small, independently deliverable tasks picked up by freelance developers.", name), maxSystemDesc),
Components: defaultComponents,
}
}
// issueText flattens the ticket into the single `issue` string the API takes —
// it has no fields for acceptance criteria, links or attachments.
func issueText(req AtomizeRequest) string {
var b strings.Builder
if t := strings.TrimSpace(req.Title); t != "" {
fmt.Fprintf(&b, "%s\n\n", t)
}
if d := strings.TrimSpace(req.Description); d != "" {
fmt.Fprintf(&b, "%s\n", d)
}
if len(req.AcceptanceCriteria) > 0 {
b.WriteString("\nAcceptance criteria:\n")
for _, c := range req.AcceptanceCriteria {
fmt.Fprintf(&b, "- %s\n", c)
}
}
if len(req.Links) > 0 {
b.WriteString("\nLinks:\n")
for _, l := range req.Links {
fmt.Fprintf(&b, "- %s\n", l)
}
}
if len(req.Attachments) > 0 {
b.WriteString("\nAttachments:\n")
for _, a := range req.Attachments {
fmt.Fprintf(&b, "- %s (%s): %s\n", a.Name, a.MimeType, a.URL)
}
}
s := strings.TrimSpace(b.String())
if s == "" {
s = "(no description provided)"
}
return s
}
// guidanceNote carries the consultant's note plus our task-count constraints,
// which the API has no dedicated field for.
func guidanceNote(req AtomizeRequest) string {
parts := make([]string, 0, 2)
if n := strings.TrimSpace(req.SubdivisionNote); n != "" {
parts = append(parts, n)
}
if c := req.Constraints; c != nil {
switch {
case c.MinTasks > 0 && c.MaxTasks > 0:
parts = append(parts, fmt.Sprintf("Produce between %d and %d features.", c.MinTasks, c.MaxTasks))
case c.MinTasks > 0:
parts = append(parts, fmt.Sprintf("Produce at least %d features.", c.MinTasks))
case c.MaxTasks > 0:
parts = append(parts, fmt.Sprintf("Produce at most %d features.", c.MaxTasks))
}
}
return strings.Join(parts, "\n\n")
}
// truncate caps a string at max bytes without splitting a rune. Byte length is
// a safe proxy for the spec's character limits: it is never smaller.
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
cut := max
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut]
}
+203
View File
@@ -0,0 +1,203 @@
package atomize
import (
"encoding/json"
"math"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func ptr(f float64) *float64 { return &f }
func sum(xs []float64) float64 {
t := 0.0
for _, x := range xs {
t += x
}
return t
}
func TestFeatureCoefficientsUsesEstimations(t *testing.T) {
// The shape the live API returns: estimations already summing to 1.
got, estimated, err := featureCoefficients([]apFeature{
{Estimation: ptr(0.3636363636)},
{Estimation: ptr(0.6363636364)},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !estimated {
t.Fatal("expected estimations to be used")
}
if math.Abs(sum(got)-1) > 1e-9 {
t.Fatalf("coefficients must sum to exactly 1, got %v (sum %v)", got, sum(got))
}
if math.Abs(got[0]-0.3636) > 1e-9 {
t.Fatalf("estimation not preserved: %v", got)
}
}
func TestFeatureCoefficientsRescalesUnnormalized(t *testing.T) {
// Nothing in the spec promises a normalized feature set.
got, estimated, err := featureCoefficients([]apFeature{
{Estimation: ptr(0.2)}, {Estimation: ptr(0.2)}, {Estimation: ptr(0.4)},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !estimated {
t.Fatal("expected estimations to be used")
}
if math.Abs(sum(got)-1) > 1e-9 {
t.Fatalf("want sum 1, got %v (sum %v)", got, sum(got))
}
if math.Abs(got[2]-0.5) > 1e-9 {
t.Fatalf("want proportional rescale (0.25/0.25/0.5), got %v", got)
}
}
func TestFeatureCoefficientsFallsBackToEvenSplit(t *testing.T) {
// estimation is optional and nullable — a missing one must not fail the
// job, and must be reported so the consultant knows the split is not real.
got, estimated, err := featureCoefficients([]apFeature{
{Estimation: ptr(0.5)}, {Estimation: nil}, {Estimation: ptr(0.2)},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if estimated {
t.Fatal("expected fallback to even split when an estimation is missing")
}
if math.Abs(sum(got)-1) > 1e-9 {
t.Fatalf("want sum 1, got %v (sum %v)", got, sum(got))
}
for _, c := range got {
if math.Abs(c-1.0/3.0) > 1e-3 {
t.Fatalf("want even split, got %v", got)
}
}
}
func TestNormalizeToOneRejectsNonPositive(t *testing.T) {
for _, bad := range [][]float64{{0.5, 0}, {0.5, -0.1}, {math.NaN()}, {}} {
if _, err := normalizeToOne(bad); err == nil {
t.Fatalf("want error for %v", bad)
}
}
}
func TestIssueTextCarriesFieldsTheAPILacks(t *testing.T) {
// The API takes a single `issue` string: criteria, links and attachments
// have no fields of their own and must survive in the text.
got := issueText(AtomizeRequest{
Title: "Add CSV export",
Description: "Customers cannot export.",
AcceptanceCriteria: []string{"Downloads a .csv"},
Links: []string{"https://tracker/1"},
Attachments: []Attachment{{Name: "mock.png", MimeType: "image/png", URL: "https://app/f/1"}},
})
for _, want := range []string{"Add CSV export", "Customers cannot export.", "Downloads a .csv", "https://tracker/1", "mock.png", "https://app/f/1"} {
if !strings.Contains(got, want) {
t.Errorf("issue text lost %q:\n%s", want, got)
}
}
}
func TestGuidanceNoteFoldsInConstraints(t *testing.T) {
got := guidanceNote(AtomizeRequest{
SubdivisionNote: "Keep it small.",
Constraints: &Constraints{MinTasks: 2, MaxTasks: 5},
})
if !strings.Contains(got, "Keep it small.") || !strings.Contains(got, "between 2 and 5") {
t.Fatalf("want note + constraints, got %q", got)
}
}
func TestTruncateDoesNotSplitRunes(t *testing.T) {
if got := truncate("héllo", 2); got != "h" {
t.Fatalf("want %q, got %q", "h", got)
}
}
// Exercises the full remote path against a stub speaking the Anypreta wire
// format, asserting both the request we send and the children we build.
func TestRemoteAtomizeMapsFeaturesToChildren(t *testing.T) {
var got apAtomizeIssueRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/atomize-issue" {
t.Errorf("wrong path: %s", r.URL.Path)
}
if auth := r.Header.Get("Authorization"); auth != "Bearer k" {
t.Errorf("wrong auth header: %q", auth)
}
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("decode: %v", err)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"feature_set":{"features":[
{"id":"f1","title":"UI","stakeholder_summary":"s","description":"d1",
"acceptance_criteria":[{"description":"c1"},{"description":"c2"}],
"priority":1,"related_components":["web"],"estimation":0.25},
{"id":"f2","title":"API","stakeholder_summary":"s","description":"d2",
"acceptance_criteria":[{"description":"c3"}],
"priority":2,"related_components":["api"],"estimation":0.75}]}}`))
}))
defer srv.Close()
resp, err := newRemote(srv.URL, "k", 5*time.Second).Atomize(t.Context(), AtomizeRequest{
TaskID: "t1", Title: "Export", Description: "desc",
CustomerID: "cust1", CustomerName: "Acme",
})
if err != nil {
t.Fatalf("Atomize: %v", err)
}
if got.System.ID != "cust1" || !strings.Contains(got.System.Description, "Acme") {
t.Errorf("system not derived from customer: %+v", got.System)
}
// An empty component tree makes the live service return zero features
// (422), so never let the tree go missing.
if len(got.System.Components) == 0 {
t.Error("system.components must not be empty: the service only emits features for in-scope components")
}
if len(resp.Tasks) != 2 {
t.Fatalf("want 2 children, got %d", len(resp.Tasks))
}
if resp.Tasks[0].Title != "UI" || resp.Tasks[0].Description != "d1" {
t.Errorf("bad child mapping: %+v", resp.Tasks[0])
}
if len(resp.Tasks[0].AcceptanceCriteria) != 2 || resp.Tasks[0].AcceptanceCriteria[0] != "c1" {
t.Errorf("acceptance criteria not mapped: %+v", resp.Tasks[0].AcceptanceCriteria)
}
if resp.Tasks[0].EffortCoefficient != 0.25 || resp.Tasks[1].EffortCoefficient != 0.75 {
t.Errorf("estimation not mapped to effort coefficient: %v / %v",
resp.Tasks[0].EffortCoefficient, resp.Tasks[1].EffortCoefficient)
}
if resp.Notes != "" {
t.Errorf("no even-split note expected when estimations are present: %q", resp.Notes)
}
}
// A remote-backed client must still serve Extend and Summarize from the §5.1
// service — the Anypreta API has no counterpart for either.
func TestRemoteDoesNotHijackExtend(t *testing.T) {
var hit string
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hit = r.URL.Path
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"task":{"title":"t","description":"d","acceptanceCriteria":[],"effortCoefficient":0.5}}`))
}))
defer mock.Close()
c := New(func() string { return mock.URL }, "tok", 5*time.Second)
c.UseRemote("http://remote.invalid", "k", 5*time.Second)
if _, err := c.Extend(t.Context(), ExtendRequest{TaskID: "t1", ExtensionNote: "more"}); err != nil {
t.Fatalf("Extend: %v", err)
}
if hit != "/v1/extend" {
t.Fatalf("Extend must go to the §5.1 service, hit %q", hit)
}
}
+45 -3
View File
@@ -33,6 +33,11 @@ type AtomizeRequest struct {
Links []string `json:"links"` Links []string `json:"links"`
SubdivisionNote string `json:"subdivisionNote,omitempty"` SubdivisionNote string `json:"subdivisionNote,omitempty"`
Constraints *Constraints `json:"constraints,omitempty"` Constraints *Constraints `json:"constraints,omitempty"`
// CustomerID / CustomerName describe the project the ticket belongs to.
// The §5.1 service ignores them; the Anypreta backend requires them to
// build its mandatory `system` object.
CustomerID string `json:"customerId,omitempty"`
CustomerName string `json:"customerName,omitempty"`
} }
type ExtendRequest struct { type ExtendRequest struct {
@@ -70,6 +75,10 @@ var ErrBadCoefficients = errors.New("atomizer returned invalid effort coefficien
type Client struct { type Client struct {
c *extsvc.Client c *extsvc.Client
// remote, when set, serves Atomize from the Anypreta Issue Atomizer
// instead of the §5.1 service. Extend and Summarize have no counterpart
// there and always go to c. See anypreta.go.
remote *remote
} }
// New builds the client. baseURL is resolved per call so the admin settings // New builds the client. baseURL is resolved per call so the admin settings
@@ -78,13 +87,46 @@ func New(baseURL func() string, token string, timeout time.Duration) *Client {
return &Client{c: extsvc.NewClient("atomizer", baseURL, token, timeout)} return &Client{c: extsvc.NewClient("atomizer", baseURL, token, timeout)}
} }
func (a *Client) BreakerState() string { return a.c.BreakerState() } // UseRemote routes Atomize to the Anypreta Issue Atomizer at baseURL.
func (a *Client) Healthy(ctx context.Context) error { return a.c.Healthy(ctx) } func (a *Client) UseRemote(baseURL, apiKey string, timeout time.Duration) {
a.remote = newRemote(baseURL, apiKey, timeout)
}
// RemoteEnabled reports whether Subdivide is served by the Anypreta backend.
func (a *Client) RemoteEnabled() bool { return a.remote != nil }
func (a *Client) BreakerState() string {
if a.remote != nil {
if s := a.remote.c.BreakerState(); s != "closed" {
return s
}
}
return a.c.BreakerState()
}
// Healthy probes every backend an atomization can touch, so readiness and the
// admin service panel go degraded if either half is down.
func (a *Client) Healthy(ctx context.Context) error {
if err := a.c.Healthy(ctx); err != nil {
return err
}
if a.remote != nil {
if err := a.remote.c.Healthy(ctx); err != nil {
return fmt.Errorf("anypreta atomizer: %w", err)
}
}
return nil
}
// Atomize calls POST /v1/atomize and applies the §5.1 contract: coefficients // Atomize calls POST /v1/atomize and applies the §5.1 contract: coefficients
// must sum to 1 ± 0.001; a deviation ≤ 0.05 is defensively renormalized, // must sum to 1 ± 0.001; a deviation ≤ 0.05 is defensively renormalized,
// anything worse is rejected as a service error. // anything worse is rejected as a service error. With a remote backend
// configured the call is served by the Anypreta API instead, which normalizes
// its own feature estimations.
func (a *Client) Atomize(ctx context.Context, req AtomizeRequest) (*AtomizeResponse, error) { func (a *Client) Atomize(ctx context.Context, req AtomizeRequest) (*AtomizeResponse, error) {
if a.remote != nil {
return a.remote.Atomize(ctx, req)
}
var resp AtomizeResponse var resp AtomizeResponse
if err := a.c.Call(ctx, http.MethodPost, "/v1/atomize", req, &resp); err != nil { if err := a.c.Call(ctx, http.MethodPost, "/v1/atomize", req, &resp); err != nil {
return nil, err return nil, err
+9
View File
@@ -99,6 +99,15 @@ func (s *Service) HandleSubdivide(ctx context.Context, job *jobs.Job) error {
Attachments: s.requestAttachments(t), Attachments: s.requestAttachments(t),
Links: taskLinks(t), Links: taskLinks(t),
SubdivisionNote: p.Note, SubdivisionNote: p.Note,
CustomerID: t.CustomerID,
}
// Project context for backends that need it (the Anypreta `system`
// object). Not worth failing an atomization over.
if cust, err := s.st.CustomerByID(ctx, t.CustomerID); err == nil {
req.CustomerName = cust.Name
} else {
s.log.Warn("subdivide: customer lookup failed, sending id only",
"task", t.ID, "customer", t.CustomerID, "err", err)
} }
if p.MinTasks > 0 || p.MaxTasks > 0 { if p.MinTasks > 0 || p.MaxTasks > 0 {
req.Constraints = &Constraints{MinTasks: p.MinTasks, MaxTasks: p.MaxTasks} req.Constraints = &Constraints{MinTasks: p.MinTasks, MaxTasks: p.MaxTasks}
+15
View File
@@ -57,6 +57,11 @@ type Config struct {
AtomizerBaseURL string AtomizerBaseURL string
AtomizerToken string AtomizerToken string
AtomizerTimeout time.Duration AtomizerTimeout time.Duration
// AtomizerRemoteBaseURL / AtomizerRemoteAPIKey enable the Anypreta Issue
// Atomizer as the Subdivide backend. Both must be set; Extend and
// Summarize stay on ATOMIZER_BASE_URL either way.
AtomizerRemoteBaseURL string
AtomizerRemoteAPIKey string
WorkPerformerBaseURL string WorkPerformerBaseURL string
WorkPerformerToken string WorkPerformerToken string
WorkPerformerHTTPTimeout time.Duration WorkPerformerHTTPTimeout time.Duration
@@ -117,6 +122,8 @@ func Load() (*Config, error) {
AtomizerBaseURL: strings.TrimRight(getenv("ATOMIZER_BASE_URL", "http://atomizer-mock:8090"), "/"), AtomizerBaseURL: strings.TrimRight(getenv("ATOMIZER_BASE_URL", "http://atomizer-mock:8090"), "/"),
AtomizerToken: os.Getenv("ATOMIZER_TOKEN"), AtomizerToken: os.Getenv("ATOMIZER_TOKEN"),
AtomizerRemoteBaseURL: strings.TrimRight(os.Getenv("ATOMIZER_REMOTE_BASE_URL"), "/"),
AtomizerRemoteAPIKey: os.Getenv("ATOMIZER_REMOTE_API_KEY"),
WorkPerformerBaseURL: strings.TrimRight(getenv("WORK_PERFORMER_BASE_URL", "http://work-performer:8091"), "/"), WorkPerformerBaseURL: strings.TrimRight(getenv("WORK_PERFORMER_BASE_URL", "http://work-performer:8091"), "/"),
WorkPerformerToken: os.Getenv("WORK_PERFORMER_TOKEN"), WorkPerformerToken: os.Getenv("WORK_PERFORMER_TOKEN"),
@@ -155,6 +162,14 @@ func Load() (*Config, error) {
fail("%s: %q is not an absolute URL", name, v) fail("%s: %q is not an absolute URL", name, v)
} }
} }
if c.AtomizerRemoteBaseURL != "" {
if u, err := url.Parse(c.AtomizerRemoteBaseURL); err != nil || u.Scheme == "" || u.Host == "" {
fail("ATOMIZER_REMOTE_BASE_URL: %q is not an absolute URL", c.AtomizerRemoteBaseURL)
}
if c.AtomizerRemoteAPIKey == "" {
fail("ATOMIZER_REMOTE_API_KEY: required when ATOMIZER_REMOTE_BASE_URL is set")
}
}
if len(c.SessionSecret) < 16 { if len(c.SessionSecret) < 16 {
fail("SESSION_SECRET: must be at least 16 characters of random data") fail("SESSION_SECRET: must be at least 16 characters of random data")
} }
+21
View File
@@ -121,13 +121,34 @@ func (c *Client) callOnce(ctx context.Context, method, path string, in, out any)
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
se := &ServiceError{Status: resp.StatusCode} se := &ServiceError{Status: resp.StatusCode}
var env struct { var env struct {
// §5 envelope: {"error":{"code":…,"message":…}}.
Error struct { Error struct {
Code string `json:"code"` Code string `json:"code"`
Message string `json:"message"` Message string `json:"message"`
} `json:"error"` } `json:"error"`
// Flat envelope, used by the Anypreta atomizer:
// {"error_code":…,"message":…}. FastAPI's own validation
// errors carry {"detail":…} instead.
ErrorCode string `json:"error_code"`
Message string `json:"message"`
Detail json.RawMessage `json:"detail"`
} }
if json.Unmarshal(data, &env) == nil { if json.Unmarshal(data, &env) == nil {
se.Code, se.Message = env.Error.Code, env.Error.Message se.Code, se.Message = env.Error.Code, env.Error.Message
if se.Code == "" {
se.Code = env.ErrorCode
}
if se.Message == "" {
se.Message = env.Message
}
if se.Message == "" && len(env.Detail) > 0 {
se.Message = string(truncate(env.Detail, 200))
}
}
if se.Message == "" {
// Never swallow the failure: an unrecognized envelope still
// has to reach the logs and the consultant's error toast.
se.Message = string(truncate(data, 200))
} }
return se // 4xx is not retryable return se // 4xx is not retryable
} }