Refactor Job System Integration to Execution Storage

- Removed the job system integration from the robot execution flow, transitioning to a dedicated ExecutionStore for managing execution records.
- Updated the design documentation to reflect the new architecture, emphasizing the relationship between robots and concurrent executions.
- Revised the API to return execution IDs instead of job IDs, ensuring clarity in execution tracking.
- Enhanced logging mechanisms to utilize the kun/log package for better traceability of execution phases.
- Updated tests and documentation to align with the removal of job-related structures and the introduction of execution management.
This commit is contained in:
Max 2026-01-22 18:24:50 +08:00
parent 36ac190637
commit add933d34c
25 changed files with 173 additions and 3209 deletions

View file

@ -60,7 +60,6 @@ flowchart TB
subgraph Storage["Storage"] subgraph Storage["Storage"]
KB[("KB")] KB[("KB")]
DB[("DB")] DB[("DB")]
Job[("Job")]
end end
WC --> TC WC --> TC
@ -75,7 +74,7 @@ flowchart TB
TT -->|Clock| P0 TT -->|Clock| P0
TT -->|Human/Event| P1 TT -->|Human/Event| P1
P0 --> P1 --> P2 --> P3 --> P4 --> P5 P0 --> P1 --> P2 --> P3 --> P4 --> P5
P5 --> KB & DB & Job P5 --> KB & DB
KB -.->|History| P0 KB -.->|History| P0
``` ```
@ -89,7 +88,7 @@ Executor supports multiple execution modes for different use cases:
| DryRun | Tests, demos, preview without LLM calls | ✅ Implemented | | DryRun | Tests, demos, preview without LLM calls | ✅ Implemented |
| Sandbox | Container-isolated for untrusted code | ⬜ Not Implemented | | Sandbox | Container-isolated for untrusted code | ⬜ Not Implemented |
**Standard Mode:** Real execution with LLM calls, Job integration, full phase execution. **Standard Mode:** Real execution with LLM calls, full phase execution, logging via kun/log.
**DryRun Mode:** Simulated execution without LLM calls. Used for: **DryRun Mode:** Simulated execution without LLM calls. Used for:
@ -1009,15 +1008,13 @@ stateDiagram-v2
2. Generate member_id if missing 2. Generate member_id if missing
3. Create KB: `robot_{team_id}_{member_id}_kb` 3. Create KB: `robot_{team_id}_{member_id}_kb`
4. Add to cache 4. Add to cache
5. Create Job 5. Set active
6. Set active
### 6.3 On Delete ### 6.3 On Delete
1. Stop running jobs 1. Stop running executions
2. Remove from cache 2. Remove from cache
3. Delete Job 3. Delete or archive KB
4. Delete or archive KB
5. Soft delete record 5. Soft delete record
### 6.4 Execution Flow ### 6.4 Execution Flow
@ -1065,35 +1062,19 @@ stateDiagram-v2
## 7. Integrations ## 7. Integrations
### 7.1 Job System ### 7.1 Execution Storage
**Relationship:** 1 Robot : N Executions (concurrent), 1 Execution = 1 job.Job **Relationship:** 1 Robot : N Executions (concurrent)
Each trigger creates a new Execution, mapped to a `job.Job` for monitoring. Each trigger creates a new Execution, stored in `ExecutionStore` (`__yao.agent_execution` table).
``` Execution data includes:
┌─────────────────────────────────────────────────────────────────┐ - Status and phase tracking
│ Activity Monitor (UI) │ - All phase outputs (Inspiration, Goals, Tasks, Results, Delivery, Learning)
│ • List jobs │ - Error information
│ • See progress │ - Timestamps and progress
│ • View logs │
│ • Cancel/retry │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Job Framework │
│ Job → Execution → Progress → Logs │
└─────────────────────────────────────────────────────────────────┘
```
**Go APIs (yao/job package):** Logging is handled by `kun/log` package for standard application logging.
| Action | API |
| ------------ | -------------------------------------------------------- |
| List Jobs | `job.ListJobs(param, page, pagesize)` |
| Get Job | `job.GetJob(jobID, param)` |
| Save Job | `job.SaveJob(j)` |
| List Execs | `job.ListExecutions(param, page, pagesize)` | | List Execs | `job.ListExecutions(param, page, pagesize)` |
| Get Exec | `job.GetExecution(execID, param)` | | Get Exec | `job.GetExecution(execID, param)` |
| Save Exec | `job.SaveExecution(exec)` | | Save Exec | `job.SaveExecution(exec)` |
@ -1247,78 +1228,49 @@ type RobotState struct {
} }
``` ```
### 8.3 Execution (Uses Job System) ### 8.3 Execution (Uses ExecutionStore)
No separate `autonomous_executions` table. Uses existing Job system. Uses dedicated `__yao.agent_execution` table via ExecutionStore.
**Each trigger creates a new job.Job:** **Each trigger creates a new Execution:**
```go ```go
// On each trigger (clock/human/event), create a new Job // On each trigger (clock/human/event), create a new Execution
execID := gonanoid.Must() exec := &types.Execution{
j, _ := job.Once(job.GOROUTINE, map[string]interface{}{ ID: utils.NewID(),
"job_id": "robot_exec_" + execID, // unique per execution MemberID: memberID,
"category_id": "autonomous_robot", TeamID: teamID,
"name": fmt.Sprintf("%s - %s", member.DisplayName, triggerType), TriggerType: triggerType,
"metadata": map[string]interface{}{ Status: types.ExecStatusRunning,
"member_id": memberID, Phase: types.PhaseP0Init,
"team_id": teamID, StartedAt: time.Now(),
"trigger_type": triggerType,
"exec_id": execID,
},
})
job.SaveJob(j)
// Configure and start
j.ExecutionConfig = &job.ExecutionConfig{
Type: job.ExecutionTypeProcess,
ProcessName: "robot.Execute",
ProcessArgs: []interface{}{memberID, execID, triggerData},
} }
j.Push()
// Save to ExecutionStore
execStore.Save(exec)
``` ```
**Query executions for a robot:** **Query executions for a robot:**
```go ```go
// List all executions for a robot member // List all executions for a robot member
param := model.QueryParam{ executions, err := execStore.List(memberID, 1, 10)
Wheres: []model.QueryWhere{
{Column: "category_id", Value: "autonomous_robot"},
{Column: "metadata->member_id", Value: memberID},
},
Orders: []model.QueryOrder{{Column: "created_at", Option: "desc"}},
}
jobs, _ := job.ListJobs(param, 1, 10)
``` ```
**Query examples:** **Query examples:**
```go ```go
// List all robot jobs (all robots, all executions) // Get execution by ID
param := model.QueryParam{ exec, err := execStore.Get(executionID)
Wheres: []model.QueryWhere{
{Column: "category_id", Value: "autonomous_robot"},
},
}
jobs, _ := job.ListJobs(param, 1, 20)
// Get executions for a robot // List executions for a robot
execParam := model.QueryParam{ executions, err := execStore.List(memberID, page, pageSize)
Wheres: []model.QueryWhere{
{Column: "job_id", Value: "robot_" + memberID},
},
Orders: []model.QueryOrder{{Column: "created_at", Option: "desc"}},
}
execs, _ := job.ListExecutions(execParam, 1, 10)
// Get logs for an execution // Update execution status
logParam := model.QueryParam{ execStore.UpdateStatus(executionID, types.ExecStatusCompleted)
Wheres: []model.QueryWhere{
{Column: "execution_id", Value: execID}, // Logging via kun/log
}, log.With(log.F{"execution_id": exec.ID, "phase": "P1"}).Info("Phase started")
}
logs, _ := job.ListLogs(logParam, 1, 100)
``` ```
--- ---

View file

@ -82,11 +82,6 @@ yao/agent/robot/
│ ├── db.go # Database queries │ ├── db.go # Database queries
│ └── learning.go # Learning entry save (to KB) │ └── learning.go # Learning entry save (to KB)
├── job/ # Job system integration
│ ├── job.go # Create/Get job for robot
│ ├── execution.go # Create/Update execution
│ └── log.go # Write execution logs
└── plan/ # Plan queue (deferred tasks) └── plan/ # Plan queue (deferred tasks)
├── plan.go # Plan queue struct ├── plan.go # Plan queue struct
└── schedule.go # Schedule for later └── schedule.go # Schedule for later
@ -111,7 +106,7 @@ yao/assert/ # Universal assertion library (global package)
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
┌───────┐┌───────┐┌───────┐┌──────┐┌────┐┌──────┐┌───────┐┌─────────┐ ┌───────┐┌───────┐┌───────┐┌──────┐┌────┐┌──────┐┌───────┐┌─────────┐
│ cache ││ dedup ││ store ││ pool ││job ││ plan ││ utils ││ trigger │ │ cache ││ dedup ││ store ││ pool ││ plan ││ utils ││ trigger │
└───┬───┘└───┬───┘└───┬───┘└──┬───┘└──┬─┘└──────┘└───────┘└────┬────┘ └───┬───┘└───┬───┘└───┬───┘└──┬───┘└──┬─┘└──────┘└───────┘└────┬────┘
│ │ │ │ │ │ │ │ │ │ │ │
└────────┴────────┴───────┴───────┴────────────────────────┘ └────────┴────────┴───────┴───────┴────────────────────────┘
@ -145,9 +140,8 @@ yao/assert/ # Universal assertion library (global package)
| `store/` | `types/` | | `store/` | `types/` |
| `pool/` | `types/` | | `pool/` | `types/` |
| `trigger/` | `types/` | | `trigger/` | `types/` |
| `job/` | `types/`, `yao/job` |
| `plan/` | `types/` | | `plan/` | `types/` |
| `executor/` | `types/`, `cache/`, `dedup/`, `store/`, `pool/`, `job/`, `yao/assert` | | `executor/` | `types/`, `cache/`, `dedup/`, `store/`, `pool/`, `yao/assert` |
| `manager/` | `types/`, `cache/`, `pool/`, `trigger/`, `executor/` | | `manager/` | `types/`, `cache/`, `pool/`, `trigger/`, `executor/` |
| | Manager handles all trigger logic (clock, intervene, event) | | | Manager handles all trigger logic (clock, intervene, event) |
| `api/` | `types/`, `manager/` | | `api/` | `types/`, `manager/` |
@ -304,7 +298,6 @@ type TriggerResult struct {
Accepted bool `json:"accepted"` // whether trigger was accepted Accepted bool `json:"accepted"` // whether trigger was accepted
Queued bool `json:"queued"` // true if queued (quota full) Queued bool `json:"queued"` // true if queued (quota full)
Execution *types.Execution `json:"execution,omitempty"` // execution info if started Execution *types.Execution `json:"execution,omitempty"` // execution info if started
JobID string `json:"job_id,omitempty"` // job ID for tracking
Message string `json:"message,omitempty"` // status message Message string `json:"message,omitempty"` // status message
} }
@ -524,7 +517,6 @@ interface TriggerResult {
accepted: boolean; accepted: boolean;
queued: boolean; queued: boolean;
execution?: Execution; execution?: Execution;
job_id?: string;
message?: string; message?: string;
} }
@ -1160,7 +1152,7 @@ import (
// Robot - runtime representation of an autonomous robot (from __yao.member) // Robot - runtime representation of an autonomous robot (from __yao.member)
// Relationship: 1 Robot : N Executions (concurrent) // Relationship: 1 Robot : N Executions (concurrent)
// Each trigger creates a new Execution (mapped to job.Job) // Each trigger creates a new Execution (stored in ExecutionStore)
type Robot struct { type Robot struct {
// From __yao.member // From __yao.member
MemberID string `json:"member_id"` MemberID string `json:"member_id"`
@ -1234,8 +1226,7 @@ func (r *Robot) GetExecutions() []*Execution {
} }
// Execution - single execution instance // Execution - single execution instance
// Each trigger creates a new Execution, mapped to a job.Job for monitoring // Each trigger creates a new Execution, stored in ExecutionStore
// Relationship: 1 Execution = 1 job.Job
type Execution struct { type Execution struct {
ID string `json:"id"` // unique execution ID ID string `json:"id"` // unique execution ID
MemberID string `json:"member_id"` // robot member ID (globally unique) MemberID string `json:"member_id"` // robot member ID (globally unique)
@ -1247,8 +1238,6 @@ type Execution struct {
Phase Phase `json:"phase"` Phase Phase `json:"phase"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
// Job integration (each Execution = 1 job.Job)
JobID string `json:"job_id"` // corresponding job.Job ID
// Trigger input (stored for traceability) // Trigger input (stored for traceability)
Input *TriggerInput `json:"input,omitempty"` // original trigger input Input *TriggerInput `json:"input,omitempty"` // original trigger input
@ -2392,7 +2381,6 @@ type ExecutionRecord struct {
ExecutionID string `json:"execution_id"` // Unique execution identifier ExecutionID string `json:"execution_id"` // Unique execution identifier
MemberID string `json:"member_id"` // Robot member ID (globally unique) MemberID string `json:"member_id"` // Robot member ID (globally unique)
TeamID string `json:"team_id"` // Team ID TeamID string `json:"team_id"` // Team ID
JobID string `json:"job_id,omitempty"` // Linked job.Job ID
TriggerType TriggerType `json:"trigger_type"` // clock | human | event TriggerType TriggerType `json:"trigger_type"` // clock | human | event
// Status tracking (synced with runtime Execution) // Status tracking (synced with runtime Execution)

View file

@ -165,7 +165,6 @@ Create empty structs and stub methods that return nil/empty/success:
- [x] `dedup/dedup.go` - Dedup struct, stub methods - [x] `dedup/dedup.go` - Dedup struct, stub methods
- [x] `store/store.go` - Store struct, stub methods - [x] `store/store.go` - Store struct, stub methods
- [x] `pool/pool.go` - Pool struct, stub methods - [x] `pool/pool.go` - Pool struct, stub methods
- [x] `job/job.go` - job helper stubs
- [x] `plan/plan.go` - Plan struct, stub methods - [x] `plan/plan.go` - Plan struct, stub methods
- [x] `trigger/trigger.go` - trigger dispatcher stub - [x] `trigger/trigger.go` - trigger dispatcher stub
- [x] `executor/executor.go` - Executor struct, stub `Execute()` - [x] `executor/executor.go` - Executor struct, stub `Execute()`
@ -278,35 +277,16 @@ Trigger → Manager → Cache → Dedup → Pool → Worker → Executor(stub)
- [x] ExecutionController lifecycle tests - [x] ExecutionController lifecycle tests
- [x] Manager integration tests for Intervene/HandleEvent - [x] Manager integration tests for Intervene/HandleEvent
### ✅ 3.5 Job Integration (COMPLETE) ### ✅ 3.5 Execution Storage (COMPLETE)
- [x] `job/job.go` - create job - [x] ExecutionStore - execution record persistence
- [x] `job_id`: `robot_exec_{execID}` - [x] Execution data stored in `__yao.agent_execution` table
- [x] `category_name`: `Autonomous Robot` / `自主机器人` (localized) - [x] All phase outputs (Inspiration, Goals, Tasks, Results, Delivery, Learning)
- [x] Metadata: member_id, team_id, trigger_type, exec_id, display_name - [x] Status and phase tracking
- [x] `Options` struct for extensibility (Priority, MaxRetryCount, DefaultTimeout, Metadata) - [x] Logging via `kun/log` package
- [x] `Create()`, `Get()`, `Update()`, `Complete()`, `Fail()`, `Cancel()`
- [x] Status mapping: ExecPending→queued, ExecRunning→running, etc.
- [x] Localization support (en-US, zh-CN) - [x] Localization support (en-US, zh-CN)
- [x] `job/execution.go` - execution lifecycle - [x] Test: execution storage, status tracking
- [x] `CreateOptions` struct for extensibility - [x] `store/execution_test.go` - execution store tests
- [x] `CreateExecution()` - create both robot Execution and job.Execution
- [x] `UpdatePhase()` - update phase with progress tracking (10%→25%→40%→60%→80%→95%)
- [x] `UpdateStatus()` - update execution status
- [x] `CompleteExecution()` / `FailExecution()` / `CancelExecution()`
- [x] TriggerType → TriggerCategory mapping (clock→scheduled, human→manual, event→event)
- [x] Duration calculation on completion/failure/cancellation
- [x] `job/log.go` - write phase logs
- [x] `Log()` - base log function with context
- [x] `LogPhaseStart()` / `LogPhaseEnd()` / `LogPhaseError()`
- [x] `LogError()` / `LogInfo()` / `LogDebug()` / `LogWarn()`
- [x] `LogTaskStart()` / `LogTaskEnd()`
- [x] `LogDelivery()` / `LogLearning()`
- [x] Localization support for all log messages
- [x] Test: job creation, execution tracking, log writing
- [x] `job/job_test.go` - 17 test cases
- [x] `job/execution_test.go` - 26 test cases
- [x] `job/log_test.go` - 24 test cases
- [x] All tests passing with real database - [x] All tests passing with real database
### ✅ 3.6 Executor Architecture (COMPLETE) ### ✅ 3.6 Executor Architecture (COMPLETE)
@ -875,7 +855,7 @@ Created new `yao/assert` package for universal assertion/validation:
- [x] `yao/models/agent/execution.mod.yao` - Execution record model (`agent_execution` table) - [x] `yao/models/agent/execution.mod.yao` - Execution record model (`agent_execution` table)
- [x] id, execution_id (unique) - [x] id, execution_id (unique)
- [x] member_id (globally unique), team_id, job_id - [x] member_id (globally unique), team_id
- [x] trigger_type (enum: clock, human, event) - [x] trigger_type (enum: clock, human, event)
- [x] **Status tracking** (synced with runtime Execution): - [x] **Status tracking** (synced with runtime Execution):
- [x] status (enum: pending, running, completed, failed, cancelled) - [x] status (enum: pending, running, completed, failed, cancelled)

View file

@ -19,7 +19,7 @@ result, _ := api.Trigger(ctx, "member_123", &api.TriggerRequest{
}) })
// Check status // Check status
exec, _ := api.GetExecution(ctx, result.JobID) exec, _ := api.GetExecution(ctx, result.ExecutionID)
``` ```
## Lifecycle ## Lifecycle
@ -148,11 +148,11 @@ type TriggerRequest struct {
```go ```go
type TriggerResult struct { type TriggerResult struct {
Accepted bool // Whether trigger was accepted Accepted bool // Whether trigger was accepted
Queued bool // Whether queued (vs immediate) Queued bool // Whether queued (vs immediate)
Execution *types.Execution // Execution details Execution *types.Execution // Execution details
JobID string // Execution ID for tracking ExecutionID string // Execution ID for tracking
Message string // Status message Message string // Status message
} }
``` ```

View file

@ -102,7 +102,7 @@ func TestAPIFullLifecycle(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, triggerResult) require.NotNil(t, triggerResult)
assert.True(t, triggerResult.Accepted) assert.True(t, triggerResult.Accepted)
assert.NotEmpty(t, triggerResult.JobID) assert.NotEmpty(t, triggerResult.ExecutionID)
// 7. Wait for execution to complete // 7. Wait for execution to complete
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
@ -402,7 +402,7 @@ func TestAPITriggerWithData(t *testing.T) {
require.NotNil(t, result) require.NotNil(t, result)
assert.True(t, result.Accepted) assert.True(t, result.Accepted)
assert.NotEmpty(t, result.JobID) assert.NotEmpty(t, result.ExecutionID)
assert.Contains(t, result.Message, "submitted") assert.Contains(t, result.Message, "submitted")
}) })
@ -542,7 +542,6 @@ func setupAPITestExecution(t *testing.T, execID, memberID string, triggerType ty
ExecutionID: execID, ExecutionID: execID,
MemberID: memberID, MemberID: memberID,
TeamID: "team_api_exec", TeamID: "team_api_exec",
JobID: "job_" + execID,
TriggerType: triggerType, TriggerType: triggerType,
Status: status, Status: status,
Phase: types.PhaseDelivery, Phase: types.PhaseDelivery,

View file

@ -71,9 +71,9 @@ func TestE2EClockTriggerFullFlow(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, result) require.NotNil(t, result)
assert.True(t, result.Accepted, "Clock trigger should be accepted: %s", result.Message) assert.True(t, result.Accepted, "Clock trigger should be accepted: %s", result.Message)
assert.NotEmpty(t, result.JobID, "Should return job ID") assert.NotEmpty(t, result.ExecutionID, "Should return execution ID")
t.Logf("Execution started: JobID=%s", result.JobID) t.Logf("Execution started: ExecutionID=%s", result.ExecutionID)
// Wait for execution to complete (real LLM calls take time) // Wait for execution to complete (real LLM calls take time)
// P0→P4 typically takes 30-60 seconds with real LLM // P0→P4 typically takes 30-60 seconds with real LLM

View file

@ -90,7 +90,7 @@ func TestE2EConcurrentMultipleRobots(t *testing.T) {
if result.Accepted { if result.Accepted {
acceptedCount.Add(1) acceptedCount.Add(1)
t.Logf("Robot %s accepted: JobID=%s", id, result.JobID) t.Logf("Robot %s accepted: ExecutionID=%s", id, result.ExecutionID)
} }
}(i, memberID) }(i, memberID)
} }
@ -176,7 +176,7 @@ func TestE2EConcurrentSameRobotMultipleTriggers(t *testing.T) {
if result.Accepted { if result.Accepted {
acceptedCount.Add(1) acceptedCount.Add(1)
t.Logf("Trigger %d accepted: JobID=%s", idx, result.JobID) t.Logf("Trigger %d accepted: ExecutionID=%s", idx, result.ExecutionID)
} else { } else {
t.Logf("Trigger %d rejected: %s", idx, result.Message) t.Logf("Trigger %d rejected: %s", idx, result.Message)
} }

View file

@ -61,7 +61,7 @@ func TestE2EControlPauseResume(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.True(t, result.Accepted) require.True(t, result.Accepted)
t.Logf("Execution started: JobID=%s", result.JobID) t.Logf("Execution started: ExecutionID=%s", result.ExecutionID)
// Wait for execution to start running // Wait for execution to start running
var execID string var execID string
@ -166,7 +166,7 @@ func TestE2EControlStop(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.True(t, result.Accepted) require.True(t, result.Accepted)
t.Logf("Execution started: JobID=%s", result.JobID) t.Logf("Execution started: ExecutionID=%s", result.ExecutionID)
// Wait for execution to start running // Wait for execution to start running
var execID string var execID string

View file

@ -83,7 +83,7 @@ func TestE2EEventTriggerFullFlow(t *testing.T) {
require.NotNil(t, result) require.NotNil(t, result)
assert.True(t, result.Accepted, "Event trigger should be accepted") assert.True(t, result.Accepted, "Event trigger should be accepted")
t.Logf("Event trigger result: Accepted=%v, JobID=%s", result.Accepted, result.JobID) t.Logf("Event trigger result: Accepted=%v, ExecutionID=%s", result.Accepted, result.ExecutionID)
// Wait for execution to complete // Wait for execution to complete
var exec *types.Execution var exec *types.Execution
@ -256,7 +256,7 @@ func TestE2EEventTriggerVariousEventTypes(t *testing.T) {
require.NotNil(t, result) require.NotNil(t, result)
assert.True(t, result.Accepted, "Event should be accepted") assert.True(t, result.Accepted, "Event should be accepted")
t.Logf("Event triggered: JobID=%s", result.JobID) t.Logf("Event triggered: ExecutionID=%s", result.ExecutionID)
// Wait for execution // Wait for execution
maxWait := 120 * time.Second maxWait := 120 * time.Second

View file

@ -57,9 +57,9 @@ func TriggerManual(ctx *types.Context, memberID string, triggerType types.Trigge
} }
return &TriggerResult{ return &TriggerResult{
Accepted: true, Accepted: true,
JobID: execID, ExecutionID: execID,
Message: fmt.Sprintf("Manual trigger (%s) submitted", triggerType), Message: fmt.Sprintf("Manual trigger (%s) submitted", triggerType),
}, nil }, nil
} }
@ -123,9 +123,9 @@ func triggerHuman(ctx *types.Context, mgr managerInterface, memberID string, req
} }
return &TriggerResult{ return &TriggerResult{
Accepted: true, Accepted: true,
JobID: result.ExecutionID, ExecutionID: result.ExecutionID,
Message: result.Message, Message: result.Message,
}, nil }, nil
} }
@ -150,9 +150,9 @@ func triggerEvent(ctx *types.Context, mgr managerInterface, memberID string, req
} }
return &TriggerResult{ return &TriggerResult{
Accepted: true, Accepted: true,
JobID: result.ExecutionID, ExecutionID: result.ExecutionID,
Message: result.Message, Message: result.Message,
}, nil }, nil
} }
@ -173,9 +173,9 @@ func triggerManual(ctx *types.Context, mgr managerInterface, memberID string, re
} }
return &TriggerResult{ return &TriggerResult{
Accepted: true, Accepted: true,
JobID: execID, ExecutionID: execID,
Message: fmt.Sprintf("Trigger (%s) submitted", req.Type), Message: fmt.Sprintf("Trigger (%s) submitted", req.Type),
}, nil }, nil
} }

View file

@ -82,11 +82,11 @@ const (
// TriggerResult - result of Trigger() // TriggerResult - result of Trigger()
type TriggerResult struct { type TriggerResult struct {
Accepted bool `json:"accepted"` Accepted bool `json:"accepted"`
Queued bool `json:"queued"` Queued bool `json:"queued"`
Execution *types.Execution `json:"execution,omitempty"` Execution *types.Execution `json:"execution,omitempty"`
JobID string `json:"job_id,omitempty"` ExecutionID string `json:"execution_id,omitempty"` // Execution ID
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
} }
// ==================== Execution Types ==================== // ==================== Execution Types ====================

View file

@ -9,8 +9,7 @@ import (
) )
// Smoke tests to verify basic flow works // Smoke tests to verify basic flow works
// These tests use SkipJobIntegration=true to avoid DB dependencies // Real integration tests are in manager_test.go
// Real integration tests are in manager_test.go and job_test.go
func TestExecutorSmoke(t *testing.T) { func TestExecutorSmoke(t *testing.T) {
exec := NewDryRunWithDelay(0) exec := NewDryRunWithDelay(0)

View file

@ -5,18 +5,18 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/robot/executor/types" "github.com/yaoapp/yao/agent/robot/executor/types"
"github.com/yaoapp/yao/agent/robot/job"
"github.com/yaoapp/yao/agent/robot/store" "github.com/yaoapp/yao/agent/robot/store"
robottypes "github.com/yaoapp/yao/agent/robot/types" robottypes "github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/robot/utils"
) )
// Executor implements the standard executor with real Agent calls // Executor implements the standard executor with real Agent calls
// This is the production executor that: // This is the production executor that:
// - Creates Job records for tracking
// - Persists execution history to database // - Persists execution history to database
// - Calls real Agents via Assistant.Stream() // - Calls real Agents via Assistant.Stream()
// - Logs phase transitions and errors // - Logs phase transitions and errors using kun/log
type Executor struct { type Executor struct {
config types.Config config types.Config
store *store.ExecutionStore store *store.ExecutionStore
@ -47,36 +47,22 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
return nil, fmt.Errorf("robot cannot be nil") return nil, fmt.Errorf("robot cannot be nil")
} }
var exec *robottypes.Execution
var err error
// Determine starting phase based on trigger type // Determine starting phase based on trigger type
startPhaseIndex := 0 startPhaseIndex := 0
if trigger == robottypes.TriggerHuman || trigger == robottypes.TriggerEvent { if trigger == robottypes.TriggerHuman || trigger == robottypes.TriggerEvent {
startPhaseIndex = 1 // Skip P0 (Inspiration) startPhaseIndex = 1 // Skip P0 (Inspiration)
} }
// Create execution with Job integration // Create execution (Job system removed, using ExecutionStore only)
if !e.config.SkipJobIntegration { exec := &robottypes.Execution{
exec, err = job.CreateExecution(ctx, &job.CreateOptions{ ID: utils.NewID(),
Robot: robot, MemberID: robot.MemberID,
TriggerType: trigger, TeamID: robot.TeamID,
Input: types.BuildTriggerInput(trigger, data), TriggerType: trigger,
}) StartTime: time.Now(),
if err != nil { Status: robottypes.ExecPending,
return nil, fmt.Errorf("failed to create execution: %w", err) Phase: robottypes.AllPhases[startPhaseIndex],
} Input: types.BuildTriggerInput(trigger, data),
} else {
exec = &robottypes.Execution{
ID: fmt.Sprintf("exec_%d", time.Now().UnixNano()),
MemberID: robot.MemberID,
TeamID: robot.TeamID,
TriggerType: trigger,
StartTime: time.Now(),
Status: robottypes.ExecPending,
Phase: robottypes.AllPhases[startPhaseIndex],
Input: types.BuildTriggerInput(trigger, data),
}
} }
// Set robot reference for phase methods // Set robot reference for phase methods
@ -88,17 +74,20 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
record := store.FromExecution(exec) record := store.FromExecution(exec)
if err := e.store.Save(ctx.Context, record); err != nil { if err := e.store.Save(ctx.Context, record); err != nil {
// Log warning but don't fail execution // Log warning but don't fail execution
if !e.config.SkipJobIntegration { log.With(log.F{
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist execution record: %v", err)) "execution_id": exec.ID,
} "member_id": exec.MemberID,
"error": err,
}).Warn("Failed to persist execution record: %v", err)
} }
} }
// Acquire execution slot // Acquire execution slot
if !robot.TryAcquireSlot(exec) { if !robot.TryAcquireSlot(exec) {
if !e.config.SkipJobIntegration && exec.JobID != "" { log.With(log.F{
_ = job.FailExecution(ctx, exec, robottypes.ErrQuotaExceeded) "execution_id": exec.ID,
} "member_id": exec.MemberID,
}).Warn("Execution quota exceeded")
return nil, robottypes.ErrQuotaExceeded return nil, robottypes.ErrQuotaExceeded
} }
defer robot.RemoveExecution(exec.ID) defer robot.RemoveExecution(exec.ID)
@ -118,17 +107,19 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
// Update status to running // Update status to running
exec.Status = robottypes.ExecRunning exec.Status = robottypes.ExecRunning
if !e.config.SkipJobIntegration { log.With(log.F{
if err := job.UpdateStatus(ctx, exec, robottypes.ExecRunning); err != nil { "execution_id": exec.ID,
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to update status to running: %v", err)) "member_id": exec.MemberID,
} "trigger_type": string(exec.TriggerType),
} }).Info("Execution started")
// Persist running status // Persist running status
if !e.config.SkipPersistence && e.store != nil { if !e.config.SkipPersistence && e.store != nil {
if err := e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecRunning, ""); err != nil { if err := e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecRunning, ""); err != nil {
if !e.config.SkipJobIntegration { log.With(log.F{
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist running status: %v", err)) "execution_id": exec.ID,
} "error": err,
}).Warn("Failed to persist running status: %v", err)
} }
} }
@ -136,9 +127,10 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
if dataStr, ok := data.(string); ok && dataStr == "simulate_failure" { if dataStr, ok := data.(string); ok && dataStr == "simulate_failure" {
exec.Status = robottypes.ExecFailed exec.Status = robottypes.ExecFailed
exec.Error = "simulated failure" exec.Error = "simulated failure"
if !e.config.SkipJobIntegration { log.With(log.F{
_ = job.FailExecution(ctx, exec, fmt.Errorf("simulated failure")) "execution_id": exec.ID,
} "member_id": exec.MemberID,
}).Warn("Simulated failure triggered")
// Persist failed status // Persist failed status
if !e.config.SkipPersistence && e.store != nil { if !e.config.SkipPersistence && e.store != nil {
_ = e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecFailed, "simulated failure") _ = e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecFailed, "simulated failure")
@ -152,9 +144,12 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
if err := e.runPhase(ctx, exec, phase, data); err != nil { if err := e.runPhase(ctx, exec, phase, data); err != nil {
exec.Status = robottypes.ExecFailed exec.Status = robottypes.ExecFailed
exec.Error = err.Error() exec.Error = err.Error()
if !e.config.SkipJobIntegration { log.With(log.F{
_ = job.FailExecution(ctx, exec, err) "execution_id": exec.ID,
} "member_id": exec.MemberID,
"phase": string(phase),
"error": err.Error(),
}).Error("Phase execution failed: %v", err)
// Persist failed status // Persist failed status
if !e.config.SkipPersistence && e.store != nil { if !e.config.SkipPersistence && e.store != nil {
_ = e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecFailed, err.Error()) _ = e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecFailed, err.Error())
@ -168,17 +163,20 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
now := time.Now() now := time.Now()
exec.EndTime = &now exec.EndTime = &now
if !e.config.SkipJobIntegration { duration := now.Sub(exec.StartTime)
if err := job.CompleteExecution(ctx, exec); err != nil { log.With(log.F{
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to mark execution as completed: %v", err)) "execution_id": exec.ID,
} "member_id": exec.MemberID,
} "duration_ms": duration.Milliseconds(),
}).Info("Execution completed successfully")
// Persist completed status // Persist completed status
if !e.config.SkipPersistence && e.store != nil { if !e.config.SkipPersistence && e.store != nil {
if err := e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecCompleted, ""); err != nil { if err := e.store.UpdateStatus(ctx.Context, exec.ID, robottypes.ExecCompleted, ""); err != nil {
if !e.config.SkipJobIntegration { log.With(log.F{
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist completed status: %v", err)) "execution_id": exec.ID,
} "error": err,
}).Warn("Failed to persist completed status: %v", err)
} }
} }
@ -189,11 +187,11 @@ func (e *Executor) Execute(ctx *robottypes.Context, robot *robottypes.Robot, tri
func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution, phase robottypes.Phase, data interface{}) error { func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution, phase robottypes.Phase, data interface{}) error {
exec.Phase = phase exec.Phase = phase
if !e.config.SkipJobIntegration { log.With(log.F{
if err := job.UpdatePhase(ctx, exec, phase); err != nil { "execution_id": exec.ID,
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to update phase to %s: %v", phase, err)) "member_id": exec.MemberID,
} "phase": string(phase),
} }).Info("Phase started: %s", phase)
if e.config.OnPhaseStart != nil { if e.config.OnPhaseStart != nil {
e.config.OnPhaseStart(phase) e.config.OnPhaseStart(phase)
@ -219,9 +217,12 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution,
} }
if err != nil { if err != nil {
if !e.config.SkipJobIntegration { log.With(log.F{
_ = job.LogPhaseError(ctx, exec, phase, err) "execution_id": exec.ID,
} "member_id": exec.MemberID,
"phase": string(phase),
"error": err.Error(),
}).Error("Phase failed: %s - %v", phase, err)
return err return err
} }
@ -231,9 +232,11 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution,
if phaseData != nil { if phaseData != nil {
if err := e.store.UpdatePhase(ctx.Context, exec.ID, phase, phaseData); err != nil { if err := e.store.UpdatePhase(ctx.Context, exec.ID, phase, phaseData); err != nil {
// Log warning but don't fail execution // Log warning but don't fail execution
if !e.config.SkipJobIntegration { log.With(log.F{
_ = job.LogWarn(ctx, exec, fmt.Sprintf("Failed to persist phase %s data: %v", phase, err)) "execution_id": exec.ID,
} "phase": string(phase),
"error": err,
}).Warn("Failed to persist phase %s data: %v", phase, err)
} }
} }
} }
@ -242,10 +245,13 @@ func (e *Executor) runPhase(ctx *robottypes.Context, exec *robottypes.Execution,
e.config.OnPhaseEnd(phase) e.config.OnPhaseEnd(phase)
} }
if !e.config.SkipJobIntegration { phaseDuration := time.Since(phaseStart).Milliseconds()
phaseDuration := time.Since(phaseStart).Milliseconds() log.With(log.F{
_ = job.LogPhaseEnd(ctx, exec, phase, phaseDuration) "execution_id": exec.ID,
} "member_id": exec.MemberID,
"phase": string(phase),
"duration_ms": phaseDuration,
}).Info("Phase completed: %s (took %dms)", phase, phaseDuration)
return nil return nil
} }

View file

@ -34,10 +34,9 @@ func TestExecutorPersistence(t *testing.T) {
robot := createPersistenceTestRobot("member_persist_001", "team_persist_001") robot := createPersistenceTestRobot("member_persist_001", "team_persist_001")
// Create executor with persistence enabled but skip job integration // Create executor with persistence enabled
e := standard.NewWithConfig(types.Config{ e := standard.NewWithConfig(types.Config{
SkipJobIntegration: true, SkipPersistence: false,
SkipPersistence: false,
}) })
// Execute with simulated failure to ensure we get a result // Execute with simulated failure to ensure we get a result
@ -71,8 +70,7 @@ func TestExecutorPersistence(t *testing.T) {
robot := createPersistenceTestRobot("member_persist_002", "team_persist_002") robot := createPersistenceTestRobot("member_persist_002", "team_persist_002")
e := standard.NewWithConfig(types.Config{ e := standard.NewWithConfig(types.Config{
SkipJobIntegration: true, SkipPersistence: false,
SkipPersistence: false,
}) })
// Execute with simulated failure // Execute with simulated failure
@ -104,8 +102,7 @@ func TestExecutorPersistence(t *testing.T) {
// Create executor with persistence disabled // Create executor with persistence disabled
e := standard.NewWithConfig(types.Config{ e := standard.NewWithConfig(types.Config{
SkipJobIntegration: true, SkipPersistence: true,
SkipPersistence: true,
}) })
exec, err := e.Execute(ctx, robot, robottypes.TriggerHuman, "simulate_failure") exec, err := e.Execute(ctx, robot, robottypes.TriggerHuman, "simulate_failure")

View file

@ -50,9 +50,6 @@ type PhaseExecutor interface {
// Config holds common executor configuration // Config holds common executor configuration
type Config struct { type Config struct {
// SkipJobIntegration skips job system integration (for testing)
SkipJobIntegration bool
// SkipPersistence skips execution record persistence (for testing) // SkipPersistence skips execution record persistence (for testing)
SkipPersistence bool SkipPersistence bool

View file

@ -1,433 +0,0 @@
package job
import (
"encoding/json"
"fmt"
"time"
"github.com/yaoapp/gou/model"
yaojob "github.com/yaoapp/yao/job"
"github.com/yaoapp/yao/agent/robot/types"
)
// CreateOptions holds options for creating a new execution
type CreateOptions struct {
Robot *types.Robot // Required: the robot to execute
TriggerType types.TriggerType // Required: clock | human | event
Input *types.TriggerInput // Optional: trigger input data
// Optional fields for future extension
Priority int // Execution priority (higher = more important)
TimeoutSeconds *int // Execution timeout
ParentExecutionID string // Parent execution ID for sub-tasks
ScheduledAt *time.Time // Scheduled execution time (for delayed execution)
Metadata map[string]interface{} // Custom metadata
}
// Validate validates the CreateOptions
func (o *CreateOptions) Validate() error {
if o.Robot == nil {
return fmt.Errorf("robot is required")
}
if o.TriggerType == "" {
return fmt.Errorf("trigger type is required")
}
return nil
}
// CreateExecution creates a new execution record in the job system
// This creates both the robot Execution and the corresponding job.Execution
func CreateExecution(ctx *types.Context, opts *CreateOptions) (*types.Execution, error) {
if opts == nil {
return nil, fmt.Errorf("options is nil")
}
if err := opts.Validate(); err != nil {
return nil, err
}
robot := opts.Robot
triggerType := opts.TriggerType
// Create job for this execution (returns jobID and execID)
jobID, execID, err := Create(ctx, &Options{
Robot: robot,
TriggerType: triggerType,
Priority: opts.Priority,
Metadata: opts.Metadata,
})
if err != nil {
return nil, fmt.Errorf("failed to create job: %w", err)
}
// Determine starting phase based on trigger type
// Clock trigger starts from P0 (Inspiration)
// Human/Event triggers skip P0 and start from P1 (Goals)
startPhase := types.PhaseInspiration
if triggerType == types.TriggerHuman || triggerType == types.TriggerEvent {
startPhase = types.PhaseGoals
}
// Create robot execution
exec := &types.Execution{
ID: execID,
MemberID: robot.MemberID,
TeamID: robot.TeamID,
TriggerType: triggerType,
StartTime: time.Now(),
Status: types.ExecPending,
Phase: startPhase,
Input: opts.Input,
JobID: jobID,
}
// Build trigger context for job execution
triggerContext, _ := json.Marshal(map[string]interface{}{
"trigger_type": string(triggerType),
"member_id": robot.MemberID,
"team_id": robot.TeamID,
})
triggerContextRaw := json.RawMessage(triggerContext)
// Map trigger type to trigger category
// TriggerCategory ENUM: manual, scheduled, event, api, system, dependency
triggerCategory := mapTriggerTypeToCategory(triggerType)
triggerSource := string(triggerType) // Store original trigger type as source
// Create job execution record
jobExec := &yaojob.Execution{
ExecutionID: execID,
JobID: jobID,
Status: "queued",
TriggerCategory: triggerCategory,
TriggerSource: &triggerSource,
TriggerContext: &triggerContextRaw,
Progress: 0,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Apply optional fields
if opts.TimeoutSeconds != nil {
jobExec.TimeoutSeconds = opts.TimeoutSeconds
}
if opts.ParentExecutionID != "" {
jobExec.ParentExecutionID = &opts.ParentExecutionID
}
if opts.ScheduledAt != nil {
jobExec.ScheduledAt = opts.ScheduledAt
}
if opts.Priority > 0 {
jobExec.ExecutionOptions = &yaojob.ExecutionOptions{
Priority: opts.Priority,
}
}
if err := yaojob.SaveExecution(jobExec); err != nil {
return nil, fmt.Errorf("failed to save job execution: %w", err)
}
// Note: Job status is automatically updated by yaojob.SaveExecution -> updateJobProgress
// No need to manually set job status here
return exec, nil
}
// UpdatePhase updates the execution phase in the job system
func UpdatePhase(ctx *types.Context, exec *types.Execution, phase types.Phase) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
exec.Phase = phase
// Update job
if err := Update(ctx, exec); err != nil {
return fmt.Errorf("failed to update job: %w", err)
}
// Update job execution progress
progress := phaseToProgress(phase)
if err := updateExecutionProgress(exec.ID, progress, string(phase)); err != nil {
return fmt.Errorf("failed to update execution progress: %w", err)
}
// Log phase transition (ignore error, non-critical)
_ = LogPhaseStart(ctx, exec, phase)
return nil
}
// UpdateStatus updates the execution status in the job system
func UpdateStatus(ctx *types.Context, exec *types.Execution, status types.ExecStatus) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
exec.Status = status
// Update job
if err := Update(ctx, exec); err != nil {
return fmt.Errorf("failed to update job: %w", err)
}
// Update job execution status
if err := updateExecutionStatus(exec.ID, status); err != nil {
return fmt.Errorf("failed to update execution status: %w", err)
}
return nil
}
// CompleteExecution marks execution as completed
func CompleteExecution(ctx *types.Context, exec *types.Execution) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
now := time.Now()
exec.EndTime = &now
exec.Status = types.ExecCompleted
// Complete the job
if err := Complete(ctx, exec); err != nil {
return fmt.Errorf("failed to complete job: %w", err)
}
// Update job execution
if err := completeJobExecution(exec.ID, exec.StartTime); err != nil {
return fmt.Errorf("failed to complete job execution: %w", err)
}
// Log completion (ignore error, non-critical)
locale := getLocale(ctx)
var msg string
if isChineseLocale(locale) {
msg = "执行完成"
} else {
msg = "Execution completed successfully"
}
_ = Log(ctx, exec, "info", msg, map[string]interface{}{
"duration_ms": now.Sub(exec.StartTime).Milliseconds(),
})
return nil
}
// FailExecution marks execution as failed
func FailExecution(ctx *types.Context, exec *types.Execution, execErr error) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
now := time.Now()
exec.EndTime = &now
exec.Status = types.ExecFailed
if execErr != nil {
exec.Error = execErr.Error()
}
// Fail the job
if err := Fail(ctx, exec, execErr); err != nil {
return fmt.Errorf("failed to fail job: %w", err)
}
// Update job execution
if err := failJobExecution(exec.ID, execErr, exec.StartTime); err != nil {
return fmt.Errorf("failed to fail job execution: %w", err)
}
// Log failure (ignore error, non-critical)
_ = LogError(ctx, exec, execErr)
return nil
}
// CancelExecution marks execution as cancelled
func CancelExecution(ctx *types.Context, exec *types.Execution) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
now := time.Now()
exec.EndTime = &now
exec.Status = types.ExecCancelled
// Cancel the job
if err := Cancel(ctx, exec); err != nil {
return fmt.Errorf("failed to cancel job: %w", err)
}
// Update job execution
if err := cancelJobExecution(exec.ID, exec.StartTime); err != nil {
return fmt.Errorf("failed to cancel job execution: %w", err)
}
// Log cancellation (ignore error, non-critical)
locale := getLocale(ctx)
var msg string
if isChineseLocale(locale) {
msg = "执行已取消"
} else {
msg = "Execution cancelled"
}
_ = Log(ctx, exec, "info", msg, nil)
return nil
}
// GetExecution retrieves a job execution by ID
func GetExecution(executionID string) (*yaojob.Execution, error) {
if executionID == "" {
return nil, fmt.Errorf("execution ID is empty")
}
return yaojob.GetExecution(executionID, model.QueryParam{})
}
// ListExecutions lists executions for a job
func ListExecutions(jobID string) ([]*yaojob.Execution, error) {
if jobID == "" {
return nil, fmt.Errorf("job ID is empty")
}
return yaojob.GetExecutions(jobID)
}
// phaseToProgress maps phase to progress percentage
func phaseToProgress(phase types.Phase) int {
switch phase {
case types.PhaseInspiration:
return 10
case types.PhaseGoals:
return 25
case types.PhaseTasks:
return 40
case types.PhaseRun:
return 60
case types.PhaseDelivery:
return 80
case types.PhaseLearning:
return 95
default:
return 0
}
}
// updateExecutionProgress updates the job execution progress
// Note: step parameter is kept for future use if yaojob.Execution adds Step field
func updateExecutionProgress(executionID string, progress int, _ string) error {
exec, err := yaojob.GetExecution(executionID, model.QueryParam{})
if err != nil {
return err
}
exec.Progress = progress
exec.UpdatedAt = time.Now()
return yaojob.SaveExecution(exec)
}
// updateExecutionStatus updates the job execution status
func updateExecutionStatus(executionID string, status types.ExecStatus) error {
exec, err := yaojob.GetExecution(executionID, model.QueryParam{})
if err != nil {
return err
}
exec.Status = mapStatusToJobStatus(status)
exec.UpdatedAt = time.Now()
if status == types.ExecRunning && exec.StartedAt == nil {
now := time.Now()
exec.StartedAt = &now
}
return yaojob.SaveExecution(exec)
}
// completeJobExecution marks job execution as completed
func completeJobExecution(executionID string, startTime time.Time) error {
exec, err := yaojob.GetExecution(executionID, model.QueryParam{})
if err != nil {
return err
}
now := time.Now()
exec.Status = "completed"
exec.Progress = 100
exec.EndedAt = &now
exec.UpdatedAt = now
// Calculate duration (handle zero startTime)
if !startTime.IsZero() {
duration := int(now.Sub(startTime).Milliseconds())
exec.Duration = &duration
}
return yaojob.SaveExecution(exec)
}
// failJobExecution marks job execution as failed
func failJobExecution(executionID string, execErr error, startTime time.Time) error {
exec, err := yaojob.GetExecution(executionID, model.QueryParam{})
if err != nil {
return err
}
now := time.Now()
exec.Status = "failed"
exec.EndedAt = &now
exec.UpdatedAt = now
// Calculate duration (handle zero startTime)
if !startTime.IsZero() {
duration := int(now.Sub(startTime).Milliseconds())
exec.Duration = &duration
}
// Store error info
if execErr != nil {
errorInfo, _ := json.Marshal(map[string]string{
"message": execErr.Error(),
})
errorInfoRaw := json.RawMessage(errorInfo)
exec.ErrorInfo = &errorInfoRaw
}
return yaojob.SaveExecution(exec)
}
// cancelJobExecution marks job execution as cancelled
func cancelJobExecution(executionID string, startTime time.Time) error {
exec, err := yaojob.GetExecution(executionID, model.QueryParam{})
if err != nil {
return err
}
now := time.Now()
exec.Status = "cancelled"
exec.EndedAt = &now
exec.UpdatedAt = now
// Calculate duration (handle zero startTime)
if !startTime.IsZero() {
duration := int(now.Sub(startTime).Milliseconds())
exec.Duration = &duration
}
return yaojob.SaveExecution(exec)
}
// mapTriggerTypeToCategory maps robot TriggerType to job execution TriggerCategory
// TriggerCategory ENUM values: manual, scheduled, event, api, system, dependency
func mapTriggerTypeToCategory(triggerType types.TriggerType) string {
switch triggerType {
case types.TriggerClock:
return "scheduled"
case types.TriggerHuman:
return "manual"
case types.TriggerEvent:
return "event"
default:
return "system"
}
}

View file

@ -1,559 +0,0 @@
package job_test
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/robot/job"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
)
// TestCreateExecution tests creating a new execution
func TestCreateExecution(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("create execution with clock trigger", func(t *testing.T) {
robot := createTestRobot("test_exec_create_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
assert.NotNil(t, exec)
assert.NotEmpty(t, exec.ID)
assert.NotEmpty(t, exec.JobID)
assert.Equal(t, robot.MemberID, exec.MemberID)
assert.Equal(t, robot.TeamID, exec.TeamID)
assert.Equal(t, types.TriggerClock, exec.TriggerType)
assert.Equal(t, types.ExecPending, exec.Status)
// Clock trigger starts from P0 (Inspiration)
assert.Equal(t, types.PhaseInspiration, exec.Phase)
assert.False(t, exec.StartTime.IsZero())
})
t.Run("create execution with human trigger starts from P1", func(t *testing.T) {
robot := createTestRobot("test_exec_create_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerHuman,
})
require.NoError(t, err)
assert.NotNil(t, exec)
// Human trigger skips P0, starts from P1 (Goals)
assert.Equal(t, types.PhaseGoals, exec.Phase)
})
t.Run("create execution with event trigger starts from P1", func(t *testing.T) {
robot := createTestRobot("test_exec_create_003")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerEvent,
})
require.NoError(t, err)
assert.NotNil(t, exec)
// Event trigger skips P0, starts from P1 (Goals)
assert.Equal(t, types.PhaseGoals, exec.Phase)
})
t.Run("create execution with input", func(t *testing.T) {
robot := createTestRobot("test_exec_create_004")
input := &types.TriggerInput{
Action: types.ActionTaskAdd,
UserID: "test_user_001",
}
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerHuman,
Input: input,
})
require.NoError(t, err)
assert.NotNil(t, exec)
assert.NotNil(t, exec.Input)
assert.Equal(t, types.ActionTaskAdd, exec.Input.Action)
})
t.Run("create execution with optional fields", func(t *testing.T) {
robot := createTestRobot("test_exec_create_005")
timeout := 300
scheduledAt := time.Now().Add(1 * time.Hour)
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
Priority: 5,
TimeoutSeconds: &timeout,
ParentExecutionID: "parent_exec_001",
ScheduledAt: &scheduledAt,
Metadata: map[string]interface{}{
"source": "test",
},
})
require.NoError(t, err)
assert.NotNil(t, exec)
})
t.Run("create execution with nil robot returns error", func(t *testing.T) {
_, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: nil,
TriggerType: types.TriggerClock,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "robot is required")
})
t.Run("create execution with empty trigger type returns error", func(t *testing.T) {
robot := createTestRobot("test_exec_create_006")
_, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: "",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "trigger type is required")
})
t.Run("create execution with nil options returns error", func(t *testing.T) {
_, err := job.CreateExecution(ctx, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "options is nil")
})
}
// TestUpdatePhase tests updating execution phase
func TestUpdatePhase(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("update phase successfully", func(t *testing.T) {
robot := createTestRobot("test_phase_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
// Update to Goals phase
err = job.UpdatePhase(ctx, exec, types.PhaseGoals)
require.NoError(t, err)
assert.Equal(t, types.PhaseGoals, exec.Phase)
// Verify job was updated
j, err := job.Get(exec.JobID)
require.NoError(t, err)
assert.Equal(t, string(types.PhaseGoals), j.Config["current_phase"])
})
t.Run("update through all phases", func(t *testing.T) {
robot := createTestRobot("test_phase_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
phases := []types.Phase{
types.PhaseGoals,
types.PhaseTasks,
types.PhaseRun,
types.PhaseDelivery,
types.PhaseLearning,
}
for _, phase := range phases {
err = job.UpdatePhase(ctx, exec, phase)
require.NoError(t, err)
assert.Equal(t, phase, exec.Phase)
}
})
t.Run("update phase with nil execution returns error", func(t *testing.T) {
err := job.UpdatePhase(ctx, nil, types.PhaseGoals)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid execution")
})
t.Run("update phase with empty execution ID returns error", func(t *testing.T) {
exec := &types.Execution{
ID: "",
JobID: "some_job_id",
}
err := job.UpdatePhase(ctx, exec, types.PhaseGoals)
assert.Error(t, err)
})
t.Run("update phase with empty job ID returns error", func(t *testing.T) {
exec := &types.Execution{
ID: "some_exec_id",
JobID: "",
}
err := job.UpdatePhase(ctx, exec, types.PhaseGoals)
assert.Error(t, err)
})
}
// TestUpdateStatus tests updating execution status
func TestUpdateStatus(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("update status to running", func(t *testing.T) {
robot := createTestRobot("test_status_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.UpdateStatus(ctx, exec, types.ExecRunning)
require.NoError(t, err)
assert.Equal(t, types.ExecRunning, exec.Status)
// Verify job was updated
j, err := job.Get(exec.JobID)
require.NoError(t, err)
assert.Equal(t, "running", j.Status)
})
t.Run("update status with nil execution returns error", func(t *testing.T) {
err := job.UpdateStatus(ctx, nil, types.ExecRunning)
assert.Error(t, err)
})
}
// TestCompleteExecution tests completing an execution
func TestCompleteExecution(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("complete execution successfully", func(t *testing.T) {
robot := createTestRobot("test_complete_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
// Simulate execution progress
exec.Delivery = &types.DeliveryResult{
RequestID: "test-delivery-001",
Content: &types.DeliveryContent{
Summary: "Test delivery completed",
Body: "# Test Delivery\n\nThis is a test delivery result.",
},
Success: true,
}
err = job.CompleteExecution(ctx, exec)
require.NoError(t, err)
assert.Equal(t, types.ExecCompleted, exec.Status)
assert.NotNil(t, exec.EndTime)
// Verify job was completed
j, err := job.Get(exec.JobID)
require.NoError(t, err)
assert.Equal(t, "completed", j.Status)
// Verify job execution was updated
jobExec, err := job.GetExecution(exec.ID)
require.NoError(t, err)
assert.Equal(t, "completed", jobExec.Status)
assert.Equal(t, 100, jobExec.Progress)
assert.NotNil(t, jobExec.EndedAt)
})
t.Run("complete execution with nil execution returns error", func(t *testing.T) {
err := job.CompleteExecution(ctx, nil)
assert.Error(t, err)
})
}
// TestFailExecution tests failing an execution
func TestFailExecution(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("fail execution with error", func(t *testing.T) {
robot := createTestRobot("test_fail_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
testErr := errors.New("task execution failed")
err = job.FailExecution(ctx, exec, testErr)
require.NoError(t, err)
assert.Equal(t, types.ExecFailed, exec.Status)
assert.NotNil(t, exec.EndTime)
assert.Equal(t, testErr.Error(), exec.Error)
// Verify job was failed
j, err := job.Get(exec.JobID)
require.NoError(t, err)
assert.Equal(t, "failed", j.Status)
})
t.Run("fail execution without error", func(t *testing.T) {
robot := createTestRobot("test_fail_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.FailExecution(ctx, exec, nil)
require.NoError(t, err)
assert.Equal(t, types.ExecFailed, exec.Status)
assert.Empty(t, exec.Error)
})
}
// TestCancelExecution tests cancelling an execution
func TestCancelExecution(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("cancel execution successfully", func(t *testing.T) {
robot := createTestRobot("test_cancel_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.CancelExecution(ctx, exec)
require.NoError(t, err)
assert.Equal(t, types.ExecCancelled, exec.Status)
assert.NotNil(t, exec.EndTime)
// Verify job was cancelled
j, err := job.Get(exec.JobID)
require.NoError(t, err)
assert.Equal(t, "cancelled", j.Status)
})
}
// TestGetExecution tests retrieving an execution
func TestGetExecution(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("get existing execution", func(t *testing.T) {
robot := createTestRobot("test_get_exec_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
jobExec, err := job.GetExecution(exec.ID)
require.NoError(t, err)
assert.NotNil(t, jobExec)
assert.Equal(t, exec.ID, jobExec.ExecutionID)
assert.Equal(t, exec.JobID, jobExec.JobID)
})
t.Run("get non-existent execution returns error", func(t *testing.T) {
_, err := job.GetExecution("non_existent_exec_id")
assert.Error(t, err)
})
t.Run("get with empty execution ID returns error", func(t *testing.T) {
_, err := job.GetExecution("")
assert.Error(t, err)
assert.Contains(t, err.Error(), "execution ID is empty")
})
}
// TestListExecutions tests listing executions for a job
func TestListExecutions(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("list executions for job", func(t *testing.T) {
robot := createTestRobot("test_list_exec_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
execs, err := job.ListExecutions(exec.JobID)
require.NoError(t, err)
assert.NotEmpty(t, execs)
assert.Equal(t, 1, len(execs))
assert.Equal(t, exec.ID, execs[0].ExecutionID)
})
t.Run("list with empty job ID returns error", func(t *testing.T) {
_, err := job.ListExecutions("")
assert.Error(t, err)
assert.Contains(t, err.Error(), "job ID is empty")
})
}
// TestPhaseToProgress tests phase to progress mapping
func TestPhaseToProgress(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
testCases := []struct {
phase types.Phase
expectedProgress int
}{
{types.PhaseInspiration, 10},
{types.PhaseGoals, 25},
{types.PhaseTasks, 40},
{types.PhaseRun, 60},
{types.PhaseDelivery, 80},
{types.PhaseLearning, 95},
}
for _, tc := range testCases {
t.Run(string(tc.phase), func(t *testing.T) {
robot := createTestRobot("test_progress_" + string(tc.phase))
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.UpdatePhase(ctx, exec, tc.phase)
require.NoError(t, err)
// Verify progress in job execution
jobExec, err := job.GetExecution(exec.ID)
require.NoError(t, err)
assert.Equal(t, tc.expectedProgress, jobExec.Progress)
})
}
}
// TestExecutionDuration tests execution duration calculation
func TestExecutionDuration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("duration calculated on completion", func(t *testing.T) {
robot := createTestRobot("test_duration_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
// Wait a bit to ensure measurable duration
time.Sleep(50 * time.Millisecond)
err = job.CompleteExecution(ctx, exec)
require.NoError(t, err)
// Verify duration was calculated
jobExec, err := job.GetExecution(exec.ID)
require.NoError(t, err)
assert.NotNil(t, jobExec.Duration)
assert.Greater(t, *jobExec.Duration, 0)
})
t.Run("duration calculated on failure", func(t *testing.T) {
robot := createTestRobot("test_duration_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
time.Sleep(50 * time.Millisecond)
err = job.FailExecution(ctx, exec, errors.New("test error"))
require.NoError(t, err)
jobExec, err := job.GetExecution(exec.ID)
require.NoError(t, err)
assert.NotNil(t, jobExec.Duration)
assert.Greater(t, *jobExec.Duration, 0)
})
}

View file

@ -1,365 +0,0 @@
package job
import (
"fmt"
"strings"
gonanoid "github.com/matoous/go-nanoid/v2"
yaojob "github.com/yaoapp/yao/job"
"github.com/yaoapp/yao/agent/robot/types"
)
// CategoryID is the job category for robot executions
const CategoryID = "autonomous_robot"
// JobIDPrefix is the prefix for robot job IDs
const JobIDPrefix = "robot_exec_"
// Options holds options for creating a new job
type Options struct {
Robot *types.Robot // Required: the robot to execute
TriggerType types.TriggerType // Required: clock | human | event
// Optional fields for future extension
Priority int // Job priority (higher = more important)
MaxRetryCount int // Max retry count on failure
DefaultTimeout *int // Default execution timeout in seconds
Metadata map[string]interface{} // Custom metadata stored in job config
}
// Validate validates the Options
func (o *Options) Validate() error {
if o.Robot == nil {
return fmt.Errorf("robot is required")
}
if o.TriggerType == "" {
return fmt.Errorf("trigger type is required")
}
return nil
}
// Create creates a new job for robot execution
// Returns the job ID (format: robot_exec_{execID}) and the generated execution ID
func Create(ctx *types.Context, opts *Options) (jobID string, execID string, err error) {
if opts == nil {
return "", "", fmt.Errorf("options is nil")
}
if err := opts.Validate(); err != nil {
return "", "", err
}
robot := opts.Robot
triggerType := opts.TriggerType
// Generate execution ID
execID, err = gonanoid.New()
if err != nil {
return "", "", fmt.Errorf("failed to generate execution ID: %w", err)
}
// Create job ID: robot_exec_{execID}
jobID = JobIDPrefix + execID
// Get locale from context
locale := getLocale(ctx)
// Build job name based on locale
// Use robot display name for better readability in Activity Monitor
displayName := robot.DisplayName
if displayName == "" {
displayName = robot.MemberID
}
name := buildJobName(locale, triggerType, displayName)
// Build job config
jobConfig := map[string]interface{}{
"member_id": robot.MemberID,
"team_id": robot.TeamID,
"trigger_type": string(triggerType),
"exec_id": execID,
"display_name": displayName,
}
// Merge custom metadata into config
if opts.Metadata != nil {
for k, v := range opts.Metadata {
jobConfig[k] = v
}
}
// Build job params
jobParams := map[string]interface{}{
"job_id": jobID,
"category_name": getCategoryName(locale),
"name": name,
"config": jobConfig,
}
// Apply optional fields
if opts.Priority > 0 {
jobParams["priority"] = opts.Priority
}
if opts.MaxRetryCount > 0 {
jobParams["max_retry_count"] = opts.MaxRetryCount
}
if opts.DefaultTimeout != nil {
jobParams["default_timeout"] = *opts.DefaultTimeout
}
// Create job using yao/job package
j, err := yaojob.Once(yaojob.GOROUTINE, jobParams)
if err != nil {
return "", "", fmt.Errorf("failed to create job: %w", err)
}
// Save job to database
if err := yaojob.SaveJob(j); err != nil {
return "", "", fmt.Errorf("failed to save job: %w", err)
}
return jobID, execID, nil
}
// Get retrieves a job by job ID
func Get(jobID string) (*yaojob.Job, error) {
if jobID == "" {
return nil, fmt.Errorf("job ID is empty")
}
return yaojob.GetJob(jobID)
}
// Update updates job status and phase
func Update(ctx *types.Context, exec *types.Execution) error {
if exec == nil || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing job ID")
}
j, err := yaojob.GetJob(exec.JobID)
if err != nil {
return fmt.Errorf("failed to get job: %w", err)
}
// Map robot status to job status
jobStatus := mapStatusToJobStatus(exec.Status)
j.Status = jobStatus
// Update config with current phase
if j.Config == nil {
j.Config = make(map[string]interface{})
}
j.Config["current_phase"] = string(exec.Phase)
j.Config["current_status"] = string(exec.Status)
if err := yaojob.SaveJob(j); err != nil {
return fmt.Errorf("failed to update job: %w", err)
}
return nil
}
// Complete marks job as completed
func Complete(ctx *types.Context, exec *types.Execution) error {
if exec == nil || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing job ID")
}
j, err := yaojob.GetJob(exec.JobID)
if err != nil {
return fmt.Errorf("failed to get job: %w", err)
}
j.Status = "completed"
// Update config with final state
if j.Config == nil {
j.Config = make(map[string]interface{})
}
j.Config["current_phase"] = string(types.PhaseLearning)
j.Config["current_status"] = string(types.ExecCompleted)
if exec.Delivery != nil {
j.Config["delivery_success"] = exec.Delivery.Success
}
if err := yaojob.SaveJob(j); err != nil {
return fmt.Errorf("failed to complete job: %w", err)
}
return nil
}
// Fail marks job as failed
func Fail(ctx *types.Context, exec *types.Execution, execErr error) error {
if exec == nil || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing job ID")
}
j, err := yaojob.GetJob(exec.JobID)
if err != nil {
return fmt.Errorf("failed to get job: %w", err)
}
j.Status = "failed"
// Update config with error info
if j.Config == nil {
j.Config = make(map[string]interface{})
}
j.Config["current_phase"] = string(exec.Phase)
j.Config["current_status"] = string(types.ExecFailed)
if execErr != nil {
j.Config["error"] = execErr.Error()
}
if err := yaojob.SaveJob(j); err != nil {
return fmt.Errorf("failed to fail job: %w", err)
}
return nil
}
// Cancel marks job as cancelled
func Cancel(ctx *types.Context, exec *types.Execution) error {
if exec == nil || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing job ID")
}
j, err := yaojob.GetJob(exec.JobID)
if err != nil {
return fmt.Errorf("failed to get job: %w", err)
}
j.Status = "cancelled"
// Update config with cancelled state
if j.Config == nil {
j.Config = make(map[string]interface{})
}
j.Config["current_phase"] = string(exec.Phase)
j.Config["current_status"] = string(types.ExecCancelled)
if err := yaojob.SaveJob(j); err != nil {
return fmt.Errorf("failed to cancel job: %w", err)
}
return nil
}
// mapStatusToJobStatus maps robot ExecStatus to job status string
// Job model ENUM values: draft, ready, queued, running, paused, completed, failed, cancelled, disabled
func mapStatusToJobStatus(status types.ExecStatus) string {
switch status {
case types.ExecPending:
return "queued"
case types.ExecRunning:
return "running"
case types.ExecCompleted:
return "completed"
case types.ExecFailed:
return "failed"
case types.ExecCancelled:
return "cancelled"
default:
return "draft"
}
}
// getLocale returns the locale from context, defaults to "en-US"
func getLocale(ctx *types.Context) string {
if ctx == nil || ctx.Locale == "" {
return "en-US"
}
return ctx.Locale
}
// isChineseLocale checks if the locale is Chinese
func isChineseLocale(locale string) bool {
return strings.HasPrefix(strings.ToLower(locale), "zh")
}
// buildJobName builds the job name based on locale
func buildJobName(locale string, triggerType types.TriggerType, displayName string) string {
var name string
if isChineseLocale(locale) {
name = fmt.Sprintf("机器人执行 - %s", getTriggerTypeName(locale, triggerType))
} else {
name = fmt.Sprintf("Robot Execution - %s", getTriggerTypeName(locale, triggerType))
}
if displayName != "" {
name = fmt.Sprintf("%s (%s)", name, displayName)
}
return name
}
// getCategoryName returns the category name based on locale
func getCategoryName(locale string) string {
if isChineseLocale(locale) {
return "自主机器人"
}
return "Autonomous Robot"
}
// getTriggerTypeName returns the trigger type name based on locale
func getTriggerTypeName(locale string, triggerType types.TriggerType) string {
if isChineseLocale(locale) {
switch triggerType {
case types.TriggerClock:
return "定时触发"
case types.TriggerHuman:
return "人工触发"
case types.TriggerEvent:
return "事件触发"
default:
return string(triggerType)
}
}
switch triggerType {
case types.TriggerClock:
return "Clock"
case types.TriggerHuman:
return "Human"
case types.TriggerEvent:
return "Event"
default:
return string(triggerType)
}
}
// getPhaseName returns the phase name based on locale
func getPhaseName(locale string, phase types.Phase) string {
if isChineseLocale(locale) {
switch phase {
case types.PhaseInspiration:
return "灵感收集"
case types.PhaseGoals:
return "目标生成"
case types.PhaseTasks:
return "任务规划"
case types.PhaseRun:
return "任务执行"
case types.PhaseDelivery:
return "结果交付"
case types.PhaseLearning:
return "学习总结"
default:
return string(phase)
}
}
switch phase {
case types.PhaseInspiration:
return "Inspiration"
case types.PhaseGoals:
return "Goals"
case types.PhaseTasks:
return "Tasks"
case types.PhaseRun:
return "Run"
case types.PhaseDelivery:
return "Delivery"
case types.PhaseLearning:
return "Learning"
default:
return string(phase)
}
}

View file

@ -1,508 +0,0 @@
package job_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/robot/job"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
)
// TestJobCreate tests creating a new job
func TestJobCreate(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("create job with clock trigger", func(t *testing.T) {
robot := createTestRobot("test_job_create_001")
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
assert.NotEmpty(t, jobID)
assert.NotEmpty(t, execID)
assert.Contains(t, jobID, job.JobIDPrefix)
assert.Contains(t, jobID, execID)
// Verify job was created in database
j, err := job.Get(jobID)
require.NoError(t, err)
assert.NotNil(t, j)
assert.Equal(t, jobID, j.JobID)
assert.Equal(t, robot.MemberID, j.Config["member_id"])
assert.Equal(t, robot.TeamID, j.Config["team_id"])
assert.Equal(t, string(types.TriggerClock), j.Config["trigger_type"])
})
t.Run("create job with human trigger", func(t *testing.T) {
robot := createTestRobot("test_job_create_002")
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerHuman,
})
require.NoError(t, err)
assert.NotEmpty(t, jobID)
assert.NotEmpty(t, execID)
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Equal(t, string(types.TriggerHuman), j.Config["trigger_type"])
})
t.Run("create job with event trigger", func(t *testing.T) {
robot := createTestRobot("test_job_create_003")
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerEvent,
})
require.NoError(t, err)
assert.NotEmpty(t, jobID)
assert.NotEmpty(t, execID)
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Equal(t, string(types.TriggerEvent), j.Config["trigger_type"])
})
t.Run("create job with priority and metadata", func(t *testing.T) {
robot := createTestRobot("test_job_create_004")
jobID, _, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
Priority: 10,
Metadata: map[string]interface{}{
"custom_key": "custom_value",
},
})
require.NoError(t, err)
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Equal(t, 10, j.Priority)
assert.Equal(t, "custom_value", j.Config["custom_key"])
})
t.Run("create job with nil robot returns error", func(t *testing.T) {
_, _, err := job.Create(ctx, &job.Options{
Robot: nil,
TriggerType: types.TriggerClock,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "robot is required")
})
t.Run("create job with empty trigger type returns error", func(t *testing.T) {
robot := createTestRobot("test_job_create_005")
_, _, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: "",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "trigger type is required")
})
t.Run("create job with nil options returns error", func(t *testing.T) {
_, _, err := job.Create(ctx, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "options is nil")
})
}
// TestJobGet tests retrieving a job by ID
func TestJobGet(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("get existing job", func(t *testing.T) {
robot := createTestRobot("test_job_get_001")
jobID, _, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
j, err := job.Get(jobID)
require.NoError(t, err)
assert.NotNil(t, j)
assert.Equal(t, jobID, j.JobID)
})
t.Run("get non-existent job returns error", func(t *testing.T) {
_, err := job.Get("non_existent_job_id")
assert.Error(t, err)
})
t.Run("get with empty job ID returns error", func(t *testing.T) {
_, err := job.Get("")
assert.Error(t, err)
assert.Contains(t, err.Error(), "job ID is empty")
})
}
// TestJobUpdate tests updating job status and phase
func TestJobUpdate(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("update job status and phase", func(t *testing.T) {
robot := createTestRobot("test_job_update_001")
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
exec := &types.Execution{
ID: execID,
JobID: jobID,
Status: types.ExecRunning,
Phase: types.PhaseGoals,
}
err = job.Update(ctx, exec)
require.NoError(t, err)
// Verify update
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Equal(t, "running", j.Status)
assert.Equal(t, string(types.PhaseGoals), j.Config["current_phase"])
assert.Equal(t, string(types.ExecRunning), j.Config["current_status"])
})
t.Run("update with nil execution returns error", func(t *testing.T) {
err := job.Update(ctx, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid execution")
})
t.Run("update with empty job ID returns error", func(t *testing.T) {
exec := &types.Execution{
ID: "some_id",
JobID: "",
Status: types.ExecRunning,
Phase: types.PhaseGoals,
}
err := job.Update(ctx, exec)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid execution")
})
}
// TestJobComplete tests completing a job
func TestJobComplete(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("complete job successfully", func(t *testing.T) {
robot := createTestRobot("test_job_complete_001")
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
exec := &types.Execution{
ID: execID,
JobID: jobID,
Delivery: &types.DeliveryResult{
Success: true,
},
}
err = job.Complete(ctx, exec)
require.NoError(t, err)
// Verify completion
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Equal(t, "completed", j.Status)
assert.Equal(t, string(types.PhaseLearning), j.Config["current_phase"])
assert.Equal(t, string(types.ExecCompleted), j.Config["current_status"])
assert.Equal(t, true, j.Config["delivery_success"])
})
t.Run("complete with nil execution returns error", func(t *testing.T) {
err := job.Complete(ctx, nil)
assert.Error(t, err)
})
}
// TestJobFail tests failing a job
func TestJobFail(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("fail job with error", func(t *testing.T) {
robot := createTestRobot("test_job_fail_001")
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
exec := &types.Execution{
ID: execID,
JobID: jobID,
Phase: types.PhaseRun,
}
testErr := assert.AnError
err = job.Fail(ctx, exec, testErr)
require.NoError(t, err)
// Verify failure
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Equal(t, "failed", j.Status)
assert.Equal(t, string(types.PhaseRun), j.Config["current_phase"])
assert.Equal(t, string(types.ExecFailed), j.Config["current_status"])
assert.NotEmpty(t, j.Config["error"])
})
t.Run("fail job without error message", func(t *testing.T) {
robot := createTestRobot("test_job_fail_002")
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
exec := &types.Execution{
ID: execID,
JobID: jobID,
Phase: types.PhaseDelivery,
}
err = job.Fail(ctx, exec, nil)
require.NoError(t, err)
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Equal(t, "failed", j.Status)
assert.Nil(t, j.Config["error"])
})
}
// TestJobCancel tests cancelling a job
func TestJobCancel(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("cancel job successfully", func(t *testing.T) {
robot := createTestRobot("test_job_cancel_001")
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
// First verify job was created
j, err := job.Get(jobID)
require.NoError(t, err)
t.Logf("Job ID: %d, Status before cancel: %s, Config: %v", j.ID, j.Status, j.Config)
exec := &types.Execution{
ID: execID,
JobID: jobID,
Phase: types.PhaseTasks,
}
err = job.Cancel(ctx, exec)
require.NoError(t, err)
// Verify cancellation - check config since status might not be returned correctly
j, err = job.Get(jobID)
require.NoError(t, err)
t.Logf("Job ID: %d, Status after cancel: %s, Config: %v", j.ID, j.Status, j.Config)
// Check config values which should be correctly updated
assert.Equal(t, string(types.PhaseTasks), j.Config["current_phase"])
assert.Equal(t, string(types.ExecCancelled), j.Config["current_status"])
})
}
// TestJobLocalization tests job name localization
func TestJobLocalization(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
t.Run("english locale", func(t *testing.T) {
ctx := &types.Context{
Context: context.Background(),
Locale: "en-US",
}
robot := createTestRobot("test_job_locale_en")
robot.DisplayName = "Sales Bot"
jobID, _, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Contains(t, j.Name, "Robot Execution")
assert.Contains(t, j.Name, "Clock")
assert.Contains(t, j.Name, "Sales Bot")
})
t.Run("chinese locale", func(t *testing.T) {
ctx := &types.Context{
Context: context.Background(),
Locale: "zh-CN",
}
robot := createTestRobot("test_job_locale_zh")
robot.DisplayName = "销售机器人"
jobID, _, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerHuman,
})
require.NoError(t, err)
j, err := job.Get(jobID)
require.NoError(t, err)
assert.Contains(t, j.Name, "机器人执行")
assert.Contains(t, j.Name, "人工触发")
assert.Contains(t, j.Name, "销售机器人")
})
}
// TestMapStatusToJobStatus tests status mapping via config
func TestMapStatusToJobStatus(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
testCases := []struct {
status types.ExecStatus
expectedStatus string // This is the raw ExecStatus string stored in config["current_status"]
}{
{types.ExecPending, "pending"},
{types.ExecRunning, "running"},
{types.ExecCompleted, "completed"},
{types.ExecFailed, "failed"},
{types.ExecCancelled, "cancelled"},
}
for _, tc := range testCases {
t.Run(string(tc.status), func(t *testing.T) {
robot := createTestRobot("test_status_map_" + string(tc.status))
jobID, execID, err := job.Create(ctx, &job.Options{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
exec := &types.Execution{
ID: execID,
JobID: jobID,
Status: tc.status,
Phase: types.PhaseInspiration,
}
err = job.Update(ctx, exec)
require.NoError(t, err)
j, err := job.Get(jobID)
require.NoError(t, err)
// Verify status is stored in config (since Job.Status field may not be reliably returned)
assert.Equal(t, tc.expectedStatus, j.Config["current_status"])
})
}
}
// createTestRobot creates a test robot for testing
func createTestRobot(memberID string) *types.Robot {
return &types.Robot{
MemberID: memberID,
TeamID: "test_team_001",
DisplayName: "Test Robot " + memberID,
SystemPrompt: "You are a test robot.",
Status: types.RobotIdle,
AutonomousMode: true,
Config: &types.Config{
Triggers: &types.Triggers{
Clock: &types.TriggerSwitch{Enabled: true},
Intervene: &types.TriggerSwitch{Enabled: true},
Event: &types.TriggerSwitch{Enabled: true},
},
Identity: &types.Identity{
Role: "Test Role",
},
Quota: &types.Quota{
Max: 2,
},
},
}
}
// cleanupTestJobs cleans up test jobs from database
func cleanupTestJobs(t *testing.T) {
// Jobs are auto-cleaned by yao/job package
// This is a placeholder for any additional cleanup
}

View file

@ -1,296 +0,0 @@
package job
import (
"encoding/json"
"fmt"
"time"
yaojob "github.com/yaoapp/yao/job"
"github.com/yaoapp/yao/agent/robot/types"
)
// Log writes a log entry for the execution
func Log(ctx *types.Context, exec *types.Execution, level string, message string, data map[string]interface{}) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
// Build context JSON with execution_id included
if data == nil {
data = make(map[string]interface{})
}
data["execution_id"] = exec.ID
var contextRaw *json.RawMessage
contextBytes, err := json.Marshal(data)
if err == nil {
raw := json.RawMessage(contextBytes)
contextRaw = &raw
}
// Extract step from data if available
var step *string
if s, ok := data["step"].(string); ok {
step = &s
}
logEntry := &yaojob.Log{
JobID: exec.JobID,
Level: level,
Message: message,
Context: contextRaw,
ExecutionID: &exec.ID,
Step: step,
Timestamp: time.Now(),
Sequence: 0,
}
return yaojob.SaveLog(logEntry)
}
// LogPhaseStart logs the start of a phase
func LogPhaseStart(ctx *types.Context, exec *types.Execution, phase types.Phase) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
locale := getLocale(ctx)
phaseName := getPhaseName(locale, phase)
var message string
if isChineseLocale(locale) {
message = fmt.Sprintf("阶段开始: %s", phaseName)
} else {
message = fmt.Sprintf("Phase started: %s", phaseName)
}
return Log(ctx, exec, "info", message, map[string]interface{}{
"phase": string(phase),
"phase_name": phaseName,
"step": fmt.Sprintf("phase_%s_start", phase),
"event": "phase_start",
})
}
// LogPhaseEnd logs the end of a phase
func LogPhaseEnd(ctx *types.Context, exec *types.Execution, phase types.Phase, durationMs int64) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
locale := getLocale(ctx)
phaseName := getPhaseName(locale, phase)
var message string
if isChineseLocale(locale) {
message = fmt.Sprintf("阶段完成: %s", phaseName)
} else {
message = fmt.Sprintf("Phase completed: %s", phaseName)
}
return Log(ctx, exec, "info", message, map[string]interface{}{
"phase": string(phase),
"phase_name": phaseName,
"step": fmt.Sprintf("phase_%s_end", phase),
"event": "phase_end",
"duration_ms": durationMs,
})
}
// LogPhaseError logs a phase error
func LogPhaseError(ctx *types.Context, exec *types.Execution, phase types.Phase, err error) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
locale := getLocale(ctx)
phaseName := getPhaseName(locale, phase)
errMsg := "unknown error"
if err != nil {
errMsg = err.Error()
}
var message string
if isChineseLocale(locale) {
message = fmt.Sprintf("阶段失败: %s - %s", phaseName, errMsg)
} else {
message = fmt.Sprintf("Phase failed: %s - %s", phaseName, errMsg)
}
return Log(ctx, exec, "error", message, map[string]interface{}{
"phase": string(phase),
"phase_name": phaseName,
"step": fmt.Sprintf("phase_%s_error", phase),
"event": "phase_error",
"error": errMsg,
})
}
// LogError logs an error
func LogError(ctx *types.Context, exec *types.Execution, err error) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
locale := getLocale(ctx)
errMsg := "unknown error"
if err != nil {
errMsg = err.Error()
}
var message string
if isChineseLocale(locale) {
message = fmt.Sprintf("错误: %s", errMsg)
} else {
message = errMsg
}
return Log(ctx, exec, "error", message, map[string]interface{}{
"event": "error",
"error": errMsg,
})
}
// LogInfo logs an info message
func LogInfo(ctx *types.Context, exec *types.Execution, message string) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
return Log(ctx, exec, "info", message, nil)
}
// LogDebug logs a debug message
func LogDebug(ctx *types.Context, exec *types.Execution, message string) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
return Log(ctx, exec, "debug", message, nil)
}
// LogWarn logs a warning message
func LogWarn(ctx *types.Context, exec *types.Execution, message string) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
return Log(ctx, exec, "warning", message, nil)
}
// LogTaskStart logs the start of a task
func LogTaskStart(ctx *types.Context, exec *types.Execution, taskID string, taskOrder int) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
locale := getLocale(ctx)
var message string
if isChineseLocale(locale) {
message = fmt.Sprintf("任务开始: %s", taskID)
} else {
message = fmt.Sprintf("Task started: %s", taskID)
}
return Log(ctx, exec, "info", message, map[string]interface{}{
"task_id": taskID,
"task_order": taskOrder,
"step": fmt.Sprintf("task_%d_start", taskOrder),
"event": "task_start",
})
}
// LogTaskEnd logs the end of a task
func LogTaskEnd(ctx *types.Context, exec *types.Execution, taskID string, taskOrder int, success bool, durationMs int64) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
locale := getLocale(ctx)
level := "info"
event := "task_success"
var msg string
if isChineseLocale(locale) {
if success {
msg = fmt.Sprintf("任务完成: %s", taskID)
} else {
level = "warning"
event = "task_failed"
msg = fmt.Sprintf("任务失败: %s", taskID)
}
} else {
if success {
msg = fmt.Sprintf("Task completed: %s", taskID)
} else {
level = "warning"
event = "task_failed"
msg = fmt.Sprintf("Task failed: %s", taskID)
}
}
return Log(ctx, exec, level, msg, map[string]interface{}{
"task_id": taskID,
"task_order": taskOrder,
"step": fmt.Sprintf("task_%d_end", taskOrder),
"event": event,
"success": success,
"duration_ms": durationMs,
})
}
// LogDelivery logs delivery result
func LogDelivery(ctx *types.Context, exec *types.Execution, deliveryType string, success bool) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
locale := getLocale(ctx)
level := "info"
var msg string
if isChineseLocale(locale) {
if success {
msg = fmt.Sprintf("交付完成: %s", deliveryType)
} else {
level = "warning"
msg = fmt.Sprintf("交付失败: %s", deliveryType)
}
} else {
if success {
msg = fmt.Sprintf("Delivery completed: %s", deliveryType)
} else {
level = "warning"
msg = fmt.Sprintf("Delivery failed: %s", deliveryType)
}
}
return Log(ctx, exec, level, msg, map[string]interface{}{
"delivery_type": deliveryType,
"step": "delivery",
"event": "delivery",
"success": success,
})
}
// LogLearning logs learning result
func LogLearning(ctx *types.Context, exec *types.Execution, entriesCount int) error {
if exec == nil || exec.ID == "" || exec.JobID == "" {
return fmt.Errorf("invalid execution or missing execution/job ID")
}
locale := getLocale(ctx)
var msg string
if isChineseLocale(locale) {
msg = fmt.Sprintf("学习保存: %d 条记录", entriesCount)
} else {
msg = fmt.Sprintf("Learning saved: %d entries", entriesCount)
}
return Log(ctx, exec, "info", msg, map[string]interface{}{
"entries_count": entriesCount,
"step": "learning",
"event": "learning",
})
}

View file

@ -1,771 +0,0 @@
package job_test
import (
"context"
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/gou/model"
"github.com/yaoapp/kun/maps"
yaojob "github.com/yaoapp/yao/job"
"github.com/yaoapp/yao/agent/robot/job"
"github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
)
// TestLog tests writing log entries
func TestLog(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("write info log", func(t *testing.T) {
robot := createTestRobot("test_log_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.Log(ctx, exec, "info", "Test message", map[string]interface{}{
"key": "value",
})
require.NoError(t, err)
// Verify log was written
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
assert.NotEmpty(t, logs)
found := false
for _, log := range logs {
if log.Message == "Test message" && log.Level == "info" {
found = true
break
}
}
assert.True(t, found, "Log entry should be found")
})
t.Run("write error log", func(t *testing.T) {
robot := createTestRobot("test_log_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.Log(ctx, exec, "error", "Error occurred", nil)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Message == "Error occurred" && log.Level == "error" {
found = true
break
}
}
assert.True(t, found, "Error log entry should be found")
})
t.Run("log with nil execution returns error", func(t *testing.T) {
err := job.Log(ctx, nil, "info", "Test", nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid execution")
})
t.Run("log with empty job ID returns error", func(t *testing.T) {
exec := &types.Execution{
ID: "some_id",
JobID: "",
}
err := job.Log(ctx, exec, "info", "Test", nil)
assert.Error(t, err)
})
}
// TestLogPhaseStart tests logging phase start
func TestLogPhaseStart(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log phase start in english", func(t *testing.T) {
ctx := &types.Context{
Context: context.Background(),
Locale: "en-US",
}
robot := createTestRobot("test_phase_log_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogPhaseStart(ctx, exec, types.PhaseGoals)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "info" && containsString(log.Message, "Phase started") && containsString(log.Message, "Goals") {
found = true
break
}
}
assert.True(t, found, "Phase start log should be found")
})
t.Run("log phase start in chinese", func(t *testing.T) {
ctx := &types.Context{
Context: context.Background(),
Locale: "zh-CN",
}
robot := createTestRobot("test_phase_log_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogPhaseStart(ctx, exec, types.PhaseGoals)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "info" && containsString(log.Message, "阶段开始") {
found = true
break
}
}
assert.True(t, found, "Chinese phase start log should be found")
})
t.Run("log phase start with nil execution returns error", func(t *testing.T) {
err := job.LogPhaseStart(ctx, nil, types.PhaseGoals)
assert.Error(t, err)
})
}
// TestLogPhaseEnd tests logging phase end
func TestLogPhaseEnd(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log phase end with duration", func(t *testing.T) {
robot := createTestRobot("test_phase_end_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogPhaseEnd(ctx, exec, types.PhaseInspiration, 1500)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "info" && (containsString(log.Message, "Phase completed") || containsString(log.Message, "阶段完成")) {
found = true
break
}
}
assert.True(t, found, "Phase end log should be found")
})
}
// TestLogPhaseError tests logging phase error
func TestLogPhaseError(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log phase error", func(t *testing.T) {
robot := createTestRobot("test_phase_err_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
testErr := errors.New("goal generation failed")
err = job.LogPhaseError(ctx, exec, types.PhaseGoals, testErr)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "error" && containsString(log.Message, "goal generation failed") {
found = true
break
}
}
assert.True(t, found, "Phase error log should be found")
})
t.Run("log phase error with nil error", func(t *testing.T) {
robot := createTestRobot("test_phase_err_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogPhaseError(ctx, exec, types.PhaseGoals, nil)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "error" && containsString(log.Message, "unknown error") {
found = true
break
}
}
assert.True(t, found, "Phase error log with unknown error should be found")
})
}
// TestLogError tests logging errors
func TestLogError(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log error", func(t *testing.T) {
robot := createTestRobot("test_error_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
testErr := errors.New("connection timeout")
err = job.LogError(ctx, exec, testErr)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "error" && containsString(log.Message, "connection timeout") {
found = true
break
}
}
assert.True(t, found, "Error log should be found")
})
}
// TestLogInfo tests logging info messages
func TestLogInfo(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log info message", func(t *testing.T) {
robot := createTestRobot("test_info_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogInfo(ctx, exec, "Processing started")
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "info" && log.Message == "Processing started" {
found = true
break
}
}
assert.True(t, found, "Info log should be found")
})
}
// TestLogDebug tests logging debug messages
func TestLogDebug(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log debug message", func(t *testing.T) {
robot := createTestRobot("test_debug_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogDebug(ctx, exec, "Debug info")
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "debug" && log.Message == "Debug info" {
found = true
break
}
}
assert.True(t, found, "Debug log should be found")
})
}
// TestLogWarn tests logging warning messages
func TestLogWarn(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log warning message", func(t *testing.T) {
robot := createTestRobot("test_warn_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogWarn(ctx, exec, "Resource running low")
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "warning" && log.Message == "Resource running low" {
found = true
break
}
}
assert.True(t, found, "Warning log should be found")
})
}
// TestLogTaskStart tests logging task start
func TestLogTaskStart(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log task start", func(t *testing.T) {
robot := createTestRobot("test_task_start_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogTaskStart(ctx, exec, "task_001", 1)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "info" && containsString(log.Message, "task_001") {
found = true
break
}
}
assert.True(t, found, "Task start log should be found")
})
}
// TestLogTaskEnd tests logging task end
func TestLogTaskEnd(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log task end success", func(t *testing.T) {
robot := createTestRobot("test_task_end_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogTaskEnd(ctx, exec, "task_001", 1, true, 500)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "info" && containsString(log.Message, "task_001") {
found = true
break
}
}
assert.True(t, found, "Task end success log should be found")
})
t.Run("log task end failure", func(t *testing.T) {
robot := createTestRobot("test_task_end_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogTaskEnd(ctx, exec, "task_002", 2, false, 300)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "warning" && containsString(log.Message, "task_002") {
found = true
break
}
}
assert.True(t, found, "Task end failure log should be found")
})
}
// TestLogDelivery tests logging delivery
func TestLogDelivery(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log delivery success", func(t *testing.T) {
robot := createTestRobot("test_delivery_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogDelivery(ctx, exec, "email", true)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "info" && containsString(log.Message, "email") {
found = true
break
}
}
assert.True(t, found, "Delivery success log should be found")
})
t.Run("log delivery failure", func(t *testing.T) {
robot := createTestRobot("test_delivery_002")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogDelivery(ctx, exec, "webhook", false)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "warning" && containsString(log.Message, "webhook") {
found = true
break
}
}
assert.True(t, found, "Delivery failure log should be found")
})
}
// TestLogLearning tests logging learning
func TestLogLearning(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
ctx := types.NewContext(context.Background(), nil)
t.Run("log learning entries", func(t *testing.T) {
robot := createTestRobot("test_learning_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogLearning(ctx, exec, 5)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if log.Level == "info" && (containsString(log.Message, "5") || containsString(log.Message, "Learning")) {
found = true
break
}
}
assert.True(t, found, "Learning log should be found")
})
}
// TestLogLocalization tests log message localization
func TestLogLocalization(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
t.Run("english locale messages", func(t *testing.T) {
ctx := &types.Context{
Context: context.Background(),
Locale: "en-US",
}
robot := createTestRobot("test_locale_en_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogPhaseStart(ctx, exec, types.PhaseRun)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if containsString(log.Message, "Phase started") && containsString(log.Message, "Run") {
found = true
break
}
}
assert.True(t, found, "English phase start message should be found")
})
t.Run("chinese locale messages", func(t *testing.T) {
ctx := &types.Context{
Context: context.Background(),
Locale: "zh-CN",
}
robot := createTestRobot("test_locale_zh_001")
exec, err := job.CreateExecution(ctx, &job.CreateOptions{
Robot: robot,
TriggerType: types.TriggerClock,
})
require.NoError(t, err)
err = job.LogPhaseStart(ctx, exec, types.PhaseRun)
require.NoError(t, err)
logs, err := getJobLogs(exec.JobID)
require.NoError(t, err)
found := false
for _, log := range logs {
if containsString(log.Message, "阶段开始") && containsString(log.Message, "任务执行") {
found = true
break
}
}
assert.True(t, found, "Chinese phase start message should be found")
})
}
// getJobLogs retrieves logs for a job
func getJobLogs(jobID string) ([]*yaojob.Log, error) {
result, err := yaojob.ListLogs(jobID, model.QueryParam{}, 1, 100)
if err != nil {
return nil, err
}
data, exists := result["data"]
if !exists {
return nil, fmt.Errorf("ListLogs result missing 'data' field")
}
// Handle nil data
if data == nil {
return []*yaojob.Log{}, nil
}
// Handle different data types from ListLogs
var logs []*yaojob.Log
switch typedData := data.(type) {
case []maps.MapStrAny:
for _, item := range typedData {
log := &yaojob.Log{}
if msg, ok := item["message"].(string); ok {
log.Message = msg
}
if level, ok := item["level"].(string); ok {
log.Level = level
}
if jid, ok := item["job_id"].(string); ok {
log.JobID = jid
}
logs = append(logs, log)
}
case []map[string]interface{}:
for _, item := range typedData {
log := &yaojob.Log{}
if msg, ok := item["message"].(string); ok {
log.Message = msg
}
if level, ok := item["level"].(string); ok {
log.Level = level
}
if jid, ok := item["job_id"].(string); ok {
log.JobID = jid
}
logs = append(logs, log)
}
case []interface{}:
// Handle generic []interface{} which may contain map types
for _, rawItem := range typedData {
log := &yaojob.Log{}
switch item := rawItem.(type) {
case maps.MapStrAny:
if msg, ok := item["message"].(string); ok {
log.Message = msg
}
if level, ok := item["level"].(string); ok {
log.Level = level
}
if jid, ok := item["job_id"].(string); ok {
log.JobID = jid
}
case map[string]interface{}:
if msg, ok := item["message"].(string); ok {
log.Message = msg
}
if level, ok := item["level"].(string); ok {
log.Level = level
}
if jid, ok := item["job_id"].(string); ok {
log.JobID = jid
}
default:
return nil, fmt.Errorf("unexpected item type in data array: %T", rawItem)
}
logs = append(logs, log)
}
default:
return nil, fmt.Errorf("unexpected data type from ListLogs: %T (value: %v)", data, data)
}
return logs, nil
}
// containsString checks if a string contains a substring
func containsString(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View file

@ -13,12 +13,11 @@ import (
// ExecutionRecord - persistent storage for robot execution history // ExecutionRecord - persistent storage for robot execution history
// Maps to __yao.agent_execution model // Maps to __yao.agent_execution model
type ExecutionRecord struct { type ExecutionRecord struct {
ID int64 `json:"id,omitempty"` // Auto-increment primary key ID int64 `json:"id,omitempty"` // Auto-increment primary key
ExecutionID string `json:"execution_id"` // Unique execution identifier ExecutionID string `json:"execution_id"` // Unique execution identifier
MemberID string `json:"member_id"` // Robot member ID (globally unique) MemberID string `json:"member_id"` // Robot member ID (globally unique)
TeamID string `json:"team_id"` // Team ID TeamID string `json:"team_id"` // Team ID
JobID string `json:"job_id,omitempty"` // Linked job.Job ID TriggerType types.TriggerType `json:"trigger_type"` // clock | human | event
TriggerType types.TriggerType `json:"trigger_type"` // clock | human | event
// Status tracking (synced with runtime Execution) // Status tracking (synced with runtime Execution)
Status types.ExecStatus `json:"status"` // pending | running | completed | failed | cancelled Status types.ExecStatus `json:"status"` // pending | running | completed | failed | cancelled
@ -344,9 +343,6 @@ func (s *ExecutionStore) recordToMap(record *ExecutionRecord) map[string]interfa
"phase": string(record.Phase), "phase": string(record.Phase),
} }
if record.JobID != "" {
data["job_id"] = record.JobID
}
if record.Error != "" { if record.Error != "" {
data["error"] = record.Error data["error"] = record.Error
} }
@ -408,9 +404,6 @@ func (s *ExecutionStore) mapToRecord(row map[string]interface{}) (*ExecutionReco
if v, ok := row["team_id"].(string); ok { if v, ok := row["team_id"].(string); ok {
record.TeamID = v record.TeamID = v
} }
if v, ok := row["job_id"].(string); ok {
record.JobID = v
}
if v, ok := row["trigger_type"].(string); ok { if v, ok := row["trigger_type"].(string); ok {
record.TriggerType = types.TriggerType(v) record.TriggerType = types.TriggerType(v)
} }
@ -634,7 +627,6 @@ func FromExecution(exec *types.Execution) *ExecutionRecord {
ExecutionID: exec.ID, ExecutionID: exec.ID,
MemberID: exec.MemberID, MemberID: exec.MemberID,
TeamID: exec.TeamID, TeamID: exec.TeamID,
JobID: exec.JobID,
TriggerType: exec.TriggerType, TriggerType: exec.TriggerType,
Status: exec.Status, Status: exec.Status,
Phase: exec.Phase, Phase: exec.Phase,
@ -673,7 +665,6 @@ func (r *ExecutionRecord) ToExecution() *types.Execution {
ID: r.ExecutionID, ID: r.ExecutionID,
MemberID: r.MemberID, MemberID: r.MemberID,
TeamID: r.TeamID, TeamID: r.TeamID,
JobID: r.JobID,
TriggerType: r.TriggerType, TriggerType: r.TriggerType,
Status: r.Status, Status: r.Status,
Phase: r.Phase, Phase: r.Phase,

View file

@ -35,7 +35,6 @@ func TestExecutionStoreSave(t *testing.T) {
ExecutionID: "exec_test_save_001", ExecutionID: "exec_test_save_001",
MemberID: "member_test_001", MemberID: "member_test_001",
TeamID: "team_test_001", TeamID: "team_test_001",
JobID: "job_test_001",
TriggerType: types.TriggerClock, TriggerType: types.TriggerClock,
Status: types.ExecPending, Status: types.ExecPending,
Phase: types.PhaseInspiration, Phase: types.PhaseInspiration,
@ -53,7 +52,6 @@ func TestExecutionStoreSave(t *testing.T) {
assert.Equal(t, "exec_test_save_001", saved.ExecutionID) assert.Equal(t, "exec_test_save_001", saved.ExecutionID)
assert.Equal(t, "member_test_001", saved.MemberID) assert.Equal(t, "member_test_001", saved.MemberID)
assert.Equal(t, "team_test_001", saved.TeamID) assert.Equal(t, "team_test_001", saved.TeamID)
assert.Equal(t, "job_test_001", saved.JobID)
assert.Equal(t, types.TriggerClock, saved.TriggerType) assert.Equal(t, types.TriggerClock, saved.TriggerType)
assert.Equal(t, types.ExecPending, saved.Status) assert.Equal(t, types.ExecPending, saved.Status)
assert.Equal(t, types.PhaseInspiration, saved.Phase) assert.Equal(t, types.PhaseInspiration, saved.Phase)
@ -574,7 +572,6 @@ func TestExecutionRecordConversion(t *testing.T) {
ID: "exec_convert_001", ID: "exec_convert_001",
MemberID: "member_convert_001", MemberID: "member_convert_001",
TeamID: "team_convert_001", TeamID: "team_convert_001",
JobID: "job_convert_001",
TriggerType: types.TriggerHuman, TriggerType: types.TriggerHuman,
Status: types.ExecCompleted, Status: types.ExecCompleted,
Phase: types.PhaseDelivery, Phase: types.PhaseDelivery,
@ -600,7 +597,6 @@ func TestExecutionRecordConversion(t *testing.T) {
assert.Equal(t, "exec_convert_001", record.ExecutionID) assert.Equal(t, "exec_convert_001", record.ExecutionID)
assert.Equal(t, "member_convert_001", record.MemberID) assert.Equal(t, "member_convert_001", record.MemberID)
assert.Equal(t, "team_convert_001", record.TeamID) assert.Equal(t, "team_convert_001", record.TeamID)
assert.Equal(t, "job_convert_001", record.JobID)
assert.Equal(t, types.TriggerHuman, record.TriggerType) assert.Equal(t, types.TriggerHuman, record.TriggerType)
assert.Equal(t, types.ExecCompleted, record.Status) assert.Equal(t, types.ExecCompleted, record.Status)
assert.Equal(t, types.PhaseDelivery, record.Phase) assert.Equal(t, types.PhaseDelivery, record.Phase)
@ -621,7 +617,6 @@ func TestExecutionRecordConversion(t *testing.T) {
ExecutionID: "exec_convert_002", ExecutionID: "exec_convert_002",
MemberID: "member_convert_002", MemberID: "member_convert_002",
TeamID: "team_convert_002", TeamID: "team_convert_002",
JobID: "job_convert_002",
TriggerType: types.TriggerClock, TriggerType: types.TriggerClock,
Status: types.ExecRunning, Status: types.ExecRunning,
Phase: types.PhaseRun, Phase: types.PhaseRun,
@ -646,7 +641,6 @@ func TestExecutionRecordConversion(t *testing.T) {
assert.Equal(t, "exec_convert_002", exec.ID) assert.Equal(t, "exec_convert_002", exec.ID)
assert.Equal(t, "member_convert_002", exec.MemberID) assert.Equal(t, "member_convert_002", exec.MemberID)
assert.Equal(t, "team_convert_002", exec.TeamID) assert.Equal(t, "team_convert_002", exec.TeamID)
assert.Equal(t, "job_convert_002", exec.JobID)
assert.Equal(t, types.TriggerClock, exec.TriggerType) assert.Equal(t, types.TriggerClock, exec.TriggerType)
assert.Equal(t, types.ExecRunning, exec.Status) assert.Equal(t, types.ExecRunning, exec.Status)
assert.Equal(t, types.PhaseRun, exec.Phase) assert.Equal(t, types.PhaseRun, exec.Phase)
@ -686,7 +680,6 @@ func setupTestExecution(t *testing.T, s *store.ExecutionStore, ctx context.Conte
ExecutionID: "exec_test_get_001", ExecutionID: "exec_test_get_001",
MemberID: "member_test_get", MemberID: "member_test_get",
TeamID: "team_test_get", TeamID: "team_test_get",
JobID: "job_test_get",
TriggerType: types.TriggerClock, TriggerType: types.TriggerClock,
Status: types.ExecCompleted, Status: types.ExecCompleted,
Phase: types.PhaseDelivery, Phase: types.PhaseDelivery,

View file

@ -11,7 +11,7 @@ import (
// Robot - runtime representation of an autonomous robot (from __yao.member) // Robot - runtime representation of an autonomous robot (from __yao.member)
// Relationship: 1 Robot : N Executions (concurrent) // Relationship: 1 Robot : N Executions (concurrent)
// Each trigger creates a new Execution (mapped to job.Job) // Each trigger creates a new Execution (stored in __yao.agent_execution)
type Robot struct { type Robot struct {
// From __yao.member // From __yao.member
MemberID string `json:"member_id"` MemberID string `json:"member_id"`
@ -117,8 +117,7 @@ func (r *Robot) GetExecutions() []*Execution {
} }
// Execution - single execution instance // Execution - single execution instance
// Each trigger creates a new Execution, mapped to a job.Job for monitoring // Each trigger creates a new Execution, stored in ExecutionStore
// Relationship: 1 Execution = 1 job.Job
type Execution struct { type Execution struct {
ID string `json:"id"` // unique execution ID ID string `json:"id"` // unique execution ID
MemberID string `json:"member_id"` // robot member ID MemberID string `json:"member_id"` // robot member ID
@ -130,9 +129,6 @@ type Execution struct {
Phase Phase `json:"phase"` Phase Phase `json:"phase"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
// Job integration (each Execution = 1 job.Job)
JobID string `json:"job_id"` // corresponding job.Job ID
// Trigger input (stored for traceability) // Trigger input (stored for traceability)
Input *TriggerInput `json:"input,omitempty"` // original trigger input Input *TriggerInput `json:"input,omitempty"` // original trigger input

View file

@ -362,7 +362,6 @@ func TestExecutionStructure(t *testing.T) {
TriggerType: types.TriggerClock, TriggerType: types.TriggerClock,
Status: types.ExecRunning, Status: types.ExecRunning,
Phase: types.PhaseGoals, Phase: types.PhaseGoals,
JobID: "job1",
} }
assert.Equal(t, "exec1", exec.ID) assert.Equal(t, "exec1", exec.ID)
@ -371,7 +370,6 @@ func TestExecutionStructure(t *testing.T) {
assert.Equal(t, types.TriggerClock, exec.TriggerType) assert.Equal(t, types.TriggerClock, exec.TriggerType)
assert.Equal(t, types.ExecRunning, exec.Status) assert.Equal(t, types.ExecRunning, exec.Status)
assert.Equal(t, types.PhaseGoals, exec.Phase) assert.Equal(t, types.PhaseGoals, exec.Phase)
assert.Equal(t, "job1", exec.JobID)
}) })
t.Run("execution with trigger input", func(t *testing.T) { t.Run("execution with trigger input", func(t *testing.T) {