Files
osmedeus/pkg/server/eventreceiver.go
T
j3ssie 777fb7c2bf chore: update ui build artifacts and refactor event trigger input syntax
- Update Next.js build IDs and chunk hashes in workflow upload static files
- Replace deprecated 'input' field syntax with new exports-style variables in event triggers (e.g., 'Target: event_data.value' instead of type/field/name)
- Simplify parameter override syntax in test workflows (remove nested 'default' structure)
2026-01-24 16:24:50 +08:00

649 lines
18 KiB
Go

package server
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/j3ssie/osmedeus/v5/internal/config"
"github.com/j3ssie/osmedeus/v5/internal/core"
"github.com/j3ssie/osmedeus/v5/internal/database"
"github.com/j3ssie/osmedeus/v5/internal/database/repository"
"github.com/j3ssie/osmedeus/v5/internal/executor"
oslogger "github.com/j3ssie/osmedeus/v5/internal/logger"
"github.com/j3ssie/osmedeus/v5/internal/parser"
"github.com/j3ssie/osmedeus/v5/internal/scheduler"
"github.com/j3ssie/osmedeus/v5/internal/terminal"
"github.com/j3ssie/osmedeus/v5/pkg/server/handlers"
"go.uber.org/zap"
)
// EventReceiver manages event-triggered workflows in server mode.
// It loads all workflows with event triggers at startup and executes
// them when matching events are received through the scheduler.
type EventReceiver struct {
scheduler *scheduler.Scheduler
loader *parser.Loader
config *config.Config
logger *zap.Logger
workflows []*core.Workflow
triggers []*RegisteredEventTrigger
mu sync.RWMutex
running bool
ctx context.Context
cancel context.CancelFunc
}
// RegisteredEventTrigger holds workflow and trigger info for event-triggered workflows
type RegisteredEventTrigger struct {
Workflow *core.Workflow
Trigger *core.Trigger
}
// NewEventReceiver creates a new event receiver for the server.
// It initializes the scheduler and workflow loader but does not start
// listening for events until Start() is called.
func NewEventReceiver(cfg *config.Config) (*EventReceiver, error) {
if cfg == nil {
return nil, fmt.Errorf("config is required")
}
sched, err := scheduler.NewScheduler()
if err != nil {
return nil, fmt.Errorf("failed to create scheduler: %w", err)
}
loader := parser.NewLoader(cfg.WorkflowsPath)
return &EventReceiver{
scheduler: sched,
loader: loader,
config: cfg,
logger: oslogger.Get(),
workflows: make([]*core.Workflow, 0),
triggers: make([]*RegisteredEventTrigger, 0),
}, nil
}
// Start loads all event-triggered workflows and begins listening for events.
// It scans the workflows directory, filters to only workflows with enabled
// event triggers, and registers them with the scheduler.
func (er *EventReceiver) Start(ctx context.Context) error {
er.mu.Lock()
defer er.mu.Unlock()
if er.running {
return nil
}
// Create cancellable context for workflow executions
er.ctx, er.cancel = context.WithCancel(ctx)
// Load all workflows
allWorkflows, err := er.loader.LoadAllWorkflows()
if err != nil {
er.logger.Warn("Failed to load some workflows", zap.Error(err))
// Continue with partial results
}
// Filter to only workflows with event triggers
var workflowCount, triggerCount int
for _, wf := range allWorkflows {
eventTriggers := wf.GetEventTriggers()
if len(eventTriggers) == 0 {
continue
}
er.workflows = append(er.workflows, wf)
workflowCount++
// Register each event trigger
for i := range eventTriggers {
trigger := &eventTriggers[i]
if err := er.scheduler.RegisterTrigger(wf, trigger); err != nil {
er.logger.Warn("Failed to register event trigger",
zap.String("workflow", wf.Name),
zap.String("trigger", trigger.Name),
zap.Error(err),
)
continue
}
er.triggers = append(er.triggers, &RegisteredEventTrigger{
Workflow: wf,
Trigger: trigger,
})
triggerCount++
er.logger.Debug("Registered event trigger",
zap.String("workflow", wf.Name),
zap.String("trigger", trigger.Name),
zap.String("topic", trigger.Event.Topic),
)
}
}
// Set handler for event triggers (use SetEventHandler to receive full event envelope)
er.scheduler.SetEventHandler(string(core.TriggerEvent), er.handleEventTriggerWithEnvelope)
// Set handler for cron triggers
er.scheduler.SetHandler(string(core.TriggerCron), er.handleCronTrigger)
// Set handler for watch triggers
er.scheduler.SetHandler(string(core.TriggerWatch), er.handleWatchTrigger)
// Start the scheduler
if err := er.scheduler.Start(); err != nil {
return fmt.Errorf("failed to start scheduler: %w", err)
}
er.running = true
er.logger.Debug("Event receiver started",
zap.Int("workflows", workflowCount),
zap.Int("triggers", triggerCount),
)
// Process any queued events from the database asynchronously
go er.processQueuedEvents()
return nil
}
// Stop gracefully stops the event receiver and its scheduler.
func (er *EventReceiver) Stop() error {
er.mu.Lock()
defer er.mu.Unlock()
if !er.running {
return nil
}
// Cancel any running workflow executions
if er.cancel != nil {
er.cancel()
}
// Stop the scheduler
if err := er.scheduler.Stop(); err != nil {
er.logger.Warn("Error stopping scheduler", zap.Error(err))
}
er.running = false
er.logger.Info("Event receiver stopped")
return nil
}
// handleEventTriggerWithEnvelope is called when an event matches a trigger.
// It receives the full event envelope JSON and resolved variables (from the new Vars syntax).
func (er *EventReceiver) handleEventTriggerWithEnvelope(workflow *core.Workflow, trigger *core.Trigger, input string, eventEnvelope string, resolvedVars map[string]string) error {
er.logger.Info("Event trigger fired",
zap.String("workflow", workflow.Name),
zap.String("trigger", trigger.Name),
zap.String("input", input),
)
er.logger.Debug("Event envelope received",
zap.String("event_envelope", eventEnvelope),
)
if resolvedVars != nil {
er.logger.Debug("Resolved vars from Vars syntax",
zap.Any("resolved_vars", resolvedVars),
)
}
// Print resolved variables to console
if len(resolvedVars) > 0 {
printer := terminal.NewPrinter()
printer.EventTriggerInfo(trigger.Name, workflow.Name, resolvedVars)
}
// Execute workflow in goroutine to not block the scheduler
go func() {
// Build params from trigger input
params := make(map[string]string)
// If this trigger is linked to a Schedule record, look it up to get Target/Params first
if trigger.ScheduleID != "" {
schedule, err := database.GetScheduleByID(er.ctx, trigger.ScheduleID)
if err != nil {
er.logger.Warn("Failed to load schedule for event trigger",
zap.String("schedule_id", trigger.ScheduleID),
zap.Error(err),
)
} else {
// Skip execution if schedule is disabled
if !schedule.IsEnabled {
er.logger.Info("Skipping disabled schedule",
zap.String("schedule_id", trigger.ScheduleID),
zap.String("workflow", workflow.Name),
)
return
}
// Use target from schedule (can be overridden by event input)
if schedule.Target != "" {
params["target"] = schedule.Target
}
// Use params from schedule
if schedule.Params != nil {
for k, v := range schedule.Params {
if s, ok := v.(string); ok {
params[k] = s
}
}
}
}
}
// New Vars syntax: set all resolved variables (overrides schedule params)
if resolvedVars != nil {
for name, value := range resolvedVars {
params[name] = value
}
// Normalize "Target" (capital T) to "target" (lowercase) for builtin variable derivation
// This allows event input `Target: event_data.url` to behave like CLI `-t` flag
if targetVal, hasTarget := resolvedVars["Target"]; hasTarget && targetVal != "" {
if _, hasLowercase := params["target"]; !hasLowercase {
params["target"] = targetVal
}
}
} else {
// Legacy syntax: set the input parameter using the trigger's input.name field
if trigger.Input.Name != "" {
params[trigger.Input.Name] = input
}
// Default to "target" if no input name specified
if input != "" && trigger.Input.Name == "" {
params["target"] = input
}
}
// Pass the event envelope as a special parameter
if eventEnvelope != "" {
params[scheduler.EventEnvelopeKey] = eventEnvelope
}
// Create executor with server mode
exec := executor.NewExecutor()
exec.SetServerMode(true)
exec.SetSchedulerInvocation(true) // Mark as scheduler invocation
exec.SetLoader(er.loader)
var err error
var result *core.WorkflowResult
if workflow.IsFlow() {
result, err = exec.ExecuteFlow(er.ctx, workflow, params, er.config)
} else {
result, err = exec.ExecuteModule(er.ctx, workflow, params, er.config)
}
if err != nil {
er.logger.Error("Event-triggered workflow failed",
zap.String("workflow", workflow.Name),
zap.String("trigger", trigger.Name),
zap.Error(err),
)
return
}
er.logger.Info("Event-triggered workflow completed",
zap.String("workflow", workflow.Name),
zap.String("status", string(result.Status)),
zap.Duration("duration", result.EndTime.Sub(result.StartTime)),
)
}()
return nil
}
// handleCronTrigger is called when a cron trigger fires.
func (er *EventReceiver) handleCronTrigger(workflow *core.Workflow, trigger *core.Trigger, input string) error {
er.logger.Info("Cron trigger fired",
zap.String("workflow", workflow.Name),
zap.String("trigger", trigger.Name),
)
go func() {
params := make(map[string]string)
// If this trigger is linked to a Schedule record, look it up to get Target/Params
if trigger.ScheduleID != "" {
schedule, err := database.GetScheduleByID(er.ctx, trigger.ScheduleID)
if err != nil {
er.logger.Warn("Failed to load schedule for cron trigger",
zap.String("schedule_id", trigger.ScheduleID),
zap.Error(err),
)
} else {
// Skip execution if schedule is disabled
if !schedule.IsEnabled {
er.logger.Info("Skipping disabled schedule",
zap.String("schedule_id", trigger.ScheduleID),
zap.String("workflow", workflow.Name),
)
return
}
// Use target from schedule
if schedule.Target != "" {
params["target"] = schedule.Target
}
// Use params from schedule
if schedule.Params != nil {
for k, v := range schedule.Params {
if s, ok := v.(string); ok {
params[k] = s
}
}
}
er.logger.Debug("Loaded schedule params for cron trigger",
zap.String("schedule_id", trigger.ScheduleID),
zap.String("target", schedule.Target),
zap.Int("param_count", len(schedule.Params)),
)
}
}
// Set input parameter if specified (can override schedule params)
if trigger.Input.Name != "" && input != "" {
params[trigger.Input.Name] = input
}
exec := executor.NewExecutor()
exec.SetServerMode(true)
exec.SetSchedulerInvocation(true)
exec.SetLoader(er.loader)
var err error
var result *core.WorkflowResult
if workflow.IsFlow() {
result, err = exec.ExecuteFlow(er.ctx, workflow, params, er.config)
} else {
result, err = exec.ExecuteModule(er.ctx, workflow, params, er.config)
}
if err != nil {
er.logger.Error("Cron-triggered workflow failed",
zap.String("workflow", workflow.Name),
zap.String("trigger", trigger.Name),
zap.Error(err),
)
return
}
er.logger.Info("Cron-triggered workflow completed",
zap.String("workflow", workflow.Name),
zap.String("status", string(result.Status)),
zap.Duration("duration", result.EndTime.Sub(result.StartTime)),
)
}()
return nil
}
// handleWatchTrigger is called when a file watch trigger fires.
func (er *EventReceiver) handleWatchTrigger(workflow *core.Workflow, trigger *core.Trigger, input string) error {
er.logger.Info("Watch trigger fired",
zap.String("workflow", workflow.Name),
zap.String("trigger", trigger.Name),
zap.String("path", trigger.Path),
)
go func() {
params := make(map[string]string)
// If this trigger is linked to a Schedule record, look it up to get Target/Params
if trigger.ScheduleID != "" {
schedule, err := database.GetScheduleByID(er.ctx, trigger.ScheduleID)
if err != nil {
er.logger.Warn("Failed to load schedule for watch trigger",
zap.String("schedule_id", trigger.ScheduleID),
zap.Error(err),
)
} else {
// Skip execution if schedule is disabled
if !schedule.IsEnabled {
er.logger.Info("Skipping disabled schedule",
zap.String("schedule_id", trigger.ScheduleID),
zap.String("workflow", workflow.Name),
)
return
}
// Use target from schedule
if schedule.Target != "" {
params["target"] = schedule.Target
}
// Use params from schedule
if schedule.Params != nil {
for k, v := range schedule.Params {
if s, ok := v.(string); ok {
params[k] = s
}
}
}
}
}
// Set input parameter if specified (can override schedule params)
if trigger.Input.Name != "" && input != "" {
params[trigger.Input.Name] = input
}
exec := executor.NewExecutor()
exec.SetServerMode(true)
exec.SetSchedulerInvocation(true)
exec.SetLoader(er.loader)
var err error
var result *core.WorkflowResult
if workflow.IsFlow() {
result, err = exec.ExecuteFlow(er.ctx, workflow, params, er.config)
} else {
result, err = exec.ExecuteModule(er.ctx, workflow, params, er.config)
}
if err != nil {
er.logger.Error("Watch-triggered workflow failed",
zap.String("workflow", workflow.Name),
zap.String("trigger", trigger.Name),
zap.Error(err),
)
return
}
er.logger.Info("Watch-triggered workflow completed",
zap.String("workflow", workflow.Name),
zap.String("status", string(result.Status)),
zap.Duration("duration", result.EndTime.Sub(result.StartTime)),
)
}()
return nil
}
// GetScheduler returns the underlying scheduler instance.
func (er *EventReceiver) GetScheduler() *scheduler.Scheduler {
return er.scheduler
}
// GetRegisteredWorkflows returns the list of registered event-triggered workflows.
func (er *EventReceiver) GetRegisteredWorkflows() []*core.Workflow {
er.mu.RLock()
defer er.mu.RUnlock()
return er.workflows
}
// GetRegisteredTriggers returns the list of registered event triggers.
func (er *EventReceiver) GetRegisteredTriggers() []*RegisteredEventTrigger {
er.mu.RLock()
defer er.mu.RUnlock()
return er.triggers
}
// IsRunning returns true if the event receiver is currently running.
func (er *EventReceiver) IsRunning() bool {
er.mu.RLock()
defer er.mu.RUnlock()
return er.running
}
// EmitEvent emits an event to trigger matching workflows.
// This is used by the API endpoint to trigger workflows via events.
func (er *EventReceiver) EmitEvent(event *core.Event) error {
if !er.running {
return fmt.Errorf("event receiver not running")
}
return er.scheduler.EmitEvent(event)
}
// GetRegisteredTriggersInfo returns trigger information for the API.
// This implements the EventReceiverProvider interface method.
func (er *EventReceiver) GetRegisteredTriggersInfo() []*handlers.EventReceiverTriggerInfo {
er.mu.RLock()
defer er.mu.RUnlock()
result := make([]*handlers.EventReceiverTriggerInfo, 0, len(er.triggers))
for _, t := range er.triggers {
topic := ""
if t.Trigger.Event != nil {
topic = t.Trigger.Event.Topic
}
result = append(result, &handlers.EventReceiverTriggerInfo{
WorkflowName: t.Workflow.Name,
TriggerName: t.Trigger.Name,
Type: string(t.Trigger.On),
Topic: topic,
Enabled: t.Trigger.Enabled,
})
}
return result
}
// GetWorkflowLoader returns the workflow loader for dynamic schedule registration.
// This implements the EventReceiverProvider interface method.
func (er *EventReceiver) GetWorkflowLoader() handlers.WorkflowLoader {
return er.loader
}
// RegisterSchedule dynamically registers a trigger with the running scheduler.
// This implements the EventReceiverProvider interface method.
func (er *EventReceiver) RegisterSchedule(workflow *core.Workflow, trigger *core.Trigger) error {
if !er.running {
return fmt.Errorf("event receiver not running")
}
err := er.scheduler.RegisterTrigger(workflow, trigger)
if err != nil {
return err
}
// Track the trigger
er.mu.Lock()
er.triggers = append(er.triggers, &RegisteredEventTrigger{
Workflow: workflow,
Trigger: trigger,
})
er.mu.Unlock()
er.logger.Info("Dynamically registered trigger",
zap.String("workflow", workflow.Name),
zap.String("trigger", trigger.Name),
zap.String("type", string(trigger.On)),
)
return nil
}
// processQueuedEvents processes any unprocessed events from the database.
// This is called asynchronously when the event receiver starts to handle
// events that were queued while the server was unavailable.
func (er *EventReceiver) processQueuedEvents() {
db := database.GetDB()
if db == nil {
er.logger.Debug("Database not available, skipping queued event processing")
return
}
repo := repository.NewEventLogRepository(db)
ctx := er.ctx
// Fetch unprocessed events
events, err := repo.ListUnprocessed(ctx, 100)
if err != nil {
er.logger.Warn("Failed to fetch unprocessed events", zap.Error(err))
return
}
if len(events) == 0 {
er.logger.Debug("No queued events to process")
return
}
er.logger.Info("Processing queued events", zap.Int("count", len(events)))
processedCount := 0
for _, eventLog := range events {
// Convert EventLog to core.Event
event := &core.Event{
Topic: eventLog.Topic,
ID: eventLog.EventID,
Name: eventLog.Name,
Source: eventLog.Source,
DataType: eventLog.DataType,
Data: eventLog.Data,
Timestamp: eventLog.CreatedAt,
}
// Parse the data into ParsedData
if eventLog.Data != "" {
var parsedData map[string]interface{}
if err := json.Unmarshal([]byte(eventLog.Data), &parsedData); err == nil {
event.ParsedData = parsedData
} else {
// If not valid JSON, wrap the value
event.ParsedData = map[string]interface{}{"value": eventLog.Data}
}
}
// Emit the event to trigger matching workflows
emitErr := er.scheduler.EmitEvent(event)
// Mark as processed (even if emit failed, to avoid retrying forever)
errMsg := ""
if emitErr != nil {
errMsg = emitErr.Error()
er.logger.Warn("Failed to emit queued event",
zap.Int64("id", eventLog.ID),
zap.String("topic", eventLog.Topic),
zap.Error(emitErr),
)
} else {
processedCount++
er.logger.Debug("Emitted queued event",
zap.Int64("id", eventLog.ID),
zap.String("topic", eventLog.Topic),
)
}
if markErr := repo.MarkProcessed(ctx, eventLog.ID, errMsg); markErr != nil {
er.logger.Warn("Failed to mark event as processed",
zap.Int64("id", eventLog.ID),
zap.Error(markErr),
)
}
// Small delay to avoid overwhelming the scheduler
time.Sleep(50 * time.Millisecond)
}
er.logger.Info("Finished processing queued events",
zap.Int("processed", processedCount),
zap.Int("total", len(events)),
)
}