Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/runware_serverless_apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ runware serverless apps [flags]
* [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 env](runware_serverless_apps_env.md) - Manage plain-text environment variables for an application
* [runware serverless apps events](runware_serverless_apps_events.md) - List events for a serverless 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
* [runware serverless apps logs](runware_serverless_apps_logs.md) - Show logs for a serverless application
Expand Down
50 changes: 50 additions & 0 deletions docs/runware_serverless_apps_events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
## runware serverless apps events

List events for a serverless application

### Synopsis

List deploy, scaling, audit, and error events for an application.

Events are the control-plane audit trail, not worker stdout. Live log
streaming is not available (apps logs is not implemented).

```
runware serverless apps events <appId> [flags]
```

### Examples

```
# list events for an application
runware serverless apps events my-app

# errors only
runware serverless apps events my-app --type error --limit 20

# page through results
runware serverless apps events my-app --type error --limit 20 --cursor <nextCursor>
```

### Options

```
--cursor string Pagination cursor from a previous nextCursor
-h, --help help for events
--limit int Maximum number of events to return (1-100)
--type string Filter by type (deploy, scaling, audit, or error)
```

### 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](runware_serverless_apps.md) - Manage deployed serverless applications

39 changes: 39 additions & 0 deletions internal/api/serverless/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ type BuildStatus = gen.BuildStatus
// ListWorkersParams are optional filters for ListWorkers.
type ListWorkersParams = gen.ListWorkersParams

// AppEvent is a deploy, scaling, audit, or error event on an app.
type AppEvent = gen.AppEvent

// AppEventType is an AppEvent.type value.
type AppEventType = gen.AppEventType

// ListAppEventsParams are optional filters for ListAppEvents.
type ListAppEventsParams = gen.ListAppEventsParams

// Task is a serverless invocation.
type Task = gen.Task

Expand Down Expand Up @@ -698,6 +707,36 @@ func (c *Client) ListWorkers(ctx context.Context, appID string, params *ListWork
}
}

// ListAppEvents returns a page of deploy, scaling, audit, and error events for an app.
func (c *Client) ListAppEvents(ctx context.Context, appID string, params *ListAppEventsParams) (Page[AppEvent], error) {
if c.apiKey == "" {
return Page[AppEvent]{}, transport.ErrNoAPIKey
}

resp, err := c.inner.ListAppEventsWithResponse(ctx, appID, params)
if err != nil {
return Page[AppEvent]{}, fmt.Errorf("list app events: %w", err)
}

c.logResponse(ctx, resp.HTTPResponse, resp.Body)

switch resp.StatusCode() {
case http.StatusOK:
if resp.JSON200 == nil {
return pageOf[AppEvent](nil, nil), nil
}
return pageOf(resp.JSON200.Data, resp.JSON200.NextCursor), nil
case http.StatusUnauthorized:
return Page[AppEvent]{}, problemToError(resp.ApplicationproblemJSON401, http.StatusUnauthorized)
case http.StatusForbidden:
return Page[AppEvent]{}, problemToError(resp.ApplicationproblemJSON403, http.StatusForbidden)
case http.StatusNotFound:
return Page[AppEvent]{}, problemToError(resp.ApplicationproblemJSON404, http.StatusNotFound)
default:
return Page[AppEvent]{}, 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
Expand Down
91 changes: 91 additions & 0 deletions internal/api/serverless/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1106,3 +1106,94 @@ func TestListWorkers(t *testing.T) {
t.Fatalf("unexpected workers: %+v", page.Data)
}
}

const (
testEventID = "55555555-5555-5555-5555-555555555555"
testWorkerID = "44444444-4444-4444-4444-444444444444"
testEndpointID = "66666666-6666-6666-6666-666666666666"
testEventType = "error"
)

func TestListAppEvents(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
want := "/v1/apps/" + testAppID + "/events"
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("type"); got != testEventType {
t.Errorf("type query = %q, want %s", got, testEventType)
}
if got := r.URL.Query().Get("limit"); got != "20" {
t.Errorf("limit query = %q, want 20", got)
}
if got := r.URL.Query().Get("cursor"); got != testCursorPage2 {
t.Errorf("cursor query = %q, want %s", got, testCursorPage2)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{
"id":"` + testEventID + `",
"appId":"my-app",
"type":"` + testEventType + `",
"message":"worker failed to start",
"workerId":"` + testWorkerID + `",
"endpointId":"` + testEndpointID + `",
"createdAt":"2026-07-30T12:00:00Z"
}],"nextCursor":"` + testCursorPage3 + `"}`))
}))
defer srv.Close()

eventType := AppEventType(testEventType)
limit := Limit(20)
cursor := Cursor(testCursorPage2)
c := newClient("test-key", srv.URL, slog.Default(), srv.Client())
page, err := c.ListAppEvents(context.Background(), testAppID, &ListAppEventsParams{
Type: &eventType,
Limit: &limit,
Cursor: &cursor,
})
if err != nil {
t.Fatalf("ListAppEvents: %v", err)
}
if len(page.Data) != 1 {
t.Fatalf("unexpected events: %+v", page.Data)
}
ev := page.Data[0]
if ev.Id.String() != testEventID || string(ev.Type) != testEventType || ev.Message != "worker failed to start" {
t.Errorf("unexpected event: %+v", ev)
}
if ev.WorkerId == nil || ev.WorkerId.String() != testWorkerID {
t.Errorf("unexpected workerId: %+v", ev.WorkerId)
}
if ev.EndpointId == nil || ev.EndpointId.String() != testEndpointID {
t.Errorf("unexpected endpointId: %+v", ev.EndpointId)
}
if page.NextCursor == nil || *page.NextCursor != testCursorPage3 {
t.Errorf("unexpected nextCursor: %+v", page.NextCursor)
}
}

func TestListAppEvents_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 app exists"}`))
}))
defer srv.Close()

c := newClient("test-key", srv.URL, slog.Default(), srv.Client())
_, err := c.ListAppEvents(context.Background(), testAppID, nil)
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 TestListAppEvents_NoAPIKey(t *testing.T) {
c := NewClient("", "https://example.invalid", slog.Default())
if _, err := c.ListAppEvents(context.Background(), testAppID, nil); !errors.Is(err, transport.ErrNoAPIKey) {
t.Fatalf("expected ErrNoAPIKey, got %v", err)
}
}
69 changes: 69 additions & 0 deletions internal/cmd/serverless/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func newAppsCmd(logger *log.Logger) *cobra.Command {
newAppsVersionsCmd(logger),
newAppsBuildsCmd(logger),
newAppsLogsCmd(),
newAppsEventsCmd(logger),
newAppsWorkersCmd(logger),
newAppsScaleCmd(logger),
newAppsUsageCmd(),
Expand Down Expand Up @@ -197,6 +198,66 @@ func newAppsLogsCmd() *cobra.Command {
)
}

func newAppsEventsCmd(logger *log.Logger) *cobra.Command {
var (
limit int
cursor string
eventType string
)

cmd := &cobra.Command{
Use: "events <appId>",
Short: "List events for a serverless application",
Long: `List deploy, scaling, audit, and error events for an application.

Events are the control-plane audit trail, not worker stdout. Live log
streaming is not available (apps logs is not implemented).`,
Example: ` # list events for an application
runware serverless apps events my-app

# errors only
runware serverless apps events my-app --type error --limit 20

# page through results
runware serverless apps events my-app --type error --limit 20 --cursor <nextCursor>`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateListLimit(limit); err != nil {
return err
}
id := args[0]
typeVal, err := parseAppEventType(eventType)
if err != nil {
return err
}
var params *serverlessapi.ListAppEventsParams
if limit > 0 || cursor != "" || eventType != "" {
params = &serverlessapi.ListAppEventsParams{}
params.Limit, params.Cursor = listPageParams(limit, cursor)
params.Type = typeVal
}

spin := cmdutil.NewSpinner(fmt.Sprintf("Fetching events for %s...", id))
spin.Start()

client := serverlessapi.NewClient(config.GetAPIKey(), config.GetServerlessBaseURL(), slog.New(logger))
page, err := client.ListAppEvents(cmd.Context(), id, params)
if err != nil {
spin.Stop()
return err
}
spin.Stop()

return printPage(cmdutil.FormatFor(cmd), page, eventsResult(page.Data), cmd.ErrOrStderr(), extraTypeCursorFlag(eventType))
},
}

cmd.Flags().IntVar(&limit, "limit", 0, "Maximum number of events to return (1-100)")
cmd.Flags().StringVar(&cursor, "cursor", "", "Pagination cursor from a previous nextCursor")
cmd.Flags().StringVar(&eventType, "type", "", "Filter by type (deploy, scaling, audit, or error)")
return cmd
}

func newAppsWorkersCmd(logger *log.Logger) *cobra.Command {
var (
limit int
Expand Down Expand Up @@ -293,6 +354,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 parseAppEventType(value string) (*serverlessapi.AppEventType, error) {
return parseValidFlag[serverlessapi.AppEventType]("--type", value, "deploy, scaling, audit, or error")
}

// 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)
Expand All @@ -308,6 +373,10 @@ func extraStatusCursorFlag(value string) string {
return strings.Join(appendFlag(nil, "--status", value), " ")
}

func extraTypeCursorFlag(value string) string {
return strings.Join(appendFlag(nil, "--type", value), " ")
}

func appendFlag(parts []string, name, value string) []string {
if value == "" {
return parts
Expand Down
26 changes: 26 additions & 0 deletions internal/cmd/serverless/display.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const (
colEnvVar = "Env var"
colError = "Error"
colCompleted = "Completed"
colTime = "Time"
colMessage = "Message"
colWorker = "Worker"
colEndpoint = "Endpoint"

colComputeType = "Compute type"
colGPUType = "GPU type"
Expand Down Expand Up @@ -149,6 +153,28 @@ func (r versionResult) Rows() [][]any {
}
}

// eventsResult wraps app event lists for table display.
type eventsResult []serverlessapi.AppEvent

func (r eventsResult) Headers() []string {
return []string{colTime, colType, colMessage, colWorker, colEndpoint}
}

func (r eventsResult) Rows() [][]any {
rows := make([][]any, len(r))
for i := range r {
ev := &r[i]
rows[i] = []any{
formatOptionalTime(ev.CreatedAt),
string(ev.Type),
ev.Message,
formatOptionalUUID(ev.WorkerId),
formatOptionalUUID(ev.EndpointId),
}
}
return rows
}

// workersResult wraps worker lists for table display.
type workersResult []serverlessapi.Worker

Expand Down
Loading