package automations import ( "context" "errors" "fmt" "log/slog" "sync" ) // Service composes the Scheduler (cron), the Engine (event rules) and the // ReminderStore into a single automation engine with one lifecycle. It routes // event rules to the Engine and cron rules to the Scheduler by Trigger kind. type Service struct { cfg Config deps Deps log *slog.Logger sched *Scheduler engine *Engine remind *ReminderStore mu sync.RWMutex cronRules map[string]ScheduleID // rule id -> schedule id } // NewService builds an automation engine from cfg and deps. The bus in deps is // required. It also resolves the AI pipeline used by AIAction construction. func NewService(cfg Config, deps Deps) (*Service, error) { if deps.Bus == nil { return nil, fmt.Errorf("automations: new service: %w", ErrNilBus) } sched, err := New(cfg, deps) if err != nil { return nil, err } engine, err := NewEngine(cfg, deps) if err != nil { return nil, err } remind, err := NewReminder(cfg, deps) if err != nil { return nil, err } return &Service{ cfg: cfg, deps: deps, log: sourcedLog(deps.Log), sched: sched, engine: engine, remind: remind, cronRules: make(map[string]ScheduleID), }, nil } // Pipeline returns the service's resolved AI pipeline, or nil when neither a loop // nor an agent is configured. func (s *Service) Pipeline() Pipeline { return s.deps.pipeline() } // Scheduler returns the underlying cron scheduler. func (s *Service) Scheduler() *Scheduler { return s.sched } // Engine returns the underlying event rule engine. func (s *Service) Engine() *Engine { return s.engine } // ReminderStore returns the underlying reminder store. func (s *Service) ReminderStore() *ReminderStore { return s.remind } // AddSchedule delegates to the Scheduler. func (s *Service) AddSchedule(ctx context.Context, cronExpr string, action Action) (ScheduleID, error) { return s.sched.AddSchedule(ctx, cronExpr, action) } // AddRule routes an event rule to the Engine and a cron rule to the Scheduler. A // malformed rule returns an error wrapping ErrInvalidRule. func (s *Service) AddRule(ctx context.Context, r Rule) error { if !r.valid() { return fmt.Errorf("automations: add rule %q: %w", r.ID, ErrInvalidRule) } switch r.Trigger { case TriggerEvent: return s.engine.AddEvent(ctx, r) case TriggerCron: id, err := s.sched.AddSchedule(ctx, r.Expression, r.Action) if err != nil { return err } s.mu.Lock() s.cronRules[r.ID] = id s.mu.Unlock() return nil default: return fmt.Errorf("automations: add rule %q: %w", r.ID, ErrInvalidRule) } } // RemoveRule removes an event or cron rule by id. It returns an error wrapping // ErrNotFound when no such rule exists. func (s *Service) RemoveRule(ctx context.Context, id string) error { s.mu.Lock() if schedID, ok := s.cronRules[id]; ok { delete(s.cronRules, id) s.mu.Unlock() s.sched.RemoveSchedule(schedID) return nil } s.mu.Unlock() return s.engine.RemoveRule(ctx, id) } // AddReminder delegates to the ReminderStore. func (s *Service) AddReminder(ctx context.Context, r Reminder) error { return s.remind.Add(ctx, r) } // Reminders returns a sorted snapshot of registered reminders. func (s *Service) Reminders() []Reminder { return s.remind.List() } // CancelReminder delegates to the ReminderStore. func (s *Service) CancelReminder(ctx context.Context, id string) error { return s.remind.Cancel(ctx, id) } // Start starts the scheduler, the event engine and the reminder loop. func (s *Service) Start(ctx context.Context) error { if err := s.sched.Start(ctx); err != nil { return err } if err := s.engine.Start(ctx); err != nil { return err } return s.remind.Start(ctx) } // Stop stops the reminder loop, the event engine and the scheduler, collecting // errors from each. func (s *Service) Stop(ctx context.Context) error { var errs []error if err := s.remind.Stop(ctx); err != nil { errs = append(errs, err) } if err := s.engine.Stop(ctx); err != nil { errs = append(errs, err) } if err := s.sched.Stop(ctx); err != nil { errs = append(errs, err) } return errors.Join(errs...) }