From 8ee316e8e1091b6fc82a238e42ae73e8074d874b Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 9 Oct 2026 21:17:37 +0200 Subject: [PATCH 1/5] Generate cloud dispatches on the entire-api cell entire dispatch posted to the BFF's one-shot POST /api/v1/dispatches/generate, with ?jurisdiction= telling the BFF which cell to read from. It now dials that cell itself: one CellClientFactory per run, aimed at the --jurisdiction cell or the caller's home cell. The cell's route is asynchronous. POST /me/dispatches answers 202 with a run still generating, so the client polls GET /me/dispatches/{id} every two seconds until the run is complete or failed, bounded by a three-minute budget that outlasts the cell's own two-minute stale threshold. A failed run surfaces its error message. The wire types follow the cell's camelCase shape. Because the client dials the requested cell directly, the jurisdiction echo check is gone, and so is the dispatch-local HTTPS guard: the cell factory already refuses a plain-HTTP data host before sending the login JWT. The cell client is built after the local window and origin checks, since building it already dials. The dispatch wizard reads the home jurisdiction from the same factory through a new CellClientFactory.HomeJurisdiction. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M4H1JY883E64PZ7HHDAN39SJ --- cmd/entire/cli/auth/cell_data_api.go | 6 + cmd/entire/cli/dispatch/cloud.go | 250 ++++------- cmd/entire/cli/dispatch/cloud_test.go | 419 ++++++------------ cmd/entire/cli/dispatch/consts_test.go | 2 +- cmd/entire/cli/dispatch/dispatch.go | 2 +- cmd/entire/cli/dispatch/dispatch_test.go | 11 +- cmd/entire/cli/dispatch/mode_cloud.go | 97 ++-- cmd/entire/cli/dispatch/mode_cloud_test.go | 380 ++++++---------- cmd/entire/cli/dispatch/mode_local.go | 5 - cmd/entire/cli/dispatch/repo_slug.go | 4 +- cmd/entire/cli/dispatch_wizard.go | 2 +- cmd/entire/cli/dispatch_wizard_scope.go | 12 +- docs/architecture/upstream-host-resolution.md | 5 +- docs/development/api-routing.md | 11 +- 14 files changed, 439 insertions(+), 767 deletions(-) diff --git a/cmd/entire/cli/auth/cell_data_api.go b/cmd/entire/cli/auth/cell_data_api.go index 15df5f076d..ce7ec51cc1 100644 --- a/cmd/entire/cli/auth/cell_data_api.go +++ b/cmd/entire/cli/auth/cell_data_api.go @@ -134,6 +134,12 @@ func (f *CellClientFactory) ClientFor(ctx context.Context, target *CellTarget) ( return api.NewClientWithBaseURL(f.subject.loginJWT, cellBaseURL), nil } +// HomeJurisdiction is the home_jurisdiction claim of the login this factory +// dials with — the cell a nil target routes to. "" when the claim is absent. +func (f *CellClientFactory) HomeJurisdiction() (string, error) { + return HomeJurisdictionFromLoginJWT(f.subject.loginJWT) +} + // cellBaseURLFor resolves the cell origin ClientFor dials for target, checked // safe to send the login JWT to. func (f *CellClientFactory) cellBaseURLFor(ctx context.Context, target *CellTarget) (string, error) { diff --git a/cmd/entire/cli/dispatch/cloud.go b/cmd/entire/cli/dispatch/cloud.go index f2573010ec..4e16df66b0 100644 --- a/cmd/entire/cli/dispatch/cloud.go +++ b/cmd/entire/cli/dispatch/cloud.go @@ -1,12 +1,9 @@ package dispatch import ( - "bytes" "context" - "encoding/json" "errors" "fmt" - "io" "net/http" "net/url" "strconv" @@ -15,94 +12,66 @@ import ( "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/logging" - "github.com/entireio/cli/cmd/entire/cli/versioninfo" ) -type CloudConfig struct { - BaseURL string - Token string - HTTP *http.Client - Timeout time.Duration -} - +// CloudClient generates dispatches on an entire-api cell: the caller's home +// cell, or the cell of the jurisdiction runServer was asked for. type CloudClient struct { - baseURL string - token string - http *http.Client + api *api.Client + pollInterval time.Duration } -const defaultCloudHTTPTimeout = 120 * time.Second - -// The gateway's one-shot dispatch route (generated in-request, nothing -// persisted). `?jurisdiction=` is the gateway-only selector for which -// jurisdiction's cell generates it — see Options.Jurisdiction. +// The cell generates asynchronously: POST /me/dispatches answers 202 with a +// run still "generating", and GET /me/dispatches/{id} reports it until the +// run completes or fails. const ( - dispatchGeneratePath = "/api/v1/dispatches/generate" - jurisdictionQueryParam = "jurisdiction" -) + dispatchesPath = "/api/v1/me/dispatches" -func NewCloudClient(cfg CloudConfig) *CloudClient { - baseURL := cfg.BaseURL + dispatchStatusGenerating = "generating" + dispatchStatusComplete = "complete" + dispatchStatusFailed = "failed" - httpClient := cfg.HTTP - if httpClient == nil { - timeout := cfg.Timeout - if timeout <= 0 { - timeout = defaultCloudHTTPTimeout - } - httpClient = &http.Client{Timeout: timeout} - } else if cfg.Timeout > 0 && httpClient.Timeout == 0 { - httpClient.Timeout = cfg.Timeout - } + defaultDispatchPollInterval = 2 * time.Second + // dispatchPollBudget outlasts the cell's two-minute stale threshold, after + // which it reports a stuck run as failed, so the budget only fires when + // the cell stops answering with a terminal status at all. + dispatchPollBudget = 3 * time.Minute +) - return &CloudClient{ - baseURL: strings.TrimRight(baseURL, "/"), - token: cfg.Token, - http: httpClient, - } +func NewCloudClient(client *api.Client) *CloudClient { + return &CloudClient{api: client, pollInterval: defaultDispatchPollInterval} } +// CreateDispatchRequest is the body of POST /me/dispatches. Repos must be +// forge-qualified (gh/owner/repo or et/project/repo). type CreateDispatchRequest struct { - Repos []string `json:"repos,omitempty"` - Since string `json:"since"` - Until string `json:"until"` - Generate bool `json:"generate"` - Voice string `json:"voice,omitempty"` -} - -type CreateDispatchResponse struct { - // Jurisdiction is the slug the gateway stamps onto the response: the - // jurisdiction whose cell the dispatch was generated from. Empty from a - // gateway that predates the selector — see runServer for how a sent - // selector is checked against it. - Jurisdiction string `json:"jurisdiction,omitempty"` - Window APIWindow `json:"window"` - Title string `json:"title,omitempty"` - CoveredRepos []string `json:"covered_repos,omitempty"` - Branches APIBranches `json:"branches,omitempty"` - Voice *string `json:"voice"` - Repos []APIRepo `json:"repos,omitempty"` - Totals APITotals `json:"totals"` - Warnings APIWarnings `json:"warnings"` - GeneratedText string `json:"generated_text,omitempty"` - GeneratedMarkdown string `json:"generated_markdown,omitempty"` + Repos []string `json:"repos,omitempty"` + Since string `json:"since"` + Until string `json:"until"` + Voice string `json:"voice,omitempty"` } -type APIBranches struct { - Values []string - All bool +// APIRun is the cell's dispatch shape, served by both the create and the +// read route; only the fields the CLI renders are decoded. +type APIRun struct { + ID string `json:"id"` + Status string `json:"status"` + CoveredRepos []string `json:"coveredRepos"` + Window APIWindow `json:"window"` + Repos []APIRepo `json:"repos"` + GeneratedMarkdown string `json:"generatedMarkdown"` + ErrorMessage *string `json:"errorMessage"` } type APIWindow struct { - NormalizedSince string `json:"normalized_since"` - NormalizedUntil string `json:"normalized_until"` - FirstCheckpointCreatedAt string `json:"first_checkpoint_created_at,omitempty"` - LastCheckpointCreatedAt string `json:"last_checkpoint_created_at,omitempty"` + NormalizedSince string `json:"normalizedSince"` + NormalizedUntil string `json:"normalizedUntil"` + FirstCheckpointCreatedAt string `json:"firstCheckpointCreatedAt"` + LastCheckpointCreatedAt string `json:"lastCheckpointCreatedAt"` } type APIRepo struct { - FullName string `json:"full_name"` - URL string `json:"url,omitempty"` + FullName string `json:"fullName"` Sections []APISection `json:"sections"` } @@ -112,59 +81,20 @@ type APISection struct { } type APIBullet struct { - CheckpointID string `json:"checkpoint_id"` + CheckpointID string `json:"checkpointId"` Text string `json:"text"` Source string `json:"source"` Branch string `json:"branch"` - CreatedAt string `json:"created_at"` + CreatedAt string `json:"createdAt"` Labels []string `json:"labels"` } -type APITotals struct { - Checkpoints int `json:"checkpoints"` - UsedCheckpointCount int `json:"used_checkpoint_count"` - Branches int `json:"branches"` - FilesTouched int `json:"files_touched"` -} - -type APIWarnings struct { - AccessDeniedCount int `json:"access_denied_count"` - PendingCount int `json:"pending_count"` - FailedCount int `json:"failed_count"` - UnknownCount int `json:"unknown_count"` - UncategorizedCount int `json:"uncategorized_count"` - TruncatedCount int `json:"truncated_count"` -} - -func (b *APIBranches) UnmarshalJSON(data []byte) error { - if bytes.Equal(data, []byte("null")) { - *b = APIBranches{} - return nil - } - - var values []string - if err := json.Unmarshal(data, &values); err == nil { - *b = APIBranches{Values: values} - return nil - } - - var sentinel string - if err := json.Unmarshal(data, &sentinel); err != nil { - return fmt.Errorf("decode branches: %w", err) - } - if sentinel != "all" { - return fmt.Errorf("decode branches: unexpected sentinel %q", sentinel) - } - *b = APIBranches{All: true} - return nil -} - -// RepoNotFoundError is the gateway's 404 for a repo that is not placed in (or +// RepoNotFoundError is the cell's 404 for a repo that is not placed in (or // not visible in) the jurisdiction the request was routed to — a repo mirrored // only in US, requested from an AU home, is simply unknown to the AU cell. // Jurisdiction is the selector the caller sent ("" = home) and Home the // caller's home jurisdiction when known (so callers know which cell answered -// when no selector was sent); Repos are the requested slugs the gateway's +// when no selector was sent); Repos are the requested slugs the cell's // message named; Message is its sentence; Cause is the underlying // *api.HTTPError. type RepoNotFoundError struct { @@ -192,7 +122,7 @@ func (e *RepoNotFoundError) Error() string { scope = strings.ToUpper(j) } // Render our own sentence when the repos were parsed, so the wording does - // not depend on (or double up with) the gateway's prose; fall back to its + // not depend on (or double up with) the cell's prose; fall back to its // message only when parsing found nothing. sentence := e.Message if len(e.Repos) > 0 { @@ -219,16 +149,12 @@ func (e *statusError) Error() string { func (e *statusError) Unwrap() error { return e.HTTPError } -// CreateDispatch generates a one-off dispatch from the cell of `jurisdiction` -// ("" = the caller's home jurisdiction). -func (c *CloudClient) CreateDispatch(ctx context.Context, reqBody CreateDispatchRequest, jurisdiction string) (*CreateDispatchResponse, error) { - path := dispatchGeneratePath - if j := strings.TrimSpace(jurisdiction); j != "" { - path += "?" + jurisdictionQueryParam + "=" + url.QueryEscape(j) - } - - var out CreateDispatchResponse - if err := c.doJSON(ctx, http.MethodPost, path, reqBody, &out); err != nil { +// CreateDispatch starts a dispatch on the client's cell and waits for it to +// finish. jurisdiction is the --jurisdiction selector the cell was picked by +// ("" = home); it only labels a repo-not-found error. +func (c *CloudClient) CreateDispatch(ctx context.Context, reqBody CreateDispatchRequest, jurisdiction string) (*APIRun, error) { + var run APIRun + if err := c.doJSON(ctx, http.MethodPost, dispatchesPath, reqBody, &run); err != nil { var httpErr *api.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound && strings.HasPrefix(strings.ToLower(httpErr.Message), repoNotFoundPrefix) { @@ -241,13 +167,48 @@ func (c *CloudClient) CreateDispatch(ctx context.Context, reqBody CreateDispatch } return nil, err } - return &out, nil + return c.waitForDispatch(ctx, &run) +} + +// waitForDispatch polls a generating run until the cell reports it complete +// or failed. +func (c *CloudClient) waitForDispatch(ctx context.Context, run *APIRun) (*APIRun, error) { + ctx, cancel := context.WithTimeout(ctx, dispatchPollBudget) + defer cancel() + for run.Status == dispatchStatusGenerating { + if run.ID == "" { + return nil, errors.New("dispatch service returned a generating dispatch without an id") + } + timer := time.NewTimer(c.pollInterval) + select { + case <-ctx.Done(): + timer.Stop() + return nil, fmt.Errorf("dispatch %s is still generating: %w", run.ID, ctx.Err()) + case <-timer.C: + } + var next APIRun + if err := c.doJSON(ctx, http.MethodGet, dispatchesPath+"/"+url.PathEscape(run.ID), nil, &next); err != nil { + return nil, err + } + run = &next + } + switch run.Status { + case dispatchStatusComplete: + return run, nil + case dispatchStatusFailed: + if run.ErrorMessage != nil && strings.TrimSpace(*run.ErrorMessage) != "" { + return nil, fmt.Errorf("dispatch generation failed: %s", strconv.Quote(*run.ErrorMessage)) + } + return nil, errors.New("dispatch generation failed") + default: + return nil, fmt.Errorf("dispatch service returned unknown status %s", strconv.Quote(run.Status)) + } } // parseNotFoundRepos pulls the slugs out of a "repository not found: a/b, c/d" // message, keeping only the repos this request asked for (in the request's // spelling) so downstream lookups are bounded by CloudRepoLimit and never fan -// out over arbitrary prose. The gateway may echo a slug bare or forge-prefixed; +// out over arbitrary prose. The cell may echo a slug bare or forge-prefixed; // both match. Best-effort: an unexpected format yields nil and the caller // still has the message. func parseNotFoundRepos(message string, requested []string) []string { @@ -269,31 +230,17 @@ func parseNotFoundRepos(message string, requested []string) []string { } func (c *CloudClient) doJSON(ctx context.Context, method, path string, reqBody, out any) error { - var body io.Reader + var ( + resp *http.Response + err error + ) if reqBody != nil { - data, err := json.Marshal(reqBody) - if err != nil { - return fmt.Errorf("marshal request body: %w", err) - } - body = bytes.NewReader(data) + resp, err = c.api.Post(ctx, path, reqBody) + } else { + resp, err = c.api.Get(ctx, path) } - - req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) if err != nil { - return fmt.Errorf("create request: %w", err) - } - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", versioninfo.UserAgent()) - if reqBody != nil { - req.Header.Set("Content-Type", "application/json") - } - if c.token != "" { - req.Header.Set("Authorization", "Bearer "+c.token) - } - - resp, err := c.http.Do(req) - if err != nil { - return fmt.Errorf("%s %s: %w", method, path, err) + return err //nolint:wrapcheck // api.Client already names the method and path } defer resp.Body.Close() @@ -308,10 +255,7 @@ func (c *CloudClient) doJSON(ctx context.Context, method, path string, reqBody, } return &statusError{HTTPError: httpErr} } - if out == nil { - return nil - } - if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + if err := api.DecodeJSON(resp, out); err != nil { return fmt.Errorf("decode response: %w", err) } return nil diff --git a/cmd/entire/cli/dispatch/cloud_test.go b/cmd/entire/cli/dispatch/cloud_test.go index 629a63b363..92e47bb490 100644 --- a/cmd/entire/cli/dispatch/cloud_test.go +++ b/cmd/entire/cli/dispatch/cloud_test.go @@ -4,12 +4,11 @@ import ( "context" "encoding/json" "errors" - "io" "net/http" "net/http/httptest" - "slices" "strconv" "strings" + "sync/atomic" "testing" "time" @@ -17,65 +16,77 @@ import ( "github.com/entireio/cli/cmd/entire/cli/versioninfo" ) +const testDispatchRunID = "01KDISPATCHRUN0000000000000" + func newTestCloudClient(t *testing.T, baseURL, token string) *CloudClient { t.Helper() - transport := &http.Transport{DisableKeepAlives: true} - t.Cleanup(transport.CloseIdleConnections) - return NewCloudClient(CloudConfig{ - BaseURL: baseURL, - Token: token, - HTTP: &http.Client{ - Transport: transport, - Timeout: defaultCloudHTTPTimeout, + client := NewCloudClient(api.NewClientWithBaseURL(token, baseURL)) + client.pollInterval = time.Millisecond + return client +} + +// testDispatchRun is a finished run in the cell's camelCase wire shape. +func testDispatchRun(markdown string) map[string]any { + return map[string]any{ + "id": testDispatchRunID, + "status": dispatchStatusComplete, + "window": map[string]any{ + "normalizedSince": "2026-04-09T00:00:00Z", + "normalizedUntil": "2026-04-16T00:00:00Z", }, - }) + "coveredRepos": []string{testRepoFullName}, + "repos": []any{}, + "generatedMarkdown": markdown, + } +} + +func writeDispatchRun(t *testing.T, w http.ResponseWriter, status int, run map[string]any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(run); err != nil { + t.Error(err) + } } func TestCloudClient_CreateDispatch_Happy(t *testing.T) { t.Parallel() - ctx := context.Background() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/dispatches/generate" { + if r.Method != http.MethodPost || r.URL.Path != testDispatchEndpoint { http.NotFound(w, r) return } - if r.Header.Get("Authorization") == "" { + if r.Header.Get("Authorization") != "Bearer t" { w.WriteHeader(http.StatusUnauthorized) return } - var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatal(err) - } - if body["repos"] == nil { - t.Fatalf("bad body: %v", body) + t.Error(err) + return } repos, ok := body["repos"].([]any) - if !ok || len(repos) != 1 || repos[0] != testRepoFullName { - t.Fatalf("bad repos payload: %v", body["repos"]) + if !ok || len(repos) != 1 || repos[0] != testRepoSlug { + t.Errorf("bad repos payload: %v", body["repos"]) } - if _, ok := body["repo"]; ok { - t.Fatalf("did not expect repo in request body: %v", body) + for _, key := range []string{"generate", "jurisdiction", "orgs", "branches"} { + if _, ok := body[key]; ok { + t.Errorf("did not expect %s in request body: %v", key, body) + } } - if _, ok := body["wait"]; ok { - t.Fatalf("did not expect wait in request body: %v", body) + if r.URL.RawQuery != "" { + t.Errorf("did not expect a query, got %q", r.URL.RawQuery) } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"window":{"normalized_since":"2026-04-09T00:00:00Z","normalized_until":"2026-04-16T00:00:00Z"},"covered_repos":["entireio/cli"],"repos":[],"totals":{"checkpoints":0,"used_checkpoint_count":0,"branches":0,"files_touched":0},"warnings":{"access_denied_count":0,"pending_count":0,"failed_count":0,"unknown_count":0,"uncategorized_count":0},"generated_markdown":"hi"}`)) //nolint:errcheck // test fixture response + writeDispatchRun(t, w, http.StatusAccepted, testDispatchRun("hi")) })) defer srv.Close() - client := newTestCloudClient(t, srv.URL, "t") - got, err := client.CreateDispatch(ctx, CreateDispatchRequest{ - Repos: []string{testRepoFullName}, - Since: "2026-04-09T00:00:00Z", - Until: "2026-04-16T00:00:00Z", - Generate: true, - }, "") + got, err := newTestCloudClient(t, srv.URL, "t").CreateDispatch(context.Background(), CreateDispatchRequest{ + Repos: []string{testRepoSlug}, + Since: "2026-04-09T00:00:00Z", + Until: "2026-04-16T00:00:00Z", + }, "eu") if err != nil { t.Fatal(err) } @@ -84,134 +95,150 @@ func TestCloudClient_CreateDispatch_Happy(t *testing.T) { } } -func TestCloudClient_CreateDispatch_SetsVersionedUserAgent(t *testing.T) { +func TestCloudClient_CreateDispatch_PollsUntilComplete(t *testing.T) { t.Parallel() - ctx := context.Background() - var gotUserAgent string + var reads atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotUserAgent = r.Header.Get("User-Agent") - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"window":{"normalized_since":"2026-04-09T00:00:00Z","normalized_until":"2026-04-16T00:00:00Z"},"covered_repos":["entireio/cli"],"repos":[],"generated_markdown":"hi"}`)) //nolint:errcheck // test fixture response + switch { + case r.Method == http.MethodPost && r.URL.Path == testDispatchEndpoint: + run := testDispatchRun("") + run["status"] = dispatchStatusGenerating + writeDispatchRun(t, w, http.StatusAccepted, run) + case r.Method == http.MethodGet && r.URL.Path == testDispatchEndpoint+"/"+testDispatchRunID: + if reads.Add(1) < 3 { + run := testDispatchRun("") + run["status"] = dispatchStatusGenerating + writeDispatchRun(t, w, http.StatusOK, run) + return + } + writeDispatchRun(t, w, http.StatusOK, testDispatchRun("done")) + default: + http.NotFound(w, r) + } })) defer srv.Close() - client := newTestCloudClient(t, srv.URL, "t") - _, err := client.CreateDispatch(ctx, CreateDispatchRequest{ - Repos: []string{testRepoFullName}, - Since: "2026-04-09T00:00:00Z", - Until: "2026-04-16T00:00:00Z", - Generate: true, - }, "") + got, err := newTestCloudClient(t, srv.URL, "t").CreateDispatch(context.Background(), CreateDispatchRequest{Repos: []string{testRepoSlug}}, "") if err != nil { t.Fatal(err) } - if want := versioninfo.UserAgent(); gotUserAgent != want { - t.Fatalf("expected User-Agent %q, got %q", want, gotUserAgent) + if got.GeneratedMarkdown != "done" { + t.Fatalf("expected the polled markdown, got %q", got.GeneratedMarkdown) + } + if n := reads.Load(); n != 3 { + t.Fatalf("expected 3 reads before completion, got %d", n) } } -func TestCloudClient_CreateDispatch_OmitsBranchesAndOrgsFromPayload(t *testing.T) { +func TestCloudClient_CreateDispatch_FailedRunReturnsItsError(t *testing.T) { t.Parallel() - ctx := context.Background() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Fatal(err) - } - if _, ok := body["orgs"]; ok { - t.Fatalf("did not expect orgs in payload: %v", body) - } - if _, ok := body["branches"]; ok { - t.Fatalf("did not expect branches in payload: %v", body) + if r.Method == http.MethodPost { + run := testDispatchRun("") + run["status"] = dispatchStatusGenerating + writeDispatchRun(t, w, http.StatusAccepted, run) + return } + run := testDispatchRun("") + run["status"] = dispatchStatusFailed + run["errorMessage"] = "generation timed out" + writeDispatchRun(t, w, http.StatusOK, run) + })) + defer srv.Close() + + _, err := newTestCloudClient(t, srv.URL, "t").CreateDispatch(context.Background(), CreateDispatchRequest{Repos: []string{testRepoSlug}}, "") + if err == nil || err.Error() != `dispatch generation failed: "generation timed out"` { + t.Fatalf("expected the run's error message, got %v", err) + } +} + +func TestCloudClient_CreateDispatch_StopsPollingWhenContextEnds(t *testing.T) { + t.Parallel() - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"window":{"normalized_since":"2026-04-09T00:00:00Z","normalized_until":"2026-04-16T00:00:00Z"},"covered_repos":["entireio/cli"],"repos":[],"generated_markdown":"hi"}`)) //nolint:errcheck // test fixture response + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + run := testDispatchRun("") + run["status"] = dispatchStatusGenerating + writeDispatchRun(t, w, http.StatusOK, run) })) defer srv.Close() - client := newTestCloudClient(t, srv.URL, "t") - _, err := client.CreateDispatch(ctx, CreateDispatchRequest{ - Repos: []string{"entireio/cli"}, - Since: "2026-04-09T00:00:00Z", - Until: "2026-04-16T00:00:00Z", - Generate: true, - }, "") - if err != nil { - t.Fatal(err) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + _, err := newTestCloudClient(t, srv.URL, "t").CreateDispatch(ctx, CreateDispatchRequest{Repos: []string{testRepoSlug}}, "") + if err == nil || !strings.Contains(err.Error(), "still generating") { + t.Fatalf("expected a still-generating error, got %v", err) } } -func TestCloudClient_CreateDispatch_Unauthorized(t *testing.T) { +func TestCloudClient_CreateDispatch_UnknownStatusIsAnError(t *testing.T) { t.Parallel() - ctx := context.Background() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) + run := testDispatchRun("hi") + run["status"] = "queued" + writeDispatchRun(t, w, http.StatusAccepted, run) })) defer srv.Close() - client := newTestCloudClient(t, srv.URL, "") - _, err := client.CreateDispatch(ctx, CreateDispatchRequest{Repos: []string{"x/y"}}, "") - if err == nil || !strings.Contains(err.Error(), "entire login") { - t.Fatalf("expected auth error, got %v", err) + _, err := newTestCloudClient(t, srv.URL, "t").CreateDispatch(context.Background(), CreateDispatchRequest{Repos: []string{testRepoSlug}}, "") + if err == nil || !strings.Contains(err.Error(), `unknown status "queued"`) { + t.Fatalf("expected an unknown-status error, got %v", err) } } -func TestNewCloudClient_DefaultHTTPClientUsesLongTimeout(t *testing.T) { +func TestCloudClient_CreateDispatch_SetsVersionedUserAgent(t *testing.T) { t.Parallel() - client := NewCloudClient(CloudConfig{BaseURL: "http://example.com", Token: "t"}) - if client.http == nil { - t.Fatal("expected http client") + var gotUserAgent atomic.Value + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUserAgent.Store(r.Header.Get("User-Agent")) + writeDispatchRun(t, w, http.StatusAccepted, testDispatchRun("hi")) + })) + defer srv.Close() + + if _, err := newTestCloudClient(t, srv.URL, "t").CreateDispatch(context.Background(), CreateDispatchRequest{Repos: []string{testRepoSlug}}, ""); err != nil { + t.Fatal(err) } - if client.http.Timeout != 120*time.Second { - t.Fatalf("expected default http timeout %s, got %s", 120*time.Second, client.http.Timeout) + if want := versioninfo.UserAgent(); gotUserAgent.Load() != want { + t.Fatalf("expected User-Agent %q, got %v", want, gotUserAgent.Load()) } } -func TestNewCloudClient_ConfiguredTimeoutStillApplies(t *testing.T) { +func TestCloudClient_CreateDispatch_Unauthorized(t *testing.T) { t.Parallel() - timeout := 45 * time.Second - client := NewCloudClient(CloudConfig{ - BaseURL: "http://example.com", - Token: "t", - Timeout: timeout, - }) - if client.http == nil { - t.Fatal("expected http client") - } - if client.http.Timeout != timeout { - t.Fatalf("expected configured timeout %s, got %s", timeout, client.http.Timeout) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + _, err := newTestCloudClient(t, srv.URL, "").CreateDispatch(context.Background(), CreateDispatchRequest{Repos: []string{testRepoSlug}}, "") + if err == nil || !strings.Contains(err.Error(), "entire login") { + t.Fatalf("expected auth error, got %v", err) } } func TestCloudClient_CreateDispatch_EscapesErrorBody(t *testing.T) { t.Parallel() - ctx := context.Background() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadGateway) if _, err := w.Write([]byte("\x1b[31mboom\x1b[0m")); err != nil { - t.Fatal(err) + t.Error(err) } })) defer srv.Close() - client := newTestCloudClient(t, srv.URL, "t") - _, err := client.CreateDispatch(ctx, CreateDispatchRequest{Repos: []string{"x/y"}}, "") + _, err := newTestCloudClient(t, srv.URL, "t").CreateDispatch(context.Background(), CreateDispatchRequest{Repos: []string{testRepoSlug}}, "") if err == nil { t.Fatal("expected error") } if !strings.Contains(err.Error(), "dispatch service returned status 502") { t.Fatalf("expected simplified status error, got %v", err) } - if strings.Contains(err.Error(), "/api/v1/dispatches/generate") { + if strings.Contains(err.Error(), testDispatchEndpoint) { t.Fatalf("did not expect endpoint path in user-facing error, got %v", err) } if !strings.Contains(err.Error(), strconv.Quote("\x1b[31mboom\x1b[0m")) { @@ -222,21 +249,14 @@ func TestCloudClient_CreateDispatch_EscapesErrorBody(t *testing.T) { func TestCloudClient_CreateDispatch_IgnoresUnknownResponseFields(t *testing.T) { t.Parallel() - ctx := context.Background() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"window":{"normalized_since":"2026-04-09T00:00:00Z","normalized_until":"2026-04-16T00:00:00Z"},"covered_repos":["entireio/cli"],"repos":[],"generated_markdown":"hi","unexpected":true}`)) //nolint:errcheck // test fixture response + run := testDispatchRun("hi") + run["unexpected"] = true + writeDispatchRun(t, w, http.StatusAccepted, run) })) defer srv.Close() - client := newTestCloudClient(t, srv.URL, "t") - got, err := client.CreateDispatch(ctx, CreateDispatchRequest{ - Repos: []string{"entireio/cli"}, - Since: "2026-04-09T00:00:00Z", - Until: "2026-04-16T00:00:00Z", - Generate: true, - }, "") + got, err := newTestCloudClient(t, srv.URL, "t").CreateDispatch(context.Background(), CreateDispatchRequest{Repos: []string{testRepoSlug}}, "") if err != nil { t.Fatalf("expected forward-compatible decode, got error: %v", err) } @@ -245,173 +265,6 @@ func TestCloudClient_CreateDispatch_IgnoresUnknownResponseFields(t *testing.T) { } } -func TestCloudClient_CreateDispatch_AcceptsBranchesResponseField(t *testing.T) { - t.Parallel() - - client := NewCloudClient(CloudConfig{ - BaseURL: "http://example.com", - Token: "t", - HTTP: &http.Client{ - Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusCreated, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader( - `{"window":{"normalized_since":"2026-04-09T00:00:00Z","normalized_until":"2026-04-16T00:00:00Z"},"covered_repos":["entireio/cli"],"branches":["main","release"],"repos":[],"generated_markdown":"hi","totals":{"checkpoints":0,"used_checkpoint_count":0,"branches":2,"files_touched":0},"warnings":{"access_denied_count":0,"pending_count":0,"failed_count":0,"unknown_count":0,"uncategorized_count":0}}`, - )), - }, nil - }), - }, - }) - got, err := client.CreateDispatch(context.Background(), CreateDispatchRequest{ - Repos: []string{"entireio/cli"}, - Since: "2026-04-09T00:00:00Z", - Until: "2026-04-16T00:00:00Z", - Generate: true, - }, "") - if err != nil { - t.Fatal(err) - } - if !slices.Equal(got.Branches.Values, []string{"main", "release"}) { - t.Fatalf("unexpected branches response: %+v", got.Branches) - } - if got.Branches.All { - t.Fatalf("did not expect all-branches sentinel: %+v", got.Branches) - } -} - -func TestCloudClient_CreateDispatch_AcceptsAllBranchesSentinelInResponseField(t *testing.T) { - t.Parallel() - - client := NewCloudClient(CloudConfig{ - BaseURL: "http://example.com", - Token: "t", - HTTP: &http.Client{ - Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusCreated, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader( - `{"window":{"normalized_since":"2026-04-09T00:00:00Z","normalized_until":"2026-04-16T00:00:00Z"},"covered_repos":["entireio/cli"],"branches":"all","repos":[],"generated_markdown":"hi","totals":{"checkpoints":0,"used_checkpoint_count":0,"branches":2,"files_touched":0},"warnings":{"access_denied_count":0,"pending_count":0,"failed_count":0,"unknown_count":0,"uncategorized_count":0}}`, - )), - }, nil - }), - }, - }) - got, err := client.CreateDispatch(context.Background(), CreateDispatchRequest{ - Repos: []string{"entireio/cli"}, - Since: "2026-04-09T00:00:00Z", - Until: "2026-04-16T00:00:00Z", - Generate: true, - }, "") - if err != nil { - t.Fatal(err) - } - if !got.Branches.All { - t.Fatalf("expected all-branches sentinel, got %+v", got.Branches) - } - if len(got.Branches.Values) != 0 { - t.Fatalf("did not expect explicit branch values, got %+v", got.Branches) - } -} - -func TestCloudClient_CreateDispatch_AcceptsVoiceResponseField(t *testing.T) { - t.Parallel() - - client := NewCloudClient(CloudConfig{ - BaseURL: "http://example.com", - Token: "t", - HTTP: &http.Client{ - Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusCreated, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader( - `{"window":{"normalized_since":"2026-04-09T00:00:00Z","normalized_until":"2026-04-16T00:00:00Z"},"covered_repos":["entireio/cli"],"branches":["main"],"voice":"calm and direct","repos":[],"generated_markdown":"hi","totals":{"checkpoints":0,"used_checkpoint_count":0,"branches":1,"files_touched":0},"warnings":{"access_denied_count":0,"pending_count":0,"failed_count":0,"unknown_count":0,"uncategorized_count":0}}`, - )), - }, nil - }), - }, - }) - got, err := client.CreateDispatch(context.Background(), CreateDispatchRequest{ - Repos: []string{"entireio/cli"}, - Since: "2026-04-09T00:00:00Z", - Until: "2026-04-16T00:00:00Z", - Generate: true, - }, "") - if err != nil { - t.Fatal(err) - } - if got.Voice == nil || *got.Voice != "calm and direct" { - t.Fatalf("unexpected voice response: %v", got.Voice) - } -} - -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} - -func TestCloudClient_CreateDispatch_SendsJurisdictionSelectorAsQueryOnly(t *testing.T) { - t.Parallel() - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != testDispatchEndpoint { - http.NotFound(w, r) - return - } - if got := r.URL.Query().Get(jurisdictionQueryParam); got != "us" { - t.Errorf("expected ?jurisdiction=us, got query %q", r.URL.RawQuery) - } - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Error(err) - return - } - if _, ok := body["jurisdiction"]; ok { - t.Errorf("jurisdiction is a gateway query selector and must not be in the body: %v", body) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"jurisdiction":"us","window":{"normalized_since":"2026-04-09T00:00:00Z","normalized_until":"2026-04-16T00:00:00Z"},"covered_repos":["entireio/cli"],"repos":[],"generated_markdown":"hi"}`)) //nolint:errcheck // test fixture response - })) - defer srv.Close() - - client := newTestCloudClient(t, srv.URL, "t") - got, err := client.CreateDispatch(context.Background(), CreateDispatchRequest{ - Repos: []string{testRepoFullName}, - Since: "2026-04-09T00:00:00Z", - Until: "2026-04-16T00:00:00Z", - Generate: true, - }, "us") - if err != nil { - t.Fatal(err) - } - if got.GeneratedMarkdown != "hi" { - t.Fatalf("unexpected response: %+v", got) - } -} - -func TestCloudClient_CreateDispatch_HomeSendsNoSelector(t *testing.T) { - t.Parallel() - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Has(jurisdictionQueryParam) { - t.Errorf("home-jurisdiction request must not send a selector, got query %q", r.URL.RawQuery) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"window":{"normalized_since":"2026-04-09T00:00:00Z","normalized_until":"2026-04-16T00:00:00Z"},"covered_repos":["entireio/cli"],"repos":[],"generated_markdown":"hi"}`)) //nolint:errcheck // test fixture response - })) - defer srv.Close() - - client := newTestCloudClient(t, srv.URL, "t") - if _, err := client.CreateDispatch(context.Background(), CreateDispatchRequest{Repos: []string{testRepoFullName}, Generate: true}, ""); err != nil { - t.Fatal(err) - } -} - func TestCloudClient_CreateDispatch_RepoNotFoundNamesTargetJurisdiction(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/dispatch/consts_test.go b/cmd/entire/cli/dispatch/consts_test.go index 09d65e9d48..119b1f3d05 100644 --- a/cmd/entire/cli/dispatch/consts_test.go +++ b/cmd/entire/cli/dispatch/consts_test.go @@ -7,7 +7,7 @@ const ( testRepoRemoteURL = "https://github.com/" + testRepoFullName + ".git" testCheckpointID = "a1b2c3d4e5f6" testLocalFallbackText = "local fallback summary" - testDispatchEndpoint = "/api/v1/dispatches/generate" + testDispatchEndpoint = "/api/v1/me/dispatches" testCloudDispatchToken = "test-token" testVoicePresetMarvin = "marvin" testVoicePresetNeutral = "neutral" diff --git a/cmd/entire/cli/dispatch/dispatch.go b/cmd/entire/cli/dispatch/dispatch.go index 75c032cfbe..26a13be8ab 100644 --- a/cmd/entire/cli/dispatch/dispatch.go +++ b/cmd/entire/cli/dispatch/dispatch.go @@ -38,7 +38,7 @@ type Options struct { Voice string // Jurisdiction is the slug (e.g. "us") of the jurisdiction whose cell // generates a cloud dispatch. Empty means the caller's home - // jurisdiction — the pre-selector behaviour. Cloud mode only. + // jurisdiction. Cloud mode only. Jurisdiction string InsecureHTTPAuth bool TextGenerator TextGenerator diff --git a/cmd/entire/cli/dispatch/dispatch_test.go b/cmd/entire/cli/dispatch/dispatch_test.go index 04a8865cc8..c13d18aed8 100644 --- a/cmd/entire/cli/dispatch/dispatch_test.go +++ b/cmd/entire/cli/dispatch/dispatch_test.go @@ -5,17 +5,16 @@ import ( "strings" "testing" + "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/auth" ) func TestRun_ServerAllowsRepos(t *testing.T) { - oldResolve := resolveDataAPI - resolveDataAPI = func(context.Context) (auth.DataAPI, error) { - return auth.DataAPI{}, auth.ErrNotLoggedIn + old := newDispatchCellClient + newDispatchCellClient = func(context.Context, bool, string) (*api.Client, string, error) { + return nil, "", auth.ErrNotLoggedIn } - t.Cleanup(func() { - resolveDataAPI = oldResolve - }) + t.Cleanup(func() { newDispatchCellClient = old }) _, err := Run(context.Background(), Options{ Mode: ModeServer, diff --git a/cmd/entire/cli/dispatch/mode_cloud.go b/cmd/entire/cli/dispatch/mode_cloud.go index 9da4151ab8..db32ff1413 100644 --- a/cmd/entire/cli/dispatch/mode_cloud.go +++ b/cmd/entire/cli/dispatch/mode_cloud.go @@ -13,34 +13,30 @@ import ( "github.com/entireio/cli/cmd/entire/cli/paths" ) -// requireSecureDispatchURL is the secure-base-URL guard used before the cloud -// client sends a bearer token. Tests swap it to allow httptest.NewServer -// (http://127.0.0.1:...) endpoints; production always routes through -// api.RequireSecureURL and rejects plain HTTP. -var requireSecureDispatchURL = api.RequireSecureURL +// newDispatchCellClient builds the client for the entire-api cell that +// generates the dispatch — jurisdiction's, or the caller's home cell when it is +// empty — and reports the caller's home jurisdiction. Tests swap in a client +// for an httptest server. +var newDispatchCellClient = defaultNewDispatchCellClient -func runServer(ctx context.Context, opts Options) (*Dispatch, error) { - if opts.InsecureHTTPAuth { - auth.EnableInsecureHTTP() - } else if override, ok := api.BaseURLOverride(); ok { - if err := requireSecureDispatchURL(override); err != nil { - return nil, fmt.Errorf("dispatch base URL: %w", err) - } +func defaultNewDispatchCellClient(ctx context.Context, insecureHTTP bool, jurisdiction string) (*api.Client, string, error) { + factory, err := auth.NewEntireAPICellClientFactory(ctx, insecureHTTP) + if err != nil { + return nil, "", err //nolint:wrapcheck // runServer wraps; ErrNotLoggedIn must stay matchable } - target, err := resolveDataAPI(ctx) - if errors.Is(err, auth.ErrNotLoggedIn) { - return nil, errors.New("dispatch requires login — run `entire login`") + var target *auth.CellTarget + if j := strings.TrimSpace(jurisdiction); j != "" { + target = &auth.CellTarget{Jurisdiction: j} } + client, err := factory.ClientFor(ctx, target) if err != nil { - return nil, fmt.Errorf("reading credentials: %w", err) - } - baseURL, token := target.BaseURL, target.Token - if !opts.InsecureHTTPAuth { - if err := requireSecureDispatchURL(baseURL); err != nil { - return nil, fmt.Errorf("dispatch base URL: %w", err) - } + return nil, "", err //nolint:wrapcheck // runServer wraps } + home, _ := factory.HomeJurisdiction() //nolint:errcheck // best-effort label for a repo-not-found error + return client, home, nil +} +func runServer(ctx context.Context, opts Options) (*Dispatch, error) { now := nowUTC() sinceInput := strings.TrimSpace(opts.Since) if sinceInput == "" { @@ -80,27 +76,32 @@ func runServer(ctx context.Context, opts Options) (*Dispatch, error) { repos = []string{repoSlug} } - cloud := NewCloudClient(CloudConfig{BaseURL: baseURL, Token: token}) + // Local inputs are settled first: building the client already dials (login + // refresh, cell catalog). + client, home, err := newDispatchCellClient(ctx, opts.InsecureHTTPAuth, opts.Jurisdiction) + if errors.Is(err, auth.ErrNotLoggedIn) { + return nil, errors.New("dispatch requires login — run `entire login`") + } + if err != nil { + return nil, fmt.Errorf("dispatch service: %w", err) + } + reqBody := CreateDispatchRequest{ - Repos: repos, - Since: normalizedSince.Format(time.RFC3339), - Until: normalizedUntil.Format(time.RFC3339), - Generate: true, - Voice: resolvedDispatchVoicePreference(opts.Voice), + Repos: repos, + Since: normalizedSince.Format(time.RFC3339), + Until: normalizedUntil.Format(time.RFC3339), + Voice: resolvedDispatchVoicePreference(opts.Voice), } - response, err := cloud.CreateDispatch(ctx, reqBody, opts.Jurisdiction) + response, err := NewCloudClient(client).CreateDispatch(ctx, reqBody, opts.Jurisdiction) if err != nil { - // With no selector the home cell answered; say which one, from the - // token already in hand, so the hint can exclude it. + // With no selector the home cell answered; say which one so the hint + // can exclude it. var notFound *RepoNotFoundError if errors.As(err, ¬Found) && notFound.Jurisdiction == "" { - notFound.Home, _ = auth.HomeJurisdictionFromLoginJWT(token) //nolint:errcheck // best-effort label on an error already being returned + notFound.Home = home } return nil, err } - if err := checkDispatchJurisdiction(opts.Jurisdiction, response.Jurisdiction); err != nil { - return nil, err - } dispatch := apiToDispatch(response) if strings.TrimSpace(dispatch.GeneratedText) == "" { @@ -109,28 +110,7 @@ func runServer(ctx context.Context, opts Options) (*Dispatch, error) { return dispatch, nil } -// checkDispatchJurisdiction verifies the gateway generated the dispatch where -// --jurisdiction asked. The gateway echoes `jurisdiction` whenever the caller -// named one, so a missing echo means a gateway that ignored the selector and -// routed home — and a different echo means another region entirely. Both fail: -// a wrong-region result would be rendered labelled with a jurisdiction it did -// not come from, which is worse than no result. No selector, no check. -func checkDispatchJurisdiction(requested, stamped string) error { - requested = strings.TrimSpace(requested) - if requested == "" { - return nil - } - stamped = strings.ToLower(strings.TrimSpace(stamped)) - switch { - case stamped == "": - return fmt.Errorf("the dispatch service ignored --jurisdiction %s (no jurisdiction in its response); it may predate jurisdiction-scoped dispatches — retry without the flag for a home dispatch", requested) - case stamped != requested: - return fmt.Errorf("dispatch was generated in jurisdiction %s, not the requested %s", strings.ToUpper(stamped), strings.ToUpper(requested)) - } - return nil -} - -func apiToDispatch(response *CreateDispatchResponse) *Dispatch { +func apiToDispatch(response *APIRun) *Dispatch { if response == nil { return &Dispatch{} } @@ -163,9 +143,6 @@ func apiToDispatch(response *CreateDispatchResponse) *Dispatch { } generatedText := strings.TrimSpace(response.GeneratedMarkdown) - if generatedText == "" { - generatedText = strings.TrimSpace(response.GeneratedText) - } return &Dispatch{ Window: Window{ diff --git a/cmd/entire/cli/dispatch/mode_cloud_test.go b/cmd/entire/cli/dispatch/mode_cloud_test.go index 9340005058..9376248c37 100644 --- a/cmd/entire/cli/dispatch/mode_cloud_test.go +++ b/cmd/entire/cli/dispatch/mode_cloud_test.go @@ -3,8 +3,10 @@ package dispatch import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" + "os" "strings" "testing" "time" @@ -14,25 +16,17 @@ import ( "github.com/entireio/cli/cmd/entire/cli/testutil" ) -// stubCloudDispatchAuth swaps the package-level auth/secure-URL hooks to -// produce a test token and bypass the HTTPS guard so tests can talk to a -// plain-HTTP httptest.NewServer. Tests that need to verify the HTTPS guard -// itself should not call this helper. +// stubCloudDispatchAuth swaps the cell-client seam for a client aimed at +// ENTIRE_API_BASE_URL (read when Run builds the client, so tests may set it +// after stubbing) with the test token and "us" as the home jurisdiction. Tests +// that exercise the real cell routing should not call this helper. func stubCloudDispatchAuth(t *testing.T) { t.Helper() - oldResolve := resolveDataAPI - oldRequire := requireSecureDispatchURL - resolveDataAPI = stubDataAPI - requireSecureDispatchURL = func(string) error { return nil } - t.Cleanup(func() { - resolveDataAPI = oldResolve - requireSecureDispatchURL = oldRequire - }) -} - -// stubDataAPI returns the test token for the configured data host. -func stubDataAPI(context.Context) (auth.DataAPI, error) { - return auth.DataAPI{BaseURL: api.BaseURL(), Token: testCloudDispatchToken}, nil + old := newDispatchCellClient + newDispatchCellClient = func(context.Context, bool, string) (*api.Client, string, error) { + return api.NewClientWithBaseURL(testCloudDispatchToken, api.BaseURL()), "us", nil + } + t.Cleanup(func() { newDispatchCellClient = old }) } func TestServerMode_HappyPath(t *testing.T) { @@ -64,33 +58,21 @@ func TestServerMode_HappyPath(t *testing.T) { if body["until"] != "2026-04-15T18:30:00Z" { t.Fatalf("unexpected until payload: %v", body["until"]) } - if body["generate"] != true { - t.Fatalf("expected generate=true payload, got %v", body["generate"]) + if _, ok := body["generate"]; ok { + t.Fatalf("did not expect generate payload: %v", body) } w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) + w.WriteHeader(http.StatusAccepted) if err := json.NewEncoder(w).Encode(map[string]any{ + "status": dispatchStatusComplete, "window": map[string]any{ - "normalized_since": "2026-04-09T00:00:00Z", - "normalized_until": "2026-04-16T00:00:00Z", - }, - "covered_repos": []string{testRepoFullName}, - "repos": []any{}, - "totals": map[string]any{ - "checkpoints": 0, - "used_checkpoint_count": 0, - "branches": 0, - "files_touched": 0, + "normalizedSince": "2026-04-09T00:00:00Z", + "normalizedUntil": "2026-04-16T00:00:00Z", }, - "warnings": map[string]any{ - "access_denied_count": 0, - "pending_count": 0, - "failed_count": 0, - "unknown_count": 0, - "uncategorized_count": 0, - }, - "generated_markdown": testDispatchGeneratedHello, + "coveredRepos": []string{testRepoFullName}, + "repos": []any{}, + "generatedMarkdown": testDispatchGeneratedHello, }); err != nil { t.Fatal(err) } @@ -140,28 +122,16 @@ func TestServerMode_ExplicitReposDoNotRequireCurrentRepo(t *testing.T) { } w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) + w.WriteHeader(http.StatusAccepted) if err := json.NewEncoder(w).Encode(map[string]any{ + "status": dispatchStatusComplete, "window": map[string]any{ - "normalized_since": "2026-04-09T00:00:00Z", - "normalized_until": "2026-04-16T00:00:00Z", - }, - "covered_repos": []string{testRepoFullName, "entireio/entire.io"}, - "repos": []any{}, - "generated_markdown": testDispatchGeneratedHello, - "totals": map[string]any{ - "checkpoints": 0, - "used_checkpoint_count": 0, - "branches": 0, - "files_touched": 0, - }, - "warnings": map[string]any{ - "access_denied_count": 0, - "pending_count": 0, - "failed_count": 0, - "unknown_count": 0, - "uncategorized_count": 0, + "normalizedSince": "2026-04-09T00:00:00Z", + "normalizedUntil": "2026-04-16T00:00:00Z", }, + "coveredRepos": []string{testRepoFullName, "entireio/entire.io"}, + "repos": []any{}, + "generatedMarkdown": testDispatchGeneratedHello, }); err != nil { t.Fatal(err) } @@ -194,7 +164,7 @@ func TestServerMode_ExplicitReposDoNotRequireCurrentRepo(t *testing.T) { func TestAPIToDispatch_DerivesRepoURLs(t *testing.T) { t.Parallel() - got := apiToDispatch(&CreateDispatchResponse{ + got := apiToDispatch(&APIRun{ Repos: []APIRepo{ {FullName: testRepoFullName}, {FullName: "bad/repo)"}, @@ -234,27 +204,15 @@ func TestServerMode_RequiresGeneratedMarkdown(t *testing.T) { } w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) + w.WriteHeader(http.StatusAccepted) if err := json.NewEncoder(w).Encode(map[string]any{ + "status": dispatchStatusComplete, "window": map[string]any{ - "normalized_since": "2026-04-09T00:00:00Z", - "normalized_until": "2026-04-16T00:00:00Z", - }, - "covered_repos": []string{testRepoFullName}, - "repos": []any{}, - "totals": map[string]any{ - "checkpoints": 0, - "used_checkpoint_count": 0, - "branches": 0, - "files_touched": 0, - }, - "warnings": map[string]any{ - "access_denied_count": 0, - "pending_count": 0, - "failed_count": 0, - "unknown_count": 0, - "uncategorized_count": 0, + "normalizedSince": "2026-04-09T00:00:00Z", + "normalizedUntil": "2026-04-16T00:00:00Z", }, + "coveredRepos": []string{testRepoFullName}, + "repos": []any{}, }); err != nil { t.Fatal(err) } @@ -307,28 +265,16 @@ func TestServerMode_NormalizesWindowAndSanitizesVoice(t *testing.T) { } w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) + w.WriteHeader(http.StatusAccepted) if err := json.NewEncoder(w).Encode(map[string]any{ + "status": dispatchStatusComplete, "window": map[string]any{ - "normalized_since": "2026-04-09T00:00:00Z", - "normalized_until": "2026-04-16T00:01:00Z", - }, - "covered_repos": []string{testRepoFullName}, - "repos": []any{}, - "generated_markdown": testDispatchGeneratedHello, - "totals": map[string]any{ - "checkpoints": 0, - "used_checkpoint_count": 0, - "branches": 0, - "files_touched": 0, - }, - "warnings": map[string]any{ - "access_denied_count": 0, - "pending_count": 0, - "failed_count": 0, - "unknown_count": 0, - "uncategorized_count": 0, + "normalizedSince": "2026-04-09T00:00:00Z", + "normalizedUntil": "2026-04-16T00:01:00Z", }, + "coveredRepos": []string{testRepoFullName}, + "repos": []any{}, + "generatedMarkdown": testDispatchGeneratedHello, }); err != nil { t.Fatal(err) } @@ -354,151 +300,6 @@ func TestServerMode_NormalizesWindowAndSanitizesVoice(t *testing.T) { } } -// TestServerMode_InsecureHTTPAuthBypassesSecureURLCheck confirms that setting -// Options.InsecureHTTPAuth=true skips the real requireSecureDispatchURL check -// and lets dispatch talk to an http:// base URL. This is the local-dev escape -// hatch that matches the --insecure-http-auth flag on login/trail. The test -// deliberately does not stub requireSecureDispatchURL — the flag alone must -// bypass the guard. -func TestServerMode_InsecureHTTPAuthBypassesSecureURLCheck(t *testing.T) { - mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != testDispatchEndpoint { - http.NotFound(w, r) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - if err := json.NewEncoder(w).Encode(map[string]any{ - "window": map[string]any{ - "normalized_since": "2026-04-09T00:00:00Z", - "normalized_until": "2026-04-16T00:00:00Z", - }, - "covered_repos": []string{testRepoFullName}, - "repos": []any{}, - "generated_markdown": testDispatchGeneratedHello, - }); err != nil { - t.Fatal(err) - } - })) - defer mock.Close() - - oldResolve := resolveDataAPI - oldNow := nowUTC - resolveDataAPI = stubDataAPI - nowUTC = func() time.Time { return time.Date(2026, 4, 16, 0, 0, 0, 0, time.UTC) } - t.Cleanup(func() { - resolveDataAPI = oldResolve - nowUTC = oldNow - }) - - t.Setenv("ENTIRE_API_BASE_URL", mock.URL) - - got, err := Run(context.Background(), Options{ - Mode: ModeServer, - RepoPaths: []string{testRepoFullName}, - Since: "7d", - InsecureHTTPAuth: true, - }) - if err != nil { - t.Fatalf("expected insecure override to bypass the HTTPS guard, got %v", err) - } - if got.GeneratedText != testDispatchGeneratedHello { - t.Fatalf("unexpected generated text: %q", got.GeneratedText) - } -} - -// TestServerMode_RejectsPlainHTTPBaseURL pins the production guarantee that -// bearer tokens are never sent to an http:// base URL. It deliberately does -// not call stubCloudDispatchAuth — the real requireSecureDispatchURL must -// fire. If a future refactor drops the check, this test breaks before the -// leak reaches users. -func TestServerMode_RejectsPlainHTTPBaseURL(t *testing.T) { - oldResolve := resolveDataAPI - resolveDataAPI = stubDataAPI - t.Cleanup(func() { resolveDataAPI = oldResolve }) - - t.Setenv("ENTIRE_API_BASE_URL", "http://dispatch.example.invalid") - - _, err := Run(context.Background(), Options{ - Mode: ModeServer, - RepoPaths: []string{testRepoFullName}, - Since: "7d", - }) - if err == nil { - t.Fatal("expected error when dispatch base URL is http://") - } - if !strings.Contains(err.Error(), api.ErrInsecureHTTP.Error()) { - t.Fatalf("expected ErrInsecureHTTP, got %v", err) - } -} - -func TestServerMode_JurisdictionIsSentAsQuerySelector(t *testing.T) { - mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != testDispatchEndpoint { - http.NotFound(w, r) - return - } - if got := r.URL.Query().Get("jurisdiction"); got != "eu" { - t.Errorf("expected ?jurisdiction=eu, got query %q", r.URL.RawQuery) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - if err := json.NewEncoder(w).Encode(map[string]any{ - // The gateway echoes the jurisdiction whenever the caller named - // one; runServer fails closed without it. - "jurisdiction": "eu", - "window": map[string]any{"normalized_since": "2026-04-09T00:00:00Z", "normalized_until": "2026-04-16T00:00:00Z"}, - "covered_repos": []string{testRepoFullName}, - "repos": []any{}, - "generated_markdown": testDispatchGeneratedHello, - }); err != nil { - t.Error(err) - } - })) - defer mock.Close() - - stubCloudDispatchAuth(t) - oldNow := nowUTC - nowUTC = func() time.Time { return time.Date(2026, 4, 16, 0, 0, 0, 0, time.UTC) } - t.Cleanup(func() { nowUTC = oldNow }) - t.Setenv("ENTIRE_API_BASE_URL", mock.URL) - - got, err := Run(context.Background(), Options{ - Mode: ModeServer, - RepoPaths: []string{testRepoFullName}, - Since: "7d", - Jurisdiction: "eu", - }) - if err != nil { - t.Fatal(err) - } - if got.GeneratedText != testDispatchGeneratedHello { - t.Fatalf("bad text: %q", got.GeneratedText) - } -} - -func TestCheckDispatchJurisdiction(t *testing.T) { - t.Parallel() - - if err := checkDispatchJurisdiction("", "us"); err != nil { - t.Fatalf("no selector sent: any stamp is fine, got %v", err) - } - if err := checkDispatchJurisdiction("", ""); err != nil { - t.Fatalf("no selector, no stamp: fine, got %v", err) - } - if err := checkDispatchJurisdiction("eu", " EU "); err != nil { - t.Fatalf("matching stamp must pass, got %v", err) - } - err := checkDispatchJurisdiction("eu", "") - if err == nil || !strings.Contains(err.Error(), "ignored --jurisdiction eu") { - t.Fatalf("an unconfirmed selector must fail (the gateway echoes it whenever sent), got %v", err) - } - err = checkDispatchJurisdiction("eu", "us") - if err == nil || err.Error() != "dispatch was generated in jurisdiction US, not the requested EU" { - t.Fatalf("a wrong-region result must fail, got %v", err) - } -} - // TestServerMode_NativeOriginNamesItsForge pins the fix for `entire dispatch` // in an Entire-native checkout: with no --repos, the origin remote // entire:///et// must become the et/ slug the server @@ -525,12 +326,13 @@ func TestServerMode_NativeOriginNamesItsForge(t *testing.T) { t.Fatalf("expected the native origin to dispatch as its et/ slug, got repos payload: %v", body["repos"]) } w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) + w.WriteHeader(http.StatusAccepted) if err := json.NewEncoder(w).Encode(map[string]any{ - "window": map[string]any{"normalized_since": "2026-04-09T00:00:00Z", "normalized_until": "2026-04-16T00:00:00Z"}, - "covered_repos": []string{"entirehq/entire-api"}, - "repos": []any{}, - "generated_markdown": testDispatchGeneratedHello, + "status": dispatchStatusComplete, + "window": map[string]any{"normalizedSince": "2026-04-09T00:00:00Z", "normalizedUntil": "2026-04-16T00:00:00Z"}, + "coveredRepos": []string{"entirehq/entire-api"}, + "repos": []any{}, + "generatedMarkdown": testDispatchGeneratedHello, }); err != nil { t.Fatal(err) } @@ -570,3 +372,91 @@ func TestServerMode_UnknownOriginHostIsAnError(t *testing.T) { t.Fatalf("expected an error naming the host and the --repos escape hatch, got %v", err) } } + +// TestServerMode_RoutesToTheRequestedCell: --jurisdiction picks the cell the +// client dials, and --insecure-http-auth reaches the cell client; neither +// travels as a query or body field. +func TestServerMode_RoutesToTheRequestedCell(t *testing.T) { + mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != testDispatchEndpoint { + http.NotFound(w, r) + return + } + if r.URL.RawQuery != "" { + t.Errorf("did not expect a query, got %q", r.URL.RawQuery) + } + writeDispatchRun(t, w, http.StatusAccepted, testDispatchRun(testDispatchGeneratedHello)) + })) + defer mock.Close() + + var gotJurisdiction string + var gotInsecure bool + old := newDispatchCellClient + newDispatchCellClient = func(_ context.Context, insecure bool, jurisdiction string) (*api.Client, string, error) { + gotJurisdiction, gotInsecure = jurisdiction, insecure + return api.NewClientWithBaseURL(testCloudDispatchToken, mock.URL), "us", nil + } + t.Cleanup(func() { newDispatchCellClient = old }) + + got, err := Run(context.Background(), Options{ + Mode: ModeServer, + RepoPaths: []string{testRepoSlug}, + Since: "7d", + Jurisdiction: "eu", + InsecureHTTPAuth: true, + }) + if err != nil { + t.Fatal(err) + } + if gotJurisdiction != "eu" || !gotInsecure { + t.Fatalf("expected the eu cell with insecure auth, got jurisdiction %q insecure %v", gotJurisdiction, gotInsecure) + } + if got.GeneratedText != testDispatchGeneratedHello { + t.Fatalf("bad text: %q", got.GeneratedText) + } +} + +// TestServerMode_RepoNotFoundAtHomeNamesHome: with no --jurisdiction the +// home cell answered, so the error carries the home jurisdiction. +func TestServerMode_RepoNotFoundAtHomeNamesHome(t *testing.T) { + mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"detail":"repository not found: gh/entireio/cli"}`)) //nolint:errcheck // test fixture response + })) + defer mock.Close() + + stubCloudDispatchAuth(t) + t.Setenv("ENTIRE_API_BASE_URL", mock.URL) + + _, err := Run(context.Background(), Options{Mode: ModeServer, RepoPaths: []string{testRepoSlug}, Since: "7d"}) + var notFound *RepoNotFoundError + if !errors.As(err, ¬Found) { + t.Fatalf("expected *RepoNotFoundError, got %T: %v", err, err) + } + if notFound.FailedJurisdiction() != "us" { + t.Fatalf("expected the home jurisdiction us, got %q", notFound.FailedJurisdiction()) + } +} + +// TestServerMode_RejectsPlainHTTPBaseURL pins that the bearer is never sent to +// an http:// data host. It runs the real cell routing (no stub): with no +// ENTIRE_TOKEN and an http override, the cell client refuses before dialing. +func TestServerMode_RejectsPlainHTTPBaseURL(t *testing.T) { + t.Setenv(auth.EnvTokenVar, "") + if err := os.Unsetenv(auth.EnvTokenVar); err != nil { + t.Fatal(err) + } + t.Setenv("ENTIRE_API_BASE_URL", "http://dispatch.example.invalid") + + _, err := Run(context.Background(), Options{ + Mode: ModeServer, + RepoPaths: []string{testRepoSlug}, + Since: "7d", + }) + if err == nil { + t.Fatal("expected error when dispatch base URL is http://") + } + if !strings.Contains(err.Error(), api.ErrInsecureHTTP.Error()) { + t.Fatalf("expected ErrInsecureHTTP, got %v", err) + } +} diff --git a/cmd/entire/cli/dispatch/mode_local.go b/cmd/entire/cli/dispatch/mode_local.go index 7e3a4fcb34..b5d884c9fd 100644 --- a/cmd/entire/cli/dispatch/mode_local.go +++ b/cmd/entire/cli/dispatch/mode_local.go @@ -11,7 +11,6 @@ import ( "sync" "time" - "github.com/entireio/cli/cmd/entire/cli/auth" "github.com/entireio/cli/cmd/entire/cli/checkpoint" checkpointid "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/gitrepo" @@ -26,10 +25,6 @@ import ( ) var ( - // resolveDataAPI picks the data API and bearer for the selected login. - // Tests swap in a fixed target. - resolveDataAPI = auth.ResolveDataAPI - nowUTC = func() time.Time { return time.Now().UTC() } ) diff --git a/cmd/entire/cli/dispatch/repo_slug.go b/cmd/entire/cli/dispatch/repo_slug.go index 7a2c6cf517..cc4399b657 100644 --- a/cmd/entire/cli/dispatch/repo_slug.go +++ b/cmd/entire/cli/dispatch/repo_slug.go @@ -76,9 +76,9 @@ func normalizeRepoSlugs(values []string) ([]string, error) { return normalized, nil } -// echoedSlugMatches reports whether a slug the gateway echoed names the +// echoedSlugMatches reports whether a slug the server echoed names the // forge-qualified slug this request sent. The forge must agree and the name -// compares case-insensitively. The gateway learned the prefixed form after +// compares case-insensitively. The server learned the prefixed form after // years of bare GitHub names, so a bare echo is read as GitHub: that is the // server's legacy spelling, never a user's input, which SplitRepoSlug refuses. func echoedSlugMatches(echoed, requested string) bool { diff --git a/cmd/entire/cli/dispatch_wizard.go b/cmd/entire/cli/dispatch_wizard.go index d4c15c7848..0b84481ed4 100644 --- a/cmd/entire/cli/dispatch_wizard.go +++ b/cmd/entire/cli/dispatch_wizard.go @@ -36,7 +36,7 @@ const ( dispatchWizardVoiceCustom = "custom" // dispatchWizardJurisdictionHome is the jurisdiction select's value for - // "send no selector" (the gateway then routes to the caller's home) and + // "no --jurisdiction" (dispatch then dials the caller's home cell) and // the summary label for it. A sentinel rather than "" because huh // pre-selects the option matching the field's initial "" value. dispatchWizardJurisdictionHome = "home" diff --git a/cmd/entire/cli/dispatch_wizard_scope.go b/cmd/entire/cli/dispatch_wizard_scope.go index 53f942b72e..1ae45cf798 100644 --- a/cmd/entire/cli/dispatch_wizard_scope.go +++ b/cmd/entire/cli/dispatch_wizard_scope.go @@ -28,7 +28,7 @@ var ( // Everything is precomputed once by newDispatchWizardScope; the form reads it // on every render. A repo the control plane does not place (or, with no // placement data at all, every repo) is attributed to home, which is where the -// gateway routes when no selector is sent — under the "" key when home is +// dispatch routes when no --jurisdiction is given — under the "" key when home is // unknown, offered as a plain "Home" choice so the repo stays selectable. Only // READY placements count — a cell cannot generate from a copy still syncing — // which deliberately differs from routedRepoPlacement's single elected primary @@ -177,16 +177,16 @@ func dispatchWizardPlacements(entries []coreapi.RepoIndexEntry) map[string][]str return out } -// defaultResolveDispatchWizardHome reads home_jurisdiction from the same -// account access token the dispatch itself will send, so the picker's default -// and the request's routing agree on which login they mean. +// defaultResolveDispatchWizardHome reads home_jurisdiction from the login the +// dispatch itself is generated with (the cell client factory), so the picker's +// default and the request's routing agree on which login they mean. func defaultResolveDispatchWizardHome(ctx context.Context) string { - target, err := auth.ResolveDataAPI(ctx) + factory, err := auth.NewEntireAPICellClientFactory(ctx, false) if err != nil { logging.Debug(ctx, "dispatch wizard: home jurisdiction unavailable", "error", err) return "" } - home, err := auth.HomeJurisdictionFromLoginJWT(target.Token) + home, err := factory.HomeJurisdiction() if err != nil { logging.Debug(ctx, "dispatch wizard: home jurisdiction unavailable", "error", err) return "" diff --git a/docs/architecture/upstream-host-resolution.md b/docs/architecture/upstream-host-resolution.md index ae70d3ff52..baf4893ef3 100644 --- a/docs/architecture/upstream-host-resolution.md +++ b/docs/architecture/upstream-host-resolution.md @@ -20,7 +20,7 @@ accept a core's JWTs. |---|---|---|---| | **Core** — IdP **and** control-plane API, co-located | `entire-core`, per region (`us.auth.entire.io`, `eu.auth.entire.io`), fronted by the apex `auth.entire.io` | `org` / `repo` / `project`, `auth *`, `login` | none needed — the host *is* the core | | **Resource: git cluster** | `entire-server` / `entiredb` | `git-remote-entire` (clone/push) | `/.well-known/entire-cluster.json` → `core_urls` | -| **Resource: web/data API** | `entire.io` (`partial.to`) | `activity` / `search` / `trail` / `dispatch` | none by default — the acting login's site is the host (`auth.ResolveDataAPI`); under `ENTIRE_API_BASE_URL`, `/.well-known/entire-api.json` → `trusted_issuers` (bearer = the context's login JWT) | +| **Resource: web/data API** | `entire.io` (`partial.to`) | `activity` / `search` / `trail` | none by default — the acting login's site is the host (`auth.ResolveDataAPI`); under `ENTIRE_API_BASE_URL`, `/.well-known/entire-api.json` → `trusted_issuers` (bearer = the context's login JWT) | `contexts.json` (`$ENTIRE_CONFIG_DIR/contexts.json`, shared with entiredb's CLIs) stores each login as `{Name, CoreURL, Handle, KeychainService}` plus a @@ -92,7 +92,7 @@ Key files: `cmd/entire/cli/auth/control_plane.go` (resolver), ### Web/data API (done) -`activity` / `search` / `trail` / `dispatch` / `recap` / the `enable` report +`activity` / `search` / `trail` / `recap` / the `enable` report **follow the acting login** (`auth.ResolveDataAPI`), with the control plane's precedence: `ENTIRE_TOKEN` when set (the token verbatim, its `aud`'s site as the host), else the selected login (its refreshed JWT as the bearer, its login @@ -169,7 +169,6 @@ Key files: `cmd/entire/cli/auth/data_api.go` (`ResolveDataAPI`, the cluster path), `internal/entireclient/discovery/cluster_cores.go` (`LoadAPICores`/`ModifyAPICores`). Seams: `NewAuthenticatedAPIClient` (activity/trail/search-completion), -`dispatch/mode_local.go` `lookupResourceToken` (dispatch), `search_cmd.go` `resolveSearchToken` (search). ## Account selection diff --git a/docs/development/api-routing.md b/docs/development/api-routing.md index 6cb1614b4e..0855902e49 100644 --- a/docs/development/api-routing.md +++ b/docs/development/api-routing.md @@ -50,7 +50,7 @@ retain precedence over name matching. Duplicate normalized slugs preserve all matching search entries (deduplicated by ID); wizard jurisdictions are merged and picker suggestions deduplicated, preserving the first spelling/order. Do not use the removed BFF -`GET /api/v1/repositories` route. Dispatch generation still uses the BFF. +`GET /api/v1/repositories` route. ### Entire-API Cell Routing (which cell does a data-plane request go to?) @@ -88,6 +88,15 @@ uses the following routing shapes, mirroring the entire.io BFF: `auth.NewEntireAPICellClient(ctx, insecure, nil)` routes by the `home_jurisdiction` JWT claim; activity/recap use it with a data-API fallback (`runAuthenticatedActivityAPI` in `entireapi_client.go`). +- **Cloud dispatch → one jurisdiction's cell**: `entire dispatch` builds one + `CellClientFactory` (`newDispatchCellClient` in `dispatch/mode_cloud.go`) + and dials the `--jurisdiction` cell, else the home cell. It posts to + `/me/dispatches`, which answers 202 with a generating run, and polls + `/me/dispatches/{id}` until the run completes or fails. The cell resolves + the forge-qualified repos among its own placements and 404s + `repository not found: …` for the rest. The dispatch wizard reads the home + jurisdiction from the same factory. Do not use the BFF's + `POST /api/v1/dispatches/generate`. - **Repo-set queries → fan out and merge client-side**: `cell_fanout.go` — `groupReposByCell` (repo index → per-cell groups; the catalog join key is `ClusterSlug`↔`Cluster.Slug`, NOT the cell name, which the catalog does not From 2a644a1d52a627ecb9951f3acfed3dae2a991a63 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 9 Oct 2026 21:47:03 +0200 Subject: [PATCH 2/5] Bound the dispatch create request by the same budget The three-minute budget started in waitForDispatch, after POST /me/dispatches had returned. api.Client sets no timeout, so a cell that accepted the request but never answered left entire dispatch blocked for good. CreateDispatch now starts the budget before the POST, and it covers create and polling together. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M4H38VR9NRVE8QZP8MH0V6QW --- cmd/entire/cli/dispatch/cloud.go | 16 +++++++------- cmd/entire/cli/dispatch/cloud_test.go | 30 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/cmd/entire/cli/dispatch/cloud.go b/cmd/entire/cli/dispatch/cloud.go index 4e16df66b0..050859b149 100644 --- a/cmd/entire/cli/dispatch/cloud.go +++ b/cmd/entire/cli/dispatch/cloud.go @@ -19,6 +19,7 @@ import ( type CloudClient struct { api *api.Client pollInterval time.Duration + budget time.Duration } // The cell generates asynchronously: POST /me/dispatches answers 202 with a @@ -32,14 +33,15 @@ const ( dispatchStatusFailed = "failed" defaultDispatchPollInterval = 2 * time.Second - // dispatchPollBudget outlasts the cell's two-minute stale threshold, after - // which it reports a stuck run as failed, so the budget only fires when - // the cell stops answering with a terminal status at all. - dispatchPollBudget = 3 * time.Minute + // dispatchBudget bounds the whole create-and-poll operation; api.Client + // sets no timeout of its own. It outlasts the cell's two-minute stale + // threshold, after which the cell reports a stuck run as failed, so it only + // fires when the cell stops answering with a terminal status at all. + dispatchBudget = 3 * time.Minute ) func NewCloudClient(client *api.Client) *CloudClient { - return &CloudClient{api: client, pollInterval: defaultDispatchPollInterval} + return &CloudClient{api: client, pollInterval: defaultDispatchPollInterval, budget: dispatchBudget} } // CreateDispatchRequest is the body of POST /me/dispatches. Repos must be @@ -153,6 +155,8 @@ func (e *statusError) Unwrap() error { return e.HTTPError } // finish. jurisdiction is the --jurisdiction selector the cell was picked by // ("" = home); it only labels a repo-not-found error. func (c *CloudClient) CreateDispatch(ctx context.Context, reqBody CreateDispatchRequest, jurisdiction string) (*APIRun, error) { + ctx, cancel := context.WithTimeout(ctx, c.budget) + defer cancel() var run APIRun if err := c.doJSON(ctx, http.MethodPost, dispatchesPath, reqBody, &run); err != nil { var httpErr *api.HTTPError @@ -173,8 +177,6 @@ func (c *CloudClient) CreateDispatch(ctx context.Context, reqBody CreateDispatch // waitForDispatch polls a generating run until the cell reports it complete // or failed. func (c *CloudClient) waitForDispatch(ctx context.Context, run *APIRun) (*APIRun, error) { - ctx, cancel := context.WithTimeout(ctx, dispatchPollBudget) - defer cancel() for run.Status == dispatchStatusGenerating { if run.ID == "" { return nil, errors.New("dispatch service returned a generating dispatch without an id") diff --git a/cmd/entire/cli/dispatch/cloud_test.go b/cmd/entire/cli/dispatch/cloud_test.go index 92e47bb490..16acdb2e2b 100644 --- a/cmd/entire/cli/dispatch/cloud_test.go +++ b/cmd/entire/cli/dispatch/cloud_test.go @@ -172,6 +172,36 @@ func TestCloudClient_CreateDispatch_StopsPollingWhenContextEnds(t *testing.T) { } } +// TestCloudClient_CreateDispatch_BudgetCoversTheCreateRequest: api.Client has +// no timeout, so a cell that accepts the POST but never answers must still be +// cut off by the operation budget. +func TestCloudClient_CreateDispatch_BudgetCoversTheCreateRequest(t *testing.T) { + t.Parallel() + + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + <-release + })) + defer srv.Close() + defer close(release) // runs first, so Close is not left waiting on the handler + + client := newTestCloudClient(t, srv.URL, "t") + client.budget = 50 * time.Millisecond + done := make(chan error, 1) + go func() { + _, err := client.CreateDispatch(context.Background(), CreateDispatchRequest{Repos: []string{testRepoSlug}}, "") + done <- err + }() + select { + case err := <-done: + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected the budget to end a stalled create, got %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("a stalled create outlived the budget") + } +} + func TestCloudClient_CreateDispatch_UnknownStatusIsAnError(t *testing.T) { t.Parallel() From fd8186f8b48b97406db2272bd65f6261700c44c0 Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 9 Oct 2026 22:51:18 +0200 Subject: [PATCH 3/5] Report a dispatch as still generating when the budget ends mid-poll A deadline that expired while a status GET was in flight surfaced as a raw transport error instead of the still-generating message, which also made TestCloudClient_CreateDispatch_StopsPollingWhenContextEnds timing-dependent. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M4H6YGAH4ZYMNF1TK17VNAQ9 --- cmd/entire/cli/dispatch/cloud.go | 10 +++++++++- cmd/entire/cli/dispatch/cloud_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/cmd/entire/cli/dispatch/cloud.go b/cmd/entire/cli/dispatch/cloud.go index 050859b149..4863f1a5a2 100644 --- a/cmd/entire/cli/dispatch/cloud.go +++ b/cmd/entire/cli/dispatch/cloud.go @@ -185,11 +185,15 @@ func (c *CloudClient) waitForDispatch(ctx context.Context, run *APIRun) (*APIRun select { case <-ctx.Done(): timer.Stop() - return nil, fmt.Errorf("dispatch %s is still generating: %w", run.ID, ctx.Err()) + return nil, stillGeneratingError(ctx, run.ID) case <-timer.C: } var next APIRun if err := c.doJSON(ctx, http.MethodGet, dispatchesPath+"/"+url.PathEscape(run.ID), nil, &next); err != nil { + // The budget can run out mid-poll as well as between polls. + if ctx.Err() != nil { + return nil, stillGeneratingError(ctx, run.ID) + } return nil, err } run = &next @@ -207,6 +211,10 @@ func (c *CloudClient) waitForDispatch(ctx context.Context, run *APIRun) (*APIRun } } +func stillGeneratingError(ctx context.Context, id string) error { + return fmt.Errorf("dispatch %s is still generating: %w", id, ctx.Err()) +} + // parseNotFoundRepos pulls the slugs out of a "repository not found: a/b, c/d" // message, keeping only the repos this request asked for (in the request's // spelling) so downstream lookups are bounded by CloudRepoLimit and never fan diff --git a/cmd/entire/cli/dispatch/cloud_test.go b/cmd/entire/cli/dispatch/cloud_test.go index 16acdb2e2b..ac14a5a450 100644 --- a/cmd/entire/cli/dispatch/cloud_test.go +++ b/cmd/entire/cli/dispatch/cloud_test.go @@ -172,6 +172,31 @@ func TestCloudClient_CreateDispatch_StopsPollingWhenContextEnds(t *testing.T) { } } +// TestCloudClient_CreateDispatch_ContextEndsMidPoll: a deadline that lands +// while a poll is in flight still reports the run as generating, not a raw +// transport error. +func TestCloudClient_CreateDispatch_ContextEndsMidPoll(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + <-r.Context().Done() + return + } + run := testDispatchRun("") + run["status"] = dispatchStatusGenerating + writeDispatchRun(t, w, http.StatusAccepted, run) + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + _, err := newTestCloudClient(t, srv.URL, "t").CreateDispatch(ctx, CreateDispatchRequest{Repos: []string{testRepoSlug}}, "") + if err == nil || !strings.Contains(err.Error(), "dispatch "+testDispatchRunID+" is still generating") { + t.Fatalf("expected a still-generating error, got %v", err) + } +} + // TestCloudClient_CreateDispatch_BudgetCoversTheCreateRequest: api.Client has // no timeout, so a cell that accepts the POST but never answers must still be // cut off by the operation budget. From abbd500a4dba0cf5c463a3e8aadf2307236af75c Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Fri, 9 Oct 2026 23:00:28 +0200 Subject: [PATCH 4/5] Reuse one poll timer across the dispatch wait loop Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M4H7F95JQ7D8VKKED5HF064V --- cmd/entire/cli/dispatch/cloud.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/entire/cli/dispatch/cloud.go b/cmd/entire/cli/dispatch/cloud.go index 4863f1a5a2..aad8a5e5d0 100644 --- a/cmd/entire/cli/dispatch/cloud.go +++ b/cmd/entire/cli/dispatch/cloud.go @@ -177,17 +177,19 @@ func (c *CloudClient) CreateDispatch(ctx context.Context, reqBody CreateDispatch // waitForDispatch polls a generating run until the cell reports it complete // or failed. func (c *CloudClient) waitForDispatch(ctx context.Context, run *APIRun) (*APIRun, error) { + timer := time.NewTimer(c.pollInterval) + defer timer.Stop() for run.Status == dispatchStatusGenerating { if run.ID == "" { return nil, errors.New("dispatch service returned a generating dispatch without an id") } - timer := time.NewTimer(c.pollInterval) select { case <-ctx.Done(): - timer.Stop() return nil, stillGeneratingError(ctx, run.ID) case <-timer.C: } + // Go 1.23+ timers: Reset after a receive needs no drain. + timer.Reset(c.pollInterval) var next APIRun if err := c.doJSON(ctx, http.MethodGet, dispatchesPath+"/"+url.PathEscape(run.ID), nil, &next); err != nil { // The budget can run out mid-poll as well as between polls. From f74b20d1e1092ead29bad79371eb048a885280bf Mon Sep 17 00:00:00 2001 From: Stefan Haubold Date: Mon, 12 Oct 2026 14:14:14 +0200 Subject: [PATCH 5/5] Retry transient dispatch polls and explain an empty window A poll that hits a 5xx, a 429 or a dropped connection is retried until the budget ends, since the run keeps generating on the cell; any other poll failure names the run. The cell's empty-window 404 now reads as which repos had no checkpoints in which window. --jurisdiction skips the unused home lookup, and the upstream-host doc points to dispatch's new routing entry. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M4R0HVKCACY3AJEYHTCNWKJF --- cmd/entire/cli/dispatch/cloud.go | 26 ++++++- cmd/entire/cli/dispatch/cloud_test.go | 77 ++++++++++++++++++- cmd/entire/cli/dispatch/mode_cloud.go | 3 + docs/architecture/upstream-host-resolution.md | 3 + 4 files changed, 106 insertions(+), 3 deletions(-) diff --git a/cmd/entire/cli/dispatch/cloud.go b/cmd/entire/cli/dispatch/cloud.go index aad8a5e5d0..814e6f2dd3 100644 --- a/cmd/entire/cli/dispatch/cloud.go +++ b/cmd/entire/cli/dispatch/cloud.go @@ -118,6 +118,9 @@ func (e *RepoNotFoundError) FailedJurisdiction() string { const repoNotFoundPrefix = "repository not found" +// noCheckpointsMessage is the cell's 404 for a window with no checkpoints. +const noCheckpointsMessage = "no checkpoints in window" + func (e *RepoNotFoundError) Error() string { scope := "your home jurisdiction" if j := strings.TrimSpace(e.Jurisdiction); j != "" { @@ -169,6 +172,11 @@ func (c *CloudClient) CreateDispatch(ctx context.Context, reqBody CreateDispatch Cause: err, } } + // The cell has no error code for an empty window, only this message. + if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound && + strings.HasPrefix(strings.ToLower(httpErr.Message), noCheckpointsMessage) { + return nil, fmt.Errorf("no checkpoints for %s between %s and %s", strings.Join(reqBody.Repos, ", "), reqBody.Since, reqBody.Until) + } return nil, err } return c.waitForDispatch(ctx, &run) @@ -196,7 +204,11 @@ func (c *CloudClient) waitForDispatch(ctx context.Context, run *APIRun) (*APIRun if ctx.Err() != nil { return nil, stillGeneratingError(ctx, run.ID) } - return nil, err + if pollIsTransient(err) { + logging.Debug(ctx, "dispatch poll failed; retrying", "dispatch_id", run.ID, "error", err) + continue + } + return nil, fmt.Errorf("dispatch %s: %w", run.ID, err) } run = &next } @@ -213,6 +225,18 @@ func (c *CloudClient) waitForDispatch(ctx context.Context, run *APIRun) (*APIRun } } +// pollIsTransient reports whether a failed status poll is worth repeating: the +// run already exists and keeps generating on the cell, so a 5xx, a 429, or a +// dropped connection must not abandon it. +func pollIsTransient(err error) bool { + var httpErr *api.HTTPError + if errors.As(err, &httpErr) { + return httpErr.StatusCode >= http.StatusInternalServerError || httpErr.StatusCode == http.StatusTooManyRequests + } + var urlErr *url.Error + return errors.As(err, &urlErr) +} + func stillGeneratingError(ctx context.Context, id string) error { return fmt.Errorf("dispatch %s is still generating: %w", id, ctx.Err()) } diff --git a/cmd/entire/cli/dispatch/cloud_test.go b/cmd/entire/cli/dispatch/cloud_test.go index ac14a5a450..480f813494 100644 --- a/cmd/entire/cli/dispatch/cloud_test.go +++ b/cmd/entire/cli/dispatch/cloud_test.go @@ -197,6 +197,79 @@ func TestCloudClient_CreateDispatch_ContextEndsMidPoll(t *testing.T) { } } +// TestCloudClient_CreateDispatch_RetriesTransientPollFailures: the run keeps +// generating on the cell, so a 5xx or 429 poll is retried, not fatal. +func TestCloudClient_CreateDispatch_RetriesTransientPollFailures(t *testing.T) { + t.Parallel() + + var reads atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + run := testDispatchRun("") + run["status"] = dispatchStatusGenerating + writeDispatchRun(t, w, http.StatusAccepted, run) + return + } + switch reads.Add(1) { + case 1: + w.WriteHeader(http.StatusBadGateway) + case 2: + w.WriteHeader(http.StatusTooManyRequests) + default: + writeDispatchRun(t, w, http.StatusOK, testDispatchRun("done")) + } + })) + defer srv.Close() + + run, err := newTestCloudClient(t, srv.URL, "t").CreateDispatch(context.Background(), CreateDispatchRequest{Repos: []string{testRepoSlug}}, "") + if err != nil { + t.Fatalf("CreateDispatch: %v", err) + } + if run.GeneratedMarkdown != "done" { + t.Fatalf("GeneratedMarkdown = %q, want done", run.GeneratedMarkdown) + } +} + +// TestCloudClient_CreateDispatch_PollFailureNamesTheRun: a poll failure that +// is not retried still tells the user which run it was. +func TestCloudClient_CreateDispatch_PollFailureNamesTheRun(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + run := testDispatchRun("") + run["status"] = dispatchStatusGenerating + writeDispatchRun(t, w, http.StatusAccepted, run) + return + } + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + _, err := newTestCloudClient(t, srv.URL, "t").CreateDispatch(context.Background(), CreateDispatchRequest{Repos: []string{testRepoSlug}}, "") + if err == nil || !strings.Contains(err.Error(), "dispatch "+testDispatchRunID+":") || !api.IsHTTPErrorStatus(err, http.StatusForbidden) { + t.Fatalf("expected a 403 naming the run, got %v", err) + } +} + +func TestCloudClient_CreateDispatch_EmptyWindow(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"title":"Not Found","status":404,"detail":"no checkpoints in window"}`)) //nolint:errcheck // test fixture response + })) + defer srv.Close() + + req := CreateDispatchRequest{Repos: []string{testRepoSlug}, Since: "2026-04-09T00:00:00Z", Until: "2026-04-16T00:00:00Z"} + _, err := newTestCloudClient(t, srv.URL, "t").CreateDispatch(context.Background(), req, "") + want := "no checkpoints for " + testRepoSlug + " between 2026-04-09T00:00:00Z and 2026-04-16T00:00:00Z" + if err == nil || err.Error() != want { + t.Fatalf("error = %v, want %q", err, want) + } +} + // TestCloudClient_CreateDispatch_BudgetCoversTheCreateRequest: api.Client has // no timeout, so a cell that accepts the POST but never answers must still be // cut off by the operation budget. @@ -406,7 +479,7 @@ func TestCloudClient_CreateDispatch_Other404StaysGeneric(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"error":"no checkpoints in window"}`)) //nolint:errcheck // test fixture response + _, _ = w.Write([]byte(`{"error":"no repos covered"}`)) //nolint:errcheck // test fixture response })) defer srv.Close() @@ -420,7 +493,7 @@ func TestCloudClient_CreateDispatch_Other404StaysGeneric(t *testing.T) { } var notFound *RepoNotFoundError if errors.As(err, ¬Found) { - t.Fatalf("an empty-window 404 is not a repo-not-found error: %v", err) + t.Fatalf("an unrecognized 404 is not a repo-not-found error: %v", err) } } diff --git a/cmd/entire/cli/dispatch/mode_cloud.go b/cmd/entire/cli/dispatch/mode_cloud.go index db32ff1413..4070e223ac 100644 --- a/cmd/entire/cli/dispatch/mode_cloud.go +++ b/cmd/entire/cli/dispatch/mode_cloud.go @@ -32,6 +32,9 @@ func defaultNewDispatchCellClient(ctx context.Context, insecureHTTP bool, jurisd if err != nil { return nil, "", err //nolint:wrapcheck // runServer wraps } + if target != nil { + return client, "", nil // home only labels a not-found error with no selector + } home, _ := factory.HomeJurisdiction() //nolint:errcheck // best-effort label for a repo-not-found error return client, home, nil } diff --git a/docs/architecture/upstream-host-resolution.md b/docs/architecture/upstream-host-resolution.md index baf4893ef3..784ad6713f 100644 --- a/docs/architecture/upstream-host-resolution.md +++ b/docs/architecture/upstream-host-resolution.md @@ -22,6 +22,9 @@ accept a core's JWTs. | **Resource: git cluster** | `entire-server` / `entiredb` | `git-remote-entire` (clone/push) | `/.well-known/entire-cluster.json` → `core_urls` | | **Resource: web/data API** | `entire.io` (`partial.to`) | `activity` / `search` / `trail` | none by default — the acting login's site is the host (`auth.ResolveDataAPI`); under `ENTIRE_API_BASE_URL`, `/.well-known/entire-api.json` → `trusted_issuers` (bearer = the context's login JWT) | +`entire dispatch` does not use the web/data API: it talks to an entire-api +cell, see "Cloud dispatch" in [API routing](../development/api-routing.md). + `contexts.json` (`$ENTIRE_CONFIG_DIR/contexts.json`, shared with entiredb's CLIs) stores each login as `{Name, CoreURL, Handle, KeychainService}` plus a `CurrentContext` pointer. `CoreURL` is the JWT `iss` — the core that minted the