diff --git a/docs/runware_serverless_apps.md b/docs/runware_serverless_apps.md index e3e5c3a..f6ccf82 100644 --- a/docs/runware_serverless_apps.md +++ b/docs/runware_serverless_apps.md @@ -30,7 +30,7 @@ runware serverless apps [flags] * [runware serverless](runware_serverless.md) - Manage Runware serverless applications * [runware serverless apps builds](runware_serverless_apps_builds.md) - Inspect application builds * [runware serverless apps delete](runware_serverless_apps_delete.md) - Delete a serverless application -* [runware serverless apps endpoints](runware_serverless_apps_endpoints.md) - List endpoints for a serverless application +* [runware serverless apps endpoints](runware_serverless_apps_endpoints.md) - List and inspect endpoints for a serverless application * [runware serverless apps env](runware_serverless_apps_env.md) - Manage plain-text environment variables for an application * [runware serverless apps invoke](runware_serverless_apps_invoke.md) - Invoke an application endpoint * [runware serverless apps list](runware_serverless_apps_list.md) - List serverless applications @@ -42,5 +42,5 @@ runware serverless apps [flags] * [runware serverless apps tasks](runware_serverless_apps_tasks.md) - List and inspect application tasks * [runware serverless apps usage](runware_serverless_apps_usage.md) - Show usage and cost for a serverless application * [runware serverless apps versions](runware_serverless_apps_versions.md) - Manage application versions -* [runware serverless apps workers](runware_serverless_apps_workers.md) - List workers for a serverless application +* [runware serverless apps workers](runware_serverless_apps_workers.md) - List and inspect workers for a serverless application diff --git a/docs/runware_serverless_apps_endpoints.md b/docs/runware_serverless_apps_endpoints.md index 48904df..bf34e26 100644 --- a/docs/runware_serverless_apps_endpoints.md +++ b/docs/runware_serverless_apps_endpoints.md @@ -1,6 +1,13 @@ ## runware serverless apps endpoints -List endpoints for a serverless application +List and inspect endpoints for a serverless application + +### Synopsis + +List the endpoints of the application's active version. + +The set is written by the source itself and is replaced atomically whenever a +version activates. Empty while the app is initializing. ``` runware serverless apps endpoints [flags] @@ -36,4 +43,5 @@ runware serverless apps endpoints [flags] ### SEE ALSO * [runware serverless apps](runware_serverless_apps.md) - Manage deployed serverless applications +* [runware serverless apps endpoints show](runware_serverless_apps_endpoints_show.md) - Show a single application endpoint diff --git a/docs/runware_serverless_apps_endpoints_show.md b/docs/runware_serverless_apps_endpoints_show.md new file mode 100644 index 0000000..724cbf3 --- /dev/null +++ b/docs/runware_serverless_apps_endpoints_show.md @@ -0,0 +1,38 @@ +## runware serverless apps endpoints show + +Show a single application endpoint + +### Synopsis + +Show one endpoint of the application's active version by ID. + +``` +runware serverless apps endpoints show [flags] +``` + +### Examples + +``` + # show an endpoint + runware serverless apps endpoints show my-app 11111111-1111-1111-1111-111111111111 +``` + +### Options + +``` + -h, --help help for show +``` + +### Options inherited from parent commands + +``` + --debug Show full debug output + -F, --format string CLI output format: table, json, yaml (default "table") + --transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws") + -v, --verbose Show request/response details +``` + +### SEE ALSO + +* [runware serverless apps endpoints](runware_serverless_apps_endpoints.md) - List and inspect endpoints for a serverless application + diff --git a/docs/runware_serverless_apps_workers.md b/docs/runware_serverless_apps_workers.md index 7a2cb55..5d7a7e3 100644 --- a/docs/runware_serverless_apps_workers.md +++ b/docs/runware_serverless_apps_workers.md @@ -1,6 +1,11 @@ ## runware serverless apps workers -List workers for a serverless application +List and inspect workers for a serverless application + +### Synopsis + +List workers observed for an application, including terminal stopped rows +until they are purged. ``` runware serverless apps workers [flags] @@ -40,4 +45,5 @@ runware serverless apps workers [flags] ### SEE ALSO * [runware serverless apps](runware_serverless_apps.md) - Manage deployed serverless applications +* [runware serverless apps workers show](runware_serverless_apps_workers_show.md) - Show a single application worker diff --git a/docs/runware_serverless_apps_workers_show.md b/docs/runware_serverless_apps_workers_show.md new file mode 100644 index 0000000..2113fed --- /dev/null +++ b/docs/runware_serverless_apps_workers_show.md @@ -0,0 +1,40 @@ +## runware serverless apps workers show + +Show a single application worker + +### Synopsis + +Show one worker by ID within the application. + +The ID is the Kubernetes pod UID recorded by the reconciler. + +``` +runware serverless apps workers show [flags] +``` + +### Examples + +``` + # show a worker + runware serverless apps workers show my-app 44444444-4444-4444-4444-444444444444 +``` + +### Options + +``` + -h, --help help for show +``` + +### Options inherited from parent commands + +``` + --debug Show full debug output + -F, --format string CLI output format: table, json, yaml (default "table") + --transport string Transport protocol: ws (WebSocket) or http (REST) (default "ws") + -v, --verbose Show request/response details +``` + +### SEE ALSO + +* [runware serverless apps workers](runware_serverless_apps_workers.md) - List and inspect workers for a serverless application + diff --git a/internal/api/serverless/client.go b/internal/api/serverless/client.go index 77d6ef4..30dc03b 100644 --- a/internal/api/serverless/client.go +++ b/internal/api/serverless/client.go @@ -548,6 +548,38 @@ func (c *Client) ListEndpoints(ctx context.Context, appID string, params *ListEn } } +// GetEndpoint returns a single endpoint by ID. +func (c *Client) GetEndpoint(ctx context.Context, appID string, endpointID uuid.UUID) (*Endpoint, error) { + if c.apiKey == "" { + return nil, transport.ErrNoAPIKey + } + + resp, err := c.inner.GetEndpointWithResponse(ctx, appID, endpointID) + if err != nil { + return nil, fmt.Errorf("get endpoint: %w", err) + } + + c.logResponse(ctx, resp.HTTPResponse, resp.Body) + + switch resp.StatusCode() { + case http.StatusOK: + if resp.JSON200 == nil { + return nil, fmt.Errorf("get endpoint: empty 200 response") + } + return resp.JSON200, nil + case http.StatusUnauthorized: + return nil, problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized) + case http.StatusForbidden: + return nil, problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden) + case http.StatusNotFound: + return nil, problemToError(resp.ApplicationproblemJSON404, http.StatusNotFound) + case http.StatusUnprocessableEntity: + return nil, problemToError(resp.ApplicationproblemJSON422, http.StatusUnprocessableEntity) + default: + return nil, problemFromBody(resp.Body, resp.StatusCode()) + } +} + // ListVersions returns a page of versions for an app. func (c *Client) ListVersions(ctx context.Context, appID string, params *ListVersionsParams) (Page[Version], error) { if c.apiKey == "" { @@ -762,6 +794,38 @@ func (c *Client) ListWorkers(ctx context.Context, appID string, params *ListWork } } +// GetWorker returns a single worker by ID (the Kubernetes pod UID). +func (c *Client) GetWorker(ctx context.Context, appID string, workerID uuid.UUID) (*Worker, error) { + if c.apiKey == "" { + return nil, transport.ErrNoAPIKey + } + + resp, err := c.inner.GetWorkerWithResponse(ctx, appID, workerID) + if err != nil { + return nil, fmt.Errorf("get worker: %w", err) + } + + c.logResponse(ctx, resp.HTTPResponse, resp.Body) + + switch resp.StatusCode() { + case http.StatusOK: + if resp.JSON200 == nil { + return nil, fmt.Errorf("get worker: empty 200 response") + } + return resp.JSON200, nil + case http.StatusUnauthorized: + return nil, problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized) + case http.StatusForbidden: + return nil, problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden) + case http.StatusNotFound: + return nil, problemToError(resp.ApplicationproblemJSON404, http.StatusNotFound) + case http.StatusUnprocessableEntity: + return nil, problemToError(resp.ApplicationproblemJSON422, http.StatusUnprocessableEntity) + default: + return nil, problemFromBody(resp.Body, resp.StatusCode()) + } +} + // NewCodeAppSource builds an appSource for a code-based create. func NewCodeAppSource(src CodeSourceUpsert) (AppSourceUpsert, error) { var source gen.AppSourceUpsert_Source diff --git a/internal/api/serverless/client_test.go b/internal/api/serverless/client_test.go index 3084f44..362c5f8 100644 --- a/internal/api/serverless/client_test.go +++ b/internal/api/serverless/client_test.go @@ -21,9 +21,12 @@ const ( testGPUType = "h100" testBuildID = "33333333-3333-3333-3333-333333333333" testVersionID = "22222222-2222-2222-2222-222222222222" + testEndpointID = "11111111-1111-1111-1111-111111111111" + testWorkerID = "44444444-4444-4444-4444-444444444444" testVersionNumber = int32(1) testCursorPage2 = "page-2" testCursorPage3 = "page-3" + testStatusReady = "ready" ) func TestListGpuTypes(t *testing.T) { @@ -761,7 +764,7 @@ func TestListEndpoints(t *testing.T) { } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"data":[{ - "id":"11111111-1111-1111-1111-111111111111", + "id":"` + testEndpointID + `", "appId":"my-app", "path":"/infer", "createdAt":"2026-07-30T12:00:00Z" @@ -782,6 +785,59 @@ func TestListEndpoints(t *testing.T) { } } +func TestGetEndpoint(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "/v1/apps/" + testAppID + "/endpoints/" + testEndpointID + if r.Method != http.MethodGet || r.URL.Path != want { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"` + testEndpointID + `", + "appId":"` + testAppID + `", + "path":"generate", + "createdAt":"2026-07-30T12:00:00Z", + "updatedAt":"2026-07-30T12:00:00Z" + }`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + e, err := c.GetEndpoint(context.Background(), testAppID, uuid.MustParse(testEndpointID)) + if err != nil { + t.Fatalf("GetEndpoint: %v", err) + } + if e.Id.String() != testEndpointID || e.Path != "generate" || e.AppId != testAppID { + t.Errorf("unexpected endpoint: %+v", e) + } +} + +func TestGetEndpoint_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"type":"about:blank","title":"Not Found","status":404,"detail":"No endpoint exists"}`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + _, err := c.GetEndpoint(context.Background(), testAppID, uuid.MustParse(testEndpointID)) + var re *transport.RunwareError + if !errors.As(err, &re) { + t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err) + } + if re.StatusCode != http.StatusNotFound { + t.Errorf("expected status 404, got %d", re.StatusCode) + } +} + +func TestGetEndpoint_NoAPIKey(t *testing.T) { + c := NewClient("", "https://example.invalid", slog.Default()) + if _, err := c.GetEndpoint(context.Background(), testAppID, uuid.MustParse(testEndpointID)); !errors.Is(err, transport.ErrNoAPIKey) { + t.Fatalf("expected ErrNoAPIKey, got %v", err) + } +} + func TestListBuilds(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { want := "/v1/apps/" + testAppID + "/builds" @@ -857,7 +913,7 @@ func TestGetBuild(t *testing.T) { if err != nil { t.Fatalf("GetBuild: %v", err) } - if b.Id.String() != testBuildID || string(b.Status) != "ready" { + if b.Id.String() != testBuildID || string(b.Status) != testStatusReady { t.Errorf("unexpected build: %+v", b) } } @@ -1214,12 +1270,12 @@ func TestListWorkers(t *testing.T) { if r.Method != http.MethodGet || r.URL.Path != want { t.Errorf("unexpected %s %s", r.Method, r.URL.Path) } - if got := r.URL.Query().Get("status"); got != "ready" { + if got := r.URL.Query().Get("status"); got != testStatusReady { t.Errorf("status query = %q, want ready", got) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"data":[{ - "id":"44444444-4444-4444-4444-444444444444", + "id":"` + testWorkerID + `", "appId":"my-app", "versionId":"22222222-2222-2222-2222-222222222222", "status":"ready", @@ -1230,13 +1286,78 @@ func TestListWorkers(t *testing.T) { })) defer srv.Close() - status := WorkerStatus("ready") + status := WorkerStatus(testStatusReady) c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) page, err := c.ListWorkers(context.Background(), testAppID, &ListWorkersParams{Status: &status}) if err != nil { t.Fatalf("ListWorkers: %v", err) } - if len(page.Data) != 1 || string(page.Data[0].Status) != "ready" || page.Data[0].PodName != "worker-0" { + if len(page.Data) != 1 || string(page.Data[0].Status) != testStatusReady || page.Data[0].PodName != "worker-0" { t.Fatalf("unexpected workers: %+v", page.Data) } } + +func TestGetWorker(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + want := "/v1/apps/" + testAppID + "/workers/" + testWorkerID + if r.Method != http.MethodGet || r.URL.Path != want { + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "id":"` + testWorkerID + `", + "appId":"` + testAppID + `", + "versionId":"` + testVersionID + `", + "status":"ready", + "podName":"worker-0", + "nodeName":"node-a", + "gpuType":"h100", + "gpuCount":1, + "createdAt":"2026-07-30T12:00:00Z", + "statusOccurredAt":"2026-07-30T12:00:05Z", + "lastSeenAt":"2026-07-30T12:01:00Z" + }`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + w, err := c.GetWorker(context.Background(), testAppID, uuid.MustParse(testWorkerID)) + if err != nil { + t.Fatalf("GetWorker: %v", err) + } + if w.Id.String() != testWorkerID || w.AppId != testAppID || string(w.Status) != testStatusReady || w.PodName != "worker-0" { + t.Errorf("unexpected worker: %+v", w) + } + if w.GpuType == nil || *w.GpuType != testGPUType || w.GpuCount != 1 { + t.Errorf("unexpected gpu: type=%v count=%d", w.GpuType, w.GpuCount) + } + if w.VersionId.String() != testVersionID { + t.Errorf("unexpected versionId: %s", w.VersionId) + } +} + +func TestGetWorker_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"type":"about:blank","title":"Not Found","status":404,"detail":"No worker exists"}`)) + })) + defer srv.Close() + + c := newClient("test-key", srv.URL, slog.Default(), srv.Client()) + _, err := c.GetWorker(context.Background(), testAppID, uuid.MustParse(testWorkerID)) + var re *transport.RunwareError + if !errors.As(err, &re) { + t.Fatalf("expected *transport.RunwareError, got %T: %v", err, err) + } + if re.StatusCode != http.StatusNotFound { + t.Errorf("expected status 404, got %d", re.StatusCode) + } +} + +func TestGetWorker_NoAPIKey(t *testing.T) { + c := NewClient("", "https://example.invalid", slog.Default()) + if _, err := c.GetWorker(context.Background(), testAppID, uuid.MustParse(testWorkerID)); !errors.Is(err, transport.ErrNoAPIKey) { + t.Fatalf("expected ErrNoAPIKey, got %v", err) + } +} diff --git a/internal/cmd/serverless/apps.go b/internal/cmd/serverless/apps.go index ba66697..b9fafa1 100644 --- a/internal/cmd/serverless/apps.go +++ b/internal/cmd/serverless/apps.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/charmbracelet/log" + "github.com/google/uuid" serverlessapi "github.com/runware/runware-cli/internal/api/serverless" "github.com/runware/runware-cli/internal/cmdutil" "github.com/runware/runware-cli/internal/config" @@ -149,7 +150,11 @@ func newAppsEndpointsCmd(logger *log.Logger) *cobra.Command { cmd := &cobra.Command{ Use: "endpoints ", - Short: "List endpoints for a serverless application", + Short: "List and inspect endpoints for a serverless application", + Long: `List the endpoints of the application's active version. + +The set is written by the source itself and is replaced atomically whenever a +version activates. Empty while the app is initializing.`, Example: ` # list endpoints for an application runware serverless apps endpoints my-app @@ -184,9 +189,41 @@ func newAppsEndpointsCmd(logger *log.Logger) *cobra.Command { cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of endpoints to return (1-100)") cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor from a previous nextCursor") + cmd.AddCommand(newAppsEndpointsShowCmd(logger)) return cmd } +func newAppsEndpointsShowCmd(logger *log.Logger) *cobra.Command { + return &cobra.Command{ + Use: "show ", + Short: "Show a single application endpoint", + Long: "Show one endpoint of the application's active version by ID.", + Example: ` # show an endpoint + runware serverless apps endpoints show my-app 11111111-1111-1111-1111-111111111111`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + appID := args[0] + endpointID, err := uuid.Parse(args[1]) + if err != nil { + return fmt.Errorf("invalid endpointId %q: %w", args[1], err) + } + + spin := cmdutil.NewSpinner(fmt.Sprintf("Fetching endpoint %s...", endpointID)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + ep, err := client.GetEndpoint(cmd.Context(), appID, endpointID) + if err != nil { + spin.Stop() + return err + } + spin.Stop() + + return output.Print(cmdutil.FormatFor(cmd), endpointResult(*ep)) + }, + } +} + func newAppsLogsCmd() *cobra.Command { cmd := stubLeaf( "logs ", @@ -211,7 +248,9 @@ func newAppsWorkersCmd(logger *log.Logger) *cobra.Command { cmd := &cobra.Command{ Use: "workers ", - Short: "List workers for a serverless application", + Short: "List and inspect workers for a serverless application", + Long: `List workers observed for an application, including terminal stopped rows +until they are purged.`, Example: ` # list workers for an application runware serverless apps workers my-app @@ -255,9 +294,43 @@ func newAppsWorkersCmd(logger *log.Logger) *cobra.Command { 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.AddCommand(newAppsWorkersShowCmd(logger)) return cmd } +func newAppsWorkersShowCmd(logger *log.Logger) *cobra.Command { + return &cobra.Command{ + Use: "show ", + Short: "Show a single application worker", + Long: `Show one worker by ID within the application. + +The ID is the Kubernetes pod UID recorded by the reconciler.`, + Example: ` # show a worker + runware serverless apps workers show my-app 44444444-4444-4444-4444-444444444444`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + appID := args[0] + workerID, err := uuid.Parse(args[1]) + if err != nil { + return fmt.Errorf("invalid workerId %q: %w", args[1], err) + } + + spin := cmdutil.NewSpinner(fmt.Sprintf("Fetching worker %s...", workerID)) + spin.Start() + + client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger)) + w, err := client.GetWorker(cmd.Context(), appID, workerID) + if err != nil { + spin.Stop() + return err + } + spin.Stop() + + return output.Print(cmdutil.FormatFor(cmd), workerResult(*w)) + }, + } +} + // validateListLimit checks --limit when set. Zero means unset (API default). func validateListLimit(limit int) error { if limit == 0 { diff --git a/internal/cmd/serverless/display.go b/internal/cmd/serverless/display.go index 9207850..0753715 100644 --- a/internal/cmd/serverless/display.go +++ b/internal/cmd/serverless/display.go @@ -112,6 +112,23 @@ func (r endpointsResult) Rows() [][]any { return rows } +// endpointResult wraps a single endpoint for table/json/yaml display. +type endpointResult serverlessapi.Endpoint + +func (r endpointResult) Headers() []string { + return []string{colField, colValue} +} + +func (r endpointResult) Rows() [][]any { + return [][]any{ + {"Path", r.Path}, + {colID, r.Id.String()}, + {colApp, r.AppId}, + {colCreated, formatOptionalTime(r.CreatedAt)}, + {colUpdated, formatOptionalTime(r.UpdatedAt)}, + } +} + // versionsResult wraps version lists for table display. type versionsResult []serverlessapi.Version @@ -186,6 +203,30 @@ func (r workersResult) Rows() [][]any { return rows } +// workerResult wraps a single worker for table/json/yaml display. +type workerResult serverlessapi.Worker + +func (r workerResult) Headers() []string { + return []string{colField, colValue} +} + +func (r workerResult) Rows() [][]any { + return [][]any{ + {colID, r.Id.String()}, + {colApp, r.AppId}, + {colStatus, string(r.Status)}, + {"Pod", r.PodName}, + {"Node", formatOptionalString(r.NodeName)}, + {colGPUType, formatOptionalString(r.GpuType)}, + {"GPU count", r.GpuCount}, + {"Version ID", r.VersionId.String()}, + {"Last seen", formatOptionalTime(r.LastSeenAt)}, + {colCreated, r.CreatedAt.Format(time.RFC3339)}, + {"Status occurred", r.StatusOccurredAt.Format(time.RFC3339)}, + {"Status reason", formatOptionalString(r.StatusReason)}, + } +} + // tasksResult wraps task lists for table display. Output is omitted. type tasksResult []serverlessapi.Task diff --git a/internal/cmd/serverless/display_test.go b/internal/cmd/serverless/display_test.go index fda7b1e..63be899 100644 --- a/internal/cmd/serverless/display_test.go +++ b/internal/cmd/serverless/display_test.go @@ -430,3 +430,51 @@ func TestWorkersResult_NilNodeName(t *testing.T) { t.Fatalf("nil NodeName should render empty, got %#v", rows[0][3]) } } + +func TestEndpointResult(t *testing.T) { + created := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + rows := (endpointResult{ + Id: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + AppId: testAppID, + Path: "generate", + CreatedAt: &created, + }).Rows() + if len(rows) != 5 { + t.Fatalf("expected 5 rows, got %d", len(rows)) + } + if rows[0][1] != "generate" || rows[2][1] != testAppID { + t.Fatalf("unexpected rows %#v", rows) + } + if rows[4][1] != "" { + t.Fatalf("nil UpdatedAt should render empty, got %#v", rows[4][1]) + } +} + +func TestWorkerResult_NilOptionalFields(t *testing.T) { + created := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + rows := (workerResult{ + Id: uuid.MustParse("44444444-4444-4444-4444-444444444444"), + AppId: testAppID, + Status: "ready", + PodName: "worker-0", + GpuCount: 1, + VersionId: uuid.MustParse("22222222-2222-2222-2222-222222222222"), + CreatedAt: created, + StatusOccurredAt: created, + }).Rows() + if len(rows) != 12 { + t.Fatalf("expected 12 rows, got %d", len(rows)) + } + if rows[4][1] != "" { + t.Fatalf("nil NodeName should render empty, got %#v", rows[4][1]) + } + if rows[5][1] != "" { + t.Fatalf("nil GpuType should render empty, got %#v", rows[5][1]) + } + if rows[8][1] != "" { + t.Fatalf("nil LastSeenAt should render empty, got %#v", rows[8][1]) + } + if rows[11][1] != "" { + t.Fatalf("nil StatusReason should render empty, got %#v", rows[11][1]) + } +}