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.
@@ -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()