msaas-workflow-engine 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msaas_workflow_engine-0.1.0/.gitignore +23 -0
- msaas_workflow_engine-0.1.0/PKG-INFO +16 -0
- msaas_workflow_engine-0.1.0/pyproject.toml +38 -0
- msaas_workflow_engine-0.1.0/src/workflow_engine/__init__.py +46 -0
- msaas_workflow_engine-0.1.0/src/workflow_engine/config.py +98 -0
- msaas_workflow_engine-0.1.0/src/workflow_engine/engine.py +254 -0
- msaas_workflow_engine-0.1.0/src/workflow_engine/hooks.py +141 -0
- msaas_workflow_engine-0.1.0/src/workflow_engine/models.py +133 -0
- msaas_workflow_engine-0.1.0/src/workflow_engine/router.py +207 -0
- msaas_workflow_engine-0.1.0/src/workflow_engine/store.py +88 -0
- msaas_workflow_engine-0.1.0/tests/__init__.py +0 -0
- msaas_workflow_engine-0.1.0/tests/conftest.py +106 -0
- msaas_workflow_engine-0.1.0/tests/test_config.py +78 -0
- msaas_workflow_engine-0.1.0/tests/test_engine.py +237 -0
- msaas_workflow_engine-0.1.0/tests/test_hooks.py +173 -0
- msaas_workflow_engine-0.1.0/tests/test_models.py +113 -0
- msaas_workflow_engine-0.1.0/tests/test_router.py +169 -0
- msaas_workflow_engine-0.1.0/tests/test_store.py +111 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.next/
|
|
4
|
+
.turbo/
|
|
5
|
+
*.pyc
|
|
6
|
+
__pycache__/
|
|
7
|
+
.venv/
|
|
8
|
+
*.egg-info/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.env
|
|
12
|
+
.env.*
|
|
13
|
+
!.env.example
|
|
14
|
+
!.env.*.example
|
|
15
|
+
!.env.*.template
|
|
16
|
+
.DS_Store
|
|
17
|
+
coverage/
|
|
18
|
+
|
|
19
|
+
# Runtime artifacts
|
|
20
|
+
logs_llm/
|
|
21
|
+
vectors.db
|
|
22
|
+
vectors.db-shm
|
|
23
|
+
vectors.db-wal
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: msaas-workflow-engine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Workflow and state machine engine for SaaS applications
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: msaas-api-core
|
|
8
|
+
Requires-Dist: msaas-errors
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: fastapi>=0.115.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: httpx>=0.27.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
15
|
+
Provides-Extra: fastapi
|
|
16
|
+
Requires-Dist: fastapi>=0.115.0; extra == 'fastapi'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "msaas-workflow-engine"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Workflow and state machine engine for SaaS applications"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
dependencies = [
|
|
8
|
+
"msaas-api-core",
|
|
9
|
+
"msaas-errors","pydantic>=2.0"
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
fastapi = ["fastapi>=0.115.0"]
|
|
14
|
+
dev = [
|
|
15
|
+
"pytest>=8.0",
|
|
16
|
+
"pytest-asyncio>=0.24.0",
|
|
17
|
+
"httpx>=0.27.0",
|
|
18
|
+
"fastapi>=0.115.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/workflow_engine"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
asyncio_mode = "auto"
|
|
30
|
+
testpaths = ["tests"]
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
target-version = "py312"
|
|
34
|
+
line-length = 100
|
|
35
|
+
|
|
36
|
+
[tool.uv.sources]
|
|
37
|
+
msaas-api-core = { workspace = true }
|
|
38
|
+
msaas-errors = { workspace = true }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Willian Workflow Engine -- State machine engine for SaaS applications."""
|
|
2
|
+
|
|
3
|
+
from workflow_engine.config import (
|
|
4
|
+
WorkflowConfig,
|
|
5
|
+
get_store,
|
|
6
|
+
get_workflow,
|
|
7
|
+
init_workflow,
|
|
8
|
+
reset,
|
|
9
|
+
)
|
|
10
|
+
from workflow_engine.engine import TransitionError, WorkflowEngine
|
|
11
|
+
from workflow_engine.hooks import HookRegistry, HookType
|
|
12
|
+
from workflow_engine.models import (
|
|
13
|
+
Condition,
|
|
14
|
+
HistoryEntry,
|
|
15
|
+
InstanceStatus,
|
|
16
|
+
State,
|
|
17
|
+
StateType,
|
|
18
|
+
Transition,
|
|
19
|
+
WorkflowDefinition,
|
|
20
|
+
WorkflowInstance,
|
|
21
|
+
)
|
|
22
|
+
from workflow_engine.router import create_workflow_router
|
|
23
|
+
from workflow_engine.store import InMemoryWorkflowStore, WorkflowStore
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Condition",
|
|
27
|
+
"HistoryEntry",
|
|
28
|
+
"HookRegistry",
|
|
29
|
+
"HookType",
|
|
30
|
+
"InMemoryWorkflowStore",
|
|
31
|
+
"InstanceStatus",
|
|
32
|
+
"State",
|
|
33
|
+
"StateType",
|
|
34
|
+
"Transition",
|
|
35
|
+
"TransitionError",
|
|
36
|
+
"WorkflowConfig",
|
|
37
|
+
"WorkflowDefinition",
|
|
38
|
+
"WorkflowEngine",
|
|
39
|
+
"WorkflowInstance",
|
|
40
|
+
"WorkflowStore",
|
|
41
|
+
"create_workflow_router",
|
|
42
|
+
"get_store",
|
|
43
|
+
"get_workflow",
|
|
44
|
+
"init_workflow",
|
|
45
|
+
"reset",
|
|
46
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Module configuration and singleton access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from workflow_engine.hooks import HookRegistry
|
|
8
|
+
from workflow_engine.store import InMemoryWorkflowStore, WorkflowStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_config: WorkflowConfig | None = None
|
|
12
|
+
_engine: object | None = None # Lazy ref to avoid circular import
|
|
13
|
+
_store: WorkflowStore | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WorkflowConfig(BaseModel):
|
|
17
|
+
"""Configuration for the workflow engine module.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
max_history_entries: Maximum history entries kept per instance (0 = unlimited).
|
|
21
|
+
enable_hooks: Whether lifecycle hooks are executed.
|
|
22
|
+
default_store_type: Store backend to use when none is provided.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
max_history_entries: int = 0
|
|
26
|
+
enable_hooks: bool = True
|
|
27
|
+
default_store_type: str = "memory"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def init_workflow(
|
|
31
|
+
config: WorkflowConfig | None = None,
|
|
32
|
+
*,
|
|
33
|
+
store: WorkflowStore | None = None,
|
|
34
|
+
) -> WorkflowConfig:
|
|
35
|
+
"""Initialize the workflow module.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
config: Module configuration. Uses defaults if None.
|
|
39
|
+
store: Optional custom store. Falls back to InMemoryWorkflowStore.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
The active WorkflowConfig.
|
|
43
|
+
"""
|
|
44
|
+
global _config, _engine, _store
|
|
45
|
+
_config = config or WorkflowConfig()
|
|
46
|
+
_store = store or InMemoryWorkflowStore()
|
|
47
|
+
_engine = None # Reset cached engine
|
|
48
|
+
return _config
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_config() -> WorkflowConfig:
|
|
52
|
+
"""Return the current module configuration.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
RuntimeError: If init_workflow() has not been called.
|
|
56
|
+
"""
|
|
57
|
+
if _config is None:
|
|
58
|
+
raise RuntimeError("Workflow module not initialized. Call init_workflow() first.")
|
|
59
|
+
return _config
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_store() -> WorkflowStore:
|
|
63
|
+
"""Return the configured workflow store.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
RuntimeError: If init_workflow() has not been called.
|
|
67
|
+
"""
|
|
68
|
+
if _store is None:
|
|
69
|
+
raise RuntimeError("Workflow module not initialized. Call init_workflow() first.")
|
|
70
|
+
return _store
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_workflow():
|
|
74
|
+
"""Return or create the singleton WorkflowEngine.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The active WorkflowEngine instance.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
RuntimeError: If init_workflow() has not been called.
|
|
81
|
+
"""
|
|
82
|
+
global _engine
|
|
83
|
+
if _config is None:
|
|
84
|
+
raise RuntimeError("Workflow module not initialized. Call init_workflow() first.")
|
|
85
|
+
if _engine is None:
|
|
86
|
+
from workflow_engine.engine import WorkflowEngine
|
|
87
|
+
|
|
88
|
+
hooks = HookRegistry() if _config.enable_hooks else None
|
|
89
|
+
_engine = WorkflowEngine(_store, hooks=hooks)
|
|
90
|
+
return _engine
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def reset() -> None:
|
|
94
|
+
"""Reset module state (useful for testing)."""
|
|
95
|
+
global _config, _engine, _store
|
|
96
|
+
_config = None
|
|
97
|
+
_engine = None
|
|
98
|
+
_store = None
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Core workflow engine: orchestrates definitions, transitions, and hooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from workflow_engine.hooks import HookRegistry, HookType
|
|
9
|
+
from workflow_engine.models import (
|
|
10
|
+
Condition,
|
|
11
|
+
HistoryEntry,
|
|
12
|
+
InstanceStatus,
|
|
13
|
+
StateType,
|
|
14
|
+
WorkflowDefinition,
|
|
15
|
+
WorkflowInstance,
|
|
16
|
+
)
|
|
17
|
+
from workflow_engine.store import WorkflowStore
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TransitionError(Exception):
|
|
21
|
+
"""Raised when a transition is invalid or cannot be executed."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class WorkflowEngine:
|
|
25
|
+
"""State machine engine that manages workflow lifecycles.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
store: Persistence backend for definitions and instances.
|
|
29
|
+
hooks: Hook registry for lifecycle callbacks.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, store: WorkflowStore, hooks: HookRegistry | None = None) -> None:
|
|
33
|
+
self._store = store
|
|
34
|
+
self._hooks = hooks or HookRegistry()
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def hooks(self) -> HookRegistry:
|
|
38
|
+
"""Access the hook registry for registering callbacks."""
|
|
39
|
+
return self._hooks
|
|
40
|
+
|
|
41
|
+
# -- Definition management --
|
|
42
|
+
|
|
43
|
+
async def register_definition(self, definition: WorkflowDefinition) -> WorkflowDefinition:
|
|
44
|
+
"""Validate and persist a workflow definition.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: If the definition is structurally invalid.
|
|
48
|
+
"""
|
|
49
|
+
self._validate_definition(definition)
|
|
50
|
+
await self._store.save_definition(definition)
|
|
51
|
+
return definition
|
|
52
|
+
|
|
53
|
+
async def get_definition(self, name: str) -> WorkflowDefinition | None:
|
|
54
|
+
"""Retrieve a definition by name."""
|
|
55
|
+
return await self._store.get_definition(name)
|
|
56
|
+
|
|
57
|
+
# -- Instance management --
|
|
58
|
+
|
|
59
|
+
async def create_instance(
|
|
60
|
+
self,
|
|
61
|
+
definition_name: str,
|
|
62
|
+
context: dict[str, Any] | None = None,
|
|
63
|
+
*,
|
|
64
|
+
instance_id: str | None = None,
|
|
65
|
+
) -> WorkflowInstance:
|
|
66
|
+
"""Create a new workflow instance from a registered definition.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
definition_name: Name of the workflow definition to instantiate.
|
|
70
|
+
context: Initial context data.
|
|
71
|
+
instance_id: Optional custom ID (auto-generated if omitted).
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
ValueError: If the definition is not registered.
|
|
75
|
+
"""
|
|
76
|
+
definition = await self._store.get_definition(definition_name)
|
|
77
|
+
if definition is None:
|
|
78
|
+
raise ValueError(f"Workflow definition '{definition_name}' not found")
|
|
79
|
+
|
|
80
|
+
kwargs: dict[str, Any] = {
|
|
81
|
+
"definition_name": definition_name,
|
|
82
|
+
"current_state": definition.initial_state,
|
|
83
|
+
"context": context or {},
|
|
84
|
+
}
|
|
85
|
+
if instance_id is not None:
|
|
86
|
+
kwargs["id"] = instance_id
|
|
87
|
+
|
|
88
|
+
instance = WorkflowInstance(**kwargs)
|
|
89
|
+
|
|
90
|
+
# Execute on_enter hooks for the initial state
|
|
91
|
+
state_obj = self._find_state(definition, definition.initial_state)
|
|
92
|
+
if state_obj and state_obj.on_enter:
|
|
93
|
+
await self._hooks.execute_many(HookType.ON_ENTER, state_obj.on_enter, instance)
|
|
94
|
+
|
|
95
|
+
await self._store.save_instance(instance)
|
|
96
|
+
return instance
|
|
97
|
+
|
|
98
|
+
async def get_instance(self, instance_id: str) -> WorkflowInstance | None:
|
|
99
|
+
"""Retrieve an instance by ID."""
|
|
100
|
+
return await self._store.get_instance(instance_id)
|
|
101
|
+
|
|
102
|
+
async def list_instances(
|
|
103
|
+
self,
|
|
104
|
+
*,
|
|
105
|
+
definition_name: str | None = None,
|
|
106
|
+
status: InstanceStatus | None = None,
|
|
107
|
+
current_state: str | None = None,
|
|
108
|
+
) -> list[WorkflowInstance]:
|
|
109
|
+
"""List instances with optional filters."""
|
|
110
|
+
return await self._store.list_instances(
|
|
111
|
+
definition_name=definition_name,
|
|
112
|
+
status=status,
|
|
113
|
+
current_state=current_state,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# -- Transition execution --
|
|
117
|
+
|
|
118
|
+
async def trigger(
|
|
119
|
+
self,
|
|
120
|
+
instance_id: str,
|
|
121
|
+
trigger: str,
|
|
122
|
+
*,
|
|
123
|
+
actor_id: str | None = None,
|
|
124
|
+
metadata: dict[str, Any] | None = None,
|
|
125
|
+
context_update: dict[str, Any] | None = None,
|
|
126
|
+
) -> WorkflowInstance:
|
|
127
|
+
"""Fire a trigger on an instance, causing a state transition.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
instance_id: Target instance.
|
|
131
|
+
trigger: Event name to fire.
|
|
132
|
+
actor_id: Who/what initiated the trigger.
|
|
133
|
+
metadata: Extra data to record in the history entry.
|
|
134
|
+
context_update: Key-value pairs to merge into the instance context.
|
|
135
|
+
|
|
136
|
+
Raises:
|
|
137
|
+
ValueError: If instance or definition is not found.
|
|
138
|
+
TransitionError: If no valid transition exists for the trigger.
|
|
139
|
+
"""
|
|
140
|
+
instance = await self._store.get_instance(instance_id)
|
|
141
|
+
if instance is None:
|
|
142
|
+
raise ValueError(f"Instance '{instance_id}' not found")
|
|
143
|
+
|
|
144
|
+
if instance.status != InstanceStatus.ACTIVE:
|
|
145
|
+
raise TransitionError(
|
|
146
|
+
f"Instance '{instance_id}' is {instance.status.value}, not active"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
definition = await self._store.get_definition(instance.definition_name)
|
|
150
|
+
if definition is None:
|
|
151
|
+
raise ValueError(f"Definition '{instance.definition_name}' not found for instance")
|
|
152
|
+
|
|
153
|
+
# Apply context update before evaluating conditions
|
|
154
|
+
if context_update:
|
|
155
|
+
instance.context.update(context_update)
|
|
156
|
+
|
|
157
|
+
# Find matching transition
|
|
158
|
+
transition = self._find_transition(definition, instance, trigger)
|
|
159
|
+
|
|
160
|
+
# Execute on_exit hooks for the current state
|
|
161
|
+
from_state = self._find_state(definition, instance.current_state)
|
|
162
|
+
if from_state and from_state.on_exit:
|
|
163
|
+
await self._hooks.execute_many(HookType.ON_EXIT, from_state.on_exit, instance)
|
|
164
|
+
|
|
165
|
+
# Execute transition action hooks
|
|
166
|
+
if transition.actions:
|
|
167
|
+
await self._hooks.execute_many(HookType.ON_TRANSITION, transition.actions, instance)
|
|
168
|
+
|
|
169
|
+
# Record history
|
|
170
|
+
entry = HistoryEntry(
|
|
171
|
+
from_state=instance.current_state,
|
|
172
|
+
to_state=transition.to_state,
|
|
173
|
+
trigger=trigger,
|
|
174
|
+
actor_id=actor_id,
|
|
175
|
+
metadata=metadata or {},
|
|
176
|
+
)
|
|
177
|
+
instance.history.append(entry)
|
|
178
|
+
|
|
179
|
+
# Move to new state
|
|
180
|
+
instance.current_state = transition.to_state
|
|
181
|
+
instance.updated_at = datetime.now(timezone.utc)
|
|
182
|
+
|
|
183
|
+
# Execute on_enter hooks for the new state
|
|
184
|
+
to_state = self._find_state(definition, transition.to_state)
|
|
185
|
+
if to_state and to_state.on_enter:
|
|
186
|
+
await self._hooks.execute_many(HookType.ON_ENTER, to_state.on_enter, instance)
|
|
187
|
+
|
|
188
|
+
# Auto-complete if we reached an end state
|
|
189
|
+
if to_state and to_state.type == StateType.END:
|
|
190
|
+
instance.status = InstanceStatus.COMPLETED
|
|
191
|
+
|
|
192
|
+
await self._store.save_instance(instance)
|
|
193
|
+
return instance
|
|
194
|
+
|
|
195
|
+
# -- Validation helpers --
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def _validate_definition(definition: WorkflowDefinition) -> None:
|
|
199
|
+
"""Validate structural integrity of a workflow definition."""
|
|
200
|
+
state_names = {s.name for s in definition.states}
|
|
201
|
+
|
|
202
|
+
if definition.initial_state not in state_names:
|
|
203
|
+
raise ValueError(f"Initial state '{definition.initial_state}' not in states")
|
|
204
|
+
|
|
205
|
+
start_states = [s for s in definition.states if s.type == StateType.START]
|
|
206
|
+
if len(start_states) > 1:
|
|
207
|
+
raise ValueError("Workflow must have at most one start state")
|
|
208
|
+
|
|
209
|
+
if start_states and start_states[0].name != definition.initial_state:
|
|
210
|
+
raise ValueError("Start state must match initial_state")
|
|
211
|
+
|
|
212
|
+
for t in definition.transitions:
|
|
213
|
+
if t.from_state not in state_names:
|
|
214
|
+
raise ValueError(f"Transition from unknown state '{t.from_state}'")
|
|
215
|
+
if t.to_state not in state_names:
|
|
216
|
+
raise ValueError(f"Transition to unknown state '{t.to_state}'")
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _find_state(definition: WorkflowDefinition, name: str):
|
|
220
|
+
"""Find a state by name within a definition."""
|
|
221
|
+
for s in definition.states:
|
|
222
|
+
if s.name == name:
|
|
223
|
+
return s
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
def _find_transition(
|
|
227
|
+
self,
|
|
228
|
+
definition: WorkflowDefinition,
|
|
229
|
+
instance: WorkflowInstance,
|
|
230
|
+
trigger: str,
|
|
231
|
+
):
|
|
232
|
+
"""Find a valid transition for the trigger from the current state.
|
|
233
|
+
|
|
234
|
+
Raises:
|
|
235
|
+
TransitionError: If no matching transition is found.
|
|
236
|
+
"""
|
|
237
|
+
candidates = [
|
|
238
|
+
t
|
|
239
|
+
for t in definition.transitions
|
|
240
|
+
if t.from_state == instance.current_state and t.trigger == trigger
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
for t in candidates:
|
|
244
|
+
if self._evaluate_conditions(t.conditions, instance.context):
|
|
245
|
+
return t
|
|
246
|
+
|
|
247
|
+
raise TransitionError(
|
|
248
|
+
f"No valid transition for trigger '{trigger}' from state '{instance.current_state}'"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
@staticmethod
|
|
252
|
+
def _evaluate_conditions(conditions: list[Condition], context: dict[str, Any]) -> bool:
|
|
253
|
+
"""Check all conditions against the instance context."""
|
|
254
|
+
return all(context.get(c.field) == c.value for c in conditions)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Hook registry for workflow lifecycle callbacks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
import inspect
|
|
7
|
+
import logging
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
from workflow_engine.models import WorkflowInstance
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
HookCallback = Callable[..., Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HookType(str, enum.Enum):
|
|
19
|
+
"""Categories of lifecycle hooks."""
|
|
20
|
+
|
|
21
|
+
ON_ENTER = "on_enter"
|
|
22
|
+
ON_EXIT = "on_exit"
|
|
23
|
+
ON_TRANSITION = "on_transition"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class HookRegistry:
|
|
27
|
+
"""Registry for sync and async callbacks on workflow lifecycle events.
|
|
28
|
+
|
|
29
|
+
Hooks are keyed by ``(hook_type, name)`` where *name* is the hook label
|
|
30
|
+
defined in the workflow definition (e.g. ``"send_notification"``).
|
|
31
|
+
|
|
32
|
+
Error isolation: if a hook raises, the error is logged and the
|
|
33
|
+
remaining hooks plus the transition continue uninterrupted.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self._hooks: dict[tuple[HookType, str], list[HookCallback]] = defaultdict(list)
|
|
38
|
+
|
|
39
|
+
def register(
|
|
40
|
+
self,
|
|
41
|
+
hook_type: HookType,
|
|
42
|
+
name: str,
|
|
43
|
+
callback: HookCallback,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Register a callback for a specific hook type and name.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
hook_type: When the hook fires (enter, exit, transition).
|
|
49
|
+
name: Hook name matching the definition's on_enter/on_exit/actions.
|
|
50
|
+
callback: Sync or async callable receiving the workflow instance.
|
|
51
|
+
"""
|
|
52
|
+
self._hooks[(hook_type, name)].append(callback)
|
|
53
|
+
|
|
54
|
+
def on_enter(self, name: str) -> Callable[[HookCallback], HookCallback]:
|
|
55
|
+
"""Decorator to register an on_enter hook."""
|
|
56
|
+
|
|
57
|
+
def decorator(fn: HookCallback) -> HookCallback:
|
|
58
|
+
self.register(HookType.ON_ENTER, name, fn)
|
|
59
|
+
return fn
|
|
60
|
+
|
|
61
|
+
return decorator
|
|
62
|
+
|
|
63
|
+
def on_exit(self, name: str) -> Callable[[HookCallback], HookCallback]:
|
|
64
|
+
"""Decorator to register an on_exit hook."""
|
|
65
|
+
|
|
66
|
+
def decorator(fn: HookCallback) -> HookCallback:
|
|
67
|
+
self.register(HookType.ON_EXIT, name, fn)
|
|
68
|
+
return fn
|
|
69
|
+
|
|
70
|
+
return decorator
|
|
71
|
+
|
|
72
|
+
def on_transition(self, name: str) -> Callable[[HookCallback], HookCallback]:
|
|
73
|
+
"""Decorator to register an on_transition hook."""
|
|
74
|
+
|
|
75
|
+
def decorator(fn: HookCallback) -> HookCallback:
|
|
76
|
+
self.register(HookType.ON_TRANSITION, name, fn)
|
|
77
|
+
return fn
|
|
78
|
+
|
|
79
|
+
return decorator
|
|
80
|
+
|
|
81
|
+
async def execute(
|
|
82
|
+
self,
|
|
83
|
+
hook_type: HookType,
|
|
84
|
+
name: str,
|
|
85
|
+
instance: WorkflowInstance,
|
|
86
|
+
) -> list[Exception]:
|
|
87
|
+
"""Execute all callbacks for the given hook type and name.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
hook_type: The lifecycle event.
|
|
91
|
+
name: The hook name.
|
|
92
|
+
instance: Current workflow instance (passed to callbacks).
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
List of exceptions raised by callbacks (empty on full success).
|
|
96
|
+
"""
|
|
97
|
+
callbacks = self._hooks.get((hook_type, name), [])
|
|
98
|
+
errors: list[Exception] = []
|
|
99
|
+
|
|
100
|
+
for cb in callbacks:
|
|
101
|
+
try:
|
|
102
|
+
result = cb(instance)
|
|
103
|
+
if inspect.isawaitable(result):
|
|
104
|
+
await result
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
logger.error(
|
|
107
|
+
"Hook %s:%s failed: %s",
|
|
108
|
+
hook_type.value,
|
|
109
|
+
name,
|
|
110
|
+
exc,
|
|
111
|
+
exc_info=True,
|
|
112
|
+
)
|
|
113
|
+
errors.append(exc)
|
|
114
|
+
|
|
115
|
+
return errors
|
|
116
|
+
|
|
117
|
+
async def execute_many(
|
|
118
|
+
self,
|
|
119
|
+
hook_type: HookType,
|
|
120
|
+
names: list[str],
|
|
121
|
+
instance: WorkflowInstance,
|
|
122
|
+
) -> list[Exception]:
|
|
123
|
+
"""Execute hooks for multiple names sequentially.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
hook_type: The lifecycle event.
|
|
127
|
+
names: List of hook names to execute in order.
|
|
128
|
+
instance: Current workflow instance.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Aggregated list of exceptions from all hooks.
|
|
132
|
+
"""
|
|
133
|
+
all_errors: list[Exception] = []
|
|
134
|
+
for name in names:
|
|
135
|
+
errors = await self.execute(hook_type, name, instance)
|
|
136
|
+
all_errors.extend(errors)
|
|
137
|
+
return all_errors
|
|
138
|
+
|
|
139
|
+
def clear(self) -> None:
|
|
140
|
+
"""Remove all registered hooks."""
|
|
141
|
+
self._hooks.clear()
|