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.
- commitlayer_integrations-0.1.0/LICENSE +21 -0
- commitlayer_integrations-0.1.0/PKG-INFO +84 -0
- commitlayer_integrations-0.1.0/README.md +41 -0
- commitlayer_integrations-0.1.0/commitlayer_crewai/__init__.py +83 -0
- commitlayer_integrations-0.1.0/commitlayer_inngest/__init__.py +31 -0
- commitlayer_integrations-0.1.0/commitlayer_inngest/steps.py +354 -0
- commitlayer_integrations-0.1.0/commitlayer_integrations/__init__.py +9 -0
- commitlayer_integrations-0.1.0/commitlayer_integrations/tools.py +244 -0
- commitlayer_integrations-0.1.0/commitlayer_integrations/verdict.py +119 -0
- commitlayer_integrations-0.1.0/commitlayer_integrations.egg-info/PKG-INFO +84 -0
- commitlayer_integrations-0.1.0/commitlayer_integrations.egg-info/SOURCES.txt +27 -0
- commitlayer_integrations-0.1.0/commitlayer_integrations.egg-info/dependency_links.txt +1 -0
- commitlayer_integrations-0.1.0/commitlayer_integrations.egg-info/requires.txt +26 -0
- commitlayer_integrations-0.1.0/commitlayer_integrations.egg-info/top_level.txt +7 -0
- commitlayer_integrations-0.1.0/commitlayer_langgraph/__init__.py +111 -0
- commitlayer_integrations-0.1.0/commitlayer_openai_agents/__init__.py +71 -0
- commitlayer_integrations-0.1.0/commitlayer_pydantic_ai/__init__.py +57 -0
- commitlayer_integrations-0.1.0/commitlayer_temporal/__init__.py +61 -0
- commitlayer_integrations-0.1.0/commitlayer_temporal/interceptor.py +127 -0
- commitlayer_integrations-0.1.0/commitlayer_temporal/mapping.py +207 -0
- commitlayer_integrations-0.1.0/commitlayer_temporal/verifier.py +108 -0
- commitlayer_integrations-0.1.0/pyproject.toml +43 -0
- commitlayer_integrations-0.1.0/setup.cfg +4 -0
- commitlayer_integrations-0.1.0/tests/test_frameworks.py +115 -0
- commitlayer_integrations-0.1.0/tests/test_inngest.py +327 -0
- commitlayer_integrations-0.1.0/tests/test_temporal_interceptor.py +270 -0
- commitlayer_integrations-0.1.0/tests/test_temporal_mapping.py +198 -0
- commitlayer_integrations-0.1.0/tests/test_tools.py +86 -0
- 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"
|