lisa-harness-e2e 0.1.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.
- e2etest/__init__.py +1 -0
- e2etest/bundle.py +67 -0
- e2etest/cli.py +63 -0
- e2etest/driver.py +249 -0
- e2etest/environment.py +134 -0
- e2etest/oracles.py +420 -0
- e2etest/packs_venv.py +59 -0
- e2etest/scenario.py +146 -0
- e2etest/scripted.py +236 -0
- lisa_harness_e2e-0.1.0.dist-info/METADATA +13 -0
- lisa_harness_e2e-0.1.0.dist-info/RECORD +13 -0
- lisa_harness_e2e-0.1.0.dist-info/WHEEL +4 -0
- lisa_harness_e2e-0.1.0.dist-info/entry_points.txt +2 -0
e2etest/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""End-to-end scenario harness for Lisa packs (drives a full ``HostRuntime``)."""
|
e2etest/bundle.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Write a run bundle: verdict, raw outcome, and a readable transcript."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from e2etest.oracles import Evidence, OracleResult
|
|
11
|
+
from e2etest.scenario import Scenario
|
|
12
|
+
|
|
13
|
+
_TEXT_LIMIT = 4000
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verdict(scenario: Scenario, evidence: Evidence, results: list[OracleResult]) -> dict[str, Any]:
|
|
17
|
+
failed = [r for r in results if not r.passed]
|
|
18
|
+
return {
|
|
19
|
+
"scenario": scenario.scenario_id,
|
|
20
|
+
"scenario_file": str(scenario.source),
|
|
21
|
+
"run_dir": str(evidence.paths.run_dir),
|
|
22
|
+
"passed": not failed,
|
|
23
|
+
"first_failure": asdict(failed[0]) if failed else None,
|
|
24
|
+
"exchanges": len(evidence.outcome.exchanges),
|
|
25
|
+
"aborted": evidence.outcome.aborted,
|
|
26
|
+
"results": [asdict(r) for r in results],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _transcript(evidence: Evidence) -> str:
|
|
31
|
+
lines = ["# Transcript", ""]
|
|
32
|
+
for ex in evidence.outcome.exchanges:
|
|
33
|
+
lines.append(f"## #{ex.index} {ex.kind} ({ex.duration_s}s)")
|
|
34
|
+
lines += ["", "```text", ex.message, "```", ""]
|
|
35
|
+
flags = []
|
|
36
|
+
if ex.approval:
|
|
37
|
+
flags.append(
|
|
38
|
+
f"approval: {ex.approval.get('target_id')} ({ex.approval.get('request_id')})"
|
|
39
|
+
)
|
|
40
|
+
if ex.operator_exit:
|
|
41
|
+
flags.append(f"operator_exit: {ex.operator_exit}")
|
|
42
|
+
if ex.yielded_question:
|
|
43
|
+
flags.append(f"yielded: {ex.yielded_question}")
|
|
44
|
+
flags += [f"error: {e}" for e in ex.errors]
|
|
45
|
+
lines += [f"- {f}" for f in flags]
|
|
46
|
+
text = ex.text if len(ex.text) <= _TEXT_LIMIT else ex.text[:_TEXT_LIMIT] + "\n…[truncated]"
|
|
47
|
+
lines += ["", text, ""]
|
|
48
|
+
lines += ["# Thread entries", ""]
|
|
49
|
+
for entry in evidence.thread_entries:
|
|
50
|
+
receipts = [r.get("capability_id") for r in entry.get("effect_receipts") or ()]
|
|
51
|
+
provenance = entry.get("provenance") or {}
|
|
52
|
+
lines.append(
|
|
53
|
+
f"- turn {entry.get('turn')} mode={entry.get('mode_id')} "
|
|
54
|
+
f"handoff={entry.get('handoff_to') or '-'} model={provenance.get('model_id', '-')} "
|
|
55
|
+
f"exit={provenance.get('operator_exit') or '-'} receipts={receipts}"
|
|
56
|
+
)
|
|
57
|
+
return "\n".join(lines) + "\n"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def write_bundle(run_dir: Path, payload: dict[str, Any], evidence: Evidence) -> Path:
|
|
61
|
+
(run_dir / "outcome.json").write_text(
|
|
62
|
+
json.dumps(evidence.outcome.to_json(), indent=1), encoding="utf-8"
|
|
63
|
+
)
|
|
64
|
+
(run_dir / "transcript.md").write_text(_transcript(evidence), encoding="utf-8")
|
|
65
|
+
path = run_dir / "verdict.json"
|
|
66
|
+
path.write_text(json.dumps(payload, indent=1), encoding="utf-8")
|
|
67
|
+
return path
|
e2etest/cli.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""``lisa-e2e``: run one scenario end to end and write a verdict bundle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from e2etest.bundle import verdict, write_bundle
|
|
11
|
+
from e2etest.driver import run_conversation
|
|
12
|
+
from e2etest.environment import prepare_run
|
|
13
|
+
from e2etest.oracles import Evidence, evaluate
|
|
14
|
+
from e2etest.packs_venv import ensure_packs_venv
|
|
15
|
+
from e2etest.scenario import load_scenario
|
|
16
|
+
|
|
17
|
+
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
18
|
+
_EXIT_FAIL = 1
|
|
19
|
+
_EXIT_SETUP = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parser() -> argparse.ArgumentParser:
|
|
23
|
+
parser = argparse.ArgumentParser(prog="lisa-e2e", description=__doc__)
|
|
24
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
25
|
+
run = sub.add_parser("run", help="Run a scenario against a live model.")
|
|
26
|
+
run.add_argument("scenario", type=Path)
|
|
27
|
+
run.add_argument("--runs-dir", type=Path, default=_REPO_ROOT / ".e2e" / "runs")
|
|
28
|
+
run.add_argument("--rebuild-venv", action="store_true")
|
|
29
|
+
return parser
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _run(args: argparse.Namespace) -> int:
|
|
33
|
+
scenario = load_scenario(args.scenario)
|
|
34
|
+
venv = ensure_packs_venv(
|
|
35
|
+
scenario.packs, _REPO_ROOT / ".e2e" / "cache", rebuild=args.rebuild_venv
|
|
36
|
+
)
|
|
37
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
|
38
|
+
run_dir = (args.runs_dir / scenario.scenario_id.replace("/", "__") / stamp).resolve()
|
|
39
|
+
paths = prepare_run(scenario, run_dir, venv)
|
|
40
|
+
print(f"run dir: {run_dir}", flush=True)
|
|
41
|
+
outcome = run_conversation(scenario, paths, venv)
|
|
42
|
+
evidence = Evidence(paths, outcome)
|
|
43
|
+
payload = verdict(scenario, evidence, evaluate(scenario.oracles, evidence))
|
|
44
|
+
path = write_bundle(run_dir, payload, evidence)
|
|
45
|
+
for result in payload["results"]:
|
|
46
|
+
mark = "PASS" if result["passed"] else "FAIL"
|
|
47
|
+
first = result["detail"].splitlines()[0] if result["detail"] else ""
|
|
48
|
+
print(f"{mark} {result['phase']}.{result['name']}: {first}")
|
|
49
|
+
print(f"verdict: {'PASS' if payload['passed'] else 'FAIL'} → {path}")
|
|
50
|
+
return 0 if payload["passed"] else _EXIT_FAIL
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main(argv: list[str] | None = None) -> int:
|
|
54
|
+
args = _parser().parse_args(argv)
|
|
55
|
+
try:
|
|
56
|
+
return _run(args)
|
|
57
|
+
except (OSError, ValueError, RuntimeError) as exc:
|
|
58
|
+
print(f"setup error: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
59
|
+
return _EXIT_SETUP
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
sys.exit(main())
|
e2etest/driver.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Drive a real ``HostRuntime`` through a scenario's conversation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import runtime.turn_hooks_builder
|
|
15
|
+
from runtime.bootstrap import build_runtime
|
|
16
|
+
from runtime.handle_message import handle_message
|
|
17
|
+
from runtime.paths import inject_packs_venv_site_packages
|
|
18
|
+
from runtime.types import HostCommandResult
|
|
19
|
+
|
|
20
|
+
from e2etest.environment import RunPaths
|
|
21
|
+
from e2etest.scenario import Scenario
|
|
22
|
+
from e2etest.scripted import ScriptedGatewayProvider, load_script
|
|
23
|
+
|
|
24
|
+
_APPROVAL_SCHEMA = "lisa.approval.v1"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Exchange:
|
|
29
|
+
"""One message sent to the host and what came back."""
|
|
30
|
+
|
|
31
|
+
index: int
|
|
32
|
+
kind: str
|
|
33
|
+
message: str
|
|
34
|
+
text: str = ""
|
|
35
|
+
is_error: bool = False
|
|
36
|
+
operator_exit: str = ""
|
|
37
|
+
yielded_question: str = ""
|
|
38
|
+
approval: dict[str, Any] | None = None
|
|
39
|
+
errors: list[str] = field(default_factory=list)
|
|
40
|
+
duration_s: float = 0.0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class DriverOutcome:
|
|
45
|
+
"""Everything the driver observed; ``aborted`` names a blown budget."""
|
|
46
|
+
|
|
47
|
+
exchanges: list[Exchange] = field(default_factory=list)
|
|
48
|
+
stages: list[dict[str, Any]] = field(default_factory=list)
|
|
49
|
+
aborted: str = ""
|
|
50
|
+
model_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
def to_json(self) -> dict[str, Any]:
|
|
53
|
+
return {
|
|
54
|
+
"aborted": self.aborted,
|
|
55
|
+
"exchanges": [asdict(e) for e in self.exchanges],
|
|
56
|
+
"stages": self.stages,
|
|
57
|
+
"model_calls": self.model_calls,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RecordingTransport:
|
|
62
|
+
"""``TransportCallbacks`` that records stages and errors instead of rendering."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, outcome: DriverOutcome) -> None:
|
|
65
|
+
self._outcome = outcome
|
|
66
|
+
self.errors: list[str] = []
|
|
67
|
+
|
|
68
|
+
async def on_stage(self, label: str, data: dict[str, Any] | None = None) -> None:
|
|
69
|
+
self._outcome.stages.append(
|
|
70
|
+
{
|
|
71
|
+
"exchange": len(self._outcome.exchanges),
|
|
72
|
+
"t": round(time.monotonic(), 3),
|
|
73
|
+
"label": label,
|
|
74
|
+
"data": _jsonable(data or {}),
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
async def on_text_chunk(self, text: str, reasoning: str = "") -> None:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
async def on_response(self, text: str, data: dict[str, Any] | None = None) -> None:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
async def on_debug_context(self, context: dict[str, Any]) -> None:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
async def on_error(self, message: str, exc: Exception | None = None) -> None:
|
|
88
|
+
self.errors.append(message)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _jsonable(value: Any) -> Any:
|
|
92
|
+
if isinstance(value, dict):
|
|
93
|
+
return {str(k): _jsonable(v) for k, v in value.items()}
|
|
94
|
+
if isinstance(value, list | tuple):
|
|
95
|
+
return [_jsonable(v) for v in value]
|
|
96
|
+
if value is None or isinstance(value, str | int | float | bool):
|
|
97
|
+
return value
|
|
98
|
+
return str(value)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def approval_payload(result: HostCommandResult) -> dict[str, Any] | None:
|
|
102
|
+
"""Return the structured approval request a turn paused on, if any."""
|
|
103
|
+
data = result.data or {}
|
|
104
|
+
nested = data.get("approval")
|
|
105
|
+
if isinstance(nested, dict) and nested.get("request_id"):
|
|
106
|
+
return nested
|
|
107
|
+
if data.get("schema") == _APPROVAL_SCHEMA and data.get("request_id"):
|
|
108
|
+
return data
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _Conversation:
|
|
113
|
+
def __init__(self, runtime: Any, scenario: Scenario, outcome: DriverOutcome) -> None:
|
|
114
|
+
self.runtime = runtime
|
|
115
|
+
self.scenario = scenario
|
|
116
|
+
self.outcome = outcome
|
|
117
|
+
self.transport = RecordingTransport(outcome)
|
|
118
|
+
self.decisions = 0
|
|
119
|
+
self.continues = 0
|
|
120
|
+
self.yield_replies = 0
|
|
121
|
+
|
|
122
|
+
async def send(self, kind: str, message: str) -> Exchange:
|
|
123
|
+
if len(self.outcome.exchanges) >= self.scenario.budgets.max_messages:
|
|
124
|
+
raise _BudgetExceededError(f"max_messages={self.scenario.budgets.max_messages}")
|
|
125
|
+
message = self._expand_message(message)
|
|
126
|
+
exchange = Exchange(index=len(self.outcome.exchanges), kind=kind, message=message)
|
|
127
|
+
self.outcome.exchanges.append(exchange)
|
|
128
|
+
self.transport.errors = []
|
|
129
|
+
started = time.monotonic()
|
|
130
|
+
result = await handle_message(self.runtime, message, self.transport)
|
|
131
|
+
exchange.duration_s = round(time.monotonic() - started, 2)
|
|
132
|
+
exchange.text = result.text
|
|
133
|
+
exchange.is_error = result.is_error
|
|
134
|
+
exchange.operator_exit = result.operator_exit
|
|
135
|
+
exchange.yielded_question = result.yielded_question
|
|
136
|
+
exchange.approval = approval_payload(result)
|
|
137
|
+
exchange.errors = list(self.transport.errors)
|
|
138
|
+
return exchange
|
|
139
|
+
|
|
140
|
+
def _expand_message(self, message: str) -> str:
|
|
141
|
+
"""Resolve ``$RECOVERY_OPEN_ID`` from the run's recovery store.
|
|
142
|
+
|
|
143
|
+
Substitutes the work-item id of the first open (running/stalled/
|
|
144
|
+
indeterminate) recovery record, read straight from
|
|
145
|
+
``data/state/recovery-items.json``. Left verbatim when there is no
|
|
146
|
+
open record (the substitution happens at send time, so a stall
|
|
147
|
+
created by an earlier turn resolves here). Lets scenarios drive
|
|
148
|
+
deterministic operator remediation by id without parsing model
|
|
149
|
+
prose.
|
|
150
|
+
"""
|
|
151
|
+
if "$RECOVERY_OPEN_ID" not in message:
|
|
152
|
+
return message
|
|
153
|
+
path = self.runtime.paths.data_dir / "state" / "recovery-items.json"
|
|
154
|
+
if not path.is_file():
|
|
155
|
+
return message
|
|
156
|
+
try:
|
|
157
|
+
records = json.loads(path.read_text(encoding="utf-8"))
|
|
158
|
+
except (OSError, ValueError):
|
|
159
|
+
return message
|
|
160
|
+
if not isinstance(records, dict):
|
|
161
|
+
return message
|
|
162
|
+
for record in records.values():
|
|
163
|
+
if record.get("state") in ("running", "stalled", "indeterminate"):
|
|
164
|
+
return message.replace("$RECOVERY_OPEN_ID", str(record.get("work_item_id") or ""))
|
|
165
|
+
return message
|
|
166
|
+
|
|
167
|
+
async def follow_up(self, exchange: Exchange) -> Exchange | None:
|
|
168
|
+
"""Answer whatever left the turn unfinished, or return ``None`` when it finished."""
|
|
169
|
+
budgets = self.scenario.budgets
|
|
170
|
+
if exchange.approval:
|
|
171
|
+
if self.decisions >= self.scenario.approvals.max_decisions:
|
|
172
|
+
raise _BudgetExceededError("approvals.max_decisions")
|
|
173
|
+
self.decisions += 1
|
|
174
|
+
verdict = self.scenario.approvals.decide(str(exchange.approval.get("target_id", "")))
|
|
175
|
+
return await self.send(verdict, f"/{verdict} {exchange.approval['request_id']}")
|
|
176
|
+
if exchange.yielded_question and self.yield_replies < budgets.max_yield_replies:
|
|
177
|
+
self.yield_replies += 1
|
|
178
|
+
return await self.send("yield_reply", self.scenario.yield_reply)
|
|
179
|
+
if exchange.operator_exit and self.continues < budgets.max_continues:
|
|
180
|
+
self.continues += 1
|
|
181
|
+
return await self.send("continue", self.scenario.continue_message)
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
async def run(self) -> None:
|
|
185
|
+
for turn in self.scenario.turns:
|
|
186
|
+
exchange: Exchange | None = await self.send("turn", turn)
|
|
187
|
+
while exchange is not None:
|
|
188
|
+
exchange = await self.follow_up(exchange)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class _BudgetExceededError(Exception):
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def _drive(runtime: Any, scenario: Scenario, outcome: DriverOutcome) -> None:
|
|
196
|
+
conversation = _Conversation(runtime, scenario, outcome)
|
|
197
|
+
await runtime.start()
|
|
198
|
+
try:
|
|
199
|
+
async with asyncio.timeout(scenario.budgets.wall_clock_s):
|
|
200
|
+
await conversation.run()
|
|
201
|
+
except _BudgetExceededError as exc:
|
|
202
|
+
outcome.aborted = f"budget exceeded: {exc}"
|
|
203
|
+
except TimeoutError:
|
|
204
|
+
outcome.aborted = f"budget exceeded: wall_clock_s={scenario.budgets.wall_clock_s}"
|
|
205
|
+
finally:
|
|
206
|
+
try:
|
|
207
|
+
await asyncio.wait_for(runtime.aclose(), timeout=30)
|
|
208
|
+
except TimeoutError:
|
|
209
|
+
outcome.aborted = outcome.aborted or "runtime.aclose() timed out"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@contextmanager
|
|
213
|
+
def _gateway_override(scripted: ScriptedGatewayProvider | None) -> Iterator[None]:
|
|
214
|
+
"""Bind every provider gateway to the model script while the runtime is wired."""
|
|
215
|
+
if scripted is None:
|
|
216
|
+
yield
|
|
217
|
+
return
|
|
218
|
+
hooks = runtime.turn_hooks_builder
|
|
219
|
+
original = hooks._resolve_gateway_provider_for_runtime
|
|
220
|
+
hooks._resolve_gateway_provider_for_runtime = lambda _runtime: scripted
|
|
221
|
+
try:
|
|
222
|
+
yield
|
|
223
|
+
finally:
|
|
224
|
+
hooks._resolve_gateway_provider_for_runtime = original
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def run_conversation(scenario: Scenario, paths: RunPaths, packs_venv: Path) -> DriverOutcome:
|
|
228
|
+
"""Boot a runtime against ``paths`` and play the scenario's turns."""
|
|
229
|
+
outcome = DriverOutcome()
|
|
230
|
+
if inject_packs_venv_site_packages(packs_venv) is None:
|
|
231
|
+
outcome.aborted = f"packs venv has no site-packages: {packs_venv}"
|
|
232
|
+
return outcome
|
|
233
|
+
scripted: ScriptedGatewayProvider | None = None
|
|
234
|
+
if scenario.model_script is not None:
|
|
235
|
+
scripted = load_script(scenario.model_script)
|
|
236
|
+
outcome.model_calls = scripted.log
|
|
237
|
+
with _gateway_override(scripted):
|
|
238
|
+
runtime = build_runtime(
|
|
239
|
+
host_kind="e2e",
|
|
240
|
+
data_dir=str(paths.data_dir),
|
|
241
|
+
session_context=str(paths.session_context),
|
|
242
|
+
streaming="none",
|
|
243
|
+
in_process_mcp=True,
|
|
244
|
+
)
|
|
245
|
+
if runtime.session.restart_required:
|
|
246
|
+
outcome.aborted = "runtime requested a restart during boot (pack auto-install?)"
|
|
247
|
+
return outcome
|
|
248
|
+
asyncio.run(_drive(runtime, scenario, outcome))
|
|
249
|
+
return outcome
|
e2etest/environment.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Isolated run directory: data dir, config, fixture repo, and process env."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from string import Template
|
|
11
|
+
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
|
|
14
|
+
from e2etest.scenario import Scenario
|
|
15
|
+
|
|
16
|
+
_DEFAULT_POLICY = """\
|
|
17
|
+
version: 1
|
|
18
|
+
mode: ask
|
|
19
|
+
sandbox_downgrade: true
|
|
20
|
+
bare_requires_approval: false
|
|
21
|
+
allow: []
|
|
22
|
+
ask: []
|
|
23
|
+
deny: []
|
|
24
|
+
"""
|
|
25
|
+
# Operator location overrides that would point the run back at real state.
|
|
26
|
+
_LOCATION_ENV = (
|
|
27
|
+
"LISA_PROFILE",
|
|
28
|
+
"LISA_THREAD_DIR",
|
|
29
|
+
"LISA_SESSION_CONTEXT_PATH",
|
|
30
|
+
"LISA_MCP_LOG_PATH",
|
|
31
|
+
)
|
|
32
|
+
_GIT_IDENTITY = {
|
|
33
|
+
"GIT_AUTHOR_NAME": "lisa-e2e",
|
|
34
|
+
"GIT_AUTHOR_EMAIL": "lisa-e2e@localhost",
|
|
35
|
+
"GIT_COMMITTER_NAME": "lisa-e2e",
|
|
36
|
+
"GIT_COMMITTER_EMAIL": "lisa-e2e@localhost",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class RunPaths:
|
|
42
|
+
"""Every location a run touches; all live under ``run_dir``."""
|
|
43
|
+
|
|
44
|
+
run_dir: Path
|
|
45
|
+
data_dir: Path
|
|
46
|
+
workspace: Path | None
|
|
47
|
+
remote: Path | None
|
|
48
|
+
initial_main_sha: str
|
|
49
|
+
scenario_dir: Path | None = None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def session_context(self) -> Path:
|
|
53
|
+
return self.data_dir / "config" / "session_context.md"
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def files_root(self) -> Path:
|
|
57
|
+
"""The lisa-storage sandbox root (``StorageBackend.files_root()``)."""
|
|
58
|
+
return self.data_dir / "data" / "files"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def base_data_dir() -> Path:
|
|
62
|
+
"""The operator's real data dir; only models.yaml and .env are read from it."""
|
|
63
|
+
override = os.getenv("LISA_E2E_BASE_DATA_DIR", "").strip()
|
|
64
|
+
return Path(override).expanduser() if override else Path.home() / ".lisa"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def git(cwd: Path, *args: str) -> str:
|
|
68
|
+
proc = subprocess.run(
|
|
69
|
+
["git", *args],
|
|
70
|
+
cwd=cwd,
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
check=True,
|
|
74
|
+
env={**os.environ, **_GIT_IDENTITY},
|
|
75
|
+
)
|
|
76
|
+
return proc.stdout.strip()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _seed_fixture(scenario: Scenario, run_dir: Path) -> tuple[Path | None, Path | None, str]:
|
|
80
|
+
if scenario.fixture_repo is None:
|
|
81
|
+
return None, None, ""
|
|
82
|
+
workspace = run_dir / "workspace" / scenario.fixture_repo.name
|
|
83
|
+
shutil.copytree(
|
|
84
|
+
scenario.fixture_repo, workspace, ignore=shutil.ignore_patterns("__pycache__", "*.pyc")
|
|
85
|
+
)
|
|
86
|
+
git(workspace, "init", "--quiet", "--initial-branch", "main")
|
|
87
|
+
for key, value in (("user.name", "lisa-e2e"), ("user.email", "lisa-e2e@localhost")):
|
|
88
|
+
git(workspace, "config", key, value)
|
|
89
|
+
git(workspace, "add", "-A")
|
|
90
|
+
git(workspace, "commit", "--quiet", "-m", "fixture: initial state")
|
|
91
|
+
remote = run_dir / "remote.git"
|
|
92
|
+
git(run_dir, "init", "--quiet", "--bare", str(remote))
|
|
93
|
+
git(workspace, "remote", "add", "origin", str(remote))
|
|
94
|
+
git(workspace, "push", "--quiet", "origin", "main")
|
|
95
|
+
return workspace, remote, git(workspace, "rev-parse", "HEAD")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def prepare_run(scenario: Scenario, run_dir: Path, packs_venv: Path) -> RunPaths:
|
|
99
|
+
"""Create the run dir, seed config + fixture, and point the process env at it."""
|
|
100
|
+
data_dir = run_dir / "data"
|
|
101
|
+
config_dir = data_dir / "config"
|
|
102
|
+
config_dir.mkdir(parents=True)
|
|
103
|
+
base = base_data_dir()
|
|
104
|
+
models = base / "config" / "models.yaml"
|
|
105
|
+
if not models.is_file():
|
|
106
|
+
raise FileNotFoundError(f"base models.yaml not found: {models}")
|
|
107
|
+
shutil.copy2(models, config_dir / "models.yaml")
|
|
108
|
+
policy_text = (
|
|
109
|
+
scenario.policy.read_text(encoding="utf-8") if scenario.policy else _DEFAULT_POLICY
|
|
110
|
+
)
|
|
111
|
+
(config_dir / "policy.yaml").write_text(policy_text, encoding="utf-8")
|
|
112
|
+
|
|
113
|
+
workspace, remote, initial_sha = _seed_fixture(scenario, run_dir)
|
|
114
|
+
paths = RunPaths(run_dir, data_dir, workspace, remote, initial_sha, scenario.base_dir)
|
|
115
|
+
if scenario.fixture_data_files is not None:
|
|
116
|
+
shutil.copytree(scenario.fixture_data_files, paths.files_root)
|
|
117
|
+
template = Template(scenario.session_context.read_text(encoding="utf-8"))
|
|
118
|
+
paths.session_context.write_text(
|
|
119
|
+
template.substitute(
|
|
120
|
+
RUN_DIR=str(run_dir),
|
|
121
|
+
WORKSPACE=str(workspace or ""),
|
|
122
|
+
WORKSPACE_PARENT=str(workspace.parent if workspace else ""),
|
|
123
|
+
),
|
|
124
|
+
encoding="utf-8",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
load_dotenv(base / ".env", override=False)
|
|
128
|
+
for name in _LOCATION_ENV:
|
|
129
|
+
os.environ.pop(name, None)
|
|
130
|
+
os.environ["LISA_DATA_DIR"] = str(data_dir)
|
|
131
|
+
os.environ["LISA_PACKS_VENV"] = str(packs_venv)
|
|
132
|
+
os.environ.update(_GIT_IDENTITY)
|
|
133
|
+
os.environ.update(scenario.env)
|
|
134
|
+
return paths
|