685 lines
26 KiB
Python
685 lines
26 KiB
Python
"""Workflow execution engine — step processing, conditions, approvals, wait/resume.
|
|
|
|
Processes workflow instances by evaluating steps sequentially.
|
|
Supports step types: action, approval, notification, condition, wait,
|
|
http, mail, calendar, dms, search, agent, crm, event, webhook.
|
|
|
|
G-RUN: Durable/resumable with resume_at, step_state, lock_owner.
|
|
G-RETRY: Retry with backoff for failed steps.
|
|
G-IDEMP: Idempotency key for side-effect steps.
|
|
G-LOG: Execution log per step (input, output, duration, status).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
import uuid
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.event_bus import get_event_bus
|
|
from app.core.notifications import post_system_message
|
|
from app.models.workflow import Workflow, WorkflowInstance
|
|
from app.services.workflow_service import (
|
|
_instance_to_dict,
|
|
_log_step_history,
|
|
create_instance,
|
|
find_workflows_for_event,
|
|
)
|
|
from app.workflows.step_handlers import StepResult, get_step_handler
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WorkflowEngine:
|
|
"""Processes workflow instances through their defined steps.
|
|
|
|
Step types (built-in):
|
|
- action: Executes a configured action (e.g. create entity, send notification)
|
|
- approval: Pauses and waits for user approve/reject via API
|
|
- notification: Sends a notification to specified users
|
|
- condition: Evaluates a condition and branches accordingly
|
|
- wait: Pauses for a duration or until a specific time (G-WAIT)
|
|
- http: Sends an HTTP request with SSRF protection (G-HTTP)
|
|
- mail: Sends an email via the mail plugin (G-MAIL)
|
|
- calendar: Creates/updates/deletes calendar events (G-CAL)
|
|
- dms: Interacts with the document management system (G-DMS)
|
|
- search: Runs a unified search query (G-SEARCH)
|
|
- agent: Invokes an autonomous AI agent (G-AGENT)
|
|
- crm: Creates/updates/deletes contacts and companies (G-CRM)
|
|
- event: Publishes an event to the event bus (G-EVT)
|
|
- webhook: Sends an outgoing webhook (G-WEB)
|
|
"""
|
|
|
|
def __init__(self, db: AsyncSession, tenant_id: uuid.UUID):
|
|
self.db = db
|
|
self.tenant_id = tenant_id
|
|
|
|
async def process_step(self, instance: WorkflowInstance) -> dict[str, Any]:
|
|
"""Process the current step of a workflow instance.
|
|
|
|
For action/notification/condition/wait/http/mail/etc steps: executes and advances.
|
|
For approval steps: sets status to in_progress and waits.
|
|
For wait steps: sets resume_at and pauses.
|
|
Returns the updated instance dict.
|
|
"""
|
|
wf_result = await self.db.execute(
|
|
select(Workflow).where(Workflow.id == instance.workflow_id)
|
|
)
|
|
workflow = wf_result.scalar_one_or_none()
|
|
if workflow is None:
|
|
return {"error": "Workflow not found", "status_code": 404}
|
|
|
|
steps = workflow.steps or []
|
|
if instance.current_step_index >= len(steps):
|
|
instance.status = "completed"
|
|
instance.completed_at = datetime.now(UTC)
|
|
await self.db.flush()
|
|
|
|
# Publish workflow.completed event
|
|
event_bus = get_event_bus()
|
|
await event_bus.publish('workflow.completed', {
|
|
'workflow_id': str(instance.workflow_id),
|
|
'instance_id': str(instance.id),
|
|
'tenant_id': str(self.tenant_id),
|
|
'initiated_by': str(instance.initiated_by) if instance.initiated_by else None,
|
|
})
|
|
|
|
# Post workflow completion to Communication (G-WORK)
|
|
try:
|
|
from app.plugins.builtins.contracts import get_contract_registry
|
|
komm = get_contract_registry().get("kommunikation")
|
|
if komm and instance.initiated_by:
|
|
room_title = f"Workflow: {workflow.name if hasattr(workflow, 'name') else str(instance.workflow_id)}"
|
|
conv_id = await komm.find_locked_room_id(
|
|
db=self.db,
|
|
tenant_id=self.tenant_id,
|
|
plugin_name="workflow",
|
|
title=room_title,
|
|
)
|
|
if not conv_id:
|
|
room = await komm.create_plugin_room(
|
|
db=self.db,
|
|
tenant_id=self.tenant_id,
|
|
user_id=instance.initiated_by,
|
|
plugin_name="workflow",
|
|
title=room_title,
|
|
participant_type="workflow",
|
|
)
|
|
conv_id = uuid.UUID(room["conversation_id"])
|
|
await komm.send_message(
|
|
db=self.db,
|
|
tenant_id=self.tenant_id,
|
|
conversation_id=conv_id,
|
|
sender_id=instance.workflow_id,
|
|
sender_type="system",
|
|
content=f"Workflow completed: {instance.status}",
|
|
content_format="text",
|
|
blocks=[
|
|
{
|
|
"block_type": "action_card",
|
|
"block_data": {
|
|
"title": f"Workflow Result: {instance.status}",
|
|
"description": f"Workflow instance {str(instance.id)[:8]} completed successfully",
|
|
"actions": [
|
|
{"label": "View Details", "action": "view_workflow_instance", "data": {"instance_id": str(instance.id)}},
|
|
],
|
|
},
|
|
"sort_order": 0,
|
|
}
|
|
],
|
|
metadata={"workflow_id": str(instance.workflow_id), "instance_id": str(instance.id), "status": instance.status},
|
|
)
|
|
await self.db.flush()
|
|
except Exception:
|
|
logger.warning("Failed to post workflow result to communication", exc_info=True)
|
|
|
|
return _instance_to_dict(instance)
|
|
|
|
step = steps[instance.current_step_index]
|
|
step_type = step.get("type", "action")
|
|
|
|
# Log step entry (G-LOG)
|
|
step_start_time = time.monotonic()
|
|
await _log_step_history(
|
|
self.db,
|
|
self.tenant_id,
|
|
instance.id,
|
|
step_index=instance.current_step_index,
|
|
step_type=step_type,
|
|
action="processing",
|
|
details={"step_name": step.get("name"), "config": step.get("config", {})},
|
|
)
|
|
|
|
# Approval steps pause and wait for user input
|
|
if step_type == "approval":
|
|
if instance.status == "pending":
|
|
instance.status = "in_progress"
|
|
instance.resume_reason = "approval"
|
|
await self.db.flush()
|
|
return _instance_to_dict(instance)
|
|
|
|
# Try registered step handlers first (new G step types)
|
|
handler = get_step_handler(step_type)
|
|
if handler is not None:
|
|
return await self._process_with_handler(
|
|
instance, step, steps, handler, step_start_time
|
|
)
|
|
|
|
# Legacy step types (action, notification, condition)
|
|
if step_type == "notification":
|
|
return await self._process_notification(instance, step)
|
|
elif step_type == "condition":
|
|
return await self._process_condition(instance, step, steps)
|
|
elif step_type == "action":
|
|
return await self._process_action(instance, step, steps)
|
|
else:
|
|
return {"error": f"Unknown step type: {step_type}", "status_code": 400}
|
|
|
|
async def _process_with_handler(
|
|
self,
|
|
instance: WorkflowInstance,
|
|
step: dict,
|
|
steps: list,
|
|
handler: Any,
|
|
step_start_time: float,
|
|
) -> dict[str, Any]:
|
|
"""Execute a step using a registered step handler (G step types)."""
|
|
step_type = step.get("type", "action")
|
|
|
|
# ── Decision Guard: check if action requires human review (Punkt 9) ──
|
|
from app.workflows.decision_guard import check_decision_guard
|
|
step_config = step.get("config", {})
|
|
action_name = step_config.get("action", step_type)
|
|
guard_result = await check_decision_guard(
|
|
db=self.db,
|
|
tenant_id=self.tenant_id,
|
|
instance_id=instance.id,
|
|
step_config=step_config,
|
|
action=action_name,
|
|
)
|
|
if not guard_result["allowed"]:
|
|
# Guard blocks — create ApprovalRequest and pause workflow
|
|
try:
|
|
from app.core.approval import create_approval_request
|
|
approval = await create_approval_request(
|
|
db=self.db,
|
|
tenant_id=self.tenant_id,
|
|
entity_type="workflow_instance",
|
|
entity_id=instance.id,
|
|
action=f"decision_guard:{action_name}",
|
|
requested_by=instance.created_by if hasattr(instance, "created_by") else None,
|
|
requested_by_type="system",
|
|
)
|
|
await self.db.flush()
|
|
return {
|
|
"status": "waiting_for_approval",
|
|
"guard": guard_result,
|
|
"approval_id": str(approval.id),
|
|
"step_index": instance.current_step_index,
|
|
"message": guard_result.get("reason", "Human review required"),
|
|
}
|
|
except Exception as e:
|
|
logger.warning("Failed to create approval request for decision guard: %s", e)
|
|
# Fallback: just pause without approval
|
|
instance.status = "in_progress"
|
|
instance.resume_reason = "decision_guard"
|
|
await self.db.flush()
|
|
return {
|
|
"status": "waiting_for_approval",
|
|
"guard": guard_result,
|
|
"step_index": instance.current_step_index,
|
|
"message": guard_result.get("reason", "Human review required"),
|
|
}
|
|
|
|
try:
|
|
result: StepResult = await handler(
|
|
self.db,
|
|
self.tenant_id,
|
|
instance,
|
|
step,
|
|
)
|
|
except Exception as e:
|
|
logger.exception("Step handler %s failed", step_type)
|
|
result = StepResult(error=str(e), abort=True)
|
|
|
|
duration_ms = int((time.monotonic() - step_start_time) * 1000)
|
|
|
|
# Handle abort
|
|
if result.abort:
|
|
instance.status = "failed"
|
|
instance.error_message = result.error or "Unknown error"
|
|
await _log_step_history(
|
|
self.db,
|
|
self.tenant_id,
|
|
instance.id,
|
|
step_index=instance.current_step_index,
|
|
step_type=step_type,
|
|
action="failed",
|
|
details={
|
|
"error": result.error,
|
|
"duration_ms": duration_ms,
|
|
"output": result.output,
|
|
},
|
|
)
|
|
await self.db.flush()
|
|
return _instance_to_dict(instance)
|
|
|
|
# Handle wait/resume (G-WAIT)
|
|
if result.wait_until is not None:
|
|
instance.status = "waiting"
|
|
instance.resume_at = result.wait_until
|
|
instance.resume_reason = result.wait_reason or "wait"
|
|
await _log_step_history(
|
|
self.db,
|
|
self.tenant_id,
|
|
instance.id,
|
|
step_index=instance.current_step_index,
|
|
step_type=step_type,
|
|
action="waiting",
|
|
details={
|
|
"resume_at": result.wait_until.isoformat(),
|
|
"reason": result.wait_reason,
|
|
"duration_ms": duration_ms,
|
|
},
|
|
)
|
|
await self.db.flush()
|
|
return _instance_to_dict(instance)
|
|
|
|
# Handle error (non-abort — retryable)
|
|
if result.error:
|
|
instance.retry_count += 1
|
|
if instance.retry_count >= instance.max_retries:
|
|
instance.status = "failed"
|
|
instance.error_message = result.error
|
|
await _log_step_history(
|
|
self.db,
|
|
self.tenant_id,
|
|
instance.id,
|
|
step_index=instance.current_step_index,
|
|
step_type=step_type,
|
|
action="failed",
|
|
details={
|
|
"error": result.error,
|
|
"duration_ms": duration_ms,
|
|
"retry_count": instance.retry_count,
|
|
},
|
|
)
|
|
await self.db.flush()
|
|
return _instance_to_dict(instance)
|
|
else:
|
|
# Retry: stay on same step, set resume_at with backoff
|
|
backoff = min(2 ** instance.retry_count, 60)
|
|
from datetime import timedelta
|
|
instance.resume_at = datetime.now(UTC) + timedelta(seconds=backoff)
|
|
instance.resume_reason = "retry"
|
|
instance.status = "waiting"
|
|
await _log_step_history(
|
|
self.db,
|
|
self.tenant_id,
|
|
instance.id,
|
|
step_index=instance.current_step_index,
|
|
step_type=step_type,
|
|
action="retry",
|
|
details={
|
|
"error": result.error,
|
|
"retry_count": instance.retry_count,
|
|
"resume_at": instance.resume_at.isoformat(),
|
|
"duration_ms": duration_ms,
|
|
},
|
|
)
|
|
await self.db.flush()
|
|
return _instance_to_dict(instance)
|
|
|
|
# Handle branch (next_index override)
|
|
if result.next_index is not None:
|
|
instance.current_step_index = result.next_index
|
|
instance.status = "in_progress"
|
|
elif result.advance:
|
|
# Advance to next step
|
|
next_idx = instance.current_step_index + 1
|
|
if next_idx >= len(steps):
|
|
instance.status = "completed"
|
|
instance.completed_at = datetime.now(UTC)
|
|
else:
|
|
instance.current_step_index = next_idx
|
|
instance.status = "in_progress"
|
|
|
|
# Store step output in step_state (G-CTX)
|
|
step_key = f"step_{instance.current_step_index}_output"
|
|
instance.step_state[step_key] = result.output
|
|
|
|
# Log success (G-LOG)
|
|
await _log_step_history(
|
|
self.db,
|
|
self.tenant_id,
|
|
instance.id,
|
|
step_index=instance.current_step_index,
|
|
step_type=step_type,
|
|
action="executed",
|
|
details={
|
|
"output": result.output,
|
|
"duration_ms": duration_ms,
|
|
},
|
|
)
|
|
|
|
await self.db.flush()
|
|
return _instance_to_dict(instance)
|
|
|
|
async def _process_action(
|
|
self, instance: WorkflowInstance, step: dict, steps: list
|
|
) -> dict[str, Any]:
|
|
"""Process an action step — executes the configured action and advances."""
|
|
config = step.get("config", {})
|
|
action_type = config.get("action_type", "noop")
|
|
|
|
# Execute action based on type
|
|
if action_type == "create_notification":
|
|
user_id = config.get("user_id") or (
|
|
str(instance.initiated_by) if instance.initiated_by else None
|
|
)
|
|
if user_id:
|
|
await post_system_message(
|
|
self.db,
|
|
self.tenant_id,
|
|
uuid.UUID(user_id),
|
|
config.get("notification_type", "info"),
|
|
config.get("notification_title", ""),
|
|
config.get("notification_body", ""),
|
|
)
|
|
|
|
elif action_type == "noop":
|
|
pass # No operation — just advance
|
|
|
|
# Log action executed
|
|
await _log_step_history(
|
|
self.db,
|
|
self.tenant_id,
|
|
instance.id,
|
|
step_index=instance.current_step_index,
|
|
step_type="action",
|
|
action="executed",
|
|
details={"action_type": action_type},
|
|
)
|
|
|
|
# Advance to next step
|
|
next_idx = instance.current_step_index + 1
|
|
if next_idx >= len(steps):
|
|
instance.status = "completed"
|
|
instance.completed_at = datetime.now(UTC)
|
|
else:
|
|
instance.current_step_index = next_idx
|
|
instance.status = "in_progress"
|
|
|
|
await self.db.flush()
|
|
return _instance_to_dict(instance)
|
|
|
|
async def _process_notification(self, instance: WorkflowInstance, step: dict) -> dict[str, Any]:
|
|
"""Process a notification step — sends notification and advances."""
|
|
config = step.get("config", {})
|
|
user_id = config.get("user_id") or (
|
|
str(instance.initiated_by) if instance.initiated_by else None
|
|
)
|
|
|
|
if user_id:
|
|
await post_system_message(
|
|
self.db,
|
|
self.tenant_id,
|
|
uuid.UUID(user_id),
|
|
config.get("notification_type", "workflow_notification"),
|
|
config.get("title", "Workflow notification"),
|
|
config.get("body", ""),
|
|
)
|
|
|
|
await _log_step_history(
|
|
self.db,
|
|
self.tenant_id,
|
|
instance.id,
|
|
step_index=instance.current_step_index,
|
|
step_type="notification",
|
|
action="sent",
|
|
details={"user_id": user_id},
|
|
)
|
|
|
|
# Advance — notification steps auto-advance
|
|
wf_result = await self.db.execute(
|
|
select(Workflow).where(Workflow.id == instance.workflow_id)
|
|
)
|
|
workflow = wf_result.scalar_one_or_none()
|
|
steps = workflow.steps if workflow else []
|
|
next_idx = instance.current_step_index + 1
|
|
if next_idx >= len(steps):
|
|
instance.status = "completed"
|
|
instance.completed_at = datetime.now(UTC)
|
|
else:
|
|
instance.current_step_index = next_idx
|
|
|
|
await self.db.flush()
|
|
return _instance_to_dict(instance)
|
|
|
|
async def _process_condition(
|
|
self, instance: WorkflowInstance, step: dict, steps: list
|
|
) -> dict[str, Any]:
|
|
"""Process a condition step — evaluates condition and branches.
|
|
|
|
Config format:
|
|
{
|
|
"field": "context_key",
|
|
"operator": "eq|ne|gt|lt|contains",
|
|
"value": "expected_value",
|
|
"on_true_step": optional_index,
|
|
"on_false_step": optional_index
|
|
}
|
|
"""
|
|
config = step.get("config", {})
|
|
field = config.get("field", "")
|
|
operator = config.get("operator", "eq")
|
|
expected = config.get("value")
|
|
actual = instance.context.get(field)
|
|
|
|
condition_met = False
|
|
if operator == "eq":
|
|
condition_met = actual == expected
|
|
elif operator == "ne":
|
|
condition_met = actual != expected
|
|
elif operator == "gt":
|
|
condition_met = actual is not None and expected is not None and actual > expected
|
|
elif operator == "lt":
|
|
condition_met = actual is not None and expected is not None and actual < expected
|
|
elif operator == "contains":
|
|
condition_met = (
|
|
actual is not None and expected in actual
|
|
if isinstance(actual, str | list)
|
|
else False
|
|
)
|
|
|
|
await _log_step_history(
|
|
self.db,
|
|
self.tenant_id,
|
|
instance.id,
|
|
step_index=instance.current_step_index,
|
|
step_type="condition",
|
|
action="evaluated",
|
|
details={"field": field, "operator": operator, "condition_met": condition_met},
|
|
)
|
|
|
|
# Branch or advance
|
|
if condition_met and "on_true_step" in config:
|
|
instance.current_step_index = config["on_true_step"]
|
|
elif not condition_met and "on_false_step" in config:
|
|
instance.current_step_index = config["on_false_step"]
|
|
else:
|
|
next_idx = instance.current_step_index + 1
|
|
if next_idx >= len(steps):
|
|
instance.status = "completed"
|
|
instance.completed_at = datetime.now(UTC)
|
|
else:
|
|
instance.current_step_index = next_idx
|
|
|
|
await self.db.flush()
|
|
return _instance_to_dict(instance)
|
|
|
|
async def resume(self, instance: WorkflowInstance) -> dict[str, Any]:
|
|
"""Resume a waiting workflow instance.
|
|
|
|
Called when resume_at has passed, an approval is decided,
|
|
or an event/webhook triggers a resume.
|
|
Clears resume_at/resume_reason and processes the current step.
|
|
"""
|
|
if instance.status != "waiting":
|
|
return _instance_to_dict(instance)
|
|
|
|
# Clear resume state
|
|
instance.resume_at = None
|
|
instance.resume_reason = None
|
|
instance.status = "in_progress"
|
|
|
|
# For wait steps, advance to next step after resume
|
|
wf_result = await self.db.execute(
|
|
select(Workflow).where(Workflow.id == instance.workflow_id)
|
|
)
|
|
workflow = wf_result.scalar_one_or_none()
|
|
if workflow is None:
|
|
return {"error": "Workflow not found", "status_code": 404}
|
|
|
|
steps = workflow.steps or []
|
|
if instance.current_step_index < len(steps):
|
|
step = steps[instance.current_step_index]
|
|
step_type = step.get("type", "action")
|
|
|
|
# If it was a wait step, advance to next
|
|
if step_type == "wait":
|
|
next_idx = instance.current_step_index + 1
|
|
if next_idx >= len(steps):
|
|
instance.status = "completed"
|
|
instance.completed_at = datetime.now(UTC)
|
|
else:
|
|
instance.current_step_index = next_idx
|
|
|
|
await self.db.flush()
|
|
# Process the next step
|
|
return await self.process_step(instance)
|
|
|
|
async def acquire_lock(
|
|
self, instance: WorkflowInstance, owner: str, ttl_seconds: int = 300
|
|
) -> bool:
|
|
"""Acquire a Redis lock for a workflow instance (G-RUN concurrency).
|
|
|
|
Prevents two workers from processing the same instance simultaneously.
|
|
"""
|
|
|
|
from app.core.redis import get_redis
|
|
|
|
try:
|
|
r = await get_redis()
|
|
lock_key = f"workflow_lock:{instance.id}"
|
|
acquired = await r.set(
|
|
lock_key,
|
|
owner,
|
|
nx=True,
|
|
ex=ttl_seconds,
|
|
)
|
|
if acquired:
|
|
instance.lock_owner = owner
|
|
instance.lock_expires_at = datetime.now(UTC) + timedelta(seconds=ttl_seconds)
|
|
await self.db.flush()
|
|
return True
|
|
return False
|
|
except Exception as e:
|
|
logger.warning("Failed to acquire workflow lock: %s", e)
|
|
return True # Fail open — allow processing without lock
|
|
|
|
async def release_lock(self, instance: WorkflowInstance) -> None:
|
|
"""Release the Redis lock for a workflow instance."""
|
|
from app.core.redis import get_redis
|
|
|
|
try:
|
|
r = await get_redis()
|
|
lock_key = f"workflow_lock:{instance.id}"
|
|
await r.delete(lock_key)
|
|
instance.lock_owner = None
|
|
instance.lock_expires_at = None
|
|
await self.db.flush()
|
|
except Exception as e:
|
|
logger.warning("Failed to release workflow lock: %s", e)
|
|
|
|
|
|
async def handle_event(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
event_name: str,
|
|
payload: dict[str, Any],
|
|
) -> list[dict[str, Any]]:
|
|
"""Handle an event by starting matching workflow instances.
|
|
|
|
Called by the event bus integration. Finds all active workflows
|
|
with trigger_event matching event_name and creates instances.
|
|
"""
|
|
workflows = await find_workflows_for_event(db, tenant_id, event_name)
|
|
instances: list[dict[str, Any]] = []
|
|
for wf in workflows:
|
|
inst = await create_instance(
|
|
db,
|
|
tenant_id,
|
|
uuid.UUID(payload.get("user_id", str(uuid.uuid4())))
|
|
if payload.get("user_id")
|
|
else None or uuid.uuid4(),
|
|
str(wf.id),
|
|
context=payload,
|
|
)
|
|
if inst:
|
|
instances.append(inst)
|
|
return instances
|
|
|
|
|
|
def register_workflow_event_handlers() -> None:
|
|
"""Register event bus handlers for workflow triggers.
|
|
|
|
Subscribes to the event bus to auto-start workflows when events fire.
|
|
Uses a wildcard '*' subscription to catch ALL events and dynamically
|
|
check which workflows have a matching trigger_event.
|
|
Should be called during application startup.
|
|
"""
|
|
event_bus = get_event_bus()
|
|
|
|
async def _workflow_event_handler(payload: dict[str, Any]) -> None:
|
|
"""Handle events that may trigger workflows."""
|
|
from app.core.db import create_db_session
|
|
|
|
tenant_id_str = payload.get("tenant_id")
|
|
event_name = payload.get("event", "")
|
|
if not tenant_id_str or not event_name:
|
|
return
|
|
|
|
tenant_id = uuid.UUID(tenant_id_str)
|
|
async with create_db_session(tenant_id) as db:
|
|
await handle_event(db, tenant_id, event_name, payload)
|
|
|
|
# Subscribe to ALL events via wildcard '*' — the handler dynamically
|
|
# queries for workflows whose trigger_event matches the published event.
|
|
event_bus.subscribe('*', _workflow_event_handler)
|
|
|
|
|
|
async def find_resumable_workflows(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
) -> list[WorkflowInstance]:
|
|
"""Find workflow instances that are waiting and their resume_at has passed.
|
|
|
|
Called by the ARQ cron job to resume waiting workflows.
|
|
"""
|
|
now = datetime.now(UTC)
|
|
result = await db.execute(
|
|
select(WorkflowInstance).where(
|
|
WorkflowInstance.tenant_id == tenant_id,
|
|
WorkflowInstance.status == "waiting",
|
|
WorkflowInstance.resume_at.is_not(None),
|
|
WorkflowInstance.resume_at <= now,
|
|
)
|
|
)
|
|
return list(result.scalars().all())
|