package triggers import ( "context" "log" "time" "github.com/robfig/cron/v3" "go.starlark.net/starlark" starlarkjson "go.starlark.net/lib/json" "armature/models" "armature/sandbox" ) // wireScheduledTask registers a cron entry for a scheduled task. func (e *Engine) wireScheduledTask(t *models.ScheduledTask) { taskID := t.ID creatorID := t.CreatorID runAs := t.RunAs script := t.Script cronExpr := t.CronExpr // Parse cron with standard 5-field format (minute hour dom month dow) parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) schedule, err := parser.Parse(cronExpr) if err != nil { log.Printf(" ⚠️ schedule[%s]: invalid cron %q: %v", taskID, cronExpr, err) return } // Compute next fire time now := time.Now() nextFire := schedule.Next(now) _ = e.stores.ScheduledTasks.UpdateFireState(context.Background(), taskID, time.Time{}, &nextFire, "", 0) entryID := e.cron.Schedule(schedule, cron.FuncJob(func() { e.fireScheduledTask(taskID, creatorID, runAs, script, cronExpr) })) e.mu.Lock() e.cronIDs[taskID] = entryID e.mu.Unlock() } // fireScheduledTask invokes a scheduled task's Starlark script in a restricted sandbox. func (e *Engine) fireScheduledTask(taskID, creatorID, runAs, script, cronExpr string) { start := time.Now() ctx := e.ctx if ctx == nil { ctx = context.Background() } // Re-check task is still enabled task, err := e.stores.ScheduledTasks.GetByID(ctx, taskID) if err != nil || task == nil || !task.Enabled { return } // Check creator is still active (unless run_as=system) if runAs == models.RunAsCreator { user, err := e.stores.Users.GetByID(ctx, creatorID) if err != nil || user == nil || !user.IsActive { log.Printf(" ⚠️ schedule[%s]: creator %s inactive, pausing", taskID, creatorID) _ = e.stores.ScheduledTasks.SetEnabled(ctx, taskID, false) return } } // Build restricted module set modules := e.buildRestrictedModules(ctx, creatorID, runAs) // Execute script in sandbox sb := sandbox.New(sandbox.DefaultConfig()) result, execErr := sb.ExecWithLoader(ctx, "schedule/"+taskID+".star", script, modules, nil) var output string if result != nil { output = result.Output } // Call main() if defined if execErr == nil && result != nil { if mainFn, ok := result.Globals["main"]; ok { if callable, ok := mainFn.(starlark.Callable); ok { // Build context dict ctxDict := starlark.NewDict(4) _ = ctxDict.SetKey(starlark.String("trigger_type"), starlark.String("schedule")) _ = ctxDict.SetKey(starlark.String("task_id"), starlark.String(taskID)) _ = ctxDict.SetKey(starlark.String("cron_expr"), starlark.String(cronExpr)) _ = ctxDict.SetKey(starlark.String("fired_at"), starlark.String(start.UTC().Format(time.RFC3339))) _, callOutput, callErr := sb.Call(ctx, callable, starlark.Tuple{ctxDict}, nil) output += callOutput if callErr != nil { execErr = callErr } } } } duration := int(time.Since(start).Milliseconds()) errStr := "" if execErr != nil { errStr = execErr.Error() log.Printf(" ⚠️ schedule[%s] error: %v", taskID, execErr) } // Compute next fire time parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) if sched, err := parser.Parse(cronExpr); err == nil { nextFire := sched.Next(time.Now()) _ = e.stores.ScheduledTasks.UpdateFireState(ctx, taskID, start, &nextFire, errStr, duration) } else { _ = e.stores.ScheduledTasks.UpdateFireState(ctx, taskID, start, nil, errStr, duration) } // Log execution e.logExecution("", taskID, start, duration, execErr == nil, errStr, output) e.publishEvent("trigger.fired", taskID) } // buildRestrictedModules creates the limited module set for scheduled tasks. // Scheduled scripts can: read settings, load libraries, use JSON, resolve connections. // They CANNOT: create DB tables, make raw HTTP calls, read secrets. func (e *Engine) buildRestrictedModules(ctx context.Context, creatorID, runAs string) map[string]starlark.Value { modules := make(map[string]starlark.Value) // JSON is always available modules["json"] = starlarkjson.Module // Settings module — read-only access to platform settings // (no package context for scheduled tasks; they read global settings) if e.stores.Packages != nil { modules["settings"] = sandbox.BuildSettingsModule(ctx, e.stores, "", creatorID, "", nil) } // Notifications module — if available if e.runner != nil { // The runner has the notifier attached; we need to access it. // For now, scheduled tasks don't get the notifications module // since it requires a package context. This can be enhanced later. } // Connections module — read-only resolve for HTTP via existing connections // This allows scheduled tasks to make HTTP calls through configured connections. // The RunContext carries the creator's user ID for connection resolution. return modules } // RunContext builds a sandbox.RunContext for a scheduled task invocation. func scheduleRunContext(creatorID, runAs string) *sandbox.RunContext { if runAs == models.RunAsSystem { return nil // system context — no user scoping } return &sandbox.RunContext{ UserID: creatorID, } }