# Copyright (c) 2025 Patrick Motsch """ Automation2 schedule scheduler. Starts/stops cron jobs for workflows with schedule entry points. """ import asyncio import logging from typing import Any, Dict from modules.shared.eventManagement import eventManager # Main loop reference for scheduling async work from job executor (may run in thread) _main_loop = None def set_main_loop(loop) -> None: global _main_loop _main_loop = loop from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import ( getGraphicalEditorInterface as getAutomation2Interface, getAllWorkflowsForScheduling, ) from modules.features.graphicalEditor.mainGraphicalEditor import getGraphicalEditorServices as getAutomation2Services from modules.features.graphicalEditor.entryPoints import find_invocation from modules.workflows.automation2.scheduleCron import parse_cron_to_kwargs def _cron_to_interval_seconds(cron: str): """ If cron represents a simple interval, return seconds. Otherwise None. E.g. "* * * * *" -> 60, "*/15 * * * *" -> 900, "*/30 * * * * *" -> 30. """ if not cron or not isinstance(cron, str): return None parts = cron.strip().split() if len(parts) == 5: minute, hour, day, month, dow = parts second = "0" elif len(parts) == 6: second, minute, hour, day, month, dow = parts else: return None # Interval minutes: */N * * * * if minute.startswith("*/") and hour == "*" and day == "*" and month == "*" and dow == "*": n = int(minute[2:]) if minute[2:].isdigit() else 0 if n > 0: return n * 60 # Every minute: * * * * * if minute == "*" and hour == "*" and day == "*" and month == "*" and dow == "*" and second == "0": return 60 # Interval hours: 0 */N * * * if minute == "0" and hour.startswith("*/") and day == "*" and month == "*" and dow == "*": n = int(hour[2:]) if hour[2:].isdigit() else 0 if n > 0: return n * 3600 # Interval seconds: */N * * * * * (6-field) if len(parts) == 6 and second.startswith("*/") and minute == "*" and hour == "*" and day == "*" and month == "*" and dow in ("*", "?"): n = int(second[2:]) if second[2:].isdigit() else 0 if n > 0: return n return None from modules.workflows.automation2.executionEngine import executeGraph from modules.workflows.automation2.runEnvelope import default_run_envelope, normalize_run_envelope logger = logging.getLogger(__name__) JOB_ID_PREFIX = "automation2." def _remove_all_automation2_schedule_jobs() -> None: """Remove all registered Automation2 schedule jobs from the scheduler.""" if not eventManager.scheduler: return for job in list(eventManager.scheduler.get_jobs()): jid = job.id if hasattr(job, "id") else str(job) if jid.startswith(JOB_ID_PREFIX): try: eventManager.remove(jid) except Exception as e: logger.debug("Could not remove job %s: %s", jid, e) def sync_automation2_schedule_events(event_user) -> Dict[str, Any]: """ Sync scheduler with all active Automation2 workflows that have schedule entry points. Registers cron jobs for each; removes jobs for workflows no longer in the list. """ if not event_user: logger.warning("Automation2 schedule: No event user, skipping sync") return {"synced": 0, "events": {}} _remove_all_automation2_schedule_jobs() items = getAllWorkflowsForScheduling() registered = {} logger.info( "Automation2 schedule: found %d workflow(s) with trigger.schedule and cron", len(items), ) for item in items: workflow_id = item.get("workflowId") mandate_id = item.get("mandateId") instance_id = item.get("featureInstanceId") entry_point_id = item.get("entryPointId") cron = item.get("cron") workflow = item.get("workflow") if not workflow_id or not instance_id or not cron: continue job_id = f"{JOB_ID_PREFIX}{workflow_id}" async_handler = _create_schedule_handler( workflow_id=workflow_id, mandate_id=mandate_id, instance_id=instance_id, entry_point_id=entry_point_id, workflow=workflow, event_user=event_user, ) # Sync wrapper: schedule async handler on main loop (job may run in executor thread) def sync_wrapper(): loop = _main_loop if loop and loop.is_running(): loop.call_soon_threadsafe( lambda: asyncio.ensure_future(async_handler(), loop=loop) ) else: # Fallback: run inline if no loop (shouldn't happen) try: asyncio.run(async_handler()) except RuntimeError: logger.warning("Automation2 schedule: could not run handler, no event loop") # Use IntervalTrigger for "every N minutes" - more reliable than CronTrigger interval_seconds = _cron_to_interval_seconds(cron) if interval_seconds is not None: eventManager.registerInterval( jobId=job_id, func=sync_wrapper, seconds=interval_seconds, replaceExisting=True, ) else: try: cron_kwargs = parse_cron_to_kwargs(cron) eventManager.registerCron( jobId=job_id, func=sync_wrapper, cronKwargs=cron_kwargs, replaceExisting=True, ) except ValueError as e: logger.warning("Workflow %s: invalid cron %r: %s", workflow_id, cron, e) continue registered[workflow_id] = job_id mode = "interval" if interval_seconds is not None else "cron" logger.info( "Automation2 schedule: registered %s for workflow %s (%s=%s)", job_id, workflow_id, mode, interval_seconds if interval_seconds is not None else cron, ) if not registered and items: logger.warning("Automation2 schedule: workflows found but none registered (check cron format)") elif not items: logger.info("Automation2 schedule: no workflows with trigger.schedule+cron (save workflow after selecting Zeitplan)") return {"synced": len(registered), "workflowsFound": len(items), "events": registered} def _create_schedule_handler( workflow_id: str, mandate_id: str, instance_id: str, entry_point_id: str, workflow: Dict[str, Any], event_user, ): """Create async handler for scheduled workflow execution.""" async def handler(): logger.info("Automation2 schedule: CRON FIRED for workflow %s", workflow_id) try: if not event_user: logger.error("Automation2 schedule: event user not available") return a2 = getAutomation2Interface(event_user, mandate_id, instance_id) wf = a2.getWorkflow(workflow_id) if not wf or not wf.get("graph"): logger.warning("Automation2 schedule: workflow %s not found or no graph", workflow_id) return if not wf.get("active", True): logger.info("Automation2 schedule: workflow %s inactive, skipping", workflow_id) return inv = find_invocation(wf, entry_point_id) if inv and (inv.get("kind") != "schedule" or not inv.get("enabled", True)): logger.info("Automation2 schedule: entry point %s disabled for workflow %s", entry_point_id, workflow_id) return # If inv not found but graph has trigger.schedule, proceed (invocations may not be synced) services = getAutomation2Services( event_user, mandateId=mandate_id, featureInstanceId=instance_id, ) from modules.workflows.processing.shared.methodDiscovery import discoverMethods discoverMethods(services) title = (inv or {}).get("title") or {} label = "" if isinstance(title, dict): label = title.get("en") or title.get("de") or "" elif isinstance(title, str): label = title run_env = default_run_envelope( "schedule", entry_point_id=entry_point_id, entry_point_label=label or None, ) run_env = normalize_run_envelope(run_env, user_id=str(event_user.id) if event_user else None) # userId=None so tasks are created unassigned and visible to all instance users result = await executeGraph( graph=wf["graph"], services=services, workflowId=workflow_id, instanceId=instance_id, userId=None, mandateId=mandate_id, automation2_interface=a2, run_envelope=run_env, ) logger.info( "Automation2 schedule: executed workflow %s success=%s paused=%s", workflow_id, result.get("success"), result.get("paused"), ) except Exception as e: logger.exception("Automation2 schedule: failed to execute workflow %s: %s", workflow_id, e) return handler def start(event_user) -> bool: """ Start Automation2 schedule scheduler and sync scheduled workflows. Registers callback so schedule is re-synced when workflows are created/updated/deleted. """ if not event_user: logger.warning("Automation2 schedule: No event user provided, skipping") return True try: eventManager.start() sync_automation2_schedule_events(event_user) logger.info("Automation2 schedule: sync complete") # Delayed sync (5s) in case DB was not ready at startup def do_delayed_sync(): import threading def _run(): import time time.sleep(5) try: sync_automation2_schedule_events(event_user) logger.info("Automation2 schedule: delayed sync done") except Exception as e: logger.warning("Automation2 schedule: delayed sync failed: %s", e) t = threading.Thread(target=_run, daemon=True) t.start() do_delayed_sync() def on_workflow_changed(_context=None): try: sync_automation2_schedule_events(event_user) logger.debug("Automation2 schedule: re-synced after workflow change") except Exception as e: logger.warning("Automation2 schedule: re-sync failed: %s", e) from modules.shared.callbackRegistry import callbackRegistry callbackRegistry.register("automation2.workflow.changed", on_workflow_changed) except Exception as e: logger.error("Automation2 schedule: Failed to start: %s", e) return False return True def stop(event_user) -> bool: """Stop Automation2 schedule scheduler (remove all schedule jobs).""" try: _remove_all_automation2_schedule_jobs() logger.info("Automation2 schedule: all jobs removed") except Exception as e: logger.warning("Automation2 schedule: error during stop: %s", e) return True