dex-python-sdk 0.0.2__cp311-abi3-win_amd64.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 (121) hide show
  1. dex/__init__.py +146 -0
  2. dex/_grpc_errors.py +73 -0
  3. dex/_invocation_context.py +269 -0
  4. dex/_native.pyd +0 -0
  5. dex/_native.pyi +20 -0
  6. dex/_utils.py +18 -0
  7. dex/_value_hydrator.py +223 -0
  8. dex/_value_mapper.py +174 -0
  9. dex/_worker_dispatcher.py +451 -0
  10. dex/_worker_service.py +60 -0
  11. dex/attribute.py +87 -0
  12. dex/blob_cache.py +73 -0
  13. dex/channel.py +165 -0
  14. dex/client.py +715 -0
  15. dex/client_options.py +19 -0
  16. dex/codec.py +286 -0
  17. dex/command_request.py +120 -0
  18. dex/command_results.py +107 -0
  19. dex/communication.py +136 -0
  20. dex/communication_schema.py +54 -0
  21. dex/condition.py +74 -0
  22. dex/context.py +86 -0
  23. dex/data_attributes.py +70 -0
  24. dex/dexpb/__init__.py +1 -0
  25. dex/dexpb/dex_pb2.py +381 -0
  26. dex/dexpb/dex_pb2.pyi +1734 -0
  27. dex/dexpb/dex_pb2_grpc.py +1298 -0
  28. dex/errors.py +109 -0
  29. dex/flow.py +456 -0
  30. dex/flow_config.py +29 -0
  31. dex/flow_info.py +51 -0
  32. dex/flow_options.py +122 -0
  33. dex/object_encoder.py +799 -0
  34. dex/persistence.py +89 -0
  35. dex/persistence_options.py +12 -0
  36. dex/persistence_schema.py +51 -0
  37. dex/py.typed +1 -0
  38. dex/registry.py +204 -0
  39. dex/reset_workflow_type_and_options.py +67 -0
  40. dex/rpc.py +93 -0
  41. dex/runtime_errors.py +81 -0
  42. dex/search_attributes.py +184 -0
  43. dex/state_decision.py +153 -0
  44. dex/state_execution_locals.py +66 -0
  45. dex/state_movement.py +115 -0
  46. dex/state_schema.py +48 -0
  47. dex/step.py +194 -0
  48. dex/step_execution.py +42 -0
  49. dex/stop_workflow_options.py +18 -0
  50. dex/tests/__init__.py +80 -0
  51. dex/tests/dex-service-env/.env +7 -0
  52. dex/tests/dex-service-env/docker-compose-init.sh +44 -0
  53. dex/tests/dex-service-env/docker-compose.yml +97 -0
  54. dex/tests/dex-service-env/dynamicconfig/README.md +39 -0
  55. dex/tests/dex-service-env/dynamicconfig/development-sql.yaml +9 -0
  56. dex/tests/dex-service-env/dynamicconfig/docker.yaml +2 -0
  57. dex/tests/test_abnormal_exit_workflow.py +43 -0
  58. dex/tests/test_basic_workflow.py +70 -0
  59. dex/tests/test_conditional_complete.py +50 -0
  60. dex/tests/test_describe_workflow.py +40 -0
  61. dex/tests/test_empty_data_decodes_properly.py +74 -0
  62. dex/tests/test_internal_channel.py +28 -0
  63. dex/tests/test_internal_channel_with_no_prefix_channel.py +41 -0
  64. dex/tests/test_persistence_data_attributes.py +62 -0
  65. dex/tests/test_persistence_search_attributes.py +127 -0
  66. dex/tests/test_persistence_state_execution_locals.py +38 -0
  67. dex/tests/test_rpc.py +64 -0
  68. dex/tests/test_rpc_with_memo.py +195 -0
  69. dex/tests/test_rpc_with_memo_duplicate_java_tests.py +117 -0
  70. dex/tests/test_signal.py +51 -0
  71. dex/tests/test_skip_wait_until.py +76 -0
  72. dex/tests/test_state_failure_recovery.py +28 -0
  73. dex/tests/test_timer.py +35 -0
  74. dex/tests/test_wait_for_state_execution_completion.py +53 -0
  75. dex/tests/test_workflow_errors.py +87 -0
  76. dex/tests/test_workflow_state_options.py +118 -0
  77. dex/tests/test_workflow_state_options_override.py +44 -0
  78. dex/tests/worker_server.py +64 -0
  79. dex/tests/workflows/abnormal_exit_workflow.py +42 -0
  80. dex/tests/workflows/basic_workflow.py +62 -0
  81. dex/tests/workflows/conditional_complete_workflow.py +95 -0
  82. dex/tests/workflows/describe_workflow.py +46 -0
  83. dex/tests/workflows/empty_data_workflow.py +45 -0
  84. dex/tests/workflows/internal_channel_workflow.py +129 -0
  85. dex/tests/workflows/internal_channel_workflow_with_no_prefix_channel.py +100 -0
  86. dex/tests/workflows/java_duplicate_rpc_memo_workflow.py +276 -0
  87. dex/tests/workflows/persistence_data_attributes_workflow.py +98 -0
  88. dex/tests/workflows/persistence_search_attributes_workflow.py +159 -0
  89. dex/tests/workflows/persistence_state_execution_local_workflow.py +63 -0
  90. dex/tests/workflows/recovery_workflow.py +82 -0
  91. dex/tests/workflows/rpc_memo_workflow.py +231 -0
  92. dex/tests/workflows/rpc_workflow.py +117 -0
  93. dex/tests/workflows/state_options_override_workflow.py +93 -0
  94. dex/tests/workflows/state_options_workflow.py +84 -0
  95. dex/tests/workflows/timer_workflow.py +46 -0
  96. dex/tests/workflows/wait_for_state_with_state_execution_id_workflow.py +70 -0
  97. dex/tests/workflows/wait_for_state_with_wait_for_key_workflow.py +71 -0
  98. dex/tests/workflows/wait_internal_channel_workflow.py +47 -0
  99. dex/tests/workflows/wait_signal_workflow.py +147 -0
  100. dex/timer.py +21 -0
  101. dex/type_store.py +99 -0
  102. dex/unregistered_client.py +585 -0
  103. dex/utils/__init__.py +3 -0
  104. dex/utils/dex_typing.py +25 -0
  105. dex/utils/persistence_utils.py +32 -0
  106. dex/wait.py +49 -0
  107. dex/worker.py +121 -0
  108. dex/worker_options.py +22 -0
  109. dex/worker_service.py +432 -0
  110. dex/workflow.py +79 -0
  111. dex/workflow_context.py +44 -0
  112. dex/workflow_info.py +16 -0
  113. dex/workflow_options.py +74 -0
  114. dex/workflow_state.py +123 -0
  115. dex/workflow_state_options.py +154 -0
  116. dex_python_sdk-0.0.2.dist-info/METADATA +202 -0
  117. dex_python_sdk-0.0.2.dist-info/RECORD +121 -0
  118. dex_python_sdk-0.0.2.dist-info/WHEEL +4 -0
  119. dex_python_sdk-0.0.2.dist-info/licenses/LEGACY_NOTICES.md +61 -0
  120. dex_python_sdk-0.0.2.dist-info/licenses/LICENSE +192 -0
  121. dex_python_sdk-0.0.2.dist-info/sboms/dex-blob-cache-python.cyclonedx.json +2406 -0
@@ -0,0 +1,451 @@
1
+ # Copyright (c) 2026 Super Durable, Inc.
2
+ #
3
+ # Licensed under the Super Durable Source License 1.0.
4
+ # You may not use this file except in compliance with the License.
5
+ # See the LICENSE file in the repository root.
6
+ #
7
+ # SPDX-License-Identifier: LicenseRef-Super-Durable-1.0
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import timedelta
12
+ from typing import Any, cast
13
+
14
+ from dex._invocation_context import InvocationContext, InvocationMethod
15
+ from dex._value_hydrator import ValueHydrator
16
+ from dex._value_mapper import ValueMapper
17
+ from dex.attribute import AttributeLock
18
+ from dex.channel import Channel, ChannelMap
19
+ from dex.condition import ChannelCondition, Condition, TimerCondition
20
+ from dex.dexpb import dex_pb2 as pb
21
+ from dex.flow import Registry, RPCResult, _RegisteredFlow, _RegisteredStep
22
+ from dex.step import (
23
+ DecisionKind,
24
+ RetryPolicy,
25
+ StepDecision,
26
+ StepDurability,
27
+ StepMovement,
28
+ StepOptions,
29
+ WaitForFailurePolicy,
30
+ )
31
+ from dex.wait import Wait, WaitKind
32
+
33
+ _INTERNAL_CONDITION_PREFIX = "__dex_internal_condition_"
34
+
35
+
36
+ class WorkerDispatcher:
37
+ def __init__(
38
+ self,
39
+ registry: Registry,
40
+ values: ValueMapper,
41
+ hydrator: ValueHydrator,
42
+ ) -> None:
43
+ self._registry = registry
44
+ self._values = values
45
+ self._hydrator = hydrator
46
+
47
+ def invoke_wait_for(
48
+ self,
49
+ original: pb.InvokeWaitForMethodRequest,
50
+ ) -> pb.InvokeWaitForMethodResponse:
51
+ request = self._hydrator.wait_for_request(original)
52
+ flow = self._registry._flow_by_type(request.flow_type)
53
+ step = flow.step(request.step_type)
54
+ context = InvocationContext(
55
+ InvocationMethod.WAIT_FOR,
56
+ flow,
57
+ request.context,
58
+ self._values,
59
+ request.attributes,
60
+ )
61
+ input = self._values.decode(request.step_input, step.input_codec)
62
+ wait = step.step.wait_for(context, input)
63
+ if not isinstance(wait, Wait):
64
+ raise TypeError("wait_for must return Wait")
65
+ response = pb.InvokeWaitForMethodResponse(
66
+ upsert_attributes=list(context.attribute_writes.values()),
67
+ upsert_step_exe_locals=list(context.local_writes.values()),
68
+ record_events=context.events,
69
+ publish_to_channel=context.publications,
70
+ )
71
+ waiting = self._map_wait(flow, wait)
72
+ if waiting is not None:
73
+ response.waiting_condition.CopyFrom(waiting)
74
+ return response
75
+
76
+ def invoke_execute(
77
+ self,
78
+ original: pb.InvokeExecuteMethodRequest,
79
+ ) -> pb.InvokeExecuteMethodResponse:
80
+ request = self._hydrator.execute_request(original)
81
+ flow = self._registry._flow_by_type(request.flow_type)
82
+ step = flow.step(request.step_type)
83
+ condition_results = (
84
+ request.condition_results if request.HasField("condition_results") else None
85
+ )
86
+ context = InvocationContext(
87
+ InvocationMethod.EXECUTE,
88
+ flow,
89
+ request.context,
90
+ self._values,
91
+ request.attributes,
92
+ request.step_exe_locals,
93
+ condition_results,
94
+ )
95
+ input = self._values.decode(request.step_input, step.input_codec)
96
+ decision = step.step.execute(context, input)
97
+ if not isinstance(decision, StepDecision):
98
+ raise TypeError("execute must return StepDecision")
99
+ return pb.InvokeExecuteMethodResponse(
100
+ step_decision=self._map_decision(flow, decision),
101
+ upsert_attributes=list(context.attribute_writes.values()),
102
+ record_events=context.events,
103
+ upsert_step_exe_locals=list(context.local_writes.values()),
104
+ publish_to_channel=context.publications,
105
+ )
106
+
107
+ def invoke_rpc(
108
+ self,
109
+ original: pb.InvokeWorkerRPCRequest,
110
+ ) -> pb.InvokeWorkerRPCResponse:
111
+ request = self._hydrator.rpc_request(original)
112
+ flow = self._registry._flow_by_type(request.flow_type)
113
+ rpc = flow.rpc(request.rpc_name)
114
+ context = InvocationContext(
115
+ InvocationMethod.RPC,
116
+ flow,
117
+ request.context,
118
+ self._values,
119
+ request.attributes,
120
+ channel_infos=dict(request.channel_infos),
121
+ )
122
+ arguments: list[object] = [context]
123
+ if rpc.input_codec is not None:
124
+ arguments.append(self._values.decode(request.input, rpc.input_codec))
125
+ returned = rpc.method(*arguments)
126
+ response = pb.InvokeWorkerRPCResponse(
127
+ upsert_attributes=list(context.attribute_writes.values()),
128
+ record_events=context.events,
129
+ publish_to_channel=context.publications,
130
+ )
131
+ if isinstance(returned, RPCResult):
132
+ if rpc.output_codec is None:
133
+ raise TypeError("RPCResult requires an output type")
134
+ response.output.CopyFrom(
135
+ self._values.encode(returned.output, rpc.output_codec)
136
+ )
137
+ if returned.next_steps:
138
+ response.step_decision.next_steps.extend(
139
+ self._map_movements(flow, returned.next_steps)
140
+ )
141
+ elif returned is None and rpc.output_codec is None:
142
+ response.output.CopyFrom(self._values.encode_dynamic(None))
143
+ else:
144
+ raise TypeError("RPC must return RPCResult or None")
145
+ return response
146
+
147
+ def map_step_options(
148
+ self,
149
+ flow: _RegisteredFlow,
150
+ options: StepOptions | None,
151
+ ) -> pb.StepOptions | None:
152
+ if options is None:
153
+ return None
154
+ mapped = pb.StepOptions(
155
+ wait_for_failure_policy=(
156
+ pb.WAIT_FOR_METHOD_FAILURE_POLICY_PROCEED_ON_FAILURE
157
+ if options.wait_for_failure is WaitForFailurePolicy.PROCEED
158
+ else pb.WAIT_FOR_METHOD_FAILURE_POLICY_FAIL_FLOW_ON_FAILURE
159
+ ),
160
+ wait_for_durability_override=cast(
161
+ Any, self._map_durability(options.wait_for_durability)
162
+ ),
163
+ execute_durability_override=cast(
164
+ Any, self._map_durability(options.execute_durability)
165
+ ),
166
+ wait_for_lock_attribute_keys=[
167
+ self._map_lock(lock) for lock in options.wait_for_lock_attributes
168
+ ],
169
+ execute_lock_attribute_keys=[
170
+ self._map_lock(lock) for lock in options.execute_lock_attributes
171
+ ],
172
+ )
173
+ if options.wait_for_method_timeout is not None:
174
+ mapped.wait_for_timeout_seconds = self._seconds32(
175
+ options.wait_for_method_timeout
176
+ )
177
+ if options.execute_method_timeout is not None:
178
+ mapped.execute_timeout_seconds = self._seconds32(
179
+ options.execute_method_timeout
180
+ )
181
+ if options.wait_for_retry is not None:
182
+ mapped.wait_for_retry_policy.CopyFrom(
183
+ self._map_retry(options.wait_for_retry)
184
+ )
185
+ if options.execute_retry is not None:
186
+ mapped.execute_retry_policy.CopyFrom(self._map_retry(options.execute_retry))
187
+ if options._execute_failure_target is not None:
188
+ target = self._registered_movement_target(
189
+ flow,
190
+ options._execute_failure_target,
191
+ )
192
+ mapped.execute_failure_policy = (
193
+ pb.EXECUTE_METHOD_FAILURE_POLICY_PROCEED_TO_CONFIGURED_STEP
194
+ )
195
+ mapped.execute_failure_proceed_step_type = target.name
196
+ target_options = self.map_step_options(
197
+ flow,
198
+ (
199
+ options._execute_failure_options
200
+ if options._execute_failure_options is not None
201
+ else target.step.get_step_options()
202
+ ),
203
+ )
204
+ if target_options is not None:
205
+ mapped.execute_failure_proceed_step_options.CopyFrom(target_options)
206
+ mapped.execute_failure_proceed_step_options.skip_wait_for = (
207
+ target.skips_wait_for
208
+ )
209
+ return mapped
210
+
211
+ def _map_wait(
212
+ self,
213
+ flow: _RegisteredFlow,
214
+ wait: Wait,
215
+ ) -> pb.WaitingCondition | None:
216
+ if wait.kind is WaitKind.SKIP_IMMEDIATELY:
217
+ return None
218
+ mapper = _ConditionMapper(flow)
219
+ waiting = pb.WaitingCondition()
220
+ if wait.kind is WaitKind.ALL_OF:
221
+ waiting.waiting_condition_type = pb.WAITING_CONDITION_TYPE_ALL_COMPLETED
222
+ mapper.add_all(wait.conditions)
223
+ elif wait.kind is WaitKind.ANY_OF:
224
+ waiting.waiting_condition_type = pb.WAITING_CONDITION_TYPE_ANY_COMPLETED
225
+ mapper.add_all(wait.conditions)
226
+ elif wait.kind is WaitKind.ANY_COMBINATION_OF:
227
+ waiting.waiting_condition_type = (
228
+ pb.WAITING_CONDITION_TYPE_ANY_COMBINATION_COMPLETED
229
+ )
230
+ for combination in wait.combinations:
231
+ waiting.condition_combinations.add(
232
+ condition_ids=[
233
+ mapper.add(condition) for condition in combination.conditions
234
+ ]
235
+ )
236
+ else:
237
+ raise ValueError("unsupported Wait kind")
238
+ waiting.timer_conditions.extend(mapper.timers)
239
+ waiting.channel_conditions.extend(mapper.channels)
240
+ return waiting
241
+
242
+ def _map_decision(
243
+ self,
244
+ flow: _RegisteredFlow,
245
+ decision: StepDecision,
246
+ ) -> pb.StepDecision:
247
+ mapped = pb.StepDecision()
248
+ if decision.kind is DecisionKind.NEXT:
249
+ if not decision.movements:
250
+ raise ValueError("go_to_multi requires a movement")
251
+ mapped.next_steps.extend(self._map_movements(flow, decision.movements))
252
+ elif decision.kind is DecisionKind.GRACEFUL_COMPLETE:
253
+ mapped.close_decision.CopyFrom(
254
+ self._close(pb.CLOSE_DECISION_TYPE_GRACEFUL_COMPLETE, decision.output)
255
+ )
256
+ elif decision.kind is DecisionKind.FORCE_COMPLETE:
257
+ mapped.close_decision.CopyFrom(
258
+ self._close(pb.CLOSE_DECISION_TYPE_FORCE_COMPLETE, decision.output)
259
+ )
260
+ elif decision.kind is DecisionKind.FORCE_FAIL:
261
+ mapped.close_decision.CopyFrom(
262
+ self._close(pb.CLOSE_DECISION_TYPE_FORCE_FAIL, decision.reason)
263
+ )
264
+ elif decision.kind is DecisionKind.DEAD_END:
265
+ mapped.close_decision.close_decision_type = pb.CLOSE_DECISION_TYPE_DEAD_END
266
+ elif decision.kind is DecisionKind.FORCE_COMPLETE_IF_CHANNELS_EMPTY:
267
+ if decision.fallback is None:
268
+ raise ValueError("conditional close requires a fallback movement")
269
+ names: list[str] = []
270
+ for channel in decision.empty_channels:
271
+ if not isinstance(channel, Channel):
272
+ raise ValueError("conditional close requires static Channels")
273
+ self._require_persistence_identity(flow, channel)
274
+ names.append(channel.name)
275
+ mapped.close_decision.CopyFrom(
276
+ pb.CloseDecision(
277
+ close_decision_type=(
278
+ pb.CLOSE_DECISION_TYPE_FORCE_COMPLETE_ON_CHANNELS_EMPTY
279
+ ),
280
+ close_input=self._values.encode_dynamic(decision.output),
281
+ conditional_channel_names=names,
282
+ )
283
+ )
284
+ mapped.next_steps.append(self._map_movement(flow, decision.fallback))
285
+ else:
286
+ raise ValueError("unsupported StepDecision kind")
287
+ return mapped
288
+
289
+ def _close(self, close_type: int, output: object) -> pb.CloseDecision:
290
+ return pb.CloseDecision(
291
+ close_decision_type=cast(Any, close_type),
292
+ close_input=self._values.encode_dynamic(output),
293
+ )
294
+
295
+ def _map_movements(
296
+ self,
297
+ flow: _RegisteredFlow,
298
+ movements: tuple[StepMovement[Any], ...],
299
+ ) -> list[pb.StepMovement]:
300
+ return [self._map_movement(flow, movement) for movement in movements]
301
+
302
+ def _map_movement(
303
+ self,
304
+ flow: _RegisteredFlow,
305
+ movement: StepMovement[Any],
306
+ ) -> pb.StepMovement:
307
+ target = self._registered_movement_target(flow, movement.step)
308
+ mapped = pb.StepMovement(
309
+ step_type=target.name,
310
+ step_input=self._values.encode(movement.input, target.input_codec),
311
+ )
312
+ options = self.map_step_options(
313
+ flow,
314
+ (
315
+ movement.options
316
+ if movement.options is not None
317
+ else target.step.get_step_options()
318
+ ),
319
+ )
320
+ if options is not None:
321
+ mapped.step_options.CopyFrom(options)
322
+ mapped.step_options.skip_wait_for = target.skips_wait_for
323
+ return mapped
324
+
325
+ @staticmethod
326
+ def _registered_movement_target(
327
+ flow: _RegisteredFlow,
328
+ step: object,
329
+ ) -> _RegisteredStep:
330
+ step_type = getattr(step, "get_step_type", lambda: "")()
331
+ target = flow.step(step_type)
332
+ if target.step is not step:
333
+ raise ValueError("Step movement target does not belong to Flow")
334
+ return target
335
+
336
+ @staticmethod
337
+ def _require_persistence_identity(
338
+ flow: _RegisteredFlow,
339
+ definition: Channel[Any] | ChannelMap[Any],
340
+ ) -> None:
341
+ if flow.persistence.get(definition.name) is not definition:
342
+ raise ValueError("Channel does not belong to Flow")
343
+
344
+ @staticmethod
345
+ def _map_retry(retry: RetryPolicy) -> pb.RetryPolicy:
346
+ mapped = pb.RetryPolicy(
347
+ backoff_coefficient=retry.backoff_coefficient,
348
+ maximum_attempts=retry.maximum_attempts,
349
+ )
350
+ if retry.initial_interval is not None:
351
+ mapped.initial_interval_seconds = WorkerDispatcher._seconds32(
352
+ retry.initial_interval
353
+ )
354
+ if retry.maximum_interval is not None:
355
+ mapped.maximum_interval_seconds = WorkerDispatcher._seconds32(
356
+ retry.maximum_interval
357
+ )
358
+ if retry.total_duration is not None:
359
+ mapped.total_duration_seconds = WorkerDispatcher._seconds32(
360
+ retry.total_duration
361
+ )
362
+ return mapped
363
+
364
+ @staticmethod
365
+ def _map_durability(durability: StepDurability) -> int:
366
+ return {
367
+ StepDurability.DEFAULT: pb.STEP_DURABILITY_UNSPECIFIED,
368
+ StepDurability.SYNC: pb.STEP_DURABILITY_SYNC,
369
+ StepDurability.ASYNC: pb.STEP_DURABILITY_ASYNC,
370
+ }[durability]
371
+
372
+ @staticmethod
373
+ def _map_lock(lock: AttributeLock) -> str:
374
+ if lock.instance is None:
375
+ return lock.attribute.name
376
+ return Registry.physical_name(lock.attribute.name, lock.instance)
377
+
378
+ @staticmethod
379
+ def _seconds32(duration: timedelta) -> int:
380
+ seconds = duration.total_seconds()
381
+ if seconds < 0 or not seconds.is_integer() or seconds > 2**31 - 1:
382
+ raise ValueError("duration must be whole seconds within int32")
383
+ return int(seconds)
384
+
385
+
386
+ class _ConditionMapper:
387
+ def __init__(self, flow: _RegisteredFlow) -> None:
388
+ self._flow = flow
389
+ self._ids: dict[int, str] = {}
390
+ self._used: set[str] = set()
391
+ self._next_id = 0
392
+ self.timers: list[pb.TimerCondition] = []
393
+ self.channels: list[pb.ChannelCondition] = []
394
+
395
+ def add_all(self, conditions: tuple[Condition, ...]) -> None:
396
+ if not conditions:
397
+ raise ValueError("Wait requires at least one Condition")
398
+ for condition in conditions:
399
+ self.add(condition)
400
+
401
+ def add(self, condition: Condition) -> str:
402
+ identity = id(condition)
403
+ existing = self._ids.get(identity)
404
+ if existing is not None:
405
+ return existing
406
+ condition_id = condition.condition_id
407
+ if condition_id is None:
408
+ condition_id = f"{_INTERNAL_CONDITION_PREFIX}{self._next_id}"
409
+ self._next_id += 1
410
+ if not condition_id or condition_id in self._used:
411
+ raise ValueError("duplicate or empty Condition ID")
412
+ self._used.add(condition_id)
413
+ if isinstance(condition, TimerCondition):
414
+ seconds = condition.duration.total_seconds()
415
+ if not seconds.is_integer():
416
+ raise ValueError("timer duration must use whole seconds")
417
+ self.timers.append(
418
+ pb.TimerCondition(
419
+ condition_id=condition_id,
420
+ duration_seconds=int(seconds),
421
+ )
422
+ )
423
+ elif isinstance(condition, ChannelCondition):
424
+ channel = condition.channel
425
+ if (
426
+ channel is None
427
+ or self._flow.persistence.get(channel.name) is not channel
428
+ ):
429
+ raise ValueError(
430
+ f"Channel is not registered: {getattr(channel, 'name', '')}"
431
+ )
432
+ channel_name = channel.name
433
+ if isinstance(channel, ChannelMap):
434
+ if condition.instance is None:
435
+ raise ValueError("ChannelMap condition requires an instance")
436
+ channel_name = Registry.physical_name(channel.name, condition.instance)
437
+ elif condition.instance is not None:
438
+ raise ValueError("static Channel cannot use an instance")
439
+ mapped = pb.ChannelCondition(
440
+ condition_id=condition_id,
441
+ channel_name=channel_name,
442
+ )
443
+ if condition.at_least is not None:
444
+ mapped.at_least = condition.at_least
445
+ if condition.at_most is not None:
446
+ mapped.at_most = condition.at_most
447
+ self.channels.append(mapped)
448
+ else:
449
+ raise TypeError("unsupported Condition")
450
+ self._ids[identity] = condition_id
451
+ return condition_id
dex/_worker_service.py ADDED
@@ -0,0 +1,60 @@
1
+ # Copyright (c) 2026 Super Durable, Inc.
2
+ #
3
+ # Licensed under the Super Durable Source License 1.0.
4
+ # You may not use this file except in compliance with the License.
5
+ # See the LICENSE file in the repository root.
6
+ #
7
+ # SPDX-License-Identifier: LicenseRef-Super-Durable-1.0
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import Callable, TypeVar
13
+
14
+ import grpc
15
+
16
+ from dex._grpc_errors import abort_worker_error
17
+ from dex._worker_dispatcher import WorkerDispatcher
18
+ from dex.dexpb import dex_pb2 as pb
19
+ from dex.dexpb import dex_pb2_grpc
20
+
21
+ _LOGGER = logging.getLogger(__name__)
22
+ ResponseT = TypeVar("ResponseT")
23
+
24
+
25
+ class WorkerService(dex_pb2_grpc.WorkerServiceServicer):
26
+ def __init__(self, dispatcher: WorkerDispatcher) -> None:
27
+ self._dispatcher = dispatcher
28
+
29
+ def InvokeWaitForMethod(
30
+ self,
31
+ request: pb.InvokeWaitForMethodRequest,
32
+ context: grpc.ServicerContext,
33
+ ) -> pb.InvokeWaitForMethodResponse:
34
+ return self._invoke(context, lambda: self._dispatcher.invoke_wait_for(request))
35
+
36
+ def InvokeExecuteMethod(
37
+ self,
38
+ request: pb.InvokeExecuteMethodRequest,
39
+ context: grpc.ServicerContext,
40
+ ) -> pb.InvokeExecuteMethodResponse:
41
+ return self._invoke(context, lambda: self._dispatcher.invoke_execute(request))
42
+
43
+ def InvokeWorkerRPC(
44
+ self,
45
+ request: pb.InvokeWorkerRPCRequest,
46
+ context: grpc.ServicerContext,
47
+ ) -> pb.InvokeWorkerRPCResponse:
48
+ return self._invoke(context, lambda: self._dispatcher.invoke_rpc(request))
49
+
50
+ @staticmethod
51
+ def _invoke(
52
+ context: grpc.ServicerContext,
53
+ invocation: Callable[[], ResponseT],
54
+ ) -> ResponseT:
55
+ try:
56
+ return invocation()
57
+ except BaseException as error:
58
+ _LOGGER.exception("Python Worker invocation failed")
59
+ abort_worker_error(context, error)
60
+ raise RuntimeError("gRPC abort returned unexpectedly") from error
dex/attribute.py ADDED
@@ -0,0 +1,87 @@
1
+ # Legacy Materials in this file remain under their original licenses.
2
+ # See LEGACY_NOTICES.md.
3
+
4
+ # Modifications Copyright (c) 2026 Super Durable, Inc.
5
+ #
6
+ # Modifications after the Legacy Cutoff are licensed under the
7
+ # Super Durable Source License 1.0.
8
+ # Legacy Materials remain under their original licenses.
9
+ # See LICENSE and LEGACY_NOTICES.md.
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+ from enum import Enum
15
+ from typing import Any, Generic, TypeVar, cast
16
+
17
+ from dex._utils import require_name
18
+ from dex.context import Context
19
+
20
+ ValueT = TypeVar("ValueT")
21
+
22
+
23
+ class IndexType(Enum):
24
+ KEYWORD = "keyword"
25
+ FULL_TEXT = "full_text"
26
+ KEYWORD_ARRAY = "keyword_array"
27
+ INT = "int"
28
+ DOUBLE = "double"
29
+ BOOL = "bool"
30
+ DATETIME = "datetime"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class AttributeIndex:
35
+ type: IndexType
36
+ index_key: str = ""
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Attribute(Generic[ValueT]):
41
+ name: str
42
+ value_type: type[ValueT]
43
+ index: AttributeIndex | None = None
44
+
45
+ def __post_init__(self) -> None:
46
+ require_name(self.name)
47
+
48
+ def get(self, context: Context) -> ValueT:
49
+ return context._get_attribute(self, None)
50
+
51
+ def set(self, context: Context, value: ValueT) -> None:
52
+ context._set_attribute(self, None, value)
53
+
54
+ def delete(self, context: Context) -> None:
55
+ context._delete_attribute(cast(Attribute[object], self), None)
56
+
57
+ def lock(self) -> AttributeLock:
58
+ return AttributeLock(self)
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class AttributeMap(Generic[ValueT]):
63
+ name: str
64
+ value_type: type[ValueT]
65
+ index: AttributeIndex | None = None
66
+
67
+ def __post_init__(self) -> None:
68
+ require_name(self.name)
69
+
70
+ def get(self, context: Context, instance: str) -> ValueT:
71
+ return context._get_attribute(self, instance)
72
+
73
+ def set(self, context: Context, instance: str, value: ValueT) -> None:
74
+ context._set_attribute(self, instance, value)
75
+
76
+ def delete(self, context: Context, instance: str) -> None:
77
+ context._delete_attribute(cast(AttributeMap[object], self), instance)
78
+
79
+ def lock(self, instance: str) -> AttributeLock:
80
+ require_name(instance)
81
+ return AttributeLock(self, instance)
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class AttributeLock:
86
+ attribute: Attribute[Any] | AttributeMap[Any]
87
+ instance: str | None = None
dex/blob_cache.py ADDED
@@ -0,0 +1,73 @@
1
+ # Copyright (c) 2026 Super Durable, Inc.
2
+ #
3
+ # Licensed under the Super Durable Source License 1.0.
4
+ # You may not use this file except in compliance with the License.
5
+ # See the LICENSE file in the repository root.
6
+ #
7
+ # SPDX-License-Identifier: LicenseRef-Super-Durable-1.0
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Protocol
11
+
12
+ from dex._native import NativeBlobCache
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class BlobCacheConfig:
17
+ directory: str
18
+ max_bytes: int
19
+ frequency_counters: int = 10_000
20
+
21
+ def __post_init__(self) -> None:
22
+ if not self.directory:
23
+ raise ValueError("blob cache directory is required")
24
+ if self.max_bytes <= 0:
25
+ raise ValueError("blob cache max_bytes must be positive")
26
+ if self.frequency_counters < 0:
27
+ raise ValueError("blob cache frequency_counters must not be negative")
28
+ if self.frequency_counters == 0:
29
+ object.__setattr__(self, "frequency_counters", 10_000)
30
+
31
+
32
+ class BlobCache(Protocol):
33
+ @property
34
+ def config(self) -> BlobCacheConfig: ...
35
+
36
+ def get(self, blob_id: str) -> bytes | None: ...
37
+
38
+ def put(self, blob_id: str, payload: bytes) -> bool: ...
39
+
40
+ def delete(self, blob_id: str) -> None: ...
41
+
42
+ def delete_all(self) -> None: ...
43
+
44
+ def close(self) -> None: ...
45
+
46
+
47
+ class _RustBlobCache:
48
+ def __init__(self, config: BlobCacheConfig) -> None:
49
+ self.config = config
50
+ self._native = NativeBlobCache(
51
+ config.directory,
52
+ config.max_bytes,
53
+ config.frequency_counters,
54
+ )
55
+
56
+ def get(self, blob_id: str) -> bytes | None:
57
+ return self._native.get(blob_id)
58
+
59
+ def put(self, blob_id: str, payload: bytes) -> bool:
60
+ return self._native.put(blob_id, payload)
61
+
62
+ def delete(self, blob_id: str) -> None:
63
+ self._native.delete(blob_id)
64
+
65
+ def delete_all(self) -> None:
66
+ self._native.delete_all()
67
+
68
+ def close(self) -> None:
69
+ self._native.close()
70
+
71
+
72
+ def open_blob_cache(config: BlobCacheConfig) -> BlobCache:
73
+ return _RustBlobCache(config)