gateway/modules/workflows/automation2/subAutomation2Schedule.py
2026-04-07 00:49:08 +02:00

304 lines
11 KiB
Python

# 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