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/__init__.py +107 -0
- openbox/activities.py +163 -0
- openbox/activity_interceptor.py +755 -0
- openbox/config.py +274 -0
- openbox/otel_setup.py +969 -0
- openbox/py.typed +0 -0
- openbox/span_processor.py +361 -0
- openbox/tracing.py +228 -0
- openbox/types.py +166 -0
- openbox/worker.py +257 -0
- openbox/workflow_interceptor.py +264 -0
- openbox_temporal_sdk_python-1.0.0.dist-info/METADATA +1214 -0
- openbox_temporal_sdk_python-1.0.0.dist-info/RECORD +15 -0
- openbox_temporal_sdk_python-1.0.0.dist-info/WHEEL +4 -0
- openbox_temporal_sdk_python-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|