temporal-parseable 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.
- temporal_parseable/__init__.py +162 -0
- temporal_parseable/_emitter.py +83 -0
- temporal_parseable/_version.py +1 -0
- temporal_parseable/activity_interceptor.py +91 -0
- temporal_parseable/config.py +116 -0
- temporal_parseable/exporters.py +121 -0
- temporal_parseable/types.py +70 -0
- temporal_parseable/workflow.py +82 -0
- temporal_parseable/workflow_interceptor.py +411 -0
- temporal_parseable-0.1.0.dist-info/METADATA +322 -0
- temporal_parseable-0.1.0.dist-info/RECORD +13 -0
- temporal_parseable-0.1.0.dist-info/WHEEL +4 -0
- temporal_parseable-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public helper for emitting custom domain events from workflow code.
|
|
3
|
+
|
|
4
|
+
Import from workflow code only::
|
|
5
|
+
|
|
6
|
+
from temporal_parseable.workflow import workflow_event
|
|
7
|
+
|
|
8
|
+
This module must be safe to import inside the Temporal workflow sandbox
|
|
9
|
+
(no I/O, no threading, no non-deterministic calls at import time).
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
@workflow.defn
|
|
14
|
+
class AgentWorkflow:
|
|
15
|
+
@workflow.run
|
|
16
|
+
async def run(self, input: AgentInput) -> AgentResult:
|
|
17
|
+
workflow_event("agent.started", {"user_id": input.user_id})
|
|
18
|
+
|
|
19
|
+
plan = await workflow.execute_activity(plan_activity, input)
|
|
20
|
+
workflow_event("agent.plan.chosen", {"steps": len(plan.steps)})
|
|
21
|
+
|
|
22
|
+
for step in plan.steps:
|
|
23
|
+
workflow_event("agent.step.start", {"tool": step.tool})
|
|
24
|
+
await workflow.execute_activity(run_step, step)
|
|
25
|
+
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
Each call emits a record with:
|
|
29
|
+
|
|
30
|
+
type = "user_event"
|
|
31
|
+
event_name = the name argument
|
|
32
|
+
event_data = the data argument (arbitrary JSON-serialisable dict)
|
|
33
|
+
workflow_id, run_id, workflow_name — current workflow context
|
|
34
|
+
|
|
35
|
+
Records are emitted only during live execution (replay-safe: guarded by
|
|
36
|
+
``workflow.unsafe.is_replaying()``), matching the TypeScript plugin's
|
|
37
|
+
``callDuringReplay: false`` sink behaviour.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from typing import Any, Dict, Optional
|
|
43
|
+
|
|
44
|
+
from temporalio import workflow
|
|
45
|
+
|
|
46
|
+
# The emitter is injected at worker startup by ParseablePlugin.
|
|
47
|
+
# Workflow code must never import _emitter directly — that would break sandbox isolation.
|
|
48
|
+
_emitter: Any = None # ParseableEmitter | None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _set_emitter(emitter: Any) -> None:
|
|
52
|
+
"""Called by ParseablePlugin during worker initialisation. Not for user code."""
|
|
53
|
+
global _emitter
|
|
54
|
+
_emitter = emitter
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def workflow_event(name: str, data: Optional[Dict[str, Any]] = None) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Emit a custom domain event from inside a Temporal workflow.
|
|
60
|
+
|
|
61
|
+
:param name: Dot-separated event name, e.g. ``"order.payment.captured"``.
|
|
62
|
+
:param data: Arbitrary JSON-serialisable payload. Defaults to ``{}``.
|
|
63
|
+
|
|
64
|
+
The call is a no-op when:
|
|
65
|
+
- the Temporal worker is replaying history (replay-safe)
|
|
66
|
+
- logs are disabled in the plugin config
|
|
67
|
+
- called outside a running workflow (e.g. in tests without the plugin)
|
|
68
|
+
"""
|
|
69
|
+
if _emitter is None:
|
|
70
|
+
return
|
|
71
|
+
if workflow.unsafe.is_replaying():
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
info = workflow.info()
|
|
75
|
+
_emitter.emit({ # type: ignore[arg-type]
|
|
76
|
+
"type": "user_event",
|
|
77
|
+
"event_name": name,
|
|
78
|
+
"event_data": data or {},
|
|
79
|
+
"workflow_id": info.workflow_id,
|
|
80
|
+
"run_id": info.run_id,
|
|
81
|
+
"workflow_name": info.workflow_type,
|
|
82
|
+
})
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Workflow interceptors.
|
|
3
|
+
|
|
4
|
+
Two classes:
|
|
5
|
+
|
|
6
|
+
ParseableWorkflowInboundInterceptor
|
|
7
|
+
Wraps:
|
|
8
|
+
execute_workflow → type=workflow started/completed/failed
|
|
9
|
+
handle_signal → type=signal direction=inbound started/completed/failed
|
|
10
|
+
handle_query → type=query direction=inbound started/completed/failed
|
|
11
|
+
handle_update → type=update direction=inbound started/completed/failed
|
|
12
|
+
|
|
13
|
+
ParseableWorkflowOutboundInterceptor
|
|
14
|
+
Wraps:
|
|
15
|
+
start_child_workflow → type=child_workflow direction=outbound started/completed/failed
|
|
16
|
+
signal_external_workflow → type=signal direction=outbound started/completed
|
|
17
|
+
signal_child_workflow → type=signal direction=outbound started/completed
|
|
18
|
+
continue_as_new → type=continue_as_new direction=outbound started (no completed)
|
|
19
|
+
|
|
20
|
+
Replay safety
|
|
21
|
+
-------------
|
|
22
|
+
The Python SDK has no equivalent of the TypeScript proxySinks
|
|
23
|
+
``callDuringReplay: false`` mechanism. Instead, every emission is guarded by:
|
|
24
|
+
|
|
25
|
+
if not workflow.unsafe.is_replaying():
|
|
26
|
+
self._emitter.emit(...)
|
|
27
|
+
|
|
28
|
+
This ensures that when Temporal replays a workflow's history (worker crash,
|
|
29
|
+
cache eviction, manual replay via Worker.run_replay_history), no duplicate
|
|
30
|
+
records are emitted. Verified by the replay-safety test suite.
|
|
31
|
+
|
|
32
|
+
Important: workflow interceptors run inside the deterministic workflow isolate.
|
|
33
|
+
They must NEVER perform I/O directly. The _emitter.emit() call is safe
|
|
34
|
+
because OTel's BatchLogRecordProcessor offloads the actual network send to a
|
|
35
|
+
background thread outside the isolate.
|
|
36
|
+
|
|
37
|
+
Mirrors the TypeScript workflow-interceptor.ts.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import time
|
|
43
|
+
from typing import Any
|
|
44
|
+
|
|
45
|
+
from temporalio import workflow
|
|
46
|
+
from temporalio.worker import (
|
|
47
|
+
WorkflowInboundInterceptor,
|
|
48
|
+
WorkflowOutboundInterceptor,
|
|
49
|
+
ExecuteWorkflowInput,
|
|
50
|
+
HandleSignalInput,
|
|
51
|
+
HandleQueryInput,
|
|
52
|
+
HandleUpdateInput,
|
|
53
|
+
StartChildWorkflowInput,
|
|
54
|
+
SignalExternalWorkflowInput,
|
|
55
|
+
SignalChildWorkflowInput,
|
|
56
|
+
ContinueAsNewInput,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
from ._emitter import ParseableEmitter, _now_iso
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
def _wf_base() -> dict:
|
|
65
|
+
"""Extract the current workflow identifiers (safe to call in WF context)."""
|
|
66
|
+
info = workflow.info()
|
|
67
|
+
return {
|
|
68
|
+
"workflow_id": info.workflow_id,
|
|
69
|
+
"run_id": info.run_id,
|
|
70
|
+
"workflow_name": info.workflow_type,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _emit_if_live(emitter: ParseableEmitter, record: dict) -> None:
|
|
75
|
+
"""Emit only during live execution, never during history replay."""
|
|
76
|
+
if not workflow.unsafe.is_replaying():
|
|
77
|
+
emitter.emit(record) # type: ignore[arg-type]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── inbound interceptor ───────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
class ParseableWorkflowInboundInterceptor(WorkflowInboundInterceptor):
|
|
83
|
+
"""
|
|
84
|
+
Intercepts inbound workflow calls and emits Parseable records.
|
|
85
|
+
|
|
86
|
+
Created once per workflow execution by
|
|
87
|
+
``ParseableWorkerInterceptor.workflow_interceptor_class``.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, next: WorkflowInboundInterceptor) -> None:
|
|
91
|
+
super().__init__(next)
|
|
92
|
+
self._emitter: ParseableEmitter # set by init()
|
|
93
|
+
self._outbound: ParseableWorkflowOutboundInterceptor
|
|
94
|
+
|
|
95
|
+
def init(self, outbound: WorkflowOutboundInterceptor) -> None:
|
|
96
|
+
# Wrap the outbound interceptor with ours so we can observe outbound calls.
|
|
97
|
+
self._outbound = ParseableWorkflowOutboundInterceptor(outbound, None)
|
|
98
|
+
super().init(self._outbound)
|
|
99
|
+
|
|
100
|
+
def _set_emitter(self, emitter: ParseableEmitter) -> None:
|
|
101
|
+
"""Called by the worker interceptor after construction."""
|
|
102
|
+
self._emitter = emitter
|
|
103
|
+
self._outbound._emitter = emitter
|
|
104
|
+
|
|
105
|
+
# ── execute_workflow ──────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
async def execute_workflow(self, input: ExecuteWorkflowInput) -> Any:
|
|
108
|
+
base = {**_wf_base(), "type": "workflow"}
|
|
109
|
+
_emit_if_live(self._emitter, {**base, "status": "started", "timestamp": _now_iso()})
|
|
110
|
+
|
|
111
|
+
start_ns = time.monotonic_ns()
|
|
112
|
+
try:
|
|
113
|
+
result = await self.next.execute_workflow(input)
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
|
|
116
|
+
_emit_if_live(self._emitter, {
|
|
117
|
+
**base,
|
|
118
|
+
"status": "failed",
|
|
119
|
+
"timestamp": _now_iso(),
|
|
120
|
+
"duration_ms": round(duration_ms, 3),
|
|
121
|
+
"error": str(exc),
|
|
122
|
+
})
|
|
123
|
+
raise
|
|
124
|
+
duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
|
|
125
|
+
_emit_if_live(self._emitter, {
|
|
126
|
+
**base,
|
|
127
|
+
"status": "completed",
|
|
128
|
+
"timestamp": _now_iso(),
|
|
129
|
+
"duration_ms": round(duration_ms, 3),
|
|
130
|
+
})
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
# ── handle_signal ─────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
async def handle_signal(self, input: HandleSignalInput) -> None:
|
|
136
|
+
base = {
|
|
137
|
+
**_wf_base(),
|
|
138
|
+
"type": "signal",
|
|
139
|
+
"direction": "inbound",
|
|
140
|
+
"message_name": input.signal,
|
|
141
|
+
}
|
|
142
|
+
_emit_if_live(self._emitter, {**base, "status": "started", "timestamp": _now_iso()})
|
|
143
|
+
|
|
144
|
+
start_ns = time.monotonic_ns()
|
|
145
|
+
try:
|
|
146
|
+
await self.next.handle_signal(input)
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
|
|
149
|
+
_emit_if_live(self._emitter, {
|
|
150
|
+
**base,
|
|
151
|
+
"status": "failed",
|
|
152
|
+
"timestamp": _now_iso(),
|
|
153
|
+
"duration_ms": round(duration_ms, 3),
|
|
154
|
+
"error": str(exc),
|
|
155
|
+
})
|
|
156
|
+
raise
|
|
157
|
+
duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
|
|
158
|
+
_emit_if_live(self._emitter, {
|
|
159
|
+
**base,
|
|
160
|
+
"status": "completed",
|
|
161
|
+
"timestamp": _now_iso(),
|
|
162
|
+
"duration_ms": round(duration_ms, 3),
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
# ── handle_query ──────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
async def handle_query(self, input: HandleQueryInput) -> Any:
|
|
168
|
+
base = {
|
|
169
|
+
**_wf_base(),
|
|
170
|
+
"type": "query",
|
|
171
|
+
"direction": "inbound",
|
|
172
|
+
"message_name": input.query,
|
|
173
|
+
}
|
|
174
|
+
_emit_if_live(self._emitter, {**base, "status": "started", "timestamp": _now_iso()})
|
|
175
|
+
|
|
176
|
+
start_ns = time.monotonic_ns()
|
|
177
|
+
try:
|
|
178
|
+
result = await self.next.handle_query(input)
|
|
179
|
+
except Exception as exc:
|
|
180
|
+
duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
|
|
181
|
+
_emit_if_live(self._emitter, {
|
|
182
|
+
**base,
|
|
183
|
+
"status": "failed",
|
|
184
|
+
"timestamp": _now_iso(),
|
|
185
|
+
"duration_ms": round(duration_ms, 3),
|
|
186
|
+
"error": str(exc),
|
|
187
|
+
})
|
|
188
|
+
raise
|
|
189
|
+
duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
|
|
190
|
+
_emit_if_live(self._emitter, {
|
|
191
|
+
**base,
|
|
192
|
+
"status": "completed",
|
|
193
|
+
"timestamp": _now_iso(),
|
|
194
|
+
"duration_ms": round(duration_ms, 3),
|
|
195
|
+
})
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
# ── handle_update ─────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
async def handle_update(self, input: HandleUpdateInput) -> Any:
|
|
201
|
+
base = {
|
|
202
|
+
**_wf_base(),
|
|
203
|
+
"type": "update",
|
|
204
|
+
"direction": "inbound",
|
|
205
|
+
"message_name": input.update,
|
|
206
|
+
}
|
|
207
|
+
_emit_if_live(self._emitter, {**base, "status": "started", "timestamp": _now_iso()})
|
|
208
|
+
|
|
209
|
+
start_ns = time.monotonic_ns()
|
|
210
|
+
try:
|
|
211
|
+
result = await self.next.handle_update(input)
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
|
|
214
|
+
_emit_if_live(self._emitter, {
|
|
215
|
+
**base,
|
|
216
|
+
"status": "failed",
|
|
217
|
+
"timestamp": _now_iso(),
|
|
218
|
+
"duration_ms": round(duration_ms, 3),
|
|
219
|
+
"error": str(exc),
|
|
220
|
+
})
|
|
221
|
+
raise
|
|
222
|
+
duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
|
|
223
|
+
_emit_if_live(self._emitter, {
|
|
224
|
+
**base,
|
|
225
|
+
"status": "completed",
|
|
226
|
+
"timestamp": _now_iso(),
|
|
227
|
+
"duration_ms": round(duration_ms, 3),
|
|
228
|
+
})
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ── outbound interceptor ──────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
class ParseableWorkflowOutboundInterceptor(WorkflowOutboundInterceptor):
|
|
235
|
+
"""
|
|
236
|
+
Intercepts outbound workflow calls (child workflows, signals, continue-as-new).
|
|
237
|
+
|
|
238
|
+
The emitter is injected after construction by
|
|
239
|
+
``ParseableWorkflowInboundInterceptor.init()``.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
def __init__(
|
|
243
|
+
self,
|
|
244
|
+
next: WorkflowOutboundInterceptor,
|
|
245
|
+
emitter: ParseableEmitter | None,
|
|
246
|
+
) -> None:
|
|
247
|
+
super().__init__(next)
|
|
248
|
+
self._emitter = emitter # type: ignore[assignment]
|
|
249
|
+
|
|
250
|
+
# ── start_child_workflow ──────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
async def start_child_workflow(self, input: StartChildWorkflowInput) -> Any:
|
|
253
|
+
base = {
|
|
254
|
+
**_wf_base(),
|
|
255
|
+
"type": "child_workflow",
|
|
256
|
+
"direction": "outbound",
|
|
257
|
+
"message_name": input.workflow,
|
|
258
|
+
"target_workflow_id": input.id or "",
|
|
259
|
+
}
|
|
260
|
+
_emit_if_live(self._emitter, {**base, "status": "started", "timestamp": _now_iso()})
|
|
261
|
+
|
|
262
|
+
start_ns = time.monotonic_ns()
|
|
263
|
+
try:
|
|
264
|
+
# next() returns a ChildWorkflowHandle; we await its result to
|
|
265
|
+
# track completion, matching the TS behaviour (completed fires when
|
|
266
|
+
# the child finishes, not when the start RPC returns).
|
|
267
|
+
handle = await self.next.start_child_workflow(input)
|
|
268
|
+
except Exception as exc:
|
|
269
|
+
duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
|
|
270
|
+
_emit_if_live(self._emitter, {
|
|
271
|
+
**base,
|
|
272
|
+
"status": "failed",
|
|
273
|
+
"timestamp": _now_iso(),
|
|
274
|
+
"duration_ms": round(duration_ms, 3),
|
|
275
|
+
"error": str(exc),
|
|
276
|
+
})
|
|
277
|
+
raise
|
|
278
|
+
|
|
279
|
+
# Wrap the handle so we can observe when the child actually finishes.
|
|
280
|
+
return _ChildWorkflowHandleWrapper(handle, base, self._emitter, start_ns)
|
|
281
|
+
|
|
282
|
+
# ── signal_external_workflow ──────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
async def signal_external_workflow(self, input: SignalExternalWorkflowInput) -> None:
|
|
285
|
+
base = {
|
|
286
|
+
**_wf_base(),
|
|
287
|
+
"type": "signal",
|
|
288
|
+
"direction": "outbound",
|
|
289
|
+
"message_name": input.signal,
|
|
290
|
+
"target_workflow_id": input.workflow_id,
|
|
291
|
+
}
|
|
292
|
+
_emit_if_live(self._emitter, {**base, "status": "started", "timestamp": _now_iso()})
|
|
293
|
+
|
|
294
|
+
start_ns = time.monotonic_ns()
|
|
295
|
+
try:
|
|
296
|
+
await self.next.signal_external_workflow(input)
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
|
|
299
|
+
_emit_if_live(self._emitter, {
|
|
300
|
+
**base,
|
|
301
|
+
"status": "failed",
|
|
302
|
+
"timestamp": _now_iso(),
|
|
303
|
+
"duration_ms": round(duration_ms, 3),
|
|
304
|
+
"error": str(exc),
|
|
305
|
+
})
|
|
306
|
+
raise
|
|
307
|
+
duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
|
|
308
|
+
_emit_if_live(self._emitter, {
|
|
309
|
+
**base,
|
|
310
|
+
"status": "completed",
|
|
311
|
+
"timestamp": _now_iso(),
|
|
312
|
+
"duration_ms": round(duration_ms, 3),
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
# ── signal_child_workflow ─────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
async def signal_child_workflow(self, input: SignalChildWorkflowInput) -> None:
|
|
318
|
+
base = {
|
|
319
|
+
**_wf_base(),
|
|
320
|
+
"type": "signal",
|
|
321
|
+
"direction": "outbound",
|
|
322
|
+
"message_name": input.signal,
|
|
323
|
+
"target_workflow_id": input.workflow_id,
|
|
324
|
+
}
|
|
325
|
+
_emit_if_live(self._emitter, {**base, "status": "started", "timestamp": _now_iso()})
|
|
326
|
+
|
|
327
|
+
start_ns = time.monotonic_ns()
|
|
328
|
+
try:
|
|
329
|
+
await self.next.signal_child_workflow(input)
|
|
330
|
+
except Exception as exc:
|
|
331
|
+
duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
|
|
332
|
+
_emit_if_live(self._emitter, {
|
|
333
|
+
**base,
|
|
334
|
+
"status": "failed",
|
|
335
|
+
"timestamp": _now_iso(),
|
|
336
|
+
"duration_ms": round(duration_ms, 3),
|
|
337
|
+
"error": str(exc),
|
|
338
|
+
})
|
|
339
|
+
raise
|
|
340
|
+
duration_ms = (time.monotonic_ns() - start_ns) / 1_000_000
|
|
341
|
+
_emit_if_live(self._emitter, {
|
|
342
|
+
**base,
|
|
343
|
+
"status": "completed",
|
|
344
|
+
"timestamp": _now_iso(),
|
|
345
|
+
"duration_ms": round(duration_ms, 3),
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
# ── continue_as_new ───────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
def continue_as_new(self, input: ContinueAsNewInput) -> None:
|
|
351
|
+
base = {
|
|
352
|
+
**_wf_base(),
|
|
353
|
+
"type": "continue_as_new",
|
|
354
|
+
"direction": "outbound",
|
|
355
|
+
}
|
|
356
|
+
# Only a single "started" record — there is no "completed" because the
|
|
357
|
+
# current execution ends immediately.
|
|
358
|
+
_emit_if_live(self._emitter, {**base, "status": "started", "timestamp": _now_iso()})
|
|
359
|
+
self.next.continue_as_new(input)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ── child workflow handle wrapper ─────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
class _ChildWorkflowHandleWrapper:
|
|
365
|
+
"""
|
|
366
|
+
Thin proxy around a ChildWorkflowHandle that emits completed/failed records
|
|
367
|
+
when the child finishes.
|
|
368
|
+
|
|
369
|
+
Delegating __getattr__ keeps this transparent to callers who use the handle
|
|
370
|
+
for signalling, querying, etc.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
def __init__(
|
|
374
|
+
self,
|
|
375
|
+
handle: Any,
|
|
376
|
+
base_record: dict,
|
|
377
|
+
emitter: ParseableEmitter,
|
|
378
|
+
start_ns: int,
|
|
379
|
+
) -> None:
|
|
380
|
+
self._handle = handle
|
|
381
|
+
self._base = base_record
|
|
382
|
+
self._emitter = emitter
|
|
383
|
+
self._start_ns = start_ns
|
|
384
|
+
|
|
385
|
+
def __getattr__(self, name: str) -> Any:
|
|
386
|
+
return getattr(self._handle, name)
|
|
387
|
+
|
|
388
|
+
def __await__(self):
|
|
389
|
+
return self._await_result().__await__()
|
|
390
|
+
|
|
391
|
+
async def _await_result(self) -> Any:
|
|
392
|
+
try:
|
|
393
|
+
result = await self._handle
|
|
394
|
+
except Exception as exc:
|
|
395
|
+
duration_ms = (time.monotonic_ns() - self._start_ns) / 1_000_000
|
|
396
|
+
_emit_if_live(self._emitter, {
|
|
397
|
+
**self._base,
|
|
398
|
+
"status": "failed",
|
|
399
|
+
"timestamp": _now_iso(),
|
|
400
|
+
"duration_ms": round(duration_ms, 3),
|
|
401
|
+
"error": str(exc),
|
|
402
|
+
})
|
|
403
|
+
raise
|
|
404
|
+
duration_ms = (time.monotonic_ns() - self._start_ns) / 1_000_000
|
|
405
|
+
_emit_if_live(self._emitter, {
|
|
406
|
+
**self._base,
|
|
407
|
+
"status": "completed",
|
|
408
|
+
"timestamp": _now_iso(),
|
|
409
|
+
"duration_ms": round(duration_ms, 3),
|
|
410
|
+
})
|
|
411
|
+
return result
|