openbox-temporal-sdk-python 1.1.1__py3-none-any.whl → 1.1.2__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.
- openbox/__init__.py +3 -3
- openbox/activities.py +101 -52
- openbox/db_governance_hooks.py +8 -3
- openbox/errors.py +15 -1
- openbox/hook_governance.py +15 -5
- openbox/plugin.py +36 -11
- openbox/worker.py +33 -20
- openbox/workflow_interceptor.py +77 -50
- {openbox_temporal_sdk_python-1.1.1.dist-info → openbox_temporal_sdk_python-1.1.2.dist-info}/METADATA +35 -8
- {openbox_temporal_sdk_python-1.1.1.dist-info → openbox_temporal_sdk_python-1.1.2.dist-info}/RECORD +12 -12
- {openbox_temporal_sdk_python-1.1.1.dist-info → openbox_temporal_sdk_python-1.1.2.dist-info}/WHEEL +0 -0
- {openbox_temporal_sdk_python-1.1.1.dist-info → openbox_temporal_sdk_python-1.1.2.dist-info}/licenses/LICENSE +0 -0
openbox/__init__.py
CHANGED
|
@@ -68,7 +68,7 @@ from .span_processor import WorkflowSpanProcessor
|
|
|
68
68
|
from .workflow_interceptor import GovernanceInterceptor
|
|
69
69
|
|
|
70
70
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
71
|
-
# Plugin (requires temporalio >= 1.
|
|
71
|
+
# Plugin (requires temporalio >= 1.23.0)
|
|
72
72
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
73
73
|
|
|
74
74
|
try:
|
|
@@ -76,7 +76,7 @@ try:
|
|
|
76
76
|
|
|
77
77
|
from .plugin import OpenBoxPlugin
|
|
78
78
|
except ImportError:
|
|
79
|
-
pass # temporalio < 1.
|
|
79
|
+
pass # temporalio < 1.23.0, plugin not available
|
|
80
80
|
|
|
81
81
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
82
82
|
# Verdict Handler
|
|
@@ -139,7 +139,7 @@ from .client import GovernanceClient
|
|
|
139
139
|
__all__ = [
|
|
140
140
|
# Simple Worker Factory (recommended)
|
|
141
141
|
"create_openbox_worker",
|
|
142
|
-
# Plugin (recommended for temporalio >= 1.
|
|
142
|
+
# Plugin (recommended for temporalio >= 1.23.0)
|
|
143
143
|
"OpenBoxPlugin",
|
|
144
144
|
# Configuration
|
|
145
145
|
"initialize",
|
openbox/activities.py
CHANGED
|
@@ -142,60 +142,109 @@ def _handle_api_error(event_type: str, error_msg: str, on_api_error: str) -> dic
|
|
|
142
142
|
return {"success": False, "error": error_msg}
|
|
143
143
|
|
|
144
144
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
"""
|
|
148
|
-
Activity that sends governance events to OpenBox Core.
|
|
145
|
+
class GovernanceActivities:
|
|
146
|
+
"""Container for Temporal activities that need OpenBox credentials.
|
|
149
147
|
|
|
150
|
-
|
|
151
|
-
to
|
|
148
|
+
Credentials live on the instance (never in activity inputs → never in
|
|
149
|
+
workflow history → not visible to anyone with namespace read access).
|
|
150
|
+
The worker registers bound methods of a single instance, created at
|
|
151
|
+
worker-init time by the plugin / create_openbox_worker factory.
|
|
152
152
|
"""
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
153
|
+
|
|
154
|
+
def __init__(self, api_url: str, api_key: str):
|
|
155
|
+
self._api_url = api_url.rstrip("/")
|
|
156
|
+
self._api_key = api_key
|
|
157
|
+
|
|
158
|
+
@activity.defn(name="send_governance_event")
|
|
159
|
+
async def send_governance_event(
|
|
160
|
+
self, input: Dict[str, Any]
|
|
161
|
+
) -> Optional[Dict[str, Any]]:
|
|
162
|
+
"""Send a governance event to OpenBox Core.
|
|
163
|
+
|
|
164
|
+
Called from WorkflowInboundInterceptor via workflow.execute_activity()
|
|
165
|
+
to maintain workflow determinism. The `input` dict carries only the
|
|
166
|
+
event payload and per-call policy — never credentials.
|
|
167
|
+
"""
|
|
168
|
+
event_payload = input.get("payload", {})
|
|
169
|
+
timeout = input.get("timeout", 30.0)
|
|
170
|
+
on_api_error = input.get("on_api_error", "fail_open")
|
|
171
|
+
|
|
172
|
+
# Add timestamp in activity context (non-deterministic code allowed)
|
|
173
|
+
payload = {**event_payload, "timestamp": _rfc3339_now()}
|
|
174
|
+
event_type = event_payload.get("event_type", "unknown")
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
178
|
+
response = await client.post(
|
|
179
|
+
f"{self._api_url}/api/v1/governance/evaluate",
|
|
180
|
+
json=payload,
|
|
181
|
+
headers=build_auth_headers(self._api_key),
|
|
176
182
|
)
|
|
177
183
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
verdict, reason, policy_id, risk_score, event_type, event_payload
|
|
184
|
+
if response.status_code != 200:
|
|
185
|
+
return _handle_api_error(
|
|
186
|
+
event_type,
|
|
187
|
+
f"HTTP {response.status_code}: {response.text}",
|
|
188
|
+
on_api_error,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
data = response.json()
|
|
192
|
+
verdict = Verdict.from_string(
|
|
193
|
+
data.get("verdict") or data.get("action", "continue")
|
|
189
194
|
)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
195
|
+
reason = data.get("reason")
|
|
196
|
+
policy_id = data.get("policy_id")
|
|
197
|
+
risk_score = data.get("risk_score", 0.0)
|
|
198
|
+
|
|
199
|
+
if verdict.should_stop():
|
|
200
|
+
result = await _handle_stop_verdict(
|
|
201
|
+
verdict,
|
|
202
|
+
reason,
|
|
203
|
+
policy_id,
|
|
204
|
+
risk_score,
|
|
205
|
+
event_type,
|
|
206
|
+
event_payload,
|
|
207
|
+
)
|
|
208
|
+
if result:
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
return _build_verdict_result(verdict, reason, policy_id, risk_score)
|
|
212
|
+
|
|
213
|
+
except (GovernanceAPIError, ApplicationError):
|
|
214
|
+
raise
|
|
215
|
+
except Exception as e:
|
|
216
|
+
logger.warning(f"Failed to send {event_type} event: {e}")
|
|
217
|
+
if on_api_error == "fail_closed":
|
|
218
|
+
raise GovernanceAPIError(str(e))
|
|
219
|
+
return {"success": False, "error": str(e)}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def build_governance_activities(
|
|
223
|
+
api_url: str, api_key: str
|
|
224
|
+
) -> GovernanceActivities:
|
|
225
|
+
"""Factory used by plugin.py and worker.py to build the activities instance."""
|
|
226
|
+
return GovernanceActivities(api_url=api_url, api_key=api_key)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
230
|
+
# Backward-compat module-level helper.
|
|
231
|
+
#
|
|
232
|
+
# Not decorated with @activity.defn — worker/plugin register the class-based
|
|
233
|
+
# version above so credentials never flow through activity inputs. This shim
|
|
234
|
+
# exists for direct callers (tests, scripts) who already hold credentials and
|
|
235
|
+
# want to invoke the HTTP logic without constructing the class themselves.
|
|
236
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
237
|
+
async def send_governance_event(input: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
238
|
+
"""Backward-compat wrapper — delegates to GovernanceActivities.
|
|
239
|
+
|
|
240
|
+
Tests and direct callers can keep passing api_url/api_key in the input
|
|
241
|
+
dict. The class-based activity registered with the worker does NOT see
|
|
242
|
+
these fields (it reads credentials from self), so nothing written to
|
|
243
|
+
workflow history ever carries the API key.
|
|
244
|
+
"""
|
|
245
|
+
instance = GovernanceActivities(
|
|
246
|
+
api_url=input.get("api_url", ""),
|
|
247
|
+
api_key=input.get("api_key", ""),
|
|
248
|
+
)
|
|
249
|
+
forwarded = {k: v for k, v in input.items() if k not in ("api_url", "api_key")}
|
|
250
|
+
return await instance.send_governance_event(forwarded)
|
openbox/db_governance_hooks.py
CHANGED
|
@@ -68,9 +68,14 @@ def _classify_sql(query: Any) -> str:
|
|
|
68
68
|
|
|
69
69
|
|
|
70
70
|
def _generate_span_id() -> str:
|
|
71
|
-
"""Generate a random 16-hex-char span ID for pymongo governance spans.
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
"""Generate a random 16-hex-char span ID for pymongo governance spans.
|
|
72
|
+
|
|
73
|
+
Uses secrets (CSPRNG) rather than random — span IDs are not secrets, but
|
|
74
|
+
the stronger generator silences security scanners without runtime cost
|
|
75
|
+
and removes any chance of cross-trace ID collision under heavy load.
|
|
76
|
+
"""
|
|
77
|
+
import secrets
|
|
78
|
+
return secrets.token_hex(8)
|
|
74
79
|
|
|
75
80
|
|
|
76
81
|
def _build_db_span_data(
|
openbox/errors.py
CHANGED
|
@@ -21,12 +21,26 @@ Hierarchy:
|
|
|
21
21
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
|
-
from typing import TYPE_CHECKING, Union
|
|
24
|
+
from typing import TYPE_CHECKING, Final, Union
|
|
25
25
|
|
|
26
26
|
if TYPE_CHECKING:
|
|
27
27
|
from .types import Verdict
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
31
|
+
# ApplicationError.type values raised by governance activities.
|
|
32
|
+
# Use these constants — never string-match on error messages or
|
|
33
|
+
# exception class names (brittle against locale / reformatting /
|
|
34
|
+
# Temporal's ActivityError wrapping).
|
|
35
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
36
|
+
|
|
37
|
+
GOVERNANCE_HALT_ERROR_TYPE: Final[str] = "GovernanceHalt"
|
|
38
|
+
GOVERNANCE_BLOCK_ERROR_TYPE: Final[str] = "GovernanceBlock"
|
|
39
|
+
GOVERNANCE_API_ERROR_TYPE: Final[str] = "GovernanceAPIError"
|
|
40
|
+
# Legacy alias; kept for histories predating the rename.
|
|
41
|
+
GOVERNANCE_STOP_ERROR_TYPE: Final[str] = "GovernanceStop"
|
|
42
|
+
|
|
43
|
+
|
|
30
44
|
# ═══════════════════════════════════════════════════════════════════
|
|
31
45
|
# Base
|
|
32
46
|
# ═══════════════════════════════════════════════════════════════════
|
openbox/hook_governance.py
CHANGED
|
@@ -16,6 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
|
|
17
17
|
import json
|
|
18
18
|
import logging
|
|
19
|
+
import threading
|
|
19
20
|
from typing import TYPE_CHECKING, Any, Dict, Optional
|
|
20
21
|
|
|
21
22
|
import httpx
|
|
@@ -38,9 +39,14 @@ _max_body_size: Optional[int] = None
|
|
|
38
39
|
_span_processor: Optional["WorkflowSpanProcessor"] = None
|
|
39
40
|
_cached_auth_headers: Optional[dict] = None
|
|
40
41
|
|
|
41
|
-
# Persistent HTTP clients
|
|
42
|
+
# Persistent HTTP clients. httpx Client/AsyncClient themselves are thread-safe
|
|
43
|
+
# for requests; the locks below only guard creation against concurrent activities
|
|
44
|
+
# racing to initialize a fresh client (which would leak connection pools when
|
|
45
|
+
# one of the losers gets garbage collected).
|
|
42
46
|
_sync_client: Optional[httpx.Client] = None
|
|
43
47
|
_async_client: Optional[httpx.AsyncClient] = None
|
|
48
|
+
_sync_client_lock = threading.Lock()
|
|
49
|
+
_async_client_lock = threading.Lock()
|
|
44
50
|
|
|
45
51
|
|
|
46
52
|
def configure(
|
|
@@ -78,18 +84,22 @@ def configure(
|
|
|
78
84
|
|
|
79
85
|
|
|
80
86
|
def _get_sync_client() -> httpx.Client:
|
|
81
|
-
"""Get or create persistent sync HTTP client."""
|
|
87
|
+
"""Get or create persistent sync HTTP client (double-checked locking)."""
|
|
82
88
|
global _sync_client
|
|
83
89
|
if _sync_client is None or _sync_client.is_closed:
|
|
84
|
-
|
|
90
|
+
with _sync_client_lock:
|
|
91
|
+
if _sync_client is None or _sync_client.is_closed:
|
|
92
|
+
_sync_client = httpx.Client(timeout=_api_timeout)
|
|
85
93
|
return _sync_client
|
|
86
94
|
|
|
87
95
|
|
|
88
96
|
def _get_async_client() -> httpx.AsyncClient:
|
|
89
|
-
"""Get or create persistent async HTTP client."""
|
|
97
|
+
"""Get or create persistent async HTTP client (double-checked locking)."""
|
|
90
98
|
global _async_client
|
|
91
99
|
if _async_client is None or _async_client.is_closed:
|
|
92
|
-
|
|
100
|
+
with _async_client_lock:
|
|
101
|
+
if _async_client is None or _async_client.is_closed:
|
|
102
|
+
_async_client = httpx.AsyncClient(timeout=_api_timeout)
|
|
93
103
|
return _async_client
|
|
94
104
|
|
|
95
105
|
|
openbox/plugin.py
CHANGED
|
@@ -17,6 +17,7 @@ Usage:
|
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
19
|
import dataclasses
|
|
20
|
+
import logging
|
|
20
21
|
from typing import Any, Optional, Set
|
|
21
22
|
|
|
22
23
|
from temporalio.plugin import SimplePlugin
|
|
@@ -27,6 +28,8 @@ from .config import initialize as validate_api_key, GovernanceConfig
|
|
|
27
28
|
from .span_processor import WorkflowSpanProcessor
|
|
28
29
|
from .client import GovernanceClient
|
|
29
30
|
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
30
33
|
|
|
31
34
|
class OpenBoxPlugin(SimplePlugin):
|
|
32
35
|
"""Temporal Plugin for OpenBox governance and observability.
|
|
@@ -62,6 +65,11 @@ class OpenBoxPlugin(SimplePlugin):
|
|
|
62
65
|
db_libraries: Optional[Set[str]] = None,
|
|
63
66
|
sqlalchemy_engine: Optional[Any] = None,
|
|
64
67
|
instrument_file_io: bool = True,
|
|
68
|
+
# Propagate W3C traceparent/baggage through Temporal headers so spans
|
|
69
|
+
# started by the caller (e.g., an HTTP server) stitch to workflow and
|
|
70
|
+
# activity spans on the worker side. Uses Temporal's built-in
|
|
71
|
+
# TracingInterceptor under the hood.
|
|
72
|
+
enable_trace_propagation: bool = True,
|
|
65
73
|
):
|
|
66
74
|
# 1. Validate API key (sync, uses urllib)
|
|
67
75
|
validate_api_key(
|
|
@@ -115,7 +123,7 @@ class OpenBoxPlugin(SimplePlugin):
|
|
|
115
123
|
on_api_error=governance_policy,
|
|
116
124
|
)
|
|
117
125
|
|
|
118
|
-
interceptors = [
|
|
126
|
+
interceptors: list = [
|
|
119
127
|
GovernanceInterceptor(
|
|
120
128
|
api_url=openbox_url,
|
|
121
129
|
api_key=openbox_api_key,
|
|
@@ -131,8 +139,23 @@ class OpenBoxPlugin(SimplePlugin):
|
|
|
131
139
|
),
|
|
132
140
|
]
|
|
133
141
|
|
|
134
|
-
#
|
|
135
|
-
|
|
142
|
+
# Header-based OTel trace propagation. Temporal's built-in
|
|
143
|
+
# TracingInterceptor implements both client.Interceptor and
|
|
144
|
+
# worker.Interceptor — SimplePlugin auto-routes it to both sides.
|
|
145
|
+
# Without it, trace IDs set by the caller don't reach workflow/
|
|
146
|
+
# activity spans, leaving disconnected trees in the backend.
|
|
147
|
+
if enable_trace_propagation:
|
|
148
|
+
from temporalio.contrib.opentelemetry import TracingInterceptor
|
|
149
|
+
|
|
150
|
+
interceptors.append(TracingInterceptor())
|
|
151
|
+
|
|
152
|
+
# 6. Build governance activity instance with credentials captured in self —
|
|
153
|
+
# avoids passing api_key through activity inputs / workflow history.
|
|
154
|
+
from .activities import build_governance_activities
|
|
155
|
+
|
|
156
|
+
governance_activities = build_governance_activities(
|
|
157
|
+
api_url=openbox_url, api_key=openbox_api_key
|
|
158
|
+
)
|
|
136
159
|
|
|
137
160
|
# 7. Sandbox passthrough for opentelemetry
|
|
138
161
|
def workflow_runner(runner: WorkflowRunner | None) -> WorkflowRunner | None:
|
|
@@ -157,7 +180,7 @@ class OpenBoxPlugin(SimplePlugin):
|
|
|
157
180
|
super().__init__(
|
|
158
181
|
"openbox.OpenBoxPlugin",
|
|
159
182
|
interceptors=interceptors,
|
|
160
|
-
activities=[send_governance_event],
|
|
183
|
+
activities=[governance_activities.send_governance_event],
|
|
161
184
|
workflow_runner=workflow_runner,
|
|
162
185
|
)
|
|
163
186
|
|
|
@@ -171,15 +194,17 @@ class OpenBoxPlugin(SimplePlugin):
|
|
|
171
194
|
|
|
172
195
|
config = super().configure_worker(config)
|
|
173
196
|
|
|
174
|
-
print("OpenBox Plugin initialized successfully")
|
|
175
|
-
print(f" - Governance policy: {self._governance_policy}")
|
|
176
|
-
print(f" - Governance timeout: {self._governance_timeout}s")
|
|
177
197
|
db_status = "enabled" if self._instrument_databases else "disabled"
|
|
178
198
|
file_status = "enabled" if self._instrument_file_io else "disabled"
|
|
179
199
|
hitl_status = "enabled" if self._hitl_enabled else "disabled"
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
200
|
+
logger.info(
|
|
201
|
+
"OpenBox Plugin initialized: policy=%s timeout=%ss "
|
|
202
|
+
"db=%s file=%s hitl=%s hook_governance=enabled",
|
|
203
|
+
self._governance_policy,
|
|
204
|
+
self._governance_timeout,
|
|
205
|
+
db_status,
|
|
206
|
+
file_status,
|
|
207
|
+
hitl_status,
|
|
208
|
+
)
|
|
184
209
|
|
|
185
210
|
return config
|
openbox/worker.py
CHANGED
|
@@ -18,6 +18,7 @@ Usage:
|
|
|
18
18
|
await worker.run()
|
|
19
19
|
"""
|
|
20
20
|
|
|
21
|
+
import logging
|
|
21
22
|
from datetime import timedelta
|
|
22
23
|
from typing import (
|
|
23
24
|
Any,
|
|
@@ -36,6 +37,8 @@ from temporalio.worker import Worker, Interceptor
|
|
|
36
37
|
from .config import initialize as validate_api_key, GovernanceConfig
|
|
37
38
|
from .span_processor import WorkflowSpanProcessor
|
|
38
39
|
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
39
42
|
|
|
40
43
|
def create_openbox_worker(
|
|
41
44
|
client: Client,
|
|
@@ -61,6 +64,9 @@ def create_openbox_worker(
|
|
|
61
64
|
sqlalchemy_engine: Optional[Any] = None,
|
|
62
65
|
# File I/O instrumentation
|
|
63
66
|
instrument_file_io: bool = True,
|
|
67
|
+
# Header-based W3C trace propagation via Temporal's built-in TracingInterceptor.
|
|
68
|
+
# Without it, trace IDs set by the caller don't reach workflow/activity spans.
|
|
69
|
+
enable_trace_propagation: bool = True,
|
|
64
70
|
# Standard Worker options
|
|
65
71
|
activity_executor: Optional[Executor] = None,
|
|
66
72
|
workflow_task_executor: Optional[ThreadPoolExecutor] = None,
|
|
@@ -156,8 +162,7 @@ def create_openbox_worker(
|
|
|
156
162
|
await worker.run()
|
|
157
163
|
```
|
|
158
164
|
"""
|
|
159
|
-
|
|
160
|
-
print(f"Initializing OpenBox SDK with URL: {openbox_url}")
|
|
165
|
+
logger.info("Initializing OpenBox SDK with URL: %s", openbox_url)
|
|
161
166
|
|
|
162
167
|
# 0. Store Temporal client reference for HALT terminate calls
|
|
163
168
|
from .activities import set_temporal_client
|
|
@@ -231,27 +236,35 @@ def create_openbox_worker(
|
|
|
231
236
|
client=governance_client,
|
|
232
237
|
)
|
|
233
238
|
|
|
234
|
-
# 6.
|
|
235
|
-
|
|
239
|
+
# 6. Build governance activities with credentials captured on the instance
|
|
240
|
+
# (so api_key never leaks through activity inputs into workflow history).
|
|
241
|
+
from .activities import build_governance_activities
|
|
236
242
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
all_activities = [*activities, send_governance_event]
|
|
240
|
-
|
|
241
|
-
print("OpenBox SDK initialized successfully")
|
|
242
|
-
print(f" - Governance policy: {governance_policy}")
|
|
243
|
-
print(f" - Governance timeout: {governance_timeout}s")
|
|
244
|
-
print(
|
|
245
|
-
" - Events: WorkflowStarted, WorkflowCompleted, WorkflowFailed, SignalReceived, ActivityStarted, ActivityCompleted"
|
|
246
|
-
)
|
|
247
|
-
print(
|
|
248
|
-
f" - Database instrumentation: {'enabled' if instrument_databases else 'disabled'}"
|
|
243
|
+
governance_activities = build_governance_activities(
|
|
244
|
+
api_url=openbox_url, api_key=openbox_api_key
|
|
249
245
|
)
|
|
250
|
-
|
|
251
|
-
|
|
246
|
+
|
|
247
|
+
# Add OpenBox components
|
|
248
|
+
all_interceptors: list = [workflow_interceptor, activity_interceptor, *interceptors]
|
|
249
|
+
|
|
250
|
+
# Header-based OTel trace propagation via Temporal's built-in interceptor.
|
|
251
|
+
if enable_trace_propagation:
|
|
252
|
+
from temporalio.contrib.opentelemetry import TracingInterceptor
|
|
253
|
+
|
|
254
|
+
all_interceptors.append(TracingInterceptor())
|
|
255
|
+
|
|
256
|
+
all_activities = [*activities, governance_activities.send_governance_event]
|
|
257
|
+
|
|
258
|
+
logger.info(
|
|
259
|
+
"OpenBox SDK initialized: policy=%s timeout=%ss db=%s file=%s hitl=%s "
|
|
260
|
+
"hook_governance=enabled events=WorkflowStarted,WorkflowCompleted,"
|
|
261
|
+
"WorkflowFailed,SignalReceived,ActivityStarted,ActivityCompleted",
|
|
262
|
+
governance_policy,
|
|
263
|
+
governance_timeout,
|
|
264
|
+
"enabled" if instrument_databases else "disabled",
|
|
265
|
+
"enabled" if instrument_file_io else "disabled",
|
|
266
|
+
"enabled" if hitl_enabled else "disabled",
|
|
252
267
|
)
|
|
253
|
-
print(f" - Approval polling: {'enabled' if hitl_enabled else 'disabled'}")
|
|
254
|
-
print(" - Hook governance: enabled")
|
|
255
268
|
|
|
256
269
|
# Create and return Worker
|
|
257
270
|
return Worker(
|
openbox/workflow_interceptor.py
CHANGED
|
@@ -20,6 +20,7 @@ from datetime import timedelta
|
|
|
20
20
|
from typing import Any, Optional, Type
|
|
21
21
|
|
|
22
22
|
from temporalio import workflow
|
|
23
|
+
from temporalio.exceptions import ActivityError, ApplicationError
|
|
23
24
|
from temporalio.worker import (
|
|
24
25
|
Interceptor,
|
|
25
26
|
WorkflowInboundInterceptor,
|
|
@@ -28,9 +29,38 @@ from temporalio.worker import (
|
|
|
28
29
|
HandleSignalInput,
|
|
29
30
|
)
|
|
30
31
|
|
|
32
|
+
from .errors import (
|
|
33
|
+
GOVERNANCE_API_ERROR_TYPE,
|
|
34
|
+
GOVERNANCE_BLOCK_ERROR_TYPE,
|
|
35
|
+
GOVERNANCE_HALT_ERROR_TYPE,
|
|
36
|
+
GOVERNANCE_STOP_ERROR_TYPE,
|
|
37
|
+
)
|
|
31
38
|
from .types import Verdict
|
|
32
39
|
|
|
33
40
|
|
|
41
|
+
def _application_error_type(exc: BaseException) -> Optional[str]:
|
|
42
|
+
"""Walk exception chain and return the ApplicationError.type if present.
|
|
43
|
+
|
|
44
|
+
Temporal wraps activity failures as ActivityError(cause=ApplicationError).
|
|
45
|
+
We walk cause/__cause__/__context__ to find the first ApplicationError and
|
|
46
|
+
return its `type` field. Matching on this field is stable across message
|
|
47
|
+
reformatting, locale changes, and nested wrapping.
|
|
48
|
+
"""
|
|
49
|
+
seen: set[int] = set()
|
|
50
|
+
current: Optional[BaseException] = exc
|
|
51
|
+
while current is not None and id(current) not in seen:
|
|
52
|
+
seen.add(id(current))
|
|
53
|
+
if isinstance(current, ApplicationError):
|
|
54
|
+
return getattr(current, "type", None)
|
|
55
|
+
next_exc = (
|
|
56
|
+
getattr(current, "cause", None)
|
|
57
|
+
or getattr(current, "__cause__", None)
|
|
58
|
+
or getattr(current, "__context__", None)
|
|
59
|
+
)
|
|
60
|
+
current = next_exc
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
34
64
|
def _safe_error_type(exc) -> Optional[str]:
|
|
35
65
|
"""Extract error type string from an exception, sanitized for JSON."""
|
|
36
66
|
t = getattr(exc, "type", None)
|
|
@@ -117,8 +147,6 @@ from .errors import GovernanceHaltError # noqa: F401
|
|
|
117
147
|
|
|
118
148
|
|
|
119
149
|
async def _send_governance_event(
|
|
120
|
-
api_url: str,
|
|
121
|
-
api_key: str,
|
|
122
150
|
payload: dict,
|
|
123
151
|
timeout: float,
|
|
124
152
|
on_api_error: str = "fail_open",
|
|
@@ -130,17 +158,18 @@ async def _send_governance_event(
|
|
|
130
158
|
on_api_error: "fail_open" (default) = continue on error
|
|
131
159
|
"fail_closed" = halt workflow if governance API fails
|
|
132
160
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
161
|
+
Credentials (api_url, api_key) are held by the activity instance itself —
|
|
162
|
+
never passed through activity inputs, so they never land in workflow
|
|
163
|
+
history. The on_api_error policy is passed to the activity, which handles
|
|
164
|
+
logging (safe outside sandbox) and raises GovernanceAPIError if
|
|
165
|
+
fail_closed. This interceptor catches that and re-raises as
|
|
166
|
+
GovernanceHaltError.
|
|
136
167
|
"""
|
|
137
168
|
try:
|
|
138
169
|
result = await workflow.execute_activity(
|
|
139
170
|
"send_governance_event",
|
|
140
171
|
args=[
|
|
141
172
|
{
|
|
142
|
-
"api_url": api_url,
|
|
143
|
-
"api_key": api_key,
|
|
144
173
|
"payload": payload,
|
|
145
174
|
"timeout": timeout,
|
|
146
175
|
"on_api_error": on_api_error,
|
|
@@ -150,27 +179,26 @@ async def _send_governance_event(
|
|
|
150
179
|
)
|
|
151
180
|
return result
|
|
152
181
|
except Exception as e:
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
if
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
182
|
+
app_error_type = _application_error_type(e)
|
|
183
|
+
|
|
184
|
+
# GovernanceHalt / legacy GovernanceStop: workflow should terminate
|
|
185
|
+
# (client.terminate() already called by the activity, but re-raise to
|
|
186
|
+
# ensure the workflow code path stops).
|
|
187
|
+
if app_error_type in (
|
|
188
|
+
GOVERNANCE_HALT_ERROR_TYPE,
|
|
189
|
+
GOVERNANCE_STOP_ERROR_TYPE,
|
|
190
|
+
):
|
|
191
|
+
raise GovernanceHaltError(str(e))
|
|
192
|
+
|
|
193
|
+
# GovernanceBlock: the current activity is blocked; the workflow continues.
|
|
194
|
+
if app_error_type == GOVERNANCE_BLOCK_ERROR_TYPE:
|
|
163
195
|
return None
|
|
164
196
|
|
|
165
|
-
#
|
|
166
|
-
if
|
|
167
|
-
raise GovernanceHaltError(
|
|
197
|
+
# Activity raised GovernanceAPIError (fail_closed + API unreachable).
|
|
198
|
+
if app_error_type == GOVERNANCE_API_ERROR_TYPE:
|
|
199
|
+
raise GovernanceHaltError(str(e))
|
|
168
200
|
|
|
169
|
-
#
|
|
170
|
-
if "GovernanceAPIError" in error_type or "GovernanceAPIError" in error_str:
|
|
171
|
-
raise GovernanceHaltError(error_str)
|
|
172
|
-
|
|
173
|
-
# Other errors with fail_open: silently continue
|
|
201
|
+
# Other errors with fail_open: silently continue.
|
|
174
202
|
return None
|
|
175
203
|
|
|
176
204
|
|
|
@@ -179,12 +207,15 @@ class GovernanceInterceptor(Interceptor):
|
|
|
179
207
|
|
|
180
208
|
def __init__(
|
|
181
209
|
self,
|
|
182
|
-
api_url: str,
|
|
183
|
-
api_key: str,
|
|
210
|
+
api_url: str = "",
|
|
211
|
+
api_key: str = "",
|
|
184
212
|
span_processor=None, # Shared with activity interceptor for HTTP spans
|
|
185
213
|
config=None, # Optional GovernanceConfig
|
|
186
214
|
):
|
|
187
|
-
|
|
215
|
+
# api_url / api_key are accepted for backward compatibility but no longer
|
|
216
|
+
# flow to the governance activity — credentials live on GovernanceActivities.
|
|
217
|
+
# Kept on self in case downstream callers introspect.
|
|
218
|
+
self.api_url = api_url.rstrip("/") if api_url else ""
|
|
188
219
|
self.api_key = api_key
|
|
189
220
|
self.span_processor = span_processor
|
|
190
221
|
self.api_timeout = getattr(config, "api_timeout", 30.0) if config else 30.0
|
|
@@ -203,8 +234,6 @@ class GovernanceInterceptor(Interceptor):
|
|
|
203
234
|
self, input: WorkflowInterceptorClassInput
|
|
204
235
|
) -> Optional[Type[WorkflowInboundInterceptor]]:
|
|
205
236
|
# Capture via closure
|
|
206
|
-
api_url = self.api_url
|
|
207
|
-
api_key = self.api_key
|
|
208
237
|
span_processor = self.span_processor
|
|
209
238
|
timeout = self.api_timeout
|
|
210
239
|
on_error = self.on_api_error
|
|
@@ -223,8 +252,6 @@ class GovernanceInterceptor(Interceptor):
|
|
|
223
252
|
# WorkflowStarted event
|
|
224
253
|
if send_start and workflow.patched("openbox-v2-start"):
|
|
225
254
|
await _send_governance_event(
|
|
226
|
-
api_url,
|
|
227
|
-
api_key,
|
|
228
255
|
{
|
|
229
256
|
"source": "workflow-telemetry",
|
|
230
257
|
"event_type": "WorkflowStarted",
|
|
@@ -254,8 +281,6 @@ class GovernanceInterceptor(Interceptor):
|
|
|
254
281
|
)
|
|
255
282
|
|
|
256
283
|
await _send_governance_event(
|
|
257
|
-
api_url,
|
|
258
|
-
api_key,
|
|
259
284
|
{
|
|
260
285
|
"source": "workflow-telemetry",
|
|
261
286
|
"event_type": "WorkflowCompleted",
|
|
@@ -273,20 +298,24 @@ class GovernanceInterceptor(Interceptor):
|
|
|
273
298
|
error = _build_error_dict(e)
|
|
274
299
|
|
|
275
300
|
if workflow.patched("openbox-v2-failed"):
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
301
|
+
# Swallow failures from the failure-reporting activity itself
|
|
302
|
+
# (fail_closed + governance API down would otherwise raise
|
|
303
|
+
# GovernanceHaltError and shadow the real workflow exception).
|
|
304
|
+
try:
|
|
305
|
+
await _send_governance_event(
|
|
306
|
+
{
|
|
307
|
+
"source": "workflow-telemetry",
|
|
308
|
+
"event_type": "WorkflowFailed",
|
|
309
|
+
"workflow_id": info.workflow_id,
|
|
310
|
+
"run_id": info.run_id,
|
|
311
|
+
"workflow_type": info.workflow_type,
|
|
312
|
+
"error": error,
|
|
313
|
+
},
|
|
314
|
+
timeout,
|
|
315
|
+
on_error,
|
|
316
|
+
)
|
|
317
|
+
except Exception:
|
|
318
|
+
pass
|
|
290
319
|
|
|
291
320
|
raise
|
|
292
321
|
|
|
@@ -300,8 +329,6 @@ class GovernanceInterceptor(Interceptor):
|
|
|
300
329
|
# SignalReceived event - check verdict and store if "stop"
|
|
301
330
|
if workflow.patched("openbox-v2-signal"):
|
|
302
331
|
result = await _send_governance_event(
|
|
303
|
-
api_url,
|
|
304
|
-
api_key,
|
|
305
332
|
{
|
|
306
333
|
"source": "workflow-telemetry",
|
|
307
334
|
"event_type": "SignalReceived",
|
{openbox_temporal_sdk_python-1.1.1.dist-info → openbox_temporal_sdk_python-1.1.2.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: openbox-temporal-sdk-python
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.2
|
|
4
4
|
Summary: OpenBox SDK - Governance and observability for Temporal workflows
|
|
5
5
|
Project-URL: Homepage, https://github.com/OpenBox-AI/temporal-sdk-python
|
|
6
6
|
Project-URL: Documentation, https://github.com/OpenBox-AI/temporal-sdk-python#readme
|
|
@@ -100,7 +100,15 @@ await worker.run()
|
|
|
100
100
|
```
|
|
101
101
|
|
|
102
102
|
The plugin automatically configures governance interceptors, OTel instrumentation,
|
|
103
|
-
sandbox passthrough,
|
|
103
|
+
sandbox passthrough, W3C trace propagation through Temporal headers (via
|
|
104
|
+
`temporalio.contrib.opentelemetry.TracingInterceptor`), and the
|
|
105
|
+
`send_governance_event` activity.
|
|
106
|
+
|
|
107
|
+
**Credentials never leave the plugin.** `openbox_api_key` is captured on the
|
|
108
|
+
governance activity instance itself — it does **not** flow through activity
|
|
109
|
+
inputs, so it is never written to workflow history. To opt out of trace
|
|
110
|
+
propagation (e.g., if you already wire `OpenTelemetryPlugin`), pass
|
|
111
|
+
`enable_trace_propagation=False`.
|
|
104
112
|
|
|
105
113
|
### Composing with Other Plugins
|
|
106
114
|
|
|
@@ -148,8 +156,10 @@ The factory automatically:
|
|
|
148
156
|
1. Validates the API key
|
|
149
157
|
2. Creates span processor
|
|
150
158
|
3. Sets up OpenTelemetry instrumentation
|
|
151
|
-
4. Creates governance interceptors
|
|
152
|
-
5.
|
|
159
|
+
4. Creates governance interceptors (incl. W3C trace propagation)
|
|
160
|
+
5. Builds the `GovernanceActivities` instance with credentials captured on
|
|
161
|
+
`self` and registers its `send_governance_event` method — the API key is
|
|
162
|
+
never passed through activity inputs / workflow history
|
|
153
163
|
6. Returns fully configured Worker
|
|
154
164
|
|
|
155
165
|
---
|
|
@@ -195,6 +205,11 @@ worker = create_openbox_worker(
|
|
|
195
205
|
# File I/O instrumentation
|
|
196
206
|
instrument_file_io=False, # disabled by default
|
|
197
207
|
|
|
208
|
+
# Header-based W3C trace propagation (client → workflow → activities).
|
|
209
|
+
# Default True. Set False if you already wire OpenTelemetryPlugin or a
|
|
210
|
+
# custom propagator.
|
|
211
|
+
enable_trace_propagation=True,
|
|
212
|
+
|
|
198
213
|
# Standard Worker options (all supported)
|
|
199
214
|
activity_executor=my_executor,
|
|
200
215
|
max_concurrent_activities=10,
|
|
@@ -471,7 +486,7 @@ from openbox import (
|
|
|
471
486
|
)
|
|
472
487
|
from openbox.otel_setup import setup_opentelemetry_for_governance
|
|
473
488
|
from openbox.activity_interceptor import ActivityGovernanceInterceptor
|
|
474
|
-
from openbox.activities import
|
|
489
|
+
from openbox.activities import build_governance_activities
|
|
475
490
|
from openbox.hook_governance import FAIL_OPEN, FAIL_CLOSED # error policy constants
|
|
476
491
|
|
|
477
492
|
# 1. Initialize SDK
|
|
@@ -511,14 +526,26 @@ activity_interceptor = ActivityGovernanceInterceptor(
|
|
|
511
526
|
config=config,
|
|
512
527
|
)
|
|
513
528
|
|
|
514
|
-
# 6.
|
|
529
|
+
# 6. Build the governance activity instance (credentials captured on `self`
|
|
530
|
+
# so they never flow through workflow history).
|
|
531
|
+
governance_activities = build_governance_activities(
|
|
532
|
+
api_url="http://localhost:8086",
|
|
533
|
+
api_key="obx_test_key_1",
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
# 7. Create worker
|
|
515
537
|
from temporalio.worker import Worker
|
|
538
|
+
from temporalio.contrib.opentelemetry import TracingInterceptor
|
|
516
539
|
worker = Worker(
|
|
517
540
|
client=client,
|
|
518
541
|
task_queue="my-task-queue",
|
|
519
542
|
workflows=[MyWorkflow],
|
|
520
|
-
activities=[my_activity, send_governance_event],
|
|
521
|
-
interceptors=[
|
|
543
|
+
activities=[my_activity, governance_activities.send_governance_event],
|
|
544
|
+
interceptors=[
|
|
545
|
+
workflow_interceptor,
|
|
546
|
+
activity_interceptor,
|
|
547
|
+
TracingInterceptor(), # W3C header propagation for OTel spans
|
|
548
|
+
],
|
|
522
549
|
)
|
|
523
550
|
```
|
|
524
551
|
|
{openbox_temporal_sdk_python-1.1.1.dist-info → openbox_temporal_sdk_python-1.1.2.dist-info}/RECORD
RENAMED
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
openbox/__init__.py,sha256
|
|
2
|
-
openbox/activities.py,sha256=
|
|
1
|
+
openbox/__init__.py,sha256=uyGpMtLwx7em2lapvIvkiTtmGcf5Og-CBWyHiY0jztA,10451
|
|
2
|
+
openbox/activities.py,sha256=mSOOQerWhF1KoRzw3jb24HBNZkAMeWjtvUUUmeUCK4M,10017
|
|
3
3
|
openbox/activity_interceptor.py,sha256=WGpnREw_rxZe9pT_MfnD_snnpdXLA5WXG8SLdgPIueE,26181
|
|
4
4
|
openbox/client.py,sha256=CKXhtZGmMeyIyZQpX1CTVGLj7iiGP_JcoOZ-0F1jEws,7135
|
|
5
5
|
openbox/config.py,sha256=nUpKGV88LOsGcFThwBJM4rr79tecEmXZ0U7trf34hQo,11369
|
|
6
6
|
openbox/context_propagation.py,sha256=hOEjqdKWgAw16ONppVI7B6YXD9SMufsdKP5ODmHCGCI,2105
|
|
7
|
-
openbox/db_governance_hooks.py,sha256=
|
|
8
|
-
openbox/errors.py,sha256=
|
|
7
|
+
openbox/db_governance_hooks.py,sha256=4g3i0bibYyBkSWl0U0dic3oBMUypiZU2xIBTtfcJfRk,31702
|
|
8
|
+
openbox/errors.py,sha256=je6Pp6Dim13cPfOphn4aJsSkHjoN_dzAlblPo_s-_mk,8676
|
|
9
9
|
openbox/file_governance_hooks.py,sha256=i25n2CeENHsaYEwBnmxtZE8APoXo1gZFBMMzFOKPCFo,14221
|
|
10
10
|
openbox/hitl.py,sha256=_sq_3DcvOOdFHi7uyNFG8FiN-q26YgkA9BEs9J8vyW4,4534
|
|
11
|
-
openbox/hook_governance.py,sha256=
|
|
11
|
+
openbox/hook_governance.py,sha256=GjUlVblO7KyMet-mU2FtKeHTTrcUYYU4s4bgC5aCHfQ,15430
|
|
12
12
|
openbox/http_governance_hooks.py,sha256=lKryJk3xwqmeXvtWtzVzXhZ3oNGV7o6C1zD3lbQpoiE,31241
|
|
13
13
|
openbox/otel_setup.py,sha256=T3MK6GqLfVC3kLJC88VSYUZNbbhmskuS11DigxLWgdc,16843
|
|
14
|
-
openbox/plugin.py,sha256=
|
|
14
|
+
openbox/plugin.py,sha256=WljrcJh_zuIxvgFTj_xcFjHoiarR6uXr4E2okMMu3Tw,7710
|
|
15
15
|
openbox/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
16
|
openbox/span_processor.py,sha256=k_tAT0sWudyVYuw3MALLXCqyNDyXTPwtPl4KVG4CscI,11913
|
|
17
17
|
openbox/tracing.py,sha256=mPaf2Q3Vrhv-GIVmT56iTDF25IECYrp3GbdtOxPaxJY,10148
|
|
18
18
|
openbox/types.py,sha256=2XD9aGdS4uNRUO2NzxluR5wOReIkmxCwfXtXrVQOhT4,6729
|
|
19
19
|
openbox/verdict_handler.py,sha256=rUeiq1Qjrkp4yXfOzoH9Au0YWSKIyEsjdF7aUvI4PbY,4017
|
|
20
|
-
openbox/worker.py,sha256=
|
|
21
|
-
openbox/workflow_interceptor.py,sha256=
|
|
22
|
-
openbox_temporal_sdk_python-1.1.
|
|
23
|
-
openbox_temporal_sdk_python-1.1.
|
|
24
|
-
openbox_temporal_sdk_python-1.1.
|
|
25
|
-
openbox_temporal_sdk_python-1.1.
|
|
20
|
+
openbox/worker.py,sha256=dPWNL-V2SLPzN-LM6AFpda3fclR83KXYVeMzDdN3EUw,11511
|
|
21
|
+
openbox/workflow_interceptor.py,sha256=cDeyAQhHREgll7l8ihJ2S3OszVo08DZyiNpeaJSpSKE,13985
|
|
22
|
+
openbox_temporal_sdk_python-1.1.2.dist-info/METADATA,sha256=hJY6QCHzukThYG-9kYQ9BbealZJzNXIzu1ocGJ1nfjE,22132
|
|
23
|
+
openbox_temporal_sdk_python-1.1.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
24
|
+
openbox_temporal_sdk_python-1.1.2.dist-info/licenses/LICENSE,sha256=J7UVgclAywF4uoxRJXhkMqYrPu0cjB_8ckiA_nMNpfI,1069
|
|
25
|
+
openbox_temporal_sdk_python-1.1.2.dist-info/RECORD,,
|
{openbox_temporal_sdk_python-1.1.1.dist-info → openbox_temporal_sdk_python-1.1.2.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|