Source code for workflows.services.engine
"""Workflow engine: advance instances step-by-step until WAITING or terminal."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from django.db import transaction
from workflows.models import State, WorkflowInstance
from workflows.services.context import VARS_MAX_BYTES, compact_context_blob
from workflows.services.executors.factory import StepExecutorFactory
if TYPE_CHECKING:
from workflows.services.types import ExecutorResult
def _current_step(inst: WorkflowInstance) -> dict[str, Any]:
for step in inst.get_steps():
if step['id'] == inst.current_step:
return step
msg = f'Unknown current_step {inst.current_step!r}'
raise ValueError(msg)
def _advance_pointer(inst: WorkflowInstance) -> bool:
nxt = inst.get_next_step()
if nxt:
inst.current_step = nxt
inst.state = State.RUNNING
inst.save(update_fields=['current_step', 'state'])
return True
return False
def _max_pass_hops(inst: WorkflowInstance) -> int:
"""Upper bound of PASS transitions from current step."""
steps = inst.get_steps()
cur = inst.get_current_step_index()
return max(0, len(steps) - (cur + 1))
def _size_bytes(obj: Any) -> int:
return len(json.dumps(obj, ensure_ascii=False))
def _deep_merge_no_overwrite(dst: dict[str, Any], src: dict[str, Any]) -> None:
"""Deep-merge src into dst; if a leaf exists with a different value, raise ValueError."""
for k, v in src.items():
if k not in dst:
dst[k] = v
continue
dv = dst[k]
if isinstance(dv, dict) and isinstance(v, dict):
_deep_merge_no_overwrite(dv, v)
continue
if dv != v:
msg = f'ctx.vars collision at key "{k}": {dv!r} vs {v!r}'
raise ValueError(msg)
def _persist_step_context(inst: WorkflowInstance, result: ExecutorResult) -> None:
"""Persist per-step compacted context if present."""
if result.context is None:
return
step_contexts = dict(inst.step_contexts or {})
context = dict(result.context)
step_contexts[str(inst.current_step)] = compact_context_blob(context)
inst.step_contexts = step_contexts
inst.save(update_fields=['step_contexts'])
def _merge_global_vars(inst: WorkflowInstance, result: ExecutorResult) -> bool:
"""Merge vars into global $vars.
Returns:
True if processing can continue.
False if the vars size exceeded VARS_MAX_BYTES (instance is marked FAILED).
"""
if not (result.vars and isinstance(result.vars, dict)):
return True
step_contexts = dict(inst.step_contexts or {})
vars_map = dict(step_contexts.get('$vars') or {})
_deep_merge_no_overwrite(vars_map, dict(result.vars))
if _size_bytes(vars_map) > VARS_MAX_BYTES:
inst.state = State.FAILED
inst.save(update_fields=['state'])
return False
step_contexts['$vars'] = vars_map
inst.step_contexts = step_contexts
inst.save(update_fields=['step_contexts'])
return True
def _handle_status(
inst: WorkflowInstance,
status: State,
signal: str | None,
) -> tuple[bool, str | None]:
"""Apply status to the instance and decide whether to continue.
Returns:
(should_continue, new_signal)
"""
# default: stop after handling this status; preserve signal
should_continue = False
new_signal = signal
if status in (State.PASSED, State.APPROVED):
if _advance_pointer(inst):
# continuation semantics: moved to next step, reset signal
return True, None
if status == State.PASSED:
inst.state = State.PASSED
else:
inst.state = State.APPROVED
elif status == State.AWAITING:
inst.state = State.AWAITING
elif status == State.REJECTED:
inst.state = State.REJECTED
elif status == State.FINALIZED:
inst.state = State.FINALIZED
inst.finalize()
elif status == State.FAILED:
inst.state = State.FAILED
else:
# Defensive fallback
inst.state = State.FAILED
inst.save(update_fields=['state'])
return should_continue, new_signal
[docs]
def advance_instance(inst: WorkflowInstance, signal: str | None = None) -> None:
"""Advance an instance until AWAITING or a terminal outcome is reached."""
with transaction.atomic():
inst = WorkflowInstance.objects.select_for_update().get(pk=inst.pk)
if inst.finalized:
return
budget = _max_pass_hops(inst) + 1
for _ in range(budget):
step_meta = _current_step(inst)
step_type = str(step_meta.get('type') or '')
executor = StepExecutorFactory.create(step_type)
result: ExecutorResult = executor.execute(inst, signal)
_persist_step_context(inst, result)
if inst.enrollment_request:
inst.enrollment_request.recompute_and_save()
if not _merge_global_vars(inst, result):
break
should_continue, signal = _handle_status(inst, result.status, signal)
if not should_continue:
break