openbox-temporal-sdk-python 1.1.0__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 +13 -0
- openbox/activities.py +144 -92
- openbox/activity_interceptor.py +425 -375
- openbox/client.py +11 -4
- openbox/config.py +13 -7
- openbox/context_propagation.py +4 -1
- openbox/db_governance_hooks.py +247 -345
- openbox/errors.py +33 -4
- openbox/file_governance_hooks.py +67 -25
- openbox/hitl.py +5 -0
- openbox/hook_governance.py +83 -22
- openbox/http_governance_hooks.py +193 -83
- openbox/otel_setup.py +104 -117
- openbox/plugin.py +210 -0
- openbox/span_processor.py +40 -12
- openbox/tracing.py +83 -106
- openbox/types.py +17 -5
- openbox/verdict_handler.py +17 -7
- openbox/worker.py +38 -16
- openbox/workflow_interceptor.py +201 -111
- {openbox_temporal_sdk_python-1.1.0.dist-info → openbox_temporal_sdk_python-1.1.2.dist-info}/METADATA +91 -17
- openbox_temporal_sdk_python-1.1.2.dist-info/RECORD +25 -0
- openbox_temporal_sdk_python-1.1.0.dist-info/RECORD +0 -24
- {openbox_temporal_sdk_python-1.1.0.dist-info → openbox_temporal_sdk_python-1.1.2.dist-info}/WHEEL +0 -0
- {openbox_temporal_sdk_python-1.1.0.dist-info → openbox_temporal_sdk_python-1.1.2.dist-info}/licenses/LICENSE +0 -0
openbox/__init__.py
CHANGED
|
@@ -67,6 +67,17 @@ from .span_processor import WorkflowSpanProcessor
|
|
|
67
67
|
|
|
68
68
|
from .workflow_interceptor import GovernanceInterceptor
|
|
69
69
|
|
|
70
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
71
|
+
# Plugin (requires temporalio >= 1.23.0)
|
|
72
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
from temporalio.plugin import SimplePlugin # noqa: F401 — probe only
|
|
76
|
+
|
|
77
|
+
from .plugin import OpenBoxPlugin
|
|
78
|
+
except ImportError:
|
|
79
|
+
pass # temporalio < 1.23.0, plugin not available
|
|
80
|
+
|
|
70
81
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
71
82
|
# Verdict Handler
|
|
72
83
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -128,6 +139,8 @@ from .client import GovernanceClient
|
|
|
128
139
|
__all__ = [
|
|
129
140
|
# Simple Worker Factory (recommended)
|
|
130
141
|
"create_openbox_worker",
|
|
142
|
+
# Plugin (recommended for temporalio >= 1.23.0)
|
|
143
|
+
"OpenBoxPlugin",
|
|
131
144
|
# Configuration
|
|
132
145
|
"initialize",
|
|
133
146
|
"get_global_config",
|
openbox/activities.py
CHANGED
|
@@ -70,7 +70,9 @@ async def _terminate_workflow_for_halt(workflow_id: str, reason: str) -> None:
|
|
|
70
70
|
except Exception as e:
|
|
71
71
|
logger.warning(f"HALT: failed to terminate workflow {workflow_id}: {e}")
|
|
72
72
|
else:
|
|
73
|
-
logger.warning(
|
|
73
|
+
logger.warning(
|
|
74
|
+
f"HALT: _temporal_client is None, cannot terminate workflow {workflow_id}"
|
|
75
|
+
)
|
|
74
76
|
|
|
75
77
|
# Always raise to stop the current activity execution.
|
|
76
78
|
# Even after successful terminate(), the activity code keeps running
|
|
@@ -82,7 +84,9 @@ async def _terminate_workflow_for_halt(workflow_id: str, reason: str) -> None:
|
|
|
82
84
|
)
|
|
83
85
|
|
|
84
86
|
|
|
85
|
-
def raise_governance_block(
|
|
87
|
+
def raise_governance_block(
|
|
88
|
+
reason: str, policy_id: str = None, risk_score: float = None
|
|
89
|
+
):
|
|
86
90
|
"""Raise non-retryable ApplicationError for BLOCK verdict — blocks activity only."""
|
|
87
91
|
details = {"policy_id": policy_id, "risk_score": risk_score}
|
|
88
92
|
raise ApplicationError(
|
|
@@ -93,106 +97,154 @@ def raise_governance_block(reason: str, policy_id: str = None, risk_score: float
|
|
|
93
97
|
)
|
|
94
98
|
|
|
95
99
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
def _build_verdict_result(verdict: Verdict, reason, policy_id, risk_score) -> dict:
|
|
101
|
+
"""Build a success result dict from a governance verdict."""
|
|
102
|
+
return {
|
|
103
|
+
"success": True,
|
|
104
|
+
"verdict": verdict.value,
|
|
105
|
+
"action": verdict.value, # backward compat
|
|
106
|
+
"reason": reason,
|
|
107
|
+
"policy_id": policy_id,
|
|
108
|
+
"risk_score": risk_score,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def _handle_stop_verdict(
|
|
113
|
+
verdict: Verdict, reason, policy_id, risk_score, event_type, event_payload
|
|
114
|
+
) -> Optional[dict]:
|
|
115
|
+
"""Handle BLOCK/HALT verdicts. Returns result for signals, raises for others."""
|
|
116
|
+
logger.info(
|
|
117
|
+
f"Governance {verdict.value} {event_type}: {reason} (policy: {policy_id})"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# SignalReceived: return result instead of raising
|
|
121
|
+
if event_type == "SignalReceived":
|
|
122
|
+
return _build_verdict_result(verdict, reason, policy_id, risk_score)
|
|
123
|
+
|
|
124
|
+
# HALT: terminate workflow + raise
|
|
125
|
+
if verdict == Verdict.HALT:
|
|
126
|
+
workflow_id = event_payload.get("workflow_id", "")
|
|
127
|
+
await _terminate_workflow_for_halt(workflow_id, reason or "No reason provided")
|
|
128
|
+
|
|
129
|
+
# BLOCK: fail this activity only
|
|
130
|
+
raise_governance_block(
|
|
131
|
+
reason=reason or "No reason provided",
|
|
132
|
+
policy_id=policy_id,
|
|
133
|
+
risk_score=risk_score,
|
|
134
|
+
)
|
|
135
|
+
|
|
100
136
|
|
|
101
|
-
|
|
102
|
-
|
|
137
|
+
def _handle_api_error(event_type: str, error_msg: str, on_api_error: str) -> dict:
|
|
138
|
+
"""Handle non-200 responses or exceptions based on error policy."""
|
|
139
|
+
logger.warning(f"Governance API error for {event_type}: {error_msg}")
|
|
140
|
+
if on_api_error == "fail_closed":
|
|
141
|
+
raise GovernanceAPIError(error_msg)
|
|
142
|
+
return {"success": False, "error": error_msg}
|
|
103
143
|
|
|
104
|
-
Args (in input dict):
|
|
105
|
-
api_url: OpenBox Core API URL
|
|
106
|
-
api_key: API key for authentication
|
|
107
|
-
payload: Event payload (without timestamp)
|
|
108
|
-
timeout: Request timeout in seconds
|
|
109
|
-
on_api_error: "fail_open" (default) or "fail_closed"
|
|
110
144
|
|
|
111
|
-
|
|
112
|
-
|
|
145
|
+
class GovernanceActivities:
|
|
146
|
+
"""Container for Temporal activities that need OpenBox credentials.
|
|
113
147
|
|
|
114
|
-
|
|
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.
|
|
115
152
|
"""
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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),
|
|
182
|
+
)
|
|
183
|
+
|
|
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
|
+
|
|
137
191
|
data = response.json()
|
|
138
|
-
|
|
139
|
-
|
|
192
|
+
verdict = Verdict.from_string(
|
|
193
|
+
data.get("verdict") or data.get("action", "continue")
|
|
194
|
+
)
|
|
140
195
|
reason = data.get("reason")
|
|
141
196
|
policy_id = data.get("policy_id")
|
|
142
197
|
risk_score = data.get("risk_score", 0.0)
|
|
143
198
|
|
|
144
|
-
# Check if governance wants to stop (BLOCK or HALT)
|
|
145
199
|
if verdict.should_stop():
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
)
|
|
174
|
-
|
|
175
|
-
return {
|
|
176
|
-
"success": True,
|
|
177
|
-
"verdict": verdict.value,
|
|
178
|
-
"action": verdict.value, # backward compat
|
|
179
|
-
"reason": reason,
|
|
180
|
-
"policy_id": policy_id,
|
|
181
|
-
"risk_score": risk_score,
|
|
182
|
-
}
|
|
183
|
-
else:
|
|
184
|
-
error_msg = f"HTTP {response.status_code}: {response.text}"
|
|
185
|
-
logger.warning(f"Governance API error for {event_type}: {error_msg}")
|
|
186
|
-
if on_api_error == "fail_closed":
|
|
187
|
-
raise GovernanceAPIError(error_msg)
|
|
188
|
-
return {"success": False, "error": error_msg}
|
|
189
|
-
|
|
190
|
-
except (GovernanceAPIError, ApplicationError):
|
|
191
|
-
raise # Re-raise to workflow (ApplicationError is non-retryable)
|
|
192
|
-
except Exception as e:
|
|
193
|
-
logger.warning(f"Failed to send {event_type} event: {e}")
|
|
194
|
-
if on_api_error == "fail_closed":
|
|
195
|
-
raise GovernanceAPIError(str(e))
|
|
196
|
-
return {"success": False, "error": str(e)}
|
|
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)
|
|
197
227
|
|
|
198
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)
|