commitlayer-integrations 0.1.0__tar.gz

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.
Files changed (29) hide show
  1. commitlayer_integrations-0.1.0/LICENSE +21 -0
  2. commitlayer_integrations-0.1.0/PKG-INFO +84 -0
  3. commitlayer_integrations-0.1.0/README.md +41 -0
  4. commitlayer_integrations-0.1.0/commitlayer_crewai/__init__.py +83 -0
  5. commitlayer_integrations-0.1.0/commitlayer_inngest/__init__.py +31 -0
  6. commitlayer_integrations-0.1.0/commitlayer_inngest/steps.py +354 -0
  7. commitlayer_integrations-0.1.0/commitlayer_integrations/__init__.py +9 -0
  8. commitlayer_integrations-0.1.0/commitlayer_integrations/tools.py +244 -0
  9. commitlayer_integrations-0.1.0/commitlayer_integrations/verdict.py +119 -0
  10. commitlayer_integrations-0.1.0/commitlayer_integrations.egg-info/PKG-INFO +84 -0
  11. commitlayer_integrations-0.1.0/commitlayer_integrations.egg-info/SOURCES.txt +27 -0
  12. commitlayer_integrations-0.1.0/commitlayer_integrations.egg-info/dependency_links.txt +1 -0
  13. commitlayer_integrations-0.1.0/commitlayer_integrations.egg-info/requires.txt +26 -0
  14. commitlayer_integrations-0.1.0/commitlayer_integrations.egg-info/top_level.txt +7 -0
  15. commitlayer_integrations-0.1.0/commitlayer_langgraph/__init__.py +111 -0
  16. commitlayer_integrations-0.1.0/commitlayer_openai_agents/__init__.py +71 -0
  17. commitlayer_integrations-0.1.0/commitlayer_pydantic_ai/__init__.py +57 -0
  18. commitlayer_integrations-0.1.0/commitlayer_temporal/__init__.py +61 -0
  19. commitlayer_integrations-0.1.0/commitlayer_temporal/interceptor.py +127 -0
  20. commitlayer_integrations-0.1.0/commitlayer_temporal/mapping.py +207 -0
  21. commitlayer_integrations-0.1.0/commitlayer_temporal/verifier.py +108 -0
  22. commitlayer_integrations-0.1.0/pyproject.toml +43 -0
  23. commitlayer_integrations-0.1.0/setup.cfg +4 -0
  24. commitlayer_integrations-0.1.0/tests/test_frameworks.py +115 -0
  25. commitlayer_integrations-0.1.0/tests/test_inngest.py +327 -0
  26. commitlayer_integrations-0.1.0/tests/test_temporal_interceptor.py +270 -0
  27. commitlayer_integrations-0.1.0/tests/test_temporal_mapping.py +198 -0
  28. commitlayer_integrations-0.1.0/tests/test_tools.py +86 -0
  29. commitlayer_integrations-0.1.0/tests/test_verdict.py +108 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Asterfold Labs, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: commitlayer-integrations
3
+ Version: 0.1.0
4
+ Summary: CommitLayer integrations for agent frameworks and workflow runtimes: LangGraph, CrewAI, OpenAI Agents SDK, Pydantic AI, Temporal and Inngest
5
+ Author-email: "Asterfold Labs, Inc." <hello@commitlayer.ai>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://commitlayer.ai
8
+ Project-URL: Documentation, https://commitlayer.ai/docs
9
+ Project-URL: Changelog, https://commitlayer.ai/changelog
10
+ Project-URL: Source, https://github.com/kobikwelu/stateprf-svcs/tree/main/integrations/python
11
+ Keywords: ai-agents,verification,audit,mcp,observability
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: commitlayer>=0.1.0
24
+ Provides-Extra: temporal
25
+ Requires-Dist: temporalio>=1.5; extra == "temporal"
26
+ Provides-Extra: inngest
27
+ Requires-Dist: inngest>=0.4; extra == "inngest"
28
+ Provides-Extra: langgraph
29
+ Requires-Dist: langgraph>=1.0; extra == "langgraph"
30
+ Requires-Dist: langchain-core>=1.0; extra == "langgraph"
31
+ Provides-Extra: crewai
32
+ Requires-Dist: crewai>=1.0; extra == "crewai"
33
+ Provides-Extra: openai-agents
34
+ Requires-Dist: openai-agents>=0.4; extra == "openai-agents"
35
+ Provides-Extra: pydantic-ai
36
+ Requires-Dist: pydantic-ai-slim>=1.0; extra == "pydantic-ai"
37
+ Provides-Extra: dev
38
+ Requires-Dist: pytest>=8; extra == "dev"
39
+ Requires-Dist: ruff>=0.6; extra == "dev"
40
+ Requires-Dist: temporalio>=1.5; extra == "dev"
41
+ Requires-Dist: inngest>=0.4; extra == "dev"
42
+ Dynamic: license-file
43
+
44
+ # commitlayer-integrations (Python)
45
+
46
+ CommitLayer for agent frameworks and durable-workflow runtimes. One distribution, six optional packages, built on the
47
+ [`commitlayer` SDK](../../sdk/python/README.md):
48
+
49
+ | Package | Framework or runtime | Install |
50
+ |---|---|---|
51
+ | [`commitlayer_langgraph`](commitlayer_langgraph/README.md) | LangGraph, LangChain v1 agents | `pip install 'commitlayer-integrations[langgraph]'` |
52
+ | [`commitlayer_crewai`](commitlayer_crewai/README.md) | CrewAI | `pip install 'commitlayer-integrations[crewai]'` |
53
+ | [`commitlayer_openai_agents`](commitlayer_openai_agents/README.md) | OpenAI Agents SDK | `pip install 'commitlayer-integrations[openai-agents]'` |
54
+ | [`commitlayer_pydantic_ai`](commitlayer_pydantic_ai/README.md) | Pydantic AI | `pip install 'commitlayer-integrations[pydantic-ai]'` |
55
+ | [`commitlayer_temporal`](commitlayer_temporal/README.md) | Temporal (`temporalio`) | `pip install 'commitlayer-integrations[temporal]'` |
56
+ | [`commitlayer_inngest`](commitlayer_inngest/README.md) | Inngest (`inngest`) | `pip install 'commitlayer-integrations[inngest]'` |
57
+
58
+ The four framework packages share one mapping and one recorder (`commitlayer_integrations.tools.ToolRecorder`):
59
+ a tool name maps to a contract, the argument that holds the subject, and the arguments that are the action's
60
+ parameters, in the same shape the MCP recorder's mapping file uses. Each framework package is the hook at
61
+ which that recorder runs: a `wrap_tool_call` wrapper for LangGraph, a `before_tool_call` hook for CrewAI,
62
+ `RunHooks` for the OpenAI Agents SDK, a `WrapperToolset` for Pydantic AI. None of them imports its framework
63
+ at module load, so the tests run without any of them installed.
64
+
65
+ Each one does the same two things in its runtime's own terms:
66
+
67
+ 1. **Record the agent's intent before the work runs** (an activity, a step) as an action under a contract:
68
+ `POST /v1/actions` with the contract, subject and parameters taken from the call's own arguments. The
69
+ result of the work is never sent.
70
+ 2. **Gate completion on the verdict**: wait for `GET /v1/actions/{ref}` to report a *settled* verification and
71
+ branch on PASS / FAIL / UNKNOWN. The workflow does not tell the customer "done" until the verdict is PASS.
72
+
73
+ The workflow engine makes the process durable. CommitLayer independently verifies the result, from the
74
+ systems of record, after the fact. The agent may declare its intent. It may read the verdict. It must never
75
+ control the independent facts, the governing contract, or the final evidence.
76
+
77
+ Python 3.10+. Both packages import without their runtime SDK installed (the SDK is imported lazily), so a
78
+ service that uses only one of them does not need the other.
79
+
80
+ ```bash
81
+ pip install ./sdk/python ./integrations/python[temporal,inngest] # from this repository
82
+ export COMMITLAYER_API_KEY=spk_... # never log or print it
83
+ .venv/bin/python -m pytest integrations/python/tests -q # no Temporal server, no Inngest dev server
84
+ ```
@@ -0,0 +1,41 @@
1
+ # commitlayer-integrations (Python)
2
+
3
+ CommitLayer for agent frameworks and durable-workflow runtimes. One distribution, six optional packages, built on the
4
+ [`commitlayer` SDK](../../sdk/python/README.md):
5
+
6
+ | Package | Framework or runtime | Install |
7
+ |---|---|---|
8
+ | [`commitlayer_langgraph`](commitlayer_langgraph/README.md) | LangGraph, LangChain v1 agents | `pip install 'commitlayer-integrations[langgraph]'` |
9
+ | [`commitlayer_crewai`](commitlayer_crewai/README.md) | CrewAI | `pip install 'commitlayer-integrations[crewai]'` |
10
+ | [`commitlayer_openai_agents`](commitlayer_openai_agents/README.md) | OpenAI Agents SDK | `pip install 'commitlayer-integrations[openai-agents]'` |
11
+ | [`commitlayer_pydantic_ai`](commitlayer_pydantic_ai/README.md) | Pydantic AI | `pip install 'commitlayer-integrations[pydantic-ai]'` |
12
+ | [`commitlayer_temporal`](commitlayer_temporal/README.md) | Temporal (`temporalio`) | `pip install 'commitlayer-integrations[temporal]'` |
13
+ | [`commitlayer_inngest`](commitlayer_inngest/README.md) | Inngest (`inngest`) | `pip install 'commitlayer-integrations[inngest]'` |
14
+
15
+ The four framework packages share one mapping and one recorder (`commitlayer_integrations.tools.ToolRecorder`):
16
+ a tool name maps to a contract, the argument that holds the subject, and the arguments that are the action's
17
+ parameters, in the same shape the MCP recorder's mapping file uses. Each framework package is the hook at
18
+ which that recorder runs: a `wrap_tool_call` wrapper for LangGraph, a `before_tool_call` hook for CrewAI,
19
+ `RunHooks` for the OpenAI Agents SDK, a `WrapperToolset` for Pydantic AI. None of them imports its framework
20
+ at module load, so the tests run without any of them installed.
21
+
22
+ Each one does the same two things in its runtime's own terms:
23
+
24
+ 1. **Record the agent's intent before the work runs** (an activity, a step) as an action under a contract:
25
+ `POST /v1/actions` with the contract, subject and parameters taken from the call's own arguments. The
26
+ result of the work is never sent.
27
+ 2. **Gate completion on the verdict**: wait for `GET /v1/actions/{ref}` to report a *settled* verification and
28
+ branch on PASS / FAIL / UNKNOWN. The workflow does not tell the customer "done" until the verdict is PASS.
29
+
30
+ The workflow engine makes the process durable. CommitLayer independently verifies the result, from the
31
+ systems of record, after the fact. The agent may declare its intent. It may read the verdict. It must never
32
+ control the independent facts, the governing contract, or the final evidence.
33
+
34
+ Python 3.10+. Both packages import without their runtime SDK installed (the SDK is imported lazily), so a
35
+ service that uses only one of them does not need the other.
36
+
37
+ ```bash
38
+ pip install ./sdk/python ./integrations/python[temporal,inngest] # from this repository
39
+ export COMMITLAYER_API_KEY=spk_... # never log or print it
40
+ .venv/bin/python -m pytest integrations/python/tests -q # no Temporal server, no Inngest dev server
41
+ ```
@@ -0,0 +1,83 @@
1
+ """CommitLayer for CrewAI: record a crew's tool calls as actions before the tools run.
2
+
3
+ CrewAI exposes a ``before_tool_call`` hook that fires with the tool's name and its input before every tool
4
+ call in the process. :func:`install` registers a recording hook built on
5
+ :class:`commitlayer_integrations.tools.ToolRecorder`; :func:`uninstall` removes it. The hook never blocks a
6
+ call and never alters its input or result: it declares the intent, and CommitLayer verifies the outcome
7
+ later from the systems of record.
8
+
9
+ from commitlayer import Client
10
+ from commitlayer_integrations.tools import ToolRecorder
11
+ import commitlayer_crewai
12
+
13
+ recorder = ToolRecorder(Client(agent_id="ops-crew"), MAPPING)
14
+ commitlayer_crewai.install(recorder) # before the crew kicks off
15
+ crew.kickoff()
16
+ recorder.wait("refund:order_10231") # the completion gate, when the crew is done
17
+
18
+ ``crewai`` is imported only inside :func:`install`, so this module imports without it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Callable
24
+ from typing import Any
25
+
26
+ from commitlayer_integrations.tools import ToolRecorder
27
+
28
+ __all__ = ["install", "uninstall", "make_hook", "CHANNEL"]
29
+
30
+ CHANNEL = "crewai"
31
+
32
+
33
+ def make_hook(recorder: ToolRecorder) -> Callable[[Any], None]:
34
+ """The ``before_tool_call`` hook: records the call from the context's ``tool_name`` and ``tool_input`` and
35
+ returns ``None`` so the call proceeds. Exposed for tests and for callers who register hooks themselves."""
36
+ if recorder.channel == "agent":
37
+ recorder.channel = CHANNEL
38
+
39
+ def before_tool_call(ctx: Any) -> None:
40
+ name = getattr(ctx, "tool_name", None)
41
+ args = getattr(ctx, "tool_input", None)
42
+ if not name:
43
+ return None
44
+ task = getattr(ctx, "task", None)
45
+ run_id = str(getattr(task, "id", "") or "") or None
46
+ recorder.record(str(name), dict(args or {}), run_id=run_id)
47
+ return None # never block the call
48
+
49
+ before_tool_call.__name__ = "commitlayer_before_tool_call"
50
+ return before_tool_call
51
+
52
+
53
+ _installed: dict[int, Callable[[Any], None]] = {}
54
+
55
+
56
+ def install(
57
+ recorder: ToolRecorder, *, tools: list[str] | None = None, agents: list[str] | None = None
58
+ ) -> Callable[[Any], None]:
59
+ """Register the recording hook with CrewAI (``crewai.hooks.register_before_tool_call_hook``). ``tools`` and
60
+ ``agents`` narrow it to tool names or agent roles, as CrewAI's own decorator does; by default the hook sees
61
+ every call and the recorder's mapping decides what is recorded."""
62
+ from crewai.hooks import register_before_tool_call_hook # imported here: optional dependency
63
+
64
+ hook = make_hook(recorder)
65
+ if tools or agents:
66
+ # CrewAI's filtered registration goes through its decorator; apply it to the hook
67
+ from crewai.hooks import before_tool_call
68
+
69
+ hook = before_tool_call(tools=tools, agents=agents)(hook)
70
+ else:
71
+ register_before_tool_call_hook(hook)
72
+ _installed[id(recorder)] = hook
73
+ return hook
74
+
75
+
76
+ def uninstall(recorder: ToolRecorder) -> bool:
77
+ """Remove the hook :func:`install` registered for this recorder. Returns whether one was removed."""
78
+ hook = _installed.pop(id(recorder), None)
79
+ if hook is None:
80
+ return False
81
+ from crewai.hooks import unregister_before_tool_call_hook
82
+
83
+ return bool(unregister_before_tool_call_hook(hook))
@@ -0,0 +1,31 @@
1
+ """CommitLayer for Inngest: record a step as an action, and gate completion on the verdict.
2
+
3
+ Inngest makes the function durable. CommitLayer independently verifies the result.
4
+
5
+ The helpers are duck-typed over the Inngest ``ctx`` / ``step`` objects, so this package imports without the
6
+ ``inngest`` SDK; install ``commitlayer-integrations[inngest]`` for the SDK itself.
7
+ """
8
+
9
+ from .steps import (
10
+ CHANNEL,
11
+ DEFAULT_ACTION_ID,
12
+ CommitLayerSteps,
13
+ RecordSpec,
14
+ build_action_body,
15
+ recorded_step,
16
+ recorded_step_async,
17
+ wait_for_verdict,
18
+ wait_for_verdict_async,
19
+ )
20
+
21
+ __all__ = [
22
+ "CHANNEL",
23
+ "DEFAULT_ACTION_ID",
24
+ "CommitLayerSteps",
25
+ "RecordSpec",
26
+ "build_action_body",
27
+ "recorded_step",
28
+ "recorded_step_async",
29
+ "wait_for_verdict",
30
+ "wait_for_verdict_async",
31
+ ]
@@ -0,0 +1,354 @@
1
+ """Record an Inngest step as a CommitLayer action, and wait for its verdict as steps.
2
+
3
+ Nothing here imports ``inngest``: the helpers take the function's ``ctx`` (``inngest.Context`` or
4
+ ``inngest.ContextSync``: anything with ``.step`` and ``.run_id``) and call ``step.run`` / ``step.sleep`` on it,
5
+ so the package imports without the SDK and the tests run against fakes.
6
+
7
+ Why the action is recorded *inside* the step function: Inngest memoises each step, so a replayed function
8
+ invocation returns the stored output without running the step again, and the action is not re-recorded. If a
9
+ step is retried before it succeeded, the same ``action_id`` (``inngest:{run_id}:{step_id}``) makes the retries
10
+ one action, because the API is idempotent on it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import inspect
17
+ import logging
18
+ import math
19
+ from collections.abc import Callable, Mapping
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime, timedelta, timezone
22
+ from typing import Any
23
+
24
+ from commitlayer import Client
25
+ from commitlayer_integrations.verdict import Verdict, fetch_verification, summarize_verification
26
+
27
+ __all__ = [
28
+ "CHANNEL",
29
+ "DEFAULT_ACTION_ID",
30
+ "CommitLayerSteps",
31
+ "RecordSpec",
32
+ "build_action_body",
33
+ "recorded_step",
34
+ "recorded_step_async",
35
+ "wait_for_verdict",
36
+ "wait_for_verdict_async",
37
+ ]
38
+
39
+ log = logging.getLogger("commitlayer.inngest")
40
+
41
+ CHANNEL = "inngest"
42
+ DEFAULT_ACTION_ID = "inngest:{run_id}:{step_id}"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class RecordSpec:
47
+ """What to record for one step: the agent's declared intent, taken from the function's own inputs.
48
+
49
+ ``action_id`` is a template over ``{run_id}``, ``{step_id}``, ``{subject_id}`` and every param by name.
50
+ """
51
+
52
+ contract_id: str
53
+ subject_id: str
54
+ params: Mapping[str, Any] = field(default_factory=dict)
55
+ action_id: str | None = None
56
+ action_type: str | None = None
57
+ subject_kind: str | None = None
58
+ workflow_id: str | None = None
59
+ agent_id: str | None = None
60
+ compensates: str | None = None
61
+ contract_version: int | None = None
62
+
63
+ @classmethod
64
+ def coerce(cls, value: RecordSpec | Mapping[str, Any]) -> RecordSpec:
65
+ if isinstance(value, RecordSpec):
66
+ return value
67
+ unknown = set(value) - set(cls.__dataclass_fields__)
68
+ if unknown:
69
+ raise ValueError(f"unknown record keys: {sorted(unknown)}")
70
+ return cls(**dict(value))
71
+
72
+
73
+ class _Fields(dict):
74
+ def __missing__(self, key: str) -> str:
75
+ raise ValueError(f"action_id template: unknown placeholder {{{key}}}")
76
+
77
+
78
+ def _now() -> str:
79
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") # noqa: UP017 - Python 3.10
80
+
81
+
82
+ def build_action_body(
83
+ record: RecordSpec | Mapping[str, Any],
84
+ *,
85
+ run_id: str,
86
+ step_id: str,
87
+ agent_id: str | None = None,
88
+ workflow_id: str | None = None,
89
+ requested_at: str | None = None,
90
+ ) -> dict[str, Any]:
91
+ """The exact ``POST /v1/actions`` body for one step. Pure; asserted as-is in the tests.
92
+
93
+ ``workflow_id`` / ``agent_id`` are defaults; values on ``record`` win. ``params`` always carries
94
+ ``inngest_run_id`` so the action can be traced back to the run.
95
+ """
96
+ r = RecordSpec.coerce(record)
97
+ if not r.subject_id:
98
+ raise ValueError("record.subject_id is required")
99
+ params = dict(r.params)
100
+ fields = _Fields(params, run_id=run_id, step_id=step_id, subject_id=str(r.subject_id))
101
+ action_id = (r.action_id or DEFAULT_ACTION_ID).format_map(fields)
102
+ if not action_id or len(action_id) > 120:
103
+ raise ValueError(f"action_id {action_id!r} must be 1-120 characters")
104
+ body: dict[str, Any] = {
105
+ "action_id": action_id,
106
+ "contract_id": r.contract_id,
107
+ "subject_id": str(r.subject_id),
108
+ "requested_at": requested_at or _now(),
109
+ "params": {**params, "inngest_run_id": run_id},
110
+ "channel": CHANNEL,
111
+ }
112
+ optional = (
113
+ ("workflow_id", r.workflow_id or workflow_id),
114
+ ("agent_id", r.agent_id or agent_id),
115
+ ("action_type", r.action_type),
116
+ ("subject_kind", r.subject_kind),
117
+ ("compensates", r.compensates),
118
+ ("contract_version", r.contract_version),
119
+ )
120
+ for key, value in optional:
121
+ if value is not None:
122
+ body[key] = value
123
+ return body
124
+
125
+
126
+ def _split(ctx_or_step: Any, run_id: str | None) -> tuple[Any, str]:
127
+ """``(step, run_id)`` from an Inngest context, or from a bare step plus an explicit ``run_id``."""
128
+ if hasattr(ctx_or_step, "step") and hasattr(ctx_or_step, "run_id"):
129
+ return ctx_or_step.step, run_id or str(ctx_or_step.run_id)
130
+ if run_id is None:
131
+ raise ValueError("pass the Inngest ctx, or a step together with run_id=")
132
+ return ctx_or_step, run_id
133
+
134
+
135
+ def _record(
136
+ client: Client,
137
+ body: dict[str, Any],
138
+ *,
139
+ step_id: str,
140
+ fail_open: bool,
141
+ on_recorded: Callable[[dict[str, Any]], None] | None,
142
+ ) -> dict[str, Any] | None:
143
+ """``POST /v1/actions`` (the SDK's ``_call``, so ``contract_version`` is kept). Fail-open by default."""
144
+ try:
145
+ rec = client._call("POST", "/v1/actions", body)
146
+ except Exception as e: # noqa: BLE001 - the business step must not depend on the verifier
147
+ if not fail_open:
148
+ raise
149
+ log.warning("commitlayer: action not recorded for step %s: %s", step_id, e)
150
+ return None
151
+ if on_recorded:
152
+ on_recorded(rec)
153
+ return rec
154
+
155
+
156
+ def recorded_step(
157
+ ctx_or_step: Any,
158
+ step_id: str,
159
+ fn: Callable[..., Any],
160
+ *args: Any,
161
+ client: Client,
162
+ record: RecordSpec | Mapping[str, Any],
163
+ agent_id: str | None = None,
164
+ workflow_id: str | None = None,
165
+ fail_open: bool = True,
166
+ on_recorded: Callable[[dict[str, Any]], None] | None = None,
167
+ run_id: str | None = None,
168
+ ) -> Any:
169
+ """``step.run(step_id, fn, *args)`` for a sync Inngest function, with the action recorded first.
170
+
171
+ The record happens inside the step, before ``fn``, so the request time precedes every effect and a
172
+ memoised replay records nothing. ``fn``'s return value is the step's output, unchanged; it is never sent
173
+ to CommitLayer. With ``fail_open=False`` a recording failure raises inside the step (Inngest retries it).
174
+ """
175
+ step, rid = _split(ctx_or_step, run_id)
176
+ spec = RecordSpec.coerce(record)
177
+
178
+ def handler() -> Any:
179
+ body = build_action_body(
180
+ spec, run_id=rid, step_id=step_id, agent_id=agent_id, workflow_id=workflow_id
181
+ )
182
+ _record(client, body, step_id=step_id, fail_open=fail_open, on_recorded=on_recorded)
183
+ return fn(*args)
184
+
185
+ return step.run(step_id, handler)
186
+
187
+
188
+ async def recorded_step_async(
189
+ ctx_or_step: Any,
190
+ step_id: str,
191
+ fn: Callable[..., Any],
192
+ *args: Any,
193
+ client: Client,
194
+ record: RecordSpec | Mapping[str, Any],
195
+ agent_id: str | None = None,
196
+ workflow_id: str | None = None,
197
+ fail_open: bool = True,
198
+ on_recorded: Callable[[dict[str, Any]], None] | None = None,
199
+ run_id: str | None = None,
200
+ ) -> Any:
201
+ """:func:`recorded_step` for an async Inngest function; ``fn`` may be sync or async."""
202
+ step, rid = _split(ctx_or_step, run_id)
203
+ spec = RecordSpec.coerce(record)
204
+
205
+ async def handler() -> Any:
206
+ body = build_action_body(
207
+ spec, run_id=rid, step_id=step_id, agent_id=agent_id, workflow_id=workflow_id
208
+ )
209
+ await asyncio.get_running_loop().run_in_executor(
210
+ None,
211
+ lambda: _record(client, body, step_id=step_id, fail_open=fail_open, on_recorded=on_recorded),
212
+ )
213
+ out = fn(*args)
214
+ return await out if inspect.isawaitable(out) else out
215
+
216
+ return await step.run(step_id, handler)
217
+
218
+
219
+ def _poll_plan(timeout_seconds: float, poll_seconds: float) -> tuple[int, timedelta]:
220
+ if poll_seconds <= 0:
221
+ raise ValueError("poll_seconds must be positive")
222
+ return max(1, math.ceil(timeout_seconds / poll_seconds)), timedelta(seconds=poll_seconds)
223
+
224
+
225
+ def wait_for_verdict(
226
+ ctx_or_step: Any,
227
+ action_ref: str,
228
+ *,
229
+ client: Client,
230
+ timeout_seconds: float = 900.0,
231
+ poll_seconds: float = 15.0,
232
+ step_prefix: str | None = None,
233
+ ) -> Verdict:
234
+ """Wait, as Inngest steps, until the verification of ``action_ref`` settles or the timeout passes.
235
+
236
+ Each poll is its own ``step.run`` (memoised, so a replay never re-fetches a settled verdict) followed by a
237
+ ``step.sleep`` of ``poll_seconds`` that suspends the function run instead of holding a worker. The wait is
238
+ ``ceil(timeout_seconds / poll_seconds)`` polls, so the timeout is a lower bound: scheduler latency on each
239
+ resume adds to it. Returns a :class:`~commitlayer_integrations.verdict.Verdict`; ``passed`` is true only
240
+ for a settled PASS under both lenses, ``timed_out`` when the deadline passed while still pending.
241
+ """
242
+ step, _ = _split(ctx_or_step, run_id="")
243
+ prefix = step_prefix or f"commitlayer_verify:{action_ref}"
244
+ polls, pause = _poll_plan(timeout_seconds, poll_seconds)
245
+ verdict: Verdict | None = None
246
+ for i in range(polls):
247
+ verdict = step.run(
248
+ f"{prefix}:poll:{i}",
249
+ lambda: dict(summarize_verification(fetch_verification(client, action_ref, poll_seconds))),
250
+ )
251
+ if verdict["settled"]:
252
+ return verdict
253
+ if i < polls - 1:
254
+ step.sleep(f"{prefix}:sleep:{i}", pause)
255
+ assert verdict is not None
256
+ verdict["timed_out"] = True
257
+ return verdict
258
+
259
+
260
+ async def wait_for_verdict_async(
261
+ ctx_or_step: Any,
262
+ action_ref: str,
263
+ *,
264
+ client: Client,
265
+ timeout_seconds: float = 900.0,
266
+ poll_seconds: float = 15.0,
267
+ step_prefix: str | None = None,
268
+ ) -> Verdict:
269
+ """:func:`wait_for_verdict` for an async Inngest function."""
270
+ step, _ = _split(ctx_or_step, run_id="")
271
+ prefix = step_prefix or f"commitlayer_verify:{action_ref}"
272
+ polls, pause = _poll_plan(timeout_seconds, poll_seconds)
273
+
274
+ async def fetch() -> dict[str, Any]:
275
+ payload = await asyncio.get_running_loop().run_in_executor(
276
+ None, fetch_verification, client, action_ref, poll_seconds
277
+ )
278
+ return dict(summarize_verification(payload))
279
+
280
+ verdict: Verdict | None = None
281
+ for i in range(polls):
282
+ verdict = await step.run(f"{prefix}:poll:{i}", fetch)
283
+ if verdict["settled"]:
284
+ return verdict
285
+ if i < polls - 1:
286
+ await step.sleep(f"{prefix}:sleep:{i}", pause)
287
+ assert verdict is not None
288
+ verdict["timed_out"] = True
289
+ return verdict
290
+
291
+
292
+ class CommitLayerSteps:
293
+ """A client plus defaults, bound once per Inngest function.
294
+
295
+ ``fn_id`` is the Inngest function id and becomes the default ``workflow_id`` (the SDK's ``ctx`` does not
296
+ carry it, so it is given here). ``run``/``run_async`` wrap :func:`recorded_step`;
297
+ ``wait_for_verdict``/``wait_for_verdict_async`` wrap the verdict wait.
298
+ """
299
+
300
+ def __init__(
301
+ self,
302
+ client: Client,
303
+ *,
304
+ fn_id: str | None = None,
305
+ agent_id: str | None = None,
306
+ fail_open: bool = True,
307
+ default_timeout_seconds: float = 900.0,
308
+ poll_seconds: float = 15.0,
309
+ on_recorded: Callable[[dict[str, Any]], None] | None = None,
310
+ ) -> None:
311
+ self.client = client
312
+ self.fn_id = fn_id
313
+ self.agent_id = agent_id or client.agent_id
314
+ self.fail_open = fail_open
315
+ self.default_timeout_seconds = default_timeout_seconds
316
+ self.poll_seconds = poll_seconds
317
+ self.on_recorded = on_recorded
318
+
319
+ def _kw(self) -> dict[str, Any]:
320
+ return {
321
+ "client": self.client,
322
+ "agent_id": self.agent_id,
323
+ "workflow_id": self.fn_id,
324
+ "fail_open": self.fail_open,
325
+ "on_recorded": self.on_recorded,
326
+ }
327
+
328
+ def run(self, ctx: Any, step_id: str, fn: Callable[..., Any], *args: Any, **record: Any) -> Any:
329
+ return recorded_step(ctx, step_id, fn, *args, record=record, **self._kw())
330
+
331
+ async def run_async(
332
+ self, ctx: Any, step_id: str, fn: Callable[..., Any], *args: Any, **record: Any
333
+ ) -> Any:
334
+ return await recorded_step_async(ctx, step_id, fn, *args, record=record, **self._kw())
335
+
336
+ def wait_for_verdict(self, ctx: Any, action_ref: str, *, timeout_seconds: float | None = None) -> Verdict:
337
+ return wait_for_verdict(
338
+ ctx,
339
+ action_ref,
340
+ client=self.client,
341
+ timeout_seconds=self.default_timeout_seconds if timeout_seconds is None else timeout_seconds,
342
+ poll_seconds=self.poll_seconds,
343
+ )
344
+
345
+ async def wait_for_verdict_async(
346
+ self, ctx: Any, action_ref: str, *, timeout_seconds: float | None = None
347
+ ) -> Verdict:
348
+ return await wait_for_verdict_async(
349
+ ctx,
350
+ action_ref,
351
+ client=self.client,
352
+ timeout_seconds=self.default_timeout_seconds if timeout_seconds is None else timeout_seconds,
353
+ poll_seconds=self.poll_seconds,
354
+ )
@@ -0,0 +1,9 @@
1
+ """Code shared by the CommitLayer runtime integrations (``commitlayer_temporal``, ``commitlayer_inngest``).
2
+
3
+ Nothing here imports a workflow SDK; it only shapes what the CommitLayer API returns.
4
+ """
5
+
6
+ from .verdict import Verdict, poll_until_settled, summarize_verification
7
+
8
+ __all__ = ["Verdict", "poll_until_settled", "summarize_verification", "__version__"]
9
+ __version__ = "0.1.0"