GitNexus/eval/environments/gitnexus_docker.py
John R. Eakin c68d7975e6
docs: agent development framework, GitHub templates, eval refactor (#479)
* ci: E2E workflow, web typecheck job, pre-commit hook, test suite

CI:
- ci.yml consolidated to reference ci-tests.yml
- ci-quality.yml: add typecheck-web job for gitnexus-web/
- ci-e2e.yml: E2E workflow with dorny/paths-filter (web changes only)
- ci-report.yml: remove dead integration-reports references
- CI gate allows skipped E2E status
- .gitignore: playwright artifacts, eval test artifacts

Pre-commit hook:
- .githooks/pre-commit: typecheck + unit tests for both packages
- Activated via git config core.hooksPath in prepare script

Test infrastructure:
- Vitest + React Testing Library: 58 unit tests
  (graph, server-connection, mermaid, settings, constants, utils, paths)
- Playwright E2E: 5 tests + manual recording harness
- vitest.config from vitest/config, engines.node >= 20
- Playwright artifacts retain-on-failure
- wait-on in devDependencies
- vitest/coverage-v8 aligned with vitest 4.x

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update gitnexus-web package-lock.json

Reflects devDependency additions (vitest, playwright, wait-on,
@testing-library, etc.) from package.json changes in this PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(e2e): add missing process-list-loaded testid, increase CI timeouts

- Add data-testid="process-list-loaded" to ProcessesPanel (E2E tests
  were waiting for an element that didn't exist)
- Increase server connect timeouts from 5s to 10s for slower CI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): run gitnexus-web unit tests in CI, remove unused variable

- Add gitnexus-web npm ci + vitest run to ci-tests.yml so web unit
  tests are gated by the CI status check (were only running locally)
- Remove unused IS_PLAYWRIGHT_AUTOMATION variable from E2E spec

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(e2e): add process-row testid, wait for networkidle on page load

- Add data-testid="process-row" to ProcessItem component (E2E tests
  referenced it but it didn't exist in the source)
- Use waitUntil: 'networkidle' on page.goto to ensure Vite dev server
  is fully ready before interacting (fixes first-test timeout in CI)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(e2e): add process-view-button and process-highlight-button testids

E2E tests referenced these data-testid attributes but they didn't
exist in ProcessItem. All 6 E2E testids now have matching source
elements: status-ready, process-list-loaded, process-row,
process-view-button, process-highlight-button, server-url-input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(e2e): remove networkidle — Vite HMR WebSocket prevents it from resolving

networkidle waits for zero network activity for 500ms, but Vite's HMR
WebSocket stays open permanently, causing page.goto to timeout at 60s
on all tests after the first. The explicit toBeVisible waits on UI
elements are sufficient and deterministic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(e2e): wait for Server button visibility, add CI retry, all 5 tests pass locally

Root cause: test 1 clicked the Server button before React hydrated,
so the tab content never rendered and the input wasn't found.

Fixes:
- Wait for Server button toBeVisible before clicking
- Increase input wait to 15s
- Remove networkidle (Vite HMR WebSocket prevents it from resolving)
- Add retries: 1 in CI for transient cold-start flakiness

Verified locally: all 5 E2E tests pass, 198 unit tests pass, typecheck clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): tolerate LadybugDB native crash during analyze step

gitnexus analyze can crash with "double free or corruption" (known
issue #273) during the LadybugDB native addon shutdown. The index is
usually written successfully before the crash. The workflow now:
1. Allows analyze to exit non-zero with a warning
2. Verifies .gitnexus index was actually created
3. Only fails if no index exists (real failure)

All tests verified locally: 198 unit, 5 E2E pass, typecheck clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): fix shell quoting in analyze step, simplify to || true

The previous echo string had special characters that broke bash
quoting in GitHub Actions. Simplified to: analyze || true, then
check if .gitnexus exists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: add agent development framework, GitHub templates, eval refactor

Agent framework (layered docs for AI-assisted contributions):
- AGENTS.md: canonical instructions, impact analysis, MCP tools
- CLAUDE.md: Claude Code-specific deltas and hooks
- GUARDRAILS.md: safety boundaries, non-negotiables, escalation
- ARCHITECTURE.md: monorepo layout, data flow map
- TESTING.md: test structure, commands, categories
- RUNBOOK.md: copy-paste operations for dev/CI/MCP
- llms.txt: minimal LLM context pointer

Editor integration:
- .cursor/index.mdc + rules/100-monorepo.mdc

GitHub templates:
- PR template with areas-touched checkboxes
- Bug report + feature request issue forms

Eval harness:
- Refactored mcp_bridge, tool_registry, constants
- Error sanitization utilities
- Property-based tests via Hypothesis

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(eval): use format_exception instead of format_exc in sanitize_exception

format_exc() returns the currently handled exception traceback, which
may be unrelated if called outside an active except block. Using
format_exception(type(exc), exc, exc.__traceback__) reliably captures
the passed exception's traceback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: update CONTRIBUTING.md and TESTING.md for current CI/hook setup

- CONTRIBUTING.md: add gitnexus-web typecheck command, pre-commit hook
  checklist item
- TESTING.md: add gitnexus-web typecheck command, pre-commit hook
  section (husky), update CI integration to list actual workflow files
  (ci-quality, ci-tests, ci-e2e)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: update testing docs to reflect CI/E2E changes from PR #486

- AGENTS.md: update test counts (CLI ~2000 unit, ~1850 integration),
  add gitnexus-web testing section (198 unit, 5 E2E with commands)
- RUNBOOK.md: fix Node requirement to >=20, fix E2E local repro command
- TESTING.md: E2E uses data-testid selectors + real servers, not mocks
- .cursor/rules/100-monorepo.mdc: add web test/E2E commands

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: address context engineering review — deduplicate tokens, expand Cursor rules

- Remove ~100-line gitnexus:start block from CLAUDE.md (was duplicated from AGENTS.md)
- Fix gitnexus:start block inlined inside AGENTS.md Reference Docs bullet (doubled)
- Replace CLAUDE.md scope table with pointer to AGENTS.md (single source of truth)
- Expand .cursor/index.mdc with 5 non-negotiable safety rules for always-on context
- Add .cursor/rules/200-eval.mdc with Python/eval commands (glob-scoped to eval/**)
- Improve llms.txt with priority annotations and descriptions
- Bump version headers to 1.2.0, last-reviewed to 2026-03-24

Saves ~1,400 tokens/session with zero information loss.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-03-25 06:48:41 +00:00

391 lines
15 KiB
Python

"""
GitNexus Docker Environment for SWE-bench Evaluation
Extends mini-swe-agent's Docker environment to:
1. Install GitNexus (Node.js + npm + gitnexus package)
2. Run `gitnexus analyze` on the repository
3. Start the eval-server daemon (persistent HTTP server with warm KuzuDB)
4. Install standalone tool scripts in /usr/local/bin/ (works with subprocess.run)
5. Cache indexes per (repo, base_commit) to avoid re-indexing
IMPORTANT: mini-swe-agent runs every command with subprocess.run in a fresh subshell.
This means .bashrc is NOT sourced, exported functions are NOT available, and env vars
don't persist. The tool scripts must be standalone executables in $PATH.
Architecture:
Agent bash cmd → /usr/local/bin/gitnexus-query → curl localhost:4848/tool/query → eval-server → KuzuDB
Fallback: → npx gitnexus query (cold start, slower)
Tool call latency: ~50-100ms via eval-server, ~5-10s via CLI fallback.
"""
import hashlib
import json
import logging
import shutil
import time
from pathlib import Path
from constants import (
EVAL_SERVER_HEALTH_INTERVAL_SECONDS,
EVAL_SERVER_HEALTH_RETRIES,
EVAL_SERVER_HEALTH_TIMEOUT_SECONDS,
)
from minisweagent.environments.docker import DockerEnvironment
from tool_registry import TOOL_SPECS, ToolScriptSpec
from utils.errors import is_debug_enabled, log_safe_exception
logger = logging.getLogger("gitnexus_docker")
DEFAULT_CACHE_DIR = Path.home() / ".gitnexus-eval-cache"
EVAL_SERVER_PORT = 4848
class GitNexusDockerEnvironment(DockerEnvironment):
"""
Docker environment with GitNexus pre-installed, indexed, and eval-server running.
Setup flow:
1. Start Docker container (base SWE-bench image)
2. Install Node.js + gitnexus inside the container
3. Run `gitnexus analyze` (or restore from cache)
4. Start `gitnexus eval-server` daemon (keeps KuzuDB warm)
5. Install standalone tool scripts in /usr/local/bin/
6. Agent runs with near-instant GitNexus tool calls
"""
def __init__(
self,
*,
enable_gitnexus: bool = True,
cache_dir: str | Path | None = None,
skip_embeddings: bool = True,
gitnexus_timeout: int = 120,
eval_server_port: int = EVAL_SERVER_PORT,
**kwargs,
):
super().__init__(**kwargs)
self.enable_gitnexus = enable_gitnexus
self.cache_dir = Path(cache_dir) if cache_dir else DEFAULT_CACHE_DIR
self.skip_embeddings = skip_embeddings
self.gitnexus_timeout = gitnexus_timeout
self.eval_server_port = eval_server_port
self.index_time: float = 0.0
self._gitnexus_ready = False
def start(self) -> dict:
"""Start the container and set up GitNexus."""
result = super().start()
if self.enable_gitnexus:
try:
self._setup_gitnexus()
except Exception as e:
log_safe_exception(
logger,
"GitNexus setup failed, continuing without it",
e,
include_debug=is_debug_enabled(),
level="warning",
)
self._gitnexus_ready = False
return result
def _setup_gitnexus(self):
"""Install and configure GitNexus in the container."""
start = time.time()
self._ensure_nodejs()
self._install_gitnexus()
self._index_repository()
self._start_eval_server()
self._install_tools()
self.index_time = time.time() - start
self._gitnexus_ready = True
logger.info(f"GitNexus setup completed in {self.index_time:.1f}s")
def _ensure_nodejs(self):
"""Ensure Node.js >= 18 is available in the container."""
check = self.execute({"command": "node --version 2>/dev/null || echo 'NOT_FOUND'"})
output = check.get("output", "").strip()
if "NOT_FOUND" in output:
logger.info("Installing Node.js in container...")
install_cmds = [
"apt-get update -qq",
"apt-get install -y -qq curl ca-certificates",
"curl -fsSL https://deb.nodesource.com/setup_20.x | bash -",
"apt-get install -y -qq nodejs",
]
for cmd in install_cmds:
result = self.execute({"command": cmd, "timeout": 60})
if result.get("returncode", 1) != 0:
raise RuntimeError(f"Failed to install Node.js: {result.get('output', '')}")
else:
logger.info(f"Node.js already available: {output}")
def _install_gitnexus(self):
"""Install the gitnexus npm package globally."""
check = self.execute({"command": "npx gitnexus --version 2>/dev/null || echo 'NOT_FOUND'"})
if "NOT_FOUND" in check.get("output", ""):
logger.info("Installing gitnexus...")
result = self.execute({
"command": "npm install -g gitnexus",
"timeout": 60,
})
if result.get("returncode", 1) != 0:
raise RuntimeError(f"Failed to install gitnexus: {result.get('output', '')}")
def _index_repository(self):
"""Run gitnexus analyze on the repo, using cache if available."""
repo_info = self._get_repo_info()
cache_key = self._make_cache_key(repo_info)
cache_path = self.cache_dir / cache_key
if cache_path.exists():
logger.info(f"Restoring GitNexus index from cache: {cache_key}")
self._restore_cache(cache_path)
return
logger.info("Running gitnexus analyze...")
skip_flag = "--skip-embeddings" if self.skip_embeddings else ""
result = self.execute({
"command": f"cd /testbed && npx gitnexus analyze . {skip_flag} 2>&1",
"timeout": self.gitnexus_timeout,
})
if result.get("returncode", 1) != 0:
output = result.get("output", "")
if "error" in output.lower() and "indexed" not in output.lower():
raise RuntimeError(f"gitnexus analyze failed: {output[-500:]}")
self._save_cache(cache_path, repo_info)
def _start_eval_server(self):
"""Start the GitNexus eval-server daemon in the background."""
logger.info(f"Starting eval-server on port {self.eval_server_port}...")
self.execute({
"command": (
f"nohup npx gitnexus eval-server --port {self.eval_server_port} "
f"--idle-timeout 600 "
f"> /tmp/gitnexus-eval-server.log 2>&1 &"
),
"timeout": 5,
})
# Wait for the server to be ready (up to ~15s for KuzuDB init)
for i in range(EVAL_SERVER_HEALTH_RETRIES):
time.sleep(EVAL_SERVER_HEALTH_INTERVAL_SECONDS)
health = self.execute({
"command": f"curl -sf http://127.0.0.1:{self.eval_server_port}/health 2>/dev/null || echo 'NOT_READY'",
"timeout": EVAL_SERVER_HEALTH_TIMEOUT_SECONDS,
})
output = health.get("output", "").strip()
if "NOT_READY" not in output and "ok" in output:
logger.info(
f"Eval-server ready after {(i + 1) * EVAL_SERVER_HEALTH_INTERVAL_SECONDS:.1f}s"
)
return
log_output = self.execute({
"command": "cat /tmp/gitnexus-eval-server.log 2>/dev/null | tail -20",
})
logger.warning(
f"Eval-server didn't become ready in "
f"{EVAL_SERVER_HEALTH_RETRIES * EVAL_SERVER_HEALTH_INTERVAL_SECONDS:.1f}s. "
f"Tools will fall back to direct CLI.\n"
f"Server log: {log_output.get('output', 'N/A')}"
)
@staticmethod
def _render_tool_script(spec: ToolScriptSpec, port: str) -> str:
"""
Render a standalone bash script for a GitNexus tool.
Scripts call the eval-server fast path when an endpoint is present,
and fall back to the CLI otherwise.
"""
lines = ["#!/bin/bash"]
if spec.endpoint:
lines.append(f'PORT="${{GITNEXUS_EVAL_PORT:-{port}}}"')
if spec.header:
lines.append(spec.header.strip())
if spec.payload_builder:
lines.append(spec.payload_builder.strip())
if spec.endpoint:
lines.append(
f'result=$(curl -sf -X POST "http://127.0.0.1:${{PORT}}{spec.endpoint}" '
'-H "Content-Type: application/json" -d "$payload" 2>/dev/null)'
)
lines.append('if [ $? -eq 0 ] && [ -n "$result" ]; then echo "$result"; exit 0; fi')
lines.append(spec.fallback.strip())
return "\n".join(lines)
def _install_tools(self):
"""
Install standalone GitNexus tool scripts in /usr/local/bin/.
Each script is a self-contained bash script that:
1. Calls the eval-server via curl (fast path, ~100ms)
2. Falls back to direct CLI if eval-server is unavailable
These are standalone executables — no sourcing, env inheritance, or .bashrc
needed. This is critical because mini-swe-agent runs every command via
subprocess.run in a fresh subshell.
Uses heredocs with quoted delimiter to avoid all quoting/escaping issues.
"""
port = str(self.eval_server_port)
for spec in TOOL_SPECS.values():
script_content = self._render_tool_script(spec, port).strip()
# Use heredoc with quoted delimiter — prevents all variable expansion and quoting issues
self.execute({
"command": (
f"cat << 'GITNEXUS_SCRIPT_EOF' > /usr/local/bin/{spec.bin_name}\n"
f"{script_content}\n"
"GITNEXUS_SCRIPT_EOF\n"
f"chmod +x /usr/local/bin/{spec.bin_name}"
),
"timeout": 5,
})
logger.info(f"Installed {len(TOOL_SPECS)} GitNexus tool scripts in /usr/local/bin/")
def _get_repo_info(self) -> dict:
"""Get repository identity info from the container."""
repo_result = self.execute({
"command": "cd /testbed && basename $(git remote get-url origin 2>/dev/null || basename $(pwd)) .git"
})
commit_result = self.execute({"command": "cd /testbed && git rev-parse HEAD 2>/dev/null || echo unknown"})
return {
"repo": repo_result.get("output", "unknown").strip(),
"commit": commit_result.get("output", "unknown").strip(),
}
@staticmethod
def _make_cache_key(repo_info: dict) -> str:
"""Create a deterministic cache key from repo info."""
content = f"{repo_info['repo']}:{repo_info['commit']}"
return hashlib.sha256(content.encode()).hexdigest()[:16]
def _save_cache(self, cache_path: Path, repo_info: dict):
"""Save the GitNexus index to the host cache directory."""
try:
cache_path.mkdir(parents=True, exist_ok=True)
find_result = self.execute({
"command": "find /root/.gitnexus -name 'kuzu' -type d 2>/dev/null | head -1"
})
gitnexus_dir = find_result.get("output", "").strip()
if gitnexus_dir:
parent = str(Path(gitnexus_dir).parent)
self.execute({
"command": f"cd {parent} && tar czf /tmp/gitnexus-cache.tar.gz .",
"timeout": 30,
})
container_id = getattr(self, "_container_id", None) or getattr(self, "container_id", None)
if container_id:
import subprocess as sp
sp.run(
["docker", "cp", f"{container_id}:/tmp/gitnexus-cache.tar.gz",
str(cache_path / "index.tar.gz")],
check=True, capture_output=True,
)
(cache_path / "metadata.json").write_text(json.dumps(repo_info, indent=2))
logger.info(f"Cached GitNexus index: {cache_path}")
except Exception as e:
log_safe_exception(
logger,
"Failed to cache GitNexus index",
e,
include_debug=is_debug_enabled(),
level="warning",
)
if cache_path.exists():
shutil.rmtree(cache_path, ignore_errors=True)
def _restore_cache(self, cache_path: Path):
"""Restore a cached GitNexus index into the container."""
try:
cache_tarball = cache_path / "index.tar.gz"
if not cache_tarball.exists():
logger.warning("Cache tarball not found, re-indexing")
self._index_repository()
return
container_id = getattr(self, "_container_id", None) or getattr(self, "container_id", None)
if container_id:
import subprocess as sp
self.execute({"command": "mkdir -p /root/.gitnexus"})
storage_result = self.execute({
"command": "npx gitnexus list 2>/dev/null | grep -o '/root/.gitnexus/[^ ]*' | head -1 || echo '/root/.gitnexus/repos/default'"
})
storage_path = storage_result.get("output", "").strip() or "/root/.gitnexus/repos/default"
self.execute({"command": f"mkdir -p {storage_path}"})
sp.run(
["docker", "cp", str(cache_tarball), f"{container_id}:/tmp/gitnexus-cache.tar.gz"],
check=True, capture_output=True,
)
self.execute({
"command": f"cd {storage_path} && tar xzf /tmp/gitnexus-cache.tar.gz",
"timeout": 30,
})
logger.info("GitNexus index restored from cache")
except Exception as e:
log_safe_exception(
logger,
"Failed to restore cache, re-indexing",
e,
include_debug=is_debug_enabled(),
level="warning",
)
self._index_repository()
def stop(self) -> dict:
"""Stop the container, shutting down eval-server first."""
if self._gitnexus_ready:
try:
self.execute({
"command": f"curl -sf -X POST http://127.0.0.1:{self.eval_server_port}/shutdown 2>/dev/null || true",
"timeout": 3,
})
except Exception:
pass
return super().stop()
def get_template_vars(self) -> dict:
"""Add GitNexus-specific template variables."""
base_vars = super().get_template_vars()
base_vars["gitnexus_ready"] = self._gitnexus_ready
base_vars["gitnexus_index_time"] = self.index_time
return base_vars
def serialize(self) -> dict:
"""Include GitNexus environment info in serialization."""
base = super().serialize()
base.setdefault("info", {})["gitnexus_env"] = {
"enabled": self.enable_gitnexus,
"ready": self._gitnexus_ready,
"index_time_seconds": round(self.index_time, 2),
"skip_embeddings": self.skip_embeddings,
"eval_server_port": self.eval_server_port,
}
return base