- Added support for reading files from workspace URIs in the delivery process, allowing for more flexible attachment management. - Introduced a new `convertWorkspaceAttachment` function to handle workspace-based file retrieval and integration into messenger attachments. - Updated the `AgentCaller` to include execution mode in the context, improving task execution tracking. - Enhanced the `RunDelivery` method to utilize workspace manifests for delivery input, reducing token usage and improving efficiency. - Implemented locale handling in various request structures to support multi-language capabilities in user interfaces.
851 lines
23 KiB
Go
851 lines
23 KiB
Go
package manager
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/yaoapp/yao/agent/robot/cache"
|
|
"github.com/yaoapp/yao/agent/robot/events"
|
|
"github.com/yaoapp/yao/agent/robot/executor"
|
|
"github.com/yaoapp/yao/agent/robot/pool"
|
|
"github.com/yaoapp/yao/agent/robot/trigger"
|
|
"github.com/yaoapp/yao/agent/robot/types"
|
|
"github.com/yaoapp/yao/event"
|
|
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
|
)
|
|
|
|
// Default configuration values
|
|
const (
|
|
DefaultTickInterval = time.Minute // default tick interval for clock checking
|
|
)
|
|
|
|
// Config holds manager configuration
|
|
type Config struct {
|
|
TickInterval time.Duration // how often to check clock triggers (default: 1 minute)
|
|
PoolConfig *pool.Config // worker pool configuration
|
|
Executor types.Executor // optional: custom executor (default: real executor)
|
|
}
|
|
|
|
// DefaultConfig returns default manager configuration
|
|
func DefaultConfig() *Config {
|
|
return &Config{
|
|
TickInterval: DefaultTickInterval,
|
|
PoolConfig: pool.DefaultConfig(),
|
|
}
|
|
}
|
|
|
|
// Manager implements types.Manager interface
|
|
// Orchestrates the robot scheduling system: Cache -> Dedup -> Pool -> Executor
|
|
type Manager struct {
|
|
config *Config
|
|
cache *cache.Cache
|
|
pool *pool.Pool
|
|
executor types.Executor
|
|
|
|
// Execution control for pause/resume/stop
|
|
execController *trigger.ExecutionController
|
|
|
|
// Ticker for clock trigger checking
|
|
ticker *time.Ticker
|
|
tickerDone chan struct{}
|
|
|
|
// State
|
|
started bool
|
|
mu sync.RWMutex
|
|
|
|
// Context for background operations
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
// New creates a new manager instance with default configuration
|
|
func New() *Manager {
|
|
return NewWithConfig(nil)
|
|
}
|
|
|
|
// NewWithConfig creates a new manager instance with custom configuration
|
|
func NewWithConfig(config *Config) *Manager {
|
|
if config == nil {
|
|
config = DefaultConfig()
|
|
}
|
|
|
|
// Apply defaults for zero values
|
|
if config.TickInterval <= 0 {
|
|
config.TickInterval = DefaultTickInterval
|
|
}
|
|
|
|
// Create components
|
|
c := cache.New()
|
|
p := pool.NewWithConfig(config.PoolConfig)
|
|
ec := trigger.NewExecutionController()
|
|
|
|
// Use custom executor if provided, otherwise create default
|
|
var e types.Executor
|
|
if config.Executor != nil {
|
|
e = config.Executor
|
|
} else {
|
|
e = executor.New()
|
|
}
|
|
|
|
// Wire up pool with executor
|
|
p.SetExecutor(e)
|
|
|
|
// Create shared executor instances for each mode
|
|
// These are reused across all executions to maintain accurate counters
|
|
dryRunExecutor := executor.NewDryRun()
|
|
|
|
// Set executor factory for mode-specific executors
|
|
p.SetExecutorFactory(func(mode types.ExecutorMode) types.Executor {
|
|
switch mode {
|
|
case types.ExecutorDryRun:
|
|
return dryRunExecutor
|
|
case types.ExecutorSandbox:
|
|
// Sandbox not implemented, fall back to DryRun
|
|
return dryRunExecutor
|
|
default:
|
|
// Standard mode or empty - use the configured executor
|
|
return e
|
|
}
|
|
})
|
|
|
|
return &Manager{
|
|
config: config,
|
|
cache: c,
|
|
pool: p,
|
|
executor: e,
|
|
execController: ec,
|
|
}
|
|
}
|
|
|
|
// Start starts the manager
|
|
// 1. Load robots into cache
|
|
// 2. Start worker pool
|
|
// 3. Start clock ticker goroutine
|
|
func (m *Manager) Start() error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
if m.started {
|
|
return fmt.Errorf("manager already started")
|
|
}
|
|
|
|
// Create background context
|
|
m.ctx, m.cancel = context.WithCancel(context.Background())
|
|
|
|
// Load robots into cache
|
|
ctx := types.NewContext(m.ctx, nil)
|
|
if err := m.cache.Load(ctx); err != nil {
|
|
return fmt.Errorf("failed to load robots: %w", err)
|
|
}
|
|
|
|
// Recover non-terminal executions from previous server lifecycle
|
|
pendingNotifications := m.recoverExecutions(m.ctx)
|
|
|
|
// Set completion callback to clean up ExecutionController when execution finishes
|
|
m.pool.SetOnComplete(func(execID, memberID string, status types.ExecStatus) {
|
|
// Remove from ExecutionController (cleans up in-memory tracking)
|
|
m.execController.Untrack(execID)
|
|
// Remove from robot's in-memory execution list
|
|
if robot := m.cache.Get(memberID); robot != nil {
|
|
robot.RemoveExecution(execID)
|
|
}
|
|
})
|
|
|
|
// Start worker pool
|
|
if err := m.pool.Start(); err != nil {
|
|
return fmt.Errorf("failed to start pool: %w", err)
|
|
}
|
|
|
|
// Start clock ticker
|
|
m.ticker = time.NewTicker(m.config.TickInterval)
|
|
m.tickerDone = make(chan struct{})
|
|
|
|
go m.tickerLoop()
|
|
|
|
// Start cache auto-refresh (every hour)
|
|
m.cache.StartAutoRefresh(ctx, nil)
|
|
|
|
m.started = true
|
|
|
|
if len(pendingNotifications) > 0 {
|
|
go func() {
|
|
for _, n := range pendingNotifications {
|
|
_, _ = event.Push(context.Background(), events.ExecRecovered, n)
|
|
}
|
|
}()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop stops the manager gracefully
|
|
// 1. Stop clock ticker
|
|
// 2. Stop cache auto-refresh
|
|
// 3. Stop worker pool (waits for running jobs)
|
|
func (m *Manager) Stop() error {
|
|
m.mu.Lock()
|
|
if !m.started {
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
m.started = false
|
|
m.mu.Unlock()
|
|
|
|
// Stop ticker
|
|
if m.tickerDone != nil {
|
|
close(m.tickerDone)
|
|
}
|
|
|
|
// Stop cache auto-refresh
|
|
m.cache.StopAutoRefresh()
|
|
|
|
// Stop pool (waits for running jobs)
|
|
if err := m.pool.Stop(); err != nil {
|
|
return fmt.Errorf("failed to stop pool: %w", err)
|
|
}
|
|
|
|
// Cancel background context
|
|
if m.cancel != nil {
|
|
m.cancel()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// tickerLoop is the main ticker goroutine
|
|
func (m *Manager) tickerLoop() {
|
|
for {
|
|
select {
|
|
case <-m.tickerDone:
|
|
m.ticker.Stop()
|
|
return
|
|
case now := <-m.ticker.C:
|
|
// Perform tick - context is created per-robot in Tick()
|
|
_ = m.Tick(m.ctx, now)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tick processes a clock tick
|
|
// 1. Get all cached robots
|
|
// 2. For each robot with clock trigger enabled
|
|
// 3. Check if should execute based on clock config
|
|
// 4. Submit to pool with robot's own identity
|
|
func (m *Manager) Tick(parentCtx context.Context, now time.Time) error {
|
|
m.mu.RLock()
|
|
if !m.started {
|
|
m.mu.RUnlock()
|
|
return nil
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
// Get autonomous robots for clock trigger check
|
|
robots := m.cache.ListAutonomous()
|
|
|
|
for _, robot := range robots {
|
|
// Skip if robot is not active
|
|
if robot.Status == types.RobotPaused || robot.Status == types.RobotError || robot.Status == types.RobotMaintenance {
|
|
continue
|
|
}
|
|
|
|
// Skip if clock trigger is disabled
|
|
if robot.Config == nil || robot.Config.Triggers == nil {
|
|
continue
|
|
}
|
|
if !robot.Config.Triggers.IsEnabled(types.TriggerClock) {
|
|
continue
|
|
}
|
|
|
|
// Skip if no clock config
|
|
if robot.Config.Clock == nil {
|
|
continue
|
|
}
|
|
|
|
// Check if should trigger based on clock config
|
|
if !m.shouldTrigger(robot, now) {
|
|
continue
|
|
}
|
|
|
|
// TODO: dedup check (Phase 11.1)
|
|
// result, err := m.dedup.Check(ctx, robot.MemberID, types.TriggerClock)
|
|
// if err != nil || result == types.DedupSkip {
|
|
// continue
|
|
// }
|
|
|
|
// Pre-generate execution ID
|
|
execID := pool.GenerateExecID()
|
|
|
|
// Pre-acquire execution slot to prevent daemon-mode race condition:
|
|
// Without this, CanRun() stays true between Tick and worker dequeue,
|
|
// causing duplicate submissions on every tick interval.
|
|
preExec := &types.Execution{
|
|
ID: execID,
|
|
MemberID: robot.MemberID,
|
|
TeamID: robot.TeamID,
|
|
TriggerType: types.TriggerClock,
|
|
Status: types.ExecPending,
|
|
StartTime: now,
|
|
}
|
|
if !robot.TryAcquireSlot(preExec) {
|
|
continue
|
|
}
|
|
|
|
// Track for pause/resume/stop — after slot is acquired
|
|
ctrlExec := m.execController.Track(execID, robot.MemberID, robot.TeamID)
|
|
|
|
// Create context with robot's own identity and cancellable context
|
|
robotAuth := m.buildRobotAuth(robot)
|
|
execCtx := types.NewContext(ctrlExec.Context(), robotAuth)
|
|
|
|
// Create clock context for P0 inspiration
|
|
clockCtx := types.NewClockContext(now, robot.Config.Clock.TZ)
|
|
|
|
// Submit to pool with the cancellable context and execution control
|
|
_, err := m.pool.SubmitWithID(execCtx, robot, types.TriggerClock, clockCtx, execID, ctrlExec)
|
|
if err != nil {
|
|
robot.RemoveExecution(execID)
|
|
m.execController.Untrack(execID)
|
|
continue
|
|
}
|
|
|
|
// Update robot's last run time
|
|
robot.LastRun = now
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// buildRobotAuth creates AuthorizedInfo for a robot's own identity
|
|
// Used when robot executes autonomously (clock trigger)
|
|
func (m *Manager) buildRobotAuth(robot *types.Robot) *oauthtypes.AuthorizedInfo {
|
|
return &oauthtypes.AuthorizedInfo{
|
|
UserID: robot.MemberID,
|
|
TeamID: robot.TeamID,
|
|
// ClientID could be set to a special "robot-agent" identifier if needed
|
|
ClientID: "robot-agent",
|
|
}
|
|
}
|
|
|
|
// shouldTrigger checks if a robot should be triggered based on its clock config
|
|
func (m *Manager) shouldTrigger(robot *types.Robot, now time.Time) bool {
|
|
clock := robot.Config.Clock
|
|
if clock == nil {
|
|
return false
|
|
}
|
|
|
|
// Get time in robot's timezone
|
|
loc := clock.GetLocation()
|
|
localNow := now.In(loc)
|
|
|
|
switch clock.Mode {
|
|
case types.ClockTimes:
|
|
return m.shouldTriggerTimes(robot, clock, localNow)
|
|
case types.ClockInterval:
|
|
return m.shouldTriggerInterval(robot, clock, localNow)
|
|
case types.ClockDaemon:
|
|
return m.shouldTriggerDaemon(robot, clock, localNow)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// shouldTriggerTimes checks if current time matches any configured times
|
|
// times mode: run at specific times (e.g., ["09:00", "14:00", "17:00"])
|
|
func (m *Manager) shouldTriggerTimes(robot *types.Robot, clock *types.Clock, now time.Time) bool {
|
|
// Check day of week first
|
|
if !m.matchesDay(clock, now) {
|
|
return false
|
|
}
|
|
|
|
// Check if current time matches any configured time
|
|
currentTime := now.Format("15:04")
|
|
for _, t := range clock.Times {
|
|
if t == currentTime {
|
|
// Check if already triggered in this minute
|
|
if !robot.LastRun.IsZero() {
|
|
lastRunInLoc := robot.LastRun.In(now.Location())
|
|
if lastRunInLoc.Format("15:04") == currentTime && lastRunInLoc.Day() == now.Day() {
|
|
return false // Already triggered this minute today
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// shouldTriggerInterval checks if enough time has passed since last run
|
|
// interval mode: run every X duration (e.g., "30m", "2h")
|
|
func (m *Manager) shouldTriggerInterval(robot *types.Robot, clock *types.Clock, now time.Time) bool {
|
|
interval, err := time.ParseDuration(clock.Every)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// First run if never executed
|
|
if robot.LastRun.IsZero() {
|
|
return true
|
|
}
|
|
|
|
// Check if interval has passed
|
|
return now.Sub(robot.LastRun) >= interval
|
|
}
|
|
|
|
// shouldTriggerDaemon checks if robot can restart immediately after last run
|
|
// daemon mode: restart immediately after each run completes
|
|
func (m *Manager) shouldTriggerDaemon(robot *types.Robot, clock *types.Clock, now time.Time) bool {
|
|
// Daemon mode: trigger if not currently running
|
|
// CanRun() checks if robot has available execution slots
|
|
return robot.CanRun()
|
|
}
|
|
|
|
// matchesDay checks if current day matches the configured days
|
|
func (m *Manager) matchesDay(clock *types.Clock, now time.Time) bool {
|
|
// Empty days or ["*"] means all days
|
|
if len(clock.Days) == 0 {
|
|
return true
|
|
}
|
|
|
|
for _, day := range clock.Days {
|
|
if day == "*" {
|
|
return true
|
|
}
|
|
// Match day name (Mon, Tue, Wed, Thu, Fri, Sat, Sun)
|
|
// or full name (Monday, Tuesday, etc.)
|
|
weekday := now.Weekday().String()
|
|
shortDay := weekday[:3] // Mon, Tue, etc.
|
|
if day == weekday || day == shortDay {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TriggerManual manually triggers a robot execution (for testing or API calls)
|
|
// This bypasses clock checking and directly submits to pool
|
|
// For non-autonomous robots: lazy-loads from DB, executes, then unloads
|
|
func (m *Manager) TriggerManual(ctx *types.Context, memberID string, trigger types.TriggerType, data interface{}) (string, error) {
|
|
m.mu.RLock()
|
|
if !m.started {
|
|
m.mu.RUnlock()
|
|
return "", fmt.Errorf("manager not started")
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
// Get robot from cache, or lazy-load if not found
|
|
robot, lazyLoaded, err := m.getOrLoadRobot(ctx, memberID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Check robot status
|
|
if robot.Status == types.RobotPaused {
|
|
return "", types.ErrRobotPaused
|
|
}
|
|
|
|
// Check if trigger type is enabled
|
|
if robot.Config != nil && robot.Config.Triggers != nil {
|
|
if !robot.Config.Triggers.IsEnabled(trigger) {
|
|
return "", types.ErrTriggerDisabled
|
|
}
|
|
}
|
|
|
|
// Pre-generate execution ID and track for pause/resume/stop
|
|
// We need to track BEFORE submit so we can pass the cancellable context to the executor
|
|
execID := pool.GenerateExecID()
|
|
ctrlExec := m.execController.Track(execID, memberID, robot.TeamID)
|
|
|
|
// Create a new context with the cancellable context from ExecutionController
|
|
// This allows Stop() to propagate cancellation to the executor
|
|
execCtx := types.NewContext(ctrlExec.Context(), ctx.Auth)
|
|
execCtx.Locale = ctx.Locale
|
|
|
|
// Submit to pool with the cancellable context and execution control
|
|
// The control interface allows executor to check pause state and wait if paused
|
|
_, err = m.pool.SubmitWithID(execCtx, robot, trigger, data, execID, ctrlExec)
|
|
if err != nil {
|
|
// If submission failed, untrack the execution
|
|
m.execController.Untrack(execID)
|
|
// If lazy-loaded and submission failed, remove from cache
|
|
if lazyLoaded {
|
|
m.cache.Remove(memberID)
|
|
}
|
|
return "", err
|
|
}
|
|
|
|
// For lazy-loaded robots, schedule cleanup after execution completes
|
|
if lazyLoaded {
|
|
m.scheduleCleanup(robot)
|
|
}
|
|
|
|
return execID, nil
|
|
}
|
|
|
|
// ==================== Human Intervention & Event Triggers ====================
|
|
|
|
// Intervene processes a human intervention request
|
|
// Human intervention skips P0 (inspiration) and goes directly to P1 (goals)
|
|
// For non-autonomous robots: lazy-loads from DB, executes, then unloads
|
|
func (m *Manager) Intervene(ctx *types.Context, req *types.InterveneRequest) (*types.ExecutionResult, error) {
|
|
m.mu.RLock()
|
|
if !m.started {
|
|
m.mu.RUnlock()
|
|
return nil, fmt.Errorf("manager not started")
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
// Validate request
|
|
if err := trigger.ValidateIntervention(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get robot from cache, or lazy-load if not found
|
|
robot, lazyLoaded, err := m.getOrLoadRobot(ctx, req.MemberID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check robot status
|
|
if robot.Status == types.RobotPaused {
|
|
return nil, types.ErrRobotPaused
|
|
}
|
|
|
|
// Check if human trigger is enabled
|
|
if robot.Config != nil && robot.Config.Triggers != nil {
|
|
if !robot.Config.Triggers.IsEnabled(types.TriggerHuman) {
|
|
return nil, types.ErrTriggerDisabled
|
|
}
|
|
}
|
|
|
|
// Build trigger input
|
|
triggerInput := &types.TriggerInput{
|
|
Action: req.Action,
|
|
Messages: req.Messages,
|
|
UserID: ctx.UserID(),
|
|
Locale: req.Locale,
|
|
}
|
|
|
|
// Handle plan.add action - schedule for later
|
|
if req.Action == types.ActionPlanAdd && req.PlanTime != nil {
|
|
// If lazy-loaded but not executing, remove immediately
|
|
if lazyLoaded {
|
|
m.cache.Remove(req.MemberID)
|
|
}
|
|
// TODO: Add to plan queue (Phase 11.3)
|
|
return &types.ExecutionResult{
|
|
Status: types.ExecPending,
|
|
Message: fmt.Sprintf("Planned for %s (plan queue not implemented yet)", req.PlanTime.Format(time.RFC3339)),
|
|
}, nil
|
|
}
|
|
|
|
// Determine executor mode: request > robot config > default
|
|
executorMode := m.resolveExecutorMode(req.ExecutorMode, robot)
|
|
|
|
// Submit to pool with executor mode
|
|
execID, err := m.pool.SubmitWithMode(ctx, robot, types.TriggerHuman, triggerInput, executorMode)
|
|
if err != nil {
|
|
// If lazy-loaded and submission failed, remove from cache
|
|
if lazyLoaded {
|
|
m.cache.Remove(req.MemberID)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Track execution for pause/resume/stop
|
|
m.execController.Track(execID, req.MemberID, req.TeamID)
|
|
|
|
// For lazy-loaded robots, schedule cleanup after execution completes
|
|
if lazyLoaded {
|
|
m.scheduleCleanup(robot)
|
|
}
|
|
|
|
return &types.ExecutionResult{
|
|
ExecutionID: execID,
|
|
Status: types.ExecPending,
|
|
Message: fmt.Sprintf("Human intervention (%s) submitted", req.Action),
|
|
}, nil
|
|
}
|
|
|
|
// HandleEvent processes an event trigger request
|
|
// Event trigger skips P0 (inspiration) and goes directly to P1 (goals)
|
|
// For non-autonomous robots: lazy-loads from DB, executes, then unloads
|
|
func (m *Manager) HandleEvent(ctx *types.Context, req *types.EventRequest) (*types.ExecutionResult, error) {
|
|
m.mu.RLock()
|
|
if !m.started {
|
|
m.mu.RUnlock()
|
|
return nil, fmt.Errorf("manager not started")
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
// Validate request
|
|
if err := trigger.ValidateEvent(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Get robot from cache, or lazy-load if not found
|
|
robot, lazyLoaded, err := m.getOrLoadRobot(ctx, req.MemberID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check robot status
|
|
if robot.Status == types.RobotPaused {
|
|
return nil, types.ErrRobotPaused
|
|
}
|
|
|
|
// Check if event trigger is enabled
|
|
if robot.Config != nil && robot.Config.Triggers != nil {
|
|
if !robot.Config.Triggers.IsEnabled(types.TriggerEvent) {
|
|
return nil, types.ErrTriggerDisabled
|
|
}
|
|
}
|
|
|
|
// Build trigger input
|
|
triggerInput := trigger.BuildEventInput(req)
|
|
|
|
// Determine executor mode: request > robot config > default
|
|
executorMode := m.resolveExecutorMode(req.ExecutorMode, robot)
|
|
|
|
// Submit to pool with executor mode
|
|
execID, err := m.pool.SubmitWithMode(ctx, robot, types.TriggerEvent, triggerInput, executorMode)
|
|
if err != nil {
|
|
// If lazy-loaded and submission failed, remove from cache
|
|
if lazyLoaded {
|
|
m.cache.Remove(req.MemberID)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Track execution for pause/resume/stop
|
|
m.execController.Track(execID, req.MemberID, "")
|
|
|
|
// For lazy-loaded robots, schedule cleanup after execution completes
|
|
if lazyLoaded {
|
|
m.scheduleCleanup(robot)
|
|
}
|
|
|
|
return &types.ExecutionResult{
|
|
ExecutionID: execID,
|
|
Status: types.ExecPending,
|
|
Message: fmt.Sprintf("Event trigger (%s: %s) submitted", req.Source, req.EventType),
|
|
}, nil
|
|
}
|
|
|
|
// ==================== Execution Control ====================
|
|
|
|
// PauseExecution pauses a running execution
|
|
func (m *Manager) PauseExecution(ctx *types.Context, execID string) error {
|
|
// Get execution info before pausing
|
|
exec := m.execController.Get(execID)
|
|
if exec == nil {
|
|
return fmt.Errorf("execution not found: %s", execID)
|
|
}
|
|
|
|
// Pause the execution
|
|
if err := m.execController.Pause(execID); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Remove from robot's in-memory execution list (paused doesn't count as running)
|
|
if robot := m.cache.Get(exec.MemberID); robot != nil {
|
|
robot.RemoveExecution(execID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ResumeExecution resumes a paused execution
|
|
func (m *Manager) ResumeExecution(ctx *types.Context, execID string) error {
|
|
// Get execution info before resuming
|
|
exec := m.execController.Get(execID)
|
|
if exec == nil {
|
|
return fmt.Errorf("execution not found: %s", execID)
|
|
}
|
|
|
|
// Resume the execution
|
|
if err := m.execController.Resume(execID); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Add back to robot's in-memory execution list
|
|
if robot := m.cache.Get(exec.MemberID); robot != nil {
|
|
robot.AddExecution(&types.Execution{
|
|
ID: execID,
|
|
MemberID: exec.MemberID,
|
|
TeamID: exec.TeamID,
|
|
Status: types.ExecRunning,
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// StopExecution stops a running execution
|
|
func (m *Manager) StopExecution(ctx *types.Context, execID string) error {
|
|
// Get execution info before stopping
|
|
exec := m.execController.Get(execID)
|
|
if exec == nil {
|
|
return fmt.Errorf("execution not found: %s", execID)
|
|
}
|
|
|
|
// Stop the execution
|
|
if err := m.execController.Stop(execID); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Remove from robot's in-memory execution list
|
|
if robot := m.cache.Get(exec.MemberID); robot != nil {
|
|
robot.RemoveExecution(execID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetExecutionStatus returns the status of an execution
|
|
func (m *Manager) GetExecutionStatus(execID string) (*trigger.ControlledExecution, error) {
|
|
exec := m.execController.Get(execID)
|
|
if exec == nil {
|
|
return nil, fmt.Errorf("execution not found: %s", execID)
|
|
}
|
|
return exec, nil
|
|
}
|
|
|
|
// ListExecutions returns all tracked executions
|
|
func (m *Manager) ListExecutions() []*trigger.ControlledExecution {
|
|
return m.execController.List()
|
|
}
|
|
|
|
// ListExecutionsByMember returns all executions for a specific robot
|
|
func (m *Manager) ListExecutionsByMember(memberID string) []*trigger.ControlledExecution {
|
|
return m.execController.ListByMember(memberID)
|
|
}
|
|
|
|
// ==================== Helper Methods ====================
|
|
|
|
// getOrLoadRobot gets a robot from cache, or lazy-loads from DB if not found
|
|
// Returns: robot, wasLazyLoaded, error
|
|
func (m *Manager) getOrLoadRobot(ctx *types.Context, memberID string) (*types.Robot, bool, error) {
|
|
// Try cache first
|
|
robot := m.cache.Get(memberID)
|
|
if robot != nil {
|
|
return robot, false, nil
|
|
}
|
|
|
|
// Not in cache - lazy load from database
|
|
robot, err := m.cache.LoadByID(ctx, memberID)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
|
|
// Add to cache temporarily for execution tracking
|
|
m.cache.Add(robot)
|
|
|
|
// Return with lazyLoaded=true to indicate cleanup needed after execution
|
|
return robot, true, nil
|
|
}
|
|
|
|
// scheduleCleanup schedules removal of a lazy-loaded robot after all executions complete
|
|
// This runs in a goroutine that monitors the robot's execution count
|
|
func (m *Manager) scheduleCleanup(robot *types.Robot) {
|
|
go func() {
|
|
memberID := robot.MemberID
|
|
|
|
// Poll every 5 seconds to check if all executions are done
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
// Timeout after 24 hours to prevent memory leaks
|
|
timeout := time.After(24 * time.Hour)
|
|
|
|
for {
|
|
select {
|
|
case <-timeout:
|
|
// Timeout - force cleanup
|
|
m.cache.Remove(memberID)
|
|
return
|
|
|
|
case <-ticker.C:
|
|
// Check if robot still exists in cache
|
|
r := m.cache.Get(memberID)
|
|
if r == nil {
|
|
// Already removed
|
|
return
|
|
}
|
|
|
|
// Check if all executions are done
|
|
if r.RunningCount() == 0 {
|
|
// Non-autonomous robots: with full-cache load they will be
|
|
// re-added on next Load() cycle, so removal is a no-op in practice.
|
|
if !r.AutonomousMode {
|
|
m.cache.Remove(memberID)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// resolveExecutorMode determines the executor mode to use
|
|
// Priority: request > robot config > default (standard)
|
|
func (m *Manager) resolveExecutorMode(requestMode types.ExecutorMode, robot *types.Robot) types.ExecutorMode {
|
|
// Request mode takes precedence
|
|
if requestMode != "" && requestMode.IsValid() {
|
|
return requestMode
|
|
}
|
|
|
|
// Robot config mode
|
|
if robot != nil && robot.Config != nil && robot.Config.Executor != nil {
|
|
return robot.Config.Executor.GetMode()
|
|
}
|
|
|
|
// Default: standard
|
|
return types.ExecutorStandard
|
|
}
|
|
|
|
// ==================== Getters for internal components ====================
|
|
// These are exposed for testing and advanced use cases
|
|
|
|
// Cache returns the internal cache
|
|
func (m *Manager) Cache() *cache.Cache {
|
|
return m.cache
|
|
}
|
|
|
|
// Pool returns the internal pool
|
|
func (m *Manager) Pool() *pool.Pool {
|
|
return m.pool
|
|
}
|
|
|
|
// Executor returns the internal executor
|
|
func (m *Manager) Executor() types.Executor {
|
|
return m.executor
|
|
}
|
|
|
|
// ExecController returns the internal execution controller
|
|
func (m *Manager) ExecController() *trigger.ExecutionController {
|
|
return m.execController
|
|
}
|
|
|
|
// IsStarted returns true if manager is started
|
|
func (m *Manager) IsStarted() bool {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.started
|
|
}
|
|
|
|
// Running returns number of currently running jobs
|
|
func (m *Manager) Running() int {
|
|
return m.pool.Running()
|
|
}
|
|
|
|
// Queued returns number of queued jobs
|
|
func (m *Manager) Queued() int {
|
|
return m.pool.Queued()
|
|
}
|
|
|
|
// CachedRobots returns number of cached robots
|
|
func (m *Manager) CachedRobots() int {
|
|
return m.cache.Count()
|
|
}
|