feat(automations): 自动化引擎(cron 调度 + 事件触发 + AI 管线动作 + 提醒调度,bus automation.result/reminder.trigger)

This commit is contained in:
Aska committed 2026-09-06 07:32:29 +08:00
1 parent a3f6f4f687
commit ff34b62079
10 files changed
+1818

No files matched your search

+169
View File
@@ -0,0 +1,169 @@
package automations
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// Action is a unit of work executed when a schedule, event rule or reminder
// fires. Execute runs the action with the trigger payload and returns a result
// (or an error, wrapped with %w). Implementations must be safe for concurrent use
// because the engine may invoke them from multiple goroutines.
//
// The three concrete implementations are FuncAction (a plain function), ToolAction
// (an sdk.Tool) and AIAction (a prompt run through the AI pipeline).
type Action interface {
// Name identifies the action in logs and in the published Result.
Name() string
// Execute runs the action with the trigger payload.
Execute(ctx context.Context, payload any) (any, error)
}
// Func is a plain function action.
type Func func(ctx context.Context, payload any) (any, error)
// FuncAction adapts a plain function to the Action interface.
type FuncAction struct {
name string
fn Func
}
// NewFuncAction wraps fn as a named action. It returns an error wrapping
// ErrNilAction when name is blank or fn is nil.
func NewFuncAction(name string, fn Func) (*FuncAction, error) {
if strings.TrimSpace(name) == "" {
return nil, fmt.Errorf("automations: func action: %w", ErrNilAction)
}
if fn == nil {
return nil, fmt.Errorf("automations: func action %q: %w", name, ErrNilAction)
}
return &FuncAction{name: name, fn: fn}, nil
}
// Name returns the action name.
func (f *FuncAction) Name() string { return f.name }
// Execute runs the wrapped function with the payload.
func (f *FuncAction) Execute(ctx context.Context, payload any) (any, error) {
return f.fn(ctx, payload)
}
// ToolAction adapts an sdk.Tool to the Action interface.
type ToolAction struct {
Tool sdk.Tool
}
// NewToolAction wraps a registered tool as a named action. A nil tool returns an
// error wrapping ErrNilAction.
func NewToolAction(t sdk.Tool) (*ToolAction, error) {
if t == nil {
return nil, fmt.Errorf("automations: tool action: %w", ErrNilAction)
}
return &ToolAction{Tool: t}, nil
}
// Name returns the underlying tool's name.
func (t *ToolAction) Name() string { return t.Tool.Name() }
// Execute delegates to the underlying tool.
func (t *ToolAction) Execute(ctx context.Context, payload any) (any, error) {
return t.Tool.Execute(ctx, payload)
}
// AIAction drives a prompt through the AI pipeline (loop or agent) and returns
// the generated text. sessionID scopes the conversation for tracing/persistence.
type AIAction struct {
name string
prompt string
sessionID string
pipeline Pipeline
}
// NewAIAction builds a prompting action. name must be non-blank and p must be
// non-nil; otherwise an error wrapping ErrNilAction or ErrNoPipeline is returned.
func NewAIAction(name, prompt, sessionID string, p Pipeline) (*AIAction, error) {
if strings.TrimSpace(name) == "" {
return nil, fmt.Errorf("automations: ai action: %w", ErrNilAction)
}
if p == nil {
return nil, fmt.Errorf("automations: ai action %q: %w", name, ErrNoPipeline)
}
return &AIAction{name: name, prompt: prompt, sessionID: sessionID, pipeline: p}, nil
}
// Name returns the action name.
func (a *AIAction) Name() string { return a.name }
// Execute runs the prompt (with the payload appended) through the pipeline and
// returns the generated text as the result.
func (a *AIAction) Execute(ctx context.Context, payload any) (any, error) {
msgs := []model.Message{model.TextMessage(model.RoleUser, a.promptMessage(payload))}
res, err := a.pipeline.Run(ctx, a.sessionID, msgs)
if err != nil {
return nil, fmt.Errorf("automations: ai action %q: %w", a.name, err)
}
return res.Text, nil
}
// promptMessage renders the prompt plus a readable form of the trigger payload.
func (a *AIAction) promptMessage(payload any) string {
if payload == nil {
return a.prompt
}
if s, ok := payload.(string); ok {
return a.prompt + "\nTrigger payload: " + s
}
if b, err := json.Marshal(payload); err == nil {
return a.prompt + "\nTrigger payload: " + string(b)
}
return a.prompt + "\nTrigger payload: " + fmt.Sprint(payload)
}
// runAction executes a with payload under the configured deadline and publishes
// the outcome to SubjectResult. It is the single place where automation execution
// and result reporting are wired, so every trigger kind reports consistently. A
// panic is recovered and recorded as a failed result so a misbehaving action
// cannot crash the calling goroutine.
func runAction(ctx context.Context, cfg Config, b bus.Bus, log *slog.Logger, kind, ruleID string, a Action, payload any) {
if a == nil {
return
}
execCtx := ctx
if cfg.ActionTimeout > 0 {
var cancel context.CancelFunc
execCtx, cancel = context.WithTimeout(ctx, cfg.ActionTimeout)
defer cancel()
}
out, err := executeSafely(execCtx, a, payload)
res := Result{Kind: kind, RuleID: ruleID, Action: a.Name(), Payload: payload, Output: out}
if err != nil {
res.Error = err.Error()
log.Error("automation action failed", "kind", kind, "rule_id", ruleID, "action", a.Name(), "err", err)
} else {
log.Info("automation action ran", "kind", kind, "rule_id", ruleID, "action", a.Name())
}
if cerr := b.Publish(ctx, SubjectResult, res); cerr != nil {
log.Error("publish automation result", "kind", kind, "rule_id", ruleID, "err", cerr)
}
}
// executeSafely runs a.Execute, converting a panic into an error so the action
// reports a failed result instead of crashing the engine goroutine.
func executeSafely(ctx context.Context, a Action, payload any) (out any, err error) {
defer func() {
if r := recover(); r != nil {
out = nil
err = fmt.Errorf("automations: action %q panicked: %v", a.Name(), r)
}
}()
return a.Execute(ctx, payload)
}
@@ -0,0 +1,133 @@
package automations
import (
"context"
"errors"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// fakePipeline is an in-memory Pipeline used to observe the prompt an AIAction
// sends and to return a canned text. It is the mock for the loop/agent pipeline;
// it implements the Pipeline interface, not a concrete loop.
type fakePipeline struct {
mu sync.Mutex
text string
err error
msgs []model.Message
calls int
}
func (f *fakePipeline) Run(ctx context.Context, sessionID string, msgs []model.Message) (*loop.Result, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls++
f.msgs = append(f.msgs, msgs...)
if f.err != nil {
return nil, f.err
}
return &loop.Result{Text: f.text, StopReason: "stop", Steps: 1}, nil
}
func (f *fakePipeline) lastMessage() model.Message {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.msgs) == 0 {
return model.Message{}
}
return f.msgs[len(f.msgs)-1]
}
func TestAIAction_RunsPipelineAndPublishesResult(t *testing.T) {
t.Parallel()
b := bus.New()
defer b.Close()
got := make(chan bus.Event, 1)
unsub, err := b.Subscribe(context.Background(), SubjectResult, func(_ context.Context, ev bus.Event) error {
got <- ev
return nil
})
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer unsub()
p := &fakePipeline{text: "hello from pipeline"}
act, err := NewAIAction("gen_greeting", "You are a greeter", "sess-1", p)
if err != nil {
t.Fatalf("NewAIAction: %v", err)
}
s, err := New(Config{}, Deps{Bus: b})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if _, err := s.AddSchedule(ctx, "@every 50ms", act); err != nil {
t.Fatalf("AddSchedule: %v", err)
}
if err := s.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
var res Result
select {
case ev := <-got:
v, ok := ev.Payload.(Result)
if !ok {
t.Fatalf("unexpected payload type: %T", ev.Payload)
}
res = v
case <-time.After(3 * time.Second):
t.Fatal("AI action did not fire")
}
if res.Kind != "schedule" {
t.Errorf("kind = %q, want schedule", res.Kind)
}
if res.Action != "gen_greeting" {
t.Errorf("action = %q, want gen_greeting", res.Action)
}
if res.Output != "hello from pipeline" {
t.Errorf("output = %#v, want hello from pipeline", res.Output)
}
last := p.lastMessage()
if len(last.Content) == 0 || last.Content[0].Text != "You are a greeter" {
t.Errorf("prompt = %#v, want the configured prompt", last.Content)
}
if p.calls < 1 {
t.Errorf("pipeline not called")
}
if err := s.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
}
func TestAIAction_RequiresPipeline(t *testing.T) {
t.Parallel()
if _, err := NewAIAction("x", "p", "s", nil); err == nil {
t.Fatal("expected error for nil pipeline")
} else if !errors.Is(err, ErrNoPipeline) {
t.Fatalf("err = %v, want ErrNoPipeline", err)
}
}
func TestAIAction_PropagatesPipelineError(t *testing.T) {
t.Parallel()
p := &fakePipeline{err: context.DeadlineExceeded}
act, err := NewAIAction("x", "p", "s", p)
if err != nil {
t.Fatalf("NewAIAction: %v", err)
}
if _, err := act.Execute(context.Background(), nil); err == nil {
t.Fatal("expected error from pipeline")
}
}
@@ -0,0 +1,176 @@
// Package automations implements the Cyrene v2 automation engine and reminder
// scheduler (DevDocs/design/04-运行时.md §8.5). It is the "how she does things on
// her own" layer: schedules and event rules fire a named action, the action runs
// (either a plain function, a registered tool, or a prompt through the AI
// loop/agent pipeline), and every outcome is published back to the bus so upper
// layers can react or push notifications.
//
// The package is split into five files:
//
// - automations.go — shared types: Config, Deps, the Pipeline abstraction, the
// published Result and the sentinel errors.
// - scheduler.go — the cron-driven Scheduler (robfig/cron/v3).
// - rule.go — the Rule model and the event-driven Engine.
// - action.go — the Action interface plus func/tool/AI implementations.
// - reminder.go — the time-based ReminderStore.
// - service.go — the Service that composes the three for lifecycle control.
//
// Execution model: every trigger (cron fire, event match, reminder due) resolves
// to the same runAction, which executes the Action under an optional deadline and
// publishes a Result to the "automation.result" subject. This keeps reporting and
// logging consistent across all trigger kinds (DevDocs/design/04-运行时.md §8.5:
// task output returns to the message queue/agent).
//
// Identity and capability boundaries (Devedocs global red lines): automation
// never decides who is allowed to do anything. It only fires named actions;
// authorization stays in the channel/policy layer.
package automations
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// Bus subjects published by the automations engine. Upper layers subscribe to
// these to observe outcomes or push notifications.
const (
// SubjectResult is the subject every executed automation publishes its
// Result to.
SubjectResult = "automation.result"
// SubjectTrigger is the subject a due Reminder is published to. Upper layers
// subscribe to it to surface the reminder to the user.
SubjectTrigger = "reminder.trigger"
)
// Sentinel errors returned by this package. Use errors.Is to test for them.
var (
// ErrNilBus is returned when a constructor receives a nil bus.
ErrNilBus = errors.New("automations: nil bus")
// ErrNilAction is returned when an action is nil or unnamed.
ErrNilAction = errors.New("automations: nil action")
// ErrInvalidRule is returned when a rule is malformed.
ErrInvalidRule = errors.New("automations: invalid rule")
// ErrInvalidCron is returned when a cron expression fails to parse.
ErrInvalidCron = errors.New("automations: invalid cron expression")
// ErrNotFound is returned when a rule or reminder is missing.
ErrNotFound = errors.New("automations: not found")
// ErrDuplicate is returned when a rule or reminder id is already registered.
ErrDuplicate = errors.New("automations: already exists")
// ErrNoPipeline is returned when an AI action is built without a pipeline.
ErrNoPipeline = errors.New("automations: no AI pipeline configured")
)
// Config configures the automations engine.
type Config struct {
// Location is the timezone used for cron schedules and reminder firing. Nil
// uses time.Local.
Location *time.Location
// WithSeconds enables 6-field cron specs (with a leading seconds field) in
// addition to the standard 5-field format.
WithSeconds bool
// ActionTimeout bounds a single action execution. Non-positive disables the
// deadline.
ActionTimeout time.Duration
// ReminderInterval is how often the reminder loop polls for due reminders.
// Non-positive selects defaultReminderInterval.
ReminderInterval time.Duration
}
// Deps is the injected dependency set for the automations engine.
type Deps struct {
// Bus is the in-process event bus. Required.
Bus bus.Bus
// Loop is the AI pipeline (the agent loop). When set, an AI action runs
// through it. Agent is used only when Loop is nil.
Loop *loop.Loop
// Agent is the single-step agent, used as the AI pipeline when Loop is nil.
Agent *agent.Agent
// Log is the base logger. A nil logger is replaced with a discard logger
// tagged with source "services/automations".
Log *slog.Logger
// Now supplies the current time for reminders. Nil uses time.Now.
Now func() time.Time
}
// Pipeline is the AI-execution surface an automation action runs through. It is
// satisfied by *loop.Loop directly, so a real pipeline can be injected without
// an adapter. Tests inject a fake to observe the prompt and return canned text.
type Pipeline interface {
Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error)
}
// AgentPipeline adapts an *agent.Agent to Pipeline by running a single step and
// returning its text as the result. It is used when only an agent (not a full
// loop) is injected.
type AgentPipeline struct {
Agent *agent.Agent
}
// Run runs one agent step and returns its text as the loop-style Result.
func (a *AgentPipeline) Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error) {
if a.Agent == nil {
return nil, fmt.Errorf("automations: agent pipeline: %w", ErrNoPipeline)
}
sr, err := a.Agent.RunStep(ctx, sessionID, messages)
if err != nil {
return nil, fmt.Errorf("automations: agent pipeline run: %w", err)
}
return &loop.Result{Text: sr.Text, StopReason: sr.StopReason, Steps: 1}, nil
}
// pipeline returns the resolved AI pipeline from deps, or nil when neither a
// loop nor an agent is configured.
func (d Deps) pipeline() Pipeline {
if d.Loop != nil {
return d.Loop
}
if d.Agent != nil {
return &AgentPipeline{Agent: d.Agent}
}
return nil
}
// Result is the structured outcome of an executed automation, published to
// SubjectResult. Upper layers subscribe to it to react or push notifications.
type Result struct {
// Kind classifies the trigger: "schedule", "event" or "reminder".
Kind string `json:"kind"`
// RuleID identifies the schedule/rule/reminder that fired.
RuleID string `json:"rule_id"`
// Action is the action name that ran.
Action string `json:"action"`
// Payload is the trigger payload (nil for a cron fire).
Payload any `json:"payload"`
// Output is the action's returned value, or nil on error.
Output any `json:"output"`
// Error carries the action's error message when the run failed.
Error string `json:"error,omitempty"`
}
// sourcedLog returns a base logger tagged with the module source
// "services/automations", replacing a nil logger with a discard one so callers
// can always log safely.
func sourcedLog(l *slog.Logger) *slog.Logger {
if l == nil {
l = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return logger.WithSource(l, "services/automations")
}
// resolveNow returns f when non-nil, otherwise time.Now.
func resolveNow(f func() time.Time) func() time.Time {
if f == nil {
return time.Now
}
return f
}
+211
View File
@@ -0,0 +1,211 @@
package automations
import (
"context"
"fmt"
"log/slog"
"sort"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
)
const (
// defaultReminderInterval is how often the reminder loop polls for due
// reminders when Config.ReminderInterval is non-positive.
defaultReminderInterval = 500 * time.Millisecond
// reminderStopWait bounds how long Stop waits for the reminder loop to exit.
reminderStopWait = 5 * time.Second
)
// Reminder is a single delayed notification.
type Reminder struct {
// ID uniquely identifies the reminder.
ID string
// At is the time the reminder should fire.
At time.Time
// Message is the payload surfaced to the user when it fires.
Message string
}
// ReminderStore schedules delayed notifications and publishes each due reminder
// to SubjectTrigger so an upper layer can push it to the user. It is safe for
// concurrent use.
type ReminderStore struct {
cfg Config
bus bus.Bus
log *slog.Logger
now func() time.Time
interval time.Duration
mu sync.RWMutex
items map[string]Reminder
cancel context.CancelFunc
done chan struct{}
started bool
}
// NewReminder builds a ReminderStore. The bus in deps is required; a nil bus
// returns an error wrapping ErrNilBus. cfg.ReminderInterval defaults to
// defaultReminderInterval.
func NewReminder(cfg Config, deps Deps) (*ReminderStore, error) {
if deps.Bus == nil {
return nil, fmt.Errorf("automations: new reminder: %w", ErrNilBus)
}
interval := cfg.ReminderInterval
if interval <= 0 {
interval = defaultReminderInterval
}
return &ReminderStore{
cfg: cfg,
bus: deps.Bus,
log: sourcedLog(deps.Log),
now: resolveNow(deps.Now),
interval: interval,
items: make(map[string]Reminder),
}, nil
}
// Add registers a reminder. It returns an error wrapping ErrInvalidRule when the
// id is blank or At is zero, and ErrDuplicate when the id is already registered.
func (rs *ReminderStore) Add(ctx context.Context, r Reminder) error {
if r.ID == "" || r.At.IsZero() {
return fmt.Errorf("automations: add reminder: %w", ErrInvalidRule)
}
rs.mu.Lock()
defer rs.mu.Unlock()
if _, exists := rs.items[r.ID]; exists {
return fmt.Errorf("automations: add reminder %q: %w", r.ID, ErrDuplicate)
}
rs.items[r.ID] = r
rs.log.Info("reminder added", "id", r.ID, "at", r.At)
return nil
}
// List returns a snapshot of the registered reminders, sorted by At ascending.
func (rs *ReminderStore) List() []Reminder {
rs.mu.RLock()
defer rs.mu.RUnlock()
out := make([]Reminder, 0, len(rs.items))
for _, r := range rs.items {
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
return out
}
// Cancel removes a reminder by id. A blank id or a missing id returns an error
// wrapping ErrInvalidRule or ErrNotFound respectively.
func (rs *ReminderStore) Cancel(ctx context.Context, id string) error {
if id == "" {
return fmt.Errorf("automations: cancel reminder: %w", ErrInvalidRule)
}
rs.mu.Lock()
defer rs.mu.Unlock()
if _, exists := rs.items[id]; !exists {
return fmt.Errorf("automations: cancel reminder %q: %w", id, ErrNotFound)
}
delete(rs.items, id)
rs.log.Info("reminder canceled", "id", id)
return nil
}
// Start launches the polling loop that fires due reminders. Start is idempotent;
// it derives the loop's cancellation from ctx.
func (rs *ReminderStore) Start(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
rs.mu.Lock()
if rs.started {
rs.mu.Unlock()
return nil
}
loopCtx, cancel := context.WithCancel(ctx)
rs.cancel = cancel
rs.done = make(chan struct{})
rs.started = true
rs.mu.Unlock()
go rs.loop(loopCtx)
rs.log.Info("reminder loop started")
return nil
}
// Stop stops the polling loop and waits (bounded by reminderStopWait) for it to
// exit. It is idempotent.
func (rs *ReminderStore) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
rs.mu.Lock()
if !rs.started {
rs.mu.Unlock()
return nil
}
rs.started = false
cancel := rs.cancel
rs.cancel = nil
done := rs.done
rs.mu.Unlock()
if cancel != nil {
cancel()
}
if done != nil {
select {
case <-done:
case <-ctx.Done():
return fmt.Errorf("automations: reminder stop: %w", ctx.Err())
case <-time.After(reminderStopWait):
rs.log.Warn("reminder stop timed out")
}
}
rs.log.Info("reminder loop stopped")
return nil
}
// loop is the polling goroutine. It owns the done channel and closes it on exit.
func (rs *ReminderStore) loop(ctx context.Context) {
defer func() {
rs.mu.Lock()
if rs.done != nil {
close(rs.done)
rs.done = nil
}
rs.mu.Unlock()
}()
ticker := time.NewTicker(rs.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
rs.fireDue(ctx)
}
}
}
// fireDue publishes every reminder whose At is at or before now and removes it
// from the store. Publishing happens outside the lock so the loop does not hold
// the mutex across the bus fan-out.
func (rs *ReminderStore) fireDue(ctx context.Context) {
now := rs.now()
var due []Reminder
rs.mu.Lock()
for id, r := range rs.items {
if !now.Before(r.At) {
due = append(due, r)
delete(rs.items, id)
}
}
rs.mu.Unlock()
for _, r := range due {
if err := rs.bus.Publish(ctx, SubjectTrigger, r); err != nil {
rs.log.Error("publish reminder", "id", r.ID, "err", err)
continue
}
rs.log.Info("reminder fired", "id", r.ID, "at", r.At, "message", r.Message)
}
}
@@ -0,0 +1,168 @@
package automations
import (
"context"
"errors"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
)
func TestReminder_AddListCancel(t *testing.T) {
t.Parallel()
rs, err := NewReminder(Config{}, Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("NewReminder: %v", err)
}
base := time.Now().Add(time.Hour)
r1 := Reminder{ID: "later", At: base.Add(time.Hour), Message: "b"}
r2 := Reminder{ID: "sooner", At: base, Message: "a"}
if err := rs.Add(context.Background(), r1); err != nil {
t.Fatalf("Add r1: %v", err)
}
if err := rs.Add(context.Background(), r2); err != nil {
t.Fatalf("Add r2: %v", err)
}
if err := rs.Add(context.Background(), r2); !errors.Is(err, ErrDuplicate) {
t.Fatalf("duplicate: err = %v, want ErrDuplicate", err)
}
list := rs.List()
if len(list) != 2 {
t.Fatalf("len(List) = %d, want 2", len(list))
}
// Sorted by At ascending.
if list[0].ID != "sooner" || list[1].ID != "later" {
t.Fatalf("not sorted by At: %+v", list)
}
if err := rs.Cancel(context.Background(), "sooner"); err != nil {
t.Fatalf("Cancel: %v", err)
}
if got := len(rs.List()); got != 1 {
t.Fatalf("len(List) after cancel = %d, want 1", got)
}
if err := rs.Cancel(context.Background(), "sooner"); !errors.Is(err, ErrNotFound) {
t.Fatalf("cancel missing: err = %v, want ErrNotFound", err)
}
if err := rs.Add(context.Background(), Reminder{ID: "", At: base}); !errors.Is(err, ErrInvalidRule) {
t.Fatalf("empty id: err = %v, want ErrInvalidRule", err)
}
if err := rs.Add(context.Background(), Reminder{ID: "x"}); !errors.Is(err, ErrInvalidRule) {
t.Fatalf("zero At: err = %v, want ErrInvalidRule", err)
}
}
func TestReminder_FiresOnDue(t *testing.T) {
t.Parallel()
b := bus.New()
defer b.Close()
got := make(chan bus.Event, 1)
unsub, err := b.Subscribe(context.Background(), SubjectTrigger, func(_ context.Context, ev bus.Event) error {
got <- ev
return nil
})
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer unsub()
rs, err := NewReminder(Config{ReminderInterval: 10 * time.Millisecond}, Deps{Bus: b})
if err != nil {
t.Fatalf("NewReminder: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
r := Reminder{ID: "wake", At: time.Now().Add(20 * time.Millisecond), Message: "time to focus"}
if err := rs.Add(ctx, r); err != nil {
t.Fatalf("Add: %v", err)
}
if err := rs.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
select {
case ev := <-got:
rem, ok := ev.Payload.(Reminder)
if !ok {
t.Fatalf("payload type = %T, want Reminder", ev.Payload)
}
if rem.ID != "wake" || rem.Message != "time to focus" {
t.Fatalf("reminder = %+v", rem)
}
case <-time.After(3 * time.Second):
t.Fatal("reminder did not fire")
}
if err := rs.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
}
func TestReminder_StartStopIdempotent(t *testing.T) {
t.Parallel()
rs, err := NewReminder(Config{}, Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("NewReminder: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := rs.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
if err := rs.Start(ctx); err != nil {
t.Fatalf("second Start: %v", err)
}
if err := rs.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
if err := rs.Stop(ctx); err != nil {
t.Fatalf("second Stop: %v", err)
}
}
func TestReminder_ConcurrentAddCancelList(t *testing.T) {
t.Parallel()
rs, err := NewReminder(Config{}, Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("NewReminder: %v", err)
}
const n = 50
done := make(chan struct{})
for i := 0; i < n; i++ {
go func(i int) {
defer func() { done <- struct{}{} }()
id := "r"
_ = rs.Add(context.Background(), Reminder{ID: id, At: time.Now().Add(time.Hour), Message: "m"})
_ = rs.List()
_ = rs.Cancel(context.Background(), id)
}(i)
}
for i := 0; i < n; i++ {
<-done
}
}
func TestService_ReminderLifecycle(t *testing.T) {
t.Parallel()
b := bus.New()
defer b.Close()
s, err := NewService(Config{}, Deps{Bus: b})
if err != nil {
t.Fatalf("NewService: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := s.AddReminder(ctx, Reminder{ID: "x", At: time.Now().Add(time.Hour), Message: "m"}); err != nil {
t.Fatalf("AddReminder: %v", err)
}
if got := len(s.Reminders()); got != 1 {
t.Fatalf("len(Reminders) = %d, want 1", got)
}
if err := s.CancelReminder(ctx, "x"); err != nil {
t.Fatalf("CancelReminder: %v", err)
}
}
+206
View File
@@ -0,0 +1,206 @@
package automations
import (
"context"
"fmt"
"log/slog"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
)
// TriggerKind classifies how a Rule fires.
type TriggerKind string
const (
// TriggerCron fires on a cron schedule (Expression is a cron spec).
TriggerCron TriggerKind = "cron"
// TriggerEvent fires on a bus event (Expression is a bus subject pattern).
TriggerEvent TriggerKind = "event"
)
// Rule is an automation rule: a trigger plus an action.
type Rule struct {
// ID uniquely identifies the rule.
ID string
// Name is a human-readable label.
Name string
// Trigger classifies how the rule fires (cron or event).
Trigger TriggerKind
// Expression is the cron spec when Trigger is cron, or a bus subject pattern
// when Trigger is event.
Expression string
// Action runs when the rule fires.
Action Action
}
// valid reports whether the rule carries every field required to fire.
func (r Rule) valid() bool {
return r.ID != "" && r.Action != nil && r.Expression != "" &&
(r.Trigger == TriggerCron || r.Trigger == TriggerEvent)
}
// Engine is the event-driven rule engine. It subscribes to bus subjects on behalf
// of event-triggered rules, matches incoming events to rules and runs the rule's
// action, publishing the outcome to the bus. Cron-triggered rules are handled by
// the Scheduler, not here. It is safe for concurrent use.
type Engine struct {
cfg Config
bus bus.Bus
log *slog.Logger
mu sync.RWMutex
rules map[string]Rule // rule id -> rule
unsub map[string]func() // rule id -> bus unsubscribe func
ctx context.Context
cancel context.CancelFunc
}
// NewEngine builds an event rule engine. The bus in deps is required.
func NewEngine(cfg Config, deps Deps) (*Engine, error) {
if deps.Bus == nil {
return nil, fmt.Errorf("automations: new engine: %w", ErrNilBus)
}
return &Engine{
cfg: cfg,
bus: deps.Bus,
log: sourcedLog(deps.Log),
rules: make(map[string]Rule),
unsub: make(map[string]func()),
}, nil
}
// AddEvent registers an event-triggered rule. It subscribes to r.Expression on
// the bus; events whose subject matches the pattern run the rule's action. It
// returns an error wrapping ErrInvalidRule when the rule is malformed or not an
// event rule, and ErrDuplicate when the id is already registered. ctx bounds the
// registration call; the subscription itself is tied to the engine's lifecycle.
func (e *Engine) AddEvent(ctx context.Context, r Rule) error {
if r.Trigger != TriggerEvent || !r.valid() {
return fmt.Errorf("automations: add event rule %q: %w", r.ID, ErrInvalidRule)
}
if err := e.checkCtx(ctx); err != nil {
return err
}
e.mu.Lock()
if _, exists := e.rules[r.ID]; exists {
e.mu.Unlock()
return fmt.Errorf("automations: add event rule %q: %w", r.ID, ErrDuplicate)
}
unsub, err := e.bus.Subscribe(context.Background(), r.Expression, func(_ context.Context, ev bus.Event) error {
e.dispatch(r, ev)
return nil
})
if err != nil {
e.mu.Unlock()
return fmt.Errorf("automations: add event rule %q: %w", r.ID, err)
}
e.rules[r.ID] = r
e.unsub[r.ID] = unsub
e.mu.Unlock()
e.log.Info("event rule added", "rule_id", r.ID, "sub", r.Expression)
return nil
}
// Rules returns a snapshot of the registered event rules, in insertion order.
func (e *Engine) Rules() []Rule {
e.mu.RLock()
defer e.mu.RUnlock()
out := make([]Rule, 0, len(e.rules))
for _, r := range e.rules {
out = append(out, r)
}
return out
}
// RemoveRule unsubscribes and removes the event rule with the given id. It
// returns an error wrapping ErrNotFound when no such rule exists.
func (e *Engine) RemoveRule(ctx context.Context, id string) error {
e.mu.Lock()
_, ok := e.rules[id]
unsub := e.unsub[id]
delete(e.rules, id)
delete(e.unsub, id)
e.mu.Unlock()
if !ok {
return fmt.Errorf("automations: remove rule %q: %w", id, ErrNotFound)
}
if unsub != nil {
unsub()
}
e.log.Info("event rule removed", "rule_id", id)
return nil
}
// Start activates in-flight action cancellation for the engine. It creates the
// cancellation context from ctx; cancelling ctx (or Stop) cancels running actions.
// Start is idempotent.
func (e *Engine) Start(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
e.mu.Lock()
defer e.mu.Unlock()
if e.cancel != nil {
return nil
}
e.ctx, e.cancel = context.WithCancel(ctx)
e.log.Info("event engine started")
return nil
}
// Stop cancels running actions, unsubscribes every rule and clears the registry.
// It is idempotent.
func (e *Engine) Stop(ctx context.Context) error {
e.mu.Lock()
if e.cancel != nil {
e.cancel()
e.cancel = nil
}
unsubs := e.unsub
e.rules = make(map[string]Rule)
e.unsub = make(map[string]func())
e.mu.Unlock()
for _, u := range unsubs {
if u != nil {
u()
}
}
e.log.Info("event engine stopped")
return nil
}
// dispatch runs the action for a matched event. It uses the engine's live context
// so Stop cancels in-flight actions, and it skips delivery when the engine is
// already stopped.
func (e *Engine) dispatch(r Rule, ev bus.Event) {
ctx := e.liveCtx()
if err := ctx.Err(); err != nil {
return
}
e.log.Info("event matched", "rule_id", r.ID, "subject", ev.Subject)
runAction(ctx, e.cfg, e.bus, e.log, "event", r.ID, r.Action, ev.Payload)
}
// liveCtx returns the engine's live context, or Background before Start.
func (e *Engine) liveCtx() context.Context {
e.mu.RLock()
defer e.mu.RUnlock()
if e.ctx != nil {
return e.ctx
}
return context.Background()
}
// checkCtx returns ctx.Err() wrapped when ctx is canceled. A nil ctx is treated as
// Background.
func (e *Engine) checkCtx(ctx context.Context) error {
if ctx == nil {
return nil
}
if err := ctx.Err(); err != nil {
return fmt.Errorf("automations: %w", err)
}
return nil
}
+233
View File
@@ -0,0 +1,233 @@
package automations
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
)
func TestEngine_EventMatchFiresOnlyMatchingRule(t *testing.T) {
t.Parallel()
b := bus.New()
defer b.Close()
eng, err := NewEngine(Config{}, Deps{Bus: b})
if err != nil {
t.Fatalf("NewEngine: %v", err)
}
var ranA, ranB atomic.Int32
actA, err := NewFuncAction("on_device", func(context.Context, any) (any, error) {
ranA.Add(1)
return nil, nil
})
if err != nil {
t.Fatalf("NewFuncAction A: %v", err)
}
actB, err := NewFuncAction("on_temp", func(context.Context, any) (any, error) {
ranB.Add(1)
return nil, nil
})
if err != nil {
t.Fatalf("NewFuncAction B: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := eng.AddEvent(ctx, Rule{ID: "r1", Trigger: TriggerEvent, Expression: "iot.device_update", Action: actA}); err != nil {
t.Fatalf("AddEvent r1: %v", err)
}
if err := eng.AddEvent(ctx, Rule{ID: "r2", Trigger: TriggerEvent, Expression: "iot.room_temperature", Action: actB}); err != nil {
t.Fatalf("AddEvent r2: %v", err)
}
if err := eng.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
if err := b.Publish(ctx, "iot.device_update", map[string]any{"value": 42}); err != nil {
t.Fatalf("Publish: %v", err)
}
waitFor(t, 2*time.Second, func() bool { return ranA.Load() > 0 })
if ranA.Load() != 1 {
t.Errorf("matching rule ran %d times, want 1", ranA.Load())
}
if ranB.Load() != 0 {
t.Errorf("non-matching rule ran %d times, want 0", ranB.Load())
}
if err := eng.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
}
func TestEngine_AddEventValidation(t *testing.T) {
t.Parallel()
eng, err := NewEngine(Config{}, Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("NewEngine: %v", err)
}
act, err := NewFuncAction("x", func(context.Context, any) (any, error) { return nil, nil })
if err != nil {
t.Fatalf("NewFuncAction: %v", err)
}
// Wrong trigger for an event rule.
err = eng.AddEvent(context.Background(), Rule{ID: "c", Trigger: TriggerCron, Expression: "*/5 * * * *", Action: act})
if !errors.Is(err, ErrInvalidRule) {
t.Fatalf("wrong trigger: err = %v, want ErrInvalidRule", err)
}
// Missing id.
err = eng.AddEvent(context.Background(), Rule{ID: "", Trigger: TriggerEvent, Expression: "iot.*", Action: act})
if !errors.Is(err, ErrInvalidRule) {
t.Fatalf("missing id: err = %v, want ErrInvalidRule", err)
}
// Nil action.
err = eng.AddEvent(context.Background(), Rule{ID: "ok", Trigger: TriggerEvent, Expression: "iot.*"})
if !errors.Is(err, ErrInvalidRule) {
t.Fatalf("nil action: err = %v, want ErrInvalidRule", err)
}
// Duplicate.
if err := eng.AddEvent(context.Background(), Rule{ID: "d", Trigger: TriggerEvent, Expression: "iot.*", Action: act}); err != nil {
t.Fatalf("AddEvent d: %v", err)
}
err = eng.AddEvent(context.Background(), Rule{ID: "d", Trigger: TriggerEvent, Expression: "iot.*", Action: act})
if !errors.Is(err, ErrDuplicate) {
t.Fatalf("duplicate: err = %v, want ErrDuplicate", err)
}
// Missing removal.
err = eng.RemoveRule(context.Background(), "nope")
if !errors.Is(err, ErrNotFound) {
t.Fatalf("remove missing: err = %v, want ErrNotFound", err)
}
}
func TestEngine_RemoveRuleStopsFiring(t *testing.T) {
t.Parallel()
b := bus.New()
defer b.Close()
eng, err := NewEngine(Config{}, Deps{Bus: b})
if err != nil {
t.Fatalf("NewEngine: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var ran atomic.Int32
act, err := NewFuncAction("x", func(context.Context, any) (any, error) { ran.Add(1); return nil, nil })
if err != nil {
t.Fatalf("NewFuncAction: %v", err)
}
if err := eng.AddEvent(ctx, Rule{ID: "r", Trigger: TriggerEvent, Expression: "iot.device_update", Action: act}); err != nil {
t.Fatalf("AddEvent: %v", err)
}
if err := eng.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
if err := b.Publish(ctx, "iot.device_update", nil); err != nil {
t.Fatalf("Publish: %v", err)
}
waitFor(t, 2*time.Second, func() bool { return ran.Load() > 0 })
if err := eng.RemoveRule(ctx, "r"); err != nil {
t.Fatalf("RemoveRule: %v", err)
}
// Give a removed subscription a chance to (incorrectly) receive the next event.
time.Sleep(50 * time.Millisecond)
before := ran.Load()
if err := b.Publish(ctx, "iot.device_update", nil); err != nil {
t.Fatalf("Publish 2: %v", err)
}
time.Sleep(50 * time.Millisecond)
if got := ran.Load(); got != before {
t.Fatalf("rule fired after removal: %d -> %d", before, got)
}
}
func TestEngine_RulesSnapshot(t *testing.T) {
t.Parallel()
eng, err := NewEngine(Config{}, Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("NewEngine: %v", err)
}
act, err := NewFuncAction("x", func(context.Context, any) (any, error) { return nil, nil })
if err != nil {
t.Fatalf("NewFuncAction: %v", err)
}
if err := eng.AddEvent(context.Background(), Rule{ID: "a", Trigger: TriggerEvent, Expression: "iot.*", Action: act}); err != nil {
t.Fatalf("AddEvent a: %v", err)
}
if err := eng.AddEvent(context.Background(), Rule{ID: "b", Trigger: TriggerEvent, Expression: "iot.temp", Action: act}); err != nil {
t.Fatalf("AddEvent b: %v", err)
}
rules := eng.Rules()
if len(rules) != 2 {
t.Fatalf("len(Rules) = %d, want 2", len(rules))
}
}
func TestEngine_ConcurrentAddRemoveAndPublish(t *testing.T) {
t.Parallel()
b := bus.New()
defer b.Close()
eng, err := NewEngine(Config{}, Deps{Bus: b})
if err != nil {
t.Fatalf("NewEngine: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
act, err := NewFuncAction("x", func(context.Context, any) (any, error) { return nil, nil })
if err != nil {
t.Fatalf("NewFuncAction: %v", err)
}
const n = 50
done := make(chan struct{})
for i := 0; i < n; i++ {
go func(i int) {
defer func() { done <- struct{}{} }()
id := "r"
_ = eng.AddEvent(ctx, Rule{ID: id, Trigger: TriggerEvent, Expression: "iot.*", Action: act})
_ = b.Publish(ctx, "iot.device_update", i)
_ = eng.RemoveRule(ctx, id)
}(i)
}
for i := 0; i < n; i++ {
<-done
}
}
func TestService_AddRuleRoutesCronAndRejectsInvalid(t *testing.T) {
t.Parallel()
s, err := NewService(Config{}, Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("NewService: %v", err)
}
act, err := NewFuncAction("cron", func(context.Context, any) (any, error) { return nil, nil })
if err != nil {
t.Fatalf("NewFuncAction: %v", err)
}
if err := s.AddRule(context.Background(), Rule{ID: "c1", Trigger: TriggerCron, Expression: "*/5 * * * *", Action: act}); err != nil {
t.Fatalf("valid cron rule: %v", err)
}
err = s.AddRule(context.Background(), Rule{ID: "c2", Trigger: TriggerCron, Expression: "not a cron", Action: act})
if !errors.Is(err, ErrInvalidCron) {
t.Fatalf("invalid cron rule: err = %v, want ErrInvalidCron", err)
}
if err := s.RemoveRule(context.Background(), "c1"); err != nil {
t.Fatalf("RemoveRule cron: %v", err)
}
}
func waitFor(t *testing.T, timeout time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
}
+208
View File
@@ -0,0 +1,208 @@
package automations
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"github.com/robfig/cron/v3"
)
// schedulerStopWait bounds how long Stop waits for in-flight schedule jobs to
// finish before giving up and returning. It prevents a blocked job from leaking
// the Stop caller.
const schedulerStopWait = 5 * time.Second
// ScheduleID aliases the cron entry id so callers only need this package's type.
type ScheduleID = cron.EntryID
// parserFor builds the cron spec parser matching cfg. When WithSeconds is set the
// parser accepts a leading seconds field (6-field specs); otherwise it accepts
// the standard 5-field format. Both accept descriptors such as "@every".
func parserFor(cfg Config) cron.Parser {
if cfg.WithSeconds {
return cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
}
return cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
}
// cronLogger adapts an *slog.Logger to the cron.Logger interface so cron's own
// operational messages carry the "services/automations" source tag.
type cronLogger struct {
log *slog.Logger
}
func (c cronLogger) Info(msg string, keysAndValues ...any) { c.log.Info(msg, keysAndValues...) }
func (c cronLogger) Error(err error, msg string, keysAndValues ...any) {
c.log.Error(msg, append([]any{"error", err}, keysAndValues...)...)
}
// Scheduler is the cron-driven automation scheduler. It wraps a robfig/cron/v3
// scheduler and runs a registered Action each time its cron expression fires,
// publishing the outcome to the bus. It is safe for concurrent use.
type Scheduler struct {
cfg Config
bus bus.Bus
log *slog.Logger
cron *cron.Cron
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
started bool
}
// New builds a Scheduler. The bus in deps is required; a nil bus returns an error
// wrapping ErrNilBus. cfg.Location defaults to time.Local.
func New(cfg Config, deps Deps) (*Scheduler, error) {
if deps.Bus == nil {
return nil, fmt.Errorf("automations: new scheduler: %w", ErrNilBus)
}
loc := cfg.Location
if loc == nil {
loc = time.Local
}
log := sourcedLog(deps.Log)
s := &Scheduler{
cfg: cfg,
bus: deps.Bus,
log: log,
cron: cron.New(
cron.WithLocation(loc),
cron.WithParser(parserFor(cfg)),
cron.WithLogger(cronLogger{log: log}),
),
}
return s, nil
}
// ValidateCron reports whether expr is a valid cron expression for this
// scheduler's configured parser. A parse failure is wrapped with ErrInvalidCron.
func (s *Scheduler) ValidateCron(expr string) error {
if _, err := parserFor(s.cfg).Parse(expr); err != nil {
return fmt.Errorf("automations: invalid cron %q: %w: %v", expr, ErrInvalidCron, err)
}
return nil
}
// AddSchedule registers action to run whenever cronExpr fires, returning its
// schedule id. The cron expression is validated against the scheduler's parser;
// an invalid expression returns an error wrapping ErrInvalidCron. ctx is used
// only to bound this call; it does not govern later fires which use the
// scheduler's own live context.
func (s *Scheduler) AddSchedule(ctx context.Context, cronExpr string, action Action) (ScheduleID, error) {
if action == nil {
return 0, fmt.Errorf("automations: add schedule: %w", ErrNilAction)
}
if _, err := parserFor(s.cfg).Parse(cronExpr); err != nil {
return 0, fmt.Errorf("automations: add schedule %q: %w: %v", cronExpr, ErrInvalidCron, err)
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return 0, fmt.Errorf("automations: add schedule %q: %w", cronExpr, err)
}
id, err := s.cron.AddFunc(cronExpr, func() { s.fire(cronExpr, action) })
if err != nil {
return 0, fmt.Errorf("automations: add schedule %q: %w: %v", cronExpr, ErrInvalidCron, err)
}
s.log.Info("schedule added", "expr", cronExpr, "action", action.Name(), "id", id)
return id, nil
}
// RemoveSchedule removes a previously added schedule by id. It is safe to call on
// an unknown id.
func (s *Scheduler) RemoveSchedule(id ScheduleID) {
s.cron.Remove(id)
s.log.Info("schedule removed", "id", id)
}
// Entries returns a snapshot of the registered schedules, ordered by the next run
// time. It is only populated once the scheduler has been started.
func (s *Scheduler) Entries() []cron.Entry {
return s.cron.Entries()
}
// Entry returns the schedule registered under id, or the zero Entry when the id
// is unknown.
func (s *Scheduler) Entry(id ScheduleID) cron.Entry {
return s.cron.Entry(id)
}
// Start begins firing registered schedules. Start derives the scheduler's live
// context from ctx; cancelling ctx (or calling Stop) stops the scheduler and
// cancels in-flight jobs. Start is idempotent.
func (s *Scheduler) Start(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
if s.started {
s.mu.Unlock()
return nil
}
s.ctx, s.cancel = context.WithCancel(ctx)
s.started = true
s.mu.Unlock()
s.cron.Start()
s.log.Info("scheduler started")
return nil
}
// Stop stops the scheduler, cancels in-flight jobs and waits (bounded by
// schedulerStopWait) for them to finish. It is idempotent.
func (s *Scheduler) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
s.mu.Lock()
if !s.started {
s.mu.Unlock()
return nil
}
s.started = false
if s.cancel != nil {
s.cancel()
s.cancel = nil
}
s.mu.Unlock()
done := s.cron.Stop()
select {
case <-done.Done():
s.log.Info("scheduler stopped")
return nil
case <-ctx.Done():
return fmt.Errorf("automations: scheduler stop: %w", ctx.Err())
case <-time.After(schedulerStopWait):
s.log.Warn("scheduler stop timed out")
return nil
}
}
// liveCtx returns the scheduler's live context, or Background before Start.
func (s *Scheduler) liveCtx() context.Context {
s.mu.RLock()
defer s.mu.RUnlock()
if s.ctx != nil {
return s.ctx
}
return context.Background()
}
// fire runs one scheduled action. It recovers panics so a misbehaving action
// cannot take down the cron scheduler goroutine, and reports its outcome to the
// bus through runAction.
func (s *Scheduler) fire(cronExpr string, action Action) {
defer func() {
if r := recover(); r != nil {
s.log.Error("schedule job panicked", "expr", cronExpr, "panic", r)
}
}()
runAction(s.liveCtx(), s.cfg, s.bus, s.log, "schedule", cronExpr, action, nil)
}
@@ -0,0 +1,165 @@
package automations
import (
"context"
"errors"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
)
func TestValidateCron(t *testing.T) {
t.Parallel()
s, err := New(Config{}, Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("New: %v", err)
}
tests := []struct {
name string
expr string
cfg Config
want bool
}{
{name: "standard 5-field", expr: "*/5 * * * *", want: true},
{name: "standard 5-field with names", expr: "0 9 * * MON", want: true},
{name: "descriptor every", expr: "@every 1h30m", want: true},
{name: "descriptor midnight", expr: "@midnight", want: true},
{name: "6-field rejected by default", expr: "* * * * * *", want: false},
{name: "6-field accepted with seconds", expr: "*/10 * * * * *", cfg: Config{WithSeconds: true}, want: true},
{name: "garbage rejected", expr: "not a cron", want: false},
{name: "empty rejected", expr: "", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ss := s
if tt.cfg.WithSeconds {
ss, err = New(tt.cfg, Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("New: %v", err)
}
}
err := ss.ValidateCron(tt.expr)
if got := err == nil; got != tt.want {
t.Fatalf("ValidateCron(%q) err = %v, wantValid=%v", tt.expr, err, tt.want)
}
})
}
}
func TestAddSchedule_InvalidCronRejected(t *testing.T) {
t.Parallel()
s, err := New(Config{}, Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("New: %v", err)
}
act, err := NewFuncAction("x", func(context.Context, any) (any, error) { return nil, nil })
if err != nil {
t.Fatalf("NewFuncAction: %v", err)
}
if _, err := s.AddSchedule(context.Background(), "not a cron", act); !errors.Is(err, ErrInvalidCron) {
t.Fatalf("err = %v, want ErrInvalidCron", err)
}
if _, err := s.AddSchedule(context.Background(), "* * * * * *", act); !errors.Is(err, ErrInvalidCron) {
t.Fatalf("6-field without seconds: err = %v, want ErrInvalidCron", err)
}
}
func TestScheduler_RejectsNilAction(t *testing.T) {
t.Parallel()
s, err := New(Config{}, Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := s.AddSchedule(context.Background(), "*/5 * * * *", nil); !errors.Is(err, ErrNilAction) {
t.Fatalf("err = %v, want ErrNilAction", err)
}
}
func TestScheduler_StartStopIdempotent(t *testing.T) {
t.Parallel()
s, err := New(Config{}, Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("New: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := s.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
if err := s.Start(ctx); err != nil {
t.Fatalf("second Start: %v", err)
}
if err := s.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
if err := s.Stop(ctx); err != nil {
t.Fatalf("second Stop: %v", err)
}
}
func TestScheduler_FiresOnDue(t *testing.T) {
t.Parallel()
b := bus.New()
defer b.Close()
got := make(chan bus.Event, 1)
unsub, err := b.Subscribe(context.Background(), SubjectResult, func(_ context.Context, ev bus.Event) error {
got <- ev
return nil
})
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer unsub()
s, err := New(Config{}, Deps{Bus: b})
if err != nil {
t.Fatalf("New: %v", err)
}
act, err := NewFuncAction("tick", func(context.Context, any) (any, error) { return "ok", nil })
if err != nil {
t.Fatalf("NewFuncAction: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if _, err := s.AddSchedule(ctx, "@every 50ms", act); err != nil {
t.Fatalf("AddSchedule: %v", err)
}
if err := s.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
select {
case ev := <-got:
res, ok := ev.Payload.(Result)
if !ok {
t.Fatalf("payload type = %T, want Result", ev.Payload)
}
if res.Kind != "schedule" || res.Action != "tick" || res.Output != "ok" {
t.Fatalf("result = %+v, want {schedule tick ok}", res)
}
case <-time.After(3 * time.Second):
t.Fatal("schedule did not fire")
}
if err := s.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
}
func TestScheduler_WithSecondsAcceptsSixField(t *testing.T) {
t.Parallel()
s, err := New(Config{WithSeconds: true}, Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("New: %v", err)
}
act, err := NewFuncAction("tick", func(context.Context, any) (any, error) { return nil, nil })
if err != nil {
t.Fatalf("NewFuncAction: %v", err)
}
if _, err := s.AddSchedule(context.Background(), "*/1 * * * * *", act); err != nil {
t.Fatalf("6-field with seconds rejected: %v", err)
}
}
+149
View File
@@ -0,0 +1,149 @@
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...)
}