cellaflow 0.1.0__tar.gz
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.
- cellaflow-0.1.0/PKG-INFO +58 -0
- cellaflow-0.1.0/README.md +39 -0
- cellaflow-0.1.0/pyproject.toml +50 -0
- cellaflow-0.1.0/setup.cfg +4 -0
- cellaflow-0.1.0/src/cellaflow/__init__.py +5 -0
- cellaflow-0.1.0/src/cellaflow/client.py +165 -0
- cellaflow-0.1.0/src/cellaflow/context.py +38 -0
- cellaflow-0.1.0/src/cellaflow/decorators.py +304 -0
- cellaflow-0.1.0/src/cellaflow/idempotency.py +61 -0
- cellaflow-0.1.0/src/cellaflow/lease.py +120 -0
- cellaflow-0.1.0/src/cellaflow/serialization.py +29 -0
- cellaflow-0.1.0/src/cellaflow/v1/common_pb2.py +40 -0
- cellaflow-0.1.0/src/cellaflow/v1/common_pb2.pyi +137 -0
- cellaflow-0.1.0/src/cellaflow/v1/common_pb2_grpc.py +24 -0
- cellaflow-0.1.0/src/cellaflow/v1/common_pb2_grpc.pyi +20 -0
- cellaflow-0.1.0/src/cellaflow/v1/idempotency_pb2.py +55 -0
- cellaflow-0.1.0/src/cellaflow/v1/idempotency_pb2.pyi +351 -0
- cellaflow-0.1.0/src/cellaflow/v1/idempotency_pb2_grpc.py +24 -0
- cellaflow-0.1.0/src/cellaflow/v1/idempotency_pb2_grpc.pyi +20 -0
- cellaflow-0.1.0/src/cellaflow/v1/internal_pb2.py +41 -0
- cellaflow-0.1.0/src/cellaflow/v1/internal_pb2.pyi +108 -0
- cellaflow-0.1.0/src/cellaflow/v1/internal_pb2_grpc.py +24 -0
- cellaflow-0.1.0/src/cellaflow/v1/internal_pb2_grpc.pyi +20 -0
- cellaflow-0.1.0/src/cellaflow/v1/service_pb2.py +50 -0
- cellaflow-0.1.0/src/cellaflow/v1/service_pb2.pyi +204 -0
- cellaflow-0.1.0/src/cellaflow/v1/service_pb2_grpc.py +327 -0
- cellaflow-0.1.0/src/cellaflow/v1/service_pb2_grpc.pyi +146 -0
- cellaflow-0.1.0/src/cellaflow.egg-info/PKG-INFO +58 -0
- cellaflow-0.1.0/src/cellaflow.egg-info/SOURCES.txt +35 -0
- cellaflow-0.1.0/src/cellaflow.egg-info/dependency_links.txt +1 -0
- cellaflow-0.1.0/src/cellaflow.egg-info/requires.txt +13 -0
- cellaflow-0.1.0/src/cellaflow.egg-info/top_level.txt +1 -0
- cellaflow-0.1.0/tests/test_client.py +185 -0
- cellaflow-0.1.0/tests/test_decorators.py +283 -0
- cellaflow-0.1.0/tests/test_idempotency.py +81 -0
- cellaflow-0.1.0/tests/test_lease.py +55 -0
- cellaflow-0.1.0/tests/test_serialization.py +47 -0
cellaflow-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cellaflow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Cellaflow
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: grpcio>=1.60.0
|
|
8
|
+
Requires-Dist: protobuf>=4.25.0
|
|
9
|
+
Requires-Dist: msgpack>=1.0.7
|
|
10
|
+
Requires-Dist: rfc8785>=0.1.4
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
14
|
+
Requires-Dist: black>=24.0.0; extra == "dev"
|
|
15
|
+
Requires-Dist: flake8>=7.0.0; extra == "dev"
|
|
16
|
+
Requires-Dist: mypy>=1.8.0; extra == "dev"
|
|
17
|
+
Requires-Dist: grpcio-tools>=1.60.0; extra == "dev"
|
|
18
|
+
Requires-Dist: mypy-protobuf>=3.5.0; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# Cellaflow Python SDK
|
|
21
|
+
|
|
22
|
+
The official Python SDK for the Cellaflow Engine.
|
|
23
|
+
## Development Setup
|
|
24
|
+
|
|
25
|
+
To get up and running with the Python SDK for development, follow these steps:
|
|
26
|
+
|
|
27
|
+
1. **Create and activate a virtual environment**:
|
|
28
|
+
```bash
|
|
29
|
+
cd python
|
|
30
|
+
python3 -m venv venv
|
|
31
|
+
source venv/bin/activate
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
2. **Install the SDK in editable mode with development dependencies**:
|
|
35
|
+
```bash
|
|
36
|
+
pip install -e ".[dev]"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Generating Protobufs and Typing Stubs (`.pyi`)
|
|
40
|
+
|
|
41
|
+
We use `grpcio-tools` and `mypy-protobuf` to generate Python code and typing stubs from the `.proto` files. This ensures your IDE (e.g. VS Code with Pylance or mypy) will properly resolve gRPC stubs and message attributes.
|
|
42
|
+
|
|
43
|
+
To regenerate the protobuf definitions and their corresponding `.pyi` typing stubs, run the generation script from the `python` directory:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
./scripts/generate_protos.sh
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
This will automatically compile the protos from `../proto/cellaflow/v1/*.proto` and output both the Python code (`*_pb2.py`, `*_pb2_grpc.py`) and typing stubs (`*.pyi`) into `src/cellaflow/v1/`.
|
|
50
|
+
|
|
51
|
+
## Running Tests and Linters
|
|
52
|
+
|
|
53
|
+
You can validate your changes using the following tools:
|
|
54
|
+
|
|
55
|
+
- **Tests**: `pytest`
|
|
56
|
+
- **Formatting**: `black src tests`
|
|
57
|
+
- **Linting**: `flake8 src tests`
|
|
58
|
+
- **Type Checking**: `mypy src tests`
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Cellaflow Python SDK
|
|
2
|
+
|
|
3
|
+
The official Python SDK for the Cellaflow Engine.
|
|
4
|
+
## Development Setup
|
|
5
|
+
|
|
6
|
+
To get up and running with the Python SDK for development, follow these steps:
|
|
7
|
+
|
|
8
|
+
1. **Create and activate a virtual environment**:
|
|
9
|
+
```bash
|
|
10
|
+
cd python
|
|
11
|
+
python3 -m venv venv
|
|
12
|
+
source venv/bin/activate
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
2. **Install the SDK in editable mode with development dependencies**:
|
|
16
|
+
```bash
|
|
17
|
+
pip install -e ".[dev]"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Generating Protobufs and Typing Stubs (`.pyi`)
|
|
21
|
+
|
|
22
|
+
We use `grpcio-tools` and `mypy-protobuf` to generate Python code and typing stubs from the `.proto` files. This ensures your IDE (e.g. VS Code with Pylance or mypy) will properly resolve gRPC stubs and message attributes.
|
|
23
|
+
|
|
24
|
+
To regenerate the protobuf definitions and their corresponding `.pyi` typing stubs, run the generation script from the `python` directory:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
./scripts/generate_protos.sh
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This will automatically compile the protos from `../proto/cellaflow/v1/*.proto` and output both the Python code (`*_pb2.py`, `*_pb2_grpc.py`) and typing stubs (`*.pyi`) into `src/cellaflow/v1/`.
|
|
31
|
+
|
|
32
|
+
## Running Tests and Linters
|
|
33
|
+
|
|
34
|
+
You can validate your changes using the following tools:
|
|
35
|
+
|
|
36
|
+
- **Tests**: `pytest`
|
|
37
|
+
- **Formatting**: `black src tests`
|
|
38
|
+
- **Linting**: `flake8 src tests`
|
|
39
|
+
- **Type Checking**: `mypy src tests`
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cellaflow"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for Cellaflow"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"grpcio>=1.60.0",
|
|
13
|
+
"protobuf>=4.25.0",
|
|
14
|
+
"msgpack>=1.0.7",
|
|
15
|
+
"rfc8785>=0.1.4",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=8.0.0",
|
|
21
|
+
"pytest-asyncio>=0.23.0",
|
|
22
|
+
"black>=24.0.0",
|
|
23
|
+
"flake8>=7.0.0",
|
|
24
|
+
"mypy>=1.8.0",
|
|
25
|
+
"grpcio-tools>=1.60.0",
|
|
26
|
+
"mypy-protobuf>=3.5.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
addopts = "-ra -q"
|
|
34
|
+
testpaths = [
|
|
35
|
+
"tests",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[tool.black]
|
|
39
|
+
line-length = 88
|
|
40
|
+
force-exclude = ".*_pb2.*\\.py"
|
|
41
|
+
|
|
42
|
+
[tool.mypy]
|
|
43
|
+
python_version = "3.10"
|
|
44
|
+
strict = true
|
|
45
|
+
ignore_missing_imports = true
|
|
46
|
+
exclude = [
|
|
47
|
+
".*_pb2\\.py$",
|
|
48
|
+
".*_pb2_grpc\\.py$",
|
|
49
|
+
".*\\.pyi$"
|
|
50
|
+
]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import grpc
|
|
2
|
+
from typing import Any, Dict, List, Optional, Tuple, cast
|
|
3
|
+
|
|
4
|
+
from cellaflow.v1 import service_pb2, service_pb2_grpc
|
|
5
|
+
from cellaflow.v1 import common_pb2, idempotency_pb2
|
|
6
|
+
from cellaflow.serialization import serialize, deserialize
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CellaflowClient:
|
|
10
|
+
"""
|
|
11
|
+
gRPC Client for the Cellaflow Engine.
|
|
12
|
+
Handles communication with the engine and strictly uses
|
|
13
|
+
MessagePack for state payloads.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, target: str = "localhost:50051", secure: bool = False) -> None:
|
|
17
|
+
if secure:
|
|
18
|
+
self.channel = grpc.secure_channel(target, grpc.ssl_channel_credentials())
|
|
19
|
+
else:
|
|
20
|
+
self.channel = grpc.insecure_channel(target)
|
|
21
|
+
self.stub = service_pb2_grpc.WorkflowEngineServiceStub(self.channel)
|
|
22
|
+
|
|
23
|
+
def start_session(
|
|
24
|
+
self, workflow_id: str, version: str, session_id: Optional[str] = None
|
|
25
|
+
) -> service_pb2.StartSessionResponse:
|
|
26
|
+
req = service_pb2.StartSessionRequest(
|
|
27
|
+
workflow_id=workflow_id,
|
|
28
|
+
version=version,
|
|
29
|
+
)
|
|
30
|
+
if session_id:
|
|
31
|
+
req.session_id = session_id
|
|
32
|
+
|
|
33
|
+
return cast(service_pb2.StartSessionResponse, self.stub.StartSession(req))
|
|
34
|
+
|
|
35
|
+
def commit_step(
|
|
36
|
+
self,
|
|
37
|
+
session_id: str,
|
|
38
|
+
sequence: int,
|
|
39
|
+
name: str,
|
|
40
|
+
status: "common_pb2.StepStatus.ValueType",
|
|
41
|
+
output_payload: Dict[str, Any],
|
|
42
|
+
idempotency_key: Optional[str] = None,
|
|
43
|
+
idempotency_fencing_token: Optional[int] = None,
|
|
44
|
+
) -> service_pb2.CommitStepResponse:
|
|
45
|
+
# Strictly serialize dict to MessagePack
|
|
46
|
+
serialized_state = serialize(output_payload)
|
|
47
|
+
|
|
48
|
+
step_result = common_pb2.StepResult(
|
|
49
|
+
sequence=sequence,
|
|
50
|
+
name=name,
|
|
51
|
+
status=status,
|
|
52
|
+
output_payload=serialized_state,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
req = service_pb2.CommitStepRequest(
|
|
56
|
+
session_id=session_id,
|
|
57
|
+
step_result=step_result,
|
|
58
|
+
)
|
|
59
|
+
if idempotency_key is not None:
|
|
60
|
+
req.idempotency_key = idempotency_key
|
|
61
|
+
if idempotency_fencing_token is None:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"idempotency_fencing_token required if idempotency_key is set"
|
|
64
|
+
)
|
|
65
|
+
req.idempotency_fencing_token = idempotency_fencing_token
|
|
66
|
+
|
|
67
|
+
return cast(service_pb2.CommitStepResponse, self.stub.CommitStep(req))
|
|
68
|
+
|
|
69
|
+
def get_graph(
|
|
70
|
+
self, session_id: str, limit: Optional[int] = None, cursor: Optional[str] = None
|
|
71
|
+
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
|
72
|
+
"""
|
|
73
|
+
Returns a tuple of (list of deserialized step results, next_cursor).
|
|
74
|
+
Each step result is a dictionary representation of the StepResult proto,
|
|
75
|
+
with the output_payload fully deserialized into a Python dictionary.
|
|
76
|
+
"""
|
|
77
|
+
req = service_pb2.GetGraphRequest(session_id=session_id)
|
|
78
|
+
if limit is not None:
|
|
79
|
+
req.limit = limit
|
|
80
|
+
if cursor is not None:
|
|
81
|
+
req.cursor = cursor
|
|
82
|
+
|
|
83
|
+
resp: service_pb2.GetGraphResponse = self.stub.GetGraph(req)
|
|
84
|
+
|
|
85
|
+
results = []
|
|
86
|
+
for step in resp.steps:
|
|
87
|
+
results.append(
|
|
88
|
+
{
|
|
89
|
+
"sequence": step.sequence,
|
|
90
|
+
"name": step.name,
|
|
91
|
+
"status": step.status,
|
|
92
|
+
"output_payload": deserialize(step.output_payload),
|
|
93
|
+
"idempotency_key": (
|
|
94
|
+
step.idempotency_key
|
|
95
|
+
if step.HasField("idempotency_key")
|
|
96
|
+
else None
|
|
97
|
+
),
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
next_cursor = resp.next_cursor if resp.next_cursor else None
|
|
102
|
+
return results, next_cursor
|
|
103
|
+
|
|
104
|
+
def check_idempotency_cache(
|
|
105
|
+
self,
|
|
106
|
+
agent_id: str,
|
|
107
|
+
idempotency_key: str,
|
|
108
|
+
wait_timeout_ms: Optional[int] = None,
|
|
109
|
+
lease_ttl_ms: Optional[int] = None,
|
|
110
|
+
) -> idempotency_pb2.CheckCacheResponse:
|
|
111
|
+
req = idempotency_pb2.CheckCacheRequest(
|
|
112
|
+
agent_id=agent_id,
|
|
113
|
+
idempotency_key=idempotency_key,
|
|
114
|
+
)
|
|
115
|
+
if wait_timeout_ms is not None:
|
|
116
|
+
req.wait_timeout_ms = wait_timeout_ms
|
|
117
|
+
if lease_ttl_ms is not None:
|
|
118
|
+
req.lease_ttl_ms = lease_ttl_ms
|
|
119
|
+
|
|
120
|
+
return cast(
|
|
121
|
+
idempotency_pb2.CheckCacheResponse,
|
|
122
|
+
self.stub.CheckIdempotencyCache(req),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def renew_lease(
|
|
126
|
+
self,
|
|
127
|
+
agent_id: str,
|
|
128
|
+
idempotency_key: str,
|
|
129
|
+
fencing_token: int,
|
|
130
|
+
extend_ms: int,
|
|
131
|
+
) -> idempotency_pb2.RenewLeaseResponse:
|
|
132
|
+
req = idempotency_pb2.RenewLeaseRequest(
|
|
133
|
+
agent_id=agent_id,
|
|
134
|
+
idempotency_key=idempotency_key,
|
|
135
|
+
fencing_token=fencing_token,
|
|
136
|
+
extend_ms=extend_ms,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return cast(
|
|
140
|
+
idempotency_pb2.RenewLeaseResponse,
|
|
141
|
+
self.stub.RenewLease(req),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def release_lease(
|
|
145
|
+
self,
|
|
146
|
+
agent_id: str,
|
|
147
|
+
idempotency_key: str,
|
|
148
|
+
fencing_token: int,
|
|
149
|
+
reason: Optional[str] = None,
|
|
150
|
+
) -> idempotency_pb2.ReleaseLeaseResponse:
|
|
151
|
+
req = idempotency_pb2.ReleaseLeaseRequest(
|
|
152
|
+
agent_id=agent_id,
|
|
153
|
+
idempotency_key=idempotency_key,
|
|
154
|
+
fencing_token=fencing_token,
|
|
155
|
+
)
|
|
156
|
+
if reason:
|
|
157
|
+
req.reason = reason
|
|
158
|
+
|
|
159
|
+
return cast(
|
|
160
|
+
idempotency_pb2.ReleaseLeaseResponse,
|
|
161
|
+
self.stub.ReleaseLease(req),
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def close(self) -> None:
|
|
165
|
+
self.channel.close()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any, Dict
|
|
3
|
+
import contextvars
|
|
4
|
+
|
|
5
|
+
from cellaflow.client import CellaflowClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class WorkflowContext:
|
|
10
|
+
client: CellaflowClient
|
|
11
|
+
session_id: str
|
|
12
|
+
workflow_version: str
|
|
13
|
+
sequence: int = 0
|
|
14
|
+
# Map sequence number to a deserialized step payload for fast replay lookups
|
|
15
|
+
replayed_steps: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_current_context: contextvars.ContextVar[WorkflowContext] = contextvars.ContextVar(
|
|
19
|
+
"workflow_context"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_context() -> WorkflowContext:
|
|
24
|
+
try:
|
|
25
|
+
return _current_context.get()
|
|
26
|
+
except LookupError:
|
|
27
|
+
raise RuntimeError(
|
|
28
|
+
"No active workflow context found. "
|
|
29
|
+
"Are you calling a @step inside a @workflow?"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def set_context(context: WorkflowContext) -> contextvars.Token[WorkflowContext]:
|
|
34
|
+
return _current_context.set(context)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def reset_context(token: contextvars.Token[WorkflowContext]) -> None:
|
|
38
|
+
_current_context.reset(token)
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import inspect
|
|
3
|
+
import uuid
|
|
4
|
+
import asyncio
|
|
5
|
+
import time
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Callable, TypeVar, cast, Optional
|
|
8
|
+
|
|
9
|
+
from cellaflow.client import CellaflowClient
|
|
10
|
+
from cellaflow.context import WorkflowContext, set_context, reset_context, get_context
|
|
11
|
+
from cellaflow.idempotency import derive_idempotency_key, IdempotencyScope
|
|
12
|
+
from cellaflow.lease import LeaseHeartbeat
|
|
13
|
+
from cellaflow.serialization import deserialize
|
|
14
|
+
from cellaflow.v1.common_pb2 import STEP_STATUS_SUCCESS
|
|
15
|
+
from cellaflow.v1.idempotency_pb2 import (
|
|
16
|
+
CACHE_STATUS_HIT,
|
|
17
|
+
CACHE_STATUS_ACQUIRED,
|
|
18
|
+
CACHE_STATUS_IN_PROGRESS,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def workflow(
|
|
27
|
+
version: str = "1.0.0", target: str = "localhost:50051", secure: bool = False
|
|
28
|
+
) -> Callable[[F], F]:
|
|
29
|
+
def decorator(func: F) -> F:
|
|
30
|
+
@functools.wraps(func)
|
|
31
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
32
|
+
# Pop _session_id if provided for recovery, else generate new
|
|
33
|
+
session_id = kwargs.pop("_session_id", str(uuid.uuid4()))
|
|
34
|
+
|
|
35
|
+
client = CellaflowClient(target=target, secure=secure)
|
|
36
|
+
resp = client.start_session(
|
|
37
|
+
workflow_id=func.__name__, version=version, session_id=session_id
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
replayed_steps = {}
|
|
41
|
+
if resp.is_recovered:
|
|
42
|
+
steps, _ = client.get_graph(resp.session_id)
|
|
43
|
+
for step_info in steps:
|
|
44
|
+
replayed_steps[step_info["sequence"]] = step_info["output_payload"]
|
|
45
|
+
|
|
46
|
+
ctx = WorkflowContext(
|
|
47
|
+
client=client,
|
|
48
|
+
session_id=resp.session_id,
|
|
49
|
+
workflow_version=resp.version,
|
|
50
|
+
sequence=0,
|
|
51
|
+
replayed_steps=replayed_steps,
|
|
52
|
+
)
|
|
53
|
+
token = set_context(ctx)
|
|
54
|
+
try:
|
|
55
|
+
return await func(*args, **kwargs)
|
|
56
|
+
finally:
|
|
57
|
+
reset_context(token)
|
|
58
|
+
|
|
59
|
+
@functools.wraps(func)
|
|
60
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
61
|
+
session_id = kwargs.pop("_session_id", str(uuid.uuid4()))
|
|
62
|
+
|
|
63
|
+
client = CellaflowClient(target=target, secure=secure)
|
|
64
|
+
resp = client.start_session(
|
|
65
|
+
workflow_id=func.__name__, version=version, session_id=session_id
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
replayed_steps = {}
|
|
69
|
+
if resp.is_recovered:
|
|
70
|
+
steps, _ = client.get_graph(resp.session_id)
|
|
71
|
+
for step_info in steps:
|
|
72
|
+
replayed_steps[step_info["sequence"]] = step_info["output_payload"]
|
|
73
|
+
|
|
74
|
+
ctx = WorkflowContext(
|
|
75
|
+
client=client,
|
|
76
|
+
session_id=resp.session_id,
|
|
77
|
+
workflow_version=resp.version,
|
|
78
|
+
sequence=0,
|
|
79
|
+
replayed_steps=replayed_steps,
|
|
80
|
+
)
|
|
81
|
+
token = set_context(ctx)
|
|
82
|
+
try:
|
|
83
|
+
return func(*args, **kwargs)
|
|
84
|
+
finally:
|
|
85
|
+
reset_context(token)
|
|
86
|
+
|
|
87
|
+
if inspect.iscoroutinefunction(func):
|
|
88
|
+
return cast(F, async_wrapper)
|
|
89
|
+
return cast(F, sync_wrapper)
|
|
90
|
+
|
|
91
|
+
return decorator
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def step(
|
|
95
|
+
func: Optional[F] = None,
|
|
96
|
+
*,
|
|
97
|
+
idempotency_key: Optional[str] = None,
|
|
98
|
+
agent_id: str = "default",
|
|
99
|
+
tool_name: Optional[str] = None,
|
|
100
|
+
scope: IdempotencyScope = IdempotencyScope.SCOPE_SESSION_WIDE,
|
|
101
|
+
) -> Any:
|
|
102
|
+
if func is None:
|
|
103
|
+
return functools.partial(
|
|
104
|
+
step,
|
|
105
|
+
idempotency_key=idempotency_key,
|
|
106
|
+
agent_id=agent_id,
|
|
107
|
+
tool_name=tool_name,
|
|
108
|
+
scope=scope,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
actual_tool_name = tool_name or func.__name__
|
|
112
|
+
|
|
113
|
+
@functools.wraps(func)
|
|
114
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
115
|
+
ctx = get_context()
|
|
116
|
+
ctx.sequence += 1
|
|
117
|
+
seq = ctx.sequence
|
|
118
|
+
|
|
119
|
+
if seq in ctx.replayed_steps:
|
|
120
|
+
return ctx.replayed_steps[seq].get("result")
|
|
121
|
+
|
|
122
|
+
# Derive idempotency key
|
|
123
|
+
ikey = idempotency_key
|
|
124
|
+
if not ikey:
|
|
125
|
+
ikey = derive_idempotency_key(
|
|
126
|
+
ctx.session_id,
|
|
127
|
+
ctx.workflow_version,
|
|
128
|
+
seq,
|
|
129
|
+
agent_id,
|
|
130
|
+
actual_tool_name,
|
|
131
|
+
scope,
|
|
132
|
+
*args,
|
|
133
|
+
**kwargs,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
fencing_token = 0
|
|
137
|
+
hb: Optional[LeaseHeartbeat] = None
|
|
138
|
+
|
|
139
|
+
while True:
|
|
140
|
+
resp = ctx.client.check_idempotency_cache(
|
|
141
|
+
agent_id=agent_id,
|
|
142
|
+
idempotency_key=ikey,
|
|
143
|
+
)
|
|
144
|
+
if resp.status == CACHE_STATUS_HIT:
|
|
145
|
+
if resp.cached_result and resp.cached_result.output_payload:
|
|
146
|
+
deserialized = deserialize(resp.cached_result.output_payload)
|
|
147
|
+
return deserialized.get("result")
|
|
148
|
+
return None
|
|
149
|
+
elif resp.status == CACHE_STATUS_IN_PROGRESS:
|
|
150
|
+
retry_ms = resp.retry_after_ms or 1000
|
|
151
|
+
logger.info(
|
|
152
|
+
"Step %s is in progress by another worker. Sleeping %d ms...",
|
|
153
|
+
actual_tool_name,
|
|
154
|
+
retry_ms,
|
|
155
|
+
)
|
|
156
|
+
await asyncio.sleep(retry_ms / 1000.0)
|
|
157
|
+
elif resp.status == CACHE_STATUS_ACQUIRED:
|
|
158
|
+
fencing_token = resp.fencing_token or 0
|
|
159
|
+
interval_ms = resp.heartbeat_interval_ms or 5000
|
|
160
|
+
hb = LeaseHeartbeat(
|
|
161
|
+
client=ctx.client,
|
|
162
|
+
agent_id=agent_id,
|
|
163
|
+
idempotency_key=ikey,
|
|
164
|
+
fencing_token=fencing_token,
|
|
165
|
+
heartbeat_interval_ms=interval_ms,
|
|
166
|
+
)
|
|
167
|
+
hb.start_async()
|
|
168
|
+
break
|
|
169
|
+
else:
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
result = await func(*args, **kwargs)
|
|
174
|
+
ctx.client.commit_step(
|
|
175
|
+
session_id=ctx.session_id,
|
|
176
|
+
sequence=seq,
|
|
177
|
+
name=actual_tool_name,
|
|
178
|
+
status=STEP_STATUS_SUCCESS,
|
|
179
|
+
output_payload={"result": result},
|
|
180
|
+
idempotency_key=ikey,
|
|
181
|
+
idempotency_fencing_token=fencing_token,
|
|
182
|
+
)
|
|
183
|
+
return result
|
|
184
|
+
except Exception as e:
|
|
185
|
+
if fencing_token > 0:
|
|
186
|
+
ctx.client.release_lease(
|
|
187
|
+
agent_id=agent_id,
|
|
188
|
+
idempotency_key=ikey,
|
|
189
|
+
fencing_token=fencing_token,
|
|
190
|
+
reason="TOOL_ERROR",
|
|
191
|
+
)
|
|
192
|
+
raise e
|
|
193
|
+
finally:
|
|
194
|
+
if hb:
|
|
195
|
+
await hb.stop_async()
|
|
196
|
+
|
|
197
|
+
@functools.wraps(func)
|
|
198
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
199
|
+
ctx = get_context()
|
|
200
|
+
ctx.sequence += 1
|
|
201
|
+
seq = ctx.sequence
|
|
202
|
+
|
|
203
|
+
if seq in ctx.replayed_steps:
|
|
204
|
+
return ctx.replayed_steps[seq].get("result")
|
|
205
|
+
|
|
206
|
+
# Derive idempotency key
|
|
207
|
+
ikey = idempotency_key
|
|
208
|
+
if not ikey:
|
|
209
|
+
ikey = derive_idempotency_key(
|
|
210
|
+
ctx.session_id,
|
|
211
|
+
ctx.workflow_version,
|
|
212
|
+
seq,
|
|
213
|
+
agent_id,
|
|
214
|
+
actual_tool_name,
|
|
215
|
+
scope,
|
|
216
|
+
*args,
|
|
217
|
+
**kwargs,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
fencing_token = 0
|
|
221
|
+
hb: Optional[LeaseHeartbeat] = None
|
|
222
|
+
|
|
223
|
+
while True:
|
|
224
|
+
resp = ctx.client.check_idempotency_cache(
|
|
225
|
+
agent_id=agent_id,
|
|
226
|
+
idempotency_key=ikey,
|
|
227
|
+
)
|
|
228
|
+
if resp.status == CACHE_STATUS_HIT:
|
|
229
|
+
if resp.cached_result and resp.cached_result.output_payload:
|
|
230
|
+
deserialized = deserialize(resp.cached_result.output_payload)
|
|
231
|
+
return deserialized.get("result")
|
|
232
|
+
return None
|
|
233
|
+
elif resp.status == CACHE_STATUS_IN_PROGRESS:
|
|
234
|
+
retry_ms = resp.retry_after_ms or 1000
|
|
235
|
+
logger.info(
|
|
236
|
+
"Step %s is in progress by another worker. Sleeping %d ms...",
|
|
237
|
+
actual_tool_name,
|
|
238
|
+
retry_ms,
|
|
239
|
+
)
|
|
240
|
+
time.sleep(retry_ms / 1000.0)
|
|
241
|
+
elif resp.status == CACHE_STATUS_ACQUIRED:
|
|
242
|
+
fencing_token = resp.fencing_token or 0
|
|
243
|
+
interval_ms = resp.heartbeat_interval_ms or 5000
|
|
244
|
+
hb = LeaseHeartbeat(
|
|
245
|
+
client=ctx.client,
|
|
246
|
+
agent_id=agent_id,
|
|
247
|
+
idempotency_key=ikey,
|
|
248
|
+
fencing_token=fencing_token,
|
|
249
|
+
heartbeat_interval_ms=interval_ms,
|
|
250
|
+
)
|
|
251
|
+
hb.start_sync()
|
|
252
|
+
break
|
|
253
|
+
else:
|
|
254
|
+
break
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
result = func(*args, **kwargs)
|
|
258
|
+
ctx.client.commit_step(
|
|
259
|
+
session_id=ctx.session_id,
|
|
260
|
+
sequence=seq,
|
|
261
|
+
name=actual_tool_name,
|
|
262
|
+
status=STEP_STATUS_SUCCESS,
|
|
263
|
+
output_payload={"result": result},
|
|
264
|
+
idempotency_key=ikey,
|
|
265
|
+
idempotency_fencing_token=fencing_token,
|
|
266
|
+
)
|
|
267
|
+
return result
|
|
268
|
+
except Exception as e:
|
|
269
|
+
if fencing_token > 0:
|
|
270
|
+
ctx.client.release_lease(
|
|
271
|
+
agent_id=agent_id,
|
|
272
|
+
idempotency_key=ikey,
|
|
273
|
+
fencing_token=fencing_token,
|
|
274
|
+
reason="TOOL_ERROR",
|
|
275
|
+
)
|
|
276
|
+
raise e
|
|
277
|
+
finally:
|
|
278
|
+
if hb:
|
|
279
|
+
hb.stop_sync()
|
|
280
|
+
|
|
281
|
+
if inspect.iscoroutinefunction(func):
|
|
282
|
+
return cast(F, async_wrapper)
|
|
283
|
+
return cast(F, sync_wrapper)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def tool(
|
|
287
|
+
func: Optional[F] = None,
|
|
288
|
+
*,
|
|
289
|
+
idempotency_key: Optional[str] = None,
|
|
290
|
+
agent_id: str = "default",
|
|
291
|
+
tool_name: Optional[str] = None,
|
|
292
|
+
scope: IdempotencyScope = IdempotencyScope.SCOPE_SESSION_WIDE,
|
|
293
|
+
) -> Any:
|
|
294
|
+
"""
|
|
295
|
+
Decorator for tool steps that require idempotency tracking.
|
|
296
|
+
Functionally identical to @step for now, but semantically distinct.
|
|
297
|
+
"""
|
|
298
|
+
return step(
|
|
299
|
+
func=func,
|
|
300
|
+
idempotency_key=idempotency_key,
|
|
301
|
+
agent_id=agent_id,
|
|
302
|
+
tool_name=tool_name,
|
|
303
|
+
scope=scope,
|
|
304
|
+
)
|