diff --git a/cmd/entire/cli/auth/cell_data_api.go b/cmd/entire/cli/auth/cell_data_api.go index c46f01480f..c3f7d6791e 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..814e6f2dd3 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,68 @@ 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 + budget 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" + + dispatchStatusGenerating = "generating" + dispatchStatusComplete = "complete" + dispatchStatusFailed = "failed" + + defaultDispatchPollInterval = 2 * time.Second + // 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(cfg CloudConfig) *CloudClient { - baseURL := cfg.BaseURL - - 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 - } - - return &CloudClient{ - baseURL: strings.TrimRight(baseURL, "/"), - token: cfg.Token, - http: httpClient, - } +func NewCloudClient(client *api.Client) *CloudClient { + return &CloudClient{api: client, pollInterval: defaultDispatchPollInterval, budget: dispatchBudget} } +// 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 +83,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 { @@ -186,13 +118,16 @@ 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 != "" { 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 +154,14 @@ 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) { + 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 if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound && strings.HasPrefix(strings.ToLower(httpErr.Message), repoNotFoundPrefix) { @@ -239,15 +172,79 @@ 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 &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) { + 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") + } + select { + case <-ctx.Done(): + 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. + if ctx.Err() != nil { + return nil, stillGeneratingError(ctx, run.ID) + } + 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 + } + 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)) + } +} + +// 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()) } // 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 +266,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 +291,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..480f813494 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,331 +95,301 @@ 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 } - - 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 + run := testDispatchRun("") + run["status"] = dispatchStatusFailed + run["errorMessage"] = "generation timed out" + 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) + _, 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_Unauthorized(t *testing.T) { +func TestCloudClient_CreateDispatch_StopsPollingWhenContextEnds(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("") + run["status"] = dispatchStatusGenerating + writeDispatchRun(t, w, http.StatusOK, 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) + 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 TestNewCloudClient_DefaultHTTPClientUsesLongTimeout(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() - client := NewCloudClient(CloudConfig{BaseURL: "http://example.com", Token: "t"}) - if client.http == nil { - t.Fatal("expected http client") - } - if client.http.Timeout != 120*time.Second { - t.Fatalf("expected default http timeout %s, got %s", 120*time.Second, client.http.Timeout) + 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) } } -func TestNewCloudClient_ConfiguredTimeoutStillApplies(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() - timeout := 45 * time.Second - client := NewCloudClient(CloudConfig{ - BaseURL: "http://example.com", - Token: "t", - Timeout: timeout, - }) - if client.http == nil { - t.Fatal("expected http client") + 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 client.http.Timeout != timeout { - t.Fatalf("expected configured timeout %s, got %s", timeout, client.http.Timeout) + if run.GeneratedMarkdown != "done" { + t.Fatalf("GeneratedMarkdown = %q, want done", run.GeneratedMarkdown) } } -func TestCloudClient_CreateDispatch_EscapesErrorBody(t *testing.T) { +// 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() - 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) + 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() - client := newTestCloudClient(t, srv.URL, "t") - _, err := client.CreateDispatch(ctx, CreateDispatchRequest{Repos: []string{"x/y"}}, "") - 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") { - 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")) { - t.Fatalf("expected quoted error body, got %v", err) + _, 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_IgnoresUnknownResponseFields(t *testing.T) { +func TestCloudClient_CreateDispatch_EmptyWindow(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 + 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() - 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, - }, "") - if err != nil { - t.Fatalf("expected forward-compatible decode, got error: %v", err) - } - if got.GeneratedMarkdown != "hi" { - t.Fatalf("expected known fields to decode, got %q", got.GeneratedMarkdown) + 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) } } -func TestCloudClient_CreateDispatch_AcceptsBranchesResponseField(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() - 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) + 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_AcceptsAllBranchesSentinelInResponseField(t *testing.T) { +func TestCloudClient_CreateDispatch_UnknownStatusIsAnError(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) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + run := testDispatchRun("hi") + run["status"] = "queued" + writeDispatchRun(t, w, http.StatusAccepted, run) + })) + defer srv.Close() + + _, 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 TestCloudClient_CreateDispatch_AcceptsVoiceResponseField(t *testing.T) { +func TestCloudClient_CreateDispatch_SetsVersionedUserAgent(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 { + 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 got.Voice == nil || *got.Voice != "calm and direct" { - t.Fatalf("unexpected voice response: %v", got.Voice) + if want := versioninfo.UserAgent(); gotUserAgent.Load() != want { + t.Fatalf("expected User-Agent %q, got %v", want, gotUserAgent.Load()) } } -type roundTripFunc func(*http.Request) (*http.Response, error) +func TestCloudClient_CreateDispatch_Unauthorized(t *testing.T) { + t.Parallel() -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) + 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_SendsJurisdictionSelectorAsQueryOnly(t *testing.T) { +func TestCloudClient_CreateDispatch_EscapesErrorBody(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 { + 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.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) + _, err := newTestCloudClient(t, srv.URL, "t").CreateDispatch(context.Background(), CreateDispatchRequest{Repos: []string{testRepoSlug}}, "") + if err == nil { + t.Fatal("expected error") } - if got.GeneratedMarkdown != "hi" { - t.Fatalf("unexpected response: %+v", got) + if !strings.Contains(err.Error(), "dispatch service returned status 502") { + t.Fatalf("expected simplified status error, got %v", err) + } + 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")) { + t.Fatalf("expected quoted error body, got %v", err) } } -func TestCloudClient_CreateDispatch_HomeSendsNoSelector(t *testing.T) { +func TestCloudClient_CreateDispatch_IgnoresUnknownResponseFields(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 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + run := testDispatchRun("hi") + run["unexpected"] = true + writeDispatchRun(t, w, http.StatusAccepted, run) })) 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) + 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) + } + if got.GeneratedMarkdown != "hi" { + t.Fatalf("expected known fields to decode, got %q", got.GeneratedMarkdown) } } @@ -498,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() @@ -512,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/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..4070e223ac 100644 --- a/cmd/entire/cli/dispatch/mode_cloud.go +++ b/cmd/entire/cli/dispatch/mode_cloud.go @@ -13,34 +13,33 @@ 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) + return nil, "", err //nolint:wrapcheck // runServer wraps } - baseURL, token := target.BaseURL, target.Token - if !opts.InsecureHTTPAuth { - if err := requireSecureDispatchURL(baseURL); err != nil { - return nil, fmt.Errorf("dispatch base URL: %w", err) - } + 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 +} +func runServer(ctx context.Context, opts Options) (*Dispatch, error) { now := nowUTC() sinceInput := strings.TrimSpace(opts.Since) if sinceInput == "" { @@ -80,27 +79,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 +113,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 +146,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..784ad6713f 100644 --- a/docs/architecture/upstream-host-resolution.md +++ b/docs/architecture/upstream-host-resolution.md @@ -20,7 +20,10 @@ 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) | + +`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 @@ -92,7 +95,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 +172,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 a868affafd..5d2aecedb4 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?) @@ -90,6 +90,15 @@ uses the following routing shapes, mirroring the entire.io BFF: fallback (`runAuthenticatedActivityAPI` in `entireapi_client.go`, `newRecapClient` in `recap.go`): the BFF serves `/me/*` by proxying to the same home cell, so it cannot answer a caller the cell path cannot. +- **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