From a989b34045aaa0c98f63cb53be54da9f14f96989 Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Wed, 8 Apr 2026 16:47:50 -0400 Subject: [PATCH 1/2] fix(sdk): use async /v1/graphs/supermodel endpoint with polling AnalyzeZip was posting to the legacy /v1/supermodel endpoint which returned a sync JSON response. All other code (internal client, scripts/check-architecture) uses the current /v1/graphs/supermodel endpoint that returns an async job envelope. This change: - Switches to /v1/graphs/supermodel - Adds jobResponse/jobResult types to decode the async envelope - Adds a polling loop that waits RetryAfter seconds between polls (defaulting to 5s) until the job reaches a terminal state - Extracts the Graph from result.graph on completion Co-Authored-By: Claude Sonnet 4.6 --- pkg/supermodel/client.go | 66 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/pkg/supermodel/client.go b/pkg/supermodel/client.go index a8f281b..e384bcb 100644 --- a/pkg/supermodel/client.go +++ b/pkg/supermodel/client.go @@ -170,9 +170,63 @@ func (c *Client) Analyze(ctx context.Context, repoPath string) (*Graph, error) { return c.AnalyzeZip(ctx, zipPath, "sdk-"+hash[:16]) } -// AnalyzeZip uploads a pre-built ZIP to the Supermodel API. +const analyzeEndpoint = "/v1/graphs/supermodel" + +// jobResponse is the async envelope returned by the API. +type jobResponse struct { + Status string `json:"status"` + JobID string `json:"jobId"` + RetryAfter int `json:"retryAfter"` + Error *string `json:"error"` + Result json.RawMessage `json:"result"` +} + +// jobResult is the inner result object wrapping the graph. +type jobResult struct { + Graph Graph `json:"graph"` +} + +// AnalyzeZip uploads a pre-built ZIP to the Supermodel API and polls until +// the async job completes, returning the resulting Graph. // idempotencyKey must be unique per logical request. func (c *Client) AnalyzeZip(ctx context.Context, zipPath, idempotencyKey string) (*Graph, error) { + post := func() (*jobResponse, error) { return c.postZip(ctx, zipPath, idempotencyKey) } + + job, err := post() + if err != nil { + return nil, err + } + for job.Status == "pending" || job.Status == "processing" { + wait := time.Duration(job.RetryAfter) * time.Second + if wait <= 0 { + wait = 5 * time.Second + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(wait): + } + job, err = post() + if err != nil { + return nil, err + } + } + if job.Error != nil { + return nil, fmt.Errorf("analysis failed: %s", *job.Error) + } + if job.Status != "completed" { + return nil, fmt.Errorf("unexpected job status: %s", job.Status) + } + + var result jobResult + if err := json.Unmarshal(job.Result, &result); err != nil { + return nil, fmt.Errorf("decode graph result: %w", err) + } + return &result.Graph, nil +} + +// postZip sends the repository ZIP to the analyze endpoint and returns the raw job response. +func (c *Client) postZip(ctx context.Context, zipPath, idempotencyKey string) (*jobResponse, error) { f, err := os.Open(zipPath) if err != nil { return nil, err @@ -190,7 +244,7 @@ func (c *Client) AnalyzeZip(ctx context.Context, zipPath, idempotencyKey string) } mw.Close() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/supermodel", &buf) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+analyzeEndpoint, &buf) if err != nil { return nil, err } @@ -217,11 +271,11 @@ func (c *Client) AnalyzeZip(ctx context.Context, zipPath, idempotencyKey string) return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } - var g Graph - if err := json.Unmarshal(body, &g); err != nil { - return nil, fmt.Errorf("decode response: %w", err) + var job jobResponse + if err := json.Unmarshal(body, &job); err != nil { + return nil, fmt.Errorf("decode job response: %w", err) } - return &g, nil + return &job, nil } // --- Archive helpers --------------------------------------------------------- From 0847b176a762d9e5d7a355bf6874e7c73bcb19a0 Mon Sep 17 00:00:00 2001 From: Grey Newell Date: Wed, 8 Apr 2026 16:56:00 -0400 Subject: [PATCH 2/2] fix(sdk): populate repoId metadata from API response The /v1/graphs/supermodel result includes a top-level "repo" field alongside "graph". jobResult now captures it, and AnalyzeZip sets graph.Metadata["repoId"] so Graph.RepoID() works correctly. Without this, Graph.RepoID() always returned "" for SDK callers. Co-Authored-By: Claude Sonnet 4.6 --- pkg/supermodel/client.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/supermodel/client.go b/pkg/supermodel/client.go index e384bcb..6cdf15e 100644 --- a/pkg/supermodel/client.go +++ b/pkg/supermodel/client.go @@ -182,8 +182,10 @@ type jobResponse struct { } // jobResult is the inner result object wrapping the graph. +// The API response has shape: {"graph": {...}, "repo": "...", "domains": [...]} type jobResult struct { - Graph Graph `json:"graph"` + Graph Graph `json:"graph"` + Repo string `json:"repo"` } // AnalyzeZip uploads a pre-built ZIP to the Supermodel API and polls until @@ -222,7 +224,11 @@ func (c *Client) AnalyzeZip(ctx context.Context, zipPath, idempotencyKey string) if err := json.Unmarshal(job.Result, &result); err != nil { return nil, fmt.Errorf("decode graph result: %w", err) } - return &result.Graph, nil + g := &result.Graph + if result.Repo != "" { + g.Metadata = map[string]any{"repoId": result.Repo} + } + return g, nil } // postZip sends the repository ZIP to the analyze endpoint and returns the raw job response.