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:
Max 2026-01-24 17:57:58 +08:00
parent fe67deb3e2
commit 2f040196ec
10 changed files with 2080 additions and 47 deletions

View 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
View 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
}

View file

@ -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{

View file

@ -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)
}
}

View file

@ -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"
}
]
}
```
---

View 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)
}

View 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)
}

View file

@ -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

View file

@ -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,
}
}

View 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)
})
}