openbox-temporal-sdk-python 1.0.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.
openbox/types.py ADDED
@@ -0,0 +1,166 @@
1
+ # openbox/types.py
2
+ """Data types for workflow-boundary governance."""
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import List, Dict, Any, Optional, Union
6
+ from enum import Enum
7
+
8
+
9
+ class WorkflowEventType(str, Enum):
10
+ """Workflow lifecycle events for governance."""
11
+
12
+ WORKFLOW_STARTED = "WorkflowStarted"
13
+ WORKFLOW_COMPLETED = "WorkflowCompleted"
14
+ WORKFLOW_FAILED = "WorkflowFailed"
15
+ SIGNAL_RECEIVED = "SignalReceived"
16
+ ACTIVITY_STARTED = "ActivityStarted"
17
+ ACTIVITY_COMPLETED = "ActivityCompleted"
18
+
19
+
20
+ class Verdict(str, Enum):
21
+ """5-tier graduated response. Priority: HALT > BLOCK > REQUIRE_APPROVAL > CONSTRAIN > ALLOW"""
22
+
23
+ ALLOW = "allow"
24
+ CONSTRAIN = "constrain"
25
+ REQUIRE_APPROVAL = "require_approval"
26
+ BLOCK = "block"
27
+ HALT = "halt"
28
+
29
+ @classmethod
30
+ def from_string(cls, value: str) -> "Verdict":
31
+ """Parse with v1.0 compat: 'continue'→ALLOW, 'stop'→HALT, 'require-approval'→REQUIRE_APPROVAL"""
32
+ if value is None:
33
+ return cls.ALLOW
34
+ normalized = value.lower().replace("-", "_")
35
+ if normalized == "continue":
36
+ return cls.ALLOW
37
+ if normalized == "stop":
38
+ return cls.HALT
39
+ if normalized in ("require_approval", "request_approval"):
40
+ return cls.REQUIRE_APPROVAL
41
+ try:
42
+ return cls(normalized)
43
+ except ValueError:
44
+ return cls.ALLOW
45
+
46
+ @property
47
+ def priority(self) -> int:
48
+ """Priority for aggregation: HALT=5, BLOCK=4, REQUIRE_APPROVAL=3, CONSTRAIN=2, ALLOW=1"""
49
+ return {Verdict.ALLOW: 1, Verdict.CONSTRAIN: 2, Verdict.REQUIRE_APPROVAL: 3, Verdict.BLOCK: 4, Verdict.HALT: 5}[self]
50
+
51
+ @classmethod
52
+ def highest_priority(cls, verdicts: List["Verdict"]) -> "Verdict":
53
+ """Get highest priority verdict from list. Returns ALLOW if empty."""
54
+ return max(verdicts, key=lambda v: v.priority) if verdicts else cls.ALLOW
55
+
56
+ def should_stop(self) -> bool:
57
+ """True if BLOCK or HALT."""
58
+ return self in (Verdict.BLOCK, Verdict.HALT)
59
+
60
+ def requires_approval(self) -> bool:
61
+ """True if REQUIRE_APPROVAL."""
62
+ return self == Verdict.REQUIRE_APPROVAL
63
+
64
+
65
+ @dataclass
66
+ class WorkflowSpanBuffer:
67
+ """Buffer for spans generated during workflow execution."""
68
+
69
+ workflow_id: str
70
+ run_id: str
71
+ workflow_type: str
72
+ task_queue: str
73
+ parent_workflow_id: Optional[str] = None
74
+ spans: List[dict] = field(default_factory=list)
75
+ status: Optional[str] = None # "completed", "failed", "cancelled", "terminated"
76
+ error: Optional[Dict[str, Any]] = None
77
+
78
+ # Governance verdict (set by workflow interceptor, checked by activity interceptor)
79
+ verdict: Optional[Verdict] = None
80
+ verdict_reason: Optional[str] = None
81
+
82
+ # Pending approval: True when activity is waiting for human approval
83
+ pending_approval: bool = False
84
+
85
+
86
+ @dataclass
87
+ class GuardrailsCheckResult:
88
+ """
89
+ Guardrails check result from governance API.
90
+
91
+ Contains redacted input/output that should replace the original activity data,
92
+ plus validation results that can block execution.
93
+ """
94
+
95
+ redacted_input: Any # Redacted activity_input or activity_output (JSON-decoded)
96
+ input_type: str # "activity_input" or "activity_output"
97
+ raw_logs: Optional[Dict[str, Any]] = None # Raw logs from guardrails evaluation
98
+ validation_passed: bool = True # If False, workflow should be stopped
99
+ reasons: List[Dict[str, str]] = field(default_factory=list) # [{type, field, reason}, ...]
100
+
101
+ def get_reason_strings(self) -> List[str]:
102
+ """Extract just the 'reason' field from each reason object."""
103
+ return [r.get("reason", "") for r in self.reasons if r.get("reason")]
104
+
105
+
106
+ @dataclass
107
+ class GovernanceVerdictResponse:
108
+ """Response from governance API evaluation."""
109
+
110
+ verdict: Verdict # v1.1: 5-tier verdict
111
+ reason: Optional[str] = None
112
+ # v1.0 fields (kept for compatibility)
113
+ policy_id: Optional[str] = None
114
+ risk_score: float = 0.0
115
+ metadata: Optional[Dict[str, Any]] = None
116
+ governance_event_id: Optional[str] = None
117
+ guardrails_result: Optional[GuardrailsCheckResult] = None
118
+ # v1.1 fields
119
+ trust_tier: Optional[str] = None
120
+ behavioral_violations: Optional[List[str]] = None
121
+ alignment_score: Optional[float] = None
122
+ approval_id: Optional[str] = None
123
+ constraints: Optional[List[Dict[str, Any]]] = None
124
+
125
+ @property
126
+ def action(self) -> str:
127
+ """Backward compat: return action string from verdict."""
128
+ if self.verdict == Verdict.ALLOW:
129
+ return "continue"
130
+ if self.verdict == Verdict.HALT:
131
+ return "stop"
132
+ if self.verdict == Verdict.REQUIRE_APPROVAL:
133
+ return "require-approval"
134
+ return self.verdict.value
135
+
136
+ @classmethod
137
+ def from_dict(cls, data: Dict[str, Any]) -> "GovernanceVerdictResponse":
138
+ """Parse governance response from JSON dict (v1.0 and v1.1 compatible)."""
139
+ guardrails_result = None
140
+ if data.get("guardrails_result"):
141
+ gr = data["guardrails_result"]
142
+ guardrails_result = GuardrailsCheckResult(
143
+ redacted_input=gr.get("redacted_input"),
144
+ input_type=gr.get("input_type", ""),
145
+ raw_logs=gr.get("raw_logs"),
146
+ validation_passed=gr.get("validation_passed", True),
147
+ reasons=gr.get("reasons") or [],
148
+ )
149
+
150
+ # Parse verdict (v1.1) or action (v1.0)
151
+ verdict = Verdict.from_string(data.get("verdict") or data.get("action", "continue"))
152
+
153
+ return cls(
154
+ verdict=verdict,
155
+ reason=data.get("reason"),
156
+ policy_id=data.get("policy_id"),
157
+ risk_score=data.get("risk_score", 0.0),
158
+ metadata=data.get("metadata"),
159
+ governance_event_id=data.get("governance_event_id"),
160
+ guardrails_result=guardrails_result,
161
+ trust_tier=data.get("trust_tier"),
162
+ behavioral_violations=data.get("behavioral_violations"),
163
+ alignment_score=data.get("alignment_score"),
164
+ approval_id=data.get("approval_id"),
165
+ constraints=data.get("constraints"),
166
+ )
openbox/worker.py ADDED
@@ -0,0 +1,257 @@
1
+ # openbox/worker.py
2
+ """
3
+ OpenBox-enabled Temporal Worker factory.
4
+
5
+ Provides a simple function to create a Temporal Worker with all OpenBox
6
+ governance components pre-configured.
7
+
8
+ Usage:
9
+ from openbox import create_openbox_worker
10
+
11
+ worker = await create_openbox_worker(
12
+ client=client,
13
+ task_queue="my-queue",
14
+ workflows=[MyWorkflow],
15
+ activities=[my_activity],
16
+ )
17
+
18
+ await worker.run()
19
+ """
20
+
21
+ from datetime import timedelta
22
+ from typing import (
23
+ Any,
24
+ Awaitable,
25
+ Callable,
26
+ Optional,
27
+ Sequence,
28
+ Type,
29
+ )
30
+ from concurrent.futures import Executor, ThreadPoolExecutor
31
+
32
+ from temporalio.client import Client
33
+ from temporalio.worker import Worker, Interceptor
34
+
35
+ from .config import initialize as validate_api_key, GovernanceConfig
36
+ from .span_processor import WorkflowSpanProcessor
37
+
38
+
39
+ def create_openbox_worker(
40
+ client: Client,
41
+ task_queue: str,
42
+ *,
43
+ workflows: Sequence[Type] = (),
44
+ activities: Sequence[Callable] = (),
45
+ # OpenBox config (required for governance)
46
+ openbox_url: Optional[str] = None,
47
+ openbox_api_key: Optional[str] = None,
48
+ governance_timeout: float = 30.0,
49
+ governance_policy: str = "fail_open",
50
+ send_start_event: bool = True,
51
+ send_activity_start_event: bool = True,
52
+ skip_workflow_types: Optional[set] = None,
53
+ skip_activity_types: Optional[set] = None,
54
+ skip_signals: Optional[set] = None,
55
+ # HITL configuration
56
+ hitl_enabled: bool = True,
57
+ # Database instrumentation
58
+ instrument_databases: bool = True,
59
+ db_libraries: Optional[set] = None,
60
+ # File I/O instrumentation
61
+ instrument_file_io: bool = False,
62
+ # Standard Worker options
63
+ activity_executor: Optional[Executor] = None,
64
+ workflow_task_executor: Optional[ThreadPoolExecutor] = None,
65
+ interceptors: Sequence[Interceptor] = (),
66
+ build_id: Optional[str] = None,
67
+ identity: Optional[str] = None,
68
+ max_cached_workflows: int = 1000,
69
+ max_concurrent_workflow_tasks: Optional[int] = None,
70
+ max_concurrent_activities: Optional[int] = None,
71
+ max_concurrent_local_activities: Optional[int] = None,
72
+ max_concurrent_workflow_task_polls: int = 5,
73
+ nonsticky_to_sticky_poll_ratio: float = 0.2,
74
+ max_concurrent_activity_task_polls: int = 5,
75
+ no_remote_activities: bool = False,
76
+ sticky_queue_schedule_to_start_timeout: timedelta = timedelta(seconds=10),
77
+ max_heartbeat_throttle_interval: timedelta = timedelta(seconds=60),
78
+ default_heartbeat_throttle_interval: timedelta = timedelta(seconds=30),
79
+ max_activities_per_second: Optional[float] = None,
80
+ max_task_queue_activities_per_second: Optional[float] = None,
81
+ graceful_shutdown_timeout: timedelta = timedelta(),
82
+ shared_state_manager: Any = None,
83
+ debug_mode: bool = False,
84
+ disable_eager_activity_execution: bool = False,
85
+ on_fatal_error: Optional[Callable[[BaseException], Awaitable[None]]] = None,
86
+ use_worker_versioning: bool = False,
87
+ disable_safe_workflow_eviction: bool = False,
88
+ ) -> Worker:
89
+ """
90
+ Create a Temporal Worker with OpenBox governance enabled.
91
+
92
+ This function:
93
+ 1. Validates the OpenBox API key
94
+ 2. Sets up OpenTelemetry HTTP instrumentation
95
+ 3. Creates governance interceptors
96
+ 4. Returns a fully configured Worker
97
+
98
+ Args:
99
+ client: Temporal client
100
+ task_queue: Task queue name
101
+ workflows: List of workflow classes
102
+ activities: List of activity functions (OpenBox activities added automatically)
103
+
104
+ # OpenBox config
105
+ openbox_url: OpenBox Core API URL (required for governance)
106
+ openbox_api_key: OpenBox API key (required for governance)
107
+ governance_timeout: Timeout for governance API calls (default: 30.0s)
108
+ governance_policy: "fail_open" or "fail_closed" (default: "fail_open")
109
+ send_start_event: Send WorkflowStarted events (default: True)
110
+ send_activity_start_event: Send ActivityStarted events (default: True)
111
+ skip_workflow_types: Workflow types to skip governance
112
+ skip_activity_types: Activity types to skip governance
113
+ skip_signals: Signal names to skip governance
114
+
115
+ # Database instrumentation
116
+ instrument_databases: Instrument database libraries (default: True)
117
+ db_libraries: Set of database libraries to instrument (None = all available).
118
+ Valid values: "psycopg2", "asyncpg", "mysql", "pymysql",
119
+ "pymongo", "redis", "sqlalchemy"
120
+
121
+ # File I/O instrumentation
122
+ instrument_file_io: Instrument file I/O operations (default: False)
123
+
124
+ # Standard Worker options (passed through to Worker)
125
+ activity_executor: Executor for activities
126
+ interceptors: Additional interceptors (OpenBox interceptors added automatically)
127
+ ... (all other standard Worker options)
128
+
129
+ Returns:
130
+ Configured Worker instance
131
+
132
+ Example:
133
+ ```python
134
+ from openbox import create_openbox_worker
135
+
136
+ client = await Client.connect("localhost:7233")
137
+
138
+ worker = create_openbox_worker(
139
+ client=client,
140
+ task_queue="my-queue",
141
+ workflows=[MyWorkflow],
142
+ activities=[my_activity, another_activity],
143
+ openbox_url="http://localhost:8086",
144
+ openbox_api_key="obx_test_key_1",
145
+ governance_policy="fail_closed",
146
+ )
147
+
148
+ await worker.run()
149
+ ```
150
+ """
151
+ # Build interceptors and activities lists
152
+ all_interceptors = list(interceptors)
153
+ all_activities = list(activities)
154
+
155
+ # Initialize OpenBox if configured
156
+ if openbox_url and openbox_api_key:
157
+ print(f"Initializing OpenBox SDK with URL: {openbox_url}")
158
+
159
+ # 1. Validate API key
160
+ validate_api_key(
161
+ api_url=openbox_url,
162
+ api_key=openbox_api_key,
163
+ governance_timeout=governance_timeout,
164
+ )
165
+
166
+ # 2. Create span processor
167
+ span_processor = WorkflowSpanProcessor(ignored_url_prefixes=[openbox_url])
168
+
169
+ # 3. Setup OTel HTTP, database, and file I/O instrumentation
170
+ from .otel_setup import setup_opentelemetry_for_governance
171
+ setup_opentelemetry_for_governance(
172
+ span_processor,
173
+ ignored_urls=[openbox_url],
174
+ instrument_databases=instrument_databases,
175
+ db_libraries=db_libraries,
176
+ instrument_file_io=instrument_file_io,
177
+ )
178
+
179
+ # 4. Create governance config
180
+ config = GovernanceConfig(
181
+ on_api_error=governance_policy,
182
+ api_timeout=governance_timeout,
183
+ send_start_event=send_start_event,
184
+ send_activity_start_event=send_activity_start_event,
185
+ skip_workflow_types=skip_workflow_types or set(),
186
+ skip_activity_types=skip_activity_types or {"send_governance_event"},
187
+ skip_signals=skip_signals or set(),
188
+ hitl_enabled=hitl_enabled,
189
+ )
190
+
191
+ # 5. Create interceptors
192
+ from .workflow_interceptor import GovernanceInterceptor
193
+ from .activity_interceptor import ActivityGovernanceInterceptor
194
+
195
+ workflow_interceptor = GovernanceInterceptor(
196
+ api_url=openbox_url,
197
+ api_key=openbox_api_key,
198
+ span_processor=span_processor,
199
+ config=config,
200
+ )
201
+
202
+ activity_interceptor = ActivityGovernanceInterceptor(
203
+ api_url=openbox_url,
204
+ api_key=openbox_api_key,
205
+ span_processor=span_processor,
206
+ config=config,
207
+ )
208
+
209
+ # 6. Get governance activities
210
+ from .activities import send_governance_event
211
+
212
+ # Add OpenBox components
213
+ all_interceptors = [workflow_interceptor, activity_interceptor, *interceptors]
214
+ all_activities = [*activities, send_governance_event]
215
+
216
+ print("OpenBox SDK initialized successfully")
217
+ print(f" - Governance policy: {governance_policy}")
218
+ print(f" - Governance timeout: {governance_timeout}s")
219
+ print(" - Events: WorkflowStarted, WorkflowCompleted, WorkflowFailed, SignalReceived, ActivityStarted, ActivityCompleted")
220
+ print(f" - Database instrumentation: {'enabled' if instrument_databases else 'disabled'}")
221
+ print(f" - File I/O instrumentation: {'enabled' if instrument_file_io else 'disabled'}")
222
+ print(f" - Approval polling: {'enabled' if hitl_enabled else 'disabled'}")
223
+ else:
224
+ print("OpenBox SDK not configured (openbox_url and openbox_api_key not provided)")
225
+
226
+ # Create and return Worker
227
+ return Worker(
228
+ client,
229
+ task_queue=task_queue,
230
+ workflows=workflows,
231
+ activities=all_activities,
232
+ activity_executor=activity_executor,
233
+ workflow_task_executor=workflow_task_executor,
234
+ interceptors=all_interceptors,
235
+ build_id=build_id,
236
+ identity=identity,
237
+ max_cached_workflows=max_cached_workflows,
238
+ max_concurrent_workflow_tasks=max_concurrent_workflow_tasks,
239
+ max_concurrent_activities=max_concurrent_activities,
240
+ max_concurrent_local_activities=max_concurrent_local_activities,
241
+ max_concurrent_workflow_task_polls=max_concurrent_workflow_task_polls,
242
+ nonsticky_to_sticky_poll_ratio=nonsticky_to_sticky_poll_ratio,
243
+ max_concurrent_activity_task_polls=max_concurrent_activity_task_polls,
244
+ no_remote_activities=no_remote_activities,
245
+ sticky_queue_schedule_to_start_timeout=sticky_queue_schedule_to_start_timeout,
246
+ max_heartbeat_throttle_interval=max_heartbeat_throttle_interval,
247
+ default_heartbeat_throttle_interval=default_heartbeat_throttle_interval,
248
+ max_activities_per_second=max_activities_per_second,
249
+ max_task_queue_activities_per_second=max_task_queue_activities_per_second,
250
+ graceful_shutdown_timeout=graceful_shutdown_timeout,
251
+ shared_state_manager=shared_state_manager,
252
+ debug_mode=debug_mode,
253
+ disable_eager_activity_execution=disable_eager_activity_execution,
254
+ on_fatal_error=on_fatal_error,
255
+ use_worker_versioning=use_worker_versioning,
256
+ disable_safe_workflow_eviction=disable_safe_workflow_eviction,
257
+ )
@@ -0,0 +1,264 @@
1
+ # openbox/workflow_interceptor.py
2
+ """
3
+ Temporal workflow interceptor for workflow-boundary governance.
4
+
5
+ Sends workflow lifecycle events via activity (for determinism).
6
+
7
+ Events:
8
+ - WorkflowStarted
9
+ - WorkflowCompleted
10
+ - WorkflowFailed
11
+ - SignalReceived
12
+
13
+ IMPORTANT: No logging inside workflow code! Python's logging module uses
14
+ linecache -> os.stat which triggers Temporal sandbox restrictions.
15
+ """
16
+
17
+ import json
18
+ from dataclasses import asdict, is_dataclass
19
+ from datetime import timedelta
20
+ from typing import Any, Optional, Type
21
+
22
+ from temporalio import workflow
23
+ from temporalio.worker import (
24
+ Interceptor,
25
+ WorkflowInboundInterceptor,
26
+ WorkflowInterceptorClassInput,
27
+ ExecuteWorkflowInput,
28
+ HandleSignalInput,
29
+ )
30
+
31
+ from .types import Verdict
32
+
33
+
34
+ def _serialize_value(value: Any) -> Any:
35
+ """Convert a value to JSON-serializable format for workflow result."""
36
+ if value is None:
37
+ return None
38
+ if isinstance(value, (str, int, float, bool)):
39
+ return value
40
+ if isinstance(value, bytes):
41
+ try:
42
+ return value.decode('utf-8')
43
+ except Exception:
44
+ import base64
45
+ return base64.b64encode(value).decode('ascii')
46
+ if is_dataclass(value) and not isinstance(value, type):
47
+ return asdict(value)
48
+ if isinstance(value, (list, tuple)):
49
+ return [_serialize_value(v) for v in value]
50
+ if isinstance(value, dict):
51
+ return {k: _serialize_value(v) for k, v in value.items()}
52
+ try:
53
+ return json.loads(json.dumps(value, default=str))
54
+ except Exception:
55
+ return str(value)
56
+
57
+
58
+ class GovernanceHaltError(Exception):
59
+ """Raised when governance halts workflow execution."""
60
+ def __init__(self, message: str):
61
+ super().__init__(message)
62
+
63
+
64
+ async def _send_governance_event(
65
+ api_url: str,
66
+ api_key: str,
67
+ payload: dict,
68
+ timeout: float,
69
+ on_api_error: str = "fail_open",
70
+ ) -> Optional[dict]:
71
+ """
72
+ Send governance event via activity.
73
+
74
+ Args:
75
+ on_api_error: "fail_open" (default) = continue on error
76
+ "fail_closed" = halt workflow if governance API fails
77
+
78
+ The on_api_error policy is passed to the activity, which handles logging
79
+ (safe outside sandbox) and raises GovernanceAPIError if fail_closed.
80
+ This interceptor catches that and re-raises as GovernanceHaltError.
81
+ """
82
+ try:
83
+ result = await workflow.execute_activity(
84
+ "send_governance_event",
85
+ args=[{
86
+ "api_url": api_url,
87
+ "api_key": api_key,
88
+ "payload": payload,
89
+ "timeout": timeout,
90
+ "on_api_error": on_api_error,
91
+ }],
92
+ start_to_close_timeout=timedelta(seconds=timeout + 5),
93
+ )
94
+ return result
95
+ except Exception as e:
96
+ error_str = str(e)
97
+ error_type = type(e).__name__
98
+
99
+ # ApplicationError with type="GovernanceStop" (non-retryable, terminates workflow)
100
+ # This is raised when governance API returns action='stop'
101
+ if "ApplicationError" in error_type or "Governance blocked:" in error_str:
102
+ raise GovernanceHaltError(error_str)
103
+
104
+ # Activity raised GovernanceAPIError (fail_closed and API unreachable)
105
+ if "GovernanceAPIError" in error_type or "GovernanceAPIError" in error_str:
106
+ raise GovernanceHaltError(error_str)
107
+
108
+ # Other errors with fail_open: silently continue
109
+ return None
110
+
111
+
112
+ class GovernanceInterceptor(Interceptor):
113
+ """Factory for workflow interceptor. Events sent via activity for determinism."""
114
+
115
+ def __init__(
116
+ self,
117
+ api_url: str,
118
+ api_key: str,
119
+ span_processor=None, # Shared with activity interceptor for HTTP spans
120
+ config=None, # Optional GovernanceConfig
121
+ ):
122
+ self.api_url = api_url.rstrip("/")
123
+ self.api_key = api_key
124
+ self.span_processor = span_processor
125
+ self.api_timeout = getattr(config, 'api_timeout', 30.0) if config else 30.0
126
+ self.on_api_error = getattr(config, 'on_api_error', 'fail_open') if config else 'fail_open'
127
+ self.send_start_event = getattr(config, 'send_start_event', True) if config else True
128
+ self.skip_workflow_types = getattr(config, 'skip_workflow_types', set()) if config else set()
129
+ self.skip_signals = getattr(config, 'skip_signals', set()) if config else set()
130
+
131
+ def workflow_interceptor_class(
132
+ self, input: WorkflowInterceptorClassInput
133
+ ) -> Optional[Type[WorkflowInboundInterceptor]]:
134
+ # Capture via closure
135
+ api_url = self.api_url
136
+ api_key = self.api_key
137
+ span_processor = self.span_processor
138
+ timeout = self.api_timeout
139
+ on_error = self.on_api_error
140
+ send_start = self.send_start_event
141
+ skip_types = self.skip_workflow_types
142
+ skip_sigs = self.skip_signals
143
+
144
+ class _Inbound(WorkflowInboundInterceptor):
145
+ async def execute_workflow(self, input: ExecuteWorkflowInput) -> Any:
146
+ info = workflow.info()
147
+
148
+ # Skip if configured
149
+ if info.workflow_type in skip_types:
150
+ return await super().execute_workflow(input)
151
+
152
+ # WorkflowStarted event
153
+ if send_start and workflow.patched("openbox-v2-start"):
154
+ await _send_governance_event(api_url, api_key, {
155
+ "source": "workflow-telemetry",
156
+ "event_type": "WorkflowStarted",
157
+ "workflow_id": info.workflow_id,
158
+ "run_id": info.run_id,
159
+ "workflow_type": info.workflow_type,
160
+ "task_queue": info.task_queue,
161
+ }, timeout, on_error)
162
+
163
+ # Execute workflow
164
+ error = None
165
+ try:
166
+ result = await super().execute_workflow(input)
167
+
168
+ # WorkflowCompleted event (success)
169
+ if workflow.patched("openbox-v2-complete"):
170
+ # Serialize workflow result for governance
171
+ workflow_output = None
172
+ try:
173
+ workflow_output = _serialize_value(result)
174
+ except Exception:
175
+ workflow_output = str(result) if result is not None else None
176
+
177
+ await _send_governance_event(api_url, api_key, {
178
+ "source": "workflow-telemetry",
179
+ "event_type": "WorkflowCompleted",
180
+ "workflow_id": info.workflow_id,
181
+ "run_id": info.run_id,
182
+ "workflow_type": info.workflow_type,
183
+ "workflow_output": workflow_output,
184
+ }, timeout, on_error)
185
+
186
+ return result
187
+ except Exception as e:
188
+ # Extract error details, including nested cause for ActivityError
189
+ error = {
190
+ "type": type(e).__name__,
191
+ "message": str(e),
192
+ }
193
+
194
+ # Get cause using Temporal's .cause property or Python's __cause__/__context__
195
+ cause = getattr(e, 'cause', None) or e.__cause__ or e.__context__
196
+
197
+ if cause:
198
+ error["cause"] = {
199
+ "type": type(cause).__name__,
200
+ "message": str(cause),
201
+ }
202
+ # Check for ApplicationError details (e.g., GovernanceStop)
203
+ if hasattr(cause, 'type') and cause.type:
204
+ error["cause"]["error_type"] = cause.type
205
+ if hasattr(cause, 'non_retryable'):
206
+ error["cause"]["non_retryable"] = cause.non_retryable
207
+
208
+ # Go deeper if there's another cause
209
+ deeper_cause = getattr(cause, 'cause', None) or getattr(cause, '__cause__', None)
210
+ if deeper_cause:
211
+ error["root_cause"] = {
212
+ "type": type(deeper_cause).__name__,
213
+ "message": str(deeper_cause),
214
+ }
215
+ if hasattr(deeper_cause, 'type') and deeper_cause.type:
216
+ error["root_cause"]["error_type"] = deeper_cause.type
217
+
218
+ # WorkflowFailed event
219
+ if workflow.patched("openbox-v2-failed"):
220
+ await _send_governance_event(api_url, api_key, {
221
+ "source": "workflow-telemetry",
222
+ "event_type": "WorkflowFailed",
223
+ "workflow_id": info.workflow_id,
224
+ "run_id": info.run_id,
225
+ "workflow_type": info.workflow_type,
226
+ "error": error,
227
+ }, timeout, on_error)
228
+
229
+ raise
230
+
231
+ async def handle_signal(self, input: HandleSignalInput) -> None:
232
+ info = workflow.info()
233
+
234
+ # Skip if configured
235
+ if input.signal in skip_sigs or info.workflow_type in skip_types:
236
+ return await super().handle_signal(input)
237
+
238
+ # SignalReceived event - check verdict and store if "stop"
239
+ if workflow.patched("openbox-v2-signal"):
240
+ result = await _send_governance_event(api_url, api_key, {
241
+ "source": "workflow-telemetry",
242
+ "event_type": "SignalReceived",
243
+ "workflow_id": info.workflow_id,
244
+ "run_id": info.run_id,
245
+ "workflow_type": info.workflow_type,
246
+ "task_queue": info.task_queue,
247
+ "signal_name": input.signal,
248
+ "signal_args": input.args,
249
+ }, timeout, on_error)
250
+
251
+ # If governance returned BLOCK/HALT, store verdict for activity interceptor
252
+ # The next activity will check this and fail with GovernanceStop
253
+ verdict = Verdict.from_string(result.get("verdict") or result.get("action")) if result else Verdict.ALLOW
254
+ if verdict.should_stop() and span_processor:
255
+ span_processor.set_verdict(
256
+ info.workflow_id,
257
+ verdict,
258
+ result.get("reason"),
259
+ info.run_id, # Include run_id to detect stale verdicts
260
+ )
261
+
262
+ await super().handle_signal(input)
263
+
264
+ return _Inbound