edda-framework 0.1.0__py3-none-any.whl → 0.3.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.
- edda/__init__.py +2 -0
- edda/activity.py +32 -19
- edda/integrations/__init__.py +1 -0
- edda/integrations/mcp/__init__.py +40 -0
- edda/integrations/mcp/decorators.py +168 -0
- edda/integrations/mcp/server.py +257 -0
- edda/wsgi.py +77 -0
- {edda_framework-0.1.0.dist-info → edda_framework-0.3.0.dist-info}/METADATA +95 -1
- {edda_framework-0.1.0.dist-info → edda_framework-0.3.0.dist-info}/RECORD +12 -7
- {edda_framework-0.1.0.dist-info → edda_framework-0.3.0.dist-info}/WHEEL +0 -0
- {edda_framework-0.1.0.dist-info → edda_framework-0.3.0.dist-info}/entry_points.txt +0 -0
- {edda_framework-0.1.0.dist-info → edda_framework-0.3.0.dist-info}/licenses/LICENSE +0 -0
edda/__init__.py
CHANGED
|
@@ -30,6 +30,7 @@ from edda.hooks import HooksBase, WorkflowHooks
|
|
|
30
30
|
from edda.outbox import OutboxRelayer, send_event_transactional
|
|
31
31
|
from edda.retry import RetryPolicy
|
|
32
32
|
from edda.workflow import workflow
|
|
33
|
+
from edda.wsgi import create_wsgi_app
|
|
33
34
|
|
|
34
35
|
__version__ = "0.1.0"
|
|
35
36
|
|
|
@@ -53,4 +54,5 @@ __all__ = [
|
|
|
53
54
|
"RetryPolicy",
|
|
54
55
|
"RetryExhaustedError",
|
|
55
56
|
"TerminalError",
|
|
57
|
+
"create_wsgi_app",
|
|
56
58
|
]
|
edda/activity.py
CHANGED
|
@@ -13,6 +13,8 @@ import time
|
|
|
13
13
|
from collections.abc import Callable
|
|
14
14
|
from typing import Any, TypeVar, cast
|
|
15
15
|
|
|
16
|
+
import anyio
|
|
17
|
+
|
|
16
18
|
from edda.context import WorkflowContext
|
|
17
19
|
from edda.exceptions import RetryExhaustedError, TerminalError, WorkflowCancelledException
|
|
18
20
|
from edda.pydantic_utils import (
|
|
@@ -40,12 +42,13 @@ class Activity:
|
|
|
40
42
|
Initialize activity wrapper.
|
|
41
43
|
|
|
42
44
|
Args:
|
|
43
|
-
func: The async function to wrap
|
|
45
|
+
func: The async or sync function to wrap
|
|
44
46
|
retry_policy: Optional retry policy for this activity.
|
|
45
47
|
If None, uses the default policy from EddaApp.
|
|
46
48
|
"""
|
|
47
49
|
self.func = func
|
|
48
50
|
self.name = func.__name__
|
|
51
|
+
self.is_async = inspect.iscoroutinefunction(func)
|
|
49
52
|
self.retry_policy = retry_policy
|
|
50
53
|
functools.update_wrapper(self, func)
|
|
51
54
|
|
|
@@ -284,8 +287,12 @@ class Activity:
|
|
|
284
287
|
"kwargs": {k: to_json_dict(v) for k, v in kwargs.items()},
|
|
285
288
|
}
|
|
286
289
|
|
|
287
|
-
# Execute the activity function
|
|
288
|
-
|
|
290
|
+
# Execute the activity function (sync or async)
|
|
291
|
+
if self.is_async:
|
|
292
|
+
result = await self.func(ctx, *args, **kwargs)
|
|
293
|
+
else:
|
|
294
|
+
# Run sync function in thread pool to avoid blocking
|
|
295
|
+
result = await anyio.to_thread.run_sync(self.func, ctx, *args, **kwargs)
|
|
289
296
|
|
|
290
297
|
# Convert Pydantic model result to JSON dict for storage
|
|
291
298
|
result_for_storage = to_json_dict(result)
|
|
@@ -369,7 +376,7 @@ class Activity:
|
|
|
369
376
|
return self.retry_policy
|
|
370
377
|
|
|
371
378
|
# Priority 2: App-level policy (EddaApp default_retry_policy)
|
|
372
|
-
#
|
|
379
|
+
# Set by ReplayEngine when creating WorkflowContext (edda/replay.py)
|
|
373
380
|
if hasattr(ctx, "_app_retry_policy") and ctx._app_retry_policy is not None:
|
|
374
381
|
return cast(RetryPolicy, ctx._app_retry_policy)
|
|
375
382
|
|
|
@@ -426,8 +433,9 @@ def activity(
|
|
|
426
433
|
"""
|
|
427
434
|
Decorator for defining activities (atomic units of work) with automatic retry.
|
|
428
435
|
|
|
429
|
-
Activities
|
|
430
|
-
parameter, followed by any other parameters.
|
|
436
|
+
Activities can be async or sync functions that take a WorkflowContext as the first
|
|
437
|
+
parameter, followed by any other parameters. Sync functions are executed in a
|
|
438
|
+
thread pool to avoid blocking the event loop.
|
|
431
439
|
|
|
432
440
|
Activities are automatically wrapped in a transaction, ensuring that
|
|
433
441
|
activity execution, history recording, and event sending are atomic.
|
|
@@ -443,34 +451,39 @@ def activity(
|
|
|
443
451
|
Workflow function.
|
|
444
452
|
|
|
445
453
|
Example:
|
|
446
|
-
>>> @activity #
|
|
447
|
-
...
|
|
448
|
-
... # Your business logic here
|
|
454
|
+
>>> @activity # Sync activity (no async/await)
|
|
455
|
+
... def reserve_inventory(ctx: WorkflowContext, order_id: str) -> dict:
|
|
456
|
+
... # Your business logic here (executed in thread pool)
|
|
457
|
+
... return {"reservation_id": "123"}
|
|
458
|
+
|
|
459
|
+
>>> @activity # Async activity (recommended for I/O-bound operations)
|
|
460
|
+
... async def reserve_inventory_async(ctx: WorkflowContext, order_id: str) -> dict:
|
|
461
|
+
... # Async I/O operations
|
|
449
462
|
... return {"reservation_id": "123"}
|
|
450
463
|
|
|
451
464
|
>>> from edda.retry import RetryPolicy, AGGRESSIVE_RETRY
|
|
452
465
|
>>> @activity(retry_policy=AGGRESSIVE_RETRY) # Custom retry policy
|
|
453
|
-
...
|
|
466
|
+
... def process_payment(ctx: WorkflowContext, amount: float) -> dict:
|
|
454
467
|
... # Fast retries for low-latency services
|
|
455
468
|
... return {"status": "completed"}
|
|
456
469
|
|
|
457
470
|
>>> @activity # Non-idempotent operations cached during replay
|
|
458
|
-
...
|
|
471
|
+
... def charge_credit_card(ctx: WorkflowContext, amount: float) -> dict:
|
|
459
472
|
... # External API call - result is cached, won't be called again on replay
|
|
460
473
|
... # If this fails, automatic retry with exponential backoff
|
|
461
474
|
... return {"transaction_id": "txn_123"}
|
|
462
475
|
|
|
463
476
|
>>> from edda.exceptions import TerminalError
|
|
464
477
|
>>> @activity
|
|
465
|
-
...
|
|
466
|
-
... user =
|
|
478
|
+
... def validate_user(ctx: WorkflowContext, user_id: str) -> dict:
|
|
479
|
+
... user = fetch_user(user_id) # No await needed for sync
|
|
467
480
|
... if not user:
|
|
468
481
|
... # Don't retry - user doesn't exist
|
|
469
482
|
... raise TerminalError(f"User {user_id} not found")
|
|
470
483
|
... return {"user_id": user_id, "name": user.name}
|
|
471
484
|
|
|
472
485
|
Args:
|
|
473
|
-
func: Async function to wrap as an activity
|
|
486
|
+
func: Async or sync function to wrap as an activity
|
|
474
487
|
retry_policy: Optional retry policy for this activity.
|
|
475
488
|
If None, uses the default policy from EddaApp.
|
|
476
489
|
|
|
@@ -480,14 +493,14 @@ def activity(
|
|
|
480
493
|
Raises:
|
|
481
494
|
RetryExhaustedError: When all retry attempts are exhausted
|
|
482
495
|
TerminalError: For non-retryable errors (no retry attempted)
|
|
496
|
+
|
|
497
|
+
Sync activities are executed in a thread pool. For I/O-bound operations
|
|
498
|
+
(database queries, HTTP requests, etc.), async activities are recommended
|
|
499
|
+
for better performance.
|
|
483
500
|
"""
|
|
484
501
|
|
|
485
502
|
def decorator(f: F) -> F:
|
|
486
|
-
#
|
|
487
|
-
if not inspect.iscoroutinefunction(f):
|
|
488
|
-
raise TypeError(f"Activity {f.__name__} must be an async function")
|
|
489
|
-
|
|
490
|
-
# Create the Activity wrapper with retry policy
|
|
503
|
+
# Create the Activity wrapper with retry policy (supports both sync and async)
|
|
491
504
|
activity_wrapper = Activity(f, retry_policy=retry_policy)
|
|
492
505
|
|
|
493
506
|
# Mark as activity for introspection
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Edda integrations package."""
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Edda MCP (Model Context Protocol) Integration.
|
|
3
|
+
|
|
4
|
+
Provides MCP server functionality for Edda durable workflows,
|
|
5
|
+
enabling long-running workflow tools via the MCP protocol.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
```python
|
|
9
|
+
from edda.integrations.mcp import EddaMCPServer
|
|
10
|
+
from edda import WorkflowContext, activity
|
|
11
|
+
|
|
12
|
+
server = EddaMCPServer(
|
|
13
|
+
name="Order Service",
|
|
14
|
+
db_url="postgresql://user:pass@localhost/orders",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
@activity
|
|
18
|
+
async def reserve_inventory(ctx, items):
|
|
19
|
+
return {"reserved": True}
|
|
20
|
+
|
|
21
|
+
@server.durable_tool(description="Process order workflow")
|
|
22
|
+
async def process_order(ctx: WorkflowContext, order_id: str):
|
|
23
|
+
await reserve_inventory(ctx, [order_id], activity_id="reserve:1")
|
|
24
|
+
return {"status": "completed"}
|
|
25
|
+
|
|
26
|
+
# Deploy with uvicorn
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
import uvicorn
|
|
29
|
+
uvicorn.run(server.asgi_app(), host="0.0.0.0", port=8000)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The server automatically generates three MCP tools for each @durable_tool:
|
|
33
|
+
- `tool_name`: Start the workflow, returns instance_id
|
|
34
|
+
- `tool_name_status`: Check workflow status
|
|
35
|
+
- `tool_name_result`: Get workflow result (if completed)
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from edda.integrations.mcp.server import EddaMCPServer
|
|
39
|
+
|
|
40
|
+
__all__ = ["EddaMCPServer"]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Decorators for MCP durable tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from edda.workflow import workflow
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from edda.integrations.mcp.server import EddaMCPServer
|
|
13
|
+
from edda.workflow import Workflow
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_durable_tool(
|
|
17
|
+
server: EddaMCPServer,
|
|
18
|
+
func: Callable,
|
|
19
|
+
*,
|
|
20
|
+
description: str = "",
|
|
21
|
+
) -> Workflow:
|
|
22
|
+
"""
|
|
23
|
+
Create a durable workflow tool with auto-generated status/result tools.
|
|
24
|
+
|
|
25
|
+
This function:
|
|
26
|
+
1. Wraps the function as an Edda @workflow
|
|
27
|
+
2. Registers three MCP tools:
|
|
28
|
+
- {name}: Start workflow, return instance_id
|
|
29
|
+
- {name}_status: Check workflow status
|
|
30
|
+
- {name}_result: Get workflow result
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
server: EddaMCPServer instance
|
|
34
|
+
func: Async workflow function
|
|
35
|
+
description: Tool description
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Workflow instance
|
|
39
|
+
"""
|
|
40
|
+
# 1. Create Edda workflow
|
|
41
|
+
workflow_instance: Workflow = workflow(func, event_handler=False)
|
|
42
|
+
workflow_name = func.__name__
|
|
43
|
+
|
|
44
|
+
# Register in server's workflow registry
|
|
45
|
+
server._workflows[workflow_name] = workflow_instance
|
|
46
|
+
|
|
47
|
+
# 2. Generate main tool (start workflow)
|
|
48
|
+
tool_description = description or func.__doc__ or f"Start {workflow_name} workflow"
|
|
49
|
+
|
|
50
|
+
# Extract parameters from workflow function (excluding ctx)
|
|
51
|
+
sig = inspect.signature(func)
|
|
52
|
+
params = [
|
|
53
|
+
param
|
|
54
|
+
for name, param in sig.parameters.items()
|
|
55
|
+
if name != "ctx" # Exclude WorkflowContext parameter
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# Create the tool function
|
|
59
|
+
async def start_tool(**kwargs: Any) -> dict:
|
|
60
|
+
"""
|
|
61
|
+
Start workflow and return instance_id.
|
|
62
|
+
|
|
63
|
+
This is the main entry point for the durable tool.
|
|
64
|
+
"""
|
|
65
|
+
# Remove 'ctx' if provided by client (workflow will inject it)
|
|
66
|
+
kwargs.pop("ctx", None)
|
|
67
|
+
|
|
68
|
+
# Start Edda workflow
|
|
69
|
+
instance_id = await workflow_instance.start(**kwargs)
|
|
70
|
+
|
|
71
|
+
# Return MCP-compliant response
|
|
72
|
+
return {
|
|
73
|
+
"content": [
|
|
74
|
+
{
|
|
75
|
+
"type": "text",
|
|
76
|
+
"text": (
|
|
77
|
+
f"Workflow '{workflow_name}' started successfully.\n"
|
|
78
|
+
f"Instance ID: {instance_id}\n\n"
|
|
79
|
+
f"Use '{workflow_name}_status' tool with instance_id='{instance_id}' to check progress.\n"
|
|
80
|
+
f"Use '{workflow_name}_result' tool to get the final result once completed."
|
|
81
|
+
),
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
"isError": False,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Override the function's signature for introspection (FastMCP uses this for schema generation)
|
|
88
|
+
start_tool.__signature__ = inspect.Signature(parameters=params) # type: ignore[attr-defined]
|
|
89
|
+
|
|
90
|
+
# Register with FastMCP (call as function, not decorator syntax)
|
|
91
|
+
server._mcp.tool(name=workflow_name, description=tool_description)(start_tool)
|
|
92
|
+
|
|
93
|
+
# 3. Generate status tool
|
|
94
|
+
status_tool_name = f"{workflow_name}_status"
|
|
95
|
+
status_tool_description = f"Check status of {workflow_name} workflow"
|
|
96
|
+
|
|
97
|
+
@server._mcp.tool(name=status_tool_name, description=status_tool_description)
|
|
98
|
+
async def status_tool(instance_id: str) -> dict:
|
|
99
|
+
"""Check workflow status."""
|
|
100
|
+
try:
|
|
101
|
+
instance = await server._edda_app.storage.get_instance(instance_id)
|
|
102
|
+
|
|
103
|
+
status = instance["status"]
|
|
104
|
+
current_activity_id = instance.get("current_activity_id", "N/A")
|
|
105
|
+
|
|
106
|
+
status_text = (
|
|
107
|
+
f"Workflow Status: {status}\n"
|
|
108
|
+
f"Current Activity: {current_activity_id}\n"
|
|
109
|
+
f"Instance ID: {instance_id}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
"content": [{"type": "text", "text": status_text}],
|
|
114
|
+
"isError": False,
|
|
115
|
+
}
|
|
116
|
+
except Exception as e:
|
|
117
|
+
return {
|
|
118
|
+
"content": [
|
|
119
|
+
{
|
|
120
|
+
"type": "text",
|
|
121
|
+
"text": f"Error checking status: {str(e)}",
|
|
122
|
+
}
|
|
123
|
+
],
|
|
124
|
+
"isError": True,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# 4. Generate result tool
|
|
128
|
+
result_tool_name = f"{workflow_name}_result"
|
|
129
|
+
result_tool_description = f"Get result of {workflow_name} workflow (if completed)"
|
|
130
|
+
|
|
131
|
+
@server._mcp.tool(name=result_tool_name, description=result_tool_description)
|
|
132
|
+
async def result_tool(instance_id: str) -> dict:
|
|
133
|
+
"""Get workflow result (if completed)."""
|
|
134
|
+
try:
|
|
135
|
+
instance = await server._edda_app.storage.get_instance(instance_id)
|
|
136
|
+
|
|
137
|
+
status = instance["status"]
|
|
138
|
+
|
|
139
|
+
if status != "completed":
|
|
140
|
+
return {
|
|
141
|
+
"content": [
|
|
142
|
+
{
|
|
143
|
+
"type": "text",
|
|
144
|
+
"text": f"Workflow not completed yet. Current status: {status}",
|
|
145
|
+
}
|
|
146
|
+
],
|
|
147
|
+
"isError": True,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
output_data = instance.get("output_data")
|
|
151
|
+
result_text = f"Workflow Result:\n{output_data}"
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
"content": [{"type": "text", "text": result_text}],
|
|
155
|
+
"isError": False,
|
|
156
|
+
}
|
|
157
|
+
except Exception as e:
|
|
158
|
+
return {
|
|
159
|
+
"content": [
|
|
160
|
+
{
|
|
161
|
+
"type": "text",
|
|
162
|
+
"text": f"Error getting result: {str(e)}",
|
|
163
|
+
}
|
|
164
|
+
],
|
|
165
|
+
"isError": True,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return workflow_instance
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""MCP Server implementation for Edda workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from edda.app import EddaApp
|
|
9
|
+
from edda.workflow import Workflow
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from mcp.server.fastmcp import FastMCP
|
|
13
|
+
except ImportError as e:
|
|
14
|
+
raise ImportError(
|
|
15
|
+
"MCP Python SDK is required for MCP integration. "
|
|
16
|
+
"Install it with: pip install edda-framework[mcp]"
|
|
17
|
+
) from e
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EddaMCPServer:
|
|
21
|
+
"""
|
|
22
|
+
MCP (Model Context Protocol) server for Edda durable workflows.
|
|
23
|
+
|
|
24
|
+
Integrates EddaApp (CloudEvents + Workflows) with FastMCP to provide
|
|
25
|
+
long-running workflow tools via the MCP protocol.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
```python
|
|
29
|
+
from edda.integrations.mcp import EddaMCPServer
|
|
30
|
+
from edda import WorkflowContext, activity
|
|
31
|
+
|
|
32
|
+
server = EddaMCPServer(
|
|
33
|
+
name="Order Service",
|
|
34
|
+
db_url="postgresql://user:pass@localhost/orders",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@activity
|
|
38
|
+
async def reserve_inventory(ctx, items):
|
|
39
|
+
return {"reserved": True}
|
|
40
|
+
|
|
41
|
+
@server.durable_tool(description="Process order workflow")
|
|
42
|
+
async def process_order(ctx: WorkflowContext, order_id: str):
|
|
43
|
+
await reserve_inventory(ctx, [order_id], activity_id="reserve:1")
|
|
44
|
+
return {"status": "completed"}
|
|
45
|
+
|
|
46
|
+
# Deploy with uvicorn (HTTP transport)
|
|
47
|
+
if __name__ == "__main__":
|
|
48
|
+
import uvicorn
|
|
49
|
+
uvicorn.run(server.asgi_app(), host="0.0.0.0", port=8000)
|
|
50
|
+
|
|
51
|
+
# Or deploy with stdio (for MCP clients, e.g., Claude Desktop)
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
import asyncio
|
|
54
|
+
|
|
55
|
+
async def main():
|
|
56
|
+
await server.initialize()
|
|
57
|
+
await server.run_stdio()
|
|
58
|
+
|
|
59
|
+
asyncio.run(main())
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The server automatically generates three MCP tools for each @durable_tool:
|
|
63
|
+
- `tool_name`: Start the workflow, returns instance_id
|
|
64
|
+
- `tool_name_status`: Check workflow status
|
|
65
|
+
- `tool_name_result`: Get workflow result (if completed)
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
name: str,
|
|
71
|
+
db_url: str,
|
|
72
|
+
*,
|
|
73
|
+
outbox_enabled: bool = False,
|
|
74
|
+
broker_url: str | None = None,
|
|
75
|
+
token_verifier: Callable[[str], bool] | None = None,
|
|
76
|
+
):
|
|
77
|
+
"""
|
|
78
|
+
Initialize MCP server.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
name: Service name (shown in MCP client)
|
|
82
|
+
db_url: Database URL for workflow storage
|
|
83
|
+
outbox_enabled: Enable transactional outbox pattern
|
|
84
|
+
broker_url: Message broker URL (if outbox enabled)
|
|
85
|
+
token_verifier: Optional function to verify authentication tokens
|
|
86
|
+
"""
|
|
87
|
+
self._name = name
|
|
88
|
+
self._edda_app = EddaApp(
|
|
89
|
+
service_name=name,
|
|
90
|
+
db_url=db_url,
|
|
91
|
+
outbox_enabled=outbox_enabled,
|
|
92
|
+
broker_url=broker_url,
|
|
93
|
+
)
|
|
94
|
+
self._mcp = FastMCP(name, json_response=True, stateless_http=True)
|
|
95
|
+
self._token_verifier = token_verifier
|
|
96
|
+
|
|
97
|
+
# Registry of durable tools (workflow_name -> Workflow instance)
|
|
98
|
+
self._workflows: dict[str, Workflow] = {}
|
|
99
|
+
|
|
100
|
+
def durable_tool(
|
|
101
|
+
self,
|
|
102
|
+
func: Callable | None = None,
|
|
103
|
+
*,
|
|
104
|
+
description: str = "",
|
|
105
|
+
) -> Callable:
|
|
106
|
+
"""
|
|
107
|
+
Decorator to define a durable workflow tool.
|
|
108
|
+
|
|
109
|
+
Automatically generates three MCP tools:
|
|
110
|
+
1. Main tool: Starts the workflow, returns instance_id
|
|
111
|
+
2. Status tool: Checks workflow status
|
|
112
|
+
3. Result tool: Gets workflow result (if completed)
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
func: Workflow function (async)
|
|
116
|
+
description: Tool description for MCP clients
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Decorated workflow instance
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
```python
|
|
123
|
+
@server.durable_tool(description="Long-running order processing")
|
|
124
|
+
async def process_order(ctx, order_id: str):
|
|
125
|
+
# Workflow logic
|
|
126
|
+
return {"status": "completed"}
|
|
127
|
+
```
|
|
128
|
+
"""
|
|
129
|
+
from edda.integrations.mcp.decorators import create_durable_tool
|
|
130
|
+
|
|
131
|
+
def decorator(f: Callable) -> Workflow:
|
|
132
|
+
return create_durable_tool(self, f, description=description)
|
|
133
|
+
|
|
134
|
+
if func is None:
|
|
135
|
+
return decorator
|
|
136
|
+
return decorator(func)
|
|
137
|
+
|
|
138
|
+
def asgi_app(self) -> Callable:
|
|
139
|
+
"""
|
|
140
|
+
Create ASGI application with MCP + CloudEvents support.
|
|
141
|
+
|
|
142
|
+
This method uses the Issue #1367 workaround: instead of using Mount,
|
|
143
|
+
we get the MCP's Starlette app directly and add Edda endpoints to it.
|
|
144
|
+
|
|
145
|
+
Routing:
|
|
146
|
+
- POST / -> FastMCP (MCP tools via streamable HTTP)
|
|
147
|
+
- POST /cancel/{instance_id} -> Workflow cancellation
|
|
148
|
+
- Other POST -> CloudEvents
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
ASGI callable (Starlette app)
|
|
152
|
+
"""
|
|
153
|
+
from starlette.requests import Request
|
|
154
|
+
from starlette.responses import Response
|
|
155
|
+
|
|
156
|
+
# Get MCP's Starlette app (Issue #1367 workaround: use directly)
|
|
157
|
+
app = self._mcp.streamable_http_app()
|
|
158
|
+
|
|
159
|
+
# Add Edda endpoints to Starlette router BEFORE wrapping with middleware
|
|
160
|
+
# Note: MCP's streamable HTTP is already mounted at "/" by default
|
|
161
|
+
# We add additional routes for Edda's CloudEvents and cancellation
|
|
162
|
+
|
|
163
|
+
async def edda_cancel_handler(request: Request) -> Response:
|
|
164
|
+
"""Handle workflow cancellation."""
|
|
165
|
+
instance_id = request.path_params["instance_id"]
|
|
166
|
+
|
|
167
|
+
# Create ASGI scope for EddaApp
|
|
168
|
+
scope = dict(request.scope)
|
|
169
|
+
scope["path"] = f"/cancel/{instance_id}"
|
|
170
|
+
|
|
171
|
+
# Capture response
|
|
172
|
+
response_data = {"status": 200, "headers": [], "body": b""}
|
|
173
|
+
|
|
174
|
+
async def send(message: dict) -> None:
|
|
175
|
+
if message["type"] == "http.response.start":
|
|
176
|
+
response_data["status"] = message["status"]
|
|
177
|
+
response_data["headers"] = message.get("headers", [])
|
|
178
|
+
elif message["type"] == "http.response.body":
|
|
179
|
+
response_data["body"] += message.get("body", b"")
|
|
180
|
+
|
|
181
|
+
# Forward to EddaApp
|
|
182
|
+
await self._edda_app(scope, request.receive, send)
|
|
183
|
+
|
|
184
|
+
# Return response
|
|
185
|
+
return Response(
|
|
186
|
+
content=response_data["body"],
|
|
187
|
+
status_code=response_data["status"],
|
|
188
|
+
headers=dict(response_data["headers"]),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Add cancel route
|
|
192
|
+
app.router.add_route("/cancel/{instance_id}", edda_cancel_handler, methods=["POST"])
|
|
193
|
+
|
|
194
|
+
# Add authentication middleware if token_verifier provided (AFTER adding routes)
|
|
195
|
+
if self._token_verifier is not None:
|
|
196
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
197
|
+
|
|
198
|
+
class AuthMiddleware(BaseHTTPMiddleware):
|
|
199
|
+
def __init__(self, app: Any, token_verifier: Callable):
|
|
200
|
+
super().__init__(app)
|
|
201
|
+
self.token_verifier = token_verifier
|
|
202
|
+
|
|
203
|
+
async def dispatch(self, request: Request, call_next: Callable):
|
|
204
|
+
auth_header = request.headers.get("authorization", "")
|
|
205
|
+
if auth_header.startswith("Bearer "):
|
|
206
|
+
token = auth_header[7:]
|
|
207
|
+
if not self.token_verifier(token):
|
|
208
|
+
return Response("Unauthorized", status_code=401)
|
|
209
|
+
return await call_next(request)
|
|
210
|
+
|
|
211
|
+
# Wrap app with auth middleware
|
|
212
|
+
app = AuthMiddleware(app, self._token_verifier)
|
|
213
|
+
|
|
214
|
+
return app
|
|
215
|
+
|
|
216
|
+
async def initialize(self) -> None:
|
|
217
|
+
"""
|
|
218
|
+
Initialize the EddaApp (setup replay engine, storage, etc.).
|
|
219
|
+
|
|
220
|
+
This method must be called before running the server in stdio mode.
|
|
221
|
+
For HTTP mode (asgi_app()), initialization happens automatically
|
|
222
|
+
when the ASGI app is deployed.
|
|
223
|
+
|
|
224
|
+
Example:
|
|
225
|
+
```python
|
|
226
|
+
async def main():
|
|
227
|
+
await server.initialize()
|
|
228
|
+
await server.run_stdio()
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
import asyncio
|
|
232
|
+
asyncio.run(main())
|
|
233
|
+
```
|
|
234
|
+
"""
|
|
235
|
+
await self._edda_app.initialize()
|
|
236
|
+
|
|
237
|
+
async def run_stdio(self) -> None:
|
|
238
|
+
"""
|
|
239
|
+
Run MCP server with stdio transport (for MCP clients, e.g., Claude Desktop).
|
|
240
|
+
|
|
241
|
+
This method uses stdin/stdout for JSON-RPC communication.
|
|
242
|
+
stderr can be used for diagnostic messages.
|
|
243
|
+
|
|
244
|
+
The server will block until terminated (Ctrl+C or SIGTERM).
|
|
245
|
+
|
|
246
|
+
Example:
|
|
247
|
+
```python
|
|
248
|
+
async def main():
|
|
249
|
+
await server.initialize()
|
|
250
|
+
await server.run_stdio()
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
import asyncio
|
|
254
|
+
asyncio.run(main())
|
|
255
|
+
```
|
|
256
|
+
"""
|
|
257
|
+
await self._mcp.run_stdio_async()
|
edda/wsgi.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WSGI adapter for Edda framework.
|
|
3
|
+
|
|
4
|
+
This module provides a WSGI adapter that wraps EddaApp (ASGI) for use with
|
|
5
|
+
WSGI servers like gunicorn or uWSGI.
|
|
6
|
+
|
|
7
|
+
The adapter uses a2wsgi to convert the ASGI interface to WSGI.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from a2wsgi import ASGIMiddleware
|
|
13
|
+
|
|
14
|
+
from edda.app import EddaApp
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_wsgi_app(edda_app: EddaApp) -> Any:
|
|
18
|
+
"""
|
|
19
|
+
Create a WSGI-compatible application from an EddaApp instance.
|
|
20
|
+
|
|
21
|
+
This function wraps an EddaApp (ASGI) with a2wsgi's ASGIMiddleware,
|
|
22
|
+
making it compatible with WSGI servers like gunicorn or uWSGI.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
edda_app: An initialized EddaApp instance
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
A WSGI-compatible application callable
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
Basic usage with EddaApp::
|
|
32
|
+
|
|
33
|
+
from edda import EddaApp
|
|
34
|
+
from edda.wsgi import create_wsgi_app
|
|
35
|
+
from edda.storage.sqlalchemy_storage import SQLAlchemyStorage
|
|
36
|
+
|
|
37
|
+
# Create storage and EddaApp
|
|
38
|
+
storage = SQLAlchemyStorage("sqlite:///edda.db")
|
|
39
|
+
app = EddaApp(storage=storage)
|
|
40
|
+
|
|
41
|
+
# Create WSGI application
|
|
42
|
+
wsgi_app = create_wsgi_app(app)
|
|
43
|
+
|
|
44
|
+
Running with gunicorn::
|
|
45
|
+
|
|
46
|
+
# In your module (e.g., demo_app.py):
|
|
47
|
+
from edda import EddaApp
|
|
48
|
+
from edda.wsgi import create_wsgi_app
|
|
49
|
+
|
|
50
|
+
application = EddaApp(...) # ASGI
|
|
51
|
+
wsgi_application = create_wsgi_app(application) # WSGI
|
|
52
|
+
|
|
53
|
+
# Command line:
|
|
54
|
+
$ gunicorn demo_app:wsgi_application --workers 4
|
|
55
|
+
|
|
56
|
+
Running with uWSGI::
|
|
57
|
+
|
|
58
|
+
$ uwsgi --http :8000 --wsgi-file demo_app.py --callable wsgi_application
|
|
59
|
+
|
|
60
|
+
Background tasks (auto-resume, timer checks, etc.) will run in each
|
|
61
|
+
worker process.
|
|
62
|
+
|
|
63
|
+
For production deployments, ASGI servers (uvicorn, hypercorn) are
|
|
64
|
+
recommended for better performance with Edda's async architecture.
|
|
65
|
+
WSGI support is provided for compatibility with existing infrastructure
|
|
66
|
+
and for users who prefer synchronous programming with sync activities.
|
|
67
|
+
|
|
68
|
+
See Also:
|
|
69
|
+
- :class:`edda.app.EddaApp`: The main ASGI application class
|
|
70
|
+
- :func:`edda.activity.activity`: Decorator supporting sync activities
|
|
71
|
+
"""
|
|
72
|
+
# Type ignore due to a2wsgi's strict ASGI type checking
|
|
73
|
+
# EddaApp implements ASGI 3.0 interface correctly
|
|
74
|
+
return ASGIMiddleware(edda_app) # type: ignore[arg-type]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = ["create_wsgi_app"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: edda-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Lightweight Durable Execution Framework
|
|
5
5
|
Project-URL: Homepage, https://github.com/i2y/edda
|
|
6
6
|
Project-URL: Documentation, https://github.com/i2y/edda#readme
|
|
@@ -20,7 +20,9 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
20
20
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
21
21
|
Classifier: Topic :: System :: Distributed Computing
|
|
22
22
|
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: a2wsgi>=1.10.0
|
|
23
24
|
Requires-Dist: aiosqlite>=0.21.0
|
|
25
|
+
Requires-Dist: anyio>=4.0.0
|
|
24
26
|
Requires-Dist: cloudevents>=1.12.0
|
|
25
27
|
Requires-Dist: httpx>=0.28.1
|
|
26
28
|
Requires-Dist: pydantic>=2.0.0
|
|
@@ -36,6 +38,8 @@ Requires-Dist: ruff>=0.14.2; extra == 'dev'
|
|
|
36
38
|
Requires-Dist: testcontainers[mysql]>=4.0.0; extra == 'dev'
|
|
37
39
|
Requires-Dist: testcontainers[postgres]>=4.0.0; extra == 'dev'
|
|
38
40
|
Requires-Dist: tsuno>=0.1.3; extra == 'dev'
|
|
41
|
+
Provides-Extra: mcp
|
|
42
|
+
Requires-Dist: mcp>=1.22.0; extra == 'mcp'
|
|
39
43
|
Provides-Extra: mysql
|
|
40
44
|
Requires-Dist: aiomysql>=0.2.0; extra == 'mysql'
|
|
41
45
|
Provides-Extra: postgresql
|
|
@@ -75,6 +79,8 @@ For detailed documentation, visit [https://i2y.github.io/edda/](https://i2y.gith
|
|
|
75
79
|
- 📦 **Transactional Outbox**: Reliable event publishing with guaranteed delivery
|
|
76
80
|
- ☁️ **CloudEvents Support**: Native support for CloudEvents protocol
|
|
77
81
|
- ⏱️ **Event & Timer Waiting**: Free up worker resources while waiting for events or timers, resume on any available worker
|
|
82
|
+
- 🤖 **MCP Integration**: Expose durable workflows as AI tools via Model Context Protocol
|
|
83
|
+
- 🌍 **ASGI/WSGI Support**: Deploy with your preferred server (uvicorn, gunicorn, uWSGI)
|
|
78
84
|
|
|
79
85
|
## Use Cases
|
|
80
86
|
|
|
@@ -638,6 +644,94 @@ api.mount("/workflows", edda_app)
|
|
|
638
644
|
|
|
639
645
|
This works with any ASGI framework (Starlette, FastAPI, Quart, etc.)
|
|
640
646
|
|
|
647
|
+
### WSGI Integration
|
|
648
|
+
|
|
649
|
+
For WSGI environments (gunicorn, uWSGI, Flask, Django), use the WSGI adapter:
|
|
650
|
+
|
|
651
|
+
```python
|
|
652
|
+
from edda import EddaApp
|
|
653
|
+
from edda.wsgi import create_wsgi_app
|
|
654
|
+
|
|
655
|
+
# Create Edda app
|
|
656
|
+
edda_app = EddaApp(db_url="sqlite:///workflow.db")
|
|
657
|
+
|
|
658
|
+
# Convert to WSGI
|
|
659
|
+
wsgi_application = create_wsgi_app(edda_app)
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
**Running with WSGI servers:**
|
|
663
|
+
|
|
664
|
+
```bash
|
|
665
|
+
# With Gunicorn
|
|
666
|
+
gunicorn demo_app:wsgi_application --workers 4
|
|
667
|
+
|
|
668
|
+
# With uWSGI
|
|
669
|
+
uwsgi --http :8000 --wsgi-file demo_app.py --callable wsgi_application
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
**Sync Activities**: For WSGI environments or legacy codebases, you can write synchronous activities:
|
|
673
|
+
|
|
674
|
+
```python
|
|
675
|
+
from edda import activity, WorkflowContext
|
|
676
|
+
|
|
677
|
+
@activity
|
|
678
|
+
def process_payment(ctx: WorkflowContext, amount: float) -> dict:
|
|
679
|
+
# Sync function - automatically executed in thread pool
|
|
680
|
+
# No async/await needed!
|
|
681
|
+
return {"status": "paid", "amount": amount}
|
|
682
|
+
|
|
683
|
+
@workflow
|
|
684
|
+
async def payment_workflow(ctx: WorkflowContext, order_id: str) -> dict:
|
|
685
|
+
# Workflows still use async (for deterministic replay)
|
|
686
|
+
result = await process_payment(ctx, 99.99, activity_id="pay:1")
|
|
687
|
+
return result
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
**Performance note**: ASGI servers (uvicorn, hypercorn) are recommended for better performance with Edda's async architecture. WSGI support is provided for compatibility with existing infrastructure and users who prefer synchronous programming.
|
|
691
|
+
|
|
692
|
+
## MCP Integration
|
|
693
|
+
|
|
694
|
+
Edda integrates with the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/), allowing AI assistants like Claude to interact with your durable workflows as long-running tools.
|
|
695
|
+
|
|
696
|
+
### Quick Example
|
|
697
|
+
|
|
698
|
+
```python
|
|
699
|
+
from edda.integrations.mcp import EddaMCPServer
|
|
700
|
+
from edda import WorkflowContext, activity
|
|
701
|
+
|
|
702
|
+
# Create MCP server
|
|
703
|
+
server = EddaMCPServer(
|
|
704
|
+
name="Order Service",
|
|
705
|
+
db_url="postgresql://user:pass@localhost/orders",
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
@activity
|
|
709
|
+
async def process_payment(ctx: WorkflowContext, amount: float):
|
|
710
|
+
return {"status": "paid", "amount": amount}
|
|
711
|
+
|
|
712
|
+
@server.durable_tool(description="Process customer order")
|
|
713
|
+
async def process_order(ctx: WorkflowContext, order_id: str):
|
|
714
|
+
await process_payment(ctx, 99.99)
|
|
715
|
+
return {"status": "completed", "order_id": order_id}
|
|
716
|
+
|
|
717
|
+
# Deploy with uvicorn
|
|
718
|
+
if __name__ == "__main__":
|
|
719
|
+
import uvicorn
|
|
720
|
+
uvicorn.run(server.asgi_app(), host="0.0.0.0", port=8000)
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
### Auto-Generated Tools
|
|
724
|
+
|
|
725
|
+
Each `@durable_tool` automatically generates **three MCP tools**:
|
|
726
|
+
|
|
727
|
+
1. **Main tool** (`process_order`): Starts the workflow, returns instance ID
|
|
728
|
+
2. **Status tool** (`process_order_status`): Checks workflow progress
|
|
729
|
+
3. **Result tool** (`process_order_result`): Gets final result when completed
|
|
730
|
+
|
|
731
|
+
This enables AI assistants to work with workflows that take minutes, hours, or even days to complete.
|
|
732
|
+
|
|
733
|
+
**For detailed documentation**, see [MCP Integration Guide](docs/integrations/mcp.md).
|
|
734
|
+
|
|
641
735
|
## Observability Hooks
|
|
642
736
|
|
|
643
737
|
Extend Edda with custom observability without coupling to specific tools:
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
edda/__init__.py,sha256=
|
|
2
|
-
edda/activity.py,sha256=
|
|
1
|
+
edda/__init__.py,sha256=gmJd0ooVbGNMOLlSj-r6rEt3IkY-FZYCFjhWjIltqlk,1657
|
|
2
|
+
edda/activity.py,sha256=nRm9eBrr0lFe4ZRQ2whyZ6mo5xd171ITIVhqytUhOpw,21025
|
|
3
3
|
edda/app.py,sha256=YxURvhaC1dKcymOwuS1PHU6_iOl_5cZWse2vAKSyX8w,37471
|
|
4
4
|
edda/compensation.py,sha256=CmnyJy4jAklVrtLJodNOcj6vxET6pdarxM1Yx2RHlL4,11898
|
|
5
5
|
edda/context.py,sha256=YZKBNtblRcaFqte1Y9t2cIP3JHzK-5Tu40x5i5FHtnU,17789
|
|
@@ -11,6 +11,11 @@ edda/pydantic_utils.py,sha256=dGVPNrrttDeq1k233PopCtjORYjZitsgASPfPnO6R10,9056
|
|
|
11
11
|
edda/replay.py,sha256=5RIRd0q2ZrH9iiiy35eOUii2cipYg9dlua56OAXvIk4,32499
|
|
12
12
|
edda/retry.py,sha256=t4_E1skrhotA1XWHTLbKi-DOgCMasOUnhI9OT-O_eCE,6843
|
|
13
13
|
edda/workflow.py,sha256=daSppYAzgXkjY_9-HS93Zi7_tPR6srmchxY5YfwgU-4,7239
|
|
14
|
+
edda/wsgi.py,sha256=1pGE5fhHpcsYnDR8S3NEFKWUs5P0JK4roTAzX9BsIj0,2391
|
|
15
|
+
edda/integrations/__init__.py,sha256=F_CaTvlDEbldfOpPKq_U9ve1E573tS6XzqXnOtyHcXI,33
|
|
16
|
+
edda/integrations/mcp/__init__.py,sha256=YK-8m0DIdP-RSqewlIX7xnWU7TD3NioCiW2_aZSgnn8,1232
|
|
17
|
+
edda/integrations/mcp/decorators.py,sha256=HYZi6Ic_w-ffqMzO0cmuio88OSm1FVQ5jzjePcW48QM,5541
|
|
18
|
+
edda/integrations/mcp/server.py,sha256=N-QTebRKd05LB9r2Cwywm4xU9wsbO_UAdYofoEhArms,8785
|
|
14
19
|
edda/outbox/__init__.py,sha256=azXG1rtheJEjOyoWmMsBeR2jp8Bz02R3wDEd5tQnaWA,424
|
|
15
20
|
edda/outbox/relayer.py,sha256=2tnN1aOQ8pKWfwEGIlYwYLLwyOKXBjZ4XZsIr1HjgK4,9454
|
|
16
21
|
edda/outbox/transactional.py,sha256=LFfUjunqRlGibaINi-efGXFFivWGO7v3mhqrqyGW6Nw,3808
|
|
@@ -28,8 +33,8 @@ edda/viewer_ui/data_service.py,sha256=mXV6bL6REa_UKsk8xMGBIFbsbLpIxe91lX3wgn-FOj
|
|
|
28
33
|
edda/visualizer/__init__.py,sha256=DOpDstNhR0VcXAs_eMKxaL30p_0u4PKZ4o2ndnYhiRo,343
|
|
29
34
|
edda/visualizer/ast_analyzer.py,sha256=plmx7C9X_X35xLY80jxOL3ljg3afXxBePRZubqUIkxY,13663
|
|
30
35
|
edda/visualizer/mermaid_generator.py,sha256=XWa2egoOTNDfJEjPcwoxwQmblUqXf7YInWFjFRI1QGo,12457
|
|
31
|
-
edda_framework-0.
|
|
32
|
-
edda_framework-0.
|
|
33
|
-
edda_framework-0.
|
|
34
|
-
edda_framework-0.
|
|
35
|
-
edda_framework-0.
|
|
36
|
+
edda_framework-0.3.0.dist-info/METADATA,sha256=fVS73kMWDrVBU3BYcRKFNMGlFhMB12dvaZbOoz2T_LI,29421
|
|
37
|
+
edda_framework-0.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
38
|
+
edda_framework-0.3.0.dist-info/entry_points.txt,sha256=dPH47s6UoJgUZxHoeSMqZsQkLaSE-SGLi-gh88k2WrU,48
|
|
39
|
+
edda_framework-0.3.0.dist-info/licenses/LICENSE,sha256=udxb-V7_cYKTHqW7lNm48rxJ-Zpf0WAY_PyGDK9BPCo,1069
|
|
40
|
+
edda_framework-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|