Enhance Execution Management with UI Display Fields and Localization Support
- Added `Name` and `CurrentTaskName` fields to the `Execution` struct for improved UI display during execution phases. - Implemented localization support for UI messages, allowing dynamic updates based on the execution context and user locale. - Updated the executor to manage these fields at various phases, ensuring accurate representation of execution status. - Enhanced OpenAPI documentation to reflect the new fields and their usage in execution responses. - Added unit tests to validate the functionality of UI fields and localization handling.
This commit is contained in:
parent
c63c68fd7b
commit
d590bd7557
24 changed files with 3252 additions and 337 deletions
|
|
@ -698,17 +698,18 @@ Save to KB:
|
|||
|
||||
```go
|
||||
type Config struct {
|
||||
Triggers *Triggers `json:"triggers,omitempty"`
|
||||
Clock *Clock `json:"clock,omitempty"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Quota *Quota `json:"quota"`
|
||||
KB *KB `json:"kb,omitempty"` // shared KB (same as assistant)
|
||||
DB *DB `json:"db,omitempty"` // shared DB (same as assistant)
|
||||
Learn *Learn `json:"learn,omitempty"` // learning for private KB
|
||||
Resources *Resources `json:"resources"`
|
||||
Delivery *DeliveryPreferences `json:"delivery,omitempty"`
|
||||
Events []Event `json:"events,omitempty"`
|
||||
Executor *Executor `json:"executor,omitempty"` // executor mode settings
|
||||
Triggers *Triggers `json:"triggers,omitempty"`
|
||||
Clock *Clock `json:"clock,omitempty"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Quota *Quota `json:"quota"`
|
||||
KB *KB `json:"kb,omitempty"` // shared KB (same as assistant)
|
||||
DB *DB `json:"db,omitempty"` // shared DB (same as assistant)
|
||||
Learn *Learn `json:"learn,omitempty"` // learning for private KB
|
||||
Resources *Resources `json:"resources"`
|
||||
Delivery *DeliveryPreferences `json:"delivery,omitempty"`
|
||||
Events []Event `json:"events,omitempty"`
|
||||
Executor *Executor `json:"executor,omitempty"` // executor mode settings
|
||||
DefaultLocale string `json:"default_locale,omitempty"` // default language for clock/event triggers ("en-US", "zh-CN")
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -938,16 +938,17 @@ import "time"
|
|||
|
||||
// Config - robot_config in __yao.member
|
||||
type Config struct {
|
||||
Triggers *Triggers `json:"triggers,omitempty"`
|
||||
Clock *Clock `json:"clock,omitempty"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Quota *Quota `json:"quota,omitempty"`
|
||||
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
|
||||
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
|
||||
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
|
||||
Resources *Resources `json:"resources,omitempty"`
|
||||
Delivery *DeliveryPreferences `json:"delivery,omitempty"` // see section 6.2
|
||||
Events []Event `json:"events,omitempty"`
|
||||
Triggers *Triggers `json:"triggers,omitempty"`
|
||||
Clock *Clock `json:"clock,omitempty"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Quota *Quota `json:"quota,omitempty"`
|
||||
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
|
||||
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
|
||||
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
|
||||
Resources *Resources `json:"resources,omitempty"`
|
||||
Delivery *DeliveryPreferences `json:"delivery,omitempty"` // see section 6.2
|
||||
Events []Event `json:"events,omitempty"`
|
||||
DefaultLocale string `json:"default_locale,omitempty"` // Default language for clock/event triggers (e.g., "en-US", "zh-CN")
|
||||
}
|
||||
|
||||
// Validate validates the config
|
||||
|
|
@ -1238,6 +1239,10 @@ type Execution struct {
|
|||
Phase Phase `json:"phase"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
// UI display fields (updated by executor at each phase)
|
||||
// These provide human-readable status for frontend display
|
||||
Name string `json:"name,omitempty"` // Execution title (updated when goals complete)
|
||||
CurrentTaskName string `json:"current_task_name,omitempty"` // Current task description (updated during run phase)
|
||||
|
||||
// Trigger input (stored for traceability)
|
||||
Input *TriggerInput `json:"input,omitempty"` // original trigger input
|
||||
|
|
@ -1263,6 +1268,7 @@ type TriggerInput struct {
|
|||
Action InterventionAction `json:"action,omitempty"` // task.add, goal.adjust, etc.
|
||||
Messages []context.Message `json:"messages,omitempty"` // user's input (text, images, files)
|
||||
UserID string `json:"user_id,omitempty"` // who triggered
|
||||
Locale string `json:"locale,omitempty"` // language for UI display (e.g., "en-US", "zh-CN")
|
||||
|
||||
// For event trigger
|
||||
Source EventSource `json:"source,omitempty"` // webhook | database
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ type TriggerRequest struct {
|
|||
|
||||
// Executor mode (optional, overrides robot config)
|
||||
ExecutorMode types.ExecutorMode `json:"executor_mode,omitempty"`
|
||||
|
||||
// i18n support
|
||||
Locale string `json:"locale,omitempty"` // Locale for UI messages (e.g., "en", "zh")
|
||||
}
|
||||
|
||||
// InsertPosition - where to insert task in queue
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
|||
}
|
||||
|
||||
// Create execution (Job system removed, using ExecutionStore only)
|
||||
input := types.BuildTriggerInput(trigger, data)
|
||||
exec := &robottypes.Execution{
|
||||
ID: utils.NewID(),
|
||||
MemberID: robot.MemberID,
|
||||
|
|
@ -62,9 +63,12 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
|||
StartTime: time.Now(),
|
||||
Status: robottypes.ExecPending,
|
||||
Phase: robottypes.AllPhases[startPhaseIndex],
|
||||
Input: types.BuildTriggerInput(trigger, data),
|
||||
Input: input,
|
||||
}
|
||||
|
||||
// Initialize UI display fields (with i18n support)
|
||||
exec.Name, exec.CurrentTaskName = e.initUIFields(trigger, input, robot)
|
||||
|
||||
// Set robot reference for phase methods
|
||||
exec.SetRobot(robot)
|
||||
|
||||
|
|
@ -138,12 +142,24 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
|||
return exec, nil
|
||||
}
|
||||
|
||||
// Determine locale for UI messages
|
||||
locale := getEffectiveLocale(robot, exec.Input)
|
||||
|
||||
// Execute phases
|
||||
phases := robottypes.AllPhases[startPhaseIndex:]
|
||||
for _, phase := range phases {
|
||||
if err := e.runPhase(ctx, exec, phase, data); err != nil {
|
||||
exec.Status = robottypes.ExecFailed
|
||||
exec.Error = err.Error()
|
||||
|
||||
// Update UI field for failure with i18n
|
||||
failedPrefix := getLocalizedMessage(locale, "failed_prefix")
|
||||
failureMsg := failedPrefix + err.Error()
|
||||
if len(failureMsg) > 100 {
|
||||
failureMsg = failureMsg[:100] + "..."
|
||||
}
|
||||
e.updateUIFields(ctx, exec, "", failureMsg)
|
||||
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"member_id": exec.MemberID,
|
||||
|
|
@ -163,6 +179,9 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
|||
now := time.Now()
|
||||
exec.EndTime = &now
|
||||
|
||||
// Update UI field for completion with i18n
|
||||
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "completed"))
|
||||
|
||||
duration := now.Sub(exec.StartTime)
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
|
|
@ -301,5 +320,154 @@ func (e *Executor) simulateStreamDelay() {
|
|||
time.Sleep(DefaultStreamDelay)
|
||||
}
|
||||
|
||||
// initUIFields initializes UI display fields based on trigger type with i18n support
|
||||
// Returns (name, currentTaskName)
|
||||
func (e *Executor) initUIFields(trigger robottypes.TriggerType, input *robottypes.TriggerInput, robot *robottypes.Robot) (string, string) {
|
||||
// Determine locale for UI messages
|
||||
locale := getEffectiveLocale(robot, input)
|
||||
|
||||
// Get localized default messages
|
||||
name := getLocalizedMessage(locale, "preparing")
|
||||
currentTaskName := getLocalizedMessage(locale, "starting")
|
||||
|
||||
switch trigger {
|
||||
case robottypes.TriggerHuman:
|
||||
// For human trigger, extract name from first message
|
||||
if input != nil && len(input.Messages) > 0 {
|
||||
if content, ok := input.Messages[0].GetContentAsString(); ok && content != "" {
|
||||
// Use first 100 chars of message as name
|
||||
name = content
|
||||
if len(name) > 100 {
|
||||
name = name[:100] + "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
case robottypes.TriggerClock:
|
||||
name = getLocalizedMessage(locale, "scheduled_execution")
|
||||
case robottypes.TriggerEvent:
|
||||
if input != nil && input.EventType != "" {
|
||||
name = getLocalizedMessage(locale, "event_prefix") + input.EventType
|
||||
} else {
|
||||
name = getLocalizedMessage(locale, "event_triggered")
|
||||
}
|
||||
}
|
||||
|
||||
return name, currentTaskName
|
||||
}
|
||||
|
||||
// getEffectiveLocale determines the locale for UI display
|
||||
// Priority: input.Locale > robot.Config.DefaultLocale > "en"
|
||||
func getEffectiveLocale(robot *robottypes.Robot, input *robottypes.TriggerInput) string {
|
||||
// 1. Human trigger with explicit locale
|
||||
if input != nil && input.Locale != "" {
|
||||
return input.Locale
|
||||
}
|
||||
// 2. Robot configured default
|
||||
if robot != nil && robot.Config != nil {
|
||||
return robot.Config.GetDefaultLocale()
|
||||
}
|
||||
// 3. System default
|
||||
return "en"
|
||||
}
|
||||
|
||||
// i18n message maps for UI display fields
|
||||
// Use simple locale codes (en, zh) as keys
|
||||
var uiMessages = map[string]map[string]string{
|
||||
"en": {
|
||||
"preparing": "Preparing...",
|
||||
"starting": "Starting...",
|
||||
"scheduled_execution": "Scheduled execution",
|
||||
"event_prefix": "Event: ",
|
||||
"event_triggered": "Event triggered",
|
||||
"analyzing_context": "Analyzing context...",
|
||||
"planning_goals": "Planning goals...",
|
||||
"breaking_down_tasks": "Breaking down tasks...",
|
||||
"completed": "Completed",
|
||||
"failed_prefix": "Failed: ",
|
||||
"task_prefix": "Task",
|
||||
},
|
||||
"zh": {
|
||||
"preparing": "准备中...",
|
||||
"starting": "启动中...",
|
||||
"scheduled_execution": "定时执行",
|
||||
"event_prefix": "事件: ",
|
||||
"event_triggered": "事件触发",
|
||||
"analyzing_context": "分析上下文...",
|
||||
"planning_goals": "规划目标...",
|
||||
"breaking_down_tasks": "分解任务...",
|
||||
"completed": "已完成",
|
||||
"failed_prefix": "失败: ",
|
||||
"task_prefix": "任务",
|
||||
},
|
||||
}
|
||||
|
||||
// getLocalizedMessage returns a localized message for the given key
|
||||
func getLocalizedMessage(locale string, key string) string {
|
||||
if messages, ok := uiMessages[locale]; ok {
|
||||
if msg, ok := messages[key]; ok {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
// Fallback to English
|
||||
if messages, ok := uiMessages["en"]; ok {
|
||||
if msg, ok := messages[key]; ok {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return key // Return key as fallback
|
||||
}
|
||||
|
||||
// updateUIFields updates UI display fields and persists to database
|
||||
func (e *Executor) updateUIFields(ctx *robottypes.Context, exec *robottypes.Execution, name string, currentTaskName string) {
|
||||
// Update in-memory execution
|
||||
if name != "" {
|
||||
exec.Name = name
|
||||
}
|
||||
if currentTaskName != "" {
|
||||
exec.CurrentTaskName = currentTaskName
|
||||
}
|
||||
|
||||
// Persist to database
|
||||
if !e.config.SkipPersistence && e.store != nil {
|
||||
if err := e.store.UpdateUIFields(ctx.Context, exec.ID, name, currentTaskName); err != nil {
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"error": err,
|
||||
}).Warn("Failed to update UI fields: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractGoalName extracts the execution name from goals output
|
||||
func extractGoalName(goals *robottypes.Goals) string {
|
||||
if goals == nil || goals.Content == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Extract first line or first sentence as the goal name
|
||||
content := goals.Content
|
||||
// Find first newline
|
||||
if idx := indexAny(content, "\n\r"); idx > 0 {
|
||||
content = content[:idx]
|
||||
}
|
||||
// Limit length
|
||||
if len(content) > 150 {
|
||||
content = content[:150] + "..."
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
// indexAny returns the index of the first occurrence of any char in chars
|
||||
func indexAny(s string, chars string) int {
|
||||
for i, c := range s {
|
||||
for _, ch := range chars {
|
||||
if c == ch {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Verify Executor implements types.Executor
|
||||
var _ types.Executor = (*Executor)(nil)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ func (e *Executor) RunGoals(ctx *robottypes.Context, exec *robottypes.Execution,
|
|||
return fmt.Errorf("robot not found in execution")
|
||||
}
|
||||
|
||||
// Update UI field with i18n
|
||||
locale := getEffectiveLocale(robot, exec.Input)
|
||||
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "planning_goals"))
|
||||
|
||||
// Get agent ID for goals phase
|
||||
agentID := "__yao.goals" // default
|
||||
if robot.Config != nil && robot.Config.Resources != nil {
|
||||
|
|
@ -113,6 +117,11 @@ func (e *Executor) RunGoals(ctx *robottypes.Context, exec *robottypes.Execution,
|
|||
return fmt.Errorf("goals agent (%s) returned empty content", agentID)
|
||||
}
|
||||
|
||||
// Update Name from goals content (extract first line as execution title)
|
||||
if goalName := extractGoalName(exec.Goals); goalName != "" {
|
||||
e.updateUIFields(ctx, exec, goalName, "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ func (e *Executor) RunInspiration(ctx *robottypes.Context, exec *robottypes.Exec
|
|||
return fmt.Errorf("robot not found in execution")
|
||||
}
|
||||
|
||||
// Update UI field with i18n
|
||||
locale := getEffectiveLocale(robot, exec.Input)
|
||||
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "analyzing_context"))
|
||||
|
||||
// Build clock context from trigger input or current time
|
||||
var clock *robottypes.ClockContext
|
||||
if exec.Input != nil && exec.Input.Clock != nil {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
|
|||
config = DefaultRunConfig()
|
||||
}
|
||||
|
||||
// Determine locale for UI messages
|
||||
locale := getEffectiveLocale(robot, exec.Input)
|
||||
|
||||
// Initialize results slice
|
||||
exec.Results = make([]robottypes.TaskResult, 0, len(exec.Tasks))
|
||||
|
||||
|
|
@ -81,6 +84,10 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
|
|||
Progress: fmt.Sprintf("%d/%d tasks", i+1, len(exec.Tasks)),
|
||||
}
|
||||
|
||||
// Update UI field with current task description (i18n)
|
||||
taskName := formatTaskProgressName(task, i, len(exec.Tasks), locale)
|
||||
e.updateUIFields(ctx, exec, "", taskName)
|
||||
|
||||
// Mark task as running
|
||||
task.Status = robottypes.TaskRunning
|
||||
now := time.Now()
|
||||
|
|
@ -122,3 +129,23 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatTaskProgressName formats a progress name for the current task (used for UI with i18n)
|
||||
func formatTaskProgressName(task *robottypes.Task, index int, total int, locale string) string {
|
||||
taskPrefix := getLocalizedMessage(locale, "task_prefix")
|
||||
prefix := fmt.Sprintf("%s %d/%d: ", taskPrefix, index+1, total)
|
||||
|
||||
// Try to get description from first message
|
||||
if len(task.Messages) > 0 {
|
||||
if content, ok := task.Messages[0].GetContentAsString(); ok && content != "" {
|
||||
// Truncate if too long
|
||||
if len(content) > 80 {
|
||||
content = content[:80] + "..."
|
||||
}
|
||||
return prefix + content
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to executor info
|
||||
return prefix + string(task.ExecutorType) + ":" + task.ExecutorID
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ func (e *Executor) RunTasks(ctx *robottypes.Context, exec *robottypes.Execution,
|
|||
return fmt.Errorf("robot not found in execution")
|
||||
}
|
||||
|
||||
// Update UI field with i18n
|
||||
locale := getEffectiveLocale(robot, exec.Input)
|
||||
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "breaking_down_tasks"))
|
||||
|
||||
// Validate: Goals must exist (from P1)
|
||||
if exec.Goals == nil || exec.Goals.Content == "" {
|
||||
return fmt.Errorf("goals not available for task planning")
|
||||
|
|
|
|||
356
agent/robot/executor/standard/ui_fields_test.go
Normal file
356
agent/robot/executor/standard/ui_fields_test.go
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
package standard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// getEffectiveLocale Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGetEffectiveLocale(t *testing.T) {
|
||||
t.Run("returns_input_locale_when_provided", func(t *testing.T) {
|
||||
robot := &robottypes.Robot{
|
||||
Config: &robottypes.Config{
|
||||
DefaultLocale: "en",
|
||||
},
|
||||
}
|
||||
input := &robottypes.TriggerInput{
|
||||
Locale: "zh",
|
||||
}
|
||||
|
||||
locale := getEffectiveLocale(robot, input)
|
||||
assert.Equal(t, "zh", locale)
|
||||
})
|
||||
|
||||
t.Run("returns_robot_default_locale_when_input_locale_empty", func(t *testing.T) {
|
||||
robot := &robottypes.Robot{
|
||||
Config: &robottypes.Config{
|
||||
DefaultLocale: "zh",
|
||||
},
|
||||
}
|
||||
input := &robottypes.TriggerInput{
|
||||
Locale: "",
|
||||
}
|
||||
|
||||
locale := getEffectiveLocale(robot, input)
|
||||
assert.Equal(t, "zh", locale)
|
||||
})
|
||||
|
||||
t.Run("returns_system_default_when_no_locale_configured", func(t *testing.T) {
|
||||
robot := &robottypes.Robot{
|
||||
Config: &robottypes.Config{},
|
||||
}
|
||||
input := &robottypes.TriggerInput{}
|
||||
|
||||
locale := getEffectiveLocale(robot, input)
|
||||
assert.Equal(t, "en", locale)
|
||||
})
|
||||
|
||||
t.Run("returns_system_default_when_robot_config_nil", func(t *testing.T) {
|
||||
robot := &robottypes.Robot{}
|
||||
input := &robottypes.TriggerInput{}
|
||||
|
||||
locale := getEffectiveLocale(robot, input)
|
||||
assert.Equal(t, "en", locale)
|
||||
})
|
||||
|
||||
t.Run("returns_system_default_when_robot_nil", func(t *testing.T) {
|
||||
input := &robottypes.TriggerInput{}
|
||||
|
||||
locale := getEffectiveLocale(nil, input)
|
||||
assert.Equal(t, "en", locale)
|
||||
})
|
||||
|
||||
t.Run("returns_system_default_when_input_nil", func(t *testing.T) {
|
||||
robot := &robottypes.Robot{}
|
||||
|
||||
locale := getEffectiveLocale(robot, nil)
|
||||
assert.Equal(t, "en", locale)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// getLocalizedMessage Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestGetLocalizedMessage(t *testing.T) {
|
||||
t.Run("returns_english_message_for_en_locale", func(t *testing.T) {
|
||||
msg := getLocalizedMessage("en", "preparing")
|
||||
assert.Equal(t, "Preparing...", msg)
|
||||
})
|
||||
|
||||
t.Run("returns_chinese_message_for_zh_locale", func(t *testing.T) {
|
||||
msg := getLocalizedMessage("zh", "preparing")
|
||||
assert.Equal(t, "准备中...", msg)
|
||||
})
|
||||
|
||||
t.Run("returns_english_fallback_for_unknown_locale", func(t *testing.T) {
|
||||
msg := getLocalizedMessage("fr", "preparing")
|
||||
assert.Equal(t, "Preparing...", msg)
|
||||
})
|
||||
|
||||
t.Run("returns_key_for_unknown_message", func(t *testing.T) {
|
||||
msg := getLocalizedMessage("en", "unknown_key")
|
||||
assert.Equal(t, "unknown_key", msg)
|
||||
})
|
||||
|
||||
t.Run("all_english_messages_exist", func(t *testing.T) {
|
||||
keys := []string{
|
||||
"preparing", "starting", "scheduled_execution",
|
||||
"event_prefix", "event_triggered", "analyzing_context",
|
||||
"planning_goals", "breaking_down_tasks", "completed",
|
||||
"failed_prefix", "task_prefix",
|
||||
}
|
||||
for _, key := range keys {
|
||||
msg := getLocalizedMessage("en", key)
|
||||
assert.NotEqual(t, key, msg, "English message should exist for key: %s", key)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all_chinese_messages_exist", func(t *testing.T) {
|
||||
keys := []string{
|
||||
"preparing", "starting", "scheduled_execution",
|
||||
"event_prefix", "event_triggered", "analyzing_context",
|
||||
"planning_goals", "breaking_down_tasks", "completed",
|
||||
"failed_prefix", "task_prefix",
|
||||
}
|
||||
for _, key := range keys {
|
||||
msg := getLocalizedMessage("zh", key)
|
||||
assert.NotEqual(t, key, msg, "Chinese message should exist for key: %s", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// initUIFields Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestInitUIFields(t *testing.T) {
|
||||
executor := New()
|
||||
|
||||
t.Run("human_trigger_extracts_name_from_message", func(t *testing.T) {
|
||||
robot := &robottypes.Robot{
|
||||
Config: &robottypes.Config{DefaultLocale: "en"},
|
||||
}
|
||||
input := &robottypes.TriggerInput{
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Please analyze the sales data"},
|
||||
},
|
||||
}
|
||||
|
||||
name, currentTaskName := executor.initUIFields(robottypes.TriggerHuman, input, robot)
|
||||
assert.Equal(t, "Please analyze the sales data", name)
|
||||
assert.Equal(t, "Starting...", currentTaskName)
|
||||
})
|
||||
|
||||
t.Run("human_trigger_truncates_long_message", func(t *testing.T) {
|
||||
robot := &robottypes.Robot{
|
||||
Config: &robottypes.Config{DefaultLocale: "en"},
|
||||
}
|
||||
longMessage := "This is a very long message that exceeds one hundred characters and should be truncated with an ellipsis at the end"
|
||||
input := &robottypes.TriggerInput{
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: longMessage},
|
||||
},
|
||||
}
|
||||
|
||||
name, _ := executor.initUIFields(robottypes.TriggerHuman, input, robot)
|
||||
assert.LessOrEqual(t, len(name), 103) // 100 chars + "..."
|
||||
assert.True(t, len(name) > 100 || name == longMessage[:100]+"...")
|
||||
})
|
||||
|
||||
t.Run("clock_trigger_uses_scheduled_execution", func(t *testing.T) {
|
||||
robot := &robottypes.Robot{
|
||||
Config: &robottypes.Config{DefaultLocale: "en"},
|
||||
}
|
||||
input := &robottypes.TriggerInput{}
|
||||
|
||||
name, currentTaskName := executor.initUIFields(robottypes.TriggerClock, input, robot)
|
||||
assert.Equal(t, "Scheduled execution", name)
|
||||
assert.Equal(t, "Starting...", currentTaskName)
|
||||
})
|
||||
|
||||
t.Run("clock_trigger_chinese_locale", func(t *testing.T) {
|
||||
robot := &robottypes.Robot{
|
||||
Config: &robottypes.Config{DefaultLocale: "zh"},
|
||||
}
|
||||
input := &robottypes.TriggerInput{}
|
||||
|
||||
name, currentTaskName := executor.initUIFields(robottypes.TriggerClock, input, robot)
|
||||
assert.Equal(t, "定时执行", name)
|
||||
assert.Equal(t, "启动中...", currentTaskName)
|
||||
})
|
||||
|
||||
t.Run("event_trigger_with_event_type", func(t *testing.T) {
|
||||
robot := &robottypes.Robot{
|
||||
Config: &robottypes.Config{DefaultLocale: "en"},
|
||||
}
|
||||
input := &robottypes.TriggerInput{
|
||||
EventType: "lead.created",
|
||||
}
|
||||
|
||||
name, currentTaskName := executor.initUIFields(robottypes.TriggerEvent, input, robot)
|
||||
assert.Equal(t, "Event: lead.created", name)
|
||||
assert.Equal(t, "Starting...", currentTaskName)
|
||||
})
|
||||
|
||||
t.Run("event_trigger_without_event_type", func(t *testing.T) {
|
||||
robot := &robottypes.Robot{
|
||||
Config: &robottypes.Config{DefaultLocale: "en"},
|
||||
}
|
||||
input := &robottypes.TriggerInput{}
|
||||
|
||||
name, _ := executor.initUIFields(robottypes.TriggerEvent, input, robot)
|
||||
assert.Equal(t, "Event triggered", name)
|
||||
})
|
||||
|
||||
t.Run("event_trigger_chinese_locale", func(t *testing.T) {
|
||||
robot := &robottypes.Robot{
|
||||
Config: &robottypes.Config{DefaultLocale: "zh"},
|
||||
}
|
||||
input := &robottypes.TriggerInput{
|
||||
EventType: "order.placed",
|
||||
}
|
||||
|
||||
name, _ := executor.initUIFields(robottypes.TriggerEvent, input, robot)
|
||||
assert.Equal(t, "事件: order.placed", name)
|
||||
})
|
||||
|
||||
t.Run("input_locale_overrides_robot_default", func(t *testing.T) {
|
||||
robot := &robottypes.Robot{
|
||||
Config: &robottypes.Config{DefaultLocale: "en"},
|
||||
}
|
||||
input := &robottypes.TriggerInput{
|
||||
Locale: "zh",
|
||||
}
|
||||
|
||||
name, currentTaskName := executor.initUIFields(robottypes.TriggerClock, input, robot)
|
||||
assert.Equal(t, "定时执行", name)
|
||||
assert.Equal(t, "启动中...", currentTaskName)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// extractGoalName Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestExtractGoalName(t *testing.T) {
|
||||
t.Run("extracts_first_line_from_content", func(t *testing.T) {
|
||||
goals := &robottypes.Goals{
|
||||
Content: "Generate monthly sales report\nAnalyze trends\nSend to stakeholders",
|
||||
}
|
||||
|
||||
name := extractGoalName(goals)
|
||||
assert.Equal(t, "Generate monthly sales report", name)
|
||||
})
|
||||
|
||||
t.Run("returns_empty_for_nil_goals", func(t *testing.T) {
|
||||
name := extractGoalName(nil)
|
||||
assert.Equal(t, "", name)
|
||||
})
|
||||
|
||||
t.Run("returns_empty_for_empty_content", func(t *testing.T) {
|
||||
goals := &robottypes.Goals{
|
||||
Content: "",
|
||||
}
|
||||
|
||||
name := extractGoalName(goals)
|
||||
assert.Equal(t, "", name)
|
||||
})
|
||||
|
||||
t.Run("truncates_long_first_line", func(t *testing.T) {
|
||||
longLine := "This is an extremely long goal description that exceeds one hundred and fifty characters and should be truncated with an ellipsis at the end to keep the display manageable"
|
||||
goals := &robottypes.Goals{
|
||||
Content: longLine,
|
||||
}
|
||||
|
||||
name := extractGoalName(goals)
|
||||
assert.LessOrEqual(t, len(name), 153) // 150 chars + "..."
|
||||
})
|
||||
|
||||
t.Run("handles_single_line_content", func(t *testing.T) {
|
||||
goals := &robottypes.Goals{
|
||||
Content: "Single line goal",
|
||||
}
|
||||
|
||||
name := extractGoalName(goals)
|
||||
assert.Equal(t, "Single line goal", name)
|
||||
})
|
||||
|
||||
t.Run("handles_carriage_return", func(t *testing.T) {
|
||||
goals := &robottypes.Goals{
|
||||
Content: "First goal\r\nSecond goal",
|
||||
}
|
||||
|
||||
name := extractGoalName(goals)
|
||||
assert.Equal(t, "First goal", name)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// formatTaskProgressName Tests
|
||||
// ============================================================================
|
||||
|
||||
func TestFormatTaskProgressName(t *testing.T) {
|
||||
t.Run("formats_with_task_description", func(t *testing.T) {
|
||||
task := &robottypes.Task{
|
||||
ID: "task-001",
|
||||
ExecutorType: robottypes.ExecutorAssistant,
|
||||
ExecutorID: "analyst",
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Analyze sales data"},
|
||||
},
|
||||
}
|
||||
|
||||
name := formatTaskProgressName(task, 0, 3, "en")
|
||||
assert.Equal(t, "Task 1/3: Analyze sales data", name)
|
||||
})
|
||||
|
||||
t.Run("formats_with_chinese_locale", func(t *testing.T) {
|
||||
task := &robottypes.Task{
|
||||
ID: "task-001",
|
||||
ExecutorType: robottypes.ExecutorAssistant,
|
||||
ExecutorID: "analyst",
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "分析销售数据"},
|
||||
},
|
||||
}
|
||||
|
||||
name := formatTaskProgressName(task, 1, 5, "zh")
|
||||
assert.Equal(t, "任务 2/5: 分析销售数据", name)
|
||||
})
|
||||
|
||||
t.Run("truncates_long_description", func(t *testing.T) {
|
||||
longDesc := "This is a very long task description that should be truncated because it exceeds 80 characters which is the maximum length allowed"
|
||||
task := &robottypes.Task{
|
||||
ID: "task-001",
|
||||
ExecutorType: robottypes.ExecutorAssistant,
|
||||
ExecutorID: "analyst",
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: longDesc},
|
||||
},
|
||||
}
|
||||
|
||||
name := formatTaskProgressName(task, 0, 1, "en")
|
||||
// Should be "Task 1/1: " (11 chars) + truncated content (83 chars max with "...")
|
||||
assert.Contains(t, name, "...")
|
||||
assert.LessOrEqual(t, len(name), 100)
|
||||
})
|
||||
|
||||
t.Run("fallback_to_executor_info_when_no_messages", func(t *testing.T) {
|
||||
task := &robottypes.Task{
|
||||
ID: "task-001",
|
||||
ExecutorType: robottypes.ExecutorMCP,
|
||||
ExecutorID: "calculator",
|
||||
Messages: []agentcontext.Message{},
|
||||
}
|
||||
|
||||
name := formatTaskProgressName(task, 2, 4, "en")
|
||||
assert.Equal(t, "Task 3/4: mcp:calculator", name)
|
||||
})
|
||||
}
|
||||
|
|
@ -25,6 +25,10 @@ type ExecutionRecord struct {
|
|||
Current *CurrentState `json:"current,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
// UI display fields (updated by executor at each phase)
|
||||
Name string `json:"name,omitempty"` // Execution title
|
||||
CurrentTaskName string `json:"current_task_name,omitempty"` // Current task description
|
||||
|
||||
// Trigger input
|
||||
Input *types.TriggerInput `json:"input,omitempty"`
|
||||
|
||||
|
|
@ -313,6 +317,41 @@ func (s *ExecutionStore) UpdateCurrent(ctx context.Context, executionID string,
|
|||
return nil
|
||||
}
|
||||
|
||||
// UpdateUIFields updates the UI display fields (name and current_task_name)
|
||||
// These fields are updated by executor at each phase for frontend display
|
||||
func (s *ExecutionStore) UpdateUIFields(ctx context.Context, executionID string, name string, currentTaskName string) error {
|
||||
mod := model.Select(s.modelID)
|
||||
if mod == nil {
|
||||
return fmt.Errorf("model %s not found", s.modelID)
|
||||
}
|
||||
|
||||
updateData := map[string]interface{}{}
|
||||
if name != "" {
|
||||
updateData["name"] = name
|
||||
}
|
||||
if currentTaskName != "" {
|
||||
updateData["current_task_name"] = currentTaskName
|
||||
}
|
||||
|
||||
if len(updateData) == 0 {
|
||||
return nil // Nothing to update
|
||||
}
|
||||
|
||||
_, err := mod.UpdateWhere(
|
||||
model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "execution_id", Value: executionID},
|
||||
},
|
||||
},
|
||||
updateData,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update UI fields: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes an execution record by execution_id
|
||||
func (s *ExecutionStore) Delete(ctx context.Context, executionID string) error {
|
||||
mod := model.Select(s.modelID)
|
||||
|
|
@ -346,6 +385,12 @@ func (s *ExecutionStore) recordToMap(record *ExecutionRecord) map[string]interfa
|
|||
if record.Error != "" {
|
||||
data["error"] = record.Error
|
||||
}
|
||||
if record.Name != "" {
|
||||
data["name"] = record.Name
|
||||
}
|
||||
if record.CurrentTaskName != "" {
|
||||
data["current_task_name"] = record.CurrentTaskName
|
||||
}
|
||||
if record.Current != nil {
|
||||
data["current"] = record.Current
|
||||
}
|
||||
|
|
@ -416,6 +461,12 @@ func (s *ExecutionStore) mapToRecord(row map[string]interface{}) (*ExecutionReco
|
|||
if v, ok := row["error"].(string); ok {
|
||||
record.Error = v
|
||||
}
|
||||
if v, ok := row["name"].(string); ok {
|
||||
record.Name = v
|
||||
}
|
||||
if v, ok := row["current_task_name"].(string); ok {
|
||||
record.CurrentTaskName = v
|
||||
}
|
||||
|
||||
// JSON fields - need to unmarshal
|
||||
if v := row["current"]; v != nil {
|
||||
|
|
@ -624,20 +675,22 @@ func (s *ExecutionStore) parseTime(v interface{}) *time.Time {
|
|||
// FromExecution creates an ExecutionRecord from a runtime Execution
|
||||
func FromExecution(exec *types.Execution) *ExecutionRecord {
|
||||
record := &ExecutionRecord{
|
||||
ExecutionID: exec.ID,
|
||||
MemberID: exec.MemberID,
|
||||
TeamID: exec.TeamID,
|
||||
TriggerType: exec.TriggerType,
|
||||
Status: exec.Status,
|
||||
Phase: exec.Phase,
|
||||
Error: exec.Error,
|
||||
Input: exec.Input,
|
||||
Inspiration: exec.Inspiration,
|
||||
Goals: exec.Goals,
|
||||
Tasks: exec.Tasks,
|
||||
Results: exec.Results,
|
||||
Delivery: exec.Delivery,
|
||||
Learning: exec.Learning,
|
||||
ExecutionID: exec.ID,
|
||||
MemberID: exec.MemberID,
|
||||
TeamID: exec.TeamID,
|
||||
TriggerType: exec.TriggerType,
|
||||
Status: exec.Status,
|
||||
Phase: exec.Phase,
|
||||
Error: exec.Error,
|
||||
Name: exec.Name,
|
||||
CurrentTaskName: exec.CurrentTaskName,
|
||||
Input: exec.Input,
|
||||
Inspiration: exec.Inspiration,
|
||||
Goals: exec.Goals,
|
||||
Tasks: exec.Tasks,
|
||||
Results: exec.Results,
|
||||
Delivery: exec.Delivery,
|
||||
Learning: exec.Learning,
|
||||
}
|
||||
|
||||
// Convert timestamps
|
||||
|
|
@ -662,20 +715,22 @@ func FromExecution(exec *types.Execution) *ExecutionRecord {
|
|||
// ToExecution converts an ExecutionRecord to a runtime Execution
|
||||
func (r *ExecutionRecord) ToExecution() *types.Execution {
|
||||
exec := &types.Execution{
|
||||
ID: r.ExecutionID,
|
||||
MemberID: r.MemberID,
|
||||
TeamID: r.TeamID,
|
||||
TriggerType: r.TriggerType,
|
||||
Status: r.Status,
|
||||
Phase: r.Phase,
|
||||
Error: r.Error,
|
||||
Input: r.Input,
|
||||
Inspiration: r.Inspiration,
|
||||
Goals: r.Goals,
|
||||
Tasks: r.Tasks,
|
||||
Results: r.Results,
|
||||
Delivery: r.Delivery,
|
||||
Learning: r.Learning,
|
||||
ID: r.ExecutionID,
|
||||
MemberID: r.MemberID,
|
||||
TeamID: r.TeamID,
|
||||
TriggerType: r.TriggerType,
|
||||
Status: r.Status,
|
||||
Phase: r.Phase,
|
||||
Error: r.Error,
|
||||
Name: r.Name,
|
||||
CurrentTaskName: r.CurrentTaskName,
|
||||
Input: r.Input,
|
||||
Inspiration: r.Inspiration,
|
||||
Goals: r.Goals,
|
||||
Tasks: r.Tasks,
|
||||
Results: r.Results,
|
||||
Delivery: r.Delivery,
|
||||
Learning: r.Learning,
|
||||
}
|
||||
|
||||
// Convert timestamps
|
||||
|
|
|
|||
|
|
@ -505,6 +505,105 @@ func TestExecutionStoreUpdateCurrent(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestExecutionStoreUpdateUIFields tests updating UI display fields
|
||||
func TestExecutionStoreUpdateUIFields(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()
|
||||
|
||||
// Create a base record
|
||||
startTime := time.Now()
|
||||
record := &store.ExecutionRecord{
|
||||
ExecutionID: "exec_test_uifields_001",
|
||||
MemberID: "member_uifields_001",
|
||||
TeamID: "team_uifields_001",
|
||||
TriggerType: types.TriggerHuman,
|
||||
Status: types.ExecRunning,
|
||||
Phase: types.PhaseInspiration,
|
||||
StartTime: &startTime,
|
||||
}
|
||||
err := s.Save(ctx, record)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("updates_name_only", func(t *testing.T) {
|
||||
err := s.UpdateUIFields(ctx, "exec_test_uifields_001", "Analyze sales data", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
saved, err := s.Get(ctx, "exec_test_uifields_001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Analyze sales data", saved.Name)
|
||||
assert.Equal(t, "", saved.CurrentTaskName)
|
||||
})
|
||||
|
||||
t.Run("updates_current_task_name_only", func(t *testing.T) {
|
||||
err := s.UpdateUIFields(ctx, "exec_test_uifields_001", "", "Analyzing context...")
|
||||
require.NoError(t, err)
|
||||
|
||||
saved, err := s.Get(ctx, "exec_test_uifields_001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Analyze sales data", saved.Name) // Previous value retained
|
||||
assert.Equal(t, "Analyzing context...", saved.CurrentTaskName)
|
||||
})
|
||||
|
||||
t.Run("updates_both_fields", func(t *testing.T) {
|
||||
err := s.UpdateUIFields(ctx, "exec_test_uifields_001", "Generate monthly report", "Task 1/3: Collect data")
|
||||
require.NoError(t, err)
|
||||
|
||||
saved, err := s.Get(ctx, "exec_test_uifields_001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Generate monthly report", saved.Name)
|
||||
assert.Equal(t, "Task 1/3: Collect data", saved.CurrentTaskName)
|
||||
})
|
||||
|
||||
t.Run("does_nothing_when_both_empty", func(t *testing.T) {
|
||||
// Get current values
|
||||
before, err := s.Get(ctx, "exec_test_uifields_001")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update with empty strings
|
||||
err = s.UpdateUIFields(ctx, "exec_test_uifields_001", "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Values should remain unchanged
|
||||
after, err := s.Get(ctx, "exec_test_uifields_001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, before.Name, after.Name)
|
||||
assert.Equal(t, before.CurrentTaskName, after.CurrentTaskName)
|
||||
})
|
||||
|
||||
t.Run("handles_chinese_content", func(t *testing.T) {
|
||||
err := s.UpdateUIFields(ctx, "exec_test_uifields_001", "生成月度报告", "任务 2/3: 分析数据")
|
||||
require.NoError(t, err)
|
||||
|
||||
saved, err := s.Get(ctx, "exec_test_uifields_001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "生成月度报告", saved.Name)
|
||||
assert.Equal(t, "任务 2/3: 分析数据", saved.CurrentTaskName)
|
||||
})
|
||||
|
||||
t.Run("handles_long_content", func(t *testing.T) {
|
||||
longName := "This is a very long execution name that might come from a detailed user instruction about what they want the robot to accomplish in this particular run cycle"
|
||||
longTask := "Task 1/5: Processing a complex multi-step operation with various sub-tasks that need to be completed..."
|
||||
|
||||
err := s.UpdateUIFields(ctx, "exec_test_uifields_001", longName, longTask)
|
||||
require.NoError(t, err)
|
||||
|
||||
saved, err := s.Get(ctx, "exec_test_uifields_001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, longName, saved.Name)
|
||||
assert.Equal(t, longTask, saved.CurrentTaskName)
|
||||
})
|
||||
}
|
||||
|
||||
// TestExecutionStoreDelete tests deleting execution records
|
||||
func TestExecutionStoreDelete(t *testing.T) {
|
||||
if testing.Short() {
|
||||
|
|
@ -569,17 +668,19 @@ func TestExecutionRecordConversion(t *testing.T) {
|
|||
now := time.Now()
|
||||
endTime := now.Add(time.Hour)
|
||||
exec := &types.Execution{
|
||||
ID: "exec_convert_001",
|
||||
MemberID: "member_convert_001",
|
||||
TeamID: "team_convert_001",
|
||||
TriggerType: types.TriggerHuman,
|
||||
Status: types.ExecCompleted,
|
||||
Phase: types.PhaseDelivery,
|
||||
StartTime: now,
|
||||
EndTime: &endTime,
|
||||
Error: "",
|
||||
Inspiration: &types.InspirationReport{Content: "Test inspiration"},
|
||||
Goals: &types.Goals{Content: "Test goals"},
|
||||
ID: "exec_convert_001",
|
||||
MemberID: "member_convert_001",
|
||||
TeamID: "team_convert_001",
|
||||
TriggerType: types.TriggerHuman,
|
||||
Status: types.ExecCompleted,
|
||||
Phase: types.PhaseDelivery,
|
||||
StartTime: now,
|
||||
EndTime: &endTime,
|
||||
Error: "",
|
||||
Name: "Analyze sales data",
|
||||
CurrentTaskName: "Task 1/3: Processing",
|
||||
Inspiration: &types.InspirationReport{Content: "Test inspiration"},
|
||||
Goals: &types.Goals{Content: "Test goals"},
|
||||
Tasks: []types.Task{
|
||||
{ID: "task_001", ExecutorType: types.ExecutorAssistant},
|
||||
},
|
||||
|
|
@ -608,22 +709,27 @@ func TestExecutionRecordConversion(t *testing.T) {
|
|||
assert.Len(t, record.Results, 1)
|
||||
assert.NotNil(t, record.Current)
|
||||
assert.Equal(t, 1, record.Current.TaskIndex)
|
||||
// Verify UI fields conversion
|
||||
assert.Equal(t, "Analyze sales data", record.Name)
|
||||
assert.Equal(t, "Task 1/3: Processing", record.CurrentTaskName)
|
||||
})
|
||||
|
||||
t.Run("converts_to_execution", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
endTime := now.Add(time.Hour)
|
||||
record := &store.ExecutionRecord{
|
||||
ExecutionID: "exec_convert_002",
|
||||
MemberID: "member_convert_002",
|
||||
TeamID: "team_convert_002",
|
||||
TriggerType: types.TriggerClock,
|
||||
Status: types.ExecRunning,
|
||||
Phase: types.PhaseRun,
|
||||
StartTime: &now,
|
||||
EndTime: &endTime,
|
||||
Inspiration: &types.InspirationReport{Content: "Test inspiration"},
|
||||
Goals: &types.Goals{Content: "Test goals"},
|
||||
ExecutionID: "exec_convert_002",
|
||||
MemberID: "member_convert_002",
|
||||
TeamID: "team_convert_002",
|
||||
TriggerType: types.TriggerClock,
|
||||
Status: types.ExecRunning,
|
||||
Phase: types.PhaseRun,
|
||||
StartTime: &now,
|
||||
EndTime: &endTime,
|
||||
Name: "定时执行",
|
||||
CurrentTaskName: "任务 1/2: 数据分析",
|
||||
Inspiration: &types.InspirationReport{Content: "Test inspiration"},
|
||||
Goals: &types.Goals{Content: "Test goals"},
|
||||
Tasks: []types.Task{
|
||||
{ID: "task_002", ExecutorType: types.ExecutorProcess},
|
||||
},
|
||||
|
|
@ -650,6 +756,9 @@ func TestExecutionRecordConversion(t *testing.T) {
|
|||
assert.Len(t, exec.Results, 1)
|
||||
assert.NotNil(t, exec.Current)
|
||||
assert.Equal(t, 0, exec.Current.TaskIndex)
|
||||
// Verify UI fields conversion
|
||||
assert.Equal(t, "定时执行", exec.Name)
|
||||
assert.Equal(t, "任务 1/2: 数据分析", exec.CurrentTaskName)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,17 +7,18 @@ import (
|
|||
|
||||
// Config - robot_config in __yao.member
|
||||
type Config struct {
|
||||
Triggers *Triggers `json:"triggers,omitempty"`
|
||||
Clock *Clock `json:"clock,omitempty"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Quota *Quota `json:"quota,omitempty"`
|
||||
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
|
||||
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
|
||||
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
|
||||
Resources *Resources `json:"resources,omitempty"`
|
||||
Delivery *DeliveryPreferences `json:"delivery,omitempty"` // delivery preferences (see robot.go)
|
||||
Events []Event `json:"events,omitempty"`
|
||||
Executor *ExecutorConfig `json:"executor,omitempty"` // executor mode settings
|
||||
Triggers *Triggers `json:"triggers,omitempty"`
|
||||
Clock *Clock `json:"clock,omitempty"`
|
||||
Identity *Identity `json:"identity"`
|
||||
Quota *Quota `json:"quota,omitempty"`
|
||||
KB *KB `json:"kb,omitempty"` // shared knowledge base (same as assistant)
|
||||
DB *DB `json:"db,omitempty"` // shared database (same as assistant)
|
||||
Learn *Learn `json:"learn,omitempty"` // learning config for private KB
|
||||
Resources *Resources `json:"resources,omitempty"`
|
||||
Delivery *DeliveryPreferences `json:"delivery,omitempty"` // delivery preferences (see robot.go)
|
||||
Events []Event `json:"events,omitempty"`
|
||||
Executor *ExecutorConfig `json:"executor,omitempty"` // executor mode settings
|
||||
DefaultLocale string `json:"default_locale,omitempty"` // default language for clock/event triggers ("en", "zh")
|
||||
}
|
||||
|
||||
// ExecutorConfig - executor settings
|
||||
|
|
@ -59,6 +60,14 @@ func (c *Config) Validate() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// GetDefaultLocale returns the default locale (default: "en")
|
||||
func (c *Config) GetDefaultLocale() string {
|
||||
if c == nil || c.DefaultLocale == "" {
|
||||
return "en"
|
||||
}
|
||||
return c.DefaultLocale
|
||||
}
|
||||
|
||||
// Triggers - trigger enable/disable
|
||||
type Triggers struct {
|
||||
Clock *TriggerSwitch `json:"clock,omitempty"`
|
||||
|
|
|
|||
|
|
@ -129,6 +129,11 @@ type Execution struct {
|
|||
Phase Phase `json:"phase"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
// UI display fields (updated by executor at each phase)
|
||||
// These provide human-readable status for frontend display
|
||||
Name string `json:"name,omitempty"` // Execution title (updated when goals complete)
|
||||
CurrentTaskName string `json:"current_task_name,omitempty"` // Current task description (updated during run phase)
|
||||
|
||||
// Trigger input (stored for traceability)
|
||||
Input *TriggerInput `json:"input,omitempty"` // original trigger input
|
||||
|
||||
|
|
@ -163,6 +168,7 @@ type TriggerInput struct {
|
|||
Action InterventionAction `json:"action,omitempty"` // task.add, goal.adjust, etc.
|
||||
Messages []agentcontext.Message `json:"messages,omitempty"` // user's input (text, images, files)
|
||||
UserID string `json:"user_id,omitempty"` // who triggered
|
||||
Locale string `json:"locale,omitempty"` // language for UI display (e.g., "en-US", "zh-CN")
|
||||
|
||||
// For event trigger
|
||||
Source EventSource `json:"source,omitempty"` // webhook | database
|
||||
|
|
|
|||
338
data/bindata.go
338
data/bindata.go
File diff suppressed because it is too large
Load diff
|
|
@ -253,9 +253,13 @@ type ExecutionResponse struct {
|
|||
Status string `json:"status"`
|
||||
Phase string `json:"phase"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
JobID string `json:"job_id"`
|
||||
Name string `json:"name,omitempty"` // Localized execution name
|
||||
CurrentTaskName string `json:"current_task_name,omitempty"` // Localized current task
|
||||
|
||||
// UI display fields (from backend Execution)
|
||||
// These are updated by executor at each phase for frontend display
|
||||
Name string `json:"name,omitempty"` // Execution title
|
||||
CurrentTaskName string `json:"current_task_name,omitempty"` // Current task description
|
||||
|
||||
// Phase outputs (for detail view)
|
||||
Goals *GoalsResponse `json:"goals,omitempty"`
|
||||
Tasks []TaskResponse `json:"tasks,omitempty"`
|
||||
Current *CurrentState `json:"current,omitempty"`
|
||||
|
|
@ -263,6 +267,17 @@ type ExecutionResponse struct {
|
|||
}
|
||||
```
|
||||
|
||||
**UI Display Fields Update Timeline:**
|
||||
|
||||
| Phase | `Name` | `CurrentTaskName` |
|
||||
|-------|--------|-------------------|
|
||||
| Created | Human: from `input.messages[0]`<br>Clock/Event: "Preparing..." | "Starting..." |
|
||||
| `inspiration` | - | "Analyzing context..." |
|
||||
| `goals` complete | Extracted from first goal in `goals.content` | "Planning goals..." |
|
||||
| `tasks` | - | "Breaking down tasks..." |
|
||||
| `run` (each task) | - | Current task description |
|
||||
| Completed/Failed | - | "Completed" / "Failed: {error}" |
|
||||
|
||||
### 4.4 ResultResponse
|
||||
|
||||
```go
|
||||
|
|
@ -491,13 +506,50 @@ data: {"error": "Something went wrong", "phase": "run"}
|
|||
|
||||
### 8.1 Locale Detection
|
||||
|
||||
Priority order:
|
||||
1. Query parameter: `?locale=zh-CN`
|
||||
2. Request body field: `locale: "zh-CN"`
|
||||
3. Accept-Language header
|
||||
4. Default: `en-US`
|
||||
**For API requests (Human trigger):**
|
||||
|
||||
### 8.2 Localized Fields
|
||||
Priority order:
|
||||
1. Request body field: `locale: "zh-CN"` (in TriggerRequest)
|
||||
2. Query parameter: `?locale=zh-CN`
|
||||
3. Accept-Language header
|
||||
4. Robot's `default_locale` config
|
||||
5. System default: `en-US`
|
||||
|
||||
**For Clock/Event triggers (no user context):**
|
||||
|
||||
Priority order:
|
||||
1. Robot's `default_locale` config (from `robot_config.default_locale`)
|
||||
2. System default: `en-US`
|
||||
|
||||
### 8.2 Robot Default Locale
|
||||
|
||||
Robots can configure a default language for clock/event triggered executions:
|
||||
|
||||
```go
|
||||
// In RobotConfig (robot_config field in __yao.member)
|
||||
type Config struct {
|
||||
// ... other fields ...
|
||||
DefaultLocale string `json:"default_locale,omitempty"` // "en-US", "zh-CN"
|
||||
}
|
||||
```
|
||||
|
||||
**Language resolution:**
|
||||
```go
|
||||
func getLocale(robot *Robot, input *TriggerInput) string {
|
||||
// 1. Human trigger with explicit locale
|
||||
if input != nil && input.Locale != "" {
|
||||
return input.Locale
|
||||
}
|
||||
// 2. Robot configured default
|
||||
if robot.Config != nil && robot.Config.DefaultLocale != "" {
|
||||
return robot.Config.DefaultLocale
|
||||
}
|
||||
// 3. System default
|
||||
return "en-US"
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Localized Fields
|
||||
|
||||
| Response Type | Localized Fields |
|
||||
|---------------|------------------|
|
||||
|
|
|
|||
|
|
@ -6,6 +6,188 @@
|
|||
|
||||
---
|
||||
|
||||
## Field Alignment Review Summary
|
||||
|
||||
> Last reviewed: 2026-01-23
|
||||
|
||||
### Robot Fields ✅ Fully Aligned
|
||||
|
||||
| Backend (`types.go`) | Frontend (`types.ts`) | Status |
|
||||
|---------------------|----------------------|--------|
|
||||
| `member_id` | `member_id` | ✅ |
|
||||
| `team_id` | `team_id` | ✅ |
|
||||
| `display_name` | `display_name` | ✅ |
|
||||
| `bio` | `bio` / `description` | ✅ |
|
||||
| `name` (← member_id) | `name` | ✅ |
|
||||
| `description` (← bio) | `description` | ✅ |
|
||||
| `robot_status` | `robot_status` | ✅ |
|
||||
| `autonomous_mode` | `autonomous_mode` | ✅ |
|
||||
| `robot_config` | `robot_config` | ✅ |
|
||||
| `robot_email` | `robot_email` | ✅ |
|
||||
| All other fields | Same | ✅ |
|
||||
|
||||
### Execution Fields ✅ Aligned
|
||||
|
||||
| Backend (`types.go`) | Frontend (`types.ts`) | Status |
|
||||
|---------------------|----------------------|--------|
|
||||
| `id` | `id` | ✅ Aligned |
|
||||
| `member_id` | `member_id` | ✅ |
|
||||
| `team_id` | `team_id` | ✅ |
|
||||
| `trigger_type` | `trigger_type` | ✅ |
|
||||
| `status` | `status` | ✅ |
|
||||
| `phase` | `phase` | ✅ |
|
||||
| `start_time` | `start_time` | ✅ |
|
||||
| `end_time` | `end_time` | ✅ |
|
||||
| `error` | `error` | ✅ |
|
||||
| `input` | `input` | ✅ Optional |
|
||||
| Phase outputs | Same | ✅ Detail view |
|
||||
| `name` | `name` | ✅ Added |
|
||||
| `current_task_name` | `current_task_name` | ✅ Added |
|
||||
| - | `job_id` | 🗑️ **Dead field, to be removed** |
|
||||
|
||||
**Action Items:**
|
||||
- [x] **Backend**: `Execution` struct - add `Name`, `CurrentTaskName` fields (see Improvement Plan below)
|
||||
- [x] **Backend**: `RobotConfig` struct - add `DefaultLocale` field (see Improvement Plan below)
|
||||
- [x] **Backend**: `TriggerInput` struct - add `Locale` field (see Improvement Plan below)
|
||||
- [x] **Backend**: Database model `execution.mod.yao` - add `name`, `current_task_name` columns
|
||||
- [x] **Backend**: Executor - update `Name`, `CurrentTaskName` at each phase
|
||||
- [x] **Backend**: Store layer - add `UpdateUIFields()` method
|
||||
- [x] **Backend**: Unit tests for UI fields and i18n (executor/standard/ui_fields_test.go, store/execution_test.go)
|
||||
- [ ] **Frontend**: Remove `job_id` field from `types.ts`
|
||||
- [ ] **Frontend**: Remove `job_id` mock data from `mock/data.ts`
|
||||
- [ ] **Frontend**: Use `name` and `current_task_name` directly from API response
|
||||
|
||||
---
|
||||
|
||||
### Improvement Plan: Execution UI Display Fields ✅ Implemented
|
||||
|
||||
> **Problem:** Frontend needs to display "execution title" and "current task", which must be dynamically updated at different phases
|
||||
> **Solution:** Backend manages these fields centrally; `Execution` struct gets new fields, executor updates them at each phase
|
||||
|
||||
**1. Execution struct fields (`agent/robot/types/robot.go`):** ✅
|
||||
```go
|
||||
type Execution struct {
|
||||
// ... existing fields ...
|
||||
|
||||
// UI display fields (updated by executor at each phase)
|
||||
Name string `json:"name,omitempty"` // Execution title
|
||||
CurrentTaskName string `json:"current_task_name,omitempty"` // Current task description
|
||||
}
|
||||
```
|
||||
|
||||
**2. Update timeline:** ✅
|
||||
|
||||
| Phase | `Name` | `CurrentTaskName` |
|
||||
|-------|--------|-------------------|
|
||||
| Created | Human: extract from `input.messages[0]`<br>Clock/Event: "Preparing..." (localized) | "Starting..." (localized) |
|
||||
| `inspiration` | - | "Analyzing context..." (localized) |
|
||||
| `goals` complete | Extract first line from `goals.content` | "Planning goals..." (localized) |
|
||||
| `tasks` | - | "Breaking down tasks..." (localized) |
|
||||
| `run` (each task) | - | Current `task` description (e.g., "Task 1/3: ...") |
|
||||
| Completed/Failed | - | "Completed" / "Failed: {error}" (localized) |
|
||||
|
||||
**3. Implementation files:**
|
||||
- `agent/robot/types/robot.go` - Execution struct fields ✅
|
||||
- `agent/robot/store/execution.go` - UpdateUIFields() method ✅
|
||||
- `agent/robot/executor/standard/executor.go` - initUIFields(), updateUIFields(), i18n messages ✅
|
||||
- `agent/robot/executor/standard/inspiration.go` - Update CurrentTaskName ✅
|
||||
- `agent/robot/executor/standard/goals.go` - Update Name and CurrentTaskName ✅
|
||||
- `agent/robot/executor/standard/tasks.go` - Update CurrentTaskName ✅
|
||||
- `agent/robot/executor/standard/run.go` - Update CurrentTaskName for each task ✅
|
||||
- `yao/models/agent/execution.mod.yao` - Database columns ✅
|
||||
|
||||
---
|
||||
|
||||
### Improvement Plan: i18n Default Locale ✅ Implemented
|
||||
|
||||
> **Problem:** Clock/Event triggers have no user context, unknown which language to use for generated content
|
||||
> **Solution:** `RobotConfig` gets a default locale configuration field
|
||||
|
||||
**1. RobotConfig struct field (`agent/robot/types/config.go`):** ✅
|
||||
```go
|
||||
type Config struct {
|
||||
// ... existing fields ...
|
||||
DefaultLocale string `json:"default_locale,omitempty"` // "en" | "zh", default "en"
|
||||
}
|
||||
|
||||
// GetDefaultLocale returns the default locale (default: "en")
|
||||
func (c *Config) GetDefaultLocale() string {
|
||||
if c == nil || c.DefaultLocale == "" {
|
||||
return "en"
|
||||
}
|
||||
return c.DefaultLocale
|
||||
}
|
||||
```
|
||||
|
||||
**2. TriggerInput struct field (`agent/robot/types/robot.go`):** ✅
|
||||
```go
|
||||
type TriggerInput struct {
|
||||
// ... existing fields ...
|
||||
Locale string `json:"locale,omitempty"` // Language from human trigger
|
||||
}
|
||||
```
|
||||
|
||||
**3. Locale determination logic (`agent/robot/executor/standard/executor.go`):** ✅
|
||||
```go
|
||||
func getEffectiveLocale(robot *Robot, input *TriggerInput) string {
|
||||
// 1. Human trigger: use locale from request
|
||||
if input != nil && input.Locale != "" {
|
||||
return input.Locale
|
||||
}
|
||||
// 2. Clock/Event trigger: use Robot config
|
||||
if robot != nil && robot.Config != nil {
|
||||
return robot.Config.GetDefaultLocale()
|
||||
}
|
||||
// 3. System default
|
||||
return "en"
|
||||
}
|
||||
```
|
||||
|
||||
**4. Locale source priority:** ✅
|
||||
|
||||
| Trigger Type | Locale Source |
|
||||
|--------------|---------------|
|
||||
| Human | Request `locale` → Robot `default_locale` → "en" |
|
||||
| Event | Robot `default_locale` → "en" |
|
||||
| Clock | Robot `default_locale` → "en" |
|
||||
|
||||
**5. Localized messages (`executor.go`):** ✅
|
||||
```go
|
||||
var uiMessages = map[string]map[string]string{
|
||||
"en": {
|
||||
"preparing": "Preparing...",
|
||||
"starting": "Starting...",
|
||||
"scheduled_execution": "Scheduled execution",
|
||||
"event_prefix": "Event: ",
|
||||
"event_triggered": "Event triggered",
|
||||
"analyzing_context": "Analyzing context...",
|
||||
"planning_goals": "Planning goals...",
|
||||
"breaking_down_tasks": "Breaking down tasks...",
|
||||
"completed": "Completed",
|
||||
"failed_prefix": "Failed: ",
|
||||
"task_prefix": "Task",
|
||||
},
|
||||
"zh": {
|
||||
"preparing": "准备中...",
|
||||
"starting": "启动中...",
|
||||
"scheduled_execution": "定时执行",
|
||||
// ... more Chinese messages
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** User preference locale fallback deferred to future version
|
||||
|
||||
### Deferred Features (Phase 5/6)
|
||||
|
||||
| Feature | Current Status | Future Plan |
|
||||
|---------|---------------|-------------|
|
||||
| Trigger/Intervene UI | Backend done, frontend deferred | Phase 5 (requires SSE) |
|
||||
| Real-time refresh | Polling 60s | Phase 6 (SSE streams) |
|
||||
| Multi-turn chat | Not started | Phase 5 |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
> **Integrate frontend immediately after each phase to validate deliverables.**
|
||||
|
|
@ -42,8 +224,8 @@
|
|||
└─ Locale parameter support
|
||||
|
||||
🟡 Medium Risk (Deferred):
|
||||
Phase 5: Multi-turn Chat API
|
||||
Phase 6: Real-time SSE Streams
|
||||
Phase 5: Multi-turn Chat API + Trigger/Intervene UI
|
||||
Phase 6: Real-time SSE Streams (replace polling)
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -277,88 +459,207 @@
|
|||
|
||||
---
|
||||
|
||||
## 🟢 Phase 2: Execution Management ⬜ [Low Risk]
|
||||
## 🟢 Phase 2: Execution Management [Backend ✅ | Frontend ⬜]
|
||||
|
||||
> **Backend:** Steps 1-4 ✅ Complete (including UI fields and i18n)
|
||||
> **Frontend:** Step 5 ⬜ Pending
|
||||
> **Deferred:** Trigger/Intervene UI → Phase 5 (requires SSE)
|
||||
|
||||
**Goal:** Execution listing, details, control, and trigger/intervene (single-submit mode)
|
||||
**Risk:** 🟢 Low - Wraps existing API functions
|
||||
**Risk:** 🟢 Low - Wraps existing `robot/api` functions
|
||||
**Workflow:** 1. Implement All Endpoints → 2. Linter Check → 3. Code Review → 4. Unit Tests → 5. Frontend Integration
|
||||
|
||||
### 2.1 List Executions ⬜
|
||||
---
|
||||
|
||||
- [ ] `execution.go` - GET /v1/robots/:id/executions
|
||||
- [ ] Parse query params: `status`, `trigger_type`, `keyword`, `page`, `pagesize`
|
||||
- [ ] Call `robot/api.GetExecutions()`
|
||||
- [ ] Add derived fields: `name`, `current_task_name`
|
||||
- [ ] Format response
|
||||
- [ ] Test: `tests/robot/execution_list_test.go`
|
||||
### Step 1: Implement All OpenAPI Endpoints ✅
|
||||
|
||||
### 2.2 Get Execution ⬜
|
||||
> Location: `yao/openapi/agent/robot/`
|
||||
> Calls: `yao/agent/robot/api/` (existing functions)
|
||||
|
||||
- [ ] GET /v1/robots/:id/executions/:exec_id
|
||||
- [ ] Call `robot/api.GetExecution()`
|
||||
- [ ] Full task details with localization
|
||||
- [ ] Test: `tests/robot/execution_get_test.go`
|
||||
#### 2.1.1 Types (`types.go`) ✅
|
||||
|
||||
### 2.3 Execution Control ⬜
|
||||
- [x] `ExecutionFilter` - query params for listing
|
||||
- [x] `ExecutionResponse` - single execution response
|
||||
- [x] `ExecutionListResponse` - paginated list response
|
||||
- [x] `ExecutionControlResponse` - pause/resume/cancel response
|
||||
- [x] `TriggerRequest` - trigger execution request
|
||||
- [x] `TriggerResponse` - trigger result response
|
||||
- [x] `InterveneRequest` - human intervention request
|
||||
- [x] `InterveneResponse` - intervention result response
|
||||
|
||||
- [ ] POST /v1/robots/:id/executions/:exec_id/pause
|
||||
- [ ] Call `robot/api.Pause()`
|
||||
- [ ] POST /v1/robots/:id/executions/:exec_id/resume
|
||||
- [ ] Call `robot/api.Resume()`
|
||||
- [ ] POST /v1/robots/:id/executions/:exec_id/cancel
|
||||
- [ ] Call `robot/api.Stop()`
|
||||
- [ ] POST /v1/robots/:id/executions/:exec_id/retry
|
||||
- [ ] Re-trigger with same input
|
||||
- [ ] Test: `tests/robot/execution_control_test.go`
|
||||
#### 2.1.2 Execution Handlers (`execution.go`) ✅
|
||||
|
||||
### 2.4 Execution Types ⬜
|
||||
> **Permission Note:** Execution permissions are inherited from the parent robot.
|
||||
> Check robot's `__yao_team_id` and `__yao_created_by` for access control.
|
||||
|
||||
- [ ] Add to `types.go`:
|
||||
- [ ] `ExecutionResponse` struct
|
||||
- [ ] `TaskResponse` struct
|
||||
- [ ] `CurrentStateResponse` struct
|
||||
- [ ] `GoalsResponse` struct
|
||||
- [ ] `DeliveryResultResponse` struct
|
||||
- [x] `ListExecutions` - GET /v1/agent/robots/:id/executions
|
||||
- Parse query: `status`, `trigger_type`, `keyword`, `page`, `pagesize`
|
||||
- Call `robot/api.ListExecutions()`
|
||||
- Permission: Check robot CanRead (via robot ID)
|
||||
- [x] `GetExecution` - GET /v1/agent/robots/:id/executions/:exec_id
|
||||
- Call `robot/api.GetExecution()`
|
||||
- Permission: Check robot CanRead (via robot ID)
|
||||
- [x] `PauseExecution` - POST /v1/agent/robots/:id/executions/:exec_id/pause
|
||||
- Call `robot/api.PauseExecution()`
|
||||
- Permission: Check robot CanWrite (via robot ID)
|
||||
- [x] `ResumeExecution` - POST /v1/agent/robots/:id/executions/:exec_id/resume
|
||||
- Call `robot/api.ResumeExecution()`
|
||||
- Permission: Check robot CanWrite (via robot ID)
|
||||
- [x] `CancelExecution` - POST /v1/agent/robots/:id/executions/:exec_id/cancel
|
||||
- Call `robot/api.StopExecution()`
|
||||
- Permission: Check robot CanWrite (via robot ID)
|
||||
|
||||
### 2.5 Trigger & Intervene (Single-Submit Mode) ⬜
|
||||
#### 2.1.3 Trigger Handlers (`trigger.go`) ✅
|
||||
|
||||
> **Note:** This is single-submit mode. Multi-turn chat is deferred to Phase 5.
|
||||
> **Permission Note:** Same as execution - check robot's permission.
|
||||
|
||||
- [ ] `trigger.go` - POST /v1/robots/:id/trigger
|
||||
- [ ] Parse `TriggerRequest` (messages, attachments)
|
||||
- [ ] Call `robot/api.Trigger()`
|
||||
- [ ] Return execution ID and status
|
||||
- [ ] Optional: Return SSE stream for progress
|
||||
- [ ] Test: `tests/robot/trigger_test.go`
|
||||
- [x] `TriggerRobot` - POST /v1/agent/robots/:id/trigger
|
||||
- Parse `TriggerRequest` (messages, trigger_type)
|
||||
- Call `robot/api.Trigger()`
|
||||
- Return execution ID and status
|
||||
- Permission: Check robot CanWrite (via robot ID)
|
||||
- [x] `InterveneRobot` - POST /v1/agent/robots/:id/intervene
|
||||
- Parse `InterveneRequest` (action, messages)
|
||||
- Call `robot/api.Intervene()`
|
||||
- Return result
|
||||
- Permission: Check robot CanWrite (via robot ID)
|
||||
|
||||
- [ ] POST /v1/robots/:id/intervene
|
||||
- [ ] Parse `InterveneRequest`
|
||||
- [ ] Call `robot/api.Intervene()`
|
||||
- [ ] Return result
|
||||
- [ ] Test: `tests/robot/intervene_test.go`
|
||||
#### 2.1.4 Route Registration (`robot.go`) ✅
|
||||
|
||||
### 2.6 Trigger Types ⬜
|
||||
- [x] Add execution routes to `Attach()`:
|
||||
- `GET /:id/executions`
|
||||
- `GET /:id/executions/:exec_id`
|
||||
- `POST /:id/executions/:exec_id/pause`
|
||||
- `POST /:id/executions/:exec_id/resume`
|
||||
- `POST /:id/executions/:exec_id/cancel`
|
||||
- `POST /:id/trigger`
|
||||
- `POST /:id/intervene`
|
||||
|
||||
- [ ] Add to `types.go`:
|
||||
- [ ] `TriggerRequest` struct
|
||||
- [ ] `TriggerResponse` struct
|
||||
- [ ] `InterveneRequest` struct
|
||||
- [ ] `InterveneResponse` struct
|
||||
- [ ] `Message` struct
|
||||
- [ ] `Attachment` struct
|
||||
---
|
||||
|
||||
### 2.7 Frontend Integration ⬜
|
||||
### Step 2: Linter Check ✅
|
||||
|
||||
> Integrate immediately after backend completion
|
||||
- [x] Run `ReadLints` on all modified files
|
||||
- [x] Fix any linter errors
|
||||
- [x] Verify imports are correct
|
||||
- [x] Build verification passed
|
||||
|
||||
- [ ] SDK: Add execution methods to `robot.ts`
|
||||
- [ ] `listExecutions(robotId, params)`
|
||||
- [ ] `getExecution(robotId, execId)`
|
||||
- [ ] `pauseExecution()`, `resumeExecution()`, `cancelExecution()`
|
||||
- [ ] `triggerRobot(robotId, data)`
|
||||
- [ ] `intervene(robotId, data)`
|
||||
- [ ] Page: Execution list/detail page integration
|
||||
- [ ] Page: Assign Task (trigger execution) integration
|
||||
- [ ] Verify: E2E testing
|
||||
---
|
||||
|
||||
### Step 3: Code Review ✅
|
||||
|
||||
- [x] Review type definitions (`types.go`)
|
||||
- `ExecutionFilter`, `ExecutionResponse`, `ExecutionListResponse`, `ExecutionControlResponse`
|
||||
- `TriggerRequest`, `TriggerResponse`, `InterveneRequest`, `InterveneResponse`
|
||||
- Conversion functions: `NewExecutionListResponse`, `NewExecutionResponseFromExecution`, `NewExecutionResponseBrief`
|
||||
- [x] Review permission handling
|
||||
- All execution/trigger handlers check robot permission first
|
||||
- Read permission for listing and getting executions
|
||||
- Write permission for control (pause/resume/cancel), trigger, and intervene
|
||||
- Permission inherited from parent robot (check via `YaoTeamID` and `YaoCreatedBy`)
|
||||
- [x] Review error handling
|
||||
- Fixed: Use `errors.Is()` instead of `==` for error comparison
|
||||
- Proper HTTP status codes (400, 404, 403, 500)
|
||||
- Consistent error response format
|
||||
- [x] Review response formats
|
||||
- Brief format for list view (omits phase outputs)
|
||||
- Full format for detail view (includes all fields)
|
||||
- Consistent with existing robot responses
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Unit Tests ✅
|
||||
|
||||
> Location: `yao/openapi/tests/agent/`
|
||||
> Uses `testing.Short()` to skip AI/manager-dependent tests
|
||||
|
||||
- [x] Create `robot_execution_test.go`
|
||||
- [x] `TestListExecutions` - list executions with pagination/filters
|
||||
- [x] `TestGetExecution` - get execution details, not found cases
|
||||
- [x] `TestExecutionControl` - pause/resume/cancel endpoints
|
||||
- [x] `TestExecutionPermissions` - permission inheritance from robot
|
||||
- [x] Create `robot_trigger_test.go`
|
||||
- [x] `TestTriggerRobot` - trigger with messages, action, invalid body
|
||||
- [x] `TestInterveneRobot` - intervene with action, missing action validation
|
||||
- [x] `TestTriggerPermissions` - permission inheritance from robot
|
||||
- [x] All tests use `testing.Short()` to skip AI-dependent tests
|
||||
- [x] Tests compile successfully
|
||||
- [x] All tests pass (with manager not started gracefully handled)
|
||||
|
||||
---
|
||||
|
||||
### Step 5: Frontend Integration ⬜
|
||||
|
||||
> Location: `cui/packages/cui/openapi/agent/robot/`
|
||||
> **Note:** Trigger/Intervene API deferred to Phase 5 (waiting for SSE support)
|
||||
> **Note:** Use 1-minute polling for execution list refresh (will switch to SSE in Phase 6)
|
||||
|
||||
#### 5.1 Prerequisites ✅
|
||||
|
||||
> **Dependency:** Backend improvement plans completed (see "Improvement Plan" sections above)
|
||||
|
||||
**Backend (Completed):**
|
||||
- [x] `Execution` struct - add `Name`, `CurrentTaskName` fields
|
||||
- [x] `RobotConfig` struct - add `DefaultLocale` field
|
||||
- [x] `TriggerInput` struct - add `Locale` field
|
||||
- [x] Executor - update `Name`, `CurrentTaskName` at each phase
|
||||
- [x] Store - add `UpdateUIFields()` method
|
||||
- [x] Unit tests for UI fields and i18n
|
||||
|
||||
**Frontend Cleanup (Pending):**
|
||||
- [x] Components already use `exec.id` (no changes needed)
|
||||
- [ ] Remove `job_id` field from `types.ts`
|
||||
- [ ] Remove `job_id` from `mock/data.ts`
|
||||
- [ ] Use `name`/`current_task_name` directly from API response
|
||||
|
||||
#### 5.2 SDK Types (`types.ts`) ⬜
|
||||
|
||||
- [ ] `ExecutionFilter` interface
|
||||
- [ ] `Execution` interface (align with backend `ExecutionResponse`)
|
||||
- [ ] `ExecutionListResponse` interface
|
||||
- [ ] `ExecutionControlResponse` interface
|
||||
|
||||
**Deferred to Phase 5 (SSE):**
|
||||
- [ ] ~~`TriggerRequest` / `TriggerResponse` interfaces~~
|
||||
- [ ] ~~`InterveneRequest` / `InterveneResponse` interfaces~~
|
||||
|
||||
#### 5.3 SDK Methods (`robots.ts`) ⬜
|
||||
|
||||
- [ ] `ListExecutions(robotId, filter)`
|
||||
- [ ] `GetExecution(robotId, execId)`
|
||||
- [ ] `PauseExecution(robotId, execId)`
|
||||
- [ ] `ResumeExecution(robotId, execId)`
|
||||
- [ ] `CancelExecution(robotId, execId)`
|
||||
|
||||
**Deferred to Phase 5 (SSE):**
|
||||
- [ ] ~~`Trigger(robotId, data)`~~
|
||||
- [ ] ~~`Intervene(robotId, data)`~~
|
||||
|
||||
#### 5.4 Page Integration ⬜
|
||||
|
||||
- [ ] ActiveTab: Replace mock with `ListExecutions()` API
|
||||
- [ ] Filter: `status=running|pending`
|
||||
- [ ] Polling: 1-minute interval (60000ms) - will switch to SSE in Phase 6
|
||||
- [ ] HistoryTab: Replace mock with `ListExecutions()` API
|
||||
- [ ] Filter: `status` filter, `keyword` search
|
||||
- [ ] Pagination: page/pagesize
|
||||
- [ ] Polling: 1-minute interval for list refresh
|
||||
|
||||
**Deferred to Phase 5 (SSE):**
|
||||
- [ ] ~~Assign Task Modal: Call `Trigger()` API~~
|
||||
- [ ] ~~GuideExecution: Call `Intervene()` API~~
|
||||
|
||||
#### 5.5 Polling vs SSE Strategy
|
||||
|
||||
**Current (Phase 2):** Polling
|
||||
- Refresh execution list every 60 seconds
|
||||
- Manual refresh button for immediate update
|
||||
- Acceptable latency for status display
|
||||
|
||||
**Future (Phase 6):** SSE Real-time
|
||||
- `GET /robots/:id/executions/stream` - real-time execution updates
|
||||
- `GET /robots/stream` - robot status changes
|
||||
- Instant updates, no polling delay
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -455,12 +756,13 @@
|
|||
|
||||
---
|
||||
|
||||
## 🟡 Phase 5: Multi-turn Chat API ⬜ [Medium Risk - Deferred]
|
||||
## 🟡 Phase 5: Multi-turn Chat API + Trigger/Intervene UI ⬜ [Medium Risk - Deferred]
|
||||
|
||||
> **Frontend Fallback:** Single-submit mode (user input → immediate execution)
|
||||
> **Risk:** 🟡 Medium - New stateful component
|
||||
> **Dependency:** Requires SSE infrastructure (partially)
|
||||
|
||||
**Goal:** Multi-turn conversation before execution
|
||||
**Goal:** Multi-turn conversation before execution + Human trigger/intervene UI
|
||||
|
||||
### 5.1 Backend Prerequisites ⬜
|
||||
|
||||
|
|
@ -486,14 +788,36 @@
|
|||
- [ ] Use conversation history as execution input
|
||||
- [ ] Auto-cleanup conversation after execution starts
|
||||
|
||||
### 5.4 Frontend Trigger/Intervene Integration (Deferred from Phase 2) ⬜
|
||||
|
||||
> **Note:** These features require SSE for proper UX (streaming response)
|
||||
> Currently backend `/trigger` and `/intervene` endpoints exist but return immediately
|
||||
> Frontend needs streaming response to show assistant's reaction before confirming
|
||||
|
||||
**SDK Types:**
|
||||
- [ ] `TriggerRequest` / `TriggerResponse` interfaces
|
||||
- [ ] `InterveneRequest` / `InterveneResponse` interfaces
|
||||
- [ ] `ChatMessage` interface for multi-turn
|
||||
|
||||
**SDK Methods:**
|
||||
- [ ] `Trigger(robotId, data)` - with SSE support
|
||||
- [ ] `Intervene(robotId, data)` - with SSE support
|
||||
- [ ] `Chat(robotId, data)` - multi-turn conversation SSE
|
||||
|
||||
**Page Integration:**
|
||||
- [ ] AssignTaskDrawer: Multi-turn chat before trigger
|
||||
- [ ] GuideExecutionDrawer: Multi-turn intervention
|
||||
- [ ] Real-time streaming response display
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Phase 6: Real-time SSE Streams ⬜ [Medium Risk - Deferred]
|
||||
|
||||
> **Frontend Fallback:** Polling (GET /executions every 3-5 seconds)
|
||||
> **Frontend Current:** Polling every 60 seconds (1 minute)
|
||||
> **Frontend Future:** SSE streams for instant updates
|
||||
> **Risk:** 🟡 Medium - Requires modification of executor/manager
|
||||
|
||||
**Goal:** SSE streams for real-time status updates
|
||||
**Goal:** SSE streams for real-time status updates, replacing polling
|
||||
|
||||
### 6.1 Backend Event System ⬜
|
||||
|
||||
|
|
@ -632,9 +956,9 @@ yao/openapi/tests/robot/
|
|||
| 1. Core CRUD | 🟢 | ✅ | ✅ | Robot CRUD endpoints |
|
||||
| 1-FE Frontend Integration | 🟢 | - | ✅ | SDK ✅, Page Integration ✅, UI/UX ✅ |
|
||||
| 1.5 Manager Lifecycle | 🟢 | ✅ | - | Auto-start, auto-reload, graceful shutdown |
|
||||
| 2. Execution | 🟢 | ⬜ | ⬜ | Execution listing, control, trigger |
|
||||
| 2. Execution | 🟢 | ✅ | ⬜ | Execution listing, control, trigger (backend complete with UI fields & i18n) |
|
||||
| 3. Results/Activities | 🟢 | ⬜ | ⬜ | Deliverables and activity feed |
|
||||
| 4. i18n | 🟢 | ⬜ | ⬜ | Locale parameter support |
|
||||
| 4. i18n | 🟢 | ✅ | ⬜ | Locale parameter support (backend executor i18n complete) |
|
||||
| 5. Chat API | 🟡 | ⬜ | ⬜ | Multi-turn conversation (Deferred) |
|
||||
| 6. SSE Streams | 🟡 | ⬜ | ⬜ | Real-time status updates (Deferred) |
|
||||
|
||||
|
|
@ -809,8 +1133,8 @@ Each phase independently deliverable:
|
|||
| Phase | Backend | Frontend | Verifiable Features |
|
||||
|-------|---------|----------|---------------------|
|
||||
| 1 | ✅ | ✅ | Robot CRUD basic management |
|
||||
| 2 | ⬜ | ⬜ | Execution list/control/trigger |
|
||||
| 2 | ✅ | ⬜ | Execution list/control/trigger (backend with UI fields & i18n) |
|
||||
| 3 | ⬜ | ⬜ | Results/Activities viewing |
|
||||
| 4 | ⬜ | ⬜ | Multi-language support |
|
||||
| 4 | ✅ | ⬜ | Multi-language support (backend executor i18n) |
|
||||
| 5 | ⬜ | ⬜ | Multi-turn chat UX (optional) |
|
||||
| 6 | ⬜ | ⬜ | Real-time push (optional) |
|
||||
|
|
|
|||
348
openapi/agent/robot/execution.go
Normal file
348
openapi/agent/robot/execution.go
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
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"
|
||||
)
|
||||
|
||||
// ==================== Execution Handlers ====================
|
||||
// Permission Note: Execution permissions are inherited from the parent robot.
|
||||
// Check robot's __yao_team_id and __yao_created_by for access control.
|
||||
|
||||
// ListExecutions lists executions for a robot
|
||||
// GET /v1/agent/robots/:id/executions
|
||||
func ListExecutions(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 (executions inherit robot permission)
|
||||
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 executions",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
var filter ExecutionFilter
|
||||
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.ExecutionQuery{
|
||||
Page: filter.Page,
|
||||
PageSize: filter.PageSize,
|
||||
}
|
||||
if filter.Status != "" {
|
||||
query.Status = robottypes.ExecStatus(filter.Status)
|
||||
}
|
||||
if filter.TriggerType != "" {
|
||||
query.Trigger = robottypes.TriggerType(filter.TriggerType)
|
||||
}
|
||||
|
||||
// Call API layer
|
||||
result, err := robotapi.ListExecutions(ctx, robotID, query)
|
||||
if err != nil {
|
||||
log.Error("Failed to list executions for robot %s: %v", robotID, err)
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to list executions: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to response (brief format for list)
|
||||
data := make([]*ExecutionResponse, 0, len(result.Data))
|
||||
for _, exec := range result.Data {
|
||||
data = append(data, NewExecutionResponseBrief(exec))
|
||||
}
|
||||
|
||||
resp := &ExecutionListResponse{
|
||||
Data: data,
|
||||
Total: result.Total,
|
||||
Page: result.Page,
|
||||
PageSize: result.PageSize,
|
||||
}
|
||||
response.RespondWithSuccess(c, response.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetExecution gets a single execution by ID
|
||||
// GET /v1/agent/robots/:id/executions/:exec_id
|
||||
func GetExecution(c *gin.Context) {
|
||||
// Get authorized information
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
// Get robot ID and execution ID from URL parameters
|
||||
robotID := c.Param("id")
|
||||
execID := c.Param("exec_id")
|
||||
|
||||
if robotID == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "robot id is required",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
if execID == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "execution 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 executions",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Get execution
|
||||
exec, err := robotapi.GetExecution(ctx, execID)
|
||||
if err != nil {
|
||||
log.Error("Failed to get execution %s: %v", execID, err)
|
||||
|
||||
if err.Error() == "execution not found: "+execID {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Execution not found: " + execID,
|
||||
}
|
||||
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to get execution: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify execution belongs to this robot
|
||||
if exec.MemberID != robotID {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Execution does not belong to this robot",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to response (full format for detail)
|
||||
resp := NewExecutionResponseFromExecution(exec)
|
||||
response.RespondWithSuccess(c, response.StatusOK, resp)
|
||||
}
|
||||
|
||||
// PauseExecution pauses a running execution
|
||||
// POST /v1/agent/robots/:id/executions/:exec_id/pause
|
||||
func PauseExecution(c *gin.Context) {
|
||||
handleExecutionControl(c, "pause")
|
||||
}
|
||||
|
||||
// ResumeExecution resumes a paused execution
|
||||
// POST /v1/agent/robots/:id/executions/:exec_id/resume
|
||||
func ResumeExecution(c *gin.Context) {
|
||||
handleExecutionControl(c, "resume")
|
||||
}
|
||||
|
||||
// CancelExecution cancels/stops an execution
|
||||
// POST /v1/agent/robots/:id/executions/:exec_id/cancel
|
||||
func CancelExecution(c *gin.Context) {
|
||||
handleExecutionControl(c, "cancel")
|
||||
}
|
||||
|
||||
// handleExecutionControl handles pause/resume/cancel operations
|
||||
func handleExecutionControl(c *gin.Context, action string) {
|
||||
// Get authorized information
|
||||
authInfo := authorized.GetInfo(c)
|
||||
|
||||
// Get robot ID and execution ID from URL parameters
|
||||
robotID := c.Param("id")
|
||||
execID := c.Param("exec_id")
|
||||
|
||||
if robotID == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "robot id is required",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
if execID == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "execution 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 write permission on robot (control operations require write)
|
||||
if !CanWrite(c, authInfo, robotResp.YaoTeamID, robotResp.YaoCreatedBy) {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrAccessDenied.Code,
|
||||
ErrorDescription: "Forbidden: No permission to control this robot's executions",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Execute the control action
|
||||
var controlErr error
|
||||
switch action {
|
||||
case "pause":
|
||||
controlErr = robotapi.PauseExecution(ctx, execID)
|
||||
case "resume":
|
||||
controlErr = robotapi.ResumeExecution(ctx, execID)
|
||||
case "cancel":
|
||||
controlErr = robotapi.StopExecution(ctx, execID)
|
||||
default:
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Invalid action: " + action,
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
if controlErr != nil {
|
||||
log.Error("Failed to %s execution %s: %v", action, execID, controlErr)
|
||||
|
||||
// Check for common errors
|
||||
errMsg := controlErr.Error()
|
||||
if errMsg == "execution_id is required" || errMsg == "execution not found" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Execution not found: " + execID,
|
||||
}
|
||||
response.RespondWithError(c, response.StatusNotFound, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to " + action + " execution: " + controlErr.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Return success
|
||||
resp := &ExecutionControlResponse{
|
||||
ExecutionID: execID,
|
||||
Action: action + "d", // paused, resumed, cancelled
|
||||
Success: true,
|
||||
Message: "Execution " + action + "d successfully",
|
||||
}
|
||||
response.RespondWithSuccess(c, response.StatusOK, resp)
|
||||
}
|
||||
|
|
@ -22,4 +22,15 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
|
|||
|
||||
// Robot Status
|
||||
group.GET("/:id/status", GetRobotStatus) // GET /robots/:id/status - Get robot runtime status
|
||||
|
||||
// Execution Management
|
||||
group.GET("/:id/executions", ListExecutions) // GET /robots/:id/executions - List robot executions
|
||||
group.GET("/:id/executions/:exec_id", GetExecution) // GET /robots/:id/executions/:exec_id - Get execution details
|
||||
group.POST("/:id/executions/:exec_id/pause", PauseExecution) // POST /robots/:id/executions/:exec_id/pause - Pause execution
|
||||
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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
271
openapi/agent/robot/trigger.go
Normal file
271
openapi/agent/robot/trigger.go
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
package robot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/kun/log"
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
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"
|
||||
)
|
||||
|
||||
// ==================== Trigger Handlers ====================
|
||||
// Permission Note: Same as execution - check robot's permission.
|
||||
|
||||
// TriggerRobot triggers a robot execution
|
||||
// POST /v1/agent/robots/:id/trigger
|
||||
func TriggerRobot(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
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req TriggerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Invalid request body: " + err.Error(),
|
||||
}
|
||||
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 write permission on robot (trigger requires write permission)
|
||||
if !CanWrite(c, authInfo, robotResp.YaoTeamID, robotResp.YaoCreatedBy) {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrAccessDenied.Code,
|
||||
ErrorDescription: "Forbidden: No permission to trigger this robot",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Build API trigger request
|
||||
apiReq := buildAPITriggerRequest(&req)
|
||||
|
||||
// Call API layer
|
||||
result, err := robotapi.Trigger(ctx, robotID, apiReq)
|
||||
if err != nil {
|
||||
log.Error("Failed to trigger robot %s: %v", robotID, err)
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to trigger robot: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to response
|
||||
resp := &TriggerResponse{
|
||||
Accepted: result.Accepted,
|
||||
ExecutionID: result.ExecutionID,
|
||||
Queued: result.Queued,
|
||||
Message: result.Message,
|
||||
}
|
||||
|
||||
if result.Accepted {
|
||||
response.RespondWithSuccess(c, response.StatusOK, resp)
|
||||
} else {
|
||||
// Trigger was not accepted (e.g., queue full, robot paused)
|
||||
response.RespondWithSuccess(c, response.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
|
||||
// InterveneRobot performs human intervention on a robot
|
||||
// POST /v1/agent/robots/:id/intervene
|
||||
func InterveneRobot(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
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req InterveneRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "Invalid request body: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusBadRequest, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate action
|
||||
if req.Action == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidRequest.Code,
|
||||
ErrorDescription: "action 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 write permission on robot (intervention requires write permission)
|
||||
if !CanWrite(c, authInfo, robotResp.YaoTeamID, robotResp.YaoCreatedBy) {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrAccessDenied.Code,
|
||||
ErrorDescription: "Forbidden: No permission to intervene with this robot",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusForbidden, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Build API trigger request for intervention
|
||||
apiReq := &robotapi.TriggerRequest{
|
||||
Type: robottypes.TriggerHuman,
|
||||
Action: robottypes.InterventionAction(req.Action),
|
||||
PlanAt: req.PlanAt,
|
||||
}
|
||||
|
||||
// Convert messages
|
||||
if len(req.Messages) > 0 {
|
||||
apiReq.Messages = convertMessagesToContext(req.Messages)
|
||||
}
|
||||
|
||||
// Call API layer (Intervene uses TriggerHuman internally)
|
||||
result, err := robotapi.Intervene(ctx, robotID, apiReq)
|
||||
if err != nil {
|
||||
log.Error("Failed to intervene with robot %s: %v", robotID, err)
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrServerError.Code,
|
||||
ErrorDescription: "Failed to intervene: " + err.Error(),
|
||||
}
|
||||
response.RespondWithError(c, response.StatusInternalServerError, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to response
|
||||
resp := &InterveneResponse{
|
||||
Accepted: result.Accepted,
|
||||
ExecutionID: result.ExecutionID,
|
||||
Message: result.Message,
|
||||
}
|
||||
|
||||
response.RespondWithSuccess(c, response.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ==================== Helper Functions ====================
|
||||
|
||||
// buildAPITriggerRequest builds robotapi.TriggerRequest from HTTP request
|
||||
func buildAPITriggerRequest(req *TriggerRequest) *robotapi.TriggerRequest {
|
||||
apiReq := &robotapi.TriggerRequest{}
|
||||
|
||||
// Set trigger type (default to human)
|
||||
switch req.TriggerType {
|
||||
case "event":
|
||||
apiReq.Type = robottypes.TriggerEvent
|
||||
case "clock":
|
||||
apiReq.Type = robottypes.TriggerClock
|
||||
default:
|
||||
apiReq.Type = robottypes.TriggerHuman
|
||||
}
|
||||
|
||||
// Human intervention fields
|
||||
if req.Action != "" {
|
||||
apiReq.Action = robottypes.InterventionAction(req.Action)
|
||||
}
|
||||
if len(req.Messages) > 0 {
|
||||
apiReq.Messages = convertMessagesToContext(req.Messages)
|
||||
}
|
||||
|
||||
// Event fields
|
||||
if req.Source != "" {
|
||||
apiReq.Source = robottypes.EventSource(req.Source)
|
||||
}
|
||||
if req.EventType != "" {
|
||||
apiReq.EventType = req.EventType
|
||||
}
|
||||
if req.Data != nil {
|
||||
apiReq.Data = req.Data
|
||||
}
|
||||
|
||||
// Executor mode
|
||||
if req.ExecutorMode != "" {
|
||||
apiReq.ExecutorMode = robottypes.ExecutorMode(req.ExecutorMode)
|
||||
}
|
||||
|
||||
// i18n locale
|
||||
if req.Locale != "" {
|
||||
apiReq.Locale = req.Locale
|
||||
}
|
||||
|
||||
return apiReq
|
||||
}
|
||||
|
||||
// convertMessagesToContext converts MessageItem slice to agent context messages
|
||||
func convertMessagesToContext(msgs []MessageItem) []agentcontext.Message {
|
||||
result := make([]agentcontext.Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
result = append(result, agentcontext.Message{
|
||||
Role: agentcontext.MessageRole(m.Role),
|
||||
Content: m.Content,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"time"
|
||||
|
||||
robotapi "github.com/yaoapp/yao/agent/robot/api"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// ==================== Request Types ====================
|
||||
|
|
@ -253,3 +254,185 @@ func NewStatusResponse(s *robotapi.RobotState) *StatusResponse {
|
|||
RunningIDs: s.RunningIDs,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Execution Types ====================
|
||||
|
||||
// ExecutionFilter - query params for listing executions
|
||||
type ExecutionFilter struct {
|
||||
Status string `form:"status"` // pending | running | paused | completed | failed | cancelled
|
||||
TriggerType string `form:"trigger_type"` // clock | human | event
|
||||
Keyword string `form:"keyword"` // search in execution details
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"pagesize"`
|
||||
}
|
||||
|
||||
// ExecutionResponse - single execution response
|
||||
type ExecutionResponse struct {
|
||||
ID string `json:"id"`
|
||||
MemberID string `json:"member_id"`
|
||||
TeamID string `json:"team_id"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
Status string `json:"status"`
|
||||
Phase string `json:"phase"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime *time.Time `json:"end_time,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
// UI display fields (updated by executor at each phase)
|
||||
Name string `json:"name,omitempty"` // Execution title
|
||||
CurrentTaskName string `json:"current_task_name,omitempty"` // Current task description
|
||||
|
||||
// Phase outputs (optional, included in detail view)
|
||||
Inspiration interface{} `json:"inspiration,omitempty"`
|
||||
Goals interface{} `json:"goals,omitempty"`
|
||||
Tasks interface{} `json:"tasks,omitempty"`
|
||||
Current interface{} `json:"current,omitempty"`
|
||||
Results interface{} `json:"results,omitempty"`
|
||||
Delivery interface{} `json:"delivery,omitempty"`
|
||||
|
||||
// Input (optional, included in detail view)
|
||||
Input interface{} `json:"input,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutionListResponse - paginated list response
|
||||
type ExecutionListResponse struct {
|
||||
Data []*ExecutionResponse `json:"data"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pagesize"`
|
||||
}
|
||||
|
||||
// ExecutionControlResponse - response for pause/resume/cancel
|
||||
type ExecutionControlResponse struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
Action string `json:"action"` // paused | resumed | cancelled
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// ==================== Trigger Types ====================
|
||||
|
||||
// TriggerRequest - HTTP request to trigger robot execution
|
||||
type TriggerRequest struct {
|
||||
// Trigger type: human | event | clock (defaults to human)
|
||||
TriggerType string `json:"trigger_type,omitempty"`
|
||||
|
||||
// Human intervention fields
|
||||
Action string `json:"action,omitempty"` // task.add, goal.adjust, etc.
|
||||
Messages []MessageItem `json:"messages,omitempty"` // user's input
|
||||
|
||||
// Event fields
|
||||
Source string `json:"source,omitempty"` // webhook | database
|
||||
EventType string `json:"event_type,omitempty"` // lead.created, etc.
|
||||
Data map[string]interface{} `json:"data,omitempty"` // event payload
|
||||
|
||||
// Executor mode (optional)
|
||||
ExecutorMode string `json:"executor_mode,omitempty"` // standard | fast | careful
|
||||
|
||||
// i18n support
|
||||
Locale string `json:"locale,omitempty"` // Locale for UI messages (e.g., "en", "zh")
|
||||
}
|
||||
|
||||
// MessageItem - a single message in trigger request
|
||||
type MessageItem struct {
|
||||
Role string `json:"role"` // user | assistant | system
|
||||
Content string `json:"content"` // message text
|
||||
Name string `json:"name,omitempty"` // optional name
|
||||
FileID string `json:"file_id,omitempty"` // optional attachment
|
||||
}
|
||||
|
||||
// TriggerResponse - response after triggering
|
||||
type TriggerResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
ExecutionID string `json:"execution_id,omitempty"`
|
||||
Queued bool `json:"queued,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// InterveneRequest - HTTP request for human intervention
|
||||
type InterveneRequest struct {
|
||||
Action string `json:"action"` // task.add, goal.adjust, etc.
|
||||
Messages []MessageItem `json:"messages,omitempty"` // user's input
|
||||
PlanAt *time.Time `json:"plan_at,omitempty"` // schedule for later
|
||||
}
|
||||
|
||||
// InterveneResponse - response after intervention
|
||||
type InterveneResponse struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
ExecutionID string `json:"execution_id,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// ==================== Execution Conversion Functions ====================
|
||||
|
||||
// NewExecutionListResponse creates an ExecutionListResponse from api.ExecutionResult
|
||||
func NewExecutionListResponse(e *robotapi.ExecutionResult) *ExecutionListResponse {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
data := make([]*ExecutionResponse, 0, len(e.Data))
|
||||
for _, exec := range e.Data {
|
||||
data = append(data, NewExecutionResponseFromExecution(exec))
|
||||
}
|
||||
|
||||
return &ExecutionListResponse{
|
||||
Data: data,
|
||||
Total: e.Total,
|
||||
Page: e.Page,
|
||||
PageSize: e.PageSize,
|
||||
}
|
||||
}
|
||||
|
||||
// NewExecutionResponseFromExecution converts types.Execution to ExecutionResponse
|
||||
func NewExecutionResponseFromExecution(exec *robottypes.Execution) *ExecutionResponse {
|
||||
if exec == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ExecutionResponse{
|
||||
ID: exec.ID,
|
||||
MemberID: exec.MemberID,
|
||||
TeamID: exec.TeamID,
|
||||
TriggerType: string(exec.TriggerType),
|
||||
Status: string(exec.Status),
|
||||
Phase: string(exec.Phase),
|
||||
StartTime: exec.StartTime,
|
||||
EndTime: exec.EndTime,
|
||||
Error: exec.Error,
|
||||
// UI display fields
|
||||
Name: exec.Name,
|
||||
CurrentTaskName: exec.CurrentTaskName,
|
||||
// Phase outputs - include in detail view
|
||||
Inspiration: exec.Inspiration,
|
||||
Goals: exec.Goals,
|
||||
Tasks: exec.Tasks,
|
||||
Current: exec.Current,
|
||||
Results: exec.Results,
|
||||
Delivery: exec.Delivery,
|
||||
Input: exec.Input,
|
||||
}
|
||||
}
|
||||
|
||||
// NewExecutionResponseBrief creates a brief ExecutionResponse (for list view)
|
||||
func NewExecutionResponseBrief(exec *robottypes.Execution) *ExecutionResponse {
|
||||
if exec == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ExecutionResponse{
|
||||
ID: exec.ID,
|
||||
MemberID: exec.MemberID,
|
||||
TeamID: exec.TeamID,
|
||||
TriggerType: string(exec.TriggerType),
|
||||
Status: string(exec.Status),
|
||||
Phase: string(exec.Phase),
|
||||
StartTime: exec.StartTime,
|
||||
EndTime: exec.EndTime,
|
||||
Error: exec.Error,
|
||||
// UI display fields - include in list view for display
|
||||
Name: exec.Name,
|
||||
CurrentTaskName: exec.CurrentTaskName,
|
||||
// Omit phase outputs for list view
|
||||
}
|
||||
}
|
||||
|
|
|
|||
416
openapi/tests/agent/robot_execution_test.go
Normal file
416
openapi/tests/agent/robot_execution_test.go
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
package openapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/openapi"
|
||||
"github.com/yaoapp/yao/openapi/tests/testutils"
|
||||
)
|
||||
|
||||
// TestListExecutions tests the execution listing endpoint
|
||||
// GET /v1/agent/robots/:id/executions
|
||||
func TestListExecutions(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping execution 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, "Execution 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_exec_list_%d", time.Now().UnixNano())
|
||||
createRobot(t, serverURL, baseURL, tokenInfo.AccessToken, robotID, "Execution List Robot")
|
||||
defer deleteRobot(t, serverURL, baseURL, tokenInfo.AccessToken, robotID)
|
||||
|
||||
t.Run("ListExecutionsSuccess", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/executions", 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 execution items structure (if any executions exist)
|
||||
data, ok := response["data"].([]interface{})
|
||||
if ok && len(data) > 0 {
|
||||
exec := data[0].(map[string]interface{})
|
||||
// Basic fields should exist
|
||||
assert.Contains(t, exec, "id")
|
||||
assert.Contains(t, exec, "status")
|
||||
assert.Contains(t, exec, "phase")
|
||||
// UI display fields (may be empty string, but field should be present in response)
|
||||
// These are new fields added for frontend display
|
||||
t.Logf("Execution response fields: id=%v, name=%v, current_task_name=%v",
|
||||
exec["id"], exec["name"], exec["current_task_name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListExecutionsWithPagination", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/executions?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("ListExecutionsWithStatusFilter", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/executions?status=completed", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, response, "data")
|
||||
})
|
||||
|
||||
t.Run("ListExecutionsWithTriggerTypeFilter", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/executions?trigger_type=human", 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("ListExecutionsRobotNotFound", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/non_existent_robot/executions", 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("ListExecutionsUnauthorized", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/executions", 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)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetExecution tests the execution detail endpoint
|
||||
// GET /v1/agent/robots/:id/executions/:exec_id
|
||||
func TestGetExecution(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping execution 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, "Execution 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_exec_get_%d", time.Now().UnixNano())
|
||||
createRobot(t, serverURL, baseURL, tokenInfo.AccessToken, robotID, "Execution Get Robot")
|
||||
defer deleteRobot(t, serverURL, baseURL, tokenInfo.AccessToken, robotID)
|
||||
|
||||
t.Run("GetExecutionNotFound", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/executions/non_existent_exec", 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("GetExecutionRobotNotFound", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/non_existent_robot/executions/some_exec", 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)
|
||||
})
|
||||
}
|
||||
|
||||
// TestExecutionControl tests the execution control endpoints
|
||||
// POST /v1/agent/robots/:id/executions/:exec_id/pause
|
||||
// POST /v1/agent/robots/:id/executions/:exec_id/resume
|
||||
// POST /v1/agent/robots/:id/executions/:exec_id/cancel
|
||||
func TestExecutionControl(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping execution control 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, "Execution Control 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_exec_ctrl_%d", time.Now().UnixNano())
|
||||
createRobot(t, serverURL, baseURL, tokenInfo.AccessToken, robotID, "Execution Control Robot")
|
||||
defer deleteRobot(t, serverURL, baseURL, tokenInfo.AccessToken, robotID)
|
||||
|
||||
t.Run("PauseExecutionNotFound", func(t *testing.T) {
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/executions/non_existent_exec/pause", 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 error (404 or 500 depending on implementation)
|
||||
assert.True(t, resp.StatusCode >= 400, "Expected error status code")
|
||||
})
|
||||
|
||||
t.Run("ResumeExecutionNotFound", func(t *testing.T) {
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/executions/non_existent_exec/resume", 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.True(t, resp.StatusCode >= 400, "Expected error status code")
|
||||
})
|
||||
|
||||
t.Run("CancelExecutionNotFound", func(t *testing.T) {
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/executions/non_existent_exec/cancel", 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.True(t, resp.StatusCode >= 400, "Expected error status code")
|
||||
})
|
||||
|
||||
t.Run("PauseExecutionRobotNotFound", func(t *testing.T) {
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/non_existent_robot/executions/some_exec/pause", 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("ControlExecutionUnauthorized", func(t *testing.T) {
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/executions/some_exec/pause", 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)
|
||||
})
|
||||
}
|
||||
|
||||
// TestExecutionPermissions tests execution permission inheritance from robot
|
||||
func TestExecutionPermissions(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping execution 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, "Execution 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_exec_perm_%d", time.Now().UnixNano())
|
||||
createRobotWithTeam(t, serverURL, baseURL, token1.AccessToken, robotID, "Permission Test Robot", user1ID)
|
||||
defer deleteRobot(t, serverURL, baseURL, token1.AccessToken, robotID)
|
||||
|
||||
t.Run("OwnerCanListExecutions", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/executions", 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 executions for their robot")
|
||||
})
|
||||
|
||||
t.Run("OtherUserExecutionAccess", func(t *testing.T) {
|
||||
// User 2 attempts to list executions for User 1's robot
|
||||
// With system:root scope this might succeed
|
||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/robots/"+robotID+"/executions", 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 execution list attempt status: %d (with system:root scope)", resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Helper Functions ====================
|
||||
|
||||
func createRobot(t *testing.T, serverURL, baseURL, token, robotID, displayName string) {
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": "test_team_001",
|
||||
"display_name": displayName,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func createRobotWithTeam(t *testing.T, serverURL, baseURL, token, robotID, displayName, teamID string) {
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": teamID,
|
||||
"display_name": displayName,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func deleteRobot(t *testing.T, serverURL, baseURL, token, robotID string) {
|
||||
req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
|
@ -214,11 +214,11 @@ func TestCreateRobot(t *testing.T) {
|
|||
createdRobotIDs = append(createdRobotIDs, robotID)
|
||||
})
|
||||
|
||||
t.Run("CreateRobotMissingRequiredFields", func(t *testing.T) {
|
||||
// Missing member_id
|
||||
t.Run("CreateRobotMissingDisplayName", func(t *testing.T) {
|
||||
// Missing display_name (the only required field)
|
||||
createData := map[string]interface{}{
|
||||
"team_id": "test_team_001",
|
||||
"display_name": "Test Robot",
|
||||
"team_id": "test_team_001",
|
||||
// display_name is missing
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
|
|
@ -235,6 +235,41 @@ func TestCreateRobot(t *testing.T) {
|
|||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("CreateRobotAutoGenerateMemberID", func(t *testing.T) {
|
||||
// member_id is optional - should be auto-generated
|
||||
createData := map[string]interface{}{
|
||||
"team_id": "test_team_001",
|
||||
"display_name": "Auto ID Robot",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
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.StatusCreated, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify member_id was auto-generated
|
||||
memberID, ok := response["member_id"].(string)
|
||||
assert.True(t, ok, "member_id should be a string")
|
||||
assert.NotEmpty(t, memberID, "member_id should be auto-generated")
|
||||
assert.Len(t, memberID, 12, "auto-generated member_id should be 12 digits")
|
||||
t.Logf("Auto-generated member_id: %s", memberID)
|
||||
|
||||
// Cleanup
|
||||
createdRobotIDs = append(createdRobotIDs, memberID)
|
||||
})
|
||||
|
||||
t.Run("CreateRobotDuplicate", func(t *testing.T) {
|
||||
robotID := fmt.Sprintf("test_robot_dup_%d", time.Now().UnixNano())
|
||||
|
||||
|
|
|
|||
502
openapi/tests/agent/robot_trigger_test.go
Normal file
502
openapi/tests/agent/robot_trigger_test.go
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
package openapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/openapi"
|
||||
"github.com/yaoapp/yao/openapi/tests/testutils"
|
||||
)
|
||||
|
||||
// TestTriggerRobot tests the robot trigger endpoint
|
||||
// POST /v1/agent/robots/:id/trigger
|
||||
func TestTriggerRobot(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping trigger tests in short mode (requires AI/manager)")
|
||||
}
|
||||
|
||||
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, "Trigger 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_trigger_%d", time.Now().UnixNano())
|
||||
createRobotForTrigger(t, serverURL, baseURL, tokenInfo.AccessToken, robotID, "Trigger Test Robot")
|
||||
defer deleteRobotForTrigger(t, serverURL, baseURL, tokenInfo.AccessToken, robotID)
|
||||
|
||||
t.Run("TriggerRobotBasic", func(t *testing.T) {
|
||||
triggerData := map[string]interface{}{
|
||||
"trigger_type": "human",
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hello, please help me with a task",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(triggerData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/trigger", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
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()
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// If manager is not started, we get a 500 error (expected in test environment)
|
||||
// In production with manager running, response should contain "accepted" field
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
// Expected when robot manager is not started
|
||||
assert.Contains(t, response, "error_description")
|
||||
t.Logf("Trigger response (manager not started): status=%d, error=%v", resp.StatusCode, response["error_description"])
|
||||
} else {
|
||||
// Manager is running - verify accepted field
|
||||
assert.Contains(t, response, "accepted")
|
||||
t.Logf("Trigger response: status=%d, accepted=%v", resp.StatusCode, response["accepted"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TriggerRobotWithAction", func(t *testing.T) {
|
||||
triggerData := map[string]interface{}{
|
||||
"trigger_type": "human",
|
||||
"action": "task.add",
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Add a new task: Review quarterly report",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(triggerData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/trigger", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
|
||||
// If manager is not started, we get a 500 error (expected in test environment)
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
assert.Contains(t, response, "error_description")
|
||||
t.Logf("Trigger with action response (manager not started): status=%d", resp.StatusCode)
|
||||
} else {
|
||||
assert.Contains(t, response, "accepted")
|
||||
t.Logf("Trigger with action response: status=%d", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TriggerRobotWithLocale", func(t *testing.T) {
|
||||
// Test the new locale parameter for i18n support
|
||||
triggerData := map[string]interface{}{
|
||||
"trigger_type": "human",
|
||||
"locale": "zh", // Chinese locale
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": "请帮我分析销售数据",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(triggerData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/trigger", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
|
||||
// If manager is not started, we get a 500 error (expected in test environment)
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
assert.Contains(t, response, "error_description")
|
||||
t.Logf("Trigger with locale response (manager not started): status=%d", resp.StatusCode)
|
||||
} else {
|
||||
assert.Contains(t, response, "accepted")
|
||||
t.Logf("Trigger with locale response: status=%d, accepted=%v", resp.StatusCode, response["accepted"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TriggerRobotNotFound", func(t *testing.T) {
|
||||
triggerData := map[string]interface{}{
|
||||
"trigger_type": "human",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(triggerData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/non_existent_robot/trigger", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
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("TriggerRobotUnauthorized", func(t *testing.T) {
|
||||
triggerData := map[string]interface{}{
|
||||
"trigger_type": "human",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(triggerData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/trigger", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// No Authorization header
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("TriggerRobotInvalidBody", func(t *testing.T) {
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/trigger", bytes.NewBuffer([]byte("invalid json")))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
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.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestInterveneRobot tests the robot intervention endpoint
|
||||
// POST /v1/agent/robots/:id/intervene
|
||||
func TestInterveneRobot(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping intervene tests in short mode (requires AI/manager)")
|
||||
}
|
||||
|
||||
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, "Intervene 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_intervene_%d", time.Now().UnixNano())
|
||||
createRobotForTrigger(t, serverURL, baseURL, tokenInfo.AccessToken, robotID, "Intervene Test Robot")
|
||||
defer deleteRobotForTrigger(t, serverURL, baseURL, tokenInfo.AccessToken, robotID)
|
||||
|
||||
t.Run("InterveneRobotBasic", func(t *testing.T) {
|
||||
interveneData := map[string]interface{}{
|
||||
"action": "task.add",
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Please add a high priority task",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(interveneData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/intervene", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
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()
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// If manager is not started, we get a 500 error (expected in test environment)
|
||||
if resp.StatusCode == http.StatusInternalServerError {
|
||||
assert.Contains(t, response, "error_description")
|
||||
t.Logf("Intervene response (manager not started): status=%d, error=%v", resp.StatusCode, response["error_description"])
|
||||
} else {
|
||||
assert.Contains(t, response, "accepted")
|
||||
t.Logf("Intervene response: status=%d, accepted=%v", resp.StatusCode, response["accepted"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("InterveneRobotMissingAction", func(t *testing.T) {
|
||||
interveneData := map[string]interface{}{
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Some message",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(interveneData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/intervene", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Action is required
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("InterveneRobotNotFound", func(t *testing.T) {
|
||||
interveneData := map[string]interface{}{
|
||||
"action": "task.add",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(interveneData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/non_existent_robot/intervene", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
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("InterveneRobotUnauthorized", func(t *testing.T) {
|
||||
interveneData := map[string]interface{}{
|
||||
"action": "task.add",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(interveneData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/intervene", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// No Authorization header
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("InterveneRobotInvalidBody", func(t *testing.T) {
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/intervene", bytes.NewBuffer([]byte("invalid json")))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
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.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestTriggerPermissions tests trigger permission inheritance from robot
|
||||
func TestTriggerPermissions(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping trigger 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, "Trigger 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_trig_perm_%d", time.Now().UnixNano())
|
||||
createRobotWithTeamForTrigger(t, serverURL, baseURL, token1.AccessToken, robotID, "Trigger Perm Robot", user1ID)
|
||||
defer deleteRobotForTrigger(t, serverURL, baseURL, token1.AccessToken, robotID)
|
||||
|
||||
t.Run("OwnerCanTrigger", func(t *testing.T) {
|
||||
triggerData := map[string]interface{}{
|
||||
"trigger_type": "human",
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Owner triggering robot",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(triggerData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/trigger", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Owner should be able to trigger (may fail at manager level with 500, but not 403 permission denied)
|
||||
// 500 = manager not started (acceptable), 403 = permission denied (not acceptable)
|
||||
assert.NotEqual(t, http.StatusForbidden, resp.StatusCode, "Owner should have permission to trigger")
|
||||
t.Logf("Owner trigger attempt status: %d", resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("OwnerCanIntervene", func(t *testing.T) {
|
||||
interveneData := map[string]interface{}{
|
||||
"action": "task.add",
|
||||
"messages": []map[string]interface{}{
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Owner intervention",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(interveneData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/intervene", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token1.AccessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 500 = manager not started (acceptable), 403 = permission denied (not acceptable)
|
||||
assert.NotEqual(t, http.StatusForbidden, resp.StatusCode, "Owner should have permission to intervene")
|
||||
t.Logf("Owner intervene attempt status: %d", resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("OtherUserTriggerAccess", func(t *testing.T) {
|
||||
// User 2 attempts to trigger User 1's robot
|
||||
// With system:root scope this might succeed
|
||||
triggerData := map[string]interface{}{
|
||||
"trigger_type": "human",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(triggerData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots/"+robotID+"/trigger", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
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 trigger attempt status: %d (with system:root scope)", resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Helper Functions ====================
|
||||
|
||||
func createRobotForTrigger(t *testing.T, serverURL, baseURL, token, robotID, displayName string) {
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": "test_team_001",
|
||||
"display_name": displayName,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func createRobotWithTeamForTrigger(t *testing.T, serverURL, baseURL, token, robotID, displayName, teamID string) {
|
||||
createData := map[string]interface{}{
|
||||
"member_id": robotID,
|
||||
"team_id": teamID,
|
||||
"display_name": displayName,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(createData)
|
||||
req, err := http.NewRequest("POST", serverURL+baseURL+"/agent/robots", bytes.NewBuffer(body))
|
||||
require.NoError(t, err)
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func deleteRobotForTrigger(t *testing.T, serverURL, baseURL, token, robotID string) {
|
||||
req, _ := http.NewRequest("DELETE", serverURL+baseURL+"/agent/robots/"+robotID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
|
@ -94,6 +94,22 @@
|
|||
"comment": "Error message if execution failed",
|
||||
"nullable": true
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"type": "string",
|
||||
"label": "Name",
|
||||
"comment": "Execution title for UI display (updated by executor at goals phase)",
|
||||
"length": 512,
|
||||
"nullable": true
|
||||
},
|
||||
{
|
||||
"name": "current_task_name",
|
||||
"type": "string",
|
||||
"label": "Current Task Name",
|
||||
"comment": "Current task description for UI display (updated by executor at run phase)",
|
||||
"length": 512,
|
||||
"nullable": true
|
||||
},
|
||||
{
|
||||
"name": "input",
|
||||
"type": "json",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue