harnessx 0.3.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 (77) hide show
  1. harnessx/__init__.py +151 -0
  2. harnessx/_journal.py +46 -0
  3. harnessx/artifacts.py +150 -0
  4. harnessx/backends/__init__.py +12 -0
  5. harnessx/backends/postgres.py +238 -0
  6. harnessx/backends/sqlite.py +122 -0
  7. harnessx/backends/store.py +368 -0
  8. harnessx/backends/temporal.py +260 -0
  9. harnessx/backends/temporal_worker.py +170 -0
  10. harnessx/backends/temporal_workflow.py +396 -0
  11. harnessx/builtin/__init__.py +123 -0
  12. harnessx/builtin/_registration.py +30 -0
  13. harnessx/builtin/bash.py +97 -0
  14. harnessx/builtin/file_output.py +50 -0
  15. harnessx/builtin/filesystem.py +291 -0
  16. harnessx/builtin/memory.py +154 -0
  17. harnessx/builtin/web.py +73 -0
  18. harnessx/core.py +250 -0
  19. harnessx/decisions/__init__.py +16 -0
  20. harnessx/decisions/base.py +25 -0
  21. harnessx/decisions/jev.py +271 -0
  22. harnessx/decisions/types.py +376 -0
  23. harnessx/engine.py +651 -0
  24. harnessx/evals/__init__.py +83 -0
  25. harnessx/evals/cli.py +237 -0
  26. harnessx/evals/datasets/__init__.py +23 -0
  27. harnessx/evals/datasets/guardrails.py +34 -0
  28. harnessx/evals/datasets/loader.py +85 -0
  29. harnessx/evals/datasets/memory.py +41 -0
  30. harnessx/evals/datasets/multi_agent.py +40 -0
  31. harnessx/evals/datasets/registry.py +41 -0
  32. harnessx/evals/datasets/skills.py +63 -0
  33. harnessx/evals/datasets/tool_calling.py +57 -0
  34. harnessx/evals/evaluators/__init__.py +171 -0
  35. harnessx/evals/evaluators/correctness.py +170 -0
  36. harnessx/evals/evaluators/delegation.py +42 -0
  37. harnessx/evals/evaluators/llm_judge.py +116 -0
  38. harnessx/evals/evaluators/skills.py +58 -0
  39. harnessx/evals/evaluators/tools.py +194 -0
  40. harnessx/evals/evaluators/trajectory.py +106 -0
  41. harnessx/evals/runner.py +269 -0
  42. harnessx/evals/target.py +247 -0
  43. harnessx/execution.py +331 -0
  44. harnessx/extensions/__init__.py +37 -0
  45. harnessx/extensions/base.py +236 -0
  46. harnessx/extensions/langsmith.py +439 -0
  47. harnessx/extensions/result_spill.py +551 -0
  48. harnessx/hooks.py +159 -0
  49. harnessx/mcp.py +437 -0
  50. harnessx/memory.py +649 -0
  51. harnessx/messages.py +97 -0
  52. harnessx/permissions.py +192 -0
  53. harnessx/prompt_cache.py +76 -0
  54. harnessx/providers/__init__.py +35 -0
  55. harnessx/providers/anthropic.py +270 -0
  56. harnessx/providers/azure_openai.py +187 -0
  57. harnessx/providers/base.py +117 -0
  58. harnessx/providers/gemini.py +520 -0
  59. harnessx/providers/openai.py +411 -0
  60. harnessx/providers/openrouter.py +116 -0
  61. harnessx/providers/retry.py +132 -0
  62. harnessx/py.typed +0 -0
  63. harnessx/recorder.py +645 -0
  64. harnessx/registry.py +35 -0
  65. harnessx/runtime.py +612 -0
  66. harnessx/sandbox.py +466 -0
  67. harnessx/skills.py +222 -0
  68. harnessx/streaming.py +4 -0
  69. harnessx/subagents.py +115 -0
  70. harnessx/tools.py +519 -0
  71. harnessx/types.py +410 -0
  72. harnessx-0.3.0.dist-info/METADATA +1321 -0
  73. harnessx-0.3.0.dist-info/RECORD +77 -0
  74. harnessx-0.3.0.dist-info/WHEEL +5 -0
  75. harnessx-0.3.0.dist-info/entry_points.txt +2 -0
  76. harnessx-0.3.0.dist-info/licenses/LICENSE +21 -0
  77. harnessx-0.3.0.dist-info/top_level.txt +1 -0
harnessx/__init__.py ADDED
@@ -0,0 +1,151 @@
1
+ """HarnessX — composable Python agents, tools, and durable execution."""
2
+
3
+ import importlib.util as _importlib_util
4
+ from importlib.metadata import PackageNotFoundError as _PackageNotFoundError, version as _dist_version
5
+
6
+ try:
7
+ __version__ = _dist_version("harnessx")
8
+ except _PackageNotFoundError: # bare checkout without an installed distribution
9
+ __version__ = "0.0.0"
10
+
11
+ from .core import Agent
12
+ from .subagents import SubAgent
13
+ from .extensions import Extension, ExtensionContext, LangSmithExtension, ResultSpillExtension
14
+ from .mcp import MCPManager, MCPServerConfig, MCPToolInfo
15
+ from .hooks import HookContext, HookEvent, HookManager, Middleware, MiddlewarePipeline
16
+ from .memory import (
17
+ AgentMemory,
18
+ ConversationMemory,
19
+ InMemoryVectorStore,
20
+ LongTermMemory,
21
+ PersistentMemory,
22
+ VectorMemoryStore,
23
+ )
24
+ from .messages import Message, ContentBlock
25
+ from .permissions import CliPermissionManager, CostLimitError, GuardrailsEngine, MaxIterationsError, PermissionManager
26
+ from .providers import AnthropicProvider, LLMProvider, make_provider
27
+ from .runtime import AgentRuntime, RunHandle
28
+ from .recorder import IncidentRecorder, ExportPolicy, BundleLimits, IncidentError, VerificationReport, Playback, export_incident
29
+ from ._journal import RecordingError
30
+ from .backends.temporal import TemporalBackend, RedisEvents
31
+ from .artifacts import S3ArtifactStore
32
+ from .backends import SQLiteBackend, PostgresBackend, SchemaError, StorageError, SessionBusyError, LeaseLostError
33
+ from .sandbox import Sandbox
34
+ from .skills import Skill, SkillManager
35
+ from .execution import RunEvent, RunEventType, RunResult, RunStatus, RunStream, RunFailure, PendingTool, ReplayPolicy, current_tool_context
36
+ from .registry import AgentRef, AgentRegistry, agents
37
+ from .tools import ToolNotFoundError, ToolRegistry, normalize_tool_registry
38
+ from .types import (
39
+ AgentConfig,
40
+ CheckpointData,
41
+ PermissionLevel,
42
+ PromptCacheHint,
43
+ PromptCachePolicy,
44
+ ProviderResponse,
45
+ Role,
46
+ RuntimeConfig,
47
+ RuntimeState,
48
+ RuntimeStatus,
49
+ SandboxConfig,
50
+ SandboxResult,
51
+ SessionState,
52
+ StopReason,
53
+ StreamChunk,
54
+ TokenUsage,
55
+ ToolCall,
56
+ ToolDefinition,
57
+ ToolResult,
58
+ )
59
+
60
+ __all__ = [
61
+ "__version__",
62
+ # Core
63
+ "Agent",
64
+ "SubAgent",
65
+ "AgentConfig",
66
+ "PromptCachePolicy",
67
+ "PromptCacheHint",
68
+ "Role",
69
+ "StopReason",
70
+ "ProviderResponse",
71
+ "StreamChunk",
72
+ # Tools
73
+ "ToolRegistry",
74
+ "ToolDefinition",
75
+ "ToolCall",
76
+ "ToolResult",
77
+ "ToolNotFoundError",
78
+ "PermissionLevel",
79
+ "normalize_tool_registry",
80
+ # Memory
81
+ "ConversationMemory",
82
+ "Message", "ContentBlock",
83
+ "PersistentMemory",
84
+ "LongTermMemory",
85
+ "AgentMemory",
86
+ "VectorMemoryStore",
87
+ "InMemoryVectorStore",
88
+ "SessionState",
89
+ "TokenUsage",
90
+ # Permissions
91
+ "PermissionManager",
92
+ "CliPermissionManager",
93
+ "GuardrailsEngine",
94
+ "MaxIterationsError",
95
+ "CostLimitError",
96
+ # Hooks
97
+ "HookManager",
98
+ "HookEvent",
99
+ "HookContext",
100
+ "Middleware",
101
+ "MiddlewarePipeline",
102
+ # Streaming
103
+ "RunEvent", "RunEventType", "RunResult", "RunStatus", "RunStream",
104
+ "RunFailure", "PendingTool",
105
+ "ReplayPolicy", "current_tool_context", "AgentRef", "AgentRegistry", "agents",
106
+ # Sandbox
107
+ "Sandbox",
108
+ "SandboxConfig",
109
+ "SandboxResult",
110
+ # Runtime
111
+ "AgentRuntime", "RunHandle", "TemporalBackend", "RedisEvents", "S3ArtifactStore", "SQLiteBackend", "PostgresBackend",
112
+ "SchemaError", "StorageError", "SessionBusyError", "LeaseLostError",
113
+ "IncidentRecorder", "ExportPolicy", "BundleLimits", "IncidentError",
114
+ "VerificationReport", "Playback", "RecordingError",
115
+ "export_incident",
116
+ "RuntimeConfig",
117
+ "RuntimeState",
118
+ "RuntimeStatus",
119
+ "CheckpointData",
120
+ # MCP
121
+ "MCPManager",
122
+ "MCPServerConfig",
123
+ "MCPToolInfo",
124
+ # Skills
125
+ "Skill",
126
+ "SkillManager",
127
+ # Providers
128
+ "LLMProvider",
129
+ "AnthropicProvider",
130
+ "make_provider",
131
+ # Extensions
132
+ "Extension",
133
+ "ExtensionContext",
134
+ "LangSmithExtension",
135
+ "ResultSpillExtension",
136
+ # Evals
137
+ "evaluate_agent",
138
+ ]
139
+
140
+ # Keep optional evaluation imports (and their environment setup) out of core startup.
141
+
142
+ if _importlib_util.find_spec("langsmith") is None:
143
+ __all__.remove("evaluate_agent")
144
+
145
+
146
+ def __getattr__(name):
147
+ if name == "evaluate_agent":
148
+ from .evals import evaluate_agent
149
+ globals()[name] = evaluate_agent
150
+ return evaluate_agent
151
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
harnessx/_journal.py ADDED
@@ -0,0 +1,46 @@
1
+ """Internal flight-recorder records. Payloads never enter the public event stream."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ import uuid
7
+
8
+ from .execution import wire
9
+
10
+
11
+ class RecordingError(RuntimeError):
12
+ """A required recording boundary could not be committed."""
13
+
14
+
15
+ def record(kind, payload, state, entry=None):
16
+ return {
17
+ "version": 1,
18
+ "id": str(uuid.uuid4()),
19
+ "kind": kind,
20
+ "recorded_at": datetime.now(timezone.utc).isoformat(),
21
+ "session_id": state["session_id"],
22
+ "run_id": state["run_id"],
23
+ "step_id": str(state.get("iterations", 0)),
24
+ "attempt_id": str((entry or state).get("attempt", 0)),
25
+ "execution_key": (entry or {}).get("execution_key"),
26
+ "payload": wire(payload),
27
+ }
28
+
29
+
30
+ def checkpoint(state):
31
+ return {
32
+ "phase": state["phase"],
33
+ "status": state["status"],
34
+ "error": state.get("error"),
35
+ # Spill envelopes contain result IDs; the extension snapshot binds those
36
+ # IDs to portable artifact URIs without introducing a public reference API.
37
+ "extensions": state.get("extensions", {}),
38
+ "tools": [
39
+ {
40
+ key: tool[key]
41
+ for key in ("execution_key", "status", "attempt", "approved")
42
+ if key in tool
43
+ }
44
+ for tool in state.get("tools", [])
45
+ ],
46
+ }
harnessx/artifacts.py ADDED
@@ -0,0 +1,150 @@
1
+ """Portable, content-addressed artifacts; persisted state contains no host paths."""
2
+
3
+ from __future__ import annotations
4
+ import asyncio
5
+ import hashlib
6
+ from pathlib import Path
7
+ import tempfile
8
+ import os
9
+
10
+
11
+ class DirectoryArtifactStore:
12
+ """Snapshot-owned artifacts, retained until PersistentMemory.delete_session()."""
13
+
14
+ def __init__(self, root):
15
+ self.root = Path(root)
16
+
17
+ async def put_artifact(self, content: bytes) -> str:
18
+ key = hashlib.sha256(content).hexdigest()
19
+
20
+ def write():
21
+ self.root.mkdir(parents=True, exist_ok=True)
22
+ with tempfile.NamedTemporaryFile(dir=self.root, delete=False) as output:
23
+ temporary = output.name
24
+ try:
25
+ output.write(content)
26
+ output.flush()
27
+ os.fsync(output.fileno())
28
+ os.replace(temporary, self.root / key)
29
+ finally:
30
+ if os.path.exists(temporary):
31
+ os.unlink(temporary)
32
+ await asyncio.to_thread(write)
33
+ return key
34
+
35
+ async def get_artifact(self, key: str) -> bytes:
36
+ if len(key) != 64 or any(c not in "0123456789abcdef" for c in key):
37
+ raise ValueError("Invalid artifact key")
38
+ content = await asyncio.to_thread((self.root / key).read_bytes)
39
+ if hashlib.sha256(content).hexdigest() != key:
40
+ raise ValueError("Artifact checksum mismatch")
41
+ return content
42
+
43
+
44
+ def replace_paths(value, replacements):
45
+ if isinstance(value, str):
46
+ for old, new in sorted(
47
+ replacements.items(), key=lambda x: len(x[0]), reverse=True
48
+ ):
49
+ value = value.replace(old, new)
50
+ return value
51
+ if isinstance(value, dict):
52
+ return {k: replace_paths(v, replacements) for k, v in value.items()}
53
+ if isinstance(value, list):
54
+ return [replace_paths(v, replacements) for v in value]
55
+ return value
56
+
57
+
58
+ async def capture(agent, state):
59
+ store = getattr(agent, "_artifact_store", None)
60
+ if store is None:
61
+ return state
62
+ paths = set(getattr(agent.memory, "_artifact_paths", set()))
63
+ for ext in state.get("extensions", {}).values():
64
+ if isinstance(ext, dict):
65
+ for result in ext.get("results", {}).values():
66
+ if isinstance(result, dict) and "path" in result:
67
+ paths.add(result["path"])
68
+ replacements = dict(getattr(agent, "_artifact_uris", {}))
69
+ for path in paths:
70
+ if path.startswith("artifact://"):
71
+ continue
72
+ content = await asyncio.to_thread(Path(path).read_bytes)
73
+ key = await store.put_artifact(content)
74
+ replacements[path] = "artifact://" + key
75
+ agent._artifact_uris = replacements
76
+ return replace_paths(state, replacements)
77
+
78
+
79
+ async def materialize(agent, state):
80
+ store = getattr(agent, "_artifact_store", None)
81
+ if store is None:
82
+ return state
83
+ import json
84
+ import re
85
+
86
+ keys = set(re.findall(r"artifact://([a-f0-9]{64})", json.dumps(state)))
87
+ replacements = {}
88
+ if not keys:
89
+ return state
90
+ if getattr(agent, "_materialized_artifacts", None) is None:
91
+ agent._materialized_artifacts = tempfile.TemporaryDirectory(prefix="harnessx-artifacts-")
92
+ root = Path(agent._materialized_artifacts.name)
93
+ for key in keys:
94
+ path = root / key
95
+ content = await store.get_artifact(key)
96
+ if hashlib.sha256(content).hexdigest() != key:
97
+ raise ValueError("Artifact checksum mismatch")
98
+ await asyncio.to_thread(path.write_bytes, content)
99
+ replacements["artifact://" + key] = str(path)
100
+ agent._artifact_uris = {path: uri for uri, path in replacements.items()}
101
+ agent.memory._artifact_paths.update(replacements.values())
102
+ return replace_paths(state, replacements)
103
+
104
+
105
+ class S3ArtifactStore:
106
+ def __init__(self, bucket, *, prefix="harness-x/", endpoint_url=None, client=None):
107
+ self._owns_client = client is None
108
+ self.bucket, self.prefix, self.endpoint_url, self._client = (
109
+ bucket,
110
+ prefix,
111
+ endpoint_url,
112
+ client,
113
+ )
114
+
115
+ def _get_client(self):
116
+ if self._client is None:
117
+ import boto3
118
+
119
+ self._client = boto3.client("s3", endpoint_url=self.endpoint_url)
120
+ return self._client
121
+
122
+ async def put_artifact(self, content):
123
+ key = hashlib.sha256(content).hexdigest()
124
+ await asyncio.to_thread(
125
+ self._get_client().put_object,
126
+ Bucket=self.bucket,
127
+ Key=self.prefix + key,
128
+ Body=content,
129
+ )
130
+ return key
131
+
132
+ async def get_artifact(self, key):
133
+ if len(key) != 64 or any(c not in "0123456789abcdef" for c in key):
134
+ raise ValueError("Invalid artifact key")
135
+
136
+ def read():
137
+ body = self._get_client().get_object(
138
+ Bucket=self.bucket, Key=self.prefix + key
139
+ )["Body"]
140
+ try:
141
+ return body.read()
142
+ finally:
143
+ body.close()
144
+
145
+ return await asyncio.to_thread(read)
146
+
147
+ async def aclose(self):
148
+ if self._owns_client and self._client is not None:
149
+ await asyncio.to_thread(self._client.close)
150
+ self._client = None
@@ -0,0 +1,12 @@
1
+ from .sqlite import SQLiteBackend
2
+ from .postgres import PostgresBackend
3
+ from .store import SchemaError, StorageError, SessionBusyError, LeaseLostError
4
+
5
+ __all__ = [
6
+ "SQLiteBackend",
7
+ "PostgresBackend",
8
+ "SchemaError",
9
+ "StorageError",
10
+ "SessionBusyError",
11
+ "LeaseLostError",
12
+ ]
@@ -0,0 +1,238 @@
1
+ """PostgreSQL runtime storage; bootstraps an isolated, versioned schema."""
2
+
3
+ from __future__ import annotations
4
+ from contextlib import asynccontextmanager
5
+ import hashlib
6
+ import asyncio
7
+ import uuid
8
+
9
+ from .store import (
10
+ SQLStore,
11
+ TABLES,
12
+ MIGRATION_CHECKSUM,
13
+ SchemaError,
14
+ StorageError,
15
+ SessionBusyError,
16
+ LeaseLostError,
17
+ retry_transaction,
18
+ )
19
+
20
+
21
+ class PostgresTransaction:
22
+ def __init__(self, conn):
23
+ self.conn = conn
24
+
25
+ async def execute(self, sql, args=()):
26
+ return await self.conn.execute(postgres_bindings(sql), args)
27
+
28
+ async def one(self, sql, args=()):
29
+ return await (await self.execute(sql, args)).fetchone()
30
+
31
+ async def all(self, sql, args=()):
32
+ return await (await self.execute(sql, args)).fetchall()
33
+
34
+
35
+ class PostgresBackend(SQLStore):
36
+ postgres = True
37
+
38
+ def __init__(
39
+ self,
40
+ connection_string: str,
41
+ *,
42
+ schema="harness_x", # Preserve existing databases across the package rename.
43
+ auto_migrate=True,
44
+ lease_seconds=30,
45
+ pool_size=10,
46
+ artifact_store=None,
47
+ ):
48
+ if not schema or "\x00" in schema or len(schema.encode()) > 63:
49
+ raise ValueError(
50
+ "Schema must be a nonempty PostgreSQL identifier of at most 63 bytes"
51
+ )
52
+ if lease_seconds <= 0:
53
+ raise ValueError("lease_seconds must be positive")
54
+ self._dsn = connection_string
55
+ self.schema = schema
56
+ self.prefix = '"' + schema.replace('"', '""') + '".'
57
+ self.auto_migrate = auto_migrate
58
+ self.lease_seconds = lease_seconds
59
+ self.pool_size = pool_size
60
+ self.pool = None
61
+ self._initialized = False
62
+ self._init_lock = asyncio.Lock()
63
+ self._artifact_store = artifact_store
64
+
65
+ def __repr__(self):
66
+ return f"PostgresBackend(schema={self.schema!r}, connection_string=<redacted>)"
67
+
68
+ async def initialize(self):
69
+ async with self._init_lock:
70
+ if self._initialized:
71
+ return
72
+ await self._initialize()
73
+ self._initialized = True
74
+
75
+ async def _initialize(self):
76
+ try:
77
+ from psycopg_pool import AsyncConnectionPool
78
+ except ImportError:
79
+ raise ImportError("Install harnessx[postgres] to use PostgreSQL") from None
80
+ # Connection failures can include DSN credentials: expose only sanitized diagnostics.
81
+ try:
82
+ self.pool = AsyncConnectionPool(
83
+ self._dsn, min_size=0, max_size=self.pool_size, open=False, timeout=10
84
+ )
85
+ await self.pool.open()
86
+ await self.migrate(validate_only=not self.auto_migrate)
87
+ except SchemaError:
88
+ await self.aclose()
89
+ raise
90
+ except Exception:
91
+ await self.aclose()
92
+ raise StorageError(
93
+ "PostgreSQL initialization failed; check connectivity, TLS, and schema privileges"
94
+ ) from None
95
+
96
+ async def migrate(self, *, validate_only=False):
97
+ if self.pool is None:
98
+ # Explicit provisioning entry point for deployments using auto_migrate=False.
99
+ saved = self.auto_migrate
100
+ self.auto_migrate = not validate_only
101
+ try:
102
+ await self.initialize()
103
+ finally:
104
+ self.auto_migrate = saved
105
+ return
106
+ key = int.from_bytes(
107
+ hashlib.sha256(("harness_x:migrations:" + self.schema).encode()).digest()[
108
+ :8
109
+ ],
110
+ "big",
111
+ signed=True,
112
+ )
113
+ try:
114
+ async with self.transaction() as tx:
115
+ await tx.execute("SELECT pg_advisory_xact_lock(?)", (key,))
116
+ row = await tx.one(
117
+ "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_schema=? AND table_name=?)",
118
+ (self.schema, "schema_versions"),
119
+ )
120
+ if not row[0]:
121
+ if validate_only:
122
+ raise SchemaError(
123
+ "Schema missing; run backend.migrate() or enable auto_migrate"
124
+ )
125
+ await tx.execute(f"CREATE SCHEMA IF NOT EXISTS {self.prefix[:-1]}")
126
+ for name, definition in TABLES.items():
127
+ await tx.execute(
128
+ f"CREATE TABLE {self.table(name)} ({definition.format(json='JSONB', blob='BYTEA', schema=self.prefix)})"
129
+ )
130
+ await tx.execute(
131
+ f"CREATE INDEX runs_session ON {self.table('runs')}(session_id)"
132
+ )
133
+ await tx.execute(
134
+ f"INSERT INTO {self.table('schema_versions')} VALUES (?,?)",
135
+ (1, MIGRATION_CHECKSUM),
136
+ )
137
+ await self._migrate_journal(tx, validate_only=validate_only)
138
+ except SchemaError:
139
+ raise
140
+ except Exception as exc:
141
+ code = getattr(exc, "sqlstate", "")
142
+ if code == "42501":
143
+ raise SchemaError(
144
+ "Insufficient PostgreSQL privileges: schema provisioning needs database CREATE; migrations need ownership of Harness objects"
145
+ ) from None
146
+ raise SchemaError(
147
+ "Schema provisioning failed; conflicting objects or unavailable database (SQLSTATE "
148
+ + str(code)
149
+ + ")"
150
+ ) from None
151
+
152
+ @asynccontextmanager
153
+ async def transaction(self):
154
+ async with self.pool.connection() as conn:
155
+ async with conn.transaction():
156
+ yield PostgresTransaction(conn)
157
+
158
+ @retry_transaction
159
+ async def claim(self, session_id):
160
+ owner = str(uuid.uuid4())
161
+ async with self.transaction() as tx:
162
+ row = await tx.one(
163
+ f"UPDATE {self.table('sessions')} SET owner=?,generation=generation+1,lease_until=EXTRACT(EPOCH FROM clock_timestamp())+? WHERE id=? AND (owner IS NULL OR lease_until<EXTRACT(EPOCH FROM clock_timestamp())) RETURNING generation",
164
+ (owner, self.lease_seconds, session_id),
165
+ )
166
+ if row is None:
167
+ raise SessionBusyError(
168
+ "Session is owned by another worker or does not exist"
169
+ )
170
+ return (owner, row[0])
171
+
172
+ async def _fence(self, tx, session_id, lease):
173
+ owner, generation = lease
174
+ row = await tx.one(
175
+ f"SELECT id FROM {self.table('sessions')} WHERE id=? AND owner=? AND generation=? AND lease_until>EXTRACT(EPOCH FROM clock_timestamp()) FOR UPDATE",
176
+ (session_id, owner, generation),
177
+ )
178
+ if row is None:
179
+ raise LeaseLostError("Session lease expired or was superseded")
180
+
181
+ @retry_transaction
182
+ async def renew(self, session_id, lease):
183
+ async with self.transaction() as tx:
184
+ await self._fence(tx, session_id, lease)
185
+ await tx.execute(
186
+ f"UPDATE {self.table('sessions')} SET lease_until=EXTRACT(EPOCH FROM clock_timestamp())+? WHERE id=?",
187
+ (self.lease_seconds, session_id),
188
+ )
189
+
190
+ @retry_transaction
191
+ async def release(self, session_id, lease):
192
+ async with self.transaction() as tx:
193
+ await tx.execute(
194
+ f"UPDATE {self.table('sessions')} SET owner=NULL,lease_until=0 WHERE id=? AND owner=? AND generation=?",
195
+ (session_id, *lease),
196
+ )
197
+
198
+ async def put_artifact(self, content):
199
+ if self._artifact_store is not None:
200
+ return await self._artifact_store.put_artifact(content)
201
+ return await super().put_artifact(content)
202
+
203
+ async def get_artifact(self, key):
204
+ if self._artifact_store is not None:
205
+ return await self._artifact_store.get_artifact(key)
206
+ return await super().get_artifact(key)
207
+
208
+ async def aclose(self):
209
+ self._initialized = False
210
+ if self.pool:
211
+ await self.pool.close()
212
+ self.pool = None
213
+
214
+
215
+ def postgres_bindings(sql: str) -> str:
216
+ """Translate our qmark parameters without rewriting quoted identifiers/literals."""
217
+ result = []
218
+ quote = None
219
+ index = 0
220
+ while index < len(sql):
221
+ char = sql[index]
222
+ if quote:
223
+ result.append("%%" if char == "%" else char)
224
+ if char == quote:
225
+ if index + 1 < len(sql) and sql[index + 1] == quote:
226
+ result.append(quote)
227
+ index += 1
228
+ else:
229
+ quote = None
230
+ elif char in ('"', "'"):
231
+ quote = char
232
+ result.append(char)
233
+ elif char == "?":
234
+ result.append("%s")
235
+ else:
236
+ result.append("%%" if char == "%" else char)
237
+ index += 1
238
+ return "".join(result)