databricks-agentbricks 0.2.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.
Files changed (159) hide show
  1. databricks_agentbricks/__init__.py +1 -0
  2. databricks_agentbricks/_group.py +217 -0
  3. databricks_agentbricks/agent_project.py +657 -0
  4. databricks_agentbricks/app_resources.py +139 -0
  5. databricks_agentbricks/cli/__init__.py +12 -0
  6. databricks_agentbricks/cli/app.py +108 -0
  7. databricks_agentbricks/cli/app_auth.py +198 -0
  8. databricks_agentbricks/cli/auth.py +142 -0
  9. databricks_agentbricks/cli/deploy.py +954 -0
  10. databricks_agentbricks/cli/dev.py +310 -0
  11. databricks_agentbricks/cli/endpoint.py +145 -0
  12. databricks_agentbricks/cli/endpoint_examples.py +33 -0
  13. databricks_agentbricks/cli/endpoint_output.py +53 -0
  14. databricks_agentbricks/cli/endpoint_request.py +71 -0
  15. databricks_agentbricks/cli/endpoint_transport.py +148 -0
  16. databricks_agentbricks/cli/help.py +467 -0
  17. databricks_agentbricks/cli/init.py +496 -0
  18. databricks_agentbricks/cli/mcp.py +75 -0
  19. databricks_agentbricks/cli/memory.py +520 -0
  20. databricks_agentbricks/cli/sandbox.py +823 -0
  21. databricks_agentbricks/cli/sessions.py +588 -0
  22. databricks_agentbricks/cli/tools.py +456 -0
  23. databricks_agentbricks/cli/tracing.py +724 -0
  24. databricks_agentbricks/databricks_cli.py +38 -0
  25. databricks_agentbricks/errors.py +114 -0
  26. databricks_agentbricks/lakebase_runtime_store.py +107 -0
  27. databricks_agentbricks/legacy_lakebase_runtime_store.py +74 -0
  28. databricks_agentbricks/project_config.py +157 -0
  29. databricks_agentbricks/project_types.py +55 -0
  30. databricks_agentbricks/py.typed +0 -0
  31. databricks_agentbricks/render.py +349 -0
  32. databricks_agentbricks/templates/agent-bricks-migrate/SKILL.md +101 -0
  33. databricks_agentbricks/templates/agent-langgraph/.env.example +36 -0
  34. databricks_agentbricks/templates/agent-langgraph/.gitignore +199 -0
  35. databricks_agentbricks/templates/agent-langgraph/AGENTKIT_CONTRACT.md +95 -0
  36. databricks_agentbricks/templates/agent-langgraph/AGENTS.md +31 -0
  37. databricks_agentbricks/templates/agent-langgraph/CLAUDE.md +1 -0
  38. databricks_agentbricks/templates/agent-langgraph/README.md +141 -0
  39. databricks_agentbricks/templates/agent-langgraph/agent/__init__.py +0 -0
  40. databricks_agentbricks/templates/agent-langgraph/agent/agent.py +177 -0
  41. databricks_agentbricks/templates/agent-langgraph/agent/mcps.py +27 -0
  42. databricks_agentbricks/templates/agent-langgraph/agent/tools/__init__.py +24 -0
  43. databricks_agentbricks/templates/agent-langgraph/agent/tools/sample_tool.py +15 -0
  44. databricks_agentbricks/templates/agent-langgraph/agent/tools/send_message.py +17 -0
  45. databricks_agentbricks/templates/agent-langgraph/app.yaml +15 -0
  46. databricks_agentbricks/templates/agent-langgraph/pyproject.toml +42 -0
  47. databricks_agentbricks/templates/agent-langgraph/runtime/__init__.py +0 -0
  48. databricks_agentbricks/templates/agent-langgraph/runtime/adapter.py +144 -0
  49. databricks_agentbricks/templates/agent-langgraph/runtime/main.py +25 -0
  50. databricks_agentbricks/templates/agent-langgraph/tests/__init__.py +0 -0
  51. databricks_agentbricks/templates/agent-langgraph/tests/test_agent.py +211 -0
  52. databricks_agentbricks/templates/agent-langgraph/tests/test_runtime.py +19 -0
  53. databricks_agentbricks/templates/agent-langgraph/tests/test_workspace.py +28 -0
  54. databricks_agentbricks/templates/agent-openai/.env.example +37 -0
  55. databricks_agentbricks/templates/agent-openai/.gitignore +199 -0
  56. databricks_agentbricks/templates/agent-openai/AGENTKIT_CONTRACT.md +107 -0
  57. databricks_agentbricks/templates/agent-openai/AGENTS.md +76 -0
  58. databricks_agentbricks/templates/agent-openai/CLAUDE.md +1 -0
  59. databricks_agentbricks/templates/agent-openai/README.md +143 -0
  60. databricks_agentbricks/templates/agent-openai/agent/__init__.py +0 -0
  61. databricks_agentbricks/templates/agent-openai/agent/agent.py +205 -0
  62. databricks_agentbricks/templates/agent-openai/agent/mcps.py +28 -0
  63. databricks_agentbricks/templates/agent-openai/agent/tools/__init__.py +24 -0
  64. databricks_agentbricks/templates/agent-openai/agent/tools/sample_tool.py +15 -0
  65. databricks_agentbricks/templates/agent-openai/agent/tools/send_message.py +18 -0
  66. databricks_agentbricks/templates/agent-openai/app.yaml +15 -0
  67. databricks_agentbricks/templates/agent-openai/pyproject.toml +42 -0
  68. databricks_agentbricks/templates/agent-openai/runtime/__init__.py +0 -0
  69. databricks_agentbricks/templates/agent-openai/runtime/adapter.py +188 -0
  70. databricks_agentbricks/templates/agent-openai/runtime/main.py +25 -0
  71. databricks_agentbricks/templates/agent-openai/tests/__init__.py +0 -0
  72. databricks_agentbricks/templates/agent-openai/tests/test_agent.py +326 -0
  73. databricks_agentbricks/templates/agent-openai/tests/test_runtime.py +19 -0
  74. databricks_agentbricks/templates/agent-openai/tests/test_workspace.py +28 -0
  75. databricks_agentbricks/templates/custom-agent-langgraph/.env.example +1 -0
  76. databricks_agentbricks/templates/custom-agent-langgraph/.gitignore +11 -0
  77. databricks_agentbricks/templates/custom-agent-langgraph/README.md +22 -0
  78. databricks_agentbricks/templates/custom-agent-langgraph/agent/__init__.py +0 -0
  79. databricks_agentbricks/templates/custom-agent-langgraph/agent/agent.py +27 -0
  80. databricks_agentbricks/templates/custom-agent-langgraph/app.yaml +1 -0
  81. databricks_agentbricks/templates/custom-agent-langgraph/pyproject.toml +38 -0
  82. databricks_agentbricks/templates/custom-agent-langgraph/runtime/__init__.py +0 -0
  83. databricks_agentbricks/templates/custom-agent-langgraph/runtime/main.py +31 -0
  84. databricks_agentbricks/templates/custom-agent-langgraph/tests/test_runtime.py +29 -0
  85. databricks_agentbricks/templates/custom-agent-openai/.env.example +1 -0
  86. databricks_agentbricks/templates/custom-agent-openai/.gitignore +11 -0
  87. databricks_agentbricks/templates/custom-agent-openai/README.md +22 -0
  88. databricks_agentbricks/templates/custom-agent-openai/agent/__init__.py +0 -0
  89. databricks_agentbricks/templates/custom-agent-openai/agent/agent.py +29 -0
  90. databricks_agentbricks/templates/custom-agent-openai/app.yaml +1 -0
  91. databricks_agentbricks/templates/custom-agent-openai/pyproject.toml +38 -0
  92. databricks_agentbricks/templates/custom-agent-openai/runtime/__init__.py +0 -0
  93. databricks_agentbricks/templates/custom-agent-openai/runtime/main.py +31 -0
  94. databricks_agentbricks/templates/custom-agent-openai/tests/test_runtime.py +29 -0
  95. databricks_agentbricks/templates/sandbox_mcp.py +39 -0
  96. databricks_agentbricks/templates/sandbox_mcp_langgraph.py +39 -0
  97. databricks_agentbricks/templates/ui/agent-langgraph/CHAT_APP.md +49 -0
  98. databricks_agentbricks/templates/ui/agent-langgraph/runtime/main.py +26 -0
  99. databricks_agentbricks/templates/ui/agent-langgraph/runtime/ui.py +528 -0
  100. databricks_agentbricks/templates/ui/agent-langgraph/tests/test_demo_ui.py +542 -0
  101. databricks_agentbricks/templates/ui/agent-langgraph/ui/agentbricks-mark.png +0 -0
  102. databricks_agentbricks/templates/ui/agent-langgraph/ui/app.js +1464 -0
  103. databricks_agentbricks/templates/ui/agent-langgraph/ui/index.html +281 -0
  104. databricks_agentbricks/templates/ui/agent-langgraph/ui/styles.css +1913 -0
  105. databricks_agentbricks/templates/ui/agent-openai/CHAT_APP.md +54 -0
  106. databricks_agentbricks/templates/ui/agent-openai/runtime/main.py +26 -0
  107. databricks_agentbricks/templates/ui/agent-openai/runtime/ui.py +521 -0
  108. databricks_agentbricks/templates/ui/agent-openai/tests/test_demo_ui.py +512 -0
  109. databricks_agentbricks/templates/ui/agent-openai/ui/agentbricks-mark.png +0 -0
  110. databricks_agentbricks/templates/ui/agent-openai/ui/app.js +1428 -0
  111. databricks_agentbricks/templates/ui/agent-openai/ui/index.html +281 -0
  112. databricks_agentbricks/templates/ui/agent-openai/ui/styles.css +1913 -0
  113. databricks_agentbricks/theme.py +72 -0
  114. databricks_agentbricks-0.2.0.dist-info/METADATA +897 -0
  115. databricks_agentbricks-0.2.0.dist-info/RECORD +159 -0
  116. databricks_agentbricks-0.2.0.dist-info/WHEEL +4 -0
  117. databricks_agentbricks-0.2.0.dist-info/entry_points.txt +2 -0
  118. databricks_agentbricks-0.2.0.dist-info/licenses/NOTICE +39 -0
  119. databricks_agentkit/__init__.py +67 -0
  120. databricks_agentkit/_api_client.py +673 -0
  121. databricks_agentkit/_pagination.py +25 -0
  122. databricks_agentkit/client.py +31 -0
  123. databricks_agentkit/langgraph/__init__.py +98 -0
  124. databricks_agentkit/langgraph/genie.py +50 -0
  125. databricks_agentkit/langgraph/mcp.py +216 -0
  126. databricks_agentkit/langgraph/memory.py +67 -0
  127. databricks_agentkit/langgraph/session_store.py +485 -0
  128. databricks_agentkit/memory_store.py +371 -0
  129. databricks_agentkit/models.py +330 -0
  130. databricks_agentkit/openai/__init__.py +93 -0
  131. databricks_agentkit/openai/genie.py +50 -0
  132. databricks_agentkit/openai/mcp.py +189 -0
  133. databricks_agentkit/openai/memory.py +67 -0
  134. databricks_agentkit/openai/sessions.py +157 -0
  135. databricks_agentkit/py.typed +0 -0
  136. databricks_agentkit/runtime/ARCHITECTURE.md +64 -0
  137. databricks_agentkit/runtime/README.md +184 -0
  138. databricks_agentkit/runtime/__init__.py +69 -0
  139. databricks_agentkit/runtime/app.py +316 -0
  140. databricks_agentkit/runtime/auth.py +149 -0
  141. databricks_agentkit/runtime/durability/__init__.py +1 -0
  142. databricks_agentkit/runtime/durability/execution.py +133 -0
  143. databricks_agentkit/runtime/durability/heartbeat.py +64 -0
  144. databricks_agentkit/runtime/durability/lakebase_runtime_store.py +648 -0
  145. databricks_agentkit/runtime/durability/recovery.py +70 -0
  146. databricks_agentkit/runtime/durability/store.py +33 -0
  147. databricks_agentkit/runtime/execution.py +160 -0
  148. databricks_agentkit/runtime/genie.py +224 -0
  149. databricks_agentkit/runtime/mcp_auth.py +114 -0
  150. databricks_agentkit/runtime/model_services.py +90 -0
  151. databricks_agentkit/runtime/runtime.py +273 -0
  152. databricks_agentkit/runtime/session_store_client.py +120 -0
  153. databricks_agentkit/runtime/store.py +287 -0
  154. databricks_agentkit/runtime/tool_manifest.py +223 -0
  155. databricks_agentkit/runtime/tracing.py +71 -0
  156. databricks_agentkit/runtime/types.py +118 -0
  157. databricks_agentkit/runtime/workspace.py +27 -0
  158. databricks_agentkit/session_store.py +434 -0
  159. databricks_agentkit/timefmt.py +85 -0
@@ -0,0 +1,648 @@
1
+ """Lakebase-backed Runtime Store with heartbeat-based recovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import time
9
+ from collections.abc import Mapping
10
+ from threading import Lock
11
+ from typing import TYPE_CHECKING, Any, Protocol
12
+
13
+ from sqlalchemy import URL, event, text
14
+ from sqlalchemy.engine import RowMapping
15
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
16
+
17
+ from databricks_agentkit.runtime.durability.store import DurableRuntimeStore
18
+ from databricks_agentkit.runtime.store import DEFAULT_RUNTIME_STORE_SCHEMA
19
+ from databricks_agentkit.runtime.types import (
20
+ Invocation,
21
+ InvocationConflictError,
22
+ InvocationEvent,
23
+ InvocationStatus,
24
+ JsonObject,
25
+ JsonValue,
26
+ )
27
+
28
+ if TYPE_CHECKING:
29
+ from databricks.sdk import WorkspaceClient
30
+
31
+
32
+ class _AsyncLakebase(Protocol):
33
+ engine: AsyncEngine
34
+
35
+ async def create_schema(self) -> None: ...
36
+
37
+
38
+ _SCHEMA_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
39
+ _TOKEN_CACHE_SECONDS = 15 * 60
40
+ _POOL_RECYCLE_SECONDS = 14 * 60
41
+
42
+
43
+ def _serialize_json_value(value: JsonValue) -> str:
44
+ return json.dumps(value, allow_nan=False)
45
+
46
+
47
+ def _validate_invocation_id(invocation_id: str) -> None:
48
+ if not invocation_id:
49
+ raise ValueError("invocation_id must not be empty")
50
+
51
+
52
+ class _AppsPostgresLakebase:
53
+ """SQLAlchemy connection for a Databricks Apps Postgres resource.
54
+
55
+ Apps injects the selected resource's connection coordinates through the standard ``PG*``
56
+ variables. The endpoint resource path is kept separately because OAuth credentials must be
57
+ refreshed through the Databricks Postgres API.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ endpoint: str,
64
+ host: str,
65
+ port: int,
66
+ database: str,
67
+ username: str,
68
+ sslmode: str,
69
+ workspace_client: WorkspaceClient | None,
70
+ schema: str,
71
+ ) -> None:
72
+ if not endpoint or not host or not database or not username:
73
+ raise ValueError("endpoint, host, database, and username must not be empty")
74
+ if port <= 0:
75
+ raise ValueError("port must be positive")
76
+
77
+ if workspace_client is None:
78
+ from databricks.sdk import WorkspaceClient
79
+
80
+ workspace_client = WorkspaceClient()
81
+
82
+ self._endpoint = endpoint
83
+ self._workspace_client = workspace_client
84
+ self._schema = schema
85
+ self._token: str | None = None
86
+ self._token_time = 0.0
87
+ self._token_lock = Lock()
88
+
89
+ url = URL.create(
90
+ drivername="postgresql+psycopg",
91
+ username=username,
92
+ host=host,
93
+ port=port,
94
+ database=database,
95
+ )
96
+ self.engine: AsyncEngine = create_async_engine(
97
+ url,
98
+ pool_recycle=_POOL_RECYCLE_SECONDS,
99
+ pool_pre_ping=True,
100
+ connect_args={"sslmode": sslmode},
101
+ )
102
+
103
+ @event.listens_for(self.engine.sync_engine, "do_connect")
104
+ def inject_token(dialect, connection_record, args, params) -> None:
105
+ params["password"] = self._get_token()
106
+
107
+ async def create_schema(self) -> None:
108
+ async with self.engine.begin() as connection:
109
+ await connection.execute(text(f"CREATE SCHEMA IF NOT EXISTS {self._schema}"))
110
+
111
+ def _get_token(self) -> str:
112
+ with self._token_lock:
113
+ if self._token and time.monotonic() - self._token_time < _TOKEN_CACHE_SECONDS:
114
+ return self._token
115
+ credential = self._workspace_client.postgres.generate_database_credential(
116
+ endpoint=self._endpoint
117
+ )
118
+ token = getattr(credential, "token", None)
119
+ if not token:
120
+ raise RuntimeError(
121
+ f"failed to generate a database credential for endpoint {self._endpoint!r}"
122
+ )
123
+ self._token = token
124
+ self._token_time = time.monotonic()
125
+ return token
126
+
127
+
128
+ class LakebaseDurableRuntimeStore(DurableRuntimeStore):
129
+ """Persist invocation state, attempt leases, and ordered events in Lakebase.
130
+
131
+ The ``invocations`` table is the source of truth for idempotency and lifecycle state. Conditional
132
+ SQL updates claim an attempt and fence completion, failure, heartbeat, and event writes by its
133
+ attempt number. The ``invocation_events`` table provides an ordered replay cursor. Because every
134
+ app replica connects to the same schema, another replica can detect a stale heartbeat, claim the
135
+ next attempt, and continue after process or pod loss.
136
+
137
+ This store requires a Lakebase Postgres database. ``ab deploy`` reuses or provisions a
138
+ dedicated app-owned database, then assigns the app its own schema. ``ab dev`` uses
139
+ ``InMemoryRuntimeStore`` instead.
140
+ """
141
+
142
+ def __init__(
143
+ self,
144
+ *,
145
+ autoscaling_endpoint: str | None = None,
146
+ project: str | None = None,
147
+ branch: str | None = None,
148
+ database: str | None = None,
149
+ username: str | None = None,
150
+ workspace_client: WorkspaceClient | None = None,
151
+ schema: str = DEFAULT_RUNTIME_STORE_SCHEMA,
152
+ lakebase: _AsyncLakebase | None = None,
153
+ ) -> None:
154
+ if not _SCHEMA_NAME.fullmatch(schema):
155
+ raise ValueError(f"invalid Runtime Store schema name: {schema!r}")
156
+
157
+ if lakebase is None:
158
+ from databricks_ai_bridge.lakebase import DEFAULT_DATABASE, AsyncLakebaseSQLAlchemy
159
+
160
+ autoscaling_endpoint = autoscaling_endpoint or os.getenv(
161
+ "LAKEBASE_AUTOSCALING_ENDPOINT"
162
+ )
163
+ if autoscaling_endpoint is None:
164
+ project = project or os.getenv("LAKEBASE_AUTOSCALING_PROJECT")
165
+ branch = branch or os.getenv("LAKEBASE_AUTOSCALING_BRANCH")
166
+ lakebase = AsyncLakebaseSQLAlchemy(
167
+ autoscaling_endpoint=autoscaling_endpoint,
168
+ project=project,
169
+ branch=branch,
170
+ database=database or DEFAULT_DATABASE,
171
+ username=username,
172
+ workspace_client=workspace_client,
173
+ schema=schema,
174
+ pool_pre_ping=True,
175
+ )
176
+
177
+ self._lakebase = lakebase
178
+ self._engine = lakebase.engine
179
+ self._table = f"{schema}.invocations"
180
+ self._events_table = f"{schema}.invocation_events"
181
+
182
+ @classmethod
183
+ def from_app_resource(
184
+ cls,
185
+ *,
186
+ endpoint: str,
187
+ host: str | None = None,
188
+ port: int | None = None,
189
+ database: str | None = None,
190
+ username: str | None = None,
191
+ sslmode: str | None = None,
192
+ workspace_client: WorkspaceClient | None = None,
193
+ schema: str = DEFAULT_RUNTIME_STORE_SCHEMA,
194
+ ) -> "LakebaseDurableRuntimeStore":
195
+ """Use connection coordinates injected for a Databricks Apps Postgres resource."""
196
+ if not _SCHEMA_NAME.fullmatch(schema):
197
+ raise ValueError(f"invalid Runtime Store schema name: {schema!r}")
198
+ host = host or os.getenv("PGHOST")
199
+ database = database or os.getenv("PGDATABASE")
200
+ username = username or os.getenv("PGUSER")
201
+ if port is None:
202
+ raw_port = os.getenv("PGPORT")
203
+ try:
204
+ port = int(raw_port or "")
205
+ except ValueError as exc:
206
+ raise RuntimeError("PGPORT must be an integer") from exc
207
+ missing = [
208
+ name
209
+ for name, value in {
210
+ "PGHOST": host,
211
+ "PGPORT": port,
212
+ "PGDATABASE": database,
213
+ "PGUSER": username,
214
+ }.items()
215
+ if not value
216
+ ]
217
+ if missing:
218
+ raise RuntimeError(
219
+ "Databricks Apps Postgres resource is missing: " + ", ".join(missing)
220
+ )
221
+ assert host is not None
222
+ assert port is not None
223
+ assert database is not None
224
+ assert username is not None
225
+ lakebase = _AppsPostgresLakebase(
226
+ endpoint=endpoint,
227
+ host=host,
228
+ port=port,
229
+ database=database,
230
+ username=username,
231
+ sslmode=sslmode or os.getenv("PGSSLMODE", "require"),
232
+ workspace_client=workspace_client,
233
+ schema=schema,
234
+ )
235
+ return cls(schema=schema, lakebase=lakebase)
236
+
237
+ @classmethod
238
+ def from_managed_runtime_store(
239
+ cls,
240
+ *,
241
+ branch: str,
242
+ database: str,
243
+ username: str,
244
+ workspace_client: WorkspaceClient | None = None,
245
+ schema: str = DEFAULT_RUNTIME_STORE_SCHEMA,
246
+ ) -> "LakebaseDurableRuntimeStore":
247
+ """Connect using the backend coordinates returned by the Runtime Store API."""
248
+ return cls(
249
+ branch=branch,
250
+ database=database,
251
+ username=username,
252
+ workspace_client=workspace_client,
253
+ schema=schema,
254
+ )
255
+
256
+ async def initialize(self) -> None:
257
+ await self._lakebase.create_schema()
258
+ async with self._engine.begin() as connection:
259
+ await connection.execute(
260
+ text(
261
+ f"""
262
+ CREATE TABLE IF NOT EXISTS {self._table} (
263
+ invocation_id TEXT PRIMARY KEY,
264
+ status TEXT NOT NULL,
265
+ attempt INTEGER NOT NULL DEFAULT 0,
266
+ heartbeat_at TIMESTAMPTZ,
267
+ request JSONB NOT NULL,
268
+ response JSONB,
269
+ CHECK (status IN ('QUEUED', 'ACTIVE', 'COMPLETED', 'FAILED'))
270
+ )
271
+ """
272
+ )
273
+ )
274
+ await connection.execute(
275
+ text(
276
+ f"""
277
+ CREATE TABLE IF NOT EXISTS {self._events_table} (
278
+ sequence_number BIGSERIAL PRIMARY KEY,
279
+ invocation_id TEXT NOT NULL
280
+ REFERENCES {self._table}(invocation_id) ON DELETE CASCADE,
281
+ attempt INTEGER NOT NULL,
282
+ event JSONB NOT NULL,
283
+ CHECK (jsonb_typeof(event) = 'object')
284
+ )
285
+ """
286
+ )
287
+ )
288
+ await connection.execute(
289
+ text(
290
+ f"""
291
+ CREATE INDEX IF NOT EXISTS invocation_events_replay_idx
292
+ ON {self._events_table} (invocation_id, sequence_number)
293
+ """
294
+ )
295
+ )
296
+ await connection.execute(
297
+ text(
298
+ f"""
299
+ CREATE INDEX IF NOT EXISTS invocations_recovery_idx
300
+ ON {self._table} (status, heartbeat_at)
301
+ WHERE status IN ('QUEUED', 'ACTIVE')
302
+ """
303
+ )
304
+ )
305
+
306
+ async def close(self) -> None:
307
+ await self._engine.dispose()
308
+
309
+ async def accept(self, invocation_id: str, request: JsonValue) -> Invocation:
310
+ _validate_invocation_id(invocation_id)
311
+ serialized_request = _serialize_json_value(request)
312
+ async with self._engine.begin() as connection:
313
+ await connection.execute(
314
+ text(
315
+ f"""
316
+ INSERT INTO {self._table} (invocation_id, status, request)
317
+ VALUES (:invocation_id, 'QUEUED', CAST(:request AS JSONB))
318
+ ON CONFLICT (invocation_id) DO NOTHING
319
+ """
320
+ ),
321
+ {"invocation_id": invocation_id, "request": serialized_request},
322
+ )
323
+ row = (
324
+ (
325
+ await connection.execute(
326
+ text(
327
+ f"""
328
+ SELECT invocation_id, status, attempt,
329
+ request::TEXT AS request_json,
330
+ response::TEXT AS response_json
331
+ FROM {self._table}
332
+ WHERE invocation_id=:invocation_id
333
+ """
334
+ ),
335
+ {"invocation_id": invocation_id},
336
+ )
337
+ )
338
+ .mappings()
339
+ .one()
340
+ )
341
+
342
+ state = self._to_invocation(row)
343
+ if state.request != request:
344
+ raise InvocationConflictError(
345
+ f"invocation {invocation_id!r} was already accepted with a different request"
346
+ )
347
+ return state
348
+
349
+ async def get(self, invocation_id: str) -> Invocation | None:
350
+ _validate_invocation_id(invocation_id)
351
+ async with self._engine.connect() as connection:
352
+ row = (
353
+ (
354
+ await connection.execute(
355
+ text(
356
+ f"""
357
+ SELECT invocation_id, status, attempt,
358
+ request::TEXT AS request_json,
359
+ response::TEXT AS response_json
360
+ FROM {self._table}
361
+ WHERE invocation_id=:invocation_id
362
+ """
363
+ ),
364
+ {"invocation_id": invocation_id},
365
+ )
366
+ )
367
+ .mappings()
368
+ .one_or_none()
369
+ )
370
+ return self._to_invocation(row) if row is not None else None
371
+
372
+ async def queued_invocation_ids(self) -> list[str]:
373
+ async with self._engine.connect() as connection:
374
+ rows = (
375
+ (
376
+ await connection.execute(
377
+ text(
378
+ f"""
379
+ SELECT invocation_id
380
+ FROM {self._table}
381
+ WHERE status='QUEUED'
382
+ ORDER BY invocation_id
383
+ """
384
+ )
385
+ )
386
+ )
387
+ .scalars()
388
+ .all()
389
+ )
390
+ return list(rows)
391
+
392
+ async def stale_invocation_ids(self, stale_seconds: float) -> list[str]:
393
+ async with self._engine.connect() as connection:
394
+ rows = (
395
+ (
396
+ await connection.execute(
397
+ text(
398
+ f"""
399
+ SELECT invocation_id
400
+ FROM {self._table}
401
+ WHERE status='ACTIVE' AND (
402
+ heartbeat_at IS NULL
403
+ OR heartbeat_at < NOW() - (:stale * INTERVAL '1 second')
404
+ )
405
+ ORDER BY heartbeat_at NULLS FIRST
406
+ """
407
+ ),
408
+ {"stale": stale_seconds},
409
+ )
410
+ )
411
+ .scalars()
412
+ .all()
413
+ )
414
+ return list(rows)
415
+
416
+ async def claim(self, invocation_id: str) -> Invocation | None:
417
+ """Claim a newly queued invocation for its first attempt."""
418
+ return await self._claim(invocation_id, stale_seconds=None)
419
+
420
+ async def claim_recoverable(
421
+ self,
422
+ invocation_id: str,
423
+ stale_seconds: float,
424
+ ) -> Invocation | None:
425
+ """Replace a stale active attempt."""
426
+ return await self._claim(invocation_id, stale_seconds=stale_seconds)
427
+
428
+ async def _claim(
429
+ self,
430
+ invocation_id: str,
431
+ *,
432
+ stale_seconds: float | None,
433
+ ) -> Invocation | None:
434
+ _validate_invocation_id(invocation_id)
435
+ eligibility = "status='QUEUED'"
436
+ parameters: dict[str, str | float] = {"invocation_id": invocation_id}
437
+ if stale_seconds is not None:
438
+ eligibility = """
439
+ status='ACTIVE' AND (
440
+ heartbeat_at IS NULL
441
+ OR heartbeat_at < NOW() - (:stale * INTERVAL '1 second')
442
+ )
443
+ """
444
+ parameters["stale"] = stale_seconds
445
+ async with self._engine.begin() as connection:
446
+ row = (
447
+ (
448
+ await connection.execute(
449
+ text(
450
+ f"""
451
+ UPDATE {self._table}
452
+ SET status='ACTIVE', attempt=attempt+1, heartbeat_at=NOW()
453
+ WHERE invocation_id=:invocation_id
454
+ AND ({eligibility})
455
+ RETURNING invocation_id, status, attempt,
456
+ request::TEXT AS request_json,
457
+ response::TEXT AS response_json
458
+ """
459
+ ),
460
+ parameters,
461
+ )
462
+ )
463
+ .mappings()
464
+ .one_or_none()
465
+ )
466
+ if row is not None:
467
+ await connection.execute(
468
+ text(
469
+ f"""
470
+ INSERT INTO {self._events_table} (invocation_id, attempt, event)
471
+ VALUES (:invocation_id, :attempt, CAST(:event AS JSONB))
472
+ """
473
+ ),
474
+ {
475
+ "invocation_id": invocation_id,
476
+ "attempt": int(row["attempt"]),
477
+ "event": _serialize_json_value({"type": "run.started"}),
478
+ },
479
+ )
480
+ return self._to_invocation(row) if row is not None else None
481
+
482
+ async def heartbeat(self, invocation_id: str, attempt: int) -> bool:
483
+ _validate_invocation_id(invocation_id)
484
+ async with self._engine.begin() as connection:
485
+ result = await connection.execute(
486
+ text(
487
+ f"""
488
+ UPDATE {self._table}
489
+ SET heartbeat_at=NOW()
490
+ WHERE invocation_id=:invocation_id
491
+ AND attempt=:attempt
492
+ AND status='ACTIVE'
493
+ """
494
+ ),
495
+ {"invocation_id": invocation_id, "attempt": attempt},
496
+ )
497
+ return result.rowcount == 1
498
+
499
+ async def complete(
500
+ self,
501
+ invocation_id: str,
502
+ attempt: int,
503
+ response: JsonValue,
504
+ ) -> bool:
505
+ _validate_invocation_id(invocation_id)
506
+ serialized_response = _serialize_json_value(response)
507
+ async with self._engine.begin() as connection:
508
+ result = await connection.execute(
509
+ text(
510
+ f"""
511
+ UPDATE {self._table}
512
+ SET status='COMPLETED', response=CAST(:response AS JSONB)
513
+ WHERE invocation_id=:invocation_id
514
+ AND attempt=:attempt
515
+ AND status='ACTIVE'
516
+ """
517
+ ),
518
+ {
519
+ "invocation_id": invocation_id,
520
+ "attempt": attempt,
521
+ "response": serialized_response,
522
+ },
523
+ )
524
+ if result.rowcount == 1:
525
+ await connection.execute(
526
+ text(
527
+ f"""
528
+ INSERT INTO {self._events_table} (invocation_id, attempt, event)
529
+ VALUES (:invocation_id, :attempt, CAST(:event AS JSONB))
530
+ """
531
+ ),
532
+ {
533
+ "invocation_id": invocation_id,
534
+ "attempt": attempt,
535
+ "event": _serialize_json_value({"type": "run.completed"}),
536
+ },
537
+ )
538
+ return result.rowcount == 1
539
+
540
+ async def fail(self, invocation_id: str, attempt: int) -> bool:
541
+ _validate_invocation_id(invocation_id)
542
+ async with self._engine.begin() as connection:
543
+ result = await connection.execute(
544
+ text(
545
+ f"""
546
+ UPDATE {self._table}
547
+ SET status='FAILED'
548
+ WHERE invocation_id=:invocation_id
549
+ AND attempt=:attempt
550
+ AND status='ACTIVE'
551
+ """
552
+ ),
553
+ {"invocation_id": invocation_id, "attempt": attempt},
554
+ )
555
+ if result.rowcount == 1:
556
+ await connection.execute(
557
+ text(
558
+ f"""
559
+ INSERT INTO {self._events_table} (invocation_id, attempt, event)
560
+ VALUES (:invocation_id, :attempt, CAST(:event AS JSONB))
561
+ """
562
+ ),
563
+ {
564
+ "invocation_id": invocation_id,
565
+ "attempt": attempt,
566
+ "event": _serialize_json_value({"type": "run.failed"}),
567
+ },
568
+ )
569
+ return result.rowcount == 1
570
+
571
+ async def append_event(
572
+ self,
573
+ invocation_id: str,
574
+ attempt: int,
575
+ event: JsonObject,
576
+ ) -> int | None:
577
+ """Append an event only while the caller owns the active attempt."""
578
+ _validate_invocation_id(invocation_id)
579
+ serialized_event = _serialize_json_value(event)
580
+ async with self._engine.begin() as connection:
581
+ result = await connection.execute(
582
+ text(
583
+ f"""
584
+ INSERT INTO {self._events_table} (invocation_id, attempt, event)
585
+ SELECT :invocation_id, :attempt, CAST(:event AS JSONB)
586
+ WHERE EXISTS (
587
+ SELECT 1
588
+ FROM {self._table}
589
+ WHERE invocation_id=:invocation_id
590
+ AND attempt=:attempt
591
+ AND status='ACTIVE'
592
+ )
593
+ RETURNING sequence_number
594
+ """
595
+ ),
596
+ {
597
+ "invocation_id": invocation_id,
598
+ "attempt": attempt,
599
+ "event": serialized_event,
600
+ },
601
+ )
602
+ sequence_number = result.scalar_one_or_none()
603
+ return int(sequence_number) if sequence_number is not None else None
604
+
605
+ async def events(
606
+ self,
607
+ invocation_id: str,
608
+ after_sequence: int | None = None,
609
+ ) -> list[InvocationEvent]:
610
+ """Return ordered events for one invocation after an optional cursor."""
611
+ _validate_invocation_id(invocation_id)
612
+ async with self._engine.connect() as connection:
613
+ result = await connection.execute(
614
+ text(
615
+ f"""
616
+ SELECT sequence_number, invocation_id, attempt,
617
+ event::TEXT AS event_json
618
+ FROM {self._events_table}
619
+ WHERE invocation_id=:invocation_id
620
+ AND (:after_sequence IS NULL OR sequence_number > :after_sequence)
621
+ ORDER BY sequence_number
622
+ """
623
+ ),
624
+ {
625
+ "invocation_id": invocation_id,
626
+ "after_sequence": after_sequence,
627
+ },
628
+ )
629
+ rows = result.mappings().all()
630
+ return [
631
+ InvocationEvent(
632
+ sequence_number=int(row["sequence_number"]),
633
+ invocation_id=str(row["invocation_id"]),
634
+ attempt=int(row["attempt"]),
635
+ event=json.loads(row["event_json"]),
636
+ )
637
+ for row in rows
638
+ ]
639
+
640
+ @staticmethod
641
+ def _to_invocation(row: Mapping[str, Any] | RowMapping) -> Invocation:
642
+ return Invocation(
643
+ invocation_id=str(row["invocation_id"]),
644
+ status=InvocationStatus(str(row["status"])),
645
+ attempt=int(row["attempt"]),
646
+ request=json.loads(row["request_json"]),
647
+ response=json.loads(row["response_json"]) if row["response_json"] else None,
648
+ )