openbox-temporal-sdk-python 1.2.1__py3-none-any.whl → 1.3.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 +28 -30
- openbox/activities.py +24 -12
- openbox/activity_interceptor.py +108 -35
- openbox/config.py +41 -13
- openbox/core_adapter.py +55 -5
- openbox/errors.py +12 -1
- openbox/governance_state.py +46 -12
- openbox/hitl.py +25 -0
- openbox/multi_agent.py +38 -8
- openbox/plugin.py +5 -2
- openbox/retry_coordinator.py +136 -0
- openbox/retryable_block.py +179 -0
- openbox/worker.py +8 -13
- openbox/workflow_interceptor.py +365 -18
- {openbox_temporal_sdk_python-1.2.1.dist-info → openbox_temporal_sdk_python-1.3.0.dist-info}/METADATA +5 -3
- openbox_temporal_sdk_python-1.3.0.dist-info/RECORD +24 -0
- openbox_temporal_sdk_python-1.2.1.dist-info/RECORD +0 -22
- {openbox_temporal_sdk_python-1.2.1.dist-info → openbox_temporal_sdk_python-1.3.0.dist-info}/WHEEL +0 -0
- {openbox_temporal_sdk_python-1.2.1.dist-info → openbox_temporal_sdk_python-1.3.0.dist-info}/licenses/LICENSE +0 -0
openbox/__init__.py
CHANGED
|
@@ -6,45 +6,43 @@
|
|
|
6
6
|
# it crashes the workflow sandbox as a circular import, lazily it recurses
|
|
7
7
|
# unboundedly from build_auth_headers on every evaluate. Keep in sync with
|
|
8
8
|
# pyproject.toml on release.
|
|
9
|
-
__version__ = "1.
|
|
10
|
-
|
|
11
|
-
from .worker import create_openbox_worker
|
|
12
|
-
|
|
13
|
-
from .config import (
|
|
14
|
-
initialize,
|
|
15
|
-
get_global_config,
|
|
16
|
-
GovernanceConfig,
|
|
17
|
-
)
|
|
9
|
+
__version__ = "1.3.0"
|
|
18
10
|
|
|
11
|
+
from .config import GovernanceConfig, get_global_config, initialize
|
|
19
12
|
from .errors import (
|
|
20
|
-
OpenBoxError,
|
|
21
|
-
OpenBoxConfigError,
|
|
22
|
-
OpenBoxAuthError,
|
|
23
|
-
OpenBoxNetworkError,
|
|
24
|
-
OpenBoxInsecureURLError,
|
|
25
|
-
OpenBoxSigningError,
|
|
26
|
-
GovernanceBlockedError,
|
|
27
|
-
GovernanceHaltError,
|
|
28
|
-
GovernanceAPIError,
|
|
29
|
-
GuardrailsValidationError,
|
|
30
13
|
ApprovalExpiredError,
|
|
31
14
|
ApprovalRejectedError,
|
|
32
15
|
ApprovalTimeoutError,
|
|
16
|
+
GovernanceAPIError,
|
|
17
|
+
GovernanceBlockedError,
|
|
18
|
+
GovernanceHaltError,
|
|
19
|
+
GuardrailsValidationError,
|
|
20
|
+
OpenBoxAuthError,
|
|
21
|
+
OpenBoxConfigError,
|
|
22
|
+
OpenBoxError,
|
|
23
|
+
OpenBoxInsecureURLError,
|
|
24
|
+
OpenBoxNetworkError,
|
|
25
|
+
OpenBoxSigningError,
|
|
33
26
|
extract_governance_error,
|
|
34
27
|
map_signing_error,
|
|
35
28
|
)
|
|
36
29
|
|
|
37
|
-
from .types import (
|
|
38
|
-
Verdict,
|
|
39
|
-
WorkflowEventType,
|
|
40
|
-
GovernanceVerdictResponse,
|
|
41
|
-
GuardrailsCheckResult,
|
|
42
|
-
)
|
|
43
|
-
|
|
44
30
|
# Multi-agent primitives (sandbox-safe — only imports temporalio.workflow eagerly;
|
|
45
31
|
# signing/HTTP routed lazily through the governance activity).
|
|
46
32
|
from .multi_agent import emit_handoff
|
|
47
33
|
|
|
34
|
+
# Retryable-BLOCK restart envelope (sandbox-safe: pure stdlib + base contracts).
|
|
35
|
+
from .retryable_block import (
|
|
36
|
+
GOVERNANCE_RETRYABLE_BLOCK_SCHEMA_VERSION,
|
|
37
|
+
RetryableBlockRequest,
|
|
38
|
+
)
|
|
39
|
+
from .types import (
|
|
40
|
+
GovernanceVerdictResponse,
|
|
41
|
+
GuardrailsCheckResult,
|
|
42
|
+
Verdict,
|
|
43
|
+
WorkflowEventType,
|
|
44
|
+
)
|
|
45
|
+
from .worker import create_openbox_worker
|
|
48
46
|
from .workflow_interceptor import GovernanceInterceptor
|
|
49
47
|
|
|
50
48
|
try:
|
|
@@ -54,11 +52,9 @@ try:
|
|
|
54
52
|
except ImportError:
|
|
55
53
|
pass # temporalio < 1.23.0, plugin not available
|
|
56
54
|
|
|
57
|
-
from .verdict_handler import enforce_verdict, VerdictEnforcementResult
|
|
58
|
-
|
|
59
|
-
from .hitl import handle_approval_response, raise_approval_pending, should_skip_hitl
|
|
60
|
-
|
|
61
55
|
from .client import GovernanceClient
|
|
56
|
+
from .hitl import handle_approval_response, raise_approval_pending, should_skip_hitl
|
|
57
|
+
from .verdict_handler import VerdictEnforcementResult, enforce_verdict
|
|
62
58
|
|
|
63
59
|
# NOTE: ActivityGovernanceInterceptor is NOT imported here because it imports
|
|
64
60
|
# OpenTelemetry which uses importlib_metadata -> os.stat, causing sandbox issues.
|
|
@@ -114,6 +110,8 @@ __all__ = [
|
|
|
114
110
|
"WorkflowEventType",
|
|
115
111
|
"GovernanceVerdictResponse",
|
|
116
112
|
"GuardrailsCheckResult",
|
|
113
|
+
"RetryableBlockRequest",
|
|
114
|
+
"GOVERNANCE_RETRYABLE_BLOCK_SCHEMA_VERSION",
|
|
117
115
|
"emit_handoff",
|
|
118
116
|
"GovernanceInterceptor",
|
|
119
117
|
"enforce_verdict",
|
openbox/activities.py
CHANGED
|
@@ -21,18 +21,16 @@ executes. This ensures timestamps are generated in activity context (non-determi
|
|
|
21
21
|
code allowed) rather than workflow context (must be deterministic).
|
|
22
22
|
"""
|
|
23
23
|
|
|
24
|
-
import httpx
|
|
25
24
|
import logging
|
|
26
25
|
from datetime import datetime, timezone
|
|
27
|
-
from typing import
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
from .types import rfc3339_now as _rfc3339_now
|
|
26
|
+
from typing import Any, Dict, Optional
|
|
31
27
|
|
|
28
|
+
import httpx
|
|
32
29
|
from temporalio import activity
|
|
33
30
|
from temporalio.exceptions import ApplicationError
|
|
34
31
|
|
|
35
|
-
from .types import Verdict
|
|
32
|
+
from .types import GovernanceVerdictResponse, Verdict
|
|
33
|
+
from .types import rfc3339_now as _rfc3339_now
|
|
36
34
|
|
|
37
35
|
logger = logging.getLogger(__name__)
|
|
38
36
|
|
|
@@ -201,12 +199,26 @@ class GovernanceActivities:
|
|
|
201
199
|
)
|
|
202
200
|
|
|
203
201
|
data = response.json()
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
202
|
+
parsed = GovernanceVerdictResponse.from_dict(data)
|
|
203
|
+
verdict = parsed.verdict
|
|
204
|
+
reason = parsed.reason
|
|
205
|
+
policy_id = parsed.policy_id
|
|
206
|
+
risk_score = parsed.risk_score
|
|
207
|
+
|
|
208
|
+
# A BLOCK carrying a valid retry plan restarts the workflow run
|
|
209
|
+
# instead of merely failing this activity — check for it before
|
|
210
|
+
# the plain BLOCK/HALT stop handling below ever sees the verdict.
|
|
211
|
+
from .errors import GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE
|
|
212
|
+
from .retryable_block import retryable_block_request
|
|
213
|
+
|
|
214
|
+
retry_req = retryable_block_request(parsed, event_type=event_type)
|
|
215
|
+
if retry_req is not None:
|
|
216
|
+
raise ApplicationError(
|
|
217
|
+
"Governance requested workflow restart",
|
|
218
|
+
retry_req.to_dict(),
|
|
219
|
+
type=GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE,
|
|
220
|
+
non_retryable=True,
|
|
221
|
+
)
|
|
210
222
|
|
|
211
223
|
if verdict.should_stop():
|
|
212
224
|
result = await _handle_stop_verdict(
|
openbox/activity_interceptor.py
CHANGED
|
@@ -11,12 +11,11 @@ IMPORTANT: Activities CAN use datetime/time and make HTTP calls directly.
|
|
|
11
11
|
This is different from workflow interceptors which must maintain determinism.
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
-
from typing import Optional, Any, List
|
|
15
14
|
import dataclasses
|
|
16
|
-
from dataclasses import asdict, is_dataclass, fields
|
|
17
|
-
import time
|
|
18
15
|
import json
|
|
19
|
-
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import asdict, fields, is_dataclass
|
|
18
|
+
from typing import Any, List, NoReturn, Optional
|
|
20
19
|
|
|
21
20
|
from .types import rfc3339_now as _rfc3339_now
|
|
22
21
|
|
|
@@ -63,27 +62,44 @@ def _update_list_items(current_list: list, new_list: list, _logger=None) -> None
|
|
|
63
62
|
current_list[i] = new_item
|
|
64
63
|
|
|
65
64
|
|
|
65
|
+
from opentelemetry import trace
|
|
66
66
|
from temporalio import activity
|
|
67
67
|
from temporalio.worker import (
|
|
68
|
-
Interceptor,
|
|
69
68
|
ActivityInboundInterceptor,
|
|
70
69
|
ExecuteActivityInput,
|
|
70
|
+
Interceptor,
|
|
71
71
|
)
|
|
72
|
-
from opentelemetry import trace
|
|
73
72
|
|
|
73
|
+
from .activities import _terminate_workflow_for_halt
|
|
74
|
+
from .client import GovernanceClient
|
|
74
75
|
from .config import GovernanceConfig
|
|
75
76
|
from .core_adapter import core_activity_scope, get_core_context_store
|
|
76
|
-
from .
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
from .errors import (
|
|
78
|
+
GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE,
|
|
79
|
+
GovernanceBlockedError,
|
|
80
|
+
GovernanceHaltError,
|
|
81
|
+
GuardrailsValidationError,
|
|
81
82
|
)
|
|
83
|
+
from .governance_state import TemporalGovernanceState
|
|
82
84
|
from .multi_agent import read_session_from_header
|
|
83
|
-
from .
|
|
85
|
+
from .retryable_block import RetryableBlockRequest, retryable_block_request
|
|
86
|
+
from .types import GovernanceVerdictResponse, Verdict, WorkflowEventType
|
|
84
87
|
from .verdict_handler import enforce_verdict
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _raise_retryable_block(req: RetryableBlockRequest) -> NoReturn:
|
|
91
|
+
"""Raise the stable, versioned ``GovernanceRetryableBlock`` ApplicationError
|
|
92
|
+
for a valid retryable-BLOCK directive. Non-retryable — the workflow
|
|
93
|
+
interceptor catches this stable type and Continue-As-News with the
|
|
94
|
+
replacement input."""
|
|
95
|
+
from temporalio.exceptions import ApplicationError
|
|
96
|
+
|
|
97
|
+
raise ApplicationError(
|
|
98
|
+
"Governance requested workflow restart",
|
|
99
|
+
req.to_dict(),
|
|
100
|
+
type=GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE,
|
|
101
|
+
non_retryable=True,
|
|
102
|
+
)
|
|
87
103
|
|
|
88
104
|
|
|
89
105
|
def _serialize_value(value: Any) -> Any:
|
|
@@ -94,6 +110,12 @@ def _serialize_value(value: Any) -> Any:
|
|
|
94
110
|
return value
|
|
95
111
|
if isinstance(value, bytes):
|
|
96
112
|
return _serialize_bytes(value)
|
|
113
|
+
model_dump = getattr(value, "model_dump", None)
|
|
114
|
+
if callable(model_dump):
|
|
115
|
+
try:
|
|
116
|
+
return model_dump(mode="json")
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
97
119
|
if is_dataclass(value) and not isinstance(value, type):
|
|
98
120
|
return asdict(value)
|
|
99
121
|
if isinstance(value, (list, tuple)):
|
|
@@ -111,6 +133,7 @@ def _serialize_bytes(value: bytes) -> str:
|
|
|
111
133
|
return value.decode("utf-8")
|
|
112
134
|
except Exception:
|
|
113
135
|
import base64
|
|
136
|
+
|
|
114
137
|
return base64.b64encode(value).decode("ascii")
|
|
115
138
|
|
|
116
139
|
|
|
@@ -238,35 +261,56 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
|
|
|
238
261
|
)
|
|
239
262
|
except Exception:
|
|
240
263
|
# The activity (or a hook) raised, so _handle_completion is skipped. A
|
|
241
|
-
# completed-hook
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
#
|
|
264
|
+
# completed-hook stop recorded during user code must still be enforced:
|
|
265
|
+
# HALT reaches the terminate path and raises GovernanceHalt, and a
|
|
266
|
+
# retryable request raises GovernanceRetryableBlock — either REPLACES
|
|
267
|
+
# the original exception (completed-hook priority HALT > retryable >
|
|
268
|
+
# original exception). A plain completed BLOCK is a no-op, so the
|
|
269
|
+
# `raise` below re-propagates the original exception unchanged. Consume
|
|
270
|
+
# the completed-stop either way (also clears it, so it can never
|
|
271
|
+
# strand/leak on the exception path).
|
|
245
272
|
await self._consume_completed_halt(info)
|
|
246
273
|
raise
|
|
247
274
|
|
|
248
275
|
result = await self._handle_completion(
|
|
249
|
-
info,
|
|
250
|
-
|
|
276
|
+
info,
|
|
277
|
+
status,
|
|
278
|
+
error,
|
|
279
|
+
start_time,
|
|
280
|
+
end_time,
|
|
281
|
+
activity_input,
|
|
282
|
+
activity_output,
|
|
283
|
+
result,
|
|
251
284
|
session_id,
|
|
252
285
|
)
|
|
253
286
|
|
|
254
287
|
return result
|
|
255
288
|
|
|
256
289
|
async def _consume_completed_halt(self, info) -> None:
|
|
257
|
-
"""Exception-path safety net: a completed-hook
|
|
258
|
-
code must
|
|
259
|
-
|
|
290
|
+
"""Exception-path safety net: a completed-hook stop recorded during user
|
|
291
|
+
code must still be enforced even though the activity itself raised, so
|
|
292
|
+
_handle_completion (the success path) never runs. Priority: HALT raises
|
|
293
|
+
GovernanceHalt (terminating the workflow and REPLACING the original
|
|
294
|
+
exception); a retryable request raises GovernanceRetryableBlock (also
|
|
295
|
+
replacing the original exception with the restart request); a plain
|
|
296
|
+
completed BLOCK is a no-op here, so the caller's re-raise propagates the
|
|
297
|
+
original exception unchanged. Clears the completed-stop and the base abort
|
|
298
|
+
flag so nothing strands."""
|
|
260
299
|
stop = self._state.take_completed_stop(
|
|
261
300
|
info.workflow_id, info.workflow_run_id, info.activity_id
|
|
262
301
|
)
|
|
263
302
|
get_core_context_store().clear_activity_aborted(
|
|
264
303
|
info.workflow_id, info.activity_id
|
|
265
304
|
)
|
|
266
|
-
if stop is
|
|
305
|
+
if stop is None:
|
|
306
|
+
return
|
|
307
|
+
if stop.verdict is Verdict.HALT:
|
|
267
308
|
await _terminate_workflow_for_halt(
|
|
268
|
-
info.workflow_id, stop
|
|
309
|
+
info.workflow_id, stop.reason or "Governance halt"
|
|
269
310
|
)
|
|
311
|
+
return
|
|
312
|
+
if stop.request is not None:
|
|
313
|
+
_raise_retryable_block(stop.request)
|
|
270
314
|
|
|
271
315
|
async def _check_pending_verdicts(self, info) -> None:
|
|
272
316
|
"""Enforce a SignalReceived BLOCK/HALT recorded for this run by the
|
|
@@ -292,6 +336,7 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
|
|
|
292
336
|
await _terminate_workflow_for_halt(workflow_id, reason)
|
|
293
337
|
else:
|
|
294
338
|
from temporalio.exceptions import ApplicationError
|
|
339
|
+
|
|
295
340
|
raise ApplicationError(
|
|
296
341
|
f"Governance blocked: {reason}",
|
|
297
342
|
type="GovernanceBlock",
|
|
@@ -329,9 +374,7 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
|
|
|
329
374
|
info.activity_id,
|
|
330
375
|
)
|
|
331
376
|
if approved:
|
|
332
|
-
activity.logger.info(
|
|
333
|
-
f"Approval granted for workflow_id={info.workflow_id}"
|
|
334
|
-
)
|
|
377
|
+
activity.logger.info(f"Approval granted for workflow_id={info.workflow_id}")
|
|
335
378
|
self._state.clear_pending_approval(
|
|
336
379
|
info.workflow_id, info.workflow_run_id, info.activity_id
|
|
337
380
|
)
|
|
@@ -362,7 +405,21 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
|
|
|
362
405
|
self, verdict_response: GovernanceVerdictResponse, info, context: str
|
|
363
406
|
) -> None:
|
|
364
407
|
"""Enforce a governance verdict (HITL, BLOCK, HALT, guardrails)."""
|
|
365
|
-
from .hitl import
|
|
408
|
+
from .hitl import raise_approval_pending, should_skip_hitl
|
|
409
|
+
|
|
410
|
+
# An exact BLOCK with a valid retry plan requests a workflow restart and
|
|
411
|
+
# takes priority over the generic BLOCK/HALT/guardrails mapping below —
|
|
412
|
+
# checked before enforce_verdict() so it is never first converted to a
|
|
413
|
+
# plain GovernanceBlockedError. These are REAL activity-lifecycle events
|
|
414
|
+
# (not hooks), so hook_trigger stays the default False.
|
|
415
|
+
event_type = (
|
|
416
|
+
WorkflowEventType.ACTIVITY_STARTED.value
|
|
417
|
+
if context == "activity_start"
|
|
418
|
+
else WorkflowEventType.ACTIVITY_COMPLETED.value
|
|
419
|
+
)
|
|
420
|
+
req = retryable_block_request(verdict_response, event_type=event_type)
|
|
421
|
+
if req is not None:
|
|
422
|
+
_raise_retryable_block(req)
|
|
366
423
|
|
|
367
424
|
try:
|
|
368
425
|
verdict_result = enforce_verdict(verdict_response, context)
|
|
@@ -384,6 +441,7 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
|
|
|
384
441
|
await _terminate_workflow_for_halt(info.workflow_id, str(e))
|
|
385
442
|
except GovernanceBlockedError as e:
|
|
386
443
|
from temporalio.exceptions import ApplicationError
|
|
444
|
+
|
|
387
445
|
raise ApplicationError(
|
|
388
446
|
f"Governance blocked: {e.reason}",
|
|
389
447
|
type="GovernanceBlock",
|
|
@@ -391,6 +449,7 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
|
|
|
391
449
|
)
|
|
392
450
|
except GuardrailsValidationError as e:
|
|
393
451
|
from temporalio.exceptions import ApplicationError
|
|
452
|
+
|
|
394
453
|
activity.logger.info(f"Guardrails validation failed: {e}")
|
|
395
454
|
raise ApplicationError(
|
|
396
455
|
f"Guardrails validation failed: {e}",
|
|
@@ -510,8 +569,16 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
|
|
|
510
569
|
return result, status, error, activity_output, end_time
|
|
511
570
|
|
|
512
571
|
async def _handle_completion(
|
|
513
|
-
self,
|
|
514
|
-
|
|
572
|
+
self,
|
|
573
|
+
info,
|
|
574
|
+
status,
|
|
575
|
+
error,
|
|
576
|
+
start_time,
|
|
577
|
+
end_time,
|
|
578
|
+
activity_input,
|
|
579
|
+
activity_output,
|
|
580
|
+
result,
|
|
581
|
+
multi_agent_session_id=None,
|
|
515
582
|
) -> Any:
|
|
516
583
|
"""Send ActivityCompleted, enforce verdict, apply output redaction."""
|
|
517
584
|
store = get_core_context_store()
|
|
@@ -525,10 +592,16 @@ class _ActivityInterceptor(ActivityInboundInterceptor):
|
|
|
525
592
|
base_aborted = store.is_activity_aborted(info.workflow_id, info.activity_id)
|
|
526
593
|
store.clear_activity_aborted(info.workflow_id, info.activity_id)
|
|
527
594
|
|
|
528
|
-
if completed_stop is not None
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
595
|
+
if completed_stop is not None:
|
|
596
|
+
if completed_stop.verdict is Verdict.HALT:
|
|
597
|
+
await _terminate_workflow_for_halt(
|
|
598
|
+
info.workflow_id, completed_stop.reason or "Governance halt"
|
|
599
|
+
)
|
|
600
|
+
elif completed_stop.request is not None:
|
|
601
|
+
# A retryable request outranks the plain skip-completed-event
|
|
602
|
+
# behavior below — raise the restart request now that user code
|
|
603
|
+
# has already returned.
|
|
604
|
+
_raise_retryable_block(completed_stop.request)
|
|
532
605
|
|
|
533
606
|
was_aborted = base_aborted or completed_stop is not None
|
|
534
607
|
|
openbox/config.py
CHANGED
|
@@ -10,17 +10,16 @@ linecache -> os.stat which triggers Temporal sandbox restrictions.
|
|
|
10
10
|
|
|
11
11
|
import re
|
|
12
12
|
from dataclasses import dataclass, field
|
|
13
|
-
from typing import
|
|
13
|
+
from typing import Optional, Set
|
|
14
14
|
|
|
15
15
|
from openbox_core.errors import OpenBoxConfigError as CoreOpenBoxConfigError
|
|
16
|
-
from openbox_core.identity import (
|
|
17
|
-
AGENT_DID_PREFIX as CORE_AGENT_DID_PREFIX,
|
|
18
|
-
load_ed25519_seed as load_core_ed25519_seed,
|
|
19
|
-
validate_agent_did as validate_core_agent_did,
|
|
20
|
-
)
|
|
21
16
|
|
|
22
|
-
# NOTE: urllib and
|
|
23
|
-
#
|
|
17
|
+
# NOTE: urllib, logging, and openbox_core.identity imports are lazy to avoid
|
|
18
|
+
# Temporal sandbox restrictions. urllib/logging use os.stat internally; identity
|
|
19
|
+
# pulls cryptography. None may load on a workflow-sandbox import path
|
|
20
|
+
# (openbox/__init__ → worker → config), so identity is imported inside the
|
|
21
|
+
# functions that need it and via module __getattr__ for AGENT_DID_PREFIX.
|
|
22
|
+
# Guarded by tests/test_workflow_sandbox_import_safety.py.
|
|
24
23
|
|
|
25
24
|
|
|
26
25
|
def _get_logger():
|
|
@@ -33,15 +32,24 @@ def _get_logger():
|
|
|
33
32
|
# API key format pattern (obx_live_... or obx_test_...)
|
|
34
33
|
API_KEY_PATTERN = re.compile(r"^obx_(live|test)_\w+$")
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
|
|
36
|
+
# Backward-compatible public constant, owned by the base SDK. Resolved lazily via
|
|
37
|
+
# module __getattr__ (PEP 562) so a workflow-sandbox import of openbox.config never
|
|
38
|
+
# eagerly loads openbox_core.identity (and its cryptography dependency).
|
|
39
|
+
def __getattr__(name: str):
|
|
40
|
+
if name == "AGENT_DID_PREFIX":
|
|
41
|
+
from openbox_core.identity import AGENT_DID_PREFIX as _prefix
|
|
42
|
+
|
|
43
|
+
return _prefix
|
|
44
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
45
|
+
|
|
38
46
|
|
|
39
47
|
# Re-export from errors.py for backward compatibility
|
|
40
48
|
from .errors import ( # noqa: F401
|
|
41
|
-
OpenBoxConfigError,
|
|
42
49
|
OpenBoxAuthError,
|
|
43
|
-
|
|
50
|
+
OpenBoxConfigError,
|
|
44
51
|
OpenBoxInsecureURLError,
|
|
52
|
+
OpenBoxNetworkError,
|
|
45
53
|
)
|
|
46
54
|
|
|
47
55
|
# GovernanceConfig - Configuration for interceptors
|
|
@@ -117,6 +125,22 @@ class GovernanceConfig:
|
|
|
117
125
|
# Temporal currently uses its native retry backoff for HITL polling.
|
|
118
126
|
hitl_poll_interval_ms: int = 5000
|
|
119
127
|
|
|
128
|
+
# Maximum Continue-As-New restarts a retryable BLOCK may trigger across the
|
|
129
|
+
# whole workflow chain (bounded input-remediation loop). Applies uniformly to
|
|
130
|
+
# every event origin; must be >= 1.
|
|
131
|
+
max_retryable_block_restarts: int = 3
|
|
132
|
+
|
|
133
|
+
def __post_init__(self) -> None:
|
|
134
|
+
# GovernanceConfig is public and can be handed directly to an interceptor,
|
|
135
|
+
# bypassing the factory/plugin — so validation lives on the dataclass (the
|
|
136
|
+
# single source of truth), not only at the call sites. Pure + sandbox-safe:
|
|
137
|
+
# raises the already-imported OpenBoxConfigError, no logging/IO.
|
|
138
|
+
if self.max_retryable_block_restarts < 1:
|
|
139
|
+
raise OpenBoxConfigError(
|
|
140
|
+
"max_retryable_block_restarts must be >= 1 "
|
|
141
|
+
f"(got {self.max_retryable_block_restarts})"
|
|
142
|
+
)
|
|
143
|
+
|
|
120
144
|
|
|
121
145
|
# Global Configuration Singleton
|
|
122
146
|
|
|
@@ -128,6 +152,8 @@ def _validate_api_key_format(api_key: str) -> bool:
|
|
|
128
152
|
|
|
129
153
|
def _validate_did(agent_did: str) -> None:
|
|
130
154
|
"""Validate agent DID format through the shared base SDK."""
|
|
155
|
+
from openbox_core.identity import validate_agent_did as validate_core_agent_did
|
|
156
|
+
|
|
131
157
|
try:
|
|
132
158
|
validate_core_agent_did(agent_did)
|
|
133
159
|
except CoreOpenBoxConfigError as exc:
|
|
@@ -150,6 +176,8 @@ def resolve_signing_defaults(agent_did, signer):
|
|
|
150
176
|
|
|
151
177
|
def _load_ed25519_seed(agent_private_key: str):
|
|
152
178
|
"""Decode and load an Ed25519 signer through the shared base SDK."""
|
|
179
|
+
from openbox_core.identity import load_ed25519_seed as load_core_ed25519_seed
|
|
180
|
+
|
|
153
181
|
try:
|
|
154
182
|
return load_core_ed25519_seed(agent_private_key)
|
|
155
183
|
except CoreOpenBoxConfigError as exc:
|
|
@@ -218,8 +246,8 @@ def _validate_api_key_with_server(
|
|
|
218
246
|
urllib.request uses os.stat internally which triggers sandbox errors.
|
|
219
247
|
"""
|
|
220
248
|
# Lazy imports to avoid sandbox restrictions
|
|
221
|
-
from urllib.request import Request, urlopen
|
|
222
249
|
from urllib.error import HTTPError, URLError
|
|
250
|
+
from urllib.request import Request, urlopen
|
|
223
251
|
|
|
224
252
|
# Build signed (or plain) headers via the single source of truth.
|
|
225
253
|
from .request_signing import prepare_signed_request
|
openbox/core_adapter.py
CHANGED
|
@@ -9,7 +9,10 @@ effects and never builds hook payloads or evaluates hook events itself:
|
|
|
9
9
|
``ApprovalPending`` (Temporal's native HITL retry loop) + a pending marker in
|
|
10
10
|
``TemporalGovernanceState``; completed-hook BLOCK/HALT -> recorded in
|
|
11
11
|
``TemporalGovernanceState`` (run-scoped) for the activity interceptor to
|
|
12
|
-
surface after user code returns.
|
|
12
|
+
surface after user code returns. An exact BLOCK carrying a valid retry plan
|
|
13
|
+
is checked FIRST at every one of these seams and raises the stable, versioned
|
|
14
|
+
``GovernanceRetryableBlock`` instead — HALT still wins because the base
|
|
15
|
+
normalizer never surfaces a directive for it.
|
|
13
16
|
- ``core_activity_scope`` binds the shared ``ActivityContext`` around activity
|
|
14
17
|
execution with a GUARANTEED try/finally reset.
|
|
15
18
|
|
|
@@ -31,8 +34,10 @@ from openbox_core.contracts.results import EvaluationResult, Verdict
|
|
|
31
34
|
from .errors import (
|
|
32
35
|
GOVERNANCE_BLOCK_ERROR_TYPE,
|
|
33
36
|
GOVERNANCE_HALT_ERROR_TYPE,
|
|
37
|
+
GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE,
|
|
34
38
|
)
|
|
35
39
|
from .governance_state import TemporalGovernanceState
|
|
40
|
+
from .retryable_block import RetryableBlockRequest, retryable_block_request
|
|
36
41
|
|
|
37
42
|
logger = logging.getLogger(__name__)
|
|
38
43
|
|
|
@@ -78,7 +83,9 @@ class TemporalFrameworkAdapter:
|
|
|
78
83
|
self._state = state
|
|
79
84
|
self._hitl_enabled = hitl_enabled
|
|
80
85
|
self._skip_hitl_activity_types = skip_hitl_activity_types or set()
|
|
81
|
-
self._store =
|
|
86
|
+
self._store = (
|
|
87
|
+
context_store if context_store is not None else _core_context_store
|
|
88
|
+
)
|
|
82
89
|
|
|
83
90
|
async def handle_approval(self, result: EvaluationResult) -> None:
|
|
84
91
|
"""Async hook REQUIRE_APPROVAL -> Temporal's retry-based HITL loop.
|
|
@@ -125,9 +132,25 @@ class TemporalFrameworkAdapter:
|
|
|
125
132
|
raise_approval_pending(result.reason or "Approval required")
|
|
126
133
|
|
|
127
134
|
def raise_lifecycle_blocked(self, result: EvaluationResult) -> NoReturn:
|
|
135
|
+
# An exact BLOCK with a valid retry plan requests a workflow restart and
|
|
136
|
+
# is checked before the generic BLOCK/HALT mapping; HALT still wins
|
|
137
|
+
# because retryable_block_request never surfaces a directive for it.
|
|
138
|
+
req = retryable_block_request(result, event_type="ActivityStarted")
|
|
139
|
+
if req is not None:
|
|
140
|
+
self._raise_retryable_block(req)
|
|
128
141
|
self._raise_application_error(result)
|
|
129
142
|
|
|
130
143
|
def raise_hook_blocked(self, result: EvaluationResult) -> NoReturn:
|
|
144
|
+
# Started hooks are wire-represented as ActivityStarted + hook_trigger=
|
|
145
|
+
# True (the stage rides in hook_stage) — same retryable-first ordering.
|
|
146
|
+
req = retryable_block_request(
|
|
147
|
+
result,
|
|
148
|
+
event_type="ActivityStarted",
|
|
149
|
+
hook_trigger=True,
|
|
150
|
+
hook_stage="started",
|
|
151
|
+
)
|
|
152
|
+
if req is not None:
|
|
153
|
+
self._raise_retryable_block(req)
|
|
131
154
|
self._raise_application_error(result)
|
|
132
155
|
|
|
133
156
|
def on_completed_hook_result(
|
|
@@ -135,17 +158,31 @@ class TemporalFrameworkAdapter:
|
|
|
135
158
|
) -> None:
|
|
136
159
|
"""Completed verdicts affect FUTURE execution only (the operation already
|
|
137
160
|
ran). Record a BLOCK/HALT run-scoped so the activity interceptor can skip
|
|
138
|
-
a duplicate completed event (BLOCK)
|
|
139
|
-
|
|
140
|
-
|
|
161
|
+
a duplicate completed event (plain BLOCK), reach the terminate path
|
|
162
|
+
(HALT), or raise a retryable-BLOCK restart request (BLOCK with a valid
|
|
163
|
+
retry plan) after user code returns. Without a resolved context there is
|
|
164
|
+
no run to key on — the base ContextStore still carries the
|
|
165
|
+
within-activity abort flag.
|
|
166
|
+
|
|
167
|
+
Every hook is wire-represented as ``event_type="ActivityStarted"`` with
|
|
168
|
+
``hook_trigger=True`` — the completed stage rides ONLY in ``hook_stage``,
|
|
169
|
+
never by flipping ``event_type`` to ``ActivityCompleted``.
|
|
170
|
+
"""
|
|
141
171
|
if not result.verdict.should_stop() or context is None:
|
|
142
172
|
return
|
|
173
|
+
request = retryable_block_request(
|
|
174
|
+
result,
|
|
175
|
+
event_type="ActivityStarted",
|
|
176
|
+
hook_trigger=True,
|
|
177
|
+
hook_stage="completed",
|
|
178
|
+
)
|
|
143
179
|
self._state.record_completed_stop(
|
|
144
180
|
context.workflow_id or "",
|
|
145
181
|
context.run_id or "",
|
|
146
182
|
context.activity_id or "",
|
|
147
183
|
result.verdict,
|
|
148
184
|
result.reason,
|
|
185
|
+
request,
|
|
149
186
|
)
|
|
150
187
|
|
|
151
188
|
@staticmethod
|
|
@@ -165,6 +202,19 @@ class TemporalFrameworkAdapter:
|
|
|
165
202
|
non_retryable=True,
|
|
166
203
|
)
|
|
167
204
|
|
|
205
|
+
@staticmethod
|
|
206
|
+
def _raise_retryable_block(req: RetryableBlockRequest) -> NoReturn:
|
|
207
|
+
"""Raise the stable, versioned ``GovernanceRetryableBlock`` the workflow
|
|
208
|
+
interceptor catches to Continue-As-New with the replacement input."""
|
|
209
|
+
from temporalio.exceptions import ApplicationError
|
|
210
|
+
|
|
211
|
+
raise ApplicationError(
|
|
212
|
+
"Governance requested workflow restart",
|
|
213
|
+
req.to_dict(),
|
|
214
|
+
type=GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE,
|
|
215
|
+
non_retryable=True,
|
|
216
|
+
)
|
|
217
|
+
|
|
168
218
|
|
|
169
219
|
def build_core_activity_context(
|
|
170
220
|
info: Any,
|
openbox/errors.py
CHANGED
|
@@ -36,6 +36,15 @@ GOVERNANCE_API_ERROR_TYPE: Final[str] = "GovernanceAPIError"
|
|
|
36
36
|
# Legacy alias; kept for histories predating the rename.
|
|
37
37
|
GOVERNANCE_STOP_ERROR_TYPE: Final[str] = "GovernanceStop"
|
|
38
38
|
|
|
39
|
+
# Retryable-BLOCK restart transport. A governance BLOCK carrying a valid retry
|
|
40
|
+
# plan crosses the activity/workflow boundary as a non-retryable ApplicationError
|
|
41
|
+
# of this stable type; the workflow interceptor catches it and Continue-As-News.
|
|
42
|
+
GOVERNANCE_RETRYABLE_BLOCK_ERROR_TYPE: Final[str] = "GovernanceRetryableBlock"
|
|
43
|
+
# Raised when the bounded restart chain would exceed max_retryable_block_restarts.
|
|
44
|
+
GOVERNANCE_RETRY_LIMIT_EXCEEDED_ERROR_TYPE: Final[str] = "GovernanceRetryLimitExceeded"
|
|
45
|
+
# Raised when replacement input cannot be converted for Continue-As-New.
|
|
46
|
+
GOVERNANCE_RETRY_INPUT_INVALID_ERROR_TYPE: Final[str] = "GovernanceRetryInputInvalid"
|
|
47
|
+
|
|
39
48
|
|
|
40
49
|
class OpenBoxError(Exception):
|
|
41
50
|
"""Base class for all OpenBox SDK errors."""
|
|
@@ -105,7 +114,9 @@ _SIGNING_REASON_MESSAGES: dict[str, str] = {
|
|
|
105
114
|
}
|
|
106
115
|
|
|
107
116
|
|
|
108
|
-
def map_signing_error(
|
|
117
|
+
def map_signing_error(
|
|
118
|
+
reason_code: str | None, fallback: str = ""
|
|
119
|
+
) -> OpenBoxSigningError:
|
|
109
120
|
"""Map a Core signing reason code to an actionable OpenBoxSigningError.
|
|
110
121
|
|
|
111
122
|
Unknown/empty codes fall back to a generic message (optionally augmented with
|