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 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(f"HALT: _temporal_client is None, cannot terminate workflow {workflow_id}")
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(reason: str, policy_id: str = None, risk_score: float = None):
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
- @activity.defn(name="send_governance_event")
97
- async def send_governance_event(input: Dict[str, Any]) -> Optional[Dict[str, Any]]:
98
- """
99
- Activity that sends governance events to OpenBox Core.
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
- This activity is called from WorkflowInboundInterceptor via workflow.execute_activity()
102
- to maintain workflow determinism. HTTP calls cannot be made directly in workflow context.
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
- When on_api_error == "fail_closed" and API fails, raises GovernanceAPIError.
112
- This is caught by the workflow interceptor and re-raised as GovernanceHaltError.
145
+ class GovernanceActivities:
146
+ """Container for Temporal activities that need OpenBox credentials.
113
147
 
114
- Logging is safe here because activities run outside the workflow sandbox.
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
- # Extract input fields
117
- api_url = input.get("api_url", "")
118
- api_key = input.get("api_key", "")
119
- event_payload = input.get("payload", {})
120
- timeout = input.get("timeout", 30.0)
121
- on_api_error = input.get("on_api_error", "fail_open")
122
-
123
- # Add timestamp here in activity context (non-deterministic code allowed)
124
- # Use RFC3339 format: 2024-01-15T10:30:45.123Z
125
- payload = {**event_payload, "timestamp": _rfc3339_now()}
126
- event_type = event_payload.get("event_type", "unknown")
127
-
128
- try:
129
- async with httpx.AsyncClient(timeout=timeout) as client:
130
- response = await client.post(
131
- f"{api_url}/api/v1/governance/evaluate",
132
- json=payload,
133
- headers=build_auth_headers(api_key),
134
- )
135
-
136
- if response.status_code == 200:
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
- # Parse verdict (v1.1) or action (v1.0)
139
- verdict = Verdict.from_string(data.get("verdict") or data.get("action", "continue"))
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
- logger.info(f"Governance {verdict.value} {event_type}: {reason} (policy: {policy_id})")
147
-
148
- # For SignalReceived events, return result instead of raising/terminating
149
- # The workflow interceptor will store verdict for activity interceptor to check
150
- if event_type == "SignalReceived":
151
- return {
152
- "success": True,
153
- "verdict": verdict.value,
154
- "action": verdict.value, # backward compat
155
- "reason": reason,
156
- "policy_id": policy_id,
157
- "risk_score": risk_score,
158
- }
159
-
160
- # HALT → terminate workflow + raise to stop activity
161
- if verdict == Verdict.HALT:
162
- workflow_id = event_payload.get("workflow_id", "")
163
- # Always raises ApplicationError(type="GovernanceHalt")
164
- await _terminate_workflow_for_halt(
165
- workflow_id, reason or "No reason provided"
166
- )
167
- else:
168
- # BLOCK → fail this activity only, workflow can continue
169
- raise_governance_block(
170
- reason=reason or "No reason provided",
171
- policy_id=policy_id,
172
- risk_score=risk_score,
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)