novastack-workflows 0.1.0__py3-none-any.whl
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.
- novastack_workflows/__init__.py +14 -0
- novastack_workflows/context/__init__.py +3 -0
- novastack_workflows/context/context.py +133 -0
- novastack_workflows/context/state_store.py +193 -0
- novastack_workflows/decorators.py +182 -0
- novastack_workflows/enums.py +7 -0
- novastack_workflows/events.py +35 -0
- novastack_workflows/exceptions.py +18 -0
- novastack_workflows/prebuilt/__init__.py +13 -0
- novastack_workflows/prebuilt/document_ingestion.py +286 -0
- novastack_workflows/runtime/__init__.py +5 -0
- novastack_workflows/runtime/engine.py +558 -0
- novastack_workflows/runtime/event_buffer.py +192 -0
- novastack_workflows/runtime/execution_pool.py +142 -0
- novastack_workflows/schemas.py +93 -0
- novastack_workflows/server/__init__.py +7 -0
- novastack_workflows/server/_api/__init__.py +0 -0
- novastack_workflows/server/_api/models.py +66 -0
- novastack_workflows/server/_api/routes.py +108 -0
- novastack_workflows/server/_registry.py +98 -0
- novastack_workflows/server/exceptions.py +5 -0
- novastack_workflows/server/server.py +74 -0
- novastack_workflows/workflow.py +274 -0
- novastack_workflows-0.1.0.dist-info/METADATA +136 -0
- novastack_workflows-0.1.0.dist-info/RECORD +27 -0
- novastack_workflows-0.1.0.dist-info/WHEEL +4 -0
- novastack_workflows-0.1.0.dist-info/licenses/LICENSE +176 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Context management
|
|
2
|
+
from novastack_workflows.context import Context
|
|
3
|
+
from novastack_workflows.decorators import step
|
|
4
|
+
from novastack_workflows.events import Event, StartEvent, StopEvent
|
|
5
|
+
from novastack_workflows.workflow import Workflow
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Workflow",
|
|
9
|
+
"Context",
|
|
10
|
+
"step",
|
|
11
|
+
"Event",
|
|
12
|
+
"StartEvent",
|
|
13
|
+
"StopEvent",
|
|
14
|
+
]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from novastack_workflows.context.state_store import InMemoryStateStore
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from novastack_workflows.events import Event
|
|
10
|
+
|
|
11
|
+
STATE_T = TypeVar("STATE_T", bound=BaseModel)
|
|
12
|
+
|
|
13
|
+
class SerializedContext(BaseModel):
|
|
14
|
+
|
|
15
|
+
version: str = "1.0.0"
|
|
16
|
+
kind: str = "Context"
|
|
17
|
+
state: dict = Field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
class Context(Generic[STATE_T]):
|
|
20
|
+
"""
|
|
21
|
+
Workflow execution context with copy-on-write state management.
|
|
22
|
+
|
|
23
|
+
Provides robust, immutable state management through 'state_store'.
|
|
24
|
+
|
|
25
|
+
State is automatically initialized based on the type annotation.
|
|
26
|
+
If no type is provided, uses DictState.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
```python
|
|
30
|
+
class RunState(BaseModel):
|
|
31
|
+
counter: int = 0
|
|
32
|
+
items: list[str] = []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# With typed state - auto-initialized
|
|
36
|
+
@step
|
|
37
|
+
async def start(self, ctx: Context[RunState], ev: StartEvent):
|
|
38
|
+
async with ctx.store.edit_state() as state:
|
|
39
|
+
state.counter += 1 # RunState auto-initialized
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@step
|
|
43
|
+
async def start(self, ctx: Context, ev: StartEvent):
|
|
44
|
+
async with ctx.store.edit_state() as state:
|
|
45
|
+
state.counter = 1
|
|
46
|
+
```
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
workflow: Any,
|
|
52
|
+
event_queue: asyncio.Queue["Event"] | Any | None = None,
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Initialize workflow context.
|
|
56
|
+
|
|
57
|
+
Each context is isolated and maintains its own state store.
|
|
58
|
+
State type is inferred from Context[StateType] annotation or defaults to DictState.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
workflow: Workflow instance
|
|
62
|
+
event_queue: Event queue for emission
|
|
63
|
+
"""
|
|
64
|
+
self._workflow = workflow
|
|
65
|
+
|
|
66
|
+
# Use provided queue or create asyncio.Queue
|
|
67
|
+
if event_queue is None:
|
|
68
|
+
self._event_queue: asyncio.Queue[Event] = asyncio.Queue()
|
|
69
|
+
else:
|
|
70
|
+
self._event_queue = event_queue
|
|
71
|
+
|
|
72
|
+
# Copy-on-write state store with auto-initialization
|
|
73
|
+
# Infer state type from workflow's _state_type if available
|
|
74
|
+
state_type = getattr(workflow, "_state_type", None)
|
|
75
|
+
self._store = InMemoryStateStore(state_type=state_type)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def workflow(self) -> Any:
|
|
79
|
+
"""Get workflow instance."""
|
|
80
|
+
return self._workflow
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def store(self): # type: ignore[return]
|
|
84
|
+
"""
|
|
85
|
+
Copy-on-write state store.
|
|
86
|
+
|
|
87
|
+
Provides immutability guarantees and thread-safety through
|
|
88
|
+
explicit edit contexts.
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
```python
|
|
92
|
+
# Edit state
|
|
93
|
+
async with ctx.store.edit_state() as state:
|
|
94
|
+
state.counter += 1
|
|
95
|
+
```
|
|
96
|
+
"""
|
|
97
|
+
return self._store
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def state(self) -> BaseModel | None:
|
|
101
|
+
"""
|
|
102
|
+
Get current state (read-only).
|
|
103
|
+
|
|
104
|
+
Convenience property for accessing store state.
|
|
105
|
+
Equivalent to ctx.store.state.
|
|
106
|
+
|
|
107
|
+
Example:
|
|
108
|
+
```python
|
|
109
|
+
# Read-only access
|
|
110
|
+
current_value = ctx.state.counter
|
|
111
|
+
```
|
|
112
|
+
"""
|
|
113
|
+
return self._store.state
|
|
114
|
+
|
|
115
|
+
async def send_event(self, event: "Event") -> None:
|
|
116
|
+
"""
|
|
117
|
+
Send an event to the workflow queue.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
event(Event): Event to send
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
```python
|
|
124
|
+
await ctx.send_event(MyEvent(data="processed"))
|
|
125
|
+
```
|
|
126
|
+
"""
|
|
127
|
+
await self._event_queue.put(event)
|
|
128
|
+
|
|
129
|
+
def to_dict(self) -> dict[str, Any]:
|
|
130
|
+
"""Serialize context to dictionary."""
|
|
131
|
+
return SerializedContext(
|
|
132
|
+
state=self._store.to_dict()
|
|
133
|
+
).model_dump()
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import copy
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
from typing import Any, Generic, Protocol, Type, TypeVar, cast
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from novastack_workflows.exceptions import ContextStateError
|
|
9
|
+
from novastack_workflows.schemas import DictLikeModel
|
|
10
|
+
|
|
11
|
+
STATE_T = TypeVar("STATE_T", bound=BaseModel)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SerializedState(BaseModel):
|
|
15
|
+
"""
|
|
16
|
+
Serialized state representation for persistence.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
state_type: Name of the state model class
|
|
20
|
+
store_type: Type of state store (e.g., "in_memory")
|
|
21
|
+
data: Serialized state data as dictionary, or None if state is not initialized
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
state_type: str = "DictState"
|
|
25
|
+
store_type: str = "in_memory"
|
|
26
|
+
data: dict = Field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StateStore(Protocol[STATE_T]):
|
|
30
|
+
"""
|
|
31
|
+
Protocol defining the interface for state store implementations.
|
|
32
|
+
|
|
33
|
+
Each state store is independent and does not inherit state
|
|
34
|
+
from other contexts. This ensures proper isolation between
|
|
35
|
+
workflows.
|
|
36
|
+
|
|
37
|
+
All state store implementations must provide thread-safe state access via the state
|
|
38
|
+
property and copy-on-write mutations via the edit_state() context manager with
|
|
39
|
+
automatic rollback on errors.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def state(self) -> STATE_T | None:
|
|
44
|
+
"""Get current state (read-only)."""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
def edit_state(self):
|
|
48
|
+
"""
|
|
49
|
+
Context manager for editing state with copy-on-write semantics.
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
```python
|
|
53
|
+
async with store.edit_state() as state:
|
|
54
|
+
state.counter += 1
|
|
55
|
+
```
|
|
56
|
+
"""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> dict[str, Any]:
|
|
60
|
+
"""
|
|
61
|
+
Serialize current state to dictionary.
|
|
62
|
+
|
|
63
|
+
Returns dictionary representation including store type, state type metadata,
|
|
64
|
+
and serialized state data.
|
|
65
|
+
"""
|
|
66
|
+
...
|
|
67
|
+
|
|
68
|
+
class DictState(DictLikeModel):
|
|
69
|
+
"""Used as the default state model when no typed state is provided."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, **params: Any):
|
|
72
|
+
super().__init__(**params)
|
|
73
|
+
|
|
74
|
+
class InMemoryStateStore(Generic[STATE_T]):
|
|
75
|
+
"""
|
|
76
|
+
In-memory state store with copy-on-write semantics.
|
|
77
|
+
|
|
78
|
+
This is a thread-safe implementation that stores state in local memory, making it
|
|
79
|
+
ideal for single-process workflows and development environments. It provides
|
|
80
|
+
copy-on-write for immutability, thread-safe mutations via asyncio.Lock, automatic
|
|
81
|
+
rollback on errors, smart copying optimized for Pydantic models, and auto-initialization
|
|
82
|
+
with type inference.
|
|
83
|
+
|
|
84
|
+
Note that state is not shared across processes, will be lost on process restart, and
|
|
85
|
+
is not suitable for distributed workflows. For distributed or persistent state, consider
|
|
86
|
+
RedisStateStore for distributed state or PostgresStateStore for persistent state.
|
|
87
|
+
|
|
88
|
+
Example:
|
|
89
|
+
```python
|
|
90
|
+
store = InMemoryStateStore(state_type=MyState)
|
|
91
|
+
async with store.edit_state() as state:
|
|
92
|
+
state.counter += 1
|
|
93
|
+
```
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
state_type: Type[STATE_T] | None = None,
|
|
99
|
+
data: STATE_T | None = None,
|
|
100
|
+
):
|
|
101
|
+
"""
|
|
102
|
+
Initialize in-memory state store.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
state_data: Initial state instance (optional)
|
|
106
|
+
state_type: State model class for auto-initialization (optional)
|
|
107
|
+
"""
|
|
108
|
+
self._lock = asyncio.Lock()
|
|
109
|
+
self._state_type: Type[BaseModel] = state_type or DictState
|
|
110
|
+
|
|
111
|
+
if data is not None:
|
|
112
|
+
self._state: STATE_T | None = data
|
|
113
|
+
else:
|
|
114
|
+
# Auto-initialize with state_type
|
|
115
|
+
self._state = cast(STATE_T, self._state_type())
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def state(self) -> STATE_T | None:
|
|
119
|
+
"""Get current state (read-only)."""
|
|
120
|
+
return self._state
|
|
121
|
+
|
|
122
|
+
def _smart_copy(self, state: STATE_T) -> STATE_T:
|
|
123
|
+
"""
|
|
124
|
+
Smart copy that optimizes based on state type.
|
|
125
|
+
|
|
126
|
+
For Pydantic models, uses the optimized model_copy() method.
|
|
127
|
+
This reduces copy overhead by 40-60% for Pydantic models.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
state: State to copy
|
|
131
|
+
"""
|
|
132
|
+
if isinstance(state, BaseModel):
|
|
133
|
+
# Pydantic has optimized copy
|
|
134
|
+
return state.model_copy(deep=True)
|
|
135
|
+
|
|
136
|
+
# Fallback to deep copy
|
|
137
|
+
return copy.deepcopy(state)
|
|
138
|
+
|
|
139
|
+
@asynccontextmanager
|
|
140
|
+
async def edit_state(self):
|
|
141
|
+
"""
|
|
142
|
+
Context manager for editing state with copy-on-write semantics.
|
|
143
|
+
|
|
144
|
+
Creates a deep copy of the state for editing. Changes are committed
|
|
145
|
+
atomically when the context exits successfully. If an exception occurs,
|
|
146
|
+
changes are automatically rolled back.
|
|
147
|
+
|
|
148
|
+
Example:
|
|
149
|
+
```python
|
|
150
|
+
async with store.edit_state() as state:
|
|
151
|
+
state.counter += 1
|
|
152
|
+
state.items.append("new")
|
|
153
|
+
|
|
154
|
+
# On error, changes are rolled back:
|
|
155
|
+
try:
|
|
156
|
+
async with store.edit_state() as state:
|
|
157
|
+
state.counter += 1
|
|
158
|
+
raise Exception("Error!") # Rollback happens
|
|
159
|
+
except Exception:
|
|
160
|
+
pass # state.counter unchanged
|
|
161
|
+
```
|
|
162
|
+
"""
|
|
163
|
+
async with self._lock:
|
|
164
|
+
# State is always initialized in __init__, but keep check for safety
|
|
165
|
+
if self._state is None:
|
|
166
|
+
raise ContextStateError("State not initialized.")
|
|
167
|
+
|
|
168
|
+
# Use smart copy instead of deepcopy
|
|
169
|
+
state_copy = self._smart_copy(self._state)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
yield state_copy
|
|
173
|
+
# Commit changes on successful exit
|
|
174
|
+
self._state = state_copy
|
|
175
|
+
except Exception:
|
|
176
|
+
# Rollback on error (don't commit)
|
|
177
|
+
raise
|
|
178
|
+
|
|
179
|
+
def to_dict(self) -> dict[str, Any]:
|
|
180
|
+
"""
|
|
181
|
+
Serialize current state to dictionary.
|
|
182
|
+
|
|
183
|
+
Returns dictionary representation including store type, state type metadata,
|
|
184
|
+
and serialized state data.
|
|
185
|
+
"""
|
|
186
|
+
state_type_name = self._state.__class__.__name__ if self._state else self._state_type.__name__
|
|
187
|
+
state_data = self._state.model_dump() if self._state else {}
|
|
188
|
+
|
|
189
|
+
return SerializedState(
|
|
190
|
+
state_type=state_type_name,
|
|
191
|
+
store_type="in_memory",
|
|
192
|
+
data=state_data,
|
|
193
|
+
).model_dump()
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from functools import wraps
|
|
3
|
+
from typing import Any, Callable, Type, get_origin
|
|
4
|
+
|
|
5
|
+
from novastack_workflows.events import Event
|
|
6
|
+
from novastack_workflows.exceptions import WorkflowValidationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def step(
|
|
10
|
+
*,
|
|
11
|
+
when: Type[Event] | list[Type[Event]],
|
|
12
|
+
timeout: float | None = None,
|
|
13
|
+
max_retries: int = 0,
|
|
14
|
+
retry_delay: float = 1.0,
|
|
15
|
+
) -> Callable:
|
|
16
|
+
"""
|
|
17
|
+
Decorator to mark a method as a workflow step.
|
|
18
|
+
|
|
19
|
+
The decorated method will be automatically registered as a step
|
|
20
|
+
for the specified event type(s) when the workflow class is defined.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
when: The Event class or list of Event classes this step handles.
|
|
24
|
+
- Single event: Step executes when that event arrives
|
|
25
|
+
- List of events: Step executes when all events are collected (join)
|
|
26
|
+
timeout: Optional timeout in seconds for step execution
|
|
27
|
+
max_retries: Number of retry attempts on failure (default: 0)
|
|
28
|
+
retry_delay: Delay in seconds between retry attempts (default: 1)
|
|
29
|
+
|
|
30
|
+
Note:
|
|
31
|
+
For event steps, the signature must be:
|
|
32
|
+
async def fn(self, ctx: Context, ev: EventType) -> Event | None
|
|
33
|
+
|
|
34
|
+
For join steps, the signature must be:
|
|
35
|
+
async def fn(self, ctx: Context, events: dict[type, Event]) -> Event | None
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def decorator(f: Callable) -> Callable:
|
|
39
|
+
if not inspect.iscoroutinefunction(f):
|
|
40
|
+
raise WorkflowValidationError(
|
|
41
|
+
f"Step method '{f.__name__}' must be an async function."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Normalize input: convert single event to list for consistency
|
|
45
|
+
is_join_step = isinstance(when, list)
|
|
46
|
+
events_list: list[Type[Event]] = when if is_join_step else [when] # type: ignore
|
|
47
|
+
|
|
48
|
+
if len(events_list) == 0:
|
|
49
|
+
raise WorkflowValidationError(
|
|
50
|
+
f"Step '{f.__name__}': 'when' cannot be empty. "
|
|
51
|
+
f"Must specify at least one event type."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
seen_event_types = set()
|
|
55
|
+
for event_type in events_list:
|
|
56
|
+
if not (isinstance(event_type, type) and issubclass(event_type, Event)):
|
|
57
|
+
raise WorkflowValidationError(
|
|
58
|
+
f"Step '{f.__name__}': All event types must be Event subclasses. "
|
|
59
|
+
f"Got: {event_type}"
|
|
60
|
+
)
|
|
61
|
+
if event_type in seen_event_types:
|
|
62
|
+
raise WorkflowValidationError(
|
|
63
|
+
f"Step '{f.__name__}': Duplicate event types not allowed. "
|
|
64
|
+
f"Duplicate: {event_type.__name__}"
|
|
65
|
+
)
|
|
66
|
+
seen_event_types.add(event_type)
|
|
67
|
+
|
|
68
|
+
# Validate step signature
|
|
69
|
+
sig = inspect.signature(f)
|
|
70
|
+
params = list(sig.parameters.items())
|
|
71
|
+
|
|
72
|
+
if len(params) < 3:
|
|
73
|
+
raise WorkflowValidationError(
|
|
74
|
+
f"Step '{f.__name__}': Must have at least 3 parameters: "
|
|
75
|
+
f"self, ctx, ev/events. Got: {[p[0] for p in params]}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Get the third parameter (event parameter)
|
|
79
|
+
event_param_name, event_param = params[2]
|
|
80
|
+
|
|
81
|
+
# Validate signature based on event step vs join step
|
|
82
|
+
if is_join_step:
|
|
83
|
+
# Join step: third param should be 'events' with dict type
|
|
84
|
+
if event_param_name != "events":
|
|
85
|
+
raise WorkflowValidationError(
|
|
86
|
+
f"Step '{f.__name__}': Join steps must use parameter name "
|
|
87
|
+
f"'events' (not '{event_param_name}'). "
|
|
88
|
+
f"Expected signature: async def {f.__name__}(self, ctx: Context, "
|
|
89
|
+
f"events: dict[type, Event]) -> Event | None"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Check type annotation if present
|
|
93
|
+
if event_param.annotation != inspect.Parameter.empty:
|
|
94
|
+
annotation = event_param.annotation
|
|
95
|
+
# Handle dict[type, Event] or dict type annotations
|
|
96
|
+
origin = get_origin(annotation)
|
|
97
|
+
if origin is not dict and annotation is not dict:
|
|
98
|
+
raise WorkflowValidationError(
|
|
99
|
+
f"Step '{f.__name__}': Join steps must annotate 'events' "
|
|
100
|
+
f"parameter as 'dict[type, Event]' or 'dict'. "
|
|
101
|
+
f"Got: {annotation}"
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
# Event step: third param should be 'ev'
|
|
105
|
+
if event_param_name != "ev":
|
|
106
|
+
raise WorkflowValidationError(
|
|
107
|
+
f"Step '{f.__name__}': Event steps must use parameter name "
|
|
108
|
+
f"'ev' (not '{event_param_name}'). "
|
|
109
|
+
f"Expected signature: async def {f.__name__}(self, ctx: Context, "
|
|
110
|
+
f"ev: {events_list[0].__name__}) -> Event | None"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Store metadata on the function
|
|
114
|
+
f._step_metadata_ = {
|
|
115
|
+
"when": events_list,
|
|
116
|
+
"is_join_step": is_join_step,
|
|
117
|
+
"timeout": timeout,
|
|
118
|
+
"max_retries": max_retries,
|
|
119
|
+
"retry_delay": retry_delay,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@wraps(f)
|
|
123
|
+
async def wrapper(*args, **kwargs):
|
|
124
|
+
return await f(*args, **kwargs)
|
|
125
|
+
|
|
126
|
+
# Copy metadata to wrapper
|
|
127
|
+
wrapper._step_metadata_ = f._step_metadata_
|
|
128
|
+
|
|
129
|
+
return wrapper # type: ignore
|
|
130
|
+
|
|
131
|
+
return decorator
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def is_step_method(method: Any) -> bool:
|
|
135
|
+
"""Check if a method is decorated with @step."""
|
|
136
|
+
return hasattr(method, "_step_metadata_")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_step_event_types(method: Any) -> list[Type[Event]] | None:
|
|
140
|
+
"""Get the normalized list of event types that a step method handles."""
|
|
141
|
+
metadata = getattr(method, "_step_metadata_", None)
|
|
142
|
+
if metadata is None:
|
|
143
|
+
return None
|
|
144
|
+
return metadata.get("when")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_join_step(method: Any) -> bool:
|
|
148
|
+
metadata = getattr(method, "_step_metadata_", None)
|
|
149
|
+
if metadata is None:
|
|
150
|
+
return False
|
|
151
|
+
return metadata.get("is_join_step", False)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def get_step_name(method: Any) -> str | None:
|
|
155
|
+
"""Get the name of a step method."""
|
|
156
|
+
if not is_step_method(method):
|
|
157
|
+
return None
|
|
158
|
+
return method.__name__ if hasattr(method, "__name__") else None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_step_timeout(method: Any) -> float | None:
|
|
162
|
+
"""Get the timeout configuration for a step method."""
|
|
163
|
+
metadata = getattr(method, "_step_metadata_", None)
|
|
164
|
+
if metadata is None:
|
|
165
|
+
return None
|
|
166
|
+
return metadata.get("timeout")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_step_max_retries(method: Any) -> int:
|
|
170
|
+
"""Get the max retries configuration for a step method."""
|
|
171
|
+
metadata = getattr(method, "_step_metadata_", None)
|
|
172
|
+
if metadata is None:
|
|
173
|
+
return 0
|
|
174
|
+
return metadata.get("max_retries", 0)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_step_retry_delay(method: Any) -> float:
|
|
178
|
+
"""Get the retry delay configuration for a step method."""
|
|
179
|
+
metadata = getattr(method, "_step_metadata_", None)
|
|
180
|
+
if metadata is None:
|
|
181
|
+
return 1.0
|
|
182
|
+
return metadata.get("retry_delay", 1.0)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from novastack_workflows.schemas import DictLikeModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Event(DictLikeModel):
|
|
9
|
+
"""Base class for all workflow events with dict-like interface."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StartEvent(Event):
|
|
13
|
+
"""
|
|
14
|
+
Workflow initialization event.
|
|
15
|
+
|
|
16
|
+
This event marks the beginning of a workflow execution and can carry
|
|
17
|
+
any dynamic fields needed to initialize the workflow state.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class StopEvent(Event):
|
|
22
|
+
"""
|
|
23
|
+
Workflow completion event.
|
|
24
|
+
|
|
25
|
+
This event marks the end of a workflow execution and typically contains
|
|
26
|
+
the final result. Additional dynamic fields can be included as needed.
|
|
27
|
+
|
|
28
|
+
Attributes:
|
|
29
|
+
result: The final result of the workflow execution.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
result: Any = Field(
|
|
33
|
+
default=None,
|
|
34
|
+
description="The final result of the workflow execution",
|
|
35
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class WorkflowError(Exception):
|
|
2
|
+
"""Generic exception for workflow-related errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class WorkflowValidationError(WorkflowError):
|
|
6
|
+
"""Raised when the workflow configuration or step signatures are invalid."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WorkflowRuntimeError(WorkflowError):
|
|
10
|
+
"""Raised when runtime errors during step execution or event routing."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WorkflowTimeoutError(WorkflowError):
|
|
14
|
+
"""Raised when a step run exceeds the configured timeout."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ContextStateError(WorkflowError):
|
|
18
|
+
"""Raised when a context method is called in the wrong state."""
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
try:
|
|
2
|
+
import novastack.core # noqa: F401
|
|
3
|
+
|
|
4
|
+
except ImportError as e:
|
|
5
|
+
raise ImportError(
|
|
6
|
+
"Missing dependency 'novastack-core' required for prebuilt workflows. "
|
|
7
|
+
"Install with `pip install novastack-workflows[prebuilt]`."
|
|
8
|
+
) from e
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
from novastack_workflows.prebuilt.document_ingestion import DocumentIngestionWorkflow
|
|
12
|
+
|
|
13
|
+
__all__ = ["DocumentIngestionWorkflow"]
|