307 lines
12 KiB
Python
307 lines
12 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, Optional
|
|
|
|
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 {}
|
|
requestLang: Optional[str] = getattr(event_user, "language", None)
|
|
if isinstance(title, dict):
|
|
picked = (requestLang and title.get(requestLang)) or title.get("xx") or next(iter(title.values()), "")
|
|
label = str(picked) if picked else ""
|
|
elif isinstance(title, str):
|
|
label = title
|
|
else:
|
|
label = ""
|
|
|
|
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
|