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.
@@ -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)