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.
- harnessx/__init__.py +151 -0
- harnessx/_journal.py +46 -0
- harnessx/artifacts.py +150 -0
- harnessx/backends/__init__.py +12 -0
- harnessx/backends/postgres.py +238 -0
- harnessx/backends/sqlite.py +122 -0
- harnessx/backends/store.py +368 -0
- harnessx/backends/temporal.py +260 -0
- harnessx/backends/temporal_worker.py +170 -0
- harnessx/backends/temporal_workflow.py +396 -0
- harnessx/builtin/__init__.py +123 -0
- harnessx/builtin/_registration.py +30 -0
- harnessx/builtin/bash.py +97 -0
- harnessx/builtin/file_output.py +50 -0
- harnessx/builtin/filesystem.py +291 -0
- harnessx/builtin/memory.py +154 -0
- harnessx/builtin/web.py +73 -0
- harnessx/core.py +250 -0
- harnessx/decisions/__init__.py +16 -0
- harnessx/decisions/base.py +25 -0
- harnessx/decisions/jev.py +271 -0
- harnessx/decisions/types.py +376 -0
- harnessx/engine.py +651 -0
- harnessx/evals/__init__.py +83 -0
- harnessx/evals/cli.py +237 -0
- harnessx/evals/datasets/__init__.py +23 -0
- harnessx/evals/datasets/guardrails.py +34 -0
- harnessx/evals/datasets/loader.py +85 -0
- harnessx/evals/datasets/memory.py +41 -0
- harnessx/evals/datasets/multi_agent.py +40 -0
- harnessx/evals/datasets/registry.py +41 -0
- harnessx/evals/datasets/skills.py +63 -0
- harnessx/evals/datasets/tool_calling.py +57 -0
- harnessx/evals/evaluators/__init__.py +171 -0
- harnessx/evals/evaluators/correctness.py +170 -0
- harnessx/evals/evaluators/delegation.py +42 -0
- harnessx/evals/evaluators/llm_judge.py +116 -0
- harnessx/evals/evaluators/skills.py +58 -0
- harnessx/evals/evaluators/tools.py +194 -0
- harnessx/evals/evaluators/trajectory.py +106 -0
- harnessx/evals/runner.py +269 -0
- harnessx/evals/target.py +247 -0
- harnessx/execution.py +331 -0
- harnessx/extensions/__init__.py +37 -0
- harnessx/extensions/base.py +236 -0
- harnessx/extensions/langsmith.py +439 -0
- harnessx/extensions/result_spill.py +551 -0
- harnessx/hooks.py +159 -0
- harnessx/mcp.py +437 -0
- harnessx/memory.py +649 -0
- harnessx/messages.py +97 -0
- harnessx/permissions.py +192 -0
- harnessx/prompt_cache.py +76 -0
- harnessx/providers/__init__.py +35 -0
- harnessx/providers/anthropic.py +270 -0
- harnessx/providers/azure_openai.py +187 -0
- harnessx/providers/base.py +117 -0
- harnessx/providers/gemini.py +520 -0
- harnessx/providers/openai.py +411 -0
- harnessx/providers/openrouter.py +116 -0
- harnessx/providers/retry.py +132 -0
- harnessx/py.typed +0 -0
- harnessx/recorder.py +645 -0
- harnessx/registry.py +35 -0
- harnessx/runtime.py +612 -0
- harnessx/sandbox.py +466 -0
- harnessx/skills.py +222 -0
- harnessx/streaming.py +4 -0
- harnessx/subagents.py +115 -0
- harnessx/tools.py +519 -0
- harnessx/types.py +410 -0
- harnessx-0.3.0.dist-info/METADATA +1321 -0
- harnessx-0.3.0.dist-info/RECORD +77 -0
- harnessx-0.3.0.dist-info/WHEEL +5 -0
- harnessx-0.3.0.dist-info/entry_points.txt +2 -0
- harnessx-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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)
|