harness-common 0.3.1__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.
- harness_common-0.3.1/.gitignore +45 -0
- harness_common-0.3.1/PKG-INFO +8 -0
- harness_common-0.3.1/harness_common/__init__.py +57 -0
- harness_common-0.3.1/harness_common/artifact.py +17 -0
- harness_common-0.3.1/harness_common/client.py +136 -0
- harness_common-0.3.1/harness_common/component.py +15 -0
- harness_common-0.3.1/harness_common/deployment.py +31 -0
- harness_common-0.3.1/harness_common/environment.py +97 -0
- harness_common-0.3.1/harness_common/execution.py +25 -0
- harness_common-0.3.1/harness_common/experiment.py +60 -0
- harness_common-0.3.1/harness_common/forge.py +12 -0
- harness_common-0.3.1/harness_common/host.py +45 -0
- harness_common-0.3.1/harness_common/llm/__init__.py +11 -0
- harness_common-0.3.1/harness_common/llm/client.py +165 -0
- harness_common-0.3.1/harness_common/operation.py +37 -0
- harness_common-0.3.1/harness_common/outcome.py +15 -0
- harness_common-0.3.1/harness_common/overlay.py +82 -0
- harness_common-0.3.1/harness_common/product.py +12 -0
- harness_common-0.3.1/harness_common/reducer.py +26 -0
- harness_common-0.3.1/harness_common/report_kit/__init__.py +19 -0
- harness_common-0.3.1/harness_common/report_kit/doc.py +134 -0
- harness_common-0.3.1/harness_common/report_kit/html.py +304 -0
- harness_common-0.3.1/harness_common/repository.py +15 -0
- harness_common-0.3.1/harness_common/run.py +34 -0
- harness_common-0.3.1/harness_common/service.py +25 -0
- harness_common-0.3.1/harness_common/tests/__init__.py +0 -0
- harness_common-0.3.1/harness_common/tests/test_client.py +127 -0
- harness_common-0.3.1/harness_common/tests/test_environment_service.py +164 -0
- harness_common-0.3.1/harness_common/tests/test_llm_client.py +182 -0
- harness_common-0.3.1/harness_common/tests/test_overlay.py +38 -0
- harness_common-0.3.1/harness_common/tests/test_report_kit.py +33 -0
- harness_common-0.3.1/harness_common/tests/test_verdict_evalperf.py +291 -0
- harness_common-0.3.1/harness_common/tests/test_workload.py +46 -0
- harness_common-0.3.1/harness_common/verdict.py +252 -0
- harness_common-0.3.1/harness_common/workload.py +36 -0
- harness_common-0.3.1/pyproject.toml +18 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.venv/
|
|
8
|
+
*.egg
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.mypy_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
htmlcov/
|
|
13
|
+
.coverage
|
|
14
|
+
*.whl
|
|
15
|
+
*.tar.gz
|
|
16
|
+
|
|
17
|
+
# Go
|
|
18
|
+
/sdks/go/bin/
|
|
19
|
+
|
|
20
|
+
# TypeScript
|
|
21
|
+
node_modules/
|
|
22
|
+
|
|
23
|
+
# IDE
|
|
24
|
+
.idea/
|
|
25
|
+
.vscode/
|
|
26
|
+
*.swp
|
|
27
|
+
*.swo
|
|
28
|
+
|
|
29
|
+
# OS
|
|
30
|
+
.DS_Store
|
|
31
|
+
Thumbs.db
|
|
32
|
+
|
|
33
|
+
# uv
|
|
34
|
+
sdks/python/.venv/
|
|
35
|
+
|
|
36
|
+
# DevOps plugin
|
|
37
|
+
.devops/
|
|
38
|
+
|
|
39
|
+
# Runtime artifacts (agent tier)
|
|
40
|
+
runtime/
|
|
41
|
+
runs/
|
|
42
|
+
|
|
43
|
+
# devloop
|
|
44
|
+
.devloop/
|
|
45
|
+
.worktrees/
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""common — harness-neutral shared code and runtime identities.
|
|
2
|
+
|
|
3
|
+
The harnesses (e2e / eval / perf / trace / trajectory) depend on this package but never on each
|
|
4
|
+
other; only genuinely neutral code lives here (no harness domain concept leaks in).
|
|
5
|
+
|
|
6
|
+
The canonical case model — `Case` / `CaseSet` / `FacetSchema` and the `Face` / `FACES`
|
|
7
|
+
judgment-face enum — lives upstream in `spec_case.model` / `spec_case.facets`
|
|
8
|
+
(the spec-case package, `[model]` extra): the case format is the asset layer's contract,
|
|
9
|
+
and this repo is one of its runners. Import those names from `spec_case` directly.
|
|
10
|
+
|
|
11
|
+
`Forge` and `Repository` identify where source lives; `Product` names a business product;
|
|
12
|
+
`Component` identifies a buildable unit within a Repository. `Environment` identifies where
|
|
13
|
+
execution happens. `Service` identifies a Component's runtime presence in one Environment.
|
|
14
|
+
`Workload` locates a declared runtime carrier; a Service can reference multiple workloads.
|
|
15
|
+
`Operation` identifies a capability exposed by a Service; `HttpOperation` adds its HTTP
|
|
16
|
+
transport contract. An `Execution` groups domain-defined work such as an e2e CaseRun or
|
|
17
|
+
a perf Trial; each `OperationRun` owns one raw `Outcome`. Domain engines own scheduling
|
|
18
|
+
details. A domain `Reducer` projects the recorded Outcomes into one or more
|
|
19
|
+
`Artifact` objects without calling the tested Service again. `Deployment` records an
|
|
20
|
+
attempt applied through a `Deployer`.
|
|
21
|
+
`Experiment` names reproducible verification intent; `ExperimentRun` records one execution
|
|
22
|
+
and references its `Artifact` outputs.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from harness_common.artifact import Artifact as Artifact
|
|
28
|
+
from harness_common.component import Component as Component
|
|
29
|
+
from harness_common.deployment import Deployer as Deployer
|
|
30
|
+
from harness_common.deployment import Deployment as Deployment
|
|
31
|
+
from harness_common.environment import Environment as Environment
|
|
32
|
+
from harness_common.environment import KubernetesEnvironment as KubernetesEnvironment
|
|
33
|
+
from harness_common.environment import HostEnvironment as HostEnvironment
|
|
34
|
+
from harness_common.host import Host as Host
|
|
35
|
+
from harness_common.environment import EnvironmentFacts as EnvironmentFacts
|
|
36
|
+
from harness_common.environment import EnvironmentSnapshot as EnvironmentSnapshot
|
|
37
|
+
from harness_common.execution import Execution as Execution
|
|
38
|
+
from harness_common.experiment import Experiment as Experiment
|
|
39
|
+
from harness_common.experiment import ExperimentRun as ExperimentRun
|
|
40
|
+
from harness_common.forge import Forge as Forge
|
|
41
|
+
from harness_common.operation import HttpOperation as HttpOperation
|
|
42
|
+
from harness_common.operation import Operation as Operation
|
|
43
|
+
from harness_common.operation import OperationRun as OperationRun
|
|
44
|
+
from harness_common.outcome import Outcome as Outcome
|
|
45
|
+
from harness_common.product import Product as Product
|
|
46
|
+
from harness_common.repository import Repository as Repository
|
|
47
|
+
from harness_common.reducer import Reducer as Reducer
|
|
48
|
+
from harness_common.service import Service as Service
|
|
49
|
+
from harness_common.workload import KubernetesWorkload as KubernetesWorkload
|
|
50
|
+
from harness_common.workload import Workload as Workload
|
|
51
|
+
|
|
52
|
+
from harness_common.client import Client as Client
|
|
53
|
+
from harness_common.client import ClientManager as ClientManager
|
|
54
|
+
from harness_common.client import ClientProvider as ClientProvider
|
|
55
|
+
from harness_common.client import DataSource as DataSource
|
|
56
|
+
from harness_common.client import ServiceDataSource as ServiceDataSource
|
|
57
|
+
from harness_common.client import data_source_key as data_source_key
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""A durable output produced by an ExperimentRun."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class Artifact:
|
|
10
|
+
"""A named domain result referenced relative to its run directory.
|
|
11
|
+
|
|
12
|
+
The producing harness owns the content and schema. Reports render one or more Artifacts
|
|
13
|
+
without becoming a new source of execution facts.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
path: str
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Execution-scoped, asynchronous client ownership.
|
|
2
|
+
|
|
3
|
+
DataSource keys describe configuration, not object identity. Consumers receive a
|
|
4
|
+
ClientProvider; only the root execution owns and disposes the ClientManager.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Generic, Protocol, TypeVar, cast
|
|
14
|
+
|
|
15
|
+
from harness_common.service import Service
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Client(Protocol):
|
|
19
|
+
async def initialize(self) -> None: ...
|
|
20
|
+
async def dispose(self) -> None: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
C = TypeVar("C", bound=Client, covariant=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DataSource(Protocol[C]):
|
|
27
|
+
@property
|
|
28
|
+
def key(self) -> str: ...
|
|
29
|
+
def create_client(self, clients: ClientProvider) -> C: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ServiceDataSource(Generic[C]):
|
|
34
|
+
"""One access association, not a platform resource or a client owner.
|
|
35
|
+
|
|
36
|
+
A Service can have zero or many associations; the same source can be shared
|
|
37
|
+
by multiple Services. Platform workload mappings belong to deployment config.
|
|
38
|
+
Use clients.get(binding.source) so logical identity never fragments client reuse.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
service: Service
|
|
42
|
+
source: DataSource[C]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ClientProvider(Protocol):
|
|
46
|
+
async def get(self, source: DataSource[C]) -> C: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def data_source_key(protocol: str, configuration: object) -> str:
|
|
50
|
+
"""Hash JSON-compatible configuration, including credentials, without exposing it."""
|
|
51
|
+
canonical = json.dumps(
|
|
52
|
+
configuration, sort_keys=True, separators=(",", ":"), allow_nan=False
|
|
53
|
+
)
|
|
54
|
+
return protocol + ":" + hashlib.sha256(canonical.encode()).hexdigest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ClientManager:
|
|
58
|
+
"""Share initialized clients within one event loop and close them at root exit.
|
|
59
|
+
|
|
60
|
+
Initialize dependencies through the supplied provider before publishing a
|
|
61
|
+
client as ready. Reverse readiness order then closes consumers first.
|
|
62
|
+
Join all command work before leaving the root context: borrowed clients are
|
|
63
|
+
invalid after disposal. Managers cannot be reopened or shared across loops.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
self._pending: dict[str, asyncio.Task[Client]] = {}
|
|
68
|
+
self._ready: list[Client] = []
|
|
69
|
+
self._disposal: asyncio.Task[None] | None = None
|
|
70
|
+
self._cleanup_errors: list[BaseException] = []
|
|
71
|
+
self._unclean: set[str] = set()
|
|
72
|
+
|
|
73
|
+
async def get(self, source: DataSource[C]) -> C:
|
|
74
|
+
if self._disposal is not None:
|
|
75
|
+
raise RuntimeError("client manager is disposed")
|
|
76
|
+
key = source.key
|
|
77
|
+
task = self._pending.get(key)
|
|
78
|
+
if task is None:
|
|
79
|
+
# Store before the task can run; factories can themselves request dependencies.
|
|
80
|
+
task = asyncio.create_task(self._initialize(source))
|
|
81
|
+
self._pending[key] = task
|
|
82
|
+
task.add_done_callback(lambda done: self._settled(key, done))
|
|
83
|
+
# A cancelled consumer must not cancel initialization needed by other consumers.
|
|
84
|
+
return cast(C, await asyncio.shield(task))
|
|
85
|
+
|
|
86
|
+
def _settled(self, key: str, task: asyncio.Task[Client]) -> None:
|
|
87
|
+
failed = task.cancelled() or task.exception() is not None
|
|
88
|
+
if failed and key not in self._unclean and self._pending.get(key) is task:
|
|
89
|
+
del self._pending[key]
|
|
90
|
+
|
|
91
|
+
async def _initialize(self, source: DataSource[Client]) -> Client:
|
|
92
|
+
client = source.create_client(self)
|
|
93
|
+
try:
|
|
94
|
+
await client.initialize()
|
|
95
|
+
self._ready.append(client)
|
|
96
|
+
return client
|
|
97
|
+
except BaseException as error:
|
|
98
|
+
try:
|
|
99
|
+
await client.dispose()
|
|
100
|
+
except BaseException as cleanup:
|
|
101
|
+
self._cleanup_errors.append(cleanup)
|
|
102
|
+
self._unclean.add(source.key)
|
|
103
|
+
raise BaseExceptionGroup(
|
|
104
|
+
"client initialization and cleanup failed", [error, cleanup]
|
|
105
|
+
) from None
|
|
106
|
+
raise
|
|
107
|
+
|
|
108
|
+
async def dispose(self) -> None:
|
|
109
|
+
"""Idempotent, concurrent-safe cleanup; cancellation of a waiter cannot stop it."""
|
|
110
|
+
if self._disposal is None:
|
|
111
|
+
self._disposal = asyncio.create_task(self._dispose())
|
|
112
|
+
await asyncio.shield(self._disposal)
|
|
113
|
+
|
|
114
|
+
async def _dispose(self) -> None:
|
|
115
|
+
pending = list(self._pending.values())
|
|
116
|
+
for task in pending:
|
|
117
|
+
if not task.done():
|
|
118
|
+
task.cancel()
|
|
119
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
120
|
+
self._pending.clear()
|
|
121
|
+
for client in reversed(self._ready):
|
|
122
|
+
try:
|
|
123
|
+
await client.dispose()
|
|
124
|
+
except BaseException as error:
|
|
125
|
+
self._cleanup_errors.append(error)
|
|
126
|
+
self._ready.clear()
|
|
127
|
+
if self._cleanup_errors:
|
|
128
|
+
raise BaseExceptionGroup("client cleanup failed", self._cleanup_errors)
|
|
129
|
+
|
|
130
|
+
async def __aenter__(self) -> ClientManager:
|
|
131
|
+
if self._disposal is not None:
|
|
132
|
+
raise RuntimeError("client manager is disposed")
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
async def __aexit__(self, *_: object) -> None:
|
|
136
|
+
await self.dispose()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Stable identity of one buildable component within a code repository."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from harness_common.repository import Repository
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class Component:
|
|
12
|
+
"""A named component owned by a repository; one repository may own many."""
|
|
13
|
+
|
|
14
|
+
repository: Repository
|
|
15
|
+
name: str
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Deployment records and the port that applies them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
from harness_common.service import Service
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Deployment:
|
|
14
|
+
"""One attempt to create or update a Service from its Component."""
|
|
15
|
+
|
|
16
|
+
service: Service
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
DeploymentT = TypeVar("DeploymentT", bound=Deployment)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Deployer(ABC, Generic[DeploymentT]):
|
|
23
|
+
"""Port implemented by a concrete deployment mechanism."""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
async def deploy(self, deployment: DeploymentT) -> None:
|
|
27
|
+
"""Apply one Deployment and return after the Service is ready."""
|
|
28
|
+
|
|
29
|
+
async def teardown(self) -> None:
|
|
30
|
+
"""Restore the baseline when the concrete deployer supports it."""
|
|
31
|
+
return None
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Deployment environment identity, optional access host and observed evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from harness_common.host import Host, parse_host
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Environment:
|
|
13
|
+
"""A named environment in which Components have running Service instances.
|
|
14
|
+
|
|
15
|
+
Host is optional. When omitted, operations use the current execution host.
|
|
16
|
+
It does not identify a Kubernetes node or dictate where the case runner runs.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
host: Host | None = field(default=None, kw_only=True)
|
|
21
|
+
kind: ClassVar[str] = "generic"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class HostEnvironment(Environment):
|
|
26
|
+
"""A target running directly on the environment's host."""
|
|
27
|
+
|
|
28
|
+
kind: ClassVar[str] = "host"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class KubernetesEnvironment(Environment):
|
|
33
|
+
"""A cluster accessed from Host; kubeconfig is a path on that host."""
|
|
34
|
+
|
|
35
|
+
kubeconfig: str = ""
|
|
36
|
+
context: str | None = None
|
|
37
|
+
kind: ClassVar[str] = "kubernetes"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class EnvironmentFacts:
|
|
42
|
+
"""Observed facts with provenance. Missing values mean unknown, not false."""
|
|
43
|
+
|
|
44
|
+
source: str = ""
|
|
45
|
+
observed_at: str = ""
|
|
46
|
+
values: dict[str, str] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class EnvironmentSnapshot:
|
|
51
|
+
"""Run evidence; access configuration and credentials are deliberately absent.
|
|
52
|
+
|
|
53
|
+
Runner and target facts are separate: a local runner can test a remote Pod.
|
|
54
|
+
Profiles select project-owned conditions and never substitute for observation.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
kind: str
|
|
59
|
+
profile: str = "default"
|
|
60
|
+
revision: str = ""
|
|
61
|
+
host_name: str = ""
|
|
62
|
+
host: EnvironmentFacts | None = None
|
|
63
|
+
runner: EnvironmentFacts = field(default_factory=EnvironmentFacts)
|
|
64
|
+
target: EnvironmentFacts = field(default_factory=EnvironmentFacts)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_environment(data: dict) -> Environment:
|
|
68
|
+
"""Parse the shared environment wire format, including legacy Kubernetes input."""
|
|
69
|
+
if not isinstance(data, dict):
|
|
70
|
+
raise ValueError("environment must be a mapping")
|
|
71
|
+
unknown = set(data) - {"name", "kind", "host", "kubeconfig", "context"}
|
|
72
|
+
if unknown:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"environment contains unknown field(s): {', '.join(sorted(unknown))}"
|
|
75
|
+
)
|
|
76
|
+
if any(
|
|
77
|
+
value is not None and not isinstance(value, str)
|
|
78
|
+
for key, value in data.items()
|
|
79
|
+
if key != "host"
|
|
80
|
+
):
|
|
81
|
+
raise ValueError("environment fields except host must be strings")
|
|
82
|
+
kind = data.get("kind") or (
|
|
83
|
+
"kubernetes" if data.get("kubeconfig") or data.get("context") else "generic"
|
|
84
|
+
)
|
|
85
|
+
if kind not in {"generic", "host", "kubernetes"}:
|
|
86
|
+
raise ValueError(f"unknown environment kind {kind!r}")
|
|
87
|
+
host = parse_host(data["host"]) if data.get("host") is not None else None
|
|
88
|
+
name = data.get("name") or ""
|
|
89
|
+
if kind == "kubernetes":
|
|
90
|
+
return KubernetesEnvironment(
|
|
91
|
+
name, data.get("kubeconfig") or "", data.get("context"), host=host
|
|
92
|
+
)
|
|
93
|
+
if data.get("kubeconfig") or data.get("context"):
|
|
94
|
+
raise ValueError(f"{kind} environment contains Kubernetes access fields")
|
|
95
|
+
if kind == "host":
|
|
96
|
+
return HostEnvironment(name, host=host)
|
|
97
|
+
return Environment(name, host=host)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Harness-neutral grouping of related OperationRuns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
from harness_common.operation import OperationRun
|
|
9
|
+
from harness_common.outcome import Outcome
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
OutcomeT = TypeVar("OutcomeT", bound=Outcome)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(kw_only=True)
|
|
16
|
+
class Execution(Generic[OutcomeT]):
|
|
17
|
+
"""One domain-defined unit of work within an ExperimentRun.
|
|
18
|
+
|
|
19
|
+
E2E and perf intentionally organize Operations differently. The shared
|
|
20
|
+
model records only the stable result: an identity and the OperationRuns
|
|
21
|
+
grouped by that unit of work.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
id: str
|
|
25
|
+
operation_runs: list[OperationRun[OutcomeT]] = field(default_factory=list)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Harness-neutral experiment identity and run artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
|
|
8
|
+
from harness_common.artifact import Artifact
|
|
9
|
+
from harness_common.execution import Execution
|
|
10
|
+
from harness_common.operation import OperationRun
|
|
11
|
+
from harness_common.outcome import Outcome
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(kw_only=True)
|
|
15
|
+
class Experiment:
|
|
16
|
+
"""A named, reproducible verification intent.
|
|
17
|
+
|
|
18
|
+
Domain harnesses add what varies and how it is executed. The shared concept owns only
|
|
19
|
+
the stable name used to group runs.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ExperimentRun:
|
|
27
|
+
"""One execution of an Experiment and the outputs it produced.
|
|
28
|
+
|
|
29
|
+
``experiment`` remains the Experiment name on the persisted boundary so a run artifact
|
|
30
|
+
does not embed the complete, domain-specific experiment object.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
run_id: str
|
|
34
|
+
experiment: str
|
|
35
|
+
created_at: str
|
|
36
|
+
executions: list[Execution] = field(default_factory=list, kw_only=True)
|
|
37
|
+
artifacts: list[Artifact] = field(default_factory=list, kw_only=True)
|
|
38
|
+
|
|
39
|
+
def operation_runs(self) -> Iterator[OperationRun]:
|
|
40
|
+
"""Iterate all OperationRuns without duplicating the stored hierarchy."""
|
|
41
|
+
for execution in self.executions:
|
|
42
|
+
yield from execution.operation_runs
|
|
43
|
+
|
|
44
|
+
def outcomes(self) -> Iterator[Outcome]:
|
|
45
|
+
"""Iterate raw Outcomes in execution order."""
|
|
46
|
+
for operation_run in self.operation_runs():
|
|
47
|
+
yield operation_run.outcome
|
|
48
|
+
|
|
49
|
+
def add_artifact(self, name: str, path: str) -> None:
|
|
50
|
+
"""Register or replace one artifact by its logical name."""
|
|
51
|
+
artifact = Artifact(name=name, path=path)
|
|
52
|
+
for index, current in enumerate(self.artifacts):
|
|
53
|
+
if current.name == name:
|
|
54
|
+
self.artifacts[index] = artifact
|
|
55
|
+
return
|
|
56
|
+
self.artifacts.append(artifact)
|
|
57
|
+
|
|
58
|
+
def artifact_paths(self) -> dict[str, str]:
|
|
59
|
+
"""Project typed artifacts to the cross-harness verdict wire shape."""
|
|
60
|
+
return {artifact.name: artifact.path for artifact in self.artifacts}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Host identity and access configuration; operations belong to toolbox."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True, slots=True)
|
|
7
|
+
class Host:
|
|
8
|
+
"""An optional constituent of an Environment.
|
|
9
|
+
|
|
10
|
+
For a host environment this machine hosts the target. For Kubernetes it
|
|
11
|
+
provides cluster access, and kubeconfig paths refer to its filesystem.
|
|
12
|
+
SSH addresses use the caller's SSH configuration; credentials are not stored.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
name: str
|
|
16
|
+
transport: str = "local"
|
|
17
|
+
address: str = ""
|
|
18
|
+
|
|
19
|
+
def validate(self) -> None:
|
|
20
|
+
if not self.name:
|
|
21
|
+
raise ValueError("host requires name")
|
|
22
|
+
if self.transport in {"", "local"}:
|
|
23
|
+
if self.address:
|
|
24
|
+
raise ValueError("local host cannot specify SSH address")
|
|
25
|
+
elif self.transport == "ssh":
|
|
26
|
+
if not self.address or self.address.startswith("-"):
|
|
27
|
+
raise ValueError("SSH host requires a non-option address")
|
|
28
|
+
else:
|
|
29
|
+
raise ValueError(f"unknown host transport {self.transport!r}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_host(data: dict) -> Host:
|
|
33
|
+
"""Parse the shared Host wire representation."""
|
|
34
|
+
if not isinstance(data, dict):
|
|
35
|
+
raise ValueError("host must be a mapping")
|
|
36
|
+
unknown = set(data) - {"name", "transport", "address"}
|
|
37
|
+
if unknown:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"host contains unknown field(s): {', '.join(sorted(unknown))}"
|
|
40
|
+
)
|
|
41
|
+
if any(not isinstance(value, str) for value in data.values()):
|
|
42
|
+
raise ValueError("host fields must be strings")
|
|
43
|
+
host = Host(**data) if "name" in data else Host(name="", **data)
|
|
44
|
+
host.validate()
|
|
45
|
+
return host
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""common.llm — OpenAI-compatible LLM client, harness-neutral shared infrastructure.
|
|
2
|
+
|
|
3
|
+
A model call (endpoint / auth / timeout / retry / provenance) is not specific to any one
|
|
4
|
+
harness: eval's LLM-as-judge metrics use it today, and e2e's casegen ``LLMCompiler`` will use
|
|
5
|
+
it tomorrow. So it lives in ``common`` (like ``case`` / ``verdict`` / ``facets``) — the 3 SDKs
|
|
6
|
+
compose ``LLMClient`` without importing each other.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from harness_common.llm.client import ChatResult as ChatResult
|
|
10
|
+
from harness_common.llm.client import LLMClient as LLMClient
|
|
11
|
+
from harness_common.llm.client import LLMConfig as LLMConfig
|