edda-framework 0.5.0__py3-none-any.whl → 0.6.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.
@@ -19,14 +19,15 @@ def create_durable_tool(
19
19
  description: str = "",
20
20
  ) -> Workflow:
21
21
  """
22
- Create a durable workflow tool with auto-generated status/result tools.
22
+ Create a durable workflow tool with auto-generated status/result/cancel tools.
23
23
 
24
24
  This function:
25
25
  1. Wraps the function as an Edda @workflow
26
- 2. Registers three MCP tools:
26
+ 2. Registers four MCP tools:
27
27
  - {name}: Start workflow, return instance_id
28
28
  - {name}_status: Check workflow status
29
29
  - {name}_result: Get workflow result
30
+ - {name}_cancel: Cancel workflow (if running or waiting)
30
31
 
31
32
  Args:
32
33
  server: EddaMCPServer instance
@@ -93,9 +94,9 @@ def create_durable_tool(
93
94
  status_tool_name = f"{workflow_name}_status"
94
95
  status_tool_description = f"Check status of {workflow_name} workflow"
95
96
 
96
- @server._mcp.tool(name=status_tool_name, description=status_tool_description) # type: ignore[misc]
97
+ @server._mcp.tool(name=status_tool_name, description=status_tool_description)
97
98
  async def status_tool(instance_id: str) -> dict[str, Any]:
98
- """Check workflow status."""
99
+ """Check workflow status with progress metadata."""
99
100
  try:
100
101
  instance = await server.storage.get_instance(instance_id)
101
102
  if instance is None:
@@ -112,9 +113,22 @@ def create_durable_tool(
112
113
  status = instance["status"]
113
114
  current_activity_id = instance.get("current_activity_id", "N/A")
114
115
 
116
+ # Get history to count completed activities
117
+ history = await server.storage.get_history(instance_id)
118
+ completed_activities = len(
119
+ [h for h in history if h["event_type"] == "ActivityCompleted"]
120
+ )
121
+
122
+ # Suggest poll interval based on status
123
+ # Running workflows need more frequent polling (5s)
124
+ # Waiting workflows need less frequent polling (10s)
125
+ suggested_poll_interval_ms = 5000 if status == "running" else 10000
126
+
115
127
  status_text = (
116
128
  f"Workflow Status: {status}\n"
117
129
  f"Current Activity: {current_activity_id}\n"
130
+ f"Completed Activities: {completed_activities}\n"
131
+ f"Suggested Poll Interval: {suggested_poll_interval_ms}ms\n"
118
132
  f"Instance ID: {instance_id}"
119
133
  )
120
134
 
@@ -137,7 +151,7 @@ def create_durable_tool(
137
151
  result_tool_name = f"{workflow_name}_result"
138
152
  result_tool_description = f"Get result of {workflow_name} workflow (if completed)"
139
153
 
140
- @server._mcp.tool(name=result_tool_name, description=result_tool_description) # type: ignore[misc]
154
+ @server._mcp.tool(name=result_tool_name, description=result_tool_description)
141
155
  async def result_tool(instance_id: str) -> dict[str, Any]:
142
156
  """Get workflow result (if completed)."""
143
157
  try:
@@ -184,4 +198,86 @@ def create_durable_tool(
184
198
  "isError": True,
185
199
  }
186
200
 
201
+ # 5. Generate cancel tool
202
+ cancel_tool_name = f"{workflow_name}_cancel"
203
+ cancel_tool_description = f"Cancel {workflow_name} workflow (if running or waiting)"
204
+
205
+ @server._mcp.tool(name=cancel_tool_name, description=cancel_tool_description)
206
+ async def cancel_tool(instance_id: str) -> dict[str, Any]:
207
+ """Cancel a running or waiting workflow."""
208
+ try:
209
+ # Check if instance exists
210
+ instance = await server.storage.get_instance(instance_id)
211
+ if instance is None:
212
+ return {
213
+ "content": [
214
+ {
215
+ "type": "text",
216
+ "text": f"Workflow instance not found: {instance_id}",
217
+ }
218
+ ],
219
+ "isError": True,
220
+ }
221
+
222
+ current_status = instance["status"]
223
+
224
+ # Check if replay_engine is available
225
+ if server.replay_engine is None:
226
+ return {
227
+ "content": [
228
+ {
229
+ "type": "text",
230
+ "text": "Server not initialized. Call server.initialize() first.",
231
+ }
232
+ ],
233
+ "isError": True,
234
+ }
235
+
236
+ # Try to cancel
237
+ success = await server.replay_engine.cancel_workflow(
238
+ instance_id=instance_id,
239
+ cancelled_by="mcp_user",
240
+ )
241
+
242
+ if success:
243
+ return {
244
+ "content": [
245
+ {
246
+ "type": "text",
247
+ "text": (
248
+ f"Workflow '{workflow_name}' cancelled successfully.\n"
249
+ f"Instance ID: {instance_id}\n"
250
+ f"Compensations executed.\n\n"
251
+ f"The workflow has been stopped and any side effects "
252
+ f"have been rolled back."
253
+ ),
254
+ }
255
+ ],
256
+ "isError": False,
257
+ }
258
+ else:
259
+ return {
260
+ "content": [
261
+ {
262
+ "type": "text",
263
+ "text": (
264
+ f"Cannot cancel workflow: {instance_id}\n"
265
+ f"Current status: {current_status}\n"
266
+ f"Only running or waiting workflows can be cancelled."
267
+ ),
268
+ }
269
+ ],
270
+ "isError": True,
271
+ }
272
+ except Exception as e:
273
+ return {
274
+ "content": [
275
+ {
276
+ "type": "text",
277
+ "text": f"Error cancelling workflow: {str(e)}",
278
+ }
279
+ ],
280
+ "isError": True,
281
+ }
282
+
187
283
  return workflow_instance
@@ -9,10 +9,11 @@ from edda.app import EddaApp
9
9
  from edda.workflow import Workflow
10
10
 
11
11
  if TYPE_CHECKING:
12
+ from edda.replay import ReplayEngine
12
13
  from edda.storage.protocol import StorageProtocol
13
14
 
14
15
  try:
15
- from mcp.server.fastmcp import FastMCP # type: ignore[import-not-found]
16
+ from mcp.server.fastmcp import FastMCP
16
17
  except ImportError as e:
17
18
  raise ImportError(
18
19
  "MCP Python SDK is required for MCP integration. "
@@ -68,10 +69,11 @@ class EddaMCPServer:
68
69
  asyncio.run(main())
69
70
  ```
70
71
 
71
- The server automatically generates three MCP tools for each @durable_tool:
72
+ The server automatically generates four MCP tools for each @durable_tool:
72
73
  - `tool_name`: Start the workflow, returns instance_id
73
74
  - `tool_name_status`: Check workflow status
74
75
  - `tool_name_result`: Get workflow result (if completed)
76
+ - `tool_name_cancel`: Cancel workflow (if running or waiting)
75
77
  """
76
78
 
77
79
  def __init__(
@@ -122,6 +124,24 @@ class EddaMCPServer:
122
124
  """
123
125
  return self._edda_app.storage
124
126
 
127
+ @property
128
+ def replay_engine(self) -> ReplayEngine | None:
129
+ """
130
+ Access replay engine for workflow operations (cancel, resume, etc.).
131
+
132
+ Returns:
133
+ ReplayEngine or None if not initialized
134
+
135
+ Example:
136
+ ```python
137
+ # Cancel a running workflow
138
+ success = await server.replay_engine.cancel_workflow(
139
+ instance_id, "mcp_user"
140
+ )
141
+ ```
142
+ """
143
+ return self._edda_app.replay_engine
144
+
125
145
  def durable_tool(
126
146
  self,
127
147
  func: Callable[..., Any] | None = None,
@@ -131,10 +151,11 @@ class EddaMCPServer:
131
151
  """
132
152
  Decorator to define a durable workflow tool.
133
153
 
134
- Automatically generates three MCP tools:
154
+ Automatically generates four MCP tools:
135
155
  1. Main tool: Starts the workflow, returns instance_id
136
156
  2. Status tool: Checks workflow status
137
157
  3. Result tool: Gets workflow result (if completed)
158
+ 4. Cancel tool: Cancels workflow (if running or waiting)
138
159
 
139
160
  Args:
140
161
  func: Workflow function (async)
@@ -207,7 +228,7 @@ class EddaMCPServer:
207
228
  def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
208
229
  # Use FastMCP's native prompt decorator
209
230
  prompt_desc = description or f.__doc__ or f"Prompt: {f.__name__}"
210
- return cast(Callable[..., Any], self._mcp.prompt(description=prompt_desc)(f))
231
+ return self._mcp.prompt(description=prompt_desc)(f)
211
232
 
212
233
  if func is None:
213
234
  return decorator
@@ -228,8 +249,8 @@ class EddaMCPServer:
228
249
  Returns:
229
250
  ASGI callable (Starlette app)
230
251
  """
231
- from starlette.requests import Request # type: ignore[import-not-found]
232
- from starlette.responses import Response # type: ignore[import-not-found]
252
+ from starlette.requests import Request
253
+ from starlette.responses import Response
233
254
 
234
255
  # Get MCP's Starlette app (Issue #1367 workaround: use directly)
235
256
  app = self._mcp.streamable_http_app()
@@ -270,14 +291,13 @@ class EddaMCPServer:
270
291
  app.router.add_route("/cancel/{instance_id}", edda_cancel_handler, methods=["POST"])
271
292
 
272
293
  # Add authentication middleware if token_verifier provided (AFTER adding routes)
294
+ result_app: Any = app
273
295
  if self._token_verifier is not None:
274
- from starlette.middleware.base import ( # type: ignore[import-not-found]
275
- BaseHTTPMiddleware,
276
- )
296
+ from starlette.middleware.base import BaseHTTPMiddleware
277
297
 
278
- class AuthMiddleware(BaseHTTPMiddleware): # type: ignore[misc]
279
- def __init__(self, app: Any, token_verifier: Callable[[str], bool]):
280
- super().__init__(app)
298
+ class AuthMiddleware(BaseHTTPMiddleware):
299
+ def __init__(self, app_inner: Any, token_verifier: Callable[[str], bool]) -> None:
300
+ super().__init__(app_inner)
281
301
  self.token_verifier = token_verifier
282
302
 
283
303
  async def dispatch(
@@ -288,12 +308,13 @@ class EddaMCPServer:
288
308
  token = auth_header[7:]
289
309
  if not self.token_verifier(token):
290
310
  return Response("Unauthorized", status_code=401)
291
- return await call_next(request)
311
+ response: Response = await call_next(request)
312
+ return response
292
313
 
293
314
  # Wrap app with auth middleware
294
- app = AuthMiddleware(app, self._token_verifier)
315
+ result_app = AuthMiddleware(app, self._token_verifier)
295
316
 
296
- return cast(Callable[..., Any], app)
317
+ return cast(Callable[..., Any], result_app)
297
318
 
298
319
  async def initialize(self) -> None:
299
320
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: edda-framework
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -709,7 +709,7 @@ def process_payment(ctx: WorkflowContext, amount: float) -> dict:
709
709
  @workflow
710
710
  async def payment_workflow(ctx: WorkflowContext, order_id: str) -> dict:
711
711
  # Workflows still use async (for deterministic replay)
712
- result = await process_payment(ctx, 99.99, activity_id="pay:1")
712
+ result = await process_payment(ctx, 99.99)
713
713
  return result
714
714
  ```
715
715
 
@@ -748,13 +748,14 @@ if __name__ == "__main__":
748
748
 
749
749
  ### Auto-Generated Tools
750
750
 
751
- Each `@durable_tool` automatically generates **three MCP tools**:
751
+ Each `@durable_tool` automatically generates **four MCP tools**:
752
752
 
753
753
  1. **Main tool** (`process_order`): Starts the workflow, returns instance ID
754
- 2. **Status tool** (`process_order_status`): Checks workflow progress
754
+ 2. **Status tool** (`process_order_status`): Checks workflow progress with completed activity count and suggested poll interval
755
755
  3. **Result tool** (`process_order_result`): Gets final result when completed
756
+ 4. **Cancel tool** (`process_order_cancel`): Cancels workflow if running or waiting, executes compensation handlers
756
757
 
757
- This enables AI assistants to work with workflows that take minutes, hours, or even days to complete.
758
+ This enables AI assistants to work with workflows that take minutes, hours, or even days to complete, with full control over the workflow lifecycle.
758
759
 
759
760
  ### MCP Prompts
760
761
 
@@ -14,8 +14,8 @@ edda/workflow.py,sha256=daSppYAzgXkjY_9-HS93Zi7_tPR6srmchxY5YfwgU-4,7239
14
14
  edda/wsgi.py,sha256=1pGE5fhHpcsYnDR8S3NEFKWUs5P0JK4roTAzX9BsIj0,2391
15
15
  edda/integrations/__init__.py,sha256=F_CaTvlDEbldfOpPKq_U9ve1E573tS6XzqXnOtyHcXI,33
16
16
  edda/integrations/mcp/__init__.py,sha256=YK-8m0DIdP-RSqewlIX7xnWU7TD3NioCiW2_aZSgnn8,1232
17
- edda/integrations/mcp/decorators.py,sha256=UTBb-Un2JK938pDZmANOvfsdKOMI2AF9yGtfSuy8VrE,6284
18
- edda/integrations/mcp/server.py,sha256=pzCG46Zko9hHmQ3REbo1w3A23SjrGFLiZupwSkIPhOA,13942
17
+ edda/integrations/mcp/decorators.py,sha256=31SmbDwmHEGvUNa3aaatW91hBkpnS5iN9uy47dID3J4,10037
18
+ edda/integrations/mcp/server.py,sha256=Q5r4AbMn-9gBcy2CZocbgW7O0fn7Qb4e9CBJa1FEmzU,14507
19
19
  edda/integrations/opentelemetry/__init__.py,sha256=x1_PyyygGDW-rxQTwoIrGzyjKErXHOOKdquFAMlCOAo,906
20
20
  edda/integrations/opentelemetry/hooks.py,sha256=FBYnSZBh8_0vw9M1E2AbJrx1cTTsKeiHf5wspr0UnzU,21288
21
21
  edda/outbox/__init__.py,sha256=azXG1rtheJEjOyoWmMsBeR2jp8Bz02R3wDEd5tQnaWA,424
@@ -35,8 +35,8 @@ edda/viewer_ui/data_service.py,sha256=mXV6bL6REa_UKsk8xMGBIFbsbLpIxe91lX3wgn-FOj
35
35
  edda/visualizer/__init__.py,sha256=DOpDstNhR0VcXAs_eMKxaL30p_0u4PKZ4o2ndnYhiRo,343
36
36
  edda/visualizer/ast_analyzer.py,sha256=plmx7C9X_X35xLY80jxOL3ljg3afXxBePRZubqUIkxY,13663
37
37
  edda/visualizer/mermaid_generator.py,sha256=XWa2egoOTNDfJEjPcwoxwQmblUqXf7YInWFjFRI1QGo,12457
38
- edda_framework-0.5.0.dist-info/METADATA,sha256=GfZ0zYUy_g4zjV_OkYPPmNRTwIxZb77nh-aaS6Z_OYE,31503
39
- edda_framework-0.5.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
40
- edda_framework-0.5.0.dist-info/entry_points.txt,sha256=dPH47s6UoJgUZxHoeSMqZsQkLaSE-SGLi-gh88k2WrU,48
41
- edda_framework-0.5.0.dist-info/licenses/LICENSE,sha256=udxb-V7_cYKTHqW7lNm48rxJ-Zpf0WAY_PyGDK9BPCo,1069
42
- edda_framework-0.5.0.dist-info/RECORD,,
38
+ edda_framework-0.6.0.dist-info/METADATA,sha256=sxR8GtZNOzswVHIh8n_uAojtOrUEn_mXbT9Li5IvTnk,31702
39
+ edda_framework-0.6.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
40
+ edda_framework-0.6.0.dist-info/entry_points.txt,sha256=dPH47s6UoJgUZxHoeSMqZsQkLaSE-SGLi-gh88k2WrU,48
41
+ edda_framework-0.6.0.dist-info/licenses/LICENSE,sha256=udxb-V7_cYKTHqW7lNm48rxJ-Zpf0WAY_PyGDK9BPCo,1069
42
+ edda_framework-0.6.0.dist-info/RECORD,,