Add Activity Type Filtering to ListActivities API

- Introduced a new `Type` field in the `ActivityQuery` struct to allow filtering activities by type (e.g., execution.started, execution.completed, execution.failed, execution.cancelled).
- Updated the `ListActivities` method in `ExecutionStore` to handle the new type filter, mapping it to corresponding execution statuses.
- Enhanced unit tests in `execution_test.go` to validate filtering by activity type, including tests for valid and invalid type scenarios.
- Modified OpenAPI definitions and related types to support the new type filter in the activities endpoint, improving API usability and flexibility.
This commit is contained in:
Max 2026-01-24 18:28:16 +08:00
parent 2f040196ec
commit 3ef536c1e7
7 changed files with 184 additions and 15 deletions

View file

@ -16,6 +16,7 @@ type ActivityQuery struct {
TeamID string `json:"team_id,omitempty"` // Filter by team ID
Limit int `json:"limit,omitempty"`
Since *time.Time `json:"since,omitempty"` // Only activities after this time
Type string `json:"type,omitempty"` // Filter by activity type: execution.started, execution.completed, execution.failed, execution.cancelled
}
// Activity - activity item for feed
@ -53,6 +54,11 @@ func ListActivities(ctx *types.Context, query *ActivityQuery) (*ActivityListResp
opts.TeamID = query.TeamID
}
// Pass type filter if provided
if query.Type != "" {
opts.Type = store.ActivityType(query.Type)
}
// Query from store
storeActivities, err := getExecutionStore().ListActivities(context.Background(), opts)
if err != nil {

View file

@ -897,9 +897,10 @@ type Activity struct {
// ActivityListOptions - options for listing activities
type ActivityListOptions struct {
TeamID string `json:"team_id,omitempty"` // Filter by team ID
Since *time.Time `json:"since,omitempty"` // Only activities after this time
Limit int `json:"limit,omitempty"`
TeamID string `json:"team_id,omitempty"` // Filter by team ID
Since *time.Time `json:"since,omitempty"` // Only activities after this time
Limit int `json:"limit,omitempty"`
Type ActivityType `json:"type,omitempty"` // Filter by activity type
}
// ListActivities derives activities from recent execution status changes
@ -912,13 +913,31 @@ func (s *ExecutionStore) ListActivities(ctx context.Context, opts *ActivityListO
// Build where conditions
var wheres []model.QueryWhere
// Only completed, failed, or cancelled executions generate activities
// For started activities, we'd need running status
wheres = append(wheres, model.QueryWhere{
Column: "status",
OP: "in",
Value: []string{"completed", "failed", "cancelled", "running"},
})
// Filter by activity type if specified
// Map activity types to execution statuses
if opts != nil && opts.Type != "" {
switch opts.Type {
case ActivityExecutionStarted:
wheres = append(wheres, model.QueryWhere{Column: "status", Value: "running"})
case ActivityExecutionCompleted:
wheres = append(wheres, model.QueryWhere{Column: "status", Value: "completed"})
case ActivityExecutionFailed:
wheres = append(wheres, model.QueryWhere{Column: "status", Value: "failed"})
case ActivityExecutionCancelled:
wheres = append(wheres, model.QueryWhere{Column: "status", Value: "cancelled"})
default:
// Unknown type, return empty
return []*Activity{}, nil
}
} else {
// Only completed, failed, or cancelled executions generate activities
// For started activities, we'd need running status
wheres = append(wheres, model.QueryWhere{
Column: "status",
OP: "in",
Value: []string{"completed", "failed", "cancelled", "running"},
})
}
if opts != nil {
if opts.TeamID != "" {

View file

@ -1221,6 +1221,45 @@ func TestExecutionStoreListActivities(t *testing.T) {
assert.Greater(t, typeCount[store.ActivityExecutionFailed], 0, "should have failed activities")
})
t.Run("filters_by_type_completed", func(t *testing.T) {
activities, err := s.ListActivities(ctx, &store.ActivityListOptions{
TeamID: "team_activity_001",
Type: store.ActivityExecutionCompleted,
})
require.NoError(t, err)
// All returned activities should be of type completed
for _, a := range activities {
assert.Equal(t, store.ActivityExecutionCompleted, a.Type, "all activities should be completed type")
}
assert.Greater(t, len(activities), 0, "should have at least one completed activity")
})
t.Run("filters_by_type_failed", func(t *testing.T) {
activities, err := s.ListActivities(ctx, &store.ActivityListOptions{
TeamID: "team_activity_001",
Type: store.ActivityExecutionFailed,
})
require.NoError(t, err)
// All returned activities should be of type failed
for _, a := range activities {
assert.Equal(t, store.ActivityExecutionFailed, a.Type, "all activities should be failed type")
}
assert.Greater(t, len(activities), 0, "should have at least one failed activity")
})
t.Run("filters_by_type_invalid_returns_empty", func(t *testing.T) {
activities, err := s.ListActivities(ctx, &store.ActivityListOptions{
TeamID: "team_activity_001",
Type: store.ActivityType("invalid.type"),
})
require.NoError(t, err)
// Invalid type should return empty result
assert.Equal(t, 0, len(activities), "invalid type should return empty result")
})
t.Run("includes_execution_name_in_message", func(t *testing.T) {
activities, err := s.ListActivities(ctx, &store.ActivityListOptions{
TeamID: "team_activity_001",

View file

@ -785,9 +785,14 @@ var uiMessages = map[string]map[string]string{
**OpenAPI Integration Tests:** `openapi/tests/agent/robot_results_activities_test.go`
- [x] `TestListResults` - test with filters, pagination, keyword search
- [x] `TestGetResult` - test single result detail
- [x] `TestListActivities` - test activity feed with `since` parameter
- [x] `TestListActivities` - test activity feed with `since` and `type` parameters
- [x] `TestResultsPermissions` - test permission checks
**Store Layer Unit Tests:** `agent/robot/store/execution_test.go`
- [x] `filters_by_type_completed` - test filtering by completed type
- [x] `filters_by_type_failed` - test filtering by failed type
- [x] `filters_by_type_invalid_returns_empty` - test invalid type returns empty
**Permissions:** ✅
- [x] Added to `yaobots/openapi/scopes/agent/robots.yml`
- [x] Added to `yaobots/openapi/scopes/alias.yml`
@ -834,9 +839,17 @@ var uiMessages = map[string]map[string]string{
- [x] Updated to use `ResultDetail` type from API
- [x] Displays delivery content (summary, body, attachments)
**Activity Feed:**
- [ ] Replace mock data with `listActivities()` API (TODO - not yet integrated in UI)
- [ ] Implement auto-refresh (polling or SSE later)
**Activity Feed:** ✅
- [x] Replace mock data with `listActivities()` API
- [x] Added `loadActivities()` function to fetch from API
- [x] Periodic refresh (30s polling, same as robots)
- [x] Updated Activity Banner to use API data format
- [x] Updated Activity Modal to use API data format
- [x] Added loading and empty states
- [x] Added `type` filter parameter to API (full stack: store → API → OpenAPI → SDK → UI)
- [x] Filter to show only `execution.completed` via API `type` param (not client-side)
- [x] Reset carousel index on data refresh (show latest activity first)
- [x] Click activity item to open result detail modal (overlays activity list)
**Error Handling UI:** ✅
- [x] Error state displays centered in content area (not in toolbar)
@ -848,7 +861,15 @@ var uiMessages = map[string]map[string]string{
- [x] Results display correctly with delivery content
- [x] Attachments show properly
- [x] Error state displays properly with retry option
- [ ] Activity feed updates in real-time (pending - Activity UI not yet connected)
- [x] Activity feed displays from API (30s polling refresh)
- [x] Activity item click opens result detail
---
### Future Enhancements (Not in current scope)
- [ ] Activity feed real-time updates via SSE/WebSocket
- [ ] Push notifications for new results
---

View file

@ -65,6 +65,7 @@ func ListActivities(c *gin.Context) {
query := &robotapi.ActivityQuery{
TeamID: teamID,
Limit: filter.Limit,
Type: filter.Type, // Pass type filter
}
// Parse 'since' if provided

View file

@ -548,6 +548,7 @@ func NewResultDetailResponse(detail *robotapi.ResultDetail) *ResultDetailRespons
type ActivityFilter struct {
Limit int `form:"limit"` // max number of activities
Since string `form:"since"` // ISO timestamp, only activities after this time
Type string `form:"type"` // activity type filter: execution.started, execution.completed, execution.failed, execution.cancelled
}
// ActivityResponse - activity item

View file

@ -301,6 +301,88 @@ func TestListActivities(t *testing.T) {
assert.Contains(t, response, "data")
})
t.Run("ListActivitiesWithTypeFilter", func(t *testing.T) {
// Test filtering by type: execution.completed
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/activities?type=execution.completed", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
assert.Contains(t, response, "data")
// Verify all returned activities are of the specified type
data, ok := response["data"].([]interface{})
if ok && len(data) > 0 {
for _, item := range data {
activity := item.(map[string]interface{})
assert.Equal(t, "execution.completed", activity["type"])
}
}
})
t.Run("ListActivitiesWithTypeStarted", func(t *testing.T) {
// Test filtering by type: execution.started
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/activities?type=execution.started", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
assert.Contains(t, response, "data")
// Verify all returned activities are of the specified type
data, ok := response["data"].([]interface{})
if ok && len(data) > 0 {
for _, item := range data {
activity := item.(map[string]interface{})
assert.Equal(t, "execution.started", activity["type"])
}
}
})
t.Run("ListActivitiesWithInvalidType", func(t *testing.T) {
// Test with an invalid/unknown type - should return empty data (not error)
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/activities?type=invalid.type", nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&response)
require.NoError(t, err)
assert.Contains(t, response, "data")
// Invalid type should return empty array
data, ok := response["data"].([]interface{})
assert.True(t, ok)
assert.Empty(t, data)
})
t.Run("ListActivitiesWithSince", func(t *testing.T) {
// Use a timestamp in the past - URL encode properly
since := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339)