pyworkflow-engine 0.1.7__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.
Files changed (196) hide show
  1. dashboard/backend/app/__init__.py +1 -0
  2. dashboard/backend/app/config.py +32 -0
  3. dashboard/backend/app/controllers/__init__.py +6 -0
  4. dashboard/backend/app/controllers/run_controller.py +86 -0
  5. dashboard/backend/app/controllers/workflow_controller.py +33 -0
  6. dashboard/backend/app/dependencies/__init__.py +5 -0
  7. dashboard/backend/app/dependencies/storage.py +50 -0
  8. dashboard/backend/app/repositories/__init__.py +6 -0
  9. dashboard/backend/app/repositories/run_repository.py +80 -0
  10. dashboard/backend/app/repositories/workflow_repository.py +27 -0
  11. dashboard/backend/app/rest/__init__.py +8 -0
  12. dashboard/backend/app/rest/v1/__init__.py +12 -0
  13. dashboard/backend/app/rest/v1/health.py +33 -0
  14. dashboard/backend/app/rest/v1/runs.py +133 -0
  15. dashboard/backend/app/rest/v1/workflows.py +41 -0
  16. dashboard/backend/app/schemas/__init__.py +23 -0
  17. dashboard/backend/app/schemas/common.py +16 -0
  18. dashboard/backend/app/schemas/event.py +24 -0
  19. dashboard/backend/app/schemas/hook.py +25 -0
  20. dashboard/backend/app/schemas/run.py +54 -0
  21. dashboard/backend/app/schemas/step.py +28 -0
  22. dashboard/backend/app/schemas/workflow.py +31 -0
  23. dashboard/backend/app/server.py +87 -0
  24. dashboard/backend/app/services/__init__.py +6 -0
  25. dashboard/backend/app/services/run_service.py +240 -0
  26. dashboard/backend/app/services/workflow_service.py +155 -0
  27. dashboard/backend/main.py +18 -0
  28. docs/concepts/cancellation.mdx +362 -0
  29. docs/concepts/continue-as-new.mdx +434 -0
  30. docs/concepts/events.mdx +266 -0
  31. docs/concepts/fault-tolerance.mdx +370 -0
  32. docs/concepts/hooks.mdx +552 -0
  33. docs/concepts/limitations.mdx +167 -0
  34. docs/concepts/schedules.mdx +775 -0
  35. docs/concepts/sleep.mdx +312 -0
  36. docs/concepts/steps.mdx +301 -0
  37. docs/concepts/workflows.mdx +255 -0
  38. docs/guides/cli.mdx +942 -0
  39. docs/guides/configuration.mdx +560 -0
  40. docs/introduction.mdx +155 -0
  41. docs/quickstart.mdx +279 -0
  42. examples/__init__.py +1 -0
  43. examples/celery/__init__.py +1 -0
  44. examples/celery/durable/docker-compose.yml +55 -0
  45. examples/celery/durable/pyworkflow.config.yaml +12 -0
  46. examples/celery/durable/workflows/__init__.py +122 -0
  47. examples/celery/durable/workflows/basic.py +87 -0
  48. examples/celery/durable/workflows/batch_processing.py +102 -0
  49. examples/celery/durable/workflows/cancellation.py +273 -0
  50. examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
  51. examples/celery/durable/workflows/child_workflows.py +202 -0
  52. examples/celery/durable/workflows/continue_as_new.py +260 -0
  53. examples/celery/durable/workflows/fault_tolerance.py +210 -0
  54. examples/celery/durable/workflows/hooks.py +211 -0
  55. examples/celery/durable/workflows/idempotency.py +112 -0
  56. examples/celery/durable/workflows/long_running.py +99 -0
  57. examples/celery/durable/workflows/retries.py +101 -0
  58. examples/celery/durable/workflows/schedules.py +209 -0
  59. examples/celery/transient/01_basic_workflow.py +91 -0
  60. examples/celery/transient/02_fault_tolerance.py +257 -0
  61. examples/celery/transient/__init__.py +20 -0
  62. examples/celery/transient/pyworkflow.config.yaml +25 -0
  63. examples/local/__init__.py +1 -0
  64. examples/local/durable/01_basic_workflow.py +94 -0
  65. examples/local/durable/02_file_storage.py +132 -0
  66. examples/local/durable/03_retries.py +169 -0
  67. examples/local/durable/04_long_running.py +119 -0
  68. examples/local/durable/05_event_log.py +145 -0
  69. examples/local/durable/06_idempotency.py +148 -0
  70. examples/local/durable/07_hooks.py +334 -0
  71. examples/local/durable/08_cancellation.py +233 -0
  72. examples/local/durable/09_child_workflows.py +198 -0
  73. examples/local/durable/10_child_workflow_patterns.py +265 -0
  74. examples/local/durable/11_continue_as_new.py +249 -0
  75. examples/local/durable/12_schedules.py +198 -0
  76. examples/local/durable/__init__.py +1 -0
  77. examples/local/transient/01_quick_tasks.py +87 -0
  78. examples/local/transient/02_retries.py +130 -0
  79. examples/local/transient/03_sleep.py +141 -0
  80. examples/local/transient/__init__.py +1 -0
  81. pyworkflow/__init__.py +256 -0
  82. pyworkflow/aws/__init__.py +68 -0
  83. pyworkflow/aws/context.py +234 -0
  84. pyworkflow/aws/handler.py +184 -0
  85. pyworkflow/aws/testing.py +310 -0
  86. pyworkflow/celery/__init__.py +41 -0
  87. pyworkflow/celery/app.py +198 -0
  88. pyworkflow/celery/scheduler.py +315 -0
  89. pyworkflow/celery/tasks.py +1746 -0
  90. pyworkflow/cli/__init__.py +132 -0
  91. pyworkflow/cli/__main__.py +6 -0
  92. pyworkflow/cli/commands/__init__.py +1 -0
  93. pyworkflow/cli/commands/hooks.py +640 -0
  94. pyworkflow/cli/commands/quickstart.py +495 -0
  95. pyworkflow/cli/commands/runs.py +773 -0
  96. pyworkflow/cli/commands/scheduler.py +130 -0
  97. pyworkflow/cli/commands/schedules.py +794 -0
  98. pyworkflow/cli/commands/setup.py +703 -0
  99. pyworkflow/cli/commands/worker.py +413 -0
  100. pyworkflow/cli/commands/workflows.py +1257 -0
  101. pyworkflow/cli/output/__init__.py +1 -0
  102. pyworkflow/cli/output/formatters.py +321 -0
  103. pyworkflow/cli/output/styles.py +121 -0
  104. pyworkflow/cli/utils/__init__.py +1 -0
  105. pyworkflow/cli/utils/async_helpers.py +30 -0
  106. pyworkflow/cli/utils/config.py +130 -0
  107. pyworkflow/cli/utils/config_generator.py +344 -0
  108. pyworkflow/cli/utils/discovery.py +53 -0
  109. pyworkflow/cli/utils/docker_manager.py +651 -0
  110. pyworkflow/cli/utils/interactive.py +364 -0
  111. pyworkflow/cli/utils/storage.py +115 -0
  112. pyworkflow/config.py +329 -0
  113. pyworkflow/context/__init__.py +63 -0
  114. pyworkflow/context/aws.py +230 -0
  115. pyworkflow/context/base.py +416 -0
  116. pyworkflow/context/local.py +930 -0
  117. pyworkflow/context/mock.py +381 -0
  118. pyworkflow/core/__init__.py +0 -0
  119. pyworkflow/core/exceptions.py +353 -0
  120. pyworkflow/core/registry.py +313 -0
  121. pyworkflow/core/scheduled.py +328 -0
  122. pyworkflow/core/step.py +494 -0
  123. pyworkflow/core/workflow.py +294 -0
  124. pyworkflow/discovery.py +248 -0
  125. pyworkflow/engine/__init__.py +0 -0
  126. pyworkflow/engine/events.py +879 -0
  127. pyworkflow/engine/executor.py +682 -0
  128. pyworkflow/engine/replay.py +273 -0
  129. pyworkflow/observability/__init__.py +19 -0
  130. pyworkflow/observability/logging.py +234 -0
  131. pyworkflow/primitives/__init__.py +33 -0
  132. pyworkflow/primitives/child_handle.py +174 -0
  133. pyworkflow/primitives/child_workflow.py +372 -0
  134. pyworkflow/primitives/continue_as_new.py +101 -0
  135. pyworkflow/primitives/define_hook.py +150 -0
  136. pyworkflow/primitives/hooks.py +97 -0
  137. pyworkflow/primitives/resume_hook.py +210 -0
  138. pyworkflow/primitives/schedule.py +545 -0
  139. pyworkflow/primitives/shield.py +96 -0
  140. pyworkflow/primitives/sleep.py +100 -0
  141. pyworkflow/runtime/__init__.py +21 -0
  142. pyworkflow/runtime/base.py +179 -0
  143. pyworkflow/runtime/celery.py +310 -0
  144. pyworkflow/runtime/factory.py +101 -0
  145. pyworkflow/runtime/local.py +706 -0
  146. pyworkflow/scheduler/__init__.py +9 -0
  147. pyworkflow/scheduler/local.py +248 -0
  148. pyworkflow/serialization/__init__.py +0 -0
  149. pyworkflow/serialization/decoder.py +146 -0
  150. pyworkflow/serialization/encoder.py +162 -0
  151. pyworkflow/storage/__init__.py +54 -0
  152. pyworkflow/storage/base.py +612 -0
  153. pyworkflow/storage/config.py +185 -0
  154. pyworkflow/storage/dynamodb.py +1315 -0
  155. pyworkflow/storage/file.py +827 -0
  156. pyworkflow/storage/memory.py +549 -0
  157. pyworkflow/storage/postgres.py +1161 -0
  158. pyworkflow/storage/schemas.py +486 -0
  159. pyworkflow/storage/sqlite.py +1136 -0
  160. pyworkflow/utils/__init__.py +0 -0
  161. pyworkflow/utils/duration.py +177 -0
  162. pyworkflow/utils/schedule.py +391 -0
  163. pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
  164. pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
  165. pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
  166. pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
  167. pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
  168. pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
  169. tests/examples/__init__.py +0 -0
  170. tests/integration/__init__.py +0 -0
  171. tests/integration/test_cancellation.py +330 -0
  172. tests/integration/test_child_workflows.py +439 -0
  173. tests/integration/test_continue_as_new.py +428 -0
  174. tests/integration/test_dynamodb_storage.py +1146 -0
  175. tests/integration/test_fault_tolerance.py +369 -0
  176. tests/integration/test_schedule_storage.py +484 -0
  177. tests/unit/__init__.py +0 -0
  178. tests/unit/backends/__init__.py +1 -0
  179. tests/unit/backends/test_dynamodb_storage.py +1554 -0
  180. tests/unit/backends/test_postgres_storage.py +1281 -0
  181. tests/unit/backends/test_sqlite_storage.py +1460 -0
  182. tests/unit/conftest.py +41 -0
  183. tests/unit/test_cancellation.py +364 -0
  184. tests/unit/test_child_workflows.py +680 -0
  185. tests/unit/test_continue_as_new.py +441 -0
  186. tests/unit/test_event_limits.py +316 -0
  187. tests/unit/test_executor.py +320 -0
  188. tests/unit/test_fault_tolerance.py +334 -0
  189. tests/unit/test_hooks.py +495 -0
  190. tests/unit/test_registry.py +261 -0
  191. tests/unit/test_replay.py +420 -0
  192. tests/unit/test_schedule_schemas.py +285 -0
  193. tests/unit/test_schedule_utils.py +286 -0
  194. tests/unit/test_scheduled_workflow.py +274 -0
  195. tests/unit/test_step.py +353 -0
  196. tests/unit/test_workflow.py +243 -0
@@ -0,0 +1,150 @@
1
+ """
2
+ Typed hooks with Pydantic validation.
3
+
4
+ Provides a type-safe way to define hooks with validated payloads.
5
+ """
6
+
7
+ from collections.abc import Awaitable, Callable
8
+ from typing import Generic, TypeVar
9
+
10
+ from pydantic import BaseModel, ValidationError
11
+
12
+ from pyworkflow.primitives.hooks import hook
13
+
14
+ T = TypeVar("T", bound=BaseModel)
15
+
16
+
17
+ class TypedHook(Generic[T]):
18
+ """
19
+ A hook that validates payload against a Pydantic schema.
20
+
21
+ Provides type-safe access to hook payloads with automatic validation.
22
+ Token is auto-generated in format "run_id:hook_id".
23
+
24
+ Example:
25
+ class ApprovalPayload(BaseModel):
26
+ approved: bool
27
+ reviewer: str
28
+ comments: Optional[str] = None
29
+
30
+ approval = define_hook("approval", ApprovalPayload)
31
+
32
+ # In workflow - result is typed as ApprovalPayload
33
+ result = await approval()
34
+ if result.approved:
35
+ await process_order(order_id)
36
+ """
37
+
38
+ def __init__(self, name: str, schema: type[T]) -> None:
39
+ """
40
+ Initialize a typed hook.
41
+
42
+ Args:
43
+ name: Hook name for logging/debugging
44
+ schema: Pydantic model class for payload validation
45
+ """
46
+ self.name = name
47
+ self.schema = schema
48
+
49
+ async def __call__(
50
+ self,
51
+ *,
52
+ timeout: str | int | None = None,
53
+ on_created: Callable[[str], Awaitable[None]] | None = None,
54
+ ) -> T:
55
+ """
56
+ Wait for external event and validate payload.
57
+
58
+ Args:
59
+ timeout: Optional maximum wait time:
60
+ - str: Duration string ("24h", "7d")
61
+ - int: Seconds
62
+ - None: Wait forever
63
+ on_created: Optional async callback invoked with the token when
64
+ the hook is created. Use this to notify external systems.
65
+
66
+ Returns:
67
+ Validated payload as the Pydantic model type
68
+
69
+ Raises:
70
+ ValidationError: If payload doesn't match schema
71
+ RuntimeError: If called outside a workflow context
72
+ """
73
+ payload = await hook(
74
+ self.name,
75
+ timeout=timeout,
76
+ on_created=on_created,
77
+ payload_schema=self.schema,
78
+ )
79
+
80
+ # Validate and return typed result
81
+ return self.schema.model_validate(payload)
82
+
83
+ def __repr__(self) -> str:
84
+ return f"TypedHook(name={self.name!r}, schema={self.schema.__name__})"
85
+
86
+
87
+ def define_hook(name: str, schema: type[T]) -> TypedHook[T]:
88
+ """
89
+ Create a typed hook with Pydantic validation.
90
+
91
+ This is the recommended way to create hooks when you want
92
+ type-safe, validated payloads. Token is auto-generated in
93
+ format "run_id:hook_id".
94
+
95
+ Args:
96
+ name: Hook name for logging/debugging
97
+ schema: Pydantic model class for payload validation
98
+
99
+ Returns:
100
+ TypedHook instance that can be awaited in workflows
101
+
102
+ Example:
103
+ # Define payload schema
104
+ class PaymentConfirmation(BaseModel):
105
+ transaction_id: str
106
+ amount: Decimal
107
+ status: Literal["success", "failed"]
108
+ timestamp: datetime
109
+
110
+ # Create typed hook
111
+ payment_confirmation = define_hook("payment", PaymentConfirmation)
112
+
113
+ # Use in workflow
114
+ @workflow
115
+ async def payment_workflow(order_id: str):
116
+ # Send payment request...
117
+
118
+ # Wait for payment confirmation (typed!)
119
+ async def notify_payment_system(token: str):
120
+ await send_webhook_url(f"/webhook/payment/{token}")
121
+
122
+ result: PaymentConfirmation = await payment_confirmation(
123
+ timeout="1h",
124
+ on_created=notify_payment_system,
125
+ )
126
+
127
+ if result.status == "success":
128
+ return {"order_id": order_id, "paid": True}
129
+ else:
130
+ return {"order_id": order_id, "paid": False}
131
+ """
132
+ return TypedHook(name, schema)
133
+
134
+
135
+ class HookValidationError(Exception):
136
+ """Raised when hook payload validation fails."""
137
+
138
+ def __init__(
139
+ self,
140
+ hook_name: str,
141
+ schema: type[BaseModel],
142
+ validation_error: ValidationError,
143
+ ) -> None:
144
+ self.hook_name = hook_name
145
+ self.schema = schema
146
+ self.validation_error = validation_error
147
+ super().__init__(
148
+ f"Hook '{hook_name}' payload validation failed for schema "
149
+ f"'{schema.__name__}': {validation_error}"
150
+ )
@@ -0,0 +1,97 @@
1
+ """
2
+ Hook primitive for waiting on external events.
3
+
4
+ Allows workflows to suspend and wait for external events such as
5
+ webhooks, manual approvals, or third-party callbacks.
6
+ """
7
+
8
+ from collections.abc import Awaitable, Callable
9
+ from typing import Any
10
+
11
+ from loguru import logger
12
+ from pydantic import BaseModel
13
+
14
+ from pyworkflow.context import get_context, has_context
15
+
16
+
17
+ async def hook(
18
+ name: str,
19
+ *,
20
+ timeout: str | int | None = None,
21
+ on_created: Callable[[str], Awaitable[None]] | None = None,
22
+ payload_schema: type[BaseModel] | None = None,
23
+ ) -> Any:
24
+ """
25
+ Wait for an external event (webhook, approval, callback).
26
+
27
+ The workflow suspends until resume_hook() is called with the token.
28
+ Token is auto-generated in format "run_id:hook_id".
29
+
30
+ Different contexts handle hooks differently:
31
+ - MockContext: Returns mock payload immediately (configurable)
32
+ - LocalContext (durable): Event-sourced hook with storage
33
+ - LocalContext (transient): Raises NotImplementedError
34
+
35
+ Args:
36
+ name: Human-readable name for the hook (for logging/debugging)
37
+ timeout: Optional maximum wait time:
38
+ - str: Duration string ("24h", "7d")
39
+ - int: Seconds
40
+ - None: Wait forever
41
+ on_created: Optional async callback invoked with the token when
42
+ the hook is created. Use this to notify external systems.
43
+ payload_schema: Optional Pydantic model class for payload validation.
44
+ When provided, the schema is stored with the hook for CLI resume.
45
+
46
+ Returns:
47
+ Payload from resume_hook()
48
+
49
+ Raises:
50
+ RuntimeError: If called outside a workflow context
51
+ NotImplementedError: If context doesn't support hooks
52
+
53
+ Examples:
54
+ # Simple hook with auto-generated token
55
+ payload = await hook("approval")
56
+
57
+ # With callback to notify external system
58
+ async def notify_approver(token: str):
59
+ await send_email(f"Approve at /webhook/{token}")
60
+
61
+ payload = await hook("approval", on_created=notify_approver)
62
+
63
+ # With timeout
64
+ payload = await hook("approval", timeout="24h")
65
+ """
66
+ if not has_context():
67
+ raise RuntimeError(
68
+ "hook() must be called within a workflow context. "
69
+ "Make sure you're using the @workflow decorator."
70
+ )
71
+
72
+ ctx = get_context()
73
+
74
+ # Parse timeout to seconds
75
+ timeout_seconds: int | None = None
76
+ if timeout is not None:
77
+ if isinstance(timeout, str):
78
+ from pyworkflow.utils.duration import parse_duration
79
+
80
+ timeout_seconds = parse_duration(timeout)
81
+ else:
82
+ timeout_seconds = int(timeout)
83
+
84
+ logger.debug(
85
+ f"Hook '{name}' via {ctx.__class__.__name__}",
86
+ run_id=ctx.run_id,
87
+ workflow_name=ctx.workflow_name,
88
+ )
89
+
90
+ # Call the context's hook method
91
+ # Token is auto-generated by context, on_created is called before suspension
92
+ return await ctx.hook(
93
+ name,
94
+ timeout=timeout_seconds,
95
+ on_created=on_created,
96
+ payload_schema=payload_schema,
97
+ )
@@ -0,0 +1,210 @@
1
+ """
2
+ Resume hook primitive for external event delivery.
3
+
4
+ Allows external systems to deliver payloads to suspended workflows.
5
+ Uses events for idempotency checks (no separate hook storage needed).
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from datetime import UTC, datetime
10
+ from typing import Any
11
+
12
+ from loguru import logger
13
+
14
+ from pyworkflow.core.exceptions import (
15
+ HookAlreadyReceivedError,
16
+ HookExpiredError,
17
+ HookNotFoundError,
18
+ InvalidTokenError,
19
+ )
20
+ from pyworkflow.engine.events import EventType
21
+ from pyworkflow.storage.base import StorageBackend
22
+
23
+ # Token format separator
24
+ HOOK_TOKEN_SEPARATOR = ":"
25
+
26
+
27
+ def parse_hook_token(token: str) -> tuple[str, str]:
28
+ """
29
+ Parse a composite hook token into run_id and hook_id.
30
+
31
+ Args:
32
+ token: Composite token in format "run_id:hook_id"
33
+
34
+ Returns:
35
+ Tuple of (run_id, hook_id)
36
+
37
+ Raises:
38
+ InvalidTokenError: If token format is invalid
39
+ """
40
+ parts = token.split(HOOK_TOKEN_SEPARATOR, 1)
41
+ if len(parts) != 2 or not parts[0] or not parts[1]:
42
+ raise InvalidTokenError(f"Invalid token format: {token}")
43
+ return parts[0], parts[1]
44
+
45
+
46
+ def create_hook_token(run_id: str, hook_id: str) -> str:
47
+ """
48
+ Create a composite hook token from run_id and hook_id.
49
+
50
+ Args:
51
+ run_id: The workflow run ID
52
+ hook_id: The hook ID
53
+
54
+ Returns:
55
+ Composite token in format "run_id:hook_id"
56
+ """
57
+ return f"{run_id}{HOOK_TOKEN_SEPARATOR}{hook_id}"
58
+
59
+
60
+ @dataclass
61
+ class ResumeResult:
62
+ """Result of a resume_hook operation."""
63
+
64
+ run_id: str
65
+ hook_id: str
66
+ status: str # "resumed", "already_received", "expired", "not_found"
67
+
68
+ def __repr__(self) -> str:
69
+ return f"ResumeResult(run_id={self.run_id!r}, hook_id={self.hook_id!r}, status={self.status!r})"
70
+
71
+
72
+ async def resume_hook(
73
+ token: str,
74
+ payload: Any,
75
+ *,
76
+ storage: StorageBackend | None = None,
77
+ ) -> ResumeResult:
78
+ """
79
+ Resume a suspended workflow with a payload.
80
+
81
+ This function is called by external systems (webhooks, APIs, etc.)
82
+ to deliver data to a waiting workflow.
83
+
84
+ Idempotency is checked via events:
85
+ - HOOK_CREATED event must exist for the hook_id
86
+ - HOOK_RECEIVED event must not exist (would mean already resumed)
87
+
88
+ Args:
89
+ token: The hook token (composite format: run_id:hook_id)
90
+ payload: Data to send to the workflow
91
+ storage: Storage backend. If None, uses the configured default.
92
+
93
+ Returns:
94
+ ResumeResult with run_id, hook_id, and status
95
+
96
+ Raises:
97
+ InvalidTokenError: If the token format is invalid
98
+ HookNotFoundError: If no hook exists with the given token
99
+ HookExpiredError: If the hook has expired
100
+ HookAlreadyReceivedError: If the hook was already resumed
101
+
102
+ Examples:
103
+ # In a FastAPI endpoint
104
+ @app.post("/webhook/{token}")
105
+ async def handle_webhook(token: str, payload: dict):
106
+ result = await resume_hook(token, payload)
107
+ return {"run_id": result.run_id, "status": result.status}
108
+
109
+ # With explicit storage
110
+ result = await resume_hook(
111
+ token="run_abc123:hook_approval_1",
112
+ payload={"approved": True},
113
+ storage=my_storage,
114
+ )
115
+ """
116
+ # Get storage backend
117
+ if storage is None:
118
+ from pyworkflow import get_storage
119
+
120
+ storage = get_storage()
121
+
122
+ if storage is None:
123
+ raise RuntimeError(
124
+ "No storage backend configured. "
125
+ "Either pass storage parameter or call pyworkflow.configure(storage=...)"
126
+ )
127
+
128
+ # Parse token to get run_id and hook_id
129
+ run_id, hook_id = parse_hook_token(token)
130
+
131
+ # Get all events for this run to check hook status
132
+ events = await storage.get_events(run_id)
133
+
134
+ # Find HOOK_CREATED event for this hook_id
135
+ hook_created_event = None
136
+ hook_received_event = None
137
+
138
+ for event in events:
139
+ if event.type == EventType.HOOK_CREATED:
140
+ if event.data.get("hook_id") == hook_id:
141
+ hook_created_event = event
142
+ elif event.type == EventType.HOOK_RECEIVED and event.data.get("hook_id") == hook_id:
143
+ hook_received_event = event
144
+
145
+ # Check if hook was created
146
+ if hook_created_event is None:
147
+ logger.warning(f"Hook not found: {hook_id} (run_id={run_id})")
148
+ raise HookNotFoundError(token)
149
+
150
+ # Check if already received (idempotency check)
151
+ if hook_received_event is not None:
152
+ logger.warning(f"Hook already received: {hook_id}")
153
+ raise HookAlreadyReceivedError(hook_id)
154
+
155
+ # Check expiration
156
+ expires_at_str = hook_created_event.data.get("expires_at")
157
+ if expires_at_str:
158
+ expires_at = datetime.fromisoformat(expires_at_str)
159
+ if datetime.now(UTC) > expires_at:
160
+ logger.warning(f"Hook expired: {hook_id}")
161
+ raise HookExpiredError(hook_id)
162
+
163
+ logger.info(
164
+ f"Resuming hook: {hook_id}",
165
+ run_id=run_id,
166
+ hook_id=hook_id,
167
+ )
168
+
169
+ # Record HOOK_RECEIVED event (this is the idempotency marker and payload store)
170
+ from pyworkflow.engine.events import create_hook_received_event
171
+ from pyworkflow.serialization.encoder import serialize
172
+ from pyworkflow.storage.schemas import HookStatus
173
+
174
+ serialized_payload = serialize(payload)
175
+
176
+ event = create_hook_received_event(
177
+ run_id=run_id,
178
+ hook_id=hook_id,
179
+ payload=serialized_payload,
180
+ )
181
+ await storage.record_event(event)
182
+
183
+ # Update hook status in storage
184
+ await storage.update_hook_status(
185
+ hook_id=hook_id,
186
+ status=HookStatus.RECEIVED,
187
+ payload=serialized_payload,
188
+ )
189
+
190
+ # Schedule workflow resumption via configured runtime
191
+ from pyworkflow.config import get_config
192
+ from pyworkflow.runtime import get_runtime
193
+
194
+ config = get_config()
195
+ runtime = get_runtime(config.default_runtime)
196
+
197
+ try:
198
+ await runtime.schedule_resume(run_id, storage)
199
+ except Exception as e:
200
+ logger.warning(
201
+ f"Failed to schedule workflow resumption: {e}",
202
+ run_id=run_id,
203
+ hook_id=hook_id,
204
+ )
205
+
206
+ return ResumeResult(
207
+ run_id=run_id,
208
+ hook_id=hook_id,
209
+ status="resumed",
210
+ )