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.
- dashboard/backend/app/__init__.py +1 -0
- dashboard/backend/app/config.py +32 -0
- dashboard/backend/app/controllers/__init__.py +6 -0
- dashboard/backend/app/controllers/run_controller.py +86 -0
- dashboard/backend/app/controllers/workflow_controller.py +33 -0
- dashboard/backend/app/dependencies/__init__.py +5 -0
- dashboard/backend/app/dependencies/storage.py +50 -0
- dashboard/backend/app/repositories/__init__.py +6 -0
- dashboard/backend/app/repositories/run_repository.py +80 -0
- dashboard/backend/app/repositories/workflow_repository.py +27 -0
- dashboard/backend/app/rest/__init__.py +8 -0
- dashboard/backend/app/rest/v1/__init__.py +12 -0
- dashboard/backend/app/rest/v1/health.py +33 -0
- dashboard/backend/app/rest/v1/runs.py +133 -0
- dashboard/backend/app/rest/v1/workflows.py +41 -0
- dashboard/backend/app/schemas/__init__.py +23 -0
- dashboard/backend/app/schemas/common.py +16 -0
- dashboard/backend/app/schemas/event.py +24 -0
- dashboard/backend/app/schemas/hook.py +25 -0
- dashboard/backend/app/schemas/run.py +54 -0
- dashboard/backend/app/schemas/step.py +28 -0
- dashboard/backend/app/schemas/workflow.py +31 -0
- dashboard/backend/app/server.py +87 -0
- dashboard/backend/app/services/__init__.py +6 -0
- dashboard/backend/app/services/run_service.py +240 -0
- dashboard/backend/app/services/workflow_service.py +155 -0
- dashboard/backend/main.py +18 -0
- docs/concepts/cancellation.mdx +362 -0
- docs/concepts/continue-as-new.mdx +434 -0
- docs/concepts/events.mdx +266 -0
- docs/concepts/fault-tolerance.mdx +370 -0
- docs/concepts/hooks.mdx +552 -0
- docs/concepts/limitations.mdx +167 -0
- docs/concepts/schedules.mdx +775 -0
- docs/concepts/sleep.mdx +312 -0
- docs/concepts/steps.mdx +301 -0
- docs/concepts/workflows.mdx +255 -0
- docs/guides/cli.mdx +942 -0
- docs/guides/configuration.mdx +560 -0
- docs/introduction.mdx +155 -0
- docs/quickstart.mdx +279 -0
- examples/__init__.py +1 -0
- examples/celery/__init__.py +1 -0
- examples/celery/durable/docker-compose.yml +55 -0
- examples/celery/durable/pyworkflow.config.yaml +12 -0
- examples/celery/durable/workflows/__init__.py +122 -0
- examples/celery/durable/workflows/basic.py +87 -0
- examples/celery/durable/workflows/batch_processing.py +102 -0
- examples/celery/durable/workflows/cancellation.py +273 -0
- examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
- examples/celery/durable/workflows/child_workflows.py +202 -0
- examples/celery/durable/workflows/continue_as_new.py +260 -0
- examples/celery/durable/workflows/fault_tolerance.py +210 -0
- examples/celery/durable/workflows/hooks.py +211 -0
- examples/celery/durable/workflows/idempotency.py +112 -0
- examples/celery/durable/workflows/long_running.py +99 -0
- examples/celery/durable/workflows/retries.py +101 -0
- examples/celery/durable/workflows/schedules.py +209 -0
- examples/celery/transient/01_basic_workflow.py +91 -0
- examples/celery/transient/02_fault_tolerance.py +257 -0
- examples/celery/transient/__init__.py +20 -0
- examples/celery/transient/pyworkflow.config.yaml +25 -0
- examples/local/__init__.py +1 -0
- examples/local/durable/01_basic_workflow.py +94 -0
- examples/local/durable/02_file_storage.py +132 -0
- examples/local/durable/03_retries.py +169 -0
- examples/local/durable/04_long_running.py +119 -0
- examples/local/durable/05_event_log.py +145 -0
- examples/local/durable/06_idempotency.py +148 -0
- examples/local/durable/07_hooks.py +334 -0
- examples/local/durable/08_cancellation.py +233 -0
- examples/local/durable/09_child_workflows.py +198 -0
- examples/local/durable/10_child_workflow_patterns.py +265 -0
- examples/local/durable/11_continue_as_new.py +249 -0
- examples/local/durable/12_schedules.py +198 -0
- examples/local/durable/__init__.py +1 -0
- examples/local/transient/01_quick_tasks.py +87 -0
- examples/local/transient/02_retries.py +130 -0
- examples/local/transient/03_sleep.py +141 -0
- examples/local/transient/__init__.py +1 -0
- pyworkflow/__init__.py +256 -0
- pyworkflow/aws/__init__.py +68 -0
- pyworkflow/aws/context.py +234 -0
- pyworkflow/aws/handler.py +184 -0
- pyworkflow/aws/testing.py +310 -0
- pyworkflow/celery/__init__.py +41 -0
- pyworkflow/celery/app.py +198 -0
- pyworkflow/celery/scheduler.py +315 -0
- pyworkflow/celery/tasks.py +1746 -0
- pyworkflow/cli/__init__.py +132 -0
- pyworkflow/cli/__main__.py +6 -0
- pyworkflow/cli/commands/__init__.py +1 -0
- pyworkflow/cli/commands/hooks.py +640 -0
- pyworkflow/cli/commands/quickstart.py +495 -0
- pyworkflow/cli/commands/runs.py +773 -0
- pyworkflow/cli/commands/scheduler.py +130 -0
- pyworkflow/cli/commands/schedules.py +794 -0
- pyworkflow/cli/commands/setup.py +703 -0
- pyworkflow/cli/commands/worker.py +413 -0
- pyworkflow/cli/commands/workflows.py +1257 -0
- pyworkflow/cli/output/__init__.py +1 -0
- pyworkflow/cli/output/formatters.py +321 -0
- pyworkflow/cli/output/styles.py +121 -0
- pyworkflow/cli/utils/__init__.py +1 -0
- pyworkflow/cli/utils/async_helpers.py +30 -0
- pyworkflow/cli/utils/config.py +130 -0
- pyworkflow/cli/utils/config_generator.py +344 -0
- pyworkflow/cli/utils/discovery.py +53 -0
- pyworkflow/cli/utils/docker_manager.py +651 -0
- pyworkflow/cli/utils/interactive.py +364 -0
- pyworkflow/cli/utils/storage.py +115 -0
- pyworkflow/config.py +329 -0
- pyworkflow/context/__init__.py +63 -0
- pyworkflow/context/aws.py +230 -0
- pyworkflow/context/base.py +416 -0
- pyworkflow/context/local.py +930 -0
- pyworkflow/context/mock.py +381 -0
- pyworkflow/core/__init__.py +0 -0
- pyworkflow/core/exceptions.py +353 -0
- pyworkflow/core/registry.py +313 -0
- pyworkflow/core/scheduled.py +328 -0
- pyworkflow/core/step.py +494 -0
- pyworkflow/core/workflow.py +294 -0
- pyworkflow/discovery.py +248 -0
- pyworkflow/engine/__init__.py +0 -0
- pyworkflow/engine/events.py +879 -0
- pyworkflow/engine/executor.py +682 -0
- pyworkflow/engine/replay.py +273 -0
- pyworkflow/observability/__init__.py +19 -0
- pyworkflow/observability/logging.py +234 -0
- pyworkflow/primitives/__init__.py +33 -0
- pyworkflow/primitives/child_handle.py +174 -0
- pyworkflow/primitives/child_workflow.py +372 -0
- pyworkflow/primitives/continue_as_new.py +101 -0
- pyworkflow/primitives/define_hook.py +150 -0
- pyworkflow/primitives/hooks.py +97 -0
- pyworkflow/primitives/resume_hook.py +210 -0
- pyworkflow/primitives/schedule.py +545 -0
- pyworkflow/primitives/shield.py +96 -0
- pyworkflow/primitives/sleep.py +100 -0
- pyworkflow/runtime/__init__.py +21 -0
- pyworkflow/runtime/base.py +179 -0
- pyworkflow/runtime/celery.py +310 -0
- pyworkflow/runtime/factory.py +101 -0
- pyworkflow/runtime/local.py +706 -0
- pyworkflow/scheduler/__init__.py +9 -0
- pyworkflow/scheduler/local.py +248 -0
- pyworkflow/serialization/__init__.py +0 -0
- pyworkflow/serialization/decoder.py +146 -0
- pyworkflow/serialization/encoder.py +162 -0
- pyworkflow/storage/__init__.py +54 -0
- pyworkflow/storage/base.py +612 -0
- pyworkflow/storage/config.py +185 -0
- pyworkflow/storage/dynamodb.py +1315 -0
- pyworkflow/storage/file.py +827 -0
- pyworkflow/storage/memory.py +549 -0
- pyworkflow/storage/postgres.py +1161 -0
- pyworkflow/storage/schemas.py +486 -0
- pyworkflow/storage/sqlite.py +1136 -0
- pyworkflow/utils/__init__.py +0 -0
- pyworkflow/utils/duration.py +177 -0
- pyworkflow/utils/schedule.py +391 -0
- pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
- pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
- pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
- pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
- pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
- pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
- tests/examples/__init__.py +0 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_cancellation.py +330 -0
- tests/integration/test_child_workflows.py +439 -0
- tests/integration/test_continue_as_new.py +428 -0
- tests/integration/test_dynamodb_storage.py +1146 -0
- tests/integration/test_fault_tolerance.py +369 -0
- tests/integration/test_schedule_storage.py +484 -0
- tests/unit/__init__.py +0 -0
- tests/unit/backends/__init__.py +1 -0
- tests/unit/backends/test_dynamodb_storage.py +1554 -0
- tests/unit/backends/test_postgres_storage.py +1281 -0
- tests/unit/backends/test_sqlite_storage.py +1460 -0
- tests/unit/conftest.py +41 -0
- tests/unit/test_cancellation.py +364 -0
- tests/unit/test_child_workflows.py +680 -0
- tests/unit/test_continue_as_new.py +441 -0
- tests/unit/test_event_limits.py +316 -0
- tests/unit/test_executor.py +320 -0
- tests/unit/test_fault_tolerance.py +334 -0
- tests/unit/test_hooks.py +495 -0
- tests/unit/test_registry.py +261 -0
- tests/unit/test_replay.py +420 -0
- tests/unit/test_schedule_schemas.py +285 -0
- tests/unit/test_schedule_utils.py +286 -0
- tests/unit/test_scheduled_workflow.py +274 -0
- tests/unit/test_step.py +353 -0
- 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
|
+
)
|