mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* feat(tests): e2e scenario 2 — the PM merge chain through the PR gate
Shared arcs extracted (arcs.py: canonical-company seeding + dev/qa/doc
segments); scenario 2 seeds a root->cell->dev hierarchy mid-flight, rides
the child through the scenario-1 arc into the cell branch (real squash
via the fake GitHub), then submit_up -> claim_gate_review/pr_pass ->
dispatcher re-claim (mirrored) -> PM complete merging cell->root. This is
the exact PM->reviewer->PM turn sequence the wave-1 turn cut shortens —
the BEFORE-net. Learned seams scripted: commit-subject validator (>=20
chars), reviewer learning-note gate, pr_pass clears ownership by design.
* feat(runtime): PR-gate turn cut — assembled parents auto-submit to the reviewer
When every child of an assembled parent is terminal, the closure
dispatcher now runs the real submit_up/submit_root through the internal
API as the owning PM (_try_auto_submit) instead of spawning the PM for
that turn — the submit's substance is deterministic gate code. Any gate
refusal falls back to the classic PM closure spawn; pr_fail routing and
the PM's final merge turn are unchanged; umbrellas never auto-submit.
ROBOCO_PR_GATE_AUTO_SUBMIT_ENABLED default-on; task.auto_submitted audit
row per cut. Proven by e2e scenario 2b (real API, real gates, real git)
against scenario 2 as the before-net.
* feat(notes): structured note sections carry a written_at trace stamp
Sections are overwrite-in-place, so without a stamp there was no way to
reconstruct WHEN a dev/qa/doc/reviewer note landed (CEO reMarkable item:
trace TIMESTAMPS). apply_structured_note stamps ISO written_at beside
the model fields; the panel notes tab renders it next to each card
title (pre-stamp rows render nothing). Progress updates, commits, and
journal entries already carried timestamps — this was the one gap.
* feat(tasks): server-side task search — title, details, and id prefix
The task list's search box only matched titles client-side, and the
trimmed summary payload deliberately carries no description — so
keyword/details/id search was impossible in the browser by design.
GET /tasks/summary gains q (ILIKE over title+description, id-prefix
match, composed with team/status and the view-permission scoping);
the panel debounces the box into the summary fetch and drops the
title-only client filter that would have hidden description matches.
* feat(wave-1): trace timestamps, real task search, Secretary task edits
- apply_structured_note stamps written_at per section; the panel notes
tab shows it (the one trace surface without a timestamp).
- GET /tasks/summary?q= searches title+description+id-prefix server-side
(summaries carry no description by design); panel debounces into the
fetch and drops the title-only client filter.
- Secretary control_task gains a CEO-gated edit action over the content
allowlist, and GET /secretary/tasks?q= resolves task names to ids for
the chat. PM-side expansion deferred per the CEO's 'not that much'.
* fix(workspace): dep-update probe scrubs the inherited venv pin
Under uv run the orchestrator's process tree carries VIRTUAL_ENV, and a
uv-based dep_update_command in the throwaway probe clone would target
that venv instead of the clone's — the same hazard _uv_subprocess_env
already guards on the install path.
* build: private per-repo uv cache — isolate from machine-wide uvx servers
Root cause of the recurring rich/pip/bandit rot, with evidence: uv cache
clean timed out on the ~/.cache/uv lock ('is another uv process
running?') — three uvx mcp-server-fetch processes (Claude Code fetch MCP,
one alive since Wednesday) share that cache and race repo syncs on it;
poisoned entries then survive venv rebuilds because rm -rf .venv never
touches the cache, and every re-link reproduces the breakage. UV_CACHE_DIR
now pins <repo>/.uv-cache (gitignored). The earlier UV_NO_SYNC
serialization stays as defense-in-depth but was not the whole story.
* feat(tests): e2e scenario 3 — pr_fail revision loop + root→CEO chain
3a: reviewer pr_fail with a concrete issue -> needs_revision ->
i_will_plan re-entry (full plan gates) -> real fix lands on the cell
branch (the unchanged-PR hard gate refuses resubmit until it does) ->
clean second pass -> merge. 3b: submit_root -> gate -> Main PM complete
escalates the root to the CEO -> the REAL approve-and-merge endpoint
squash-merges to the origin's master. Harness gains the tasks router, a
seeded CEO identity, origin_commit, and a fake GitHub whose head.sha is
recomputed live (real-GitHub semantics the unchanged gate reads). Seeds
now encode the real shape: delivery roots are team=main_pm and
planning-typed.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
480 lines
17 KiB
Python
480 lines
17 KiB
Python
"""e2e smoke harness — in-process RoboCo stack + scripted-agent driver.
|
|
|
|
Pieces (all REAL except GitHub and the LLM):
|
|
|
|
- The API: the real v1 flow/do routers + real middleware/exception handlers,
|
|
served by uvicorn in a thread, over the ephemeral test Postgres (the app's
|
|
own lazy engine is pointed at it by patching ``settings.database_*`` and
|
|
resetting ``_DbHolder``).
|
|
- Git: a local bare origin whose path CONTAINS ``github.com/<owner>/<repo>``
|
|
— ``_parse_git_url`` extracts owner/repo from it while clone/fetch/push
|
|
run tokenless over the local protocol.
|
|
- GitHub REST: a fake ``/_github`` router mounted on the same app
|
|
(``settings.github_api_base_url`` points at it). PR state lives in memory;
|
|
merges perform REAL git merges (squash included) on the bare origin, so
|
|
downstream git logic (cherry checks, freshness, branch sync) sees reality.
|
|
- Agents: ``ScriptedAgent`` reloads the REAL ``roboco.mcp.flow_server`` /
|
|
``do_server`` modules with that agent's env (id, role, role-scoped
|
|
manifest built from the real ``role_config``) and calls the REAL tool
|
|
functions, which POST to the in-process API over loopback HTTP.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import importlib
|
|
import json
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from contextlib import suppress
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
import uvicorn
|
|
from cryptography.fernet import Fernet
|
|
from fastapi import APIRouter, FastAPI, Request
|
|
from fastapi.responses import JSONResponse
|
|
from sqlalchemy.engine.url import make_url
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
from types import ModuleType
|
|
from uuid import UUID
|
|
|
|
_OWNER = "e2e-smoke"
|
|
_REPO = "proj"
|
|
|
|
|
|
def _git(cwd: Path, *args: str) -> str:
|
|
res = subprocess.run(
|
|
["git", "-C", str(cwd), *args],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
return res.stdout.strip()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fake GitHub REST — PR state in memory, merges as REAL git ops on the origin
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class _FakeGitHub:
|
|
origin: Path
|
|
admin_clone: Path
|
|
prs: dict[int, dict[str, Any]] = field(default_factory=dict)
|
|
comments: list[dict[str, Any]] = field(default_factory=list)
|
|
next_number: int = 1
|
|
|
|
def create_pr(self, title: str, body: str, head: str, base: str) -> dict[str, Any]:
|
|
number = self.next_number
|
|
self.next_number += 1
|
|
pr = {
|
|
"number": number,
|
|
"html_url": f"https://github.com/{_OWNER}/{_REPO}/pull/{number}",
|
|
"title": title,
|
|
"body": body,
|
|
"state": "open",
|
|
"merged": False,
|
|
"head": {
|
|
"ref": head,
|
|
"sha": self._sha_of(head),
|
|
"repo": {"full_name": f"{_OWNER}/{_REPO}"},
|
|
},
|
|
"base": {"ref": base},
|
|
"user": {"login": "e2e-bot"},
|
|
"author_association": "MEMBER",
|
|
}
|
|
self.prs[number] = pr
|
|
return pr
|
|
|
|
def _sha_of(self, branch: str) -> str:
|
|
try:
|
|
return _git(self.origin, "rev-parse", branch)
|
|
except subprocess.CalledProcessError:
|
|
return "0" * 40
|
|
|
|
def merge_pr(self, number: int, merge_method: str) -> dict[str, Any]:
|
|
pr = self.prs[number]
|
|
head, base = pr["head"]["ref"], pr["base"]["ref"]
|
|
admin = self.admin_clone
|
|
_git(admin, "fetch", "origin", "--prune")
|
|
_git(admin, "checkout", "-B", base, f"origin/{base}")
|
|
if merge_method == "squash":
|
|
_git(admin, "merge", "--squash", f"origin/{head}")
|
|
_git(admin, "commit", "-m", f"{pr['title']} (#{number})")
|
|
else:
|
|
_git(
|
|
admin,
|
|
"merge",
|
|
"--no-ff",
|
|
"-m",
|
|
f"Merge pull request #{number} from {head}",
|
|
f"origin/{head}",
|
|
)
|
|
_git(admin, "push", "origin", base)
|
|
sha = _git(admin, "rev-parse", "HEAD")
|
|
pr["merged"] = True
|
|
pr["state"] = "closed"
|
|
return {
|
|
"merged": True,
|
|
"sha": sha,
|
|
"message": "Pull Request successfully merged",
|
|
}
|
|
|
|
def open_prs(self, head: str | None, base: str | None) -> list[dict[str, Any]]:
|
|
out = []
|
|
for pr in self.prs.values():
|
|
if pr["state"] != "open":
|
|
continue
|
|
if head and pr["head"]["ref"] != head.split(":", 1)[-1]:
|
|
continue
|
|
if base and pr["base"]["ref"] != base:
|
|
continue
|
|
out.append(pr)
|
|
return out
|
|
|
|
|
|
def _fake_github_router(gh: _FakeGitHub) -> APIRouter:
|
|
r = APIRouter(prefix="/_github")
|
|
|
|
@r.get("/repos/{owner}/{repo}")
|
|
async def repo_caps(owner: str, repo: str) -> dict[str, Any]:
|
|
return {
|
|
"allow_squash_merge": True,
|
|
"allow_merge_commit": True,
|
|
"allow_rebase_merge": False,
|
|
}
|
|
|
|
@r.get("/repos/{owner}/{repo}/pulls/{number}")
|
|
async def get_pr(owner: str, repo: str, number: int) -> JSONResponse:
|
|
pr = gh.prs.get(number)
|
|
if pr is None:
|
|
return JSONResponse({"message": "Not Found"}, status_code=404)
|
|
# Real GitHub recomputes head.sha as the branch advances; a stale
|
|
# creation-time snapshot broke the unchanged-PR gate's semantics.
|
|
pr["head"]["sha"] = gh._sha_of(pr["head"]["ref"])
|
|
return JSONResponse(pr)
|
|
|
|
@r.get("/repos/{owner}/{repo}/pulls")
|
|
async def list_prs(
|
|
owner: str,
|
|
repo: str,
|
|
head: str | None = None,
|
|
base: str | None = None,
|
|
state: str = "open",
|
|
) -> list[dict[str, Any]]:
|
|
return gh.open_prs(head, base)
|
|
|
|
@r.post("/repos/{owner}/{repo}/pulls", status_code=201)
|
|
async def create_pr(owner: str, repo: str, request: Request) -> dict[str, Any]:
|
|
body = await request.json()
|
|
return gh.create_pr(
|
|
body["title"], body.get("body", ""), body["head"], body["base"]
|
|
)
|
|
|
|
@r.patch("/repos/{owner}/{repo}/pulls/{number}")
|
|
async def patch_pr(
|
|
owner: str, repo: str, number: int, request: Request
|
|
) -> JSONResponse:
|
|
pr = gh.prs.get(number)
|
|
if pr is None:
|
|
return JSONResponse({"message": "Not Found"}, status_code=404)
|
|
body = await request.json()
|
|
for key in ("title", "body", "state"):
|
|
if key in body:
|
|
pr[key] = body[key]
|
|
return JSONResponse(pr)
|
|
|
|
@r.put("/repos/{owner}/{repo}/pulls/{number}/merge")
|
|
async def merge_pr(
|
|
owner: str, repo: str, number: int, request: Request
|
|
) -> JSONResponse:
|
|
if number not in gh.prs:
|
|
return JSONResponse({"message": "Not Found"}, status_code=404)
|
|
body = await request.json()
|
|
try:
|
|
result = gh.merge_pr(number, body.get("merge_method", "merge"))
|
|
except subprocess.CalledProcessError as exc:
|
|
return JSONResponse(
|
|
{"message": f"Merge conflict: {exc.stderr}"}, status_code=409
|
|
)
|
|
return JSONResponse(result)
|
|
|
|
@r.post("/repos/{owner}/{repo}/pulls/{number}/requested_reviewers", status_code=201)
|
|
async def request_reviewers(
|
|
owner: str, repo: str, number: int, request: Request
|
|
) -> dict[str, Any]:
|
|
return gh.prs.get(number, {})
|
|
|
|
@r.post("/repos/{owner}/{repo}/issues/{number}/comments", status_code=201)
|
|
async def comment(
|
|
owner: str, repo: str, number: int, request: Request
|
|
) -> dict[str, Any]:
|
|
gh.comments.append({"number": number, "body": (await request.json())})
|
|
return {"id": len(gh.comments)}
|
|
|
|
@r.delete("/repos/{owner}/{repo}/git/refs/heads/{branch:path}", status_code=204)
|
|
async def delete_branch(owner: str, repo: str, branch: str) -> None:
|
|
with suppress(subprocess.CalledProcessError):
|
|
_git(gh.origin, "branch", "-D", branch)
|
|
|
|
return r
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stack: settings patches + origin + app + uvicorn thread
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class E2EStack:
|
|
base_url: str
|
|
root: Path
|
|
origin: Path
|
|
workspaces_root: Path
|
|
db_url: str
|
|
github: _FakeGitHub
|
|
|
|
def workspace_of(self, project_slug: str, team: str, agent_slug: str) -> Path:
|
|
return self.workspaces_root / project_slug / team / agent_slug
|
|
|
|
def run_db(self, coro_fn: Any) -> Any:
|
|
"""Run ``coro_fn(session)`` against a fresh engine/session and return."""
|
|
|
|
async def _run() -> Any:
|
|
engine = create_async_engine(self.db_url)
|
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
try:
|
|
async with factory() as session:
|
|
result = await coro_fn(session)
|
|
await session.commit()
|
|
return result
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
return asyncio.run(_run())
|
|
|
|
|
|
def _free_port() -> int:
|
|
with socket.socket() as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return int(s.getsockname()[1])
|
|
|
|
|
|
def _seed_origin(root: Path) -> Path:
|
|
"""Bare origin at a path _parse_git_url can read owner/repo from."""
|
|
origin = root / "github.com" / _OWNER / f"{_REPO}.git"
|
|
origin.parent.mkdir(parents=True)
|
|
subprocess.run(
|
|
["git", "init", "--bare", "--initial-branch=master", str(origin)],
|
|
check=True,
|
|
capture_output=True,
|
|
)
|
|
seed = root / "seed-clone"
|
|
subprocess.run(
|
|
["git", "clone", str(origin), str(seed)], check=True, capture_output=True
|
|
)
|
|
_git(seed, "config", "user.name", "roboco-e2e")
|
|
_git(seed, "config", "user.email", "e2e@roboco.local")
|
|
(seed / "README.md").write_text("# e2e smoke project\n")
|
|
_git(seed, "add", "README.md")
|
|
_git(seed, "commit", "-m", "Initial commit")
|
|
_git(seed, "push", "origin", "master")
|
|
return origin
|
|
|
|
|
|
def _make_admin_clone(root: Path, origin: Path) -> Path:
|
|
admin = root / "gh-admin-clone"
|
|
subprocess.run(
|
|
["git", "clone", str(origin), str(admin)], check=True, capture_output=True
|
|
)
|
|
_git(admin, "config", "user.name", "fake-github")
|
|
_git(admin, "config", "user.email", "merge@github.local")
|
|
return admin
|
|
|
|
|
|
def _build_app(gh: _FakeGitHub) -> FastAPI:
|
|
from roboco.api.middleware import setup_middleware
|
|
from roboco.api.routes.health import router as health_router
|
|
from roboco.api.routes.tasks import router as tasks_router
|
|
from roboco.api.routes.v1 import do as do_module
|
|
from roboco.api.routes.v1 import flow_auditor as fa
|
|
from roboco.api.routes.v1 import flow_board as fb
|
|
from roboco.api.routes.v1 import flow_cell_pm as fcp
|
|
from roboco.api.routes.v1 import flow_dev as fd
|
|
from roboco.api.routes.v1 import flow_doc as fdoc
|
|
from roboco.api.routes.v1 import flow_main_pm as fmp
|
|
from roboco.api.routes.v1 import flow_pr_reviewer as fpr
|
|
from roboco.api.routes.v1 import flow_qa as fq
|
|
|
|
app = FastAPI(title="roboco-e2e-smoke")
|
|
setup_middleware(app)
|
|
app.include_router(health_router)
|
|
for module in (fd, fq, fdoc, fcp, fmp, fb, fa, fpr):
|
|
app.include_router(module.router)
|
|
app.include_router(do_module.router)
|
|
# The REST task surface — scenario 3 drives the real CEO
|
|
# approve-and-merge endpoint (the human gate) through it.
|
|
app.include_router(tasks_router, prefix="/api/tasks")
|
|
app.include_router(_fake_github_router(gh))
|
|
return app
|
|
|
|
|
|
def build_e2e_stack(
|
|
_test_database_url: str, tmp_path_factory: pytest.TempPathFactory
|
|
) -> Iterator[E2EStack]:
|
|
"""Generator behind the ``e2e_stack`` fixture (defined in conftest)."""
|
|
from roboco.config import settings
|
|
from roboco.db import base as db_base
|
|
|
|
mp = pytest.MonkeyPatch()
|
|
root = tmp_path_factory.mktemp("e2e")
|
|
origin = _seed_origin(root)
|
|
admin = _make_admin_clone(root, origin)
|
|
gh = _FakeGitHub(origin=origin, admin_clone=admin)
|
|
workspaces = root / "workspaces"
|
|
workspaces.mkdir()
|
|
|
|
url = make_url(_test_database_url)
|
|
mp.setattr(settings, "database_host", url.host or "localhost")
|
|
mp.setattr(settings, "database_port", url.port or 5432)
|
|
mp.setattr(settings, "database_user", url.username or "")
|
|
mp.setattr(settings, "database_password", url.password or "")
|
|
mp.setattr(settings, "database_name", url.database or "")
|
|
mp.setattr(settings, "workspaces_root", str(workspaces))
|
|
mp.setattr(settings, "workspace_auto_clone", True)
|
|
mp.setattr(settings, "encryption_key", Fernet.generate_key().decode())
|
|
|
|
# The app's lazy engine must bind to the patched settings, not a leftover.
|
|
db_base._DbHolder.engine = None
|
|
db_base._DbHolder.session_factory = None
|
|
|
|
port = _free_port()
|
|
base_url = f"http://127.0.0.1:{port}"
|
|
mp.setattr(settings, "github_api_base_url", f"{base_url}/_github")
|
|
|
|
app = _build_app(gh)
|
|
server = uvicorn.Server(
|
|
uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
|
|
)
|
|
thread = threading.Thread(target=server.run, daemon=True)
|
|
thread.start()
|
|
|
|
import httpx
|
|
|
|
deadline = time.time() + 30
|
|
while time.time() < deadline:
|
|
try:
|
|
# Any HTTP response at all means the server thread is up.
|
|
httpx.get(f"{base_url}/health", timeout=1)
|
|
break
|
|
except httpx.HTTPError:
|
|
time.sleep(0.1)
|
|
else:
|
|
raise RuntimeError("e2e app server did not become ready")
|
|
|
|
try:
|
|
yield E2EStack(
|
|
base_url=base_url,
|
|
root=root,
|
|
origin=origin,
|
|
workspaces_root=workspaces,
|
|
db_url=_test_database_url,
|
|
github=gh,
|
|
)
|
|
finally:
|
|
server.should_exit = True
|
|
thread.join(timeout=10)
|
|
db_base._DbHolder.engine = None
|
|
db_base._DbHolder.session_factory = None
|
|
mp.undo()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scripted agents — the REAL MCP tool functions, per-agent module reloads
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class ScriptedAgent:
|
|
"""Drives the real flow/do MCP tool functions as one seeded agent."""
|
|
|
|
def __init__(self, stack: E2EStack, agent_id: UUID, slug: str, role: str) -> None:
|
|
self.stack = stack
|
|
self.agent_id = agent_id
|
|
self.slug = slug
|
|
self.role = role
|
|
self._manifest_path = stack.root / f"manifest-{slug}.json"
|
|
self._manifest_path.write_text(json.dumps(self._manifest()))
|
|
|
|
def _manifest(self) -> dict[str, Any]:
|
|
from roboco.services.gateway.role_config import get_role_config
|
|
|
|
cfg = get_role_config(self.role)
|
|
return {
|
|
"agent_id": str(self.agent_id),
|
|
"role": self.role,
|
|
"team": "backend",
|
|
"workspace_path": str(self.stack.workspaces_root),
|
|
"flow_tools": list(cfg.flow_tools),
|
|
"do_tools": list(cfg.do_tools),
|
|
"read_tools": ["Read", "Glob", "Grep"],
|
|
"write_tools": ["Edit", "Write"] if cfg.allows_write else [],
|
|
"bash_allowed": True,
|
|
"subagent_allowed": False,
|
|
"subagent_model": None,
|
|
"env": {},
|
|
}
|
|
|
|
def _module(self, name: str) -> ModuleType:
|
|
os.environ["ROBOCO_AGENT_ID"] = str(self.agent_id)
|
|
os.environ["ROBOCO_AGENT_ROLE"] = self.role
|
|
os.environ["ROBOCO_ORCHESTRATOR_URL"] = self.stack.base_url
|
|
os.environ["ROBOCO_TOOL_MANIFEST_PATH"] = str(self._manifest_path)
|
|
module = importlib.import_module(name)
|
|
if getattr(module, "AGENT_ID", None) != str(self.agent_id):
|
|
module = importlib.reload(module)
|
|
return module
|
|
|
|
def flow(self, verb: str, /, **kwargs: Any) -> dict[str, Any]:
|
|
result: dict[str, Any] = getattr(self._module("roboco.mcp.flow_server"), verb)(
|
|
**kwargs
|
|
)
|
|
return result
|
|
|
|
def do(self, tool: str, /, **kwargs: Any) -> dict[str, Any]:
|
|
result: dict[str, Any] = getattr(self._module("roboco.mcp.do_server"), tool)(
|
|
**kwargs
|
|
)
|
|
return result
|
|
|
|
|
|
def expect_error(env: dict[str, Any], kind: str, context: str) -> dict[str, Any]:
|
|
"""Assert an envelope is the EXPECTED rejection kind."""
|
|
assert env.get("error") == kind, (
|
|
f"{context}: expected rejection {kind!r}, got error={env.get('error')!r}\n"
|
|
f" full: {json.dumps(env, default=str, indent=2)[:4000]}"
|
|
)
|
|
return env
|
|
|
|
|
|
def expect_ok(env: dict[str, Any], context: str) -> dict[str, Any]:
|
|
"""Assert an envelope is a success; on failure show the whole envelope."""
|
|
assert isinstance(env, dict), f"{context}: non-dict envelope: {env!r}"
|
|
assert not env.get("error"), (
|
|
f"{context}: rejected with error={env.get('error')!r}\n"
|
|
f" message : {env.get('message')}\n"
|
|
f" remediate: {env.get('remediate')}\n"
|
|
f" missing : {env.get('missing')}\n"
|
|
f" full : {json.dumps(env, default=str, indent=2)[:4000]}"
|
|
)
|
|
return env
|