From fef2b2cdf66e90ac197719ee9bf5f2702d823d25 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Sun, 6 Sep 2026 10:18:12 +0100 Subject: [PATCH] feat(serverless): filter live workers and wait for deploy Expose the existing listWorkers state query and poll getApp after create so a deploy can block until the app is active or failed. --- docs/runware_serverless_apps_workers.md | 12 ++- docs/runware_serverless_deploy.md | 9 +- internal/api/serverless/client.go | 58 +++++++++++++ internal/api/serverless/client_test.go | 111 +++++++++++++++++++++++- internal/cmd/serverless/apps.go | 32 ++++++- internal/cmd/serverless/deploy.go | 62 ++++++++++++- internal/cmd/serverless/deploy_test.go | 63 ++++++++++++++ internal/cmd/serverless/display_test.go | 40 +++++++++ 8 files changed, 374 insertions(+), 13 deletions(-) diff --git a/docs/runware_serverless_apps_workers.md b/docs/runware_serverless_apps_workers.md index 5d7a7e3..99da7cc 100644 --- a/docs/runware_serverless_apps_workers.md +++ b/docs/runware_serverless_apps_workers.md @@ -4,8 +4,12 @@ List and inspect workers for a serverless application ### Synopsis -List workers observed for an application, including terminal stopped rows -until they are purged. +List workers observed for an application. + +The default state is all: terminal stopped rows stay in the page until they +are purged. Pass --state live to drop them. --state live with --status stopped +is refused by the API (422), because an empty page would read as "this app +has never run". ``` runware serverless apps workers [flags] @@ -17,6 +21,9 @@ runware serverless apps workers [flags] # list workers for an application runware serverless apps workers my-app + # omit terminal stopped rows + runware serverless apps workers my-app --state live + # filter by status runware serverless apps workers my-app --status ready @@ -30,6 +37,7 @@ runware serverless apps workers [flags] --cursor string Pagination cursor from a previous nextCursor -h, --help help for workers --limit int Maximum number of workers to return (1-100) + --state string Include stopped rows (all, the API default) or drop them (live) --status string Filter by status (ready, busy, pending, …) ``` diff --git a/docs/runware_serverless_deploy.md b/docs/runware_serverless_deploy.md index 5d45c36..560478a 100644 --- a/docs/runware_serverless_deploy.md +++ b/docs/runware_serverless_deploy.md @@ -20,7 +20,9 @@ copies). The directory is zipped and uploaded as source type container. Runware builds a hosted wrapper image from that archive; the version records a buildId, not a customer image reference. Invalid container.yaml is rejected on create (400 if it cannot be parsed, 422 if it breaks a rule). The app stays -initializing until that first build rolls out. +initializing until that first build rolls out. Pass --wait to poll until the +application is active or failed. A successful wait is not a live worker: +minWorkers=0 stays scaled to zero until the first invoke. --container cannot be combined with an entry file, --src-dir, --base-image, or --requirement. @@ -76,6 +78,9 @@ runware serverless deploy [file] [flags] # deploy a container source (Dockerfile + container.yaml at the directory root) runware serverless deploy --id my-app --gpu-type h100 --container ./wrapper + + # wait until the first rollout is active or failed + runware serverless deploy ./app.py --id my-app --gpu-type h100 --wait ``` ### Options @@ -93,10 +98,12 @@ runware serverless deploy [file] [flags] --max-workers int32 Maximum number of workers (default 1) --min-workers int32 Minimum number of workers --name string Display name (defaults to --id) + --poll-interval duration Polling interval when waiting for the application (default 2s) --requirement stringArray Additional pip package to install (repeatable; code deploys only) --scaling-delay int32 Scaling delay in seconds (default 10) --src-dir string Directory to package as the application source (default: the working directory; code deploys only) --volume stringArray Absolute path inside the app backed by persistent node-local storage (repeatable) + --wait Poll until the application is active or failed ``` ### Options inherited from parent commands diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index 30dc03b..c51944d 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -128,12 +128,30 @@ type Worker = gen.Worker // AppStatus is an app lifecycle status. type AppStatus = gen.AppStatus +const ( + AppStatusActive AppStatus = gen.AppStatusActive + AppStatusDeleted AppStatus = gen.AppStatusDeleted + AppStatusDeleting AppStatus = gen.AppStatusDeleting + AppStatusFailed AppStatus = gen.AppStatusFailed + AppStatusInitializing AppStatus = gen.AppStatusInitializing + AppStatusStopped AppStatus = gen.AppStatusStopped + AppStatusStopping AppStatus = gen.AppStatusStopping +) + // AppSort is a listApps ordering. type AppSort = gen.AppSort // WorkerStatus is a worker lifecycle status. type WorkerStatus = gen.WorkerStatus +// WorkerStateFilter selects whether listWorkers includes terminal stopped rows. +type WorkerStateFilter = gen.WorkerStateFilter + +const ( + WorkerStateFilterAll WorkerStateFilter = gen.WorkerStateFilterAll + WorkerStateFilterLive WorkerStateFilter = gen.WorkerStateFilterLive +) + // Limit is a page size for cursor-paginated list endpoints. type Limit = gen.Limit @@ -360,6 +378,46 @@ func (c *Client) GetApp(ctx context.Context, appID string) (*App, error) { } } +// defaultAppPollInterval is used when WaitApp is called with a non-positive interval. +const defaultAppPollInterval = 2 * time.Second + +// WaitApp polls getApp until the application leaves initializing/stopping. +// Active is success; failed, stopped, deleting, and deleted are also terminal +// so a create or redeploy wait cannot spin forever. The caller inspects Status. +func (c *Client) WaitApp(ctx context.Context, appID string, interval time.Duration) (*App, error) { + if interval <= 0 { + interval = defaultAppPollInterval + } + + for { + app, err := c.GetApp(ctx, appID) + if err != nil { + return nil, err + } + if AppDeployTerminal(app.Status) { + return app, nil + } + + timer := time.NewTimer(interval) + select { + case <-ctx.Done(): + timer.Stop() + return nil, ctx.Err() + case <-timer.C: + } + } +} + +// AppDeployTerminal reports whether WaitApp would stop polling for status. +func AppDeployTerminal(status AppStatus) bool { + switch status { + case AppStatusActive, AppStatusFailed, AppStatusStopped, AppStatusDeleting, AppStatusDeleted: + return true + default: + return false + } +} + // UpdateApp patches an app in place. Omitted fields are left unchanged. // Currently persisted: appName and configuration. func (c *Client) UpdateApp(ctx context.Context, appID string, body AppUpdate) (*App, error) { diff --git a/internal/api/serverless/client_test.go b/internal/api/serverless/client_test.go index 362c5f8..bff3fe5 100644 --- a/internal/api/serverless/client_test.go +++ b/internal/api/serverless/client_test.go @@ -10,7 +10,9 @@ import ( "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" + "time" "github.com/google/uuid" "github.com/runware/runware-cli/internal/api/transport" @@ -191,7 +193,7 @@ func TestCreateApp(t *testing.T) { if err != nil { t.Fatalf("CreateApp: %v", err) } - if app.AppId != testAppID || string(app.Status) != "initializing" { + if app.AppId != testAppID || app.Status != AppStatusInitializing { t.Errorf("unexpected app: %+v", app) } } @@ -477,11 +479,94 @@ func TestGetApp(t *testing.T) { if err != nil { t.Fatalf("GetApp: %v", err) } - if app.AppId != testAppID || string(app.Status) != "initializing" { + if app.AppId != testAppID || app.Status != AppStatusInitializing { t.Errorf("unexpected app: %+v", app) } } +func TestWaitApp_PollsUntilActive(t *testing.T) { + var gets atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/apps/"+testAppID { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + n := gets.Add(1) + w.Header().Set("Content-Type", "application/json") + status := AppStatusInitializing + if n > 1 { + status = AppStatusActive + } + _, _ = w.Write([]byte(testAppJSON(status))) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + app, err := c.WaitApp(context.Background(), testAppID, time.Millisecond) + if err != nil { + t.Fatalf("WaitApp: %v", err) + } + if app.Status != AppStatusActive { + t.Fatalf("unexpected app: %+v", app) + } + if gets.Load() < 2 { + t.Fatalf("expected at least 2 GETs, got %d", gets.Load()) + } +} + +func TestWaitApp_FailedIsTerminal(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(testAppJSON(AppStatusFailed))) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + app, err := c.WaitApp(context.Background(), testAppID, time.Millisecond) + if err != nil { + t.Fatalf("WaitApp: %v", err) + } + if app.Status != AppStatusFailed { + t.Fatalf("unexpected app: %+v", app) + } +} + +func TestAppDeployTerminal(t *testing.T) { + terminal := []AppStatus{ + AppStatusActive, + AppStatusFailed, + AppStatusStopped, + AppStatusDeleting, + AppStatusDeleted, + } + for _, status := range terminal { + if !AppDeployTerminal(status) { + t.Errorf("%s should be terminal", status) + } + } + for _, status := range []AppStatus{AppStatusInitializing, AppStatusStopping} { + if AppDeployTerminal(status) { + t.Errorf("%s should keep polling", status) + } + } +} + +func TestWaitApp_ContextCanceled(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(testAppJSON(AppStatusInitializing))) + })) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + _, err := c.WaitApp(ctx, testAppID, time.Second) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + func TestGetApp_NotFound(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/problem+json") @@ -1273,6 +1358,9 @@ func TestListWorkers(t *testing.T) { if got := r.URL.Query().Get("status"); got != testStatusReady { t.Errorf("status query = %q, want ready", got) } + if got := r.URL.Query().Get("state"); got != string(WorkerStateFilterLive) { + t.Errorf("state query = %q, want live", got) + } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"data":[{ "id":"` + testWorkerID + `", @@ -1287,8 +1375,12 @@ func TestListWorkers(t *testing.T) { defer srv.Close() status := WorkerStatus(testStatusReady) + state := WorkerStateFilterLive c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) - page, err := c.ListWorkers(context.Background(), testAppID, &ListWorkersParams{Status: &status}) + page, err := c.ListWorkers(context.Background(), testAppID, &ListWorkersParams{ + Status: &status, + State: &state, + }) if err != nil { t.Fatalf("ListWorkers: %v", err) } @@ -1361,3 +1453,16 @@ func TestGetWorker_NoAPIKey(t *testing.T) { t.Fatalf("expected ErrNoAPIKey, got %v", err) } } + +func testAppJSON(status AppStatus) string { + return `{ + "appId":"my-app", + "appName":"My App", + "status":"` + string(status) + `", + "configuration":{"maxWorkers":1,"idleTtlSecs":60,"scalingDelaySecs":10,"minWorkers":0,"gpusPerWorker":1,"concurrency":1,"gracefulStopTtlSecs":120,"computeType":"gpu"}, + "environmentVariables":[], + "secrets":[], + "createdAt":"2026-07-30T12:00:00Z", + "updatedAt":"2026-07-30T12:00:00Z" + }` +} diff --git a/internal/cmd/serverless/apps.go b/internal/cmd/serverless/apps.go index b9fafa1..0cd7efc 100644 --- a/internal/cmd/serverless/apps.go +++ b/internal/cmd/serverless/apps.go @@ -244,16 +244,24 @@ func newAppsWorkersCmd(logger *log.Logger) *cobra.Command { limit int cursor string status string + state string ) cmd := &cobra.Command{ Use: "workers ", Short: "List and inspect workers for a serverless application", - Long: `List workers observed for an application, including terminal stopped rows -until they are purged.`, + Long: `List workers observed for an application. + +The default state is all: terminal stopped rows stay in the page until they +are purged. Pass --state live to drop them. --state live with --status stopped +is refused by the API (422), because an empty page would read as "this app +has never run".`, Example: ` # list workers for an application runware serverless apps workers my-app + # omit terminal stopped rows + runware serverless apps workers my-app --state live + # filter by status runware serverless apps workers my-app --status ready @@ -269,11 +277,16 @@ until they are purged.`, if err != nil { return err } + stateVal, err := parseWorkerState(state) + if err != nil { + return err + } var params *serverlessapi.ListWorkersParams - if limit > 0 || cursor != "" || status != "" { + if limit > 0 || cursor != "" || status != "" || state != "" { params = &serverlessapi.ListWorkersParams{} params.Limit, params.Cursor = listPageParams(limit, cursor) params.Status = statusVal + params.State = stateVal } spin := cmdutil.NewSpinner(fmt.Sprintf("Fetching workers for %s...", id)) @@ -287,13 +300,14 @@ until they are purged.`, } spin.Stop() - return printPage(cmdutil.FormatFor(cmd), page, workersResult(page.Data), cmd.ErrOrStderr(), extraStatusCursorFlag(status)) + return printPage(cmdutil.FormatFor(cmd), page, workersResult(page.Data), cmd.ErrOrStderr(), extraWorkersCursorFlags(state, status)) }, } cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of workers to return (1-100)") cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor from a previous nextCursor") cmd.Flags().StringVar(&status, "status", "", "Filter by status (ready, busy, pending, …)") + cmd.Flags().StringVar(&state, "state", "", "Include stopped rows (all, the API default) or drop them (live)") cmd.AddCommand(newAppsWorkersShowCmd(logger)) return cmd } @@ -371,6 +385,10 @@ func parseWorkerStatus(status string) (*serverlessapi.WorkerStatus, error) { return parseValidFlag[serverlessapi.WorkerStatus]("--status", status, "pending, pulling, loading, ready, busy, unhealthy, draining, stopping, or stopped") } +func parseWorkerState(state string) (*serverlessapi.WorkerStateFilter, error) { + return parseValidFlag[serverlessapi.WorkerStateFilter]("--state", state, "live or all") +} + // extraListCursorFlags repeats the apps-list filter flags a next-page --cursor is bound to. func extraListCursorFlags(query, gpuType, sort, status string) string { parts := make([]string, 0, 4) @@ -386,6 +404,12 @@ func extraStatusCursorFlag(value string) string { return strings.Join(appendFlag(nil, "--status", value), " ") } +// extraWorkersCursorFlags repeats workers list filters a next-page --cursor is bound to. +func extraWorkersCursorFlags(state, status string) string { + parts := appendFlag(nil, "--state", state) + return strings.Join(appendFlag(parts, "--status", status), " ") +} + func appendFlag(parts []string, name, value string) []string { if value == "" { return parts diff --git a/internal/cmd/serverless/deploy.go b/internal/cmd/serverless/deploy.go index 04b9489..823abab 100644 --- a/internal/cmd/serverless/deploy.go +++ b/internal/cmd/serverless/deploy.go @@ -1,8 +1,10 @@ package serverless import ( + "context" "fmt" "log/slog" + "time" "github.com/charmbracelet/log" "github.com/google/uuid" @@ -110,6 +112,8 @@ func newDeployCmd(logger *log.Logger) *cobra.Command { volumes []string envVars []string envFiles []string + wait bool + pollInterval time.Duration ) cmd := &cobra.Command{ @@ -131,7 +135,9 @@ copies). The directory is zipped and uploaded as source type container. Runware builds a hosted wrapper image from that archive; the version records a buildId, not a customer image reference. Invalid container.yaml is rejected on create (400 if it cannot be parsed, 422 if it breaks a rule). The app stays -initializing until that first build rolls out. +initializing until that first build rolls out. Pass --wait to poll until the +application is active or failed. A successful wait is not a live worker: +minWorkers=0 stays scaled to zero until the first invoke. --container cannot be combined with an entry file, --src-dir, --base-image, or --requirement. @@ -178,7 +184,10 @@ the SDK (code) or from container.yaml (container).`, --base-image python:3.11-slim --requirement torch # deploy a container source (Dockerfile + container.yaml at the directory root) - runware serverless deploy --id my-app --gpu-type h100 --container ./wrapper`, + runware serverless deploy --id my-app --gpu-type h100 --container ./wrapper + + # wait until the first rollout is active or failed + runware serverless deploy ./app.py --id my-app --gpu-type h100 --wait`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if err := validateDeployArgs(cmd, args, containerDir); err != nil { @@ -242,9 +251,24 @@ the SDK (code) or from container.yaml (container).`, spin.Stop() return err } + if wait && !serverlessapi.AppDeployTerminal(app.Status) { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Application %s is %s; waiting...\n", app.AppId, app.Status) + spin.SetMessage(fmt.Sprintf("Waiting for application %s...", app.AppId)) + app, err = client.WaitApp(cmd.Context(), app.AppId, pollInterval) + if err != nil { + spin.Stop() + return err + } + } spin.Stop() - return output.Print(cmdutil.FormatFor(cmd), appResult(*app)) + if err := output.Print(cmdutil.FormatFor(cmd), appResult(*app)); err != nil { + return err + } + if !wait { + return nil + } + return appFailedErr(cmd.Context(), client, app) }, } @@ -263,6 +287,8 @@ the SDK (code) or from container.yaml (container).`, cmd.Flags().StringArrayVar(&requirements, "requirement", nil, "Additional pip package to install (repeatable; code deploys only)") cmd.Flags().Int32Var(&minWorkers, "min-workers", 0, "Minimum number of workers") cmd.Flags().Int32Var(&gpusPerWorker, "gpus-per-worker", 1, "GPUs allocated per worker") + cmd.Flags().BoolVar(&wait, "wait", false, "Poll until the application is active or failed") + cmd.Flags().DurationVar(&pollInterval, "poll-interval", 2*time.Second, "Polling interval when waiting for the application") if err := cmd.MarkFlagRequired("id"); err != nil { panic(err) @@ -305,3 +331,33 @@ func optionalFlagStringPtr(cmd *cobra.Command, name, v string) *string { } return &v } + +func appFailedErr(ctx context.Context, client *serverlessapi.Client, app *serverlessapi.App) error { + if app == nil { + return nil + } + switch app.Status { + case serverlessapi.AppStatusActive: + return nil + case serverlessapi.AppStatusFailed: + if msg := latestBuildError(ctx, client, app.AppId); msg != "" { + return fmt.Errorf("application %s failed: %s", app.AppId, msg) + } + return fmt.Errorf("application %s failed; inspect builds with 'runware serverless apps builds list %s'", app.AppId, app.AppId) + default: + return fmt.Errorf("application %s ended in status %s", app.AppId, app.Status) + } +} + +func latestBuildError(ctx context.Context, client *serverlessapi.Client, appID string) string { + page, err := client.ListBuilds(ctx, appID, nil) + if err != nil { + return "" + } + for i := range page.Data { + if page.Data[i].Error != nil && *page.Data[i].Error != "" { + return *page.Data[i].Error + } + } + return "" +} diff --git a/internal/cmd/serverless/deploy_test.go b/internal/cmd/serverless/deploy_test.go index a7bfd2c..742fa1b 100644 --- a/internal/cmd/serverless/deploy_test.go +++ b/internal/cmd/serverless/deploy_test.go @@ -1,7 +1,11 @@ package serverless import ( + "context" "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" "strings" "testing" @@ -187,7 +191,66 @@ func TestNewDeployCmd_RegistersContainerFlag(t *testing.T) { if cmd.Flags().Lookup("container") == nil { t.Fatal("deploy is missing --container") } + if cmd.Flags().Lookup("wait") == nil { + t.Fatal("deploy is missing --wait") + } + if cmd.Flags().Lookup("poll-interval") == nil { + t.Fatal("deploy is missing --poll-interval") + } if cmd.Use != "deploy [file]" { t.Errorf("Use = %q, want deploy [file]", cmd.Use) } } + +func TestAppFailedErr(t *testing.T) { + if err := appFailedErr(context.Background(), nil, nil); err != nil { + t.Fatalf("nil app: %v", err) + } + + active := &serverlessapi.App{ + AppId: testAppID, + Status: serverlessapi.AppStatusActive, + } + if err := appFailedErr(context.Background(), nil, active); err != nil { + t.Fatalf("active: %v", err) + } + + stopped := &serverlessapi.App{ + AppId: testAppID, + Status: serverlessapi.AppStatusStopped, + } + err := appFailedErr(context.Background(), nil, stopped) + if err == nil || !strings.Contains(err.Error(), "stopped") { + t.Fatalf("stopped: %v", err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/apps/"+testAppID+"/builds" { + t.Errorf("unexpected path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"33333333-3333-3333-3333-333333333333","status":"failed","error":"pip install failed","createdAt":"2026-07-30T12:00:00Z"}]}`)) + })) + defer srv.Close() + + client := serverlessapi.NewClient("test-key", srv.URL, slog.Default()) + failed := &serverlessapi.App{ + AppId: testAppID, + Status: serverlessapi.AppStatusFailed, + } + err = appFailedErr(context.Background(), client, failed) + if err == nil || !strings.Contains(err.Error(), "pip install failed") { + t.Fatalf("failed with build error: %v", err) + } + + empty := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[]}`)) + })) + defer empty.Close() + + err = appFailedErr(context.Background(), serverlessapi.NewClient("test-key", empty.URL, slog.Default()), failed) + if err == nil || !strings.Contains(err.Error(), "inspect builds") { + t.Fatalf("failed without build error: %v", err) + } +} diff --git a/internal/cmd/serverless/display_test.go b/internal/cmd/serverless/display_test.go index 63be899..338012e 100644 --- a/internal/cmd/serverless/display_test.go +++ b/internal/cmd/serverless/display_test.go @@ -99,6 +99,31 @@ func TestParseAppStatus(t *testing.T) { } } +func TestParseWorkerState(t *testing.T) { + got, err := parseWorkerState("") + if err != nil || got != nil { + t.Fatalf("unset state: got=%v err=%v", got, err) + } + + got, err = parseWorkerState("live") + if err != nil || got == nil || *got != serverlessapi.WorkerStateFilterLive { + t.Fatalf("live: got=%v err=%v", got, err) + } + + got, err = parseWorkerState("all") + if err != nil || got == nil || *got != serverlessapi.WorkerStateFilterAll { + t.Fatalf("all: got=%v err=%v", got, err) + } + + _, err = parseWorkerState("nope") + if err == nil { + t.Fatal("expected error for state nope") + } + if !strings.Contains(err.Error(), "invalid --state") { + t.Fatalf("error %q should mention invalid --state", err) + } +} + func TestParseWorkerStatus(t *testing.T) { got, err := parseWorkerStatus("") if err != nil || got != nil { @@ -156,6 +181,21 @@ func TestExtraStatusCursorFlag(t *testing.T) { } } +func TestExtraWorkersCursorFlags(t *testing.T) { + if got := extraWorkersCursorFlags("live", "ready"); got != "--state live --status ready" { + t.Fatalf("both: got %q", got) + } + if got := extraWorkersCursorFlags("live", ""); got != "--state live" { + t.Fatalf("state only: got %q", got) + } + if got := extraWorkersCursorFlags("", "ready"); got != "--status ready" { + t.Fatalf("status only: got %q", got) + } + if got := extraWorkersCursorFlags("", ""); got != "" { + t.Fatalf("empty: got %q", got) + } +} + func TestAppResult_IncludesConfiguration(t *testing.T) { gpu := testGPUType created := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC)