Add Description Field to Task Struct and Enhance Execution Management
- Introduced a new `Description` field in the `Task` struct for a human-readable task description, improving UI clarity. - Updated the `ParseTask` function to save the description from input data and convert it to a message if no explicit messages are provided. - Enhanced the `Executor` to update UI fields with localized messages during task execution phases, ensuring better user feedback. - Implemented a new method in the `ExecutionStore` to persist task status updates, allowing real-time UI updates. - Added unit tests to validate the new task description handling and UI updates during execution phases.
This commit is contained in:
parent
de9e2c589b
commit
28d5730289
15 changed files with 651 additions and 221 deletions
|
|
@ -351,6 +351,7 @@ P2 Agent reads Goals markdown and breaks into executable tasks:
|
|||
```go
|
||||
type Task struct {
|
||||
ID string // unique task ID
|
||||
Description string // human-readable task description (for UI display)
|
||||
Messages []context.Message // original input (text, images, files, audio)
|
||||
GoalRef string // reference to goal (e.g., "Goal 1")
|
||||
Source TaskSource // auto | human | event
|
||||
|
|
|
|||
|
|
@ -1314,10 +1314,11 @@ type DeliveryTarget struct {
|
|||
|
||||
// Task - planned task (structured, for execution)
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Messages []context.Message `json:"messages"` // original input (text, images, files)
|
||||
GoalRef string `json:"goal_ref,omitempty"` // reference to goal (e.g., "Goal 1")
|
||||
Source TaskSource `json:"source"` // auto | human | event
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description,omitempty"` // human-readable task description (for UI display)
|
||||
Messages []context.Message `json:"messages"` // original input (text, images, files)
|
||||
GoalRef string `json:"goal_ref,omitempty"` // reference to goal (e.g., "Goal 1")
|
||||
Source TaskSource `json:"source"` // auto | human | event
|
||||
|
||||
// Executor
|
||||
ExecutorType ExecutorType `json:"executor_type"`
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ const memberModel = "__yao.member"
|
|||
// robotStore is the shared robot store instance
|
||||
var robotStore = store.NewRobotStore()
|
||||
|
||||
// executionStore is the shared execution store instance
|
||||
var executionStore = store.NewExecutionStore()
|
||||
|
||||
// GetRobot returns a robot by member ID
|
||||
// Returns the robot from cache if available, otherwise loads from database
|
||||
func GetRobot(ctx *types.Context, memberID string) (*types.Robot, error) {
|
||||
|
|
@ -95,7 +98,6 @@ func GetRobotStatus(ctx *types.Context, memberID string) (*RobotState, error) {
|
|||
DisplayName: robot.DisplayName,
|
||||
Bio: robot.Bio,
|
||||
Status: robot.Status,
|
||||
Running: robot.RunningCount(),
|
||||
MaxRunning: 2, // default
|
||||
}
|
||||
|
||||
|
|
@ -109,11 +111,33 @@ func GetRobotStatus(ctx *types.Context, memberID string) (*RobotState, error) {
|
|||
state.MaxRunning = robot.Config.Quota.GetMax()
|
||||
}
|
||||
|
||||
// Get running execution IDs
|
||||
executions := robot.GetExecutions()
|
||||
state.RunningIDs = make([]string, 0, len(executions))
|
||||
for _, exec := range executions {
|
||||
state.RunningIDs = append(state.RunningIDs, exec.ID)
|
||||
// Get running execution IDs from ExecutionStore (more reliable than in-memory)
|
||||
// This ensures we get accurate status even when robot is loaded from database
|
||||
runningExecs, err := executionStore.List(context.Background(), &store.ListOptions{
|
||||
MemberID: memberID,
|
||||
Status: types.ExecRunning,
|
||||
Limit: 100,
|
||||
})
|
||||
if err == nil && len(runningExecs) > 0 {
|
||||
state.Running = len(runningExecs)
|
||||
state.RunningIDs = make([]string, 0, len(runningExecs))
|
||||
for _, exec := range runningExecs {
|
||||
state.RunningIDs = append(state.RunningIDs, exec.ExecutionID)
|
||||
}
|
||||
// Update status based on running count
|
||||
state.Status = types.RobotWorking
|
||||
} else {
|
||||
// No running executions from store, check in-memory
|
||||
executions := robot.GetExecutions()
|
||||
state.Running = len(executions)
|
||||
state.RunningIDs = make([]string, 0, len(executions))
|
||||
for _, exec := range executions {
|
||||
state.RunningIDs = append(state.RunningIDs, exec.ID)
|
||||
}
|
||||
// If there are running executions in memory, update status
|
||||
if state.Running > 0 {
|
||||
state.Status = types.RobotWorking
|
||||
}
|
||||
}
|
||||
|
||||
// Set last run time
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Executi
|
|||
return fmt.Errorf("robot not found in execution")
|
||||
}
|
||||
|
||||
// Update UI field with i18n
|
||||
locale := getEffectiveLocale(robot, exec.Input)
|
||||
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "generating_delivery"))
|
||||
|
||||
// Get agent ID for delivery phase
|
||||
agentID := "__yao.delivery" // default
|
||||
if robot.Config != nil && robot.Config.Resources != nil {
|
||||
|
|
@ -108,6 +112,10 @@ func (e *Executor) routeToDeliveryCenter(ctx *robottypes.Context, exec *robottyp
|
|||
return nil
|
||||
}
|
||||
|
||||
// Update UI field to show delivery is in progress
|
||||
locale := getEffectiveLocale(robot, exec.Input)
|
||||
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "sending_delivery"))
|
||||
|
||||
// Create Delivery Center and execute
|
||||
center := NewDeliveryCenter()
|
||||
results, err := center.Deliver(ctx, exec.Delivery.Content, &robottypes.DeliveryContext{
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import (
|
|||
type Executor struct {
|
||||
config types.Config
|
||||
store *store.ExecutionStore
|
||||
robotStore *store.RobotStore
|
||||
execCount atomic.Int32
|
||||
currentCount atomic.Int32
|
||||
onStart func()
|
||||
|
|
@ -29,15 +30,17 @@ type Executor struct {
|
|||
// New creates a new standard executor
|
||||
func New() *Executor {
|
||||
return &Executor{
|
||||
store: store.NewExecutionStore(),
|
||||
store: store.NewExecutionStore(),
|
||||
robotStore: store.NewRobotStore(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewWithConfig creates a new standard executor with configuration
|
||||
func NewWithConfig(config types.Config) *Executor {
|
||||
return &Executor{
|
||||
config: config,
|
||||
store: store.NewExecutionStore(),
|
||||
config: config,
|
||||
store: store.NewExecutionStore(),
|
||||
robotStore: store.NewRobotStore(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +97,19 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
|||
}).Warn("Execution quota exceeded")
|
||||
return nil, robottypes.ErrQuotaExceeded
|
||||
}
|
||||
defer robot.RemoveExecution(exec.ID)
|
||||
// Defer: remove execution from robot's tracking and update robot status if no more executions
|
||||
defer func() {
|
||||
robot.RemoveExecution(exec.ID)
|
||||
// Update robot status to idle if no more running executions
|
||||
if robot.RunningCount() == 0 && !e.config.SkipPersistence && e.robotStore != nil {
|
||||
if err := e.robotStore.UpdateStatus(ctx.Context, robot.MemberID, robottypes.RobotIdle); err != nil {
|
||||
log.With(log.F{
|
||||
"member_id": robot.MemberID,
|
||||
"error": err,
|
||||
}).Warn("Failed to update robot status to idle: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Track execution count
|
||||
e.execCount.Add(1)
|
||||
|
|
@ -127,6 +142,16 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
|
|||
}
|
||||
}
|
||||
|
||||
// Update robot status to working (when execution starts)
|
||||
if !e.config.SkipPersistence && e.robotStore != nil {
|
||||
if err := e.robotStore.UpdateStatus(ctx.Context, robot.MemberID, robottypes.RobotWorking); err != nil {
|
||||
log.With(log.F{
|
||||
"member_id": robot.MemberID,
|
||||
"error": err,
|
||||
}).Warn("Failed to update robot status to working: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for simulated failure (for testing)
|
||||
if dataStr, ok := data.(string); ok && dataStr == "simulate_failure" {
|
||||
exec.Status = robottypes.ExecFailed
|
||||
|
|
@ -211,6 +236,17 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution,
|
|||
"phase": string(phase),
|
||||
}).Info("Phase started: %s", phase)
|
||||
|
||||
// Persist phase change immediately (so frontend sees current phase)
|
||||
if !e.config.SkipPersistence && e.store != nil {
|
||||
if err := e.store.UpdatePhase(ctx.Context, exec.ID, phase, nil); err != nil {
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"phase": string(phase),
|
||||
"error": err,
|
||||
}).Warn("Failed to persist phase start: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if e.config.OnPhaseStart != nil {
|
||||
e.config.OnPhaseStart(phase)
|
||||
}
|
||||
|
|
@ -381,6 +417,9 @@ var uiMessages = map[string]map[string]string{
|
|||
"analyzing_context": "Analyzing context...",
|
||||
"planning_goals": "Planning goals...",
|
||||
"breaking_down_tasks": "Breaking down tasks...",
|
||||
"generating_delivery": "Generating delivery content...",
|
||||
"sending_delivery": "Sending delivery...",
|
||||
"learning_from_exec": "Learning from execution...",
|
||||
"completed": "Completed",
|
||||
"failed_prefix": "Failed at ",
|
||||
"task_prefix": "Task",
|
||||
|
|
@ -401,6 +440,9 @@ var uiMessages = map[string]map[string]string{
|
|||
"analyzing_context": "分析上下文...",
|
||||
"planning_goals": "规划目标...",
|
||||
"breaking_down_tasks": "分解任务...",
|
||||
"generating_delivery": "生成交付内容...",
|
||||
"sending_delivery": "正在发送...",
|
||||
"learning_from_exec": "学习执行经验...",
|
||||
"completed": "已完成",
|
||||
"failed_prefix": "失败于",
|
||||
"task_prefix": "任务",
|
||||
|
|
@ -451,6 +493,30 @@ func (e *Executor) updateUIFields(ctx *robottypes.Context, exec *robottypes.Exec
|
|||
}
|
||||
}
|
||||
|
||||
// updateTasksState persists the current tasks array with status to database
|
||||
// This should be called after each task status change for real-time UI updates
|
||||
func (e *Executor) updateTasksState(ctx *robottypes.Context, exec *robottypes.Execution) {
|
||||
if e.config.SkipPersistence || e.store == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert Current to store.CurrentState
|
||||
var current *store.CurrentState
|
||||
if exec.Current != nil {
|
||||
current = &store.CurrentState{
|
||||
TaskIndex: exec.Current.TaskIndex,
|
||||
Progress: exec.Current.Progress,
|
||||
}
|
||||
}
|
||||
|
||||
if err := e.store.UpdateTasks(ctx.Context, exec.ID, exec.Tasks, current); err != nil {
|
||||
log.With(log.F{
|
||||
"execution_id": exec.ID,
|
||||
"error": err,
|
||||
}).Warn("Failed to update tasks state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// extractGoalName extracts the execution name from goals output
|
||||
func extractGoalName(goals *robottypes.Goals) string {
|
||||
if goals == nil || goals.Content == "" {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ import (
|
|||
//
|
||||
// TODO: Implement real learning extraction
|
||||
func (e *Executor) RunLearning(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error {
|
||||
// Get robot for locale
|
||||
robot := exec.GetRobot()
|
||||
|
||||
// Update UI field with i18n
|
||||
locale := getEffectiveLocale(robot, exec.Input)
|
||||
e.updateUIFields(ctx, exec, "", getLocalizedMessage(locale, "learning_from_exec"))
|
||||
|
||||
e.simulateStreamDelay()
|
||||
|
||||
exec.Learning = []robottypes.LearningEntry{
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
|
|||
now := time.Now()
|
||||
task.StartTime = &now
|
||||
|
||||
// Persist running state to database
|
||||
e.updateTasksState(ctx, exec)
|
||||
|
||||
// Build task context with previous results
|
||||
taskCtx := runner.BuildTaskContext(exec, i)
|
||||
|
||||
|
|
@ -114,12 +117,17 @@ func (e *Executor) RunExecution(ctx *robottypes.Context, exec *robottypes.Execut
|
|||
// Store result
|
||||
exec.Results = append(exec.Results, *result)
|
||||
|
||||
// Persist completed/failed state to database
|
||||
e.updateTasksState(ctx, exec)
|
||||
|
||||
// Check if we should continue on failure
|
||||
if !result.Success && !config.ContinueOnFailure {
|
||||
// Mark remaining tasks as skipped
|
||||
for j := i + 1; j < len(exec.Tasks); j++ {
|
||||
exec.Tasks[j].Status = robottypes.TaskSkipped
|
||||
}
|
||||
// Persist skipped state to database
|
||||
e.updateTasksState(ctx, exec)
|
||||
return fmt.Errorf("task %s failed: %s", task.ID, result.Error)
|
||||
}
|
||||
}
|
||||
|
|
@ -135,7 +143,16 @@ func formatTaskProgressName(task *robottypes.Task, index int, total int, locale
|
|||
taskPrefix := getLocalizedMessage(locale, "task_prefix")
|
||||
prefix := fmt.Sprintf("%s %d/%d: ", taskPrefix, index+1, total)
|
||||
|
||||
// Try to get description from first message
|
||||
// Priority 1: Use Description field if available
|
||||
if task.Description != "" {
|
||||
desc := task.Description
|
||||
if len(desc) > 80 {
|
||||
desc = desc[:80] + "..."
|
||||
}
|
||||
return prefix + desc
|
||||
}
|
||||
|
||||
// Priority 2: 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
|
||||
|
|
|
|||
|
|
@ -156,9 +156,11 @@ func ParseTask(data map[string]interface{}, index int) (*robottypes.Task, error)
|
|||
task.Messages = ParseMessages(messages)
|
||||
}
|
||||
|
||||
// Optional: description -> convert to message if no messages
|
||||
if len(task.Messages) == 0 {
|
||||
if desc, ok := data["description"].(string); ok && desc != "" {
|
||||
// Optional: description - save to Description field and convert to message if no messages
|
||||
if desc, ok := data["description"].(string); ok && desc != "" {
|
||||
task.Description = desc
|
||||
// Also convert to Messages for execution if no explicit messages provided
|
||||
if len(task.Messages) == 0 {
|
||||
task.Messages = []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: desc},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -309,7 +309,8 @@ func TestParseTasks(t *testing.T) {
|
|||
// Second task
|
||||
assert.Equal(t, "task-002", tasks[1].ID)
|
||||
assert.Equal(t, "experts.text-writer", tasks[1].ExecutorID)
|
||||
assert.Len(t, tasks[1].Messages, 1) // description converted to message
|
||||
assert.Equal(t, "Generate report from analysis", tasks[1].Description) // description saved to field
|
||||
assert.Len(t, tasks[1].Messages, 1) // description also converted to message
|
||||
assert.Equal(t, 1, tasks[1].Order)
|
||||
})
|
||||
|
||||
|
|
@ -329,6 +330,62 @@ func TestParseTasks(t *testing.T) {
|
|||
assert.Equal(t, "task-001", tasks[0].ID)
|
||||
})
|
||||
|
||||
t.Run("saves description to field and preserves explicit messages", func(t *testing.T) {
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "task-001",
|
||||
"executor_type": "agent",
|
||||
"executor_id": "experts.summarizer",
|
||||
"description": "Task description for UI",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Explicit message content",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tasks, err := standard.ParseTasks(data)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tasks, 1)
|
||||
|
||||
// Description should be saved to field
|
||||
assert.Equal(t, "Task description for UI", tasks[0].Description)
|
||||
|
||||
// Explicit messages should be preserved (not overwritten by description)
|
||||
assert.Len(t, tasks[0].Messages, 1)
|
||||
content, ok := tasks[0].Messages[0].GetContentAsString()
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "Explicit message content", content)
|
||||
})
|
||||
|
||||
t.Run("converts description to message when no messages provided", func(t *testing.T) {
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "task-001",
|
||||
"executor_type": "agent",
|
||||
"executor_id": "experts.summarizer",
|
||||
"description": "Only description, no messages",
|
||||
},
|
||||
}
|
||||
|
||||
tasks, err := standard.ParseTasks(data)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, tasks, 1)
|
||||
|
||||
// Description should be saved to field
|
||||
assert.Equal(t, "Only description, no messages", tasks[0].Description)
|
||||
|
||||
// Description should also be converted to message for execution
|
||||
assert.Len(t, tasks[0].Messages, 1)
|
||||
content, ok := tasks[0].Messages[0].GetContentAsString()
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "Only description, no messages", content)
|
||||
})
|
||||
|
||||
t.Run("returns error for missing executor_type", func(t *testing.T) {
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
|
|
|
|||
|
|
@ -103,8 +103,9 @@ func TestGetLocalizedMessage(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",
|
||||
"planning_goals", "breaking_down_tasks",
|
||||
"generating_delivery", "sending_delivery", "learning_from_exec",
|
||||
"completed", "failed_prefix", "task_prefix",
|
||||
// Phase names for failure messages
|
||||
"phase_inspiration", "phase_goals", "phase_tasks",
|
||||
"phase_run", "phase_delivery", "phase_learning",
|
||||
|
|
@ -119,8 +120,9 @@ func TestGetLocalizedMessage(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",
|
||||
"planning_goals", "breaking_down_tasks",
|
||||
"generating_delivery", "sending_delivery", "learning_from_exec",
|
||||
"completed", "failed_prefix", "task_prefix",
|
||||
// Phase names for failure messages
|
||||
"phase_inspiration", "phase_goals", "phase_tasks",
|
||||
"phase_run", "phase_delivery", "phase_learning",
|
||||
|
|
@ -312,9 +314,26 @@ func TestExtractGoalName(t *testing.T) {
|
|||
// ============================================================================
|
||||
|
||||
func TestFormatTaskProgressName(t *testing.T) {
|
||||
t.Run("formats_with_task_description", func(t *testing.T) {
|
||||
t.Run("prioritizes_description_field_over_messages", func(t *testing.T) {
|
||||
task := &robottypes.Task{
|
||||
ID: "task-001",
|
||||
Description: "High-level task description for UI",
|
||||
ExecutorType: robottypes.ExecutorAssistant,
|
||||
ExecutorID: "analyst",
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Detailed message content for execution"},
|
||||
},
|
||||
}
|
||||
|
||||
name := formatTaskProgressName(task, 0, 3, "en")
|
||||
// Should use Description field, NOT the message content
|
||||
assert.Equal(t, "Task 1/3: High-level task description for UI", name)
|
||||
})
|
||||
|
||||
t.Run("falls_back_to_message_when_no_description", func(t *testing.T) {
|
||||
task := &robottypes.Task{
|
||||
ID: "task-001",
|
||||
Description: "", // Empty description
|
||||
ExecutorType: robottypes.ExecutorAssistant,
|
||||
ExecutorID: "analyst",
|
||||
Messages: []agentcontext.Message{
|
||||
|
|
@ -340,14 +359,31 @@ func TestFormatTaskProgressName(t *testing.T) {
|
|||
assert.Equal(t, "任务 2/5: 分析销售数据", name)
|
||||
})
|
||||
|
||||
t.Run("truncates_long_description", func(t *testing.T) {
|
||||
t.Run("truncates_long_description_field", 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",
|
||||
Description: longDesc,
|
||||
ExecutorType: robottypes.ExecutorAssistant,
|
||||
ExecutorID: "analyst",
|
||||
Messages: []agentcontext.Message{},
|
||||
}
|
||||
|
||||
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("truncates_long_message_content", func(t *testing.T) {
|
||||
longContent := "This is a very long message content that should be truncated because it exceeds 80 characters which is the maximum length allowed"
|
||||
task := &robottypes.Task{
|
||||
ID: "task-001",
|
||||
Description: "", // No description, will use message
|
||||
ExecutorType: robottypes.ExecutorAssistant,
|
||||
ExecutorID: "analyst",
|
||||
Messages: []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: longDesc},
|
||||
{Role: agentcontext.RoleUser, Content: longContent},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -317,6 +317,34 @@ func (s *ExecutionStore) UpdateCurrent(ctx context.Context, executionID string,
|
|||
return nil
|
||||
}
|
||||
|
||||
// UpdateTasks updates the tasks array with current status
|
||||
// This should be called after each task completes to persist status changes
|
||||
func (s *ExecutionStore) UpdateTasks(ctx context.Context, executionID string, tasks []types.Task, current *CurrentState) error {
|
||||
mod := model.Select(s.modelID)
|
||||
if mod == nil {
|
||||
return fmt.Errorf("model %s not found", s.modelID)
|
||||
}
|
||||
|
||||
updateData := map[string]interface{}{
|
||||
"tasks": tasks,
|
||||
"current": current,
|
||||
}
|
||||
|
||||
_, err := mod.UpdateWhere(
|
||||
model.QueryParam{
|
||||
Wheres: []model.QueryWhere{
|
||||
{Column: "execution_id", Value: executionID},
|
||||
},
|
||||
},
|
||||
updateData,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update tasks: %w", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -604,6 +604,161 @@ func TestExecutionStoreUpdateUIFields(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
// TestExecutionStoreUpdateTasks tests updating tasks array with status
|
||||
func TestExecutionStoreUpdateTasks(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 with initial tasks
|
||||
startTime := time.Now()
|
||||
record := &store.ExecutionRecord{
|
||||
ExecutionID: "exec_test_tasks_001",
|
||||
MemberID: "member_tasks_001",
|
||||
TeamID: "team_tasks_001",
|
||||
TriggerType: types.TriggerClock,
|
||||
Status: types.ExecRunning,
|
||||
Phase: types.PhaseRun,
|
||||
StartTime: &startTime,
|
||||
Tasks: []types.Task{
|
||||
{ID: "task_001", ExecutorType: types.ExecutorAssistant, Status: types.TaskPending, Order: 0},
|
||||
{ID: "task_002", ExecutorType: types.ExecutorProcess, Status: types.TaskPending, Order: 1},
|
||||
{ID: "task_003", ExecutorType: types.ExecutorAssistant, Status: types.TaskPending, Order: 2},
|
||||
},
|
||||
}
|
||||
err := s.Save(ctx, record)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("updates_task_status_to_running", func(t *testing.T) {
|
||||
// Update first task to running
|
||||
tasks := []types.Task{
|
||||
{ID: "task_001", ExecutorType: types.ExecutorAssistant, Status: types.TaskRunning, Order: 0},
|
||||
{ID: "task_002", ExecutorType: types.ExecutorProcess, Status: types.TaskPending, Order: 1},
|
||||
{ID: "task_003", ExecutorType: types.ExecutorAssistant, Status: types.TaskPending, Order: 2},
|
||||
}
|
||||
current := &store.CurrentState{TaskIndex: 0, Progress: "1/3 tasks"}
|
||||
|
||||
err := s.UpdateTasks(ctx, "exec_test_tasks_001", tasks, current)
|
||||
require.NoError(t, err)
|
||||
|
||||
saved, err := s.Get(ctx, "exec_test_tasks_001")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, saved.Tasks, 3)
|
||||
|
||||
assert.Equal(t, types.TaskRunning, saved.Tasks[0].Status)
|
||||
assert.Equal(t, types.TaskPending, saved.Tasks[1].Status)
|
||||
assert.Equal(t, types.TaskPending, saved.Tasks[2].Status)
|
||||
|
||||
assert.NotNil(t, saved.Current)
|
||||
assert.Equal(t, 0, saved.Current.TaskIndex)
|
||||
})
|
||||
|
||||
t.Run("updates_task_status_to_completed", func(t *testing.T) {
|
||||
// First task completed, second running
|
||||
tasks := []types.Task{
|
||||
{ID: "task_001", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted, Order: 0},
|
||||
{ID: "task_002", ExecutorType: types.ExecutorProcess, Status: types.TaskRunning, Order: 1},
|
||||
{ID: "task_003", ExecutorType: types.ExecutorAssistant, Status: types.TaskPending, Order: 2},
|
||||
}
|
||||
current := &store.CurrentState{TaskIndex: 1, Progress: "2/3 tasks"}
|
||||
|
||||
err := s.UpdateTasks(ctx, "exec_test_tasks_001", tasks, current)
|
||||
require.NoError(t, err)
|
||||
|
||||
saved, err := s.Get(ctx, "exec_test_tasks_001")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, types.TaskCompleted, saved.Tasks[0].Status)
|
||||
assert.Equal(t, types.TaskRunning, saved.Tasks[1].Status)
|
||||
assert.Equal(t, types.TaskPending, saved.Tasks[2].Status)
|
||||
assert.Equal(t, 1, saved.Current.TaskIndex)
|
||||
})
|
||||
|
||||
t.Run("updates_task_status_to_failed_with_skipped", func(t *testing.T) {
|
||||
// Second task failed, third skipped
|
||||
tasks := []types.Task{
|
||||
{ID: "task_001", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted, Order: 0},
|
||||
{ID: "task_002", ExecutorType: types.ExecutorProcess, Status: types.TaskFailed, Order: 1},
|
||||
{ID: "task_003", ExecutorType: types.ExecutorAssistant, Status: types.TaskSkipped, Order: 2},
|
||||
}
|
||||
current := &store.CurrentState{TaskIndex: 1, Progress: "Failed at 2/3"}
|
||||
|
||||
err := s.UpdateTasks(ctx, "exec_test_tasks_001", tasks, current)
|
||||
require.NoError(t, err)
|
||||
|
||||
saved, err := s.Get(ctx, "exec_test_tasks_001")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, types.TaskCompleted, saved.Tasks[0].Status)
|
||||
assert.Equal(t, types.TaskFailed, saved.Tasks[1].Status)
|
||||
assert.Equal(t, types.TaskSkipped, saved.Tasks[2].Status)
|
||||
})
|
||||
|
||||
t.Run("updates_with_nil_current", func(t *testing.T) {
|
||||
// All tasks completed, no current
|
||||
tasks := []types.Task{
|
||||
{ID: "task_001", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted, Order: 0},
|
||||
{ID: "task_002", ExecutorType: types.ExecutorProcess, Status: types.TaskCompleted, Order: 1},
|
||||
{ID: "task_003", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted, Order: 2},
|
||||
}
|
||||
|
||||
err := s.UpdateTasks(ctx, "exec_test_tasks_001", tasks, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
saved, err := s.Get(ctx, "exec_test_tasks_001")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, types.TaskCompleted, saved.Tasks[0].Status)
|
||||
assert.Equal(t, types.TaskCompleted, saved.Tasks[1].Status)
|
||||
assert.Equal(t, types.TaskCompleted, saved.Tasks[2].Status)
|
||||
})
|
||||
|
||||
t.Run("preserves_task_description", func(t *testing.T) {
|
||||
// Create a new record with descriptions
|
||||
record2 := &store.ExecutionRecord{
|
||||
ExecutionID: "exec_test_tasks_002",
|
||||
MemberID: "member_tasks_002",
|
||||
TeamID: "team_tasks_002",
|
||||
TriggerType: types.TriggerHuman,
|
||||
Status: types.ExecRunning,
|
||||
Phase: types.PhaseRun,
|
||||
StartTime: &startTime,
|
||||
Tasks: []types.Task{
|
||||
{ID: "task_d01", Description: "Analyze data", ExecutorType: types.ExecutorAssistant, Status: types.TaskPending, Order: 0},
|
||||
{ID: "task_d02", Description: "Generate report", ExecutorType: types.ExecutorAssistant, Status: types.TaskPending, Order: 1},
|
||||
},
|
||||
}
|
||||
err := s.Save(ctx, record2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Update status preserving description
|
||||
tasks := []types.Task{
|
||||
{ID: "task_d01", Description: "Analyze data", ExecutorType: types.ExecutorAssistant, Status: types.TaskCompleted, Order: 0},
|
||||
{ID: "task_d02", Description: "Generate report", ExecutorType: types.ExecutorAssistant, Status: types.TaskRunning, Order: 1},
|
||||
}
|
||||
|
||||
err = s.UpdateTasks(ctx, "exec_test_tasks_002", tasks, &store.CurrentState{TaskIndex: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
saved, err := s.Get(ctx, "exec_test_tasks_002")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Analyze data", saved.Tasks[0].Description)
|
||||
assert.Equal(t, "Generate report", saved.Tasks[1].Description)
|
||||
assert.Equal(t, types.TaskCompleted, saved.Tasks[0].Status)
|
||||
assert.Equal(t, types.TaskRunning, saved.Tasks[1].Status)
|
||||
})
|
||||
}
|
||||
|
||||
// TestExecutionStoreDelete tests deleting execution records
|
||||
func TestExecutionStoreDelete(t *testing.T) {
|
||||
if testing.Short() {
|
||||
|
|
|
|||
|
|
@ -216,10 +216,11 @@ type DeliveryTarget struct {
|
|||
|
||||
// Task - planned task (structured, for execution)
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Messages []agentcontext.Message `json:"messages"` // original input (text, images, files)
|
||||
GoalRef string `json:"goal_ref,omitempty"` // reference to goal (e.g., "Goal 1")
|
||||
Source TaskSource `json:"source"` // auto | human | event
|
||||
ID string `json:"id"`
|
||||
Description string `json:"description,omitempty"` // human-readable task description (for UI display)
|
||||
Messages []agentcontext.Message `json:"messages"` // original input (text, images, files)
|
||||
GoalRef string `json:"goal_ref,omitempty"` // reference to goal (e.g., "Goal 1")
|
||||
Source TaskSource `json:"source"` // auto | human | event
|
||||
|
||||
// Executor
|
||||
ExecutorType ExecutorType `json:"executor_type"`
|
||||
|
|
|
|||
336
data/bindata.go
336
data/bindata.go
File diff suppressed because it is too large
Load diff
|
|
@ -45,6 +45,21 @@
|
|||
| `current_task_name` | `current_task_name` | ✅ Added |
|
||||
| - | `job_id` | 🗑️ **Dead field, to be removed** |
|
||||
|
||||
### Task Fields ✅ Aligned
|
||||
|
||||
| Backend (`types.go`) | Frontend (`types.ts`) | Status |
|
||||
|---------------------|----------------------|--------|
|
||||
| `id` | `id` | ✅ |
|
||||
| `description` | `description` | ✅ Added |
|
||||
| `goal_ref` | `goal_ref` | ✅ |
|
||||
| `source` | `source` | ✅ |
|
||||
| `executor_type` | `executor_type` | ✅ |
|
||||
| `executor_id` | `executor_id` | ✅ |
|
||||
| `status` | `status` | ✅ |
|
||||
| `order` | `order` | ✅ |
|
||||
| `start_time` | `start_time` | ✅ |
|
||||
| `end_time` | `end_time` | ✅ |
|
||||
|
||||
**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)
|
||||
|
|
@ -53,6 +68,10 @@
|
|||
- [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)
|
||||
- [x] **Backend**: `Task` struct - add `Description` field for human-readable task description
|
||||
- [x] **Backend**: `ParseTask()` - save description from LLM output to `Task.Description`
|
||||
- [x] **Frontend**: `Task` type - add `description` field
|
||||
- [x] **Frontend**: Task list display - use `description` as primary title, fallback to `executor_id`
|
||||
- [ ] **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
|
||||
|
|
@ -606,44 +625,52 @@ var uiMessages = map[string]map[string]string{
|
|||
- [x] Store - add `UpdateUIFields()` method
|
||||
- [x] Unit tests for UI fields and i18n
|
||||
|
||||
**Frontend Cleanup (Pending):**
|
||||
**Frontend Cleanup (Completed):**
|
||||
- [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
|
||||
- [x] Remove `job_id` field from `types.ts`
|
||||
- [x] ~~Remove `job_id` from `mock/data.ts`~~ (mock kept for reference, not used)
|
||||
- [x] Use `name`/`current_task_name` directly from API response (string, not `{en, cn}`)
|
||||
|
||||
#### 5.2 SDK Types (`types.ts`) ⬜
|
||||
#### 5.2 SDK Types (`types.ts`) ✅
|
||||
|
||||
- [ ] `ExecutionFilter` interface
|
||||
- [ ] `Execution` interface (align with backend `ExecutionResponse`)
|
||||
- [ ] `ExecutionListResponse` interface
|
||||
- [ ] `ExecutionControlResponse` interface
|
||||
- [x] `ExecutionFilter` interface
|
||||
- [x] `ExecutionResponse` interface (align with backend)
|
||||
- [x] `ExecutionListResponse` interface
|
||||
- [x] `ExecutionControlResponse` interface
|
||||
- [x] `ExecStatus`, `TriggerType`, `Phase` type aliases
|
||||
|
||||
**Deferred to Phase 5 (SSE):**
|
||||
- [ ] ~~`TriggerRequest` / `TriggerResponse` interfaces~~
|
||||
- [ ] ~~`InterveneRequest` / `InterveneResponse` interfaces~~
|
||||
|
||||
#### 5.3 SDK Methods (`robots.ts`) ⬜
|
||||
#### 5.3 SDK Methods (`robots.ts`) ✅
|
||||
|
||||
- [ ] `ListExecutions(robotId, filter)`
|
||||
- [ ] `GetExecution(robotId, execId)`
|
||||
- [ ] `PauseExecution(robotId, execId)`
|
||||
- [ ] `ResumeExecution(robotId, execId)`
|
||||
- [ ] `CancelExecution(robotId, execId)`
|
||||
- [x] `ListExecutions(robotId, filter)`
|
||||
- [x] `GetExecution(robotId, execId)`
|
||||
- [x] `PauseExecution(robotId, execId)`
|
||||
- [x] `ResumeExecution(robotId, execId)`
|
||||
- [x] `CancelExecution(robotId, execId)`
|
||||
|
||||
**Deferred to Phase 5 (SSE):**
|
||||
- [ ] ~~`Trigger(robotId, data)`~~
|
||||
- [ ] ~~`Intervene(robotId, data)`~~
|
||||
|
||||
#### 5.4 Page Integration ⬜
|
||||
#### 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
|
||||
- [x] ActiveTab: Replace mock with `ListExecutions()` API
|
||||
- [x] Filter: `status=running|pending`
|
||||
- [x] Polling: 1-minute interval (60000ms) - will switch to SSE in Phase 6
|
||||
- [x] HistoryTab: Replace mock with `ListExecutions()` API
|
||||
- [x] Filter: `status` filter, `keyword` search
|
||||
- [x] Pagination: page/pagesize
|
||||
- [x] Polling: 1-minute interval for list refresh
|
||||
- [x] Execution Detail: Call `GetExecution()` API
|
||||
- [x] Display execution phases and outputs
|
||||
- [x] Display `name` and `current_task_name` from API
|
||||
- [x] Display `error` field for failed executions
|
||||
- [x] Execution controls: Pause/Resume/Cancel buttons (call control APIs)
|
||||
- [x] Auto-refresh while execution is running (5s for running)
|
||||
- [x] useRobots hook extended with execution methods
|
||||
|
||||
**Deferred to Phase 5 (SSE):**
|
||||
- [ ] ~~Assign Task Modal: Call `Trigger()` API~~
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue