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.
This commit is contained in:
parent
fe67deb3e2
commit
2f040196ec
10 changed files with 2080 additions and 47 deletions
106
agent/robot/api/activities.go
Normal file
106
agent/robot/api/activities.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
194
agent/robot/api/results.go
Normal file
194
agent/robot/api/results.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ResultListResponse>`
|
||||
- [x] `GetResult(robotId: string, resultId: string): Promise<ResultDetail>`
|
||||
- [x] `ListActivities(params?: { limit?: number, since?: string }): Promise<ActivityListResponse>`
|
||||
|
||||
**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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
106
openapi/agent/robot/activities.go
Normal file
106
openapi/agent/robot/activities.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
221
openapi/agent/robot/results.go
Normal file
221
openapi/agent/robot/results.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
412
openapi/tests/agent/robot_results_activities_test.go
Normal file
412
openapi/tests/agent/robot_results_activities_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue