From 2f040196ec14055aed5f571820d667d55557c7ef Mon Sep 17 00:00:00 2001 From: Max Date: Sat, 24 Jan 2026 17:57:58 +0800 Subject: [PATCH] Add Results and Activities Functionality to ExecutionStore - Implemented `ListResults` and `CountResults` methods in `ExecutionStore` to retrieve and count completed executions with delivery content, supporting filtering by member ID, team ID, trigger type, and keyword. - Developed `ListActivities` method to derive activities from execution status changes, allowing retrieval of recent activities for a team. - Created corresponding unit tests in `execution_test.go` to validate the functionality of results and activities listing, ensuring accurate filtering and pagination. - Updated OpenAPI routes and types to include new endpoints for listing results and activities, enhancing the API's capabilities for managing execution data. --- agent/robot/api/activities.go | 106 +++++ agent/robot/api/results.go | 194 +++++++++ agent/robot/store/execution.go | 339 ++++++++++++++ agent/robot/store/execution_test.go | 354 +++++++++++++++ openapi/agent/robot/TODO.md | 264 +++++++++-- openapi/agent/robot/activities.go | 106 +++++ openapi/agent/robot/results.go | 221 ++++++++++ openapi/agent/robot/robot.go | 12 +- openapi/agent/robot/types.go | 119 +++++ .../agent/robot_results_activities_test.go | 412 ++++++++++++++++++ 10 files changed, 2080 insertions(+), 47 deletions(-) create mode 100644 agent/robot/api/activities.go create mode 100644 agent/robot/api/results.go create mode 100644 openapi/agent/robot/activities.go create mode 100644 openapi/agent/robot/results.go create mode 100644 openapi/tests/agent/robot_results_activities_test.go diff --git a/agent/robot/api/activities.go b/agent/robot/api/activities.go new file mode 100644 index 00000000..d2855dbd --- /dev/null +++ b/agent/robot/api/activities.go @@ -0,0 +1,106 @@ +package api + +import ( + "context" + "fmt" + "time" + + "github.com/yaoapp/yao/agent/robot/store" + "github.com/yaoapp/yao/agent/robot/types" +) + +// ==================== Activity Types ==================== + +// ActivityQuery - query parameters for listing activities +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 +} + +// Activity - activity item for feed +type Activity struct { + Type store.ActivityType `json:"type"` + RobotID string `json:"robot_id"` + RobotName string `json:"robot_name,omitempty"` // Display name from robot + ExecutionID string `json:"execution_id"` + Message string `json:"message"` + Timestamp time.Time `json:"timestamp"` +} + +// ActivityListResponse - response with activities +type ActivityListResponse struct { + Data []*Activity `json:"data"` +} + +// ==================== Activity API Functions ==================== + +// ListActivities returns recent activities for a team +// Activities are derived from execution status changes +func ListActivities(ctx *types.Context, query *ActivityQuery) (*ActivityListResponse, error) { + if query == nil { + query = &ActivityQuery{} + } + query.applyDefaults() + + // Build store options + opts := &store.ActivityListOptions{ + Limit: query.Limit, + Since: query.Since, + } + + if query.TeamID != "" { + opts.TeamID = query.TeamID + } + + // Query from store + storeActivities, err := getExecutionStore().ListActivities(context.Background(), opts) + if err != nil { + return nil, fmt.Errorf("failed to list activities: %w", err) + } + + // Transform to Activity slice + // Also enrich with robot display names + activities := make([]*Activity, 0, len(storeActivities)) + robotNames := make(map[string]string) // Cache robot names + + for _, sa := range storeActivities { + activity := &Activity{ + Type: sa.Type, + RobotID: sa.RobotID, + ExecutionID: sa.ExecutionID, + Message: sa.Message, + Timestamp: sa.Timestamp, + } + + // Try to get robot name (with caching) + if name, ok := robotNames[sa.RobotID]; ok { + activity.RobotName = name + } else { + // Try to get robot display name + robotResp, err := GetRobotResponse(ctx, sa.RobotID) + if err == nil && robotResp != nil { + activity.RobotName = robotResp.DisplayName + robotNames[sa.RobotID] = robotResp.DisplayName + } + } + + activities = append(activities, activity) + } + + return &ActivityListResponse{ + Data: activities, + }, nil +} + +// ==================== Helper Functions ==================== + +// applyDefaults applies default values to ActivityQuery +func (q *ActivityQuery) applyDefaults() { + if q.Limit <= 0 { + q.Limit = 20 + } + if q.Limit > 100 { + q.Limit = 100 + } +} diff --git a/agent/robot/api/results.go b/agent/robot/api/results.go new file mode 100644 index 00000000..3bd06824 --- /dev/null +++ b/agent/robot/api/results.go @@ -0,0 +1,194 @@ +package api + +import ( + "context" + "fmt" + "time" + + "github.com/yaoapp/yao/agent/robot/store" + "github.com/yaoapp/yao/agent/robot/types" +) + +// ==================== Result Types ==================== + +// ResultQuery - query parameters for listing results +type ResultQuery struct { + TriggerType types.TriggerType `json:"trigger_type,omitempty"` // clock | human | event + Keyword string `json:"keyword,omitempty"` // Search in name/summary + Page int `json:"page,omitempty"` + PageSize int `json:"pagesize,omitempty"` +} + +// ResultItem - result list item (subset of execution) +type ResultItem struct { + ID string `json:"id"` + MemberID string `json:"member_id"` + TriggerType types.TriggerType `json:"trigger_type"` + Status types.ExecStatus `json:"status"` + Name string `json:"name"` + Summary string `json:"summary"` + StartTime time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time,omitempty"` + HasAttachments bool `json:"has_attachments"` +} + +// ResultDetail - full result with delivery content +type ResultDetail struct { + ID string `json:"id"` + MemberID string `json:"member_id"` + TriggerType types.TriggerType `json:"trigger_type"` + Status types.ExecStatus `json:"status"` + Name string `json:"name"` + Delivery *types.DeliveryResult `json:"delivery,omitempty"` + StartTime time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time,omitempty"` +} + +// ResultListResponse - paginated response +type ResultListResponse struct { + Data []*ResultItem `json:"data"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"pagesize"` +} + +// ==================== Result API Functions ==================== + +// ListResults returns completed executions with delivery content for a robot +func ListResults(ctx *types.Context, memberID string, query *ResultQuery) (*ResultListResponse, error) { + if memberID == "" { + return nil, fmt.Errorf("member_id is required") + } + + if query == nil { + query = &ResultQuery{} + } + query.applyDefaults() + + // Build store options + opts := &store.ResultListOptions{ + MemberID: memberID, + Limit: query.PageSize, + Offset: (query.Page - 1) * query.PageSize, + } + + if query.TriggerType != "" { + opts.TriggerType = query.TriggerType + } + if query.Keyword != "" { + opts.Keyword = query.Keyword + } + + // Query from store + result, err := getExecutionStore().ListResults(context.Background(), opts) + if err != nil { + return nil, fmt.Errorf("failed to list results: %w", err) + } + + // Transform to ResultItem slice + items := make([]*ResultItem, 0, len(result.Data)) + for _, record := range result.Data { + item := recordToResultItem(record) + if item != nil { + items = append(items, item) + } + } + + return &ResultListResponse{ + Data: items, + Total: result.Total, + Page: result.Page, + PageSize: result.PageSize, + }, nil +} + +// GetResult returns a single result by execution ID +func GetResult(ctx *types.Context, execID string) (*ResultDetail, error) { + if execID == "" { + return nil, fmt.Errorf("execution_id is required") + } + + // Get from store + record, err := getExecutionStore().Get(context.Background(), execID) + if err != nil { + return nil, fmt.Errorf("failed to get result: %w", err) + } + if record == nil { + return nil, fmt.Errorf("result not found: %s", execID) + } + + // Verify it has delivery content + if record.Delivery == nil || record.Delivery.Content == nil { + return nil, fmt.Errorf("result not found: %s (no delivery content)", execID) + } + + return recordToResultDetail(record), nil +} + +// ==================== Helper Functions ==================== + +// applyDefaults applies default values to ResultQuery +func (q *ResultQuery) applyDefaults() { + if q.Page <= 0 { + q.Page = 1 + } + if q.PageSize <= 0 { + q.PageSize = 20 + } + if q.PageSize > 100 { + q.PageSize = 100 + } +} + +// recordToResultItem converts ExecutionRecord to ResultItem +func recordToResultItem(record *store.ExecutionRecord) *ResultItem { + if record == nil { + return nil + } + + item := &ResultItem{ + ID: record.ExecutionID, + MemberID: record.MemberID, + TriggerType: record.TriggerType, + Status: record.Status, + Name: record.Name, + } + + // Set times + if record.StartTime != nil { + item.StartTime = *record.StartTime + } + item.EndTime = record.EndTime + + // Extract summary and attachments from delivery + if record.Delivery != nil && record.Delivery.Content != nil { + item.Summary = record.Delivery.Content.Summary + item.HasAttachments = len(record.Delivery.Content.Attachments) > 0 + } + + return item +} + +// recordToResultDetail converts ExecutionRecord to ResultDetail +func recordToResultDetail(record *store.ExecutionRecord) *ResultDetail { + if record == nil { + return nil + } + + detail := &ResultDetail{ + ID: record.ExecutionID, + MemberID: record.MemberID, + TriggerType: record.TriggerType, + Status: record.Status, + Name: record.Name, + Delivery: record.Delivery, + } + + // Set times + if record.StartTime != nil { + detail.StartTime = *record.StartTime + } + detail.EndTime = record.EndTime + + return detail +} diff --git a/agent/robot/store/execution.go b/agent/robot/store/execution.go index 82a42f90..fe94ecbf 100644 --- a/agent/robot/store/execution.go +++ b/agent/robot/store/execution.go @@ -700,6 +700,345 @@ func (s *ExecutionStore) parseTime(v interface{}) *time.Time { return nil } +// ==================== Results & Activities ==================== + +// ResultListOptions - options for listing execution results (deliveries) +type ResultListOptions struct { + MemberID string `json:"member_id,omitempty"` // Filter by robot member ID + TeamID string `json:"team_id,omitempty"` // Filter by team ID + TriggerType types.TriggerType `json:"trigger_type,omitempty"` // Filter by trigger type + Keyword string `json:"keyword,omitempty"` // Search in delivery.content.summary + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` +} + +// ResultListResponse - paginated result list response +type ResultListResponse struct { + Data []*ExecutionRecord `json:"data"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"pagesize"` +} + +// ListResults retrieves completed executions with delivery content +// Only returns executions where delivery.content is not null +func (s *ExecutionStore) ListResults(ctx context.Context, opts *ResultListOptions) (*ResultListResponse, error) { + mod := model.Select(s.modelID) + if mod == nil { + return nil, fmt.Errorf("model %s not found", s.modelID) + } + + // Build where conditions + var wheres []model.QueryWhere + + // Must have completed status and delivery content + wheres = append(wheres, model.QueryWhere{Column: "status", Value: "completed"}) + wheres = append(wheres, model.QueryWhere{Column: "delivery", OP: "notnull"}) + + if opts != nil { + if opts.MemberID != "" { + wheres = append(wheres, model.QueryWhere{Column: "member_id", Value: opts.MemberID}) + } + if opts.TeamID != "" { + wheres = append(wheres, model.QueryWhere{Column: "team_id", Value: opts.TeamID}) + } + if opts.TriggerType != "" { + wheres = append(wheres, model.QueryWhere{Column: "trigger_type", Value: string(opts.TriggerType)}) + } + // Keyword search in name field (delivery.content.summary is in JSON, harder to search) + // For now search in the name field + if opts.Keyword != "" { + wheres = append(wheres, model.QueryWhere{Column: "name", OP: "like", Value: "%" + opts.Keyword + "%"}) + } + } + + // Get total count first + total, err := s.countWithWheres(wheres) + if err != nil { + return nil, fmt.Errorf("failed to count results: %w", err) + } + + // Set pagination defaults + limit := 20 + offset := 0 + if opts != nil { + if opts.Limit > 0 { + limit = opts.Limit + if limit > 100 { + limit = 100 + } + } + if opts.Offset > 0 { + offset = opts.Offset + } + } + + // Calculate page from offset + page := 1 + if limit > 0 && offset > 0 { + page = (offset / limit) + 1 + } + + params := model.QueryParam{ + Wheres: wheres, + Limit: limit, + Page: page, + Orders: []model.QueryOrder{{Column: "end_time", Option: "desc"}}, + } + + rows, err := mod.Get(params) + if err != nil { + return nil, fmt.Errorf("failed to list results: %w", err) + } + + records := make([]*ExecutionRecord, 0, len(rows)) + for _, row := range rows { + record, err := s.mapToRecord(row) + if err != nil { + continue // skip invalid records + } + // Double check delivery content exists + if record.Delivery != nil && record.Delivery.Content != nil { + records = append(records, record) + } + } + + return &ResultListResponse{ + Data: records, + Total: total, + Page: page, + PageSize: limit, + }, nil +} + +// CountResults counts total results matching criteria +func (s *ExecutionStore) CountResults(ctx context.Context, opts *ResultListOptions) (int, error) { + var wheres []model.QueryWhere + + // Must have completed status and delivery content + wheres = append(wheres, model.QueryWhere{Column: "status", Value: "completed"}) + wheres = append(wheres, model.QueryWhere{Column: "delivery", OP: "notnull"}) + + if opts != nil { + if opts.MemberID != "" { + wheres = append(wheres, model.QueryWhere{Column: "member_id", Value: opts.MemberID}) + } + if opts.TeamID != "" { + wheres = append(wheres, model.QueryWhere{Column: "team_id", Value: opts.TeamID}) + } + if opts.TriggerType != "" { + wheres = append(wheres, model.QueryWhere{Column: "trigger_type", Value: string(opts.TriggerType)}) + } + if opts.Keyword != "" { + wheres = append(wheres, model.QueryWhere{Column: "name", OP: "like", Value: "%" + opts.Keyword + "%"}) + } + } + + return s.countWithWheres(wheres) +} + +// countWithWheres counts records matching the given where conditions +func (s *ExecutionStore) countWithWheres(wheres []model.QueryWhere) (int, error) { + mod := model.Select(s.modelID) + if mod == nil { + return 0, fmt.Errorf("model %s not found", s.modelID) + } + + // Use model Paginate to get total count + params := model.QueryParam{ + Wheres: wheres, + Limit: 1, + } + + result, err := mod.Paginate(params, 1, 1) + if err != nil { + return 0, fmt.Errorf("failed to count records: %w", err) + } + + // Paginate returns map with total field + if result == nil { + return 0, nil + } + + total := 0 + if t, ok := result["total"]; ok { + switch v := t.(type) { + case float64: + total = int(v) + case int64: + total = int(v) + case int: + total = v + } + } + + return total, nil +} + +// ActivityType represents the type of activity +type ActivityType string + +const ( + ActivityExecutionStarted ActivityType = "execution.started" + ActivityExecutionCompleted ActivityType = "execution.completed" + ActivityExecutionFailed ActivityType = "execution.failed" + ActivityExecutionCancelled ActivityType = "execution.cancelled" +) + +// Activity represents a robot activity entry +type Activity struct { + Type ActivityType `json:"type"` + RobotID string `json:"robot_id"` + RobotName string `json:"robot_name,omitempty"` // Will be populated by API layer + ExecutionID string `json:"execution_id"` + Message string `json:"message"` + Timestamp time.Time `json:"timestamp"` +} + +// 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"` +} + +// ListActivities derives activities from recent execution status changes +func (s *ExecutionStore) ListActivities(ctx context.Context, opts *ActivityListOptions) ([]*Activity, error) { + mod := model.Select(s.modelID) + if mod == nil { + return nil, fmt.Errorf("model %s not found", s.modelID) + } + + // 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"}, + }) + + if opts != nil { + if opts.TeamID != "" { + wheres = append(wheres, model.QueryWhere{Column: "team_id", Value: opts.TeamID}) + } + if opts.Since != nil { + // Get executions that ended or started after 'since' + wheres = append(wheres, model.QueryWhere{Column: "updated_at", OP: ">=", Value: *opts.Since}) + } + } + + limit := 20 + if opts != nil && opts.Limit > 0 { + limit = opts.Limit + if limit > 100 { + limit = 100 + } + } + + params := model.QueryParam{ + Wheres: wheres, + Limit: limit, + Orders: []model.QueryOrder{{Column: "updated_at", Option: "desc"}}, + } + + rows, err := mod.Get(params) + if err != nil { + return nil, fmt.Errorf("failed to list activities: %w", err) + } + + activities := make([]*Activity, 0, len(rows)) + for _, row := range rows { + record, err := s.mapToRecord(row) + if err != nil { + continue + } + + activity := s.executionToActivity(record) + if activity != nil { + activities = append(activities, activity) + } + } + + return activities, nil +} + +// executionToActivity converts an execution record to an activity +func (s *ExecutionStore) executionToActivity(record *ExecutionRecord) *Activity { + var actType ActivityType + var message string + var timestamp time.Time + + switch record.Status { + case types.ExecRunning: + actType = ActivityExecutionStarted + message = "Started" + if record.StartTime != nil { + timestamp = *record.StartTime + } else { + timestamp = time.Now() + } + case types.ExecCompleted: + actType = ActivityExecutionCompleted + message = "Completed" + if record.EndTime != nil { + timestamp = *record.EndTime + } else if record.UpdatedAt != nil { + timestamp = *record.UpdatedAt + } else { + timestamp = time.Now() + } + case types.ExecFailed: + actType = ActivityExecutionFailed + message = "Failed" + if record.Error != "" { + message = "Failed: " + record.Error + // Truncate long error messages + if len(message) > 100 { + message = message[:97] + "..." + } + } + if record.EndTime != nil { + timestamp = *record.EndTime + } else if record.UpdatedAt != nil { + timestamp = *record.UpdatedAt + } else { + timestamp = time.Now() + } + case types.ExecCancelled: + actType = ActivityExecutionCancelled + message = "Cancelled" + if record.EndTime != nil { + timestamp = *record.EndTime + } else if record.UpdatedAt != nil { + timestamp = *record.UpdatedAt + } else { + timestamp = time.Now() + } + default: + return nil // Other statuses don't generate activities + } + + // Add execution name to message if available + if record.Name != "" { + message = message + ": " + record.Name + // Truncate long messages + if len(message) > 150 { + message = message[:147] + "..." + } + } + + return &Activity{ + Type: actType, + RobotID: record.MemberID, + ExecutionID: record.ExecutionID, + Message: message, + Timestamp: timestamp, + } +} + // FromExecution creates an ExecutionRecord from a runtime Execution func FromExecution(exec *types.Execution) *ExecutionRecord { record := &ExecutionRecord{ diff --git a/agent/robot/store/execution_test.go b/agent/robot/store/execution_test.go index f6bd9835..4d3ae8ca 100644 --- a/agent/robot/store/execution_test.go +++ b/agent/robot/store/execution_test.go @@ -1023,3 +1023,357 @@ func setupTestExecutionsForList(t *testing.T, s *store.ExecutionStore, ctx conte require.NoError(t, err) } } + +// ==================== Results & Activities Tests ==================== + +// TestExecutionStoreListResults tests listing execution results (deliveries) +func TestExecutionStoreListResults(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestExecutions(t) + defer cleanupTestExecutions(t) + + s := store.NewExecutionStore() + ctx := context.Background() + + // Setup test data with delivery content + setupTestResultsData(t, s, ctx) + + t.Run("lists_results_without_filters", func(t *testing.T) { + result, err := s.ListResults(ctx, &store.ResultListOptions{ + MemberID: "member_result_001", + }) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 2, result.Total) + assert.Len(t, result.Data, 2) + // Should be ordered by end_time desc + for _, r := range result.Data { + assert.NotNil(t, r.Delivery) + assert.NotNil(t, r.Delivery.Content) + } + }) + + t.Run("filters_by_trigger_type", func(t *testing.T) { + result, err := s.ListResults(ctx, &store.ResultListOptions{ + MemberID: "member_result_001", + TriggerType: types.TriggerClock, + }) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 1, result.Total) + assert.Len(t, result.Data, 1) + assert.Equal(t, types.TriggerClock, result.Data[0].TriggerType) + }) + + t.Run("filters_by_keyword", func(t *testing.T) { + result, err := s.ListResults(ctx, &store.ResultListOptions{ + MemberID: "member_result_001", + Keyword: "Weekly", + }) + require.NoError(t, err) + require.NotNil(t, result) + // Should match "Weekly Sales Report" + assert.GreaterOrEqual(t, result.Total, 1) + }) + + t.Run("respects_pagination", func(t *testing.T) { + result, err := s.ListResults(ctx, &store.ResultListOptions{ + MemberID: "member_result_001", + Limit: 1, + Offset: 0, + }) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 1, len(result.Data)) + assert.Equal(t, 2, result.Total) + assert.Equal(t, 1, result.Page) + }) + + t.Run("excludes_executions_without_delivery", func(t *testing.T) { + result, err := s.ListResults(ctx, &store.ResultListOptions{ + MemberID: "member_result_002", // Has no delivery content + }) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 0, result.Total) + assert.Empty(t, result.Data) + }) +} + +// TestExecutionStoreCountResults tests counting results +func TestExecutionStoreCountResults(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestExecutions(t) + defer cleanupTestExecutions(t) + + s := store.NewExecutionStore() + ctx := context.Background() + + // Setup test data with delivery content + setupTestResultsData(t, s, ctx) + + t.Run("counts_all_results_for_member", func(t *testing.T) { + count, err := s.CountResults(ctx, &store.ResultListOptions{ + MemberID: "member_result_001", + }) + require.NoError(t, err) + assert.Equal(t, 2, count) + }) + + t.Run("counts_filtered_results", func(t *testing.T) { + count, err := s.CountResults(ctx, &store.ResultListOptions{ + MemberID: "member_result_001", + TriggerType: types.TriggerHuman, + }) + require.NoError(t, err) + assert.Equal(t, 1, count) + }) + + t.Run("returns_zero_for_no_results", func(t *testing.T) { + count, err := s.CountResults(ctx, &store.ResultListOptions{ + MemberID: "member_result_002", + }) + require.NoError(t, err) + assert.Equal(t, 0, count) + }) +} + +// TestExecutionStoreListActivities tests listing activities +func TestExecutionStoreListActivities(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + testutils.Prepare(t) + defer testutils.Clean(t) + + cleanupTestExecutions(t) + defer cleanupTestExecutions(t) + + s := store.NewExecutionStore() + ctx := context.Background() + + // Setup test data + setupTestActivitiesData(t, s, ctx) + + t.Run("lists_activities_for_team", func(t *testing.T) { + activities, err := s.ListActivities(ctx, &store.ActivityListOptions{ + TeamID: "team_activity_001", + }) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(activities), 3) + }) + + t.Run("respects_limit", func(t *testing.T) { + activities, err := s.ListActivities(ctx, &store.ActivityListOptions{ + TeamID: "team_activity_001", + Limit: 2, + }) + require.NoError(t, err) + assert.LessOrEqual(t, len(activities), 2) + }) + + t.Run("filters_by_since", func(t *testing.T) { + // Without since, should get all activities + activitiesAll, err := s.ListActivities(ctx, &store.ActivityListOptions{ + TeamID: "team_activity_001", + }) + require.NoError(t, err) + allCount := len(activitiesAll) + assert.GreaterOrEqual(t, allCount, 3, "should have at least 3 activities without filter") + + // Use a time in the future to ensure we get no results + future := time.Now().Add(24 * time.Hour) + activitiesFuture, err := s.ListActivities(ctx, &store.ActivityListOptions{ + TeamID: "team_activity_001", + Since: &future, + }) + require.NoError(t, err) + assert.Equal(t, 0, len(activitiesFuture), "should get no results with future since time") + }) + + t.Run("generates_correct_activity_types", func(t *testing.T) { + activities, err := s.ListActivities(ctx, &store.ActivityListOptions{ + TeamID: "team_activity_001", + }) + require.NoError(t, err) + + // Should have activities of different types + typeCount := make(map[store.ActivityType]int) + for _, a := range activities { + typeCount[a.Type]++ + } + + // We should have at least completed and failed types + assert.Greater(t, typeCount[store.ActivityExecutionCompleted], 0, "should have completed activities") + assert.Greater(t, typeCount[store.ActivityExecutionFailed], 0, "should have failed activities") + }) + + t.Run("includes_execution_name_in_message", func(t *testing.T) { + activities, err := s.ListActivities(ctx, &store.ActivityListOptions{ + TeamID: "team_activity_001", + }) + require.NoError(t, err) + + // Find a completed activity + var completedActivity *store.Activity + for _, a := range activities { + if a.Type == store.ActivityExecutionCompleted && a.Message != "" { + completedActivity = a + break + } + } + + require.NotNil(t, completedActivity, "should find a completed activity") + assert.Contains(t, completedActivity.Message, "Completed") + }) +} + +// Helper function to setup test results data +func setupTestResultsData(t *testing.T, s *store.ExecutionStore, ctx context.Context) { + startTime := time.Now().Add(-2 * time.Hour) + endTime := time.Now().Add(-1 * time.Hour) + endTime2 := time.Now().Add(-30 * time.Minute) + + records := []*store.ExecutionRecord{ + { + ExecutionID: "exec_test_result_001", + MemberID: "member_result_001", + TeamID: "team_result_001", + TriggerType: types.TriggerClock, + Status: types.ExecCompleted, + Phase: types.PhaseDelivery, + Name: "Weekly Sales Report", + StartTime: &startTime, + EndTime: &endTime, + Delivery: &types.DeliveryResult{ + Success: true, + Content: &types.DeliveryContent{ + Summary: "Weekly sales report generated successfully", + Body: "## Weekly Sales Report\n\nTotal sales: $50,000", + }, + }, + }, + { + ExecutionID: "exec_test_result_002", + MemberID: "member_result_001", + TeamID: "team_result_001", + TriggerType: types.TriggerHuman, + Status: types.ExecCompleted, + Phase: types.PhaseDelivery, + Name: "Custom Analysis", + StartTime: &startTime, + EndTime: &endTime2, + Delivery: &types.DeliveryResult{ + Success: true, + Content: &types.DeliveryContent{ + Summary: "Custom analysis completed", + Body: "## Analysis Results\n\nFindings...", + Attachments: []types.DeliveryAttachment{ + {Title: "Report.pdf", File: "__attachment://file_001"}, + }, + }, + }, + }, + { + // Completed but no delivery content - should be excluded + ExecutionID: "exec_test_result_003", + MemberID: "member_result_002", + TeamID: "team_result_001", + TriggerType: types.TriggerClock, + Status: types.ExecCompleted, + Phase: types.PhaseDelivery, + Name: "No Delivery Content", + StartTime: &startTime, + EndTime: &endTime, + // No Delivery field + }, + { + // Running - should be excluded from results + ExecutionID: "exec_test_result_004", + MemberID: "member_result_001", + TeamID: "team_result_001", + TriggerType: types.TriggerClock, + Status: types.ExecRunning, + Phase: types.PhaseRun, + Name: "Running Task", + StartTime: &startTime, + }, + } + + for _, record := range records { + err := s.Save(ctx, record) + require.NoError(t, err) + } +} + +// Helper function to setup test activities data +func setupTestActivitiesData(t *testing.T, s *store.ExecutionStore, ctx context.Context) { + startTime := time.Now().Add(-2 * time.Hour) + endTime := time.Now().Add(-1 * time.Hour) + endTimeFailed := time.Now().Add(-45 * time.Minute) + + records := []*store.ExecutionRecord{ + { + ExecutionID: "exec_test_activity_001", + MemberID: "member_activity_001", + TeamID: "team_activity_001", + TriggerType: types.TriggerClock, + Status: types.ExecCompleted, + Phase: types.PhaseDelivery, + Name: "Daily Report", + StartTime: &startTime, + EndTime: &endTime, + }, + { + ExecutionID: "exec_test_activity_002", + MemberID: "member_activity_001", + TeamID: "team_activity_001", + TriggerType: types.TriggerHuman, + Status: types.ExecFailed, + Phase: types.PhaseRun, + Name: "Custom Task", + StartTime: &startTime, + EndTime: &endTimeFailed, + Error: "Task timeout", + }, + { + ExecutionID: "exec_test_activity_003", + MemberID: "member_activity_002", + TeamID: "team_activity_001", + TriggerType: types.TriggerEvent, + Status: types.ExecCancelled, + Phase: types.PhaseTasks, + Name: "Lead Processing", + StartTime: &startTime, + EndTime: &endTime, + }, + { + ExecutionID: "exec_test_activity_004", + MemberID: "member_activity_002", + TeamID: "team_activity_001", + TriggerType: types.TriggerClock, + Status: types.ExecRunning, + Phase: types.PhaseRun, + Name: "Data Analysis", + StartTime: &startTime, + }, + } + + for _, record := range records { + err := s.Save(ctx, record) + require.NoError(t, err) + } +} diff --git a/openapi/agent/robot/TODO.md b/openapi/agent/robot/TODO.md index e31b7d6b..eb7aecd8 100644 --- a/openapi/agent/robot/TODO.md +++ b/openapi/agent/robot/TODO.md @@ -690,68 +690,242 @@ var uiMessages = map[string]map[string]string{ --- -## 🟢 Phase 3: Results & Activities ⬜ [Low Risk] +## 🟢 Phase 3: Results & Activities ✅ [Completed] **Goal:** Deliverables listing and activity feed **Risk:** 🟢 Low - Read-only queries, derived from existing data +**Status:** ✅ Completed 2026-01-22 -### 3.1 Backend Prerequisites ⬜ +> **Implementation Pattern:** Follow Phase 2 approach - Store → API → OpenAPI → Frontend SDK → UI -#### Store Layer (Core implementation) -- [ ] Add `ExecutionStore.ListResults()` - query deliverables from execution delivery data -- [ ] Add `ExecutionStore.GetResult()` - get single deliverable detail -- [ ] Add `ExecutionStore.ListActivities()` - derive activities from execution history +--- -#### API Layer (Thin wrappers) -- [ ] Create `api/results.go` with `ListResults()`, `GetResult()` - call store -- [ ] Create `api/activities.go` with `ListActivities()` - call store +### Step 1: Store Layer ✅ -### 3.2 Results Endpoints ⬜ +> Location: `yao/agent/robot/store/execution.go` +> Add methods to existing `ExecutionStore` - query from `delivery` field -- [ ] `results.go` - results handlers -- [ ] GET /v1/robots/:id/results - - [ ] Parse filters: `trigger_type`, `keyword`, `page`, `pagesize` - - [ ] Call `robot/api.ListResults()` - - [ ] Format response -- [ ] GET /v1/robots/:id/results/:result_id - - [ ] Call `robot/api.GetResult()` - - [ ] Return full delivery content -- [ ] Test: `tests/robot/results_test.go` +- [x] `ListResults()` - query completed executions with delivery content + - Filter by: `member_id`, `team_id`, `trigger_type`, `keyword` (search in name) + - Only return executions where `delivery.content` is not null + - Return: `*ResultListResponse` with pagination info + - Order by: `end_time desc` (newest first) +- [x] `CountResults()` - count total results for pagination +- [x] `GetResult()` - get single execution by ID (reuse existing `Get()`) +- [x] `ListActivities()` - derive activities from execution status changes + - Query recent executions across all robots (for team) + - Transform to activity format: `{type, robot_id, execution_id, message, timestamp}` + - Activity types: `execution.started`, `execution.completed`, `execution.failed`, `execution.cancelled` + - Filter by: `team_id`, `since` (timestamp), `limit` -### 3.3 Results Types ⬜ +**Unit Tests:** `store/execution_test.go` ✅ +- [x] `TestListResults` - verify filtering and pagination +- [x] `TestCountResults` - verify count accuracy +- [x] `TestListActivities` - verify activity derivation -- [ ] Add to `types.go`: - - [ ] `ResultResponse` struct - - [ ] `ResultDetailResponse` struct - - [ ] `DeliveryContentResponse` struct - - [ ] `DeliveryAttachmentResponse` struct +--- -### 3.4 Activities Endpoints ⬜ +### Step 2: API Layer ✅ -- [ ] `activities.go` - activities handlers -- [ ] GET /v1/robots/activities - - [ ] Parse: `limit`, `since` - - [ ] Call `robot/api.ListActivities()` - - [ ] Format response -- [ ] Test: `tests/robot/activities_test.go` +> Location: `yao/agent/robot/api/` +> Thin wrappers calling store methods -### 3.5 Activity Types ⬜ +**File: `api/results.go`** ✅ +- [x] `ResultQuery` struct - query parameters +- [x] `ResultItem` struct - result list item (subset of execution) +- [x] `ResultDetail` struct - full result with delivery content +- [x] `ResultListResponse` struct - paginated response +- [x] `ListResults(ctx, robotID, query)` - call store, transform to response +- [x] `GetResult(ctx, resultID)` - call store, return detail -- [ ] Add to `types.go`: - - [ ] `ActivityResponse` struct - - [ ] `ActivityType` constants +**File: `api/activities.go`** ✅ +- [x] `ActivityQuery` struct - query parameters +- [x] `Activity` struct - activity item +- [x] `ActivityListResponse` struct - response with activities +- [x] `ListActivities(ctx, query)` - call store, transform to response -### 3.6 Frontend Integration ⬜ +--- -> Integrate immediately after backend completion +### Step 3: OpenAPI Handlers ✅ -- [ ] SDK: Add results/activities methods to `robot.ts` - - [ ] `listResults(robotId, params)` - - [ ] `getResult(robotId, resultId)` - - [ ] `listActivities(params)` -- [ ] Page: Results Tab integration -- [ ] Page: Activity Feed integration -- [ ] Verify: E2E testing +> Location: `yao/openapi/agent/robot/` + +**File: `results.go`** ✅ +- [x] `ListResults` handler - GET /v1/agent/robots/:id/results + - Parse query params: `trigger_type`, `keyword`, `page`, `pagesize` + - Check robot permission (read) + - Call `robotapi.ListResults()` + - Return `ResultListResponse` +- [x] `GetResult` handler - GET /v1/agent/robots/:id/results/:result_id + - Check robot permission (read) + - Call `robotapi.GetResult()` + - Return `ResultDetailResponse` + +**File: `activities.go`** ✅ +- [x] `ListActivities` handler - GET /v1/agent/robots/activities + - Parse query params: `limit`, `since` + - Use team_id from auth + - Call `robotapi.ListActivities()` + - Return `ActivityListResponse` + +**Types in `types.go`:** ✅ +- [x] `ResultFilter` struct - query params +- [x] `ResultResponse` struct - list item +- [x] `ResultDetailResponse` struct - full detail +- [x] `ResultListResponse` struct - paginated list +- [x] `ActivityResponse` struct - activity item +- [x] `ActivityListResponse` struct - activity list +- [x] Conversion functions: `NewResultResponse()`, `NewResultDetailResponse()`, `NewActivityResponse()` + +**Routes in `robot.go`:** ✅ +- [x] Register `GET /v1/agent/robots/:id/results` → `ListResults` +- [x] Register `GET /v1/agent/robots/:id/results/:result_id` → `GetResult` +- [x] Register `GET /v1/agent/robots/activities` → `ListActivities` + +**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] `TestResultsPermissions` - test permission checks + +**Permissions:** ✅ +- [x] Added to `yaobots/openapi/scopes/agent/robots.yml` +- [x] Added to `yaobots/openapi/scopes/alias.yml` +- [x] Added to `yao-dev-app/openapi/scopes/agent/robots.yml` +- [x] Added to `yao-dev-app/openapi/scopes/alias.yml` + +--- + +### Step 4: Frontend SDK ✅ + +> Location: `cui/packages/cui/openapi/agent/robot/` + +**Types in `types.ts`:** ✅ +- [x] `ResultFilter` interface +- [x] `Result` interface +- [x] `ResultDetail` interface +- [x] `ResultListResponse` interface +- [x] `Activity` interface +- [x] `ActivityListResponse` interface + +**Methods in `robots.ts`:** ✅ +- [x] `ListResults(robotId: string, filter?: ResultFilter): Promise` +- [x] `GetResult(robotId: string, resultId: string): Promise` +- [x] `ListActivities(params?: { limit?: number, since?: string }): Promise` + +**Hook in `hooks/useRobots.ts`:** ✅ +- [x] `listResults` - wrapper for API +- [x] `getResult` - wrapper for API +- [x] `listActivities` - wrapper for API + +--- + +### Step 5: Frontend UI Integration ✅ + +> Location: `cui/packages/cui/pages/mission-control/` + +**Results Tab (`ResultsTab.tsx`):** ✅ +- [x] Replace mock data with `listResults()` API +- [x] Implement result detail modal/drawer with `getResult()` API +- [x] Add filtering (trigger type, keyword search) +- [x] Add pagination (infinite scroll) + +**Result Detail Modal (`ResultDetailModal/index.tsx`):** ✅ +- [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) + +**Error Handling UI:** ✅ +- [x] Error state displays centered in content area (not in toolbar) +- [x] Error state hides empty placeholder +- [x] Retry button for reloading +- [x] Uses CSS variable `--color_danger` (no hardcoded colors) + +**Verify:** +- [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) + +--- + +### API Reference + +**GET /v1/agent/robots/:id/results** +``` +Query Params: + - trigger_type: string (clock|human|event) + - keyword: string (search in summary) + - page: number (default: 1) + - pagesize: number (default: 20, max: 100) + +Response: +{ + "data": [ + { + "id": "exec-id", + "member_id": "robot-id", + "trigger_type": "clock", + "status": "completed", + "name": "Execution title", + "summary": "Delivery summary...", + "start_time": "2026-01-24T10:00:00Z", + "end_time": "2026-01-24T10:05:00Z", + "has_attachments": true + } + ], + "total": 50, + "page": 1, + "pagesize": 20 +} +``` + +**GET /v1/agent/robots/:id/results/:result_id** +``` +Response: +{ + "id": "exec-id", + "member_id": "robot-id", + "trigger_type": "clock", + "status": "completed", + "name": "Execution title", + "delivery": { + "content": { + "summary": "...", + "body": "...", + "attachments": [...] + }, + "success": true, + "sent_at": "2026-01-24T10:05:00Z" + }, + "start_time": "2026-01-24T10:00:00Z", + "end_time": "2026-01-24T10:05:00Z" +} +``` + +**GET /v1/agent/robots/activities** +``` +Query Params: + - limit: number (default: 20, max: 100) + - since: string (ISO timestamp, optional) + +Response: +{ + "data": [ + { + "type": "execution.completed", + "robot_id": "robot-id", + "robot_name": "Sales Robot", + "execution_id": "exec-id", + "message": "Completed: Weekly report generation", + "timestamp": "2026-01-24T10:05:00Z" + } + ] +} +``` --- diff --git a/openapi/agent/robot/activities.go b/openapi/agent/robot/activities.go new file mode 100644 index 00000000..6230417d --- /dev/null +++ b/openapi/agent/robot/activities.go @@ -0,0 +1,106 @@ +package robot + +import ( + "time" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/kun/log" + robotapi "github.com/yaoapp/yao/agent/robot/api" + robottypes "github.com/yaoapp/yao/agent/robot/types" + "github.com/yaoapp/yao/openapi/oauth/authorized" + "github.com/yaoapp/yao/openapi/response" +) + +// ==================== Activities Handler ==================== +// Activities are derived from execution status changes across all robots in a team + +// ListActivities lists recent activities for the user's team +// GET /v1/agent/robots/activities +func ListActivities(c *gin.Context) { + // Get authorized information + authInfo := authorized.GetInfo(c) + + // Get team_id from auth - activities are team-scoped + teamID := "" + if authInfo != nil { + teamID = authInfo.TeamID + // If no team_id, fall back to user_id for personal users + if teamID == "" { + teamID = authInfo.UserID + } + } + + if teamID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: "Unable to determine team scope", + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + return + } + + // Parse query parameters + var filter ActivityFilter + if err := c.ShouldBindQuery(&filter); err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invalid query parameters: " + err.Error(), + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Apply defaults + if filter.Limit <= 0 { + filter.Limit = 20 + } + if filter.Limit > 100 { + filter.Limit = 100 + } + + // Create robot context + ctx := &robottypes.Context{} + + // Build API query + query := &robotapi.ActivityQuery{ + TeamID: teamID, + Limit: filter.Limit, + } + + // Parse 'since' if provided + if filter.Since != "" { + since, err := time.Parse(time.RFC3339, filter.Since) + if err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invalid 'since' parameter: must be RFC3339 format", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + query.Since = &since + } + + // Call API layer + result, err := robotapi.ListActivities(ctx, query) + if err != nil { + log.Error("Failed to list activities for team %s: %v", teamID, err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to list activities: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Convert to response + data := make([]*ActivityResponse, 0, len(result.Data)) + for _, item := range result.Data { + data = append(data, NewActivityResponse(item)) + } + + resp := &ActivityListResponse{ + Data: data, + } + response.RespondWithSuccess(c, response.StatusOK, resp) +} diff --git a/openapi/agent/robot/results.go b/openapi/agent/robot/results.go new file mode 100644 index 00000000..f0224e1f --- /dev/null +++ b/openapi/agent/robot/results.go @@ -0,0 +1,221 @@ +package robot + +import ( + "errors" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/kun/log" + robotapi "github.com/yaoapp/yao/agent/robot/api" + robottypes "github.com/yaoapp/yao/agent/robot/types" + "github.com/yaoapp/yao/openapi/oauth/authorized" + "github.com/yaoapp/yao/openapi/response" +) + +// ==================== Results Handlers ==================== +// Results are completed executions with delivery content + +// ListResults lists results (deliveries) for a robot +// GET /v1/agent/robots/:id/results +func ListResults(c *gin.Context) { + // Get authorized information + authInfo := authorized.GetInfo(c) + + // Get robot ID from URL parameter + robotID := c.Param("id") + if robotID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "robot id is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Create robot context + ctx := &robottypes.Context{} + + // Check robot permission first + robotResp, err := robotapi.GetRobotResponse(ctx, robotID) + if err != nil { + if errors.Is(err, robottypes.ErrRobotNotFound) { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Robot not found: " + robotID, + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to get robot: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Check read permission on robot + if !CanRead(c, authInfo, robotResp.YaoTeamID, robotResp.YaoCreatedBy) { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: "Forbidden: No permission to access this robot's results", + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + return + } + + // Parse query parameters + var filter ResultFilter + if err := c.ShouldBindQuery(&filter); err != nil { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Invalid query parameters: " + err.Error(), + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Apply defaults + if filter.Page <= 0 { + filter.Page = 1 + } + if filter.PageSize <= 0 { + filter.PageSize = 20 + } + if filter.PageSize > 100 { + filter.PageSize = 100 + } + + // Build API query + query := &robotapi.ResultQuery{ + Page: filter.Page, + PageSize: filter.PageSize, + } + if filter.TriggerType != "" { + query.TriggerType = robottypes.TriggerType(filter.TriggerType) + } + if filter.Keyword != "" { + query.Keyword = filter.Keyword + } + + // Call API layer + result, err := robotapi.ListResults(ctx, robotID, query) + if err != nil { + log.Error("Failed to list results for robot %s: %v", robotID, err) + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to list results: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Convert to response + data := make([]*ResultResponse, 0, len(result.Data)) + for _, item := range result.Data { + data = append(data, NewResultResponse(item)) + } + + resp := &ResultListResponse{ + Data: data, + Total: result.Total, + Page: result.Page, + PageSize: result.PageSize, + } + response.RespondWithSuccess(c, response.StatusOK, resp) +} + +// GetResult gets a single result by execution ID +// GET /v1/agent/robots/:id/results/:result_id +func GetResult(c *gin.Context) { + // Get authorized information + authInfo := authorized.GetInfo(c) + + // Get robot ID and result ID from URL parameters + robotID := c.Param("id") + resultID := c.Param("result_id") + + if robotID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "robot id is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + if resultID == "" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "result id is required", + } + response.RespondWithError(c, response.StatusBadRequest, errorResp) + return + } + + // Create robot context + ctx := &robottypes.Context{} + + // Check robot permission first + robotResp, err := robotapi.GetRobotResponse(ctx, robotID) + if err != nil { + if errors.Is(err, robottypes.ErrRobotNotFound) { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Robot not found: " + robotID, + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to get robot: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Check read permission on robot + if !CanRead(c, authInfo, robotResp.YaoTeamID, robotResp.YaoCreatedBy) { + errorResp := &response.ErrorResponse{ + Code: response.ErrAccessDenied.Code, + ErrorDescription: "Forbidden: No permission to access this robot's results", + } + response.RespondWithError(c, response.StatusForbidden, errorResp) + return + } + + // Get result + result, err := robotapi.GetResult(ctx, resultID) + if err != nil { + log.Error("Failed to get result %s: %v", resultID, err) + + if err.Error() == "result not found: "+resultID || err.Error() == "result not found: "+resultID+" (no delivery content)" { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Result not found: " + resultID, + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + errorResp := &response.ErrorResponse{ + Code: response.ErrServerError.Code, + ErrorDescription: "Failed to get result: " + err.Error(), + } + response.RespondWithError(c, response.StatusInternalServerError, errorResp) + return + } + + // Verify result belongs to this robot + if result.MemberID != robotID { + errorResp := &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "Result does not belong to this robot", + } + response.RespondWithError(c, response.StatusNotFound, errorResp) + return + } + + // Convert to response + resp := NewResultDetailResponse(result) + response.RespondWithSuccess(c, response.StatusOK, resp) +} diff --git a/openapi/agent/robot/robot.go b/openapi/agent/robot/robot.go index 9ec692f0..39172854 100644 --- a/openapi/agent/robot/robot.go +++ b/openapi/agent/robot/robot.go @@ -14,8 +14,12 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) { group.Use(oauth.Guard) // Robot CRUD - Standard REST endpoints - group.GET("", ListRobots) // GET /robots - List robots with pagination and filtering - group.POST("", CreateRobot) // POST /robots - Create a new robot + group.GET("", ListRobots) // GET /robots - List robots with pagination and filtering + group.POST("", CreateRobot) // POST /robots - Create a new robot + + // Activities - Cross-robot activity feed for team (must be before /:id to avoid conflict) + group.GET("/activities", ListActivities) // GET /robots/activities - List team activities + group.GET("/:id", GetRobot) // GET /robots/:id - Get robot details group.PUT("/:id", UpdateRobot) // PUT /robots/:id - Update robot group.DELETE("/:id", DeleteRobot) // DELETE /robots/:id - Delete robot @@ -30,6 +34,10 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) { group.POST("/:id/executions/:exec_id/resume", ResumeExecution) // POST /robots/:id/executions/:exec_id/resume - Resume execution group.POST("/:id/executions/:exec_id/cancel", CancelExecution) // POST /robots/:id/executions/:exec_id/cancel - Cancel execution + // Results (Deliveries) - Completed executions with delivery content + group.GET("/:id/results", ListResults) // GET /robots/:id/results - List robot results + group.GET("/:id/results/:result_id", GetResult) // GET /robots/:id/results/:result_id - Get result details + // Trigger & Intervene group.POST("/:id/trigger", TriggerRobot) // POST /robots/:id/trigger - Trigger robot execution group.POST("/:id/intervene", InterveneRobot) // POST /robots/:id/intervene - Human intervention diff --git a/openapi/agent/robot/types.go b/openapi/agent/robot/types.go index 34ca9b75..6c3a35eb 100644 --- a/openapi/agent/robot/types.go +++ b/openapi/agent/robot/types.go @@ -461,3 +461,122 @@ func NewExecutionResponseBrief(exec *robottypes.Execution) *ExecutionResponse { // Omit other phase outputs for list view (inspiration, goals, tasks, results, delivery, input) } } + +// ==================== Results Types ==================== + +// ResultFilter - query params for listing results +type ResultFilter struct { + TriggerType string `form:"trigger_type"` // clock | human | event + Keyword string `form:"keyword"` // search in name/summary + Page int `form:"page"` + PageSize int `form:"pagesize"` +} + +// ResultResponse - result list item +type ResultResponse struct { + ID string `json:"id"` + MemberID string `json:"member_id"` + TriggerType string `json:"trigger_type"` + Status string `json:"status"` + Name string `json:"name"` + Summary string `json:"summary"` + StartTime time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time,omitempty"` + HasAttachments bool `json:"has_attachments"` +} + +// ResultDetailResponse - full result with delivery content +type ResultDetailResponse struct { + ID string `json:"id"` + MemberID string `json:"member_id"` + TriggerType string `json:"trigger_type"` + Status string `json:"status"` + Name string `json:"name"` + Delivery interface{} `json:"delivery,omitempty"` + StartTime time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time,omitempty"` +} + +// ResultListResponse - paginated list response +type ResultListResponse struct { + Data []*ResultResponse `json:"data"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"pagesize"` +} + +// NewResultResponse creates a ResultResponse from api.ResultItem +func NewResultResponse(item *robotapi.ResultItem) *ResultResponse { + if item == nil { + return nil + } + + return &ResultResponse{ + ID: item.ID, + MemberID: item.MemberID, + TriggerType: string(item.TriggerType), + Status: string(item.Status), + Name: item.Name, + Summary: item.Summary, + StartTime: item.StartTime, + EndTime: item.EndTime, + HasAttachments: item.HasAttachments, + } +} + +// NewResultDetailResponse creates a ResultDetailResponse from api.ResultDetail +func NewResultDetailResponse(detail *robotapi.ResultDetail) *ResultDetailResponse { + if detail == nil { + return nil + } + + return &ResultDetailResponse{ + ID: detail.ID, + MemberID: detail.MemberID, + TriggerType: string(detail.TriggerType), + Status: string(detail.Status), + Name: detail.Name, + Delivery: detail.Delivery, + StartTime: detail.StartTime, + EndTime: detail.EndTime, + } +} + +// ==================== Activities Types ==================== + +// ActivityFilter - query params for listing activities +type ActivityFilter struct { + Limit int `form:"limit"` // max number of activities + Since string `form:"since"` // ISO timestamp, only activities after this time +} + +// ActivityResponse - activity item +type ActivityResponse struct { + Type string `json:"type"` // execution.started, execution.completed, etc. + RobotID string `json:"robot_id"` + RobotName string `json:"robot_name,omitempty"` + ExecutionID string `json:"execution_id"` + Message string `json:"message"` + Timestamp time.Time `json:"timestamp"` +} + +// ActivityListResponse - activity list response +type ActivityListResponse struct { + Data []*ActivityResponse `json:"data"` +} + +// NewActivityResponse creates an ActivityResponse from api.Activity +func NewActivityResponse(activity *robotapi.Activity) *ActivityResponse { + if activity == nil { + return nil + } + + return &ActivityResponse{ + Type: string(activity.Type), + RobotID: activity.RobotID, + RobotName: activity.RobotName, + ExecutionID: activity.ExecutionID, + Message: activity.Message, + Timestamp: activity.Timestamp, + } +} diff --git a/openapi/tests/agent/robot_results_activities_test.go b/openapi/tests/agent/robot_results_activities_test.go new file mode 100644 index 00000000..b31b33ac --- /dev/null +++ b/openapi/tests/agent/robot_results_activities_test.go @@ -0,0 +1,412 @@ +package openapi_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/yao/openapi" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +// TestListResults tests the results listing endpoint +// GET /v1/agent/robots/:id/results +func TestListResults(t *testing.T) { + if testing.Short() { + t.Skip("Skipping results tests in short mode") + } + + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register test client and get token + client := testutils.RegisterTestClient(t, "Results List Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, client.ClientID) + tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile") + + // Create a test robot + robotID := fmt.Sprintf("test_results_list_%d", time.Now().UnixNano()) + createRobot(t, serverURL, baseURL, tokenInfo.AccessToken, robotID, "Results List Robot") + defer deleteRobot(t, serverURL, baseURL, tokenInfo.AccessToken, robotID) + + t.Run("ListResultsSuccess", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/results", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NotNil(t, resp) + 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) + + // Verify pagination fields exist + assert.Contains(t, response, "data") + assert.Contains(t, response, "page") + assert.Contains(t, response, "pagesize") + assert.Contains(t, response, "total") + + // Verify result items structure (if any results exist) + data, ok := response["data"].([]interface{}) + if ok && len(data) > 0 { + result := data[0].(map[string]interface{}) + // Basic fields should exist + assert.Contains(t, result, "id") + assert.Contains(t, result, "member_id") + assert.Contains(t, result, "trigger_type") + assert.Contains(t, result, "status") + assert.Contains(t, result, "name") + assert.Contains(t, result, "summary") + assert.Contains(t, result, "has_attachments") + t.Logf("Result response fields: id=%v, name=%v, summary=%v", + result["id"], result["name"], result["summary"]) + } + }) + + t.Run("ListResultsWithPagination", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/results?page=1&pagesize=5", 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.Equal(t, float64(1), response["page"]) + assert.Equal(t, float64(5), response["pagesize"]) + }) + + t.Run("ListResultsWithTriggerTypeFilter", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/results?trigger_type=clock", 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") + }) + + t.Run("ListResultsWithKeywordFilter", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/results?keyword=test", 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") + }) + + t.Run("ListResultsRobotNotFound", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/non_existent_robot/results", 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.StatusNotFound, resp.StatusCode) + }) + + t.Run("ListResultsUnauthorized", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/results", nil) + require.NoError(t, err) + // No Authorization header + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +// TestGetResult tests the result detail endpoint +// GET /v1/agent/robots/:id/results/:result_id +func TestGetResult(t *testing.T) { + if testing.Short() { + t.Skip("Skipping result tests in short mode") + } + + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register test client and get token + client := testutils.RegisterTestClient(t, "Result Get Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, client.ClientID) + tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile") + + // Create a test robot + robotID := fmt.Sprintf("test_result_get_%d", time.Now().UnixNano()) + createRobot(t, serverURL, baseURL, tokenInfo.AccessToken, robotID, "Result Get Robot") + defer deleteRobot(t, serverURL, baseURL, tokenInfo.AccessToken, robotID) + + t.Run("GetResultNotFound", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/results/non_existent_result", 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.StatusNotFound, resp.StatusCode) + }) + + t.Run("GetResultRobotNotFound", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/non_existent_robot/results/some_result", 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.StatusNotFound, resp.StatusCode) + }) + + t.Run("GetResultUnauthorized", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/results/some_result", nil) + require.NoError(t, err) + // No Authorization header + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +// TestListActivities tests the activities listing endpoint +// GET /v1/agent/robots/activities +func TestListActivities(t *testing.T) { + if testing.Short() { + t.Skip("Skipping activities tests in short mode") + } + + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register test client and get token + client := testutils.RegisterTestClient(t, "Activities List Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, client.ClientID) + tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile") + + t.Run("ListActivitiesSuccess", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/activities", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NotNil(t, resp) + 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) + + // Verify data field exists + assert.Contains(t, response, "data") + + // Verify activity items structure (if any activities exist) + data, ok := response["data"].([]interface{}) + if ok && len(data) > 0 { + activity := data[0].(map[string]interface{}) + // Basic fields should exist + assert.Contains(t, activity, "type") + assert.Contains(t, activity, "robot_id") + assert.Contains(t, activity, "execution_id") + assert.Contains(t, activity, "message") + assert.Contains(t, activity, "timestamp") + t.Logf("Activity response fields: type=%v, robot_id=%v, message=%v", + activity["type"], activity["robot_id"], activity["message"]) + } + }) + + t.Run("ListActivitiesWithLimit", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/activities?limit=10", 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") + }) + + 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) + // URL encode the since parameter (+ becomes %2B, : stays) + encodedSince := url.QueryEscape(since) + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/activities?since="+encodedSince, 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") + }) + + t.Run("ListActivitiesWithInvalidSince", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/activities?since=invalid_date", 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() + + // Should return 400 Bad Request for invalid date format + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + + t.Run("ListActivitiesUnauthorized", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/activities", nil) + require.NoError(t, err) + // No Authorization header + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + }) +} + +// TestResultsPermissions tests result permission inheritance from robot +func TestResultsPermissions(t *testing.T) { + if testing.Short() { + t.Skip("Skipping results permission tests in short mode") + } + + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + // Register test client + client := testutils.RegisterTestClient(t, "Results Permission Test Client", []string{"https://localhost/callback"}) + defer testutils.CleanupTestClient(t, client.ClientID) + + // Create User 1 + token1 := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile") + user1ID := token1.UserID + + // Create User 2 + token2 := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile") + + // User 1 creates a robot + robotID := fmt.Sprintf("test_results_perm_%d", time.Now().UnixNano()) + createRobotWithTeam(t, serverURL, baseURL, token1.AccessToken, robotID, "Results Permission Test Robot", user1ID) + defer deleteRobot(t, serverURL, baseURL, token1.AccessToken, robotID) + + t.Run("OwnerCanListResults", func(t *testing.T) { + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/results", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+token1.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + t.Logf("Owner (User 1) successfully listed results for their robot") + }) + + t.Run("OtherUserResultsAccess", func(t *testing.T) { + // User 2 attempts to list results for User 1's robot + // With system:root scope this might succeed + req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/results", nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "Bearer "+token2.AccessToken) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + t.Logf("User 2 results list attempt status: %d (with system:root scope)", resp.StatusCode) + }) +}