cellaflow 0.1.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.
- cellaflow/__init__.py +5 -0
- cellaflow/client.py +165 -0
- cellaflow/context.py +38 -0
- cellaflow/decorators.py +304 -0
- cellaflow/idempotency.py +61 -0
- cellaflow/lease.py +120 -0
- cellaflow/serialization.py +29 -0
- cellaflow/v1/common_pb2.py +40 -0
- cellaflow/v1/common_pb2.pyi +137 -0
- cellaflow/v1/common_pb2_grpc.py +24 -0
- cellaflow/v1/common_pb2_grpc.pyi +20 -0
- cellaflow/v1/idempotency_pb2.py +55 -0
- cellaflow/v1/idempotency_pb2.pyi +351 -0
- cellaflow/v1/idempotency_pb2_grpc.py +24 -0
- cellaflow/v1/idempotency_pb2_grpc.pyi +20 -0
- cellaflow/v1/internal_pb2.py +41 -0
- cellaflow/v1/internal_pb2.pyi +108 -0
- cellaflow/v1/internal_pb2_grpc.py +24 -0
- cellaflow/v1/internal_pb2_grpc.pyi +20 -0
- cellaflow/v1/service_pb2.py +50 -0
- cellaflow/v1/service_pb2.pyi +204 -0
- cellaflow/v1/service_pb2_grpc.py +327 -0
- cellaflow/v1/service_pb2_grpc.pyi +146 -0
- cellaflow-0.1.0.dist-info/METADATA +58 -0
- cellaflow-0.1.0.dist-info/RECORD +27 -0
- cellaflow-0.1.0.dist-info/WHEEL +5 -0
- cellaflow-0.1.0.dist-info/top_level.txt +1 -0
cellaflow/__init__.py
ADDED
cellaflow/client.py
ADDED
|
@@ -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()
|
cellaflow/context.py
ADDED
|
@@ -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)
|
cellaflow/decorators.py
ADDED
|
@@ -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
|
+
)
|
cellaflow/idempotency.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import rfc8785
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class IdempotencyScope(Enum):
|
|
10
|
+
SCOPE_UNSPECIFIED = 0
|
|
11
|
+
SCOPE_SESSION_WIDE = 1 # Shared across all agents in the session (Default)
|
|
12
|
+
SCOPE_AGENT_PRIVATE = 2 # Isolated to the executing agent
|
|
13
|
+
SCOPE_STEP_LOCAL = 3 # Isolated to the specific superstep / node
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _hash_inputs(*args: Any, **kwargs: Any) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Hashes the inputs using RFC 8785 Canonical JSON and SHA-256.
|
|
19
|
+
Returns the first 16 bytes of the hash, hex-encoded (32 characters).
|
|
20
|
+
"""
|
|
21
|
+
payload = {"args": list(args), "kwargs": kwargs}
|
|
22
|
+
|
|
23
|
+
# Serialize to canonical JSON bytes according to RFC 8785
|
|
24
|
+
canonical_bytes = rfc8785.dumps(payload) # type: ignore[arg-type]
|
|
25
|
+
|
|
26
|
+
# Hash using SHA-256
|
|
27
|
+
hash_obj = hashlib.sha256(canonical_bytes)
|
|
28
|
+
|
|
29
|
+
# Return the first 16 bytes hex-encoded
|
|
30
|
+
return hash_obj.digest()[:16].hex()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def derive_idempotency_key(
|
|
34
|
+
session_id: str,
|
|
35
|
+
workflow_version: str,
|
|
36
|
+
step_sequence: int,
|
|
37
|
+
agent_id: str,
|
|
38
|
+
tool_name: str,
|
|
39
|
+
scope: IdempotencyScope,
|
|
40
|
+
*args: Any,
|
|
41
|
+
**kwargs: Any,
|
|
42
|
+
) -> str:
|
|
43
|
+
"""
|
|
44
|
+
Derives the canonical idempotency key for a step or tool execution.
|
|
45
|
+
Format varies based on IdempotencyScope.
|
|
46
|
+
"""
|
|
47
|
+
inputs_hash = _hash_inputs(*args, **kwargs)
|
|
48
|
+
|
|
49
|
+
seq_part = "session_wide"
|
|
50
|
+
agent_part = "session_wide"
|
|
51
|
+
|
|
52
|
+
if scope == IdempotencyScope.SCOPE_AGENT_PRIVATE:
|
|
53
|
+
agent_part = agent_id
|
|
54
|
+
elif scope == IdempotencyScope.SCOPE_STEP_LOCAL:
|
|
55
|
+
seq_part = str(step_sequence)
|
|
56
|
+
agent_part = agent_id
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
f"{session_id}:{workflow_version}:{seq_part}:"
|
|
60
|
+
f"{agent_part}:{tool_name}:{inputs_hash}"
|
|
61
|
+
)
|
cellaflow/lease.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import threading
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Optional, Any
|
|
5
|
+
from cellaflow.client import CellaflowClient
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LeaseHeartbeat:
|
|
11
|
+
"""
|
|
12
|
+
Manages the background heartbeat (RenewLease) for an actively running step.
|
|
13
|
+
Supports both sync (daemon thread) and async (asyncio task) environments.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
client: CellaflowClient,
|
|
19
|
+
agent_id: str,
|
|
20
|
+
idempotency_key: str,
|
|
21
|
+
fencing_token: int,
|
|
22
|
+
heartbeat_interval_ms: int,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.client = client
|
|
25
|
+
self.agent_id = agent_id
|
|
26
|
+
self.idempotency_key = idempotency_key
|
|
27
|
+
self.fencing_token = fencing_token
|
|
28
|
+
# Renew slightly before expiration
|
|
29
|
+
self.interval_sec = max(0.1, (heartbeat_interval_ms - 100) / 1000.0)
|
|
30
|
+
self.extend_ms = (
|
|
31
|
+
heartbeat_interval_ms * 4
|
|
32
|
+
) # Usually TTL is 4x heartbeat interval
|
|
33
|
+
|
|
34
|
+
# State tracking
|
|
35
|
+
self._stop_event_sync = threading.Event()
|
|
36
|
+
self._sync_thread: Optional[threading.Thread] = None
|
|
37
|
+
|
|
38
|
+
self._stop_event_async: Optional[asyncio.Event] = None
|
|
39
|
+
self._async_task: Optional[asyncio.Task[Any]] = None
|
|
40
|
+
|
|
41
|
+
def start_sync(self) -> None:
|
|
42
|
+
"""Starts a daemon thread for synchronous execution."""
|
|
43
|
+
self._stop_event_sync.clear()
|
|
44
|
+
self._sync_thread = threading.Thread(
|
|
45
|
+
target=self._sync_loop, daemon=True, name=f"lease-hb-{self.fencing_token}"
|
|
46
|
+
)
|
|
47
|
+
self._sync_thread.start()
|
|
48
|
+
|
|
49
|
+
def stop_sync(self) -> None:
|
|
50
|
+
"""Stops the daemon thread cleanly."""
|
|
51
|
+
self._stop_event_sync.set()
|
|
52
|
+
if self._sync_thread and self._sync_thread.is_alive():
|
|
53
|
+
self._sync_thread.join(timeout=1.0)
|
|
54
|
+
|
|
55
|
+
def _sync_loop(self) -> None:
|
|
56
|
+
while not self._stop_event_sync.wait(timeout=self.interval_sec):
|
|
57
|
+
try:
|
|
58
|
+
resp = self.client.renew_lease(
|
|
59
|
+
agent_id=self.agent_id,
|
|
60
|
+
idempotency_key=self.idempotency_key,
|
|
61
|
+
fencing_token=self.fencing_token,
|
|
62
|
+
extend_ms=self.extend_ms,
|
|
63
|
+
)
|
|
64
|
+
if not resp.renewed:
|
|
65
|
+
logger.warning(
|
|
66
|
+
f"Lease {self.idempotency_key} failed to renew: "
|
|
67
|
+
f"{resp.failure_reason}"
|
|
68
|
+
)
|
|
69
|
+
break
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.error(f"Lease renewal error: {e}")
|
|
72
|
+
|
|
73
|
+
def start_async(self) -> None:
|
|
74
|
+
"""Starts an asyncio task for asynchronous execution."""
|
|
75
|
+
self._stop_event_async = asyncio.Event()
|
|
76
|
+
loop = asyncio.get_running_loop()
|
|
77
|
+
self._async_task = loop.create_task(self._async_loop())
|
|
78
|
+
|
|
79
|
+
async def stop_async(self) -> None:
|
|
80
|
+
"""Stops the asyncio task cleanly."""
|
|
81
|
+
if self._stop_event_async:
|
|
82
|
+
self._stop_event_async.set()
|
|
83
|
+
if self._async_task and not self._async_task.done():
|
|
84
|
+
self._async_task.cancel()
|
|
85
|
+
try:
|
|
86
|
+
await self._async_task
|
|
87
|
+
except asyncio.CancelledError:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
async def _async_loop(self) -> None:
|
|
91
|
+
if not self._stop_event_async:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
# We use asyncio.wait_for to wait for the stop event, with a timeout
|
|
95
|
+
while True:
|
|
96
|
+
try:
|
|
97
|
+
await asyncio.wait_for(
|
|
98
|
+
self._stop_event_async.wait(), timeout=self.interval_sec
|
|
99
|
+
)
|
|
100
|
+
# If we get here, the stop event was set
|
|
101
|
+
break
|
|
102
|
+
except asyncio.TimeoutError:
|
|
103
|
+
# Timeout means interval elapsed, do heartbeat
|
|
104
|
+
try:
|
|
105
|
+
resp = self.client.renew_lease(
|
|
106
|
+
agent_id=self.agent_id,
|
|
107
|
+
idempotency_key=self.idempotency_key,
|
|
108
|
+
fencing_token=self.fencing_token,
|
|
109
|
+
extend_ms=self.extend_ms,
|
|
110
|
+
)
|
|
111
|
+
if not resp.renewed:
|
|
112
|
+
logger.warning(
|
|
113
|
+
f"Lease {self.idempotency_key} failed to renew: "
|
|
114
|
+
f"{resp.failure_reason}"
|
|
115
|
+
)
|
|
116
|
+
break
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.error(f"Lease renewal error: {e}")
|
|
119
|
+
except asyncio.CancelledError:
|
|
120
|
+
break
|