package automations import ( "context" "errors" "fmt" "log/slog" "sync" "time" ) // 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 cronMeta map[string]Rule // rule id -> cron rule (name/expression for listing) } // 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), cronMeta: make(map[string]Rule), }, 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.cronMeta[r.ID] = r 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) delete(s.cronMeta, 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() } // List returns a read-only snapshot of every registered automation and reminder // for the admin panel, composing the reminder store, the event rules and the // cron schedules. It never mutates state and is safe for concurrent use. Cron // entries' LastRun/NextRun are zero until the scheduler has run. func (s *Service) List() []AutomationRecord { out := make([]AutomationRecord, 0) for _, r := range s.remind.List() { at := r.At out = append(out, AutomationRecord{ ID: r.ID, Name: r.Message, Kind: "reminder", Enabled: true, NextRun: at, }) } for _, r := range s.engine.Rules() { out = append(out, AutomationRecord{ ID: r.ID, Name: r.Name, Kind: string(r.Trigger), Enabled: true, }) } s.mu.RLock() for id, rule := range s.cronMeta { entry := s.sched.Entry(s.cronRules[id]) out = append(out, AutomationRecord{ ID: id, Name: rule.Name, Kind: string(rule.Trigger), Cron: rule.Expression, Enabled: true, LastRun: entry.Prev, NextRun: entry.Next, }) } s.mu.RUnlock() return out } // CancelReminder delegates to the ReminderStore. func (s *Service) CancelReminder(ctx context.Context, id string) error { return s.remind.Cancel(ctx, id) } // AddRuleByName registers a named cron/event automation whose action records its // fire on the service logger (a real, observable side effect) and returns the // generated rule id. kind must be "cron" or "event"; expression is a cron spec // for cron rules or a bus subject pattern for event rules. A malformed rule or // expression returns an error wrapping ErrInvalidRule / ErrInvalidCron so the // admin handler can answer honestly instead of fabricating a success. func (s *Service) AddRuleByName(ctx context.Context, name, kind, expression string) (string, error) { act, err := NewFuncAction(name, func(_ context.Context, payload any) (any, error) { s.log.Info("admin automation fired", "name", name, "payload", payload) return map[string]any{"fired": name, "payload": payload}, nil }) if err != nil { return "", err } rule := Rule{ ID: fmt.Sprintf("auto-%d", time.Now().UnixNano()), Name: name, Trigger: TriggerKind(kind), Expression: expression, Action: act, } if err := s.AddRule(ctx, rule); err != nil { return "", err } return rule.ID, nil } // RunRule executes one registered rule's action once and returns its output. It // is the manual "run now" path for the admin panel. It returns an error wrapping // ErrNotFound when no rule or reminder carries id. func (s *Service) RunRule(ctx context.Context, id string) (any, error) { if id == "" { return nil, fmt.Errorf("automations: run rule: %w", ErrInvalidRule) } for _, r := range s.engine.Rules() { if r.ID == id { return r.Action.Execute(ctx, nil) } } s.mu.RLock() r, ok := s.cronMeta[id] s.mu.RUnlock() if ok { return r.Action.Execute(ctx, nil) } for _, rem := range s.remind.List() { if rem.ID == id { return map[string]any{"reminder": rem.ID, "message": rem.Message, "at": rem.At}, nil } } return nil, fmt.Errorf("automations: run rule %q: %w", id, ErrNotFound) } // 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...) }