commitlayer-integrations 0.1.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.
- commitlayer_crewai/__init__.py +83 -0
- commitlayer_inngest/__init__.py +31 -0
- commitlayer_inngest/steps.py +354 -0
- commitlayer_integrations/__init__.py +9 -0
- commitlayer_integrations/tools.py +244 -0
- commitlayer_integrations/verdict.py +119 -0
- commitlayer_integrations-0.1.0.dist-info/METADATA +84 -0
- commitlayer_integrations-0.1.0.dist-info/RECORD +18 -0
- commitlayer_integrations-0.1.0.dist-info/WHEEL +5 -0
- commitlayer_integrations-0.1.0.dist-info/licenses/LICENSE +21 -0
- commitlayer_integrations-0.1.0.dist-info/top_level.txt +7 -0
- commitlayer_langgraph/__init__.py +111 -0
- commitlayer_openai_agents/__init__.py +71 -0
- commitlayer_pydantic_ai/__init__.py +57 -0
- commitlayer_temporal/__init__.py +61 -0
- commitlayer_temporal/interceptor.py +127 -0
- commitlayer_temporal/mapping.py +207 -0
- commitlayer_temporal/verifier.py +108 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""CommitLayer for the OpenAI Agents SDK: record function-tool calls as actions before they run.
|
|
2
|
+
|
|
3
|
+
:class:`CommitLayerHooks` is a ``RunHooks`` whose ``on_tool_start`` records the mapped call. The SDK passes
|
|
4
|
+
function tools a ``ToolContext`` carrying ``tool_name``, ``tool_call_id`` and the raw ``tool_arguments`` JSON,
|
|
5
|
+
which is everything the recorder needs; hosted tools (web search, computer use) pass a plain context and are
|
|
6
|
+
left alone. The hook never blocks the call and never reads its result.
|
|
7
|
+
|
|
8
|
+
from agents import Agent, Runner
|
|
9
|
+
from commitlayer import Client
|
|
10
|
+
from commitlayer_integrations.tools import ToolRecorder
|
|
11
|
+
from commitlayer_openai_agents import CommitLayerHooks
|
|
12
|
+
|
|
13
|
+
recorder = ToolRecorder(Client(agent_id="support-bot"), MAPPING)
|
|
14
|
+
result = await Runner.run(agent, "Refund order 10231", hooks=CommitLayerHooks(recorder))
|
|
15
|
+
recorder.wait("refund:order_10231") # the completion gate
|
|
16
|
+
|
|
17
|
+
``agents`` (the SDK) is imported lazily: when it is installed, :class:`CommitLayerHooks` subclasses its
|
|
18
|
+
``RunHooks`` so the SDK's type checks pass; when it is not, the class stands alone with the same methods, so
|
|
19
|
+
this module imports and tests anywhere.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from commitlayer_integrations.tools import ToolRecorder
|
|
28
|
+
|
|
29
|
+
__all__ = ["CommitLayerHooks", "CHANNEL"]
|
|
30
|
+
|
|
31
|
+
CHANNEL = "openai_agents"
|
|
32
|
+
|
|
33
|
+
try: # the SDK's base class when available, so the hooks object is a RunHooks to the SDK
|
|
34
|
+
from agents import RunHooks as _Base # type: ignore[import-not-found]
|
|
35
|
+
except Exception: # noqa: BLE001 - optional dependency
|
|
36
|
+
|
|
37
|
+
class _Base: # type: ignore[no-redef]
|
|
38
|
+
"""Stand-in for ``agents.RunHooks`` when the SDK is not installed."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _arguments(context: Any) -> dict[str, Any]:
|
|
42
|
+
raw = getattr(context, "tool_arguments", None)
|
|
43
|
+
if isinstance(raw, dict):
|
|
44
|
+
return raw
|
|
45
|
+
if isinstance(raw, str) and raw.strip():
|
|
46
|
+
try:
|
|
47
|
+
parsed = json.loads(raw)
|
|
48
|
+
return parsed if isinstance(parsed, dict) else {"value": parsed}
|
|
49
|
+
except ValueError:
|
|
50
|
+
return {"raw": raw}
|
|
51
|
+
return {}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CommitLayerHooks(_Base): # type: ignore[misc]
|
|
55
|
+
"""``RunHooks`` that records every mapped tool call through ``recorder`` before the tool runs."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, recorder: ToolRecorder):
|
|
58
|
+
super().__init__()
|
|
59
|
+
self.recorder = recorder
|
|
60
|
+
if recorder.channel == "agent":
|
|
61
|
+
recorder.channel = CHANNEL
|
|
62
|
+
|
|
63
|
+
async def on_tool_start(self, context: Any, agent: Any, tool: Any) -> None:
|
|
64
|
+
name = getattr(context, "tool_name", None) or getattr(tool, "name", None)
|
|
65
|
+
if not name or not hasattr(context, "tool_arguments"):
|
|
66
|
+
return # a hosted tool with no arguments to map
|
|
67
|
+
call_id = getattr(context, "tool_call_id", None)
|
|
68
|
+
self.recorder.record(str(name), _arguments(context), call_id=call_id)
|
|
69
|
+
|
|
70
|
+
async def on_tool_end(self, context: Any, agent: Any, tool: Any, result: Any) -> None:
|
|
71
|
+
return None # the tool's own result is never evidence
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""CommitLayer for Pydantic AI: record tool calls as actions before the tools run.
|
|
2
|
+
|
|
3
|
+
Pydantic AI routes every tool call through a toolset's ``call_tool``; :class:`RecordingToolset` wraps any
|
|
4
|
+
toolset and records the mapped calls before delegating to it. The recorder sees the validated arguments and
|
|
5
|
+
the run's ``tool_call_id``; the tool's result is never sent.
|
|
6
|
+
|
|
7
|
+
from pydantic_ai import Agent, FunctionToolset
|
|
8
|
+
from commitlayer import Client
|
|
9
|
+
from commitlayer_integrations.tools import ToolRecorder
|
|
10
|
+
from commitlayer_pydantic_ai import RecordingToolset
|
|
11
|
+
|
|
12
|
+
recorder = ToolRecorder(Client(agent_id="support-bot"), MAPPING)
|
|
13
|
+
tools = FunctionToolset(tools=[refund_payment, close_ticket])
|
|
14
|
+
agent = Agent("openai:gpt-5", toolsets=[RecordingToolset(tools, recorder)])
|
|
15
|
+
|
|
16
|
+
``pydantic_ai`` is imported lazily: with it installed :class:`RecordingToolset` is a real ``WrapperToolset``;
|
|
17
|
+
without it the class keeps the same ``call_tool`` shape so this module imports and tests anywhere.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from commitlayer_integrations.tools import ToolRecorder
|
|
25
|
+
|
|
26
|
+
__all__ = ["RecordingToolset", "CHANNEL"]
|
|
27
|
+
|
|
28
|
+
CHANNEL = "pydantic_ai"
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from pydantic_ai import WrapperToolset as _Base # type: ignore[import-not-found]
|
|
32
|
+
except Exception: # noqa: BLE001 - optional dependency
|
|
33
|
+
|
|
34
|
+
class _Base: # type: ignore[no-redef]
|
|
35
|
+
"""Stand-in for ``pydantic_ai.WrapperToolset`` when Pydantic AI is not installed."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, wrapped: Any):
|
|
38
|
+
self.wrapped = wrapped
|
|
39
|
+
|
|
40
|
+
async def call_tool(self, name: str, tool_args: dict[str, Any], ctx: Any, tool: Any) -> Any:
|
|
41
|
+
return await self.wrapped.call_tool(name, tool_args, ctx, tool)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RecordingToolset(_Base): # type: ignore[misc]
|
|
45
|
+
"""A toolset wrapper that records every mapped tool call through ``recorder``, then delegates."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, wrapped: Any, recorder: ToolRecorder):
|
|
48
|
+
super().__init__(wrapped)
|
|
49
|
+
self.recorder = recorder
|
|
50
|
+
if recorder.channel == "agent":
|
|
51
|
+
recorder.channel = CHANNEL
|
|
52
|
+
|
|
53
|
+
async def call_tool(self, name: str, tool_args: dict[str, Any], ctx: Any, tool: Any) -> Any:
|
|
54
|
+
call_id = getattr(ctx, "tool_call_id", None)
|
|
55
|
+
run_id = getattr(ctx, "run_id", None)
|
|
56
|
+
self.recorder.record(name, dict(tool_args or {}), call_id=call_id, run_id=run_id)
|
|
57
|
+
return await super().call_tool(name, tool_args, ctx, tool)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""CommitLayer for Temporal: record each mapped activity as an action, and gate completion on the verdict.
|
|
2
|
+
|
|
3
|
+
Temporal makes the workflow durable. CommitLayer independently verifies the result.
|
|
4
|
+
|
|
5
|
+
``temporalio`` is imported lazily: the mapping helpers work without it, the interceptor and the verifier need it
|
|
6
|
+
(``pip install commitlayer-integrations[temporal]``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .mapping import (
|
|
14
|
+
CHANNEL,
|
|
15
|
+
DEFAULT_ACTION_ID,
|
|
16
|
+
ActivityMapping,
|
|
17
|
+
MappingError,
|
|
18
|
+
TemporalRef,
|
|
19
|
+
bind_kwargs,
|
|
20
|
+
build_action_body,
|
|
21
|
+
resolve_path,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"CHANNEL",
|
|
26
|
+
"DEFAULT_ACTION_ID",
|
|
27
|
+
"ACTIVITY_NAME",
|
|
28
|
+
"RECORD_FAILED",
|
|
29
|
+
"ActivityMapping",
|
|
30
|
+
"CommitLayerActivityInboundInterceptor",
|
|
31
|
+
"CommitLayerInterceptor",
|
|
32
|
+
"CommitLayerVerifier",
|
|
33
|
+
"MappingError",
|
|
34
|
+
"TemporalRef",
|
|
35
|
+
"bind_kwargs",
|
|
36
|
+
"build_action_body",
|
|
37
|
+
"resolve_path",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
_LAZY = {
|
|
41
|
+
"CommitLayerInterceptor": "interceptor",
|
|
42
|
+
"CommitLayerActivityInboundInterceptor": "interceptor",
|
|
43
|
+
"RECORD_FAILED": "interceptor",
|
|
44
|
+
"CommitLayerVerifier": "verifier",
|
|
45
|
+
"ACTIVITY_NAME": "verifier",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def __getattr__(name: str) -> Any:
|
|
50
|
+
module = _LAZY.get(name)
|
|
51
|
+
if module is None:
|
|
52
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
53
|
+
try:
|
|
54
|
+
import importlib
|
|
55
|
+
|
|
56
|
+
mod = importlib.import_module(f".{module}", __name__)
|
|
57
|
+
except ImportError as e: # pragma: no cover - depends on the environment
|
|
58
|
+
raise ImportError(
|
|
59
|
+
f"{name} needs the Temporal SDK: pip install 'commitlayer-integrations[temporal]'"
|
|
60
|
+
) from e
|
|
61
|
+
return getattr(mod, name)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""A Temporal worker interceptor that records a CommitLayer action before a mapped activity runs.
|
|
2
|
+
|
|
3
|
+
Wire it once on the worker::
|
|
4
|
+
|
|
5
|
+
from commitlayer import Client
|
|
6
|
+
from commitlayer_temporal import CommitLayerInterceptor
|
|
7
|
+
|
|
8
|
+
cl = Client() # COMMITLAYER_API_KEY from the environment; never log it
|
|
9
|
+
worker = Worker(
|
|
10
|
+
client, task_queue="orders",
|
|
11
|
+
workflows=[RefundWorkflow], activities=[refund_payment],
|
|
12
|
+
interceptors=[CommitLayerInterceptor(cl, {
|
|
13
|
+
"refund_payment": {
|
|
14
|
+
"contract_id": "refund_workflow",
|
|
15
|
+
"subject": "kwargs.order_id",
|
|
16
|
+
"params": {"amount": "kwargs.amount", "reason": "kwargs.reason"},
|
|
17
|
+
"subject_kind": "order",
|
|
18
|
+
},
|
|
19
|
+
}, agent_id="refund-agent")],
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
The interceptor only declares the agent's intent: contract, subject and parameters, taken from the activity's
|
|
23
|
+
arguments before it executes. The activity's result is never sent. Verification reads the systems of record.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import logging
|
|
30
|
+
from collections.abc import Callable, Mapping
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from commitlayer import Client
|
|
34
|
+
from temporalio import activity
|
|
35
|
+
from temporalio.exceptions import ApplicationError
|
|
36
|
+
from temporalio.worker import ActivityInboundInterceptor, ExecuteActivityInput, Interceptor
|
|
37
|
+
|
|
38
|
+
from .mapping import ActivityMapping, TemporalRef, bind_kwargs, build_action_body
|
|
39
|
+
|
|
40
|
+
__all__ = ["CommitLayerInterceptor", "CommitLayerActivityInboundInterceptor", "RECORD_FAILED"]
|
|
41
|
+
|
|
42
|
+
log = logging.getLogger("commitlayer.temporal")
|
|
43
|
+
|
|
44
|
+
RECORD_FAILED = "CommitLayerRecordFailed"
|
|
45
|
+
"""``ApplicationError.type`` raised when ``fail_open=False`` and the action could not be recorded."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CommitLayerInterceptor(Interceptor):
|
|
49
|
+
"""Record an action for every activity named in ``mapping``; leave the others alone.
|
|
50
|
+
|
|
51
|
+
``mapping`` is ``{activity name: ActivityMapping | dict}`` (see :mod:`commitlayer_temporal.mapping`).
|
|
52
|
+
``agent_id`` is sent on every action; ``workflow_id`` replaces the Temporal workflow id when set.
|
|
53
|
+
With ``fail_open=True`` (default) a recording failure is logged as a warning and the activity still runs;
|
|
54
|
+
with ``fail_open=False`` the activity does not run and a non-retryable ``ApplicationError`` (type
|
|
55
|
+
``CommitLayerRecordFailed``) fails it. ``on_recorded`` receives the API response for each recorded action.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
client: Client,
|
|
61
|
+
mapping: Mapping[str, ActivityMapping | Mapping[str, Any]],
|
|
62
|
+
*,
|
|
63
|
+
agent_id: str | None = None,
|
|
64
|
+
workflow_id: str | None = None,
|
|
65
|
+
fail_open: bool = True,
|
|
66
|
+
on_recorded: Callable[[dict[str, Any]], None] | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.client = client
|
|
69
|
+
self.mapping = {name: ActivityMapping.coerce(m) for name, m in mapping.items()}
|
|
70
|
+
self.agent_id = agent_id or client.agent_id
|
|
71
|
+
self.workflow_id = workflow_id
|
|
72
|
+
self.fail_open = fail_open
|
|
73
|
+
self.on_recorded = on_recorded
|
|
74
|
+
|
|
75
|
+
def intercept_activity(self, next: ActivityInboundInterceptor) -> ActivityInboundInterceptor:
|
|
76
|
+
return CommitLayerActivityInboundInterceptor(next, self)
|
|
77
|
+
|
|
78
|
+
# ---- recording ----
|
|
79
|
+
|
|
80
|
+
def body_for(self, input: ExecuteActivityInput, info: Any) -> dict[str, Any] | None:
|
|
81
|
+
"""The ``POST /v1/actions`` body for this invocation, or ``None`` when the activity is not mapped."""
|
|
82
|
+
ref = TemporalRef.from_info(info)
|
|
83
|
+
mapping = self.mapping.get(ref.activity_type)
|
|
84
|
+
if mapping is None:
|
|
85
|
+
return None
|
|
86
|
+
return build_action_body(
|
|
87
|
+
mapping,
|
|
88
|
+
ref=ref,
|
|
89
|
+
args=input.args,
|
|
90
|
+
kwargs=bind_kwargs(input.fn, input.args),
|
|
91
|
+
agent_id=self.agent_id,
|
|
92
|
+
workflow_id=self.workflow_id,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def record(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
96
|
+
"""``POST /v1/actions`` with the body as built (the SDK's ``_call`` so ``contract_version`` is kept)."""
|
|
97
|
+
return self.client._call("POST", "/v1/actions", body)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class CommitLayerActivityInboundInterceptor(ActivityInboundInterceptor):
|
|
101
|
+
def __init__(self, next: ActivityInboundInterceptor, config: CommitLayerInterceptor) -> None:
|
|
102
|
+
super().__init__(next)
|
|
103
|
+
self.config = config
|
|
104
|
+
|
|
105
|
+
async def execute_activity(self, input: ExecuteActivityInput) -> Any:
|
|
106
|
+
info = activity.info()
|
|
107
|
+
try:
|
|
108
|
+
body = self.config.body_for(input, info)
|
|
109
|
+
if body is not None:
|
|
110
|
+
# urllib blocks; keep the worker's event loop free while the action is recorded.
|
|
111
|
+
rec = await asyncio.get_running_loop().run_in_executor(None, self.config.record, body)
|
|
112
|
+
if self.config.on_recorded:
|
|
113
|
+
self.config.on_recorded(rec)
|
|
114
|
+
except Exception as e: # noqa: BLE001 - the business activity must not depend on the verifier
|
|
115
|
+
if not self.config.fail_open:
|
|
116
|
+
raise ApplicationError(
|
|
117
|
+
f"commitlayer: action not recorded for activity {info.activity_type}: {e}",
|
|
118
|
+
type=RECORD_FAILED,
|
|
119
|
+
non_retryable=True,
|
|
120
|
+
) from e
|
|
121
|
+
log.warning(
|
|
122
|
+
"commitlayer: action not recorded for activity %s (workflow %s): %s",
|
|
123
|
+
info.activity_type,
|
|
124
|
+
info.workflow_id,
|
|
125
|
+
e,
|
|
126
|
+
)
|
|
127
|
+
return await self.next.execute_activity(input)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""From a Temporal activity invocation to the body of ``POST /v1/actions``.
|
|
2
|
+
|
|
3
|
+
Pure: nothing here imports ``temporalio`` or talks to the network, so the exact body an activity produces can
|
|
4
|
+
be asserted in a unit test. The interceptor in :mod:`commitlayer_temporal.interceptor` calls
|
|
5
|
+
:func:`build_action_body` and posts the result unchanged.
|
|
6
|
+
|
|
7
|
+
Paths
|
|
8
|
+
-----
|
|
9
|
+
A mapping names where the subject and the parameters live in the activity's arguments:
|
|
10
|
+
|
|
11
|
+
* ``args.0.order_id`` -- the first positional argument's ``order_id`` (dict key or attribute);
|
|
12
|
+
* ``kwargs.order_id`` -- the argument bound to the activity function's parameter ``order_id``;
|
|
13
|
+
* ``order_id`` -- shorthand for ``kwargs.order_id``.
|
|
14
|
+
|
|
15
|
+
Temporal delivers activity arguments positionally; ``kwargs.<name>`` works because the integration binds the
|
|
16
|
+
arguments to the activity function's signature, so a mapping can use parameter names instead of positions.
|
|
17
|
+
|
|
18
|
+
Templates
|
|
19
|
+
---------
|
|
20
|
+
``action_id`` (and nothing else) is a template over ``{workflow_id}``, ``{workflow_run_id}``, ``{activity_id}``,
|
|
21
|
+
``{attempt}``, ``{activity}``, ``{subject_id}`` and every resolved parameter by name (``{amount}``). Without a
|
|
22
|
+
template the action id is ``temporal:{workflow_id}:{activity_id}``: every retry attempt of one activity
|
|
23
|
+
execution records the one action, because the API is idempotent on ``action_id``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import inspect
|
|
29
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
CHANNEL = "temporal"
|
|
35
|
+
DEFAULT_ACTION_ID = "temporal:{workflow_id}:{activity_id}"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"CHANNEL",
|
|
39
|
+
"DEFAULT_ACTION_ID",
|
|
40
|
+
"ActivityMapping",
|
|
41
|
+
"MappingError",
|
|
42
|
+
"TemporalRef",
|
|
43
|
+
"bind_kwargs",
|
|
44
|
+
"build_action_body",
|
|
45
|
+
"resolve_path",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MappingError(LookupError):
|
|
50
|
+
"""A path or template in an activity mapping could not be resolved against the invocation."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ActivityMapping:
|
|
55
|
+
"""What to record for one activity name.
|
|
56
|
+
|
|
57
|
+
``subject`` and every value of ``params`` and ``compensates`` are paths (see the module docstring);
|
|
58
|
+
``action_id`` is a template. ``contract_version`` pins the version the action is recorded under.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
contract_id: str
|
|
62
|
+
subject: str
|
|
63
|
+
params: Mapping[str, str] = field(default_factory=dict)
|
|
64
|
+
action_id: str | None = None
|
|
65
|
+
action_type: str | None = None
|
|
66
|
+
subject_kind: str | None = None
|
|
67
|
+
compensates: str | None = None
|
|
68
|
+
contract_version: int | None = None
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def coerce(cls, value: ActivityMapping | Mapping[str, Any]) -> ActivityMapping:
|
|
72
|
+
if isinstance(value, ActivityMapping):
|
|
73
|
+
return value
|
|
74
|
+
unknown = set(value) - {f for f in cls.__dataclass_fields__}
|
|
75
|
+
if unknown:
|
|
76
|
+
raise MappingError(f"unknown mapping keys: {sorted(unknown)}")
|
|
77
|
+
return cls(**dict(value))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class TemporalRef:
|
|
82
|
+
"""The Temporal identifiers an action carries; ``from_info`` reads them off ``temporalio.activity.Info``."""
|
|
83
|
+
|
|
84
|
+
workflow_id: str | None
|
|
85
|
+
activity_id: str
|
|
86
|
+
attempt: int
|
|
87
|
+
activity_type: str
|
|
88
|
+
workflow_run_id: str | None = None
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_info(cls, info: Any) -> TemporalRef:
|
|
92
|
+
return cls(
|
|
93
|
+
workflow_id=getattr(info, "workflow_id", None),
|
|
94
|
+
activity_id=str(getattr(info, "activity_id", "")),
|
|
95
|
+
attempt=int(getattr(info, "attempt", 1)),
|
|
96
|
+
activity_type=str(getattr(info, "activity_type", "")),
|
|
97
|
+
workflow_run_id=getattr(info, "workflow_run_id", None),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def bind_kwargs(fn: Callable[..., Any], args: Sequence[Any]) -> dict[str, Any]:
|
|
102
|
+
"""The activity's positional arguments by parameter name (``self`` excluded for bound methods)."""
|
|
103
|
+
try:
|
|
104
|
+
bound = inspect.signature(fn).bind_partial(*args)
|
|
105
|
+
except (TypeError, ValueError):
|
|
106
|
+
return {}
|
|
107
|
+
bound.apply_defaults()
|
|
108
|
+
return dict(bound.arguments)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _step(value: Any, key: str, path: str) -> Any:
|
|
112
|
+
if isinstance(value, Mapping):
|
|
113
|
+
if key in value:
|
|
114
|
+
return value[key]
|
|
115
|
+
raise MappingError(f"{path!r}: no key {key!r}")
|
|
116
|
+
if isinstance(value, Sequence) and not isinstance(value, str | bytes):
|
|
117
|
+
try:
|
|
118
|
+
return value[int(key)]
|
|
119
|
+
except (ValueError, IndexError) as e:
|
|
120
|
+
raise MappingError(f"{path!r}: no index {key!r}") from e
|
|
121
|
+
if hasattr(value, key):
|
|
122
|
+
return getattr(value, key)
|
|
123
|
+
raise MappingError(f"{path!r}: {type(value).__name__} has no attribute {key!r}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def resolve_path(path: str, args: Sequence[Any], kwargs: Mapping[str, Any]) -> Any:
|
|
127
|
+
"""Resolve ``args.N.x.y`` / ``kwargs.x.y`` / ``x.y`` (= ``kwargs.x.y``) against an invocation."""
|
|
128
|
+
if not isinstance(path, str) or not path:
|
|
129
|
+
raise MappingError(f"path must be a non-empty string, got {path!r}")
|
|
130
|
+
parts = path.split(".")
|
|
131
|
+
root, rest = parts[0], parts[1:]
|
|
132
|
+
if root == "args":
|
|
133
|
+
value: Any = list(args)
|
|
134
|
+
elif root == "kwargs":
|
|
135
|
+
value = dict(kwargs)
|
|
136
|
+
else:
|
|
137
|
+
value, rest = dict(kwargs), parts
|
|
138
|
+
for key in rest:
|
|
139
|
+
value = _step(value, key, path)
|
|
140
|
+
return value
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _Fields(dict):
|
|
144
|
+
def __missing__(self, key: str) -> str:
|
|
145
|
+
raise MappingError(f"action_id template: unknown placeholder {{{key}}}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _now() -> str:
|
|
149
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") # noqa: UP017 - Python 3.10
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_action_body(
|
|
153
|
+
mapping: ActivityMapping | Mapping[str, Any],
|
|
154
|
+
*,
|
|
155
|
+
ref: TemporalRef,
|
|
156
|
+
args: Sequence[Any] = (),
|
|
157
|
+
kwargs: Mapping[str, Any] | None = None,
|
|
158
|
+
agent_id: str | None = None,
|
|
159
|
+
workflow_id: str | None = None,
|
|
160
|
+
requested_at: str | None = None,
|
|
161
|
+
) -> dict[str, Any]:
|
|
162
|
+
"""The exact ``POST /v1/actions`` body for one activity invocation.
|
|
163
|
+
|
|
164
|
+
``workflow_id`` overrides the Temporal workflow id as the action's ``workflow_id``. ``requested_at``
|
|
165
|
+
defaults to now, which is before the activity runs, so the request time precedes every effect.
|
|
166
|
+
"""
|
|
167
|
+
m = ActivityMapping.coerce(mapping)
|
|
168
|
+
kw = dict(kwargs or {})
|
|
169
|
+
subject_id = resolve_path(m.subject, args, kw)
|
|
170
|
+
if subject_id is None or subject_id == "":
|
|
171
|
+
raise MappingError(f"subject path {m.subject!r} resolved to nothing")
|
|
172
|
+
params: dict[str, Any] = {name: resolve_path(path, args, kw) for name, path in m.params.items()}
|
|
173
|
+
fields = _Fields(
|
|
174
|
+
params,
|
|
175
|
+
subject_id=str(subject_id),
|
|
176
|
+
workflow_id=ref.workflow_id or "",
|
|
177
|
+
workflow_run_id=ref.workflow_run_id or "",
|
|
178
|
+
activity_id=ref.activity_id,
|
|
179
|
+
attempt=ref.attempt,
|
|
180
|
+
activity=ref.activity_type,
|
|
181
|
+
)
|
|
182
|
+
action_id = (m.action_id or DEFAULT_ACTION_ID).format_map(fields)
|
|
183
|
+
if not action_id or len(action_id) > 120:
|
|
184
|
+
raise MappingError(f"action_id {action_id!r} must be 1-120 characters")
|
|
185
|
+
|
|
186
|
+
body: dict[str, Any] = {
|
|
187
|
+
"action_id": action_id,
|
|
188
|
+
"contract_id": m.contract_id,
|
|
189
|
+
"workflow_id": workflow_id or ref.workflow_id,
|
|
190
|
+
"subject_id": str(subject_id),
|
|
191
|
+
"requested_at": requested_at or _now(),
|
|
192
|
+
"params": {**params, "temporal_activity_id": ref.activity_id, "temporal_attempt": ref.attempt},
|
|
193
|
+
"channel": CHANNEL,
|
|
194
|
+
}
|
|
195
|
+
optional = (
|
|
196
|
+
("agent_id", agent_id),
|
|
197
|
+
("action_type", m.action_type),
|
|
198
|
+
("subject_kind", m.subject_kind),
|
|
199
|
+
("compensates", resolve_path(m.compensates, args, kw) if m.compensates else None),
|
|
200
|
+
("contract_version", m.contract_version),
|
|
201
|
+
)
|
|
202
|
+
for key, value in optional:
|
|
203
|
+
if value is not None:
|
|
204
|
+
body[key] = value
|
|
205
|
+
if body["workflow_id"] is None:
|
|
206
|
+
del body["workflow_id"]
|
|
207
|
+
return body
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The workflow completion gate: an activity that waits for a CommitLayer verdict to settle.
|
|
2
|
+
|
|
3
|
+
Each poll goes through the API's completion gate (``GET /v1/actions/{ref}/wait``), which holds the request for up
|
|
4
|
+
to ``poll_seconds`` and returns the moment the verdict settles; the loop around it lives in
|
|
5
|
+
:func:`commitlayer_integrations.verdict.poll_until_settled` and this module wraps it as a Temporal activity::
|
|
6
|
+
|
|
7
|
+
verifier = CommitLayerVerifier(Client())
|
|
8
|
+
Worker(..., activities=[refund_payment, verifier.commitlayer_verify], activity_executor=ThreadPoolExecutor(8))
|
|
9
|
+
|
|
10
|
+
and, in the workflow, after the activities whose actions were recorded::
|
|
11
|
+
|
|
12
|
+
verdict = await workflow.execute_activity(
|
|
13
|
+
CommitLayerVerifier.commitlayer_verify,
|
|
14
|
+
args=["temporal:" + workflow.info().workflow_id + ":refund", 900],
|
|
15
|
+
start_to_close_timeout=timedelta(minutes=20),
|
|
16
|
+
heartbeat_timeout=timedelta(minutes=1),
|
|
17
|
+
)
|
|
18
|
+
if verdict["passed"]:
|
|
19
|
+
... # only now tell the customer it is done
|
|
20
|
+
elif verdict["status"] == "settled":
|
|
21
|
+
... # FAIL: run the compensation activity (its action should carry compensates=<this action id>)
|
|
22
|
+
else:
|
|
23
|
+
... # timed out while pending: the workflow decides (wait longer, escalate, or hold the confirmation)
|
|
24
|
+
|
|
25
|
+
The activity is synchronous (the SDK's transport is ``urllib``), so the worker needs an ``activity_executor``.
|
|
26
|
+
It heartbeats after every poll. A cancellation reaches a sync activity as ``CancelledError`` raised in its
|
|
27
|
+
thread (Temporal's default); between polls the loop also checks ``activity.is_cancelled()`` and raises it.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
import time
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from commitlayer import Client
|
|
38
|
+
from commitlayer_integrations.verdict import Verdict, fetch_verification, poll_until_settled
|
|
39
|
+
from temporalio import activity
|
|
40
|
+
from temporalio.exceptions import CancelledError
|
|
41
|
+
|
|
42
|
+
__all__ = ["CommitLayerVerifier", "ACTIVITY_NAME"]
|
|
43
|
+
|
|
44
|
+
log = logging.getLogger("commitlayer.temporal")
|
|
45
|
+
|
|
46
|
+
ACTIVITY_NAME = "commitlayer_verify"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CommitLayerVerifier:
|
|
50
|
+
"""Holds the client; ``commitlayer_verify`` is the activity to register on the worker.
|
|
51
|
+
|
|
52
|
+
``default_timeout_seconds`` and ``poll_seconds`` apply when the workflow does not pass its own.
|
|
53
|
+
``sleep`` and ``clock`` exist for tests.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
client: Client,
|
|
59
|
+
*,
|
|
60
|
+
default_timeout_seconds: float = 900.0,
|
|
61
|
+
poll_seconds: float = 5.0,
|
|
62
|
+
sleep: Callable[[float], None] | None = None,
|
|
63
|
+
clock: Callable[[], float] = time.monotonic,
|
|
64
|
+
) -> None:
|
|
65
|
+
# The gate holds each poll server-side, so no local sleep is needed between polls; tests inject one.
|
|
66
|
+
self.client = client
|
|
67
|
+
self.default_timeout_seconds = default_timeout_seconds
|
|
68
|
+
self.poll_seconds = poll_seconds
|
|
69
|
+
self._sleep = sleep or (lambda s: None)
|
|
70
|
+
self._clock = clock
|
|
71
|
+
|
|
72
|
+
@activity.defn(name=ACTIVITY_NAME)
|
|
73
|
+
def commitlayer_verify(
|
|
74
|
+
self,
|
|
75
|
+
action_ref: str,
|
|
76
|
+
timeout_seconds: float | None = None,
|
|
77
|
+
poll_seconds: float | None = None,
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
"""Wait on the completion gate (``GET /v1/actions/{action_ref}/wait``) until the verification settles
|
|
80
|
+
or the timeout passes; each call to the gate is held for at most ``poll_seconds`` and a heartbeat follows.
|
|
81
|
+
|
|
82
|
+
Returns a :class:`commitlayer_integrations.verdict.Verdict`: ``passed`` is true only for a settled PASS
|
|
83
|
+
under both lenses; ``timed_out`` is true when the deadline passed while the verdict was still
|
|
84
|
+
``pending`` (or never verified). Both are JSON, so the workflow can branch on them and Temporal can
|
|
85
|
+
store them in history.
|
|
86
|
+
"""
|
|
87
|
+
timeout = self.default_timeout_seconds if timeout_seconds is None else float(timeout_seconds)
|
|
88
|
+
poll = self.poll_seconds if poll_seconds is None else float(poll_seconds)
|
|
89
|
+
|
|
90
|
+
def heartbeat(v: Verdict) -> None:
|
|
91
|
+
activity.heartbeat({"status": v["status"], "outcome": v["outcome"], "policy": v["policy"]})
|
|
92
|
+
|
|
93
|
+
verdict = poll_until_settled(
|
|
94
|
+
lambda: fetch_verification(self.client, action_ref, poll),
|
|
95
|
+
timeout_seconds=timeout,
|
|
96
|
+
poll_seconds=poll,
|
|
97
|
+
sleep=self._sleep,
|
|
98
|
+
clock=self._clock,
|
|
99
|
+
on_poll=heartbeat,
|
|
100
|
+
should_stop=activity.is_cancelled,
|
|
101
|
+
)
|
|
102
|
+
if verdict["timed_out"] and activity.is_cancelled():
|
|
103
|
+
raise CancelledError(f"commitlayer_verify cancelled while {action_ref} was {verdict['status']}")
|
|
104
|
+
if verdict["timed_out"]:
|
|
105
|
+
log.warning(
|
|
106
|
+
"commitlayer: verdict for %s still %s after %.0fs", action_ref, verdict["status"], timeout
|
|
107
|
+
)
|
|
108
|
+
return dict(verdict)
|